rpx_benchmark.pose_metrics

RPX-RCPE evaluation metrics — novel + standard.

Consumes per-pair results (from the runner) enriched with the metadata fields that PosePairGenerator attaches (pair_type, rotation_bin, chain_id, chain_position).

Standard

Novel (unique to RPX-RCPE)

  • Metric AUC@(θ°, d cm) — joint threshold on rotation AND metric translation L2. Only meaningful with metric-scale GT (MegaDepth can't do this).
  • Cross-phase Δ — performance drop from intra-phase to cross-phase pairs at matched rotation bins. Measures scene-change robustness.
  • Temporal drift — accumulated rotation/translation error over a chain. Measures stability for SLAM / trajectory integration.
  • Per-bin / per-type breakdowns — full disaggregation by pair type and rotation difficulty.
  1"""RPX-RCPE evaluation metrics — novel + standard.
  2
  3Consumes per-pair results (from the runner) enriched with the
  4metadata fields that :class:`PosePairGenerator` attaches
  5(``pair_type``, ``rotation_bin``, ``chain_id``, ``chain_position``).
  6
  7Standard
  8--------
  9- ``rotation_error_deg``, ``translation_error_m`` — already computed
 10  per-sample by ``rpx_benchmark.metrics.pose``.
 11- ``translation_angular_deg`` — scale-invariant direction error.
 12- ``pose_error_max_deg`` — max(rot, trans_angular).
 13- ``AUC@5° / @10° / @20°`` — standard pose AUC.
 14
 15Novel (unique to RPX-RCPE)
 16--------------------------
 17- **Metric AUC@(θ°, d cm)** — joint threshold on rotation AND metric
 18  translation L2. Only meaningful with metric-scale GT (MegaDepth
 19  can't do this).
 20- **Cross-phase Δ** — performance drop from intra-phase to cross-phase
 21  pairs at matched rotation bins. Measures scene-change robustness.
 22- **Temporal drift** — accumulated rotation/translation error over a
 23  chain. Measures stability for SLAM / trajectory integration.
 24- **Per-bin / per-type breakdowns** — full disaggregation by pair type
 25  and rotation difficulty.
 26"""
 27
 28from __future__ import annotations
 29
 30from typing import Any, Dict, List, Sequence
 31
 32import numpy as np
 33
 34CANONICAL_POSE_METRICS = ("auc_5deg", "auc_10deg", "auc_20deg")
 35PHASE_LABELS = {0: "clutter", 1: "interaction", 2: "clean"}
 36_trapezoid = getattr(np, "trapezoid", None) or np.trapz  # type: ignore[attr-defined]
 37
 38# ─────────────────────────────────────────────────────────────────────────────
 39# Per-pair error functions (pure numpy, no framework deps)
 40# ─────────────────────────────────────────────────────────────────────────────
 41
 42def rotation_error_deg(R_pred: np.ndarray, R_gt: np.ndarray) -> float:
 43    R_rel = R_pred @ R_gt.T
 44    cos_t = np.clip((np.trace(R_rel) - 1.0) / 2.0, -1.0, 1.0)
 45    return float(np.degrees(np.arccos(cos_t)))
 46
 47
 48def translation_l2(t_pred: np.ndarray, t_gt: np.ndarray) -> float:
 49    return float(np.linalg.norm(t_pred - t_gt))
 50
 51
 52def translation_angular_deg(t_pred: np.ndarray, t_gt: np.ndarray) -> float:
 53    n_pred = np.linalg.norm(t_pred)
 54    n_gt = np.linalg.norm(t_gt)
 55    if n_pred < 1e-9 or n_gt < 1e-9:
 56        return 180.0
 57    cos_a = np.clip(np.dot(t_pred, t_gt) / (n_pred * n_gt), -1.0, 1.0)
 58    return float(np.degrees(np.arccos(cos_a)))
 59
 60
 61# ─────────────────────────────────────────────────────────────────────────────
 62# AUC computation
 63# ─────────────────────────────────────────────────────────────────────────────
 64
 65def auc_at_thresholds(
 66    errors: np.ndarray,
 67    thresholds: Sequence[float] = (5.0, 10.0, 20.0),
 68) -> Dict[str, float]:
 69    """Fraction-of-pairs ≤ threshold, integrated (AUC) up to each threshold.
 70
 71    Returns ``{"auc@5": ..., "auc@10": ..., "auc@20": ...}`` etc.
 72    """
 73    errors = np.sort(errors)
 74    n = len(errors)
 75    out: Dict[str, float] = {}
 76    for th in thresholds:
 77        # Trapezoidal AUC of the recall curve from 0 to th
 78        bins = np.linspace(0, th, num=100)
 79        recall = np.array([(errors <= b).sum() / max(n, 1) for b in bins])
 80        auc = float(_trapezoid(recall, bins) / th)
 81        out[f"auc@{th:.0f}"] = auc
 82    return out
 83
 84
 85def metric_auc(
 86    rot_errors: np.ndarray,
 87    trans_errors_m: np.ndarray,
 88    rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
 89    trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0),
 90) -> Dict[str, float]:
 91    """Joint AUC: fraction of pairs with rot ≤ θ AND trans ≤ d.
 92
 93    Novel metric — only possible with metric-scale GT translation.
 94    Returns e.g. ``{"metric_auc@(5deg,5cm)": 0.32, ...}``.
 95    """
 96    out: Dict[str, float] = {}
 97    for th_r in rot_thresholds:
 98        for th_t in trans_thresholds_cm:
 99            th_t_m = th_t / 100.0
