rpx_benchmark.video_loader

Sequence-shaped loader for video tasks (Video Depth today; future video tasks).

Iterates per (scene, phase) clip, yielding ~rpx_benchmark.api.VideoSample. The loader supports an optional frame_budget + sampling mode for the temporal- resolution ablation study (run at full 250 frames + downsampled budgets such as 150/75/25 to characterise how each video depth model degrades with sparser input).

Cell-log granularity

One (scene, phase) clip → one cell-log row keyed (model, task, scene, phase, frame_budget). frame_budget=None (or the literal 0) is the canonical "full clip" row that feeds Φ; non-None budgets feed the ablation plot. Φ is computed only over frame_budget=None rows to keep the comparison across models honest.

Sampling modes

  • "all" — every frame on disk (the default). frame_budget must be None in this mode.
  • "stride" — uniform stride keeping the first and last frame anchored. Reduces to FPS-in-1D-timestamp when frames are evenly spaced (our 30 fps capture is).
  • "fps_se3" — farthest-point sampling in 3D camera-translation space (T265 positions). First and last frame anchored. Selects frame_budget frames that maximise spatial coverage of the camera path. This is the most interesting mode for a robotics paper: it answers "does losing frames hurt because of less data or because we miss key viewpoints?" — only the second is diagnostic. Deterministic given (scene, phase, frame_budget).

Per-clip scale-and-shift alignment for affine-invariant models is applied at the runner level, not here — this loader is decode-only.

