visionA/local-tool/server/scripts/test_kneron_bridge_classification.py
jim800121chen f14d24bd7b feat(bridge): classification 自適應解析 + 推論期動態切換
新增 classification 推論支援,核心是「不預設模型長相」:

- output shape 自適應:支援 (1,C)/(C,)/(1,C,1,1)/(C,1,1)/(1,1,1,C)
  及任何 squeeze 後為一維的張量;類別數從 shape 動態取得,
  移除原本寫死的 num_classes=1000
- 無法解析時明確報錯(附實際 shape),不靜默回空結果
- logits vs 已 softmax 自動偵測:sum≈1.0 且全非負才跳過 softmax
  (容差 1e-4,實測 float32 softmax 偏差最大僅 ~4e-7)
- label 為純顯示層:有注入用 label、沒有則輸出原始 enum class_N
- input size 三層來源:SDK > 宣告值 > 檔名猜測,log 標示來源
- 新增 handle_set_inference_options:不重載模型即可改解析方式與 label

修正:
- _detect_model_type 改為外部指定優先,解決未知檔名被誤判成
  tiny_yolov3 而回傳空結果的根因
- handle_disconnect/reset 未清 label 狀態,導致換模型後沿用舊 label 表
- load_model 失敗路徑會污染全域 metadata,改為 defer-until-success
- taskType 值域統一為 object_detection(原本回 detection)
- 檔名解析原本只取 width 丟棄 height,非正方形模型會被壓成正方形

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:26:51 +08:00

