WitneyWW commited on
Commit
3770c94
·
verified ·
1 Parent(s): cb87839

Initial upload of tactile_vae (code, model, config, inference)

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ inference/reconstruction_grid.png filter=lfs diff=lfs merge=lfs -text
37
+ inference/vae_baseline_3/reconstruction_grid.png filter=lfs diff=lfs merge=lfs -text
38
+ test/color_jitter_compare.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Large training data (16 GB parquet) — do not push
2
+ data/
3
+
4
+ # Python build / cache
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ .eggs/
9
+ build/
10
+ dist/
11
+
12
+ # Test caches
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+
17
+ # Local outputs / regenerable artifacts
18
+ runs/
19
+ test_output/
20
+
21
+ # OS / editor
22
+ .DS_Store
23
+ .vscode/
24
+ .idea/
25
+ *.swp
README.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tactile_vae
2
+
3
+ ViT-based tactile variational autoencoder.
4
+
5
+ ## Architecture
6
+ - Encoder: `PatchEmbed + Transformer blocks + fixed sin-cos positional embedding`
7
+ - Latent: `mu/logvar` + reparameterization
8
+ - Decoder: latent-conditioned transformer patch decoder + unpatchify
9
+
10
+ ## Usage
11
+
12
+ ```python
13
+ import torch
14
+ from tactile_vae.model import TactileVAE, VAELoss
15
+
16
+ model = TactileVAE()
17
+ out = model(torch.randn(2, 3, 128, 128))
18
+ loss_fn = VAELoss(beta=1.0)
19
+ losses = loss_fn(out["x_hat"], torch.randn(2, 3, 128, 128), out["mu"], out["logvar"])
20
+ ```
__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """tactile_vae package."""
2
+
3
+ __version__ = "0.0.1"
config/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration dataclasses for tactile_vae."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class TactileVAEModelConfig:
8
+ img_size: int = 128
9
+ patch_size: int = 16
10
+ in_chans: int = 3
11
+ embed_dim: int = 256
12
+ encoder_depth: int = 4
13
+ encoder_heads: int = 8
14
+ decoder_embed_dim: int = 192
15
+ decoder_depth: int = 4
16
+ decoder_heads: int = 8
17
+ mlp_ratio: float = 4.0
18
+ latent_dim: int = 128
19
+
20
+
21
+ @dataclass
22
+ class TactileVAELossConfig:
23
+ beta: float = 1.0
24
+ recon_type: str = "l1"
25
+ ssim_weight: float = 0.0
26
+ ssim_window_size: int = 11
27
+
28
+
29
+ @dataclass
30
+ class TactileVAETrainConfig:
31
+ model: TactileVAEModelConfig = field(default_factory=TactileVAEModelConfig)
32
+ loss: TactileVAELossConfig = field(default_factory=TactileVAELossConfig)
config/train_vae.yaml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tactile VAE training config.
2
+ #
3
+ # Paths can be absolute or relative to the config file. The training script
4
+ # resolves relative paths against the repo root (`tactile_world_model/`).
5
+ seed: 0
6
+ device: auto # auto | cuda | cuda:0 | cpu
7
+
8
+ # Each run lives at <runs_root>/<run_id>. If that directory already contains
9
+ # a ckpt_last.pt the trainer auto-resumes from it (override with --no-resume
10
+ # or set train.resume_from explicitly). Leave run_id null to auto-generate
11
+ # a timestamped one like "run_2026-05-16_05-30-12".
12
+ runs_root: runs
13
+ run_id: vae_baseline
14
+
15
+ model:
16
+ img_size: 128
17
+ patch_size: 16
18
+ in_chans: 3
19
+ embed_dim: 384
20
+ encoder_depth: 3
21
+ encoder_heads: 6
22
+ decoder_embed_dim: 384
23
+ decoder_depth: 8
24
+ decoder_heads: 12
25
+ mlp_ratio: 4.0
26
+ latent_dim: 256
27
+
28
+ loss:
29
+ beta: 1.0e-3
30
+ recon_type: l1 # l1 | mse
31
+ perceptual_type: lpips # ssim | lpips
32
+ ssim_weight: 0.1 # used for SSIM; also fallback for LPIPS if lpips_weight unset
33
+ ssim_window_size: 11
34
+ lpips_weight: 1 # used when perceptual_type = lpips
35
+
36
+ data:
37
+ root: tactile_vae/data
38
+ image_size: 128
39
+ cache_files: 1
40
+ splits_path: tactile_vae/dataset/splits_subset.json
41
+ meta_columns: [] # leave empty unless you actually consume them
42
+ return_meta: false
43
+ color_jitter: # null to disable
44
+ brightness: 0.2
45
+ contrast: 0.2
46
+ saturation: 0.2
47
+ hue: 0.05
48
+
49
+ optim:
50
+ lr: 1.0e-4
51
+ weight_decay: 0.05
52
+ betas: [0.9, 0.95]
53
+ eps: 1.0e-8
54
+
55
+ scheduler:
56
+ type: cosine # cosine | constant
57
+ warmup_steps: 1000
58
+ min_lr_ratio: 0.1
59
+
60
+ train:
61
+ batch_size: 128
62
+ num_workers: 8
63
+ epochs: 50000
64
+ max_steps: null # int to cap total steps; null = run all epochs
65
+ log_every: 50
66
+ val_every_steps: 2000
67
+ num_val_batches: 100 # cap validation batches per check
68
+ sample_every_steps: 2000
69
+ num_sample_images: 16
70
+ ckpt_every_steps: 5000
71
+ keep_last_ckpts: 3 # rotate periodic checkpoints; "best" is kept forever
72
+ gradient_clip_norm: 1.0
73
+ amp: true # mixed-precision on CUDA; ignored on CPU
74
+ amp_dtype: bf16 # enforced to bf16 by train_vae.py
75
+ resume_from: null # path to checkpoint to resume from
dataset/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tactile_vae.dataset.dataset import (
2
+ DEFAULT_DATA_ROOT,
3
+ DEFAULT_FILE_GLOB,
4
+ META_COLUMNS,
5
+ ColorJitterConfig,
6
+ ParquetFileShuffleSampler,
7
+ TactileParquetDataset,
8
+ default_image_transform,
9
+ )
10
+
11
+ __all__ = [
12
+ "DEFAULT_DATA_ROOT",
13
+ "DEFAULT_FILE_GLOB",
14
+ "META_COLUMNS",
15
+ "ColorJitterConfig",
16
+ "ParquetFileShuffleSampler",
17
+ "TactileParquetDataset",
18
+ "default_image_transform",
19
+ ]
dataset/dataset.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map-style dataset over the fota_unlabeled tactile parquets.
2
+
3
+ Each parquet row holds a JPEG-encoded `image` blob plus per-frame metadata
4
+ (`obj_name`, pose, force, etc.). The dataset enumerates all parquet files
5
+ under `root`, indexes them by file metadata only (no full read at init),
6
+ and decodes JPEGs on demand.
7
+
8
+ For DataLoader use:
9
+ - Each worker keeps an LRU of `cache_files` parquet image-columns in memory
10
+ (PyArrow `ChunkedArray` of binary refs). Random reads within a cached
11
+ file are zero-copy slices; misses pay one full column read (~2 GB).
12
+ - Combine with `ParquetFileShuffleSampler` for shuffle without thrashing:
13
+ shuffles file order each epoch and shuffles indices inside each file,
14
+ so a worker only ever pulls from one cached file at a time.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import json
20
+ from collections import OrderedDict
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Callable, Iterator, Mapping, Sequence
24
+
25
+ import numpy as np
26
+ import pyarrow.parquet as pq
27
+ import torch
28
+ from PIL import Image
29
+ from torch.utils.data import Dataset, Sampler
30
+ from torchvision.transforms import ColorJitter
31
+
32
+ DEFAULT_DATA_ROOT = Path("/group2/ct/weihanx/tactile_world_model/tactile_vae/data")
33
+ DEFAULT_FILE_GLOB = "train-*-of-*.parquet"
34
+ DEFAULT_SPLITS_PATH = Path(__file__).with_name("splits.json")
35
+ VALID_SPLITS = ("train", "val", "test")
36
+
37
+ # Metadata columns we expose by default — float32 scalars usable as conditioning.
38
+ META_COLUMNS: tuple[str, ...] = (
39
+ "x_mm", "y_mm", "z_mm",
40
+ "quat_x", "quat_y", "quat_z", "quat_w",
41
+ "f_x", "f_y", "f_z",
42
+ "grid_z_max", "grid_z_mean",
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class ColorJitterConfig:
48
+ """Training-time color-jitter knobs (forwarded to `torchvision.transforms.ColorJitter`).
49
+
50
+ Each value is the magnitude of perturbation around the identity. `0.0`
51
+ disables that channel; when all four are zero the config is a no-op.
52
+ Defaults match a mild augmentation reasonable for tactile RGB frames.
53
+
54
+ Treat as part of the training config — eval/inference paths should pass
55
+ `color_jitter=None` to the dataset so augmentation is off.
56
+ """
57
+ brightness: float = 0.2
58
+ contrast: float = 0.2
59
+ saturation: float = 0.2
60
+ hue: float = 0.1
61
+
62
+ def is_noop(self) -> bool:
63
+ return not any((self.brightness, self.contrast, self.saturation, self.hue))
64
+
65
+ def build(self) -> ColorJitter | None:
66
+ if self.is_noop():
67
+ return None
68
+ return ColorJitter(
69
+ brightness=self.brightness,
70
+ contrast=self.contrast,
71
+ saturation=self.saturation,
72
+ hue=self.hue,
73
+ )
74
+
75
+
76
+ def _resolve_color_jitter(
77
+ cfg: ColorJitterConfig | Mapping[str, float] | ColorJitter | None,
78
+ ) -> ColorJitter | None:
79
+ if cfg is None:
80
+ return None
81
+ if isinstance(cfg, ColorJitter):
82
+ return cfg
83
+ if isinstance(cfg, Mapping):
84
+ cfg = ColorJitterConfig(**cfg)
85
+ if isinstance(cfg, ColorJitterConfig):
86
+ return cfg.build()
87
+ raise TypeError(f"unsupported color_jitter type: {type(cfg).__name__}")
88
+
89
+
90
+ def default_image_transform(
91
+ image_size: int = 128,
92
+ color_jitter: ColorJitterConfig | Mapping[str, float] | ColorJitter | None = None,
93
+ ) -> Callable[[Image.Image], torch.Tensor]:
94
+ """Resize → (optional) color-jitter → CHW float32 in [0, 1].
95
+
96
+ `color_jitter` accepts a `ColorJitterConfig`, a dict of its fields, an
97
+ already-built `torchvision.transforms.ColorJitter`, or `None` to disable.
98
+ Apply jitter on PIL (before tensor conversion) — torchvision's RNG is
99
+ per-worker in PyTorch DataLoaders, so per-sample augmentation is correct
100
+ with `num_workers > 0`.
101
+ """
102
+ jitter = _resolve_color_jitter(color_jitter)
103
+
104
+ def _tx(img: Image.Image) -> torch.Tensor:
105
+ if img.size != (image_size, image_size):
106
+ img = img.resize((image_size, image_size), Image.BILINEAR)
107
+ if jitter is not None:
108
+ img = jitter(img)
109
+ arr = np.array(img, dtype=np.uint8, copy=True) # H, W, 3, writable
110
+ t = torch.from_numpy(arr).permute(2, 0, 1).contiguous().float().div_(255.0) # [0, 1]
111
+ return t
112
+ return _tx
113
+
114
+
115
+ class TactileParquetDataset(Dataset):
116
+ """Lazy parquet dataset that yields decoded RGB tensors (+ optional metadata).
117
+
118
+ Args:
119
+ root: directory containing parquet shards.
120
+ file_glob: glob pattern relative to `root`. Defaults to
121
+ `train-*-of-*.parquet`; `.raw.parquet` shards are excluded.
122
+ image_size: output square size if `transform` is the default.
123
+ transform: callable(PIL.Image) -> Tensor. If None, uses
124
+ `default_image_transform(image_size, color_jitter=color_jitter)`.
125
+ Pass an explicit transform to fully override preprocessing;
126
+ `color_jitter` is then ignored.
127
+ color_jitter: training-time RGB augmentation, applied inside the
128
+ default transform. Accepts a `ColorJitterConfig`, a dict of its
129
+ fields, a prebuilt `torchvision.transforms.ColorJitter`, or
130
+ `None` (default — augmentation off). For eval/inference, leave
131
+ as `None`.
132
+ split: one of `"train"`, `"val"`, `"test"`, or `None`. If set, the
133
+ dataset only exposes rows assigned to that split by `splits_path`.
134
+ splits_path: JSON manifest produced by `make_splits.py`. Defaults to
135
+ `tactile_vae/dataset/splits.json`. Only consulted when
136
+ `split is not None`.
137
+ return_meta: if True, `__getitem__` returns `(image, meta_dict)` where
138
+ `meta_dict` has float32 tensors for the columns in `meta_columns`.
139
+ meta_columns: which metadata columns to surface.
140
+ cache_files: how many parquet image-columns to keep in memory per worker.
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ root: str | Path = DEFAULT_DATA_ROOT,
146
+ file_glob: str = DEFAULT_FILE_GLOB,
147
+ image_size: int = 128,
148
+ transform: Callable[[Image.Image], torch.Tensor] | None = None,
149
+ color_jitter: ColorJitterConfig | Mapping[str, float] | ColorJitter | None = None,
150
+ split: str | None = None,
151
+ splits_path: str | Path | None = None,
152
+ return_meta: bool = False,
153
+ meta_columns: Sequence[str] = META_COLUMNS,
154
+ cache_files: int = 1,
155
+ ) -> None:
156
+ root = Path(root)
157
+ files = sorted(root.glob(file_glob))
158
+ files = [p for p in files if ".raw." not in p.name]
159
+ if not files:
160
+ raise FileNotFoundError(f"no parquet shards under {root} matching {file_glob!r}")
161
+
162
+ self.files: list[Path] = files
163
+ self.image_size = image_size
164
+ self.transform = transform or default_image_transform(
165
+ image_size, color_jitter=color_jitter
166
+ )
167
+ self.split = split
168
+ self.return_meta = return_meta
169
+ self.meta_columns: list[str] = list(meta_columns)
170
+ self.cache_files = max(1, int(cache_files))
171
+
172
+ # True per-file row counts from parquet metadata (independent of split).
173
+ file_row_counts: list[int] = [pq.ParquetFile(p).metadata.num_rows for p in files]
174
+
175
+ # Build `_samples`: (N, 2) int64 array of (file_idx, row_idx), sorted
176
+ # by file_idx then row_idx. Grouping by file keeps contiguous global
177
+ # indices on the same parquet — that's what the file-cache relies on.
178
+ if split is None:
179
+ per_file_rows: list[np.ndarray] = [
180
+ np.arange(n, dtype=np.int64) for n in file_row_counts
181
+ ]
182
+ else:
183
+ if split not in VALID_SPLITS:
184
+ raise ValueError(f"split must be one of {VALID_SPLITS}; got {split!r}")
185
+ spath = Path(splits_path) if splits_path is not None else DEFAULT_SPLITS_PATH
186
+ if not spath.exists():
187
+ raise FileNotFoundError(
188
+ f"split={split!r} requested but {spath} not found. "
189
+ "Generate it with `python tactile_vae/dataset/make_splits.py`."
190
+ )
191
+ with spath.open() as f:
192
+ manifest = json.load(f)
193
+ split_map: dict[str, list[int]] = manifest["splits"][split]
194
+ per_file_rows = []
195
+ for p, total_rows in zip(files, file_row_counts):
196
+ rows = split_map.get(p.name, [])
197
+ arr = np.asarray(rows, dtype=np.int64)
198
+ if arr.size and (arr.min() < 0 or arr.max() >= total_rows):
199
+ raise ValueError(
200
+ f"split manifest for {p.name} has row index out of range "
201
+ f"[0, {total_rows}): min={int(arr.min())} max={int(arr.max())}"
202
+ )
203
+ # Sort within file so cache reads progress monotonically.
204
+ per_file_rows.append(np.sort(arr))
205
+
206
+ chunks = [
207
+ np.stack([np.full(rows.shape[0], fi, dtype=np.int64), rows], axis=1)
208
+ for fi, rows in enumerate(per_file_rows)
209
+ if rows.shape[0] > 0
210
+ ]
211
+ self._samples: np.ndarray = (
212
+ np.concatenate(chunks, axis=0)
213
+ if chunks
214
+ else np.zeros((0, 2), dtype=np.int64)
215
+ )
216
+ per_file_counts = [rows.shape[0] for rows in per_file_rows]
217
+ self._per_file_offsets: np.ndarray = np.concatenate(
218
+ [[0], np.cumsum(per_file_counts, dtype=np.int64)]
219
+ )
220
+
221
+ # Per-worker caches; populated lazily.
222
+ self._image_cache: "OrderedDict[int, object]" = OrderedDict()
223
+ self._meta_cache: "OrderedDict[int, dict[str, np.ndarray]]" = OrderedDict()
224
+
225
+ def __len__(self) -> int:
226
+ return int(self._samples.shape[0])
227
+
228
+ @property
229
+ def num_files(self) -> int:
230
+ return len(self.files)
231
+
232
+ def file_lengths(self) -> list[int]:
233
+ """Number of samples drawn from each file under the current split."""
234
+ return np.diff(self._per_file_offsets).astype(int).tolist()
235
+
236
+ def sample_id(self, idx: int) -> str:
237
+ """Stable ID for sample `idx`: `<file-stem>:<row>` — matches `make_splits.py`."""
238
+ fi, ri = self._locate(idx)
239
+ return f"{self.files[fi].stem}:{ri}"
240
+
241
+ def __getstate__(self) -> dict:
242
+ # Strip caches before pickling to workers — pyarrow objects don't
243
+ # pickle cleanly and the cache should be per-worker anyway.
244
+ state = self.__dict__.copy()
245
+ state["_image_cache"] = OrderedDict()
246
+ state["_meta_cache"] = OrderedDict()
247
+ return state
248
+
249
+ def _locate(self, idx: int) -> tuple[int, int]:
250
+ n = self.__len__()
251
+ if idx < 0:
252
+ idx += n
253
+ if idx < 0 or idx >= n:
254
+ raise IndexError(idx)
255
+ fi, ri = self._samples[idx]
256
+ return int(fi), int(ri)
257
+
258
+ def _get_image_column(self, fi: int):
259
+ col = self._image_cache.get(fi)
260
+ if col is not None:
261
+ self._image_cache.move_to_end(fi)
262
+ return col
263
+ col = pq.read_table(self.files[fi], columns=["image"]).column("image")
264
+ self._image_cache[fi] = col
265
+ while len(self._image_cache) > self.cache_files:
266
+ self._image_cache.popitem(last=False)
267
+ return col
268
+
269
+ def _get_meta_columns(self, fi: int) -> dict[str, np.ndarray]:
270
+ if not self.meta_columns:
271
+ return {}
272
+ cached = self._meta_cache.get(fi)
273
+ if cached is not None:
274
+ self._meta_cache.move_to_end(fi)
275
+ return cached
276
+ tbl = pq.read_table(self.files[fi], columns=self.meta_columns)
277
+ cached = {name: tbl.column(name).to_numpy(zero_copy_only=False)
278
+ for name in self.meta_columns}
279
+ self._meta_cache[fi] = cached
280
+ while len(self._meta_cache) > self.cache_files:
281
+ self._meta_cache.popitem(last=False)
282
+ return cached
283
+
284
+ def __getitem__(self, idx: int):
285
+ fi, ri = self._locate(idx)
286
+ col = self._get_image_column(fi)
287
+ raw = col[ri].as_py()
288
+ with Image.open(io.BytesIO(raw)) as im:
289
+ im = im.convert("RGB")
290
+ tensor = self.transform(im)
291
+
292
+ if not self.return_meta:
293
+ return tensor
294
+
295
+ meta_np = self._get_meta_columns(fi)
296
+ meta = {
297
+ name: torch.as_tensor(float(arr[ri]), dtype=torch.float32)
298
+ for name, arr in meta_np.items()
299
+ }
300
+ return tensor, meta
301
+
302
+
303
+ class ParquetFileShuffleSampler(Sampler[int]):
304
+ """Shuffle that keeps each worker reading from one parquet at a time.
305
+
306
+ Shuffles file order per epoch, then yields indices inside each file in a
307
+ shuffled order. With `cache_files=1` on the dataset this means each
308
+ worker only ever pays one file-load cost per file.
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ dataset: TactileParquetDataset,
314
+ seed: int = 0,
315
+ shuffle_files: bool = True,
316
+ shuffle_within_file: bool = True,
317
+ ) -> None:
318
+ self.dataset = dataset
319
+ self.seed = seed
320
+ self.shuffle_files = shuffle_files
321
+ self.shuffle_within_file = shuffle_within_file
322
+ self.epoch = 0
323
+
324
+ def set_epoch(self, epoch: int) -> None:
325
+ self.epoch = epoch
326
+
327
+ def __len__(self) -> int:
328
+ return len(self.dataset)
329
+
330
+ def __iter__(self) -> Iterator[int]:
331
+ rng = np.random.default_rng(self.seed + self.epoch)
332
+ n_files = self.dataset.num_files
333
+ file_order = rng.permutation(n_files) if self.shuffle_files else np.arange(n_files)
334
+ offsets = self.dataset._per_file_offsets
335
+ for fi in file_order:
336
+ start = int(offsets[fi])
337
+ end = int(offsets[fi + 1])
338
+ n = end - start
339
+ if n == 0:
340
+ continue # this file contributes no samples to the current split
341
+ local = rng.permutation(n) if self.shuffle_within_file else np.arange(n)
342
+ for li in local:
343
+ yield int(start + li)
dataset/make_splits.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a stable train/val/test split manifest for the fota_unlabeled parquets.
2
+
3
+ Each sample is identified by `(parquet_filename, row_index)`. We hash that
4
+ identity to assign a stable bucket independent of file order, so adding /
5
+ reordering shards never reshuffles existing samples (only new files get
6
+ fresh assignments).
7
+
8
+ Run:
9
+ python tactile_vae/dataset/make_splits.py
10
+
11
+ Default writes to `tactile_vae/dataset/splits.json` and looks like:
12
+
13
+ {
14
+ "seed": 42,
15
+ "ratios": {"train": 0.9, "val": 0.05, "test": 0.05},
16
+ "counts": {...}, "total": ...,
17
+ "files": ["train-00000-of-00008.parquet", ...],
18
+ "splits": {
19
+ "train": {"train-00000-of-00008.parquet": [0, 3, 4, ...], ...},
20
+ "val": {...},
21
+ "test": {...}
22
+ }
23
+ }
24
+
25
+ Per-file row lists are sorted ascending so they're easy to diff across runs
26
+ and cheap to bisect / slice from a parquet reader.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import json
33
+ import struct
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ import numpy as np
39
+ import pyarrow.parquet as pq
40
+
41
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
42
+ if str(_REPO_ROOT) not in sys.path:
43
+ sys.path.insert(0, str(_REPO_ROOT))
44
+
45
+ from tactile_vae.dataset.dataset import DEFAULT_DATA_ROOT, DEFAULT_FILE_GLOB
46
+
47
+ DEFAULT_OUTPUT = Path(__file__).with_name("splits.json")
48
+ DEFAULT_RATIOS = (0.9, 0.05, 0.05)
49
+ DEFAULT_SEED = 42
50
+ SPLIT_NAMES = ("train", "val", "test")
51
+
52
+
53
+ def _bucket_for(filename: str, row_idx: int, seed: int) -> float:
54
+ """Map a (filename, row) pair to a deterministic float in [0, 1).
55
+
56
+ Uses BLAKE2b for a stable cross-run hash that doesn't depend on Python's
57
+ PYTHONHASHSEED. Adding new files leaves existing samples in their bucket.
58
+ """
59
+ h = hashlib.blake2b(digest_size=8)
60
+ h.update(struct.pack("<I", seed))
61
+ h.update(filename.encode("utf-8"))
62
+ h.update(b"\x00")
63
+ h.update(struct.pack("<q", row_idx))
64
+ return int.from_bytes(h.digest(), "little") / 2**64
65
+
66
+
67
+ def _assign(filename: str, n_rows: int, seed: int, cutoffs: tuple[float, float]) -> np.ndarray:
68
+ """Return a uint8 array of length `n_rows` with bucket {0,1,2} per row."""
69
+ # Vectorized blake2b is impractical, but for n ≤ ~100k per file the
70
+ # python loop is still fast (<1s/file) and keeps the bucketing portable.
71
+ out = np.empty(n_rows, dtype=np.uint8)
72
+ train_cut, val_cut = cutoffs
73
+ for i in range(n_rows):
74
+ u = _bucket_for(filename, i, seed)
75
+ if u < train_cut:
76
+ out[i] = 0 # train
77
+ elif u < val_cut:
78
+ out[i] = 1 # val
79
+ else:
80
+ out[i] = 2 # test
81
+ return out
82
+
83
+
84
+ def make_splits(
85
+ root: Path = DEFAULT_DATA_ROOT,
86
+ file_glob: str = DEFAULT_FILE_GLOB,
87
+ ratios: tuple[float, float, float] = DEFAULT_RATIOS,
88
+ seed: int = DEFAULT_SEED,
89
+ ) -> dict:
90
+ if not (len(ratios) == 3 and abs(sum(ratios) - 1.0) < 1e-6):
91
+ raise ValueError(f"ratios must sum to 1.0; got {ratios} (sum={sum(ratios)})")
92
+ train_r, val_r, _ = ratios
93
+ cutoffs = (train_r, train_r + val_r)
94
+
95
+ files = sorted(p for p in root.glob(file_glob) if ".raw." not in p.name)
96
+ if not files:
97
+ raise FileNotFoundError(f"no parquet shards under {root} matching {file_glob!r}")
98
+
99
+ per_split: dict[str, dict[str, list[int]]] = {n: {} for n in SPLIT_NAMES}
100
+ counts = {n: 0 for n in SPLIT_NAMES}
101
+ total = 0
102
+ t0 = time.time()
103
+ for p in files:
104
+ n = pq.ParquetFile(p).metadata.num_rows
105
+ total += n
106
+ assignments = _assign(p.name, n, seed=seed, cutoffs=cutoffs)
107
+ for split_idx, split_name in enumerate(SPLIT_NAMES):
108
+ rows = np.flatnonzero(assignments == split_idx).tolist()
109
+ per_split[split_name][p.name] = rows
110
+ counts[split_name] += len(rows)
111
+ print(f" {p.name}: rows={n:,} "
112
+ f"train={int((assignments==0).sum()):,} "
113
+ f"val={int((assignments==1).sum()):,} "
114
+ f"test={int((assignments==2).sum()):,}",
115
+ flush=True)
116
+ dt = time.time() - t0
117
+
118
+ print(f"\nAssignment done in {dt:.1f}s")
119
+ for n in SPLIT_NAMES:
120
+ print(f" {n:5s}: {counts[n]:>8,} ({100*counts[n]/total:.2f}%)")
121
+ print(f" total: {total:>8,}")
122
+
123
+ return {
124
+ "seed": seed,
125
+ "ratios": {"train": ratios[0], "val": ratios[1], "test": ratios[2]},
126
+ "counts": counts,
127
+ "total": total,
128
+ "source_root": str(root),
129
+ "source_glob": file_glob,
130
+ "files": [p.name for p in files],
131
+ "split_names": list(SPLIT_NAMES),
132
+ "splits": per_split,
133
+ }
134
+
135
+
136
+ def parse_args() -> argparse.Namespace:
137
+ p = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
138
+ p.add_argument("--root", type=Path, default=DEFAULT_DATA_ROOT)
139
+ p.add_argument("--glob", default=DEFAULT_FILE_GLOB)
140
+ p.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
141
+ p.add_argument("--seed", type=int, default=DEFAULT_SEED)
142
+ p.add_argument("--train", type=float, default=DEFAULT_RATIOS[0])
143
+ p.add_argument("--val", type=float, default=DEFAULT_RATIOS[1])
144
+ p.add_argument("--test", type=float, default=DEFAULT_RATIOS[2])
145
+ return p.parse_args()
146
+
147
+
148
+ def main() -> int:
149
+ args = parse_args()
150
+ manifest = make_splits(
151
+ root=args.root,
152
+ file_glob=args.glob,
153
+ ratios=(args.train, args.val, args.test),
154
+ seed=args.seed,
155
+ )
156
+ args.output.parent.mkdir(parents=True, exist_ok=True)
157
+ with args.output.open("w") as f:
158
+ json.dump(manifest, f, separators=(",", ":"))
159
+ size_mb = args.output.stat().st_size / 1e6
160
+ print(f"\nwrote {args.output} ({size_mb:.2f} MB)")
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__":
165
+ sys.exit(main())
dataset/splits.json ADDED
The diff for this file is too large to render. See raw diff
 
