主题
GR00T N1.7 对 LeRobot dataset 的硬性约束
本文件是 L3 输出格式的最终 ground truth。所有结论都直接读自 Isaac-GR00T 仓库 n1.7-release tag (commit 23ace64)[1],源码已 clone 在本仓库 ./Isaac-GR00T 下(已 gitignore)。
本文件优先级高于 LeRobot v2 Schema 的通用 LeRobot v2.x spec 描述。两者冲突时以本文件为准。
0. 一句话结论
GR00T 1.7 不读 codebase_version,但强依赖 v2.0 风格的全局 meta/stats.json(带 q01/q99,不带 count)。 写出来要的是 NVIDIA 自家魔改的混合体:codebase_version="v2.1" magic + 全局 stats.json schema。直接对齐 Isaac-GR00T/demo_data/cube_to_bowl_5/ 这个样例 dataset 即可[2]。
1. 没有 codebase_version 检查
Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:147-200 是整个 dataset metadata 加载的入口[3]。整个函数读了 info.json / episodes.jsonl / tasks.jsonl / modality.json / stats.json 五个文件,没有任何地方读 codebase_version,没有任何 version compatibility check,没有针对 v2.0 / v2.1 的不同 code path[4]。
意味着对 GR00T 1.7 来说:写 "v2.0" 还是 "v2.1" 是纯礼仪问题,文件内部的 schema 才是真正的契约。
NVIDIA 自家样例数据 (Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:2) 写的是 "v2.1"[5];v3→v2 反转脚本 (Isaac-GR00T/scripts/lerobot_conversion/convert_v3_to_v2.py:67,149) 也写 "v2.1"[6]。本仓库跟随 "v2.1"。
2. stats.json 是硬约束
python
# Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:182-185
stats_path = meta_dir / LEROBOT_STATS_FILE_NAME # "stats.json"
assert stats_path.exists(), (
f"{stats_path} does not exist for {self.dataset_path}, "
f"please use gr00t/data/stats.py to generate it"
)缺 meta/stats.json → loader hard fail[7]。注意是全局 stats.json,不是 lerobot v2.1 的 per-episode episodes_stats.jsonl——后者 GR00T 整个仓库 grep 不到[8]。
stats.json 的精确 schema
Isaac-GR00T/gr00t/data/stats.py:87-94 是 stats 生成器[9]:
python
dataset_statistics[le_modality] = dict(
mean=np.mean(np_data, axis=0).tolist(),
std=np.std(np_data, axis=0).tolist(),
min=np.min(np_data, axis=0).tolist(),
max=np.max(np_data, axis=0).tolist(),
q01=np.quantile(np_data, 0.01, axis=0).tolist(),
q99=np.quantile(np_data, 0.99, axis=0).tolist(),
)Isaac-GR00T/gr00t/data/stats.py:104-112 是验证器[10]:
python
for stat in ["mean", "std", "min", "max", "q01", "q99"]:
if stat not in stats[feature]:
return False6 个字段全部必填,不带 count 字段(注意:lerobot v2.1 的 episodes_stats.jsonl 反而是 min/max/mean/std/count、不带 q01/q99,完全相反)。每个字段对 vector feature 是长度 D 的 list,对 scalar feature 是长度 1 的 list (例如 timestamp)[11]。
谁需要写 stats?
stats.py:118-123[12]:只对 info.json["features"] 里 dtype 含 "float" 字符串的 feature 写 stats。
python
for feature in le_features:
if "float" in le_features[feature]["dtype"]:
lowdim_features.append(feature)所以 int64 列 (frame_index / episode_index / index / task_index) 和 bool 列都不写 stats。视频列 dtype="video" 也不写。本仓库 lift_cube 当前需要写 stats 的 feature 就 3 个:observation.state / action / timestamp。
3. parquet 列约束
Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:303-396 是 parquet 加载逻辑[13]。
必填列
由 _extract_joint_groups (:303-342) 间接决定。它根据 modality.json 里的 original_key (默认 state → observation.state、action → action,见 :60-63[14]) 取 parquet 列然后做 [start:end] 切片。所以 observation.state 和 action 必填。
:333 检查是否是 ndarray 决定走 vector slice 还是 scalar 直传[15]:
python
if isinstance(df[original_key].iloc[0], np.ndarray):
joint_data[group_name] = df[original_key].map(lambda x: x[start_idx:end_idx])
else:
joint_data[group_name] = df[original_key] # for strings and scalars→ observation.state / action 单 frame 的值必须是 1-D np.ndarray,不能是 Python list。pandas to_parquet 写 list 会变 list;必须用 numpy array column (本仓库 convert.py 用 list(state.astype(np.float32)) 让 pandas 把 ndarray 当 cell value)。
dtype
对齐 Isaac-GR00T/demo_data/cube_to_bowl_5/data/chunk-000/episode_000000.parquet[16]:
| 列 | dtype | 单 frame 形状 |
|---|---|---|
observation.state | float32 ndarray | (D_state,) |
action | float32 ndarray | (D_action,) |
timestamp | float32 scalar | () |
frame_index | int64 scalar | () |
episode_index | int64 scalar | () |
index | int64 scalar | () |
task_index | int64 scalar | () |
stats.py:84-85 在算 stats 时也是 np.asarray(x, dtype=np.float32)[17],跟落盘 dtype 对齐就不用一次额外强转。
不写的列
GR00T 1.7 整个 Isaac-GR00T/gr00t/ 子树 grep next.done / next.reward / frame_index 都没有引用(frame_index 只在 info.json features 声明里出现)[18]:
next.done/next.reward:lerobot 库自己用,GR00T 1.7 不读;demo dataset 也没有。不写。frame_index:GR00T 1.7 loader 不读它,但 demo dataset 有这个列,本仓库为了对齐惯例写。
4. modality.json schema
_extract_joint_groups (:303-342) 把 1-D concat 的 observation.state / action 按 modality.json 切回语义字段。
必填顶层 key
json
{
"state": {
"<semantic_name>": {"start": 0, "end": 9},
...
},
"action": {
"<semantic_name>": {"start": 0, "end": 7},
...
}
}start / end 0-based,end-exclusive(:329-330 的 start_idx:end_idx 是 Python slice 语义[19])。
可选 key
| 顶层 key | 何时写 | 备注 |
|---|---|---|
video | 有 cam observation 时 | video.<k>.original_key 必须出现在 info.json["features"](:427-429[20])。video.<k> 可以无 original_key,GR00T :283-291 会按位置 auto-map(允许 model config 和 dataset 用不同 cam name)[21] |
annotation | 有自定义 language annotation 时(任务字符串走 tasks.jsonl + task_index 不需要这块) | :369-382 仅在用户传 language modality config 时读,否则忽略[22] |
lift_cube 当前阶段:只写 state 和 action,不写 video / annotation。
5. info.json features 字段
样例:Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:14-123[23]。
最少必填(lerobot_episode_loader.py:194-200 直接读这几个[24]):
json
{
"codebase_version": "v2.1",
"robot_type": "<string>",
"total_episodes": N,
"total_frames": F,
"total_tasks": T,
"chunks_size": 1000,
"fps": 30,
"splits": {"train": "0:N"},
"data_path": "data/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet",
"video_path": "videos/chunk-{episode_chunk:03d}/{video_key}/episode_{episode_index:06d}.mp4",
"features": { ... },
"total_chunks": ceil(N / chunks_size),
"total_videos": <count>
}features 里每个 key 必含 dtype 和 shape,可含 names(stats.py:122 只看 dtype[25])。video feature 还要含 info 字段(注意 demo dataset 用的 key 是 "info" 不是 "video_info",跟 lerobot 库的 v2 spec 命名不同[26])。
6. episodes.jsonl 与 tasks.jsonl
lerobot_episode_loader.py:166-211[27]:
episodes.jsonl每行被json.loads解析,只用到length字段(:211[28])。其他字段(episode_index/tasks/ 自定义)不影响 loader,但 demo dataset 都写[29]。本仓库额外写的source/src_file/src_demo字段安全,GR00T 不读,合并多来源 dataset 时可用。tasks.jsonl每行{task_index, task},被:174转成task_index → task字典[30]。仅在 language modality 配置时被:381用tasks_map[task_index]反查[31]。
7. 与本仓库 l3/convert.py 的对应关系
| GR00T 1.7 约束 | convert.py 实现 |
|---|---|
meta/stats.json 全局 + 6 字段 q01/q99 | compute_global_feature_stats() |
| stats 只覆盖 float features | 主流程只算 observation.state / action / timestamp |
observation.state / action 是 float32 ndarray cell | list(state.astype(np.float32)) |
frame_index 列 | np.arange(T, dtype=np.int64) |
不写 next.done / next.reward | 移除 |
codebase_version="v2.1" | CODEBASE_VERSION = "v2.1" |
modality.json 只 state+action | build_modality_json() |
端到端 verify 命令(在 l3/.venv 内,需要 PYTHONPATH=../Isaac-GR00T):
python
import sys, types
# stub 掉 video / initial_actions 避免拉 av/cv2/torchvision
sys.modules['gr00t.utils.video_utils'] = types.ModuleType('gr00t.utils.video_utils')
sys.modules['gr00t.utils.video_utils'].get_frames_by_indices = lambda *a, **kw: None
stub2 = types.ModuleType('gr00t.utils.initial_actions')
stub2.INITIAL_ACTIONS_FILENAME = 'initial_actions.npz'
stub2.load_initial_actions = lambda *a, **kw: None
sys.modules['gr00t.utils.initial_actions'] = stub2
from gr00t.data.dataset.lerobot_episode_loader import LeRobotEpisodeLoader
from gr00t.data.types import ModalityConfig
loader = LeRobotEpisodeLoader(
'../data/lerobot/lift_cube_v0',
{
'state': ModalityConfig(delta_indices=[0],
modality_keys=['joint_pos','joint_vel','object_position','target_object_position','last_action']),
'action': ModalityConfig(delta_indices=[0],
modality_keys=['ee_pose','gripper']),
},
)
df = loader._load_parquet_data(0)
# 应输出语义切片后的列: state.joint_pos (9,), state.last_action (8,), action.ee_pose (7,), action.gripper (1,) 等(l3/.venv 是 Python 3.12,GR00T pyproject 要求 3.10——但 LeRobotEpisodeLoader 本身只用 stdlib + numpy + pandas,在 3.12 里能跑,只是不能从 GR00T venv 里完整 import 整个包。这个 verify 路径只覆盖 dataset loader 这一片,不是真正的微调入口,所以 acceptable。)
来源(footnote)
cd Isaac-GR00T && git describe --tags→n1.7-release;git log --oneline -1→23ace64 GR00T N1.7 Release。 ↩︎Isaac-GR00T/demo_data/cube_to_bowl_5/是 NVIDIA 在 GR00T 1.7 release 里附带的 LeRobot v2 dataset 样例;包含meta/{info.json,stats.json,episodes.jsonl,tasks.jsonl,modality.json,relative_stats.json}+ 5 个data/chunk-000/episode_*.parquet,可直接当 schema 真相对照。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:147-200的_load_metadata():147-187 行依次打开info.json / episodes.jsonl / tasks.jsonl / modality.json / stats.json,189-192 行可选relative_stats.json,194-200 行从已加载info_meta取features / data_path / video_path / mask_path / chunks_size / fps。 ↩︎cd Isaac-GR00T && grep -rn 'codebase_version' gr00t/在 GR00T 1.7 (commit 23ace64) 下整个gr00t/子树无任何匹配(只有scripts/lerobot_conversion/convert_v3_to_v2.py在写、不在读)。 ↩︎Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:2写"codebase_version": "v2.1"。 ↩︎Isaac-GR00T/scripts/lerobot_conversion/convert_v3_to_v2.py:67定义常量V21 = "v2.1",:149在convert_info()里info["codebase_version"] = V21,:501用f"{root.name}_{V21}"作为输出目录后缀。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:182-185是无条件assert stats_path.exists(),缺文件直接AssertionError。 ↩︎cd Isaac-GR00T && grep -rn 'episodes_stats' .唯一匹配是scripts/lerobot_conversion/convert_v3_to_v2.py:418的日志字符串("Reconstructing legacy episodes and episodes_stats JSONL files");gr00t/子树本身完全不读episodes_stats.jsonl。 ↩︎Isaac-GR00T/gr00t/data/stats.py:87-94是calculate_dataset_statistics()里给dataset_statistics[le_modality]赋的 dict literal,6 个 key 一字不差。 ↩︎Isaac-GR00T/gr00t/data/stats.py:104-112是check_stats_validity()内部 loop,显式列了["mean", "std", "min", "max", "q01", "q99"]6 个 key,缺一个就return False。 ↩︎python -c "import json; s=json.load(open('Isaac-GR00T/demo_data/cube_to_bowl_5/meta/stats.json')); print({k: list(v.keys()) for k,v in s.items()})"→{'action': [...], 'observation.state': [...], 'timestamp': [...]},各自含mean/std/min/max/q01/q996 个字段;action/observation.state长度 6 (匹配 features.shape),timestamp长度 1。 ↩︎Isaac-GR00T/gr00t/data/stats.py:121-123在generate_stats()里for feature in le_features: if "float" in le_features[feature]["dtype"]: lowdim_features.append(feature)—— 只 float 进 stats。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:303-396包含_extract_joint_groups()(303-342) 和_load_parquet_data()(344-396) 两个函数,合起来覆盖 parquet 读、language annotation 转字符串、state/action 切片三件事。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:60-63定义DEFAULT_COLUMN_NAMES = {"state": "observation.state", "action": "action"};:331用group_info.get("original_key", DEFAULT_COLUMN_NAMES[modality_type])作 fallback,所以没original_key时 state→observation.state、action→action。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:333-336的 if 分支:ndarray 走df[original_key].map(lambda x: x[start_idx:end_idx])切片;否则整列直传。 ↩︎python -c "import pandas as pd; df=pd.read_parquet('Isaac-GR00T/demo_data/cube_to_bowl_5/data/chunk-000/episode_000000.parquet'); print(list(df.columns)); [print(c, type(df[c].iloc[0]).__name__, getattr(df[c].iloc[0], 'shape', None), getattr(df[c].iloc[0], 'dtype', None)) for c in df.columns]"→ 列顺序[action, observation.state, timestamp, frame_index, episode_index, index, task_index];前两列np.ndarray shape (6,) dtype float32,后五列 scalar (float32/int64)。 ↩︎Isaac-GR00T/gr00t/data/stats.py:84-86的np_data = np.vstack([np.asarray(x, dtype=np.float32) for x in all_low_dim_data[le_modality]]),所有数值列在做 stats 前都先强转到float32。 ↩︎cd Isaac-GR00T && grep -rn 'next\.done\|next\.reward' gr00t/整个gr00t/子树无匹配;grep -rn 'frame_index' gr00t/也无匹配。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:329-330的start_idx = group_info["start"]; end_idx = group_info["end"],后续:334直接x[start_idx:end_idx],即 Python list/ndarray 切片语义,end-exclusive。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:427-429的_load_video_data()中对每个 video key 都assert original_key in self.feature_config, f"Original key {original_key} not found in feature config"。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:280-295的 video key mapping block::283算needs_mapping = any(k not in self.modality_meta["video"] for k in config_keys);:284-289当 needs_mapping 时 assert 数量相等;:290-291for config_key, meta_key in zip(config_keys, meta_keys): self._video_key_mapping[config_key] = meta_key(按位置映射)。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:369-382在_load_parquet_data()里被if "language" in self.modality_configs:(:369) 包住——只在用户传languagemodality config 时才会读modality.json["annotation"]和tasks_map。 ↩︎Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:14-123是featuresblock,含action/observation.state/observation.images.wrist/observation.images.front/timestamp/frame_index/episode_index/index/task_index。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:194-200直接读self.feature_config = self.info_meta.get("features", {})、self.data_path_pattern = self.info_meta["data_path"]、self.video_path_pattern = self.info_meta.get("video_path")、self.mask_path_pattern = self.info_meta.get("mask_path")、self.chunk_size = self.info_meta["chunks_size"]、self.fps = self.info_meta.get("fps", 30)。data_path和chunks_size是[]直取(必须存在),其他可选有默认值。 ↩︎Isaac-GR00T/gr00t/data/stats.py:122的if "float" in le_features[feature]["dtype"]:—— 只看 features[feature]["dtype"] 一个字段。 ↩︎Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:55-64(observation.images.wrist) 和:78-87(observation.images.front),嵌套子对象的 key 是"info"(不是"video_info")。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:166-211覆盖_load_metadata()中读episodes.jsonl、tasks.jsonl、modality.json、stats.json、并最终在get_episode_lengths()(:202-212) 里只用length字段。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:211的episode_lengths.append(int(ep_meta["length"]))是唯一引用episodes.jsonl字段的地方(__getitem__里:586-587还会读episode_index/length,但都是显式必填)。 ↩︎head -3 Isaac-GR00T/demo_data/cube_to_bowl_5/meta/episodes.jsonl→{"episode_index": 0, "tasks": ["cube into yellow bowl"], "length": 568}等,demo 写了episode_index / tasks / length三个字段。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:172-174:with open(tasks_path) as f: tasks_data = [json.loads(line) for line in f]; self.tasks_map = {task["task_index"]: task["task"] for task in tasks_data}。 ↩︎Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:380-381的loaded_df[f"language.{key}"] = original_df[original_key].apply(lambda x: self.tasks_map[x])——tasks_map仅在_load_parquet_data()处理annotation.*列时被反查,前提是用户传了 language modality config (:369)。 ↩︎