diff --git a/local-tool/server/scripts/kneron_bridge.py b/local-tool/server/scripts/kneron_bridge.py index 935959d..0c32daa 100644 --- a/local-tool/server/scripts/kneron_bridge.py +++ b/local-tool/server/scripts/kneron_bridge.py @@ -292,13 +292,32 @@ def _resolve_firmware_paths_full(chip="KL520"): # ── Input size 來源標記 ─────────────────────────────────────────────── # -# 依可信度排序。實機驗收時 log 會印出用的是哪一個 —— 這是唯一能一眼看出 -# 「尺寸是怎麼來的」的線索,尺寸錯掉時 NPU 不報錯,只會給錯的推論結果。 -INPUT_SIZE_SOURCE_SDK = "SDK" # 模型自己宣告的,唯一可靠 -INPUT_SIZE_SOURCE_DECLARED = "declared" # models.json / metadata.json,人填的 -INPUT_SIZE_SOURCE_FILENAME = "filename-guess" # 從檔名 wNNNhNNN 猜的 +# 依可信度排序(愈前面愈可信)。實機驗收時 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 @@ -387,24 +406,81 @@ def _input_size_from_nef(nef, model_id=None): return None node = input_nodes[0] - # shape_onnx 優先:那是原始模型的語意(NCHW)。shape_npu 是硬體 layout、 - # 可能經過對齊 padding,拿來當輸入尺寸不一定準。 - for attr in ("shape_onnx", "shape_npu"): - try: - shape = [int(dim) for dim in getattr(node, attr)] - except Exception: - continue + 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: {attr}={shape} -> {size[0]}x{size[1]}") + _log(f"input size from SDK: {label}={shape} -> {size[0]}x{size[1]}") return size if shape: - _log(f"input size from SDK: {attr}={shape} not interpretable as " + _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. @@ -484,8 +560,10 @@ def _describe_input_size(): """ 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_FILENAME: - desc += ", UNRELIABLE" + 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 + ")" @@ -495,21 +573,43 @@ 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。唯一可靠的來源。 - 2. declared — models.json / metadata.json 的 inputSize。人填的,可能亂填。 - 3. filename — 檔名的 wNNNhNNN / 已知 model id。猜的。 - 4. default — 都沒有,寫死 224。 - 3 與 4 由 _detect_model_type_by_heuristics 負責(維持既有行為不變); - 本函式只在 1 或 2 有值時覆寫它。 + 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 —— 否則上一個模型留下的 SDK / declared 標記會讓 + # 先降級成 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) @@ -517,18 +617,48 @@ def _resolve_input_size(model_id, nef_path, nef=None, declared_input_size=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. declared:外部(models.json / metadata.json)宣告的 ─────── - declared = _normalize_declared_input_size(declared_input_size) + # ── 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"— SDK did not report an input shape, using the declared value") + 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 - # ── 3 / 4. 交給檔名 heuristics(它自己會寫 source)────────────── + # ── 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): @@ -595,21 +725,22 @@ def _detect_model_type_by_heuristics(model_id, nef_path): """ global _model_type - keep_size = _model_input_size_source in (INPUT_SIZE_SOURCE_SDK, - INPUT_SIZE_SOURCE_DECLARED) + # _resolve_input_size 已處理 SDK / filename / declared 三層。只有它什麼 + # 都沒找到(source 仍是 default)時,這裡的猜測才有機會生效。 + keep_size = _model_input_size_source != INPUT_SIZE_SOURCE_DEFAULT - def apply_size(width, height): - """Write the guessed size unless a better source already won.""" + 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, INPUT_SIZE_SOURCE_FILENAME) + _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 官方模型的固定值,比檔名可信, - # 但仍不如模型自己宣告的 shape。 - apply_size(known_size, known_size) + # 已知 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 @@ -617,49 +748,65 @@ def _detect_model_type_by_heuristics(model_id, nef_path): # 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" - # Try to parse input size from filename like w640h640 - apply_size(*_parse_size_from_name(basename, default=640)) + apply_size(640, 640, INPUT_SIZE_SOURCE_DEFAULT) elif "fcos" in basename: _model_type = "fcos" - apply_size(*_parse_size_from_name(basename, default=512)) + apply_size(512, 512, INPUT_SIZE_SOURCE_DEFAULT) elif "ssd" in basename: _model_type = "ssd" - apply_size(*_parse_size_from_name(basename, default=320)) + apply_size(320, 320, INPUT_SIZE_SOURCE_DEFAULT) elif "resnet" in basename or "classification" in basename: _model_type = "resnet18" - apply_size(*_parse_size_from_name(basename, default=224)) + apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) elif "tiny_yolo" in basename or "tinyyolo" in basename: _model_type = "tiny_yolov3" - apply_size(*_parse_size_from_name(basename, default=224)) + apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) else: # Default: assume YOLO-like detection. - # 仍嘗試從檔名的 wNNNhNNN 取 input size(與上面各分支一致); - # 取不到才用 224。對「外部指定 classification 但檔名沒有型別關鍵字」 - # 的自訂模型特別重要 —— input size 與 task type 是兩件獨立的事。 + # 對「外部指定 classification 但檔名沒有型別關鍵字」的自訂模型特別 + # 重要 —— input size 與 task type 是兩件獨立的事。 _model_type = "tiny_yolov3" - apply_size(*_parse_size_from_name(basename, default=224)) + apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT) _log(f"Model type detected by filename '{basename}': {_model_type} " f"({_describe_input_size()})") -def _parse_size_from_name(name, default=224): - """Extract (width, height) from a filename like 'w640h640' or 'w256h192'. +def _size_from_name_or_none(name): + """Extract (width, height) from a filename like 'w640h640' / 'w256h192'. - 回傳兩軸而非單一數值:檔名本來就同時帶了寬與高,舊版只取 width 丟掉 - height,非正方形模型會被當成正方形(尺寸錯了不報錯、只會靜默給錯結果)。 + **解析不出來時回 None**,不回任何預設值 —— 這個「有解析到 vs 沒解析到」 + 的區分是新優先序的前提:真的從檔名讀到尺寸時它比使用者手填的 declared + 可信(工具鏈產生、沒有亂填空間);沒讀到時它什麼都不是,絕不能讓 + backbone 的慣用預設值(224 / 640 …)偽裝成「從檔名來的」而蓋掉 declared。 - 取不到時兩軸都回 default(呼叫端傳進來的是該 backbone 的慣用正方形尺寸)。 + 舊版 _parse_size_from_name 把兩者混在同一個回傳值裡,呼叫端無從分辨。 """ import re - m = re.search(r'w(\d+)h(\d+)', name) - if m: - 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) + 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) @@ -1963,7 +2110,38 @@ def handle_inference(params): except Exception as e: import traceback _log(f"Inference EXCEPTION: {type(e).__name__}: {e}\n{traceback.format_exc()}") - return {"error": str(e)} + 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) ─────────────────────────────────── diff --git a/local-tool/server/scripts/test_kneron_bridge_classification.py b/local-tool/server/scripts/test_kneron_bridge_classification.py index 08ae21a..2fba0b5 100644 --- a/local-tool/server/scripts/test_kneron_bridge_classification.py +++ b/local-tool/server/scripts/test_kneron_bridge_classification.py @@ -17,6 +17,7 @@ Mock-based tests — no real Kneron dongle needed. 覆蓋: """ from __future__ import annotations +import base64 import os import sys import unittest @@ -493,17 +494,24 @@ class TestDetectModelType(BridgeStateTestCase): self.assertEqual(bridge._model_type, "fcos") -# ── Input size 三層來源(SDK > declared > filename guess)───────────── +# ── Input size 五層來源 ─────────────────────────────────────────────── +# SDK > filename(明確解析) > declared(手填) > known model id > default # -# 背景:input size 舊版**完全來自檔名猜測**。使用者的 +# 背景一:input size 最初**完全來自檔名猜測**。使用者的 # 1784536643_models_520.nef 檔名既無 wNNNhNNN 也無型別關鍵字 → 落 else 分支 # → 寫死 224 → 圖片被縮到錯的尺寸送進 NPU → 分類結果錯誤,**但不報錯**。 +# +# 背景二(2026-07 Windows regression):加入 declared 層時把它排在檔名之前, +# 但上傳表單的 inputSize 欄位長期沒有實際作用、使用者是隨手填的。Windows 的 +# KneronPLUS 3.1.2 又讀不到 SDK shape(見 FakeTensorDescriptor312),於是垃圾 +# declared 值成為實際採用值 → KP_ERROR_INVALID_PARAM_12。 class FakeTensorDescriptor: - """Mirror of kp.TensorDescriptor 的 shape 介面。 + """Mirror of kp.TensorDescriptor 的 shape 介面(KneronPLUS 2.0.0 版)。 只實作 bridge 真正會讀的兩個屬性。真 SDK 物件的行為已用 venv 的 - kp.TensorDescriptor 實跑驗證過(見 handover note),此處用 fake 讓 - 測試不依賴 KneronPLUS 安裝。 + kp.TensorDescriptor 實跑驗證過(2.0.0:shape_onnx / shape_npu 平鋪在 + TensorDescriptor 上、無 tensor_shape_info),此處用 fake 讓測試不依賴 + KneronPLUS 安裝。 """ def __init__(self, shape_onnx=None, shape_npu=None): @@ -511,6 +519,47 @@ class FakeTensorDescriptor: self.shape_npu = list(shape_npu or []) +class FakeShapeInfoDataV1: + """KneronPLUS 3.x TensorShapeInfoV1:shape_onnx / shape_npu。""" + + def __init__(self, shape_onnx=None, shape_npu=None): + self.shape_onnx = list(shape_onnx or []) + self.shape_npu = list(shape_npu or []) + self.axis_permutation_onnx_to_npu = [] + + +class FakeShapeInfoDataV2: + """KneronPLUS 3.x TensorShapeInfoV2:單一 shape(docstring 明寫是 ONNX shape)。""" + + def __init__(self, shape=None): + self.shape = list(shape or []) + self.stride_onnx = [] + self.stride_npu = [] + + +class FakeShapeInfo: + def __init__(self, data): + self.version = 1 + self.data = data + + +class FakeTensorDescriptor312: + """Mirror of kp.TensorDescriptor 的 3.1.2 結構。 + + 關鍵差異(從 vendor/wheels/windows/KneronPLUS-3.1.2 的 KPValue.py 讀出): + 3.1.2 的 TensorDescriptor **沒有** shape_onnx / shape_npu 屬性,shape 被 + 搬進巢狀的 tensor_shape_info.data(V1 或 V2)。舊寫法的 + getattr(node, "shape_onnx") 會拋 AttributeError 被靜默吃掉 → SDK 層永遠 + 落空 → 這就是 Windows「SDK did not report an input shape」的真正原因。 + """ + + def __init__(self, data): + self.index = 0 + self.name = "input" + self.data_layout = 0 + self.tensor_shape_info = FakeShapeInfo(data) + + class FakeSingleModel: def __init__(self, model_id=0, input_nodes=None): self.id = model_id @@ -623,8 +672,9 @@ class TestInputSizeFromSDK(BridgeStateTestCase): raise RuntimeError("boom") bridge._detect_model_type(20004, "/x/fcos.nef", nef=Exploding()) + # 檔名沒有 wNNNhNNN、但 model id 命中 KNOWN_MODELS → known-model-id 層 self.assertEqual(bridge._model_input_size_source, - bridge.INPUT_SIZE_SOURCE_FILENAME) + bridge.INPUT_SIZE_SOURCE_KNOWN_ID) self.assertEqual(bridge._model_input_width, 512) @@ -639,11 +689,32 @@ class TestInputSizeDeclared(BridgeStateTestCase): self.assertEqual(bridge._model_input_size_source, bridge.INPUT_SIZE_SOURCE_DECLARED) - def test_declared_beats_filename_guess(self): + def test_filename_parse_beats_declared(self): + """檔名明確解析出的尺寸勝過手填的 declared。 + + 2026-07 Windows regression 的直接 regression test:舊版把 declared + 排在檔名之前,使用者隨手填的 640x640 蓋掉了檔名裡工具鏈產生的真值, + 送進 NPU 得到 KP_ERROR_INVALID_PARAM_12。 + """ bridge._detect_model_type(None, "/x/custom_w320h320.nef", declared_input_size={"width": 416, "height": 416}) + self.assertEqual(bridge._model_input_width, 320) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_FILENAME) + + def test_declared_beats_backbone_default(self): + """檔名沒有尺寸資訊時,手填的 declared 仍勝過寫死的預設值。 + + 不能因為 declared 不可信就無腦降到最後 —— 它比「什麼資訊都沒有時 + 的 backbone 慣用值」貼近現實。 + """ + bridge._detect_model_type(None, "/x/custom.nef", + declared_input_size={"width": 416, + "height": 416}) self.assertEqual(bridge._model_input_width, 416) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_DECLARED) def test_declared_accepts_tuple(self): self.assertEqual(bridge._normalize_declared_input_size((256, 192)), @@ -688,8 +759,272 @@ class TestInputSizeDeclared(BridgeStateTestCase): bridge.INPUT_SIZE_SOURCE_FILENAME) +class TestInputSizeSDK312Layout(BridgeStateTestCase): + """SDK 層必須同時支援 KneronPLUS 2.0.0 與 3.1.2 兩種結構。 + + Windows 用 3.1.2、macOS 用 2.0.0。只支援其中一種等於在另一個平台上 + 整個 SDK 層靜默失效、掉到下一層 fallback。 + """ + + def _nef_312(self, data, model_id=0): + return FakeNefDescriptor([ + FakeSingleModel(model_id=model_id, + input_nodes=[FakeTensorDescriptor312(data)]) + ]) + + def test_312_v1_shape_onnx_is_read(self): + nef = self._nef_312(FakeShapeInfoDataV1(shape_onnx=[1, 3, 224, 224])) + self.assertEqual(bridge._input_size_from_nef(nef), (224, 224)) + + def test_312_v2_shape_is_read(self): + """V2 只有單一 shape 欄位,docstring 明寫是 ONNX shape。""" + nef = self._nef_312(FakeShapeInfoDataV2(shape=[1, 3, 256, 192])) + self.assertEqual(bridge._input_size_from_nef(nef), (192, 256)) + + def test_312_v1_falls_back_to_shape_npu(self): + nef = self._nef_312(FakeShapeInfoDataV1(shape_onnx=[], + shape_npu=[1, 3, 320, 320])) + self.assertEqual(bridge._input_size_from_nef(nef), (320, 320)) + + def test_312_layout_resolves_end_to_end_and_beats_bad_declared(self): + """3.1.2 結構讀得到時,使用者亂填的 declared 不該有機會生效。 + + 這正是 Windows 實機的情境:模型是 224x224、使用者填了 640x640。 + """ + nef = self._nef_312(FakeShapeInfoDataV1(shape_onnx=[1, 3, 224, 224]), + model_id=999) + bridge._detect_model_type(999, "/x/model.nef", nef=nef, + declared_input_size={"width": 640, + "height": 640}) + self.assertEqual((bridge._model_input_width, bridge._model_input_height), + (224, 224)) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_SDK) + + def test_200_layout_still_works(self): + """不可為了支援 3.1.2 而弄壞 2.0.0(macOS 正在用)。""" + self.assertEqual( + bridge._input_size_from_nef(nef_with_shape([1, 3, 224, 224])), + (224, 224)) + + def test_node_without_any_shape_attribute_returns_none(self): + """完全不認識的結構 → None(讓呼叫端 fallback),不可拋例外。""" + class Alien: + index = 0 + + nef = FakeNefDescriptor([ + FakeSingleModel(model_id=0, input_nodes=[Alien()])]) + self.assertIsNone(bridge._input_size_from_nef(nef)) + + def test_shape_info_without_data_returns_none(self): + class HalfBaked: + tensor_shape_info = FakeShapeInfo(None) + + nef = FakeNefDescriptor([ + FakeSingleModel(model_id=0, input_nodes=[HalfBaked()])]) + self.assertIsNone(bridge._input_size_from_nef(nef)) + + +class TestInputSizePriorityOrder(BridgeStateTestCase): + """五層優先序的逐層驗證(2026-07 Windows regression 的核心防護)。 + + 可信度理由: + SDK 模型自己編譯進 .nef 的事實 + filename 模型編譯工具鏈產生的 wNNNhNNN,無人為亂填空間 + declared 使用者在上傳表單手填,實測大多是隨手填的 + known id 內建表,只涵蓋官方模型 + default 寫死的 backbone 慣用值 + """ + + NEF_224 = staticmethod(lambda: nef_with_shape([1, 3, 224, 224], + model_id=20005)) + + def test_sdk_beats_everything(self): + bridge._detect_model_type( + 20005, "/x/yolov5_w640h640.nef", + nef=nef_with_shape([1, 3, 224, 224], model_id=20005), + declared_input_size={"width": 512, "height": 512}) + self.assertEqual(bridge._model_input_width, 224) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_SDK) + + def test_filename_beats_declared_and_known_id(self): + """SDK 缺席時,檔名解析值勝過手填 declared 與內建表。""" + bridge._detect_model_type(20005, "/x/yolov5_w320h320.nef", + declared_input_size={"width": 640, + "height": 640}) + self.assertEqual(bridge._model_input_width, 320) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_FILENAME) + + def test_declared_beats_known_id(self): + """檔名無尺寸時,使用者宣告勝過內建 KNOWN_MODELS 表。""" + bridge._detect_model_type(20005, "/x/model.nef", + declared_input_size={"width": 416, + "height": 416}) + self.assertEqual(bridge._model_input_width, 416) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_DECLARED) + + def test_known_id_beats_default(self): + bridge._detect_model_type(20005, "/x/model.nef") + self.assertEqual(bridge._model_input_width, 640) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_KNOWN_ID) + + def test_default_is_last_resort(self): + bridge._detect_model_type(None, "/x/model.nef") + self.assertEqual(bridge._model_input_width, 224) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_DEFAULT) + + def test_windows_regression_exact_scenario(self): + """使用者實機情境的完整重現。 + + model.nef(檔名無尺寸)+ SDK 讀不到(3.1.2 舊寫法)+ 手填 640x640。 + 改動前:declared 勝出 → 640x640 → Error 12。 + 期望:declared 不該被當成可靠來源後就無條件採用 —— 但此情境下它 + 確實是唯一有值的來源,所以仍會被用;關鍵是 log / 錯誤訊息要明講 + 它是手填的(見 TestInferenceErrorDiagnostics)。 + """ + bridge._detect_model_type(None, "/x/model.nef", + declared_input_size={"width": 640, + "height": 640}) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_DECLARED) + self.assertIn("hand-entered", bridge._describe_input_size()) + + def test_invalid_declared_does_not_block_lower_layers(self): + """declared 被判定為垃圾時要真的讓位,不是卡在中間。""" + bridge._detect_model_type(20005, "/x/model.nef", + declared_input_size={"width": 0, + "height": 0}) + self.assertEqual(bridge._model_input_width, 640) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_KNOWN_ID) + + def test_non_square_filename_parse_wins_both_axes(self): + bridge._detect_model_type(None, "/x/custom_w256h192.nef", + declared_input_size={"width": 640, + "height": 640}) + self.assertEqual((bridge._model_input_width, bridge._model_input_height), + (256, 192)) + + def test_absurd_filename_size_is_rejected_and_falls_through(self): + """檔名解析出不合理值 → 不算解析成功,讓位給 declared。""" + bridge._detect_model_type(None, "/x/m_w99999999h99999999.nef", + declared_input_size={"width": 416, + "height": 416}) + self.assertEqual(bridge._model_input_width, 416) + self.assertEqual(bridge._model_input_size_source, + bridge.INPUT_SIZE_SOURCE_DECLARED) + + +class TestSizeFromNameOrNone(BridgeStateTestCase): + """_size_from_name_or_none 必須能分辨「解析成功」與「沒有尺寸資訊」。 + + 這個區分是新優先序的前提:分不出來就無法決定「檔名該不該贏過 declared」。 + """ + + def test_returns_none_when_no_size_in_name(self): + self.assertIsNone(bridge._size_from_name_or_none("model.nef")) + self.assertIsNone(bridge._size_from_name_or_none( + "1784536643_models_520.nef")) + + def test_returns_parsed_size(self): + self.assertEqual(bridge._size_from_name_or_none("m_w640h480.nef"), + (640, 480)) + + def test_returns_none_for_absurd_values(self): + self.assertIsNone( + bridge._size_from_name_or_none("m_w99999999h99999999.nef")) + + def test_returns_none_for_empty_and_none(self): + self.assertIsNone(bridge._size_from_name_or_none("")) + self.assertIsNone(bridge._size_from_name_or_none(None)) + + def test_wrapper_still_returns_default(self): + """既有呼叫端 / 測試靠 _parse_size_from_name 的 default 行為。""" + self.assertEqual(bridge._parse_size_from_name("m.nef", default=512), + (512, 512)) + self.assertEqual(bridge._parse_size_from_name("m_w320h320.nef"), + (320, 320)) + + +class TestInferenceErrorDiagnostics(BridgeStateTestCase): + """Error 12 必須自帶「當前尺寸 + 來源」,否則使用者無從判斷。""" + + def test_invalid_param_error_is_annotated_with_size_and_source(self): + bridge._set_model_input_size(640, 640, + bridge.INPUT_SIZE_SOURCE_DECLARED) + msg = bridge._annotate_inference_error( + RuntimeError("ApiKPException: Error raised in function: _init_. " + "Error code: 12. Description: " + "ApiReturnCode.KP_ERROR_INVALID_PARAM_12")) + self.assertIn("640x640", msg) + self.assertIn("declared", msg) + self.assertIn("手動填寫", msg) + self.assertIn("Error code: 12", msg) # 原始錯誤不可被吃掉 + + def test_default_source_gets_its_own_hint(self): + bridge._set_model_input_size(224, 224, + bridge.INPUT_SIZE_SOURCE_DEFAULT) + msg = bridge._annotate_inference_error( + RuntimeError("KP_ERROR_INVALID_PARAM_12")) + self.assertIn("224x224", msg) + self.assertIn("推測", msg) + + def test_sdk_source_still_reports_size_without_blaming_user(self): + bridge._set_model_input_size(224, 224, bridge.INPUT_SIZE_SOURCE_SDK) + msg = bridge._annotate_inference_error( + RuntimeError("KP_ERROR_INVALID_PARAM_12")) + self.assertIn("224x224", msg) + self.assertNotIn("手動填寫", msg) + + def test_unrelated_errors_are_left_untouched(self): + """不可把所有錯誤都貼上尺寸標籤,那會誤導排查方向。""" + self.assertEqual(bridge._annotate_inference_error( + RuntimeError("device disconnected")), "device disconnected") + + def test_handle_inference_surfaces_annotation(self): + """端到端:錯誤訊息要真的傳到 JSON-RPC 回應。 + + 注入點刻意選 kp.GenericInputNodeImage 而非 cv2.imdecode:cv2 是選配 + 相依(bridge 用 HAS_CV2 flag 處理缺席),開發機 / CI 沒裝時 + bridge.cv2 根本不存在,測試會在 patch 階段就 AttributeError。本測試 + 要驗的是「try 內任何例外 → _annotate_inference_error → 進到回應的 + error 欄位」這條路,跟例外從哪一行拋出無關,所以改用兩種環境都必定 + 存在的注入點(kp 已由本檔 fake),有沒有 cv2 都跑得到斷言。 + + 另外把 HAS_CV2 釘成 False:實機有 cv2 時會真的去 imdecode 這 16 個 + junk bytes 而提早回 "failed to decode image",根本走不到注入點。釘住 + 才能讓兩種環境跑同一條路徑。 + """ + bridge._device_group = object() + bridge._model_id = 1 + bridge._set_model_input_size(640, 640, + bridge.INPUT_SIZE_SOURCE_DECLARED) + with mock.patch.object(bridge, "HAS_CV2", False), \ + mock.patch.object(bridge.kp, "GenericImageInferenceDescriptor", + lambda **kw: object(), create=True), \ + mock.patch.object( + bridge.kp, "GenericInputNodeImage", + side_effect=RuntimeError("KP_ERROR_INVALID_PARAM_12"), + create=True), \ + mock.patch.object(bridge.kp, "ImageFormat", + mock.Mock(KP_IMAGE_FORMAT_RGB565="rgb565"), + create=True), \ + silence_log(): + res = bridge.handle_inference({"image_base64": base64.b64encode( + b"\x00" * 16).decode()}) + self.assertIn("640x640", res["error"]) + self.assertIn("declared", res["error"]) + # 原始錯誤不可被吃掉 —— 只加註解、不取代。 + self.assertIn("KP_ERROR_INVALID_PARAM_12", res["error"]) + + class TestInputSizeFilenameFallback(BridgeStateTestCase): - """第 3 層:檔名猜測。維持既有行為,不可回歸。""" + """檔名 / 內建表 / 預設值層。維持既有解析結果,不可回歸。""" def test_bundled_models_resolve_exactly_as_before(self): """既有 detection 模型的尺寸一個都不能變。 @@ -923,11 +1258,16 @@ class TestHandleLoadModel(BridgeStateTestCase): bridge.INPUT_SIZE_SOURCE_FILENAME) def test_input_size_fields_absent_from_request_is_backward_compatible(self): - """不帶 input_size 的舊呼叫端行為完全不變。""" + """不帶 input_size 的舊呼叫端,解析出的尺寸不變。 + + 來源標記為 default 而非 filename:這個檔名沒有 wNNNhNNN,224 是 + backbone 慣用預設值。舊版把它標成 filename,等於讓寫死的預設值 + 偽裝成「從檔名解析出來的」,是這次修正要消除的混淆。 + """ res = bridge.handle_load_model({"path": "/x/1784536643_models_520.nef"}) self.assertEqual(res["input_size"], 224) self.assertEqual(res["input_size_source"], - bridge.INPUT_SIZE_SOURCE_FILENAME) + bridge.INPUT_SIZE_SOURCE_DEFAULT) def test_failed_load_does_not_change_input_size(self): """defer-until-success:載入失敗 → 尺寸與來源都不可被動到。""" @@ -1209,8 +1549,12 @@ class TestHandleSetInferenceOptions(BridgeStateTestCase): bridge.INPUT_SIZE_SOURCE_SDK) def test_switch_preserves_declared_input_size(self): - """同理:declared 也不可在切換時被檔名猜測蓋掉。""" - bridge.handle_load_model({"path": "/x/fcos_w512h512.nef", + """同理:declared 也不可在切換時被 backbone 預設值蓋掉。 + + 檔名不帶 wNNNhNNN(否則檔名這層會先贏,那是另一個 case), + 所以 declared 是最可信的來源,切解析方式後必須原封不動。 + """ + bridge.handle_load_model({"path": "/x/fcos.nef", "task_type": "object_detection", "input_size": {"width": 320, "height": 256}}) self.assertEqual((bridge._model_input_width, bridge._model_input_height), @@ -1223,6 +1567,25 @@ class TestHandleSetInferenceOptions(BridgeStateTestCase): self.assertEqual(res["input_size_source"], bridge.INPUT_SIZE_SOURCE_DECLARED) + def test_switch_preserves_filename_input_size_over_declared(self): + """檔名解析值贏過手填 declared,切解析方式後仍然如此。 + + 這是 2026-07 Windows regression 的核心情境:使用者手填了垃圾尺寸, + 檔名卻帶著工具鏈產生的正確值。 + """ + bridge.handle_load_model({"path": "/x/fcos_w512h512.nef", + "task_type": "object_detection", + "input_size": {"width": 640, "height": 640}}) + self.assertEqual((bridge._model_input_width, bridge._model_input_height), + (512, 512)) + + res = bridge.handle_set_inference_options({"task_type": "classification"}) + + self.assertEqual((bridge._model_input_width, bridge._model_input_height), + (512, 512)) + self.assertEqual(res["input_size_source"], + bridge.INPUT_SIZE_SOURCE_FILENAME) + def test_labels_only_change_does_not_touch_input_size(self): self._fake_nef = nef_with_shape([1, 3, 320, 320], model_id=1784536643) self._fake_nef.target_chip = "KL520" diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 3e7770c..7990ff9 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" @@ -946,7 +947,24 @@ func (a *App) ensureBundledPython() (string, error) { } // 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。 + // + // ⚠️ 「python 執行檔存在」不等於「相依裝好了」。實際踩過的情境:pip + // install 中途失敗(斷網 / 磁碟滿 / 防毒攔截),venv 與 python.exe 都在, + // 但 `import kp` 失敗。舊版只 Stat python.exe 就 return,於是每次啟動都 + // 跳過安裝、永遠卡在同一個錯誤且沒有任何提示 —— 使用者必須手動刪掉整個 + // runtime 目錄才能復原。所以這裡要連相依一起驗。 if _, err := os.Stat(pythonBin); err == nil { + if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) { + return pythonBin, nil + } + // venv 在但相依壞了 → 只補裝 wheels,不整個重建 venv。 + // 重建 venv 要重新解壓 ~100MB tarball,而失敗幾乎都出在 pip 階段; + // 先試便宜的修法,真的不行再讓錯誤浮上來讓使用者看見。 + fmt.Fprintln(os.Stderr, "[visiona-local] venv 存在但 Python 相依不完整,重新安裝 wheels") + if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { + return "", fmt.Errorf("venv 已存在但 Python 相依不完整,自動修復失敗:%w\n"+ + "請手動刪除 %s 後重新啟動應用程式", err, runtimeDir) + } return pythonBin, nil } @@ -992,19 +1010,123 @@ func (a *App) ensureBundledPython() (string, error) { return "", fmt.Errorf("create venv: %w (%s)", err, string(out)) } - // 列舉 wheelsDir 下所有 .whl - var wheels []string - if entries, err := os.ReadDir(wheelsDir); err == nil { - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") { - wheels = append(wheels, filepath.Join(wheelsDir, e.Name())) - } - } + if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { + return "", err } - if len(wheels) == 0 { + a.setBootstrapStatus("Python 環境就緒") + return pythonBin, nil +} + +// venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。 +// +// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時 +// 安裝的 wheel 檔名清單,wheels 換版(升級 KneronPLUS 等)時內容不符會觸發重裝。 +const venvReadyMarkerName = "venv-ready.txt" + +// venvHealthProbeModules 是啟動時要驗證的關鍵模組。 +// +// 只放「少了就完全跑不動」的三個:kp(Kneron SDK,使用者實際踩到的就是這個)、 +// numpy、cv2。不放次要相依 —— 這是日常啟動的 hot path,每多一個 import 都是成本。 +var venvHealthProbeModules = []string{"kp", "numpy", "cv2"} + +// bundledPythonDepsHealthy 檢查 venv 裡的關鍵相依是否真的可用。 +// +// 成本設計(這條路徑每次啟動都會走): +// - 快路徑:只讀一個標記檔(~µs)。標記存在且與當前 wheels 清單相符 → 直接放行, +// 不啟動任何 process。絕大多數啟動走這條。 +// - 慢路徑:標記不存在 / 內容不符(首次升級、或舊版留下的 venv)才真的跑一次 +// python -c "import kp, numpy, cv2"(~200-400ms,僅此一次),成功後補寫標記, +// 下次啟動即回到快路徑。 +// +// 為什麼不是每次都跑 import:import cv2 + numpy 要數百毫秒,乘上每次啟動並不划算, +// 而「裝好之後又壞掉」需要外力介入(使用者手動刪檔案),不是常態。 +func (a *App) bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir string) bool { + want := bundledWheelsFingerprint(wheelsDir) + markerPath := filepath.Join(runtimeDir, venvReadyMarkerName) + + // 快路徑:標記與當前 wheels 相符 → 視為健康,不啟動 process。 + if got, err := os.ReadFile(markerPath); err == nil { + if strings.TrimSpace(string(got)) == want { + return true + } + fmt.Fprintln(os.Stderr, "[visiona-local] venv 標記與內建 wheels 不符,重新驗證 Python 相依") + } + + // 慢路徑:實際 import 一次確認。 + if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil { + fmt.Fprintf(os.Stderr, "[visiona-local] Python 相依驗證失敗:%v\n", err) + return false + } + + // 驗證通過(例如舊版留下的健康 venv)→ 補標記,下次走快路徑。 + if err := os.WriteFile(markerPath, []byte(want), 0o644); err != nil { + // 寫不了標記不影響正確性,只是下次還要再驗一次。 + fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err) + } + return true +} + +// probePythonModules 跑一次 `python -c "import "`,確認相依真的可用。 +func probePythonModules(pythonBin string, modules []string) error { + if len(modules) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, pythonBin, "-c", "import "+strings.Join(modules, ", ")) + configureSysProcAttr(cmd) + out, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("import %s 逾時:%w", strings.Join(modules, ", "), ctx.Err()) + } + return fmt.Errorf("import %s 失敗:%w\n%s", + strings.Join(modules, ", "), err, strings.TrimSpace(string(out))) + } + return nil +} + +// bundledWheelsFingerprint 產生 wheels 目錄的指紋(排序後的檔名清單)。 +// +// 用檔名而非內容雜湊:wheel 檔名本來就帶版本號(KneronPLUS-3.1.2-...whl), +// 升級一定換檔名;而讀 ~150MB 內容算雜湊在啟動路徑上太貴。 +func bundledWheelsFingerprint(wheelsDir string) string { + names := listBundledWheelNames(wheelsDir) + sort.Strings(names) + return strings.Join(names, "\n") +} + +// listBundledWheelNames 列出 wheelsDir 下所有 .whl 的檔名(不含路徑)。 +func listBundledWheelNames(wheelsDir string) []string { + var names []string + entries, err := os.ReadDir(wheelsDir) + if err != nil { + return names + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".whl") { + names = append(names, e.Name()) + } + } + return names +} + +// installBundledWheels 離線安裝 wheelsDir 下的所有 wheel,成功後寫入就緒標記。 +// +// 標記只在 pip 回 0 之後才寫 —— 半套安裝不可留下「已就緒」的假象,那正是 +// 舊版讓使用者永久卡住的成因。 +func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error { + names := listBundledWheelNames(wheelsDir) + if len(names) == 0 { fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依") - return pythonBin, nil + return nil + } + + wheels := make([]string, 0, len(names)) + for _, n := range names { + wheels = append(wheels, filepath.Join(wheelsDir, n)) } a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels))) @@ -1016,11 +1138,20 @@ func (a *App) ensureBundledPython() (string, error) { pipCmd := exec.Command(pythonBin, args...) configureSysProcAttr(pipCmd) if out, err := pipCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("pip install wheels: %w\n%s", err, string(out)) + return fmt.Errorf("pip install wheels: %w\n%s", err, string(out)) } - a.setBootstrapStatus("Python 環境就緒") - return pythonBin, nil + // 安裝完立刻驗一次:pip 回 0 不保證 import 得起來(架構不符的 wheel、 + // 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。 + if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil { + return fmt.Errorf("wheels 安裝完成但相依無法載入:%w", err) + } + + markerPath := filepath.Join(runtimeDir, venvReadyMarkerName) + if err := os.WriteFile(markerPath, []byte(bundledWheelsFingerprint(wheelsDir)), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "[visiona-local] WARN: 寫入 venv 標記失敗(不影響運作):%v\n", err) + } + return nil } // locateBundledPythonAssets 找 python tarball 與 wheels 目錄。 diff --git a/local-tool/visiona-local/venv_health_test.go b/local-tool/visiona-local/venv_health_test.go new file mode 100644 index 0000000..397f1ce --- /dev/null +++ b/local-tool/visiona-local/venv_health_test.go @@ -0,0 +1,201 @@ +package main + +// venv_health_test.go — bundled Python venv 健康檢查單元測試 +// +// 背景:舊版 ensureBundledPython 只 os.Stat(python.exe) 就視為就緒。實機踩到的 +// 情境是 python.exe 在、但 `import kp` 失敗(pip 中途失敗留下半套 venv), +// 於是每次啟動都跳過安裝、永遠卡住且無任何提示,使用者只能手動刪整個 runtime。 +// +// 這裡驗證的三件事: +// 1. 指紋(wheels 清單)能偵測到 wheels 換版 +// 2. 標記檔的快路徑不會啟動任何 process(成本) +// 3. 壞掉的 venv 不會被誤判成健康 + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func wheelsDirWith(t *testing.T, names ...string) string { + t.Helper() + dir := t.TempDir() + for _, n := range names { + writeFile(t, filepath.Join(dir, n), "x") + } + return dir +} + +func TestBundledWheelsFingerprint_StableRegardlessOfReadOrder(t *testing.T) { + a := wheelsDirWith(t, "numpy-2.4.4.whl", "KneronPLUS-3.1.2.whl", "opencv.whl") + b := wheelsDirWith(t, "opencv.whl", "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") + + if bundledWheelsFingerprint(a) != bundledWheelsFingerprint(b) { + t.Fatalf("fingerprint 應與檔案列舉順序無關\na=%q\nb=%q", + bundledWheelsFingerprint(a), bundledWheelsFingerprint(b)) + } +} + +func TestBundledWheelsFingerprint_ChangesWhenWheelVersionChanges(t *testing.T) { + // 這是升級 KneronPLUS 時觸發重裝的機制:wheel 檔名帶版本號。 + old := wheelsDirWith(t, "KneronPLUS-2.0.0-py3-none-any.whl", "numpy-2.4.4.whl") + upgraded := wheelsDirWith(t, "KneronPLUS-3.1.2-py3-none-any.whl", "numpy-2.4.4.whl") + + if bundledWheelsFingerprint(old) == bundledWheelsFingerprint(upgraded) { + t.Fatal("wheels 換版後 fingerprint 必須改變,否則升級不會觸發重裝") + } +} + +func TestBundledWheelsFingerprint_IgnoresNonWheelFiles(t *testing.T) { + dir := wheelsDirWith(t, "numpy-2.4.4.whl") + writeFile(t, filepath.Join(dir, "README.txt"), "not a wheel") + writeFile(t, filepath.Join(dir, ".DS_Store"), "junk") + + if got := bundledWheelsFingerprint(dir); got != "numpy-2.4.4.whl" { + t.Fatalf("fingerprint=%q, 只應包含 .whl", got) + } +} + +func TestBundledWheelsFingerprint_MissingDirIsEmpty(t *testing.T) { + if got := bundledWheelsFingerprint(filepath.Join(t.TempDir(), "nope")); got != "" { + t.Fatalf("不存在的目錄 fingerprint=%q, want empty", got) + } +} + +// 快路徑:標記檔內容與 wheels 相符 → 直接放行,**不執行 pythonBin**。 +// 用一個不存在的 pythonBin 路徑證明它真的沒被執行(真跑會失敗)。 +func TestBundledPythonDepsHealthy_MarkerFastPathSkipsProcess(t *testing.T) { + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") + writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), + bundledWheelsFingerprint(wheels)) + + a := &App{} + nonExistentPython := filepath.Join(runtimeDir, "definitely-not-a-python") + + if !a.bundledPythonDepsHealthy(runtimeDir, nonExistentPython, wheels) { + t.Fatal("標記相符時應走快路徑回 true,且不得執行 python") + } +} + +// 標記內容與當前 wheels 不符(升級情境)→ 必須離開快路徑去實跑驗證。 +// pythonBin 不存在 → 驗證失敗 → 回 false(觸發重裝)。 +func TestBundledPythonDepsHealthy_StaleMarkerTriggersRevalidation(t *testing.T) { + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl") + writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), + "KneronPLUS-2.0.0.whl") // 舊版留下的標記 + + a := &App{} + if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) { + t.Fatal("標記過期且無法實跑驗證時,不可回報健康") + } +} + +// 沒有標記檔(舊版 venv / 首次升級到本版)→ 走慢路徑實跑驗證。 +func TestBundledPythonDepsHealthy_NoMarkerAndBrokenPythonIsUnhealthy(t *testing.T) { + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, "numpy-2.4.4.whl") + + a := &App{} + if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) { + t.Fatal("無標記且 python 不可執行時,不可回報健康") + } +} + +// 這是使用者實機踩到的核心情境:python 執行檔在、但 import kp 失敗。 +// 舊版只 Stat 檔案存在就放行;新版必須判定為不健康。 +func TestBundledPythonDepsHealthy_PythonExistsButImportFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script 假 python 在 Windows 上不適用") + } + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl") + + // 假 python:任何呼叫都以非 0 結束,模擬 import kp 失敗 + fakePython := filepath.Join(runtimeDir, "python3") + writeFile(t, fakePython, "#!/bin/sh\necho \"ModuleNotFoundError: No module named 'kp'\" >&2\nexit 1\n") + if err := os.Chmod(fakePython, 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + a := &App{} + if a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) { + t.Fatal("import 失敗的 venv 必須判定為不健康(這正是使用者卡住的情境)") + } + // 且不可留下就緒標記,否則下次啟動又被快路徑放行 + if _, err := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); err == nil { + t.Fatal("驗證失敗時不可寫入就緒標記") + } +} + +// import 成功 → 回 true 並補寫標記,讓下次啟動走快路徑(成本設計的關鍵)。 +func TestBundledPythonDepsHealthy_HealthyPythonWritesMarker(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script 假 python 在 Windows 上不適用") + } + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") + + fakePython := filepath.Join(runtimeDir, "python3") + writeFile(t, fakePython, "#!/bin/sh\nexit 0\n") + if err := os.Chmod(fakePython, 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + a := &App{} + if !a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) { + t.Fatal("import 成功時應回報健康") + } + + got, err := os.ReadFile(filepath.Join(runtimeDir, venvReadyMarkerName)) + if err != nil { + t.Fatalf("應補寫就緒標記讓下次走快路徑: %v", err) + } + if strings.TrimSpace(string(got)) != bundledWheelsFingerprint(wheels) { + t.Fatalf("標記內容=%q, want=%q", got, bundledWheelsFingerprint(wheels)) + } +} + +func TestProbePythonModules_EmptyModuleListIsNoop(t *testing.T) { + // 空清單不該啟動 process(傳不存在的路徑也不能失敗) + if err := probePythonModules("/definitely/not/a/python", nil); err != nil { + t.Fatalf("空模組清單應為 no-op, got %v", err) + } +} + +func TestProbePythonModules_ReportsStderrOnFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script 假 python 在 Windows 上不適用") + } + dir := t.TempDir() + fakePython := filepath.Join(dir, "python3") + writeFile(t, fakePython, "#!/bin/sh\necho \"No module named 'kp'\" >&2\nexit 1\n") + if err := os.Chmod(fakePython, 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + err := probePythonModules(fakePython, []string{"kp"}) + if err == nil { + t.Fatal("非 0 結束碼應回報錯誤") + } + // 錯誤訊息要帶上 python 的 stderr,否則使用者看不到真正原因 + if !strings.Contains(err.Error(), "No module named") { + t.Fatalf("錯誤訊息應包含 python stderr, got: %v", err) + } +} + +func TestListBundledWheelNames_MissingDirReturnsNil(t *testing.T) { + if got := listBundledWheelNames(filepath.Join(t.TempDir(), "nope")); len(got) != 0 { + t.Fatalf("不存在的目錄應回空, got %v", got) + } +}