fruit-picking-fastwam / training_code /robot_video_dataset.py
SleepMastger's picture
add model card, conditioning, and training-time processing
7896def verified
Raw
History Blame Contribute Delete
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