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 <noreply@anthropic.com>
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>
This commit is contained in:
abritton2002 2026-04-13 11:10:59 -04:00 committed by GitHub
parent 6b5eed2ba1
commit e19f3120ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 82 additions and 17 deletions

View File

@ -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:

View File

@ -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