File size: 14,417 Bytes
121869d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | """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/<key> (T, H, W) or (T,H,W,3) uint8 for each k in image_keys
/wrench/<f> (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/<key>`` 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 "/<group>/<leaf>". Default group is "wrench" so
# plain leaf names ("1") and "wrench_1" both resolve to "/wrench/1". Any
# "<group>_<leaf>" key (e.g. "sitr_0") routes to "/<group>/<leaf>".
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
|