10. 转换为 LeRobot v3.0¶
发布数据采用 LeRobotDataset v3.0 格式。转换脚本是工具包里的 scripts/build_lerobot_dataset.py,输入就是前面各章的产出:原始录制和 unified_30hz/。

每条录制带有步骤级标注:步骤编号、描述、起止时间、技能、物体角色和结果。
10.1 目录结构¶
<dataset>/
├── meta/
│ ├── info.json fps=30、各字段的类型与形状、路径模板、坐标系说明
│ ├── stats.json 各字段的统计量
│ ├── tasks.parquet task_index 与任务描述的对应
│ └── episodes/chunk-000/file-000.parquet 每个 episode 的行范围、视频偏移
├── data/chunk-000/file-NNN.parquet 逐帧数据
├── videos/observation.images.camera_0/chunk-000/file-NNN.mp4
└── episode_meta/episode_XXXXXX/ 每个 episode 的附加信息(XXXXXX = episode_index + 1)
├── metadata.json 录制名、任务、场景、起止时间、设备清单、裁剪方式、处理说明
├── calibration/calibration.json 相机内参、tracker 安装参数、坐标系、触觉标定、时钟信息
├── annotations/instruction.json 任务指令
├── annotations/segments.parquet 步骤标注
├── quality/quality.json 拟合残差、tracker 插值统计
└── smplx/smplx_episode.npz 该 episode 的 SMPL-X(约定与 unified_30hz 相同)
10.2 逐帧字段¶
| 字段 | 类型与形状 | 说明 |
|---|---|---|
timestamp frame_index episode_index index task_index |
标量 | timestamp 是 episode 内的秒数,与 mp4 的显示时间一致 |
tracker.<role>.position |
float32 (3) | 米;<role> 为 camera、left_wrist、right_wrist、left_ankle、right_ankle |
tracker.<role>.orientation |
float32 (4) | 四元数,顺序为 w, x, y, z |
tracker.<role>.rotation_6d |
float32 (6) | 旋转矩阵的前两列,顺序为 r00, r10, r20, r01, r11, r21 |
gravity.direction |
float32 (3) | (0, -1, 0) |
observation.hand.{left,right}.joint_positions |
float32 (21, 3) | 手套关节点(21 节点) |
observation.hand.{side}.tactile_17zone_contact |
float32 (17) | 各区域接触(0 或 1) |
observation.hand.{side}.tactile_17zone_score |
float32 (17) | 去基线后的分数 |
observation.hand.{side}.tactile_17zone_residual |
float32 (17) | 基线残差 |
observation.hand.{side}.tactile_17zone_coarse_contact、_coarse_score |
float32 (6) | 五指加手掌 |
observation.images.camera_0 |
视频 (970, 1920, 3) | 裁掉顶部 110 像素,严格 30 fps |
没有触觉的手(例如 MANUS 录制),触觉字段为 NaN。
10.3 运行转换¶
1. 准备 episode 清单:CSV 文件,一行一个 episode,行的顺序就是 episode 的顺序。
session,task_zh,task_en,environment_type,trim_head_s,trim_tail_s
session_20260901_154755_物料称量,物料称量,Weighing,lab,0,5
session_20260916_112944_萃取,萃取,Extraction,lab,0,0
environment_type 填 lab 或 home;trim_head_s 和 trim_tail_s 可选,用于去掉开头和结尾。
2. 运行
python scripts/build_lerobot_dataset.py \
--episodes episodes.csv \
--raw-root $WORK/input/ego_data_sync \
--unified-root $WORK/out/unified_30hz \
--projected-root $WORK/out/projected \
--output $WORK/out/lerobot
| 参数 | 说明 |
|---|---|
--projected-root |
可选,把拟合残差写进 quality.json |
--captions-root |
可选,存放 <录制名>.en.jsonl 步骤标注的目录 |
--crop-top |
视频顶部裁掉的像素,默认 110(去掉 GoPro 屏幕信息) |
--fx --fy --cx --cy |
写进 calibration.json 的相机内参,默认 1012 / 1016 / 960 / 430 |
--license --privacy-status |
写进元数据的许可和隐私说明 |
--validate-only |
只检查已有的数据集,不重新转换 |
视频编码需要系统里有 ffmpeg。已经编码过的视频会跳过,中断后重跑即可接着做。
转换结束时会自动检查:每个 episode 的 parquet 行数等于视频帧数,timestamp 落在 1/30 秒的栅格上(误差小于 1e-4 秒)。在一条 192 秒的 Wuji 录制上实测:Mac CPU 约 1 分钟,得到 5,620 帧,视频帧数与行数一致,栅格误差 7×10⁻⁶ 秒;用 lerobot 0.4.4 的 LeRobotDataset 以默认容差读取首、中、末三帧(含视频解码)均正常。
10.4 转换器做了什么¶
- 重新编码视频。 原始 mp4 是可变帧率,
timestamp和显示时间最多差约 25 ms,会让 LeRobot 默认tolerance_s = 1e-4的视频读取报错。因此用 ffmpeg 裁掉顶部 110 像素并转成严格 30 fps,之后第 k 帧的显示时间正好是 k/30 秒。 - 以视频帧为时间轴。 tracker 从原始约 65 Hz 的数据插值到每个视频帧的时刻:位置线性插值,旋转用最短路径 Slerp。原始数据中断超过 0.25 秒,或者相邻两个样本位置跳变超过 0.10 m、旋转跳变超过 60°(tracker 重新定位或坏帧)时,不在这段之间插值,而是取较近一端的样本。每个 tracker 的插值统计写进
quality.json。 - 写出四元数和
rotation_6d,四元数顺序为 w, x, y, z。 - 手部与触觉取自
unified_30hz。导出里缺失的手,用同一套触觉处理从原始手套文件重新计算,来源记在metadata.json和info.json里。某个区域没有可用触点时,分数和残差为 NaN,接触恒为 0,各区域覆盖率记在coverage_17。 - SMPL-X 每个 episode 存一份
smplx_episode.npz,键与unified_30hz相同;体型、尺度和安装参数写在calibration.json里,不在每一行重复。 - 写入元数据:
info.json、stats.json、tasks.parquet和episodes/…parquet。
发布前,建议再用 LeRobotDataset 以默认容差完整读一遍每个 episode。
10.5 读取示例¶
from lerobot.datasets.lerobot_dataset import LeRobotDataset
ds = LeRobotDataset("InternRobotics/EgoExplorer", root="EgoExplorer/dataset/lerobot")
print(ds.meta.info["fps"], ds.num_episodes, ds.num_frames)
frame = ds[0]
frame["observation.images.camera_0"] # (3, H, W)
frame["tracker.camera.orientation"] # w, x, y, z
SMPL-X 前向计算(与总览页、校验页的约定相同):
import numpy as np, smplx, torch
z = np.load("episode_meta/episode_000001/smplx/smplx_episode.npz")
model = smplx.create("models", model_type="smplx", gender="neutral", use_pca=False, num_betas=10)
T = len(z["body_pose"]); zero = torch.zeros(T, 3)
out = model(betas=torch.tensor(z["betas"])[None].expand(T, -1),
global_orient=torch.tensor(z["global_orient"]), body_pose=torch.tensor(z["body_pose"]),
left_hand_pose=torch.tensor(z["left_hand_pose"]), right_hand_pose=torch.tensor(z["right_hand_pose"]),
transl=zero, jaw_pose=zero, leye_pose=zero, reye_pose=zero, expression=torch.zeros(T, 10))
vertices = float(z["scale"]) * out.vertices.numpy() + z["transl"][:, None] # 与 tracker 同一世界坐标系,+Y 向上
10.6 正式发布前需要确定的事¶
- 平移字段的含义。
smplx_episode.npz沿用unified_30hz的约定(顶点 = scale × SMPL-X(transl=0) + transl);论文和 HF 数据卡描述的完整版里,平移字段存的是世界坐标下的骨盆位置(换算见第 11.4 节)。正式发布前要选定一种,并写进info.json。 - 视频字段名。 当前为
observation.images.camera_0,论文附录写的是observation.images.ego。