Compare commits

...
2 Commits
Author SHA1 Message Date
qing 2068319c3a fix(actpred): 落实 0812 代码审计 N-2/N-3 修复项
- 默认 batch_size 32→4(1920×1080 下 CPU/Pi 内存不可行)
- 速度基准报告补 1080p 复测注记(CPU 不可实时,需 NPU 或降分辨率)
- 附重写后首轮全量审计报告(含 N-1 checkpoint 兼容性回归记录)
2026-08-13 01:34:00 +00:00
qing 0d16832612 feat(actpred): 新增 Hailo DFC 编译链路,ONNX → HEF 全流程打通
训练态 ONNX(5D 输入/AdaptiveAvgPool/注意力 head reshape)无法被 DFC 解析,
新增 export_deploy.py 做权重不变的等价图改造:帧分支展开 CNN、img_proj
折叠为 1x1 卷积、注意力按头展开、全程 (1,1,S,d) 无 transpose 布局。
dfc_compile.py 一键走 parser→optimize→compiler(DFC 5.3),
gen_calibration.py --deploy 生成部署布局校准集。
已验证 110 层完整网络编译为 hailo10h HEF(1.8MB,4 contexts)。
2026-08-13 01:33:58 +00:00
8 changed files with 822 additions and 22 deletions

No files matched your search

