rpx_benchmark.evaluators

Legacy metric module (compat shim).

MetricSuite and BenchmarkResult have moved to rpx_benchmark.metrics. This file still re-exports them so old imports continue to work, and it still hosts the raw numpy metric functions (depth_metrics, detection_metrics, etc.) used internally by the calculator classes.

New code should import from rpx_benchmark.metrics instead.

  1"""Legacy metric module (compat shim).
  2
  3``MetricSuite`` and ``BenchmarkResult`` have moved to
  4:mod:`rpx_benchmark.metrics`. This file still re-exports them so old
  5imports continue to work, and it still hosts the raw numpy metric
  6*functions* (``depth_metrics``, ``detection_metrics``, etc.) used
  7internally by the calculator classes.
  8
  9New code should import from :mod:`rpx_benchmark.metrics` instead.
 10"""
 11
 12from __future__ import annotations
 13
 14from typing import Dict, Sequence
 15
 16import numpy as np
 17
 18# Re-export the new registry-backed facade so old ``from
 19# rpx_benchmark.evaluators import MetricSuite`` imports keep working.
 20from .metrics.registry import BenchmarkResult, MetricSuite  # noqa: F401
 21
 22# ------------------------------------------------------------------ #
 23# Task-specific metric functions
 24# ------------------------------------------------------------------ #
 25
 26
 27def depth_metrics(pred: np.ndarray, gt: np.ndarray) -> Dict[str, float]:
 28    """AbsRel, RMSE, and threshold accuracy (δ<1.25) for monocular depth."""
 29    pred = np.asarray(pred, dtype=np.float32)
 30    gt = np.asarray(gt, dtype=np.float32)
 31
 32    valid = gt > 0
 33    if valid.sum() == 0:
 34        return {"rmse": 0.0, "absrel": 0.0, "delta1": 1.0, "delta2": 1.0, "delta3": 1.0}
 35
 36    pred_v, gt_v = pred[valid], gt[valid]
 37    rmse = float(np.sqrt(np.mean((pred_v - gt_v) ** 2)))
 38    absrel = float(np.mean(np.abs(pred_v - gt_v) / gt_v))
 39
 40    thresh = np.maximum(pred_v / gt_v, gt_v / pred_v)
 41    delta1 = float(np.mean(thresh < 1.25))
 42    delta2 = float(np.mean(thresh < 1.25**2))
 43    delta3 = float(np.mean(thresh < 1.25**3))
 44
 45    return {"rmse": rmse, "absrel": absrel, "delta1": delta1, "delta2": delta2, "delta3": delta3}
 46
 47
 48def detection_metrics(
 49    pred_boxes: np.ndarray,
 50    pred_scores: Sequence[float],
 51    pred_labels: Sequence[str],
 52    gt_boxes: np.ndarray,
 53    gt_labels: Sequence[str],
 54    iou_threshold: float = 0.5,
 55) -> Dict[str, float]:
 56    """Greedy matching at a single IoU threshold → precision/recall/F1."""
 57    if len(pred_boxes) == 0 and len(gt_boxes) == 0:
 58        return {"precision": 1.0, "recall": 1.0, "f1": 1.0}
 59    if len(pred_boxes) == 0:
 60        return {"precision": 0.0, "recall": 0.0, "f1": 0.0}
 61
 62    pred_indices = np.argsort(-np.asarray(pred_scores))
 63    matched_gt: set[int] = set()
 64    tp = 0
 65    for idx in pred_indices:
 66        box = pred_boxes[idx]
 67        label = pred_labels[idx]
 68        best_iou, best_j = 0.0, -1
 69        for j, (gt_box, gt_label) in enumerate(zip(gt_boxes, gt_labels, strict=False)):
 70            if j in matched_gt or gt_label != label:
 71                continue
 72            iou = bbox_iou(box, gt_box)
 73            if iou > best_iou:
 74                best_iou, best_j = iou, j
 75        if best_iou >= iou_threshold and best_j >= 0:
 76            matched_gt.add(best_j)
 77            tp += 1
 78    fp = len(pred_boxes) - tp
 79    fn = len(gt_boxes) - tp
 80    precision = tp / (tp + fp) if (tp + fp) else 0.0
 81    recall = tp / (tp + fn) if (tp + fn) else 0.0
 82    f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) else 0.0
 83    return {"precision": float(precision), "recall": float(recall), "f1": float(f1)}
 84
 85
 86def segmentation_metrics(pred_mask: np.ndarray, gt_mask: np.ndarray) -> Dict[str, float]:
 87    """Per-class IoU averaged across classes present in GT (mIoU)."""
 88    pred_mask = np.asarray(pred_mask, dtype=np.int32)
 89    gt_mask = np.asarray(gt_mask, dtype=np.int32)
 90    classes = np.unique(gt_mask)
 91    classes = classes[classes >= 0]  # ignore background = -1 if used
 92
 93    if len(classes) == 0:
 94        return {"miou": 1.0}
 95
 96    ious = []
 97    for c in classes:
 98        pred_c = pred_mask == c
 99        gt_c = gt_mask == c