100            frac = float(np.mean(
101                (rot_errors <= th_r) & (trans_errors_m <= th_t_m)
102            ))
103            out[f"metric_auc@({th_r:.0f}deg,{th_t:.0f}cm)"] = frac
104    return out
105
106
107def metric_auc_curve(
108    rot_errors_deg: np.ndarray,
109    trans_errors_m: np.ndarray,
110    max_rotation_deg: float = 20.0,
111    max_translation_cm: float = 20.0,
112) -> float:
113    """Joint rotation/metric-translation AUC used as the paper's M-AUC.
114
115    At curve position ``u`` a pair succeeds only when both
116    ``rotation <= u*max_rotation_deg`` and
117    ``translation <= u*max_translation_cm``. Integrating joint recall over
118    ``u in [0, 1]`` produces one bounded higher-is-better number.
119    """
120    rot = np.asarray(rot_errors_deg, dtype=np.float64)
121    trans_cm = np.asarray(trans_errors_m, dtype=np.float64) * 100.0
122    valid = np.isfinite(rot) & np.isfinite(trans_cm)
123    if not np.any(valid):
124        return 0.0
125    u = np.linspace(0.0, 1.0, 101)
126    recall = np.array([
127        np.mean(
128            (rot[valid] <= value * max_rotation_deg)
129            & (trans_cm[valid] <= value * max_translation_cm)
130        )
131        for value in u
132    ])
133    return float(_trapezoid(recall, u))
134
135
136def canonical_metric_vector(
137    per_pair: List[Dict[str, Any]],
138    *,
139    metric_translation_available: bool = True,
140) -> Dict[str, float | None]:
141    """Return the active D6 K=3 angular vector for one pose cell.
142
143    The three angular AUCs use ``max(rotation error, translation-direction
144    error)``. Metric-translation M-AUC is intentionally excluded from the
145    active protocol so metric- and up-to-scale adapters remain comparable.
146    """
147    valid = [
148        row for row in per_pair
149        if np.isfinite(row.get("pose_error_max_deg", np.nan))
150    ]
151    if not valid:
152        return {key: None for key in CANONICAL_POSE_METRICS}
153    pose_error = np.asarray(
154        [row["pose_error_max_deg"] for row in valid], dtype=np.float64
155    )
156    auc = auc_at_thresholds(pose_error, (5.0, 10.0, 20.0))
157    values: Dict[str, float | None] = {
158        "auc_5deg": auc["auc@5"],
159        "auc_10deg": auc["auc@10"],
160        "auc_20deg": auc["auc@20"],
161    }
162    return values
163
164
165def build_rcpe_cells(
166    per_pair: List[Dict[str, Any]],
167    *,
168    model_name: str,
169    difficulty: str,
170    metric_translation_available: bool,
171) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
172    """Build canonical per-(scene, phase) D6 cells from intra-phase pairs.
173
174    Cross-phase pairs and temporal chains are diagnostics and intentionally
175    do not enter the phase MANOVA/JEDI cells. Their results remain in
176    ``cross_phase_delta`` and ``temporal_drift`` respectively.
177    """
178    from .cell_log import cell_from_metrics
179
180    buckets: Dict[tuple[str, int], List[Dict[str, Any]]] = {}
181    for row in per_pair:
182        if row.get("pair_type") != "intra_phase":
183            continue
184        try:
185            phase = int(row.get("phase"))
186        except (TypeError, ValueError):
187            continue
188        if phase not in PHASE_LABELS:
189            continue
190        scene = str(row.get("scene_id") or "")
191        if not scene:
192            continue
193        buckets.setdefault((scene, phase), []).append(row)
194
195    cells: List[Dict[str, Any]] = []
196    analysis_rows: List[Dict[str, Any]] = []
197    for (scene, phase), rows in sorted(buckets.items()):
198        metrics = canonical_metric_vector(
199            rows,
200            metric_translation_available=metric_translation_available,
201        )
202        phase_label = PHASE_LABELS[phase]
203        cells.append(cell_from_metrics(
204            metrics,
205            model_name=model_name,
206            task="relative_camera_pose",
207            scene_id=scene,
208            phase=phase_label,
209            n_samples=len(rows),
210            difficulty=difficulty,
211            metric_keys=CANONICAL_POSE_METRICS,
212        ))
213        analysis_rows.append({
214            "id": f"{scene}_{phase_label}",
215            "scene": scene,
216            "phase": phase_label,
217            "difficulty": difficulty,
218            **metrics,
219        })
220    return cells, analysis_rows
221
222
223# ─────────────────────────────────────────────────────────────────────────────
224# Disaggregated breakdowns
225# ─────────────────────────────────────────────────────────────────────────────
226
227def _group_mean(
228    per_pair: List[Dict[str, Any]],
229    group_key: str,
230    metric_keys: Sequence[str],
231) -> Dict[str, Dict[str, float]]:
232    """Group per-pair results by ``group_key`` and compute means."""
233    groups: Dict[str, List[Dict]] = {}
234    for p in per_pair:
235        g = str(p.get(group_key, "unknown"))
236        groups.setdefault(g, []).append(p)
237
238    out: Dict[str, Dict[str, float]] = {}
239    for g, items in sorted(groups.items()):
240        means: Dict[str, float] = {"n_pairs": float(len(items))}
241        for k in metric_keys:
242            vals = [item[k] for item in items if k in item]
243            if vals:
244                means[k] = float(np.mean(vals))
245        out[g] = means
246    return out
247
248
249def per_bin_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
250    """Aggregate by rotation_bin (easy / medium / hard / extreme)."""
251    return _group_mean(per_pair, "rotation_bin", [
252        "rotation_error_deg", "translation_error_m",
253        "translation_angular_deg", "pose_error_max_deg",
254    ])
255
256
257def per_type_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
258    """Aggregate by pair_type (intra_phase / cross_phase / temporal_chain)."""
259    return _group_mean(per_pair, "pair_type", [
260        "rotation_error_deg", "translation_error_m",
261        "translation_angular_deg", "pose_error_max_deg",
262    ])
263
264
265# ─────────────────────────────────────────────────────────────────────────────
266# Cross-phase Δ
267# ─────────────────────────────────────────────────────────────────────────────
268
269def cross_phase_delta(
270    per_pair: List[Dict[str, Any]],
271    metric_key: str = "rotation_error_deg",
272) -> Dict[str, float]:
273    """Performance drop from intra-phase to cross-phase at matched bins.
274
275    Returns ``{"delta_easy": ..., "delta_medium": ..., ...}`` where
276    delta = mean(cross) - mean(intra) for the same rotation bin.
277    Positive = cross-phase is harder (expected).
278    """
279    intra: Dict[str, List[float]] = {}
280    cross: Dict[str, List[float]] = {}
281    for p in per_pair:
282        pt = p.get("pair_type")
283        rb = p.get("rotation_bin")
284        if rb is None or metric_key not in p:
285            continue
286        val = p[metric_key]
287        if pt == "intra_phase":
288            intra.setdefault(rb, []).append(val)
289        elif pt == "cross_phase":
290            cross.setdefault(rb, []).append(val)
291
292    out: Dict[str, float] = {}
293    for bin_name in ("easy", "medium", "hard", "extreme"):
294        if bin_name in intra and bin_name in cross:
295            delta = float(np.mean(cross[bin_name]) - np.mean(intra[bin_name]))
296            out[f"delta_{bin_name}"] = delta
297    if intra and cross:
298        all_intra = [v for vs in intra.values() for v in vs]
299        all_cross = [v for vs in cross.values() for v in vs]
300        out["delta_overall"] = float(np.mean(all_cross) - np.mean(all_intra))
301    return out
302
303
304def cross_phase_auc_delta(
305    per_pair: List[Dict[str, Any]],
306    thresholds: Sequence[float] = (5.0, 10.0, 20.0),
307) -> Dict[str, float]:
308    """Matched-bin AUC loss from intra-phase to Clutter/Clean pairs.
309
310    Positive means cross-phase is worse. The overall value is the mean of
311    available bin/threshold deltas, so lower is better as in the paper table.
312    """
313    deltas: Dict[str, float] = {}
314    all_values = []
315    for bin_name in ("easy", "medium", "hard", "extreme"):
316        intra = [
317            float(row["pose_error_max_deg"])
318            for row in per_pair
319            if row.get("pair_type") == "intra_phase"
320            and row.get("rotation_bin") == bin_name
321            and np.isfinite(row.get("pose_error_max_deg", np.nan))
322        ]
323        cross = [
324            float(row["pose_error_max_deg"])
325            for row in per_pair
326            if row.get("pair_type") == "cross_phase"
327            and row.get("rotation_bin") == bin_name
328            and np.isfinite(row.get("pose_error_max_deg", np.nan))
329        ]
330        if not intra or not cross:
331            continue
332        intra_auc = auc_at_thresholds(np.asarray(intra), thresholds)
333        cross_auc = auc_at_thresholds(np.asarray(cross), thresholds)
334        for threshold in thresholds:
335            key = f"auc@{threshold:.0f}"
336            value = float(intra_auc[key] - cross_auc[key])
337            deltas[f"delta_{bin_name}_{key}"] = value
338            all_values.append(value)
339    if all_values:
340        deltas["delta_overall"] = float(np.mean(all_values))
341    return deltas
342
343
344# ─────────────────────────────────────────────────────────────────────────────
345# Temporal drift
346# ─────────────────────────────────────────────────────────────────────────────
347
348def temporal_drift(
349    per_pair: List[Dict[str, Any]], *, metric_translation_available: bool = True
350) -> Dict[str, Any]:
351    """Accumulated error along temporal chains.
352
353    Groups chain pairs by ``chain_id``, orders by ``chain_position``,
354    and computes cumulative rotation + translation error.
355
356    Returns ``{"chains": [...], "mean_drift_rot_deg": ...,
357    "mean_drift_trans_m": ...}``.
358    """
359    chains: Dict[str, List[Dict]] = {}
360    for p in per_pair:
361        if p.get("pair_type") != "temporal_chain":
362            continue
363        cid = (p.get("metadata") or p).get("chain_id")
364        if cid is None:
365            continue
366        chains.setdefault(cid, []).append(p)
367
368    chain_results = []
369    for cid, pairs in sorted(chains.items()):
370        pairs.sort(key=lambda x: (x.get("metadata") or x).get("chain_position", 0))
371        pred_total = np.eye(4, dtype=np.float64)
372        gt_total = np.eye(4, dtype=np.float64)
373        legacy_cum_rot = 0.0
374        legacy_cum_trans = 0.0
375        steps = []
376        for p in pairs:
377            if all(
378                key in p
379                for key in (
380                    "pred_rotation", "pred_translation",
381                    "gt_rotation", "gt_translation",
382                )
383            ):
384                pred_step = np.eye(4, dtype=np.float64)
385                gt_step = np.eye(4, dtype=np.float64)
386                pred_step[:3, :3] = np.asarray(p["pred_rotation"])
387                pred_step[:3, 3] = np.asarray(p["pred_translation"])
388                gt_step[:3, :3] = np.asarray(p["gt_rotation"])
389                gt_step[:3, 3] = np.asarray(p["gt_translation"])
390                pred_total = pred_total @ pred_step
391                gt_total = gt_total @ gt_step
392                cum_rot = rotation_error_deg(pred_total[:3, :3], gt_total[:3, :3])
393                cum_trans = translation_l2(pred_total[:3, 3], gt_total[:3, 3])
394            else:
395                legacy_cum_rot += p.get("rotation_error_deg", 0.0)
396                legacy_cum_trans += p.get("translation_error_m", 0.0)
397                cum_rot = legacy_cum_rot
398                cum_trans = legacy_cum_trans
399            steps.append({
400                "position": (p.get("metadata") or p).get("chain_position", 0),
401                "rotation_error_deg": p.get("rotation_error_deg", 0.0),
402                "translation_error_m": (
403                    p.get("translation_error_m", 0.0)
404                    if metric_translation_available else None
405                ),
406                "cumulative_rotation_error": cum_rot,
407                "cumulative_translation_error": (
408                    cum_trans if metric_translation_available else None
409                ),
410            })
411        chain_results.append({
412            "chain_id": cid,
413            "length": len(pairs),
414            "total_drift_rot_deg": cum_rot,
415            "total_drift_trans_m": cum_trans if metric_translation_available else None,
416            "steps": steps,
417        })
418
419    if not chain_results:
420        return {
421            "chains": [],
422            "mean_drift_rot_deg": 0.0,
423            "mean_drift_trans_m": 0.0 if metric_translation_available else None,
424        }
425
426    return {
427        "chains": chain_results,
428        "mean_drift_rot_deg": float(np.mean([c["total_drift_rot_deg"] for c in chain_results])),
429        "mean_drift_trans_m": (
430            float(np.mean([c["total_drift_trans_m"] for c in chain_results]))
431            if metric_translation_available else None
432        ),
433        "n_chains": len(chain_results),
434    }
435
436
437# ─────────────────────────────────────────────────────────────────────────────
438# Full evaluation — the one function users call
439# ─────────────────────────────────────────────────────────────────────────────
440
441def evaluate_rcpe(
442    per_pair: List[Dict[str, Any]],
443    rot_auc_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
444    metric_rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
445    metric_trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0),
446    metric_translation_available: bool = True,
447) -> Dict[str, Any]:
448    """Compute the full RPX-RCPE metric basket from per-pair results.
449
450    Parameters
451    ----------
452    per_pair : list of dict
453        Each dict must contain at minimum ``rotation_error_deg`` and
454        ``translation_error_m``.  Should also carry ``pair_type``,
455        ``rotation_bin``, ``chain_id``, ``chain_position`` from the
456        PosePairGenerator metadata for the novel metrics to fire.
457
458    Returns
459    -------
460    dict with keys:
461        ``aggregated`` — overall means.
462        ``standard_auc`` — AUC@5°/10°/20° on pose_error_max_deg.
463        ``metric_auc`` — null with status ``skipped_by_protocol``.
464        ``per_bin`` — breakdown by rotation difficulty.
465        ``per_type`` — breakdown by pair type (intra/cross/temporal).
466        ``cross_phase_delta`` — perf drop intra→cross (novel).
467        ``temporal_drift`` — accumulated error over chains (novel).
468    """
469    if not per_pair:
470        return {}
471
472    # Ensure derived per-pair fields exist.  The runner now emits
473    # translation_angular_deg and pose_error_max_deg directly; this
474    # fallback handles legacy per-pair dicts that only carry the
475    # two original scalar errors.
476    for p in per_pair:
477        if "translation_angular_deg" not in p:
478            p["translation_angular_deg"] = float("nan")
479        if "pose_error_max_deg" not in p:
480            ta = p.get("translation_angular_deg", 0.0)
481            if np.isnan(ta):
482                ta = 0.0
483            p["pose_error_max_deg"] = max(
484                p.get("rotation_error_deg", 0.0), ta,
485            )
486
487    # Headline angular AUC values are defined on exact-gap intra-phase pairs.
488    # Cross-phase pairs and temporal chains have separate diagnostics.
489    headline_rows = [p for p in per_pair if p.get("pair_type") == "intra_phase"]
490    if not headline_rows:  # backwards compatibility for legacy manifests
491        headline_rows = per_pair
492    rot_errs = np.array([p["rotation_error_deg"] for p in headline_rows])
493    trans_errs = np.array([p["translation_error_m"] for p in headline_rows])
494    pose_max = np.array([p["pose_error_max_deg"] for p in headline_rows])
495    canonical = canonical_metric_vector(
496        headline_rows,
497        metric_translation_available=metric_translation_available,
498    )
499
500    per_bin = per_bin_breakdown(per_pair)
501    per_type = per_type_breakdown(per_pair)
502    if not metric_translation_available:
503        for breakdown in (per_bin, per_type):
504            for values in breakdown.values():
505                values.pop("translation_error_m", None)
506
507    return {
508        "n_pairs": len(per_pair),
509        "n_headline_intra_pairs": len(headline_rows),
510        "metric_translation_available": metric_translation_available,
511        "aggregated": {
512            "rotation_error_deg": float(np.mean(rot_errs)),
513            "rotation_error_deg_median": float(np.median(rot_errs)),
514            "translation_error_m": (
515                float(np.mean(trans_errs)) if metric_translation_available else None
516            ),
517            "translation_error_m_median": (
518                float(np.median(trans_errs)) if metric_translation_available else None
519            ),
520            "pose_error_max_deg": float(np.mean(pose_max)),
521        },
522        "standard_auc": auc_at_thresholds(pose_max, rot_auc_thresholds),
523        "canonical": canonical,
524        "m_auc": None,
525        "metric_auc": None,
526        "metric_auc_status": "skipped_by_protocol",
527        "per_bin": per_bin,
528        "per_type": per_type,
529        "cross_phase_delta": cross_phase_auc_delta(per_pair, rot_auc_thresholds),
530        "cross_phase_error_delta": cross_phase_delta(per_pair, "rotation_error_deg"),
531        "cross_phase_delta_trans": (
532            cross_phase_delta(per_pair, "translation_error_m")
533            if metric_translation_available else {}
534        ),
535        "temporal_drift": temporal_drift(
536            per_pair, metric_translation_available=metric_translation_available
537        ),
538    }
CANONICAL_POSE_METRICS = ('auc_5deg', 'auc_10deg', 'auc_20deg')
PHASE_LABELS = {0: 'clutter', 1: 'interaction', 2: 'clean'}
def rotation_error_deg(R_pred: numpy.ndarray, R_gt: numpy.ndarray) -> float:
43def rotation_error_deg(R_pred: np.ndarray, R_gt: np.ndarray) -> float:
44    R_rel = R_pred @ R_gt.T
45    cos_t = np.clip((np.trace(R_rel) - 1.0) / 2.0, -1.0, 1.0)
46    return float(np.degrees(np.arccos(cos_t)))
def translation_l2(t_pred: numpy.ndarray, t_gt: numpy.ndarray) -> float:
49def translation_l2(t_pred: np.ndarray, t_gt: np.ndarray) -> float:
50    return float(np.linalg.norm(t_pred - t_gt))
def translation_angular_deg(t_pred: numpy.ndarray, t_gt: numpy.ndarray) -> float:
53def translation_angular_deg(t_pred: np.ndarray, t_gt: np.ndarray) -> float:
54    n_pred = np.linalg.norm(t_pred)
55    n_gt = np.linalg.norm(t_gt)
56    if n_pred < 1e-9 or n_gt < 1e-9:
57        return 180.0
58    cos_a = np.clip(np.dot(t_pred, t_gt) / (n_pred * n_gt), -1.0, 1.0)
59    return float(np.degrees(np.arccos(cos_a)))
def auc_at_thresholds( errors: numpy.ndarray, thresholds: Sequence[float] = (5.0, 10.0, 20.0)) -> Dict[str, float]:
66def auc_at_thresholds(
67    errors: np.ndarray,
68    thresholds: Sequence[float] = (5.0, 10.0, 20.0),
69) -> Dict[str, float]:
70    """Fraction-of-pairs ≤ threshold, integrated (AUC) up to each threshold.
71
72    Returns ``{"auc@5": ..., "auc@10": ..., "auc@20": ...}`` etc.
73    """
74    errors = np.sort(errors)
75    n = len(errors)
76    out: Dict[str, float] = {}
77    for th in thresholds:
78        # Trapezoidal AUC of the recall curve from 0 to th
79        bins = np.linspace(0, th, num=100)
80        recall = np.array([(errors <= b).sum() / max(n, 1) for b in bins])
81        auc = float(_trapezoid(recall, bins) / th)
82        out[f"auc@{th:.0f}"] = auc
83    return out

