rpx_benchmark.pose_pairs
On-the-fly deterministic pose-pair generation for RPX-RCPE.
Generates exact-gap intra-phase pairs and temporal chains without writing anything to disk. Phase 0 and phase 2 are evaluated independently because each capture has its own T265 local world frame; cross-phase relative-pose ground truth is therefore undefined. Given the same seed and config the output is bit-identical, so results are fully reproducible.
The generator produces lightweight sample dicts (frame indices + paths)
that plug directly into RPXDataset.from_dict. Actual pixel
data (RGB, depth) is loaded lazily by the dataset iterator, not here.
Usage
from rpx_benchmark.pose_pairs import PosePairGenerator, PairConfig
gen = PosePairGenerator(
extracted_root="/path/to/extracted",
parquet_path="/path/to/frames_v1.parquet",
split="easy",
)
manifest = gen.manifest() # dict consumable by RPXDataset.from_dict
dataset = gen.as_dataset(batch_size=1) # ready for BenchmarkRunner
# Or iterate pairs without building the full manifest:
for pair in gen.iter_pairs():
print(pair["id"], pair["pair_type"], pair["rotation_bin"])
1"""On-the-fly deterministic pose-pair generation for RPX-RCPE. 2 3Generates exact-gap intra-phase pairs and temporal chains without writing 4anything to disk. Phase 0 and phase 2 are evaluated independently because 5each capture has its own T265 local world frame; cross-phase relative-pose 6ground truth is therefore undefined. Given the same seed and config the output is 7bit-identical, so results are fully reproducible. 8 9The generator produces lightweight sample dicts (frame indices + paths) 10that plug directly into :class:`RPXDataset.from_dict`. Actual pixel 11data (RGB, depth) is loaded lazily by the dataset iterator, not here. 12 13Usage 14----- 15 from rpx_benchmark.pose_pairs import PosePairGenerator, PairConfig 16 17 gen = PosePairGenerator( 18 extracted_root="/path/to/extracted", 19 parquet_path="/path/to/frames_v1.parquet", 20 split="easy", 21 ) 22 manifest = gen.manifest() # dict consumable by RPXDataset.from_dict 23 dataset = gen.as_dataset(batch_size=1) # ready for BenchmarkRunner 24 25 # Or iterate pairs without building the full manifest: 26 for pair in gen.iter_pairs(): 27 print(pair["id"], pair["pair_type"], pair["rotation_bin"]) 28""" 29 30from __future__ import annotations 31 32import tarfile 33from dataclasses import dataclass 34from pathlib import Path 35from typing import Any, Dict, Iterator, List, Optional, Set, Tuple 36 37import numpy as np 38import pandas as pd 39 40from .exceptions import ConfigError 41from .logging_utils import get_logger 42 43log = get_logger(__name__) 44 45 46# ───────────────────────────────────────────────────────────────────────────── 47# Tar extraction helpers 48# ───────────────────────────────────────────────────────────────────────────── 49 50def _extract_from_tar( 51 tar_path: Path, member_name: str, out_path: Path, 52) -> None: 53 """Extract a single member from a tar to ``out_path``. Idempotent.""" 54 if out_path.exists(): 55 return 56 out_path.parent.mkdir(parents=True, exist_ok=True) 57 with tarfile.open(tar_path, "r") as tf: 58 f = tf.extractfile(member_name) 59 if f is None: 60 raise FileNotFoundError( 61 f"{member_name} not found in {tar_path}" 62 ) 63 tmp = out_path.with_suffix(out_path.suffix + ".part") 64 with tmp.open("wb") as g: 65 g.write(f.read()) 66 tmp.rename(out_path) 67 68RPX_SEED = 5_062_026 69 70# ───────────────────────────────────────────────────────────────────────────── 71# Exclusions 72# ───────────────────────────────────────────────────────────────────────────── 73 74EXCLUDED_SCENE_IDS: Set[str] = {"scene58"} 75"""Scenes excluded entirely — no ICP-optimized poses available.""" 76 77EXCLUDED_SCENE_PHASES: Set[Tuple[str, int]] = { 78 ("scene83.jsom.garden.pot", 0), 79 ("scene98.jsom.atrium", 0), 80 ("scene100.jsom.atrium", 0), 81 ("scene50.ecss.out.stairs", 2), 82 ("scene72.ecsw.atriumStairs", 2), 83} 84"""(scene_id, phase) pairs where ICP optimization failed.""" 85 86VALID_PHASES: Tuple[int, ...] = (0, 2) 87"""Clutter and Clean; Interaction is excluded from the RCPE protocol.""" 88 89 90def _mode_split(series: pd.Series) -> str | None: 91 """Return the most common non-null split, or ``None`` if unspecified.""" 92 counts = series.dropna().value_counts() 93 return str(counts.idxmax()) if not counts.empty else None 94 95# ───────────────────────────────────────────────────────────────────────────── 96# Rotation bins 97# ───────────────────────────────────────────────────────────────────────────── 98 99ROTATION_BINS: List[Tuple[float, float]] = [ 100 (0.0, 15.0), 101 (15.0, 45.0), 102 (45.0, 90.0), 103 (90.0, 180.000001), 104] 105BIN_NAMES: List[str] = ["easy", "medium", "hard", "extreme"] 106 107 108def _bin_index(rot_deg: float) -> int: 109 """Return bin index for a rotation, or -1 if below minimum.""" 110 for i, (lo, hi) in enumerate(ROTATION_BINS): 111 if lo <= rot_deg < hi: 112 return i 113 return -1 114 115 116# ───────────────────────────────────────────────────────────────────────────── 117# Pose math 118# ───────────────────────────────────────────────────────────────────────────── 119 120def _quat_xyzw_to_rot(q: np.ndarray) -> np.ndarray: 121 x, y, z, w = q / np.linalg.norm(q) 122 return np.array([ 123 [1 - 2*(y*y + z*z), 2*(x*y - z*w), 2*(x*z + y*w)], 124 [2*(x*y + z*w), 1 - 2*(x*x + z*z), 2*(y*z - x*w)], 125 [2*(x*z - y*w), 2*(y*z + x*w), 1 - 2*(x*x + y*y)], 126 ], dtype=np.float64) 127 128 129def _load_pose_file(path: Path) -> tuple[np.ndarray, np.ndarray]: 130 """Load an RPX camera pose as ``(rotation, translation)``. 131 132 Published RPX snapshots store compact ``.npy`` vectors as 133 ``[x, y, z, qx, qy, qz, qw]``. Legacy extracted trees may still contain 134 ``.npz`` files with named ``position`` and ``orientation`` arrays. 135 """ 136 data = np.load(path) 137 if path.suffix.lower() == ".npy": 138 values = np.asarray(data, dtype=np.float64) 139 if values.shape != (7,): 140 raise ConfigError(f"camera pose {path} must have shape (7,), got {values.shape}") 141 translation = values[:3] 142 quaternion = values[3:] 143 else: 144 translation = np.asarray(data["position"], dtype=np.float64).reshape(3) 145 quaternion = np.asarray(data["orientation"], dtype=np.float64).reshape(4) 146 return _quat_xyzw_to_rot(quaternion), translation 147 148 149def _rotation_deg(R_a: np.ndarray, R_b: np.ndarray) -> float: 150 cos_t = (np.trace(R_a.T @ R_b) - 1.0) * 0.5 151 return float(np.degrees(np.arccos(np.clip(cos_t, -1.0, 1.0)))) 152 153 154# ───────────────────────────────────────────────────────────────────────────── 155# Config 156# ───────────────────────────────────────────────────────────────────────────── 157 158@dataclass 159class PairConfig: 160 """All knobs for pair generation. Deterministic given ``seed``.""" 161 162 # Intra-phase 163 intra_pairs_per_bin: int = 50 164 frame_gap: int = 5 165 # D6 retains every available t -> t+5 pair. The first rotation bin 166 # therefore starts at zero instead of dropping low-motion pairs. 167 min_rotation_deg: float = 0.0 168 min_translation_m: float = 0.0 169 170 # Cross-phase is locked off: separate captures have unrelated T265 worlds. 171 cross_pairs_per_bin: int = 0 172 cross_max_candidates: int = 5000 173 174 # Temporal chains 175 chain_count: int = 5 176 chain_length: int = 10 177 chain_stride: int = 5 178 179 seed: int = RPX_SEED 180 181 182# ───────────────────────────────────────────────────────────────────────────── 183# Core generator 184# ───────────────────────────────────────────────────────────────────────────── 185 186@dataclass 187class _SeqPoses: 188 """Loaded poses + frame indices for one (scene, phase).""" 189 scene: str 190 phase: int 191 frame_idxs: List[int] 192 rotations: List[np.ndarray] # 3×3 each 193 translations: List[np.ndarray] # (3,) each 194 195 196class PosePairGenerator: 197 """Deterministic on-the-fly pair generator. 198 199 Loads only poses (NPZ, lightweight) at construction time. 200 Pixel data is never touched — that happens downstream in the 201 :class:`RPXDataset` iterator. 202 203 Parameters 204 ---------- 205 extracted_root : Path 206 Root of the extracted frames tree (contains ``scenes/…``). 207 parquet_path : Path 208 ``frames_v1.parquet`` with scene/phase/frame metadata. 209 split : str 210 ``"easy"`` | ``"medium"`` | ``"hard"``. 211 config : PairConfig, optional 212 Generation parameters. Defaults are the standard benchmark 213 settings (exact t→t+5 pairs plus stride-5 temporal chains). 214 snapshot_root : Path, optional 215 HF snapshot root (parent of ``manifest/`` and tar shards). 216 When provided, cam_pose files are auto-extracted from tars 217 if not already on disk. If omitted, files must already be 218 extracted under ``extracted_root``. 219 """ 220 221 def __init__( 222 self, 223 extracted_root: Path | str, 224 parquet_path: Path | str, 225 split: str, 226 config: PairConfig | None = None, 227 snapshot_root: Path | str | None = None, 228 repo_id: str | None = None, 229 ) -> None: 230 self.root = Path(extracted_root) 231 self.split = split 232 self.cfg = config or PairConfig() 233 if self.cfg.cross_pairs_per_bin != 0: 234 raise ConfigError( 235 "cross-phase RCPE pairs are invalid because each phase has an " 236 "unrelated T265 local world frame; cross_pairs_per_bin must be 0" 237 ) 238 self._snapshot_root = Path(snapshot_root) if snapshot_root else None 239 self._repo_id = repo_id 240 241 self._sequences: Dict[Tuple[str, int], _SeqPoses] = {} 242 self._frame_filenames: Dict[Tuple[str, int, int], str] = {} 243 self._cross_scenes: List[str] = [] 244 self._load_poses(Path(parquet_path)) 245 246 # ── pose loading (one-time) ─────────────────────────────────────────── 247 248 def _load_poses(self, parquet_path: Path) -> None: 249 df = pd.read_parquet(parquet_path) 250 if "scene_type" in df.columns: 251 df = df[df["scene_type"].astype(str) == "multi_object"].copy() 252 self._df_cache = df # kept for ensure_pairs_extracted 253 254 # Scene-wise split assignment 255 scene_split = df.groupby("scene_id")["split"].agg(_mode_split).dropna() 256 df = df.drop(columns=["split"]).merge( 257 scene_split.rename("split"), left_on="scene_id", right_index=True 258 ) 259 df = df[ 260 (df["split"].astype(str) == self.split) 261 & df["has_cam_pose"].fillna(False).astype(bool) 262 ] 263 264 for (scene, phase), grp in df.groupby(["scene_id", "phase"], sort=True): 265 phase = int(phase) 266 if scene in EXCLUDED_SCENE_IDS: 267 continue 268 if (scene, phase) in EXCLUDED_SCENE_PHASES: 269 continue 270 if phase not in VALID_PHASES: 271 continue 272 273 grp = grp.sort_values("frame_idx").reset_index(drop=True) 274 frame_idxs, rots, trans = [], [], [] 275 276 # Auto-extract cam_pose from tar shards if needed 277 self._ensure_cam_pose_extracted(scene, phase, grp) 278 279 for _, row in grp.iterrows(): 280 frame_filename = str(row["frame_filename"]) 281 stem = frame_filename.rsplit(".", 1)[0] 282 pose_dir = self.root / "scenes" / scene / str(phase) / "cam_pose" 283 npy_path = pose_dir / f"{stem}.npy" 284 npz_path = pose_dir / f"{stem}.npz" 285 pose_path = npy_path if npy_path.exists() else npz_path 286 rotation, translation = _load_pose_file(pose_path) 287 frame_idx = int(row["frame_idx"]) 288 frame_idxs.append(frame_idx) 289 rots.append(rotation) 290 trans.append(translation) 291 self._frame_filenames[(str(scene), phase, frame_idx)] = frame_filename 292 293 self._sequences[(scene, phase)] = _SeqPoses( 294 scene=scene, phase=phase, 295 frame_idxs=frame_idxs, rotations=rots, translations=trans, 296 ) 297 298 log.info( 299 "loaded poses: %d sequences, split=%s", 300 len(self._sequences), self.split, 301 ) 302 303 # ── tar extraction ───────────────────────────────────────────────────── 304 305 def _ensure_cam_pose_extracted( 306 self, scene: str, phase: int, grp: pd.DataFrame, 307 ) -> None: 308 """Extract cam_pose NPZ files from tar shards if not on disk. 309 310 If a tar shard is missing locally (partial HF cache), it is 311 downloaded via ``huggingface_hub`` before extraction. 312 """ 313 if self._snapshot_root is None: 314 return 315 if "shard_cam_pose" not in grp.columns: 316 return 317 318 for _, row in grp.iterrows(): 319 stem = str(row["frame_filename"]).rsplit(".", 1)[0] 320 pose_dir = ( 321 self.root / "scenes" / scene / str(phase) 322 / "cam_pose" 323 ) 324 out_path = pose_dir / f"{stem}.npy" 325 if out_path.exists() or (pose_dir / f"{stem}.npz").exists(): 326 continue 327 shard_rel = str(row["shard_cam_pose"]) 328 tar_path = self._snapshot_root / shard_rel 329 if not tar_path.exists(): 330 tar_path = self._download_shard(shard_rel) 331 if tar_path is None: 332 raise FileNotFoundError( 333 f"cam_pose shard not found and download failed: {shard_rel}" 334 ) 335 member = f"cam_pose/{stem}.npy" 336 _extract_from_tar(tar_path, member, out_path) 337 338 def _download_shard(self, shard_rel: str) -> Optional[Path]: 339 """Download a single tar shard from HuggingFace Hub. 340 341 Returns the local path on success, None on failure. 342 """ 343 try: 344 from huggingface_hub import hf_hub_download 345 except ImportError: 346 log.warning( 347 "huggingface_hub not installed — cannot download shard %s. " 348 "Install with: pip install 'rpx-benchmark[hub]'", 349 shard_rel, 350 ) 351 return None 352 353 repo_id = self._repo_id or "IRVLUTD/RPX" 354 log.info("downloading shard %s from %s ...", shard_rel, repo_id) 355 try: 356 local = hf_hub_download( 357 repo_id=repo_id, 358 filename=shard_rel, 359 repo_type="dataset", 360 # Never mix files from the Hub's default branch into a 361 # benchmark pinned to a specific RPX snapshot. 362 revision=(self._snapshot_root.name if self._snapshot_root else None), 363 ) 364 return Path(local) 365 except Exception as e: # noqa: BLE001 366 log.warning("shard download failed for %s: %s", shard_rel, e) 367 return None 368 369 def ensure_pairs_extracted(self, samples: Optional[List[Dict]] = None) -> None: 370 """Extract RGB for all selected pairs so the dataset iterator works. 371 372 Call after :meth:`pairs` or :meth:`manifest`. Idempotent — 373 skips files already on disk. 374 """ 375 if self._snapshot_root is None: 376 return 377 if samples is None: 378 samples = self.pairs() 379 380 df = self._df_cache if hasattr(self, "_df_cache") else None 381 if df is None: 382 return 383 384 # Build a lookup: (scene, phase, stem) → shard_rgb 385 if not hasattr(self, "_shard_lookup"): 386 lookup: Dict[Tuple[str, int, str], str] = {} 387 for _, row in df.iterrows(): 388 s = str(row["scene_id"]) 389 p = int(row["phase"]) 390 stem = str(row["frame_filename"]).rsplit(".", 1)[0] 391 if "shard_rgb" in row.index: 392 lookup[(s, p, stem)] = str(row["shard_rgb"]) 393 self._shard_lookup = lookup 394 395 for s in samples: 396 for path_key in ("rgb", "rgb_b"): 397 rel = s.get(path_key, "") 398 out_path = self.root / rel 399 if out_path.exists(): 400 continue 401 parts = Path(rel).parts 402 if len(parts) < 5: 403 continue 404 scene, phase_s, _mod, fname = parts[1], parts[2], parts[3], parts[4] 405 stem = fname.rsplit(".", 1)[0] 406 shard = self._shard_lookup.get((scene, int(phase_s), stem)) 407 if shard is None: 408 continue 409 tar_path = self._snapshot_root / shard 410 if not tar_path.exists(): 411 downloaded = self._download_shard(shard) 412 if downloaded is None: 413 continue 414 tar_path = downloaded 415 member = f"rgb/{fname}" 416 _extract_from_tar(tar_path, member, out_path) 417 418 # ── pair generators ─────────────────────────────────────────────────── 419 420 def _intra_pairs( 421 self, seq: _SeqPoses, rng: np.random.Generator, 422 ) -> List[Dict[str, Any]]: 423 """Stratified random intra-phase pairs for one sequence.""" 424 cfg = self.cfg 425 n = len(seq.frame_idxs) 426 # Collect candidates per bin 427 bins: List[List[Tuple[int, int, float, float]]] = [[] for _ in BIN_NAMES] 428 index_by_frame = {frame: index for index, frame in enumerate(seq.frame_idxs)} 429 for i, frame_a in enumerate(seq.frame_idxs): 430 frame_b = frame_a + cfg.frame_gap 431 j = index_by_frame.get(frame_b) 432 if j is None: 433 continue 434 rot = _rotation_deg(seq.rotations[i], seq.rotations[j]) 435 if rot < cfg.min_rotation_deg: 436 continue 437 t_m = float(np.linalg.norm(seq.translations[j] - seq.translations[i])) 438 if t_m < cfg.min_translation_m: 439 continue 440 bi = _bin_index(rot) 441 if bi >= 0: 442 bins[bi].append((frame_a, frame_b, rot, t_m)) 443 444 out: List[Dict[str, Any]] = [] 445 for bi, name in enumerate(BIN_NAMES): 446 cands = bins[bi] 447 if not cands: 448 continue 449 k = min(len(cands), cfg.intra_pairs_per_bin) 450 sel = rng.choice(len(cands), size=k, replace=False) 451 for idx in sel: 452 fa, fb, rot, t_m = cands[idx] 453 out.append(self._make_entry( 454 seq.scene, seq.phase, fa, seq.phase, fb, 455 rot, t_m, "intra_phase", name, 456 )) 457 return out 458 459 def _cross_pairs( 460 self, scene: str, rng: np.random.Generator, 461 ) -> List[Dict[str, Any]]: 462 """Stratified random cross-phase pairs (phase 0 ↔ phase 2).""" 463 cfg = self.cfg 464 s0 = self._sequences[(scene, 0)] 465 s2 = self._sequences[(scene, 2)] 466 n0, n2 = len(s0.frame_idxs), len(s2.frame_idxs) 467 total = n0 * n2 468 469 # Subsample candidate index pairs for speed 470 if total > cfg.cross_max_candidates: 471 flat = rng.choice(total, size=cfg.cross_max_candidates, replace=False) 472 ii, jj = flat // n2, flat % n2 473 else: 474 ii = np.repeat(np.arange(n0), n2) 475 jj = np.tile(np.arange(n2), n0) 476 477 bins: List[List[Tuple[int, int, float, float]]] = [[] for _ in BIN_NAMES] 478 for idx in range(len(ii)): 479 i, j = int(ii[idx]), int(jj[idx]) 480 rot = _rotation_deg(s0.rotations[i], s2.rotations[j]) 481 if rot < cfg.min_rotation_deg: 482 continue 483 t_m = float(np.linalg.norm(s2.translations[j] - s0.translations[i])) 484 bi = _bin_index(rot) 485 if bi >= 0: 486 bins[bi].append((s0.frame_idxs[i], s2.frame_idxs[j], rot, t_m)) 487 488 out: List[Dict[str, Any]] = [] 489 for bi, name in enumerate(BIN_NAMES): 490 cands = bins[bi] 491 if not cands: 492 continue 493 k = min(len(cands), cfg.cross_pairs_per_bin) 494 sel = rng.choice(len(cands), size=k, replace=False) 495 for idx in sel: 496 fa, fb, rot, t_m = cands[idx] 497 out.append(self._make_entry( 498 scene, 0, fa, 2, fb, 499 rot, t_m, "cross_phase", name, 500 )) 501 return out 502 503 def _temporal_chains( 504 self, seq: _SeqPoses, rng: np.random.Generator, 505 ) -> List[Dict[str, Any]]: 506 """Ordered chains of consecutive pairs for drift evaluation.""" 507 cfg = self.cfg 508 index_by_frame = {frame: index for index, frame in enumerate(seq.frame_idxs)} 509 starts = [ 510 frame 511 for frame in seq.frame_idxs 512 if all( 513 frame + step * cfg.chain_stride in index_by_frame 514 for step in range(cfg.chain_length + 1) 515 ) 516 ] 517 if not starts: 518 return [] 519 k = min(cfg.chain_count, len(starts)) 520 starts = [starts[index] for index in rng.choice(len(starts), size=k, replace=False)] 521 522 out: List[Dict[str, Any]] = [] 523 for ci, start_frame in enumerate(starts): 524 for pi in range(cfg.chain_length): 525 frame_a = start_frame + pi * cfg.chain_stride 526 frame_b = start_frame + (pi + 1) * cfg.chain_stride 527 i = index_by_frame[frame_a] 528 j = index_by_frame[frame_b] 529 rot = _rotation_deg(seq.rotations[i], seq.rotations[j]) 530 t_m = float(np.linalg.norm(seq.translations[j] - seq.translations[i])) 531 entry = self._make_entry( 532 seq.scene, seq.phase, seq.frame_idxs[i], 533 seq.phase, seq.frame_idxs[j], 534 rot, t_m, "temporal_chain", None, 535 ) 536 # Override ID to include chain index (avoids duplicates 537 # when two chains share a frame pair) 538 sa = f"{seq.frame_idxs[i]:05d}" 539 sb = f"{seq.frame_idxs[j]:05d}" 540 entry["id"] = f"{seq.scene}__{seq.phase}__c{ci}_{pi}__{sa}__{sb}" 541 entry["metadata"]["chain_id"] = ( 542 f"{seq.scene}__{seq.phase}__chain{ci}" 543 ) 544 entry["metadata"]["chain_position"] = pi 545 out.append(entry) 546 return out 547 548 # ── entry builder ───────────────────────────────────────────────────── 549 550 def _make_entry( 551 self, 552 scene: str, 553 phase_a: int, frame_a: int, 554 phase_b: int, frame_b: int, 555 rot_deg: float, t_m: float, 556 pair_type: str, rotation_bin: Optional[str], 557 ) -> Dict[str, Any]: 558 frame_filenames = getattr(self, "_frame_filenames", {}) 559 filename_a = frame_filenames.get( 560 (str(scene), phase_a, frame_a), f"{frame_a:05d}.png" 561 ) 562 filename_b = frame_filenames.get( 563 (str(scene), phase_b, frame_b), f"{frame_b:05d}.png" 564 ) 565 sa = Path(filename_a).stem 566 sb = Path(filename_b).stem 567 if pair_type == "cross_phase": 568 pair_id = f"{scene}__cross__0_{sa}__2_{sb}" 569 elif pair_type == "temporal_chain": 570 pair_id = f"{scene}__{phase_a}__tc__{sa}__{sb}" 571 else: 572 pair_id = f"{scene}__{phase_a}__{sa}__{sb}" 573 574 return { 575 "id": pair_id, 576 "scene_id": scene, 577 "phase": phase_a, 578 "phase_b": phase_b, 579 "frame_idx": frame_a, 580 "frame_idx_b": frame_b, 581 "difficulty": None, # filled by split 582 "pair_type": pair_type, 583 "rotation_bin": rotation_bin, 584 "metadata": { 585 "scene_id": scene, 586 "phase_idx": phase_a, 587 "phase_idx_b": phase_b, 588 "frame": sa, 589 "frame_b": sb, 590 "pair_type": pair_type, 591 "rotation_bin": rotation_bin, 592 "rotation_deg_gt": rot_deg, 593 "translation_m_gt": t_m, 594 }, 595 "rgb": f"scenes/{scene}/{phase_a}/rgb/{filename_a}", 596 "rgb_b": f"scenes/{scene}/{phase_b}/rgb/{filename_b}", 597 "pose_a": self._pose_relative_path(scene, phase_a, sa), 598 "pose_b": self._pose_relative_path(scene, phase_b, sb), 599 } 600 601 def _pose_relative_path(self, scene: str, phase: int, stem: str) -> str: 602 """Return the published NPY path, retaining legacy NPZ compatibility.""" 603 relative_dir = Path("scenes") / scene / str(phase) / "cam_pose" 604 root = getattr(self, "root", None) 605 if root is not None and (root / relative_dir / f"{stem}.npz").exists(): 606 return str(relative_dir / f"{stem}.npz") 607 return str(relative_dir / f"{stem}.npy") 608 609 # ── public API ──────────────────────────────────────────────────────── 610 611 def iter_pairs(self) -> Iterator[Dict[str, Any]]: 612 """Yield pair dicts in deterministic order. Zero disk writes.""" 613 rng = np.random.default_rng(self.cfg.seed) 614 615 for key in sorted(self._sequences): 616 seq = self._sequences[key] 617 for p in self._intra_pairs(seq, rng): 618 p["difficulty"] = self.split 619 yield p 620 621 for key in sorted(self._sequences): 622 seq = self._sequences[key] 623 for p in self._temporal_chains(seq, rng): 624 p["difficulty"] = self.split 625 yield p 626 627 def pairs(self) -> List[Dict[str, Any]]: 628 """Materialise all pairs as a list (for manifest / stats).""" 629 return list(self.iter_pairs()) 630 631 def manifest(self) -> Dict[str, Any]: 632 """Build a manifest dict consumable by ``RPXDataset.from_dict``. 633 634 No files are written — call ``json.dump`` yourself if you want 635 to cache it, or pass directly to the dataset constructor. 636 """ 637 samples = self.pairs() 638 stats = self._compute_stats(samples) 639 return { 640 "task": "relative_camera_pose", 641 "split": self.split, 642 "root": str(self.root), 643 "samples": samples, 644 "_sampler": { 645 "name": "stratified_v2", 646 "version": "2.0", 647 "rotation_bins_deg": [[lo, hi] for lo, hi in ROTATION_BINS], 648 "valid_phases": list(VALID_PHASES), 649 "seed": self.cfg.seed, 650 "intra_pairs_per_bin": self.cfg.intra_pairs_per_bin, 651 "cross_pairs_per_bin": 0, 652 "cross_phase_status": "excluded_unrelated_t265_world_frames", 653 "chain_count": self.cfg.chain_count, 654 "chain_length": self.cfg.chain_length, 655 "chain_stride": self.cfg.chain_stride, 656 "frame_gap": self.cfg.frame_gap, 657 "excluded_scenes": sorted(EXCLUDED_SCENE_IDS), 658 "excluded_scene_phases": [ 659 f"{s}/{p}" for s, p in sorted(EXCLUDED_SCENE_PHASES) 660 ], 661 **stats, 662 }, 663 } 664 665 def as_dataset(self, batch_size: int = 1): 666 """Return an :class:`RPXDataset` ready for the runner. 667 668 Automatically extracts RGB files from tar shards for the 669 selected pairs (idempotent, skips existing files). 670 """ 671 from .loader import RPXDataset # noqa: PLC0415 672 673 m = self.manifest() 674 self.ensure_pairs_extracted(m["samples"]) 675 return RPXDataset.from_dict(m, batch_size=batch_size) 676 677 @staticmethod 678 def _compute_stats(samples: List[Dict[str, Any]]) -> Dict[str, Any]: 679 by_type = {"intra_phase": 0, "cross_phase": 0, "temporal_chain": 0} 680 by_bin = {n: 0 for n in BIN_NAMES} 681 scenes = set() 682 for s in samples: 683 by_type[s["pair_type"]] += 1 684 if s.get("rotation_bin"): 685 by_bin[s["rotation_bin"]] += 1 686 scenes.add(s["scene_id"]) 687 return { 688 "total_pairs": len(samples), 689 "intra_pairs": by_type["intra_phase"], 690 "cross_pairs": by_type["cross_phase"], 691 "temporal_pairs": by_type["temporal_chain"], 692 "pairs_by_bin": by_bin, 693 "n_scenes": len(scenes), 694 } 695 696 def summary(self) -> str: 697 """Human-readable summary string.""" 698 m = self.manifest() 699 s = m["_sampler"] 700 lines = [ 701 f"RPX-RCPE Pair Generator — split={self.split}", 702 f" Scenes: {s['n_scenes']}", 703 f" Intra-phase: {s['intra_pairs']:,}", 704 f" Cross-phase: {s['cross_pairs']:,}", 705 f" Temporal chains: {s['temporal_pairs']:,}", 706 f" ──────────────────────", 707 f" TOTAL: {s['total_pairs']:,}", 708 f" By bin: {s['pairs_by_bin']}", 709 f" Seed: {s['seed']}", 710 ] 711 return "\n".join(lines)
Scenes excluded entirely — no ICP-optimized poses available.
(scene_id, phase) pairs where ICP optimization failed.
Clutter and Clean; Interaction is excluded from the RCPE protocol.
159@dataclass 160class PairConfig: 161 """All knobs for pair generation. Deterministic given ``seed``.""" 162 163 # Intra-phase 164 intra_pairs_per_bin: int = 50 165 frame_gap: int = 5 166 # D6 retains every available t -> t+5 pair. The first rotation bin 167 # therefore starts at zero instead of dropping low-motion pairs. 168 min_rotation_deg: float = 0.0 169 min_translation_m: float = 0.0 170 171 # Cross-phase is locked off: separate captures have unrelated T265 worlds. 172 cross_pairs_per_bin: int = 0 173 cross_max_candidates: int = 5000 174 175 # Temporal chains 176 chain_count: int = 5 177 chain_length: int = 10 178 chain_stride: int = 5 179 180 seed: int = RPX_SEED
All knobs for pair generation. Deterministic given seed.
197class PosePairGenerator: 198 """Deterministic on-the-fly pair generator. 199 200 Loads only poses (NPZ, lightweight) at construction time. 201 Pixel data is never touched — that happens downstream in the 202 :class:`RPXDataset` iterator. 203 204 Parameters 205 ---------- 206 extracted_root : Path 207 Root of the extracted frames tree (contains ``scenes/…``). 208 parquet_path : Path 209 ``frames_v1.parquet`` with scene/phase/frame metadata. 210 split : str 211 ``"easy"`` | ``"medium"`` | ``"hard"``. 212 config : PairConfig, optional 213 Generation parameters. Defaults are the standard benchmark 214 settings (exact t→t+5 pairs plus stride-5 temporal chains). 215 snapshot_root : Path, optional 216 HF snapshot root (parent of ``manifest/`` and tar shards). 217 When provided, cam_pose files are auto-extracted from tars 218 if not already on disk. If omitted, files must already be 219 extracted under ``extracted_root``. 220 """ 221 222 def __init__( 223 self, 224 extracted_root: Path | str, 225 parquet_path: Path | str, 226 split: str, 227 config: PairConfig | None = None, 228 snapshot_root: Path | str | None = None, 229 repo_id: str | None = None, 230 ) -> None: 231 self.root = Path(extracted_root) 232 self.split = split 233 self.cfg = config or PairConfig() 234 if self.cfg.cross_pairs_per_bin != 0: 235 raise ConfigError( 236 "cross-phase RCPE pairs are invalid because each phase has an " 237 "unrelated T265 local world frame; cross_pairs_per_bin must be 0" 238 ) 239 self._snapshot_root = Path(snapshot_root) if snapshot_root else None 240 self._repo_id = repo_id 241 242 self._sequences: Dict[Tuple[str, int], _SeqPoses] = {} 243 self._frame_filenames: Dict[Tuple[str, int, int], str] = {} 244 self._cross_scenes: List[str] = [] 245 self._load_poses(Path(parquet_path)) 246 247 # ── pose loading (one-time) ─────────────────────────────────────────── 248 249 def _load_poses(self, parquet_path: Path) -> None: 250 df = pd.read_parquet(parquet_path) 251 if "scene_type" in df.columns: 252 df = df[df["scene_type"].astype(str) == "multi_object"].copy() 253 self._df_cache = df # kept for ensure_pairs_extracted 254 255 # Scene-wise split assignment 256 scene_split = df.groupby("scene_id")["split"].agg(_mode_split).dropna() 257 df = df.drop(columns=["split"]).merge( 258 scene_split.rename("split"), left_on="scene_id", right_index=True 259 ) 260 df = df[ 261 (df["split"].astype(str) == self.split) 262 & df["has_cam_pose"].fillna(False).astype(bool) 263 ] 264 265 for (scene, phase), grp in df.groupby(["scene_id", "phase"], sort=True): 266 phase = int(phase) 267 if scene in EXCLUDED_SCENE_IDS: 268 continue 269 if (scene, phase) in EXCLUDED_SCENE_PHASES: 270 continue 271 if phase not in VALID_PHASES: 272 continue 273 274 grp = grp.sort_values("frame_idx").reset_index(drop=True) 275 frame_idxs, rots, trans = [], [], [] 276 277 # Auto-extract cam_pose from tar shards if needed 278 self._ensure_cam_pose_extracted(scene, phase, grp) 279 280 for _, row in grp.iterrows(): 281 frame_filename = str(row["frame_filename"]) 282 stem = frame_filename.rsplit(".", 1)[0] 283 pose_dir = self.root / "scenes" / scene / str(phase) / "cam_pose" 284 npy_path = pose_dir / f"{stem}.npy" 285 npz_path = pose_dir / f"{stem}.npz" 286 pose_path = npy_path if npy_path.exists() else npz_path 287 rotation, translation = _load_pose_file(pose_path) 288 frame_idx = int(row["frame_idx"]) 289 frame_idxs.append(frame_idx) 290 rots.append(rotation) 291 trans.append(translation) 292 self._frame_filenames[(str(scene), phase, frame_idx)] = frame_filename 293 294 self._sequences[(scene, phase)] = _SeqPoses( 295 scene=scene, phase=phase, 296 frame_idxs=frame_idxs, rotations=rots, translations=trans, 297 ) 298 299 log.info( 300 "loaded poses: %d sequences, split=%s", 301 len(self._sequences), self.split, 302 ) 303 304 # ── tar extraction ───────────────────────────────────────────────────── 305 306 def _ensure_cam_pose_extracted( 307 self, scene: str, phase: int, grp: pd.DataFrame, 308 ) -> None: 309 """Extract cam_pose NPZ files from tar shards if not on disk. 310 311 If a tar shard is missing locally (partial HF cache), it is 312 downloaded via ``huggingface_hub`` before extraction. 313 """ 314 if self._snapshot_root is None: 315 return 316 if "shard_cam_pose" not in grp.columns: 317 return 318 319 for _, row in grp.iterrows(): 320 stem = str(row["frame_filename"]).rsplit(".", 1)[0] 321 pose_dir = ( 322 self.root / "scenes" / scene / str(phase) 323 / "cam_pose" 324 ) 325 out_path = pose_dir / f"{stem}.npy" 326 if out_path.exists() or (pose_dir / f"{stem}.npz").exists(): 327 continue 328 shard_rel = str(row["shard_cam_pose"]) 329 tar_path = self._snapshot_root / shard_rel 330 if not tar_path.exists(): 331 tar_path = self._download_shard(shard_rel) 332 if tar_path is None: 333 raise FileNotFoundError( 334 f"cam_pose shard not found and download failed: {shard_rel}" 335 ) 336 member = f"cam_pose/{stem}.npy" 337 _extract_from_tar(tar_path, member, out_path) 338 339 def _download_shard(self, shard_rel: str) -> Optional[Path]: 340 """Download a single tar shard from HuggingFace Hub. 341 342 Returns the local path on success, None on failure. 343 """ 344 try: 345 from huggingface_hub import hf_hub_download 346 except ImportError: 347 log.warning( 348 "huggingface_hub not installed — cannot download shard %s. " 349 "Install with: pip install 'rpx-benchmark[hub]'", 350 shard_rel, 351 ) 352 return None 353 354 repo_id = self._repo_id or "IRVLUTD/RPX" 355 log.info("downloading shard %s from %s ...", shard_rel, repo_id) 356 try: 357 local = hf_hub_download( 358 repo_id=repo_id, 359 filename=shard_rel, 360 repo_type="dataset", 361 # Never mix files from the Hub's default branch into a 362 # benchmark pinned to a specific RPX snapshot. 363 revision=(self._snapshot_root.name if self._snapshot_root else None), 364 ) 365 return Path(local) 366 except Exception as e: # noqa: BLE001 367 log.warning("shard download failed for %s: %s", shard_rel, e) 368 return None 369 370 def ensure_pairs_extracted(self, samples: Optional[List[Dict]] = None) -> None: 371 """Extract RGB for all selected pairs so the dataset iterator works. 372 373 Call after :meth:`pairs` or :meth:`manifest`. Idempotent — 374 skips files already on disk. 375 """ 376 if self._snapshot_root is None: 377 return 378 if samples is None: 379 samples = self.pairs() 380 381 df = self._df_cache if hasattr(self, "_df_cache") else None 382 if df is None: 383 return 384 385 # Build a lookup: (scene, phase, stem) → shard_rgb 386 if not hasattr(self, "_shard_lookup"): 387 lookup: Dict[Tuple[str, int, str], str] = {} 388 for _, row in df.iterrows(): 389 s = str(row["scene_id"]) 390 p = int(row["phase"]) 391 stem = str(row["frame_filename"]).rsplit(".", 1)[0] 392 if "shard_rgb" in row.index: 393 lookup[(s, p, stem)] = str(row["shard_rgb"]) 394 self._shard_lookup = lookup 395 396 for s in samples: 397 for path_key in ("rgb", "rgb_b"): 398 rel = s.get(path_key, "") 399 out_path = self.root / rel 400 if out_path.exists(): 401 continue 402 parts = Path(rel).parts 403 if len(parts) < 5: 404 continue 405 scene, phase_s, _mod, fname = parts[1], parts[2], parts[3], parts[4] 406 stem = fname.rsplit(".", 1)[0] 407 shard = self._shard_lookup.get((scene, int(phase_s), stem)) 408 if shard is None: 409 continue 410 tar_path = self._snapshot_root / shard 411 if not tar_path.exists(): 412 downloaded = self._download_shard(shard) 413 if downloaded is None: 414 continue 415 tar_path = downloaded 416 member = f"rgb/{fname}" 417 _extract_from_tar(tar_path, member, out_path) 418 419 # ── pair generators ─────────────────────────────────────────────────── 420 421 def _intra_pairs( 422 self, seq: _SeqPoses, rng: np.random.Generator, 423 ) -> List[Dict[str, Any]]: 424 """Stratified random intra-phase pairs for one sequence.""" 425 cfg = self.cfg 426 n = len(seq.frame_idxs) 427 # Collect candidates per bin 428 bins: List[List[Tuple[int, int, float, float]]] = [[] for _ in BIN_NAMES] 429 index_by_frame = {frame: index for index, frame in enumerate(seq.frame_idxs)} 430 for i, frame_a in enumerate(seq.frame_idxs): 431 frame_b = frame_a + cfg.frame_gap 432 j = index_by_frame.get(frame_b) 433 if j is None: 434 continue 435 rot = _rotation_deg(seq.rotations[i], seq.rotations[j]) 436 if rot < cfg.min_rotation_deg: 437 continue 438 t_m = float(np.linalg.norm(seq.translations[j] - seq.translations[i])) 439 if t_m < cfg.min_translation_m: 440 continue 441 bi = _bin_index(rot) 442 if bi >= 0: 443 bins[bi].append((frame_a, frame_b, rot, t_m)) 444 445 out: List[Dict[str, Any]] = [] 446 for bi, name in enumerate(BIN_NAMES): 447 cands = bins[bi] 448 if not cands: 449 continue 450 k = min(len(cands), cfg.intra_pairs_per_bin) 451 sel = rng.choice(len(cands), size=k, replace=False) 452 for idx in sel: 453 fa, fb, rot, t_m = cands[idx] 454 out.append(self._make_entry( 455 seq.scene, seq.phase, fa, seq.phase, fb, 456 rot, t_m, "intra_phase", name, 457 )) 458 return out 459 460 def _cross_pairs( 461 self, scene: str, rng: np.random.Generator, 462 ) -> List[Dict[str, Any]]: 463 """Stratified random cross-phase pairs (phase 0 ↔ phase 2).""" 464 cfg = self.cfg 465 s0 = self._sequences[(scene, 0)] 466 s2 = self._sequences[(scene, 2)] 467 n0, n2 = len(s0.frame_idxs), len(s2.frame_idxs) 468 total = n0 * n2 469 470 # Subsample candidate index pairs for speed 471 if total > cfg.cross_max_candidates: 472 flat = rng.choice(total, size=cfg.cross_max_candidates, replace=False) 473 ii, jj = flat // n2, flat % n2 474 else: 475 ii = np.repeat(np.arange(n0), n2) 476 jj = np.tile(np.arange(n2), n0) 477 478 bins: List[List[Tuple[int, int, float, float]]] = [[] for _ in BIN_NAMES] 479 for idx in range(len(ii)): 480 i, j = int(ii[idx]), int(jj[idx]) 481 rot = _rotation_deg(s0.rotations[i], s2.rotations[j]) 482 if rot < cfg.min_rotation_deg: 483 continue 484 t_m = float(np.linalg.norm(s2.translations[j] - s0.translations[i])) 485 bi = _bin_index(rot) 486 if bi >= 0: 487 bins[bi].append((s0.frame_idxs[i], s2.frame_idxs[j], rot, t_m)) 488 489 out: List[Dict[str, Any]] = [] 490 for bi, name in enumerate(BIN_NAMES): 491 cands = bins[bi] 492 if not cands: 493 continue 494 k = min(len(cands), cfg.cross_pairs_per_bin) 495 sel = rng.choice(len(cands), size=k, replace=False) 496 for idx in sel: 497 fa, fb, rot, t_m = cands[idx] 498 out.append(self._make_entry( 499 scene, 0, fa, 2, fb, 500 rot, t_m, "cross_phase", name, 501 )) 502 return out 503 504 def _temporal_chains( 505 self, seq: _SeqPoses, rng: np.random.Generator, 506 ) -> List[Dict[str, Any]]: 507 """Ordered chains of consecutive pairs for drift evaluation.""" 508 cfg = self.cfg 509 index_by_frame = {frame: index for index, frame in enumerate(seq.frame_idxs)} 510 starts = [ 511 frame 512 for frame in seq.frame_idxs 513 if all( 514 frame + step * cfg.chain_stride in index_by_frame 515 for step in range(cfg.chain_length + 1) 516 ) 517 ] 518 if not starts: 519 return [] 520 k = min(cfg.chain_count, len(starts)) 521 starts = [starts[index] for index in rng.choice(len(starts), size=k, replace=False)] 522 523 out: List[Dict[str, Any]] = [] 524 for ci, start_frame in enumerate(starts): 525 for pi in range(cfg.chain_length): 526 frame_a = start_frame + pi * cfg.chain_stride 527 frame_b = start_frame + (pi + 1) * cfg.chain_stride 528 i = index_by_frame[frame_a] 529 j = index_by_frame[frame_b] 530 rot = _rotation_deg(seq.rotations[i], seq.rotations[j]) 531 t_m = float(np.linalg.norm(seq.translations[j] - seq.translations[i])) 532 entry = self._make_entry( 533 seq.scene, seq.phase, seq.frame_idxs[i], 534 seq.phase, seq.frame_idxs[j], 535 rot, t_m, "temporal_chain", None, 536 ) 537 # Override ID to include chain index (avoids duplicates 538 # when two chains share a frame pair) 539 sa = f"{seq.frame_idxs[i]:05d}" 540 sb = f"{seq.frame_idxs[j]:05d}" 541 entry["id"] = f"{seq.scene}__{seq.phase}__c{ci}_{pi}__{sa}__{sb}" 542 entry["metadata"]["chain_id"] = ( 543 f"{seq.scene}__{seq.phase}__chain{ci}" 544 ) 545 entry["metadata"]["chain_position"] = pi 546 out.append(entry) 547 return out 548 549 # ── entry builder ───────────────────────────────────────────────────── 550 551 def _make_entry( 552 self, 553 scene: str, 554 phase_a: int, frame_a: int, 555 phase_b: int, frame_b: int, 556 rot_deg: float, t_m: float, 557 pair_type: str, rotation_bin: Optional[str], 558 ) -> Dict[str, Any]: 559 frame_filenames = getattr(self, "_frame_filenames", {}) 560 filename_a = frame_filenames.get( 561 (str(scene), phase_a, frame_a), f"{frame_a:05d}.png" 562 ) 563 filename_b = frame_filenames.get( 564 (str(scene), phase_b, frame_b), f"{frame_b:05d}.png" 565 ) 566 sa = Path(filename_a).stem 567 sb = Path(filename_b).stem 568 if pair_type == "cross_phase": 569 pair_id = f"{scene}__cross__0_{sa}__2_{sb}" 570 elif pair_type == "temporal_chain": 571 pair_id = f"{scene}__{phase_a}__tc__{sa}__{sb}" 572 else: 573 pair_id = f"{scene}__{phase_a}__{sa}__{sb}" 574 575 return { 576 "id": pair_id, 577 "scene_id": scene, 578 "phase": phase_a, 579 "phase_b": phase_b, 580 "frame_idx": frame_a, 581 "frame_idx_b": frame_b, 582 "difficulty": None, # filled by split 583 "pair_type": pair_type, 584 "rotation_bin": rotation_bin, 585 "metadata": { 586 "scene_id": scene, 587 "phase_idx": phase_a, 588 "phase_idx_b": phase_b, 589 "frame": sa, 590 "frame_b": sb, 591 "pair_type": pair_type, 592 "rotation_bin": rotation_bin, 593 "rotation_deg_gt": rot_deg, 594 "translation_m_gt": t_m, 595 }, 596 "rgb": f"scenes/{scene}/{phase_a}/rgb/{filename_a}", 597 "rgb_b": f"scenes/{scene}/{phase_b}/rgb/{filename_b}", 598 "pose_a": self._pose_relative_path(scene, phase_a, sa), 599 "pose_b": self._pose_relative_path(scene, phase_b, sb), 600 } 601 602 def _pose_relative_path(self, scene: str, phase: int, stem: str) -> str: 603 """Return the published NPY path, retaining legacy NPZ compatibility.""" 604 relative_dir = Path("scenes") / scene / str(phase) / "cam_pose" 605 root = getattr(self, "root", None) 606 if root is not None and (root / relative_dir / f"{stem}.npz").exists(): 607 return str(relative_dir / f"{stem}.npz") 608 return str(relative_dir / f"{stem}.npy") 609 610 # ── public API ──────────────────────────────────────────────────────── 611 612 def iter_pairs(self) -> Iterator[Dict[str, Any]]: 613 """Yield pair dicts in deterministic order. Zero disk writes.""" 614 rng = np.random.default_rng(self.cfg.seed) 615 616 for key in sorted(self._sequences): 617 seq = self._sequences[key] 618 for p in self._intra_pairs(seq, rng): 619 p["difficulty"] = self.split 620 yield p 621 622 for key in sorted(self._sequences): 623 seq = self._sequences[key] 624 for p in self._temporal_chains(seq, rng): 625 p["difficulty"] = self.split 626 yield p 627 628 def pairs(self) -> List[Dict[str, Any]]: 629 """Materialise all pairs as a list (for manifest / stats).""" 630 return list(self.iter_pairs()) 631 632 def manifest(self) -> Dict[str, Any]: 633 """Build a manifest dict consumable by ``RPXDataset.from_dict``. 634 635 No files are written — call ``json.dump`` yourself if you want 636 to cache it, or pass directly to the dataset constructor. 637 """ 638 samples = self.pairs() 639 stats = self._compute_stats(samples) 640 return { 641 "task": "relative_camera_pose", 642 "split": self.split, 643 "root": str(self.root), 644 "samples": samples, 645 "_sampler": { 646 "name": "stratified_v2", 647 "version": "2.0", 648 "rotation_bins_deg": [[lo, hi] for lo, hi in ROTATION_BINS], 649 "valid_phases": list(VALID_PHASES), 650 "seed": self.cfg.seed, 651 "intra_pairs_per_bin": self.cfg.intra_pairs_per_bin, 652 "cross_pairs_per_bin": 0, 653 "cross_phase_status": "excluded_unrelated_t265_world_frames", 654 "chain_count": self.cfg.chain_count, 655 "chain_length": self.cfg.chain_length, 656 "chain_stride": self.cfg.chain_stride, 657 "frame_gap": self.cfg.frame_gap, 658 "excluded_scenes": sorted(EXCLUDED_SCENE_IDS), 659 "excluded_scene_phases": [ 660 f"{s}/{p}" for s, p in sorted(EXCLUDED_SCENE_PHASES) 661 ], 662 **stats, 663 }, 664 } 665 666 def as_dataset(self, batch_size: int = 1): 667 """Return an :class:`RPXDataset` ready for the runner. 668 669 Automatically extracts RGB files from tar shards for the 670 selected pairs (idempotent, skips existing files). 671 """ 672 from .loader import RPXDataset # noqa: PLC0415 673 674 m = self.manifest() 675 self.ensure_pairs_extracted(m["samples"]) 676 return RPXDataset.from_dict(m, batch_size=batch_size) 677 678 @staticmethod 679 def _compute_stats(samples: List[Dict[str, Any]]) -> Dict[str, Any]: 680 by_type = {"intra_phase": 0, "cross_phase": 0, "temporal_chain": 0} 681 by_bin = {n: 0 for n in BIN_NAMES} 682 scenes = set() 683 for s in samples: 684 by_type[s["pair_type"]] += 1 685 if s.get("rotation_bin"): 686 by_bin[s["rotation_bin"]] += 1 687 scenes.add(s["scene_id"]) 688 return { 689 "total_pairs": len(samples), 690 "intra_pairs": by_type["intra_phase"], 691 "cross_pairs": by_type["cross_phase"], 692 "temporal_pairs": by_type["temporal_chain"], 693 "pairs_by_bin": by_bin, 694 "n_scenes": len(scenes), 695 } 696 697 def summary(self) -> str: 698 """Human-readable summary string.""" 699 m = self.manifest() 700 s = m["_sampler"] 701 lines = [ 702 f"RPX-RCPE Pair Generator — split={self.split}", 703 f" Scenes: {s['n_scenes']}", 704 f" Intra-phase: {s['intra_pairs']:,}", 705 f" Cross-phase: {s['cross_pairs']:,}", 706 f" Temporal chains: {s['temporal_pairs']:,}", 707 f" ──────────────────────", 708 f" TOTAL: {s['total_pairs']:,}", 709 f" By bin: {s['pairs_by_bin']}", 710 f" Seed: {s['seed']}", 711 ] 712 return "\n".join(lines)
Deterministic on-the-fly pair generator.
Loads only poses (NPZ, lightweight) at construction time.
Pixel data is never touched — that happens downstream in the
RPXDataset iterator.
Parameters
extracted_root : Path
Root of the extracted frames tree (contains scenes/…).
parquet_path : Path
frames_v1.parquet with scene/phase/frame metadata.
split : str
"easy" | "medium" | "hard".
config : PairConfig, optional
Generation parameters. Defaults are the standard benchmark
settings (exact t→t+5 pairs plus stride-5 temporal chains).
snapshot_root : Path, optional
HF snapshot root (parent of manifest/ and tar shards).
When provided, cam_pose files are auto-extracted from tars
if not already on disk. If omitted, files must already be
extracted under extracted_root.
222 def __init__( 223 self, 224 extracted_root: Path | str, 225 parquet_path: Path | str, 226 split: str, 227 config: PairConfig | None = None, 228 snapshot_root: Path | str | None = None, 229 repo_id: str | None = None, 230 ) -> None: 231 self.root = Path(extracted_root) 232 self.split = split 233 self.cfg = config or PairConfig() 234 if self.cfg.cross_pairs_per_bin != 0: 235 raise ConfigError( 236 "cross-phase RCPE pairs are invalid because each phase has an " 237 "unrelated T265 local world frame; cross_pairs_per_bin must be 0" 238 ) 239 self._snapshot_root = Path(snapshot_root) if snapshot_root else None 240 self._repo_id = repo_id 241 242 self._sequences: Dict[Tuple[str, int], _SeqPoses] = {} 243 self._frame_filenames: Dict[Tuple[str, int, int], str] = {} 244 self._cross_scenes: List[str] = [] 245 self._load_poses(Path(parquet_path))
370 def ensure_pairs_extracted(self, samples: Optional[List[Dict]] = None) -> None: 371 """Extract RGB for all selected pairs so the dataset iterator works. 372 373 Call after :meth:`pairs` or :meth:`manifest`. Idempotent — 374 skips files already on disk. 375 """ 376 if self._snapshot_root is None: 377 return 378 if samples is None: 379 samples = self.pairs() 380 381 df = self._df_cache if hasattr(self, "_df_cache") else None 382 if df is None: 383 return 384 385 # Build a lookup: (scene, phase, stem) → shard_rgb 386 if not hasattr(self, "_shard_lookup"): 387 lookup: Dict[Tuple[str, int, str], str] = {} 388 for _, row in df.iterrows(): 389 s = str(row["scene_id"]) 390 p = int(row["phase"]) 391 stem = str(row["frame_filename"]).rsplit(".", 1)[0] 392 if "shard_rgb" in row.index: 393 lookup[(s, p, stem)] = str(row["shard_rgb"]) 394 self._shard_lookup = lookup 395 396 for s in samples: 397 for path_key in ("rgb", "rgb_b"): 398 rel = s.get(path_key, "") 399 out_path = self.root / rel 400 if out_path.exists(): 401 continue 402 parts = Path(rel).parts 403 if len(parts) < 5: 404 continue 405 scene, phase_s, _mod, fname = parts[1], parts[2], parts[3], parts[4] 406 stem = fname.rsplit(".", 1)[0] 407 shard = self._shard_lookup.get((scene, int(phase_s), stem)) 408 if shard is None: 409 continue 410 tar_path = self._snapshot_root / shard 411 if not tar_path.exists(): 412 downloaded = self._download_shard(shard) 413 if downloaded is None: 414 continue 415 tar_path = downloaded 416 member = f"rgb/{fname}" 417 _extract_from_tar(tar_path, member, out_path)
Extract RGB for all selected pairs so the dataset iterator works.
Call after pairs() or manifest(). Idempotent —
skips files already on disk.
612 def iter_pairs(self) -> Iterator[Dict[str, Any]]: 613 """Yield pair dicts in deterministic order. Zero disk writes.""" 614 rng = np.random.default_rng(self.cfg.seed) 615 616 for key in sorted(self._sequences): 617 seq = self._sequences[key] 618 for p in self._intra_pairs(seq, rng): 619 p["difficulty"] = self.split 620 yield p 621 622 for key in sorted(self._sequences): 623 seq = self._sequences[key] 624 for p in self._temporal_chains(seq, rng): 625 p["difficulty"] = self.split 626 yield p
Yield pair dicts in deterministic order. Zero disk writes.
628 def pairs(self) -> List[Dict[str, Any]]: 629 """Materialise all pairs as a list (for manifest / stats).""" 630 return list(self.iter_pairs())
Materialise all pairs as a list (for manifest / stats).
632 def manifest(self) -> Dict[str, Any]: 633 """Build a manifest dict consumable by ``RPXDataset.from_dict``. 634 635 No files are written — call ``json.dump`` yourself if you want 636 to cache it, or pass directly to the dataset constructor. 637 """ 638 samples = self.pairs() 639 stats = self._compute_stats(samples) 640 return { 641 "task": "relative_camera_pose", 642 "split": self.split, 643 "root": str(self.root), 644 "samples": samples, 645 "_sampler": { 646 "name": "stratified_v2", 647 "version": "2.0", 648 "rotation_bins_deg": [[lo, hi] for lo, hi in ROTATION_BINS], 649 "valid_phases": list(VALID_PHASES), 650 "seed": self.cfg.seed, 651 "intra_pairs_per_bin": self.cfg.intra_pairs_per_bin, 652 "cross_pairs_per_bin": 0, 653 "cross_phase_status": "excluded_unrelated_t265_world_frames", 654 "chain_count": self.cfg.chain_count, 655 "chain_length": self.cfg.chain_length, 656 "chain_stride": self.cfg.chain_stride, 657 "frame_gap": self.cfg.frame_gap, 658 "excluded_scenes": sorted(EXCLUDED_SCENE_IDS), 659 "excluded_scene_phases": [ 660 f"{s}/{p}" for s, p in sorted(EXCLUDED_SCENE_PHASES) 661 ], 662 **stats, 663 }, 664 }
Build a manifest dict consumable by RPXDataset.from_dict.
No files are written — call json.dump yourself if you want
to cache it, or pass directly to the dataset constructor.
666 def as_dataset(self, batch_size: int = 1): 667 """Return an :class:`RPXDataset` ready for the runner. 668 669 Automatically extracts RGB files from tar shards for the 670 selected pairs (idempotent, skips existing files). 671 """ 672 from .loader import RPXDataset # noqa: PLC0415 673 674 m = self.manifest() 675 self.ensure_pairs_extracted(m["samples"]) 676 return RPXDataset.from_dict(m, batch_size=batch_size)
Return an RPXDataset ready for the runner.
Automatically extracts RGB files from tar shards for the selected pairs (idempotent, skips existing files).
697 def summary(self) -> str: 698 """Human-readable summary string.""" 699 m = self.manifest() 700 s = m["_sampler"] 701 lines = [ 702 f"RPX-RCPE Pair Generator — split={self.split}", 703 f" Scenes: {s['n_scenes']}", 704 f" Intra-phase: {s['intra_pairs']:,}", 705 f" Cross-phase: {s['cross_pairs']:,}", 706 f" Temporal chains: {s['temporal_pairs']:,}", 707 f" ──────────────────────", 708 f" TOTAL: {s['total_pairs']:,}", 709 f" By bin: {s['pairs_by_bin']}", 710 f" Seed: {s['seed']}", 711 ] 712 return "\n".join(lines)
Human-readable summary string.