feat: 按算法生成模板并支持从 Git LFS 下载模型

- init 新增 yolo26/resnet/arcface/lprnet/face-recognition 模板(多模型人脸检测+识别)
- 新增 embedding/text 输出类型与模板资源(COCO、字符表等)
- 新增 algpkg download-models:从 Gitea LFS 下载 models/BM1688 并做 SHA256 校验
- 补充单元测试与 README
This commit is contained in:
linaro
2026-10-17 20:33:00 +08:00
parent 897aa73bbf
commit 211341cbbb
7 changed files with 2220 additions and 110 deletions
+436
View File
@@ -7,12 +7,18 @@
"""
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,
@@ -318,6 +324,436 @@ def test_cli_init_build_inspect_check(tmp_path: Path) -> None:
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"