mirror of
https://github.com/Tavish9/any4lerobot.git
synced 2026-07-07 12:31:42 +00:00
2baee72741
* Speed up generic converter aggregation Co-authored-by: Codex <codex@openai.com> * Vectorize generic metadata video updates Co-authored-by: Codex <codex@openai.com> --------- Co-authored-by: Codex <codex@openai.com>
540 lines
18 KiB
Python
540 lines
18 KiB
Python
import os
|
|
import shutil
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
|
|
from datatrove.pipeline.base import PipelineStep
|
|
from datatrove.utils.logging import get_random_str, get_timestamp
|
|
from lerobot.datasets import LeRobotDataset
|
|
from lerobot.datasets.aggregate import aggregate_datasets
|
|
|
|
from .adapter import BaseAdapter
|
|
from .utils import (
|
|
ConversionTask,
|
|
setup_logger,
|
|
unique_strings,
|
|
)
|
|
|
|
|
|
class SaveLeRobotDataset(PipelineStep):
|
|
name = "Save Temp LeRobotDataset"
|
|
|
|
def __init__(self, tasks: list[ConversionTask], adapter: BaseAdapter):
|
|
super().__init__()
|
|
self.tasks = tasks
|
|
self.adapter = adapter
|
|
self.type = f"{adapter.dataset_type}2lerobot"
|
|
|
|
def run(self, data=None, rank: int = 0, world_size: int = 1):
|
|
logger = setup_logger()
|
|
task = self.tasks[rank]
|
|
|
|
if task.output_path.exists():
|
|
shutil.rmtree(task.output_path)
|
|
|
|
dataset = self.adapter.create_dataset(task)
|
|
|
|
logger.info(
|
|
f"start processing for {task.input_path}, saving to {task.output_path}"
|
|
)
|
|
raw_dataset = self.adapter.load_subset(task)
|
|
saved_episodes = 0
|
|
for episode_index, episode_data in enumerate(raw_dataset):
|
|
with self.track_time("saving episode"):
|
|
saved = self.adapter.save_episode(
|
|
dataset,
|
|
episode_data,
|
|
task,
|
|
)
|
|
status = "skipped" if saved is False else "process done"
|
|
logger.info(
|
|
f"{status} for {dataset.repo_id}, episode {episode_index}, "
|
|
f"len {self.adapter.get_episode_length(episode_data)}"
|
|
)
|
|
if saved is not False:
|
|
saved_episodes += 1
|
|
dataset.finalize()
|
|
if saved_episodes == 0:
|
|
logger.info(
|
|
f"no episodes saved for {dataset.repo_id}; deleting temp output"
|
|
)
|
|
shutil.rmtree(task.output_path, ignore_errors=True)
|
|
|
|
|
|
def run_converter(
|
|
adapter: BaseAdapter,
|
|
executor: str,
|
|
cpus_per_task: int,
|
|
tasks_per_job: int,
|
|
workers: int,
|
|
resume_dir: str | None = None,
|
|
debug: bool = False,
|
|
local_repo_id: str | None = None,
|
|
hub_repo_id: str | None = None,
|
|
push_to_hub: bool = False,
|
|
cleanup_temp: bool = True,
|
|
extra_tags: Sequence[str] | None = None,
|
|
) -> Path:
|
|
tasks = adapter.load_tasks()
|
|
output_path = adapter.output_path
|
|
|
|
if not tasks:
|
|
raise ValueError(
|
|
"No conversion tasks found. Provide a non-empty tasks file or matching source files."
|
|
)
|
|
if cpus_per_task < 1:
|
|
raise ValueError("--cpus-per-task must be >= 1")
|
|
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
if debug:
|
|
executor = "local"
|
|
workers = 1
|
|
tasks = tasks[:2]
|
|
push_to_hub = False
|
|
|
|
match executor:
|
|
case "local":
|
|
from datatrove.executor import LocalPipelineExecutor
|
|
|
|
resolved_workers = (
|
|
max(1, (os.cpu_count() or 1) // cpus_per_task)
|
|
if workers == -1
|
|
else workers
|
|
)
|
|
executor_cls, executor_config = (
|
|
LocalPipelineExecutor,
|
|
{
|
|
"tasks": len(tasks),
|
|
"workers": resolved_workers,
|
|
},
|
|
)
|
|
case "ray":
|
|
import ray
|
|
from datatrove.executor import RayPipelineExecutor
|
|
from ray.runtime_env import RuntimeEnv
|
|
|
|
runtime_env = RuntimeEnv(env_vars=_build_ray_env_vars())
|
|
ray.init(runtime_env=runtime_env)
|
|
executor_cls, executor_config = (
|
|
RayPipelineExecutor,
|
|
{
|
|
"tasks": len(tasks),
|
|
"workers": workers,
|
|
"cpus_per_task": cpus_per_task,
|
|
"tasks_per_job": tasks_per_job,
|
|
},
|
|
)
|
|
case _:
|
|
raise ValueError(f"Executor {executor} not supported")
|
|
|
|
if resume_dir:
|
|
logging_dir = str(resume_dir)
|
|
else:
|
|
logging_dir = str(Path.cwd() / "logs" / f"{get_timestamp()}_{get_random_str()}")
|
|
|
|
executor_cls(
|
|
pipeline=[SaveLeRobotDataset(tasks, adapter)],
|
|
**executor_config,
|
|
logging_dir=logging_dir,
|
|
).run()
|
|
aggregate_tasks(
|
|
tasks,
|
|
output_path,
|
|
aggr_repo_id=local_repo_id,
|
|
)
|
|
|
|
if cleanup_temp:
|
|
logger = setup_logger()
|
|
logger.info("Delete temp data_dir")
|
|
shutil.rmtree(adapter.temp_output_path, ignore_errors=True)
|
|
|
|
if push_to_hub:
|
|
if hub_repo_id is None:
|
|
raise ValueError("--repo-id is required when --push-to-hub is set")
|
|
|
|
tags = unique_strings(
|
|
[
|
|
"LeRobot",
|
|
adapter.dataset_type,
|
|
adapter.robot_type,
|
|
*adapter.tags,
|
|
*(extra_tags or []),
|
|
]
|
|
)
|
|
LeRobotDataset(
|
|
repo_id=hub_repo_id,
|
|
root=output_path,
|
|
).push_to_hub(
|
|
tags=tags,
|
|
private=False,
|
|
push_videos=True,
|
|
license="apache-2.0",
|
|
upload_large_folder=False,
|
|
)
|
|
|
|
return output_path
|
|
|
|
|
|
def _build_ray_env_vars() -> dict[str, str]:
|
|
env_vars = {
|
|
"HDF5_USE_FILE_LOCKING": "FALSE",
|
|
"HF_DATASETS_DISABLE_PROGRESS_BARS": "TRUE",
|
|
"SVT_LOG": "1",
|
|
}
|
|
pythonpath = _build_ray_pythonpath()
|
|
if pythonpath:
|
|
env_vars["PYTHONPATH"] = pythonpath
|
|
return env_vars
|
|
|
|
|
|
def _build_ray_pythonpath() -> str:
|
|
repo_root = Path(__file__).resolve().parents[1]
|
|
paths: list[str] = []
|
|
|
|
def add_path(path_value: str | Path):
|
|
path = Path(path_value).expanduser()
|
|
try:
|
|
path = path.resolve()
|
|
except OSError:
|
|
return
|
|
if not path.exists():
|
|
return
|
|
path_str = str(path)
|
|
if path_str not in paths:
|
|
paths.append(path_str)
|
|
|
|
add_path(repo_root)
|
|
add_path(Path.cwd())
|
|
for path in sys.path:
|
|
if path:
|
|
add_path(path)
|
|
for path in os.environ.get("PYTHONPATH", "").split(os.pathsep):
|
|
if path:
|
|
add_path(path)
|
|
|
|
return os.pathsep.join(paths)
|
|
|
|
|
|
def aggregate_tasks(
|
|
tasks: list[ConversionTask],
|
|
output_dir: Path,
|
|
aggr_repo_id: str | None = None,
|
|
):
|
|
logger = setup_logger()
|
|
|
|
if output_dir.exists():
|
|
shutil.rmtree(output_dir)
|
|
|
|
roots = [task.output_path for task in tasks if task.output_path.exists()]
|
|
if not roots:
|
|
raise ValueError("No temporary datasets were produced; nothing to aggregate.")
|
|
|
|
resolved_aggr_repo_id = aggr_repo_id or output_dir.name
|
|
|
|
logger.info(
|
|
f"aggregate {len(roots)} temporary datasets into {output_dir} as {resolved_aggr_repo_id}"
|
|
)
|
|
_aggregate_datasets_with_normalized_arrays(
|
|
repo_ids=[None] * len(roots),
|
|
roots=roots,
|
|
aggr_repo_id=resolved_aggr_repo_id,
|
|
aggr_root=output_dir,
|
|
)
|
|
logger.info(f"aggregation complete: {output_dir}")
|
|
|
|
|
|
def _aggregate_datasets_with_normalized_arrays(**kwargs) -> None:
|
|
from lerobot.datasets import aggregate as aggregate_module
|
|
|
|
original_aggregate_videos = aggregate_module.aggregate_videos
|
|
original_read_parquet = aggregate_module.pd.read_parquet
|
|
original_writer = aggregate_module.to_parquet_one_row_group_per_episode
|
|
original_update_meta_data = aggregate_module.update_meta_data
|
|
|
|
def read_normalized_arrays(*args, **kwargs):
|
|
return _normalize_array_values(original_read_parquet(*args, **kwargs))
|
|
|
|
def write_normalized_arrays(df, path):
|
|
return original_writer(_normalize_array_values(df), path)
|
|
|
|
aggregate_module.aggregate_videos = _aggregate_videos_by_key_parallel
|
|
aggregate_module.pd.read_parquet = read_normalized_arrays
|
|
aggregate_module.to_parquet_one_row_group_per_episode = write_normalized_arrays
|
|
aggregate_module.update_meta_data = _update_meta_data_without_fragmenting
|
|
try:
|
|
aggregate_datasets(**kwargs)
|
|
finally:
|
|
aggregate_module.aggregate_videos = original_aggregate_videos
|
|
aggregate_module.pd.read_parquet = original_read_parquet
|
|
aggregate_module.to_parquet_one_row_group_per_episode = original_writer
|
|
aggregate_module.update_meta_data = original_update_meta_data
|
|
|
|
|
|
def _aggregate_videos_by_key_parallel(
|
|
src_meta,
|
|
dst_meta,
|
|
videos_idx,
|
|
video_files_size_in_mb,
|
|
chunk_size,
|
|
concatenate_videos=True,
|
|
):
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
for video_idx in videos_idx.values():
|
|
video_idx["episode_duration"] = 0
|
|
video_idx["src_to_offset"] = {}
|
|
video_idx["src_to_dst"] = {}
|
|
if "dst_file_durations" not in video_idx:
|
|
video_idx["dst_file_durations"] = {}
|
|
|
|
def aggregate_key(key):
|
|
return (
|
|
key,
|
|
_aggregate_video_key(
|
|
key,
|
|
src_meta,
|
|
dst_meta,
|
|
videos_idx[key],
|
|
video_files_size_in_mb,
|
|
chunk_size,
|
|
concatenate_videos,
|
|
),
|
|
)
|
|
|
|
keys = list(videos_idx)
|
|
if not keys:
|
|
return videos_idx
|
|
|
|
max_workers = min(len(keys), os.cpu_count() or len(keys))
|
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
for key, video_idx in executor.map(aggregate_key, keys):
|
|
videos_idx[key] = video_idx
|
|
|
|
return videos_idx
|
|
|
|
|
|
def _aggregate_video_key(
|
|
key,
|
|
src_meta,
|
|
dst_meta,
|
|
video_idx,
|
|
video_files_size_in_mb,
|
|
chunk_size,
|
|
concatenate_videos,
|
|
):
|
|
from lerobot.datasets import aggregate as aggregate_module
|
|
|
|
unique_chunk_file_pairs = {
|
|
(chunk, file)
|
|
for chunk, file in zip(
|
|
src_meta.episodes[f"videos/{key}/chunk_index"],
|
|
src_meta.episodes[f"videos/{key}/file_index"],
|
|
strict=False,
|
|
)
|
|
}
|
|
unique_chunk_file_pairs = sorted(unique_chunk_file_pairs)
|
|
|
|
chunk_idx = video_idx["chunk"]
|
|
file_idx = video_idx["file"]
|
|
dst_file_durations = video_idx["dst_file_durations"]
|
|
|
|
for src_chunk_idx, src_file_idx in unique_chunk_file_pairs:
|
|
src_path = src_meta.root / aggregate_module.DEFAULT_VIDEO_PATH.format(
|
|
video_key=key,
|
|
chunk_index=src_chunk_idx,
|
|
file_index=src_file_idx,
|
|
)
|
|
dst_path = dst_meta.root / aggregate_module.DEFAULT_VIDEO_PATH.format(
|
|
video_key=key,
|
|
chunk_index=chunk_idx,
|
|
file_index=file_idx,
|
|
)
|
|
|
|
src_duration = aggregate_module.get_video_duration_in_s(src_path)
|
|
dst_key = (chunk_idx, file_idx)
|
|
|
|
if not dst_path.exists():
|
|
video_idx["src_to_offset"][(src_chunk_idx, src_file_idx)] = 0
|
|
video_idx["src_to_dst"][(src_chunk_idx, src_file_idx)] = dst_key
|
|
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(str(src_path), str(dst_path))
|
|
dst_file_durations[dst_key] = src_duration
|
|
video_idx["episode_duration"] += src_duration
|
|
continue
|
|
|
|
src_size = aggregate_module.get_file_size_in_mb(src_path)
|
|
dst_size = aggregate_module.get_file_size_in_mb(dst_path)
|
|
|
|
if not concatenate_videos or dst_size + src_size >= video_files_size_in_mb:
|
|
chunk_idx, file_idx = aggregate_module.update_chunk_file_indices(
|
|
chunk_idx, file_idx, chunk_size
|
|
)
|
|
dst_key = (chunk_idx, file_idx)
|
|
video_idx["src_to_offset"][(src_chunk_idx, src_file_idx)] = 0
|
|
video_idx["src_to_dst"][(src_chunk_idx, src_file_idx)] = dst_key
|
|
dst_path = dst_meta.root / aggregate_module.DEFAULT_VIDEO_PATH.format(
|
|
video_key=key,
|
|
chunk_index=chunk_idx,
|
|
file_index=file_idx,
|
|
)
|
|
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(str(src_path), str(dst_path))
|
|
dst_file_durations[dst_key] = src_duration
|
|
else:
|
|
current_dst_duration = dst_file_durations.get(dst_key, 0)
|
|
video_idx["src_to_offset"][(src_chunk_idx, src_file_idx)] = (
|
|
current_dst_duration
|
|
)
|
|
video_idx["src_to_dst"][(src_chunk_idx, src_file_idx)] = dst_key
|
|
aggregate_module.concatenate_video_files(
|
|
[dst_path, src_path],
|
|
dst_path,
|
|
compatibility_check=True,
|
|
)
|
|
dst_file_durations[dst_key] = current_dst_duration + src_duration
|
|
|
|
video_idx["episode_duration"] += src_duration
|
|
|
|
video_idx["chunk"] = chunk_idx
|
|
video_idx["file"] = file_idx
|
|
|
|
return video_idx
|
|
|
|
|
|
def _update_meta_data_without_fragmenting(df, dst_meta, meta_idx, data_idx, videos_idx):
|
|
import pandas as pd
|
|
|
|
df["meta/episodes/chunk_index"] = (
|
|
df["meta/episodes/chunk_index"] + meta_idx["chunk"]
|
|
)
|
|
df["meta/episodes/file_index"] = df["meta/episodes/file_index"] + meta_idx["file"]
|
|
|
|
data_src_to_dst = data_idx.get("src_to_dst", {})
|
|
if data_src_to_dst:
|
|
orig_data_chunk = df["data/chunk_index"].copy()
|
|
orig_data_file = df["data/file_index"].copy()
|
|
mapping_index = pd.MultiIndex.from_tuples(
|
|
list(data_src_to_dst.keys()),
|
|
names=["chunk_index", "file_index"],
|
|
)
|
|
mapping_df = pd.DataFrame(
|
|
list(data_src_to_dst.values()),
|
|
index=mapping_index,
|
|
columns=["dst_chunk", "dst_file"],
|
|
)
|
|
row_index = pd.MultiIndex.from_arrays(
|
|
[orig_data_chunk, orig_data_file],
|
|
names=["chunk_index", "file_index"],
|
|
)
|
|
reindexed = mapping_df.reindex(row_index)
|
|
reindexed[["dst_chunk", "dst_file"]] = reindexed[
|
|
["dst_chunk", "dst_file"]
|
|
].fillna({"dst_chunk": data_idx["chunk"], "dst_file": data_idx["file"]})
|
|
df["data/chunk_index"] = reindexed["dst_chunk"].to_numpy()
|
|
df["data/file_index"] = reindexed["dst_file"].to_numpy()
|
|
else:
|
|
df["data/chunk_index"] = df["data/chunk_index"] + data_idx["chunk"]
|
|
df["data/file_index"] = df["data/file_index"] + data_idx["file"]
|
|
|
|
for key, video_idx in videos_idx.items():
|
|
orig_chunk_col = f"videos/{key}/chunk_index"
|
|
orig_file_col = f"videos/{key}/file_index"
|
|
orig_chunks = df[orig_chunk_col].copy()
|
|
orig_files = df[orig_file_col].copy()
|
|
|
|
src_to_offset = video_idx.get("src_to_offset", {})
|
|
src_to_dst = video_idx.get("src_to_dst", {})
|
|
row_index = pd.MultiIndex.from_arrays(
|
|
[orig_chunks, orig_files],
|
|
names=["chunk_index", "file_index"],
|
|
)
|
|
|
|
if src_to_dst:
|
|
src_keys = list(src_to_dst)
|
|
mapping_index = pd.MultiIndex.from_tuples(
|
|
src_keys,
|
|
names=["chunk_index", "file_index"],
|
|
)
|
|
mapping_df = pd.DataFrame(
|
|
[
|
|
(
|
|
*src_to_dst[src_key],
|
|
src_to_offset.get(src_key, 0.0),
|
|
)
|
|
for src_key in src_keys
|
|
],
|
|
index=mapping_index,
|
|
columns=["dst_chunk", "dst_file", "offset"],
|
|
)
|
|
reindexed = mapping_df.reindex(row_index)
|
|
df[orig_chunk_col] = (
|
|
reindexed["dst_chunk"]
|
|
.fillna(video_idx["chunk"])
|
|
.astype(orig_chunks.dtype, copy=False)
|
|
.to_numpy()
|
|
)
|
|
df[orig_file_col] = (
|
|
reindexed["dst_file"]
|
|
.fillna(video_idx["file"])
|
|
.astype(orig_files.dtype, copy=False)
|
|
.to_numpy()
|
|
)
|
|
offsets = reindexed["offset"].fillna(0.0).to_numpy(dtype=float)
|
|
df[f"videos/{key}/from_timestamp"] += offsets
|
|
df[f"videos/{key}/to_timestamp"] += offsets
|
|
elif src_to_offset:
|
|
df[orig_chunk_col] = video_idx["chunk"]
|
|
df[orig_file_col] = video_idx["file"]
|
|
mapping_series = pd.Series(src_to_offset, dtype=float)
|
|
offsets = mapping_series.reindex(row_index).fillna(0.0).to_numpy()
|
|
df[f"videos/{key}/from_timestamp"] += offsets
|
|
df[f"videos/{key}/to_timestamp"] += offsets
|
|
else:
|
|
df[orig_chunk_col] = video_idx["chunk"]
|
|
df[orig_file_col] = video_idx["file"]
|
|
df[f"videos/{key}/from_timestamp"] = (
|
|
df[f"videos/{key}/from_timestamp"] + video_idx["latest_duration"]
|
|
)
|
|
df[f"videos/{key}/to_timestamp"] = (
|
|
df[f"videos/{key}/to_timestamp"] + video_idx["latest_duration"]
|
|
)
|
|
|
|
df["dataset_from_index"] = df["dataset_from_index"] + dst_meta.info.total_frames
|
|
df["dataset_to_index"] = df["dataset_to_index"] + dst_meta.info.total_frames
|
|
df["episode_index"] = df["episode_index"] + dst_meta.info.total_episodes
|
|
|
|
return df
|
|
|
|
|
|
def _normalize_array_values(df):
|
|
import pandas as pd
|
|
|
|
df = df.copy()
|
|
for column in df.columns:
|
|
if _has_array_values(df[column]):
|
|
df[column] = pd.Series(
|
|
[_normalize_array_value(value) for value in df[column]],
|
|
dtype=object,
|
|
index=df.index,
|
|
)
|
|
return df
|
|
|
|
|
|
def _normalize_array_value(value):
|
|
import numpy as np
|
|
|
|
if isinstance(value, np.ndarray) and value.ndim > 1:
|
|
return [_normalize_array_value(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _has_array_values(series) -> bool:
|
|
import numpy as np
|
|
|
|
for value in series.head(32):
|
|
if isinstance(value, np.ndarray):
|
|
return True
|
|
return False
|