From f5ee5451199635e0190fcfe315b5da9ed59de2f0 Mon Sep 17 00:00:00 2001 From: Mahbod Date: Mon, 25 May 2026 12:51:08 +0200 Subject: [PATCH] feat(detection): add require_all_anchors to PolygonZone Currently a detection counts as 'in the zone' only when every anchor in triggering_anchors is inside. For boxes that straddle the zone boundary this means a detection with many anchors (e.g. the four corners) is often under-counted unless the user shrinks triggering_anchors to a single point. Add require_all_anchors: bool = True so callers can opt into 'any anchor inside is enough'. Default preserves current behaviour. Closes #1022. --- .../detection/tools/polygon_zone.py | 15 ++++++++-- tests/detection/test_polygonzone.py | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/supervision/detection/tools/polygon_zone.py b/src/supervision/detection/tools/polygon_zone.py index 58e979f9b1..3187646b82 100644 --- a/src/supervision/detection/tools/polygon_zone.py +++ b/src/supervision/detection/tools/polygon_zone.py @@ -32,6 +32,10 @@ class PolygonZone: which anchors of the detections bounding box to consider when deciding on whether the detection fits within the PolygonZone (default: (sv.Position.BOTTOM_CENTER,)). + require_all_anchors: If `True` (default), a detection is considered inside + the zone only when *every* anchor in `triggering_anchors` is inside. + If `False`, the detection triggers as soon as *any* anchor is inside. + Has no effect when `triggering_anchors` has a single entry. current_count: The current count of detected objects within the zone mask: The 2D bool mask for the polygon zone @@ -62,11 +66,14 @@ def __init__( self, polygon: npt.NDArray[np.int64], triggering_anchors: Iterable[Position] = (Position.BOTTOM_CENTER,), + require_all_anchors: bool = True, ): self.polygon = polygon.astype(int) - self.triggering_anchors = triggering_anchors - if not list(self.triggering_anchors): + # Materialize once so we can safely accept generators without exhausting them. + self.triggering_anchors = list(triggering_anchors) + if not self.triggering_anchors: raise ValueError("Triggering anchors cannot be empty.") + self.require_all_anchors = require_all_anchors self.current_count = 0 @@ -108,7 +115,9 @@ def trigger(self, detections: Detections) -> npt.NDArray[np.bool_]: in_bounds = (x >= 0) & (y >= 0) & (x < mask_w) & (y < mask_h) x_safe = np.clip(x, 0, mask_w - 1) y_safe = np.clip(y, 0, mask_h - 1) - is_in_zone = np.all(in_bounds & self.mask[y_safe, x_safe], axis=0) + anchor_hits = in_bounds & self.mask[y_safe, x_safe] + reduce = np.all if self.require_all_anchors else np.any + is_in_zone = reduce(anchor_hits, axis=0) self.current_count = int(np.sum(is_in_zone)) return is_in_zone.astype(bool) diff --git a/tests/detection/test_polygonzone.py b/tests/detection/test_polygonzone.py index 6e952ec7d9..903b3a8b8f 100644 --- a/tests/detection/test_polygonzone.py +++ b/tests/detection/test_polygonzone.py @@ -44,6 +44,16 @@ def test_empty_anchors_raises(self, polygon, triggering_anchors, exception): with exception: sv.PolygonZone(polygon, triggering_anchors=triggering_anchors) + def test_generator_triggering_anchors_is_materialized(self): + """A generator passed for triggering_anchors must not be silently exhausted.""" + zone = sv.PolygonZone( + POLYGON, triggering_anchors=(p for p in [sv.Position.CENTER]) + ) + detections = _create_detections( + xyxy=[[140.0, 140.0, 160.0, 160.0]], class_id=[0] + ) + assert zone.trigger(detections)[0] + class TestPolygonZoneTrigger: @pytest.mark.parametrize( @@ -164,3 +174,21 @@ def test_anchor_on_polygon_boundary_included(self) -> None: ) result = zone.trigger(detections) assert result[0] + + def test_require_all_anchors_false_triggers_on_any_anchor(self) -> None: + """With require_all_anchors=False, any anchor inside triggers.""" + # Box [85, 85, 115, 115] has only BOTTOM_RIGHT (115, 115) inside POLYGON + # ([100, 100]..[200, 200]); the other three corners are outside. + detections = _create_detections(xyxy=[[85.0, 85.0, 115.0, 115.0]], class_id=[0]) + anchors = ( + sv.Position.TOP_LEFT, + sv.Position.TOP_RIGHT, + sv.Position.BOTTOM_LEFT, + sv.Position.BOTTOM_RIGHT, + ) + all_required = sv.PolygonZone(POLYGON, triggering_anchors=anchors) + any_anchor = sv.PolygonZone( + POLYGON, triggering_anchors=anchors, require_all_anchors=False + ) + assert not all_required.trigger(detections)[0] + assert any_anchor.trigger(detections)[0]