2026-04-07 14:37:04 +08:00

570 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design Doc: KNEO AcademyInnovedus AI Playgroundv2.0
**作者**Architect Agent
**狀態**Draft從既有程式碼反向整理
**日期**2026-04-04
**產品版本**v2.0
---
## 1. 背景與目標
### 1.1 產品概述
KNEO Academy對外名稱Innovedus AI Playground是一套 Windows 桌面應用程式,讓擁有 Kneron NPU USB Dongle 的使用者能夠在本機端執行 Edge AI 推論,無需雲端服務、無需撰寫程式碼。
**核心使用情境**
- 業務展示:即插即用,開箱展示 AI 推論能力
- 研發驗證:快速測試 Kneron NPU 在特定任務的推論效果
- 客戶自訂:上傳自訂 `.nef` 模型進行推論測試
### 1.2 設計目標
- **即插即用**:連接 Kneron dongle 後,數秒內可開始推論
- **Plugin 化架構**:透過目錄結構 + `config.json` + `script.py` 新增模型,不需修改主程式
- **Thread 隔離**UI 執行緒與推論執行緒完全分離,確保畫面不卡頓
- **PyInstaller 相容**:可打包為單一可執行檔,方便分發
### 1.3 非目標Out of Scope
- 雲端推論或 API 服務
- 多裝置同時推論
- 行動平台支援
---
## 2. 整體架構概覽
### 2.1 設計模式
本應用程式採用 **MVCModel-View-Controller** 架構,搭配 Qt 的 **Signal/Slot** 機制實現跨執行緒通訊。
```
┌─────────────────────────────────────────────────────────────────┐
│ Views呈現層
│ SelectionScreen LoginScreen MainWindow UtilitiesScreen │
│ QWidget QWidget QWidget QWidget │
└────────────────────────────┬────────────────────────────────────┘
│ Signal/Slot
┌────────────────────────────▼────────────────────────────────────┐
│ AppControllermain.py
│ QStackedWidget — 頁面路由中樞 │
└──────┬──────────────────┬───────────────────┬───────────────────┘
│ │ │
┌──────▼──────┐ ┌────────▼────────┐ ┌──────▼──────────┐
│ Device │ │ Inference │ │ Media │
│ Controller │ │ Controller │ │ Controller │
│ (裝置管理) │ │ (推論管理) │ │ (相機/媒體) │
└──────┬──────┘ └───────┬─────────┘ └──────┬──────────┘
│ │ │
┌──────▼──────┐ ┌───────▼─────────┐ ┌──────▼──────────┐
│ device_ │ │ InferenceWorker │ │ VideoThread │
│ service.py │ │ Thread │ │ QThread
kp SDK │ │ QThread │ │ OpenCV 擷取) │
└─────────────┘ └────────┬────────┘ └─────────────────┘
│ 動態載入
┌────────▼────────┐
│ script.py │
Plugin 推論) │
└─────────────────┘
```
### 2.2 頁面導航架構
AppController 使用 `QStackedWidget` 作為根容器,所有頁面在啟動時一次性初始化,透過 `setCurrentWidget()` 切換顯示,不需重新建立物件。
```
AppController
└── QStackedWidgetstack
├── [index 0] SelectionScreen ← 預設顯示
├── [index 1] LoginScreen
├── [index 2] UtilitiesScreen
└── [index 3] MainWindow
```
**Signal 連接關係(頁面切換)**
| 發出者 | Signal | 接收者Slot | 效果 |
|--------|--------|--------------|------|
| SelectionScreen | `open_utilities` | `AppController.show_login_screen` | 跳至登入頁 |
| SelectionScreen | `open_demo_app` | `AppController.show_demo_app` | 跳至主視窗 |
| LoginScreen | `login_success` | `AppController.show_utilities_screen` | 登入成功,進入工具頁 |
| LoginScreen | `back_to_selection` | `AppController.show_selection_screen` | 返回首頁 |
| UtilitiesScreen | `back_to_selection` | `AppController.show_selection_screen` | 返回首頁 |
### 2.3 MainWindow 內部架構
MainWindow 是 AI Demo 推論的核心容器,內部持有三個 Controller 組成協作關係:
```
MainWindowQWidget
├── DeviceController — 管理 kp SDK 裝置連接
├── InferenceController — 管理推論 Worker Thread 與 Queue
│ └── inference_queuequeue.Queue, maxsize=5
└── MediaController — 管理相機擷取與畫面更新
└── VideoThreadQThread
```
---
## 3. 模組依賴圖
```
main.pyAppController
├── views/selection_screen.pySelectionScreen
│ └── config.py
├── views/login_screen.pyLoginScreen
│ └── config.py
├── views/utilities_screen.pyUtilitiesScreen
│ ├── config.py
│ ├── controllers/device_controller.pyDeviceController
│ └── services/device_service.pycheck_available_device
└── views/mainWindows.pyMainWindow
├── config.py
├── controllers/device_controller.pyDeviceController
│ ├── config.py
│ └── services/device_service.py
├── controllers/inference_controller.pyInferenceController
│ ├── config.py
│ ├── models/inference_worker.pyInferenceWorkerThread
│ │ ├── config.py
│ │ └── [動態載入] utils/{mode}/{model}/script.py
│ └── models/custom_inference_worker.pyCustomInferenceWorkerThread
├── controllers/media_controller.pyMediaController
│ ├── models/video_thread.pyVideoThread
│ └── utils/image_utils.py
├── services/file_service.pyFileService
└── utils/config_utils.pyConfigUtils
```
**注意**`UtilitiesScreen` 建立了**自己的** `DeviceController` 實例(與 `MainWindow` 的是不同物件),兩者不共享裝置狀態。
---
## 4. 資料流程圖
### 4.1 Video 即時推論流程
```
相機硬體
│ 每幀(~30fps
VideoThread.run()
│ QImage
│ change_pixmap_signal.emit(qt_image)
MediaController.update_image(qt_image)
├── 1. 繪製 Bounding Box → canvas_label.setPixmap(pixmap)
└── 2. qimage_to_numpy(qt_image) → frame_np
InferenceController.add_frame_to_queue(frame_np)
│ 若 queue 未滿maxsize=5
inference_queue.put(frame_np)
InferenceWorkerThread.run()
├── 1. MSE 比較(與前一幀)→ 差異不大時emit 快取結果
├── 2. 時間間隔檢查min_interval=2秒
└── 3. script.inference(frame, params) → result
│ inference_result_signal.emit(result)
MainWindow.handle_inference_result(result)
├── 若有 "bounding box"/"bounding boxes"
│ → 更新 current_bounding_boxes下一幀繪製
└── 若無 bounding box → QMessageBox 彈出顯示
```
### 4.2 Image 推論流程
```
使用者點擊「Upload」
FileService.upload_file()
├── 1. 暫停相機 Signaldisconnect change_pixmap_signal
├── 2. QFileDialog 選檔
├── 3. shutil.copy2() → %LOCALAPPDATA%/Kneron_Academy/uploads/
├── 4. 顯示圖片於 canvas_label
└── 5. InferenceController.process_uploaded_image(file_path)
_clear_inference_queue()
inference_queue.put(img)
InferenceWorkerThreadonce_mode=True
│ 只處理一幀後停止
script.inference(frame, params) → result
│ inference_result_signal.emit(result)
MainWindow.handle_inference_result(result)
```
### 4.3 Custom Model 推論流程
```
使用者提供:
- custom_model_path.nef 檔)
- custom_scpu_pathfw_scpu.bin
- custom_ncpu_pathfw_ncpu.bin
- custom_labels可選
InferenceController.select_custom_tool(tool_config)
CustomInferenceWorkerThreadQThread
├── initialize_device()(首次執行時)
│ ├── kp.core.connect_devices([port_id])
│ ├── kp.core.load_firmware_from_file(scpu, ncpu)
│ └── kp.core.load_model_from_file(model_path)
└── run_single_inference(frame)
├── preprocess_frame()resize to 640, BGR → BGR565
├── kp.GenericImageInferenceDescriptor
├── kp.inference.generic_image_inference_send()
├── kp.inference.generic_image_inference_receive()
├── kp.inference.generic_inference_retrieve_float_node()
└── post_process_yolo_v5() → ExampleYoloResult
│ inference_result_signal.emit(result_dict)
MainWindow.handle_inference_result()
```
**注意**`CustomInferenceWorkerThread` 在 Worker Thread 內部**自行連接/重置裝置**,與 `DeviceController` 管理的 `device_group` 是**不同的連接**。這是一個雙重連接問題(見第 8 節技術問題)。
---
## 5. Thread 架構
### 5.1 執行緒關係圖
```
Qt Main ThreadUI Thread
├── AppControllerQStackedWidget 管理)
├── MainWindowUI 事件處理)
│ ├── handle_inference_result() ← 由 Signal 呼叫(執行在主執行緒)
│ └── update_image() via MediaController ← 由 Signal 呼叫(執行在主執行緒)
├── VideoThreadQThread #1
│ └── 職責相機擷取、QImage 轉換、emit change_pixmap_signal
│ └── 內部threading.Thread用於相機開啟 timeout 機制)
├── InferenceWorkerThreadQThread #2
│ └── 職責:從 queue 取幀、MSE 比較、呼叫 script.inference()、emit 結果
└── CustomInferenceWorkerThreadQThread #3替代 InferenceWorkerThread
└── 職責device init、kp 推論、YOLOv5 後處理、emit 結果
```
### 5.2 裝置掃描的執行緒
`check_available_device()` 使用 `threading.Thread`(非 QThread執行 `kp.core.scan_devices()`,並設 5 秒 timeout
```
check_available_device() in Main Thread
└── threading.Threaddaemon=True
└── kp.core.scan_devices()(阻塞式 SDK 呼叫)
thread.join(timeout=5.0)
```
同樣地,`VideoThread._open_camera_with_timeout()` 也使用 `threading.Thread` 開啟相機timeout 為 5 秒。
### 5.3 跨執行緒通訊
所有跨執行緒通訊均透過 Qt Signal/Slot 機制Qt 保證跨執行緒的 Signal 會在接收執行緒的 Event Loop 中排隊執行:
| Signal | 發出執行緒 | 接收執行緒Slot |
|--------|-----------|----------------|
| `VideoThread.change_pixmap_signal` | VideoThread | Main Thread`MediaController.update_image` |
| `InferenceWorkerThread.inference_result_signal` | InferenceWorkerThread | Main Thread`MainWindow.handle_inference_result` |
| `CustomInferenceWorkerThread.inference_result_signal` | CustomInferenceWorkerThread | Main Thread`MainWindow.handle_inference_result` |
---
## 6. Plugin 系統設計
### 6.1 架構概念
Plugin 系統讓 Kneron 或第三方可以透過放置目錄和設定檔來新增 AI 工具,完全不需修改主程式碼。
### 6.2 目錄結構
```
%LOCALAPPDATA%/Kneron_Academy/utils/
├── config.json ← 全域 Plugin 索引(自動產生)
├── {mode_name}/ ← 推論模式目錄(如 object_detection
│ └── {model_name}/ ← 模型目錄(如 yolov5_person
│ ├── config.json ← 模型設定
│ ├── script.py ← 推論腳本Plugin 核心)
│ └── {model_name}.nef ← Kneron 模型檔
```
### 6.3 Plugin 載入流程
```
應用程式啟動
ConfigUtils.generate_global_config()
├── 掃描 utils/ 下所有 mode 目錄(跳過 _ 開頭的目錄)
├── 掃描每個 mode 下所有 model 目錄
├── 讀取每個 model/config.json
└── 輸出 utils/config.jsonPlugin 索引)
使用者選擇工具
InferenceController.select_tool(tool_config)
InferenceWorkerThread.__init__()
└── load_inference_module(mode, model_name)
└── importlib.util.spec_from_file_location()
→ 動態 import utils/{mode}/{model}/script.py
```
### 6.4 `script.py` 介面規範
每個 Plugin 的 `script.py` 必須實作以下介面:
```python
def inference(frame: np.ndarray, params: dict) -> dict | None:
"""
Args:
frame: 影像幀numpy array形狀 (H, W, 3)RGB 格式
params: 推論參數字典(詳見 config.json schema
Returns:
dict 或 NoneNone 表示跳過此幀)
支援的回傳格式(在 handle_inference_result 中處理):
格式 A單一 Bounding Box
{
"bounding box": [x1, y1, x2, y2],
"result": "class_label"
}
格式 B多個 Bounding Box推薦
{
"bounding boxes": [[x1, y1, x2, y2], ...],
"results": ["label1", "label2", ...]
}
格式 C任意分類結果彈出 QMessageBox 顯示)
{
"key": "value",
...
}
"""
```
### 6.5 `params` 字典的內容
`InferenceController.select_tool()` 在建立 `InferenceWorkerThread` 前,會將以下資訊注入 `input_params`
| Key | 型別 | 來源 |
|-----|------|------|
| `device_group` | kp.DeviceGroup | DeviceController |
| `usb_port_id` | int | 已連接裝置 |
| `scpu_path` | str | firmware 路徑 |
| `ncpu_path` | str | firmware 路徑 |
| `model` | str | model .nef 完整路徑 |
| `model_descriptor` | kp.ModelDescriptor | 已上傳的模型描述 |
| `file_path` | str | 圖片/聲音模式的上傳檔案路徑 |
| (其他) | any | 來自 model config.json 的 `input_parameters` |
---
## 7. 資料存放設計
### 7.1 執行期資料目錄
全部存放於 Windows 的 `%LOCALAPPDATA%\Kneron_Academy\`
```
%LOCALAPPDATA%\Kneron_Academy\
├── utils\
│ ├── config.json ← Plugin 全域索引(啟動時自動產生)
│ ├── {mode}\
│ │ └── {model}\
│ │ ├── config.json ← 模型設定
│ │ ├── script.py ← 推論腳本
│ │ └── *.nef ← 模型檔
│ └── ...
├── uploads\ ← 使用者上傳的圖片(不自動清理)
│ └── *.jpg / *.png / *.wav / ...
└── firmware\
├── KL520\
│ ├── fw_scpu.bin
│ └── fw_ncpu.bin
└── KL720\
├── fw_scpu.bin
└── fw_ncpu.bin
```
### 7.2 靜態 UI 資源
打包在應用程式內(`uxui/` 目錄),路徑透過 `PROJECT_ROOT` 常數計算:
```python
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UXUI_ASSETS = os.path.join(PROJECT_ROOT, "uxui", "")
```
**PyInstaller 注意**:打包後 `__file__` 的位置會改變,需確認 `UXUI_ASSETS` 路徑在打包後仍正確(詳見第 8.3 節)。
---
## 8. 打包架構PyInstaller
### 8.1 打包工具鏈
- **PyInstaller 6.12.0**:將 Python 應用打包為 Windows .exe
- **Inno Setup**`dist/test.iss`):製作 Windows 安裝包 (.exe installer)
- **PyArmor**(計畫中):混淆/加密 Python 原始碼
### 8.2 打包注意事項
| 項目 | 問題 | 解決方向 |
|------|------|---------|
| `kp` SDK | kp 是 C Extension需確認是否能被 PyInstaller 正確打包 | 需設定 `hiddenimports``binaries` |
| 動態 import | `importlib.util.spec_from_file_location()` 在打包後需從外部路徑載入 | `script.py` 必須放在 `%LOCALAPPDATA%`,不能打包進 exe |
| `UXUI_ASSETS` 路徑 | 打包後 `__file__` 指向臨時目錄 | 需在 `.spec` 中設定 `datas`,並使用 `sys._MEIPASS` 處理路徑 |
| OpenCV | OpenCV 需包含 DLL | 通常 PyInstaller 能自動偵測 |
### 8.3 目錄結構(打包後)
```
安裝目錄/
├── Innovedus AI Playground.exe ← 主執行檔
├── uxui/ ← 靜態資源(需隨 exe 一起安裝)
└── ...
%LOCALAPPDATA%\Kneron_Academy\ ← 使用者資料(安裝時建立)
├── utils/
├── uploads/
└── firmware/
```
---
## 9. 已知技術問題 / 技術債
### 9.1 雙重裝置連接(⚠️ 嚴重)
**問題**`CustomInferenceWorkerThread` 在 Worker Thread 內部調用 `kp.core.connect_devices()`,但 `DeviceController` 可能已經對同一個 `usb_port_id` 建立了連接(在 MainWindow 流程中)。
**影響**:可能導致 kp SDK 報告「裝置已被連接」的錯誤,或產生未定義行為。
**建議**`CustomInferenceWorkerThread` 應改為接受外部傳入的 `device_group`,而非自行連接。
### 9.2 UtilitiesScreen 的 DeviceController 孤立問題(⚠️ 中度)
**問題**`UtilitiesScreen` 建立了自己的 `DeviceController(self)` 實例,與 `MainWindow``DeviceController` 完全獨立,兩者各自管理自己的 `device_group`
**影響**:使用者在 UtilitiesScreen 連接裝置後,切換到 MainWindow 並不知道裝置已連接;反之亦然。
**建議**:將 `DeviceController` 提升到 `AppController` 層級,作為共享的單例。
### 9.3 推論 Queue 丟幀而不通知(⚠️ 中度)
**問題**`inference_queue``maxsize=5`,當 queue 滿時,`add_frame_to_queue()` 靜默丟棄幀(只印 print不通知 UI
**影響**:在高推論延遲時,使用者不知道有幀被丟棄,可能誤以為推論仍在即時進行。
**建議**:新增 UI 指示推論 queue 壓力如幀率顯示、lag 指示)。
### 9.4 LoginScreen 的驗證邏輯未實作(⚠️ 中度)
**問題**`LoginScreen.attempt_login()` 的實際 Server 驗證邏輯未實作,目前只要輸入任何非空帳密就會成功登入。
```python
# 目前實作(不安全)
if not username or not password:
self.show_error("Please enter both username and password")
return
self.login_success.emit() # 永遠成功
```
**建議**:需補齊 Server 端驗證 API 呼叫。
### 9.5 debug print 語句散落各處(低優先)
**問題**:各 Controller 和 Thread 中有大量 `print()` 呼叫作為 debug 輸出,打包後仍會執行(輸出被丟棄,但有效能成本)。
**建議**:改用 Python 的 `logging` 模組,並設定適當的 log level。
### 9.6 `custom_inference_worker.py` 中的 `kp` 全域引用問題(⚠️ 中度)
**問題**`_boxes_scale()``post_process_yolo_v5()` 函數的型別標注直接引用 `kp.HwPreProcInfo``kp.InferenceFloatNodeOutput`(如 `def _boxes_scale(boxes, hardware_preproc_info: kp.HwPreProcInfo)`),但 `kp` 在模組頂層未被 import。實際 kp import 是在函數內部的 `run_single_inference()` 中延遲進行的。
**影響**:型別標注在模組載入時會被解析(在 Python 3.10+ 以下),可能導致 `NameError`
**建議**:在頂層加入 `if TYPE_CHECKING: import kp`,或改用字串型別標注 `"kp.HwPreProcInfo"`
### 9.7 VideoThread 的 `threading.Thread` 記憶體洩漏風險(低優先)
**問題**`_open_camera_with_timeout()` 啟動了 `daemon=True``threading.Thread` 並等待最多 5 秒,但如果 thread 仍存活timeout其仍會繼續嘗試開啟相機可能導致相機資源被不正確佔用。
**建議**:使用 cv2 的 nonblocking 方式或設定相機 timeout 參數,避免 daemon thread 的不確定行為。
### 9.8 MSE 計算的效能問題(低優先)
**問題**`InferenceWorkerThread``CustomInferenceWorkerThread` 的 MSE 計算會把整個 frame 轉成 float32 進行運算:
```python
mse = np.mean((frame.astype(np.float32) - self.last_frame.astype(np.float32)) ** 2)
```
對於 640x480 的 3 通道影像,每次計算需要處理 ~921,600 個浮點數。
**建議**:可改為縮小解析度後再計算 MSE或使用 histogram 比較等更快速的方式。
---
## 10. 容量與效能估算
### 10.1 系統需求(桌面應用)
| 資源 | 需求 | 備註 |
|------|------|------|
| CPU | 雙核心以上 | 主要用於影像轉換和後處理 |
| RAM | 2GB 以上 | kp SDK + OpenCV + PyQt5 |
| USB | USB 3.0 | KL720 需要 USB 3.0 |
| GPU | 不需要 | 推論在 NPU 執行 |
| 磁碟 | 500MB 以上 | 安裝包 + 模型檔 |
### 10.2 推論速度特性
- **Queue maxsize**5 幀
- **VideoThread 輸出**~30fps640x480
- **InferenceWorkerThread min_interval**2 秒(標準模式)/ 0.5 秒Custom 模式)
- **MSE threshold**500低於此值視為相似幀使用快取結果
- **相機開啟 timeout**5 秒 × 最多 3 次嘗試
---
## 11. 安全性設計
### 11.1 目前狀態
| 項目 | 狀態 | 說明 |
|------|------|------|
| Server 登入驗證 | ❌ 未實作 | `attempt_login()` 永遠成功 |
| 程式碼保護 | ⚠️ 計畫中 | PyArmor 列在計畫中 |
| 自定義模型驗證 | ❌ 無 | 任何 .nef 檔都能上傳 |
| 網路通訊加密 | ❌ 未知 | Server 驗證端點未見 TLS 設定 |
### 11.2 Plugin 安全風險
`load_inference_module()` 使用 `importlib` 動態執行 `script.py`,等同於執行任意 Python 程式碼。若 `%LOCALAPPDATA%` 中的 `script.py` 被惡意替換,攻擊者可以完整控制推論行為。
**建議**:考慮對 `script.py` 進行簽章驗證,或限制其沙盒執行環境。