From 823e91a30e3fb8230e9f1fc5c2fc2cefe61e1ac4 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Thu, 13 Aug 2026 15:22:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(actpred):=20=E4=BF=AE=E5=A4=8D=20HEF=20?= =?UTF-8?q?=E5=B8=A7=E5=BD=92=E4=B8=80=E5=8C=96=E8=AF=AF=E5=88=A4=E4=B8=8E?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E5=9B=BE=E6=95=B0=E5=80=BC=E4=B8=8D=E7=AD=89?= =?UTF-8?q?=E4=BB=B7=EF=BC=8C=E8=A1=A5=E9=BD=90=20P3=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 与测速报告更新为验证轮次数据 --- actpred/README.md | 34 +-- actpred/_npu_test.py | 9 +- actpred/config.py | 43 +++- actpred/dataset.py | 4 + actpred/dfc_compile.py | 22 +- actpred/export_deploy.py | 12 +- actpred/gen_calibration.py | 36 ++- actpred/hailo_infer.py | 9 +- actpred/mt_parser.py | 12 +- actpred/predict.py | 4 +- actpred/pretrain_mt.py | 11 +- actpred/test_actpred.py | 211 +++++++++++++++++- actpred/train.py | 4 +- ...pred_inference_speed_benchmark_20260813.md | 32 ++- 14 files changed, 381 insertions(+), 62 deletions(-) diff --git a/actpred/README.md b/actpred/README.md index c89afd2..48cadf6 100644 --- a/actpred/README.md +++ b/actpred/README.md @@ -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)。 diff --git a/actpred/_npu_test.py b/actpred/_npu_test.py index ef719b3..8be7add 100644 --- a/actpred/_npu_test.py +++ b/actpred/_npu_test.py @@ -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 对比量化误差" diff --git a/actpred/config.py b/actpred/config.py index d009696..fe5d782 100644 --- a/actpred/config.py +++ b/actpred/config.py @@ -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)}") diff --git a/actpred/dataset.py b/actpred/dataset.py index 1911729..748fae6 100644 --- a/actpred/dataset.py +++ b/actpred/dataset.py @@ -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: diff --git a/actpred/dfc_compile.py b/actpred/dfc_compile.py index 59ad912..4f54702 100644 --- a/actpred/dfc_compile.py +++ b/actpred/dfc_compile.py @@ -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: 量化校准") diff --git a/actpred/export_deploy.py b/actpred/export_deploy.py index 0bcb08c..7a7f294 100644 --- a/actpred/export_deploy.py +++ b/actpred/export_deploy.py @@ -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) diff --git a/actpred/gen_calibration.py b/actpred/gen_calibration.py index 8ab12f0..9c54ae1 100644 --- a/actpred/gen_calibration.py +++ b/actpred/gen_calibration.py @@ -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}") diff --git a/actpred/hailo_infer.py b/actpred/hailo_infer.py index ebf3450..445a50b 100644 --- a/actpred/hailo_infer.py +++ b/actpred/hailo_infer.py @@ -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] diff --git a/actpred/mt_parser.py b/actpred/mt_parser.py index d75b09a..5067603 100644 --- a/actpred/mt_parser.py +++ b/actpred/mt_parser.py @@ -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"): diff --git a/actpred/predict.py b/actpred/predict.py index f547826..51b1f42 100644 --- a/actpred/predict.py +++ b/actpred/predict.py @@ -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( diff --git a/actpred/pretrain_mt.py b/actpred/pretrain_mt.py index eebce47..95ef97e 100644 --- a/actpred/pretrain_mt.py +++ b/actpred/pretrain_mt.py @@ -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: diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index 994570b..41dac13 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -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 = [ diff --git a/actpred/train.py b/actpred/train.py index 57fa1e3..77368c2 100644 --- a/actpred/train.py +++ b/actpred/train.py @@ -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: diff --git a/docs/reports/actpred_inference_speed_benchmark_20260813.md b/docs/reports/actpred_inference_speed_benchmark_20260813.md index fd238b7..b7144fe 100644 --- a/docs/reports/actpred_inference_speed_benchmark_20260813.md +++ b/docs/reports/actpred_inference_speed_benchmark_20260813.md @@ -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), + 而不是全分辨率后缩放。 ## 参考