diff --git a/docs/PRD-Integrated.md b/docs/PRD-Integrated.md index dd884c3..c5631f6 100644 --- a/docs/PRD-Integrated.md +++ b/docs/PRD-Integrated.md @@ -8,8 +8,8 @@ |------|------| | 文件名稱 | 邊緣 AI 開發平台 PRD | | 產品名稱 | (暫未定名,以下稱「本平台」) | -| 版本 | v2.7 | -| 日期 | 2026-03-02 | +| 版本 | v3.1 | +| 日期 | 2026-03-05 | | 狀態 | 更新中 | --- @@ -1087,7 +1087,7 @@ Kneron Dongle Arduino 開發板 非 Kneron 晶片 ### B4.5 已實作的額外功能 -> 以下功能為 MVP 開發過程中額外實作,超出原始 PRD 範圍。共 11 項功能。 +> 以下功能為 MVP 開發過程中額外實作,超出原始 PRD 範圍。共 18 項功能。 #### F5 — 多來源推論輸入 @@ -1203,15 +1203,16 @@ Kneron Dongle Arduino 開發板 非 Kneron 晶片 | 項目 | 規格 | |------|------| -| **概述** | 提供一行指令安裝體驗,涵蓋 binary 下載、環境設定、硬體偵測,支援 macOS 與 Windows | +| **概述** | 提供一行指令安裝體驗,涵蓋 binary 下載、環境設定、硬體偵測,支援 macOS、Windows 與 Linux | | **macOS/Linux 安裝** | `curl -fsSL https://gitea.innovedus.com/.../install.sh \| bash`,自動偵測 OS + 架構(darwin/linux + amd64/arm64) | | **Windows 安裝** | `irm https://gitea.innovedus.com/.../install.ps1 \| iex`,自動下載 zip、解壓、加入 PATH | | **安裝步驟** | 4 步驟自動化:(1) 下載 binary + 資料檔 (2) 安裝 USB 驅動(libusb)(3) 建立 Python venv + pyusb (4) 檢查環境 + 偵測硬體 | -| **安裝目錄** | macOS: `~/.edge-ai-platform/`、Windows: `%LOCALAPPDATA%\EdgeAIPlatform` | -| **解除安裝** | macOS: `rm -rf ~/.edge-ai-platform && sudo rm -f /usr/local/bin/edge-ai-server`、Windows: 刪除目錄 + 移除 PATH | +| **安裝目錄** | macOS: `~/.edge-ai-platform/`、Linux: `~/.edge-ai-platform/`、Windows: `%LOCALAPPDATA%\EdgeAIPlatform` | +| **解除安裝** | macOS/Linux: `rm -rf ~/.edge-ai-platform && sudo rm -f /usr/local/bin/edge-ai-server`、Windows: 刪除目錄 + 移除 PATH | | **啟動依賴檢查** | Server 啟動時自動檢查 ffmpeg、yt-dlp、python3,缺少時顯示對應平台的安裝指引 | -| **GoReleaser 打包** | 跨平台 archive 自動產出(darwin amd64/arm64 tar.gz、windows amd64 zip),含 binary + data + scripts | +| **GoReleaser 打包** | 跨平台 archive 自動產出(darwin amd64/arm64、linux amd64/arm64 tar.gz、windows amd64 zip),含 binary + data + scripts + firmware 目錄 + kneron_detect.py | | **Kneron 硬體偵測** | 安裝完成時自動偵測 USB Kneron 裝置(KL520/KL720/KL730),使用 pyusb;KL720 KDP legacy 裝置提示韌體更新 | +| **Linux 特殊處理** | Ubuntu/Debian: `apt install libusb-1.0-0-dev python3-venv`;設定 udev rules 以允許非 root 使用者存取 USB 裝置 | #### F17 — 圖形化安裝與解除安裝程式 @@ -1220,7 +1221,10 @@ Kneron Dongle Arduino 開發板 非 Kneron 晶片 | **概述** | 提供非技術人員可使用的桌面 GUI 安裝精靈,雙擊即可完成所有安裝步驟(server binary、Python 環境、系統依賴、硬體驅動),無需開終端機或輸入任何指令 | | **目標平台** | macOS (.dmg → .app) + Windows (.exe installer) | | **技術方案** | Go + Wails v2(WebView-based GUI),前端以 HTML/CSS/JS 實作安裝畫面,後端 Go 執行實際安裝邏輯。共用 Go 工具鏈,無需額外 runtime | -| **安裝精靈流程** | 6 步驟:(1) 歡迎頁 + 授權協議 → (2) 安裝路徑選擇 → (3) 元件選擇(必要/可選)→ (4) 自動安裝 + 即時進度 → (5) 硬體偵測結果 → (6) 完成 + 啟動選項 | +| **安裝精靈流程** | 7 步驟:(1) 歡迎頁 + 授權協議 → (2) Relay 連線設定 → (3) 安裝路徑選擇 → (4) 元件選擇(必要/可選)→ (5) 自動安裝 + 即時進度 → (6) 硬體偵測結果 → (7) 完成 + 啟動選項 | +| **Relay 設定步驟** | 新增 Relay Configuration 步驟:輸入 Relay URL、Relay Token、本地 Port(預設 3721);設定儲存至共用設定檔,供 Launcher 與 server 讀取 | +| **i18n 支援** | 安裝程式支援多語系切換(English + 繁體中文),安裝介面右上角提供語言切換器,所有步驟文字與錯誤訊息皆翻譯 | +| **設定檔** | `~/.edge-ai-platform/config.json`,安裝程式與 Launcher(系統匣應用)共用同一設定檔,包含 relay URL/token、port、語言偏好、auto-start 設定等 | | **必要元件(自動安裝)** | edge-ai-server binary(含嵌入式前端)、Python 3 venv + numpy + opencv-python-headless + pyusb、Kneron 韌體檔案(KL520 + KL720)、NEF 預訓練模型、libusb(USB 裝置通訊) | | **可選元件** | ffmpeg(攝影機/影片串流)、yt-dlp(YouTube 影片下載) | | **自動依賴解析** | macOS: 自動安裝 Homebrew(若未安裝)→ `brew install libusb python3 ffmpeg`;Windows: 自動下載 Python embedded + libusb DLL,免管理員權限 | @@ -1228,10 +1232,11 @@ Kneron Dongle Arduino 開發板 非 Kneron 晶片 | **硬體偵測** | 安裝完成後自動掃描 USB Kneron 裝置,顯示偵測到的晶片型號(KL520/KL720)、韌體版本、連線狀態;KL720 KDP legacy 裝置提示一鍵韌體更新 | | **解除安裝** | 內建解除安裝功能:刪除 server binary + Python venv + 資料檔案 + symlink/PATH,macOS 提供拖曳到垃圾桶 + 深度清理選項,Windows 整合「新增或移除程式」 | | **安裝目錄** | macOS: `~/.edge-ai-platform/`(不需 sudo)、Windows: `%LOCALAPPDATA%\EdgeAIPlatform\`(不需管理員) | -| **安裝完成動作** | 可選擇立即啟動 server + 自動開啟瀏覽器(`http://localhost:3721`)、建立桌面捷徑、設定開機自動啟動 | +| **安裝完成動作** | 自動啟動選項現在註冊 Launcher(系統匣應用程式)而非裸 server 進程;可選擇立即啟動 Launcher + 自動開啟瀏覽器(`http://localhost:3721`)、建立桌面捷徑、設定開機自動啟動 Launcher | | **更新支援** | 偵測現有安裝版本,僅更新 binary + 新模型,保留使用者資料(custom-models、設定檔)與 Python venv | | **打包產出** | macOS: `EdgeAI-Installer.dmg`(含 .app + 背景圖 + Applications 捷徑)、Windows: `EdgeAI-Setup.exe`(NSIS + Wails 包裝) | | **安裝體積** | 完整安裝約 300-400 MB(binary ~10MB + 模型 ~73MB + Python venv ~250MB + firmware ~2MB) | +| **UI 設計** | 現代化視覺設計,包含步驟進度指示器、平滑過渡動畫、統一的色彩主題與字體排版 | #### F18 — Kneron 硬體通訊整合(KL520 + KL720) @@ -1270,6 +1275,75 @@ Kneron Dongle Arduino 開發板 非 Kneron 晶片 | **WebSocket** | 叢集推論結果使用 `inference:cluster:{clusterId}` room 廣播 | | **限制** | MVP 階段:每叢集最多 8 裝置;叢集設定不持久化;不支援跨機器叢集 | +#### F20 — 雲端中繼隧道(Cloud Relay Tunnel) + +| 項目 | 規格 | +|------|------| +| **概述** | 允許部署在雲端(AWS EC2 等)的前端 UI 透過中繼伺服器存取使用者本地端的 edge-ai-server,無需本地開放防火牆或使用 VPN。採用業界標準的反向隧道架構(參考 Edge Impulse daemon / Balena tunnel 模式) | +| **架構** | 瀏覽器 → nginx(:80) → relay-server(:3800) ←yamux/WebSocket→ 本地 edge-ai-server(:3721) ↔ Kneron 硬體。relay-server 支援多台 local server 同時連線,以 token 為 key 管理多個 yamux session | +| **Relay Server** | 獨立 Go binary(`relay-server`),部署於雲端 EC2。接受本地 server 的 WebSocket 隧道連線,透過 yamux 多工串流轉發前端的 HTTP 和 WebSocket 請求 | +| **Tunnel Client** | 整合在 edge-ai-server 內,透過 `--relay-url` 和 `--relay-token` 啟動。主動發起 WebSocket 連線到 relay server,建立 yamux session,接收並處理轉發的請求 | +| **yamux 多工** | 使用 hashicorp/yamux(Consul/Nomad 同款)在單一 WebSocket 上建立多條獨立串流,每個 HTTP 請求或 WebSocket 連線是獨立的 yamux stream | +| **透明轉發** | 前端自動從 localhost:3721/auth/token 取得本機 relay token,HTTP 請求透過 `X-Relay-Token` header、WebSocket 透過 `?token=` query parameter 路由到對應的本地 server | +| **串流支援** | 完整支援 MJPEG 即時影像串流(relay 使用 `http.Flusher` 逐塊轉發)、WebSocket 推送(upgrade 後雙向 byte copy)、大檔案上傳 | +| **認證機制** | 硬體 ID 自動認證:local server 啟動時自動從 MAC 位址 SHA-256 雜湊產生 16 字元 token,連接 relay 時透過 URL query parameter 傳遞。`--relay-token` flag 仍可手動覆蓋 | +| **自動重連** | Tunnel client 斷線後自動重連,使用指數退避策略(1s → 2s → 4s → ... → 30s 上限)| +| **健康檢查** | `GET /relay/status?token=xxx` 回傳 `{"tunnelConnected": true/false}`,無 token 時回傳所有隧道總數 | +| **部署** | `deploy-ec2.sh --relay` 自動上傳 relay-server binary、建立 systemd service、設定 nginx proxy 規則 | +| **不影響本地** | 未設定 `--relay-url` 時完全不啟動 tunnel client,本地模式零影響 | + +#### F21 — 系統匣啟動器(System Tray Launcher) + +| 項目 | 規格 | +|------|------| +| **概述** | 系統匣常駐應用程式,整合於 edge-ai-server binary 中,透過 `--tray` flag 啟動(參考 Ollama 模式)。提供圖形化的 server 生命週期管理,無需開啟終端機 | +| **GUI 框架** | fyne.io/systray(輕量級,無 GTK 依賴,由 Fyne 團隊維護),僅使用系統原生匣圖示 API,不引入完整 GUI 框架 | +| **功能** | Start/Stop server 切換、伺服器狀態顯示(Running / Stopped + port 號)、匣圖示狀態反映(綠色 = 運行中、灰色 = 已停止)、Open Browser(開啟 `http://localhost:{port}` 或 relay URL + token)、View Logs(開啟 log 檔案或系統 Console)、Relay 狀態顯示(Connected / Disconnected)、Quit(停止 server 並退出) | +| **架構** | `--tray` flag 啟動系統匣模式,Launcher 作為主進程管理 server 作為子進程(child process);從 `~/.edge-ai-platform/config.json` 讀取 port、relay URL/token 等設定,傳遞為 server 啟動參數 | +| **Build Tags** | 使用 Go build tags 區分建置模式:預設建置包含 tray 功能(CGO_ENABLED=1),`notray` tag 產出無 CGO 依賴的純 CLI binary;安裝程式建置啟用 CGO 以支援系統匣 | +| **macOS 平台** | 開機自動啟動透過 launchd(`~/Library/LaunchAgents/com.edge-ai-platform.launcher.plist`);使用 template icons(支援 Light/Dark menu bar 自動切換) | +| **Windows 平台** | 開機自動啟動透過 Scheduled Task(`schtasks /create`,登入時觸發);使用 .ico 格式匣圖示 | +| **Linux 平台** | 開機自動啟動透過 XDG autostart(`~/.config/autostart/edge-ai-platform.desktop`);使用 PNG 格式匣圖示 | +| **設定整合** | 讀取 `~/.edge-ai-platform/config.json`(與 F17 安裝程式共用),設定項包含:`port`、`relayUrl`、`relayToken`、`autoStart`、`language` | +| **子進程管理** | 啟動 server 時以 `exec.Command` 建立子進程,監控其存活狀態;Quit 時先優雅關閉子進程(SIGTERM),超時後強制終止(SIGKILL) | + +#### F22 — 多租戶裝置隔離(Multi-Tenant Device Isolation) + +| 項目 | 規格 | +|------|------| +| **概述** | 多位使用者同時透過同一 relay server 存取各自的本機 edge-ai-server 時,每位使用者只能看到自己電腦上的裝置。零設定、自動化認證,無需手動輸入 token | +| **硬體 ID Token** | `pkg/hwid/hwid.go`:讀取第一個非 loopback 網路介面的 MAC 位址 → SHA-256 → 取前 16 字元 hex 作為 token。確保同一台電腦每次產生相同 token | +| **Relay Server 多工** | Relay server 從單一 session 改為 `map[string]*yamux.Session`(以 token 為 key),支援多台 local server 同時連線 | +| **Token 路由** | 前端 HTTP 請求透過 `X-Relay-Token` header、WebSocket 透過 `?token=` query parameter 傳遞 token,relay 根據 token 路由到對應的 yamux session | +| **自動傳遞** | Local server 啟動並連線 relay 後,自動開啟瀏覽器前往 `{relay_url}/?token={hwid_token}`。前端從 URL query param 讀取 token → 存入 localStorage → 以 `history.replaceState` 移除 URL 參數。此機制取代先前的 localhost fetch 方式,因 Chrome Private Network Access (PNA) 策略禁止公網頁面 fetch localhost | +| **手動備份** | Settings 頁面提供 Relay Token 手動輸入欄位,供自動傳遞失敗時使用(如瀏覽器未自動開啟) | +| **安全性** | Token 在 log 中遮蔽顯示(僅前 8 字元);`X-Relay-Token` 在轉發至 local server 前被移除 | +| **向下相容** | 未設定 relay 時所有 token 機制不啟動,本地模式完全不受影響 | + +#### F23 — 新手引導體驗(Onboarding & Empty States) + +| 項目 | 規格 | +|------|------| +| **概述** | 針對首次使用者提供引導式體驗,降低上手門檻。包含首次使用導覽、空狀態引導、以及更友善的錯誤訊息 | +| **首次導覽** | 首次進入平台時顯示 3 步驟快速導覽:(1) 連接 Kneron 裝置 (2) 選擇 AI 模型 (3) 開始推論。使用 localStorage 記錄是否已完成導覽 | +| **裝置空狀態** | Devices 頁面無裝置時顯示插圖 + 引導文字:「請插入 Kneron USB Dongle 並點擊掃描」,附帶硬體購買連結與常見問題 | +| **模型空狀態** | Models 頁面無模型時引導使用者瀏覽預訓練模型庫或上傳自訂模型 | +| **推論空狀態** | Workspace 頁面顯示清楚的步驟提示:先連接裝置 → 載入模型 → 選擇輸入來源 | +| **錯誤訊息改善** | 所有錯誤訊息附帶可能原因與排除建議。例:「無法偵測到裝置」→ 建議檢查 USB 連接、安裝驅動、嘗試其他 USB 孔位 | +| **幫助連結** | 關鍵操作旁提供 tooltip 或 info icon,連結至文件或常見問題頁 | + +#### F24 — 自動版本檢查與更新提示(Auto Update Check) + +| 項目 | 規格 | +|------|------| +| **概述** | Server 啟動時自動檢查是否有新版本可用,前端 Settings 頁面顯示更新提示與下載連結 | +| **版本檢查 API** | Server 啟動時呼叫 Gitea API(`/api/v1/repos/{owner}/{repo}/releases/latest`)取得最新版本號,與當前 `Version` 比較 | +| **檢查頻率** | Server 啟動時檢查一次;前端 Settings 頁面可手動觸發檢查 | +| **前端顯示** | Settings 頁面顯示:當前版本、最新版本、更新日期。若有新版本則顯示醒目的更新提示 badge + 變更日誌摘要 + 下載連結 | +| **API 端點** | `GET /api/system/update-check` — 回傳 `{ currentVersion, latestVersion, updateAvailable, releaseUrl, releaseNotes }` | +| **降級處理** | 檢查失敗時靜默忽略(網路不通、Gitea 不可達),不影響正常使用 | +| **隱私** | 版本檢查不傳送任何使用者資料或硬體資訊 | + --- ## B5. 功能路線圖(Post-MVP) @@ -1456,4 +1530,16 @@ Phase 3 — 進階功能(長期差異化) --- -*文件版本:v2.7 | 日期:2026-03-02 | 狀態:更新中* +*文件版本:v3.1 | 日期:2026-03-05 | 狀態:更新中* + +--- + +## 修訂紀錄 + +| 版本 | 日期 | 變更內容 | +|------|------|---------| +| v3.1 | 2026-03-05 | F22: Token 傳遞改為 URL query param(因 Chrome PNA 限制移除 localhost fetch)。F16: 加入 Linux 平台支援 + GoReleaser 打包 firmware/detect script。F21: System Tray 增強(Open Browser、View Logs、Relay 狀態)。新增 F23 新手引導體驗(Onboarding + 空狀態 UX)。新增 F24 自動版本檢查與更新提示。功能總數更新至 20 項 | +| v3.0 | 2026-03-04 | F20: 更新為多租戶架構(多 session map + token routing)。新增 F22 多租戶裝置隔離(硬體 ID 自動 token + 前端自動偵測 + relay token routing)。功能總數更新至 18 項 | +| v2.9 | 2026-03-04 | F17: 新增 Relay 設定步驟、i18n 支援(EN + zh-TW)、config.json 共用設定檔、auto-start 改為註冊 Launcher、UI 現代化設計。新增 F21 系統匣啟動器(System Tray Launcher)。功能總數更新至 17 項 | +| v2.8 | 2026-03-04 | 前次更新 | +| v2.7 | 2026-03-02 | 前次更新 | diff --git a/docs/TDD.md b/docs/TDD.md index 67397ca..7b04f9b 100644 --- a/docs/TDD.md +++ b/docs/TDD.md @@ -7,9 +7,9 @@ | 項目 | 內容 | |------|------| | 文件名稱 | 邊緣 AI 開發平台 TDD | -| 對應 PRD | PRD-Integrated.md v2.7 | -| 版本 | v1.6 | -| 日期 | 2026-03-02 | +| 對應 PRD | PRD-Integrated.md v3.1 | +| 版本 | v2.0 | +| 日期 | 2026-03-05 | | 狀態 | 更新中 | --- @@ -88,6 +88,27 @@ │ ● 模型儲存庫 │ │ ● 使用者帳號 │ └──────────────────┘ + +雲端中繼模式(Cloud Relay Mode): +┌────────────────────────────────────────────┐ +│ 雲端 EC2 │ +│ │ +│ ┌──────────┐ proxy ┌────────────────┐ │ +│ │ nginx │────────►│ relay-server │ │ +│ │ (:80) │ │ (:3800) │ │ +│ └──────────┘ └───────┬────────┘ │ +└───────────────────────────────┼────────────┘ + │ yamux/WebSocket + │ (outbound from local) +┌───────────────────────────────┼────────────┐ +│ 使用者電腦 │ │ +│ ┌────────────────────────────┴──────────┐ │ +│ │ edge-ai-server (:3721) │ │ +│ │ ├── tunnel client (→ relay) │ │ +│ │ ├── REST API + WebSocket │ │ +│ │ └── Device Manager → Kneron 硬體 │ │ +│ └───────────────────────────────────────┘ │ +└────────────────────────────────────────────┘ ``` ### 1.2 技術選型總覽 @@ -1910,14 +1931,21 @@ type StepProgress struct { } ``` -**安裝精靈 UI 流程(6 步驟):** +**i18n 多語系支援:** + +安裝程式支援英文(EN)與繁體中文(zh-TW)雙語介面: +- 語系偵測:啟動時讀取系統語言設定(macOS: `defaults read -g AppleLanguages`、Windows: `GetUserDefaultUILanguage()`) +- 翻譯檔案:Go `embed` 嵌入 `installer/locales/en.json` 和 `installer/locales/zh-TW.json` +- 使用者可在歡迎頁右上角切換語言,選擇結果寫入 config.json 的 `launcher.language` + +**安裝精靈 UI 流程(7 步驟):** ``` ┌─────────────────────────────────────────────────────────┐ -│ Step 1: 歡迎頁 │ +│ Step 1: 歡迎頁 [EN|中] │ │ │ │ ┌─────────────────────────────────┐ │ -│ │ 🔧 Edge AI Platform Installer │ │ +│ │ Edge AI Platform Installer │ │ │ │ │ │ │ │ 版本: v0.2.0 │ │ │ │ 安裝大小: ~350 MB │ │ @@ -1942,7 +1970,25 @@ type StepProgress struct { └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ -│ Step 3: 元件選擇 │ +│ Step 3: Relay 中繼設定(選填) │ +│ │ +│ 雲端中繼可讓您從外部網路存取本機裝置。 │ +│ 若不使用,請直接按「下一步」跳過。 │ +│ │ +│ Relay URL: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ wss://relay.example.com/tunnel/connect │ │ +│ └──────────────────────────────────────────┘ │ +│ Relay Token: │ +│ ┌──────────────────────────────────────────┐ │ +│ │ ●●●●●●●●●●●● │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ [← 上一步] [下一步 →] │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 4: 元件選擇 │ │ │ │ 必要元件: │ │ ☑ Edge AI Server (10 MB) ── 核心伺服器 │ @@ -1959,7 +2005,7 @@ type StepProgress struct { └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ -│ Step 4: 安裝進度 │ +│ Step 5: 安裝進度 │ │ │ │ ✅ 下載 Edge AI Server 完成 │ │ ✅ 解壓縮檔案 完成 │ @@ -1969,13 +2015,14 @@ type StepProgress struct { │ │ 安裝 numpy... │ │ │ └──────────────────────────────────┘ │ │ ○ 安裝模型檔案 等待中 │ +│ ○ 寫入設定檔 等待中 │ │ ○ 偵測硬體 等待中 │ │ │ │ [取消] │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ -│ Step 5: 硬體偵測 │ +│ Step 6: 硬體偵測 │ │ │ │ 偵測到的 Kneron 裝置: │ │ │ @@ -1989,13 +2036,13 @@ type StepProgress struct { │ │ PID: 0x0100 │ │ │ └──────────────────────────────────┘ │ │ │ -│ ⚠️ 未偵測到裝置?請確認 USB 連接 │ +│ 未偵測到裝置?請確認 USB 連接。 │ │ │ │ [← 上一步] [下一步 →] │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ -│ Step 6: 完成 │ +│ Step 7: 完成 │ │ │ │ ✅ 安裝成功! │ │ │ @@ -2006,7 +2053,7 @@ type StepProgress struct { │ ☑ 立即啟動 Edge AI Server │ │ ☑ 開啟瀏覽器 (http://localhost:3721) │ │ ☐ 建立桌面捷徑 │ -│ ☐ 開機自動啟動 │ +│ ☑ 開機自動啟動 │ │ │ │ [完成] │ └─────────────────────────────────────────────────────────┘ @@ -2016,16 +2063,17 @@ type StepProgress struct { | 步驟 | macOS 實作 | Windows 實作 | |------|-----------|-------------| -| 下載 binary | `net/http` GET → Gitea release tar.gz | `net/http` GET → Gitea release zip | +| 下載 binary | `net/http` GET → Gitea release tar.gz(tray-enabled build) | `net/http` GET → Gitea release zip(tray-enabled build) | | 解壓 | `archive/tar` + `compress/gzip` | `archive/zip` | | symlink | `os.Symlink()` → `/usr/local/bin/edge-ai-server` (需確認權限) | 加入 User PATH (`os.Setenv` + registry) | | libusb | `exec.Command("brew", "install", "libusb")` 或內嵌 dylib | 內嵌 `libusb-1.0.dll` 到安裝目錄 | | Python venv | `exec.Command("python3", "-m", "venv", ...)` | `exec.Command("python", "-m", "venv", ...)` 或內嵌 Python embedded | | pip install | `venv/bin/pip install numpy opencv-python-headless pyusb` | `venv\Scripts\pip install ...` | | ffmpeg | `brew install ffmpeg` (可選) | `winget install Gyan.FFmpeg` 或內嵌 (可選) | +| 寫入設定檔 | 寫入 `~/.edge-ai-platform/config.json`(含 port、relay URL/token、語言偏好) | 寫入 `%LOCALAPPDATA%\EdgeAIPlatform\config.json` | | 硬體偵測 | `exec.Command(python, "kneron_detect.py")` | 同左 | | 桌面捷徑 | `~/Desktop/EdgeAI.command` | `shell:desktop\EdgeAI.lnk` (COM API) | -| 開機啟動 | `~/Library/LaunchAgents/com.innovedus.edge-ai.plist` | `HKCU\...\Run` registry | +| 開機啟動 | `~/Library/LaunchAgents/com.innovedus.edge-ai.plist`(執行 `edge-ai-server --tray`) | `HKCU\...\Run` registry(執行 `edge-ai-server.exe --tray`) | **macOS 無 Homebrew 情境處理:** @@ -2291,6 +2339,520 @@ POST /api/clusters/:id/flash {modelId: "yolov5s"} 實際吞吐量受 USB 頻寬和 host CPU 限制,建議使用多個 USB controller 或 powered hub。 +#### 8.5.16 雲端中繼隧道(F20 — Cloud Relay Tunnel) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| (無需修改) | `internal/relay/server.go` | Relay server 核心邏輯 | +| — | `cmd/relay-server/main.go` | Relay server 獨立 binary 進入點 | +| — | `internal/tunnel/client.go` | Tunnel client(整合在 edge-ai-server) | +| — | `pkg/wsconn/wsconn.go` | WebSocket-to-net.Conn 轉接器 | +| — | `pkg/hwid/hwid.go` | 硬體 ID 產生 | +| — | `internal/api/middleware.go` | CORS 支援 X-Relay-Token | +| — | `internal/config/config.go` | 新增 `--relay-url`、`--relay-token` flags | +| — | `main.go` | 條件啟動 tunnel client | +| — | `scripts/deploy-ec2.sh` | EC2 部署腳本(含 relay 部署) | +| `lib/constants.ts` | — | relay token 管理 | +| `lib/api.ts` | — | X-Relay-Token header | +| `lib/ws.ts` | — | token query param | +| `components/relay-token-sync.tsx` | — | 自動偵測 token | +| `app/settings/page.tsx` | — | relay token UI | + +**整體架構:** + +``` +瀏覽器 → nginx(:80) ─┬─ /api/* ──► relay-server(:3800) ◄──yamux/WS──► edge-ai-server(:3721) + ├─ /ws/* ──► ↑ ↕ + ├─ /tunnel/ ─► │ Kneron 硬體 + └─ /* ──► 靜態前端 (SPA) +``` + +**wsconn — WebSocket-to-net.Conn 轉接器(`pkg/wsconn/wsconn.go`):** + +```go +// 將 gorilla/websocket.Conn 包裝成 net.Conn 介面 +// 供 hashicorp/yamux 多工器使用 +type Conn struct { + ws *websocket.Conn + reader io.Reader // 當前 WebSocket 訊息的 reader + rmu sync.Mutex // 讀取鎖 + wmu sync.Mutex // 寫入鎖 +} + +// Read: 從 WebSocket Binary frame 讀取,自動處理訊息邊界 +// Write: 每次 Write 呼叫包裝為一個 Binary frame +// 實現 net.Conn 所有方法: Close, LocalAddr, RemoteAddr, SetDeadline... +``` + +**Relay Server(`internal/relay/server.go`):** + +```go +type Server struct { + sessions map[string]*yamux.Session // token → yamux session + mu sync.RWMutex +} + +// 路由: +// GET /tunnel/connect?token=xxx → handleTunnel() — 接受 WS tunnel 連線 +// GET /relay/status → handleStatus() — 回傳 tunnel 狀態 +// /* → handleProxy() — 轉發請求到 tunnel + +// getToken 從 X-Relay-Token header 或 ?token query param 取得 token +func getToken(r *http.Request) string { + if tok := r.Header.Get("X-Relay-Token"); tok != "" { return tok } + return r.URL.Query().Get("token") +} +``` + +**handleTunnel 流程:** +1. 從 URL query parameter 取得 token(必填,無 token 回傳 401) +2. `upgrader.Upgrade()` 升級為 WebSocket +3. `wsconn.New(conn)` 包裝成 net.Conn +4. `yamux.Server(netConn, config)` 建立 server-side session +5. 以 token 為 key 存入 `sessions[token]`,替換該 token 的舊 session(若有),舊 session 關閉 +6. 阻塞等待 `session.CloseChan()` 直到斷線,斷線後從 `sessions` 移除 + +**handleProxy 流程(一般 HTTP 請求):** +1. 從 `X-Relay-Token` header 或 `?token=` query param 取得 token(`getToken(r)`) +2. 以 token 查找 `sessions[token]` 取得對應 yamux session(RLock),找不到回傳 502 +3. `r.Header.Del("X-Relay-Token")` 移除 token header,避免傳到後端 +4. `session.Open()` 開啟新串流 +5. `r.Write(stream)` 將原始 HTTP request 寫入串流 +6. `http.ReadResponse(stream)` 讀取 response +7. 複製 response headers + status code +8. 串流 response body(使用 `http.Flusher` 支援 MJPEG 等串流回應) + +**proxyWebSocket 流程(WebSocket 升級請求):** +1. 寫入 HTTP upgrade request 到 yamux stream +2. 讀取 response(預期 101 Switching Protocols) +3. `hijacker.Hijack()` 奪取 client TCP 連線 +4. 寫入 101 response 給瀏覽器 +5. 雙向 `io.Copy`(client ↔ tunnel stream) + +**Tunnel Client(`internal/tunnel/client.go`):** + +```go +type Client struct { + relayURL string // ws(s)://host:port/tunnel/connect + token string + localAddr string // "127.0.0.1:3721" + stopCh chan struct{} + stoppedCh chan struct{} +} + +// Start() — 背景 goroutine 執行連線迴圈 +// Stop() — 關閉 tunnel 並等待清理完成 +``` + +**connect() 流程:** +1. WebSocket Dial 到 relay server +2. `yamux.Client(netConn, config)` 建立 client-side session +3. 迴圈 `session.Accept()` 接受 relay 開啟的串流 +4. 每個串流在 goroutine 中處理 + +**handleStream() 流程:** +1. `http.ReadRequest(stream)` 從 yamux stream 讀取 HTTP request +2. 判斷是否為 WebSocket upgrade +3. 一般請求:`http.DefaultTransport.RoundTrip(req)` → `resp.Write(stream)` +4. WebSocket:raw TCP 連到 local server → 雙向 `io.Copy` + +**自動重連:** +```go +// 指數退避: 1s, 2s, 4s, 8s, 16s, 30s (cap) +func backoff(attempt int) time.Duration { + d := min(2^(attempt-1), 30) * time.Second + return d +} +``` + +**部署方式:** + +```bash +# 建置 relay-server binary +make build-relay + +# 部署到 EC2(含 relay + nginx proxy)— relay-server 不再需要 --relay-token(tokens 由各 client 自帶) +bash scripts/deploy-ec2.sh user@host --key key.pem --build --relay + +# 本地 server 連接到 relay(token 由 hwid.Generate() 自動產生) +./edge-ai-server --relay-url ws://ec2-host:3800/tunnel/connect --relay-token +``` + +**nginx proxy 設定(自動由 deploy-ec2.sh 產生):** +```nginx +# API 請求 → relay(關閉 buffering 以支援串流) +location /api/ { + proxy_pass http://127.0.0.1:3800; + proxy_buffering off; + proxy_read_timeout 3600s; +} +# WebSocket → relay(upgrade 支援) +location /ws/ { + proxy_pass http://127.0.0.1:3800; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +# Tunnel 端點(超長 timeout) +location /tunnel/ { + proxy_pass http://127.0.0.1:3800; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400s; +} +``` + +**依賴:** +| 套件 | 版本 | 用途 | +|------|------|------| +| `github.com/hashicorp/yamux` | v0.1.2 | WebSocket 上的多工串流(Consul/Nomad 同款)| +| `github.com/gorilla/websocket` | 既有 | WebSocket 連線(relay + tunnel 兩端共用)| + +#### 8.5.17 系統匣啟動器(F21 — System Tray Launcher) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| (無) | `server/tray/config.go` | Config struct、`LoadConfig()`、`Save()`、`ConfigDir()`、`ConfigPath()` | +| — | `server/tray/stub.go` | `//go:build notray` — 未啟用 CGO 時印出錯誤訊息 | +| — | `server/tray/tray.go` | `//go:build !notray` — `fyne.io/systray` 系統匣實作 | +| — | `server/tray/icons.go` | `//go:embed` 載入 tray icon 資源 | +| — | `server/tray/assets/` | `icon_running.png`(22x22 綠色)、`icon_stopped.png`(22x22 灰色) | +| — | `server/main.go` | `--tray` flag 進入點:`config.Load()` → `tray.Run(tray.LoadConfig())` | + +**目錄結構:** + +``` +server/tray/ +├── config.go # Config struct, LoadConfig(), Save(), ConfigDir(), ConfigPath() +├── stub.go # //go:build notray — prints error if --tray used without CGO +├── tray.go # //go:build !notray — fyne.io/systray implementation +├── icons.go # //go:embed for tray icon assets +└── assets/ + ├── icon_running.png # 22x22 green icon + └── icon_stopped.png # 22x22 gray icon +``` + +**Config 檔案格式**(`~/.edge-ai-platform/config.json`): + +```json +{ + "version": 1, + "server": { "port": 3721, "host": "127.0.0.1" }, + "relay": { "url": "", "token": "" }, + "launcher": { "autoStart": true, "language": "zh-TW" } +} +``` + +**核心元件:** + +```go +// server/tray/config.go +type Config struct { + Version int `json:"version"` + Server ServerConfig `json:"server"` + Relay RelayConfig `json:"relay"` + Launcher LauncherConfig `json:"launcher"` +} + +type ServerConfig struct { + Port int `json:"port"` + Host string `json:"host"` +} + +type RelayConfig struct { + URL string `json:"url"` + Token string `json:"token"` +} + +type LauncherConfig struct { + AutoStart bool `json:"autoStart"` + Language string `json:"language"` +} + +func ConfigDir() string // 回傳 ~/.edge-ai-platform/ +func ConfigPath() string // 回傳 ~/.edge-ai-platform/config.json +func LoadConfig() *Config // 讀取設定檔,不存在時回傳預設值 +func (c *Config) Save() error // 寫入設定檔 +``` + +```go +// server/tray/tray.go (//go:build !notray) +type TrayApp struct { + cfg *Config + cmd *exec.Cmd // server 子程序 + running bool + mu sync.Mutex +} + +// Run() — systray.Run(onReady, onExit) 主迴圈 +// onReady() — 設定 icon、tooltip、選單項目 +// startServer() — 以 os/exec 啟動子程序: os.Args[0] --port X --relay-url Y --relay-token Z +// stopServer() — SIGTERM (Unix) / Process.Kill() (Windows) 優雅關閉 +// watchServer() — goroutine 監控 cmd.Wait(),子程序結束時更新 tray 狀態 +``` + +**`TrayApp` 管理 server 子程序:** + +- Server 以子程序方式啟動:`os.Args[0] --port X --relay-url Y --relay-token Z` +- 背景 goroutine 呼叫 `cmd.Wait()` 監控子程序狀態,若異常結束則更新 tray icon 及選單狀態 +- 優雅關閉:Unix 發送 `SIGTERM`;Windows 呼叫 `Process.Kill()` + +**選單結構:** + +``` +┌──────────────────────────┐ +│ Edge AI Platform │ ← Status(disabled,僅顯示) +├──────────────────────────┤ +│ ▶ Start Server │ ← Start/Stop 切換 +├──────────────────────────┤ +│ Quit │ +└──────────────────────────┘ +``` + +- 選單項目:Status (disabled) → Start/Stop toggle → Separator → Quit +- Icon 平台適配:macOS 使用 `SetTemplateIcon()` 自動適配深色/淺色模式;Windows/Linux 使用 `SetIcon()` +- 狀態圖示:運行中 = `icon_running.png`(綠色)、已停止 = `icon_stopped.png`(灰色) + +**Build System:** + +| 建置模式 | 環境變數 | 用途 | +|---------|---------|------| +| `CGO_ENABLED=0 -tags notray` | CLI 發行版 | GoReleaser 跨平台 CLI 分發(stub 印出錯誤) | +| `CGO_ENABLED=1` | Tray 完整版 | 安裝程式 payload(含系統匣功能) | + +```makefile +# Makefile 新增目標 +build-server-tray: + cd server && CGO_ENABLED=1 go build -o ../dist/edge-ai-server-tray + +# installer-payload 目標改為依賴 build-server-tray +installer-payload: build-frontend build-server-tray +``` + +**進入點(`server/main.go`):** + +```go +func main() { + cfg := config.Load() // 解析 --tray flag + + if cfg.TrayMode { + // Tray 模式:啟動系統匣,server 由 TrayApp 管理 + tray.Run(tray.LoadConfig()) + return + } + + // 一般模式:直接啟動 server + startServer(cfg) +} +``` + +**Stub(`server/tray/stub.go`):** + +```go +//go:build notray + +package tray + +import ( + "fmt" + "os" +) + +func Run(cfg *Config) { + fmt.Fprintln(os.Stderr, "error: --tray requires CGO_ENABLED=1 build (this binary was built with -tags notray)") + os.Exit(1) +} +``` + +**依賴:** + +| 套件 | 版本 | 用途 | +|------|------|------| +| `fyne.io/systray` | latest | 跨平台系統匣(macOS/Windows/Linux) | + +#### 8.5.18 多租戶裝置隔離(F22 — Multi-Tenant Device Isolation) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| `components/relay-token-sync.tsx` | `pkg/hwid/hwid.go` | 硬體 ID → token 產生 | +| `lib/constants.ts` | `internal/relay/server.go` | Relay 多 session map | +| `lib/api.ts` | `internal/api/router.go` | /auth/token endpoint | +| `lib/ws.ts` | `internal/api/middleware.go` | CORS X-Relay-Token | +| `app/settings/page.tsx` | `main.go` | 自動產生 + 注入 token | +| `stores/camera-store.ts` | — | Stream URL token 注入 | +| `components/camera/*` | — | appendRelayToken for | + +**硬體 ID 產生(`pkg/hwid/hwid.go`):** + +```go +func Generate() string { + ifaces, _ := net.Interfaces() + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { continue } + mac := iface.HardwareAddr.String() + if mac == "" { continue } + return SHA256(mac)[:16] // 16-char hex token + } + return SHA256("unknown")[:16] +} +``` + +**Token 流程:** +``` +啟動時: + local server → hwid.Generate() → relayToken + local server → tunnel.NewClient(relayURL, relayToken, addr) → 連到 relay + local server → relayWebURL(wsURL, token) → http://relay-host/?token=xxx + local server → openBrowser(relayHTTP) → 自動開啟瀏覽器 + +瀏覽器載入時(URL 帶 ?token=xxx): + RelayTokenSync → syncRelayTokenFromURL() + → URLSearchParams.get("token") → "abc123..." + → localStorage.setItem("edge-ai-relay-token", token) + → history.replaceState() 移除 URL 中的 token 參數 + ※ 取代先前的 fetch("http://localhost:3721/auth/token") 方式 + ※ 原因:Chrome Private Network Access (PNA) 禁止公網頁面 fetch localhost + +API 請求: + api.ts → buildHeaders() → { "X-Relay-Token": token } + relay → getToken(r) → sessions[token] → yamux stream → local server + +WebSocket: + ws.ts → buildUrl() → ws://host/ws/path?token=abc123 + relay → getToken(r) → sessions[token] → yamux stream → local server + +MJPEG/圖片(): + appendRelayToken(url) → url + "?token=abc123" + relay → getToken(r) → sessions[token] → yamux stream → local server +``` + +**Relay Server 多租戶架構:** +```go +type Server struct { + sessions map[string]*yamux.Session // token → session + mu sync.RWMutex +} + +func NewServer() *Server { + return &Server{sessions: make(map[string]*yamux.Session)} +} + +// getToken 從 X-Relay-Token header 或 ?token query param 取得 token +func getToken(r *http.Request) string { + if tok := r.Header.Get("X-Relay-Token"); tok != "" { return tok } + return r.URL.Query().Get("token") +} +``` + +**前端 Token 管理(`lib/constants.ts`):** +```typescript +// 從 localStorage 取得快取的 token +export function getRelayToken(): string +// 儲存 token 到 localStorage +export function setRelayToken(token: string): void +// 從 URL query param 讀取 token → 存入 localStorage → 清理 URL +export function syncRelayTokenFromURL(): string +// 向下相容 wrapper(內部呼叫 syncRelayTokenFromURL) +export async function fetchAndCacheRelayToken(): Promise +// 為 等無法帶 header 的元素附加 ?token= 參數 +export function appendRelayToken(url: string): string +``` + +**Server 端自動開啟瀏覽器(`main.go`):** +```go +// relayWebURL 將 WebSocket URL 轉換為帶 token 的 HTTP URL +// ws://host:port/tunnel/connect → http://host:port/?token=xxx +func relayWebURL(wsURL, token string) string + +// openBrowser 使用系統預設瀏覽器開啟 URL +// darwin: open、linux: xdg-open、windows: cmd /c start +func openBrowser(url string) +``` + +**安全考量:** +- Token 在 log 中僅顯示前 8 字元(`relayToken[:8]`) +- `X-Relay-Token` header 在轉發前被 `r.Header.Del("X-Relay-Token")` 移除 +- CORS middleware 新增 `X-Relay-Token` 到 `Access-Control-Allow-Headers` +- Token 透過 URL 傳遞後立即從網址列移除(`history.replaceState`),降低被意外分享的風險 + +#### 8.5.19 新手引導體驗(F23 — Onboarding & Empty States) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| `components/onboarding/tour.tsx` | — | 3 步驟首次導覽(連接裝置 → 選模型 → 推論) | +| `components/onboarding/empty-state.tsx` | — | 可重用空狀態元件(插圖 + 引導文字 + CTA) | +| `hooks/use-first-visit.ts` | — | localStorage 記錄首次造訪狀態 | +| `app/devices/page.tsx` | — | 裝置頁空狀態引導 | +| `app/models/page.tsx` | — | 模型頁空狀態引導 | +| `app/workspace/[deviceId]/page.tsx` | — | Workspace 步驟提示 | +| `components/ui/error-message.tsx` | — | 增強錯誤訊息(附原因 + 排除建議) | + +**首次導覽流程:** +``` +用戶首次進入 + → useFirstVisit() 檢查 localStorage("onboarding-complete") + → 未完成 → 顯示 OnboardingTour + → Step 1: "連接你的 Kneron 裝置"(示意圖 + 插入 USB 提示) + → Step 2: "選擇 AI 模型"(模型庫預覽 + 說明) + → Step 3: "開始推論!"(攝影機/影片/圖片選擇提示) + → 完成 → localStorage.set("onboarding-complete", "true") +``` + +**空狀態設計原則:** +- 每個空狀態包含:插圖/icon + 主標題 + 描述文字 + 主要 CTA 按鈕 +- 裝置頁:「尚未偵測到裝置」→ [掃描裝置] 按鈕 + 疑難排解連結 +- 模型頁:「探索預訓練模型」→ [瀏覽模型庫] + [上傳自訂模型] +- Workspace:步驟式引導 → 1. 連接裝置 ✓/✗ → 2. 載入模型 ✓/✗ → 3. 選擇輸入 + +**錯誤訊息增強:** +```typescript +interface EnhancedError { + message: string; // 原始錯誤訊息 + possibleCauses: string[]; // 可能原因列表 + suggestions: string[]; // 排除建議 + helpUrl?: string; // 文件連結 +} +``` + +#### 8.5.20 自動版本檢查(F24 — Auto Update Check) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| `app/settings/page.tsx` | `internal/api/handlers/system.go` | 版本資訊 + 更新提示 | +| `components/update-badge.tsx` | `internal/update/checker.go` | 更新提示 badge | +| — | `main.go` | 啟動時觸發檢查 | + +**後端版本檢查(`internal/update/checker.go`):** +```go +type UpdateInfo struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` + ReleaseURL string `json:"releaseUrl"` + ReleaseNotes string `json:"releaseNotes"` + PublishedAt string `json:"publishedAt"` +} + +// Check 呼叫 Gitea API 取得最新 release,與當前版本比較 +func Check(currentVersion, giteaURL, owner, repo string) (*UpdateInfo, error) { + // GET /api/v1/repos/{owner}/{repo}/releases/latest + // 比較 semver,失敗時靜默返回(不影響正常使用) +} +``` + +**API 端點:** +``` +GET /api/system/update-check +→ 200 { currentVersion, latestVersion, updateAvailable, releaseUrl, releaseNotes, publishedAt } +→ 200 { currentVersion, latestVersion: "", updateAvailable: false } // 檢查失敗時 +``` + +**前端顯示:** +- Settings 頁面「關於」區塊顯示當前版本 + build time +- 有新版本時:醒目 badge + 版本號對比 + 變更摘要 + 下載按鈕 +- 檢查按鈕可手動觸發 `/api/system/update-check` + --- ## 9. 開發環境與工具鏈 @@ -2370,6 +2932,8 @@ edge-ai-platform/ │ ├── go.sum │ ├── main.go │ ├── cmd/ +│ │ └── relay-server/ # Relay server 獨立 binary +│ │ └── main.go │ ├── internal/ │ │ ├── api/ │ │ ├── device/ @@ -2377,10 +2941,24 @@ edge-ai-platform/ │ │ ├── model/ │ │ ├── flash/ │ │ ├── inference/ +│ │ ├── relay/ # Relay server 核心邏輯 +│ │ │ └── server.go +│ │ ├── tunnel/ # Tunnel client(連接 relay) +│ │ │ └── client.go │ │ └── config/ +│ ├── tray/ # 系統匣啟動器(F21) +│ │ ├── config.go # Config struct, LoadConfig(), Save() +│ │ ├── stub.go # //go:build notray stub +│ │ ├── tray.go # //go:build !notray — systray 實作 +│ │ ├── icons.go # //go:embed tray icon assets +│ │ └── assets/ +│ │ ├── icon_running.png # 22x22 運行中(綠色) +│ │ └── icon_stopped.png # 22x22 已停止(灰色) │ ├── pkg/ │ │ ├── serial/ │ │ ├── usb/ +│ │ ├── wsconn/ # WebSocket↔net.Conn 轉接器 +│ │ │ └── wsconn.go │ │ └── logger/ │ ├── data/ # 內建模型中繼資料 │ │ └── models.json @@ -2448,6 +3026,23 @@ edge-ai-platform/ | Windows | windows/amd64 | `edge-ai-platform.exe` | `.msi` 安裝包 | | Linux | linux/amd64 | `edge-ai-platform-linux-amd64` | `.deb` / `.rpm` / 直接執行 | +#### Relay Server(雲端部署) + +| 平台 | 架構 | 輸出檔案 | 部署方式 | +|------|------|---------|---------| +| Linux (EC2) | linux/amd64 | `relay-server` | `deploy-ec2.sh --relay` + systemd service | + +```bash +# 建置 relay-server +make build-relay + +# 部署到 EC2(自動上傳 binary + 建立 systemd service + 設定 nginx proxy) +bash scripts/deploy-ec2.sh user@host --key key.pem --build --relay --relay-token SECRET + +# 本地 edge-ai-server 連接到雲端 relay +./edge-ai-server --relay-url ws://ec2-host:3800/tunnel/connect --relay-token SECRET +``` + ### 11.3 GoReleaser 設定 ```yaml @@ -2465,6 +3060,8 @@ builds: - arm64 env: - CGO_ENABLED=0 + tags: + - notray # CLI 發行版使用 stub(無系統匣) ldflags: - -s -w -X main.version={{.Version}} @@ -2473,8 +3070,20 @@ archives: format_overrides: - goos: windows format: zip + files: + - src: server/data/**/* # 模型定義 + NEF 檔案 + strip_parent: true + - src: server/scripts/**/* # Python bridge + detect + strip_parent: true + - src: server/firmware/**/* # Kneron firmware 檔案 + strip_parent: true ``` +**打包內容說明:** +- `data/`: models.json、custom-models/、nef/ 預訓練模型 +- `scripts/`: kneron_bridge.py(JSON-RPC bridge)、kneron_detect.py(USB 裝置偵測) +- `firmware/`: KL520 fw_scpu.bin/fw_ncpu.bin、KL720 韌體更新檔 + ### 11.4 Makefile ```makefile @@ -2497,7 +3106,18 @@ build-frontend: cp -r frontend/out server/frontend/out build-backend: build-frontend - cd server && go build -o ../dist/edge-ai-platform + cd server && CGO_ENABLED=0 go build -tags notray -o ../dist/edge-ai-platform + +# Tray-enabled build(安裝程式 payload 使用) +build-server-tray: build-frontend + cd server && CGO_ENABLED=1 go build -o ../dist/edge-ai-server-tray + +# Relay server +build-relay: + cd server && go build -o ../dist/relay-server ./cmd/relay-server + +# Installer payload(使用 tray-enabled build) +installer-payload: build-frontend build-server-tray # 跨平台打包 release: @@ -2839,4 +3459,15 @@ go.uber.org/zap // 結構化日誌 --- -*文件版本:v1.6 | 日期:2026-03-02 | 狀態:更新中* +*文件版本:v2.0 | 日期:2026-03-05 | 狀態:更新中* + +--- + +## 變更紀錄 + +| 版本 | 日期 | 變更內容 | +|------|------|---------| +| v2.0 | 2026-03-05 | 更新 8.5.18 token 傳遞方式為 URL query param(因 Chrome PNA 限制移除 localhost fetch);新增 8.5.19 新手引導體驗(F23);新增 8.5.20 自動版本檢查(F24);GoReleaser 打包加入 firmware + detect script | +| v1.9 | 2026-03-04 | 更新 8.5.16 中繼隧道為多租戶架構;新增 8.5.18 多租戶裝置隔離(F22):硬體 ID token 自動產生、relay multi-session map、前端自動 token 偵測、stream URL token 注入 | +| v1.8 | 2026-03-04 | 新增 8.5.17 系統匣啟動器(F21);更新 8.5.14 安裝程式:新增 Step 3 Relay 中繼設定、i18n 雙語支援(EN/zh-TW)、config.json 寫入步驟、開機自動啟動改用 `--tray` flag;更新專案目錄結構新增 `server/tray/`;Makefile 新增 `build-server-tray` 與 `installer-payload` 目標;GoReleaser 新增 `-tags notray` | +| v1.7 | 2026-03-04 | 前次更新 | diff --git a/.gitea/workflows/build-installer.yaml b/edge-ai-platform/.gitea/workflows/build-installer.yaml similarity index 100% rename from .gitea/workflows/build-installer.yaml rename to edge-ai-platform/.gitea/workflows/build-installer.yaml diff --git a/.gitignore b/edge-ai-platform/.gitignore similarity index 100% rename from .gitignore rename to edge-ai-platform/.gitignore diff --git a/.goreleaser.yaml b/edge-ai-platform/.goreleaser.yaml similarity index 100% rename from .goreleaser.yaml rename to edge-ai-platform/.goreleaser.yaml diff --git a/Makefile b/edge-ai-platform/Makefile similarity index 100% rename from Makefile rename to edge-ai-platform/Makefile diff --git a/edge-ai-platform/README.md b/edge-ai-platform/README.md new file mode 100644 index 0000000..46717b7 --- /dev/null +++ b/edge-ai-platform/README.md @@ -0,0 +1,116 @@ +# Edge AI Platform + +邊緣 AI 開發平台 — 管理 AI 模型、連接邊緣裝置(Kneron KL720/KL730)、即時攝影機推論。 + +單一執行檔,下載即可使用。 + +## Quick Start + +### macOS + +```bash +# 安裝(下載至 ~/.edge-ai-platform) +curl -fsSL https://gitea.innovedus.com/warrenchen/web_academy_prototype/raw/branch/main/scripts/install.sh | bash + +# 啟動(Mock 模式,不需硬體) +edge-ai-server --mock --mock-devices=3 + +# 開啟瀏覽器 +open http://127.0.0.1:3721 +``` + +### Windows (PowerShell) + +```powershell +# 安裝 +irm https://gitea.innovedus.com/warrenchen/web_academy_prototype/raw/branch/main/scripts/install.ps1 | iex + +# 啟動(Mock 模式) +edge-ai-server.exe --mock --mock-devices=3 + +# 開啟瀏覽器 +Start-Process http://127.0.0.1:3721 +``` + +### 手動下載 + +從 [Releases](https://gitea.innovedus.com/warrenchen/web_academy_prototype/releases) 下載對應平台的壓縮檔: + +| 平台 | 檔案 | +|:-----|:-----| +| macOS Intel | `edge-ai-platform_vX.Y.Z_darwin_amd64.tar.gz` | +| macOS Apple Silicon | `edge-ai-platform_vX.Y.Z_darwin_arm64.tar.gz` | +| Windows x64 | `edge-ai-platform_vX.Y.Z_windows_amd64.zip` | + +解壓後執行: + +```bash +# macOS +tar xzf edge-ai-platform_*.tar.gz +cd edge-ai-platform_*/ +./edge-ai-server --mock --mock-devices=3 + +# Windows: 解壓 zip,在資料夾中開啟 PowerShell +.\edge-ai-server.exe --mock --mock-devices=3 +``` + +然後開啟瀏覽器 http://127.0.0.1:3721 + +## 命令列選項 + +| Flag | 預設值 | 說明 | +|:-----|:------|:-----| +| `--port` | `3721` | 伺服器連接埠 | +| `--host` | `127.0.0.1` | 伺服器位址 | +| `--mock` | `false` | 啟用模擬裝置驅動 | +| `--mock-camera` | `false` | 啟用模擬攝影機 | +| `--mock-devices` | `1` | 模擬裝置數量 | +| `--log-level` | `info` | 日誌等級(debug/info/warn/error) | +| `--dev` | `false` | 開發模式(停用嵌入式前端) | + +## 可選依賴 + +以下工具可增強功能,但**非必要**: + +| 工具 | 用途 | macOS 安裝 | Windows 安裝 | +|:-----|:-----|:----------|:------------| +| `ffmpeg` | 攝影機擷取、影片處理 | `brew install ffmpeg` | `winget install Gyan.FFmpeg` | +| `yt-dlp` | YouTube / 影片 URL 解析 | `brew install yt-dlp` | `winget install yt-dlp` | +| `python3` | Kneron KL720 硬體驅動 | `brew install python3` | `winget install Python.Python.3.12` | + +啟動時會自動檢查並提示缺少的工具。 + +## 解除安裝 + +### macOS + +```bash +rm -rf ~/.edge-ai-platform +sudo rm -f /usr/local/bin/edge-ai-server +``` + +### Windows (PowerShell) + +```powershell +Remove-Item -Recurse -Force "$env:LOCALAPPDATA\EdgeAIPlatform" +# 手動從系統環境變數移除 PATH 中的 EdgeAIPlatform 路徑 +``` + +## 開發 + +```bash +# 安裝依賴 +make install + +# 啟動開發伺服器(前端 :3000 + 後端 :3721) +make dev + +# 編譯單一 binary +make build + +# 跨平台打包(本機測試,不發佈) +make release-snapshot + +# 發佈至 Gitea Release +make release +``` diff --git a/edge-ai-platform/docs/PRD-Integrated.md b/edge-ai-platform/docs/PRD-Integrated.md new file mode 100644 index 0000000..dd884c3 --- /dev/null +++ b/edge-ai-platform/docs/PRD-Integrated.md @@ -0,0 +1,1459 @@ +# 邊緣 AI 開發平台 — 產品需求文件(PRD) + +--- + +## 文件資訊 + +| 項目 | 內容 | +|------|------| +| 文件名稱 | 邊緣 AI 開發平台 PRD | +| 產品名稱 | (暫未定名,以下稱「本平台」) | +| 版本 | v2.7 | +| 日期 | 2026-03-02 | +| 狀態 | 更新中 | + +--- + +## 目錄 + +**Part A — 競品分析(Case Study)** + +- [A1. 競品概覽](#a1-競品概覽) +- [A2. 功能維度比較](#a2-功能維度比較) +- [A3. 使用者流程比較](#a3-使用者流程比較) +- [A4. 技術架構與裝置連線實作](#a4-技術架構與裝置連線實作) +- [A5. 商業模式與 SWOT](#a5-商業模式與-swot) +- [A6. 市場痛點與未滿足需求](#a6-市場痛點與未滿足需求) + - [A6.3 TOP 3 痛點深度解析](#a63-top-3-痛點深度解析) + +**Part B — 本平台產品設計** + +- [B1. 產品定位與目標](#b1-產品定位與目標) +- [B2. MVP 核心流程設計](#b2-mvp-核心流程設計) +- [B3. 系統架構設計](#b3-系統架構設計) + - [B3.7 硬體支援路線圖](#b37-硬體支援路線圖) + - [B3.8 硬體技術規格](#b38-硬體技術規格) +- [B4. MVP 功能規格](#b4-mvp-功能規格) + - [B4.5 已實作的額外功能](#b45-已實作的額外功能) +- [B5. 功能路線圖(Post-MVP)](#b5-功能路線圖post-mvp) +- [B6. 商業模式與策略](#b6-商業模式與策略) + +**附錄** + +- [C1. 術語對照表](#c1-術語對照表) +- [C2. 競品支援硬體清單](#c2-競品支援硬體清單) +- [C3. 參考來源](#c3-參考來源) + +--- + +# Part A — 競品分析(Case Study) + +> 本部分分析 **Edge Impulse Studio** 與 **SenseCraft AI** 兩個邊緣 AI 開發平台的功能、流程、技術架構和商業模式,作為本平台設計的參考依據。 + +--- + +## A1. 競品概覽 + +| 維度 | Edge Impulse Studio | SenseCraft AI | +|------|-------------------|---------------| +| **母公司** | Qualcomm Technologies(2025.3 收購) | Seeed Studio | +| **產品定位** | 專業級端到端邊緣 AI 開發平台 | 零程式碼視覺 AI 快速部署平台 | +| **核心訴求** | "Making edge AI accessible to every developer" | "Build Vision AI Sensors in 3 Steps" | +| **目標客群** | 嵌入式工程師、ML 工程師、IoT 開發者、企業 | 創客、AI 初學者、教育工作者、系統整合商 | +| **平台網址** | studio.edgeimpulse.com | sensecraft.seeed.cc/ai | +| **規模** | 250K+ 開發者、1K+ 企業 | 40K+ 硬體產品(軟體功能較新) | +| **定價** | Freemium(免費版 + 企業版客製報價) | 完全免費 | +| **API/SDK** | REST API + Python/Node.js/C++ SDK + CLI | 無公開 API/SDK | +| **多語系** | 僅英文 | 5 語(EN/簡中/法/西/日) | +| **前端框架** | React(推測) | Vue.js + Element UI | + +### 核心發現 + +- **Edge Impulse**:成熟、全方位,覆蓋從資料收集到部署的完整 MLOps 流程,支援 40+ 硬體。但**學習門檻高、免費方案受限、企業版定價不透明** +- **SenseCraft AI**:輕量、易用,強調三步驟零程式碼體驗。但**僅限視覺 AI、硬體鎖定 Seeed 自家產品、功能初級** +- **共同缺口**:兩者都**嚴重缺乏部署後監控、模型漂移偵測、主動學習閉環**等 MLOps 後半段功能 + +--- + +## A2. 功能維度比較 + +### A2.1 共同功能 + +| 共同功能 | Edge Impulse 實作 | SenseCraft AI 實作 | +|---------|-------------------|-------------------| +| 無程式碼模型訓練 | 視覺化 Impulse 設計器 | 三步驟引導介面 | +| 影像分類訓練 | Classification (Keras) + Transfer Learning | TensorFlow (瀏覽器端 WebGL) | +| 物件偵測訓練 | FOMO / MobileNetV2 SSD / YOLO-Pro | YOLO-World (伺服器端) | +| 預訓練模型庫 | 公開專案庫 | 658+ 預訓練模型 | +| 裝置連線 | Web Serial API + daemon | Web Serial API (USB) | +| 即時推論預覽 | Live Classification 頁面 | Workspace Preview 面板 | +| 模型部署到裝置 | 多格式下載 + 燒錄 | 一鍵 Serial 燒錄 | + +### A2.2 Edge Impulse 獨有功能 + +| 類別 | 功能 | 說明 | +|------|------|------| +| 感測器 | 多類型支援 | 音訊、IMU、心率、環境感測器、時間序列、表格 | +| 資料收集 | 合成資料生成 | DALL-E / Whisper / ElevenLabs | +| | AI 輔助標記 | GPT-4o / OWL-ViT / 自訂 LLM | +| | 雲端匯入 | Amazon S3 整合 | +| 特徵工程 | 處理區塊 | Spectral / Spectrogram / MFCC 等 10+ 區塊 | +| 模型訓練 | Expert Mode | 完整 Keras API 存取 | +| | BYOM | 匯入 TFLite / ONNX / SavedModel | +| 最佳化 | EON Tuner / Compiler | 貝氏搜尋 + 最佳化 C++ 生成(省 65% RAM) | +| 部署 | 15+ 格式 | Arduino / WebAssembly / Docker / TensorRT 等 | +| | 40+ 硬體 | Arduino / Nordic / Espressif / STM / NVIDIA 等 | +| 協作 | 團隊功能 | 協作者、Organization Hub、版本控管 | +| API | 完整 REST API | OpenAPI + CLI 工具群 + 多語言 SDK | + +### A2.3 SenseCraft AI 獨有功能 + +| 類別 | 功能 | 說明 | +|------|------|------| +| 模型市集 | 658+ 模型庫 | 含篩選、模型卡片、按讚/收藏 | +| 應用商店 | 12+ 即用範本 | Home Assistant / Telegram / n8n 整合 | +| 快速訓練 | 文字即訓練 | YOLO-World 僅需輸入名稱即可偵測 | +| Workspace | 統一裝置管理 | 三欄佈局、Logger、WiFi/MQTT 配置 | +| 輸出 | 多協定原生支援 | MQTT / GPIO / Serial / LoRaWAN | +| UX | 完全免費 + 多語系 | 無付費牆、5 種語言 | + +### A2.4 功能差異矩陣 + +| 功能維度 | Edge Impulse | SenseCraft AI | 優勢方 | +|---------|:-----------:|:------------:|:------:| +| 感測器類型廣度 | 音訊/IMU/影像/時序/心率/表格 | 僅影像(+初步振動/音訊) | EI | +| 硬體支援廣度 | 40+ 開發板,多廠商 | 5-6 款 Seeed 裝置 | EI | +| 模型類型多樣性 | 分類/偵測/迴歸/異常偵測/關鍵字 | 分類/偵測/分割 | EI | +| 特徵工程 | 10+ 處理區塊 | 無 | EI | +| 模型最佳化 | EON Tuner + Compiler + 量化 | 無 | EI | +| 部署格式 | 15+ 格式 | 僅裝置燒錄 | EI | +| API/SDK | REST API + 多語言 SDK + CLI | 無 | EI | +| --- | --- | --- | --- | +| 預訓練模型數量 | 公開專案 | 658+ 模型 | SC | +| 應用商店/範本 | 無 | 12+ 即用應用 | SC | +| 文字即訓練 | 無 | YOLO-World | SC | +| 入門門檻 | 中高 | 極低 | SC | +| 免費方案完整度 | 受限 | 完全免費 | SC | +| IoT 輸出整合 | 需自行實作 | MQTT/GPIO/Serial/LoRa 原生 | SC | +| --- | --- | --- | --- | +| 部署後監控 | 僅企業版 | 無 | 雙方均弱 | +| 模型漂移偵測 | 無 | 無 | 雙方均無 | +| 主動學習 | 無 | 無 | 雙方均無 | +| OTA 模型更新 | 有限 | 無 | 雙方均弱 | + +--- + +## A3. 使用者流程比較 + +### A3.1 核心工作流程對照 + +``` +Edge Impulse(11 步驟,專業完整) SenseCraft AI(3-6 步驟,極簡快速) +═══════════════════════════════ ═══════════════════════════════ + +1. 建立專案 1. 瀏覽模型庫 / 選擇預訓練模型 + ├── 選擇標記方法 或 進入 Training 頁面 + └── 設定目標硬體 + 2. 連接裝置 (USB Serial) +2. 收集/上傳資料 ├── 選擇裝置類型 + ├── 裝置 / 手機 / 檔案上傳 └── 瀏覽器 Serial Port 選擇 + ├── 合成資料生成 + └── 雲端匯入 3. 部署模型到裝置 + └── 一鍵燒錄 +3. 標記資料 + ├── 手動 / AI 輔助 (GPT-4o) --- 如果需要訓練 --- + └── 匯入預標記資料 + 4. 建立類別 / 輸入目標名稱 +4. 設計 Impulse + ├── Input / Processing / Learning Block 5. 擷取訓練影像 或 僅輸入文字 + +5. 產生特徵 + Feature Explorer 6. Start Training + ├── 瀏覽器端 (分類) +6. 訓練模型 └── 伺服器端 (偵測) + ├── NN 架構 / Expert Mode / EON Tuner + --- 結束 --- +7. 驗證(Model Testing / Live Classification) + +8. 最佳化(量化 / EON Compiler) + +9. 部署(選格式 → Build → Download) + +10. 版本控管 + +11. 監控與迭代 +``` + +### A3.2 關鍵差異 + +| 面向 | Edge Impulse | SenseCraft AI | +|------|-------------|---------------| +| 步驟數 | 11 步(完整 MLOps) | 3-6 步(快速部署) | +| 上手時間 | 數小時到數天 | 數分鐘到數小時 | +| 靈活度 | 高(每步可深度自訂) | 低(固定流程) | +| 狀態閘門 | 嚴格(需資料→需訓練→才能部署) | 寬鬆(可直接部署預訓練模型) | +| 訓練執行 | 雲端運算 | 瀏覽器端 / 伺服器端 | + +--- + +## A4. 技術架構與裝置連線實作 + +### A4.1 技術架構比較 + +| 架構面向 | Edge Impulse | SenseCraft AI | +|---------|-------------|---------------| +| 前端框架 | React(推測) | Vue.js + Element UI | +| 渲染方式 | 伺服器端 + 客戶端混合 | 純 SPA (CSR) | +| 認證 | JWT + API Key + OAuth (Google/GitHub) | 跨域 iframe Cookie 共享 | +| 訓練後端 | 雲端 GPU 叢集 | 瀏覽器端 WebGL / 雲端 YOLO-World | +| 裝置連線 | Web Serial API + Node.js daemon + WebSocket | Web Serial API | +| 模型格式 | TFLite / ONNX / SavedModel + EON C++ | TFLite / ONNX / TensorRT / PyTorch | +| API | REST API (OpenAPI) + WebSocket | 無公開 API | +| 瀏覽器需求 | Chrome/Edge(瀏覽器模式);daemon 不限 | 僅 Chrome/Edge/Opera | + +### A4.2 SenseCraft AI — Web Serial API 實作 + +**技術方案:** 完全使用瀏覽器原生 Web Serial API(`navigator.serial`),零安裝。 + +**原始碼驗證:** + +| 裝置 | 原始碼位置 | 鮑率 | +|------|-----------|------| +| Grove Vision AI V2 | `SenseCraft-Web-Toolkit/src/sscma/grove_ai_we2/deviceHimax.ts` | 921600 | +| XIAO ESP32S3 | `SenseCraft-Web-Toolkit/src/sscma/xiao_esp32s3/EspSerialDevice.ts` | 115200 | + +**連線流程(Grove Vision AI V2):** + +``` +瀏覽器 (Chrome/Edge/Opera) + │ + ├── 1. navigator.serial.requestPort({ filters }) ← 使用者選擇裝置 + ├── 2. serial.open({ baudRate: 921600 }) + ├── 3. setRTS(false) → delay(100ms) → setRTS(true) [Hard Reset] + ├── 4. readLoop() ← 持續讀取,解析 \r{JSON}\n 格式回應 + └── 5. write() ← 發送 AT 命令或韌體資料 +``` + +**韌體燒錄:** Grove Vision AI V2 使用 Xmodem 協定;ESP32S3 使用 `esptool-js`(ESPLoader 協定)。 + +**優點與限制:** + +| 優點 | 限制 | +|------|------| +| 零安裝,開箱即用 | 僅 Chrome/Edge/Opera(無 Firefox/Safari) | +| 瀏覽器沙箱安全模型 | 每次開頁面需重新選裝置 | +| 無跨平台安裝問題 | 無法背景執行(關頁面即斷線) | +| 原始碼開源可參考 | 無法做裝置自動偵測 | + +### A4.3 Edge Impulse — 雙模式架構 + +#### 模式 A:瀏覽器直連 + +UI 標示為「Connect using WebUSB」,但實際使用 **Web Serial API**(非 WebUSB)。適用瀏覽器中輕量資料收集。 + +#### 模式 B:Node.js Daemon(主要模式) + +透過 npm 全域安裝的 CLI daemon,使用 `serialport` npm 套件(v8.0.8,C++ 原生模組),並透過 WebSocket 連線雲端。 + +**安裝需求:** Node.js 16+ / Python 3 / `npm install -g edge-impulse-cli`(需 node-gyp + C++ 編譯器) + +**原始碼驗證:** + +| 檔案 | 關鍵實作 | +|------|---------| +| `cli/serial-connector.ts` | `require('serialport')` — Node.js 串列埠通訊 | +| `cli/daemon.ts` | `new WebSocket(url)` — 連線 `wss://remote-mgmt.edgeimpulse.com` | + +**Daemon 架構:** + +``` +┌──────────────────┐ WSS ┌─────────────────┐ Serial ┌──────────────┐ +│ EI Studio (雲端) │◄═══════►│ Local Node.js │◄════════►│ Edge Device │ +│ 資料/訓練/部署 │ │ Daemon │ │ (MCU) │ +└──────────────────┘ │ serialport npm │ └──────────────┘ + └─────────────────┘ +``` + +**安裝痛點:** Node.js 版本依賴、原生模組需 C++ 編譯器、Python 依賴、全域安裝權限問題、跨平台差異。 + +### A4.4 兩平台連線技術比較 + +| 維度 | SenseCraft (Web Serial) | EI Daemon (Node.js) | +|------|------------------------|---------------------| +| 安裝需求 | 無 | Node.js + npm + C++ 編譯器 | +| 底層技術 | `navigator.serial` | `serialport` npm | +| 連線穩定性 | 受瀏覽器限制 | 原生,穩定性高 | +| 背景執行 | 不可 | 可 | +| 裝置偵測 | 手動選擇 | 自動偵測 | +| 鮑率切換 | 需斷線重連 | 動態切換 | +| 瀏覽器限制 | Chrome/Edge/Opera only | 無限制 | +| 適合場景 | 快速原型、教育 | 長時間收集、量產 | + +--- + +## A5. 商業模式與 SWOT + +### A5.1 定價模式 + +| 項目 | Edge Impulse | SenseCraft AI | +|------|-------------|---------------| +| 免費方案 | 3 專案 / 60 分鐘運算 / 3 協作者 / IP 歸平台 | 完全免費、無限制 | +| 付費方案 | Enterprise(客製報價):無限運算、SSO、白標 | 無 | +| 收入模式 | SaaS 訂閱 + 企業授權 | 硬體銷售帶動 | + +### A5.2 生態系 + +| 維度 | Edge Impulse | SenseCraft AI | +|------|-------------|---------------| +| 硬體生態 | 硬體中立(40+ 廠商) | Seeed 自有硬體 | +| 軟體生態 | 獨立平台 | SenseCraft 生態系(AI/HMI/Data/App) | +| 社群 | 250K+ 開發者、論壇、Discord | 模型分享社群、Dify 聊天機器人 | +| 開源 | 部分 SDK/CLI 開源 | Web Toolkit 開源 | + +### A5.3 SWOT 分析 + +**Edge Impulse** + +| | 正面 | 負面 | +|---|------|------| +| 內部 | 完整 MLOps、多感測器、40+ 硬體、EON 最佳化、250K+ 社群 | 學習曲線陡、免費版受限、企業版定價不透明、平台鎖定 | +| 外部 | Qualcomm 資源、邊緣 AI 市場成長 | 社群擔心硬體鎖定 Qualcomm、開源替代方案興起 | + +**SenseCraft AI** + +| | 正面 | 負面 | +|---|------|------| +| 內部 | 極低門檻、完全免費、658+ 模型、YOLO-World、多語系 | 僅視覺 AI、僅 Seeed 硬體、無 API/SDK、無版本控管 | +| 外部 | 創客/教育市場成長、模型市集先發 | 專業平台降門檻、硬體鎖定限天花板 | + +--- + +## A6. 市場痛點與未滿足需求 + +### A6.1 痛點概覽 + +``` + 嚴重程度 + 極高 ─────── 低 + │ + ┌──────────────┼──────────────────┐ + │ │ │ + │ [5] 監控維運 │ [1] 資料收集 │ + │ ● 部署後黑箱 │ ● 遠端收集困難 │ + │ ● 無漂移偵測 │ ● 品質檢測不足 │ + │ ● 無主動學習 │ │ + │ │ [2] 資料標記 │ + │ [4] 部署 │ ● 手動標記耗時 │ + │ ● OTA 不足 │ ● 協作標記缺乏 │ + │ ● 跨硬體難 │ │ + │ │ [3] 模型訓練 │ + │ [6] 協作 │ ● 簡易vs彈性矛盾 │ + │ ● 免費版受限 │ ● 可解釋性不足 │ + │ ● 無 CI/CD │ │ + └──────────────┴──────────────────┘ +``` + +### A6.2 最嚴重的痛點 TOP 10 + +| # | 痛點 | 嚴重度 | 兩平台現況 | +|:-:|------|:-----:|-----------| +| 1 | 部署後監控完全空白 | 極高 | EI: 僅企業版 / SC: 無 | +| 2 | 缺乏模型漂移偵測 | 極高 | 雙方均無 | +| 3 | 無主動學習閉環 | 極高 | 雙方均無 | +| 4 | OTA 模型更新不足 | 高 | 雙方均弱 | +| 5 | 多感測器資料融合困難 | 高 | EI: 有限 / SC: 無 | +| 6 | 協作標記缺乏 | 高 | EI: 限企業版 / SC: 無 | +| 7 | 簡易性與彈性的矛盾 | 高 | 雙方均有 | +| 8 | 訓練結果可解釋性不足 | 高 | 雙方均缺 | +| 9 | 缺乏持續訓練管道 | 高 | 雙方均無 | +| 10 | 跨硬體移植成本高 | 高 | 雙方均有 | + +### A6.3 TOP 3 痛點深度解析 + +以下針對排名前三的痛點進行深度解析,這三者環環相扣,構成完整的「模型維運(MLOps)後半段」。 + +#### 痛點 #1 — 部署後監控完全空白 + +**問題本質:** 模型燒錄到裝置後變成「黑箱」,無法得知它在現場跑得好不好。 + +目前不管是 Edge Impulse 還是 SenseCraft,模型部署後都缺乏以下資訊: +- 模型每天做了多少次推論? +- 信心分數的分佈如何?是否大量推論低於門檻? +- 裝置的 CPU/RAM 使用率、溫度是否正常? +- 裝置有沒有離線?離線多久了? + +**具體場景:** + +``` +情境:把「瑕疵檢測模型」部署到工廠產線的 10 台裝置 + +沒有監控: +├── 某台裝置的攝影機鏡頭髒了 → 推論全錯 → 沒人知道 +├── 某台裝置記憶體不足偶爾崩潰 → 漏檢 50 件瑕疵品 → 客訴才發現 +└── 想知道模型整體表現 → 只能一台一台去現場看 + +有監控: +├── 儀表板即時顯示:10 台裝置、今日推論 12,340 次、平均信心 87% +├── 告警:「裝置 #3 過去 1 小時信心分數異常下降,請檢查」 +└── 趨勢圖:「本週整體準確率從 92% 降到 85%」→ 該更新模型了 +``` + +**兩平台現況:** + +| 平台 | 部署後監控能力 | +|------|--------------| +| Edge Impulse | 僅 Enterprise 版提供有限的裝置管理,免費版完全沒有 | +| SenseCraft AI | 完全沒有。模型燒錄後與平台斷開連結 | + +**類比:** 就像部署了一個 Web 服務後需要 Datadog / Grafana 做監控,AI 模型部署後也需要類似的可觀測性(observability),但目前沒有平台提供。 + +--- + +#### 痛點 #2 — 缺乏模型漂移偵測 + +**問題本質:** 模型訓練時的資料和實際運行時碰到的資料不一樣了,導致模型效能退化,但沒有機制自動偵測。 + +模型是用「某個時間點」收集的資料訓練的,但現實環境會變化。模型本身沒變,但輸入的資料分佈改變了,準確率就會悄悄下降。 + +**兩種漂移類型:** + +| 類型 | 定義 | 例子 | +|------|------|------| +| **Data Drift(資料漂移)** | 輸入資料的統計分佈改變 | 訓練時用白天影像,實際運行有夜間場景;季節變化導致光線不同 | +| **Concept Drift(概念漂移)** | 輸入和目標之間的關係改變 | 「好產品」的定義變了(品質標準提高),但模型還用舊標準判定 | + +**具體場景:** + +``` +情境:訓練了一個「農作物病蟲害偵測」模型,春天部署到田間 + +春天(訓練時): +├── 光線充足、葉子翠綠、特定害蟲種類 +└── 模型準確率 95% ✅ + +三個月後(夏天): +├── 光線角度不同、葉子更大更深綠、出現新害蟲種類 +├── 模型準確率悄悄降到 70% ❌ +└── 沒有偵測機制 → 沒人知道 + +又三個月後(秋天): +├── 落葉枯黃、光線更暗 +├── 模型準確率降到 50%(跟隨機猜差不多)❌❌ +└── 農民發現漏報太多才來投訴 → 為時已晚 +``` + +**偵測方法(業界已知但尚未應用在邊緣 AI 平台):** + +``` +邊緣端(裝置上,輕量): +├── 監控推論信心分數的滑動平均 +├── 信心分數持續下降 → 發出告警 +└── 統計測試(KS 檢定、KL 散度)比較近期 vs 歷史分佈 + +雲端(上報後,精細): +├── 比較訓練時的特徵分佈 vs 近期推論的特徵分佈 +├── 視覺化報告:哪些特徵漂移了、漂移幅度 +└── 自動分類:是 data drift 還是 concept drift +``` + +**兩平台現況:** + +| 平台 | 漂移偵測能力 | +|------|------------| +| Edge Impulse | 完全沒有。即使企業版也無此功能 | +| SenseCraft AI | 完全沒有 | + +**類比:** 漂移偵測就像汽車的儀表板警示燈——在引擎嚴重損壞之前提醒你該做保養了。目前的邊緣 AI 平台等於開車沒有儀表板。 + +--- + +#### 痛點 #3 — 無主動學習閉環 + +**問題本質:** 偵測到模型效能下降後,目前沒有自動化機制來修復,只能靠大量人工手動操作。 + +即使偵測到了漂移(假設有監控的話),目前的解決方案是: + +``` +手動去現場收集新資料 → 手動標記 → 手動重新訓練 → 手動燒錄到每台裝置 +``` + +這個流程太慢、太貴,尤其當裝置數量超過幾十台時根本不可行。 + +**主動學習閉環的完整流程:** + +``` +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐│ +│ │ 1. 裝置 │ │ 2. 篩選 │ │ 3. 標記 │ │4. 訓練 ││ +│ │ 執行推論 │───►│ 低信心樣本│───►│ 人工確認 │───►│重新訓練││ +│ │ │ │ 自動回傳 │ │ (AI 預標記│ │ ││ +│ │ │ │ │ │ 人工修正) │ │ ││ +│ └─────────┘ └──────────┘ └──────────┘ └───┬────┘│ +│ ▲ │ │ +│ │ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ 6. OTA │ │ 5. 驗證 │ │ │ +│ └──────────│ 更新模型 │◄───│ 新舊比較 │◄──────┘ │ +│ │ 到裝置 │ │ 準確率提升 │ │ +│ │ │ │ 才推送 │ │ +│ └──────────┘ └──────────┘ │ +│ │ +└──────────────── 持續循環,模型越來越好 ──────────────────────┘ +``` + +**各步驟說明:** + +| 步驟 | 動作 | 自動化程度 | +|------|------|-----------| +| 1. 裝置推論 | 模型在裝置上執行推論 | 全自動 | +| 2. 低信心樣本篩選 | 信心 < 門檻的樣本自動暫存,連網後批次上傳(省頻寬) | 全自動 | +| 3. 人機標記 | AI 先預標記 → 人工快速確認或修正(比從零標記快 10 倍) | 半自動 | +| 4. 重新訓練 | 累積足夠新資料後自動觸發訓練 | 全自動 | +| 5. 驗證 | 新舊模型自動比較,新模型更好才核准 | 全自動 | +| 6. OTA 更新 | 新模型無線推送到所有裝置,無需人跑去現場燒錄 | 全自動 | + +**具體場景:** + +``` +接續農作物的例子,有了主動學習閉環: + +夏天,模型看到一張影像,信心只有 45%(低於門檻 70%) +→ 判定為「不確定的樣本」,自動上傳到雲端 + +雲端 AI 預標記:「這可能是新種害蟲」 +→ 農業專家只需點「確認」或修正標記(10 秒搞定) + +累積 200 張新標記後,系統自動觸發重新訓練 +→ 新模型準確率 93%(比漂移後的 70% 好很多) + +系統自動比較:新 93% > 舊 70% → 核准部署 +→ OTA 無線推送到所有田間裝置 + +結果:模型隨環境變化自動適應,不需要人跑去田裡 +``` + +**為什麼叫「主動」學習?** 傳統 ML 是被動的——人類決定什麼時候收集資料。主動學習是**模型主動告訴你它不確定什麼**,讓你只標記最有價值的資料,用最少人力達到最大改善。 + +**兩平台現況:** + +| 平台 | 主動學習能力 | +|------|------------| +| Edge Impulse | 完全沒有。有 API 可以手動上傳新資料,但無自動化閉環 | +| SenseCraft AI | 完全沒有。甚至沒有 API 可以程式化上傳資料 | + +--- + +#### 三者的關係 + +``` +部署後監控 ──── 發現問題 ───► 漂移偵測 ──── 確認原因 ───► 主動學習 ──── 解決問題 + │ │ │ + │ 「模型表現在下降」 │ 「因為環境變了」 │ 「自動修復」 + │ │ │ + └── 可觀測性(眼睛)────────┴── 診斷(大腦)────────────┴── 治療(手) +``` + +這三者串起來就是完整的「模型維運後半段」,也是目前**所有邊緣 AI 平台最大的功能空白**。任何平台若能率先提供這個閉環,將在市場上形成極強的差異化。 + +--- + +### A6.4 按使用者角色的痛點 + +| 角色 | 最痛的問題 | +|------|-----------| +| AI 初學者/創客 | 訓練效果不佳不知如何改善;免費資源不夠用 | +| 嵌入式工程師 | 部署整合工作量大;OTA 困難;跨硬體移植成本高 | +| ML 工程師 | 缺乏實驗追蹤;無 CI/CD;平台鎖定 | +| 產品經理/企業 | 部署後監控空白;模型漂移偵測無;協作不足 | +| 教育工作者 | EI 門檻高;SC 功能太少無法教進階概念 | + +--- + +# Part B — 本平台產品設計 + +> 本部分定義本平台的產品定位、核心流程、系統架構和功能規格。 + +--- + +## B1. 產品定位與目標 + +### B1.1 市場定位 + +``` + 入門門檻低 ──────────────────── 入門門檻高 + │ │ + │ SenseCraft AI │ Edge Impulse + │ ● 極簡三步驟 │ ● 完整 MLOps + │ ● 僅視覺 AI │ ● 多感測器 + │ ● 硬體鎖定 │ ● 硬體中立 + │ │ + │ ┌──────────────┐ │ + │ │ 本平台 │ │ + │ │ 定位 │ │ + │ │ │ │ + │ │ 低門檻 │ │ + │ │ + 開放硬體 │ │ + │ │ + 漸進深度 │ │ + │ └──────────────┘ │ + │ │ + 功能簡單 ──────────────────── 功能完整 +``` + +### B1.2 目標客群 + +| 客群 | 描述 | 核心需求 | +|------|------|---------| +| **主要** | 嵌入式/IoT 開發者 | 快速將 AI 模型部署到裝置,驗證效果 | +| **主要** | AI 初學者與創客 | 低門檻體驗邊緣 AI,零程式碼上手 | +| **次要** | 系統整合商 | 快速為客戶建置 AI 感測方案 | +| **次要** | 教育工作者 | 用於教學與工作坊的即時展示工具 | + +### B1.3 核心價值主張 + +> **三步驟將預訓練 AI 模型部署到任何邊緣裝置,並即時驗證推論效果。** + +- **簡單**:選模型 → 燒錄 → 看結果,三步完成 +- **開放**:初期以 Kneron 晶片為核心,未來擴展至多廠商硬體 +- **即時**:燒錄完成後立即在裝置上執行推論,結果即時回傳前端顯示 +- **本地化**:透過本地端 Backend Server 操作裝置,穩定、安全、不受瀏覽器限制 + +### B1.4 差異化策略 + +| 策略 | 對比競品的差異 | +|------|--------------| +| **本地端 Server 架構** | SenseCraft 受 Web Serial API 瀏覽器限制;EI daemon 安裝痛苦。本平台使用單一執行檔,零依賴安裝 | +| **Kneron 晶片深度整合 → 逐步開放** | MVP 以 Kneron Dongle 為首要支援硬體,深度整合 Kneron 工具鏈;透過抽象層設計,未來擴展至其他廠商晶片 | +| **預訓練模型優先** | EI 強調從零訓練的完整流程。本平台 MVP 聚焦「選模型→部署→驗證」的最短路徑 | +| **即時推論視覺化** | 將裝置端推論結果即時串流到前端,提供直觀的效果驗證體驗 | + +--- + +## B2. MVP 核心流程設計 + +### B2.1 核心流程總覽 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Step 1 │ │ Step 2 │ │ Step 3 │ +│ 選擇預訓練模型 │ ──► │ 燒錄到硬體 │ ──► │ 裝置端即時推論 │ +│ │ │ │ │ │ +│ ● 瀏覽模型庫 │ │ ● 連接裝置 │ │ ● 啟動推論 │ +│ ● 篩選/搜尋 │ │ ● 選擇目標裝置 │ │ ● 即時結果串流 │ +│ ● 查看模型資訊 │ │ ● 一鍵燒錄 │ │ ● 視覺化顯示 │ +│ ● 確認相容性 │ │ ● 進度回報 │ │ ● 參數調整 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### B2.2 Step 1 — 選擇預訓練模型 + +**使用者故事:** 身為開發者,我希望能快速找到適合我裝置和應用場景的預訓練模型。 + +**流程:** + +``` +1. 進入模型庫頁面 +2. 篩選條件: + ├── 任務類型(影像分類 / 物件偵測 / ...) + ├── 目標硬體(或自動偵測已連線裝置) + └── 類別(人臉 / 車輛 / 工業 / 農業 / ...) +3. 瀏覽模型卡片 + ├── 模型名稱 + 縮圖 + ├── 支援的硬體清單 + ├── 效能指標(準確度 / 延遲 / 模型大小) + └── 描述與使用場景 +4. 選擇模型 → 進入詳情頁 +5. 確認硬體相容性 → 點擊「部署到裝置」 +``` + +**功能需求:** + +| 需求 | 說明 | 優先級 | +|------|------|:------:| +| 模型庫瀏覽 | 卡片式列表,支援分頁或無限捲動 | P0 | +| 篩選與搜尋 | 依任務類型、硬體、關鍵字篩選 | P0 | +| 模型卡片 | 顯示名稱、縮圖、任務類型、支援硬體、大小 | P0 | +| 模型詳情頁 | 完整描述、效能指標、支援硬體、使用範例 | P0 | +| 硬體相容性檢查 | 選擇模型時自動比對已連線裝置是否相容 | P1 | +| 模型下載 | 從模型庫下載模型檔到本地 | P0 | + +**驗收標準:** +- 使用者可在 30 秒內找到適合的模型 +- 篩選後的結果正確反映篩選條件 +- 模型詳情頁包含足夠資訊讓使用者判斷是否適用 + +### B2.3 Step 2 — 燒錄到硬體 + +**使用者故事:** 身為開發者,我希望能一鍵將選好的模型燒錄到我的裝置上。 + +**流程:** + +``` +1. 確認裝置已透過 USB 連接電腦 +2. 本地端 Server 自動偵測裝置 + ├── 顯示裝置類型、連接埠、狀態 + └── 若未偵測到 → 提示安裝驅動或手動選擇 +3. 選擇目標裝置(若有多個) +4. 點擊「燒錄」 +5. 燒錄過程: + ├── 前端顯示進度條(透過 WebSocket 即時更新) + ├── 進入 bootloader(自動處理 RTS/DTR 信號) + ├── 傳輸模型檔(Xmodem / esptool / 其他協定) + └── 重啟裝置 +6. 燒錄完成 → 自動跳轉到 Step 3 +``` + +**功能需求:** + +| 需求 | 說明 | 優先級 | +|------|------|:------:| +| 裝置自動偵測 | 本地 Server 偵測 USB 裝置並回報前端 | P0 | +| 裝置列表 | 顯示所有已連線裝置的類型、埠號、狀態 | P0 | +| 一鍵燒錄 | 點擊按鈕即開始燒錄流程 | P0 | +| 燒錄進度 | WebSocket 即時回報進度百分比 | P0 | +| 燒錄協定支援 | 支援目標硬體的燒錄協定(Xmodem / esptool / ...) | P0 | +| 錯誤處理 | 燒錄失敗時清楚的錯誤訊息與重試選項 | P0 | +| 裝置熱插拔 | 裝置插拔時即時更新裝置列表 | P1 | + +**驗收標準:** +- 裝置接上 USB 後 5 秒內出現在裝置列表 +- 燒錄過程中進度條平滑更新 +- 燒錄失敗時提供可操作的錯誤訊息(非技術性 stack trace) +- 燒錄完成後裝置自動重啟並進入推論模式 + +### B2.4 Step 3 — 裝置端即時推論 + +**使用者故事:** 身為開發者,我希望燒錄完成後立即看到模型在裝置上的推論結果,以驗證效果。 + +**流程:** + +``` +1. 燒錄完成後,裝置重啟進入推論模式 +2. 本地 Server 與裝置建立串列通訊 +3. 裝置執行推論並透過串列埠回傳結果 +4. 本地 Server 透過 WebSocket 將結果即時推送到前端 +5. 前端顯示推論結果: + ├── 影像分類:類別名稱 + 信心分數 + 即時圖表 + ├── 物件偵測:邊界框 + 類別 + 信心分數(疊加在影像上) + └── 推論延遲、FPS 等效能指標 +6. 使用者可即時調整: + ├── Confidence Threshold 滑桿 + └── 其他模型參數(視裝置支援而定) +``` + +**功能需求:** + +| 需求 | 說明 | 優先級 | +|------|------|:------:| +| 推論結果串流 | 裝置端推論結果透過 WebSocket 即時顯示 | P0 | +| 分類結果顯示 | 類別名稱 + 信心分數 + 即時長條圖 | P0 | +| 偵測結果顯示 | 邊界框繪製 + 類別標籤(若裝置有攝影機輸出) | P1 | +| 效能指標 | 推論延遲 (ms)、FPS | P0 | +| Confidence Threshold | 可調整的信心門檻滑桿 | P1 | +| 推論啟停控制 | 開始/停止推論的按鈕 | P0 | +| 裝置日誌 | 即時串列通訊日誌(展開/收合) | P1 | + +**驗收標準:** +- 燒錄完成到首次推論結果顯示 < 5 秒 +- 推論結果更新延遲 < 200ms(從裝置到前端顯示) +- Confidence Threshold 調整即時生效 +- 推論過程中頁面不卡頓 + +### B2.5 使用者旅程圖 + +``` +使用者情境:開發者想用 Kneron Dongle 部署一個人臉偵測模型 + +時間軸 ──────────────────────────────────────────────────► + +[安裝階段] [使用階段] + Step 1 Step 2 Step 3 + 下載安裝 選擇模型 燒錄 即時推論 + 本地 Server ───── ───── ───── + │ │ │ │ + ▼ ▼ ▼ ▼ + 執行安裝檔 開啟瀏覽器 Dongle 自動偵測 結果即時顯示 + (1 分鐘) 瀏覽模型庫 點擊「燒錄」 「偵測到人臉 + 篩選:偵測 進度 10%→100% 信心 92%」 + 啟動 Server 選:人臉偵測 裝置就緒 + (自動) 查看詳情+相容性 調整 Threshold + 點「部署」 查看 FPS + + ◄── 約 1 分鐘 ──► ◄── 約 1 分鐘 ──► ◄── 約 1 分鐘 ──► ◄── 持續 ──► + +情緒曲線: + 😐 安裝 😊 找到模型 😊 一鍵搞定 🎉 成功了! + 好簡單 正在燒錄... 效果不錯 +``` + +--- + +## B3. 系統架構設計 + +### B3.1 整體架構 + +``` +┌────────────────────────────────────────────────────────────────┐ +│ 使用者電腦 │ +│ │ +│ ┌──────────────────┐ HTTP/WS ┌──────────────────────┐ │ +│ │ 前端 Web App │◄═════════════►│ 本地端 Backend Server │ │ +│ │ (瀏覽器) │ localhost:PORT │ │ │ +│ │ │ │ ● 裝置偵測/管理 │ │ +│ │ ● 模型庫瀏覽 │ │ ● 串列埠通訊 │ │ +│ │ ● 燒錄介面 │ │ ● 韌體燒錄 │ │ +│ │ ● 推論結果顯示 │ │ ● 推論結果轉發 │ │ +│ │ ● 裝置狀態 │ │ ● 模型檔管理 │ │ +│ └──────────────────┘ └──────────┬───────────┘ │ +│ │ Serial/USB │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Edge Device │ │ +│ │ (MCU / SBC) │ │ +│ └──────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ + │ + │ HTTPS(選用) + ▼ +┌──────────────────┐ +│ 雲端服務(選用) │ +│ ● 模型儲存庫 │ +│ ● 使用者帳號 │ +└──────────────────┘ +``` + +### B3.2 本地端 Backend Server + +**開發語言選擇:** + +| 方案 | 優點 | 缺點 | 評估 | +|------|------|------|------| +| **Go 單一執行檔**(推薦) | 跨平台編譯簡單、無 runtime、單一 binary、串列埠函式庫成熟(`go.bug.st/serial`) | ML 生態系較弱(但本 Server 不做 ML) | 最適合 | +| **Rust 單一執行檔** | 效能極佳、無 runtime、`serialport-rs` 成熟 | 學習曲線陡、開發速度慢 | 備選 | +| **Node.js** | 開發快、前後端統一 | 需安裝 runtime、原生模組編譯問題 | 不建議(重蹈 EI 痛點) | +| **Python** | ML 生態強、開發快 | 需 runtime、打包體積大 | 可考慮 | +| **Tauri** | 桌面應用框架、原生存取 | 需安裝桌面應用、非 Web 架構 | 備選 | + +**推薦方案:Go 編譯為單一執行檔**,使用者僅需下載一個檔案即可執行。 + +### B3.3 前後端通訊協定 + +**HTTP REST API — request/response 操作:** + +``` +GET /api/devices → 列出已連線裝置 +GET /api/devices/:id → 取得裝置詳細資訊 +POST /api/devices/:id/connect → 連線到裝置 +POST /api/devices/:id/disconnect → 中斷裝置連線 +POST /api/devices/:id/flash → 燒錄韌體/模型 +GET /api/devices/:id/status → 取得裝置狀態 +POST /api/devices/:id/command → 發送命令 +GET /api/models → 列出可用模型 +GET /api/models/:id → 取得模型詳情 +POST /api/models/:id/download → 下載模型到本地 +``` + +**WebSocket — 即時雙向資料流:** + +``` +ws://localhost:PORT/ws/devices/:id/inference → 即時推論結果 +ws://localhost:PORT/ws/devices/:id/log → 即時裝置日誌 +ws://localhost:PORT/ws/devices/:id/flash-progress → 燒錄進度 +ws://localhost:PORT/ws/devices/events → 裝置插拔事件 +``` + +### B3.4 裝置通訊抽象層 + +為支援多種硬體,設計裝置驅動的插件式架構: + +``` +┌─────────────────────────────────────────────┐ +│ Device Manager │ +│ ● 裝置偵測(USB 監聽) │ +│ ● 裝置生命週期管理 │ +│ ● 路由到對應 Driver │ +└─────────────────┬───────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ +┌───────────┐ ┌───────────┐ ┌─────────┐ +│ Driver A │ │ Driver B │ │ Driver C │ +│ Kneron │ │ Kneron │ │ (未來 │ +│ Dongle │ │ Arduino / │ │ 擴充) │ +│ (MVP) │ │ KNEO Pi │ │ │ +│ │ │ (Phase 1) │ │ 非 Kneron│ +│ Kneron SDK │ │ Kneron SDK │ │ 晶片 │ +└───────────┘ └───────────┘ └─────────┘ +``` + +每個 Driver 實作統一介面: + +``` +interface DeviceDriver { + detect() → 是否為此類裝置 + connect() → 建立連線 + flash(binary) → 燒錄模型/韌體 + startInference() → 啟動推論 + readInference() → 讀取推論結果 + sendCommand() → 發送命令 + disconnect() → 中斷連線 +} +``` + +### B3.5 技術挑戰與解決方案 + +| 挑戰 | 解決方案 | +|------|---------| +| HTTPS ↔ HTTP 混合內容 | 前端也由本地 Server 提供(同源);或使用 `127.0.0.1`(Chrome loopback 特殊處理) | +| 連接埠佔用 | 自動偵測可用 port + 在固定路徑寫入 port 資訊 | +| 跨平台串列埠差異 | Go/Rust 串列埠函式庫已抽象化;Linux 處理 `dialout` 權限 | +| 裝置熱插拔 | 監聽系統 USB 事件(`udev` / `IOKit` / `WMI`) | +| 安裝/更新 | 各平台安裝包(`.dmg` / `.msi` / `.deb`)+ 自動更新 | +| 背景服務 | 系統服務(`launchd` / `systemd` / Windows Service)或系統匣 | +| CORS | `Access-Control-Allow-Origin` 設定(localhost 安全性可接受) | + +### B3.6 對比競品的架構優勢 + +| 優勢 | vs SenseCraft (Web Serial) | vs EI Daemon (Node.js) | +|------|---------------------------|------------------------| +| 背景執行 | ✅ 關頁面不斷線 | ✅ 相同能力 | +| 裝置自動偵測 | ✅ 無需手動選擇 | ✅ 相同能力 | +| 瀏覽器無限制 | ✅ Firefox/Safari 均可 | ✅ 相同能力 | +| 安裝簡易 | ⚠️ 需安裝(但僅一個檔案) | ✅ 遠比 Node+npm+C++ 簡單 | +| 離線操作 | ✅ 完全離線 | ✅ 相同能力 | +| 高鮑率穩定 | ✅ 原生串列埠 | ✅ 相同能力 | + +### B3.7 硬體支援路線圖 + +``` +MVP Phase 1 Phase 2+ +────────────── ────────────── ────────────── +Kneron Dongle Arduino 開發板 非 Kneron 晶片 +(USB AI 加速棒) (Kneron 晶片) ────────────── + ────────────── ● ESP32-S3 +● 首要支援硬體 ● KNEO Pi ● Arduino (非 Kneron) +● USB 連接 ● 其他 Kneron ● NVIDIA Jetson +● Kneron NPU 推論 晶片開發板 ● 其他廠商 +● Kneron SDK 整合 ● 透過 Driver 插件擴充 +``` + +**分階段策略:** + +| 階段 | 支援硬體 | 說明 | +|------|---------|------| +| **MVP** | Kneron Dongle | USB AI 推論加速棒,插上即用。深度整合 Kneron SDK,實現模型部署與即時推論 | +| **Phase 1** | Arduino 開發板(Kneron 晶片)/ KNEO Pi | 擴展到同為 Kneron 晶片的其他載體,共用 Kneron SDK 工具鏈 | +| **Phase 2+** | 不限於 Kneron 晶片 | 透過 Driver 抽象層擴充至 ESP32、NVIDIA Jetson 等其他廠商硬體 | + +**設計原則:** MVP 階段深度整合 Kneron 生態,但 Driver 抽象層從第一天就設計好,確保未來擴展硬體支援時不需重構核心架構。 + +### B3.8 硬體技術規格 + +#### Kneron KL720 USB Dongle(MVP 目標硬體) + +| 規格項目 | 內容 | +|---------|------| +| **晶片型號** | Kneron KL720 AI SoC | +| **NPU** | 700 MHz,1.4 TOPS(INT8);能效 0.9 TOPS/W | +| **DSP** | Cadence Tensilica VP6 DSP @ 500 MHz(NPU 協處理器) | +| **CPU** | Arm Cortex-M4 @ 400 MHz(系統控制) | +| **記憶體** | 128 MB LPDDR4(搭配 Winbond 1Gb LPDDR3 DRAM die) | +| **儲存** | SPI Flash + NAND Flash(容量依模組配置) | +| **USB 介面** | USB 3.0(Device mode)+ USB 2.0;主機端透過 USB 連接 | +| **其他介面** | MIPI-CSI-2(攝影機輸入)、SPI、SDIO、UART、I2C、GPIO | +| **影像輸入** | 支援 MIPI-CSI 攝影機模組(晶片層級) | +| **功耗** | 約 1.2 W(NPU 全速推論時) | +| **主機通訊** | 透過 libusb USB bulk transfer | +| **韌體架構** | 雙核 RTOS:SCPU(系統控制)+ NCPU(NPU 推論) | +| **韌體更新** | KneronDFUT 工具(支援 SCPU/NCPU firmware + NEF 模型燒錄) | +| **模型格式** | Kneron NEF(Neural Efficient Format)— 經量化與編譯的專用格式 | +| **外觀** | USB 隨身碟形式,即插即用 | + +#### Kneron KL730 / KNEO Pi(Phase 1 目標硬體) + +| 規格項目 | 內容 | +|---------|------| +| **晶片型號** | Kneron KL730 AI SoC | +| **NPU** | 第三代 KDP 系列可重配置 NPU,最高 8 TOPS(有效算力 4 TOPS) | +| **CPU** | 四核 Arm Cortex-A55(時脈待查) | +| **記憶體** | 待查(高於 KL720 的 128 MB) | +| **儲存** | 4 Gb SPI-NAND Flash + micro-SD 插槽 | +| **USB 介面** | USB 2.0 + USB 3.0 | +| **網路** | Ethernet(乙太網路) | +| **GPIO** | 40-pin(Raspberry Pi 相容排針) | +| **攝影機** | MIPI-CSI,支援最高 8MP @ 90fps 輸入、4K 60fps 處理 | +| **功耗** | 低功耗設計(具體數值待查) | +| **定位** | 開發板形式,類似 Raspberry Pi 的邊緣 AI 開發平台 | +| **SDK** | 與 KL720 共用 Kneron PLUS SDK | + +#### 兩代晶片比較 + +| 比較項目 | KL720(MVP) | KL730(Phase 1) | +|---------|-------------|-----------------| +| NPU 算力 | 1.4 TOPS | 8 TOPS(約 5.7 倍) | +| CPU | Arm Cortex-M4(微控制器) | Arm Cortex-A55 四核(應用處理器) | +| 影像處理 | 基本 | 4K 60fps、8MP 90fps | +| 連接方式 | USB(Dongle 形式) | USB + Ethernet + GPIO 40-pin | +| SDK 相容 | Kneron PLUS SDK | Kneron PLUS SDK(相容) | +| 使用場景 | 筆電/桌機外接 AI 推論 | 獨立邊緣 AI 開發平台 | + +#### Kneron 軟體工具鏈 + +**Kneron PLUS SDK(Platform Libraries Unified Software):** + +| 項目 | 說明 | +|------|------| +| **語言** | C/C++ 與 Python API | +| **通訊協定** | USB bulk transfer(基於 libusb) | +| **核心 API** | `kp_load_firmware()` — 上傳韌體(SCPU/NCPU)到裝置 | +| | `kp_load_model()` / `kp_load_model_from_file()` — 上傳 NEF 模型到裝置 | +| | `kp_load_model_from_flash()` — 從裝置 Flash 載入模型 | +| | `kp_get_model_info()` — 取得模型資訊(CRC、Model ID) | +| | `kp_get_system_info()` — 取得系統資訊(KN number、韌體版本) | +| **加密支援** | 支援上傳加密 NEF 模型 | +| **安裝需求** | Windows: Git for Windows SDK + libusb + cmake + Zadig(USB driver) | +| | Linux: 需配置 USB 權限(udev rules) | +| **限制** | 不支援 SELinux 環境 | + +**Kneron Toolchain(模型轉換工具):** + +| 項目 | 說明 | +|------|------| +| **工具名稱** | `ktc`(Kneron Toolchain Compiler) | +| **轉換流程** | ONNX → BIE(量化中間格式)→ NEF(部署格式) | +| **支援格式** | ONNX opset 11 / 12 | +| **功能** | 模型量化(INT8)、模型編譯、模型最佳化 | +| **API** | Python API(`ktc` 套件) | + +**KneronDFUT(Device Firmware Update Tool):** + +| 項目 | 說明 | +|------|------| +| **功能** | 更新裝置韌體(SCPU firmware + NCPU firmware)及 NEF 模型 | +| **運作方式** | 透過 USB 將 `fw_scpu.bin` / `fw_ncpu.bin` 寫入裝置 Flash | +| **開機流程** | IPL(ROM)→ SPL(Flash)→ SCPU firmware → NCPU firmware | +| **平台支援** | Windows、Linux(含 Raspberry Pi OS) | +| **替代方案** | 可透過 Kneron PLUS SDK API 程式化執行相同操作 | + +> **MVP 開發影響:** 本地端 Backend Server 需整合 Kneron PLUS SDK(透過 CGo 呼叫 C API 或獨立 Python subprocess),實現 USB 裝置偵測、NEF 模型上傳、韌體更新、推論啟動等核心操作。 + +--- + +## B4. MVP 功能規格 + +### B4.1 F1 — 預訓練模型庫與選擇 + +| 項目 | 規格 | +|------|------| +| **概述** | 提供可瀏覽、搜尋、篩選的預訓練模型庫 | +| **模型來源** | 初期以 Kneron 模型庫(支援 Kneron NPU 的預訓練模型)為主,未來擴展其他來源 | +| **模型卡片資訊** | 名稱、縮圖、任務類型、支援硬體、模型大小、準確度、描述 | +| **篩選維度** | 任務類型 / 硬體相容性 / 模型大小 / 關鍵字搜尋 | +| **模型格式** | Kneron NEF(Kneron 專用格式)為主;未來擴展支援 ONNX / TFLite 等 | +| **儲存** | 模型中繼資料可存雲端;模型檔下載到本地 Server 快取 | + +### B4.2 F2 — 裝置連線與管理 + +| 項目 | 規格 | +|------|------| +| **概述** | 透過本地 Server 自動偵測、連線和管理 USB 裝置 | +| **裝置偵測** | 監聽 USB 事件,依 VendorId/ProductId 識別裝置類型 | +| **裝置資訊** | 裝置名稱、類型、連接埠、連線狀態 | +| **連線管理** | 建立/中斷串列連線、自動重連(斷線後) | +| **多裝置** | 支援同時連接多個裝置,各自獨立管理 | +| **驅動擴充** | 插件式 Driver 架構,新硬體透過新增 Driver 支援 | + +### B4.3 F3 — 韌體/模型燒錄 + +| 項目 | 規格 | +|------|------| +| **概述** | 將選定模型燒錄到目標裝置 | +| **燒錄協定** | MVP 使用 Kneron PLUS SDK:`kp_load_model()` 上傳 NEF 模型、`kp_load_firmware()` 更新韌體;未來依 Driver 擴充 | +| **燒錄流程** | USB 連線 → `kp_load_model_from_file(nef_path)` 傳輸 NEF → 驗證 CRC → 裝置自動重啟 | +| **進度回報** | WebSocket 即時回報百分比,前端顯示進度條 | +| **錯誤處理** | 逾時重試、傳輸錯誤偵測、使用者可讀的錯誤訊息 | +| **安全性** | 燒錄前確認模型與裝置相容;提示使用者確認覆寫 | + +### B4.4 F4 — 裝置端即時推論與結果顯示 + +| 項目 | 規格 | +|------|------| +| **概述** | 燒錄完成後,在裝置端執行推論並即時顯示結果 | +| **推論模式** | 裝置自動進入推論模式,或透過命令手動啟動 | +| **資料流** | 裝置 → Serial → 本地 Server → WebSocket → 前端 | +| **分類結果** | 類別名稱 + 信心分數 + 即時長條圖 | +| **偵測結果** | 邊界框座標 + 類別 + 信心分數(疊加在影像上,若有影像輸出) | +| **效能指標** | 推論延遲 (ms)、FPS | +| **互動控制** | Confidence Threshold 滑桿、開始/停止推論 | +| **裝置日誌** | 原始串列通訊日誌(可展開查看) | + +### B4.5 已實作的額外功能 + +> 以下功能為 MVP 開發過程中額外實作,超出原始 PRD 範圍。共 11 項功能。 + +#### F5 — 多來源推論輸入 + +| 項目 | 規格 | +|------|------| +| **概述** | 除了攝影機之外,支援上傳圖片和影片作為推論輸入來源 | +| **輸入來源** | 攝影機(即時)、圖片上傳(JPG/PNG,支援單張與多張批次)、影片上傳(MP4/AVI/MOV)、影片 URL(直接連結、YouTube、RTSP) | +| **圖片推論** | 上傳圖片至後端,送入裝置 NPU 推論後回傳結果 | +| **批次圖片推論** | 上傳多張圖片(最多 50 張),後端逐張送入 NPU 推論,結果透過 WebSocket 即時串流回前端;前端提供縮圖列表、進度條、點擊切換瀏覽各張結果 | +| **影片推論** | 上傳影片或提供 URL,後端逐幀擷取送入裝置推論,結果透過 WebSocket 即時串流 | +| **YouTube 支援** | 透過 yt-dlp 解析 YouTube URL 取得直接影片連結,再進行影片推論 | +| **拖放上傳** | 圖片 tab 支援 drag & drop 拖放上傳,拖入時顯示高亮邊框提示 | +| **UI 設計** | Tab 式切換(攝影機 / 圖片 / 影片),影片 tab 內再切換上傳檔案或貼上 URL | + +#### F6 — 多語系支援(i18n) + +| 項目 | 規格 | +|------|------| +| **概述** | 完整的前端多語系支援,支援英文與繁體中文切換 | +| **支援語言** | English (en)、繁體中文 (zh-TW) | +| **實作方式** | 自訂 TypeScript i18n 系統,不依賴外部框架(無 next-intl / react-i18next) | +| **翻譯字典** | 約 130+ 翻譯 key,按功能分區(common, nav, dashboard, devices, models, camera, inference, settings, errors) | +| **型別安全** | `TranslationKey` 型別由 `Paths` 遞迴產生,提供所有 key 的自動完成,缺 key 時 TypeScript 報錯 | +| **切換方式** | Settings 頁面語言下拉選單,切換後即時生效(無需重新整理) | +| **持久化** | 語言設定透過 Zustand persist 儲存至 localStorage | + +#### F7 — 深色模式 + +| 項目 | 規格 | +|------|------| +| **概述** | 支援淺色和深色主題切換 | +| **實作方式** | ThemeSync 元件監聽 Zustand store 的 theme 設定,同步更新 `` 的 `class` 屬性(`dark`) | +| **CSS 方案** | 使用 Tailwind CSS 的 `dark:` variant,搭配 CSS custom properties | +| **切換方式** | Settings 頁面主題選擇 | +| **持久化** | 主題設定透過 Zustand persist 儲存至 localStorage | + +#### F8 — 儀表板(Dashboard) + +| 項目 | 規格 | +|------|------| +| **概述** | 首頁儀表板提供平台總覽,包含統計卡片、快速操作和即時資訊 | +| **統計卡片** | 模型數量、裝置數量、已連線裝置數、燒錄次數 | +| **快速操作** | 「瀏覽模型」、「管理裝置」、「上傳模型」按鈕,快速導航到對應頁面 | +| **活動時間線** | 顯示最近 10 筆系統活動(模型上傳/刪除、裝置連線/斷線、燒錄開始/完成/錯誤) | +| **已連線裝置** | 即時顯示目前已連線的裝置列表及狀態 | + +#### F9 — 裝置偏好設定 + +| 項目 | 規格 | +|------|------| +| **概述** | 每台裝置可設定自訂名稱和備註 | +| **自訂名稱** | alias 欄位,例如「實驗室裝置 #1」 | +| **備註** | 自由文字備註欄位 | +| **持久化** | 透過 Zustand persist 儲存至 localStorage,以 deviceId 為 key | + +#### F10 — 模型比較 + +| 項目 | 規格 | +|------|------| +| **概述** | 模型庫內可選取最多 3 個模型進行並排比較 | +| **比較面向** | 準確率、FPS、模型大小、延遲、任務類型 | +| **UI 設計** | Dialog 式比較表,含指標長條圖視覺化 | +| **操作方式** | 在模型列表勾選後點擊「比較」按鈕 | + +#### F11 — 自訂模型上傳 + +| 項目 | 規格 | +|------|------| +| **概述** | 使用者可上傳自訂 .nef 模型檔案到模型庫 | +| **上傳資訊** | 模型檔案(.nef)、模型名稱、描述、任務類型、標籤、輸入尺寸、量化方式 | +| **驗證** | 必填欄位驗證(檔案、名稱、任務類型、標籤) | +| **模型管理** | 自訂模型可在模型詳情頁刪除 | + +#### F12 — 設定頁面 + +| 項目 | 規格 | +|------|------| +| **概述** | 集中管理應用程式設定 | +| **伺服器設定** | 顯示 API URL 和 WebSocket URL(唯讀) | +| **外觀設定** | 主題切換(淺色/深色)、語言切換(EN/zh-TW) | +| **關於** | 顯示平台版本資訊 | +| **恢復預設** | 一鍵恢復所有設定至預設值 | + +#### F13 — 裝置健康狀態與連線歷史 + +| 項目 | 規格 | +|------|------| +| **概述** | 裝置詳情頁顯示健康狀態和連線歷史 | +| **健康狀態** | 狀態、韌體版本、運行時間、最後活動時間 | +| **連線歷史** | 按時間排列的連線/斷線事件紀錄 | + +#### F14 — 批次圖片推論 + +| 項目 | 規格 | +|------|------| +| **概述** | 支援一次上傳多張圖片進行批次推論,適用於工業檢測等需要大量圖片驗證的場景 | +| **上傳方式** | 檔案選擇器多選、拖放上傳(drag & drop),上限 50 張 | +| **處理方式** | 後端逐張送入 NPU 推論(硬體限制),結果透過 WebSocket 即時串流回前端 | +| **進度顯示** | 進度條顯示已處理 / 總數,縮圖列表標示完成(✓)/處理中(⏳)/待處理(編號) | +| **結果瀏覽** | 水平捲動縮圖列表,點擊切換檢視不同圖片的推論結果(BBox overlay + 分類結果) | +| **UI 設計** | 批次模式下自動隱藏手動推論按鈕,inference panel 顯示批次進度卡片 | + +#### F15 — 攝影機自動偵測 + +| 項目 | 規格 | +|------|------| +| **概述** | 進入 workspace 時自動偵測系統是否有可用攝影機,無攝影機時自動停用攝影機功能 | +| **偵測方式** | 後端透過 ffmpeg 偵測系統攝影機(macOS: AVFoundation, Windows: DirectShow) | +| **無攝影機行為** | Camera tab 自動 disabled、預設切換至 Image tab、顯示「未偵測到攝影機」提示訊息 | +| **有攝影機行為** | 維持原有攝影機推論功能不變 | + +#### F16 — 跨平台安裝與分發 + +| 項目 | 規格 | +|------|------| +| **概述** | 提供一行指令安裝體驗,涵蓋 binary 下載、環境設定、硬體偵測,支援 macOS 與 Windows | +| **macOS/Linux 安裝** | `curl -fsSL https://gitea.innovedus.com/.../install.sh \| bash`,自動偵測 OS + 架構(darwin/linux + amd64/arm64) | +| **Windows 安裝** | `irm https://gitea.innovedus.com/.../install.ps1 \| iex`,自動下載 zip、解壓、加入 PATH | +| **安裝步驟** | 4 步驟自動化:(1) 下載 binary + 資料檔 (2) 安裝 USB 驅動(libusb)(3) 建立 Python venv + pyusb (4) 檢查環境 + 偵測硬體 | +| **安裝目錄** | macOS: `~/.edge-ai-platform/`、Windows: `%LOCALAPPDATA%\EdgeAIPlatform` | +| **解除安裝** | macOS: `rm -rf ~/.edge-ai-platform && sudo rm -f /usr/local/bin/edge-ai-server`、Windows: 刪除目錄 + 移除 PATH | +| **啟動依賴檢查** | Server 啟動時自動檢查 ffmpeg、yt-dlp、python3,缺少時顯示對應平台的安裝指引 | +| **GoReleaser 打包** | 跨平台 archive 自動產出(darwin amd64/arm64 tar.gz、windows amd64 zip),含 binary + data + scripts | +| **Kneron 硬體偵測** | 安裝完成時自動偵測 USB Kneron 裝置(KL520/KL720/KL730),使用 pyusb;KL720 KDP legacy 裝置提示韌體更新 | + +#### F17 — 圖形化安裝與解除安裝程式 + +| 項目 | 規格 | +|------|------| +| **概述** | 提供非技術人員可使用的桌面 GUI 安裝精靈,雙擊即可完成所有安裝步驟(server binary、Python 環境、系統依賴、硬體驅動),無需開終端機或輸入任何指令 | +| **目標平台** | macOS (.dmg → .app) + Windows (.exe installer) | +| **技術方案** | Go + Wails v2(WebView-based GUI),前端以 HTML/CSS/JS 實作安裝畫面,後端 Go 執行實際安裝邏輯。共用 Go 工具鏈,無需額外 runtime | +| **安裝精靈流程** | 6 步驟:(1) 歡迎頁 + 授權協議 → (2) 安裝路徑選擇 → (3) 元件選擇(必要/可選)→ (4) 自動安裝 + 即時進度 → (5) 硬體偵測結果 → (6) 完成 + 啟動選項 | +| **必要元件(自動安裝)** | edge-ai-server binary(含嵌入式前端)、Python 3 venv + numpy + opencv-python-headless + pyusb、Kneron 韌體檔案(KL520 + KL720)、NEF 預訓練模型、libusb(USB 裝置通訊) | +| **可選元件** | ffmpeg(攝影機/影片串流)、yt-dlp(YouTube 影片下載) | +| **自動依賴解析** | macOS: 自動安裝 Homebrew(若未安裝)→ `brew install libusb python3 ffmpeg`;Windows: 自動下載 Python embedded + libusb DLL,免管理員權限 | +| **即時進度顯示** | 每個安裝步驟獨立顯示進度條 + 狀態文字(下載中 → 解壓中 → 設定中 → 完成),失敗時顯示錯誤訊息 + 重試按鈕 | +| **硬體偵測** | 安裝完成後自動掃描 USB Kneron 裝置,顯示偵測到的晶片型號(KL520/KL720)、韌體版本、連線狀態;KL720 KDP legacy 裝置提示一鍵韌體更新 | +| **解除安裝** | 內建解除安裝功能:刪除 server binary + Python venv + 資料檔案 + symlink/PATH,macOS 提供拖曳到垃圾桶 + 深度清理選項,Windows 整合「新增或移除程式」 | +| **安裝目錄** | macOS: `~/.edge-ai-platform/`(不需 sudo)、Windows: `%LOCALAPPDATA%\EdgeAIPlatform\`(不需管理員) | +| **安裝完成動作** | 可選擇立即啟動 server + 自動開啟瀏覽器(`http://localhost:3721`)、建立桌面捷徑、設定開機自動啟動 | +| **更新支援** | 偵測現有安裝版本,僅更新 binary + 新模型,保留使用者資料(custom-models、設定檔)與 Python venv | +| **打包產出** | macOS: `EdgeAI-Installer.dmg`(含 .app + 背景圖 + Applications 捷徑)、Windows: `EdgeAI-Setup.exe`(NSIS + Wails 包裝) | +| **安裝體積** | 完整安裝約 300-400 MB(binary ~10MB + 模型 ~73MB + Python venv ~250MB + firmware ~2MB) | + +#### F18 — Kneron 硬體通訊整合(KL520 + KL720) + +| 項目 | 規格 | +|------|------| +| **概述** | 雙晶片 Kneron USB Dongle 完整通訊管線已驗證,涵蓋 USB 偵測、晶片感知連線、韌體載入/更新、模型載入、即時推論與多模型後處理 | +| **支援裝置** | Kneron KL520 (VID 0x3231, PID 0x0100, USB 2.0) + KL720 (PID 0x0720 KDP2 / PID 0x0200 KDP legacy, USB 3.0) | +| **晶片感知架構** | Driver 從 `device_type` 或 `product_id` 自動推導晶片類型(KL520/KL720),所有行為依晶片動態調整 | +| **KL520 USB Boot** | 無板載 Flash 韌體,每次連線自動上傳 fw_scpu.bin + fw_ncpu.bin;每 USB session 僅能載入一個模型(Error 40 限制),換模型需 reset + bridge restart | +| **KL720 Flash-based** | KDP2 韌體預燒在 SPI Flash,連線免上傳韌體;無 Error 40 限制,可自由切換模型 | +| **KL720 KDP→KDP2 更新** | KL720 出廠可能為舊版 KDP 韌體(PID=0x0200),透過 DFUT magic bypass (`0x1FF55B4F`) 連線後以 `kp_update_kdp_firmware_from_files()` 永久更新為 KDP2;更新後 PID 變為 0x0720 | +| **SDK** | Kneron PLUS SDK v3.1.2,從 C 原始碼編譯為 macOS dylib(Apple Silicon 透過 Rosetta 2 執行) | +| **通訊架構** | Go Server → JSON-RPC (stdin/stdout) → Python Bridge (`kneron_bridge.py`) → Kneron PLUS SDK (kp) → USB → KL520/KL720 | +| **支援模型** | Kneron NEF 格式,10+ 預訓練模型。KL520: Tiny YOLO v3、YOLOv5s、FCOS、ResNet18、SSD 人臉偵測。KL720: YOLOv5s、FCOS、ResNet18 | +| **跨晶片模型路徑** | `resolveModelPath()` 自動將 `data/nef/kl520/kl520_20001_...` 替換為 `data/nef/kl720/kl720_20001_...`(依連線晶片),跨平台模型自動選用正確的 NEF 檔案 | +| **推論延遲** | KL520: ~25ms(Tiny YOLO v3, 224×224)、KL720: 更快(USB 3.0 + 更強 NPU) | +| **後處理引擎** | 多模型自動偵測:Tiny YOLO v3(雙尺度 7×7+14×14)、YOLOv5s(三尺度 P3/P4/P5)、FCOS(5 層特徵金字塔)、SSD 人臉偵測、ResNet18 分類(Top-5 softmax) | +| **Letterbox 校正** | 自動讀取 `hw_pre_proc_info` 的 padding 參數,修正 bbox 座標以對應原始圖片比例 | +| **輸出格式** | 統一 `InferenceResult` JSON:taskType (detection/classification)、latencyMs、detections[{label, confidence, bbox}]、classifications[{label, confidence}] | +| **Python Bridge 指令** | `scan`、`connect`(含 `device_type` 參數)、`load_model`、`inference`、`reset`(KP_RESET_REBOOT)、`disconnect` | +| **Error 40 處理** | KL520: `restartBridge()` → 重建整個 Python 子進程 → 重新連線 + 載入模型。KL720: 先直接 retry → 若失敗才 fallback 到 bridge restart | +| **驗證結果** | KL520: 街道場景 8 物件偵測正確;KL720: KDP→KDP2 韌體更新成功(0x0200→0x0720)、connect/flash/inference 流程驗證 | + +#### F19 — 多裝置叢集推論(Cluster Inference) + +| 項目 | 規格 | +|------|------| +| **概述** | 允許使用者將多個 Kneron 邊緣裝置(KL520、KL720 可混用)組成推論叢集,透過加權分派機制將影像幀分配至各裝置並行推論,合併後以單一串流輸出結果,實現線性吞吐量擴展 | +| **叢集管理** | 建立/刪除叢集、新增/移除成員裝置;叢集設定純記憶體管理(MVP 階段),重啟後需重新建立 | +| **異質支援** | 同一叢集可混用 KL520 + KL720,系統依裝置晶片類型自動選用正確的 NEF 檔案(透過既有 `resolveModelPath` 機制) | +| **加權分派** | 依裝置算力動態分配幀:KL720 預設權重 3、KL520 預設權重 1(可由使用者調整);採用加權輪詢(Weighted Round-Robin)演算法 | +| **統一結果** | 各裝置推論結果合併至單一 channel,標記來源裝置 ID 與幀序號(frameIndex),前端可依幀序排序或直接串流顯示 | +| **叢集燒錄** | 一鍵為叢集所有裝置燒錄同一邏輯模型(系統自動依晶片選取對應 NEF),各裝置燒錄進度獨立追蹤 | +| **叢集工作區** | 單一工作區 UI 顯示叢集推論串流,側邊面板顯示各裝置個別 FPS/延遲與叢集整體 FPS | +| **容錯機制** | 裝置推論失敗自動標記為 degraded,叢集持續以剩餘裝置運作;裝置恢復後可重新加入 | +| **WebSocket** | 叢集推論結果使用 `inference:cluster:{clusterId}` room 廣播 | +| **限制** | MVP 階段:每叢集最多 8 裝置;叢集設定不持久化;不支援跨機器叢集 | + +--- + +## B5. 功能路線圖(Post-MVP) + +### B5.1 總覽 + +``` +MVP(當前) +├── 預訓練模型庫 +├── 裝置連線與燒錄 +└── 裝置端即時推論 + +Phase 1 — 訓練能力(基礎差異化) +├── F05 影像分類訓練(瀏覽器端或伺服器端) +├── F06 物件偵測訓練 +├── F07 資料收集與標記工具 +├── F08 模型可解釋性儀表板 +└── F09 資料品質檢測 + +Phase 2 — MLOps 能力(關鍵競爭力) +├── F10 部署後監控平台 +├── F11 模型漂移偵測引擎 +├── F12 OTA 模型更新管道 +├── F13 主動學習閉環 +└── F14 自動化 MLOps 管道 + +Phase 3 — 進階功能(長期差異化) +├── F15 LLM 驅動的模型設計助手 +├── F16 多感測器融合 +├── F17 邊緣端持續學習 +├── F18 聯邦學習支援 +└── F19 合規與安全框架 +``` + +### B5.2 Phase 1 — 訓練能力 + +| 功能 | 說明 | 對標競品 | +|------|------|---------| +| **F05 影像分類訓練** | 使用者上傳影像 + 標記 → 訓練分類模型 → 部署 | EI: Keras Transfer Learning / SC: 瀏覽器端 WebGL | +| **F06 物件偵測訓練** | 框選標記 → 訓練偵測模型 → 部署 | EI: FOMO/YOLO / SC: YOLO-World | +| **F07 資料收集與標記** | 多類型標記(邊界框/多邊形/關鍵點)、AI 預標記、協作標記 | EI: GPT-4o 標記 / SC: 僅框選 | +| **F08 模型可解釋性** | Grad-CAM 熱力圖、錯誤分析、信心度分佈、邊界案例 | 雙方均缺 | +| **F09 資料品質檢測** | 重複偵測、離群值、類別平衡分析、品質評分 | EI: Data Explorer 但無自動建議 / SC: 無 | + +### B5.3 Phase 2 — MLOps 能力 + +| 功能 | 說明 | 市場現況 | +|------|------|---------| +| **F10 部署後監控** | 推論統計、裝置健康、告警規則、群組管理 | 邊緣 AI 最大功能空白 | +| **F11 漂移偵測** | 邊緣端輕量偵測 + 雲端深度分析、自動告警 | 無任何平台提供 | +| **F12 OTA 更新** | 差分更新、分階段發佈、回滾機制、A/B 測試 | 雙方均弱 | +| **F13 主動學習** | 低信心樣本採集 → 人機標記 → 自動再訓練 → 部署 | 完整閉環不存在 | +| **F14 MLOps 管道** | 拖放式 pipeline、觸發機制、品質閘門、CI/CD | 雙方均無 | + +### B5.4 Phase 3 — 進階功能 + +| 功能 | 說明 | +|------|------| +| **F15 LLM 模型助手** | 自然語言描述需求 → 自動推薦模型架構與訓練策略 | +| **F16 多感測器融合** | 多通道同步收集、時間對齊、多模態融合訓練 | +| **F17 邊緣端持續學習** | 裝置端微調最後幾層,隱私保護 | +| **F18 聯邦學習** | 多裝置協同訓練、差分隱私 | +| **F19 合規安全** | 公平性審計、PII 偵測、對抗攻擊測試、合規報告 | + +### B5.5 優先級矩陣 + +``` + 高影響力 + │ + F10 監控 │ F13 主動學習 + F11 漂移偵測 │ F15 LLM 助手 + F12 OTA │ F18 聯邦學習 + │ + 低實作難度 ─────────┼──────────── 高實作難度 + │ + F05 分類訓練 │ F16 多感測器融合 + F08 可解釋性 │ F17 邊緣端學習 + F09 品質檢測 │ F19 合規框架 + F07 標記工具 │ F14 MLOps 管道 + F06 偵測訓練 │ + │ + 低影響力 +``` + +--- + +## B6. 商業模式與策略 + +### B6.1 定價策略 + +| 方案 | 內容 | 對標 | +|------|------|------| +| **免費版** | 模型庫瀏覽、裝置燒錄、即時推論、基礎訓練 — 核心功能完全免費 | 比 EI 更寬鬆(無專案數/運算時間限制) | +| **Pro 版**(未來) | 進階訓練功能、模型監控、OTA 更新、團隊協作 — 透明月費定價 | 比 EI Enterprise 更平易 | +| **Enterprise**(未來) | SSO、白標、私有部署、SLA、優先支援 | 類似 EI Enterprise | + +### B6.2 生態系規劃 + +| 維度 | 策略 | +|------|------| +| **硬體** | MVP 以 Kneron Dongle 為核心 → Phase 1 擴展至 Kneron Arduino / KNEO Pi → Phase 2+ 開放非 Kneron 晶片,透過 Driver 插件支援多廠商 | +| **模型** | 開放模型市集,整合公開模型庫 + 允許社群貢獻 | +| **社群** | 開源 Driver SDK,鼓勵社群貢獻硬體支援 | +| **整合** | API 優先設計,未來可整合 CI/CD、IoT 平台等 | + +--- + +# 附錄 + +## C1. 術語對照表 + +| 術語 | Edge Impulse 用語 | SenseCraft AI 用語 | 本平台用語 | +|------|------------------|-------------------|-----------| +| ML Pipeline | Impulse | 訓練流程 | (MVP 不含) | +| Feature Engineering | Processing Block | (無) | (MVP 不含) | +| Model Training | Learning Block | Training | (Post-MVP) | +| Model Deployment | Deployment (Build) | Deploy to device | 燒錄到裝置 | +| Live Testing | Live Classification | Preview / Invoke | 即時推論 | +| Device Management | Devices | Workspace | 裝置管理 | +| Pre-trained Models | 公開專案 | 模型庫 | 模型庫 | + +## C2. 競品支援硬體清單 + +**Edge Impulse 支援硬體(40+):** +- Arduino: Nano 33 BLE Sense, Nicla (Sense ME/Vision/Voice), Portenta H7, UNO Q +- Nordic: nRF52840/5340/54L15/7002/9160/9161/9151 DK, Thingy:53/91 +- Espressif: ESP-EYE, ESP32-S3 +- STM: B-L475E-IOT01A, STM32N6570-DK +- Renesas: CK-RA6M5, EK-RA8D1, RZ/G2L, RZ/V2L, RZ/V2H +- Silicon Labs: Thunderboard Sense 2, xG24 Dev Kit +- Raspberry Pi: Pi 4 & 5, Pico +- NVIDIA: Jetson Orin & Nano +- 其他: Syntiant, TI, Qualcomm, Sony Spresense, Particle Photon 2 + +**SenseCraft AI 支援硬體(6 款):** +- Grove Vision AI V2 +- XIAO ESP32S3 Sense +- SenseCAP A1102 +- SenseCAP Watcher +- reCamera +- NVIDIA Jetson (reComputer) + +## C3. 參考來源 + +### 競品分析來源 + +| 來源 | 說明 | +|------|------| +| Edge Impulse PRD | 透過 Playwright 自動化測試在 2026-02-11 驗證的完整平台分析 | +| SenseCraft AI PRD | 透過網頁爬取和截圖在 2026-02-09 完成的完整平台分析 | +| Edge Impulse 論壇 | forum.edgeimpulse.com 常見問題模式分析 | +| 社群討論 | Reddit (r/embedded, r/MachineLearning, r/tinyML) | +| 產業報告 | TinyML Foundation 報告、MLSys 會議論文 | +| 競品資料 | Roboflow、Google Teachable Machine、SensiML、Arduino Cloud | + +### 技術實作來源 + +| 來源 | 類型 | 說明 | +|------|------|------| +| `github.com/Seeed-Studio/SenseCraft-Web-Toolkit` | GitHub | SenseCraft 開源 Web 工具套件 | +| `src/sscma/grove_ai_we2/deviceHimax.ts` | 原始碼 | Grove AI V2 Web Serial 連線(921600 baud) | +| `src/sscma/xiao_esp32s3/EspSerialDevice.ts` | 原始碼 | XIAO ESP32S3 Web Serial + ESPLoader(115200 baud) | +| `github.com/edgeimpulse/edge-impulse-cli` | GitHub | EI CLI 工具,daemon 實作 | +| `cli/serial-connector.ts` | 原始碼 | EI daemon 串列埠通訊(`serialport` npm v8.0.8) | +| `cli/daemon.ts` | 原始碼 | EI daemon WebSocket 連雲端架構 | +| `docs.edgeimpulse.com/docs/tools/edge-impulse-cli/cli-installation` | 文件 | CLI 安裝指南 | +| `wicg.github.io/serial/` | W3C 標準 | Web Serial API 規格 | +| `pkg.go.dev/go.bug.st/serial` | Go 套件 | Go 串列埠通訊(建議方案) | + +### Kneron 硬體與 SDK 來源 + +| 來源 | 類型 | 說明 | +|------|------|------| +| `mouser.com/catalog/specsheets/Kneron_01292021_KL720B3421B.pdf` | Datasheet | KL720 AI SoC 初步規格書(Version 0.8) | +| `mouser.com/.../Kneron_KP73B703A_M1__KL730_KNEOPi_HDK_V2_0_Spec` | Datasheet | KL730 KNEO-Pi V2.0 HDK 規格書 | +| `doc.kneron.com/docs/720_2.2.0/introduction/` | 官方文件 | KL720 SDK v2.2.x 文件中心 | +| `doc.kneron.com/docs/plus_python/api_document/V1.3.0/kp/core/` | API 文件 | Kneron PLUS Python SDK — kp.core API 參考 | +| `doc.kneron.com/docs/plus_c_api/api_reference_1.1.x/kp_core.h/` | API 文件 | Kneron PLUS C API — kp_core.h 參考 | +| `github.com/kneron/host_lib` | GitHub | Kneron USB Dongle Python 範例 | +| `kneron.com/page/soc/` | 官方頁面 | Kneron 邊緣 AI 晶片產品總覽(KL520/KL720/KL730) | +| `kneron.com/news/blog/178/` | 官方部落格 | KL730 AI 晶片發佈公告 | +| `cnx-software.com/2020/09/02/kneron-kl720-arm-cortex-m4-ai-soc/` | 媒體報導 | KL720 技術規格分析(CNX Software) | +| `winbond.com/.../high-bandwidth-1gb-lpddr3-helps-kneron-kl720/` | 合作夥伴 | Winbond LPDDR3 搭配 KL720 資訊 | + +--- + +*文件版本:v2.7 | 日期:2026-03-02 | 狀態:更新中* diff --git a/edge-ai-platform/docs/TDD.md b/edge-ai-platform/docs/TDD.md new file mode 100644 index 0000000..67397ca --- /dev/null +++ b/edge-ai-platform/docs/TDD.md @@ -0,0 +1,2842 @@ +# 邊緣 AI 開發平台 — 技術設計文件(TDD) + +--- + +## 文件資訊 + +| 項目 | 內容 | +|------|------| +| 文件名稱 | 邊緣 AI 開發平台 TDD | +| 對應 PRD | PRD-Integrated.md v2.7 | +| 版本 | v1.6 | +| 日期 | 2026-03-02 | +| 狀態 | 更新中 | + +--- + +## 目錄 + +- [1. 技術架構總覽](#1-技術架構總覽) +- [2. 前端架構設計](#2-前端架構設計) +- [3. 後端架構設計(本地端 Backend Server)](#3-後端架構設計本地端-backend-server) +- [4. 前後端通訊設計](#4-前後端通訊設計) +- [5. 裝置通訊抽象層設計](#5-裝置通訊抽象層設計) +- [6. 模型庫設計](#6-模型庫設計) +- [7. 資料模型設計](#7-資料模型設計) +- [8. MVP 功能模組對照](#8-mvp-功能模組對照) +- [8.5 已實作額外功能技術對照](#85-已實作額外功能技術對照) +- [9. 開發環境與工具鏈](#9-開發環境與工具鏈) +- [10. 專案目錄結構](#10-專案目錄結構) +- [11. 部署與打包策略](#11-部署與打包策略) +- [12. 安全性考量](#12-安全性考量) +- [13. 效能目標](#13-效能目標) +- [14. 測試策略](#14-測試策略) +- [15. 開發階段規劃](#15-開發階段規劃) + +--- + +## 1. 技術架構總覽 + +### 1.1 整體架構圖 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 使用者電腦 │ +│ │ +│ ┌─────────────────────────┐ HTTP/WS ┌───────────────────────────┐ │ +│ │ 前端 Web App │◄═══════════►│ 本地端 Backend Server │ │ +│ │ (瀏覽器) │ :3721 │ (Go 單一執行檔) │ │ +│ │ │ │ │ │ +│ │ Next.js (React) │ │ ┌──────────────────────┐ │ │ +│ │ + shadcn/ui │ │ │ HTTP REST API │ │ │ +│ │ + Tailwind CSS │ │ │ WebSocket Server │ │ │ +│ │ + Zustand │ │ │ Static File Server │ │ │ +│ │ │ │ └──────────┬───────────┘ │ │ +│ │ Features: │ │ │ │ │ +│ │ ├── 模型庫瀏覽/篩選 │ │ ┌──────────┴───────────┐ │ │ +│ │ ├── 裝置狀態面板 │ │ │ Device Manager │ │ │ +│ │ ├── 燒錄進度 UI │ │ │ ├── USB 監聽 │ │ │ +│ │ ├── Camera 即時預覽 │ │ │ ├── Driver Registry │ │ │ +│ │ ├── 推論結果疊加顯示 │ │ │ └── Session Manager │ │ │ +│ │ └── Canvas 邊界框繪製 │ │ ├────────────────────────┤ │ │ +│ └─────────────────────────┘ │ │ Camera Manager │ │ │ +│ │ │ ├── Webcam 擷取 │ │ │ +│ │ │ ├── Frame → Device │ │ │ +│ │ │ └── MJPEG/WS 串流 │ │ │ +│ │ └──────────┬───────────┘ │ │ +│ │ │ USB │ │ +│ ┌──────────────┐ │ ▼ │ │ +│ │ USB Webcam │─── Video ──────────────►│ ┌──────────────────────┐ │ │ +│ │ (UVC) │ │ │ Device Drivers │ │ │ +│ └──────────────┘ │ │ ├── Kneron KL720 │ │ │ +│ │ │ ├── Kneron KL730 │ │ │ +│ │ │ └── (擴充) │ │ │ +│ │ └──────────┬───────────┘ │ │ +│ └─────────────┼─────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Edge Device │ │ +│ │ (Kneron Dongle) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + │ HTTPS(選用,Post-MVP) + ▼ +┌──────────────────┐ +│ 雲端服務(選用) │ +│ ● 模型儲存庫 │ +│ ● 使用者帳號 │ +└──────────────────┘ +``` + +### 1.2 技術選型總覽 + +| 層面 | 技術選型 | 版本 | 選型理由 | +|------|---------|------|---------| +| **前端框架** | Next.js (React) | 15.x | SSG 輸出、生態系成熟、元件庫豐富 | +| **UI 框架** | shadcn/ui + Tailwind CSS | latest | 可客製化、不鎖定、輕量 | +| **狀態管理** | Zustand | 5.x | 輕量、適合即時資料流、無 boilerplate | +| **後端語言** | Go | 1.22+ | 跨平台編譯、單一 binary、串列埠函式庫成熟 | +| **HTTP 框架** | Gin 或 Echo | latest | 高效能、middleware 豐富 | +| **WebSocket** | gorilla/websocket | latest | Go 生態最成熟的 WS 實作 | +| **串列埠** | go.bug.st/serial | latest | 跨平台、純 Go 實作 | +| **USB 偵測** | gousb / 平台原生 | latest | USB 裝置偵測與熱插拔 | +| **Kneron SDK** | Kneron PLUS SDK (C API) | 1.3.x | 透過 CGo 呼叫或 subprocess | +| **打包工具** | GoReleaser | latest | 跨平台 binary 打包與發佈 | +| **Camera 擷取** | OpenCV (gocv) / ffmpeg | latest | 後端擷取 webcam 影像,送至裝置推論 | + +### 1.3 設計原則 + +1. **本地優先 (Local-first)**:所有核心功能在本地端完成,不依賴雲端 +2. **Driver 抽象化**:從第一天就設計裝置抽象層,確保未來硬體擴展不需重構 +3. **前後端分離**:前端透過 REST + WebSocket 與後端通訊,後端同時提供靜態檔案服務 +4. **零依賴安裝**:使用者只需下載一個執行檔,前端 build 後嵌入 binary +5. **漸進式複雜度**:UI 預設簡潔,進階功能可展開 +6. **Camera-in-the-loop**:Camera 影像擷取由後端負責,送至 AI 硬體推論後將結果與畫面同步回傳前端 + +--- + +## 2. 前端架構設計 + +### 2.1 技術棧 + +``` +Next.js 15.x (App Router) +├── React 19 +├── TypeScript 5.x +├── Tailwind CSS 4.x +├── shadcn/ui (元件庫) +├── Zustand 5.x (狀態管理) +├── Recharts (圖表) +├── Lucide Icons (圖示) +└── next export (靜態輸出,嵌入 Go binary) +``` + +### 2.2 為什麼選 Next.js + React + +| 考量面向 | Next.js (React) | Nuxt (Vue) | SvelteKit | 評估 | +|---------|:-:|:-:|:-:|------| +| 元件生態系 | ★★★★★ | ★★★★ | ★★★ | React 的 Dashboard/資料視覺化元件最豐富 | +| 靜態輸出 | ★★★★★ | ★★★★ | ★★★★ | `next export` 成熟,可直接嵌入 Go binary | +| WebSocket 整合 | ★★★★★ | ★★★★ | ★★★★ | 搭配 Zustand 管理即時資料流 | +| Canvas/繪圖 | ★★★★★ | ★★★★ | ★★★ | 邊界框繪製、影像疊加等需求,React Canvas 生態最成熟 | +| 團隊熟悉度 | 考量中 | 考量中 | 考量中 | 需確認 | +| 長期維護性 | ★★★★★ | ★★★★ | ★★★ | React 社群最大,招募最容易 | + +### 2.3 頁面結構 + +``` +/ (首頁 / Dashboard) +├── /models ← Step 1:模型庫 +│ ├── /models?type=... ← 篩選結果 +│ └── /models/[id] ← 模型詳情 +├── /devices ← Step 2:裝置管理 +│ └── /devices/[id] ← 裝置詳情/燒錄 +├── /workspace ← Step 3:即時推論工作區 +│ └── /workspace/[deviceId] ← 單一裝置推論介面 +└── /settings ← 設定 +``` + +### 2.4 元件架構 + +``` +src/ +├── app/ # Next.js App Router +│ ├── layout.tsx # 全域 Layout (側邊欄 + 頂部列) +│ ├── page.tsx # Dashboard / 首頁 +│ ├── models/ +│ │ ├── page.tsx # 模型庫列表 +│ │ └── [id]/page.tsx # 模型詳情 +│ ├── devices/ +│ │ ├── page.tsx # 裝置列表 +│ │ └── [id]/page.tsx # 裝置詳情 + 燒錄 +│ └── workspace/ +│ └── [deviceId]/page.tsx # 即時推論工作區 +│ +├── components/ +│ ├── ui/ # shadcn/ui 基礎元件 +│ │ ├── button.tsx +│ │ ├── card.tsx +│ │ ├── dialog.tsx +│ │ ├── progress.tsx +│ │ ├── slider.tsx +│ │ └── ... +│ │ +│ ├── layout/ # 版面元件 +│ │ ├── sidebar.tsx # 側邊導覽列 +│ │ ├── header.tsx # 頂部列 (裝置狀態指示) +│ │ └── connection-status.tsx # Server 連線狀態 +│ │ +│ ├── models/ # 模型庫相關元件 +│ │ ├── model-card.tsx # 模型卡片 +│ │ ├── model-grid.tsx # 模型卡片網格 +│ │ ├── model-filters.tsx # 篩選面板 +│ │ ├── model-detail.tsx # 模型詳情 +│ │ └── model-compatibility.tsx # 硬體相容性徽章 +│ │ +│ ├── devices/ # 裝置相關元件 +│ │ ├── device-list.tsx # 裝置列表 +│ │ ├── device-card.tsx # 裝置卡片 (狀態、類型) +│ │ ├── device-status.tsx # 裝置即時狀態 +│ │ ├── flash-dialog.tsx # 燒錄確認對話框 +│ │ └── flash-progress.tsx # 燒錄進度條 +│ │ +│ ├── inference/ # 推論結果相關元件 +│ │ ├── inference-panel.tsx # 推論主面板 +│ │ ├── classification-result.tsx # 分類結果 (長條圖) +│ │ ├── detection-canvas.tsx # 物件偵測 Canvas (邊界框) +│ │ ├── performance-metrics.tsx # FPS / 延遲指標 +│ │ ├── confidence-slider.tsx # Confidence Threshold 滑桿 +│ │ └── device-log.tsx # 裝置日誌 (展開/收合) +│ │ +│ └── camera/ # Camera 相關元件 +│ ├── camera-feed.tsx # Camera 即時影像顯示 (MJPEG/WS) +│ ├── camera-overlay.tsx # 推論結果疊加層 (Canvas: 邊界框+標籤) +│ ├── camera-controls.tsx # Camera 控制面板 (選擇/解析度/開關) +│ ├── camera-selector.tsx # Camera 裝置選擇下拉選單 +│ └── camera-inference-view.tsx # Camera + 推論整合檢視 (主元件) +│ +│ ├── dashboard/ # 儀表板元件 +│ │ ├── activity-timeline.tsx # 活動時間線 +│ │ └── connected-devices-list.tsx # 已連線裝置列表 +│ │ +│ ├── theme-sync.tsx # 主題同步(Zustand → class) +│ └── lang-sync.tsx # 語言同步(Zustand → lang) +│ +├── stores/ # Zustand 狀態管理 +│ ├── device-store.ts # 裝置狀態 (列表、連線、狀態) +│ ├── model-store.ts # 模型庫狀態 (列表、篩選、選取) +│ ├── inference-store.ts # 推論結果狀態 (即時資料) +│ ├── camera-store.ts # Camera 狀態 (選擇、解析度、串流狀態) +│ └── flash-store.ts # 燒錄狀態 (進度、錯誤) +│ ├── settings-store.ts # 設定狀態 (主題、語言、persist) +│ ├── activity-store.ts # 活動記錄 (時間線事件) +│ └── device-preferences-store.ts # 裝置偏好 (alias、notes、persist) +│ +├── hooks/ # 自訂 Hooks +│ ├── use-websocket.ts # WebSocket 連線管理 +│ ├── use-device-events.ts # 裝置插拔事件 +│ ├── use-inference-stream.ts # 推論結果串流 +│ ├── use-camera-stream.ts # Camera 影像串流訂閱 +│ └── use-flash-progress.ts # 燒錄進度訂閱 +│ +├── lib/ # 工具函式 +│ ├── api.ts # REST API client +│ ├── i18n/ # 多語系模組 +│ │ ├── types.ts # TranslationDict 介面 + Paths 型別 +│ │ ├── en.ts # 英文翻譯字典 +│ │ ├── zh-TW.ts # 繁體中文翻譯字典 +│ │ └── index.ts # useTranslation / getTranslation +│ ├── toast.ts # Toast 通知工具 +│ ├── ws.ts # WebSocket client +│ └── constants.ts # 常數定義 +│ +└── types/ # TypeScript 型別定義 + ├── device.ts # 裝置相關型別 + ├── model.ts # 模型相關型別 + ├── inference.ts # 推論結果型別 + └── api.ts # API 回應型別 +``` + +### 2.5 狀態管理設計 (Zustand) + +```typescript +// stores/device-store.ts +interface DeviceState { + devices: Device[]; + selectedDevice: Device | null; + connectionStatus: 'connected' | 'disconnected' | 'connecting'; + + // Actions + setDevices: (devices: Device[]) => void; + selectDevice: (id: string) => void; + updateDeviceStatus: (id: string, status: DeviceStatus) => void; +} + +// stores/inference-store.ts +interface InferenceState { + isRunning: boolean; + results: InferenceResult[]; // 最近 N 筆結果 + latestResult: InferenceResult | null; + fps: number; + latency: number; + confidenceThreshold: number; + + // Actions + addResult: (result: InferenceResult) => void; + setConfidenceThreshold: (value: number) => void; + clearResults: () => void; + startInference: () => void; + stopInference: () => void; +} + +// stores/flash-store.ts +interface FlashState { + isFlashing: boolean; + progress: number; // 0-100 + stage: 'idle' | 'preparing' | 'transferring' | 'verifying' | 'rebooting' | 'done' | 'error'; + error: string | null; + + // Actions + startFlash: (deviceId: string, modelId: string) => void; + updateProgress: (progress: number, stage: string) => void; + setError: (error: string) => void; + reset: () => void; +} +``` + +### 2.6 Camera 狀態管理與元件設計 + +#### Camera Store + +```typescript +// stores/camera-store.ts +interface CameraState { + cameras: CameraDevice[]; // 可用的 Camera 列表 + selectedCameraId: string | null; // 目前選擇的 Camera + isStreaming: boolean; // 是否正在串流 + resolution: { width: number; height: number }; + streamUrl: string | null; // MJPEG 串流 URL + + // Actions + setCameras: (cameras: CameraDevice[]) => void; + selectCamera: (id: string) => void; + setResolution: (width: number, height: number) => void; + setStreaming: (streaming: boolean) => void; + setStreamUrl: (url: string | null) => void; +} + +interface CameraDevice { + id: string; + name: string; // e.g., "FaceTime HD Camera", "USB Camera" + index: number; // OpenCV camera index + resolutions: { width: number; height: number }[]; // 支援的解析度 +} +``` + +#### Camera 推論整合檢視元件 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Camera Inference View │ +│ ┌────────────────────────────────┐ ┌─────────────────────┐│ +│ │ │ │ 推論結果面板 ││ +│ │ Camera Feed │ │ ││ +│ │ (MJPEG 串流) │ │ 🏷 person 92% ││ +│ │ │ │ 🏷 car 85% ││ +│ │ ┌─────────┐ │ │ 🏷 dog 23% ││ +│ │ │ person │ ← 邊界框疊加 │ │ ││ +│ │ │ 92% │ (Canvas Layer) │ │ ─────────────────── ││ +│ │ └─────────┘ │ │ FPS: 24.3 ││ +│ │ │ │ Latency: 38ms ││ +│ │ │ │ Model: YOLOv5-face ││ +│ └────────────────────────────────┘ │ ││ +│ ┌────────────────────────────────┐ │ Threshold: ──●──── ││ +│ │ Camera: [FaceTime HD ▼] │ │ 0.65 ││ +│ │ Resolution: [640x480 ▼] │ │ ││ +│ │ [▶ Start] [⏹ Stop] │ │ [📋 Log ▼] ││ +│ └────────────────────────────────┘ └─────────────────────┘│ +└──────────────────────────────────────────────────────────────┘ +``` + +#### Camera Feed 元件設計 + +```typescript +// components/camera/camera-feed.tsx +// 顯示後端 MJPEG 串流的影像 + +interface CameraFeedProps { + streamUrl: string; // MJPEG stream URL from backend + onFrameSize?: (w: number, h: number) => void; +} + +// 實作方式: tag 直接載入 MJPEG stream URL +// MJPEG 是 motion JPEG,瀏覽器原生支援,無需特殊解碼 +// + + +// components/camera/camera-overlay.tsx +// 在 Camera Feed 上方疊加推論結果的 Canvas 層 + +interface CameraOverlayProps { + width: number; + height: number; + detections: DetectionResult[]; // 邊界框 + 標籤 + classifications: ClassResult[]; // 分類結果 + confidenceThreshold: number; +} + +// 使用 Canvas 2D API 繪製: +// - 邊界框 (不同顏色對應不同類別) +// - 類別標籤 + 信心分數 +// - 僅顯示 confidence > threshold 的結果 +``` + +### 2.7 WebSocket Hook 設計 + +```typescript +// hooks/use-websocket.ts +function useWebSocket(url: string, options?: { + onMessage?: (data: any) => void; + onConnect?: () => void; + onDisconnect?: () => void; + reconnect?: boolean; + reconnectInterval?: number; +}) { + // 自動連線、斷線重連、心跳檢測 + // 回傳: { send, disconnect, status } +} + +// hooks/use-inference-stream.ts +function useInferenceStream(deviceId: string) { + const ws = useWebSocket(`ws://localhost:3721/ws/devices/${deviceId}/inference`, { + onMessage: (data) => { + useInferenceStore.getState().addResult(data); + } + }); + return ws; +} + +// hooks/use-camera-stream.ts +function useCameraStream(cameraId: string) { + const { setStreamUrl, setStreaming } = useCameraStore(); + + const start = async (deviceId: string) => { + // POST /api/camera/start → 啟動後端 camera 擷取 + 推論 pipeline + // 取得 MJPEG stream URL + const res = await api.post(`/api/camera/start`, { cameraId, deviceId }); + setStreamUrl(res.data.streamUrl); + setStreaming(true); + }; + + const stop = async () => { + await api.post(`/api/camera/stop`); + setStreamUrl(null); + setStreaming(false); + }; + + return { start, stop }; +} +``` + +### 2.7 靜態輸出策略 + +前端使用 `next export` 產生純靜態檔案(HTML/CSS/JS),嵌入 Go binary: + +``` +next build → out/ 目錄 → Go embed → 單一執行檔 +``` + +Go 端使用 `embed.FS` 提供靜態檔案服務: + +```go +//go:embed frontend/out/* +var frontendFS embed.FS + +func main() { + // 將前端靜態檔案掛載到 HTTP server + http.Handle("/", http.FileServer(http.FS(frontendFS))) +} +``` + +--- + +## 3. 後端架構設計(本地端 Backend Server) + +### 3.1 模組架構 + +``` +server/ +├── main.go # 入口點 +│ +├── cmd/ # CLI 命令 +│ └── root.go # 啟動 server、版本資訊 +│ +├── internal/ +│ ├── api/ # HTTP API 層 +│ │ ├── router.go # 路由定義 +│ │ ├── middleware/ # 中間件 (CORS, logging) +│ │ ├── handlers/ +│ │ │ ├── device_handler.go # /api/devices 端點 +│ │ │ ├── model_handler.go # /api/models 端點 +│ │ │ └── system_handler.go # /api/system 端點 +│ │ └── ws/ +│ │ ├── hub.go # WebSocket 連線管理 +│ │ ├── inference_ws.go # 推論結果 WS 端點 +│ │ ├── flash_ws.go # 燒錄進度 WS 端點 +│ │ └── device_events_ws.go # 裝置事件 WS 端點 +│ │ +│ ├── device/ # 裝置管理核心 +│ │ ├── manager.go # Device Manager (偵測、生命週期) +│ │ ├── registry.go # Driver Registry +│ │ ├── session.go # 裝置 Session 管理 +│ │ └── types.go # 裝置相關型別 +│ │ +│ ├── driver/ # 裝置驅動層 +│ │ ├── interface.go # DeviceDriver 介面定義 +│ │ ├── kneron/ +│ │ │ ├── kl720_driver.go # Kneron Driver (KL520/KL720, JSON-RPC → Python bridge) +│ │ │ └── detector.go # USB 裝置偵測 (透過 Python bridge scan) +│ │ └── mock/ +│ │ └── mock_driver.go # 模擬驅動 (開發/測試用) +│ │ +│ ├── model/ # 模型管理 +│ │ ├── repository.go # 模型中繼資料儲存 +│ │ ├── downloader.go # 模型檔案下載 +│ │ └── types.go # 模型相關型別 +│ │ +│ ├── flash/ # 燒錄服務 +│ │ ├── service.go # 燒錄流程控制 +│ │ └── progress.go # 進度追蹤與回報 +│ │ +│ ├── inference/ # 推論服務 +│ │ ├── service.go # 推論流程控制 +│ │ ├── parser.go # 推論結果解析 +│ │ └── stream.go # 結果串流管理 +│ │ +│ ├── camera/ # Camera 管理 +│ │ ├── manager.go # Camera Manager (偵測、開啟、串流) +│ │ ├── capture.go # 影像擷取 (OpenCV/ffmpeg) +│ │ ├── mjpeg.go # MJPEG 串流 HTTP handler +│ │ └── pipeline.go # Camera → Device 推論 Pipeline +│ │ +│ └── config/ # 設定管理 +│ └── config.go # 設定檔讀取、預設值 +│ +├── pkg/ # 可重用套件 +│ ├── serial/ # 串列埠工具 +│ │ └── serial.go # go.bug.st/serial 封裝 +│ ├── usb/ # USB 工具 +│ │ └── monitor.go # USB 裝置熱插拔監聽 +│ └── logger/ # 日誌工具 +│ └── logger.go +│ +└── frontend/ # 嵌入的前端靜態檔案 + └── out/ # next export 輸出 +``` + +### 3.2 核心模組設計 + +#### 3.2.1 Device Manager + +```go +// internal/device/manager.go + +type DeviceManager struct { + registry *DriverRegistry + sessions map[string]*DeviceSession + usbMonitor *usb.Monitor + eventBus chan DeviceEvent + mu sync.RWMutex +} + +// 核心方法 +func (m *DeviceManager) Start() error // 啟動 USB 監聽 +func (m *DeviceManager) Stop() // 停止 +func (m *DeviceManager) ListDevices() []DeviceInfo // 列出裝置 +func (m *DeviceManager) GetDevice(id string) (*DeviceInfo, error) +func (m *DeviceManager) Connect(id string) error // 連線 +func (m *DeviceManager) Disconnect(id string) error // 斷線 +func (m *DeviceManager) Flash(id string, modelPath string, progressCh chan<- FlashProgress) error +func (m *DeviceManager) StartInference(id string, resultCh chan<- InferenceResult) error +func (m *DeviceManager) StopInference(id string) error +func (m *DeviceManager) Events() <-chan DeviceEvent // 裝置事件串流 +``` + +#### 3.2.2 Driver Registry + +```go +// internal/device/registry.go + +type DriverRegistry struct { + drivers []DriverFactory +} + +type DriverFactory struct { + Name string + Match func(usbInfo USBDeviceInfo) bool // VID/PID 比對 + Create func(usbInfo USBDeviceInfo) (DeviceDriver, error) +} + +func (r *DriverRegistry) Register(factory DriverFactory) +func (r *DriverRegistry) FindDriver(usbInfo USBDeviceInfo) (DeviceDriver, error) +``` + +#### 3.2.3 WebSocket Hub + +```go +// internal/api/ws/hub.go + +type Hub struct { + rooms map[string]map[*Client]bool // roomId → clients + register chan *Subscription + unregister chan *Subscription + broadcast chan *Message +} + +type Subscription struct { + Client *Client + Room string // e.g., "inference:device-123", "flash:device-123" +} + +// 前端訂閱特定裝置的推論結果或燒錄進度 +// Hub 負責將 DeviceManager 的事件分發到對應的 WebSocket 客戶端 +``` + +### 3.3 Kneron SDK 整合策略 + +由於 Kneron PLUS SDK 是 C/C++ API,有兩種整合方式: + +| 方式 | 優點 | 缺點 | 建議 | +|------|------|------|------| +| **CGo 直接呼叫** | 效能好、整合緊密 | 交叉編譯複雜、需 C 編譯環境 | Phase 1+ | +| **Python subprocess** | 開發快、隔離好 | 需安裝 Python、效能略差 | MVP 階段 | + +**MVP 建議方案:** 使用 Python subprocess 呼叫 Kneron PLUS Python SDK,透過 JSON 通訊。後續再以 CGo 替換。 + +```go +// internal/driver/kneron/kneron_sdk.go + +type KneronSDK struct { + pythonPath string + scriptDir string +} + +func (sdk *KneronSDK) LoadModel(deviceIndex int, nefPath string) error { + // 呼叫 Python script: kp_load_model_from_file() + cmd := exec.Command(sdk.pythonPath, filepath.Join(sdk.scriptDir, "load_model.py"), + "--device-index", strconv.Itoa(deviceIndex), + "--nef-path", nefPath, + ) + // 解析 JSON 輸出 +} + +func (sdk *KneronSDK) StartInference(deviceIndex int, resultCh chan<- InferenceResult) error { + // 啟動長時間運行的 Python 推論 process + // 透過 stdout JSON lines 串流結果 +} +``` + +### 3.4 Mock Driver(開發/測試用) + +為了在沒有實體硬體的情況下開發和測試前後端整合: + +```go +// internal/driver/mock/mock_driver.go + +type MockDriver struct { + deviceType string + connected bool +} + +func (d *MockDriver) Detect() bool { return true } + +func (d *MockDriver) Flash(binary []byte, progressCh chan<- FlashProgress) error { + // 模擬燒錄進度:每 200ms 增加 5% + for i := 0; i <= 100; i += 5 { + progressCh <- FlashProgress{Percent: i, Stage: "transferring"} + time.Sleep(200 * time.Millisecond) + } + return nil +} + +func (d *MockDriver) ReadInference() (*InferenceResult, error) { + // 回傳模擬的推論結果 + return &InferenceResult{ + Type: "classification", + Results: []ClassResult{ + {Label: "person", Confidence: 0.92}, + {Label: "car", Confidence: 0.05}, + }, + LatencyMs: 28, + }, nil +} +``` + +### 3.5 Camera Manager 設計 + +KL720 Dongle 本身沒有攝影機,推論流程是:**電腦端 Webcam 擷取影像 → 透過 USB 送到 KL720 NPU 推論 → 推論結果回傳 → 前端顯示影像 + 疊加邊界框**。 + +Camera 相關的影像擷取由 **後端 Go Server** 負責(而非前端瀏覽器),原因: + +| 方案 | 優點 | 缺點 | 決策 | +|------|------|------|------| +| **後端擷取 (Go/OpenCV)** | 直接將 frame 送到裝置推論,無需前端→後端傳輸影像;可背景執行 | 需 OpenCV 或 ffmpeg 依賴 | **採用** | +| 前端擷取 (getUserMedia) | 不需後端處理影像 | 影像需從瀏覽器→後端→裝置,延遲高;關頁面斷流 | 不採用 | + +#### Camera-Inference Pipeline 架構 + +``` +┌──────────┐ USB/UVC ┌─────────────────────────────────────────────────┐ +│ Webcam │───────────────►│ Go Backend Server │ +│ (UVC) │ frame │ │ +└──────────┘ │ ┌─────────────┐ frame ┌─────────────────┐ │ + │ │ Camera │──────────►│ Inference │ │ + │ │ Manager │ │ Pipeline │ │ + │ │ │ │ │ │ + │ │ OpenCV / │ result │ frame → resize │ │ + │ │ gocv │◄──────────│ → send to USB │ │ + │ │ │ │ → read result │ │ + │ └──────┬──────┘ └────────┬────────┘ │ + │ │ │ │ + │ MJPEG stream WS push │ + │ (HTTP /api/camera/stream) (inference) │ + │ │ │ │ + └─────────┼───────────────────────────┼──────────┘ + │ │ + ▼ ▼ + ┌─────────────────────────────────────────────────┐ + │ Browser (React) │ + │ │ + │ ┌──────────────────┐ ┌──────────────────────┐│ + │ │ MJPEG │ │ overlay ││ + │ │ Camera 即時影像 │ │ 邊界框 + 標籤 ││ + │ │ │ │ (InferenceResult) ││ + │ └──────────────────┘ └──────────────────────┘│ + └─────────────────────────────────────────────────┘ +``` + +#### Camera Manager 實作 + +```go +// internal/camera/manager.go + +type CameraManager struct { + cameras []CameraInfo + capture *gocv.VideoCapture // OpenCV camera capture + streaming bool + frameCh chan gocv.Mat // 影像 frame channel + mu sync.Mutex +} + +type CameraInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Index int `json:"index"` // OpenCV camera index (0, 1, 2...) + Width int `json:"width"` + Height int `json:"height"` +} + +// 列出可用 Camera +func (m *CameraManager) ListCameras() ([]CameraInfo, error) { + // 嘗試開啟 index 0, 1, 2... 偵測可用 camera +} + +// 開啟 Camera +func (m *CameraManager) Open(index int, width, height int) error { + m.capture = gocv.OpenVideoCapture(index) + m.capture.Set(gocv.VideoCaptureFrameWidth, float64(width)) + m.capture.Set(gocv.VideoCaptureFrameHeight, float64(height)) +} + +// 關閉 Camera +func (m *CameraManager) Close() error + +// 讀取一幀 +func (m *CameraManager) ReadFrame() (gocv.Mat, error) +``` + +#### Camera-Inference Pipeline + +```go +// internal/camera/pipeline.go + +type InferencePipeline struct { + camera *CameraManager + device driver.DeviceDriver + resultCh chan *driver.InferenceResult + frameCh chan []byte // JPEG encoded frames for MJPEG + running bool + mu sync.Mutex +} + +// 核心推論迴圈 +func (p *InferencePipeline) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + default: + // 1. 從 Camera 擷取一幀 + frame, err := p.camera.ReadFrame() + + // 2. 編碼為 JPEG → 送到 MJPEG 串流 (給前端顯示) + jpegBuf := encodeJPEG(frame) + p.frameCh <- jpegBuf + + // 3. 前處理 (resize 到模型輸入大小) + resized := preprocess(frame, modelInputWidth, modelInputHeight) + + // 4. 送到裝置推論 (透過 Kneron PLUS SDK) + result, err := p.device.RunInference(resized) + + // 5. 推論結果送到 WebSocket (給前端疊加邊界框) + p.resultCh <- result + } + } +} +``` + +#### MJPEG 串流 HTTP Handler + +```go +// internal/camera/mjpeg.go + +// MJPEG 是 motion JPEG 串流格式,瀏覽器 tag 原生支援 +// Content-Type: multipart/x-mixed-replace; boundary=frame +// +// 前端只需: + +func (h *MJPEGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + w.Header().Set("Cache-Control", "no-cache") + + for { + select { + case <-r.Context().Done(): + return + case jpegFrame := <-h.frameCh: + // 寫入 MJPEG boundary + JPEG data + fmt.Fprintf(w, "--frame\r\n") + fmt.Fprintf(w, "Content-Type: image/jpeg\r\n") + fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(jpegFrame)) + w.Write(jpegFrame) + fmt.Fprintf(w, "\r\n") + w.(http.Flusher).Flush() + } + } +} +``` + +#### Driver 介面擴充 — Camera 推論 + +```go +// 在 DeviceDriver 介面中新增 Camera 推論方法 + +type DeviceDriver interface { + // ... 原有方法 ... + + // Camera 推論(送入影像 frame,回傳推論結果) + RunInference(imageData []byte) (*InferenceResult, error) + + // 取得模型所需的輸入尺寸 + GetInputSize() (width int, height int, error) +} +``` + +#### 影像擷取技術選型 + +| 方案 | 優點 | 缺點 | 評估 | +|------|------|------|------| +| **gocv (OpenCV Go binding)** | 功能完整、影像處理能力強、Camera 支援好 | 需安裝 OpenCV C 函式庫 (可靜態連結) | **MVP 推薦** | +| ffmpeg (透過 subprocess) | 不需 CGo、格式支援廣 | 啟動慢、控制粒度粗 | 備選 | +| v4l2 (Linux) / AVFoundation (macOS) | 零依賴、效能最佳 | 需各平台實作、開發量大 | 不建議 MVP | + +> **注意:** 使用 gocv 會引入 OpenCV 依賴,影響「單一執行檔零依賴」的目標。 +> 緩解策略:靜態連結 OpenCV 或提供帶 OpenCV 的安裝包,或 MVP 先使用 ffmpeg subprocess 方案。 + +--- + +## 4. 前後端通訊設計 + +### 4.1 REST API 規格 + +#### 裝置管理 API + +``` +GET /api/devices + 回應: { devices: Device[] } + 說明: 列出所有已偵測到的裝置 + +GET /api/devices/:id + 回應: { device: DeviceDetail } + 說明: 取得單一裝置詳細資訊 + +POST /api/devices/:id/connect + 回應: { success: boolean } + 說明: 建立與裝置的連線 + +POST /api/devices/:id/disconnect + 回應: { success: boolean } + 說明: 中斷裝置連線 + +POST /api/devices/:id/flash + 請求: { modelId: string } + 回應: { taskId: string } + 說明: 開始燒錄流程(進度透過 WebSocket 回報) + +GET /api/devices/:id/status + 回應: { status: DeviceStatus } + 說明: 取得裝置當前狀態 + +POST /api/devices/:id/inference/start + 回應: { success: boolean } + 說明: 啟動推論(結果透過 WebSocket 串流) + +POST /api/devices/:id/inference/stop + 回應: { success: boolean } + 說明: 停止推論 +``` + +#### 模型管理 API + +``` +GET /api/models + 查詢: ?type=classification&hardware=kl720&q=face + 回應: { models: ModelSummary[], total: number } + 說明: 列出模型,支援篩選與搜尋 + +GET /api/models/:id + 回應: { model: ModelDetail } + 說明: 取得模型詳細資訊 + +POST /api/models/:id/download + 回應: { taskId: string, status: "downloading" | "cached" } + 說明: 下載模型到本地快取 +``` + +#### 媒體管理 API + +``` +POST /api/media/upload/image + 請求: multipart/form-data (file + deviceId) + 回應: { success: boolean, filename: string } + 說明: 上傳圖片檔案作為推論輸入 + +POST /api/media/upload/video + 請求: multipart/form-data (file + deviceId) + 回應: { success: boolean, filename: string } + 說明: 上傳影片檔案作為推論輸入 + +POST /api/media/url + 請求: { url: string, deviceId: string } + 回應: { success: boolean, resolvedUrl?: string } + 說明: 提供影片 URL 作為推論輸入,支援直接連結、YouTube(yt-dlp 解析)、RTSP +``` + +#### 自訂模型上傳 API + +``` +POST /api/models/upload + 請求: multipart/form-data (file + metadata JSON) + 回應: { success: boolean, model: ModelSummary } + 說明: 上傳自訂 .nef 模型 + +DELETE /api/models/:id + 回應: { success: boolean } + 說明: 刪除自訂模型 +``` + +#### Camera API + +``` +GET /api/camera/list + 回應: { cameras: CameraDevice[] } + 說明: 列出所有可用的 Camera 裝置 + +POST /api/camera/start + 請求: { cameraId: string, deviceId: string, resolution?: { width, height } } + 回應: { streamUrl: string, inputSize: { width, height } } + 說明: 啟動 Camera 擷取 + 推論 Pipeline + 回傳 MJPEG 串流 URL 供前端 顯示 + +POST /api/camera/stop + 回應: { success: boolean } + 說明: 停止 Camera 擷取與推論 + +GET /api/camera/stream + 查詢: ?id=0 + 回應: multipart/x-mixed-replace (MJPEG 串流) + 說明: Camera 即時影像 MJPEG 串流端點 + 前端直接 即可顯示 +``` + +#### 系統 API + +``` +GET /api/system/info + 回應: { version: string, platform: string, uptime: number } + 說明: Server 系統資訊 + +GET /api/system/health + 回應: { status: "ok" } + 說明: 健康檢查 +``` + +### 4.2 WebSocket 端點 + +``` +ws://localhost:3721/ws/devices/events + 訊息格式: { event: "connected" | "disconnected" | "updated", device: Device } + 說明: 裝置插拔與狀態變更事件 + +ws://localhost:3721/ws/devices/:id/inference + 訊息格式: InferenceResult (JSON) + 說明: 即時推論結果串流 + +ws://localhost:3721/ws/devices/:id/flash-progress + 訊息格式: { percent: number, stage: string, message?: string } + 說明: 燒錄進度即時更新 + +ws://localhost:3721/ws/devices/:id/log + 訊息格式: { timestamp: string, level: string, message: string } + 說明: 裝置通訊日誌 +``` + +### 4.3 資料流圖 + +#### Camera 推論資料流 (MVP 核心流程) + +``` +Webcam Go Server Browser (React) + │ │ │ + │ UVC frame │ │ + ├────────────────────►│ │ + │ (video capture) │ │ + │ │ ┌──────────────────────┐ │ + │ │ │ Camera-Inference │ │ + │ │ │ Pipeline │ │ + │ │ │ │ │ + │ │ │ 1. capture frame │ │ + │ │ │ 2. encode JPEG ──────┼──── MJPEG stream ───────►│ + │ │ │ │ (HTTP multipart) │ 顯示 + │ │ │ 3. resize frame │ │ Camera 畫面 + │ │ │ 4. send to device ───┼──► KL720 Dongle │ + │ │ │ │ (USB bulk transfer) │ + │ │ │ 5. read result ◄─────┼─── NPU inference │ + │ │ │ 6. push result ──────┼──── WebSocket ──────────►│ + │ │ │ │ { detections, fps } │ Canvas overlay + │ │ └──────────────────────┘ │ 邊界框繪製 + │ │ │ + │ │ ┌───────────────┤ + │ │ │ 疊加結果到 │ + │ │ │ Camera 畫面上 │ + │ │ └───────────────┘ +``` + +#### 裝置端直接推論資料流 (無 Camera) + +``` +Device (MCU) Go Server Browser (React) + │ │ │ + │ Serial/USB data │ │ + ├─────────────────────────────►│ │ + │ (raw inference output) │ │ + │ │ parse & structure │ + │ ├──────┐ │ + │ │ │ InferenceResult │ + │ │◄─────┘ │ + │ │ │ + │ │ WebSocket push │ + │ ├───────────────────────────────►│ + │ │ { type, results, latency } │ + │ │ │ Zustand store + │ │ ├──────┐ + │ │ │ │ update + │ │ │◄─────┘ + │ │ │ + │ │ │ React re-render + │ │ ├──────┐ + │ │ │ │ UI update + │ │ │◄─────┘ +``` + +### 4.4 通用 API 回應格式 + +```typescript +// 成功 +{ + "success": true, + "data": { ... } +} + +// 錯誤 +{ + "success": false, + "error": { + "code": "DEVICE_NOT_FOUND", + "message": "裝置未找到", + "detail": "未偵測到 ID 為 'abc123' 的裝置,請確認 USB 連接。" + } +} +``` + +### 4.5 錯誤代碼 + +| 代碼 | HTTP | 說明 | +|------|------|------| +| `DEVICE_NOT_FOUND` | 404 | 裝置未找到 | +| `DEVICE_NOT_CONNECTED` | 409 | 裝置未連線 | +| `DEVICE_BUSY` | 409 | 裝置正在燒錄或推論中 | +| `MODEL_NOT_FOUND` | 404 | 模型未找到 | +| `MODEL_INCOMPATIBLE` | 400 | 模型與裝置不相容 | +| `FLASH_FAILED` | 500 | 燒錄失敗 | +| `FLASH_TIMEOUT` | 408 | 燒錄逾時 | +| `INFERENCE_ERROR` | 500 | 推論錯誤 | +| `SERIAL_ERROR` | 500 | 串列埠通訊錯誤 | +| `CAMERA_NOT_FOUND` | 404 | Camera 裝置未找到 | +| `CAMERA_BUSY` | 409 | Camera 已被其他 pipeline 使用 | +| `CAMERA_OPEN_FAILED` | 500 | Camera 開啟失敗 | +| `PIPELINE_ALREADY_RUNNING` | 409 | 推論 pipeline 已在執行中 | + +--- + +## 5. 裝置通訊抽象層設計 + +### 5.1 Driver 介面 + +```go +// internal/driver/interface.go + +package driver + +// DeviceDriver 定義所有裝置驅動必須實作的介面 +type DeviceDriver interface { + // 基本資訊 + Info() DeviceInfo + + // 連線管理 + Connect() error + Disconnect() error + IsConnected() bool + + // 燒錄 + Flash(modelPath string, progressCh chan<- FlashProgress) error + + // 推論 + StartInference() error + StopInference() error + ReadInference() (*InferenceResult, error) + + // 命令 + SendCommand(cmd string) (string, error) + + // 系統 + GetSystemInfo() (*SystemInfo, error) + GetModelInfo() (*ModelInfo, error) +} + +// DeviceInfo 裝置基本資訊 +type DeviceInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "kneron_kl720", "kneron_kl730", ... + Port string `json:"port"` // 串列埠路徑 or USB device path + VendorID uint16 `json:"vendorId"` + ProductID uint16 `json:"productId"` + Status DeviceStatus `json:"status"` + FirmwareVer string `json:"firmwareVersion,omitempty"` +} + +type DeviceStatus string + +const ( + StatusDetected DeviceStatus = "detected" // 已偵測,未連線 + StatusConnecting DeviceStatus = "connecting" // 連線中 + StatusConnected DeviceStatus = "connected" // 已連線 + StatusFlashing DeviceStatus = "flashing" // 燒錄中 + StatusInferencing DeviceStatus = "inferencing" // 推論中 + StatusError DeviceStatus = "error" // 錯誤 + StatusDisconnected DeviceStatus = "disconnected" // 已斷線 +) + +// FlashProgress 燒錄進度 +type FlashProgress struct { + Percent int `json:"percent"` // 0-100 + Stage string `json:"stage"` // preparing, transferring, verifying, rebooting, done + Message string `json:"message,omitempty"` +} + +// InferenceResult 推論結果 +type InferenceResult struct { + Type string `json:"type"` // "classification" | "detection" + Timestamp int64 `json:"timestamp"` // Unix ms + LatencyMs float64 `json:"latencyMs"` + + // 分類結果 + Classifications []ClassResult `json:"classifications,omitempty"` + + // 偵測結果 + Detections []DetectionResult `json:"detections,omitempty"` +} + +type ClassResult struct { + Label string `json:"label"` + Confidence float64 `json:"confidence"` // 0.0 - 1.0 +} + +type DetectionResult struct { + Label string `json:"label"` + Confidence float64 `json:"confidence"` + BBox BBox `json:"bbox"` +} + +type BBox struct { + X float64 `json:"x"` // 左上角 X (0.0-1.0, 歸一化) + Y float64 `json:"y"` // 左上角 Y + Width float64 `json:"width"` // 寬 + Height float64 `json:"height"` // 高 +} +``` + +### 5.2 Kneron Driver 實作策略(已實作,KL520 + KL720) + +Go Driver 透過 Python subprocess(`kneron_bridge.py`)與 Kneron PLUS SDK 通訊,採用 JSON-RPC over stdin/stdout 模式。支援 KL520(USB Boot)和 KL720(Flash-based)兩種晶片。 + +``` +Go Server ──stdin/stdout──► kneron_bridge.py ──ctypes──► libkplus.dylib ──USB──► KL520/KL720 + JSON-RPC kp module BULK EP +``` + +```go +// internal/driver/kneron/kl720_driver.go + +type KneronDriver struct { + info driver.DeviceInfo + chipType string // "KL520" or "KL720" — 從 info.Type 推導 + connected bool + inferring bool + modelLoaded string + mu sync.Mutex + scriptPath string // kneron_bridge.py 路徑 + pythonCmd *exec.Cmd // Python 子進程 + stdin io.WriteCloser // JSON 指令輸入 + stdout *bufio.Scanner // JSON 回應輸出 + pythonReady bool +} + +// chipType 推導邏輯 +func NewKneronDriver(info driver.DeviceInfo) *KneronDriver { + chip := "KL520" + if strings.Contains(strings.ToLower(info.Type), "kl720") { + chip = "KL720" + } + return &KneronDriver{info: info, chipType: chip, ...} +} +``` + +**USB 裝置識別:** + +| 晶片 | Vendor ID | Product ID | USB | 韌體模式 | +|------|-----------|-----------|-----|---------| +| KL520 | 0x3231 | 0x0100 | 2.0 High-Speed | USB Boot(每次上傳) | +| KL720 (KDP2) | 0x3231 | 0x0720 | 3.0 Super-Speed | Flash-based(預燒) | +| KL720 (KDP legacy) | 0x3231 | 0x0200 | 3.0 Super-Speed | 舊版韌體,需更新 | + +**Python Bridge 指令協定:** + +| 指令 | 參數 | 回應 | 說明 | +|------|------|------|------| +| `scan` | — | `{devices: [{port, firmware, kn_number, product_id, connectable}]}` | USB 裝置掃描 | +| `connect` | `port`, `device_type` | `{status, firmware, kn_number, chip, kdp_legacy?}` | 晶片感知連線 + 自動韌體處理 | +| `load_model` | `path` | `{status, model_id, model_type, input_size, target_chip}` | NEF 模型載入 + 自動類型偵測 | +| `inference` | `image_base64` | `{taskType, latencyMs, detections[], classifications[]}` | 圖片推論(偵測或分類) | +| `reset` | — | `{status}` | 裝置重置(KP_RESET_REBOOT) | +| `disconnect` | — | `{status}` | 斷線 + 狀態清理 | + +**KL520 連線流程(USB Boot 模式):** + +``` +connect_devices(port_id) + → set_timeout(10000ms) + → 偵測 firmware 狀態 + → if "Loader": load_firmware_from_file(scpu, ncpu) + → sleep(5s) 等待 reboot + → 重新 scan + connect + → 回傳 firmware 版本 +``` + +**KL720 連線流程(Flash-based):** + +``` +KL720 KDP2 (pid=0x0720): + → connect_devices(port_id) — 正常連線 + → firmware 已在 flash,跳過上傳 + → 直接就緒 + +KL720 KDP legacy (pid=0x0200): + → connect_devices_without_check(port_id) — 繞過韌體版本檢查 + → load_firmware_from_file(KL720/fw_scpu.bin, fw_ncpu.bin) — 載入 KDP2 到 RAM + → sleep(5s) → reconnect + → 提示使用者執行 update_kl720_firmware.py 永久更新 +``` + +**KL720 KDP→KDP2 韌體更新流程(`update_kl720_firmware.py`):** + +``` +1. scan_devices() → 找到 pid=0x0200 的 KL720 +2. kp_connect_devices(port, &KDP_MAGIC_CONNECTION_PASS) — DFUT magic bypass +3. kp_update_kdp_firmware_from_files(dg, scpu, NULL) — flash SCPU +4. kp_reset_device() → sleep(3s) → reconnect +5. kp_update_kdp_firmware_from_files(dg, NULL, ncpu) — flash NCPU +6. kp_reset_device() → verify pid=0x0720 +``` + +**晶片感知 Error 40 處理(`Flash()` 方法):** + +``` +KL520 — Error 40 (KP_ERROR_USB_BOOT_LOAD_SECOND_MODEL_40): + → restartBridge() — 完全重啟 Python 子進程 + → 重新 connect + load_firmware + → retry load_model + +KL720 — Error 40(理論上不會發生): + → 先直接 retry load_model(不需 restart) + → 若仍失敗才 fallback 到 restartBridge() +``` + +**跨晶片模型路徑解析(`flash/service.go`):** + +```go +// resolveModelPath 自動替換晶片特定 NEF 路徑 +// 例:data/nef/kl520/kl520_20001_... → data/nef/kl720/kl720_20001_... +func resolveModelPath(filePath string, deviceType string) string { + targetChip := chipFromDeviceType(deviceType) // "kl520" or "kl720" + if alreadyTargetChip(filePath, targetChip) { return filePath } + candidate := swapChipPrefix(filePath, sourceChip, targetChip) + if fileExists(candidate) { return candidate } + return filePath // fallback 原路徑 +} +``` + +**多模型後處理引擎(`kneron_bridge.py`):** + +| 模型類型 | Model ID | 輸入尺寸 | 後處理 | 適用晶片 | +|---------|----------|---------|--------|---------| +| Tiny YOLO v3 | 0/19 | 224×224 | 雙尺度 YOLO decode + NMS | KL520 | +| YOLOv5s | 20005 | 640×640 | 三尺度 YOLO decode + NMS | KL520, KL720 | +| FCOS | 20004 | 512×512 | 5 層 FPN decode + NMS | KL520, KL720 | +| SSD (人臉) | — | 320×320 | SSD bbox decode + NMS | KL520 | +| ResNet18 | 20001 | 224×224 | Softmax + Top-5 分類 | KL520, KL720 | + +### 5.3 Driver 註冊流程 + +```go +// main.go or cmd/root.go + +func setupDrivers(registry *device.DriverRegistry) { + // 註冊 Kneron Driver(KL520 + KL720) + // detector.go 的 chipFromProductID() 自動判斷晶片型號 + // KL520: PID=0x0100, KL720: PID=0x0200(KDP)/0x0720(KDP2) + registry.Register(device.DriverFactory{ + Name: "kneron_kl520", + Match: func(info USBDeviceInfo) bool { + return info.VendorID == 0x3231 && info.ProductID == 0x0100 + }, + Create: func(info USBDeviceInfo) (DeviceDriver, error) { + return kneron.NewKneronDriver(info) + }, + }) + registry.Register(device.DriverFactory{ + Name: "kneron_kl720", + Match: func(info USBDeviceInfo) bool { + return info.VendorID == 0x3231 && + (info.ProductID == 0x0200 || info.ProductID == 0x0720) + }, + Create: func(info USBDeviceInfo) (DeviceDriver, error) { + return kneron.NewKneronDriver(info) + }, + }) + + // 註冊 Mock Driver(開發模式) + if config.DevMode { + registry.Register(device.DriverFactory{ + Name: "mock", + Match: func(info USBDeviceInfo) bool { return true }, + Create: func(info USBDeviceInfo) (DeviceDriver, error) { + return mock.NewMockDriver(), nil + }, + }) + } +} +``` + +--- + +## 6. 模型庫設計 + +### 6.1 模型中繼資料結構 + +```typescript +// types/model.ts + +interface Model { + id: string; + name: string; + description: string; + thumbnail: string; // 縮圖 URL + + // 分類 + taskType: "classification" | "detection" | "segmentation"; + categories: string[]; // ["face", "vehicle", "industrial"] + + // 技術規格 + framework: string; // "kneron_nef" | "onnx" | "tflite" + inputSize: { width: number; height: number }; + modelSize: number; // bytes + quantization: "int8" | "float16" | "float32"; + + // 效能指標 + accuracy: number; // 0-100 (%) + latencyMs: number; // 推論延遲 + fps: number; // 在目標硬體上的 FPS + + // 相容性 + supportedHardware: string[]; // ["kneron_kl720", "kneron_kl730"] + + // 模型內容 + labels: string[]; // 類別標籤列表 + sampleImages: string[]; // 範例圖片 + + // 元資料 + version: string; + author: string; + license: string; + createdAt: string; + updatedAt: string; +} +``` + +### 6.2 MVP 模型庫資料來源 + +MVP 階段使用本地 JSON 檔案作為模型中繼資料儲存: + +``` +server/data/ +├── models.json # 模型中繼資料列表 +└── models/ # 模型檔案目錄 + ├── kneron-face-detect-v1.nef + ├── kneron-person-detect-v1.nef + └── ... +``` + +後續可擴展為: +- 遠端模型庫 API +- 社群模型市集 +- Kneron 官方模型庫整合 + +### 6.3 模型篩選邏輯 + +```go +// internal/model/repository.go + +type ModelFilter struct { + TaskType string `query:"type"` // classification, detection + Hardware string `query:"hardware"` // kneron_kl720 + Category string `query:"category"` // face, vehicle + Query string `query:"q"` // 關鍵字搜尋 + SortBy string `query:"sortBy"` // name, accuracy, size + SortOrder string `query:"order"` // asc, desc + Page int `query:"page"` + PageSize int `query:"pageSize"` +} + +func (r *Repository) List(filter ModelFilter) ([]ModelSummary, int, error) { + // 1. 載入所有模型中繼資料 + // 2. 依篩選條件過濾 + // 3. 排序 + // 4. 分頁 + // 5. 回傳結果與總數 +} +``` + +--- + +## 7. 資料模型設計 + +### 7.1 核心型別對照 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 前端 (TypeScript) 後端 (Go) │ +│ │ +│ Device ←→ device.DeviceInfo │ +│ DeviceStatus ←→ device.DeviceStatus │ +│ Model ←→ model.Model │ +│ ModelSummary ←→ model.ModelSummary │ +│ InferenceResult ←→ driver.InferenceResult │ +│ ClassResult ←→ driver.ClassResult │ +│ DetectionResult ←→ driver.DetectionResult │ +│ FlashProgress ←→ driver.FlashProgress │ +│ DeviceEvent ←→ device.DeviceEvent │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 本地儲存 + +MVP 階段不使用資料庫,使用檔案系統: + +``` +~/.edge-ai-platform/ # 應用資料目錄 +├── config.json # 使用者設定 +├── models/ # 模型檔案快取 +│ ├── kneron-face-detect-v1.nef +│ └── ... +└── logs/ # 日誌 + └── server.log +``` + +--- + +## 8. MVP 功能模組對照 + +### PRD 功能 → 技術實作對照 + +| PRD 功能 (B4) | 前端元件 | 後端模組 | API | 優先級 | +|--------------|---------|---------|-----|:------:| +| **F1 模型庫瀏覽** | `ModelGrid`, `ModelCard`, `ModelFilters` | `model/repository` | `GET /api/models` | P0 | +| **F1 模型篩選** | `ModelFilters` (TaskType, Hardware, Search) | `model/repository.List(filter)` | `GET /api/models?type=&q=` | P0 | +| **F1 模型詳情** | `ModelDetail`, `ModelCompatibility` | `model/repository` | `GET /api/models/:id` | P0 | +| **F1 模型下載** | 下載按鈕 + 進度 | `model/downloader` | `POST /api/models/:id/download` | P0 | +| **F2 裝置偵測** | `DeviceList`, `DeviceCard` | `device/manager`, `usb/monitor` | `GET /api/devices` + WS | P0 | +| **F2 裝置連線** | `DeviceStatus` | `device/manager.Connect()` | `POST /api/devices/:id/connect` | P0 | +| **F2 裝置熱插拔** | `useDeviceEvents` hook | `usb/monitor` | `ws://.../ws/devices/events` | P1 | +| **F3 燒錄** | `FlashDialog`, `FlashProgress` | `flash/service`, `driver` | `POST .../flash` + WS progress | P0 | +| **F3 燒錄進度** | `FlashProgress` (進度條) | `flash/progress` | `ws://.../flash-progress` | P0 | +| **F4 推論串流** | `InferencePanel` | `inference/service`, `driver` | WS `/inference` | P0 | +| **F4 分類結果** | `ClassificationResult` (長條圖) | `inference/parser` | InferenceResult JSON | P0 | +| **F4 偵測結果** | `DetectionCanvas` (邊界框) | `inference/parser` | InferenceResult JSON | P1 | +| **F4 效能指標** | `PerformanceMetrics` (FPS, 延遲) | `inference/service` | 含在 InferenceResult 內 | P0 | +| **F4 Threshold** | `ConfidenceSlider` | 前端過濾 | 不需 API(前端處理) | P1 | +| **F4 裝置日誌** | `DeviceLog` | `api/ws/device_events_ws` | `ws://.../log` | P1 | +| **F5 Camera 列表** | `CameraSelector` | `camera/manager.ListCameras()` | `GET /api/camera/list` | P0 | +| **F5 Camera 串流** | `CameraFeed` (MJPEG ``) | `camera/mjpeg`, `camera/capture` | `GET /api/camera/stream` | P0 | +| **F5 Camera 推論** | `CameraInferenceView` | `camera/pipeline` | `POST /api/camera/start` | P0 | +| **F5 推論疊加** | `CameraOverlay` (Canvas 邊界框) | `inference/service` | WS `/inference` | P0 | +| **F5 Camera 控制** | `CameraControls` (解析度/開關) | `camera/manager` | `POST /api/camera/stop` | P1 | + +### 8.5 已實作額外功能技術對照 + +以下為 MVP 開發期間實作但不在原始 PRD 範圍內的功能,及其技術實作對照。 + +#### 8.5.1 多來源推論輸入(F5) + +| 前端元件 | 後端模組 | API | +|---------|---------|-----| +| `SourceSelector` (Tab: camera/image/video) | `camera/manager` | `POST /api/camera/start` | +| Image upload button | `api/handlers/media_handler` | `POST /api/media/upload/image` | +| Batch image upload (多選 + drag & drop) | `api/handlers/camera_handler` | `POST /api/media/upload/batch-images` | +| Batch image frame viewer | `camera/multi_image_source` | `GET /api/media/batch-images/:index` | +| Video upload button | `api/handlers/media_handler` | `POST /api/media/upload/video` | +| URL input + submit | `api/handlers/media_handler` | `POST /api/media/url` | + +**影片 URL 處理流程:** + +``` +URL 輸入 → 後端分類 URL 類型: +├── 直接連結 (.mp4 等) → 直接開啟影片串流 +├── YouTube URL → yt-dlp 解析取得直接連結 → 開啟影片串流 +└── 無效 URL → 回傳錯誤 +``` + +#### 8.5.2 多語系支援 i18n(F6) + +**架構設計:** + +``` +src/lib/i18n/ +├── types.ts # TranslationDict 介面 + Paths 遞迴型別 +├── en.ts # 英文字典(~130+ keys) +├── zh-TW.ts # 繁體中文字典 +└── index.ts # useTranslation() hook + getTranslation() helper +``` + +| 使用場景 | API | 說明 | +|---------|-----|------| +| React 元件 | `useTranslation()` | Hook,語言切換時自動 re-render | +| Zustand Store | `getTranslation()` | 非 hook 存取器,用於 store action 中的 toast 訊息 | +| 參數插值 | `t('key', { n: 5 })` | 支援 `{param}` 格式的字串替換 | + +**型別安全:** `TranslationKey` 由 `Paths` 遞迴產生,所有 `t()` 呼叫都有 key 自動完成,新增 key 時兩份語言檔必須同步更新否則 TypeScript 報錯。 + +**同步元件:** +- `LangSync`:監聽 settings store 的 `language`,同步更新 `` 屬性 +- `ThemeSync`:監聽 settings store 的 `theme`,同步更新 `` 的 `class` 屬性(加/移除 `dark`) + +#### 8.5.3 深色模式(F7) + +| 元件 | 機制 | +|------|------| +| `ThemeSync` | Zustand `settings-store.theme` → `document.documentElement.classList.toggle('dark')` | +| Tailwind CSS | 使用 `dark:` variant,搭配 CSS custom properties 定義色彩 | +| Settings UI | 淺色/深色切換按鈕,persist 至 localStorage | + +#### 8.5.4 儀表板 Dashboard(F8) + +| 前端元件 | 資料來源 | 說明 | +|---------|---------|------| +| `app/page.tsx` | `model-store`, `device-store`, `activity-store` | 統計卡片 + 快速操作 | +| `ActivityTimeline` | `activity-store` | 最近 10 筆活動,含圖示和相對時間 | +| `ConnectedDevicesList` | `device-store` | 已連線裝置即時列表 | + +**Activity Store 事件類型:** `model_upload`, `model_delete`, `device_connect`, `device_disconnect`, `flash_start`, `flash_complete`, `flash_error` + +#### 8.5.5 裝置偏好設定(F9) + +| Store | 持久化 | 資料結構 | +|-------|--------|---------| +| `device-preferences-store` | Zustand persist → localStorage | `Record` | + +#### 8.5.6 模型比較(F10) + +| 前端元件 | 功能 | +|---------|------| +| `ModelComparisonDialog` | 最多 3 模型並排比較 | +| 比較指標 | 準確率、FPS、模型大小、延遲、任務類型 | +| 視覺化 | 指標長條圖(百分比 bar) | + +#### 8.5.7 設定頁面(F12) + +| 區塊 | 內容 | +|------|------| +| 伺服器設定 | API URL、WebSocket URL(唯讀顯示) | +| 外觀 | 主題切換(淺色/深色)、語言切換(EN/zh-TW) | +| 關於 | 版本資訊 | +| 操作 | 恢復預設值 | + +**Settings Store:** `settings-store.ts` 管理 `theme` ('light' | 'dark') 和 `language` ('en' | 'zh-TW'),persist 至 localStorage。 + +#### 8.5.8 自訂模型上傳(F11) + +| 前端元件 | 後端模組 | API | +|---------|---------|-----| +| `ModelUploadDialog` | `api/handlers/model_handler` | `POST /api/models/upload` | +| 表單驗證(檔案、名稱、任務類型、標籤) | 模型元資訊存檔 | `DELETE /api/models/:id` | + +**上傳流程:** 前端 FormData(.nef + 元資訊)→ 後端儲存檔案 + 建立模型紀錄 → 回傳模型 ID → 前端更新模型庫。 + +#### 8.5.9 裝置健康狀態與連線歷史(F13) + +| 前端元件 | 資料來源 | 說明 | +|---------|---------|------| +| `DeviceHealthCard` | `device-store` | 狀態、韌體版本、運行時間(useEffect + setInterval 更新) | +| `DeviceConnectionLog` | `device-preferences-store` | 連線/斷線事件紀錄,含時間戳記 | + +**Hydration 安全:** 運行時間使用 `useState(0)` + `useEffect` 避免 `Date.now()` 造成 SSR 不匹配;時間格式化使用 `suppressHydrationWarning`。 + +#### 8.5.10 批次圖片推論(F14) + +| 前端元件 | 後端模組 | API | +|---------|---------|-----| +| `BatchImageThumbnails` | `camera/multi_image_source.go` | `POST /api/media/upload/batch-images` | +| `SourceSelector`(多選 + drag & drop) | `api/handlers/camera_handler` | `GET /api/media/batch-images/:index` | +| `InferencePanel`(批次進度卡片) | `camera/pipeline.go`(SourceBatchImage 分支) | WebSocket 推論結果串流 | + +**資料流:** + +``` +前端多選/拖放圖片 → FormData POST 至後端 +→ 後端建立 MultiImageSource → Pipeline 逐張 ReadFrame +→ NPU 推論 → InferenceResult 加入 imageIndex/totalImages/filename +→ WebSocket 即時回傳 → 前端 addBatchResult → 更新進度 + 縮圖狀態 +→ 使用者點擊縮圖 → 切換 batchSelectedIndex → 顯示對應圖片 + BBox overlay +``` + +**前端 Store 擴展:** +- `camera-store`:新增 `batchImages`, `batchSelectedIndex`, `batchProcessedCount`, `uploadBatchImages()` +- `inference-store`:新增 `batchResults: Record`, `addBatchResult()` +- `use-inference-stream`:判斷 `imageIndex !== undefined` 走批次處理邏輯 + +**型別擴展:** +- `SourceType` 新增 `'batch_image'` +- `BatchImageInfo`:`{ index, filename, width, height }` +- `InferenceResult` 新增 `imageIndex?`, `totalImages?`, `filename?` + +#### 8.5.11 攝影機自動偵測(F15) + +| 前端元件 | 後端模組 | API | +|---------|---------|-----| +| `workspace-client.tsx`(fetchCameras) | `camera/manager.go` | `GET /api/camera/list` | +| `SourceSelector`(cameraDisabled 邏輯) | `camera/ffmpeg_detect.go` | — | + +**偵測流程:** + +``` +workspace 載入 → fetchCameras() → GET /api/camera/list +→ 後端 Manager.ListCameras(): + ├── mockMode=true → 回傳 mock camera + └── mockMode=false → ffmpeg 偵測真實攝影機 → 回傳列表或空陣列 +→ 前端 cameras.length === 0 → useEffect 切換至 Image tab + disable Camera tab +``` + +**Hydration 安全:** 使用 `useHydrated()` hook 確保 `disabled` 屬性在 SSR 時為 `false`(與 `Tabs value="camera"` 一致),hydration 完成後才根據 cameras 列表設定 disabled 狀態。 + +#### 8.5.12 跨平台安裝與分發(F16) + +| 安裝腳本 | 後端模組 | 配置檔 | +|---------|---------|--------| +| `scripts/install.sh`(macOS/Linux) | `internal/deps/checker.go` | `.goreleaser.yaml` | +| `scripts/install.ps1`(Windows) | `server/main.go`(baseDir) | `Makefile`(release targets) | +| `scripts/setup-kneron.sh`(獨立硬體設定) | — | — | +| `scripts/kneron_detect.py`(USB 偵測) | — | — | + +**安裝流程(macOS `install.sh`):** + +``` +curl | bash → detect_platform() → resolve_version() +→ Step 1/4: install_binary() — 下載 tar.gz + 解壓到 ~/.edge-ai-platform/ + symlink +→ Step 2/4: setup_libusb() — brew install libusb (macOS) / apt install (Linux) +→ Step 3/4: setup_python_venv() — python3 -m venv + pip install pyusb +→ Step 4/4: check_optional_deps() + detect_kneron_devices() +``` + +**安裝流程(Windows `install.ps1`):** + +``` +irm | iex → Invoke-RestMethod (resolve version) +→ Step 1/4: 下載 zip + Expand-Archive + 加入 PATH (User scope) +→ Step 2/4: 檢查 libusb-1.0.dll,提示 Zadig 安裝 +→ Step 3/4: python -m venv + pip install pyusb(同時嘗試 python3 / python) +→ Step 4/4: 檢查 ffmpeg/yt-dlp + kneron_detect.py +``` + +**Binary 路徑解析(`main.go` baseDir):** + +```go +func baseDir(devMode bool) string { + if devMode { return "." } // go run 用 CWD + exe, _ := os.Executable() + return filepath.Dir(exe) // production 用 binary 目錄 +} +// 3 處路徑:data/models.json, data/custom-models, scripts/kneron_bridge.py +``` + +**啟動依賴檢查(`internal/deps/checker.go`):** + +``` +Server 啟動 → deps.PrintStartupReport(logger) +→ 檢查 ffmpeg (exec.LookPath) → [OK] / [MISSING] +→ 檢查 yt-dlp → [OK] / [OPTIONAL] +→ 檢查 python3 → [OK] / [OPTIONAL] +``` + +**GoReleaser 打包產出:** + +| 平台 | 格式 | 檔名範例 | +|------|------|---------| +| macOS Intel | tar.gz | `edge-ai-platform_v0.1.0_darwin_amd64.tar.gz` | +| macOS Apple Silicon | tar.gz | `edge-ai-platform_v0.1.0_darwin_arm64.tar.gz` | +| Windows x64 | zip | `edge-ai-platform_v0.1.0_windows_amd64.zip` | + +每個 archive 含:`edge-ai-server` binary + `data/models.json` + `scripts/kneron_bridge.py` + `scripts/requirements.txt` + `scripts/firmware/KL520/` + `scripts/firmware/KL720/` + +#### 8.5.13 Kneron 硬體通訊整合(F18 — KL520 + KL720) + +| 前端元件 | 後端模組 | 腳本/資源 | +|---------|---------|----------| +| 既有 inference panel | `internal/driver/kneron/kl720_driver.go` | `scripts/kneron_bridge.py` | +| 既有 device panel | `internal/driver/kneron/detector.go` | `scripts/firmware/KL520/fw_{scpu,ncpu}.bin` | +| — | `internal/flash/service.go` (resolveModelPath) | `scripts/firmware/KL720/fw_{scpu,ncpu}.bin` | +| — | `internal/driver/interface.go` | `scripts/update_kl720_firmware.py` | +| — | — | `data/nef/kl720/kl720_2000{1,4,5}_*.nef` | + +**通訊架構(晶片感知):** + +``` +┌──────────┐ JSON-RPC ┌──────────────────┐ ctypes ┌──────────────┐ USB BULK ┌─────────────┐ +│ Go Server│──stdin/out──►│ kneron_bridge.py │──────────►│libkplus.dylib│──────────►│ KL520 (2.0) │ +│ (Gin) │◄────────────│ (Python 3.9) │◄──────────│(Kneron PLUS) │◄──────────│ KL720 (3.0) │ +└──────────┘ └──────────────────┘ └──────────────┘ └─────────────┘ + │ │ + │ chipType="KL520"/"KL720" │ _device_chip 全域變數 + │ device_type 參數傳遞 │ 依晶片調整連線/韌體邏輯 +``` + +**kneron_bridge.py 推論流程:** + +```python +# 1. 接收 base64 圖片 +img_bytes = base64.b64decode(image_base64) + +# 2. OpenCV 解碼 + 色彩空間轉換 +img = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) +img_bgr565 = cv2.cvtColor(img, cv2.COLOR_BGR2BGR565) + +# 3. 建立推論描述符 +inf_config = kp.GenericImageInferenceDescriptor( + model_id=model_id, + input_node_image_list=[kp.GenericInputNodeImage( + image=img_bgr565, + image_format=kp.ImageFormat.KP_IMAGE_FORMAT_RGB565, + )] +) + +# 4. 送出推論 + 接收結果 +kp.inference.generic_image_inference_send(device_group, inf_config) +result = kp.inference.generic_image_inference_receive(device_group) + +# 5. 依 model_type 自動選擇後處理 +# tiny_yolov3 → _parse_yolo_output(ANCHORS_TINY_YOLOV3) +# yolov5s → _parse_yolo_output(ANCHORS_YOLOV5S) +# fcos → _parse_fcos_output() +# ssd → _parse_ssd_output() +# resnet18 → _parse_classification_output() +``` + +**後處理引擎細節:** + +| 模型 | 偵測頭 / 輸出 | Anchor / 架構 | 信心門檻 | NMS IoU | +|------|-------------|--------------|---------|---------| +| Tiny YOLO v3 | 雙尺度 7×7 + 14×14 | (81,82)(135,169)(344,319) / (10,14)(23,27)(37,58) | ≥ 0.25 | 0.45 | +| YOLOv5s | 三尺度 P5/P4/P3 | (116,90)(156,198)(373,326) / (30,61)(62,45)(59,119) / (10,13)(16,30)(33,23) | ≥ 0.25 | 0.45 | +| FCOS | 5 層 FPN (stride 8~128) | Anchor-free (centerness × cls) | ≥ 0.25 | 0.45 | +| SSD 人臉 | 2 類(bg + face) | SSD prior boxes | ≥ 0.25 | 0.45 | +| ResNet18 | 1000 類 softmax | Top-5 排序 | — | — | + +**Letterbox 校正(`_correct_bbox_for_letterbox`):** + +SDK 的 `hw_pre_proc_info` 提供 `pad_left`, `pad_top`, `resized_w`, `resized_h` 等參數,後處理自動從模型空間轉換到原始圖片空間,消除 padding 偏移。 + +**macOS Apple Silicon 相容性:** + +``` +Kneron PLUS SDK (C source) ──編譯──► libkplus.dylib (x86_64) + ↓ +Python 3.9 (x86_64 via Rosetta 2) ──ctypes.cdll.LoadLibrary──► libkplus.dylib + ↓ +pendian.h 修正:#if defined(__APPLE__) #include +CMakeLists.txt 修正:移除 -Werror,改用 -Wno-unused-but-set-variable +``` + +**KL520 驗證結果(bike_cars_street_224x224.bmp):** + +| 物件 | 信心值 | BBox (x, y, w, h) | +|------|--------|-------------------| +| car | 0.998 | (0.66, 0.38, 0.20, 0.37) | +| car | 0.971 | (0.53, 0.34, 0.43, 0.43) | +| person | 0.965 | (0.26, 0.34, 0.10, 0.44) | +| person | 0.754 | (0.18, 0.34, 0.26, 0.43) | +| car | 0.499 | (0.42, 0.41, 0.12, 0.07) | +| bicycle | 0.465 | (0.26, 0.50, 0.12, 0.37) | +| car | 0.368 | (0.16, 0.37, 0.05, 0.05) | +| car | 0.294 | (0.45, 0.43, 0.06, 0.09) | + +推論延遲:KL520 ~25ms(NPU 硬體推論) + +**KL720 驗證結果:** + +| 項目 | 結果 | +|------|------| +| 韌體更新 | KDP→KDP2 成功(PID 0x0200→0x0720,firmware: KDP2 Comp/F) | +| 連線 | `connect_devices()` 正常連線 | +| 模型載入 | NEF 模型載入成功(跨晶片路徑解析正確) | +| USB 速度 | USB 3.0 Super-Speed | + +#### 8.5.14 圖形化安裝與解除安裝程式(F17) + +**技術選型:Wails v2** + +| 項目 | 規格 | +|------|------| +| 框架 | [Wails v2](https://wails.io/) — Go + WebView GUI 框架 | +| 選型理由 | 共用 Go 工具鏈(不需 Node.js runtime)、單一 binary 產出、WebView 體積小(~5MB vs Electron ~150MB)、macOS/Windows 原生 WebView 支援 | +| 前端 | 輕量 HTML/CSS/JS(vanilla 或 Preact),不使用 React 以減少安裝程式體積 | +| 後端 | Go — 執行實際安裝邏輯(檔案操作、下載、venv 建立、硬體偵測) | +| 產出 | macOS: `.app` → `.dmg`、Windows: `.exe`(Wails 內建 NSIS 包裝) | +| 架構分離 | Installer 是獨立 Go module(`installer/`),與 server 分開編譯,但共用 GoReleaser 發布 | + +**安裝程式目錄結構:** + +``` +installer/ +├── main.go # Wails 應用程式入口 +├── app.go # Go 後端邏輯(安裝/解除安裝/偵測) +├── wails.json # Wails 專案設定 +├── frontend/ +│ ├── index.html # 安裝精靈 UI +│ ├── style.css # 樣式(含深色模式) +│ └── main.js # 前端邏輯(步驟導航、進度更新) +└── build/ + ├── darwin/ + │ └── Info.plist # macOS app metadata + └── windows/ + └── installer.nsi # NSIS 安裝腳本 +``` + +**Go 後端 API(Wails binding → 前端可呼叫):** + +```go +// installer/app.go + +type Installer struct { + ctx context.Context + installDir string + platform string // "darwin" / "windows" + arch string // "amd64" / "arm64" + progress chan StepProgress +} + +// 安裝流程 API +func (a *Installer) GetSystemInfo() SystemInfo // 偵測 OS、已安裝版本、磁碟空間 +func (a *Installer) SetInstallDir(dir string) error // 設定安裝路徑 +func (a *Installer) GetComponents() []Component // 取得可安裝元件清單 +func (a *Installer) Install(components []string) error // 執行安裝(串流進度) +func (a *Installer) DetectHardware() []HardwareInfo // 掃描 Kneron USB 裝置 +func (a *Installer) UpdateKL720Firmware() error // KDP→KDP2 韌體更新 +func (a *Installer) LaunchServer() error // 啟動 edge-ai-server +func (a *Installer) OpenBrowser() error // 開啟瀏覽器 + +// 解除安裝 API +func (a *Installer) GetInstalledInfo() InstalledInfo // 讀取已安裝版本 + 大小 +func (a *Installer) Uninstall(keepData bool) error // 解除安裝(可選保留資料) + +// 進度回報(透過 Wails Events 推送到前端) +type StepProgress struct { + Step string `json:"step"` // "download", "extract", "python", "libusb", ... + Percent int `json:"percent"` // 0-100 + Message string `json:"message"` // 人類可讀狀態 + Error string `json:"error"` // 錯誤訊息(空=正常) +} +``` + +**安裝精靈 UI 流程(6 步驟):** + +``` +┌─────────────────────────────────────────────────────────┐ +│ Step 1: 歡迎頁 │ +│ │ +│ ┌─────────────────────────────────┐ │ +│ │ 🔧 Edge AI Platform Installer │ │ +│ │ │ │ +│ │ 版本: v0.2.0 │ │ +│ │ 安裝大小: ~350 MB │ │ +│ │ │ │ +│ │ ☐ 我同意授權條款 │ │ +│ │ │ │ +│ │ [下一步 →] │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 2: 安裝路徑 │ +│ │ +│ 安裝位置: │ +│ ┌──────────────────────────────┐ [瀏覽...] │ +│ │ ~/.edge-ai-platform │ │ +│ └──────────────────────────────┘ │ +│ 可用空間: 58.3 GB │ +│ 需要空間: ~350 MB │ +│ │ +│ [← 上一步] [下一步 →] │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 3: 元件選擇 │ +│ │ +│ 必要元件: │ +│ ☑ Edge AI Server (10 MB) ── 核心伺服器 │ +│ ☑ Python 環境 (250 MB) ── Kneron SDK 執行 │ +│ ☑ 預訓練模型 (73 MB) ── KL520/KL720 NEF │ +│ ☑ 韌體檔案 (2 MB) ── KL520/KL720 FW │ +│ ☑ USB 驅動 (libusb) (1 MB) ── 裝置通訊 │ +│ │ +│ 可選元件: │ +│ ☐ ffmpeg (80 MB) ── 攝影機/影片串流 │ +│ ☐ yt-dlp (15 MB) ── YouTube 影片 │ +│ │ +│ [← 上一步] [安裝 →] │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 4: 安裝進度 │ +│ │ +│ ✅ 下載 Edge AI Server 完成 │ +│ ✅ 解壓縮檔案 完成 │ +│ ✅ 安裝 libusb 完成 │ +│ 🔄 建立 Python 環境 62% │ +│ ┌──────────────████████░░░░░░──────┐ │ +│ │ 安裝 numpy... │ │ +│ └──────────────────────────────────┘ │ +│ ○ 安裝模型檔案 等待中 │ +│ ○ 偵測硬體 等待中 │ +│ │ +│ [取消] │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 5: 硬體偵測 │ +│ │ +│ 偵測到的 Kneron 裝置: │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ ✅ KL720 (port 136) │ │ +│ │ Firmware: KDP2 Comp/F │ │ +│ │ PID: 0x0720 │ │ +│ ├──────────────────────────────────┤ │ +│ │ ✅ KL520 (port 33) │ │ +│ │ Firmware: Loader │ │ +│ │ PID: 0x0100 │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ⚠️ 未偵測到裝置?請確認 USB 連接 │ +│ │ +│ [← 上一步] [下一步 →] │ +└─────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────┐ +│ Step 6: 完成 │ +│ │ +│ ✅ 安裝成功! │ +│ │ +│ 安裝位置: ~/.edge-ai-platform │ +│ 版本: v0.2.0 │ +│ 安裝大小: 342 MB │ +│ │ +│ ☑ 立即啟動 Edge AI Server │ +│ ☑ 開啟瀏覽器 (http://localhost:3721) │ +│ ☐ 建立桌面捷徑 │ +│ ☐ 開機自動啟動 │ +│ │ +│ [完成] │ +└─────────────────────────────────────────────────────────┘ +``` + +**安裝步驟實作細節:** + +| 步驟 | macOS 實作 | Windows 實作 | +|------|-----------|-------------| +| 下載 binary | `net/http` GET → Gitea release tar.gz | `net/http` GET → Gitea release zip | +| 解壓 | `archive/tar` + `compress/gzip` | `archive/zip` | +| symlink | `os.Symlink()` → `/usr/local/bin/edge-ai-server` (需確認權限) | 加入 User PATH (`os.Setenv` + registry) | +| libusb | `exec.Command("brew", "install", "libusb")` 或內嵌 dylib | 內嵌 `libusb-1.0.dll` 到安裝目錄 | +| Python venv | `exec.Command("python3", "-m", "venv", ...)` | `exec.Command("python", "-m", "venv", ...)` 或內嵌 Python embedded | +| pip install | `venv/bin/pip install numpy opencv-python-headless pyusb` | `venv\Scripts\pip install ...` | +| ffmpeg | `brew install ffmpeg` (可選) | `winget install Gyan.FFmpeg` 或內嵌 (可選) | +| 硬體偵測 | `exec.Command(python, "kneron_detect.py")` | 同左 | +| 桌面捷徑 | `~/Desktop/EdgeAI.command` | `shell:desktop\EdgeAI.lnk` (COM API) | +| 開機啟動 | `~/Library/LaunchAgents/com.innovedus.edge-ai.plist` | `HKCU\...\Run` registry | + +**macOS 無 Homebrew 情境處理:** + +``` +if !commandExists("brew") { + // 方案 A: 提示安裝 Homebrew(推薦) + // 方案 B: 直接下載 libusb dylib 放入安裝目錄 + // 設定 DYLD_LIBRARY_PATH 環境變數 +} +``` + +**Windows 無 Python 情境處理:** + +``` +if !commandExists("python") && !commandExists("python3") { + // 方案 A: 自動下載 Python embeddable package (無需安裝) + // 解壓到 installDir/python/,直接使用 + // 方案 B: 提示使用者安裝 Python (winget install Python.Python.3.12) +} +``` + +**解除安裝流程:** + +```go +func (a *Installer) Uninstall(keepData bool) error { + // 1. 停止正在運行的 edge-ai-server 進程 + killProcess("edge-ai-server") + + // 2. 移除 symlink / PATH + // macOS: os.Remove("/usr/local/bin/edge-ai-server") + // Windows: 從 User PATH 移除安裝目錄 + + // 3. 移除安裝目錄 + if keepData { + // 保留 data/custom-models/(使用者自訂模型) + removeAllExcept(installDir, "data/custom-models") + } else { + os.RemoveAll(installDir) + } + + // 4. 移除桌面捷徑 / 開機啟動 + removeDesktopShortcut() + removeAutoStart() + + // 5. macOS: 不需要額外清理(brew 套件保留) + // Windows: 不移除 Python(可能被其他程式使用) +} +``` + +**更新流程(覆蓋安裝):** + +```go +func (a *Installer) Install(components []string) error { + existing := a.GetInstalledInfo() + if existing.Version != "" { + // 升級模式:保留 venv + custom-models + 設定 + // 只更新 binary + 預訓練模型 + firmware + scripts + backupUserData() + installNewVersion() + restoreUserData() + } else { + // 全新安裝 + fullInstall() + } +} +``` + +**打包與發布:** + +```yaml +# .goreleaser.yaml — Installer 額外產出 +builds: + - id: installer + dir: installer + binary: EdgeAI-Installer + goos: [darwin, windows] + goarch: [amd64, arm64] + hooks: + pre: wails build -platform {{ .Os }}/{{ .Arch }} + +# macOS: create-dmg 產出 .dmg +# Windows: NSIS 包裝成 Setup.exe +``` + +#### 8.5.15 多裝置叢集推論(F19 — Cluster Inference) + +| 前端元件 | 後端模組 | 說明 | +|---------|---------|------| +| `components/cluster/cluster-list.tsx` | `internal/cluster/manager.go` | 叢集清單與 CRUD | +| `components/cluster/cluster-card.tsx` | `internal/cluster/dispatcher.go` | 加權分派引擎 | +| `components/cluster/cluster-performance.tsx` | `internal/cluster/pipeline.go` | 叢集推論管線 | +| `app/workspace/cluster/[clusterId]/` | `internal/cluster/types.go` | 叢集資料結構 | +| `stores/cluster-store.ts` | `internal/api/handlers/cluster_handler.go` | 叢集 REST API | +| `hooks/use-cluster-inference-stream.ts` | `internal/api/ws/cluster_inference_ws.go` | 叢集推論 WS | +| `types/cluster.ts` | `internal/api/ws/cluster_flash_ws.go` | 叢集燒錄 WS | + +**叢集推論架構:** + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Cluster Manager │ +│ clusters map[string]*Cluster │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Cluster {ID, Name, Devices[], ModelID, Status} │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────┬───────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Cluster Inference Pipeline │ +│ │ +│ FrameSource ──► Dispatcher ──┬──► Device A (KL720, w=3) ──┐ │ +│ (camera/ (weighted ├──► Device B (KL520, w=1) ──┤ │ +│ image/ round-robin) └──► Device C (KL720, w=3) ──┤ │ +│ video) │ │ +│ ▼ │ +│ Result Collector │ +│ (merge + tag │ +│ frameIndex + │ +│ deviceId) │ +│ │ │ +│ ▼ │ +│ resultCh ──► WS │ +│ "inference: │ +│ cluster:{id}" │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**核心資料結構(`internal/cluster/types.go`):** + +```go +type ClusterStatus string // "idle" / "inferencing" / "degraded" + +type DeviceMember struct { + DeviceID string `json:"deviceId"` + Weight int `json:"weight"` // 分派權重: KL720=3, KL520=1 + Status string `json:"status"` // "active", "degraded", "removed" +} + +type Cluster struct { + ID string `json:"id"` + Name string `json:"name"` + Devices []DeviceMember `json:"devices"` + ModelID string `json:"modelId,omitempty"` + Status ClusterStatus `json:"status"` +} + +type ClusterResult struct { + *driver.InferenceResult + ClusterID string `json:"clusterId"` + FrameIndex int64 `json:"frameIndex"` +} +``` + +**加權分派演算法(`internal/cluster/dispatcher.go`):** + +```go +// Weighted Round-Robin 分派器 +// 範例: devices A(w=3), B(w=1), C(w=3) +// 分派序列: A, A, A, B, C, C, C, A, A, A, B, ... +type Dispatcher struct { + members []DeviceMember + drivers []driver.DeviceDriver + current int // 目前裝置索引 + remaining int // 目前裝置剩餘幀數 + frameIndex int64 // 全域幀計數器 + mu sync.Mutex +} + +func (d *Dispatcher) Next() (driver.DeviceDriver, int64, error) // 跳過 degraded 裝置 +func (d *Dispatcher) MarkDegraded(deviceID string) // 標記故障 +func (d *Dispatcher) MarkActive(deviceID string) // 恢復正常 +func (d *Dispatcher) ActiveCount() int // 可用裝置數 +``` + +**並行推論 Worker 模式(`internal/cluster/pipeline.go`):** + +``` +Main goroutine: + for { + frame = source.ReadFrame() + frameCh <- frame (MJPEG 預覽) + device, frameIdx = dispatcher.Next() + workerCh[device] <- {frame, frameIdx} + } + +Per-device worker goroutine (每裝置一個): + for job := range workerCh { + result = device.RunInference(job.frame) + result.FrameIndex = job.frameIdx + result.DeviceID = device.Info().ID + resultCh <- result // 合併到統一輸出 + } +``` + +每裝置獨立 goroutine + buffered channel(size=2),慢裝置不會阻塞快裝置。Dispatcher 按權重分配更多幀給算力強的裝置。 + +**容錯機制:** + +```go +// Worker 內部容錯 +result, err := device.RunInference(frame) +if err != nil { + consecutiveErrors++ + if consecutiveErrors >= 3 { + dispatcher.MarkDegraded(deviceID) + cluster.Status = ClusterDegraded + // 通知前端,worker 暫停並定期重試 + } + continue +} +consecutiveErrors = 0 +resultCh <- result +``` + +**REST API:** + +| Method | Path | 功能 | +|--------|------|------| +| `GET` | `/api/clusters` | 列出所有叢集 | +| `POST` | `/api/clusters` | 建立叢集 `{name, deviceIds}` | +| `GET` | `/api/clusters/:id` | 取得叢集詳情 | +| `DELETE` | `/api/clusters/:id` | 刪除叢集 | +| `POST` | `/api/clusters/:id/devices` | 新增裝置 `{deviceId, weight?}` | +| `DELETE` | `/api/clusters/:id/devices/:deviceId` | 移除裝置 | +| `PUT` | `/api/clusters/:id/devices/:deviceId/weight` | 更新權重 `{weight}` | +| `POST` | `/api/clusters/:id/flash` | 叢集燒錄 `{modelId}` | +| `POST` | `/api/clusters/:id/inference/start` | 啟動叢集推論 | +| `POST` | `/api/clusters/:id/inference/stop` | 停止叢集推論 | + +**WebSocket 端點:** + +| Path | Room | 說明 | +|------|------|------| +| `/ws/clusters/:id/inference` | `inference:cluster:{id}` | 叢集推論結果串流 | +| `/ws/clusters/:id/flash-progress` | `flash:cluster:{id}` | 叢集燒錄進度 | + +**叢集燒錄流程(異質 NEF 自動解析):** + +``` +POST /api/clusters/:id/flash {modelId: "yolov5s"} + │ + ├── 遍歷 cluster.Devices + │ ├── device A (KL720) → resolveModelPath → data/nef/kl720/kl720_20005_yolov5s.nef + │ ├── device B (KL520) → resolveModelPath → data/nef/kl520/kl520_20005_yolov5s.nef + │ └── device C (KL720) → resolveModelPath → data/nef/kl720/kl720_20005_yolov5s.nef + │ + ├── 並行燒錄所有裝置 (各開 goroutine) + │ + └── WS 推送各裝置進度: + {deviceId: "A", percent: 45, stage: "transferring"} + {deviceId: "B", percent: 20, stage: "preparing"} +``` + +**效能估算:** + +| 配置 | 單裝置 FPS | 叢集 FPS(理論) | +|------|-----------|----------------| +| 1× KL520 | ~40 | ~40 | +| 1× KL720 | ~80 | ~80 | +| 2× KL520 + 2× KL720 | — | ~240 (40×2 + 80×2) | +| 4× KL720 | — | ~320 | + +實際吞吐量受 USB 頻寬和 host CPU 限制,建議使用多個 USB controller 或 powered hub。 + +--- + +## 9. 開發環境與工具鏈 + +### 9.1 前端開發環境 + +| 工具 | 版本 | 用途 | +|------|------|------| +| Node.js | 20 LTS | JavaScript runtime | +| pnpm | 9.x | 套件管理(比 npm 快、磁碟效率高) | +| TypeScript | 5.x | 型別安全 | +| ESLint | 9.x | 程式碼品質 | +| Prettier | 3.x | 程式碼格式化 | + +### 9.2 後端開發環境 + +| 工具 | 版本 | 用途 | +|------|------|------| +| Go | 1.22+ | 後端語言 | +| golangci-lint | latest | Go linter | +| Air | latest | Go 熱重載(開發用) | +| GoReleaser | latest | 跨平台打包 | + +### 9.3 開發流程 + +``` +開發模式 (Development): +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Next.js Dev │ │ Go Server │ │ Device / Mock │ +│ (pnpm dev) │ │ (air / go run) │ │ │ +│ localhost:3000 │───►│ localhost:3721 │───►│ USB / Serial │ +│ │ │ │ │ │ +│ HMR 熱重載 │ │ 熱重載 (Air) │ │ Mock Driver │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + +生產模式 (Production): +┌─────────────────────────────────────────────┐ +│ 單一執行檔 │ +│ │ +│ Go Server (localhost:3721) │ +│ ├── REST API (/api/...) │ +│ ├── WebSocket (/ws/...) │ +│ ├── Static (/ → 嵌入的前端 build) │ +│ └── Device Communication (USB/Serial) │ +└─────────────────────────────────────────────┘ +``` + +### 9.4 開發模式代理設定 + +```javascript +// next.config.js +module.exports = { + async rewrites() { + return [ + { + source: '/api/:path*', + destination: 'http://localhost:3721/api/:path*', + }, + // WebSocket 透過環境變數指定 + ]; + }, +}; +``` + +--- + +## 10. 專案目錄結構 + +``` +edge-ai-platform/ +├── README.md +├── Makefile # 建置腳本 +├── .goreleaser.yml # 跨平台打包設定 +│ +├── server/ # Go 後端 +│ ├── go.mod +│ ├── go.sum +│ ├── main.go +│ ├── cmd/ +│ ├── internal/ +│ │ ├── api/ +│ │ ├── device/ +│ │ ├── driver/ +│ │ ├── model/ +│ │ ├── flash/ +│ │ ├── inference/ +│ │ └── config/ +│ ├── pkg/ +│ │ ├── serial/ +│ │ ├── usb/ +│ │ └── logger/ +│ ├── data/ # 內建模型中繼資料 +│ │ └── models.json +│ ├── scripts/ # Kneron SDK Python 腳本 +│ │ ├── load_model.py +│ │ ├── start_inference.py +│ │ └── requirements.txt +│ └── frontend/ # 嵌入用前端 build +│ └── out/ +│ +├── frontend/ # Next.js 前端 +│ ├── package.json +│ ├── pnpm-lock.yaml +│ ├── next.config.js +│ ├── tailwind.config.ts +│ ├── tsconfig.json +│ ├── components.json # shadcn/ui 設定 +│ ├── src/ +│ │ ├── app/ +│ │ ├── components/ +│ │ ├── stores/ +│ │ ├── hooks/ +│ │ ├── lib/ +│ │ └── types/ +│ └── public/ +│ └── images/ +│ +├── docs/ # 文件 +│ ├── api.md # API 文件 +│ └── driver-development.md # Driver 開發指南 +│ +└── .github/ # CI/CD + └── workflows/ + ├── ci.yml + └── release.yml +``` + +--- + +## 11. 部署與打包策略 + +### 11.1 建置流程 + +``` +1. 前端建置 + pnpm build && pnpm export + → frontend/out/ (靜態檔案) + +2. 複製前端到 Go embed 目錄 + cp -r frontend/out server/frontend/out + +3. Go 編譯 (含嵌入前端) + cd server && go build -o edge-ai-platform + +4. 或使用 GoReleaser 跨平台打包 + goreleaser release --snapshot +``` + +### 11.2 目標平台 + +| 平台 | 架構 | 輸出檔案 | 安裝方式 | +|------|------|---------|---------| +| macOS (Apple Silicon) | darwin/arm64 | `edge-ai-platform-darwin-arm64` | `.dmg` 或直接執行 | +| macOS (Intel) | darwin/amd64 | `edge-ai-platform-darwin-amd64` | `.dmg` 或直接執行 | +| Windows | windows/amd64 | `edge-ai-platform.exe` | `.msi` 安裝包 | +| Linux | linux/amd64 | `edge-ai-platform-linux-amd64` | `.deb` / `.rpm` / 直接執行 | + +### 11.3 GoReleaser 設定 + +```yaml +# .goreleaser.yml +builds: + - id: edge-ai-platform + dir: server + binary: edge-ai-platform + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - format: tar.gz + format_overrides: + - goos: windows + format: zip +``` + +### 11.4 Makefile + +```makefile +.PHONY: dev dev-frontend dev-backend build clean + +# 開發模式 +dev: dev-frontend dev-backend + +dev-frontend: + cd frontend && pnpm dev + +dev-backend: + cd server && air + +# 建置 +build: build-frontend build-backend + +build-frontend: + cd frontend && pnpm build && pnpm export + cp -r frontend/out server/frontend/out + +build-backend: build-frontend + cd server && go build -o ../dist/edge-ai-platform + +# 跨平台打包 +release: + goreleaser release --snapshot --clean + +clean: + rm -rf dist/ frontend/out/ server/frontend/out/ +``` + +### 11.5 安裝腳本 + +| 腳本 | 平台 | 安裝目錄 | 執行方式 | +|------|------|---------|---------| +| `scripts/install.sh` | macOS / Linux | `~/.edge-ai-platform/` | `curl -fsSL \| bash` | +| `scripts/install.ps1` | Windows | `%LOCALAPPDATA%\EdgeAIPlatform` | `irm \| iex` | +| `scripts/setup-kneron.sh` | macOS | 同上(venv 子目錄) | `bash scripts/setup-kneron.sh` | + +**安裝內容:** +1. Edge AI Server binary + data 檔案 +2. Python venv(`$INSTALL_DIR/venv`)+ pyusb + numpy + opencv-python-headless +3. libusb 系統驅動(macOS: Homebrew / Linux: apt / Windows: Zadig 提示) +4. `/usr/local/bin/edge-ai-server` symlink(macOS)或 PATH 設定(Windows) +5. KL520 韌體檔案:`scripts/firmware/KL520/fw_scpu.bin`, `fw_ncpu.bin` +6. Kneron PLUS SDK:`kp` Python module + `libkplus.dylib`(macOS)/ `libkplus.so`(Linux) + +**scripts 目錄結構:** +``` +scripts/ +├── kneron_bridge.py # Go↔Kneron JSON-RPC bridge +├── requirements.txt # numpy, opencv-python-headless, pyusb +├── firmware/ +│ └── KL520/ +│ ├── fw_scpu.bin # SCPU 韌體 (52KB) +│ └── fw_ncpu.bin # NCPU 韌體 (40KB) +└── venv/ # Python venv(安裝時建立) + └── lib/.../site-packages/kp/ # Kneron PLUS SDK module +``` + +**解除安裝:** +- macOS: `rm -rf ~/.edge-ai-platform && sudo rm -f /usr/local/bin/edge-ai-server` +- Windows: `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\EdgeAIPlatform"` + 移除 PATH + +### 11.6 啟動依賴檢查 + +`internal/deps/checker.go` 在 Server 啟動時檢查外部工具: + +| 工具 | 等級 | 用途 | +|------|------|------| +| ffmpeg | Required(Camera 功能)| Camera 擷取、影片處理 | +| yt-dlp | Optional | YouTube URL 解析 | +| python3 | Optional | Kneron 硬體驅動(pyusb) | + +啟動輸出範例: +``` +[INFO] External dependency check: +[INFO] [OK] ffmpeg: ffmpeg version 7.1.1 +[INFO] [OPTIONAL] yt-dlp: not found — macOS: brew install yt-dlp | Windows: winget install yt-dlp +[INFO] [OPTIONAL] python3: Python 3.12.3 +``` + +--- + +## 12. 安全性考量 + +### 12.1 本地 Server 安全 + +| 風險 | 緩解措施 | +|------|---------| +| 外部存取本地 Server | 僅綁定 `127.0.0.1`,拒絕外部連線 | +| 跨站請求偽造 (CSRF) | 驗證 `Origin` header | +| 路徑穿越 (Path Traversal) | 模型檔案路徑白名單、嚴格驗證 | +| 命令注入 | 不將使用者輸入直接傳入 shell 命令 | +| CORS | 嚴格限制為 `localhost` 來源 | +| WebSocket 安全 | 驗證 Origin header | + +### 12.2 裝置通訊安全 + +| 風險 | 緩解措施 | +|------|---------| +| 惡意韌體 | 驗證 NEF 檔案完整性(CRC/Hash) | +| USB 攻擊面 | 僅處理已知 VID/PID 的裝置 | +| 串列埠注入 | 嚴格解析裝置回應格式 | + +--- + +## 13. 效能目標 + +### 13.1 回應時間目標(對應 PRD 驗收標準) + +| 指標 | 目標 | PRD 來源 | +|------|------|---------| +| 裝置偵測延遲 | USB 接上 < 5 秒出現在列表 | B2.3 驗收標準 | +| 燒錄進度更新 | < 500ms 更新一次 | B2.3 | +| 推論結果延遲 | < 200ms(裝置到前端) | B2.4 驗收標準 | +| 首次推論顯示 | 燒錄完成後 < 5 秒 | B2.4 驗收標準 | +| 前端頁面載入 | < 2 秒 | 一般標準 | +| API 回應時間 | < 100ms(列表/詳情) | 一般標準 | +| 模型搜尋 | 使用者可在 30 秒內找到模型 | B2.2 驗收標準 | + +### 13.2 資源使用 + +| 指標 | 目標 | +|------|------| +| Server 記憶體 | < 100 MB(空閒)/ < 200 MB(推論串流中) | +| Server CPU | < 5%(空閒)/ < 15%(推論串流中) | +| 執行檔大小 | < 50 MB(含前端) | + +--- + +## 14. 測試策略 + +### 14.1 前端測試 + +| 類型 | 工具 | 覆蓋範圍 | +|------|------|---------| +| 單元測試 | Vitest | 工具函式、Store 邏輯 | +| 元件測試 | Vitest + Testing Library | UI 元件渲染、互動 | +| E2E 測試 | Playwright | 完整使用者流程 | + +### 14.2 後端測試 + +| 類型 | 工具 | 覆蓋範圍 | +|------|------|---------| +| 單元測試 | Go testing | Driver 介面、解析器、業務邏輯 | +| 整合測試 | Go testing + httptest | API 端點、WebSocket | +| Mock 測試 | Mock Driver | 無硬體環境的完整流程測試 | + +### 14.3 關鍵測試場景 + +``` +模型庫: +├── 模型列表載入與顯示 +├── 篩選功能(單選、多選、組合) +├── 搜尋功能 +└── 模型詳情頁內容正確性 + +裝置管理: +├── 裝置偵測(插入 USB) +├── 裝置移除(拔出 USB) +├── 多裝置同時連線 +└── 裝置斷線重連 + +燒錄: +├── 正常燒錄流程(進度 0→100%) +├── 燒錄中斷線(錯誤處理) +├── 模型不相容(預先阻止) +└── 燒錄逾時 + +推論: +├── 分類結果即時顯示 +├── 偵測結果邊界框繪製 +├── 高頻率更新不卡頓 +├── Confidence Threshold 過濾 +└── 推論啟停控制 + +Camera: +├── Camera 列表偵測 +├── Camera 開啟與 MJPEG 串流 +├── Camera 切換(多 Camera 支援) +├── Camera → Device 推論 Pipeline +├── MJPEG 串流 + 邊界框疊加同步 +├── Camera 關閉後正確釋放資源 +└── 不同解析度切換 +``` + +--- + +## 15. 開發階段規劃 + +### 15.1 MVP 開發階段 + +``` +Phase 0 — 基礎建設 (Scaffold) +━━━━━━━━━━━━━━━━━━━━━━━━━━━ +├── 初始化前端專案 (Next.js + shadcn/ui + Tailwind) +├── 初始化後端專案 (Go module + 專案結構) +├── 設定開發工具鏈 (ESLint, Prettier, golangci-lint) +├── 實作 Mock Driver +├── 前後端通訊基礎 (REST + WebSocket) +└── CI 設定 + +Phase 1 — 模型庫 (Step 1) +━━━━━━━━━━━━━━━━━━━━━━━━ +├── 後端: 模型中繼資料 API (GET /api/models, GET /api/models/:id) +├── 後端: 模型篩選與搜尋 +├── 前端: 模型庫頁面 (ModelGrid + ModelCard) +├── 前端: 篩選面板 (ModelFilters) +├── 前端: 模型詳情頁 (ModelDetail) +└── 測試: 模型 API + 前端元件 + +Phase 2 — 裝置管理 (Step 2) +━━━━━━━━━━━━━━━━━━━━━━━━━━ +├── 後端: USB 裝置偵測與熱插拔 +├── 後端: Driver 抽象層 + Mock Driver 完善 +├── 後端: 裝置 API (GET /api/devices, POST connect/disconnect) +├── 後端: WebSocket 裝置事件 +├── 前端: 裝置列表頁面 (DeviceList + DeviceCard) +├── 前端: 裝置狀態即時更新 +└── 測試: 裝置偵測 + WebSocket 事件 + +Phase 3 — 燒錄 (Step 2 續) +━━━━━━━━━━━━━━━━━━━━━━━━━ +├── 後端: 燒錄服務 (Flash Service) +├── 後端: 燒錄進度 WebSocket +├── 後端: Kneron SDK 整合 (Python subprocess) +├── 前端: 燒錄對話框 + 進度條 +├── 前端: 錯誤處理 UI +└── 測試: 燒錄流程 (Mock + 實機) + +Phase 4 — Camera 與即時推論 (Step 3) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +├── 後端: Camera Manager (偵測、開啟、擷取) +├── 後端: MJPEG 串流 HTTP handler +├── 後端: Camera-Inference Pipeline (frame → device → result) +├── 後端: 推論結果串流 (WebSocket) +├── 後端: 推論結果解析器 +├── 前端: Camera 選擇器 + 控制面板 +├── 前端: Camera Feed 元件 (MJPEG ) +├── 前端: 推論結果 Canvas 疊加層 (邊界框 + 標籤) +├── 前端: Camera + 推論整合檢視 (CameraInferenceView) +├── 前端: 分類結果長條圖 (Recharts) +├── 前端: 效能指標顯示 (FPS, 延遲) +├── 前端: Confidence Threshold 滑桿 +├── 前端: 裝置日誌面板 +└── 測試: Camera 串流 + 推論疊加 + UI 效能 + +Phase 5 — 整合與打包 +━━━━━━━━━━━━━━━━━━━ +├── 前端 build 嵌入 Go binary +├── 跨平台打包 (GoReleaser) +├── 安裝包製作 (dmg, msi, deb) +├── E2E 測試 (完整使用者流程) +├── 效能測試與最佳化 +└── 文件撰寫 +``` + +### 15.2 技術風險與緩解 + +| 風險 | 嚴重度 | 緩解策略 | +|------|:------:|---------| +| Kneron PLUS SDK 整合困難 | 高 | 先用 Python subprocess;確保 Mock Driver 可用於平行開發 | +| 跨平台串列埠差異 | 中 | 使用成熟的 `go.bug.st/serial`;各平台 CI 測試 | +| Kneron USB VID/PID 未確認 | 中 | 取得實體硬體後確認;Mock Driver 先行開發 | +| WebSocket 高頻推論資料效能 | 中 | 前端節流(throttle);二進位格式(MessagePack)備選 | +| OpenCV (gocv) 依賴與跨平台 | 中 | 靜態連結 OpenCV 或使用 ffmpeg subprocess 作為 fallback;Docker 建置環境 | +| Camera 擷取延遲 | 中 | MJPEG 串流減少前端解碼負擔;後端控制 frame rate (可調 15-30 FPS) | +| Go embed 前端檔案大小 | 低 | Next.js 靜態輸出已最佳化;gzip 壓縮 | +| CGo 交叉編譯 | 中 | MVP 用 Python subprocess 避免;Phase 1+ 再評估 CGo | + +### 15.3 開發模式與 Mock 策略 + +為了在沒有實體 Kneron Dongle 的情況下進行完整的前後端開發: + +``` +啟動方式: + edge-ai-platform --mock # 使用 Mock Driver + edge-ai-platform --mock-devices 3 # 模擬 3 台裝置 + +Mock Driver 行為: + ● 偵測: 模擬 1-N 台虛擬裝置 + ● 連線: 立即成功 + ● 燒錄: 模擬 10 秒燒錄過程(進度遞增) + ● 推論: 每 100ms 產生隨機推論結果 + ├── 分類: 在 5 個類別中隨機分配信心分數 + └── 偵測: 隨機位置的 1-3 個邊界框 + +Mock Camera 行為: + ● Camera 列表: 回傳 1 台虛擬 Camera ("Mock Camera 0") + ● MJPEG 串流: 產生帶有時間戳的測試圖卡影像 (color bars / gradient) + ● Camera 推論: 以測試圖卡影像送入 Mock Driver,回傳隨機推論結果 + ● 用途: 在沒有實體 Camera 和 Dongle 的環境下完整測試前後端整合 +``` + +--- + +## 附錄 + +### A. 預設連接埠 + +| 服務 | 預設埠 | 說明 | +|------|:------:|------| +| Go Server (HTTP + WS + Static) | 3721 | 所有服務共用 | +| Next.js Dev Server | 3000 | 僅開發模式 | + +### B. 環境變數 + +| 變數 | 預設值 | 說明 | +|------|--------|------| +| `PORT` | `3721` | Server 監聽埠 | +| `HOST` | `127.0.0.1` | Server 綁定地址 | +| `LOG_LEVEL` | `info` | 日誌等級 (debug/info/warn/error) | +| `DEV_MODE` | `false` | 開發模式 (啟用 Mock Driver) | +| `MOCK_DEVICES` | `1` | Mock 裝置數量 | +| `DATA_DIR` | `~/.edge-ai-platform` | 應用資料目錄 | +| `PYTHON_PATH` | `python3` | Python 路徑 (Kneron SDK) | + +### C. 相依套件清單 + +**前端 (package.json):** + +```json +{ + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zustand": "^5.0.0", + "recharts": "^2.12.0", + "lucide-react": "^0.400.0", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "tailwindcss": "^4.0.0", + "@types/react": "^19.0.0", + "vitest": "^2.0.0", + "@testing-library/react": "^16.0.0", + "playwright": "^1.45.0", + "eslint": "^9.0.0", + "prettier": "^3.0.0" + } +} +``` + +**後端 (go.mod 主要依賴):** + +``` +go.bug.st/serial // 串列埠通訊 +github.com/gin-gonic/gin // HTTP 框架 (或 echo) +github.com/gorilla/websocket // WebSocket +github.com/google/gousb // USB 裝置偵測 +gocv.io/x/gocv // OpenCV Go binding (Camera 擷取) +github.com/fsnotify/fsnotify // 檔案監聽 +github.com/spf13/cobra // CLI 框架 +github.com/spf13/viper // 設定管理 +go.uber.org/zap // 結構化日誌 +``` + +--- + +*文件版本:v1.6 | 日期:2026-03-02 | 狀態:更新中* diff --git a/frontend b/edge-ai-platform/frontend similarity index 100% rename from frontend rename to edge-ai-platform/frontend diff --git a/installer/app.go b/edge-ai-platform/installer/app.go similarity index 100% rename from installer/app.go rename to edge-ai-platform/installer/app.go diff --git a/installer/embed.go b/edge-ai-platform/installer/embed.go similarity index 100% rename from installer/embed.go rename to edge-ai-platform/installer/embed.go diff --git a/installer/frontend/app.js b/edge-ai-platform/installer/frontend/app.js similarity index 100% rename from installer/frontend/app.js rename to edge-ai-platform/installer/frontend/app.js diff --git a/installer/frontend/index.html b/edge-ai-platform/installer/frontend/index.html similarity index 100% rename from installer/frontend/index.html rename to edge-ai-platform/installer/frontend/index.html diff --git a/installer/frontend/style.css b/edge-ai-platform/installer/frontend/style.css similarity index 100% rename from installer/frontend/style.css rename to edge-ai-platform/installer/frontend/style.css diff --git a/installer/go.mod b/edge-ai-platform/installer/go.mod similarity index 100% rename from installer/go.mod rename to edge-ai-platform/installer/go.mod diff --git a/installer/go.sum b/edge-ai-platform/installer/go.sum similarity index 100% rename from installer/go.sum rename to edge-ai-platform/installer/go.sum diff --git a/installer/main.go b/edge-ai-platform/installer/main.go similarity index 100% rename from installer/main.go rename to edge-ai-platform/installer/main.go diff --git a/installer/platform_darwin.go b/edge-ai-platform/installer/platform_darwin.go similarity index 100% rename from installer/platform_darwin.go rename to edge-ai-platform/installer/platform_darwin.go diff --git a/installer/platform_linux.go b/edge-ai-platform/installer/platform_linux.go similarity index 100% rename from installer/platform_linux.go rename to edge-ai-platform/installer/platform_linux.go diff --git a/installer/platform_windows.go b/edge-ai-platform/installer/platform_windows.go similarity index 100% rename from installer/platform_windows.go rename to edge-ai-platform/installer/platform_windows.go diff --git a/installer/wails.json b/edge-ai-platform/installer/wails.json similarity index 100% rename from installer/wails.json rename to edge-ai-platform/installer/wails.json diff --git a/scripts/deploy-aws.sh b/edge-ai-platform/scripts/deploy-aws.sh similarity index 100% rename from scripts/deploy-aws.sh rename to edge-ai-platform/scripts/deploy-aws.sh diff --git a/scripts/deploy-ec2.sh b/edge-ai-platform/scripts/deploy-ec2.sh similarity index 100% rename from scripts/deploy-ec2.sh rename to edge-ai-platform/scripts/deploy-ec2.sh diff --git a/scripts/install.ps1 b/edge-ai-platform/scripts/install.ps1 similarity index 100% rename from scripts/install.ps1 rename to edge-ai-platform/scripts/install.ps1 diff --git a/scripts/install.sh b/edge-ai-platform/scripts/install.sh similarity index 100% rename from scripts/install.sh rename to edge-ai-platform/scripts/install.sh diff --git a/scripts/kneron_detect.py b/edge-ai-platform/scripts/kneron_detect.py similarity index 100% rename from scripts/kneron_detect.py rename to edge-ai-platform/scripts/kneron_detect.py diff --git a/scripts/setup-kneron.sh b/edge-ai-platform/scripts/setup-kneron.sh similarity index 100% rename from scripts/setup-kneron.sh rename to edge-ai-platform/scripts/setup-kneron.sh diff --git a/server/.next/trace b/edge-ai-platform/server/.next/trace similarity index 100% rename from server/.next/trace rename to edge-ai-platform/server/.next/trace diff --git a/server/.next/trace-build b/edge-ai-platform/server/.next/trace-build similarity index 100% rename from server/.next/trace-build rename to edge-ai-platform/server/.next/trace-build diff --git a/server/cmd/relay-server/main.go b/edge-ai-platform/server/cmd/relay-server/main.go similarity index 100% rename from server/cmd/relay-server/main.go rename to edge-ai-platform/server/cmd/relay-server/main.go diff --git a/server/data/models.json b/edge-ai-platform/server/data/models.json similarity index 100% rename from server/data/models.json rename to edge-ai-platform/server/data/models.json diff --git a/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef b/edge-ai-platform/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef similarity index 100% rename from server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef rename to edge-ai-platform/server/data/nef/kl520/kl520_20001_resnet18_w224h224.nef diff --git a/local_service_win/TestRes/Models/kl520_20004_fcos-drk53s_w512h512.nef b/edge-ai-platform/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef similarity index 100% rename from local_service_win/TestRes/Models/kl520_20004_fcos-drk53s_w512h512.nef rename to edge-ai-platform/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef diff --git a/local_service_win/TestRes/Models/kl520_20005_yolov5-noupsample_w640h640.nef b/edge-ai-platform/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef similarity index 100% rename from local_service_win/TestRes/Models/kl520_20005_yolov5-noupsample_w640h640.nef rename to edge-ai-platform/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef diff --git a/server/data/nef/kl520/kl520_ssd_fd_lm.nef b/edge-ai-platform/server/data/nef/kl520/kl520_ssd_fd_lm.nef similarity index 100% rename from server/data/nef/kl520/kl520_ssd_fd_lm.nef rename to edge-ai-platform/server/data/nef/kl520/kl520_ssd_fd_lm.nef diff --git a/server/data/nef/kl520/kl520_tiny_yolo_v3.nef b/edge-ai-platform/server/data/nef/kl520/kl520_tiny_yolo_v3.nef similarity index 100% rename from server/data/nef/kl520/kl520_tiny_yolo_v3.nef rename to edge-ai-platform/server/data/nef/kl520/kl520_tiny_yolo_v3.nef diff --git a/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef b/edge-ai-platform/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef similarity index 100% rename from server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef rename to edge-ai-platform/server/data/nef/kl720/kl720_20001_resnet18_w224h224.nef diff --git a/local_service_win/TestRes/Models/kl720_20004_fcos-drk53s_w512h512.nef b/edge-ai-platform/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef similarity index 100% rename from local_service_win/TestRes/Models/kl720_20004_fcos-drk53s_w512h512.nef rename to edge-ai-platform/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef diff --git a/local_service_win/TestRes/Models/kl720_20005_yolov5-noupsample_w640h640.nef b/edge-ai-platform/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef similarity index 100% rename from local_service_win/TestRes/Models/kl720_20005_yolov5-noupsample_w640h640.nef rename to edge-ai-platform/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef diff --git a/server/edge-ai-server b/edge-ai-platform/server/edge-ai-server similarity index 100% rename from server/edge-ai-server rename to edge-ai-platform/server/edge-ai-server diff --git a/server/go.mod b/edge-ai-platform/server/go.mod similarity index 100% rename from server/go.mod rename to edge-ai-platform/server/go.mod diff --git a/server/go.sum b/edge-ai-platform/server/go.sum similarity index 100% rename from server/go.sum rename to edge-ai-platform/server/go.sum diff --git a/server/internal/api/handlers/camera_handler.go b/edge-ai-platform/server/internal/api/handlers/camera_handler.go similarity index 100% rename from server/internal/api/handlers/camera_handler.go rename to edge-ai-platform/server/internal/api/handlers/camera_handler.go diff --git a/server/internal/api/handlers/cluster_handler.go b/edge-ai-platform/server/internal/api/handlers/cluster_handler.go similarity index 100% rename from server/internal/api/handlers/cluster_handler.go rename to edge-ai-platform/server/internal/api/handlers/cluster_handler.go diff --git a/server/internal/api/handlers/device_handler.go b/edge-ai-platform/server/internal/api/handlers/device_handler.go similarity index 100% rename from server/internal/api/handlers/device_handler.go rename to edge-ai-platform/server/internal/api/handlers/device_handler.go diff --git a/server/internal/api/handlers/model_handler.go b/edge-ai-platform/server/internal/api/handlers/model_handler.go similarity index 100% rename from server/internal/api/handlers/model_handler.go rename to edge-ai-platform/server/internal/api/handlers/model_handler.go diff --git a/server/internal/api/handlers/model_upload_handler.go b/edge-ai-platform/server/internal/api/handlers/model_upload_handler.go similarity index 100% rename from server/internal/api/handlers/model_upload_handler.go rename to edge-ai-platform/server/internal/api/handlers/model_upload_handler.go diff --git a/server/internal/api/handlers/system_handler.go b/edge-ai-platform/server/internal/api/handlers/system_handler.go similarity index 100% rename from server/internal/api/handlers/system_handler.go rename to edge-ai-platform/server/internal/api/handlers/system_handler.go diff --git a/server/internal/api/middleware.go b/edge-ai-platform/server/internal/api/middleware.go similarity index 100% rename from server/internal/api/middleware.go rename to edge-ai-platform/server/internal/api/middleware.go diff --git a/server/internal/api/router.go b/edge-ai-platform/server/internal/api/router.go similarity index 100% rename from server/internal/api/router.go rename to edge-ai-platform/server/internal/api/router.go diff --git a/server/internal/api/ws/cluster_flash_ws.go b/edge-ai-platform/server/internal/api/ws/cluster_flash_ws.go similarity index 100% rename from server/internal/api/ws/cluster_flash_ws.go rename to edge-ai-platform/server/internal/api/ws/cluster_flash_ws.go diff --git a/server/internal/api/ws/cluster_inference_ws.go b/edge-ai-platform/server/internal/api/ws/cluster_inference_ws.go similarity index 100% rename from server/internal/api/ws/cluster_inference_ws.go rename to edge-ai-platform/server/internal/api/ws/cluster_inference_ws.go diff --git a/server/internal/api/ws/device_events_ws.go b/edge-ai-platform/server/internal/api/ws/device_events_ws.go similarity index 100% rename from server/internal/api/ws/device_events_ws.go rename to edge-ai-platform/server/internal/api/ws/device_events_ws.go diff --git a/server/internal/api/ws/flash_ws.go b/edge-ai-platform/server/internal/api/ws/flash_ws.go similarity index 100% rename from server/internal/api/ws/flash_ws.go rename to edge-ai-platform/server/internal/api/ws/flash_ws.go diff --git a/server/internal/api/ws/hub.go b/edge-ai-platform/server/internal/api/ws/hub.go similarity index 100% rename from server/internal/api/ws/hub.go rename to edge-ai-platform/server/internal/api/ws/hub.go diff --git a/server/internal/api/ws/inference_ws.go b/edge-ai-platform/server/internal/api/ws/inference_ws.go similarity index 100% rename from server/internal/api/ws/inference_ws.go rename to edge-ai-platform/server/internal/api/ws/inference_ws.go diff --git a/server/internal/api/ws/server_logs_ws.go b/edge-ai-platform/server/internal/api/ws/server_logs_ws.go similarity index 100% rename from server/internal/api/ws/server_logs_ws.go rename to edge-ai-platform/server/internal/api/ws/server_logs_ws.go diff --git a/server/internal/camera/ffmpeg_camera.go b/edge-ai-platform/server/internal/camera/ffmpeg_camera.go similarity index 100% rename from server/internal/camera/ffmpeg_camera.go rename to edge-ai-platform/server/internal/camera/ffmpeg_camera.go diff --git a/server/internal/camera/ffmpeg_detect.go b/edge-ai-platform/server/internal/camera/ffmpeg_detect.go similarity index 100% rename from server/internal/camera/ffmpeg_detect.go rename to edge-ai-platform/server/internal/camera/ffmpeg_detect.go diff --git a/server/internal/camera/frame_source.go b/edge-ai-platform/server/internal/camera/frame_source.go similarity index 100% rename from server/internal/camera/frame_source.go rename to edge-ai-platform/server/internal/camera/frame_source.go diff --git a/server/internal/camera/image_source.go b/edge-ai-platform/server/internal/camera/image_source.go similarity index 100% rename from server/internal/camera/image_source.go rename to edge-ai-platform/server/internal/camera/image_source.go diff --git a/server/internal/camera/manager.go b/edge-ai-platform/server/internal/camera/manager.go similarity index 100% rename from server/internal/camera/manager.go rename to edge-ai-platform/server/internal/camera/manager.go diff --git a/server/internal/camera/mjpeg.go b/edge-ai-platform/server/internal/camera/mjpeg.go similarity index 100% rename from server/internal/camera/mjpeg.go rename to edge-ai-platform/server/internal/camera/mjpeg.go diff --git a/server/internal/camera/mock_camera.go b/edge-ai-platform/server/internal/camera/mock_camera.go similarity index 100% rename from server/internal/camera/mock_camera.go rename to edge-ai-platform/server/internal/camera/mock_camera.go diff --git a/server/internal/camera/multi_image_source.go b/edge-ai-platform/server/internal/camera/multi_image_source.go similarity index 100% rename from server/internal/camera/multi_image_source.go rename to edge-ai-platform/server/internal/camera/multi_image_source.go diff --git a/server/internal/camera/pipeline.go b/edge-ai-platform/server/internal/camera/pipeline.go similarity index 100% rename from server/internal/camera/pipeline.go rename to edge-ai-platform/server/internal/camera/pipeline.go diff --git a/server/internal/camera/video_source.go b/edge-ai-platform/server/internal/camera/video_source.go similarity index 100% rename from server/internal/camera/video_source.go rename to edge-ai-platform/server/internal/camera/video_source.go diff --git a/server/internal/cluster/dispatcher.go b/edge-ai-platform/server/internal/cluster/dispatcher.go similarity index 100% rename from server/internal/cluster/dispatcher.go rename to edge-ai-platform/server/internal/cluster/dispatcher.go diff --git a/server/internal/cluster/manager.go b/edge-ai-platform/server/internal/cluster/manager.go similarity index 100% rename from server/internal/cluster/manager.go rename to edge-ai-platform/server/internal/cluster/manager.go diff --git a/server/internal/cluster/pipeline.go b/edge-ai-platform/server/internal/cluster/pipeline.go similarity index 100% rename from server/internal/cluster/pipeline.go rename to edge-ai-platform/server/internal/cluster/pipeline.go diff --git a/server/internal/cluster/types.go b/edge-ai-platform/server/internal/cluster/types.go similarity index 100% rename from server/internal/cluster/types.go rename to edge-ai-platform/server/internal/cluster/types.go diff --git a/server/internal/config/config.go b/edge-ai-platform/server/internal/config/config.go similarity index 100% rename from server/internal/config/config.go rename to edge-ai-platform/server/internal/config/config.go diff --git a/server/internal/deps/checker.go b/edge-ai-platform/server/internal/deps/checker.go similarity index 100% rename from server/internal/deps/checker.go rename to edge-ai-platform/server/internal/deps/checker.go diff --git a/server/internal/device/manager.go b/edge-ai-platform/server/internal/device/manager.go similarity index 100% rename from server/internal/device/manager.go rename to edge-ai-platform/server/internal/device/manager.go diff --git a/server/internal/device/manager_test.go b/edge-ai-platform/server/internal/device/manager_test.go similarity index 100% rename from server/internal/device/manager_test.go rename to edge-ai-platform/server/internal/device/manager_test.go diff --git a/server/internal/device/registry.go b/edge-ai-platform/server/internal/device/registry.go similarity index 100% rename from server/internal/device/registry.go rename to edge-ai-platform/server/internal/device/registry.go diff --git a/server/internal/device/session.go b/edge-ai-platform/server/internal/device/session.go similarity index 100% rename from server/internal/device/session.go rename to edge-ai-platform/server/internal/device/session.go diff --git a/server/internal/device/types.go b/edge-ai-platform/server/internal/device/types.go similarity index 100% rename from server/internal/device/types.go rename to edge-ai-platform/server/internal/device/types.go diff --git a/server/internal/driver/interface.go b/edge-ai-platform/server/internal/driver/interface.go similarity index 100% rename from server/internal/driver/interface.go rename to edge-ai-platform/server/internal/driver/interface.go diff --git a/server/internal/driver/kneron/detector.go b/edge-ai-platform/server/internal/driver/kneron/detector.go similarity index 100% rename from server/internal/driver/kneron/detector.go rename to edge-ai-platform/server/internal/driver/kneron/detector.go diff --git a/server/internal/driver/kneron/kl720_driver.go b/edge-ai-platform/server/internal/driver/kneron/kl720_driver.go similarity index 100% rename from server/internal/driver/kneron/kl720_driver.go rename to edge-ai-platform/server/internal/driver/kneron/kl720_driver.go diff --git a/server/internal/driver/mock/mock_driver.go b/edge-ai-platform/server/internal/driver/mock/mock_driver.go similarity index 100% rename from server/internal/driver/mock/mock_driver.go rename to edge-ai-platform/server/internal/driver/mock/mock_driver.go diff --git a/server/internal/flash/progress.go b/edge-ai-platform/server/internal/flash/progress.go similarity index 100% rename from server/internal/flash/progress.go rename to edge-ai-platform/server/internal/flash/progress.go diff --git a/server/internal/flash/service.go b/edge-ai-platform/server/internal/flash/service.go similarity index 100% rename from server/internal/flash/service.go rename to edge-ai-platform/server/internal/flash/service.go diff --git a/server/internal/inference/service.go b/edge-ai-platform/server/internal/inference/service.go similarity index 100% rename from server/internal/inference/service.go rename to edge-ai-platform/server/internal/inference/service.go diff --git a/server/internal/model/repository.go b/edge-ai-platform/server/internal/model/repository.go similarity index 100% rename from server/internal/model/repository.go rename to edge-ai-platform/server/internal/model/repository.go diff --git a/server/internal/model/repository_test.go b/edge-ai-platform/server/internal/model/repository_test.go similarity index 100% rename from server/internal/model/repository_test.go rename to edge-ai-platform/server/internal/model/repository_test.go diff --git a/server/internal/model/store.go b/edge-ai-platform/server/internal/model/store.go similarity index 100% rename from server/internal/model/store.go rename to edge-ai-platform/server/internal/model/store.go diff --git a/server/internal/model/types.go b/edge-ai-platform/server/internal/model/types.go similarity index 100% rename from server/internal/model/types.go rename to edge-ai-platform/server/internal/model/types.go diff --git a/server/internal/relay/server.go b/edge-ai-platform/server/internal/relay/server.go similarity index 100% rename from server/internal/relay/server.go rename to edge-ai-platform/server/internal/relay/server.go diff --git a/server/internal/tunnel/client.go b/edge-ai-platform/server/internal/tunnel/client.go similarity index 100% rename from server/internal/tunnel/client.go rename to edge-ai-platform/server/internal/tunnel/client.go diff --git a/server/internal/update/checker.go b/edge-ai-platform/server/internal/update/checker.go similarity index 100% rename from server/internal/update/checker.go rename to edge-ai-platform/server/internal/update/checker.go diff --git a/server/main.go b/edge-ai-platform/server/main.go similarity index 100% rename from server/main.go rename to edge-ai-platform/server/main.go diff --git a/server/pkg/hwid/hwid.go b/edge-ai-platform/server/pkg/hwid/hwid.go similarity index 100% rename from server/pkg/hwid/hwid.go rename to edge-ai-platform/server/pkg/hwid/hwid.go diff --git a/server/pkg/logger/broadcaster.go b/edge-ai-platform/server/pkg/logger/broadcaster.go similarity index 100% rename from server/pkg/logger/broadcaster.go rename to edge-ai-platform/server/pkg/logger/broadcaster.go diff --git a/server/pkg/logger/logger.go b/edge-ai-platform/server/pkg/logger/logger.go similarity index 100% rename from server/pkg/logger/logger.go rename to edge-ai-platform/server/pkg/logger/logger.go diff --git a/server/pkg/wsconn/wsconn.go b/edge-ai-platform/server/pkg/wsconn/wsconn.go similarity index 100% rename from server/pkg/wsconn/wsconn.go rename to edge-ai-platform/server/pkg/wsconn/wsconn.go diff --git a/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc b/edge-ai-platform/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc similarity index 100% rename from server/scripts/__pycache__/kneron_bridge.cpython-39.pyc rename to edge-ai-platform/server/scripts/__pycache__/kneron_bridge.cpython-39.pyc diff --git a/local_service_win/firmware/KL520/fw_ncpu.bin b/edge-ai-platform/server/scripts/firmware/KL520/fw_ncpu.bin similarity index 100% rename from local_service_win/firmware/KL520/fw_ncpu.bin rename to edge-ai-platform/server/scripts/firmware/KL520/fw_ncpu.bin diff --git a/local_service_win/firmware/KL520/fw_scpu.bin b/edge-ai-platform/server/scripts/firmware/KL520/fw_scpu.bin similarity index 100% rename from local_service_win/firmware/KL520/fw_scpu.bin rename to edge-ai-platform/server/scripts/firmware/KL520/fw_scpu.bin diff --git a/local_service_win/firmware/KL720/fw_ncpu.bin b/edge-ai-platform/server/scripts/firmware/KL720/fw_ncpu.bin similarity index 100% rename from local_service_win/firmware/KL720/fw_ncpu.bin rename to edge-ai-platform/server/scripts/firmware/KL720/fw_ncpu.bin diff --git a/local_service_win/firmware/KL720/fw_scpu.bin b/edge-ai-platform/server/scripts/firmware/KL720/fw_scpu.bin similarity index 100% rename from local_service_win/firmware/KL720/fw_scpu.bin rename to edge-ai-platform/server/scripts/firmware/KL720/fw_scpu.bin diff --git a/server/scripts/kneron_bridge.py b/edge-ai-platform/server/scripts/kneron_bridge.py similarity index 100% rename from server/scripts/kneron_bridge.py rename to edge-ai-platform/server/scripts/kneron_bridge.py diff --git a/server/scripts/requirements.txt b/edge-ai-platform/server/scripts/requirements.txt similarity index 100% rename from server/scripts/requirements.txt rename to edge-ai-platform/server/scripts/requirements.txt diff --git a/server/scripts/update_kl720_firmware.py b/edge-ai-platform/server/scripts/update_kl720_firmware.py similarity index 100% rename from server/scripts/update_kl720_firmware.py rename to edge-ai-platform/server/scripts/update_kl720_firmware.py diff --git a/server/tray/assets/icon_running.png b/edge-ai-platform/server/tray/assets/icon_running.png similarity index 100% rename from server/tray/assets/icon_running.png rename to edge-ai-platform/server/tray/assets/icon_running.png diff --git a/server/tray/assets/icon_stopped.png b/edge-ai-platform/server/tray/assets/icon_stopped.png similarity index 100% rename from server/tray/assets/icon_stopped.png rename to edge-ai-platform/server/tray/assets/icon_stopped.png diff --git a/server/tray/config.go b/edge-ai-platform/server/tray/config.go similarity index 100% rename from server/tray/config.go rename to edge-ai-platform/server/tray/config.go diff --git a/server/tray/icons.go b/edge-ai-platform/server/tray/icons.go similarity index 100% rename from server/tray/icons.go rename to edge-ai-platform/server/tray/icons.go diff --git a/server/tray/stub.go b/edge-ai-platform/server/tray/stub.go similarity index 100% rename from server/tray/stub.go rename to edge-ai-platform/server/tray/stub.go diff --git a/server/tray/tray.go b/edge-ai-platform/server/tray/tray.go similarity index 100% rename from server/tray/tray.go rename to edge-ai-platform/server/tray/tray.go diff --git a/server/web/embed.go b/edge-ai-platform/server/web/embed.go similarity index 100% rename from server/web/embed.go rename to edge-ai-platform/server/web/embed.go diff --git a/local_service_win/.gitignore b/local_service_win/.gitignore deleted file mode 100644 index b23a173..0000000 --- a/local_service_win/.gitignore +++ /dev/null @@ -1,140 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff -instance/ -.webassets-cache - -# Scrapy stuff -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -#Pipfile.lock - -# poetry -#poetry.lock - -# pdm -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582 -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# IDE -.vscode/ -.idea/ diff --git a/local_service_win/KneronPLUS-3.1.2-py3-none-any.whl b/local_service_win/KneronPLUS-3.1.2-py3-none-any.whl deleted file mode 100644 index c8f5b22..0000000 Binary files a/local_service_win/KneronPLUS-3.1.2-py3-none-any.whl and /dev/null differ diff --git a/local_service_win/LocalAPI/__init__.py b/local_service_win/LocalAPI/__init__.py deleted file mode 100644 index da5f2a6..0000000 --- a/local_service_win/LocalAPI/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# LocalAPI package diff --git a/local_service_win/LocalAPI/legacy_plus121_runner.py b/local_service_win/LocalAPI/legacy_plus121_runner.py deleted file mode 100644 index 62f60a1..0000000 --- a/local_service_win/LocalAPI/legacy_plus121_runner.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import ctypes -import hashlib -import json -import os -import sys -import time -from pathlib import Path -from typing import Any, Dict - -KDP_MAGIC_CONNECTION_PASS = 536173391 -KP_SUCCESS = 0 -KP_RESET_REBOOT = 0 -USB_WAIT_CONNECT_DELAY_MS = 100 -USB_WAIT_AFTER_REBOOT_MS = 2000 -USB_WAIT_RETRY_CONNECT_MS = 10 -MAX_RETRY_CONNECT_TIMES = 10 - - -def _normalize_code(code: int) -> int: - # Some legacy paths may return int8-like unsigned values (e.g. 253 for -3). - if code > 127: - return code - 256 - return code - - -def _load_libkplus(dist_root: Path) -> ctypes.CDLL: - lib_dir = dist_root / "kp" / "lib" - dll_path = lib_dir / "libkplus.dll" - if not dll_path.is_file(): - raise RuntimeError(f"libkplus.dll not found: {dll_path}") - - if hasattr(os, "add_dll_directory"): - os.add_dll_directory(str(lib_dir)) - - lib = ctypes.CDLL(str(dll_path)) - lib.kp_connect_devices.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int)] - lib.kp_connect_devices.restype = ctypes.c_void_p - lib.kp_set_timeout.argtypes = [ctypes.c_void_p, ctypes.c_int] - lib.kp_set_timeout.restype = None - lib.kp_reset_device.argtypes = [ctypes.c_void_p, ctypes.c_int] - lib.kp_reset_device.restype = ctypes.c_int - lib.kp_load_firmware_from_file.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p] - lib.kp_load_firmware_from_file.restype = ctypes.c_int - lib.kp_update_kdp_firmware_from_files.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_bool] - lib.kp_update_kdp_firmware_from_files.restype = ctypes.c_int - lib.kp_disconnect_devices.argtypes = [ctypes.c_void_p] - lib.kp_disconnect_devices.restype = ctypes.c_int - lib.kp_scan_devices.argtypes = [] - lib.kp_scan_devices.restype = ctypes.c_void_p - if hasattr(lib, "kp_error_string"): - lib.kp_error_string.argtypes = [ctypes.c_int] - lib.kp_error_string.restype = ctypes.c_char_p - return lib - - -def _errstr(lib: ctypes.CDLL, code: int) -> str: - signed_code = _normalize_code(code) - if hasattr(lib, "kp_error_string"): - try: - msg = lib.kp_error_string(int(code)) - if not msg and signed_code != code: - msg = lib.kp_error_string(int(signed_code)) - if msg: - return msg.decode("utf-8", errors="replace") - except Exception: - pass - return str(code) - - -def _find_port_id_with_kp(dist_root: Path, port_id: int | None, scan_index: int | None) -> int: - if port_id is not None: - return int(port_id) - - sys.path.insert(0, str(dist_root)) - import kp - - device_list = kp.core.scan_devices() - if device_list.device_descriptor_number == 0: - raise RuntimeError("NO_DEVICE: no device found") - - idx = 0 if scan_index is None else int(scan_index) - if idx < 0 or idx >= device_list.device_descriptor_number: - raise RuntimeError(f"INVALID_SCAN_INDEX: {idx}") - return int(device_list.device_descriptor_list[idx].usb_port_id) - - -def _file_diag(path_str: str) -> Dict[str, Any]: - p = Path(path_str) - info: Dict[str, Any] = { - "path": str(p), - "name": p.name, - "exists": p.is_file(), - } - if not p.is_file(): - return info - data = p.read_bytes() - info["size_bytes"] = len(data) - info["sha256"] = hashlib.sha256(data).hexdigest() - return info - - -def _scan_diag_with_kp(dist_root: Path) -> Dict[str, Any]: - sys.path.insert(0, str(dist_root)) - import kp - - scanned = [] - dev_list = kp.core.scan_devices() - num = int(dev_list.device_descriptor_number) - for i in range(num): - d = dev_list.device_descriptor_list[i] - scanned.append( - { - "scan_index": i, - "usb_port_id": int(d.usb_port_id), - "vendor_id": int(d.vendor_id), - "product_id": f"0x{int(d.product_id):04X}", - "link_speed": str(d.link_speed), - "usb_port_path": str(d.usb_port_path), - "is_connectable": bool(d.is_connectable), - "firmware": str(d.firmware), - } - ) - return {"count": num, "devices": scanned} - - -def _firmware_from_scan(scan_diag: Dict[str, Any], port_id: int) -> str: - for d in scan_diag.get("devices", []): - if int(d.get("usb_port_id", -1)) == int(port_id): - return str(d.get("firmware", "")).upper() - return "" - - -def _product_id_from_scan(scan_diag: Dict[str, Any], port_id: int) -> int | None: - for d in scan_diag.get("devices", []): - if int(d.get("usb_port_id", -1)) != int(port_id): - continue - raw = d.get("product_id") - if raw is None: - return None - text = str(raw).strip() - try: - if text.lower().startswith("0x"): - return int(text, 16) - return int(text) - except Exception: - return None - return None - - -def _connect_with_magic(lib: ctypes.CDLL, port_id: int) -> ctypes.c_void_p: - port_ids = (ctypes.c_int * 1)(int(port_id)) - status = ctypes.c_int(KDP_MAGIC_CONNECTION_PASS) - device_group = lib.kp_connect_devices(1, port_ids, ctypes.byref(status)) - if not device_group or status.value != KP_SUCCESS: - signed = _normalize_code(status.value) - raise RuntimeError( - f"CONNECT_FAILED: raw_code={status.value}, signed_code={signed}, msg={_errstr(lib, status.value)}" - ) - return device_group - - -def _reboot_and_reconnect(lib: ctypes.CDLL, device_group: ctypes.c_void_p, port_id: int) -> ctypes.c_void_p: - time.sleep(USB_WAIT_CONNECT_DELAY_MS / 1000.0) - ret = lib.kp_reset_device(device_group, KP_RESET_REBOOT) - if ret != KP_SUCCESS: - raise RuntimeError( - f"RESET_FAILED: raw_code={ret}, signed_code={_normalize_code(ret)}, msg={_errstr(lib, ret)}" - ) - time.sleep(USB_WAIT_AFTER_REBOOT_MS / 1000.0) - lib.kp_disconnect_devices(device_group) - - retries = 0 - while retries <= MAX_RETRY_CONNECT_TIMES: - try: - return _connect_with_magic(lib, port_id) - except RuntimeError: - time.sleep(USB_WAIT_RETRY_CONNECT_MS / 1000.0) - retries += 1 - - raise RuntimeError("RECONNECT_FAILED: max retry exceeded") - - -def main() -> None: - stage = "init" - diag: Dict[str, Any] = {} - try: - if len(sys.argv) != 2: - raise RuntimeError("missing json payload argument") - - req: Dict[str, Any] = json.loads(sys.argv[1]) - dist_root = Path(req["legacy_dist_root"]) - lib = _load_libkplus(dist_root) - - stage = "resolve_port" - port_id = _find_port_id_with_kp(dist_root, req.get("port_id"), req.get("scan_index")) - timeout_ms = req.get("timeout_ms", 5000) - scpu_path = req["scpu_path"] - ncpu_path = req["ncpu_path"] - loader_path = req.get("loader_path") or str(Path(scpu_path).with_name("fw_loader.bin")) - scan_diag = _scan_diag_with_kp(dist_root) - detected_firmware = _firmware_from_scan(scan_diag, int(port_id)) - selected_product_id = _product_id_from_scan(scan_diag, int(port_id)) - diag = { - "selected_port_id": int(port_id), - "selected_product_id": ( - f"0x{int(selected_product_id):04X}" if selected_product_id is not None else None - ), - "timeout_ms": int(timeout_ms) if timeout_ms is not None else None, - "firmware_files": { - "loader": _file_diag(loader_path), - "scpu": _file_diag(scpu_path), - "ncpu": _file_diag(ncpu_path), - }, - "scan": scan_diag, - "detected_firmware": detected_firmware, - } - - stage = "connect" - device_group = _connect_with_magic(lib, port_id) - - stage = "set_timeout" - if timeout_ms is not None: - lib.kp_set_timeout(device_group, int(timeout_ms)) - - method = "" - if detected_firmware == "KDP": - if not Path(loader_path).is_file(): - raise RuntimeError(f"LOADER_NOT_FOUND: {loader_path}") - - stage = "fw_switch_to_usb_boot_loader" - ret = lib.kp_update_kdp_firmware_from_files( - device_group, - loader_path.encode("utf-8"), - None, - True, - ) - method = "kp_update_kdp_firmware_from_files(loader)->kp_load_firmware_from_file" - if ret != KP_SUCCESS: - stage = "disconnect_after_fw_fail" - lib.kp_disconnect_devices(device_group) - raise RuntimeError( - f"FW_LOAD_FAILED: method={method}, raw_code={ret}, msg={_errstr(lib, ret)}" - ) - - stage = "fw_load_kdp2_after_loader" - if timeout_ms is not None: - lib.kp_set_timeout(device_group, int(timeout_ms)) - ret = lib.kp_load_firmware_from_file( - device_group, - scpu_path.encode("utf-8"), - ncpu_path.encode("utf-8"), - ) - else: - stage = "fw_load_kdp2_direct" - method = "kp_load_firmware_from_file_direct" - ret = lib.kp_load_firmware_from_file( - device_group, - scpu_path.encode("utf-8"), - ncpu_path.encode("utf-8"), - ) - - if ret != KP_SUCCESS: - stage = "disconnect_after_fw_fail" - lib.kp_disconnect_devices(device_group) - raise RuntimeError( - f"FW_LOAD_FAILED: method={method}, raw_code={ret}, msg={_errstr(lib, ret)}" - ) - - stage = "disconnect_after_fw_success" - # After firmware update with auto_reboot, disconnect may fail due to USB re-enumeration. - disc = lib.kp_disconnect_devices(device_group) - if disc != KP_SUCCESS: - disc_info = f"disconnect_nonzero_raw={disc},signed={_normalize_code(disc)}" - else: - disc_info = "disconnect_ok" - - print( - json.dumps( - { - "ok": True, - "port_id": int(port_id), - "connect_mode": "kp_connect_devices_with_magic_pass", - "firmware_method": method, - "disconnect_info": disc_info, - "diag": diag, - } - ) - ) - except Exception as exc: - print(json.dumps({"ok": False, "stage": stage, "error": str(exc), "diag": diag})) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/local_service_win/LocalAPI/main.py b/local_service_win/LocalAPI/main.py deleted file mode 100644 index 076410f..0000000 --- a/local_service_win/LocalAPI/main.py +++ /dev/null @@ -1,1282 +0,0 @@ -from __future__ import annotations - -import base64 -import json -import os -import tempfile -import subprocess -import sys -import threading -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List, Optional - -from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import FileResponse, JSONResponse, StreamingResponse -from pydantic import BaseModel, Field - -import kp - -try: - import cv2 # type: ignore -except Exception: - cv2 = None - - -SERVICE_VERSION = "0.1.0" -PROJECT_ROOT = Path(__file__).resolve().parent.parent -DFUT_ROOT = PROJECT_ROOT / "third_party" / "Kneron_DFUT" -DFUT_BIN = DFUT_ROOT / "bin" -DFUT_EXE = DFUT_BIN / "KneronDFUT.exe" -KP121_DIST = PROJECT_ROOT / "third_party" / "kneron_plus_1_2_1" / "dist" -KP121_RUNNER = Path(__file__).resolve().parent / "legacy_plus121_runner.py" -VIDEO_VIEWER_HTML = PROJECT_ROOT / "TestRes" / "Images" / "VideoInferenceWeb.html" - - -@dataclass -class DeviceState: - device_group: Optional[kp.DeviceGroup] = None - port_id: Optional[int] = None - model_desc: Optional[kp.ModelNefDescriptor] = None - - -STATE = DeviceState() -STATE_LOCK = threading.Lock() - - -app = FastAPI(title="Kneron LocalAPI", version=SERVICE_VERSION) - - -def _ok(data: Any) -> Dict[str, Any]: - return {"ok": True, "data": data, "error": None} - - -def _err(code: str, message: str) -> Dict[str, Any]: - return {"ok": False, "data": None, "error": {"code": code, "message": message}} - - -def _require_device() -> kp.DeviceGroup: - if STATE.device_group is None: - raise HTTPException(status_code=400, detail=_err("NO_DEVICE", "No connected device")) - return STATE.device_group - - -def _image_format_from_str(value: str) -> kp.ImageFormat: - value = value.upper() - # Build mapping from symbols that exist in the installed kp version. - candidate_names = { - "RGB565": "KP_IMAGE_FORMAT_RGB565", - "RGBA8888": "KP_IMAGE_FORMAT_RGBA8888", - "RAW8": "KP_IMAGE_FORMAT_RAW8", - "YUYV": "KP_IMAGE_FORMAT_YUYV", - "YUV420": "KP_IMAGE_FORMAT_YUV420", - } - mapping: Dict[str, kp.ImageFormat] = {} - for key, attr_name in candidate_names.items(): - enum_value = getattr(kp.ImageFormat, attr_name, None) - if enum_value is not None: - mapping[key] = enum_value - if value not in mapping: - supported = ", ".join(sorted(mapping.keys())) - raise HTTPException( - status_code=400, - detail=_err( - "INVALID_IMAGE_FORMAT", - f"Unsupported image_format: {value}. supported=[{supported}]", - ), - ) - return mapping[value] - - -def _channels_ordering_from_str(value: str) -> kp.ChannelOrdering: - value = value.upper() - candidate_names = { - "HCW": "KP_CHANNEL_ORDERING_HCW", - "CHW": "KP_CHANNEL_ORDERING_CHW", - "HWC": "KP_CHANNEL_ORDERING_HWC", - "DEFAULT": "KP_CHANNEL_ORDERING_DEFAULT", - } - mapping: Dict[str, kp.ChannelOrdering] = {} - for key, attr_name in candidate_names.items(): - enum_value = getattr(kp.ChannelOrdering, attr_name, None) - if enum_value is not None: - mapping[key] = enum_value - if "DEFAULT" not in mapping: - if "CHW" in mapping: - mapping["DEFAULT"] = mapping["CHW"] - elif "HCW" in mapping: - mapping["DEFAULT"] = mapping["HCW"] - if value not in mapping: - supported = ", ".join(sorted(mapping.keys())) - raise HTTPException( - status_code=400, - detail=_err( - "INVALID_CHANNEL_ORDERING", - f"Unsupported channels_ordering: {value}. supported=[{supported}]", - ), - ) - return mapping[value] - - -def _expected_image_size_bytes(image_format: str, width: int, height: int) -> Optional[int]: - fmt = image_format.upper() - if fmt == "RGB565": - return width * height * 2 - if fmt == "RGBA8888": - return width * height * 4 - if fmt == "RAW8": - return width * height - if fmt == "YUYV": - return width * height * 2 - if fmt == "YUV420": - # YUV420 requires even width/height; caller checks exact size only. - return (width * height * 3) // 2 - return None - - -def _product_name_from_id(product_id: int) -> str: - try: - return kp.ProductId(product_id).name.replace("KP_DEVICE_", "") - except ValueError: - return "UNKNOWN" - - -@app.exception_handler(HTTPException) -def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: - if isinstance(exc.detail, dict) and "ok" in exc.detail: - return JSONResponse(status_code=exc.status_code, content=exc.detail) - return JSONResponse( - status_code=exc.status_code, - content=_err("HTTP_ERROR", str(exc.detail)), - ) - - -@app.exception_handler(Exception) -def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse: - return JSONResponse( - status_code=500, - content=_err("INTERNAL_ERROR", str(exc)), - ) - - -class ConnectRequest(BaseModel): - port_id: Optional[int] = Field(default=None) - scan_index: Optional[int] = Field(default=None) - timeout_ms: Optional[int] = Field(default=5000) - - -class FirmwareLoadRequest(BaseModel): - scpu_path: str - ncpu_path: str - - -class LegacyPlusFirmwareLoadRequest(BaseModel): - port_id: Optional[int] = Field(default=None) - scan_index: Optional[int] = Field(default=None) - timeout_ms: Optional[int] = Field(default=5000) - loader_path: Optional[str] = Field(default=None) - scpu_path: str - ncpu_path: str - - -class ModelLoadRequest(BaseModel): - nef_path: str - - -class DriverInstallRequest(BaseModel): - target: str = "ALL" # ALL | KL520 | KL720 | KL630 | KL730 | KL830 - force: bool = False - - -class InferenceRunRequest(BaseModel): - model_id: int - image_format: str - width: int - height: int - image_base64: str - channels_ordering: str = "DEFAULT" - output_dtype: str = "float32" - - -def _resolve_port_id(req: ConnectRequest) -> int: - port_id = req.port_id - if port_id is not None: - return int(port_id) - - device_list = kp.core.scan_devices() - if device_list.device_descriptor_number == 0: - raise HTTPException(status_code=404, detail=_err("NO_DEVICE", "No device found")) - - scan_index = 0 if req.scan_index is None else req.scan_index - if scan_index < 0 or scan_index >= device_list.device_descriptor_number: - raise HTTPException( - status_code=400, - detail=_err("INVALID_SCAN_INDEX", f"Invalid scan_index: {scan_index}"), - ) - return int(device_list.device_descriptor_list[scan_index].usb_port_id) - - -def _scan_devices_snapshot() -> List[Dict[str, Any]]: - device_list = kp.core.scan_devices() - out: List[Dict[str, Any]] = [] - for i in range(int(device_list.device_descriptor_number)): - d = device_list.device_descriptor_list[i] - out.append( - { - "scan_index": i, - "usb_port_id": int(d.usb_port_id), - "product_id": int(d.product_id), - "firmware": str(d.firmware), - "usb_port_path": str(d.usb_port_path), - } - ) - return out - - -def _kl520_kdp_observed_after_timeout(target_port_id: int) -> Dict[str, Any]: - devices = _scan_devices_snapshot() - kl520 = [d for d in devices if int(d["product_id"]) == 0x0100] - exact = [d for d in kl520 if int(d["usb_port_id"]) == int(target_port_id)] - exact_kdp = [d for d in exact if "KDP" in str(d["firmware"]).upper()] - if exact_kdp: - return {"is_success": True, "reason": "exact_port_kdp", "devices": devices} - if not exact: - any_kdp = [d for d in kl520 if "KDP" in str(d["firmware"]).upper()] - if any_kdp: - return {"is_success": True, "reason": "reenumerated_port_kdp", "devices": devices} - return {"is_success": False, "reason": "kdp_not_observed", "devices": devices} - - -def _ensure_file_exists(path: Path, label: str) -> None: - if not path.is_file(): - raise HTTPException( - status_code=500, - detail=_err("FILE_NOT_FOUND", f"{label} not found: {path}"), - ) - - -def _run_dfut_command( - args: List[str], - timeout_sec: int = 180, - echo_console: bool = False, - echo_tag: str = "DFUT", -) -> Dict[str, Any]: - _ensure_file_exists(DFUT_EXE, "DFUT executable") - if echo_console: - print(f"[{echo_tag}] CMD: {' '.join(args)}", flush=True) - - stdout_lines: List[str] = [] - stderr_lines: List[str] = [] - - proc = subprocess.Popen( - args, - cwd=str(DFUT_BIN), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, - ) - - def _read_stream(stream: Any, collector: List[str], stream_name: str) -> None: - if stream is None: - return - try: - for line in iter(stream.readline, ""): - text = line.rstrip("\r\n") - collector.append(text) - if echo_console: - print(f"[{echo_tag}][{stream_name}] {text}", flush=True) - finally: - try: - stream.close() - except Exception: - pass - - t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_lines, "STDOUT"), daemon=True) - t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_lines, "STDERR"), daemon=True) - t_out.start() - t_err.start() - - try: - proc.wait(timeout=timeout_sec) - except subprocess.TimeoutExpired as exc: - proc.kill() - proc.wait() - t_out.join(timeout=1.0) - t_err.join(timeout=1.0) - stdout_text = "\n".join(stdout_lines).strip() - stderr_text = "\n".join(stderr_lines).strip() - raise HTTPException( - status_code=504, - detail=_err( - "DFUT_TIMEOUT", - ( - f"DFUT timed out after {timeout_sec}s. " - f"stdout={stdout_text} " - f"stderr={stderr_text}" - ), - ), - ) - t_out.join(timeout=1.0) - t_err.join(timeout=1.0) - - stdout_text = "\n".join(stdout_lines).strip() - stderr_text = "\n".join(stderr_lines).strip() - if proc.returncode != 0: - signed_code = proc.returncode - if signed_code > 0x7FFFFFFF: - signed_code -= 0x100000000 - raise HTTPException( - status_code=500, - detail=_err( - "DFUT_FAILED", - ( - f"DFUT failed with return code {proc.returncode} (signed={signed_code}). " - f"command={args}. " - f"stdout={stdout_text} " - f"stderr={stderr_text}" - ), - ), - ) - return { - "return_code": proc.returncode, - "stdout": stdout_text, - "stderr": stderr_text, - "command": args, - } - - -def _run_legacy_plus121_load_firmware(req: LegacyPlusFirmwareLoadRequest) -> Dict[str, Any]: - _ensure_file_exists(KP121_RUNNER, "Legacy 1.2.1 runner") - if not KP121_DIST.is_dir(): - raise HTTPException( - status_code=500, - detail=_err( - "LEGACY_PLUS_NOT_FOUND", - f"Legacy KneronPLUS 1.2.1 package directory not found: {KP121_DIST}", - ), - ) - - payload = { - "port_id": req.port_id, - "scan_index": req.scan_index, - "timeout_ms": req.timeout_ms, - "loader_path": req.loader_path, - "scpu_path": req.scpu_path, - "ncpu_path": req.ncpu_path, - "legacy_dist_root": str(KP121_DIST), - } - - env = os.environ.copy() - existing_pythonpath = env.get("PYTHONPATH", "") - legacy_pythonpath = str(KP121_DIST) - env["PYTHONPATH"] = ( - f"{legacy_pythonpath}{os.pathsep}{existing_pythonpath}" - if existing_pythonpath - else legacy_pythonpath - ) - - proc = subprocess.run( - [sys.executable, str(KP121_RUNNER), json.dumps(payload)], - cwd=str(PROJECT_ROOT), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - env=env, - ) - - if proc.returncode != 0: - runner_json = None - if proc.stdout.strip(): - try: - runner_json = json.loads(proc.stdout.strip().splitlines()[-1]) - except json.JSONDecodeError: - runner_json = None - debug_payload = { - "return_code": proc.returncode, - "stdout": proc.stdout.strip(), - "stderr": proc.stderr.strip(), - "runner_json": runner_json, - } - print("[legacy-plus121-runner-failed]", json.dumps(debug_payload, ensure_ascii=False)) - raise HTTPException( - status_code=500, - detail=_err( - "LEGACY_PLUS_FW_LOAD_FAILED", - json.dumps(debug_payload, ensure_ascii=False), - ), - ) - - parsed = {} - if proc.stdout.strip(): - try: - parsed = json.loads(proc.stdout.strip().splitlines()[-1]) - except json.JSONDecodeError: - parsed = {"raw_stdout": proc.stdout.strip()} - - return { - "return_code": proc.returncode, - "result": parsed, - } - - -def _target_product_ids(target: str) -> List[kp.ProductId]: - target = target.upper() - if target == "KL520": - return [kp.ProductId.KP_DEVICE_KL520] - if target == "KL720": - return [kp.ProductId.KP_DEVICE_KL720_LEGACY, kp.ProductId.KP_DEVICE_KL720] - if target == "KL630": - return [kp.ProductId.KP_DEVICE_KL630] - if target == "KL730": - return [kp.ProductId.KP_DEVICE_KL730] - if target == "KL830": - return [kp.ProductId.KP_DEVICE_KL830] - if target == "ALL": - return [ - kp.ProductId.KP_DEVICE_KL520, - kp.ProductId.KP_DEVICE_KL720_LEGACY, - kp.ProductId.KP_DEVICE_KL720, - kp.ProductId.KP_DEVICE_KL630, - kp.ProductId.KP_DEVICE_KL730, - kp.ProductId.KP_DEVICE_KL830, - ] - raise HTTPException(status_code=400, detail=_err("INVALID_TARGET", f"Unsupported target: {target}")) - - -def _pid_to_product_name(pid_value: int) -> str: - try: - return kp.ProductId(pid_value).name.replace("KP_DEVICE_", "") - except ValueError: - return f"UNKNOWN_0x{pid_value:04X}" - - -def _query_windows_driver_status() -> List[Dict[str, Any]]: - # Query current connected Kneron USB devices from Windows PnP layer. - ps = ( - "$items = Get-CimInstance Win32_PnPEntity | " - "Where-Object { $_.PNPDeviceID -like 'USB\\VID_3231&PID_*' } | " - "Select-Object PNPDeviceID,Name,Service,Status; " - "if ($null -eq $items) { '[]' } else { $items | ConvertTo-Json -Depth 3 }" - ) - proc = subprocess.run( - ["powershell", "-NoProfile", "-Command", ps], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - if proc.returncode != 0: - raise HTTPException( - status_code=500, - detail=_err("DRIVER_CHECK_FAILED", proc.stderr.strip() or "failed to query Windows PnP devices"), - ) - - out = proc.stdout.strip() - if not out: - return [] - try: - parsed = json.loads(out) - except json.JSONDecodeError: - return [] - if isinstance(parsed, dict): - parsed = [parsed] - - results = [] - for item in parsed: - pnp_id = item.get("PNPDeviceID", "") - pid_hex = "" - if "PID_" in pnp_id: - pid_hex = pnp_id.split("PID_")[1][:4] - pid_val = int(pid_hex, 16) if pid_hex else None - service = item.get("Service") or "" - results.append( - { - "pnp_device_id": pnp_id, - "name": item.get("Name"), - "status": item.get("Status"), - "service": service, - "pid_hex": f"0x{pid_hex}" if pid_hex else None, - "pid_value": pid_val, - "product_name": _pid_to_product_name(pid_val) if pid_val is not None else "UNKNOWN", - "is_winusb": service.lower() == "winusb", - } - ) - return results - - -def _open_camera_capture(camera_id: int) -> Any: - if cv2 is None: - raise HTTPException( - status_code=500, - detail=_err("OPENCV_NOT_AVAILABLE", "opencv-python is not installed"), - ) - cap = cv2.VideoCapture(camera_id, cv2.CAP_DSHOW) - if not cap.isOpened(): - cap.release() - cap = cv2.VideoCapture(camera_id) - if not cap.isOpened(): - cap.release() - raise HTTPException( - status_code=404, - detail=_err("CAMERA_NOT_FOUND", f"Cannot open camera id={camera_id}"), - ) - return cap - - -def _mjpeg_stream_generator(cap: Any, jpeg_quality: int, frame_interval_sec: float): - try: - while True: - ok, frame = cap.read() - if not ok: - time.sleep(0.03) - continue - - ok, encoded = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality]) - if not ok: - continue - - jpg = encoded.tobytes() - header = ( - b"--frame\r\n" - b"Content-Type: image/jpeg\r\n" - b"Content-Length: " + str(len(jpg)).encode("ascii") + b"\r\n\r\n" - ) - yield header + jpg + b"\r\n" - - if frame_interval_sec > 0: - time.sleep(frame_interval_sec) - finally: - cap.release() - - -def _frame_to_input_bytes(frame_bgr: Any, image_format: str) -> bytes: - fmt = image_format.upper() - if fmt == "RGB565": - converted = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2BGR565) - return converted.tobytes() - if fmt == "RGBA8888": - converted = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA) - return converted.tobytes() - if fmt == "RAW8": - converted = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) - return converted.tobytes() - raise HTTPException( - status_code=400, - detail=_err( - "UNSUPPORTED_STREAM_IMAGE_FORMAT", - "For /inference/run_video, supported image_format: RGB565, RGBA8888, RAW8", - ), - ) - - -def _run_inference_from_image_bytes( - image_bytes: bytes, - width: int, - height: int, - model_id: int, - image_format_text: str, - channels_ordering_text: str, - output_dtype_text: str, -) -> List[Dict[str, Any]]: - device_group = _require_device() - image_format = _image_format_from_str(image_format_text) - channels_ordering = _channels_ordering_from_str(channels_ordering_text) - if output_dtype_text.lower() != "float32": - raise HTTPException( - status_code=400, - detail=_err("INVALID_OUTPUT_DTYPE", "Only float32 output is supported in PoC"), - ) - - try: - if STATE.port_id is not None: - kp.core.get_model_info(device_group, STATE.port_id) - except kp.ApiKPException as exc: - if exc.api_return_code == kp.ApiReturnCode.KP_ERROR_MODEL_NOT_LOADED_35: - raise HTTPException( - status_code=500, - detail=_err( - "KP_ERROR_MODEL_NOT_LOADED_35", - str(kp.ApiReturnCode.KP_ERROR_MODEL_NOT_LOADED_35), - ), - ) - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - expected_size = _expected_image_size_bytes(image_format_text, width, height) - if expected_size is not None and len(image_bytes) != expected_size: - raise HTTPException( - status_code=400, - detail=_err( - "INVALID_IMAGE_SIZE", - ( - f"image bytes size mismatch: expected={expected_size}, actual={len(image_bytes)}. " - "Send raw pixel bytes for selected image_format (not BMP/JPEG/PNG file bytes)." - ), - ), - ) - - input_image = kp.GenericInputNodeImage( - image=image_bytes, - width=width, - height=height, - image_format=image_format, - ) - - input_desc = kp.GenericImageInferenceDescriptor( - model_id=model_id, - input_node_image_list=[input_image], - ) - - try: - kp.inference.generic_image_inference_send(device_group, input_desc) - result = kp.inference.generic_image_inference_receive(device_group) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - outputs = [] - for node_idx in range(result.header.num_output_node): - try: - node_output = kp.inference.generic_inference_retrieve_float_node( - node_idx, result, channels_ordering - ) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - data_bytes = node_output.ndarray.astype("float32").tobytes() - outputs.append( - { - "node_idx": node_idx, - "name": node_output.name, - "dtype": "float32", - "shape": node_output.shape, - "data_base64": base64.b64encode(data_bytes).decode("ascii"), - "channels_ordering": channels_ordering.name, - } - ) - return outputs - - -@app.get("/health") -def health() -> Dict[str, Any]: - return _ok({"status": "up"}) - - -@app.get("/tools/video-inference") -def tools_video_inference() -> FileResponse: - if not VIDEO_VIEWER_HTML.is_file(): - raise HTTPException( - status_code=404, - detail=_err("TOOL_PAGE_NOT_FOUND", f"Tool page not found: {VIDEO_VIEWER_HTML}"), - ) - return FileResponse(str(VIDEO_VIEWER_HTML), media_type="text/html; charset=utf-8") - - -@app.get("/version") -def version() -> Dict[str, Any]: - return _ok( - { - "service_version": SERVICE_VERSION, - "kneronplus_version": kp.core.get_version(), - } - ) - - -@app.get("/camera/list") -def camera_list(max_probe: int = 5) -> Dict[str, Any]: - if max_probe < 1 or max_probe > 20: - raise HTTPException( - status_code=400, - detail=_err("INVALID_MAX_PROBE", "max_probe must be between 1 and 20"), - ) - if cv2 is None: - raise HTTPException( - status_code=500, - detail=_err("OPENCV_NOT_AVAILABLE", "opencv-python is not installed"), - ) - - cameras: List[Dict[str, Any]] = [] - for camera_id in range(max_probe): - cap = cv2.VideoCapture(camera_id, cv2.CAP_DSHOW) - opened = cap.isOpened() - if not opened: - cap.release() - cap = cv2.VideoCapture(camera_id) - opened = cap.isOpened() - if opened: - cameras.append( - { - "camera_id": camera_id, - "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0), - "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0), - "fps": float(cap.get(cv2.CAP_PROP_FPS) or 0.0), - } - ) - cap.release() - return _ok({"cameras": cameras}) - - -@app.get("/camera/stream") -def camera_stream( - camera_id: int = 0, - width: Optional[int] = None, - height: Optional[int] = None, - fps: Optional[float] = None, - jpeg_quality: int = 80, -) -> StreamingResponse: - if camera_id < 0: - raise HTTPException( - status_code=400, - detail=_err("INVALID_CAMERA_ID", "camera_id must be >= 0"), - ) - if width is not None and width <= 0: - raise HTTPException(status_code=400, detail=_err("INVALID_WIDTH", "width must be > 0")) - if height is not None and height <= 0: - raise HTTPException(status_code=400, detail=_err("INVALID_HEIGHT", "height must be > 0")) - if fps is not None and (fps <= 0 or fps > 60): - raise HTTPException(status_code=400, detail=_err("INVALID_FPS", "fps must be in range (0, 60]")) - if jpeg_quality < 1 or jpeg_quality > 100: - raise HTTPException( - status_code=400, - detail=_err("INVALID_JPEG_QUALITY", "jpeg_quality must be in range [1, 100]"), - ) - - cap = _open_camera_capture(camera_id) - if width is not None: - cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) - if height is not None: - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) - if fps is not None: - cap.set(cv2.CAP_PROP_FPS, float(fps)) - - frame_interval_sec = (1.0 / float(fps)) if fps else 0.0 - stream = _mjpeg_stream_generator(cap, jpeg_quality=jpeg_quality, frame_interval_sec=frame_interval_sec) - - headers = { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - } - return StreamingResponse( - stream, - media_type="multipart/x-mixed-replace; boundary=frame", - headers=headers, - ) - - -@app.get("/devices") -def devices() -> Dict[str, Any]: - device_list = kp.core.scan_devices() - devices_out = [] - for idx, device in enumerate(device_list.device_descriptor_list): - devices_out.append( - { - "scan_index": idx, - "usb_port_id": device.usb_port_id, - "vendor_id": device.vendor_id, - "product_id": f"0x{device.product_id:X}", - "product_name": _product_name_from_id(device.product_id), - "link_speed": device.link_speed.name, - "usb_port_path": device.usb_port_path, - "kn_number": device.kn_number, - "is_connectable": device.is_connectable, - "firmware": device.firmware, - } - ) - return _ok({"devices": devices_out}) - - -@app.post("/devices/connect") -def connect(req: ConnectRequest) -> Dict[str, Any]: - with STATE_LOCK: - if STATE.device_group is not None: - try: - kp.core.disconnect_devices(STATE.device_group) - except kp.ApiKPException: - pass - STATE.device_group = None - STATE.port_id = None - STATE.model_desc = None - - port_id = _resolve_port_id(req) - - try: - device_group = kp.core.connect_devices([int(port_id)]) - if req.timeout_ms is not None: - kp.core.set_timeout(device_group, int(req.timeout_ms)) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - STATE.device_group = device_group - STATE.port_id = int(port_id) - return _ok({"connected": True, "port_id": STATE.port_id}) - - -@app.post("/devices/connect_force") -def connect_force(req: ConnectRequest) -> Dict[str, Any]: - with STATE_LOCK: - if STATE.device_group is not None: - try: - kp.core.disconnect_devices(STATE.device_group) - except kp.ApiKPException: - pass - STATE.device_group = None - STATE.port_id = None - STATE.model_desc = None - - port_id = _resolve_port_id(req) - - try: - device_group = kp.core.connect_devices_without_check([int(port_id)]) - if req.timeout_ms is not None: - kp.core.set_timeout(device_group, int(req.timeout_ms)) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - STATE.device_group = device_group - STATE.port_id = int(port_id) - return _ok({"connected": True, "port_id": STATE.port_id, "forced": True}) - - -@app.post("/devices/disconnect") -def disconnect() -> Dict[str, Any]: - with STATE_LOCK: - if STATE.device_group is not None: - try: - kp.core.disconnect_devices(STATE.device_group) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - STATE.device_group = None - STATE.port_id = None - STATE.model_desc = None - return _ok({"connected": False}) - - -@app.get("/driver/check") -def driver_check() -> Dict[str, Any]: - entries = _query_windows_driver_status() - all_winusb = all(entry["is_winusb"] for entry in entries) if entries else False - return _ok( - { - "entries": entries, - "all_connected_kneron_are_winusb": all_winusb, - "count": len(entries), - "note": "Run service as Administrator if driver install/update fails.", - } - ) - - -@app.post("/driver/install") -def driver_install(req: DriverInstallRequest) -> Dict[str, Any]: - targets = _target_product_ids(req.target) - results = [] - for product_id in targets: - try: - kp.core.install_driver_for_windows(product_id=product_id) - results.append({"product_id": f"0x{product_id.value:04X}", "product_name": product_id.name, "installed": True}) - except kp.ApiKPException as exc: - results.append( - { - "product_id": f"0x{product_id.value:04X}", - "product_name": product_id.name, - "installed": False, - "error_code": str(exc.api_return_code), - "error_message": str(exc), - } - ) - return _ok({"target": req.target.upper(), "results": results}) - - -@app.post("/driver/ensure") -def driver_ensure(req: DriverInstallRequest) -> Dict[str, Any]: - target = req.target.upper() - target_ids = _target_product_ids(target) - target_pid_values = {pid.value for pid in target_ids} - - entries = _query_windows_driver_status() - needs_install = req.force - for entry in entries: - if entry["pid_value"] in target_pid_values and not entry["is_winusb"]: - needs_install = True - break - - install_result = None - if needs_install: - install_result = driver_install(req) - - after = _query_windows_driver_status() - return _ok( - { - "target": target, - "needed_install": needs_install, - "install_result": install_result["data"] if install_result else None, - "before": entries, - "after": after, - } - ) - - -@app.post("/firmware/load") -def firmware_load(req: FirmwareLoadRequest) -> Dict[str, Any]: - device_group = _require_device() - try: - kp.core.load_firmware_from_file(device_group, req.scpu_path, req.ncpu_path) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - return _ok({"loaded": True}) - - -@app.post("/firmware/legacy-plus121/load") -def firmware_legacy_plus121_load(req: LegacyPlusFirmwareLoadRequest) -> Dict[str, Any]: - # Ensure current process does not hold USB handles before legacy subprocess runs. - with STATE_LOCK: - if STATE.device_group is not None: - try: - kp.core.disconnect_devices(STATE.device_group) - except kp.ApiKPException: - pass - STATE.device_group = None - STATE.port_id = None - STATE.model_desc = None - - _ensure_file_exists(Path(req.scpu_path), "Legacy SCPU firmware") - _ensure_file_exists(Path(req.ncpu_path), "Legacy NCPU firmware") - if req.loader_path is not None: - _ensure_file_exists(Path(req.loader_path), "Legacy loader firmware") - result = _run_legacy_plus121_load_firmware(req) - return _ok({"loaded": True, "legacy_plus_version": "1.2.1", "runner": result}) - - -@app.post("/firmware/legacy-upgrade/kl520") -def firmware_legacy_upgrade_kl520(req: ConnectRequest) -> Dict[str, Any]: - print("[DFUT-KL520-UPGRADE] endpoint entered", flush=True) - port_id = _resolve_port_id(req) - print(f"[DFUT-KL520-UPGRADE] resolved port_id={port_id}", flush=True) - fw_scpu = PROJECT_ROOT / "firmware" / "KL520" / "fw_scpu.bin" - fw_ncpu = PROJECT_ROOT / "firmware" / "KL520" / "fw_ncpu.bin" - _ensure_file_exists(fw_scpu, "KL520 SCPU firmware") - _ensure_file_exists(fw_ncpu, "KL520 NCPU firmware") - - args = [ - str(DFUT_EXE), - "--kl520-flash-boot", - "--port", - str(port_id), - "--scpu", - str(fw_scpu), - "--ncpu", - str(fw_ncpu), - "--quiet", - ] - result = _run_dfut_command(args, echo_console=True, echo_tag="DFUT-KL520-UPGRADE") - return _ok( - { - "upgraded": True, - "target": "KL520", - "port_id": port_id, - "dfut": result, - } - ) - - -@app.post("/firmware/legacy-downgrade/kl520") -def firmware_legacy_downgrade_kl520(req: ConnectRequest) -> Dict[str, Any]: - port_id = _resolve_port_id(req) - fw_scpu = PROJECT_ROOT / "firmware" / "KL520_kdp" / "fw_scpu.bin" - fw_ncpu = PROJECT_ROOT / "firmware" / "KL520_kdp" / "fw_ncpu.bin" - _ensure_file_exists(fw_scpu, "KL520 KDP SCPU firmware") - _ensure_file_exists(fw_ncpu, "KL520 KDP NCPU firmware") - - args = [ - str(DFUT_EXE), - "--kl520-update", - "--port", - str(port_id), - "--scpu", - str(fw_scpu), - "--ncpu", - str(fw_ncpu), - ] - timeout_sec = 30 - try: - result = _run_dfut_command( - args, - timeout_sec=timeout_sec, - echo_console=False, - echo_tag="DFUT-KL520-DOWNGRADE", - ) - except HTTPException as exc: - detail = exc.detail if isinstance(exc.detail, dict) else None - err_code = ( - str(detail.get("error", {}).get("code", "")) if isinstance(detail, dict) else "" - ) - if exc.status_code == 504 and err_code == "DFUT_TIMEOUT": - post = _kl520_kdp_observed_after_timeout(port_id) - if post["is_success"]: - return _ok( - { - "downgraded": True, - "target": "KL520", - "port_id": port_id, - "dfut_timeout_but_verified": True, - "verification_reason": post["reason"], - "scan_after_timeout": post["devices"], - "warning": "DFUT timeout but KL520 KDP state observed after timeout.", - } - ) - raise - return _ok( - { - "downgraded": True, - "target": "KL520", - "port_id": port_id, - "dfut": result, - } - ) - - -@app.post("/firmware/legacy-upgrade/kl720") -def firmware_legacy_upgrade_kl720(req: ConnectRequest) -> Dict[str, Any]: - port_id = _resolve_port_id(req) - fw_scpu = PROJECT_ROOT / "firmware" / "KL720" / "fw_scpu.bin" - fw_ncpu = PROJECT_ROOT / "firmware" / "KL720" / "fw_ncpu.bin" - _ensure_file_exists(fw_scpu, "KL720 SCPU firmware") - _ensure_file_exists(fw_ncpu, "KL720 NCPU firmware") - - args = [ - str(DFUT_EXE), - "--kl720-update", - "--port", - str(port_id), - "--scpu", - str(fw_scpu), - "--ncpu", - str(fw_ncpu), - "--quiet", - ] - result = _run_dfut_command(args) - return _ok( - { - "upgraded": True, - "target": "KL720", - "port_id": port_id, - "dfut": result, - } - ) - - -@app.post("/models/load") -def models_load(req: ModelLoadRequest) -> Dict[str, Any]: - device_group = _require_device() - try: - model_desc = kp.core.load_model_from_file(device_group, req.nef_path) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - with STATE_LOCK: - STATE.model_desc = model_desc - - models = [] - for model in model_desc.models: - models.append( - { - "id": model.id, - "input_nodes": len(model.input_nodes), - "output_nodes": len(model.output_nodes), - "max_raw_out_size": model.max_raw_out_size, - } - ) - - return _ok({"models": models}) - - -def _reset_device_and_clear_state(device_group: kp.DeviceGroup) -> None: - kp.core.reset_device(device_group, kp.ResetMode.KP_RESET_REBOOT) - kp.core.disconnect_devices(device_group) - - -@app.post("/models/clear") -def models_clear() -> Dict[str, Any]: - device_group = _require_device() - try: - _reset_device_and_clear_state(device_group) - except kp.ApiKPException as exc: - raise HTTPException( - status_code=500, - detail=_err(str(exc.api_return_code), str(exc)), - ) - - with STATE_LOCK: - STATE.device_group = None - STATE.port_id = None - STATE.model_desc = None - - return _ok({"cleared": True}) - - -@app.post("/models/reset") -def models_reset() -> Dict[str, Any]: - return models_clear() - - -@app.post("/inference/run") -def inference_run(req: InferenceRunRequest) -> Dict[str, Any]: - b64_text = req.image_base64.strip() - if b64_text.startswith("data:") and "," in b64_text: - b64_text = b64_text.split(",", 1)[1] - - try: - image_bytes = base64.b64decode(b64_text) - except (ValueError, TypeError): - raise HTTPException( - status_code=400, - detail=_err("INVALID_BASE64", "image_base64 is not valid base64 data"), - ) - outputs = _run_inference_from_image_bytes( - image_bytes=image_bytes, - width=req.width, - height=req.height, - model_id=req.model_id, - image_format_text=req.image_format, - channels_ordering_text=req.channels_ordering, - output_dtype_text=req.output_dtype, - ) - - return _ok({"outputs": outputs}) - - -@app.post("/inference/run_video") -async def inference_run_video( - file: UploadFile = File(...), - model_id: int = Form(...), - image_format: str = Form(...), - channels_ordering: str = Form("DEFAULT"), - output_dtype: str = Form("float32"), - sample_every_n: int = Form(1), - max_frames: Optional[int] = Form(default=None), -) -> StreamingResponse: - if cv2 is None: - raise HTTPException( - status_code=500, - detail=_err("OPENCV_NOT_AVAILABLE", "opencv-python is not installed"), - ) - if sample_every_n <= 0: - raise HTTPException( - status_code=400, - detail=_err("INVALID_SAMPLE_EVERY_N", "sample_every_n must be >= 1"), - ) - if max_frames is not None and max_frames <= 0: - raise HTTPException( - status_code=400, - detail=_err("INVALID_MAX_FRAMES", "max_frames must be >= 1 when provided"), - ) - - suffix = Path(file.filename or "upload.mp4").suffix or ".mp4" - tmp_path = Path(tempfile.gettempdir()) / f"inference_upload_{int(time.time() * 1000)}{suffix}" - with tmp_path.open("wb") as f: - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - f.write(chunk) - await file.close() - - def _iter_results(): - cap = cv2.VideoCapture(str(tmp_path)) - if not cap.isOpened(): - cap.release() - if tmp_path.exists(): - tmp_path.unlink() - error_line = json.dumps( - _err("VIDEO_OPEN_FAILED", f"Cannot open uploaded video: {tmp_path.name}"), - ensure_ascii=False, - ) - yield (error_line + "\n").encode("utf-8") - return - - sent_count = 0 - frame_index = -1 - try: - while True: - ok, frame = cap.read() - if not ok: - break - frame_index += 1 - if frame_index % sample_every_n != 0: - continue - - height, width = int(frame.shape[0]), int(frame.shape[1]) - image_bytes = _frame_to_input_bytes(frame, image_format) - outputs = _run_inference_from_image_bytes( - image_bytes=image_bytes, - width=width, - height=height, - model_id=model_id, - image_format_text=image_format, - channels_ordering_text=channels_ordering, - output_dtype_text=output_dtype, - ) - payload = _ok( - { - "frame_index": frame_index, - "width": width, - "height": height, - "outputs": outputs, - } - ) - yield (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8") - - sent_count += 1 - if max_frames is not None and sent_count >= max_frames: - break - finally: - cap.release() - if tmp_path.exists(): - tmp_path.unlink() - - headers = { - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - "Connection": "keep-alive", - } - return StreamingResponse( - _iter_results(), - media_type="application/x-ndjson", - headers=headers, - ) - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(app, host="127.0.0.1", port=4398) diff --git a/local_service_win/LocalAPI/postprocess_core.py b/local_service_win/LocalAPI/postprocess_core.py deleted file mode 100644 index 8021e75..0000000 --- a/local_service_win/LocalAPI/postprocess_core.py +++ /dev/null @@ -1,293 +0,0 @@ -from __future__ import annotations - -import base64 -import math -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import numpy as np - - -YOLO_DEFAULT_ANCHORS: List[List[Tuple[float, float]]] = [ - [(10.0, 14.0), (23.0, 27.0), (37.0, 58.0)], - [(81.0, 82.0), (135.0, 169.0), (344.0, 319.0)], -] - - -@dataclass -class Box: - cls: int - score: float - x1: float - y1: float - x2: float - y2: float - - -def _sigmoid(v: np.ndarray | float) -> np.ndarray | float: - return 1.0 / (1.0 + np.exp(-v)) - - -def decode_outputs(raw_outputs: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: - decoded: List[Dict[str, Any]] = [] - for idx, o in enumerate(raw_outputs): - shape = list(o.get("shape") or []) - data_b64 = str(o.get("data_base64") or "") - raw = base64.b64decode(data_b64) - arr = np.frombuffer(raw, dtype=" List[Dict[str, Any]]: - picked: List[Dict[str, Any]] = [] - for o in all_nodes: - shape = o["shape"] - if len(shape) != 4 or shape[0] != 1: - continue - ch = int(shape[1]) - if ch % (5 + num_classes) != 0: - continue - picked.append(o) - picked.sort(key=lambda n: int(n["shape"][2]), reverse=True) - return picked - - -def decode_yolo_common( - all_nodes: Sequence[Dict[str, Any]], - mode: str, - num_classes: int, - input_w: int, - input_h: int, - conf_th: float, - use_sigmoid: bool = True, - use_xy_sigmoid: bool = True, - score_mode: str = "obj_cls", - anchors_by_level: Optional[List[List[Tuple[float, float]]]] = None, -) -> List[Box]: - nodes = _pick_yolo_nodes(all_nodes, num_classes) - if not nodes: - raise RuntimeError("No YOLO-like [1,C,H,W] output nodes found") - anchors_levels = anchors_by_level or YOLO_DEFAULT_ANCHORS - - boxes: List[Box] = [] - attrs = 5 + num_classes - - for lv, o in enumerate(nodes): - _, ch, gh, gw = o["shape"] - na = int(ch // attrs) - data: np.ndarray = o["data"] - anchors = anchors_levels[min(lv, len(anchors_levels) - 1)] - - def at(channel_idx: int, y: int, x: int) -> float: - return float(data[channel_idx * gh * gw + y * gw + x]) - - for a in range(na): - aw, ah = anchors[min(a, len(anchors) - 1)] - base = a * attrs - for y in range(gh): - for x in range(gw): - tx = at(base + 0, y, x) - ty = at(base + 1, y, x) - tw = at(base + 2, y, x) - th = at(base + 3, y, x) - to = at(base + 4, y, x) - - obj = float(_sigmoid(to) if use_sigmoid else to) - best_cls = -1 - best_prob = -1e9 - for k in range(num_classes): - p = at(base + 5 + k, y, x) - p = float(_sigmoid(p) if use_sigmoid else p) - if p > best_prob: - best_prob = p - best_cls = k - - if score_mode == "obj": - score = obj - elif score_mode == "cls": - score = best_prob - else: - score = obj * best_prob - if score < conf_th: - continue - - if mode == "yolov5": - sx = input_w / gw - sy = input_h / gh - txv = float(_sigmoid(tx) if use_xy_sigmoid else tx) - tyv = float(_sigmoid(ty) if use_xy_sigmoid else ty) - bx = (txv * 2.0 - 0.5 + x) * sx - by = (tyv * 2.0 - 0.5 + y) * sy - bw = (float(_sigmoid(tw)) * 2.0) ** 2 * aw - bh = (float(_sigmoid(th)) * 2.0) ** 2 * ah - else: - txv = float(_sigmoid(tx) if use_xy_sigmoid else tx) - tyv = float(_sigmoid(ty) if use_xy_sigmoid else ty) - bx = (txv + x) / gw * input_w - by = (tyv + y) / gh * input_h - bw = aw * math.exp(tw) - bh = ah * math.exp(th) - - boxes.append( - Box( - cls=best_cls, - score=score, - x1=bx - bw / 2.0, - y1=by - bh / 2.0, - x2=bx + bw / 2.0, - y2=by + bh / 2.0, - ) - ) - return boxes - - -def _auto_fcos_indices(all_nodes: Sequence[Dict[str, Any]], num_classes: int) -> List[Tuple[int, int, int, int]]: - valid = [o for o in all_nodes if len(o["shape"]) == 4 and o["shape"][0] == 1] - cls_nodes = [o for o in valid if int(o["shape"][1]) == num_classes] - reg_nodes = [o for o in valid if int(o["shape"][1]) == 4] - ctr_nodes = [o for o in valid if int(o["shape"][1]) == 1] - - by_hw: Dict[Tuple[int, int], Dict[str, Dict[str, Any]]] = {} - for n in cls_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["cls"] = n - for n in reg_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["reg"] = n - for n in ctr_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["ctr"] = n - - levels: List[Tuple[int, int, int, int]] = [] - for (h, _w), items in by_hw.items(): - if not {"cls", "reg", "ctr"}.issubset(items.keys()): - continue - levels.append( - ( - h, - int(items["cls"]["node_idx"]), - int(items["reg"]["node_idx"]), - int(items["ctr"]["node_idx"]), - ) - ) - levels.sort(key=lambda x: x[0], reverse=True) - strides = [8, 16, 32, 64, 128] - return [ - (cls_i, reg_i, ctr_i, strides[min(i, len(strides) - 1)]) - for i, (_h, cls_i, reg_i, ctr_i) in enumerate(levels) - ] - - -def decode_fcos( - all_nodes: Sequence[Dict[str, Any]], - num_classes: int, - input_w: int, - input_h: int, - conf_th: float, - use_sigmoid: bool = True, - score_mode: str = "obj_cls", -) -> List[Box]: - levels = _auto_fcos_indices(all_nodes, num_classes) - if not levels: - raise RuntimeError("Cannot auto match FCOS cls/reg/ctr nodes") - - boxes: List[Box] = [] - by_idx = {int(n["node_idx"]): n for n in all_nodes} - - for cls_idx, reg_idx, ctr_idx, stride in levels: - cls_node = by_idx.get(cls_idx) - reg_node = by_idx.get(reg_idx) - ctr_node = by_idx.get(ctr_idx) - if not cls_node or not reg_node or not ctr_node: - continue - - gh = int(cls_node["shape"][2]) - gw = int(cls_node["shape"][3]) - cls_data: np.ndarray = cls_node["data"] - reg_data: np.ndarray = reg_node["data"] - ctr_data: np.ndarray = ctr_node["data"] - - def at(node_data: np.ndarray, channel_idx: int, y: int, x: int) -> float: - return float(node_data[channel_idx * gh * gw + y * gw + x]) - - cls_channels = int(cls_node["shape"][1]) - for y in range(gh): - for x in range(gw): - ctr = at(ctr_data, 0, y, x) - ctr = float(_sigmoid(ctr) if use_sigmoid else ctr) - - best_cls = -1 - best_prob = -1e9 - for k in range(min(num_classes, cls_channels)): - p = at(cls_data, k, y, x) - p = float(_sigmoid(p) if use_sigmoid else p) - if p > best_prob: - best_prob = p - best_cls = k - - if score_mode == "obj": - score = ctr - elif score_mode == "cls": - score = best_prob - else: - score = math.sqrt(max(0.0, best_prob * ctr)) - if score < conf_th: - continue - - l = max(0.0, at(reg_data, 0, y, x)) - t = max(0.0, at(reg_data, 1, y, x)) - r = max(0.0, at(reg_data, 2, y, x)) - b = max(0.0, at(reg_data, 3, y, x)) - cx = (x + 0.5) * stride - cy = (y + 0.5) * stride - - x1 = max(0.0, min(input_w, cx - l)) - y1 = max(0.0, min(input_h, cy - t)) - x2 = max(0.0, min(input_w, cx + r)) - y2 = max(0.0, min(input_h, cy + b)) - if x2 <= x1 or y2 <= y1: - continue - boxes.append(Box(cls=best_cls, score=score, x1=x1, y1=y1, x2=x2, y2=y2)) - return boxes - - -def _iou(a: Box, b: Box) -> float: - xx1 = max(a.x1, b.x1) - yy1 = max(a.y1, b.y1) - xx2 = min(a.x2, b.x2) - yy2 = min(a.y2, b.y2) - w = max(0.0, xx2 - xx1) - h = max(0.0, yy2 - yy1) - inter = w * h - if inter <= 0: - return 0.0 - area_a = max(0.0, a.x2 - a.x1) * max(0.0, a.y2 - a.y1) - area_b = max(0.0, b.x2 - b.x1) * max(0.0, b.y2 - b.y1) - return inter / max(1e-9, area_a + area_b - inter) - - -def nms(boxes: Sequence[Box], iou_th: float, max_out: int) -> List[Box]: - by_cls: Dict[int, List[Box]] = {} - for b in boxes: - by_cls.setdefault(b.cls, []).append(b) - - kept: List[Box] = [] - for cls_boxes in by_cls.values(): - cls_boxes = sorted(cls_boxes, key=lambda b: b.score, reverse=True) - picked: List[Box] = [] - while cls_boxes: - cur = cls_boxes.pop(0) - picked.append(cur) - cls_boxes = [b for b in cls_boxes if _iou(cur, b) <= iou_th] - kept.extend(picked) - - kept.sort(key=lambda b: b.score, reverse=True) - return kept[:max_out] diff --git a/local_service_win/LocalAPI/win_driver/amd64/WdfCoInstaller01011.dll b/local_service_win/LocalAPI/win_driver/amd64/WdfCoInstaller01011.dll deleted file mode 100644 index d49d291..0000000 Binary files a/local_service_win/LocalAPI/win_driver/amd64/WdfCoInstaller01011.dll and /dev/null differ diff --git a/local_service_win/LocalAPI/win_driver/amd64/winusbcoinstaller2.dll b/local_service_win/LocalAPI/win_driver/amd64/winusbcoinstaller2.dll deleted file mode 100644 index 30e5502..0000000 Binary files a/local_service_win/LocalAPI/win_driver/amd64/winusbcoinstaller2.dll and /dev/null differ diff --git a/local_service_win/LocalAPI/win_driver/installer_x64.exe b/local_service_win/LocalAPI/win_driver/installer_x64.exe deleted file mode 100644 index 0373edc..0000000 Binary files a/local_service_win/LocalAPI/win_driver/installer_x64.exe and /dev/null differ diff --git a/local_service_win/LocalAPI/win_driver/kneron_kl520.inf b/local_service_win/LocalAPI/win_driver/kneron_kl520.inf deleted file mode 100644 index 38deac1..0000000 Binary files a/local_service_win/LocalAPI/win_driver/kneron_kl520.inf and /dev/null differ diff --git a/local_service_win/STRATEGY.md b/local_service_win/STRATEGY.md deleted file mode 100644 index 43881a1..0000000 --- a/local_service_win/STRATEGY.md +++ /dev/null @@ -1,544 +0,0 @@ -# Kneron Dongle PoC (Windows) - Strategy - -## Scope (PoC) -- OS: Windows only. -- Devices: KL520, KL720. -- Control path: Browser -> localhost HTTP service -> KneronPLUS (kp wrapper + DLL). -- Non-goals: macOS/Linux support, production hardening, installer automation for all platforms. - -## Required Installation (Windows) -Before running the local service, install Python dependencies and the KneronPLUS wheel. - -### 1. Install dependencies from requirements -```powershell -cd local_service_win -python -m pip install -r requirements.txt -``` - -### 2. Install KneronPLUS wheel -```powershell -cd local_service_win -python -m pip install .\KneronPLUS-3.1.2-py3-none-any.whl -``` - -### 3. (Optional) Force reinstall KneronPLUS wheel -Use this when switching versions or seeing package mismatch issues. -```powershell -cd local_service_win -python -m pip install --force-reinstall .\KneronPLUS-3.1.2-py3-none-any.whl -``` - -## Dependency Strategy -- Open-source packages installed by pip: - - `fastapi`, `uvicorn`, `numpy`, `PyQt5`, `opencv-python`, `pyinstaller`, `pyarmor` -- Non-pip dependency: - - `KneronPLUS` (installed from local wheel) -- Bundled runtime (not pip): - - `third_party/Kneron_DFUT` is copied into this repo and used by LocalAPI to recover old firmware in one tool. - - `third_party/kneron_plus_1_2_1/dist` is extracted from `KneronPLUS-1.2.1` wheel and used by a subprocess runner for old-device firmware update experiments. - -## Cross-Project Workflow -This repo is the main PoC implementation. If additional references are required, we can switch to -other repos during the same conversation and return here as needed. This is workable. - -## High-Level Architecture -- Browser UI - - Talks to localhost HTTP service for control APIs. - - Uses WebSocket for streaming inference. - - No direct USB access from browser. -- Local Service (Windows) - - Owns Kneron device lifecycle and IO. - - Uses Python `kp` high-level API (backed by `libkplus.dll`). - - Exposes HTTP endpoints for scan/connect/model/firmware/inference. -- KneronPLUS Runtime - - `kp` Python wrapper + DLLs + required USB driver. - - Version pinned inside installer to avoid mismatches. - -## API Spec (PoC) -### Conventions -- Base URL: `http://127.0.0.1:4398` -- WebSocket URL: `ws://127.0.0.1:4398/ws` -- Response envelope: - ```json - { - "ok": true, - "data": {}, - "error": null - } - ``` - ```json - { - "ok": false, - "data": null, - "error": { "code": "KP_ERROR_CONNECT_FAILED", "message": "..." } - } - ``` - -### `GET /health` -Response -```json -{ "ok": true, "data": { "status": "up" }, "error": null } -``` - -### `GET /version` -Response -```json -{ - "ok": true, - "data": { - "service_version": "0.1.0", - "kneronplus_version": "3.0.0" - }, - "error": null -} -``` - -### `GET /devices` -Response -```json -{ - "ok": true, - "data": { - "devices": [ - { - "scan_index": 0, - "usb_port_id": 32, - "product_id": 0x520, - "link_speed": "High-Speed", - "usb_port_path": "1-3", - "kn_number": 12345, - "is_connectable": true, - "firmware": "KDP2" - } - ] - }, - "error": null -} -``` - -### `POST /devices/connect` -Request -```json -{ "port_id": 32 } -``` -Response -```json -{ - "ok": true, - "data": { - "connected": true, - "port_id": 32 - }, - "error": null -} -``` - -### `POST /devices/connect_force` -Notes -- Force connection without firmware validation. -- Use this when firmware is incompatible and you need to call `/firmware/load` first. -Request -```json -{ "port_id": 32 } -``` -Response -```json -{ - "ok": true, - "data": { - "connected": true, - "port_id": 32, - "forced": true - }, - "error": null -} -``` - -### `POST /devices/disconnect` -Response -```json -{ "ok": true, "data": { "connected": false }, "error": null } -``` - -### `GET /driver/check` -Notes -- Query currently connected Kneron USB devices from Windows PnP. -- Reports whether each entry is bound to WinUSB. -Response -```json -{ - "ok": true, - "data": { - "entries": [ - { - "pnp_device_id": "USB\\VID_3231&PID_0720\\...", - "service": "WinUSB", - "pid_hex": "0x0720", - "product_name": "KL720", - "is_winusb": true - } - ], - "all_connected_kneron_are_winusb": true - }, - "error": null -} -``` - -### `POST /driver/install` -Notes -- Install/replace driver using `kp.core.install_driver_for_windows`. -- Requires Administrator privilege on Windows. -Request -```json -{ "target": "KL720", "force": false } -``` - -### `POST /driver/ensure` -Notes -- Check connected device driver binding, auto install if not WinUSB (or `force=true`). -- `target`: `ALL` | `KL520` | `KL720` | `KL630` | `KL730` | `KL830` -Request -```json -{ "target": "ALL", "force": false } -``` - -### `POST /firmware/load` -Request -```json -{ - "scpu_path": "C:\\path\\fw_scpu.bin", - "ncpu_path": "C:\\path\\fw_ncpu.bin" -} -``` -Response -```json -{ "ok": true, "data": { "loaded": true }, "error": null } -``` - -### `POST /firmware/legacy-plus121/load` -Notes -- Experimental endpoint for old hardware/firmware path. -- Independent route from DFUT. -- Runs a subprocess with bundled `KneronPLUS 1.2.1` package and calls `libkplus.dll` directly by `ctypes`. -- Single-endpoint auto flow: - - Scan target device firmware state. - - If firmware is `KDP`: first call loader (`fw_loader.bin`) to switch USB-boot, then call `kp_load_firmware_from_file(scpu,ncpu)`. - - If firmware is not `KDP`: call `kp_load_firmware_from_file(scpu,ncpu)` directly. - - Finally disconnect. -- Diagnostics include selected port, detected firmware, scan snapshot, and firmware file metadata (path/size/sha256). -Request -```json -{ - "port_id": 32, - "loader_path": "C:\\path\\fw_loader.bin", - "scpu_path": "C:\\path\\fw_scpu.bin", - "ncpu_path": "C:\\path\\fw_ncpu.bin" -} -``` -Response -```json -{ - "ok": true, - "data": { - "loaded": true, - "legacy_plus_version": "1.2.1" - }, - "error": null -} -``` - -### `POST /firmware/legacy-upgrade/kl520` -Notes -- Used for old KL520 firmware recovery path. -- Runs bundled DFUT console with `--kl520-update`. -Request -```json -{ "port_id": 32 } -``` -Response -```json -{ - "ok": true, - "data": { - "upgraded": true, - "target": "KL520", - "port_id": 32 - }, - "error": null -} -``` - -### `POST /firmware/legacy-upgrade/kl720` -Notes -- Used for old KL720 / KL720 legacy recovery path. -- Runs bundled DFUT console with `--kl720-update`. -Request -```json -{ "port_id": 32 } -``` -Response -```json -{ - "ok": true, - "data": { - "upgraded": true, - "target": "KL720", - "port_id": 32 - }, - "error": null -} -``` - -### `POST /models/load` -Request -```json -{ "nef_path": "C:\\path\\model.nef" } -``` -Response -```json -{ - "ok": true, - "data": { - "model_id": 1, - "input_tensor_count": 1, - "output_tensor_count": 1 - }, - "error": null -} -``` - -### `POST /models/clear` -Notes -- PoC uses device reset to clear RAM model. -Response -```json -{ "ok": true, "data": { "cleared": true }, "error": null } -``` - -### `POST /models/reset` -Notes -- Alias of `/models/clear`, uses device reset to clear RAM model. -Response -```json -{ "ok": true, "data": { "reset": true }, "error": null } -``` - -### `POST /inference/run` -Request (image inference, single image) -```json -{ - "model_id": 1, - "image_format": "RGB888", - "width": 224, - "height": 224, - "image_base64": "..." -} -``` -Response -```json -{ - "ok": true, - "data": { - "outputs": [ - { "node_idx": 0, "dtype": "float", "shape": [1, 1000], "data_base64": "..." } - ] - }, - "error": null -} -``` - -### `POST /inference/run_video` -Notes -- Video file upload endpoint for continuous inference in PoC. -- Response is NDJSON stream (`application/x-ndjson`), one JSON object per processed frame. -- ByteTrack-specific tracking output is out of scope for current PoC; this endpoint returns raw model outputs per frame. -Request (`multipart/form-data`) -- `file`: video file (`.mp4/.avi/...`) -- `model_id`: integer -- `image_format`: `RGB565` | `RGBA8888` | `RAW8` -- `channels_ordering`: optional, default `DEFAULT` -- `output_dtype`: optional, default `float32` -- `sample_every_n`: optional, default `1` -- `max_frames`: optional - -Response line example (NDJSON) -```json -{ - "ok": true, - "data": { - "frame_index": 0, - "width": 640, - "height": 640, - "outputs": [ - { "node_idx": 0, "dtype": "float32", "shape": [1, 255, 80, 80], "data_base64": "..." } - ] - }, - "error": null -} -``` - -### `GET /tools/video-inference` -Notes -- Serves a single-page visual test tool from LocalAPI. -- Supports two input sources: - - Video file - - Webcam (browser `getUserMedia`) -- Frontend calls `POST /inference/run` frame-by-frame and draws decoded boxes on canvas. -- Purpose: PoC visual validation for YOLOv5/FCOS/TinyYOLO style models. -- ByteTrack visualization/tracking is intentionally excluded in current phase. - -### `WS /ws` (streaming inference) -Notes -- For camera/video stream, use WebSocket for low-latency send/receive. -- HTTP endpoints remain for control operations during PoC. -Message (client -> server) -```json -{ - "type": "inference_frame", - "model_id": 1, - "image_format": "RGB888", - "width": 224, - "height": 224, - "image_base64": "..." -} -``` -Message (server -> client) -```json -{ - "type": "inference_result", - "outputs": [ - { "node_idx": 0, "dtype": "float", "shape": [1, 1000], "data_base64": "..." } - ] -} -``` - -### `POST /firmware/update` -- Reserved for flash update (later; may need C wrapper). - -## Packaging (PoC) -- Single Windows installer: - - Includes driver, `kp` wrapper, DLLs, and service. - - Ensures fixed versions (no external Kneron tools required). - - Reference from `C:\Users\user\Documents\KNEOX\README.md`: - - Install KneronPLUS wheel from `external/kneron_plus_{version}/package/{platform}/` - - `pip install KneronPLUS-{version}-py3-none-any.whl` (use `--force-reinstall` if needed) - - PyInstaller must bundle `kp\lib` with the app. - - Example: - ```shell - pyinstaller --onefile --windowed main.py --additional-hooks-dir=hooks --add-data "uxui;uxui" --add-data "src;src" --add-data "C:\path\to\venv\Lib\site-packages\kp\lib;kp\lib" - ``` - -## Risks / Constraints -- Flash model update / flash firmware update may not be exposed in Python. - - Use C library or request Kneron to expose in wrapper if required. -- Browser security model prevents direct USB access; local service is required. -- Driver install/update on Windows may require Administrator privileges (`install_driver_for_windows` can fail without elevation). - - MEMO: define production approach for privilege handling (installer-time elevation, helper process with UAC prompt, or enterprise pre-install policy) so end-user flow does not get blocked. - -## API Test Progress (Windows PoC) -Updated: 2026-03-04 - -### Completed -- `GET /health` -- `GET /version` -- `GET /devices` -- `POST /devices/connect` -- `POST /devices/connect_force` -- `POST /devices/disconnect` -- `GET /driver/check` -- `POST /driver/install` -- `POST /driver/ensure` -- `POST /firmware/load` -- `POST /firmware/legacy-plus121/load` -- `POST /firmware/legacy-upgrade/kl520` -- `POST /firmware/legacy-downgrade/kl520` -- `POST /firmware/legacy-upgrade/kl720` -- `POST /models/load` -- `POST /models/clear` -- `POST /models/reset` -- `POST /inference/run` -- `POST /inference/run_video` -- `GET /tools/video-inference` - -### Pending -- None (for currently implemented HTTP endpoints). - -### Not Implemented Yet (API spec) -- `WS /ws` -- `POST /firmware/update` - -### Paired Test Requirement -- `POST /models/load` and `POST /inference/run` must be tested as a pair in the same flow. -- Test pairs are defined in `local_service_win/TestRes/TEST_PAIRS.md`. - -### Video/Webcam PoC Test Flow -1. Start LocalAPI service. -2. Connect device and load model: - - `POST /devices/connect` - - `POST /models/load` -3. Visual tool path: - - Open `http://127.0.0.1:4398/tools/video-inference` - - Select source (`Video File` or `Webcam`) - - Use default model presets (YOLOv5=20005, FCOS=20004, TinyYOLO=19), then click `Start` -4. API-only path: - - Use `POST /inference/run_video` with `multipart/form-data` - - Start with small values: `sample_every_n=3`, `max_frames=30` -5. Expected: - - Continuous frame-wise inference results are returned. - - Visual page overlays detection boxes on displayed frames. -6. Current scope note: - - ByteTrack tracking output (`track_id` continuity) is not covered in this PoC phase. - -### Model/Inference Test Pairs -#### KL520 -1. YOLOv5 (model zoo) - - Model: `kl520_20005_yolov5-noupsample_w640h640.nef` - - Image: `one_bike_many_cars_800x800` (Base64) -2. FCOS (model zoo) - - Model: `kl520_20004_fcos-drk53s_w512h512.nef` - - Image: `one_bike_many_cars_800x800` (Base64) -3. Tiny YOLO v3 (generic demo) - - Model: `models_520.nef` - - Image: `bike_cars_street_224x224` (Base64) -4. Tiny YOLO v3 (multithread demo) - - Model: `models_520.nef` - - Image: `bike_cars_street_224x224` (Base64) - -#### KL720 -1. YOLOv5 (model zoo) - - Model: `kl720_20005_yolov5-noupsample_w640h640.nef` - - Image: `one_bike_many_cars_800x800` (Base64) -2. FCOS (model zoo) - - Model: `kl720_20004_fcos-drk53s_w512h512.nef` - - Image: `one_bike_many_cars_800x800` (Base64) - -## Next Steps (After Strategy) -- Confirm endpoint payloads (JSON schema). -- Decide service framework (FastAPI/Flask). -- Define error model and device state machine. -- Plan installer workflow (driver + service). - -## Legacy Firmware Story And Recommended Handling -- Background: - - Many shipped devices are still on old KDP firmware or KL720 legacy states. - - In that state, `kp.core.connect_devices` and even `connect_devices_without_check` may still return `KP_ERROR_INVALID_FIRMWARE_24`. -- Goal: - - Keep user operations in one tool without requiring a separate DFUT install flow. -- Recommended handling: - 1. User scans devices via `GET /devices`. - 2. If normal connect fails with `KP_ERROR_INVALID_FIRMWARE_24`, call: - - `POST /firmware/legacy-upgrade/kl520` or - - `POST /firmware/legacy-upgrade/kl720` - 3. Re-scan and reconnect using `POST /devices/connect`. - 4. Continue with `POST /firmware/load` (if needed), `POST /models/load`, and inference. -- Experimental alternative: - - If DFUT route is blocked on specific old-device states, test `POST /firmware/legacy-plus121/load` as an independent non-DFUT legacy SDK compatibility path. -- Notes: - - Recovery endpoints use bundled `third_party/Kneron_DFUT/bin/KneronDFUT.exe`. - - This keeps firmware recovery and inference service in the same product boundary. - -## Validation Memo (Next) -- Record and verify on newer KneronPLUS versions: - - For KL520 old KDP state, `loader -> load_firmware_from_file(scpu,ncpu)` sequence works in legacy-plus121 path. - - Hypothesis: the same sequence may also work on newer PLUS runtime. - - Action later: add an explicit validation task on current PLUS branch and capture pass/fail matrix by device FW state. diff --git a/local_service_win/TestRes/Images/BmpToRGB565.html b/local_service_win/TestRes/Images/BmpToRGB565.html deleted file mode 100644 index fd0a45f..0000000 --- a/local_service_win/TestRes/Images/BmpToRGB565.html +++ /dev/null @@ -1,271 +0,0 @@ - - - - - - BMP to RGB565 - - - -
-

BMP to RGB565 (Raw)

-

- Select a BMP file, convert pixels to RGB565 raw bytes (little-endian), then copy Base64 for - /inference/run. -

- -
- - - - - - - - - -
- - Preview will appear here -
No file loaded.
- -
- - -
- -
- - - - -
- -
- - -
-
- - - - diff --git a/local_service_win/TestRes/Images/MOT16-03_trim.mp4 b/local_service_win/TestRes/Images/MOT16-03_trim.mp4 deleted file mode 100644 index 6124631..0000000 Binary files a/local_service_win/TestRes/Images/MOT16-03_trim.mp4 and /dev/null differ diff --git a/local_service_win/TestRes/Images/PayloadDetectionView.html b/local_service_win/TestRes/Images/PayloadDetectionView.html deleted file mode 100644 index caf0f0f..0000000 --- a/local_service_win/TestRes/Images/PayloadDetectionView.html +++ /dev/null @@ -1,955 +0,0 @@ - - - - - - Payload Detection Viewer (YOLO/TinyYOLO/FCOS) - - - -
-

Payload Detection Viewer

-

- 專為 POC:手動選擇模型 (YOLOv5 / TinyYOLOv3 / FCOS) 後處理,將 payload 推論結果畫成框 + 類別 + 分數。 -

- -
-
- - - - - -
- -
- - - -
-
- - - - - - - - - - - - - - -
- -
- - - - - - - - - -
- -
- - - - - - - - -
- -
- - - - - - - - - - -
- -
- - - - -
-
- -
-
-
Detection Overlay
- -
-
-
-
Top Boxes
-
- - - - - - - -
#clsscorex1y1x2y2
-
-
-
-
-
-
- - - - diff --git a/local_service_win/TestRes/Images/PayloadTensorView.html b/local_service_win/TestRes/Images/PayloadTensorView.html deleted file mode 100644 index f91959f..0000000 --- a/local_service_win/TestRes/Images/PayloadTensorView.html +++ /dev/null @@ -1,624 +0,0 @@ - - - - - - Payload Tensor Viewer - - - -
-

Payload Tensor Viewer

-

- Paste full JSON payload and click Parse & Render. Supports base64 float32 tensors in NCHW shape (e.g. [1,255,7,7]). -

- - -
- - -
- - - -
-

Overlay Viewer

-

- Upload original image, pick output/channel, then overlay activation heatmap on top. -

- -
- - - -
- -
- - - - - - - - - - - 0.45 - -
- -
-
-
Overlay
- -
-
-
-
Heatmap Only
- -
-
-
-
- -
-
- - - - diff --git a/local_service_win/TestRes/Images/Pic64View.html b/local_service_win/TestRes/Images/Pic64View.html deleted file mode 100644 index a972019..0000000 --- a/local_service_win/TestRes/Images/Pic64View.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - Pic64View - - - -
-

Pic64View

-

- Paste a Base64 image string (with or without data URL prefix) and click "Render". -

- -
- - -
- Preview will appear here -
- - - - diff --git a/local_service_win/TestRes/Images/VideoInferenceWeb.html b/local_service_win/TestRes/Images/VideoInferenceWeb.html deleted file mode 100644 index b05a64d..0000000 --- a/local_service_win/TestRes/Images/VideoInferenceWeb.html +++ /dev/null @@ -1,627 +0,0 @@ - - - - - - Video Inference Viewer - - - -
-
-

Video Inference (API)

-
-
- - -
-
- - -
- -
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
Ready.
-
預設值可直接測 YOLOv5。先確認 LocalAPI 已啟動,並完成 connect + load model。
-
- -
-
- -
- - -
-
- - - - diff --git a/local_service_win/TestRes/Images/bike_cars_street_224x224.bmp b/local_service_win/TestRes/Images/bike_cars_street_224x224.bmp deleted file mode 100644 index 987b217..0000000 Binary files a/local_service_win/TestRes/Images/bike_cars_street_224x224.bmp and /dev/null differ diff --git a/local_service_win/TestRes/Images/bike_cars_street_224x224.html b/local_service_win/TestRes/Images/bike_cars_street_224x224.html deleted file mode 100644 index a9e9c3e..0000000 --- a/local_service_win/TestRes/Images/bike_cars_street_224x224.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/local_service_win/TestRes/Images/one_bike_many_cars_800x800.bmp b/local_service_win/TestRes/Images/one_bike_many_cars_800x800.bmp deleted file mode 100644 index 2893d85..0000000 Binary files a/local_service_win/TestRes/Images/one_bike_many_cars_800x800.bmp and /dev/null differ diff --git a/local_service_win/TestRes/Images/one_bike_many_cars_800x800.html b/local_service_win/TestRes/Images/one_bike_many_cars_800x800.html deleted file mode 100644 index ff12d84..0000000 --- a/local_service_win/TestRes/Images/one_bike_many_cars_800x800.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/local_service_win/TestRes/Models/models_520.nef b/local_service_win/TestRes/Models/models_520.nef deleted file mode 100644 index adefdaa..0000000 Binary files a/local_service_win/TestRes/Models/models_520.nef and /dev/null differ diff --git a/local_service_win/TestRes/TEST_PAIRS.md b/local_service_win/TestRes/TEST_PAIRS.md deleted file mode 100644 index 62cebdb..0000000 --- a/local_service_win/TestRes/TEST_PAIRS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Model/Image Test Pairs (from kneron_plus examples) - -## KL520 -- YOLOv5 (model zoo) - - Model: `res/models/KL520/yolov5-noupsample_w640h640_kn-model-zoo/kl520_20005_yolov5-noupsample_w640h640.nef` - - Image: `res/images/one_bike_many_cars_800x800.bmp` - - Source: `examples_model_zoo/kl520_kn-model-zoo_generic_inference_post_yolov5/kl520_kn-model-zoo_generic_inference_post_yolov5.c` -- FCOS (model zoo) - - Model: `res/models/KL520/fcos-drk53s_w512h512_kn-model-zoo/kl520_20004_fcos-drk53s_w512h512.nef` - - Image: `res/images/one_bike_many_cars_800x800.bmp` - - Source: `examples_model_zoo/kl520_kn-model-zoo_generic_inference_post_fcos/kl520_kn-model-zoo_generic_inference_post_fcos.c` -- Tiny YOLO v3 (generic demo) - - Model: `res/models/KL520/tiny_yolo_v3/models_520.nef` - - Image: `res/images/bike_cars_street_224x224.bmp` - - Source: `examples/kl520_demo_app_yolo_inference/kl520_demo_app_yolo_inference.c` -- Tiny YOLO v3 (multithread demo) - - Model: `res/models/KL520/tiny_yolo_v3/models_520.nef` - - Image: `res/images/bike_cars_street_224x224.bmp` - - Source: `examples/kl520_demo_app_yolo_inference_multithread/kl520_demo_app_yolo_inference_multithread.c` - -## KL720 -- YOLOv5 (model zoo) - - Model: `res/models/KL720/yolov5-noupsample_w640h640_kn-model-zoo/kl720_20005_yolov5-noupsample_w640h640.nef` - - Image: `res/images/one_bike_many_cars_800x800.bmp` - - Source: `examples_model_zoo/kl720_kn-model-zoo_generic_inference_post_yolov5/kl720_kn-model-zoo_generic_inference_post_yolov5.c` -- FCOS (model zoo) - - Model: `res/models/KL720/fcos-drk53s_w512h512_kn-model-zoo/kl720_20004_fcos-drk53s_w512h512.nef` - - Image: `res/images/one_bike_many_cars_800x800.bmp` - - Source: `examples_model_zoo/kl720_kn-model-zoo_generic_inference_post_fcos/kl720_kn-model-zoo_generic_inference_post_fcos.c` diff --git a/local_service_win/TestRes/video_inference_viewer.py b/local_service_win/TestRes/video_inference_viewer.py deleted file mode 100644 index ae714ee..0000000 --- a/local_service_win/TestRes/video_inference_viewer.py +++ /dev/null @@ -1,514 +0,0 @@ -from __future__ import annotations - -import argparse -import base64 -import json -import math -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple - -import cv2 -import numpy as np - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from LocalAPI import postprocess_core as core - - -YOLO_DEFAULT_ANCHORS: List[List[Tuple[float, float]]] = [ - [(10.0, 14.0), (23.0, 27.0), (37.0, 58.0)], - [(81.0, 82.0), (135.0, 169.0), (344.0, 319.0)], -] - - -def _sigmoid(v: np.ndarray | float) -> np.ndarray | float: - return 1.0 / (1.0 + np.exp(-v)) - - -def _encode_frame(frame_bgr: np.ndarray, image_format: str) -> bytes: - fmt = image_format.upper() - if fmt == "RGBA8888": - rgba = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA) - return rgba.tobytes() - if fmt == "RAW8": - gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY) - return gray.tobytes() - if fmt == "RGB565": - bgr565 = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2BGR565) - return bgr565.tobytes() - raise ValueError(f"Unsupported image_format: {image_format}") - - -def _call_inference_run( - base_url: str, - model_id: int, - image_format: str, - width: int, - height: int, - image_bytes: bytes, - channels_ordering: str = "DEFAULT", - output_dtype: str = "float32", - timeout_sec: float = 20.0, -) -> Dict[str, Any]: - body = { - "model_id": model_id, - "image_format": image_format, - "width": width, - "height": height, - "image_base64": base64.b64encode(image_bytes).decode("ascii"), - "channels_ordering": channels_ordering, - "output_dtype": output_dtype, - } - req = urllib.request.Request( - url=f"{base_url.rstrip('/')}/inference/run", - data=json.dumps(body).encode("utf-8"), - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout_sec) as resp: - content = resp.read().decode("utf-8", errors="replace") - except urllib.error.HTTPError as exc: - msg = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"HTTP {exc.code}: {msg}") from exc - except urllib.error.URLError as exc: - raise RuntimeError(f"Request failed: {exc}") from exc - - parsed = json.loads(content) - if not parsed.get("ok"): - raise RuntimeError(json.dumps(parsed.get("error"), ensure_ascii=False)) - return parsed["data"] - - -def _decode_outputs(raw_outputs: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: - decoded: List[Dict[str, Any]] = [] - for idx, o in enumerate(raw_outputs): - shape = list(o.get("shape") or []) - data_b64 = str(o.get("data_base64") or "") - raw = base64.b64decode(data_b64) - arr = np.frombuffer(raw, dtype=" List[Dict[str, Any]]: - picked: List[Dict[str, Any]] = [] - for o in all_nodes: - shape = o["shape"] - if len(shape) != 4 or shape[0] != 1: - continue - ch = int(shape[1]) - if ch % (5 + num_classes) != 0: - continue - picked.append(o) - picked.sort(key=lambda n: int(n["shape"][2]), reverse=True) - return picked - - -def _decode_yolo_common( - all_nodes: Sequence[Dict[str, Any]], - mode: str, - num_classes: int, - input_w: int, - input_h: int, - conf_th: float, - use_sigmoid: bool = True, - use_xy_sigmoid: bool = True, - score_mode: str = "obj_cls", - anchors_by_level: Optional[List[List[Tuple[float, float]]]] = None, -) -> List[Box]: - nodes = _pick_yolo_nodes(all_nodes, num_classes) - if not nodes: - raise RuntimeError("No YOLO-like [1,C,H,W] output nodes found") - anchors_levels = anchors_by_level or YOLO_DEFAULT_ANCHORS - - boxes: List[Box] = [] - attrs = 5 + num_classes - - for lv, o in enumerate(nodes): - _, ch, gh, gw = o["shape"] - na = int(ch // attrs) - data: np.ndarray = o["data"] - anchors = anchors_levels[min(lv, len(anchors_levels) - 1)] - - def at(channel_idx: int, y: int, x: int) -> float: - return float(data[channel_idx * gh * gw + y * gw + x]) - - for a in range(na): - aw, ah = anchors[min(a, len(anchors) - 1)] - base = a * attrs - - for y in range(gh): - for x in range(gw): - tx = at(base + 0, y, x) - ty = at(base + 1, y, x) - tw = at(base + 2, y, x) - th = at(base + 3, y, x) - to = at(base + 4, y, x) - - obj = float(_sigmoid(to) if use_sigmoid else to) - best_cls = -1 - best_prob = -1e9 - for k in range(num_classes): - p = at(base + 5 + k, y, x) - p = float(_sigmoid(p) if use_sigmoid else p) - if p > best_prob: - best_prob = p - best_cls = k - - if score_mode == "obj": - score = obj - elif score_mode == "cls": - score = best_prob - else: - score = obj * best_prob - - if score < conf_th: - continue - - if mode == "yolov5": - sx = input_w / gw - sy = input_h / gh - txv = float(_sigmoid(tx) if use_xy_sigmoid else tx) - tyv = float(_sigmoid(ty) if use_xy_sigmoid else ty) - bx = (txv * 2.0 - 0.5 + x) * sx - by = (tyv * 2.0 - 0.5 + y) * sy - bw = (float(_sigmoid(tw)) * 2.0) ** 2 * aw - bh = (float(_sigmoid(th)) * 2.0) ** 2 * ah - else: - txv = float(_sigmoid(tx) if use_xy_sigmoid else tx) - tyv = float(_sigmoid(ty) if use_xy_sigmoid else ty) - bx = (txv + x) / gw * input_w - by = (tyv + y) / gh * input_h - bw = aw * math.exp(tw) - bh = ah * math.exp(th) - - boxes.append( - Box( - cls=best_cls, - score=score, - x1=bx - bw / 2.0, - y1=by - bh / 2.0, - x2=bx + bw / 2.0, - y2=by + bh / 2.0, - ) - ) - - return boxes - - -def _auto_fcos_indices(all_nodes: Sequence[Dict[str, Any]], num_classes: int) -> List[Tuple[int, int, int, int]]: - valid = [o for o in all_nodes if len(o["shape"]) == 4 and o["shape"][0] == 1] - cls_nodes = [o for o in valid if int(o["shape"][1]) == num_classes] - reg_nodes = [o for o in valid if int(o["shape"][1]) == 4] - ctr_nodes = [o for o in valid if int(o["shape"][1]) == 1] - - by_hw: Dict[Tuple[int, int], Dict[str, Dict[str, Any]]] = {} - for n in cls_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["cls"] = n - for n in reg_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["reg"] = n - for n in ctr_nodes: - by_hw.setdefault((int(n["shape"][2]), int(n["shape"][3])), {})["ctr"] = n - - levels: List[Tuple[int, int, int, int]] = [] - for (h, w), items in by_hw.items(): - if not {"cls", "reg", "ctr"}.issubset(items.keys()): - continue - levels.append( - ( - h, - int(items["cls"]["node_idx"]), - int(items["reg"]["node_idx"]), - int(items["ctr"]["node_idx"]), - ) - ) - levels.sort(key=lambda x: x[0], reverse=True) - strides = [8, 16, 32, 64, 128] - return [(cls_i, reg_i, ctr_i, strides[min(i, len(strides) - 1)]) for i, (_, cls_i, reg_i, ctr_i) in enumerate(levels)] - - -def _decode_fcos( - all_nodes: Sequence[Dict[str, Any]], - num_classes: int, - input_w: int, - input_h: int, - conf_th: float, - use_sigmoid: bool = True, - score_mode: str = "obj_cls", -) -> List[Box]: - levels = _auto_fcos_indices(all_nodes, num_classes) - if not levels: - raise RuntimeError("Cannot auto match FCOS cls/reg/ctr nodes") - - boxes: List[Box] = [] - by_idx = {int(n["node_idx"]): n for n in all_nodes} - - for cls_idx, reg_idx, ctr_idx, stride in levels: - cls_node = by_idx.get(cls_idx) - reg_node = by_idx.get(reg_idx) - ctr_node = by_idx.get(ctr_idx) - if not cls_node or not reg_node or not ctr_node: - continue - - gh = int(cls_node["shape"][2]) - gw = int(cls_node["shape"][3]) - cls_data: np.ndarray = cls_node["data"] - reg_data: np.ndarray = reg_node["data"] - ctr_data: np.ndarray = ctr_node["data"] - - def at(node_data: np.ndarray, channel_idx: int, y: int, x: int) -> float: - return float(node_data[channel_idx * gh * gw + y * gw + x]) - - cls_channels = int(cls_node["shape"][1]) - for y in range(gh): - for x in range(gw): - ctr = at(ctr_data, 0, y, x) - ctr = float(_sigmoid(ctr) if use_sigmoid else ctr) - - best_cls = -1 - best_prob = -1e9 - for k in range(min(num_classes, cls_channels)): - p = at(cls_data, k, y, x) - p = float(_sigmoid(p) if use_sigmoid else p) - if p > best_prob: - best_prob = p - best_cls = k - - if score_mode == "obj": - score = ctr - elif score_mode == "cls": - score = best_prob - else: - score = math.sqrt(max(0.0, best_prob * ctr)) - if score < conf_th: - continue - - l = max(0.0, at(reg_data, 0, y, x)) - t = max(0.0, at(reg_data, 1, y, x)) - r = max(0.0, at(reg_data, 2, y, x)) - b = max(0.0, at(reg_data, 3, y, x)) - cx = (x + 0.5) * stride - cy = (y + 0.5) * stride - - x1 = max(0.0, min(input_w, cx - l)) - y1 = max(0.0, min(input_h, cy - t)) - x2 = max(0.0, min(input_w, cx + r)) - y2 = max(0.0, min(input_h, cy + b)) - if x2 <= x1 or y2 <= y1: - continue - boxes.append(Box(cls=best_cls, score=score, x1=x1, y1=y1, x2=x2, y2=y2)) - - return boxes - - -def _iou(a: Box, b: Box) -> float: - xx1 = max(a.x1, b.x1) - yy1 = max(a.y1, b.y1) - xx2 = min(a.x2, b.x2) - yy2 = min(a.y2, b.y2) - w = max(0.0, xx2 - xx1) - h = max(0.0, yy2 - yy1) - inter = w * h - if inter <= 0: - return 0.0 - area_a = max(0.0, a.x2 - a.x1) * max(0.0, a.y2 - a.y1) - area_b = max(0.0, b.x2 - b.x1) * max(0.0, b.y2 - b.y1) - return inter / max(1e-9, area_a + area_b - inter) - - -def _nms(boxes: Sequence[Box], iou_th: float, max_out: int) -> List[Box]: - by_cls: Dict[int, List[Box]] = {} - for b in boxes: - by_cls.setdefault(b.cls, []).append(b) - - kept: List[Box] = [] - for cls_boxes in by_cls.values(): - cls_boxes = sorted(cls_boxes, key=lambda b: b.score, reverse=True) - picked: List[Box] = [] - while cls_boxes: - cur = cls_boxes.pop(0) - picked.append(cur) - cls_boxes = [b for b in cls_boxes if _iou(cur, b) <= iou_th] - kept.extend(picked) - - kept.sort(key=lambda b: b.score, reverse=True) - return kept[:max_out] - - -def _draw_boxes(frame: np.ndarray, boxes: Sequence[core.Box], input_w: int, input_h: int) -> np.ndarray: - out = frame.copy() - h, w = out.shape[:2] - sx = w / float(input_w) - sy = h / float(input_h) - - for b in boxes: - x1 = int(max(0, min(w - 1, round(b.x1 * sx)))) - y1 = int(max(0, min(h - 1, round(b.y1 * sy)))) - x2 = int(max(0, min(w - 1, round(b.x2 * sx)))) - y2 = int(max(0, min(h - 1, round(b.y2 * sy)))) - if x2 <= x1 or y2 <= y1: - continue - color = tuple(int(c) for c in cv2.cvtColor(np.uint8([[[b.cls * 47 % 180, 255, 220]]]), cv2.COLOR_HSV2BGR)[0][0]) - cv2.rectangle(out, (x1, y1), (x2, y2), color, 2) - text = f"{b.cls}:{b.score:.3f}" - cv2.putText(out, text, (x1, max(14, y1 - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 2, cv2.LINE_AA) - return out - - -def _pick_video_via_dialog() -> Optional[str]: - try: - import tkinter as tk - from tkinter import filedialog - except Exception: - return None - root = tk.Tk() - root.withdraw() - path = filedialog.askopenfilename( - title="Select video file", - filetypes=[("Video files", "*.mp4 *.avi *.mov *.mkv *.wmv"), ("All files", "*.*")], - ) - root.destroy() - return path or None - - -def _defaults_for_model(model_type: str) -> Tuple[int, int]: - mt = model_type.lower() - if mt == "fcos": - return 512, 512 - if mt == "tinyyolo": - return 224, 224 - return 640, 640 - - -def main() -> None: - parser = argparse.ArgumentParser(description="Video -> /inference/run -> draw detection boxes") - parser.add_argument("--base-url", default="http://127.0.0.1:4398") - parser.add_argument("--video", default="") - parser.add_argument("--model-id", type=int, required=True) - parser.add_argument("--model-type", choices=["yolov5", "fcos", "tinyyolo"], default="yolov5") - parser.add_argument("--input-width", type=int, default=0) - parser.add_argument("--input-height", type=int, default=0) - parser.add_argument("--image-format", default="RGBA8888") - parser.add_argument("--num-classes", type=int, default=80) - parser.add_argument("--score-th", type=float, default=0.25) - parser.add_argument("--iou-th", type=float, default=0.45) - parser.add_argument("--max-boxes", type=int, default=200) - parser.add_argument("--sample-every-n", type=int, default=3) - parser.add_argument("--save-output", default="") - args = parser.parse_args() - - video_path = args.video.strip() or _pick_video_via_dialog() - if not video_path: - raise SystemExit("No video selected") - if not Path(video_path).is_file(): - raise SystemExit(f"Video not found: {video_path}") - - default_w, default_h = _defaults_for_model(args.model_type) - in_w = int(args.input_width or default_w) - in_h = int(args.input_height or default_h) - - cap = cv2.VideoCapture(video_path) - if not cap.isOpened(): - raise SystemExit(f"Cannot open video: {video_path}") - - writer: Optional[cv2.VideoWriter] = None - if args.save_output: - fourcc = cv2.VideoWriter_fourcc(*"mp4v") - fps = float(cap.get(cv2.CAP_PROP_FPS) or 20.0) - frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or in_w) - frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or in_h) - writer = cv2.VideoWriter(args.save_output, fourcc, fps, (frame_w, frame_h)) - - print("Press 'q' to quit.") - frame_idx = -1 - infer_count = 0 - last_boxes: List[core.Box] = [] - t0 = time.time() - - try: - while True: - ok, frame = cap.read() - if not ok: - break - frame_idx += 1 - - if frame_idx % max(1, args.sample_every_n) == 0: - infer_count += 1 - resized = cv2.resize(frame, (in_w, in_h), interpolation=cv2.INTER_AREA) - image_bytes = _encode_frame(resized, args.image_format) - try: - result = _call_inference_run( - base_url=args.base_url, - model_id=args.model_id, - image_format=args.image_format, - width=in_w, - height=in_h, - image_bytes=image_bytes, - ) - raw_outputs = result.get("outputs") or [] - outputs = core.decode_outputs(raw_outputs) - if args.model_type == "fcos": - raw_boxes = core.decode_fcos( - outputs, - num_classes=args.num_classes, - input_w=in_w, - input_h=in_h, - conf_th=args.score_th, - ) - else: - raw_boxes = core.decode_yolo_common( - outputs, - mode="yolov5" if args.model_type == "yolov5" else "tinyyolo", - num_classes=args.num_classes, - input_w=in_w, - input_h=in_h, - conf_th=args.score_th, - ) - last_boxes = core.nms(raw_boxes, iou_th=args.iou_th, max_out=args.max_boxes) - except Exception as exc: - print(f"[frame {frame_idx}] inference failed: {exc}") - - vis = _draw_boxes(frame, last_boxes, in_w, in_h) - elapsed = max(1e-6, time.time() - t0) - api_fps = infer_count / elapsed - cv2.putText( - vis, - f"frame={frame_idx} infer={infer_count} api_fps={api_fps:.2f} boxes={len(last_boxes)}", - (10, 24), - cv2.FONT_HERSHEY_SIMPLEX, - 0.65, - (0, 255, 0), - 2, - cv2.LINE_AA, - ) - - cv2.imshow("Kneron Video Inference Viewer", vis) - if writer is not None: - writer.write(vis) - - key = cv2.waitKey(1) & 0xFF - if key == ord("q"): - break - finally: - cap.release() - if writer is not None: - writer.release() - cv2.destroyAllWindows() - - -if __name__ == "__main__": - main() diff --git a/local_service_win/firmware/KL520/VERSION b/local_service_win/firmware/KL520/VERSION deleted file mode 100644 index ccbccc3..0000000 --- a/local_service_win/firmware/KL520/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.2.0 diff --git a/local_service_win/firmware/KL520/dfw/minions.bin b/local_service_win/firmware/KL520/dfw/minions.bin deleted file mode 100644 index 7463ab8..0000000 Binary files a/local_service_win/firmware/KL520/dfw/minions.bin and /dev/null differ diff --git a/local_service_win/firmware/KL520/fw_loader.bin b/local_service_win/firmware/KL520/fw_loader.bin deleted file mode 100644 index 51ca844..0000000 Binary files a/local_service_win/firmware/KL520/fw_loader.bin and /dev/null differ diff --git a/local_service_win/firmware/KL520_kdp/fw_ncpu.bin b/local_service_win/firmware/KL520_kdp/fw_ncpu.bin deleted file mode 100644 index 0ed154f..0000000 Binary files a/local_service_win/firmware/KL520_kdp/fw_ncpu.bin and /dev/null differ diff --git a/local_service_win/firmware/KL520_kdp/fw_scpu.bin b/local_service_win/firmware/KL520_kdp/fw_scpu.bin deleted file mode 100644 index 5101c06..0000000 Binary files a/local_service_win/firmware/KL520_kdp/fw_scpu.bin and /dev/null differ diff --git a/local_service_win/firmware/KL630/VERSION b/local_service_win/firmware/KL630/VERSION deleted file mode 100644 index aefb325..0000000 --- a/local_service_win/firmware/KL630/VERSION +++ /dev/null @@ -1 +0,0 @@ -SDK-v2.5.7 diff --git a/local_service_win/firmware/KL630/kp_firmware.tar b/local_service_win/firmware/KL630/kp_firmware.tar deleted file mode 100644 index 822b319..0000000 Binary files a/local_service_win/firmware/KL630/kp_firmware.tar and /dev/null differ diff --git a/local_service_win/firmware/KL630/kp_loader.tar b/local_service_win/firmware/KL630/kp_loader.tar deleted file mode 100644 index 45f6cd4..0000000 Binary files a/local_service_win/firmware/KL630/kp_loader.tar and /dev/null differ diff --git a/local_service_win/firmware/KL720/VERSION b/local_service_win/firmware/KL720/VERSION deleted file mode 100644 index ccbccc3..0000000 --- a/local_service_win/firmware/KL720/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.2.0 diff --git a/local_service_win/firmware/KL730/VERSION b/local_service_win/firmware/KL730/VERSION deleted file mode 100644 index 5332ca0..0000000 --- a/local_service_win/firmware/KL730/VERSION +++ /dev/null @@ -1 +0,0 @@ -SDK-v1.3.0 diff --git a/local_service_win/firmware/KL730/kp_firmware.tar b/local_service_win/firmware/KL730/kp_firmware.tar deleted file mode 100644 index a688586..0000000 Binary files a/local_service_win/firmware/KL730/kp_firmware.tar and /dev/null differ diff --git a/local_service_win/firmware/KL730/kp_loader.tar b/local_service_win/firmware/KL730/kp_loader.tar deleted file mode 100644 index 3e799e2..0000000 Binary files a/local_service_win/firmware/KL730/kp_loader.tar and /dev/null differ diff --git a/local_service_win/requirements.txt b/local_service_win/requirements.txt deleted file mode 100644 index 55f981e..0000000 --- a/local_service_win/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Core SDK (installed via local wheel; see STRATEGY.md) -# KneronPLUS==3.0.0 - -# HTTP service -fastapi -uvicorn -python-multipart - -# Reference packages from C:\Users\user\Documents\KNEOX\README.md -PyQt5 -opencv-python -pyinstaller -pyarmor - -# Common dependency for kp data handling -numpy diff --git a/local_service_win/third_party/Kneron_DFUT/VERSION b/local_service_win/third_party/Kneron_DFUT/VERSION deleted file mode 100644 index 56fea8a..0000000 --- a/local_service_win/third_party/Kneron_DFUT/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.0.0 \ No newline at end of file diff --git a/local_service_win/third_party/Kneron_DFUT/bin/D3Dcompiler_47.dll b/local_service_win/third_party/Kneron_DFUT/bin/D3Dcompiler_47.dll deleted file mode 100644 index 56512f5..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/D3Dcompiler_47.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/KneronDFUT.exe b/local_service_win/third_party/Kneron_DFUT/bin/KneronDFUT.exe deleted file mode 100644 index d1a231d..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/KneronDFUT.exe and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Core.dll b/local_service_win/third_party/Kneron_DFUT/bin/Qt5Core.dll deleted file mode 100644 index 4e0ee9a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Core.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Gui.dll b/local_service_win/third_party/Kneron_DFUT/bin/Qt5Gui.dll deleted file mode 100644 index bba96c8..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Gui.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Svg.dll b/local_service_win/third_party/Kneron_DFUT/bin/Qt5Svg.dll deleted file mode 100644 index b006006..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Svg.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Widgets.dll b/local_service_win/third_party/Kneron_DFUT/bin/Qt5Widgets.dll deleted file mode 100644 index e6ff156..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/Qt5Widgets.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/iconengines/qsvgicon.dll b/local_service_win/third_party/Kneron_DFUT/bin/iconengines/qsvgicon.dll deleted file mode 100644 index b10761c..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/iconengines/qsvgicon.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qgif.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qgif.dll deleted file mode 100644 index 97beb57..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qgif.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qicns.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qicns.dll deleted file mode 100644 index a64309b..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qicns.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qico.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qico.dll deleted file mode 100644 index 5c744ae..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qico.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qjpeg.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qjpeg.dll deleted file mode 100644 index f2bb6be..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qjpeg.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qsvg.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qsvg.dll deleted file mode 100644 index 921f25f..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qsvg.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtga.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtga.dll deleted file mode 100644 index b68d35d..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtga.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtiff.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtiff.dll deleted file mode 100644 index 31a8ce3..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qtiff.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwbmp.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwbmp.dll deleted file mode 100644 index a6751ab..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwbmp.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwebp.dll b/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwebp.dll deleted file mode 100644 index 49862fa..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/imageformats/qwebp.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libEGL.dll b/local_service_win/third_party/Kneron_DFUT/bin/libEGL.dll deleted file mode 100644 index d3247d2..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libEGL.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libGLESV2.dll b/local_service_win/third_party/Kneron_DFUT/bin/libGLESV2.dll deleted file mode 100644 index 7825e28..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libGLESV2.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libgcc_s_seh-1.dll b/local_service_win/third_party/Kneron_DFUT/bin/libgcc_s_seh-1.dll deleted file mode 100644 index 4ec945b..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libgcc_s_seh-1.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libkplus.dll b/local_service_win/third_party/Kneron_DFUT/bin/libkplus.dll deleted file mode 100644 index 0a9ff70..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libkplus.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libstdc++-6.dll b/local_service_win/third_party/Kneron_DFUT/bin/libstdc++-6.dll deleted file mode 100644 index 8e55acc..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libstdc++-6.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libusb-1.0.dll b/local_service_win/third_party/Kneron_DFUT/bin/libusb-1.0.dll deleted file mode 100644 index bec7169..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libusb-1.0.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libwdi.dll b/local_service_win/third_party/Kneron_DFUT/bin/libwdi.dll deleted file mode 100644 index 354b339..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libwdi.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/libwinpthread-1.dll b/local_service_win/third_party/Kneron_DFUT/bin/libwinpthread-1.dll deleted file mode 100644 index d9f4e1a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/libwinpthread-1.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/opengl32sw.dll b/local_service_win/third_party/Kneron_DFUT/bin/opengl32sw.dll deleted file mode 100644 index 475e82a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/opengl32sw.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/platforms/qwindows.dll b/local_service_win/third_party/Kneron_DFUT/bin/platforms/qwindows.dll deleted file mode 100644 index f9874fc..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/platforms/qwindows.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/styles/qwindowsvistastyle.dll b/local_service_win/third_party/Kneron_DFUT/bin/styles/qwindowsvistastyle.dll deleted file mode 100644 index a3c0e52..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/styles/qwindowsvistastyle.dll and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ar.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ar.qm deleted file mode 100644 index 1e9227a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ar.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_bg.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_bg.qm deleted file mode 100644 index dcec255..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_bg.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ca.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ca.qm deleted file mode 100644 index 0b798e5..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ca.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_cs.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_cs.qm deleted file mode 100644 index 3ab5ca7..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_cs.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_da.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_da.qm deleted file mode 100644 index 6756496..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_da.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_de.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_de.qm deleted file mode 100644 index 86ae7fa..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_de.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_en.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_en.qm deleted file mode 100644 index 9dad8df..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_en.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_es.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_es.qm deleted file mode 100644 index 82012da..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_es.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fi.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fi.qm deleted file mode 100644 index 2548cca..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fi.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fr.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fr.qm deleted file mode 100644 index 8353f0a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_fr.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_gd.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_gd.qm deleted file mode 100644 index fd7eecd..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_gd.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_he.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_he.qm deleted file mode 100644 index e15d45e..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_he.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_hu.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_hu.qm deleted file mode 100644 index b51bd1a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_hu.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_it.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_it.qm deleted file mode 100644 index a2433c6..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_it.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ja.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ja.qm deleted file mode 100644 index 74409b1..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ja.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ko.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ko.qm deleted file mode 100644 index a46b8a0..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ko.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_lv.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_lv.qm deleted file mode 100644 index c1dbfbd..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_lv.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_pl.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_pl.qm deleted file mode 100644 index 0909204..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_pl.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ru.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ru.qm deleted file mode 100644 index b77ce55..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_ru.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_sk.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_sk.qm deleted file mode 100644 index 215d234..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_sk.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_uk.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_uk.qm deleted file mode 100644 index 88c4362..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_uk.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_zh_TW.qm b/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_zh_TW.qm deleted file mode 100644 index 21c4190..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/bin/translations/qt_zh_TW.qm and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/flash_helper.bin b/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/flash_helper.bin deleted file mode 100644 index e746210..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/flash_helper.bin and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/fw_loader.bin b/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/fw_loader.bin deleted file mode 100644 index 51ca844..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL520/fw_loader.bin and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL630/flash_helper.tar b/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL630/flash_helper.tar deleted file mode 100644 index eb94b5a..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL630/flash_helper.tar and /dev/null differ diff --git a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL730/flash_helper.tar b/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL730/flash_helper.tar deleted file mode 100644 index b042714..0000000 Binary files a/local_service_win/third_party/Kneron_DFUT/resource/firmware/KL730/flash_helper.tar and /dev/null differ diff --git a/local_service_win/third_party/kneron_plus_1_2_1/KneronPLUS-1.2.1.zip b/local_service_win/third_party/kneron_plus_1_2_1/KneronPLUS-1.2.1.zip deleted file mode 100644 index e2aee1c..0000000 Binary files a/local_service_win/third_party/kneron_plus_1_2_1/KneronPLUS-1.2.1.zip and /dev/null differ diff --git a/relay-server-linux b/relay-server-linux deleted file mode 100755 index 01c341e..0000000 Binary files a/relay-server-linux and /dev/null differ diff --git a/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef b/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef deleted file mode 100644 index 8d9138a..0000000 Binary files a/server/data/nef/kl520/kl520_20004_fcos-drk53s_w512h512.nef and /dev/null differ diff --git a/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef b/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef deleted file mode 100644 index 3ebaf01..0000000 Binary files a/server/data/nef/kl520/kl520_20005_yolov5-noupsample_w640h640.nef and /dev/null differ diff --git a/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef b/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef deleted file mode 100644 index e540bff..0000000 Binary files a/server/data/nef/kl720/kl720_20004_fcos-drk53s_w512h512.nef and /dev/null differ diff --git a/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef b/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef deleted file mode 100644 index 64a0bec..0000000 Binary files a/server/data/nef/kl720/kl720_20005_yolov5-noupsample_w640h640.nef and /dev/null differ diff --git a/server/scripts/firmware/KL520/fw_ncpu.bin b/server/scripts/firmware/KL520/fw_ncpu.bin deleted file mode 100644 index 10cd08b..0000000 Binary files a/server/scripts/firmware/KL520/fw_ncpu.bin and /dev/null differ diff --git a/server/scripts/firmware/KL520/fw_scpu.bin b/server/scripts/firmware/KL520/fw_scpu.bin deleted file mode 100644 index 482315a..0000000 Binary files a/server/scripts/firmware/KL520/fw_scpu.bin and /dev/null differ diff --git a/server/scripts/firmware/KL720/fw_ncpu.bin b/server/scripts/firmware/KL720/fw_ncpu.bin deleted file mode 100644 index 815530e..0000000 Binary files a/server/scripts/firmware/KL720/fw_ncpu.bin and /dev/null differ diff --git a/server/scripts/firmware/KL720/fw_scpu.bin b/server/scripts/firmware/KL720/fw_scpu.bin deleted file mode 100644 index 82f23e2..0000000 Binary files a/server/scripts/firmware/KL720/fw_scpu.bin and /dev/null differ