move_detect/tests/test_zone.py
유창욱 f1ee7f0a36 fix(edge): confidence-gate foot/nose keypoints, warn on MoveNet single-person (P2-9/10/11)
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):

- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
  hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
  point into or out of a zone. Now averages only ankles with conf >= 0.2
  and falls back to the bbox bottom-centre when neither is trustworthy.

- P2-11 geometry.py: head_below_hips used the NOSE regardless of
  confidence. Now returns False when the nose is below the visibility
  threshold, so an unconfident/hallucinated nose can't fire the
  head-below-hips fall cue. The confident-nose path is unchanged
  (test_fall.py stays green).

- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
  a multi-worker site every worker but one is silently invisible to
  fall/zone/ghost detection. Construction now logs a loud startup WARNING
  pointing to the Hailo backend.

Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
2026-07-17 10:25:04 +09:00

63 lines
3.2 KiB
Python

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 == []
def _track_lowconf_ankles(bbox, t):
kps = [Keypoint(0, 0, 0.0) for _ in range(17)] # every keypoint hallucinated at the origin
return Track(1, deque([TrackFrame(t, bbox, kps)], maxlen=90))
def test_foot_falls_back_to_bbox_when_ankles_low_confidence():
# Both ankles are hallucinated at (0,0) with conf 0. Averaging them would pin the foot to the
# image origin — outside any centred zone. The detector must instead use the bbox
# bottom-centre, where the worker actually stands, so the intrusion still fires.
z = Zone("z1", "restricted", SQUARE)
det = ZoneIntrusionDetector([z], ZoneConfig(debounce_s=0.0))
bbox = BBox(40, 10, 60, 50) # bottom-centre = (cx=50, y2=50), inside SQUARE
out = det.update(_track_lowconf_ankles(bbox, 0.0), now=0.0)
assert out == [{"zone_id": "z1", "level": "restricted"}]
def test_zone_uses_normalized_coordinates_when_frame_size_given():
# The dashboard stores zones as 0..1 of the image (it stretches the snapshot to a fixed
# 960x540 canvas). The edge foot is camera-native pixels. Normalizing the foot by the
# frame size makes the two comparable regardless of camera resolution — without this,
# every zone is silently misplaced.
zn = Zone("z1", "restricted", [(0.4, 0.4), (0.6, 0.4), (0.6, 0.6), (0.4, 0.6)])
det = ZoneIntrusionDetector([zn], ZoneConfig(debounce_s=0.0))
# foot at the centre of a 1280x720 frame -> (0.5, 0.5) -> inside the 0.4..0.6 zone
inside = det.update(_track_with_ankles(640, 360, 0.0), now=0.0, frame_wh=(1280, 720))
assert inside == [{"zone_id": "z1", "level": "restricted"}]
# foot near the corner -> ~(0.008, 0.014) -> outside
det2 = ZoneIntrusionDetector([zn], ZoneConfig(debounce_s=0.0))
assert det2.update(_track_with_ankles(10, 10, 0.0), now=0.0, frame_wh=(1280, 720)) == []