概述

convert_dora_to_lerobot.py 将外骨骼遥操采集的 Dora-Record 格式数据转换为 LeRobot v3.0 格式,用于后续的策略模型训练(ACT、Diffusion、PI0、SmolVLA 等)。

运行命令

# 基本用法
python convert_dora_to_lerobot.py \
    --input ./dora-record/<session_id> \
    --output ./pick_and_place \
    --task "grab the bottle on the table" \
    --fps 30 \
    --robot-type fourier_gr2 \
    --video-codec libopenh264

# 或使用 run_convert.sh
bash run_convert.sh

命令行参数

参数 缩写 必填 默认值 说明
--input -i - Dora-Record session 目录路径,该目录下包含 episode_000000000/episode_000000001/ 等子目录
--output -o - 输出的 LeRobot 数据集目录,转换后所有文件写入此目录
--task -t teleoperation 任务的自然语言描述,VLA 模型训练时会用到这个文本作为语言指令。例如 "grab the bottle on the table"
--fps - 30 输出数据集的帧率。所有传感器数据会被重采样到这个统一帧率
--video-codec - libx264 ffmpeg 视频编码器。conda 环境下推荐 libopenh264,系统 ffmpeg 可用 libx264libsvtav1(AV1)
--robot-type - fourier_gr3 机器人类型标识,写入 info.json,训练时用于区分不同机器人
--no-video - false 加上此参数跳过图像/视频处理,只转换关节状态和动作数据

video-codec 可选值

编码器 说明 压缩率 速度
libopenh264 Cisco 开源 H.264,conda ffmpeg 自带
libx264 标准 H.264,需系统 ffmpeg
libsvtav1 AV1 编码,压缩最好 最高
libx265 H.265/HEVC

输入数据格式(Dora-Record)

目录结构

dora-record/<session_id>/
├── episode_000000000/
│   ├── metadata.json                           # episode 元信息
│   ├── action.parquet                          # 手臂关节动作(31维)
│   ├── action.base.parquet                     # 底盘动作(6维)
│   ├── observation.state.parquet               # 手臂关节状态(29维)
│   ├── observation.base_state.parquet          # 底盘状态(位姿+IMU,16维)
│   ├── observation.images.camera_top.parquet   # RGB 图像(JPEG 编码)
│   └── observation.images.camera_top_depth.parquet  # 深度图像(PNG 编码)
├── episode_000000001/
│   └── ...
└── episode_000000002/
    └── ...

parquet 文件内部格式

每个 parquet 文件都包含以下公共列:

列名 类型 说明
timestamp_utc timestamp[ns] UTC 时间戳(纳秒精度)
trace_id string 分布式追踪 ID
span_id string 追踪 span ID
parameters string 附加参数

加上各自的数据列:

action.parquet — 手臂关节动作

数据列 action 类型为 list<struct<name: string, value: double>>,每行是一个字典列表:

[
    {"name": "left_shoulder_pitch_joint",  "value": 0.123},
    {"name": "left_shoulder_roll_joint",   "value": -0.456},
    {"name": "left_shoulder_yaw_joint",    "value": 0.789},
    ...  # 共 31 个关节
]

31 个关节的完整维度映射:

维度 关节名 所属部位
0 left_shoulder_pitch_joint 左臂
1 left_shoulder_roll_joint 左臂
2 left_shoulder_yaw_joint 左臂
3 left_elbow_pitch_joint 左臂
4 left_wrist_yaw_joint 左臂
5 left_wrist_pitch_joint 左臂
6 left_wrist_roll_joint 左臂
7 right_shoulder_pitch_joint 右臂
8 right_shoulder_roll_joint 右臂
9 right_shoulder_yaw_joint 右臂
10 right_elbow_pitch_joint 右臂
11 right_wrist_yaw_joint 右臂
12 right_wrist_pitch_joint 右臂
13 right_wrist_roll_joint 右臂
14 L_pinky_proximal_joint 左手
15 L_ring_proximal_joint 左手
16 L_middle_proximal_joint 左手
17 L_index_proximal_joint 左手
18 L_thumb_proximal_pitch_joint 左手
19 L_thumb_proximal_yaw_joint 左手
20 R_pinky_proximal_joint 右手
21 R_ring_proximal_joint 右手
22 R_middle_proximal_joint 右手
23 R_index_proximal_joint 右手
24 R_thumb_proximal_pitch_joint 右手
25 R_thumb_proximal_yaw_joint 右手
26 head_yaw_joint 头部
27 head_pitch_joint 头部
28 waist_yaw_joint 腰部
29 waist_roll_joint 腰部
30 waist_pitch_joint 腰部

action.base.parquet — 底盘动作

