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
This commit is contained in:
qing committed 2026-08-14 09:07:24 +08:00
1 parent 3e773debc9
commit a2ba797410
22 files changed
+1236 -133

No files matched your search

+30 -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 扩展板)部署友好。
## 快速开始
@@ -40,6 +41,9 @@ bash _run.sh train --data-dir ./data --epochs 100 --resume checkpoints/last.pt
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 \
@@ -109,6 +113,7 @@ data/
0001.jpg
...
actions.npy # shape: (T, action_dim) float32
imu.npy # 可选,shape: (T, imu_dim) float32(MPU6050 姿态,R2)
episode_002/
...
```
@@ -116,6 +121,13 @@ data/
每个 timestep 的样本:帧 `[t-frame_len+1, t]`(旧→新)+ 前 `history_len` 步动作 → 当前步动作。
采样起点为 `max(history_len, frame_len-1)`。
`imu.npy` 的键序为 `[roll, pitch, ax, ay, az, gx, gy, gz]`(roll/pitch 角度制,
其余为 MPU6050 原始 int16),由 pybot `--record` 在脚本含 `@MPU6050_BIND` 块时
逐帧同步落盘。**旧 episode 没有姿态数据,训练 IMU 模型需用新版 pybot 重新录制**;
`--imu-dim`>0 时缺失 `imu.npy` 的 episode 按零向量兜底(向后兼容,但无姿态先验收益)。
姿态归一化按物理量程固定缩放(roll/pitch ÷180,原始 int16 ÷32768),训练与推理
共用 `imu_norm.py`,无需统计文件。
### 数据采集
```python
@@ -138,6 +150,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=roll,pitch,ax,ay,az,gx,gy,gz)|
| `--backbone` | simple | CNN 类型 (simple/mobilenet) |
| `--attn-dim` | 128 | 注意力 token 维度 d_model(须能被 attn-heads 整除)|
| `--attn-heads` | 4 | 注意力头数 |
@@ -177,6 +190,8 @@ recorder.flush()
物理可行性用数据分布的经验界(min/max + 10% margin)而非硬编码范围——18 列混含
舵机角度/频率/延时/预留,无统一硬限位。`--compare` 用于 A/B 对比(如 R1 开/关)。
滚动复现闭环口径:模型带 imu 输入时按 episode 的 `imu.npy` 真值姿态逐步喂入
(缺失则零向量,与 pybot 闭环无 `@MPU6050_BIND` 块时的行为一致)。
## 部署注意
@@ -195,13 +210,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。
@@ -232,7 +250,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
@@ -269,9 +288,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")
+14
View File
@@ -13,6 +13,10 @@ 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 = roll,pitch,ax,ay,az,gx,gy,gz(pybot recorder 的 imu.npy 格式)。
imu_dim: int = 0
@dataclass
@@ -79,6 +83,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=roll,pitch,ax,ay,az,gx,gy,gz,序列变 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)
@@ -126,6 +133,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),
@@ -190,6 +198,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:
@@ -235,6 +248,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"),
+52 -8
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'"""
@@ -22,9 +24,12 @@ class ActionDataset(Dataset):
normalize_actions: bool = False,
action_stats_path: str = None, episode_indices: list = None,
frame_len: int = 3, augment: bool = False,
predict_delta: bool = False, rollout_len: int = 1):
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
@@ -65,12 +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)
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_start in range(start, n - (self.rollout_len - 1)):
self.samples.append((image_files, actions, t_start))
self.samples.append((image_files, actions, imus, t_start))
self._action_mean = None
self._action_std = None
@@ -94,7 +100,7 @@ class ActionDataset(Dataset):
# 归一化统计基于绝对动作(与 Δ 无关):覆盖所有样本 rollout 范围内
# 出现的 history 与 target 动作,保持与旧版一致的统计口径。
all_actions = np.stack([
s[1][s[2] - self.history_len: s[2] + self.rollout_len]
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)
@@ -134,22 +140,57 @@ class ActionDataset(Dataset):
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, t_start = self.samples[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)
return {"images": images, "action_history": history, "target": target}
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
steps.append({
step = {
"images": self._load_frames(image_files, t),
# 真实历史(scheduled sampling 的真值来源;首步必用)
"action_history": torch.tensor(
@@ -157,7 +198,10 @@ class ActionDataset(Dataset):
"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):
+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_")
+27 -6
View File
@@ -28,7 +28,11 @@ def _episode_ids(data_dir):
def _load_episode(data_dir, ep_name):
"""加载单个 episode 的图像路径(自然序)与动作真值 (T, D)"""
"""加载单个 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
@@ -37,7 +41,14 @@ def _load_episode(data_dir, ep_name):
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))
return image_files[:n], actions[:n]
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):
@@ -58,16 +69,19 @@ def _action_bounds(data_dir, action_dim, margin_ratio=0.1):
@np.errstate(all="ignore")
def rollout_episode(predictor, image_files, actions, horizon=None,
lo=None, hi=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:
@@ -88,8 +102,12 @@ def rollout_episode(predictor, image_files, actions, horizon=None,
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)),
predictor.predict(frames, np.array(history), **kwargs),
dtype=np.float32).reshape(-1)
true = actions[t]
err = pred - true
@@ -158,14 +176,16 @@ def evaluate(checkpoint, data_dir, episodes=None, horizon=None, device=None):
lo, hi = _action_bounds(data_dir, predictor.action_dim)
all_steps = []
for name in ep_names:
image_files, actions = _load_episode(data_dir, name)
steps = rollout_episode(predictor, image_files, actions, horizon, lo, hi)
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
@@ -174,6 +194,7 @@ 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 = 误差随步数发散)",
+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))
+37
View File
@@ -0,0 +1,37 @@
"""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]
仅依赖 numpy,保证 hailo_infer.py(HAT 部署环境无 torch)可直接复用。
"""
import numpy as np
# 各索引归一化除数(imu_dim=8 全量;更短的向量取前缀)
IMU_SCALES = np.array([180.0, 180.0,
32768.0, 32768.0, 32768.0,
32768.0, 32768.0, 32768.0], dtype=np.float32)
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))
+24 -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,10 @@ 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,行为与旧版一致。
@@ -67,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:
@@ -88,7 +97,12 @@ 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
@@ -118,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()
@@ -131,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))
+492 -4
View File
@@ -1714,7 +1714,8 @@ class TestR4Evaluate(unittest.TestCase):
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")
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
@@ -1729,7 +1730,8 @@ class TestR4Evaluate(unittest.TestCase):
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")
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)
@@ -1739,7 +1741,8 @@ class TestR4Evaluate(unittest.TestCase):
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")
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))
@@ -1758,7 +1761,8 @@ class TestR4Evaluate(unittest.TestCase):
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")
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)
@@ -1797,5 +1801,489 @@ class TestR4Evaluate(unittest.TestCase):
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)
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_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)
+12 -6
View File
@@ -57,7 +57,8 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
pred = model(images, history)
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:
@@ -71,10 +72,11 @@ def train_one_epoch(model, loader, optimizer, device, max_grad_norm: float = Non
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)
pred = model(images, history, imu)
loss = loss + torch.nn.functional.mse_loss(pred, target)
bs = images.size(0)
# 回传:残差模式先把 Δ̂ 加回 a_{t-1} 得到绝对动作,再移入历史
@@ -101,7 +103,8 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
images = batch["images"].to(device)
history = batch["action_history"].to(device)
target = batch["target"].to(device)
pred = model(images, history)
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:
@@ -113,7 +116,8 @@ def validate(model, loader, device, rollout_len: int = 1, predict_delta: bool =
images = step["images"].to(device)
history = step["action_history"].to(device)
target = step["target"].to(device)
pred = model(images, history)
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"])
@@ -151,7 +155,8 @@ def main():
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,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_len)
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")
@@ -163,7 +168,8 @@ 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, val_indices, cfg.data.frame_len,
predict_delta=cfg.train.predict_delta, rollout_len=cfg.train.rollout_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:
+8
View File
@@ -31,6 +31,10 @@ cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法
./_run.sh --record data/ep scripts/std.mt # 录制 actpred 格式 episode
```
`--record` 时若脚本含 `@MPU6050_BIND` 块,逐帧同步落盘 `imu.npy`
(T×8:roll,pitch,ax,ay,az,gx,gy,gz,与图像/动作按帧时刻天然对齐);
无 IMU 硬件则保持旧格式(仅 images + actions.npy)。
`-i` / `-o` 参数位保留(reserved),未来对接跨进程 FIFO。
### 实时预测闭环(`--loop`)
@@ -49,6 +53,10 @@ cat scripts/std.mt | ./_run.sh # 与 bot4cj 相同的管道用法
- **帧源**:闭环从 camera 守护线程队列取最近 F 帧(无阻塞),`@Camera` 的
`framerate` 建议 ≥ 2×`hz` 以保证帧新鲜度;`capacity` 应 ≥ F(帧窗长度)。
部署时若日志出现 `loop overrun` 告警,说明 `hz` 超过推理+执行能力,应降低 `hz`
- **姿态回流(R2)**:脚本含 `@MPU6050_BIND` 且模型带 imu 输入
(`predictor.imu_dim` 非 None)时,每步读取 MPU6050 最新姿态随帧/历史
一同传给推理器(原始量纲,归一化在推理端完成);模型无 imu 输入时
不接入推理(若带 `--record` 仍会读取落盘 imu.npy)
## 目录
+37 -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,26 @@ 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)。
只要同一 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 +59,17 @@ 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) 顺序取值。
为 None 时该帧记零向量(混合数据集中标记"无姿态")。
"""
import numpy as np
@@ -68,6 +79,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 +101,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)
+94 -20
View File
@@ -21,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,
@@ -156,11 +160,15 @@ class RobotStruct:
delay_idx = self._find_delay_idx(columns)
camera_var = None
imu_var = None
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_var = self._find_imu()
if imu_var is None:
log.info("--record: 无 @MPU6050_BIND 块,不产出 imu.npy")
prev_vals = None
@@ -180,7 +188,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_imu(imu_var))
self._execute_step(step_vals, columns)
if self.dry_run:
import time
@@ -189,7 +198,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_imu(imu_var))
self._execute_step(vals, columns)
prev_vals = vals
@@ -207,6 +217,9 @@ class RobotStruct:
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 为零向量。
hz: 控制频率(周期 = 1/hz 秒,须 ≥ 推理 + 执行耗时)
max_steps: 步数上限,None 表示无限循环(Ctrl-C 退出)
recorder: 可选 EpisodeRecorder,闭环中同步录制 (frame, action)
@@ -226,6 +239,19 @@ class RobotStruct:
if camera_var is None and not self.dry_run:
raise ValueError("闭环需要 @Camera 块(或使用 --dry-run 生成合成帧)")
imu_var = self._find_imu()
# 鸭子类型约定:predictor 声明 imu_dim 属性即接受 imu= 关键字
# (方案一:独立 IMU token);未声明则保持旧两参调用,向后兼容
use_imu = getattr(predictor, "imu_dim", None) is not None
if imu_var is None:
if use_imu:
log.warning("predictor 声明 imu_dim 但脚本无 @MPU6050_BIND "
"块,imu 将以零向量输入(等价于无姿态先验)")
else:
log.info("closed loop imu: %s → predictor %s",
imu_var.name,
"imu= 接入" if use_imu else "不接入(仅录制)")
action = self.find_mt("Action")
if action is None:
raise ValueError("no @Action matrix in script")
@@ -248,25 +274,40 @@ class RobotStruct:
overrun = 0
log.info("closed loop start: hz=%.1f F=%d H=%d action_dim=%d",
hz, F, H, action_dim)
while max_steps is None or step < max_steps:
t0 = time.monotonic()
frames.extend(self._loop_recent_frames(camera_var, F)) # 1 采样
act = predictor.predict(list(frames), np.array(history)) # 2 推理
act = np.asarray(act, dtype=np.float32).reshape(-1)
self._execute_action_vec(act, exec_cols) # 3 执行
history.append(act.copy()) # 4 回写
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_imu(imu_var)
if use_imu: # 2 推理
act = predictor.predict(list(frames), np.array(history),
imu=self._imu_to_vec(
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:
recorder.record(frames[-1],
self._build_action_vec(act, recorder.action_dim))
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)
path = recorder.flush()
log.info("episode saved: %s", path)
if overrun:
log.warning("closed loop overrun %d/%d steps (%.0f%%),"
"建议降低 --hz 或检查推理后端延迟",
@@ -378,6 +419,39 @@ class RobotStruct:
return var
return None
def _find_imu(self):
"""在已创建的硬件变量中查找第一个 MPU6050Var(无则 None)"""
for var in self.M.values():
if isinstance(var, MPU6050Var):
return var
return None
def _read_imu(self, imu_var):
"""读 MPU6050 最新姿态 dict(键序见 IMU_KEYS)
无硬件 / 读失败返回 None(recorder 记零向量、predictor 侧零输入)。
latest() 冷启动返回全零 dict,语义安全无需特判。
"""
if imu_var is None:
return None
try:
return imu_var.play({})
except Exception:
log.exception("imu read failed")
return None
@staticmethod
def _imu_to_vec(imu, imu_dim):
"""姿态 dict → np 向量(按 IMU_KEYS 顺序 pad/truncate 到 imu_dim)"""
import numpy as np
vec = np.zeros(imu_dim, dtype=np.float32)
if imu is not None:
src = np.asarray([imu.get(k, 0.0) for k in IMU_KEYS],
dtype=np.float32)
n = min(len(src), imu_dim)
vec[:n] = src[:n]
return vec
def _build_action_vec(self, vals, action_dim):
"""从值列表构建动作向量,pad/truncate 到 action_dim"""
import numpy as np
+106 -1
View File
@@ -58,9 +58,19 @@ 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 - - - 0;
s1 90 - - pca9685-0 0;
@
@Action
@@ -281,3 +291,98 @@ def test_loop_empty_queue_falls_back_synthetic(mock_i2c):
# 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
+89
View File
@@ -285,6 +285,95 @@ 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)
def test_actpred_compatibility(tmp_path, mock_i2c):
"""用 actpred 的 ActionDataset 加载录制的 episode"""
pytest.importorskip("torch")