rpx_benchmark.adapters.batched_multimodal
Generic batched-dispatch BenchmarkModel base for multi-modal tasks.
The stock rpx_benchmark.adapters.base.BenchmarkableModel iterates
samples one at a time inside predict: even with --batch-size 8,
the underlying adapter still sees one sample per call. BatchedDepthBenchmarkModel
fixes that for monocular depth specifically.
This module generalises the pattern so every other task (segmentation, relative pose, sparse depth, keypoint matching, ...) can opt into true-batched dispatch without each one having to re-implement the batch-extraction loop, the per-sample save logic, and the prediction- wrapping ceremony.
Three task-specific hooks
Subclasses override:
extract_inputs(sample) -> Any()— pull whatever the model needs off the sample (rgb, rgb_b, fisheye_left, intrinsics, …). The return value is opaque to the base class; the adapter receives a list of these.call_adapter(inputs_list) -> outputs_list()— invoke the adapter on the full batch in one call. Default:self._adapter(inputs_list).wrap_output(model_output, sample) -> Prediction()— wrap one model output into the task's Prediction dataclass (e.g.,DepthPrediction,SegmentationPrediction,RelativePosePrediction).
Optional:
maybe_save(sample, model_output)()— persist per-frame outputs for offline analytics (the depth wrapper writes<save_dir>/<scene>/<phase>/<frame>.npz; segmentation could write PNG masks; tracking writes JSON tracklets).
Sample.metadata is the multi-modal side-channel
The loader's _load_sample puts non-RGB modalities into
sample.metadata (rgb_b, fisheye_left, fisheye_right,
…). Subclasses pull from there:
::
class BatchedRelativePoseBenchmarkModel(BatchedTaskBenchmarkModel):
task = TaskType.RELATIVE_CAMERA_POSE
def extract_inputs(self, sample):
rgb_a = np.asarray(sample.rgb, dtype=np.uint8)
rgb_b = np.asarray(sample.metadata["rgb_b"], dtype=np.uint8)
return {"rgb_a": rgb_a, "rgb_b": rgb_b}
The contract is intentionally loose: extract_inputs returns
whatever shape the adapter expects (numpy array, dict, tuple, ...),
and the adapter dispatches the batch as a list of those.
1"""Generic batched-dispatch BenchmarkModel base for multi-modal tasks. 2 3The stock :class:`rpx_benchmark.adapters.base.BenchmarkableModel` iterates 4samples one at a time inside ``predict``: even with ``--batch-size 8``, 5the underlying adapter still sees one sample per call. ``BatchedDepthBenchmarkModel`` 6fixes that for monocular depth specifically. 7 8This module generalises the pattern so every other task (segmentation, 9relative pose, sparse depth, keypoint matching, ...) can opt into 10true-batched dispatch without each one having to re-implement the 11batch-extraction loop, the per-sample save logic, and the prediction- 12wrapping ceremony. 13 14Three task-specific hooks 15------------------------- 16Subclasses override: 17 18* :meth:`extract_inputs(sample) -> Any` — pull whatever the model needs 19 off the sample (rgb, rgb_b, fisheye_left, intrinsics, …). The return 20 value is opaque to the base class; the adapter receives a list of 21 these. 22* :meth:`call_adapter(inputs_list) -> outputs_list` — invoke the 23 adapter on the full batch in one call. Default: ``self._adapter(inputs_list)``. 24* :meth:`wrap_output(model_output, sample) -> Prediction` — wrap one 25 model output into the task's Prediction dataclass (e.g., 26 ``DepthPrediction``, ``SegmentationPrediction``, 27 ``RelativePosePrediction``). 28 29Optional: 30 31* :meth:`maybe_save(sample, model_output)` — persist per-frame outputs 32 for offline analytics (the depth wrapper writes 33 ``<save_dir>/<scene>/<phase>/<frame>.npz``; segmentation could write 34 PNG masks; tracking writes JSON tracklets). 35 36Sample.metadata is the multi-modal side-channel 37----------------------------------------------- 38The loader's ``_load_sample`` puts non-RGB modalities into 39``sample.metadata`` (``rgb_b``, ``fisheye_left``, ``fisheye_right``, 40…). Subclasses pull from there: 41 42:: 43 44 class BatchedRelativePoseBenchmarkModel(BatchedTaskBenchmarkModel): 45 task = TaskType.RELATIVE_CAMERA_POSE 46 def extract_inputs(self, sample): 47 rgb_a = np.asarray(sample.rgb, dtype=np.uint8) 48 rgb_b = np.asarray(sample.metadata["rgb_b"], dtype=np.uint8) 49 return {"rgb_a": rgb_a, "rgb_b": rgb_b} 50 51The contract is intentionally loose: ``extract_inputs`` returns 52whatever shape the adapter expects (numpy array, dict, tuple, ...), 53and the adapter dispatches the batch as a list of those. 54""" 55 56from __future__ import annotations 57 58from pathlib import Path 59from typing import Any, List, Optional, Sequence 60 61import numpy as np 62 63from ..api import Sample, TaskType 64 65__all__ = [ 66 "BatchedTaskBenchmarkModel", 67 "BatchedSegmentationBenchmarkModel", 68 "BatchedRelativePoseBenchmarkModel", 69] 70 71 72class BatchedTaskBenchmarkModel: 73 """Abstract base — true-batched-dispatch wrapper for any RPX task. 74 75 Subclasses set the class-level ``task`` (a :class:`TaskType`) and 76 override ``extract_inputs`` + ``wrap_output``. The harness contract 77 matches ``BenchmarkableModel`` (``setup`` + ``predict(batch)``). 78 79 Parameters 80 ---------- 81 adapter 82 The model callable. Receives a list of whatever ``extract_inputs`` 83 returns (one per sample). Returns a list of model outputs (one per 84 sample, same order). 85 name 86 Display name surfaced in result.json + Box paths. 87 save_dir 88 If set, ``maybe_save`` writes per-frame artefacts. Default: no save. 89 """ 90 91 #: Task this wrapper produces predictions for. Subclasses set this. 92 task: TaskType = TaskType.MONOCULAR_DEPTH # placeholder — must be overridden 93 94 def __init__( 95 self, 96 adapter: Any, 97 *, 98 name: str, 99 save_dir: Optional[str | Path] = None, 100 ) -> None: 101 self._adapter = adapter 102 self.name = name 103 self._save_dir = Path(save_dir) if save_dir else None 104 # Profiler walker reaches the underlying nn.Module via this attr. 105 self.model = adapter 106 107 def setup(self) -> None: 108 """No-op — the adapter loads its own weights at construction.""" 109 return None 110 111 # ──────────────────────── hooks ──────────────────────── 112 113 def extract_inputs(self, sample: Sample) -> Any: 114 """Subclass: return whatever the adapter expects per sample. 115 116 Examples: 117 - Depth: ``np.asarray(sample.rgb, dtype=np.uint8)`` 118 - Pose: ``{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]}`` 119 - Stereo depth: ``{"left": metadata["fisheye_left"], 120 "right": metadata["fisheye_right"]}`` 121 """ 122 raise NotImplementedError 123 124 def call_adapter(self, inputs: List[Any]) -> List[Any]: 125 """Subclass override point: how the adapter is invoked on a list. 126 127 Default: ``self._adapter(inputs)`` — works when the adapter 128 accepts a list directly (HF pipelines, our depth adapters). 129 Override for adapters with a different batched contract. 130 """ 131 out = self._adapter(inputs) 132 if not isinstance(out, (list, tuple)): 133 out = [out] 134 return list(out) 135 136 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 137 """Subclass: wrap one model output as the task's Prediction dataclass.""" 138 raise NotImplementedError 139 140 def maybe_save(self, sample: Sample, model_output: Any) -> None: 141 """Optional override — persist per-frame artefacts for analytics.""" 142 return None 143 144 # ──────────────────────── predict() ──────────────────────── 145 146 def predict(self, batch: Sequence[Sample]) -> List[Any]: 147 inputs = [self.extract_inputs(s) for s in batch] 148 outputs = self.call_adapter(inputs) 149 if len(outputs) != len(batch): 150 from ..exceptions import AdapterError 151 152 raise AdapterError( 153 f"adapter returned {len(outputs)} outputs for a batch of {len(batch)}", 154 hint="Check the adapter's batched contract: it must return " 155 "one output per input sample.", 156 ) 157 preds: List[Any] = [] 158 for sample, out in zip(batch, outputs, strict=False): 159 preds.append(self.wrap_output(out, sample)) 160 self.maybe_save(sample, out) 161 return preds 162 163 164# ──────────────────────── Reference subclasses ──────────────────────── 165 166 167class BatchedSegmentationBenchmarkModel(BatchedTaskBenchmarkModel): 168 """Batched wrapper for object-segmentation models. 169 170 Adapter contract: ``adapter(list_of_rgb_uint8_HxWx3) -> list_of_int_mask_HxW``. 171 Output mask values are integer instance IDs (0 = background). If the 172 model returns a different shape than the input RGB, we nearest-neighbour 173 resize back to preserve integer IDs. 174 """ 175 176 task = TaskType.OBJECT_SEGMENTATION 177 178 def extract_inputs(self, sample: Sample) -> np.ndarray: 179 return np.asarray(sample.rgb, dtype=np.uint8) 180 181 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 182 from PIL import Image 183 184 from ..api import SegmentationPrediction 185 186 mask = np.asarray(model_output) 187 target_hw = np.asarray(sample.rgb).shape[:2] 188 if mask.shape != target_hw: 189 # Nearest-neighbour preserves integer IDs. 190 img = Image.fromarray(mask.astype(np.int32), mode="I") 191 img = img.resize((target_hw[1], target_hw[0]), Image.NEAREST) 192 mask = np.asarray(img, dtype=np.int32) 193 return SegmentationPrediction(mask=mask.astype(np.int32)) 194 195 def maybe_save(self, sample: Sample, model_output: Any) -> None: 196 if self._save_dir is None: 197 return 198 from PIL import Image 199 200 meta = getattr(sample, "metadata", None) or {} 201 scene = meta.get("scene_id") 202 phase = meta.get("phase_idx") 203 frame = meta.get("frame") 204 if not (scene and frame is not None and phase is not None): 205 try: 206 scene, phase, frame = str(sample.id).split("__", 2) 207 except ValueError: 208 scene, phase, frame = "unknown", "0", str(sample.id) 209 out = self._save_dir / str(scene) / str(phase) / f"{frame}.png" 210 out.parent.mkdir(parents=True, exist_ok=True) 211 # 16-bit PNG preserves up to 65 K instance ids; sufficient for SAM2. 212 Image.fromarray(np.asarray(model_output, dtype=np.uint16), mode="I;16").save(out) 213 214 215class BatchedRelativePoseBenchmarkModel(BatchedTaskBenchmarkModel): 216 """Batched wrapper for paired-frame relative-pose models. 217 218 Adapter contract: ``adapter(list_of_dicts) -> list_of_pose_dicts``, 219 where each input dict is ``{"rgb_a": HxWx3 uint8, "rgb_b": HxWx3 uint8}`` 220 and each output is ``{"rotation": 3x3, "translation": 3}`` (or a tuple). 221 """ 222 223 task = TaskType.RELATIVE_CAMERA_POSE 224 225 def extract_inputs(self, sample: Sample) -> dict: 226 meta = getattr(sample, "metadata", None) or {} 227 rgb_a = np.asarray(sample.rgb, dtype=np.uint8) 228 rgb_b = meta.get("rgb_b") 229 if rgb_b is None: 230 from ..exceptions import AdapterError 231 232 raise AdapterError( 233 "Relative-pose adapter requires `rgb_b` in sample.metadata; " 234 "check the manifest writes `rgb_b` so the loader stashes it.", 235 ) 236 return {"rgb_a": rgb_a, "rgb_b": np.asarray(rgb_b, dtype=np.uint8)} 237 238 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 239 from ..api import RelativePosePrediction 240 241 if isinstance(model_output, dict): 242 rot = model_output["rotation"] 243 trans = model_output["translation"] 244 else: 245 rot, trans = model_output 246 return RelativePosePrediction( 247 rotation=np.asarray(rot, dtype=np.float64), 248 translation=np.asarray(trans, dtype=np.float64), 249 ) 250 251 #: CSV columns. ``sample_id`` is the canonical identity; both phases are 252 #: retained so cross-phase pairs cannot collide with intra-phase pairs. 253 _LOG_COLUMNS: tuple[str, ...] = ( 254 "sample_id", 255 "scene_id", 256 "phase", 257 "phase_b", 258 "frame_a", 259 "frame_b", 260 "R00", 261 "R01", 262 "R02", 263 "R10", 264 "R11", 265 "R12", 266 "R20", 267 "R21", 268 "R22", 269 "tx", 270 "ty", 271 "tz", 272 ) 273 274 def maybe_save(self, sample: Sample, model_output: Any) -> None: 275 """Append one row per pair to ``<save_dir>/predictions.csv``. 276 277 Rationale: paired-pose runs produce ~3 K rows per (model, split); 278 a single CSV is friendlier for Box and analytics than 3 K 279 per-pair ``.npz`` files. Columns: scene_id, phase, frame_a, 280 frame_b, the 9 rotation entries (row-major) and 3 translation 281 components. Header is written exactly once per file (on the 282 first append to an empty / non-existent log). Idempotent on 283 ``sample.id`` within a single process so 284 the runner's deployment-readiness double pass (warmup + measure) 285 doesn't duplicate rows. 286 """ 287 if self._save_dir is None: 288 return 289 import csv 290 291 meta = getattr(sample, "metadata", None) or {} 292 scene = meta.get("scene_id") or "unknown" 293 phase = meta.get("phase_idx") if meta.get("phase_idx") is not None else "0" 294 phase_b = meta.get("phase_idx_b") 295 if phase_b is None: 296 phase_b = phase 297 frame_a = meta.get("frame") or str(sample.id) 298 frame_b = meta.get("frame_b") or "?" 299 sample_id = str(sample.id) 300 301 seen = self.__dict__.setdefault("_pose_csv_seen", set()) 302 key = sample_id 303 if key in seen: 304 return 305 seen.add(key) 306 307 if isinstance(model_output, dict): 308 rot = np.asarray(model_output["rotation"], dtype=np.float64) 309 trans = np.asarray(model_output["translation"], dtype=np.float64) 310 else: 311 rot, trans = model_output 312 rot = np.asarray(rot, dtype=np.float64) 313 trans = np.asarray(trans, dtype=np.float64) 314 315 # Tolerate rotation given as a flat 9-vector or a quaternion. 316 rot = rot.reshape(-1) 317 if rot.size == 4: 318 # Quaternion (x, y, z, w) → 3×3 rotation. We don't depend on 319 # scipy here; build it inline from the unit-quaternion formula. 320 x, y, z, w = rot.tolist() 321 rot = np.asarray( 322 [ 323 1 - 2 * (y * y + z * z), 324 2 * (x * y - z * w), 325 2 * (x * z + y * w), 326 2 * (x * y + z * w), 327 1 - 2 * (x * x + z * z), 328 2 * (y * z - x * w), 329 2 * (x * z - y * w), 330 2 * (y * z + x * w), 331 1 - 2 * (x * x + y * y), 332 ], 333 dtype=np.float64, 334 ) 335 elif rot.size != 9: 336 from ..exceptions import AdapterError 337 338 raise AdapterError( 339 f"unexpected rotation shape: got {rot.size} entries; " 340 "expected 9 (3×3 matrix) or 4 (xyzw quaternion).", 341 ) 342 trans = trans.reshape(-1) 343 if trans.size != 3: 344 from ..exceptions import AdapterError 345 346 raise AdapterError( 347 f"unexpected translation shape: got {trans.size} entries; expected 3.", 348 ) 349 350 log_path = self._save_dir / "predictions.csv" 351 log_path.parent.mkdir(parents=True, exist_ok=True) 352 write_header = not log_path.exists() or log_path.stat().st_size == 0 353 with log_path.open("a", newline="") as f: 354 writer = csv.writer(f) 355 if write_header: 356 writer.writerow(self._LOG_COLUMNS) 357 writer.writerow( 358 [ 359 sample_id, 360 scene, 361 phase, 362 phase_b, 363 frame_a, 364 frame_b, 365 *(f"{v:.10g}" for v in rot.tolist()), 366 *(f"{v:.10g}" for v in trans.tolist()), 367 ] 368 )
73class BatchedTaskBenchmarkModel: 74 """Abstract base — true-batched-dispatch wrapper for any RPX task. 75 76 Subclasses set the class-level ``task`` (a :class:`TaskType`) and 77 override ``extract_inputs`` + ``wrap_output``. The harness contract 78 matches ``BenchmarkableModel`` (``setup`` + ``predict(batch)``). 79 80 Parameters 81 ---------- 82 adapter 83 The model callable. Receives a list of whatever ``extract_inputs`` 84 returns (one per sample). Returns a list of model outputs (one per 85 sample, same order). 86 name 87 Display name surfaced in result.json + Box paths. 88 save_dir 89 If set, ``maybe_save`` writes per-frame artefacts. Default: no save. 90 """ 91 92 #: Task this wrapper produces predictions for. Subclasses set this. 93 task: TaskType = TaskType.MONOCULAR_DEPTH # placeholder — must be overridden 94 95 def __init__( 96 self, 97 adapter: Any, 98 *, 99 name: str, 100 save_dir: Optional[str | Path] = None, 101 ) -> None: 102 self._adapter = adapter 103 self.name = name 104 self._save_dir = Path(save_dir) if save_dir else None 105 # Profiler walker reaches the underlying nn.Module via this attr. 106 self.model = adapter 107 108 def setup(self) -> None: 109 """No-op — the adapter loads its own weights at construction.""" 110 return None 111 112 # ──────────────────────── hooks ──────────────────────── 113 114 def extract_inputs(self, sample: Sample) -> Any: 115 """Subclass: return whatever the adapter expects per sample. 116 117 Examples: 118 - Depth: ``np.asarray(sample.rgb, dtype=np.uint8)`` 119 - Pose: ``{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]}`` 120 - Stereo depth: ``{"left": metadata["fisheye_left"], 121 "right": metadata["fisheye_right"]}`` 122 """ 123 raise NotImplementedError 124 125 def call_adapter(self, inputs: List[Any]) -> List[Any]: 126 """Subclass override point: how the adapter is invoked on a list. 127 128 Default: ``self._adapter(inputs)`` — works when the adapter 129 accepts a list directly (HF pipelines, our depth adapters). 130 Override for adapters with a different batched contract. 131 """ 132 out = self._adapter(inputs) 133 if not isinstance(out, (list, tuple)): 134 out = [out] 135 return list(out) 136 137 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 138 """Subclass: wrap one model output as the task's Prediction dataclass.""" 139 raise NotImplementedError 140 141 def maybe_save(self, sample: Sample, model_output: Any) -> None: 142 """Optional override — persist per-frame artefacts for analytics.""" 143 return None 144 145 # ──────────────────────── predict() ──────────────────────── 146 147 def predict(self, batch: Sequence[Sample]) -> List[Any]: 148 inputs = [self.extract_inputs(s) for s in batch] 149 outputs = self.call_adapter(inputs) 150 if len(outputs) != len(batch): 151 from ..exceptions import AdapterError 152 153 raise AdapterError( 154 f"adapter returned {len(outputs)} outputs for a batch of {len(batch)}", 155 hint="Check the adapter's batched contract: it must return " 156 "one output per input sample.", 157 ) 158 preds: List[Any] = [] 159 for sample, out in zip(batch, outputs, strict=False): 160 preds.append(self.wrap_output(out, sample)) 161 self.maybe_save(sample, out) 162 return preds
Abstract base — true-batched-dispatch wrapper for any RPX task.
Subclasses set the class-level task (a TaskType) and
override extract_inputs + wrap_output. The harness contract
matches BenchmarkableModel (setup + predict(batch)).
Parameters
adapter
The model callable. Receives a list of whatever extract_inputs
returns (one per sample). Returns a list of model outputs (one per
sample, same order).
name
Display name surfaced in result.json + Box paths.
save_dir
If set, maybe_save writes per-frame artefacts. Default: no save.
95 def __init__( 96 self, 97 adapter: Any, 98 *, 99 name: str, 100 save_dir: Optional[str | Path] = None, 101 ) -> None: 102 self._adapter = adapter 103 self.name = name 104 self._save_dir = Path(save_dir) if save_dir else None 105 # Profiler walker reaches the underlying nn.Module via this attr. 106 self.model = adapter
108 def setup(self) -> None: 109 """No-op — the adapter loads its own weights at construction.""" 110 return None
No-op — the adapter loads its own weights at construction.
114 def extract_inputs(self, sample: Sample) -> Any: 115 """Subclass: return whatever the adapter expects per sample. 116 117 Examples: 118 - Depth: ``np.asarray(sample.rgb, dtype=np.uint8)`` 119 - Pose: ``{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]}`` 120 - Stereo depth: ``{"left": metadata["fisheye_left"], 121 "right": metadata["fisheye_right"]}`` 122 """ 123 raise NotImplementedError
Subclass: return whatever the adapter expects per sample.
Examples:
- Depth:
np.asarray(sample.rgb, dtype=np.uint8) - Pose:
{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]} - Stereo depth:
{"left": metadata["fisheye_left"], "right": metadata["fisheye_right"]}
125 def call_adapter(self, inputs: List[Any]) -> List[Any]: 126 """Subclass override point: how the adapter is invoked on a list. 127 128 Default: ``self._adapter(inputs)`` — works when the adapter 129 accepts a list directly (HF pipelines, our depth adapters). 130 Override for adapters with a different batched contract. 131 """ 132 out = self._adapter(inputs) 133 if not isinstance(out, (list, tuple)): 134 out = [out] 135 return list(out)
Subclass override point: how the adapter is invoked on a list.
Default: self._adapter(inputs) — works when the adapter
accepts a list directly (HF pipelines, our depth adapters).
Override for adapters with a different batched contract.
137 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 138 """Subclass: wrap one model output as the task's Prediction dataclass.""" 139 raise NotImplementedError
Subclass: wrap one model output as the task's Prediction dataclass.
141 def maybe_save(self, sample: Sample, model_output: Any) -> None: 142 """Optional override — persist per-frame artefacts for analytics.""" 143 return None
Optional override — persist per-frame artefacts for analytics.
147 def predict(self, batch: Sequence[Sample]) -> List[Any]: 148 inputs = [self.extract_inputs(s) for s in batch] 149 outputs = self.call_adapter(inputs) 150 if len(outputs) != len(batch): 151 from ..exceptions import AdapterError 152 153 raise AdapterError( 154 f"adapter returned {len(outputs)} outputs for a batch of {len(batch)}", 155 hint="Check the adapter's batched contract: it must return " 156 "one output per input sample.", 157 ) 158 preds: List[Any] = [] 159 for sample, out in zip(batch, outputs, strict=False): 160 preds.append(self.wrap_output(out, sample)) 161 self.maybe_save(sample, out) 162 return preds
168class BatchedSegmentationBenchmarkModel(BatchedTaskBenchmarkModel): 169 """Batched wrapper for object-segmentation models. 170 171 Adapter contract: ``adapter(list_of_rgb_uint8_HxWx3) -> list_of_int_mask_HxW``. 172 Output mask values are integer instance IDs (0 = background). If the 173 model returns a different shape than the input RGB, we nearest-neighbour 174 resize back to preserve integer IDs. 175 """ 176 177 task = TaskType.OBJECT_SEGMENTATION 178 179 def extract_inputs(self, sample: Sample) -> np.ndarray: 180 return np.asarray(sample.rgb, dtype=np.uint8) 181 182 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 183 from PIL import Image 184 185 from ..api import SegmentationPrediction 186 187 mask = np.asarray(model_output) 188 target_hw = np.asarray(sample.rgb).shape[:2] 189 if mask.shape != target_hw: 190 # Nearest-neighbour preserves integer IDs. 191 img = Image.fromarray(mask.astype(np.int32), mode="I") 192 img = img.resize((target_hw[1], target_hw[0]), Image.NEAREST) 193 mask = np.asarray(img, dtype=np.int32) 194 return SegmentationPrediction(mask=mask.astype(np.int32)) 195 196 def maybe_save(self, sample: Sample, model_output: Any) -> None: 197 if self._save_dir is None: 198 return 199 from PIL import Image 200 201 meta = getattr(sample, "metadata", None) or {} 202 scene = meta.get("scene_id") 203 phase = meta.get("phase_idx") 204 frame = meta.get("frame") 205 if not (scene and frame is not None and phase is not None): 206 try: 207 scene, phase, frame = str(sample.id).split("__", 2) 208 except ValueError: 209 scene, phase, frame = "unknown", "0", str(sample.id) 210 out = self._save_dir / str(scene) / str(phase) / f"{frame}.png" 211 out.parent.mkdir(parents=True, exist_ok=True) 212 # 16-bit PNG preserves up to 65 K instance ids; sufficient for SAM2. 213 Image.fromarray(np.asarray(model_output, dtype=np.uint16), mode="I;16").save(out)
Batched wrapper for object-segmentation models.
Adapter contract: adapter(list_of_rgb_uint8_HxWx3) -> list_of_int_mask_HxW.
Output mask values are integer instance IDs (0 = background). If the
model returns a different shape than the input RGB, we nearest-neighbour
resize back to preserve integer IDs.
179 def extract_inputs(self, sample: Sample) -> np.ndarray: 180 return np.asarray(sample.rgb, dtype=np.uint8)
Subclass: return whatever the adapter expects per sample.
Examples:
- Depth:
np.asarray(sample.rgb, dtype=np.uint8) - Pose:
{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]} - Stereo depth:
{"left": metadata["fisheye_left"], "right": metadata["fisheye_right"]}
182 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 183 from PIL import Image 184 185 from ..api import SegmentationPrediction 186 187 mask = np.asarray(model_output) 188 target_hw = np.asarray(sample.rgb).shape[:2] 189 if mask.shape != target_hw: 190 # Nearest-neighbour preserves integer IDs. 191 img = Image.fromarray(mask.astype(np.int32), mode="I") 192 img = img.resize((target_hw[1], target_hw[0]), Image.NEAREST) 193 mask = np.asarray(img, dtype=np.int32) 194 return SegmentationPrediction(mask=mask.astype(np.int32))
Subclass: wrap one model output as the task's Prediction dataclass.
196 def maybe_save(self, sample: Sample, model_output: Any) -> None: 197 if self._save_dir is None: 198 return 199 from PIL import Image 200 201 meta = getattr(sample, "metadata", None) or {} 202 scene = meta.get("scene_id") 203 phase = meta.get("phase_idx") 204 frame = meta.get("frame") 205 if not (scene and frame is not None and phase is not None): 206 try: 207 scene, phase, frame = str(sample.id).split("__", 2) 208 except ValueError: 209 scene, phase, frame = "unknown", "0", str(sample.id) 210 out = self._save_dir / str(scene) / str(phase) / f"{frame}.png" 211 out.parent.mkdir(parents=True, exist_ok=True) 212 # 16-bit PNG preserves up to 65 K instance ids; sufficient for SAM2. 213 Image.fromarray(np.asarray(model_output, dtype=np.uint16), mode="I;16").save(out)
Optional override — persist per-frame artefacts for analytics.
Inherited Members
216class BatchedRelativePoseBenchmarkModel(BatchedTaskBenchmarkModel): 217 """Batched wrapper for paired-frame relative-pose models. 218 219 Adapter contract: ``adapter(list_of_dicts) -> list_of_pose_dicts``, 220 where each input dict is ``{"rgb_a": HxWx3 uint8, "rgb_b": HxWx3 uint8}`` 221 and each output is ``{"rotation": 3x3, "translation": 3}`` (or a tuple). 222 """ 223 224 task = TaskType.RELATIVE_CAMERA_POSE 225 226 def extract_inputs(self, sample: Sample) -> dict: 227 meta = getattr(sample, "metadata", None) or {} 228 rgb_a = np.asarray(sample.rgb, dtype=np.uint8) 229 rgb_b = meta.get("rgb_b") 230 if rgb_b is None: 231 from ..exceptions import AdapterError 232 233 raise AdapterError( 234 "Relative-pose adapter requires `rgb_b` in sample.metadata; " 235 "check the manifest writes `rgb_b` so the loader stashes it.", 236 ) 237 return {"rgb_a": rgb_a, "rgb_b": np.asarray(rgb_b, dtype=np.uint8)} 238 239 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 240 from ..api import RelativePosePrediction 241 242 if isinstance(model_output, dict): 243 rot = model_output["rotation"] 244 trans = model_output["translation"] 245 else: 246 rot, trans = model_output 247 return RelativePosePrediction( 248 rotation=np.asarray(rot, dtype=np.float64), 249 translation=np.asarray(trans, dtype=np.float64), 250 ) 251 252 #: CSV columns. ``sample_id`` is the canonical identity; both phases are 253 #: retained so cross-phase pairs cannot collide with intra-phase pairs. 254 _LOG_COLUMNS: tuple[str, ...] = ( 255 "sample_id", 256 "scene_id", 257 "phase", 258 "phase_b", 259 "frame_a", 260 "frame_b", 261 "R00", 262 "R01", 263 "R02", 264 "R10", 265 "R11", 266 "R12", 267 "R20", 268 "R21", 269 "R22", 270 "tx", 271 "ty", 272 "tz", 273 ) 274 275 def maybe_save(self, sample: Sample, model_output: Any) -> None: 276 """Append one row per pair to ``<save_dir>/predictions.csv``. 277 278 Rationale: paired-pose runs produce ~3 K rows per (model, split); 279 a single CSV is friendlier for Box and analytics than 3 K 280 per-pair ``.npz`` files. Columns: scene_id, phase, frame_a, 281 frame_b, the 9 rotation entries (row-major) and 3 translation 282 components. Header is written exactly once per file (on the 283 first append to an empty / non-existent log). Idempotent on 284 ``sample.id`` within a single process so 285 the runner's deployment-readiness double pass (warmup + measure) 286 doesn't duplicate rows. 287 """ 288 if self._save_dir is None: 289 return 290 import csv 291 292 meta = getattr(sample, "metadata", None) or {} 293 scene = meta.get("scene_id") or "unknown" 294 phase = meta.get("phase_idx") if meta.get("phase_idx") is not None else "0" 295 phase_b = meta.get("phase_idx_b") 296 if phase_b is None: 297 phase_b = phase 298 frame_a = meta.get("frame") or str(sample.id) 299 frame_b = meta.get("frame_b") or "?" 300 sample_id = str(sample.id) 301 302 seen = self.__dict__.setdefault("_pose_csv_seen", set()) 303 key = sample_id 304 if key in seen: 305 return 306 seen.add(key) 307 308 if isinstance(model_output, dict): 309 rot = np.asarray(model_output["rotation"], dtype=np.float64) 310 trans = np.asarray(model_output["translation"], dtype=np.float64) 311 else: 312 rot, trans = model_output 313 rot = np.asarray(rot, dtype=np.float64) 314 trans = np.asarray(trans, dtype=np.float64) 315 316 # Tolerate rotation given as a flat 9-vector or a quaternion. 317 rot = rot.reshape(-1) 318 if rot.size == 4: 319 # Quaternion (x, y, z, w) → 3×3 rotation. We don't depend on 320 # scipy here; build it inline from the unit-quaternion formula. 321 x, y, z, w = rot.tolist() 322 rot = np.asarray( 323 [ 324 1 - 2 * (y * y + z * z), 325 2 * (x * y - z * w), 326 2 * (x * z + y * w), 327 2 * (x * y + z * w), 328 1 - 2 * (x * x + z * z), 329 2 * (y * z - x * w), 330 2 * (x * z - y * w), 331 2 * (y * z + x * w), 332 1 - 2 * (x * x + y * y), 333 ], 334 dtype=np.float64, 335 ) 336 elif rot.size != 9: 337 from ..exceptions import AdapterError 338 339 raise AdapterError( 340 f"unexpected rotation shape: got {rot.size} entries; " 341 "expected 9 (3×3 matrix) or 4 (xyzw quaternion).", 342 ) 343 trans = trans.reshape(-1) 344 if trans.size != 3: 345 from ..exceptions import AdapterError 346 347 raise AdapterError( 348 f"unexpected translation shape: got {trans.size} entries; expected 3.", 349 ) 350 351 log_path = self._save_dir / "predictions.csv" 352 log_path.parent.mkdir(parents=True, exist_ok=True) 353 write_header = not log_path.exists() or log_path.stat().st_size == 0 354 with log_path.open("a", newline="") as f: 355 writer = csv.writer(f) 356 if write_header: 357 writer.writerow(self._LOG_COLUMNS) 358 writer.writerow( 359 [ 360 sample_id, 361 scene, 362 phase, 363 phase_b, 364 frame_a, 365 frame_b, 366 *(f"{v:.10g}" for v in rot.tolist()), 367 *(f"{v:.10g}" for v in trans.tolist()), 368 ] 369 )
Batched wrapper for paired-frame relative-pose models.
Adapter contract: adapter(list_of_dicts) -> list_of_pose_dicts,
where each input dict is {"rgb_a": HxWx3 uint8, "rgb_b": HxWx3 uint8}
and each output is {"rotation": 3x3, "translation": 3} (or a tuple).
226 def extract_inputs(self, sample: Sample) -> dict: 227 meta = getattr(sample, "metadata", None) or {} 228 rgb_a = np.asarray(sample.rgb, dtype=np.uint8) 229 rgb_b = meta.get("rgb_b") 230 if rgb_b is None: 231 from ..exceptions import AdapterError 232 233 raise AdapterError( 234 "Relative-pose adapter requires `rgb_b` in sample.metadata; " 235 "check the manifest writes `rgb_b` so the loader stashes it.", 236 ) 237 return {"rgb_a": rgb_a, "rgb_b": np.asarray(rgb_b, dtype=np.uint8)}
Subclass: return whatever the adapter expects per sample.
Examples:
- Depth:
np.asarray(sample.rgb, dtype=np.uint8) - Pose:
{"rgb_a": ..., "rgb_b": sample.metadata["rgb_b"]} - Stereo depth:
{"left": metadata["fisheye_left"], "right": metadata["fisheye_right"]}
239 def wrap_output(self, model_output: Any, sample: Sample) -> Any: 240 from ..api import RelativePosePrediction 241 242 if isinstance(model_output, dict): 243 rot = model_output["rotation"] 244 trans = model_output["translation"] 245 else: 246 rot, trans = model_output 247 return RelativePosePrediction( 248 rotation=np.asarray(rot, dtype=np.float64), 249 translation=np.asarray(trans, dtype=np.float64), 250 )
Subclass: wrap one model output as the task's Prediction dataclass.
275 def maybe_save(self, sample: Sample, model_output: Any) -> None: 276 """Append one row per pair to ``<save_dir>/predictions.csv``. 277 278 Rationale: paired-pose runs produce ~3 K rows per (model, split); 279 a single CSV is friendlier for Box and analytics than 3 K 280 per-pair ``.npz`` files. Columns: scene_id, phase, frame_a, 281 frame_b, the 9 rotation entries (row-major) and 3 translation 282 components. Header is written exactly once per file (on the 283 first append to an empty / non-existent log). Idempotent on 284 ``sample.id`` within a single process so 285 the runner's deployment-readiness double pass (warmup + measure) 286 doesn't duplicate rows. 287 """ 288 if self._save_dir is None: 289 return 290 import csv 291 292 meta = getattr(sample, "metadata", None) or {} 293 scene = meta.get("scene_id") or "unknown" 294 phase = meta.get("phase_idx") if meta.get("phase_idx") is not None else "0" 295 phase_b = meta.get("phase_idx_b") 296 if phase_b is None: 297 phase_b = phase 298 frame_a = meta.get("frame") or str(sample.id) 299 frame_b = meta.get("frame_b") or "?" 300 sample_id = str(sample.id) 301 302 seen = self.__dict__.setdefault("_pose_csv_seen", set()) 303 key = sample_id 304 if key in seen: 305 return 306 seen.add(key) 307 308 if isinstance(model_output, dict): 309 rot = np.asarray(model_output["rotation"], dtype=np.float64) 310 trans = np.asarray(model_output["translation"], dtype=np.float64) 311 else: 312 rot, trans = model_output 313 rot = np.asarray(rot, dtype=np.float64) 314 trans = np.asarray(trans, dtype=np.float64) 315 316 # Tolerate rotation given as a flat 9-vector or a quaternion. 317 rot = rot.reshape(-1) 318 if rot.size == 4: 319 # Quaternion (x, y, z, w) → 3×3 rotation. We don't depend on 320 # scipy here; build it inline from the unit-quaternion formula. 321 x, y, z, w = rot.tolist() 322 rot = np.asarray( 323 [ 324 1 - 2 * (y * y + z * z), 325 2 * (x * y - z * w), 326 2 * (x * z + y * w), 327 2 * (x * y + z * w), 328 1 - 2 * (x * x + z * z), 329 2 * (y * z - x * w), 330 2 * (x * z - y * w), 331 2 * (y * z + x * w), 332 1 - 2 * (x * x + y * y), 333 ], 334 dtype=np.float64, 335 ) 336 elif rot.size != 9: 337 from ..exceptions import AdapterError 338 339 raise AdapterError( 340 f"unexpected rotation shape: got {rot.size} entries; " 341 "expected 9 (3×3 matrix) or 4 (xyzw quaternion).", 342 ) 343 trans = trans.reshape(-1) 344 if trans.size != 3: 345 from ..exceptions import AdapterError 346 347 raise AdapterError( 348 f"unexpected translation shape: got {trans.size} entries; expected 3.", 349 ) 350 351 log_path = self._save_dir / "predictions.csv" 352 log_path.parent.mkdir(parents=True, exist_ok=True) 353 write_header = not log_path.exists() or log_path.stat().st_size == 0 354 with log_path.open("a", newline="") as f: 355 writer = csv.writer(f) 356 if write_header: 357 writer.writerow(self._LOG_COLUMNS) 358 writer.writerow( 359 [ 360 sample_id, 361 scene, 362 phase, 363 phase_b, 364 frame_a, 365 frame_b, 366 *(f"{v:.10g}" for v in rot.tolist()), 367 *(f"{v:.10g}" for v in trans.tolist()), 368 ] 369 )
Append one row per pair to <save_dir>/predictions.csv.
Rationale: paired-pose runs produce ~3 K rows per (model, split);
a single CSV is friendlier for Box and analytics than 3 K
per-pair .npz files. Columns: scene_id, phase, frame_a,
frame_b, the 9 rotation entries (row-major) and 3 translation
components. Header is written exactly once per file (on the
first append to an empty / non-existent log). Idempotent on
sample.id within a single process so
the runner's deployment-readiness double pass (warmup + measure)
doesn't duplicate rows.