# 技術設計文件(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 [AppController(main.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 對應) ``` #### DongleModelMap(product_id → model name) ```python DongleModelMap = { "0x100": "KL520", "0x720": "KL720", } # 注意:key 為 lowercase hex string(含 0x 前綴),如 "0x100" ``` #### DongleIconMap(product_id → icon 檔名) ```python DongleIconMap = { "0x100": "ic_dongle_520.png", "0x720": "ic_dongle_720.png", } ``` --- ### 1.2 AppController(main.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 | 掃描裝置並更新 UI;True = 找到裝置 | | `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_queue(maxsize=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: InferenceWorkerThread(QThread) ```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 = False,break 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/縮放補償 - 分數閾值過濾 - NMS(per-class,IoU threshold=0.5) 回傳 ExampleYoloResult """ ``` #### Class: CustomInferenceWorkerThread(QThread) ```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: VideoThread(QThread) ```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 次) 幀格式轉換: ``` BGR(OpenCV 預設) → RGB(cv2.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/無裝置:EmptyDescriptor(device_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=uint8,RGB 格式 注意:使用 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` | | 色彩空間 | RGB(VideoThread 輸出)| | 典型尺寸 | 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") # 只有 print,UI 不更新 ``` **⚠️ 問題**:這是 `put_nowait` 語意,但實際上是 `if not full` 檢查後再 `put`,在多執行緒環境中存在微小的 race window(check-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 頁面導航 Signal(AppController 層) | 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 媒體與推論 Signal(MainWindow 層) | 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.json(SCRIPT_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 |