Memory budget per full-budget clip at D435 resolution: ~540 MB (250 × 640 × 480 × 3 B RGB + 250 × 640 × 480 × 4 B depth). Workable on a 64 GB host with one clip in flight per worker.

  1"""Sequence-shaped loader for video tasks (Video Depth today; future video tasks).
  2
  3Iterates per ``(scene, phase)`` clip, yielding
  4:class:`~rpx_benchmark.api.VideoSample`. The loader supports an
  5optional ``frame_budget`` + ``sampling`` mode for the temporal-
  6resolution ablation study (run at full 250 frames + downsampled
  7budgets such as 150/75/25 to characterise how each video depth model
  8degrades with sparser input).
  9
 10Cell-log granularity
 11--------------------
 12
 13One ``(scene, phase)`` clip → one cell-log row keyed
 14``(model, task, scene, phase, frame_budget)``. ``frame_budget=None``
 15(or the literal 0) is the canonical "full clip" row that feeds Φ;
 16non-None budgets feed the ablation plot. Φ is computed only over
 17``frame_budget=None`` rows to keep the comparison across models
 18honest.
 19
 20Sampling modes
 21--------------
 22
 23* ``"all"`` — every frame on disk (the default). ``frame_budget`` must
 24  be ``None`` in this mode.
 25* ``"stride"`` — uniform stride keeping the first and last frame
 26  anchored. Reduces to FPS-in-1D-timestamp when frames are evenly
 27  spaced (our 30 fps capture is).
 28* ``"fps_se3"`` — farthest-point sampling in 3D camera-translation
 29  space (T265 positions). First and last frame anchored. Selects
 30  ``frame_budget`` frames that maximise spatial coverage of the
 31  camera path. This is the most interesting mode for a robotics
 32  paper: it answers "does losing frames hurt because of less data
 33  or because we miss key viewpoints?" — only the second is
 34  diagnostic. Deterministic given ``(scene, phase, frame_budget)``.
 35
 36Per-clip scale-and-shift alignment for affine-invariant models is
 37applied at the runner level, not here — this loader is decode-only.
 38
 39Memory budget per full-budget clip at D435 resolution: ~540 MB
 40(250 × 640 × 480 × 3 B RGB + 250 × 640 × 480 × 4 B depth). Workable
 41on a 64 GB host with one clip in flight per worker.
 42"""
 43
 44from __future__ import annotations
 45
 46import json
 47from dataclasses import dataclass
 48from pathlib import Path
 49from typing import Iterator, List, Literal, Optional
 50
 51import numpy as np
 52
 53from .api import TaskType, VideoDepthGroundTruth, VideoSample
 54from .decode_contracts import safe_load_depth, safe_load_pose, safe_load_rgb
 55from .exceptions import ConfigError, ManifestError
 56from .logging_utils import get_logger
 57from .metrics.depth_alignment import DEPTH_MAX_M, DEPTH_MIN_M
 58
 59log = get_logger(__name__)
 60
 61
 62SamplingMode = Literal["all", "stride", "fps_se3"]
 63
 64
 65@dataclass
 66class VideoDepthDataset:
 67    """Iterates per ``(scene, phase)`` clip for the video-depth task.
 68
 69    Yielded value is :class:`~rpx_benchmark.api.VideoSample` whose
 70    ``rgb_seq`` is a ``(T, H, W, 3) uint8`` stack and whose
 71    ``ground_truth`` is :class:`~rpx_benchmark.api.VideoDepthGroundTruth`
 72    with the same ``T``. When ``frame_budget`` is ``None`` (the
 73    canonical run), ``T`` is whatever the phase has on disk; otherwise
 74    ``T == frame_budget`` and ``metadata['frame_indices']`` records
 75    which original-phase indices were kept.
 76
 77    Parameters
 78    ----------
 79    samples : list[dict]
 80        Per-clip manifest entries (one dict per (scene, phase)).
 81        Produced by
 82        :func:`rpx_benchmark.dataset_hub.split_manifests.write_split_manifests`
 83        in its ``"video_depth"`` mode. Each dict carries ``scene_id``,
 84        ``phase``, ``frame_filenames`` (sorted list), and optionally
 85        ``pose_filenames``, ``intrinsics`` (3×3 list), ``difficulty``.
 86    task : TaskType
 87        Must be :data:`~rpx_benchmark.api.TaskType.VIDEO_DEPTH`.
 88    root : Path
 89        Snapshot root that ``frame_filenames`` and ``pose_filenames``
 90        are resolved against.
 91    batch_size : int
 92        Always 1 for Video Depth — clips are not stackable. Kept for API
 93        uniformity with :class:`~rpx_benchmark.loader.RPXDataset`.
 94    frame_budget : int or None
 95        If set, subsample each clip to exactly this many frames via
 96        ``sampling``. ``None`` means use every frame on disk.
 97    sampling : Literal["all", "stride", "fps_se3"]
 98        Subsampling strategy. ``"all"`` requires
 99        ``frame_budget is None``. ``"stride"`` is uniform-with-anchored-
100        endpoints. ``"fps_se3"`` is farthest-point sampling in T265
101        position space (requires ``pose_filenames`` in the manifest
102        entry).
103    """
104
105    samples: List[dict]
106    task: TaskType
107    root: Path
108    batch_size: int = 1
109    frame_budget: Optional[int] = None
110    sampling: SamplingMode = "all"
111    compute_fscore: bool = False
112
113    def __post_init__(self) -> None:
114        if self.sampling == "all" and self.frame_budget is not None:
115            raise ConfigError(
116                "sampling='all' is incompatible with a frame_budget",
117                hint="Drop the budget (None means take every frame) or "
118                "pick sampling='stride' or 'fps_se3'.",
119            )
120        if self.sampling != "all" and self.frame_budget is None:
121            raise ConfigError(
122                f"sampling={self.sampling!r} requires a frame_budget",
123                hint="Pass frame_budget=<int> or set sampling='all'.",
124            )
125        if self.frame_budget is not None and self.frame_budget < 2:
126            raise ConfigError(
127                f"frame_budget must be >= 2 (got {self.frame_budget})",
128                hint="Temporal metrics need at least two frames.",
129            )
130
131    # ------------------------------------------------------------------ #
132    # Iteration
133    # ------------------------------------------------------------------ #
134
135    def __len__(self) -> int:
136        return len(self.samples)
137
138    def __iter__(self) -> Iterator[List[VideoSample]]:
139        for group in self.samples:
140            yield [self._load_one_clip(group)]
141
142    # ------------------------------------------------------------------ #
143    # Per-clip loading
144    # ------------------------------------------------------------------ #
145
146    def _load_one_clip(self, group: dict) -> VideoSample:
147        scene = group["scene_id"]
148        phase = group["phase"]
149        frame_files = list(group["frame_filenames"])
150        pose_files = list(group.get("pose_filenames") or [])
151
152        if not frame_files:
153            raise ManifestError(
154                f"clip {scene}/{phase}: frame_filenames is empty",
155            )
156        if pose_files and len(pose_files) != len(frame_files):
157            raise ManifestError(
158                f"clip {scene}/{phase}: pose_filenames length "
159                f"{len(pose_files)} != frame_filenames length "
160                f"{len(frame_files)}",
161            )
162        if pose_files:
163            missing_poses = [p for p in pose_files if not (self.root / p).is_file()]
164            if missing_poses:
165                if self.sampling == "fps_se3":
166                    raise ManifestError(
167                        f"clip {scene}/{phase}: sampling='fps_se3' requires pose files, "
168                        f"but {len(missing_poses)} are absent (first: {missing_poses[0]})",
169                    )
170                log.warning(
171                    "clip %s/%s: manifest references %d unavailable pose files; "
172                    "continuing RGB-D-only with pose diagnostics disabled",
173                    scene,
174                    phase,
175                    len(missing_poses),
176                )
177                pose_files = []
178
179        # 1. Decide which frame indices to keep.
180        T_full = len(frame_files)
181        if self.frame_budget is None:
182            keep = np.arange(T_full, dtype=np.int32)
183        else:
184            if self.frame_budget > T_full:
185                raise ManifestError(
186                    f"clip {scene}/{phase}: frame_budget="
187                    f"{self.frame_budget} exceeds available frame count "
188                    f"{T_full}",
189                )
190            if self.sampling == "stride":
191                keep = _stride_sample(T_full, self.frame_budget)
192            elif self.sampling == "fps_se3":
193                if not pose_files:
194                    raise ManifestError(
195                        f"clip {scene}/{phase}: sampling='fps_se3' "
196                        f"requires pose_filenames in the manifest entry",
197                    )
198                positions = _read_translation_track(self.root, pose_files)
199                keep = _fps_se3(positions, self.frame_budget)
200            else:  # pragma: no cover — guarded by __post_init__
201                raise ConfigError(
202                    f"unknown sampling mode {self.sampling!r}",
203                    hint="Pick one of 'all', 'stride', 'fps_se3'.",
204                )
205
206        # 2. Decode RGB + depth + poses ONLY for the kept indices.
207        #    Reading 250 PNGs then throwing 200 away would waste 80% of
208        #    the I/O budget; kept-only decode is the only sane strategy
209        #    when the budget is small.
210        rgb_seq = np.stack([safe_load_rgb(self.root / frame_files[i]) for i in keep])
211        # Frame files come from the rgb manifest; depth files mirror
212        # the layout (rgb/<stem>.png ↔ depth/<stem>.png). Group dicts
213        # carry an explicit ``depth_filenames`` list to avoid relying on
214        # that convention; fall back if it's absent.
215        depth_files = group.get("depth_filenames") or [
216            _swap_modality(p, "rgb", "depth") for p in frame_files
217        ]
218        depth_u16 = np.stack([safe_load_depth(self.root / depth_files[i]) for i in keep])
219        depth_seq = depth_u16.astype(np.float32) / 1000.0
220        valid_seq = (
221            np.isfinite(depth_seq)
222            & (depth_seq > DEPTH_MIN_M)
223            & (depth_seq < DEPTH_MAX_M)
224        )
225
226        camera_pose_seq: Optional[np.ndarray] = None
227        if pose_files:
228            poses = []
229            for i in keep:
230                pos, quat = safe_load_pose(self.root / pose_files[i])
231                T_mat = _se3_from_pos_quat(pos, quat)
232                poses.append(T_mat)
233            camera_pose_seq = np.stack(poses)
234
235        gt = VideoDepthGroundTruth(
236            depth_map_seq=depth_seq,
237            valid_mask_seq=valid_seq,
238            frame_indices=keep.astype(np.int32),
239            compute_fscore=self.compute_fscore,
240        )
241        # Stash optional extras the temporal calculator may want on the
242        # GT object (the calculator reads them via getattr so absence is
243        # graceful).
244        if camera_pose_seq is not None:
245            object.__setattr__(gt, "poses", camera_pose_seq)
246        object.__setattr__(gt, "rgb_seq", rgb_seq)
247
248        metadata: dict = {
249            "scene_id": scene,
250            "phase_idx": phase,
251            "frame_indices": keep.tolist(),
252            "frame_budget": int(self.frame_budget) if self.frame_budget else 0,
253            "sampling": self.sampling,
254        }
255        if "intrinsics" in group:
256            metadata["intrinsics"] = np.asarray(group["intrinsics"], dtype=np.float32)
257
258        from .api import Difficulty, Phase
259
260        _PHASE_BY_INT = {0: Phase.CLUTTER, 1: Phase.INTERACTION, 2: Phase.CLEAN}
261        try:
262            phase_enum: Phase | None = (
263                Phase(phase) if isinstance(phase, str) else _PHASE_BY_INT.get(int(phase))
264            )
265        except (ValueError, TypeError):
266            phase_enum = None
267        difficulty_str = group.get("difficulty")
268        try:
269            difficulty_enum: Difficulty | None = (
270                Difficulty(difficulty_str) if difficulty_str else None
271            )
272        except ValueError:
273            difficulty_enum = None
274
275        return VideoSample(
276            id=f"{scene}_{phase}_budget{metadata['frame_budget']}",
277            rgb_seq=rgb_seq,
278            ground_truth=gt,
279            metadata=metadata,
280            phase=phase_enum,
281            difficulty=difficulty_enum,
282            camera_pose_seq=camera_pose_seq,
283        )
284
285    # ------------------------------------------------------------------ #
286    # Constructors
287    # ------------------------------------------------------------------ #
288
289    @classmethod
290    def from_manifest(
291        cls,
292        manifest_path: str | Path,
293        batch_size: int = 1,
294        frame_budget: Optional[int] = None,
295        sampling: SamplingMode = "all",
296        max_samples: Optional[int] = None,
297        compute_fscore: bool = False,
298    ) -> "VideoDepthDataset":
299        """Build a VideoDepthDataset from a per-clip JSON manifest.
300
301        Expected JSON shape::
302
303            {
304              "task": "video_depth",
305              "root": "/abs/path/or/snapshot/root",
306              "samples": [
307                {
308                  "scene_id": "scene_001",
309                  "phase": 0,
310                  "frame_filenames": ["scenes/scene_001/0/rgb/00000.png", ...],
311                  "depth_filenames": ["scenes/scene_001/0/depth/00000.png", ...],
312                  "pose_filenames":  ["scenes/scene_001/0/cam_pose/00000.npz", ...],
313                  "intrinsics": [[fx,0,cx],[0,fy,cy],[0,0,1]],
314                  "difficulty": "easy"
315                },
316                ...
317              ]
318            }
319
320        The writer for this shape lives in
321        :func:`rpx_benchmark.dataset_hub.split_manifests.write_split_manifests`
322        under the ``video_depth`` recipe.
323        """
324        manifest_path = Path(manifest_path)
325        if not manifest_path.is_file():
326            raise ManifestError(
327                f"Manifest file not found: {manifest_path}",
328            )
329        try:
330            payload = json.loads(manifest_path.read_text(encoding="utf-8"))
331        except json.JSONDecodeError as e:
332            raise ManifestError(
333                f"Manifest at {manifest_path} is not valid JSON: {e}",
334            ) from e
335        task_str = payload.get("task")
336        if task_str != "video_depth":
337            raise ManifestError(
338                f"VideoDepthDataset.from_manifest expected task='video_depth'; got {task_str!r}",
339            )
340        # Resolve the modality-file root. The manifest's ``root`` field
341        # is the canonical source — every frame_filename / depth_filename
342        # / pose_filename is resolved relative to it. If the manifest
343        # omits ``root`` (or sets it to None / ""), fall back to the
344        # manifest file's parent dir; warn loudly so the caller notices
345        # before a misleading FileNotFoundError downstream.
346        manifest_root = payload.get("root")
347        if manifest_root:
348            root = Path(manifest_root)
349        else:
350            from .logging_utils import get_logger
351
352            get_logger(__name__).warning(
353                "VideoDepthDataset.from_manifest: %s has no 'root' field; falling "
354                "back to manifest parent dir %s. If your frame_filenames "
355                "are relative to a different root (e.g. an HF cache "
356                "snapshot or a staged dataset tree), set the 'root' field "
357                "explicitly in the manifest. See "
358                "benchmark/README.md for the manifest schema.",
359                manifest_path,
360                manifest_path.parent,
361            )
362            root = manifest_path.parent
363        samples = payload.get("samples") or []
364        if not isinstance(samples, list):
365            raise ManifestError(
366                f"Manifest 'samples' must be a list, got {type(samples).__name__}",
367            )
368        if max_samples is not None:
369            if max_samples < 1:
370                raise ConfigError(
371                    f"max_samples must be >= 1, got {max_samples}",
372                )
373            samples = samples[:max_samples]
374        return cls(
375            samples=samples,
376            task=TaskType.VIDEO_DEPTH,
377            root=root,
378            batch_size=batch_size,
379            frame_budget=frame_budget,
380            sampling=sampling,
381            compute_fscore=compute_fscore,
382        )
383
384
385# --------------------------------------------------------------------------- #
386# Frame-selection helpers
387# --------------------------------------------------------------------------- #
388
389
390def _stride_sample(T_full: int, budget: int) -> np.ndarray:
391    """Uniform-stride frame selection with first/last anchored.
392
393    Equivalent to FPS in 1D timestamp space at constant frame rate.
394    Always includes indices 0 and ``T_full - 1`` so the clip endpoints
395    are preserved (matters for temporal-edge metrics).
396    """
397    if budget == T_full:
398        return np.arange(T_full, dtype=np.int32)
399    # np.linspace(0, T_full-1, budget) gives evenly-spaced floats with
400    # both endpoints; round and dedupe.
401    raw = np.linspace(0, T_full - 1, budget)
402    idx = np.unique(np.round(raw).astype(np.int32))
403    # In rare cases rounding collapses adjacent picks; pad with the
404    # nearest missing indices to hit the exact budget.
405    if len(idx) < budget:
406        all_idx = set(idx.tolist())
407        for cand in range(T_full):
408            if cand not in all_idx:
409                all_idx.add(cand)
410                if len(all_idx) == budget:
411                    break
412        idx = np.sort(np.fromiter(all_idx, dtype=np.int32))
413    return idx[:budget]
414
415
416def _fps_se3(positions: np.ndarray, budget: int) -> np.ndarray:
417    """Farthest-point sampling in 3D camera-translation space.
418
419    Anchors the first frame (index 0) and iteratively adds the index
420    whose minimum Euclidean distance to the selected set is largest.
421    Deterministic given ``(positions, budget)``.
422
423    Parameters
424    ----------
425    positions : (T_full, 3) float64
426        T265 camera positions in metres.
427    budget : int
428        Number of frames to select. Must satisfy ``2 <= budget <= T_full``.
429
430    Returns
431    -------
432    np.ndarray
433        Sorted indices, shape ``(budget,)``, dtype int32.
434    """
435    if positions.ndim != 2 or positions.shape[1] != 3:
436        raise ConfigError(
437            f"_fps_se3: expected (T, 3) positions, got {positions.shape}",
438            hint="Pass T265 translations as an (N, 3) float64 array.",
439        )
440    T_full = positions.shape[0]
441    if not (2 <= budget <= T_full):
442        raise ConfigError(
443            f"_fps_se3: budget {budget} out of range [2, {T_full}]",
444            hint="Budget must be in [2, len(positions)].",
445        )
446
447    selected = [0]
448    # Squared distance from each frame to the closest selected frame.
449    sq_dist_to_set = np.sum((positions - positions[0]) ** 2, axis=1)
450    sq_dist_to_set[0] = -np.inf  # never re-pick
451
452    while len(selected) < budget:
453        next_idx = int(np.argmax(sq_dist_to_set))
454        selected.append(next_idx)
455        # Update min-distance bookkeeping.
456        new_sq = np.sum((positions - positions[next_idx]) ** 2, axis=1)
457        sq_dist_to_set = np.minimum(sq_dist_to_set, new_sq)
458        sq_dist_to_set[next_idx] = -np.inf
459
460    return np.sort(np.array(selected, dtype=np.int32))
461
462
463def _se3_from_pos_quat(position: np.ndarray, quat_xyzw: np.ndarray) -> np.ndarray:
464    """Pack a (3,) position + (4,) xyzw quaternion into a 4×4 SE(3)."""
465    qx, qy, qz, qw = quat_xyzw
466    # Standard xyzw → rotation matrix
467    R = np.array(
468        [
469            [1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw)],
470            [2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw)],
471            [2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy)],
472        ],
473        dtype=np.float64,
474    )
475    T_mat = np.eye(4, dtype=np.float64)
476    T_mat[:3, :3] = R
477    T_mat[:3, 3] = position
478    return T_mat
479
480
481def _read_translation_track(root: Path, pose_files: List[str]) -> np.ndarray:
482    """Read pose files and return ``(T, 3) float64`` translation track.
483
484    Uses :func:`~rpx_benchmark.decode_contracts.safe_load_pose` to honour
485    both ``.npz`` (legacy) and ``.npy`` (v2) on-disk formats.
486    """
487    positions = []
488    for p in pose_files:
489        pos, _quat = safe_load_pose(root / p)
490        positions.append(pos)
491    return np.stack(positions).astype(np.float64)
492
493
494def _swap_modality(rel_path: str, src: str, dst: str) -> str:
495    """``scenes/x/0/rgb/00000.png`` → ``scenes/x/0/depth/00000.png``.
496
497    Only swaps the *first* occurrence of ``/src/`` to avoid corrupting
498    paths that happen to mention ``rgb`` or ``depth`` elsewhere.
499    """
500    needle = f"/{src}/"
501    repl = f"/{dst}/"
502    i = rel_path.find(needle)
503    if i < 0:
504        return rel_path  # caller will surface a missing-file error downstream
505    return rel_path[:i] + repl + rel_path[i + len(needle) :]
506
507
508# --------------------------------------------------------------------------- #
509# Per-clip scale-and-shift alignment for affine-invariant video models
510# --------------------------------------------------------------------------- #
511
512
513def align_scale_and_shift_per_clip(
514    pred_seq: np.ndarray,
515    gt_seq: np.ndarray,
516    valid_mask_seq: np.ndarray,
517) -> np.ndarray:
518    """Per-clip scale + shift alignment — thin wrapper around the shared
519    pooled solver.
520
521    Delegates to :func:`~rpx_benchmark.metrics.depth_alignment.align_pred_to_gt_pooled`
522    in ``ls_affine`` mode so the math lives in exactly one place
523    (shared with the Image Depth per-scene-phase aligner). Per-clip (not
524    per-frame) alignment is the standard convention for video depth —
525    per-frame alignment would zero out the temporal-consistency
526    signal the video metrics measure.
527
528    The returned sequence is ``s * pred_seq + t`` so callers can plug
529    it straight into the metric calculators.
530    """
531    from .metrics.depth_alignment import align_pred_to_gt_pooled
532
533    if pred_seq.shape != gt_seq.shape or pred_seq.shape != valid_mask_seq.shape:
534        raise ConfigError(
535            f"align_scale_and_shift_per_clip: shape mismatch "
536            f"pred={pred_seq.shape} gt={gt_seq.shape} valid={valid_mask_seq.shape}",
537            hint="All three sequences must be (T, H, W) with matching T/H/W.",
538        )
539    return align_pred_to_gt_pooled(
540        pred_seq=pred_seq,
541        gt_seq=gt_seq,
542        mode="ls_affine",
543        valid_seq=valid_mask_seq.astype(bool),
544    )
545
546
547# Backwards-compatibility alias. Earlier branches (jishnu/d1v-runner-
548# first-adapter, jishnu/d1v-manifest-root-warning) and external callers
549# import ``D1VDataset`` directly. Keeping the alias avoids forcing every
550# caller to update their import line in lockstep with the rename.
551D1VDataset = VideoDepthDataset
552
553
554__all__ = [
555    "VideoDepthDataset",
556    "D1VDataset",
557    "_fps_se3",
558    "_stride_sample",
559    "align_scale_and_shift_per_clip",
560]
@dataclass
class VideoDepthDataset:
 66@dataclass
 67class VideoDepthDataset:
 68    """Iterates per ``(scene, phase)`` clip for the video-depth task.
 69
 70    Yielded value is :class:`~rpx_benchmark.api.VideoSample` whose
 71    ``rgb_seq`` is a ``(T, H, W, 3) uint8`` stack and whose
 72    ``ground_truth`` is :class:`~rpx_benchmark.api.VideoDepthGroundTruth`
 73    with the same ``T``. When ``frame_budget`` is ``None`` (the
 74    canonical run), ``T`` is whatever the phase has on disk; otherwise
 75    ``T == frame_budget`` and ``metadata['frame_indices']`` records
 76    which original-phase indices were kept.
 77
 78    Parameters
 79    ----------
 80    samples : list[dict]
 81        Per-clip manifest entries (one dict per (scene, phase)).
 82        Produced by
 83        :func:`rpx_benchmark.dataset_hub.split_manifests.write_split_manifests`
 84        in its ``"video_depth"`` mode. Each dict carries ``scene_id``,
 85        ``phase``, ``frame_filenames`` (sorted list), and optionally
 86        ``pose_filenames``, ``intrinsics`` (3×3 list), ``difficulty``.
 87    task : TaskType
 88        Must be :data:`~rpx_benchmark.api.TaskType.VIDEO_DEPTH`.
 89    root : Path
 90        Snapshot root that ``frame_filenames`` and ``pose_filenames``
 91        are resolved against.
 92    batch_size : int
 93        Always 1 for Video Depth — clips are not stackable. Kept for API
 94        uniformity with :class:`~rpx_benchmark.loader.RPXDataset`.
 95    frame_budget : int or None
 96        If set, subsample each clip to exactly this many frames via
 97        ``sampling``. ``None`` means use every frame on disk.
 98    sampling : Literal["all", "stride", "fps_se3"]
 99        Subsampling strategy. ``"all"`` requires
100        ``frame_budget is None``. ``"stride"`` is uniform-with-anchored-
101        endpoints. ``"fps_se3"`` is farthest-point sampling in T265
102        position space (requires ``pose_filenames`` in the manifest
103        entry).
104    """
105
106    samples: List[dict]
107    task: TaskType
108    root: Path
109    batch_size: int = 1
110    frame_budget: Optional[int] = None
111    sampling: SamplingMode = "all"
112    compute_fscore: bool = False
113
114    def __post_init__(self) -> None:
115        if self.sampling == "all" and self.frame_budget is not None:
116            raise ConfigError(
117                "sampling='all' is incompatible with a frame_budget",
118                hint="Drop the budget (None means take every frame) or "
119                "pick sampling='stride' or 'fps_se3'.",
120            )
121        if self.sampling != "all" and self.frame_budget is None:
122            raise ConfigError(
123                f"sampling={self.sampling!r} requires a frame_budget",
124                hint="Pass frame_budget=<int> or set sampling='all'.",
125            )
126        if self.frame_budget is not None and self.frame_budget < 2:
127            raise ConfigError(
128                f"frame_budget must be >= 2 (got {self.frame_budget})",
129                hint="Temporal metrics need at least two frames.",
130            )
131
132    # ------------------------------------------------------------------ #
133    # Iteration
134    # ------------------------------------------------------------------ #
135
136    def __len__(self) -> int:
137        return len(self.samples)
138
139    def __iter__(self) -> Iterator[List[VideoSample]]:
140        for group in self.samples:
141            yield [self._load_one_clip(group)]
142
143    # ------------------------------------------------------------------ #
144    # Per-clip loading
145    # ------------------------------------------------------------------ #
146
147    def _load_one_clip(self, group: dict) -> VideoSample:
148        scene = group["scene_id"]
149        phase = group["phase"]
150        frame_files = list(group["frame_filenames"])
151        pose_files = list(group.get("pose_filenames") or [])
152
153        if not frame_files:
154            raise ManifestError(
155                f"clip {scene}/{phase}: frame_filenames is empty",
156            )
157        if pose_files and len(pose_files) != len(frame_files):
158            raise ManifestError(
159                f"clip {scene}/{phase}: pose_filenames length "
160                f"{len(pose_files)} != frame_filenames length "
161                f"{len(frame_files)}",
162            )
163        if pose_files:
164            missing_poses = [p for p in pose_files if not (self.root / p).is_file()]
165            if missing_poses:
166                if self.sampling == "fps_se3":
167                    raise ManifestError(
168                        f"clip {scene}/{phase}: sampling='fps_se3' requires pose files, "
169                        f"but {len(missing_poses)} are absent (first: {missing_poses[0]})",
170                    )
171                log.warning(
172                    "clip %s/%s: manifest references %d unavailable pose files; "
173                    "continuing RGB-D-only with pose diagnostics disabled",
174                    scene,
175                    phase,
176                    len(missing_poses),
177                )
178                pose_files = []
179
180        # 1. Decide which frame indices to keep.
181        T_full = len(frame_files)
182        if self.frame_budget is None:
183            keep = np.arange(T_full, dtype=np.int32)
184        else:
185            if self.frame_budget > T_full:
186                raise ManifestError(
187                    f"clip {scene}/{phase}: frame_budget="
188                    f"{self.frame_budget} exceeds available frame count "
189                    f"{T_full}",
190                )
191            if self.sampling == "stride":
192                keep = _stride_sample(T_full, self.frame_budget)
193            elif self.sampling == "fps_se3":
194                if not pose_files:
195                    raise ManifestError(
196                        f"clip {scene}/{phase}: sampling='fps_se3' "
197                        f"requires pose_filenames in the manifest entry",
198                    )
199                positions = _read_translation_track(self.root, pose_files)
200                keep = _fps_se3(positions, self.frame_budget)
201            else:  # pragma: no cover — guarded by __post_init__
202                raise ConfigError(
203                    f"unknown sampling mode {self.sampling!r}",
204                    hint="Pick one of 'all', 'stride', 'fps_se3'.",
205                )
206
207        # 2. Decode RGB + depth + poses ONLY for the kept indices.
208        #    Reading 250 PNGs then throwing 200 away would waste 80% of
209        #    the I/O budget; kept-only decode is the only sane strategy
210        #    when the budget is small.
211        rgb_seq = np.stack([safe_load_rgb(self.root / frame_files[i]) for i in keep])
212        # Frame files come from the rgb manifest; depth files mirror
213        # the layout (rgb/<stem>.png ↔ depth/<stem>.png). Group dicts
214        # carry an explicit ``depth_filenames`` list to avoid relying on
215        # that convention; fall back if it's absent.
216        depth_files = group.get("depth_filenames") or [
217            _swap_modality(p, "rgb", "depth") for p in frame_files
218        ]
219        depth_u16 = np.stack([safe_load_depth(self.root / depth_files[i]) for i in keep])
220        depth_seq = depth_u16.astype(np.float32) / 1000.0
221        valid_seq = (
222            np.isfinite(depth_seq)
223            & (depth_seq > DEPTH_MIN_M)
224            & (depth_seq < DEPTH_MAX_M)
225        )
226
227        camera_pose_seq: Optional[np.ndarray] = None
228        if pose_files:
229            poses = []
230            for i in keep:
231                pos, quat = safe_load_pose(self.root / pose_files[i])
232                T_mat = _se3_from_pos_quat(pos, quat)
233                poses.append(T_mat)
234            camera_pose_seq = np.stack(poses)
235
236        gt = VideoDepthGroundTruth(
237            depth_map_seq=depth_seq,
238            valid_mask_seq=valid_seq,
239            frame_indices=keep.astype(np.int32),
240            compute_fscore=self.compute_fscore,
241        )
242        # Stash optional extras the temporal calculator may want on the
243        # GT object (the calculator reads them via getattr so absence is
244        # graceful).
245        if camera_pose_seq is not None:
246            object.__setattr__(gt, "poses", camera_pose_seq)
247        object.__setattr__(gt, "rgb_seq", rgb_seq)
248
249        metadata: dict = {
250            "scene_id": scene,
251            "phase_idx": phase,
252            "frame_indices": keep.tolist(),
253            "frame_budget": int(self.frame_budget) if self.frame_budget else 0,
254            "sampling": self.sampling,
255        }
256        if "intrinsics" in group:
257            metadata["intrinsics"] = np.asarray(group["intrinsics"], dtype=np.float32)
258
259        from .api import Difficulty, Phase
260
261        _PHASE_BY_INT = {0: Phase.CLUTTER, 1: Phase.INTERACTION, 2: Phase.CLEAN}
262        try:
263            phase_enum: Phase | None = (
264                Phase(phase) if isinstance(phase, str) else _PHASE_BY_INT.get(int(phase))
265            )
266        except (ValueError, TypeError):
267            phase_enum = None
268        difficulty_str = group.get("difficulty")
269        try:
270            difficulty_enum: Difficulty | None = (
271                Difficulty(difficulty_str) if difficulty_str else None
272            )
273        except ValueError:
274            difficulty_enum = None
275
276        return VideoSample(
277            id=f"{scene}_{phase}_budget{metadata['frame_budget']}",
278            rgb_seq=rgb_seq,
279            ground_truth=gt,
280            metadata=metadata,
281            phase=phase_enum,
282            difficulty=difficulty_enum,
283            camera_pose_seq=camera_pose_seq,
284        )
285
286    # ------------------------------------------------------------------ #
287    # Constructors
288    # ------------------------------------------------------------------ #
289
290    @classmethod
291    def from_manifest(
292        cls,
293        manifest_path: str | Path,
294        batch_size: int = 1,
295        frame_budget: Optional[int] = None,
296        sampling: SamplingMode = "all",
297        max_samples: Optional[int] = None,
298        compute_fscore: bool = False,
299    ) -> "VideoDepthDataset":
300        """Build a VideoDepthDataset from a per-clip JSON manifest.
301
302        Expected JSON shape::
303
304            {
305              "task": "video_depth",
306              "root": "/abs/path/or/snapshot/root",
307              "samples": [
308                {
309                  "scene_id": "scene_001",
310                  "phase": 0,
311                  "frame_filenames": ["scenes/scene_001/0/rgb/00000.png", ...],
312                  "depth_filenames": ["scenes/scene_001/0/depth/00000.png", ...],
313                  "pose_filenames":  ["scenes/scene_001/0/cam_pose/00000.npz", ...],
314                  "intrinsics": [[fx,0,cx],[0,fy,cy],[0,0,1]],
315                  "difficulty": "easy"
316                },
317                ...
318              ]
319            }
320
321        The writer for this shape lives in
322        :func:`rpx_benchmark.dataset_hub.split_manifests.write_split_manifests`
323        under the ``video_depth`` recipe.
324        """
325        manifest_path = Path(manifest_path)
326        if not manifest_path.is_file():
327            raise ManifestError(
328                f"Manifest file not found: {manifest_path}",
329            )
330        try:
331            payload = json.loads(manifest_path.read_text(encoding="utf-8"))
332        except json.JSONDecodeError as e:
333            raise ManifestError(
334                f"Manifest at {manifest_path} is not valid JSON: {e}",
335            ) from e
336        task_str = payload.get("task")
337        if task_str != "video_depth":
338            raise ManifestError(
339                f"VideoDepthDataset.from_manifest expected task='video_depth'; got {task_str!r}",
340            )
341        # Resolve the modality-file root. The manifest's ``root`` field
342        # is the canonical source — every frame_filename / depth_filename
343        # / pose_filename is resolved relative to it. If the manifest
344        # omits ``root`` (or sets it to None / ""), fall back to the
345        # manifest file's parent dir; warn loudly so the caller notices
346        # before a misleading FileNotFoundError downstream.
347        manifest_root = payload.get("root")
348        if manifest_root:
349            root = Path(manifest_root)
350        else:
351            from .logging_utils import get_logger
352
353            get_logger(__name__).warning(
354                "VideoDepthDataset.from_manifest: %s has no 'root' field; falling "
355                "back to manifest parent dir %s. If your frame_filenames "
356                "are relative to a different root (e.g. an HF cache "
357                "snapshot or a staged dataset tree), set the 'root' field "
358                "explicitly in the manifest. See "
359                "benchmark/README.md for the manifest schema.",
360                manifest_path,
361                manifest_path.parent,
362            )
363            root = manifest_path.parent
364        samples = payload.get("samples") or []
365        if not isinstance(samples, list):
366            raise ManifestError(
367                f"Manifest 'samples' must be a list, got {type(samples).__name__}",
368            )
369        if max_samples is not None:
370            if max_samples < 1:
371                raise ConfigError(
372                    f"max_samples must be >= 1, got {max_samples}",
373                )
374            samples = samples[:max_samples]
375        return cls(
376            samples=samples,
377            task=TaskType.VIDEO_DEPTH,
378            root=root,
379            batch_size=batch_size,
380            frame_budget=frame_budget,
381            sampling=sampling,
382            compute_fscore=compute_fscore,
383        )