100        intersection = float((pred_c & gt_c).sum())
101        union = float((pred_c | gt_c).sum())
102        ious.append(intersection / union if union > 0 else 1.0)
103
104    return {"miou": float(np.mean(ious))}
105
106
107def tracking_metrics(
108    pred_tracks: Sequence,
109    gt_tracks: Sequence,
110    iou_threshold: float = 0.5,
111) -> Dict[str, float]:
112    """MOTA and IDF1 for multi-object tracking (single-frame / tracklet level).
113
114    This is a simplified tracklet-level computation suitable for per-sample
115    evaluation across a video clip.  A full MOTA/IDF1 computation requires
116    frame-by-frame association across the full sequence and is typically run
117    by the PhaseStratifiedRunner over an entire scene.
118    """
119    # Build frame-level sets from Tracklet objects
120    # Each Tracklet has boxes: T x 4, so T = number of frames it covers.
121    # We evaluate over each frame index present in GT.
122
123    # Determine number of frames
124    max_t = max((t.boxes.shape[0] for t in gt_tracks), default=0)
125    if max_t == 0:
126        return {"mota": 1.0, "idf1": 1.0}
127
128    total_gt = 0
129    total_fp = 0
130    total_fn = 0
131    total_idsw = 0  # ID switches — approximated here
132
133    # id assignment tracking: pred_track_id -> last matched gt_track_id
134    pred_last_match: Dict[str, str] = {}
135
136    for t in range(max_t):
137        gt_boxes_t = []
138        gt_ids_t = []
139        for tr in gt_tracks:
140            if t < tr.boxes.shape[0]:
141                gt_boxes_t.append(tr.boxes[t])
142                gt_ids_t.append(tr.track_id)
143
144        pred_boxes_t = []
145        pred_ids_t = []
146        for tr in pred_tracks:
147            if t < tr.boxes.shape[0]:
148                pred_boxes_t.append(tr.boxes[t])
149                pred_ids_t.append(tr.track_id)
150
151        total_gt += len(gt_boxes_t)
152
153        if len(pred_boxes_t) == 0:
154            total_fn += len(gt_boxes_t)
155            continue
156
157        # Greedy matching
158        matched_gt_idx: set[int] = set()
159        for _p_idx, (p_box, p_id) in enumerate(zip(pred_boxes_t, pred_ids_t, strict=False)):
160            best_iou, best_g = 0.0, -1
161            for g_idx, g_box in enumerate(gt_boxes_t):
162                if g_idx in matched_gt_idx:
163                    continue
164                iou = bbox_iou(np.array(p_box), np.array(g_box))
165                if iou > best_iou:
166                    best_iou, best_g = iou, g_idx
167            if best_iou >= iou_threshold and best_g >= 0:
168                matched_gt_idx.add(best_g)
169                g_id = gt_ids_t[best_g]
170                if p_id in pred_last_match and pred_last_match[p_id] != g_id:
171                    total_idsw += 1
172                pred_last_match[p_id] = g_id
173
174        total_fn += len(gt_boxes_t) - len(matched_gt_idx)
175        total_fp += len(pred_boxes_t) - len(matched_gt_idx)
176
177    mota = 1.0 - (total_fn + total_fp + total_idsw) / max(total_gt, 1)
178    mota = float(np.clip(mota, -1.0, 1.0))
179
180    # IDF1: identity recall × precision harmonic mean (simplified: treat matched as IDTP)
181    total_tp = total_gt - total_fn
182    idf1 = (2 * total_tp) / max(2 * total_tp + total_fp + total_fn, 1)
183    idf1 = float(np.clip(idf1, 0.0, 1.0))
184
185    return {
186        "mota": mota,
187        "idf1": idf1,
188        "fp": float(total_fp),
189        "fn": float(total_fn),
190        "idsw": float(total_idsw),
191    }
192
193
194def pose_metrics(
195    pred_rot: np.ndarray,
196    pred_trans: np.ndarray,
197    gt_rot: np.ndarray,
198    gt_trans: np.ndarray,
199) -> Dict[str, float]:
200    """Rotation geodesic error (degrees) and translation L2 error (meters)."""
201    pred_rot = np.asarray(pred_rot, dtype=np.float64)
202    gt_rot = np.asarray(gt_rot, dtype=np.float64)
203
204    # Handle quaternion input (4-vector)
205    if pred_rot.shape == (4,):
206        pred_rot = _quat_to_rotmat(pred_rot)
207    if gt_rot.shape == (4,):
208        gt_rot = _quat_to_rotmat(gt_rot)
209
210    # Geodesic distance: arccos((trace(R^T R') - 1) / 2)
211    relative = pred_rot.T @ gt_rot
212    trace_val = float(np.trace(relative))
213    cos_angle = (trace_val - 1.0) / 2.0
214    cos_angle = np.clip(cos_angle, -1.0, 1.0)
215    rot_err_deg = float(np.degrees(np.arccos(cos_angle)))
216
217    pred_t = np.asarray(pred_trans, dtype=np.float64)
218    gt_t = np.asarray(gt_trans, dtype=np.float64)
219
220    trans_err_m = float(np.linalg.norm(pred_t - gt_t))
221
222    # Translation angular error (scale-invariant direction error).
223    # Standard metric in ScanNet-1500 / MegaDepth-1500 / MapFree.
224    n_pred = np.linalg.norm(pred_t)
225    n_gt = np.linalg.norm(gt_t)
226    if n_pred < 1e-9 or n_gt < 1e-9:
227        trans_angular_deg = 180.0
228    else:
229        cos_a = np.clip(np.dot(pred_t, gt_t) / (n_pred * n_gt), -1.0, 1.0)
230        trans_angular_deg = float(np.degrees(np.arccos(cos_a)))
231
232    pose_error_max_deg = max(rot_err_deg, trans_angular_deg)
233
234    return {
235        "rotation_error_deg": rot_err_deg,
236        "translation_error_m": trans_err_m,
237        "translation_angular_deg": trans_angular_deg,
238        "pose_error_max_deg": pose_error_max_deg,
239    }
240
241
242def grounding_metrics(
243    pred_boxes: np.ndarray,
244    gt_boxes: np.ndarray,
245    iou_threshold: float = 0.5,
246) -> Dict[str, float]:
247    """Visual grounding: top-1 IoU and accuracy at IoU threshold.
248
249    Standard referring grounding protocol: a prediction is correct if the
250    highest-scoring predicted box has IoU ≥ threshold with any GT box.
251    """
252    if len(pred_boxes) == 0:
253        return {"grounding_iou": 0.0, "grounding_acc": 0.0}
254    if len(gt_boxes) == 0:
255        return {"grounding_iou": 1.0, "grounding_acc": 1.0}
256
257    # Evaluate the first (highest-scoring) prediction against all GT boxes
258    top_box = pred_boxes[0]
259    best_iou = max(bbox_iou(top_box, gt_box) for gt_box in gt_boxes)
260    acc = 1.0 if best_iou >= iou_threshold else 0.0
261
262    return {"grounding_iou": float(best_iou), "grounding_acc": float(acc)}
263
264
265def sparse_depth_metrics(
266    pred_coords: np.ndarray,
267    pred_depths: np.ndarray,
268    gt_coords: np.ndarray,
269    gt_depths: np.ndarray,
270    radius: float = 2.0,
271) -> Dict[str, float]:
272    """AbsRel and RMSE at sparse GT pixel locations.
273
274    We match predicted depths to GT coordinates by nearest-neighbor within
275    `radius` pixels.
276    """
277    if len(gt_coords) == 0:
278        return {"sparse_absrel": 0.0, "sparse_rmse": 0.0}
279
280    abs_errors = []
281    sq_errors = []
282    for (gx, gy), gd in zip(gt_coords, gt_depths, strict=False):
283        if len(pred_coords) == 0:
284            abs_errors.append(gd)
285            sq_errors.append(gd**2)
286            continue
287        dists = np.sqrt(((pred_coords - np.array([gx, gy])) ** 2).sum(axis=1))
288        nearest = int(np.argmin(dists))
289        if dists[nearest] <= radius:
290            pd = pred_depths[nearest]
291        else:
292            pd = 0.0  # no prediction at this location
293        abs_errors.append(abs(pd - gd) / max(gd, 1e-6))
294        sq_errors.append((pd - gd) ** 2)
295
296    return {
297        "sparse_absrel": float(np.mean(abs_errors)),
298        "sparse_rmse": float(np.sqrt(np.mean(sq_errors))),
299    }
300
301
302
303
304def keypoint_metrics(
305    pred_p0: np.ndarray,
306    pred_p1: np.ndarray,
307    gt_p0: np.ndarray,
308    gt_p1: np.ndarray,
309    visibility: np.ndarray | None = None,
310    px_threshold: float = 3.0,
311) -> Dict[str, float]:
312    """Keypoint matching accuracy: % correspondences within px_threshold pixels.
313
314    We match each GT keypoint pair to the nearest predicted pair in image0
315    and check if the predicted point in image1 is within threshold pixels of GT.
316    """
317    if len(gt_p0) == 0:
318        return {"keypoint_acc": 1.0, "mean_match_error": 0.0}
319    if len(pred_p0) == 0:
320        return {"keypoint_acc": 0.0, "mean_match_error": float("inf")}
321
322    mask = visibility if visibility is not None else np.ones(len(gt_p0), dtype=bool)
323    gt_p0_v = gt_p0[mask]
324    gt_p1_v = gt_p1[mask]
325
326    if len(gt_p0_v) == 0:
327        return {"keypoint_acc": 1.0, "mean_match_error": 0.0}
328
329    errors = []
330    for gp0, gp1 in zip(gt_p0_v, gt_p1_v, strict=False):
331        dists0 = np.sqrt(((pred_p0 - gp0) ** 2).sum(axis=1))
332        nearest = int(np.argmin(dists0))
333        err = float(np.sqrt(((pred_p1[nearest] - gp1) ** 2).sum()))
334        errors.append(err)
335
336    errors_arr = np.array(errors)
337    acc = float(np.mean(errors_arr < px_threshold))
338    mean_err = float(np.mean(errors_arr))
339
340    return {"keypoint_acc": acc, "mean_match_error": mean_err}
341
342
343# ------------------------------------------------------------------ #
344# Geometry helpers
345# ------------------------------------------------------------------ #
346
347
348def bbox_iou(box_a: np.ndarray, box_b: np.ndarray) -> float:
349    ax1, ay1, ax2, ay2 = box_a
350    bx1, by1, bx2, by2 = box_b
351    inter_x1, inter_y1 = max(ax1, bx1), max(ay1, by1)
352    inter_x2, inter_y2 = min(ax2, bx2), min(ay2, by2)
353    inter_w = max(0.0, inter_x2 - inter_x1)
354    inter_h = max(0.0, inter_y2 - inter_y1)
355    inter_area = inter_w * inter_h
356    area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
357    area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
358    union = area_a + area_b - inter_area
359    return float(inter_area / union) if union > 0 else 0.0
360
361
362def _quat_to_rotmat(q: np.ndarray) -> np.ndarray:
363    """Convert quaternion [w, x, y, z] to 3x3 rotation matrix."""
364    w, x, y, z = q / np.linalg.norm(q)
365    return np.array(
366        [
367            [1 - 2 * y * y - 2 * z * z, 2 * x * y - 2 * z * w, 2 * x * z + 2 * y * w],
368            [2 * x * y + 2 * z * w, 1 - 2 * x * x - 2 * z * z, 2 * y * z - 2 * x * w],
369            [2 * x * z - 2 * y * w, 2 * y * z + 2 * x * w, 1 - 2 * x * x - 2 * y * y],
370        ],
371        dtype=np.float64,
372    )
def depth_metrics(pred: numpy.ndarray, gt: numpy.ndarray) -> Dict[str, float]:
28def depth_metrics(pred: np.ndarray, gt: np.ndarray) -> Dict[str, float]:
29    """AbsRel, RMSE, and threshold accuracy (δ<1.25) for monocular depth."""
30    pred = np.asarray(pred, dtype=np.float32)
31    gt = np.asarray(gt, dtype=np.float32)
32
33    valid = gt > 0
34    if valid.sum() == 0:
35        return {"rmse": 0.0, "absrel": 0.0, "delta1": 1.0, "delta2": 1.0, "delta3": 1.0}
36
37    pred_v, gt_v = pred[valid], gt[valid]
38    rmse = float(np.sqrt(np.mean((pred_v - gt_v) ** 2)))
39    absrel = float(np.mean(np.abs(pred_v - gt_v) / gt_v))
40
41    thresh = np.maximum(pred_v / gt_v, gt_v / pred_v)
42    delta1 = float(np.mean(thresh < 1.25))
43    delta2 = float(np.mean(thresh < 1.25**2))
44    delta3 = float(np.mean(thresh < 1.25**3))
45
46    return {"rmse": rmse, "absrel": absrel, "delta1": delta1, "delta2": delta2, "delta3": delta3}

