fix(metrics): ignore out-of-bucket detections in size-bucketed sco… (#2428)

* fix: ignore out-of-bucket   detections in size-bucketed scoring
* fix: honor area metadata in buckets

- Prefer stored COCO area metadata before geometry, mask, or OBB fallbacks.
- Add explicit-area, mask, and OBB regression coverage.
- Align COCO, mAP, and changelog area semantics.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Abhijith Neil Abraham 2026-07-15 03:48:19 -07:00 committed by GitHub
parent 3e80c0ce3c
commit 94cfb7f290
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 408 additions and 47 deletions

View File

@ -1,6 +1,6 @@
---
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
date_modified: 2026-07-08
date_modified: 2026-07-15
---
# Changelog
@ -18,6 +18,7 @@ date_modified: 2026-07-08
- `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)).
### Fixed
- Fixed [#2427](https://github.com/roboflow/supervision/issues/2427): size-bucketed `sv.Precision` and `sv.F1Score` no longer count out-of-bucket detections as false positives. `sv.Recall` now matches only targets in the requested bucket, and all three metrics prioritize in-bucket targets during matching, matching COCO evaluation and `sv.MeanAveragePrecision`. A pixel-perfect detector now scores 1.0 in every bucket.
- `sv.hex_to_rgba` now rejects multiple leading `#` characters instead of silently normalizing them, matching `sv.is_valid_hex` and the documented single optional prefix.
- `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged.
- Legacy COCO prediction loading in `sv.EvaluationDataset.load_predictions` now raises `ValueError` for image ids absent from the ground-truth COCO set instead of relying on a bare `assert`, so the check is no longer silently skipped under `python -O`.

View File

@ -1,5 +1,7 @@
CLASS_NAME_DATA_FIELD: str = "class_name"
COCO_RAW_SEGMENTATION: str = "coco_raw_segmentation"
#: Key for per-detection area metadata in ``Detections.data``.
AREA_DATA_FIELD: str = "area"
#: Key for oriented bounding-box corner coordinates in ``Detections.data``.
#:
#: Value layout: ``np.ndarray`` of shape ``(N, 4, 2)``, dtype ``float32``, pixel

View File

@ -9,7 +9,7 @@ import numpy as np
import numpy.typing as npt
from tqdm.auto import tqdm
from supervision.config import COCO_RAW_SEGMENTATION
from supervision.config import AREA_DATA_FIELD, COCO_RAW_SEGMENTATION
from supervision.dataset.utils import (
approximate_mask_with_polygons,
check_no_basename_collisions,
@ -360,8 +360,8 @@ def detections_to_coco_annotations(
segmentation = list(raw_seg)
stored_area = None
if "area" in data:
stored_area = float(np.asarray(data["area"]).item())
if AREA_DATA_FIELD in data:
stored_area = float(np.asarray(data[AREA_DATA_FIELD]).item())
if stored_area is not None and np.isfinite(stored_area):
area = stored_area

View File

@ -159,15 +159,6 @@ class F1Score(Metric["F1ScoreResult"]):
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
if size_category != ObjectSizeCategory.ANY:
# Score the requested bucket on bucket-filtered targets so detections
# outside the bucket cannot consume the only available target.
targets_list = [
self._filter_detections_by_size(targets, size_category)
for targets in targets_list
]
size_category = ObjectSizeCategory.ANY
iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32)
stats: list[Any] = []
@ -272,12 +263,20 @@ class F1Score(Metric["F1ScoreResult"]):
"Unsupported metric target for IoU calculation"
)
# None keeps the matcher on its single-round fast path
# when no size bucket is scored.
target_scored_mask = (
target_size_mask
if size_category != ObjectSizeCategory.ANY
else None
)
matches, matched_target_indices = (
_match_detection_batch_with_target_indices(
prediction_class_ids,
target_class_ids,
iou,
iou_thresholds,
target_scored_mask=target_scored_mask,
)
)
ignored_matches = np.zeros_like(matches, dtype=bool)

View File

@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
import numpy as np
import numpy.typing as npt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.config import AREA_DATA_FIELD, ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.utils.iou_and_nms import (
box_iou_batch_with_jaccard,
@ -1531,9 +1531,12 @@ class MeanAveragePrecision(Metric[MeanAveragePrecisionResult]):
# Use area from data if available, otherwise calculate from the
# metric target content (bbox, mask or oriented box)
area = None
if image_targets.data is not None and "area" in image_targets.data:
if (
image_targets.data is not None
and AREA_DATA_FIELD in image_targets.data
):
area_data: npt.NDArray[np.float32] = np.asarray(
image_targets.data["area"], dtype=np.float32
image_targets.data[AREA_DATA_FIELD], dtype=np.float32
)
area = float(area_data[target_idx])
@ -1611,10 +1614,10 @@ class MeanAveragePrecision(Metric[MeanAveragePrecisionResult]):
area = None
if (
image_predictions.data is not None
and "area" in image_predictions.data
and AREA_DATA_FIELD in image_predictions.data
):
area_data: npt.NDArray[np.float32] = np.asarray(
image_predictions.data["area"], dtype=np.float32
image_predictions.data[AREA_DATA_FIELD], dtype=np.float32
)
area = float(area_data[pred_idx])

View File

@ -390,13 +390,13 @@ class MeanAverageRecall(Metric["MeanAverageRecallResult"]):
size_category: ObjectSizeCategory = ObjectSizeCategory.ANY,
) -> MeanAverageRecallResult:
if size_category != ObjectSizeCategory.ANY:
# Score the requested bucket on bucket-filtered targets so detections
# outside the bucket cannot consume the only available target.
# Recall is unaffected by false-positive bookkeeping, and out-of-bucket
# predictions must still consume top-K rank slots, so bucket-filtering
# the targets is all the size handling mAR needs.
targets_list = [
self._filter_detections_by_size(targets, size_category)
for targets in targets_list
]
size_category = ObjectSizeCategory.ANY
iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32)
stats: list[Any] = []

View File

@ -161,15 +161,6 @@ class Precision(Metric["PrecisionResult"]):
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
if size_category != ObjectSizeCategory.ANY:
# Score the requested bucket on bucket-filtered targets so detections
# outside the bucket cannot consume the only available target.
targets_list = [
self._filter_detections_by_size(targets, size_category)
for targets in targets_list
]
size_category = ObjectSizeCategory.ANY
iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32)
stats: list[Any] = []
@ -268,12 +259,20 @@ class Precision(Metric["PrecisionResult"]):
"Unsupported metric target for IoU calculation"
)
# None keeps the matcher on its single-round fast path
# when no size bucket is scored.
target_scored_mask = (
target_size_mask
if size_category != ObjectSizeCategory.ANY
else None
)
matches, matched_target_indices = (
_match_detection_batch_with_target_indices(
prediction_class_ids,
target_class_ids,
iou,
iou_thresholds,
target_scored_mask=target_scored_mask,
)
)
ignored_matches = np.zeros_like(matches, dtype=bool)

View File

@ -153,15 +153,6 @@ class Recall(Metric["RecallResult"]):
targets_list: list[Detections],
size_category: ObjectSizeCategory = ObjectSizeCategory.ANY,
) -> RecallResult:
if size_category != ObjectSizeCategory.ANY:
# Score the requested bucket on bucket-filtered targets so detections
# outside the bucket cannot consume the only available target.
targets_list = [
self._filter_detections_by_size(targets, size_category)
for targets in targets_list
]
size_category = ObjectSizeCategory.ANY
iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32)
stats: list[Any] = []
@ -235,12 +226,20 @@ class Recall(Metric["RecallResult"]):
"Unsupported metric target for IoU calculation"
)
# None keeps the matcher on its single-round fast path
# when no size bucket is scored.
target_scored_mask = (
target_size_mask
if size_category != ObjectSizeCategory.ANY
else None
)
matches, matched_target_indices = (
_match_detection_batch_with_target_indices(
prediction_class_ids,
target_class_ids,
iou,
iou_thresholds,
target_scored_mask=target_scored_mask,
)
)
ignored_matches = np.zeros_like(matches, dtype=bool)

View File

@ -38,8 +38,31 @@ def _match_detection_batch_with_target_indices(
target_classes: npt.NDArray[np.int32],
iou: npt.NDArray[np.float32],
iou_thresholds: npt.NDArray[np.float32],
target_scored_mask: npt.NDArray[np.bool_] | None = None,
) -> tuple[npt.NDArray[np.bool_], npt.NDArray[np.int32]]:
"""Match predictions to targets and retain target indices per IoU threshold."""
"""Match predictions to targets and retain target indices per IoU threshold.
When ``target_scored_mask`` is provided, scored targets are matched first and
only predictions left unmatched may then match unscored (ignored) targets.
This mirrors COCO evaluation, where detections prefer non-ignored ground
truth, so an out-of-bucket target can never steal a prediction from an
in-bucket one.
Examples:
>>> import numpy as np
>>> predictions_classes = np.array([0], dtype=np.int32)
>>> target_classes = np.array([0], dtype=np.int32)
>>> iou = np.array([[1.0]], dtype=np.float32)
>>> thresholds = np.array([0.5], dtype=np.float32)
>>> correct, matched = _match_detection_batch_with_target_indices(
... predictions_classes,
... target_classes,
... iou,
... thresholds,
... )
>>> correct.tolist(), matched.tolist()
([[True]], [[0]])
"""
num_predictions = predictions_classes.shape[0]
num_iou_levels = iou_thresholds.shape[0]
correct = np.zeros((num_predictions, num_iou_levels), dtype=bool)
@ -47,10 +70,21 @@ def _match_detection_batch_with_target_indices(
correct_class = target_classes[:, None] == predictions_classes
for i, iou_level in enumerate(iou_thresholds):
matched_indices = np.where((iou >= iou_level) & correct_class)
candidate_pairs = (iou >= iou_level) & correct_class
if target_scored_mask is None:
match_rounds = [candidate_pairs]
else:
match_rounds = [
candidate_pairs & target_scored_mask[:, None],
candidate_pairs & ~target_scored_mask[:, None],
]
for target_idx, prediction_idx in _greedy_match(iou, matched_indices):
correct[prediction_idx, i] = True
matched_targets[prediction_idx, i] = target_idx
for round_pairs in match_rounds:
unmatched_predictions = matched_targets[:, i] < 0
matched_indices = np.where(round_pairs & unmatched_predictions)
for target_idx, prediction_idx in _greedy_match(iou, matched_indices):
correct[prediction_idx, i] = True
matched_targets[prediction_idx, i] = target_idx
return correct, matched_targets

View File

@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, cast
import numpy as np
import numpy.typing as npt
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.config import AREA_DATA_FIELD, ORIENTED_BOX_COORDINATES
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.masks import count_mask_pixels
from supervision.metrics.core import MetricTarget
@ -128,6 +128,41 @@ def get_bbox_size_category(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.int_]
return result
def get_area_size_category(
areas: npt.NDArray[np.number],
) -> npt.NDArray[np.int_]:
"""Get object size categories from per-detection pixel areas.
Args:
areas: One-dimensional pixel areas shaped (N,).
Returns:
The size category of each area, matching the enum values of
`ObjectSizeCategory`. Shaped (N,).
Raises:
ValueError: If `areas` is not one-dimensional.
Example:
```pycon
>>> import numpy as np
>>> from supervision.metrics.utils.object_size import get_area_size_category
>>> get_area_size_category(np.array([100, 2500, 10000]))
array([1, 2, 3])
```
"""
if len(areas.shape) != 1:
raise ValueError("Areas must be shaped (N,)")
result = np.full(areas.shape, ObjectSizeCategory.ANY.value)
sm, lg = SIZE_THRESHOLDS
result[areas < sm] = ObjectSizeCategory.SMALL.value
result[(areas >= sm) & (areas < lg)] = ObjectSizeCategory.MEDIUM.value
result[areas >= lg] = ObjectSizeCategory.LARGE.value
return result
def get_mask_size_category(
mask: npt.NDArray[np.bool_] | CompactMask,
) -> npt.NDArray[np.int_]:
@ -224,8 +259,11 @@ def get_obb_size_category(xyxyxyxy: npt.NDArray[np.number]) -> npt.NDArray[np.in
def get_detection_size_category(
detections: Detections, metric_target: MetricTarget = MetricTarget.BOXES
) -> npt.NDArray[np.int_]:
"""
Get the size category of a detections object.
"""Get the size category of each detection.
Explicit area metadata takes precedence for every metric target, matching
COCO-style evaluation. Geometry, mask, or oriented-box content remains the
fallback when area metadata is absent.
Args:
detections: The detections object.
@ -235,7 +273,35 @@ def get_detection_size_category(
Returns:
The size category of each bounding box, matching
the enum values of ObjectSizeCategory. Shaped (N,).
Raises:
ValueError: If area metadata is not one-dimensional or is not aligned
with the detections.
Example:
```pycon
>>> import numpy as np
>>> from supervision.config import AREA_DATA_FIELD
>>> from supervision.detection.core import Detections
>>> detections = Detections(
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
... data={AREA_DATA_FIELD: np.array([2500.0])},
... )
>>> get_detection_size_category(detections)
array([2])
```
"""
area_data = detections.data.get(AREA_DATA_FIELD)
if area_data is not None:
areas = np.asarray(area_data, dtype=np.float64)
if len(areas.shape) != 1 or len(areas) != len(detections):
raise ValueError(
"Detection area metadata must be shaped (N,) and aligned "
"with detections"
)
return get_area_size_category(areas)
if metric_target == MetricTarget.BOXES:
return get_bbox_size_category(detections.xyxy)
if metric_target == MetricTarget.MASKS:

View File

@ -1,6 +1,7 @@
import numpy as np
import pytest
from supervision.config import AREA_DATA_FIELD, ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.metrics import (
F1Score,
@ -125,6 +126,219 @@ def test_medium_bucket_mar_counts_global_rank_budget() -> None:
assert result.medium_objects.mAR_at_100 == 1.0
@pytest.mark.parametrize(
("metric_cls", "bucket_attrs", "score_attrs"),
[
pytest.param(
Precision,
("medium_objects", "large_objects"),
("precision_at_50", "precision_at_75"),
id="precision",
),
pytest.param(
Recall,
("medium_objects", "large_objects"),
("recall_at_50", "recall_at_75"),
id="recall",
),
pytest.param(
F1Score,
("medium_objects", "large_objects"),
("f1_50", "f1_75"),
id="f1",
),
pytest.param(
MeanAverageRecall,
("medium_objects", "large_objects"),
("mAR_at_10", "mAR_at_100"),
id="mar",
),
],
)
def test_perfect_detector_scores_full_marks_in_every_bucket(
metric_cls, bucket_attrs, score_attrs
):
"""Bucketed metrics must score a perfect detector 1.0 in every bucket."""
xyxy = np.array(
[[0, 0, 50, 50], [100, 100, 250, 250]],
dtype=np.float32,
)
predictions = Detections(
xyxy=xyxy.copy(),
confidence=np.array([0.9, 0.8], dtype=np.float32),
class_id=np.array([0, 0], dtype=np.int32),
)
targets = Detections(
xyxy=xyxy.copy(),
class_id=np.array([0, 0], dtype=np.int32),
)
result = (
metric_cls(metric_target=MetricTarget.BOXES)
.update(predictions, targets)
.compute()
)
for bucket_attr in bucket_attrs:
bucket_result = getattr(result, bucket_attr)
assert bucket_result is not None
for score_attr in score_attrs:
assert getattr(bucket_result, score_attr) == pytest.approx(1.0)
@pytest.mark.parametrize(
("metric_cls", "score_attr", "overall_expected"),
[
pytest.param(Precision, "precision_at_50", 0.5, id="precision"),
pytest.param(F1Score, "f1_50", 2 / 3, id="f1"),
],
)
def test_unmatched_out_of_bucket_prediction_does_not_penalize_bucket(
metric_cls, score_attr, overall_expected
):
"""A stray large false positive lowers overall scores, not the medium bucket."""
predictions = Detections(
xyxy=np.array(
[[0, 0, 50, 50], [200, 200, 350, 350]],
dtype=np.float32,
),
confidence=np.array([0.9, 0.8], dtype=np.float32),
class_id=np.array([0, 0], dtype=np.int32),
)
targets = Detections(
xyxy=np.array([[0, 0, 50, 50]], dtype=np.float32),
class_id=np.array([0], dtype=np.int32),
)
result = (
metric_cls(metric_target=MetricTarget.BOXES)
.update(predictions, targets)
.compute()
)
assert getattr(result, score_attr) == pytest.approx(overall_expected)
assert result.medium_objects is not None
assert getattr(result.medium_objects, score_attr) == pytest.approx(1.0)
@pytest.mark.parametrize(
("metric_cls", "score_attr"),
[
pytest.param(Precision, "precision_at_50", id="precision"),
pytest.param(Recall, "recall_at_50", id="recall"),
pytest.param(F1Score, "f1_50", id="f1"),
],
)
def test_explicit_area_metadata_controls_bucket_assignment(
metric_cls: type, score_attr: str
) -> None:
"""Explicit area metadata controls bucket assignment for scoring metrics."""
box = np.array([[0, 0, 10, 10]], dtype=np.float32)
predictions = Detections(
xyxy=box.copy(),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0], dtype=np.int32),
)
targets = Detections(
xyxy=box.copy(),
class_id=np.array([0], dtype=np.int32),
data={AREA_DATA_FIELD: np.array([2500.0], dtype=np.float32)},
)
result = (
metric_cls(metric_target=MetricTarget.BOXES)
.update(predictions, targets)
.compute()
)
assert result.small_objects is not None
assert result.medium_objects is not None
assert getattr(result.small_objects, score_attr) == pytest.approx(0.0)
assert getattr(result.medium_objects, score_attr) == pytest.approx(1.0)
@pytest.mark.parametrize(
("metric_cls", "score_attr"),
[
pytest.param(Precision, "precision_at_50", id="precision"),
pytest.param(Recall, "recall_at_50", id="recall"),
pytest.param(F1Score, "f1_50", id="f1"),
],
)
def test_mask_bucket_ignores_out_of_bucket_predictions(
metric_cls: type, score_attr: str
) -> None:
"""Mask bucket scoring ignores predictions outside the requested bucket."""
masks = np.zeros((2, 128, 128), dtype=bool)
masks[0, :50, :50] = True
masks[1, 28:, 28:] = True
target_mask = masks[0:1].copy()
predictions = Detections(
xyxy=np.array([[0, 0, 50, 50], [28, 28, 128, 128]], dtype=np.float32),
mask=masks,
confidence=np.array([0.9, 0.8], dtype=np.float32),
class_id=np.array([0, 0], dtype=np.int32),
)
targets = Detections(
xyxy=np.array([[0, 0, 50, 50]], dtype=np.float32),
mask=target_mask,
class_id=np.array([0], dtype=np.int32),
)
result = (
metric_cls(metric_target=MetricTarget.MASKS)
.update(predictions, targets)
.compute()
)
assert result.medium_objects is not None
assert getattr(result.medium_objects, score_attr) == pytest.approx(1.0)
@pytest.mark.parametrize(
("metric_cls", "score_attr"),
[
pytest.param(Precision, "precision_at_50", id="precision"),
pytest.param(Recall, "recall_at_50", id="recall"),
pytest.param(F1Score, "f1_50", id="f1"),
],
)
def test_obb_bucket_ignores_out_of_bucket_predictions(
metric_cls: type, score_attr: str
) -> None:
"""OBB bucket scoring ignores predictions outside the requested bucket."""
target_obb = np.array([[[0, 0], [50, 0], [50, 50], [0, 50]]], dtype=np.float32)
prediction_obb = np.concatenate(
[
target_obb,
np.array(
[[[28, 28], [128, 28], [128, 128], [28, 128]]],
dtype=np.float32,
),
]
)
predictions = Detections(
xyxy=np.array([[0, 0, 50, 50], [28, 28, 128, 128]], dtype=np.float32),
confidence=np.array([0.9, 0.8], dtype=np.float32),
class_id=np.array([0, 0], dtype=np.int32),
data={ORIENTED_BOX_COORDINATES: prediction_obb},
)
targets = Detections(
xyxy=np.array([[0, 0, 50, 50]], dtype=np.float32),
class_id=np.array([0], dtype=np.int32),
data={ORIENTED_BOX_COORDINATES: target_obb},
)
result = (
metric_cls(metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES)
.update(predictions, targets)
.compute()
)
assert result.medium_objects is not None
assert getattr(result.medium_objects, score_attr) == pytest.approx(1.0)
@pytest.mark.parametrize(
("metric_cls", "missing_side"),
[

View File

@ -5,9 +5,13 @@ from __future__ import annotations
import numpy as np
import pytest
from supervision.config import AREA_DATA_FIELD
from supervision.detection.core import Detections
from supervision.metrics.core import MetricTarget
from supervision.metrics.utils.object_size import (
SIZE_THRESHOLDS,
ObjectSizeCategory,
get_detection_size_category,
get_mask_size_category,
)
@ -60,3 +64,43 @@ class TestGetMaskSizeCategory:
ObjectSizeCategory.LARGE.value,
],
)
def test_detection_size_category_prefers_explicit_area_metadata() -> None:
"""Explicit area metadata overrides geometry for size categorization."""
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
data={AREA_DATA_FIELD: np.array([2500.0], dtype=np.float32)},
)
result = get_detection_size_category(detections, MetricTarget.BOXES)
np.testing.assert_array_equal(result, [ObjectSizeCategory.MEDIUM.value])
def test_detection_size_category_falls_back_without_area_metadata() -> None:
"""Geometry remains the fallback when explicit area metadata is absent."""
detections = Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32))
result = get_detection_size_category(detections, MetricTarget.BOXES)
np.testing.assert_array_equal(result, [ObjectSizeCategory.SMALL.value])
@pytest.mark.parametrize(
"area_data",
[
pytest.param(np.array([[2500.0]], dtype=np.float32), id="two-dimensional"),
],
)
def test_detection_size_category_rejects_invalid_area_metadata(
area_data: np.ndarray,
) -> None:
"""Invalid area metadata is rejected instead of misaligning detections."""
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
data={AREA_DATA_FIELD: area_data},
)
with pytest.raises(ValueError, match="area metadata"):
get_detection_size_category(detections, MetricTarget.BOXES)