Files
algpkg-sdk/tests/test_sdk.py
T
linaro 211341cbbb feat: 按算法生成模板并支持从 Git LFS 下载模型
- init 新增 yolo26/resnet/arcface/lprnet/face-recognition 模板(多模型人脸检测+识别)
- 新增 embedding/text 输出类型与模板资源(COCO、字符表等)
- 新增 algpkg download-models:从 Gitea LFS 下载 models/BM1688 并做 SHA256 校验
- 补充单元测试与 README
2026-10-17 20:33:00 +08:00

825 lines
36 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.
"""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