rpx_benchmark.data.clip_dataset

Per-(scene, phase) clip iteration for Video Depth and other sequence-level tasks (e.g. D3 tracking, which is also clip-based).

The flat ~rpx_benchmark.loader.RPXDataset yields one independent ~rpx_benchmark.api.Sample per frame — correct for per-frame tasks (Image Depth, D2, D4/D5). Video and tracking tasks instead need the whole phase clip in one shot. PhaseClipDataset groups a depth manifest's frames by (scene, phase), orders them by frame index, and yields a PhaseClip per group, decoding every modality through the existing RPXDataset loaders — no duplicate IO code, one source of truth for how RGB / depth / pose are read.

Design notes (benchmark/README.md §3):

  • Iteration order is outer = scene (sorted), inner = phase (0, 1, 2), which matches the cell-log grouping key (model, task, scene, phase).
  • Clips are loaded lazily, one at a time, so host-RAM stays at ~1 clip (~0.6 GB) regardless of dataset size.
  • Clips shorter than ~rpx_benchmark.metrics.depth_temporal.MIN_CLIP_FRAMES are skipped (logged) — temporal metrics are meaningless on a few frames, and we never pad (padding biases OPW/TAE downward).
  1"""Per-(scene, phase) clip iteration for Video Depth and other
  2sequence-level tasks (e.g. D3 tracking, which is also clip-based).
  3
  4The flat :class:`~rpx_benchmark.loader.RPXDataset` yields one independent
  5:class:`~rpx_benchmark.api.Sample` per frame — correct for per-frame
  6tasks (Image Depth, D2, D4/D5).  Video and tracking tasks instead need the
  7whole phase clip in one shot.  :class:`PhaseClipDataset` groups a depth
  8manifest's frames by ``(scene, phase)``, orders them by frame index, and
  9yields a :class:`PhaseClip` per group, decoding every modality through the
 10*existing* ``RPXDataset`` loaders — no duplicate IO code, one source of
 11truth for how RGB / depth / pose are read.
 12
 13Design notes (``benchmark/README.md`` §3):
 14
 15* Iteration order is ``outer = scene (sorted), inner = phase (0, 1, 2)``,
 16  which matches the cell-log grouping key ``(model, task, scene, phase)``.
 17* Clips are loaded lazily, one at a time, so host-RAM stays at ~1 clip
 18  (~0.6 GB) regardless of dataset size.
 19* Clips shorter than :data:`~rpx_benchmark.metrics.depth_temporal.MIN_CLIP_FRAMES`
 20  are **skipped** (logged) — temporal metrics are meaningless on a few
 21  frames, and we never pad (padding biases OPW/TAE downward).
 22"""
 23
 24from __future__ import annotations
 25
 26import re
 27from dataclasses import dataclass
 28from pathlib import Path
 29from typing import Any, Dict, Iterator, List, Optional, Tuple
 30
 31import numpy as np
 32
 33from ..api import Difficulty, Phase
 34from ..loader import RPXDataset
 35from ..logging_utils import get_logger
 36from ..metrics.depth_alignment import DEPTH_MAX_M, DEPTH_MIN_M
 37from ..metrics.depth_temporal import MIN_CLIP_FRAMES
 38
 39log = get_logger(__name__)
 40
 41__all__ = ["PhaseClip", "PhaseClipDataset", "D1VClip", "D1VClipDataset"]
 42
 43#: Canonical phase ordering for clip iteration and the temporal axis.
 44_PHASE_TO_IDX: Dict[Phase, int] = {Phase.CLUTTER: 0, Phase.INTERACTION: 1, Phase.CLEAN: 2}
 45
 46#: Default D435 intrinsics (shared with the metric calculators).
 47_D435_K = np.array([[615.0, 0.0, 320.0], [0.0, 615.0, 240.0], [0.0, 0.0, 1.0]], dtype=np.float64)
 48
 49_TRAILING_INT = re.compile(r"(\d+)(?!.*\d)")
 50
 51
 52@dataclass
 53class PhaseClip:
 54    """One phase clip for Video Depth evaluation (decisions doc §3.2).
 55
 56    Attributes
 57    ----------
 58    rgb_seq : (T, H, W, 3) uint8
 59    depth_gt_seq : (T, H, W) float32, metres (0 = invalid sentinel).
 60    valid_mask_seq : (T, H, W) bool — GT in (0.3, 5.0) m and finite.
 61    scene_id : str
 62    phase_idx : int — 0=clutter, 1=interaction, 2=clean.
 63    frame_indices : (T,) int — original frame numbers, ascending.
 64    intrinsics : (3, 3) float64 — pinhole K (default D435).
 65    poses : (T, 4, 4) float64 | None — world-from-camera SE(3); ``None``
 66        if any frame in the clip lacks a T265 pose.
 67    difficulty : Difficulty | None — the scene's ESD tier (easy/medium/
 68        hard), needed so the (scene, phase) cell can carry its split tag
 69        for cross-split (phase×difficulty) Φ. ``None`` if untagged.
 70    """
 71
 72    rgb_seq: np.ndarray
 73    depth_gt_seq: np.ndarray
 74    valid_mask_seq: np.ndarray
 75    scene_id: str
 76    phase_idx: int
 77    frame_indices: np.ndarray
 78    intrinsics: np.ndarray
 79    poses: Optional[np.ndarray]
 80    difficulty: Optional[Difficulty] = None
 81
 82    @property
 83    def num_frames(self) -> int:
 84        return int(self.rgb_seq.shape[0])
 85
 86    @property
 87    def phase(self) -> Phase:
 88        return {0: Phase.CLUTTER, 1: Phase.INTERACTION, 2: Phase.CLEAN}[self.phase_idx]
 89
 90
 91def _phase_idx_of(entry: Dict[str, Any]) -> Optional[int]:
 92    """Resolve an entry's phase to a canonical 0/1/2 index, or ``None``."""
 93    v = entry.get("phase")
 94    if v is None:
 95        return None
 96    if isinstance(v, (int, np.integer)):
 97        return int(v) if int(v) in (0, 1, 2) else None
 98    try:
 99        return _PHASE_TO_IDX[Phase(v)]