dataset/splits_subset.json ADDED
The diff for this file is too large to render. See raw diff
 
inference/per_sample_metrics.json ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "subset_rank": 0,
4
+ "dataset_index": 28002,
5
+ "sample_id": "train-00000-of-00008:31065",
6
+ "mae": 0.01892464980483055,
7
+ "mse": 0.0015020258724689484
8
+ },
9
+ {
10
+ "subset_rank": 1,
11
+ "dataset_index": 10540,
12
+ "sample_id": "train-00000-of-00008:11713",
13
+ "mae": 0.018629208207130432,
14
+ "mse": 0.001927545526996255
15
+ },
16
+ {
17
+ "subset_rank": 2,
18
+ "dataset_index": 315096,
19
+ "sample_id": "train-00006-of-00008:40249",
20
+ "mae": 0.0188576839864254,
21
+ "mse": 0.001595273963175714
22
+ },
23
+ {
24
+ "subset_rank": 3,
25
+ "dataset_index": 316578,
26
+ "sample_id": "train-00006-of-00008:41869",
27
+ "mae": 0.01664964109659195,
28
+ "mse": 0.001574263907968998
29
+ },
30
+ {
31
+ "subset_rank": 4,
32
+ "dataset_index": 271509,
33
+ "sample_id": "train-00005-of-00008:37878",
34
+ "mae": 0.01655184105038643,
35
+ "mse": 0.001182803069241345
36
+ },
37
+ {
38
+ "subset_rank": 5,
39
+ "dataset_index": 319123,
40
+ "sample_id": "train-00006-of-00008:44695",
41
+ "mae": 0.0181032232940197,
42
+ "mse": 0.0014666281640529633
43
+ },
44
+ {
45
+ "subset_rank": 6,
46
+ "dataset_index": 33232,
47
+ "sample_id": "train-00000-of-00008:36906",
48
+ "mae": 0.019307494163513184,
49
+ "mse": 0.0023352960124611855
50
+ },
51
+ {
52
+ "subset_rank": 7,
53
+ "dataset_index": 190231,
54
+ "sample_id": "train-00004-of-00008:498",
55
+ "mae": 0.015174083411693573,
56
+ "mse": 0.000804901123046875
57
+ },
58
+ {
59
+ "subset_rank": 8,
60
+ "dataset_index": 29925,
61
+ "sample_id": "train-00000-of-00008:33188",
62
+ "mae": 0.019524700939655304,
63
+ "mse": 0.002352712210267782
64
+ },
65
+ {
66
+ "subset_rank": 9,
67
+ "dataset_index": 100407,
68
+ "sample_id": "train-00001-of-00008:38654",
69
+ "mae": 0.02003355696797371,
70
+ "mse": 0.0019040403421968222
71
+ },
72
+ {
73
+ "subset_rank": 10,
74
+ "dataset_index": 187441,
75
+ "sample_id": "train-00003-of-00008:42709",
76
+ "mae": 0.019151723012328148,
77
+ "mse": 0.0011700440663844347
78
+ },
79
+ {
80
+ "subset_rank": 11,
81
+ "dataset_index": 302683,
82
+ "sample_id": "train-00006-of-00008:26476",
83
+ "mae": 0.018105922266840935,
84
+ "mse": 0.0015790475299581885
85
+ },
86
+ {
87
+ "subset_rank": 12,
88
+ "dataset_index": 202331,
89
+ "sample_id": "train-00004-of-00008:13870",
90
+ "mae": 0.019837066531181335,
91
+ "mse": 0.0021240056958049536
92
+ },
93
+ {
94
+ "subset_rank": 13,
95
+ "dataset_index": 103226,
96
+ "sample_id": "train-00001-of-00008:41777",
97
+ "mae": 0.018462352454662323,
98
+ "mse": 0.0015763709088787436
99
+ },
100
+ {
101
+ "subset_rank": 14,
102
+ "dataset_index": 284692,
103
+ "sample_id": "train-00006-of-00008:6576",
104
+ "mae": 0.016698075458407402,
105
+ "mse": 0.0011528099421411753
106
+ },
107
+ {
108
+ "subset_rank": 15,
109
+ "dataset_index": 146700,
110
+ "sample_id": "train-00002-of-00008:43384",
111
+ "mae": 0.02269577607512474,
112
+ "mse": 0.001561635173857212
113
+ },
114
+ {
115
+ "subset_rank": 16,
116
+ "dataset_index": 15249,
117
+ "sample_id": "train-00000-of-00008:16924",
118
+ "mae": 0.01747109368443489,
119
+ "mse": 0.0012944282498210669
120
+ },
121
+ {
122
+ "subset_rank": 17,
123
+ "dataset_index": 8226,
124
+ "sample_id": "train-00000-of-00008:9149",
125
+ "mae": 0.01862267032265663,
126
+ "mse": 0.0015876510879024863
127
+ },
128
+ {
129
+ "subset_rank": 18,
130
+ "dataset_index": 241700,
131
+ "sample_id": "train-00005-of-00008:4679",
132
+ "mae": 0.017128579318523407,
133
+ "mse": 0.0009350610198453069
134
+ },
135
+ {
136
+ "subset_rank": 19,
137
+ "dataset_index": 249617,
138
+ "sample_id": "train-00005-of-00008:13464",
139
+ "mae": 0.018679119646549225,
140
+ "mse": 0.0016614518826827407
141
+ },
142
+ {
143
+ "subset_rank": 20,
144
+ "dataset_index": 6151,
145
+ "sample_id": "train-00000-of-00008:6832",
146
+ "mae": 0.019154969602823257,
147
+ "mse": 0.0019272298086434603
148
+ },
149
+ {
150
+ "subset_rank": 21,
151
+ "dataset_index": 201535,
152
+ "sample_id": "train-00004-of-00008:13012",
153
+ "mae": 0.02364823780953884,
154
+ "mse": 0.003448478877544403
155
+ },
156
+ {
157
+ "subset_rank": 22,
158
+ "dataset_index": 303654,
159
+ "sample_id": "train-00006-of-00008:27559",
160
+ "mae": 0.01817791536450386,
161
+ "mse": 0.001617558067664504
162
+ },
163
+ {
164
+ "subset_rank": 23,
165
+ "dataset_index": 237059,
166
+ "sample_id": "train-00004-of-00008:52418",
167
+ "mae": 0.017815029248595238,
168
+ "mse": 0.001500395592302084
169
+ },
170
+ {
171
+ "subset_rank": 24,
172
+ "dataset_index": 240896,
173
+ "sample_id": "train-00005-of-00008:3774",
174
+ "mae": 0.017452210187911987,
175
+ "mse": 0.001413716934621334
176
+ },
177
+ {
178
+ "subset_rank": 25,
179
+ "dataset_index": 95772,
180
+ "sample_id": "train-00001-of-00008:33454",
181
+ "mae": 0.01503452192991972,
182
+ "mse": 0.0008028325974009931
183
+ },
184
+ {
185
+ "subset_rank": 26,
186
+ "dataset_index": 225780,
187
+ "sample_id": "train-00004-of-00008:39911",
188
+ "mae": 0.020092152059078217,
189
+ "mse": 0.002289179712533951
190
+ },
191
+ {
192
+ "subset_rank": 27,
193
+ "dataset_index": 1992,
194
+ "sample_id": "train-00000-of-00008:2219",
195
+ "mae": 0.017521802335977554,
196
+ "mse": 0.0013774571707472205
197
+ },
198
+ {
199
+ "subset_rank": 28,
200
+ "dataset_index": 65230,
201
+ "sample_id": "train-00000-of-00008:72439",
202
+ "mae": 0.01493510790169239,
203
+ "mse": 0.000781821203418076
204
+ },
205
+ {
206
+ "subset_rank": 29,
207
+ "dataset_index": 339712,
208
+ "sample_id": "train-00007-of-00008:21662",
209
+ "mae": 0.020267298445105553,
210
+ "mse": 0.0018565801437944174
211
+ },
212
+ {
213
+ "subset_rank": 30,
214
+ "dataset_index": 46260,
215
+ "sample_id": "train-00000-of-00008:51312",
216
+ "mae": 0.01745472103357315,
217
+ "mse": 0.0011822863016277552
218
+ },
219
+ {
220
+ "subset_rank": 31,
221
+ "dataset_index": 12500,
222
+ "sample_id": "train-00000-of-00008:13870",
223
+ "mae": 0.017031168565154076,
224
+ "mse": 0.0013072905130684376
225
+ },
226
+ {
227
+ "subset_rank": 32,
228
+ "dataset_index": 1019,
229
+ "sample_id": "train-00000-of-00008:1144",
230
+ "mae": 0.01728898286819458,
231
+ "mse": 0.0012464504688978195
232
+ },
233
+ {
234
+ "subset_rank": 33,
235
+ "dataset_index": 348025,
236
+ "sample_id": "train-00007-of-00008:30906",
237
+ "mae": 0.014839177951216698,
238
+ "mse": 0.0006855515530332923
239
+ },
240
+ {
241
+ "subset_rank": 34,
242
+ "dataset_index": 114566,
243
+ "sample_id": "train-00002-of-00008:7678",
244
+ "mae": 0.018796399235725403,
245
+ "mse": 0.0015862470027059317
246
+ },
247
+ {
248
+ "subset_rank": 35,
249
+ "dataset_index": 65379,
250
+ "sample_id": "train-00000-of-00008:72604",
251
+ "mae": 0.013970417901873589,
252
+ "mse": 0.0007457585888914764
253
+ },
254
+ {
255
+ "subset_rank": 36,
256
+ "dataset_index": 361296,
257
+ "sample_id": "train-00007-of-00008:45623",
258
+ "mae": 0.02225802093744278,
259
+ "mse": 0.0027801282703876495
260
+ },
261
+ {
262
+ "subset_rank": 37,
263
+ "dataset_index": 229059,
264
+ "sample_id": "train-00004-of-00008:43552",
265
+ "mae": 0.019468698650598526,
266
+ "mse": 0.0020611113868653774
267
+ },
268
+ {
269
+ "subset_rank": 38,
270
+ "dataset_index": 150090,
271
+ "sample_id": "train-00003-of-00008:1264",
272
+ "mae": 0.02170371823012829,
273
+ "mse": 0.00205692695453763
274
+ },
275
+ {
276
+ "subset_rank": 39,
277
+ "dataset_index": 321279,
278
+ "sample_id": "train-00007-of-00008:1230",
279
+ "mae": 0.017519645392894745,
280
+ "mse": 0.0015829935437068343
281
+ },
282
+ {
283
+ "subset_rank": 40,
284
+ "dataset_index": 206314,
285
+ "sample_id": "train-00004-of-00008:18294",
286
+ "mae": 0.017531011253595352,
287
+ "mse": 0.0018101041205227375
288
+ },
289
+ {
290
+ "subset_rank": 41,
291
+ "dataset_index": 157328,
292
+ "sample_id": "train-00003-of-00008:9302",
293
+ "mae": 0.01673971116542816,
294
+ "mse": 0.000940843892749399
295
+ },
296
+ {
297
+ "subset_rank": 42,
298
+ "dataset_index": 271578,
299
+ "sample_id": "train-00005-of-00008:37956",
300
+ "mae": 0.017461789771914482,
301
+ "mse": 0.0012990653049200773
302
+ },
303
+ {
304
+ "subset_rank": 43,
305
+ "dataset_index": 235323,
306
+ "sample_id": "train-00004-of-00008:50492",
307
+ "mae": 0.01795295439660549,
308
+ "mse": 0.0015181120252236724
309
+ },
310
+ {
311
+ "subset_rank": 44,
312
+ "dataset_index": 3083,
313
+ "sample_id": "train-00000-of-00008:3430",
314
+ "mae": 0.015775706619024277,
315
+ "mse": 0.0009211775613948703
316
+ },
317
+ {
318
+ "subset_rank": 45,
319
+ "dataset_index": 111555,
320
+ "sample_id": "train-00002-of-00008:4339",
321
+ "mae": 0.01904463954269886,
322
+ "mse": 0.0021079955622553825
323
+ },
324
+ {
325
+ "subset_rank": 46,
326
+ "dataset_index": 195644,
327
+ "sample_id": "train-00004-of-00008:6481",
328
+ "mae": 0.0185789056122303,
329
+ "mse": 0.0015466390177607536
330
+ },
331
+ {
332
+ "subset_rank": 47,
333
+ "dataset_index": 249696,
334
+ "sample_id": "train-00005-of-00008:13548",
335
+ "mae": 0.018499009311199188,
336
+ "mse": 0.0016744902823120356
337
+ },
338
+ {
339
+ "subset_rank": 48,
340
+ "dataset_index": 208395,
341
+ "sample_id": "train-00004-of-00008:20588",
342
+ "mae": 0.01778639853000641,
343
+ "mse": 0.0018380036344751716
344
+ },
345
+ {
346
+ "subset_rank": 49,
347
+ "dataset_index": 179055,
348
+ "sample_id": "train-00003-of-00008:33409",
349
+ "mae": 0.01800130307674408,
350
+ "mse": 0.0015790577745065093
351
+ }
352
+ ]
inference/reconstruction_grid.png ADDED

Git LFS Details

  • SHA256: 0e6978f5e89b0bf12d8596cfe7b1f09340cf20f2da221d4ac5405aa9b772ac8f
  • Pointer size: 131 Bytes
  • Size of remote file: 498 kB
inference/summary.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "config": "/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_4/config.snapshot.yaml",
3
+ "checkpoint": "/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_4/checkpoints/last.ckpt",
4
+ "split": "train",
5
+ "seed": 0,
6
+ "selected_num_samples": 50,
7
+ "mean_mae": 0.018208201732486485,
8
+ "mean_mse": 0.001575469592353329,
9
+ "grid_path": "/group2/ct/weihanx/tactile_world_model/tactile_vae/inference/reconstruction_grid.png"
10
+ }
inference/vae_baseline_3/per_sample_metrics.json ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "subset_rank": 0,
4
+ "dataset_index": 28002,
5
+ "sample_id": "train-00000-of-00008:31065",
6
+ "mae": 0.0166653823107481,
7
+ "mse": 0.0014663146575912833
8
+ },
9
+ {
10
+ "subset_rank": 1,
11
+ "dataset_index": 10540,
12
+ "sample_id": "train-00000-of-00008:11713",
13
+ "mae": 0.017510399222373962,
14
+ "mse": 0.0017775435699149966
15
+ },
16
+ {
17
+ "subset_rank": 2,
18
+ "dataset_index": 315096,
19
+ "sample_id": "train-00006-of-00008:40249",
20
+ "mae": 0.017776841297745705,
21
+ "mse": 0.00159529410302639
22
+ },
23
+ {
24
+ "subset_rank": 3,
25
+ "dataset_index": 316578,
26
+ "sample_id": "train-00006-of-00008:41869",
27
+ "mae": 0.01655813306570053,
28
+ "mse": 0.0015516902785748243
29
+ },
30
+ {
31
+ "subset_rank": 4,
32
+ "dataset_index": 271509,
33
+ "sample_id": "train-00005-of-00008:37878",
34
+ "mae": 0.01444108597934246,
35
+ "mse": 0.0008555233362130821
36
+ },
37
+ {
38
+ "subset_rank": 5,
39
+ "dataset_index": 319123,
40
+ "sample_id": "train-00006-of-00008:44695",
41
+ "mae": 0.018781110644340515,
42
+ "mse": 0.0018363429699093103
43
+ },
44
+ {
45
+ "subset_rank": 6,
46
+ "dataset_index": 33232,
47
+ "sample_id": "train-00000-of-00008:36906",
48
+ "mae": 0.01676507666707039,
49
+ "mse": 0.001682119444012642
50
+ },
51
+ {
52
+ "subset_rank": 7,
53
+ "dataset_index": 190231,
54
+ "sample_id": "train-00004-of-00008:498",
55
+ "mae": 0.014347122982144356,
56
+ "mse": 0.0007567598950117826
57
+ },
58
+ {
59
+ "subset_rank": 8,
60
+ "dataset_index": 29925,
61
+ "sample_id": "train-00000-of-00008:33188",
62
+ "mae": 0.016472000628709793,
63
+ "mse": 0.0017040781676769257
64
+ },
65
+ {
66
+ "subset_rank": 9,
67
+ "dataset_index": 100407,
68
+ "sample_id": "train-00001-of-00008:38654",
69
+ "mae": 0.017876047641038895,
70
+ "mse": 0.0018246680265292525
71
+ },
72
+ {
73
+ "subset_rank": 10,
74
+ "dataset_index": 187441,
75
+ "sample_id": "train-00003-of-00008:42709",
76
+ "mae": 0.016988320276141167,
77
+ "mse": 0.0009907050989568233
78
+ },
79
+ {
80
+ "subset_rank": 11,
81
+ "dataset_index": 302683,
82
+ "sample_id": "train-00006-of-00008:26476",
83
+ "mae": 0.018481288105249405,
84
+ "mse": 0.0022351769730448723
85
+ },
86
+ {
87
+ "subset_rank": 12,
88
+ "dataset_index": 202331,
89
+ "sample_id": "train-00004-of-00008:13870",
90
+ "mae": 0.017929738387465477,
91
+ "mse": 0.001798365032300353
92
+ },
93
+ {
94
+ "subset_rank": 13,
95
+ "dataset_index": 103226,
96
+ "sample_id": "train-00001-of-00008:41777",
97
+ "mae": 0.017578527331352234,
98
+ "mse": 0.0016123488312587142
99
+ },
100
+ {
101
+ "subset_rank": 14,
102
+ "dataset_index": 284692,
103
+ "sample_id": "train-00006-of-00008:6576",
104
+ "mae": 0.015600966289639473,
105
+ "mse": 0.00086554343579337
106
+ },
107
+ {
108
+ "subset_rank": 15,
109
+ "dataset_index": 146700,
110
+ "sample_id": "train-00002-of-00008:43384",
111
+ "mae": 0.021709434688091278,
112
+ "mse": 0.0013935761526226997
113
+ },
114
+ {
115
+ "subset_rank": 16,
116
+ "dataset_index": 15249,
117
+ "sample_id": "train-00000-of-00008:16924",
118
+ "mae": 0.014922507107257843,
119
+ "mse": 0.0007923931698314846
120
+ },
121
+ {
122
+ "subset_rank": 17,
123
+ "dataset_index": 8226,
124
+ "sample_id": "train-00000-of-00008:9149",
125
+ "mae": 0.016841208562254906,
126
+ "mse": 0.0015739825321361423
127
+ },
128
+ {
129
+ "subset_rank": 18,
130
+ "dataset_index": 241700,
131
+ "sample_id": "train-00005-of-00008:4679",
132
+ "mae": 0.01727406494319439,
133
+ "mse": 0.0009527048096060753
134
+ },
135
+ {
136
+ "subset_rank": 19,
137
+ "dataset_index": 249617,
138
+ "sample_id": "train-00005-of-00008:13464",
139
+ "mae": 0.0178667064756155,
140
+ "mse": 0.0016339406138285995
141
+ },
142
+ {
143
+ "subset_rank": 20,
144
+ "dataset_index": 6151,
145
+ "sample_id": "train-00000-of-00008:6832",
146
+ "mae": 0.018926797434687614,
147
+ "mse": 0.0019104413222521544
148
+ },
149
+ {
150
+ "subset_rank": 21,
151
+ "dataset_index": 201535,
152
+ "sample_id": "train-00004-of-00008:13012",
153
+ "mae": 0.020377418026328087,
154
+ "mse": 0.0026058475486934185
155
+ },
156
+ {
157
+ "subset_rank": 22,
158
+ "dataset_index": 303654,
159
+ "sample_id": "train-00006-of-00008:27559",
160
+ "mae": 0.0186694897711277,
161
+ "mse": 0.002249116078019142
162
+ },
163
+ {
164
+ "subset_rank": 23,
165
+ "dataset_index": 237059,
166
+ "sample_id": "train-00004-of-00008:52418",
167
+ "mae": 0.01686570607125759,
168
+ "mse": 0.0015954857226461172
169
+ },
170
+ {
171
+ "subset_rank": 24,
172
+ "dataset_index": 240896,
173
+ "sample_id": "train-00005-of-00008:3774",
174
+ "mae": 0.01646217331290245,
175
+ "mse": 0.0014256120193749666
176
+ },
177
+ {
178
+ "subset_rank": 25,
179
+ "dataset_index": 95772,
180
+ "sample_id": "train-00001-of-00008:33454",
181
+ "mae": 0.014364289119839668,
182
+ "mse": 0.0007188305607996881
183
+ },
184
+ {
185
+ "subset_rank": 26,
186
+ "dataset_index": 225780,
187
+ "sample_id": "train-00004-of-00008:39911",
188
+ "mae": 0.018801426514983177,
189
+ "mse": 0.002039157785475254
190
+ },
191
+ {
192
+ "subset_rank": 27,
193
+ "dataset_index": 1992,
194
+ "sample_id": "train-00000-of-00008:2219",
195
+ "mae": 0.018728084862232208,
196
+ "mse": 0.0016581187956035137
197
+ },
198
+ {
199
+ "subset_rank": 28,
200
+ "dataset_index": 65230,
201
+ "sample_id": "train-00000-of-00008:72439",
202
+ "mae": 0.013995742425322533,
203
+ "mse": 0.0007463646470569074
204
+ },
205
+ {
206
+ "subset_rank": 29,
207
+ "dataset_index": 339712,
208
+ "sample_id": "train-00007-of-00008:21662",
209
+ "mae": 0.020348068326711655,
210
+ "mse": 0.0021553956903517246
211
+ },
212
+ {
213
+ "subset_rank": 30,
214
+ "dataset_index": 46260,
215
+ "sample_id": "train-00000-of-00008:51312",
216
+ "mae": 0.017040027305483818,
217
+ "mse": 0.001189670292660594
218
+ },
219
+ {
220
+ "subset_rank": 31,
221
+ "dataset_index": 12500,
222
+ "sample_id": "train-00000-of-00008:13870",
223
+ "mae": 0.017332643270492554,
224
+ "mse": 0.0015696667833253741
225
+ },
226
+ {
227
+ "subset_rank": 32,
228
+ "dataset_index": 1019,
229
+ "sample_id": "train-00000-of-00008:1144",
230
+ "mae": 0.01800120621919632,
231
+ "mse": 0.0015177734894677997
232
+ },
233
+ {
234
+ "subset_rank": 33,
235
+ "dataset_index": 348025,
236
+ "sample_id": "train-00007-of-00008:30906",
237
+ "mae": 0.01363951526582241,
238
+ "mse": 0.0006664479151368141
239
+ },
240
+ {
241
+ "subset_rank": 34,
242
+ "dataset_index": 114566,
243
+ "sample_id": "train-00002-of-00008:7678",
244
+ "mae": 0.01782606542110443,
245
+ "mse": 0.0016285644378513098
246
+ },
247
+ {
248
+ "subset_rank": 35,
249
+ "dataset_index": 65379,
250
+ "sample_id": "train-00000-of-00008:72604",
251
+ "mae": 0.012702388688921928,
252
+ "mse": 0.0006791208870708942
253
+ },
254
+ {
255
+ "subset_rank": 36,
256
+ "dataset_index": 361296,
257
+ "sample_id": "train-00007-of-00008:45623",
258
+ "mae": 0.01820572093129158,
259
+ "mse": 0.0015046261250972748
260
+ },
261
+ {
262
+ "subset_rank": 37,
263
+ "dataset_index": 229059,
264
+ "sample_id": "train-00004-of-00008:43552",
265
+ "mae": 0.018051331862807274,
266
+ "mse": 0.0017623631283640862
267
+ },
268
+ {
269
+ "subset_rank": 38,
270
+ "dataset_index": 150090,
271
+ "sample_id": "train-00003-of-00008:1264",
272
+ "mae": 0.0181649811565876,
273
+ "mse": 0.0011993813095614314
274
+ },
275
+ {
276
+ "subset_rank": 39,
277
+ "dataset_index": 321279,
278
+ "sample_id": "train-00007-of-00008:1230",
279
+ "mae": 0.016737129539251328,
280
+ "mse": 0.0016499034827575088
281
+ },
282
+ {
283
+ "subset_rank": 40,
284
+ "dataset_index": 206314,
285
+ "sample_id": "train-00004-of-00008:18294",
286
+ "mae": 0.015606882981956005,
287
+ "mse": 0.0015939919976517558
288
+ },
289
+ {
290
+ "subset_rank": 41,
291
+ "dataset_index": 157328,
292
+ "sample_id": "train-00003-of-00008:9302",
293
+ "mae": 0.017554117366671562,
294
+ "mse": 0.0011766867246478796
295
+ },
296
+ {
297
+ "subset_rank": 42,
298
+ "dataset_index": 271578,
299
+ "sample_id": "train-00005-of-00008:37956",
300
+ "mae": 0.01577179506421089,
301
+ "mse": 0.0010084250243380666
302
+ },
303
+ {
304
+ "subset_rank": 43,
305
+ "dataset_index": 235323,
306
+ "sample_id": "train-00004-of-00008:50492",
307
+ "mae": 0.016777435317635536,
308
+ "mse": 0.0015847641043365002
309
+ },
310
+ {
311
+ "subset_rank": 44,
312
+ "dataset_index": 3083,
313
+ "sample_id": "train-00000-of-00008:3430",
314
+ "mae": 0.015516860410571098,
315
+ "mse": 0.0011327709071338177
316
+ },
317
+ {
318
+ "subset_rank": 45,
319
+ "dataset_index": 111555,
320
+ "sample_id": "train-00002-of-00008:4339",
321
+ "mae": 0.0198848657310009,
322
+ "mse": 0.002476822817698121
323
+ },
324
+ {
325
+ "subset_rank": 46,
326
+ "dataset_index": 195644,
327
+ "sample_id": "train-00004-of-00008:6481",
328
+ "mae": 0.016649916768074036,
329
+ "mse": 0.001522408565506339
330
+ },
331
+ {
332
+ "subset_rank": 47,
333
+ "dataset_index": 249696,
334
+ "sample_id": "train-00005-of-00008:13548",
335
+ "mae": 0.017414681613445282,
336
+ "mse": 0.0016105175018310547
337
+ },
338
+ {
339
+ "subset_rank": 48,
340
+ "dataset_index": 208395,
341
+ "sample_id": "train-00004-of-00008:20588",
342
+ "mae": 0.016060233116149902,
343
+ "mse": 0.0016828887164592743
344
+ },
345
+ {
346
+ "subset_rank": 49,
347
+ "dataset_index": 179055,
348
+ "sample_id": "train-00003-of-00008:33409",
349
+ "mae": 0.017118770629167557,
350
+ "mse": 0.0016486085951328278
351
+ }
352
+ ]
inference/vae_baseline_3/reconstruction_grid.png ADDED