AbsRel, RMSE, and threshold accuracy (δ<1.25) for monocular depth.

def detection_metrics( pred_boxes: numpy.ndarray, pred_scores: Sequence[float], pred_labels: Sequence[str], gt_boxes: numpy.ndarray, gt_labels: Sequence[str], iou_threshold: float = 0.5) -> Dict[str, float]:
49def detection_metrics(
50    pred_boxes: np.ndarray,
51    pred_scores: Sequence[float],
52    pred_labels: Sequence[str],
53    gt_boxes: np.ndarray,
54    gt_labels: Sequence[str],
55    iou_threshold: float = 0.5,
56) -> Dict[str, float]:
57    """Greedy matching at a single IoU threshold → precision/recall/F1."""
58    if len(pred_boxes) == 0 and len(gt_boxes) == 0:
59        return {"precision": 1.0, "recall": 1.0, "f1": 1.0}
60    if len(pred_boxes) == 0:
61        return {"precision": 0.0, "recall": 0.0, "f1": 0.0}
62
63    pred_indices = np.argsort(-np.asarray(pred_scores))
64    matched_gt: set[int] = set()
65    tp = 0
66    for idx in pred_indices:
67        box = pred_boxes[idx]
68        label = pred_labels[idx]
69        best_iou, best_j = 0.0, -1
70        for j, (gt_box, gt_label) in enumerate(zip(gt_boxes, gt_labels, strict=False)):
71            if j in matched_gt or gt_label != label:
72                continue
73            iou = bbox_iou(box, gt_box)
74            if iou > best_iou:
75                best_iou, best_j = iou, j
76        if best_iou >= iou_threshold and best_j >= 0:
77            matched_gt.add(best_j)
78            tp += 1
79    fp = len(pred_boxes) - tp
80    fn = len(gt_boxes) - tp
81    precision = tp / (tp + fp) if (tp + fp) else 0.0
82    recall = tp / (tp + fn) if (tp + fn) else 0.0
83    f1 = (2 * precision * recall) / (precision + recall) if (precision + recall) else 0.0
84    return {"precision": float(precision), "recall": float(recall), "f1": float(f1)}

