fix(actpred): 修复 HEF 帧归一化误判与部署图数值不等价,补齐 P3 审计项

- hailo_infer: 帧归一化判定改为量化网格 dequant(0)(旧启发式把归一化
  帧当原始像素喂入,图像路径饱和破坏;实机 max_diff 2.55→0.37,
  img 敏感性 1.44→22.26)
- export_deploy: pool kernel 由卷积真实输出尺寸探测(H//16 在 1080
  高度下静默丢弃末行);mobilenet backbone 给出明确报错
- mt_parser: @Action 为文件末位块时解析成功,不再静默丢数据
- config: checkpoint train 段未知键/非 dict 校验;init-from 兼容性
  警告助手(normalize/action_dim),train 与 pretrain 共用
- dataset: stats 文件形状校验;predict: 移除误导性 --action-dim
- dfc_compile: --frame-count 参数化(原硬编码 3 帧)
- gen_calibration: 旧模式校准图像改分块 memmap 生成(1080p 原一次性
  分配 ~600MB)
- _npu_test: torch 参考反归一化对齐后再与 HEF 比较
- 单测 81→90 全过;README 与测速报告更新为验证轮次数据
This commit is contained in:
qing committed 2026-08-13 15:22:34 +08:00
1 parent 316758d80e
commit 823e91a30e
14 files changed
+381 -62

No files matched your search

