rpx_benchmark.metrics

Pluggable metric registry for RPX benchmark tasks.

Adding a new metric to an existing task is a three-line change::

from rpx_benchmark.metrics import MetricCalculator, register_metric
from rpx_benchmark.api import TaskType

@register_metric(TaskType.MONOCULAR_DEPTH)
class DepthMedian(MetricCalculator):
    name = "depth_median"

    def compute(self, prediction, ground_truth):
        # return a dict of metric_name -> float
        return {"median_absrel": float(np.median(np.abs(...)))}

Any number of calculators can be registered per task; the runner aggregates their outputs into the per-sample metric dict. To remove a built-in metric, unregister it via unregister_metric().

The built-in calculators for every task we ship live in sibling modules (metrics/depth.py, metrics/segmentation.py, etc.). They register themselves at import time; importing the rpx_benchmark package triggers those registrations so the registry is always populated.

Design notes

  • One class per metric family (not per metric key). A family shares preprocessing cost (e.g. masking valid depth pixels) across several related outputs like AbsRel + RMSE + δ<1.25.
  • Families are stateless between frames — instantiate once, reuse. If a family needs per-run state (running variance, etc.) it should hold it internally and return the aggregate via a finalize() hook the runner will call at the end. (Not implemented yet; add when needed.)
  • Metric output keys must be numeric so the aggregator can average them. Non-numeric metadata belongs in the sample record, not the metric record.
 1"""Pluggable metric registry for RPX benchmark tasks.
 2
 3Adding a new metric to an existing task is a three-line change::
 4
 5    from rpx_benchmark.metrics import MetricCalculator, register_metric
 6    from rpx_benchmark.api import TaskType
 7
 8    @register_metric(TaskType.MONOCULAR_DEPTH)
 9    class DepthMedian(MetricCalculator):
10        name = "depth_median"
11
12        def compute(self, prediction, ground_truth):
13            # return a dict of metric_name -> float
14            return {"median_absrel": float(np.median(np.abs(...)))}
15
16Any number of calculators can be registered per task; the runner
17aggregates their outputs into the per-sample metric dict. To *remove*
18a built-in metric, unregister it via
19:func:`unregister_metric`.
20
21The built-in calculators for every task we ship live in sibling
22modules (``metrics/depth.py``, ``metrics/segmentation.py``, etc.). They
23register themselves at import time; importing the :mod:`rpx_benchmark`
24package triggers those registrations so the registry is always
25populated.
26
27Design notes
28------------
29
30- **One class per metric family** (not per metric key). A family
31  shares preprocessing cost (e.g. masking valid depth pixels) across
32  several related outputs like AbsRel + RMSE + δ<1.25.
33- **Families are stateless** between frames — instantiate once, reuse.
34  If a family needs per-run state (running variance, etc.) it should
35  hold it internally and return the aggregate via a ``finalize()``
36  hook the runner will call at the end. (Not implemented yet; add
37  when needed.)
38- **Metric output keys must be numeric** so the aggregator can average
39  them. Non-numeric metadata belongs in the sample record, not the
40  metric record.
41"""
42
43# Built-in calculator imports register themselves at import time via
44# @register_metric decorators. Import-order matters: registry has to
45# exist first (imported above), then the calculator modules.
46from . import depth as _depth  # noqa: F401 — triggers registration
47from . import detection as _detection  # noqa: F401
48from . import grounding as _grounding  # noqa: F401
49from . import keypoints as _keypoints  # noqa: F401
50from . import pose as _pose  # noqa: F401
51from . import segmentation as _segmentation  # noqa: F401
52from . import sparse_depth as _sparse_depth  # noqa: F401
53from . import tracking as _tracking  # noqa: F401
54from . import video_depth as _video_depth  # noqa: F401  # registers VIDEO_DEPTH
55from .registry import (
56    MetricCalculator,
57    MetricSuite,
58    available_metrics,
59    clear_registry,
60    compute_metrics,
61    get_calculators,
62    register_metric,
63    unregister_metric,
64)
65
66__all__ = [
67    "MetricCalculator",
68    "MetricSuite",
69    "available_metrics",
70    "clear_registry",
71    "compute_metrics",
72    "get_calculators",
73    "register_metric",
74    "unregister_metric",
75]
class MetricCalculator(abc.ABC):
33class MetricCalculator(ABC):
34    """Abstract base for a family of metrics bound to a single task.
35
36    Subclasses are lightweight, stateless objects. The ``compute``
37    method accepts one prediction and its ground truth and returns a
38    dict mapping metric name → scalar float.
39
40    Subclasses must set ``name`` (human-readable identifier used for
41    registration) and implement :meth:`compute`. Subclasses should
42    take their own hyperparameters via ``__init__`` when needed.
43
44    Examples
45    --------
46    >>> from rpx_benchmark.metrics import MetricCalculator, register_metric
47    >>> from rpx_benchmark.api import TaskType
48    >>> @register_metric(TaskType.MONOCULAR_DEPTH)
49    ... class MeanPred(MetricCalculator):
50    ...     name = "mean_pred"
51    ...     def compute(self, prediction, ground_truth):
52    ...         return {"mean_pred": float(prediction.depth_map.mean())}
53    """
54
55    name: str = ""
56
57    @abstractmethod
58    def compute(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
59        """Return a dict of metric name → scalar for one sample.
60
61        Parameters
62        ----------
63        prediction : Any
64            Task-specific Prediction dataclass
65            (e.g. :class:`DepthPrediction`). The concrete type is
66            whatever the calculator is designed for.
67        ground_truth : Any
68            Task-specific GroundTruth dataclass
69            (e.g. :class:`DepthGroundTruth`).
70
71        Returns
72        -------
73        dict[str, float]
74            Metric values. Keys should be short snake_case names;
75            values must be numeric so the runner can average them.
76
77        Raises
78        ------
79        MetricError
80            When inputs have the wrong shape/type/content.
81        """

Abstract base for a family of metrics bound to a single task.

Subclasses are lightweight, stateless objects. The compute method accepts one prediction and its ground truth and returns a dict mapping metric name → scalar float.

Subclasses must set name (human-readable identifier used for registration) and implement compute(). Subclasses should take their own hyperparameters via __init__ when needed.

Examples

>>> from rpx_benchmark.metrics import MetricCalculator, register_metric
>>> from rpx_benchmark.api import TaskType
>>> @register_metric(TaskType.MONOCULAR_DEPTH)
... class MeanPred(MetricCalculator):
...     name = "mean_pred"
...     def compute(self, prediction, ground_truth):
...         return {"mean_pred": float(prediction.depth_map.mean())}
name: str = ''
@abstractmethod
def compute(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
57    @abstractmethod
58    def compute(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
59        """Return a dict of metric name → scalar for one sample.
60
61        Parameters
62        ----------
63        prediction : Any
64            Task-specific Prediction dataclass
65            (e.g. :class:`DepthPrediction`). The concrete type is
66            whatever the calculator is designed for.
67        ground_truth : Any
68            Task-specific GroundTruth dataclass
69            (e.g. :class:`DepthGroundTruth`).
70
71        Returns
72        -------
73        dict[str, float]
74            Metric values. Keys should be short snake_case names;
75            values must be numeric so the runner can average them.
76
77        Raises
78        ------
79        MetricError
80            When inputs have the wrong shape/type/content.
81        """

Return a dict of metric name → scalar for one sample.

Parameters

prediction : Any Task-specific Prediction dataclass (e.g. DepthPrediction). The concrete type is whatever the calculator is designed for. ground_truth : Any Task-specific GroundTruth dataclass (e.g. DepthGroundTruth).

Returns

dict[str, float] Metric values. Keys should be short snake_case names; values must be numeric so the runner can average them.

Raises

MetricError When inputs have the wrong shape/type/content.

class MetricSuite:
255class MetricSuite:
256    """Thin wrapper around the metric registry used by the runner.
257
258    Kept as a class rather than a function because historical API
259    expects ``MetricSuite.for_task(...).evaluate(pred, gt)``. New code
260    can call :func:`compute_metrics` directly.
261    """
262
263    def __init__(self, task: TaskType) -> None:
264        self.task = task
265
266    @classmethod
267    def for_task(cls, task: TaskType) -> "MetricSuite":
268        """Create a suite for the given task.
269
270        Raises
271        ------
272        MetricError
273            If no calculators are registered for ``task``.
274        """
275        if not get_calculators(task):
276            raise MetricError(
277                f"No metric calculator registered for task {task.value!r}.",
278                hint="Import rpx_benchmark.metrics to trigger built-ins.",
279            )
280        return cls(task=task)
281
282    def evaluate(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
283        """Run every registered calculator and return merged results.
284
285        Raises
286        ------
287        MetricError
288            Propagated from individual calculators when inputs are
289            shape-mismatched or wrong-typed.
290        """
291        return compute_metrics(self.task, prediction, ground_truth)
292
293    def aggregate(self, per_sample: List[Dict[str, Any]]) -> Dict[str, float]:
294        """Mean over numeric metric keys; non-numeric metadata is skipped.
295
296        Parameters
297        ----------
298        per_sample : list of dict
299            Per-sample rows. May contain metric floats and metadata
300            strings/enums in the same dict.
301
302        Returns
303        -------
304        dict[str, float]
305            One float per numeric key. Empty dict if ``per_sample`` is
306            empty or contains no numeric values.
307        """
308        if not per_sample:
309            return {}
310        numeric_keys = [
311            k
312            for k, v in per_sample[0].items()
313            if isinstance(v, (int, float)) and not isinstance(v, bool)
314        ]
315        out: Dict[str, float] = {}
316        for k in numeric_keys:
317            vals = [
318                m[k]
319                for m in per_sample
320                if isinstance(m.get(k), (int, float)) and not isinstance(m.get(k), bool)
321            ]
322            if vals:
323                out[k] = float(np.mean(vals))
324        return out
325
326    def build_result(self, per_sample: List[Dict[str, Any]]) -> "BenchmarkResult":
327        """Convenience: wrap ``per_sample`` and its aggregate in a
328        :class:`BenchmarkResult`.
329        """
330        return BenchmarkResult(
331            task=self.task,
332            per_sample=per_sample,
333            aggregated=self.aggregate(per_sample),
334            num_samples=len(per_sample),
335        )

Thin wrapper around the metric registry used by the runner.

Kept as a class rather than a function because historical API expects MetricSuite.for_task(...).evaluate(pred, gt). New code can call compute_metrics() directly.

MetricSuite(task: rpx_benchmark.api.TaskType)
263    def __init__(self, task: TaskType) -> None:
264        self.task = task
task
@classmethod
def for_task( cls, task: rpx_benchmark.api.TaskType) -> MetricSuite:
266    @classmethod
267    def for_task(cls, task: TaskType) -> "MetricSuite":
268        """Create a suite for the given task.
269
270        Raises
271        ------
272        MetricError
273            If no calculators are registered for ``task``.
274        """
275        if not get_calculators(task):
276            raise MetricError(
277                f"No metric calculator registered for task {task.value!r}.",
278                hint="Import rpx_benchmark.metrics to trigger built-ins.",
279            )
280        return cls(task=task)

Create a suite for the given task.

Raises

MetricError If no calculators are registered for task.

def evaluate(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
282    def evaluate(self, prediction: Any, ground_truth: Any) -> Dict[str, float]:
283        """Run every registered calculator and return merged results.
284
285        Raises
286        ------
287        MetricError
288            Propagated from individual calculators when inputs are
289            shape-mismatched or wrong-typed.
290        """
291        return compute_metrics(self.task, prediction, ground_truth)

Run every registered calculator and return merged results.

Raises

MetricError Propagated from individual calculators when inputs are shape-mismatched or wrong-typed.

def aggregate(self, per_sample: List[Dict[str, Any]]) -> Dict[str, float]:
293    def aggregate(self, per_sample: List[Dict[str, Any]]) -> Dict[str, float]:
294        """Mean over numeric metric keys; non-numeric metadata is skipped.
295
296        Parameters
297        ----------
298        per_sample : list of dict
299            Per-sample rows. May contain metric floats and metadata
300            strings/enums in the same dict.
301
302        Returns
303        -------
304        dict[str, float]
305            One float per numeric key. Empty dict if ``per_sample`` is
306            empty or contains no numeric values.
307        """
308        if not per_sample:
309            return {}
310        numeric_keys = [
311            k
312            for k, v in per_sample[0].items()
313            if isinstance(v, (int, float)) and not isinstance(v, bool)
314        ]
315        out: Dict[str, float] = {}
316        for k in numeric_keys:
317            vals = [
318                m[k]
319                for m in per_sample
320                if isinstance(m.get(k), (int, float)) and not isinstance(m.get(k), bool)
321            ]
322            if vals:
323                out[k] = float(np.mean(vals))
324        return out

Mean over numeric metric keys; non-numeric metadata is skipped.

Parameters

per_sample : list of dict Per-sample rows. May contain metric floats and metadata strings/enums in the same dict.

Returns

dict[str, float] One float per numeric key. Empty dict if per_sample is empty or contains no numeric values.

def build_result( self, per_sample: List[Dict[str, Any]]) -> rpx_benchmark.metrics.registry.BenchmarkResult:
326    def build_result(self, per_sample: List[Dict[str, Any]]) -> "BenchmarkResult":
327        """Convenience: wrap ``per_sample`` and its aggregate in a
328        :class:`BenchmarkResult`.
329        """
330        return BenchmarkResult(
331            task=self.task,
332            per_sample=per_sample,
333            aggregated=self.aggregate(per_sample),
334            num_samples=len(per_sample),
335        )

Convenience: wrap per_sample and its aggregate in a BenchmarkResult.

def available_metrics() -> Dict[rpx_benchmark.api.TaskType, List[str]]:
174def available_metrics() -> Dict[TaskType, List[str]]:
175    """List the registered metric names grouped by task.
176
177    Returns
178    -------
179    dict
180        ``{TaskType: [calculator_name, ...]}``. Only tasks with at
181        least one registered calculator appear.
182    """
183    return {task: [c.name for c in calcs] for task, calcs in _CALCULATORS.items() if calcs}

List the registered metric names grouped by task.

Returns

dict {TaskType: [calculator_name, ...]}. Only tasks with at least one registered calculator appear.

def clear_registry() -> None:
164def clear_registry() -> None:
165    """Remove every registered calculator. Primarily for tests."""
166    _CALCULATORS.clear()

Remove every registered calculator. Primarily for tests.

def compute_metrics( task: rpx_benchmark.api.TaskType, prediction: Any, ground_truth: Any) -> Dict[str, float]:
186def compute_metrics(
187    task: TaskType,
188    prediction: Any,
189    ground_truth: Any,
190) -> Dict[str, float]:
191    """Run every registered calculator for ``task`` and merge outputs.
192
193    Parameters
194    ----------
195    task : TaskType
196    prediction : Any
197    ground_truth : Any
198
199    Returns
200    -------
201    dict[str, float]
202        Union of all calculators' output dicts. Later calculators may
203        overwrite earlier ones if they emit the same key.
204
205    Raises
206    ------
207    MetricError
208        If no calculators are registered for ``task``.
209    """
210    calcs = _CALCULATORS.get(task)
211    if not calcs:
212        raise MetricError(
213            f"No metric calculator registered for task {task.value!r}.",
214            hint=(
215                "Either import the built-in calculators (e.g. "
216                "`import rpx_benchmark.metrics`) or register a custom "
217                "one via @register_metric."
218            ),
219        )
220    merged: Dict[str, float] = {}
221    for calc in calcs:
222        merged.update(calc.compute(prediction, ground_truth))
223    return merged

Run every registered calculator for task and merge outputs.

Parameters

task : TaskType prediction : Any ground_truth : Any

Returns

dict[str, float] Union of all calculators' output dicts. Later calculators may overwrite earlier ones if they emit the same key.

Raises

MetricError If no calculators are registered for task.

def get_calculators( task: rpx_benchmark.api.TaskType) -> List[MetricCalculator]:
169def get_calculators(task: TaskType) -> List[MetricCalculator]:
170    """Return the calculators registered for ``task`` (empty list if none)."""
171    return list(_CALCULATORS.get(task, []))

Return the calculators registered for task (empty list if none).

def register_metric( task: rpx_benchmark.api.TaskType) -> Callable[[Type[MetricCalculator]], Type[MetricCalculator]]:
 91def register_metric(
 92    task: TaskType,
 93) -> Callable[[Type[MetricCalculator]], Type[MetricCalculator]]:
 94    """Class decorator registering a calculator under a task.
 95
 96    Parameters
 97    ----------
 98    task : TaskType
 99        Task the calculator belongs to. Multiple calculators can be
100        registered against the same task and will be composed at
101        evaluation time.
102
103    Returns
104    -------
105    Callable
106        The decorator; when applied to a class it instantiates it
107        once with no arguments and appends to the registry.
108
109    Raises
110    ------
111    MetricError
112        If ``task`` is not a :class:`TaskType`, or if the decorated
113        object is not a :class:`MetricCalculator` subclass.
114
115    Examples
116    --------
117    >>> from rpx_benchmark.api import TaskType
118    >>> from rpx_benchmark.metrics import MetricCalculator, register_metric
119    >>> @register_metric(TaskType.MONOCULAR_DEPTH)
120    ... class MyMetric(MetricCalculator):
121    ...     name = "my_metric"
122    ...     def compute(self, prediction, ground_truth):
123    ...         return {"my_metric": 0.0}
124    """
125    if not isinstance(task, TaskType):
126        raise MetricError(
127            f"register_metric expected a TaskType, got {type(task).__name__}",
128            hint="Use one of the members of rpx_benchmark.api.TaskType.",
129        )
130
131    def decorator(cls: Type[MetricCalculator]) -> Type[MetricCalculator]:
132        if not (isinstance(cls, type) and issubclass(cls, MetricCalculator)):
133            raise MetricError(
134                f"@register_metric can only decorate MetricCalculator subclasses; got {cls!r}",
135            )
136        instance = cls()
137        _CALCULATORS[task].append(instance)
138        log.debug("registered metric %s for task %s", instance.name or cls.__name__, task.value)
139        return cls
140
141    return decorator

Class decorator registering a calculator under a task.

Parameters

task : TaskType Task the calculator belongs to. Multiple calculators can be registered against the same task and will be composed at evaluation time.

Returns

Callable The decorator; when applied to a class it instantiates it once with no arguments and appends to the registry.

Raises

MetricError If task is not a TaskType, or if the decorated object is not a MetricCalculator subclass.

Examples

>>> from rpx_benchmark.api import TaskType
>>> from rpx_benchmark.metrics import MetricCalculator, register_metric
>>> @register_metric(TaskType.MONOCULAR_DEPTH)
... class MyMetric(MetricCalculator):
...     name = "my_metric"
...     def compute(self, prediction, ground_truth):
...         return {"my_metric": 0.0}
def unregister_metric(task: rpx_benchmark.api.TaskType, name: str) -> bool:
144def unregister_metric(task: TaskType, name: str) -> bool:
145    """Remove a previously registered calculator by its ``name`` field.
146
147    Parameters
148    ----------
149    task : TaskType
150    name : str
151        Name attribute of the calculator to remove.
152
153    Returns
154    -------
155    bool
156        True if a calculator was removed; False if no match was found.
157    """
158    before = len(_CALCULATORS.get(task, []))
159    _CALCULATORS[task] = [c for c in _CALCULATORS.get(task, []) if c.name != name]
160    after = len(_CALCULATORS[task])
161    return after < before

Remove a previously registered calculator by its name field.

Parameters

task : TaskType name : str Name attribute of the calculator to remove.

Returns

bool True if a calculator was removed; False if no match was found.