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