1573 lines
71 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Unit tests for kneron_bridge classification support (M1).
Mock-based tests — no real Kneron dongle needed. 覆蓋:
- _normalize_task_type值域統一detection → object_detection
- _sanitize_labelslabels payload 正規化
- _resolve_label有/無 label、稀疏、長度不符的 fallback
- _extract_logits_vectoroutput shape 自適應(含無法判定時拋錯)
- _looks_like_probabilitieslogits vs 已 softmax 的偵測
- _parse_classification_output端到端 post-process
- _detect_model_type外部指定優先於檔名猜測classification 根因修正)
- handle_load_model / handle_inferencetaskType 值域 + detection 不回歸
執行方式:
cd server/scripts && python3 test_kneron_bridge_classification.py
"""
from __future__ import annotations
import os
import sys
import unittest
from unittest import mock
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# ── 在 import bridge 前 fake kp module避免實機相依─────────────────
class _FakeChannelOrdering:
KP_CHANNEL_ORDERING_CHW = "chw"
class _FakeKpInference:
"""generic_inference_retrieve_float_node 由各 test 用 mock.patch 覆寫。"""
def generic_inference_retrieve_float_node(self, **kwargs):
raise NotImplementedError("must be patched per test")
class _FakeKpCore:
def disconnect_devices(self, *args, **kwargs):
return 0
class _FakeKp:
core = _FakeKpCore()
inference = _FakeKpInference()
ChannelOrdering = _FakeChannelOrdering
sys.modules.setdefault("kp", _FakeKp())
import kneron_bridge as bridge # noqa: E402
# ── Helpers ──────────────────────────────────────────────────────────
class FakeOutputNode:
def __init__(self, ndarray):
self.ndarray = ndarray
class FakeHeader:
def __init__(self, num_output_node):
self.num_output_node = num_output_node
class FakeResult:
"""Fake generic raw result帶任意數量 output node。"""
def __init__(self, arrays):
self.header = FakeHeader(len(arrays))
self._arrays = arrays
def retrieve(self, node_idx=0, **kwargs):
return FakeOutputNode(self._arrays[node_idx])
def patch_retrieve(result):
"""把 SDK 的 retrieve_float_node 導向 FakeResult。"""
return mock.patch.object(
bridge.kp.inference,
"generic_inference_retrieve_float_node",
side_effect=lambda **kw: result.retrieve(**kw),
create=True,
)
def silence_log():
return mock.patch.object(bridge, "_log", lambda *a, **k: None)
# models.json 每個 detection model 實際帶的 labels —— 只有前 10 筆 COCO。
# 生產環境的 detection 路徑拿到的就是這份(不是 None所以測試必須用它。
TRUNCATED_COCO_LABELS = bridge.COCO_CLASSES[:10]
def make_yolo_tensor(class_id, grid=7, num_classes=80, num_anchors=3):
"""Build a Tiny-YOLOv3-shaped tensor with exactly one high-confidence box.
Layout: (1, num_anchors * (5 + num_classes), grid, grid)CHW ordering。
只在 anchor 0 / cell (0,0) 放一個物件,其類別為 class_id其餘全部設成
-10sigmoid ≈ 0確保低於 CONF_THRESHOLD 而被濾掉,因此解析結果必定
是「剛好一個 detection」斷言才能精確。
"""
entry_size = 5 + num_classes
arr = np.full((1, num_anchors * entry_size, grid, grid), -10.0, dtype=np.float32)
arr[0, 0, 0, 0] = 0.0 # tx
arr[0, 1, 0, 0] = 0.0 # ty
arr[0, 2, 0, 0] = -2.0 # tw小框避免 NMS 邊界效應)
arr[0, 3, 0, 0] = -2.0 # th
arr[0, 4, 0, 0] = 10.0 # objectness → sigmoid ≈ 1
arr[0, 5 + class_id, 0, 0] = 10.0 # 該類別分數 → sigmoid ≈ 1
return arr
class BridgeStateTestCase(unittest.TestCase):
"""每個 test 前後還原 bridge 的全域狀態,確保測試互相獨立。"""
_GLOBALS = ("_model_type", "_model_input_size", "_model_id", "_model_nef",
"_model_nef_path", "_task_type_override", "_model_labels",
"_device_group",
# input size 三層來源M10 實機驗收修正)。漏還原會讓前一個
# test 留下的 SDK 來源標記影響後一個 test 的 fallback 判定。
"_model_input_width", "_model_input_height",
"_model_input_size_source", "_model_declared_input_size")
def setUp(self):
self._saved = {name: getattr(bridge, name) for name in self._GLOBALS}
self._log_patch = silence_log()
self._log_patch.start()
self.addCleanup(self._log_patch.stop)
self.addCleanup(self._restore)
def _restore(self):
for name, value in self._saved.items():
setattr(bridge, name, value)
# ── _normalize_task_type ─────────────────────────────────────────────
class TestNormalizeTaskType(BridgeStateTestCase):
def test_classification_passthrough(self):
self.assertEqual(bridge._normalize_task_type("classification"),
bridge.TASK_TYPE_CLASSIFICATION)
def test_object_detection_passthrough(self):
self.assertEqual(bridge._normalize_task_type("object_detection"),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_legacy_detection_alias_maps_to_object_detection(self):
"""舊值 'detection' 必須映射到統一值域 object_detectionR-4"""
self.assertEqual(bridge._normalize_task_type("detection"),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_case_and_whitespace_insensitive(self):
self.assertEqual(bridge._normalize_task_type(" Classification "),
bridge.TASK_TYPE_CLASSIFICATION)
def test_none_and_empty_return_none(self):
self.assertIsNone(bridge._normalize_task_type(None))
self.assertIsNone(bridge._normalize_task_type(""))
self.assertIsNone(bridge._normalize_task_type(" "))
def test_unknown_value_returns_none(self):
self.assertIsNone(bridge._normalize_task_type("segmentation"))
def test_non_string_returns_none(self):
self.assertIsNone(bridge._normalize_task_type(123))
self.assertIsNone(bridge._normalize_task_type(["classification"]))
# ── _sanitize_labels ─────────────────────────────────────────────────
class TestSanitizeLabels(BridgeStateTestCase):
def test_none_returns_none(self):
self.assertIsNone(bridge._sanitize_labels(None))
def test_empty_list_returns_none(self):
self.assertIsNone(bridge._sanitize_labels([]))
def test_all_blank_returns_none(self):
self.assertIsNone(bridge._sanitize_labels(["", " "]))
def test_normal_list_passthrough(self):
self.assertEqual(bridge._sanitize_labels(["剪刀", "石頭", ""]),
["剪刀", "石頭", ""])
def test_none_element_becomes_empty_string(self):
self.assertEqual(bridge._sanitize_labels(["a", None, "c"]), ["a", "", "c"])
def test_non_string_element_coerced(self):
self.assertEqual(bridge._sanitize_labels(["a", 7]), ["a", "7"])
def test_non_list_returns_none(self):
self.assertIsNone(bridge._sanitize_labels("剪刀,石頭"))
self.assertIsNone(bridge._sanitize_labels({"0": "a"}))
# ── _resolve_label ───────────────────────────────────────────────────
class TestResolveLabel(BridgeStateTestCase):
def test_uses_injected_label(self):
self.assertEqual(bridge._resolve_label(1, labels=["剪刀", "石頭", ""]),
"石頭")
def test_no_labels_falls_back_to_enum_index(self):
"""沒有 label 不是錯誤狀態、輸出原始 enum。"""
self.assertEqual(bridge._resolve_label(2), "class_2")
def test_index_beyond_labels_falls_back_to_enum(self):
"""labels 長度與類別數不符時,對不到的 fallback 回 index。"""
self.assertEqual(bridge._resolve_label(5, labels=["a", "b"]), "class_5")
def test_sparse_blank_label_falls_back(self):
self.assertEqual(bridge._resolve_label(1, labels=["a", "", "c"]), "class_1")
def test_fallback_labels_used_when_no_injection(self):
"""detection 沒注入 labels 時沿用 COCO既有行為不變"""
self.assertEqual(
bridge._resolve_label(0, labels=None, fallback_labels=bridge.COCO_CLASSES),
"person")
def test_injected_labels_take_priority_over_fallback(self):
self.assertEqual(
bridge._resolve_label(0, labels=["自訂"], fallback_labels=bridge.COCO_CLASSES),
"自訂")
def test_negative_index_falls_back(self):
self.assertEqual(bridge._resolve_label(-1, labels=["a", "b"]), "class_-1")
# ── _extract_logits_vectoroutput shape 自適應)─────────────────────
class TestExtractLogitsVector(BridgeStateTestCase):
def _extract(self, arrays):
result = FakeResult(arrays)
with patch_retrieve(result):
return bridge._extract_logits_vector(result)
def test_shape_1xC(self):
scores, node = self._extract([np.array([[1.0, 2.0, 3.0]])])
np.testing.assert_allclose(scores, [1.0, 2.0, 3.0])
self.assertEqual(node, 0)
self.assertEqual(scores.ndim, 1)
def test_shape_C(self):
scores, _ = self._extract([np.array([4.0, 5.0])])
np.testing.assert_allclose(scores, [4.0, 5.0])
def test_shape_1xCx1x1(self):
arr = np.array([1.0, 2.0, 3.0]).reshape(1, 3, 1, 1)
scores, _ = self._extract([arr])
np.testing.assert_allclose(scores, [1.0, 2.0, 3.0])
def test_shape_Cx1x1(self):
arr = np.array([1.0, 2.0, 3.0]).reshape(3, 1, 1)
scores, _ = self._extract([arr])
np.testing.assert_allclose(scores, [1.0, 2.0, 3.0])
def test_shape_1x1x1xC_nhwc(self):
arr = np.array([1.0, 2.0, 3.0]).reshape(1, 1, 1, 3)
scores, _ = self._extract([arr])
np.testing.assert_allclose(scores, [1.0, 2.0, 3.0])
def test_single_class_scalar_shape(self):
"""C == 1 squeeze 後是純量,仍須視為合法的一類輸出。"""
scores, _ = self._extract([np.array([[0.9]])])
self.assertEqual(scores.shape, (1,))
np.testing.assert_allclose(scores, [0.9])
def test_multiple_nodes_picks_largest_1d(self):
arrays = [np.array([[0.1, 0.2]]), np.array([[1.0, 2.0, 3.0, 4.0]])]
scores, node = self._extract(arrays)
self.assertEqual(node, 1)
self.assertEqual(scores.size, 4)
def test_skips_spatial_nodes_and_uses_1d_node(self):
"""detection 風格的 (C,H,W) 節點會被跳過、只取一維節點。"""
arrays = [np.zeros((85, 7, 7)), np.array([[1.0, 2.0, 3.0]])]
scores, node = self._extract(arrays)
self.assertEqual(node, 1)
np.testing.assert_allclose(scores, [1.0, 2.0, 3.0])
def test_class_count_is_dynamic_not_hardcoded(self):
for c in (2, 3, 7, 1000):
scores, _ = self._extract([np.zeros((1, c))])
self.assertEqual(scores.size, c)
def test_unrecognizable_shape_raises_with_actual_shape(self):
"""只有 spatial 輸出時必須明確拋錯、且訊息含實際 shape。"""
with self.assertRaises(ValueError) as ctx:
self._extract([np.zeros((85, 7, 7))])
msg = str(ctx.exception)
self.assertIn("(85, 7, 7)", msg)
self.assertIn("classification output not recognizable", msg)
def test_empty_output_raises(self):
with self.assertRaises(ValueError):
self._extract([np.zeros((0,))])
def test_error_message_lists_all_nodes(self):
with self.assertRaises(ValueError) as ctx:
self._extract([np.zeros((3, 4, 5)), np.zeros((6, 7, 8))])
msg = str(ctx.exception)
self.assertIn("node[0]=(3, 4, 5)", msg)
self.assertIn("node[1]=(6, 7, 8)", msg)
# ── _looks_like_probabilities ────────────────────────────────────────
class TestLooksLikeProbabilities(BridgeStateTestCase):
def test_exact_probability_vector_detected(self):
self.assertTrue(bridge._looks_like_probabilities(np.array([0.9, 0.07, 0.03])))
def test_within_tolerance_detected(self):
self.assertTrue(bridge._looks_like_probabilities(np.array([0.5, 0.50005])))
def test_outside_tolerance_not_detected(self):
self.assertFalse(bridge._looks_like_probabilities(np.array([0.5, 0.6])))
def test_real_float32_softmax_still_detected(self):
"""收緊容差後,真正的 softmax 輸出仍必須被認出(不可誤殺)。
float32 softmax 的總和誤差實測最壞約 4e-7遠小於 1e-4。
這個測試釘住「容差不可再收到比浮點誤差還小」。
"""
for c in (2, 3, 10, 1000):
logits = np.linspace(-8.0, 8.0, c).astype(np.float32)
e = np.exp(logits - logits.max())
probs = (e / e.sum()).astype(np.float32).astype(np.float64)
self.assertTrue(bridge._looks_like_probabilities(probs),
msg=f"real softmax output rejected at C={c}")
def test_tolerance_is_tight_enough_for_low_class_counts(self):
"""M-2容差必須遠小於低類別數 logits 的偶然偏差尺度。"""
self.assertLessEqual(bridge.PROB_SUM_TOLERANCE, 1e-4)
def test_negative_values_are_logits(self):
self.assertFalse(bridge._looks_like_probabilities(np.array([-1.0, 2.0])))
def test_large_logits_not_probabilities(self):
self.assertFalse(bridge._looks_like_probabilities(np.array([5.0, 3.0, 1.0])))
def test_single_class_probability(self):
self.assertTrue(bridge._looks_like_probabilities(np.array([1.0])))
def test_nan_not_probabilities(self):
self.assertFalse(bridge._looks_like_probabilities(np.array([np.nan, 1.0])))
def test_empty_not_probabilities(self):
self.assertFalse(bridge._looks_like_probabilities(np.array([])))
def test_two_class_logits_summing_to_one_is_known_ambiguous(self):
"""M-2 已知殘留風險:恰好和為 1 的非負 logits 無法與機率區分。
[0.4, 0.6] 在數學上與機率向量完全等價,任何只看「非負 + 和為 1」
的啟發式都會判為機率。此測試不是斷言「這樣是對的」,而是把
**已知且已接受的行為** 釘住 —— 若未來有人加了第三條件改變此判定,
測試會失敗、迫使他重新評估是否誤殺真機率。
"""
self.assertTrue(bridge._looks_like_probabilities(np.array([0.4, 0.6])))
def test_two_class_logits_slightly_off_one_now_caught(self):
"""收緊容差的實際收益:舊的 0.01 容差會誤判、1e-4 能擋下。"""
for vec in ([0.4, 0.605], [0.3, 0.695], [0.45, 0.555]):
arr = np.array(vec)
# 這些和落在 1.0±0.01 內但超出 1e-4 → 舊實作誤判、新實作正確
self.assertLess(abs(float(arr.sum()) - 1.0), 0.01)
self.assertFalse(bridge._looks_like_probabilities(arr),
msg=f"{vec} should be treated as logits")
# ── _parse_classification_output ─────────────────────────────────────
class TestParseClassificationOutput(BridgeStateTestCase):
def _parse(self, arrays, **kwargs):
result = FakeResult(arrays)
with patch_retrieve(result):
return bridge._parse_classification_output(result, **kwargs)
def test_logits_get_softmaxed_and_sorted_desc(self):
out = self._parse([np.array([[1.0, 3.0, 2.0]])])
self.assertEqual([c["classIndex"] for c in out], [1, 2, 0])
self.assertAlmostEqual(sum(c["confidence"] for c in out), 1.0, places=6)
self.assertGreater(out[0]["confidence"], out[1]["confidence"])
def test_already_softmaxed_output_is_not_flattened(self):
"""R-2已是機率的輸出不可再 softmax否則 3 類趨近 0.33)。"""
probs = np.array([[0.94, 0.04, 0.02]])
out = self._parse([probs])
self.assertAlmostEqual(out[0]["confidence"], 0.94, places=6)
self.assertEqual(out[0]["classIndex"], 0)
def test_double_softmax_would_have_flattened(self):
"""對照組:確認若真的再 softmax 一次top-1 會掉到 ~0.5 以下。"""
probs = np.array([0.94, 0.04, 0.02])
double = bridge._softmax(probs)
self.assertLess(float(np.max(double)), 0.6)
def test_labels_replace_display_name(self):
out = self._parse([np.array([[0.1, 5.0, 0.2]])],
labels=["剪刀", "石頭", ""])
self.assertEqual(out[0]["label"], "石頭")
self.assertEqual(out[0]["classIndex"], 1)
def test_without_labels_uses_raw_enum(self):
out = self._parse([np.array([[0.1, 5.0, 0.2]])])
self.assertEqual(out[0]["label"], "class_1")
self.assertEqual(out[0]["classIndex"], 1)
def test_label_count_mismatch_partial_fallback(self):
"""labels 太短:對到的用 label、對不到的用 index不整批失敗。"""
out = self._parse([np.array([[3.0, 2.0, 1.0]])], labels=["", ""])
by_index = {c["classIndex"]: c["label"] for c in out}
self.assertEqual(by_index[0], "")
self.assertEqual(by_index[1], "")
self.assertEqual(by_index[2], "class_2")
def test_top_k_default_is_five(self):
out = self._parse([np.arange(10.0).reshape(1, 10)])
self.assertEqual(len(out), 5)
def test_top_k_capped_by_class_count(self):
out = self._parse([np.array([[1.0, 2.0, 3.0]])])
self.assertEqual(len(out), 3)
def test_top_k_configurable(self):
out = self._parse([np.arange(10.0).reshape(1, 10)], top_k=2)
self.assertEqual(len(out), 2)
def test_invalid_top_k_falls_back_to_default(self):
out = self._parse([np.arange(10.0).reshape(1, 10)], top_k=0)
self.assertEqual(len(out), bridge.DEFAULT_CLASSIFICATION_TOP_K)
out = self._parse([np.arange(10.0).reshape(1, 10)], top_k="abc")
self.assertEqual(len(out), bridge.DEFAULT_CLASSIFICATION_TOP_K)
def test_result_schema_fields(self):
out = self._parse([np.array([[1.0, 2.0]])], labels=["a", "b"])
self.assertEqual(set(out[0].keys()), {"label", "classIndex", "confidence"})
self.assertIsInstance(out[0]["label"], str)
self.assertIsInstance(out[0]["classIndex"], int)
self.assertIsInstance(out[0]["confidence"], float)
def test_unparseable_output_raises_not_silently_empty(self):
"""絕對不要靜默回傳空結果 —— 必須拋出可讀錯誤。"""
with self.assertRaises(ValueError):
self._parse([np.zeros((85, 7, 7))])
# ── _detect_model_type外部指定優先 = classification 根因修正)──────
class TestDetectModelType(BridgeStateTestCase):
def test_unknown_filename_without_hint_falls_back_to_yolo(self):
"""既有行為:沒有外部指定時仍靠檔名猜(向後相容)。"""
bridge._detect_model_type(1784536643, "/x/1784536643_models_520.nef")
self.assertEqual(bridge._model_type, "tiny_yolov3")
def test_external_classification_overrides_filename_guess(self):
"""根因修正:外部指定 classification 就不猜、不再誤判成 tiny_yolov3。"""
bridge._detect_model_type(1784536643, "/x/1784536643_models_520.nef",
task_type="classification")
self.assertEqual(bridge._model_type, "classification")
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
def test_external_classification_on_generic_model_nef(self):
"""上傳流程存成 model.nef同樣不含關鍵字。"""
bridge._detect_model_type(999, "/data/models/uuid/model.nef",
task_type="classification")
self.assertEqual(bridge._model_type, "classification")
def test_external_object_detection_keeps_detection_backbone(self):
bridge._detect_model_type(20004, "/x/fcos.nef", task_type="object_detection")
self.assertEqual(bridge._model_type, "fcos")
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_external_object_detection_overrides_resnet_filename(self):
"""宣告 detection 但檔名像 resnet → 不可跑 classification 分支。"""
bridge._detect_model_type(12345, "/x/resnet18_custom.nef",
task_type="object_detection")
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_known_model_id_still_wins_without_hint(self):
bridge._detect_model_type(20005, "/x/whatever.nef")
self.assertEqual(bridge._model_type, "yolov5s")
self.assertEqual(bridge._model_input_size, 640)
def test_classification_hint_still_parses_input_size(self):
bridge._detect_model_type(None, "/x/custom_w320h320.nef",
task_type="classification")
self.assertEqual(bridge._model_type, "classification")
self.assertEqual(bridge._model_input_size, 320)
def test_unknown_task_type_is_ignored_and_falls_back(self):
bridge._detect_model_type(None, "/x/fcos.nef", task_type="segmentation")
self.assertEqual(bridge._model_type, "fcos")
# ── Input size 三層來源SDK > declared > filename guess─────────────
#
# 背景input size 舊版**完全來自檔名猜測**。使用者的
# 1784536643_models_520.nef 檔名既無 wNNNhNNN 也無型別關鍵字 → 落 else 分支
# → 寫死 224 → 圖片被縮到錯的尺寸送進 NPU → 分類結果錯誤,**但不報錯**。
class FakeTensorDescriptor:
"""Mirror of kp.TensorDescriptor 的 shape 介面。
只實作 bridge 真正會讀的兩個屬性。真 SDK 物件的行為已用 venv 的
kp.TensorDescriptor 實跑驗證過(見 handover note此處用 fake 讓
測試不依賴 KneronPLUS 安裝。
"""
def __init__(self, shape_onnx=None, shape_npu=None):
self.shape_onnx = list(shape_onnx or [])
self.shape_npu = list(shape_npu or [])
class FakeSingleModel:
def __init__(self, model_id=0, input_nodes=None):
self.id = model_id
self.input_nodes = list(input_nodes or [])
class FakeNefDescriptor:
def __init__(self, models=None):
self.models = list(models or [])
def nef_with_shape(shape_onnx=None, shape_npu=None, model_id=0):
return FakeNefDescriptor([
FakeSingleModel(model_id,
[FakeTensorDescriptor(shape_onnx, shape_npu)])
])
class TestInputSizeFromSDK(BridgeStateTestCase):
"""第 1 層:模型自己宣告的 shape唯一可靠的來源。"""
def test_sdk_shape_nchw_wins_over_filename_guess(self):
"""核心修正:檔名猜不到時不再退回 224而是問模型自己。"""
bridge._detect_model_type(
1784536643, "/x/1784536643_models_520.nef",
task_type="classification",
nef=nef_with_shape([1, 3, 320, 320], model_id=1784536643))
self.assertEqual(bridge._model_input_width, 320)
self.assertEqual(bridge._model_input_height, 320)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_SDK)
def test_sdk_wins_over_wrong_declared_value(self):
"""使用者填 640x640 但模型其實是 320x320 → 必須聽模型的。
這正是實機驗收踩到的情境:使用者自己說 640 是「隨便填的」。
"""
bridge._detect_model_type(
1784536643, "/x/1784536643_models_520.nef",
task_type="classification",
nef=nef_with_shape([1, 3, 320, 320], model_id=1784536643),
declared_input_size={"width": 640, "height": 640})
self.assertEqual((bridge._model_input_width, bridge._model_input_height),
(320, 320))
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_SDK)
def test_sdk_nhwc_shape(self):
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([1, 192, 256, 3],
model_id=999))
self.assertEqual((bridge._model_input_width, bridge._model_input_height),
(256, 192))
def test_sdk_non_square_keeps_both_axes(self):
"""非正方形不可被壓成正方形 —— 舊版只取 width、height 直接丟掉。"""
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([1, 3, 192, 256],
model_id=999))
self.assertEqual(bridge._model_input_width, 256)
self.assertEqual(bridge._model_input_height, 192)
def test_scalar_input_size_is_the_shorter_edge(self):
"""派生純量取短邊:它的用途是 min_dim取長邊會讓短軸不足。"""
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([1, 3, 192, 256],
model_id=999))
self.assertEqual(bridge._model_input_size, 192)
def test_shape_npu_used_when_shape_onnx_empty(self):
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([], [1, 3, 512, 512],
model_id=999))
self.assertEqual(bridge._model_input_width, 512)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_SDK)
def test_picks_node_matching_model_id_in_multi_model_nef(self):
"""一個 .nef 可包多個 model要取 _model_id 指的那個。"""
nef = FakeNefDescriptor([
FakeSingleModel(111, [FakeTensorDescriptor([1, 3, 128, 128])]),
FakeSingleModel(222, [FakeTensorDescriptor([1, 3, 640, 640])]),
])
bridge._detect_model_type(222, "/x/m.nef", nef=nef)
self.assertEqual(bridge._model_input_width, 640)
def test_uninterpretable_shape_falls_through_to_declared(self):
"""判讀不了就 fallback**不猜** —— 猜錯就是用錯尺寸推論。"""
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([1, 1000], model_id=999),
declared_input_size={"width": 300,
"height": 300})
self.assertEqual(bridge._model_input_width, 300)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_DECLARED)
def test_empty_nef_falls_through_to_filename(self):
bridge._detect_model_type(
20004, "/x/kl520_20004_fcos-drk53s_w512h512.nef",
nef=FakeNefDescriptor([]))
self.assertEqual(bridge._model_input_width, 512)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_FILENAME)
def test_broken_nef_object_does_not_raise(self):
"""SDK 物件壞掉不可讓 load_model 整個炸掉,降級即可。"""
class Exploding:
@property
def models(self):
raise RuntimeError("boom")
bridge._detect_model_type(20004, "/x/fcos.nef", nef=Exploding())
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_FILENAME)
self.assertEqual(bridge._model_input_width, 512)
class TestInputSizeDeclared(BridgeStateTestCase):
"""第 2 層models.json / metadata.json 宣告值。人填的,可能亂填。"""
def test_declared_used_when_no_sdk(self):
bridge._detect_model_type(1784536643, "/x/1784536643_models_520.nef",
task_type="classification",
declared_input_size={"width": 224,
"height": 224})
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_DECLARED)
def test_declared_beats_filename_guess(self):
bridge._detect_model_type(None, "/x/custom_w320h320.nef",
declared_input_size={"width": 416,
"height": 416})
self.assertEqual(bridge._model_input_width, 416)
def test_declared_accepts_tuple(self):
self.assertEqual(bridge._normalize_declared_input_size((256, 192)),
(256, 192))
def test_declared_non_square(self):
bridge._detect_model_type(999, "/x/m.nef",
declared_input_size={"width": 256,
"height": 192})
self.assertEqual((bridge._model_input_width, bridge._model_input_height),
(256, 192))
def test_declared_zero_is_rejected(self):
"""models.json 沒填 inputSize 時 Go 端會送 0 —— 不可當成有效值。"""
self.assertIsNone(
bridge._normalize_declared_input_size({"width": 0, "height": 0}))
def test_declared_absurd_value_is_rejected(self):
self.assertIsNone(
bridge._normalize_declared_input_size({"width": 999999,
"height": 999999}))
def test_declared_partially_invalid_is_rejected_entirely(self):
"""半套採用比整組不用更危險 —— 看起來像有正確來源。"""
self.assertIsNone(
bridge._normalize_declared_input_size({"width": 320, "height": 0}))
def test_declared_non_numeric_is_rejected(self):
self.assertIsNone(
bridge._normalize_declared_input_size({"width": "abc",
"height": "abc"}))
def test_declared_none_is_rejected(self):
self.assertIsNone(bridge._normalize_declared_input_size(None))
def test_rejected_declared_falls_back_to_filename(self):
bridge._detect_model_type(
20004, "/x/kl520_20004_fcos-drk53s_w512h512.nef",
declared_input_size={"width": 0, "height": 0})
self.assertEqual(bridge._model_input_width, 512)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_FILENAME)
class TestInputSizeFilenameFallback(BridgeStateTestCase):
"""第 3 層:檔名猜測。維持既有行為,不可回歸。"""
def test_bundled_models_resolve_exactly_as_before(self):
"""既有 detection 模型的尺寸一個都不能變。
這些檔名帶 wNNNhNNN / 已知 model id舊版猜對了新版三層 fallback
在沒有 SDK / declared 時必須落到同樣的值。
"""
expected = [
(0, "kl520_tiny_yolo_v3.nef", "tiny_yolov3", 224),
(20001, "kl520_20001_resnet18_w224h224.nef", "resnet18", 224),
(20004, "kl520_20004_fcos-drk53s_w512h512.nef", "fcos", 512),
(20005, "kl520_20005_yolov5-noupsample_w640h640.nef", "yolov5s", 640),
(None, "kl520_ssd_fd_lm.nef", "ssd", 320),
(20001, "kl720_20001_resnet18_w224h224.nef", "resnet18", 224),
(20004, "kl720_20004_fcos-drk53s_w512h512.nef", "fcos", 512),
(20005, "kl720_20005_yolov5-noupsample_w640h640.nef", "yolov5s", 640),
]
for model_id, name, want_type, want_size in expected:
with self.subTest(nef=name):
bridge._detect_model_type(model_id, "/data/nef/" + name)
self.assertEqual(bridge._model_type, want_type)
self.assertEqual(bridge._model_input_size, want_size)
self.assertEqual(bridge._model_input_width, want_size)
self.assertEqual(bridge._model_input_height, want_size)
def test_filename_non_square_keeps_height(self):
"""舊版 _parse_size_from_name 只取 width、丟掉 height。"""
self.assertEqual(bridge._parse_size_from_name("m_w256h192.nef"),
(256, 192))
def test_filename_without_size_uses_default_for_both_axes(self):
self.assertEqual(bridge._parse_size_from_name("m.nef", default=512),
(512, 512))
def test_absurd_filename_size_is_rejected(self):
self.assertEqual(
bridge._parse_size_from_name("m_w99999999h99999999.nef",
default=224),
(224, 224))
def test_source_marked_unreliable_in_log_description(self):
"""實機驗收要能一眼看出尺寸是猜的。"""
bridge._detect_model_type(1784536643, "/x/1784536643_models_520.nef")
self.assertIn("UNRELIABLE", bridge._describe_input_size())
def test_sdk_source_not_marked_unreliable(self):
bridge._detect_model_type(999, "/x/m.nef",
nef=nef_with_shape([1, 3, 320, 320],
model_id=999))
desc = bridge._describe_input_size()
self.assertIn("source: SDK", desc)
self.assertNotIn("UNRELIABLE", desc)
class TestInputSizeSourceIsolation(BridgeStateTestCase):
"""來源標記不可跨模型殘留。"""
def test_previous_sdk_source_does_not_block_next_model(self):
"""先載一個有 SDK shape 的,再載一個沒有的 → 後者不可沿用前者尺寸。
少了 _resolve_input_size 開頭的降級,第二次會因為「已有更可信來源」
而不敢寫,靜默沿用 320 —— 只在換模型時才出現,極難 debug。
"""
bridge._detect_model_type(999, "/x/a.nef",
nef=nef_with_shape([1, 3, 320, 320],
model_id=999))
self.assertEqual(bridge._model_input_width, 320)
bridge._detect_model_type(
20004, "/x/kl520_20004_fcos-drk53s_w512h512.nef")
self.assertEqual(bridge._model_input_width, 512)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_FILENAME)
def test_reset_model_metadata_restores_defaults(self):
bridge._detect_model_type(999, "/x/a.nef",
nef=nef_with_shape([1, 3, 640, 480],
model_id=999))
bridge._reset_model_metadata()
self.assertEqual(bridge._model_input_width, 224)
self.assertEqual(bridge._model_input_height, 224)
self.assertEqual(bridge._model_input_size, 224)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_DEFAULT)
self.assertIsNone(bridge._model_declared_input_size)
# ── _current_task_type ───────────────────────────────────────────────
class TestCurrentTaskType(BridgeStateTestCase):
def test_legacy_resnet18_maps_to_classification(self):
bridge._model_type = "resnet18"
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
def test_classification_maps_to_classification(self):
bridge._model_type = "classification"
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
def test_detection_types_map_to_object_detection(self):
for t in ("tiny_yolov3", "yolov5s", "fcos", "ssd"):
bridge._model_type = t
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION,
msg=f"model_type={t}")
def test_never_returns_legacy_detection_string(self):
"""R-4值域統一不可再回傳 'detection'"""
for t in ("tiny_yolov3", "classification", "unknown_type"):
bridge._model_type = t
self.assertNotEqual(bridge._current_task_type(), "detection")
# ── _reset_model_metadata ────────────────────────────────────────────
class TestResetModelMetadata(BridgeStateTestCase):
def test_clears_injected_metadata(self):
bridge._task_type_override = bridge.TASK_TYPE_CLASSIFICATION
bridge._model_labels = ["a", "b"]
bridge._reset_model_metadata()
self.assertIsNone(bridge._task_type_override)
self.assertIsNone(bridge._model_labels)
# ── handle_load_model ────────────────────────────────────────────────
class TestHandleLoadModel(BridgeStateTestCase):
def setUp(self):
super().setUp()
bridge._device_group = object()
class FakeModel:
id = 1784536643
class FakeNef:
models = [FakeModel()]
target_chip = "KL520"
self._fake_nef = FakeNef()
self._load_patch = mock.patch.object(
bridge.kp.core, "load_model_from_file",
side_effect=lambda **kw: self._fake_nef, create=True)
self._load_patch.start()
self.addCleanup(self._load_patch.stop)
self._exists_patch = mock.patch.object(os.path, "exists", lambda p: True)
self._exists_patch.start()
self.addCleanup(self._exists_patch.stop)
def test_classification_hint_is_honored(self):
res = bridge.handle_load_model({
"path": "/x/1784536643_models_520.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertEqual(res["task_type"], bridge.TASK_TYPE_CLASSIFICATION)
self.assertEqual(res["model_type"], "classification")
self.assertEqual(res["label_count"], 3)
self.assertEqual(bridge._model_labels, ["剪刀", "石頭", ""])
def test_without_hint_behaviour_unchanged(self):
"""向後相容:不帶新欄位時與改動前一致(走檔名猜測)。"""
res = bridge.handle_load_model({"path": "/x/1784536643_models_520.nef"})
self.assertEqual(res["model_type"], "tiny_yolov3")
self.assertEqual(res["task_type"], bridge.TASK_TYPE_OBJECT_DETECTION)
self.assertEqual(res["label_count"], 0)
self.assertIsNone(bridge._model_labels)
def test_legacy_response_fields_preserved(self):
res = bridge.handle_load_model({"path": "/x/m.nef"})
for key in ("status", "model_id", "model_type", "input_size",
"model_path", "target_chip"):
self.assertIn(key, res)
self.assertEqual(res["status"], "loaded")
def test_labels_without_task_type_still_stored(self):
res = bridge.handle_load_model({"path": "/x/fcos.nef",
"labels": ["自訂A", "自訂B"]})
self.assertEqual(res["label_count"], 2)
self.assertEqual(bridge._model_labels, ["自訂A", "自訂B"])
def test_reload_clears_previous_labels(self):
bridge.handle_load_model({"path": "/x/a.nef", "labels": [""]})
bridge.handle_load_model({"path": "/x/b.nef"})
self.assertIsNone(bridge._model_labels)
# ── input size 三層來源的端到端接線 ──────────────────────────────
def test_input_size_taken_from_sdk_when_nef_reports_shape(self):
"""端到端SDK 有 shape 就用它,不再靠檔名猜。
使用者的 .nef 檔名不含尺寸資訊,舊版必定落到寫死的 224。
"""
self._fake_nef = nef_with_shape([1, 3, 320, 320], model_id=1784536643)
self._fake_nef.target_chip = "KL520"
res = bridge.handle_load_model({
"path": "/x/1784536643_models_520.nef",
"task_type": "classification",
})
self.assertEqual(res["input_width"], 320)
self.assertEqual(res["input_height"], 320)
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_SDK)
def test_sdk_overrides_wrong_declared_input_size(self):
"""實機驗收情境:使用者自承 640 是隨便填的,模型其實是 320。"""
self._fake_nef = nef_with_shape([1, 3, 320, 320], model_id=1784536643)
self._fake_nef.target_chip = "KL520"
res = bridge.handle_load_model({
"path": "/x/1784536643_models_520.nef",
"task_type": "classification",
"input_size": {"width": 640, "height": 640},
})
self.assertEqual(res["input_width"], 320)
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_SDK)
def test_declared_input_size_used_when_sdk_silent(self):
"""SDK 沒回報 shapeFakeNef 無 input_nodes→ 用宣告值,不是 224。"""
res = bridge.handle_load_model({
"path": "/x/1784536643_models_520.nef",
"task_type": "classification",
"input_size": {"width": 320, "height": 320},
})
self.assertEqual(res["input_width"], 320)
self.assertEqual(res["input_size"], 320)
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_DECLARED)
def test_filename_guess_when_neither_sdk_nor_declared(self):
res = bridge.handle_load_model({
"path": "/x/kl520_20004_fcos-drk53s_w512h512.nef"})
self.assertEqual(res["input_size"], 512)
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_FILENAME)
def test_input_size_fields_absent_from_request_is_backward_compatible(self):
"""不帶 input_size 的舊呼叫端行為完全不變。"""
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)
def test_failed_load_does_not_change_input_size(self):
"""defer-until-success載入失敗 → 尺寸與來源都不可被動到。"""
bridge.handle_load_model({
"path": "/x/kl520_20004_fcos-drk53s_w512h512.nef"})
before = (bridge._model_input_width, bridge._model_input_height,
bridge._model_input_size_source,
bridge._model_declared_input_size)
with mock.patch.object(bridge.kp.core, "load_model_from_file",
side_effect=RuntimeError("boom"), create=True):
res = bridge.handle_load_model({
"path": "/x/other.nef",
"input_size": {"width": 640, "height": 640}})
self.assertIn("error", res)
self.assertEqual(
(bridge._model_input_width, bridge._model_input_height,
bridge._model_input_size_source,
bridge._model_declared_input_size),
before)
def test_missing_file_returns_error(self):
self._exists_patch.stop()
with mock.patch.object(os.path, "exists", lambda p: False):
res = bridge.handle_load_model({"path": "/nope.nef"})
self.assertIn("error", res)
self._exists_patch.start()
def test_no_device_returns_error(self):
bridge._device_group = None
res = bridge.handle_load_model({"path": "/x/m.nef"})
self.assertEqual(res["error"], "device not connected")
# ── M-1失敗路徑不可污染全域 metadata ──────────────────────────
#
# 不變式_model_labels / _task_type_override 必須永遠描述
# 「_model_id 當前指向的模型」。載入失敗時舊模型仍在裝置上、仍可被
# 推論,若 labels 已被新模型的值覆蓋 → 舊模型配新 label 表、標籤全錯。
def _load_first_model(self):
"""先成功載入一個 detection 模型,作為「舊模型」基準。"""
bridge.handle_load_model({
"path": "/x/fcos.nef",
"task_type": "object_detection",
"labels": ["舊A", "舊B"],
})
return {
"model_id": bridge._model_id,
"model_type": bridge._model_type,
"labels": list(bridge._model_labels),
"task_type_override": bridge._task_type_override,
}
def _assert_state_unchanged(self, before):
self.assertEqual(bridge._model_id, before["model_id"])
self.assertEqual(bridge._model_type, before["model_type"])
self.assertEqual(bridge._model_labels, before["labels"])
self.assertEqual(bridge._task_type_override, before["task_type_override"])
def test_load_failure_does_not_pollute_labels(self):
"""load_model_from_file 拋錯 → labels 不可被新模型的值覆蓋。"""
before = self._load_first_model()
with mock.patch.object(bridge.kp.core, "load_model_from_file",
side_effect=RuntimeError("error 40"),
create=True):
res = bridge.handle_load_model({
"path": "/x/new_classification.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertIn("error", res)
self.assertIn("error 40", res["error"])
self._assert_state_unchanged(before)
def test_load_failure_keeps_task_type_consistent_with_model_id(self):
"""失敗後 _current_task_type() 必須仍描述舊模型detection"""
self._load_first_model()
with mock.patch.object(bridge.kp.core, "load_model_from_file",
side_effect=RuntimeError("boom"), create=True):
bridge.handle_load_model({
"path": "/x/c.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
# 若 _task_type_override 被污染成 classification後續推論會走
# classification 分支解析 detection 模型的輸出 → 直接拋錯。
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_models_list_failure_does_not_pollute_state(self):
"""取 nef.models[0] 失敗(空 list→ 同樣不可留下半套狀態。"""
before = self._load_first_model()
class EmptyNef:
models = []
target_chip = "KL520"
with mock.patch.object(bridge.kp.core, "load_model_from_file",
side_effect=lambda **kw: EmptyNef(), create=True):
res = bridge.handle_load_model({
"path": "/x/broken.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertIn("error", res)
self._assert_state_unchanged(before)
# _model_nef 也不可被換成載入失敗的那個 nef
self.assertNotIsInstance(bridge._model_nef, EmptyNef)
def test_missing_file_does_not_pollute_state(self):
"""更早的 early return檔案不存在同樣不可動全域。"""
before = self._load_first_model()
self._exists_patch.stop()
try:
with mock.patch.object(os.path, "exists", lambda p: False):
res = bridge.handle_load_model({
"path": "/nope.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
finally:
self._exists_patch.start()
self.assertIn("error", res)
self._assert_state_unchanged(before)
def test_device_not_connected_does_not_pollute_state(self):
before = self._load_first_model()
bridge._device_group = None
res = bridge.handle_load_model({
"path": "/x/c.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertIn("error", res)
self._assert_state_unchanged(before)
def test_successful_load_still_commits_new_metadata(self):
"""對照組:成功路徑必須真的換成新 metadata不是永遠不寫"""
self._load_first_model()
res = bridge.handle_load_model({
"path": "/x/1784536643_models_520.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertEqual(res["status"], "loaded")
self.assertEqual(bridge._model_labels, ["剪刀", "石頭", ""])
self.assertEqual(bridge._task_type_override,
bridge.TASK_TYPE_CLASSIFICATION)
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
# ── handle_set_inference_optionsM4推論期切換解析方式 / label─────
class TestHandleSetInferenceOptions(BridgeStateTestCase):
"""推論期動態切換:不重新 load_model 就換解析方式與 label 表。
這個 handler 的每一條 early return 都必須是 all-or-nothing —— 半套寫入
會產生「新解析方式配舊 label 表」這種不報錯的錯誤,跟 M-1 修的
load_model 污染問題是同一類。
"""
def setUp(self):
super().setUp()
bridge._device_group = object()
class FakeModel:
id = 1784536643
class FakeNef:
models = [FakeModel()]
target_chip = "KL520"
self._fake_nef = FakeNef()
self._load_patch = mock.patch.object(
bridge.kp.core, "load_model_from_file",
side_effect=lambda **kw: self._fake_nef, create=True)
self._load_patch.start()
self.addCleanup(self._load_patch.stop)
self._exists_patch = mock.patch.object(os.path, "exists", lambda p: True)
self._exists_patch.start()
self.addCleanup(self._exists_patch.stop)
def _load_detection_model(self, path="/x/fcos_w512h512.nef"):
"""先載一個 detection 模型作為「當前已載入」的基準。"""
bridge.handle_load_model({
"path": path,
"task_type": "object_detection",
"labels": ["舊A", "舊B"],
})
def _snapshot(self):
return {
"model_id": bridge._model_id,
"model_type": bridge._model_type,
"input_size": bridge._model_input_size,
"labels": None if bridge._model_labels is None else list(bridge._model_labels),
"task_type_override": bridge._task_type_override,
}
def _assert_unchanged(self, before):
self.assertEqual(bridge._model_id, before["model_id"])
self.assertEqual(bridge._model_type, before["model_type"])
self.assertEqual(bridge._model_input_size, before["input_size"])
self.assertEqual(bridge._model_labels, before["labels"])
self.assertEqual(bridge._task_type_override, before["task_type_override"])
# ── 核心:切換解析方式 ────────────────────────────────────────
def test_switch_detection_to_classification(self):
self._load_detection_model()
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
res = bridge.handle_set_inference_options({"task_type": "classification"})
self.assertEqual(res["status"], "updated")
self.assertEqual(res["task_type"], bridge.TASK_TYPE_CLASSIFICATION)
self.assertEqual(bridge._model_type, "classification")
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
def test_switch_classification_back_to_detection(self):
bridge.handle_load_model({"path": "/x/m.nef", "task_type": "classification"})
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_CLASSIFICATION)
res = bridge.handle_set_inference_options({"task_type": "object_detection"})
self.assertEqual(res["task_type"], bridge.TASK_TYPE_OBJECT_DETECTION)
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_legacy_detection_alias_accepted(self):
"""bridge 層仍收 'detection' 別名Go 端另有更嚴的值域把關)。"""
self._load_detection_model()
res = bridge.handle_set_inference_options({"task_type": "detection"})
self.assertEqual(res["task_type"], bridge.TASK_TYPE_OBJECT_DETECTION)
def test_switch_does_not_change_input_size(self):
"""切解析方式不應該偷改 input size靠保留的 .nef 路徑重推)。"""
self._load_detection_model(path="/x/fcos_w512h512.nef")
before_size = bridge._model_input_size
self.assertEqual(before_size, 512, "前置條件:檔名應解析出 512")
bridge.handle_set_inference_options({"task_type": "classification"})
self.assertEqual(bridge._model_input_size, before_size)
def test_switch_preserves_sdk_input_size(self):
"""切解析方式不可讓 SDK 尺寸退回檔名猜測。
_detect_model_type 會被重跑,若沒把 _model_nef 一起帶回去,尺寸就會
從模型宣告的真值掉回檔名猜的值 —— 而且不報錯。
"""
self._fake_nef = nef_with_shape([1, 3, 320, 320], model_id=1784536643)
self._fake_nef.target_chip = "KL520"
bridge.handle_load_model({"path": "/x/fcos_w512h512.nef",
"task_type": "object_detection"})
self.assertEqual(bridge._model_input_width, 320)
self.assertEqual(bridge._model_input_size_source,
bridge.INPUT_SIZE_SOURCE_SDK)
res = bridge.handle_set_inference_options({"task_type": "classification"})
self.assertEqual(bridge._model_input_width, 320)
self.assertEqual(bridge._model_input_height, 320)
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_SDK)
def test_switch_preserves_declared_input_size(self):
"""同理declared 也不可在切換時被檔名猜測蓋掉。"""
bridge.handle_load_model({"path": "/x/fcos_w512h512.nef",
"task_type": "object_detection",
"input_size": {"width": 320, "height": 256}})
self.assertEqual((bridge._model_input_width, bridge._model_input_height),
(320, 256))
res = bridge.handle_set_inference_options({"task_type": "classification"})
self.assertEqual((bridge._model_input_width, bridge._model_input_height),
(320, 256))
self.assertEqual(res["input_size_source"],
bridge.INPUT_SIZE_SOURCE_DECLARED)
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"
bridge.handle_load_model({"path": "/x/fcos_w512h512.nef",
"task_type": "classification"})
before = (bridge._model_input_width, bridge._model_input_height,
bridge._model_input_size_source)
bridge.handle_set_inference_options({"labels": ["a", "b"]})
self.assertEqual((bridge._model_input_width, bridge._model_input_height,
bridge._model_input_size_source), before)
# ── 核心:切換 label 表 ───────────────────────────────────────
def test_set_labels_only(self):
self._load_detection_model()
res = bridge.handle_set_inference_options({"labels": ["剪刀", "石頭", ""]})
self.assertEqual(res["label_count"], 3)
self.assertEqual(bridge._model_labels, ["剪刀", "石頭", ""])
# 只給 labels 不應動到解析方式
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
def test_set_both_task_type_and_labels(self):
self._load_detection_model()
res = bridge.handle_set_inference_options({
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
self.assertEqual(res["task_type"], bridge.TASK_TYPE_CLASSIFICATION)
self.assertEqual(bridge._model_labels, ["剪刀", "石頭", ""])
def test_empty_labels_clears_table(self):
"""空 list = 清掉 label 表、回到原始 enumclass_N。刻意可達。"""
bridge.handle_load_model({"path": "/x/m.nef",
"task_type": "classification",
"labels": ["剪刀", "石頭", ""]})
res = bridge.handle_set_inference_options({"labels": []})
self.assertEqual(res["label_count"], 0)
self.assertIsNone(bridge._model_labels)
self.assertEqual(bridge._resolve_label(1), "class_1")
def test_null_labels_clears_table(self):
bridge.handle_load_model({"path": "/x/m.nef", "labels": ["a", "b"]})
bridge.handle_set_inference_options({"labels": None})
self.assertIsNone(bridge._model_labels)
def test_sparse_labels_preserved(self):
"""稀疏 label空字串佔位必須原樣保留由 _resolve_label 決定 fallback。"""
self._load_detection_model()
bridge.handle_set_inference_options({"labels": ["a", "", "c"]})
self.assertEqual(bridge._model_labels, ["a", "", "c"])
self.assertEqual(bridge._resolve_label(1, labels=bridge._model_labels),
"class_1")
def test_task_type_only_keeps_existing_labels(self):
"""沒帶 labels 欄位 → label 表原封不動(不是被清空)。"""
bridge.handle_load_model({"path": "/x/m.nef", "labels": ["保留A", "保留B"]})
bridge.handle_set_inference_options({"task_type": "classification"})
self.assertEqual(bridge._model_labels, ["保留A", "保留B"])
# ── 前置條件守衛 ─────────────────────────────────────────────
def test_no_device_returns_error(self):
self._load_detection_model()
before = self._snapshot()
bridge._device_group = None
res = bridge.handle_set_inference_options({"task_type": "classification"})
self.assertIn("error", res)
self.assertEqual(res["error"], "device not connected")
self._assert_unchanged(before)
def test_no_model_loaded_returns_error(self):
"""裝置連了但沒載 model → 明確報錯,不能靜默接受設定。"""
bridge._model_id = None
res = bridge.handle_set_inference_options({"task_type": "classification"})
self.assertIn("error", res)
self.assertIn("no model loaded", res["error"])
def test_empty_params_returns_error(self):
"""兩個欄位都沒帶 = 呼叫端搞錯,回 200 等於假裝做了事。"""
self._load_detection_model()
before = self._snapshot()
res = bridge.handle_set_inference_options({})
self.assertIn("error", res)
self._assert_unchanged(before)
# ── 驗證失敗 → 零全域變動defer-until-success────────────────
def test_invalid_task_type_rejected_and_state_untouched(self):
"""與 load_model 不同:這裡不可 fallback 猜測,必須明確報錯。
靜默忽略會回 200 但什麼都沒切換,使用者以為生效了 —— 正是這個功能
要防的靜默失敗。
"""
self._load_detection_model()
before = self._snapshot()
res = bridge.handle_set_inference_options({"task_type": "segmentation"})
self.assertIn("error", res)
self.assertIn("invalid task_type", res["error"])
self._assert_unchanged(before)
def test_invalid_task_type_does_not_apply_labels(self):
"""關鍵task_type 非法時,同一次請求帶的 labels 也不可被寫入。
若先寫 labels 再驗 task_type就會出現「舊解析方式 + 新 label 表」,
跟 M-1 修掉的污染是同一類的靜默錯誤。
"""
self._load_detection_model()
before = self._snapshot()
res = bridge.handle_set_inference_options({
"task_type": "bogus",
"labels": ["剪刀", "石頭", ""],
})
self.assertIn("error", res)
self.assertEqual(bridge._model_labels, before["labels"],
"task_type 驗證失敗時 labels 不可被寫入")
self._assert_unchanged(before)
def test_non_list_labels_rejected_and_state_untouched(self):
self._load_detection_model()
before = self._snapshot()
res = bridge.handle_set_inference_options({"labels": "剪刀,石頭,布"})
self.assertIn("error", res)
self.assertIn("must be a list", res["error"])
self._assert_unchanged(before)
def test_non_list_labels_does_not_apply_task_type(self):
"""反向labels 非法時,同一次請求帶的 task_type 也不可生效。"""
self._load_detection_model()
before = self._snapshot()
res = bridge.handle_set_inference_options({
"task_type": "classification",
"labels": {"0": "剪刀"},
})
self.assertIn("error", res)
self.assertEqual(bridge._task_type_override, before["task_type_override"],
"labels 驗證失敗時 task_type 不可被寫入")
self._assert_unchanged(before)
# ── 生命週期 ─────────────────────────────────────────────────
def test_options_cleared_on_disconnect(self):
self._load_detection_model()
bridge.handle_set_inference_options({
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
bridge.handle_disconnect({})
self.assertIsNone(bridge._model_labels)
self.assertIsNone(bridge._task_type_override)
self.assertEqual(bridge._model_nef_path, "")
def test_reload_model_overrides_runtime_options(self):
"""重新 load_model 是 source of truth會蓋掉推論期的臨時設定。"""
self._load_detection_model()
bridge.handle_set_inference_options({
"task_type": "classification",
"labels": ["剪刀", "石頭", ""],
})
bridge.handle_load_model({"path": "/x/fcos.nef",
"task_type": "object_detection"})
self.assertEqual(bridge._current_task_type(),
bridge.TASK_TYPE_OBJECT_DETECTION)
self.assertIsNone(bridge._model_labels)
def test_response_shape(self):
self._load_detection_model()
res = bridge.handle_set_inference_options({"task_type": "classification"})
for key in ("status", "model_id", "model_type", "task_type",
"label_count", "input_size"):
self.assertIn(key, res)
def test_dispatch_registered_in_main_loop(self):
"""指令要真的接得到 —— handler 寫好但沒掛進 dispatch 是靜默失效。"""
import ast
import pathlib
src = pathlib.Path(bridge.__file__).read_text(encoding="utf-8")
tree = ast.parse(src)
main_fn = next(n for n in ast.walk(tree)
if isinstance(n, ast.FunctionDef) and n.name == "main")
literals = {n.value for n in ast.walk(main_fn)
if isinstance(n, ast.Constant) and isinstance(n.value, str)}
self.assertIn("set_inference_options", literals,
"set_inference_options 未掛進 main() 的 dispatch")
# ── handle_inference分支 + 不回歸)─────────────────────────────────
class TestHandleInferenceBranching(BridgeStateTestCase):
def setUp(self):
super().setUp()
bridge._device_group = object()
bridge._model_id = 1784536643
# 讓 handle_inference 跳過影像 decode / SDK send-receive
self._patches = [
mock.patch.object(bridge, "HAS_CV2", False),
mock.patch.object(bridge.kp, "GenericImageInferenceDescriptor",
lambda **kw: object(), create=True),
mock.patch.object(bridge.kp, "GenericInputNodeImage",
lambda **kw: object(), create=True),
mock.patch.object(bridge.kp, "ImageFormat",
mock.Mock(KP_IMAGE_FORMAT_RGB565="rgb565"),
create=True),
mock.patch.object(bridge.kp.inference, "generic_image_inference_send",
lambda *a, **k: None, create=True),
]
for p in self._patches:
p.start()
self.addCleanup(p.stop)
def _run(self, arrays, image_b64="Zm9v"):
result = FakeResult(arrays)
with mock.patch.object(bridge.kp.inference,
"generic_image_inference_receive",
lambda *a, **k: result, create=True), \
patch_retrieve(result):
return bridge.handle_inference({"image_base64": image_b64})
def test_classification_result_shape(self):
bridge._model_type = "classification"
bridge._model_labels = ["剪刀", "石頭", ""]
res = self._run([np.array([[0.1, 5.0, 0.2]])])
self.assertEqual(res["taskType"], bridge.TASK_TYPE_CLASSIFICATION)
self.assertEqual(res["detections"], [])
self.assertEqual(res["classifications"][0]["label"], "石頭")
self.assertEqual(res["classifications"][0]["classIndex"], 1)
self.assertIn("timestamp", res)
self.assertIn("latencyMs", res)
def test_classification_without_labels_uses_enum(self):
bridge._model_type = "classification"
bridge._model_labels = None
res = self._run([np.array([[0.1, 5.0, 0.2]])])
self.assertEqual(res["classifications"][0]["label"], "class_1")
def test_legacy_resnet18_still_goes_classification(self):
bridge._model_type = "resnet18"
res = self._run([np.array([[1.0, 2.0]])])
self.assertEqual(res["taskType"], bridge.TASK_TYPE_CLASSIFICATION)
def test_detection_task_type_is_object_detection_not_detection(self):
"""R-4detection 路徑回傳統一值域。"""
bridge._model_type = "tiny_yolov3"
bridge._model_labels = None
res = self._run([np.zeros((1, 255, 7, 7))])
self.assertEqual(res["taskType"], bridge.TASK_TYPE_OBJECT_DETECTION)
self.assertEqual(res["classifications"], [])
def test_detection_path_unaffected_by_absent_labels(self):
"""既有 detection 流程不回歸:沒 labels 時仍走 COCO fallback。
斷言到實際 label 字串,而非只檢查 key 存在 —— 否則 detection
完全壞掉回空陣列也會通過m-2
"""
bridge._model_type = "tiny_yolov3"
bridge._model_labels = None
res = self._run([make_yolo_tensor(16)])
self.assertEqual([d["label"] for d in res["detections"]], ["dog"])
def test_detection_with_truncated_labels_falls_back_to_coco(self):
"""m-2 / C-1 情境釘死:生產環境 labels 永遠不是 None。
models.json 每個 detection model 都帶一份 **只有 10 筆的截斷 COCO**。
_resolve_label 是「逐項 fallback」而非「整體覆蓋」index < 10 用注入的
labels、index >= 10 落到完整 80 類 COCO_CLASSES。
曾有審查認為這會讓 index >= 10 退化成 class_N實際不會。此測試把
正確行為釘死:未來若有人把 _resolve_label 改成「有 labels 就整份取代」
或拿掉 detection parser 的 fallback_labels這裡會立刻失敗。
"""
bridge._model_type = "tiny_yolov3"
bridge._model_labels = list(TRUNCATED_COCO_LABELS)
self.assertEqual(len(bridge._model_labels), 10)
for class_id, expected in ((16, "dog"), (23, "giraffe"), (56, "chair"),
(79, "toothbrush")):
res = self._run([make_yolo_tensor(class_id)])
self.assertEqual([d["label"] for d in res["detections"]], [expected],
msg=f"class_id={class_id} should resolve to {expected}")
def test_detection_with_truncated_labels_uses_injection_in_range(self):
"""對照組index < 10 必須用注入的 labels不是永遠走 COCO"""
bridge._model_type = "tiny_yolov3"
custom = list(TRUNCATED_COCO_LABELS)
custom[2] = "自訂汽車"
bridge._model_labels = custom
res = self._run([make_yolo_tensor(2)])
self.assertEqual([d["label"] for d in res["detections"]], ["自訂汽車"])
def test_classification_parse_failure_returns_error_not_empty(self):
"""shape 對不上時必須回 error、不可靜默回空結果。"""
bridge._model_type = "classification"
res = self._run([np.zeros((85, 7, 7))])
self.assertIn("error", res)
self.assertIn("(85, 7, 7)", res["error"])
self.assertNotIn("classifications", res)
def test_no_model_loaded_returns_error(self):
bridge._model_id = None
res = bridge.handle_inference({"image_base64": "Zm9v"})
self.assertEqual(res["error"], "no model loaded")
def test_no_image_returns_error(self):
bridge._model_type = "classification"
res = bridge.handle_inference({"image_base64": ""})
self.assertEqual(res["error"], "no image data provided")
# ── detection parser label injection ─────────────────────────────────
class TestDetectionLabelInjection(BridgeStateTestCase):
def test_ssd_defaults_to_face(self):
self.assertEqual(
bridge._resolve_label(0, labels=None, fallback_labels=["face"]),
"face")
def test_ssd_uses_injected_label(self):
self.assertEqual(
bridge._resolve_label(0, labels=["人臉"], fallback_labels=["face"]),
"人臉")
def test_parsers_accept_labels_kwarg(self):
import inspect
for fn in (bridge._parse_yolo_output, bridge._parse_fcos_output,
bridge._parse_ssd_output):
self.assertIn("labels", inspect.signature(fn).parameters,
msg=f"{fn.__name__} missing labels kwarg")
if __name__ == "__main__":
unittest.main(verbosity=2)