Iterates per (scene, phase) clip for the video-depth task.

Yielded value is ~rpx_benchmark.api.VideoSample whose rgb_seq is a (T, H, W, 3) uint8 stack and whose ground_truth is ~rpx_benchmark.api.VideoDepthGroundTruth with the same T. When frame_budget is None (the canonical run), T is whatever the phase has on disk; otherwise T == frame_budget and metadata['frame_indices'] records which original-phase indices were kept.

Parameters

samples : list[dict] Per-clip manifest entries (one dict per (scene, phase)). Produced by rpx_benchmark.dataset_hub.split_manifests.write_split_manifests() in its "video_depth" mode. Each dict carries scene_id, phase, frame_filenames (sorted list), and optionally pose_filenames, intrinsics (3×3 list), difficulty. task : TaskType Must be ~rpx_benchmark.api.TaskType.VIDEO_DEPTH. root : Path Snapshot root that frame_filenames and pose_filenames are resolved against. batch_size : int Always 1 for Video Depth — clips are not stackable. Kept for API uniformity with ~rpx_benchmark.loader.RPXDataset. frame_budget : int or None If set, subsample each clip to exactly this many frames via sampling. None means use every frame on disk. sampling : Literal["all", "stride", "fps_se3"] Subsampling strategy. "all" requires frame_budget is None. "stride" is uniform-with-anchored- endpoints. "fps_se3" is farthest-point sampling in T265 position space (requires pose_filenames in the manifest entry).

