fix: input size 優先序錯誤導致 Windows 推論失敗 + venv 半套安裝永久卡住

## input size 優先序(Windows Error 12 的根因)

先前把使用者手填的 inputSize 排在檔名解析之前,Windows 上 SDK 讀不到
shape 時就用了隨手填的 640x640(模型實際 224x224)→ 推論回
KP_ERROR_INVALID_PARAM。改動前靠檔名 fallback 的 224 反而是對的。

新優先序(可信度由高到低):
  SDK > 檔名明確解析 wNNNhNNN > 使用者宣告 > 已知 model id > 寫死預設

檔名排在宣告之前,因為它由編譯工具鏈產生、沒有人為亂填空間;宣告不降到
最底,是因為使用者若刻意填對,仍比無資訊時的預設值貼近現實。

配套:_size_from_name_or_none 讓「真的解析到」與「用了 default」可區分
(舊版兩者回傳型別相同,預設值會偽裝成檔名來源蓋掉宣告值)。

## KneronPLUS 3.1.2 相容

3.1.2 把 shape 搬進巢狀 union,TensorDescriptor 不再有 shape_onnx:
  2.0.0  TensorDescriptor.shape_onnx
  3.1.2  TensorDescriptor.tensor_shape_info.data → V1 .shape_onnx / V2 .shape

舊碼 getattr 失敗被 except 靜默吃掉,SDK 層在 Windows 永遠落空。現在
兩版都支援,不依賴 enum 版本判斷。

## Error 12 診斷

KP_ERROR_INVALID_PARAM 對使用者無法理解,現在附上當前 input size 與
來源,並針對 declared 來源提示「此尺寸來自手動填寫欄位,請優先確認」。
原始錯誤保留不吞。

## venv 半套安裝

app.go 原本只檢查 python.exe 存在就跳過安裝,導致 wheels 裝到一半中斷
後每次啟動都跳過、永遠卡住且無提示,使用者必須手動刪整個 runtime 目錄。

改為比對 wheel 清單指紋(快路徑不啟動 process),不符才實跑
import kp/numpy/cv2 驗證,失敗則只重跑 wheels 安裝。標記檔僅在 pip 成功
且 import 驗過後才寫入,不留「已就緒」假象。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-22 23:41:19 +08:00
parent a05d206c07
commit f9fbc778be
4 changed files with 955 additions and 82 deletions

View File