Greedy matching at a single IoU threshold → precision/recall/F1.

def segmentation_metrics(pred_mask: numpy.ndarray, gt_mask: numpy.ndarray) -> Dict[str, float]:
 87def segmentation_metrics(pred_mask: np.ndarray, gt_mask: np.ndarray) -> Dict[str, float]:
 88    """Per-class IoU averaged across classes present in GT (mIoU)."""
 89    pred_mask = np.asarray(pred_mask, dtype=np.int32)
 90    gt_mask = np.asarray(gt_mask, dtype=np.int32)
 91    classes = np.unique(gt_mask)
 92    classes = classes[classes >= 0]  # ignore background = -1 if used
 93
 94    if len(classes) == 0:
 95        return {"miou": 1.0}
 96
 97    ious = []
 98    for c in classes:
 99        pred_c = pred_mask == c
100        gt_c = gt_mask == c
101        intersection = float((pred_c & gt_c).sum())
102        union = float((pred_c | gt_c).sum())
103        ious.append(intersection / union if union > 0 else 1.0)
104
105    return {"miou": float(np.mean(ious))}

Per-class IoU averaged across classes present in GT (mIoU).

def tracking_metrics( pred_tracks: Sequence, gt_tracks: Sequence, iou_threshold: float = 0.5) -> Dict[str, float]:
108def tracking_metrics(
109    pred_tracks: Sequence,
110    gt_tracks: Sequence,
111    iou_threshold: float = 0.5,
112) -> Dict[str, float]:
113    """MOTA and IDF1 for multi-object tracking (single-frame / tracklet level).
114
115    This is a simplified tracklet-level computation suitable for per-sample
116    evaluation across a video clip.  A full MOTA/IDF1 computation requires
117    frame-by-frame association across the full sequence and is typically run
118    by the PhaseStratifiedRunner over an entire scene.
119    """
120    # Build frame-level sets from Tracklet objects
121    # Each Tracklet has boxes: T x 4, so T = number of frames it covers.
122    # We evaluate over each frame index present in GT.
123
124    # Determine number of frames
125    max_t = max((t.boxes.shape[0] for t in gt_tracks), default=0)
126    if max_t == 0:
127        return {"mota": 1.0, "idf1": 1.0}
128
129    total_gt = 0
130    total_fp = 0
131    total_fn = 0
132    total_idsw = 0  # ID switches — approximated here
133
134    # id assignment tracking: pred_track_id -> last matched gt_track_id
135    pred_last_match: Dict[str, str] = {}
136
137    for t in range(max_t):
138        gt_boxes_t = []
139        gt_ids_t = []
140        for tr in gt_tracks:
141            if t < tr.boxes.shape[0]:
142                gt_boxes_t.append(tr.boxes[t])
143                gt_ids_t.append(tr.track_id)
144
145        pred_boxes_t = []
146        pred_ids_t = []
147        for tr in pred_tracks:
148            if t < tr.boxes.shape[0]:
149                pred_boxes_t.append(tr.boxes[t])
150                pred_ids_t.append(tr.track_id)
151
152        total_gt += len(gt_boxes_t)
153
154        if len(pred_boxes_t) == 0:
155            total_fn += len(gt_boxes_t)
156            continue
157
158        # Greedy matching
159        matched_gt_idx: set[int] = set()
160        for _p_idx, (p_box, p_id) in enumerate(zip(pred_boxes_t, pred_ids_t, strict=False)):
161            best_iou, best_g = 0.0, -1
162            for g_idx, g_box in enumerate(gt_boxes_t):
163                if g_idx in matched_gt_idx:
164                    continue
165                iou = bbox_iou(np.array(p_box), np.array(g_box))
166                if iou > best_iou:
167                    best_iou, best_g = iou, g_idx
168            if best_iou >= iou_threshold and best_g >= 0:
169                matched_gt_idx.add(best_g)
170                g_id = gt_ids_t[best_g]
171                if p_id in pred_last_match and pred_last_match[p_id] != g_id:
172                    total_idsw += 1
173                pred_last_match[p_id] = g_id
174
175        total_fn += len(gt_boxes_t) - len(matched_gt_idx)
176        total_fp += len(pred_boxes_t) - len(matched_gt_idx)
177
178    mota = 1.0 - (total_fn + total_fp + total_idsw) / max(total_gt, 1)
179    mota = float(np.clip(mota, -1.0, 1.0))
180
181    # IDF1: identity recall × precision harmonic mean (simplified: treat matched as IDTP)
182    total_tp = total_gt - total_fn
183    idf1 = (2 * total_tp) / max(2 * total_tp + total_fp + total_fn, 1)
184    idf1 = float(np.clip(idf1, 0.0, 1.0))
185
186    return {
187        "mota": mota,
188        "idf1": idf1,
189        "fp": float(total_fp),
190        "fn": float(total_fn),
191        "idsw": float(total_idsw),
192    }

