Files
94cfb7f290 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>
2026-07-15 12:48:19 +02:00

107 lines
3.8 KiB
Python

"""Tests for supervision.metrics.utils.object_size — mask pixel-count path."""
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,
)
def _reference_mask_size_category(mask: np.ndarray) -> np.ndarray:
"""Reference categorization using a plain ``np.sum`` area."""
areas = np.sum(mask, axis=(1, 2))
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
class TestGetMaskSizeCategory:
"""Verify the count_nonzero-based mask area categorization."""
@pytest.mark.parametrize(
("n", "h", "w", "seed"),
[
pytest.param(0, 8, 8, 1, id="empty"),
pytest.param(1, 40, 40, 2, id="single"),
pytest.param(6, 64, 64, 3, id="batch"),
pytest.param(4, 200, 200, 4, id="across-thresholds"),
],
)
def test_matches_sum_based_reference(
self, n: int, h: int, w: int, seed: int
) -> None:
"""Categories match a plain ``np.sum(axis=(1, 2))`` reference."""
rng = np.random.default_rng(seed)
mask = rng.random((n, h, w)) < rng.random()
np.testing.assert_array_equal(
get_mask_size_category(mask), _reference_mask_size_category(mask)
)
def test_threshold_boundaries(self) -> None:
"""Pixel counts on each SMALL/MEDIUM/LARGE boundary land in the right bin."""
sm, lg = SIZE_THRESHOLDS
mask = np.zeros((3, 100, 100), dtype=bool)
mask[0].flat[: sm - 1] = True # below SMALL threshold -> SMALL
mask[1].flat[:sm] = True # exactly SMALL threshold -> MEDIUM
mask[2].flat[:lg] = True # exactly LARGE threshold -> LARGE
np.testing.assert_array_equal(
get_mask_size_category(mask),
[
ObjectSizeCategory.SMALL.value,
ObjectSizeCategory.MEDIUM.value,
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)