From e19f3120ca1971cbd86b95c0f02e948e140b9b5c Mon Sep 17 00:00:00 2001 From: abritton2002 Date: Mon, 13 Apr 2026 11:10:59 -0400 Subject: [PATCH] fix: is_empty() returns False for empty tracker arrays (#2209) * fix: is_empty() returns False for empty tracker arrays (closes #2195) * fix: use tuple in pytest.mark.parametrize (ruff PT006) * test: add mask regression case and improve test_is_empty quality * docs: update is_empty() docstring with pycon examples for clarity and consistency --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/supervision/detection/core.py | 30 +++++++++----- tests/detection/test_core.py | 69 +++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 600adee4..34578627 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -2073,17 +2073,27 @@ class Detections: def is_empty(self) -> bool: """ - Returns `True` if the `Detections` object is considered empty. + Check whether the `Detections` object has zero bounding boxes. + + Returns: + `True` if there are no detections, `False` otherwise. + + Examples: + ```pycon + >>> import numpy as np + >>> import supervision as sv + >>> detections = sv.Detections( + ... xyxy=np.array([[10, 20, 110, 120]]), + ... class_id=np.array([1]), + ... tracker_id=np.array([1]), + ... ) + >>> filtered = detections[detections.class_id == 99] + >>> filtered.is_empty() + True + + ``` """ - # Fast path: avoids __eq__ which calls np.array_equal(to_dense(), ...) - # and would materialise the entire (N, H, W) CompactMask to a dense - # array just to check emptiness — O(N·H·W) for an O(1) check. - if len(self.xyxy) > 0: - return False - empty_detections = Detections.empty() - empty_detections.data = self.data - empty_detections.metadata = self.metadata - return bool(self == empty_detections) + return len(self.xyxy) == 0 @classmethod def merge(cls, detections_list: list[Detections]) -> Detections: diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index ccd3dca6..94434c6f 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -325,9 +325,9 @@ def test_getitem( ), # single detection with fields ( [TEST_DET_NONE], - TEST_DET_NONE, + Detections.empty(), DoesNotRaise(), - ), # Single weakly-defined detection + ), # Single weakly-defined detection: now correctly treated as empty ( [TEST_DET_1, TEST_DET_2], TEST_DET_1_2, @@ -348,17 +348,17 @@ def test_getitem( ), # Single detection and empty-array fields ( [TEST_DET_ZERO_LENGTH, TEST_DET_ZERO_LENGTH], - TEST_DET_ZERO_LENGTH, + Detections.empty(), DoesNotRaise(), - ), # Zero-length fields across all Detections + ), # Zero-length fields: all treated as empty, result is canonical empty ( [ TEST_DET_1, TEST_DET_NONE, ], - None, - pytest.raises(ValueError, match="mask' fields must be None"), - ), # Empty detection, but not Detections.empty() + TEST_DET_1, + DoesNotRaise(), + ), # Empty detection stripped; non-empty detection returned intact # Errors: Non-zero-length differently defined keys & data ( [TEST_DET_1, TEST_DET_DIFFERENT_FIELDS], @@ -878,3 +878,58 @@ def test_merge_inner_detection_object_pair( with exception: result = merge_inner_detection_object_pair(detection_1, detection_2) assert result == expected_result + + +@pytest.mark.parametrize( + ("detections", "expected"), + [ + ( + Detections.empty(), + True, + ), # canonical empty + ( + Detections( + xyxy=np.array([[0, 0, 10, 10]]), + class_id=np.array([1]), + confidence=np.array([0.9]), + ), + False, + ), # non-empty, no tracker_id + ( + Detections( + xyxy=np.array([[0, 0, 10, 10], [0, 0, 20, 30]]), + class_id=np.array([1, 2]), + confidence=np.array([0.6, 0.7]), + tracker_id=np.array([1, 2]), + )[np.array([False, False])], + True, + ), # filtered to empty with tracker_id — the regression case from #2195 + ( + Detections( + xyxy=np.array([[0, 0, 10, 10], [0, 0, 20, 30]]), + class_id=np.array([1, 2]), + confidence=np.array([0.6, 0.7]), + tracker_id=np.array([1, 2]), + )[np.array([True, False])], + False, + ), # one detection remaining after filter + ( + Detections( + xyxy=np.array([[0, 0, 10, 10], [0, 0, 20, 30]]), + mask=np.zeros((2, 4, 4), dtype=bool), + class_id=np.array([1, 2]), + )[np.array([False, False])], + True, + ), # filtered to empty with mask — same bug could affect mask field + ], + ids=[ + "canonical_empty", + "non_empty_no_tracker", + "filtered_empty_with_tracker", + "one_remaining_after_filter", + "filtered_empty_with_mask", + ], +) +def test_is_empty(detections: Detections, expected: bool) -> None: + """Verify is_empty() returns True iff the Detections object has zero detections.""" + assert detections.is_empty() == expected