Git LFS Details

  • SHA256: 92e8ec14987684552a42a869a913b0ae2f4a2bf9a1314d9687963ac3d85ecfff
  • Pointer size: 131 Bytes
  • Size of remote file: 497 kB
inference/vae_baseline_3/summary.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "config": "/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_3/config.snapshot.yaml",
3
+ "checkpoint": "/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_3/checkpoints/last.ckpt",
4
+ "split": "train",
5
+ "seed": 0,
6
+ "selected_num_samples": 50,
7
+ "mean_mae": 0.017119634542614223,
8
+ "mean_mse": 0.0014961768814828248,
9
+ "grid_path": "/group2/ct/weihanx/tactile_world_model/tactile_vae/inference/vae_baseline_3/reconstruction_grid.png"
10
+ }
model/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tactile_vae.model.tactile_vae import (
2
+ BetaVAELoss,
3
+ SSIMLoss,
4
+ TactileVAE,
5
+ VAELoss,
6
+ load_pretrained,
7
+ )
8
+
9
+ __all__ = [
10
+ "TactileVAE",
11
+ "VAELoss",
12
+ "BetaVAELoss",
13
+ "SSIMLoss",
14
+ "load_pretrained",
15
+ ]
model/cosmos_tokenizer.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cosmos CV4x8x8 tokenizer / detokenizer for single-frame RGB images.
2
+
3
+ Wraps the Cosmos JIT encoder and decoder with a clean encode/decode interface.
4
+ Input images are expected as (B, 3, H, W) float32 in [-1, 1].
5
+ Latents have shape (B, 16, 1, H/8, W/8) – the temporal dim is always 1 for
6
+ single frames, matching the CV (Causal Video) 4×8×8 compression scheme.
7
+
8
+ Usage:
9
+ tokenizer = CosmosTokenizer(ckpt_dir, device)
10
+ z = tokenizer(x, mode="encode") # or tokenizer.encode(x)
11
+ x_hat = tokenizer(z, mode="decode") # or tokenizer.decode(z)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn as nn
21
+ from PIL import Image
22
+
23
+ _DEFAULT_CKPT_DIR = Path(
24
+ "/group2/ct/weihanx/tactile_world_model/tactile_wm/pretrained_models/"
25
+ "Cosmos-0.1-Tokenizer-CV4x8x8"
26
+ )
27
+
28
+
29
+ class CosmosTokenizer(nn.Module):
30
+ def __init__(
31
+ self,
32
+ ckpt_dir: str | Path = _DEFAULT_CKPT_DIR,
33
+ device: str | torch.device = "cuda",
34
+ dtype: str = "bfloat16",
35
+ ):
36
+ super().__init__()
37
+ self.device = torch.device(device)
38
+ self.dtype = getattr(torch, dtype)
39
+ ckpt_dir = Path(ckpt_dir)
40
+ for name in ("encoder.jit", "decoder.jit"):
41
+ if not (ckpt_dir / name).exists():
42
+ raise FileNotFoundError(f"Cosmos checkpoint not found: {ckpt_dir / name}")
43
+ self.encoder = torch.jit.load(str(ckpt_dir / "encoder.jit"), map_location=self.device).eval()
44
+ self.decoder = torch.jit.load(str(ckpt_dir / "decoder.jit"), map_location=self.device).eval()
45
+
46
+ def to(self, device):
47
+ super().to(device)
48
+ self.device = torch.device(device)
49
+ self.encoder = self.encoder.to(device)
50
+ self.decoder = self.decoder.to(device)
51
+ return self
52
+
53
+ @staticmethod
54
+ def _extract(obj) -> torch.Tensor:
55
+ if isinstance(obj, torch.Tensor):
56
+ return obj
57
+ if isinstance(obj, (tuple, list)):
58
+ for item in obj:
59
+ t = CosmosTokenizer._extract(item)
60
+ if isinstance(t, torch.Tensor):
61
+ return t
62
+ raise TypeError(f"no tensor in model output: {type(obj)!r}")
63
+
64
+ @torch.no_grad()
65
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
66
+ """Tokenize (B, 3, H, W) float32 [-1,1] → latent (B, 16, 1, H/8, W/8)."""
67
+ video = x.to(device=self.device, dtype=self.dtype).unsqueeze(2) # (B,3,1,H,W)
68
+ return self._extract(self.encoder(video))
69
+
70
+ @torch.no_grad()
71
+ def decode(self, z: torch.Tensor) -> torch.Tensor:
72
+ """Detokenize latent (B, 16, 1, H/8, W/8) → (B, 3, H, W) float32 [-1,1]."""
73
+ z = z.to(device=self.device, dtype=self.dtype)
74
+ recon = self._extract(self.decoder(z)) # (B, 3, 1, H, W)
75
+ return recon[:, :, 0].float()
76
+
77
+ @torch.no_grad()
78
+ def forward(self, x: torch.Tensor, mode: str) -> torch.Tensor:
79
+ if mode == "encode":
80
+ return self.encode(x)
81
+ if mode == "decode":
82
+ return self.decode(x)
83
+ raise ValueError(f"mode must be 'encode' or 'decode', got {mode!r}")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ _EPISODE_PATH = Path("/group2/ct/weihanx/tactile_world_model/mode1_v1/0323_episode_000.pt")
88
+ _OUT_DIR = Path("/group2/ct/weihanx/tactile_world_model/tactile_vae/test_output/cosmos")
89
+ _SAMPLE_INDICES = [0, 100, 500, 1000, 2000]
90
+
91
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
92
+ print(f"device: {device}")
93
+
94
+ # ── Load tokenizer ────────────────────────────────────────────────────────
95
+ tokenizer = CosmosTokenizer(ckpt_dir=_DEFAULT_CKPT_DIR, device=device)
96
+ print(f"encoder loaded: {_DEFAULT_CKPT_DIR / 'encoder.jit'}")
97
+ print(f"decoder loaded: {_DEFAULT_CKPT_DIR / 'decoder.jit'}")
98
+
99
+ # ── Load episode frames ───────────────────────────────────────────────────
100
+ ep = torch.load(str(_EPISODE_PATH), map_location="cpu", weights_only=False)
101
+ views = ep["view"] # (T, 3, H, W) uint8
102
+ frames_u8 = views[_SAMPLE_INDICES] # (N, 3, H, W) uint8
103
+ frames = frames_u8.float() / 127.5 - 1.0 # (N, 3, H, W) float32 in [-1, 1]
104
+ print(f"\nepisode frames: {tuple(frames.shape)}")
105
+
106
+ # ── Tokenize ──────────────────────────────────────────────────────────────
107
+ z = tokenizer.encode(frames)
108
+ print(f"latent shape: {tuple(z.shape)}")
109
+
110
+ # ── Detokenize ────────────────────────────────────────────────────────────
111
+ x_hat = tokenizer.decode(z)
112
+ print(f"recon shape: {tuple(x_hat.shape)}")
113
+
114
+ # ── Save panels and print PSNR ────────────────────────────────────────────
115
+ _OUT_DIR.mkdir(parents=True, exist_ok=True)
116
+ psnrs = []
117
+ for i, idx in enumerate(_SAMPLE_INDICES):
118
+ orig_np = ((frames[i].permute(1, 2, 0).clamp(-1, 1) + 1) * 127.5).byte().numpy()
119
+ recon_np = ((x_hat[i].permute(1, 2, 0).clamp(-1, 1) + 1) * 127.5).byte().cpu().numpy()
120
+ diff_np = np.abs(orig_np.astype(int) - recon_np.astype(int)).astype(np.uint8)
121
+ mse = float(((orig_np.astype(float) - recon_np.astype(float)) ** 2).mean())
122
+ psnr = 10 * np.log10(255.0 ** 2 / mse) if mse > 0 else float("inf")
123
+ psnrs.append(psnr)
124
+ print(f" frame {idx:5d} PSNR={psnr:.2f} dB")
125
+
126
+ h, w = orig_np.shape[:2]
127
+ panel = Image.new("RGB", (3 * w + 16, h), (20, 20, 20))
128
+ panel.paste(Image.fromarray(orig_np), (0, 0))
129
+ panel.paste(Image.fromarray(recon_np), (w + 8, 0))
130
+ panel.paste(Image.fromarray(diff_np), (2 * w + 16, 0))
131
+ panel.save(_OUT_DIR / f"cosmos_frame_{idx:05d}_panel.png")
132
+ Image.fromarray(orig_np).save(_OUT_DIR / f"cosmos_frame_{idx:05d}_input.png")
133
+ Image.fromarray(recon_np).save(_OUT_DIR / f"cosmos_frame_{idx:05d}_recon.png")
134
+
135
+ print(f"\nmean PSNR: {np.mean(psnrs):.2f} dB (over {len(psnrs)} frames)")
136
+ print(f"panels saved to {_OUT_DIR}")
model/tactile_vae.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ViT-based tactile VAE (no MAE masking).
2
+
3
+ Architecture:
4
+ - Encoder: PatchEmbed + Transformer blocks + fixed sin-cos positional embedding.
5
+ - Latent: mu/logvar heads + reparameterization.
6
+ - Decoder: latent-conditioned transformer decoder that predicts image patches.
7
+ - Reconstruction: unpatchify patch predictions into image space.
8
+
9
+ Training objective: regular VAE loss (reconstruction + beta * KL), with optional SSIM.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+ from PIL import Image
16
+
17
+ import numpy as np
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from timm.models.vision_transformer import Block, PatchEmbed
22
+
23
+
24
+ DEFAULT_TACTILE_VAE_DIR = Path("/group2/ct/weihanx/tactile_world_model/tactile_wm/pretrained_models")
25
+ DEFAULT_CHECKPOINT_NAME = "ckpt_best.pt"
26
+
27
+
28
+ def _get_1d_sincos_pos_embed_from_grid(embed_dim: int, pos: np.ndarray) -> np.ndarray:
29
+ assert embed_dim % 2 == 0
30
+ omega = np.arange(embed_dim // 2, dtype=float)
31
+ omega /= embed_dim / 2.0
32
+ omega = 1.0 / (10000**omega)
33
+ pos = pos.reshape(-1)
34
+ out = np.einsum("m,d->md", pos, omega)
35
+ emb_sin = np.sin(out)
36
+ emb_cos = np.cos(out)
37
+ return np.concatenate([emb_sin, emb_cos], axis=1)
38
+
39
+
40
+ def _get_2d_sincos_pos_embed_from_grid(embed_dim: int, grid: np.ndarray) -> np.ndarray:
41
+ assert embed_dim % 2 == 0
42
+ emb_h = _get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[0])
43
+ emb_w = _get_1d_sincos_pos_embed_from_grid(embed_dim // 2, grid[1])
44
+ return np.concatenate([emb_h, emb_w], axis=1)
45
+
46
+
47
+ def get_2d_sincos_pos_embed(embed_dim: int, grid_size: int, cls_token: bool = False) -> np.ndarray:
48
+ grid_h = np.arange(grid_size, dtype=np.float32)
49
+ grid_w = np.arange(grid_size, dtype=np.float32)
50
+ grid = np.meshgrid(grid_w, grid_h)
51
+ grid = np.stack(grid, axis=0)
52
+ grid = grid.reshape([2, 1, grid_size, grid_size])
53
+ pos_embed = _get_2d_sincos_pos_embed_from_grid(embed_dim, grid)
54
+ if cls_token:
55
+ pos_embed = np.concatenate([np.zeros([1, embed_dim]), pos_embed], axis=0)
56
+ return pos_embed
57
+
58
+
59
+ def unpatchify(pred_patches: torch.Tensor, patch_size: int, in_chans: int) -> torch.Tensor:
60
+ """Convert patch predictions (B, L, p*p*C) to images (B, C, H, W)."""
61
+ h = w = int(pred_patches.shape[1] ** 0.5)
62
+ assert h * w == pred_patches.shape[1], "number of patches must be a square"
63
+ x = pred_patches.reshape(pred_patches.shape[0], h, w, patch_size, patch_size, in_chans)
64
+ x = torch.einsum("nhwpqc->nchpwq", x)
65
+ return x.reshape(pred_patches.shape[0], in_chans, h * patch_size, h * patch_size)
66
+
67
+
68
+ class ViTEncoder(nn.Module):
69
+ """PatchEmbed + transformer blocks + fixed sin-cos positional embeddings."""
70
+
71
+ def __init__(
72
+ self,
73
+ img_size: int,
74
+ patch_size: int,
75
+ in_chans: int,
76
+ embed_dim: int,
77
+ depth: int,
78
+ num_heads: int,
79
+ mlp_ratio: float,
80
+ norm_layer: type[nn.Module] = nn.LayerNorm,
81
+ ):
82
+ super().__init__()
83
+ self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, embed_dim)
84
+ num_patches = self.patch_embed.num_patches
85
+
86
+ self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
87
+ self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim), requires_grad=False)
88
+ self.blocks = nn.ModuleList(
89
+ [Block(embed_dim, num_heads, mlp_ratio, qkv_bias=True, norm_layer=norm_layer) for _ in range(depth)]
90
+ )
91
+ self.norm = norm_layer(embed_dim)
92
+
93
+ self._initialize_weights()
94
+
95
+ def _initialize_weights(self) -> None:
96
+ pos_embed = get_2d_sincos_pos_embed(
97
+ self.pos_embed.shape[-1], int(self.patch_embed.num_patches**0.5), cls_token=True
98
+ )
99
+ self.pos_embed.data.copy_(torch.from_numpy(pos_embed).float().unsqueeze(0))
100
+
101
+ w = self.patch_embed.proj.weight.data
102
+ torch.nn.init.xavier_uniform_(w.view([w.shape[0], -1]))
103
+ torch.nn.init.normal_(self.cls_token, std=0.02)
104
+ self.apply(self._init_weights)
105
+
106
+ @staticmethod
107
+ def _init_weights(m: nn.Module) -> None:
108
+ if isinstance(m, nn.Linear):
109
+ torch.nn.init.xavier_uniform_(m.weight)
110
+ if m.bias is not None:
111
+ nn.init.constant_(m.bias, 0)
112
+ elif isinstance(m, nn.LayerNorm):
113
+ nn.init.constant_(m.bias, 0)
114
+ nn.init.constant_(m.weight, 1.0)
115
+
116
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
117
+ x = self.patch_embed(x)
118
+ cls_tokens = self.cls_token.expand(x.shape[0], -1, -1)
119
+ x = torch.cat((cls_tokens, x), dim=1)
120
+ x = x + self.pos_embed
121
+ for blk in self.blocks:
122
+ x = blk(x)
123
+ return self.norm(x)
124
+
125
+
126
+ class ViTDecoder(nn.Module):
127
+ """Latent-conditioned transformer decoder that predicts image patches."""
128
+
129
+ def __init__(
130
+ self,
131
+ img_size: int,
132
+ patch_size: int,
133
+ in_chans: int,
134
+ latent_dim: int,
135
+ embed_dim: int,
136
+ depth: int,
137
+ num_heads: int,
138
+ mlp_ratio: float,
139
+ norm_layer: type[nn.Module] = nn.LayerNorm,
140
+ ):
141
+ super().__init__()
142
+ self.patch_size = patch_size
143
+ self.in_chans = in_chans
144
+ self.num_patches = (img_size // patch_size) ** 2
145
+
146
+ self.z_token = nn.Linear(latent_dim, embed_dim)
147
+ self.patch_tokens = nn.Parameter(torch.zeros(1, self.num_patches, embed_dim))
148
+ self.pos_embed = nn.Parameter(torch.zeros(1, self.num_patches, embed_dim), requires_grad=False)
149
+
150
+ self.blocks = nn.ModuleList(
151
+ [Block(embed_dim, num_heads, mlp_ratio, qkv_bias=True, norm_layer=norm_layer) for _ in range(depth)]
152
+ )
153
+ self.norm = norm_layer(embed_dim)
154
+ self.pred = nn.Linear(embed_dim, patch_size * patch_size * in_chans)
155
+
156
+ self._initialize_weights()
157
+
158
+ def _initialize_weights(self) -> None:
159
+ pos_embed = get_2d_sincos_pos_embed(self.pos_embed.shape[-1], int(self.num_patches**0.5), cls_token=False)
160
+ self.pos_embed.data.copy_(torch.from_numpy(pos_embed).float().unsqueeze(0))
161
+ torch.nn.init.normal_(self.patch_tokens, std=0.02)
162
+ self.apply(ViTEncoder._init_weights)
163
+
164
+ def forward(self, z: torch.Tensor) -> torch.Tensor:
165
+ b = z.shape[0]
166
+ ztok = self.z_token(z).unsqueeze(1)
167
+ ptok = self.patch_tokens.expand(b, -1, -1)
168
+ x = ztok + ptok
169
+ x = x + self.pos_embed
170
+ for blk in self.blocks:
171
+ x = blk(x)
172
+ x = self.norm(x)
173
+ return self.pred(x)
174
+
175
+
176
+ class TactileVAE(nn.Module):
177
+ """Regular ViT-based VAE for tactile image reconstruction."""
178
+
179
+ def __init__(
180
+ self,
181
+ img_size: int = 128,
182
+ patch_size: int = 16,
183
+ in_chans: int = 3,
184
+ embed_dim: int = 256,
185
+ encoder_depth: int = 4,
186
+ encoder_heads: int = 8,
187
+ decoder_embed_dim: int = 192,
188
+ decoder_depth: int = 4,
189
+ decoder_heads: int = 8,
190
+ mlp_ratio: float = 4.0,
191
+ latent_dim: int = 128,
192
+ ):
193
+ super().__init__()
194
+ self.patch_size = patch_size
195
+ self.in_chans = in_chans
196
+
197
+ self.encoder = ViTEncoder(
198
+ img_size=img_size,
199
+ patch_size=patch_size,
200
+ in_chans=in_chans,
201
+ embed_dim=embed_dim,
202
+ depth=encoder_depth,
203
+ num_heads=encoder_heads,
204
+ mlp_ratio=mlp_ratio,
205
+ )
206
+
207
+ self.mu_head = nn.Linear(embed_dim, latent_dim)
208
+ self.logvar_head = nn.Linear(embed_dim, latent_dim)
209
+
210
+ self.decoder = ViTDecoder(
211
+ img_size=img_size,
212
+ patch_size=patch_size,
213
+ in_chans=in_chans,
214
+ latent_dim=latent_dim,
215
+ embed_dim=decoder_embed_dim,
216
+ depth=decoder_depth,
217
+ num_heads=decoder_heads,
218
+ mlp_ratio=mlp_ratio,
219
+ )
220
+
221
+ def encode(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, dict[str, torch.Tensor]]:
222
+ enc_tokens = self.encoder(x)
223
+ cls = enc_tokens[:, 0]
224
+ mu = self.mu_head(cls)
225
+ logvar = self.logvar_head(cls)
226
+ return mu, logvar, {"enc_tokens": enc_tokens}
227
+
228
+ @staticmethod
229
+ def reparameterize(mu: torch.Tensor, logvar: torch.Tensor) -> torch.Tensor:
230
+ std = torch.exp(0.5 * logvar)
231
+ eps = torch.randn_like(std)
232
+ return mu + eps * std
233
+
234
+ def decode(self, z: torch.Tensor, enc_ctx: Optional[dict[str, torch.Tensor]] = None) -> torch.Tensor:
235
+ del enc_ctx # decoder is latent-conditioned only for regular VAE.
236
+ pred_patches = self.decoder(z)
237
+ return unpatchify(pred_patches, patch_size=self.patch_size, in_chans=self.in_chans)
238
+
239
+ def reconstruct(self, x: torch.Tensor, use_mean: bool = True) -> torch.Tensor:
240
+ mu, logvar, enc_ctx = self.encode(x)
241
+ z = mu if use_mean else self.reparameterize(mu, logvar)
242
+ return self.decode(z, enc_ctx=enc_ctx)
243
+
244
+ def forward(self, x: torch.Tensor, sample: bool = True) -> dict[str, torch.Tensor]:
245
+ mu, logvar, enc_ctx = self.encode(x)
246
+ z = self.reparameterize(mu, logvar) if sample else mu
247
+ pred_patches = self.decoder(z)
248
+ x_hat = unpatchify(pred_patches, patch_size=self.patch_size, in_chans=self.in_chans)
249
+ return {
250
+ "x_hat": x_hat,
251
+ "mu": mu,
252
+ "logvar": logvar,
253
+ "z": z,
254
+ "pred_patches": pred_patches,
255
+ "enc_ctx": enc_ctx,
256
+ }
257
+
258
+
259
+ class SSIMLoss(nn.Module):
260
+ """Simple differentiable SSIM loss (1 - SSIM mean)."""
261
+
262
+ def __init__(self, window_size: int = 11, channels: int = 3):
263
+ super().__init__()
264
+ self.window_size = window_size
265
+ self.channels = channels
266
+ self.padding = window_size // 2
267
+ self.register_buffer(
268
+ "kernel",
269
+ torch.ones((channels, 1, window_size, window_size), dtype=torch.float32)
270
+ / (window_size * window_size),
271
+ persistent=False,
272
+ )
273
+
274
+ def _filter(self, x: torch.Tensor) -> torch.Tensor:
275
+ if x.shape[1] == self.channels:
276
+ kernel = self.kernel.to(device=x.device, dtype=x.dtype)
277
+ else:
278
+ kernel = torch.ones(
279
+ (x.shape[1], 1, self.window_size, self.window_size),
280
+ device=x.device,
281
+ dtype=x.dtype,
282
+ ) / (self.window_size * self.window_size)
283
+ return F.conv2d(x, kernel, padding=self.padding, groups=x.shape[1])
284
+
285
+ def forward(self, x_hat: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
286
+ c1 = 0.01**2
287
+ c2 = 0.03**2
288
+ mu_x = self._filter(x)
289
+ mu_y = self._filter(x_hat)
290
+ sigma_x = self._filter(x * x) - mu_x * mu_x
291
+ sigma_y = self._filter(x_hat * x_hat) - mu_y * mu_y
292
+ sigma_xy = self._filter(x * x_hat) - mu_x * mu_y
293
+ ssim_map = ((2 * mu_x * mu_y + c1) * (2 * sigma_xy + c2)) / (
294
+ (mu_x * mu_x + mu_y * mu_y + c1) * (sigma_x + sigma_y + c2) + 1e-8
295
+ )
296
+ return 1.0 - ssim_map.mean()
297
+
298
+
299
+ class VAELoss(nn.Module):
300
+ """Regular VAE loss: reconstruction + beta * KL."""
301
+
302
+ def __init__(
303
+ self,
304
+ beta: float = 1.0,
305
+ recon_type: str = "l1",
306
+ ssim_weight: float = 0.0,
307
+ ssim_window_size: int = 11,
308
+ ):
309
+ super().__init__()
310
+ if recon_type not in {"l1", "mse"}:
311
+ raise ValueError(f"recon_type must be 'l1' or 'mse', got: {recon_type}")
312
+ self.beta = beta
313
+ self.recon_type = recon_type
314
+ self.ssim_weight = ssim_weight
315
+ self.ssim_loss = SSIMLoss(window_size=ssim_window_size)
316
+
317
+ def forward(
318
+ self,
319
+ x_hat: torch.Tensor,
320
+ x: torch.Tensor,
321
+ mu: torch.Tensor,
322
+ logvar: torch.Tensor,
323
+ ) -> dict[str, torch.Tensor]:
324
+ recon = F.l1_loss(x_hat, x) if self.recon_type == "l1" else F.mse_loss(x_hat, x)
325
+ ssim_term = self.ssim_loss(x_hat, x) if self.ssim_weight > 0 else x_hat.new_zeros(())
326
+ recon_total = recon + self.ssim_weight * ssim_term
327
+ kl = -0.5 * torch.mean(1 + logvar - mu.pow(2) - logvar.exp())
328
+ total = recon_total + self.beta * kl
329
+ return {
330
+ "total": total,
331
+ "recon": recon,
332
+ "ssim": ssim_term,
333
+ "recon_total": recon_total,
334
+ "kl": kl,
335
+ }
336
+
337
+
338
+ class BetaVAELoss(VAELoss):
339
+ """Backward-compatible alias for VAELoss."""
340
+
341
+
342
+ class TactileVAEWrapper(nn.Module):
343
+ """Inference wrapper around TactileVAE with a mode-based interface.
344
+
345
+ Usage:
346
+ wrapper = TactileVAEWrapper(ckpt_path, device)
347
+ z = wrapper(x, mode="encode") # (B, latent_dim)
348
+ x_hat = wrapper(z, mode="decode") # (B, C, H, W)
349
+ Or equivalently:
350
+ z = wrapper.encode(x)
351
+ x_hat = wrapper.decode(z)
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ ckpt_path: str | Path,
357
+ device: str | torch.device = "cpu",
358
+ model_kwargs: Optional[dict[str, Any]] = None,
359
+ ):
360
+ super().__init__()
361
+ self.device = torch.device(device)
362
+ self.vae = self._load(ckpt_path, model_kwargs or {})
363
+
364
+ def _load(self, ckpt_path: str | Path, model_kwargs: dict) -> TactileVAE:
365
+ ckpt_path = Path(ckpt_path)
366
+ if not ckpt_path.exists():
367
+ raise FileNotFoundError(f"TactileVAE checkpoint not found: {ckpt_path}")
368
+ state = torch.load(str(ckpt_path), map_location=self.device, weights_only=False)
369
+ state_dict = _unwrap_state(state)
370
+ vae = TactileVAE(**model_kwargs)
371
+ vae.load_state_dict(state_dict, strict=True)
372
+ vae.eval().to(self.device)
373
+ vae.requires_grad_(False)
374
+ return vae
375
+
376
+ def to(self, device):
377
+ super().to(device)
378
+ self.device = torch.device(device)
379
+ self.vae = self.vae.to(device)
380
+ return self
381
+
382
+ @torch.no_grad()
383
+ def encode(self, x: torch.Tensor) -> torch.Tensor:
384
+ """x: (B, C, H, W) float in [-1, 1]. Returns z: (B, latent_dim) using mu."""
385
+ mu, _logvar, _ctx = self.vae.encode(x.to(self.device))
386
+ return mu
387
+
388
+ @torch.no_grad()
389
+ def decode(self, z: torch.Tensor) -> torch.Tensor:
390
+ """z: (B, latent_dim). Returns x_hat: (B, C, H, W) float in [-1, 1]."""
391
+ return self.vae.decode(z.to(self.device))
392
+
393
+ @torch.no_grad()
394
+ def forward(self, x: torch.Tensor, mode: str) -> torch.Tensor:
395
+ if mode == "encode":
396
+ return self.encode(x)
397
+ if mode == "decode":
398
+ return self.decode(x)
399
+ raise ValueError(f"mode must be 'encode' or 'decode', got {mode!r}")
400
+
401
+
402
+ def _resolve_checkpoint(checkpoint: Optional[str | Path], vae_dir: str | Path) -> Path:
403
+ if checkpoint is None:
404
+ return Path(vae_dir) / DEFAULT_CHECKPOINT_NAME
405
+ p = Path(checkpoint)
406
+ return p if p.is_absolute() else Path(vae_dir) / p
407
+
408
+
409
+ def _unwrap_state(state: Any) -> dict[str, torch.Tensor]:
410
+ if isinstance(state, dict):
411
+ if "state_dict" in state and isinstance(state["state_dict"], dict):
412
+ return state["state_dict"]
413
+ if "model" in state and isinstance(state["model"], dict):
414
+ return state["model"]
415
+ return state
416
+ raise TypeError(f"Unsupported checkpoint payload type: {type(state)!r}")
417
+
418
+
419
+ def load_pretrained(
420
+ checkpoint: Optional[str | Path] = None,
421
+ vae_dir: str | Path = DEFAULT_TACTILE_VAE_DIR,
422
+ map_location: str | torch.device = "cpu",
423
+ freeze: bool = True,
424
+ strict: bool = True,
425
+ model_kwargs: Optional[dict[str, Any]] = None,
426
+ ) -> TactileVAE:
427
+ ckpt_path = _resolve_checkpoint(checkpoint, vae_dir)
428
+ if not ckpt_path.exists():
429
+ raise FileNotFoundError(f"Tactile VAE checkpoint not found at: {ckpt_path}")
430
+
431
+ state = torch.load(str(ckpt_path), map_location=map_location)
432
+ state_dict = _unwrap_state(state)
433
+
434
+ model = TactileVAE(**(model_kwargs or {}))
435
+ model.load_state_dict(state_dict, strict=strict)
436
+
437
+ if freeze:
438
+ model.eval()
439
+ for p in model.parameters():
440
+ p.requires_grad_(False)
441
+ return model
442
+
443
+
444
+ if __name__ == "__main__":
445
+ # Architecture roundtrip test (no real checkpoint required).
446
+ # To test with a real checkpoint, set CKPT_PATH below.
447
+ _CKPT_PATH = Path("/group2/ct/weihanx/tactile_world_model/tactile_wm/pretrained_models/ckpt_best.pt")
448
+ _OUT_DIR = Path("/group2/ct/weihanx/tactile_world_model/tactile_vae/test_output")
449
+ _EPISODE_PATH = Path("/group2/ct/weihanx/tactile_world_model/mode1_v1/0323_episode_000.pt")
450
+
451
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
452
+ print(f"device: {device}")
453
+
454
+ # ── 1. Architecture test with random weights ─────────────────────────────
455
+ print("\n[1] Architecture roundtrip with random weights")
456
+ vae = TactileVAE().eval().to(device)
457
+ x_rand = torch.randn(2, 3, 128, 128, device=device)
458
+ with torch.no_grad():
459
+ out = vae(x_rand)
460
+ print(f" input: {tuple(x_rand.shape)}")
461
+ print(f" z: {tuple(out['z'].shape)}")
462
+ print(f" x_hat: {tuple(out['x_hat'].shape)}")
463
+
464
+ # Separate encode → decode
465
+ with torch.no_grad():
466
+ mu, logvar, _ = vae.encode(x_rand)
467
+ x_hat2 = vae.decode(mu)
468
+ print(f" encode → z (mu): {tuple(mu.shape)}")
469
+ print(f" decode → x_hat: {tuple(x_hat2.shape)}")
470
+
471
+ # ── 2. TactileVAEWrapper with real checkpoint (if available) ──────────────
472
+ print(f"\n[2] TactileVAEWrapper from checkpoint: {_CKPT_PATH}")
473
+ if not _CKPT_PATH.exists():
474
+ print(" checkpoint not found — skipping pretrained test")
475
+ else:
476
+ ep = torch.load(str(_EPISODE_PATH), map_location="cpu", weights_only=False)
477
+ views = ep["view"] # (T, 3, H, W) uint8
478
+ sample_indices = [0, 100, 500, 1000, 2000]
479
+ frames_u8 = views[sample_indices] # (N, 3, H, W)
480
+ frames = frames_u8.float() / 127.5 - 1.0 # [-1, 1]
481
+
482
+ wrapper = TactileVAEWrapper(str(_CKPT_PATH), device=device)
483
+ z = wrapper.encode(frames)
484
+ x_hat = wrapper.decode(z)
485
+ print(f" frames: {tuple(frames.shape)} z: {tuple(z.shape)} x_hat: {tuple(x_hat.shape)}")
486
+
487
+ _OUT_DIR.mkdir(parents=True, exist_ok=True)
488
+ for i, idx in enumerate(sample_indices):
489
+ orig_np = ((frames[i].permute(1, 2, 0).clamp(-1, 1) + 1) * 127.5).byte().numpy()
490
+ recon_np = ((x_hat[i].permute(1, 2, 0).clamp(-1, 1) + 1) * 127.5).byte().cpu().numpy()
491
+ diff_np = (np.abs(orig_np.astype(int) - recon_np.astype(int))).astype(np.uint8)
492
+ h, w = orig_np.shape[:2]
493
+ panel = Image.new("RGB", (3 * w + 16, h), (20, 20, 20))
494
+ panel.paste(Image.fromarray(orig_np), (0, 0))
495
+ panel.paste(Image.fromarray(recon_np), (w + 8, 0))
496
+ panel.paste(Image.fromarray(diff_np), (2 * w + 16, 0))
497
+ panel.save(_OUT_DIR / f"vae_frame_{idx:05d}_panel.png")
498
+ mse = float(((orig_np.astype(float) - recon_np.astype(float)) ** 2).mean())
499
+ psnr = 10 * np.log10(255.0 ** 2 / mse) if mse > 0 else float("inf")
500
+ print(f" frame {idx:5d} PSNR={psnr:.2f} dB")
501
+ print(f" saved panels to {_OUT_DIR}")
502
+
503
+ print("\nAll tests passed.")
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tactile_vae"
7
+ version = "0.0.1"
8
+ description = "ViT-based tactile variational autoencoder"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "torch>=2.1",
12
+ "torchvision>=0.16",
13
+ "timm>=1.0",
14
+ "numpy",
15
+ ]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["."]
19
+ include = ["tactile_vae*"]
20
+ exclude = ["data*"]
script/inference.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """General Lightning-based inference script for TactileVAE.
2
+
3
+ Features:
4
+ - Load any Lightning `.ckpt` checkpoint.
5
+ - Load any config YAML.
6
+ - Randomly select `N` samples from any split (`train` / `val` / `test`).
7
+ - Run reconstruction inference and save metrics + visualization.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import pytorch_lightning as pl
19
+ import torch
20
+ import yaml
21
+ from PIL import Image
22
+ from torch.utils.data import DataLoader, Subset
23
+
24
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
25
+ if str(_REPO_ROOT) not in sys.path:
26
+ sys.path.insert(0, str(_REPO_ROOT))
27
+
28
+ from tactile_vae.dataset import TactileParquetDataset
29
+ from tactile_vae.model import TactileVAE
30
+
31
+ DEFAULT_CONFIG = Path("/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_3/config.snapshot.yaml")
32
+ DEFAULT_CKPT = Path("/group2/ct/weihanx/tactile_world_model/runs/vae_baseline_3/checkpoints/last.ckpt")
33
+ DEFAULT_OUT_DIR = Path("/group2/ct/weihanx/tactile_world_model/tactile_vae/inference/vae_baseline_3")
34
+
35
+
36
+ def _resolve_path(p: str | Path) -> Path:
37
+ path = Path(p)
38
+ return path if path.is_absolute() else (_REPO_ROOT / path).resolve()
39
+
40
+
41
+ def load_config(path: Path) -> dict:
42
+ with path.open() as f:
43
+ cfg = yaml.safe_load(f)
44
+ if not isinstance(cfg, dict):
45
+ raise ValueError(f"invalid config: {path}")
46
+ cfg["data"]["root"] = str(_resolve_path(cfg["data"]["root"]))
47
+ if cfg["data"].get("splits_path"):
48
+ cfg["data"]["splits_path"] = str(_resolve_path(cfg["data"]["splits_path"]))
49
+ return cfg
50
+
51
+
52
+ def pick_device(spec: str) -> torch.device:
53
+ if spec == "auto":
54
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
55
+ return torch.device(spec)
56
+
57
+
58
+ class InferenceModule(pl.LightningModule):
59
+ """Minimal LightningModule used for strict Lightning checkpoint loading."""
60
+
61
+ def __init__(self, config: dict):
62
+ super().__init__()
63
+ self.config = config
64
+ self.model = TactileVAE(**config["model"])
65
+
66
+ def forward(self, x, **kw):
67
+ return self.model(x, **kw)
68
+
69
+
70
+ def parse_args() -> argparse.Namespace:
71
+ p = argparse.ArgumentParser()
72
+ p.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help="config yaml")
73
+ p.add_argument("--ckpt", type=Path, default=DEFAULT_CKPT, help="Lightning checkpoint .ckpt")
74
+ p.add_argument("--out-dir", type=Path, default=DEFAULT_OUT_DIR, help="output directory")
75
+ p.add_argument("--split", type=str, default="test", choices=["train", "val", "test"])
76
+ p.add_argument("--num-samples", type=int, default=50, help="number of random samples from the split")
77
+ p.add_argument("--batch-size", type=int, default=16)
78
+ p.add_argument("--num-workers", type=int, default=0)
79
+ p.add_argument("--seed", type=int, default=0)
80
+ p.add_argument("--device", type=str, default="auto", help="auto / cuda / cpu / cuda:0 ...")
81
+ p.add_argument("--max-grid", type=int, default=16, help="max samples shown in saved reconstruction grid")
82
+ return p.parse_args()
83
+
84
+
85
+ def build_dataset(cfg: dict, split: str) -> TactileParquetDataset:
86
+ dcfg = cfg["data"]
87
+ return TactileParquetDataset(
88
+ root=dcfg["root"],
89
+ split=split,
90
+ splits_path=dcfg.get("splits_path"),
91
+ image_size=dcfg["image_size"],
92
+ cache_files=dcfg.get("cache_files", 1),
93
+ color_jitter=None,
94
+ )
95
+
96
+
97
+ def select_subset(ds: TactileParquetDataset, n: int, seed: int) -> tuple[Subset, list[int]]:
98
+ n = min(max(1, int(n)), len(ds))
99
+ rng = np.random.default_rng(seed)
100
+ idx = rng.choice(len(ds), size=n, replace=False).tolist()
101
+ return Subset(ds, idx), idx
102
+
103
+
104
+ @torch.no_grad()
105
+ def run_inference(
106
+ module: InferenceModule,
107
+ ds: TactileParquetDataset,
108
+ subset_idx: list[int],
109
+ loader: DataLoader,
110
+ device: torch.device,
111
+ ) -> tuple[list[dict[str, Any]], float, float, list[tuple[torch.Tensor, torch.Tensor]]]:
112
+ module.eval().to(device)
113
+ per_sample: list[dict[str, Any]] = []
114
+ vis_pairs: list[tuple[torch.Tensor, torch.Tensor]] = []
115
+ mae_total = 0.0
116
+ mse_total = 0.0
117
+ n_total = 0
118
+
119
+ cursor = 0
120
+ for x in loader:
121
+ x = x.to(device, non_blocking=True)
122
+ out = module.model(x, sample=False)
123
+ x_hat = out["x_hat"]
124
+
125
+ abs_err = (x - x_hat).abs().mean(dim=(1, 2, 3))
126
+ sq_err = ((x - x_hat) ** 2).mean(dim=(1, 2, 3))
127
+ bs = x.shape[0]
128
+
129
+ for i in range(bs):
130
+ gidx = subset_idx[cursor + i]
131
+ sample_id = ds.sample_id(gidx)
132
+ mae_i = float(abs_err[i].item())
133
+ mse_i = float(sq_err[i].item())
134
+ per_sample.append(
135
+ {
136
+ "subset_rank": cursor + i,
137
+ "dataset_index": int(gidx),
138
+ "sample_id": sample_id,
139
+ "mae": mae_i,
140
+ "mse": mse_i,
141
+ }
142
+ )
143
+ vis_pairs.append((x[i].detach().cpu(), x_hat[i].detach().cpu()))
144
+ mae_total += mae_i
145
+ mse_total += mse_i
146
+ n_total += 1
147
+ cursor += bs
148
+
149
+ mae_mean = mae_total / max(1, n_total)
150
+ mse_mean = mse_total / max(1, n_total)
151
+ return per_sample, mae_mean, mse_mean, vis_pairs
152
+
153
+
154
+ def save_grid(pairs: list[tuple[torch.Tensor, torch.Tensor]], out_path: Path, n_show: int, image_size: int) -> None:
155
+ n = min(n_show, len(pairs))
156
+ if n <= 0:
157
+ return
158
+ h = w = int(image_size)
159
+ canvas = np.zeros((2 * h, n * w, 3), dtype=np.uint8)
160
+ for i in range(n):
161
+ src, rec = pairs[i]
162
+ src_np = (src.clamp(0, 1).permute(1, 2, 0).numpy() * 255).astype(np.uint8)
163
+ rec_np = (rec.clamp(0, 1).permute(1, 2, 0).numpy() * 255).astype(np.uint8)
164
+ canvas[:h, i * w : (i + 1) * w] = src_np
165
+ canvas[h:, i * w : (i + 1) * w] = rec_np
166
+ out_path.parent.mkdir(parents=True, exist_ok=True)
167
+ Image.fromarray(canvas).save(out_path)
168
+
169
+
170
+ def main() -> None:
171
+ args = parse_args()
172
+ cfg = load_config(args.config)
173
+ device = pick_device(args.device)
174
+
175
+ args.out_dir.mkdir(parents=True, exist_ok=True)
176
+ print(f"config: {args.config}")
177
+ print(f"ckpt: {args.ckpt}")
178
+ print(f"split: {args.split}")
179
+ print(f"num_samples: {args.num_samples}")
180
+ print(f"device: {device}")
181
+ print(f"out_dir: {args.out_dir}")
182
+
183
+ ds = build_dataset(cfg, split=args.split)
184
+ subset, subset_idx = select_subset(ds, args.num_samples, args.seed)
185
+ print(f"split_size={len(ds)} selected={len(subset_idx)}")
186
+ print(f"preview_sample_ids={[ds.sample_id(i) for i in subset_idx[:5]]}")
187
+
188
+ loader = DataLoader(
189
+ subset,
190
+ batch_size=min(max(1, args.batch_size), len(subset)),
191
+ shuffle=False,
192
+ num_workers=args.num_workers,
193
+ pin_memory=device.type == "cuda",
194
+ drop_last=False,
195
+ persistent_workers=args.num_workers > 0,
196
+ )
197
+
198
+ module = InferenceModule.load_from_checkpoint(
199
+ str(args.ckpt),
200
+ config=cfg,
201
+ strict=True,
202
+ map_location="cpu",
203
+ )
204
+
205
+ per_sample, mae_mean, mse_mean, vis_pairs = run_inference(
206
+ module=module, ds=ds, subset_idx=subset_idx, loader=loader, device=device
207
+ )
208
+
209
+ grid_path = args.out_dir / "reconstruction_grid.png"
210
+ save_grid(vis_pairs, out_path=grid_path, n_show=args.max_grid, image_size=cfg["data"]["image_size"])
211
+
212
+ summary = {
213
+ "config": str(args.config),
214
+ "checkpoint": str(args.ckpt),
215
+ "split": args.split,
216
+ "seed": args.seed,
217
+ "selected_num_samples": len(subset_idx),
218
+ "mean_mae": mae_mean,
219
+ "mean_mse": mse_mean,
220
+ "grid_path": str(grid_path),
221
+ }
222
+ with (args.out_dir / "summary.json").open("w") as f:
223
+ json.dump(summary, f, indent=2)
224
+ with (args.out_dir / "per_sample_metrics.json").open("w") as f:
225
+ json.dump(per_sample, f, indent=2)
226
+
227
+ print(f"mean_mae={mae_mean:.6f} mean_mse={mse_mean:.6f}")
228
+ print(f"saved: {args.out_dir / 'summary.json'}")
229
+ print(f"saved: {args.out_dir / 'per_sample_metrics.json'}")
230
+ print(f"saved: {grid_path}")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
script/train_vae.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train TactileVAE on the fota_unlabeled parquet dataset.
2
+
3
+ Run:
4
+ python tactile_vae/script/train_vae.py --config tactile_vae/config/train_vae.yaml
5
+
6
+ Each run lives in `<runs_root>/<run_id>/`. Re-launching with the same
7
+ `run_id` auto-resumes from `ckpt_last.pt` in that directory (override with
8
+ `--no-resume`, or `--resume-from <path>` to resume from a specific file).
9
+
10
+ Writes into `<runs_root>/<run_id>/`:
11
+ - metrics.csv per-step training + per-eval validation metrics
12
+ - samples/step_*.png input vs. reconstruction grid (val-set images)
13
+ - ckpt_last.pt most recent checkpoint
14
+ - ckpt_step_*.pt periodic checkpoints (rotated; keep_last_ckpts)
15
+ - ckpt_best.pt lowest monitored validation metric (default: val/total)
16
+ - run.log stdout mirror
17
+ - config.snapshot.yaml the resolved config (first launch — preserved on resume)
18
+
19
+ Checkpoints are saved as:
20
+ {"state_dict": ..., "optimizer": ..., "scaler": ..., "scheduler": ...,
21
+ "step": int, "epoch": int, "config": dict, "best_val_recon": float}
22
+ which `tactile_vae.model.load_pretrained` can re-open via its `state_dict` key.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import csv
28
+ import datetime as dt
29
+ import math
30
+ import os
31
+ import random
32
+ import signal
33
+ import sys
34
+ import time
35
+ from collections import deque
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ import numpy as np
41
+ import torch
42
+ import torch.nn as nn
43
+ import yaml
44
+ from PIL import Image
45
+ from torch.utils.data import DataLoader
46
+ import torch.nn.functional as F
47
+
48
+ try:
49
+ import wandb # optional — only imported if WANDB_PROJECT is set in env.
50
+ except ImportError: # pragma: no cover - wandb is optional
51
+ wandb = None # type: ignore[assignment]
52
+
53
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
54
+ if str(_REPO_ROOT) not in sys.path:
55
+ sys.path.insert(0, str(_REPO_ROOT))
56
+
57
+ from tactile_vae.dataset import (
58
+ ColorJitterConfig,
59
+ ParquetFileShuffleSampler,
60
+ TactileParquetDataset,
61
+ )
62
+ from tactile_vae.model import TactileVAE, VAELoss
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Utility: config loading, path resolution, logging
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def _resolve_path(p: str | None) -> Path | None:
70
+ if p is None:
71
+ return None
72
+ path = Path(p)
73
+ return path if path.is_absolute() else (_REPO_ROOT / path).resolve()
74
+
75
+
76
+ def _autogenerate_run_id() -> str:
77
+ return "run_" + dt.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
78
+
79
+
80
+ def load_config(path: Path) -> dict:
81
+ with path.open() as f:
82
+ cfg = yaml.safe_load(f)
83
+
84
+ # `run_id` is the run's identity; auto-generate if missing.
85
+ if not cfg.get("run_id"):
86
+ cfg["run_id"] = _autogenerate_run_id()
87
+
88
+ # Derived `output_dir` = <runs_root>/<run_id>. An explicit `output_dir`
89
+ # in the YAML (legacy) is honored verbatim.
90
+ if cfg.get("output_dir"):
91
+ cfg["output_dir"] = str(_resolve_path(cfg["output_dir"]))
92
+ else:
93
+ runs_root = _resolve_path(cfg.get("runs_root", "runs"))
94
+ cfg["output_dir"] = str(runs_root / cfg["run_id"])
95
+
96
+ cfg["data"]["root"] = str(_resolve_path(cfg["data"]["root"]))
97
+ if cfg["data"].get("splits_path"):
98
+ cfg["data"]["splits_path"] = str(_resolve_path(cfg["data"]["splits_path"]))
99
+ if cfg["train"].get("resume_from"):
100
+ cfg["train"]["resume_from"] = str(_resolve_path(cfg["train"]["resume_from"]))
101
+ return cfg
102
+
103
+
104
+ def _maybe_autoresume(cfg: dict, *, allow_autoresume: bool) -> None:
105
+ """If the run dir already has ckpt_last.pt and the user didn't pin
106
+ `resume_from`, auto-resume from it. Mutates `cfg["train"]` in place."""
107
+ if cfg["train"].get("resume_from") or not allow_autoresume:
108
+ return
109
+ last = Path(cfg["output_dir"]) / "ckpt_last.pt"
110
+ if last.exists():
111
+ cfg["train"]["resume_from"] = str(last)
112
+
113
+
114
+ def set_seed(seed: int) -> None:
115
+ random.seed(seed)
116
+ np.random.seed(seed)
117
+ torch.manual_seed(seed)
118
+ if torch.cuda.is_available():
119
+ torch.cuda.manual_seed_all(seed)
120
+
121
+
122
+ def pick_device(spec: str) -> torch.device:
123
+ if spec == "auto":
124
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
125
+ return torch.device(spec)
126
+
127
+
128
+ def init_wandb(config: dict, output_dir: Path) -> Any:
129
+ """Initialize a wandb run if `WANDB_PROJECT` is set in the environment.
130
+
131
+ Run id / name default to the training-script `run_id` so that re-launching
132
+ with the same run_id continues the same wandb run (`resume="allow"`).
133
+ Returns the wandb run handle, or None when wandb is unavailable / disabled.
134
+ """
135
+ if wandb is None or not os.environ.get("WANDB_PROJECT"):
136
+ return None
137
+ run = wandb.init(
138
+ project=os.environ["WANDB_PROJECT"],
139
+ entity=os.environ.get("WANDB_ENTITY"),
140
+ id=os.environ.get("WANDB_RUN_ID") or config["run_id"],
141
+ name=os.environ.get("WANDB_NAME") or config["run_id"],
142
+ resume="allow",
143
+ config=config,
144
+ mode=os.environ.get("WANDB_MODE", "online"),
145
+ dir=str(output_dir),
146
+ )
147
+ return run
148
+
149
+
150
+ class TeeLogger:
151
+ """stdout that also appends to a file."""
152
+
153
+ def __init__(self, path: Path):
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ self._fh = path.open("a", buffering=1)
156
+ self._stdout = sys.stdout
157
+
158
+ def write(self, msg: str) -> None:
159
+ self._stdout.write(msg)
160
+ self._fh.write(msg)
161
+
162
+ def flush(self) -> None:
163
+ self._stdout.flush()
164
+ self._fh.flush()
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Data + model + optim builders
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def build_datasets(data_cfg: dict) -> tuple[TactileParquetDataset, TactileParquetDataset]:
172
+ common = dict(
173
+ root=data_cfg["root"],
174
+ image_size=data_cfg["image_size"],
175
+ cache_files=data_cfg.get("cache_files", 1),
176
+ splits_path=data_cfg.get("splits_path"),
177
+ return_meta=data_cfg.get("return_meta", False),
178
+ )
179
+ if data_cfg.get("meta_columns"):
180
+ common["meta_columns"] = data_cfg["meta_columns"]
181
+
182
+ jitter_cfg = data_cfg.get("color_jitter")
183
+ color_jitter = ColorJitterConfig(**jitter_cfg) if jitter_cfg else None
184
+
185
+ train_ds = TactileParquetDataset(split="train", color_jitter=color_jitter, **common)
186
+ val_ds = TactileParquetDataset(split="val", color_jitter=None, **common)
187
+ return train_ds, val_ds
188
+
189
+
190
+ def build_model(model_cfg: dict) -> TactileVAE:
191
+ return TactileVAE(**model_cfg)
192
+
193
+
194
+ class ConfigurablePerceptualVAELoss(nn.Module):
195
+ """VAE loss with configurable perceptual term: SSIM or LPIPS."""
196
+
197
+ def __init__(self, loss_cfg: dict):
198
+ super().__init__()
199
+ self.perceptual_type = str(loss_cfg.get("perceptual_type", "ssim")).lower()
200
+ if self.perceptual_type not in {"ssim", "lpips"}:
201
+ raise ValueError(
202
+ f"loss.perceptual_type must be one of [ssim, lpips], got: {self.perceptual_type!r}"
203
+ )
204
+ self.aux_key = self.perceptual_type
205
+ self.ssim_impl: VAELoss | None = None
206
+ self.lpips_impl: nn.Module | None = None
207
+
208
+ if self.perceptual_type == "ssim":
209
+ self.ssim_impl = VAELoss(**loss_cfg)
210
+ else:
211
+ self.beta = float(loss_cfg.get("beta", 1e-3))
212
+ self.recon_type = str(loss_cfg.get("recon_type", "l1")).lower()
213
+ self.lpips_weight = float(loss_cfg.get("lpips_weight", loss_cfg.get("ssim_weight", 0.1)))
214
+ try:
215
+ import lpips # type: ignore
216
+ except ImportError as exc: # pragma: no cover - depends on runtime env
217
+ raise ImportError(
218
+ "LPIPS loss requested but `lpips` is not installed. "
219
+ "Install with: pip install lpips"
220
+ ) from exc
221
+ self.lpips_impl = lpips.LPIPS(net="alex")
222
+ self.lpips_impl.eval()
223
+ for p in self.lpips_impl.parameters():
224
+ p.requires_grad = False
225
+
226
+ def forward(self, x_hat: torch.Tensor, x: torch.Tensor, mu: torch.Tensor, logvar: torch.Tensor) -> dict[str, torch.Tensor]:
227
+ if self.perceptual_type == "ssim":
228
+ assert self.ssim_impl is not None
229
+ return self.ssim_impl(x_hat, x, mu, logvar)
230
+
231
+ if self.recon_type == "l1":
232
+ recon = F.l1_loss(x_hat, x)
233
+ elif self.recon_type == "mse":
234
+ recon = F.mse_loss(x_hat, x)
235
+ else:
236
+ raise ValueError(f"loss.recon_type must be one of [l1, mse], got: {self.recon_type!r}")
237
+
238
+ # LPIPS expects inputs in [-1, 1].
239
+ with torch.amp.autocast(device_type=x_hat.device.type, enabled=False):
240
+ x_hat_lp = (2.0 * x_hat.float()) - 1.0
241
+ x_lp = (2.0 * x.float()) - 1.0
242
+ assert self.lpips_impl is not None
243
+ lpips_val = self.lpips_impl(x_hat_lp, x_lp).mean()
244
+ recon_total = recon + self.lpips_weight * lpips_val
245
+ kl = (-0.5 * (1 + logvar - mu.pow(2) - logvar.exp())).mean()
246
+ total = recon_total + self.beta * kl
247
+ return {
248
+ "total": total,
249
+ "recon": recon,
250
+ "recon_total": recon_total,
251
+ "lpips": lpips_val,
252
+ "kl": kl,
253
+ }
254
+
255
+
256
+ def build_loss(loss_cfg: dict) -> nn.Module:
257
+ return ConfigurablePerceptualVAELoss(loss_cfg)
258
+
259
+
260
+ def build_optimizer(params, optim_cfg: dict) -> torch.optim.Optimizer:
261
+ return torch.optim.AdamW(
262
+ params,
263
+ lr=optim_cfg["lr"],
264
+ weight_decay=optim_cfg.get("weight_decay", 0.0),
265
+ betas=tuple(optim_cfg.get("betas", (0.9, 0.95))),
266
+ eps=optim_cfg.get("eps", 1e-8),
267
+ )
268
+
269
+
270
+ def lr_at_step(step: int, base_lr: float, total_steps: int, sched_cfg: dict) -> float:
271
+ warmup = int(sched_cfg.get("warmup_steps", 0))
272
+ sched = sched_cfg.get("type", "constant")
273
+ if step < warmup:
274
+ return base_lr * (step + 1) / max(1, warmup)
275
+ if sched == "constant":
276
+ return base_lr
277
+ if sched == "cosine":
278
+ min_ratio = float(sched_cfg.get("min_lr_ratio", 0.1))
279
+ # Cosine from base_lr → base_lr * min_ratio over the remaining steps.
280
+ progress = (step - warmup) / max(1, total_steps - warmup)
281
+ progress = min(max(progress, 0.0), 1.0)
282
+ cos = 0.5 * (1.0 + math.cos(math.pi * progress))
283
+ return base_lr * (min_ratio + (1 - min_ratio) * cos)
284
+ raise ValueError(f"unknown scheduler type: {sched}")
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Training utilities: validation, sampling, checkpoints
289
+ # ---------------------------------------------------------------------------
290
+
291
+ @dataclass
292
+ class MetricAccum:
293
+ sum: float = 0.0
294
+ n: int = 0
295
+
296
+ def add(self, v: float, count: int = 1) -> None:
297
+ self.sum += v * count
298
+ self.n += count
299
+
300
+ def mean(self) -> float:
301
+ return self.sum / self.n if self.n else float("nan")
302
+
303
+
304
+ @torch.no_grad()
305
+ def run_validation(
306
+ model: TactileVAE,
307
+ criterion: nn.Module,
308
+ loader: DataLoader,
309
+ device: torch.device,
310
+ max_batches: int,
311
+ ) -> dict[str, float]:
312
+ model.eval()
313
+ accs: dict[str, MetricAccum] = {}
314
+ for i, batch in enumerate(loader):
315
+ if i >= max_batches:
316
+ break
317
+ x = batch.to(device, non_blocking=True)
318
+ out = model(x, sample=False)
319
+ losses = criterion(out["x_hat"], x, out["mu"], out["logvar"])
320
+ bs = x.shape[0]
321
+ for k, v in losses.items():
322
+ if k not in accs:
323
+ accs[k] = MetricAccum()
324
+ accs[k].add(v.item(), bs)
325
+ model.train()
326
+ return {f"val/{k}": a.mean() for k, a in accs.items()}
327
+
328
+
329
+ def _to_uint8_hwc(t: torch.Tensor) -> np.ndarray:
330
+ arr = t.detach().cpu().clamp(0, 1).permute(1, 2, 0).numpy()
331
+ return (arr * 255).astype(np.uint8)
332
+
333
+
334
+ @torch.no_grad()
335
+ def save_sample_grid(
336
+ model: TactileVAE,
337
+ val_ds: TactileParquetDataset,
338
+ device: torch.device,
339
+ out_path: Path,
340
+ n: int,
341
+ rng_state: np.random.Generator,
342
+ ) -> tuple[list[np.ndarray], list[np.ndarray]]:
343
+ """Sample `n` images from val, run reconstruction, save a top=target/bottom=recon grid.
344
+
345
+ Returns (targets, reconstructions) as lists of HWC uint8 arrays for wandb logging.
346
+ """
347
+ out_path.parent.mkdir(parents=True, exist_ok=True)
348
+ indices = rng_state.choice(len(val_ds), size=n, replace=False).tolist()
349
+ imgs = torch.stack([val_ds[i] for i in indices]).to(device, non_blocking=True)
350
+ model.eval()
351
+ recon = model(imgs, sample=False)["x_hat"]
352
+ model.train()
353
+
354
+ targets = [_to_uint8_hwc(imgs[i]) for i in range(n)]
355
+ recons = [_to_uint8_hwc(recon[i]) for i in range(n)]
356
+
357
+ # Local PNG: top row = target, bottom row = reconstruction.
358
+ h = w = val_ds.image_size
359
+ canvas = np.zeros((2 * h, n * w, 3), dtype=np.uint8)
360
+ for i in range(n):
361
+ canvas[:h, i * w : (i + 1) * w] = targets[i]
362
+ canvas[h:, i * w : (i + 1) * w] = recons[i]
363
+ Image.fromarray(canvas).save(out_path)
364
+
365
+ return targets, recons
366
+
367
+
368
+ def save_checkpoint(
369
+ path: Path,
370
+ *,
371
+ model: nn.Module,
372
+ optimizer: torch.optim.Optimizer,
373
+ scaler: torch.amp.GradScaler | None,
374
+ step: int,
375
+ epoch: int,
376
+ config: dict,
377
+ best_val_metric: float,
378
+ best_metric_name: str,
379
+ ) -> None:
380
+ path.parent.mkdir(parents=True, exist_ok=True)
381
+ payload: dict[str, Any] = {
382
+ "state_dict": model.state_dict(),
383
+ "optimizer": optimizer.state_dict(),
384
+ "step": step,
385
+ "epoch": epoch,
386
+ "config": config,
387
+ "best_val_metric": best_val_metric,
388
+ "best_metric_name": best_metric_name,
389
+ # Backward compatibility for older checkpoints/resume logic.
390
+ "best_val_recon": best_val_metric,
391
+ }
392
+ if scaler is not None:
393
+ payload["scaler"] = scaler.state_dict()
394
+ tmp = path.with_suffix(path.suffix + ".tmp")
395
+ torch.save(payload, tmp)
396
+ os.replace(tmp, path)
397
+
398
+
399
+ def rotate_periodic_ckpts(out_dir: Path, keep: int) -> None:
400
+ ckpts = sorted(out_dir.glob("ckpt_step_*.pt"))
401
+ while len(ckpts) > keep:
402
+ ckpts.pop(0).unlink(missing_ok=True)
403
+
404
+
405
+ # ---------------------------------------------------------------------------
406
+ # Main training loop
407
+ # ---------------------------------------------------------------------------
408
+
409
+ def train(config: dict) -> None:
410
+ set_seed(config["seed"])
411
+ device = pick_device(config["device"])
412
+ out_dir = Path(config["output_dir"])
413
+ out_dir.mkdir(parents=True, exist_ok=True)
414
+
415
+ sys.stdout = TeeLogger(out_dir / "run.log") # type: ignore[assignment]
416
+ print(f"== Tactile VAE training ==")
417
+ print(f"run_id: {config['run_id']} device: {device}")
418
+ print(f"output_dir: {out_dir}")
419
+ if config["train"].get("resume_from"):
420
+ print(f"resume_from: {config['train']['resume_from']}")
421
+
422
+ wandb_run = init_wandb(config, out_dir)
423
+ if wandb_run is not None:
424
+ print(f"wandb: project={os.environ.get('WANDB_PROJECT')} "
425
+ f"run_id={wandb_run.id} url={wandb_run.url}")
426
+ else:
427
+ print("wandb: disabled (set WANDB_PROJECT to enable)")
428
+
429
+ # Snapshot the resolved config on first launch; preserve the original on
430
+ # resume so the config used to start the run isn't silently overwritten.
431
+ snap = out_dir / "config.snapshot.yaml"
432
+ if not snap.exists():
433
+ with snap.open("w") as f:
434
+ yaml.safe_dump(config, f, sort_keys=False)
435
+
436
+ train_ds, val_ds = build_datasets(config["data"])
437
+ print(f"datasets: train={len(train_ds):,} val={len(val_ds):,}")
438
+
439
+ tcfg = config["train"]
440
+ train_sampler = ParquetFileShuffleSampler(train_ds, seed=config["seed"])
441
+ train_loader = DataLoader(
442
+ train_ds,
443
+ batch_size=tcfg["batch_size"],
444
+ sampler=train_sampler,
445
+ num_workers=tcfg["num_workers"],
446
+ pin_memory=device.type == "cuda",
447
+ drop_last=True,
448
+ persistent_workers=tcfg["num_workers"] > 0,
449
+ prefetch_factor=2 if tcfg["num_workers"] > 0 else None,
450
+ )
451
+ val_loader = DataLoader(
452
+ val_ds,
453
+ batch_size=tcfg["batch_size"],
454
+ shuffle=False,
455
+ num_workers=max(2, tcfg["num_workers"] // 2),
456
+ pin_memory=device.type == "cuda",
457
+ drop_last=False,
458
+ )
459
+ steps_per_epoch = len(train_loader)
460
+ total_steps = (
461
+ tcfg["max_steps"]
462
+ if tcfg.get("max_steps")
463
+ else steps_per_epoch * tcfg["epochs"]
464
+ )
465
+ print(f"steps/epoch={steps_per_epoch:,} total_steps={total_steps:,}")
466
+
467
+ model = build_model(config["model"]).to(device)
468
+ criterion = build_loss(config["loss"]).to(device)
469
+ optimizer = build_optimizer(model.parameters(), config["optim"])
470
+
471
+ n_params = sum(p.numel() for p in model.parameters())
472
+ print(f"model: {model.__class__.__name__} params={n_params:,}")
473
+
474
+ use_amp = bool(tcfg.get("amp", False)) and device.type == "cuda"
475
+ amp_dtype_cfg = str(tcfg.get("amp_dtype", "bf16")).lower()
476
+ if amp_dtype_cfg not in {"bf16", "bfloat16"}:
477
+ print(f"[info] overriding train.amp_dtype={amp_dtype_cfg!r} to 'bf16' (enforced)")
478
+ amp_dtype = torch.bfloat16
479
+ if not use_amp:
480
+ amp_dtype = torch.float32
481
+
482
+ # Enforced bf16 path: no GradScaler.
483
+ scaler = None
484
+
485
+ # Graceful shutdown on preemption/cancel: write ckpt_last then exit.
486
+ stop_requested = False
487
+
488
+ def _request_stop(signum: int, _frame) -> None:
489
+ nonlocal stop_requested
490
+ stop_requested = True
491
+ try:
492
+ sig_name = signal.Signals(signum).name
493
+ except ValueError:
494
+ sig_name = str(signum)
495
+ print(f"[signal] received {sig_name}; stopping after current step and saving ckpt_last.pt")
496
+
497
+ prev_sigterm = signal.getsignal(signal.SIGTERM)
498
+ prev_sigint = signal.getsignal(signal.SIGINT)
499
+ signal.signal(signal.SIGTERM, _request_stop)
500
+ signal.signal(signal.SIGINT, _request_stop)
501
+
502
+ # ----- resume ---------------------------------------------------------
503
+ step = 0
504
+ epoch_start = 0
505
+ best_metric_name = str(tcfg.get("best_metric", "val/total"))
506
+ best_val_metric = float("inf")
507
+ if tcfg.get("resume_from"):
508
+ ckpt = torch.load(tcfg["resume_from"], map_location=device)
509
+ model.load_state_dict(ckpt["state_dict"])
510
+ optimizer.load_state_dict(ckpt["optimizer"])
511
+ if scaler is not None and "scaler" in ckpt:
512
+ scaler.load_state_dict(ckpt["scaler"])
513
+ step = int(ckpt.get("step", 0))
514
+ epoch_start = int(ckpt.get("epoch", 0))
515
+ best_val_metric = float(
516
+ ckpt.get("best_val_metric", ckpt.get("best_val_recon", float("inf")))
517
+ )
518
+ best_metric_name = str(ckpt.get("best_metric_name", best_metric_name))
519
+ print(f"resumed from {tcfg['resume_from']} @ step={step} epoch={epoch_start}")
520
+
521
+ # ----- bookkeeping ----------------------------------------------------
522
+ metrics_csv = out_dir / "metrics.csv"
523
+ new_csv = not metrics_csv.exists()
524
+ csv_fh = metrics_csv.open("a", newline="", buffering=1)
525
+ csv_writer = csv.writer(csv_fh)
526
+ if new_csv:
527
+ aux_metric_name = str(getattr(criterion, "aux_key", "ssim"))
528
+ csv_writer.writerow(
529
+ ["step", "epoch", "lr", "split",
530
+ "loss_total", "recon", "recon_total", aux_metric_name, "kl", "throughput"]
531
+ )
532
+
533
+ aux_metric_name = str(getattr(criterion, "aux_key", "ssim"))
534
+ metric_keys = ("total", "recon", "recon_total", aux_metric_name, "kl")
535
+ running = {k: deque(maxlen=tcfg["log_every"]) for k in metric_keys}
536
+ grad_norm_running: deque[float] = deque(maxlen=tcfg["log_every"])
537
+ sample_rng = np.random.default_rng(config["seed"] + 1)
538
+ t_window = time.time()
539
+ samples_in_window = 0
540
+
541
+ base_lr = config["optim"]["lr"]
542
+ model.train()
543
+
544
+ # ----- training loop --------------------------------------------------
545
+ done = False
546
+ try:
547
+ for epoch in range(epoch_start, tcfg["epochs"]):
548
+ train_sampler.set_epoch(epoch)
549
+ epoch_accs = {k: MetricAccum() for k in metric_keys}
550
+ epoch_samples = 0
551
+ for batch in train_loader:
552
+ if step >= total_steps or stop_requested:
553
+ done = True
554
+ break
555
+
556
+ lr = lr_at_step(step, base_lr, total_steps, config["scheduler"])
557
+ for g in optimizer.param_groups:
558
+ g["lr"] = lr
559
+
560
+ x = batch.to(device, non_blocking=True)
561
+ optimizer.zero_grad(set_to_none=True)
562
+
563
+ with torch.amp.autocast(device.type, dtype=amp_dtype, enabled=use_amp):
564
+ out = model(x)
565
+ losses = criterion(out["x_hat"], x, out["mu"], out["logvar"])
566
+ loss = losses["total"]
567
+
568
+ if not torch.isfinite(loss).item():
569
+ print(
570
+ f"[warn] non-finite loss at step={step + 1}, epoch={epoch}; "
571
+ "skipping optimizer step"
572
+ )
573
+ optimizer.zero_grad(set_to_none=True)
574
+ continue
575
+
576
+ grad_norm_value: float | None = None
577
+ if scaler is not None:
578
+ scaler.scale(loss).backward()
579
+ scaler.unscale_(optimizer)
580
+ if tcfg.get("gradient_clip_norm"):
581
+ gn = torch.nn.utils.clip_grad_norm_(
582
+ model.parameters(),
583
+ tcfg["gradient_clip_norm"],
584
+ )
585
+ grad_norm_value = float(gn.item())
586
+ scaler.step(optimizer)
587
+ scaler.update()
588
+ else:
589
+ loss.backward()
590
+ if tcfg.get("gradient_clip_norm"):
591
+ gn = torch.nn.utils.clip_grad_norm_(
592
+ model.parameters(),
593
+ tcfg["gradient_clip_norm"],
594
+ )
595
+ grad_norm_value = float(gn.item())
596
+ optimizer.step()
597
+
598
+ bs = x.shape[0]
599
+ for k, dq in running.items():
600
+ dq.append(losses[k].item())
601
+ for k, acc in epoch_accs.items():
602
+ acc.add(losses[k].item(), bs)
603
+ if grad_norm_value is not None and math.isfinite(grad_norm_value):
604
+ grad_norm_running.append(grad_norm_value)
605
+ samples_in_window += bs
606
+ epoch_samples += bs
607
+ step += 1
608
+
609
+ # ----- step-level logging -----------------------------------
610
+ if step % tcfg["log_every"] == 0 or step == 1:
611
+ now = time.time()
612
+ throughput = samples_in_window / max(1e-6, now - t_window)
613
+ means = {k: sum(dq) / len(dq) for k, dq in running.items()}
614
+ print(
615
+ f"step {step:>7} | ep {epoch:>3} | lr {lr:.2e} | "
616
+ f"total {means['total']:.4f} recon {means['recon']:.4f} "
617
+ f"{aux_metric_name} {means[aux_metric_name]:.4f} kl {means['kl']:.4f} "
618
+ f"gclip {((sum(grad_norm_running) / len(grad_norm_running)) if grad_norm_running else float('nan')):.3f} | "
619
+ f"{throughput:.0f} img/s"
620
+ )
621
+ csv_writer.writerow([
622
+ step, epoch, f"{lr:.6g}", "train",
623
+ f"{means['total']:.6g}", f"{means['recon']:.6g}",
624
+ f"{means['recon_total']:.6g}", f"{means[aux_metric_name]:.6g}",
625
+ f"{means['kl']:.6g}", f"{throughput:.1f}",
626
+ ])
627
+ if wandb_run is not None:
628
+ wandb_run.log({
629
+ "train/total": means["total"],
630
+ "train/recon": means["recon"],
631
+ "train/recon_total": means["recon_total"],
632
+ f"train/{aux_metric_name}": means[aux_metric_name],
633
+ "train/kl": means["kl"],
634
+ "train/throughput_img_per_s": throughput,
635
+ "train/lr": lr,
636
+ "epoch": epoch,
637
+ }, step=step)
638
+ t_window = now
639
+ samples_in_window = 0
640
+
641
+ # ----- validation -------------------------------------------
642
+ if step % tcfg["val_every_steps"] == 0:
643
+ vmetrics = run_validation(
644
+ model, criterion, val_loader, device,
645
+ max_batches=tcfg["num_val_batches"],
646
+ )
647
+ print(
648
+ f" [val @ step {step}] "
649
+ + " ".join(f"{k.split('/')[-1]}={v:.4f}" for k, v in vmetrics.items())
650
+ )
651
+ csv_writer.writerow([
652
+ step, epoch, f"{lr:.6g}", "val",
653
+ f"{vmetrics['val/total']:.6g}", f"{vmetrics['val/recon']:.6g}",
654
+ f"{vmetrics['val/recon_total']:.6g}", f"{vmetrics[f'val/{aux_metric_name}']:.6g}",
655
+ f"{vmetrics['val/kl']:.6g}", "",
656
+ ])
657
+ if wandb_run is not None:
658
+ wandb_run.log(vmetrics, step=step)
659
+ if best_metric_name not in vmetrics:
660
+ raise KeyError(
661
+ f"train.best_metric={best_metric_name!r} not found in validation metrics "
662
+ f"{sorted(vmetrics.keys())}"
663
+ )
664
+ if vmetrics[best_metric_name] < best_val_metric:
665
+ best_val_metric = vmetrics[best_metric_name]
666
+ save_checkpoint(
667
+ out_dir / "ckpt_best.pt",
668
+ model=model, optimizer=optimizer, scaler=scaler,
669
+ step=step, epoch=epoch, config=config,
670
+ best_val_metric=best_val_metric,
671
+ best_metric_name=best_metric_name,
672
+ )
673
+ print(
674
+ f" -> new best {best_metric_name}={best_val_metric:.4f}, "
675
+ "saved ckpt_best.pt"
676
+ )
677
+
678
+ # ----- sample grid ------------------------------------------
679
+ if step % tcfg["sample_every_steps"] == 0:
680
+ sample_path = out_dir / "samples" / f"step_{step:07d}.png"
681
+ targets, recons = save_sample_grid(
682
+ model, val_ds, device,
683
+ out_path=sample_path,
684
+ n=tcfg["num_sample_images"],
685
+ rng_state=sample_rng,
686
+ )
687
+ if wandb_run is not None:
688
+ wandb_run.log({
689
+ "samples/target": [
690
+ wandb.Image(tgt, caption=f"sample {i}") for i, tgt in enumerate(targets)
691
+ ],
692
+ "samples/reconstruction": [
693
+ wandb.Image(rec, caption=f"sample {i}") for i, rec in enumerate(recons)
694
+ ],
695
+ }, step=step)
696
+
697
+ # ----- periodic checkpoint ----------------------------------
698
+ if step % tcfg["ckpt_every_steps"] == 0:
699
+ save_checkpoint(
700
+ out_dir / f"ckpt_step_{step:07d}.pt",
701
+ model=model, optimizer=optimizer, scaler=scaler,
702
+ step=step, epoch=epoch, config=config,
703
+ best_val_metric=best_val_metric,
704
+ best_metric_name=best_metric_name,
705
+ )
706
+ save_checkpoint(
707
+ out_dir / "ckpt_last.pt",
708
+ model=model, optimizer=optimizer, scaler=scaler,
709
+ step=step, epoch=epoch, config=config,
710
+ best_val_metric=best_val_metric,
711
+ best_metric_name=best_metric_name,
712
+ )
713
+ rotate_periodic_ckpts(out_dir, tcfg["keep_last_ckpts"])
714
+ print(f" saved ckpt_step_{step:07d}.pt")
715
+
716
+ # ----- epoch-end logging ----------------------------------------
717
+ if epoch_samples > 0:
718
+ epoch_means = {k: acc.mean() for k, acc in epoch_accs.items()}
719
+ print(
720
+ f"[epoch {epoch} end @ step {step}] "
721
+ + " ".join(f"{k}={v:.4f}" for k, v in epoch_means.items())
722
+ )
723
+ csv_writer.writerow([
724
+ step, epoch, f"{lr:.6g}", "train_epoch",
725
+ f"{epoch_means['total']:.6g}", f"{epoch_means['recon']:.6g}",
726
+ f"{epoch_means['recon_total']:.6g}", f"{epoch_means[aux_metric_name]:.6g}",
727
+ f"{epoch_means['kl']:.6g}", "",
728
+ ])
729
+ if wandb_run is not None:
730
+ wandb_run.log(
731
+ {f"epoch_train/{k}": v for k, v in epoch_means.items()} | {"epoch": epoch},
732
+ step=step,
733
+ )
734
+
735
+ if done:
736
+ break
737
+ finally:
738
+ signal.signal(signal.SIGTERM, prev_sigterm)
739
+ signal.signal(signal.SIGINT, prev_sigint)
740
+
741
+ save_checkpoint(
742
+ out_dir / "ckpt_last.pt",
743
+ model=model, optimizer=optimizer, scaler=scaler,
744
+ step=step, epoch=epoch, config=config,
745
+ best_val_metric=best_val_metric,
746
+ best_metric_name=best_metric_name,
747
+ )
748
+ csv_fh.close()
749
+ if wandb_run is not None:
750
+ wandb_run.summary["best_val_metric"] = best_val_metric
751
+ wandb_run.summary["best_metric_name"] = best_metric_name
752
+ wandb_run.summary["final_step"] = step
753
+ wandb_run.finish()
754
+ print(f"done. step={step} best_{best_metric_name}={best_val_metric:.4f}")
755
+
756
+
757
+ # ---------------------------------------------------------------------------
758
+
759
+ def parse_args() -> argparse.Namespace:
760
+ p = argparse.ArgumentParser()
761
+ p.add_argument(
762
+ "--config",
763
+ type=Path,
764
+ default=Path(__file__).resolve().parents[1] / "config" / "train_vae.yaml",
765
+ )
766
+ p.add_argument("--run-id", type=str, default=None,
767
+ help="override run_id from the config (output_dir becomes runs_root/<run-id>)")
768
+ p.add_argument("--output-dir", type=str, default=None,
769
+ help="override output_dir directly (bypasses runs_root/run_id derivation)")
770
+ p.add_argument("--resume-from", type=str, default=None,
771
+ help="resume from a specific checkpoint path (overrides auto-resume)")
772
+ p.add_argument("--no-resume", action="store_true",
773
+ help="start fresh even if <output_dir>/ckpt_last.pt exists")
774
+ return p.parse_args()
775
+
776
+
777
+ def main() -> int:
778
+ args = parse_args()
779
+
780
+ # Read the YAML, then apply CLI overrides BEFORE deriving output_dir so
781
+ # `--run-id` re-points the run directory.
782
+ with args.config.open() as f:
783
+ raw_cfg = yaml.safe_load(f)
784
+ if args.run_id:
785
+ raw_cfg["run_id"] = args.run_id
786
+ if args.output_dir:
787
+ raw_cfg["output_dir"] = args.output_dir # bypasses runs_root/run_id
788
+
789
+ # Funnel through the normal loader to resolve paths + autogen run_id.
790
+ tmp = args.config.parent / f".__cli_override_{os.getpid()}.yaml"
791
+ try:
792
+ with tmp.open("w") as f:
793
+ yaml.safe_dump(raw_cfg, f, sort_keys=False)
794
+ cfg = load_config(tmp)
795
+ finally:
796
+ tmp.unlink(missing_ok=True)
797
+
798
+ if args.resume_from:
799
+ cfg["train"]["resume_from"] = str(_resolve_path(args.resume_from))
800
+ _maybe_autoresume(cfg, allow_autoresume=not args.no_resume)
801
+
802
+ train(cfg)
803
+ return 0
804
+
805
+
806
+ if __name__ == "__main__":
807
+ sys.exit(main())
script/train_vae.sh ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #SBATCH --job-name=tactile_vae
3
+ #SBATCH --partition=ct
4
+ #SBATCH --nodes=1
5
+ #SBATCH --ntasks-per-node=1
6
+ #SBATCH --gres=gpu:h100:1
7
+ #SBATCH --requeue
8
+ #SBATCH --output=/group2/ct/weihanx/tactile_world_model/slurm-logs/tactile_vae.%j.log
9
+ #SBATCH --error=/group2/ct/weihanx/tactile_world_model/slurm-logs/tactile_vae.%j.log
10
+
11
+ # Train tactile_vae.model.TactileVAE on the fota_unlabeled parquet dataset.
12
+ #
13
+ # Each run lives at <RUNS_DIR>/<RUN_ID>/. Re-launching with the same RUN_ID
14
+ # auto-resumes from ckpt_last.pt; wandb keeps the same run id, so metrics
15
+ # append to the same dashboard.
16
+ #
17
+ # Usage (sbatch): sbatch tactile_vae/script/train_vae.sh <run_id> [config.yaml]
18
+ # Usage (local): ./tactile_vae/script/train_vae.sh <run_id> [config.yaml]
19
+ #
20
+ # Diagnostics: set DEBUG=1 to enable `set -x` command tracing.
21
+ # sbatch --export=ALL,DEBUG=1 tactile_vae/script/train_vae.sh <run_id>
22
+
23
+ # Force unbuffered stdout/stderr so the slurm log shows progress live, not in
24
+ # one giant flush at the end. (Without this, NFS-backed log files can look
25
+ # completely empty for minutes while bash + python buffer output.)
26
+ exec 1> >(stdbuf -oL -eL cat) 2>&1
27
+
28
+ set -euo pipefail
29
+ [[ "${DEBUG:-0}" == "1" ]] && set -x
30
+
31
+ # ============================================================
32
+ # Inputs (positional)
33
+ # ============================================================
34
+ if [[ $# -lt 1 ]]; then
35
+ echo "Usage: $0 <run_id> [config.yaml]" >&2
36
+ echo " run_id : required. Both the output subdir name and the wandb run id." >&2
37
+ echo " config : optional. Defaults to tactile_vae/config/train_vae.yaml." >&2
38
+ exit 2
39
+ fi
40
+ RUN_ID="$1"
41
+ CONFIG="${2:-tactile_vae/config/train_vae.yaml}"
42
+
43
+ # ============================================================
44
+ # Paths
45
+ # ============================================================
46
+ WORKDIR="/group2/ct/weihanx/tactile_world_model"
47
+ # Keep this aligned with train_vae.yaml default `runs_root: runs`.
48
+ RUNS_DIR="$WORKDIR/tactile_world_model/runs"
49
+ RUN_DIR="$RUNS_DIR/$RUN_ID"
50
+ DATA_DIR="$WORKDIR/tactile_vae/data"
51
+ SPLITS_PATH="$WORKDIR/tactile_vae/dataset/splits.json"
52
+
53
+ # Conda env with all required deps installed. Override via env var if you
54
+ # prefer a different env (e.g. CONDA_ENV=samaudio311 sbatch ...).
55
+ # torch torchvision timm numpy pyarrow PIL pyyaml wandb
56
+ # `twm` is the project's standard env (matches tactile_jepa training).
57
+ CONDA_ENV="${CONDA_ENV:-twm}"
58
+
59
+ mkdir -p "$WORKDIR/slurm-logs"
60
+ mkdir -p "$RUNS_DIR"
61
+ umask 027
62
+
63
+ # ============================================================
64
+ # Print startup info IMMEDIATELY — before any heavy operation
65
+ # (conda activate / python imports) so the slurm log is never
66
+ # silent for more than a fraction of a second.
67
+ # ============================================================
68
+ echo "=== Tactile VAE training ==="
69
+ echo "Host: $(hostname)"
70
+ echo "Job ID: ${SLURM_JOB_ID:-N/A}"
71
+ echo "Start time: $(date)"
72
+ echo "Run ID: $RUN_ID"
73
+ echo "Workdir: $WORKDIR"
74
+ echo "Config: $CONFIG"
75
+ echo "Run dir: $RUN_DIR"
76
+ echo "Conda env: $CONDA_ENV"
77
+ echo
78
+
79
+ # ============================================================
80
+ # Environment knobs
81
+ # ============================================================
82
+ export OMP_NUM_THREADS=8
83
+ export MKL_NUM_THREADS=8
84
+ export TOKENIZERS_PARALLELISM="false"
85
+ export PYTHONFAULTHANDLER=1
86
+ export PYTHONUNBUFFERED=1 # ensures `print()` in Python flushes per line
87
+
88
+ # ============================================================
89
+ # Weights & Biases (mirrors jepa_training.sh — same account)
90
+ # ============================================================
91
+ export WANDB_API_KEY="76cdc4261bf436617e661171fd41d80403e69e9b"
92
+ export WANDB_ENTITY="weihanx-university-of-michigan"
93
+ export WANDB_USERNAME="weihanx@umich.edu"
94
+ export WANDB_PROJECT="tactile_vae"
95
+ export WANDB_MODE="online"
96
+ export WANDB_RUN_ID="$RUN_ID"
97
+ export WANDB_NAME="$RUN_ID"
98
+ export WANDB_SERVICE_WAIT=300
99
+ export WANDB_INIT_TIMEOUT=300
100
+ export WANDB_START_METHOD="thread"
101
+ export WANDB_CONSOLE="wrap"
102
+ # Keep wandb metadata/cache off network storage to speed init/resume.
103
+ export WANDB_DIR="${WANDB_DIR:-/tmp/$USER/wandb/$RUN_ID}"
104
+ export WANDB_CACHE_DIR="${WANDB_CACHE_DIR:-/tmp/$USER/wandb-cache}"
105
+ export WANDB_DATA_DIR="${WANDB_DATA_DIR:-/tmp/$USER/wandb-data}"
106
+ mkdir -p "$WANDB_DIR" "$WANDB_CACHE_DIR" "$WANDB_DATA_DIR"
107
+
108
+ # Debug knob: disable wandb entirely to isolate startup stalls.
109
+ # Default is enabled; set DISABLE_WANDB=1 to disable.
110
+ DISABLE_WANDB="${DISABLE_WANDB:-0}"
111
+ if [[ "$DISABLE_WANDB" == "1" ]]; then
112
+ unset WANDB_PROJECT WANDB_ENTITY WANDB_API_KEY WANDB_USERNAME
113
+ export WANDB_MODE="disabled"
114
+ fi
115
+
116
+ echo "--- Wandb ---"
117
+ echo " project=${WANDB_PROJECT:-<disabled>} entity=${WANDB_ENTITY:-<disabled>}"
118
+ echo " run_id=$WANDB_RUN_ID name=$WANDB_NAME mode=$WANDB_MODE"
119
+ echo " dir=$WANDB_DIR"
120
+ echo " cache_dir=$WANDB_CACHE_DIR"
121
+ echo " data_dir=$WANDB_DATA_DIR"
122
+ if [[ -n "${WANDB_API_KEY:-}" ]]; then
123
+ echo " api_key=${WANDB_API_KEY:0:10}...${WANDB_API_KEY: -4}"
124
+ else
125
+ echo " api_key=<disabled>"
126
+ fi
127
+ echo
128
+
129
+ # ============================================================
130
+ # Sanity checks (cheap; before conda activate)
131
+ # ============================================================
132
+ if [[ ! -f "$WORKDIR/$CONFIG" ]] && [[ ! -f "$CONFIG" ]]; then
133
+ echo "ERROR: config not found: $CONFIG (or $WORKDIR/$CONFIG)" >&2
134
+ exit 2
135
+ fi
136
+ if [[ ! -d "$DATA_DIR" ]]; then
137
+ echo "ERROR: data dir does not exist: $DATA_DIR" >&2
138
+ exit 2
139
+ fi
140
+ if [[ ! -f "$SPLITS_PATH" ]]; then
141
+ echo "ERROR: splits manifest not found: $SPLITS_PATH" >&2
142
+ echo " Generate it with: python tactile_vae/dataset/make_splits.py" >&2
143
+ exit 2
144
+ fi
145
+
146
+ if [[ -f "$RUN_DIR/ckpt_last.pt" ]]; then
147
+ echo "Resume: auto-resume from $RUN_DIR/ckpt_last.pt"
148
+ else
149
+ echo "Resume: fresh run (no $RUN_DIR/ckpt_last.pt)"
150
+ fi
151
+ echo
152
+
153
+ # ============================================================
154
+ # Resolve Python interpreter
155
+ # ============================================================
156
+ # Fast path: call env python directly to avoid expensive `conda activate`
157
+ # startup on busy shared filesystems. Fallback to full activation if needed.
158
+ PYTHON_BIN="${PYTHON_BIN:-$HOME/miniconda3/envs/$CONDA_ENV/bin/python}"
159
+ if [[ -x "$PYTHON_BIN" ]]; then
160
+ echo "[$(date +%H:%M:%S)] using env python directly: $PYTHON_BIN"
161
+ else
162
+ echo "[$(date +%H:%M:%S)] env python not found; falling back to conda activate..."
163
+ source ~/miniconda3/etc/profile.d/conda.sh
164
+ echo "[$(date +%H:%M:%S)] activating $CONDA_ENV..."
165
+ conda activate "$CONDA_ENV"
166
+ PYTHON_BIN="$(which python)"
167
+ echo "[$(date +%H:%M:%S)] env activated. python = $PYTHON_BIN"
168
+ fi
169
+ echo "[$(date +%H:%M:%S)] GPU(s): ${CUDA_VISIBLE_DEVICES:-$(nvidia-smi -L 2>/dev/null | head -1 || echo none)}"
170
+ echo
171
+
172
+ # ============================================================
173
+ # Launch training (`-u` is also forced by PYTHONUNBUFFERED above)
174
+ # ============================================================
175
+ cd "$WORKDIR"
176
+ echo "[$(date +%H:%M:%S)] launching trainer..."
177
+ "$PYTHON_BIN" -u tactile_vae/script/train_vae.py \
178
+ --config "$CONFIG" \
179
+ --run-id "$RUN_ID"
180
+
181
+ echo
182
+ echo "[$(date +%H:%M:%S)] Finished."
183
+ echo "Run dir contents:"
184
+ ls -lh "$RUN_DIR" 2>/dev/null || echo " (empty)"
script/train_vae_pl.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train TactileVAE with PyTorch Lightning.
2
+
3
+ Run:
4
+ python tactile_vae/script/train_vae_pl.py --config tactile_vae/config/train_vae.yaml
5
+
6
+ Same YAML config format as train_vae.py.
7
+
8
+ Checkpoints written to <output_dir>/:
9
+ ckpt_best.pt / ckpt_last.pt / ckpt_step_*.pt — original format (TactileVAEWrapper compat)
10
+ checkpoints/last.ckpt — Lightning format (full resume with trainer state)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import datetime as dt
16
+ import math
17
+ import os
18
+ import random
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import numpy as np
24
+ import pytorch_lightning as pl
25
+ import torch
26
+ import yaml
27
+ from PIL import Image
28
+ from pytorch_lightning.callbacks import ModelCheckpoint
29
+ from pytorch_lightning.loggers import CSVLogger
30
+ import torch.nn as nn
31
+ import torch.nn.functional as F
32
+ from torch.optim.lr_scheduler import LambdaLR
33
+ from torch.utils.data import DataLoader
34
+
35
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
36
+ if str(_REPO_ROOT) not in sys.path:
37
+ sys.path.insert(0, str(_REPO_ROOT))
38
+
39
+ from tactile_vae.dataset import ColorJitterConfig, ParquetFileShuffleSampler, TactileParquetDataset
40
+ from tactile_vae.model import TactileVAE, VAELoss
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Utilities (same as train_vae.py)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _resolve_path(p: str | None) -> Path | None:
48
+ if p is None:
49
+ return None
50
+ path = Path(p)
51
+ return path if path.is_absolute() else (_REPO_ROOT / path).resolve()
52
+
53
+
54
+ def _autogenerate_run_id() -> str:
55
+ return "run_" + dt.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
56
+
57
+
58
+ def load_config(path: Path) -> dict:
59
+ with path.open() as f:
60
+ cfg = yaml.safe_load(f)
61
+ if not cfg.get("run_id"):
62
+ cfg["run_id"] = _autogenerate_run_id()
63
+ if cfg.get("output_dir"):
64
+ cfg["output_dir"] = str(_resolve_path(cfg["output_dir"]))
65
+ else:
66
+ runs_root = _resolve_path(cfg.get("runs_root", "runs"))
67
+ cfg["output_dir"] = str(runs_root / cfg["run_id"])
68
+ cfg["data"]["root"] = str(_resolve_path(cfg["data"]["root"]))
69
+ if cfg["data"].get("splits_path"):
70
+ cfg["data"]["splits_path"] = str(_resolve_path(cfg["data"]["splits_path"]))
71
+ if cfg["train"].get("resume_from"):
72
+ cfg["train"]["resume_from"] = str(_resolve_path(cfg["train"]["resume_from"]))
73
+ return cfg
74
+
75
+
76
+ def _maybe_autoresume(cfg: dict, *, allow_autoresume: bool) -> None:
77
+ if cfg["train"].get("resume_from") or not allow_autoresume:
78
+ return
79
+ # Prefer Lightning checkpoint for full state restore (step count, optimizer, etc.)
80
+ last_ckpt = Path(cfg["output_dir"]) / "checkpoints" / "last.ckpt"
81
+ if last_ckpt.exists():
82
+ cfg["train"]["resume_from"] = str(last_ckpt)
83
+ return
84
+ last_pt = Path(cfg["output_dir"]) / "ckpt_last.pt"
85
+ if last_pt.exists():
86
+ cfg["train"]["resume_from"] = str(last_pt)
87
+
88
+
89
+ def set_seed(seed: int) -> None:
90
+ random.seed(seed)
91
+ np.random.seed(seed)
92
+ torch.manual_seed(seed)
93
+ if torch.cuda.is_available():
94
+ torch.cuda.manual_seed_all(seed)
95
+
96
+
97
+ def build_datasets(data_cfg: dict) -> tuple[TactileParquetDataset, TactileParquetDataset]:
98
+ common = dict(
99
+ root=data_cfg["root"],
100
+ image_size=data_cfg["image_size"],
101
+ cache_files=data_cfg.get("cache_files", 1),
102
+ splits_path=data_cfg.get("splits_path"),
103
+ return_meta=data_cfg.get("return_meta", False),
104
+ )
105
+ if data_cfg.get("meta_columns"):
106
+ common["meta_columns"] = data_cfg["meta_columns"]
107
+ jitter_cfg = data_cfg.get("color_jitter")
108
+ color_jitter = ColorJitterConfig(**jitter_cfg) if jitter_cfg else None
109
+ train_ds = TactileParquetDataset(split="train", color_jitter=color_jitter, **common)
110
+ val_ds = TactileParquetDataset(split="val", color_jitter=None, **common)
111
+ return train_ds, val_ds
112
+
113
+
114
+ def lr_at_step(step: int, base_lr: float, total_steps: int, sched_cfg: dict) -> float:
115
+ warmup = int(sched_cfg.get("warmup_steps", 0))
116
+ sched = sched_cfg.get("type", "constant")
117
+ if step < warmup:
118
+ return base_lr * (step + 1) / max(1, warmup)
119
+ if sched == "constant":
120
+ return base_lr
121
+ if sched == "cosine":
122
+ min_ratio = float(sched_cfg.get("min_lr_ratio", 0.1))
123
+ progress = (step - warmup) / max(1, total_steps - warmup)
124
+ progress = min(max(progress, 0.0), 1.0)
125
+ return base_lr * (min_ratio + (1 - min_ratio) * 0.5 * (1.0 + math.cos(math.pi * progress)))
126
+ raise ValueError(f"unknown scheduler type: {sched!r}")
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # LightningModule
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ class ConfigurablePerceptualVAELoss(nn.Module):
135
+ """VAE loss with configurable perceptual term: SSIM or LPIPS."""
136
+
137
+ def __init__(self, loss_cfg: dict):
138
+ super().__init__()
139
+ self.perceptual_type = str(loss_cfg.get("perceptual_type", "ssim")).lower()
140
+ if self.perceptual_type not in {"ssim", "lpips"}:
141
+ raise ValueError(
142
+ f"loss.perceptual_type must be one of [ssim, lpips], got: {self.perceptual_type!r}"
143
+ )
144
+ self.aux_key = self.perceptual_type
145
+ self.ssim_impl: VAELoss | None = None
146
+ self.lpips_impl: nn.Module | None = None
147
+
148
+ if self.perceptual_type == "ssim":
149
+ self.ssim_impl = VAELoss(**loss_cfg)
150
+ else:
151
+ self.beta = float(loss_cfg.get("beta", 1e-3))
152
+ self.recon_type = str(loss_cfg.get("recon_type", "l1")).lower()
153
+ self.lpips_weight = float(loss_cfg.get("lpips_weight", loss_cfg.get("ssim_weight", 0.1)))
154
+ try:
155
+ import lpips # type: ignore
156
+ except ImportError as exc: # pragma: no cover - depends on runtime env
157
+ raise ImportError(
158
+ "LPIPS loss requested but `lpips` is not installed. "
159
+ "Install with: pip install lpips"
160
+ ) from exc
161
+ self.lpips_impl = lpips.LPIPS(net="alex")
162
+ self.lpips_impl.eval()
163
+ for p in self.lpips_impl.parameters():
164
+ p.requires_grad = False
165
+
166
+ def forward(self, x_hat: torch.Tensor, x: torch.Tensor, mu: torch.Tensor, logvar: torch.Tensor) -> dict[str, torch.Tensor]:
167
+ if self.perceptual_type == "ssim":
168
+ assert self.ssim_impl is not None
169
+ return self.ssim_impl(x_hat, x, mu, logvar)
170
+
171
+ if self.recon_type == "l1":
172
+ recon = F.l1_loss(x_hat, x)
173
+ elif self.recon_type == "mse":
174
+ recon = F.mse_loss(x_hat, x)
175
+ else:
176
+ raise ValueError(f"loss.recon_type must be one of [l1, mse], got: {self.recon_type!r}")
177
+
178
+ # LPIPS expects inputs in [-1, 1], and is more stable in fp32.
179
+ with torch.amp.autocast(device_type=x_hat.device.type, enabled=False):
180
+ x_hat_lp = (2.0 * x_hat.float()) - 1.0
181
+ x_lp = (2.0 * x.float()) - 1.0
182
+ assert self.lpips_impl is not None
183
+ lpips_val = self.lpips_impl(x_hat_lp, x_lp).mean()
184
+ recon_total = recon + self.lpips_weight * lpips_val
185
+ kl = (-0.5 * (1 + logvar - mu.pow(2) - logvar.exp())).mean()
186
+ total = recon_total + self.beta * kl
187
+ return {
188
+ "total": total,
189
+ "recon": recon,
190
+ "recon_total": recon_total,
191
+ "lpips": lpips_val,
192
+ "kl": kl,
193
+ }
194
+
195
+
196
+ class TactileVAEModule(pl.LightningModule):
197
+ def __init__(self, config: dict, *, step_offset: int = 0, total_steps: int = 0):
198
+ super().__init__()
199
+ self.config = config
200
+ self.step_offset = int(step_offset)
201
+ self.total_steps = int(total_steps)
202
+ self.model = TactileVAE(**config["model"])
203
+ self.criterion = ConfigurablePerceptualVAELoss(config["loss"])
204
+
205
+ def forward(self, x, **kw):
206
+ return self.model(x, **kw)
207
+
208
+ def training_step(self, batch, batch_idx):
209
+ x = batch
210
+ out = self.model(x)
211
+ losses = self.criterion(out["x_hat"], x, out["mu"], out["logvar"])
212
+ if not torch.isfinite(losses["total"]).item():
213
+ print(
214
+ f"[warn] non-finite loss at step={self.trainer.global_step + self.step_offset + 1}, "
215
+ f"epoch={self.trainer.current_epoch}; skipping optimizer step"
216
+ )
217
+ return None
218
+ self.log("train/total", losses["total"], prog_bar=True, on_step=True, on_epoch=False, batch_size=x.shape[0])
219
+ self.log_dict(
220
+ {f"train/{k}": v for k, v in losses.items() if k != "total"},
221
+ on_step=True, on_epoch=False, batch_size=x.shape[0],
222
+ )
223
+ return losses["total"]
224
+
225
+ @torch.no_grad()
226
+ def validation_step(self, batch, batch_idx):
227
+ x = batch
228
+ out = self.model(x, sample=False)
229
+ losses = self.criterion(out["x_hat"], x, out["mu"], out["logvar"])
230
+ self.log_dict(
231
+ {f"val/{k}": v for k, v in losses.items()},
232
+ on_step=False, on_epoch=True, batch_size=x.shape[0],
233
+ )
234
+
235
+ def configure_optimizers(self):
236
+ optim_cfg = self.config["optim"]
237
+ optimizer = torch.optim.AdamW(
238
+ self.model.parameters(),
239
+ lr=optim_cfg["lr"],
240
+ weight_decay=optim_cfg.get("weight_decay", 0.0),
241
+ betas=tuple(optim_cfg.get("betas", (0.9, 0.95))),
242
+ eps=optim_cfg.get("eps", 1e-8),
243
+ )
244
+ base_lr = float(optim_cfg["lr"])
245
+ sched_cfg = self.config["scheduler"]
246
+ scheduler = LambdaLR(
247
+ optimizer,
248
+ lr_lambda=lambda step: lr_at_step(
249
+ step + self.step_offset, base_lr, self.total_steps, sched_cfg
250
+ ) / base_lr,
251
+ )
252
+ return {
253
+ "optimizer": optimizer,
254
+ "lr_scheduler": {"scheduler": scheduler, "interval": "step", "frequency": 1},
255
+ }
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # LightningDataModule
260
+ # ---------------------------------------------------------------------------
261
+
262
+ class TactileVAEDataModule(pl.LightningDataModule):
263
+ def __init__(self, config: dict):
264
+ super().__init__()
265
+ self.config = config
266
+ self.train_ds: TactileParquetDataset | None = None
267
+ self.val_ds: TactileParquetDataset | None = None
268
+ self.train_sampler: ParquetFileShuffleSampler | None = None
269
+
270
+ def setup(self, stage: str | None = None):
271
+ if self.train_ds is not None:
272
+ return
273
+ self.train_ds, self.val_ds = build_datasets(self.config["data"])
274
+ self.train_sampler = ParquetFileShuffleSampler(self.train_ds, seed=self.config["seed"])
275
+
276
+ def train_dataloader(self):
277
+ tcfg = self.config["train"]
278
+ return DataLoader(
279
+ self.train_ds,
280
+ batch_size=tcfg["batch_size"],
281
+ sampler=self.train_sampler,
282
+ num_workers=tcfg["num_workers"],
283
+ pin_memory=True,
284
+ drop_last=True,
285
+ persistent_workers=tcfg["num_workers"] > 0,
286
+ prefetch_factor=2 if tcfg["num_workers"] > 0 else None,
287
+ )
288
+
289
+ def val_dataloader(self):
290
+ tcfg = self.config["train"]
291
+ return DataLoader(
292
+ self.val_ds,
293
+ batch_size=tcfg["batch_size"],
294
+ shuffle=False,
295
+ num_workers=max(2, tcfg["num_workers"] // 2),
296
+ pin_memory=True,
297
+ drop_last=False,
298
+ )
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Callbacks
303
+ # ---------------------------------------------------------------------------
304
+
305
+ class SetEpochCallback(pl.Callback):
306
+ """Keeps ParquetFileShuffleSampler epoch-aware for proper per-epoch shuffling."""
307
+
308
+ def __init__(self, *, epoch_offset: int = 0):
309
+ self.epoch_offset = int(epoch_offset)
310
+
311
+ def on_train_epoch_start(self, trainer: pl.Trainer, pl_module: pl.LightningModule) -> None:
312
+ dm = trainer.datamodule
313
+ if hasattr(dm, "train_sampler") and hasattr(dm.train_sampler, "set_epoch"):
314
+ dm.train_sampler.set_epoch(trainer.current_epoch + self.epoch_offset)
315
+
316
+
317
+ class SampleGridCallback(pl.Callback):
318
+ """Saves a top=original / bottom=reconstruction image grid every N steps."""
319
+
320
+ def __init__(self, config: dict, *, step_offset: int = 0):
321
+ self.sample_every = config["train"]["sample_every_steps"]
322
+ self.n = config["train"]["num_sample_images"]
323
+ self.out_dir = Path(config["output_dir"]) / "samples"
324
+ self.rng = np.random.default_rng(config["seed"] + 1)
325
+ self.step_offset = int(step_offset)
326
+
327
+ def on_train_batch_end(self, trainer, pl_module, outputs, batch, batch_idx):
328
+ effective_step = trainer.global_step + self.step_offset
329
+ if effective_step > 0 and effective_step % self.sample_every == 0:
330
+ self._save_grid(trainer, pl_module, effective_step)
331
+
332
+ @torch.no_grad()
333
+ def _save_grid(self, trainer, pl_module, step):
334
+ val_ds = trainer.datamodule.val_ds
335
+ device = pl_module.device
336
+ self.out_dir.mkdir(parents=True, exist_ok=True)
337
+ idx = self.rng.choice(len(val_ds), size=self.n, replace=False).tolist()
338
+ imgs = torch.stack([val_ds[i] for i in idx]).to(device)
339
+ pl_module.eval()
340
+ recon = pl_module.model(imgs, sample=False)["x_hat"]
341
+ pl_module.train()
342
+ h = w = val_ds.image_size
343
+ canvas = np.zeros((2 * h, self.n * w, 3), dtype=np.uint8)
344
+ for i in range(self.n):
345
+ orig = (imgs[i].cpu().clamp(0, 1).permute(1, 2, 0).numpy() * 255).astype(np.uint8)
346
+ rec = (recon[i].cpu().clamp(0, 1).permute(1, 2, 0).numpy() * 255).astype(np.uint8)
347
+ canvas[:h, i * w:(i + 1) * w] = orig
348
+ canvas[h:, i * w:(i + 1) * w] = rec
349
+ Image.fromarray(canvas).save(self.out_dir / f"step_{step:07d}.png")
350
+
351
+
352
+ class CompatCheckpointCallback(pl.Callback):
353
+ """Saves ckpt_last.pt / ckpt_step_*.pt / ckpt_best.pt in the original format
354
+ so that TactileVAEWrapper.load_pretrained keeps working unchanged."""
355
+
356
+ def __init__(
357
+ self,
358
+ config: dict,
359
+ *,
360
+ step_offset: int = 0,
361
+ epoch_offset: int = 0,
362
+ initial_best_val_metric: float = float("inf"),
363
+ ):
364
+ self.config = config
365
+ self.out_dir = Path(config["output_dir"])
366
+ self.ckpt_every = config["train"]["ckpt_every_steps"]
367
+ self.keep_last = config["train"]["keep_last_ckpts"]
368
+ self.best_metric = config["train"].get("best_metric", "val/total")
369
+ self.best_val_metric = float(initial_best_val_metric)
370
+ self.step_offset = int(step_offset)
371
+ self.epoch_offset = int(epoch_offset)
372
+
373
+ def _build_payload(self, trainer: pl.Trainer, pl_module: pl.LightningModule) -> dict:
374
+ # LightningModule.state_dict() prefixes all keys with "model." — strip it.
375
+ sd = {k[len("model."):]: v for k, v in pl_module.state_dict().items() if k.startswith("model.")}
376
+ return {
377
+ "state_dict": sd,
378
+ "optimizer": trainer.optimizers[0].state_dict(),
379
+ "step": trainer.global_step + self.step_offset,
380
+ "epoch": trainer.current_epoch + self.epoch_offset,
381
+ "config": self.config,
382
+ "best_val_metric": self.best_val_metric,
383
+ "best_metric_name": self.best_metric,
384
+ "best_val_recon": self.best_val_metric, # backward compat key
385
+ }
386
+
387
+ def _save(self, path: Path, trainer, pl_module) -> None:
388
+ path.parent.mkdir(parents=True, exist_ok=True)
389
+ tmp = path.with_suffix(path.suffix + ".tmp")
390
+ torch.save(self._build_payload(trainer, pl_module), tmp)
391
+ os.replace(tmp, path)
392
+
393
+ def _rotate(self) -> None:
394
+ ckpts = sorted(self.out_dir.glob("ckpt_step_*.pt"))
395
+ while len(ckpts) > self.keep_last:
396
+ ckpts.pop(0).unlink(missing_ok=True)
397
+
398
+ def on_train_batch_end(self, trainer, pl_module, outputs, batch, batch_idx):
399
+ effective_step = trainer.global_step + self.step_offset
400
+ if effective_step > 0 and effective_step % self.ckpt_every == 0:
401
+ self._save(self.out_dir / f"ckpt_step_{effective_step:07d}.pt", trainer, pl_module)
402
+ self._save(self.out_dir / "ckpt_last.pt", trainer, pl_module)
403
+ self._rotate()
404
+ print(f" saved ckpt_step_{effective_step:07d}.pt")
405
+
406
+ def on_validation_epoch_end(self, trainer, pl_module):
407
+ val = float(trainer.callback_metrics.get(self.best_metric, float("inf")))
408
+ if val < self.best_val_metric:
409
+ self.best_val_metric = val
410
+ self._save(self.out_dir / "ckpt_best.pt", trainer, pl_module)
411
+ print(f" -> new best {self.best_metric}={val:.4f}, saved ckpt_best.pt")
412
+
413
+ def on_train_end(self, trainer, pl_module):
414
+ self._save(self.out_dir / "ckpt_last.pt", trainer, pl_module)
415
+
416
+
417
+ class CompatResumeStateCallback(pl.Callback):
418
+ """Loads optimizer state from compat .pt resume checkpoints."""
419
+
420
+ def __init__(self, optim_state: dict[str, Any] | None):
421
+ self.optim_state = optim_state
422
+
423
+ def on_fit_start(self, trainer, pl_module):
424
+ if self.optim_state is None:
425
+ return
426
+ if not trainer.optimizers:
427
+ return
428
+ trainer.optimizers[0].load_state_dict(self.optim_state)
429
+ print("loaded optimizer state from compat checkpoint")
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Entry point
434
+ # ---------------------------------------------------------------------------
435
+
436
+ def parse_args() -> argparse.Namespace:
437
+ p = argparse.ArgumentParser()
438
+ p.add_argument("--config", type=Path,
439
+ default=Path(__file__).resolve().parents[1] / "config" / "train_vae.yaml")
440
+ p.add_argument("--run-id", type=str, default=None,
441
+ help="override run_id (output_dir = runs_root/<run-id>)")
442
+ p.add_argument("--output-dir", type=str, default=None,
443
+ help="override output_dir directly")
444
+ p.add_argument("--resume-from", type=str, default=None,
445
+ help="path to .ckpt (Lightning) or .pt (compat) checkpoint")
446
+ p.add_argument("--no-resume", action="store_true",
447
+ help="start fresh even if ckpt_last.pt / last.ckpt exists")
448
+ return p.parse_args()
449
+
450
+
451
+ def _init_loggers(cfg: dict, out_dir: Path) -> list[Any]:
452
+ loggers: list[Any] = [CSVLogger(str(out_dir), name="", version="")]
453
+ if os.environ.get("WANDB_PROJECT"):
454
+ try:
455
+ from pytorch_lightning.loggers import WandbLogger
456
+ loggers.append(WandbLogger(
457
+ project=os.environ["WANDB_PROJECT"],
458
+ entity=os.environ.get("WANDB_ENTITY"),
459
+ id=os.environ.get("WANDB_RUN_ID") or cfg["run_id"],
460
+ name=os.environ.get("WANDB_NAME") or cfg["run_id"],
461
+ save_dir=str(out_dir),
462
+ config=cfg,
463
+ ))
464
+ except ImportError:
465
+ print("wandb not available — logging disabled")
466
+ return loggers
467
+
468
+
469
+ def _build_trainer(
470
+ cfg: dict,
471
+ *,
472
+ callbacks: list[pl.Callback],
473
+ loggers: list[Any],
474
+ precision: str,
475
+ resume_from: str | None,
476
+ resume_step_offset: int,
477
+ total_steps: int,
478
+ ) -> pl.Trainer:
479
+ tcfg = cfg["train"]
480
+ trainer_kwargs: dict[str, Any] = {
481
+ "accelerator": "auto",
482
+ "devices": 1,
483
+ "precision": precision,
484
+ "callbacks": callbacks,
485
+ "logger": loggers,
486
+ "limit_val_batches": tcfg["num_val_batches"],
487
+ "val_check_interval": tcfg["val_every_steps"],
488
+ "check_val_every_n_epoch": None, # step-based only; disable epoch-end validation
489
+ "log_every_n_steps": tcfg["log_every"],
490
+ "gradient_clip_val": tcfg.get("gradient_clip_norm") or None,
491
+ "num_sanity_val_steps": 0,
492
+ "default_root_dir": str(cfg["output_dir"]),
493
+ }
494
+ if resume_from and Path(resume_from).suffix != ".ckpt":
495
+ remaining_steps = max(0, total_steps - resume_step_offset)
496
+ trainer_kwargs["max_steps"] = remaining_steps
497
+ print(f"compat resume remaining_steps={remaining_steps}")
498
+ elif tcfg.get("max_steps"):
499
+ trainer_kwargs["max_steps"] = tcfg["max_steps"]
500
+ else:
501
+ trainer_kwargs["max_epochs"] = tcfg["epochs"]
502
+ return pl.Trainer(**trainer_kwargs)
503
+
504
+
505
+ def main(cfg: dict) -> None:
506
+ set_seed(cfg["seed"])
507
+ out_dir = Path(cfg["output_dir"])
508
+ out_dir.mkdir(parents=True, exist_ok=True)
509
+
510
+ # Resume bookkeeping for compat .pt checkpoints.
511
+ resume_step_offset = 0
512
+ resume_epoch_offset = 0
513
+ resume_optimizer_state: dict[str, Any] | None = None
514
+ resume_best_val_metric = float("inf")
515
+ resume_from = cfg["train"].get("resume_from")
516
+ if resume_from and Path(resume_from).suffix != ".ckpt":
517
+ compat = torch.load(str(resume_from), map_location="cpu", weights_only=False)
518
+ resume_step_offset = int(compat.get("step", 0))
519
+ resume_epoch_offset = int(compat.get("epoch", 0))
520
+ resume_optimizer_state = compat.get("optimizer")
521
+ resume_best_val_metric = float(
522
+ compat.get("best_val_metric", compat.get("best_val_recon", float("inf")))
523
+ )
524
+
525
+ snap = out_dir / "config.snapshot.yaml"
526
+ if not snap.exists():
527
+ with snap.open("w") as f:
528
+ yaml.safe_dump(cfg, f, sort_keys=False)
529
+
530
+ # Build data module early so SampleGridCallback can reference val_ds via trainer.datamodule
531
+ datamodule = TactileVAEDataModule(cfg)
532
+ datamodule.setup()
533
+ print(f"datasets: train={len(datamodule.train_ds):,} val={len(datamodule.val_ds):,}")
534
+
535
+ tcfg = cfg["train"]
536
+ steps_per_epoch = len(datamodule.train_dataloader())
537
+ total_steps = tcfg["max_steps"] if tcfg.get("max_steps") else steps_per_epoch * tcfg["epochs"]
538
+ print(f"steps/epoch={steps_per_epoch:,} total_steps={total_steps:,}")
539
+ module = TactileVAEModule(cfg, step_offset=resume_step_offset, total_steps=total_steps)
540
+ n_params = sum(p.numel() for p in module.model.parameters())
541
+ print(f"model: {module.model.__class__.__name__} params={n_params:,}")
542
+
543
+ # Precision
544
+ use_amp = bool(tcfg.get("amp", False))
545
+ if use_amp:
546
+ amp_dtype = str(tcfg.get("amp_dtype", "bf16")).lower()
547
+ if amp_dtype not in {"bf16", "bfloat16"}:
548
+ print(f"[info] overriding train.amp_dtype={amp_dtype!r} to 'bf16' (enforced)")
549
+ precision = "bf16-mixed"
550
+ else:
551
+ precision = "32"
552
+
553
+ loggers = _init_loggers(cfg, out_dir)
554
+
555
+ callbacks = [
556
+ SetEpochCallback(epoch_offset=resume_epoch_offset),
557
+ SampleGridCallback(cfg, step_offset=resume_step_offset),
558
+ CompatCheckpointCallback(
559
+ cfg,
560
+ step_offset=resume_step_offset,
561
+ epoch_offset=resume_epoch_offset,
562
+ initial_best_val_metric=resume_best_val_metric,
563
+ ),
564
+ CompatResumeStateCallback(resume_optimizer_state),
565
+ ModelCheckpoint(
566
+ dirpath=str(out_dir / "checkpoints"),
567
+ filename="last",
568
+ save_last=True,
569
+ save_top_k=0,
570
+ every_n_train_steps=tcfg["ckpt_every_steps"],
571
+ ),
572
+ ]
573
+
574
+ trainer = _build_trainer(
575
+ cfg,
576
+ callbacks=callbacks,
577
+ loggers=loggers,
578
+ precision=precision,
579
+ resume_from=resume_from,
580
+ resume_step_offset=resume_step_offset,
581
+ total_steps=total_steps,
582
+ )
583
+
584
+ # Resume: .ckpt = full Lightning resume; .pt = model+optimizer+offsets compat resume.
585
+ ckpt_path: str | None = None
586
+ if resume_from:
587
+ rf = Path(resume_from)
588
+ if rf.suffix == ".ckpt":
589
+ ckpt_path = str(rf)
590
+ print(f"resuming (Lightning): {rf}")
591
+ else:
592
+ ckpt = torch.load(str(rf), map_location="cpu", weights_only=False)
593
+ module.model.load_state_dict(ckpt["state_dict"])
594
+ print(
595
+ f"resuming (compat): {rf} "
596
+ f"step={resume_step_offset} epoch={resume_epoch_offset}"
597
+ )
598
+
599
+ trainer.fit(module, datamodule=datamodule, ckpt_path=ckpt_path)
600
+ print(f"done. global_step={trainer.global_step}")
601
+
602
+
603
+ if __name__ == "__main__":
604
+ args = parse_args()
605
+ with args.config.open() as f:
606
+ raw_cfg = yaml.safe_load(f)
607
+ if args.run_id:
608
+ raw_cfg["run_id"] = args.run_id
609
+ if args.output_dir:
610
+ raw_cfg["output_dir"] = args.output_dir
611
+
612
+ tmp = args.config.parent / f".__cli_override_{os.getpid()}.yaml"
613
+ try:
614
+ with tmp.open("w") as f:
615
+ yaml.safe_dump(raw_cfg, f, sort_keys=False)
616
+ cfg = load_config(tmp)
617
+ finally:
618
+ tmp.unlink(missing_ok=True)
619
+
620
+ if args.resume_from:
621
+ cfg["train"]["resume_from"] = str(_resolve_path(args.resume_from))
622
+ _maybe_autoresume(cfg, allow_autoresume=not args.no_resume)
623
+
624
+ main(cfg)
script/train_vae_pl.sh ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ #SBATCH --job-name=tactile_vae_pl
3
+ #SBATCH --partition=sharedp
4
+ #SBATCH --nodes=2
5
+ #SBATCH --ntasks-per-node=2
6
+ #SBATCH --gres=gpu:h100:4
7
+ #SBATCH --requeue
8
+ #SBATCH --exclude=mfmc10
9
+ #SBATCH --output=/group2/ct/weihanx/tactile_world_model/slurm-logs/tactile_vae_pl.%j.log
10
+ #SBATCH --error=/group2/ct/weihanx/tactile_world_model/slurm-logs/tactile_vae_pl.%j.log
11
+
12
+ # Train TactileVAE with PyTorch Lightning (train_vae_pl.py).
13
+ #
14
+ # Each run lives at <RUNS_DIR>/<RUN_ID>/. Re-launching with the same RUN_ID
15
+ # auto-resumes from checkpoints/last.ckpt (Lightning) or ckpt_last.pt (compat).
16
+ # wandb keeps the same run id so metrics append to the same dashboard.
17
+ #
18
+ # Usage (sbatch): sbatch tactile_vae/script/train_vae_pl.sh <run_id> [config.yaml]
19
+ # Usage (local): ./tactile_vae/script/train_vae_pl.sh <run_id> [config.yaml]
20
+ #
21
+ # Diagnostics: set DEBUG=1 to enable `set -x` command tracing.
22
+ # sbatch --export=ALL,DEBUG=1 tactile_vae/script/train_vae_pl.sh <run_id>
23
+
24
+ exec 1> >(stdbuf -oL -eL cat) 2>&1
25
+
26
+ set -euo pipefail
27
+ [[ "${DEBUG:-0}" == "1" ]] && set -x
28
+
29
+ # ============================================================
30
+ # Inputs (positional)
31
+ # ============================================================
32
+ if [[ $# -lt 1 ]]; then
33
+ echo "Usage: $0 <run_id> [config.yaml]" >&2
34
+ echo " run_id : required. Both the output subdir name and the wandb run id." >&2
35
+ echo " config : optional. Defaults to tactile_vae/config/train_vae.yaml." >&2
36
+ exit 2
37
+ fi
38
+ RUN_ID="$1"
39
+ CONFIG="${2:-tactile_vae/config/train_vae.yaml}"
40
+
41
+ # ============================================================
42
+ # Paths
43
+ # ============================================================
44
+ WORKDIR="/group2/ct/weihanx/tactile_world_model"
45
+ RUNS_DIR="$WORKDIR/runs"
46
+ RUN_DIR="$RUNS_DIR/$RUN_ID"
47
+ DATA_DIR="$WORKDIR/tactile_vae/data"
48
+ SPLITS_PATH="$WORKDIR/tactile_vae/dataset/splits_subset.json"
49
+
50
+ CONDA_ENV="${CONDA_ENV:-twm}"
51
+
52
+ mkdir -p "$WORKDIR/slurm-logs"
53
+ mkdir -p "$RUNS_DIR"
54
+ umask 027
55
+
56
+ # ============================================================
57
+ # Print startup info
58
+ # ============================================================
59
+ echo "=== Tactile VAE (PyTorch Lightning) ==="
60
+ echo "Host: $(hostname)"
61
+ echo "Job ID: ${SLURM_JOB_ID:-N/A}"
62
+ echo "Start time: $(date)"
63
+ echo "Run ID: $RUN_ID"
64
+ echo "Workdir: $WORKDIR"
65
+ echo "Config: $CONFIG"
66
+ echo "Run dir: $RUN_DIR"
67
+ echo "Conda env: $CONDA_ENV"
68
+ echo
69
+
70
+ # ============================================================
71
+ # Environment knobs
72
+ # ============================================================
73
+ export OMP_NUM_THREADS=8
74
+ export MKL_NUM_THREADS=8
75
+ export TOKENIZERS_PARALLELISM="false"
76
+ export PYTHONFAULTHANDLER=1
77
+ export PYTHONUNBUFFERED=1
78
+
79
+ # ============================================================
80
+ # Weights & Biases
81
+ # ============================================================
82
+ export WANDB_API_KEY="76cdc4261bf436617e661171fd41d80403e69e9b"
83
+ export WANDB_ENTITY="weihanx-university-of-michigan"
84
+ export WANDB_USERNAME="weihanx@umich.edu"
85
+ export WANDB_PROJECT="tactile_vae"
86
+ export WANDB_MODE="online"
87
+ export WANDB_RUN_ID="$RUN_ID"
88
+ export WANDB_NAME="$RUN_ID"
89
+ export WANDB_SERVICE_WAIT=300
90
+ export WANDB_INIT_TIMEOUT=300
91
+ export WANDB_START_METHOD="thread"
92
+ export WANDB_CONSOLE="wrap"
93
+ export WANDB_DIR="${WANDB_DIR:-/tmp/$USER/wandb/$RUN_ID}"
94
+ export WANDB_CACHE_DIR="${WANDB_CACHE_DIR:-/tmp/$USER/wandb-cache}"
95
+ export WANDB_DATA_DIR="${WANDB_DATA_DIR:-/tmp/$USER/wandb-data}"
96
+ mkdir -p "$WANDB_DIR" "$WANDB_CACHE_DIR" "$WANDB_DATA_DIR"
97
+
98
+ DISABLE_WANDB="${DISABLE_WANDB:-0}"
99
+ if [[ "$DISABLE_WANDB" == "1" ]]; then
100
+ unset WANDB_PROJECT WANDB_ENTITY WANDB_API_KEY WANDB_USERNAME
101
+ export WANDB_MODE="disabled"
102
+ fi
103
+
104
+ echo "--- Wandb ---"
105
+ echo " project=${WANDB_PROJECT:-<disabled>} entity=${WANDB_ENTITY:-<disabled>}"
106
+ echo " run_id=$WANDB_RUN_ID name=$WANDB_NAME mode=$WANDB_MODE"
107
+ echo " dir=$WANDB_DIR"
108
+ echo " cache_dir=$WANDB_CACHE_DIR"
109
+ echo " data_dir=$WANDB_DATA_DIR"
110
+ if [[ -n "${WANDB_API_KEY:-}" ]]; then
111
+ echo " api_key=${WANDB_API_KEY:0:10}...${WANDB_API_KEY: -4}"
112
+ else
113
+ echo " api_key=<disabled>"
114
+ fi
115
+ echo
116
+
117
+ # ============================================================
118
+ # Sanity checks
119
+ # ============================================================
120
+ if [[ ! -f "$WORKDIR/$CONFIG" ]] && [[ ! -f "$CONFIG" ]]; then
121
+ echo "ERROR: config not found: $CONFIG (or $WORKDIR/$CONFIG)" >&2
122
+ exit 2
123
+ fi
124
+ if [[ ! -d "$DATA_DIR" ]]; then
125
+ echo "ERROR: data dir does not exist: $DATA_DIR" >&2
126
+ exit 2
127
+ fi
128
+ if [[ ! -f "$SPLITS_PATH" ]]; then
129
+ echo "ERROR: splits manifest not found: $SPLITS_PATH" >&2
130
+ echo " Generate it with: python tactile_vae/dataset/make_splits.py" >&2
131
+ exit 2
132
+ fi
133
+
134
+ # Report resume state (Lightning ckpt takes priority over compat ckpt)
135
+ if [[ -f "$RUN_DIR/checkpoints/last.ckpt" ]]; then
136
+ echo "Resume: auto-resume from $RUN_DIR/checkpoints/last.ckpt (Lightning)"
137
+ elif [[ -f "$RUN_DIR/ckpt_last.pt" ]]; then
138
+ echo "Resume: auto-resume from $RUN_DIR/ckpt_last.pt (compat)"
139
+ else
140
+ echo "Resume: fresh run (no checkpoint found)"
141
+ fi
142
+ echo
143
+
144
+ # ============================================================
145
+ # Resolve Python interpreter
146
+ # ============================================================
147
+ PYTHON_BIN="${PYTHON_BIN:-$HOME/miniconda3/envs/$CONDA_ENV/bin/python}"
148
+ if [[ -x "$PYTHON_BIN" ]]; then
149
+ echo "[$(date +%H:%M:%S)] using env python directly: $PYTHON_BIN"
150
+ else
151
+ echo "[$(date +%H:%M:%S)] env python not found; falling back to conda activate..."
152
+ source ~/miniconda3/etc/profile.d/conda.sh
153
+ echo "[$(date +%H:%M:%S)] activating $CONDA_ENV..."
154
+ conda activate "$CONDA_ENV"
155
+ PYTHON_BIN="$(which python)"
156
+ echo "[$(date +%H:%M:%S)] env activated. python = $PYTHON_BIN"
157
+ fi
158
+ echo "[$(date +%H:%M:%S)] GPU(s): ${CUDA_VISIBLE_DEVICES:-$(nvidia-smi -L 2>/dev/null | head -1 || echo none)}"
159
+ echo
160
+
161
+ # ============================================================
162
+ # Launch training
163
+ # ============================================================
164
+ cd "$WORKDIR"
165
+ echo "[$(date +%H:%M:%S)] launching trainer (PyTorch Lightning)..."
166
+ "$PYTHON_BIN" -u tactile_vae/script/train_vae_pl.py \
167
+ --config "$CONFIG" \
168
+ --run-id "$RUN_ID"
169
+
170
+ echo
171
+ echo "[$(date +%H:%M:%S)] Finished."
172
+ echo "Run dir contents:"
173
+ ls -lh "$RUN_DIR" 2>/dev/null || echo " (empty)"
test/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests for tactile_vae."""
test/color_jitter_compare.png ADDED

Git LFS Details

  • SHA256: 054bdf9c62d4fe90463a5b25fb671517d61f71068160e9c840ea66393558f4da
  • Pointer size: 132 Bytes
  • Size of remote file: 1.49 MB
test/test_raw_parquet.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sanity checks for the decoded raw-RGB parquet at
2
+ `tactile_vae/data/train-00000-of-00008.raw.parquet`.
3
+
4
+ We don't reload the JPEG source to compare pixel-for-pixel — we only check
5
+ that the file is well-formed: row count matches the source shard, every
6
+ image blob is `H*W*3` bytes, and the on-disk size is consistent with the
7
+ sum of expected per-row sizes.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import pyarrow.compute as pc
15
+ import pyarrow.parquet as pq
16
+ import pytest
17
+
18
+ DATA_DIR = Path("/group2/ct/weihanx/tactile_world_model/tactile_vae/data")
19
+ JPEG_PATH = DATA_DIR / "train-00000-of-00008.parquet"
20
+ RAW_PATH = DATA_DIR / "train-00000-of-00008.raw.parquet"
21
+
22
+ EXPECTED_ROWS = 72859 # from the JPEG source shard
23
+ EXPECTED_FORMAT = "raw_rgb_uint8"
24
+ EXPECTED_CHANNELS = 3
25
+ EXPECTED_HW = (480, 640) # all rows in this shard share these dims
26
+
27
+ # Loose tolerance for total file size vs. payload size (parquet overhead).
28
+ SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024 # 64 MB header/footer/metadata slack
29
+
30
+
31
+ @pytest.fixture(scope="module")
32
+ def pf() -> pq.ParquetFile:
33
+ if not RAW_PATH.exists():
34
+ pytest.skip(f"{RAW_PATH} not present")
35
+ return pq.ParquetFile(RAW_PATH)
36
+
37
+
38
+ def test_row_count_matches_source(pf: pq.ParquetFile) -> None:
39
+ assert pf.metadata.num_rows == EXPECTED_ROWS
40
+ if JPEG_PATH.exists():
41
+ src_rows = pq.ParquetFile(JPEG_PATH).metadata.num_rows
42
+ assert pf.metadata.num_rows == src_rows, (
43
+ f"raw rows {pf.metadata.num_rows} != source rows {src_rows}"
44
+ )
45
+
46
+
47
+ def test_schema_has_expected_columns(pf: pq.ParquetFile) -> None:
48
+ names = set(pf.schema_arrow.names)
49
+ for required in ("image", "image_format", "height", "width"):
50
+ assert required in names, f"missing column: {required}"
51
+
52
+
53
+ def test_image_format_string_is_raw(pf: pq.ParquetFile) -> None:
54
+ fmts = pf.read(columns=["image_format"]).column("image_format").to_pylist()
55
+ unique = set(fmts)
56
+ assert unique == {EXPECTED_FORMAT}, f"unexpected image_format values: {unique}"
57
+
58
+
59
+ def test_blob_lengths_match_height_width(pf: pq.ParquetFile) -> None:
60
+ """Every `image` blob must be exactly H*W*C bytes."""
61
+ tbl = pf.read(columns=["image", "height", "width"])
62
+ images = tbl.column("image")
63
+ heights = tbl.column("height").to_numpy()
64
+ widths = tbl.column("width").to_numpy()
65
+
66
+ # Length-per-row across chunks. Avoid combine_chunks(): a 67 GB binary
67
+ # column overflows the 32-bit offset limit of pa.binary().
68
+ lengths = pc.binary_length(images).to_numpy(zero_copy_only=False).astype(np.int64)
69
+
70
+ expected = heights.astype(np.int64) * widths.astype(np.int64) * EXPECTED_CHANNELS
71
+ mismatch = np.flatnonzero(lengths != expected)
72
+ assert mismatch.size == 0, (
73
+ f"{mismatch.size} rows have wrong blob size; "
74
+ f"first bad row {int(mismatch[0])}: got {int(lengths[mismatch[0]])} "
75
+ f"expected {int(expected[mismatch[0]])}"
76
+ )
77
+
78
+
79
+ def test_first_row_decodes_to_valid_image(pf: pq.ParquetFile) -> None:
80
+ """Spot-check: row 0 reshapes to (H, W, 3) uint8, plausible value range."""
81
+ batch = next(pf.iter_batches(batch_size=1, columns=["image", "height", "width"]))
82
+ raw = batch["image"][0].as_py()
83
+ h = int(batch["height"][0].as_py())
84
+ w = int(batch["width"][0].as_py())
85
+
86
+ assert (h, w) == EXPECTED_HW, f"unexpected (h,w)=({h},{w})"
87
+ assert len(raw) == h * w * EXPECTED_CHANNELS
88
+
89
+ arr = np.frombuffer(raw, dtype=np.uint8).reshape(h, w, EXPECTED_CHANNELS)
90
+ assert arr.dtype == np.uint8
91
+ assert arr.min() >= 0 and arr.max() <= 255
92
+ # A real tactile image has > 1 unique value — guard against accidental zero-fill.
93
+ assert int(arr.std()) > 0
94
+
95
+
96
+ def test_total_loaded_payload_bytes(pf: pq.ParquetFile) -> None:
97
+ """Sum of (h*w*3) over all rows should approximate the on-disk file size,
98
+ within a small slack for parquet headers/footers/metadata."""
99
+ tbl = pf.read(columns=["height", "width"])
100
+ heights = tbl.column("height").to_numpy().astype(np.int64)
101
+ widths = tbl.column("width").to_numpy().astype(np.int64)
102
+ payload_bytes = int((heights * widths * EXPECTED_CHANNELS).sum())
103
+
104
+ on_disk = RAW_PATH.stat().st_size
105
+ overhead = on_disk - payload_bytes
106
+ assert payload_bytes == EXPECTED_ROWS * EXPECTED_HW[0] * EXPECTED_HW[1] * EXPECTED_CHANNELS
107
+ assert 0 < overhead < SIZE_TOLERANCE_BYTES, (
108
+ f"unexpected file overhead: payload={payload_bytes:,}B "
109
+ f"on_disk={on_disk:,}B overhead={overhead:,}B "
110
+ f"(tolerance {SIZE_TOLERANCE_BYTES:,}B)"
111
+ )
112
+
113
+ print(
114
+ f"\n raw parquet OK: {EXPECTED_ROWS:,} rows × "
115
+ f"{EXPECTED_HW[0]}×{EXPECTED_HW[1]}×{EXPECTED_CHANNELS}"
116
+ f" = {payload_bytes/1e9:.2f} GB payload, "
117
+ f"{on_disk/1e9:.2f} GB on disk "
118
+ f"({overhead/1e6:.1f} MB overhead)"
119
+ )
120
+
121
+
122
+ if __name__ == "__main__":
123
+ pf_ = pq.ParquetFile(RAW_PATH)
124
+ test_row_count_matches_source(pf_)
125
+ test_schema_has_expected_columns(pf_)
126
+ test_image_format_string_is_raw(pf_)
127
+ test_blob_lengths_match_height_width(pf_)
128
+ test_first_row_decodes_to_valid_image(pf_)
129
+ test_total_loaded_payload_bytes(pf_)
130
+ print("All raw-parquet tests passed.")
test/test_tactile_vae.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke/contract tests for tactile_vae.model.tactile_vae."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+ import tempfile
6
+
7
+ import torch
8
+
9
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
10
+ if str(_REPO_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(_REPO_ROOT))
12
+
13
+ from tactile_vae.model.tactile_vae import TactileVAE, VAELoss, load_pretrained
14
+
15
+
16
+ def _small_model() -> TactileVAE:
17
+ return TactileVAE(
18
+ img_size=64,
19
+ patch_size=16,
20
+ in_chans=3,
21
+ embed_dim=96,
22
+ encoder_depth=2,
23
+ encoder_heads=4,
24
+ decoder_embed_dim=96,
25
+ decoder_depth=2,
26
+ decoder_heads=4,
27
+ latent_dim=48,
28
+ )
29
+
30
+
31
+ def test_forward_contract_shapes():
32
+ torch.manual_seed(0)
33
+ model = _small_model()
34
+ x = torch.randn(2, 3, 64, 64)
35
+
36
+ out = model(x)
37
+ assert out["x_hat"].shape == x.shape
38
+ assert out["mu"].shape == (2, 48)
39
+ assert out["logvar"].shape == (2, 48)
40
+ assert out["z"].shape == (2, 48)
41
+ assert out["pred_patches"].shape == (2, (64 // 16) ** 2, 16 * 16 * 3)
42
+
43
+
44
+ def test_losses_finite_and_backprop():
45
+ torch.manual_seed(0)
46
+ model = _small_model()
47
+ criterion = VAELoss(beta=1e-3, recon_type="l1", ssim_weight=0.1)
48
+ x = torch.randn(2, 3, 64, 64)
49
+
50
+ out = model(x)
51
+ losses = criterion(out["x_hat"], x, out["mu"], out["logvar"])
52
+ for name, val in losses.items():
53
+ assert torch.isfinite(val).all(), f"non-finite loss component: {name}"
54
+
55
+ losses["total"].backward()
56
+ grad_ok = any(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters() if p.requires_grad)
57
+ assert grad_ok, "expected finite gradients after backward"
58
+
59
+
60
+ def test_tiny_overfit_recon_drops():
61
+ torch.manual_seed(0)
62
+ model = _small_model()
63
+ criterion = VAELoss(beta=1e-3, recon_type="l1", ssim_weight=0.0)
64
+ opt = torch.optim.Adam(model.parameters(), lr=1e-3)
65
+
66
+ x = torch.tanh(torch.randn(8, 3, 64, 64))
67
+
68
+ with torch.no_grad():
69
+ out0 = model(x)
70
+ start = criterion(out0["x_hat"], x, out0["mu"], out0["logvar"])["recon_total"].item()
71
+
72
+ for _ in range(30):
73
+ out = model(x)
74
+ losses = criterion(out["x_hat"], x, out["mu"], out["logvar"])
75
+ opt.zero_grad(set_to_none=True)
76
+ losses["total"].backward()
77
+ opt.step()
78
+
79
+ with torch.no_grad():
80
+ out1 = model(x)
81
+ end = criterion(out1["x_hat"], x, out1["mu"], out1["logvar"])["recon_total"].item()
82
+
83
+ assert end < start, f"expected recon to decrease; start={start:.4f}, end={end:.4f}"
84
+
85
+
86
+ def test_checkpoint_roundtrip_consistent_eval():
87
+ torch.manual_seed(0)
88
+ model = _small_model().eval()
89
+ x = torch.randn(2, 3, 64, 64)
90
+
91
+ with torch.no_grad():
92
+ out_ref = model(x, sample=False)["x_hat"]
93
+
94
+ with tempfile.TemporaryDirectory() as tmpdir:
95
+ ckpt_path = Path(tmpdir) / "tactile_vae.pt"
96
+ torch.save(model.state_dict(), ckpt_path)
97
+ loaded = load_pretrained(
98
+ checkpoint=str(ckpt_path),
99
+ model_kwargs={
100
+ "img_size": 64,
101
+ "patch_size": 16,
102
+ "in_chans": 3,
103
+ "embed_dim": 96,
104
+ "encoder_depth": 2,
105
+ "encoder_heads": 4,
106
+ "decoder_embed_dim": 96,
107
+ "decoder_depth": 2,
108
+ "decoder_heads": 4,
109
+ "latent_dim": 48,
110
+ },
111
+ freeze=True,
112
+ strict=True,
113
+ ).eval()
114
+
115
+ with torch.no_grad():
116
+ out_loaded = loaded(x, sample=False)["x_hat"]
117
+
118
+ torch.testing.assert_close(out_ref, out_loaded, atol=1e-6, rtol=1e-5)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ test_forward_contract_shapes()
123
+ test_losses_finite_and_backprop()
124
+ test_tiny_overfit_recon_drops()
125
+ test_checkpoint_roundtrip_consistent_eval()
126
+ print("All tactile VAE tests passed.")
test/visualize_color_jitter.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visual sanity check: 10 samples with vs. without color jitter.
2
+
3
+ Writes `color_jitter_compare.png` next to this script. Layout:
4
+ row 0 — base image (no augmentation)
5
+ row 1 — color-jittered image (training-time augmentation)
6
+ row 2 — |jittered − base| amplified for visibility
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import torch
16
+
17
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
18
+ if str(_REPO_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(_REPO_ROOT))
20
+
21
+ from tactile_vae.dataset import ColorJitterConfig, TactileParquetDataset
22
+
23
+ N_SAMPLES = 10
24
+ SEED = 0
25
+ JITTER = ColorJitterConfig(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05)
26
+ OUT_PNG = Path(__file__).with_name("color_jitter_compare.png")
27
+
28
+
29
+ def _to_hwc(t: torch.Tensor) -> np.ndarray:
30
+ return t.detach().cpu().clamp(0, 1).permute(1, 2, 0).numpy()
31
+
32
+
33
+ def main() -> None:
34
+ ds_plain = TactileParquetDataset(image_size=128, color_jitter=None)
35
+ ds_jit = TactileParquetDataset(image_size=128, color_jitter=JITTER)
36
+
37
+ # Sample 10 distinct indices across the dataset for variety.
38
+ n = len(ds_plain)
39
+ rng = np.random.default_rng(SEED)
40
+ indices = sorted(rng.choice(n, size=N_SAMPLES, replace=False).tolist())
41
+ print(f"Comparing {N_SAMPLES} samples (indices: {indices[:3]}... of {n:,})")
42
+ print(f"Jitter config: {JITTER}")
43
+
44
+ torch.manual_seed(SEED) # fix jitter RNG so the PNG is reproducible
45
+
46
+ fig, axes = plt.subplots(3, N_SAMPLES, figsize=(2 * N_SAMPLES, 6.6))
47
+
48
+ for col, idx in enumerate(indices):
49
+ base = ds_plain[idx] # (3,128,128) float in [0,1]
50
+ jittered = ds_jit[idx]
51
+ base_hwc = _to_hwc(base)
52
+ jit_hwc = _to_hwc(jittered)
53
+
54
+ # Amplify residual so visible per-pixel changes are visible at a glance.
55
+ diff = np.abs(jit_hwc - base_hwc)
56
+ scale = max(diff.max(), 1e-6)
57
+ diff_vis = np.clip(diff / scale, 0, 1)
58
+
59
+ axes[0, col].imshow(base_hwc)
60
+ axes[0, col].set_title(f"idx={idx}", fontsize=8)
61
+ axes[1, col].imshow(jit_hwc)
62
+ axes[1, col].set_title(f"Δmean={float(jit_hwc.mean()-base_hwc.mean()):+.3f}",
63
+ fontsize=8)
64
+ axes[2, col].imshow(diff_vis)
65
+ axes[2, col].set_title(f"|Δ|max={float(diff.max()):.3f}", fontsize=8)
66
+ for r in range(3):
67
+ axes[r, col].set_xticks([])
68
+ axes[r, col].set_yticks([])
69
+
70
+ axes[0, 0].set_ylabel("no jitter", fontsize=10)
71
+ axes[1, 0].set_ylabel("with jitter", fontsize=10)
72
+ axes[2, 0].set_ylabel("|Δ| (norm)", fontsize=10)
73
+
74
+ fig.suptitle(
75
+ f"Color jitter comparison (b={JITTER.brightness}, c={JITTER.contrast}, "
76
+ f"s={JITTER.saturation}, h={JITTER.hue})",
77
+ fontsize=11,
78
+ )
79
+ fig.tight_layout(rect=(0, 0, 1, 0.96))
80
+ fig.savefig(OUT_PNG, dpi=120)
81
+ print(f"wrote {OUT_PNG} ({OUT_PNG.stat().st_size/1024:.1f} KB)")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()