From 406c0f5b6cbb89c6481b7c5f74a5f3ffdae9064b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sat, 11 Jul 2026 21:05:42 +0900 Subject: [PATCH] feat(edge): ZoneIntrusionDetector (foot-anchor point-in-polygon, debounce) --- edge/detectors/zone.py | 60 ++++++++++++++++++++++++++++++++++++++++++ tests/test_zone.py | 33 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 edge/detectors/zone.py create mode 100644 tests/test_zone.py diff --git a/edge/detectors/zone.py b/edge/detectors/zone.py new file mode 100644 index 0000000..e15370b --- /dev/null +++ b/edge/detectors/zone.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass + +from edge.tracking import Track +from edge.models import L_ANKLE, R_ANKLE + + +def point_in_polygon(x: float, y: float, poly: list[tuple[float, float]]) -> bool: + inside = False + n = len(poly) + j = n - 1 + for i in range(n): + xi, yi = poly[i] + xj, yj = poly[j] + if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-9) + xi): + inside = not inside + j = i + return inside + + +@dataclass(frozen=True) +class Zone: + zone_id: str + level: str # "warning" | "restricted" + polygon: list # list[tuple[float,float]] + + +@dataclass +class ZoneConfig: + debounce_s: float = 0.75 + + +class ZoneIntrusionDetector: + def __init__(self, zones: list[Zone], cfg: ZoneConfig): + self.zones = zones + self.cfg = cfg + self._inside_since: dict[tuple[int, str], float] = {} + self._fired: set[tuple[int, str]] = set() + + def _foot(self, track: Track) -> tuple[float, float]: + kps = track.latest.keypoints + a, b = kps[L_ANKLE], kps[R_ANKLE] + return ((a.x + b.x) / 2, (a.y + b.y) / 2) + + def active_zone_ids(self, track_id: int) -> set: + return {z for (tid, z) in self._inside_since if tid == track_id} + + def update(self, track: Track, now: float) -> list[dict]: + fx, fy = self._foot(track) + out = [] + for zone in self.zones: + key = (track.track_id, zone.zone_id) + if point_in_polygon(fx, fy, zone.polygon): + start = self._inside_since.setdefault(key, now) + if now - start >= self.cfg.debounce_s and key not in self._fired: + self._fired.add(key) + out.append({"zone_id": zone.zone_id, "level": zone.level}) + else: + self._inside_since.pop(key, None) + self._fired.discard(key) + return out diff --git a/tests/test_zone.py b/tests/test_zone.py new file mode 100644 index 0000000..887b24b --- /dev/null +++ b/tests/test_zone.py @@ -0,0 +1,33 @@ +from collections import deque +from edge.models import Keypoint, BBox +from edge.tracking import Track, TrackFrame +from edge.detectors.zone import point_in_polygon, Zone, ZoneIntrusionDetector, ZoneConfig + +SQUARE = [(0, 0), (100, 0), (100, 100), (0, 100)] + +def test_point_in_polygon_basic(): + assert point_in_polygon(50, 50, SQUARE) is True + assert point_in_polygon(150, 50, SQUARE) is False + +def _track_with_ankles(ax, ay, t): + kps = [Keypoint(ax, ay, 0.9) for _ in range(17)] + kps[15] = Keypoint(ax, ay, 0.9) # L_ANKLE + kps[16] = Keypoint(ax, ay, 0.9) # R_ANKLE + return Track(1, deque([TrackFrame(t, BBox(ax - 10, ay - 60, ax + 10, ay), kps)], maxlen=90)) + +def test_intrusion_fires_after_debounce(): + z = Zone("z1", "restricted", SQUARE) + det = ZoneIntrusionDetector([z], ZoneConfig(debounce_s=0.5)) + tr = _track_with_ankles(50, 50, 0.0) + assert det.update(tr, now=0.0) == [] + tr = _track_with_ankles(50, 50, 0.6) + out = det.update(tr, now=0.6) + assert out == [{"zone_id": "z1", "level": "restricted"}] + +def test_leaving_zone_resets(): + z = Zone("z1", "warning", SQUARE) + det = ZoneIntrusionDetector([z], ZoneConfig(debounce_s=0.5)) + det.update(_track_with_ankles(50, 50, 0.0), now=0.0) + det.update(_track_with_ankles(200, 200, 0.6), now=0.6) # 이탈 + out = det.update(_track_with_ankles(50, 50, 1.0), now=1.0) # 재진입, 디바운스 재시작 + assert out == []