2026_s23dr / data /dataset.py
jskvrna's picture
new submission
4fa78b3
Raw
History Blame Contribute Delete
24.4 kB
"""HoHo26k dataset.
Two surface paths share the per-tar WebDataset → `build_scene_input` pipeline:
- `HoHoStreamingDataset` (IterableDataset): reads the raw HF tars, applies
`build_scene_input` + `build_gt_verts` per sample. Used by the
preprocessing CLI to write per-tar shards, and by inline eval when no
cache exists.
- `HoHoCachedDataset` (map-style Dataset): loads ALL semantic cache shards into memory
at construction (small enough — 26k samples × ~tens of KB each fits in a
few GB). Used for training and cached eval. Combine with
`DistributedSampler` for DDP-safe, shuffle-every-epoch loading. Optional
train-time augmentation transforms live in `data.transforms`.
Cache filename (one shard per input tar):
`{cache_dir}/{split}.sem_v7.shard{NNN}-of-{TOTAL}.pkl`
The shard index `NNN` matches the input tar's `dataset-NNNN.tar` index; TOTAL
is the total number of tars for the split.
"""
from __future__ import annotations
import hashlib
import json
import pickle
import re
from pathlib import Path
from typing import Iterable, List, Optional
import numpy as np
import torch
from torch.utils.data import Dataset, IterableDataset
CACHE_VERSION = "sem_v10"
SUPPORTED_CACHE_VERSIONS = {"sem_v7", "sem_v10", "sem_v11"}
# Cache versions for which GT wireframes have small connected components
# (chimneys: <=4 verts and <5 m^2 XY footprint) dropped before building verts_gt.
CHIMNEY_FILTER_VERSIONS = {"sem_v11"}
SHARD_FILENAME_TEMPLATE = "{split}.{ver}.shard{shard:03d}-of-{total:03d}.pkl"
SHARD_GLOB_TEMPLATE = "{split}.{ver}.shard*.pkl"
_SHARD_RE = re.compile(r"\.shard(\d{3})-of-(\d{3})\.pkl$")
from hoho2025.example_solutions import read_colmap_rec
from data.preprocess import build_scene_input, build_gt_verts, filter_small_components
# V11 chimney filter parameters: any connected component of the raw GT
# wireframe with <= V11_MAX_VERTS_PER_CC vertices AND XY footprint < V11_MAX_AREA_M2
# square metres is dropped (in raw world coords) before building verts_gt.
V11_MAX_VERTS_PER_CC = 4
V11_MAX_AREA_M2 = 2.0
DATASET_ID = "usm3d/hoho22k_2026_trainval"
# ---------------------------------------------------------------------------
# Raw WebDataset sample decoder (shared by both dataset classes)
# ---------------------------------------------------------------------------
def _parse_npy(raw_bytes):
import io
return np.load(io.BytesIO(raw_bytes))
def decode_wds_sample(sample: dict) -> dict:
"""Decode one raw WebDataset sample dict into the canonical HoHo record."""
order_id_str = str(sample.get("__key__", "")).replace("order_", "")
K_dict, R_dict, t_dict, pose_only_dict = {}, {}, {}, {}
ade_dict, depth_dict, gestalt_dict = {}, {}, {}
wf_vertices_list, wf_edges_list, wf_classifications_list = [], [], []
colmap_zip_bytes = None
for k, v in sample.items():
k_lower = k.lower()
if k_lower == "__key__":
continue
elif k_lower == "colmap.zip":
colmap_zip_bytes = v
elif k_lower.startswith("ade_") and k_lower.endswith(".png"):
ade_dict[k_lower[4:-4]] = {"bytes": v}
elif k_lower.startswith("depth_") and k_lower.endswith(".png"):
depth_dict[k_lower[6:-4]] = {"bytes": v}
elif k_lower.startswith("gestalt_") and k_lower.endswith(".png"):
gestalt_dict[k_lower[8:-4]] = {"bytes": v}
elif k_lower.endswith(".npy"):
if k_lower.startswith("k_"):
K_dict[k_lower[2:-4]] = _parse_npy(v)
elif k_lower.startswith("r_"):
R_dict[k_lower[2:-4]] = _parse_npy(v)
elif k_lower.startswith("t_"):
t_dict[k_lower[2:-4]] = _parse_npy(v)
elif k_lower.startswith("pose_only_in_colmap_"):
pose_only_dict[k_lower[20:-4]] = bool(_parse_npy(v))
elif k_lower == "wf_vertices.npy":
wf_vertices_list = _parse_npy(v).tolist()
elif k_lower == "wf_edges.npy":
wf_edges_list = _parse_npy(v).tolist()
elif k_lower == "wf_classifications.npy":
arr = _parse_npy(v)
if arr.shape == ():
arr = arr.reshape((1,))
wf_classifications_list = arr.tolist()
img_ids_list = sorted(K_dict.keys())
K_list, R_list, t_list, pose_only_list = [], [], [], []
ade_list, depth_list, gestalt_list = [], [], []
for img_id in img_ids_list:
K_arr = K_dict.get(img_id, np.zeros((3, 3), dtype=np.float32))
R_arr = R_dict.get(img_id, np.zeros((3, 3), dtype=np.float32))
t_arr = np.array(t_dict.get(img_id, np.zeros((3, 1), dtype=np.float32)), dtype=np.float32)
if t_arr.ndim == 1:
t_arr = t_arr.reshape(3, 1)
K_list.append(K_arr.tolist())
R_list.append(R_arr.tolist())
t_list.append(t_arr.tolist())
pose_only_list.append(pose_only_dict.get(img_id, False))
ade_list.append(ade_dict.get(img_id, None))
depth_list.append(depth_dict.get(img_id, None))
gestalt_list.append(gestalt_dict.get(img_id, None))
return {
"order_id": order_id_str,
"image_ids": img_ids_list,
"ade": ade_list,
"depth": depth_list,
"gestalt": gestalt_list,
"K": K_list,
"R": R_list,
"t": t_list,
"pose_only_in_colmap": pose_only_list,
"wf_vertices": wf_vertices_list,
"wf_edges": wf_edges_list,
"wf_classifications": wf_classifications_list,
"colmap": colmap_zip_bytes,
}
# ---------------------------------------------------------------------------
# Tar discovery + shard naming
# ---------------------------------------------------------------------------
_TAR_RE = re.compile(r"data/([a-z]+)/dataset-(\d+)\.tar")
def _split_aliases(split: str) -> str:
return {"val": "validation", "valid": "validation", "dev": "validation"}.get(split, split)
def discover_tars(cache_dir: Path, dataset_id: str, split: str) -> List[tuple[int, str]]:
"""Return [(tar_index, tar_local_path), …] sorted by tar_index."""
split = _split_aliases(split)
downloads_dir = Path(cache_dir) / "downloads"
out: list[tuple[int, str]] = []
for jf in downloads_dir.glob("*.json"):
try:
data = json.loads(jf.read_text())
except json.JSONDecodeError:
continue
url = data.get("url", "")
if not url.startswith(f"hf://datasets/{dataset_id}/data/{split}/"):
continue
m = _TAR_RE.search(url)
if m is None:
continue
tar_idx = int(m.group(2))
tar_path = str(jf)[:-5] # strip .json
out.append((tar_idx, tar_path))
out.sort(key=lambda x: x[0])
return out
def _normalize_cache_version(cache_version: str) -> str:
ver = str(cache_version).strip().lower()
if ver in {"v7", "7"}:
ver = "sem_v7"
elif ver in {"v10", "10"}:
ver = "sem_v10"
elif ver in {"v11", "11"}:
ver = "sem_v11"
if ver not in SUPPORTED_CACHE_VERSIONS:
raise ValueError(
f"Unsupported cache_version={cache_version!r}; "
f"expected one of {sorted(SUPPORTED_CACHE_VERSIONS)}"
)
return ver
def shard_filename(split: str, shard_idx: int, total: int, cache_version: str = CACHE_VERSION) -> str:
cache_version = _normalize_cache_version(cache_version)
return SHARD_FILENAME_TEMPLATE.format(
split=_split_aliases(split), ver=cache_version, shard=shard_idx, total=total
)
def discover_shards(cache_dir: Path, split: str, cache_version: str = CACHE_VERSION) -> List[Path]:
"""Return existing semantic cache shard files for a split, sorted by shard index."""
split = _split_aliases(split)
cache_version = _normalize_cache_version(cache_version)
paths = sorted(Path(cache_dir).glob(SHARD_GLOB_TEMPLATE.format(split=split, ver=cache_version)))
paths.sort(key=lambda p: int(_SHARD_RE.search(p.name).group(1)))
return paths
def _seed_from_order_id(order_id: str) -> int:
"""Stable 64-bit seed derived from order_id (independent of stream position)."""
h = hashlib.sha1(order_id.encode("utf-8")).digest()
return int.from_bytes(h[:8], "big", signed=False)
# ---------------------------------------------------------------------------
# Per-sample preprocessing
# ---------------------------------------------------------------------------
def process_sample(
sample: dict,
n_pts: int,
k_verts: int,
use_depth: bool,
voxel_size_m: float = 0.0,
cache_version: str = CACHE_VERSION,
) -> Optional[dict]:
"""Decode + featurise one raw WebDataset sample. Returns None on failure.
For ``cache_version`` in :data:`CHIMNEY_FILTER_VERSIONS` (e.g. ``sem_v11``),
small connected components of the raw GT wireframe are dropped before
building ``verts_gt``. The original (unfiltered) ``wf_vertices_raw`` and
``wf_edges_raw`` are still saved alongside boolean keep masks so eval and
visualisation can show the deleted parts.
"""
order_id = sample.get("order_id", "")
rng = np.random.default_rng(_seed_from_order_id(order_id))
cache_version = _normalize_cache_version(cache_version)
try:
colmap_rec = read_colmap_rec(sample["colmap"])
sample_dec = {**sample, "colmap": colmap_rec}
scene = build_scene_input(
sample_dec,
n_pts=n_pts,
use_depth=use_depth,
voxel_size_m=voxel_size_m,
rng=rng,
)
wf_v_raw = np.asarray(sample["wf_vertices"], dtype=np.float32)
wf_e_raw = np.asarray(sample["wf_edges"], dtype=np.int64)
if wf_v_raw.ndim == 1:
wf_v_raw = wf_v_raw.reshape(0, 3)
if wf_e_raw.ndim == 1:
wf_e_raw = wf_e_raw.reshape(0, 2)
if cache_version in CHIMNEY_FILTER_VERSIONS:
keep_v, keep_e = filter_small_components(
wf_v_raw, wf_e_raw,
max_verts_per_cc=V11_MAX_VERTS_PER_CC,
max_area_m2=V11_MAX_AREA_M2,
)
else:
keep_v = np.ones(len(wf_v_raw), dtype=bool)
keep_e = np.ones(len(wf_e_raw), dtype=bool)
# Build verts_gt from kept vertices only. We do this by overriding the
# `wf_vertices` field that build_gt_verts reads.
kept_verts = wf_v_raw[keep_v]
sample_for_gt = {**sample_dec, "wf_vertices": kept_verts.tolist()}
gt = build_gt_verts(
sample_for_gt,
scene["bbox_center"],
scene["bbox_scale"],
k_verts,
)
record = {
"scene_xyz": torch.from_numpy(scene["scene_xyz"]),
"scene_type_ids": torch.from_numpy(scene["scene_type_ids"]),
"scene_gestalt_ids": torch.from_numpy(scene["scene_gestalt_ids"]),
"scene_gestalt_id2": torch.from_numpy(scene["scene_gestalt_id2"]),
"scene_gestalt_w1": torch.from_numpy(scene["scene_gestalt_w1"]),
"scene_ade_ids": torch.from_numpy(scene["scene_ade_ids"]),
"scene_geom_conf": torch.from_numpy(scene["scene_geom_conf"]),
"scene_sem_conf": torch.from_numpy(scene["scene_sem_conf"]),
"scene_rgb": torch.from_numpy(scene["scene_rgb"]),
"verts_gt": torch.from_numpy(gt),
"bbox_center": torch.from_numpy(scene["bbox_center"]),
"bbox_scale": torch.tensor(scene["bbox_scale"], dtype=torch.float32),
"bbox_R": torch.from_numpy(scene["bbox_R"]),
"order_id": order_id,
"n_gt_verts": int(keep_v.sum()),
"wf_vertices_raw": wf_v_raw,
"wf_edges_raw": wf_e_raw,
}
if cache_version in CHIMNEY_FILTER_VERSIONS:
record["wf_vertex_kept_mask"] = keep_v
record["wf_edge_kept_mask"] = keep_e
return record
except Exception as e:
print(f"[dataset] skip sample {order_id}: {e}", flush=True)
return None
# ---------------------------------------------------------------------------
# Streaming (preprocessing) dataset — one tar at a time
# ---------------------------------------------------------------------------
class HoHoStreamingDataset(IterableDataset):
"""Iterable view over raw HF tars; yields featurised samples.
Used by the preprocessing CLI to build cache shards, and by HoHoCachedDataset
transparently as a fallback if no cache exists.
"""
def __init__(
self,
dataset_id: str = DATASET_ID,
split: str = "train",
cache_dir: Optional[str] = None,
n_pts: int = 8192,
k_verts: int = 64,
use_depth: bool = True,
voxel_size_m: float = 0.0,
tar_paths: Optional[Iterable[str]] = None,
n_samples: Optional[int] = None,
cache_version: str = CACHE_VERSION,
):
self.dataset_id = dataset_id
self.split = _split_aliases(split)
self.cache_dir = Path(cache_dir) if cache_dir else None
self.n_pts = n_pts
self.k_verts = k_verts
self.use_depth = use_depth
self.voxel_size_m = float(voxel_size_m)
self.n_samples = n_samples
self.cache_version = _normalize_cache_version(cache_version)
if tar_paths is not None:
self.tar_paths = list(tar_paths)
else:
if self.cache_dir is None:
raise ValueError("HoHoStreamingDataset needs cache_dir or explicit tar_paths.")
tars = discover_tars(self.cache_dir, dataset_id, self.split)
self.tar_paths = [path for _, path in tars]
if not self.tar_paths:
raise ValueError(
f"No cached tar files found for split {self.split} in "
f"{self.cache_dir}/downloads. Download the dataset first."
)
def __iter__(self):
import webdataset as wds
ds = wds.WebDataset(self.tar_paths, shardshuffle=False, empty_check=False)
ds = ds.map(decode_wds_sample)
emitted = 0
for raw in ds:
if self.n_samples is not None and emitted >= self.n_samples:
break
item = process_sample(
raw, self.n_pts, self.k_verts, self.use_depth,
voxel_size_m=self.voxel_size_m,
cache_version=self.cache_version,
)
if item is not None:
yield item
emitted += 1
# ---------------------------------------------------------------------------
# Cached (training/eval) dataset — map-style, all shards in memory
# ---------------------------------------------------------------------------
class HoHoCachedDataset(Dataset):
"""Map-style dataset that loads all semantic cache shards into memory.
Use with `torch.utils.data.distributed.DistributedSampler(shuffle=True)`
to get unique-per-rank, reshuffled-every-epoch ordering across DDP ranks.
Args:
cache_dir: directory containing `{split}.{cache_version}.shard*.pkl` files.
split: split name (`train`, `validation`).
n_pts: model-input point count (= transformer query count).
augment: if True, apply training augmentations on each `__getitem__`
(random subsample, flip, yaw rotation, jitter). If False, take a
deterministic prefix slice (eval/inference).
flip / yaw / jitter_sigma: enable/strength of individual augmentations.
n_samples: optional cap for debug runs.
"""
def __init__(
self,
cache_dir: str,
split: str = "train",
n_pts: int = 8192,
augment: bool = False,
flip: bool = True,
yaw: bool = True,
jitter_sigma: float = 0.01,
n_samples: Optional[int] = None,
cache_version: str = CACHE_VERSION,
):
super().__init__()
self.cache_dir = Path(cache_dir)
self.split = _split_aliases(split)
self.cache_version = _normalize_cache_version(cache_version)
self.n_pts = int(n_pts)
self.augment = bool(augment)
self.flip = bool(flip)
self.yaw = bool(yaw)
self.jitter_sigma = float(jitter_sigma)
self.n_samples = n_samples
shard_paths = discover_shards(self.cache_dir, self.split, self.cache_version)
if not shard_paths:
raise FileNotFoundError(
f"No {self.cache_version} cache shards found at {self.cache_dir} for split {self.split}.\n"
f"Run preprocessing first, e.g.:\n"
f" python -m data.dataset --split {self.split} --cache_dir {self.cache_dir} "
f"--cache_version {self.cache_version} --shard_index 0 --total_shards 1"
)
self.samples: list[dict] = []
for p in shard_paths:
with open(p, "rb") as f:
self.samples.extend(pickle.load(f))
if self.n_samples is not None:
self.samples = self.samples[: self.n_samples]
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> dict:
# Avoid mutating the in-memory cache: shallow-copy the dict and the
# per-point tensors that we will swap in. Other tensors are read-only.
from data.transforms import apply_train_transforms, apply_eval_transforms
src = self.samples[idx]
out = {k: v for k, v in src.items()}
# `verts_gt` may be rotated/flipped; clone to avoid aliasing the cache copy.
if "verts_gt" in out and torch.is_tensor(out["verts_gt"]):
out["verts_gt"] = out["verts_gt"].clone()
if self.augment:
return apply_train_transforms(
out,
n_pts=self.n_pts,
yaw=self.yaw,
flip=self.flip,
jitter_sigma=self.jitter_sigma,
)
return apply_eval_transforms(out, n_pts=self.n_pts)
# ---------------------------------------------------------------------------
# Cache builder (run via SLURM array; one shard per input tar)
# ---------------------------------------------------------------------------
def build_shards(
cache_dir: str,
split: str,
shard_index: int,
total_shards: int,
dataset_id: str = DATASET_ID,
n_pts: int = 8192,
k_verts: int = 64,
use_depth: bool = True,
voxel_size_m: float = 0.0,
cache_version: str = CACHE_VERSION,
) -> List[Path]:
"""Build the shards assigned to this array task.
Tar selection is `tars[shard_index::total_shards]` over the sorted tar
list. Each tar produces exactly one output shard file whose `NNN` matches
the input `dataset-NNNN.tar` index.
"""
cache_dir_p = Path(cache_dir)
cache_dir_p.mkdir(parents=True, exist_ok=True)
cache_version = _normalize_cache_version(cache_version)
tars = discover_tars(cache_dir_p, dataset_id, split)
if not tars:
raise ValueError(
f"No cached tar files for split {split} in {cache_dir_p}/downloads."
)
# `total_tars` is used for the {NNN}-of-{TOTAL} suffix in filenames; we use
# the maximum tar index +1 so that shard names line up with the dataset's
# native `dataset-NNNN.tar` numbering even if some tars are missing locally.
total_tars = max(t for t, _ in tars) + 1
my_tars = tars[shard_index::total_shards]
print(
f"[shard] split={split} task {shard_index}/{total_shards}: "
f"{len(my_tars)} tars (of {len(tars)} total)",
flush=True,
)
written: list[Path] = []
for tar_idx, tar_path in my_tars:
out_path = cache_dir_p / shard_filename(split, tar_idx, total_tars, cache_version)
if out_path.exists():
print(f"[shard] skip (exists) {out_path.name}", flush=True)
written.append(out_path)
continue
ds = HoHoStreamingDataset(
dataset_id=dataset_id,
split=split,
cache_dir=str(cache_dir_p),
n_pts=n_pts,
k_verts=k_verts,
use_depth=use_depth,
voxel_size_m=voxel_size_m,
tar_paths=[tar_path],
cache_version=cache_version,
)
rows = list(ds)
with open(out_path, "wb") as f:
pickle.dump(rows, f)
count_path = out_path.with_suffix(out_path.suffix + ".count")
count_path.write_text(str(len(rows)))
written.append(out_path)
print(f"[shard] wrote {out_path.name} ({len(rows)} samples)", flush=True)
return written
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
from config_utils import load_config, resolve_arg, DEFAULT_CONFIG_PATH
p = argparse.ArgumentParser(description="Build HoHo semantic cache shards (one per input tar)")
p.add_argument("--config", default=DEFAULT_CONFIG_PATH)
p.add_argument("--split", default="train")
p.add_argument("--cache_dir", default=None)
p.add_argument("--dataset_id", default=None)
p.add_argument("--n_pts_cache", type=int, default=None,
help="Cache point count (default 8192 = 4096 COLMAP/camera + 4096 depth)")
p.add_argument("--k_verts", type=int, default=None)
p.add_argument("--use_depth", action="store_true", default=None)
p.add_argument("--no_depth", dest="use_depth", action="store_false")
p.add_argument("--shard_index", type=int, default=0,
help="This task's index in the SLURM array (0-based)")
p.add_argument("--total_shards", type=int, default=1,
help="Total array tasks; tars are split round-robin across these")
p.add_argument("--voxel_size_m", type=float, default=None,
help="Metric voxel size (m) for joint downsampling; 0 disables")
p.add_argument("--cache_version", default=CACHE_VERSION, choices=sorted(SUPPORTED_CACHE_VERSIONS),
help="Semantic cache filename version to write")
args = p.parse_args()
config, _ = load_config(args.config)
dataset_id = resolve_arg(args.dataset_id, config, "dataset.id", DATASET_ID)
cache_dir = resolve_arg(args.cache_dir, config, "paths.cache_dir", "cache/")
n_pts_cache = resolve_arg(args.n_pts_cache, config, "model.n_pts_cache", 8192)
k_verts = resolve_arg(args.k_verts, config, "model.k_verts", 64)
use_depth = resolve_arg(args.use_depth, config, "train.use_depth", True)
voxel_size_m = resolve_arg(args.voxel_size_m, config, "model.voxel_size_m", 0.0)
build_shards(
cache_dir=cache_dir,
split=args.split,
shard_index=args.shard_index,
total_shards=args.total_shards,
dataset_id=dataset_id,
n_pts=n_pts_cache,
k_verts=k_verts,
use_depth=use_depth,
voxel_size_m=voxel_size_m,
cache_version=args.cache_version,
)
# ---------------------------------------------------------------------------
# DataLoader collate
# ---------------------------------------------------------------------------
def collate_fn(batch: list) -> dict:
"""Stack fixed-shape tensor fields; keep ragged / string fields as lists."""
if batch and "scene_rgb" not in batch[0]:
for b in batch:
b["scene_rgb"] = torch.zeros((*b["scene_xyz"].shape[:-1], 3), dtype=torch.uint8)
tensor_keys = (
"scene_xyz", "scene_type_ids",
"scene_gestalt_ids", "scene_gestalt_id2", "scene_gestalt_w1",
"scene_ade_ids", "scene_geom_conf", "scene_sem_conf",
"scene_rgb",
"verts_gt", "bbox_center", "bbox_scale", "bbox_R",
)
out = {k: torch.stack([b[k] for b in batch]) for k in tensor_keys}
out["order_id"] = [b["order_id"] for b in batch]
out["n_gt_verts"] = [b["n_gt_verts"] for b in batch]
out["wf_vertices_raw"] = [b.get("wf_vertices_raw") for b in batch]
out["wf_edges_raw"] = [b.get("wf_edges_raw") for b in batch]
out["wf_vertex_kept_mask"] = [b.get("wf_vertex_kept_mask") for b in batch]
out["wf_edge_kept_mask"] = [b.get("wf_edge_kept_mask") for b in batch]
return out