Fraction-of-pairs ≤ threshold, integrated (AUC) up to each threshold.

Returns {"auc@5": ..., "auc@10": ..., "auc@20": ...} etc.

def metric_auc( rot_errors: numpy.ndarray, trans_errors_m: numpy.ndarray, rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0), trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0)) -> Dict[str, float]:
 86def metric_auc(
 87    rot_errors: np.ndarray,
 88    trans_errors_m: np.ndarray,
 89    rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
 90    trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0),
 91) -> Dict[str, float]:
 92    """Joint AUC: fraction of pairs with rot ≤ θ AND trans ≤ d.
 93
 94    Novel metric — only possible with metric-scale GT translation.
 95    Returns e.g. ``{"metric_auc@(5deg,5cm)": 0.32, ...}``.
 96    """
 97    out: Dict[str, float] = {}
 98    for th_r in rot_thresholds:
 99        for th_t in trans_thresholds_cm:
100            th_t_m = th_t / 100.0
101            frac = float(np.mean(
102                (rot_errors <= th_r) & (trans_errors_m <= th_t_m)
103            ))
104            out[f"metric_auc@({th_r:.0f}deg,{th_t:.0f}cm)"] = frac
105    return out

Joint AUC: fraction of pairs with rot ≤ θ AND trans ≤ d.

