Download training_code/robot_video_dataset.py from SleepMastger/pusht-flashwam: direct link, hf CLI and curl.
- Browser
- Download file 20.5 kB
-
https://huggingface.co/SleepMastger/pusht-flashwam/resolve/main/training_code/robot_video_dataset.py
- Command line
-
hf download hf://SleepMastger/pusht-flashwam/training_code/robot_video_dataset.py
-
curl -L -o robot_video_dataset.py https://huggingface.co/SleepMastger/pusht-flashwam/resolve/main/training_code/robot_video_dataset.py
20.5 kB
| # Author: Rui Heng Yang | |
| import hashlib | |
| import os | |
| from pathlib import Path | |
| from typing import Any, Optional | |
| import numpy as np | |
| import traceback | |
| import torch | |
| import torchvision.transforms.functional as transforms_F | |
| from omegaconf import DictConfig, OmegaConf | |
| from hydra.utils import instantiate | |
| from .base_lerobot_dataset import BaseLerobotDataset | |
| from ..video_latent_cache import VideoLatentCache, build_video_preprocess_spec | |
| from .utils.normalizer import save_dataset_stats_to_json, load_dataset_stats_from_json | |
| from ..dataset_utils import ResizeSmallestSideAspectPreserving, CenterCrop, Normalize | |
| from fastwam.utils.logging_config import get_logger | |
| from fastwam.utils import misc, pytorch_utils | |
| logger = get_logger(__name__) | |
| DEFAULT_PROMPT = "A video recorded from a robot's point of view executing the following instruction: {task}" | |
| def _is_main_process_without_init() -> bool: | |
| return pytorch_utils._resolve_global_rank() == 0 | |
| class RobotVideoDataset(torch.utils.data.Dataset): | |
| def __init__( | |
| self, | |
| dataset_dirs, | |
| shape_meta, | |
| num_frames=33, | |
| video_size=[384, 640], | |
| camera_key=None, | |
| processor=None, | |
| text_embedding_cache_dir=None, | |
| context_len=128, | |
| pretrained_norm_stats=None, | |
| val_set_proportion=0.05, | |
| is_training_set=False, | |
| episode_indices=None, | |
| episode_indices_path=None, | |
| episode_indices_key=None, | |
| global_sample_stride=1, | |
| action_video_freq_ratio: int = 1, | |
| skip_padding_as_possible: bool = False, | |
| max_padding_retry: int = 3, | |
| concat_multi_camera: str = "horizontal", # "horizontal", "vertical", "robotwin", or None | |
| video_backend: Optional[str] = None, | |
| video_latent_cache_dir: Optional[str] = None, | |
| video_latent_cache_vae_identity: Optional[dict[str, str]] = None, | |
| strict_getitem: bool = False, | |
| precompute_video_only: bool = False, | |
| override_instruction: Optional[str] = None, # whether to hardcode a specific instruction for all samples, for debugging | |
| eef_anchor_calibration_path: Optional[str] = None, | |
| eef_anchor_raw_resolution: int = 512, | |
| ): | |
| if video_latent_cache_dir is not None and not strict_getitem: | |
| raise ValueError( | |
| "`strict_getitem=true` is required with `video_latent_cache_dir` " | |
| "to prevent latent/action sample misalignment." | |
| ) | |
| if video_latent_cache_dir is not None and video_backend is None: | |
| raise ValueError( | |
| "`video_backend` must be explicitly pinned when using a video latent cache." | |
| ) | |
| if video_latent_cache_dir is not None and video_latent_cache_vae_identity is None: | |
| raise ValueError( | |
| "`video_latent_cache_vae_identity` is required with a video latent cache." | |
| ) | |
| if strict_getitem and skip_padding_as_possible: | |
| raise ValueError( | |
| "`skip_padding_as_possible` must be false in strict/cache mode because " | |
| "padding retries substitute a random global sample index." | |
| ) | |
| if precompute_video_only and (not strict_getitem or video_latent_cache_dir is not None): | |
| raise ValueError( | |
| "`precompute_video_only` requires strict_getitem=true and no latent cache." | |
| ) | |
| if (num_frames - 1) % action_video_freq_ratio != 0: | |
| raise ValueError( | |
| "num_frames-1 must be divisible by action_video_freq_ratio, got " | |
| f"{num_frames - 1} and {action_video_freq_ratio}" | |
| ) | |
| if ((num_frames - 1) // action_video_freq_ratio) % 4 != 0: | |
| raise ValueError( | |
| "video transitions must be divisible by 4 for tokenization, got " | |
| f"{(num_frames - 1) // action_video_freq_ratio}" | |
| ) | |
| video_sample_indices = list(range(0, num_frames, action_video_freq_ratio)) | |
| shape_meta_container = OmegaConf.to_container(shape_meta, resolve=True) | |
| processor_config: dict[str, Any] | None = None | |
| if isinstance(processor, DictConfig): | |
| processor_config = OmegaConf.to_container(processor, resolve=True) | |
| self.lerobot_dataset = BaseLerobotDataset( | |
| dataset_dirs=dataset_dirs, | |
| shape_meta=shape_meta_container, | |
| obs_size=num_frames, | |
| action_size=num_frames - 1, | |
| val_set_proportion=val_set_proportion, | |
| is_training_set=is_training_set, | |
| episode_indices=episode_indices, | |
| episode_indices_path=episode_indices_path, | |
| episode_indices_key=episode_indices_key, | |
| global_sample_stride=global_sample_stride, | |
| video_backend=video_backend, | |
| strict_getitem=strict_getitem, | |
| image_only=precompute_video_only, | |
| image_sample_indices=(video_sample_indices if precompute_video_only else None), | |
| ) | |
| self.num_frames = num_frames | |
| self.action_video_freq_ratio = action_video_freq_ratio | |
| self.video_sample_indices = video_sample_indices | |
| self.camera_key = camera_key | |
| self.video_latent_cache_dir = video_latent_cache_dir | |
| self.strict_getitem = bool(strict_getitem) | |
| self.precompute_video_only = bool(precompute_video_only) | |
| self.lerobot_dataset._set_return_images(video_latent_cache_dir is None) | |
| self.video_size = video_size | |
| self.text_embedding_cache_dir = text_embedding_cache_dir | |
| self.context_len = context_len | |
| self.skip_padding_as_possible = skip_padding_as_possible | |
| self.max_padding_retry = max_padding_retry | |
| self.concat_multi_camera = concat_multi_camera | |
| self.override_instruction = override_instruction | |
| resolved_episode_indices_path = None | |
| episode_indices_path_sha256 = None | |
| if episode_indices_path is not None: | |
| resolved_path = Path(episode_indices_path).expanduser().resolve() | |
| if not resolved_path.is_file(): | |
| raise FileNotFoundError(f"Episode split file not found: {resolved_path}") | |
| resolved_episode_indices_path = str(resolved_path) | |
| episode_indices_path_sha256 = hashlib.sha256(resolved_path.read_bytes()).hexdigest() | |
| preprocess_spec = build_video_preprocess_spec( | |
| dataset_dirs=dataset_dirs, | |
| shape_meta=shape_meta_container, | |
| processor_config=processor_config, | |
| num_frames=num_frames, | |
| action_video_freq_ratio=action_video_freq_ratio, | |
| video_size=video_size, | |
| concat_multi_camera=concat_multi_camera, | |
| video_backend=str(video_backend), | |
| global_sample_stride=global_sample_stride, | |
| sample_selection={ | |
| "val_set_proportion": float(val_set_proportion), | |
| "is_training_set": bool(is_training_set), | |
| "episode_indices": ( | |
| None if episode_indices is None else [int(value) for value in episode_indices] | |
| ), | |
| "episode_indices_path": ( | |
| resolved_episode_indices_path | |
| ), | |
| "episode_indices_path_sha256": episode_indices_path_sha256, | |
| "episode_indices_key": episode_indices_key, | |
| }, | |
| ) | |
| self.video_preprocess_spec = preprocess_spec | |
| self.video_latent_cache = None | |
| if video_latent_cache_dir is not None: | |
| expected_vae_identity = OmegaConf.to_container( | |
| video_latent_cache_vae_identity, resolve=True | |
| ) if isinstance(video_latent_cache_vae_identity, DictConfig) else dict( | |
| video_latent_cache_vae_identity | |
| ) | |
| self.video_latent_cache = VideoLatentCache( | |
| video_latent_cache_dir, | |
| expected_sample_count=len(self.lerobot_dataset), | |
| expected_preprocess_spec=preprocess_spec, | |
| expected_vae_identity=expected_vae_identity, | |
| ) | |
| self.resize_transform = ResizeSmallestSideAspectPreserving( | |
| args={"img_w": self.video_size[1], "img_h": self.video_size[0]}, | |
| ) | |
| self.crop_transform = CenterCrop( | |
| args={"img_w": self.video_size[1], "img_h": self.video_size[0]}, | |
| ) | |
| self.normalize_transform = Normalize( | |
| args={"mean": 0.5, "std": 0.5}, | |
| ) | |
| if processor is not None: | |
| if isinstance(processor, DictConfig): | |
| processor = instantiate(processor) | |
| if not pretrained_norm_stats: | |
| if not is_training_set: | |
| raise ValueError("pretrained_norm_stats must be provided for validation/test sets since we don't want to calculate stats on them.") | |
| dist_initialized = ( | |
| torch.distributed.is_available() and torch.distributed.is_initialized() | |
| ) | |
| if _is_main_process_without_init() or not dist_initialized: | |
| logger.info("Calculating dataset stats for normalization...") | |
| dataset_stats = self.lerobot_dataset.get_dataset_stats(processor) | |
| work_dir = misc.get_work_dir() | |
| if _is_main_process_without_init(): | |
| save_dataset_stats_to_json(dataset_stats, os.path.join(work_dir, "dataset_stats.json")) | |
| else: | |
| dataset_stats = None | |
| if dist_initialized: | |
| obj_list = [dataset_stats] | |
| torch.distributed.broadcast_object_list(obj_list, src=0) | |
| dataset_stats = obj_list[0] | |
| else: | |
| dataset_stats = load_dataset_stats_from_json(pretrained_norm_stats) | |
| logger.info(f"Using dataset stats: {pretrained_norm_stats}") | |
| if _is_main_process_without_init(): | |
| work_dir = misc.get_work_dir() | |
| save_dataset_stats_to_json(dataset_stats, os.path.join(work_dir, "dataset_stats.json")) | |
| processor.set_normalizer_from_stats(dataset_stats) | |
| self.lerobot_dataset.set_processor(processor) | |
| # EEF-relative camera RoPE: resolve every episode's anchors once, here, | |
| # and hold them in RAM. No sidecar exists (plan Section 15). Disabled | |
| # unless a calibration path is configured, so every other mode keeps its | |
| # existing sample keys and behavior byte for byte. | |
| self.eef_anchor_index = None | |
| if eef_anchor_calibration_path: | |
| from fastwam.datasets.eef_anchors import EpisodeAnchorIndex | |
| from fastwam.geometry import EEFProjector, load_calibration | |
| calibration = load_calibration(eef_anchor_calibration_path) | |
| self.eef_anchor_index = EpisodeAnchorIndex.build( | |
| [str(d) for d in dataset_dirs], | |
| calibration_path=eef_anchor_calibration_path, | |
| projector=EEFProjector(calibration, int(eef_anchor_raw_resolution)), | |
| ) | |
| def __len__(self): | |
| return len(self.lerobot_dataset) | |
| def _get(self, idx): | |
| sample_idx = idx | |
| sample = None | |
| for attempt in range(self.max_padding_retry + 1): | |
| sample = self.lerobot_dataset[sample_idx] | |
| if not self.skip_padding_as_possible: | |
| break | |
| action_is_pad = sample["action_is_pad"] | |
| image_is_pad = sample["image_is_pad"] | |
| proprio_is_pad = sample["proprio_is_pad"] | |
| has_pad = False | |
| if bool(action_is_pad.any().item()): | |
| has_pad = True | |
| if bool(image_is_pad.any().item()): | |
| has_pad = True | |
| if bool(proprio_is_pad.any().item()): | |
| has_pad = True | |
| if not has_pad or attempt >= self.max_padding_retry: | |
| break | |
| sample_idx = np.random.randint(len(self.lerobot_dataset)) | |
| image_is_pad = sample["image_is_pad"] | |
| if not self.precompute_video_only: | |
| image_is_pad = image_is_pad[self.video_sample_indices] | |
| video = None | |
| video_latents = None | |
| if self.video_latent_cache is None: | |
| video = sample["pixel_values"] # [T, C, H, W] or [num_cameras, T, C, H, W] | |
| num_cameras = 1 | |
| if video.ndim == 5: | |
| if not self.precompute_video_only: | |
| video = video[:, self.video_sample_indices, :, :, :] | |
| num_cameras, T_video, C, H, W = video.shape | |
| else: | |
| assert video.ndim == 4, f"Expected video to have shape [T, C, H, W], but got {video.shape}" | |
| if not self.precompute_video_only: | |
| video = video[self.video_sample_indices, :, :, :] | |
| T_video, C, H, W = video.shape | |
| video = video.view(num_cameras, T_video, C, H, W) # [num_cameras, T_video, C, H, W] | |
| if self.concat_multi_camera == "robotwin": | |
| if num_cameras != 3: | |
| raise ValueError( | |
| f"`concat_multi_camera='robotwin'` requires exactly 3 cameras, got {num_cameras}" | |
| ) | |
| cam_top = transforms_F.resize( | |
| video[0], | |
| size=[256, 320], | |
| interpolation=transforms_F.InterpolationMode.BILINEAR, | |
| antialias=True, | |
| ) # [T_video, C, 256, 320] | |
| cam_left = transforms_F.resize( | |
| video[1], | |
| size=[128, 160], | |
| interpolation=transforms_F.InterpolationMode.BILINEAR, | |
| antialias=True, | |
| ) # [T_video, C, 128, 160] | |
| cam_right = transforms_F.resize( | |
| video[2], | |
| size=[128, 160], | |
| interpolation=transforms_F.InterpolationMode.BILINEAR, | |
| antialias=True, | |
| ) # [T_video, C, 128, 160] | |
| bottom = torch.cat([cam_left, cam_right], dim=-1) # [T_video, C, 128, 320] | |
| video = torch.cat([cam_top, bottom], dim=-2) # [T_video, C, 384, 320] | |
| elif num_cameras > 1: | |
| if self.concat_multi_camera == "horizontal": | |
| video = torch.cat([video[i] for i in range(num_cameras)], dim=-1) # [T_video, C, H, num_cameras*W] | |
| elif self.concat_multi_camera == "vertical": | |
| video = torch.cat([video[i] for i in range(num_cameras)], dim=-2) # [T_video, C, num_cameras*H, W] | |
| else: | |
| raise ValueError( | |
| f"Invalid concat_multi_camera: {self.concat_multi_camera}. " | |
| "Expected one of: horizontal, vertical, robotwin." | |
| ) | |
| else: | |
| video = video.squeeze(0) # [T_video, C, H, W] | |
| # final resize and normalization | |
| video = self.resize_transform(video) | |
| video = self.crop_transform(video) | |
| video = self.normalize_transform(video) # [T_video, C, H, W] | |
| video = video.permute(1, 0, 2, 3) # [C, T_video, H, W], range [-1, 1] | |
| else: | |
| returned_index = int(sample["idx"]) | |
| video_latents = self.video_latent_cache[returned_index] | |
| if self.precompute_video_only: | |
| if video is None: | |
| raise RuntimeError("precompute_video_only unexpectedly resolved cached latents.") | |
| return {"video": video} | |
| # Proxy (from lerobot): | |
| # action: [num_frames-1, action_dim] # start from t0, except the last frame | |
| # proprio: [num_frames, proprio_dim] # start from t0 to the last frame, aligned with video frames | |
| action = sample["action"] # [T-1, action_dim] | |
| proprio = sample["proprio"][:-1, :] # [T-1, state_dim], to align with action | |
| sampled_video_frames = len(self.video_sample_indices) | |
| if sampled_video_frames <= 1: | |
| raise ValueError(f"At least two sampled video frames are required, got {sampled_video_frames}") | |
| if action.shape[0] % (sampled_video_frames - 1) != 0: | |
| raise ValueError( | |
| f"`action` horizon must be divisible by `video` transitions, got {action.shape[0]} and {sampled_video_frames - 1}" | |
| ) | |
| task = sample["instruction"] | |
| # FIXME | |
| if self.override_instruction is not None: | |
| task = self.override_instruction | |
| instruction = DEFAULT_PROMPT.format(task=task) | |
| context, context_mask = self._get_cached_text_context(instruction) | |
| # NOTE: to keep consistent with wan2.2's behavior | |
| context[~context_mask] = 0.0 | |
| context_mask = torch.ones_like(context_mask) | |
| data = { | |
| "action": action, | |
| "proprio": proprio, | |
| "prompt": instruction, | |
| "context": context, | |
| "context_mask": context_mask, | |
| "image_is_pad": image_is_pad, | |
| "action_is_pad": sample["action_is_pad"], | |
| "proprio_is_pad": sample["proprio_is_pad"], | |
| } | |
| if video is not None: | |
| data["video"] = video | |
| else: | |
| data["video_latents"] = video_latents | |
| data["source_video_shape"] = torch.tensor( | |
| [3, sampled_video_frames, int(self.video_size[0]), int(self.video_size[1])], | |
| dtype=torch.int64, | |
| ) | |
| if self.eef_anchor_index is not None: | |
| # Key off the RETURNED sample, never the requested idx. Both the | |
| # padding retry above and __getitem__'s exception handler resample | |
| # to a random global index, so an idx-keyed lookup would pair these | |
| # frames with another episode's gripper position -- silently. | |
| # Indexed, not `.get(..., 0)`: a missing `dataset_index` would | |
| # silently key every directory of a concatenated MultiLeRobotDataset | |
| # to directory 0, serving one suite's geometry for another's frames. | |
| data["eef_anchor_token"] = torch.from_numpy( | |
| self.eef_anchor_index.lookup( | |
| dataset_index=int(sample["dataset_index"]), | |
| episode_index=int(sample["episode_index"]), | |
| frame_index=int(sample["frame_index"]), | |
| ).copy() | |
| ) | |
| return data | |
| def _get_cached_text_context(self, prompt: str): | |
| if self.text_embedding_cache_dir is None: | |
| raise ValueError("text_embedding_cache_dir is not set.") | |
| cache_dir = self.text_embedding_cache_dir | |
| os.makedirs(cache_dir, exist_ok=True) | |
| hashed = hashlib.sha256(prompt.encode("utf-8")).hexdigest() | |
| cache_path = os.path.join(cache_dir, f"{hashed}.t5_len{self.context_len}.wan22ti2v5b.pt") | |
| if not os.path.exists(cache_path): | |
| raise FileNotFoundError( | |
| f"Missing text embedding cache: {cache_path}. " | |
| "Run scripts/precompute_text_embeds.py first." | |
| ) | |
| payload = torch.load(cache_path, map_location="cpu") | |
| context = payload["context"] | |
| context_mask = payload["mask"].bool() | |
| if context.ndim != 2: | |
| raise ValueError( | |
| f"Cached `context` must be 2D [L, D], got shape {tuple(context.shape)} in {cache_path}" | |
| ) | |
| if context_mask.ndim != 1: | |
| raise ValueError( | |
| f"Cached `mask` must be 1D [L], got shape {tuple(context_mask.shape)} in {cache_path}" | |
| ) | |
| if context.shape[0] != self.context_len: | |
| raise ValueError( | |
| f"Cached context_len mismatch: expected {self.context_len}, got {context.shape[0]} in {cache_path}" | |
| ) | |
| if context_mask.shape[0] != self.context_len: | |
| raise ValueError( | |
| f"Cached mask_len mismatch: expected {self.context_len}, got {context_mask.shape[0]} in {cache_path}" | |
| ) | |
| return context, context_mask | |
| def __getitem__(self, idx): | |
| if self.strict_getitem: | |
| return self._get(idx) | |
| try: | |
| data = self._get(idx) | |
| except Exception as e: | |
| print(f"Error processing sample idx {idx}: {e}. Returning a random sample instead.") | |
| # trace back | |
| print(traceback.format_exc()) | |
| random_idx = np.random.randint(len(self)) | |
| data = self._get(random_idx) | |
| return data | |