rpx_benchmark.metrics.depth_temporal
Clip-level temporal depth metrics for Video Depth.
The per-frame depth calculators in rpx_benchmark.metrics.depth
operate on a single (prediction, ground_truth) pair, which is the
right contract for Image Depth (frame-level depth). Video Depth additionally scores a
model on how temporally consistent its depth sequence is. Those
metrics need a whole clip (T frames) plus, for some of them, the
T265 camera poses — they do not fit the per-sample
~rpx_benchmark.metrics.registry.MetricCalculator interface, so
they live here as standalone sequence functions.
Locked MANOVA temporal tuple for Video Depth (benchmark/README.md):
tae— Temporal Alignment Error (pose-based geometric consistency). In the K = 7 MANOVA vector.opw— Optical-Flow Warping error (appearance-based consistency). In the K = 7 MANOVA vector. Needs an optical-flow backend (RAFT); the math here is backend-agnostic, the backend is injected.
Emitted for diagnostics but excluded from the MANOVA K:
tgm— Temporal Gradient Matching vs GT depth.tcc— Temporal Consistency (SSIM on depth-change maps).
Directionality (see rpx_benchmark.metrics.specs): tae, opw,
tgm are lower-is-better; tcc is higher-is-better.
All metrics are computed only over valid pixels. For the GT-referenced
metrics (tgm, tcc) validity is the standard D435 gate
(~rpx_benchmark.metrics.depth_alignment.default_valid_mask()).
For the GT-free metrics (tae, opw) validity is "the prediction
is positive and finite, and the warp/reprojection landed on a valid
pixel" — these measure the prediction's self-consistency under known
camera motion (tae) or scene appearance flow (opw).
1"""Clip-level temporal depth metrics for Video Depth. 2 3The per-frame depth calculators in :mod:`rpx_benchmark.metrics.depth` 4operate on a single ``(prediction, ground_truth)`` pair, which is the 5right contract for Image Depth (frame-level depth). Video Depth additionally scores a 6model on how *temporally consistent* its depth sequence is. Those 7metrics need a whole clip (``T`` frames) plus, for some of them, the 8T265 camera poses — they do not fit the per-sample 9:class:`~rpx_benchmark.metrics.registry.MetricCalculator` interface, so 10they live here as standalone sequence functions. 11 12Locked MANOVA temporal tuple for Video Depth (``benchmark/README.md``): 13 14* ``tae`` — Temporal Alignment Error (pose-based geometric consistency). 15 In the K = 7 MANOVA vector. 16* ``opw`` — Optical-Flow Warping error (appearance-based consistency). 17 In the K = 7 MANOVA vector. Needs an optical-flow backend (RAFT); 18 the math here is backend-agnostic, the backend is injected. 19 20Emitted for diagnostics but **excluded** from the MANOVA K: 21 22* ``tgm`` — Temporal Gradient Matching vs GT depth. 23* ``tcc`` — Temporal Consistency (SSIM on depth-change maps). 24 25Directionality (see :mod:`rpx_benchmark.metrics.specs`): ``tae``, ``opw``, 26``tgm`` are lower-is-better; ``tcc`` is higher-is-better. 27 28All metrics are computed only over valid pixels. For the GT-referenced 29metrics (``tgm``, ``tcc``) validity is the standard D435 gate 30(:func:`~rpx_benchmark.metrics.depth_alignment.default_valid_mask`). 31For the GT-free metrics (``tae``, ``opw``) validity is "the prediction 32is positive and finite, and the warp/reprojection landed on a valid 33pixel" — these measure the prediction's *self*-consistency under known 34camera motion (``tae``) or scene appearance flow (``opw``). 35""" 36 37from __future__ import annotations 38 39from typing import Callable, Dict, Optional 40 41import numpy as np 42 43from ..deployment import _D435_CX, _D435_CY, _D435_FX, _D435_FY, se3_reproject_depth 44from ..exceptions import MetricError 45from ..logging_utils import get_logger 46from .depth_alignment import default_valid_mask 47 48log = get_logger(__name__) 49 50__all__ = [ 51 "FlowFn", 52 "temporal_alignment_error", 53 "optical_flow_warping_error", 54 "temporal_gradient_matching", 55 "temporal_gradient_squared_error", 56 "temporal_motion_consistency", 57 "temporal_consistency_coefficient", 58 "compute_temporal_depth_metrics", 59 "range_stratified_tae", 60 "range_stratified_tgm_tgse", 61 "range_stratified_per_frame_metrics", 62 "TEMPORAL_GRADIENT_STATIC_THRESH_M", 63 "DEPTH_RANGE_BINS", 64] 65 66#: A flow backend maps two RGB frames (H, W, 3) to a dense flow field 67#: (H, W, 2) in pixels, expressed as the *backward* flow from the second 68#: frame to the first: a pixel ``(y, x)`` in ``rgb_to`` corresponds to 69#: ``(y + flow[y, x, 1], x + flow[y, x, 0])`` in ``rgb_from``. This is 70#: the convention :func:`optical_flow_warping_error` warps with. RAFT's 71#: forward pass produces exactly this when called as ``raft(rgb_to, 72#: rgb_from)``. 73FlowFn = Callable[[np.ndarray, np.ndarray], np.ndarray] 74 75#: TGM only compares depth-change in *static* regions (|ΔGT| below this), 76#: per the appendix definition — dynamic pixels carry real scene motion, 77#: not model jitter. 0.05 m matches Video Depth Anything (Chen et al. 2025). 78TEMPORAL_GRADIENT_STATIC_THRESH_M: float = 0.05 79 80#: A clip shorter than this is not scored — temporal metrics are not 81#: meaningful on a handful of frames (decisions doc §3.8). 82MIN_CLIP_FRAMES: int = 30 83 84 85# --------------------------------------------------------------------------- # 86# Small numeric helpers 87# --------------------------------------------------------------------------- # 88 89 90def _absrel(pred: np.ndarray, ref: np.ndarray, valid: np.ndarray) -> Optional[float]: 91 """Mean ``|pred − ref| / ref`` over ``valid`` pixels, or ``None`` if empty.""" 92 if not valid.any(): 93 return None 94 p = pred[valid].astype(np.float64) 95 r = ref[valid].astype(np.float64) 96 return float(np.mean(np.abs(p - r) / r)) 97 98 99def _bilinear_sample(img: np.ndarray, ys: np.ndarray, xs: np.ndarray) -> np.ndarray: 100 """Bilinearly sample ``img`` (H, W) at float coords ``ys``/``xs``. 101 102 Coordinates outside the image are clamped to the border. Returns an 103 array shaped like ``ys``. 104 """ 105 h, w = img.shape 106 x0 = np.floor(xs).astype(np.int64) 107 y0 = np.floor(ys).astype(np.int64) 108 x1 = x0 + 1 109 y1 = y0 + 1 110 111 wx = xs - x0 112 wy = ys - y0 113 114 x0c = np.clip(x0, 0, w - 1) 115 x1c = np.clip(x1, 0, w - 1) 116 y0c = np.clip(y0, 0, h - 1) 117 y1c = np.clip(y1, 0, h - 1) 118 119 img64 = img.astype(np.float64) 120 va = img64[y0c, x0c] 121 vb = img64[y0c, x1c] 122 vc = img64[y1c, x0c] 123 vd = img64[y1c, x1c] 124 125 top = va * (1 - wx) + vb * wx 126 bot = vc * (1 - wx) + vd * wx 127 return top * (1 - wy) + bot * wy 128 129 130def _ssim(a: np.ndarray, b: np.ndarray) -> float: 131 """SSIM between two equal-shaped arrays. 132 133 Uses scikit-image when available (the community-standard impl) and 134 falls back to a global single-window SSIM otherwise, so the metric is 135 always computable without a hard skimage dependency. 136 """ 137 try: 138 from skimage.metrics import structural_similarity # type: ignore 139 140 rng = float(max(a.max() - a.min(), b.max() - b.min(), 1e-6)) 141 return float(structural_similarity(a, b, data_range=rng)) 142 except Exception: # pragma: no cover - exercised only without skimage 143 a64 = a.astype(np.float64).ravel() 144 b64 = b.astype(np.float64).ravel() 145 mu_a, mu_b = a64.mean(), b64.mean() 146 va, vb = a64.var(), b64.var() 147 cov = float(np.mean((a64 - mu_a) * (b64 - mu_b))) 148 c1 = (0.01 * 7.0) ** 2 149 c2 = (0.03 * 7.0) ** 2 150 num = (2 * mu_a * mu_b + c1) * (2 * cov + c2) 151 den = (mu_a**2 + mu_b**2 + c1) * (va + vb + c2) 152 return float(num / den) if den else 0.0 153 154 155def _as_seq(name: str, arr: np.ndarray, ndim: int) -> np.ndarray: 156 a = np.asarray(arr) 157 if a.ndim != ndim: 158 raise MetricError( 159 f"{name} must be a {ndim}-D sequence, got shape {a.shape}.", 160 hint="Expected (T, H, W) for depth/mask sequences, (T, H, W, 3) for RGB.", 161 ) 162 if a.shape[0] < 2: 163 raise MetricError(f"{name} needs at least 2 frames, got T={a.shape[0]}.") 164 return a 165 166 167# --------------------------------------------------------------------------- # 168# TAE — Temporal Alignment Error (pose-based, GT-free). In MANOVA K. 169# --------------------------------------------------------------------------- # 170 171 172def temporal_alignment_error( 173 pred_seq: np.ndarray, 174 poses: np.ndarray, 175 *, 176 fx: float = _D435_FX, 177 fy: float = _D435_FY, 178 cx: float = _D435_CX, 179 cy: float = _D435_CY, 180) -> float: 181 """Bidirectional SE(3)-reprojection AbsRel of a predicted depth clip. 182 183 For every consecutive frame pair ``(t, t+1)`` the prediction at one 184 frame is back-projected to 3D, transported to the other camera via 185 the known world-from-camera poses, and re-projected. The depth it 186 *predicts there* is compared (AbsRel) against the model's *own* 187 prediction at that frame. A geometrically consistent model has 188 ``tae → 0``; a model whose per-frame depth disagrees in 3D under 189 known camera motion scores high. No ground truth is used (this is a 190 self-consistency measure), matching ChronoDepth / Video Depth 191 Anything. 192 193 Parameters 194 ---------- 195 pred_seq : np.ndarray 196 ``(T, H, W)`` predicted depth in metres. 197 poses : np.ndarray 198 ``(T, 4, 4)`` world-from-camera SE(3) per frame. 199 fx, fy, cx, cy : float 200 Pinhole intrinsics (default D435). 201 202 Returns 203 ------- 204 float 205 Mean bidirectional AbsRel over all valid consecutive pairs. 206 ``nan`` if no pair yields a single valid reprojected pixel. 207 """ 208 pred_seq = _as_seq("pred_seq", pred_seq, 3) 209 poses = np.asarray(poses, dtype=np.float64) 210 if poses.shape[0] != pred_seq.shape[0] or poses.shape[1:] != (4, 4): 211 raise MetricError( 212 f"poses must be (T, 4, 4) with T={pred_seq.shape[0]}, got {poses.shape}.", 213 ) 214 215 errors = [] 216 for t in range(pred_seq.shape[0] - 1): 217 d0, d1 = pred_seq[t], pred_seq[t + 1] 218 p0, p1 = poses[t], poses[t + 1] 219 if not (np.all(np.isfinite(p0)) and np.all(np.isfinite(p1))): 220 continue 221 222 # Forward: frame t -> frame t+1. 223 reproj01, ok01 = se3_reproject_depth(d0, p0, p1, fx, fy, cx, cy) 224 v01 = ok01 & np.isfinite(reproj01) & (reproj01 > 0) & (d1 > 0) & np.isfinite(d1) 225 e01 = _absrel(reproj01, d1, v01) 226 if e01 is not None: 227 errors.append(e01) 228 229 # Backward: frame t+1 -> frame t. 230 reproj10, ok10 = se3_reproject_depth(d1, p1, p0, fx, fy, cx, cy) 231 v10 = ok10 & np.isfinite(reproj10) & (reproj10 > 0) & (d0 > 0) & np.isfinite(d0) 232 e10 = _absrel(reproj10, d0, v10) 233 if e10 is not None: 234 errors.append(e10) 235 236 return float(np.mean(errors)) if errors else float("nan") 237 238 239# --------------------------------------------------------------------------- # 240# OPW — Optical-Flow Warping error (appearance-based, GT-free). In MANOVA K. 241# --------------------------------------------------------------------------- # 242 243 244def optical_flow_warping_error( 245 pred_seq: np.ndarray, 246 rgb_seq: np.ndarray, 247 flow_fn: Optional[FlowFn], 248) -> float: 249 """AbsRel between flow-warped consecutive predicted depth maps. 250 251 For each pair ``(t, t+1)`` the backward flow ``rgb[t+1] → rgb[t]`` is 252 used to sample the depth prediction at ``t`` into the pixel grid of 253 ``t+1``; the warped depth is compared (AbsRel) against the prediction 254 at ``t+1``. This measures appearance-level temporal smoothness and 255 needs no GT or pose — only an optical-flow backend. 256 257 Parameters 258 ---------- 259 pred_seq : np.ndarray 260 ``(T, H, W)`` predicted depth in metres. 261 rgb_seq : np.ndarray 262 ``(T, H, W, 3)`` RGB uint8 frames (the flow source). 263 flow_fn : FlowFn | None 264 Backend that returns the **backward** flow (see :data:`FlowFn`). 265 ``None`` raises :class:`MetricError` — OPW cannot be computed 266 without a flow model. RPX pins RAFT (``raft-sintel.pth``); wire 267 it as ``flow_fn=raft_backward_flow``. 268 269 Returns 270 ------- 271 float 272 Mean AbsRel over all valid pairs; ``nan`` if none are valid. 273 """ 274 pred_seq = _as_seq("pred_seq", pred_seq, 3) 275 rgb_seq = _as_seq("rgb_seq", rgb_seq, 4) 276 if rgb_seq.shape[0] != pred_seq.shape[0]: 277 raise MetricError( 278 f"rgb_seq T={rgb_seq.shape[0]} must match pred_seq T={pred_seq.shape[0]}.", 279 ) 280 if flow_fn is None: 281 raise MetricError( 282 "optical_flow_warping_error requires a flow backend; flow_fn is None.", 283 hint=( 284 "OPW needs dense optical flow. RPX pins RAFT (raft-sintel.pth, " 285 "Teed & Deng 2020). Pass flow_fn=<raft backward-flow callable>. " 286 "See benchmark/README.md §2 (optical-flow " 287 "recommendation)." 288 ), 289 ) 290 291 h, w = pred_seq.shape[1:] 292 grid_y, grid_x = np.mgrid[0:h, 0:w].astype(np.float64) 293 294 errors = [] 295 for t in range(pred_seq.shape[0] - 1): 296 flow = np.asarray(flow_fn(rgb_seq[t + 1], rgb_seq[t]), dtype=np.float64) 297 if flow.shape != (h, w, 2): 298 raise MetricError( 299 f"flow_fn returned shape {flow.shape}, expected ({h}, {w}, 2).", 300 ) 301 src_x = grid_x + flow[..., 0] 302 src_y = grid_y + flow[..., 1] 303 warped = _bilinear_sample(pred_seq[t], src_y, src_x) 304 305 d1 = pred_seq[t + 1] 306 inside = (src_x >= 0) & (src_x <= w - 1) & (src_y >= 0) & (src_y <= h - 1) 307 valid = inside & (warped > 0) & np.isfinite(warped) & (d1 > 0) & np.isfinite(d1) 308 e = _absrel(warped, d1, valid) 309 if e is not None: 310 errors.append(e) 311 312 return float(np.mean(errors)) if errors else float("nan") 313 314 315# --------------------------------------------------------------------------- # 316# TGM — Temporal Gradient Matching (GT-referenced). Diagnostic, not in K. 317# --------------------------------------------------------------------------- # 318 319 320def temporal_gradient_matching( 321 pred_seq: np.ndarray, 322 gt_seq: np.ndarray, 323 *, 324 static_thresh_m: float = TEMPORAL_GRADIENT_STATIC_THRESH_M, 325) -> float: 326 """Mean L1 between predicted and GT per-pixel depth-change in static regions. 327 328 .. math:: 329 330 \\text{TGM} = \\frac{1}{T-1}\\sum_t 331 \\big\\| \\,|\\hat d_{t+1}-\\hat d_t| - |d_{t+1}-d_t|\\, \\big\\|_1 332 333 restricted to pixels where ``|d_{t+1} − d_t| < static_thresh_m`` (the 334 scene is locally static, so any predicted change is model jitter). 335 Flow-free, GT-referenced. Lower is better. 336 337 Returns ``nan`` if no static valid pixels exist across the clip. 338 """ 339 pred_seq = _as_seq("pred_seq", pred_seq, 3) 340 gt_seq = _as_seq("gt_seq", gt_seq, 3) 341 if pred_seq.shape != gt_seq.shape: 342 raise MetricError( 343 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 344 ) 345 346 errors = [] 347 for t in range(pred_seq.shape[0] - 1): 348 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 349 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 350 valid = v0 & v1 351 if not valid.any(): 352 continue 353 d_gt = np.abs(gt_seq[t + 1] - gt_seq[t]) 354 d_pred = np.abs(pred_seq[t + 1] - pred_seq[t]) 355 static = valid & (d_gt < static_thresh_m) 356 if not static.any(): 357 continue 358 errors.append(float(np.mean(np.abs(d_pred[static] - d_gt[static])))) 359 360 return float(np.mean(errors)) if errors else float("nan") 361 362 363# --------------------------------------------------------------------------- # 364# TCC — Temporal Consistency Coefficient (GT-referenced). Diagnostic, not in K. 365# --------------------------------------------------------------------------- # 366 367 368def temporal_consistency_coefficient( 369 pred_seq: np.ndarray, 370 gt_seq: np.ndarray, 371) -> float: 372 """Mean SSIM between predicted and GT temporal depth-change maps. 373 374 Captures the *structural pattern* of depth change over time rather 375 than its per-pixel magnitude (which TGM measures). Higher is better, 376 in ``[-1, 1]``. Returns ``nan`` if no pair has valid pixels. 377 """ 378 pred_seq = _as_seq("pred_seq", pred_seq, 3) 379 gt_seq = _as_seq("gt_seq", gt_seq, 3) 380 if pred_seq.shape != gt_seq.shape: 381 raise MetricError( 382 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 383 ) 384 385 scores = [] 386 for t in range(pred_seq.shape[0] - 1): 387 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 388 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 389 valid = v0 & v1 390 if not valid.any(): 391 continue 392 diff_pred = (pred_seq[t + 1] - pred_seq[t]) * valid 393 diff_gt = (gt_seq[t + 1] - gt_seq[t]) * valid 394 scores.append(_ssim(diff_pred.astype(np.float64), diff_gt.astype(np.float64))) 395 396 return float(np.mean(scores)) if scores else float("nan") 397 398 399# --------------------------------------------------------------------------- # 400# TGSE — Temporal Gradient Squared Error (GT-referenced). Diagnostic. 401# Origin: VDPP (Yoon et al., arXiv 2026, arXiv:2604.06665). 402# --------------------------------------------------------------------------- # 403 404 405def temporal_gradient_squared_error( 406 pred_seq: np.ndarray, 407 gt_seq: np.ndarray, 408) -> float: 409 """Mean squared error of temporal depth gradients (pred vs GT). 410 411 .. math:: 412 413 \\text{TGSE} = \\frac{1}{T-1}\\sum_t \\frac{1}{|V_t|} 414 \\sum_{(i,j) \\in V_t} 415 \\left( \\hat d^{(t+1)}_{i,j} - \\hat d^{(t)}_{i,j} 416 - (d^{(t+1)}_{i,j} - d^{(t)}_{i,j}) \\right)^2 417 418 Unlike TGM: (a) preserves sign of depth change (no inner absolute 419 value), (b) uses L2 so severe flicker dominates the average. 420 Lower is better. Returns ``nan`` if no pair has valid pixels. 421 422 Reference: Yoon et al. "VDPP: Video Depth Post-Processing for Speed 423 and Scalability" (arXiv 2026, 2604.06665). 424 """ 425 pred_seq = _as_seq("pred_seq", pred_seq, 3) 426 gt_seq = _as_seq("gt_seq", gt_seq, 3) 427 if pred_seq.shape != gt_seq.shape: 428 raise MetricError( 429 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 430 ) 431 432 errors = [] 433 for t in range(pred_seq.shape[0] - 1): 434 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 435 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 436 valid = v0 & v1 437 if not valid.any(): 438 continue 439 delta_pred = pred_seq[t + 1][valid] - pred_seq[t][valid] 440 delta_gt = gt_seq[t + 1][valid] - gt_seq[t][valid] 441 errors.append(float(np.mean((delta_pred.astype(np.float64) 442 - delta_gt.astype(np.float64)) ** 2))) 443 444 return float(np.mean(errors)) if errors else float("nan") 445 446 447# --------------------------------------------------------------------------- # 448# TMC — Temporal Motion Consistency (GT-referenced). Diagnostic. 449# Origin: Zhang et al. ICCV 2019 (arXiv:1908.03706). 450# --------------------------------------------------------------------------- # 451 452 453def _dense_flow_on_depth(d0: np.ndarray, d1: np.ndarray) -> np.ndarray: 454 """Compute dense flow between two depth maps using Farneback. 455 456 Returns (H, W, 2) flow field. Falls back to zero-flow if OpenCV is 457 not available. 458 """ 459 try: 460 import cv2 # type: ignore 461 except ImportError: # pragma: no cover 462 return np.zeros((*d0.shape, 2), dtype=np.float32) 463 # Normalize depth to [0, 255] uint8 for Farneback. 464 def _to_u8(d: np.ndarray) -> np.ndarray: 465 d64 = d.astype(np.float64) 466 lo, hi = np.nanmin(d64), np.nanmax(d64) 467 if hi - lo < 1e-8: 468 return np.zeros_like(d64, dtype=np.uint8) 469 return ((d64 - lo) / (hi - lo) * 255).astype(np.uint8) 470 471 return cv2.calcOpticalFlowFarneback( 472 _to_u8(d0), _to_u8(d1), 473 None, # type: ignore[arg-type] 474 pyr_scale=0.5, levels=3, winsize=15, 475 iterations=3, poly_n=5, poly_sigma=1.2, flags=0, 476 ) 477 478 479def temporal_motion_consistency( 480 pred_seq: np.ndarray, 481 gt_seq: np.ndarray, 482) -> float: 483 """Mean SSIM between optical flows computed on depth maps (pred vs GT). 484 485 Computes dense optical flow (Farneback) on consecutive depth frames 486 for both prediction and GT, then takes the SSIM between the 487 resulting flow fields. Captures whether the apparent motion 488 *pattern* in depth space matches GT. 489 490 Higher is better, in ``[-1, 1]``. Returns ``nan`` if no pair is 491 computable or if OpenCV is unavailable. 492 493 Reference: Zhang et al. "Exploiting temporal consistency for 494 real-time video depth estimation" (ICCV 2019, arXiv:1908.03706). 495 """ 496 pred_seq = _as_seq("pred_seq", pred_seq, 3) 497 gt_seq = _as_seq("gt_seq", gt_seq, 3) 498 if pred_seq.shape != gt_seq.shape: 499 raise MetricError( 500 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 501 ) 502 503 scores = [] 504 for t in range(pred_seq.shape[0] - 1): 505 flow_pred = _dense_flow_on_depth(pred_seq[t], pred_seq[t + 1]) 506 flow_gt = _dense_flow_on_depth(gt_seq[t], gt_seq[t + 1]) 507 # SSIM on flow magnitude 508 mag_pred = np.sqrt(flow_pred[..., 0] ** 2 + flow_pred[..., 1] ** 2).astype(np.float64) 509 mag_gt = np.sqrt(flow_gt[..., 0] ** 2 + flow_gt[..., 1] ** 2).astype(np.float64) 510 scores.append(_ssim(mag_pred, mag_gt)) 511 512 return float(np.mean(scores)) if scores else float("nan") 513 514 515# --------------------------------------------------------------------------- # 516# Range-stratified TAE and per-frame metrics 517# --------------------------------------------------------------------------- # 518 519#: Depth range bins for robotics-aware stratification (metres). 520#: Near = tabletop manipulation, Mid = arm's reach, Far = room-scale. 521DEPTH_RANGE_BINS = { 522 "near": (0.3, 1.0), 523 "mid": (1.0, 2.5), 524 "far": (2.5, 5.0), 525} 526 527#: Minimum valid-pixel fraction per frame for a bin to be scored. 528_MIN_BIN_PIXELS = 100 529 530 531def range_stratified_tae( 532 pred_seq: np.ndarray, 533 gt_seq: np.ndarray, 534 poses: np.ndarray, 535 *, 536 fx: float = _D435_FX, 537 fy: float = _D435_FY, 538 cx: float = _D435_CX, 539 cy: float = _D435_CY, 540) -> Dict[str, float]: 541 """TAE stratified by GT-depth range bins. 542 543 For each consecutive frame pair and each range bin, computes the 544 TAE only over pixels whose GT depth at the source frame falls in 545 that bin. Returns ``{tae_near, tae_mid, tae_far}``; bins with 546 insufficient valid pixels are ``nan``. 547 """ 548 pred_seq = _as_seq("pred_seq", pred_seq, 3) 549 gt_seq = _as_seq("gt_seq", gt_seq, 3) 550 poses = np.asarray(poses, dtype=np.float64) 551 if poses.shape[0] != pred_seq.shape[0] or poses.shape[1:] != (4, 4): 552 raise MetricError( 553 f"poses must be (T, 4, 4) with T={pred_seq.shape[0]}, got {poses.shape}.", 554 ) 555 556 bin_errors: Dict[str, list] = {f"tae_{k}": [] for k in DEPTH_RANGE_BINS} 557 558 for t in range(pred_seq.shape[0] - 1): 559 d0, d1 = pred_seq[t], pred_seq[t + 1] 560 g0 = gt_seq[t] 561 p0, p1 = poses[t], poses[t + 1] 562 if not (np.all(np.isfinite(p0)) and np.all(np.isfinite(p1))): 563 continue 564 565 # Forward reproject: frame t -> frame t+1 566 reproj01, ok01 = se3_reproject_depth(d0, p0, p1, fx, fy, cx, cy) 567 base_valid = ok01 & np.isfinite(reproj01) & (reproj01 > 0) & (d1 > 0) & np.isfinite(d1) 568 569 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 570 bin_mask = base_valid & (g0 >= lo) & (g0 < hi) 571 if bin_mask.sum() < _MIN_BIN_PIXELS: 572 continue 573 e = _absrel(reproj01, d1, bin_mask) 574 if e is not None: 575 bin_errors[f"tae_{bin_name}"].append(e) 576 577 return { 578 k: float(np.mean(v)) if v else float("nan") 579 for k, v in bin_errors.items() 580 } 581 582 583def range_stratified_tgm_tgse( 584 pred_seq: np.ndarray, 585 gt_seq: np.ndarray, 586 *, 587 static_thresh_m: float = TEMPORAL_GRADIENT_STATIC_THRESH_M, 588) -> Dict[str, float]: 589 """TGM and TGSE stratified by GT depth range, clip-averaged. 590 591 For each consecutive frame pair and each range bin, computes: 592 * TGM = mean L1(|Δd_pred| − |Δd_gt|) over static-region pixels 593 (|Δd_gt| < ``static_thresh_m``) whose GT depth at frame ``t`` 594 falls in the bin; 595 * TGSE = mean squared error of signed depth-change (Δd_pred − Δd_gt) 596 over all valid pixels whose GT depth at frame ``t`` falls in the 597 bin (no static mask; TGSE is defined for all changes, not just 598 static regions). 599 600 Returns ``{tgm_near, tgm_mid, tgm_far, tgse_near, tgse_mid, tgse_far}``. 601 Bins with fewer than ``_MIN_BIN_PIXELS`` in any pair are ``nan``. 602 """ 603 pred_seq = _as_seq("pred_seq", pred_seq, 3) 604 gt_seq = _as_seq("gt_seq", gt_seq, 3) 605 if pred_seq.shape != gt_seq.shape: 606 raise MetricError( 607 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 608 ) 609 610 tgm_bins: Dict[str, list] = {f"tgm_{b}": [] for b in DEPTH_RANGE_BINS} 611 tgse_bins: Dict[str, list] = {f"tgse_{b}": [] for b in DEPTH_RANGE_BINS} 612 613 for t in range(pred_seq.shape[0] - 1): 614 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 615 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 616 valid = v0 & v1 617 if not valid.any(): 618 continue 619 620 g0 = gt_seq[t] 621 d_gt_abs = np.abs(gt_seq[t + 1] - gt_seq[t]) 622 d_pred_abs = np.abs(pred_seq[t + 1] - pred_seq[t]) 623 delta_gt = gt_seq[t + 1] - gt_seq[t] 624 delta_pred = pred_seq[t + 1] - pred_seq[t] 625 static_mask = valid & (d_gt_abs < static_thresh_m) 626 627 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 628 bin_source = (g0 >= lo) & (g0 < hi) 629 630 tgm_pix = static_mask & bin_source 631 if tgm_pix.sum() >= _MIN_BIN_PIXELS: 632 tgm_bins[f"tgm_{bin_name}"].append( 633 float(np.mean(np.abs(d_pred_abs[tgm_pix] - d_gt_abs[tgm_pix]))) 634 ) 635 636 tgse_pix = valid & bin_source 637 if tgse_pix.sum() >= _MIN_BIN_PIXELS: 638 tgse_bins[f"tgse_{bin_name}"].append( 639 float(np.mean((delta_pred[tgse_pix] - delta_gt[tgse_pix]) ** 2)) 640 ) 641 642 out: Dict[str, float] = {} 643 for d in (tgm_bins, tgse_bins): 644 for k, v in d.items(): 645 out[k] = float(np.mean(v)) if v else float("nan") 646 return out 647 648 649def range_stratified_per_frame_metrics( 650 pred_seq: np.ndarray, 651 gt_seq: np.ndarray, 652) -> Dict[str, float]: 653 """Per-frame AbsRel/RMSE/δ₁ stratified by GT depth range, clip-averaged. 654 655 Returns keys like ``absrel_near``, ``rmse_mid``, ``delta1_far``. 656 Bins with insufficient valid pixels across the clip are ``nan``. 657 """ 658 pred_seq = _as_seq("pred_seq", pred_seq, 3) 659 gt_seq = _as_seq("gt_seq", gt_seq, 3) 660 if pred_seq.shape != gt_seq.shape: 661 raise MetricError( 662 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 663 ) 664 665 metric_names = ("absrel", "rmse", "delta1") 666 accum: Dict[str, list] = {} 667 for m in metric_names: 668 for b in DEPTH_RANGE_BINS: 669 accum[f"{m}_{b}"] = [] 670 671 for t in range(pred_seq.shape[0]): 672 p = pred_seq[t].astype(np.float64) 673 g = gt_seq[t].astype(np.float64) 674 base_valid = default_valid_mask(pred_seq[t], gt_seq[t]) 675 676 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 677 v = base_valid & (g >= lo) & (g < hi) 678 if v.sum() < _MIN_BIN_PIXELS: 679 continue 680 pv, gv = p[v], g[v] 681 accum[f"absrel_{bin_name}"].append(float(np.mean(np.abs(pv - gv) / gv))) 682 accum[f"rmse_{bin_name}"].append(float(np.sqrt(np.mean((pv - gv) ** 2)))) 683 ratio = np.maximum(pv / gv, gv / pv) 684 accum[f"delta1_{bin_name}"].append(float(np.mean(ratio < 1.25))) 685 686 return { 687 k: float(np.mean(v)) if v else float("nan") 688 for k, v in accum.items() 689 } 690 691 692# --------------------------------------------------------------------------- # 693# Orchestrator 694# --------------------------------------------------------------------------- # 695 696 697def compute_temporal_depth_metrics( 698 pred_seq: np.ndarray, 699 *, 700 gt_seq: Optional[np.ndarray] = None, 701 poses: Optional[np.ndarray] = None, 702 rgb_seq: Optional[np.ndarray] = None, 703 flow_fn: Optional[FlowFn] = None, 704 fx: float = _D435_FX, 705 fy: float = _D435_FY, 706 cx: float = _D435_CX, 707 cy: float = _D435_CY, 708) -> Dict[str, float]: 709 """Compute every available temporal depth metric for one clip. 710 711 Each metric is computed only when its required inputs are present; 712 missing inputs degrade gracefully (the metric is set to ``nan`` and a 713 note is logged) rather than raising — a clip with no T265 poses still 714 yields ``opw``/``tgm``/``tcc``. 715 716 Inputs 717 ------ 718 pred_seq : (T, H, W) predicted depth, metres. Required. 719 gt_seq : (T, H, W) GT depth, metres. Enables ``tgm`` and ``tcc``. 720 poses : (T, 4, 4) world-from-camera SE(3). Enables ``tae``. 721 rgb_seq : (T, H, W, 3) RGB. With ``flow_fn`` enables ``opw``. 722 flow_fn : optical-flow backend (see :data:`FlowFn`). 723 724 Returns 725 ------- 726 dict[str, float] 727 Keys ``tae, opw, tgm, tcc`` (``nan`` where not computable). The 728 MANOVA selection (``tae``, ``opw``) happens downstream in 729 ``analyze_experiment.py``; this calculator emits all four. 730 """ 731 out: Dict[str, float] = { 732 "tae": float("nan"), "opw": float("nan"), 733 "tgm": float("nan"), "tcc": float("nan"), 734 "tgse": float("nan"), "tmc": float("nan"), 735 } 736 # Range-stratified keys initialized to nan. 737 for b in DEPTH_RANGE_BINS: 738 out[f"tae_{b}"] = float("nan") 739 for m in ("absrel", "rmse", "delta1", "tgm", "tgse"): 740 for b in DEPTH_RANGE_BINS: 741 out[f"{m}_{b}"] = float("nan") 742 743 if poses is not None: 744 out["tae"] = temporal_alignment_error(pred_seq, poses, fx=fx, fy=fy, cx=cx, cy=cy) 745 if gt_seq is not None: 746 strat = range_stratified_tae(pred_seq, gt_seq, poses, fx=fx, fy=fy, cx=cx, cy=cy) 747 out.update(strat) 748 else: 749 log.debug("temporal: poses absent — tae/tae_stratified skipped (nan)") 750 751 if rgb_seq is not None and flow_fn is not None: 752 out["opw"] = optical_flow_warping_error(pred_seq, rgb_seq, flow_fn) 753 else: 754 log.debug("temporal: rgb_seq/flow_fn absent — opw skipped (nan)") 755 756 if gt_seq is not None: 757 out["tgm"] = temporal_gradient_matching(pred_seq, gt_seq) 758 out["tcc"] = temporal_consistency_coefficient(pred_seq, gt_seq) 759 out["tgse"] = temporal_gradient_squared_error(pred_seq, gt_seq) 760 out["tmc"] = temporal_motion_consistency(pred_seq, gt_seq) 761 strat_pf = range_stratified_per_frame_metrics(pred_seq, gt_seq) 762 out.update(strat_pf) 763 strat_tg = range_stratified_tgm_tgse(pred_seq, gt_seq) 764 out.update(strat_tg) 765 else: 766 log.debug("temporal: gt_seq absent — tgm/tcc/tgse/tmc/stratified skipped (nan)") 767 768 return out
173def temporal_alignment_error( 174 pred_seq: np.ndarray, 175 poses: np.ndarray, 176 *, 177 fx: float = _D435_FX, 178 fy: float = _D435_FY, 179 cx: float = _D435_CX, 180 cy: float = _D435_CY, 181) -> float: 182 """Bidirectional SE(3)-reprojection AbsRel of a predicted depth clip. 183 184 For every consecutive frame pair ``(t, t+1)`` the prediction at one 185 frame is back-projected to 3D, transported to the other camera via 186 the known world-from-camera poses, and re-projected. The depth it 187 *predicts there* is compared (AbsRel) against the model's *own* 188 prediction at that frame. A geometrically consistent model has 189 ``tae → 0``; a model whose per-frame depth disagrees in 3D under 190 known camera motion scores high. No ground truth is used (this is a 191 self-consistency measure), matching ChronoDepth / Video Depth 192 Anything. 193 194 Parameters 195 ---------- 196 pred_seq : np.ndarray 197 ``(T, H, W)`` predicted depth in metres. 198 poses : np.ndarray 199 ``(T, 4, 4)`` world-from-camera SE(3) per frame. 200 fx, fy, cx, cy : float 201 Pinhole intrinsics (default D435). 202 203 Returns 204 ------- 205 float 206 Mean bidirectional AbsRel over all valid consecutive pairs. 207 ``nan`` if no pair yields a single valid reprojected pixel. 208 """ 209 pred_seq = _as_seq("pred_seq", pred_seq, 3) 210 poses = np.asarray(poses, dtype=np.float64) 211 if poses.shape[0] != pred_seq.shape[0] or poses.shape[1:] != (4, 4): 212 raise MetricError( 213 f"poses must be (T, 4, 4) with T={pred_seq.shape[0]}, got {poses.shape}.", 214 ) 215 216 errors = [] 217 for t in range(pred_seq.shape[0] - 1): 218 d0, d1 = pred_seq[t], pred_seq[t + 1] 219 p0, p1 = poses[t], poses[t + 1] 220 if not (np.all(np.isfinite(p0)) and np.all(np.isfinite(p1))): 221 continue 222 223 # Forward: frame t -> frame t+1. 224 reproj01, ok01 = se3_reproject_depth(d0, p0, p1, fx, fy, cx, cy) 225 v01 = ok01 & np.isfinite(reproj01) & (reproj01 > 0) & (d1 > 0) & np.isfinite(d1) 226 e01 = _absrel(reproj01, d1, v01) 227 if e01 is not None: 228 errors.append(e01) 229 230 # Backward: frame t+1 -> frame t. 231 reproj10, ok10 = se3_reproject_depth(d1, p1, p0, fx, fy, cx, cy) 232 v10 = ok10 & np.isfinite(reproj10) & (reproj10 > 0) & (d0 > 0) & np.isfinite(d0) 233 e10 = _absrel(reproj10, d0, v10) 234 if e10 is not None: 235 errors.append(e10) 236 237 return float(np.mean(errors)) if errors else float("nan")
Bidirectional SE(3)-reprojection AbsRel of a predicted depth clip.
For every consecutive frame pair (t, t+1) the prediction at one
frame is back-projected to 3D, transported to the other camera via
the known world-from-camera poses, and re-projected. The depth it
predicts there is compared (AbsRel) against the model's own
prediction at that frame. A geometrically consistent model has
tae → 0; a model whose per-frame depth disagrees in 3D under
known camera motion scores high. No ground truth is used (this is a
self-consistency measure), matching ChronoDepth / Video Depth
Anything.
Parameters
pred_seq : np.ndarray
(T, H, W) predicted depth in metres.
poses : np.ndarray
(T, 4, 4) world-from-camera SE(3) per frame.
fx, fy, cx, cy : float
Pinhole intrinsics (default D435).
Returns
float
Mean bidirectional AbsRel over all valid consecutive pairs.
nan if no pair yields a single valid reprojected pixel.
245def optical_flow_warping_error( 246 pred_seq: np.ndarray, 247 rgb_seq: np.ndarray, 248 flow_fn: Optional[FlowFn], 249) -> float: 250 """AbsRel between flow-warped consecutive predicted depth maps. 251 252 For each pair ``(t, t+1)`` the backward flow ``rgb[t+1] → rgb[t]`` is 253 used to sample the depth prediction at ``t`` into the pixel grid of 254 ``t+1``; the warped depth is compared (AbsRel) against the prediction 255 at ``t+1``. This measures appearance-level temporal smoothness and 256 needs no GT or pose — only an optical-flow backend. 257 258 Parameters 259 ---------- 260 pred_seq : np.ndarray 261 ``(T, H, W)`` predicted depth in metres. 262 rgb_seq : np.ndarray 263 ``(T, H, W, 3)`` RGB uint8 frames (the flow source). 264 flow_fn : FlowFn | None 265 Backend that returns the **backward** flow (see :data:`FlowFn`). 266 ``None`` raises :class:`MetricError` — OPW cannot be computed 267 without a flow model. RPX pins RAFT (``raft-sintel.pth``); wire 268 it as ``flow_fn=raft_backward_flow``. 269 270 Returns 271 ------- 272 float 273 Mean AbsRel over all valid pairs; ``nan`` if none are valid. 274 """ 275 pred_seq = _as_seq("pred_seq", pred_seq, 3) 276 rgb_seq = _as_seq("rgb_seq", rgb_seq, 4) 277 if rgb_seq.shape[0] != pred_seq.shape[0]: 278 raise MetricError( 279 f"rgb_seq T={rgb_seq.shape[0]} must match pred_seq T={pred_seq.shape[0]}.", 280 ) 281 if flow_fn is None: 282 raise MetricError( 283 "optical_flow_warping_error requires a flow backend; flow_fn is None.", 284 hint=( 285 "OPW needs dense optical flow. RPX pins RAFT (raft-sintel.pth, " 286 "Teed & Deng 2020). Pass flow_fn=<raft backward-flow callable>. " 287 "See benchmark/README.md §2 (optical-flow " 288 "recommendation)." 289 ), 290 ) 291 292 h, w = pred_seq.shape[1:] 293 grid_y, grid_x = np.mgrid[0:h, 0:w].astype(np.float64) 294 295 errors = [] 296 for t in range(pred_seq.shape[0] - 1): 297 flow = np.asarray(flow_fn(rgb_seq[t + 1], rgb_seq[t]), dtype=np.float64) 298 if flow.shape != (h, w, 2): 299 raise MetricError( 300 f"flow_fn returned shape {flow.shape}, expected ({h}, {w}, 2).", 301 ) 302 src_x = grid_x + flow[..., 0] 303 src_y = grid_y + flow[..., 1] 304 warped = _bilinear_sample(pred_seq[t], src_y, src_x) 305 306 d1 = pred_seq[t + 1] 307 inside = (src_x >= 0) & (src_x <= w - 1) & (src_y >= 0) & (src_y <= h - 1) 308 valid = inside & (warped > 0) & np.isfinite(warped) & (d1 > 0) & np.isfinite(d1) 309 e = _absrel(warped, d1, valid) 310 if e is not None: 311 errors.append(e) 312 313 return float(np.mean(errors)) if errors else float("nan")
AbsRel between flow-warped consecutive predicted depth maps.
For each pair (t, t+1) the backward flow rgb[t+1] → rgb[t] is
used to sample the depth prediction at t into the pixel grid of
t+1; the warped depth is compared (AbsRel) against the prediction
at t+1. This measures appearance-level temporal smoothness and
needs no GT or pose — only an optical-flow backend.
Parameters
pred_seq : np.ndarray
(T, H, W) predicted depth in metres.
rgb_seq : np.ndarray
(T, H, W, 3) RGB uint8 frames (the flow source).
flow_fn : FlowFn | None
Backend that returns the backward flow (see FlowFn).
None raises MetricError — OPW cannot be computed
without a flow model. RPX pins RAFT (raft-sintel.pth); wire
it as flow_fn=raft_backward_flow.
Returns
float
Mean AbsRel over all valid pairs; nan if none are valid.
321def temporal_gradient_matching( 322 pred_seq: np.ndarray, 323 gt_seq: np.ndarray, 324 *, 325 static_thresh_m: float = TEMPORAL_GRADIENT_STATIC_THRESH_M, 326) -> float: 327 """Mean L1 between predicted and GT per-pixel depth-change in static regions. 328 329 .. math:: 330 331 \\text{TGM} = \\frac{1}{T-1}\\sum_t 332 \\big\\| \\,|\\hat d_{t+1}-\\hat d_t| - |d_{t+1}-d_t|\\, \\big\\|_1 333 334 restricted to pixels where ``|d_{t+1} − d_t| < static_thresh_m`` (the 335 scene is locally static, so any predicted change is model jitter). 336 Flow-free, GT-referenced. Lower is better. 337 338 Returns ``nan`` if no static valid pixels exist across the clip. 339 """ 340 pred_seq = _as_seq("pred_seq", pred_seq, 3) 341 gt_seq = _as_seq("gt_seq", gt_seq, 3) 342 if pred_seq.shape != gt_seq.shape: 343 raise MetricError( 344 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 345 ) 346 347 errors = [] 348 for t in range(pred_seq.shape[0] - 1): 349 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 350 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 351 valid = v0 & v1 352 if not valid.any(): 353 continue 354 d_gt = np.abs(gt_seq[t + 1] - gt_seq[t]) 355 d_pred = np.abs(pred_seq[t + 1] - pred_seq[t]) 356 static = valid & (d_gt < static_thresh_m) 357 if not static.any(): 358 continue 359 errors.append(float(np.mean(np.abs(d_pred[static] - d_gt[static])))) 360 361 return float(np.mean(errors)) if errors else float("nan")
Mean L1 between predicted and GT per-pixel depth-change in static regions.
$$\text{TGM} = \frac{1}{T-1}\sum_t \big\| \,|\hat d_{t+1}-\hat d_t| - |d_{t+1}-d_t|\, \big\|_1$$
restricted to pixels where |d_{t+1} − d_t| < static_thresh_m (the
scene is locally static, so any predicted change is model jitter).
Flow-free, GT-referenced. Lower is better.
Returns nan if no static valid pixels exist across the clip.
406def temporal_gradient_squared_error( 407 pred_seq: np.ndarray, 408 gt_seq: np.ndarray, 409) -> float: 410 """Mean squared error of temporal depth gradients (pred vs GT). 411 412 .. math:: 413 414 \\text{TGSE} = \\frac{1}{T-1}\\sum_t \\frac{1}{|V_t|} 415 \\sum_{(i,j) \\in V_t} 416 \\left( \\hat d^{(t+1)}_{i,j} - \\hat d^{(t)}_{i,j} 417 - (d^{(t+1)}_{i,j} - d^{(t)}_{i,j}) \\right)^2 418 419 Unlike TGM: (a) preserves sign of depth change (no inner absolute 420 value), (b) uses L2 so severe flicker dominates the average. 421 Lower is better. Returns ``nan`` if no pair has valid pixels. 422 423 Reference: Yoon et al. "VDPP: Video Depth Post-Processing for Speed 424 and Scalability" (arXiv 2026, 2604.06665). 425 """ 426 pred_seq = _as_seq("pred_seq", pred_seq, 3) 427 gt_seq = _as_seq("gt_seq", gt_seq, 3) 428 if pred_seq.shape != gt_seq.shape: 429 raise MetricError( 430 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 431 ) 432 433 errors = [] 434 for t in range(pred_seq.shape[0] - 1): 435 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 436 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 437 valid = v0 & v1 438 if not valid.any(): 439 continue 440 delta_pred = pred_seq[t + 1][valid] - pred_seq[t][valid] 441 delta_gt = gt_seq[t + 1][valid] - gt_seq[t][valid] 442 errors.append(float(np.mean((delta_pred.astype(np.float64) 443 - delta_gt.astype(np.float64)) ** 2))) 444 445 return float(np.mean(errors)) if errors else float("nan")
Mean squared error of temporal depth gradients (pred vs GT).
$$\text{TGSE} = \frac{1}{T-1}\sum_t \frac{1}{|V_t|} \sum_{(i,j) \in V_t} \left( \hat d^{(t+1)}_{i,j} - \hat d^{(t)}_{i,j} - (d^{(t+1)}_{i,j} - d^{(t)}_{i,j}) \right)^2$$
Unlike TGM: (a) preserves sign of depth change (no inner absolute
value), (b) uses L2 so severe flicker dominates the average.
Lower is better. Returns nan if no pair has valid pixels.
Reference: Yoon et al. "VDPP: Video Depth Post-Processing for Speed and Scalability" (arXiv 2026, 2604.06665).
480def temporal_motion_consistency( 481 pred_seq: np.ndarray, 482 gt_seq: np.ndarray, 483) -> float: 484 """Mean SSIM between optical flows computed on depth maps (pred vs GT). 485 486 Computes dense optical flow (Farneback) on consecutive depth frames 487 for both prediction and GT, then takes the SSIM between the 488 resulting flow fields. Captures whether the apparent motion 489 *pattern* in depth space matches GT. 490 491 Higher is better, in ``[-1, 1]``. Returns ``nan`` if no pair is 492 computable or if OpenCV is unavailable. 493 494 Reference: Zhang et al. "Exploiting temporal consistency for 495 real-time video depth estimation" (ICCV 2019, arXiv:1908.03706). 496 """ 497 pred_seq = _as_seq("pred_seq", pred_seq, 3) 498 gt_seq = _as_seq("gt_seq", gt_seq, 3) 499 if pred_seq.shape != gt_seq.shape: 500 raise MetricError( 501 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 502 ) 503 504 scores = [] 505 for t in range(pred_seq.shape[0] - 1): 506 flow_pred = _dense_flow_on_depth(pred_seq[t], pred_seq[t + 1]) 507 flow_gt = _dense_flow_on_depth(gt_seq[t], gt_seq[t + 1]) 508 # SSIM on flow magnitude 509 mag_pred = np.sqrt(flow_pred[..., 0] ** 2 + flow_pred[..., 1] ** 2).astype(np.float64) 510 mag_gt = np.sqrt(flow_gt[..., 0] ** 2 + flow_gt[..., 1] ** 2).astype(np.float64) 511 scores.append(_ssim(mag_pred, mag_gt)) 512 513 return float(np.mean(scores)) if scores else float("nan")
Mean SSIM between optical flows computed on depth maps (pred vs GT).
Computes dense optical flow (Farneback) on consecutive depth frames for both prediction and GT, then takes the SSIM between the resulting flow fields. Captures whether the apparent motion pattern in depth space matches GT.
Higher is better, in [-1, 1]. Returns nan if no pair is
computable or if OpenCV is unavailable.
Reference: Zhang et al. "Exploiting temporal consistency for real-time video depth estimation" (ICCV 2019, arXiv:1908.03706).
369def temporal_consistency_coefficient( 370 pred_seq: np.ndarray, 371 gt_seq: np.ndarray, 372) -> float: 373 """Mean SSIM between predicted and GT temporal depth-change maps. 374 375 Captures the *structural pattern* of depth change over time rather 376 than its per-pixel magnitude (which TGM measures). Higher is better, 377 in ``[-1, 1]``. Returns ``nan`` if no pair has valid pixels. 378 """ 379 pred_seq = _as_seq("pred_seq", pred_seq, 3) 380 gt_seq = _as_seq("gt_seq", gt_seq, 3) 381 if pred_seq.shape != gt_seq.shape: 382 raise MetricError( 383 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 384 ) 385 386 scores = [] 387 for t in range(pred_seq.shape[0] - 1): 388 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 389 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 390 valid = v0 & v1 391 if not valid.any(): 392 continue 393 diff_pred = (pred_seq[t + 1] - pred_seq[t]) * valid 394 diff_gt = (gt_seq[t + 1] - gt_seq[t]) * valid 395 scores.append(_ssim(diff_pred.astype(np.float64), diff_gt.astype(np.float64))) 396 397 return float(np.mean(scores)) if scores else float("nan")
Mean SSIM between predicted and GT temporal depth-change maps.
Captures the structural pattern of depth change over time rather
than its per-pixel magnitude (which TGM measures). Higher is better,
in [-1, 1]. Returns nan if no pair has valid pixels.
698def compute_temporal_depth_metrics( 699 pred_seq: np.ndarray, 700 *, 701 gt_seq: Optional[np.ndarray] = None, 702 poses: Optional[np.ndarray] = None, 703 rgb_seq: Optional[np.ndarray] = None, 704 flow_fn: Optional[FlowFn] = None, 705 fx: float = _D435_FX, 706 fy: float = _D435_FY, 707 cx: float = _D435_CX, 708 cy: float = _D435_CY, 709) -> Dict[str, float]: 710 """Compute every available temporal depth metric for one clip. 711 712 Each metric is computed only when its required inputs are present; 713 missing inputs degrade gracefully (the metric is set to ``nan`` and a 714 note is logged) rather than raising — a clip with no T265 poses still 715 yields ``opw``/``tgm``/``tcc``. 716 717 Inputs 718 ------ 719 pred_seq : (T, H, W) predicted depth, metres. Required. 720 gt_seq : (T, H, W) GT depth, metres. Enables ``tgm`` and ``tcc``. 721 poses : (T, 4, 4) world-from-camera SE(3). Enables ``tae``. 722 rgb_seq : (T, H, W, 3) RGB. With ``flow_fn`` enables ``opw``. 723 flow_fn : optical-flow backend (see :data:`FlowFn`). 724 725 Returns 726 ------- 727 dict[str, float] 728 Keys ``tae, opw, tgm, tcc`` (``nan`` where not computable). The 729 MANOVA selection (``tae``, ``opw``) happens downstream in 730 ``analyze_experiment.py``; this calculator emits all four. 731 """ 732 out: Dict[str, float] = { 733 "tae": float("nan"), "opw": float("nan"), 734 "tgm": float("nan"), "tcc": float("nan"), 735 "tgse": float("nan"), "tmc": float("nan"), 736 } 737 # Range-stratified keys initialized to nan. 738 for b in DEPTH_RANGE_BINS: 739 out[f"tae_{b}"] = float("nan") 740 for m in ("absrel", "rmse", "delta1", "tgm", "tgse"): 741 for b in DEPTH_RANGE_BINS: 742 out[f"{m}_{b}"] = float("nan") 743 744 if poses is not None: 745 out["tae"] = temporal_alignment_error(pred_seq, poses, fx=fx, fy=fy, cx=cx, cy=cy) 746 if gt_seq is not None: 747 strat = range_stratified_tae(pred_seq, gt_seq, poses, fx=fx, fy=fy, cx=cx, cy=cy) 748 out.update(strat) 749 else: 750 log.debug("temporal: poses absent — tae/tae_stratified skipped (nan)") 751 752 if rgb_seq is not None and flow_fn is not None: 753 out["opw"] = optical_flow_warping_error(pred_seq, rgb_seq, flow_fn) 754 else: 755 log.debug("temporal: rgb_seq/flow_fn absent — opw skipped (nan)") 756 757 if gt_seq is not None: 758 out["tgm"] = temporal_gradient_matching(pred_seq, gt_seq) 759 out["tcc"] = temporal_consistency_coefficient(pred_seq, gt_seq) 760 out["tgse"] = temporal_gradient_squared_error(pred_seq, gt_seq) 761 out["tmc"] = temporal_motion_consistency(pred_seq, gt_seq) 762 strat_pf = range_stratified_per_frame_metrics(pred_seq, gt_seq) 763 out.update(strat_pf) 764 strat_tg = range_stratified_tgm_tgse(pred_seq, gt_seq) 765 out.update(strat_tg) 766 else: 767 log.debug("temporal: gt_seq absent — tgm/tcc/tgse/tmc/stratified skipped (nan)") 768 769 return out
Compute every available temporal depth metric for one clip.
Each metric is computed only when its required inputs are present;
missing inputs degrade gracefully (the metric is set to nan and a
note is logged) rather than raising — a clip with no T265 poses still
yields opw/tgm/tcc.
Inputs
pred_seq : (T, H, W) predicted depth, metres. Required.
gt_seq : (T, H, W) GT depth, metres. Enables tgm and tcc.
poses : (T, 4, 4) world-from-camera SE(3). Enables tae.
rgb_seq : (T, H, W, 3) RGB. With flow_fn enables opw.
flow_fn : optical-flow backend (see FlowFn).
Returns
dict[str, float]
Keys tae, opw, tgm, tcc (nan where not computable). The
MANOVA selection (tae, opw) happens downstream in
analyze_experiment.py; this calculator emits all four.
532def range_stratified_tae( 533 pred_seq: np.ndarray, 534 gt_seq: np.ndarray, 535 poses: np.ndarray, 536 *, 537 fx: float = _D435_FX, 538 fy: float = _D435_FY, 539 cx: float = _D435_CX, 540 cy: float = _D435_CY, 541) -> Dict[str, float]: 542 """TAE stratified by GT-depth range bins. 543 544 For each consecutive frame pair and each range bin, computes the 545 TAE only over pixels whose GT depth at the source frame falls in 546 that bin. Returns ``{tae_near, tae_mid, tae_far}``; bins with 547 insufficient valid pixels are ``nan``. 548 """ 549 pred_seq = _as_seq("pred_seq", pred_seq, 3) 550 gt_seq = _as_seq("gt_seq", gt_seq, 3) 551 poses = np.asarray(poses, dtype=np.float64) 552 if poses.shape[0] != pred_seq.shape[0] or poses.shape[1:] != (4, 4): 553 raise MetricError( 554 f"poses must be (T, 4, 4) with T={pred_seq.shape[0]}, got {poses.shape}.", 555 ) 556 557 bin_errors: Dict[str, list] = {f"tae_{k}": [] for k in DEPTH_RANGE_BINS} 558 559 for t in range(pred_seq.shape[0] - 1): 560 d0, d1 = pred_seq[t], pred_seq[t + 1] 561 g0 = gt_seq[t] 562 p0, p1 = poses[t], poses[t + 1] 563 if not (np.all(np.isfinite(p0)) and np.all(np.isfinite(p1))): 564 continue 565 566 # Forward reproject: frame t -> frame t+1 567 reproj01, ok01 = se3_reproject_depth(d0, p0, p1, fx, fy, cx, cy) 568 base_valid = ok01 & np.isfinite(reproj01) & (reproj01 > 0) & (d1 > 0) & np.isfinite(d1) 569 570 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 571 bin_mask = base_valid & (g0 >= lo) & (g0 < hi) 572 if bin_mask.sum() < _MIN_BIN_PIXELS: 573 continue 574 e = _absrel(reproj01, d1, bin_mask) 575 if e is not None: 576 bin_errors[f"tae_{bin_name}"].append(e) 577 578 return { 579 k: float(np.mean(v)) if v else float("nan") 580 for k, v in bin_errors.items() 581 }
TAE stratified by GT-depth range bins.
For each consecutive frame pair and each range bin, computes the
TAE only over pixels whose GT depth at the source frame falls in
that bin. Returns {tae_near, tae_mid, tae_far}; bins with
insufficient valid pixels are nan.
584def range_stratified_tgm_tgse( 585 pred_seq: np.ndarray, 586 gt_seq: np.ndarray, 587 *, 588 static_thresh_m: float = TEMPORAL_GRADIENT_STATIC_THRESH_M, 589) -> Dict[str, float]: 590 """TGM and TGSE stratified by GT depth range, clip-averaged. 591 592 For each consecutive frame pair and each range bin, computes: 593 * TGM = mean L1(|Δd_pred| − |Δd_gt|) over static-region pixels 594 (|Δd_gt| < ``static_thresh_m``) whose GT depth at frame ``t`` 595 falls in the bin; 596 * TGSE = mean squared error of signed depth-change (Δd_pred − Δd_gt) 597 over all valid pixels whose GT depth at frame ``t`` falls in the 598 bin (no static mask; TGSE is defined for all changes, not just 599 static regions). 600 601 Returns ``{tgm_near, tgm_mid, tgm_far, tgse_near, tgse_mid, tgse_far}``. 602 Bins with fewer than ``_MIN_BIN_PIXELS`` in any pair are ``nan``. 603 """ 604 pred_seq = _as_seq("pred_seq", pred_seq, 3) 605 gt_seq = _as_seq("gt_seq", gt_seq, 3) 606 if pred_seq.shape != gt_seq.shape: 607 raise MetricError( 608 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 609 ) 610 611 tgm_bins: Dict[str, list] = {f"tgm_{b}": [] for b in DEPTH_RANGE_BINS} 612 tgse_bins: Dict[str, list] = {f"tgse_{b}": [] for b in DEPTH_RANGE_BINS} 613 614 for t in range(pred_seq.shape[0] - 1): 615 v0 = default_valid_mask(pred_seq[t], gt_seq[t]) 616 v1 = default_valid_mask(pred_seq[t + 1], gt_seq[t + 1]) 617 valid = v0 & v1 618 if not valid.any(): 619 continue 620 621 g0 = gt_seq[t] 622 d_gt_abs = np.abs(gt_seq[t + 1] - gt_seq[t]) 623 d_pred_abs = np.abs(pred_seq[t + 1] - pred_seq[t]) 624 delta_gt = gt_seq[t + 1] - gt_seq[t] 625 delta_pred = pred_seq[t + 1] - pred_seq[t] 626 static_mask = valid & (d_gt_abs < static_thresh_m) 627 628 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 629 bin_source = (g0 >= lo) & (g0 < hi) 630 631 tgm_pix = static_mask & bin_source 632 if tgm_pix.sum() >= _MIN_BIN_PIXELS: 633 tgm_bins[f"tgm_{bin_name}"].append( 634 float(np.mean(np.abs(d_pred_abs[tgm_pix] - d_gt_abs[tgm_pix]))) 635 ) 636 637 tgse_pix = valid & bin_source 638 if tgse_pix.sum() >= _MIN_BIN_PIXELS: 639 tgse_bins[f"tgse_{bin_name}"].append( 640 float(np.mean((delta_pred[tgse_pix] - delta_gt[tgse_pix]) ** 2)) 641 ) 642 643 out: Dict[str, float] = {} 644 for d in (tgm_bins, tgse_bins): 645 for k, v in d.items(): 646 out[k] = float(np.mean(v)) if v else float("nan") 647 return out
TGM and TGSE stratified by GT depth range, clip-averaged.
For each consecutive frame pair and each range bin, computes:
- TGM = mean L1(|Δd_pred| − |Δd_gt|) over static-region pixels
(|Δd_gt| <
static_thresh_m) whose GT depth at frametfalls in the bin; - TGSE = mean squared error of signed depth-change (Δd_pred − Δd_gt)
over all valid pixels whose GT depth at frame
tfalls in the bin (no static mask; TGSE is defined for all changes, not just static regions).
Returns {tgm_near, tgm_mid, tgm_far, tgse_near, tgse_mid, tgse_far}.
Bins with fewer than _MIN_BIN_PIXELS in any pair are nan.
650def range_stratified_per_frame_metrics( 651 pred_seq: np.ndarray, 652 gt_seq: np.ndarray, 653) -> Dict[str, float]: 654 """Per-frame AbsRel/RMSE/δ₁ stratified by GT depth range, clip-averaged. 655 656 Returns keys like ``absrel_near``, ``rmse_mid``, ``delta1_far``. 657 Bins with insufficient valid pixels across the clip are ``nan``. 658 """ 659 pred_seq = _as_seq("pred_seq", pred_seq, 3) 660 gt_seq = _as_seq("gt_seq", gt_seq, 3) 661 if pred_seq.shape != gt_seq.shape: 662 raise MetricError( 663 f"pred_seq {pred_seq.shape} and gt_seq {gt_seq.shape} must match.", 664 ) 665 666 metric_names = ("absrel", "rmse", "delta1") 667 accum: Dict[str, list] = {} 668 for m in metric_names: 669 for b in DEPTH_RANGE_BINS: 670 accum[f"{m}_{b}"] = [] 671 672 for t in range(pred_seq.shape[0]): 673 p = pred_seq[t].astype(np.float64) 674 g = gt_seq[t].astype(np.float64) 675 base_valid = default_valid_mask(pred_seq[t], gt_seq[t]) 676 677 for bin_name, (lo, hi) in DEPTH_RANGE_BINS.items(): 678 v = base_valid & (g >= lo) & (g < hi) 679 if v.sum() < _MIN_BIN_PIXELS: 680 continue 681 pv, gv = p[v], g[v] 682 accum[f"absrel_{bin_name}"].append(float(np.mean(np.abs(pv - gv) / gv))) 683 accum[f"rmse_{bin_name}"].append(float(np.sqrt(np.mean((pv - gv) ** 2)))) 684 ratio = np.maximum(pv / gv, gv / pv) 685 accum[f"delta1_{bin_name}"].append(float(np.mean(ratio < 1.25))) 686 687 return { 688 k: float(np.mean(v)) if v else float("nan") 689 for k, v in accum.items() 690 }
Per-frame AbsRel/RMSE/δ₁ stratified by GT depth range, clip-averaged.
Returns keys like absrel_near, rmse_mid, delta1_far.
Bins with insufficient valid pixels across the clip are nan.