MOTA and IDF1 for multi-object tracking (single-frame / tracklet level).

This is a simplified tracklet-level computation suitable for per-sample evaluation across a video clip. A full MOTA/IDF1 computation requires frame-by-frame association across the full sequence and is typically run by the PhaseStratifiedRunner over an entire scene.

def pose_metrics( pred_rot: numpy.ndarray, pred_trans: numpy.ndarray, gt_rot: numpy.ndarray, gt_trans: numpy.ndarray) -> Dict[str, float]:
195def pose_metrics(
196    pred_rot: np.ndarray,
197    pred_trans: np.ndarray,
198    gt_rot: np.ndarray,
199    gt_trans: np.ndarray,
200) -> Dict[str, float]:
201    """Rotation geodesic error (degrees) and translation L2 error (meters)."""
202    pred_rot = np.asarray(pred_rot, dtype=np.float64)
203    gt_rot = np.asarray(gt_rot, dtype=np.float64)
204
205    # Handle quaternion input (4-vector)
206    if pred_rot.shape == (4,):
207        pred_rot = _quat_to_rotmat(pred_rot)
208    if gt_rot.shape == (4,):
209        gt_rot = _quat_to_rotmat(gt_rot)
210
211    # Geodesic distance: arccos((trace(R^T R') - 1) / 2)
212    relative = pred_rot.T @ gt_rot
213    trace_val = float(np.trace(relative))
214    cos_angle = (trace_val - 1.0) / 2.0
215    cos_angle = np.clip(cos_angle, -1.0, 1.0)
216    rot_err_deg = float(np.degrees(np.arccos(cos_angle)))
217
218    pred_t = np.asarray(pred_trans, dtype=np.float64)
219    gt_t = np.asarray(gt_trans, dtype=np.float64)
220
221    trans_err_m = float(np.linalg.norm(pred_t - gt_t))
222
223    # Translation angular error (scale-invariant direction error).
224    # Standard metric in ScanNet-1500 / MegaDepth-1500 / MapFree.
225    n_pred = np.linalg.norm(pred_t)
226    n_gt = np.linalg.norm(gt_t)
227    if n_pred < 1e-9 or n_gt < 1e-9:
228        trans_angular_deg = 180.0
229    else:
230        cos_a = np.clip(np.dot(pred_t, gt_t) / (n_pred * n_gt), -1.0, 1.0)
231        trans_angular_deg = float(np.degrees(np.arccos(cos_a)))
232
233    pose_error_max_deg = max(rot_err_deg, trans_angular_deg)
234
235    return {
236        "rotation_error_deg": rot_err_deg,
237        "translation_error_m": trans_err_m,
238        "translation_angular_deg": trans_angular_deg,
239        "pose_error_max_deg": pose_error_max_deg,
240    }

