Initial upload of tactile_vae (code, model, config, inference)
Browse files- .gitattributes +3 -0
- .gitignore +25 -0
- README.md +20 -0
- __init__.py +3 -0
- config/__init__.py +32 -0
- config/train_vae.yaml +75 -0
- dataset/__init__.py +19 -0
- dataset/dataset.py +343 -0
- dataset/make_splits.py +165 -0
- dataset/splits.json +0 -0
- dataset/splits_subset.json +0 -0
- inference/per_sample_metrics.json +352 -0
- inference/reconstruction_grid.png +3 -0
- inference/summary.json +10 -0
- inference/vae_baseline_3/per_sample_metrics.json +352 -0
- inference/vae_baseline_3/reconstruction_grid.png +3 -0
- inference/vae_baseline_3/summary.json +10 -0
- model/__init__.py +15 -0
- model/cosmos_tokenizer.py +136 -0
- model/tactile_vae.py +503 -0
- pyproject.toml +20 -0
- script/inference.py +234 -0
- script/train_vae.py +807 -0
- script/train_vae.sh +184 -0
- script/train_vae_pl.py +624 -0
- script/train_vae_pl.sh +173 -0
- test/__init__.py +1 -0
- test/color_jitter_compare.png +3 -0
- test/test_raw_parquet.py +130 -0
- test/test_tactile_vae.py +126 -0
- test/visualize_color_jitter.py +85 -0
.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
|
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
|
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
|
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()
|