Novel metric — only possible with metric-scale GT translation. Returns e.g. {"metric_auc@(5deg,5cm)": 0.32, ...}.

def metric_auc_curve( rot_errors_deg: numpy.ndarray, trans_errors_m: numpy.ndarray, max_rotation_deg: float = 20.0, max_translation_cm: float = 20.0) -> float:
108def metric_auc_curve(
109    rot_errors_deg: np.ndarray,
110    trans_errors_m: np.ndarray,
111    max_rotation_deg: float = 20.0,
112    max_translation_cm: float = 20.0,
113) -> float:
114    """Joint rotation/metric-translation AUC used as the paper's M-AUC.
115
116    At curve position ``u`` a pair succeeds only when both
117    ``rotation <= u*max_rotation_deg`` and
118    ``translation <= u*max_translation_cm``. Integrating joint recall over
119    ``u in [0, 1]`` produces one bounded higher-is-better number.
120    """
121    rot = np.asarray(rot_errors_deg, dtype=np.float64)
122    trans_cm = np.asarray(trans_errors_m, dtype=np.float64) * 100.0
123    valid = np.isfinite(rot) & np.isfinite(trans_cm)
124    if not np.any(valid):
125        return 0.0
126    u = np.linspace(0.0, 1.0, 101)
127    recall = np.array([
128        np.mean(
129            (rot[valid] <= value * max_rotation_deg)
130            & (trans_cm[valid] <= value * max_translation_cm)
131        )
132        for value in u
133    ])
134    return float(_trapezoid(recall, u))