VideoDepthDataset( samples: List[dict], task: rpx_benchmark.api.TaskType, root: pathlib.Path, batch_size: int = 1, frame_budget: Optional[int] = None, sampling: Literal['all', 'stride', 'fps_se3'] = 'all', compute_fscore: bool = False)
samples: List[dict]
root: pathlib.Path
batch_size: int = 1
frame_budget: Optional[int] = None
sampling: Literal['all', 'stride', 'fps_se3'] = 'all'
compute_fscore: bool = False
@classmethod
def from_manifest( cls, manifest_path: str | pathlib.Path, batch_size: int = 1, frame_budget: Optional[int] = None, sampling: Literal['all', 'stride', 'fps_se3'] = 'all', max_samples: Optional[int] = None, compute_fscore: bool = False) -> VideoDepthDataset:
290    @classmethod
291    def from_manifest(
292        cls,
293        manifest_path: str | Path,
294        batch_size: int = 1,
295        frame_budget: Optional[int] = None,
296        sampling: SamplingMode = "all",
297        max_samples: Optional[int] = None,
298        compute_fscore: bool = False,
299    ) -> "VideoDepthDataset":
300        """Build a VideoDepthDataset from a per-clip JSON manifest.
301
302        Expected JSON shape::
303
304            {
305              "task": "video_depth",
306              "root": "/abs/path/or/snapshot/root",
307              "samples": [
308                {
309                  "scene_id": "scene_001",
310                  "phase": 0,
311                  "frame_filenames": ["scenes/scene_001/0/rgb/00000.png", ...],
312                  "depth_filenames": ["scenes/scene_001/0/depth/00000.png", ...],
313                  "pose_filenames":  ["scenes/scene_001/0/cam_pose/00000.npz", ...],
314                  "intrinsics": [[fx,0,cx],[0,fy,cy],[0,0,1]],
315                  "difficulty": "easy"
316                },
317                ...
318              ]
319            }
320
321        The writer for this shape lives in
322        :func:`rpx_benchmark.dataset_hub.split_manifests.write_split_manifests`
323        under the ``video_depth`` recipe.
324        """
325        manifest_path = Path(manifest_path)
326        if not manifest_path.is_file():
327            raise ManifestError(
328                f"Manifest file not found: {manifest_path}",
329            )
330        try:
331            payload = json.loads(manifest_path.read_text(encoding="utf-8"))
332        except json.JSONDecodeError as e:
333            raise ManifestError(
334                f"Manifest at {manifest_path} is not valid JSON: {e}",
335            ) from e
336        task_str = payload.get("task")
337        if task_str != "video_depth":
338            raise ManifestError(
339                f"VideoDepthDataset.from_manifest expected task='video_depth'; got {task_str!r}",
340            )
341        # Resolve the modality-file root. The manifest's ``root`` field
342        # is the canonical source — every frame_filename / depth_filename
343        # / pose_filename is resolved relative to it. If the manifest
344        # omits ``root`` (or sets it to None / ""), fall back to the
345        # manifest file's parent dir; warn loudly so the caller notices
346        # before a misleading FileNotFoundError downstream.
347        manifest_root = payload.get("root")
348        if manifest_root:
349            root = Path(manifest_root)
350        else:
351            from .logging_utils import get_logger
352
353            get_logger(__name__).warning(
354                "VideoDepthDataset.from_manifest: %s has no 'root' field; falling "
355                "back to manifest parent dir %s. If your frame_filenames "
356                "are relative to a different root (e.g. an HF cache "
357                "snapshot or a staged dataset tree), set the 'root' field "
358                "explicitly in the manifest. See "
359                "benchmark/README.md for the manifest schema.",
360                manifest_path,
361                manifest_path.parent,
362            )
363            root = manifest_path.parent
364        samples = payload.get("samples") or []
365        if not isinstance(samples, list):
366            raise ManifestError(
367                f"Manifest 'samples' must be a list, got {type(samples).__name__}",
368            )
369        if max_samples is not None:
370            if max_samples < 1:
371                raise ConfigError(
372                    f"max_samples must be >= 1, got {max_samples}",
373                )
374            samples = samples[:max_samples]
375        return cls(
376            samples=samples,
377            task=TaskType.VIDEO_DEPTH,
378            root=root,
379            batch_size=batch_size,
380            frame_budget=frame_budget,
381            sampling=sampling,
382            compute_fscore=compute_fscore,
383        )