+209 -2
View File
@@ -21,8 +21,9 @@ import numpy as np
from PIL import Image
import torch
from config import (Config, DataConfig, ModelConfig, build_argparser,
apply_overrides, validate_config_values, parse_checkpoint_config)
from config import (Config, DataConfig, ModelConfig, TrainConfig,
build_argparser, apply_overrides, validate_config_values,
parse_checkpoint_config)
from dataset import _natural_key, ActionDataset, DataRecorder
from model import ActionPredictor
from mt_parser import parse_mt_action, parse_mt_directory
@@ -230,6 +231,19 @@ class TestActionDataset(unittest.TestCase):
normalize_actions=True,
action_stats_path="/nonexistent/path.npy")
def test_bad_stats_shape_raises(self):
"""stats 文件形状不是 (2, action_dim) 时应报错而非广播出诡异结果"""
with tempfile.TemporaryDirectory() as tmpdir:
base = Path(tmpdir)
self._create_dummy_episode(base, n_frames=10, action_dim=4)
stats_path = base / "bad_stats.npy"
np.save(stats_path, np.zeros((2, 3), dtype=np.float32))
with self.assertRaisesRegex(ValueError, "形状"):
ActionDataset(tmpdir, history_len=3, action_dim=4,
image_width=32, image_height=32,
frame_len=3, normalize_actions=True,
action_stats_path=str(stats_path))
def test_action_dim_mismatch_raises(self):
"""actions.npy 列数与 action_dim 不一致应在加载时抛 ValueError"""
with tempfile.TemporaryDirectory() as tmpdir:
@@ -371,6 +385,28 @@ class TestMtParser(unittest.TestCase):
np.testing.assert_array_equal(matrix[0], [0, 0, 0])
np.testing.assert_array_equal(matrix[1], [45, 0, 10])
def test_action_as_last_block_parses(self):
"""@Action 为文件最后一个块(无后续 @ 段)时也应解析成功。
回归:旧正则 @Action\\s+(.*?)\\s*@ 要求后续必有 @,
末位块会报"未找到 @Action 块",parse_mt_directory 进而
静默跳过该文件,造成预训练数据丢失。"""
content = (
"@Servo\n"
" name max min;\n"
" s1 90 -;\n"
"@\n"
"\n"
"@Action\n"
"s1.A s2.A spk.F;\n"
" 10 20 100;\n"
" - 22 -;\n"
)
with tempfile.TemporaryDirectory() as tmpdir:
path = _write_mt(tmpdir, "lastblock.mt", content)
matrix = parse_mt_action(path)
np.testing.assert_array_equal(matrix, [[10, 20, 100], [10, 22, 100]])
def test_std_mt_first_row_matches_pybot(self):
"""跨模块回归锚点:std.mt 首行解析结果与 pybot 运行时执行值一致
@@ -602,6 +638,53 @@ class TestParseCheckpointConfig(unittest.TestCase):
with self.assertRaises(ValueError):
parse_checkpoint_config("not a dict")
def test_train_section_unknown_key_raises(self):
"""train 段含未知键也应报 ValueError(与 data/model 段一致)"""
cfg = Config()
d = cfg.to_dict()
d["train"]["unknown_field"] = 1
with self.assertRaisesRegex(ValueError, "train.*unknown_field"):
parse_checkpoint_config(d)
def test_train_section_non_dict_raises(self):
"""train 段为非 dict 应报 ValueError,而非裸 TypeError"""
cfg = Config()
d = cfg.to_dict()
d["train"] = "not a dict"
with self.assertRaisesRegex(ValueError, "train"):
parse_checkpoint_config(d)
def test_missing_train_section_still_ok(self):
"""旧 checkpoint 无 train 段(推理 checkpoint 可能被裁剪)应可用默认值"""
cfg = Config()
d = cfg.to_dict()
del d["train"]
parsed = parse_checkpoint_config(d)
self.assertEqual(parsed.train.batch_size, TrainConfig().batch_size)
def test_warn_init_from_mismatch(self):
"""--init-from 兼容性警告:normalize 或 action_dim 不一致时提示"""
from config import warn_init_from_mismatch
cfg = Config()
init_cfg = {
"data": {"normalize_actions": True, "action_dim": 12},
}
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warn_init_from_mismatch(init_cfg, cfg)
out = buf.getvalue()
self.assertIn("normalize_actions=True", out)
self.assertIn("action_dim=12", out)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
warn_init_from_mismatch(
{"data": {"normalize_actions": False,
"action_dim": cfg.data.action_dim}}, cfg)
self.assertEqual(buf.getvalue(), "")
with contextlib.redirect_stdout(buf):
warn_init_from_mismatch(None, cfg)
self.assertEqual(buf.getvalue(), "")
class TestDataRecorderValidation(unittest.TestCase):
"""测试 DataRecorder 的输入校验与目录原子分配"""
@@ -774,6 +857,70 @@ class TestExportOnnx(unittest.TestCase):
self.assertGreater(os.path.getsize(out_path), 0)
class TestDeployWrapper(unittest.TestCase):
"""部署版导出(export_deploy.DeployWrapper)的图等价性"""
def test_pool_kernel_matches_conv_output(self):
"""固定 kernel AvgPool 必须覆盖卷积真实输出尺寸。
回归:旧实现用 H//16 取 kernel,1080 高度下卷积输出 68 行、
kernel 取 67 会静默丢弃末行(训练/部署图数值不等价)。"""
from export_deploy import DeployWrapper
cfg = Config()
cfg.data.image_height, cfg.data.image_width = 1080, 1920
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).eval()
w = DeployWrapper(model)
self.assertEqual(w.pool.kernel_size, (68, 120))
cfg.data.image_height, cfg.data.image_width = 224, 224
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).eval()
w = DeployWrapper(model)
self.assertEqual(w.pool.kernel_size, (14, 14))
def test_mobilenet_backbone_rejected(self):
"""MobileNet 的 DFC 改造未实现,应给出明确错误而非 AttributeError"""
from export_deploy import DeployWrapper
cfg = Config()
cfg.model.cnn_backbone = "mobilenet"
model = ActionPredictor(cfg.model, cfg.data, pretrained=False).eval()
with self.assertRaises(ValueError):
DeployWrapper(model)
class TestCompileAndCalibrationHelpers(unittest.TestCase):
"""dfc_compile / gen_calibration 的辅助函数(P3 修复回归)"""
def test_start_nodes_follows_frame_count(self):
"""start_nodes 按 frame_count 生成输入名(旧实现硬编码 3 帧)"""
from dfc_compile import start_nodes
self.assertEqual(start_nodes(3), [
"frame0", "frame1", "frame2", "action_history", "pred_token"])
self.assertEqual(start_nodes(1),
["frame0", "action_history", "pred_token"])
def test_build_image_samples_memmap_chunked(self):
"""分块 memmap 生成的校准图像形状/dtype/值域正确,且可清理"""
from gen_calibration import build_image_samples
rng = np.random.default_rng(0)
images = build_image_samples(
n_samples=6, frame_len=2, height=16, width=16, rng=rng,
chunk_samples=2)
try:
self.assertIsInstance(images, np.memmap)
self.assertEqual(images.shape, (6, 2, 3, 16, 16))
self.assertEqual(images.dtype, np.uint8)
self.assertTrue((images <= 255).all())
self.assertTrue(os.path.exists(images.filename))
finally:
images._mmap.close()
os.unlink(images.filename)
def test_build_image_samples_invalid_chunk(self):
from gen_calibration import build_image_samples
with self.assertRaises(ValueError):
build_image_samples(4, 2, 16, 16, np.random.default_rng(0),
chunk_samples=0)
class TestResumeCheckpoint(unittest.TestCase):
"""测试 --resume 断点续训练:checkpoint 格式与状态恢复"""
@@ -1199,6 +1346,66 @@ class TestHailoPredictor(unittest.TestCase):
np.testing.assert_array_equal(
seen["net/input_layer3"], np.full((8, 8, 3), 30, np.float32))
def _fake_five_input_hef(self, q_frame):
infos_in = [
_FakeVStreamInfo("net/input_layer1", (8, 8, 3),
_FakeFormatType.UINT8, q_frame),
_FakeVStreamInfo("net/input_layer2", (8, 8, 3),
_FakeFormatType.UINT8, q_frame),
_FakeVStreamInfo("net/input_layer3", (8, 8, 3),
_FakeFormatType.UINT8, q_frame),
_FakeVStreamInfo("net/input_layer4", (1, 5, 18),
_FakeFormatType.UINT8,
_FakeQuantInfo(qp_zp=0.0, qp_scale=1.960784)),
_FakeVStreamInfo("net/input_layer5", (1, 1, 128),
_FakeFormatType.UINT8,
_FakeQuantInfo(qp_zp=123.0, qp_scale=0.016)),
]
infos_out = [_FakeVStreamInfo(
"net/conv49", (1, 1, 18), _FakeFormatType.UINT8,
_FakeQuantInfo(qp_zp=2.0, qp_scale=0.837115))]
seen = {}
def transform(b):
seen["f0"] = np.asarray(
b.input("net/input_layer1").get_buffer()).copy()
b.output("net/conv49").get_buffer()[...] = 2
_install_fake_hailo(self, infos_in, infos_out, transform)
return seen
def test_frame_normalized_when_quant_grid_covers_negatives(self):
"""帧输入归一化判定来自量化网格:dequant(0)≈-2.1(实测 HEF
zp=114, scale=0.0187)⇒ 须先 ImageNet 归一化再量化。
回归:旧启发式 zp*scale<0 将其误判为原始像素直通,图像路径
被饱和破坏(实机 max_diff 2.55 → 修复后 0.37)。"""
from hailo_infer import HailoPredictor
seen = self._fake_five_input_hef(
_FakeQuantInfo(qp_zp=114.0, qp_scale=0.018723))
frame = np.full((8, 8, 3), 128, dtype=np.uint8)
with HailoPredictor("fake.hef") as p:
self.assertTrue(p._normalize_frames)
p.predict([frame] * 3, np.zeros((5, 18), np.float32))
# 中灰 128 → 归一化 (128/255-mean)/std → 量化码 ≈ [118, 125, 137]
np.testing.assert_array_equal(
seen["f0"][:, :, 0], np.full((8, 8), 118, np.uint8))
np.testing.assert_array_equal(
seen["f0"][:, :, 1], np.full((8, 8), 125, np.uint8))
np.testing.assert_array_equal(
seen["f0"][:, :, 2], np.full((8, 8), 137, np.uint8))
def test_frame_raw_when_quant_grid_is_pixel_range(self):
"""原始像素校准(zp=0, scale=1)⇒ 帧直通不归一化"""
from hailo_infer import HailoPredictor
seen = self._fake_five_input_hef(
_FakeQuantInfo(qp_zp=0.0, qp_scale=1.0))
frame = np.full((8, 8, 3), 128, dtype=np.uint8)
with HailoPredictor("fake.hef") as p:
self.assertFalse(p._normalize_frames)
p.predict([frame] * 3, np.zeros((5, 18), np.float32))
np.testing.assert_array_equal(
seen["f0"], np.full((8, 8, 3), 128, np.uint8))
def test_frame_count_mismatch_raises(self):
from hailo_infer import HailoPredictor
infos_in = [