@ -292,13 +292,32 @@ def _resolve_firmware_paths_full(chip="KL520"):
# ── Input size 來源標記 ─────────────────────────────────────────────── # ── Input size 來源標記 ───────────────────────────────────────────────
# #
# 依可信度排序。實機驗收時 log 會印出用的是哪一個 —— 這是唯一能一眼看出 # 依可信度排序(愈前面愈可信)。實機驗收時 log 會印出用的是哪一個 ——
# 「尺寸是怎麼來的」的線索,尺寸錯掉時 NPU 不報錯,只會給錯的推論結果。 # 這是唯一能一眼看出「尺寸是怎麼來的」的線索,尺寸錯掉時 NPU 不一定報錯,
INPUT_SIZE_SOURCE_SDK = "SDK" # 模型自己宣告的,唯一可靠 # 可能只是安靜地給出錯的推論結果。
INPUT_SIZE_SOURCE_DECLARED = "declared" # models.json / metadata.json人填的 #
INPUT_SIZE_SOURCE_FILENAME = "filename-guess" # 從檔名 wNNNhNNN 猜的 # ⚠️ declared 為什麼**不是**第二可信2026-07 Windows regression 的根因):
# 上傳表單的 inputSize 欄位長期沒有實際作用,使用者是「隨手填」的
# (實際案例:填 640x640模型其實是 224x224。而 KneronPLUS 3.1.2 不再
# 提供 2.0.0 的 shape_onnx 屬性(見 _input_size_from_nefSDK 這層在
# 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" # 什麼都沒有,寫死的預設值 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 邊長範圍。用來擋掉明顯不合理的宣告值 / 解析結果 # 合理的 input 邊長範圍。用來擋掉明顯不合理的宣告值 / 解析結果
# (例如 shape 判讀錯把 batch=1 或 channel=3 當成邊長)。 # (例如 shape 判讀錯把 batch=1 或 channel=3 當成邊長)。
MIN_REASONABLE_INPUT_DIM = 8 MIN_REASONABLE_INPUT_DIM = 8
@ -387,24 +406,81 @@ def _input_size_from_nef(nef, model_id=None):
return None return None
node = input_nodes[0] node = input_nodes[0]
# shape_onnx 優先那是原始模型的語意NCHW。shape_npu 是硬體 layout、 candidates = _tensor_shape_candidates(node)
# 可能經過對齊 padding拿來當輸入尺寸不一定準。 if not candidates:
for attr in ("shape_onnx", "shape_npu"): _log("input size from SDK: input node exposes no readable shape "
try: "(unsupported KneronPLUS layout?)")
shape = [int(dim) for dim in getattr(node, attr)] return None
except Exception:
continue for label, shape in candidates:
size = _input_size_from_shape(shape) size = _input_size_from_shape(shape)
if size is not None: 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 return size
if shape: 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") f"an image input shape")
return None return None
def _tensor_shape_candidates(node):
"""Collect candidate shapes from a TensorDescriptor, newest-API-aware.
KneronPLUS 3.x 改了 TensorDescriptor 的結構**欄位名沒改但搬了一層**
2.0.0macOS 目前用的版本
TensorDescriptor.shape_onnx : List[int]
TensorDescriptor.shape_npu : List[int]
3.1.2Windows 目前用的版本
TensorDescriptor.tensor_shape_info.version : ModelTensorShapeInformationVersion
TensorDescriptor.tensor_shape_info.data : TensorShapeInfoV1 | TensorShapeInfoV2
V1 .shape_onnx / .shape_npu / .axis_permutation_onnx_to_npu
V2 .shapedocstring 明寫ONNX shape of the tensor
/ .stride_onnx / .stride_npu
3.1.2 TensorDescriptor **沒有** shape_onnx 屬性舊寫法的
`getattr(node, "shape_onnx")` 會拋 AttributeError except 靜默吃掉
整個 SDK 層等於永遠落空 這就是 WindowsSDK 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): def _input_size_from_shape(shape):
"""Interpret a 4-D tensor shape as (width, height), or None. """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} " desc = (f"input_size={_model_input_width}x{_model_input_height} "
f"(source: {_model_input_size_source}") f"(source: {_model_input_size_source}")
if _model_input_size_source == INPUT_SIZE_SOURCE_FILENAME: if _model_input_size_source == INPUT_SIZE_SOURCE_DECLARED:
desc += ", UNRELIABLE" 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: elif _model_input_size_source == INPUT_SIZE_SOURCE_DEFAULT:
desc += ", UNRELIABLE — no size information available anywhere" desc += ", UNRELIABLE — no size information available anywhere"
return desc + ")" 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. """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. SDK 模型自己宣告的 input tensor shape唯一完全可靠那是
本函式只在 1 2 有值時覆寫它 模型編譯進 .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 撞上官方編號但檔案已被換過此時使用者的宣告
反而比我們的內建表貼近現實
本函式負責 13 4 5 _detect_model_type_by_heuristics 收尾
它同時要決定 model type順道寫入尺寸
""" """
global _model_input_size_source global _model_input_size_source
# 先降級成 default —— 否則上一個模型留下的 SDK / declared 標記會讓 # 先降級成 default —— 否則上一個模型留下的較可信標記會讓
# _detect_model_type_by_heuristics 誤以為「已有更可信來源」而不敢寫, # _detect_model_type_by_heuristics 誤以為「已有更可信來源」而不敢寫,
# 新模型就會沿用舊模型的尺寸(靜默、且只在換模型時才出現)。 # 新模型就會沿用舊模型的尺寸(靜默、且只在換模型時才出現)。
_model_input_size_source = INPUT_SIZE_SOURCE_DEFAULT _model_input_size_source = INPUT_SIZE_SOURCE_DEFAULT
declared = _normalize_declared_input_size(declared_input_size)
# ── 1. SDK模型自己說的 ──────────────────────────────────────── # ── 1. SDK模型自己說的 ────────────────────────────────────────
if nef is not None: if nef is not None:
sdk_size = _input_size_from_nef(nef, model_id=model_id) 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], _set_model_input_size(sdk_size[0], sdk_size[1],
INPUT_SIZE_SOURCE_SDK) INPUT_SIZE_SOURCE_SDK)
_log(f"Model input size resolved: {_describe_input_size()}") _log(f"Model input size resolved: {_describe_input_size()}")
_warn_if_declared_disagrees(declared)
return return
# ── 2. declared外部models.json / metadata.json宣告的 ─────── # ── 2. filename工具鏈產生的 wNNNhNNN**明確解析到**才算 ────────
declared = _normalize_declared_input_size(declared_input_size) 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: if declared is not None:
_set_model_input_size(declared[0], declared[1], _set_model_input_size(declared[0], declared[1],
INPUT_SIZE_SOURCE_DECLARED) INPUT_SIZE_SOURCE_DECLARED)
_log(f"Model input size resolved: {_describe_input_size()} " _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 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): def _normalize_declared_input_size(declared):
@ -595,21 +725,22 @@ def _detect_model_type_by_heuristics(model_id, nef_path):
""" """
global _model_type global _model_type
keep_size = _model_input_size_source in (INPUT_SIZE_SOURCE_SDK, # _resolve_input_size 已處理 SDK / filename / declared 三層。只有它什麼
INPUT_SIZE_SOURCE_DECLARED) # 都沒找到source 仍是 default這裡的猜測才有機會生效。
keep_size = _model_input_size_source != INPUT_SIZE_SOURCE_DEFAULT
def apply_size(width, height): def apply_size(width, height, source):
"""Write the guessed size unless a better source already won.""" """Write the guessed size unless a more trustworthy source already won."""
if keep_size: if keep_size:
return return
_set_model_input_size(width, height, INPUT_SIZE_SOURCE_FILENAME) _set_model_input_size(width, height, source)
# Check known model IDs # Check known model IDs
if model_id in KNOWN_MODELS: if model_id in KNOWN_MODELS:
_model_type, known_size = KNOWN_MODELS[model_id] _model_type, known_size = KNOWN_MODELS[model_id]
# 已知 model id 的尺寸是 Kneron 官方模型的固定值,比檔名可信, # 已知 model id 的尺寸是 Kneron 官方模型的固定值。比寫死的 backbone
# 但仍不如模型自己宣告的 shape # 預設值可信,但不如 SDK / 檔名 / 使用者宣告(見 _resolve_input_size
apply_size(known_size, known_size) apply_size(known_size, known_size, INPUT_SIZE_SOURCE_KNOWN_ID)
_log(f"Model type detected by ID {model_id}: {_model_type} " _log(f"Model type detected by ID {model_id}: {_model_type} "
f"({_describe_input_size()})") f"({_describe_input_size()})")
return return
@ -617,49 +748,65 @@ def _detect_model_type_by_heuristics(model_id, nef_path):
# Fallback: try to infer from filename # Fallback: try to infer from filename
basename = os.path.basename(nef_path).lower() if nef_path else "" basename = os.path.basename(nef_path).lower() if nef_path else ""
# 這裡的尺寸一律是「該 backbone 的慣用值」——檔名真的帶 wNNNhNNN 時
# _resolve_input_size 第 2 層早就命中了,走到這裡代表檔名沒有尺寸資訊,
# 所以 source 是 default 而不是 filename不可讓預設值偽裝成解析結果
if "yolov5" in basename: if "yolov5" in basename:
_model_type = "yolov5s" _model_type = "yolov5s"
# Try to parse input size from filename like w640h640 apply_size(640, 640, INPUT_SIZE_SOURCE_DEFAULT)
apply_size(*_parse_size_from_name(basename, default=640))
elif "fcos" in basename: elif "fcos" in basename:
_model_type = "fcos" _model_type = "fcos"
apply_size(*_parse_size_from_name(basename, default=512)) apply_size(512, 512, INPUT_SIZE_SOURCE_DEFAULT)
elif "ssd" in basename: elif "ssd" in basename:
_model_type = "ssd" _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: elif "resnet" in basename or "classification" in basename:
_model_type = "resnet18" _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: elif "tiny_yolo" in basename or "tinyyolo" in basename:
_model_type = "tiny_yolov3" _model_type = "tiny_yolov3"
apply_size(*_parse_size_from_name(basename, default=224)) apply_size(224, 224, INPUT_SIZE_SOURCE_DEFAULT)
else: else:
# Default: assume YOLO-like detection. # Default: assume YOLO-like detection.
# 仍嘗試從檔名的 wNNNhNNN 取 input size與上面各分支一致 # 對「外部指定 classification 但檔名沒有型別關鍵字」的自訂模型特別
# 取不到才用 224。對「外部指定 classification 但檔名沒有型別關鍵字」 # 重要 —— input size 與 task type 是兩件獨立的事。
# 的自訂模型特別重要 —— input size 與 task type 是兩件獨立的事。
_model_type = "tiny_yolov3" _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} " _log(f"Model type detected by filename '{basename}': {_model_type} "
f"({_describe_input_size()})") f"({_describe_input_size()})")
def _parse_size_from_name(name, default=224): def _size_from_name_or_none(name):
"""Extract (width, height) from a filename like 'w640h640' or 'w256h192'. """Extract (width, height) from a filename like 'w640h640' / 'w256h192'.
回傳兩軸而非單一數值檔名本來就同時帶了寬與高舊版只取 width 丟掉 **解析不出來時回 None**不回任何預設值 這個有解析到 vs 沒解析到
height非正方形模型會被當成正方形尺寸錯了不報錯只會靜默給錯結果 的區分是新優先序的前提真的從檔名讀到尺寸時它比使用者手填的 declared
可信工具鏈產生沒有亂填空間沒讀到時它什麼都不是絕不能讓
backbone 的慣用預設值224 / 640 偽裝成從檔名來的而蓋掉 declared
取不到時兩軸都回 default呼叫端傳進來的是該 backbone 的慣用正方形尺寸 舊版 _parse_size_from_name 把兩者混在同一個回傳值裡呼叫端無從分辨
""" """
import re import re
m = re.search(r'w(\d+)h(\d+)', name) m = re.search(r'w(\d+)h(\d+)', name or "")
if m: if not m:
return None
width = int(m.group(1)) width = int(m.group(1))
height = int(m.group(2)) height = int(m.group(2))
if _is_reasonable_input_dim(width) and _is_reasonable_input_dim(height): if _is_reasonable_input_dim(width) and _is_reasonable_input_dim(height):
return (width, 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) return (default, default)
@ -1963,7 +2110,38 @@ def handle_inference(params):
except Exception as e: except Exception as e:
import traceback import traceback
_log(f"Inference EXCEPTION: {type(e).__name__}: {e}\n{traceback.format_exc()}") _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) ─────────────────────────────────── # ── Firmware upgrade (A 階段 M9-1) ───────────────────────────────────