100    except (ValueError, KeyError):
101        return None
102
103
104def _frame_key(entry: Dict[str, Any]) -> int:
105    """Best-effort ascending frame index for ordering within a clip.
106
107    Prefers an explicit ``frame`` field (top-level or in ``metadata``),
108    then the trailing integer of the sample ``id``, then the RGB filename
109    stem.  Falls back to 0 so ordering is at worst stable-by-insertion.
110    """
111    for src in (entry.get("frame"), (entry.get("metadata") or {}).get("frame")):
112        if isinstance(src, (int, np.integer)):
113            return int(src)
114    for text in (str(entry.get("id", "")), Path(str(entry.get("rgb", ""))).stem):
115        m = _TRAILING_INT.search(text)
116        if m:
117            return int(m.group(1))
118    return 0
119
120
121@dataclass
122class PhaseClipDataset:
123    """Groups a depth manifest into per-(scene, phase) clips.
124
125    Construct via :meth:`from_manifest`.  Iterating yields
126    :class:`PhaseClip` objects in ``(scene, phase)`` order, skipping clips
127    with fewer than ``min_frames`` frames.
128    """
129
130    base: RPXDataset
131    min_frames: int = MIN_CLIP_FRAMES
132    intrinsics: np.ndarray = None  # type: ignore[assignment]
133
134    def __post_init__(self) -> None:
135        if self.intrinsics is None:
136            self.intrinsics = _D435_K
137
138    @classmethod
139    def from_manifest(
140        cls,
141        manifest_path: str | Path,
142        min_frames: int = MIN_CLIP_FRAMES,
143        intrinsics: Optional[np.ndarray] = None,
144    ) -> "PhaseClipDataset":
145        base = RPXDataset.from_manifest(manifest_path, batch_size=1)
146        return cls(base=base, min_frames=min_frames,
147                   intrinsics=intrinsics if intrinsics is not None else _D435_K)
148
149    # -- grouping ---------------------------------------------------------- #
150
151    def _groups(self) -> List[Tuple[str, int, List[Dict[str, Any]]]]:
152        """Return ``[(scene, phase_idx, [entries...]), ...]`` in iteration order.
153
154        Entries are sorted by frame index within each group; groups are
155        sorted by ``(scene, phase_idx)``.  Entries with no resolvable
156        scene or phase are dropped (warned once per dropped count).
157        """
158        buckets: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
159        dropped = 0
160        for entry in self.base.samples:
161            scene = entry.get("scene") or (entry.get("metadata") or {}).get("scene_id")
162            phase_idx = _phase_idx_of(entry)
163            if scene is None or phase_idx is None:
164                dropped += 1
165                continue
166            buckets.setdefault((str(scene), phase_idx), []).append(entry)
167        if dropped:
168            log.warning("PhaseClipDataset: dropped %d entries with no scene/phase", dropped)
169
170        groups = []
171        for (scene, phase_idx), entries in sorted(buckets.items(), key=lambda kv: kv[0]):
172            entries_sorted = sorted(entries, key=_frame_key)
173            groups.append((scene, phase_idx, entries_sorted))
174        return groups
175
176    def clip_plan(self) -> List[Tuple[str, int, int]]:
177        """``[(scene, phase_idx, n_frames), ...]`` without loading pixels.
178
179        Cheap introspection for run planning / progress bars; reflects the
180        ``min_frames`` skip.
181        """
182        return [
183            (scene, phase_idx, len(entries))
184            for scene, phase_idx, entries in self._groups()
185            if len(entries) >= self.min_frames
186        ]
187
188    # -- loading ----------------------------------------------------------- #
189
190    def _load_clip(self, scene: str, phase_idx: int, entries: List[Dict[str, Any]]) -> PhaseClip:
191        rgbs, depths, valids, frame_ids, poses = [], [], [], [], []
192        pose_complete = True
193        difficulty: Optional[Difficulty] = None
194        for entry in entries:
195            sample = self.base._load_sample(entry)
196            depth = np.asarray(sample.ground_truth.depth_map, dtype=np.float32)
197            rgbs.append(np.asarray(sample.rgb, dtype=np.uint8))
198            depths.append(depth)
199            valids.append(np.isfinite(depth) & (depth > DEPTH_MIN_M) & (depth < DEPTH_MAX_M))
200            frame_ids.append(_frame_key(entry))
201            if sample.camera_pose is None:
202                pose_complete = False
203            else:
204                poses.append(np.asarray(sample.camera_pose, dtype=np.float64))
205            # Difficulty is a per-scene ESD tier — constant within a
206            # (scene, phase) clip; take the first non-null and warn on drift.
207            if sample.difficulty is not None:
208                if difficulty is None:
209                    difficulty = sample.difficulty
210                elif difficulty != sample.difficulty:
211                    log.warning(
212                        "PhaseClipDataset: %s phase %d has mixed difficulty (%s vs %s); keeping %s",
213                        scene, phase_idx, difficulty, sample.difficulty, difficulty,
214                    )
215
216        return PhaseClip(
217            rgb_seq=np.stack(rgbs),
218            depth_gt_seq=np.stack(depths),
219            valid_mask_seq=np.stack(valids),
220            scene_id=scene,
221            phase_idx=phase_idx,
222            frame_indices=np.asarray(frame_ids, dtype=np.int64),
223            intrinsics=self.intrinsics,
224            poses=np.stack(poses) if (pose_complete and poses) else None,
225            difficulty=difficulty,
226        )
227
228    def __iter__(self) -> Iterator[PhaseClip]:
229        for scene, phase_idx, entries in self._groups():
230            if len(entries) < self.min_frames:
231                log.warning(
232                    "PhaseClipDataset: skipping %s phase %d — only %d frames (< %d)",
233                    scene, phase_idx, len(entries), self.min_frames,
234                )
235                continue
236            yield self._load_clip(scene, phase_idx, entries)
237
238    def __len__(self) -> int:
239        """Number of clips that will be yielded (after the min_frames skip)."""
240        return len(self.clip_plan())
241
242
243# --------------------------------------------------------------------------- #
244# Paper-facing aliases — RPX depth task labels onto the generic clip API.
245# The generic names above are canonical; these are the paper's Video Depth labels.
246# --------------------------------------------------------------------------- #
247
248#: Paper alias for :class:`PhaseClip` (RPX task Video Depth).
249D1VClip = PhaseClip
250#: Paper alias for :class:`PhaseClipDataset` (RPX task Video Depth).
251D1VClipDataset = PhaseClipDataset
@dataclass
class PhaseClip:
53@dataclass
54class PhaseClip:
55    """One phase clip for Video Depth evaluation (decisions doc §3.2).
56
57    Attributes
58    ----------
59    rgb_seq : (T, H, W, 3) uint8
60    depth_gt_seq : (T, H, W) float32, metres (0 = invalid sentinel).
61    valid_mask_seq : (T, H, W) bool — GT in (0.3, 5.0) m and finite.
62    scene_id : str
63    phase_idx : int — 0=clutter, 1=interaction, 2=clean.
64    frame_indices : (T,) int — original frame numbers, ascending.
65    intrinsics : (3, 3) float64 — pinhole K (default D435).
66    poses : (T, 4, 4) float64 | None — world-from-camera SE(3); ``None``
67        if any frame in the clip lacks a T265 pose.
68    difficulty : Difficulty | None — the scene's ESD tier (easy/medium/
69        hard), needed so the (scene, phase) cell can carry its split tag
70        for cross-split (phase×difficulty) Φ. ``None`` if untagged.
71    """
72
73    rgb_seq: np.ndarray
74    depth_gt_seq: np.ndarray
75    valid_mask_seq: np.ndarray
76    scene_id: str
77    phase_idx: int
78    frame_indices: np.ndarray
79    intrinsics: np.ndarray
80    poses: Optional[np.ndarray]
81    difficulty: Optional[Difficulty] = None
82
83    @property
84    def num_frames(self) -> int:
85        return int(self.rgb_seq.shape[0])
86
87    @property
88    def phase(self) -> Phase:
89        return {0: Phase.CLUTTER, 1: Phase.INTERACTION, 2: Phase.CLEAN}[self.phase_idx]