Build a VideoDepthDataset from a per-clip JSON manifest.

Expected JSON shape::

{
  "task": "video_depth",
  "root": "/abs/path/or/snapshot/root",
  "samples": [
    {
      "scene_id": "scene_001",
      "phase": 0,
      "frame_filenames": ["scenes/scene_001/0/rgb/00000.png", ...],
      "depth_filenames": ["scenes/scene_001/0/depth/00000.png", ...],
      "pose_filenames":  ["scenes/scene_001/0/cam_pose/00000.npz", ...],
      "intrinsics": [[fx,0,cx],[0,fy,cy],[0,0,1]],
      "difficulty": "easy"
    },
    ...
  ]
}

The writer for this shape lives in rpx_benchmark.dataset_hub.split_manifests.write_split_manifests() under the video_depth recipe.

D1VDataset = <class 'VideoDepthDataset'>
def _fps_se3(positions: numpy.ndarray, budget: int) -> numpy.ndarray:
417def _fps_se3(positions: np.ndarray, budget: int) -> np.ndarray:
418    """Farthest-point sampling in 3D camera-translation space.
419
420    Anchors the first frame (index 0) and iteratively adds the index
421    whose minimum Euclidean distance to the selected set is largest.
422    Deterministic given ``(positions, budget)``.
423
424    Parameters
425    ----------
426    positions : (T_full, 3) float64
427        T265 camera positions in metres.
428    budget : int
429        Number of frames to select. Must satisfy ``2 <= budget <= T_full``.
430
431    Returns
432    -------
433    np.ndarray
434        Sorted indices, shape ``(budget,)``, dtype int32.
435    """
436    if positions.ndim != 2 or positions.shape[1] != 3:
437        raise ConfigError(
438            f"_fps_se3: expected (T, 3) positions, got {positions.shape}",
439            hint="Pass T265 translations as an (N, 3) float64 array.",
440        )
441    T_full = positions.shape[0]
442    if not (2 <= budget <= T_full):
443        raise ConfigError(
444            f"_fps_se3: budget {budget} out of range [2, {T_full}]",
445            hint="Budget must be in [2, len(positions)].",
446        )
447
448    selected = [0]
449    # Squared distance from each frame to the closest selected frame.
450    sq_dist_to_set = np.sum((positions - positions[0]) ** 2, axis=1)
451    sq_dist_to_set[0] = -np.inf  # never re-pick
452
453    while len(selected) < budget:
454        next_idx = int(np.argmax(sq_dist_to_set))
455        selected.append(next_idx)
456        # Update min-distance bookkeeping.
457        new_sq = np.sum((positions - positions[next_idx]) ** 2, axis=1)
458        sq_dist_to_set = np.minimum(sq_dist_to_set, new_sq)
459        sq_dist_to_set[next_idx] = -np.inf
460
461    return np.sort(np.array(selected, dtype=np.int32))

