rpx_benchmark.metrics.depth_robotics

Robotics-deployment depth metric calculators.

Camera-frame Chamfer-L1 + grasp F-score, surface normals (MAE, %11.25°), and scale-invariant boundary F1. All are pose-free (intrinsics only) — they measure shape/reachability, not pose drift.

  1"""Robotics-deployment depth metric calculators.
  2
  3Camera-frame Chamfer-L1 + grasp F-score, surface normals (MAE, %11.25°),
  4and scale-invariant boundary F1.  All are **pose-free** (intrinsics
  5only) — they measure shape/reachability, not pose drift.
  6"""
  7
  8from __future__ import annotations
  9
 10from typing import Dict
 11
 12import numpy as np
 13
 14from ..api import DepthGroundTruth, DepthPrediction, TaskType
 15from ..exceptions import MetricError
 16from .registry import MetricCalculator, register_metric
 17
 18# D435 intrinsics at 640×480
 19_FX: float = 615.0
 20_FY: float = 615.0
 21_CX: float = 320.0
 22_CY: float = 240.0
 23
 24# Max points for Chamfer/F-score (computational tractability)
 25_MAX_POINTS: int = 50_000
 26
 27
 28def _backproject(depth: np.ndarray, valid: np.ndarray) -> np.ndarray:
 29    """Backproject valid depth pixels to 3D in camera frame.
 30
 31    Returns (N, 3) float64 array of 3D points.
 32    """
 33    h, w = depth.shape
 34    u, v = np.meshgrid(np.arange(w, dtype=np.float64),
 35                        np.arange(h, dtype=np.float64))
 36    z = depth[valid].astype(np.float64)
 37    x = (u[valid] - _CX) * z / _FX
 38    y = (v[valid] - _CY) * z / _FY
 39    return np.stack([x, y, z], axis=-1)
 40
 41
 42def _subsample(pts: np.ndarray, max_n: int, rng: np.random.Generator) -> np.ndarray:
 43    if len(pts) <= max_n:
 44        return pts
 45    idx = rng.choice(len(pts), size=max_n, replace=False)
 46    return pts[idx]
 47
 48
 49def _nearest_distances(a: np.ndarray, b: np.ndarray, *, p: int = 2) -> np.ndarray:
 50    """Exact nearest-neighbour distances without quadratic temporary arrays."""
 51    from scipy.spatial import cKDTree
 52
 53    return cKDTree(b).query(a, k=1, p=p, workers=1)[0]
 54
 55
 56def _chamfer_l1(a: np.ndarray, b: np.ndarray) -> float:
 57    """Symmetric mean nearest-neighbour L1 distance (sum of both directions)."""
 58    return float(_nearest_distances(a, b, p=1).mean() + _nearest_distances(b, a, p=1).mean())
 59
 60
 61def _fscore(a: np.ndarray, b: np.ndarray, threshold: float) -> float:
 62    """Harmonic mean of bidirectional Euclidean matches below the threshold."""
 63    precision = float(np.mean(_nearest_distances(a, b) < threshold))
 64    recall = float(np.mean(_nearest_distances(b, a) < threshold))
 65    return 0.0 if precision + recall < 1e-12 else 2 * precision * recall / (precision + recall)
 66
 67
 68@register_metric(TaskType.MONOCULAR_DEPTH)
 69class DepthGrasp3D(MetricCalculator):
 70    """Camera-frame Chamfer-L1 and grasp F-score @ {1, 2, 5, 10 cm}.
 71
 72    Back-projects predicted and GT depth to 3D using D435 intrinsics
 73    only (no camera pose) — measures shape/reachability, not pose drift.
 74    """
 75
 76    name = "depth_grasp_3d"
 77
 78    def compute(
 79        self,
 80        prediction: DepthPrediction,
 81        ground_truth: DepthGroundTruth,
 82    ) -> Dict[str, float]:
 83        if not isinstance(prediction, DepthPrediction):
 84            raise MetricError(
 85                f"DepthGrasp3D expected DepthPrediction, got {type(prediction).__name__}",
 86            )
 87        if not isinstance(ground_truth, DepthGroundTruth):
 88            raise MetricError(
 89                f"DepthGrasp3D expected DepthGroundTruth, got {type(ground_truth).__name__}",
 90            )
 91
 92        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 93        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 94
 95        from .depth_alignment import default_valid_mask
 96
 97        valid = default_valid_mask(pred, gt)
 98        if valid.sum() < 10:
 99            return {
100                "chamfer_l1": float("nan"),
101                "fscore_1cm": 0.0,
102                "fscore_2cm": 0.0,
103                "fscore_5cm": 0.0,
104                "fscore_10cm": 0.0,
105            }
106
107        # Use independent RNGs so identical pred/gt get the same subset
108        rng_pred = np.random.default_rng(42)
109        rng_gt = np.random.default_rng(42)
110        pts_pred = _subsample(_backproject(pred, valid), _MAX_POINTS, rng_pred)
111        pts_gt = _subsample(_backproject(gt, valid), _MAX_POINTS, rng_gt)
112
113        chamfer = _chamfer_l1(pts_pred, pts_gt)
114
115        return {
116            "chamfer_l1": chamfer,
117            "fscore_1cm": _fscore(pts_pred, pts_gt, 0.01),
118            "fscore_2cm": _fscore(pts_pred, pts_gt, 0.02),
119            "fscore_5cm": _fscore(pts_pred, pts_gt, 0.05),
120            "fscore_10cm": _fscore(pts_pred, pts_gt, 0.10),
121        }
122
123
124# --------------------------------------------------------------------------- #
125# Surface normals from depth
126# --------------------------------------------------------------------------- #
127
128def _depth_to_normals(depth: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
129    """Compute surface normals from depth via finite differences + backprojection.
130
131    Returns (H, W, 3) normals and (H, W) bool mask of pixels where
132    normals are reliable (both neighbours valid and away from holes).
133    """
134    h, w = depth.shape
135    z = depth.astype(np.float64)
136
137    # Backproject to 3D (per-pixel camera-frame positions)
138    u, v = np.meshgrid(np.arange(w, dtype=np.float64),
139                        np.arange(h, dtype=np.float64))
140    x = (u - _CX) * z / _FX
141    y = (v - _CY) * z / _FY
142
143    # Finite-difference tangent vectors (central where possible)
144    dx = np.zeros((h, w, 3), dtype=np.float64)
145    dy = np.zeros((h, w, 3), dtype=np.float64)
146
147    dx[:, 1:-1, 0] = x[:, 2:] - x[:, :-2]
148    dx[:, 1:-1, 1] = y[:, 2:] - y[:, :-2]
149    dx[:, 1:-1, 2] = z[:, 2:] - z[:, :-2]
150
151    dy[1:-1, :, 0] = x[2:, :] - x[:-2, :]
152    dy[1:-1, :, 1] = y[2:, :] - y[:-2, :]
153    dy[1:-1, :, 2] = z[2:, :] - z[:-2, :]
154
155    normals = np.cross(dx, dy)
156    norm = np.linalg.norm(normals, axis=-1, keepdims=True)
157    norm = np.where(norm > 1e-8, norm, 1e-8)
158    normals = normals / norm
159
160    # Mask: valid depth at this pixel and all 4 finite-diff neighbours;
161    # exclude 1px border and k=2px around holes.
162    neighbour_valid = (
163        valid[1:-1, 1:-1]
164        & valid[:-2, 1:-1] & valid[2:, 1:-1]
165        & valid[1:-1, :-2] & valid[1:-1, 2:]
166    )
167    normal_valid = np.zeros((h, w), dtype=bool)
168    normal_valid[1:-1, 1:-1] = neighbour_valid
169
170    return normals, normal_valid
171
172
173@register_metric(TaskType.MONOCULAR_DEPTH)
174class DepthNormals(MetricCalculator):
175    """Surface normal MAE and %11.25° from depth-derived normals.
176
177    Normals are derived via finite differences on the back-projected
178    3D positions (intrinsics only, no pose). Pixels near depth holes
179    and image borders are masked out to avoid amplifying D435 noise.
180    """
181
182    name = "depth_normals"
183
184    def compute(
185        self,
186        prediction: DepthPrediction,
187        ground_truth: DepthGroundTruth,
188    ) -> Dict[str, float]:
189        if not isinstance(prediction, DepthPrediction):
190            raise MetricError(
191                f"DepthNormals expected DepthPrediction, got {type(prediction).__name__}",
192            )
193        if not isinstance(ground_truth, DepthGroundTruth):
194            raise MetricError(
195                f"DepthNormals expected DepthGroundTruth, got {type(ground_truth).__name__}",
196            )
197
198        pred = np.asarray(prediction.depth_map, dtype=np.float32)
199        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
200
201        from .depth_alignment import default_valid_mask
202
203        valid = default_valid_mask(pred, gt)
204
205        pred_normals, pred_nvalid = _depth_to_normals(pred, valid)
206        gt_normals, gt_nvalid = _depth_to_normals(gt, valid)
207
208        both_valid = pred_nvalid & gt_nvalid
209        if both_valid.sum() < 10:
210            return {"normal_mae": float("nan"), "normal_acc1125": 0.0}
211
212        # Cosine similarity → angular error (degrees)
213        cos_sim = np.sum(
214            pred_normals[both_valid] * gt_normals[both_valid], axis=-1
215        )
216        cos_sim = np.clip(cos_sim, -1.0, 1.0)
217        angles_deg = np.degrees(np.arccos(np.abs(cos_sim)))  # abs: sign ambiguity
218
219        normal_mae = float(np.mean(angles_deg))
220        normal_acc1125 = float(np.mean(angles_deg < 11.25))
221
222        return {
223            "normal_mae": normal_mae,
224            "normal_acc1125": normal_acc1125,
225        }
226
227
228# --------------------------------------------------------------------------- #
229# Scale-invariant boundary F1
230# --------------------------------------------------------------------------- #
231
232def _depth_ratio_contours(depth: np.ndarray, threshold_pct: float) -> np.ndarray:
233    """Binary occluding-contour map from depth ratios.
234
235    A pixel (i,j) is an occluding contour if any 4-connected neighbour
236    satisfies d(neighbour)/d(pixel) > 1 + threshold_pct/100.
237    """
238    t = 1.0 + threshold_pct / 100.0
239    h, w = depth.shape
240    contour = np.zeros((h, w), dtype=bool)
241
242    for dy, dx in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
243        sy = slice(max(0, dy), h + min(0, dy))
244        sx = slice(max(0, dx), w + min(0, dx))
245        ny = slice(max(0, -dy), h + min(0, -dy))
246        nx = slice(max(0, -dx), w + min(0, -dx))
247        d_self = depth[sy, sx]
248        d_neigh = depth[ny, nx]
249        valid_pair = (d_self > 1e-6) & (d_neigh > 1e-6)
250        ratio = np.ones_like(d_self)
251        ratio[valid_pair] = d_neigh[valid_pair] / d_self[valid_pair]
252        contour[sy, sx] |= (ratio > t) & valid_pair
253
254    return contour
255
256
257@register_metric(TaskType.MONOCULAR_DEPTH)
258class DepthBoundaryF1(MetricCalculator):
259    """Scale-invariant occluding-contour F1 from depth ratios.
260
261    Averages F1 over thresholds t ∈ {5, 10, 15, 20, 25} (percent).
262    No GT boundary annotations needed — contours are derived from
263    depth ratios on both predicted and GT depth maps.
264    """
265
266    name = "depth_boundary_f1"
267
268    _thresholds = (5, 10, 15, 20, 25)
269
270    def compute(
271        self,
272        prediction: DepthPrediction,
273        ground_truth: DepthGroundTruth,
274    ) -> Dict[str, float]:
275        if not isinstance(prediction, DepthPrediction):
276            raise MetricError(
277                f"DepthBoundaryF1 expected DepthPrediction, got {type(prediction).__name__}",
278            )
279        if not isinstance(ground_truth, DepthGroundTruth):
280            raise MetricError(
281                f"DepthBoundaryF1 expected DepthGroundTruth, got {type(ground_truth).__name__}",
282            )
283
284        pred = np.asarray(prediction.depth_map, dtype=np.float32)
285        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
286
287        f1_scores = []
288        for t in self._thresholds:
289            pred_c = _depth_ratio_contours(pred, t)
290            gt_c = _depth_ratio_contours(gt, t)
291
292            tp = float((pred_c & gt_c).sum())
293            fp = float((pred_c & ~gt_c).sum())
294            fn = float((~pred_c & gt_c).sum())
295
296            if tp + fp < 1e-6 and tp + fn < 1e-6:
297                # No contours in either → perfect agreement
298                f1_scores.append(1.0)
299            elif tp < 1e-6:
300                f1_scores.append(0.0)
301            else:
302                prec = tp / (tp + fp)
303                rec = tp / (tp + fn)
304                f1_scores.append(2 * prec * rec / (prec + rec))
305
306        return {"boundary_f1": float(np.mean(f1_scores))}
@register_metric(TaskType.MONOCULAR_DEPTH)
class DepthGrasp3D(rpx_benchmark.metrics.registry.MetricCalculator):
 69@register_metric(TaskType.MONOCULAR_DEPTH)
 70class DepthGrasp3D(MetricCalculator):
 71    """Camera-frame Chamfer-L1 and grasp F-score @ {1, 2, 5, 10 cm}.
 72
 73    Back-projects predicted and GT depth to 3D using D435 intrinsics
 74    only (no camera pose) — measures shape/reachability, not pose drift.
 75    """
 76
 77    name = "depth_grasp_3d"
 78
 79    def compute(
 80        self,
 81        prediction: DepthPrediction,
 82        ground_truth: DepthGroundTruth,
 83    ) -> Dict[str, float]:
 84        if not isinstance(prediction, DepthPrediction):
 85            raise MetricError(
 86                f"DepthGrasp3D expected DepthPrediction, got {type(prediction).__name__}",
 87            )
 88        if not isinstance(ground_truth, DepthGroundTruth):
 89            raise MetricError(
 90                f"DepthGrasp3D expected DepthGroundTruth, got {type(ground_truth).__name__}",
 91            )
 92
 93        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 94        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 95
 96        from .depth_alignment import default_valid_mask
 97
 98        valid = default_valid_mask(pred, gt)
 99        if valid.sum() < 10:
100            return {
101                "chamfer_l1": float("nan"),
102                "fscore_1cm": 0.0,
103                "fscore_2cm": 0.0,
104                "fscore_5cm": 0.0,
105                "fscore_10cm": 0.0,
106            }
107
108        # Use independent RNGs so identical pred/gt get the same subset
109        rng_pred = np.random.default_rng(42)
110        rng_gt = np.random.default_rng(42)
111        pts_pred = _subsample(_backproject(pred, valid), _MAX_POINTS, rng_pred)
112        pts_gt = _subsample(_backproject(gt, valid), _MAX_POINTS, rng_gt)
113
114        chamfer = _chamfer_l1(pts_pred, pts_gt)
115
116        return {
117            "chamfer_l1": chamfer,
118            "fscore_1cm": _fscore(pts_pred, pts_gt, 0.01),
119            "fscore_2cm": _fscore(pts_pred, pts_gt, 0.02),
120            "fscore_5cm": _fscore(pts_pred, pts_gt, 0.05),
121            "fscore_10cm": _fscore(pts_pred, pts_gt, 0.10),
122        }

Camera-frame Chamfer-L1 and grasp F-score @ {1, 2, 5, 10 cm}.

Back-projects predicted and GT depth to 3D using D435 intrinsics only (no camera pose) — measures shape/reachability, not pose drift.

name = 'depth_grasp_3d'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
 79    def compute(
 80        self,
 81        prediction: DepthPrediction,
 82        ground_truth: DepthGroundTruth,
 83    ) -> Dict[str, float]:
 84        if not isinstance(prediction, DepthPrediction):
 85            raise MetricError(
 86                f"DepthGrasp3D expected DepthPrediction, got {type(prediction).__name__}",
 87            )
 88        if not isinstance(ground_truth, DepthGroundTruth):
 89            raise MetricError(
 90                f"DepthGrasp3D expected DepthGroundTruth, got {type(ground_truth).__name__}",
 91            )
 92
 93        pred = np.asarray(prediction.depth_map, dtype=np.float32)
 94        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
 95
 96        from .depth_alignment import default_valid_mask
 97
 98        valid = default_valid_mask(pred, gt)
 99        if valid.sum() < 10:
100            return {
101                "chamfer_l1": float("nan"),
102                "fscore_1cm": 0.0,
103                "fscore_2cm": 0.0,
104                "fscore_5cm": 0.0,
105                "fscore_10cm": 0.0,
106            }
107
108        # Use independent RNGs so identical pred/gt get the same subset
109        rng_pred = np.random.default_rng(42)
110        rng_gt = np.random.default_rng(42)
111        pts_pred = _subsample(_backproject(pred, valid), _MAX_POINTS, rng_pred)
112        pts_gt = _subsample(_backproject(gt, valid), _MAX_POINTS, rng_gt)
113
114        chamfer = _chamfer_l1(pts_pred, pts_gt)
115
116        return {
117            "chamfer_l1": chamfer,
118            "fscore_1cm": _fscore(pts_pred, pts_gt, 0.01),
119            "fscore_2cm": _fscore(pts_pred, pts_gt, 0.02),
120            "fscore_5cm": _fscore(pts_pred, pts_gt, 0.05),
121            "fscore_10cm": _fscore(pts_pred, pts_gt, 0.10),
122        }

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 DepthNormals(rpx_benchmark.metrics.registry.MetricCalculator):
174@register_metric(TaskType.MONOCULAR_DEPTH)
175class DepthNormals(MetricCalculator):
176    """Surface normal MAE and %11.25° from depth-derived normals.
177
178    Normals are derived via finite differences on the back-projected
179    3D positions (intrinsics only, no pose). Pixels near depth holes
180    and image borders are masked out to avoid amplifying D435 noise.
181    """
182
183    name = "depth_normals"
184
185    def compute(
186        self,
187        prediction: DepthPrediction,
188        ground_truth: DepthGroundTruth,
189    ) -> Dict[str, float]:
190        if not isinstance(prediction, DepthPrediction):
191            raise MetricError(
192                f"DepthNormals expected DepthPrediction, got {type(prediction).__name__}",
193            )
194        if not isinstance(ground_truth, DepthGroundTruth):
195            raise MetricError(
196                f"DepthNormals expected DepthGroundTruth, got {type(ground_truth).__name__}",
197            )
198
199        pred = np.asarray(prediction.depth_map, dtype=np.float32)
200        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
201
202        from .depth_alignment import default_valid_mask
203
204        valid = default_valid_mask(pred, gt)
205
206        pred_normals, pred_nvalid = _depth_to_normals(pred, valid)
207        gt_normals, gt_nvalid = _depth_to_normals(gt, valid)
208
209        both_valid = pred_nvalid & gt_nvalid
210        if both_valid.sum() < 10:
211            return {"normal_mae": float("nan"), "normal_acc1125": 0.0}
212
213        # Cosine similarity → angular error (degrees)
214        cos_sim = np.sum(
215            pred_normals[both_valid] * gt_normals[both_valid], axis=-1
216        )
217        cos_sim = np.clip(cos_sim, -1.0, 1.0)
218        angles_deg = np.degrees(np.arccos(np.abs(cos_sim)))  # abs: sign ambiguity
219
220        normal_mae = float(np.mean(angles_deg))
221        normal_acc1125 = float(np.mean(angles_deg < 11.25))
222
223        return {
224            "normal_mae": normal_mae,
225            "normal_acc1125": normal_acc1125,
226        }

Surface normal MAE and %11.25° from depth-derived normals.

Normals are derived via finite differences on the back-projected 3D positions (intrinsics only, no pose). Pixels near depth holes and image borders are masked out to avoid amplifying D435 noise.

name = 'depth_normals'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
185    def compute(
186        self,
187        prediction: DepthPrediction,
188        ground_truth: DepthGroundTruth,
189    ) -> Dict[str, float]:
190        if not isinstance(prediction, DepthPrediction):
191            raise MetricError(
192                f"DepthNormals expected DepthPrediction, got {type(prediction).__name__}",
193            )
194        if not isinstance(ground_truth, DepthGroundTruth):
195            raise MetricError(
196                f"DepthNormals expected DepthGroundTruth, got {type(ground_truth).__name__}",
197            )
198
199        pred = np.asarray(prediction.depth_map, dtype=np.float32)
200        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
201
202        from .depth_alignment import default_valid_mask
203
204        valid = default_valid_mask(pred, gt)
205
206        pred_normals, pred_nvalid = _depth_to_normals(pred, valid)
207        gt_normals, gt_nvalid = _depth_to_normals(gt, valid)
208
209        both_valid = pred_nvalid & gt_nvalid
210        if both_valid.sum() < 10:
211            return {"normal_mae": float("nan"), "normal_acc1125": 0.0}
212
213        # Cosine similarity → angular error (degrees)
214        cos_sim = np.sum(
215            pred_normals[both_valid] * gt_normals[both_valid], axis=-1
216        )
217        cos_sim = np.clip(cos_sim, -1.0, 1.0)
218        angles_deg = np.degrees(np.arccos(np.abs(cos_sim)))  # abs: sign ambiguity
219
220        normal_mae = float(np.mean(angles_deg))
221        normal_acc1125 = float(np.mean(angles_deg < 11.25))
222
223        return {
224            "normal_mae": normal_mae,
225            "normal_acc1125": normal_acc1125,
226        }

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 DepthBoundaryF1(rpx_benchmark.metrics.registry.MetricCalculator):
258@register_metric(TaskType.MONOCULAR_DEPTH)
259class DepthBoundaryF1(MetricCalculator):
260    """Scale-invariant occluding-contour F1 from depth ratios.
261
262    Averages F1 over thresholds t ∈ {5, 10, 15, 20, 25} (percent).
263    No GT boundary annotations needed — contours are derived from
264    depth ratios on both predicted and GT depth maps.
265    """
266
267    name = "depth_boundary_f1"
268
269    _thresholds = (5, 10, 15, 20, 25)
270
271    def compute(
272        self,
273        prediction: DepthPrediction,
274        ground_truth: DepthGroundTruth,
275    ) -> Dict[str, float]:
276        if not isinstance(prediction, DepthPrediction):
277            raise MetricError(
278                f"DepthBoundaryF1 expected DepthPrediction, got {type(prediction).__name__}",
279            )
280        if not isinstance(ground_truth, DepthGroundTruth):
281            raise MetricError(
282                f"DepthBoundaryF1 expected DepthGroundTruth, got {type(ground_truth).__name__}",
283            )
284
285        pred = np.asarray(prediction.depth_map, dtype=np.float32)
286        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
287
288        f1_scores = []
289        for t in self._thresholds:
290            pred_c = _depth_ratio_contours(pred, t)
291            gt_c = _depth_ratio_contours(gt, t)
292
293            tp = float((pred_c & gt_c).sum())
294            fp = float((pred_c & ~gt_c).sum())
295            fn = float((~pred_c & gt_c).sum())
296
297            if tp + fp < 1e-6 and tp + fn < 1e-6:
298                # No contours in either → perfect agreement
299                f1_scores.append(1.0)
300            elif tp < 1e-6:
301                f1_scores.append(0.0)
302            else:
303                prec = tp / (tp + fp)
304                rec = tp / (tp + fn)
305                f1_scores.append(2 * prec * rec / (prec + rec))
306
307        return {"boundary_f1": float(np.mean(f1_scores))}

Scale-invariant occluding-contour F1 from depth ratios.

Averages F1 over thresholds t ∈ {5, 10, 15, 20, 25} (percent). No GT boundary annotations needed — contours are derived from depth ratios on both predicted and GT depth maps.

name = 'depth_boundary_f1'
def compute( self, prediction: rpx_benchmark.api.DepthPrediction, ground_truth: rpx_benchmark.api.DepthGroundTruth) -> Dict[str, float]:
271    def compute(
272        self,
273        prediction: DepthPrediction,
274        ground_truth: DepthGroundTruth,
275    ) -> Dict[str, float]:
276        if not isinstance(prediction, DepthPrediction):
277            raise MetricError(
278                f"DepthBoundaryF1 expected DepthPrediction, got {type(prediction).__name__}",
279            )
280        if not isinstance(ground_truth, DepthGroundTruth):
281            raise MetricError(
282                f"DepthBoundaryF1 expected DepthGroundTruth, got {type(ground_truth).__name__}",
283            )
284
285        pred = np.asarray(prediction.depth_map, dtype=np.float32)
286        gt = np.asarray(ground_truth.depth_map, dtype=np.float32)
287
288        f1_scores = []
289        for t in self._thresholds:
290            pred_c = _depth_ratio_contours(pred, t)
291            gt_c = _depth_ratio_contours(gt, t)
292
293            tp = float((pred_c & gt_c).sum())
294            fp = float((pred_c & ~gt_c).sum())
295            fn = float((~pred_c & gt_c).sum())
296
297            if tp + fp < 1e-6 and tp + fn < 1e-6:
298                # No contours in either → perfect agreement
299                f1_scores.append(1.0)
300            elif tp < 1e-6:
301                f1_scores.append(0.0)
302            else:
303                prec = tp / (tp + fp)
304                rec = tp / (tp + fn)
305                f1_scores.append(2 * prec * rec / (prec + rec))
306
307        return {"boundary_f1": float(np.mean(f1_scores))}

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.