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:
parent
3e80c0ce3c
commit
94cfb7f290
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue