diff --git a/.gitignore b/.gitignore index 955d990..264605a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ waf/certs/ # 本地 IDE / 工具 .qoder/ .vscode/ + +# 运行时日志(HailoRT 等) +hailort.log diff --git a/actpred/.gitignore b/actpred/.gitignore index 950586d..360bff9 100644 --- a/actpred/.gitignore +++ b/actpred/.gitignore @@ -20,6 +20,7 @@ checkpoints/ # DFC logs acceleras.log hailo_sdk.*.log +hailort.log # Dataset (too large for git) data/ diff --git a/actpred/_npu_test.py b/actpred/_npu_test.py new file mode 100644 index 0000000..b000b85 --- /dev/null +++ b/actpred/_npu_test.py @@ -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 输出对输入不敏感,编译仍有问题") diff --git a/actpred/_onnx_validate.py b/actpred/_onnx_validate.py new file mode 100644 index 0000000..6bb8881 --- /dev/null +++ b/actpred/_onnx_validate.py @@ -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") diff --git a/actpred/dfc_compile.py b/actpred/dfc_compile.py index 1f44957..e3b2252 100644 --- a/actpred/dfc_compile.py +++ b/actpred/dfc_compile.py @@ -57,6 +57,44 @@ def har_input_layer_names(har_path: str) -> list: 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(): parser = argparse.ArgumentParser(description="Compile actpred deploy ONNX to HEF") parser.add_argument("--onnx", default="checkpoints/actpred_deploy.onnx") @@ -93,11 +131,13 @@ def main(): names = har_input_layer_names(har_raw) if len(names) != len(START_NODES): sys.exit(f"错误: HAR 输入层数 {len(names)} != {len(START_NODES)},解析可能被截断") + check_input_connectivity(har_raw, names) 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) + check_input_connectivity(har_opt, har_input_layer_names(har_opt)) print("[3/3] compiler: HAR → HEF") hailo("compiler", har_opt, "--hw-arch", args.hw_arch, diff --git a/actpred/export_deploy.py b/actpred/export_deploy.py index c44f3f1..3b3268e 100644 --- a/actpred/export_deploy.py +++ b/actpred/export_deploy.py @@ -128,6 +128,9 @@ 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) @@ -156,6 +159,11 @@ def main(): ) 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) with torch.no_grad(): orig_out = model(stacked, dummy_history.squeeze(1)) diff --git a/actpred/gen_calibration.py b/actpred/gen_calibration.py index 8eba17f..decf65c 100644 --- a/actpred/gen_calibration.py +++ b/actpred/gen_calibration.py @@ -6,7 +6,8 @@ --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) 常量。 + action_history (N,1,H_len,D)、pred_token (N,1,1,d) 学习值 ± 抖动 + (常量校准会使量化范围退化,见 --pred-jitter)。 图像分辨率与 --model checkpoint 的配置一致。 数据源: @@ -81,9 +82,15 @@ def build_deploy_set(args): 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() + v = model.pred_token.data.unsqueeze(1).numpy() # (1, 1, d) 学习到的常量 + # 常量校准会使量化范围退化(上次实测 scale=18571.7,任何输入都被 + # 量化成 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( args.output, @@ -91,7 +98,8 @@ def build_deploy_set(args): 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}") + print(f" action_history: {history.shape} pred_token: {pred.shape}" + f"(学习值 ±{args.pred_jitter:.0%} 抖动)") def main(): @@ -104,6 +112,10 @@ def main(): parser.add_argument("--output", default=None) parser.add_argument("--samples", type=int, default=32) 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() if args.deploy: diff --git a/actpred/hailo_infer.py b/actpred/hailo_infer.py index 1f0155e..ebf3450 100644 --- a/actpred/hailo_infer.py +++ b/actpred/hailo_infer.py @@ -3,30 +3,40 @@ 接口对齐 predict.ActionPredictorInference:predict(images, action_history)。 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) - 或 (1, H, W, F*3) → 帧沿通道拼接 - 动作输入: (1, H, D) float32,uint8 边缘时按 quant_info 量化 - 动作输出: (1, D),uint8 边缘时按 quant_info 反量化 + 新版 5 输入(export_deploy.py + dfc_compile.py 产物): + frame0~2 各 (H,W,3) NHWC uint8(ImageNet 归一化校准,负零偏自动识别) + + action_history (1,H_len,D) + pred_token (1,1,d) + 旧版单图像输入: + (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: - hailort 5.x 对 Hailo-10 仅支持新 API(create_infer_model), 旧版 VDevice.configure(hef) 报 HAILO_NOT_IMPLEMENTED; - - 绑定缓冲区 dtype/大小必须匹配 HEF 边缘格式(量化 uint8); - - 图像输入按 Hailo 惯例为 uint8 原始像素(ImageNet 归一化由 - DFC 编译时折叠进网络)。 + - 绑定缓冲区 dtype/大小必须匹配 HEF 边缘格式(量化 uint8)。 """ import argparse import contextlib import json import os +import re import numpy as np 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 相同逻辑;此处内联以保持 # 本模块零 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) +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: """HEF 动作预测推理器(Hailo NPU 后端) + 支持新版 5 输入 HEF(逐帧输入 + pred_token)与旧版单图像输入 HEF, + 布局与量化参数从 vstream info 自动推导。 + 用法: with HailoPredictor("actpred.hef") as predictor: action = predictor.predict(images, action_history) @@ -68,9 +86,9 @@ class HailoPredictor: if len(self._out_infos) != 1: raise ValueError( 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._frame_infos, self._history_info, self._pred_info = \ + self._classify_inputs() self._derive_dims() self._action_mean = None @@ -89,40 +107,77 @@ class HailoPredictor: self._stack.close() # 初始化失败也释放设备,避免 fd 泄漏 raise - # ---------------- 形状推导 ---------------- + # ---------------- 输入分类与形状推导 ---------------- - def _find_image_input(self): - cands = [i for i in self._in_infos if len(i.shape) in (4, 5)] - 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 _classify_inputs(self): + """将 HEF 输入分为 帧列表 / 动作历史 / pred_token(可能为 None) - def _find_history_input(self): - cands = [i for i in self._in_infos - if len(i.shape) == 3 and i is not self._image_info] - if len(cands) != 1: + 多帧输入的绑定顺序按 input_layerN 数字后缀排序,而非 vstream + 报告顺序:hailort 5.3.0 实测报告顺序被打乱(1,5,4,3,2), + 按报告顺序绑定会导致 frame1/frame2 错位。 + """ + 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( + f"无法识别 HEF 输入 {i.name} 形状 {s}(期望动作历史 " + f"(1,H_len,D) 或 pred_token (1,1,d))") + if not frames: + raise ValueError("HEF 输入中未找到图像输入") + if history is None: raise ValueError( - "HEF 输入中无法唯一识别动作历史 vstream(期望一个 3 维输入)," - f"实际: {[(i.name, tuple(i.shape)) for i in self._in_infos]}") - return cands[0] + f"HEF 输入中未找到动作历史输入(输出维度 {out_dim})," + f"实际: {[(i.name, tuple(i.shape)) for i in ins]}") + return frames, history, pred def _derive_dims(self): - shape = tuple(self._image_info.shape) - if len(shape) == 5: - _, self.frame_len, c, self.image_height, self.image_width = shape - if c != 3: - raise ValueError(f"图像输入通道维应为 3,实际形状 {shape}") - self._image_layout = "ncfhw" - else: # (1, H, W, F*3) NHWC 帧拼通道 - _, self.image_height, self.image_width, c = shape - if c % 3 != 0 or c == 3: - raise ValueError( - f"无法从 4 维图像输入推导帧数(期望 (1,H,W,F*3))," - f"实际形状 {shape}") - self.frame_len = c // 3 - self._image_layout = "nhwc_fused" + # 帧输入布局 + 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: + _, self.frame_len, c, self.image_height, self.image_width = shape + if c != 3: + raise ValueError(f"图像输入通道维应为 3,实际形状 {shape}") + self._image_layout = "ncfhw" + else: # (1, H, W, F*3) NHWC 帧拼通道 + _, self.image_height, self.image_width, c = shape + if c % 3 != 0 or c == 3: + raise ValueError( + f"无法从 4 维图像输入推导帧数(期望 (1,H,W,F*3))," + f"实际形状 {shape}") + self.frame_len = c // 3 + 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) 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 图像列表(旧→新,末帧为当前帧) - 返回 (action_dim,) float32 动作向量。 + 返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF + 需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。 """ if not isinstance(images, (list, tuple)): images = [images] @@ -170,9 +227,8 @@ class HailoPredictor: from hailo_platform import FormatType bindings = self._configured.create_bindings() - frames = np.stack([self._preprocess(img) for img in images]) - bindings.input(self._image_info.name).set_buffer( - self._image_buffer(frames)) + frame_float = np.stack([self._frame_float(img) for img in images]) + self._bind_frames(bindings, frame_float) h_shape = tuple(self._history_info.shape) if self._history_info.format.type == FormatType.UINT8: @@ -183,6 +239,18 @@ class HailoPredictor: buf = action_history.astype(np.float32).reshape(h_shape) 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) if self._out_info.format.type == FormatType.UINT8: out_buf = np.zeros(out_shape, dtype=np.uint8) @@ -203,19 +271,43 @@ class HailoPredictor: result = result * self._action_std + self._action_mean 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 = img.convert("RGB").resize( (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": - return frames.transpose(0, 3, 1, 2)[np.newaxis] # (1,F,3,H,W) - # nhwc_fused: 帧沿通道拼接 (1, H, W, F*3) - return frames.reshape(1, self.image_height, self.image_width, - self.frame_len * 3) + buf = frame_float.transpose(0, 3, 1, 2)[np.newaxis] # (1,F,3,H,W) + info = self._frame_infos[0] + if info.format.type == FormatType.UINT8: + 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(): @@ -227,10 +319,14 @@ def main(): help="冒号分隔各步,每步 action_dim 个值") parser.add_argument("--stats", default=None, 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"], default="json") 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: if len(args.images) != predictor.frame_len: raise SystemExit( @@ -238,7 +334,7 @@ def main(): f"实际为 {len(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) - predicted = predictor.predict(images, history) + predicted = predictor.predict(images, history, pred_token=pred_token) if args.output_format == "json": print(json.dumps({"predicted_action": predicted.tolist()}, indent=2)) diff --git a/actpred/test_actpred.py b/actpred/test_actpred.py index d2f2bd1..d6fb537 100644 --- a/actpred/test_actpred.py +++ b/actpred/test_actpred.py @@ -1093,6 +1093,88 @@ class TestHailoPredictor(unittest.TestCase): np.testing.assert_allclose(result, [0, 0, 0]) 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): from hailo_infer import HailoPredictor infos_in = [ diff --git a/docs/reports/actpred_attention_rewrite_test_20260809.md b/docs/reports/actpred_attention_rewrite_test_20260809.md deleted file mode 100644 index f428cb8..0000000 --- a/docs/reports/actpred_attention_rewrite_test_20260809.md +++ /dev/null @@ -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 已删除并重训 | diff --git a/docs/reports/actpred_bot4cj_research_20260809.md b/docs/reports/actpred_bot4cj_research_20260809.md deleted file mode 100644 index e99af6b..0000000 --- a/docs/reports/actpred_bot4cj_research_20260809.md +++ /dev/null @@ -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/.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 + 构建脚本 + 示例脚本),未做真机/真模型运行验证。* diff --git a/docs/reports/actpred_effectiveness_evaluation_20260809.md b/docs/reports/actpred_effectiveness_evaluation_20260809.md deleted file mode 100644 index f2d04ae..0000000 --- a/docs/reports/actpred_effectiveness_evaluation_20260809.md +++ /dev/null @@ -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 部署测试**。 diff --git a/docs/reports/actpred_pretrain_test_20260808.md b/docs/reports/actpred_pretrain_test_20260808.md deleted file mode 100644 index 7029673..0000000 --- a/docs/reports/actpred_pretrain_test_20260808.md +++ /dev/null @@ -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(推理用) |