One phase clip for Video Depth evaluation (decisions doc §3.2).

Attributes

rgb_seq : (T, H, W, 3) uint8 depth_gt_seq : (T, H, W) float32, metres (0 = invalid sentinel). valid_mask_seq : (T, H, W) bool — GT in (0.3, 5.0) m and finite. scene_id : str phase_idx : int — 0=clutter, 1=interaction, 2=clean. frame_indices : (T,) int — original frame numbers, ascending. intrinsics : (3, 3) float64 — pinhole K (default D435). poses : (T, 4, 4) float64 | None — world-from-camera SE(3); None if any frame in the clip lacks a T265 pose. difficulty : Difficulty | None — the scene's ESD tier (easy/medium/ hard), needed so the (scene, phase) cell can carry its split tag for cross-split (phase×difficulty) Φ. None if untagged.

PhaseClip( rgb_seq: numpy.ndarray, depth_gt_seq: numpy.ndarray, valid_mask_seq: numpy.ndarray, scene_id: str, phase_idx: int, frame_indices: numpy.ndarray, intrinsics: numpy.ndarray, poses: Optional[numpy.ndarray], difficulty: Optional[rpx_benchmark.api.Difficulty] = None)
rgb_seq: numpy.ndarray
depth_gt_seq: numpy.ndarray
valid_mask_seq: numpy.ndarray
scene_id: str
phase_idx: int
frame_indices: numpy.ndarray
intrinsics: numpy.ndarray
poses: Optional[numpy.ndarray]
difficulty: Optional[rpx_benchmark.api.Difficulty] = None
num_frames: int
83    @property
84    def num_frames(self) -> int:
85        return int(self.rgb_seq.shape[0])
phase: rpx_benchmark.api.Phase
87    @property
88    def phase(self) -> Phase:
89        return {0: Phase.CLUTTER, 1: Phase.INTERACTION, 2: Phase.CLEAN}[self.phase_idx]
@dataclass
class PhaseClipDataset:
122@dataclass
123class PhaseClipDataset:
124    """Groups a depth manifest into per-(scene, phase) clips.
125
126    Construct via :meth:`from_manifest`.  Iterating yields
127    :class:`PhaseClip` objects in ``(scene, phase)`` order, skipping clips
128    with fewer than ``min_frames`` frames.
129    """
130
131    base: RPXDataset
132    min_frames: int = MIN_CLIP_FRAMES
133    intrinsics: np.ndarray = None  # type: ignore[assignment]
134
135    def __post_init__(self) -> None:
136        if self.intrinsics is None:
137            self.intrinsics = _D435_K
138
139    @classmethod
140    def from_manifest(
141        cls,
142        manifest_path: str | Path,
143        min_frames: int = MIN_CLIP_FRAMES,
144        intrinsics: Optional[np.ndarray] = None,
145    ) -> "PhaseClipDataset":
146        base = RPXDataset.from_manifest(manifest_path, batch_size=1)
147        return cls(base=base, min_frames=min_frames,
148                   intrinsics=intrinsics if intrinsics is not None else _D435_K)
149
150    # -- grouping ---------------------------------------------------------- #
151
152    def _groups(self) -> List[Tuple[str, int, List[Dict[str, Any]]]]:
153        """Return ``[(scene, phase_idx, [entries...]), ...]`` in iteration order.
154
155        Entries are sorted by frame index within each group; groups are
156        sorted by ``(scene, phase_idx)``.  Entries with no resolvable
157        scene or phase are dropped (warned once per dropped count).
158        """
159        buckets: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
160        dropped = 0
161        for entry in self.base.samples:
162            scene = entry.get("scene") or (entry.get("metadata") or {}).get("scene_id")
163            phase_idx = _phase_idx_of(entry)
164            if scene is None or phase_idx is None:
165                dropped += 1
166                continue
167            buckets.setdefault((str(scene), phase_idx), []).append(entry)
168        if dropped:
169            log.warning("PhaseClipDataset: dropped %d entries with no scene/phase", dropped)
170
171        groups = []
172        for (scene, phase_idx), entries in sorted(buckets.items(), key=lambda kv: kv[0]):
173            entries_sorted = sorted(entries, key=_frame_key)
174            groups.append((scene, phase_idx, entries_sorted))
175        return groups
176
177    def clip_plan(self) -> List[Tuple[str, int, int]]:
178        """``[(scene, phase_idx, n_frames), ...]`` without loading pixels.
179
180        Cheap introspection for run planning / progress bars; reflects the
181        ``min_frames`` skip.
182        """
183        return [
184            (scene, phase_idx, len(entries))
185            for scene, phase_idx, entries in self._groups()
186            if len(entries) >= self.min_frames
187        ]
188
189    # -- loading ----------------------------------------------------------- #
190
191    def _load_clip(self, scene: str, phase_idx: int, entries: List[Dict[str, Any]]) -> PhaseClip:
192        rgbs, depths, valids, frame_ids, poses = [], [], [], [], []
193        pose_complete = True
194        difficulty: Optional[Difficulty] = None
195        for entry in entries:
196            sample = self.base._load_sample(entry)
197            depth = np.asarray(sample.ground_truth.depth_map, dtype=np.float32)
198            rgbs.append(np.asarray(sample.rgb, dtype=np.uint8))
199            depths.append(depth)
200            valids.append(np.isfinite(depth) & (depth > DEPTH_MIN_M) & (depth < DEPTH_MAX_M))
201            frame_ids.append(_frame_key(entry))
202            if sample.camera_pose is None:
203                pose_complete = False
204            else:
205                poses.append(np.asarray(sample.camera_pose, dtype=np.float64))
206            # Difficulty is a per-scene ESD tier — constant within a
207            # (scene, phase) clip; take the first non-null and warn on drift.
208            if sample.difficulty is not None:
209                if difficulty is None:
210                    difficulty = sample.difficulty
211                elif difficulty != sample.difficulty:
212                    log.warning(
213                        "PhaseClipDataset: %s phase %d has mixed difficulty (%s vs %s); keeping %s",
214                        scene, phase_idx, difficulty, sample.difficulty, difficulty,
215                    )
216
217        return PhaseClip(
218            rgb_seq=np.stack(rgbs),
219            depth_gt_seq=np.stack(depths),
220            valid_mask_seq=np.stack(valids),
221            scene_id=scene,
222            phase_idx=phase_idx,
223            frame_indices=np.asarray(frame_ids, dtype=np.int64),
224            intrinsics=self.intrinsics,
225            poses=np.stack(poses) if (pose_complete and poses) else None,
226            difficulty=difficulty,
227        )
228
229    def __iter__(self) -> Iterator[PhaseClip]:
230        for scene, phase_idx, entries in self._groups():
231            if len(entries) < self.min_frames:
232                log.warning(
233                    "PhaseClipDataset: skipping %s phase %d — only %d frames (< %d)",
234                    scene, phase_idx, len(entries), self.min_frames,
235                )
236                continue
237            yield self._load_clip(scene, phase_idx, entries)
238
239    def __len__(self) -> int:
240        """Number of clips that will be yielded (after the min_frames skip)."""
241        return len(self.clip_plan())