Joint rotation/metric-translation AUC used as the paper's M-AUC.

At curve position u a pair succeeds only when both rotation <= u*max_rotation_deg and translation <= u*max_translation_cm. Integrating joint recall over u in [0, 1] produces one bounded higher-is-better number.

def canonical_metric_vector( per_pair: List[Dict[str, Any]], *, metric_translation_available: bool = True) -> Dict[str, float | None]:
137def canonical_metric_vector(
138    per_pair: List[Dict[str, Any]],
139    *,
140    metric_translation_available: bool = True,
141) -> Dict[str, float | None]:
142    """Return the active D6 K=3 angular vector for one pose cell.
143
144    The three angular AUCs use ``max(rotation error, translation-direction
145    error)``. Metric-translation M-AUC is intentionally excluded from the
146    active protocol so metric- and up-to-scale adapters remain comparable.
147    """
148    valid = [
149        row for row in per_pair
150        if np.isfinite(row.get("pose_error_max_deg", np.nan))
151    ]
152    if not valid:
153        return {key: None for key in CANONICAL_POSE_METRICS}
154    pose_error = np.asarray(
155        [row["pose_error_max_deg"] for row in valid], dtype=np.float64
156    )
157    auc = auc_at_thresholds(pose_error, (5.0, 10.0, 20.0))
158    values: Dict[str, float | None] = {
159        "auc_5deg": auc["auc@5"],
160        "auc_10deg": auc["auc@10"],
161        "auc_20deg": auc["auc@20"],
162    }
163    return values

