| |
| """ |
| H2R dataset loader for the generic dataset converter for Robometer model training. |
| https://huggingface.co/datasets/dannyXSC/HumanAndRobot |
| Human2Robot: Learning Robot Actions from Paired Human-Robot Videos |
| This module contains H2R-specific logic for loading and processing HDF5 files. |
| |
| Updated to support OXE-style streaming conversion: write videos and build |
| HF entries on the fly, and return a ready `datasets.Dataset` to be pushed |
| or saved by the caller. |
| """ |
|
|
| import os |
| from pathlib import Path |
| from typing import Any |
|
|
| import h5py |
| import numpy as np |
| from dataset_upload.helpers import ( |
| create_hf_trajectory, |
| generate_unique_id, |
| load_sentence_transformer_model, |
| ) |
| from tqdm import tqdm |
| from datasets import Dataset |
|
|
|
|
| class H2RFrameLoader: |
| """Pickle-able loader that reads H2R frames from an HDF5 dataset on demand. |
| |
| Stores only simple fields so it can be safely passed across processes. |
| """ |
|
|
| def __init__(self, hdf5_path: str, convert_to_rgb: bool = True): |
| self.hdf5_path = hdf5_path |
| self.convert_to_rgb = convert_to_rgb |
|
|
| def __call__(self) -> tuple[np.ndarray, np.ndarray]: |
| """Load frames from HDF5 when called. |
| |
| Returns: |
| np.ndarray of shape (T, H, W, 3), dtype uint8 |
| """ |
| with h5py.File(self.hdf5_path, "r") as f: |
| human_frames = f["/cam_data/human_camera"][:] |
| robot_frames = f["/cam_data/robot_camera"][:] |
|
|
| if self.convert_to_rgb: |
| human_frames = human_frames[..., [2, 1, 0]] |
| robot_frames = robot_frames[..., [2, 1, 0]] |
|
|
| |
| if not isinstance(human_frames, np.ndarray) or human_frames.ndim != 4 or human_frames.shape[-1] != 3: |
| raise ValueError(f"Unexpected frames shape for {self.hdf5_path}: {getattr(human_frames, 'shape', None)}") |
|
|
| if not isinstance(robot_frames, np.ndarray) or robot_frames.ndim != 4 or robot_frames.shape[-1] != 3: |
| raise ValueError(f"Unexpected frames shape for {self.hdf5_path}: {getattr(robot_frames, 'shape', None)}") |
|
|
| |
| if human_frames.dtype != np.uint8: |
| human_frames = human_frames.astype(np.uint8, copy=False) |
| if robot_frames.dtype != np.uint8: |
| robot_frames = robot_frames.astype(np.uint8, copy=False) |
|
|
| return human_frames, robot_frames |
|
|
|
|
| |
| FOLDER_TO_TASK_NAME = { |
| "grab_both_cubes_v1": "pick up each cube individually and place them onto the plate", |
| "grab_cube2_v1": "pick up the cube and place it onto the plate", |
| "grab_cup_v1": "move the cup from left to right", |
| "grab_pencil1_v1": "pick up the marker and place it on the plate", |
| "grab_pencil2_v1": "pick up the marker and place it on the plate", |
| "grab_pencil_v1": "pick up the marker and place it on the plate", |
| "grab_two_cubes2_v1": "pick up the green cube and place it onto the plate", |
| "grab_to_plate1_and_back_v1": "put the red cube on the darker plate", |
| "grab_to_plate1_v1": "pick up the red cube and place it onto the darker plate", |
| "grab_to_plate2_v1": "pick up the red cube and place it onto the lighter plate", |
| "grab_to_plate2_and_back_v1": "put the red cube on the yellow plate", |
| "grab_to_plate2_and_pull_v1": "put the cube on the plate, then pull the plate from bottom to top", |
| "pull_plate_grab_cube": "pull the plate from bottom to top, then pick up the cube and place it onto the plate", |
| "pull_plate_v1": "pull the plate from bottom to top", |
| "push_box_common_v1": "push the box from left to right", |
| "push_box_random_v1": "push the box from left to right", |
| "push_box_two_v1": "push the tissues from left to right", |
| "push_plate_v1": "push the plate from top to bottom", |
| |
| |
| } |
|
|
|
|
| def _get_task_name_from_folder(folder_name: str) -> str: |
| """Convert folder name to task name using the mapping.""" |
| |
| if folder_name in FOLDER_TO_TASK_NAME: |
| return FOLDER_TO_TASK_NAME[folder_name] |
| else: |
| return None |
|
|
|
|
| def _discover_h2r_files(dataset_path: Path) -> list[tuple[Path, str]]: |
| """Discover all video files in the H2R dataset structure. |
| |
| Expected structure: |
| dataset_path/ |
| folder_name_1/ |
| hdf5_file_1.hdf5 |
| hdf5_file_2.hdf5 |
| hdf5_file_3.hdf5 |
| ... |
| folder_name_2/ |
| hdf5_file_1.hdf5 |
| hdf5_file_2.hdf5 |
| hdf5_file_3.hdf5 |
| ... |
| ... |
| |
| Returns: |
| List of tuples: (hdf5_file_path, task_name) |
| """ |
| trajectory_files: list[tuple[Path, str]] = [] |
| for folder in dataset_path.iterdir(): |
| if folder.is_dir(): |
| for file in folder.glob("*.hdf5"): |
| trajectory_files.append((file, folder.name)) |
|
|
| return trajectory_files |
|
|
|
|
| def _stable_shard_for_index(index: int, shard_modulus: int = 1000) -> str: |
| """Deterministically bucket an index into a shard directory name. |
| |
| Matches the naming style used in the OXE loader for consistent layout. |
| """ |
| try: |
| idx = int(index) |
| except Exception: |
| idx = abs(hash(str(index))) |
| shard_index = idx // shard_modulus |
| return f"shard_{shard_index:04d}" |
|
|
|
|
| def _build_h2r_video_paths( |
| output_dir: str, |
| dataset_label: str, |
| episode_idx: int, |
| role: str, |
| ) -> tuple[str, str]: |
| shard_dir = _stable_shard_for_index(episode_idx) |
| episode_dir = os.path.join(output_dir, dataset_label.lower(), shard_dir, f"episode_{episode_idx:06d}") |
| os.makedirs(episode_dir, exist_ok=True) |
| filename = f"clip@{role}.mp4" |
| full_path = os.path.join(episode_dir, filename) |
| rel_path = os.path.join(dataset_label.lower(), shard_dir, f"episode_{episode_idx:06d}", filename) |
| return full_path, rel_path |
|
|
|
|
| def _process_single_h2r_file(args): |
| """Worker to process a single H2R HDF5 file into up to two entries. |
| |
| Returns a list of entries (human and/or robot), each with relative frame paths. |
| """ |
| ( |
| file_path, |
| folder_name, |
| ep_idx, |
| dataset_name, |
| output_dir, |
| max_frames, |
| fps, |
| task, |
| lang_vec, |
| ) = args |
|
|
| entries: list[dict[str, Any]] = [] |
|
|
| |
| human_frames, robot_frames = H2RFrameLoader(str(file_path))() |
|
|
| |
| full_h_path, rel_h_path = _build_h2r_video_paths( |
| output_dir=output_dir, |
| dataset_label=dataset_name, |
| episode_idx=ep_idx, |
| role="human", |
| ) |
| human_traj = { |
| "id": generate_unique_id(), |
| "frames": human_frames, |
| "task": task, |
| "is_robot": False, |
| "quality_label": "successful", |
| "preference_group_id": None, |
| "preference_rank": None, |
| } |
| human_entry = create_hf_trajectory( |
| traj_dict=human_traj, |
| video_path=full_h_path, |
| lang_vector=lang_vec, |
| max_frames=max_frames, |
| dataset_name=dataset_name, |
| use_video=True, |
| fps=fps, |
| ) |
| if human_entry: |
| human_entry["frames"] = rel_h_path |
| entries.append(human_entry) |
|
|
| |
| full_r_path, rel_r_path = _build_h2r_video_paths( |
| output_dir=output_dir, |
| dataset_label=dataset_name, |
| episode_idx=ep_idx, |
| role="robot", |
| ) |
| robot_traj = { |
| "id": generate_unique_id(), |
| "frames": robot_frames, |
| "task": task, |
| "is_robot": True, |
| "quality_label": "successful", |
| "preference_group_id": None, |
| "preference_rank": None, |
| } |
| robot_entry = create_hf_trajectory( |
| traj_dict=robot_traj, |
| video_path=full_r_path, |
| lang_vector=lang_vec, |
| max_frames=max_frames, |
| dataset_name=dataset_name, |
| use_video=True, |
| fps=fps, |
| ) |
| if robot_entry: |
| robot_entry["frames"] = rel_r_path |
| entries.append(robot_entry) |
|
|
| return entries |
|
|
|
|
| def convert_h2r_dataset_to_hf( |
| dataset_path: str, |
| dataset_name: str, |
| output_dir: str, |
| max_trajectories: int | None = None, |
| max_frames: int = 64, |
| fps: int = 10, |
| num_workers: int = -1, |
| ) -> Dataset: |
| """Convert the H2R dataset to HF format by writing videos directly. |
| |
| This mirrors the OXE loader's streaming approach: iterate files, write videos, |
| assemble entries, and return a Dataset at the end. |
| """ |
|
|
| if dataset_name is None: |
| raise ValueError("dataset_name is required") |
|
|
| base_path = Path(dataset_path) |
| if not base_path.exists(): |
| raise FileNotFoundError(f"H2R dataset path not found: {base_path}") |
|
|
| discovered = _discover_h2r_files(base_path) |
| if len(discovered) == 0: |
| |
| return Dataset.from_dict({ |
| "id": [], |
| "task": [], |
| "lang_vector": [], |
| "data_source": [], |
| "frames": [], |
| "is_robot": [], |
| "quality_label": [], |
| |
| "preference_group_id": [], |
| "preference_rank": [], |
| }) |
|
|
| |
| lang_model = load_sentence_transformer_model() |
| lang_cache: dict[str, Any] = {} |
|
|
| |
| if num_workers == -1: |
| try: |
| from multiprocessing import cpu_count as _cpu_count |
|
|
| num_workers = min(_cpu_count(), 8) |
| except Exception: |
| num_workers = 1 |
| elif num_workers == 0: |
| num_workers = 1 |
|
|
| batch_size = 64 |
|
|
| entries: list[dict[str, Any]] = [] |
| produced_pairs = 0 |
| max_limit = float("inf") if (max_trajectories is None or max_trajectories == -1) else int(max_trajectories) |
|
|
| print(f"Found {len(discovered)} HDF5 files; processing in batches of {batch_size} with {num_workers} workers...") |
|
|
| |
| file_batch: list[tuple[Path, str]] = [] |
| info_batch: list[tuple[int, str, Any]] = [] |
|
|
| for ep_idx, (file_path, folder_name) in enumerate(tqdm(discovered, desc="Queuing H2R files")): |
| if produced_pairs >= max_limit: |
| break |
|
|
| task = _get_task_name_from_folder(folder_name) |
| if task is None: |
| print("Skipping file: ", file_path) |
| continue |
| if task not in lang_cache: |
| lang_cache[task] = lang_model.encode(task) |
| lang_vec = lang_cache[task] |
|
|
| file_batch.append((file_path, folder_name)) |
| info_batch.append((ep_idx, task, lang_vec)) |
|
|
| if len(file_batch) >= batch_size or ep_idx + 1 == len(discovered): |
| |
| worker_args = list( |
| zip( |
| [f for (f, _) in file_batch], |
| [fn for (_, fn) in file_batch], |
| [i for (i, _, _) in info_batch], |
| [dataset_name] * len(file_batch), |
| [output_dir] * len(file_batch), |
| [max_frames] * len(file_batch), |
| [fps] * len(file_batch), |
| [t for (_, t, _) in info_batch], |
| [lv for (_, _, lv) in info_batch], |
| strict=False, |
| ) |
| ) |
|
|
| if num_workers == 1: |
| |
| for args in worker_args: |
| entries.extend(_process_single_h2r_file(args)) |
| produced_pairs += 1 |
| if produced_pairs >= max_limit: |
| break |
| else: |
| from multiprocessing import Pool |
|
|
| with Pool(processes=num_workers) as pool: |
| results = list( |
| tqdm( |
| pool.imap_unordered(_process_single_h2r_file, worker_args), |
| total=len(worker_args), |
| desc=f"Processing batch (workers={num_workers})", |
| ) |
| ) |
| for res in results: |
| entries.extend(res) |
| produced_pairs += 1 |
| if produced_pairs >= max_limit: |
| break |
|
|
| |
| file_batch = [] |
| info_batch = [] |
|
|
| if not entries: |
| return Dataset.from_dict({ |
| "id": [], |
| "task": [], |
| "lang_vector": [], |
| "data_source": [], |
| "frames": [], |
| "is_robot": [], |
| "quality_label": [], |
| "preference_group_id": [], |
| "preference_rank": [], |
| }) |
|
|
| return Dataset.from_list(entries) |
|
|