32 KiB
技術設計文件(TDD)— KNEO Academy v2.0
作者:Architect Agent
狀態:Draft(從既有程式碼反向整理)
日期:2026-04-04
對應 Design Doc:04-architecture/design-doc.md
目錄
- 模組 API 規格
- 1.1 config.py — 全域設定
- 1.2 AppController(main.py)
- 1.3 DeviceController
- 1.4 InferenceController
- 1.5 MediaController
- 1.6 InferenceWorkerThread
- 1.7 CustomInferenceWorkerThread
- 1.8 VideoThread
- 1.9 DeviceService
- 1.10 FileService
- 1.11 ConfigUtils
- 1.12 image_utils
- InferenceWorkerThread 與 script.py 介面規範
- 裝置連接 API 流程(kp SDK 呼叫順序)
- 推論 Queue 設計
- Signal/Slot 對應表
- config.json 完整 Schema 定義
- 已知限制與邊界條件
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
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)
DongleModelMap = {
"0x100": "KL520",
"0x720": "KL720",
}
# 注意:key 為 lowercase hex string(含 0x 前綴),如 "0x100"
DongleIconMap(product_id → icon 檔名)
DongleIconMap = {
"0x100": "ic_dongle_520.png",
"0x720": "ic_dongle_720.png",
}
1.2 AppController(main.py)
路徑:main.py
Class: AppController
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
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 元素格式
{
"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
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 期望格式
{
"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
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
模組函數
def load_inference_module(mode: str, model_name: str) -> module:
"""
動態載入 {UTILS_DIR}/{mode}/{model_name}/script.py 並回傳 module 物件。
若檔案不存在,importlib 會拋出 FileNotFoundError。
"""
Class: InferenceWorkerThread(QThread)
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 秒):
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
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
模組函數
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)
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
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() 回傳格式
{
"num_boxes": int,
"bounding boxes": [[x1, y1, x2, y2], ...], # 注意:key 有空格
"results": ["label1", "label2", ...]
}
⚠️ 注意:key 為 "bounding boxes"(有空格),MainWindow.handle_inference_result() 中對應的處理 key 也是 "bounding boxes"(有空格),需保持一致。
YOLOv5 後處理常數
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)
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() |
相機設定
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) # 最小緩衝以降低延遲
開啟順序:
- 優先嘗試 DirectShow backend(
cv2.CAP_DSHOW) - 失敗則嘗試預設 backend
- 兩者都失敗則等 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
class EmptyDescriptor:
device_descriptor_number: int = 0
device_descriptor_list: list = []
函數
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
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() 輸出範例
{
"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
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 必須 實作以下函數:
def inference(frame: np.ndarray, params: dict) -> dict | None:
...
InferenceWorkerThread 呼叫方式:
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 顯示:
return {
"result": "class_name",
# 任意 key-value 對
}
格式 B:單一 Bounding Box(舊格式,仍相容)
直接繪製於畫面:
return {
"bounding box": [x1, y1, x2, y2], # 整數,像素座標
"result": "class_label" # 可選
}
格式 C:多個 Bounding Box(推薦格式)
直接繪製於畫面:
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() 流程
# 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() 流程
# 完整的連接 + 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)
# 建立推論描述符
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 中斷連接
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 滿時,新幀被靜默丟棄:
# 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 取幀的行為
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() 自動產生,不應手動修改。
{
"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 開發者提供。
{
"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() 產生的範本
{
"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 |