rpx_benchmark.metrics.depth

Monocular absolute depth metric calculators.

Registers DepthErrorMetrics which computes AbsRel, RMSE, and three thresholded accuracy ratios (δ<1.25, δ<1.25², δ<1.25³) against the subset of pixels where the ground-truth depth map is valid (gt > 0). This matches the paper's eval protocol: we mask out D435-invalid pixels so models are not penalised where the sensor itself produced no measurement.

  1"""Monocular absolute depth metric calculators.
  2
  3Registers :class:`DepthErrorMetrics` which computes AbsRel, RMSE, and
  4three thresholded accuracy ratios (δ<1.25, δ<1.25², δ<1.25³) against
  5the subset of pixels where the ground-truth depth map is valid
  6(``gt > 0``). This matches the paper's eval protocol: we mask out
  7D435-invalid pixels so models are not penalised where the sensor
  8itself produced no measurement.
  9"""
 10
 11from __future__ import annotations
 12
 13from typing import Dict
 14
 15import numpy as np
 16
 17from ..api import DepthGroundTruth, DepthPrediction, TaskType
 18from ..exceptions import MetricError
 19from .registry import MetricCalculator, register_metric
 20
 21
 22@register_metric(TaskType.MONOCULAR_DEPTH)
 23class DepthErrorMetrics(MetricCalculator):
 24    """AbsRel / RMSE / δ-accuracy for monocular metric depth.
 25
 26    Notes
 27    -----
 28    All metrics are computed in metres. Neither raw logits nor any
 29    kind of scale alignment is applied — we deliberately measure the
 30    model's *absolute* metric accuracy, which is the property that
 31    matters for robot policies that consume depth directly.
 32
 33    For empty validity masks (fully invalid GT frames) we return the
 34    safe no-op sentinel ``{"absrel": 0, "rmse": 0, "delta1": 1, ...}``
 35    so a single bad frame does not break aggregate means. In practice
 36    the dataset pipeline should filter these out upstream.
 37
 38    Metric definitions
 39    ------------------
 40
 41    .. math::
 42
 43        \\text{AbsRel} &= \\frac{1}{N} \\sum_i \\frac{|\\hat d_i - d_i|}{d_i}
 44
 45        \\text{RMSE}   &= \\sqrt{\\frac{1}{N}\\sum_i(\\hat d_i - d_i)^2}
 46
 47        \\delta_k      &= \\frac{1}{N}\\,\\#\\Big\\{i : \\max\\!\\big(\\tfrac{\\hat d_i}{d_i},
 48                          \\tfrac{d_i}{\\hat d_i}\\big) < 1.25^{\\,k}\\Big\\}
 49    """
 50
 51    name = "depth_error_metrics"
 52
 53    def compute(
 54        self,
 55        prediction: DepthPrediction,
 56        ground_truth: DepthGroundTruth,
 57    ) -> Dict[str, float]:
 58        if not isinstance(prediction, DepthPrediction):
 59            raise MetricError(
 60                f"DepthErrorMetrics expected DepthPrediction, got {type(prediction).__name__}",
 61            )
 62        if not isinstance(ground_truth, DepthGroundTruth):
 63            raise MetricError(
 64                f"DepthErrorMetrics expected DepthGroundTruth, got {type(ground_truth).__name__}",
 65            )
 66
 67        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 68        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 69
 70        if pred.shape != gt.shape:
 71            raise MetricError(
 72                f"Depth prediction shape {pred.shape} does not match ground-truth shape {gt.shape}",
 73                hint=(
 74                    "The adapter's OutputAdapter should resize predictions "
 75                    "back to the sample RGB resolution before returning."
 76                ),
 77            )
 78
 79        valid = gt > 0
 80        if not valid.any():
 81            return {"rmse": 0.0, "absrel": 0.0, "delta1": 1.0, "delta2": 1.0, "delta3": 1.0}
 82
 83        pred_v = pred[valid]
 84        gt_v = gt[valid]
 85
 86        # Guard against pathological predictions (0 or negative) that
 87        # would blow up the ratio-based thresholds.
 88        pred_v = np.clip(pred_v, a_min=1e-6, a_max=None)
 89
 90        rmse = float(np.sqrt(np.mean((pred_v - gt_v) ** 2)))
 91        absrel = float(np.mean(np.abs(pred_v - gt_v) / gt_v))
 92
 93        thresh = np.maximum(pred_v / gt_v, gt_v / pred_v)
 94        delta1 = float(np.mean(thresh < 1.25))
 95        delta2 = float(np.mean(thresh < 1.25**2))
 96        delta3 = float(np.mean(thresh < 1.25**3))
 97
 98        return {
 99            "rmse": rmse,
100            "absrel": absrel,
101            "delta1": delta1,
102            "delta2": delta2,
103            "delta3": delta3,
104        }
105
106
107def _log_depth_pair(prediction: DepthPrediction, ground_truth: DepthGroundTruth):
108    from .depth_alignment import default_valid_mask
109
110    if not isinstance(prediction, DepthPrediction) or not isinstance(ground_truth, DepthGroundTruth):
111        raise MetricError("Expected DepthPrediction and DepthGroundTruth")
112    pred = np.asarray(prediction.depth_map, dtype=np.float64)
113    gt = np.asarray(ground_truth.depth_map, dtype=np.float64)
114    if pred.shape != gt.shape:
115        raise MetricError(f"Depth shape mismatch: {pred.shape} != {gt.shape}")
116    return pred, gt, default_valid_mask(pred, gt)
117
118
119def _log_scores(pred: np.ndarray, gt: np.ndarray) -> Dict[str, float]:
120    if not pred.size:
121        return {"rmselog": 0.0, "silog": 0.0}
122    error = np.log(pred) - np.log(gt)
123    return {"rmselog": float(np.sqrt(np.mean(error ** 2))),
124            "silog": float(100.0 * np.std(error))}
125
126
127@register_metric(TaskType.MONOCULAR_DEPTH)
128class DepthLogMetrics(MetricCalculator):
129    """Natural-log RMSE and SILog (100 times the log-error standard deviation).
130
131    Uses the shared 0.3 < GT < 5 m validity mask. Empty frames retain the
132    historical zero sentinel; callers should filter empty GT frames upstream.
133    """
134
135    name = "depth_log_metrics"
136
137    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
138        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
139        return _log_scores(pred[valid], gt[valid])
140
141
142@register_metric(TaskType.MONOCULAR_DEPTH)
143class DepthRangeStratified(MetricCalculator):
144    """Depth quality in near/mid/far bins; fewer than 100 pixels yields NaN."""
145
146    name = "depth_range_stratified"
147
148    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
149        from .depth_temporal import DEPTH_RANGE_BINS
150
151        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
152        result: Dict[str, float] = {}
153        for name, (lo, hi) in DEPTH_RANGE_BINS.items():
154            selected = valid & (gt >= lo) & (gt < hi)
155            values = dict.fromkeys(("absrel", "rmse", "rmselog", "silog", "delta1"), float("nan"))
156            if selected.sum() >= 100:
157                p, g = pred[selected], gt[selected]
158                values.update(_log_scores(p, g))
159                values.update(absrel=float(np.mean(np.abs(p-g)/g)),
160                              rmse=float(np.sqrt(np.mean((p-g)**2))),
161                              delta1=float(np.mean(np.maximum(p/g, g/p) < 1.25)))
162            result.update({f"{metric}_{name}": value for metric, value in values.items()})
163        return result
@register_metric(TaskType.MONOCULAR_DEPTH)
class DepthErrorMetrics(rpx_benchmark.metrics.registry.MetricCalculator):
 23@register_metric(TaskType.MONOCULAR_DEPTH)
 24class DepthErrorMetrics(MetricCalculator):
 25    """AbsRel / RMSE / δ-accuracy for monocular metric depth.
 26
 27    Notes
 28    -----
 29    All metrics are computed in metres. Neither raw logits nor any
 30    kind of scale alignment is applied — we deliberately measure the
 31    model's *absolute* metric accuracy, which is the property that
 32    matters for robot policies that consume depth directly.
 33
 34    For empty validity masks (fully invalid GT frames) we return the
 35    safe no-op sentinel ``{"absrel": 0, "rmse": 0, "delta1": 1, ...}``
 36    so a single bad frame does not break aggregate means. In practice
 37    the dataset pipeline should filter these out upstream.
 38
 39    Metric definitions
 40    ------------------
 41
 42    .. math::
 43
 44        \\text{AbsRel} &= \\frac{1}{N} \\sum_i \\frac{|\\hat d_i - d_i|}{d_i}
 45
 46        \\text{RMSE}   &= \\sqrt{\\frac{1}{N}\\sum_i(\\hat d_i - d_i)^2}
 47
 48        \\delta_k      &= \\frac{1}{N}\\,\\#\\Big\\{i : \\max\\!\\big(\\tfrac{\\hat d_i}{d_i},
 49                          \\tfrac{d_i}{\\hat d_i}\\big) < 1.25^{\\,k}\\Big\\}
 50    """
 51
 52    name = "depth_error_metrics"
 53
 54    def compute(
 55        self,
 56        prediction: DepthPrediction,
 57        ground_truth: DepthGroundTruth,
 58    ) -> Dict[str, float]:
 59        if not isinstance(prediction, DepthPrediction):
 60            raise MetricError(
 61                f"DepthErrorMetrics expected DepthPrediction, got {type(prediction).__name__}",
 62            )
 63        if not isinstance(ground_truth, DepthGroundTruth):
 64            raise MetricError(
 65                f"DepthErrorMetrics expected DepthGroundTruth, got {type(ground_truth).__name__}",
 66            )
 67
 68        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 69        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 70
 71        if pred.shape != gt.shape:
 72            raise MetricError(
 73                f"Depth prediction shape {pred.shape} does not match ground-truth shape {gt.shape}",
 74                hint=(
 75                    "The adapter's OutputAdapter should resize predictions "
 76                    "back to the sample RGB resolution before returning."
 77                ),
 78            )
 79
 80        valid = gt > 0
 81        if not valid.any():
 82            return {"rmse": 0.0, "absrel": 0.0, "delta1": 1.0, "delta2": 1.0, "delta3": 1.0}
 83
 84        pred_v = pred[valid]
 85        gt_v = gt[valid]
 86
 87        # Guard against pathological predictions (0 or negative) that
 88        # would blow up the ratio-based thresholds.
 89        pred_v = np.clip(pred_v, a_min=1e-6, a_max=None)
 90
 91        rmse = float(np.sqrt(np.mean((pred_v - gt_v) ** 2)))
 92        absrel = float(np.mean(np.abs(pred_v - gt_v) / gt_v))
 93
 94        thresh = np.maximum(pred_v / gt_v, gt_v / pred_v)
 95        delta1 = float(np.mean(thresh < 1.25))
 96        delta2 = float(np.mean(thresh < 1.25**2))
 97        delta3 = float(np.mean(thresh < 1.25**3))
 98
 99        return {
100            "rmse": rmse,
101            "absrel": absrel,
102            "delta1": delta1,
103            "delta2": delta2,
104            "delta3": delta3,
105        }

