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

+18 -16
View File
@@ -218,25 +218,27 @@ python3 dfc_compile.py # 产物 checkpoints/actpred_pretrain.hef
量化、编译全部通过;hailo10h 目标。
编译要点与踩坑记录见 `dfc_compile.py` 文档字符串。
> **警告(2026-08-13 树莓派实测)**:首版 HEF 在 NPU 上**输出对全部
> 5 个输入均不敏感**(全 0 / 全 255 / 真实数据输出完全一致,对照
> resnet HEF 同 API 验证输入通道有效)。驱动升级 5.3.0 后复测依旧,
> 判定为 DFC 编译侧问题(输入在编译图中被断开/常量化),并非推理封装
> 问题。**该 HEF 不可用于推理**。已做如下修复,待 x86 重编译后复验:
> - `_onnx_validate.py`:编译前数值验证 `actpred_deploy.onnx` 输入
> 敏感性与 PyTorch 一致性(本次已 PASS,差异 1.5e-5)
> - `dfc_compile.py`:parser/optimize 后自动检查输入层连通性,断开即报错
> - `gen_calibration.py`:pred_token 常量校准退化(scale=18571.7,任何
> 输入都被量化成 0)已改抖动校准,推理端喂学习值(`--pred-token`)
> - `hailo_infer.py`:帧输入按 input_layerN 排序绑定(vstream 报告顺序
> 实测为 1,5,4,3,2,按报告顺序绑定会 frame1/frame2 错位)
> **验证记录(2026-08-13 树莓派实测)**:归一化轮次(`--normalize-actions`
> + `--random-images` 预训练,224×224)重编译的 HEF 已通过 `_npu_test.py`
> 实机复验:img 敏感性 22.3、hist 敏感性 38.9(阈值 0.1),HEF vs torch
> max_diff 0.37(原始动作空间),约 12ms/次(含预处理)。此前两轮失败的
> 根因与修复均已闭环:
> - BN 统计退化 → `--random-images` 预训练 + `export_deploy.py`
> `check_token_ranges` 导出前拦截
> - pred_token 常量校准退化(scale=18571.7)→ 抖动校准,推理端喂学习值
> - 输入层断连 → `dfc_compile.py` parser/optimize 后连通性检查
> - vstream 报告顺序乱序 → `hailo_infer.py` 按 `input_layerN` 排序绑定
> - 帧归一化判定错误(把归一化帧当原始像素喂,图像路径饱和破坏)→
> 按量化网格 dequant(0) 判定,见 hailo_infer.py 注释
>
> 重编译后用 `_npu_test.py` 做 4 角输入敏感性复验(6.3ms/次的封装
> 性能基线已在新驱动 5.3.0 下测通)。
> 验证链:`export_deploy.py`(token 量级)→ `_onnx_validate.py`(ONNX
> 数值)→ `dfc_compile.py`(输入连通性)→ `_npu_test.py`(4 角敏感性)。
> 遗留:全零历史角(z≈-3.5)超出校准输入范围(-2.7)会被截断,
> 该角仅作连通性验证,非真实部署场景。
`hailo_infer.py` 的 `HailoPredictor` 适配 5 输入 HEF,输入布局与量化
参数(含负零偏→ImageNet 归一化识别)从 vstream info 自动推导,单元测试
含 fake hailo_platform 的 5 输入与乱序报告顺序用例。CLI 支持
参数(帧归一化由量化网格 dequant(0) 自动判定)从 vstream info 自动推导,
单元测试含 fake hailo_platform 的 5 输入与乱序报告顺序用例。CLI 支持
`--pred-token` 喂入学习值、`--stats` 喂入归一化统计。
环境调研与 API 踩坑记录见
[docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。
+8 -1
View File
@@ -116,9 +116,16 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
ref = torch_pred(imgs_real, hist_real, True)
ref_zero_pred = torch_pred(imgs_real, hist_real, False)
# torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化
# 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义
if stats_path is not None:
s = np.load(stats_path)
ref = ref * s[1] + s[0]
ref_zero_pred = ref_zero_pred * s[1] + s[0]
print(f"torch learned_pred : {np.round(ref[:6], 3)}...")
print(f"torch zero_pred : {np.round(ref_zero_pred[:6], 3)}...")
print(f"HEF vs torch(learned) max_diff={np.abs(a - ref).max():.4f}")
print(f"HEF vs torch(learned) max_diff={np.abs(a - ref).max():.4f}"
f"(原始动作空间)")
ok = img_sens > 0.1 and hist_sens > 0.1
print("\nPASS: HEF 输入连通,可继续与 torch 对比量化误差"
+36 -7
View File
@@ -150,24 +150,53 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig,
f"配置 {name}={val} 超出合理范围 [{lo}, {hi}]")
def warn_init_from_mismatch(init_cfg: dict, current: Config):
"""--init-from 加载预训练权重前的兼容性警告(train.py / pretrain_mt.py 共用)
normalize_actions 不一致时输入分布与权重不匹配,预训练收益大打折扣;
action_dim 不一致时 act_proj 无法加载对应权重(strict=False 静默跳过,
该层随机初始化)。
"""
if not isinstance(init_cfg, dict):
return
init_data = init_cfg.get("data") or {}
init_norm = init_data.get("normalize_actions", False)
if init_norm != current.data.normalize_actions:
print(f"WARNING: init-from checkpoint 的 normalize_actions="
f"{init_norm} 与当前运行 {current.data.normalize_actions} "
f"不一致,输入分布不匹配会损失预训练收益(见 README 预训练说明)")
init_dim = init_data.get("action_dim")
if init_dim is not None and init_dim != current.data.action_dim:
print(f"WARNING: init-from checkpoint 的 action_dim={init_dim} "
f"与当前运行 {current.data.action_dim} 不一致,"
f"act_proj 权重将随机初始化")
def parse_checkpoint_config(cfg_dict: dict) -> Config:
"""从 checkpoint 恢复 Config,校验键集合与值域
- 顶层缺 data/model 键 → ValueError(带明确信息)
- data/model 段含未知键 → ValueError(列出未知键,避免裸 TypeError)
- data/model/train 段含未知键 → ValueError(列出未知键,避免裸 TypeError)
- 值域非法 → ValueError(复用 validate_config_values)
"""
if not isinstance(cfg_dict, dict):
raise ValueError(f"checkpoint config 应为 dict,实际为 {type(cfg_dict).__name__}")
for section in ("data", "model"):
section_dataclasses = {
"data": DataConfig,
"model": ModelConfig,
"train": TrainConfig,
}
for section, dataclass in section_dataclasses.items():
if section not in cfg_dict:
raise ValueError(f"checkpoint config 缺少 '{section}' 段")
if section in ("data", "model"):
raise ValueError(f"checkpoint config 缺少 '{section}' 段")
continue
section_dict = cfg_dict[section]
if not isinstance(section_dict, dict):
raise ValueError(f"checkpoint config['{section}'] 应为 dict,实际为 {type(section_dict).__name__}")
known = set(DataConfig.__dataclass_fields__) if section == "data" \
else set(ModelConfig.__dataclass_fields__)
unknown = set(section_dict) - known
raise ValueError(
f"checkpoint config['{section}'] 应为 dict,"
f"实际为 {type(section_dict).__name__}")
unknown = set(section_dict) - set(dataclass.__dataclass_fields__)
if unknown:
raise ValueError(
f"checkpoint config['{section}'] 含未知键: {sorted(unknown)}")
+4
View File
@@ -69,6 +69,10 @@ class ActionDataset(Dataset):
raise FileNotFoundError(
f"指定的 action_stats_path 不存在: {action_stats_path}")
stats = np.load(action_stats_path)
if stats.shape != (2, self.action_dim):
raise ValueError(
f"action stats 形状应为 (2, {self.action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}")
self._action_mean = stats[0]
self._action_std = stats[1]
else:
+16 -6
View File
@@ -34,8 +34,12 @@ import tempfile
NET_NAME = "actpred_deploy"
START_NODES = ["frame0", "frame1", "frame2", "action_history", "pred_token"]
INPUT_FORMATS = ["NHWC", "NHWC", "NHWC", "NHWC", "NHWC"]
def start_nodes(frame_count: int) -> list:
"""export_deploy.py 的输入命名约定:frame{i} × frame_count + action_history + pred_token"""
return ([f"frame{i}" for i in range(frame_count)]
+ ["action_history", "pred_token"])
def hailo(*args, venv_bin: str):
@@ -111,6 +115,9 @@ def main():
parser.add_argument("--hw-arch", default="hailo10h",
choices=["hailo15h", "hailo15m", "hailo15l",
"hailo10h", "hailo10p"])
parser.add_argument("--frame-count", type=int, default=3,
help="帧输入个数(与导出 ONNX 的 frame_len 一致,"
"默认 3)")
parser.add_argument("--venv-bin", default="venv_dfc/bin",
help="安装了 hailo-sdk 的 venv bin 目录")
args = parser.parse_args()
@@ -119,6 +126,9 @@ def main():
if not os.path.exists(os.path.join(venv_bin, "hailo")):
sys.exit(f"错误: {venv_bin}/hailo 不存在,请先安装 hailo-sdk")
nodes = start_nodes(args.frame_count)
input_formats = ["NHWC"] * len(nodes)
workdir = tempfile.mkdtemp(prefix="dfc_")
har_raw = os.path.join(workdir, "parsed.har")
har_opt = os.path.join(workdir, "optimized.har")
@@ -129,16 +139,16 @@ def main():
print("[1/3] parser: ONNX → HAR")
hailo("parser", "onnx", args.onnx, "--hw-arch", args.hw_arch, "-y",
"--har-path", har_raw,
"--start-node-names", *START_NODES,
"--start-node-names", *nodes,
"--end-node-names", "predicted_action",
"--input-format",
*[f"{n}={f}" for n, f in zip(START_NODES, INPUT_FORMATS)],
*[f"{n}={f}" for n, f in zip(nodes, input_formats)],
"--disable-onnx-simplifier", venv_bin=venv_bin)
# 校验未被推荐机制截断:输出层应来自预测头(conv 而非 ew_add)
names = har_input_layer_names(har_raw)
if len(names) != len(START_NODES):
sys.exit(f"错误: HAR 输入层数 {len(names)} != {len(START_NODES)},解析可能被截断")
if len(names) != len(nodes):
sys.exit(f"错误: HAR 输入层数 {len(names)} != {len(nodes)},解析可能被截断")
check_input_connectivity(har_raw, names)
print("[2/3] optimize: 量化校准")
+11 -1
View File
@@ -86,6 +86,9 @@ class DeployWrapper(nn.Module):
super().__init__()
import copy
m = model
if not hasattr(m.image_encoder, "net"):
raise ValueError(
"部署版导出仅支持 backbone=simple(MobileNet 的 DFC 改造未实现)")
ref_cnn = nn.Sequential(*list(m.image_encoder.net.children())[:-1])
self.cnns = nn.ModuleList(
[copy.deepcopy(ref_cnn) for _ in range(m._frame_len)])
@@ -99,7 +102,14 @@ class DeployWrapper(nn.Module):
m.img_proj.out_features, m.img_proj.in_features, 1, 1))
self.proj_convs.append(c)
self.proj_bias = nn.Parameter(m.img_proj.bias.data.clone())
self.pool = nn.AvgPool2d((m._image_height // 16, m._image_width // 16))
# 固定 kernel AvgPool 须与训练态 AdaptiveAvgPool2d(1) 逐值等价:
# kernel 取 ref_cnn 的真实输出尺寸。H//16 只在分辨率能被 16 整除
# 时成立(1080 高度下卷积输出 68 行,按 67 取 kernel 会静默丢弃
# 末行,破坏与训练图的数值一致)
with torch.no_grad():
probe = ref_cnn(torch.zeros(
1, 3, m._image_height, m._image_width))
self.pool = nn.AvgPool2d((probe.shape[2], probe.shape[3]))
# act_proj 权重注册为常量,用显式 MatMul+Add 代替 Linear,
# 避免 DFC 对 3D Linear 的错误卷积融合
self.act_w = nn.Parameter(m.act_proj.weight.data.t().clone()) # (D, d)
+31 -5
View File
@@ -23,6 +23,7 @@
import argparse
import os
import tempfile
import numpy as np
@@ -54,11 +55,33 @@ def build_action_history_samples(mt_dir, n_samples, history_len, action_dim, rng
return np.stack([windows[i] for i in idx]).astype(np.float32)
def build_image_samples(n_samples, frame_len, height, width, rng):
# 存 uint8 原始像素值(0~255),加载时再做 ToTensor+Normalize,节省磁盘
return rng.integers(0, 256,
size=(n_samples, frame_len, 3, height, width),
dtype=np.uint8)
def build_image_samples(n_samples, frame_len, height, width, rng,
chunk_samples: int = 4):
"""生成 (n_samples, frame_len, 3, H, W) uint8 校准图像
存 uint8 原始像素值(0~255),加载时再做 ToTensor+Normalize,节省磁盘。
经 memmap 分块填充后返回:1080p 默认分辨率下整块数组约 600MB,
一次性分配容易挤爆内存,分块写盘把峰值压到 chunk_samples 帧量级。
"""
if chunk_samples < 1:
raise ValueError(f"chunk_samples 应 >= 1,实际为 {chunk_samples}")
chunk_samples = min(chunk_samples, n_samples)
fd, tmp_path = tempfile.mkstemp(suffix=".npy")
os.close(fd)
images = np.lib.format.open_memmap(
tmp_path, mode="w+", dtype=np.uint8,
shape=(n_samples, frame_len, 3, height, width))
try:
for start in range(0, n_samples, chunk_samples):
stop = min(start + chunk_samples, n_samples)
images[start:stop] = rng.integers(
0, 256, size=(stop - start, frame_len, 3, height, width),
dtype=np.uint8)
except Exception:
images._mmap.close()
os.unlink(images.filename)
raise
return images
def build_deploy_set(args):
@@ -155,6 +178,9 @@ def main():
args.samples, cfg.frame_len, cfg.image_height, cfg.image_width, rng)
np.savez_compressed(args.output, images=images, action_history=history)
# 释放 memmap 并删除临时文件(数据已写入 npz)
images._mmap.close()
os.unlink(images.filename)
print(f"校准集已保存: {args.output}")
print(f" images: {images.shape} dtype={images.dtype}")
print(f" action_history: {history.shape} dtype={history.dtype}")
+6 -3
View File
@@ -174,10 +174,13 @@ class HailoPredictor:
self.frame_len = len(self._frame_infos)
self._image_layout = "nhwc_frames"
self._frame_quant = self._frame_infos[0].quant_info
# 帧量化零偏为负 ⇒ 校准数据是 ImageNet 归一化后的浮点,
# 推理端需先归一化再量化;零偏为 0 则为原始像素直通
# 帧输入是否需 ImageNet 归一化,由量化网格判定:DFC 的 zp 恒非负
# (zp = round(-min/scale)),码 0 反量化值 dequant(0) = -zp*scale。
# 原始像素输入(min=0, zp=0)下为 0;归一化输入(min≈-2.1)下
# ≈ -2.1(实测 HEF zp=114, scale=0.0187)。旧启发式 zp*scale<0
# 把归一化输入误判为原始像素直通,图像路径被饱和破坏。
self._normalize_frames = \
self._frame_quant.qp_zp * self._frame_quant.qp_scale < 0
(0 - self._frame_quant.qp_zp) * self._frame_quant.qp_scale < -0.5
h_shape = tuple(self._history_info.shape)
self.history_len, self.action_dim = h_shape[1], h_shape[2]
+8 -4
View File
@@ -34,11 +34,15 @@ def parse_mt_action(path) -> np.ndarray:
# 先去除整行注释(# 开头),避免注释中的 @Action 干扰正则匹配
text = re.sub(r"(?m)^[ \t]*#.*$", "", text)
match = re.search(r"@Action\s+(.*?)\s*@", text, re.DOTALL)
if not match:
start_m = re.search(r"@Action\s+", text)
if not start_m:
raise ValueError(f"未找到 @Action 块: {path}")
block = match.group(1)
# 块结束于下一个 @ 段标记;@Action 为文件最后一个块时取到文末
# (旧正则 @Action\s+(.*?)\s*@ 要求后续必有 @,末位块会解析失败)
block = text[start_m.end():]
end_m = re.search(r"\s*@", block)
if end_m:
block = block[:end_m.start()]
rows = []
for line in block.split("\n"):
+1 -3
View File
@@ -111,14 +111,12 @@ def main():
parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。"
"e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)")
parser.add_argument("--action-dim", type=int, default=None,
help="override action dim (auto-detected from checkpoint if omitted)")
parser.add_argument("--output-format", choices=["json", "raw"], default="json")
parser.add_argument("--device", type=str, default=None)
args = parser.parse_args()
predictor = ActionPredictorInference(args.model, args.device)
action_dim = args.action_dim or predictor.action_dim
action_dim = predictor.action_dim
if len(args.images) != predictor.frame_len:
raise SystemExit(
+3 -8
View File
@@ -29,7 +29,8 @@ import torch
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm
from config import Config, build_argparser, apply_overrides, validate_config_values
from config import (Config, build_argparser, apply_overrides,
validate_config_values, warn_init_from_mismatch)
from model import ActionPredictor
from mt_parser import parse_mt_directory
@@ -283,13 +284,7 @@ def main():
if args.init_from:
init_ckpt = torch.load(args.init_from, map_location="cpu",
weights_only=True)
init_cfg = init_ckpt.get("config", {})
init_norm = init_cfg.get("data", {}).get("normalize_actions", False)
if init_norm != cfg.data.normalize_actions:
print(f"WARNING: init-from checkpoint 的 normalize_actions="
f"{init_norm} 与当前运行 "
f"{cfg.data.normalize_actions} 不一致,输入分布不匹配"
f"会损失预训练收益(见 README 预训练说明)")
warn_init_from_mismatch(init_ckpt.get("config", {}), cfg)
missing, unexpected = model.load_state_dict(
init_ckpt["model_state_dict"], strict=False)
if missing:
+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 = [
+3 -1
View File
@@ -6,7 +6,8 @@ import torch
from torch.utils.data import DataLoader, Subset
from tqdm import tqdm
from config import Config, build_argparser, apply_overrides, validate_config_values
from config import (Config, build_argparser, apply_overrides,
validate_config_values, warn_init_from_mismatch)
from model import ActionPredictor
from dataset import ActionDataset
@@ -119,6 +120,7 @@ def main():
if args.init_from:
init_ckpt = torch.load(args.init_from, map_location="cpu", weights_only=True)
warn_init_from_mismatch(init_ckpt.get("config", {}), cfg)
missing, unexpected = model.load_state_dict(
init_ckpt["model_state_dict"], strict=False)
if missing:
@@ -93,11 +93,33 @@ NPU 部署轮次以 224×224 的 `pretrain_best_inference.pt` 为准(HEF 编
| ONNX Runtime CPU | 224×224 | 10.7 ms | 纯 `session.run`(预缩放输入) |
| Hailo-10 NPU(actpred HEF,首版) | 64×64 | 6.3 ms | 含预处理 + 量化/反量化,驱动 5.3.0 |
注:本组 ONNX 纯推理(10.7ms)与 §2 的 5.9ms(同为 224 模型)存在
差异,未追根(torch≥2.12 dynamo 导出路径、系统负载、ORT 线程调度均
可能影响),以本组实测为准;ONNX 仍比 PyTorch 快约 2 倍。NPU 首版
HEF 因编译侧输入断开输出恒定,仅作封装性能基线,数值有效性待重编译
后复验(见 hailo 调研报告)。
### 6.1 归一化轮次 HEF 复测(2026-08-13 同日,输入连通性验证通过后)
同一轮次对通过 `_npu_test.py` 验收的归一化轮次 HEF
(224×224,`hailo_infer.py` 帧归一化判定修复后)实测,预热 5 次取
50 次均值(纯 NPU run 取 200 次):
| 方案 | 输入分辨率 | 延迟 | 备注 |
|---|---|---|---|
| Hailo-10 NPU 端到端 predict | 224×224 | 10.5 ms | 含预处理 + 量化/反量化 |
| Hailo-10 NPU 纯 run(绑定后循环) | — | 4.4 ms | Python API/PCIe 开销主导,HEF 计算本身远小于此 |
| Hailo-10 NPU 预处理(3 帧 resize+归一化) | 224×224 | 3.4 ms | PIL resize 为主 |
| PyTorch CPU(同机复测) | 224×224 | 22.4 ms | 含预处理 |
| ONNX Runtime CPU(同机复测) | 224×224 | 11.0 ms | 含预处理,重新导出的当前 checkpoint |
**结论**:
1. 224×224 下 NPU 端到端(10.5ms)与 ONNX Runtime(11.0ms)基本持平,
纯 NPU run 的 4.4ms 是 Python 绑定/PCIe 同步开销主导(对照 §2
resnet50 串行单帧同为 4.29ms,而 actpred 计算量仅其 ~1/55)。
后续可用 `run_async` 流水线把预处理与 NPU 执行重叠。
2. NPU 的价值不在绝对延迟而是**把推理从 CPU 卸载**,让 4×A76 专注
pybot 主控循环;CPU 侧 ORT 仍是有效的纯软件方案。
3. **1080p 采集是最大瓶颈**:预处理随输入像素量增长,实测
640×480 输入 11.8ms、960×540 输入 15.7ms、1080p 输入 45.0ms
(端到端分别 18.6 / 22.8 / 51.7ms)。部署建议摄像头以
640×480 或更低分辨率采集(或 224×224 中心裁剪 ROI),
而不是全分辨率后缩放。
## 参考