Groups a depth manifest into per-(scene, phase) clips.

Construct via from_manifest(). Iterating yields PhaseClip objects in (scene, phase) order, skipping clips with fewer than min_frames frames.

PhaseClipDataset( base: rpx_benchmark.loader.RPXDataset, min_frames: int = 30, intrinsics: numpy.ndarray = None)
min_frames: int = 30
intrinsics: numpy.ndarray = None
@classmethod
def from_manifest( cls, manifest_path: str | pathlib.Path, min_frames: int = 30, intrinsics: Optional[numpy.ndarray] = None) -> PhaseClipDataset:
139    @classmethod
140    def from_manifest(
141        cls,
142        manifest_path: str | Path,
143        min_frames: int = MIN_CLIP_FRAMES,
144        intrinsics: Optional[np.ndarray] = None,
145    ) -> "PhaseClipDataset":
146        base = RPXDataset.from_manifest(manifest_path, batch_size=1)
147        return cls(base=base, min_frames=min_frames,
148                   intrinsics=intrinsics if intrinsics is not None else _D435_K)
def clip_plan(self) -> List[Tuple[str, int, int]]:
177    def clip_plan(self) -> List[Tuple[str, int, int]]:
178        """``[(scene, phase_idx, n_frames), ...]`` without loading pixels.
179
180        Cheap introspection for run planning / progress bars; reflects the
181        ``min_frames`` skip.
182        """
183        return [
184            (scene, phase_idx, len(entries))
185            for scene, phase_idx, entries in self._groups()
186            if len(entries) >= self.min_frames
187        ]

[(scene, phase_idx, n_frames), ...] without loading pixels.

Cheap introspection for run planning / progress bars; reflects the min_frames skip.

D1VClip = <class 'PhaseClip'>
D1VClipDataset = <class 'PhaseClipDataset'>