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:
parent
60d748e57d
commit
937ed4af37
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue