971 lines
32 KiB
Markdown
971 lines
32 KiB
Markdown
# 技術設計文件(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 |
|