"""SDK 自测:清单校验 / 打包往返 / 装载策略 / 本地测试台(无需 worker)。 有设备的用例标 `@pytest.mark.device`(无 sophon.sail 时自动跳过): uv run pytest # 静态用例 uv run pytest -m device # 设备上跑推理 """ from __future__ import annotations import hashlib import http.server import importlib.util import json import subprocess import sys import tempfile import threading import types from pathlib import Path import numpy as np import pytest from algpkg_sdk import (AlgoBuilder, AlgoError, AlgoPackage, AlgoPackageError, Manifest, ManifestError, install_sdk_alias, sha256_file) from algpkg_sdk import testkit from algpkg_sdk.cli import main as cli_main EXAMPLES = Path(__file__).resolve().parents[1] / "examples" def _manifest(**overrides) -> dict: payload = { "manifest_format": 1, "id": "t.demo", "name": "测试包", "version": "1.0.0", "api_version": 1, "kind": "detector", "entry": "Algorithm", "code_package": "alg_t_demo", "models": [{"name": "default", "file": "models/t.bmodel", "precision": "fp16", "resolution": [640, 640], "batch": 1, "default": True}], "params_schema": [{"key": "conf", "type": "float", "default": 0.25, "min": 0.01, "max": 0.99, "label": "置信度"}], "defaults": {"conf": 0.25}, } payload.update(overrides) return payload # ---------------------------------------------------------------------- 清单 def test_manifest_required_fields() -> None: Manifest.parse(_manifest()) for missing in ("id", "version", "models"): bad = _manifest() bad.pop(missing) with pytest.raises(ManifestError): Manifest.parse(bad) def test_manifest_rejects_bad_values() -> None: with pytest.raises(ManifestError): # id 形态 Manifest.parse(_manifest(id="Bad Id!")) with pytest.raises(ManifestError): # 模型路径越界 Manifest.parse(_manifest(models=[{"name": "d", "file": "../x.bmodel"}])) with pytest.raises(ManifestError): # api_version 过高 Manifest.parse(_manifest(api_version=99)) with pytest.raises(ManifestError): # params_schema 重复键 Manifest.parse(_manifest(params_schema=[ {"key": "conf", "type": "float"}, {"key": "conf", "type": "float"}])) def test_manifest_default_model_pick() -> None: manifest = Manifest.parse(_manifest(models=[ {"name": "fp16", "file": "models/a.bmodel"}, {"name": "int8", "file": "models/b.bmodel", "default": True}])) assert manifest.default_model().name == "int8" assert manifest.model("fp16").file == "models/a.bmodel" with pytest.raises(ManifestError): manifest.model("nope") def test_manifest_check_params_semantics() -> None: manifest = Manifest.parse(_manifest()) merged = manifest.check_params({"conf": 0.5}) assert merged["conf"] == 0.5 assert manifest.check_params({})["conf"] == 0.25 # 补默认 with pytest.raises(ManifestError): # 越界拒绝(不静默截断) manifest.check_params({"conf": 2.0}) with pytest.raises(ManifestError): # 类型不符 manifest.check_params({"conf": "abc"}) def test_manifest_legacy_and_entry_helpers() -> None: manifest = Manifest.parse(_manifest(entry="sub:Alg", legacy={"algorithm": "yolo"})) assert manifest.entry_module == "alg_t_demo.sub" assert manifest.entry_class == "Alg" assert manifest.legacy["algorithm"] == "yolo" # ---------------------------------------------------------------------- 打包往返 def _make_source(root: Path, *, model_bytes: bytes = b"FAKE-BMODEL" * 512) -> Path: src = root / "t.demo" (src / "alg_t_demo").mkdir(parents=True) (src / "alg_t_demo" / "__init__.py").write_text( "from video_algpkg_api import Algorithm as Base, AlgoResult\n" "class Algorithm(Base):\n" " def load(self):\n" " self._data = self.ctx.model_bytes()\n" " self.ctx.logger.info('loaded %s bytes', len(self._data))\n" " def infer(self, frame):\n" " return AlgoResult(boxes=None, value=len(self._data))\n", encoding="utf-8", ) (src / "models").mkdir() (src / "models" / "t.bmodel").write_bytes(model_bytes) (src / "assets").mkdir() (src / "assets" / "labels.txt").write_text("person\ncar\n", encoding="utf-8") (src / "manifest.json").write_text(json.dumps(_manifest()), encoding="utf-8") return src def test_build_roundtrip_and_bytes_fidelity() -> None: install_sdk_alias() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = _make_source(root) out = root / "t.demo-1.0.0.mbank" summary = AlgoBuilder(src, verbose=False).build(out) assert out.is_file() and summary["algo_id"] == "t.demo" assert summary["sha256"] == sha256_file(out) pkg = AlgoPackage(out) pkg.open() manifest = pkg.require_manifest() assert manifest.version == "1.0.0" data, meta = pkg.read_model("default") assert data == (src / "models" / "t.bmodel").read_bytes() # 包内模型与源文件逐字节相同 assert meta["precision"] == "fp16" assert pkg.read_asset("assets/labels.txt").decode().splitlines() == ["person", "car"] files = {item["path"] for item in pkg.list_files()} assert {"manifest.json", "assets/labels.txt", "models/t.bmodel"} <= files pkg.close() def test_build_creates_version_snapshot_and_signature() -> None: from sqlite_vfs.core import SQLiteVFS with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = _make_source(root) out = root / "signed.mbank" AlgoBuilder(src, verbose=False).build(out, sign_key="KEY-1", sign_key_id="ci") vfs = SQLiteVFS(str(out), readonly=True, check_same_thread=False) try: assert [s["name"] for s in vfs.list_snapshots()] == ["1.0.0"] report = vfs.verify_signature("KEY-1") assert report["ok"] and report["key_id"] == "ci" assert not vfs.verify_signature("KEY-WRONG")["ok"] finally: vfs.close() def test_build_reports_missing_model_and_code() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = _make_source(root) (src / "models" / "t.bmodel").unlink() with pytest.raises(Exception) as exc: AlgoBuilder(src, verbose=False).build(root / "x.mbank") assert "模型" in str(exc.value) src2 = _make_source(root / "other") import shutil shutil.rmtree(src2 / "alg_t_demo") with pytest.raises(Exception) as exc2: AlgoBuilder(src2, verbose=False).build(root / "y.mbank") assert "代码目录" in str(exc2.value) def test_encrypt_removes_plain_source() -> None: from sqlite_vfs.core import SQLiteVFS key = "uA9mYq0T2wGZ0dX1vJ8kL3pR5sN7bC4eF6hK2mP0qT8=" with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = _make_source(root) out = root / "enc.mbank" AlgoBuilder(src, key=key, verbose=False).build(out) vfs = SQLiteVFS(str(out), readonly=True, check_same_thread=False) try: files = [e["path"] for e in vfs.all_entries() if not e["is_directory"]] finally: vfs.close() assert not any(f.endswith(".py") for f in files) # 明文源码不得留在包里 # 不带 key 打开结构没问题;装载(import)会因缺密钥失败 pkg = AlgoPackage(out) pkg.open() with pytest.raises(AlgoPackageError) as exc: pkg.mount() assert "密钥" in str(exc.value) pkg.close() def test_bad_file_rejected() -> None: with tempfile.TemporaryDirectory() as tmp: bad = Path(tmp) / "bad.mbank" bad.write_bytes(b"not a database at all") with pytest.raises(AlgoPackageError): AlgoPackage(bad).open() # ---------------------------------------------------------------------- 多模型包 def _make_multi_model_source(root: Path) -> Path: """一个包带两份模型(检测 + 二次判定):代码按名字取第二份(多模型包的标准写法)。""" src = root / "t.multi" (src / "alg_t_multi").mkdir(parents=True) (src / "alg_t_multi" / "__init__.py").write_text( "from video_algpkg_api import Algorithm as Base, AlgoResult\n" "class Algorithm(Base):\n" " def load(self):\n" " self._det = self.ctx.model_bytes() # 选中模型(默认检测器)\n" " self._cls = self.ctx.model_bytes('fp_filter') # 按名字取第二份\n" " def infer(self, frame):\n" " return AlgoResult(boxes=None, value=len(self._det) + len(self._cls))\n", encoding="utf-8", ) (src / "models").mkdir() (src / "models" / "det.bmodel").write_bytes(b"DET" * 256) (src / "models" / "fp.bmodel").write_bytes(b"FPFILTER" * 128) (src / "manifest.json").write_text(json.dumps(_manifest( id="t.multi", name="多模型包", code_package="alg_t_multi", models=[{"name": "detector", "file": "models/det.bmodel", "default": True}, {"name": "fp_filter", "file": "models/fp.bmodel", "precision": "int8"}])), encoding="utf-8") return src def test_multi_model_package_context_access() -> None: """`ctx.model_bytes(name)` 按 manifest.models[] 取包内任意模型;不给名 = 选中模型(旧写法兼容)。""" install_sdk_alias() with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = _make_multi_model_source(root) out = root / "multi.mbank" AlgoBuilder(src, verbose=False).build(out) pkg = AlgoPackage(out) pkg.open() pkg.mount() try: instance = pkg.new_instance("detector", {}) ctx = instance.ctx det = (src / "models" / "det.bmodel").read_bytes() fp = (src / "models" / "fp.bmodel").read_bytes() assert ctx.model_name == "detector" assert ctx.model_bytes() == det # 选中模型(向后兼容) assert ctx.model_bytes("fp_filter") == fp # 按 name 取第二份 assert ctx.model_bytes("models/fp.bmodel") == fp # 按 file 路径也认 assert [m["name"] for m in ctx.models] == ["detector", "fp_filter"] assert ctx.model_spec("fp_filter")["precision"] == "int8" assert ctx.model_spec()["name"] == "detector" with pytest.raises(AlgoError) as exc: ctx.model_bytes("nope") assert "fp_filter" in str(exc.value) # 报错带可选清单 assert instance.infer(None).value == len(det) + len(fp) # 包内代码真读到两份不同字节 finally: pkg.close() # ---------------------------------------------------------------------- 契约名(video_algpkg_api) def test_contract_module_importable_without_prior_registration() -> None: """`video_algpkg_api` 是随 SDK 分发的**真实模块**,不只是运行时注册的别名。 新解释器里**先** import 它也要成功(本地开发/IDE/类型检查的常见姿势), 且与 `algpkg_sdk.api` 是同一批对象(重导出,非副本)——否则 worker 侧 `isinstance(instance, Algorithm)` 会出现两个基类("本地过、设备挂")。 """ script = ( "import video_algpkg_api as contract\n" "assert contract.__file__.endswith('video_algpkg_api/__init__.py'), contract.__file__\n" "import algpkg_sdk.api as api\n" "for name in contract.__all__:\n" " assert getattr(contract, name) is getattr(api, name), name\n" "print('contract-ok')\n" ) with tempfile.TemporaryDirectory() as tmp: proc = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30, cwd=tmp) assert proc.returncode == 0, proc.stderr assert "contract-ok" in proc.stdout def test_contract_alias_still_registered_to_api_module() -> None: """worker 侧注册语义不变:导入 `algpkg_sdk` 后,`video_algpkg_api` 解析到 api 模块本身。 (`install_sdk_alias` 用 setdefault:设备上 worker 先注册,真实模块被惰性遮蔽, 两条路径拿到的是同一批对象。) """ import video_algpkg_api as resolved from algpkg_sdk import api assert resolved is api # ---------------------------------------------------------------------- CLI def test_cli_init_build_inspect_check(tmp_path: Path) -> None: src = tmp_path / "cli.demo" assert cli_main(["init", str(src), "--id", "cli.demo", "--name", "CLI 示例"]) == 0 assert (src / "manifest.json").is_file() code_pkg = next((src.glob("alg_cli_demo")), None) assert code_pkg is not None and (code_pkg / "__init__.py").is_file() model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE" * 1024) out = tmp_path / "cli.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"default={model}", "--quiet"]) == 0 assert cli_main(["inspect", str(out), "--json"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 assert cli_main(["list", str(tmp_path)]) == 0 def test_cli_init_yolo26_template(tmp_path: Path) -> None: """`--algorithm yolo26` 生成可直接运行的检测包(不是 TODO 占位)。""" src = tmp_path / "yolo.demo" assert cli_main(["init", str(src), "--id", "yolo.demo", "--name", "YOLO26 示例", "--algorithm", "yolo26"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.id == "yolo.demo" and manifest.kind == "detector" assert manifest.default_model().name == "yolo26s_fp16_1b" assert manifest.default_model().file == "models/yolo26s_fp16_1b.bmodel" assert manifest.output["format"] == "N6" code = (src / manifest.code_package / "__init__.py").read_text() compile(code, "alg_yolo_demo/__init__.py", "exec") # 语法可编译(不 import 设备库) assert "class Algorithm" in code and "_preprocess" in code and "_postprocess" in code assert "TODO" not in code labels = [line for line in (src / "assets" / "labels.txt").read_text().splitlines() if line.strip()] assert len(labels) == 80 and labels[0] == "person" and labels[-1] == "toothbrush" model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE-BMODEL" * 512) out = tmp_path / "yolo.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"yolo26s_fp16_1b={model}", "--quiet"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 def test_cli_init_yolo26_alias_and_unknown_template(tmp_path: Path) -> None: src = tmp_path / "alias.demo" assert cli_main(["init", str(src), "--id", "alias.demo", "--name", "别名", "--template", "yolov26"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.default_model().name == "yolo26s_fp16_1b" bad = tmp_path / "bad.demo" assert cli_main(["init", str(bad), "--id", "bad.demo", "--name", "未知", "--algorithm", "nope"]) == 2 assert not (bad / "manifest.json").exists() def test_cli_init_resnet_template(tmp_path: Path) -> None: """`--algorithm resnet` 生成 ResNet 分类包(ImageNet 归一化 + softmax + 整帧框)。""" src = tmp_path / "resnet.demo" assert cli_main(["init", str(src), "--id", "resnet.demo", "--name", "ResNet 示例", "--algorithm", "resnet"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.id == "resnet.demo" and manifest.kind == "classifier" assert manifest.default_model().name == "resnet50_fp32_1b" assert manifest.default_model().file == "models/resnet50_fp32_1b.bmodel" assert manifest.input["mean"] == [0.485, 0.456, 0.406] assert manifest.schema_defaults()["conf"] == 0.1 code = (src / manifest.code_package / "__init__.py").read_text() compile(code, "alg_resnet_demo/__init__.py", "exec") assert "class Algorithm" in code and "_preprocess" in code assert "_postprocess" in code and "yuv2bgr" in code labels = [line for line in (src / "assets" / "labels.txt").read_text().splitlines() if line.strip() and not line.startswith("#")] assert len(labels) == 1000 and labels[0] == "class_0" and labels[-1] == "class_999" model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE-BMODEL" * 512) out = tmp_path / "resnet.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"resnet50_fp32_1b={model}", "--quiet"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 def test_resnet_template_infer_with_fake_sail(tmp_path: Path, monkeypatch) -> None: """不依赖设备验证分类后处理:top-1 → 整帧框(score/cls)与阈值过滤。""" src = tmp_path / "resnet.infer" cli_main(["init", str(src), "--id", "resnet.infer", "--name", "推理示例", "--algorithm", "resnet"]) code_path = src / "alg_resnet_infer" / "__init__.py" # 生成代码只在方法体内使用 sail;空模块足以导入(不需要真实设备) monkeypatch.setitem(sys.modules, "sophon", types.ModuleType("sophon")) monkeypatch.setitem(sys.modules, "sophon.sail", types.ModuleType("sophon.sail")) spec = importlib.util.spec_from_file_location("alg_resnet_infer_under_test", code_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) class Frame: def width(self) -> int: return 640 def height(self) -> int: return 480 class Tensor: def asnumpy(self): return [[0.1, 3.0, 0.2]] # logits: top-1 = 类别 1 class Net: def process(self, *args, **kwargs) -> None: return None class Bmcv: def bm_image_to_tensor(self, *args, **kwargs) -> None: return None algo = module.Algorithm(ctx=None) algo._conf = 0.5 algo._labels = ["cat", "dog", "bird"] algo._output_scale = 1.0 algo._output_tensor = Tensor() algo._output_tensors = {0: algo._output_tensor} algo._input_tensor = object() algo._input_name, algo._graph = "input", "graph" algo._net = Net() algo._bmcv = Bmcv() algo._preprocess = lambda frame: object() result = algo.infer(Frame()) assert result.boxes.shape == (1, 6) assert (float(result.boxes[0, 0]), float(result.boxes[0, 2])) == (0.0, 639.0) assert int(result.boxes[0, 5]) == 1 assert float(result.boxes[0, 4]) > 0.8 assert result.counts == {"dog": 1} and result.value == 1.0 assert result.meta["class_id"] == 1 algo._conf = 0.99 # 低于阈值 → 空结果,不上报告警 assert algo.infer(Frame()).boxes.shape == (0, 6) def test_cli_init_arcface_template(tmp_path: Path) -> None: """`--algorithm arcface` 生成 ArcFace 特征包(embedding 输出、无类别表)。""" src = tmp_path / "arcface.demo" assert cli_main(["init", str(src), "--id", "arcface.demo", "--name", "ArcFace 示例", "--algorithm", "arcface"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.id == "arcface.demo" and manifest.kind == "classifier" assert manifest.default_model().name == "arcface_resnet50_fp32_1b" assert manifest.default_model().file == "models/arcface_resnet50_fp32_1b.bmodel" assert manifest.output["type"] == "embedding" assert manifest.output["embedding_size"] == 512 assert manifest.schema_defaults()["interval"] == 0.5 code = (src / manifest.code_package / "__init__.py").read_text() compile(code, "alg_arcface_demo/__init__.py", "exec") assert "class Algorithm" in code and "_preprocess" in code assert "meta" in code and "linalg.norm" in code labels = [line for line in (src / "assets" / "labels.txt").read_text().splitlines() if line.strip() and not line.startswith("#")] assert labels == [] # 特征包不需要类别表 model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE-BMODEL" * 512) out = tmp_path / "arcface.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"arcface_resnet50_fp32_1b={model}", "--quiet"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 def test_arcface_template_infer_with_fake_sail(tmp_path: Path, monkeypatch) -> None: """不依赖设备验证特征提取:L2 归一化、512 维 embedding、face 计数。""" src = tmp_path / "arcface.infer" cli_main(["init", str(src), "--id", "arcface.infer", "--name", "特征示例", "--algorithm", "arcface"]) code_path = src / "alg_arcface_infer" / "__init__.py" monkeypatch.setitem(sys.modules, "sophon", types.ModuleType("sophon")) monkeypatch.setitem(sys.modules, "sophon.sail", types.ModuleType("sophon.sail")) spec = importlib.util.spec_from_file_location("alg_arcface_infer_under_test", code_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) class Frame: def width(self) -> int: return 112 def height(self) -> int: return 112 class Tensor: def asnumpy(self): return [[3.0, 4.0] + [0.0] * 510] # 归一化后前两维 = 0.6, 0.8 class Net: def process(self, *args, **kwargs) -> None: return None class Bmcv: def bm_image_to_tensor(self, *args, **kwargs) -> None: return None algo = module.Algorithm(ctx=None) algo._output_tensor = Tensor() algo._input_tensor = object() algo._input_name, algo._input_shapes = "input", {} algo._output_name, algo._graph = "output", "graph" algo._net, algo._bmcv = Net(), Bmcv() algo._preprocess = lambda frame: object() result = algo.infer(Frame()) assert result.boxes is None assert result.counts == {"face": 1} and result.value == 1.0 assert result.meta["dim"] == 512 assert result.meta["embedding"][0] == pytest.approx(0.6) assert result.meta["embedding"][1] == pytest.approx(0.8) assert result.meta["norm"] == pytest.approx(1.0) def test_cli_init_lprnet_template(tmp_path: Path) -> None: """`--algorithm lprnet` 生成车牌识别包(text 输出 + 字符表)。""" src = tmp_path / "lprnet.demo" assert cli_main(["init", str(src), "--id", "lprnet.demo", "--name", "车牌示例", "--algorithm", "lprnet"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.id == "lprnet.demo" and manifest.kind == "classifier" assert manifest.default_model().name == "lprnet_fp32_1b" assert manifest.default_model().file == "models/lprnet_fp32_1b.bmodel" assert manifest.default_model().resolution == [94, 24] assert manifest.output["type"] == "text" assert manifest.output["chars_file"] == "assets/chars.txt" assert manifest.schema_defaults()["interval"] == 0.5 code = (src / manifest.code_package / "__init__.py").read_text() compile(code, "alg_lprnet_demo/__init__.py", "exec") assert "_preprocess" in code and "_postprocess" in code and "chars.txt" in code chars = [line for line in (src / "assets" / "chars.txt").read_text().splitlines() if line.strip() and not line.startswith("#")] assert len(chars) == 68 and chars[0] == "京" and chars[-1] == "-" model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE-BMODEL" * 512) out = tmp_path / "lprnet.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"lprnet_fp32_1b={model}", "--quiet"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 def test_lprnet_template_infer_with_fake_sail(tmp_path: Path, monkeypatch) -> None: """不依赖设备验证 CTC 贪心解码:去重、去 blank、空结果。""" src = tmp_path / "lprnet.infer" cli_main(["init", str(src), "--id", "lprnet.infer", "--name", "车牌推理", "--algorithm", "lprnet"]) code_path = src / "alg_lprnet_infer" / "__init__.py" monkeypatch.setitem(sys.modules, "sophon", types.ModuleType("sophon")) monkeypatch.setitem(sys.modules, "sophon.sail", types.ModuleType("sophon.sail")) spec = importlib.util.spec_from_file_location("alg_lprnet_infer_under_test", code_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) class Frame: def width(self) -> int: return 94 def height(self) -> int: return 24 class Tensor: def __init__(self) -> None: self.logits = np.full((1, 4, 6), -10.0, dtype=np.float32) def asnumpy(self): return self.logits class Net: def process(self, *args, **kwargs) -> None: return None class Bmcv: def bm_image_to_tensor(self, *args, **kwargs) -> None: return None tensor = Tensor() algo = module.Algorithm(ctx=None) algo._chars = ["京", "A", "1", "-"] algo._blank = 3 algo._output_tensor = tensor algo._input_tensor = object() algo._input_name, algo._input_shapes = "input", {} algo._output_name, algo._graph = "output", "graph" algo._net, algo._bmcv = Net(), Bmcv() algo._preprocess = lambda frame: object() # 模型输出 [1, C=4, T=6]:0,0,3(blank),1,2,2 → CTC 去重后 "京A1" for t, cls in enumerate([0, 0, 3, 1, 2, 2]): tensor.logits[0, cls, t] = 10.0 result = algo.infer(Frame()) assert result.boxes is None assert result.meta["text"] == "京A1" assert result.meta["chars"] == [0, 1, 2] assert result.counts == {"plate": 1} and result.value == 1.0 tensor.logits = np.full((1, 4, 6), -10.0, dtype=np.float32) # 全是 blank tensor.logits[0, 3, :] = 10.0 empty = algo.infer(Frame()) assert empty.meta["text"] == "" and empty.meta["chars"] == [] assert empty.counts == {} and empty.value == 0.0 def test_cli_init_face_recognition_template(tmp_path: Path, monkeypatch) -> None: """`--algorithm face-recognition` 生成双模型包(检测框 + 512 维 embedding)。""" src = tmp_path / "face.demo" assert cli_main(["init", str(src), "--id", "face.demo", "--name", "人脸示例", "--algorithm", "face-recognition"]) == 0 manifest = Manifest.parse(json.loads((src / "manifest.json").read_text())) assert manifest.id == "face.demo" and manifest.kind == "detector" assert [m.name for m in manifest.models] == ["face_detector", "arcface"] assert manifest.default_model().name == "face_detector" assert manifest.model("arcface").file == "models/arcface_resnet50_fp32_1b.bmodel" assert manifest.output["embedding_size"] == 512 assert manifest.schema_defaults()["nms"] == 0.3 assert manifest.schema_defaults()["min_face"] == 16 code = (src / manifest.code_package / "__init__.py").read_text() compile(code, "alg_face_demo/__init__.py", "exec") assert "_priorbox" in code and "_detect" in code and "_embed" in code assert 'model_bytes("arcface")' in code labels = [line for line in (src / "assets" / "labels.txt").read_text().splitlines() if line.strip() and not line.startswith("#")] assert labels == ["face"] det = tmp_path / "det.bmodel" det.write_bytes(b"DET" * 2048) arc = tmp_path / "arc.bmodel" arc.write_bytes(b"ARC" * 2048) out = tmp_path / "face.demo-1.0.0.mbank" assert cli_main(["build", str(src), "--out", str(out), "--model", f"face_detector={det}", "--model", f"arcface={arc}", "--quiet"]) == 0 assert cli_main(["verify", str(out), "--json"]) == 0 # 不依赖设备验证先验框/解码/NMS 的集成(官方 RetinaFace 口径) monkeypatch.setitem(sys.modules, "sophon", types.ModuleType("sophon")) monkeypatch.setitem(sys.modules, "sophon.sail", types.ModuleType("sophon.sail")) spec = importlib.util.spec_from_file_location( "alg_face_detect_under_test", src / "alg_face_demo" / "__init__.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) priors = module._priorbox(640, 640) assert priors.shape == (16800, 4) assert np.allclose(priors[0], [4 / 640, 4 / 640, 16 / 640, 16 / 640]) algo = module.Algorithm(ctx=None) algo._conf, algo._nms, algo._max_faces = 0.5, 0.3, 20 algo._priors = priors algo._det_w = algo._det_h = 640 loc = np.zeros((1, 16800, 4), dtype=np.float32) conf = np.zeros((1, 16800, 2), dtype=np.float32) conf[0, :, 1] = 0.01 conf[0, 0, 1] = 0.9 dets = algo._detect([loc, conf], 1.0, 640, 640) assert dets.shape == (1, 6) assert np.allclose(dets[0, :4], [0, 0, 12, 12]) assert dets[0, 4] == pytest.approx(0.9) and dets[0, 5] == 0 def test_model_download_specs_and_aliases() -> None: from algpkg_sdk import models as model_store assert [m.key for m in model_store.resolve_models(["all"])] == list(model_store.MODEL_FILES) assert model_store.resolve_models(["yolov26"])[0].key == "yolo26" assert model_store.resolve_models(["lpr"])[0].key == "lprnet" assert model_store.lfs_batch_url("http://host/repo.git").endswith( "/repo.git/info/lfs/objects/batch") with pytest.raises(ValueError): model_store.resolve_models(["nope"]) def test_cli_download_models_via_lfs(tmp_path: Path, monkeypatch, capsys) -> None: """本地假 LFS 服务验证下载/校验/跳过(不依赖外网与 git-lfs)。""" from algpkg_sdk import models as model_store content = b"FAKE-BMODEL-DOWNLOAD" * 4096 oid = hashlib.sha256(content).hexdigest() spec = model_store.ModelFile("demo", "demo_model.bmodel", oid, len(content), "测试模型") monkeypatch.setattr(model_store, "MODEL_FILES", {"demo": spec}) monkeypatch.setattr(model_store, "ALIASES", {"demo": "demo"}) monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") class Handler(http.server.BaseHTTPRequestHandler): def do_POST(self) -> None: # LFS batch length = int(self.headers.get("Content-Length") or 0) payload = json.loads(self.rfile.read(length) or b"{}") port = self.server.server_port objects = [{ "oid": item["oid"], "size": item["size"], "actions": {"download": { "href": f"http://127.0.0.1:{port}/objects/{item['oid']}"}}, } for item in payload.get("objects", [])] body = json.dumps({"objects": objects}).encode() self.send_response(200) self.send_header("Content-Type", "application/vnd.git-lfs+json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self) -> None: # LFS object if self.path.startswith("/objects/"): self.send_response(200) self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) else: self.send_error(404) def log_message(self, *args) -> None: return None server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: dest = tmp_path / "models" / "BM1688" url = f"http://127.0.0.1:{server.server_port}/sophon-edge/algpkg-sdk.git" assert cli_main(["download-models", "--dest", str(dest), "--url", url, "--model", "demo"]) == 0 assert (dest / "demo_model.bmodel").read_bytes() == content assert "下载 1" in capsys.readouterr().out assert cli_main(["download-models", "--dest", str(dest), "--url", url, "--model", "demo"]) == 0 assert "跳过 1" in capsys.readouterr().out assert (dest / "demo_model.bmodel").read_bytes() == content finally: server.shutdown() server.server_close() thread.join(timeout=5) def test_cli_check_reports_static_results(tmp_path: Path) -> None: """check 在无设备时也要给出"清单/结构 OK、试加载跳过"的诚实结论。""" src = tmp_path / "chk.demo" cli_main(["init", str(src), "--id", "chk.demo", "--name", "检查示例"]) model = tmp_path / "fake.bmodel" model.write_bytes(b"FAKE" * 1024) out = tmp_path / "chk.mbank" cli_main(["build", str(src), "--out", str(out), "--model", f"default={model}", "--quiet"]) report = testkit.check(out) names = {item["name"]: item for item in report["checks"]} assert names["manifest 合法"]["ok"] assert names["模型文件在包内"]["ok"] if not testkit.has_device(): assert not names["代码挂载(试加载)"]["ok"] and "跳过" in names["代码挂载(试加载)"]["detail"] def test_module_entry_point_help() -> None: proc = subprocess.run([sys.executable, "-m", "algpkg_sdk.cli", "--help"], capture_output=True, text=True, timeout=30) assert proc.returncode == 0 assert "check" in proc.stdout and "run" in proc.stdout and "compare" in proc.stdout # ---------------------------------------------------------------------- 示例包(真实结构) def test_example_person_count_manifest() -> None: example = EXAMPLES / "person-count" manifest = Manifest.parse(json.loads((example / "manifest.json").read_text())) assert manifest.id == "demo.person-count" and manifest.kind == "counter" assert manifest.output["type"] == "counter" assert (example / manifest.code_package).is_dir() # 模型文件由打包时 --model 指定(示例目录不带 bmodel),这里只校验声明形态 assert manifest.default_model().file.startswith("models/") # ---------------------------------------------------------------------- 设备用例 @pytest.mark.device def test_check_deep_on_device() -> None: """设备:入口类可实例化(模型真加载)。 默认用**自包含**示例包(examples/person-count 打出来的包);内置包(依赖 worker 内部模块) 属"特权包",需额外传 `extra_path=[worker 工程目录]`。 """ import os pkg_path = os.environ.get("ALGPKG_DEVICE_PACKAGE") if not pkg_path or not Path(pkg_path).is_file(): pytest.skip("未提供 ALGPKG_DEVICE_PACKAGE(指向含真实模型的 .mbank)") extra = [p for p in (os.environ.get("ALGPKG_EXTRA_PATH") or "").split(":") if p] report = testkit.check(pkg_path, deep=True, extra_path=extra) assert report["ok"], report["checks"] @pytest.mark.device def test_run_on_device() -> None: """设备:跑一段视频,拿到逐帧检出与单帧耗时。""" import os pkg_path = os.environ.get("ALGPKG_DEVICE_PACKAGE") clip = os.environ.get("ALGPKG_DEVICE_CLIP") if not pkg_path or not clip: pytest.skip("未提供 ALGPKG_DEVICE_PACKAGE / ALGPKG_DEVICE_CLIP") extra = [p for p in (os.environ.get("ALGPKG_EXTRA_PATH") or "").split(":") if p] report = testkit.run(pkg_path, source=clip, frames=3, warmup=1, extra_path=extra) summary = report.summary() assert summary["frames"] >= 1 assert summary["infer_ms"]["avg"] > 0