AbsRel / RMSE / δ-accuracy for monocular metric depth.

Notes

All metrics are computed in metres. Neither raw logits nor any kind of scale alignment is applied — we deliberately measure the model's absolute metric accuracy, which is the property that matters for robot policies that consume depth directly.

For empty validity masks (fully invalid GT frames) we return the safe no-op sentinel {"absrel": 0, "rmse": 0, "delta1": 1, ...} so a single bad frame does not break aggregate means. In practice the dataset pipeline should filter these out upstream.

Metric definitions

$$\text{AbsRel} &= \frac{1}{N} \sum_i \frac{|\hat d_i - d_i|}{d_i}

\text{RMSE} &= \sqrt{\frac{1}{N}\sum_i(\hat d_i - d_i)^2}

\delta_k &= \frac{1}{N}\,#\Big{i : \max!\big(\tfrac{\hat d_i}{d_i}, \tfrac{d_i}{\hat d_i}\big) < 1.25^{\,k}\Big}$$

name = 'depth_error_metrics'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
 54    def compute(
 55        self,
 56        prediction: DepthPrediction,
 57        ground_truth: DepthGroundTruth,
 58    ) -> Dict[str, float]:
 59        if not isinstance(prediction, DepthPrediction):
 60            raise MetricError(
 61                f"DepthErrorMetrics expected DepthPrediction, got {type(prediction).__name__}",
 62            )
 63        if not isinstance(ground_truth, DepthGroundTruth):
 64            raise MetricError(
 65                f"DepthErrorMetrics expected DepthGroundTruth, got {type(ground_truth).__name__}",
 66            )
 67
 68        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 69        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 70
 71        if pred.shape != gt.shape:
 72            raise MetricError(
 73                f"Depth prediction shape {pred.shape} does not match ground-truth shape {gt.shape}",
 74                hint=(
 75                    "The adapter's OutputAdapter should resize predictions "
 76                    "back to the sample RGB resolution before returning."
 77                ),
 78            )
 79
 80        valid = gt > 0
 81        if not valid.any():
 82            return {"rmse": 0.0, "absrel": 0.0, "delta1": 1.0, "delta2": 1.0, "delta3": 1.0}
 83
 84        pred_v = pred[valid]
 85        gt_v = gt[valid]
 86
 87        # Guard against pathological predictions (0 or negative) that
 88        # would blow up the ratio-based thresholds.
 89        pred_v = np.clip(pred_v, a_min=1e-6, a_max=None)
 90
 91        rmse = float(np.sqrt(np.mean((pred_v - gt_v) ** 2)))
 92        absrel = float(np.mean(np.abs(pred_v - gt_v) / gt_v))
 93
 94        thresh = np.maximum(pred_v / gt_v, gt_v / pred_v)
 95        delta1 = float(np.mean(thresh < 1.25))
 96        delta2 = float(np.mean(thresh < 1.25**2))
 97        delta3 = float(np.mean(thresh < 1.25**3))
 98
 99        return {
100            "rmse": rmse,
101            "absrel": absrel,
102            "delta1": delta1,
103            "delta2": delta2,
104            "delta3": delta3,
105        }

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.