Return the active D6 K=3 angular vector for one pose cell.

The three angular AUCs use max(rotation error, translation-direction error). Metric-translation M-AUC is intentionally excluded from the active protocol so metric- and up-to-scale adapters remain comparable.

def build_rcpe_cells( per_pair: List[Dict[str, Any]], *, model_name: str, difficulty: str, metric_translation_available: bool) -> tuple[typing.List[typing.Dict[str, typing.Any]], typing.List[typing.Dict[str, typing.Any]]]:
166def build_rcpe_cells(
167    per_pair: List[Dict[str, Any]],
168    *,
169    model_name: str,
170    difficulty: str,
171    metric_translation_available: bool,
172) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
173    """Build canonical per-(scene, phase) D6 cells from intra-phase pairs.
174
175    Cross-phase pairs and temporal chains are diagnostics and intentionally
176    do not enter the phase MANOVA/JEDI cells. Their results remain in
177    ``cross_phase_delta`` and ``temporal_drift`` respectively.
178    """
179    from .cell_log import cell_from_metrics
180
181    buckets: Dict[tuple[str, int], List[Dict[str, Any]]] = {}
182    for row in per_pair:
183        if row.get("pair_type") != "intra_phase":
184            continue
185        try:
186            phase = int(row.get("phase"))
187        except (TypeError, ValueError):
188            continue
189        if phase not in PHASE_LABELS:
190            continue
191        scene = str(row.get("scene_id") or "")
192        if not scene:
193            continue
194        buckets.setdefault((scene, phase), []).append(row)
195
196    cells: List[Dict[str, Any]] = []
197    analysis_rows: List[Dict[str, Any]] = []
198    for (scene, phase), rows in sorted(buckets.items()):
199        metrics = canonical_metric_vector(
200            rows,
201            metric_translation_available=metric_translation_available,
202        )
203        phase_label = PHASE_LABELS[phase]
204        cells.append(cell_from_metrics(
205            metrics,
206            model_name=model_name,
207            task="relative_camera_pose",
208            scene_id=scene,
209            phase=phase_label,
210            n_samples=len(rows),
211            difficulty=difficulty,
212            metric_keys=CANONICAL_POSE_METRICS,
213        ))
214        analysis_rows.append({
215            "id": f"{scene}_{phase_label}",
216            "scene": scene,
217            "phase": phase_label,
218            "difficulty": difficulty,
219            **metrics,
220        })
221    return cells, analysis_rows

Build canonical per-(scene, phase) D6 cells from intra-phase pairs.

Cross-phase pairs and temporal chains are diagnostics and intentionally do not enter the phase MANOVA/JEDI cells. Their results remain in cross_phase_delta and temporal_drift respectively.

def per_bin_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
250def per_bin_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
251    """Aggregate by rotation_bin (easy / medium / hard / extreme)."""
252    return _group_mean(per_pair, "rotation_bin", [
253        "rotation_error_deg", "translation_error_m",
254        "translation_angular_deg", "pose_error_max_deg",
255    ])

Aggregate by rotation_bin (easy / medium / hard / extreme).