Farthest-point sampling in 3D camera-translation space.

Anchors the first frame (index 0) and iteratively adds the index whose minimum Euclidean distance to the selected set is largest. Deterministic given (positions, budget).

Parameters

positions : (T_full, 3) float64 T265 camera positions in metres. budget : int Number of frames to select. Must satisfy 2 <= budget <= T_full.

Returns

np.ndarray Sorted indices, shape (budget,), dtype int32.

def _stride_sample(T_full: int, budget: int) -> numpy.ndarray:
391def _stride_sample(T_full: int, budget: int) -> np.ndarray:
392    """Uniform-stride frame selection with first/last anchored.
393
394    Equivalent to FPS in 1D timestamp space at constant frame rate.
395    Always includes indices 0 and ``T_full - 1`` so the clip endpoints
396    are preserved (matters for temporal-edge metrics).
397    """
398    if budget == T_full:
399        return np.arange(T_full, dtype=np.int32)
400    # np.linspace(0, T_full-1, budget) gives evenly-spaced floats with
401    # both endpoints; round and dedupe.
402    raw = np.linspace(0, T_full - 1, budget)
403    idx = np.unique(np.round(raw).astype(np.int32))
404    # In rare cases rounding collapses adjacent picks; pad with the
405    # nearest missing indices to hit the exact budget.
406    if len(idx) < budget:
407        all_idx = set(idx.tolist())
408        for cand in range(T_full):
409            if cand not in all_idx:
410                all_idx.add(cand)
411                if len(all_idx) == budget:
412                    break
413        idx = np.sort(np.fromiter(all_idx, dtype=np.int32))
414    return idx[:budget]

