rpx_benchmark.adapters.batched_depth
True-batched depth model wrapper.
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 image per call, so frameworks that
batch internally (HuggingFace pipelines, DataLoader-driven PyTorch
inference) get no benefit. BatchedDepthBenchmarkModel fixes that for
monocular metric depth: it hands the whole list of RGBs to the adapter
in one call, lets the adapter do a single batched forward, then unwraps
the depth predictions one-per-sample.
The model itself is untouched — only the dispatch shape changes — so metric values are bit-identical to the per-sample path. Only throughput improves (≈1.3× at batch 8 for ZoeDepth on an RTX 5070 Laptop; typically larger for smaller models).
Adapter contract
The wrapped adapter must accept either:
- a single
np.ndarrayof shape(H, W, 3)uint8 → return depth(H', W')float - a
list[np.ndarray]→ returnlist[np.ndarray](one depth array per RGB, same order)
If the adapter returns a depth shape different from its input RGB, we bilinearly resize back to the RGB's HW so the depth aligns with the GT mask (PIL-only, no OpenCV dep).
Save layout
When save_dir is provided, predictions are written as
<save_dir>/<scene>/<phase>/<frame>.npz (key "depth", compressed
float32 in the model's native output space) — the same scene/phase shape as
the on-disk dataset, so downstream analytics and per-scene plotting share one
navigation pattern. Writes are atomic and compression is outside inference
timing.
Example
::
from rpx_benchmark.adapters import BatchedDepthBenchmarkModel
from depth_models.zoedepth import ZoeDepth
adapter = ZoeDepth(device="cuda", batch_size=8)
model = BatchedDepthBenchmarkModel(
adapter, name="ZoeDepth_NK",
save_dir="rpx_results/ZoeDepth_NK/easy/predictions",
)
runner = BenchmarkRunner(model=model, dataset=dataset, ...)
1"""True-batched depth model wrapper. 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 image per call, so frameworks that 6batch internally (HuggingFace pipelines, DataLoader-driven PyTorch 7inference) get no benefit. ``BatchedDepthBenchmarkModel`` fixes that for 8monocular metric depth: it hands the whole list of RGBs to the adapter 9in one call, lets the adapter do a single batched forward, then unwraps 10the depth predictions one-per-sample. 11 12The model itself is untouched — only the dispatch shape changes — so 13metric values are bit-identical to the per-sample path. Only throughput 14improves (≈1.3× at batch 8 for ZoeDepth on an RTX 5070 Laptop; 15typically larger for smaller models). 16 17Adapter contract 18---------------- 19The wrapped adapter must accept either: 20 21* a single ``np.ndarray`` of shape ``(H, W, 3)`` uint8 → return depth ``(H', W')`` float 22* a ``list[np.ndarray]`` → return ``list[np.ndarray]`` (one depth array per RGB, 23 same order) 24 25If the adapter returns a depth shape different from its input RGB, we 26bilinearly resize back to the RGB's HW so the depth aligns with the GT 27mask (PIL-only, no OpenCV dep). 28 29Save layout 30----------- 31When ``save_dir`` is provided, predictions are written as 32``<save_dir>/<scene>/<phase>/<frame>.npz`` (key ``"depth"``, compressed 33float32 in the model's native output space) — the same scene/phase shape as 34the on-disk dataset, so downstream analytics and per-scene plotting share one 35navigation pattern. Writes are atomic and compression is outside inference 36timing. 37 38Example 39------- 40:: 41 42 from rpx_benchmark.adapters import BatchedDepthBenchmarkModel 43 from depth_models.zoedepth import ZoeDepth 44 45 adapter = ZoeDepth(device="cuda", batch_size=8) 46 model = BatchedDepthBenchmarkModel( 47 adapter, name="ZoeDepth_NK", 48 save_dir="rpx_results/ZoeDepth_NK/easy/predictions", 49 ) 50 runner = BenchmarkRunner(model=model, dataset=dataset, ...) 51""" 52 53from __future__ import annotations 54 55import os 56import tempfile 57import zipfile 58from concurrent.futures import ThreadPoolExecutor 59from pathlib import Path 60from typing import Any, List, Optional, Sequence 61 62import numpy as np 63from PIL import Image 64 65from ..api import DepthPrediction, Sample, TaskType 66 67__all__ = ["BatchedDepthBenchmarkModel"] 68 69 70def _resize_bilinear_2d(src: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray: 71 """Resize a 2D float array to ``(H, W)``. PIL-only — no OpenCV dependency.""" 72 img = Image.fromarray(src.astype(np.float32), mode="F") 73 img = img.resize((target_hw[1], target_hw[0]), Image.BILINEAR) 74 return np.asarray(img, dtype=np.float32) 75 76 77class BatchedDepthBenchmarkModel: 78 """Depth model wrapper that batches predictions across a sample list. 79 80 Parameters 81 ---------- 82 adapter 83 Callable: ``adapter(rgb_list_or_single) -> depth_list_or_single``. 84 Single-image: ``np.ndarray (H, W, 3) uint8`` → ``np.ndarray (H', W') float``. 85 Batched: ``list[np.ndarray]`` → ``list[np.ndarray]``. 86 name 87 Display name surfaced in result.json and Box paths. 88 save_dir 89 If provided, writes per-frame ``.npz`` predictions under 90 ``<save_dir>/<scene>/<phase>/<frame>.npz``. Created lazily on the 91 first save (no side effect at construction time). 92 native_alignment 93 Declared by the adapter (``"none"`` for metric models like ZoeDepth / 94 UniDepth V2 / Depth Pro; ``"ls_affine"`` for up-to-scale models 95 like Marigold / Lotus-2). The runner reads this to apply the 96 right alignment by default. Falls back to ``"none"`` if the 97 adapter doesn't declare it. The wrapper returns raw predictions; 98 the runner applies this mode once per complete scene/phase cell. 99 native_precision 100 Declared by the adapter (``"fp32"``, ``"fp16"``, ``"bf16"``). 101 Used by the runner to record the OperatingPoint precision 102 tag. Falls back to ``"fp32"`` if not declared. 103 allow_nonpositive_predictions 104 Permit finite, non-degenerate raw depth maps containing zero or 105 negative values. This is opt-in for protocols that preserve raw model 106 output and apply a documented evaluation-domain transform later. 107 """ 108 109 task = TaskType.MONOCULAR_DEPTH 110 111 def __init__( 112 self, 113 adapter: Any, 114 *, 115 name: str, 116 save_dir: Optional[str | Path] = None, 117 native_alignment: Optional[str] = None, 118 native_precision: Optional[str] = None, 119 resume_predictions: bool = False, 120 allow_nonpositive_predictions: bool = False, 121 ) -> None: 122 self._adapter = adapter 123 self.name = name 124 self._save_dir = Path(save_dir) if save_dir else None 125 if resume_predictions and self._save_dir is None: 126 from ..exceptions import ConfigError 127 128 raise ConfigError("resume_predictions requires save_dir") 129 self.resume_predictions = bool(resume_predictions) 130 self.allow_nonpositive_predictions = bool(allow_nonpositive_predictions) 131 self.resume_stats = { 132 "cache_hits": 0, 133 "inferred_new": 0, 134 "invalid_recomputed": 0, 135 } 136 self.last_cache_hits: list[bool] = [] 137 self._last_save_required: list[bool] = [] 138 # Read alignment off the adapter if it declared one, else fall 139 # back to the constructor arg, else "none" (metric assumption). 140 self.native_alignment: str = ( 141 native_alignment or getattr(adapter, "native_alignment", None) or "none" 142 ) 143 self.native_precision: str = ( 144 native_precision or getattr(adapter, "native_precision", None) or "fp32" 145 ) 146 self.depth_output_kind: str = "relative" if self.native_alignment != "none" else "metric" 147 # Profiler walker reaches the underlying nn.Module via this attr. 148 self.model = adapter 149 150 # No-op: the adapter loads its own weights at construction. 151 def setup(self) -> None: 152 return None 153 154 def predict(self, batch: Sequence[Sample]) -> List[DepthPrediction]: 155 cached: dict[int, np.ndarray] = {} 156 missing_indices: list[int] = [] 157 invalid_existing: set[int] = set() 158 for index, sample in enumerate(batch): 159 path = self._prediction_path(sample) 160 if self.resume_predictions and path is not None and path.exists(): 161 depth = self._load_valid_prediction( 162 path, 163 np.asarray(sample.rgb).shape[:2], 164 require_positive=not self.allow_nonpositive_predictions, 165 ) 166 if depth is not None: 167 cached[index] = depth 168 self.resume_stats["cache_hits"] += 1 169 continue 170 invalid_existing.add(index) 171 missing_indices.append(index) 172 173 inferred: list[np.ndarray] = [] 174 if missing_indices: 175 rgbs = [np.asarray(batch[i].rgb, dtype=np.uint8) for i in missing_indices] 176 raw_depths = self._adapter(rgbs) 177 if not isinstance(raw_depths, (list, tuple)): 178 raw_depths = [raw_depths] 179 inferred = [np.asarray(depth, dtype=np.float32) for depth in raw_depths] 180 if len(inferred) != len(missing_indices): 181 from ..exceptions import AdapterError 182 183 raise AdapterError( 184 f"adapter returned {len(inferred)} depths for {len(missing_indices)} inputs", 185 hint="Check the adapter's batched contract: it must return " 186 "one depth map per input RGB.", 187 ) 188 189 inferred_by_index = dict(zip(missing_indices, inferred, strict=True)) 190 preds: List[DepthPrediction] = [] 191 self.last_cache_hits = [] 192 self._last_save_required = [] 193 for index, sample in enumerate(batch): 194 is_cached = index in cached 195 d_raw = cached[index] if is_cached else inferred_by_index[index] 196 target_hw = np.asarray(sample.rgb).shape[:2] 197 if d_raw.shape != target_hw: 198 d_raw = _resize_bilinear_2d(d_raw, target_hw) 199 if not is_cached: 200 if index in invalid_existing: 201 self.resume_stats["invalid_recomputed"] += 1 202 else: 203 self.resume_stats["inferred_new"] += 1 204 205 # Return the raw prediction. Relative outputs are deliberately 206 # aligned later by BenchmarkRunner, after every frame in the 207 # same (scene, phase) cell is available for one pooled solve. 208 preds.append(DepthPrediction(depth_map=d_raw)) 209 self.last_cache_hits.append(is_cached) 210 self._last_save_required.append(not is_cached) 211 return preds 212 213 def persist_predictions( 214 self, 215 batch: Sequence[Sample], 216 predictions: Sequence[DepthPrediction], 217 ) -> None: 218 """Persist newly inferred predictions after the runner stops timing.""" 219 pending = [ 220 (sample, prediction.depth_map) 221 for sample, prediction, required in zip( 222 batch, 223 predictions, 224 self._last_save_required, 225 strict=True, 226 ) 227 if required 228 ] 229 if len(pending) <= 1: 230 for sample, depth in pending: 231 self._maybe_save(sample, depth) 232 return 233 234 # Compression is CPU-bound and each frame has a distinct atomic target. 235 # Parallelize it within the batch so a fast GPU is not followed by a 236 # serial chain of np.savez_compressed calls. 237 workers = min(len(pending), 8) 238 with ThreadPoolExecutor(max_workers=workers) as executor: 239 futures = [ 240 executor.submit(self._maybe_save, sample, depth) 241 for sample, depth in pending 242 ] 243 for future in futures: 244 future.result() 245 246 def _prediction_path(self, sample: Sample) -> Path | None: 247 if self._save_dir is None: 248 return None 249 # Prefer the manifest's metadata block (set by local_manifest.py 250 # and dataset_hub.split_manifests so the loader rides it through 251 # to Sample.metadata). Fall back to id-parsing. 252 meta = getattr(sample, "metadata", None) or {} 253 scene = meta.get("scene_id") 254 phase = meta.get("phase_idx") 255 frame = meta.get("frame") 256 if not (scene and frame is not None and phase is not None): 257 try: 258 scene, phase, frame = str(sample.id).split("__", 2) 259 except ValueError: 260 scene, phase, frame = "unknown", "0", str(sample.id) 261 return self._save_dir / str(scene) / str(phase) / f"{frame}.npz" 262 263 @staticmethod 264 def _load_valid_prediction( 265 path: Path, 266 target_hw: tuple[int, int], 267 *, 268 require_positive: bool = True, 269 ) -> np.ndarray | None: 270 try: 271 with np.load(path, allow_pickle=False) as payload: 272 if payload.files != ["depth"]: 273 return None 274 depth = np.asarray(payload["depth"]) 275 return BatchedDepthBenchmarkModel._validated_depth( 276 depth, 277 target_hw, 278 require_positive=require_positive, 279 ) 280 except (OSError, ValueError, KeyError, EOFError, zipfile.BadZipFile): 281 return None 282 283 @staticmethod 284 def _validated_depth( 285 depth: np.ndarray, 286 target_hw: tuple[int, int], 287 *, 288 require_positive: bool = True, 289 ) -> np.ndarray | None: 290 depth = np.asarray(depth) 291 if depth.ndim != 2 or depth.shape != target_hw: 292 return None 293 if depth.dtype != np.dtype(np.float32): 294 return None 295 if not np.isfinite(depth).all(): 296 return None 297 if require_positive and np.any(depth <= 0): 298 return None 299 if float(np.ptp(depth)) <= 1e-6: 300 return None 301 return depth 302 303 def _maybe_save(self, sample: Sample, depth: np.ndarray) -> None: 304 out = self._prediction_path(sample) 305 if out is None: 306 return 307 canonical = np.asarray(depth, dtype=np.float32) 308 target_hw = np.asarray(sample.rgb).shape[:2] 309 if ( 310 self._validated_depth( 311 canonical, 312 target_hw, 313 require_positive=not self.allow_nonpositive_predictions, 314 ) 315 is None 316 ): 317 from ..exceptions import AdapterError 318 319 raise AdapterError( 320 f"Refusing to save an invalid depth prediction for sample {sample.id!r}: " 321 f"expected a finite, non-degenerate float32 map with shape {target_hw}" 322 + ( 323 " and strictly positive values." 324 if not self.allow_nonpositive_predictions 325 else "." 326 ) 327 ) 328 out.parent.mkdir(parents=True, exist_ok=True) 329 temp_path: Path | None = None 330 try: 331 with tempfile.NamedTemporaryFile( 332 mode="w+b", 333 suffix=".npz.part", 334 dir=out.parent, 335 delete=False, 336 ) as handle: 337 temp_path = Path(handle.name) 338 np.savez_compressed(handle, depth=canonical) 339 handle.flush() 340 os.fsync(handle.fileno()) 341 os.replace(temp_path, out) 342 finally: 343 if temp_path is not None and temp_path.exists(): 344 temp_path.unlink()
78class BatchedDepthBenchmarkModel: 79 """Depth model wrapper that batches predictions across a sample list. 80 81 Parameters 82 ---------- 83 adapter 84 Callable: ``adapter(rgb_list_or_single) -> depth_list_or_single``. 85 Single-image: ``np.ndarray (H, W, 3) uint8`` → ``np.ndarray (H', W') float``. 86 Batched: ``list[np.ndarray]`` → ``list[np.ndarray]``. 87 name 88 Display name surfaced in result.json and Box paths. 89 save_dir 90 If provided, writes per-frame ``.npz`` predictions under 91 ``<save_dir>/<scene>/<phase>/<frame>.npz``. Created lazily on the 92 first save (no side effect at construction time). 93 native_alignment 94 Declared by the adapter (``"none"`` for metric models like ZoeDepth / 95 UniDepth V2 / Depth Pro; ``"ls_affine"`` for up-to-scale models 96 like Marigold / Lotus-2). The runner reads this to apply the 97 right alignment by default. Falls back to ``"none"`` if the 98 adapter doesn't declare it. The wrapper returns raw predictions; 99 the runner applies this mode once per complete scene/phase cell. 100 native_precision 101 Declared by the adapter (``"fp32"``, ``"fp16"``, ``"bf16"``). 102 Used by the runner to record the OperatingPoint precision 103 tag. Falls back to ``"fp32"`` if not declared. 104 allow_nonpositive_predictions 105 Permit finite, non-degenerate raw depth maps containing zero or 106 negative values. This is opt-in for protocols that preserve raw model 107 output and apply a documented evaluation-domain transform later. 108 """ 109 110 task = TaskType.MONOCULAR_DEPTH 111 112 def __init__( 113 self, 114 adapter: Any, 115 *, 116 name: str, 117 save_dir: Optional[str | Path] = None, 118 native_alignment: Optional[str] = None, 119 native_precision: Optional[str] = None, 120 resume_predictions: bool = False, 121 allow_nonpositive_predictions: bool = False, 122 ) -> None: 123 self._adapter = adapter 124 self.name = name 125 self._save_dir = Path(save_dir) if save_dir else None 126 if resume_predictions and self._save_dir is None: 127 from ..exceptions import ConfigError 128 129 raise ConfigError("resume_predictions requires save_dir") 130 self.resume_predictions = bool(resume_predictions) 131 self.allow_nonpositive_predictions = bool(allow_nonpositive_predictions) 132 self.resume_stats = { 133 "cache_hits": 0, 134 "inferred_new": 0, 135 "invalid_recomputed": 0, 136 } 137 self.last_cache_hits: list[bool] = [] 138 self._last_save_required: list[bool] = [] 139 # Read alignment off the adapter if it declared one, else fall 140 # back to the constructor arg, else "none" (metric assumption). 141 self.native_alignment: str = ( 142 native_alignment or getattr(adapter, "native_alignment", None) or "none" 143 ) 144 self.native_precision: str = ( 145 native_precision or getattr(adapter, "native_precision", None) or "fp32" 146 ) 147 self.depth_output_kind: str = "relative" if self.native_alignment != "none" else "metric" 148 # Profiler walker reaches the underlying nn.Module via this attr. 149 self.model = adapter 150 151 # No-op: the adapter loads its own weights at construction. 152 def setup(self) -> None: 153 return None 154 155 def predict(self, batch: Sequence[Sample]) -> List[DepthPrediction]: 156 cached: dict[int, np.ndarray] = {} 157 missing_indices: list[int] = [] 158 invalid_existing: set[int] = set() 159 for index, sample in enumerate(batch): 160 path = self._prediction_path(sample) 161 if self.resume_predictions and path is not None and path.exists(): 162 depth = self._load_valid_prediction( 163 path, 164 np.asarray(sample.rgb).shape[:2], 165 require_positive=not self.allow_nonpositive_predictions, 166 ) 167 if depth is not None: 168 cached[index] = depth 169 self.resume_stats["cache_hits"] += 1 170 continue 171 invalid_existing.add(index) 172 missing_indices.append(index) 173 174 inferred: list[np.ndarray] = [] 175 if missing_indices: 176 rgbs = [np.asarray(batch[i].rgb, dtype=np.uint8) for i in missing_indices] 177 raw_depths = self._adapter(rgbs) 178 if not isinstance(raw_depths, (list, tuple)): 179 raw_depths = [raw_depths] 180 inferred = [np.asarray(depth, dtype=np.float32) for depth in raw_depths] 181 if len(inferred) != len(missing_indices): 182 from ..exceptions import AdapterError 183 184 raise AdapterError( 185 f"adapter returned {len(inferred)} depths for {len(missing_indices)} inputs", 186 hint="Check the adapter's batched contract: it must return " 187 "one depth map per input RGB.", 188 ) 189 190 inferred_by_index = dict(zip(missing_indices, inferred, strict=True)) 191 preds: List[DepthPrediction] = [] 192 self.last_cache_hits = [] 193 self._last_save_required = [] 194 for index, sample in enumerate(batch): 195 is_cached = index in cached 196 d_raw = cached[index] if is_cached else inferred_by_index[index] 197 target_hw = np.asarray(sample.rgb).shape[:2] 198 if d_raw.shape != target_hw: 199 d_raw = _resize_bilinear_2d(d_raw, target_hw) 200 if not is_cached: 201 if index in invalid_existing: 202 self.resume_stats["invalid_recomputed"] += 1 203 else: 204 self.resume_stats["inferred_new"] += 1 205 206 # Return the raw prediction. Relative outputs are deliberately 207 # aligned later by BenchmarkRunner, after every frame in the 208 # same (scene, phase) cell is available for one pooled solve. 209 preds.append(DepthPrediction(depth_map=d_raw)) 210 self.last_cache_hits.append(is_cached) 211 self._last_save_required.append(not is_cached) 212 return preds 213 214 def persist_predictions( 215 self, 216 batch: Sequence[Sample], 217 predictions: Sequence[DepthPrediction], 218 ) -> None: 219 """Persist newly inferred predictions after the runner stops timing.""" 220 pending = [ 221 (sample, prediction.depth_map) 222 for sample, prediction, required in zip( 223 batch, 224 predictions, 225 self._last_save_required, 226 strict=True, 227 ) 228 if required 229 ] 230 if len(pending) <= 1: 231 for sample, depth in pending: 232 self._maybe_save(sample, depth) 233 return 234 235 # Compression is CPU-bound and each frame has a distinct atomic target. 236 # Parallelize it within the batch so a fast GPU is not followed by a 237 # serial chain of np.savez_compressed calls. 238 workers = min(len(pending), 8) 239 with ThreadPoolExecutor(max_workers=workers) as executor: 240 futures = [ 241 executor.submit(self._maybe_save, sample, depth) 242 for sample, depth in pending 243 ] 244 for future in futures: 245 future.result() 246 247 def _prediction_path(self, sample: Sample) -> Path | None: 248 if self._save_dir is None: 249 return None 250 # Prefer the manifest's metadata block (set by local_manifest.py 251 # and dataset_hub.split_manifests so the loader rides it through 252 # to Sample.metadata). Fall back to id-parsing. 253 meta = getattr(sample, "metadata", None) or {} 254 scene = meta.get("scene_id") 255 phase = meta.get("phase_idx") 256 frame = meta.get("frame") 257 if not (scene and frame is not None and phase is not None): 258 try: 259 scene, phase, frame = str(sample.id).split("__", 2) 260 except ValueError: 261 scene, phase, frame = "unknown", "0", str(sample.id) 262 return self._save_dir / str(scene) / str(phase) / f"{frame}.npz" 263 264 @staticmethod 265 def _load_valid_prediction( 266 path: Path, 267 target_hw: tuple[int, int], 268 *, 269 require_positive: bool = True, 270 ) -> np.ndarray | None: 271 try: 272 with np.load(path, allow_pickle=False) as payload: 273 if payload.files != ["depth"]: 274 return None 275 depth = np.asarray(payload["depth"]) 276 return BatchedDepthBenchmarkModel._validated_depth( 277 depth, 278 target_hw, 279 require_positive=require_positive, 280 ) 281 except (OSError, ValueError, KeyError, EOFError, zipfile.BadZipFile): 282 return None 283 284 @staticmethod 285 def _validated_depth( 286 depth: np.ndarray, 287 target_hw: tuple[int, int], 288 *, 289 require_positive: bool = True, 290 ) -> np.ndarray | None: 291 depth = np.asarray(depth) 292 if depth.ndim != 2 or depth.shape != target_hw: 293 return None 294 if depth.dtype != np.dtype(np.float32): 295 return None 296 if not np.isfinite(depth).all(): 297 return None 298 if require_positive and np.any(depth <= 0): 299 return None 300 if float(np.ptp(depth)) <= 1e-6: 301 return None 302 return depth 303 304 def _maybe_save(self, sample: Sample, depth: np.ndarray) -> None: 305 out = self._prediction_path(sample) 306 if out is None: 307 return 308 canonical = np.asarray(depth, dtype=np.float32) 309 target_hw = np.asarray(sample.rgb).shape[:2] 310 if ( 311 self._validated_depth( 312 canonical, 313 target_hw, 314 require_positive=not self.allow_nonpositive_predictions, 315 ) 316 is None 317 ): 318 from ..exceptions import AdapterError 319 320 raise AdapterError( 321 f"Refusing to save an invalid depth prediction for sample {sample.id!r}: " 322 f"expected a finite, non-degenerate float32 map with shape {target_hw}" 323 + ( 324 " and strictly positive values." 325 if not self.allow_nonpositive_predictions 326 else "." 327 ) 328 ) 329 out.parent.mkdir(parents=True, exist_ok=True) 330 temp_path: Path | None = None 331 try: 332 with tempfile.NamedTemporaryFile( 333 mode="w+b", 334 suffix=".npz.part", 335 dir=out.parent, 336 delete=False, 337 ) as handle: 338 temp_path = Path(handle.name) 339 np.savez_compressed(handle, depth=canonical) 340 handle.flush() 341 os.fsync(handle.fileno()) 342 os.replace(temp_path, out) 343 finally: 344 if temp_path is not None and temp_path.exists(): 345 temp_path.unlink()
Depth model wrapper that batches predictions across a sample list.
Parameters
adapter
Callable: adapter(rgb_list_or_single) -> depth_list_or_single.
Single-image: np.ndarray (H, W, 3) uint8 → np.ndarray (H', W') float.
Batched: list[np.ndarray] → list[np.ndarray].
name
Display name surfaced in result.json and Box paths.
save_dir
If provided, writes per-frame .npz predictions under
<save_dir>/<scene>/<phase>/<frame>.npz. Created lazily on the
first save (no side effect at construction time).
native_alignment
Declared by the adapter ("none" for metric models like ZoeDepth /
UniDepth V2 / Depth Pro; "ls_affine" for up-to-scale models
like Marigold / Lotus-2). The runner reads this to apply the
right alignment by default. Falls back to "none" if the
adapter doesn't declare it. The wrapper returns raw predictions;
the runner applies this mode once per complete scene/phase cell.
native_precision
Declared by the adapter ("fp32", "fp16", "bf16").
Used by the runner to record the OperatingPoint precision
tag. Falls back to "fp32" if not declared.
allow_nonpositive_predictions
Permit finite, non-degenerate raw depth maps containing zero or
negative values. This is opt-in for protocols that preserve raw model
output and apply a documented evaluation-domain transform later.
112 def __init__( 113 self, 114 adapter: Any, 115 *, 116 name: str, 117 save_dir: Optional[str | Path] = None, 118 native_alignment: Optional[str] = None, 119 native_precision: Optional[str] = None, 120 resume_predictions: bool = False, 121 allow_nonpositive_predictions: bool = False, 122 ) -> None: 123 self._adapter = adapter 124 self.name = name 125 self._save_dir = Path(save_dir) if save_dir else None 126 if resume_predictions and self._save_dir is None: 127 from ..exceptions import ConfigError 128 129 raise ConfigError("resume_predictions requires save_dir") 130 self.resume_predictions = bool(resume_predictions) 131 self.allow_nonpositive_predictions = bool(allow_nonpositive_predictions) 132 self.resume_stats = { 133 "cache_hits": 0, 134 "inferred_new": 0, 135 "invalid_recomputed": 0, 136 } 137 self.last_cache_hits: list[bool] = [] 138 self._last_save_required: list[bool] = [] 139 # Read alignment off the adapter if it declared one, else fall 140 # back to the constructor arg, else "none" (metric assumption). 141 self.native_alignment: str = ( 142 native_alignment or getattr(adapter, "native_alignment", None) or "none" 143 ) 144 self.native_precision: str = ( 145 native_precision or getattr(adapter, "native_precision", None) or "fp32" 146 ) 147 self.depth_output_kind: str = "relative" if self.native_alignment != "none" else "metric" 148 # Profiler walker reaches the underlying nn.Module via this attr. 149 self.model = adapter
155 def predict(self, batch: Sequence[Sample]) -> List[DepthPrediction]: 156 cached: dict[int, np.ndarray] = {} 157 missing_indices: list[int] = [] 158 invalid_existing: set[int] = set() 159 for index, sample in enumerate(batch): 160 path = self._prediction_path(sample) 161 if self.resume_predictions and path is not None and path.exists(): 162 depth = self._load_valid_prediction( 163 path, 164 np.asarray(sample.rgb).shape[:2], 165 require_positive=not self.allow_nonpositive_predictions, 166 ) 167 if depth is not None: 168 cached[index] = depth 169 self.resume_stats["cache_hits"] += 1 170 continue 171 invalid_existing.add(index) 172 missing_indices.append(index) 173 174 inferred: list[np.ndarray] = [] 175 if missing_indices: 176 rgbs = [np.asarray(batch[i].rgb, dtype=np.uint8) for i in missing_indices] 177 raw_depths = self._adapter(rgbs) 178 if not isinstance(raw_depths, (list, tuple)): 179 raw_depths = [raw_depths] 180 inferred = [np.asarray(depth, dtype=np.float32) for depth in raw_depths] 181 if len(inferred) != len(missing_indices): 182 from ..exceptions import AdapterError 183 184 raise AdapterError( 185 f"adapter returned {len(inferred)} depths for {len(missing_indices)} inputs", 186 hint="Check the adapter's batched contract: it must return " 187 "one depth map per input RGB.", 188 ) 189 190 inferred_by_index = dict(zip(missing_indices, inferred, strict=True)) 191 preds: List[DepthPrediction] = [] 192 self.last_cache_hits = [] 193 self._last_save_required = [] 194 for index, sample in enumerate(batch): 195 is_cached = index in cached 196 d_raw = cached[index] if is_cached else inferred_by_index[index] 197 target_hw = np.asarray(sample.rgb).shape[:2] 198 if d_raw.shape != target_hw: 199 d_raw = _resize_bilinear_2d(d_raw, target_hw) 200 if not is_cached: 201 if index in invalid_existing: 202 self.resume_stats["invalid_recomputed"] += 1 203 else: 204 self.resume_stats["inferred_new"] += 1 205 206 # Return the raw prediction. Relative outputs are deliberately 207 # aligned later by BenchmarkRunner, after every frame in the 208 # same (scene, phase) cell is available for one pooled solve. 209 preds.append(DepthPrediction(depth_map=d_raw)) 210 self.last_cache_hits.append(is_cached) 211 self._last_save_required.append(not is_cached) 212 return preds
214 def persist_predictions( 215 self, 216 batch: Sequence[Sample], 217 predictions: Sequence[DepthPrediction], 218 ) -> None: 219 """Persist newly inferred predictions after the runner stops timing.""" 220 pending = [ 221 (sample, prediction.depth_map) 222 for sample, prediction, required in zip( 223 batch, 224 predictions, 225 self._last_save_required, 226 strict=True, 227 ) 228 if required 229 ] 230 if len(pending) <= 1: 231 for sample, depth in pending: 232 self._maybe_save(sample, depth) 233 return 234 235 # Compression is CPU-bound and each frame has a distinct atomic target. 236 # Parallelize it within the batch so a fast GPU is not followed by a 237 # serial chain of np.savez_compressed calls. 238 workers = min(len(pending), 8) 239 with ThreadPoolExecutor(max_workers=workers) as executor: 240 futures = [ 241 executor.submit(self._maybe_save, sample, depth) 242 for sample, depth in pending 243 ] 244 for future in futures: 245 future.result()
Persist newly inferred predictions after the runner stops timing.