View File

@ -17,6 +17,7 @@ Mock-based tests — no real Kneron dongle needed. 覆蓋:
""" """
from __future__ import annotations from __future__ import annotations
import base64
import os import os
import sys import sys
import unittest import unittest
@ -493,17 +494,24 @@ class TestDetectModelType(BridgeStateTestCase):
self.assertEqual(bridge._model_type, "fcos") 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 分支 # 1784536643_models_520.nef 檔名既無 wNNNhNNN 也無型別關鍵字 → 落 else 分支
# → 寫死 224 → 圖片被縮到錯的尺寸送進 NPU → 分類結果錯誤,**但不報錯**。 # → 寫死 224 → 圖片被縮到錯的尺寸送進 NPU → 分類結果錯誤,**但不報錯**。
#
# 背景二2026-07 Windows regression加入 declared 層時把它排在檔名之前,
# 但上傳表單的 inputSize 欄位長期沒有實際作用、使用者是隨手填的。Windows 的
# KneronPLUS 3.1.2 又讀不到 SDK shape見 FakeTensorDescriptor312於是垃圾
# declared 值成為實際採用值 → KP_ERROR_INVALID_PARAM_12。
class FakeTensorDescriptor: class FakeTensorDescriptor:
"""Mirror of kp.TensorDescriptor 的 shape 介面。 """Mirror of kp.TensorDescriptor 的 shape 介面KneronPLUS 2.0.0 版)
只實作 bridge 真正會讀的兩個屬性 SDK 物件的行為已用 venv 只實作 bridge 真正會讀的兩個屬性 SDK 物件的行為已用 venv
kp.TensorDescriptor 實跑驗證過 handover note此處用 fake kp.TensorDescriptor 實跑驗證過2.0.0shape_onnx / shape_npu 平鋪在
測試不依賴 KneronPLUS 安裝 TensorDescriptor tensor_shape_info此處用 fake 讓測試不依賴
KneronPLUS 安裝
""" """
def __init__(self, shape_onnx=None, shape_npu=None): def __init__(self, shape_onnx=None, shape_npu=None):
@ -511,6 +519,47 @@ class FakeTensorDescriptor:
self.shape_npu = list(shape_npu or []) self.shape_npu = list(shape_npu or [])
class FakeShapeInfoDataV1:
"""KneronPLUS 3.x TensorShapeInfoV1shape_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單一 shapedocstring 明寫是 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.dataV1 V2舊寫法的
getattr(node, "shape_onnx") 會拋 AttributeError 被靜默吃掉 SDK 層永遠
落空 這就是 WindowsSDK 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: class FakeSingleModel:
def __init__(self, model_id=0, input_nodes=None): def __init__(self, model_id=0, input_nodes=None):
self.id = model_id self.id = model_id
@ -623,8 +672,9 @@ class TestInputSizeFromSDK(BridgeStateTestCase):
raise RuntimeError("boom") raise RuntimeError("boom")
bridge._detect_model_type(20004, "/x/fcos.nef", nef=Exploding()) 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, 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) self.assertEqual(bridge._model_input_width, 512)
@ -639,11 +689,32 @@ class TestInputSizeDeclared(BridgeStateTestCase):
self.assertEqual(bridge._model_input_size_source, self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_DECLARED) 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", bridge._detect_model_type(None, "/x/custom_w320h320.nef",
declared_input_size={"width": 416, declared_input_size={"width": 416,
"height": 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_width, 416)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_DECLARED)
def test_declared_accepts_tuple(self): def test_declared_accepts_tuple(self):
self.assertEqual(bridge._normalize_declared_input_size((256, 192)), self.assertEqual(bridge._normalize_declared_input_size((256, 192)),
@ -688,8 +759,272 @@ class TestInputSizeDeclared(BridgeStateTestCase):
bridge.INPUT_SIZE_SOURCE_FILENAME) bridge.INPUT_SIZE_SOURCE_FILENAME)
class TestInputSizeSDK312Layout(BridgeStateTestCase):
"""SDK 層必須同時支援 KneronPLUS 2.0.0 與 3.1.2 兩種結構。
Windows 3.1.2macOS 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.0macOS 正在用)。"""
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.imdecodecv2 是選配
相依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): class TestInputSizeFilenameFallback(BridgeStateTestCase):
"""第 3 層:檔名猜測。維持既有行為,不可回歸。""" """檔名 / 內建表 / 預設值層。維持既有解析結果,不可回歸。"""
def test_bundled_models_resolve_exactly_as_before(self): def test_bundled_models_resolve_exactly_as_before(self):
"""既有 detection 模型的尺寸一個都不能變。 """既有 detection 模型的尺寸一個都不能變。
@ -923,11 +1258,16 @@ class TestHandleLoadModel(BridgeStateTestCase):
bridge.INPUT_SIZE_SOURCE_FILENAME) bridge.INPUT_SIZE_SOURCE_FILENAME)
def test_input_size_fields_absent_from_request_is_backward_compatible(self): def test_input_size_fields_absent_from_request_is_backward_compatible(self):
"""不帶 input_size 的舊呼叫端行為完全不變。""" """不帶 input_size 的舊呼叫端,解析出的尺寸不變。
來源標記為 default 而非 filename這個檔名沒有 wNNNhNNN224
backbone 慣用預設值舊版把它標成 filename等於讓寫死的預設值
偽裝成從檔名解析出來的是這次修正要消除的混淆
"""
res = bridge.handle_load_model({"path": "/x/1784536643_models_520.nef"}) res = bridge.handle_load_model({"path": "/x/1784536643_models_520.nef"})
self.assertEqual(res["input_size"], 224) self.assertEqual(res["input_size"], 224)
self.assertEqual(res["input_size_source"], 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): def test_failed_load_does_not_change_input_size(self):
"""defer-until-success載入失敗 → 尺寸與來源都不可被動到。""" """defer-until-success載入失敗 → 尺寸與來源都不可被動到。"""
@ -1209,8 +1549,12 @@ class TestHandleSetInferenceOptions(BridgeStateTestCase):
bridge.INPUT_SIZE_SOURCE_SDK) bridge.INPUT_SIZE_SOURCE_SDK)
def test_switch_preserves_declared_input_size(self): def test_switch_preserves_declared_input_size(self):
"""同理declared 也不可在切換時被檔名猜測蓋掉。""" """同理declared 也不可在切換時被 backbone 預設值蓋掉。
bridge.handle_load_model({"path": "/x/fcos_w512h512.nef",
檔名不帶 wNNNhNNN否則檔名這層會先贏那是另一個 case
所以 declared 是最可信的來源切解析方式後必須原封不動
"""
bridge.handle_load_model({"path": "/x/fcos.nef",
"task_type": "object_detection", "task_type": "object_detection",
"input_size": {"width": 320, "height": 256}}) "input_size": {"width": 320, "height": 256}})
self.assertEqual((bridge._model_input_width, bridge._model_input_height), self.assertEqual((bridge._model_input_width, bridge._model_input_height),
@ -1223,6 +1567,25 @@ class TestHandleSetInferenceOptions(BridgeStateTestCase):
self.assertEqual(res["input_size_source"], self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_DECLARED) 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): 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 = nef_with_shape([1, 3, 320, 320], model_id=1784536643)
self._fake_nef.target_chip = "KL520" self._fake_nef.target_chip = "KL520"

View File

@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -946,7 +947,24 @@ func (a *App) ensureBundledPython() (string, error) {
} }
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。 // 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。
//
// ⚠️ 「python 執行檔存在」不等於「相依裝好了」。實際踩過的情境pip
// install 中途失敗(斷網 / 磁碟滿 / 防毒攔截venv 與 python.exe 都在,
// 但 `import kp` 失敗。舊版只 Stat python.exe 就 return於是每次啟動都
// 跳過安裝、永遠卡在同一個錯誤且沒有任何提示 —— 使用者必須手動刪掉整個
// runtime 目錄才能復原。所以這裡要連相依一起驗。
if _, err := os.Stat(pythonBin); err == nil { 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 return pythonBin, nil
} }
@ -992,19 +1010,123 @@ func (a *App) ensureBundledPython() (string, error) {
return "", fmt.Errorf("create venv: %w (%s)", err, string(out)) return "", fmt.Errorf("create venv: %w (%s)", err, string(out))
} }
// 列舉 wheelsDir 下所有 .whl if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil {
var wheels []string return "", err
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 len(wheels) == 0 { a.setBootstrapStatus("Python 環境就緒")
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
return pythonBin, nil return pythonBin, nil
}
// venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。
//
// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時
// 安裝的 wheel 檔名清單wheels 換版(升級 KneronPLUS 等)時內容不符會觸發重裝。
const venvReadyMarkerName = "venv-ready.txt"
// venvHealthProbeModules 是啟動時要驗證的關鍵模組。
//
// 只放「少了就完全跑不動」的三個kpKneron 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僅此一次成功後補寫標記
// 下次啟動即回到快路徑。
//
// 為什麼不是每次都跑 importimport 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 <mods>"`,確認相依真的可用。
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 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))) 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...) pipCmd := exec.Command(pythonBin, args...)
configureSysProcAttr(pipCmd) configureSysProcAttr(pipCmd)
if out, err := pipCmd.CombinedOutput(); err != nil { 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 環境就緒") // 安裝完立刻驗一次pip 回 0 不保證 import 得起來(架構不符的 wheel、
return pythonBin, nil // 缺系統層 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 目錄。 // locateBundledPythonAssets 找 python tarball 與 wheels 目錄。

View File

@ -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)
}
}