feat(detection): add require_all_anchors to PolygonZone (#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>
This commit is contained in:
Mahbod 2026-07-17 18:56:42 +02:00 committed by GitHub
parent 60d748e57d
commit 937ed4af37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 146 additions and 3 deletions

View File

@ -30,6 +30,14 @@ 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.
This is anchor-based, not a true geometric box/polygon intersection
test: it fires only when a listed anchor point lands inside the mask.
Use mask/IoU-based approaches instead when full-overlap semantics are
required.
current_count: The current count of detected objects within the zone
mask: The 2D bool mask for the polygon zone
@ -49,17 +57,33 @@ class PolygonZone:
1
```
```pycon
>>> polygon = np.array([[0, 0], [100, 0], [100, 100], [0, 100]])
>>> polygon_zone = sv.PolygonZone(
... polygon=polygon,
... triggering_anchors=[sv.Position.TOP_LEFT, sv.Position.BOTTOM_RIGHT],
... require_all_anchors=False,
... )
>>> detections = sv.Detections(xyxy=np.array([[80, 80, 120, 120]]))
>>> polygon_zone.trigger(detections)
array([ True])
```
"""
def __init__(
self,
polygon: npt.NDArray[np.int64],
triggering_anchors: Iterable[Position] = (Position.BOTTOM_CENTER,),
require_all_anchors: bool = True,
) -> None:
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
@ -101,7 +125,9 @@ class PolygonZone:
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 cast(npt.NDArray[np.bool_], is_in_zone.astype(bool))

View File

@ -44,6 +44,23 @@ class TestPolygonZoneInit:
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.
Calls trigger() twice on the same zone: a materialized list keeps returning
results on repeated calls, whereas an un-materialized generator would be
exhausted after the first trigger() and silently yield no anchors on the
second.
"""
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]
assert zone.trigger(detections)[0]
class TestPolygonZoneTrigger:
@pytest.mark.parametrize(
@ -165,6 +182,106 @@ class TestPolygonZoneTrigger:
result = zone.trigger(detections)
assert result[0]
def test_anchor_on_polygon_boundary_included_any_mode(self) -> None:
"""With require_all_anchors=False and multiple anchors, an anchor landing
exactly on the polygon boundary is enough to trigger, even though the
other anchors of the same detection fall outside the polygon."""
polygon = np.array([[0, 0], [100, 0], [100, 100], [0, 100]])
anchors = [sv.Position.TOP_LEFT, sv.Position.BOTTOM_RIGHT]
# TOP_LEFT = (-50, -50) is outside; BOTTOM_RIGHT = (100, 100) is exactly
# the polygon corner (boundary), which counts as inside.
detections = _create_detections(
xyxy=[[-50.0, -50.0, 100.0, 100.0]],
class_id=[0],
)
any_anchor_zone = sv.PolygonZone(
polygon, triggering_anchors=anchors, require_all_anchors=False
)
all_anchors_zone = sv.PolygonZone(
polygon, triggering_anchors=anchors, require_all_anchors=True
)
assert any_anchor_zone.trigger(detections)[0]
assert not all_anchors_zone.trigger(detections)[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]
result = any_anchor.trigger(detections)
assert result[0]
assert any_anchor.current_count == 1
def test_require_all_anchors_false_all_outside_does_not_trigger(self) -> None:
"""With require_all_anchors=False, a detection with every anchor outside the
zone still does not trigger (exercises the np.any all-False branch)."""
# Box [0, 0, 20, 20] has all four corners well outside POLYGON
# ([100, 100]..[200, 200]).
detections = _create_detections(xyxy=[[0.0, 0.0, 20.0, 20.0]], class_id=[0])
anchors = (
sv.Position.TOP_LEFT,
sv.Position.TOP_RIGHT,
sv.Position.BOTTOM_LEFT,
sv.Position.BOTTOM_RIGHT,
)
any_anchor = sv.PolygonZone(
POLYGON, triggering_anchors=anchors, require_all_anchors=False
)
result = any_anchor.trigger(detections)
assert not result[0]
assert any_anchor.current_count == 0
def test_require_all_anchors_default_matches_explicit_true(self) -> None:
"""Omitting require_all_anchors and passing require_all_anchors=True
explicitly must produce identical trigger() results, pinning the
documented default (True)."""
anchors = (
sv.Position.TOP_LEFT,
sv.Position.TOP_RIGHT,
sv.Position.BOTTOM_LEFT,
sv.Position.BOTTOM_RIGHT,
)
default_zone = sv.PolygonZone(POLYGON, triggering_anchors=anchors)
explicit_true_zone = sv.PolygonZone(
POLYGON, triggering_anchors=anchors, require_all_anchors=True
)
assert np.array_equal(
default_zone.trigger(DETECTIONS), explicit_true_zone.trigger(DETECTIONS)
)
def test_require_all_anchors_has_no_effect_with_single_anchor(self) -> None:
"""With a single triggering anchor, require_all_anchors is a no-op: both
settings must produce identical trigger() results (per the docstring:
"Has no effect when triggering_anchors has a single entry")."""
# Box [140, 140, 160, 160] has its BOTTOM_CENTER inside POLYGON.
detections = _create_detections(
xyxy=[[140.0, 140.0, 160.0, 160.0]], class_id=[0]
)
require_all = sv.PolygonZone(
POLYGON,
triggering_anchors=[sv.Position.BOTTOM_CENTER],
require_all_anchors=True,
)
require_any = sv.PolygonZone(
POLYGON,
triggering_anchors=[sv.Position.BOTTOM_CENTER],
require_all_anchors=False,
)
assert np.array_equal(
require_all.trigger(detections), require_any.trigger(detections)
)
def test_half_pixel_anchor_uses_nearest_pixel(self) -> None:
"""Half-pixel anchors should not be biased toward the larger x and y."""
zone_left = sv.PolygonZone(