| """ |
| AgiBotWorld dataset loader for the generic dataset converter for Robometer model training. |
| This module contains AgiBotWorld-specific logic for loading and processing data using |
| HuggingFace streaming to efficiently handle large datasets. |
| """ |
|
|
| import json |
| import os |
| from functools import partial |
| from multiprocessing import Pool, cpu_count |
| from pathlib import Path |
| from typing import Any |
|
|
| import h5py |
| import numpy as np |
| from dataset_upload.helpers import ( |
| create_hf_trajectory, |
| create_trajectory_video_optimized, |
| load_sentence_transformer_model, |
| generate_unique_id, |
| ) |
| from dataset_upload.video_helpers import load_video_frames |
| from tqdm import tqdm |
|
|
| import datasets as hfds |
| from datasets import Dataset, load_dataset |
|
|
| |
| from dataset_upload.data_scripts.agibot.agibot_helper import get_episode_record |
|
|
| |
| |
| |
|
|
| CAMERA_KEYS = { |
| "head_color", |
| "head_left_fisheye_color", |
| "head_right_fisheye_color", |
| "head_center_fisheye_color", |
| } |
|
|
|
|
| def _stable_shard_for_episode(episode_id: str, shard_modulus: int = 1000) -> str: |
| """Return a stable top-level shard name based on episode id. |
| |
| Keeps at most ~shard_modulus episode directories per shard. |
| """ |
|
|
| try: |
| idx = int(episode_id) |
| except Exception: |
| idx = abs(hash(episode_id)) |
| shard_index = idx // shard_modulus |
| return f"shard_{shard_index:04d}" |
|
|
|
|
| def _parse_episode_and_camera(key: str) -> tuple[str, str | None]: |
| """Parse __key__ like '678985/videos/head_color' -> ('678985', 'head_color').""" |
| parts = key.split("/") |
| if len(parts) < 3: |
| return parts[0], None |
| return parts[0], parts[2] |
|
|
|
|
| def _build_video_paths( |
| output_dir: str, |
| dataset_name: str, |
| episode_id: str, |
| subtask_idx: int, |
| camera: str, |
| ) -> tuple[str, str]: |
| """Return (full_path, relative_path) using a two-level shard + per-episode layout. |
| |
| Layout: |
| <output>/<dataset>/<shard_X>/<episode_id>/clip_<k>@<camera>.mp4 |
| This avoids >1k files per directory while keeping resume-friendly structure. |
| """ |
| shard_dir = _stable_shard_for_episode(episode_id) |
| episode_dir = os.path.join(output_dir, dataset_name.lower(), shard_dir, f"episode_{episode_id}") |
| os.makedirs(episode_dir, exist_ok=True) |
| filename = f"clip_{subtask_idx}@{camera}.mp4" |
| full_path = os.path.join(episode_dir, filename) |
| rel_path = os.path.join(dataset_name.lower(), shard_dir, f"episode_{episode_id}", filename) |
| return full_path, rel_path |
|
|
|
|
| def _collect_unique_texts_for_batch(records: list[tuple[str, dict]]) -> list[str]: |
| """Collect unique instruction texts from a list of (episode_id, record) pairs.""" |
| texts: list[str] = [] |
| seen: set = set() |
| for _episode_id, rec in records: |
| |
| full_text = rec.get("task_name") or rec.get("task_description") or "" |
| if full_text and full_text not in seen: |
| seen.add(full_text) |
| texts.append(full_text) |
|
|
| |
| actions = rec.get("label_info", {}).get("action_config", []) |
| for a in actions: |
| t = (a or {}).get("action_text", "").strip() |
| if t and t not in seen: |
| seen.add(t) |
| texts.append(t) |
| return texts |
|
|
|
|
| def _encode_texts(texts: list[str], model) -> dict[str, Any]: |
| """Encode a list of texts to vectors using a preloaded model, with caching.""" |
| if not texts: |
| return {} |
| vectors = model.encode(texts) |
| return dict(zip(texts, vectors, strict=False)) |
|
|
|
|
| def _frames_for_subrange(frames: np.ndarray, start: int, end: int) -> np.ndarray: |
| """Return frames[start:end] with guardrails; [start, end) semantics.""" |
| start = max(int(start), 0) |
| end = min(int(end), len(frames)) |
| if start >= end: |
| return np.empty((0,), dtype=object) |
| return frames[start:end] |
|
|
|
|
| def _process_single_stream_sample( |
| sample: dict[str, Any], |
| embeddings: dict[str, Any], |
| output_dir: str, |
| dataset_name: str, |
| max_frames: int, |
| fps: int, |
| ) -> list[dict]: |
| """Process one streaming sample: returns zero or more HF entries. |
| |
| This function does not load any language model; it expects embeddings for |
| the relevant instruction texts to be provided. |
| """ |
|
|
| result_entries: list[dict] = [] |
|
|
| |
| key = sample.get("__key__") or "" |
| episode_id, camera = _parse_episode_and_camera(key) |
| if not camera or camera not in CAMERA_KEYS: |
| return result_entries |
|
|
| |
| try: |
| _json_path, rec = get_episode_record(episode_id) |
| except Exception: |
| return result_entries |
|
|
| |
| video_bytes = sample.get("mp4") |
| if not video_bytes: |
| return result_entries |
|
|
| |
| try: |
| frames = load_video_frames(video_bytes) |
| except Exception: |
| return result_entries |
|
|
| if frames is None or len(frames) == 0: |
| return result_entries |
|
|
| |
| |
| full_text = rec.get("task_name") or rec.get("task_description") or "" |
| if full_text: |
| subtask_idx = 0 |
| full_out_path, rel_path = _build_video_paths(output_dir, dataset_name, episode_id, subtask_idx, camera) |
| |
| if not os.path.exists(full_out_path): |
| _ = create_trajectory_video_optimized(frames, full_out_path, max_frames=max_frames, fps=fps) |
|
|
| lang_vec = embeddings.get(full_text) |
| if lang_vec is None: |
| |
| lang_vec = np.zeros((384,), dtype=np.float32) |
|
|
| traj_dict = { |
| "id": generate_unique_id(), |
| "frames": frames, |
| "task": full_text, |
| "is_robot": True, |
| "quality_label": "successful", |
| "preference_group_id": None, |
| "preference_rank": None, |
| } |
| entry = create_hf_trajectory( |
| traj_dict=traj_dict, |
| video_path=full_out_path, |
| lang_vector=lang_vec, |
| max_frames=max_frames, |
| dataset_name=dataset_name, |
| use_video=True, |
| fps=fps, |
| ) |
| if entry: |
| entry["frames"] = rel_path |
| result_entries.append(entry) |
|
|
| |
| actions = rec.get("label_info", {}).get("action_config", []) |
| for i, a in enumerate(actions, start=1): |
| if not isinstance(a, dict): |
| continue |
| text = (a.get("action_text") or "").strip() |
| if not text: |
| continue |
| start = a.get("start_frame", 0) |
| end = a.get("end_frame", len(frames)) |
| sub_frames = _frames_for_subrange(frames, start, end) |
| if sub_frames.size == 0: |
| continue |
|
|
| out_path, rel_path = _build_video_paths(output_dir, dataset_name, episode_id, i, camera) |
| if not os.path.exists(out_path): |
| _ = create_trajectory_video_optimized(sub_frames, out_path, max_frames=max_frames, fps=fps) |
|
|
| lang_vec = embeddings.get(text) |
| if lang_vec is None: |
| lang_vec = np.zeros((384,), dtype=np.float32) |
|
|
| traj_dict = { |
| "id": generate_unique_id(), |
| "frames": sub_frames, |
| "task": text, |
| "is_robot": True, |
| "quality_label": "successful", |
| "preference_group_id": None, |
| "preference_rank": None, |
| } |
| entry = create_hf_trajectory( |
| traj_dict=traj_dict, |
| video_path=out_path, |
| lang_vector=lang_vec, |
| max_frames=max_frames, |
| dataset_name=dataset_name, |
| use_video=True, |
| fps=fps, |
| ) |
| if entry: |
| entry["frames"] = rel_path |
| result_entries.append(entry) |
|
|
| return result_entries |
|
|
|
|
| def convert_agibotworld_streaming_to_hf( |
| dataset_name: str, |
| output_dir: str, |
| dataset_label: str = "agibotworld", |
| max_trajectories: int | None = None, |
| max_frames: int = 64, |
| fps: int = 10, |
| num_workers: int = -1, |
| ) -> Dataset: |
| """Stream AgiBotWorld, extract camera videos, and write HF entries. |
| |
| Returns a datasets.Dataset built from the collected entries. All videos are |
| saved to disk under output_dir. |
| """ |
|
|
| |
| ds = load_dataset(dataset_name, streaming=True, split="train") |
| |
| |
| widened = hfds.Features({ |
| "__key__": hfds.Value("string"), |
| "__url__": hfds.Value("string"), |
| "mp4": hfds.Value("binary"), |
| "png": hfds.Value("binary"), |
| }) |
| try: |
| ds = ds.cast(widened) |
| except Exception: |
| pass |
|
|
| |
| if num_workers == -1: |
| num_workers = max(1, min(cpu_count(), 8)) |
| elif num_workers == 0: |
| num_workers = 1 |
|
|
| |
| lang_model = load_sentence_transformer_model() |
|
|
| entries: list[dict] = [] |
| processed = 0 |
| default_batch_size = 64 |
| batch_size = default_batch_size if (max_trajectories is None) else min(default_batch_size, max_trajectories) |
| batch_samples: list[dict[str, Any]] = [] |
| batch_records: list[tuple[str, dict]] = [] |
|
|
| |
| seen_samples = 0 |
| skipped_camera = 0 |
| skipped_no_record = 0 |
| skipped_no_mp4 = 0 |
|
|
| def flush_batch(): |
| nonlocal entries, processed, batch_samples, batch_records |
| if not batch_samples: |
| return |
|
|
| |
| unique_texts = _collect_unique_texts_for_batch(batch_records) |
| emb_map = _encode_texts(unique_texts, lang_model) |
|
|
| if num_workers == 1: |
| for sample in tqdm(batch_samples, desc="Batch (seq)", leave=False): |
| res = _process_single_stream_sample( |
| sample=sample, |
| embeddings=emb_map, |
| output_dir=output_dir, |
| dataset_name=dataset_label, |
| max_frames=max_frames, |
| fps=fps, |
| ) |
| |
| entries.extend(res) |
| else: |
| with Pool(processes=num_workers) as pool: |
| worker = partial( |
| _process_single_stream_sample, |
| embeddings=emb_map, |
| output_dir=output_dir, |
| dataset_name=dataset_label, |
| max_frames=max_frames, |
| fps=fps, |
| ) |
| for res in tqdm( |
| pool.imap_unordered(worker, batch_samples), |
| total=len(batch_samples), |
| desc=f"Batch (workers={num_workers})", |
| leave=False, |
| ): |
| entries.extend(res) |
|
|
| processed += len(batch_samples) |
| batch_samples = [] |
| batch_records = [] |
|
|
| print(f"Streaming {dataset_name}; workers={num_workers}, batch_size={batch_size}") |
| stream_pbar = tqdm(desc="Streaming samples", unit="sample", dynamic_ncols=True) |
|
|
| for sample in ds: |
| if max_trajectories is not None and processed >= max_trajectories: |
| break |
|
|
| key = sample.get("__key__", "") |
| episode_id, camera = _parse_episode_and_camera(key) |
| seen_samples += 1 |
| stream_pbar.update(1) |
| if not camera or camera not in CAMERA_KEYS: |
| skipped_camera += 1 |
| continue |
|
|
| |
| try: |
| _json_path, rec = get_episode_record(episode_id) |
| except Exception: |
| skipped_no_record += 1 |
| continue |
|
|
| |
| if not sample.get("mp4"): |
| skipped_no_mp4 += 1 |
| continue |
|
|
| batch_samples.append(sample) |
| batch_records.append((episode_id, rec)) |
|
|
| if len(batch_samples) >= batch_size: |
| flush_batch() |
|
|
| |
| if max_trajectories is not None and (processed + len(batch_samples)) >= max_trajectories: |
| flush_batch() |
| break |
|
|
| |
| flush_batch() |
| stream_pbar.close() |
|
|
| |
| if not entries: |
| return Dataset.from_dict({ |
| "id": [], |
| "task": [], |
| "lang_vector": [], |
| "data_source": [], |
| "frames": [], |
| "is_robot": [], |
| "quality_label": [], |
| "preference_group_id": [], |
| "preference_rank": [], |
| }) |
|
|
| |
| print( |
| f"Done. seen={seen_samples}, entries={len(entries)}, " |
| f"skipped_camera={skipped_camera}, skipped_no_record={skipped_no_record}, " |
| f"skipped_no_mp4={skipped_no_mp4}" |
| ) |
| return Dataset.from_list(entries) |
|
|
|
|
| def load_agibotworld_dataset(dataset_name_or_path: str, max_trajectories: int = 100) -> dict[str, list[dict]]: |
| """Load AgiBotWorld dataset using HuggingFace streaming and extract head_color.mp4 files. |
| |
| Args: |
| dataset_name_or_path: HuggingFace dataset name (e.g. "agibot-world/AgiBotWorld-Alpha") |
| or local path to the dataset |
| |
| Returns: |
| Dictionary mapping task names to lists of trajectory dictionaries |
| """ |
|
|
| print(f"Loading AgiBotWorld dataset from: {dataset_name_or_path}") |
| print("=" * 100) |
| print("LOADING AGIBOTWORLD DATASET") |
| print("=" * 100) |
|
|
| task_data = {} |
|
|
| |
| if os.path.exists(dataset_name_or_path): |
| |
| task_data = _load_local_agibotworld(dataset_name_or_path, max_trajectories) |
| else: |
| |
| task_data = _load_streaming_agibotworld(dataset_name_or_path, max_trajectories) |
|
|
| print( |
| f"Loaded {sum(len(trajectories) for trajectories in task_data.values())} trajectories from {len(task_data)} tasks" |
| ) |
| return task_data |
|
|
|
|
| |
| def _load_local_agibotworld(base_path: str, max_trajectories: int = 100, max_frames: int = 32) -> dict[str, list[dict]]: |
| """Load AgiBotWorld dataset from local files, starting with task_info JSON files.""" |
| base_path = Path(base_path) |
| task_data = {} |
|
|
| |
| observations_dir = base_path / "observations" |
| task_info_dir = base_path / "task_info" |
| proprio_stats_dir = base_path / "proprio_stats" |
|
|
| if not observations_dir.exists(): |
| raise FileNotFoundError(f"Observations directory not found: {observations_dir}") |
| if not task_info_dir.exists(): |
| raise FileNotFoundError(f"Task info directory not found: {task_info_dir}") |
|
|
| |
| task_info_files = list(task_info_dir.glob("*.json")) |
|
|
| if not task_info_files: |
| raise FileNotFoundError(f"No task info JSON files found in: {task_info_dir}") |
|
|
| print(f"Found {len(task_info_files)} task info files") |
|
|
| total_trajectories = 0 |
|
|
| for task_info_file in tqdm(task_info_files, desc="Processing tasks"): |
| if total_trajectories >= max_trajectories: |
| print(f"Reached max_trajectories limit ({max_trajectories}), stopping...") |
| break |
|
|
| |
| task_id = task_info_file.stem.replace("task_", "") |
|
|
| |
| task_info = _load_task_info(task_info_file) |
|
|
| if not task_info: |
| print(f"Skipping task {task_id} - no valid task info") |
| continue |
|
|
| |
| if task_info and len(task_info) > 0: |
| first_episode = task_info[0] |
| task_name = first_episode.get("task_name", f"Task {task_id}") |
| first_episode.get("task_description", f"AgiBotWorld Task {task_id}") |
| else: |
| task_name = f"Task {task_id}" |
|
|
| print(f"Processing task {task_id}: '{task_name}'") |
|
|
| |
| task_dir = observations_dir / task_id |
| if not task_dir.exists(): |
| print(f"Task directory not found: {task_dir}, skipping...") |
| continue |
|
|
| trajectories = [] |
|
|
| |
| for episode_info in task_info: |
| if total_trajectories >= max_trajectories: |
| break |
|
|
| episode_id = str(episode_info.get("episode_id", "")) |
| if not episode_id: |
| continue |
|
|
| |
| episode_dir = task_dir / episode_id |
| if not episode_dir.exists(): |
| print(f"Episode directory not found: {episode_dir}, skipping episode {episode_id}") |
| continue |
|
|
| |
| videos_dir = episode_dir / "videos" |
| head_color_video = videos_dir / "head_color.mp4" |
|
|
| if head_color_video.exists(): |
| |
| proprio_file = proprio_stats_dir / task_id / episode_id / "proprio_stats.h5" |
| actions = _load_actions_from_h5(proprio_file) |
|
|
| |
| try: |
| processed_frames = load_video_frames(head_color_video) |
|
|
| trajectory = { |
| "frames": processed_frames, |
| "actions": actions, |
| "is_robot": True, |
| "task": task_name, |
| "optimal": "optimal", |
| } |
| except Exception as e: |
| print(f" ❌ Failed to process video {head_color_video}: {e}") |
| continue |
|
|
| trajectories.append(trajectory) |
| total_trajectories += 1 |
|
|
| print(f" ✅ Loaded episode {episode_id} ({total_trajectories}/{max_trajectories})") |
| else: |
| print(f" ❌ head_color.mp4 not found for episode {episode_id}") |
|
|
| if trajectories: |
| |
| task_data[task_name] = trajectories |
| print(f"Added {len(trajectories)} trajectories for task '{task_name}'") |
|
|
| print(f"Loaded {total_trajectories} total trajectories from {len(task_data)} tasks") |
| return task_data |
|
|
|
|
| def _load_streaming_agibotworld(dataset_name: str, max_trajectories: int = 100) -> dict[str, list[dict]]: |
| """Legacy helper no longer used. Kept for compatibility.""" |
| raise NotImplementedError("Use convert_agibotworld_streaming_to_hf() for streaming conversion.") |
|
|
|
|
| def _load_task_info(task_info_file: Path) -> list[dict]: |
| """Load task information from JSON file.""" |
| if not task_info_file.exists(): |
| print(f"Task info file not found: {task_info_file}") |
| return [] |
|
|
| try: |
| with open(task_info_file) as f: |
| task_info = json.load(f) |
| return task_info if isinstance(task_info, list) else [task_info] |
| except Exception as e: |
| print(f"Error loading task info from {task_info_file}: {e}") |
| return [] |
|
|
|
|
| def _load_actions_from_h5(proprio_file: Path) -> np.ndarray: |
| """Load actions from proprioceptive H5 file.""" |
| if not proprio_file.exists(): |
| print(f"Proprioceptive file not found: {proprio_file}") |
| return np.array([]) |
|
|
| try: |
| with h5py.File(proprio_file, "r") as f: |
| |
| if "action" in f: |
| action_group = f["action"] |
|
|
| |
| if "joint" in action_group and "position" in action_group["joint"]: |
| actions = action_group["joint"]["position"][:] |
| elif "end" in action_group and "position" in action_group["end"]: |
| |
| end_positions = action_group["end"]["position"][:] |
| end_orientations = ( |
| action_group["end"]["orientation"][:] if "orientation" in action_group["end"] else None |
| ) |
|
|
| if end_orientations is not None: |
| |
| |
| end_orientations_flat = end_orientations.reshape(end_orientations.shape[0], -1) |
| |
| end_positions_flat = end_positions.reshape(end_positions.shape[0], -1) |
| actions = np.concatenate([end_positions_flat, end_orientations_flat], axis=1) |
| else: |
| actions = end_positions.reshape(end_positions.shape[0], -1) |
| else: |
| print(f"No recognizable action data found in {proprio_file}") |
| return np.array([]) |
|
|
| return actions |
| else: |
| print(f"No action group found in {proprio_file}") |
| return np.array([]) |
|
|
| except Exception as e: |
| print(f"Error loading actions from {proprio_file}: {e}") |
| return np.array([]) |
|
|