| |
|
|
| 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", |
| 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, |
| 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) |
| |
| |
| |
| |
| |
| 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"] |
| 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) |
| 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, |
| ) |
| cam_left = transforms_F.resize( |
| video[1], |
| size=[128, 160], |
| interpolation=transforms_F.InterpolationMode.BILINEAR, |
| antialias=True, |
| ) |
| cam_right = transforms_F.resize( |
| video[2], |
| size=[128, 160], |
| interpolation=transforms_F.InterpolationMode.BILINEAR, |
| antialias=True, |
| ) |
| bottom = torch.cat([cam_left, cam_right], dim=-1) |
| video = torch.cat([cam_top, bottom], dim=-2) |
| elif num_cameras > 1: |
| if self.concat_multi_camera == "horizontal": |
| video = torch.cat([video[i] for i in range(num_cameras)], dim=-1) |
| elif self.concat_multi_camera == "vertical": |
| video = torch.cat([video[i] for i in range(num_cameras)], dim=-2) |
| else: |
| raise ValueError( |
| f"Invalid concat_multi_camera: {self.concat_multi_camera}. " |
| "Expected one of: horizontal, vertical, robotwin." |
| ) |
| else: |
| video = video.squeeze(0) |
|
|
| |
| video = self.resize_transform(video) |
| video = self.crop_transform(video) |
| video = self.normalize_transform(video) |
|
|
| video = video.permute(1, 0, 2, 3) |
| 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} |
|
|
| |
| |
| |
| action = sample["action"] |
| proprio = sample["proprio"][:-1, :] |
| 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"] |
| |
| |
| 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) |
| |
| 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: |
| |
| |
| |
| |
| |
| |
| |
| 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.") |
| |
| print(traceback.format_exc()) |
| random_idx = np.random.randint(len(self)) |
| data = self._get(random_idx) |
| return data |
|
|