fix(actpred): 修复 Hailo HEF 编译链路的输入断连与封装问题

- hailo_infer: 帧输入按 input_layerN 排序绑定(vstream 报告顺序实测
  为 1,5,4,3,2,按报告顺序会 frame1/frame2 错位),新增 --pred-token
- gen_calibration: pred_token 校准改为学习值 ±30% 抖动,修复常量
  校准量化退化(scale=18571.7,任何输入都被量化成 0)
- export_deploy: 新增 --pred-token-out 保存学习值供推理端喂入
- dfc_compile: parser/optimize 后自动检查输入层连通性,断开即报错
- 新增 _npu_test.py(NPU 4 角敏感性复验)与 _onnx_validate.py
  (编译前 ONNX 数值门槛),test_actpred 补乱序帧序用例
This commit is contained in:
qing committed 2026-08-13 12:22:26 +08:00
1 parent 2068319c3a
commit 14964b9574
13 files changed
+487 -958

No files matched your search

+3
View File
@@ -22,3 +22,6 @@ waf/certs/
# 本地 IDE / 工具 # 本地 IDE / 工具
.qoder/ .qoder/
.vscode/ .vscode/
# 运行时日志(HailoRT 等)
hailort.log
+1
View File
@@ -20,6 +20,7 @@ checkpoints/
# DFC logs # DFC logs
acceleras.log acceleras.log
hailo_sdk.*.log hailo_sdk.*.log
hailort.log
# Dataset (too large for git) # Dataset (too large for git)
data/ data/
+108
View File
@@ -0,0 +1,108 @@
"""actpred HEF NPU 输入敏感性复验脚本(重编译后的验收门槛)
用法: venv/bin/python _npu_test.py
4 角测试(真实/全零帧 × 真实/全零历史)+ 与 PyTorch 参考对比。
HEF 尺寸/布局从 vstream info 自动推导,checkpoint 用
checkpoints/pretrain_best_inference.pt。
"""
import sys
import time
sys.path.insert(0, "/opt/blackbird/actpred")
import numpy as np
import torch
import torch.nn as nn
from PIL import Image
from hailo_platform import HEF
from config import parse_checkpoint_config
from model import ActionPredictor
from hailo_infer import HailoPredictor
HEF_PATH = "checkpoints/actpred_pretrain.hef"
CKPT = "checkpoints/pretrain_best_inference.pt"
IMG_DIR = "data/episode_0000/images"
hef = HEF(HEF_PATH)
print("=== HEF vstream infos ===")
for i in hef.get_input_vstream_infos():
q = i.quant_info
print(f"IN {i.name:16s} {tuple(i.shape)} {i.format.type} "
f"zp={q.qp_zp} scale={q.qp_scale:.6g}")
for o in hef.get_output_vstream_infos():
q = o.quant_info
print(f"OUT {o.name:16s} {tuple(o.shape)} {o.format.type} "
f"zp={q.qp_zp} scale={q.qp_scale:.6g}")
imgs_real = [np.array(Image.open(f"{IMG_DIR}/000{i}.jpg").convert("RGB"))
for i in range(3)]
imgs_zero = [np.zeros_like(imgs_real[0]) for _ in range(3)]
hist_real = np.array([
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 10, 200, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 20, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32)
# PyTorch 参考(checkpoint 自带 config)
ck = torch.load(CKPT, map_location="cpu", weights_only=True)
cfg = parse_checkpoint_config(ck["config"])
model = ActionPredictor(cfg.model, cfg.data, pretrained=False)
model.load_state_dict(ck["model_state_dict"])
model.eval()
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,d)
from torchvision import transforms
tf = transforms.Compose([
transforms.Resize((cfg.data.image_height, cfg.data.image_width)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
def torch_pred(imgs, hist, use_learned_pred=True):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
h = torch.tensor(hist).unsqueeze(0)
if use_learned_pred:
with torch.no_grad():
return model(x, h).squeeze(0).numpy()
saved = model.pred_token.data.clone()
model.pred_token.data.zero_()
with torch.no_grad():
out = model(x, h).squeeze(0).numpy()
model.pred_token.data.copy_(saved)
return out
with HailoPredictor(HEF_PATH) as p:
print(f"\ndims: frame_len={p.frame_len} img={p.image_width}x{p.image_height} "
f"history={p.history_len} action_dim={p.action_dim}")
t0 = time.perf_counter()
a = p.predict(imgs_real, hist_real, pred_token=pred_learned)
b = p.predict(imgs_zero, hist_real, pred_token=pred_learned)
c = p.predict(imgs_real, hist_zero, pred_token=pred_learned)
d = p.predict(imgs_zero, hist_zero, pred_token=pred_learned)
t1 = time.perf_counter()
print(f"4x predict: {(t1 - t0) / 4 * 1000:.1f} ms/次")
for name, r in [("real_img+real_hist", a), ("zero_img+real_hist", b),
("real_img+zero_hist", c), ("zero_img+zero_hist", d)]:
print(f"{name:20s} max_abs={np.abs(r).max():.4f} out={np.round(r[:6], 3)}...")
img_sens = np.abs(a - b).max()
hist_sens = np.abs(a - c).max()
print(f"\nimg sensitivity = {img_sens:.4f}(>0.1 视为输入连通)")
print(f"hist sensitivity = {hist_sens:.4f}(>0.1 视为输入连通)")
ref = torch_pred(imgs_real, hist_real, True)
ref_zero_pred = torch_pred(imgs_real, hist_real, False)
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}")
ok = img_sens > 0.1 and hist_sens > 0.1
print("\nPASS: HEF 输入连通,可继续与 torch 对比量化误差"
if ok else "\nFAIL: HEF 输出对输入不敏感,编译仍有问题")
+92
View File
@@ -0,0 +1,92 @@
"""部署版 ONNX 数值验证:输入敏感性 + 与 PyTorch 一致性(重编译前门槛)
用法: venv/bin/python _onnx_validate.py
"""
import sys
sys.path.insert(0, "/opt/blackbird/actpred")
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
import onnxruntime as ort
from config import parse_checkpoint_config
from model import ActionPredictor
CKPT = "checkpoints/pretrain_best_inference.pt"
ONNX = "checkpoints/actpred_deploy.onnx"
IMG_DIR = "data/episode_0000/images"
ck = torch.load(CKPT, map_location="cpu", weights_only=True)
cfg = parse_checkpoint_config(ck["config"])
model = ActionPredictor(cfg.model, cfg.data, pretrained=False)
model.load_state_dict(ck["model_state_dict"])
model.eval()
sess = ort.InferenceSession(ONNX)
in_names = [i.name for i in sess.get_inputs()]
print("ONNX inputs:", in_names)
h, w = cfg.data.image_height, cfg.data.image_width
tf = transforms.Compose([
transforms.Resize((h, w)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
imgs_real = [np.array(Image.open(f"{IMG_DIR}/000{i}.jpg").convert("RGB"))
for i in range(3)]
imgs_zero = [np.zeros_like(imgs_real[0]) for _ in range(3)]
hist_real = np.array([
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 10, 200, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 20, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
[45, 0, 45, 0, 45, 0, 45, 0, 90, 90, 0, 0, 0, 0, 0, 0, 0, 0],
], dtype=np.float32)
hist_zero = np.zeros((5, 18), dtype=np.float32)
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,128)
pred_none = np.zeros((1, 1, 1, 128), dtype=np.float32)
def frames_nhwc(imgs):
"""列表帧 → (1,H,W,3) 归一化 float32 NHWC × frame_len"""
chw = np.stack([tf(Image.fromarray(im)).numpy() for im in imgs])
return [chw[i].transpose(1, 2, 0)[None] for i in range(len(imgs))]
def run_onnx(imgs, hist, pred):
feeds = {f"frame{i}": f for i, f in enumerate(frames_nhwc(imgs))}
feeds["action_history"] = hist[None, None]
feeds["pred_token"] = pred
return sess.run(None, feeds)[0].reshape(-1)
def run_torch(imgs, hist):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
hh = torch.tensor(hist).unsqueeze(0)
with torch.no_grad():
return model(x, hh).squeeze(0).numpy()
a = run_onnx(imgs_real, hist_real, pred_learned)
b = run_onnx(imgs_zero, hist_real, pred_learned)
c = run_onnx(imgs_real, hist_zero, pred_learned)
d = run_onnx(imgs_zero, hist_zero, pred_learned)
p0 = run_onnx(imgs_real, hist_real, pred_none)
print(f"onnx real_img+real_hist: {np.round(a[:6], 3)}...")
print(f"onnx pred_token=0 diff : max={np.abs(a - p0).max():.4f}")
print(f"img sensitivity (a-b): max={np.abs(a - b).max():.4f}")
print(f"hist sensitivity (a-c): max={np.abs(a - c).max():.4f}")
ref = run_torch(imgs_real, hist_real)
print(f"torch reference : {np.round(ref[:6], 3)}...")
print(f"onnx vs torch max_diff : {np.abs(a - ref).max():.6e}")
ok = (np.abs(a - b).max() > 1e-4 and np.abs(a - c).max() > 1e-4
and np.abs(a - ref).max() < 1e-4)
print("PASS" if ok else "FAIL")
+40
View File
@@ -57,6 +57,44 @@ def har_input_layer_names(har_path: str) -> list:
return [name for name, _ in layers] return [name for name, _ in layers]
def check_input_connectivity(har_path: str, input_layers: list):
"""检查 HAR 中输入层是否都有下游边
输入层被优化断开时编译照常成功,但 HEF 输出恒定值且无任何报错
(2026-08-13 实测踩坑),必须在编译前拦截。.edges 文件缺失或格式
无法识别时仅告警不阻断。
"""
with tempfile.TemporaryDirectory() as tmp:
with tarfile.open(har_path) as tf:
tf.extractall(tmp)
edge_files = [f for f in os.listdir(tmp) if f.endswith(".edges")]
if not edge_files:
print(f"WARNING: {har_path} 无 .edges 文件,跳过输入连通性检查")
return
with open(os.path.join(tmp, edge_files[0])) as f:
edges = json.load(f)
if not isinstance(edges, list):
print(f"WARNING: .edges 不是列表({type(edges).__name__}),"
f"跳过连通性检查")
return
adjacency = set()
for e in edges:
if isinstance(e, dict) and "from" in e:
adjacency.add(e["from"])
elif isinstance(e, (list, tuple)) and len(e) == 2:
adjacency.add(e[0])
else:
print(f"WARNING: 无法识别 .edges 条目格式: {e!r},跳过连通性检查")
return
disconnected = [n for n in input_layers if n not in adjacency]
if disconnected:
sys.exit(
f"错误: {har_path} 中输入层无下游边(输入被优化断开,"
f"编译出的 HEF 将输出恒定值): {disconnected}")
print(f"输入连通性检查通过: {len(input_layers)} 个输入层均有下游边")
def main(): def main():
parser = argparse.ArgumentParser(description="Compile actpred deploy ONNX to HEF") parser = argparse.ArgumentParser(description="Compile actpred deploy ONNX to HEF")
parser.add_argument("--onnx", default="checkpoints/actpred_deploy.onnx") parser.add_argument("--onnx", default="checkpoints/actpred_deploy.onnx")
@@ -93,11 +131,13 @@ def main():
names = har_input_layer_names(har_raw) names = har_input_layer_names(har_raw)
if len(names) != len(START_NODES): if len(names) != len(START_NODES):
sys.exit(f"错误: HAR 输入层数 {len(names)} != {len(START_NODES)},解析可能被截断") sys.exit(f"错误: HAR 输入层数 {len(names)} != {len(START_NODES)},解析可能被截断")
check_input_connectivity(har_raw, names)
print("[2/3] optimize: 量化校准") print("[2/3] optimize: 量化校准")
hailo("optimize", har_raw, "--hw-arch", args.hw_arch, hailo("optimize", har_raw, "--hw-arch", args.hw_arch,
"--calib-set-path", os.path.abspath(args.calib_npz), "--calib-set-path", os.path.abspath(args.calib_npz),
"--output-har-path", har_opt, venv_bin=venv_bin) "--output-har-path", har_opt, venv_bin=venv_bin)
check_input_connectivity(har_opt, har_input_layer_names(har_opt))
print("[3/3] compiler: HAR → HEF") print("[3/3] compiler: HAR → HEF")
hailo("compiler", har_opt, "--hw-arch", args.hw_arch, hailo("compiler", har_opt, "--hw-arch", args.hw_arch,
+8
View File
@@ -128,6 +128,9 @@ def main():
parser = argparse.ArgumentParser(description="Export Hailo-friendly ONNX") parser = argparse.ArgumentParser(description="Export Hailo-friendly ONNX")
parser.add_argument("--model", required=True, help="checkpoint path") parser.add_argument("--model", required=True, help="checkpoint path")
parser.add_argument("--output", default="checkpoints/actpred_deploy.onnx") 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() args = parser.parse_args()
checkpoint = torch.load(args.model, map_location="cpu", weights_only=True) checkpoint = torch.load(args.model, map_location="cpu", weights_only=True)
@@ -156,6 +159,11 @@ def main():
) )
print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token)") print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token)")
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) stacked = torch.stack([fr.permute(0, 3, 1, 2) for fr in dummy_frames], dim=1)
with torch.no_grad(): with torch.no_grad():
orig_out = model(stacked, dummy_history.squeeze(1)) orig_out = model(stacked, dummy_history.squeeze(1))
+17 -5
View File
@@ -6,7 +6,8 @@
--deploy 模式(data/calibration_deploy.npz):为 export_deploy.py 导出 --deploy 模式(data/calibration_deploy.npz):为 export_deploy.py 导出
的部署版 ONNX 生成校准集,键名匹配 HN 层名 actpred_deploy/input_layerN, 的部署版 ONNX 生成校准集,键名匹配 HN 层名 actpred_deploy/input_layerN,
形状匹配 HEF 输入:frame0~2 (N,H,W,3) 已归一化 float32、 形状匹配 HEF 输入:frame0~2 (N,H,W,3) 已归一化 float32、
action_history (N,1,H_len,D)、pred_token (N,1,1,d) 常量。 action_history (N,1,H_len,D)、pred_token (N,1,1,d) 学习值 ± 抖动
(常量校准会使量化范围退化,见 --pred-jitter)。
图像分辨率与 --model checkpoint 的配置一致。 图像分辨率与 --model checkpoint 的配置一致。
数据源: 数据源:
@@ -81,9 +82,15 @@ def build_deploy_set(args):
cfg, pretrained=False) cfg, pretrained=False)
model.load_state_dict(ckpt["model_state_dict"]) model.load_state_dict(ckpt["model_state_dict"])
d = model.pred_token.shape[-1] d = model.pred_token.shape[-1]
pred = np.broadcast_to( v = model.pred_token.data.unsqueeze(1).numpy() # (1, 1, d) 学习到的常量
model.pred_token.data.unsqueeze(1).numpy(), # 常量校准会使量化范围退化(上次实测 scale=18571.7,任何输入都被
(args.samples, 1, 1, d)).astype(np.float32).copy() # 量化成 0);按学习值 ±相对抖动生成校准样本,运行时喂学习值即可
# 无损还原(量化误差 ~1e-4)
jitter = v * rng.uniform(-args.pred_jitter, args.pred_jitter,
size=(args.samples, 1, 1, d)).astype(np.float32)
jitter = jitter + rng.normal(0.0, args.pred_jitter_abs,
size=jitter.shape).astype(np.float32)
pred = np.broadcast_to(v, (args.samples, 1, 1, d)).astype(np.float32) + jitter
np.savez_compressed( np.savez_compressed(
args.output, args.output,
@@ -91,7 +98,8 @@ def build_deploy_set(args):
for i, arr in enumerate([*frames, history, pred])}) for i, arr in enumerate([*frames, history, pred])})
print(f"部署校准集已保存: {args.output}") print(f"部署校准集已保存: {args.output}")
print(f" frame0~2: {frames[0].shape}(分辨率 {w}x{h},与 checkpoint 一致)") print(f" frame0~2: {frames[0].shape}(分辨率 {w}x{h},与 checkpoint 一致)")
print(f" action_history: {history.shape} pred_token: {pred.shape}") print(f" action_history: {history.shape} pred_token: {pred.shape}"
f"(学习值 ±{args.pred_jitter:.0%} 抖动)")
def main(): def main():
@@ -104,6 +112,10 @@ def main():
parser.add_argument("--output", default=None) parser.add_argument("--output", default=None)
parser.add_argument("--samples", type=int, default=32) parser.add_argument("--samples", type=int, default=32)
parser.add_argument("--seed", type=int, default=42) parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--pred-jitter", type=float, default=0.3,
help="pred_token 校准相对抖动幅度(默认 ±30%)")
parser.add_argument("--pred-jitter-abs", type=float, default=0.005,
help="pred_token 校准绝对抖动标准差(近零元素保底)")
args = parser.parse_args() args = parser.parse_args()
if args.deploy: if args.deploy:
+136 -40
View File
@@ -3,30 +3,40 @@
接口对齐 predict.ActionPredictorInference:predict(images, action_history)。 接口对齐 predict.ActionPredictorInference:predict(images, action_history)。
HEF 由 actpred ONNX 经 Hailo DFC 编译生成(量化校准后)。本封装不硬编码 HEF 由 actpred ONNX 经 Hailo DFC 编译生成(量化校准后)。本封装不硬编码
vstream 名称/形状/量化参数,全部从 HEF 的 input/output vstream info 动态推导: vstream 名称/形状/量化参数,全部从 HEF 的 input/output vstream info 动态推导,
支持两种布局:
图像输入: (1, F, 3, H, W) → NHWC uint8 (1, F, H, W, 3) 新版 5 输入(export_deploy.py + dfc_compile.py 产物):
或 (1, H, W, F*3) → 帧沿通道拼接 frame0~2 各 (H,W,3) NHWC uint8(ImageNet 归一化校准,负零偏自动识别)
动作输入: (1, H, D) float32,uint8 边缘时按 quant_info 量化 + action_history (1,H_len,D) + pred_token (1,1,d)
动作输出: (1, D),uint8 边缘时按 quant_info 反量化 旧版单图像输入:
(1, F, 3, H, W) → NHWC uint8 (1, F, H, W, 3)
或 (1, H, W, F*3) → 帧沿通道拼接;action_history (1, H, D)
uint8 边缘按 quant_info 量化/反量化;pred_token 输入默认喂零,
新编译 HEF(抖动校准,见 gen_calibration.py --pred-jitter)应喂
checkpoint 的学习值(--pred-token npy 或 predict(pred_token=...)),
否则该 token 语义与训练时不一致。
背景与踩坑记录见 背景与踩坑记录见
docs/reports/actpred_hailo_hat2_inference_research_20260813.md: docs/reports/actpred_hailo_hat2_inference_research_20260813.md:
- hailort 5.x 对 Hailo-10 仅支持新 API(create_infer_model), - hailort 5.x 对 Hailo-10 仅支持新 API(create_infer_model),
旧版 VDevice.configure(hef) 报 HAILO_NOT_IMPLEMENTED; 旧版 VDevice.configure(hef) 报 HAILO_NOT_IMPLEMENTED;
- 绑定缓冲区 dtype/大小必须匹配 HEF 边缘格式(量化 uint8); - 绑定缓冲区 dtype/大小必须匹配 HEF 边缘格式(量化 uint8)。
- 图像输入按 Hailo 惯例为 uint8 原始像素(ImageNet 归一化由
DFC 编译时折叠进网络)。
""" """
import argparse import argparse
import contextlib import contextlib
import json import json
import os import os
import re
import numpy as np import numpy as np
from PIL import Image from PIL import Image
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)
# 与 predict.parse_action_history 相同逻辑;此处内联以保持 # 与 predict.parse_action_history 相同逻辑;此处内联以保持
# 本模块零 torch 依赖(HAT 部署环境只装 hailo_platform + numpy + PIL) # 本模块零 torch 依赖(HAT 部署环境只装 hailo_platform + numpy + PIL)
@@ -43,9 +53,17 @@ def parse_action_history(s: str, action_dim: int) -> np.ndarray:
return np.array(history, dtype=np.float32) return np.array(history, dtype=np.float32)
def _input_layer_index(name: str) -> int:
m = re.search(r"input_layer(\d+)$", name)
return int(m.group(1)) if m else 0
class HailoPredictor: class HailoPredictor:
"""HEF 动作预测推理器(Hailo NPU 后端) """HEF 动作预测推理器(Hailo NPU 后端)
支持新版 5 输入 HEF(逐帧输入 + pred_token)与旧版单图像输入 HEF,
布局与量化参数从 vstream info 自动推导。
用法: 用法:
with HailoPredictor("actpred.hef") as predictor: with HailoPredictor("actpred.hef") as predictor:
action = predictor.predict(images, action_history) action = predictor.predict(images, action_history)
@@ -68,9 +86,9 @@ class HailoPredictor:
if len(self._out_infos) != 1: if len(self._out_infos) != 1:
raise ValueError( raise ValueError(
f"HEF 应只有一个输出 vstream,实际 {len(self._out_infos)}") f"HEF 应只有一个输出 vstream,实际 {len(self._out_infos)}")
self._image_info = self._find_image_input()
self._history_info = self._find_history_input()
self._out_info = self._out_infos[0] self._out_info = self._out_infos[0]
self._frame_infos, self._history_info, self._pred_info = \
self._classify_inputs()
self._derive_dims() self._derive_dims()
self._action_mean = None self._action_mean = None
@@ -89,27 +107,49 @@ class HailoPredictor:
self._stack.close() # 初始化失败也释放设备,避免 fd 泄漏 self._stack.close() # 初始化失败也释放设备,避免 fd 泄漏
raise raise
# ---------------- 形状推导 ---------------- # ---------------- 输入分类与形状推导 ----------------
def _find_image_input(self): def _classify_inputs(self):
cands = [i for i in self._in_infos if len(i.shape) in (4, 5)] """将 HEF 输入分为 帧列表 / 动作历史 / pred_token(可能为 None)
if len(cands) != 1:
raise ValueError(
"HEF 输入中无法唯一识别图像 vstream(期望一个 4/5 维输入),"
f"实际: {[(i.name, tuple(i.shape)) for i in self._in_infos]}")
return cands[0]
def _find_history_input(self): 多帧输入的绑定顺序按 input_layerN 数字后缀排序,而非 vstream
cands = [i for i in self._in_infos 报告顺序:hailort 5.3.0 实测报告顺序被打乱(1,5,4,3,2),
if len(i.shape) == 3 and i is not self._image_info] 按报告顺序绑定会导致 frame1/frame2 错位。
if len(cands) != 1: """
ins = list(self._in_infos)
out_dim = tuple(self._out_info.shape)[-1]
# 新版:每帧独立 (H,W,3) 输入
frames = [i for i in ins if len(i.shape) == 3
and i.shape[2] == 3 and i.shape[0] > 3]
if frames:
frames.sort(key=lambda i: _input_layer_index(i.name))
else:
# 旧版:单一 4/5 维图像输入
frames = [i for i in ins if len(i.shape) in (4, 5)]
rest = [i for i in ins if i not in frames]
history, pred = None, None
for i in rest:
s = tuple(i.shape)
if len(s) == 3 and s[-1] == out_dim:
history = i
elif len(s) == 3 and s[1] == 1:
pred = i
else:
raise ValueError( raise ValueError(
"HEF 输入中无法唯一识别动作历史 vstream(期望一个 3 维输入)," f"无法识别 HEF 输入 {i.name} 形状 {s}(期望动作历史 "
f"实际: {[(i.name, tuple(i.shape)) for i in self._in_infos]}") f"(1,H_len,D) 或 pred_token (1,1,d))")
return cands[0] if not frames:
raise ValueError("HEF 输入中未找到图像输入")
if history is None:
raise ValueError(
f"HEF 输入中未找到动作历史输入(输出维度 {out_dim}),"
f"实际: {[(i.name, tuple(i.shape)) for i in ins]}")
return frames, history, pred
def _derive_dims(self): def _derive_dims(self):
shape = tuple(self._image_info.shape) # 帧输入布局
if len(self._frame_infos) == 1 and len(self._frame_infos[0].shape) in (4, 5):
shape = tuple(self._frame_infos[0].shape)
if len(shape) == 5: if len(shape) == 5:
_, self.frame_len, c, self.image_height, self.image_width = shape _, self.frame_len, c, self.image_height, self.image_width = shape
if c != 3: if c != 3:
@@ -123,6 +163,21 @@ class HailoPredictor:
f"实际形状 {shape}") f"实际形状 {shape}")
self.frame_len = c // 3 self.frame_len = c // 3
self._image_layout = "nhwc_fused" self._image_layout = "nhwc_fused"
else:
# 新版:每帧独立 (H,W,3) 输入,形状须一致
shapes = [tuple(i.shape) for i in self._frame_infos]
if any(len(s) != 3 or s[2] != 3
or s[0] != shapes[0][0] or s[1] != shapes[0][1]
for s in shapes):
raise ValueError(f"帧输入形状不一致: {shapes}")
self.image_height, self.image_width, _ = shapes[0]
self.frame_len = len(self._frame_infos)
self._image_layout = "nhwc_frames"
self._frame_quant = self._frame_infos[0].quant_info
# 帧量化零偏为负 ⇒ 校准数据是 ImageNet 归一化后的浮点,
# 推理端需先归一化再量化;零偏为 0 则为原始像素直通
self._normalize_frames = \
self._frame_quant.qp_zp * self._frame_quant.qp_scale < 0
h_shape = tuple(self._history_info.shape) h_shape = tuple(self._history_info.shape)
self.history_len, self.action_dim = h_shape[1], h_shape[2] self.history_len, self.action_dim = h_shape[1], h_shape[2]
@@ -147,10 +202,12 @@ class HailoPredictor:
# ---------------- 推理 ---------------- # ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray) -> np.ndarray: def predict(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧) """images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
返回 (action_dim,) float32 动作向量。 返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
""" """
if not isinstance(images, (list, tuple)): if not isinstance(images, (list, tuple)):
images = [images] images = [images]
@@ -170,9 +227,8 @@ class HailoPredictor:
from hailo_platform import FormatType from hailo_platform import FormatType
bindings = self._configured.create_bindings() bindings = self._configured.create_bindings()
frames = np.stack([self._preprocess(img) for img in images]) frame_float = np.stack([self._frame_float(img) for img in images])
bindings.input(self._image_info.name).set_buffer( self._bind_frames(bindings, frame_float)
self._image_buffer(frames))
h_shape = tuple(self._history_info.shape) h_shape = tuple(self._history_info.shape)
if self._history_info.format.type == FormatType.UINT8: if self._history_info.format.type == FormatType.UINT8:
@@ -183,6 +239,18 @@ class HailoPredictor:
buf = action_history.astype(np.float32).reshape(h_shape) buf = action_history.astype(np.float32).reshape(h_shape)
bindings.input(self._history_info.name).set_buffer(buf) bindings.input(self._history_info.name).set_buffer(buf)
if self._pred_info is not None:
p_shape = tuple(self._pred_info.shape)
if pred_token is None:
values = np.zeros(p_shape, dtype=np.float32)
else:
values = np.asarray(pred_token, dtype=np.float32).reshape(p_shape)
if self._pred_info.format.type == FormatType.UINT8:
q = self._pred_info.quant_info
values = np.clip(np.round(values / q.qp_scale + q.qp_zp),
0, 255).astype(np.uint8)
bindings.input(self._pred_info.name).set_buffer(values)
out_shape = tuple(self._out_info.shape) out_shape = tuple(self._out_info.shape)
if self._out_info.format.type == FormatType.UINT8: if self._out_info.format.type == FormatType.UINT8:
out_buf = np.zeros(out_shape, dtype=np.uint8) out_buf = np.zeros(out_shape, dtype=np.uint8)
@@ -203,19 +271,43 @@ class HailoPredictor:
result = result * self._action_std + self._action_mean result = result * self._action_std + self._action_mean
return result return result
def _preprocess(self, image) -> np.ndarray: def _frame_float(self, image) -> np.ndarray:
"""Resize → float32 HWC;归一化 HEF(负零偏)再走 ImageNet 归一化"""
img = Image.fromarray(image) if isinstance(image, np.ndarray) else image img = Image.fromarray(image) if isinstance(image, np.ndarray) else image
img = img.convert("RGB").resize( img = img.convert("RGB").resize(
(self.image_width, self.image_height), Image.BILINEAR) (self.image_width, self.image_height), Image.BILINEAR)
return np.asarray(img, dtype=np.uint8) # HWC uint8 x = np.asarray(img, dtype=np.float32) # HWC 0..255
if self._normalize_frames:
x = (x / 255.0 - IMAGENET_MEAN) / IMAGENET_STD
return x
def _bind_frames(self, bindings, frame_float: np.ndarray):
"""(F,H,W,3) float 帧 → HEF 帧输入缓冲区(按布局与量化)"""
from hailo_platform import FormatType
def quantize(buf, q):
return np.clip(np.round(buf / q.qp_scale + q.qp_zp),
0, 255).astype(np.uint8)
def _image_buffer(self, frames: np.ndarray) -> np.ndarray:
"""(F, H, W, 3) → HEF 图像输入缓冲区(按编译后布局)"""
if self._image_layout == "ncfhw": if self._image_layout == "ncfhw":
return frames.transpose(0, 3, 1, 2)[np.newaxis] # (1,F,3,H,W) buf = frame_float.transpose(0, 3, 1, 2)[np.newaxis] # (1,F,3,H,W)
# nhwc_fused: 帧沿通道拼接 (1, H, W, F*3) info = self._frame_infos[0]
return frames.reshape(1, self.image_height, self.image_width, if info.format.type == FormatType.UINT8:
self.frame_len * 3) buf = quantize(buf, info.quant_info)
bindings.input(info.name).set_buffer(buf)
elif self._image_layout == "nhwc_fused":
buf = frame_float.reshape(
1, self.image_height, self.image_width, self.frame_len * 3)
info = self._frame_infos[0]
if info.format.type == FormatType.UINT8:
buf = quantize(buf, info.quant_info)
bindings.input(info.name).set_buffer(buf)
else: # nhwc_frames:每帧独立输入
for i, info in enumerate(self._frame_infos):
buf = frame_float[i]
if info.format.type == FormatType.UINT8:
buf = quantize(buf, info.quant_info)
bindings.input(info.name).set_buffer(buf)
def main(): def main():
@@ -227,10 +319,14 @@ def main():
help="冒号分隔各步,每步 action_dim 个值") help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None, parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)") help="action_stats.npy 路径(训练时启用归一化才需要)")
parser.add_argument("--pred-token", default=None,
help="pred_token .npy 路径(export_deploy.py "
"--pred-token-out 产物;缺省喂零)")
parser.add_argument("--output-format", choices=["json", "raw"], parser.add_argument("--output-format", choices=["json", "raw"],
default="json") default="json")
args = parser.parse_args() args = parser.parse_args()
pred_token = np.load(args.pred_token) if args.pred_token else None
with HailoPredictor(args.hef, stats_path=args.stats) as predictor: with HailoPredictor(args.hef, stats_path=args.stats) as predictor:
if len(args.images) != predictor.frame_len: if len(args.images) != predictor.frame_len:
raise SystemExit( raise SystemExit(
@@ -238,7 +334,7 @@ def main():
f"实际为 {len(args.images)} 个") f"实际为 {len(args.images)} 个")
images = [np.array(Image.open(p).convert("RGB")) for p in args.images] images = [np.array(Image.open(p).convert("RGB")) for p in args.images]
history = parse_action_history(args.action_history, predictor.action_dim) history = parse_action_history(args.action_history, predictor.action_dim)
predicted = predictor.predict(images, history) predicted = predictor.predict(images, history, pred_token=pred_token)
if args.output_format == "json": if args.output_format == "json":
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2)) print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
+82
View File
@@ -1093,6 +1093,88 @@ class TestHailoPredictor(unittest.TestCase):
np.testing.assert_allclose(result, [0, 0, 0]) np.testing.assert_allclose(result, [0, 0, 0])
self.assertEqual(seen_shapes["image"], (1, 4, 4, 6)) self.assertEqual(seen_shapes["image"], (1, 4, 4, 6))
def test_multiframe_inputs_layout(self):
"""新版 5 输入 HEF:frame×3 (H,W,3) + history + pred_token 常量"""
from hailo_infer import HailoPredictor
q_frame = _FakeQuantInfo(qp_zp=114.0, qp_scale=0.018723)
q_pred = _FakeQuantInfo(qp_zp=123.0, qp_scale=18571.0)
infos_in = [
_FakeVStreamInfo("net/input_layer1", (64, 64, 3),
_FakeFormatType.UINT8, q_frame),
_FakeVStreamInfo("net/input_layer2", (64, 64, 3),
_FakeFormatType.UINT8, q_frame),
_FakeVStreamInfo("net/input_layer3", (64, 64, 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, q_pred),
]
infos_out = [_FakeVStreamInfo(
"net/conv49", (1, 1, 18), _FakeFormatType.UINT8,
_FakeQuantInfo(qp_zp=2.0, qp_scale=0.837115))]
seen = {}
def transform(b):
for name in ("net/input_layer1", "net/input_layer2", "net/input_layer3"):
seen[name] = np.asarray(b.input(name).get_buffer())
seen["history"] = np.asarray(b.input("net/input_layer4").get_buffer())
seen["pred"] = np.asarray(b.input("net/input_layer5").get_buffer())
b.output("net/conv49").get_buffer()[...] = 2 # 反量化后为 0
_install_fake_hailo(self, infos_in, infos_out, transform)
with HailoPredictor("fake.hef") as p:
self.assertEqual((p.frame_len, p.history_len, p.action_dim), (3, 5, 18))
self.assertEqual((p.image_width, p.image_height), (64, 64))
result = p.predict(self._frames(3, size=64), np.zeros((5, 18), np.float32))
self.assertEqual(seen["net/input_layer1"].dtype, np.uint8)
self.assertEqual(seen["net/input_layer1"].shape, (64, 64, 3))
self.assertEqual(seen["history"].shape, (1, 5, 18))
np.testing.assert_array_equal(seen["history"], 0)
np.testing.assert_array_equal(seen["pred"], 123) # 退化量化:喂 0 → q=123
np.testing.assert_allclose(result, 0)
def test_multiframe_scrambled_report_order(self):
"""HEF vstream 报告顺序可能被打乱(实测 1,5,4,3,2),
帧输入必须按 input_layerN 排序绑定,否则 frame1/frame2 错位"""
from hailo_infer import HailoPredictor
infos_in = [
_FakeVStreamInfo("net/input_layer1", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer3", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer2", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer4", (1, 2, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer5", (1, 1, 4),
_FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/conv49", (1, 1, 3),
_FakeFormatType.FLOAT32)]
seen = {}
def transform(b):
for name in ("net/input_layer1", "net/input_layer2",
"net/input_layer3"):
seen[name] = np.asarray(b.input(name).get_buffer()).copy()
_first_row_transform("net/input_layer4", "net/conv49")(b)
_install_fake_hailo(self, infos_in, infos_out, transform)
frames = self._frames(3)
frames[0][:] = 10
frames[1][:] = 20
frames[2][:] = 30
with HailoPredictor("fake.hef") as p:
p.predict(frames, np.zeros((2, 3), dtype=np.float32))
np.testing.assert_array_equal(
seen["net/input_layer1"], np.full((8, 8, 3), 10, np.float32))
np.testing.assert_array_equal(
seen["net/input_layer2"], np.full((8, 8, 3), 20, np.float32))
np.testing.assert_array_equal(
seen["net/input_layer3"], np.full((8, 8, 3), 30, np.float32))
def test_frame_count_mismatch_raises(self): def test_frame_count_mismatch_raises(self):
from hailo_infer import HailoPredictor from hailo_infer import HailoPredictor
infos_in = [ infos_in = [
@@ -1,212 +0,0 @@
# actpred 多帧注意力架构改造测试报告
| 项目 | 值 |
|------|-----|
| 日期 | 2026-08-09 |
| 模块 | actpred |
| 测试人 | qing |
| 环境 | Intel Xeon E3-1200 v2 (Ivy Bridge), 4 核, Linux 24.04, Python 3.x, PyTorch (CPU-only) |
| 关联文件 | `config.py`, `model.py`, `dataset.py`, `train.py`, `pretrain_mt.py`, `predict.py`, `export_onnx.py`, `test_actpred.py` |
| 前置报告 | [actpred_pretrain_test_20260808.md](actpred_pretrain_test_20260808.md) |
---
## 一、改造背景与目标
原架构为"单帧 CNN + 历史动作 MLP/GRU + concat 融合",仅支持单帧图像输入。
本次改造目标:
1. 引入注意力机制,支持**多帧图像 + 历史动作序列**联合建模,预测下一时间步动作
2. 保持 checkpoint 格式(`model_state_dict` + `config`)、CLI 工作流与 ONNX 导出链路不变
3. 保持除 batch 维外全静态 shape,面向树莓派 AI 扩展板(NPU)部署友好
4. 直接替换旧架构(用户决策):删除 MLP/GRU 编码器与 concat 路径,旧 checkpoint 作废
## 二、新架构
```
F 帧图像 (F×3×64×64) ─► 共享 CNN 编码器 ─► F 个 token ─► proj → d_model ┐
├─► [img×F, act×H, PRED] + 位置编码
动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ──────────┤ │
可学习 [PRED] token ─────────────────────────────────────────────────┘ ▼
TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128)
│
取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim)
```
| 设计点 | 取值/说明 |
|--------|----------|
| token 序列 | `[img×F, act×H, PRED]`,长度固定 F+H+1(默认 3+5+1=9) |
| 帧序约定 | 旧→新,末帧为当前帧;冷启动可重复传同一帧 |
| 注意力 | `nn.TransformerEncoder`,pre-LN(norm_first),dim_feedforward=2×d_model |
| 位置编码 | 可学习 `pos_embed (1, F+H+1, d)`,trunc_normal 初始化 |
| 输出 | 取末位 PRED token 过 MLP 头(d → 128 → 64 → action_dim) |
| 静态性 | forward 中 reshape 仅 batch 维动态,其余编译期固定 |
| 参数量 | 409,042(旧架构 142,034,约 2.9 倍) |
### 新增配置参数
| 参数 | 默认值 | 校验范围 |
|------|--------|---------|
| `--frame-len` | 3 | [1, 1024] |
| `--attn-dim` | 128 | [1, 4096],且须能被 attn-heads 整除 |
| `--attn-heads` | 4 | [1, 64] |
| `--attn-layers` | 2 | [1, 64] |
删除参数:`--history-encoder`、`--action-hidden-dim`。
旧 checkpoint 因 config 含未知键(`history_encoder`/`action_hidden_dim`)在加载时被
`parse_checkpoint_config` 以明确 ValueError 拒载,符合"作废"预期(有专项测试覆盖)。
## 三、单元测试
```
$ python test_actpred.py
Ran 56 tests in 5.959s
OK
```
**56 项测试全部通过**(安装 onnxscript/onnxruntime 后无跳过项)。本次新增/改写用例:
| 用例 | 验证点 |
|------|--------|
| `test_forward_multi_frame` | (2,3,3,64,64) + (2,5,18) → (2,18) |
| `test_forward_single_frame_degenerate` | frame_len=1 退化单帧仍正常前向 |
| `test_forward_dynamic_batch` | batch 1/4/8 动态(frame_len=2) |
| `test_custom_attn_config` | attn_dim=64/heads=2/layers=1 可构建前向 |
| `test_attn_dim_not_divisible_by_heads_raises` | 130 % 4 ≠ 0 → ValueError |
| `test_dataset_loading` | 多帧样本 shape (3,3,32,32),batch key 改为 `images` |
| `test_frame_len_gt_history_shifts_start` | 采样起点 max(history_len, frame_len-1) 正确 |
| `test_legacy_checkpoint_config_rejected` | 旧架构 checkpoint 拒载 |
| `test_wrong_frame_count_raises` | 推理帧数不符 → ValueError |
| `test_end_to_end_predict` 等推理用例 | 多帧输入端到端、归一化等价性、确定性 |
## 四、预训练测试
### 4.1 训练配置
| 参数 | 值 |
|------|-----|
| 数据来源 | `../bot4cj/scripts/*.mt`(13 文件) |
| train/val 划分 | **文件级**(11 训练文件 / 2 验证文件,杜绝滑动窗口泄漏) |
| 输入 | 全零多帧图像 (B, 3, 3, 64, 64) + action_history (B, 5, 18) |
| 优化器/调度 | AdamW (lr=0.001, wd=1e-4) + CosineAnnealingLR |
| 数据增强 | Gaussian noise (std=1.0) |
| batch_size / epochs / patience | 8 / 200 / 30 |
| 随机种子 | 42 |
### 4.2 训练结果
| 指标 | 值 |
|------|-----|
| 最终轮数 | 65(早停,patience=30) |
| 最优 val_loss | **2476.75** |
| 产出 | `checkpoints/pretrain_best.pt`(含 optimizer)、`checkpoints/pretrain_best_inference.pt` |
> **关于 val_loss 数值的说明**:本次 2476.75 与前次报告的 190.58 不可直接对比——
> 前次数值系样本级滑动窗口划分导致的数据泄漏虚低值(已在 0808 审计中定性);
> 本次为文件级划分(验证集是训练中完全未见过的动作脚本),且模型更大(409K vs 142K)
> 而数据仅 13 个脚本,属"诚实的困难任务"数值。注意力架构的真实收益需在真实图像数据
> 微调后评估。
## 五、推理链路验证
### 5.1 CLI 全链路
```
$ bash _run.sh --model checkpoints/pretrain_best_inference.pt \
--images /tmp/f0.jpg /tmp/f1.jpg /tmp/f2.jpg \
--action-history "45,0,...(5 步 × 18 值)"
18.3430 10.1614 17.3654 ... (18 维输出,数值有限)
```
✅ 3 帧 + 5 步历史全链路输出正常。
### 5.2 错误场景
```
$ ... --images /tmp/f0.jpg ...
--images 需传入 3 个路径(旧→新),实际为 1 个。冷启动时可重复传同一帧路径。
```
✅ 帧数不符时报错信息明确,含冷启动指引。
## 六、ONNX 导出验证
```
$ python export_onnx.py --model checkpoints/pretrain_best_inference.pt --output /tmp/actpred_attn.onnx
ONNX model exported to: /tmp/actpred_attn.onnx
ONNX 一致性验证通过(最大差异: 7.629395e-06)
```
| 项目 | 结果 |
|------|------|
| 导出 | ✅ 成功(opset 17,输入 `images (batch,3,3,64,64)` + `action_history (batch,5,18)`) |
| onnxruntime 一致性 | ✅ 最大差异 7.63e-06(< 1e-4 阈值) |
| 动态维 | 仅 batch,NPU 编译友好 |
注:本次为环境补装了 `onnxscript` 与 `onnxruntime`(torch ≥ 2.12 ONNX 导出依赖)。
## 七、CPU 推理性能基准
测试方法:warmup 20 次后测 200 次取平均,输入随机 3 帧 (64,64,3) uint8 + (5,18) float32。
| 环节 | 本次(注意力,3 帧) | 前次(concat,单帧) |
|------|---------------------|---------------------|
| 参数量 | 409,042 | 142,034 |
| 端到端推理(含预处理) | **4.25 ms (235 FPS)** | 1.61 ms (622 FPS) |
| 纯前向 | 2.77 ms (360 FPS) | 1.09 ms (916 FPS) |
| image_encoder 占比 | 1.30 ms(47%,3 帧) | 0.82 ms(75%,1 帧) |
评估:
- 235 FPS 对 bot4cj 30 FPS 控制循环仍有 **7.8 倍余量**,实时性满足
- 延迟增长主因:CNN 由 1 帧变 3 帧(约 3×0.43ms)+ Transformer 编码器(约 1.5ms)
- image_encoder 占比由 75% 降至 47%,注意力部分成为新的次要热点;
部署到树莓派 AI 扩展板后两者均可被 NPU 加速(静态 shape 已保证)
## 八、结论与下一步
### 8.1 测试结论
| 测试项 | 结果 |
|--------|------|
| 多帧注意力模型前向 | ✅ 通过(多帧/单帧退化/动态 batch/自定义 attn 配置) |
| 配置校验 | ✅ 通过(frame_len/attn_* 值域 + 整除校验 + 旧 checkpoint 拒载) |
| 多帧数据集采样 | ✅ 通过(采样起点与样本数断言) |
| 单元测试 | ✅ 56/56 通过 |
| 预训练 | ✅ 通过(65 epoch 早停,文件级划分无泄漏) |
| 推理 CLI | ✅ 通过(正常 + 错误场景) |
| ONNX 导出 | ✅ 通过(一致性 7.63e-06) |
| CPU 性能 | ✅ 通过(235 FPS,7.8 倍实时余量) |
### 8.2 已知局限
1. **预训练图像分支未习得**:全零多帧占位下 image_encoder 与图像 token 的注意力权重
无有效梯度信号,真实收益依赖后续真实图像微调
2. **数据量极少**:13 个 .mt 脚本对 409K 参数模型明显不足,val_loss 绝对值偏高属预期
3. **旧 checkpoint 全部作废**:已删除并重训预训练 checkpoint;如有外部持有旧
`best_inference.pt` 需同步告知重训
### 8.3 下一步建议
1. **采集真实多帧数据**:用 `DataRecorder` 录制 bot4cj 摄像头 episode,以本次预训练
checkpoint 为起点微调(勿开 `--normalize-actions`)
2. **树莓派 AI 扩展板部署验证**:将 ONNX 模型走扩展板厂商工具链编译,实测 NPU 延迟
3. **帧数消融**:真实数据到位后对比 frame_len=1/3/5 的验证误差,确认多帧收益
4. **扩充 .mt 脚本**:增加动作脚本数量,缓解预训练数据不足
---
## 附录:文件变更清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `actpred/config.py` | 修改 | 新增 frame_len/attn_*;删除 history_encoder/action_hidden_dim;整除校验 |
| `actpred/model.py` | 重写 | 多帧 Transformer 注意力架构;删除 MLP/GRU 编码器与 concat 融合 |
| `actpred/dataset.py` | 修改 | 多帧窗口采样;batch key `image` → `images` |
| `actpred/train.py` | 修改 | 多帧适配(frame_len 透传、batch key) |
| `actpred/pretrain_mt.py` | 修改 | 全零多帧占位;打印信息更新 |
| `actpred/predict.py` | 修改 | `predict(images, history)` 多帧 API;CLI `--image` → `--images` |
| `actpred/export_onnx.py` | 修改 | 多帧 dummy 输入;input_names 更新 |
| `actpred/test_actpred.py` | 修改 | 56 项用例(新增多帧/校验/拒载/帧数错误等) |
| `actpred/README.md` | 修改 | 架构图、CLI 示例、配置表、API 文档同步 |
| `actpred/checkpoints/pretrain_best*.pt` | 重新生成 | 旧架构 checkpoint 已删除并重训 |
@@ -1,124 +0,0 @@
# 研究报告:actpred 与 bot4cj 项目
> 研究时间:2026-08-09
> 仓库:`git.cancerai.top/qing/blackbird`(micro_servers 单体仓库)
> 结论先行:**两个项目是同一套「具身智能 / 机器人」技术栈的两个互补半环**——
> `actpred` 是「大脑」(学出来的动作预测器),`bot4cj` 是「身体」(把动作落到真实硬件的执行引擎)。
---
## 1. 一句话定位
| 项目 | 语言/框架 | 角色 | 输入 → 输出 |
|------|-----------|------|-------------|
| **actpred** | Python / PyTorch | 动作预测「大脑」 | 摄像头图像 + 历史动作序列 → 下一步连续动作(回归) |
| **bot4cj** | 仓颉 Cangjie + C/C++/Lua | 硬件执行「身体」 | 矩阵式脚本(.mt) → 驱动舵机/IMU/扬声器 |
---
## 2. actpred 详解
### 2.1 它是什么
纯神经网络实现的**动作预测模块**:给定一帧摄像头图像(3×64×64)和前 `history_len` 步动作,回归预测当前步的连续动作向量。默认参数量约 140K,最大可到 ~2.3M(mobilenet + gru)。
### 2.2 架构
```
图像(3×64×64) ──► CNN编码器(simple/mobilenet) ──► img_feat(128)
│ concat
动作历史(H×D) ──► MLP/GRU编码器 ──► act_feat(64) ──► 融合头 ──► 动作(action_dim)
```
- **图像编码器**:`SimpleCNN`(4 层卷积+自适应池化)或 `MobileNetEncoder`(ImageNet 预训练,带离线权重容错)。
- **历史编码器**:`MLPHistoryEncoder`(默认)或 `GRUHistoryEncoder`。
- **损失**:纯 MSE 回归;优化器 AdamW + CosineAnnealingLR;早停 + 梯度裁剪。
### 2.3 工程质量(亮点)
- 全部超参通过 `config.py` 的 dataclass + CLI 覆盖,序列化进 checkpoint,推理时自动还原。
- 数据层 `dataset.py` 设计扎实:自然排序键、按 episode 切分训练/验证、动作归一化统计量**只用训练集计算**(避免 val loss 失真)、`DataRecorder` 采集器。
- `export_onnx.py` 导出 ONNX 并**自动用 onnxruntime 验证一致性**(最大差异打印)。
- `test_actpred.py` 单元测试覆盖:排序、解析、前向、动态 batch、录制器、数据集、配置——质量明显高于一般研究脚本。
### 2.4 默认配置
`image_size=64, action_dim=8, history_len=5, backbone=simple, history_encoder=mlp, epochs=100, lr=1e-3`。
---
## 3. bot4cj 详解
### 3.1 它是什么
基于**仓颉(Cangjie)**编程语言 + Linux I2C 接口的**机器人硬件脚本解析器**。用一种「行列式表格」脚本(`.mt`)描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。目标平台是树莓派(aarch64),适配 Dashun 蜘蛛机器人(10 舵机 + 2 云台 + 扬声器)。
### 3.2 架构分层
```
.mt 矩阵脚本
↓ MatrixParser (P.cj) —— FSM 逐字符解析 → MatrixObject 链表
↓ RobotStruct (r.cj) —— createHardVars() 构建硬件变量(工厂模式)
↓ 硬件实现 hd/*.cj:PCA9685 / Servo / MPU6050 / Speaker / Lua扩展
↓ 系统库 cpp/ + cuda/:I2C / 矩阵 / FFT / CNN / 音频
↓ 数学/NN 库 math/*.cj:Matrix1 / Tensor / Af / Nn1 / Adam / Kalman
```
### 3.3 关键设计
- **MatrixVars_Hardware 基类**:用三个闭包回调统一硬件接口——`build()`(rise,拿资源)、`play()`(things,执行,输入输出均为 JsonValue)、`free()`(destroy,放资源)。硬件与脚本通过 `JsonValue` 解耦。
- **硬件变量工厂**:`MtVars_I2C / Servo / Speaker / MPU6050_BIND / Alias / Lua`,从 `@I2C/@PCA9685/@Servo/@Speaker/@MPU6050` 块按名构建。
- **Lua 扩展口**:任意未知 `@Block` 会路由到 `modules/<Name>.lua`(已接 ADS1115/ADS1256/PCA9685/SPI),C 层注册了 i2c_*/spi_*/milli_sleep 给 Lua 调用——这是「通用机器人硬件描述语言」野心的落点。
- **I2C 总线保护**:20 槽锁数组,多硬件共享总线时互斥。
- **资源释放**:`main.cj` 用 `try/finally` 显式 `deactive()`,规避仓颉 GC 析构时机不确定的风险。
### 3.4 自带 NN 库(有意思的工程副产物)
不依赖 PyTorch,纯仓颉实现了 `Nn1`(全连接,含前向/反向/权重更新)、`Matrix1`/`Tensor`、`Af`(6 种激活)、`Ini`(Xavier/Kaiming)、`Adam`、`cnn`、`fft`、`kalman`。配套 C/C++ 加速库(可切 CUDA)。这说明作者**试图把训练与推理都搬进仓颉生态**,为后续 DRL 端到端闭环做准备。
### 3.5 已支持的硬件
- **PCA9685**(0x40,50Hz PWM,角度→脉宽 0.5~2.5ms→12 位 PWM)。
- **Servo**(max/min/offset 校准,max<0 自动翻转反向安装舵机)。
- **MPU6050**(±500°/s,±8g,后台 10ms 采样线程 + 卡尔曼融合)。
- **Speaker**(正弦波发声,5ms 淡入淡出)。
---
## 4. 两个项目的关系(核心结论)
1. **闭环互补**:`actpred` 感知+记忆→预测动作(连续向量);`bot4cj` 把动作变成舵机角度执行。
这在代码里已有明确意图:
- `bot4cj/src/main.cj` 预留了 DRL 分支(`--test`、注释掉的 `run_action_drl`、`-i/-o` 模型加载/保存)。
- `bot4cj/src/r.cj:838` 注释:`TODO(20260518-qing): 使用神经网络RNN直接根据动作+传感器脚本生成下一步动作`。
2. **尚未真正打通**:目前两者**没有共享数据格式**。`actpred` 默认 `action_dim=8`,`bot4cj` 是 10 舵机 + 云台;`actpred` 输出 numpy 向量,`bot4cj` 消费 `.mt` 矩阵。集成是明确的「未来工作」。
3. **同作者、同仓库**:均署名 `qing`,同属 `blackbird` 单体仓库,是同一具身智能蓝图的「预测端」与「执行端」。
---
## 5. 发现的问题与不一致(值得跟进)
### 5.1 在代码中已修复、但 README 仍列为「已知问题」的旧项(README 滞后)
- **D6(Softmax 雅可比错误)**:当前 `math/Af.cj:softmax_jacobian` 已实现正确——对角线 `y_i(1-y_i)`、非对角 `-y_i*y_j`,逻辑无误。
- **D8(Tanh 导数错误)**:当前 `math/Af.cj:tanh_dot` 用 `1 - y_val*y_val`(`y` 即 tanh 输出),**这是正确的**。代码里 `NOTE(20260525-qing): 修复命名(x => y)` 表明作者已意识到此前是按输出 y 计算的。README 描述的旧 bug 已修。
- → 建议:更新 `README.robot.md` 的「已知问题」表,删除/标注 D6、D8 已修复,避免误导。
### 5.2 真实存在的代码瑕疵(未在 README 列出)
- **`src/P.cj:147` 调试残留**:`parse()` 主循环里有 `print(s)`,**逐字符把脚本内容打到 stdout**。运行脚本时会污染标准输出(若将来用 stdout 与上层通信会很致命)。应删除或改为 debug 开关。
- **`src/P.cj:93` 运算符优先级 bug**:`if (!values.size == 0 && !grp.size == values[...].size)`。`!values.size` 实际是对 size 整数取反(非零→false,零→true),**并非** `!= 0`。正确写法应为 `values.size != 0 && grp.size != values[...].size`。当前该行恒为假分支,导致「行未对齐」检测 `DoesNotSync` 实际**从不触发**,矩阵列数不一致时不会报错而可能静默错位。
- **`src/P.cj:202` 转义不一致**:同文件里换行判断混用 `r'\n'` 与 `r"\n"`(后者是字面两字符,非换行符),属复制粘贴隐患。
- **PCA9685 双实现**:`hd/PCA9685.cj` 仍保留主路径,但已标注「由 Lua 取代」(`modules/PCA9685.lua`),存在代码冗余(D4)。
### 5.3 设计层面的待办(来自代码 TODO)
- DRL 闭环未实现(`main.cj` 中 `run_action_drl`、`run_action` 均注释)。
- `RobotStruct` 的 `@Action` 位置指示器(增量行动)未做,脚本路径写死 `./modules/`,未从环境变量/相对路径查找(r.cj:996, :998)。
- `Tensor` 乘法仅支持 2D(D7),限制模型复杂度。
---
## 6. 总体评价
| 维度 | actpred | bot4cj |
|------|---------|--------|
| 代码成熟度 | 高(测试完备、可导出 ONNX) | 中(功能可用,有调试残留与 TODO) |
| 实用性 | 可直接训练/推理/集成 | 强硬件依赖(树莓派+I2C),需真机验证 |
| 创新性 | 常规回归网络 | 「矩阵式机器人硬件 DSL」+ 纯仓颉 NN 库,有想法 |
| 集成度 | 独立 | 与 actpred 的物理闭环尚未打通 |
**建议下一步**:
1. 修掉 `P.cj` 的 `print(s)` 与 `!size == 0` 两个硬 bug(影响解析正确性)。
2. 更新 `README.robot.md`,把 D6/D8 标记为已修复。
3. 定义 `actpred` 动作向量 ↔ `bot4cj` `.mt` 舵机角度的映射协议,打通预测—执行闭环(可先用 `action_dim=10` 对齐 10 舵机,或保留舵机+云台=12 维)。
---
*本报告基于源码静态阅读(actpred 全部 .py、bot4cj 的 README/main/P/r/nn/Af + 构建脚本 + 示例脚本),未做真机/真模型运行验证。*
@@ -1,265 +0,0 @@
# actpred 模块效果评估报告
> 日期:2026-08-09
> 评估范围:actpred 模块架构、代码质量、预训练模型实际预测能力、部署就绪度
> 评估方法:源码审阅 + 单元测试 + 预训练 checkpoint 全量滑动窗口定量评估
---
## 一、模块定位
actpred 是 micro_servers 的独立/边缘模块,纯离线 CLI 工具,不参与微服务调用链。
核心功能:输入多帧摄像头图像 + 历史动作序列 → 预测下一时间步的连续动作(回归)。
面向树莓派 Dashun 蜘蛛机器人(配合 bot4cj 模块),目标部署场景为嵌入式机器人实时控制。
---
## 二、架构评估
### 2.1 模型架构
多帧 Transformer 注意力融合(2026-08 重写):
```
F 帧图像 (F×3×64×64) → 共享 CNN 编码器 → F 个 token → proj → d_model ┐
├→ [img×F, act×H, PRED] + 位置编码
动作历史 (H×D) → Linear proj → d_model → H 个 token ─┤ │
可学习 [PRED] token ─────────────────────────────────────────┘ ▼
TransformerEncoder (pre-LN, 2层, 4头, d=128)
│
取 PRED 位置输出 → MLP 头 → 预测动作
```
### 2.2 关键指标
| 指标 | 数值 |
|------|------|
| 参数量 | ~450K |
| CPU 端到端推理 | 4.25 ms(235 FPS) |
| CPU 纯 forward | 2.77 ms |
| image_encoder 占比 | 47%(pure forward) |
| ONNX 导出 | opset 17,仅 batch 维动态 |
| ONNX 一致性 | max diff ~1.5e-8 |
| 单元测试 | 56 个,全部通过 |
### 2.3 架构评价
| 维度 | 评级 | 说明 |
|------|------|------|
| 模型设计 | A | 多帧注意力融合合理,token 序列固定长度 F+H+1,NPU 友好 |
| 推理速度 | A | 4.25ms 端到端,满足实时控制需求(>100 FPS) |
| ONNX 部署 | A- | 静态 shape 导出正确,版本转换有轻微告警不影响功能 |
| 安全性 | A | 零网络攻击面 + weights_only + 完整配置校验链 |
---
## 三、代码质量评估
### 3.1 代码规模
| 文件 | 行数 | 职责 |
|------|------|------|
| config.py | 205 | 三层 dataclass 配置 + CLI + 校验 |
| model.py | 130 | SimpleCNN / MobileNetV2 / Transformer 模型 |
| dataset.py | 151 | 数据集加载 + DataRecorder |
| train.py | 187 | 训练入口(episode 级划分) |
| pretrain_mt.py | 289 | .mt 预训练入口(文件级划分) |
| predict.py | 138 | 推理封装 + CLI |
| export_onnx.py | 58 | ONNX 导出 + 一致性验证 |
| mt_parser.py | 133 | .mt @Action 矩阵解析器 |
| test_actpred.py | 722 | 单元测试(56 cases) |
| **合计** | **2013** | |
### 3.2 优点
1. **安全性设计扎实**
- `torch.load(..., weights_only=True)` 全局使用
- `parse_checkpoint_config` 实现键集合 + 值域 + 整除性三重校验
- 旧架构 checkpoint 被未知键检查拒载
2. **数据泄漏已修复**
- pretrain_mt.py 采用文件级 train/val 划分(非样本级滑动窗口)
- train.py 采用 episode 级划分,归一化统计量仅用训练集计算
3. **工程化成熟**
- Config dataclass 三层结构 + CLI override + checkpoint roundtrip
- DataRecorder 原子 mkdir 防并发竞态
- mt_parser `-` 前向填充取 @Servo 初始角度
4. **测试覆盖全面**
- 56 单元测试,覆盖前向传播、数据加载、配置校验、mt 解析、推理端到端、ONNX 导出
- 边界条件充分(空数据集、维度不匹配、帧数不一致、未知键、旧 checkpoint 拒载等)
### 3.3 可改进项
1. image_encoder 仍为推理瓶颈(占 pure forward 的 47%),后续可 INT8 量化
2. 训练仅用 CosineAnnealingLR,未配置 warmup
3. ONNX 导出 opset 17 版本转换有告警(`axes_input_to_attribute` 断言失败后 fallback),不影响正确性但值得跟进
4. `requirements.txt` 版本范围宽松(`torch>=2.0,<3.0`),生产环境建议锁定
---
## 四、预训练模型定量评估
### 4.1 训练数据概况
```
.mt 脚本文件数: 13
总动作行数: 77
训练数据列数: 12(补零到 18)
文件级划分后: ~10 文件训练 / ~3 文件验证
训练样本数: ~50(滑动窗口)
模型参数量: 450K
样本/参数比: 1:9000(严重欠定)
```
各 .mt 文件数据量:
| 文件 | 行数 | 动作范围 |
|------|------|----------|
| jump.mt | 5 | [-180, 200] |
| left-hand.mt | 4 | [-180, 1000] |
| left-turn.mt | 11 | [-180, 200] |
| lie-down.mt | 6 | [-180, 200] |
| rght-hand.mt | 4 | [-180, 1000] |
| std.mt | 1 | [-180, 200] |
| stdd.mt | 7 | [-180, 200] |
| t0.mt | 10 | [-180, 500] |
| t1.mt | 1 | [10, 20] |
| tilt-b/f/l/r.mt | 各 7 | [-180, 200] |
### 4.2 全量滑动窗口评估
使用预训练 checkpoint `pretrain_best_inference.pt`,对所有 .mt 文件做 history_len=5 滑动窗口预测:
| 脚本 | 预测数 | 平均 L2 误差 |
|------|--------|-------------|
| left-turn.mt | 6 | 117.9 |
| lie-down.mt | 1 | 107.8 |
| stdd.mt | 2 | 138.7 |
| t0.mt | 5 | 239.1 |
| tilt-b.mt | 2 | 193.9 |
| tilt-f.mt | 2 | 201.4 |
| tilt-l.mt | 2 | 210.9 |
| tilt-r.mt | 2 | 183.4 |
### 4.3 与 Baseline 对比
| 方法 | Mean L2 Error | 相对比率 |
|------|---------------|----------|
| **模型预测** | **175.8** | **0.76x** |
| Naive(复制上一步) | 230.0 | 1.0x(基准) |
| 全局均值预测器 | 251.2 | 1.09x |
模型比 "复制上一步" 好 24%,比 "全局均值" 好 30%。但评估集仅 22 个预测点,统计意义有限。
### 4.4 预测分布分析
| 指标 | 预测 | 实际 |
|------|------|------|
| 数值范围 | [-164.5, 176.3] | [-180.0, 500.0] |
| 各维 std(舵机 0-9) | 12~57 | 0~95 |
| 各维 std(扬声器/延时 10-11) | 4~58 | 4~139 |
| 各维 std(预留 12-17) | 1~6 | 0 |
**核心问题**:预测严重趋于均值(regression to the mean),预测 std 仅为实际 std 的 ~50%。极端值(如扬声器频率 1000Hz、延时 200ms)完全无法捕获。
### 4.5 典型预测案例
left-turn.mt 滑动窗口预测(前 6 维):
```
t=5: pred=[18.0, 10.3, 17.1, -1.3, 16.5, 11.2] actual=[45, 0, 45, 45, 45, 0] L2=129.4
t=6: pred=[16.2, 8.5, 15.5, -0.4, 15.1, 8.9] actual=[45, 0, 0, 45, 45, 0] L2=127.3
t=7: pred=[15.7, 8.0, 15.0, -0.3, 14.7, 8.3] actual=[45, 0, 0, 0, 45, 0] L2=109.1
```
预测值集中在 8~18 附近,而实际动作在 0~45 之间跳变,模型未能跟踪动作变化趋势。
---
## 五、分场景效果预测
### 场景 A:当前预训练模型直接部署
**评级:不可用**
- image_encoder 在零图像上训练,真实摄像头输入产生随机特征
- 预测值被压缩到均值附近 ±15°,无法执行跳变动作
- 扬声器频率预测几乎为 0
- 预期表现:蜘蛛机器人缓慢无规律蠕动,无法完成有意义动作
### 场景 B:初步采集真实数据微调(~1K 帧,1-2 episodes)
**评级:有限可用**
- 450K 参数在 1K 帧上可学到基本运动模式
- 平滑动作(站立→缓走)MAE ~15-25°/关节
- 急变动作(跳跃、急转)MAE ~40-60°/关节
- image_encoder 开始学习真实视觉特征
### 场景 C:充分数据采集(~10K 帧,10+ episodes,多场景)
**评级:基本可用**
- 平滑动作 MAE ~5-15°/关节
- 急变动作 MAE ~20-40°/关节
- 配合 noise_std 数据增强和早停,泛化可接受
- SimpleCNN 表达力上限开始制约视觉理解
### 场景 D:场景 C + MobileNetV2 + 归一化动作
**评级:较好**
- MobileNetV2 ImageNet 预训练权重提供强视觉先验
- 归一化动作空间让 MSE loss 对各维度公平
- 平滑动作 MAE ~3-8°/关节
### 场景 E:理想状态(~100K 帧,充分多样性)
**评级:生产可用**
- MAE ~1-5°/关节
- 可完成绝大部分预设动作
---
## 六、核心瓶颈排序
| 优先级 | 瓶颈 | 严重度 | 说明 |
|--------|------|--------|------|
| 1 | 数据量 | 致命 | 77 行 / 13 文件,450K 参数 vs ~50 样本,样本/参数比 1:9000 |
| 2 | 零图像预训练的 image_encoder | 严重 | 47% 推理算力浪费在未训练的随机 CNN 上 |
| 3 | 动作空间异质性 | 中等 | dim 0-9 角度 [0-200°]、dim 10-11 频率/延时 [0-1000]、dim 12-17 全零,未归一化导致 MSE 被大数值维度主导 |
| 4 | 编舞脚本 vs 反应式行为 | 中等 | .mt 是预设动作编排,不含视觉反馈逻辑,模型学到的是 "动作惯性" 而非 "视觉→动作" 映射 |
| 5 | SimpleCNN 表达力 | 轻微 | 4 层 Conv 对细粒度空间信息编码不足 |
---
## 七、量化预测总结
| 阶段 | 数据量 | 可用度 | 舵机 MAE | 能否完成预设动作 |
|------|--------|--------|----------|-----------------|
| 当前预训练 | 77 行 / 零图像 | 不可用 | >50° | 不能 |
| 初步采集 | ~1K 帧 | 有限 | 15-40° | 部分(简单动作) |
| 充分采集 | ~10K 帧 | 基本可用 | 5-20° | 大部分 |
| 充分 + MobileNet | ~10K 帧 | 较好 | 3-10° | 大部分 |
| 理想状态 | ~100K 帧 | 生产可用 | 1-5° | 绝大部分 |
---
## 八、建议优先级
1. **最高优先**:实现遥操作/示教采集,尽快积累真实 (图像, 动作) 配对数据
2. **高优先**:采集前开启 `--normalize-actions`,平衡各维度 loss 权重
3. **中优先**:用真实数据微调后再评估,决定是否换 backbone
4. **低优先**:考虑预训练时冻结 image_encoder(减少无效梯度),或换恒等映射替代零图像
---
## 九、结论
actpred 模块架构设计合理,代码质量高,安全防护到位,推理速度满足实时需求。
当前最大短板是 **数据量严重不足**(77 行零图像预训练),导致模型预测趋于均值、直接部署不可用。
采集 10K 帧以上真实数据微调后,预计可达实用水平。
模块已具备从 "实验原型" 向 "生产部署" 过渡的架构基础,下一步关键路径是 **数据采集 → 微调验证 → NPU 部署测试**。
@@ -1,312 +0,0 @@
# actpred 预训练与推理测试报告
| 项目 | 值 |
|------|-----|
| 日期 | 2026-08-08 |
| 模块 | actpred |
| 测试人 | qing |
| 环境 | Intel Xeon E3-1200 v2 (Ivy Bridge), 4 核, Linux 24.04, Python 3.x, PyTorch (CPU-only) |
| 关联文件 | `config.py`, `mt_parser.py`, `pretrain_mt.py`, `test_actpred.py`, `export_onnx.py` |
---
## 一、测试目标
1. 验证 `action_dim` 默认值从 8 改为 18 后模型结构与测试的正确性
2. 验证 `.mt` 脚本 `@Action` 矩阵解析器的功能正确性
3. 执行 `.mt` 脚本预训练并评估训练收敛情况
4. 测量推理延迟与吞吐量
5. 调研 PyTorch 对 NPU / 向量计算单元的支持现状
---
## 二、action_dim 参数变更验证
### 2.1 变更内容
| 文件 | 变更 |
|------|------|
| `config.py` | `action_dim: int = 8` → `18` |
| `test_actpred.py` | `test_forward_simple_mlp` / `test_forward_simple_gru` 的 shape 断言 8 → 18;`test_default_config` 断言 8 → 18 |
| `README.md` | 训练命令、推理示例、DataRecorder 示例、配置表说明 |
### 2.2 参数量对比
| action_dim | 参数量 | 增量 | 输出形状 |
|------------|--------|------|---------|
| 8 | 138,184 (0.138M) | 基准 | (batch, 8) |
| 18 | 142,034 (0.142M) | +3,850 | (batch, 18) |
| 30 | 146,654 (0.147M) | +8,470 | (batch, 30) |
每增加 1 维 action_dim 约多 385 参数(action_encoder 输入层 +5×64=320,head 输出层 +64+1=65),对 0.14M 的模型影响微乎其微。
### 2.3 单元测试
```
$ python test_actpred.py
Ran 17 tests in 0.060s
OK
```
17 项测试全部通过,包括:
- `test_forward_simple_mlp`(action_dim=18, output shape (2, 18))
- `test_forward_simple_gru`(action_dim=18, output shape (2, 18))
- `test_default_config`(assert action_dim == 18)
- `test_forward_dynamic_batch`(action_dim=4, 未改,测试不同维度兼容性)
---
## 三、.mt 脚本解析器测试
### 3.1 解析器实现
`mt_parser.py` 实现了轻量级 `.mt` 脚本 `@Action` 矩阵解析器:
1. 正则提取 `@Action ... @` 块(先去除 `#` 注释行,避免注释中的 `@Action` 干扰)
2. 按行解析,去除行尾 `;`,按空白分割列
3. 第一行为表头(列名如 `s1.A`, `s2.A`),跳过
4. `-` 占位符前向填充(沿用上一行同列值)
5. 返回 `(n_data_rows, n_cols)` float32 矩阵
### 3.2 解析结果
从 `../bot4cj/scripts/` 目录解析到 13 个 `@Action` 矩阵:
| 文件 | shape | 列数 | 数据行 |
|------|-------|------|--------|
| jump.mt | (5, 12) | 12 | 5 |
| left-hand.mt | (4, 12) | 12 | 4 |
| left-turn.mt | (11, 12) | 12 | 11 |
| lie-down.mt | (6, 12) | 12 | 6 |
| rght-hand.mt | (4, 12) | 12 | 4 |
| std.mt | (1, 12) | 12 | 1 |
| stdd.mt | (7, 12) | 12 | 7 |
| t0.mt | (10, 12) | 12 | 10 |
| t1.mt | (1, 2) | 2 | 1 |
| tilt-b.mt | (7, 12) | 12 | 7 |
| tilt-f.mt | (7, 12) | 12 | 7 |
| tilt-l.mt | (7, 12) | 12 | 7 |
| tilt-r.mt | (7, 12) | 12 | 7 |
### 3.3 占位符填充验证
以 `jump.mt` 为例:
```
原始(部分):
45 - 45 - 45 - 45 - 90 90 10 200;
- 10 - 10 - 10 - 10 - - - 20;
解析结果:
row 0: [45. 0. 45. 0. 45. 0. 45. 0. 90. 90. 10. 200.] ← 首行 - 填 0
row 1: [45. 10. 45. 10. 45. 10. 45. 10. 90. 90. 10. 20.] ← - 沿用上行
```
前向填充正确。`-` 占位符被正确替换为上一行同列的值。
### 3.4 列数适配
`.mt` 脚本 12 列 vs `action_dim=18`:解析器自动补零至 18 列,不影响训练。`t1.mt`(2 列)同样补零至 18 列。
---
## 四、预训练测试
### 4.1 训练配置
| 参数 | 值 |
|------|-----|
| 数据来源 | `../bot4cj/scripts/*.mt` (13 文件) |
| 总样本数 | 22(history_len=5 滑动窗口) |
| 训练集 | 19 样本(85%) |
| 验证集 | 3 样本(15%) |
| 模型 | ActionPredictor (SimpleCNN + MLP, 142,034 参数) |
| 输入 | 零图像占位 (1, 3, 64, 64) + action_history (1, 5, 18) |
| 损失函数 | MSE Loss |
| 优化器 | AdamW (lr=0.001, weight_decay=1e-4) |
| 学习率调度 | CosineAnnealingLR (T_max=200) |
| 数据增强 | Gaussian noise (std=1.0) |
| batch_size | 8 |
| 最大轮数 | 200 |
| 早停耐心值 | 30 |
| 随机种子 | 42 |
### 4.2 训练过程
| Epoch | train_loss | val_loss | 备注 |
|-------|-----------|----------|------|
| 1 | 3340.79 | 2125.46 | 初始 loss |
| 7 | 2106.50 | 1051.22 | 快速下降阶段 |
| 12 | 1404.49 | 917.81 | |
| 20 | ~300 | ~400 | 进入收敛区 |
| 48 | 171.64 | 228.02 | |
| 63 | 124.30 | **190.58** | 最优 checkpoint |
| 93 | 92.29 | 223.89 | 早停触发(patience=30) |
### 4.3 训练结果
| 指标 | 值 |
|------|-----|
| 最终轮数 | 93(早停) |
| 最优 val_loss | 190.58(epoch 63) |
| val_loss 下降幅度 | 2125 → 190(-91%) |
| RMSE | √190 ≈ 13.8 |
| 训练耗时 | ~2-3 分钟(CPU) |
### 4.4 checkpoint 文件
| 文件 | 大小 | 说明 |
|------|------|------|
| `checkpoints/pretrain_best.pt` | 1.7 MB | 含 optimizer state,可恢复训练 |
| `checkpoints/pretrain_best_inference.pt` | 572 KB | 仅模型权重 + config,直接推理用 |
### 4.5 checkpoint 兼容性验证
预训练 checkpoint 可被 `ActionPredictorInference` 直接加载:
```python
predictor = ActionPredictorInference("./checkpoints/pretrain_best_inference.pt")
# action_dim=18, history_len=5, backbone=simple, encoder=mlp
# output shape: (1, 18)
```
与现有 `predict.py` 推理管线、`export_onnx.py` ONNX 导出完全兼容。
---
## 五、推理性能基准
### 5.1 测试方法
- 模型:预训练 checkpoint(`pretrain_best_inference.pt`)
- 输入:随机图像 (64, 64, 3) uint8 + 随机 history (5, 18) float32
- warmup:10 次推理后计时
- 测量:1000-2000 次取平均
### 5.2 结果
| 环节 | 耗时 (ms) | FPS | 占比 |
|------|-----------|-----|------|
| **端到端推理(含预处理)** | **1.61** | **622** | 100% |
| 纯前向(含 CNN) | 1.09 | 916 | 100% |
| ├ image_encoder (SimpleCNN) | 0.82 | — | 75% |
| └ action_encoder + FusionHead | 0.27 | — | 25% |
### 5.3 性能评估
- 622 FPS 远超 bot4cj 控制循环所需的 30 FPS(20 倍余量)
- 瓶颈在 image_encoder(75%),但 SimpleCNN 仅 98K 参数,进一步优化收益有限
- 预处理(numpy → tensor + resize)仅占 0.52ms
---
## 六、PyTorch NPU / 向量计算单元支持现状
### 6.1 原生支持
| 硬件 | 支持程度 | 说明 |
|------|----------|------|
| x86 AVX2 | ✅ 最低要求 | PyTorch 2.x 起强制,不支持的 CPU 回退到非向量化代码 |
| x86 AVX-512 | ✅ oneDNN 后端 | Intel Xeon Scalable+,`torch.compile` 自动利用 |
| x86 AMX | ✅ oneDNN 后端 | Intel Sapphire Rapids+,矩阵乘法加速 |
| ARM NEON | ✅ 长期支持 | 树莓派/手机端 |
| ARM SVE/SVE2 | ✅ PyTorch 2.9+ | 2025 年新增,向量化长度自适应 |
| Intel iGPU (XPU) | ✅ PyTorch 2.5+ | `device="xpu"` |
### 6.2 间接支持(需格式转换)
| 硬件 | 路径 | 说明 |
|------|------|------|
| Intel NPU | PyTorch → ONNX → OpenVINO | Core Ultra 系列内置 NPU |
| Apple ANE | PyTorch → CoreML | M1/M2/M3 芯片神经引擎 |
| Qualcomm Hexagon | PyTorch → ExecuTorch | 手机端 NPU |
| ARM SME2 | PyTorch → ExecuTorch + KleidiAI | ARMv9 旗舰核 |
### 6.3 当前环境分析
测试机器 CPU 为 Intel Xeon E3-1200 v2(Ivy Bridge, 2012 年):
- 支持 AVX(128bit),不支持 AVX2(需 Haswell+)
- PyTorch 可能回退到非向量化代码
- 无独立 GPU,无 NPU
### 6.4 可行的加速路径
| 路径 | 预估推理 | 说明 |
|------|---------|------|
| 当前(PyTorch CPU) | 1.61 ms (622 FPS) | 可能未启用向量化 |
| `torch.compile` | ~1.0 ms (~1000 FPS) | 自动融合 + oneDNN |
| ONNX → OpenVINO | ~0.5 ms (~2000 FPS) | 自动利用 AVX2/AVX-512/AMX/NPU |
| 换 AVX2 CPU | ~0.5 ms (~2000 FPS) | 任何 Haswell(2013)+ CPU |
### 6.5 结论
622 FPS 对 bot4cj 的 30 FPS 控制循环已有 20 倍余量,推理加速优先级低。加速投入应留给训练环节(微调阶段 batch_size=32 + MobileNetV2 2.4M 参数时 CPU 较慢)。
---
## 七、结论与下一步
### 7.1 测试结论
| 测试项 | 结果 |
|--------|------|
| action_dim=18 参数变更 | ✅ 通过(17 项单元测试全通过) |
| .mt 解析器 | ✅ 通过(13 文件,22 样本,占位符正确) |
| 预训练 | ✅ 通过(val_loss 2125→190,91% 下降) |
| checkpoint 兼容性 | ✅ 通过(与现有推理管线完全兼容) |
| 推理性能 | ✅ 通过(622 FPS,20 倍余量) |
### 7.2 已知局限
1. **数据量极少**:22 个样本(19 训练 / 3 验证),预训练仅学到基本的动作序列模式,val_loss 190(RMSE ≈ 14)仍有优化空间
2. **列数不匹配**:.mt 脚本 12 列,action_dim=18,补零的 6 列在训练中贡献零梯度
3. **无图像预训练**:image_encoder 在零图像上训练,学到的是常数映射,微调时需要重新训练
### 7.3 下一步建议
1. **用真实图像微调**:收集 bot4cj 运行时摄像头图像 + 动作序列数据,以预训练 checkpoint 为起点微调
2. **增加 .mt 脚本**:编写更多动作脚本(当前仅 13 个),扩充预训练数据量
3. **列数对齐**:扩展 .mt 脚本至 18 列(增加 6 列预留通道),或调整 action_dim 为 12
4. **ONNX 导出验证**:测试 `export_onnx.py` 对预训练 checkpoint 的导出兼容性
5. **bot4cj 集成**:修复 bot4cj 审计 P0 项(S01/S02/S07),打通 actpred → bot4cj 的动作执行闭环
---
## 附录 A:训练曲线关键节点
```
val_loss
2100 │●
│ ╲
1800 │ ●
│ ╲
1500 │ ●
│ ╲
1200 │ ●
│ ╲
900 │ ●
│ ╲___
600 │ ●●
│ ●●●
300 │ ●●●●___
│ ●●●●●●●●●●●●●
100 │ ●●●●●●●●●●●●●●
└──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──→ epoch
10 20 30 40 50 60 70 80 90
↑
epoch 63: best (190.58)
```
## 附录 B:文件清单
| 文件 | 类型 | 说明 |
|------|------|------|
| `actpred/config.py` | 修改 | action_dim 默认值 8 → 18 |
| `actpred/mt_parser.py` | 新增 | .mt @Action 矩阵解析器 |
| `actpred/pretrain_mt.py` | 新增 | .mt 脚本预训练入口 |
| `actpred/_run.sh` | 修改 | 新增 pretrain 子命令 |
| `actpred/README.md` | 修改 | 新增预训练文档 |
| `actpred/test_actpred.py` | 修改 | 测试断言更新为 18 |
| `actpred/checkpoints/pretrain_best.pt` | 生成 | 预训练 checkpoint(含 optimizer) |
| `actpred/checkpoints/pretrain_best_inference.pt` | 生成 | 预训练 checkpoint(推理用) |