Skip to content

Commit 937ed4a

Browse files
Ace3ZBordaclaude[bot]codex
authored
feat(detection): add require_all_anchors to PolygonZone (roboflow#2272)
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. * test: strengthen PolygonZone require_all_anchors coverage * docs: clarify require_all_anchors anchor-based semantics --------- Co-authored-by: jirka <6035284+Borda@users.noreply.github.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex <codex@openai.com>
1 parent 60d748e commit 937ed4a

2 files changed

Lines changed: 146 additions & 3 deletions

File tree

src/supervision/detection/tools/polygon_zone.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ class PolygonZone:
3030
which anchors of the detections bounding box to consider when deciding on
3131
whether the detection fits within the PolygonZone
3232
(default: (sv.Position.BOTTOM_CENTER,)).
33+
require_all_anchors: If `True` (default), a detection is considered inside
34+
the zone only when *every* anchor in `triggering_anchors` is inside.
35+
If `False`, the detection triggers as soon as *any* anchor is inside.
36+
Has no effect when `triggering_anchors` has a single entry.
37+
This is anchor-based, not a true geometric box/polygon intersection
38+
test: it fires only when a listed anchor point lands inside the mask.
39+
Use mask/IoU-based approaches instead when full-overlap semantics are
40+
required.
3341
current_count: The current count of detected objects within the zone
3442
mask: The 2D bool mask for the polygon zone
3543
@@ -49,17 +57,33 @@ class PolygonZone:
4957
1
5058
5159
```
60+
61+
```pycon
62+
>>> polygon = np.array([[0, 0], [100, 0], [100, 100], [0, 100]])
63+
>>> polygon_zone = sv.PolygonZone(
64+
... polygon=polygon,
65+
... triggering_anchors=[sv.Position.TOP_LEFT, sv.Position.BOTTOM_RIGHT],
66+
... require_all_anchors=False,
67+
... )
68+
>>> detections = sv.Detections(xyxy=np.array([[80, 80, 120, 120]]))
69+
>>> polygon_zone.trigger(detections)
70+
array([ True])
71+
72+
```
5273
"""
5374

5475
def __init__(
5576
self,
5677
polygon: npt.NDArray[np.int64],
5778
triggering_anchors: Iterable[Position] = (Position.BOTTOM_CENTER,),
79+
require_all_anchors: bool = True,
5880
) -> None:
5981
self.polygon = polygon.astype(int)
60-
self.triggering_anchors = triggering_anchors
61-
if not list(self.triggering_anchors):
82+
# Materialize once so we can safely accept generators without exhausting them.
83+
self.triggering_anchors = list(triggering_anchors)
84+
if not self.triggering_anchors:
6285
raise ValueError("Triggering anchors cannot be empty.")
86+
self.require_all_anchors = require_all_anchors
6387

6488
self.current_count = 0
6589

@@ -101,7 +125,9 @@ def trigger(self, detections: Detections) -> npt.NDArray[np.bool_]:
101125
in_bounds = (x >= 0) & (y >= 0) & (x < mask_w) & (y < mask_h)
102126
x_safe = np.clip(x, 0, mask_w - 1)
103127
y_safe = np.clip(y, 0, mask_h - 1)
104-
is_in_zone = np.all(in_bounds & self.mask[y_safe, x_safe], axis=0)
128+
anchor_hits = in_bounds & self.mask[y_safe, x_safe]
129+
reduce = np.all if self.require_all_anchors else np.any
130+
is_in_zone = reduce(anchor_hits, axis=0)
105131
self.current_count = int(np.sum(is_in_zone))
106132
return cast(npt.NDArray[np.bool_], is_in_zone.astype(bool))
107133

tests/detection/test_polygonzone.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ def test_empty_anchors_raises(self, polygon, triggering_anchors, exception) -> N
4444
with exception:
4545
sv.PolygonZone(polygon, triggering_anchors=triggering_anchors)
4646

47+
def test_generator_triggering_anchors_is_materialized(self):
48+
"""A generator passed for triggering_anchors must not be silently exhausted.
49+
50+
Calls trigger() twice on the same zone: a materialized list keeps returning
51+
results on repeated calls, whereas an un-materialized generator would be
52+
exhausted after the first trigger() and silently yield no anchors on the
53+
second.
54+
"""
55+
zone = sv.PolygonZone(
56+
POLYGON, triggering_anchors=(p for p in [sv.Position.CENTER])
57+
)
58+
detections = _create_detections(
59+
xyxy=[[140.0, 140.0, 160.0, 160.0]], class_id=[0]
60+
)
61+
assert zone.trigger(detections)[0]
62+
assert zone.trigger(detections)[0]
63+
4764

4865
class TestPolygonZoneTrigger:
4966
@pytest.mark.parametrize(
@@ -165,6 +182,106 @@ def test_anchor_on_polygon_boundary_included(self) -> None:
165182
result = zone.trigger(detections)
166183
assert result[0]
167184

185+
def test_anchor_on_polygon_boundary_included_any_mode(self) -> None:
186+
"""With require_all_anchors=False and multiple anchors, an anchor landing
187+
exactly on the polygon boundary is enough to trigger, even though the
188+
other anchors of the same detection fall outside the polygon."""
189+
polygon = np.array([[0, 0], [100, 0], [100, 100], [0, 100]])
190+
anchors = [sv.Position.TOP_LEFT, sv.Position.BOTTOM_RIGHT]
191+
# TOP_LEFT = (-50, -50) is outside; BOTTOM_RIGHT = (100, 100) is exactly
192+
# the polygon corner (boundary), which counts as inside.
193+
detections = _create_detections(
194+
xyxy=[[-50.0, -50.0, 100.0, 100.0]],
195+
class_id=[0],
196+
)
197+
any_anchor_zone = sv.PolygonZone(
198+
polygon, triggering_anchors=anchors, require_all_anchors=False
199+
)
200+
all_anchors_zone = sv.PolygonZone(
201+
polygon, triggering_anchors=anchors, require_all_anchors=True
202+
)
203+
assert any_anchor_zone.trigger(detections)[0]
204+
assert not all_anchors_zone.trigger(detections)[0]
205+
206+
def test_require_all_anchors_false_triggers_on_any_anchor(self) -> None:
207+
"""With require_all_anchors=False, any anchor inside triggers."""
208+
# Box [85, 85, 115, 115] has only BOTTOM_RIGHT (115, 115) inside POLYGON
209+
# ([100, 100]..[200, 200]); the other three corners are outside.
210+
detections = _create_detections(xyxy=[[85.0, 85.0, 115.0, 115.0]], class_id=[0])
211+
anchors = (
212+
sv.Position.TOP_LEFT,
213+
sv.Position.TOP_RIGHT,
214+
sv.Position.BOTTOM_LEFT,
215+
sv.Position.BOTTOM_RIGHT,
216+
)
217+
all_required = sv.PolygonZone(POLYGON, triggering_anchors=anchors)
218+
any_anchor = sv.PolygonZone(
219+
POLYGON, triggering_anchors=anchors, require_all_anchors=False
220+
)
221+
assert not all_required.trigger(detections)[0]
222+
result = any_anchor.trigger(detections)
223+
assert result[0]
224+
assert any_anchor.current_count == 1
225+
226+
def test_require_all_anchors_false_all_outside_does_not_trigger(self) -> None:
227+
"""With require_all_anchors=False, a detection with every anchor outside the
228+
zone still does not trigger (exercises the np.any all-False branch)."""
229+
# Box [0, 0, 20, 20] has all four corners well outside POLYGON
230+
# ([100, 100]..[200, 200]).
231+
detections = _create_detections(xyxy=[[0.0, 0.0, 20.0, 20.0]], class_id=[0])
232+
anchors = (
233+
sv.Position.TOP_LEFT,
234+
sv.Position.TOP_RIGHT,
235+
sv.Position.BOTTOM_LEFT,
236+
sv.Position.BOTTOM_RIGHT,
237+
)
238+
any_anchor = sv.PolygonZone(
239+
POLYGON, triggering_anchors=anchors, require_all_anchors=False
240+
)
241+
result = any_anchor.trigger(detections)
242+
assert not result[0]
243+
assert any_anchor.current_count == 0
244+
245+
def test_require_all_anchors_default_matches_explicit_true(self) -> None:
246+
"""Omitting require_all_anchors and passing require_all_anchors=True
247+
explicitly must produce identical trigger() results, pinning the
248+
documented default (True)."""
249+
anchors = (
250+
sv.Position.TOP_LEFT,
251+
sv.Position.TOP_RIGHT,
252+
sv.Position.BOTTOM_LEFT,
253+
sv.Position.BOTTOM_RIGHT,
254+
)
255+
default_zone = sv.PolygonZone(POLYGON, triggering_anchors=anchors)
256+
explicit_true_zone = sv.PolygonZone(
257+
POLYGON, triggering_anchors=anchors, require_all_anchors=True
258+
)
259+
assert np.array_equal(
260+
default_zone.trigger(DETECTIONS), explicit_true_zone.trigger(DETECTIONS)
261+
)
262+
263+
def test_require_all_anchors_has_no_effect_with_single_anchor(self) -> None:
264+
"""With a single triggering anchor, require_all_anchors is a no-op: both
265+
settings must produce identical trigger() results (per the docstring:
266+
"Has no effect when triggering_anchors has a single entry")."""
267+
# Box [140, 140, 160, 160] has its BOTTOM_CENTER inside POLYGON.
268+
detections = _create_detections(
269+
xyxy=[[140.0, 140.0, 160.0, 160.0]], class_id=[0]
270+
)
271+
require_all = sv.PolygonZone(
272+
POLYGON,
273+
triggering_anchors=[sv.Position.BOTTOM_CENTER],
274+
require_all_anchors=True,
275+
)
276+
require_any = sv.PolygonZone(
277+
POLYGON,
278+
triggering_anchors=[sv.Position.BOTTOM_CENTER],
279+
require_all_anchors=False,
280+
)
281+
assert np.array_equal(
282+
require_all.trigger(detections), require_any.trigger(detections)
283+
)
284+
168285
def test_half_pixel_anchor_uses_nearest_pixel(self) -> None:
169286
"""Half-pixel anchors should not be biased toward the larger x and y."""
170287
zone_left = sv.PolygonZone(

0 commit comments

Comments
 (0)