Skip to content

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 False

6 个字段全部必填,不带 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 (默认 stateobservation.stateactionaction,见 :60-63[14]) 取 parquet 列然后做 [start:end] 切片。所以 observation.stateaction 必填

: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.pylist(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.statefloat32 ndarray(D_state,)
actionfloat32 ndarray(D_action,)
timestampfloat32 scalar()
frame_indexint64 scalar()
episode_indexint64 scalar()
indexint64 scalar()
task_indexint64 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 / actionmodality.json 切回语义字段。

必填顶层 key

json
{
  "state": {
    "<semantic_name>": {"start": 0, "end": 9},
    ...
  },
  "action": {
    "<semantic_name>": {"start": 0, "end": 7},
    ...
  }
}

start / end 0-based,end-exclusive(:329-330start_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 当前阶段:只写 stateaction,不写 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 必含 dtypeshape,可含 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 配置时被 :381tasks_map[task_index] 反查[31]

7. 与本仓库 l3/convert.py 的对应关系

GR00T 1.7 约束convert.py 实现
meta/stats.json 全局 + 6 字段 q01/q99compute_global_feature_stats()
stats 只覆盖 float features主流程只算 observation.state / action / timestamp
observation.state / action 是 float32 ndarray celllist(state.astype(np.float32))
frame_indexnp.arange(T, dtype=np.int64)
不写 next.done / next.reward移除
codebase_version="v2.1"CODEBASE_VERSION = "v2.1"
modality.json 只 state+actionbuild_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)


  1. cd Isaac-GR00T && git describe --tagsn1.7-release;git log --oneline -123ace64 GR00T N1.7 Release↩︎

  2. 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 真相对照。 ↩︎

  3. 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_metafeatures / data_path / video_path / mask_path / chunks_size / fps↩︎

  4. cd Isaac-GR00T && grep -rn 'codebase_version' gr00t/ 在 GR00T 1.7 (commit 23ace64) 下整个 gr00t/ 子树无任何匹配(只有 scripts/lerobot_conversion/convert_v3_to_v2.py 在写、不在读)。 ↩︎

  5. Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:2"codebase_version": "v2.1"↩︎

  6. Isaac-GR00T/scripts/lerobot_conversion/convert_v3_to_v2.py:67 定义常量 V21 = "v2.1",:149convert_info()info["codebase_version"] = V21,:501f"{root.name}_{V21}" 作为输出目录后缀。 ↩︎

  7. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:182-185 是无条件 assert stats_path.exists(),缺文件直接 AssertionError↩︎

  8. 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↩︎

  9. Isaac-GR00T/gr00t/data/stats.py:87-94calculate_dataset_statistics() 里给 dataset_statistics[le_modality] 赋的 dict literal,6 个 key 一字不差。 ↩︎

  10. Isaac-GR00T/gr00t/data/stats.py:104-112check_stats_validity() 内部 loop,显式列了 ["mean", "std", "min", "max", "q01", "q99"] 6 个 key,缺一个就 return False↩︎

  11. 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/q99 6 个字段;action/observation.state 长度 6 (匹配 features.shape),timestamp 长度 1。 ↩︎

  12. Isaac-GR00T/gr00t/data/stats.py:121-123generate_stats()for feature in le_features: if "float" in le_features[feature]["dtype"]: lowdim_features.append(feature) —— 只 float 进 stats。 ↩︎

  13. 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 切片三件事。 ↩︎

  14. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:60-63 定义 DEFAULT_COLUMN_NAMES = {"state": "observation.state", "action": "action"};:331group_info.get("original_key", DEFAULT_COLUMN_NAMES[modality_type]) 作 fallback,所以没 original_key 时 state→observation.state、action→action↩︎

  15. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:333-336 的 if 分支:ndarray 走 df[original_key].map(lambda x: x[start_idx:end_idx]) 切片;否则整列直传。 ↩︎

  16. 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)。 ↩︎

  17. Isaac-GR00T/gr00t/data/stats.py:84-86np_data = np.vstack([np.asarray(x, dtype=np.float32) for x in all_low_dim_data[le_modality]]),所有数值列在做 stats 前都先强转到 float32↩︎

  18. cd Isaac-GR00T && grep -rn 'next\.done\|next\.reward' gr00t/ 整个 gr00t/ 子树无匹配;grep -rn 'frame_index' gr00t/ 也无匹配。 ↩︎

  19. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:329-330start_idx = group_info["start"]; end_idx = group_info["end"],后续 :334 直接 x[start_idx:end_idx],即 Python list/ndarray 切片语义,end-exclusive。 ↩︎

  20. 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"↩︎

  21. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:280-295 的 video key mapping block::283needs_mapping = any(k not in self.modality_meta["video"] for k in config_keys);:284-289 当 needs_mapping 时 assert 数量相等;:290-291 for config_key, meta_key in zip(config_keys, meta_keys): self._video_key_mapping[config_key] = meta_key(按位置映射)。 ↩︎

  22. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:369-382_load_parquet_data() 里被 if "language" in self.modality_configs: (:369) 包住——只在用户传 language modality config 时才会读 modality.json["annotation"]tasks_map↩︎

  23. Isaac-GR00T/demo_data/cube_to_bowl_5/meta/info.json:14-123features block,含 action / observation.state / observation.images.wrist / observation.images.front / timestamp / frame_index / episode_index / index / task_index↩︎

  24. 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_pathchunks_size[] 直取(必须存在),其他可选有默认值。 ↩︎

  25. Isaac-GR00T/gr00t/data/stats.py:122if "float" in le_features[feature]["dtype"]: —— 只看 features[feature]["dtype"] 一个字段。 ↩︎

  26. 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")。 ↩︎

  27. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:166-211 覆盖 _load_metadata() 中读 episodes.jsonltasks.jsonlmodality.jsonstats.json、并最终在 get_episode_lengths() (:202-212) 里只用 length 字段。 ↩︎

  28. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:211episode_lengths.append(int(ep_meta["length"])) 是唯一引用 episodes.jsonl 字段的地方(__getitem__:586-587 还会读 episode_index / length,但都是显式必填)。 ↩︎

  29. 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 三个字段。 ↩︎

  30. 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}↩︎

  31. Isaac-GR00T/gr00t/data/dataset/lerobot_episode_loader.py:380-381loaded_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)。 ↩︎