def per_type_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
258def per_type_breakdown(per_pair: List[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
259    """Aggregate by pair_type (intra_phase / cross_phase / temporal_chain)."""
260    return _group_mean(per_pair, "pair_type", [
261        "rotation_error_deg", "translation_error_m",
262        "translation_angular_deg", "pose_error_max_deg",
263    ])

Aggregate by pair_type (intra_phase / cross_phase / temporal_chain).

def cross_phase_delta( per_pair: List[Dict[str, Any]], metric_key: str = 'rotation_error_deg') -> Dict[str, float]:
270def cross_phase_delta(
271    per_pair: List[Dict[str, Any]],
272    metric_key: str = "rotation_error_deg",
273) -> Dict[str, float]:
274    """Performance drop from intra-phase to cross-phase at matched bins.
275
276    Returns ``{"delta_easy": ..., "delta_medium": ..., ...}`` where
277    delta = mean(cross) - mean(intra) for the same rotation bin.
278    Positive = cross-phase is harder (expected).
279    """
280    intra: Dict[str, List[float]] = {}
281    cross: Dict[str, List[float]] = {}
282    for p in per_pair:
283        pt = p.get("pair_type")
284        rb = p.get("rotation_bin")
285        if rb is None or metric_key not in p:
286            continue
287        val = p[metric_key]
288        if pt == "intra_phase":
289            intra.setdefault(rb, []).append(val)
290        elif pt == "cross_phase":
291            cross.setdefault(rb, []).append(val)
292
293    out: Dict[str, float] = {}
294    for bin_name in ("easy", "medium", "hard", "extreme"):
295        if bin_name in intra and bin_name in cross:
296            delta = float(np.mean(cross[bin_name]) - np.mean(intra[bin_name]))
297            out[f"delta_{bin_name}"] = delta
298    if intra and cross:
299        all_intra = [v for vs in intra.values() for v in vs]
300        all_cross = [v for vs in cross.values() for v in vs]
301        out["delta_overall"] = float(np.mean(all_cross) - np.mean(all_intra))
302    return out

Performance drop from intra-phase to cross-phase at matched bins.

Returns {"delta_easy": ..., "delta_medium": ..., ...} where delta = mean(cross) - mean(intra) for the same rotation bin. Positive = cross-phase is harder (expected).

def cross_phase_auc_delta( per_pair: List[Dict[str, Any]], thresholds: Sequence[float] = (5.0, 10.0, 20.0)) -> Dict[str, float]:
305def cross_phase_auc_delta(
306    per_pair: List[Dict[str, Any]],
307    thresholds: Sequence[float] = (5.0, 10.0, 20.0),
308) -> Dict[str, float]:
309    """Matched-bin AUC loss from intra-phase to Clutter/Clean pairs.
310
311    Positive means cross-phase is worse. The overall value is the mean of
312    available bin/threshold deltas, so lower is better as in the paper table.
313    """
314    deltas: Dict[str, float] = {}
315    all_values = []
316    for bin_name in ("easy", "medium", "hard", "extreme"):
317        intra = [
318            float(row["pose_error_max_deg"])
319            for row in per_pair
320            if row.get("pair_type") == "intra_phase"
321            and row.get("rotation_bin") == bin_name
322            and np.isfinite(row.get("pose_error_max_deg", np.nan))
323        ]
324        cross = [
325            float(row["pose_error_max_deg"])
326            for row in per_pair
327            if row.get("pair_type") == "cross_phase"
328            and row.get("rotation_bin") == bin_name
329            and np.isfinite(row.get("pose_error_max_deg", np.nan))
330        ]
331        if not intra or not cross:
332            continue
333        intra_auc = auc_at_thresholds(np.asarray(intra), thresholds)
334        cross_auc = auc_at_thresholds(np.asarray(cross), thresholds)
335        for threshold in thresholds:
336            key = f"auc@{threshold:.0f}"
337            value = float(intra_auc[key] - cross_auc[key])
338            deltas[f"delta_{bin_name}_{key}"] = value
339            all_values.append(value)
340    if all_values:
341        deltas["delta_overall"] = float(np.mean(all_values))
342    return deltas

Matched-bin AUC loss from intra-phase to Clutter/Clean pairs.

Positive means cross-phase is worse. The overall value is the mean of available bin/threshold deltas, so lower is better as in the paper table.

def temporal_drift( per_pair: List[Dict[str, Any]], *, metric_translation_available: bool = True) -> Dict[str, Any]:
349def temporal_drift(
350    per_pair: List[Dict[str, Any]], *, metric_translation_available: bool = True
351) -> Dict[str, Any]:
352    """Accumulated error along temporal chains.
353
354    Groups chain pairs by ``chain_id``, orders by ``chain_position``,
355    and computes cumulative rotation + translation error.
356
357    Returns ``{"chains": [...], "mean_drift_rot_deg": ...,
358    "mean_drift_trans_m": ...}``.
359    """
360    chains: Dict[str, List[Dict]] = {}
361    for p in per_pair:
362        if p.get("pair_type") != "temporal_chain":
363            continue
364        cid = (p.get("metadata") or p).get("chain_id")
365        if cid is None:
366            continue
367        chains.setdefault(cid, []).append(p)
368
369    chain_results = []
370    for cid, pairs in sorted(chains.items()):
371        pairs.sort(key=lambda x: (x.get("metadata") or x).get("chain_position", 0))
372        pred_total = np.eye(4, dtype=np.float64)
373        gt_total = np.eye(4, dtype=np.float64)
374        legacy_cum_rot = 0.0
375        legacy_cum_trans = 0.0
376        steps = []
377        for p in pairs:
378            if all(
379                key in p
380                for key in (
381                    "pred_rotation", "pred_translation",
382                    "gt_rotation", "gt_translation",
383                )
384            ):
385                pred_step = np.eye(4, dtype=np.float64)
386                gt_step = np.eye(4, dtype=np.float64)
387                pred_step[:3, :3] = np.asarray(p["pred_rotation"])
388                pred_step[:3, 3] = np.asarray(p["pred_translation"])
389                gt_step[:3, :3] = np.asarray(p["gt_rotation"])
390                gt_step[:3, 3] = np.asarray(p["gt_translation"])
391                pred_total = pred_total @ pred_step
392                gt_total = gt_total @ gt_step
393                cum_rot = rotation_error_deg(pred_total[:3, :3], gt_total[:3, :3])
394                cum_trans = translation_l2(pred_total[:3, 3], gt_total[:3, 3])
395            else:
396                legacy_cum_rot += p.get("rotation_error_deg", 0.0)
397                legacy_cum_trans += p.get("translation_error_m", 0.0)
398                cum_rot = legacy_cum_rot
399                cum_trans = legacy_cum_trans
400            steps.append({
401                "position": (p.get("metadata") or p).get("chain_position", 0),
402                "rotation_error_deg": p.get("rotation_error_deg", 0.0),
403                "translation_error_m": (
404                    p.get("translation_error_m", 0.0)
405                    if metric_translation_available else None
406                ),
407                "cumulative_rotation_error": cum_rot,
408                "cumulative_translation_error": (
409                    cum_trans if metric_translation_available else None
410                ),
411            })
412        chain_results.append({
413            "chain_id": cid,
414            "length": len(pairs),
415            "total_drift_rot_deg": cum_rot,
416            "total_drift_trans_m": cum_trans if metric_translation_available else None,
417            "steps": steps,
418        })
419
420    if not chain_results:
421        return {
422            "chains": [],
423            "mean_drift_rot_deg": 0.0,
424            "mean_drift_trans_m": 0.0 if metric_translation_available else None,
425        }
426
427    return {
428        "chains": chain_results,
429        "mean_drift_rot_deg": float(np.mean([c["total_drift_rot_deg"] for c in chain_results])),
430        "mean_drift_trans_m": (
431            float(np.mean([c["total_drift_trans_m"] for c in chain_results]))
432            if metric_translation_available else None
433        ),
434        "n_chains": len(chain_results),
435    }

Accumulated error along temporal chains.

Groups chain pairs by chain_id, orders by chain_position, and computes cumulative rotation + translation error.

Returns {"chains": [...], "mean_drift_rot_deg": ..., "mean_drift_trans_m": ...}.

def evaluate_rcpe( per_pair: List[Dict[str, Any]], rot_auc_thresholds: Sequence[float] = (5.0, 10.0, 20.0), metric_rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0), metric_trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0), metric_translation_available: bool = True) -> Dict[str, Any]:
442def evaluate_rcpe(
443    per_pair: List[Dict[str, Any]],
444    rot_auc_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
445    metric_rot_thresholds: Sequence[float] = (5.0, 10.0, 20.0),
446    metric_trans_thresholds_cm: Sequence[float] = (5.0, 10.0, 20.0),
447    metric_translation_available: bool = True,
448) -> Dict[str, Any]:
449    """Compute the full RPX-RCPE metric basket from per-pair results.
450
451    Parameters
452    ----------
453    per_pair : list of dict
454        Each dict must contain at minimum ``rotation_error_deg`` and
455        ``translation_error_m``.  Should also carry ``pair_type``,
456        ``rotation_bin``, ``chain_id``, ``chain_position`` from the
457        PosePairGenerator metadata for the novel metrics to fire.
458
459    Returns
460    -------
461    dict with keys:
462        ``aggregated`` — overall means.
463        ``standard_auc`` — AUC@5°/10°/20° on pose_error_max_deg.
464        ``metric_auc`` — null with status ``skipped_by_protocol``.
465        ``per_bin`` — breakdown by rotation difficulty.
466        ``per_type`` — breakdown by pair type (intra/cross/temporal).
467        ``cross_phase_delta`` — perf drop intra→cross (novel).
468        ``temporal_drift`` — accumulated error over chains (novel).
469    """
470    if not per_pair:
471        return {}
472
473    # Ensure derived per-pair fields exist.  The runner now emits
474    # translation_angular_deg and pose_error_max_deg directly; this
475    # fallback handles legacy per-pair dicts that only carry the
476    # two original scalar errors.
477    for p in per_pair:
478        if "translation_angular_deg" not in p:
479            p["translation_angular_deg"] = float("nan")
480        if "pose_error_max_deg" not in p:
481            ta = p.get("translation_angular_deg", 0.0)
482            if np.isnan(ta):
483                ta = 0.0
484            p["pose_error_max_deg"] = max(
485                p.get("rotation_error_deg", 0.0), ta,
486            )
487
488    # Headline angular AUC values are defined on exact-gap intra-phase pairs.
489    # Cross-phase pairs and temporal chains have separate diagnostics.
490    headline_rows = [p for p in per_pair if p.get("pair_type") == "intra_phase"]
491    if not headline_rows:  # backwards compatibility for legacy manifests
492        headline_rows = per_pair
493    rot_errs = np.array([p["rotation_error_deg"] for p in headline_rows])
494    trans_errs = np.array([p["translation_error_m"] for p in headline_rows])
495    pose_max = np.array([p["pose_error_max_deg"] for p in headline_rows])
496    canonical = canonical_metric_vector(
497        headline_rows,
498        metric_translation_available=metric_translation_available,
499    )
500
501    per_bin = per_bin_breakdown(per_pair)
502    per_type = per_type_breakdown(per_pair)
503    if not metric_translation_available:
504        for breakdown in (per_bin, per_type):
505            for values in breakdown.values():
506                values.pop("translation_error_m", None)
507
508    return {
509        "n_pairs": len(per_pair),
510        "n_headline_intra_pairs": len(headline_rows),
511        "metric_translation_available": metric_translation_available,
512        "aggregated": {
513            "rotation_error_deg": float(np.mean(rot_errs)),
514            "rotation_error_deg_median": float(np.median(rot_errs)),
515            "translation_error_m": (
516                float(np.mean(trans_errs)) if metric_translation_available else None
517            ),
518            "translation_error_m_median": (
519                float(np.median(trans_errs)) if metric_translation_available else None
520            ),
521            "pose_error_max_deg": float(np.mean(pose_max)),
522        },
523        "standard_auc": auc_at_thresholds(pose_max, rot_auc_thresholds),
524        "canonical": canonical,
525        "m_auc": None,
526        "metric_auc": None,
527        "metric_auc_status": "skipped_by_protocol",
528        "per_bin": per_bin,
529        "per_type": per_type,
530        "cross_phase_delta": cross_phase_auc_delta(per_pair, rot_auc_thresholds),
531        "cross_phase_error_delta": cross_phase_delta(per_pair, "rotation_error_deg"),
532        "cross_phase_delta_trans": (
533            cross_phase_delta(per_pair, "translation_error_m")
534            if metric_translation_available else {}
535        ),
536        "temporal_drift": temporal_drift(
537            per_pair, metric_translation_available=metric_translation_available
538        ),
539    }

Compute the full RPX-RCPE metric basket from per-pair results.

Parameters

per_pair : list of dict Each dict must contain at minimum rotation_error_deg and translation_error_m. Should also carry pair_type, rotation_bin, chain_id, chain_position from the PosePairGenerator metadata for the novel metrics to fire.

Returns

dict with keys: aggregated — overall means. standard_auc — AUC@5°/10°/20° on pose_error_max_deg. metric_auc — null with status skipped_by_protocol. per_bin — breakdown by rotation difficulty. per_type — breakdown by pair type (intra/cross/temporal). cross_phase_delta — perf drop intra→cross (novel). temporal_drift — accumulated error over chains (novel).