Rotation geodesic error (degrees) and translation L2 error (meters).

def grounding_metrics( pred_boxes: numpy.ndarray, gt_boxes: numpy.ndarray, iou_threshold: float = 0.5) -> Dict[str, float]:
243def grounding_metrics(
244    pred_boxes: np.ndarray,
245    gt_boxes: np.ndarray,
246    iou_threshold: float = 0.5,
247) -> Dict[str, float]:
248    """Visual grounding: top-1 IoU and accuracy at IoU threshold.
249
250    Standard referring grounding protocol: a prediction is correct if the
251    highest-scoring predicted box has IoU ≥ threshold with any GT box.
252    """
253    if len(pred_boxes) == 0:
254        return {"grounding_iou": 0.0, "grounding_acc": 0.0}
255    if len(gt_boxes) == 0:
256        return {"grounding_iou": 1.0, "grounding_acc": 1.0}
257
258    # Evaluate the first (highest-scoring) prediction against all GT boxes
259    top_box = pred_boxes[0]
260    best_iou = max(bbox_iou(top_box, gt_box) for gt_box in gt_boxes)
261    acc = 1.0 if best_iou >= iou_threshold else 0.0
262
263    return {"grounding_iou": float(best_iou), "grounding_acc": float(acc)}

Visual grounding: top-1 IoU and accuracy at IoU threshold.

