diff --git a/docs/changelog.md b/docs/changelog.md index f4515da9..a8cbaae1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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`. diff --git a/src/supervision/config.py b/src/supervision/config.py index 18600b4a..fbb2df2e 100644 --- a/src/supervision/config.py +++ b/src/supervision/config.py @@ -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 diff --git a/src/supervision/dataset/formats/coco.py b/src/supervision/dataset/formats/coco.py index 7cd0be0f..95d5f681 100644 --- a/src/supervision/dataset/formats/coco.py +++ b/src/supervision/dataset/formats/coco.py @@ -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 diff --git a/src/supervision/metrics/f1_score.py b/src/supervision/metrics/f1_score.py index e4a58d51..88b79555 100644 --- a/src/supervision/metrics/f1_score.py +++ b/src/supervision/metrics/f1_score.py @@ -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) diff --git a/src/supervision/metrics/mean_average_precision.py b/src/supervision/metrics/mean_average_precision.py index 388be7b7..5cd04fa6 100644 --- a/src/supervision/metrics/mean_average_precision.py +++ b/src/supervision/metrics/mean_average_precision.py @@ -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]) diff --git a/src/supervision/metrics/mean_average_recall.py b/src/supervision/metrics/mean_average_recall.py index e540d783..0eee5b97 100644 --- a/src/supervision/metrics/mean_average_recall.py +++ b/src/supervision/metrics/mean_average_recall.py @@ -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] = [] diff --git a/src/supervision/metrics/precision.py b/src/supervision/metrics/precision.py index e3f64fe4..c8bcf34b 100644 --- a/src/supervision/metrics/precision.py +++ b/src/supervision/metrics/precision.py @@ -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) diff --git a/src/supervision/metrics/recall.py b/src/supervision/metrics/recall.py index dba16e17..16659508 100644 --- a/src/supervision/metrics/recall.py +++ b/src/supervision/metrics/recall.py @@ -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) diff --git a/src/supervision/metrics/utils/matching.py b/src/supervision/metrics/utils/matching.py index dc72234c..025754f9 100644 --- a/src/supervision/metrics/utils/matching.py +++ b/src/supervision/metrics/utils/matching.py @@ -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 diff --git a/src/supervision/metrics/utils/object_size.py b/src/supervision/metrics/utils/object_size.py index 2ed706da..b9b7bb2a 100644 --- a/src/supervision/metrics/utils/object_size.py +++ b/src/supervision/metrics/utils/object_size.py @@ -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: diff --git a/tests/metrics/test_size_bucket_regressions.py b/tests/metrics/test_size_bucket_regressions.py index 18fb10a6..ef20a092 100644 --- a/tests/metrics/test_size_bucket_regressions.py +++ b/tests/metrics/test_size_bucket_regressions.py @@ -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"), [ diff --git a/tests/metrics/utils/test_object_size.py b/tests/metrics/utils/test_object_size.py index a3f62239..ff26919d 100644 --- a/tests/metrics/utils/test_object_size.py +++ b/tests/metrics/utils/test_object_size.py @@ -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)