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 來源標記 ───────────────────────────────────────────────
#
# 依可信度排序。實機驗收時 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_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" # 什麼都沒有,寫死的預設值
# 可信度排序(僅供 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.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):
"""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 撞上官方編號但檔案已被換過此時使用者的宣告
反而比我們的內建表貼近現實
本函式負責 13 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) ───────────────────────────────────

View File

@ -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.0shape_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 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:
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.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):
"""第 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這個檔名沒有 wNNNhNNN224
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"

View File

@ -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 是啟動時要驗證的關鍵模組。
//
// 只放「少了就完全跑不動」的三個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)))
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 目錄。

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