#!/usr/bin/env python3 """Kneron Bridge - JSON-RPC over stdin/stdout This script acts as a bridge between the Go backend and the Kneron PLUS Python SDK. It reads JSON commands from stdin and writes JSON responses to stdout. Supports: - KL520 (USB Boot mode - firmware must be loaded each session) - KL720 (flash-based - firmware pre-installed, models freely reloadable) """ import sys import json import base64 import time import os import io import numpy as np def _force_utf8_stdio(): """把 stdin / stdout / stderr 一律綁成 UTF-8,不理會系統預設編碼。 為什麼需要這支(Windows 中文亂碼根因): Windows 的 Python 把 stdio 綁到「系統 ANSI code page」而非 UTF-8。繁體 中文 Windows 的 ANSI code page 是 cp950。而本 bridge 的 JSON-RPC 兩個 方向並不對稱: Go → Python (stdin):Go 的 `encoding/json` **不** escape 非 ASCII, `{"labels":["布"]}` 在 wire 上就是 raw UTF-8 位元組 e5 b8 83。 以 cp950 解碼會得到 `撣` + U+FFFD(使用者截圖的亂碼), 嚴格模式下則直接 UnicodeDecodeError 讓整個 bridge 掛掉。 Python → Go (stdout):`json.dumps` 預設 ensure_ascii=True,中文被 escape 成 \\uXXXX 純 ASCII,所以這條路「目前」剛好沒壞。但那是 隱性依賴 —— 任何人加上 ensure_ascii=False 就會壞。這裡一併綁定, 把「stdout 是 UTF-8」變成顯式契約而非巧合。 stderr:`_log()` 的中文訊息與 em dash(U+2014)等字元現在就會壞 (使用者先前看到的 `�X SDK did not report` 即為此)。 為什麼用 errors="replace" 而非預設的 "strict": 這是診斷用的 log 通道與協定通道。遇到極端的無效位元組時,我們寧可看到 一個 U+FFFD 也不要讓整個 bridge 因為一行 log 而崩潰 —— bridge 掛掉 會讓裝置直接失聯,比一個壞字元嚴重得多。stdin 側同理:Go 端送出的一定 是合法 UTF-8,replace 只是最後一道防線。 為什麼在 module import 時就呼叫(而不是在 main() 裡): 必須早於任何 I/O。main() 會 os.dup() stdout、import 期間的例外也會經由 stderr 輸出,兩者都得在編碼已經正確之後才發生。 Python 3.7+ 才有 TextIOWrapper.reconfigure()。專案用的是 python-build-standalone 3.12,安全;仍以 hasattr 做防禦, 在極舊環境下退化成 no-op 而不是 crash。 """ for name in ("stdin", "stdout", "stderr"): stream = getattr(sys, name, None) if stream is None: continue # pythonw / 被重導向到 None 的情境 try: if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8", errors="replace") except (AttributeError, ValueError, OSError): # 已被換成非 TextIOWrapper(例如測試的 StringIO)或已關閉。 # 這不是致命錯誤,維持原編碼繼續跑。 pass _force_utf8_stdio() def _preload_kneron_dylibs_macos(): """macOS 專用:用絕對路徑預先 dlopen wheel 內的 libusb + libkplus。 背景: - KneronPLUS wheel 把 libusb-1.0.0.dylib + libkplus.dylib 放在 kp/lib/。 - macOS dyld 在載入 libkplus 時會去找它的相依 libusb-1.0.0.dylib。 預設搜尋路徑(/usr/local/lib、/usr/lib)在 bundled Python 環境下通常 找不到(我們沒有 brew libusb),於是 `import kp` 就拋 OSError → HAS_KP=False → scan 回空陣列。 - macOS hardened runtime 會剝掉 DYLD_LIBRARY_PATH 等環境變數,所以 改從 Go 端注入 env 也不保險;最穩的做法是在 Python 這端用 ctypes 以絕對路徑先載入,後續 `import kp` 時 dyld 會重用已載入的映像。 Windows / Linux 不走這支 — 各自機制已在 Go 端處理(Windows 靠 PATH、 Linux 靠 wheel 自帶的 libusb.so.1.0.0 + LD_LIBRARY_PATH)。 """ if sys.platform != "darwin": return try: import ctypes import importlib.util spec = importlib.util.find_spec("kp") if spec is None or not spec.submodule_search_locations: return kp_dir = spec.submodule_search_locations[0] lib_dir = os.path.join(kp_dir, "lib") # 載入順序:先 libusb,再 libkplus(libkplus 相依 libusb) for name in ("libusb-1.0.0.dylib", "libkplus.dylib"): path = os.path.join(lib_dir, name) if os.path.isfile(path): try: ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL) except OSError: pass except Exception: pass _preload_kneron_dylibs_macos() try: import kp HAS_KP = True except (ImportError, AttributeError, Exception): HAS_KP = False try: import usb.core HAS_PYUSB = True except ImportError: HAS_PYUSB = False try: import cv2 HAS_CV2 = True except ImportError: HAS_CV2 = False # ── Global state ────────────────────────────────────────────────────── _device_group = None def _clear_device_group(): """Safely disconnect and clear the global _device_group. KneronPLUS SDK's DeviceGroup.__del__ calls kp_disconnect_devices on the native handle, but if the handle is already invalid (failed connect / stale state) it causes 'OSError: access violation'. By explicitly disconnecting before setting None, __del__ becomes a no-op on an already-disconnected handle. All errors are silenced — this is best-effort cleanup. """ global _device_group if _device_group is not None: try: kp.core.disconnect_devices(_device_group) except Exception: pass _device_group = None _model_id = None _model_nef = None # _model_nef_path 是當前已載入 model 的 .nef 絕對路徑。 # 需要保留是因為 set_inference_options 在推論期切換解析方式時要重跑 # _detect_model_type —— 該函式用檔名推導 input size,沒有原始路徑就只能 # 退回預設 224,等於切換解析方式的副作用是偷改 input size。 _model_nef_path = "" _model_input_size = 224 # updated on model load # _model_input_width / _model_input_height 是「模型真正的輸入尺寸」,逐軸保存。 # # 為什麼要逐軸而不是沿用單一 _model_input_size:模型的輸入不保證是正方形 # (常見的 w256h192 之類)。舊版全靠檔名的 wNNNhNNN 猜、而且 _parse_size_from_name # 只取 width 丟掉 height,等於「非正方形模型一律被當成正方形」——尺寸錯了 # NPU 不會報錯,只會安靜地給出錯的推論結果。 # # _model_input_size 保留為「短邊」派生值,維持既有 detection post-process 與 # 對外 JSON 欄位的相容性(見 _set_model_input_size)。 _model_input_width = 224 _model_input_height = 224 # _model_input_size_source 記錄這次的尺寸「從哪來」,只作 log / 診斷用。 # 值域:見 INPUT_SIZE_SOURCE_*。 _model_input_size_source = "default" _model_type = "tiny_yolov3" # updated on model load based on model_id / nef name _firmware_loaded = False _device_chip = "KL520" # updated on connect from product_id / device_type # ── Model metadata injected by the Go layer on load_model ──────────── # 這兩個由 Go 端在 load_model payload 帶進來(M2)。在此之前 bridge 只能靠 # model id / 檔名猜 model type、對「檔名沒有關鍵字」的自訂模型必定猜錯 # (落到 tiny_yolov3 → 走 YOLO 解析 → 回空結果)。外部指定一律優先於猜測。 # # _task_type_override: "classification" | "object_detection" | None # _model_labels: list[str],index 對應 class index;None 表示未注入 _task_type_override = None _model_labels = None # _model_declared_input_size 是 models.json / metadata.json 宣告的 (width, height)。 # 保留是為了 set_inference_options —— 推論期切解析方式會重跑 _detect_model_type, # 沒有這份值就只能退回檔名猜測,等於「切解析方式」偷偷改掉了 input size。 _model_declared_input_size = None # 前端 / models.json / Go 端統一使用的 task type 值域。 # 注意:舊版 bridge 回傳的是 "detection",與 models.json 的 "object_detection" # 不一致(見 plan §7 R-4),此處統一為 object_detection。 TASK_TYPE_CLASSIFICATION = "classification" TASK_TYPE_OBJECT_DETECTION = "object_detection" # classification top-K 預設值(可由 load_model / inference 參數覆寫) DEFAULT_CLASSIFICATION_TOP_K = 5 # 判定「模型輸出是否已是機率分佈」的容差(見 _looks_like_probabilities)。 # # 為何是 1e-4 而不是更寬鬆的 0.01:真正經過 softmax 的 float32 輸出,其總和 # 與 1.0 的偏差來自浮點捨入、實測(C=2..1000、常態 logits)最壞約 4e-7, # 比 1e-4 還小兩個數量級 —— 容差開到 0.01 只會放進誤判、不會救回任何真的 # 機率向量。實測非負 logits 被誤判的機率:C=3 時 0.4%(0.01)→ 0.003%(1e-4)。 PROB_SUM_TOLERANCE = 1e-4 # COCO 80-class labels COCO_CLASSES = [ "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush" ] # Anchor boxes per model type (each list entry = one output head) ANCHORS_TINY_YOLOV3 = [ [(81, 82), (135, 169), (344, 319)], # 7×7 head (large objects) [(10, 14), (23, 27), (37, 58)], # 14×14 head (small objects) ] # YOLOv5s anchors (Kneron model 20005, no-upsample variant for KL520) ANCHORS_YOLOV5S = [ [(116, 90), (156, 198), (373, 326)], # P5/32 (large) [(30, 61), (62, 45), (59, 119)], # P4/16 (medium) [(10, 13), (16, 30), (33, 23)], # P3/8 (small) ] CONF_THRESHOLD = 0.25 NMS_IOU_THRESHOLD = 0.45 # Known Kneron model IDs → (model_type, input_size) KNOWN_MODELS = { # Tiny YOLO v3 (default KL520 model) 0: ("tiny_yolov3", 224), # ResNet18 classification (model 20001) 20001: ("resnet18", 224), # FCOS DarkNet53s detection (model 20004) 20004: ("fcos", 512), # YOLOv5s no-upsample (model 20005) 20005: ("yolov5s", 640), } def _log(msg): """Write log messages to stderr (stdout is reserved for JSON-RPC).""" print(f"[kneron_bridge] {msg}", file=sys.stderr, flush=True) def _resolve_firmware_paths(chip="KL520"): """Resolve firmware paths relative to this script's directory. Returns (scpu_path, ncpu_path) tuple for backward compat with existing handle_connect() callers. Use _resolve_firmware_paths_full(chip) to get loader path additionally (only KL520 has fw_loader.bin in A 階段). """ base = os.path.dirname(os.path.abspath(__file__)) fw_dir = os.path.join(base, "firmware", chip) scpu = os.path.join(fw_dir, "fw_scpu.bin") ncpu = os.path.join(fw_dir, "fw_ncpu.bin") if os.path.exists(scpu) and os.path.exists(ncpu): return scpu, ncpu # Fallback: check KNERON_FW_DIR env var fw_dir = os.environ.get("KNERON_FW_DIR", "") if fw_dir: scpu = os.path.join(fw_dir, "fw_scpu.bin") ncpu = os.path.join(fw_dir, "fw_ncpu.bin") if os.path.exists(scpu) and os.path.exists(ncpu): return scpu, ncpu return None, None _FW_ALLOWED_CHIPS = ("KL520", "KL720") # A 階段範圍、Reviewer m1 雙重防護用 def _resolve_firmware_paths_full(chip="KL520"): """Resolve scpu / ncpu / loader paths. A 階段:只有 KL520 有 fw_loader.bin(用於 KDP1 legacy → KDP2 升級的 SDK loader stage)。KL720 不需要 loader(不走 SDK loader path、直接 ctypes 呼叫 kp_update_kdp_firmware_from_files 也不需要 loader 檔)。 Reviewer m1:對 chip 參數做雙重 allow-list 防護。chip 來自 JSON-RPC stdin、 雖然 caller (handle_firmware_upgrade) 已 enforce allow-list、但這裡再過一道 避免未來 caller 拓寬時破防。額外拒絕含 path separator / 父目錄 / 絕對路徑 的非法輸入、確保 os.path.join 絕不 traverse。 Returns: dict: {"scpu": , "ncpu": , "loader": , "version": } 若 scpu/ncpu 任一缺檔、scpu/ncpu 為 None。 """ # 雙重 allow-list 防護(caller 已過一次、這裡再過一次防 path traversal) if not isinstance(chip, str) or chip not in _FW_ALLOWED_CHIPS: return {"scpu": None, "ncpu": None, "loader": None, "version": None} # 額外字元防護(即使 _FW_ALLOWED_CHIPS 拓寬到不安全字串也擋) if "/" in chip or "\\" in chip or ".." in chip or os.path.isabs(chip): return {"scpu": None, "ncpu": None, "loader": None, "version": None} base = os.path.dirname(os.path.abspath(__file__)) fw_dir = os.path.join(base, "firmware", chip) scpu = os.path.join(fw_dir, "fw_scpu.bin") ncpu = os.path.join(fw_dir, "fw_ncpu.bin") loader = os.path.join(fw_dir, "fw_loader.bin") version_file = os.path.join(fw_dir, "VERSION") result = {"scpu": None, "ncpu": None, "loader": None, "version": None} if os.path.exists(scpu) and os.path.exists(ncpu): result["scpu"] = scpu result["ncpu"] = ncpu if os.path.exists(loader): result["loader"] = loader if os.path.exists(version_file): try: with open(version_file, "r", encoding="utf-8") as f: result["version"] = f.read().strip() except Exception: pass # Fallback: KNERON_FW_DIR env var if result["scpu"] is None or result["ncpu"] is None: env_dir = os.environ.get("KNERON_FW_DIR", "") if env_dir: scpu2 = os.path.join(env_dir, "fw_scpu.bin") ncpu2 = os.path.join(env_dir, "fw_ncpu.bin") if os.path.exists(scpu2) and os.path.exists(ncpu2): result["scpu"] = scpu2 result["ncpu"] = ncpu2 loader2 = os.path.join(env_dir, "fw_loader.bin") if os.path.exists(loader2): result["loader"] = loader2 return result # ── Input size 來源標記 ─────────────────────────────────────────────── # # 依可信度排序(愈前面愈可信)。實機驗收時 log 會印出用的是哪一個 —— # 這是唯一能一眼看出「尺寸是怎麼來的」的線索,尺寸錯掉時 NPU 不一定報錯, # 可能只是安靜地給出錯的推論結果。 # # ⚠️ declared 為什麼**不是**第二可信(2026-07 Windows regression 的根因): # 上傳表單的 inputSize 欄位長期沒有實際作用,使用者是「隨手填」的 # (實際案例:填 640x640,模型其實是 224x224)。而 KneronPLUS 3.1.2 不再 # 提供 2.0.0 的 shape_onnx 屬性(見 _input_size_from_nef),SDK 這層在 # Windows 直接落空 —— 於是垃圾 declared 值成為實際採用值,送進 NPU 得到 # KP_ERROR_INVALID_PARAM_12。相對地,檔名的 wNNNhNNN 是模型編譯工具鏈 # 產生的,沒有人為亂填的空間,**明確解析出來時**比 declared 可信。 INPUT_SIZE_SOURCE_SDK = "SDK" # 模型自己宣告的,唯一完全可靠 INPUT_SIZE_SOURCE_FILENAME = "filename" # 檔名 wNNNhNNN 明確解析出的,工具鏈產生 INPUT_SIZE_SOURCE_DECLARED = "declared" # models.json / metadata.json,人手填的 INPUT_SIZE_SOURCE_KNOWN_ID = "known-model-id" # 內建 KNOWN_MODELS 表的官方固定值 INPUT_SIZE_SOURCE_DEFAULT = "default" # 什麼都沒有,寫死的預設值 # 可信度排序(僅供 log / 診斷描述用;實際流程由 _resolve_input_size 決定)。 INPUT_SIZE_SOURCE_RANK = { INPUT_SIZE_SOURCE_SDK: 0, INPUT_SIZE_SOURCE_FILENAME: 1, INPUT_SIZE_SOURCE_DECLARED: 2, INPUT_SIZE_SOURCE_KNOWN_ID: 3, INPUT_SIZE_SOURCE_DEFAULT: 4, } # 合理的 input 邊長範圍。用來擋掉明顯不合理的宣告值 / 解析結果 # (例如 shape 判讀錯把 batch=1 或 channel=3 當成邊長)。 MIN_REASONABLE_INPUT_DIM = 8 MAX_REASONABLE_INPUT_DIM = 8192 def _set_model_input_size(width, height, source): """Commit the resolved model input size to the globals. _model_input_size(單一純量)沿用「短邊」而非寬邊:它在 detection post-process 裡的用途是 `min_dim`(確保送進 NPU 的影像不小於模型輸入) 以及 anchor / bbox 正規化的除數。取短邊可保證兩軸都達標;取長邊會讓 短的那一軸不足。正方形模型(絕大多數)兩者相同、行為完全不變。 """ global _model_input_width, _model_input_height global _model_input_size, _model_input_size_source _model_input_width = int(width) _model_input_height = int(height) _model_input_size = min(_model_input_width, _model_input_height) _model_input_size_source = source def _is_reasonable_input_dim(value): """True if value looks like a plausible model input edge length.""" return (isinstance(value, int) and not isinstance(value, bool) and MIN_REASONABLE_INPUT_DIM <= value <= MAX_REASONABLE_INPUT_DIM) def _input_size_from_nef(nef, model_id=None): """Read the **real** input width/height from a loaded NEF descriptor. 這是唯一可靠的來源:模型自己知道它要多大的輸入。檔名可能沒有尺寸資訊、 使用者填的 inputSize 可能是隨手填的,兩者都會靜默地把圖縮到錯的尺寸。 來源是 KneronPLUS 公開 API(kp.ModelNefDescriptor): nef.models[i].input_nodes[j].shape_onnx / .shape_npu → List[int] shape 判讀規則(優先 shape_onnx,因為那是原始 ONNX 的語意): - 4 維 NCHW (1, 3, H, W) → 取後兩維。這是 KneronPLUS 對影像模型的常態。 - 4 維 NHWC (1, H, W, 3) → 最後一維是通道數時改取中間兩維。 兩者用「哪一個軸看起來像通道數(1/3/4)」區分。 無法可靠判讀時回 None(呼叫端會 fallback),**不猜** —— 猜錯等於用錯尺寸 推論,而那正是這個函式要解決的問題。 Args: nef: kp.ModelNefDescriptor(load_model_from_file 的回傳值) model_id: 指定要取哪個 model 的 input;None 表示取第一個。 一個 .nef 可以包多個 model。 Returns: (width, height) tuple,或 None。 """ try: models = list(nef.models) except Exception as e: _log(f"input size from SDK: cannot read nef.models ({type(e).__name__}: {e})") return None if not models: _log("input size from SDK: nef contains no models") return None model = None if model_id is not None: for candidate in models: try: if candidate.id == model_id: model = candidate break except Exception: continue if model is None: model = models[0] try: input_nodes = list(model.input_nodes) except Exception as e: _log(f"input size from SDK: cannot read input_nodes " f"({type(e).__name__}: {e})") return None if not input_nodes: _log("input size from SDK: model has no input nodes") return None node = input_nodes[0] candidates = _tensor_shape_candidates(node) if not candidates: _log("input size from SDK: input node exposes no readable shape " "(unsupported KneronPLUS layout?)") return None for label, shape in candidates: size = _input_size_from_shape(shape) if size is not None: _log(f"input size from SDK: {label}={shape} -> {size[0]}x{size[1]}") return size if shape: _log(f"input size from SDK: {label}={shape} not interpretable as " f"an image input shape") return None def _tensor_shape_candidates(node): """Collect candidate shapes from a TensorDescriptor, newest-API-aware. KneronPLUS 在 3.x 改了 TensorDescriptor 的結構,**欄位名沒改、但搬了一層**: 2.0.0(macOS 目前用的版本) TensorDescriptor.shape_onnx : List[int] TensorDescriptor.shape_npu : List[int] 3.1.2(Windows 目前用的版本) TensorDescriptor.tensor_shape_info.version : ModelTensorShapeInformationVersion TensorDescriptor.tensor_shape_info.data : TensorShapeInfoV1 | TensorShapeInfoV2 V1 → .shape_onnx / .shape_npu / .axis_permutation_onnx_to_npu V2 → .shape(docstring 明寫「ONNX shape of the tensor」) / .stride_onnx / .stride_npu 3.1.2 的 TensorDescriptor **沒有** shape_onnx 屬性,舊寫法的 `getattr(node, "shape_onnx")` 會拋 AttributeError、被 except 靜默吃掉, 整個 SDK 層等於永遠落空 —— 這就是 Windows「SDK did not report an input shape」的真正原因(不是模型沒帶 shape)。 回傳 [(label, shape), ...],依可信度排序。label 只作 log 用。 ONNX 語意一律優先於 NPU layout:後者可能有對齊 padding,拿來當輸入尺寸不準。 """ candidates = [] def take(label, value): if value is None: return try: shape = [int(dim) for dim in value] except (TypeError, ValueError): return if shape: candidates.append((label, shape)) # ── 3.x:巢狀 tensor_shape_info(先試,因為新版沒有平鋪欄位)────── info = getattr(node, "tensor_shape_info", None) if info is not None: data = getattr(info, "data", None) if data is not None: # V1 與 V2 的欄位名不重疊,直接各自試、不需判斷 version enum # (少一個跨版本 enum 相依,enum 名稱改了也不會整個壞掉)。 take("tensor_shape_info.data.shape_onnx", getattr(data, "shape_onnx", None)) take("tensor_shape_info.data.shape", getattr(data, "shape", None)) take("tensor_shape_info.data.shape_npu", getattr(data, "shape_npu", None)) # ── 2.x:平鋪在 TensorDescriptor 上 ──────────────────────────────── take("shape_onnx", getattr(node, "shape_onnx", None)) take("shape_npu", getattr(node, "shape_npu", None)) return candidates def _input_size_from_shape(shape): """Interpret a 4-D tensor shape as (width, height), or None. 只處理 4 維 —— 影像輸入必為 4 維(batch + 3 個空間/通道軸)。其他維度 (例如純向量輸入的模型)不是本函式能判讀的,回 None 讓呼叫端 fallback。 """ if not shape or len(shape) != 4: return None _, a, b, c = shape channel_like = (1, 3, 4) # NCHW: (N, C, H, W) —— KneronPLUS 對影像模型的常態 if a in channel_like and _is_reasonable_input_dim(b) and _is_reasonable_input_dim(c): return (c, b) # (width, height) # NHWC: (N, H, W, C) if c in channel_like and _is_reasonable_input_dim(a) and _is_reasonable_input_dim(b): return (b, a) # (width, height) return None def _detect_model_type(model_id, nef_path, task_type=None, nef=None, declared_input_size=None): """Detect model type and input size from model ID or .nef filename. Args: model_id: model id read from the loaded .nef. nef_path: path of the .nef file (used for filename heuristics). task_type: 外部(Go 層)指定的 task type。有指定時**一律優先**、 不做任何猜測。這是「自訂 classification 模型跑不出結果」的根因修正: 使用者上傳的模型檔名(如 model.nef / 1784536643_models_520.nef) 不含任何關鍵字,舊邏輯會落到 else 被誤判成 tiny_yolov3。 nef: 已載入的 kp.ModelNefDescriptor。有帶就從它取**真正的** input size。 declared_input_size: 外部宣告的 (width, height),來自 models.json / metadata.json 的 inputSize。只在 SDK 取不到時才用 —— 那是人填的, 使用者可能隨手填(實際案例:填 640x640 但模型是別的尺寸)。 task type 與 input size 是兩件獨立的事:task type 決定走哪條 post-process、 input size 決定圖片怎麼縮。外部指定 task type 時仍會照常解析 input size。 """ global _model_type # input size 三層來源,與 model type 的判斷完全獨立。 _resolve_input_size(model_id, nef_path, nef=nef, declared_input_size=declared_input_size) normalized_task = _normalize_task_type(task_type) if normalized_task == TASK_TYPE_CLASSIFICATION: # 先用既有邏輯決定 backbone(順帶會寫 input size),再強制覆寫 type。 _detect_model_type_by_heuristics(model_id, nef_path) _model_type = "classification" _log(f"Model type set by caller: classification " f"({_describe_input_size()})") return if normalized_task == TASK_TYPE_OBJECT_DETECTION: # detection 有多種 backbone(yolo / fcos / ssd…),外部只告訴我們 # 「這是 detection」、無法指出是哪一種,所以仍需 heuristics 決定 # 用哪個 post-process。但至少可以確定它不是 classification。 _detect_model_type_by_heuristics(model_id, nef_path) if _model_type == "classification" or _model_type == "resnet18": _log("Caller declared object_detection but heuristics said " "classification; falling back to tiny_yolov3 post-process") _model_type = "tiny_yolov3" return _detect_model_type_by_heuristics(model_id, nef_path) def _describe_input_size(): """One-line description of the current input size **and where it came from**. 實機驗收要能一眼看出尺寸是怎麼來的 —— 尺寸錯掉時 NPU 不會報錯, 只會安靜地給出錯的推論結果,這行 log 是第一個要看的線索。 """ desc = (f"input_size={_model_input_width}x{_model_input_height} " f"(source: {_model_input_size_source}") if _model_input_size_source == INPUT_SIZE_SOURCE_DECLARED: desc += ", hand-entered — verify if inference fails" elif _model_input_size_source == INPUT_SIZE_SOURCE_KNOWN_ID: desc += ", UNRELIABLE — guessed from a built-in model-id table" elif _model_input_size_source == INPUT_SIZE_SOURCE_DEFAULT: desc += ", UNRELIABLE — no size information available anywhere" return desc + ")" def _resolve_input_size(model_id, nef_path, nef=None, declared_input_size=None): """Resolve the model input size from the most trustworthy source available. 優先序(愈前面愈可信): 1. SDK — 模型自己宣告的 input tensor shape。唯一完全可靠:那是 模型編譯進 .nef 的事實,不是任何人的說法。 2. filename — 檔名 wNNNhNNN **明確解析出來**的值。由模型編譯工具鏈 產生(kl520_20004_fcos-drk53s_w512h512.nef 這種格式), 沒有人為亂填的空間。注意:只有真的解析到才算這一層, 解析不到不會退化成 backbone 預設值假裝有來源。 3. declared — models.json / metadata.json 的 inputSize,**使用者手填**。 4. known id — 內建 KNOWN_MODELS 表,Kneron 官方模型的固定尺寸。 5. default — 都沒有,backbone 慣用值(多為 224)。 ⚠️ 為什麼 declared 排在 filename **之後**(2026-07 Windows regression): 上傳表單的 inputSize 欄位過去長期沒有實際作用,既有資料裡的值大多是 使用者隨手填的垃圾(實際案例:填 640x640、模型其實 224x224)。把它排在 檔名之前,等於讓最不可信的來源壓過工具鏈產生的事實。 但也不能無腦把 declared 降到最後:使用者若是刻意填對的,它仍然比 「檔名沒有尺寸資訊時的寫死預設值」可信 —— 所以它排在 known id / default 之前,只讓位給 SDK 與檔名明確解析。 known id 排在 declared 之後而非之前:KNOWN_MODELS 只涵蓋 Kneron 官方 幾個內建模型,且那些檔名本來就帶 wNNNhNNN(會在第 2 層就命中);真正 落到這層的情境是「id 撞上官方編號但檔案已被換過」,此時使用者的宣告 反而比我們的內建表貼近現實。 本函式負責 1–3 層;4 與 5 由 _detect_model_type_by_heuristics 收尾 (它同時要決定 model type,順道寫入尺寸)。 """ global _model_input_size_source # 先降級成 default —— 否則上一個模型留下的較可信標記會讓 # _detect_model_type_by_heuristics 誤以為「已有更可信來源」而不敢寫, # 新模型就會沿用舊模型的尺寸(靜默、且只在換模型時才出現)。 _model_input_size_source = INPUT_SIZE_SOURCE_DEFAULT declared = _normalize_declared_input_size(declared_input_size) # ── 1. SDK:模型自己說的 ──────────────────────────────────────── if nef is not None: sdk_size = _input_size_from_nef(nef, model_id=model_id) if sdk_size is not None: _set_model_input_size(sdk_size[0], sdk_size[1], INPUT_SIZE_SOURCE_SDK) _log(f"Model input size resolved: {_describe_input_size()}") _warn_if_declared_disagrees(declared) return # ── 2. filename:工具鏈產生的 wNNNhNNN,**明確解析到**才算 ──────── basename = os.path.basename(nef_path).lower() if nef_path else "" from_name = _size_from_name_or_none(basename) if from_name is not None: _set_model_input_size(from_name[0], from_name[1], INPUT_SIZE_SOURCE_FILENAME) _log(f"Model input size resolved: {_describe_input_size()} " f"— SDK did not report an input shape, parsed from the filename") _warn_if_declared_disagrees(declared) return # ── 3. declared:外部(models.json / metadata.json)宣告的 ─────── if declared is not None: _set_model_input_size(declared[0], declared[1], INPUT_SIZE_SOURCE_DECLARED) _log(f"Model input size resolved: {_describe_input_size()} " f"— no SDK shape and no size in the filename, falling back to " f"the user-declared value (this value is hand-entered and is a " f"likely cause if inference fails with KP_ERROR_INVALID_PARAM)") return # ── 4 / 5. 交給 known id / 檔名 heuristics(它自己會寫 source)──── def _warn_if_declared_disagrees(declared): """Log when the user-declared size contradicts the source we actually used. 這行 log 是使用者「為什麼我填的尺寸沒有生效」的唯一線索。不改行為 —— declared 本來就該讓位給更可信的來源,但要讓人看得見它被讓位了。 """ if declared is None: return if (declared[0], declared[1]) == (_model_input_width, _model_input_height): return _log(f"Note: declared input size {declared[0]}x{declared[1]} " f"(hand-entered) disagrees with the resolved " f"{_model_input_width}x{_model_input_height} " f"(source: {_model_input_size_source}); the more trustworthy source " f"wins. Update the model's declared size if the declared one is right.") def _normalize_declared_input_size(declared): """Validate an externally declared input size. 接受 (width, height) tuple/list 或 {"width": w, "height": h} dict。 任一軸不合理就整組拒絕 —— 半套採用(例如寬對高錯)比整組不用更危險, 因為看起來像是有正確來源。 """ if declared is None: return None if isinstance(declared, dict): width = declared.get("width") height = declared.get("height") elif isinstance(declared, (tuple, list)) and len(declared) == 2: width, height = declared else: _log(f"Declared input size ignored: unsupported form {declared!r}") return None try: width = int(width) height = int(height) except (TypeError, ValueError): _log(f"Declared input size ignored: not integers ({declared!r})") return None if not _is_reasonable_input_dim(width) or not _is_reasonable_input_dim(height): _log(f"Declared input size ignored: {width}x{height} outside the " f"plausible range [{MIN_REASONABLE_INPUT_DIM}, " f"{MAX_REASONABLE_INPUT_DIM}]") return None return (width, height) def _normalize_task_type(task_type): """Normalize an externally supplied task type string. 接受 models.json / 前端使用的 object_detection、以及舊版 bridge 的 detection 別名。無法辨識(含 None / 空字串)回傳 None 代表「未指定」。 """ if not isinstance(task_type, str): return None value = task_type.strip().lower() if not value: return None if value == TASK_TYPE_CLASSIFICATION: return TASK_TYPE_CLASSIFICATION if value in (TASK_TYPE_OBJECT_DETECTION, "detection"): return TASK_TYPE_OBJECT_DETECTION _log(f"Unknown task_type '{task_type}' from caller, ignoring") return None def _detect_model_type_by_heuristics(model_id, nef_path): """Guess model type / input size from model ID or .nef filename. ⚠️ Input size 的部分是**最後手段**:只有在更可信的來源(SDK / 外部宣告) 沒有結果時才會生效。已經由 _resolve_input_size 取到 SDK 或 declared 尺寸 時,這裡只決定 model type(走哪條 post-process),不動尺寸 —— 否則猜出來 的值會蓋掉模型自己宣告的真值。 """ global _model_type # _resolve_input_size 已處理 SDK / filename / declared 三層。只有它什麼 # 都沒找到(source 仍是 default)時,這裡的猜測才有機會生效。 keep_size = _model_input_size_source != INPUT_SIZE_SOURCE_DEFAULT def apply_size(width, height, source): """Write the guessed size unless a more trustworthy source already won.""" if keep_size: return _set_model_input_size(width, height, source) # Check known model IDs if model_id in KNOWN_MODELS: _model_type, known_size = KNOWN_MODELS[model_id] # 已知 model id 的尺寸是 Kneron 官方模型的固定值。比寫死的 backbone # 預設值可信,但不如 SDK / 檔名 / 使用者宣告(見 _resolve_input_size)。 apply_size(known_size, known_size, INPUT_SIZE_SOURCE_KNOWN_ID) _log(f"Model type detected by ID {model_id}: {_model_type} " f"({_describe_input_size()})") return # Fallback: try to infer from filename basename = os.path.basename(nef_path).lower() if nef_path else "" # 這裡的尺寸一律是「該 backbone 的慣用值」——檔名真的帶 wNNNhNNN 時 # _resolve_input_size 第 2 層早就命中了,走到這裡代表檔名沒有尺寸資訊, # 所以 source 是 default 而不是 filename(不可讓預設值偽裝成解析結果)。 if "yolov5" in basename: _model_type = "yolov5s" apply_size(640, 640, INPUT_SIZE_SOURCE_DEFAULT) elif "fcos" in basename: _model_type = "fcos" apply_size(512, 512, INPUT_SIZE_SOURCE_DEFAULT) elif "ssd" in basename: _model_type = "ssd" apply_size(320, 320, INPUT_SIZE_SOURCE_DEFAULT) elif "resnet" in basename or "classification" in basename: _model_type = "resnet18" apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) elif "tiny_yolo" in basename or "tinyyolo" in basename: _model_type = "tiny_yolov3" apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) else: # Default: assume YOLO-like detection. # 對「外部指定 classification 但檔名沒有型別關鍵字」的自訂模型特別 # 重要 —— input size 與 task type 是兩件獨立的事。 _model_type = "tiny_yolov3" apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) _log(f"Model type detected by filename '{basename}': {_model_type} " f"({_describe_input_size()})") def _size_from_name_or_none(name): """Extract (width, height) from a filename like 'w640h640' / 'w256h192'. **解析不出來時回 None**,不回任何預設值 —— 這個「有解析到 vs 沒解析到」 的區分是新優先序的前提:真的從檔名讀到尺寸時它比使用者手填的 declared 可信(工具鏈產生、沒有亂填空間);沒讀到時它什麼都不是,絕不能讓 backbone 的慣用預設值(224 / 640 …)偽裝成「從檔名來的」而蓋掉 declared。 舊版 _parse_size_from_name 把兩者混在同一個回傳值裡,呼叫端無從分辨。 """ import re m = re.search(r'w(\d+)h(\d+)', name or "") if not m: return None width = int(m.group(1)) height = int(m.group(2)) if _is_reasonable_input_dim(width) and _is_reasonable_input_dim(height): return (width, height) return None def _parse_size_from_name(name, default=224): """Backward-compatible wrapper: returns (default, default) when unparsed. 保留給既有呼叫端 / 測試。新程式碼請用 _size_from_name_or_none —— 它能 分辨「解析成功」與「用了 default」,那是決定優先序所必需的資訊。 """ parsed = _size_from_name_or_none(name) if parsed is not None: return parsed return (default, default) # ── Post-processing ────────────────────────────────────────────────── def _sigmoid(x): return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500))) def _nms(detections, iou_threshold=NMS_IOU_THRESHOLD): """Non-Maximum Suppression.""" detections.sort(key=lambda d: d["confidence"], reverse=True) keep = [] for d in detections: skip = False for k in keep: if d["class_id"] != k["class_id"]: continue x1 = max(d["bbox"]["x"], k["bbox"]["x"]) y1 = max(d["bbox"]["y"], k["bbox"]["y"]) x2 = min(d["bbox"]["x"] + d["bbox"]["width"], k["bbox"]["x"] + k["bbox"]["width"]) y2 = min(d["bbox"]["y"] + d["bbox"]["height"], k["bbox"]["y"] + k["bbox"]["height"]) inter = max(0, x2 - x1) * max(0, y2 - y1) a1 = d["bbox"]["width"] * d["bbox"]["height"] a2 = k["bbox"]["width"] * k["bbox"]["height"] if inter / (a1 + a2 - inter + 1e-6) > iou_threshold: skip = True break if not skip: keep.append(d) return keep def _get_preproc_info(result): """Extract letterbox padding info from the inference result. Kneron SDK applies letterbox resize (aspect-ratio-preserving + zero padding) before inference. The hw_pre_proc_info tells us how to reverse it. Returns (pad_left, pad_top, resize_w, resize_h, model_w, model_h) or None. """ try: info = result.header.hw_pre_proc_info_list[0] return { "pad_left": info.pad_left if hasattr(info, 'pad_left') else 0, "pad_top": info.pad_top if hasattr(info, 'pad_top') else 0, "resized_w": info.resized_img_width if hasattr(info, 'resized_img_width') else 0, "resized_h": info.resized_img_height if hasattr(info, 'resized_img_height') else 0, "model_w": info.model_input_width if hasattr(info, 'model_input_width') else 0, "model_h": info.model_input_height if hasattr(info, 'model_input_height') else 0, "img_w": info.img_width if hasattr(info, 'img_width') else 0, "img_h": info.img_height if hasattr(info, 'img_height') else 0, } except Exception: return None def _correct_bbox_for_letterbox(x, y, w, h, preproc, model_size): """Remove letterbox padding offset from normalized bbox coordinates. Input (x, y, w, h) is in model-input-space normalized to 0-1. Output is re-normalized to the original image aspect ratio (still 0-1). For KP_PADDING_CORNER (default): image is at top-left, padding at bottom/right. """ if preproc is None: return x, y, w, h model_w = preproc["model_w"] or model_size model_h = preproc["model_h"] or model_size pad_left = preproc["pad_left"] pad_top = preproc["pad_top"] resized_w = preproc["resized_w"] or model_w resized_h = preproc["resized_h"] or model_h # If no padding was applied, skip correction if pad_left == 0 and pad_top == 0 and resized_w == model_w and resized_h == model_h: return x, y, w, h # Convert from normalized (0-1 of model input) to pixel coords in model space px = x * model_w py = y * model_h pw = w * model_w ph = h * model_h # Subtract padding offset px -= pad_left py -= pad_top # Re-normalize to the resized (un-padded) image dimensions nx = px / resized_w ny = py / resized_h nw = pw / resized_w nh = ph / resized_h # Clip to 0-1 nx = max(0.0, min(1.0, nx)) ny = max(0.0, min(1.0, ny)) nw = min(1.0 - nx, nw) nh = min(1.0 - ny, nh) return nx, ny, nw, nh def _parse_yolo_output(result, anchors, input_size, num_classes=80, labels=None): """Parse YOLO (v3/v5) raw output into detection results. Works for both Tiny YOLOv3 and YOLOv5 — the tensor layout is the same: (num_anchors * (5 + num_classes), grid_h, grid_w) The key differences are: - anchor values - input_size used for anchor normalization - number of output heads Bounding boxes are corrected for letterbox padding so coordinates are relative to the original image (normalized 0-1). """ detections = [] entry_size = 5 + num_classes # 85 for COCO 80 classes # Get letterbox padding info preproc = _get_preproc_info(result) if preproc: _log(f"Preproc info: pad=({preproc['pad_left']},{preproc['pad_top']}), " f"resized=({preproc['resized_w']}x{preproc['resized_h']}), " f"model=({preproc['model_w']}x{preproc['model_h']}), " f"img=({preproc['img_w']}x{preproc['img_h']})") for head_idx in range(result.header.num_output_node): output = kp.inference.generic_inference_retrieve_float_node( node_idx=head_idx, generic_raw_result=result, channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW ) arr = output.ndarray[0] # (C, H, W) channels, grid_h, grid_w = arr.shape # Determine number of anchors for this head num_anchors = channels // entry_size if num_anchors < 1: _log(f"Head {head_idx}: unexpected shape {arr.shape}, skipping") continue # Use the correct anchor set for this head if head_idx < len(anchors): head_anchors = anchors[head_idx] else: _log(f"Head {head_idx}: no anchors defined, skipping") continue for a_idx in range(min(num_anchors, len(head_anchors))): off = a_idx * entry_size for cy in range(grid_h): for cx in range(grid_w): obj_conf = _sigmoid(arr[off + 4, cy, cx]) if obj_conf < CONF_THRESHOLD: continue cls_scores = _sigmoid(arr[off + 5:off + entry_size, cy, cx]) cls_id = int(np.argmax(cls_scores)) cls_conf = float(cls_scores[cls_id]) conf = float(obj_conf * cls_conf) if conf < CONF_THRESHOLD: continue bx = (_sigmoid(arr[off, cy, cx]) + cx) / grid_w by = (_sigmoid(arr[off + 1, cy, cx]) + cy) / grid_h aw, ah = head_anchors[a_idx] bw = (np.exp(min(float(arr[off + 2, cy, cx]), 10)) * aw) / input_size bh = (np.exp(min(float(arr[off + 3, cy, cx]), 10)) * ah) / input_size # Convert center x,y,w,h to corner x,y,w,h (normalized to model input) x = max(0.0, bx - bw / 2) y = max(0.0, by - bh / 2) w = min(1.0, bx + bw / 2) - x h = min(1.0, by + bh / 2) - y # Correct for letterbox padding x, y, w, h = _correct_bbox_for_letterbox(x, y, w, h, preproc, input_size) label = _resolve_label(cls_id, labels=labels, fallback_labels=COCO_CLASSES) detections.append({ "label": label, "class_id": cls_id, "confidence": conf, "bbox": {"x": x, "y": y, "width": w, "height": h}, }) detections = _nms(detections) # Remove internal class_id before returning for d in detections: del d["class_id"] return detections def _parse_ssd_output(result, input_size=320, num_classes=2, labels=None): """Parse SSD face detection output. SSD typically outputs two tensors: - locations: (num_boxes, 4) — bounding box coordinates - confidences: (num_boxes, num_classes) — class scores For the KL520 SSD face detection model (kl520_ssd_fd_lm.nef), the output contains face detections with landmarks. """ detections = [] preproc = _get_preproc_info(result) try: # Retrieve all output nodes num_outputs = result.header.num_output_node outputs = [] for i in range(num_outputs): output = kp.inference.generic_inference_retrieve_float_node( node_idx=i, generic_raw_result=result, channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW ) outputs.append(output.ndarray[0]) if num_outputs < 2: _log(f"SSD: expected >=2 output nodes, got {num_outputs}") return detections # Heuristic: the larger tensor is locations, smaller is confidences # Or: first output = locations, second = confidences locations = outputs[0] confidences = outputs[1] # Flatten if needed if locations.ndim > 2: locations = locations.reshape(-1, 4) if confidences.ndim > 2: confidences = confidences.reshape(-1, confidences.shape[-1]) num_boxes = min(locations.shape[0], confidences.shape[0]) for i in range(num_boxes): # SSD confidence: class 0 = background, class 1 = face if confidences.shape[-1] > 1: conf = float(confidences[i, 1]) # face class else: conf = float(_sigmoid(confidences[i, 0])) if conf < CONF_THRESHOLD: continue # SSD outputs are typically [x_min, y_min, x_max, y_max] normalized x_min = float(np.clip(locations[i, 0], 0.0, 1.0)) y_min = float(np.clip(locations[i, 1], 0.0, 1.0)) x_max = float(np.clip(locations[i, 2], 0.0, 1.0)) y_max = float(np.clip(locations[i, 3], 0.0, 1.0)) w = x_max - x_min h = y_max - y_min if w <= 0 or h <= 0: continue # Correct for letterbox padding x_min, y_min, w, h = _correct_bbox_for_letterbox( x_min, y_min, w, h, preproc, input_size) # SSD 人臉模型只有單一前景類別;有注入 labels 時用 labels[0], # 沒有時維持既有的 "face"。 detections.append({ "label": _resolve_label(0, labels=labels, fallback_labels=["face"]), "class_id": 0, "confidence": conf, "bbox": {"x": x_min, "y": y_min, "width": w, "height": h}, }) detections = _nms(detections) for d in detections: del d["class_id"] except Exception as e: _log(f"SSD parse error: {e}") return detections def _parse_fcos_output(result, input_size=512, num_classes=80, labels=None): """Parse FCOS (Fully Convolutional One-Stage) detection output. FCOS outputs per feature level: - classification: (num_classes, H, W) - centerness: (1, H, W) - regression: (4, H, W) — distances from each pixel to box edges (l, t, r, b) The outputs come in groups of 3 per feature level. """ detections = [] preproc = _get_preproc_info(result) try: num_outputs = result.header.num_output_node outputs = [] for i in range(num_outputs): output = kp.inference.generic_inference_retrieve_float_node( node_idx=i, generic_raw_result=result, channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW ) outputs.append(output.ndarray[0]) # FCOS typically has 5 feature levels × 3 outputs = 15 output nodes # Or fewer for simplified models. Group by 3: (cls, centerness, reg) # If we can't determine the grouping, try a simpler approach. strides = [8, 16, 32, 64, 128] num_levels = num_outputs // 3 for level in range(num_levels): cls_out = outputs[level * 3] # (num_classes, H, W) cnt_out = outputs[level * 3 + 1] # (1, H, W) reg_out = outputs[level * 3 + 2] # (4, H, W) stride = strides[level] if level < len(strides) else (8 * (2 ** level)) h, w = cls_out.shape[1], cls_out.shape[2] for cy in range(h): for cx in range(w): cls_scores = _sigmoid(cls_out[:, cy, cx]) cls_id = int(np.argmax(cls_scores)) cls_conf = float(cls_scores[cls_id]) centerness = float(_sigmoid(cnt_out[0, cy, cx])) conf = cls_conf * centerness if conf < CONF_THRESHOLD: continue # Regression: distances from pixel center to box edges px = (cx + 0.5) * stride py = (cy + 0.5) * stride l = float(np.exp(min(reg_out[0, cy, cx], 10))) * stride t = float(np.exp(min(reg_out[1, cy, cx], 10))) * stride r = float(np.exp(min(reg_out[2, cy, cx], 10))) * stride b = float(np.exp(min(reg_out[3, cy, cx], 10))) * stride x_min = max(0.0, (px - l) / input_size) y_min = max(0.0, (py - t) / input_size) x_max = min(1.0, (px + r) / input_size) y_max = min(1.0, (py + b) / input_size) bw = x_max - x_min bh = y_max - y_min if bw <= 0 or bh <= 0: continue # Correct for letterbox padding x_min, y_min, bw, bh = _correct_bbox_for_letterbox( x_min, y_min, bw, bh, preproc, input_size) label = _resolve_label(cls_id, labels=labels, fallback_labels=COCO_CLASSES) detections.append({ "label": label, "class_id": cls_id, "confidence": conf, "bbox": {"x": x_min, "y": y_min, "width": bw, "height": bh}, }) detections = _nms(detections) for d in detections: del d["class_id"] except Exception as e: _log(f"FCOS parse error: {e}") return detections def _sanitize_labels(raw): """Normalize the labels payload coming from the Go layer. 只接受 list/tuple。非字串元素轉成 str;None 轉成空字串(稀疏佔位)。 空 list 視為「未提供」回傳 None,讓下游走 class_N / COCO fallback。 """ if raw is None: return None if not isinstance(raw, (list, tuple)): _log(f"load_model: labels must be a list, got {type(raw).__name__}; ignoring") return None labels = [] for item in raw: if item is None: labels.append("") elif isinstance(item, str): labels.append(item) else: labels.append(str(item)) if not any(label.strip() for label in labels): return None return labels def _reset_model_metadata(): """Clear the injected model metadata (called on disconnect / reset). 不清會讓下一個模型沿用上一個的 labels / task_type —— 例如先載 classification 再載 detection,detection 會拿到錯的 label 表。 _model_nef_path 一併清掉:它描述「_model_id 指向的那個 .nef 在哪」, model 都沒了還留著路徑,set_inference_options 有機會拿舊路徑去推導 input size。(實務上 set_inference_options 會先被 _model_id is None 擋掉,但讓不變式「path 與 model_id 同生同滅」成立比依賴那道守衛安全。) input size 三個全域(width / height / source)也一併回到預設,理由相同: 它們描述的是「_model_id 那個模型要多大的輸入」,模型沒了就不該留著 —— 留著會讓下一個模型在來源判定時看到上一個模型的 SDK 標記。 """ global _task_type_override, _model_labels, _model_nef_path global _model_declared_input_size _task_type_override = None _model_labels = None _model_nef_path = "" _model_declared_input_size = None _set_model_input_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) def _current_task_type(): """The task type that will be reported for inference results. 以「實際會執行哪一條 post-process」為準(而非外部宣告值),確保回傳的 taskType 與 detections / classifications 欄位始終一致。 """ if _model_type in ("classification", "resnet18"): return TASK_TYPE_CLASSIFICATION return TASK_TYPE_OBJECT_DETECTION def _resolve_label(class_index, labels=None, fallback_labels=None): """Resolve a class index to a display label. 優先序: 1. 注入的 labels(來自 models.json / 使用者上傳,經 Go 層傳入) 2. fallback_labels(detection 沿用 COCO_CLASSES 以維持既有行為) 3. f"class_{index}" —— 原始 enum index 「沒有 label」不是錯誤狀態:classification 必須照樣能跑,只是顯示成 class_0 / class_1 / … 讓使用者自己對照。labels 長度與實際類別數不符時, 對得到的用 label、對不到的 fallback 回 index(不讓整批失敗)。 """ if labels: if 0 <= class_index < len(labels): label = labels[class_index] # 稀疏 labels 允許用空字串佔位(見 plan §3.2 A1),此時 fallback if isinstance(label, str) and label.strip(): return label if fallback_labels and 0 <= class_index < len(fallback_labels): return fallback_labels[class_index] return f"class_{class_index}" def _extract_logits_vector(result): """Extract a 1-D score vector from a classification model's output. 自適應處理:不預設任何 output node 數、shape 或類別數。 支援的 shape 變體(取回時已指定 CHW ordering): (1, C) — batch 1、C 類(最常見) (C,) — 已 squeeze 的一維向量 (1, C, 1, 1) — NCHW、spatial 為 1×1 (C, 1, 1) — CHW、spatial 為 1×1 (1, 1, 1, C) — NHWC 尾端通道 任何 size == C 但其餘維度皆為 1 的張量 → squeeze 後取用 多個 output node 時:挑選 squeeze 後為一維、且元素數最多的那個節點 (classification head 通常是唯一的一維輸出)。 無法判定時 **拋出 ValueError**、附上實際 shape,讓上層明確報錯。 絕不靜默回傳可能是錯的結果。 Returns: (scores: np.ndarray 1-D, node_idx: int) Raises: ValueError: 沒有任何 output node 可解讀成 classification logits。 """ num_nodes = getattr(result.header, "num_output_node", 1) or 1 observed = [] # [(node_idx, original_shape)] candidates = [] # [(node_idx, original_shape, squeezed 1-D ndarray)] for node_idx in range(num_nodes): output = kp.inference.generic_inference_retrieve_float_node( node_idx=node_idx, generic_raw_result=result, channels_ordering=kp.ChannelOrdering.KP_CHANNEL_ORDERING_CHW ) arr = np.asarray(output.ndarray) observed.append((node_idx, tuple(arr.shape))) if arr.size == 0: continue # squeeze 掉所有長度 1 的維度。classification 的輸出無論是 # (1,C) / (1,C,1,1) / (C,1,1) / (1,1,1,C),squeeze 後都會變成 (C,)。 squeezed = np.squeeze(arr) if squeezed.ndim == 0: # 單一純量(C == 1):視為只有一個類別的合法輸出 squeezed = squeezed.reshape(1) if squeezed.ndim != 1: # 仍是多維 → 帶有 spatial 維度,不是 classification head # (例如 detection 的 (C,H,W))。不猜、直接跳過這個節點。 continue candidates.append((node_idx, tuple(arr.shape), squeezed)) if not candidates: shapes = ", ".join(f"node[{i}]={s}" for i, s in observed) or "(no output node)" raise ValueError( "classification output not recognizable: expected a tensor that " "squeezes to 1-D logits, e.g. (1,C) / (C,) / (1,C,1,1); " f"got {shapes}" ) # 多個一維候選時取元素數最多的(classification head 的類別數通常 # 明顯大於其它輔助輸出)。單一候選時就是它。 node_idx, original_shape, scores = max(candidates, key=lambda c: c[2].size) if len(candidates) > 1: _log(f"Classification: {len(candidates)} 1-D output nodes found, " f"using node[{node_idx}] shape={original_shape} (largest)") return scores.astype(np.float64, copy=False), node_idx def _looks_like_probabilities(scores): """Heuristic: has this vector already been through softmax? 若模型輸出已是機率、再做一次 softmax 會把分佈壓平(3 類會趨近 0.33/0.33/0.33),**不報錯但結果無意義、極難 debug**(plan §7 R-2)。 判定條件(兩者皆須成立): - 所有元素 >= 0(logits 幾乎必有負值) - 總和落在 1.0 ± PROB_SUM_TOLERANCE 單一類別(C == 1)且值為 1.0 也符合,屬正確判定。 **已知殘留風險(無法用啟發式根除)**:全非負、且總和恰好為 1.0 的 logits 在數學上與機率向量不可區分 —— 例如 C=2 的 [0.4, 0.6]。收緊 PROB_SUM_TOLERANCE 到 1e-4 已把誤判率壓到 C=3 時約 0.003%(見常數處 的實測數據),但無法歸零。因此呼叫端 **必須把判定結果寫進 log** (_parse_classification_output 有做),這是實機驗收時唯一能看出 判斷對錯的線索。要真正根除只能由模型端明示輸出是否已正規化 (models.json 加欄位),屬 M4 範圍。 """ if scores.size == 0: return False if not np.all(np.isfinite(scores)): return False if float(np.min(scores)) < 0.0: return False return abs(float(np.sum(scores)) - 1.0) <= PROB_SUM_TOLERANCE def _softmax(scores): """Numerically stable softmax over a 1-D vector.""" shifted = scores - np.max(scores) exp_scores = np.exp(shifted) total = exp_scores.sum() if not np.isfinite(total) or total <= 0: # 理論上不會發生(shifted 最大值為 0 → exp 至少有一個 1.0), # 但寧可退回均勻分佈也不要回傳 NaN 給前端。 _log("Classification: softmax denominator invalid, falling back to uniform") return np.full(scores.shape, 1.0 / scores.size, dtype=np.float64) return exp_scores / total def _parse_classification_output(result, labels=None, top_k=None): """Parse classification model output. 完全自適應:不假設 output node 數、shape 或類別數,類別數一律從實際 張量 shape 取得。無法判定時明確拋錯(由 handle_inference 轉成 error response),不靜默回傳可能是錯的結果。 Args: result: SDK 的 generic raw result。 labels: 注入的 label 陣列。None / 空 → 輸出原始 enum(class_N)。 top_k: 取前幾名。None 或 <= 0 → 使用 DEFAULT_CLASSIFICATION_TOP_K。 Returns: list[dict],依 confidence 降序,每筆含 label / classIndex / confidence。 Raises: ValueError: output shape 無法解讀成 classification logits。 """ scores, node_idx = _extract_logits_vector(result) num_classes = int(scores.size) if _looks_like_probabilities(scores): probs = scores # 這條 log 是實機驗收時唯一能看出「判定為已是機率」對不對的線索 # (見 _looks_like_probabilities 的殘留風險說明)。若信心度數值看起來 # 不合理、先來這裡確認是不是誤判成已正規化而跳過了 softmax。 _log(f"Classification: node[{node_idx}] C={num_classes}, " f"SKIPPING SOFTMAX — output judged already normalized " f"(sum={float(np.sum(scores)):.8f}, " f"range=[{float(np.min(scores)):.4f}, {float(np.max(scores)):.4f}], " f"tolerance={PROB_SUM_TOLERANCE:g}). " f"If confidences look wrong, this verdict is the first suspect.") else: probs = _softmax(scores) _log(f"Classification: node[{node_idx}] C={num_classes}, " f"raw range=[{float(np.min(scores)):.4f}, {float(np.max(scores)):.4f}], " f"applied softmax") effective_k = top_k if isinstance(top_k, int) and top_k > 0 else DEFAULT_CLASSIFICATION_TOP_K effective_k = min(effective_k, num_classes) if labels and len(labels) != num_classes: _log(f"Classification: label count ({len(labels)}) != class count " f"({num_classes}); unmatched indices fall back to class_N") top_indices = np.argsort(probs)[::-1][:effective_k] classifications = [] for idx in top_indices: class_index = int(idx) classifications.append({ "label": _resolve_label(class_index, labels=labels), "classIndex": class_index, "confidence": float(probs[class_index]), }) return classifications # ── Command handlers ───────────────────────────────────────────────── def handle_scan(): """Scan for connected Kneron devices. Tries Kneron PLUS SDK first (provides firmware info, kn_number, etc.). Falls back to pyusb if the SDK is unavailable (e.g. macOS missing .dylib). """ if HAS_KP: try: descs = kp.core.scan_devices() devices = [] for i in range(descs.device_descriptor_number): dev = descs.device_descriptor_list[i] devices.append({ "port": str(dev.usb_port_id), "firmware": str(dev.firmware), "kn_number": f"0x{dev.kn_number:08X}", "product_id": f"0x{dev.product_id:04X}", "connectable": dev.is_connectable, }) return {"devices": devices} except Exception as e: _log(f"kp.core.scan_devices failed: {e}, trying pyusb fallback") # Fallback: use pyusb (same approach as kneron_detect.py) if HAS_PYUSB: return _scan_with_pyusb() return {"devices": [], "error_detail": "neither kp nor pyusb available"} # Known Kneron product IDs (same as kneron_detect.py) _KNERON_VENDOR_ID = 0x3231 _KNOWN_PRODUCTS = { 0x0100: "KL520", 0x0200: "KL720", 0x0720: "KL720", 0x0530: "KL530", 0x0630: "KL630", 0x0730: "KL730", } def _scan_with_pyusb(): """Scan for Kneron devices using pyusb (libusb backend).""" try: usb_devices = list(usb.core.find(find_all=True, idVendor=_KNERON_VENDOR_ID)) devices = [] for dev in usb_devices: product_id = f"0x{dev.idProduct:04X}" chip = _KNOWN_PRODUCTS.get(dev.idProduct, f"Unknown-{product_id}") # pyusb port_id: bus-address port = f"{dev.bus}-{dev.address}" firmware = "unknown" try: firmware = dev.product or "unknown" except Exception: pass devices.append({ "port": port, "firmware": firmware, "kn_number": "0x00000000", "product_id": product_id, "connectable": True, }) return {"devices": devices} except Exception as e: return {"devices": [], "error_detail": f"pyusb scan failed: {e}"} def handle_connect(params): """Connect to a Kneron device and load firmware if needed. KL520: USB Boot mode — firmware MUST be uploaded every session. KL720 (KDP2, pid=0x0720): Flash-based — firmware pre-installed. KL720 (KDP legacy, pid=0x0200): Old firmware — needs connect_without_check + firmware load to RAM before normal operation. """ global _device_group, _firmware_loaded, _device_chip if not HAS_KP: return {"error": "kp module not available"} try: port = params.get("port", "") device_type = params.get("device_type", "") # Scan to find device descs = kp.core.scan_devices() if descs.device_descriptor_number == 0: return {"error": "no Kneron device found"} # Find device by port or use first one target_dev = None for i in range(descs.device_descriptor_number): dev = descs.device_descriptor_list[i] if port and str(dev.usb_port_id) == port: target_dev = dev break if target_dev is None: target_dev = descs.device_descriptor_list[0] # Note: KL520 in USB Boot mode has is_connectable=False, which is # normal — it becomes connectable after firmware is loaded. KL720 KDP # legacy (pid=0x0200) is also not connectable until firmware load. # So we do NOT reject is_connectable=False here; instead we attempt # connection and firmware load as appropriate. # Determine chip type from device_type param or product_id pid = target_dev.product_id if "kl720" in device_type.lower(): _device_chip = "KL720" elif "kl520" in device_type.lower(): _device_chip = "KL520" elif pid in (0x0200, 0x0720): _device_chip = "KL720" else: _device_chip = "KL520" fw_str = str(target_dev.firmware) is_kdp_legacy = (_device_chip == "KL720" and pid == 0x0200) _log(f"Chip type: {_device_chip} (product_id=0x{pid:04X}, device_type={device_type}, fw={fw_str})") # ── KL720 KDP Legacy (pid=0x0200): old firmware, incompatible with SDK ── if is_kdp_legacy: _log(f"KL720 has legacy KDP firmware (pid=0x0200). Using connect_devices_without_check...") _device_group = kp.core.connect_devices_without_check( usb_port_ids=[target_dev.usb_port_id] ) kp.core.set_timeout(device_group=_device_group, milliseconds=60000) # Load KDP2 firmware to RAM so the device can operate with this SDK scpu_path, ncpu_path = _resolve_firmware_paths("KL720") if scpu_path and ncpu_path: _log(f"KL720: Loading KDP2 firmware to RAM: {scpu_path}") kp.core.load_firmware_from_file( _device_group, scpu_path, ncpu_path ) _firmware_loaded = True _log("KL720: Firmware loaded to RAM, waiting for reboot...") time.sleep(5) # Reconnect — device should now be running KDP2 in RAM descs = kp.core.scan_devices() reconnected = False for i in range(descs.device_descriptor_number): dev = descs.device_descriptor_list[i] if dev.product_id in (0x0200, 0x0720): target_dev = dev reconnected = True break if not reconnected: return {"error": "KL720 not found after firmware load. Unplug and re-plug."} # Try normal connect first, fallback to without_check try: _device_group = kp.core.connect_devices( usb_port_ids=[target_dev.usb_port_id] ) except Exception as conn_err: _log(f"KL720: Normal reconnect failed ({conn_err}), using without_check...") _device_group = kp.core.connect_devices_without_check( usb_port_ids=[target_dev.usb_port_id] ) kp.core.set_timeout(device_group=_device_group, milliseconds=10000) fw_str = str(target_dev.firmware) _log(f"KL720: Reconnected after firmware load, pid=0x{target_dev.product_id:04X}, fw={fw_str}") else: _log("WARNING: KL720 firmware files not found. Cannot operate with KDP legacy device.") _clear_device_group() return {"error": "KL720 has legacy KDP firmware but KDP2 firmware files not found. " "Run update_kl720_firmware.py to flash KDP2 permanently."} return { "status": "connected", "firmware": fw_str, "kn_number": f"0x{target_dev.kn_number:08X}", "chip": _device_chip, "kdp_legacy": True, } # ── Normal connection (KL520 or KL720 KDP2) ── # Use connect_devices_without_check when: # - KL720 KDP2: connect_devices() often fails with Error 28 # - KL520 USB Boot: is_connectable=False, connect_devices() rejects it # In these cases, connect_devices_without_check() works and we can # still load firmware afterwards. use_without_check = (_device_chip == "KL720") or (not target_dev.is_connectable) max_retries = 3 last_err = None for attempt in range(max_retries): try: # Clear any stale device group from previous failed attempt. _clear_device_group() if use_without_check: _log(f"{_device_chip}: connect_devices_without_check(usb_port_id={target_dev.usb_port_id}, connectable={target_dev.is_connectable}) attempt {attempt+1}/{max_retries}...") _device_group = kp.core.connect_devices_without_check( usb_port_ids=[target_dev.usb_port_id] ) else: _log(f"connect_devices(usb_port_id={target_dev.usb_port_id}) attempt {attempt+1}/{max_retries}...") _device_group = kp.core.connect_devices( usb_port_ids=[target_dev.usb_port_id] ) _log(f"connect succeeded on attempt {attempt+1}") last_err = None break except Exception as conn_err: _clear_device_group() last_err = conn_err _log(f"connect attempt {attempt+1} failed: {conn_err}") if attempt < max_retries - 1: time.sleep(2) # Re-scan to refresh device handle try: descs = kp.core.scan_devices() for i in range(descs.device_descriptor_number): dev = descs.device_descriptor_list[i] if port and str(dev.usb_port_id) == port: target_dev = dev break elif not port: target_dev = descs.device_descriptor_list[0] break except Exception: pass if last_err is not None: hint = "" if sys.platform == "win32": hint = (" On Windows, ensure the WinUSB driver is installed for this device." " Re-run the installer or use Zadig (https://zadig.akeo.ie).") raise RuntimeError(f"Failed to connect after {max_retries} attempts: {last_err}.{hint}") # KL720 needs longer timeout for large NEF transfers (12MB+ over USB) _timeout_ms = 60000 if _device_chip == "KL720" else 10000 _log(f"Calling set_timeout(milliseconds={_timeout_ms})...") kp.core.set_timeout(device_group=_device_group, milliseconds=_timeout_ms) _log(f"set_timeout succeeded") # Firmware handling — chip-dependent. # fresh_firmware_loaded is used by Go driver to decide whether to # skip the post-connect reset (freshly loaded firmware is already # in a clean state — reset would just waste 30-60s reloading it). fresh_firmware_loaded = False if "Loader" in fw_str: # Device is in USB Boot (Loader) mode and needs firmware if _device_chip == "KL720": _log(f"WARNING: {_device_chip} is in Loader mode (unusual). Attempting firmware load...") scpu_path, ncpu_path = _resolve_firmware_paths(_device_chip) if scpu_path and ncpu_path: _log(f"{_device_chip}: Loading firmware: {scpu_path}") kp.core.load_firmware_from_file( _device_group, scpu_path, ncpu_path ) _firmware_loaded = True _log("Firmware loaded, waiting for reboot...") time.sleep(5) # Reconnect after firmware load (with retry) _clear_device_group() for retry in range(3): try: descs = kp.core.scan_devices() target_dev = descs.device_descriptor_list[0] try: _device_group = kp.core.connect_devices( usb_port_ids=[target_dev.usb_port_id] ) except Exception: _device_group = kp.core.connect_devices_without_check( usb_port_ids=[target_dev.usb_port_id] ) break except Exception as re_err: _log(f"Reconnect attempt {retry+1} failed: {re_err}") if retry < 2: time.sleep(3) if _device_group is None: return {"error": "Device not found after firmware load. Unplug and re-plug the device."} kp.core.set_timeout( device_group=_device_group, milliseconds=_timeout_ms ) fw_str = str(target_dev.firmware) fresh_firmware_loaded = True _log(f"Reconnected after firmware load, firmware: {fw_str}") else: _log(f"WARNING: {_device_chip} firmware files not found, skipping firmware load") else: # Not in Loader mode — firmware already present from a previous # session. This is the state that triggers Error 15 on inference # without reset, per observed bug. _log(f"{_device_chip}: firmware already present (normal). fw={fw_str}") return { "status": "connected", "firmware": fw_str, "kn_number": f"0x{target_dev.kn_number:08X}", "chip": _device_chip, "fresh_firmware_loaded": fresh_firmware_loaded, } except Exception as e: _clear_device_group() return {"error": str(e)} def handle_disconnect(params): """Disconnect from the current device.""" global _device_group, _model_id, _model_nef, _firmware_loaded global _model_type, _device_chip _clear_device_group() _model_id = None _model_nef = None _model_type = "tiny_yolov3" _firmware_loaded = False _device_chip = "KL520" # input size 由 _reset_model_metadata 一併還原(含 width/height/source), # 不在這裡另外寫 _model_input_size —— 兩處各寫一半會讓三個全域不同步。 _reset_model_metadata() return {"status": "disconnected"} def handle_reset(params): """Reset the device back to USB Boot (Loader) state. This forces the device to drop its firmware and any loaded models. After reset the device will re-enumerate on USB, so the caller must wait and issue a fresh 'connect' command. """ global _device_group, _model_id, _model_nef, _firmware_loaded global _model_type, _device_chip if _device_group is None: return {"error": "device not connected"} try: _log("Resetting device (kp.core.reset_device KP_RESET_REBOOT)...") kp.core.reset_device( device_group=_device_group, reset_mode=kp.ResetMode.KP_RESET_REBOOT, ) _log("Device reset command sent successfully") except Exception as e: _log(f"reset_device raised: {e}") # Even if it throws, the device usually does reset. # Clear all state — the device is gone until it re-enumerates. _clear_device_group() _model_id = None _model_nef = None _model_type = "tiny_yolov3" _firmware_loaded = False _device_chip = "KL520" # input size 由 _reset_model_metadata 一併還原(含 width/height/source)。 _reset_model_metadata() return {"status": "reset"} def handle_load_model(params): """Load a model file onto the device. KL520 USB Boot mode limitation: only one model can be loaded per USB session. If error 40 occurs, the error is returned to the Go driver which handles it by restarting the entire Python bridge. Accepted params: path (required) — .nef 檔絕對路徑 task_type (optional) — "classification" | "object_detection"。 由 Go 層從 model metadata 帶入。**有指定就不做猜測。** labels (optional) — list[str],index 對應 class index。 沒帶時 classification 輸出原始 enum(class_N), detection 沿用 COCO_CLASSES。 input_size (optional) — {"width": w, "height": h},models.json / metadata.json 宣告的 inputSize。**只在 SDK 沒回報 input shape 時才會用**:這個值是人填的,使用者可能 隨手填一個數字,不能當成可信來源。 失敗語意:任一步失敗都 **不會修改任何全域狀態**(all-or-nothing)。 裝置上的舊模型仍可繼續推論,其 _model_id / _model_type / _model_labels 保持互相一致。 """ global _model_id, _model_nef, _model_nef_path, _task_type_override, _model_labels global _model_declared_input_size if _device_group is None: return {"error": "device not connected"} path = params.get("path", "") if not path or not os.path.exists(path): return {"error": f"model file not found: {path}"} # 先解析成 local,**還不要寫全域**。全域 metadata 必須永遠描述 # 「_model_id 當前指向的那個模型」;載入失敗時舊模型仍在裝置上、 # 仍可能被繼續推論,此時若 labels 已被新模型的值覆蓋,就會用 # 舊模型 + 新 label 表 → 標籤張冠李戴(靜默錯誤、極難 debug)。 # 所以所有 metadata 一律等到「確定成功」才一次寫入(見下方 commit 段)。 pending_task_type = _normalize_task_type(params.get("task_type")) pending_labels = _sanitize_labels(params.get("labels")) pending_declared_size = _normalize_declared_input_size(params.get("input_size")) _log(f"load_model: task_type={pending_task_type or '(not specified)'}, " f"labels={len(pending_labels) if pending_labels else 0}, " f"declared_input_size=" f"{'x'.join(map(str, pending_declared_size)) if pending_declared_size else '(not specified)'}") try: nef = kp.core.load_model_from_file( device_group=_device_group, file_path=path ) model = nef.models[0] model_id = model.id target_chip = str(nef.target_chip) except Exception as e: # 任何一步失敗 → 全域完全不動,維持舊模型的一致狀態。 _log(f"load_model failed, keeping previous model state: {e}") return {"error": str(e)} # ── Commit:到這裡才確定成功,一次寫入全部全域 metadata ────────── _model_nef = nef _model_id = model_id _model_nef_path = path _task_type_override = pending_task_type _model_labels = pending_labels # 留著給 set_inference_options 用:推論期切解析方式會重跑 _detect_model_type, # 那時要能取到同一組 declared 值,否則切換的副作用是偷改 input size。 _model_declared_input_size = pending_declared_size # Detect model type and input size(外部指定的 task_type 優先)。 # nef 帶進去 → input size 由模型自己宣告的 shape 決定,不再靠檔名猜。 _detect_model_type(_model_id, path, task_type=_task_type_override, nef=nef, declared_input_size=pending_declared_size) _log(f"Model loaded: id={_model_id}, type={_model_type}, " f"{_describe_input_size()}, target={target_chip}") return { "status": "loaded", "model_id": _model_id, "model_type": _model_type, "task_type": _current_task_type(), "label_count": len(_model_labels) if _model_labels else 0, "input_size": _model_input_size, "input_width": _model_input_width, "input_height": _model_input_height, "input_size_source": _model_input_size_source, "model_path": path, "target_chip": target_chip, } def handle_set_inference_options(params): """Update parse mode / labels for the **already loaded** model. KL520 USB Boot 模式一次只能載一個 model,換 model 必須重燒(數十秒)。 但「怎麼解析輸出」與「class index 顯示成什麼名字」都只是 post-process 的事,跟裝置上那份 model 無關 —— 所以這兩件事可以在推論期即時切換, 不需要重新 load_model。 Accepted params(兩者皆為 optional,但至少要帶一個): task_type (optional) — "classification" | "object_detection"。 帶了就覆寫當前的解析方式。 labels (optional) — list[str],index 對應 class index。 帶空 list(或全空字串)= 清掉 label 表,回到原始 enum 輸出(class_N)。這是刻意可達的狀態:使用者可能上傳錯 label 檔想清掉。 失敗語意(沿用 handle_load_model 的 defer-until-success):任一步驗證 失敗 → **零全域變動**。驗證通過才一次 commit。理由相同 —— 全域 metadata 必須永遠與 _model_id 指向的那個模型互相一致,半套寫入會產生「舊解析方式 配新 label 表」這種不報錯的錯誤。 """ global _task_type_override, _model_labels if _device_group is None: return {"error": "device not connected"} if _model_id is None: return {"error": "no model loaded — load a model before setting inference options"} has_task_type = "task_type" in params has_labels = "labels" in params if not has_task_type and not has_labels: return {"error": "set_inference_options requires task_type and/or labels"} # ── 驗證階段:全部算成 local,還不要碰全域 ────────────────────── pending_task_type = _task_type_override if has_task_type: raw_task_type = params.get("task_type") normalized = _normalize_task_type(raw_task_type) if normalized is None: # 這裡與 load_model 不同:load_model 的 task_type 是「附帶提示」、 # 無法辨識就 fallback 檔名猜測是合理的。但這個 handler 的**唯一 # 目的**就是套用呼叫端指定的解析方式 —— 靜默忽略等於回 200 但什麼 # 都沒做,使用者會以為切換生效了。所以一律明確報錯。 return {"error": f"invalid task_type: {raw_task_type!r} " f"(expected {TASK_TYPE_CLASSIFICATION} or " f"{TASK_TYPE_OBJECT_DETECTION})"} pending_task_type = normalized pending_labels = _model_labels if has_labels: raw_labels = params.get("labels") if raw_labels is None: pending_labels = None elif not isinstance(raw_labels, (list, tuple)): return {"error": f"labels must be a list, got {type(raw_labels).__name__}"} else: # _sanitize_labels 對空 list / 全空字串回 None,正好就是「清掉 # label 表」的語意(下游 _resolve_label 會回 class_N)。 pending_labels = _sanitize_labels(raw_labels) # ── Commit:驗證全過才一次寫入 ───────────────────────────────── _task_type_override = pending_task_type _model_labels = pending_labels if has_task_type: # 重跑 model type 偵測讓 _model_type(實際決定走哪條 post-process) # 跟著新的 task_type 走。 # # ⚠️ 三個 input size 來源都要一起帶回去(_model_nef / declared / 路徑), # 否則這次重跑會退回較低可信度的來源,等於「切解析方式」這個動作 # 偷偷改掉了 input size —— 而 input size 錯了 NPU 不會報錯。 _detect_model_type(_model_id, _model_nef_path, task_type=_task_type_override, nef=_model_nef, declared_input_size=_model_declared_input_size) _log(f"set_inference_options: task_type={_task_type_override or '(unchanged/none)'}, " f"labels={len(_model_labels) if _model_labels else 0}, " f"model_type={_model_type}, {_describe_input_size()}") return { "status": "updated", "model_id": _model_id, "model_type": _model_type, "task_type": _current_task_type(), "label_count": len(_model_labels) if _model_labels else 0, "input_size": _model_input_size, "input_width": _model_input_width, "input_height": _model_input_height, "input_size_source": _model_input_size_source, } def handle_inference(params): """Run inference on the provided image data.""" if _device_group is None: return {"error": "device not connected"} if _model_id is None: return {"error": "no model loaded"} image_b64 = params.get("image_base64", "") try: t0 = time.time() if image_b64: # Decode base64 image img_bytes = base64.b64decode(image_b64) if HAS_CV2: # Decode image with OpenCV img_array = np.frombuffer(img_bytes, dtype=np.uint8) img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) if img is None: return {"error": "failed to decode image"} h, w = img.shape[:2] # KL520 NPU requires input image dimensions >= model input size # and both width/height must be even numbers. # # 逐軸比較(而非拿單一 min_dim 比兩軸):非正方形模型用單一值 # 會讓較長的那一軸不足 —— 例如模型要 256x192、影像 200x200, # 用 min_dim=192 會判定「已達標」而不放大,但寬度其實差 56px。 min_w = _model_input_width min_h = _model_input_height if w < min_w or h < min_h or w % 2 != 0 or h % 2 != 0: if w < min_w or h < min_h: # 等比例放大到兩軸都達標(取較大的縮放倍率)。 scale = max(min_w / w, min_h / h) new_w = int(w * scale) new_h = int(h * scale) else: new_w, new_h = w, h new_w = (new_w + 1) & ~1 new_h = (new_h + 1) & ~1 img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) _log(f"Inference image resized: {w}x{h} -> {new_w}x{new_h} " f"(model needs >= {min_w}x{min_h})") # Convert BGR to BGR565 img_bgr565 = cv2.cvtColor(src=img, code=cv2.COLOR_BGR2BGR565) else: img_bgr565 = np.frombuffer(img_bytes, dtype=np.uint8) else: return {"error": "no image data provided"} # Create inference config (original: pass numpy ndarray, SDK reads shape) inf_config = kp.GenericImageInferenceDescriptor( model_id=_model_id, inference_number=0, input_node_image_list=[ kp.GenericInputNodeImage( image=img_bgr565, image_format=kp.ImageFormat.KP_IMAGE_FORMAT_RGB565, ) ] ) # Send and receive _log(f"Inference: sending to NPU (model_type={_model_type}, " f"{_describe_input_size()})") kp.inference.generic_image_inference_send(_device_group, inf_config) result = kp.inference.generic_image_inference_receive(_device_group) _log(f"Inference: receive complete, parsing...") elapsed_ms = (time.time() - t0) * 1000 # Parse output based on model type detections = [] classifications = [] task_type = _current_task_type() if task_type == TASK_TYPE_CLASSIFICATION: # 解析失敗會拋 ValueError(附實際 shape),由外層 except 轉成 # error response —— 不靜默回傳可能是錯的空結果。 classifications = _parse_classification_output( result, labels=_model_labels, top_k=params.get("top_k"), ) elif _model_type == "ssd": detections = _parse_ssd_output( result, input_size=_model_input_size, labels=_model_labels) elif _model_type == "fcos": detections = _parse_fcos_output( result, input_size=_model_input_size, labels=_model_labels) elif _model_type == "yolov5s": detections = _parse_yolo_output( result, anchors=ANCHORS_YOLOV5S, input_size=_model_input_size, labels=_model_labels, ) else: # Default: Tiny YOLOv3 detections = _parse_yolo_output( result, anchors=ANCHORS_TINY_YOLOV3, input_size=_model_input_size, labels=_model_labels, ) _log(f"Inference: parse done, detections={len(detections)}, classifications={len(classifications)}, elapsed={elapsed_ms:.1f}ms") return { "taskType": task_type, "timestamp": int(time.time() * 1000), "latencyMs": round(elapsed_ms, 1), "detections": detections, "classifications": classifications, } except Exception as e: import traceback _log(f"Inference EXCEPTION: {type(e).__name__}: {e}\n{traceback.format_exc()}") return {"error": _annotate_inference_error(e)} # KP_ERROR_INVALID_PARAM_12 幾乎都是「送進去的影像尺寸不是模型要的」。 # SDK 只回一個對使用者毫無意義的錯誤碼,不會說是哪個參數不對。 _INVALID_PARAM_MARKERS = ("KP_ERROR_INVALID_PARAM", "Error code: 12") def _annotate_inference_error(exc): """Attach input-size context to errors that are most likely size mismatches. 為什麼要做這件事:KP_ERROR_INVALID_PARAM_12 對使用者完全無法理解,而 尺寸不符是它最常見的成因。把「當前用的尺寸 + 尺寸從哪來」直接寫進錯誤 訊息,下次遇到就不必再從 log 反推來源(2026-07 那次繞了一大圈)。 """ message = str(exc) if not any(marker in message for marker in _INVALID_PARAM_MARKERS): return message hint = (f"推論失敗(KP_ERROR_INVALID_PARAM)。" f"當前 input_size={_model_input_width}x{_model_input_height} " f"(source: {_model_input_size_source})。" f"若模型實際尺寸不同,這是最可能的原因。") if _model_input_size_source == INPUT_SIZE_SOURCE_DECLARED: hint += ("此尺寸來自上傳時手動填寫的欄位(非模型自述)," "請優先確認它是否填錯。") elif _model_input_size_source in (INPUT_SIZE_SOURCE_KNOWN_ID, INPUT_SIZE_SOURCE_DEFAULT): hint += ("此尺寸是在沒有任何可靠來源時推測的," "請於模型設定中填入正確的輸入尺寸。") _log(hint) return f"{hint} 原始錯誤:{message}" # ── Firmware upgrade (A 階段 M9-1) ─────────────────────────────────── # # 對應 TDD v2/firmware-management.md §5.1 / §6.1: # - 自動升級 KDP1 legacy → KDP2,含 KL520(USB Boot mode + loader stage) # 與 KL720(含 KDP legacy pid=0x0200)。 # - Stage 命名採 Design:preparing / loading / flashing / verifying / done / error # (TDD §4.3 為 source of truth)。 # - 失敗 reason enum(TDD §3.4):scan_not_found / connect_failed / # loader_write_failed / upgrade_mid_failed / disconnect_during_op / # timeout / verify_mismatch / verify_not_found。 # # 為什麼走 ctypes:KneronPLUS Python wrapper 沒 export # `kp_update_kdp_firmware_from_files`(見 research-kl520-fw-management/ # 56-m9-6-strong-validation-result.md 附帶發現 1),warrenchen reference # 實作 `LocalAPI/legacy_plus121_runner.py` 直接 ctypes 打 C symbol,本檔 # 沿用該模式。 KDP_MAGIC_CONNECTION_PASS = 536173391 # 與 warrenchen reference 一致 KP_SUCCESS = 0 USB_WAIT_AFTER_REBOOT_MS = 2000 # SDK loader 階段 reboot 等待 USB_WAIT_AFTER_UPGRADE_MS = 5000 # AC-FW-1.6:升級後 5-8s USB stable USB_WAIT_RETRY_CONNECT_MS = 200 MAX_RECONNECT_RETRIES = 15 # 5s sleep + 15 * 200ms = 8s 上界 KL520_UPGRADE_TIMEOUT_S = 60 # AC-FW-1.7 KL720_UPGRADE_TIMEOUT_S = 200 # AC-FW-1.7 # 進度事件 stage % 對照(TDD §4.3) _FW_STAGE_PERCENT = { "preparing": 5, "loading": 20, "flashing": 50, "verifying": 90, "done": 100, "error": -1, } # 升級進行中旗標(SIGTERM handler 用、AC-FW-1.9 graceful shutdown 拒絕) # Reviewer m4:原本還有 _firmware_upgrade_start_ts 全域變數、與 SIGTERM handler # closure capture 的 start_ts 重複、容易未來 desync → 砍掉、單一 source of truth # 走 closure。 _firmware_upgrade_in_progress = False def _fw_normalize_code(code): """Convert int8-like unsigned (e.g. 253 for -3) to signed. 與 warrenchen reference 一致:某些 legacy 路徑回 unsigned int8 值。 """ try: c = int(code) except Exception: return code if c > 127: return c - 256 return c def _fw_emit_progress(stage, message="", elapsed_ms=0, eta_ms=0, extra=None): """Push a progress event to stderr as a JSON-RPC notification line. Go driver 抓 stderr line-by-line、轉成 WebSocket FirmwareProgress 給前端。 Schema 對齊 TDD §4.2 `FirmwareProgress`: {"event": "firmware_progress", "percent": int, "stage": str, "message": str, "elapsed_ms": int, "eta_ms": int, ...} Stage `error` 時 caller 應 push 額外 reason / raw_error / before_version 透過 extra dict。 """ payload = { "event": "firmware_progress", "percent": _FW_STAGE_PERCENT.get(stage, 0), "stage": stage, "message": message, "elapsed_ms": int(elapsed_ms), "eta_ms": int(eta_ms), } if extra: payload.update(extra) try: # 寫到 stderr、與既有 _log() 同 fd、但用 JSON 格式(不加 [kneron_bridge] prefix) # 方便 Go driver 區分「progress event JSON」vs「自由文字 log」。 print(json.dumps(payload), file=sys.stderr, flush=True) except Exception: # progress emit 失敗不該影響升級流程本身 pass def _fw_load_libkplus(): """Load libkplus shared library via ctypes、bind needed C symbol signatures. 跨平台:macOS .dylib / Linux .so / Windows .dll。優先用 `kp` module 已載 入的 lib path(避免重複載入造成 mismatch),fallback 到 wheel 內 lib/ 目錄。 Raises: RuntimeError: 若 libkplus 找不到或符號 binding 失敗。 """ import ctypes import importlib.util spec = importlib.util.find_spec("kp") if spec is None or not spec.submodule_search_locations: raise RuntimeError("kp module spec not found") kp_dir = spec.submodule_search_locations[0] lib_dir = os.path.join(kp_dir, "lib") # 平台對應的 lib filename if sys.platform == "darwin": lib_name = "libkplus.dylib" elif sys.platform == "win32": lib_name = "libkplus.dll" else: lib_name = "libkplus.so" lib_path = os.path.join(lib_dir, lib_name) if not os.path.isfile(lib_path): # Windows 可能用其他命名(warrenchen reference 是 libkplus.dll) # 嘗試找任何 libkplus* 檔案 # Reviewer m2:sort() 確保 deterministic 順序、不依賴 os.listdir 回傳次序 candidates = sorted( f for f in os.listdir(lib_dir) if f.startswith("libkplus") ) if not candidates: raise RuntimeError(f"libkplus not found in {lib_dir}") lib_path = os.path.join(lib_dir, candidates[0]) _log(f"WARNING: libkplus fallback using {candidates[0]} (primary {lib_name} not found)") # Windows: add_dll_directory 確保相依 dll 可解析 if sys.platform == "win32" and hasattr(os, "add_dll_directory"): try: os.add_dll_directory(lib_dir) except Exception: pass lib = ctypes.CDLL(lib_path) # Bind C symbol signatures(與 warrenchen reference 完全一致) lib.kp_connect_devices.argtypes = [ ctypes.c_int, # num_devices ctypes.POINTER(ctypes.c_int), # usb_port_ids ctypes.POINTER(ctypes.c_int), # status_out ] lib.kp_connect_devices.restype = ctypes.c_void_p # device_group handle lib.kp_set_timeout.argtypes = [ctypes.c_void_p, ctypes.c_int] lib.kp_set_timeout.restype = None lib.kp_load_firmware_from_file.argtypes = [ ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p ] lib.kp_load_firmware_from_file.restype = ctypes.c_int lib.kp_update_kdp_firmware_from_files.argtypes = [ ctypes.c_void_p, # device_group ctypes.c_char_p, # scpu_or_loader path ctypes.c_char_p, # ncpu path or NULL ctypes.c_bool, # auto_reboot ] lib.kp_update_kdp_firmware_from_files.restype = ctypes.c_int lib.kp_disconnect_devices.argtypes = [ctypes.c_void_p] lib.kp_disconnect_devices.restype = ctypes.c_int if hasattr(lib, "kp_error_string"): lib.kp_error_string.argtypes = [ctypes.c_int] lib.kp_error_string.restype = ctypes.c_char_p return lib def _fw_errstr(lib, code): """Decode kp error code → string via kp_error_string()。 與 warrenchen 一致:先試 raw code、若無回應再試 signed normalize 後值。 """ signed = _fw_normalize_code(code) if hasattr(lib, "kp_error_string"): try: msg = lib.kp_error_string(int(code)) if not msg and signed != code: msg = lib.kp_error_string(int(signed)) if msg: return msg.decode("utf-8", errors="replace") except Exception: pass return f"code={code}" def _fw_connect_with_magic(lib, port_id): """Connect with magic pass = 536173391 (允許 KDP1 legacy device 連線)。 Returns: device_group handle (c_void_p int). Raises: RuntimeError("connect_failed: ...") on failure. """ import ctypes port_ids = (ctypes.c_int * 1)(int(port_id)) status = ctypes.c_int(KDP_MAGIC_CONNECTION_PASS) dg = lib.kp_connect_devices(1, port_ids, ctypes.byref(status)) if not dg or status.value != KP_SUCCESS: signed = _fw_normalize_code(status.value) raise RuntimeError( f"connect_failed: raw_code={status.value}, signed={signed}, " f"msg={_fw_errstr(lib, status.value)}" ) return dg def _fw_scan_target(port): """Scan devices via kp.core.scan_devices() and find target by usb_port_id. Returns: descriptor or None. """ try: descs = kp.core.scan_devices() except Exception as e: _log(f"fw_scan_target: scan_devices failed: {e}") return None if descs.device_descriptor_number == 0: return None for i in range(descs.device_descriptor_number): dev = descs.device_descriptor_list[i] if port and str(dev.usb_port_id) == str(port): return dev return None def _fw_rescan_and_wait(port, max_wait_s=8.0, initial_sleep_s=5.0): """等 USB re-enumerate stable → rescan 找回 target by port (AC-FW-1.6)。 Args: port: 原 usb_port_id(升級後 re-enumerate 通常保留同 port)。 max_wait_s: 從 initial_sleep_s 過後再加 max_wait_s - initial_sleep_s 秒輪詢上界。實測 5 秒已穩、保留上界 8 秒(AC-FW-1.6)。 initial_sleep_s: 第一次 rescan 前固定等的秒數。 Returns: (descriptor or None, total_wait_s). """ time.sleep(initial_sleep_s) waited = initial_sleep_s target = _fw_scan_target(port) if target is not None: return target, waited # 多輪 short-poll poll_step = 0.5 while waited < max_wait_s: time.sleep(poll_step) waited += poll_step target = _fw_scan_target(port) if target is not None: return target, waited return None, waited def _fw_classify_legacy(firmware_str, product_id): """判斷 device 是否為 KDP1 legacy state(需走 loader stage)。 KL520 legacy 訊號:firmware 字串為 "KDP"、"KDP1"、"KDP1.x"、"USB Boot"、 "USB Boot Loader"、"LOADER" 等 legacy state、或空字串 (某些 USB Boot state 不回 firmware string)。 KL720 legacy 訊號:product_id == 0x0200 (KP_DEVICE_KL720_LEGACY)。 Reviewer M3 + s3:原本只用 substring match `"KDP" in fw and "KDP2" not in fw` 對 KDP3(未來 firmware)會誤判 legacy → 改用顯式 prefix 比對表 + 已知字串 enumeration、確保覆蓋 KDP1 各種 firmware 字串變體、forward-compat KDP3+。 Returns True if needs SDK loader stage、False if can short-circuit to flashing. """ if product_id == 0x0200: return True # KL720 KDP1 legacy(pid 明示、不靠 firmware 字串) fw = (firmware_str or "").strip().upper() # 已知 KDP1 legacy firmware 字串完整列舉(明示比對、不靠 substring) legacy_exact = { "", # 某些 USB Boot state 不回 firmware string "KDP", "KDP1", "USB BOOT", "USB BOOT LOADER", "LOADER", "BOOTLOADER", } if fw in legacy_exact: return True # KDP1.x(KDP1.0 / KDP1.5 等版本字串) if fw.startswith("KDP1.") or fw.startswith("KDP1 "): return True # 明示放行 KDP2 / KDP3+(forward-compat、避免 substring match 對未來 firmware 誤判) # KDP2.x / KDP3.x / KDP4.x ... 皆為 modern firmware、不需走 loader for prefix in ("KDP2", "KDP3", "KDP4", "KDP5", "KDP6", "KDP7", "KDP8", "KDP9"): if fw.startswith(prefix): return False # 未知 firmware 字串:保守 default = 不走 loader(避免誤觸 loader stage brick device) # 例:未來 firmware 用全新命名("NEF"、"K3"、等)→ 假設是 modern firmware # 若這判斷錯了、verify 階段會 detect verify_mismatch、不致 brick return False def _fw_eta_ms(chip, current_stage): """估算剩餘 ms(給前端顯示 ~X 秒、非精確)。 依 TDD §4.2:UI 顯示「~X 秒 remaining」、精度低可接受。 """ # 各 stage 預估完成時刻(以升級開始為 0): if chip == "KL520": total_ms = 30000 # AC-FW-1.7 預估 30s cum = {"preparing": 2000, "loading": 8000, "flashing": 22000, "verifying": 28000} else: # KL720 total_ms = 180000 # AC-FW-1.7 預估 180s cum = {"preparing": 5000, "loading": 30000, "flashing": 160000, "verifying": 175000} done_at = cum.get(current_stage, total_ms) return max(0, total_ms - done_at) # ── Firmware upgrade exceptions + failure handler ──────────────────── # # Reviewer M1:原本 _FwError / _FwTimeoutError / _fw_handle_failure 宣告位於 # handle_firmware_upgrade **之後**(語法上 Python module load 時會先掃完整個檔 # 才走 handler、所以 happy-path 不會炸 NameError、但 readability 差、且若有人 # 在 handler 中間插入 module-level code 觸發呼叫就會炸)。 # 移到 handler 之前、讓讀者從上而下能理解 error flow。 class _FwError(Exception): """Internal exception carrying (stage, reason, message) for firmware ops.""" def __init__(self, stage, reason, message): super().__init__(message) self.stage = stage self.reason = reason self.message = message class _FwTimeoutError(Exception): """Raised when total upgrade duration exceeds chip timeout.""" def __init__(self, stage): super().__init__(f"timeout at stage={stage}") self.stage = stage def _fw_handle_failure(stage, reason, message, before_fw, start_ts, dg, lib, raw=""): """彙整失敗 progress event + return 給 caller 的 error dict。 對齊 TDD §6.1 失敗回傳格式: {"error":, "stage":, "reason":, "raw_error":} Reviewer m3:原本此 helper 內 disconnect、caller 的 finally 也 disconnect、 雙重 disconnect 對 SDK 行為未定。改成「single owner of disconnect」原則: 本 helper 不再 disconnect、由 caller 的 finally 統一處理。本函式只負責 emit progress event + 組裝 error dict。 """ elapsed = int((time.monotonic() - start_ts) * 1000) _log(f"firmware_upgrade FAILED: stage={stage}, reason={reason}, " f"message={message}, elapsed_ms={elapsed}") _fw_emit_progress( "error", message=message, elapsed_ms=elapsed, eta_ms=0, extra={ "error": message, "reason": reason, "raw_error": raw or message, "before_version": before_fw, }, ) return { "error": message, "stage": stage, "reason": reason, "raw_error": raw or message, } def handle_firmware_upgrade(params): """A 階段 M9-1:自動升級 KDP1 → KDP2、KL520 與 KL720。 對應 TDD §6.1 表 + §5.1 流程: Input: {"port": "", "chip": "KL520" | "KL720"} Output (success): {"status":"upgraded", "before_firmware":, "after_firmware":, "method":"ctypes_kp_update_kdp_firmware_from_files", "duration_ms":} Output (failure): {"error":, "stage":, "reason":, "raw_error":} 每進入一個 stage 透過 _fw_emit_progress() 推 progress event 到 stderr, Go driver 抓 stderr line-by-line 轉成 WebSocket FirmwareProgress 給前端。 """ global _firmware_upgrade_in_progress if not HAS_KP: return {"error": "kp module not available", "stage": "preparing", "reason": "scan_not_found", "raw_error": "kp not available"} chip = params.get("chip", "KL520") port = str(params.get("port", "")) if chip not in ("KL520", "KL720"): return {"error": f"unsupported chip for A 階段: {chip}", "stage": "preparing", "reason": "scan_not_found", "raw_error": f"chip={chip} not in (KL520, KL720)"} timeout_s = KL520_UPGRADE_TIMEOUT_S if chip == "KL520" else KL720_UPGRADE_TIMEOUT_S start_ts = time.monotonic() def elapsed_ms(): return int((time.monotonic() - start_ts) * 1000) def check_timeout(current_stage): if (time.monotonic() - start_ts) > timeout_s: raise _FwTimeoutError(current_stage) # ── AC-FW-1.9 graceful shutdown 拒絕:標記升級進行中 ── # Reviewer m4:原本還寫 _firmware_upgrade_start_ts 全域、與 SIGTERM handler # closure 重複、已移除、改由 closure capture start_ts 為 single source。 _firmware_upgrade_in_progress = True # 在升降版進入 critical section 期間註冊 SIGTERM handler # (收 SIGTERM 不立即退、改 log warning event;實際 server 端 lock # 由 M9-2 Go driver / M9-3 service 實作、bridge.py 只負責「正在跑時 # 拒絕被 kill」) _fw_register_sigterm_handler(start_ts) method = "ctypes_kp_update_kdp_firmware_from_files" before_fw = "" lib = None dg = None try: # ── preparing:scan + connect ──────────────────────────────── _fw_emit_progress( "preparing", message=f"scanning {chip} on port {port}", elapsed_ms=elapsed_ms(), eta_ms=_fw_eta_ms(chip, "preparing"), ) check_timeout("preparing") # 先 disconnect 既有 _device_group(若有)、避免 handle 衝突 _clear_device_group() target = _fw_scan_target(port) if target is None: raise _FwError( "preparing", "scan_not_found", f"device with port_id={port} not found in scan", ) before_fw = str(target.firmware) target_port_id = int(target.usb_port_id) target_pid = int(target.product_id) _log(f"firmware_upgrade: chip={chip}, port={target_port_id}, " f"pid=0x{target_pid:04X}, firmware='{before_fw}'") # ── 解析 firmware 檔路徑 ───────────────────────────────────── fw_paths = _resolve_firmware_paths_full(chip) if fw_paths["scpu"] is None or fw_paths["ncpu"] is None: raise _FwError( "preparing", "scan_not_found", f"firmware files not found for {chip} " f"(scpu/ncpu missing in server/scripts/firmware/{chip}/)", ) # ── 載入 libkplus + ctypes binding ────────────────────────── try: lib = _fw_load_libkplus() except Exception as e: raise _FwError( "preparing", "connect_failed", f"libkplus load failed: {e}", ) # ── connect with magic(allow KDP1 legacy device)─────────── try: dg = _fw_connect_with_magic(lib, target_port_id) except RuntimeError as e: raise _FwError("preparing", "connect_failed", str(e)) # set timeout for SDK operations(注意:不是整體 upgrade timeout、 # 是單一 SDK call 的 timeout、避免單個 kp_load/update call 卡住) lib.kp_set_timeout(dg, int(timeout_s * 1000)) # ── 判斷是否走 SDK loader stage ────────────────────────────── # Reviewer M2:原本控制流隱式(`if needs_loader: if loader_path is None: ...` # nested)、讀者不易看清「實際會跑 loading stage」的條件。改為三個顯式 bool: # # needs_loader = device 處於 KDP1 legacy state(_fw_classify_legacy) # should_run_loader_stage = 實際會跑 loading stage(loader.bin 存在 + needs_loader) # loader_required_but_missing = KL520 KDP1 legacy 但缺 loader.bin(必失敗) # # 三個情境的流程: # 1. KL520 KDP1 legacy + loader.bin 存在 → loading → flashing(SDK load) # → verifying → done (should_run_loader_stage=True) # 2. KL520 KDP1 legacy + loader.bin 缺 → fail at loading (loader_write_failed) # 3. KL720 KDP1 legacy + loader.bin 缺 → skip loading、直接 flashing(warrenchen 模式) # → verifying → done (should_run_loader_stage=False) # 4. already KDP2(KL520/KL720)→ skip loading、直接 flashing(warrenchen 模式) # → verifying → done (should_run_loader_stage=False) needs_loader = _fw_classify_legacy(before_fw, target_pid) loader_path = fw_paths["loader"] should_run_loader_stage = needs_loader and loader_path is not None loader_required_but_missing = ( needs_loader and loader_path is None and chip == "KL520" ) _log(f"firmware_upgrade: needs_loader={needs_loader}, " f"should_run_loader_stage={should_run_loader_stage}, " f"loader_required_but_missing={loader_required_but_missing}, " f"legacy={'yes' if needs_loader else 'no'}") # ── 情境 2:KL520 KDP1 legacy 但缺 loader.bin → 直接失敗 ───── if loader_required_but_missing: check_timeout("loading") raise _FwError( "loading", "loader_write_failed", f"fw_loader.bin not found for {chip} but device is in " f"KDP1 legacy state (firmware='{before_fw}')", ) # ── 情境 1:跑 loading stage(KL520 KDP1 legacy + loader.bin)── if should_run_loader_stage: check_timeout("loading") _fw_emit_progress( "loading", message="writing USB Boot loader firmware", elapsed_ms=elapsed_ms(), eta_ms=_fw_eta_ms(chip, "loading"), ) ret = lib.kp_update_kdp_firmware_from_files( dg, loader_path.encode("utf-8"), None, # loader stage: ncpu = NULL True, # auto_reboot ) if ret != KP_SUCCESS: raise _FwError( "loading", "loader_write_failed", f"kp_update_kdp_firmware_from_files(loader) ret={ret} " f"({_fw_errstr(lib, ret)})", ) # auto_reboot 後 disconnect 可能失敗(USB re-enumerate)容忍 try: lib.kp_disconnect_devices(dg) except Exception: pass # disconnect 完設 dg=None、避免 finally double-disconnect 已 freed handle dg = None # 等 device reboot 完進 USB Boot mode(Loader firmware loaded) time.sleep(USB_WAIT_AFTER_REBOOT_MS / 1000.0) # rescan + reconnect with magic target = _fw_scan_target(port) if target is None: raise _FwError( "loading", "disconnect_during_op", f"device disappeared after loader write, port={port}", ) try: dg = _fw_connect_with_magic(lib, int(target.usb_port_id)) except RuntimeError as e: raise _FwError( "loading", "connect_failed", f"reconnect after loader failed: {e}", ) lib.kp_set_timeout(dg, int(timeout_s * 1000)) elif needs_loader: # 情境 3:KL720 KDP1 legacy 沒 loader.bin → 跳過 loading、直接 flashing # warrenchen 模式:kp_update_kdp_firmware_from_files(scpu, ncpu, True) 一次寫 _log(f"firmware_upgrade: {chip} legacy without loader.bin、" f"skipping loading stage, will go directly to flashing") # ── flashing:寫入 KDP2 firmware(scpu + ncpu)───────────── check_timeout("flashing") _fw_emit_progress( "flashing", message="writing KDP2 firmware (scpu + ncpu)", elapsed_ms=elapsed_ms(), eta_ms=_fw_eta_ms(chip, "flashing"), ) if should_run_loader_stage: # 情境 1:device 已透過 loader stage 進 Loader mode、用 # kp_load_firmware_from_file 載 scpu + ncpu 到 RAM ret = lib.kp_load_firmware_from_file( dg, fw_paths["scpu"].encode("utf-8"), fw_paths["ncpu"].encode("utf-8"), ) if ret != KP_SUCCESS: raise _FwError( "flashing", "upgrade_mid_failed", f"kp_load_firmware_from_file ret={ret} " f"({_fw_errstr(lib, ret)})", ) else: # 情境 3 / 4:沒走 loader stage(KL720 legacy without loader.bin、 # 或 already KDP2)→ warrenchen 模式:直接 # kp_update_kdp_firmware_from_files(scpu, ncpu, True) 一次寫 ret = lib.kp_update_kdp_firmware_from_files( dg, fw_paths["scpu"].encode("utf-8"), fw_paths["ncpu"].encode("utf-8"), True, # auto_reboot ) if ret != KP_SUCCESS: raise _FwError( "flashing", "upgrade_mid_failed", f"kp_update_kdp_firmware_from_files ret={ret} " f"({_fw_errstr(lib, ret)})", ) # disconnect after upgrade:auto_reboot 後 disconnect 失敗預期、容忍 try: lib.kp_disconnect_devices(dg) except Exception: pass dg = None # ── verifying:等 USB re-enumerate → rescan → 驗 firmware 字串 ── check_timeout("verifying") _fw_emit_progress( "verifying", message="waiting USB re-enumerate and verifying firmware version", elapsed_ms=elapsed_ms(), eta_ms=_fw_eta_ms(chip, "verifying"), ) # AC-FW-1.6: 等 5-8 秒 USB stable target_after, waited = _fw_rescan_and_wait( port, max_wait_s=USB_WAIT_AFTER_UPGRADE_MS / 1000.0 + 3.0, # 5 + 3 = 8s 上界 initial_sleep_s=USB_WAIT_AFTER_UPGRADE_MS / 1000.0, ) if target_after is None: raise _FwError( "verifying", "verify_not_found", f"device not found after upgrade (waited {waited:.1f}s)、" f"USB may still be re-enumerating, please re-plug", ) after_fw = str(target_after.firmware) after_pid = int(target_after.product_id) # 驗證 firmware 字串已升到 KDP2(不再是 KDP1 legacy) if _fw_classify_legacy(after_fw, after_pid): raise _FwError( "verifying", "verify_mismatch", f"firmware after upgrade still appears legacy: " f"firmware='{after_fw}', pid=0x{after_pid:04X}", ) # ── done ── duration_ms = elapsed_ms() _fw_emit_progress( "done", message=f"upgraded from '{before_fw}' to '{after_fw}'", elapsed_ms=duration_ms, eta_ms=0, ) return { "status": "upgraded", "before_firmware": before_fw, "after_firmware": after_fw, "method": method, "duration_ms": duration_ms, } except _FwTimeoutError as e: return _fw_handle_failure( e.stage, "timeout", f"upgrade exceeded {timeout_s}s timeout at stage={e.stage}", before_fw, start_ts, dg, lib, raw=str(e), ) except _FwError as e: return _fw_handle_failure( e.stage, e.reason, e.message, before_fw, start_ts, dg, lib, raw=str(e), ) except Exception as e: import traceback tb = traceback.format_exc() _log(f"firmware_upgrade UNEXPECTED EXCEPTION: {type(e).__name__}: {e}\n{tb}") return _fw_handle_failure( "flashing", "upgrade_mid_failed", f"unexpected: {type(e).__name__}: {e}", before_fw, start_ts, dg, lib, raw=tb, ) finally: _firmware_upgrade_in_progress = False # Reviewer m3:disconnect 的 single owner = 此 finally block。 # _fw_handle_failure 已改為「不在裡面 disconnect」、避免 double-disconnect。 # success path 在 1810 行已 disconnect 並設 dg=None、此處 if dg is not None # 會 short-circuit 跳過、不會 double。 # fail path:dg 可能還持有 handle、由本 finally 統一收尾。 if dg is not None and lib is not None: try: lib.kp_disconnect_devices(dg) except Exception: pass dg = None # 確保不會被外部誤用 _fw_unregister_sigterm_handler() # ── SIGTERM handler (AC-FW-1.9 graceful shutdown rejection) ────────── # # 升級進行中收到 SIGTERM 時,不立即退出、改在 stderr push warning event。 # 實際的 server-side lock 機制由 M9-2 / M9-3 實作(progress.md「未解決問題」 # 註記為依賴)。本處 bridge.py 端的責任:「正在跑時拒絕被 kill」。 # # Windows 沒有 SIGTERM 概念、改用 atexit。Linux/macOS 用 signal handler。 _fw_original_sigterm_handler = None def _fw_register_sigterm_handler(start_ts): """註冊 SIGTERM handler:升級進行中時拒絕並 log warning。""" global _fw_original_sigterm_handler if sys.platform == "win32": return # Windows 沒 SIGTERM try: import signal def handler(signum, frame): if _firmware_upgrade_in_progress: elapsed = int((time.monotonic() - start_ts) * 1000) try: print( json.dumps({ "event": "shutdown_rejected", "reason": "firmware_upgrade_in_progress", "task": "firmware_upgrade", "elapsed_ms": elapsed, }), file=sys.stderr, flush=True, ) except Exception: pass # 拒絕 SIGTERM:不呼叫 sys.exit、不 raise、繼續執行升級 return # 沒升級進行中、走預設行為 if callable(_fw_original_sigterm_handler): _fw_original_sigterm_handler(signum, frame) else: sys.exit(0) _fw_original_sigterm_handler = signal.signal(signal.SIGTERM, handler) except Exception as e: _log(f"SIGTERM handler registration failed: {e}") def _fw_unregister_sigterm_handler(): """還原 SIGTERM handler 為 install 前狀態。""" global _fw_original_sigterm_handler if sys.platform == "win32": return try: import signal if _fw_original_sigterm_handler is not None: signal.signal(signal.SIGTERM, _fw_original_sigterm_handler) _fw_original_sigterm_handler = None else: signal.signal(signal.SIGTERM, signal.SIG_DFL) except Exception: pass # ── Main loop ──────────────────────────────────────────────────────── def main(): """Main loop: read JSON commands from stdin, write responses to stdout.""" # The Kneron C SDK may write ANSI-colored warnings directly to fd 1 # (stdout), which corrupts our JSON-RPC protocol. To prevent this we # dup the real stdout fd, then redirect fd 1 to stderr so any C-level # writes go to stderr. Our JSON responses use the duped fd. _real_stdout_fd = os.dup(1) # duplicate fd 1 os.dup2(2, 1) # fd 1 now points to stderr # encoding 必須顯式指定 —— os.fdopen() 不帶 encoding 時會用 # locale.getpreferredencoding(),在繁中 Windows 上是 cp950。這個檔案 # 物件是 JSON-RPC 的**回應通道**,且它是全新建立的,不受 # _force_utf8_stdio() 對 sys.stdout 的 reconfigure 影響,所以必須在 # 這裡各自綁一次。errors 理由同 _force_utf8_stdio():寧可壞一個字元 # 也不要讓 bridge 崩潰而導致裝置失聯。 _real_stdout = os.fdopen( _real_stdout_fd, "w", encoding="utf-8", errors="replace") sys.stdout = sys.stderr # Python-level redirect too def _respond(obj): """Write a JSON response to the real stdout (not stderr).""" _real_stdout.write(json.dumps(obj) + "\n") _real_stdout.flush() # Signal readiness _respond({"status": "ready"}) _log(f"Bridge started (kp={'yes' if HAS_KP else 'no'}, pyusb={'yes' if HAS_PYUSB else 'no'}, cv2={'yes' if HAS_CV2 else 'no'})") for line in sys.stdin: line = line.strip() if not line: continue try: cmd = json.loads(line) action = cmd.get("cmd", "") if action == "scan": result = handle_scan() elif action == "connect": result = handle_connect(cmd) elif action == "disconnect": result = handle_disconnect(cmd) elif action == "reset": result = handle_reset(cmd) elif action == "load_model": result = handle_load_model(cmd) elif action == "set_inference_options": result = handle_set_inference_options(cmd) elif action == "inference": result = handle_inference(cmd) elif action == "firmware_upgrade": result = handle_firmware_upgrade(cmd) else: result = {"error": f"unknown command: {action}"} _respond(result) except Exception as e: _respond({"error": str(e)}) def _cleanup(): """Explicitly disconnect and clear _device_group before Python GC runs. KneronPLUS SDK's DeviceGroup.__del__ calls kp_disconnect_devices on a native handle that may already be freed when the interpreter is shutting down, causing 'OSError: access violation reading 0x00...'. By doing a clean disconnect + setting the global to None here, __del__ becomes a no-op (None has no __del__). """ global _device_group if _device_group is not None: try: kp.core.disconnect_devices(_device_group) except Exception: pass _device_group = None if __name__ == "__main__": import atexit atexit.register(_cleanup) main() _cleanup() # also call synchronously in case atexit doesn't fire