| import argparse |
| from copy import deepcopy |
| from functools import partial |
| import gc |
| import json |
| import logging |
| from math import ceil |
| import os |
| from pathlib import Path |
| import shutil |
| from typing import Callable |
|
|
| import einops |
| import h5py |
| from lerobot.common.datasets.lerobot_dataset import LeRobotDataset |
| from lerobot.common.datasets.utils import ( |
| STATS_PATH, |
| check_timestamps_sync, |
| get_episode_data_index, |
| serialize_dict, |
| write_json, |
| ) |
| import numpy as np |
| import torch |
| from tqdm import tqdm |
| from tqdm.contrib.concurrent import process_map |
|
|
|
|
| def generate_modality_json(output_dir: str) -> None: |
| """Generate modality.json file defining field mappings for the dataset.""" |
| modality_config = { |
| "state": { |
| "left_arm_joint_position": { |
| "original_key": "observation.state", |
| "start": 0, |
| "end": 7, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "right_arm_joint_position": { |
| "original_key": "observation.state", |
| "start": 7, |
| "end": 14, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "left_effector_position": { |
| "original_key": "observation.state", |
| "start": 14, |
| "end": 15, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "right_effector_position": { |
| "original_key": "observation.state", |
| "start": 15, |
| "end": 16, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "head_position": { |
| "original_key": "observation.state", |
| "start": 16, |
| "end": 18, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "waist_pitch": { |
| "original_key": "observation.state", |
| "start": 18, |
| "end": 19, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "waist_lift": { |
| "original_key": "observation.state", |
| "start": 19, |
| "end": 20, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| }, |
| "action": { |
| "left_arm_joint_position": { |
| "original_key": "action", |
| "start": 0, |
| "end": 7, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "right_arm_joint_position": { |
| "original_key": "action", |
| "start": 7, |
| "end": 14, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "left_effector_position": { |
| "original_key": "action", |
| "start": 14, |
| "end": 15, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "right_effector_position": { |
| "original_key": "action", |
| "start": 15, |
| "end": 16, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "head_position": { |
| "original_key": "action", |
| "start": 16, |
| "end": 18, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "waist_pitch": { |
| "original_key": "action", |
| "start": 18, |
| "end": 19, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "waist_lift": { |
| "original_key": "action", |
| "start": 19, |
| "end": 20, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| "robot_velocity": { |
| "original_key": "action", |
| "start": 20, |
| "end": 22, |
| "rotation_type": None, |
| "absolute": True, |
| "dtype": "float64", |
| "range": None |
| }, |
| }, |
| "video": { |
| "top_head": { |
| "original_key": "observation.images.top_head" |
| }, |
| "hand_left": { |
| "original_key": "observation.images.hand_left" |
| }, |
| "hand_right": { |
| "original_key": "observation.images.hand_right" |
| }, |
| }, |
| "annotation": { |
| "language.action_text": { |
| "original_key": "task_index" |
| }, |
| "agibot.sub_task": { |
| "original_key": "annotation.agibot.sub_task" |
| }, |
| "frame_type": { |
| "original_key": "annotation.frame_type" |
| }, |
| }, |
| } |
|
|
| modality_path = os.path.join(output_dir, "modality.json") |
| with open(modality_path, "w") as f: |
| json.dump(modality_config, f, indent=4) |
| print(f"Generated modality.json at {modality_path}") |
|
|
|
|
| HEAD_COLOR = "head_color.mp4" |
| HAND_LEFT_COLOR = "hand_left_color.mp4" |
| HAND_RIGHT_COLOR = "hand_right_color.mp4" |
|
|
| FEATURES = { |
| "observation.images.top_head": { |
| "dtype": "video", |
| "shape": [480, 640, 3], |
| "names": ["height", "width", "channel"], |
| "video_info": { |
| "video.fps": 30.0, |
| "video.codec": "av1", |
| "video.pix_fmt": "yuv420p", |
| "video.is_depth_map": False, |
| "has_audio": False, |
| }, |
| }, |
| "observation.images.hand_left": { |
| "dtype": "video", |
| "shape": [480, 640, 3], |
| "names": ["height", "width", "channel"], |
| "video_info": { |
| "video.fps": 30.0, |
| "video.codec": "av1", |
| "video.pix_fmt": "yuv420p", |
| "video.is_depth_map": False, |
| "has_audio": False, |
| }, |
| }, |
| "observation.images.hand_right": { |
| "dtype": "video", |
| "shape": [480, 640, 3], |
| "names": ["height", "width", "channel"], |
| "video_info": { |
| "video.fps": 30.0, |
| "video.codec": "av1", |
| "video.pix_fmt": "yuv420p", |
| "video.is_depth_map": False, |
| "has_audio": False, |
| }, |
| }, |
| "observation.state": { |
| "dtype": "float32", |
| "shape": [20], |
| }, |
| "action": { |
| "dtype": "float32", |
| "shape": [22], |
| }, |
| "annotation.language.action_text": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| "annotation.agibot.tasks": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| "episode_index": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| "frame_index": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| "index": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| "task_index": { |
| "dtype": "int64", |
| "shape": [1], |
| "names": None, |
| }, |
| } |
|
|
|
|
| def get_stats_einops_patterns(dataset, num_workers=0): |
| """These einops patterns will be used to aggregate batches and compute statistics. |
| |
| Note: We assume the images are in channel first format |
| """ |
|
|
| dataloader = torch.utils.data.DataLoader( |
| dataset, |
| num_workers=num_workers, |
| batch_size=2, |
| shuffle=False, |
| ) |
| batch = next(iter(dataloader)) |
|
|
| stats_patterns = {} |
|
|
| for key in dataset.features: |
| |
| assert batch[key].dtype != torch.float64 |
|
|
| |
| if key in dataset.meta.camera_keys: |
| |
| _, c, h, w = batch[key].shape |
| assert c < h and c < w, f"expect channel first images, but instead {batch[key].shape}" |
| assert ( |
| batch[key].dtype == torch.float32 |
| ), f"expect torch.float32, but instead {batch[key].dtype=}" |
| |
| |
| stats_patterns[key] = "b c h w -> c 1 1" |
| elif batch[key].ndim == 2: |
| stats_patterns[key] = "b c -> c " |
| elif batch[key].ndim == 1: |
| stats_patterns[key] = "b -> 1" |
| else: |
| raise ValueError(f"{key}, {batch[key].shape}") |
|
|
| return stats_patterns |
|
|
|
|
| def compute_stats(dataset, batch_size=8, num_workers=4, max_num_samples=None): |
| """Compute mean/std and min/max statistics of all data keys in a LeRobotDataset.""" |
| if max_num_samples is None: |
| max_num_samples = len(dataset) |
|
|
| |
| stats_patterns = get_stats_einops_patterns(dataset, num_workers) |
|
|
| |
| mean, std, max, min = {}, {}, {}, {} |
| for key in stats_patterns: |
| mean[key] = torch.tensor(0.0).float() |
| std[key] = torch.tensor(0.0).float() |
| max[key] = torch.tensor(-float("inf")).float() |
| min[key] = torch.tensor(float("inf")).float() |
|
|
| def create_seeded_dataloader(dataset, batch_size, seed): |
| generator = torch.Generator() |
| generator.manual_seed(seed) |
| dataloader = torch.utils.data.DataLoader( |
| dataset, |
| num_workers=num_workers, |
| batch_size=batch_size, |
| shuffle=True, |
| drop_last=False, |
| generator=generator, |
| ) |
| return dataloader |
|
|
| |
| |
| first_batch = None |
| running_item_count = 0 |
| dataloader = create_seeded_dataloader(dataset, batch_size, seed=1337) |
| for i, batch in enumerate( |
| tqdm( |
| dataloader, |
| total=ceil(max_num_samples / batch_size), |
| desc="Compute mean, min, max", |
| ) |
| ): |
| this_batch_size = len(batch["index"]) |
| running_item_count += this_batch_size |
| if first_batch is None: |
| first_batch = deepcopy(batch) |
| for key, pattern in stats_patterns.items(): |
| batch[key] = batch[key].float() |
| |
| batch_mean = einops.reduce(batch[key], pattern, "mean") |
| |
| |
| |
| |
| |
| mean[key] = mean[key] + this_batch_size * (batch_mean - mean[key]) / running_item_count |
| max[key] = torch.maximum(max[key], einops.reduce(batch[key], pattern, "max")) |
| min[key] = torch.minimum(min[key], einops.reduce(batch[key], pattern, "min")) |
|
|
| if i == ceil(max_num_samples / batch_size) - 1: |
| break |
|
|
| first_batch_ = None |
| running_item_count = 0 |
| dataloader = create_seeded_dataloader(dataset, batch_size, seed=1337) |
| for i, batch in enumerate( |
| tqdm(dataloader, total=ceil(max_num_samples / batch_size), desc="Compute std") |
| ): |
| this_batch_size = len(batch["index"]) |
| running_item_count += this_batch_size |
| |
| if first_batch_ is None: |
| first_batch_ = deepcopy(batch) |
| for key in stats_patterns: |
| assert torch.equal(first_batch_[key], first_batch[key]) |
| for key, pattern in stats_patterns.items(): |
| batch[key] = batch[key].float() |
| |
| |
| batch_std = einops.reduce((batch[key] - mean[key]) ** 2, pattern, "mean") |
| std[key] = std[key] + this_batch_size * (batch_std - std[key]) / running_item_count |
|
|
| if i == ceil(max_num_samples / batch_size) - 1: |
| break |
|
|
| for key in stats_patterns: |
| std[key] = torch.sqrt(std[key]) |
|
|
| stats = {} |
| for key in stats_patterns: |
| stats[key] = { |
| "mean": mean[key], |
| "std": std[key], |
| "max": max[key], |
| "min": min[key], |
| } |
| return stats |
|
|
|
|
| class AgiBotDataset(LeRobotDataset): |
| def __init__( |
| self, |
| repo_id: str, |
| root: str | Path | None = None, |
| episodes: list[int] | None = None, |
| image_transforms: Callable | None = None, |
| delta_timestamps: dict[list[float]] | None = None, |
| tolerance_s: float = 1e-4, |
| download_videos: bool = True, |
| local_files_only: bool = False, |
| video_backend: str | None = None, |
| ): |
| super().__init__( |
| repo_id=repo_id, |
| root=root, |
| episodes=episodes, |
| image_transforms=image_transforms, |
| delta_timestamps=delta_timestamps, |
| tolerance_s=tolerance_s, |
| download_videos=download_videos, |
| local_files_only=local_files_only, |
| video_backend=video_backend, |
| ) |
|
|
| def save_episode( |
| self, task: str, episode_data: dict | None = None, videos: dict | None = None |
| ) -> None: |
| """ |
| We rewrite this method to copy mp4 videos to the target position |
| """ |
| if not episode_data: |
| episode_buffer = self.episode_buffer |
|
|
| episode_length = episode_buffer.pop("size") |
| episode_index = episode_buffer["episode_index"] |
| if episode_index != self.meta.total_episodes: |
| |
| raise NotImplementedError( |
| "You might have manually provided the episode_buffer with an episode_index that doesn't " |
| "match the total number of episodes in the dataset. This is not supported for now." |
| ) |
|
|
| if episode_length == 0: |
| raise ValueError( |
| "You must add one or several frames with `add_frame` before calling `add_episode`." |
| ) |
|
|
| |
| task_index = getattr(self, "_custom_task_to_index", {}).get(task, 0) |
|
|
| |
| episode_buffer.pop("task", None) |
|
|
| if not set(episode_buffer.keys()) == set(self.features): |
| raise ValueError() |
|
|
| for key, ft in self.features.items(): |
| if key == "index": |
| episode_buffer[key] = np.arange( |
| self.meta.total_frames, self.meta.total_frames + episode_length |
| ) |
| elif key == "episode_index": |
| episode_buffer[key] = np.full((episode_length,), episode_index) |
| elif key == "task_index": |
| episode_buffer[key] = np.full((episode_length,), task_index) |
| elif ft["dtype"] in ["image", "video"]: |
| continue |
| elif ft["dtype"] == "string": |
| pass |
| elif len(ft["shape"]) == 1 and ft["shape"][0] == 1: |
| episode_buffer[key] = np.array(episode_buffer[key], dtype=ft["dtype"]) |
| elif len(ft["shape"]) == 1 and ft["shape"][0] > 1: |
| episode_buffer[key] = np.stack(episode_buffer[key]) |
| else: |
| raise ValueError(key) |
|
|
| self._wait_image_writer() |
| self._save_episode_table(episode_buffer, episode_index) |
|
|
| |
| for key in self.meta.video_keys: |
| video_path = self.root / self.meta.get_video_file_path(episode_index, key) |
| episode_buffer[key] = video_path |
| video_path.parent.mkdir(parents=True, exist_ok=True) |
| |
| shutil.copyfile(str(videos[key]), str(video_path)) |
|
|
| try: |
| |
| |
| |
| self.meta.save_episode(episode_index, episode_length, [], {}) |
| except AttributeError as e: |
| if "'NoneType' object has no attribute 'items'" in str(e): |
| |
| print( |
| f"Warning: Episode stats computation failed, proceeding without stats " |
| f"for episode {episode_index}" |
| ) |
| |
| pass |
| else: |
| raise |
| if not episode_data: |
| self.episode_buffer = self.create_episode_buffer() |
| self.consolidated = False |
|
|
| def consolidate(self, run_compute_stats: bool = True, keep_image_files: bool = False) -> None: |
| self.hf_dataset = self.load_hf_dataset() |
| self.episode_data_index = get_episode_data_index(self.meta.episodes, self.episodes) |
| check_timestamps_sync(self.hf_dataset, self.episode_data_index, self.fps, self.tolerance_s) |
| if len(self.meta.video_keys) > 0: |
| self.meta.write_video_info() |
|
|
| if not keep_image_files: |
| img_dir = self.root / "images" |
| if img_dir.is_dir(): |
| shutil.rmtree(self.root / "images") |
| video_files = list(self.root.rglob("*.mp4")) |
| assert len(video_files) == self.num_episodes * len(self.meta.video_keys) |
|
|
| parquet_files = list(self.root.rglob("*.parquet")) |
| assert len(parquet_files) == self.num_episodes |
|
|
| if run_compute_stats: |
| self.stop_image_writer() |
| self.meta.stats = compute_stats(self) |
| serialized_stats = serialize_dict(self.meta.stats) |
| write_json(serialized_stats, self.root / STATS_PATH) |
| self.consolidated = True |
| else: |
| logging.warning( |
| "Skipping computation of the dataset statistics, dataset is not fully consolidated." |
| ) |
|
|
| def add_frame(self, frame: dict) -> None: |
| """ |
| This function only adds the frame to the episode_buffer. Apart from images — which are written in a |
| temporary directory — nothing is written to disk. To save those frames, the 'save_episode()' method |
| then needs to be called. |
| """ |
| |
| |
|
|
| if self.episode_buffer is None: |
| self.episode_buffer = self.create_episode_buffer() |
|
|
| frame_index = self.episode_buffer["size"] |
| timestamp = frame.pop("timestamp") if "timestamp" in frame else frame_index / self.fps |
| self.episode_buffer["frame_index"].append(frame_index) |
| self.episode_buffer["timestamp"].append(timestamp) |
|
|
| for key in frame: |
| if key not in self.features: |
| raise ValueError(key) |
| item = frame[key].numpy() if isinstance(frame[key], torch.Tensor) else frame[key] |
| self.episode_buffer[key].append(item) |
|
|
| self.episode_buffer["size"] += 1 |
|
|
|
|
| def detect_dataset_format(src_path: str) -> str: |
| """Detect whether the dataset follows old or new format structure""" |
| src_path = Path(src_path) |
|
|
| |
| if (src_path / "task_info").exists() and (src_path / "proprio_stats").exists(): |
| return "old" |
|
|
| |
| |
| |
| subdirs = [d for d in src_path.iterdir() if d.is_dir()] |
| if subdirs: |
| |
| for job_dir in subdirs: |
| if not job_dir.is_dir(): |
| continue |
| for ( |
| robot_dir |
| ) in job_dir.iterdir(): |
| if not robot_dir.is_dir(): |
| continue |
| for ( |
| episode_dir |
| ) in robot_dir.iterdir(): |
| if episode_dir.is_dir() and (episode_dir / "aligned_joints.h5").exists(): |
| return "new" |
|
|
| return "unknown" |
|
|
|
|
| def load_local_dataset_old_format(episode_id: int, src_path: str, task_id: int) -> list | None: |
| """Load local dataset from old format and return a dict with observations and actions""" |
|
|
| |
| task_json_path = Path(src_path) / f"task_info/task_{task_id}.json" |
| task_info_list = None |
| if task_json_path.exists(): |
| try: |
| with open(task_json_path, "r") as f: |
| task_info_list = json.load(f) |
| except json.JSONDecodeError: |
| print(f"Warning: Failed to decode JSON {task_json_path} for episode {episode_id}") |
| task_info_list = [] |
| else: |
| print(f"Warning: Task info JSON not found at {task_json_path} for episode {episode_id}") |
| task_info_list = [] |
|
|
| |
| episode_action_config = None |
| if isinstance(task_info_list, list): |
| for item in task_info_list: |
| |
| if "episode_id" in item and int(item["episode_id"]) == episode_id: |
| episode_action_config = item.get("label_info", {}).get("action_config") |
| break |
|
|
| default_action_text = "N/A" |
|
|
| ob_dir = Path(src_path) / f"observations/{task_id}/{episode_id}" |
| proprio_dir = Path(src_path) / f"proprio_stats/{task_id}/{episode_id}" |
|
|
| with h5py.File(proprio_dir / "proprio_stats.h5") as f: |
| state_joint = np.array(f["state/joint/position"]) |
| state_effector = np.clip((np.array(f["state/effector/position"]) - 35.0) / (120.0 - 35.0), 0.0, 1.0) |
| state_head = np.array(f["state/head/position"]) |
| state_waist = np.array(f["state/waist/position"]) |
| action_joint = np.array(f["action/joint/position"]) |
| action_effector = np.clip((np.array(f["action/effector/position"]) - 35.0) / (120.0 - 35.0), 0.0, 1.0) |
| action_head = np.array(f["action/head/position"]) |
| action_waist = np.array(f["action/waist/position"]) |
| action_velocity = np.array(f["action/robot/velocity"]) |
|
|
| |
| states_value = np.hstack( |
| [ |
| state_joint, |
| state_effector, |
| state_head, |
| state_waist, |
| ] |
| ).astype(np.float32) |
| assert ( |
| action_joint.shape[0] == action_effector.shape[0] |
| ), f"shape of action_joint:{action_joint.shape};shape of action_effector:{action_effector.shape}" |
| |
| action_value = np.hstack( |
| [ |
| action_joint, |
| action_effector, |
| action_head, |
| action_waist, |
| action_velocity, |
| ] |
| ).astype(np.float32) |
|
|
| num_frames = len(states_value) |
|
|
| |
| frame_action_texts = [default_action_text] * num_frames |
| if episode_action_config: |
| for action in episode_action_config: |
| start = action.get("start_frame") |
| end = action.get("end_frame") |
| text = action.get("action_text", default_action_text) |
|
|
| if start is None or end is None: |
| continue |
|
|
| clamped_start = max(0, start) |
| clamped_end = min(num_frames, end) |
| for i in range(clamped_start, clamped_end): |
| frame_action_texts[i] = text |
|
|
| frames = [ |
| { |
| "observation.state": states_value[i], |
| "action": action_value[i], |
| "annotation.language.action_text": [frame_action_texts[i]], |
| } |
| for i in range(num_frames) |
| ] |
|
|
| v_path = ob_dir / "videos" |
| videos = { |
| "observation.images.top_head": v_path / HEAD_COLOR, |
| "observation.images.hand_left": v_path / HAND_LEFT_COLOR, |
| "observation.images.hand_right": v_path / HAND_RIGHT_COLOR, |
| } |
| return frames, videos |
|
|
|
|
| def load_local_dataset_new_format(episode_path: str) -> list | None: |
| """Load local dataset from new format and return a dict with observations and actions""" |
|
|
| episode_dir = Path(episode_path) |
|
|
| |
| data_info_path = episode_dir / "data_info.json" |
| episode_action_config = None |
| default_action_text = "N/A" |
|
|
| if data_info_path.exists(): |
| try: |
| with open(data_info_path, "r") as f: |
| data_info = json.load(f) |
| episode_action_config = data_info.get("label_info", {}).get("action_config") |
| except json.JSONDecodeError: |
| print(f"Warning: Failed to decode JSON {data_info_path}") |
|
|
| |
| joints_path = episode_dir / "aligned_joints.h5" |
| if not joints_path.exists(): |
| print(f"Warning: aligned_joints.h5 not found at {joints_path}") |
| return None |
|
|
| with h5py.File(joints_path) as f: |
| |
| state_joint = np.array(f["state/joint/position"]) |
| state_head = np.array(f["state/head/position"]) |
| state_waist = np.array(f["state/waist/position"]) |
|
|
| |
| |
| state_left_effector = np.array(f["state/left_effector/position"]) |
| state_right_effector = np.array(f["state/right_effector/position"]) |
| state_effector = np.clip( |
| np.column_stack([state_left_effector.flatten(), state_right_effector.flatten()]) - 35.0, |
| 0.0, 85.0 |
| ) / 85.0 |
|
|
| |
| action_joint = np.array(f["action/joint/position"]) |
| action_head = np.array(f["action/head/position"]) |
| action_waist = np.array(f["action/waist/position"]) |
|
|
| |
| |
| action_left_effector = np.array(f["action/left_effector/position"]) |
| action_right_effector = np.array(f["action/right_effector/position"]) |
| action_effector = np.clip( |
| np.column_stack([action_left_effector.flatten(), action_right_effector.flatten()]) - 35.0, |
| 0.0, 85.0 |
| ) / 85.0 |
|
|
| |
| action_velocity_raw = np.array(f["action/robot/velocity"]) |
| if action_velocity_raw.ndim == 1: |
| |
| action_velocity = np.column_stack( |
| [action_velocity_raw, np.zeros_like(action_velocity_raw)] |
| ) |
| else: |
| action_velocity = action_velocity_raw[:, :2] |
|
|
| |
| states_value = np.hstack( |
| [ |
| state_joint, |
| state_effector, |
| state_head, |
| state_waist, |
| ] |
| ).astype(np.float32) |
| |
| action_value = np.hstack( |
| [ |
| action_joint, |
| action_effector, |
| action_head, |
| action_waist, |
| action_velocity, |
| ] |
| ).astype(np.float32) |
|
|
| num_frames = len(states_value) |
|
|
| |
| frame_action_texts = [default_action_text] * num_frames |
| if episode_action_config: |
| for action in episode_action_config: |
| start = action.get("start_frame") |
| end = action.get("end_frame") |
| |
| text = action.get("english_action_text") or action.get( |
| "action_text", default_action_text |
| ) |
|
|
| if start is None or end is None: |
| continue |
|
|
| clamped_start = max(0, start) |
| clamped_end = min(num_frames, end) |
| for i in range(clamped_start, clamped_end): |
| frame_action_texts[i] = text |
|
|
| frames = [ |
| { |
| "observation.state": states_value[i], |
| "action": action_value[i], |
| "annotation.language.action_text": [frame_action_texts[i]], |
| } |
| for i in range(num_frames) |
| ] |
|
|
| |
| videos = { |
| "observation.images.top_head": episode_dir / HEAD_COLOR, |
| "observation.images.hand_left": episode_dir / HAND_LEFT_COLOR, |
| "observation.images.hand_right": episode_dir / HAND_RIGHT_COLOR, |
| } |
| return frames, videos |
|
|
|
|
| def load_local_dataset( |
| episode_id: int, |
| src_path: str, |
| task_id: int = None, |
| episode_path: str = None, |
| format_type: str = "old", |
| ) -> list | None: |
| """Load local dataset and return a dict with observations and actions |
| |
| Args: |
| episode_id: Episode ID (used for old format) |
| src_path: Source path (used for old format) |
| task_id: Task ID (used for old format) |
| episode_path: Full path to episode directory (used for new format) |
| format_type: "old" or "new" format |
| """ |
| if format_type == "old": |
| return load_local_dataset_old_format(episode_id, src_path, task_id) |
| elif format_type == "new": |
| return load_local_dataset_new_format(episode_path) |
| else: |
| raise ValueError(f"Unknown format type: {format_type}") |
|
|
|
|
| def get_task_instruction_old_format(task_json_path: str) -> str: |
| """Get task language instruction from old format""" |
| with open(task_json_path, "r") as f: |
| task_info = json.load(f) |
| task_name = task_info[0]["task_name"] |
| task_init_scene = task_info[0]["init_scene_text"] |
| task_instruction = f"{task_name}.{task_init_scene}" |
| print(f"Get Task Instruction <{task_instruction}>") |
| return task_instruction |
|
|
|
|
| def get_task_instruction_new_format(episode_paths: list) -> str: |
| """Get task language instruction from new format - use first episode's data_info.json""" |
| if not episode_paths: |
| return "Unknown Task" |
|
|
| first_episode_path = Path(episode_paths[0]) |
| data_info_path = first_episode_path / "data_info.json" |
|
|
| if data_info_path.exists(): |
| try: |
| with open(data_info_path, "r") as f: |
| data_info = json.load(f) |
| |
| task_name = data_info.get("english_task_name") or data_info.get( |
| "task_name", "Unknown Task" |
| ) |
| task_instruction = task_name |
| print( |
| f"Get Task Instruction <{task_instruction}> " |
| f"(english_task_name: {data_info.get('english_task_name')}, " |
| f"task_name: {data_info.get('task_name')})" |
| ) |
| return task_instruction |
| except json.JSONDecodeError: |
| print(f"Warning: Failed to decode JSON {data_info_path}") |
|
|
| return "Unknown Task" |
|
|
|
|
| def get_task_instruction( |
| task_json_path: str = None, episode_paths: list = None, format_type: str = "old" |
| ) -> str: |
| """Get task language instruction""" |
| if format_type == "old": |
| return get_task_instruction_old_format(task_json_path) |
| elif format_type == "new": |
| return get_task_instruction_new_format(episode_paths) |
| else: |
| raise ValueError(f"Unknown format type: {format_type}") |
|
|
|
|
| def load_new_format_episode(episode_path): |
| """Helper function for multiprocessing - load new format episode""" |
| return load_local_dataset( |
| episode_id=0, src_path="", episode_path=episode_path, format_type="new" |
| ) |
|
|
|
|
| def create_tasks_jsonl(tgt_path: str, repo_id: str, task_name: str, all_action_texts: set) -> dict: |
| """Create tasks.jsonl file with unique task names and action texts.""" |
| meta_path = os.path.join(tgt_path, repo_id, "meta") |
| os.makedirs(meta_path, exist_ok=True) |
|
|
| tasks_jsonl_path = os.path.join(meta_path, "tasks.jsonl") |
|
|
| |
| |
| unique_action_texts = all_action_texts - {task_name} |
|
|
| tasks = [task_name] |
| tasks.extend(sorted(unique_action_texts)) |
|
|
| |
| should_write = True |
| if os.path.exists(tasks_jsonl_path): |
| try: |
| with open(tasks_jsonl_path, "r") as f: |
| existing_content = f.read().strip() |
|
|
| |
| expected_lines = [] |
| for i, task in enumerate(tasks): |
| task_entry = {"task_index": i, "task": task} |
| expected_lines.append(json.dumps(task_entry)) |
| expected_content = "\n".join(expected_lines) |
|
|
| |
| if existing_content == expected_content: |
| should_write = False |
| except Exception as e: |
| print(f"Warning: Failed to read tasks.jsonl: {e}") |
| |
| pass |
|
|
| if should_write: |
| |
| with open(tasks_jsonl_path, "w") as f: |
| for i, task in enumerate(tasks): |
| task_entry = {"task_index": i, "task": task} |
| f.write(json.dumps(task_entry) + "\n") |
|
|
| print(f"Created tasks.jsonl with {len(tasks)} entries at {tasks_jsonl_path}") |
| else: |
| print(f"tasks.jsonl already exists with correct content at {tasks_jsonl_path}") |
|
|
| |
| task_to_index = {task: i for i, task in enumerate(tasks)} |
|
|
| return task_to_index |
|
|
|
|
| def main( |
| src_path: str, |
| tgt_path: str, |
| task_id: int = None, |
| repo_id: str = None, |
| task_info_json: str = None, |
| debug: bool = False, |
| chunk_size: int = 10, |
| ): |
| |
| format_type = detect_dataset_format(src_path) |
| print(f"Detected dataset format: {format_type}") |
|
|
| if format_type == "unknown": |
| raise ValueError(f"Unable to detect dataset format for path: {src_path}") |
|
|
| |
| all_action_texts = set() |
|
|
| |
| if not repo_id: |
| if format_type == "old": |
| repo_id = f"agibotworld/task_{task_id}" |
| else: |
| |
| task_id = Path(src_path).name |
| repo_id = f"agibotworld/task_{task_id}" |
|
|
| dataset = AgiBotDataset.create( |
| repo_id=repo_id, |
| root=f"{tgt_path}/{repo_id}", |
| fps=30, |
| robot_type="a2d", |
| features=FEATURES, |
| ) |
|
|
| if format_type == "old": |
| |
| task_name = get_task_instruction(task_json_path=task_info_json, format_type="old") |
|
|
| all_subdir = sorted( |
| [f.as_posix() for f in Path(src_path).glob(f"observations/{task_id}/*") if f.is_dir()] |
| ) |
|
|
| if debug: |
| all_subdir = all_subdir[:2] |
|
|
| |
| all_subdir_eids = [int(Path(path).name) for path in all_subdir] |
| all_subdir_episode_desc = [task_name] * len(all_subdir_eids) |
|
|
| |
| print("Collecting unique action texts...") |
| for episode_id in tqdm(all_subdir_eids, desc="Scanning for action texts"): |
| frames_data, _ = load_local_dataset( |
| episode_id, src_path=src_path, task_id=task_id, format_type="old" |
| ) |
| if frames_data: |
| for frame in frames_data: |
| action_text = frame["annotation.language.action_text"][0] |
| all_action_texts.add(action_text) |
|
|
| |
| task_to_index = create_tasks_jsonl(tgt_path, repo_id, task_name, all_action_texts) |
|
|
| |
| dataset._custom_task_to_index = task_to_index |
|
|
| |
| for chunk_start in tqdm( |
| range(0, len(all_subdir_eids), chunk_size), desc="Processing chunks" |
| ): |
| chunk_end = min(chunk_start + chunk_size, len(all_subdir_eids)) |
| chunk_eids = all_subdir_eids[chunk_start:chunk_end] |
| chunk_descs = all_subdir_episode_desc[chunk_start:chunk_end] |
|
|
| |
| if debug: |
| raw_datasets_chunk = [ |
| load_local_dataset( |
| subdir, src_path=src_path, task_id=task_id, format_type="old" |
| ) |
| for subdir in tqdm(chunk_eids, desc="Loading chunk data") |
| ] |
| else: |
| raw_datasets_chunk = process_map( |
| partial( |
| load_local_dataset, src_path=src_path, task_id=task_id, format_type="old" |
| ), |
| chunk_eids, |
| max_workers=os.cpu_count() // 2, |
| desc=f"Loading chunk {chunk_start//chunk_size + 1}/" |
| f"{(len(all_subdir_eids) + chunk_size - 1)//chunk_size}", |
| ) |
|
|
| |
| valid_datasets = [ |
| (ds, desc) for ds, desc in zip(raw_datasets_chunk, chunk_descs) if ds is not None |
| ] |
|
|
| |
| for raw_dataset, episode_desc in tqdm( |
| valid_datasets, desc="Processing episodes in chunk" |
| ): |
| for raw_dataset_sub in tqdm(raw_dataset[0], desc="Processing frames", leave=False): |
| |
| action_text = raw_dataset_sub["annotation.language.action_text"][0] |
| raw_dataset_sub["annotation.language.action_text"] = [ |
| task_to_index[action_text] |
| ] |
| raw_dataset_sub["annotation.agibot.tasks"] = [task_to_index[episode_desc]] |
| dataset.add_frame(raw_dataset_sub) |
| dataset.save_episode(task=episode_desc, videos=raw_dataset[1]) |
|
|
| |
| raw_datasets_chunk = None |
| valid_datasets = None |
| gc.collect() |
|
|
| else: |
| |
| all_episode_paths = [] |
| src_path = Path(src_path) |
|
|
| |
| for job_dir in src_path.iterdir(): |
| if not job_dir.is_dir(): |
| continue |
| for ( |
| robot_dir |
| ) in job_dir.iterdir(): |
| if not robot_dir.is_dir(): |
| continue |
| for ( |
| episode_dir |
| ) in robot_dir.iterdir(): |
| if episode_dir.is_dir() and (episode_dir / "aligned_joints.h5").exists(): |
| all_episode_paths.append(str(episode_dir)) |
|
|
| all_episode_paths = sorted(all_episode_paths) |
|
|
| if debug: |
| all_episode_paths = all_episode_paths[:2] |
|
|
| |
| task_name = get_task_instruction(episode_paths=all_episode_paths, format_type="new") |
| all_episode_descs = [task_name] * len(all_episode_paths) |
|
|
| |
| print("Collecting unique action texts...") |
| for episode_path in tqdm(all_episode_paths, desc="Scanning for action texts"): |
| frames_data, _ = load_local_dataset( |
| episode_id=0, src_path="", episode_path=episode_path, format_type="new" |
| ) |
| if frames_data: |
| for frame in frames_data: |
| action_text = frame["annotation.language.action_text"][0] |
| all_action_texts.add(action_text) |
|
|
| |
| task_to_index = create_tasks_jsonl(tgt_path, repo_id, task_name, all_action_texts) |
|
|
| |
| dataset._custom_task_to_index = task_to_index |
|
|
| |
| for chunk_start in tqdm( |
| range(0, len(all_episode_paths), chunk_size), desc="Processing chunks" |
| ): |
| chunk_end = min(chunk_start + chunk_size, len(all_episode_paths)) |
| chunk_paths = all_episode_paths[chunk_start:chunk_end] |
| chunk_descs = all_episode_descs[chunk_start:chunk_end] |
|
|
| |
| if debug: |
| raw_datasets_chunk = [ |
| load_local_dataset( |
| episode_id=0, src_path="", episode_path=episode_path, format_type="new" |
| ) |
| for episode_path in tqdm(chunk_paths, desc="Loading chunk data") |
| ] |
| else: |
| raw_datasets_chunk = process_map( |
| load_new_format_episode, |
| chunk_paths, |
| max_workers=os.cpu_count() // 2, |
| desc=f"Loading chunk {chunk_start//chunk_size + 1}/" |
| f"{(len(all_episode_paths) + chunk_size - 1)//chunk_size}", |
| ) |
|
|
| |
| valid_datasets = [ |
| (ds, desc) for ds, desc in zip(raw_datasets_chunk, chunk_descs) if ds is not None |
| ] |
|
|
| |
| for raw_dataset, episode_desc in tqdm( |
| valid_datasets, desc="Processing episodes in chunk" |
| ): |
| for raw_dataset_sub in tqdm(raw_dataset[0], desc="Processing frames", leave=False): |
| |
| action_text = raw_dataset_sub["annotation.language.action_text"][0] |
| raw_dataset_sub["annotation.language.action_text"] = [ |
| task_to_index[action_text] |
| ] |
| raw_dataset_sub["annotation.agibot.tasks"] = [task_to_index[episode_desc]] |
| dataset.add_frame(raw_dataset_sub) |
| dataset.save_episode(task=episode_desc, videos=raw_dataset[1]) |
|
|
| |
| raw_datasets_chunk = None |
| valid_datasets = None |
| gc.collect() |
|
|
| |
| try: |
| dataset.consolidate(run_compute_stats=False) |
| except Exception as e: |
| print(f"Warning: Consolidation failed with error: {e}") |
| print("Dataset conversion completed but may not be fully consolidated.") |
|
|
| |
| meta_path = os.path.join(tgt_path, repo_id, "meta") |
| os.makedirs(meta_path, exist_ok=True) |
| generate_modality_json(meta_path) |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser( |
| description="Convert AgiBot dataset to LeRobot format. Supports both old and new format datasets." |
| ) |
| parser.add_argument( |
| "--src_path", type=str, required=True, help="Path to source dataset directory" |
| ) |
| parser.add_argument( |
| "--task_id", |
| type=str, |
| required=False, |
| help="Task ID (required for old format, optional for new format)", |
| ) |
| parser.add_argument( |
| "--tgt_path", type=str, required=True, help="Path to target output directory" |
| ) |
| parser.add_argument( |
| "--repo_id", |
| type=str, |
| required=False, |
| help="Repository ID for the dataset (auto-generated if not provided)", |
| ) |
| parser.add_argument( |
| "--debug", action="store_true", help="Process only first 2 episodes for debugging" |
| ) |
| parser.add_argument( |
| "--chunk_size", type=int, default=10, help="Number of episodes to process at once" |
| ) |
| args = parser.parse_args() |
|
|
| |
| format_type = detect_dataset_format(args.src_path) |
| print(f"Auto-detected format: {format_type}") |
|
|
| |
| if format_type == "old": |
| if not args.task_id: |
| parser.error("--task_id is required for old format datasets") |
|
|
| task_id = int(args.task_id) |
| json_file = f"{args.src_path}/task_info/task_{args.task_id}.json" |
|
|
| if not Path(json_file).exists(): |
| parser.error(f"Cannot find task info file: {json_file}") |
|
|
| main( |
| src_path=args.src_path, |
| tgt_path=args.tgt_path, |
| task_id=task_id, |
| repo_id=args.repo_id, |
| task_info_json=json_file, |
| debug=args.debug, |
| chunk_size=args.chunk_size, |
| ) |
|
|
| elif format_type == "new": |
| main( |
| src_path=args.src_path, |
| tgt_path=args.tgt_path, |
| task_id=args.task_id, |
| repo_id=args.repo_id, |
| task_info_json=None, |
| debug=args.debug, |
| chunk_size=args.chunk_size, |
| ) |
|
|
| else: |
| parser.error( |
| f"Unknown dataset format. Please check the directory structure at: {args.src_path}" |
| ) |
| |
|
|