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

971 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# 技術設計文件TDD— KNEO Academy v2.0
**作者**Architect Agent
**狀態**Draft從既有程式碼反向整理
**日期**2026-04-04
**對應 Design Doc**`04-architecture/design-doc.md`
---
## 目錄
1. [模組 API 規格](#1-模組-api-規格)
- 1.1 [config.py — 全域設定](#11-configpy--全域設定)
- 1.2 [AppControllermain.py](#12-appcontrollermainpy)
- 1.3 [DeviceController](#13-devicecontroller)
- 1.4 [InferenceController](#14-inferencecontroller)
- 1.5 [MediaController](#15-mediacontroller)
- 1.6 [InferenceWorkerThread](#16-inferenceworkerthread)
- 1.7 [CustomInferenceWorkerThread](#17-custominferenceworkerthread)
- 1.8 [VideoThread](#18-videothread)
- 1.9 [DeviceService](#19-deviceservice)
- 1.10 [FileService](#110-fileservice)
- 1.11 [ConfigUtils](#111-configutils)
- 1.12 [image_utils](#112-image_utils)
2. [InferenceWorkerThread 與 script.py 介面規範](#2-inferenceworkerthread-與-scriptpy-介面規範)
3. [裝置連接 API 流程kp SDK 呼叫順序)](#3-裝置連接-api-流程kp-sdk-呼叫順序)
4. [推論 Queue 設計](#4-推論-queue-設計)
5. [Signal/Slot 對應表](#5-signalslot-對應表)
6. [config.json 完整 Schema 定義](#6-configjson-完整-schema-定義)
7. [已知限制與邊界條件](#7-已知限制與邊界條件)
---
## 1. 模組 API 規格
### 1.1 config.py — 全域設定
**路徑**`src/config.py`
#### 常數
| 常數名稱 | 型別 | 說明 |
|---------|------|------|
| `APPDATA_PATH` | str | `%LOCALAPPDATA%` 環境變數值 |
| `PROJECT_ROOT` | str | 專案根目錄絕對路徑 |
| `UXUI_ASSETS` | str | `{PROJECT_ROOT}/uxui/` |
| `UTILS_DIR` | str | `%LOCALAPPDATA%/Kneron_Academy/utils` |
| `SCRIPT_CONFIG` | str | `{UTILS_DIR}/config.json` |
| `UPLOAD_DIR` | str | `%LOCALAPPDATA%/Kneron_Academy/uploads` |
| `FW_DIR` | str | `%LOCALAPPDATA%/Kneron_Academy/firmware` |
| `APP_NAME` | str | `"Innovedus AI Playground"` |
| `WINDOW_SIZE` | tuple[int, int] | `(1200, 900)` |
| `BACKGROUND_COLOR` | str | `"#143058"` |
| `MODEL_TIMEOUT` | int | `5000`(毫秒) |
| `FIRMWARE_PATHS` | dict | `{"scpu": "../../res/firmware/fw_scpu.bin", "ncpu": "..."}` ⚠️ 見備注 |
> **⚠️ 備注**`FIRMWARE_PATHS` 使用相對路徑,但在打包後此相對路徑可能無效,實際運作中 `DeviceController.connect_device()` 改用 `os.path.join(FW_DIR, dongle, "fw_scpu.bin")` 計算路徑。`FIRMWARE_PATHS` 常數目前**未被使用**。
#### DeviceType Enum
```python
class DeviceType(Enum):
KL520 = 256 # product_id hex: 0x100
KL720 = 1824 # product_id hex: 0x720
KL720_L = 512 # product_id hex: 0x200 無 DongleModelMap 對應)
KL530 = 530 # (⚠️ 無 DongleModelMap 對應)
KL832 = 832 # (⚠️ 無 DongleModelMap 對應)
KL730 = 732 # (⚠️ 無 DongleModelMap 對應)
KL630 = 630 # (⚠️ 無 DongleModelMap 對應)
KL540 = 540 # (⚠️ 無 DongleModelMap 對應)
```
#### DongleModelMapproduct_id → model name
```python
DongleModelMap = {
"0x100": "KL520",
"0x720": "KL720",
}
# 注意key 為 lowercase hex string含 0x 前綴),如 "0x100"
```
#### DongleIconMapproduct_id → icon 檔名)
```python
DongleIconMap = {
"0x100": "ic_dongle_520.png",
"0x720": "ic_dongle_720.png",
}
```
---
### 1.2 AppControllermain.py
**路徑**`main.py`
#### Class: AppController
```python
class AppController:
app: QApplication
stack: QStackedWidget
selection_screen: SelectionScreen
login_screen: LoginScreen
utilities_screen: UtilitiesScreen
main_window: MainWindow
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `__init__()` | — | — | 初始化 QApplication、QStackedWidget、所有頁面並連接 Signal |
| `init_screens()` | — | None | 建立四個頁面物件並加入 stack |
| `connect_signals()` | — | None | 連接頁面間的 Signal/Slot |
| `show_selection_screen()` | — | None | 切換到 SelectionScreen |
| `show_login_screen()` | — | None | 切換到 LoginScreen |
| `show_utilities_screen()` | — | None | 切換到 UtilitiesScreen |
| `show_demo_app()` | — | None | 切換到 MainWindow |
| `run()` | — | int | 啟動 Qt event loop回傳 exit code |
**⚠️ 已知問題**:所有頁面在 `init_screens()` 時一次性建立,包含 `MainWindow`(內部會啟動相機等重資源操作)。若希望延遲初始化,需要重構。
---
### 1.3 DeviceController
**路徑**`src/controllers/device_controller.py`
#### Class: DeviceController
```python
class DeviceController:
main_window: QWidget # 持有 UI 參考(用於更新 device_list_widget
selected_device: dict | None # 當前選中的裝置dict 格式)
connected_devices: list[dict] # 已解析的裝置列表
device_group: kp.DeviceGroup | None # kp SDK 連接的裝置群組
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `__init__(main_window)` | main_window: QWidget | — | 初始化 |
| `refresh_devices()` | — | bool | 掃描裝置並更新 UITrue = 找到裝置 |
| `parse_and_store_devices(devices)` | devices: list | None | 解析 descriptor 存入 `connected_devices` |
| `display_devices(devices)` | devices: list | None | 更新 `main_window.device_list_widget` |
| `get_devices()` | — | list | 呼叫掃描並回傳 descriptor list |
| `get_selected_device()` | — | dict \| None | 回傳目前選中的裝置 |
| `select_device(device, list_item, list_widget)` | device, item, widget | None | 選取裝置,更新 UI 高亮 |
| `connect_device()` | — | bool | 連接 selected_device 並上傳 firmware |
| `disconnect_device()` | — | bool | 中斷連接,釋放 device_group |
| `get_device_group()` | — | kp.DeviceGroup \| None | 回傳目前的 device_group |
#### connected_devices 元素格式
```python
{
"usb_port_id": int, # USB port 編號(唯一識別)
"product_id": int, # 數字形式的 product_id
"kn_number": int | str, # KN number裝置序號
"dongle": str # 裝置型號名稱,如 "KL520"
}
```
---
### 1.4 InferenceController
**路徑**`src/controllers/inference_controller.py`
#### Class: InferenceController
```python
class InferenceController:
main_window: QWidget
device_controller: DeviceController
inference_worker: InferenceWorkerThread | CustomInferenceWorkerThread | None
inference_queue: queue.Queue # maxsize=5
current_tool_config: dict | None
previous_tool_config: dict | None
_camera_was_active: bool
original_frame_width: int # 預設 640
original_frame_height: int # 預設 480
model_descriptor: kp.ModelDescriptor | None
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `__init__(main_window, device_controller)` | — | — | 初始化,建立 inference_queuemaxsize=5 |
| `select_tool(tool_config)` | tool_config: dict | bool | 選擇標準工具,建立 InferenceWorkerThread |
| `select_custom_tool(tool_config)` | tool_config: dict | bool | 選擇自定義模型,建立 CustomInferenceWorkerThread |
| `_clear_inference_queue()` | — | None | 清空 inference_queue 中所有幀 |
| `add_frame_to_queue(frame)` | frame: np.ndarray | None | 若 queue 未滿,加入幀;同時更新 original_frame_width/height |
| `stop_inference()` | — | None | 停止 inference_worker |
| `process_uploaded_image(file_path)` | file_path: str | None | 清空 queue 後加入指定圖片,供 Image 模式使用 |
#### select_tool() 的 tool_config 期望格式
```python
{
"display_name": str, # 顯示名稱
"mode": str, # 推論模式目錄名稱(對應 utils/{mode}/
"model_name": str, # 模型目錄名稱(對應 utils/{mode}/{model_name}/
"input_info": {
"type": "video" | "image" | "voice"
},
"input_parameters": dict, # 傳入 script.py 的 params
"compatible_devices": list[str], # 如 ["KL520", "KL720"]
"model_file": str, # .nef 檔名(可選,若有則自動上傳模型)
}
```
---
### 1.5 MediaController
**路徑**`src/controllers/media_controller.py`
#### Class: MediaController
```python
class MediaController:
main_window: QWidget
inference_controller: InferenceController
video_thread: VideoThread | None
recording: bool
recording_audio: bool
recorded_frames: list[np.ndarray]
_signal_was_connected: bool
_inference_paused: bool
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `start_camera()` | — | None | 建立 VideoThread 並連接 Signal若已運行則跳過 |
| `stop_camera()` | — | None | 斷開 Signal停止並銷毀 VideoThread |
| `update_image(qt_image)` | qt_image: QImage | None | 接收相機幀,繪製 Bounding Box更新 canvas加入 inference_queue |
| `reconnect_camera_signal()` | — | None | 重新連接已斷開的 change_pixmap_signal |
| `record_video(button=None)` | button: QPushButton \| None | None | 切換錄影狀態;停止時彈出儲存對話框 |
| `record_audio(button=None)` | button: QPushButton \| None | None | 切換錄音狀態 |
| `take_screenshot()` | — | None | 儲存 canvas 當前幀為圖片 |
| `toggle_inference_pause()` | — | bool | 切換推論暫停狀態,回傳新狀態 |
| `is_inference_paused()` | — | bool | 回傳目前是否暫停推論 |
---
### 1.6 InferenceWorkerThread
**路徑**`src/models/inference_worker.py`
#### 模組函數
```python
def load_inference_module(mode: str, model_name: str) -> module:
"""
動態載入 {UTILS_DIR}/{mode}/{model_name}/script.py 並回傳 module 物件。
若檔案不存在importlib 會拋出 FileNotFoundError。
"""
```
#### Class: InferenceWorkerThreadQThread
```python
class InferenceWorkerThread(QThread):
inference_result_signal = pyqtSignal(object) # 回傳 dict | None
frame_queue: queue.Queue
mode: str
model_name: str
min_interval: float # 最小推論間隔(秒)
mse_threshold: float # MSE 閾值,低於此值視為相似幀
_running: bool
once_mode: bool # True = 處理一幀後停止Image 模式)
last_inference_time: float
last_frame: np.ndarray | None
cached_result: object | None
input_params: dict
inference_module: module # 動態載入的 script.py module
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `__init__(frame_queue, mode, model_name, min_interval=0.5, mse_threshold=500, once_mode=False)` | — | — | 初始化並立即載入 inference_module |
| `run()` | — | None | 主迴圈:取幀 → MSE 判斷 → 推論 → emit 結果 |
| `stop()` | — | None | 設 `_running=False``wait()` |
**⚠️ 注意**`InferenceController.select_tool()` 建立 Worker 時,`min_interval` 設定為 **2 秒**(非預設的 0.5 秒):
```python
self.inference_worker = InferenceWorkerThread(
...,
min_interval=2,
mse_threshold=500,
once_mode=once_mode
)
```
#### run() 主迴圈邏輯
```
while _running:
1. frame_queue.get(timeout=0.1) — 若 0.1 秒內無幀則 continue
2. 時間間隔檢查current_time - last_inference_time < min_interval → continue
3. MSE 比較(若 last_frame 不為 None 且尺寸相同):
- mse < mse_threshold AND cached_result 不為 None → emit cached_result → continue
4. inference_module.inference(frame, params=input_params) → result
5. 更新 last_inference_time、last_frame、cached_result
6. result 不為 None → inference_result_signal.emit(result)
7. once_mode = True → _running = Falsebreak
quit()
```
---
### 1.7 CustomInferenceWorkerThread
**路徑**`src/models/custom_inference_worker.py`
#### 輔助 Classes
```python
class ExampleBoundingBox:
x1: int; y1: int; x2: int; y2: int
score: float
class_num: int
def get_member_variable_dict() -> dict
class ExampleYoloResult:
class_count: int
box_count: int
box_list: list[ExampleBoundingBox]
def get_member_variable_dict() -> dict
```
#### 模組函數
```python
def preprocess_frame(frame: np.ndarray, target_size: int = 640) -> tuple[np.ndarray, int, int]:
"""
Args:
frame: BGR 格式 numpy array
target_size: 縮放目標尺寸(預設 640
Returns:
(frame_bgr565, original_width, original_height)
- frame_bgr565: cv2 BGR565 格式
"""
def postprocess(output_list, hw_preproc_info, original_width, original_height,
target_size=640, thresh=0.2) -> ExampleYoloResult:
"""
YOLO V5 後處理主入口
- 呼叫 post_process_yolo_v5()
- 將 bounding box 座標縮放回原始影像尺寸
"""
def post_process_yolo_v5(inference_float_node_output_list, hardware_preproc_info,
thresh_value, with_sigmoid=True) -> ExampleYoloResult:
"""
完整 YOLO V5 後處理:
- sigmoid 激活
- anchor 解碼(使用內建 YOLO_V5_ANCHERS
- padding/縮放補償
- 分數閾值過濾
- NMSper-classIoU threshold=0.5
回傳 ExampleYoloResult
"""
```
#### Class: CustomInferenceWorkerThreadQThread
```python
class CustomInferenceWorkerThread(QThread):
inference_result_signal = pyqtSignal(object) # 回傳 dict | None
frame_queue: queue.Queue
min_interval: float
mse_threshold: float
_running: bool
last_inference_time: float
last_frame: np.ndarray | None
cached_result: object | None
input_params: dict
device_group: kp.DeviceGroup | None # Worker 內部管理的裝置連接
model_descriptor: kp.ModelDescriptor | None
is_initialized: bool
custom_labels: list[str] | None
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `__init__(frame_queue, min_interval=0.5, mse_threshold=500)` | — | — | 初始化 |
| `initialize_device()` | — | bool | 連接裝置、上傳 firmware 和模型(從 input_params 讀取路徑) |
| `run_single_inference(frame)` | frame: np.ndarray | dict \| None | 執行單次 YOLOv5 推論,回傳結果 dict |
| `run()` | — | None | 主迴圈(類似 InferenceWorkerThread但無 once_mode |
| `cleanup()` | — | None | 斷開 device_group |
| `stop()` | — | None | 設 `_running=False``wait()`,然後 `cleanup()` |
#### initialize_device() 的 input_params 期望 Keys
```python
input_params = {
"custom_model_path": str, # .nef 完整路徑
"custom_scpu_path": str, # fw_scpu.bin 完整路徑
"custom_ncpu_path": str, # fw_ncpu.bin 完整路徑
"usb_port_id": int, # USB port 編號
"custom_labels": list[str] | None # 自定義類別名稱(可選)
}
```
#### run_single_inference() 回傳格式
```python
{
"num_boxes": int,
"bounding boxes": [[x1, y1, x2, y2], ...], # 注意key 有空格
"results": ["label1", "label2", ...]
}
```
**⚠️ 注意**key 為 `"bounding boxes"`(有空格),`MainWindow.handle_inference_result()` 中對應的處理 key 也是 `"bounding boxes"`(有空格),需保持一致。
#### YOLOv5 後處理常數
```python
YOLO_V3_CELL_BOX_NUM = 3
NMS_THRESH_YOLOV5 = 0.5
YOLO_MAX_DETECTION_PER_CLASS = 100
YOLO_V5_ANCHERS = np.array([
[[10, 13], [16, 30], [33, 23]],
[[30, 61], [62, 45], [59, 119]],
[[116, 90], [156, 198], [373, 326]]
])
```
---
### 1.8 VideoThread
**路徑**`src/models/video_thread.py`
#### Class: VideoThreadQThread
```python
class VideoThread(QThread):
change_pixmap_signal = pyqtSignal(QImage) # 每幀觸發
camera_error_signal = pyqtSignal(str) # 目前未被任何 Slot 連接
_run_flag: bool
_camera_open_attempts: int
_max_attempts: int # = 3
_camera_timeout: int # = 5
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `_open_camera_with_timeout(camera_index, backend=None)` | camera_index: int, backend | cv2.VideoCapture \| None | 在 daemon thread 中開啟相機5 秒 timeout |
| `run()` | — | None | 嘗試最多 3 次開啟相機;成功後進入幀擷取迴圈 |
| `stop()` | — | None | 設 `_run_flag=False``wait()` |
#### 相機設定
```python
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
cap.set(cv2.CAP_PROP_FPS, 30)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 最小緩衝以降低延遲
```
開啟順序:
1. 優先嘗試 DirectShow backend`cv2.CAP_DSHOW`
2. 失敗則嘗試預設 backend
3. 兩者都失敗則等 1 秒後重試(最多 3 次)
幀格式轉換:
```
BGROpenCV 預設) → RGBcv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
→ QImage.Format_RGB888
→ change_pixmap_signal.emit(qt_image.copy())
```
---
### 1.9 DeviceService
**路徑**`src/services/device_service.py`
#### Class: EmptyDescriptor
```python
class EmptyDescriptor:
device_descriptor_number: int = 0
device_descriptor_list: list = []
```
#### 函數
```python
def check_available_device(timeout: float = 5.0) -> kp.DeviceDescriptors | EmptyDescriptor:
"""
在 daemon thread 中執行 kp.core.scan_devices(),設 timeout=5.0 秒。
Returns:
- 成功kp.DeviceDescriptors含 device_descriptor_number 和 device_descriptor_list
- 失敗/Timeout/無裝置EmptyDescriptordevice_descriptor_number = 0
注意事項:
- 使用 threading.Thread非 QThread
- thread 設為 daemon=True即使 timeout 後 thread 仍可能繼續執行
"""
```
---
### 1.10 FileService
**路徑**`src/services/file_service.py`
#### Class: FileService
```python
class FileService:
main_window: QWidget
upload_dir: str # = UPLOAD_DIR%LOCALAPPDATA%/Kneron_Academy/uploads
destination: str | None # 最近上傳的檔案完整路徑
_camera_was_active: bool
```
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `upload_file()` | — | str \| None | 開啟檔案選擇器,複製檔案,觸發推論;回傳目的路徑 |
| `show_message(icon, title, message)` | — | None | 顯示自訂樣式的 QMessageBox |
#### upload_file() 完整流程
```
1. 若相機正在運行:
a. 暫停推論toggle_inference_pause()
b. 斷開 change_pixmap_signal保留 VideoThread不停止
2. QFileDialog.getOpenFileName()
3. 若使用者取消 → return None
4. 確認/建立 upload_dir
5. 檢查來源檔案存在
6. 測試目的地寫入權限
7. shutil.copy2(source, destination)
8. 更新 main_window.destination
9. 若 inference_controller.current_tool_config 存在:
a. 讀取並顯示圖片於 canvas_label
b. inference_controller.process_uploaded_image(destination)
10. finally若相機之前活動重新連接 Signal 並恢復推論
```
---
### 1.11 ConfigUtils
**路徑**`src/utils/config_utils.py`
#### Class: ConfigUtils
| 方法 | 參數 | 回傳 | 說明 |
|------|------|------|------|
| `generate_global_config()` | — | dict | 掃描 UTILS_DIR產生 config.json |
| `create_model_config_template(model_path)` | model_path: str | bool | 為指定模型目錄建立 config.json 範本 |
#### generate_global_config() 輸出範例
```json
{
"plugins": [
{
"mode": "object_detection",
"display_name": "Object Detection",
"models": [
{
"name": "yolov5_coco",
"display_name": "Yolov5 Coco",
"description": "YOLOv5 COCO object detection",
"compatible_devices": ["KL520", "KL720"]
}
]
}
]
}
```
---
### 1.12 image_utils
**路徑**`src/utils/image_utils.py`
```python
def qimage_to_numpy(qimage: QImage) -> np.ndarray:
"""
將 QImage 轉換為 numpy array。
先強制轉換為 QImage.Format_RGB888確保格式一致
Returns:
np.ndarray形狀 (H, W, 3)dtype=uint8RGB 格式
注意:使用 qimage.bits() 直接存取記憶體,需確保 qimage 在 array 使用期間有效。
"""
```
---
## 2. InferenceWorkerThread 與 script.py 介面規範
### 2.1 script.py 必須實作的函數
每個 Plugin 的 `script.py` **必須** 實作以下函數:
```python
def inference(frame: np.ndarray, params: dict) -> dict | None:
...
```
`InferenceWorkerThread` 呼叫方式:
```python
result = self.inference_module.inference(frame, params=self.input_params)
```
### 2.2 `frame` 參數規格
| 屬性 | 值 |
|------|-----|
| 型別 | `np.ndarray` |
| 形狀 | `(H, W, 3)` |
| dtype | `uint8` |
| 色彩空間 | RGBVideoThread 輸出)|
| 典型尺寸 | 640 x 480 |
### 2.3 `params` 字典完整 Keys
以下 keys 由 `InferenceController.select_tool()` 注入,加上 model `config.json``input_parameters`
| Key | 型別 | 必填 | 說明 |
|-----|------|------|------|
| `device_group` | kp.DeviceGroup \| None | 是 | kp SDK 連接的裝置群組 |
| `usb_port_id` | int | 是 | USB port 編號 |
| `scpu_path` | str | 否 | SCPU firmware 完整路徑 |
| `ncpu_path` | str | 否 | NCPU firmware 完整路徑 |
| `model` | str | 否 | .nef 模型完整路徑(若 config.json 有 model_file |
| `model_descriptor` | kp.ModelDescriptor \| None | 否 | 已上傳的模型描述(若有 model_file |
| `file_path` | str | 否 | image/voice 模式的上傳檔案路徑 |
| (自訂)| any | 否 | 來自 model config.json 的 `input_parameters` 欄位 |
### 2.4 回傳值格式
#### 格式 A無 Bounding Box分類/其他結果)
觸發 `QMessageBox` 顯示:
```python
return {
"result": "class_name",
# 任意 key-value 對
}
```
#### 格式 B單一 Bounding Box舊格式仍相容
直接繪製於畫面:
```python
return {
"bounding box": [x1, y1, x2, y2], # 整數,像素座標
"result": "class_label" # 可選
}
```
#### 格式 C多個 Bounding Box推薦格式
直接繪製於畫面:
```python
return {
"bounding boxes": [ # 注意 key 有空格
[x1, y1, x2, y2], # 整數,像素座標
[x1, y1, x2, y2],
...
],
"results": [ # 可選,與 bounding boxes 對應
"label1",
"label2",
...
]
}
```
#### 回傳 None
表示此幀跳過,不更新 Bounding Box不顯示 QMessageBox。
---
## 3. 裝置連接 API 流程kp SDK 呼叫順序)
### 3.1 DeviceController.connect_device() 流程
```python
# 1. 掃描(在 refresh_devices() 時已完成)
descriptors = kp.core.scan_devices()
# descriptors.device_descriptor_number: int
# descriptors.device_descriptor_list: list
# 2. 連接
device_group = kp.core.connect_devices(usb_port_ids=[usb_port_id])
# 3. 上傳 firmware
kp.core.load_firmware_from_file(
device_group=device_group,
scpu_fw_path=scpu_path, # 完整路徑
ncpu_fw_path=ncpu_path # 完整路徑
)
# 4. 上傳模型(在 InferenceController.select_tool() 中,非 DeviceController
model_descriptor = kp.core.load_model_from_file(
device_group=device_group,
file_path=model_file_path
)
```
### 3.2 CustomInferenceWorkerThread.initialize_device() 流程
```python
# 完整的連接 + firmware + model 上傳
device_group = kp.core.connect_devices(usb_port_ids=[port_id])
kp.core.set_timeout(device_group=device_group, milliseconds=5000)
kp.core.load_firmware_from_file(device_group, scpu_path, ncpu_path)
model_descriptor = kp.core.load_model_from_file(device_group, file_path=model_path)
```
### 3.3 推論 API 呼叫順序CustomInferenceWorkerThread
```python
# 建立推論描述符
descriptor = kp.GenericImageInferenceDescriptor(
model_id=model_descriptor.models[0].id,
inference_number=0,
input_node_image_list=[
kp.GenericInputNodeImage(
image=img_bgr565,
image_format=kp.ImageFormat.KP_IMAGE_FORMAT_RGB565,
resize_mode=kp.ResizeMode.KP_RESIZE_ENABLE,
padding_mode=kp.PaddingMode.KP_PADDING_CORNER,
normalize_mode=kp.NormalizeMode.KP_NORMALIZE_KNERON
)
]
)
# 非同步發送推論請求
kp.inference.generic_image_inference_send(device_group, descriptor)
# 接收推論結果(阻塞式)
result = kp.inference.generic_image_inference_receive(device_group)
# result.header.num_output_node: int
# result.header.hw_pre_proc_info_list: list
# 取得每個輸出節點的 float 值
for node_idx in range(result.header.num_output_node):
node_output = kp.inference.generic_inference_retrieve_float_node(
node_idx=node_idx,
generic_raw_result=result,
channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW
)
# node_output.shape: (batch, channels, height, width)
# node_output.ndarray: numpy array
```
### 3.4 中斷連接
```python
kp.core.disconnect_devices(device_group=device_group)
```
---
## 4. 推論 Queue 設計
### 4.1 基本規格
| 屬性 | 值 |
|------|-----|
| 類型 | `queue.Queue` |
| maxsize | **5** |
| 生產者 | `MediaController.update_image()`VideoThread 幀)、`InferenceController.process_uploaded_image()`(圖片)、`InferenceController.select_tool()`(初始圖片) |
| 消費者 | `InferenceWorkerThread.run()``CustomInferenceWorkerThread.run()` |
### 4.2 滿了怎麼辦drop policy
當 queue 滿時,**新幀被靜默丟棄**
```python
# add_frame_to_queue() 的邏輯
if not self.inference_queue.full():
self.inference_queue.put(frame)
else:
print("Warning: inference queue is full") # 只有 printUI 不更新
```
**⚠️ 問題**:這是 `put_nowait` 語意,但實際上是 `if not full` 檢查後再 `put`,在多執行緒環境中存在微小的 race windowcheck-then-act
### 4.3 清空時機
以下情況會清空 queue
| 觸發 | 方法 | 說明 |
|------|------|------|
| 切換工具 | `InferenceController.select_tool()` 開頭 | 避免舊工具的幀被新 Worker 處理 |
| 切換自定義工具 | `InferenceController.select_custom_tool()` 開頭 | 同上 |
| 上傳圖片 | `InferenceController.process_uploaded_image()` 開頭 | 確保只處理最新上傳的圖片 |
### 4.4 Worker 從 Queue 取幀的行為
```python
frame = self.frame_queue.get(timeout=0.1)
# - 若 0.1 秒無幀,拋出 queue.Empty → continue
# - 不 blocking僅等 0.1 秒)
# - 不呼叫 task_done()queue 未使用 join 功能)
```
---
## 5. Signal/Slot 對應表
### 5.1 頁面導航 SignalAppController 層)
| Signal | 發出 Class | Slot | 說明 |
|--------|-----------|------|------|
| `open_utilities` | SelectionScreen | AppController.show_login_screen | 點擊 Utilities 卡片 |
| `open_demo_app` | SelectionScreen | AppController.show_demo_app | 點擊 Demo App 卡片 |
| `login_success` | LoginScreen | AppController.show_utilities_screen | 登入成功 |
| `back_to_selection` | LoginScreen | AppController.show_selection_screen | 點擊 Back |
| `back_to_selection` | UtilitiesScreen | AppController.show_selection_screen | 點擊 Back |
### 5.2 媒體與推論 SignalMainWindow 層)
| Signal | 型別 | 發出 Class | Slot | 說明 |
|--------|------|-----------|------|------|
| `change_pixmap_signal` | QImage | VideoThread | MediaController.update_image | 相機每幀 |
| `camera_error_signal` | str | VideoThread | **未連接** | ⚠️ 無 Slot 接收 |
| `inference_result_signal` | object | InferenceWorkerThread | MainWindow.handle_inference_result | 推論完成 |
| `inference_result_signal` | object | CustomInferenceWorkerThread | MainWindow.handle_inference_result | 推論完成 |
**⚠️ 注意**`VideoThread.camera_error_signal` 目前沒有任何 Slot 連接,相機錯誤不會通知 UI。
### 5.3 UtilitiesScreen 內部 Signal
| Signal | 發出 Class | 說明 |
|--------|-----------|------|
| `back_to_selection` | UtilitiesScreen | 點擊 Back 按鈕 |
---
## 6. config.json 完整 Schema 定義
### 6.1 全域 config.jsonSCRIPT_CONFIG
路徑:`%LOCALAPPDATA%/Kneron_Academy/utils/config.json`
`ConfigUtils.generate_global_config()` 自動產生,不應手動修改。
```json
{
"plugins": [
{
"mode": "string", // 模式目錄名稱(如 "object_detection"
"display_name": "string", // UI 顯示名稱(自動從 mode 名稱產生)
"models": [
{
"name": "string", // 模型目錄名稱(如 "yolov5_coco"
"display_name": "string", // UI 顯示名稱(來自 model config.json
"description": "string", // 描述(來自 model config.json
"compatible_devices": ["string"] // 如 ["KL520", "KL720"]
}
]
}
]
}
```
### 6.2 模型層級 config.json
路徑:`%LOCALAPPDATA%/Kneron_Academy/utils/{mode}/{model_name}/config.json`
由 Plugin 開發者提供。
```json
{
"display_name": "string", // 必填UI 顯示名稱
"description": "string", // 選填:模型描述
"model_file": "string", // 選填:.nef 檔名(若有則 InferenceController 自動上傳)
"input_info": {
"type": "video" | "image" | "voice", // 必填:輸入類型
"supported_formats": ["string"] // 選填:支援的格式(目前主要用於文件)
},
"input_parameters": {
// 選填:任意 key-value會被合併進 input_params 傳給 script.py
"threshold": 0.5,
// 可包含任何 script.py 需要的參數
},
"compatible_devices": ["string"] // 必填:如 ["KL520", "KL720"]
}
```
### 6.3 ConfigUtils.create_model_config_template() 產生的範本
```json
{
"display_name": "Model Name",
"description": "AI model for model_name",
"model_file": "model_name.nef",
"input_info": {
"type": "video",
"supported_formats": ["mp4", "avi"]
},
"input_parameters": {
"threshold": 0.5
},
"compatible_devices": ["KL520", "KL720"]
}
```
---
## 7. 已知限制與邊界條件
### 7.1 裝置相關限制
| 限制 | 描述 |
|------|------|
| 僅支援 KL520 / KL720 | `DongleModelMap` 只有這兩個映射;其他型號被標注為 "unknown" |
| 單一裝置 | `connect_devices(usb_port_ids=[single_id])`,不支援多裝置並行推論 |
| Firmware 必須預先放置 | `connect_device()` 不自動下載 firmware`%LOCALAPPDATA%/Kneron_Academy/firmware/{dongle}/` 下不存在,回傳 False |
| kp SDK timeout | `CustomInferenceWorkerThread.initialize_device()` 設定 timeout=5000ms`DeviceController.connect_device()` 未設定 timeout |
### 7.2 相機相關限制
| 限制 | 描述 |
|------|------|
| 僅支援 camera index 0 | `cv2.VideoCapture(0, cv2.CAP_DSHOW)` — 硬體寫死 |
| 最多嘗試 3 次 | `_max_attempts=3`;若 3 次後仍無法開啟Worker 靜默退出,無 UI 通知 |
| 解析度固定 640x480 | `cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)` 固定設定 |
| camera_error_signal 未連接 | 相機錯誤只有 print使用者不知道相機失敗 |
### 7.3 推論相關限制
| 限制 | 描述 |
|------|------|
| Custom Model 僅支援 YOLOv5 | `CustomInferenceWorkerThread` 的後處理 hardcode 了 YOLOv5 的 anchor 和 NMS 邏輯 |
| COCO 80 類 | 預設使用 COCO 80 類別;需自定義類別時必須傳入 `custom_labels` |
| Voice 模式未完整實作 | `input_info.type = "voice"` 的路徑只傳 `file_path`,無音訊處理邏輯在主程式 |
| Image 模式一次只能處理一張 | `once_mode=True` 後 Worker 自動停止,需重新建立才能推論下一張 |
| MSE 比較跨 frame 尺寸變化 | 若相機解析度切換MSE 比較因形狀不一致而 reset功能正確但可能不符預期 |
| Script module 快取問題 | `load_inference_module()` 每次切換工具都重新 `exec_module()`若同一模組切換多次Python module cache 可能有舊版本 |
### 7.4 Plugin 開發注意事項
| 事項 | 說明 |
|------|------|
| script.py 執行在 Worker Thread 中 | 不可在 script.py 中直接呼叫 PyQt UI 方法 |
| script.py 不應長時間阻塞 | 若推論超過 `min_interval`,下一幀會等待 queue 中的幀累積 |
| Bounding Box 座標系 | 座標為相對於 `frame`QImage 轉出的 numpy array的像素座標若輸入解析度是 640x480座標範圍為 0-639 / 0-479 |
| script.py 例外處理 | `InferenceWorkerThread``try/except` 會捕捉 `script.inference()` 的例外,設 result=None 並繼續;不會中斷 Worker |
### 7.5 打包相關邊界條件
| 條件 | 問題 |
|------|------|
| PyInstaller + kp SDK | kp 為 C Extension需在 `.spec` 中手動設定 `hiddenimports` |
| UXUI_ASSETS 路徑 | 開發模式下透過 `__file__` 計算;打包後需改用 `sys._MEIPASS` |
| `FIRMWARE_PATHS` 常數 | 使用相對路徑,打包後無效;實際使用的是 `FW_DIR` + dongle 名稱(正確) |
| script.py 動態載入 | Plugin 的 `script.py` 必須放在 `%LOCALAPPDATA%`(外部目錄),不能打包進 exe |