"""Dataset stubs for ACT training. Two datasets are provided: * `RandomDemoDataset`: on-the-fly random tensors with the right shapes, useful for smoke-testing the pipeline without having any real data on disk. * `EpisodicHDF5Dataset`: loader for the per-episode HDF5 layout produced by `tools/zips_to_hdf5.py`. Each `episode_*.hdf5` contains: /qpos (T, state_dim) float32 /action (T, action_dim) float32 /images/ (T, H, W) or (T,H,W,3) uint8 for each k in image_keys /wrench/ (T, 6) float32 for f in 0..4 /timestamps (T,) float64 Grayscale images are auto-replicated to 3 channels for the ResNet backbone. """ from __future__ import annotations import glob import os from dataclasses import dataclass, field import numpy as np import torch from torch.utils.data import Dataset try: import h5py except ImportError: # h5py is only required when actually loading from disk h5py = None @dataclass class DatasetSpec: """Mirrors the obs schema the policy expects, so dataset and ACTConfig stay in sync.""" state_dim: int # per-frame qpos dim (e.g. 22) action_dim: int image_keys: list[str] image_shape: tuple[int, int, int] # (C, H, W) — what the policy receives (after replication) tactile_dim: int = 0 # per-frame tactile dim (e.g. 30 = 5 wrenches × 6) # Wrench keys to concatenate into the tactile vector (e.g. ["wrench_1"] or # ["wrench_0","wrench_1","wrench_2","wrench_3","wrench_4"]). When tactile_dim > 0 # the concatenation must produce exactly tactile_dim values. tactile_keys: list[str] = field(default_factory=list) chunk_size: int = 100 # Observation history: when > 1, the dataset returns the concatenation of the # last N frames (oldest first), front-padded with the first frame for t < N-1. # The policy then sees state of dim (state_dim * state_history), and similar # for tactile. state_history: int = 1 tactile_history: int = 1 # If True, the dataset returns ``action - qpos`` (per-step delta) as the # supervised target instead of the absolute action. Stats are then computed # on the deltas (much smaller std). Requires state_dim == action_dim. delta_actions: bool = False # Optional dim-subset masks. When set, the dataset slices qpos[indices] / # action[indices] per frame; state_dim/action_dim above refer to the # post-slice per-frame dim (i.e. len(indices)). Indices are applied AFTER # history concat is built (each frame is sliced, then frames concatenated). state_indices: list[int] | None = None action_indices: list[int] | None = None # Per-finger image keys for the in-graph tactile-fusion branch (e.g. # ["raw_0", ..., "raw_4"]). When set, the dataset returns: # "tactile_frames": (n_fingers, T_pair, H, W) uint8 grayscale # "tactile_bg": (n_fingers, H, W) uint8 grayscale # and does NOT return a "tactile" tensor — the in-graph encoder produces it. # ``tactile_image_pair`` controls the number of consecutive frames stacked # per sample (the encoder expects T=2 for vision modalities). tactile_image_keys: list[str] = field(default_factory=list) tactile_image_pair: int = 2 # HDF5 group holding per-finger image frames. Default ``"images"`` reads # ``/images/`` at native resolution and compression — slow for the # in-graph branch. After running ``tools/precompute_tactile_resized.py`` # set this to ``"tactile_imgs"`` to read the pre-resized uncompressed # version (~10x faster I/O). tactile_image_group: str = "images" # HDF5 group holding per-episode per-finger bg images (uint8 grayscale). # ``"tf_bg"`` is the native-resolution variant from precompute_tactile_bg.py; # ``"tactile_bg_224"`` is the pre-resized variant from precompute_tactile_resized.py. tactile_bg_group: str = "tf_bg" # --------------------------- Synthetic dataset (smoke test) ------------------------------- class RandomDemoDataset(Dataset): """Returns random tensors with the right shapes — for shape/sanity testing only.""" def __init__(self, spec: DatasetSpec, length: int = 64): self.spec = spec self.length = length def __len__(self) -> int: return self.length def __getitem__(self, idx: int) -> dict[str, torch.Tensor]: s = self.spec sample: dict[str, torch.Tensor] = {} for key in s.image_keys: sample[key] = torch.rand(*s.image_shape) if s.state_dim > 0: sample["state"] = torch.randn(s.state_dim) if s.tactile_dim > 0: sample["tactile"] = torch.randn(s.tactile_dim) sample["action"] = torch.randn(s.chunk_size, s.action_dim) sample["action_is_pad"] = torch.zeros(s.chunk_size, dtype=torch.bool) return sample # --------------------------- HDF5 episode dataset ----------------------------------------- def _to_3chw(img: np.ndarray, target_hw: tuple[int, int]) -> torch.Tensor: """Convert an HDF5 image frame to a uint8 (3, H, W) torch tensor. Accepts (H, W) grayscale or (H, W, 3) RGB. Resizes via a simple slice/pad if `target_hw` differs from the source — kept minimal here; preprocess with the desired final resolution at conversion time for best results. """ if img.ndim == 2: img = np.stack([img, img, img], axis=0) # (3, H, W) elif img.ndim == 3 and img.shape[-1] == 3: img = np.transpose(img, (2, 0, 1)) # (3, H, W) elif img.ndim == 3 and img.shape[0] == 3: pass # already (3, H, W) else: raise ValueError(f"unexpected image shape {img.shape}") H_t, W_t = target_hw H_s, W_s = img.shape[1], img.shape[2] if (H_s, W_s) != (H_t, W_t): # Center-crop or zero-pad to target. For a real run, resize at conversion time. out = np.zeros((3, H_t, W_t), dtype=img.dtype) h = min(H_s, H_t) w = min(W_s, W_t) ys, ys_t = (H_s - h) // 2, (H_t - h) // 2 xs, xs_t = (W_s - w) // 2, (W_t - w) // 2 out[:, ys_t:ys_t + h, xs_t:xs_t + w] = img[:, ys:ys + h, xs:xs + w] img = out return torch.from_numpy(np.ascontiguousarray(img)) class EpisodicHDF5Dataset(Dataset): """Random-access dataset over a directory of per-episode HDF5 files. Each __getitem__ samples one (episode, t) pair: returns the obs at time t plus the ground-truth action chunk a[t : t + chunk_size], padded with zeros and a boolean `action_is_pad` mask if the episode ends inside the chunk. """ IMAGE_PATH = "/images/{key}" # Tactile features live under "//". Default group is "wrench" so # plain leaf names ("1") and "wrench_1" both resolve to "/wrench/1". Any # "_" key (e.g. "sitr_0") routes to "//". DEFAULT_TACTILE_GROUP = "wrench" QPOS_PATH = "/qpos" ACTION_PATH = "/action" def __init__( self, root: str | None, spec: DatasetSpec, episode_glob: str = "episode_*.hdf5", files: list[str] | None = None, ): """Either pass ``root`` (+ optional ``episode_glob``) to discover files, or pass an explicit ``files`` list (useful for train/val splits). """ if h5py is None: raise ImportError("h5py is not installed. `pip install h5py` to use EpisodicHDF5Dataset.") self.spec = spec if files is not None: if not files: raise FileNotFoundError("EpisodicHDF5Dataset: explicit file list is empty.") self.files = list(files) else: if root is None: raise ValueError("EpisodicHDF5Dataset: must pass either root or files.") discovered = sorted(glob.glob(os.path.join(root, episode_glob))) if not discovered: raise FileNotFoundError(f"No HDF5 episodes found under {root!r} matching {episode_glob!r}.") self.files = discovered # Cache episode lengths. self.episode_lengths: list[int] = [] for path in self.files: with h5py.File(path, "r") as f: self.episode_lengths.append(f[self.ACTION_PATH].shape[0]) self.cum_lengths = np.cumsum(self.episode_lengths) if spec.tactile_dim > 0 and not spec.tactile_keys and not spec.tactile_image_keys: raise ValueError( "DatasetSpec.tactile_dim > 0 but both tactile_keys and tactile_image_keys are empty." ) def __len__(self) -> int: return int(self.cum_lengths[-1]) def _locate(self, idx: int) -> tuple[int, int]: ep = int(np.searchsorted(self.cum_lengths, idx, side="right")) prev = 0 if ep == 0 else int(self.cum_lengths[ep - 1]) return ep, idx - prev @classmethod def _tactile_path(cls, name: str) -> str: """Map a user-facing tactile key to its HDF5 path. ``"wrench_1"`` → ``"/wrench/1"`` (legacy) ``"sitr_0"`` → ``"/sitr/0"`` ``"1"`` → ``"/wrench/1"`` (default group) """ if "_" in name: group, leaf = name.split("_", 1) return f"/{group}/{leaf}" return f"/{cls.DEFAULT_TACTILE_GROUP}/{name}" def __getitem__(self, idx: int) -> dict[str, torch.Tensor]: s = self.spec ep_idx, t = self._locate(idx) path = self.files[ep_idx] ep_len = self.episode_lengths[ep_idx] sample: dict[str, torch.Tensor] = {} target_hw = (s.image_shape[1], s.image_shape[2]) with h5py.File(path, "r") as f: for key in s.image_keys: img = f[self.IMAGE_PATH.format(key=key)][t] # (H, W) or (H, W, 3) uint8 sample[key] = _to_3chw(img, target_hw) if s.state_dim > 0: hist = max(1, s.state_history) # Indices [t-H+1, ..., t], clamped to >= 0 (front-pad with frame 0). ts = [max(0, t - hist + 1 + k) for k in range(hist)] # h5py wants sorted unique indices for fancy indexing; build the # window by manual reads (fast for small H). seq = np.stack([f[self.QPOS_PATH][ti].astype(np.float32) for ti in ts]) if s.state_indices is not None: seq = seq[:, s.state_indices] sample["state"] = torch.from_numpy(seq.reshape(-1)) # (H*state_dim,) if s.tactile_image_keys: # In-graph branch consumes raw image pairs + per-episode bg per # finger. Stack a (n_fingers, T_pair, H, W) uint8 tensor and a # (n_fingers, H, W) uint8 bg tensor; the model does the resize + # normalization on the GPU (or skips resize if frames are # already at the encoder input resolution). n_pair = max(1, s.tactile_image_pair) ts = [max(0, t - n_pair + 1 + k) for k in range(n_pair)] frames_per_finger = [] bg_per_finger = [] for key in s.tactile_image_keys: img_ds = f[f"/{s.tactile_image_group}/{key}"] pair = np.stack([img_ds[ti] for ti in ts], axis=0) # (T_pair, H, W) uint8 frames_per_finger.append(pair) bg_ds = f[f"/{s.tactile_bg_group}/{key}"] bg_per_finger.append(np.asarray(bg_ds, dtype=np.uint8)) tactile_frames = np.stack(frames_per_finger, axis=0) # (n_f, T_pair, H, W) tactile_bg = np.stack(bg_per_finger, axis=0) # (n_f, H, W) sample["tactile_frames"] = torch.from_numpy(tactile_frames) sample["tactile_bg"] = torch.from_numpy(tactile_bg) elif s.tactile_keys and s.tactile_dim > 0: hist = max(1, s.tactile_history) ts = [max(0, t - hist + 1 + k) for k in range(hist)] # For each timestep, concat all tactile keys; then concat across timesteps. per_t = [] for ti in ts: parts = [f[self._tactile_path(k)][ti].astype(np.float32) for k in s.tactile_keys] per_t.append(np.concatenate(parts, axis=-1)) tac = np.concatenate(per_t, axis=-1) # (H * sum_dim,) expected = s.tactile_dim * hist if tac.shape[0] != expected: raise ValueError( f"tactile_dim*history={expected} but concat of {s.tactile_keys}×{hist} gave {tac.shape[0]}" ) sample["tactile"] = torch.from_numpy(tac) end = min(t + s.chunk_size, ep_len) action = f[self.ACTION_PATH][t:end].astype(np.float32) if s.action_indices is not None: action = action[:, s.action_indices] if s.delta_actions: # delta[k] = action[t+k] - qpos[t+k] = qpos[t+k+1] - qpos[t+k] # (action at the last episode step is the repeated final pose, so the # resulting delta there is zero — a sensible "do nothing" target.) q_chunk = f[self.QPOS_PATH][t:end].astype(np.float32) # For delta_actions to be well-defined we must subtract qpos sliced # to the same indices as action. if s.action_indices is not None: q_chunk = q_chunk[:, s.action_indices] if q_chunk.shape[1] != action.shape[1]: raise ValueError( f"delta_actions requires action_dim == state_dim per frame; " f"got action {action.shape[1]} vs qpos {q_chunk.shape[1]}." ) action = action - q_chunk pad = s.chunk_size - action.shape[0] if pad > 0: action = np.concatenate([action, np.zeros((pad, s.action_dim), dtype=np.float32)], axis=0) is_pad = np.zeros(s.chunk_size, dtype=bool) if pad > 0: is_pad[-pad:] = True sample["action"] = torch.from_numpy(action) sample["action_is_pad"] = torch.from_numpy(is_pad) return sample