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:
parent
a05d206c07
commit
f9fbc778be
@ -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:
|
||||
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) ───────────────────────────────────
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,21 +1010,125 @@ 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 {
|
||||
fmt.Fprintln(os.Stderr, "[visiona-local] WARN: no wheels found in", wheelsDir, "— venv 已建立但未安裝任何相依")
|
||||
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 <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)))
|
||||
if a.startupPipeline != nil {
|
||||
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.pip", 0)
|
||||
@ -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 目錄。
|
||||
|
||||
201
local-tool/visiona-local/venv_health_test.go
Normal file
201
local-tool/visiona-local/venv_health_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user