| """ |
| Convert a standard LeRobot v2 dataset to the GEAR/DreamZero training format. |
| |
| This script takes a dataset collected with LeRobot v2 and generates/augments the |
| metadata files required by DreamZero's training pipeline: |
| |
| - meta/modality.json (state/action/video/annotation key mapping) |
| - meta/embodiment.json (embodiment tag for the training pipeline) |
| - meta/stats.json (dataset-level statistics: mean, std, min, max, q01, q99) |
| - meta/relative_stats_dreamzero.json (relative action statistics) |
| - meta/tasks.jsonl (task descriptions) |
| - meta/episodes.jsonl (episode-level metadata) |
| |
| The script does NOT modify parquet files or videos -- it only creates metadata. |
| |
| Usage: |
| # Auto-detect state/action structure, default embodiment tag 'xdof': |
| python scripts/data/convert_lerobot_to_gear.py --dataset-path ./Dataset/my_robot_data |
| |
| # Explicit modality mapping via JSON: |
| python scripts/data/convert_lerobot_to_gear.py \\ |
| --dataset-path ./Dataset/my_robot_data \\ |
| --embodiment-tag xdof \\ |
| --state-keys '{"joint_pos": [0, 6], "gripper_pos": [6, 7]}' \\ |
| --action-keys '{"joint_pos": [0, 6], "gripper_pos": [6, 7]}' \\ |
| --relative-action-keys joint_pos \\ |
| --task-key annotation.task |
| |
| # Copy to a new output directory instead of modifying in-place: |
| python scripts/data/convert_lerobot_to_gear.py \\ |
| --dataset-path ./Dataset/my_robot_data \\ |
| --output-path ./Dataset/my_robot_data_gear |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import logging |
| import shutil |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| from tqdm import tqdm |
|
|
| logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") |
| log = logging.getLogger(__name__) |
|
|
| VALID_EMBODIMENT_TAGS = [ |
| "real_gr1_arms_only", "real_gr1_arms_only_annotated", |
| "real_gr1_arms_waist", "real_gr1_arms_waist_annotated", |
| "dexmg_gr1_arms_only_inspire", "dexmg_gr1_arms_only_fourier", |
| "dexmg_gr1_arms_waist_fourier", |
| "robocasa_single_arm", "onex_eve_gripper", |
| "robocasa_gr1_arms_only_inspire_hands", "robocasa_gr1_arms_only_fourier_hands", |
| "robocasa_gr1_fixed_lower_body_inspire_hands", "robocasa_gr1_fixed_lower_body_fourier_hands", |
| "robocasa_panda_omron", |
| "robocasa_bimanual_panda_parallel_gripper", "robocasa_bimanual_panda_inspire_hand", |
| "oxe_droid", "oxe_fractal", "oxe_language_table", "oxe_bridge", |
| "real_panda_single_arm", "hot3d_hands_only", |
| "gr1_unified", "robocasa_gr1_arms_waist_fourier_hands", |
| "agibot", "lapa", "oxe_mutex", "oxe_roboset", "oxe_plex", |
| "dream", "yam", "xdof", |
| "gr1_unified_segmentation", "language_table_sim", "gr1_isaac", |
| "sim_behavior_r1_pro", "mecka_hands", "real_r1_pro_sharpa", "libero_sim", |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def load_info(dataset_path: Path) -> dict: |
| info_path = dataset_path / "meta" / "info.json" |
| if not info_path.exists(): |
| log.error("meta/info.json not found at %s", info_path) |
| sys.exit(1) |
| with open(info_path) as f: |
| return json.load(f) |
|
|
|
|
| def get_parquet_paths(dataset_path: Path, info: dict) -> list[Path]: |
| pattern = info.get("data_path", "data/chunk-{episode_chunk:03d}/episode_{episode_index:06d}.parquet") |
| total_episodes = info["total_episodes"] |
| chunks_size = info.get("chunks_size", 1000) |
| paths = [] |
| for ep_idx in range(total_episodes): |
| chunk_idx = ep_idx // chunks_size |
| p = dataset_path / pattern.format(episode_chunk=chunk_idx, episode_index=ep_idx) |
| if p.exists(): |
| paths.append(p) |
| return sorted(paths) |
|
|
|
|
| def detect_features(info: dict) -> dict: |
| """Return categorised feature names from info.json.""" |
| features = info.get("features", {}) |
| state_keys = [k for k in features if k.startswith("observation.state")] |
| action_keys = [k for k in features if k == "action" or k.startswith("action.")] |
| video_keys = [k for k in features if features[k].get("dtype") == "video"] |
| annotation_keys = [k for k in features if k.startswith("annotation")] |
| return { |
| "state": state_keys, |
| "action": action_keys, |
| "video": video_keys, |
| "annotation": annotation_keys, |
| "features": features, |
| } |
|
|
|
|
| def parse_key_mapping(raw: str | None) -> dict[str, list[int]] | None: |
| """Parse a JSON string like '{"joint_pos": [0, 6], "gripper": [6, 7]}'.""" |
| if raw is None: |
| return None |
| try: |
| mapping = json.loads(raw) |
| except json.JSONDecodeError as e: |
| log.error("Invalid JSON for key mapping: %s", e) |
| sys.exit(1) |
| for name, bounds in mapping.items(): |
| if not isinstance(bounds, list) or len(bounds) != 2: |
| log.error("Each entry must be [start, end]. Got %s for '%s'", bounds, name) |
| sys.exit(1) |
| return mapping |
|
|
|
|
| |
| |
| |
|
|
| def build_modality_json( |
| info: dict, |
| detected: dict, |
| state_mapping: dict[str, list[int]] | None, |
| action_mapping: dict[str, list[int]] | None, |
| task_key: str | None, |
| ) -> dict: |
| """Build the modality.json structure expected by GEAR/DreamZero.""" |
| features = detected["features"] |
| modality: dict = {"state": {}, "action": {}, "video": {}, "annotation": {}} |
|
|
| |
| state_col = detected["state"][0] if detected["state"] else None |
| if state_col and state_mapping: |
| for name, (start, end) in state_mapping.items(): |
| dtype = features[state_col].get("dtype", "float64") |
| modality["state"][name] = { |
| "original_key": state_col, |
| "start": start, |
| "end": end, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": dtype, |
| "range": None, |
| } |
| elif state_col: |
| shape = features[state_col].get("shape", [1]) |
| dim = shape[0] if isinstance(shape, list) else shape |
| dtype = features[state_col].get("dtype", "float64") |
| modality["state"]["state"] = { |
| "original_key": state_col, |
| "start": 0, |
| "end": dim, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": dtype, |
| "range": None, |
| } |
|
|
| |
| action_col = detected["action"][0] if detected["action"] else None |
| if action_col and action_mapping: |
| for name, (start, end) in action_mapping.items(): |
| dtype = features[action_col].get("dtype", "float64") |
| modality["action"][name] = { |
| "original_key": action_col, |
| "start": start, |
| "end": end, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": dtype, |
| "range": None, |
| } |
| elif action_col: |
| shape = features[action_col].get("shape", [1]) |
| dim = shape[0] if isinstance(shape, list) else shape |
| dtype = features[action_col].get("dtype", "float64") |
| modality["action"]["action"] = { |
| "original_key": action_col, |
| "start": 0, |
| "end": dim, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": dtype, |
| "range": None, |
| } |
|
|
| |
| for vk in detected["video"]: |
| short_name = vk.replace("observation.images.", "") |
| modality["video"][short_name] = {"original_key": vk} |
|
|
| |
| if task_key: |
| short = task_key.replace("annotation.", "") |
| modality["annotation"][short] = {"original_key": task_key} |
| else: |
| for ak in detected["annotation"]: |
| short = ak.replace("annotation.", "") |
| modality["annotation"][short] = {"original_key": ak} |
|
|
| return modality |
|
|
|
|
| |
| |
| |
|
|
| def compute_stats(parquet_paths: list[Path], columns: list[str]) -> dict: |
| """Compute mean/std/min/max/q01/q99 for numeric columns across all episodes.""" |
| all_data: dict[str, list] = {col: [] for col in columns} |
| for pp in tqdm(parquet_paths, desc="Computing stats"): |
| df = pd.read_parquet(pp) |
| for col in columns: |
| if col not in df.columns: |
| continue |
| arr = np.stack(df[col].values) |
| if arr.ndim == 1: |
| arr = arr.reshape(-1, 1) |
| all_data[col].append(arr) |
|
|
| stats = {} |
| for col in columns: |
| if not all_data[col]: |
| continue |
| data = np.concatenate(all_data[col], axis=0).astype(np.float64) |
| stats[col] = { |
| "mean": np.mean(data, axis=0).tolist(), |
| "std": np.std(data, axis=0).tolist(), |
| "min": np.min(data, axis=0).tolist(), |
| "max": np.max(data, axis=0).tolist(), |
| "q01": np.quantile(data, 0.01, axis=0).tolist(), |
| "q99": np.quantile(data, 0.99, axis=0).tolist(), |
| } |
| return stats |
|
|
|
|
| def compute_relative_stats( |
| parquet_paths: list[Path], |
| modality: dict, |
| relative_action_keys: list[str], |
| action_horizon: int = 24, |
| ) -> dict: |
| """Compute relative-action statistics: (action - reference_state) for each key. |
| |
| This replicates the logic in groot/vla/data/dataset/lerobot.py |
| _calculate_relative_stats_for_key. |
| """ |
| stats: dict = {} |
| for rel_key in relative_action_keys: |
| if rel_key not in modality["action"]: |
| log.warning("Relative action key '%s' not found in action modality, skipping", rel_key) |
| continue |
| if rel_key not in modality["state"]: |
| log.warning( |
| "Relative action key '%s' has no matching state key -- " |
| "relative stats require a corresponding state key with the same name. Skipping.", |
| rel_key, |
| ) |
| continue |
|
|
| action_meta = modality["action"][rel_key] |
| state_meta = modality["state"][rel_key] |
|
|
| all_relative = [] |
| for pp in tqdm(parquet_paths, desc=f"Relative stats [{rel_key}]"): |
| df = pd.read_parquet(pp) |
| action_col = action_meta["original_key"] |
| state_col = state_meta["original_key"] |
| if action_col not in df.columns or state_col not in df.columns: |
| continue |
|
|
| action_data = np.stack(df[action_col].values).astype(np.float64) |
| state_data = np.stack(df[state_col].values).astype(np.float64) |
| if action_data.ndim == 1: |
| action_data = action_data.reshape(-1, 1) |
| if state_data.ndim == 1: |
| state_data = state_data.reshape(-1, 1) |
|
|
| a_start, a_end = action_meta["start"], action_meta["end"] |
| s_start, s_end = state_meta["start"], state_meta["end"] |
|
|
| action_slice = action_data[:, a_start:a_end] |
| state_slice = state_data[:, s_start:s_end] |
|
|
| traj_len = len(df) |
| usable = traj_len - action_horizon |
| for i in range(max(usable, 0)): |
| ref_state = state_slice[i] |
| chunk_end = min(i + action_horizon, traj_len) |
| actions = action_slice[i:chunk_end] |
| relative = actions - ref_state |
| all_relative.extend(relative) |
|
|
| if not all_relative: |
| log.warning("No relative actions computed for '%s'", rel_key) |
| continue |
|
|
| data = np.array(all_relative) |
| stats[rel_key] = { |
| "max": np.max(data, axis=0).tolist(), |
| "min": np.min(data, axis=0).tolist(), |
| "mean": np.mean(data, axis=0).tolist(), |
| "std": np.std(data, axis=0).tolist(), |
| "q01": np.quantile(data, 0.01, axis=0).tolist(), |
| "q99": np.quantile(data, 0.99, axis=0).tolist(), |
| } |
|
|
| return stats |
|
|
|
|
| |
| |
| |
|
|
| def build_tasks(parquet_paths: list[Path], task_key: str | None) -> list[dict]: |
| """Build tasks.jsonl entries from the dataset.""" |
| if task_key is None: |
| return [{"task_index": 0, "task": ""}] |
|
|
| task_set: dict[str, int] = {} |
| for pp in tqdm(parquet_paths, desc="Extracting tasks"): |
| df = pd.read_parquet(pp) |
| if task_key not in df.columns: |
| continue |
| for val in df[task_key].unique(): |
| text = str(val) if not isinstance(val, str) else val |
| if text not in task_set: |
| task_set[text] = len(task_set) |
|
|
| if not task_set: |
| return [{"task_index": 0, "task": ""}] |
|
|
| return [{"task_index": idx, "task": text} for text, idx in sorted(task_set.items(), key=lambda x: x[1])] |
|
|
|
|
| def build_episodes(parquet_paths: list[Path], info: dict, task_key: str | None, tasks: list[dict]) -> list[dict]: |
| """Build episodes.jsonl entries.""" |
| task_text_to_idx = {t["task"]: t["task_index"] for t in tasks} |
| episodes = [] |
| for ep_idx, pp in enumerate(tqdm(parquet_paths, desc="Building episodes")): |
| df = pd.read_parquet(pp) |
| length = len(df) |
|
|
| ep_tasks: list[str] = [] |
| if task_key and task_key in df.columns: |
| unique_tasks = df[task_key].unique() |
| for t in unique_tasks: |
| text = str(t) if not isinstance(t, str) else t |
| if text and text in task_text_to_idx: |
| ep_tasks.append(text) |
| if not ep_tasks: |
| ep_tasks = [""] |
|
|
| episodes.append({ |
| "episode_index": ep_idx, |
| "tasks": ep_tasks, |
| "length": length, |
| }) |
|
|
| return episodes |
|
|
|
|
| |
| |
| |
|
|
| def validate_dataset(dataset_path: Path, info: dict, modality: dict) -> list[str]: |
| """Run basic validation and return a list of warnings.""" |
| warnings = [] |
|
|
| |
| for subdir in ["data", "videos", "meta"]: |
| if not (dataset_path / subdir).exists(): |
| warnings.append(f"Missing directory: {subdir}/") |
|
|
| |
| if not modality["video"]: |
| warnings.append("No video features detected -- DreamZero requires at least one camera view") |
|
|
| |
| if not modality["state"]: |
| warnings.append("No state modality keys defined") |
| if not modality["action"]: |
| warnings.append("No action modality keys defined") |
|
|
| |
| if info.get("total_episodes", 0) == 0: |
| warnings.append("total_episodes is 0 in info.json") |
|
|
| |
| if info.get("fps") is None: |
| warnings.append("fps not set in info.json") |
|
|
| return warnings |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Convert a LeRobot v2 dataset to GEAR/DreamZero training format.", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog=__doc__, |
| ) |
| parser.add_argument("--dataset-path", type=str, required=True, help="Path to the LeRobot v2 dataset") |
| parser.add_argument("--output-path", type=str, default=None, help="Output path (default: modify in-place)") |
| parser.add_argument( |
| "--embodiment-tag", type=str, default="xdof", |
| help=f"Embodiment tag (default: xdof). Valid: {', '.join(sorted(set(VALID_EMBODIMENT_TAGS)))}" |
| ) |
| parser.add_argument( |
| "--state-keys", type=str, default=None, |
| help='JSON mapping of state sub-keys to [start, end] index ranges, ' |
| 'e.g. \'{"joint_pos": [0, 6], "gripper_pos": [6, 7]}\'' |
| ) |
| parser.add_argument( |
| "--action-keys", type=str, default=None, |
| help='JSON mapping of action sub-keys to [start, end] index ranges' |
| ) |
| parser.add_argument( |
| "--relative-action-keys", type=str, nargs="*", default=None, |
| help="Action sub-key names to compute relative stats for (e.g. joint_pos gripper_pos). " |
| "Each key must also exist in --state-keys. If omitted, skips relative stats." |
| ) |
| parser.add_argument("--task-key", type=str, default=None, help="Column name for language annotations (auto-detected if not set)") |
| parser.add_argument("--fps", type=float, default=None, help="Override FPS (default: use dataset FPS from info.json)") |
| parser.add_argument("--action-horizon", type=int, default=24, help="Action horizon for relative stats (default: 24)") |
| parser.add_argument("--force", action="store_true", help="Overwrite existing GEAR metadata files") |
|
|
| args = parser.parse_args() |
|
|
| dataset_path = Path(args.dataset_path).resolve() |
| if not dataset_path.exists(): |
| log.error("Dataset path does not exist: %s", dataset_path) |
| sys.exit(1) |
|
|
| |
| if args.embodiment_tag not in VALID_EMBODIMENT_TAGS: |
| log.error( |
| "Invalid embodiment tag '%s'. Valid tags:\n %s", |
| args.embodiment_tag, |
| "\n ".join(sorted(set(VALID_EMBODIMENT_TAGS))), |
| ) |
| sys.exit(1) |
|
|
| |
| if args.output_path: |
| output_path = Path(args.output_path).resolve() |
| if output_path != dataset_path: |
| log.info("Copying dataset to %s", output_path) |
| if output_path.exists(): |
| if not args.force: |
| log.error("Output path already exists. Use --force to overwrite.") |
| sys.exit(1) |
| shutil.rmtree(output_path) |
| shutil.copytree(dataset_path, output_path) |
| dataset_path = output_path |
| else: |
| output_path = dataset_path |
|
|
| meta_dir = output_path / "meta" |
| meta_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| info = load_info(dataset_path) |
| detected = detect_features(info) |
|
|
| log.info("Dataset: %s", dataset_path.name) |
| log.info(" Episodes: %d", info.get("total_episodes", 0)) |
| log.info(" FPS: %s", info.get("fps", "not set")) |
| log.info(" State columns: %s", detected["state"]) |
| log.info(" Action columns: %s", detected["action"]) |
| log.info(" Video features: %d camera(s)", len(detected["video"])) |
| log.info(" Annotation columns: %s", detected["annotation"]) |
|
|
| if args.fps is not None: |
| info["fps"] = args.fps |
| with open(output_path / "meta" / "info.json", "w") as f: |
| json.dump(info, f, indent=4) |
| log.info(" Overriding FPS to %s", args.fps) |
|
|
| |
| state_mapping = parse_key_mapping(args.state_keys) |
| action_mapping = parse_key_mapping(args.action_keys) |
|
|
| |
| task_key = args.task_key |
| if task_key is None and detected["annotation"]: |
| for candidate in ["annotation.task", "annotation.language.language_instruction"]: |
| if candidate in detected["annotation"]: |
| task_key = candidate |
| break |
| if task_key is None: |
| task_key = detected["annotation"][0] |
| log.info(" Auto-detected task key: %s", task_key) |
|
|
| |
| modality = build_modality_json(info, detected, state_mapping, action_mapping, task_key) |
|
|
| modality_path = meta_dir / "modality.json" |
| if modality_path.exists() and not args.force: |
| log.info(" modality.json already exists, skipping (use --force to overwrite)") |
| else: |
| with open(modality_path, "w") as f: |
| json.dump(modality, f, indent=4) |
| log.info(" Wrote modality.json (%d state keys, %d action keys, %d video keys)", |
| len(modality["state"]), len(modality["action"]), len(modality["video"])) |
|
|
| |
| embodiment = {"robot_type": args.embodiment_tag, "embodiment_tag": args.embodiment_tag} |
| embodiment_path = meta_dir / "embodiment.json" |
| if embodiment_path.exists() and not args.force: |
| log.info(" embodiment.json already exists, skipping") |
| else: |
| with open(embodiment_path, "w") as f: |
| json.dump(embodiment, f, indent=4) |
| log.info(" Wrote embodiment.json (tag=%s)", args.embodiment_tag) |
|
|
| |
| parquet_paths = get_parquet_paths(output_path, info) |
| if not parquet_paths: |
| log.error("No parquet files found. Check dataset structure.") |
| sys.exit(1) |
| log.info(" Found %d parquet files", len(parquet_paths)) |
|
|
| |
| stats_path = meta_dir / "stats.json" |
| numeric_cols = detected["state"] + detected["action"] |
| if "timestamp" in info.get("features", {}): |
| numeric_cols.append("timestamp") |
|
|
| if stats_path.exists() and not args.force: |
| log.info(" stats.json already exists, skipping") |
| else: |
| log.info(" Computing dataset statistics...") |
| stats = compute_stats(parquet_paths, numeric_cols) |
| with open(stats_path, "w") as f: |
| json.dump(stats, f, indent=4) |
| log.info(" Wrote stats.json (%d features)", len(stats)) |
|
|
| |
| rel_stats_path = meta_dir / "relative_stats_dreamzero.json" |
| if args.relative_action_keys: |
| if rel_stats_path.exists() and not args.force: |
| log.info(" relative_stats_dreamzero.json already exists, skipping") |
| else: |
| log.info(" Computing relative action statistics for keys: %s", args.relative_action_keys) |
| rel_stats = compute_relative_stats( |
| parquet_paths, modality, args.relative_action_keys, |
| action_horizon=args.action_horizon, |
| ) |
| if rel_stats: |
| with open(rel_stats_path, "w") as f: |
| json.dump(rel_stats, f, indent=4) |
| log.info(" Wrote relative_stats_dreamzero.json (%d keys)", len(rel_stats)) |
| else: |
| log.warning(" No relative stats computed (check key names match between state and action)") |
| else: |
| log.info(" Skipping relative stats (no --relative-action-keys provided)") |
|
|
| |
| tasks_path = meta_dir / "tasks.jsonl" |
| if tasks_path.exists() and not args.force: |
| log.info(" tasks.jsonl already exists, skipping") |
| else: |
| tasks = build_tasks(parquet_paths, task_key) |
| with open(tasks_path, "w") as f: |
| for t in tasks: |
| f.write(json.dumps(t) + "\n") |
| log.info(" Wrote tasks.jsonl (%d tasks)", len(tasks)) |
|
|
| |
| episodes_path = meta_dir / "episodes.jsonl" |
| if episodes_path.exists() and not args.force: |
| log.info(" episodes.jsonl already exists, skipping") |
| else: |
| tasks = [] |
| if tasks_path.exists(): |
| with open(tasks_path) as f: |
| for line in f: |
| tasks.append(json.loads(line.strip())) |
| if not tasks: |
| tasks = [{"task_index": 0, "task": ""}] |
| episodes = build_episodes(parquet_paths, info, task_key, tasks) |
| with open(episodes_path, "w") as f: |
| for ep in episodes: |
| f.write(json.dumps(ep) + "\n") |
| log.info(" Wrote episodes.jsonl (%d episodes)", len(episodes)) |
|
|
| |
| warnings = validate_dataset(output_path, info, modality) |
| if warnings: |
| log.warning("Validation warnings:") |
| for w in warnings: |
| log.warning(" - %s", w) |
| else: |
| log.info("Validation passed -- no warnings") |
|
|
| |
| print("\n" + "=" * 60) |
| print("Conversion complete!") |
| print(f" Output: {output_path}") |
| print(f" Embodiment tag: {args.embodiment_tag}") |
| print(f" State keys: {list(modality['state'].keys())}") |
| print(f" Action keys: {list(modality['action'].keys())}") |
| print(f" Video keys: {list(modality['video'].keys())}") |
| print(f" Task key: {task_key or '(none)'}") |
| if args.relative_action_keys: |
| print(f" Relative action keys: {args.relative_action_keys}") |
| print("=" * 60) |
| print("\nNext steps:") |
| print(" 1. Create a YAML data config in groot/vla/configs/data/dreamzero/") |
| print(" 2. Add modality configs to base_48_wan_fine_aug_relative.yaml") |
| print(" 3. Create a training script in scripts/train/") |
| print(" See docs/CUSTOM_EMBODIMENT_TRAINING.md for the full guide.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|