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

32 KiB
Raw Permalink Blame History

技術設計文件TDD— KNEO Academy v2.0

作者Architect Agent
狀態Draft從既有程式碼反向整理
日期2026-04-04
對應 Design Doc04-architecture/design-doc.md


目錄

  1. 模組 API 規格
  2. InferenceWorkerThread 與 script.py 介面規範
  3. 裝置連接 API 流程kp SDK 呼叫順序)
  4. 推論 Queue 設計
  5. Signal/Slot 對應表
  6. config.json 完整 Schema 定義
  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

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

DongleModelMap = {
    "0x100": "KL520",
    "0x720": "KL720",
}
# 注意key 為 lowercase hex string含 0x 前綴),如 "0x100"

DongleIconMapproduct_id → icon 檔名)

DongleIconMap = {
    "0x100": "ic_dongle_520.png",
    "0x720": "ic_dongle_720.png",
}

1.2 AppControllermain.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 掃描裝置並更新 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 元素格式

{
    "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_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 期望格式

{
    "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: InferenceWorkerThreadQThread

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=Falsewait()

⚠️ 注意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 = Falsebreak
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/縮放補償
    - 分數閾值過濾
    - NMSper-classIoU threshold=0.5
    回傳 ExampleYoloResult
    """

Class: CustomInferenceWorkerThreadQThread

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=Falsewait(),然後 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: VideoThreadQThread

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=Falsewait()

相機設定

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 backendcv2.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

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/無裝置EmptyDescriptordevice_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=uint8RGB 格式
    
    注意:使用 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
色彩空間 RGBVideoThread 輸出)
典型尺寸 640 x 480

2.3 params 字典完整 Keys

以下 keys 由 InferenceController.select_tool() 注入,加上 model config.jsoninput_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")  # 只有 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 取幀的行為

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() 自動產生,不應手動修改。

{
  "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=5000msDeviceController.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 座標系 座標為相對於 frameQImage 轉出的 numpy array的像素座標若輸入解析度是 640x480座標範圍為 0-639 / 0-479
script.py 例外處理 InferenceWorkerThreadtry/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