+8
View File
@@ -1,5 +1,6 @@
# Virtual environment # Virtual environment
venv/ venv/
venv_dfc/
.env/ .env/
# Python bytecode # Python bytecode
@@ -12,6 +13,13 @@ checkpoints/
*.pt *.pt
*.pth *.pth
*.onnx *.onnx
*.onnx.data
*.hef
*.har
# DFC logs
acceleras.log
hailo_sdk.*.log
# Dataset (too large for git) # Dataset (too large for git)
data/ data/
+20 -21
View File
@@ -121,7 +121,7 @@ recorder.flush()
| `--attn-layers` | 2 | TransformerEncoder 层数 | | `--attn-layers` | 2 | TransformerEncoder 层数 |
| `--epochs` | 100 | 训练轮数 | | `--epochs` | 100 | 训练轮数 |
| `--lr` | 0.001 | 学习率 | | `--lr` | 0.001 | 学习率 |
| `--batch-size` | 32 | 批次大小 | | `--batch-size` | 4 | 批次大小(默认分辨率 1920×1080 下 CPU/Pi 内存受限;小分辨率可调大) |
| `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) | | `--normalize-actions` | False | 动作归一化(`--no-normalize-actions` 关闭) |
| `--dropout` | 0.1 | Dropout 率 | | `--dropout` | 0.1 | Dropout 率 |
| `--weight-decay` | 0.0001 | 权重衰减 | | `--weight-decay` | 0.0001 | 权重衰减 |
@@ -180,30 +180,29 @@ python3 onnx_infer.py --model model.onnx --images f1.jpg f2.jpg f3.jpg \
## Hailo 部署(AI HAT 2 / Hailo-10H) ## Hailo 部署(AI HAT 2 / Hailo-10H)
树莓派 AI HAT 2 上通过 `hailo_infer.py` 的 `HailoPredictor` 走 NPU 推理, 模型需先经 Hailo DFC(仅 x86 Linux,`venv_dfc`)编译为 HEF。
接口与 `ActionPredictorInference` 对齐。模型需先经 Hailo DFC 编译为 HEF: 训练态 ONNX(5D 输入、AdaptiveAvgPool、MultiheadAttention 的 head
reshape)**无法被 DFC 解析**,必须用 `export_deploy.py` 的部署版导出
(按帧展开 CNN、固定 kernel AvgPool、注意力按头展开为独立 Linear、
全程 (1,1,S,d) 无 transpose 布局,权重数值不变):
```bash ```bash
# 1. 导出 ONNX # x86 主机(venv_dfc 内已装 hailo-sdk 5.3):
bash _run.sh export --model checkpoints/best_inference.pt --output model.onnx # 1. 导出部署版 ONNX(输入 frame0~2 + action_history + pred_token)
# 2. 在 x86 主机(或 Hailo 云端 DFC)上编译 + 量化校准 → model.hef,拷回树莓派 python3 export_deploy.py --model checkpoints/pretrain_best_inference.pt
# 3. 推理(零 torch 依赖,仅需 hailo_platform + numpy + PIL) # 2. 生成校准集(合成数据;真实 episode 到位后应替换)
python3 gen_calibration.py --deploy
# 3. 一键编译(parser → optimize → compiler)
python3 dfc_compile.py # 产物 checkpoints/actpred_pretrain.hef
# 4. 将 HEF 拷回树莓派
``` ```
```python 已验证:完整 110 层网络(含 matmul/softmax/layernorm 注意力)解析、
from hailo_infer import HailoPredictor 量化、编译全部通过;HEF 1.8MB,4 contexts,hailo10h 目标。
编译要点与踩坑记录见 `dfc_compile.py` 文档字符串。
with HailoPredictor("model.hef") as predictor: # stats_path= 仅归一化模型需要 树莓派端 `hailo_infer.py` 的 `HailoPredictor` 尚需适配新的 5 输入
action = predictor.predict(images, action_history) HEF(frame0~2 各 (1,64,64,3) NHWC、action_history (1,1,5,18)、
``` pred_token (1,1,1,128) 常量),输出 predicted_action (1,1,18)。
```bash
python3 hailo_infer.py --hef model.hef --images f1.jpg f2.jpg f3.jpg \
--action-history "45,0,...:45,0,..." [--stats action_stats.npy]
```
HEF 的 vstream 名称/形状/量化参数全部从 `HEF.get_input/output_vstream_infos()`
动态推导,不硬编码;图像输入按 Hailo 惯例为 uint8 原始像素
(ImageNet 归一化由 DFC 编译时折叠进网络)。
环境调研与 API 踩坑记录见 环境调研与 API 踩坑记录见
[docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。 [docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。
+3 -1
View File
@@ -28,7 +28,9 @@ class ModelConfig:
@dataclass @dataclass
class TrainConfig: class TrainConfig:
batch_size: int = 32 # 默认输入分辨率 1920×1080,batch=32 在 CPU/Pi 上内存不可行;
# 高分辨率训练取小 batch,小分辨率可用 --batch-size 调大
batch_size: int = 4
epochs: int = 100 epochs: int = 100
lr: float = 1e-3 lr: float = 1e-3
weight_decay: float = 1e-4 weight_decay: float = 1e-4
+115
View File
@@ -0,0 +1,115 @@
"""actpred 部署版 ONNX → HEF 编译脚本(Hailo DFC 5.3,仅 x86 Linux)
流程(对应 DFC 5.x CLI):
hailo parser onnx → HAR(网络翻译)
hailo optimize → 量化优化后的 HAR(需校准集 npz)
hailo compiler → HEF
用法:
# 1. 导出部署版 ONNX(DFC 友好图结构)
python export_deploy.py --model checkpoints/pretrain_best_inference.pt
# 2. 生成校准集(首次)
python gen_calibration.py --deploy
# 3. 编译
python dfc_compile.py
关键点(踩坑记录):
- 训练态 ONNX(5D 输入、AdaptiveAvgPool、MultiheadAttention 的 head
reshape)DFC 无法解析,必须用 export_deploy.py 的改造版图
- pred_token 必须作为图输入:常量 initializer 会被 DFC 误转置
- 必须显式 --start/--end-node-names 与 --input-format,且禁用
onnxsim(本图简化无收益,反而引入 reshape)
- 校准集键名必须匹配 HN 层名 actpred_deploy/input_layerN
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
NET_NAME = "actpred_deploy"
START_NODES = ["frame0", "frame1", "frame2", "action_history", "pred_token"]
INPUT_FORMATS = ["NHWC", "NHWC", "NHWC", "NHWC", "NHWC"]
def hailo(*args, venv_bin: str):
cmd = [os.path.join(venv_bin, "hailo"), *args]
env = dict(os.environ, PATH=venv_bin + os.pathsep + os.environ["PATH"])
return subprocess.run(cmd, env=env, check=True)
def har_input_layer_names(har_path: str) -> list:
"""从 HAR(tar 包)中读 hn 文件,按 input_layerN 排序返回层名"""
with tempfile.TemporaryDirectory() as tmp:
with tarfile.open(har_path) as tf:
tf.extractall(tmp)
hn_files = [f for f in os.listdir(tmp) if f.endswith(".hn")]
with open(os.path.join(tmp, hn_files[0])) as f:
hn = json.load(f)
layers = [(name, l) for name, l in hn["layers"].items()
if l["type"] == "input_layer"]
layers.sort(key=lambda x: x[0])
return [name for name, _ in layers]
def main():
parser = argparse.ArgumentParser(description="Compile actpred deploy ONNX to HEF")
parser.add_argument("--onnx", default="checkpoints/actpred_deploy.onnx")
parser.add_argument("--calib-npz", default="data/calibration_deploy.npz")
parser.add_argument("--output", default="checkpoints/actpred_pretrain.hef")
parser.add_argument("--hw-arch", default="hailo10h",
choices=["hailo15h", "hailo15m", "hailo15l",
"hailo10h", "hailo10p"])
parser.add_argument("--venv-bin", default="venv_dfc/bin",
help="安装了 hailo-sdk 的 venv bin 目录")
args = parser.parse_args()
venv_bin = os.path.abspath(args.venv_bin)
if not os.path.exists(os.path.join(venv_bin, "hailo")):
sys.exit(f"错误: {venv_bin}/hailo 不存在,请先安装 hailo-sdk")
workdir = tempfile.mkdtemp(prefix="dfc_")
har_raw = os.path.join(workdir, "parsed.har")
har_opt = os.path.join(workdir, "optimized.har")
hef_dir = os.path.join(workdir, "hef")
os.makedirs(hef_dir)
try:
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,
"--end-node-names", "predicted_action",
"--input-format",
*[f"{n}={f}" for n, f in zip(START_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)},解析可能被截断")
print("[2/3] optimize: 量化校准")
hailo("optimize", har_raw, "--hw-arch", args.hw_arch,
"--calib-set-path", os.path.abspath(args.calib_npz),
"--output-har-path", har_opt, venv_bin=venv_bin)
print("[3/3] compiler: HAR → HEF")
hailo("compiler", har_opt, "--hw-arch", args.hw_arch,
"--output-dir", hef_dir, venv_bin=venv_bin)
hef_src = os.path.join(hef_dir, f"{NET_NAME}.hef")
shutil.copy(hef_src, args.output)
shutil.copy(har_opt, args.output.replace(".hef", "_optimized.har"))
print(f"HEF 已保存: {args.output} ({os.path.getsize(args.output)} bytes)")
finally:
shutil.rmtree(workdir, ignore_errors=True)
if __name__ == "__main__":
main()
+178
View File
@@ -0,0 +1,178 @@
"""导出 Hailo 部署版 ONNX:DFC 友好图结构
训练态 ONNX 对 DFC 不友好:5D 图像输入、AdaptiveAvgPool2d(被
torch.export 展开成 Loop 节点)、MultiheadAttention 的 head 拆分
Reshape 序列。此脚本逐项改造(权重数值完全不变):
1. 图像输入拆为 F 个独立 (1,H,W,3) NHWC 输入,按帧展开 CNN 分支,
各分支持有独立权重副本(共享 initializer 会被 DFC 误融合)
2. AdaptiveAvgPool2d 换成等价的固定 kernel AvgPool2d
3. 自注意力手工展开为每头独立 Linear(128→32),无 head reshape;
pre-LN 残差结构与训练态 nn.TransformerEncoderLayer(norm_first=True)
逐位等价
"""
import argparse
import torch
import torch.nn as nn
from config import parse_checkpoint_config
from model import ActionPredictor
class ManualAttnBlock(nn.Module):
"""等价替换 nn.TransformerEncoderLayer(d=128, nhead=4, ff=256, norm_first=True)"""
def __init__(self, layer: nn.TransformerEncoderLayer):
super().__init__()
d = layer.self_attn.embed_dim
nh = layer.self_attn.num_heads
self.head_dim = d // nh
self.nh = nh
self.scale = self.head_dim ** -0.5
ipw = layer.self_attn.in_proj_weight # (3d, d) = [Wq; Wk; Wv]
ipb = layer.self_attn.in_proj_bias
self.q_proj = nn.ModuleList()
self.k_proj = nn.ModuleList()
self.v_proj = nn.ModuleList()
for h in range(nh):
sl = slice(h * self.head_dim, (h + 1) * self.head_dim)
slices = (sl, slice(d + sl.start, d + sl.stop),
slice(2 * d + sl.start, 2 * d + sl.stop))
for proj, ws in ((self.q_proj, slices[0]), (self.k_proj, slices[1]),
(self.v_proj, slices[2])):
lin = nn.Linear(d, self.head_dim)
lin.weight.data.copy_(ipw[ws])
proj.append(lin)
for h in range(nh):
self.q_proj[h].bias.data.copy_(ipb[h * self.head_dim:(h + 1) * self.head_dim])
self.k_proj[h].bias.data.copy_(ipb[d + h * self.head_dim:d + (h + 1) * self.head_dim])
self.v_proj[h].bias.data.copy_(ipb[2 * d + h * self.head_dim:2 * d + (h + 1) * self.head_dim])
self.out_proj = layer.self_attn.out_proj
self.norm1 = layer.norm1
self.norm2 = layer.norm2
self.fc1 = layer.linear1
self.fc2 = layer.linear2
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (1, 1, S, d),seq 占 W 维,pre-LN 自注意力 + 残差
n = self.norm1(x)
heads = []
for h in range(self.nh):
q = self.q_proj[h](n)
k = self.k_proj[h](n)
v = self.v_proj[h](n)
s = torch.matmul(q, k.transpose(-1, -2)) * self.scale
a = torch.softmax(s, dim=-1)
heads.append(torch.matmul(a, v))
attn = self.out_proj(torch.cat(heads, dim=3)) # feature 维 concat
x = x + attn
x = x + self.fc2(torch.relu(self.fc1(self.norm2(x))))
return x
class DeployWrapper(nn.Module):
def __init__(self, model: ActionPredictor):
super().__init__()
import copy
m = model
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)])
# img_proj ∘ AvgPool == AvgPool ∘ Conv1x1(均为线性算子,可交换)。
# 把 img_proj 的权重折进每分支末尾的 1×1 卷积,偏置单独用加法,
# 避免 DFC 对 3D Linear 的错误卷积融合
self.proj_convs = nn.ModuleList()
for _ in range(m._frame_len):
c = nn.Conv2d(m.img_proj.in_features, m.img_proj.out_features, 1, bias=False)
c.weight.data.copy_(m.img_proj.weight.data.view(
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))
# act_proj 权重注册为常量,用显式 MatMul+Add 代替 Linear,
# 避免 DFC 对 3D Linear 的错误卷积融合
self.act_w = nn.Parameter(m.act_proj.weight.data.t().clone()) # (D, d)
self.act_b = nn.Parameter(m.act_proj.bias.data.clone())
# pred_token 改为图输入(常量 initializer 会被 DFC 误转置),
# 位置编码经 concat 后的 ew_add 广播可被正确推断
self.pos_embed4 = nn.Parameter(m.pos_embed.data.unsqueeze(1).clone()) # (1, 1, S, d)
self.blocks = nn.ModuleList(
[ManualAttnBlock(l) for l in model.encoder.layers])
self.head = m.head
self._frame_len = m._frame_len
def _frame_feat(self, frame_nhwc: torch.Tensor, i: int) -> torch.Tensor:
x = self.cnns[i](frame_nhwc.permute(0, 3, 1, 2))
x = self.proj_convs[i](x) # 1x1 conv ≡ 池化后的 img_proj
return self.pool(x) # (1, d, 1, 1) NCHW
def forward(self, frame0: torch.Tensor, frame1: torch.Tensor, frame2: torch.Tensor,
action_history: torch.Tensor, pred_token: torch.Tensor) -> torch.Tensor:
# 全程 (1, 1, S, d) 布局:seq 占 W 维,避免任何 transpose
frames = [frame0, frame1, frame2]
feats = [self._frame_feat(frames[i], i).permute(0, 2, 3, 1)
for i in range(self._frame_len)] # (1, 1, 1, d) NHWC
img_tokens = torch.cat(feats, dim=2) + self.proj_bias # (1, 1, F, d)
act_tokens = torch.matmul(action_history, self.act_w) + self.act_b # (1, 1, H, d)
tokens = torch.cat([img_tokens, act_tokens, pred_token], dim=2) # (1, 1, S, d)
tokens = tokens + self.pos_embed4
for blk in self.blocks:
tokens = blk(tokens)
return self.head(tokens[:, :, -1:, :]).squeeze(1) # (1, 1, action_dim)
def main():
parser = argparse.ArgumentParser(description="Export Hailo-friendly ONNX")
parser.add_argument("--model", required=True, help="checkpoint path")
parser.add_argument("--output", default="checkpoints/actpred_deploy.onnx")
args = parser.parse_args()
checkpoint = torch.load(args.model, map_location="cpu", weights_only=True)
cfg = parse_checkpoint_config(checkpoint["config"])
model = ActionPredictor(cfg.model, cfg.data, pretrained=False)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()
wrapper = DeployWrapper(model).eval()
h, w = cfg.data.image_height, cfg.data.image_width
f = cfg.data.frame_len
dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)]
dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim)
dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d)
with torch.no_grad():
ref = wrapper(*dummy_frames, dummy_history, dummy_pred)
torch.onnx.export(
wrapper, (*dummy_frames, dummy_history, dummy_pred),
args.output,
input_names=[f"frame{i}" for i in range(f)] + ["action_history", "pred_token"],
output_names=["predicted_action"],
opset_version=18,
)
print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token)")
stacked = torch.stack([fr.permute(0, 3, 1, 2) for fr in dummy_frames], dim=1)
with torch.no_grad():
orig_out = model(stacked, dummy_history.squeeze(1))
print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}")
try:
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession(args.output)
feeds = {f"frame{i}": dummy_frames[i].numpy() for i in range(f)}
feeds["action_history"] = dummy_history.numpy()
feeds["pred_token"] = dummy_pred.numpy()
ort_out = sess.run(None, feeds)[0]
print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})")
except ImportError:
pass
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""生成 DFC 量化校准数据集
两种模式:
默认模式(data/calibration.npz):为旧版 dfc_compile.py 的 ClientModel
流程准备,目前保留兼容。
--deploy 模式(data/calibration_deploy.npz):为 export_deploy.py 导出
的部署版 ONNX 生成校准集,键名匹配 HN 层名 actpred_deploy/input_layerN,
形状匹配 HEF 输入:frame0~2 (N,H,W,3) 已归一化 float32、
action_history (N,1,H_len,D)、pred_token (N,1,1,d) 常量。
图像分辨率与 --model checkpoint 的配置一致。
数据源:
- 图像:无真实 episode 时用 [0,255] 均匀采样像素 + ImageNet 归一化
- 动作历史:从 bot4cj/scripts/*.mt 的真实 @Action 矩阵滑窗采样
(列不足 18 补零,与 pretrain_mt.py 语义一致)
真实数据到位后应替换为真实帧重新校准。
用法:
python gen_calibration.py --deploy --model checkpoints/pretrain_best_inference.pt
"""
import argparse
import numpy as np
from config import DataConfig, parse_checkpoint_config
from mt_parser import parse_mt_directory
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def build_action_history_samples(mt_dir, n_samples, history_len, action_dim, rng):
sequences = []
for _path, matrix in parse_mt_directory(mt_dir):
n_cols = matrix.shape[1]
if n_cols < action_dim:
matrix = np.pad(matrix, ((0, 0), (0, action_dim - n_cols)))
elif n_cols > action_dim:
matrix = matrix[:, :action_dim]
if len(matrix) >= history_len:
sequences.append(matrix)
if not sequences:
raise ValueError(f"{mt_dir} 中没有可用的 @Action 序列")
windows = []
for matrix in sequences:
for i in range(len(matrix) - history_len + 1):
windows.append(matrix[i:i + history_len])
idx = rng.integers(0, len(windows), size=n_samples)
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_deploy_set(args):
import torch
from model import ActionPredictor
ckpt = torch.load(args.model, map_location="cpu", weights_only=True)
cfg = parse_checkpoint_config(ckpt["config"]).data
rng = np.random.default_rng(args.seed)
h, w = cfg.image_height, cfg.image_width
raw = rng.integers(0, 256, size=(args.samples, h, w, 3),
dtype=np.uint8).astype(np.float32) / 255.0
norm = ((raw - IMAGENET_MEAN) / IMAGENET_STD).astype(np.float32)
frames = [norm.copy() for _ in range(cfg.frame_len)]
history = build_action_history_samples(
args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng)
history = history[:, None, :, :] # (N, 1, H_len, D)
model = ActionPredictor(parse_checkpoint_config(ckpt["config"]).model,
cfg, pretrained=False)
model.load_state_dict(ckpt["model_state_dict"])
d = model.pred_token.shape[-1]
pred = np.broadcast_to(
model.pred_token.data.unsqueeze(1).numpy(),
(args.samples, 1, 1, d)).astype(np.float32).copy()
np.savez_compressed(
args.output,
**{f"actpred_deploy/input_layer{i + 1}": arr
for i, arr in enumerate([*frames, history, pred])})
print(f"部署校准集已保存: {args.output}")
print(f" frame0~2: {frames[0].shape}(分辨率 {w}x{h},与 checkpoint 一致)")
print(f" action_history: {history.shape} pred_token: {pred.shape}")
def main():
parser = argparse.ArgumentParser(description="Generate DFC calibration dataset")
parser.add_argument("--deploy", action="store_true",
help="生成部署版(export_deploy.py)校准集")
parser.add_argument("--model", default="checkpoints/pretrain_best_inference.pt",
help="--deploy 模式的 checkpoint(决定分辨率与 pred_token)")
parser.add_argument("--mt-dir", default="../bot4cj/scripts")
parser.add_argument("--output", default=None)
parser.add_argument("--samples", type=int, default=32)
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
if args.deploy:
args.output = args.output or "data/calibration_deploy.npz"
build_deploy_set(args)
return
args.output = args.output or "data/calibration.npz"
cfg = DataConfig()
rng = np.random.default_rng(args.seed)
history = build_action_history_samples(
args.mt_dir, args.samples, cfg.history_len, cfg.action_dim, rng)
images = build_image_samples(
args.samples, cfg.frame_len, cfg.image_height, cfg.image_width, rng)
np.savez_compressed(args.output, images=images, action_history=history)
print(f"校准集已保存: {args.output}")
print(f" images: {images.shape} dtype={images.dtype}")
print(f" action_history: {history.shape} dtype={history.dtype}")
if __name__ == "__main__":
main()
@@ -4,6 +4,16 @@
设备:Raspberry Pi 5(4×A76)+ AI HAT 2(Hailo-10H) 设备:Raspberry Pi 5(4×A76)+ AI HAT 2(Hailo-10H)
模型:actpred(~450K 参数,frame_len=3,history_len=5,action_dim=18) 模型:actpred(~450K 参数,frame_len=3,history_len=5,action_dim=18)
> ⚠️ **基线分辨率说明(2026-08-12 补注,N-3 修复)**
>
> 本报告 §1~§4 的全部数据均在 **640×480** 输入分辨率下测得。
> 提交 `91b3004`(2026-08-12)已将模块默认输入分辨率改为
> **1920×1080**(`config.py` 默认 `image_width/image_height`,与 pybot
> 相机采集分辨率对齐)。**推理延迟随输入像素量近似线性增长
> (1920×1080 ≈ 640×480 的 6.75 倍像素),本报告数字不能直接外推到
> 新默认分辨率。** x86 参考机上的 1920×1080 复测数据见 §5;
> Pi 5 / Hailo 板载在新分辨率下的数据待补测。
## 1. 测试方法 ## 1. 测试方法
- **PyTorch CPU**:`ActionPredictorInference(checkpoints/best_inference.pt)`, - **PyTorch CPU**:`ActionPredictorInference(checkpoints/best_inference.pt)`,
@@ -45,6 +55,31 @@
编译环境调研详见 编译环境调研详见
[actpred_hailo_hat2_inference_research_20260813.md](actpred_hailo_hat2_inference_research_20260813.md) [actpred_hailo_hat2_inference_research_20260813.md](actpred_hailo_hat2_inference_research_20260813.md)
## 5. 1920×1080 复测(2026-08-12,x86 参考机)
分辨率重构(`91b3004`)后在新默认分辨率下补测 CPU 部分
(Hailo 板载待有硬件环境后补测):
| 方案 | 输入分辨率 | 延迟 | 备注 |
|---|---|---|---|
| PyTorch CPU | 1920×1080 | 701.6 ms | 含 PIL 预处理,预热 2 次取 5 次均值 |
| ONNX Runtime CPU | 1920×1080 | 326.3 ms | 纯推理,预热 2 次取 10 次均值 |
测试机:Intel i3-3240(2 核 4 线程,2012 年款),7.7 GB 内存;
与 Pi 5(4×A76)**不可直接横向对比绝对值**,仅用于观察分辨率影响趋势。
**结论**:
1. ONNX 相对 PyTorch 仍快约 2.2 倍,§4 的部署优先级方向不变;
2. 即便在 x86 桌面 CPU 上,1920×1080 单帧推理也需数百毫秒,
**远不能满足实时闭环**(原 640×480 报告推算 Pi 5 上将超过 1 秒)。
1920×1080 全分辨率闭环推理必须依赖:
- Hailo HAT 2 NPU(uint8 量化 + 硬件加速,预期可压到几十毫秒级),或
- 训练/推理时降低实际分辨率(如 960×540 或 640×360),或
- 裁剪输入(ROI)后再推理;
3. 建议在 Pi 5 + HAT 2 环境补测 1920×1080(ONNX CPU / HEF NPU),
并据此确定量产部署分辨率。
## 参考 ## 参考
- [张高兴的 Raspberry Pi AI 开发指南:将自定义模型编译为 Hailo NPU 的 .hef 模型](https://www.cnblogs.com/zhanggaoxing/articles/18597244.html) - [张高兴的 Raspberry Pi AI 开发指南:将自定义模型编译为 Hailo NPU 的 .hef 模型](https://www.cnblogs.com/zhanggaoxing/articles/18597244.html)
+333
View File
@@ -0,0 +1,333 @@
# actpred 模块代码审计报告(Transformer 架构重写后,三轮审查)
> 审查日期:2026-08-12 (Asia/Shanghai)
> 审查人:QoderCN Automated Review
> 审查范围:`actpred/` 全模块(Python,PyTorch 动作预测神经网络,注意力架构)
> 代码基线:第 1/2 轮 — `36feeb4 feat(actpred): 新增 ONNX Runtime 推理后端 OnnxPredictor`;
> 第 3 轮 — `91b3004 refactor(actpred): 模型输入尺寸 image_size → image_width/image_height`
>
> **与上轮审计的关系**:上一份报告 `actpred_CODE_AUDIT_20260808.md` 针对的是
> 旧架构(MLP/GRU 历史编码 + 融合头,~140K 参数)。此后模块经历了注意力架构重写
> (`docs/reports/actpred_attention_rewrite_test_20260809.md`),并新增了
> .mt 预训练(`pretrain_mt.py`)、Hailo NPU 后端(`hailo_infer.py`)、
> ONNX Runtime 后端(`onnx_infer.py`)。本报告为架构重写后的**首轮全量审计**,
> 含三轮审查:第 1 轮整体审计 + 第 2 轮深度复审(边界行为/资源管理/跨文件一致性)
> 见 §二~§七;第 3 轮针对分辨率重构提交(`91b3004`)的增量审计及 N-1 回归的
> 修复验证见 §八。上上轮报告(20260808)的 27 项发现已全部修复,
> 本报告仅记录新发现的问题。
---
## 一、模块概述
| 属性 | 值 |
|------|-----|
| 定位 | 机器人动作预测(多帧图像 + 历史动作 → 下一步动作回归) |
| 语言 | Python 3.12(venv 内置) |
| 框架 | PyTorch ≥ 2.0 |
| 架构 | CNN/MobileNetV2 图像编码 + Transformer(pre-LN)token 融合 + `[PRED]` token 回归 |
| 参数量 | ~450K(simple CNN 默认配置) |
| 入口 | `_run.sh`(train / pretrain / export / predict 子命令分发) |
### 目录结构
```
actpred/
├── config.py # 三组 dataclass 配置 + CLI 解析 + checkpoint 配置校验
├── model.py # ActionPredictor(SimpleCNN/MobileNetV2 + Transformer encoder)
├── dataset.py # ActionDataset + DataRecorder
├── train.py # 训练主流程(episode 级划分、早停、余弦退火、断点续训)
├── pretrain_mt.py # 从 .mt 脚本预训练动作动力学(无图像,零帧占位)
├── predict.py # PyTorch 推理后端
├── onnx_infer.py # ONNX Runtime CPU 推理后端(零 torch 依赖)
├── hailo_infer.py # Hailo NPU (HEF) 推理后端(零 torch 依赖)
├── export_onnx.py # ONNX 导出(opset 18,动态 batch)
├── mt_parser.py # .mt @Action 矩阵解析器(`-` 前向填充语义)
├── test_actpred.py # 单元测试(1133 行,含 fake hailo_platform 注入)
├── _run.sh / _set.sh # 运行 / 环境安装脚本
└── requirements.txt
```
### 总体评价
模块质量**较高**:
- checkpoint 反序列化全部使用 `weights_only=True`,杜绝 pickle RCE;
- `parse_checkpoint_config` 拒绝未知键、`validate_config_values` 限制值域,
可防恶意/损坏 checkpoint 注入异常维度(OOM 攻击面已收敛);
- 测试覆盖全面:模型前向、配置校验、.mt 解析、三后端端到端、Hailo fake 注入;
- 三个推理后端接口对齐(`predict(images, action_history)`),维度全部从
模型/HEF 元数据动态推导,无硬编码。
本轮共发现 **16 项**问题:0 严重、4 中、10 低、2 信息级。
---
## 二、发现清单(汇总)
| # | 严重度 | 位置 | 问题摘要 |
|---|--------|------|---------|
| R1-1 | 中 | `pretrain_mt.py:141` | `data_dir` 被覆盖为 .mt 目录,`--save-config` 导出语义错误的 JSON |
| R1-2 | 中 | `predict.py:92` / `onnx_infer.py:23` / `hailo_infer.py:34` | `parse_action_history` 重复实现三次 |
| R1-3 | 低 | `train.py:14-20` | `split_episodes` 在 n=1 时训练集为空,报错信息不明确 |
| R1-4 | 低 | `dataset.py:137-149` | `DataRecorder` 崩溃时产生孤儿 episode(有图无 actions.npy) |
| R1-5 | 低 | `predict.py:113-114` | `--action-dim` override 参数无用且是陷阱(详见 R2-9) |
| R1-6 | 低 | `_set.sh:8` | PyTorch 安装未固定版本,与 requirements.txt 约束不一致 |
| R1-7 | 信息 | `hailo_infer.py:206-210` | Hailo 后端不做 ImageNet 归一化(依赖 DFC 编译折叠),HEF 与代码强绑定 |
| R1-8 | 低 | 所有 CLI 路径参数 | 无目录遍历校验(本地工具可接受,暴露为服务需加固) |
| R2-1 | 中 | `train.py:158` / `pretrain_mt.py:250` | NaN 验证损失永久阻断 best checkpoint 保存 |
| R2-2 | 中 | `dataset.py:69-71` | `ActionDataset` 加载外部 stats 文件缺 shape 校验(推理端均有) |
| R2-3 | 中 | `train.py:103-105,193-201` | 空验证集时 `best.pt` 从不创建,`--init-from` 微调会找不到文件 |
| R2-4 | 中 | `_set.sh:11` | requirements.txt 重装时可能从镜像解析到 CUDA 版 torch |
| R2-5 | 低 | `dataset.py:77` | 归一化统计量仅从 target 计算,episode 前 start 步动作未参与 |
| R2-6 | 低 | `hailo_infer.py:65` | `HEF` 对象读取 vstream info 后从不释放 |
| R2-7 | 低 | `_run.sh:14-16` | 未知子命令静默回退到 `predict.py`,拼写错误难以发现 |
| R2-8 | 低 | `parse_action_history`(三处) | 非数值输入抛裸 `ValueError`,无 step/列上下文 |
| R2-9 | 低 | `predict.py:120` | `--action-dim` override 后必然在 shape 校验处失败,应删除 |
(编号:R1-x = 第 1 轮发现,R2-x = 第 2 轮复审新发现)
---
## 三、安全审查
### 已具备的防护(良好实践)
1. **pickle 反序列化**:全部 6 处 `torch.load` 均带 `weights_only=True`
(`train.py:120,140`、`predict.py:15`、`export_onnx.py:14`、
`pretrain_mt.py:227`、`model.py:50`)。
2. **checkpoint 配置校验**:`parse_checkpoint_config` 拒绝未知键
(含旧架构遗留键 `history_encoder`/`action_hidden_dim`),
`validate_config_values` 限制所有维度在合理范围(image_size ≤ 1024、
attn_dim ≤ 4096 等),防 OOM 注入。
3. **shell 脚本**:`_run.sh`/`_set.sh` 引号使用正确,无命令注入面。
### 残留风险(R1-8)
所有用户输入路径(`--data-dir`、`--model`、`--images`、`--action-stats-path`、
`--mt-dir`)均无目录遍历/白名单校验。当前 actpred 是本地机器人工具,风险可接受;
若未来通过 fsys/dnfs 等暴露为网络服务,需在入口层做路径规范化校验。
---
## 四、问题详述
### R2-1(中)NaN 验证损失永久阻断 best checkpoint 保存
**位置**:`train.py:158`、`pretrain_mt.py:250`
```python
if val_loss < best_val_loss: # NaN < x 恒为 False
```
**影响**:任一 epoch 出现 NaN(梯度爆炸、数据异常),即使后续 epoch 恢复正常,
best checkpoint 也永远不会再更新;NaN 也不触发 early stopping 的语义保护。
**建议修复**:
```python
if np.isfinite(val_loss) and val_loss < best_val_loss:
...
else:
if not np.isfinite(val_loss):
print(f"WARNING: val_loss 非有限值({val_loss}),跳过 best 更新")
```
### R2-2(中)`ActionDataset` 外部 stats 文件缺 shape 校验
**位置**:`dataset.py:65-71`
```python
stats = np.load(action_stats_path)
self._action_mean = stats[0] # 无 (2, action_dim) 校验
self._action_std = stats[1]
```
**对比**:三个推理后端(`predict.py:57`、`onnx_infer.py:69`、`hailo_infer.py:82`)
均有 `stats.shape != (2, action_dim)` 校验,唯独训练端缺失。错误 shape 会导致
`__getitem__` 中 broadcasting 产生静默错误数据或难懂的报错。
**建议修复**:补上与推理端一致的 shape 校验。
### R2-3(中)空验证集时 `best.pt` 从不创建
**位置**:`train.py:103-105, 158-173, 193-201`
验证集为空时 `validate()` 返回 `inf`,`inf < inf` 为 `False`,训练循环内
从不保存 best;结尾兜底分支只保存 `best_inference.pt`。用户若按 README
用 `best.pt` 做 `--init-from` 微调会得到文件不存在错误。
**建议修复**:空验证集警告中明确说明只会有 `best_inference.pt`,
或兜底同时保存 `best.pt`(与 last.pt 相同的完整结构)。
### R1-1(中)pretrain 覆盖 `data_dir` 为 .mt 目录
**位置**:`pretrain_mt.py:141`
```python
cfg.data.data_dir = args.mt_dir
```
`data_dir` 在预训练流程中未被使用,但 `--save-config` 导出的 JSON 会携带
语义错误的值(.mt 脚本目录被记为图像数据目录),后续加载该配置做微调
会指向错误位置。建议删除该行。
### R1-2(中)`parse_action_history` 三处重复
`predict.py:92`、`onnx_infer.py:23`、`hailo_infer.py:34` 完全相同。
`hailo_infer.py` 注释声明零 torch 依赖是有意为之,但三者均为纯 numpy 实现,
可抽到 `utils.py`(无 torch 导入)共享,同时修 R2-8 时只改一处。
### R2-4(中)`_set.sh` CUDA 版 torch 误装风险
**位置**:`_set.sh:8,11`
先从 CPU index 装 torch,但随后 `pip install -r requirements.txt -i tuna`
在升级/重装场景可能从清华镜像解析到 CUDA 版 torch(requirements.txt 无
CPU-only 约束)。对 Pi 5 等无 CUDA 环境会造成体积膨胀或运行异常。
**建议修复**:requirements.txt 顶部注释警示,或 `_set.sh` 对 torch 依赖
显式指定 `--index-url`(pip 支持 per-requirement index 需借助 constraints)。
### R1-7(信息)Hailo 与 PyTorch/ONNX 图像预处理差异
- PyTorch/ONNX 后端:`Resize → /255 → ImageNet normalize`(float32);
- Hailo 后端:直接传 uint8 原始像素,归一化由 DFC 编译阶段折叠进网络。
这是 Hailo 官方推荐做法且已有文档说明,但 **HEF 文件与本推理代码强绑定**:
手动替换未按正确 preprocessing 配置编译的 HEF,不会报错但结果全错。
测试已通过 fake `hailo_platform` 验证量化/反量化路径正确。
### 其余低优先级问题
| # | 详述 |
|---|------|
| R1-3 | `split_episodes`:n=1 时 `val_count=max(1,0)=1` 吃掉全部 episode,训练集为空。`train.py:99` 会抛错但信息不明。建议 split 内前置检查 `n >= 2`。 |
| R1-4 | `DataRecorder`:`actions.npy` 仅 `flush()` 时写入,崩溃留下孤儿 episode(加载器静默跳过,数据丢失)。建议 flush 失败时清理目录,或 record 时增量落盘。 |
| R1-5 / R2-9 | `predict.py --action-dim` override:checkpoint 已记录维度,override 后 `parse_action_history` 按新维度解析,随后 `predict()` shape 校验必然失败。建议删除该参数。 |
| R1-6 | `_set.sh` 装 torch 无版本约束,requirements.txt 为 `torch>=2.0,<3.0`,二者可能不一致。建议脚本改为 `pip install "torch>=2.0,<3.0" ...`。 |
| R2-5 | 归一化统计量从样本 target(`actions[start:]`)计算,episode 前 `max(H, F-1)` 步仅出现在历史中未参与统计。数据量大时可忽略。 |
| R2-6 | `hailo_infer.py:65` `HEF` 对象仅用于读 vstream info,之后不释放,可能持有句柄。 |
| R2-7 | `_run.sh` 未知子命令静默执行 `predict.py`,`bash _run.sh trani` 拼写错误报参数错而非提示未知命令。建议 `*) echo unknown; exit 1`。 |
| R2-8 | `parse_action_history` 非数值输入抛裸 `ValueError`,建议包装为带 step/列号的错误。 |
---
## 五、测试评估
**覆盖率**(相对源码 ~1500 行,测试 1133 行):
- ✅ 模型前向(多帧 / 单帧退化 / 动态 batch / 自定义 attn)
- ✅ 配置值域 + checkpoint 配置解析(未知键、缺段、旧架构拒绝、越界)
- ✅ .mt 解析(前向填充、首行 `-` 填 0、std.mt 跨模块回归锚点、坏文件跳过)
- ✅ 数据集(形状、归一化、帧数不一致警告、维度不匹配)
- ✅ 三后端端到端(PyTorch / ONNX 与 torch 前向对齐 / Hailo fake 注入含量化往返)
- ✅ 断点续训 checkpoint 格式与权重恢复
- ✅ ONNX 导出(缺 onnxscript 时自动跳过)
**缺口**:
- 无全链路集成测试(pretrain → train → export → OnnxPredictor 推理一致性);
- NaN/inf 损失路径无测试(对应 R2-1);
- `DataRecorder` 崩溃/部分写入场景无测试(对应 R1-4)。
---
## 六、修复优先级建议
| 优先级 | 问题 | 工作量 |
|--------|------|--------|
| 建议修 | R2-1(NaN 污染 best)、R2-2(stats shape 校验) | 小 |
| 值得修 | R1-1、R2-3、R2-4、R2-7、R1-5/R2-9 | 小 |
| 可选 | R1-2(抽共享 utils)、R1-3、R1-4、R1-6、R2-5、R2-6、R2-8 | 中 |
---
## 七、结论
actpred 在注意力架构重写 + 三后端部署扩展后,整体质量保持在较高水平:
安全基线(weights_only、配置校验)完好,测试体系完备,代码可读性好。
第 1/2 轮未发现严重缺陷;4 项中等问题(NaN best 阻断、stats shape 校验缺失、
空验证集 best.pt 缺失、CUDA torch 误装风险)均属健壮性/部署范畴,
建议在下一次训练迭代前修复。
第 3 轮(分辨率重构)发现 1 项**严重回归**(N-1:既有 checkpoint 无法加载),
已通过重新预训练策略修复并验证,见 §八。
---
## 八、第 3 轮审计:分辨率重构(`91b3004`)与 N-1 修复
### 8.1 变更概述
提交 `91b3004 refactor(actpred): 模型输入尺寸 image_size → image_width/image_height,默认 1920×1080`
配合 `a240377 feat(pybot): Camera 默认采集分辨率 640×480 → 1920×1080`,
将单一方形 `image_size`(默认 224)拆分为 `image_width`/`image_height`(默认 1920×1080),
与相机原生采集分辨率对齐。涉及全部 11 个源文件及测试。
### 8.2 第 3 轮发现
| # | 严重度 | 位置 | 问题摘要 | 状态 |
|---|--------|------|---------|------|
| N-1 | 严重 | `config.py:parse_checkpoint_config` | 旧 checkpoint 的 `image_size` 键被判为未知键,全部既有 checkpoint 无法加载 | ✅ 已修复(重训练策略) |
| N-2 | 中 | 默认 1920×1080 | 训练默认分辨率计算/内存开销大,CPU/Pi 上 batch=32 不可行 | ✅ 已修复(默认 batch_size 32 → 4) |
| N-3 | 低 | `docs/reports/actpred_inference_speed_benchmark_20260813.md` | 5.9ms 基准数据基于旧小分辨率,分辨率变更后失效 | ✅ 已修复(注明 640×480 基线 + 新增 §5 1920×1080 复测) |
### 8.3 N-1 详述与修复
**问题**:`parse_checkpoint_config` 的未知键拒绝机制(本身是安全特性)与配置
字段改名冲突——所有旧 checkpoint 的 `config['data']` 含 `image_size` 键,
新代码判定为未知键:
```
ValueError: checkpoint config['data'] 含未知键: ['image_size']
```
实测确认仓库内 `checkpoints/pretrain_best_inference.pt` 加载失败,波及
`predict.py --model`、`export_onnx.py --model`、`train.py --init-from/--resume`。
**修复策略**:重新训练(而非代码内做旧键迁移)。模型权重与输入分辨率无关
(`SimpleCNN`/`MobileNetEncoder` 均以 `AdaptiveAvgPool2d` 收尾),旧权重本身
可迁移,但选择重训练以获得干净的新格式 checkpoint:
1. 旧 checkpoint 归档至 `checkpoints/legacy_image_size_64/`(不删除,保留追溯);
2. 以与旧 checkpoint 相同参数重跑预训练:
`bash _run.sh pretrain --mt-dir ../bot4cj/scripts --epochs 200 --image-width 64 --image-height 64`
(预训练使用零帧占位图像,图像分支不参与学习,小分辨率仅为提速,
与旧 checkpoint 的 image_size=64 保持一致);
3. 训练在 epoch 101 早停(patience=15),best val_loss=205.77
(旧 checkpoint 为 190.58,同一小数据集上的正常波动)。
**验证结果**:
- 新 checkpoint config 为 `image_width: 64, image_height: 64`,无 `image_size` 键 ✅
- `ActionPredictorInference` 加载成功,推理输出形状 (18,)、数值有限 ✅
- `pretrain_best.pt` / `pretrain_best_inference.pt` / `pretrain_last.pt` 均已重新生成 ✅
- 全量测试套件 71/71 通过 ✅
**附注**:本次修复不改任何代码——`parse_checkpoint_config` 的严格未知键拒绝
是正确的安全设计,不应为兼容旧格式放宽;配置 schema 变更时重新生成 checkpoint
是更干净的做法。归档的 legacy checkpoint 仅供追溯,不应再用于推理。
### 8.4 重构正确性核查(无问题项)
- `transforms.Resize((H, W))`(torchvision 语义)与 PIL `resize((W, H))`
在 `dataset.py`/`predict.py` 与 `onnx_infer.py`/`hailo_infer.py` 中参数顺序均正确;
- Hailo 4D/5D 形状推导正确放宽方形约束(`_derive_dims` 删除 `s2 == image_size` 检查),
支持非方形输入;ONNX `_locate_inputs` 同步放宽;
- 重构无遗漏:源码中仅剩测试辅助函数 `_create_dummy_episode` 的局部参数名
`image_size`(与 config 字段无关,无害);
- README、配置表、docstring 中的形状标注(`(S,S)` → `(H,W)`)同步更新。
### 8.5 遗留事项(第 3 轮)
- **N-2 已修复**:`config.py` 默认 `batch_size` 32 → 4(1920×1080 单样本
激活+梯度约数百 MB,batch=4 是 CPU/Pi 8GB 的安全上限;小分辨率可经
`--batch-size` 调大),README 配置表同步更新;71/71 测试通过;
- **N-3 已修复**:基准报告顶部补注 640×480 基线说明,新增 §5 记录
1920×1080 复测(x86 参考机:PyTorch 701.6 ms / ONNX 326.3 ms)。
复测同时暴露新的部署级问题:**1920×1080 全分辨率 CPU 推理远不能
满足实时闭环**,量产必须依赖 Hailo NPU 量化推理或降低推理分辨率,
已在报告 §5 给出建议;Pi 5 板载新分辨率数据待补测;
- §六 优先级表中的第 1/2 轮遗留项(R2-1 NaN、R2-2 stats 校验等)仍未修复,
继续有效。