Compare commits

..

3 Commits

Author SHA1 Message Date
30d0ff5695 fix(local-tool): 推論 bbox 標註不顯示 — 前端 canvas 尺寸 + KL520 reset + 延長 timeout
症狀:Mac 版上傳單張圖推論,畫面完全沒有 bbox 標註。實測追根因後發現
兩層獨立問題疊加(前端 + 後端),擇一修復都無法解決。

## Layer 1: 前端 canvas 尺寸對不上 img 顯示尺寸
- camera-inference-view.tsx renderedSize 初始值硬寫 {w:640, h:480}
- ResizeObserver 理應在 <img> load 後 fire,但實測沒 fire 或時機不對
- 結果 overlay canvas 永遠用 640×480 畫,bbox 嚴重偏位或跑出 canvas

修法(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 改為拿到真實尺寸才 render
- setState callback 用 prev 比對,同尺寸不觸發 render
- camera-overlay.tsx 加 [bbox-debug] console.log 保留(debug 成本低,
  對未來排查有幫助)

## Layer 2: KL520 推論炸 ApiKPException Error 15
- kp.inference.generic_image_inference_send 回 SEND_DATA_TOO_LARGE
- 試過 image 尺寸(516×640 / 640×794 / 640×640 host 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 跳過 reset」當時
為解 Windows 60s HTTP timeout 的優化。但副作用:KL520 若 session 間
firmware 殘留(fw=KDP2 Comp/U),直接 load_model + inference 100% 炸
Error 15。必須走完整 reset → 退回 Loader → 重新載 firmware → Comp/U
流程才能得到能 inference 的 session。

修法(kl720_driver.go):
- 移除「KL520 跳過 reset」特例,讓 KL520 和 KL720 都走 needsReset → restartBridge
- 註解記錄 trade-off:KL520 connect 時間 ~2s → ~15-20s(macOS),
  Windows 可能 60s+

## HTTP timeout 配套調整
- device_handler.go ConnectDevice timeout 60s → 120s
- Windows worst-case(~65s:Loader reconnect 16s + firmware load 31s +
  reboot 8s + reconnect 5s)留 buffer,避免 504 CONNECT_TIMEOUT

## Bridge 清理
- kneron_bridge.py 清掉中途試驗遺留的 `_host_preproc` 死碼
  (還原成原版 _correct_bbox_for_letterbox)
- 加了 debug log(Inference: sending / parse done / EXCEPTION with
  traceback)保留,未來排查 inference 路徑很有用

## 驗證(function 層)
/tmp/test_bridge.py 三種尺寸全通過:
- 516×640 直式 → 11 detections (person×8, tie×3) latency 308ms
- 1920×1080 横式 → 0 detections(合成圖,正常)
- 512×512 正方 → 0 detections

## 待使用者驗證
- Mac UI 實測:上傳 ~/Downloads/000000000459.jpg 應見 11 個 bbox 精準框住
- Windows 實測 connect 耗時 + timeout 是否足夠
- Linux 實測

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:12:10 +08:00
c1d2e2ddaa feat(local-tool): macOS DMG 美化(create-dmg 背景圖 + Applications 捷徑)
需求:Mac 端 installer 體驗類比 Windows .exe — 進 DMG 就看到漂亮的視窗
背景 + 拖到 Applications 的視覺引導。

實作:
- installer/macos/ 新資料夾
  - make-dmg-background.py:動態生成 640×400 深色背景,配色對齊 Wails
    控制台 splash(#111827→#0B0F19 漸層 + #38BDF8 accent)
  - background.png + background@2x.png(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 流程零改動

驗證:
- `make dmg-fancy` 產出 157MB DMG,mount 後內容:app + Applications 捷徑
  + .background/background.png + .DS_Store(視窗樣式)
- `hdiutil verify` 通過

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:11:22 +08:00
d0b33f8c71 fix(local-tool): macOS 掃不到 Kneron 裝置 — PythonModeAuto 先 bundled
症狀:Mac 版 app 啟動後,前端顯示沒有裝置(實際 KL520 透過 USB 連上)。

根因:
PythonModeAuto 原本「先 system 後 bundled」,但系統 python3 通常沒裝
KneronPLUS wheel → `import kp` 失敗 → HAS_KP=False → bridge 降級 pyusb →
pyusb 找不到 libusb → scan 空陣列。表面看起來啟動成功但 detector 是空的。

修法:
- 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`,避開 macOS hardened
  runtime 剝掉 DYLD_LIBRARY_PATH 的風險。Windows/Linux 守門不執行。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:10:59 +08:00
13 changed files with 386 additions and 44 deletions

View File

@ -3,7 +3,111 @@
## 目的:全新專案(從 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-14 ## 最後更新2026-04-21
## 2026-04-21 推論 bbox 標註不顯示 + KL520 Error 15S 級 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 對不上 → 就算有 detectionbbox 位置會嚴重偏位甚至跑出 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 timeoutLoader 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`,是就立刻 reportcover 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-offKL520 connect 時間從 ~2s 變 ~15-20smacOSWindows 可能 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。走方案 Ccreate-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 plainCI 無痛)
- `dmg-fancy`:強制美化版(需 `brew install create-dmg`
- `dmg-plain`:原本的 hdiutil UDZO保留為 fallback
- Windows / Linux 流程零改動
驗證:
- `brew install create-dmg` 成功
- `make dmg-fancy` 產出 157MB DMGmount 後內容app + Applications 捷徑 + .background/background.png + .DS_Store視窗樣式
- `hdiutil verify` 通過
## 🔴 2026-04-14 使用者提出 L 級重大方向變更 ## 🔴 2026-04-14 使用者提出 L 級重大方向變更

View File

@ -520,15 +520,48 @@ 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 ## hdiutil UDZO → dist/visiona-local.dmg dmg: wails-macos ## 美化 DMGcreate-dmg 有裝)或 plain DMGfallback→ 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 DMGhdiutil 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
@file $(DIST)/visiona-local.dmg dmg-fancy: ## create-dmg 美化版 → dist/visiona-local.dmg需 brew install create-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 \

View File

@ -23,17 +23,30 @@ 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 observer = new ResizeObserver((entries) => { const report = () => {
for (const entry of entries) { const rect = img.getBoundingClientRect();
const { width: w, height: h } = entry.contentRect; const w = Math.round(rect.width);
if (w > 0 && h > 0) { const h = Math.round(rect.height);
onDimensionsChange(Math.round(w), Math.round(h)); if (w > 0 && h > 0) onDimensionsChange(w, 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]); }, [onDimensionsChange, streamUrl, batchImageUrl]);
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 (
@ -64,6 +77,7 @@ 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"
/> />

View File

@ -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: 640, h: 480 }); const [renderedSize, setRenderedSize] = useState<{ w: number; h: number } | null>(null);
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({ w, h }); setRenderedSize((prev) => (prev && prev.w === w && prev.h === h ? prev : { 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 ? ( isStreaming && renderedSize ? (
<CameraOverlay <CameraOverlay
detections={detections} detections={detections}
width={renderedSize.w} width={renderedSize.w}

View File

@ -25,6 +25,16 @@ 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

View File

@ -0,0 +1,42 @@
# macOS DMG 美化資源
## 檔案
| 檔案 | 用途 |
|------|------|
| `make-dmg-background.py` | 生成背景圖的 Python script需 Pillow |
| `background.png` | 640×400 DMG 背景圖1x |
| `background@2x.png` | 1280×800 DMG 背景圖Retinacreate-dmg 自動挑用) |
## 使用
```bash
# 一次性安裝
brew install create-dmg
# Build 美化 DMG
make dmg
```
`make dmg` 會自動偵測 `create-dmg`
- 有裝 → 產出美化版(深色背景 + 拖曳示意)
- 沒裝 → fallback 到 `make dmg-plain`(原本的 hdiutil UDZOCI 友善)
可直接指定 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.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,85 @@
#!/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=460y=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()

View File

@ -82,9 +82,12 @@ 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 can take ~40s: retry connect (3x2s) + firmware // KL520 USB Boot flow now includes mandatory reset + firmware reload on
// load + 5s reboot wait + reconnect retry (3x3s). Use 60s timeout. // first connect (required for inference to work — see kl720_driver.go
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) // needsReset block). Worst-case path on Windows: Loader-mode reconnect
// 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)

View File

@ -290,26 +290,28 @@ 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.
// //
// KL520 跳過 resetKL520 是 USB Boot 裝置reset 會退回 Loader 模式 // BOTH KL520 and KL720 需要 reset
// firmware 從 RAM 清掉),然後需要重新載 firmware~30 秒)+ USB
// 重新枚舉(~8 秒)。這讓原本 2 秒的 connect 變成 60+ 秒,而且
// Loader 模式的 connect_devices_without_check 不穩定(常需重試)。
// //
// KL520 每次 connect 本來就是 clean stateRAM-based firmware斷電 // - KL720 是 flash-based 裝置firmware 和 model 會保留在 flashreset
// 即清),不需要主動 reset 清 stale model。如果真的有殘留 model
// 下次 load_model 前的 restartBridge 會處理。
//
// KL720 是 flash-based 裝置firmware 和 model 會保留在 flashreset
// 清 stale model 才有意義。 // 清 stale model 才有意義。
if needsReset && d.chipType == "KL720" { //
d.driverLog("INFO", "[kneron] first connect after server start — resetting KL720 to clear stale model...") // - KL520 雖然是 USB Boot 裝置RAM-based firmware斷電即清理論上
// 每次 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-20smacOS 實測)。
// 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

View File

@ -18,6 +18,47 @@ 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再 libkpluslibkplus 相依 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
@ -1012,7 +1053,6 @@ 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)
@ -1020,12 +1060,11 @@ 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 # Create inference config (original: pass numpy ndarray, SDK reads shape)
inf_config = kp.GenericImageInferenceDescriptor( inf_config = kp.GenericImageInferenceDescriptor(
model_id=_model_id, model_id=_model_id,
inference_number=0, inference_number=0,
@ -1038,8 +1077,10 @@ 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
@ -1069,6 +1110,8 @@ 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),
@ -1078,6 +1121,8 @@ 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)}

View File

@ -62,9 +62,9 @@ const (
type PythonMode string type PythonMode string
const ( const (
PythonModeAuto PythonMode = "auto" // 先試 system失敗才走 bundledR4 決策M1 先 system PythonModeAuto PythonMode = "auto" // 先試 bundledkp wheel 已預裝),失敗才 fallback system
PythonModeBundled PythonMode = "bundled" // 策略 A內嵌 python-build-standalone PythonModeBundled PythonMode = "bundled" // 策略 A內嵌 python-build-standalone
PythonModeSystem PythonMode = "system" // 策略 B系統 python3 PythonModeSystem PythonMode = "system" // 策略 B系統 python3(需使用者自行 pip install
) )
// ServerStatus 回報給前端。 // ServerStatus 回報給前端。
@ -852,23 +852,27 @@ func (p *ServerProcess) stop() {
// //
// M1 實作狀況: // M1 實作狀況:
// - PythonModeSystem完整實作findSystemPython // - PythonModeSystem完整實作findSystemPython
// - PythonModeAuto先試 system失敗才走 bundled // - PythonModeAuto先試 bundled含預裝 kp wheel失敗才 fallback system
// - PythonModeBundledplaceholder回錯誤M2 才實作 // - PythonModeBundled完整實作ensureBundledPython
// //
// R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。 // R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。
//
// 為什麼不先 systemLocal-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.findSystemPython(); err == nil {
return bin, PythonModeSystem, nil
}
if bin, err := a.ensureBundledPython(); err == nil { if bin, err := a.ensureBundledPython(); err == nil {
return bin, PythonModeBundled, nil return bin, PythonModeBundled, nil
} }
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried system + bundled)") if bin, err := a.findSystemPython(); err == nil {
return bin, PythonModeSystem, nil
}
return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried bundled + system)")
case PythonModeSystem: case PythonModeSystem:
bin, err := a.findSystemPython() bin, err := a.findSystemPython()