Compare commits
No commits in common. "30d0ff5695b3f3c3a0621dfd00e9d73ecb3dda3b" and "b76acbe22737af92a5ced3368af783f93261f8a5" have entirely different histories.
30d0ff5695
...
b76acbe227
@ -3,111 +3,7 @@
|
|||||||
## 目的:全新專案(從 edge-ai-platform 衍生的 local 版本)
|
## 目的:全新專案(從 edge-ai-platform 衍生的 local 版本)
|
||||||
## 當前階段:🔴 **第一階段回溯** — L 級重大方向變更(Wails 內嵌 → Wails 控制台 + 瀏覽器 Web UI)
|
## 當前階段:🔴 **第一階段回溯** — L 級重大方向變更(Wails 內嵌 → Wails 控制台 + 瀏覽器 Web UI)
|
||||||
## 當前狀態:✅ 使用者決策全部收齊(R5 第五輪決策),待三方產出正式 PRD v2 / Design Spec v2 / TDD v2
|
## 當前狀態:✅ 使用者決策全部收齊(R5 第五輪決策),待三方產出正式 PRD v2 / Design Spec v2 / TDD v2
|
||||||
## 最後更新:2026-04-21
|
## 最後更新:2026-04-14
|
||||||
|
|
||||||
## 2026-04-21 推論 bbox 標註不顯示 + KL520 Error 15(S 級 bug fix)
|
|
||||||
|
|
||||||
### 症狀
|
|
||||||
Mac 版 app 上傳單張圖推論,畫面上完全沒有 bbox 標註。
|
|
||||||
|
|
||||||
### 根因(兩層獨立問題,疊加讓「bbox 完全不見」)
|
|
||||||
|
|
||||||
**Layer 1(前端 canvas 尺寸)**:
|
|
||||||
- `camera-inference-view.tsx` `renderedSize` 初始值硬寫 `{w:640, h:480}`
|
|
||||||
- ResizeObserver 理應在 `<img>` load 後 fire 更新成實際顯示尺寸(例如 516×640 直式圖 → CSS 640×794),但實測沒 fire 或 fire 時機不對
|
|
||||||
- 結果 overlay canvas 永遠用 640×480 畫,和 img 實際 DOM box 對不上 → 就算有 detection,bbox 位置會嚴重偏位甚至跑出 canvas
|
|
||||||
|
|
||||||
**Layer 2(後端推論 Error 15)**:
|
|
||||||
- `kp.inference.generic_image_inference_send` 回 `ApiKPException Error 15 SEND_DATA_TOO_LARGE`
|
|
||||||
- 試過:image 尺寸(516×640 / 640×794 / 640×640 pad)、傳 numpy vs bytes、明確傳 width/height — **全部都炸**
|
|
||||||
- Python bridge 直接測試(`/tmp/test_bridge.py`)做完整 `connect → reset → reconnect → load_model → inference` → **11 個 detection 正常回傳**
|
|
||||||
- 對比 Go driver 實際路徑:`connect → load_model → inference` **跳過了 reset**
|
|
||||||
|
|
||||||
### 兇手:commit `ddf0eb8`(2026-04-16)
|
|
||||||
`KL520 首次 connect 跳過不必要的 device reset` — 當時為解 Windows 60s HTTP timeout(Loader mode connect 不穩定 + firmware load 總耗 64s)而加的優化,讓 KL520 首次 connect 不再 restartBridge。
|
|
||||||
|
|
||||||
副作用:KL520 雖然是 USB Boot / RAM-based 裝置,理論上每次 connect 是 clean state,但實測若 session 間 firmware 殘留(`fw=KDP2 Comp/U`),**直接 load_model + inference 100% 炸 Error 15**。只有走完整 `reset → 退回 Loader → 重新載 firmware 到 Comp/U` 流程,才能拿到能正常 inference 的 session。
|
|
||||||
|
|
||||||
### 修法
|
|
||||||
|
|
||||||
**前端(`camera-feed.tsx` + `camera-inference-view.tsx`)**:
|
|
||||||
- `<img>` 加 `onLoad` handler,圖片 decode 完立刻用 `getBoundingClientRect` 回報尺寸(最可靠時機)
|
|
||||||
- ResizeObserver effect 進來先檢查 `img.complete && naturalWidth > 0`,是就立刻 report(cover HMR / cached image)
|
|
||||||
- effect 依賴加 `streamUrl / batchImageUrl`,換圖會重觀察
|
|
||||||
- `renderedSize` 初始值改 `null`,overlay 改為 `isStreaming && renderedSize` 才 render(避免首次用預設值畫錯)
|
|
||||||
- setState callback 用 prev 比對,同尺寸不觸發 render
|
|
||||||
|
|
||||||
**後端(`server/internal/driver/kneron/kl720_driver.go`)**:
|
|
||||||
- 移除 `ddf0eb8` 的「KL520 跳過 reset」特例,讓 KL520 和 KL720 都走 `needsReset=true → restartBridge()`
|
|
||||||
- 註解記錄 trade-off:KL520 connect 時間從 ~2s 變 ~15-20s(macOS),Windows 可能 60s+
|
|
||||||
- 同步調整 `server/internal/api/handlers/device_handler.go` connect timeout:`60s → 120s`,為 Windows worst-case(~65s)留 buffer
|
|
||||||
|
|
||||||
**Python bridge(`server/scripts/kneron_bridge.py`)**:
|
|
||||||
- 無實質改動(試過 host-side letterbox、numpy→bytes、明確傳 w/h 全部無效 → 還原回原版,確認問題在 Go driver 的 reset 流程)
|
|
||||||
- 只加了 debug log(`Inference: sending...` / `Inference: parse done, detections=N` / `Inference EXCEPTION with traceback`),追 bug 時用,commit 前會保留(低成本、高價值)
|
|
||||||
|
|
||||||
### 驗證(function 層)
|
|
||||||
|
|
||||||
`/tmp/test_bridge.py` 直接測試 bridge JSON-RPC:
|
|
||||||
|
|
||||||
```
|
|
||||||
[5/5] inference (real 516x640) keys: ['taskType', 'timestamp', 'latencyMs', 'detections', 'classifications']
|
|
||||||
✅ inference OK — detections=11 classifications=0 latency=308.3ms
|
|
||||||
- person 0.705 bbox=(x=0.427, y=0.526, w=0.089, h=0.070)
|
|
||||||
- person 0.701 bbox=(x=0.360, y=0.438, w=0.227, h=0.246)
|
|
||||||
- tie 0.639 bbox=(x=0.351, y=0.573, w=0.011, h=0.107)
|
|
||||||
...
|
|
||||||
✅ 1920x1080 OK — detections=0
|
|
||||||
✅ 512x512 OK — detections=0
|
|
||||||
=== ALL TESTS PASSED ===
|
|
||||||
```
|
|
||||||
|
|
||||||
三種尺寸(516×640 直式 / 1920×1080 横式 / 512×512 正方)全通過。
|
|
||||||
|
|
||||||
### 待使用者驗證
|
|
||||||
- [ ] Mac UI 端實測:上傳 `~/Downloads/000000000459.jpg` 應見 11 個 bbox 精準框住 person + tie
|
|
||||||
- [ ] Windows 實測首次 connect 耗時 + 是否還踩 HTTP timeout(現已放寬到 120s)
|
|
||||||
- [ ] Linux 實測
|
|
||||||
|
|
||||||
### 前端 debug log 去留
|
|
||||||
`camera-overlay.tsx` 的 `console.log('[bbox-debug] ...')` 驗證完成後**可清可留**。保留成本低,對未來 debug 有幫助。
|
|
||||||
|
|
||||||
## 2026-04-20 macOS 掃不到 Kneron 裝置(S 級 bug fix)
|
|
||||||
|
|
||||||
症狀:Mac 版 app 啟動後,前端顯示沒有裝置(實際 KL520 透過 USB 連上)。
|
|
||||||
|
|
||||||
根因(兩層):
|
|
||||||
1. **主要**:`PythonModeAuto` 預設「先 system 後 bundled」,系統 python3 通常沒裝 KneronPLUS wheel → `import kp` 失敗 → bridge 降級 pyusb → pyusb 找不到 libusb → scan 空。
|
|
||||||
2. **次要(潛在)**:macOS hardened runtime 會剝掉 `DYLD_LIBRARY_PATH`;若未來 bundle 架構變動 dyld 找不到 libkplus 的相依 libusb,會再踩坑。
|
|
||||||
|
|
||||||
修法:
|
|
||||||
- `visiona-local/app.go` `PythonModeAuto` 語意翻轉 → **先 bundled(已預裝 kp wheel),失敗才 fallback system**。理由:local-tool 整包內嵌 Python + wheels,系統 python 不會裝 kp,不該優先。
|
|
||||||
- `server/scripts/kneron_bridge.py` 在 `import kp` 前新增 `_preload_kneron_dylibs_macos()` — 用 `ctypes.CDLL` 絕對路徑預載 wheel 內 `kp/lib/libusb-1.0.0.dylib` + `libkplus.dylib`,避開 DYLD 被 hardened runtime 砍的風險。Windows/Linux 分支不動。
|
|
||||||
- 同步 bridge 到 payload/{darwin,linux,windows}/scripts/ + build bundle。
|
|
||||||
|
|
||||||
驗證:
|
|
||||||
- `go build` 兩個 module 都通過
|
|
||||||
- bridge script 直跑:`{"cmd":"scan"}` → 回傳 KL520 裝置 `kn_number 0xB906162C`
|
|
||||||
- 待 rebuild wails app 後實測(需要 `make wails-macos`)
|
|
||||||
|
|
||||||
## 2026-04-20 macOS DMG 美化(S 級)
|
|
||||||
|
|
||||||
需求:Mac 端也要有 installer(類比 Windows .exe)。走方案 C(create-dmg 美化 DMG + 背景圖 + Applications 捷徑)。
|
|
||||||
|
|
||||||
實作:
|
|
||||||
- 新增 `installer/macos/{make-dmg-background.py, background.png, background@2x.png, README.md}`
|
|
||||||
- 動態生成 640×400 深色背景(對齊 Wails 控制台 splash 配色 `#111827→#0B0F19` + `#38BDF8` accent)
|
|
||||||
- 含 1x + 2x Retina 版本
|
|
||||||
- Makefile `dmg` 拆成三個 target:
|
|
||||||
- `dmg`:auto-detect,有 create-dmg 走 fancy,沒有 fallback plain(CI 無痛)
|
|
||||||
- `dmg-fancy`:強制美化版(需 `brew install create-dmg`)
|
|
||||||
- `dmg-plain`:原本的 hdiutil UDZO(保留為 fallback)
|
|
||||||
- Windows / Linux 流程零改動
|
|
||||||
|
|
||||||
驗證:
|
|
||||||
- `brew install create-dmg` 成功
|
|
||||||
- `make dmg-fancy` 產出 157MB DMG,mount 後內容:app + Applications 捷徑 + .background/background.png + .DS_Store(視窗樣式)
|
|
||||||
- `hdiutil verify` 通過
|
|
||||||
|
|
||||||
## 🔴 2026-04-14 使用者提出 L 級重大方向變更
|
## 🔴 2026-04-14 使用者提出 L 級重大方向變更
|
||||||
|
|
||||||
|
|||||||
@ -520,48 +520,15 @@ wails-linux: payload-linux ## ⚠️ 必須在 Linux runner 上執行:wails bu
|
|||||||
@du -sh visiona-local/build/bin/visiona-local
|
@du -sh visiona-local/build/bin/visiona-local
|
||||||
|
|
||||||
# ── 安裝檔打包 ─────────────────────────────────────────────────────
|
# ── 安裝檔打包 ─────────────────────────────────────────────────────
|
||||||
dmg: wails-macos ## 美化 DMG(create-dmg 有裝)或 plain DMG(fallback)→ dist/visiona-local.dmg
|
dmg: wails-macos ## hdiutil UDZO → dist/visiona-local.dmg
|
||||||
@mkdir -p $(DIST)
|
mkdir -p $(DIST)
|
||||||
@rm -f $(DIST)/visiona-local.dmg
|
|
||||||
@if command -v create-dmg > /dev/null 2>&1; then \
|
|
||||||
$(MAKE) --no-print-directory dmg-fancy; \
|
|
||||||
else \
|
|
||||||
echo "⚠️ create-dmg 未安裝,使用 plain DMG(hdiutil UDZO)"; \
|
|
||||||
echo " 想要美化版本請執行:brew install create-dmg"; \
|
|
||||||
$(MAKE) --no-print-directory dmg-plain; \
|
|
||||||
fi
|
|
||||||
@du -sh $(DIST)/visiona-local.dmg
|
|
||||||
@file $(DIST)/visiona-local.dmg
|
|
||||||
|
|
||||||
dmg-plain: ## hdiutil UDZO → dist/visiona-local.dmg(無背景圖,CI / fallback 用)
|
|
||||||
@mkdir -p $(DIST)
|
|
||||||
rm -f $(DIST)/visiona-local.dmg
|
rm -f $(DIST)/visiona-local.dmg
|
||||||
hdiutil create -volname "visionA-local" \
|
hdiutil create -volname "visionA-local" \
|
||||||
-srcfolder visiona-local/build/bin/visiona-local.app \
|
-srcfolder visiona-local/build/bin/visiona-local.app \
|
||||||
-ov -format UDZO \
|
-ov -format UDZO \
|
||||||
$(DIST)/visiona-local.dmg
|
$(DIST)/visiona-local.dmg
|
||||||
|
@du -sh $(DIST)/visiona-local.dmg
|
||||||
dmg-fancy: ## create-dmg 美化版 → dist/visiona-local.dmg(需 brew install create-dmg)
|
@file $(DIST)/visiona-local.dmg
|
||||||
@if [ ! -d visiona-local/build/bin/visiona-local.app ]; then \
|
|
||||||
echo "❌ visiona-local/build/bin/visiona-local.app 不存在,請先跑 make wails-macos"; exit 1; \
|
|
||||||
fi
|
|
||||||
@if ! command -v create-dmg > /dev/null 2>&1; then \
|
|
||||||
echo "❌ create-dmg 未安裝,請執行:brew install create-dmg"; exit 1; \
|
|
||||||
fi
|
|
||||||
@mkdir -p $(DIST)
|
|
||||||
rm -f $(DIST)/visiona-local.dmg
|
|
||||||
create-dmg \
|
|
||||||
--volname "visionA-local" \
|
|
||||||
--background installer/macos/background.png \
|
|
||||||
--window-pos 200 120 \
|
|
||||||
--window-size 640 400 \
|
|
||||||
--icon-size 128 \
|
|
||||||
--icon "visiona-local.app" 180 200 \
|
|
||||||
--app-drop-link 460 200 \
|
|
||||||
--hide-extension "visiona-local.app" \
|
|
||||||
--no-internet-enable \
|
|
||||||
$(DIST)/visiona-local.dmg \
|
|
||||||
visiona-local/build/bin/visiona-local.app
|
|
||||||
|
|
||||||
exe-only: ## 只跑 iscc 打包 installer(前置產物必須已存在),不重 build wails/payload
|
exe-only: ## 只跑 iscc 打包 installer(前置產物必須已存在),不重 build wails/payload
|
||||||
@if [ ! -f visiona-local/build/bin/visiona-local.exe ]; then \
|
@if [ ! -f visiona-local/build/bin/visiona-local.exe ]; then \
|
||||||
|
|||||||
@ -23,30 +23,17 @@ export function CameraFeed({ streamUrl, width = 640, height = 480, sourceType, b
|
|||||||
const img = imgRef.current;
|
const img = imgRef.current;
|
||||||
if (!img || !onDimensionsChange) return;
|
if (!img || !onDimensionsChange) return;
|
||||||
|
|
||||||
const report = () => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
const rect = img.getBoundingClientRect();
|
for (const entry of entries) {
|
||||||
const w = Math.round(rect.width);
|
const { width: w, height: h } = entry.contentRect;
|
||||||
const h = Math.round(rect.height);
|
if (w > 0 && h > 0) {
|
||||||
if (w > 0 && h > 0) onDimensionsChange(w, h);
|
onDimensionsChange(Math.round(w), Math.round(h));
|
||||||
};
|
}
|
||||||
|
}
|
||||||
// Initial read — covers the case where the image is already cached/decoded
|
});
|
||||||
// before the observer attaches (common for fast image swaps / HMR).
|
|
||||||
if (img.complete && img.naturalWidth > 0) report();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(report);
|
|
||||||
observer.observe(img);
|
observer.observe(img);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onDimensionsChange, streamUrl, batchImageUrl]);
|
}, [onDimensionsChange]);
|
||||||
|
|
||||||
const handleLoad = () => {
|
|
||||||
const img = imgRef.current;
|
|
||||||
if (!img || !onDimensionsChange) return;
|
|
||||||
const rect = img.getBoundingClientRect();
|
|
||||||
const w = Math.round(rect.width);
|
|
||||||
const h = Math.round(rect.height);
|
|
||||||
if (w > 0 && h > 0) onDimensionsChange(w, h);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
return (
|
return (
|
||||||
@ -77,7 +64,6 @@ export function CameraFeed({ streamUrl, width = 640, height = 480, sourceType, b
|
|||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
src={displayUrl}
|
src={displayUrl}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
onLoad={handleLoad}
|
|
||||||
style={{ width, height: 'auto' }}
|
style={{ width, height: 'auto' }}
|
||||||
className="block"
|
className="block"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -18,11 +18,11 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
|||||||
const { result, batchResults, confidenceThreshold } = useInferenceStore();
|
const { result, batchResults, confidenceThreshold } = useInferenceStore();
|
||||||
|
|
||||||
const displayWidth = 640;
|
const displayWidth = 640;
|
||||||
const [renderedSize, setRenderedSize] = useState<{ w: number; h: number } | null>(null);
|
const [renderedSize, setRenderedSize] = useState({ w: 640, h: 480 });
|
||||||
const isBatchMode = sourceType === 'batch_image';
|
const isBatchMode = sourceType === 'batch_image';
|
||||||
|
|
||||||
const handleDimensionsChange = useCallback((w: number, h: number) => {
|
const handleDimensionsChange = useCallback((w: number, h: number) => {
|
||||||
setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { w, h }));
|
setRenderedSize({ w, h });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// In batch mode, show the selected image's detections
|
// In batch mode, show the selected image's detections
|
||||||
@ -47,7 +47,7 @@ export function CameraInferenceView({ deviceId }: CameraInferenceViewProps) {
|
|||||||
batchImageUrl={batchImageUrl}
|
batchImageUrl={batchImageUrl}
|
||||||
onDimensionsChange={handleDimensionsChange}
|
onDimensionsChange={handleDimensionsChange}
|
||||||
overlay={
|
overlay={
|
||||||
isStreaming && renderedSize ? (
|
isStreaming ? (
|
||||||
<CameraOverlay
|
<CameraOverlay
|
||||||
detections={detections}
|
detections={detections}
|
||||||
width={renderedSize.w}
|
width={renderedSize.w}
|
||||||
|
|||||||
@ -25,16 +25,6 @@ export function CameraOverlay({ detections, width, height, confidenceThreshold }
|
|||||||
|
|
||||||
const filtered = detections.filter((d) => d.confidence >= confidenceThreshold);
|
const filtered = detections.filter((d) => d.confidence >= confidenceThreshold);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
// TEMP debug: 驗證 bbox coordinate space 對齊問題
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log('[bbox-debug] canvas=%dx%d total=%d filtered=%d threshold=%s', width, height, detections.length, filtered.length, confidenceThreshold, filtered.map((d) => ({
|
|
||||||
label: d.label,
|
|
||||||
bbox: d.bbox,
|
|
||||||
conf: d.confidence,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.forEach((det, i) => {
|
filtered.forEach((det, i) => {
|
||||||
const color = COLORS[i % COLORS.length];
|
const color = COLORS[i % COLORS.length];
|
||||||
// Convert normalized coordinates (0-1) to pixel values
|
// Convert normalized coordinates (0-1) to pixel values
|
||||||
|
|||||||
@ -1,42 +0,0 @@
|
|||||||
# macOS DMG 美化資源
|
|
||||||
|
|
||||||
## 檔案
|
|
||||||
|
|
||||||
| 檔案 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `make-dmg-background.py` | 生成背景圖的 Python script(需 Pillow) |
|
|
||||||
| `background.png` | 640×400 DMG 背景圖(1x) |
|
|
||||||
| `background@2x.png` | 1280×800 DMG 背景圖(Retina,create-dmg 自動挑用) |
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 一次性安裝
|
|
||||||
brew install create-dmg
|
|
||||||
|
|
||||||
# Build 美化 DMG
|
|
||||||
make dmg
|
|
||||||
```
|
|
||||||
|
|
||||||
`make dmg` 會自動偵測 `create-dmg`:
|
|
||||||
- 有裝 → 產出美化版(深色背景 + 拖曳示意)
|
|
||||||
- 沒裝 → fallback 到 `make dmg-plain`(原本的 hdiutil UDZO,CI 友善)
|
|
||||||
|
|
||||||
可直接指定 target:
|
|
||||||
- `make dmg-fancy` — 強制美化版(`create-dmg` 未裝會報錯)
|
|
||||||
- `make dmg-plain` — 強制 plain 版
|
|
||||||
|
|
||||||
## 重新生成背景圖
|
|
||||||
|
|
||||||
改配色或文案時:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 installer/macos/make-dmg-background.py
|
|
||||||
```
|
|
||||||
|
|
||||||
會同時輸出 `background.png` 與 `background@2x.png`。
|
|
||||||
|
|
||||||
## 設計對齊
|
|
||||||
|
|
||||||
背景圖配色對齊 Wails 控制台深色 splash(`#111827` → `#0B0F19` 漸層 + `#38BDF8` accent)。
|
|
||||||
左側 app icon 位於 (180, 200),右側 Applications 捷徑位於 (460, 200),箭頭在中間。
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@ -1,85 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
生成 DMG 美化背景圖(640×400),對齊 Wails 控制台深色 splash 風格。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python3 installer/macos/make-dmg-background.py
|
|
||||||
|
|
||||||
輸出:
|
|
||||||
installer/macos/background.png (1x, 640×400)
|
|
||||||
installer/macos/background@2x.png (2x, 1280×800 Retina)
|
|
||||||
|
|
||||||
create-dmg 會自動挑 @2x 版本用於 Retina 螢幕。
|
|
||||||
"""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
OUT_DIR = Path(__file__).resolve().parent
|
|
||||||
W, H = 640, 400
|
|
||||||
|
|
||||||
BG_TOP = (17, 24, 39)
|
|
||||||
BG_BOTTOM = (11, 15, 25)
|
|
||||||
TEXT = (229, 231, 235)
|
|
||||||
MUTED = (148, 163, 184)
|
|
||||||
ACCENT = (56, 189, 248)
|
|
||||||
|
|
||||||
|
|
||||||
def make(scale: int, out_path: Path) -> None:
|
|
||||||
w, h = W * scale, H * scale
|
|
||||||
img = Image.new("RGB", (w, h), BG_TOP)
|
|
||||||
px = img.load()
|
|
||||||
for y in range(h):
|
|
||||||
t = y / (h - 1)
|
|
||||||
r = int(BG_TOP[0] + (BG_BOTTOM[0] - BG_TOP[0]) * t)
|
|
||||||
g = int(BG_TOP[1] + (BG_BOTTOM[1] - BG_TOP[1]) * t)
|
|
||||||
b = int(BG_TOP[2] + (BG_BOTTOM[2] - BG_TOP[2]) * t)
|
|
||||||
for x in range(w):
|
|
||||||
px[x, y] = (r, g, b)
|
|
||||||
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
try:
|
|
||||||
font_title = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 22 * scale)
|
|
||||||
font_hint = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14 * scale)
|
|
||||||
except OSError:
|
|
||||||
font_title = ImageFont.load_default()
|
|
||||||
font_hint = ImageFont.load_default()
|
|
||||||
|
|
||||||
title = "Drag visionA-local to Applications"
|
|
||||||
tw = draw.textlength(title, font=font_title)
|
|
||||||
draw.text(((w - tw) / 2, 28 * scale), title, fill=TEXT, font=font_title)
|
|
||||||
|
|
||||||
hint = "拖曳圖示到右邊的 Applications 即可安裝"
|
|
||||||
hw = draw.textlength(hint, font=font_hint)
|
|
||||||
draw.text(((w - hw) / 2, 60 * scale), hint, fill=MUTED, font=font_hint)
|
|
||||||
|
|
||||||
# 箭頭位置:左右兩個 icon 約在 y=230,中心。icon 本身 128×128,由 create-dmg 擺。
|
|
||||||
# create-dmg 預設 app icon x=180, Applications x=460(y=200)。箭頭畫在中間 240-400 區段。
|
|
||||||
arrow_y = 200 * scale
|
|
||||||
arrow_x1 = 260 * scale
|
|
||||||
arrow_x2 = 380 * scale
|
|
||||||
line_w = 3 * scale
|
|
||||||
draw.line([(arrow_x1, arrow_y), (arrow_x2, arrow_y)], fill=ACCENT, width=line_w)
|
|
||||||
# 箭頭頭
|
|
||||||
head = 12 * scale
|
|
||||||
draw.polygon(
|
|
||||||
[
|
|
||||||
(arrow_x2, arrow_y),
|
|
||||||
(arrow_x2 - head, arrow_y - head // 2 - 2 * scale),
|
|
||||||
(arrow_x2 - head, arrow_y + head // 2 + 2 * scale),
|
|
||||||
],
|
|
||||||
fill=ACCENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
img.save(out_path, "PNG", optimize=True)
|
|
||||||
print(f"wrote {out_path} ({out_path.stat().st_size // 1024} KB)")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
make(1, OUT_DIR / "background.png")
|
|
||||||
make(2, OUT_DIR / "background@2x.png")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -82,12 +82,9 @@ func (h *DeviceHandler) GetDevice(c *gin.Context) {
|
|||||||
func (h *DeviceHandler) ConnectDevice(c *gin.Context) {
|
func (h *DeviceHandler) ConnectDevice(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
// KL520 USB Boot flow now includes mandatory reset + firmware reload on
|
// KL520 USB Boot flow can take ~40s: retry connect (3x2s) + firmware
|
||||||
// first connect (required for inference to work — see kl720_driver.go
|
// load + 5s reboot wait + reconnect retry (3x3s). Use 60s timeout.
|
||||||
// needsReset block). Worst-case path on Windows: Loader-mode reconnect
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
|
||||||
// retry (16s) + firmware load (~31s) + reboot wait + second reconnect
|
|
||||||
// (~13s) = ~60-65s. Use 120s to leave headroom and avoid spurious 504s.
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
|
|||||||
@ -290,28 +290,26 @@ func (d *KneronDriver) Connect() error {
|
|||||||
|
|
||||||
// First connect after server start: reset device to clear stale models.
|
// First connect after server start: reset device to clear stale models.
|
||||||
//
|
//
|
||||||
// BOTH KL520 and KL720 需要 reset:
|
// KL520 跳過 reset:KL520 是 USB Boot 裝置,reset 會退回 Loader 模式
|
||||||
|
// (firmware 從 RAM 清掉),然後需要重新載 firmware(~30 秒)+ USB
|
||||||
|
// 重新枚舉(~8 秒)。這讓原本 2 秒的 connect 變成 60+ 秒,而且
|
||||||
|
// Loader 模式的 connect_devices_without_check 不穩定(常需重試)。
|
||||||
//
|
//
|
||||||
// - KL720 是 flash-based 裝置,firmware 和 model 會保留在 flash,reset
|
// KL520 每次 connect 本來就是 clean state(RAM-based firmware,斷電
|
||||||
|
// 即清),不需要主動 reset 清 stale model。如果真的有殘留 model,
|
||||||
|
// 下次 load_model 前的 restartBridge 會處理。
|
||||||
|
//
|
||||||
|
// KL720 是 flash-based 裝置,firmware 和 model 會保留在 flash,reset
|
||||||
// 清 stale model 才有意義。
|
// 清 stale model 才有意義。
|
||||||
//
|
if needsReset && d.chipType == "KL720" {
|
||||||
// - KL520 雖然是 USB Boot 裝置(RAM-based firmware,斷電即清),理論上
|
d.driverLog("INFO", "[kneron] first connect after server start — resetting KL720 to clear stale model...")
|
||||||
// 每次 connect 是 clean state。但實測發現若 session 間 firmware 殘留
|
|
||||||
// (fw=KDP2 Comp/U 而非 Loader),直接走 load_model + inference 會
|
|
||||||
// 100% 炸 ApiKPException Error 15 (SEND_DATA_TOO_LARGE)。只有走
|
|
||||||
// reset → reboot 到 Loader → 重新載 firmware 到 Comp/U 的完整流程,
|
|
||||||
// 才能得到能正常 inference 的 session。
|
|
||||||
//
|
|
||||||
// 成本:KL520 reset + firmware load + reconnect ~15-20s(macOS 實測)。
|
|
||||||
// Windows 上可能更久;若 HTTP connect timeout 60s 不夠,需調高或改
|
|
||||||
// 非同步 connect pattern。
|
|
||||||
if needsReset {
|
|
||||||
d.driverLog("INFO", "[kneron] first connect after server start — resetting %s to clear stale session...", d.chipType)
|
|
||||||
if err := d.restartBridge(); err != nil {
|
if err := d.restartBridge(); err != nil {
|
||||||
d.driverLog("WARN", "[kneron] reset on connect failed (non-fatal): %v", err)
|
d.driverLog("WARN", "[kneron] reset on connect failed (non-fatal): %v", err)
|
||||||
} else {
|
} else {
|
||||||
d.driverLog("INFO", "[kneron] device reset complete — clean state ready")
|
d.driverLog("INFO", "[kneron] device reset complete — clean state ready")
|
||||||
}
|
}
|
||||||
|
} else if needsReset {
|
||||||
|
d.driverLog("INFO", "[kneron] %s: skipping reset on first connect (USB Boot device, clean state by default)", d.chipType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -18,47 +18,6 @@ import io
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
def _preload_kneron_dylibs_macos():
|
|
||||||
"""macOS 專用:用絕對路徑預先 dlopen wheel 內的 libusb + libkplus。
|
|
||||||
|
|
||||||
背景:
|
|
||||||
- KneronPLUS wheel 把 libusb-1.0.0.dylib + libkplus.dylib 放在 kp/lib/。
|
|
||||||
- macOS dyld 在載入 libkplus 時會去找它的相依 libusb-1.0.0.dylib。
|
|
||||||
預設搜尋路徑(/usr/local/lib、/usr/lib)在 bundled Python 環境下通常
|
|
||||||
找不到(我們沒有 brew libusb),於是 `import kp` 就拋 OSError →
|
|
||||||
HAS_KP=False → scan 回空陣列。
|
|
||||||
- macOS hardened runtime 會剝掉 DYLD_LIBRARY_PATH 等環境變數,所以
|
|
||||||
改從 Go 端注入 env 也不保險;最穩的做法是在 Python 這端用 ctypes
|
|
||||||
以絕對路徑先載入,後續 `import kp` 時 dyld 會重用已載入的映像。
|
|
||||||
|
|
||||||
Windows / Linux 不走這支 — 各自機制已在 Go 端處理(Windows 靠 PATH、
|
|
||||||
Linux 靠 wheel 自帶的 libusb.so.1.0.0 + LD_LIBRARY_PATH)。
|
|
||||||
"""
|
|
||||||
if sys.platform != "darwin":
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
import ctypes
|
|
||||||
import importlib.util
|
|
||||||
spec = importlib.util.find_spec("kp")
|
|
||||||
if spec is None or not spec.submodule_search_locations:
|
|
||||||
return
|
|
||||||
kp_dir = spec.submodule_search_locations[0]
|
|
||||||
lib_dir = os.path.join(kp_dir, "lib")
|
|
||||||
# 載入順序:先 libusb,再 libkplus(libkplus 相依 libusb)
|
|
||||||
for name in ("libusb-1.0.0.dylib", "libkplus.dylib"):
|
|
||||||
path = os.path.join(lib_dir, name)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
try:
|
|
||||||
ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
_preload_kneron_dylibs_macos()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import kp
|
import kp
|
||||||
HAS_KP = True
|
HAS_KP = True
|
||||||
@ -1053,6 +1012,7 @@ def handle_inference(params):
|
|||||||
new_h = int(h * scale)
|
new_h = int(h * scale)
|
||||||
else:
|
else:
|
||||||
new_w, new_h = w, h
|
new_w, new_h = w, h
|
||||||
|
# Ensure even dimensions (NPU requirement)
|
||||||
new_w = (new_w + 1) & ~1
|
new_w = (new_w + 1) & ~1
|
||||||
new_h = (new_h + 1) & ~1
|
new_h = (new_h + 1) & ~1
|
||||||
img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
||||||
@ -1060,11 +1020,12 @@ def handle_inference(params):
|
|||||||
# Convert BGR to BGR565
|
# Convert BGR to BGR565
|
||||||
img_bgr565 = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2BGR565)
|
img_bgr565 = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2BGR565)
|
||||||
else:
|
else:
|
||||||
|
# Fallback: try to use raw bytes (assume RGB565 format)
|
||||||
img_bgr565 = np.frombuffer(img_bytes, dtype=np.uint8)
|
img_bgr565 = np.frombuffer(img_bytes, dtype=np.uint8)
|
||||||
else:
|
else:
|
||||||
return {"error": "no image data provided"}
|
return {"error": "no image data provided"}
|
||||||
|
|
||||||
# Create inference config (original: pass numpy ndarray, SDK reads shape)
|
# Create inference config
|
||||||
inf_config = kp.GenericImageInferenceDescriptor(
|
inf_config = kp.GenericImageInferenceDescriptor(
|
||||||
model_id=_model_id,
|
model_id=_model_id,
|
||||||
inference_number=0,
|
inference_number=0,
|
||||||
@ -1077,10 +1038,8 @@ def handle_inference(params):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send and receive
|
# Send and receive
|
||||||
_log(f"Inference: sending to NPU (model_type={_model_type}, input_size={_model_input_size})")
|
|
||||||
kp.inference.generic_image_inference_send(_device_group, inf_config)
|
kp.inference.generic_image_inference_send(_device_group, inf_config)
|
||||||
result = kp.inference.generic_image_inference_receive(_device_group)
|
result = kp.inference.generic_image_inference_receive(_device_group)
|
||||||
_log(f"Inference: receive complete, parsing...")
|
|
||||||
|
|
||||||
elapsed_ms = (time.time() - t0) * 1000
|
elapsed_ms = (time.time() - t0) * 1000
|
||||||
|
|
||||||
@ -1110,8 +1069,6 @@ def handle_inference(params):
|
|||||||
input_size=_model_input_size,
|
input_size=_model_input_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
_log(f"Inference: parse done, detections={len(detections)}, classifications={len(classifications)}, elapsed={elapsed_ms:.1f}ms")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"taskType": task_type,
|
"taskType": task_type,
|
||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
@ -1121,8 +1078,6 @@ def handle_inference(params):
|
|||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
|
||||||
_log(f"Inference EXCEPTION: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -62,9 +62,9 @@ const (
|
|||||||
type PythonMode string
|
type PythonMode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PythonModeAuto PythonMode = "auto" // 先試 bundled(kp wheel 已預裝),失敗才 fallback system
|
PythonModeAuto PythonMode = "auto" // 先試 system,失敗才走 bundled(R4 決策:M1 先 system)
|
||||||
PythonModeBundled PythonMode = "bundled" // 策略 A:內嵌 python-build-standalone
|
PythonModeBundled PythonMode = "bundled" // 策略 A:內嵌 python-build-standalone
|
||||||
PythonModeSystem PythonMode = "system" // 策略 B:系統 python3(需使用者自行 pip install)
|
PythonModeSystem PythonMode = "system" // 策略 B:系統 python3
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerStatus 回報給前端。
|
// ServerStatus 回報給前端。
|
||||||
@ -852,27 +852,23 @@ func (p *ServerProcess) stop() {
|
|||||||
//
|
//
|
||||||
// M1 實作狀況:
|
// M1 實作狀況:
|
||||||
// - PythonModeSystem:完整實作(findSystemPython)
|
// - PythonModeSystem:完整實作(findSystemPython)
|
||||||
// - PythonModeAuto:先試 bundled(含預裝 kp wheel),失敗才 fallback system
|
// - PythonModeAuto:先試 system,失敗才走 bundled
|
||||||
// - PythonModeBundled:完整實作(ensureBundledPython)
|
// - PythonModeBundled:placeholder,回錯誤(M2 才實作)
|
||||||
//
|
//
|
||||||
// R5-5a 之後:python 失敗直接擋啟動(沒有模擬回退)。
|
// R5-5a 之後:python 失敗直接擋啟動(沒有模擬回退)。
|
||||||
//
|
|
||||||
// 為什麼不先 system:Local-tool 的架構是整包內嵌 Python + KneronPLUS wheel,
|
|
||||||
// 使用者系統 python 絕大多數沒裝 kp,先 system 會拿到可執行但「import kp 失敗」
|
|
||||||
// 的解譯器,導致 detector scan 空、表面卻看似啟動成功。
|
|
||||||
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
|
||||||
if a.startupPipeline != nil {
|
if a.startupPipeline != nil {
|
||||||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.detect", 0)
|
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.detect", 0)
|
||||||
}
|
}
|
||||||
switch mode {
|
switch mode {
|
||||||
case PythonModeAuto:
|
case PythonModeAuto:
|
||||||
if bin, err := a.ensureBundledPython(); err == nil {
|
|
||||||
return bin, PythonModeBundled, nil
|
|
||||||
}
|
|
||||||
if bin, err := a.findSystemPython(); err == nil {
|
if bin, err := a.findSystemPython(); err == nil {
|
||||||
return bin, PythonModeSystem, nil
|
return bin, PythonModeSystem, nil
|
||||||
}
|
}
|
||||||
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried bundled + system)")
|
if bin, err := a.ensureBundledPython(); err == nil {
|
||||||
|
return bin, PythonModeBundled, nil
|
||||||
|
}
|
||||||
|
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried system + bundled)")
|
||||||
|
|
||||||
case PythonModeSystem:
|
case PythonModeSystem:
|
||||||
bin, err := a.findSystemPython()
|
bin, err := a.findSystemPython()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user