数据列 action.base 类型同上,6 个自由度:

维度 名称 说明
0 vel_x 前后线速度
1 vel_y 左右线速度
2 vel_yaw 偏航角速度
3 vel_height 升降速度
4 vel_pitch 俯仰角速度
5 base_yaw 底盘偏航角

observation.state.parquet — 手臂关节状态

数据列 observation.state,29 个关节(比 action 少腰部 roll 和 pitch)。

observation.base_state.parquet — 底盘状态

数据列 observation.base_state 为嵌套结构体:

[{
    "base": {
        "position": [x, y, z],           # 位置(米)
        "quat": [qx, qy, qz, qw],       # 四元数
        "rpy": [roll, pitch, yaw]         # 欧拉角(弧度)
    },
    "imu": {
        "acc_B": [ax, ay, az],            # 加速度(m/s²)
        "omega_B": [wx, wy, wz]          # 角速度(rad/s)
    },
    "stand_pose": [4个值],
    "state": 11                           # 状态码
}]

展平后为 16 维向量。

observation.images.camera_top.parquet — RGB 图像

数据列为 list<uint8>,每行是一张 JPEG 编码的图像字节流(约 67KB/帧,480x640x3)。

observation.images.camera_top_depth.parquet — 深度图像

数据列为 list<uint8>,每行是一张 PNG 编码的深度图字节流(约 106KB/帧,480x640)。

metadata.json

{
    "episode_index": 0,
    "task_id": null,
    "start_time": "2026-02-12T02:29:11.715Z",
    "end_time": "2026-02-12T02:29:25.814Z",
    "session_id": "019c4fad-86f2-7017-bc8c-35744b1de20d",
    "machine_id": "GR3",
    "equipment": "t5d",
    "notes": "grxtest"
}

各传感器采样频率不同

数据 频率 说明
action ~100Hz 动作指令发送最快
observation.state ~60Hz 关节状态反馈
observation.base_state ~60Hz 底盘状态反馈
camera_top (RGB) ~30Hz RGB 摄像头
camera_top_depth ~30Hz 深度摄像头

转换脚本会将所有数据重采样到统一帧率(默认 30fps)。

输出数据格式(LeRobot v3.0)

目录结构

pick_and_place/
├── meta/
│   ├── info.json                                    # 数据集元信息
│   ├── stats.json                                   # 全局统计(归一化用)
│   ├── tasks.parquet                                # 任务描述映射表
│   └── episodes/
│       └── chunk-000/
│           └── file-000.parquet                     # episode 级元信息
├── data/
│   └── chunk-000/
│       └── file-000.parquet                         # 帧级数据(action、state等)
└── videos/
    ├── observation.images.camera_top/
    │   └── chunk-000/
    │       └── file-000.mp4                         # RGB 视频(所有 episode 合并)
    └── observation.images.camera_top_depth/
        └── chunk-000/
            └── file-000.mp4                         # 深度视频(所有 episode 合并)

data/chunk-000/file-000.parquet — 帧级数据

所有 episode 的所有帧按顺序存储在一个 parquet 文件中:

列名 类型 说明
action list\<float64> 动作向量(37维 = 31关节 + 6底盘)
observation.state list\<float64> 状态向量(45维 = 29关节 + 16底盘状态)
timestamp float64 episode 内的时间(秒),从 0 开始
frame_index int64 episode 内的帧序号,从 0 开始
episode_index int64 所属 episode 的编号
index int64 全局帧序号(跨所有 episode 的唯一 ID)
task_index int64 任务编号,通过 tasks.parquet 映射到文本

转换后的 action 维度映射(37维)

维度 名称 部位 部署时对应 GR2Robot
0-6 left_shoulder/elbow/wrist (7个) 左臂 left_manipulator
7-13 right_shoulder/elbow/wrist (7个) 右臂 right_manipulator
14-19 L_pinky/ring/middle/index/thumb (6个) 左手 left_hand
20-25 R_pinky/ring/middle/index/thumb (6个) 右手 right_hand
26-27 head_yaw, head_pitch 头部 head
28-30 waist_yaw/roll/pitch 腰部 waist
31-36 base_vel_x/y/yaw/height/pitch/yaw 底盘 set_velocity()

转换后的 observation.state 维度映射(45维)

维度 名称 说明
0-6 左臂 7 关节 与 action 相同
7-13 右臂 7 关节 与 action 相同
14-15 head_yaw, head_pitch 头部
16 waist_yaw 腰部(注意:state 比 action 少 roll/pitch)
17-22 左手 6 关节 顺序与 action 不同:index/middle/ring/pinky/thumb
23-28 右手 6 关节 同上
29-31 base_pos_x/y/z 底盘位置
32-35 base_quat_x/y/z/w 底盘四元数
36-38 base_rpy_roll/pitch/yaw 底盘欧拉角
39-41 imu_acc_x/y/z IMU 加速度
42-44 imu_omega_x/y/z IMU 角速度

