AlexWortega commited on
Commit
b7acedc
·
verified ·
1 Parent(s): b152c62

Upload tinyvla2/src/tinyvla/data/wds_shards.py with huggingface_hub

Browse files
tinyvla2/src/tinyvla/data/wds_shards.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map-style source over prebuilt WebDataset tar shards (AlexWortega/wds-*).
2
+
3
+ Layout produced by the packer, uniform across all six repos:
4
+
5
+ <root>/manifest.json norm_stats, embodiment_id, chunk, dims, shard list
6
+ <root>/tasks.json {task_index: instruction string}
7
+ <root>/shard-XXXXX.tar <name>_<ep>_<frame>.cam0.jpg
8
+ <name>_<ep>_<frame>.cam1.jpg
9
+ <name>_<ep>_<frame>.meta.npz
10
+
11
+ `meta.npz` carries `state`, `action_chunk` (chunk, action_dim), `action_is_pad`,
12
+ `action_dim`, `state_dim`, `task_index`, `embodiment_id`.
13
+
14
+ Why map-style and not IterableDataset: the training loop mixes these with
15
+ `CanonicalSource` inside `WeightedMixtureDataset`, which is map-style and seeds
16
+ sampling per index. tar members have fixed offsets, so once the offsets are
17
+ indexed a sample is a plain seek+read — no need to rewrite the loader as a
18
+ stream, and matched-pairs sampling across variants keeps working.
19
+
20
+ The index (one pass over the tar headers, no payload read) is cached next to the
21
+ shards, because building it for the 635-shard unitree pack takes minutes.
22
+
23
+ Emits exactly the schema CanonicalSource emits, so it drops into the existing
24
+ mixture unchanged. Actions here are NATIVE (unitree joint positions 7-28d, nav
25
+ waypoints/step-deltas 3d), not the 7-dim canonical EE space — the per-sample
26
+ `action_dim_mask` is what lets one head serve both.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import io
32
+ import json
33
+ import pickle
34
+ import tarfile
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+ import torch
39
+ import torch.nn.functional as F
40
+
41
+ INDEX_VERSION = 1
42
+
43
+
44
+ def _pad_strict(x: torch.Tensor, dim: int, what: str) -> torch.Tensor:
45
+ """Zero-pad the last dim, REFUSING to truncate.
46
+
47
+ Silently cutting a 28-dim G1 action down to 8 is exactly the class of
48
+ convention bug this project keeps paying for, so it raises instead.
49
+ """
50
+ d = x.shape[-1]
51
+ if d > dim:
52
+ raise ValueError(
53
+ f"{what} has {d} dims but max is {dim} — raise max_{what}_dim in the "
54
+ f"policy config instead of truncating"
55
+ )
56
+ return F.pad(x, (0, dim - d)) if d < dim else x
57
+
58
+
59
+ class WdsShardSource(torch.utils.data.Dataset):
60
+ def __init__(
61
+ self,
62
+ root: str | Path,
63
+ embodiment_id: int | None = None,
64
+ chunk: int = 50,
65
+ image_size: int = 256,
66
+ max_state_dim: int = 32,
67
+ max_action_dim: int = 32,
68
+ morphology: torch.Tensor | None = None,
69
+ robot_prompt: str | None = None,
70
+ normalize: bool = True,
71
+ emit_latent_image: bool = False,
72
+ ):
73
+ self.root = Path(root).expanduser()
74
+ self.chunk = chunk
75
+ self.image_size = image_size
76
+ self.max_state_dim = max_state_dim
77
+ self.max_action_dim = max_action_dim
78
+ self.morphology = morphology
79
+ self.robot_prompt = robot_prompt
80
+ self.normalize = normalize
81
+ self.emit_latent_image = emit_latent_image
82
+
83
+ self.manifest = json.loads((self.root / "manifest.json").read_text())
84
+ self.name = self.manifest.get("name", self.root.name)
85
+ self.action_space = self.manifest.get("action_space", "native")
86
+ # config may override the packed id (three nav repos ship the same id 16
87
+ # for three different robots)
88
+ self.embodiment_id = int(
89
+ embodiment_id if embodiment_id is not None else self.manifest["embodiment_id"]
90
+ )
91
+
92
+ tasks_path = self.root / "tasks.json"
93
+ self._tasks = (
94
+ {int(k): v for k, v in json.loads(tasks_path.read_text()).items()}
95
+ if tasks_path.exists()
96
+ else {}
97
+ )
98
+
99
+ ns = self.manifest["norm_stats"]
100
+ self._s_mu, self._s_sd = self._stat(ns.get("observation.state"))
101
+ self._a_mu, self._a_sd = self._stat(ns.get("action"))
102
+
103
+ self._index = self._load_or_build_index()
104
+
105
+ # The packs ship mean/std only, which is unusable here: go-stanford's
106
+ # second action channel has std 0.008, so a single outlier normalizes to
107
+ # ~250 sigma (observed range [-121, 243]). Same reason canonical actions
108
+ # use quantiles. Prefer cached q01/q99 -> [-1, 1]; fall back to mean/std
109
+ # with a hard clip so a missing stats file degrades instead of exploding.
110
+ qs = self.root / "_qstats.json"
111
+ self.q_stats = json.loads(qs.read_text()) if qs.exists() else None
112
+ if self.q_stats is not None:
113
+ self._a_span, self._a_mid = self._span_mid(self.q_stats["action"])
114
+ self._s_span, self._s_mid = self._span_mid(self.q_stats["state"])
115
+ self._fh: dict[str, object] = {} # per-worker file handles, opened lazily
116
+
117
+ # ------------------------------------------------------------------ setup
118
+
119
+ @staticmethod
120
+ def _span_mid(q):
121
+ q01 = torch.tensor(q["q01"], dtype=torch.float32)
122
+ q99 = torch.tensor(q["q99"], dtype=torch.float32)
123
+ floor = 0.01 * torch.median(torch.abs(torch.cat([q01, q99])) + 1e-6)
124
+ return torch.clamp(q99 - q01, min=float(floor) if float(floor) > 0 else 1e-3), 0.5 * (q01 + q99)
125
+
126
+ def compute_quantile_stats(self, n_samples: int = 40000, seed: int = 0) -> dict:
127
+ """q01/q99 per dim over sampled meta.npz, written to _qstats.json."""
128
+ rng = np.random.default_rng(seed)
129
+ n = len(self)
130
+ pick = rng.choice(n, size=min(n_samples, n), replace=False)
131
+ acts, states = [], []
132
+ for i in pick:
133
+ si, _, _, _, _, mo, ms = (int(v) for v in self._index["entries"][i])
134
+ meta = np.load(io.BytesIO(self._read(si, mo, ms)), allow_pickle=False)
135
+ acts.append(meta["action_chunk"].astype(np.float32))
136
+ states.append(meta["state"].astype(np.float32))
137
+ a = np.concatenate(acts, 0)
138
+ st = np.stack(states, 0)
139
+ out = {
140
+ "action": {"q01": np.quantile(a, 0.01, 0).tolist(), "q99": np.quantile(a, 0.99, 0).tolist()},
141
+ "state": {"q01": np.quantile(st, 0.01, 0).tolist(), "q99": np.quantile(st, 0.99, 0).tolist()},
142
+ "n": int(len(pick)),
143
+ }
144
+ (self.root / "_qstats.json").write_text(json.dumps(out))
145
+ return out
146
+
147
+ @staticmethod
148
+ def _stat(s):
149
+ if not s:
150
+ return None, None
151
+ mu = torch.tensor(s["mean"], dtype=torch.float32)
152
+ sd = torch.tensor(s["std"], dtype=torch.float32)
153
+ # scand/tartandrive ship an all-zero state with zero std; clamping keeps
154
+ # the division finite and the (already zero) channel stays zero
155
+ return mu, sd.clamp(min=1e-6)
156
+
157
+ def _load_or_build_index(self):
158
+ cache = self.root / f"_offsets_v{INDEX_VERSION}.pkl"
159
+ if cache.exists():
160
+ return pickle.loads(cache.read_bytes())
161
+
162
+ shards = sorted(self.root.glob("shard-*.tar"))
163
+ if not shards:
164
+ raise FileNotFoundError(f"no shard-*.tar under {self.root}")
165
+
166
+ entries = [] # (shard_idx, cam0_off, cam0_sz, cam1_off, cam1_sz, meta_off, meta_sz)
167
+ for si, sp in enumerate(shards):
168
+ by_key: dict[str, dict] = {}
169
+ with tarfile.open(sp) as tf:
170
+ for m in tf: # streaming header scan, payload never read
171
+ if not m.isfile():
172
+ continue
173
+ key, _, ext = m.name.partition(".")
174
+ by_key.setdefault(key, {})[ext] = (m.offset_data, m.size)
175
+ for key in sorted(by_key):
176
+ parts = by_key[key]
177
+ if "cam0.jpg" not in parts or "meta.npz" not in parts:
178
+ continue # incomplete sample, drop it rather than guess
179
+ c0 = parts["cam0.jpg"]
180
+ c1 = parts.get("cam1.jpg", (-1, 0))
181
+ mo = parts["meta.npz"]
182
+ entries.append((si, c0[0], c0[1], c1[0], c1[1], mo[0], mo[1]))
183
+
184
+ idx = {
185
+ "shards": [str(p) for p in shards],
186
+ "entries": np.asarray(entries, dtype=np.int64),
187
+ }
188
+ cache.write_bytes(pickle.dumps(idx))
189
+ return idx
190
+
191
+ # ------------------------------------------------------------------ access
192
+
193
+ def __len__(self):
194
+ return len(self._index["entries"])
195
+
196
+ def _read(self, shard_idx: int, off: int, size: int) -> bytes:
197
+ path = self._index["shards"][shard_idx]
198
+ fh = self._fh.get(path)
199
+ if fh is None:
200
+ fh = open(path, "rb") # noqa: SIM115 — kept open per worker on purpose
201
+ self._fh[path] = fh
202
+ fh.seek(off)
203
+ return fh.read(size)
204
+
205
+ def _img(self, blob: bytes) -> torch.Tensor:
206
+ from PIL import Image
207
+
208
+ img = Image.open(io.BytesIO(blob)).convert("RGB")
209
+ x = torch.from_numpy(np.asarray(img, dtype=np.uint8).copy()).permute(2, 0, 1).float() / 255.0
210
+ if x.shape[-2:] != (self.image_size, self.image_size):
211
+ x = F.interpolate(x[None], size=(self.image_size, self.image_size),
212
+ mode="bilinear", align_corners=False)[0]
213
+ return x
214
+
215
+ def __getitem__(self, i: int) -> dict:
216
+ si, c0o, c0s, c1o, c1s, mo, ms = (int(v) for v in self._index["entries"][i])
217
+
218
+ cam0 = self._img(self._read(si, c0o, c0s))
219
+ # single-camera packs (all five nav sets) still ship a cam1.jpg member,
220
+ # but it is a ZERO-BYTE placeholder — presence is not enough, check size
221
+ if c1o >= 0 and c1s > 0:
222
+ cam1 = self._img(self._read(si, c1o, c1s))
223
+ cam_mask = torch.tensor([True, True])
224
+ else:
225
+ cam1 = torch.zeros_like(cam0)
226
+ cam_mask = torch.tensor([True, False])
227
+
228
+ meta = np.load(io.BytesIO(self._read(si, mo, ms)), allow_pickle=False)
229
+ state = torch.from_numpy(meta["state"].astype(np.float32))
230
+ action = torch.from_numpy(meta["action_chunk"].astype(np.float32))
231
+ is_pad = torch.from_numpy(meta["action_is_pad"].astype(bool))
232
+
233
+ if self.normalize:
234
+ if self.q_stats is not None:
235
+ state = (2.0 * (state - self._s_mid) / self._s_span).clamp(-3, 3)
236
+ action = (2.0 * (action - self._a_mid) / self._a_span).clamp(-3, 3)
237
+ else:
238
+ if self._s_mu is not None and len(state):
239
+ n = min(len(state), len(self._s_mu))
240
+ state = torch.cat([(state[:n] - self._s_mu[:n]) / self._s_sd[:n], state[n:]])
241
+ state = state.clamp(-10, 10)
242
+ if self._a_mu is not None:
243
+ n = min(action.shape[-1], len(self._a_mu))
244
+ action = torch.cat(
245
+ [(action[..., :n] - self._a_mu[:n]) / self._a_sd[:n], action[..., n:]], dim=-1
246
+ ).clamp(-10, 10)
247
+
248
+ a_dim = action.shape[-1]
249
+ mask = torch.zeros(self.max_action_dim, dtype=torch.bool)
250
+ mask[: min(a_dim, self.max_action_dim)] = True
251
+
252
+ if action.shape[0] < self.chunk:
253
+ pad = self.chunk - action.shape[0]
254
+ action = torch.cat([action, action[-1:].expand(pad, -1)], 0)
255
+ is_pad = torch.cat([is_pad, torch.ones(pad, dtype=torch.bool)])
256
+ action, is_pad = action[: self.chunk], is_pad[: self.chunk]
257
+
258
+ task = self._tasks.get(int(meta["task_index"]), "")
259
+ if self.robot_prompt:
260
+ task = f"{self.robot_prompt} {task}".strip()
261
+
262
+ out = {
263
+ "observation.images.cam0": cam0,
264
+ "observation.images.cam1": cam1,
265
+ "camera_mask": cam_mask,
266
+ "observation.state": _pad_strict(state, self.max_state_dim, "state"),
267
+ "action": _pad_strict(action, self.max_action_dim, "action"),
268
+ "action_dim_mask": mask,
269
+ "action_is_pad": is_pad,
270
+ "embodiment_id": torch.tensor(self.embodiment_id, dtype=torch.long),
271
+ "task": task,
272
+ }
273
+ if self.morphology is not None:
274
+ out["morphology"] = self.morphology
275
+ # Batches mix wds packs with CanonicalSource, so every optional key the
276
+ # other source emits must be emitted here too or default_collate raises
277
+ # on whichever sample lacks it. CanonicalSource always emits
278
+ # spatial_cell (-1 when unlabelled) for exactly this reason.
279
+ out["spatial_cell"] = torch.tensor(-1, dtype=torch.long)
280
+ if self.emit_latent_image:
281
+ out["latent_image"] = cam0 # no episode history here: no staleness aug
282
+ return out