Standard referring grounding protocol: a prediction is correct if the highest-scoring predicted box has IoU ≥ threshold with any GT box.

def sparse_depth_metrics( pred_coords: numpy.ndarray, pred_depths: numpy.ndarray, gt_coords: numpy.ndarray, gt_depths: numpy.ndarray, radius: float = 2.0) -> Dict[str, float]:
266def sparse_depth_metrics(
267    pred_coords: np.ndarray,
268    pred_depths: np.ndarray,
269    gt_coords: np.ndarray,
270    gt_depths: np.ndarray,
271    radius: float = 2.0,
272) -> Dict[str, float]:
273    """AbsRel and RMSE at sparse GT pixel locations.
274
275    We match predicted depths to GT coordinates by nearest-neighbor within
276    `radius` pixels.
277    """
278    if len(gt_coords) == 0:
279        return {"sparse_absrel": 0.0, "sparse_rmse": 0.0}
280
281    abs_errors = []
282    sq_errors = []
283    for (gx, gy), gd in zip(gt_coords, gt_depths, strict=False):
284        if len(pred_coords) == 0:
285            abs_errors.append(gd)
286            sq_errors.append(gd**2)
287            continue
288        dists = np.sqrt(((pred_coords - np.array([gx, gy])) ** 2).sum(axis=1))
289        nearest = int(np.argmin(dists))
290        if dists[nearest] <= radius:
291            pd = pred_depths[nearest]
292        else:
293            pd = 0.0  # no prediction at this location
294        abs_errors.append(abs(pd - gd) / max(gd, 1e-6))
295        sq_errors.append((pd - gd) ** 2)
296
297    return {
298        "sparse_absrel": float(np.mean(abs_errors)),
299        "sparse_rmse": float(np.sqrt(np.mean(sq_errors))),
300    }

