move_detect/tests/test_geometry.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

82 lines
3.7 KiB
Python

import math
from collections import deque
from edge.models import Keypoint, BBox
from edge.tracking import Track, TrackFrame
from edge.geometry import (
com, torso_angle_deg, head_below_hips, visible_count,
core_visible, normalize, track_displacement,
)
def _kps(overrides):
kps = [Keypoint(0, 0, 0.0) for _ in range(17)]
for i, (x, y, c) in overrides.items():
kps[i] = Keypoint(x, y, c)
return kps
def test_com_is_mean_of_shoulders_and_hips():
kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 10, 1), 12: (10, 10, 1)})
assert com(kps) == (5.0, 5.0)
def test_torso_angle_upright_near_zero():
# shoulders above hips (smaller y), vertical torso
kps = _kps({5: (0, 0, 1), 6: (10, 0, 1), 11: (0, 100, 1), 12: (10, 100, 1)})
assert torso_angle_deg(kps) < 10
def test_torso_angle_lying_near_ninety():
# shoulders and hips at same height, horizontal torso
kps = _kps({5: (0, 50, 1), 6: (0, 55, 1), 11: (100, 50, 1), 12: (100, 55, 1)})
assert torso_angle_deg(kps) > 80
def test_head_below_hips_true_when_nose_lower_on_screen():
kps = _kps({0: (5, 120, 1), 11: (0, 100, 1), 12: (10, 100, 1)})
assert head_below_hips(kps) is True
def test_head_below_hips_false_when_nose_low_confidence():
# A hallucinated low-confidence nose must not fire the head-below-hips fall cue, even when
# its coordinate happens to sit below the hips.
low = _kps({0: (5, 120, 0.1), 11: (0, 100, 1), 12: (10, 100, 1)})
assert head_below_hips(low) is False
# ...but a confident nose in the same position still fires.
ok = _kps({0: (5, 120, 0.9), 11: (0, 100, 1), 12: (10, 100, 1)})
assert head_below_hips(ok) is True
def test_visible_count_and_core_visible():
kps = _kps({5: (0, 0, 0.9), 6: (1, 0, 0.9), 11: (0, 1, 0.9), 12: (1, 1, 0.9)})
assert visible_count(kps, thr=0.2) == 4
assert core_visible(kps, thr=0.5) is True
def test_normalize_divides_by_bbox_height():
assert normalize(30, 60) == 0.5
assert normalize(30, 0) == 0.0
def test_track_displacement_defaults_to_one_with_fewer_than_two_frames():
tr = Track(1, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(0, 0, 20, 60),
[Keypoint(0, 0, 0.9) for _ in range(17)]))
assert track_displacement(tr) == 1.0
def test_track_displacement_normalizes_mean_delta_by_bbox_height_and_time():
tr = Track(1, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(0, 0, 20, 60),
[Keypoint(0, 0, 0.9) for _ in range(17)]))
tr.frames.append(TrackFrame(0.1, BBox(0, 0, 20, 60),
[Keypoint(6, 8, 0.9) for _ in range(17)]))
# per-point delta = sqrt(6^2+8^2) = 10; /bbox height 60; /dt 0.1 -> per-second, per-height
assert math.isclose(track_displacement(tr), (10 / 60) / 0.1)
def test_displacement_is_fps_invariant():
# The stillness measure must reflect real SPEED, not per-frame delta: the same physical
# motion sampled at 10fps vs 1fps must read the same, or immobility/no_motion flips with
# the daemon's actual frame rate (which varies with Hailo/CPU load and camera count).
def moving_track(dt, speed_px_s):
move = speed_px_s * dt
tr = Track(1, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(0, 0, 10, 100),
[Keypoint(0, 0, 0.9) for _ in range(17)]))
tr.frames.append(TrackFrame(dt, BBox(0, 0, 10, 100),
[Keypoint(0, move, 0.9) for _ in range(17)]))
return tr
slow = track_displacement(moving_track(1.0, 5.0)) # sampled once per second
fast = track_displacement(moving_track(0.1, 5.0)) # sampled ten times per second
assert math.isclose(slow, fast)