@register_metric(TaskType.MONOCULAR_DEPTH)
class DepthLogMetrics(rpx_benchmark.metrics.registry.MetricCalculator):
128@register_metric(TaskType.MONOCULAR_DEPTH)
129class DepthLogMetrics(MetricCalculator):
130    """Natural-log RMSE and SILog (100 times the log-error standard deviation).
131
132    Uses the shared 0.3 < GT < 5 m validity mask. Empty frames retain the
133    historical zero sentinel; callers should filter empty GT frames upstream.
134    """
135
136    name = "depth_log_metrics"
137
138    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
139        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
140        return _log_scores(pred[valid], gt[valid])

Natural-log RMSE and SILog (100 times the log-error standard deviation).

Uses the shared 0.3 < GT < 5 m validity mask. Empty frames retain the historical zero sentinel; callers should filter empty GT frames upstream.

name = 'depth_log_metrics'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
138    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
139        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
140        return _log_scores(pred[valid], gt[valid])

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.

@register_metric(TaskType.MONOCULAR_DEPTH)
class DepthRangeStratified(rpx_benchmark.metrics.registry.MetricCalculator):
143@register_metric(TaskType.MONOCULAR_DEPTH)
144class DepthRangeStratified(MetricCalculator):
145    """Depth quality in near/mid/far bins; fewer than 100 pixels yields NaN."""
146
147    name = "depth_range_stratified"
148
149    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
150        from .depth_temporal import DEPTH_RANGE_BINS
151
152        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
153        result: Dict[str, float] = {}
154        for name, (lo, hi) in DEPTH_RANGE_BINS.items():
155            selected = valid & (gt >= lo) & (gt < hi)
156            values = dict.fromkeys(("absrel", "rmse", "rmselog", "silog", "delta1"), float("nan"))
157            if selected.sum() >= 100:
158                p, g = pred[selected], gt[selected]
159                values.update(_log_scores(p, g))
160                values.update(absrel=float(np.mean(np.abs(p-g)/g)),
161                              rmse=float(np.sqrt(np.mean((p-g)**2))),
162                              delta1=float(np.mean(np.maximum(p/g, g/p) < 1.25)))
163            result.update({f"{metric}_{name}": value for metric, value in values.items()})
164        return result

Depth quality in near/mid/far bins; fewer than 100 pixels yields NaN.

name = 'depth_range_stratified'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
149    def compute(self, prediction: DepthPrediction, ground_truth: DepthGroundTruth) -> Dict[str, float]:
150        from .depth_temporal import DEPTH_RANGE_BINS
151
152        pred, gt, valid = _log_depth_pair(prediction, ground_truth)
153        result: Dict[str, float] = {}
154        for name, (lo, hi) in DEPTH_RANGE_BINS.items():
155            selected = valid & (gt >= lo) & (gt < hi)
156            values = dict.fromkeys(("absrel", "rmse", "rmselog", "silog", "delta1"), float("nan"))
157            if selected.sum() >= 100:
158                p, g = pred[selected], gt[selected]
159                values.update(_log_scores(p, g))
160                values.update(absrel=float(np.mean(np.abs(p-g)/g)),
161                              rmse=float(np.sqrt(np.mean((p-g)**2))),
162                              delta1=float(np.mean(np.maximum(p/g, g/p) < 1.25)))
163            result.update({f"{metric}_{name}": value for metric, value in values.items()})
164        return result

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.