AbsRel and RMSE at sparse GT pixel locations.

We match predicted depths to GT coordinates by nearest-neighbor within radius pixels.

def keypoint_metrics( pred_p0: numpy.ndarray, pred_p1: numpy.ndarray, gt_p0: numpy.ndarray, gt_p1: numpy.ndarray, visibility: numpy.ndarray | None = None, px_threshold: float = 3.0) -> Dict[str, float]:
305def keypoint_metrics(
306    pred_p0: np.ndarray,
307    pred_p1: np.ndarray,
308    gt_p0: np.ndarray,
309    gt_p1: np.ndarray,
310    visibility: np.ndarray | None = None,
311    px_threshold: float = 3.0,
312) -> Dict[str, float]:
313    """Keypoint matching accuracy: % correspondences within px_threshold pixels.
314
315    We match each GT keypoint pair to the nearest predicted pair in image0
316    and check if the predicted point in image1 is within threshold pixels of GT.
317    """
318    if len(gt_p0) == 0:
319        return {"keypoint_acc": 1.0, "mean_match_error": 0.0}
320    if len(pred_p0) == 0:
321        return {"keypoint_acc": 0.0, "mean_match_error": float("inf")}
322
323    mask = visibility if visibility is not None else np.ones(len(gt_p0), dtype=bool)
324    gt_p0_v = gt_p0[mask]
325    gt_p1_v = gt_p1[mask]
326
327    if len(gt_p0_v) == 0:
328        return {"keypoint_acc": 1.0, "mean_match_error": 0.0}
329
330    errors = []
331    for gp0, gp1 in zip(gt_p0_v, gt_p1_v, strict=False):
332        dists0 = np.sqrt(((pred_p0 - gp0) ** 2).sum(axis=1))
333        nearest = int(np.argmin(dists0))
334        err = float(np.sqrt(((pred_p1[nearest] - gp1) ** 2).sum()))
335        errors.append(err)
336
337    errors_arr = np.array(errors)
338    acc = float(np.mean(errors_arr < px_threshold))
339    mean_err = float(np.mean(errors_arr))
340
341    return {"keypoint_acc": acc, "mean_match_error": mean_err}

Keypoint matching accuracy: % correspondences within px_threshold pixels.

We match each GT keypoint pair to the nearest predicted pair in image0 and check if the predicted point in image1 is within threshold pixels of GT.

def bbox_iou(box_a: numpy.ndarray, box_b: numpy.ndarray) -> float:
349def bbox_iou(box_a: np.ndarray, box_b: np.ndarray) -> float:
350    ax1, ay1, ax2, ay2 = box_a
351    bx1, by1, bx2, by2 = box_b
352    inter_x1, inter_y1 = max(ax1, bx1), max(ay1, by1)
353    inter_x2, inter_y2 = min(ax2, bx2), min(ay2, by2)
354    inter_w = max(0.0, inter_x2 - inter_x1)
355    inter_h = max(0.0, inter_y2 - inter_y1)
356    inter_area = inter_w * inter_h
357    area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
358    area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
359    union = area_a + area_b - inter_area
360    return float(inter_area / union) if union > 0 else 0.0