meta/tasks.parquet — 任务映射

索引(任务文本) task_index
grab the bottle on the table 0

训练时,框架通过 task_index 查找任务文本,送入 VLA 模型的语言编码器。

meta/info.json — 数据集元信息

{
    "codebase_version": "v3.0",
    "robot_type": "fourier_gr2",
    "total_episodes": 86,
    "total_frames": 25000,
    "total_tasks": 1,
    "chunks_size": 1000,
    "fps": 30,
    "splits": {"train": "0:86"},
    "data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
    "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4",
    "features": {
        "action": {"dtype": "float32", "shape": [37], "names": [...]},
        "observation.state": {"dtype": "float32", "shape": [45], "names": [...]},
        "observation.images.camera_top": {"dtype": "video", "shape": [480, 640, 3], ...},
        "observation.images.camera_top_depth": {"dtype": "video", ...},
        "timestamp": {"dtype": "float32", "shape": [1]},
        "frame_index": {"dtype": "int64", "shape": [1]},
        "episode_index": {"dtype": "int64", "shape": [1]},
        "index": {"dtype": "int64", "shape": [1]},
        "task_index": {"dtype": "int64", "shape": [1]}
    }
}

meta/stats.json — 全局统计

用于训练时数据归一化,包含每个特征的 min/max/mean/std/count。

视频存储方式

所有 episode 的帧合并到同一个 mp4 文件中(与 LeRobot 标准一致)。每个 episode 在视频中的位置由 episodes parquet 中的 from_timestampto_timestamp 指定。训练时框架根据时间戳自动从视频中提取对应帧。

转换流程详解

第一步:读取 Dora-Record 数据

对每个 episode 目录:
  1. 读取 metadata.json(空文件或损坏会自动跳过)
  2. 读取 action.parquet → 31维关节动作 + 时间戳
  3. 读取 action.base.parquet → 6维底盘动作 + 时间戳
  4. 读取 observation.state.parquet → 29维关节状态 + 时间戳
  5. 读取 observation.base_state.parquet → 16维底盘状态 + 时间戳
  6. 读取 observation.images.camera_top.parquet → JPEG 图像 + 时间戳
  7. 读取 observation.images.camera_top_depth.parquet → PNG 深度图 + 时间戳

第二步:时间对齐与重采样

由于各传感器采样频率不同(action ~100Hz,state ~60Hz,camera ~30Hz),需要统一到目标帧率:

1. 以 action 的时间范围为基准:start_ns = action_ts[0], end_ns = action_ts[-1]
2. 按目标 fps 生成均匀时间戳序列
3. 对每个数据源用最近邻插值重采样到统一时间轴

第三步:合并维度

action = [31维手臂关节] + [6维底盘动作] = 37维
state  = [29维手臂关节] + [16维底盘状态] = 45维

第四步:编码视频

所有 episode 的 RGB 帧拼接 → 一个 mp4 文件
所有 episode 的深度帧拼接 → 一个 mp4 文件(深度图归一化为 8bit 灰度伪 RGB)
记录每个 episode 在视频中的起止时间戳

第五步:生成 LeRobot 元数据

1. data parquet: 所有帧的 action、state、timestamp、frame_index 等
2. tasks.parquet: 任务文本到 task_index 的映射
3. episodes parquet: 每个 episode 的统计信息和视频位置信息
4. stats.json: 全局统计(归一化用)
5. info.json: 数据集描述(特征定义、视频参数等)

常见问题

Q: ffmpeg 报 "Unknown encoder 'libx264'"

A: conda 环境的 ffmpeg 通常不带 libx264,改用 --video-codec libopenh264

Q: 某个 episode 的 metadata.json 为空

A: 脚本会自动跳过损坏的 episode 并打印警告。

Q: 只想转换状态/动作数据,不需要图像

A:--no-video 参数。

Q: 转换后数据量太大

A: 视频是主要的空间占用。可以用 --video-codec libsvtav1(AV1)获得更好的压缩率,但编码更慢。

Q: 如何验证转换结果

A: 用 Python 检查输出:

import pyarrow.parquet as pq
import json

# 检查数据
t = pq.read_table("pick_and_place/data/chunk-000/file-000.parquet")
print(t.schema)
print(t.num_rows)

# 检查 info
with open("pick_and_place/meta/info.json") as f:
    info = json.load(f)
print(json.dumps(info, indent=2))