Compare commits

..
6 Commits
Author SHA1 Message Date
qing cd4d512ecd feat(pybot×actpred): R2 多 MPU6050 支持——8×N 姿态向量拼接与归一化平铺
- robot:_find_imus/_read_imus 按脚本块行序收集拼接 ≤4 个 MPU6050,_pad_imu 对齐 predictor.imu_dim

- recorder/imu_norm/dataset/config:imu.npy 宽度 8×N,归一化除数按传感器数平铺

- 测试:双传感器闭环拼接/pad/truncate、宽布局录制、16 维归一化加载
2026-08-14 09:34:39 +08:00
qing b289e7d17c fix(pybot): Windows 测试环境兼容(scripts 软链解析、os.pathsep 分隔)
- conftest:pybot/scripts 为 git 符号链接,Windows 落盘为文本文件(内容=目标相对路径),测试时手动解析到 bot4cj/scripts

- plugin:PYBOT_MODULES_PATH 改按 os.pathsep 分隔,避免 Windows 盘符冒号被切碎导致 find_module 返回丢盘符的路径
2026-08-14 09:34:26 +08:00
qing a2ba797410 feat(pybot×actpred): R2 姿态反馈方案一——MPU6050 独立 IMU token 全链路
- pybot:recorder 逐帧落盘 imu.npy(T×8,roll,pitch,ax,ay,az,gx,gy,gz,与帧/动作同调用对齐);run_loop 对声明 imu_dim 的 predictor 以 predict(..., imu=) 调用(鸭子类型,旧两参调用向后兼容)

- actpred:新增 --imu-dim;model 序列 [img×F, act×H, (IMU), PRED](imu_dim>0 时 F+H+2,IMU token 位于 PRED 前);dataset/train 读 imu.npy(缺失按零向量兜底);predict/ONNX/Hailo 三后端与 export/gen_calibration/dfc_compile/_npu_test 部署链适配 imu 输入

- 姿态按物理量程固定缩放归一化(imu_norm.py,训练/推理共用,零状态)

- 测试:actpred 128 passed / pybot loop+recorder 28 passed
2026-08-14 09:07:24 +08:00
qing 3e773debc9 docs: pybot×actpred 审计报告与 actpred 研究方向报告
- docs/reviews/pybot_actpred_AUDIT_20260814.md:双模块审计(15 项既有
  修复确认 + 新发现 P1×4/P2×6,含 P1-2 闭环帧源语义、P1-1 节拍失效研究)
- docs/reports/actpred_research_directions_20260814.md:RNN 思想四切入点
  (C 残差/B 多步/A GRU 记忆 token/D 全 recurrent)+ R1~R13 分层演进路线
- 迁移文档 §9 更新为已实现:进程内闭环
2026-08-14 07:52:35 +08:00
qing cc2f7bca79 feat(actpred): R1 残差预测+多步展开训练 与 R4 离线评估基准
R1(可选开关,默认关,向后兼容):
- --predict-delta:残差预测,target=Δ=a[t]-a[t-1](归一化空间),
  推理端 predict 自动加回 a_{t-1}(惯性先验平滑动作)
- --rollout-len K + --ss-prob:多步展开 + scheduled sampling,
  首步真历史、后续步按概率用真历史或自预测回传(detach 截断),
  治 exposure bias;dataset 多步样本返回 {steps:[...]},自定义 collate
- 旧 checkpoint getattr 默认 False 兼容;pybot 闭环/部署链零改动

R4(新增 evaluate.py):
- 留出 episode 开环滚动预测,复刻闭环口径(图像真值滚动、动作自预测回写)
- 5 个闭环代理指标:overall_mse/error_growth(发散)/error_curve/
  smoothness_ratio(平滑)/oob_rate(越界,数据经验界 min/max+margin)
- 支持 --compare A/B 对比与 --output JSON 报告
- 端到端验证:正确捕捉开环发散(单步 MSE 盲区)与残差平滑收益

新增 17 用例(R1×10 + R4×7),全量 100 passed 无回归
2026-08-14 07:52:22 +08:00
qing fe12dd7b33 feat(pybot): 进程内实时预测闭环(--loop)与帧源/节拍修复
- 新增 run_loop 闭环:F 帧+H 步历史双滑窗,冷启动重复同帧+零动作,
  每步 采样→推理→执行→回写,hz 节拍控制,可选同步录制
- --loop CKPT/--hz/--loop-steps 参数,按 .hef 后缀选 Hailo/PyTorch 后端,
  actpred 软依赖(仅 --loop 时 import),保持 pybot 零依赖
- P1-2 帧源修复:闭环改从 camera 守护线程队列取最近 F 帧(低延迟),
  替代 capture() 同步曝光阻塞,framerate/capacity 配置真正生效
- P1-1 overrun 告警:超时步记录实际耗时,汇总 overrun 比例并给降频建议
- 剔除 Speaker.delay 发声阻塞,speaker 补 delay=0 非阻塞
- 新增 tests/test_loop.py 11 用例(冷启动/历史滑动/索引映射/帧源等)
2026-08-14 07:52:07 +08:00
28 changed files with 2959 additions and 142 deletions

No files matched your search

