Files
blackbird/actpred/export_deploy.py
T
qing 85bf68f505 feat(actpred): 固定范围归一化 + 逐列加权损失,附 pybot*actpred 审计修复
新增 action_norm.py 固定物理范围 min-max 归一化,无 stats 文件零状态部署;train.py 引入 _weighted_mse 逐列加权(舵机 1.0/spk 0.2/保留位 0.0)。审计修复: P0-1 pybot 读 normalize_mode, R2-1 NaN 阻断 best, P2-a 后端大小写不敏感, P1-3 utf-8-sig BOM, R1-1 data_dir 覆盖, R2-7 _run.sh 报错, R2-3 空 val 存 best.pt, P2-a2 误报警, R1-2 合并 parse_action_history, P2-b 合并 numpy import。评估修正 oob 物理界+error_growth 按 episode 中位数。推理部署全链支持 fixed_range。
2026-08-14 18:26:03 +08:00

302 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""导出 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 os
import numpy as np
import torch
import torch.nn as nn
from config import parse_checkpoint_config
from model import ActionPredictor
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)
MAX_TOKEN_ABS = 100.0
MERGE_RATIO_WARN = 32.0
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
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)])
# 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())
# 固定 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)
self.act_b = nn.Parameter(m.act_proj.bias.data.clone())
# R2 方案一:IMU 独立 token(imu_dim>0 时追加图输入,同 MatMul+Add 手法)
self._imu_dim = m._imu_dim
if self._imu_dim > 0:
self.imu_w = nn.Parameter(m.imu_proj.weight.data.t().clone())
self.imu_b = nn.Parameter(m.imu_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, *inputs: torch.Tensor) -> torch.Tensor:
# inputs = frame0..frame{F-1}, action_history, pred_token, [imu]
# 全程 (1, 1, S, d) 布局:seq 占 W 维,避免任何 transpose
frames = list(inputs[:self._frame_len])
action_history = inputs[self._frame_len]
pred_token = inputs[self._frame_len + 1]
imu = inputs[self._frame_len + 2] if self._imu_dim > 0 else None
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)
parts = [img_tokens, act_tokens]
if self._imu_dim > 0:
# imu: (1, 1, 1, imu_dim) 归一化姿态 → (1, 1, 1, d),位于 PRED 之前
parts.append(torch.matmul(imu, self.imu_w) + self.imu_b)
parts.append(pred_token)
tokens = torch.cat(parts, 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 check_token_ranges(wrapper: "DeployWrapper", frame_len: int, height: int,
width: int, history_len: int, action_dim: int,
pred_token: torch.Tensor, max_abs: float = MAX_TOKEN_ABS,
action_stats_path: str = None,
normalize_mode: str = "zscore"):
"""导出前检查 token 量级:幅度过大时 uint8 量化会把小信号压成 0
预训练用全零图像占位 → BN running_var 停在初始小值 → 推理时每级
BN 放大 ~50 倍,图像 token 可达 ±2e5。DFC 在 token merge 点按最大
幅度分配共享量化 scale(实测 scale=1965.92,pred_token/act_tokens
全部量化成 0),HEF 输出对任何输入恒定。必须在编译前拦截。
归一化模型(normalize_actions)的 HEF 输入是归一化后的历史,
检查输入须与推理端一致(zscore → z 空间采样;fixed_range → [0,1])。
"""
rng = np.random.default_rng(42)
raw = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8)
norm = (raw.astype(np.float32) / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
frames = [torch.tensor(norm[None]) for _ in range(frame_len)]
history = rng.uniform(0.0, 500.0, size=(1, 1, history_len, action_dim))
if normalize_mode == "fixed_range":
# fixed_range 归一化空间即 [0,1],直接在归一化空间采样
history = rng.uniform(0.0, 1.0, size=(1, 1, history_len, action_dim))
elif action_stats_path is not None:
stats = np.load(action_stats_path)
if stats.shape != (2, action_dim):
raise ValueError(
f"action stats 形状应为 (2, {action_dim}),"
f"实际为 {stats.shape}: {action_stats_path}")
# z-score 归一化模型直接在 z 空间采样:raw 均匀采样会在零方差列上
# 产生天文数字 z 值(实际推理中这些列恒为 0,z 恒为 0)
history = rng.normal(0.0, 1.5, size=(1, 1, history_len, action_dim))
history = torch.tensor(history, dtype=torch.float32)
with torch.no_grad():
feats = [wrapper._frame_feat(f, i).permute(0, 2, 3, 1)
for i, f in enumerate(frames)]
img_tokens = torch.cat(feats, dim=2) + wrapper.proj_bias
act_tokens = torch.matmul(history, wrapper.act_w) + wrapper.act_b
img_max = img_tokens.abs().max().item()
act_max = act_tokens.abs().max().item()
pred_max = pred_token.abs().max().item()
print(f"token 量级检查: |img|={img_max:.4g} |act|={act_max:.4g} "
f"|pred|={pred_max:.4g}(图像上限 {max_abs})")
if img_max > max_abs:
raise SystemExit(
f"错误: 图像 token 幅度 {img_max:.3g} 超过可量化上限 {max_abs}。"
f"checkpoint 的 BN 统计退化(预训练用全零图像占位,running_var "
f"停在初始小值),uint8 量化会把小信号全部压成 0、HEF 输出恒定。"
f"请先用真实/随机图像数据微调后再导出。")
if act_max > MERGE_RATIO_WARN * max(img_max, 1.0):
print(f"WARNING: act_tokens 幅度 {act_max:.3g} 远大于 img_tokens "
f"{img_max:.3g},merge 点共享 scale 会粗量化图像路径;"
f"建议用 --normalize-actions 训练(动作归一化)压小 act_tokens")
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")
parser.add_argument("--pred-token-out", default=None,
help="保存学习到的 pred_token 到 .npy(HEF 推理端喂入用,"
"配合抖动校准避免常量量化退化)")
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
stats_path = None
if cfg.data.normalize_actions and cfg.data.normalize_mode != "fixed_range":
# 与 predict.py 语义一致:相对路径按 checkpoint 目录解析
# (fixed_range 模式免 stats 文件,跳过)
stats_path = cfg.data.action_stats_path
if not stats_path:
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)),
"action_stats.npy")
elif not os.path.isabs(stats_path):
stats_path = os.path.join(
os.path.dirname(os.path.abspath(args.model)), stats_path)
if not os.path.exists(stats_path):
raise FileNotFoundError(
f"模型训练时启用了动作归一化,但未找到统计文件: {stats_path}")
check_token_ranges(wrapper, f, h, w, cfg.data.history_len,
cfg.data.action_dim, model.pred_token.data.unsqueeze(1),
action_stats_path=stats_path,
normalize_mode=cfg.data.normalize_mode)
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)
imu_dim = cfg.data.imu_dim
# imu_dim>0 时追加归一化姿态输入(均匀采样的归一化值域)
dummy_imu = torch.randn(1, 1, 1, imu_dim) if imu_dim > 0 else None
extra_inputs = (dummy_imu,) if imu_dim > 0 else ()
extra_names = ["imu"] if imu_dim > 0 else []
with torch.no_grad():
ref = wrapper(*dummy_frames, dummy_history, dummy_pred, *extra_inputs)
torch.onnx.export(
wrapper, (*dummy_frames, dummy_history, dummy_pred, *extra_inputs),
args.output,
input_names=[f"frame{i}" for i in range(f)] + ["action_history", "pred_token"] + extra_names,
output_names=["predicted_action"],
opset_version=18,
)
print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token"
f"{' + imu' if imu_dim > 0 else ''})")
if args.pred_token_out:
import numpy as np
np.save(args.pred_token_out, model.pred_token.data.unsqueeze(1).numpy())
print(f"pred_token 已保存: {args.pred_token_out} (1,1,1,{model.pred_token.shape[-1]})")
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),
dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None)
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()
if imu_dim > 0:
feeds["imu"] = dummy_imu.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()