fix: prevent single object from appearing in multiple polygon zones (#1991)

* fix: prevent single object from appearing in multiple polygon zones

when checking if a detection is inside a polygon zone, the previous implementation
would clip the bounding box to fit within each ROI's dimensions before calculating
anchor points. This caused the same detection to produce different anchor points
for different ROIs, allowing it to be counted as present in multiple zones.

* Add regression test for PolygonZone trigger issue #1987 and remove unused `frame_resolution_wh` attribute
* refactor(polygon_zone): vectorize trigger() and strengthen tests

Replace the O(n×m) Python double-loop in PolygonZone.trigger() with
vectorized NumPy. Semantics are identical: compute a (num_anchors,
num_detections) in_bounds mask, use np.clip solely for safe fancy-index
access, then AND with the polygon mask and reduce with np.all(axis=0).
Also removes the now-unused `from dataclasses import replace` import and
a latent np.all(axis=1) call on a 1D array.

Test improvements:
- Group into TestPolygonZoneInit / TestPolygonZoneTrigger classes
- Replace the trivially-passing regression (sum=0 on both old and new
  code) with adjacent zones + straddling detection that gives sum=2 on
  the old clip_boxes implementation and sum=1 on the fix
- Rename tests to describe behaviour, not issue numbers
- Add test_out_of_bounds_anchor_excluded and
  test_anchor_on_polygon_boundary_included edge cases

* test(polygon_zone): verify current_count updates with expected results during trigger

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Adithi Sreenath 2026-03-11 01:49:09 +05:30 committed by GitHub
parent ca9bff184c
commit c010656161
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 151 additions and 90 deletions

View File

@ -1,7 +1,6 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import replace
from typing import Any, cast
import cv2
@ -9,7 +8,6 @@ import numpy as np
import numpy.typing as npt
from supervision import Detections
from supervision.detection.utils.boxes import clip_boxes
from supervision.detection.utils.converters import polygon_to_mask
from supervision.draw.color import Color
from supervision.draw.utils import draw_filled_polygon, draw_polygon, draw_text
@ -73,7 +71,6 @@ class PolygonZone:
self.current_count = 0
x_max, y_max = np.max(polygon, axis=0)
self.frame_resolution_wh = (x_max + 1, y_max + 1)
self.mask = polygon_to_mask(
polygon=polygon, resolution_wh=(x_max + 2, y_max + 2)
)
@ -82,33 +79,36 @@ class PolygonZone:
"""
Determines if the detections are within the polygon zone.
Anchor points are calculated from original (unclipped) detection boxes to
avoid per-zone clipping shifting anchor positions. This prevents a single
detection from being counted in multiple non-overlapping zones due to
clipping artifacts, although overlapping zones may still legitimately
contain the same detection.
Args:
detections: The detections
to be checked against the polygon zone
detections: The detections to be checked against the polygon zone
Returns:
A boolean numpy array indicating
if each detection is within the polygon zone
"""
if len(detections) == 0:
self.current_count = 0
return np.array([], dtype=bool)
clipped_xyxy = clip_boxes(
xyxy=detections.xyxy, resolution_wh=self.frame_resolution_wh
)
clipped_detections = replace(detections, xyxy=clipped_xyxy)
all_clipped_anchors = np.array(
all_anchors = np.array(
[
np.ceil(clipped_detections.get_anchors_coordinates(anchor)).astype(int)
for anchor in self.triggering_anchors
np.ceil(detections.get_anchors_coordinates(anchors)).astype(int)
for anchors in self.triggering_anchors
]
)
is_in_zone: npt.NDArray[np.bool_] = (
self.mask[all_clipped_anchors[:, :, 1], all_clipped_anchors[:, :, 0]]
.transpose()
.astype(bool)
)
is_in_zone = np.all(is_in_zone, axis=1)
mask_h, mask_w = self.mask.shape
x, y = all_anchors[:, :, 0], all_anchors[:, :, 1]
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)
self.current_count = int(np.sum(is_in_zone))
return is_in_zone.astype(bool)

View File

@ -28,78 +28,139 @@ DETECTIONS = _create_detections(
POLYGON = np.array([[100, 100], [200, 100], [200, 200], [100, 200]])
@pytest.mark.parametrize(
("detections", "polygon_zone", "expected_results", "exception"),
[
(
DETECTIONS,
sv.PolygonZone(
class TestPolygonZoneInit:
@pytest.mark.parametrize(
("polygon", "triggering_anchors", "exception"),
[
(POLYGON, [sv.Position.CENTER], DoesNotRaise()),
(
POLYGON,
triggering_anchors=(
sv.Position.TOP_LEFT,
sv.Position.TOP_RIGHT,
sv.Position.BOTTOM_LEFT,
sv.Position.BOTTOM_RIGHT,
[],
pytest.raises(ValueError, match="Triggering anchors cannot be empty"),
),
],
)
def test_empty_anchors_raises(self, polygon, triggering_anchors, exception):
with exception:
sv.PolygonZone(polygon, triggering_anchors=triggering_anchors)
class TestPolygonZoneTrigger:
@pytest.mark.parametrize(
("detections", "polygon_zone", "expected_results", "exception"),
[
(
DETECTIONS,
sv.PolygonZone(
POLYGON,
triggering_anchors=(
sv.Position.TOP_LEFT,
sv.Position.TOP_RIGHT,
sv.Position.BOTTOM_LEFT,
sv.Position.BOTTOM_RIGHT,
),
),
),
np.array(
[False, False, False, True, True, True, False, False, False], dtype=bool
),
DoesNotRaise(),
), # Test all four corners
(
DETECTIONS,
sv.PolygonZone(
POLYGON,
),
np.array(
[False, False, True, True, True, True, False, False, False], dtype=bool
),
DoesNotRaise(),
), # Test default behaviour when no anchors are provided
(
DETECTIONS,
sv.PolygonZone(
POLYGON,
triggering_anchors=[sv.Position.BOTTOM_CENTER],
),
np.array(
[False, False, True, True, True, True, False, False, False], dtype=bool
),
DoesNotRaise(),
), # Test default behaviour with deprecated api.
(
sv.Detections.empty(),
sv.PolygonZone(
POLYGON,
),
np.array([], dtype=bool),
DoesNotRaise(),
), # Test empty detections
],
)
def test_polygon_zone_trigger(
detections: sv.Detections,
polygon_zone: sv.PolygonZone,
expected_results: np.ndarray,
exception: Exception,
) -> None:
with exception:
in_zone = polygon_zone.trigger(detections)
assert np.all(in_zone == expected_results)
np.array(
[False, False, False, True, True, True, False, False, False],
dtype=bool,
),
DoesNotRaise(),
), # all four corners must be inside
(
DETECTIONS,
sv.PolygonZone(POLYGON),
np.array(
[False, False, True, True, True, True, False, False, False],
dtype=bool,
),
DoesNotRaise(),
), # default anchor (BOTTOM_CENTER)
(
DETECTIONS,
sv.PolygonZone(
POLYGON,
triggering_anchors=[sv.Position.BOTTOM_CENTER],
),
np.array(
[False, False, True, True, True, True, False, False, False],
dtype=bool,
),
DoesNotRaise(),
), # explicit BOTTOM_CENTER matches default
(
sv.Detections.empty(),
sv.PolygonZone(POLYGON),
np.array([], dtype=bool),
DoesNotRaise(),
), # empty detections return empty array
],
)
def test_anchor_configurations(
self,
detections: sv.Detections,
polygon_zone: sv.PolygonZone,
expected_results: np.ndarray,
exception: Exception,
) -> None:
with exception:
in_zone = polygon_zone.trigger(detections)
assert np.all(in_zone == expected_results)
assert polygon_zone.current_count == int(np.sum(expected_results))
def test_straddling_detection_assigned_to_one_zone(self) -> None:
"""Detection straddling two adjacent zones is counted in exactly one zone.
@pytest.mark.parametrize(
("polygon", "triggering_anchors", "exception"),
[
(POLYGON, [sv.Position.CENTER], DoesNotRaise()),
(
POLYGON,
[],
pytest.raises(ValueError, match="Triggering anchors cannot be empty"),
),
],
)
def test_polygon_zone_initialization(polygon, triggering_anchors, exception):
with exception:
sv.PolygonZone(polygon, triggering_anchors=triggering_anchors)
The old implementation clipped each detection box to the zone's bounding box
before computing anchors, so a box straddling two adjacent zones got a
different anchor per zone and was double-counted. The fix computes anchors
from the original unclipped box, giving a single consistent position.
Setup: zone_left covers x 0-99, zone_right covers x 100-200.
Detection [60, 80, 140, 120] has BOTTOM_CENTER = ceil((60+140)/2), ceil(120)
= (100, 120), which falls in zone_right only.
"""
zone_left = sv.PolygonZone(
np.array([[0, 0], [99, 0], [99, 200], [0, 200]], dtype=np.int32)
)
zone_right = sv.PolygonZone(
np.array([[100, 0], [200, 0], [200, 200], [100, 200]], dtype=np.int32)
)
detections = _create_detections(
xyxy=[[60.0, 80.0, 140.0, 120.0]],
class_id=[0],
)
results = np.array(
[zone.trigger(detections)[0] for zone in [zone_left, zone_right]],
dtype=bool,
)
assert np.sum(results) == 1, (
"Detection should appear in exactly one zone, not zero or multiple"
)
assert results[1], "BOTTOM_CENTER (100, 120) should be in zone_right"
def test_out_of_bounds_anchor_excluded(self) -> None:
"""Anchors with negative coordinates are excluded, not wrapped or clamped."""
zone = sv.PolygonZone(
np.array([[0, 0], [100, 0], [100, 100], [0, 100]]),
triggering_anchors=[sv.Position.CENTER],
)
# CENTER = (ceil((-50+0)/2), ceil((25+75)/2)) = (-25, 50) — x < 0.
detections = _create_detections(
xyxy=[[-50.0, 25.0, 0.0, 75.0]],
class_id=[0],
)
result = zone.trigger(detections)
assert not result[0]
def test_anchor_on_polygon_boundary_included(self) -> None:
"""An anchor landing exactly on a polygon corner is counted as inside."""
polygon = np.array([[0, 0], [100, 0], [100, 100], [0, 100]])
zone = sv.PolygonZone(polygon, triggering_anchors=[sv.Position.BOTTOM_RIGHT])
# BOTTOM_RIGHT = (ceil(x2), ceil(y2)) = (100, 100) — the polygon corner.
detections = _create_detections(
xyxy=[[50.0, 50.0, 100.0, 100.0]],
class_id=[0],
)
result = zone.trigger(detections)
assert result[0]