Update v21 to v30 converter (#113)

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Qizhi Chen
2026-07-01 18:05:12 -07:00
committed by GitHub
parent 4ad4f9f12a
commit 69373154e7
@@ -26,11 +26,23 @@ This script will help you convert any LeRobot dataset already pushed to the hub
Usage: Usage:
Convert a dataset from the hub:
```bash ```bash
python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \ python src/lerobot/scripts/convert_dataset_v21_to_v30.py \
--repo-id=lerobot/pusht --repo-id=lerobot/pusht
``` ```
Convert a local dataset (works in place):
```bash
python src/lerobot/scripts/convert_dataset_v21_to_v30.py \
--repo-id=lerobot/pusht \
--root=/path/to/local/dataset/directory \
--push-to-hub=false
N.B. Path semantics (v2): --root is the exact dataset folder containing
meta/, data/, videos/. When omitted, defaults to $HF_LEROBOT_HOME/{repo_id}.
```
""" """
import argparse import argparse
@@ -39,39 +51,47 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from lerobot.utils.import_utils import require_package
require_package("jsonlines", extra="dataset")
import jsonlines import jsonlines
import pandas as pd import pandas as pd
import pyarrow as pa import pyarrow as pa
import tqdm import tqdm
from datasets import Dataset, Features, Image from datasets import Dataset, Features, Image
from huggingface_hub import HfApi, snapshot_download from huggingface_hub import HfApi, snapshot_download
from lerobot.datasets.compute_stats import aggregate_stats from requests import HTTPError
from lerobot.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset
from lerobot.datasets import CODEBASE_VERSION, LeRobotDataset, aggregate_stats
from lerobot.datasets.io_utils import (
cast_stats_to_numpy,
get_file_size_in_mb,
get_parquet_file_size_in_mb,
get_parquet_num_frames,
load_info,
load_json,
write_episodes,
write_info,
write_stats,
write_tasks,
)
from lerobot.datasets.utils import ( from lerobot.datasets.utils import (
DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_SIZE,
DEFAULT_DATA_FILE_SIZE_IN_MB, DEFAULT_DATA_FILE_SIZE_IN_MB,
DEFAULT_DATA_PATH, DEFAULT_DATA_PATH,
DEFAULT_VIDEO_FILE_SIZE_IN_MB, DEFAULT_VIDEO_FILE_SIZE_IN_MB,
DEFAULT_VIDEO_PATH, DEFAULT_VIDEO_PATH,
INFO_PATH,
LEGACY_EPISODES_PATH, LEGACY_EPISODES_PATH,
LEGACY_EPISODES_STATS_PATH, LEGACY_EPISODES_STATS_PATH,
LEGACY_TASKS_PATH, LEGACY_TASKS_PATH,
cast_stats_to_numpy, DatasetInfo,
flatten_dict,
get_file_size_in_mb,
get_parquet_file_size_in_mb,
get_parquet_num_frames,
load_info,
update_chunk_file_indices, update_chunk_file_indices,
write_episodes,
write_info,
write_stats,
write_tasks,
) )
from lerobot.datasets.video_utils import concatenate_video_files, get_video_duration_in_s from lerobot.datasets.video_utils import concatenate_video_files, get_video_duration_in_s
from lerobot.utils.constants import HF_LEROBOT_HOME from lerobot.utils.constants import HF_LEROBOT_HOME
from lerobot.utils.utils import init_logging from lerobot.utils.utils import flatten_dict, init_logging
from requests import HTTPError
V21 = "v2.1" V21 = "v2.1"
V30 = "v3.0" V30 = "v3.0"
@@ -95,7 +115,7 @@ episodes.jsonl
{"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266} {"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266}
NEW NEW
meta/episodes/chunk-000/episodes_000.parquet meta/episodes/chunk-000/file_000.parquet
episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length
------------------------- -------------------------
OLD OLD
@@ -103,15 +123,16 @@ tasks.jsonl
{"task_index": 1, "task": "Put the blue block in the green bowl"} {"task_index": 1, "task": "Put the blue block in the green bowl"}
NEW NEW
meta/tasks/chunk-000/file_000.parquet meta/tasks.parquet
task_index | task task_index | task
------------------------- -------------------------
OLD OLD
episodes_stats.jsonl episodes_stats.jsonl
{"episode_index": 1, "stats": {"feature_name": {"min": ..., "max": ..., "mean": ..., "std": ..., "count": ...}}}
NEW NEW
meta/episodes_stats/chunk-000/file_000.parquet meta/episodes/chunk-000/file_000.parquet
episode_index | mean | std | min | max episode_index | feature_name/min | feature_name/max | feature_name/mean | feature_name/std | feature_name/count
------------------------- -------------------------
UPDATE UPDATE
meta/info.json meta/info.json
@@ -147,7 +168,7 @@ def legacy_load_tasks(local_dir: Path) -> tuple[dict, dict]:
def validate_local_dataset_version(local_path: Path) -> None: def validate_local_dataset_version(local_path: Path) -> None:
"""Validate that the local dataset has the expected v2.1 version.""" """Validate that the local dataset has the expected v2.1 version."""
info = load_info(local_path) info = load_info(local_path)
dataset_version = info.get("codebase_version", "unknown") dataset_version = info.codebase_version or "unknown"
if dataset_version != V21: if dataset_version != V21:
raise ValueError( raise ValueError(
f"Local dataset has codebase version '{dataset_version}', expected '{V21}'. " f"Local dataset has codebase version '{dataset_version}', expected '{V21}'. "
@@ -160,7 +181,7 @@ def convert_tasks(root, new_root):
tasks, _ = legacy_load_tasks(root) tasks, _ = legacy_load_tasks(root)
task_indices = tasks.keys() task_indices = tasks.keys()
task_strings = tasks.values() task_strings = tasks.values()
df_tasks = pd.DataFrame({"task_index": task_indices}, index=task_strings) df_tasks = pd.DataFrame({"task_index": task_indices}, index=pd.Index(task_strings, name="task"))
write_tasks(df_tasks, new_root) write_tasks(df_tasks, new_root)
@@ -191,7 +212,6 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
image_keys = get_image_keys(root) image_keys = get_image_keys(root)
ep_idx = 0
chunk_idx = 0 chunk_idx = 0
file_idx = 0 file_idx = 0
size_in_mb = 0 size_in_mb = 0
@@ -201,9 +221,23 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
logging.info(f"Converting data files from {len(ep_paths)} episodes") logging.info(f"Converting data files from {len(ep_paths)} episodes")
for ep_path in tqdm.tqdm(ep_paths, desc="convert data files"): for ep_idx, ep_path in enumerate(tqdm.tqdm(ep_paths, desc="convert data files")):
ep_size_in_mb = get_parquet_file_size_in_mb(ep_path) ep_size_in_mb = get_parquet_file_size_in_mb(ep_path)
ep_num_frames = get_parquet_num_frames(ep_path) ep_num_frames = get_parquet_num_frames(ep_path)
# Check if we need to start a new file BEFORE creating metadata
if size_in_mb + ep_size_in_mb >= data_file_size_in_mb and len(paths_to_cat) > 0:
# Write the accumulated data files
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Move to next file
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Reset for the next file
size_in_mb = 0
paths_to_cat = []
# Now create metadata with correct chunk/file indices
ep_metadata = { ep_metadata = {
"episode_index": ep_idx, "episode_index": ep_idx,
"data/chunk_index": chunk_idx, "data/chunk_index": chunk_idx,
@@ -214,20 +248,7 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
size_in_mb += ep_size_in_mb size_in_mb += ep_size_in_mb
num_frames += ep_num_frames num_frames += ep_num_frames
episodes_metadata.append(ep_metadata) episodes_metadata.append(ep_metadata)
ep_idx += 1 paths_to_cat.append(ep_path)
if size_in_mb < data_file_size_in_mb:
paths_to_cat.append(ep_path)
continue
if paths_to_cat:
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Reset for the next file
size_in_mb = ep_size_in_mb
paths_to_cat = [ep_path]
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Write remaining data if any # Write remaining data if any
if paths_to_cat: if paths_to_cat:
@@ -238,14 +259,14 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
def get_video_keys(root): def get_video_keys(root):
info = load_info(root) info = load_info(root)
features = info["features"] features = info.features
video_keys = [key for key, ft in features.items() if ft["dtype"] == "video"] video_keys = [key for key, ft in features.items() if ft["dtype"] == "video"]
return video_keys return video_keys
def get_image_keys(root): def get_image_keys(root):
info = load_info(root) info = load_info(root)
features = info["features"] features = info.features
image_keys = [key for key, ft in features.items() if ft["dtype"] == "image"] image_keys = [key for key, ft in features.items() if ft["dtype"] == "image"]
return image_keys return image_keys
@@ -268,7 +289,7 @@ def convert_videos(root: Path, new_root: Path, video_file_size_in_mb: int):
if len(set(num_eps_per_cam)) != 1: if len(set(num_eps_per_cam)) != 1:
raise ValueError(f"All cams dont have same number of episodes ({num_eps_per_cam}).") raise ValueError(f"All cams dont have same number of episodes ({num_eps_per_cam}).")
episods_metadata = [] episodes_metadata = []
num_cameras = len(video_keys) num_cameras = len(video_keys)
num_episodes = num_eps_per_cam[0] num_episodes = num_eps_per_cam[0]
for ep_idx in tqdm.tqdm(range(num_episodes), desc="convert videos"): for ep_idx in tqdm.tqdm(range(num_episodes), desc="convert videos"):
@@ -281,9 +302,9 @@ def convert_videos(root: Path, new_root: Path, video_file_size_in_mb: int):
ep_dict = {} ep_dict = {}
for cam_idx in range(num_cameras): for cam_idx in range(num_cameras):
ep_dict.update(eps_metadata_per_cam[cam_idx][ep_idx]) ep_dict.update(eps_metadata_per_cam[cam_idx][ep_idx])
episods_metadata.append(ep_dict) episodes_metadata.append(ep_dict)
return episods_metadata return episodes_metadata
def convert_videos_of_camera(root: Path, new_root: Path, video_key: str, video_file_size_in_mb: int): def convert_videos_of_camera(root: Path, new_root: Path, video_key: str, video_file_size_in_mb: int):
@@ -416,7 +437,8 @@ def convert_episodes_metadata(root, new_root, episodes_metadata, episodes_video_
def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb): def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb):
info = load_info(root) # Load as raw dict to remove legacy v2.1 fields before constructing DatasetInfo.
info = load_json(root / INFO_PATH)
info["codebase_version"] = V30 info["codebase_version"] = V30
del info["total_chunks"] del info["total_chunks"]
del info["total_videos"] del info["total_videos"]
@@ -431,7 +453,9 @@ def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb):
# already has fps in video_info # already has fps in video_info
continue continue
info["features"][key]["fps"] = info["fps"] info["features"][key]["fps"] = info["fps"]
write_info(info, new_root) # Convert raw dict to typed DatasetInfo before writing
dataset_info = DatasetInfo.from_dict(info)
write_info(dataset_info, new_root)
def convert_dataset( def convert_dataset(
@@ -459,7 +483,7 @@ def convert_dataset(
# Set root based on whether local dataset path is provided # Set root based on whether local dataset path is provided
use_local_dataset = False use_local_dataset = False
root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) / repo_id root = HF_LEROBOT_HOME / repo_id if root is None else Path(root)
if root.exists(): if root.exists():
validate_local_dataset_version(root) validate_local_dataset_version(root)
use_local_dataset = True use_local_dataset = True
@@ -519,7 +543,7 @@ if __name__ == "__main__":
type=str, type=str,
required=True, required=True,
help="Repository identifier on Hugging Face: a community or a user name `/` the name of the dataset " help="Repository identifier on Hugging Face: a community or a user name `/` the name of the dataset "
"(e.g. `lerobot/pusht`, `cadene/aloha_sim_insertion_human`).", "(e.g. `lerobot/pusht`, `<USER>/aloha_sim_insertion_human`).",
) )
parser.add_argument( parser.add_argument(
"--branch", "--branch",
@@ -543,7 +567,7 @@ if __name__ == "__main__":
"--root", "--root",
type=str, type=str,
default=None, default=None,
help="Local directory to use for downloading/writing the dataset.", help="Local directory to use for downloading/writing the dataset. Defaults to $HF_LEROBOT_HOME/repo_id.",
) )
parser.add_argument( parser.add_argument(
"--push-to-hub", "--push-to-hub",
@@ -558,4 +582,4 @@ if __name__ == "__main__":
) )
args = parser.parse_args() args = parser.parse_args()
convert_dataset(**vars(args)) convert_dataset(**vars(args))