LeRobotDataset v3 数据格式详解:从“机器人一帧数据”到大规模流式训练适读对象:高中生、机器人学习初学者、准备自己录制/转换 LeRobot 数据集的人。阅读目标:读完后,你应该能回答 5 个问题:机器人数据的一帧是什么?episode 是什么?v3 为什么把多个 episode 装进同一个 Parquet/MP4?metadata 怎样把它们重新找回来?dataset[i]最终为什么能变成模型直接使用的张量?版本说明:本文按 2026 年 9 月可查到的 LeRobot v3 官方文档与当前mainAPI 整理。官方 v3 概念页仍能看到较早的meta/tasks.jsonl描述,而当前LeRobotDatasetMetadataAPI 已明确管理tasks.parquet;所以工程上应以实际安装版本和真实 schema 为准。1. 先别管文件格式:机器人到底记录了什么?1.1 把一次机器人操作想成“带传感器的短视频”假设一个机械臂要完成任务:“把红色方块拿起来,放进盒子。”机器人每隔很短的一段时间就记录一次信息。某一时刻,它可能记录:前置相机图像 腕部相机图像 6 个关节的位置 夹爪开合程度 本时刻要执行的动作 当前属于哪个任务 当前时间这一组“同一时刻的数据”,可以把它理解为一个frame(帧 / 时间步)。很多连续 frame 连起来,就是一次完整操作,也就是一个episode(回合 / 轨迹)。例如:Episode 12 │ ├── frame 0 机械臂刚开始移动 ├── frame 1 ├── frame 2 ├── ... ├── frame 86 夹爪抓住方块 ├── ... └── frame 149 方块被放进盒子所以最基础的关系是:Dataset └── 很多个 Episode └── 很多个 Frame ├── 状态 state ├── 动作 action ├── 图像 image └── 时间 timestamp下面这张图先建立全文最重要的第一层直觉。图 1-1|机器人数据集:Dataset、Episode 与 Frame 的关系这张图里只要记住一句话:Frame 是一个时刻,Episode 是一次完整操作,Dataset 是很多次操作的集合。2. LeRobot v3 为什么要重新设计存储方式?2.1 旧思路:一个 episode 配一套文件早期更直观的做法,是让每个 episode 拥有自己的数据文件和视频文件。例如:episode_000000.parquet episode_000000.mp4 episode_000001.parquet episode_000001.mp4这在只有几十个 episode 时非常舒服,因为“文件 = episode”,人一眼就能看懂。问题出现在规模变大以后。假设有:1,000,000 个 episodes 3 路相机如果每个 episode 有 1 个 Parquet 和 3 个 MP4,理论上就可能接近:4,000,000 个文件真正麻烦的不只是硬盘容量,而是操作系统和云存储还必须管理数百万个“文件对象”:列目录、打开文件、同步 Hub、维护 inode、请求对象存储元数据,都可能变慢。2.2 v3 思路:episode 还是 episode,但文件不再等于 episodeLeRobot v3 的核心变化是file-based storage:很多 episode 可以共同放进一个较大的 Parquet 或 MP4 文件中;episode 的边界由 metadata 来记录,而不是靠文件名表达。官方也把“many episodes per Parquet/MP4 file”“relational metadata”“fewer, larger files”列为 v3 的关键变化。可以把它类比成一本书:v2: 一章内容 = 一本单独的小册子 v3: 很多章 = 一本厚书 目录告诉你每章从哪一页开始也就是说:episode 是逻辑单位;Parquet/MP4 是物理存储单位。下面这张图专门对比两种思路。上面那张把多个知识点拼到了一张总览图里,不把它计入正式插图编号。从这里开始严格按你的要求:每张图只解释一个核心知识点,并且每张图都是独立渲染的。2.3 为什么 v3 的改变不影响训练代码的“episode 视角”?虽然物理文件改变了,但对使用者来说,仍然可以按 episode 或 frame 取数据。也就是说,v3 不是“取消 episode”,而是把:episode = 一个文件改成:episode = metadata 定义出来的一段逻辑数据这个区别非常重要。图 2-1|v2.1 与 v3:从“一回合一文件”到“多回合共享文件”这张图对应 v3 的第一性原理:减少物理文件数量,但保留逻辑上的 episode 访问方式。3. v3 的三大组成部分:Parquet、MP4、Metadata3.1 为什么要把机器人数据拆成三类?机器人数据里,不同内容的“体积”和“访问方式”差异很大。关节角、动作、时间戳通常只是一些数字,例如:observation.state = [0.12, -0.83, 1.07, ...] action = [0.04, 0.01, -0.02, ...] timestamp = 1.267这些内容体积小,而且很适合按“行和列”组织。但一张 RGB 图像可能有:640 × 480 × 3 = 921,600 个字节若完全不压缩,一路 30 FPS 的相机每秒约产生 26 MiB 数据;三路相机就更大。因此LeRobot 不把所有 RGB 像素直接塞进普通表格,而是让视频编码器利用相邻画面的相似性进行压缩。于是 v3 可以先用一个高中生很容易记住的模型理解:部分像什么主要保存什么ParquetExcel 表格state、action、timestamp、各种 indexMP4视频文件RGB 相机连续画面Metadata目录 + 说明书schema、统计量、episode 边界、文件位置官方文档把 v3 描述为标准化的多模态机器人学习数据格式,其中逐时间步表格数据与多相机视频由 metadata 统一解释和索引。3.2 三者不是互相独立,而是一起还原“某一帧”例如模型想取 episode 17 的第 20 帧,系统可能需要:Parquet → 找到第 20 帧的 state / action MP4 → 找到第 20 帧的 RGB 图像 Metadata → 告诉系统 episode 17 实际存在哪个文件、哪一段也就是说,Parquet 装“数字”,MP4 装“画面”,Metadata 装“关系”。图 3-1|v3 的三大组成部分:Parquet、MP4 与 Metadata4. 打开一个 v3 数据集,目录里到底有什么?4.1 先看“地图”,不要一上来打开 MP4一个典型的 v3 数据集可以抽象成:my_dataset/ ├── meta/ │ ├── info.json │ ├── stats.json │ ├── tasks.parquet │ └── episodes/ │ └── chunk-000/ │ └── file-000.parquet │ ├── data/ │ └── chunk-000/ │ ├── file-000.parquet │ └── file-001.parquet │ └── videos/ ├── observation.images.front/ │ └── chunk-000/ │ └── file-000.mp4 │ └── observation.images.wrist/ └── chunk-000/ └── file-000.mp4当前官方大型数据迁移文档也展示了meta/episodes/.../*.parquet、meta/tasks.parquet、data/.../*.parquet和videos/camera_key/.../*.mp4这种组织方式。4.2 三个顶层目录分别回答三个问题可以把它们记成:meta/ → “这些数据应该怎样解释?” data/ → “状态、动作等数字在哪里?” videos/ → “相机画面在哪里?”其中chunk-000可以理解成“文件柜”,而file-000是“文件柜里的一本档案”。这样就不会把单个目录塞进几十万甚至上百万个文件。图 4-1|LeRobotDataset v3 的典型目录结构4.3info.json:整个数据集的“说明书”如果第一次拿到一个陌生 LeRobot 数据集,最先看的通常不是视频,而是:meta/info.json它告诉你:这个数据集是什么版本? 机器人类型是什么? 采样频率 fps 是多少? 一共有多少 episode / frame? 有哪些 feature? 每个 feature 的 dtype 和 shape 是什么? data 和 video 的路径模板是什么?概念上可以想成:{"codebase_version":"v3.0","robot_type":"so101","fps":30,"total_episodes":1000,"total_frames":300000,"features":{"observation.state":{"dtype":"float32","shape":[6]},"action":{"dtype":"float32","shape":[6]},"observation.images.front":{"dtype":"video","shape":[480,640,3]}}}一条真实 meta/info.json 如下:{"codebase_version":"v3.0","robot_type":"so101_follower","total_episodes":20,"total_frames":46700,"total_tasks":1,"chunks_size":1000,"fps":30,"splits":{"train":"0:20"},"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":[6],"names":["shoulder_pan.pos","shoulder_lift.pos","elbow_flex.pos","wrist_flex.pos","wrist_roll.pos","gripper.pos"],"fps":30},"observation.state":{"dtype":"float32","shape":[6],"names":["shoulder_pan.pos","shoulder_lift.pos","elbow_flex.pos","wrist_flex.pos","wrist_roll.pos","gripper.pos"],"fps":30},"observation.images.wrist":{"dtype":"video","shape":[480,640,3],"names":["height","width","channels"],"video_info":{"video.height":480,"video.width":640,"video.codec":"av1","video.pix_fmt":"yuv420p","video.is_depth_map":false,"video.fps":30,"video.channels":3,"has_audio":false},"info":{"video.height":480,"video.width":640,"video.codec":"av1","video.pix_fmt":"yuv420p","video.is_depth_map":false,"video.fps":30,"video.channels":3,"has_audio":false}},"observation.images.front":{"dtype":"video","shape":[480,640,3],"names":["height","width","channels"],"video_info":{"video.height":480,"video.width":640,"video.codec":"av1","video.pix_fmt":"yuv420p","video.is_depth_map":false,"video.fps":30,"video.channels":3,"has_audio":false},"info":{"video.height":480,"video.width":640,"video.codec":"av1","video.pix_fmt":"yuv420p","video.is_depth_map":false,"video.fps":30,"video.channels":3,"has_audio":false}},"timestamp":{"dtype":"float32","shape":[1],"names":null,"fps":30},"frame_index":{"dtype":"int64","shape":[1],"names":null,"fps":30},"episode_index":{"dtype":"int64","shape":[1],"names":null,"fps":30},"index":{"dtype":"int64","shape":[1],"names":null,"fps":30},"task_index":{"dtype":"int64","shape":[1],"names":null,"fps":30}},"data_files_size_in_mb":100,"video_files_size_in_mb":200}注释版:{"codebase_version":"v3.0",// 创建/保存该数据集时采用的 LeRobotDataset 数据格式版本。这里表示 v3.0;加载器会据此判断数据格式兼容性。"robot_type":"so101_follower",// 采集该数据集所使用的机器人类型。这里是 SO-101 follower 机械臂。"total_episodes":20,// 数据集中 episode(完整轨迹/一次示教)的总数量。这里共有 20 条轨迹,episode_index 通常为 0~19。"total_frames":46700,// 所有 episode 的总 frame 数。一个 frame 表示一个同步时间步,而不是“两路摄像头各算一个 frame”。// 因此这里表示整个数据集共有 46700 个机器人时间步。"total_tasks":1,// 数据集中不同任务(task)的总数量。这里所有 episode 都属于同一种任务。"chunks_size":1000,// 每个 chunk 目录最多容纳的 shard 文件数量,而不是每个 chunk 最多 1000 个 frame/episode。// 当 file_index 达到该 chunk 的容量后,会继续使用下一个 chunk_index。"fps":30,// 数据集的全局采样频率,单位 Hz / frames per second。// 这里表示每秒记录 30 个机器人时间步,理论相邻时间步间隔约为 1/30 秒。"splits":{// 数据集划分,例如 train / val / test。这里仅定义了训练集。"train":"0:20"// 表示训练集包含 episode 范围 [0, 20),即 episode 0~19,共 20 个 episode。},"data_path":"data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",// 非视频时序数据(action、state、timestamp 等)的 Parquet 文件路径模板。// {chunk_index:03d} 表示 chunk 编号使用 3 位十进制补零,如 chunk-000。// {file_index:03d} 表示文件编号使用 3 位十进制补零,如 file-000.parquet。// 例如:data/chunk-000/file-000.parquet。"video_path":"videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4",// 视频数据的 MP4 文件路径模板。// {video_key} 会替换成具体的视频 feature 名称,例如 observation.images.wrist。// {chunk_index} 和 {file_index} 的含义与 data_path 相同。// 例如:videos/observation.images.wrist/chunk-000/file-000.mp4。"features":{// 数据集中所有“列/模态/特征”的 schema 定义。// 包括机器人 action、机器人 state、摄像头、时间戳以及各种索引字段。"action":{// 控制动作。表示每个时间步发送给机器人/作为策略训练目标的控制量。"dtype":"float32",// action 每一个维度的数据类型均为 32 位浮点数。"shape":[6// action 是一个长度为 6 的向量,即每个时间步包含 6 个执行器控制值。],"names":[// action 向量 6 个维度分别对应的物理/逻辑含义,顺序必须与实际数组一致。"shoulder_pan.pos",// 第 0 维:肩部水平旋转(shoulder pan)的位置控制值。"shoulder_lift.pos",// 第 1 维:肩部抬升(shoulder lift)的位置控制值。"elbow_flex.pos",// 第 2 维:肘关节弯曲(elbow flex)的位置控制值。"wrist_flex.pos",// 第 3 维:腕部弯曲(wrist flex)的位置控制值。"wrist_roll.pos",// 第 4 维:腕部滚转(wrist roll)的位置控制值。"gripper.pos"// 第 5 维:夹爪(gripper)的位置/开合控制值。// 注意:".pos" 表示 position 语义,但具体单位、零点和量程由 SO-101 的标定/驱动定义,// 单凭 info.json 不能判断它一定是度、弧度或某种归一化值。],"fps":30// 该 feature 在这份数据中声明的采样频率为 30 Hz,与全局 fps 一致。},"observation.state":{// 机器人的本体状态 observation。通常是当前实际/测量到的关节状态,作为策略输入。"dtype":"float32",// 每一个 state 分量使用 float32 保存。"shape":[6// 每个时间步的机器人状态向量长度为 6。],"names":[// state 向量每个维度对应的机器人关节,顺序与 action 相同。"shoulder_pan.pos",// 第 0 维:当前肩部水平旋转关节位置。"shoulder_lift.pos",// 第 1 维:当前肩部抬升关节位置。"elbow_flex.pos",// 第 2 维:当前肘关节位置。"wrist_flex.pos",// 第 3 维:当前腕部弯曲关节位置。"wrist_roll.pos",// 第 4 维:当前腕部滚转关节位置。"gripper.pos"// 第 5 维:当前夹爪位置/开合状态。],"fps":30// observation.state 的采样频率为 30 Hz。},"observation.images.wrist":{// 腕部摄像头 observation。通常安装在机械臂末端附近,用于近距离观察操作目标。"dtype":"video",// 表示视觉数据不是直接作为普通数值数组存进 Parquet,// 而是编码到视频文件中,由 LeRobot 根据 episode/frame/timestamp 定位视频帧。"shape":[480,// 图像高度 height = 480 像素。640,// 图像宽度 width = 640 像素。3// 图像通道数 channels = 3,即彩色图像。],"names":["height",// shape 第 0 维对应图像高度。"width",// shape 第 1 维对应图像宽度。"channels"// shape 第 2 维对应图像通道数。],"video_info":{// 视频流自身的编码/媒体信息。// 你这份数据同时存在 video_info 和 info,两者内容相同。// 在当前 LeRobot v3 源码中,info 是 canonical 字段,// video_info 属于仍被兼容识别的旧式/兼容性表示。"video.height":480,// MP4 视频流实际编码高度为 480 像素。"video.width":640,// MP4 视频流实际编码宽度为 640 像素。"video.codec":"av1",// 视频编码器/编码格式为 AV1。"video.pix_fmt":"yuv420p",// MP4 内部像素格式为 YUV 4:2:0 planar。// 这是视频压缩层面的像素表示,不代表模型最终拿到的 tensor 一定是 YUV。"video.is_depth_map":false