+65 -10
View File
@@ -10,15 +10,16 @@ Transformer(pre-LN)自注意力融合,取 [PRED] token 回归下一步动
```
F 帧图像 (F×3×H×W) ─► 共享 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)
├─► [img×F, act×H, (IMU), PRED]
动作历史 (H×D) ─► Linear proj → d_model ─► H 个 token ────────┤ │ + 位置编码
IMU 姿态 (imu_dim) ─► Linear proj → 1 个 token(R2,可选)────────┤ ▼
可学习 [PRED] token ────────────────────────────────────────┘ TransformerEncoder (pre-LN, 2 层, 4 头, d_model=128)
│
取 PRED 位置输出 ─► MLP 头 ─► 预测动作 (action_dim)
```
token 序列长度固定为 F+H+1(默认 3+5+1=9),除 batch 外无动态 shape,对 NPU(如树莓派
token 序列长度固定为 F+H+1(默认 3+5+1=9;`--imu-dim`>0 时 IMU token 插到
PRED 之前,长度 F+H+2),除 batch 外无动态 shape,对 NPU(如树莓派
AI 扩展板)部署友好。
## 快速开始
@@ -33,6 +34,16 @@ bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18
# 断点续训练(从 last.pt 恢复 model/optimizer/scheduler/epoch)
bash _run.sh train --data-dir ./data --epochs 100 --resume checkpoints/last.pt
# R1 改进:残差预测 + 多步展开训练(可叠加)
# --predict-delta 预测 Δ=actions[t]-actions[t-1](惯性先验,平滑动作;推理端自动加回 a_{t-1})
# --rollout-len K 多步展开训练(K>1 时 scheduled sampling,治 exposure bias)
# --ss-prob P scheduled sampling 真实历史概率(默认 1.0=全真;0.5 表示半数步用自预测回传)
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 \
--predict-delta --rollout-len 4 --ss-prob 0.7
# R2 姿态反馈:--imu-dim 8 启用独立 IMU token(数据需含 imu.npy,见"数据格式")
bash _run.sh train --data-dir ./data --epochs 100 --action-dim 18 --imu-dim 8
# 推理(--images 按时间顺序旧→新传入 frame_len 个图像路径,冷启动时可重复传同一帧;
# --action-history 格式:冒号分隔各步,每步 action_dim 个值,共 history_len 步)
bash _run.sh --model checkpoints/best_inference.pt \
@@ -42,6 +53,12 @@ bash _run.sh --model checkpoints/best_inference.pt \
# 导出 ONNX(如安装了 onnxruntime,导出后自动验证一致性)
bash _run.sh export --model checkpoints/best_inference.pt --output model.onnx
# R4 离线评估:开环滚动预测,量化闭环代理指标(误差发散/平滑度/越界率)
python evaluate.py --model checkpoints/best_inference.pt --data-dir ./data
# A/B 对比两个 checkpoint(负值 = 主模型更优)
python evaluate.py --model checkpoints/r1.pt --compare checkpoints/baseline.pt \
--data-dir ./data --episodes 3 --horizon 50 --output report.json
# 运行单元测试
python test_actpred.py
```
@@ -96,6 +113,7 @@ data/
0001.jpg
...
actions.npy # shape: (T, action_dim) float32
imu.npy # 可选,shape: (T, 8×N) float32(N 个 MPU6050 姿态拼接,R2)
episode_002/
...
```
@@ -103,6 +121,15 @@ data/
每个 timestep 的样本:帧 `[t-frame_len+1, t]`(旧→新)+ 前 `history_len` 步动作 → 当前步动作。
采样起点为 `max(history_len, frame_len-1)`。
`imu.npy` 每传感器占 8 槽 `[roll, pitch, ax, ay, az, gx, gy, gz]`(roll/pitch
角度制,其余为 MPU6050 原始 int16),多传感器(最多 4 个)按脚本
`@MPU6050_BIND` 行顺序拼接,由 pybot `--record` 逐帧同步落盘。**旧 episode
没有姿态数据,训练 IMU 模型需用新版 pybot 重新录制**;`--imu-dim`>0 时缺失
`imu.npy` 的 episode 按零向量兜底(向后兼容,但无姿态先验收益)。
姿态归一化按物理量程固定缩放(roll/pitch ÷180,原始 int16 ÷32768,8 槽模式
按传感器数平铺),训练与推理共用 `imu_norm.py`,无需统计文件。
录制/训练/闭环脚本的传感器行顺序必须一致,否则槽位错位。
### 数据采集
```python
@@ -125,6 +152,7 @@ recorder.flush()
| `--action-dim` | 18 | 动作向量维度(对齐 bot4cj 18 列:10 舵机 + 2 云台 + 扬声器频率 + 延时 + 4 预留)|
| `--history-len` | 5 | 历史动作窗口长度 |
| `--frame-len` | 3 | 输入图像帧数(含当前帧,旧帧在前)|
| `--imu-dim` | 0 | R2 姿态输入维度(>0 启用独立 IMU token;8×N,N=传感器数≤4)|
| `--backbone` | simple | CNN 类型 (simple/mobilenet) |
| `--attn-dim` | 128 | 注意力 token 维度 d_model(须能被 attn-heads 整除)|
| `--attn-heads` | 4 | 注意力头数 |
@@ -144,6 +172,28 @@ recorder.flush()
| `--init-from` | 无 | 预训练 checkpoint 路径,作为微调起点(strict=False) |
| `--resume` | 无 | 从 last.pt 断点续训练(恢复 model/optimizer/scheduler/epoch) |
| `--save-config` | 无 | 保存配置到 JSON 文件 |
| `--predict-delta` | False | R1-C 残差预测(target 改 Δ,推理端自动加回 a_{t-1}) |
| `--rollout-len` | 1 | R1-B 多步展开步数 K(>1 时 scheduled sampling) |
| `--ss-prob` | 1.0 | scheduled sampling 真实历史概率(仅 rollout-len>1 生效) |
## 离线评估(R4)
`evaluate.py` 在留出 episode 上**开环滚动预测**(复现 pybot 闭环口径:图像用真值滚动、
动作用自预测回写),输出闭环代理指标。单步 MSE(teacher forcing)看不见闭环误差累积,
本评估补上这一盲区:
| 指标 | 含义 | 判读 |
|------|------|------|
| `overall_mse` | 全部滚动步的平均 MSE | 越低越好 |
| `error_growth` | 末段(后25%)误差 / 首段(前25%)误差 | >1 表示误差随步数发散(闭环不稳) |
| `error_curve` | 误差随步数的增长曲线 | 看发散拐点 |
| `smoothness_ratio` | 预测平滑度 / 真值平滑度 | <1 表示比真值更平滑(残差模式的预期收益) |
| `oob_rate` | 预测落在训练数据经验范围外的比例 | 越高越危险(舵机越界风险) |
物理可行性用数据分布的经验界(min/max + 10% margin)而非硬编码范围——18 列混含
舵机角度/频率/延时/预留,无统一硬限位。`--compare` 用于 A/B 对比(如 R1 开/关)。
滚动复现闭环口径:模型带 imu 输入时按 episode 的 `imu.npy` 真值姿态逐步喂入
(缺失则零向量,与 pybot 闭环无 `@MPU6050_BIND` 块时的行为一致)。
## 部署注意
@@ -162,13 +212,16 @@ print(predictor.action_dim, predictor.history_len, predictor.frame_len) # 公
# images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧;冷启动可重复同一帧)
action = predictor.predict(images, action_history) # np.ndarray (action_dim,)
# action_history 形状须为 (history_len, action_dim)
# 模型含 IMU 输入时(predictor.imu_dim 非 None)可传 imu= 原始姿态向量
# (roll,pitch,ax,ay,az,gx,gy,gz;内部自动归一化,None 等价零向量)
action = predictor.predict(images, action_history, imu=imu_vec)
```
## ONNX Runtime 后端(CPU 加速)
`onnx_infer.py` 的 `OnnxPredictor` 走 ONNX Runtime CPU 推理,接口与
`ActionPredictorInference` 对齐,维度从 ONNX 输入元数据自动推导(无需
checkpoint)。Pi 5 实测(224×224,2026-08-13 复测)11.4ms/次含预处理
`ActionPredictorInference` 对齐(含可选 `imu=` 入参),维度从 ONNX 输入元数据
自动推导(无需 checkpoint)。Pi 5 实测(224×224,2026-08-13 复测)11.4ms/次含预处理
(纯推理 10.7ms),PyTorch 21ms,约 2 倍加速,与 torch 前向输出差异
<1e-5。数据见
[测速报告](../docs/reports/actpred_inference_speed_benchmark_20260813.md) §6。
@@ -199,7 +252,8 @@ reshape)**无法被 DFC 解析**,必须用 `export_deploy.py` 的部署版
当前 NPU 目标分辨率为 **224×224**(pretrain checkpoint 配置),HEF 输入
为 frame0~2 各 (1,224,224,3) NHWC + action_history (1,1,5,18) +
pred_token (1,1,1,128),形状与量化参数由 `hailo_infer.py` 从 vstream
pred_token (1,1,1,128)(`--imu-dim`>0 时追加 imu (1,1,1,imu_dim),共 6 输入),
形状与量化参数由 `hailo_infer.py` 从 vstream
info 自动推导,不硬编码。
```bash
@@ -236,9 +290,10 @@ python3 dfc_compile.py # 产物 checkpoints/actpred_pretrain.hef
> 遗留:全零历史角(z≈-3.5)超出校准输入范围(-2.7)会被截断,
> 该角仅作连通性验证,非真实部署场景。
`hailo_infer.py` 的 `HailoPredictor` 适配 5 输入 HEF,输入布局与量化
`hailo_infer.py` 的 `HailoPredictor` 适配 5/6 输入 HEF(含可选 imu 输入,
按形状自动归类绑定),输入布局与量化
参数(帧归一化由量化网格 dequant(0) 自动判定)从 vstream info 自动推导,
单元测试含 fake hailo_platform 的 5 输入与乱序报告顺序用例。CLI 支持
`--pred-token` 喂入学习值、`--stats` 喂入归一化统计。
`--pred-token` 喂入学习值、`--stats` 喂入归一化统计、`--imu` 喂入姿态。
环境调研与 API 踩坑记录见
[docs/reports/actpred_hailo_hat2_inference_research_20260813.md](../docs/reports/actpred_hailo_hat2_inference_research_20260813.md)。
+22 -9
View File
@@ -78,30 +78,39 @@ tf = transforms.Compose([
])
def torch_pred(imgs, hist, use_learned_pred=True):
def torch_pred(imgs, hist, use_learned_pred=True, imu=None):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
h = torch.tensor(hist).unsqueeze(0)
if stats_path is not None:
stats = np.load(stats_path)
h = (h - torch.tensor(stats[0])) / torch.tensor(stats[1])
ii = None
if imu_dim > 0:
# 与 HailoPredictor 同口径:原始姿态 → 内部归一化前需手动归一
from imu_norm import normalize_imu
ii = torch.from_numpy(normalize_imu(imu, imu_dim)).unsqueeze(0)
if use_learned_pred:
with torch.no_grad():
return model(x, h).squeeze(0).numpy()
return model(x, h, ii).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()
out = model(x, h, ii).squeeze(0).numpy()
model.pred_token.data.copy_(saved)
return out
# R2 方案一:imu_dim>0 时每步传原始姿态(HailoPredictor 内部归一化)
imu_dim = cfg.data.imu_dim
imu_real = np.array([5.0, -3.0] + [0.0] * 6, dtype=np.float32)
with HailoPredictor(HEF_PATH, stats_path=stats_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)
a = p.predict(imgs_real, hist_real, pred_token=pred_learned, imu=imu_real)
b = p.predict(imgs_zero, hist_real, pred_token=pred_learned, imu=imu_real)
c = p.predict(imgs_real, hist_zero, pred_token=pred_learned, imu=imu_real)
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/次")
@@ -113,9 +122,14 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
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 视为输入连通)")
ok = img_sens > 0.1 and hist_sens > 0.1
if imu_dim > 0:
e = p.predict(imgs_real, hist_real, pred_token=pred_learned,
imu=np.zeros_like(imu_real))
print(f"imu sensitivity = {np.abs(a - e).max():.4f}(>0 视为姿态连通)")
ref = torch_pred(imgs_real, hist_real, True)
ref_zero_pred = torch_pred(imgs_real, hist_real, False)
ref = torch_pred(imgs_real, hist_real, True, imu_real)
ref_zero_pred = torch_pred(imgs_real, hist_real, False, imu_real)
# torch 参考是模型原始 z 空间输出;HailoPredictor 带 stats 时已反归一化
# 到原始动作空间,比较前须同空间对齐,否则归一化模型下 max_diff 无意义
if stats_path is not None:
@@ -127,6 +141,5 @@ with HailoPredictor(HEF_PATH, stats_path=stats_path) as p:
print(f"HEF vs torch(learned) max_diff={np.abs(a - ref).max():.4f}"
f"(原始动作空间)")
ok = img_sens > 0.1 and hist_sens > 0.1
print("\nPASS: HEF 输入连通,可继续与 torch 对比量化误差"
if ok else "\nFAIL: HEF 输出对输入不敏感,编译仍有问题")
+31 -10
View File
@@ -69,6 +69,17 @@ if action_stats is not None:
pred_learned = model.pred_token.data.unsqueeze(1).numpy() # (1,1,1,128)
pred_none = np.zeros((1, 1, 1, 128), dtype=np.float32)
# R2 方案一:imu_dim>0 时模型/ONNX 多了归一化姿态输入
imu_dim = cfg.data.imu_dim
imu_real = None
imu_zero = None
if imu_dim > 0:
from imu_norm import normalize_imu
# 原始姿态(roll=5°, pitch=-3°, 其余 0)→ 部署版输入布局 (1,1,1,imu_dim)
imu_real = normalize_imu([5.0, -3.0] + [0.0] * 6, imu_dim).reshape(
1, 1, 1, imu_dim)
imu_zero = np.zeros((1, 1, 1, imu_dim), dtype=np.float32)
def frames_nhwc(imgs):
"""列表帧 → (1,H,W,3) 归一化 float32 NHWC × frame_len"""
@@ -76,35 +87,45 @@ def frames_nhwc(imgs):
return [chw[i].transpose(1, 2, 0)[None] for i in range(len(imgs))]
def run_onnx(imgs, hist, pred):
def run_onnx(imgs, hist, pred, imu=None):
feeds = {f"frame{i}": f for i, f in enumerate(frames_nhwc(imgs))}
feeds["action_history"] = hist[None, None]
feeds["pred_token"] = pred
if imu_dim > 0:
feeds["imu"] = imu if imu is not None else imu_zero
return sess.run(None, feeds)[0].reshape(-1)
def run_torch(imgs, hist):
def run_torch(imgs, hist, imu=None):
x = torch.stack([tf(Image.fromarray(im)) for im in imgs]).unsqueeze(0)
hh = torch.tensor(hist).unsqueeze(0)
ii = None
if imu_dim > 0:
ii = torch.tensor(
imu if imu is not None else imu_zero).reshape(1, imu_dim).float()
with torch.no_grad():
return model(x, hh).squeeze(0).numpy()
return model(x, hh, ii).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)
a = run_onnx(imgs_real, hist_real, pred_learned, imu_real)
b = run_onnx(imgs_zero, hist_real, pred_learned, imu_real)
c = run_onnx(imgs_real, hist_zero, pred_learned, imu_real)
d = run_onnx(imgs_zero, hist_zero, pred_learned)
p0 = run_onnx(imgs_real, hist_real, pred_none)
p0 = run_onnx(imgs_real, hist_real, pred_none, imu_real)
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)
ok = np.abs(a - b).max() > 1e-4 and np.abs(a - c).max() > 1e-4
if imu_dim > 0:
e = run_onnx(imgs_real, hist_real, pred_learned, imu_zero)
print(f"imu sensitivity (a-e): max={np.abs(a - e).max():.4f}")
ref = run_torch(imgs_real, hist_real, imu_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)
ok = ok and np.abs(a - ref).max() < 1e-4
print("PASS" if ok else "FAIL")
+35
View File
@@ -13,6 +13,11 @@ class DataConfig:
data_dir: str = "./data"
normalize_actions: bool = False
action_stats_path: Optional[str] = None
# R2 方案一:MPU6050 姿态作为独立 IMU token 注入(序列 F+H+2)。
# 0 = 关闭(默认,序列保持 F+H+1,旧 checkpoint 完全兼容);
# 8×N = N 个传感器的 roll,pitch,ax,ay,az,gx,gy,gz 按 pybot 脚本
# @MPU6050_BIND 行顺序拼接(最多 4 个 → 32,pybot recorder 的 imu.npy 格式)。
imu_dim: int = 0
@dataclass
@@ -41,6 +46,14 @@ class TrainConfig:
seed: int = 42
max_grad_norm: float = 1.0
augment: bool = False
# R1-C 残差预测:True 时 target 改为 Δ=actions[t]-actions[t-1],
# 推理端 â_t = a_{t-1} + Δ̂(惯性先验,平滑动作)。默认 False 兼容旧 checkpoint。
predict_delta: bool = False
# R1-B 多步展开训练:rollout_len>1 时 dataset 返回 K 步连续序列,
# 训练用 scheduled sampling(前 ss_prob 比例步用真历史,其余用自预测回传),
# 治 exposure bias。rollout_len=1 时退化为现状单步。
rollout_len: int = 1
ss_prob: float = 1.0
@dataclass
@@ -71,6 +84,9 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--data-dir", type=str, default=None)
parser.add_argument("--normalize-actions", action=argparse.BooleanOptionalAction, default=None)
parser.add_argument("--action-stats-path", type=str, default=None)
parser.add_argument("--imu-dim", type=int, default=None,
help="R2 方案一:MPU6050 姿态维度(0=关闭,默认;"
"8×N=N 个传感器拼接(≤4 个),序列变 F+H+2)")
parser.add_argument("--backbone", type=str, default=None, choices=["simple", "mobilenet"])
parser.add_argument("--cnn-feature-dim", type=int, default=None)
parser.add_argument("--fused-hidden-dim", type=int, default=None)
@@ -90,6 +106,13 @@ def build_argparser(description: str = "") -> argparse.ArgumentParser:
parser.add_argument("--max-grad-norm", type=float, default=None)
parser.add_argument("--augment", action=argparse.BooleanOptionalAction, default=None,
help="训练时启用安全图像增强(ColorJitter:亮度/对比度/饱和度/色调)")
parser.add_argument("--predict-delta", action=argparse.BooleanOptionalAction, default=None,
help="R1-C 残差预测:target 改为 Δ=actions[t]-actions[t-1],"
"推理端 â_t=a_{t-1}+Δ̂(惯性先验平滑动作,默认关兼容旧 checkpoint)")
parser.add_argument("--rollout-len", type=int, default=None,
help="R1-B 多步展开训练步数 K(默认 1=单步;K>1 时 scheduled sampling)")
parser.add_argument("--ss-prob", type=float, default=None,
help="scheduled sampling 真实历史概率(仅 rollout-len>1 生效,默认 1.0=全真)")
parser.add_argument("--init-from", type=str, default=None,
help="预训练 checkpoint 路径,作为微调起点(strict=False 加载兼容权重)")
parser.add_argument("--resume", type=str, default=None,
@@ -111,6 +134,7 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig,
("history_len", data_cfg.history_len, 1, 1024, int),
("frame_len", data_cfg.frame_len, 1, 1024, int),
("action_dim", data_cfg.action_dim, 1, 1024, int),
("imu_dim", data_cfg.imu_dim, 0, 64, int),
("cnn_feature_dim", model_cfg.cnn_feature_dim, 1, 4096, int),
("fused_hidden_dim", model_cfg.fused_hidden_dim, 1, 4096, int),
("dropout", model_cfg.dropout, 0.0, 1.0, float),
@@ -142,6 +166,8 @@ def validate_config_values(data_cfg: DataConfig, model_cfg: ModelConfig,
("patience", train_cfg.patience, 0, 100000, int),
("num_workers", train_cfg.num_workers, 0, 256, int),
("max_grad_norm", train_cfg.max_grad_norm, 0.0, 1e6, float),
("rollout_len", train_cfg.rollout_len, 1, 1024, int),
("ss_prob", train_cfg.ss_prob, 0.0, 1.0, float),
]
for name, val, lo, hi, typ in t_checks:
if typ is int:
@@ -173,6 +199,11 @@ def warn_init_from_mismatch(init_cfg: dict, current: Config):
print(f"WARNING: init-from checkpoint 的 action_dim={init_dim} "
f"与当前运行 {current.data.action_dim} 不一致,"
f"act_proj 权重将随机初始化")
init_imu = init_data.get("imu_dim", 0) # 旧 checkpoint 无此键 → 0
if init_imu != current.data.imu_dim:
print(f"WARNING: init-from checkpoint 的 imu_dim={init_imu} "
f"与当前运行 {current.data.imu_dim} 不一致,"
f"imu_proj/pos_embed 权重将随机初始化")
def parse_checkpoint_config(cfg_dict: dict) -> Config:
@@ -218,6 +249,7 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
"data_dir": ("data", "data_dir"),
"normalize_actions": ("data", "normalize_actions"),
"action_stats_path": ("data", "action_stats_path"),
"imu_dim": ("data", "imu_dim"),
"backbone": ("model", "cnn_backbone"),
"cnn_feature_dim": ("model", "cnn_feature_dim"),
"fused_hidden_dim": ("model", "fused_hidden_dim"),
@@ -236,6 +268,9 @@ def apply_overrides(cfg: Config, args: argparse.Namespace) -> Config:
"seed": ("train", "seed"),
"max_grad_norm": ("train", "max_grad_norm"),
"augment": ("train", "augment"),
"predict_delta": ("train", "predict_delta"),
"rollout_len": ("train", "rollout_len"),
"ss_prob": ("train", "ss_prob"),
}
args_dict = vars(args)
for arg_name, (section, field) in mapping.items():
+107 -19
View File
@@ -10,6 +10,8 @@ from torch.utils.data import Dataset
from torchvision import transforms
from torchvision.transforms import functional as TF
from imu_norm import imu_scales
def _natural_key(p):
"""自然排序键:'1.jpg' < '2.jpg' < ... < '10.jpg'"""
@@ -21,13 +23,21 @@ class ActionDataset(Dataset):
image_width: int = 640, image_height: int = 480,
normalize_actions: bool = False,
action_stats_path: str = None, episode_indices: list = None,
frame_len: int = 3, augment: bool = False):
frame_len: int = 3, augment: bool = False,
predict_delta: bool = False, rollout_len: int = 1,
imu_dim: int = 0):
self.history_len = history_len
self.action_dim = action_dim
# R2 方案一:>0 时样本带 "imu" 键(归一化姿态向量,缺失 episode 补零)
self.imu_dim = imu_dim
self.image_width = image_width
self.image_height = image_height
self.frame_len = frame_len
self.augment = augment
# R1-C 残差预测:target 改为 Δ=actions[t]-actions[t-1](归一化空间内求差)
self.predict_delta = predict_delta
# R1-B 多步展开:rollout_len>1 时每个样本返回 K 步连续 (images,history,target)
self.rollout_len = max(1, rollout_len)
self.transform = transforms.Compose([
transforms.Resize((image_height, image_width)),
@@ -39,6 +49,8 @@ class ActionDataset(Dataset):
if episode_indices is not None:
episodes = [episodes[i] for i in episode_indices]
# 样本存 (image_files, actions, t_start):t_start 是 rollout 第一步的目标时刻,
# __getitem__ 按 rollout_len 展开 K 步。存原始数组引用避免重复加载/拷贝。
self.samples = []
for ep_dir in episodes:
actions_path = ep_dir / "actions.npy"
@@ -58,11 +70,13 @@ class ActionDataset(Dataset):
print(f"WARNING: {ep_dir} 动作帧数 {n_frames} 与图像数 {n_images} 不一致,"
f"按较少者({min(n_frames, n_images)})使用,请检查录制完整性")
n = min(n_frames, n_images)
# 每个样本:帧 [t-frame_len+1, t](旧→新)+ 动作历史 [t-history_len, t) → 动作 t
imus = self._load_imu(ep_dir, n)
# 第 k 步(k=0..K-1)目标时刻 t=t_start+k:帧 [t-frame_len+1, t]、
# 历史 [t-history_len, t)、目标 actions[t]。t_start 下界保证第 0 步窗口完整,
# 上界保证第 K-1 步目标不越界。
start = max(history_len, frame_len - 1)
for t in range(start, n):
self.samples.append((image_files[t - frame_len + 1: t + 1],
actions[t - history_len:t], actions[t]))
for t_start in range(start, n - (self.rollout_len - 1)):
self.samples.append((image_files, actions, imus, t_start))
self._action_mean = None
self._action_std = None
@@ -83,24 +97,34 @@ class ActionDataset(Dataset):
raise ValueError(
f"数据集为空(0 样本),无法计算归一化统计量。"
f"请检查 data_dir={data_dir} 中的 episode 数据。")
all_actions = np.stack(
[np.concatenate([s[1], s[2][None]]) for s in self.samples]
).reshape(-1, self.action_dim)
# 归一化统计基于绝对动作(与 Δ 无关):覆盖所有样本 rollout 范围内
# 出现的 history 与 target 动作,保持与旧版一致的统计口径。
all_actions = np.stack([
s[1][s[3] - self.history_len: s[3] + self.rollout_len]
for s in self.samples
]).reshape(-1, self.action_dim)
self._action_mean = all_actions.mean(axis=0)
self._action_std = all_actions.std(axis=0) + 1e-8
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
image_paths, action_history, target = self.samples[idx]
frames = []
def _norm(self, a):
"""z-score 归一化(numpy → torch 前在 numpy 空间做,保持 float32)"""
if self._action_mean is None:
return a
return (a - self._action_mean) / self._action_std
def _load_frames(self, image_files, t):
"""加载时刻 t 的 F 帧窗口 [t-frame_len+1, t](旧→新),augment 对整个窗口一致"""
paths = image_files[t - self.frame_len + 1: t + 1]
if self.augment:
bf = random.uniform(0.8, 1.2)
cf = random.uniform(0.8, 1.2)
sf = random.uniform(0.8, 1.2)
hf = random.uniform(-0.05, 0.05)
for p in image_paths:
frames = []
for p in paths:
image = Image.open(p).convert("RGB")
if self.augment:
image = TF.adjust_brightness(image, bf)
@@ -108,13 +132,77 @@ class ActionDataset(Dataset):
image = TF.adjust_saturation(image, sf)
image = TF.adjust_hue(image, hf)
frames.append(self.transform(image))
images = torch.stack(frames) # (F, 3, S, S)
action_history = torch.tensor(action_history, dtype=torch.float32)
target = torch.tensor(target, dtype=torch.float32)
if self._action_mean is not None:
action_history = (action_history - self._action_mean) / self._action_std
target = (target - self._action_mean) / self._action_std
return {"images": images, "action_history": action_history, "target": target}
return torch.stack(frames) # (F, 3, H, W)
def _step_target(self, actions, t):
"""时刻 t 的监督目标(归一化空间);predict_delta 时为 Δ=actions[t]-actions[t-1]"""
if self.predict_delta:
return self._norm(actions[t]) - self._norm(actions[t - 1])
return self._norm(actions[t])
def _load_imu(self, ep_dir, n):
"""加载 episode 的 imu.npy 并按物理量程归一化(R2)
imu_dim=0(未启用)或文件缺失 → None(样本侧补零向量,等价无姿态
先验,混合数据集可训)。形状 pad/truncate 到 (n, imu_dim)。
"""
if self.imu_dim <= 0:
return None
path = ep_dir / "imu.npy"
if not path.exists():
print(f"WARNING: {ep_dir} 无 imu.npy,该 episode 姿态按零向量处理")
return None
imus = np.load(path).astype(np.float32)
if imus.ndim != 2:
raise ValueError(
f"{path} 形状应为 (T, imu_dim),实际为 {imus.shape}")
if imus.shape[1] != self.imu_dim:
fixed = np.zeros((imus.shape[0], self.imu_dim), dtype=np.float32)
m = min(imus.shape[1], self.imu_dim)
fixed[:, :m] = imus[:, :m]
imus = fixed
if len(imus) < n: # 与帧数对齐:不足补零
imus = np.concatenate([
imus, np.zeros((n - len(imus), self.imu_dim), dtype=np.float32)])
return imus[:n] / imu_scales(self.imu_dim)
def _imu_at(self, imus, t):
"""时刻 t 的归一化姿态向量(与最新帧对齐);无数据 → 零向量"""
if imus is None:
return torch.zeros(self.imu_dim, dtype=torch.float32)
return torch.tensor(imus[t], dtype=torch.float32)
def __getitem__(self, idx):
image_files, actions, imus, t_start = self.samples[idx]
if self.rollout_len == 1:
# 单步:与旧版输出结构完全一致(imu_dim>0 时附加 "imu" 键)
images = self._load_frames(image_files, t_start)
history = torch.tensor(self._norm(
actions[t_start - self.history_len: t_start]), dtype=torch.float32)
target = torch.tensor(self._step_target(actions, t_start),
dtype=torch.float32)
out = {"images": images, "action_history": history, "target": target}
if self.imu_dim > 0:
out["imu"] = self._imu_at(imus, t_start)
return out
# 多步:返回 K 步序列,scheduled sampling 在 train 循环内按步取用
steps = []
for k in range(self.rollout_len):
t = t_start + k
step = {
"images": self._load_frames(image_files, t),
# 真实历史(scheduled sampling 的真值来源;首步必用)
"action_history": torch.tensor(
self._norm(actions[t - self.history_len: t]), dtype=torch.float32),
"target": torch.tensor(self._step_target(actions, t), dtype=torch.float32),
# 上一步真实动作(Δ 残差的基准 a_{t-1},归一化空间)
"prev_action": torch.tensor(self._norm(actions[t - 1]), dtype=torch.float32),
}
if self.imu_dim > 0:
step["imu"] = self._imu_at(imus, t)
steps.append(step)
return {"steps": steps}
def get_action_stats(self):
if self._action_mean is not None:
+12 -5
View File
@@ -36,10 +36,14 @@ import tempfile
NET_NAME = "actpred_deploy"
def start_nodes(frame_count: int) -> list:
"""export_deploy.py 的输入命名约定:frame{i} × frame_count + action_history + pred_token"""
return ([f"frame{i}" for i in range(frame_count)]
+ ["action_history", "pred_token"])
def start_nodes(frame_count: int, with_imu: bool = False) -> list:
"""export_deploy.py 的输入命名约定:frame{i} × frame_count + action_history
+ pred_token(R2 imu_dim>0 时末尾追加 imu)"""
nodes = ([f"frame{i}" for i in range(frame_count)]
+ ["action_history", "pred_token"])
if with_imu:
nodes.append("imu")
return nodes
def hailo(*args, venv_bin: str):
@@ -118,6 +122,9 @@ def main():
parser.add_argument("--frame-count", type=int, default=3,
help="帧输入个数(与导出 ONNX 的 frame_len 一致,"
"默认 3)")
parser.add_argument("--with-imu", action="store_true",
help="R2:导出 ONNX 含 imu 输入时开启(start nodes "
"末尾追加 imu,与 export_deploy.py 命名一致)")
parser.add_argument("--venv-bin", default="venv_dfc/bin",
help="安装了 hailo-sdk 的 venv bin 目录")
args = parser.parse_args()
@@ -126,7 +133,7 @@ def main():
if not os.path.exists(os.path.join(venv_bin, "hailo")):
sys.exit(f"错误: {venv_bin}/hailo 不存在,请先安装 hailo-sdk")
nodes = start_nodes(args.frame_count)
nodes = start_nodes(args.frame_count, args.with_imu)
input_formats = ["NHWC"] * len(nodes)
workdir = tempfile.mkdtemp(prefix="dfc_")
+253
View File
@@ -0,0 +1,253 @@
"""R4 离线评估基准:开环滚动预测,量化闭环代理指标
现状只有开环单步 MSE(teacher forcing),无法反映真实闭环表现。本脚本在留出
episode 上复现 pybot 闭环的开环滚动(图像用真值滚动、动作用自预测回写),
输出三类闭环代理指标:
累积误差 逐步 MSE 及随步数的增长曲线(衡量误差累积/发散)
平滑度 预测动作相邻差分方差(越低越平滑,对物理舵机越友好),与真值对比
物理可行性 预测动作落在训练数据经验范围外的比例(越界率,越高越危险)
用法:
python evaluate.py --model checkpoints/best_inference.pt --data-dir ./data
python evaluate.py --model A.pt --compare B.pt --data-dir ./data # A/B 对比
python evaluate.py --model m.pt --data-dir ./data --episodes 3 --horizon 50 \
--output report.json
"""
import argparse
import json
import numpy as np
from PIL import Image
from dataset import ActionDataset
from predict import ActionPredictorInference
def _episode_ids(data_dir):
return [d.name for d in ActionDataset._valid_episode_dirs(data_dir)]
def _load_episode(data_dir, ep_name):
"""加载单个 episode 的图像路径(自然序)、动作真值 (T, D)、姿态真值 (T, imu_dim)
imu.npy 缺失时第三项返回 None(旧数据兼容,滚动时按零向量喂入)。
姿态保持原始量纲——predict 端内部归一化,与 pybot 闭环口径一致。
"""
ep_dir = [d for d in ActionDataset._valid_episode_dirs(data_dir)
if d.name == ep_name][0]
from dataset import _natural_key
images_dir = ep_dir / "images"
image_files = sorted(list(images_dir.glob("*.jpg")) +
list(images_dir.glob("*.png")), key=_natural_key)
actions = np.load(ep_dir / "actions.npy").astype(np.float32)
n = min(len(image_files), len(actions))
imus = None
imu_path = ep_dir / "imu.npy"
if imu_path.exists():
imus = np.load(imu_path).astype(np.float32)
if imus.ndim != 2:
raise ValueError(f"{imu_path} 形状应为 (T, imu_dim),实际为 {imus.shape}")
imus = imus[:n]
return image_files[:n], actions[:n], imus
def _action_bounds(data_dir, action_dim, margin_ratio=0.1):
"""从全数据集统计动作经验范围(min/max 加 margin),作为物理可行性判据。
actpred 侧无硬限位定义(18 列混含舵机角度/频率/延时/预留),硬编码范围不可靠;
用数据分布的经验界更通用。margin 默认 10% 容忍合理外推。
"""
mins = np.full(action_dim, np.inf, dtype=np.float32)
maxs = np.full(action_dim, -np.inf, dtype=np.float32)
for ep_dir in ActionDataset._valid_episode_dirs(data_dir):
a = np.load(ep_dir / "actions.npy").astype(np.float32)
mins = np.minimum(mins, a.min(axis=0))
maxs = np.maximum(maxs, a.max(axis=0))
span = np.maximum(maxs - mins, 1e-6)
return mins - margin_ratio * span, maxs + margin_ratio * span
@np.errstate(all="ignore")
def rollout_episode(predictor, image_files, actions, horizon=None,
lo=None, hi=None, imus=None):
"""单 episode 开环滚动,复现闭环口径(图像真值滚动、动作自预测回写)。
从 t0 = max(history_len, frame_len-1) 起步(窗口完整处),每步:
帧窗 = 真值图像 [t-frame_len+1, t](旧→新)
历史 = 前 H 步动作(首步用真值填充,之后用自预测回写,复刻 run_loop)
姿态 = imus[t](predictor.imu_dim 非 None 时传入;imus 为 None 或
长度不足按零向量 = 无姿态先验)
预测 â_t → 与真值 a_t 比较 → 回写历史
返回逐步记录 dict。horizon=None 表示滚到 episode 末尾。
"""
F, H = predictor.frame_len, predictor.history_len
use_imu = getattr(predictor, "imu_dim", None) is not None
t0 = max(H, F - 1)
n = len(actions)
if horizon is None:
horizon = n - t0
horizon = min(horizon, n - t0)
if horizon <= 0:
return None
cache = {}
def load(t):
if t not in cache:
cache[t] = np.array(Image.open(image_files[t]).convert("RGB"))
return cache[t]
history = [actions[t0 - H + k].copy() for k in range(H)] # 真值冷启动
steps = []
for k in range(horizon):
t = t0 + k
frames = [load(t - F + 1 + i) for i in range(F)]
kwargs = {}
if use_imu:
kwargs["imu"] = (imus[t] if imus is not None and t < len(imus)
else None)
pred = np.asarray(
predictor.predict(frames, np.array(history), **kwargs),
dtype=np.float32).reshape(-1)
true = actions[t]
err = pred - true
rec = {
"t": int(t),
"mse": float(np.mean(err ** 2)),
"pred": pred,
"true": true,
}
if lo is not None:
rec["oob"] = bool(np.any((pred < lo) | (pred > hi)))
steps.append(rec)
history = history[1:] + [pred.copy()] # 自预测回写(复刻 run_loop)
return steps
def _smoothness(seq):
"""相邻差分方差(多维取均值);序列 <2 返回 0。越低越平滑。"""
seq = np.asarray(seq, dtype=np.float32)
if len(seq) < 2:
return 0.0
return float(np.mean(np.var(np.diff(seq, axis=0), axis=0)))
def summarize(all_steps, lo, hi):
"""汇总多个 episode 的滚动记录为报告指标"""
if not all_steps:
raise ValueError("无可评估的滚动步(episode 太短或 horizon 过小)")
flat = [s for steps in all_steps for s in steps]
mses = np.array([s["mse"] for s in flat], dtype=np.float32)
# 误差随步数增长曲线:按相对步位置对齐各 episode,取每位置的平均 MSE
max_len = max(len(s) for s in all_steps)
curve = []
for k in range(max_len):
vals = [s[k]["mse"] for s in all_steps if k < len(s)]
if vals:
curve.append(float(np.mean(vals)))
preds = np.array([s["pred"] for s in flat], dtype=np.float32)
trues = np.array([s["true"] for s in flat], dtype=np.float32)
oob = [s.get("oob", False) for s in flat]
return {
"n_episodes": len(all_steps),
"n_steps": len(flat),
"overall_mse": float(np.mean(mses)),
# 末段(后 25% 步)相对首段(前 25%)的误差比,>1 表示误差随步数发散
"error_growth": float(np.mean(mses[int(len(mses) * 0.75):]) /
max(np.mean(mses[:max(1, int(len(mses) * 0.25))]),
1e-12)),
"error_curve": curve,
"pred_smoothness": _smoothness(preds),
"true_smoothness": _smoothness(trues),
# 平滑度比:<1 表示预测比真值还平滑(好),>1 表示预测更抖(坏)
"smoothness_ratio": _smoothness(preds) / max(_smoothness(trues), 1e-12),
"oob_rate": float(np.mean(oob)) if lo is not None else None,
}
def evaluate(checkpoint, data_dir, episodes=None, horizon=None, device=None):
predictor = ActionPredictorInference(checkpoint, device)
ep_names = _episode_ids(data_dir)
if episodes is not None:
ep_names = ep_names[-episodes:] # 取尾部 episode 作为留出集
lo, hi = _action_bounds(data_dir, predictor.action_dim)
all_steps = []
for name in ep_names:
image_files, actions, imus = _load_episode(data_dir, name)
steps = rollout_episode(predictor, image_files, actions, horizon,
lo, hi, imus=imus)
if steps:
all_steps.append(steps)
rep = summarize(all_steps, lo, hi)
rep["checkpoint"] = checkpoint
rep["frame_len"] = predictor.frame_len
rep["history_len"] = predictor.history_len
rep["imu_dim"] = getattr(predictor, "imu_dim", None)
rep["predict_delta"] = getattr(predictor, "_predict_delta", False)
return rep
def _fmt(rep):
lines = [
f"checkpoint : {rep['checkpoint']}",
f"predict_delta : {rep['predict_delta']}",
f"imu_dim : {rep.get('imu_dim')}",
f"episodes/steps : {rep['n_episodes']} / {rep['n_steps']}",
f"overall_mse : {rep['overall_mse']:.6f}",
f"error_growth : {rep['error_growth']:.3f} (>1 = 误差随步数发散)",
f"smoothness_ratio: {rep['smoothness_ratio']:.3f} (<1 = 比真值更平滑)",
f" pred/true : {rep['pred_smoothness']:.4f} / {rep['true_smoothness']:.4f}",
]
if rep["oob_rate"] is not None:
lines.append(f"oob_rate : {rep['oob_rate']:.4f} (越界率, 越低越安全)")
return "\n".join(lines)
def main():
p = argparse.ArgumentParser(description="actpred 离线闭环代理评估(R4)")
p.add_argument("--model", required=True, help="主 checkpoint 路径")
p.add_argument("--data-dir", required=True, help="episode 数据目录")
p.add_argument("--compare", default=None, help="可选对比 checkpoint(A/B)")
p.add_argument("--episodes", type=int, default=None,
help="评估用尾部 episode 数(默认全部)")
p.add_argument("--horizon", type=int, default=None,
help="每 episode 滚动步数上限(默认滚到末尾)")
p.add_argument("--device", default=None)
p.add_argument("--output", default=None, help="保存 JSON 报告路径")
args = p.parse_args()
rep = evaluate(args.model, args.data_dir, args.episodes, args.horizon,
args.device)
print("== 主模型 ==")
print(_fmt(rep))
result = {"main": rep}
if args.compare:
rep_b = evaluate(args.compare, args.data_dir, args.episodes,
args.horizon, args.device)
print("\n== 对比模型 ==")
print(_fmt(rep_b))
# 关键指标对比(负值=主模型更优)
result["compare"] = rep_b
result["delta"] = {
"overall_mse": rep["overall_mse"] - rep_b["overall_mse"],
"error_growth": rep["error_growth"] - rep_b["error_growth"],
"smoothness_ratio": rep["smoothness_ratio"] - rep_b["smoothness_ratio"],
}
d = result["delta"]
print("\n== 主 − 对比 (负值=主模型更优) ==")
print(f"overall_mse : {d['overall_mse']:+.6f}")
print(f"error_growth : {d['error_growth']:+.3f}")
print(f"smoothness_ratio: {d['smoothness_ratio']:+.3f}")
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\n报告已保存: {args.output}")
if __name__ == "__main__":
main()
+28 -7
View File
@@ -114,6 +114,11 @@ class DeployWrapper(nn.Module):
# 避免 DFC 对 3D Linear 的错误卷积融合
self.act_w = nn.Parameter(m.act_proj.weight.data.t().clone()) # (D, d)
self.act_b = nn.Parameter(m.act_proj.bias.data.clone())
# R2 方案一:IMU 独立 token(imu_dim>0 时追加图输入,同 MatMul+Add 手法)
self._imu_dim = m._imu_dim
if self._imu_dim > 0:
self.imu_w = nn.Parameter(m.imu_proj.weight.data.t().clone())
self.imu_b = nn.Parameter(m.imu_proj.bias.data.clone())
# pred_token 改为图输入(常量 initializer 会被 DFC 误转置),
# 位置编码经 concat 后的 ew_add 广播可被正确推断
self.pos_embed4 = nn.Parameter(m.pos_embed.data.unsqueeze(1).clone()) # (1, 1, S, d)
@@ -128,14 +133,20 @@ class DeployWrapper(nn.Module):
return self.pool(x) # (1, d, 1, 1) NCHW
def forward(self, frame0: torch.Tensor, frame1: torch.Tensor, frame2: torch.Tensor,
action_history: torch.Tensor, pred_token: torch.Tensor) -> torch.Tensor:
action_history: torch.Tensor, pred_token: torch.Tensor,
imu: torch.Tensor = None) -> torch.Tensor:
# 全程 (1, 1, S, d) 布局:seq 占 W 维,避免任何 transpose
frames = [frame0, frame1, frame2]
feats = [self._frame_feat(frames[i], i).permute(0, 2, 3, 1)
for i in range(self._frame_len)] # (1, 1, 1, d) NHWC
img_tokens = torch.cat(feats, dim=2) + self.proj_bias # (1, 1, F, d)
act_tokens = torch.matmul(action_history, self.act_w) + self.act_b # (1, 1, H, d)
tokens = torch.cat([img_tokens, act_tokens, pred_token], dim=2) # (1, 1, S, d)
parts = [img_tokens, act_tokens]
if self._imu_dim > 0:
# imu: (1, 1, 1, imu_dim) 归一化姿态 → (1, 1, 1, d),位于 PRED 之前
parts.append(torch.matmul(imu, self.imu_w) + self.imu_b)
parts.append(pred_token)
tokens = torch.cat(parts, dim=2) # (1, 1, S, d)
tokens = tokens + self.pos_embed4
for blk in self.blocks:
tokens = blk(tokens)
@@ -232,18 +243,25 @@ def main():
dummy_frames = [torch.randn(1, h, w, 3) for _ in range(f)]
dummy_history = torch.randn(1, 1, cfg.data.history_len, cfg.data.action_dim)
dummy_pred = model.pred_token.data.unsqueeze(1).clone() # (1, 1, 1, d)
imu_dim = cfg.data.imu_dim
# imu_dim>0 时追加归一化姿态输入(均匀采样的归一化值域)
dummy_imu = torch.randn(1, 1, 1, imu_dim) if imu_dim > 0 else None
extra_inputs = (dummy_imu,) if imu_dim > 0 else ()
extra_names = ["imu"] if imu_dim > 0 else []
with torch.no_grad():
ref = wrapper(*dummy_frames, dummy_history, dummy_pred)
ref = wrapper(*dummy_frames, dummy_history, dummy_pred, *extra_inputs)
torch.onnx.export(
wrapper, (*dummy_frames, dummy_history, dummy_pred),
wrapper, (*dummy_frames, dummy_history, dummy_pred, *extra_inputs),
args.output,
input_names=[f"frame{i}" for i in range(f)] + ["action_history", "pred_token"],
input_names=[f"frame{i}" for i in range(f)] + ["action_history", "pred_token"] + extra_names,
output_names=["predicted_action"],
opset_version=18,
)
print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token)")
print(f"部署版 ONNX 已导出: {args.output}({f} 个帧输入 (1,{h},{w},3) NHWC + action_history + pred_token"
f"{' + imu' if imu_dim > 0 else ''})")
if args.pred_token_out:
import numpy as np
@@ -252,7 +270,8 @@ def main():
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))
orig_out = model(stacked, dummy_history.squeeze(1),
dummy_imu.squeeze(1).squeeze(1) if imu_dim > 0 else None)
print(f"wrapper 与原模型最大差异: {(ref.squeeze(1) - orig_out).abs().max():.6e}")
try:
@@ -262,6 +281,8 @@ def main():
feeds = {f"frame{i}": dummy_frames[i].numpy() for i in range(f)}
feeds["action_history"] = dummy_history.numpy()
feeds["pred_token"] = dummy_pred.numpy()
if imu_dim > 0:
feeds["imu"] = dummy_imu.numpy()
ort_out = sess.run(None, feeds)[0]
print(f"ONNX 一致性验证通过(最大差异: {np.abs(ort_out - ref.numpy()).max():.6e})")
except ImportError:
+18 -11
View File
@@ -23,31 +23,38 @@ def main():
dummy_images = torch.randn(
1, data_cfg.frame_len, 3, data_cfg.image_height, data_cfg.image_width)
dummy_history = torch.randn(1, data_cfg.history_len, data_cfg.action_dim)
# R2 方案一:imu_dim>0 时追加归一化姿态输入 (1, imu_dim)
imu_dim = data_cfg.imu_dim
extra_inputs = (torch.randn(1, imu_dim),) if imu_dim > 0 else ()
extra_names = ["imu"] if imu_dim > 0 else []
batch = Dim("batch")
dynamic = {"images": {0: batch}, "action_history": {0: batch}}
if imu_dim > 0:
dynamic["imu"] = {0: batch}
torch.onnx.export(
model, (dummy_images, dummy_history),
model, (dummy_images, dummy_history, *extra_inputs),
args.output,
input_names=["images", "action_history"],
input_names=["images", "action_history"] + extra_names,
output_names=["predicted_action"],
dynamic_shapes={
"images": {0: batch},
"action_history": {0: batch},
},
dynamic_shapes=dynamic,
opset_version=18,
)
print(f"ONNX model exported to: {args.output}")
print(f"ONNX model exported to: {args.output}"
f"{'(含 imu 输入)' if imu_dim > 0 else ''}")
# 可选:验证导出的 ONNX 模型一致性
try:
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession(args.output)
img_np = dummy_images.numpy()
hist_np = dummy_history.numpy()
ort_out = sess.run(None, {"images": img_np, "action_history": hist_np})[0]
feeds = {"images": dummy_images.numpy(),
"action_history": dummy_history.numpy()}
for name, t in zip(extra_names, extra_inputs):
feeds[name] = t.numpy()
ort_out = sess.run(None, feeds)[0]
with torch.no_grad():
torch_out = model(dummy_images, dummy_history).numpy()
torch_out = model(dummy_images, dummy_history, *extra_inputs).numpy()
max_diff = np.abs(ort_out - torch_out).max()
print(f"ONNX 一致性验证通过(最大差异: {max_diff:.6e})")
except ImportError:
+11 -1
View File
@@ -137,14 +137,24 @@ def build_deploy_set(args):
size=jitter.shape).astype(np.float32)
pred = np.broadcast_to(v, (args.samples, 1, 1, d)).astype(np.float32) + jitter
inputs = [*frames, history, pred]
if cfg.imu_dim > 0:
# R2 方案一:imu 输入为归一化姿态(roll,pitch/180,ax..gz/32768)。
# 校准覆盖典型工作范围:归一化值域 ±0.5(≈±90°/±16384)均匀采样
imu = rng.uniform(-0.5, 0.5,
size=(args.samples, 1, 1, cfg.imu_dim)).astype(np.float32)
inputs.append(imu)
np.savez_compressed(
args.output,
**{f"actpred_deploy/input_layer{i + 1}": arr
for i, arr in enumerate([*frames, history, pred])})
for i, arr in enumerate(inputs)})
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}"
f"(学习值 ±{args.pred_jitter:.0%} 抖动)")
if cfg.imu_dim > 0:
print(f" imu: {inputs[-1].shape}(归一化值域 ±0.5 均匀采样)")
def main():
+41 -9
View File
@@ -6,9 +6,10 @@ HEF 由 actpred ONNX 经 Hailo DFC 编译生成(量化校准后)。本封装
vstream 名称/形状/量化参数,全部从 HEF 的 input/output vstream info 动态推导,
支持两种布局:
新版 5 输入(export_deploy.py + dfc_compile.py 产物):
新版 5/6 输入(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)
+ imu (1,1,1,imu_dim)(R2 方案一可选输入,原始姿态内部归一化)
旧版单图像输入:
(1, F, 3, H, W) → NHWC uint8 (1, F, H, W, 3)
或 (1, H, W, F*3) → 帧沿通道拼接;action_history (1, H, D)
@@ -34,6 +35,8 @@ import re
import numpy as np
from PIL import Image
from imu_norm import normalize_imu
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)
@@ -87,8 +90,8 @@ class HailoPredictor:
raise ValueError(
f"HEF 应只有一个输出 vstream,实际 {len(self._out_infos)}")
self._out_info = self._out_infos[0]
self._frame_infos, self._history_info, self._pred_info = \
self._classify_inputs()
self._frame_infos, self._history_info, self._pred_info, \
self._imu_info = self._classify_inputs()
self._derive_dims()
self._action_mean = None
@@ -110,11 +113,12 @@ class HailoPredictor:
# ---------------- 输入分类与形状推导 ----------------
def _classify_inputs(self):
"""将 HEF 输入分为 帧列表 / 动作历史 / pred_token(可能为 None)
"""将 HEF 输入分为 帧列表 / 动作历史 / pred_token / imu(后两者可为 None)
多帧输入的绑定顺序按 input_layerN 数字后缀排序,而非 vstream
报告顺序:hailort 5.3.0 实测报告顺序被打乱(1,5,4,3,2),
按报告顺序绑定会导致 frame1/frame2 错位。
imu 为 R2 方案一新增输入(1,1,1,imu_dim),旧 HEF 无此输入。
"""
ins = list(self._in_infos)
out_dim = tuple(self._out_info.shape)[-1]
@@ -127,24 +131,26 @@ class HailoPredictor:
# 旧版:单一 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
history, pred, imu = None, 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
elif len(s) == 4 and s[0] == 1 and s[1] == 1 and s[2] == 1:
imu = i # R2:imu (1,1,1,imu_dim)
else:
raise ValueError(
f"无法识别 HEF 输入 {i.name} 形状 {s}(期望动作历史 "
f"(1,H_len,D) 或 pred_token (1,1,d))")
f"(1,H_len,D)、pred_token (1,1,d) 或 imu (1,1,1,imu_dim))")
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
return frames, history, pred, imu
def _derive_dims(self):
# 帧输入布局
@@ -185,6 +191,11 @@ class HailoPredictor:
h_shape = tuple(self._history_info.shape)
self.history_len, self.action_dim = h_shape[1], h_shape[2]
# R2:imu 输入存在时暴露 imu_dim(pybot 闭环鸭子类型据此传 imu=),
# 无则 None(旧 HEF 保持旧两参调用路径)
self.imu_dim = tuple(self._imu_info.shape)[-1] \
if self._imu_info is not None else None
out_shape = tuple(self._out_info.shape)
out_dim = out_shape[-1]
if out_dim != self.action_dim:
@@ -206,8 +217,11 @@ class HailoPredictor:
# ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray,
pred_token: np.ndarray = None) -> np.ndarray:
pred_token: np.ndarray = None,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,HEF 含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。pred_token 仅新版 5 输入 HEF
需要;默认喂零,抖动校准的新 HEF 建议喂 checkpoint 学习值。
@@ -254,6 +268,16 @@ class HailoPredictor:
0, 255).astype(np.uint8)
bindings.input(self._pred_info.name).set_buffer(values)
if self._imu_info is not None:
i_shape = tuple(self._imu_info.shape)
# 原始姿态 → 物理量程归一化(与训练 dataset 同口径)
values = normalize_imu(imu, i_shape[-1]).reshape(i_shape)
if self._imu_info.format.type == FormatType.UINT8:
q = self._imu_info.quant_info
values = np.clip(np.round(values / q.qp_scale + q.qp_zp),
0, 255).astype(np.uint8)
bindings.input(self._imu_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)
@@ -325,11 +349,18 @@ def main():
parser.add_argument("--pred-token", default=None,
help="pred_token .npy 路径(export_deploy.py "
"--pred-token-out 产物;缺省喂零)")
parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);HEF 含 imu 输入且缺省时按零向量")
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
imu = None
if args.imu is not None:
imu = np.array([float(v) for v in args.imu.split(",")],
dtype=np.float32)
with HailoPredictor(args.hef, stats_path=args.stats) as predictor:
if len(args.images) != predictor.frame_len:
raise SystemExit(
@@ -337,7 +368,8 @@ 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, pred_token=pred_token)
predicted = predictor.predict(images, history, pred_token=pred_token,
imu=imu)
if args.output_format == "json":
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
+48
View File
@@ -0,0 +1,48 @@
"""IMU 姿态归一化 —— 训练(dataset)与推理(predict / hailo_infer)共用
量纲差异大(pybot MPU6050 原始输出,见 pybot/hd/mpu6050.py):
roll/pitch 角度制,±180°
ax/ay/az 加速度计原始 int16(±8g 量程,1g≈4096 LSB)
gx/gy/gz 陀螺仪原始 int16(±500°/s 量程)
按物理量程做固定缩放归一化(不依赖数据统计文件,部署链零状态):
索引 0,1 /180 → ≈[-1, 1]
索引 2..7 /32768 → ≈[-1, 1]
单传感器键序与 pybot robot.IMU_KEYS / recorder 一致:
[roll, pitch, ax, ay, az, gx, gy, gz]
多传感器(最多 4 个)按 pybot 脚本 @MPU6050_BIND 行顺序拼接,
每传感器占 8 槽,imu_dim = 8×N,归一化除数按 8 槽模式平铺。
仅依赖 numpy,保证 hailo_infer.py(HAT 部署环境无 torch)可直接复用。
"""
import numpy as np
# 各索引归一化除数(单传感器 8 槽;多传感器用 imu_scales() 平铺)
IMU_SCALES = np.array([180.0, 180.0,
32768.0, 32768.0, 32768.0,
32768.0, 32768.0, 32768.0], dtype=np.float32)
def imu_scales(imu_dim: int) -> np.ndarray:
"""归一化除数向量:8 槽模式按传感器数平铺(imu_dim = 8×N,N≤4)
imu_dim ≤ 8 时等价 IMU_SCALES 前缀(roll/pitch 子集仍可用)。
"""
reps = (imu_dim + len(IMU_SCALES) - 1) // len(IMU_SCALES)
return np.tile(IMU_SCALES, reps)[:imu_dim]
def normalize_imu(imu, imu_dim: int = 8) -> np.ndarray:
"""原始姿态向量 → 归一化向量(pad/truncate 到 imu_dim)
imu: list/np.ndarray,长度任意(None 等价全零)。
返回 (imu_dim,) float32。
"""
vec = np.zeros(imu_dim, dtype=np.float32)
if imu is not None:
src = np.asarray(imu, dtype=np.float32).ravel()
n = min(len(src), imu_dim)
vec[:n] = src[:n]
return vec / imu_scales(imu_dim)
+18 -4
View File
@@ -89,7 +89,8 @@ class PredictionHead(nn.Module):
class ActionPredictor(nn.Module):
"""多帧图像 + 历史动作的 Transformer 注意力融合预测器
token 序列:[img×F, act×H, PRED],长度固定为 F+H+1,
token 序列:[img×F, act×H, (IMU), PRED],长度固定为 F+H+1(imu_dim=0)
或 F+H+2(imu_dim>0,R2 方案一:IMU 独立 token 位于 PRED 之前),
取 PRED 位置输出进预测头回归下一时间步动作。
除 batch 维外全部维度静态,保证 ONNX/NPU 部署友好。
"""
@@ -107,7 +108,12 @@ class ActionPredictor(nn.Module):
self.img_proj = nn.Linear(model_cfg.cnn_feature_dim, d)
self.act_proj = nn.Linear(data_cfg.action_dim, d)
seq_len = data_cfg.frame_len + data_cfg.history_len + 1
self._imu_dim = data_cfg.imu_dim
if self._imu_dim > 0:
self.imu_proj = nn.Linear(self._imu_dim, d)
seq_len = data_cfg.frame_len + data_cfg.history_len + 1 \
+ (1 if self._imu_dim > 0 else 0)
self.pred_token = nn.Parameter(torch.zeros(1, 1, d))
self.pos_embed = nn.Parameter(torch.zeros(1, seq_len, d))
nn.init.trunc_normal_(self.pred_token, std=0.02)
@@ -131,8 +137,10 @@ class ActionPredictor(nn.Module):
self._image_height = data_cfg.image_height
self._cnn_feature_dim = model_cfg.cnn_feature_dim
def forward(self, images: torch.Tensor, action_history: torch.Tensor) -> torch.Tensor:
def forward(self, images: torch.Tensor, action_history: torch.Tensor,
imu: torch.Tensor = None) -> torch.Tensor:
# images: (B, F, 3, H, W) action_history: (B, H_len, D)
# imu: (B, imu_dim) 已归一化;None 时填零(无姿态先验)
b = images.size(0)
frames = images.reshape(
b * self._frame_len, 3, self._image_height, self._image_width)
@@ -141,7 +149,13 @@ class ActionPredictor(nn.Module):
img_tokens = self.img_proj(img_feat)
act_tokens = self.act_proj(action_history)
pred_token = self.pred_token.expand(b, -1, -1)
tokens = torch.cat([img_tokens, act_tokens, pred_token], dim=1)
parts = [img_tokens, act_tokens]
if self._imu_dim > 0:
if imu is None:
imu = images.new_zeros(b, self._imu_dim)
parts.append(self.imu_proj(imu).unsqueeze(1))
parts.append(pred_token)
tokens = torch.cat(parts, dim=1)
tokens = tokens + self.pos_embed
out = self.encoder(tokens)
return self.head(out[:, -1])
+33 -10
View File
@@ -1,10 +1,11 @@
"""actpred ONNX Runtime 推理封装 —— CPU 后端(Pi 5 实测 5.9ms/次,PyTorch 的 ~5 倍)
接口对齐 predict.ActionPredictorInference:predict(images, action_history)。
接口对齐 predict.ActionPredictorInference:predict(images, action_history, imu=None)。
模型维度从 ONNX session 输入元数据动态推导(images: [1,F,3,H,W],
action_history: [1,H,D]),无需 checkpoint。
图像预处理复现训练时的 dataset.py 流水线:Resize → /255 → ImageNet 归一化。
action_history: [1,H,D];R2 imu_dim>0 的模型另有 imu: [1,imu_dim]),
无需 checkpoint。图像预处理复现训练时的 dataset.py 流水线:
Resize → /255 → ImageNet 归一化。
背景见 docs/reports/actpred_inference_speed_benchmark_20260813.md。
"""
@@ -16,6 +17,8 @@ import os
import numpy as np
from PIL import Image
from imu_norm import normalize_imu
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)
@@ -54,11 +57,16 @@ class OnnxPredictor:
inputs = {i.name: [d if isinstance(d, int) else -1
for d in i.shape] for i in self._session.get_inputs()}
img_shape, hist_shape = self._locate_inputs(inputs)
img_shape, hist_shape, imu_shape = self._locate_inputs(inputs)
_, self.frame_len, _, self.image_height, self.image_width = img_shape
_, self.history_len, self.action_dim = hist_shape
self._image_name = next(n for n, s in inputs.items() if len(s) == 5)
self._history_name = next(n for n, s in inputs.items() if len(s) == 3)
# R2 方案一:2 维输入 [1, imu_dim] 为姿态(无则 None)。
# pybot 闭环鸭子类型约定:None 时按旧两参调用。
self.imu_dim = imu_shape[-1] if imu_shape is not None else None
self._imu_name = (next(n for n, s in inputs.items() if len(s) == 2)
if imu_shape is not None else None)
self._action_mean = None
self._action_std = None
@@ -75,17 +83,19 @@ class OnnxPredictor:
@staticmethod
def _locate_inputs(inputs):
"""按维度数识别图像(5维)/动作历史(3维)输入,返回各自形状"""
"""按维度数识别图像(5维)/动作历史(3维)/姿态(2维,可选)输入,返回各自形状"""
img = [s for s in inputs.values() if len(s) == 5]
hist = [s for s in inputs.values() if len(s) == 3]
if len(img) != 1 or len(hist) != 1:
imu = [s for s in inputs.values() if len(s) == 2]
if len(img) != 1 or len(hist) != 1 or len(imu) > 1:
raise ValueError(
"ONNX 输入应为 1 个 5 维图像 + 1 个 3 维动作历史,"
"ONNX 输入应为 1 个 5 维图像 + 1 个 3 维动作历史"
"(R2 可再加 1 个 2 维 imu),"
f"实际: {inputs}")
if img[0][2] != 3:
raise ValueError(
f"图像输入形状应为 [1,F,3,H,W],实际为 {img[0]}")
return img[0], hist[0]
return img[0], hist[0], (imu[0] if imu else None)
# ---------------- 生命周期 ----------------
@@ -101,8 +111,11 @@ class OnnxPredictor:
# ---------------- 推理 ----------------
def predict(self, images, action_history: np.ndarray) -> np.ndarray:
def predict(self, images, action_history: np.ndarray,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像列表(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,模型含 imu
输入时生效,内部按物理量程归一化;None 等价零向量)
返回 (action_dim,) float32 动作向量。
"""
@@ -128,6 +141,9 @@ class OnnxPredictor:
self._image_name: frames[np.newaxis], # (1,F,3,S,S)
self._history_name: action_history[np.newaxis], # (1,H,D)
}
if self._imu_name is not None:
feeds[self._imu_name] = normalize_imu(
imu, self.imu_dim)[np.newaxis] # (1,imu_dim)
result = self._session.run(None, feeds)[0].reshape(-1).astype(np.float32)
if self._action_mean is not None:
result = result * self._action_std + self._action_mean
@@ -152,6 +168,9 @@ def main():
help="冒号分隔各步,每步 action_dim 个值")
parser.add_argument("--stats", default=None,
help="action_stats.npy 路径(训练时启用归一化才需要)")
parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);模型含 imu 输入且缺省时按零向量")
parser.add_argument("--output-format", choices=["json", "raw"],
default="json")
args = parser.parse_args()
@@ -163,7 +182,11 @@ 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)
imu = None
if args.imu is not None:
imu = np.array([float(v) for v in args.imu.split(",")],
dtype=np.float32)
predicted = predictor.predict(images, history, imu=imu)
if args.output_format == "json":
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
+31 -4
View File
@@ -7,6 +7,7 @@ from PIL import Image
from torchvision import transforms
from config import parse_checkpoint_config
from imu_norm import normalize_imu
from model import ActionPredictor
@@ -29,6 +30,14 @@ class ActionPredictorInference:
self.action_dim = self._data_cfg.action_dim
self.history_len = self._data_cfg.history_len
self.frame_len = self._data_cfg.frame_len
# R2 方案一:imu_dim>0 时 predict 接受 imu= 原始姿态向量(内部归一化)。
# pybot 闭环鸭子类型约定:此属性为 None 时按旧两参调用,
# 故 imu_dim=0(旧 checkpoint)必须映射为 None。
self.imu_dim = self._data_cfg.imu_dim or None
# R1-C 残差预测:checkpoint 记录 predict_delta=True 时,模型输出 Δ̂,
# 需在归一化空间加回 a_{t-1}(history 末步)得到绝对动作。
# 旧 checkpoint 无该字段,getattr 默认 False,行为与旧版一致。
self._predict_delta = getattr(cfg.train, "predict_delta", False)
self._transform = transforms.Compose([
transforms.Resize((self._data_cfg.image_height,
@@ -63,8 +72,12 @@ class ActionPredictorInference:
self._action_std = torch.from_numpy(stats[1].astype(np.float32)).to(self._device)
@torch.no_grad()
def predict(self, images, action_history: np.ndarray) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像序列(旧→新,末帧为当前帧)"""
def predict(self, images, action_history: np.ndarray,
imu: np.ndarray = None) -> np.ndarray:
"""images: 长度为 frame_len 的 RGB 图像序列(旧→新,末帧为当前帧)
imu: 可选原始姿态向量(roll,pitch,ax,ay,az,gx,gy,gz,imu_dim>0 时
生效,内部按物理量程归一化;None 等价零向量 = 无姿态先验)
"""
if not isinstance(images, (list, tuple)):
images = [images]
if len(images) != self._data_cfg.frame_len:
@@ -84,7 +97,15 @@ class ActionPredictorInference:
if self._action_mean is not None:
history = (history - self._action_mean) / self._action_std
history = history.unsqueeze(0).to(self._device)
pred = self._model(img_tensor, history).squeeze(0)
imu_tensor = None
if self._data_cfg.imu_dim > 0:
imu_tensor = torch.from_numpy(
normalize_imu(imu, self._data_cfg.imu_dim)
).unsqueeze(0).to(self._device)
pred = self._model(img_tensor, history, imu_tensor).squeeze(0) # 归一化空间
if self._predict_delta:
# 残差:Δ̂ 加回 a_{t-1}(history 末步,同为归一化空间)→ 绝对动作
pred = history[0, -1] + pred
if self._action_mean is not None:
pred = pred * self._action_std + self._action_mean
return pred.cpu().numpy()
@@ -111,6 +132,9 @@ def main():
parser.add_argument("--action-history", required=True,
help="冒号分隔各步,每步 action_dim 个值,共 history_len 步。"
"e.g. '0,0,0,0:1,0,0,0' (action_dim=4, history_len=2)")
parser.add_argument("--imu", type=str, default=None,
help="可选姿态向量(逗号分隔,原始量纲:roll,pitch 度 / "
"ax..gz int16 原始值);模型 imu_dim>0 且缺省时按零向量")
parser.add_argument("--output-format", choices=["json", "raw"], default="json")
parser.add_argument("--device", type=str, default=None)
args = parser.parse_args()
@@ -124,7 +148,10 @@ 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, action_dim)
predicted = predictor.predict(images, history)
imu = None
if args.imu is not None:
imu = np.array([float(v) for v in args.imu.split(",")], dtype=np.float32)
predicted = predictor.predict(images, history, imu=imu)
if args.output_format == "json":
print(json.dumps({"predicted_action": predicted.tolist()}, indent=2))
+806
View File
@@ -1509,5 +1509,811 @@ class TestDfcCompile(unittest.TestCase):
self.assertIn("n/input_layer2", str(ctx.exception))
class TestR1PredictDelta(unittest.TestCase):
"""R1-C 残差预测:dataset Δ target 构造与 predict 逆变换"""
@staticmethod
def _create_episode(base_dir, actions, image_size=16):
ep_dir = base_dir / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
n = len(actions)
for i in range(n):
Image.fromarray(np.random.randint(0, 255, (image_size, image_size, 3),
dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
np.save(ep_dir / "actions.npy", np.array(actions, dtype=np.float32))
def test_delta_target_unnormalized(self):
"""不归一化时 target 应为 Δ=actions[t]-actions[t-1]"""
with tempfile.TemporaryDirectory() as tmpdir:
# 线性递增动作,Δ 恒为 [1,1]
actions = [[float(i), float(i)] for i in range(8)]
self._create_episode(Path(tmpdir), actions)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
predict_delta=True)
sample = ds[0]
# 第 0 样本 t_start=2,target = a[2]-a[1] = [1,1]
np.testing.assert_allclose(sample["target"].numpy(), [1.0, 1.0],
atol=1e-6)
def test_absolute_target_when_disabled(self):
"""predict_delta=False 时 target 应为绝对动作 actions[t]"""
with tempfile.TemporaryDirectory() as tmpdir:
actions = [[float(i), float(i)] for i in range(8)]
self._create_episode(Path(tmpdir), actions)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
predict_delta=False)
# t_start=2,target = a[2] = [2,2]
np.testing.assert_allclose(ds[0]["target"].numpy(), [2.0, 2.0],
atol=1e-6)
def test_delta_target_normalized_space(self):
"""归一化开启时 Δ 应在归一化空间求差(先 z-score 再相减)"""
with tempfile.TemporaryDirectory() as tmpdir:
rng = np.random.RandomState(0)
actions = rng.randn(12, 3).astype(np.float32)
self._create_episode(Path(tmpdir), actions)
ds = ActionDataset(tmpdir, history_len=2, action_dim=3,
image_width=16, image_height=16, frame_len=2,
normalize_actions=True, predict_delta=True)
mean, std = ds._action_mean, ds._action_std
sample = ds[0]
t = 2 # 第 0 样本 t_start
expected = (actions[t] - mean) / std - (actions[t - 1] - mean) / std
np.testing.assert_allclose(sample["target"].numpy(), expected, atol=1e-5)
class TestR1Rollout(unittest.TestCase):
"""R1-B 多步展开:dataset K 步序列结构与窗口对齐"""
@staticmethod
def _create_episode(base_dir, n, action_dim, image_size=16):
ep_dir = base_dir / "episode_0001"
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
for i in range(n):
Image.fromarray(np.random.randint(0, 255, (image_size, image_size, 3),
dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.arange(n * action_dim, dtype=np.float32).reshape(n, action_dim)
np.save(ep_dir / "actions.npy", actions)
return actions
def test_rollout_sample_count(self):
"""rollout_len=K 时样本数 = n - start - (K-1)"""
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), n=10, action_dim=2)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
rollout_len=3)
# start=max(2,1)=2,n-(K-1)=8,样本数=8-2=6
self.assertEqual(len(ds), 6)
def test_rollout_structure_and_alignment(self):
"""多步样本返回 K 步,每步 history/target/prev_action 与时刻对齐"""
with tempfile.TemporaryDirectory() as tmpdir:
actions = self._create_episode(Path(tmpdir), n=10, action_dim=2)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
rollout_len=3)
sample = ds[0]
self.assertIn("steps", sample)
steps = sample["steps"]
self.assertEqual(len(steps), 3)
for k, step in enumerate(steps):
t = 2 + k # t_start=2
self.assertEqual(step["images"].shape, (2, 3, 16, 16))
self.assertEqual(step["action_history"].shape, (2, 2))
self.assertEqual(step["target"].shape, (2,))
self.assertEqual(step["prev_action"].shape, (2,))
# 未归一化:history=actions[t-2:t]、target=actions[t]、prev=actions[t-1]
np.testing.assert_allclose(step["action_history"].numpy(),
actions[t - 2:t], atol=1e-6)
np.testing.assert_allclose(step["target"].numpy(),
actions[t], atol=1e-6)
np.testing.assert_allclose(step["prev_action"].numpy(),
actions[t - 1], atol=1e-6)
def test_rollout_delta_prev_action_is_base(self):
"""残差+多步:prev_action 应为 prev+target 的基准(target=Δ 时 prev+Δ=abs)"""
with tempfile.TemporaryDirectory() as tmpdir:
actions = self._create_episode(Path(tmpdir), n=10, action_dim=2)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
predict_delta=True, rollout_len=3)
step = ds[0]["steps"][1] # t=3
# prev + Δ 应还原绝对动作 actions[t]
recon = step["prev_action"].numpy() + step["target"].numpy()
np.testing.assert_allclose(recon, actions[3], atol=1e-6)
def test_single_step_has_no_steps_key(self):
"""rollout_len=1 时保持旧版扁平结构(无 steps 键)"""
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), n=8, action_dim=2)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
rollout_len=1)
sample = ds[0]
self.assertNotIn("steps", sample)
self.assertIn("target", sample)
class TestR1TrainHelpers(unittest.TestCase):
"""R1 训练辅助:_shift_history 与 _collate"""
def test_shift_history_appends_and_drops(self):
from train import _shift_history
history = torch.tensor([[[1.0], [2.0], [3.0]]]) # (1,3,1)
new_action = torch.tensor([[4.0]]) # (1,1)
out = _shift_history(history, new_action)
self.assertEqual(out.shape, (1, 3, 1))
np.testing.assert_allclose(out[0, :, 0].numpy(), [2.0, 3.0, 4.0])
def test_collate_single_step_passthrough(self):
from train import _collate
b = [{"images": torch.zeros(2, 3, 4, 4),
"action_history": torch.zeros(2, 2),
"target": torch.zeros(2)} for _ in range(3)]
out = _collate(b)
self.assertNotIn("steps", out)
self.assertEqual(out["images"].shape, (3, 2, 3, 4, 4))
def test_collate_multi_step_stacks_per_k(self):
from train import _collate
def mk(k):
return {"images": torch.full((2, 3, 4, 4), float(k)),
"action_history": torch.zeros(2, 2),
"target": torch.zeros(2),
"prev_action": torch.zeros(2)}
# 2 个样本,每个 K=3 步
b = [{"steps": [mk(k) for k in range(3)]} for _ in range(2)]
out = _collate(b)
self.assertEqual(len(out["steps"]), 3)
# 第 k 步 batch 的 images 值应全为 k
for k in range(3):
self.assertEqual(out["steps"][k]["images"].shape, (2, 2, 3, 4, 4))
self.assertTrue(
torch.allclose(out["steps"][k]["images"],
torch.full((2, 2, 3, 4, 4), float(k))))
class TestR4Evaluate(unittest.TestCase):
"""R4 离线评估:滚动口径、平滑度、误差增长、越界、汇总"""
@staticmethod
def _create_episode(base_dir, name, actions, image_size=16):
ep_dir = base_dir / name
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
for i in range(len(actions)):
Image.fromarray(np.random.randint(0, 255, (image_size, image_size, 3),
dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
np.save(ep_dir / "actions.npy", np.array(actions, dtype=np.float32))
class _ConstPredictor:
"""恒定输出 c 的假推理器(鸭子类型),用于可控验证滚动逻辑"""
frame_len = 2
history_len = 2
action_dim = 2
def __init__(self, c):
self._c = np.asarray(c, dtype=np.float32)
self.calls = 0
def predict(self, images, history):
self.calls += 1
return self._c.copy()
def test_rollout_uses_self_prediction_feedback(self):
"""滚动应把自预测回写进历史(复刻 run_loop),步数 = n - t0"""
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.arange(20, dtype=np.float32).reshape(10, 2)
self._create_episode(Path(tmpdir), "episode_0001", actions)
image_files, acts, _ = evaluate._load_episode(
tmpdir, "episode_0001")
pred = self._ConstPredictor([100.0, 100.0])
steps = evaluate.rollout_episode(pred, image_files, acts)
# t0 = max(H=2, F-1=1) = 2,horizon = 10-2 = 8
self.assertEqual(len(steps), 8)
self.assertEqual(pred.calls, 8)
# 恒预测 100,真值 a[t],逐步 mse = mean((100-a[t])^2)
self.assertAlmostEqual(steps[0]["mse"],
float(np.mean((100 - acts[2]) ** 2)))
def test_rollout_respects_horizon(self):
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((10, 2), dtype=np.float32)
self._create_episode(Path(tmpdir), "episode_0001", actions)
image_files, acts, _ = evaluate._load_episode(
tmpdir, "episode_0001")
pred = self._ConstPredictor([0.0, 0.0])
steps = evaluate.rollout_episode(pred, image_files, acts, horizon=3)
self.assertEqual(len(steps), 3)
def test_rollout_short_episode_returns_none(self):
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((2, 2), dtype=np.float32) # n=2 <= t0=2
self._create_episode(Path(tmpdir), "episode_0001", actions)
image_files, acts, _ = evaluate._load_episode(
tmpdir, "episode_0001")
pred = self._ConstPredictor([0.0, 0.0])
self.assertIsNone(evaluate.rollout_episode(pred, image_files, acts))
def test_smoothness_constant_vs_noisy(self):
import evaluate
const = np.tile([1.0, 2.0], (10, 1))
rng = np.random.RandomState(0)
noisy = const + rng.randn(10, 2).astype(np.float32)
self.assertEqual(evaluate._smoothness(const), 0.0)
self.assertGreater(evaluate._smoothness(noisy), 0.0)
self.assertEqual(evaluate._smoothness(np.zeros((1, 2))), 0.0)
def test_oob_detection(self):
"""越界:预测超出经验范围应标记 oob=True"""
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((10, 2), dtype=np.float32) # 真值全 0
self._create_episode(Path(tmpdir), "episode_0001", actions)
image_files, acts, _ = evaluate._load_episode(
tmpdir, "episode_0001")
pred = self._ConstPredictor([5.0, 5.0]) # 远超 [0,0] 范围
lo = np.zeros(2, dtype=np.float32)
hi = np.zeros(2, dtype=np.float32)
steps = evaluate.rollout_episode(pred, image_files, acts,
lo=lo, hi=hi)
self.assertTrue(all(s["oob"] for s in steps))
def test_summarize_keys_and_growth(self):
import evaluate
# 构造误差随步数线性增长的滚动记录
steps = [{"t": k, "mse": float(k + 1),
"pred": np.zeros(2), "true": np.zeros(2), "oob": False}
for k in range(40)]
lo = np.zeros(2); hi = np.zeros(2)
rep = evaluate.summarize([steps], lo, hi)
for key in ("n_episodes", "n_steps", "overall_mse", "error_growth",
"error_curve", "pred_smoothness", "true_smoothness",
"smoothness_ratio", "oob_rate"):
self.assertIn(key, rep)
self.assertEqual(rep["n_episodes"], 1)
self.assertEqual(rep["n_steps"], 40)
# 误差递增 → error_growth > 1
self.assertGreater(rep["error_growth"], 1.0)
self.assertEqual(len(rep["error_curve"]), 40)
def test_action_bounds_margin(self):
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), "episode_0001",
np.zeros((5, 2), dtype=np.float32))
self._create_episode(Path(tmpdir), "episode_0002",
np.full((5, 2), 10.0, dtype=np.float32))
lo, hi = evaluate._action_bounds(tmpdir, 2, margin_ratio=0.1)
# min=0,max=10,span=10,margin=1 → lo=-1, hi=11
np.testing.assert_allclose(lo, [-1.0, -1.0], atol=1e-6)
np.testing.assert_allclose(hi, [11.0, 11.0], atol=1e-6)
class TestR2ImuNorm(unittest.TestCase):
"""R2 姿态归一化:物理量程除数、pad/truncate、None 兜底"""
def test_scales_applied(self):
from imu_norm import normalize_imu, IMU_SCALES
raw = [180.0, -180.0, 32768.0, -32768.0, 16384.0, 655.36, -655.36, 0.0]
out = normalize_imu(raw, 8)
np.testing.assert_allclose(out, np.asarray(raw) / IMU_SCALES, atol=1e-6)
def test_none_gives_zeros(self):
from imu_norm import normalize_imu
out = normalize_imu(None, 8)
self.assertEqual(out.shape, (8,))
np.testing.assert_array_equal(out, np.zeros(8, np.float32))
def test_short_input_pads_zero(self):
from imu_norm import normalize_imu
out = normalize_imu([90.0, -90.0], 8)
self.assertEqual(out.shape, (8,))
np.testing.assert_allclose(out[:2], [0.5, -0.5], atol=1e-6)
np.testing.assert_array_equal(out[2:], np.zeros(6, np.float32))
def test_long_input_truncates(self):
from imu_norm import normalize_imu
out = normalize_imu([180.0] * 10, 8)
self.assertEqual(out.shape, (8,))
np.testing.assert_allclose(out[:2], [1.0, 1.0], atol=1e-6)
def test_imu_dim_prefix_of_scales(self):
"""imu_dim<8 时取 IMU_SCALES 前缀(roll/pitch 子集可用)"""
from imu_norm import normalize_imu
out = normalize_imu([180.0, -180.0], 2)
self.assertEqual(out.shape, (2,))
np.testing.assert_allclose(out, [1.0, -1.0], atol=1e-6)
def test_multi_sensor_tiled_scales(self):
"""多传感器(8×N,N≤4):归一化除数按 8 槽模式平铺"""
from imu_norm import normalize_imu, imu_scales, IMU_SCALES
scales = imu_scales(32)
self.assertEqual(scales.shape, (32,))
for k in range(4):
np.testing.assert_array_equal(scales[k * 8:(k + 1) * 8],
IMU_SCALES)
# 单传感器 8 槽模式 [±180, ∓180, 32768×6] 拼接两组 → 归一化重复
raw = ([180.0, -180.0] + [32768.0] * 6) * 2
out = normalize_imu(raw, 16)
np.testing.assert_allclose(
out, ([1.0, -1.0] + [1.0] * 6) * 2, atol=1e-6)
class TestR2ImuModel(unittest.TestCase):
"""R2 方案一模型:独立 IMU token 注入(序列 F+H+2)"""
@staticmethod
def _make_cfg(imu_dim):
cfg = Config()
cfg.data.image_width = 32
cfg.data.image_height = 32
cfg.data.action_dim = 4
cfg.data.history_len = 3
cfg.data.frame_len = 3
cfg.data.imu_dim = imu_dim
return cfg
def test_imu_token_changes_output(self):
"""imu_dim>0:不同姿态输入应产生不同输出(token 真的进了序列)"""
cfg = self._make_cfg(imu_dim=8)
model = ActionPredictor(cfg.model, cfg.data).eval()
imgs = torch.randn(1, 3, 3, 32, 32)
hist = torch.randn(1, 3, 4)
with torch.no_grad():
out_zero = model(imgs, hist, torch.zeros(1, 8)).numpy()
out_imu = model(imgs, hist, torch.randn(1, 8)).numpy()
self.assertGreater(np.abs(out_zero - out_imu).max(), 1e-6)
def test_none_imu_equals_zero_vector(self):
"""imu=None 应与显式零向量一致(无姿态先验语义)"""
cfg = self._make_cfg(imu_dim=8)
model = ActionPredictor(cfg.model, cfg.data).eval()
imgs = torch.randn(1, 3, 3, 32, 32)
hist = torch.randn(1, 3, 4)
with torch.no_grad():
a = model(imgs, hist).numpy()
b = model(imgs, hist, torch.zeros(1, 8)).numpy()
np.testing.assert_allclose(a, b, atol=1e-6)
def test_imu_dim_zero_has_no_imu_proj(self):
"""imu_dim=0(默认)不建 imu_proj,旧 checkpoint 完全兼容"""
cfg = self._make_cfg(imu_dim=0)
model = ActionPredictor(cfg.model, cfg.data)
self.assertFalse(hasattr(model, "imu_proj"))
# 序列长度保持 F+H+1
self.assertEqual(model.pos_embed.shape[1], 3 + 3 + 1)
def test_imu_dim_positive_seq_len(self):
"""imu_dim>0 时序列长度 F+H+2"""
cfg = self._make_cfg(imu_dim=8)
model = ActionPredictor(cfg.model, cfg.data)
self.assertEqual(model.pos_embed.shape[1], 3 + 3 + 2)
def test_dynamic_batch_with_imu(self):
"""batch 维动态:imu 输入随 batch 变化"""
cfg = self._make_cfg(imu_dim=8)
model = ActionPredictor(cfg.model, cfg.data).eval()
with torch.no_grad():
out = model(torch.randn(2, 3, 3, 32, 32),
torch.randn(2, 3, 4), torch.randn(2, 8))
self.assertEqual(out.shape, (2, 4))
class TestR2ImuDataset(unittest.TestCase):
"""R2 数据集:imu.npy 加载、归一化、缺失补零、形状修正"""
@staticmethod
def _create_episode(base_dir, name, n, action_dim, image_size=16,
imus=None):
ep_dir = base_dir / name
images_dir = ep_dir / "images"
images_dir.mkdir(parents=True, exist_ok=True)
for i in range(n):
Image.fromarray(np.random.randint(0, 255, (image_size, image_size, 3),
dtype=np.uint8)).save(
images_dir / f"{i:04d}.jpg")
actions = np.arange(n * action_dim, dtype=np.float32).reshape(
n, action_dim)
np.save(ep_dir / "actions.npy", actions)
if imus is not None:
np.save(ep_dir / "imu.npy", np.asarray(imus, dtype=np.float32))
def test_imu_loaded_and_normalized(self):
"""imu_dim>0 且 imu.npy 存在 → 样本带归一化 imu 键"""
with tempfile.TemporaryDirectory() as tmpdir:
# roll=180 → 归一化 1.0;ax=32768 → 1.0
imus = [[180.0, -180.0, 32768.0, 0, 0, 0, 0, 0]] * 10
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=imus)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
imu_dim=8)
sample = ds[0]
self.assertIn("imu", sample)
np.testing.assert_allclose(
sample["imu"].numpy(),
[1.0, -1.0, 1.0, 0, 0, 0, 0, 0], atol=1e-6)
def test_missing_imu_fills_zero(self):
"""无 imu.npy 的 episode → 零向量(混合数据集可训)"""
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), "episode_0001", 10, 2)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
imu_dim=8)
np.testing.assert_array_equal(
ds[0]["imu"].numpy(), np.zeros(8, np.float32))
def test_imu_dim_zero_no_imu_key(self):
"""imu_dim=0 时样本无 imu 键(旧行为不变)"""
with tempfile.TemporaryDirectory() as tmpdir:
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=[[1.0] * 8] * 10)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2)
self.assertNotIn("imu", ds[0])
def test_imu_dim_mismatch_padded(self):
"""imu.npy 列数与 imu_dim 不一致 → pad/truncate 不炸"""
with tempfile.TemporaryDirectory() as tmpdir:
imus = [[180.0, -180.0]] * 10 # 只有 2 列,imu_dim=8
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=imus)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
imu_dim=8)
imu = ds[0]["imu"].numpy()
self.assertEqual(imu.shape, (8,))
np.testing.assert_allclose(imu[:2], [1.0, -1.0], atol=1e-6)
np.testing.assert_array_equal(imu[2:], np.zeros(6, np.float32))
def test_multi_sensor_16wide_normalized(self):
"""双传感器 imu.npy(16 列):两组 8 槽分别按物理量程归一化"""
with tempfile.TemporaryDirectory() as tmpdir:
imus = [[180.0, 0, 0, 0, 0, 0, 0, 0, # sensor0 roll
0, 0, 0, 0, 0, 0, 0, 32768.0]] * 10 # sensor1 gz
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=imus)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
imu_dim=16)
imu = ds[0]["imu"].numpy()
self.assertEqual(imu.shape, (16,))
np.testing.assert_allclose(imu[0], 1.0, atol=1e-6)
np.testing.assert_allclose(imu[15], 1.0, atol=1e-6)
np.testing.assert_array_equal(imu[1:15], np.zeros(14, np.float32))
def test_short_imu_aligned_with_zero_pad(self):
"""imu.npy 行数少于帧数 → 尾部补零对齐"""
with tempfile.TemporaryDirectory() as tmpdir:
imus = [[180.0] + [0.0] * 7] * 5 # 5 行 < 10 帧
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=imus)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
imu_dim=8)
# 第 0 样本 t_start=2(有 imu),末样本 t_start=8(超出 5 行 → 0)
np.testing.assert_allclose(ds[0]["imu"].numpy()[0], 1.0, atol=1e-6)
np.testing.assert_array_equal(ds[-1]["imu"].numpy(),
np.zeros(8, np.float32))
def test_rollout_steps_carry_imu(self):
"""多步展开时每步带对应时刻的 imu"""
with tempfile.TemporaryDirectory() as tmpdir:
imus = [[float(t), 0, 0, 0, 0, 0, 0, 0] for t in range(10)]
self._create_episode(Path(tmpdir), "episode_0001", 10, 2,
imus=imus)
ds = ActionDataset(tmpdir, history_len=2, action_dim=2,
image_width=16, image_height=16, frame_len=2,
rollout_len=3, imu_dim=8)
steps = ds[0]["steps"]
for k, step in enumerate(steps):
t = 2 + k
self.assertIn("imu", step)
np.testing.assert_allclose(step["imu"].numpy()[0],
t / 180.0, atol=1e-6)
@unittest.skipUnless(
importlib.util.find_spec("onnxscript") is not None
and importlib.util.find_spec("onnxruntime") is not None,
"OnnxPredictor 测试需要 onnxscript + onnxruntime")
class TestR2OnnxPredictorImu(unittest.TestCase):
"""R2 ONNX 后端:含 imu 输入的导出模型推理对齐"""
@staticmethod
def _export_imu_onnx(tmpdir):
cfg = Config()
cfg.data.image_width = 32
cfg.data.image_height = 32
cfg.data.action_dim = 4
cfg.data.history_len = 3
cfg.data.frame_len = 3
cfg.data.imu_dim = 8
model = ActionPredictor(cfg.model, cfg.data)
model.eval()
out_path = os.path.join(tmpdir, "model_imu.onnx")
torch.onnx.export(
model,
(torch.randn(1, 3, 3, 32, 32), torch.randn(1, 3, 4),
torch.randn(1, 8)),
out_path,
input_names=["images", "action_history", "imu"],
output_names=["predicted_action"],
opset_version=18)
return out_path, model
def _frames(self, n):
return [np.random.randint(0, 255, (48, 64, 3), dtype=np.uint8)
for _ in range(n)]
def test_imu_dim_detected(self):
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = self._export_imu_onnx(tmpdir)
with OnnxPredictor(path) as p:
self.assertEqual(p.imu_dim, 8)
def test_imu_predict_matches_torch(self):
"""predict(imu=原始姿态) 应与 torch(手动归一化)前向一致"""
from imu_norm import normalize_imu
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, model = self._export_imu_onnx(tmpdir)
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
imu_raw = np.array([5.0, -3.0, 100, 200, 16384, 10, -20, 30],
dtype=np.float32)
with OnnxPredictor(path) as p:
onnx_out = p.predict(frames, history, imu=imu_raw)
pre = np.stack([p._preprocess(f) for f in frames])
with torch.no_grad():
torch_out = model(
torch.from_numpy(pre[np.newaxis]),
torch.from_numpy(history[np.newaxis]),
torch.from_numpy(
normalize_imu(imu_raw, 8)[np.newaxis])).numpy()
np.testing.assert_allclose(onnx_out, torch_out.reshape(-1),
atol=1e-4)
def test_imu_changes_output(self):
"""姿态输入应影响输出(确认 imu 真的喂进了图)"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = self._export_imu_onnx(tmpdir)
frames = self._frames(3)
history = np.random.randn(3, 4).astype(np.float32)
with OnnxPredictor(path) as p:
a = p.predict(frames, history, imu=None)
b = p.predict(frames, history,
imu=np.array([180.0] + [0.0] * 7, np.float32))
self.assertGreater(np.abs(a - b).max(), 1e-6)
def test_no_imu_model_imu_dim_none(self):
"""旧两输入 ONNX:imu_dim 为 None(pybot 鸭子类型走旧两参路径)"""
from onnx_infer import OnnxPredictor
with tempfile.TemporaryDirectory() as tmpdir:
path, _ = TestOnnxPredictor._export_small_onnx(tmpdir)
with OnnxPredictor(path) as p:
self.assertIsNone(p.imu_dim)
class TestR2EvaluateImu(unittest.TestCase):
"""R2 评估:滚动时姿态真值按时刻传给 predict"""
class _ImuRecordingPredictor:
"""记录每步收到的 imu 的假推理器"""
frame_len = 2
history_len = 2
action_dim = 2
imu_dim = 8
def __init__(self):
self.imus_received = []
def predict(self, images, history, imu=None):
self.imus_received.append(
None if imu is None else np.asarray(imu).copy())
return np.zeros(2, dtype=np.float32)
def test_rollout_passes_imu_per_step(self):
"""imus 存在时逐步传 imus[t](t0 起步与帧窗对齐)"""
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((10, 2), dtype=np.float32)
TestR4Evaluate._create_episode(Path(tmpdir), "episode_0001",
actions)
imus = np.arange(80, dtype=np.float32).reshape(10, 8)
np.save(Path(tmpdir) / "episode_0001" / "imu.npy", imus)
image_files, acts, loaded = evaluate._load_episode(
tmpdir, "episode_0001")
np.testing.assert_array_equal(loaded, imus)
pred = self._ImuRecordingPredictor()
steps = evaluate.rollout_episode(pred, image_files, acts,
imus=loaded)
self.assertEqual(len(steps), 8) # t0=2 → t=2..9
for k, rec in enumerate(steps):
t = 2 + k
np.testing.assert_array_equal(pred.imus_received[k], imus[t])
def test_rollout_without_imu_passes_none(self):
"""imu.npy 缺失 → 每步 imu=None(等价零向量,不炸)"""
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((10, 2), dtype=np.float32)
TestR4Evaluate._create_episode(Path(tmpdir), "episode_0001",
actions)
image_files, acts, loaded = evaluate._load_episode(
tmpdir, "episode_0001")
self.assertIsNone(loaded)
pred = self._ImuRecordingPredictor()
evaluate.rollout_episode(pred, image_files, acts, imus=loaded)
self.assertTrue(all(v is None for v in pred.imus_received))
def test_legacy_predictor_not_given_imu_kwarg(self):
"""imu_dim=None 的旧推理器不应收到 imu 关键字(鸭子类型兼容)"""
import evaluate
with tempfile.TemporaryDirectory() as tmpdir:
actions = np.zeros((10, 2), dtype=np.float32)
TestR4Evaluate._create_episode(Path(tmpdir), "episode_0001",
actions)
image_files, acts, _ = evaluate._load_episode(
tmpdir, "episode_0001")
pred = TestR4Evaluate._ConstPredictor([0.0, 0.0]) # 无 imu_dim
steps = evaluate.rollout_episode(pred, image_files, acts)
self.assertEqual(len(steps), 8)
self.assertEqual(pred.calls, 8)
class TestR2HailoImu(unittest.TestCase):
"""R2 HEF 封装:6 输入布局(frame×3 + history + pred_token + imu)"""
def _frames(self, n, size=8):
return [np.random.randint(0, 255, (size, size, 3), dtype=np.uint8)
for _ in range(n)]
def _install_six_input_fake(self, seen):
infos_in = [
_FakeVStreamInfo("net/input_layer1", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer2", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer3", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer4", (1, 2, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer5", (1, 1, 4),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer6", (1, 1, 1, 8),
_FakeFormatType.FLOAT32),
]
infos_out = [_FakeVStreamInfo("net/conv49", (1, 1, 3),
_FakeFormatType.FLOAT32)]
def transform(b):
seen["imu"] = np.asarray(
b.input("net/input_layer6").get_buffer()).copy()
_first_row_transform("net/input_layer4", "net/conv49")(b)
_install_fake_hailo(self, infos_in, infos_out, transform)
def test_imu_input_classified_and_bound(self):
"""imu (1,1,1,8) 输入应被识别为姿态而非 pred_token,绑定归一化值"""
from hailo_infer import HailoPredictor
seen = {}
self._install_six_input_fake(seen)
with HailoPredictor("fake.hef") as p:
self.assertEqual(p.imu_dim, 8)
# roll=180° → 归一化 1.0
p.predict(self._frames(3), np.zeros((2, 3), np.float32),
imu=np.array([180.0] + [0.0] * 7, np.float32))
self.assertEqual(seen["imu"].shape, (1, 1, 1, 8))
np.testing.assert_allclose(seen["imu"][0, 0, 0, 0], 1.0, atol=1e-6)
np.testing.assert_array_equal(seen["imu"][0, 0, 0, 1:],
np.zeros(7, np.float32))
def test_imu_none_binds_zeros(self):
"""imu=None → 零向量绑定(无姿态先验)"""
from hailo_infer import HailoPredictor
seen = {}
self._install_six_input_fake(seen)
with HailoPredictor("fake.hef") as p:
p.predict(self._frames(3), np.zeros((2, 3), np.float32))
np.testing.assert_array_equal(seen["imu"], np.zeros((1, 1, 1, 8)))
def test_imu_uint8_quantized(self):
"""uint8 量化边缘:归一化值按 quant_info 量化"""
from hailo_infer import HailoPredictor
q_imu = _FakeQuantInfo(qp_zp=128.0, qp_scale=0.01)
infos_in = [
_FakeVStreamInfo("net/input_layer1", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer2", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer3", (8, 8, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer4", (1, 2, 3),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer5", (1, 1, 4),
_FakeFormatType.FLOAT32),
_FakeVStreamInfo("net/input_layer6", (1, 1, 1, 8),
_FakeFormatType.UINT8, q_imu),
]
infos_out = [_FakeVStreamInfo("net/conv49", (1, 1, 3),
_FakeFormatType.FLOAT32)]
seen = {}
def transform(b):
seen["imu"] = np.asarray(
b.input("net/input_layer6").get_buffer()).copy()
_first_row_transform("net/input_layer4", "net/conv49")(b)
_install_fake_hailo(self, infos_in, infos_out, transform)
with HailoPredictor("fake.hef") as p:
# roll=18° → 归一化 0.1 → 量化 round(0.1/0.01+128)=138
p.predict(self._frames(3), np.zeros((2, 3), np.float32),
imu=np.array([18.0] + [0.0] * 7, np.float32))
self.assertEqual(seen["imu"].dtype, np.uint8)
self.assertEqual(seen["imu"][0, 0, 0, 0], 138)
# 零值 → round(0/0.01+128)=128
np.testing.assert_array_equal(seen["imu"][0, 0, 0, 1:],
np.full(7, 128, np.uint8))
class TestR2DfcCompileImu(unittest.TestCase):
"""R2 dfc_compile:--with-imu 时 start_nodes 末尾追加 imu"""
def test_start_nodes_with_imu(self):
from dfc_compile import start_nodes
self.assertEqual(start_nodes(3, with_imu=True),
["frame0", "frame1", "frame2",
"action_history", "pred_token", "imu"])
def test_start_nodes_without_imu_unchanged(self):
from dfc_compile import start_nodes
self.assertEqual(start_nodes(3),
["frame0", "frame1", "frame2",
"action_history", "pred_token"])
self.assertEqual(start_nodes(3, with_imu=False), start_nodes(3))
class TestR2ConfigImu(unittest.TestCase):
"""R2 配置:imu_dim 校验与覆盖"""
def test_default_imu_dim_zero(self):
self.assertEqual(DataConfig().imu_dim, 0)
def test_imu_dim_out_of_range_raises(self):
data = DataConfig(imu_dim=65)
with self.assertRaises(ValueError):
validate_config_values(data, ModelConfig())
data = DataConfig(imu_dim=-1)
with self.assertRaises(ValueError):
validate_config_values(data, ModelConfig())
def test_imu_dim_cli_override(self):
args = build_argparser().parse_args(["--imu-dim", "8"])
cfg = apply_overrides(Config(), args)
self.assertEqual(cfg.data.imu_dim, 8)
def test_checkpoint_config_roundtrip_with_imu(self):
cfg = Config()
cfg.data.imu_dim = 8
restored = parse_checkpoint_config(cfg.to_dict())
self.assertEqual(restored.data.imu_dim, 8)
if __name__ == "__main__":
unittest.main(verbosity=2)
+101 -22
View File
@@ -21,39 +21,108 @@ def split_episodes(data_dir: str, val_ratio: float, seed: int = 42):
return indices[val_count:], indices[:val_count]
def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = None):
def _collate(batch):
"""多步样本({"steps":[...]})逐 k 聚合成 batch 列表;单步样本走默认 collate。
rollout_len==1 时 __getitem__ 返回标准 dict,直接默认 collate;
rollout_len>1 时返回 {"steps":[step_0..step_{K-1}]},把每个 step_k 跨样本堆叠,
返回 {"steps":[batch_0..batch_{K-1}]},供 train_one_epoch 按步 scheduled sampling。
"""
from torch.utils.data._utils.collate import default_collate
if batch and isinstance(batch[0], dict) and "steps" in batch[0]:
k = len(batch[0]["steps"])
return {"steps": [default_collate([b["steps"][i] for b in batch])
for i in range(k)]}
return default_collate(batch)
def _shift_history(history: torch.Tensor, new_action: torch.Tensor) -> torch.Tensor:
"""动作历史左移一步、追加 new_action 到末尾(scheduled sampling 回传自预测用)。
history: (B, H, D),new_action: (B, D) → (B, H, D)
"""
return torch.cat([history[:, 1:], new_action.unsqueeze(1)], dim=1)
def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = None,
rollout_len: int = 1, ss_prob: float = 1.0,
predict_delta: bool = False):
model.train()
total_loss = 0.0
count = 0
rng = np.random.RandomState()
for batch in tqdm(loader, desc=" train", leave=False):
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
optimizer.zero_grad()
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
if rollout_len == 1:
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target)
bs = images.size(0)
else:
# 多步 scheduled sampling:首步必用真历史;后续步以 ss_prob 用真历史、
# 否则用上一步模型预测(detach 截断,避免梯度跨步爆炸)回传构造历史。
loss = 0.0
bs = 0
history = None
for k, step in enumerate(batch["steps"]):
images = step["images"].to(device)
true_history = step["action_history"].to(device)
target = step["target"].to(device)
prev_action = step["prev_action"].to(device)
imu = step["imu"].to(device) if "imu" in step else None
if k == 0 or rng.random_sample() < ss_prob:
history = true_history
# 否则沿用上一轮回传的 history(自预测构造)
pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target)
bs = images.size(0)
# 回传:残差模式先把 Δ̂ 加回 a_{t-1} 得到绝对动作,再移入历史
with torch.no_grad():
abs_action = prev_action + pred if predict_delta else pred
history = _shift_history(true_history, abs_action.detach())
loss = loss / len(batch["steps"])
loss.backward()
if max_grad_norm:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
total_loss += loss.item() * images.size(0)
count += images.size(0)
total_loss += loss.item() * bs
count += bs
return total_loss / max(count, 1)
@torch.no_grad()
def validate(model, loader, device):
def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool = False):
model.eval()
total_loss = 0.0
count = 0
for batch in tqdm(loader, desc=" val ", leave=False):
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
pred = model(images, history)
loss = torch.nn.functional.mse_loss(pred, target)
total_loss += loss.item() * images.size(0)
count += images.size(0)
if rollout_len == 1:
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
imu = batch["imu"].to(device) if "imu" in batch else None
pred = model(images, history, imu)
loss = torch.nn.functional.mse_loss(pred, target)
bs = images.size(0)
else:
# 验证固定全真历史(确定性,不带 scheduled sampling 随机性),
# 对 K 步累加,保证 best 模型选择口径稳定可比。
loss = 0.0
bs = 0
for step in batch["steps"]:
images = step["images"].to(device)
history = step["action_history"].to(device)
target = step["target"].to(device)
imu = step["imu"].to(device) if "imu" in step else None
pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target)
bs = images.size(0)
loss = loss / len(batch["steps"])
total_loss += loss.item() * bs
count += bs
if count == 0:
return float("inf") # 空验证集不参与比较,避免假 best
return total_loss / count
@@ -85,7 +154,9 @@ def main():
cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim,
cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions,
cfg.data.action_stats_path, train_indices, cfg.data.frame_len,
augment=cfg.train.augment)
augment=cfg.train.augment,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim)
if cfg.data.normalize_actions and not cfg.data.action_stats_path:
# 归一化统计量必须只用训练集计算,再供验证集复用,否则 val loss 不可信
stats_path = os.path.join(cfg.train.checkpoint_dir, "action_stats.npy")
@@ -96,7 +167,9 @@ def main():
val_ds = ActionDataset(
cfg.data.data_dir, cfg.data.history_len, cfg.data.action_dim,
cfg.data.image_width, cfg.data.image_height, cfg.data.normalize_actions,
cfg.data.action_stats_path, val_indices, cfg.data.frame_len)
cfg.data.action_stats_path, val_indices, cfg.data.frame_len,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len,
imu_dim=cfg.data.imu_dim)
print(f"Samples: {len(train_ds)} train, {len(val_ds)} val")
if len(train_ds) == 0:
@@ -111,9 +184,10 @@ def main():
loader_gen.manual_seed(cfg.train.seed)
train_loader = DataLoader(train_ds, batch_size=cfg.train.batch_size,
shuffle=True, num_workers=cfg.train.num_workers,
generator=loader_gen)
generator=loader_gen, collate_fn=_collate)
val_loader = DataLoader(val_ds, batch_size=cfg.train.batch_size,
shuffle=False, num_workers=cfg.train.num_workers)
shuffle=False, num_workers=cfg.train.num_workers,
collate_fn=_collate)
model = ActionPredictor(cfg.model, cfg.data).to(device)
n_params = sum(p.numel() for p in model.parameters())
@@ -154,8 +228,13 @@ def main():
for epoch in range(start_epoch, cfg.train.epochs + 1):
print(f"\nEpoch {epoch}/{cfg.train.epochs} lr={optimizer.param_groups[0]['lr']:.6f}")
train_loss = train_one_epoch(model, train_loader, optimizer, device,
cfg.train.max_grad_norm)
val_loss = validate(model, val_loader, device)
cfg.train.max_grad_norm,
rollout_len=cfg.train.rollout_len,
ss_prob=cfg.train.ss_prob,
predict_delta=cfg.train.predict_delta)
val_loss = validate(model, val_loader, device,
rollout_len=cfg.train.rollout_len,
predict_delta=cfg.train.predict_delta)
scheduler.step()
print(f" train_loss={train_loss:.6f} val_loss={val_loss:.6f}")
@@ -0,0 +1,166 @@
# actpred × pybot 研究方向报告:循环神经网络思想与机器人学习演进路线
日期:2026-08-14(Asia/Shanghai)
范围:`actpred/`(动作预测,大脑)与 `pybot/`(机器人运行时,身体)
类型:技术研究方向调研(`advisory`;涉及部署可行性的判断标注 `verify_pending`,需 Hailo 实测确认)
## 0. 报告说明
本报告源于两个递进的调研问题:(1)动作预测能否引入更多循环神经网络(RNN)思想;(2)在此之上的整体研究方向。报告先剖析 actpred 当前建模范式的本质与痛点,再给出 RNN 思想的四个切入点,最后组织成分层的演进路线图。所有判断基于当日代码基线(进程内闭环已落地、Hailo NPU 部署链已实机验证)与项目现实约束。
## 1. 现状建模范式与核心痛点
### 1.1 当前是"单步窗口预测",模型无跨步隐状态
actpred 的 `ActionPredictor`([model.py:89-147](../../actpred/model.py#L89-L147))每步独立地用
`[F帧图像, H步动作, PRED]` token 序列做一次性 Transformer 注意力融合,取 PRED 位置回归下一动作。
关键事实:**模型本身没有任何跨步隐状态**,跨步记忆完全由 H 步(默认 5)原始动作向量窗口承载。
训练侧是 teacher forcing([dataset.py:112-117](../../actpred/dataset.py#L112-L117)):
`action_history` 取真实历史,`target` 取下一真实动作,单步监督。
### 1.2 由此产生的三个真实痛点
| 痛点 | 根因 | 后果 |
|------|------|------|
| 长程依赖被截断 | 记忆容量 = H 步原始动作 | 超过 5 步的运动模式(起步→加速→匀速的长惯性)在窗口外丢失 |
| 动作不平滑 | 每步独立回归,无惯性先验 | 预测抖动,对物理舵机是磨损/损坏风险 |
| exposure bias | 训练用真实历史、闭环推理用自预测历史 | 分布失配,误差随步数累积,闭环易发散 |
### 1.3 决定性约束:Hailo NPU 部署链
[model.py:94](../../actpred/model.py#L94) 明确"除 batch 外全部维度静态,保证 ONNX/NPU 部署友好";
[export_deploy.py](../../actpred/export_deploy.py) 整套 DFC 改造(图像输入拆帧、AvgPool 替换、
注意力手工展开)都围绕**静态图**。因此**循环算子(GRU/LSTM)是可行性的分水岭**——
DFC 对循环算子的支持通常弱于静态卷积。这一约束直接决定下文各方案的取舍。
## 2. RNN 思想的四个切入点(按部署链冲击排序)
### 方案 C · 残差/惯性先验:预测 Δaction(零参数,立即见效)⭐
把预测目标从绝对动作改为增量:`â_t = a_{t-1} + Δ`,模型只学 `Δ`。
- **本质**:等价于对角权重固定为 1 的线性 RNN(积分器),是 RNN 思想的极简形式。
- **收益**:输出被惯性锚定,天然平滑;训练更易收敛;舵机抖动大减。
- **部署影响**:零(只改 dataset 的 target 构造 + predict 逆变换时加回 `a_{t-1}`)。
- **代价**:极小。**强烈建议无论选哪条路线都先做这条**。
### 方案 B · 训练目标:多步展开 + scheduled sampling(治 exposure bias)
不改模型,只改训练:
- dataset 一次取 K 步连续样本而非单步;
- 第 1 步用真实历史,第 2 步起用模型自己的预测回传(scheduled sampling),loss 对 K 步累加。
- **收益**:直接消除训练/推理分布失配,闭环稳定性大幅提升。
- **部署影响**:零(模型结构不变,已部署 Hailo 推理链不动)。
- **代价**:只改 train.py 训练循环 + dataset 采样逻辑,风险最低。
### 方案 A · 混合架构:动作历史用 GRU 压缩(进阶)
`act_proj` 后不接 H 个独立 token,而是过一层 GRU,取最终隐状态压成 **1 个"动作记忆 token"**,
与 F 个图像 token + PRED 一起进 Transformer:
```
现状: H 个动作 token ──────────────────┐
改进: H 步动作 → GRU → h_final (1 token) ├→ [img×F, mem×1, PRED] → Transformer
```
- **收益**:跨步记忆容量与 H 解耦(GRU 隐状态压缩全部历史),长程惯性可建模。
- **部署影响**:token 序列从 `F+H+1` 变 `F+2`,更短更静态;唯一新增算子是 GRU——
**关键风险点**,需先验证 Hailo DFC 对 GRU 的支持(可用固定 H 步展开的 cell 堆叠规避真循环)。
- **代价**:中等改动(model.py + export_deploy.py 的 GRU 展开)。
### 方案 D · 全 recurrent 编码器(表达力最强,部署风险最高)
图像帧也过 ConvGRU,跨步隐状态贯穿视觉与动作。**但这打破"除 batch 外全静态 shape"的
NPU 部署前提**,需重新设计部署图。不推荐在当前 Hailo 目标下做。
### 切入点评审结论
```
第 1 步(零风险): C 残差预测 ──→ 立即改善平滑性,验证收益
第 2 步(零部署影响): B 多步展开训练 ──→ 治 exposure bias
第 3 步(需验证): A GRU 记忆 token ──→ 先做 Hailo GRU 支持单测
├─ 支持 → 展开 GRU 进部署链
└─ 不支持 → 保持 Transformer,仅用 B+C
```
**C+B 组合是性价比最高的第一步**:不改部署链、不增参数,直接解决"抖动 + exposure bias"
两个最痛的问题。A 留作需要更强长程记忆时的进阶项。
## 3. 整体研究方向:分层演进路线
在 RNN 思想之外,从机器人学习前沿看,两模块的演进空间组织为四层。现实约束:
**纯模仿学习(无奖励信号)、MPU6050 已装未用、数据靠实机 `--record`(稀缺)、Hailo NPU 部署硬约束**。
### Tier 0 · 立足现状(零部署风险,先做)
| 编号 | 方向 | 价值 | 改动 |
|------|------|------|------|
| R1 | 残差预测 + 多步展开训练(C+B) | 治抖动+失配,所有后续工作的地基 | dataset + train |
| R2 | **MPU6050 姿态回流** ⭐ | 给模型"本体感觉",是机器人标准状态量 | pybot 读 IMU + actpred 加输入维 |
| R3 | 数据增强扩充 | 稀缺数据的低成本放大 | dataset 增方法 |
| R4 | **离线评估基准** | 没有基准,后续改进全是盲改 | 新增评估脚本 |
**R2 被低估**:pybot 已有 [mpu6050.py](../../pybot/pybot/hd/mpu6050.py) Kalman 姿态解算,但完全没进
actpred 的状态表征——模型对自身俯仰/横滚/角速度是盲的。接入 IMU 6 轴作为额外 token 或并入
动作向量,既补本体感觉,又为 R9 奖励设计提供信号源。
**R4 是基础设施**:现在只有开环 MSE。需要闭环代理指标——留出 episode 上开环滚动预测,
测多步累积误差、动作平滑度(相邻动作差分方差)、物理可行性(越界率)。
**R3**:当前仅亮度/对比度/饱和度/色调增强([dataset.py:99-102](../../actpred/dataset.py#L99-L102)),
可加时间扭曲(帧率扰动)、动作镜像(左右对称对)、episode 混合拼接。
### Tier 1 · 模型能力(需验证部署兼容)
| 编号 | 方向 | 说明 |
|------|------|------|
| R5 | GRU 记忆 token(方案 A) | 先验 Hailo DFC 循环算子支持 |
| R6 | 多模态状态融合 | 视觉+IMU+本体感觉统一表征(R2 完整版) |
| R7 | 世界模型雏形 | 学"动作→下一帧"预测头,支撑 MPC 规划与 model-based RL |
### Tier 2 · 学习范式跃迁(数据/奖励是瓶颈)
| 编号 | 方向 | 说明 |
|------|------|------|
| R8 | **离线强化学习(CQL/IQL)** | 关键契合点:已有大量录制 episode、无法实机安全探索(舵机会坏),离线 RL 从固定数据集学策略、无需在线交互 |
| R9 | 奖励设计(MPU6050) | 姿态稳定性(姿态角方差小=奖励高)、能耗(动作差分平滑=省能)、任务进度 → 标量奖励;从"模仿"走向"优化"的关键一跃 |
| R10 | 自监督视觉预训练 | MAE/对比学习在相机图像上预训练编码器,缓解数据稀缺(ImageNet 预训练与机器人第一视角差异大) |
**R8 的洞察**:离线 RL 正好契合"数据已有 + 不能实机探索"的现状。但前提是 R9 的奖励信号。
### Tier 3 · 远景(与 aibox 联动)
- **R11 语言条件策略**:aibox 高层语言指令("向前走""左转")作为条件,actpred 出低层动作——
分层控制,打通项目"AI 智能体"与"机器人"两大域。
- **R12 sim2real / 数字孪生**:pybot 的 dry_run 已具备合成帧 + Mock 总线,可演进为轻量仿真环境,
先仿真预训练再迁移真机,解决数据稀缺与安全探索。
- **R13 持续学习**:闭环运行中不断回流新 episode 增量微调,机器人越用越聪明。
## 4. 推荐切入点与关键决策点
**短期(本月)**:**R1 + R4**。先落地"残差+多步训练",同时建评估基准——没有基准,后续改进无法量化对比。
**中期**:**R2(MPU6050 回流)性价比之王**——硬件已装、代码已在、收益明确(本体感觉 + 奖励信号源),
只缺"接一根线"。
**关键决策点**:R5 GRU 是否值得做,取决于先做 **"Hailo DFC 循环算子支持验证"** 这个小实验:
- 支持 → R5/R7 可行,循环路线打开;
- 不支持 → Tier 1 循环类方案全部绕行(用更长静态窗口替代隐状态),精力转向
**R2 多模态 + R8 离线 RL** 这条不依赖循环算子的路线。
## 5. 与本季其他工作的衔接
- 进程内闭环已落地(pybot `run_loop`/`--loop`),P1-2 帧源(守护线程队列)与 P1-1 overrun 告警已修复
——见 [pybot_actpred_AUDIT_20260814.md](../reviews/pybot_actpred_AUDIT_20260814.md)。
- 数据飞轮已通:`--record` 录制 → 训练 → 推理 → `--loop` 闭环,为 R8 离线 RL 与 R13 持续学习
提供数据回路。
- `--record` 微动作与录制设计见 [pybot_micro_action_and_recording_20260813.md](pybot_micro_action_and_recording_20260813.md);
Hailo 部署链详见 [actpred_hailo_hat2_inference_research_20260813.md](actpred_hailo_hat2_inference_research_20260813.md)。
---
*本报告为研究方向调研(advisory),方案 A/R5/R7 的部署可行性标 verify_pending,
落地前需做 Hailo DFC 循环算子支持实测。*
@@ -320,18 +320,25 @@ class I2CBus:
---
## 9. 与 actpred 的集成展望(非本期范围)
## 9. 与 actpred 的集成(已实现:进程内闭环)
迁移完成后,"大脑-身体"闭环的最短路径:
原规划的"大脑-身体"闭环已在 `run_loop()` 中落地,采用进程内调用替代 FIFO 序列化:
```
actpred predict.py(图像+历史 → 动作向量 action_dim=18)
→ 向量转 @Action 单行(pybot 提供 action_row_from_vector() 工具函数)
→ pybot RobotStruct 增量执行(对应仓颉版 TODO:@Action 位置指示器)
→ MPU6050 姿态反馈回流 actpred(DRL 奖励信号)
camera 帧 → actpred.predict(F帧, H历史) → 动作向量(18) → pybot 下发执行
↑ ↓
history deque ←────────── 回写实际动作 ←────────────────────┘
```
同语言后,上述管线可在单进程内完成,无需序列化协议——这正是本次迁移最大的战略收益。
**用法**:`python3 -m pybot --loop CKPT --hz 10 scripts/std.mt`
- checkpoint 后缀 `.hef` → Hailo NPU 后端,否则 → PyTorch 后端
- `@Action` 表头列数 ≤ `action_dim`(默认 18),列索引即动作向量索引
- 闭环中剔除 `spk.delay` 阻塞(发声但不延时),节拍由 `hz` 主循环掌控
- 冷启动:F 帧重复同帧 + H 步零动作历史
- `--loop-steps N` 限步数调试;`-i/-o` FIFO 保留用于未来跨进程隔离
**与录制的衔接**:`--record DIR` 产出 actpred 格式 episode → actpred 训练 → 推理 checkpoint → `--loop` 闭环执行,形成完整数据飞轮。
---
@@ -0,0 +1,188 @@
# pybot & actpred 闭环功能审计报告(含 P1-2 帧源语义研究)
> 审查日期:2026-08-14 (Asia/Shanghai)
> 审查人:AI Assistant(SOLO Coder)
> 审查范围:`pybot/`(机器人运行时)与 `actpred/`(动作预测)两模块,重点是当日新增的进程内实时预测闭环(`run_loop()` / `--loop`)
> 代码基线:pybot 新增 `run_loop`/`_execute_action_vec`/`--loop` CLI;actpred 为部署链稳定版
>
> **审计背景**:当日实现进程内闭环后做专项审计。pybot 此前已有 bot4cj 审计 15 项(S01~S08/Q01~Q07)
> 全部修复的基线;actpred 已有 `actpred_CODE_AUDIT_20260808.md`(27 项已修复)与
> `actpred_CODE_AUDIT_20260812.md`(架构重写后首轮)。本报告**仅记录新发现问题**,
> 焦点是闭环路径与两模块接口。
---
## 一、审计结论摘要
| 级别 | pybot | actpred | 说明 |
|------|-------|---------|------|
| P0(崩溃/数据损坏) | 0 | 0 | 无 |
| P1(功能风险) | 2 | 2 | 见 §二、§三 |
| P2(代码质量) | 3 | 3 | 见 §四 |
**无 P0**。两模块既有防护(pybot Mock 总线逐字节等价、actpred 部署链层层数值验证)覆盖了大部分高危路径。
最高优先:**P1-2 闭环帧源语义**——闭环的相机读取路径与 camera 守护线程完全解耦,导致 `framerate`/`capacity` 配置在闭环中失效,且闭环周期被同步曝光锁死在 ~33ms+。本报告 §五 给出专项研究与修复方案。
---
## 二、pybot P1 发现
### P1-1 闭环节拍在长执行下静默失效
**位置**:[pybot/robot.py:245-258](../../pybot/pybot/robot.py#L245-L258)
```python
remain = dt - (time.monotonic() - t0)
if remain > 0:
time.sleep(remain)
```
**问题**:当某步"推理+执行"耗时超过 `1/hz`(NPU 抖动、首帧预热、系统调度抢占),`remain <= 0` 则不 sleep。连续多步超时会让实际控制频率**静默漂移**低于设定 `hz`,无任何累积补偿、无告警。闭环是硬实时假设,部署者无法察觉节拍已失守。
**影响**:`--hz 50` 设定下若实际只能跑 30Hz,动作节奏变慢但无人知晓,可能导致预测模型的"动作→结果"时序与训练分布失配。
**修复建议**:
- 超时步 `log.warning("loop overrun: step took %.1fms > %.1fms", elapsed*1000, dt*1000)`;
- 可选:统计 overrun 比例,超过阈值(如 10%)时提示降低 `hz`;
- 不做累积补偿(追赶会引入动作抖动,比降频更危险)。
### P1-2 闭环帧源与 camera 守护线程解耦(详见 §五 专项研究)
**位置**:[pybot/robot.py:359-371](../../pybot/pybot/robot.py#L359-L371) `_grab_frame` + [pybot/robot.py:247](../../pybot/pybot/robot.py#L247)
**问题**:闭环 `_grab_frame` 走 `camera.capture()`(同步曝光),而非 camera 守护线程维护的有界队列 `latest()`。后果:
1. `framerate`/`capacity` 配置在闭环中**完全不生效**(守护线程空转、队列白填);
2. 闭环每步同步阻塞等相机曝光(30fps ≈ 33ms),`hz` 上限被锁死;
3. `hz` 与相机 `framerate` 两个参数实际耦合却未声明。
**修复建议**:闭环改从 `camera.snapshot()` 取最近 F 帧。详见 §五。
---
## 三、actpred P1 发现
### P1-3 mt_parser 对 UTF-8 BOM 与首块表头的边界
**位置**:[actpred/mt_parser.py:33-37](../../actpred/mt_parser.py#L33-L37)
**问题**:`read_text(encoding="utf-8")` 对 UTF-8 with BOM 文件,首字符是 ``。`re.search(r"@Action\s+")` 仍能匹配(BOM 在 `@Action` 前不干扰定位),但**若 `@Action` 恰为文件第一个块**,`start_m.end()` 之后、`split("\n")` 之前的表头首列名可能粘附 BOM(如 `s1.A`)。下游用表头列名做 `.mt` 列对齐时可能失配。
**影响**:低概率(BOM 文件少见,且 `@Action` 常非首块),但 pybot 的 FSM 解析器对 BOM 的处理需与 actpred 对齐验证,否则两侧对同一文件的列名解析可能不一致——这正是两侧"语义锚点"测试未覆盖的盲区。
**修复建议**:`read_text(encoding="utf-8-sig")` 显式剥离 BOM;在 `test_actpred.py::TestMtParser` 补一个 BOM 文件用例,并在 pybot `test_parser.py` 补对应锚点用例。
### P1-4 DataRecorder 缺 output_dir 入口校验
**位置**:[actpred/dataset.py:136-156](../../actpred/dataset.py#L136-L156)
**问题**:`self.output_dir = Path(output_dir)` 后直接 `glob` + `images_dir.mkdir(parents=True)`。父目录靠 `parents=True` 隐式创建,正常路径 OK。但若 `output_dir` 是**已存在的文件**(非目录),`glob` 抛 `NotADirectoryError` 或 `mkdir` 抛出不含路径上下文的底层错误,排查困难。对比 pybot `EpisodeRecorder.__init__` 有显式 `output.mkdir(parents=True, exist_ok=True)` 先行([pybot/recorder.py:36](../../pybot/pybot/recorder.py#L36))。
**修复建议**:`__init__` 开头加 `self.output_dir.mkdir(parents=True, exist_ok=True)`,并对 `output_dir` 已存在但非目录的情况抛出含路径的清晰错误。
---
## 四、P2 代码质量发现(汇总)
### pybot
| # | 位置 | 问题 | 建议 |
|---|------|------|------|
| P2-a | [main.py:137](../../pybot/pybot/main.py#L137) | `_load_predictor` 用 `endswith(".hef")` 区分后端,大小写敏感且无 ONNX 分支,误传 `.HEF`/`.onnx` 会走 PyTorch 后端抛出晦涩错误 | 按扩展名白名单(`.hef`/`.onnx`/`.pt`)显式分发,或加 `--backend` 参数 |
| P2-b | [robot.py:281](../../pybot/pybot/robot.py#L281) 等 3 处 | `numpy` 在闭环路径 3 个函数各自 `import`;闭环本就依赖 actpred(必装 numpy) | 闭环路径顶部统一 import;脚本模式保留函数级 import 以守零依赖承诺 |
| P2-c | [tests/test_loop.py](../../pybot/tests/test_loop.py) | 未覆盖闭环 + recorder 集成、predictor 抛异常时的资源清理 | 补 `run_loop`+recorder 落盘验证、predictor 异常时 `with robot` 清理验证 |
### actpred
| # | 位置 | 问题 | 建议 |
|---|------|------|------|
| P2-d | [dataset.py:32-36](../../actpred/dataset.py#L32-L36) | 图像 `Resize((H,W))` 不保持宽高比,16:9 相机配 4:3 尺寸会畸变;训练/推理一致故对精度无害,但与真实几何脱节 | 文档注明取舍;换相机/分辨率时需重评估 |
| P2-e | [train.py:135](../../actpred/train.py#L135) | resume 时改 `--epochs` 会按新 T_max 重算 lr,用户可能误以为冻结原调度 | 文档注明 resume 改 epochs 的语义 |
| P2-f | [export_deploy.py:31-81](../../actpred/export_deploy.py#L31-L81) | `ManualAttnBlock` 从 `in_proj_weight` 切 Q/K/V 依赖 PyTorch 内部布局约定(非 API 承诺);已有导出时数值对比兜底 | 风险已缓解;保留导出数值验证作为强制门槛 |
---
## 五、P1-2 专项研究:闭环帧源语义
### 5.1 两条帧路径的真实差异
camera 提供两种帧访问([hd/camera.py](../../pybot/pybot/hd/camera.py)):
| 路径 | 实现 | 延迟特性 | 帧新旧 |
|------|------|---------|--------|
| `capture()` (L72-76) | 同步调 `picam.capture_array()` | **阻塞等一帧曝光**(30fps≈33ms) | "调用之后"的新帧 |
| `latest()` (L67-70) | 读守护线程有界队列队尾 | **无阻塞** | 最多落后一个抓帧周期 |
守护线程(`start_daemon`/`_loop`)按 `framerate` 持续抓帧填入 `deque(maxlen=capacity)`,满丢最旧。
### 5.2 闭环当前行为的问题
`run_loop` 每步 `frames.append(self._grab_frame(camera_var))`(L247),而 `_grab_frame`(L359-371)走 `camera.capture()`:
```
闭环步 → capture() → 同步阻塞 ~33ms(30fps 曝光)→ 拿到新帧
↓
camera 守护线程:空转(抓的帧填入队列,但无人消费)
```
**量化后果**(以相机 30fps、闭环目标 50Hz 为例):
- 每步 `capture()` 阻塞 ~33ms → 闭环实际周期 ≥ 33ms → **实际 hz ≤ 30**,设定 50 完全达不到;
- `framerate=30`、`capacity=4` 的配置在闭环里无任何作用(守护线程抓的帧全部过期丢弃);
- `hz` 与 `framerate` 必须手工对齐(hz ≤ framerate),但代码无任何校验或声明。
### 5.3 修复方案:闭环改走后台队列
闭环应消费守护线程的队列,而非同步抓帧。核心改动在 `run_loop` 取帧处:
```python
# 改进前:同步曝光,阻塞 ~33ms
frames.append(self._grab_frame(camera_var))
# 改进后:从守护线程队列取最近 F 帧,无阻塞
snap = camera_var.camera.snapshot() # [(ts, frame), ...] 旧→新
recent = snap[-F:] # 取最近 F 帧
if len(recent) < F: # 队列未填满:重复最旧帧兜底
recent = [snap[0]] * (F - len(recent)) + recent if snap else [self._grab_frame(camera_var)] * F
frames.clear(); frames.extend(f for _, f in recent)
```
**收益**:
- 闭环不再同步阻塞曝光,周期由 `hz` 主导(受限于推理+执行,~12ms);
- `framerate`/`capacity` 配置真正生效:相机高频抓帧,闭环消费最新 F 帧,**帧延迟从 33ms 降到 1/framerate**;
- `hz` 与 `framerate` 解耦(hz 控制节奏、framerate 控制帧新鲜度),但需文档建议 `framerate ≥ 2×hz`。
**代价与对策**:
- 帧可能略旧(最多 1/framerate):对 50Hz 控制,33ms 旧帧可接受(舵机机械惯性 ~100ms 量级);
- `snapshot()` 返回带时间戳,可顺带校验帧新鲜度(队尾 ts 距今 > 阈值时 warning);
- 冷启动 `_loop_cold_start` 同样改从队列取,队列空时回退 `_grab_frame` 合成帧。
### 5.4 与录制路径的一致性
脚本模式 `run()` 的录制(L180/190)也用 `_grab_frame`。若闭环改走队列,需同步评估:
- 录制用 `capture()`(同步新帧)反而合理——录制追求"动作↔帧"严格对齐,新帧更准确;
- 闭环用队列(最新可得帧)合理——实时控制追求低延迟,容忍轻微旧帧。
两者语义本可不同,不必强行统一。但应在文档明确"录制=同步新帧,闭环=队列最新帧"的取舍。
### 5.5 验证建议
修复后补测试([tests/test_loop.py](../../pybot/tests/test_loop.py)):
- 注入 FakeGrabber 的 Camera,验证闭环从队列取最近 F 帧(而非 capture);
- 队列未满时重复最旧帧兜底;
- 断言闭环周期不再含 capture 阻塞(Mock 计时)。
---
## 六、修复优先级建议
| 优先级 | 项 | 理由 |
|--------|-----|------|
| 1 | P1-2 闭环帧源(§五) | 直接决定 `--loop` 真机延迟特性与 hz/framerate 语义,影响最大 |
| 2 | P1-1 节拍 overrun 告警 | 一行 warning + 统计,低成本高诊断价值 |
| 3 | P1-4 DataRecorder 入口校验 | 一行 mkdir + 校验,与 pybot 对齐 |
| 4 | P1-3 mt_parser BOM | 低概率但涉及两侧语义锚点一致性 |
| 5 | P2 各项 | 文档/测试补强,随下次迭代带入 |
---
*本报告为闭环功能专项审计,修复前建议在树莓派真机验证 P1-2 的实际延迟影响(capture vs 队列两种模式的周期对比)。*
+30 -2
View File
@@ -28,9 +28,37 @@ cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法
./_run.sh --dry-run scripts/std.mt # Mock 总线,动作序列到 stdout
./_run.sh --test roundtrip in.mt out.mt # 解析↔序列化往返自检
./_run.sh --verbose scripts/std.mt # DEBUG 日志(stderr)
./_run.sh --record data/ep scripts/std.mt # 录制 actpred 格式 episode
```
`-i` / `-o` 参数位保留(reserved),未来对接 [actpred](../actpred/) 动作预测。
`--record` 时若脚本含 `@MPU6050_BIND` 块,逐帧同步落盘 `imu.npy`
(每传感器 8 槽 roll,pitch,ax,ay,az,gx,gy,gz;多个传感器(≤4)按块行顺序
拼接,shape (T, 8×N),与图像/动作按帧时刻天然对齐);
无 IMU 硬件则保持旧格式(仅 images + actions.npy)。
`-i` / `-o` 参数位保留(reserved),未来对接跨进程 FIFO。
### 实时预测闭环(`--loop`)
`--loop CKPT` 进入进程内闭环:采样 → actpred 推理 → 执行 → 回写历史。
脚本提供硬件配置与 `@Action` 表头(动作向量索引映射),不执行脚本动作行。
```bash
./_run.sh --loop ../actpred/checkpoints/best_inference.pt --hz 10 scripts/std.mt
./_run.sh --loop actpred_pretrain.hef --hz 20 --loop-steps 100 scripts/std.mt # NPU 后端
```
- checkpoint 后缀 `.hef` → Hailo NPU 后端,否则 → PyTorch 后端
- `hz` 须 ≥ 推理 + 执行耗时(ONNX ~11ms / Hailo ~12ms,hz ≤ 50 安全)
- `@Action` 表头列数须 ≤ `action_dim`(默认 18),闭环中跳过 `spk.delay` 阻塞
- **帧源**:闭环从 camera 守护线程队列取最近 F 帧(无阻塞),`@Camera` 的
`framerate` 建议 ≥ 2×`hz` 以保证帧新鲜度;`capacity` 应 ≥ F(帧窗长度)。
部署时若日志出现 `loop overrun` 告警,说明 `hz` 超过推理+执行能力,应降低 `hz`
- **姿态回流(R2)**:脚本含 `@MPU6050_BIND` 且模型带 imu 输入
(`predictor.imu_dim` 非 None)时,每步读取 MPU6050 最新姿态随帧/历史
一同传给推理器(原始量纲,归一化在推理端完成);多个传感器(≤4)按
块行顺序拼接为 8×N 向量,再 pad/truncate 到 `predictor.imu_dim`;
模型无 imu 输入时不接入推理(若带 `--record` 仍会读取落盘 imu.npy)
## 目录
@@ -42,7 +70,7 @@ scripts -> ../bot4cj/scripts # .mt 脚本与仓颉版共享
tests/ # pytest 单元测试(Mock 总线,无需硬件)
```
插件查找顺序:`$PYBOT_MODULES_PATH`(冒号分隔)→ 包内置 `modules/` → `./modules/`。
插件查找顺序:`$PYBOT_MODULES_PATH`(`os.pathsep` 分隔:Linux `:` / Windows `;`)→ 包内置 `modules/` → `./modules/`。
## 开发
+42 -1
View File
@@ -43,6 +43,14 @@ def _build_argparser():
"--dry-run 下用合成帧)")
p.add_argument("--action-dim", type=int, default=18,
help="动作向量维度(默认 18,与 actpred 一致)")
p.add_argument("--loop", metavar="CKPT", default=None,
help="实时预测闭环:加载 actpred checkpoint,"
"采样→推理→执行→回写(不执行脚本 @Action 行,"
"仅用其表头做动作向量索引映射)")
p.add_argument("--hz", type=float, default=10.0,
help="闭环控制频率 Hz(默认 10,须 ≥ 推理+执行耗时)")
p.add_argument("--loop-steps", type=int, default=None, metavar="N",
help="闭环步数上限(默认无限,调试用)")
return p
@@ -91,6 +99,12 @@ def main(argv=None):
if args.dry_run:
mocks = (i2c.use_mock(), spi.use_mock())
predictor = None
if args.loop:
predictor = _load_predictor(args.loop)
if predictor is None:
return 2
source = open(args.script, encoding="utf-8") if args.script else sys.stdin
try:
robot = RobotStruct(source, dry_run=args.dry_run)
@@ -100,7 +114,11 @@ def main(argv=None):
recorder = EpisodeRecorder(args.record,
action_dim=args.action_dim)
with robot:
robot.run(recorder=recorder)
if predictor is not None:
robot.run_loop(predictor, hz=args.hz,
max_steps=args.loop_steps, recorder=recorder)
else:
robot.run(recorder=recorder)
finally:
if args.script:
source.close()
@@ -110,5 +128,28 @@ def main(argv=None):
return 0
def _load_predictor(checkpoint):
"""进程内构建 actpred 推理器;失败返回 None
pybot 不硬依赖 actpred:仅 --loop 时才尝试 import,actpred 不在
sys.path 时给出可操作提示。checkpoint 目录名决定后端(.hef → Hailo
NPU,否则 → PyTorch),推理类由 actpred 提供。
"""
try:
if checkpoint.endswith(".hef"):
from actpred.hailo_infer import HailoPredictor as _P
else:
from actpred.predict import ActionPredictorInference as _P
except ImportError:
logging.error("--loop 需要 actpred 模块(把 blackbird 根目录加入 "
"PYTHONPATH,或在 actpred/ 同级运行)")
return None
try:
return _P(checkpoint)
except Exception:
logging.exception("加载 actpred checkpoint 失败: %s", checkpoint)
return None
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -52,7 +52,7 @@ def search_dirs():
"""插件搜索目录(按优先级):$PYBOT_MODULES_PATH → 包内置 → cwd"""
dirs = []
env = os.environ.get("PYBOT_MODULES_PATH", "")
for p in env.split(":"):
for p in env.split(os.pathsep):
if p:
dirs.append(Path(p))
dirs.append(Path(__file__).resolve().parent.parent / "modules") # 包内置
+40 -8
View File
@@ -6,6 +6,7 @@
images/
0000.jpg, 0001.jpg, ...
actions.npy # shape (T, action_dim) float32
imu.npy # 可选,shape (T, imu_dim) float32(MPU6050 姿态回流,R2)
numpy / Pillow 为可选依赖(lazy import),仅 --record 时需要,
不影响 pybot 的零依赖运行时承诺。
@@ -18,19 +19,28 @@ log = logging.getLogger("pybot.recorder")
class EpisodeRecorder:
"""录制 actpred 格式的 episode(images/*.jpg + actions.npy)
"""录制 actpred 格式的 episode(images/*.jpg + actions.npy [+ imu.npy])
用法:
rec = EpisodeRecorder("./actpred/data", action_dim=18)
for frame, action in stream:
rec.record(frame, action)
rec.record(frame, action, imu_vec=imu_or_None)
rec.flush()
imu_vec:可选 MPU6050 姿态向量(单传感器 roll,pitch,ax,ay,az,gx,gy,gz;
多传感器按脚本 @MPU6050_BIND 行顺序拼接为 8×N,最多 4 个)。
RobotStruct 会在录制前把 imu_dim 同步为 8×N(N=传感器数)。
只要同一 record 调用内同时传入帧/动作/姿态,三者即按帧时刻天然对齐。
全程 None(无 IMU 硬件)则不产出 imu.npy,保持旧格式兼容。
"""
def __init__(self, output_dir, action_dim=18):
def __init__(self, output_dir, action_dim=18, imu_dim=8):
self.action_dim = action_dim
self.imu_dim = imu_dim
self._frame_idx = 0
self._actions = []
self._imus = []
self._has_imu = False
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
@@ -51,14 +61,18 @@ class EpisodeRecorder:
except FileExistsError:
ep_idx += 1
log.info("recording to %s (action_dim=%d)",
self.episode_dir, action_dim)
log.info("recording to %s (action_dim=%d imu_dim=%d)",
self.episode_dir, action_dim, imu_dim)
def record(self, frame, action_vec):
"""记录一帧 + 动作向量
def record(self, frame, action_vec, imu_vec=None):
"""记录一帧 + 动作向量 [+ 姿态向量]
frame: np.ndarray (H,W,3) uint8 或 None(跳过图像保存)
action_vec: list/np.array,长度任意,内部 pad/truncate 到 action_dim
imu_vec: 可选 list/np.array/dict,长度任意,pad/truncate 到 imu_dim;
dict 时按 (roll,pitch,ax,ay,az,gx,gy,gz) 顺序取值(单传感器;
多传感器请传 RobotStruct._read_imus 拼好的 8×N 向量)。
为 None 时该帧记零向量(混合数据集中标记"无姿态")。
"""
import numpy as np
@@ -68,6 +82,19 @@ class EpisodeRecorder:
vec[:n] = src[:n]
self._actions.append(vec)
if imu_vec is None:
ivec = np.zeros(self.imu_dim, dtype=np.float32)
else:
self._has_imu = True
if isinstance(imu_vec, dict):
imu_vec = [imu_vec.get(k, 0.0) for k in
("roll", "pitch", "ax", "ay", "az", "gx", "gy", "gz")]
ivec = np.zeros(self.imu_dim, dtype=np.float32)
isrc = np.asarray(imu_vec, dtype=np.float32).ravel()
m = min(len(isrc), self.imu_dim)
ivec[:m] = isrc[:m]
self._imus.append(ivec)
if frame is not None:
from PIL import Image
img = Image.fromarray(frame)
@@ -77,11 +104,16 @@ class EpisodeRecorder:
self._frame_idx += 1
def flush(self):
"""保存 actions.npy,返回 episode 路径"""
"""保存 actions.npy(及有姿态时的 imu.npy),返回 episode 路径"""
import numpy as np
if self._actions:
actions = np.array(self._actions, dtype=np.float32)
np.save(self.episode_dir / "actions.npy", actions)
# 仅当确实采到非零姿态才落盘 imu.npy,避免全程无 IMU 时产生误导性文件
if self._has_imu:
imus = np.array(self._imus, dtype=np.float32)
np.save(self.episode_dir / "imu.npy", imus)
log.info("recorded imu.npy shape=%s", imus.shape)
log.info("recorded %d frames to %s",
self._frame_idx, self.episode_dir)
return str(self.episode_dir)
+219 -2
View File
@@ -11,6 +11,8 @@
import atexit
import logging
import time
from collections import deque
from . import plugin
from .hardvars import (AliasVar, CameraVar, I2CVar, MPU6050Var, ServoVar,
@@ -19,6 +21,10 @@ from .parser import MatrixParser, MatrixSaver, get_mt, parse_mt
log = logging.getLogger("pybot.robot")
# MPU6050 姿态向量键序(recorder.record 的 dict 取值顺序与此一致,
# mpu6050.MPU6050._latest 的键集合相同)
IMU_KEYS = ("roll", "pitch", "ax", "ay", "az", "gx", "gy", "gz")
# 已知块名 → 硬件变量工厂(Action 特殊处理;未知块 → 插件)
_FACTORIES = {
"I2C": I2CVar,
@@ -154,11 +160,18 @@ class RobotStruct:
delay_idx = self._find_delay_idx(columns)
camera_var = None
imu_vars = []
if recorder is not None:
camera_var = self._find_camera()
if camera_var is None and not self.dry_run:
raise ValueError(
"--record 需要 @Camera 块(或使用 --dry-run 生成合成帧)")
imu_vars = self._find_imus()
if not imu_vars:
log.info("--record: 无 @MPU6050_BIND 块,不产出 imu.npy")
else:
# 槽位宽度 8×N(按块顺序拼接),录制前同步给 recorder
recorder.imu_dim = len(IMU_KEYS) * len(imu_vars)
prev_vals = None
@@ -178,7 +191,8 @@ class RobotStruct:
recorder.record(
self._grab_frame(camera_var),
self._build_action_vec(
step_vals, recorder.action_dim))
step_vals, recorder.action_dim),
imu_vec=self._read_imus(imu_vars))
self._execute_step(step_vals, columns)
if self.dry_run:
import time
@@ -187,7 +201,8 @@ class RobotStruct:
if recorder is not None:
recorder.record(
self._grab_frame(camera_var),
self._build_action_vec(vals, recorder.action_dim))
self._build_action_vec(vals, recorder.action_dim),
imu_vec=self._read_imus(imu_vars))
self._execute_step(vals, columns)
prev_vals = vals
@@ -196,6 +211,171 @@ class RobotStruct:
path = recorder.flush()
log.info("episode saved: %s", path)
# ---------------- 实时预测闭环 ----------------
def run_loop(self, predictor, hz=10.0, max_steps=None, recorder=None):
"""实时预测闭环:无限循环 采样→推理→执行→回写历史
predictor: actpred 推理器(鸭子类型),需具备
frame_len / history_len / action_dim 属性与
predict(images, action_history) -> (action_dim,) 方法。
pybot 不 import 具体推理类,保持零依赖承诺。
若额外声明 imu_dim 属性(R2 方案一),则以
predict(images, action_history, imu=(imu_dim,)) 调用;
脚本无 @MPU6050_BIND 块时 imu 为零向量。多个 MPU6050
(最多 4 个)按脚本块行顺序拼接为 8×N 原始向量。
hz: 控制频率(周期 = 1/hz 秒,须 ≥ 推理 + 执行耗时)
max_steps: 步数上限,None 表示无限循环(Ctrl-C 退出)
recorder: 可选 EpisodeRecorder,闭环中同步录制 (frame, action)
与 run() 的差异:run() 是脚本驱动一次性执行(行内 delay 分解为
10ms 微步插值);run_loop() 由 predictor 逐步产出动作,节奏由
hz 主循环掌控,每步直接下发不做行分解(delay 仅作回放兼容,
闭环中跳过其发声阻塞)。
帧源:闭环从 camera 守护线程的有界队列取最近 F 帧(低延迟,
让 framerate/capacity 配置生效),而非 capture() 同步曝光
(会阻塞 ~1/framerate 秒,把 hz 锁死在相机帧率之下)。dry_run
或队列空时回退 _grab_frame 合成帧。
"""
import numpy as np
camera_var = self._find_camera()
if camera_var is None and not self.dry_run:
raise ValueError("闭环需要 @Camera 块(或使用 --dry-run 生成合成帧)")
imu_vars = self._find_imus()
# 鸭子类型约定:predictor 声明 imu_dim 属性即接受 imu= 关键字
# (方案一:独立 IMU token);未声明则保持旧两参调用,向后兼容
use_imu = getattr(predictor, "imu_dim", None) is not None
if not imu_vars:
if use_imu:
log.warning("predictor 声明 imu_dim 但脚本无 @MPU6050_BIND "
"块,imu 将以零向量输入(等价于无姿态先验)")
else:
if recorder is not None:
recorder.imu_dim = len(IMU_KEYS) * len(imu_vars)
log.info("closed loop imu: %s → predictor %s",
",".join(v.name for v in imu_vars),
"imu= 接入" if use_imu else "不接入(仅录制)")
action = self.find_mt("Action")
if action is None:
raise ValueError("no @Action matrix in script")
columns = self._validate_action(action)
F, H = predictor.frame_len, predictor.history_len
action_dim = predictor.action_dim
if len(columns) > action_dim:
raise ValueError(
f"@Action 列数 {len(columns)} 超过 predictor.action_dim "
f"{action_dim},无法按索引对齐")
exec_cols = self._loop_exec_columns(columns)
frames = deque(maxlen=F)
history = deque(maxlen=H)
self._loop_cold_start(frames, history, camera_var, action_dim)
dt = 1.0 / hz
step = 0
overrun = 0
log.info("closed loop start: hz=%.1f F=%d H=%d action_dim=%d",
hz, F, H, action_dim)
try:
while max_steps is None or step < max_steps:
t0 = time.monotonic()
frames.extend(self._loop_recent_frames(camera_var, F)) # 1 采样
imu = self._read_imus(imu_vars)
if use_imu: # 2 推理
act = predictor.predict(list(frames), np.array(history),
imu=self._pad_imu(
imu, predictor.imu_dim))
else:
act = predictor.predict(list(frames), np.array(history))
act = np.asarray(act, dtype=np.float32).reshape(-1)
self._execute_action_vec(act, exec_cols) # 3 执行
history.append(act.copy()) # 4 回写
if recorder is not None:
recorder.record(
frames[-1],
self._build_action_vec(act, recorder.action_dim),
imu_vec=imu)
step += 1
remain = dt - (time.monotonic() - t0) # 节拍
if remain > 0:
time.sleep(remain)
else:
overrun += 1
log.warning("loop overrun: step %d took %.1fms > %.1fms "
"(推理+执行超过周期,实际频率已低于 %.1fHz)",
step, (dt - remain) * 1000, dt * 1000, hz)
finally:
# max_steps 正常退出与 Ctrl-C(KeyboardInterrupt)都要落盘,
# 否则闭环录制的 actions.npy/imu.npy 全部丢失
if recorder is not None:
path = recorder.flush()
log.info("episode saved: %s", path)
if overrun:
log.warning("closed loop overrun %d/%d steps (%.0f%%),"
"建议降低 --hz 或检查推理后端延迟",
overrun, step, 100.0 * overrun / max(step, 1))
log.info("closed loop stop after %d steps", step)
def _loop_recent_frames(self, camera_var, F):
"""从 camera 守护线程队列取最近 F 帧(旧→新),无阻塞
队列未填满 F 帧时重复最旧帧兜底;队列完全空(dry_run / 守护线程
刚启动)时回退 _grab_frame 合成帧。相比 capture() 同步曝光,
从队列消费让 framerate/capacity 配置生效,闭环周期不被曝光阻塞。
"""
if camera_var is None:
return [self._grab_frame(camera_var)] * F
snap = camera_var.camera.snapshot() # [(ts, frame), ...] 旧→新
if not snap:
return [self._grab_frame(camera_var)] * F
recent = [f for _, f in snap[-F:]]
if len(recent) < F: # 队列未满:重复最旧帧
recent = [recent[0]] * (F - len(recent)) + recent
return recent
def _loop_exec_columns(self, columns):
"""闭环执行列:剔除 Speaker.delay(发声阻塞会卡死实时节拍)
返回 [(action_idx, dev, param)],action_idx 是动作向量索引(按
@Action 表头顺序,与 actpred 的 action_dim 对齐)。
"""
out = []
for j, (dev, param) in enumerate(columns):
rp = self._resolve_param(param)
if isinstance(self.M.get(dev), SpeakerVar) and rp == "delay":
continue
out.append((j, dev, rp))
return out
def _loop_cold_start(self, frames, history, camera_var, action_dim):
"""冷启动:F 帧滑窗从队列取最近帧(空则合成帧重复),H 步零动作
帧源与主循环一致(_loop_recent_frames),避免冷启动与运行期帧源
不一致。history 用零向量填满是保守初值,避免随机姿态跳变损坏舵机。
"""
import numpy as np
frames.extend(self._loop_recent_frames(camera_var, frames.maxlen))
for _ in range(history.maxlen):
history.append(np.zeros(action_dim, dtype=np.float32))
def _execute_action_vec(self, act, exec_cols):
"""把动作向量按 exec_cols 索引映射到设备并直接下发(不分解微步)
SpeakerVar.play 强制要求 delay 键;闭环已剔除 delay 列(避免发声
阻塞卡死节拍),此处为其补 delay=0 —— 发声但不阻塞,节拍由 hz 掌控。
"""
row_sets = {}
for j, dev, rp in exec_cols:
row_sets.setdefault(dev, {})[rp] = float(act[j])
for dev, params in row_sets.items():
if isinstance(self.M[dev], SpeakerVar) and "delay" not in params:
params["delay"] = 0
self.M[dev].play(params)
def _resolve_row(self, action, row, n_cols):
"""解析一行的全部值(占位符继承)"""
return [parse_mt(action, row, j) for j in range(n_cols)]
@@ -245,6 +425,43 @@ class RobotStruct:
return var
return None
def _find_imus(self):
"""按脚本块顺序收集全部 MPU6050Var(无则空列表,最多 4 个)"""
return [v for v in self.M.values() if isinstance(v, MPU6050Var)]
def _read_imus(self, imu_vars):
"""读全部 MPU6050 最新姿态,按块顺序拼接为 (8×N,) 原始向量
imu_vars 为空返回 None(无传感器:recorder 不产出 imu.npy);
单个传感器读失败其 8 槽记零并 log。latest() 冷启动返回全零
dict,语义安全无需特判。
"""
if not imu_vars:
return None
import numpy as np
nk = len(IMU_KEYS)
out = np.zeros(nk * len(imu_vars), dtype=np.float32)
for k, var in enumerate(imu_vars):
try:
latest = var.play({})
except Exception:
log.exception("imu read failed: %s", var.name)
continue
out[k * nk:(k + 1) * nk] = [latest.get(key, 0.0)
for key in IMU_KEYS]
return out
@staticmethod
def _pad_imu(vec, imu_dim):
"""拼接姿态向量 → pad/truncate 到 predictor 的 imu_dim(None → 全零)"""
import numpy as np
out = np.zeros(imu_dim, dtype=np.float32)
if vec is not None:
src = np.asarray(vec, dtype=np.float32).ravel()
n = min(len(src), imu_dim)
out[:n] = src[:n]
return out
def _build_action_vec(self, vals, action_dim):
"""从值列表构建动作向量,pad/truncate 到 action_dim"""
import numpy as np
+3
View File
@@ -12,6 +12,9 @@ from pybot.hd import i2c, spi # noqa: E402
SCRIPTS_DIR = ROOT / "scripts"
if not SCRIPTS_DIR.is_dir():
# Windows:git 符号链接落盘为文本文件(内容 = 目标相对路径),手动解析
SCRIPTS_DIR = (ROOT / SCRIPTS_DIR.read_text().strip()).resolve()
@pytest.fixture()
+460
View File
@@ -0,0 +1,460 @@
"""实时预测闭环测试:run_loop() + Mock predictor + Mock camera
全部在 dry_run / mock I2C 下运行,不碰真实硬件与真实 actpred。
predictor 用鸭子类型桩(具备 frame_len/history_len/action_dim/predict),
验证闭环的冷启动、采样→推理→执行→回写、索引映射与节拍。
"""
import numpy as np
import pytest
from pybot.robot import RobotStruct
# @Camera + @Servo + @Speaker + @Action(含别名 A=angle / F=frequency)
# @Action 表头 4 列:s1.A s2.A spk.F spk.delay → 动作向量 action_dim=4
LOOP_MT = """
@Alias
name alias;
A angle;
F frequency;
@
@I2C
name path;
i2c-1 /dev/i2c-1;
@
@PCA9685
name I2C address;
pca9685-0 i2c-1 0x40;
@
@Servo
name max min offset controller channel;
s1 90 - - pca9685-0 0;
s2 180 - - - 1;
@
@Speaker
name channels sample_rate volumn max_f min_f;
spk 2 48000 0.6 5000 -;
@
@Camera
name width height framerate capacity;
cam0 320 240 15 4;
@
@Action
s1.A s2.A spk.F spk.delay;
45 0 10 20;
@
"""
# 无 @Camera 的脚本(验证闭环缺相机报错)
NO_CAMERA_MT = """
@Alias
name alias;
A angle;
@
@I2C
name path;
i2c-1 /dev/i2c-1;
@
@PCA9685
name I2C address;
pca9685-0 i2c-1 0x40;
@
@Servo
name max min offset controller channel;
s1 90 - - pca9685-0 0;
@
@Action
s1.A;
45;
@
"""
class MockPredictor:
"""鸭子类型推理器桩:记录每次调用,返回固定/递增动作向量"""
def __init__(self, frame_len=3, history_len=2, action_dim=4):
self.frame_len = frame_len
self.history_len = history_len
self.action_dim = action_dim
self.calls = [] # [(frames_list, history_array)]
self._step = 0
def predict(self, images, action_history):
self.calls.append((list(images), np.array(action_history)))
# 返回可辨识的动作:s1=step+1, s2=step+2, spk.F=step+3, delay=0
act = np.arange(self._step + 1, self._step + 1 + self.action_dim,
dtype=np.float32)
self._step += 1
return act
def _make_robot(mt_text, mock_i2c):
import io
return RobotStruct(io.StringIO(mt_text), dry_run=True)
# ---------------- 冷启动 ----------------
def test_cold_start_fills_windows(mock_i2c):
"""冷启动:F 帧重复同帧 + H 步历史全零,首步推理前即填满"""
robot = _make_robot(LOOP_MT, mock_i2c)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.active()
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
# 首次调用 predict:frames 应为 F=3 帧,history 应为 H=2 步全零
frames, history = pred.calls[0]
assert len(frames) == 3
assert history.shape == (2, 4)
assert np.allclose(history, 0.0)
def test_history_window_slides(mock_i2c):
"""回写:每步把 predictor 返回的动作 append 进 history,窗口滑动"""
robot = _make_robot(LOOP_MT, mock_i2c)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.active()
robot.run_loop(pred, hz=1000.0, max_steps=3)
robot.deactive()
# 第 3 次调用时 history 应含前两步返回的动作([1..4] 与 [2..5])
_, history = pred.calls[2]
assert np.allclose(history[0], [1, 2, 3, 4])
assert np.allclose(history[1], [2, 3, 4, 5])
# ---------------- 动作向量 → 设备映射 ----------------
def test_execute_action_vec_maps_by_index(mock_i2c):
"""18 维向量按 @Action 表头索引映射;舵机角度写入 PCA9685"""
robot = _make_robot(LOOP_MT, mock_i2c)
fd = robot.M["i2c-1"].play({})["fd"]
robot.active()
mock_i2c.journal.clear()
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
# 首步返回 [1,2,3,4]:s1=1°, s2=2°, spk.F=3
writes = [(e[2], e[3]) for e in mock_i2c.journal
if e[0] == "write_reg" and e[1] == fd]
# s1=1° → 脉宽 0.5+2/180 → offval = int((0.5+2/180)*4096/20)
off1 = int((0.5 + 2 * 1 / 180) * 4096 / 20)
off2 = int((0.5 + 2 * 2 / 180) * 4096 / 20)
reg = lambda ch: 0x06 + 4 * ch # noqa: E731
assert (reg(0) + 2, off1 & 0xFF) in writes
assert (reg(1) + 2, off2 & 0xFF) in writes
def test_speaker_delay_non_blocking(mock_i2c):
"""闭环剔除 spk.delay 阻塞:传给 speaker 的 delay 恒为 0(发声不阻塞)"""
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active()
spk = robot.M["spk"]
played = []
orig_play = spk.play
spk.play = lambda p: played.append(dict(p)) or orig_play(p)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
# 每次 play 含 frequency,且 delay 被强制为 0(非阻塞)
assert played, "spk 应被 play(含 frequency)"
for p in played:
assert "frequency" in p
assert p.get("delay") == 0
# ---------------- 校验与边界 ----------------
def test_loop_requires_camera(mock_i2c):
"""非 dry_run 且无 @Camera → 报错;dry_run 下合成帧可用"""
robot = RobotStruct(__import__("io").StringIO(NO_CAMERA_MT), dry_run=False)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=1)
with pytest.raises(ValueError, match="@Camera"):
robot.run_loop(pred, max_steps=1)
robot.deactive()
def test_loop_requires_action_block(mock_i2c):
"""缺 @Action 块 → 报错"""
mt = """
@Camera
name width height framerate capacity;
cam0 320 240 15 4;
@
"""
robot = _make_robot(mt, mock_i2c)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
with pytest.raises(ValueError, match="@Action"):
robot.run_loop(pred, max_steps=1)
robot.deactive()
def test_loop_action_dim_too_small(mock_i2c):
"""@Action 列数超过 predictor.action_dim → 报错"""
robot = _make_robot(LOOP_MT, mock_i2c)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=2) # < 4 列
robot.active()
with pytest.raises(ValueError, match="action_dim"):
robot.run_loop(pred, max_steps=1)
robot.deactive()
# ---------------- 节拍 ----------------
def test_loop_pacing(mock_i2c):
"""节拍:hz 控制周期下界(推理+执行 < 周期时 sleep 补足)"""
import time
robot = _make_robot(LOOP_MT, mock_i2c)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.active()
t0 = time.monotonic()
robot.run_loop(pred, hz=50.0, max_steps=5) # 周期 20ms × 5 = ≥100ms
elapsed = time.monotonic() - t0
robot.deactive()
assert elapsed >= 0.09 # 允许少量计时误差
# ---------------- 帧源:守护线程队列 ----------------
class _SeqGrabber:
"""递增序号帧源(dry_run 下闭环注入用)"""
def __init__(self):
self.n = 0
def __call__(self):
self.n += 1
return self.n
def test_loop_consumes_daemon_queue(mock_i2c):
"""闭环从守护线程队列取最近 F 帧,而非 capture() 同步曝光
dry_run 下守护线程不启动,手动 _grab_once 填队列模拟运行期队列。
"""
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active()
cam = robot.M["cam0"].camera
cam._grabber = _SeqGrabber()
for _ in range(5): # 填 5 帧:队列容量 4,保留最新 [2,3,4,5]
cam._grab_once()
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
frames, _ = pred.calls[0]
# 帧来自序号帧源(int),闭环应取队列最近 F=3 帧 [3,4,5](旧→新递增)
assert all(isinstance(f, int) for f in frames)
assert frames == [3, 4, 5], "应取队列中最近 F=3 帧(旧→新)"
def test_loop_queue_underfill_repeats_oldest(mock_i2c):
"""队列未满 F 帧时:重复最旧帧兜底"""
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active()
cam = robot.M["cam0"].camera
cam._grabber = _SeqGrabber()
# 不启动守护线程,手动抓 1 帧入队(队列只有 1 帧 < F=3)
cam._grab_once()
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
frames, _ = pred.calls[0]
assert frames == [1, 1, 1], "队列只有 1 帧时应重复最旧帧填满 F"
def test_loop_empty_queue_falls_back_synthetic(mock_i2c):
"""队列完全空(dry_run 无守护线程)时回退合成帧"""
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active() # dry_run:守护线程不启动,队列恒空
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=1)
robot.deactive()
frames, _ = pred.calls[0]
# dry_run 合成帧是 numpy 数组(HWC uint8),非 int
assert all(isinstance(f, np.ndarray) for f in frames)
assert frames[0].shape == (240, 320, 3) # LOOP_MT @Camera 320x240
# ---------------- IMU 姿态回流(R2 方案一:独立 IMU token) ----------------
# 在 LOOP_MT 基础上加 @MPU6050_BIND(i2c-1 已定义,相机块之前插入)
LOOP_IMU_MT = LOOP_MT.replace(
"@Camera",
"""@MPU6050_BIND
name I2C addr;
mpu6050-0 i2c-1 0x69;
@
@Camera""")
class ImuPredictor(MockPredictor):
"""声明 imu_dim 的推理器桩:闭环应以 imu= 关键字调用"""
def __init__(self, *args, imu_dim=8, **kwargs):
super().__init__(*args, **kwargs)
self.imu_dim = imu_dim
self.imus = [] # 每步收到的 imu 向量
def predict(self, images, action_history, imu=None):
self.imus.append(None if imu is None else np.asarray(imu).copy())
return super().predict(images, action_history)
def _freeze_imu(robot, name="mpu6050-0", **vals):
"""停掉采样线程并注入固定姿态(避免 10ms 采样覆盖测试值)"""
sensor = robot.M[name].sensor
sensor.stop_daemon()
sensor.wait_daemon()
sensor._latest.update(**vals)
def test_loop_imu_passed_to_predictor(mock_i2c):
"""predictor 声明 imu_dim + 脚本含 @MPU6050_BIND → 每步 imu= 向量"""
robot = _make_robot(LOOP_IMU_MT, mock_i2c)
robot.active()
_freeze_imu(robot, roll=1.5, pitch=-2.0, gz=30.0)
pred = ImuPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=3)
robot.deactive()
assert len(pred.imus) == 3
for imu in pred.imus:
assert imu is not None and imu.shape == (8,)
# IMU_KEYS 顺序:roll pitch ax ay az gx gy gz
assert imu[0] == 1.5 and imu[1] == -2.0 and imu[7] == 30.0
def test_loop_imu_zero_without_bind(mock_i2c):
"""predictor 声明 imu_dim 但脚本无 @MPU6050_BIND → imu 零向量输入"""
robot = _make_robot(LOOP_MT, mock_i2c)
robot.active()
pred = ImuPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=2)
robot.deactive()
assert len(pred.imus) == 2
for imu in pred.imus:
assert imu is not None and imu.shape == (8,)
assert np.allclose(imu, 0.0)
def test_loop_imu_not_passed_to_legacy_predictor(mock_i2c):
"""未声明 imu_dim 的旧推理器:保持两参调用(向后兼容)"""
robot = _make_robot(LOOP_IMU_MT, mock_i2c)
robot.active()
_freeze_imu(robot, roll=9.0)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=2) # 不炸即兼容
robot.deactive()
assert len(pred.calls) == 2
def test_loop_imu_recorded(mock_i2c, tmp_path):
"""闭环录制:含 IMU 时 imu.npy 与 actions.npy 帧对齐(T 一致)"""
from pybot.recorder import EpisodeRecorder
robot = _make_robot(LOOP_IMU_MT, mock_i2c)
robot.active()
_freeze_imu(robot, roll=3.0, ay=0.5)
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=4, recorder=rec)
robot.deactive()
ep = tmp_path / "episode_0000"
actions = np.load(ep / "actions.npy")
imus = np.load(ep / "imu.npy")
assert actions.shape == (4, 18)
assert imus.shape == (4, 8) # 与 actions 帧数对齐
assert np.allclose(imus[:, 0], 3.0) # roll
assert np.allclose(imus[:, 3], 0.5) # ay
# ---------------- 多 MPU6050(最多 4 个,8×N 槽位拼接) ----------------
# 双传感器脚本(槽位顺序对齐 std.mt 前两行:i2c-1 上 0x69 / 0x68)
LOOP_IMU2_MT = LOOP_MT.replace(
"@Camera",
"""@MPU6050_BIND
name I2C addr;
mpu6050-0 i2c-1 0x69;
mpu6050-1 i2c-1 0x68;
@
@Camera""")
def test_loop_imu_multi_sensor_concat(mock_i2c):
"""多 MPU6050:按块行顺序拼接 8×N 向量喂 predictor(槽位不串)"""
robot = _make_robot(LOOP_IMU2_MT, mock_i2c)
robot.active()
_freeze_imu(robot, "mpu6050-0", roll=1.0, gz=7.0)
_freeze_imu(robot, "mpu6050-1", roll=2.0, pitch=-3.0)
pred = ImuPredictor(frame_len=3, history_len=2, action_dim=4, imu_dim=16)
robot.run_loop(pred, hz=1000.0, max_steps=2)
robot.deactive()
assert len(pred.imus) == 2
for imu in pred.imus:
assert imu is not None and imu.shape == (16,)
assert imu[0] == 1.0 and imu[7] == 7.0 # sensor0: roll, gz
assert imu[8] == 2.0 and imu[9] == -3.0 # sensor1: roll, pitch
def test_loop_imu_multi_sensor_pad_truncate(mock_i2c):
"""传感器数与 predictor.imu_dim 不匹配:pad 零 / 截断,槽位不漂移"""
robot = _make_robot(LOOP_IMU2_MT, mock_i2c)
robot.active()
_freeze_imu(robot, "mpu6050-0", roll=1.0)
_freeze_imu(robot, "mpu6050-1", roll=2.0)
pred24 = ImuPredictor(frame_len=3, history_len=2, action_dim=4,
imu_dim=24)
robot.run_loop(pred24, hz=1000.0, max_steps=1) # 16 → 24 补零
imu = pred24.imus[0]
assert imu.shape == (24,)
assert imu[0] == 1.0 and imu[8] == 2.0
assert np.allclose(imu[16:], 0.0)
pred8 = ImuPredictor(frame_len=3, history_len=2, action_dim=4,
imu_dim=8)
robot.run_loop(pred8, hz=1000.0, max_steps=1) # 16 → 8 截断(仅 sensor0)
imu = pred8.imus[0]
assert imu.shape == (8,) and imu[0] == 1.0
robot.deactive()
def test_loop_imu_multi_recorded(mock_i2c, tmp_path):
"""多传感器闭环录制:imu.npy 宽度 8×N 且槽位按块行顺序"""
from pybot.recorder import EpisodeRecorder
robot = _make_robot(LOOP_IMU2_MT, mock_i2c)
robot.active()
_freeze_imu(robot, "mpu6050-0", roll=1.0)
_freeze_imu(robot, "mpu6050-1", pitch=4.0)
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
pred = MockPredictor(frame_len=3, history_len=2, action_dim=4)
robot.run_loop(pred, hz=1000.0, max_steps=3, recorder=rec)
robot.deactive()
imus = np.load(tmp_path / "episode_0000" / "imu.npy")
assert imus.shape == (3, 16)
assert np.allclose(imus[:, 0], 1.0) # sensor0 roll
assert np.allclose(imus[:, 9], 4.0) # sensor1 pitch
+136
View File
@@ -285,6 +285,142 @@ def test_delay_not_multiple_of_10(tmp_path, mock_i2c):
robot.deactive()
# ---------------- IMU 姿态录制(R2) ----------------
# @Camera + @MPU6050_BIND + 2 行 @Action —— 脚本驱动录制姿态
IMU_MT = """
@I2C
name path;
i2c-1 /dev/i2c-1;
@
@MPU6050_BIND
name I2C addr;
mpu6050-0 i2c-1 0x69;
@
@Camera
name width height framerate capacity;
cam0 320 240 15 2;
@
@Action
cam0.frame;
0;
0;
@
"""
def test_imu_npy_written_with_dict(tmp_path):
"""record 传姿态 dict → flush 产出 imu.npy(T×8),键序 roll..gz"""
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
imu = {"roll": 1.0, "pitch": 2.0, "ax": 3.0, "ay": 4.0,
"az": 5.0, "gx": 6.0, "gy": 7.0, "gz": 8.0}
rec.record(None, [0.0], imu_vec=imu)
rec.record(None, [0.0], imu_vec=imu)
rec.flush()
imus = np.load(tmp_path / "episode_0000" / "imu.npy")
assert imus.shape == (2, 8)
assert list(imus[0]) == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
def test_imu_mixed_none_marks_zero(tmp_path):
"""混合数据集:部分帧无姿态 → 该帧记零向量,仍产出 imu.npy"""
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
rec.record(None, [0.0], imu_vec=None)
rec.record(None, [0.0], imu_vec={"roll": 9.0})
rec.flush()
imus = np.load(tmp_path / "episode_0000" / "imu.npy")
assert imus.shape == (2, 8)
assert np.allclose(imus[0], 0.0)
assert imus[1, 0] == 9.0
def test_no_imu_no_file(tmp_path):
"""全程无姿态(旧数据路径)→ 不产出 imu.npy,保持旧格式兼容"""
robot = RobotStruct(CAMERA_MT, dry_run=True)
robot.active()
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
robot.run(recorder=rec)
robot.deactive()
ep_dir = tmp_path / "episode_0000"
assert (ep_dir / "actions.npy").exists()
assert not (ep_dir / "imu.npy").exists()
def test_run_with_imu_block(tmp_path, mock_i2c):
"""脚本含 @MPU6050_BIND 时 run() 录制产出 imu.npy 且与 actions 帧对齐"""
robot = RobotStruct(IMU_MT, dry_run=True)
robot.active()
# 停采样线程注入固定姿态,避免 mock 采样值干扰断言
sensor = robot.M["mpu6050-0"].sensor
sensor.stop_daemon()
sensor.wait_daemon()
sensor._latest.update(roll=-4.0, gz=12.0)
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
robot.run(recorder=rec)
robot.deactive()
ep_dir = tmp_path / "episode_0000"
actions = np.load(ep_dir / "actions.npy")
imus = np.load(ep_dir / "imu.npy")
assert actions.shape == (2, 18) # 2 行 @Action,无 Speaker 不分解
assert imus.shape == (2, 8)
assert np.allclose(imus[:, 0], -4.0)
assert np.allclose(imus[:, 7], 12.0)
# 双传感器脚本(槽位顺序对齐 std.mt 前两行:i2c-1 上 0x69 / 0x68)
IMU2_MT = """
@I2C
name path;
i2c-1 /dev/i2c-1;
@
@MPU6050_BIND
name I2C addr;
mpu6050-0 i2c-1 0x69;
mpu6050-1 i2c-1 0x68;
@
@Camera
name width height framerate capacity;
cam0 320 240 15 2;
@
@Action
cam0.frame;
0;
0;
@
"""
def test_run_multi_imu_wide_layout(tmp_path, mock_i2c):
"""多 MPU6050:run() 录制 imu.npy 宽度 8×N,槽位按块行顺序"""
robot = RobotStruct(IMU2_MT, dry_run=True)
robot.active()
for name, vals in (("mpu6050-0", {"roll": -4.0}),
("mpu6050-1", {"roll": 6.0, "gz": 12.0})):
sensor = robot.M[name].sensor
sensor.stop_daemon()
sensor.wait_daemon()
sensor._latest.update(**vals)
rec = EpisodeRecorder(str(tmp_path), action_dim=18)
robot.run(recorder=rec)
robot.deactive()
imus = np.load(tmp_path / "episode_0000" / "imu.npy")
assert imus.shape == (2, 16)
assert np.allclose(imus[:, 0], -4.0) # sensor0 roll
assert np.allclose(imus[:, 8], 6.0) # sensor1 roll(槽位 8 起)
assert np.allclose(imus[:, 15], 12.0) # sensor1 gz
def test_actpred_compatibility(tmp_path, mock_i2c):
"""用 actpred 的 ActionDataset 加载录制的 episode"""
pytest.importorskip("torch")