Uniform-stride frame selection with first/last anchored.

Equivalent to FPS in 1D timestamp space at constant frame rate. Always includes indices 0 and T_full - 1 so the clip endpoints are preserved (matters for temporal-edge metrics).

def align_scale_and_shift_per_clip( pred_seq: numpy.ndarray, gt_seq: numpy.ndarray, valid_mask_seq: numpy.ndarray) -> numpy.ndarray:
514def align_scale_and_shift_per_clip(
515    pred_seq: np.ndarray,
516    gt_seq: np.ndarray,
517    valid_mask_seq: np.ndarray,
518) -> np.ndarray:
519    """Per-clip scale + shift alignment — thin wrapper around the shared
520    pooled solver.
521
522    Delegates to :func:`~rpx_benchmark.metrics.depth_alignment.align_pred_to_gt_pooled`
523    in ``ls_affine`` mode so the math lives in exactly one place
524    (shared with the Image Depth per-scene-phase aligner). Per-clip (not
525    per-frame) alignment is the standard convention for video depth —
526    per-frame alignment would zero out the temporal-consistency
527    signal the video metrics measure.
528
529    The returned sequence is ``s * pred_seq + t`` so callers can plug
530    it straight into the metric calculators.
531    """
532    from .metrics.depth_alignment import align_pred_to_gt_pooled
533
534    if pred_seq.shape != gt_seq.shape or pred_seq.shape != valid_mask_seq.shape:
535        raise ConfigError(
536            f"align_scale_and_shift_per_clip: shape mismatch "
537            f"pred={pred_seq.shape} gt={gt_seq.shape} valid={valid_mask_seq.shape}",
538            hint="All three sequences must be (T, H, W) with matching T/H/W.",
539        )
540    return align_pred_to_gt_pooled(
541        pred_seq=pred_seq,
542        gt_seq=gt_seq,
543        mode="ls_affine",
544        valid_seq=valid_mask_seq.astype(bool),
545    )

Per-clip scale + shift alignment — thin wrapper around the shared pooled solver.

Delegates to ~rpx_benchmark.metrics.depth_alignment.align_pred_to_gt_pooled() in ls_affine mode so the math lives in exactly one place (shared with the Image Depth per-scene-phase aligner). Per-clip (not per-frame) alignment is the standard convention for video depth — per-frame alignment would zero out the temporal-consistency signal the video metrics measure.

The returned sequence is s * pred_seq + t so callers can plug it straight into the metric calculators.