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)
log = <Logger rpx_benchmark.pose_pairs (WARNING)>
RPX_SEED = 5062026
EXCLUDED_SCENE_IDS: Set[str] = {'scene58'}

Scenes excluded entirely — no ICP-optimized poses available.

EXCLUDED_SCENE_PHASES: Set[Tuple[str, int]] = {('scene98.jsom.atrium', 0), ('scene83.jsom.garden.pot', 0), ('scene50.ecss.out.stairs', 2), ('scene100.jsom.atrium', 0), ('scene72.ecsw.atriumStairs', 2)}

(scene_id, phase) pairs where ICP optimization failed.

VALID_PHASES: Tuple[int, ...] = (0, 2)

Clutter and Clean; Interaction is excluded from the RCPE protocol.

ROTATION_BINS: List[Tuple[float, float]] = [(0.0, 15.0), (15.0, 45.0), (45.0, 90.0), (90.0, 180.000001)]
BIN_NAMES: List[str] = ['easy', 'medium', 'hard', 'extreme']
@dataclass
class PairConfig:
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.

PairConfig( intra_pairs_per_bin: int = 50, frame_gap: int = 5, min_rotation_deg: float = 0.0, min_translation_m: float = 0.0, cross_pairs_per_bin: int = 0, cross_max_candidates: int = 5000, chain_count: int = 5, chain_length: int = 10, chain_stride: int = 5, seed: int = 5062026)
intra_pairs_per_bin: int = 50
frame_gap: int = 5
min_rotation_deg: float = 0.0
min_translation_m: float = 0.0
cross_pairs_per_bin: int = 0
cross_max_candidates: int = 5000
chain_count: int = 5
chain_length: int = 10
chain_stride: int = 5
seed: int = 5062026
class PosePairGenerator:
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.

PosePairGenerator( extracted_root: pathlib.Path | str, parquet_path: pathlib.Path | str, split: str, config: PairConfig | None = None, snapshot_root: pathlib.Path | str | None = None, repo_id: str | None = None)
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))
root
split
cfg
def ensure_pairs_extracted(self, samples: Optional[List[Dict]] = None) -> None:
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.

def iter_pairs(self) -> Iterator[Dict[str, Any]]:
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.

def pairs(self) -> List[Dict[str, Any]]:
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).

def manifest(self) -> Dict[str, Any]:
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.

def as_dataset(self, batch_size: int = 1):
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).

def summary(self) -> str:
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.