move_detect/edge/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 KiB
Python

import math
from edge.models import (
Keypoint, BBox, NOSE, L_SHOULDER, R_SHOULDER, L_HIP, R_HIP, CORE_JOINTS,
)
def _mean(points: list[tuple[float, float]]) -> tuple[float, float]:
n = len(points)
return (sum(p[0] for p in points) / n, sum(p[1] for p in points) / n)
def com(keypoints: list[Keypoint]) -> tuple[float, float]:
pts = [(keypoints[i].x, keypoints[i].y) for i in CORE_JOINTS]
return _mean(pts)
def _shoulder_center(kps: list[Keypoint]) -> tuple[float, float]:
return _mean([(kps[L_SHOULDER].x, kps[L_SHOULDER].y),
(kps[R_SHOULDER].x, kps[R_SHOULDER].y)])
def _hip_center(kps: list[Keypoint]) -> tuple[float, float]:
return _mean([(kps[L_HIP].x, kps[L_HIP].y),
(kps[R_HIP].x, kps[R_HIP].y)])
def torso_angle_deg(keypoints: list[Keypoint]) -> float:
"""몸통(어깨중심→엉덩이중심) 벡터가 수직축과 이루는 각. 0=수직, 90=수평."""
sx, sy = _shoulder_center(keypoints)
hx, hy = _hip_center(keypoints)
vx, vy = hx - sx, hy - sy
# 수직축(0,1) 대비 편차. 위/아래 무관하게 |성분| 사용.
return math.degrees(math.atan2(abs(vx), abs(vy))) if (vx or vy) else 0.0
def head_below_hips(keypoints: list[Keypoint], nose_thr: float = 0.2) -> bool:
# Only trust the nose if the model is confident about it. An unconfident nose (commonly
# hallucinated at (0,0) / a frame corner) would otherwise assert — or silently suppress —
# this fall cue on data we don't trust. Same 'visible' gate as visible_count().
nose = keypoints[NOSE]
if nose.conf < nose_thr:
return False
_, hy = _hip_center(keypoints)
return nose.y > hy
def visible_count(keypoints: list[Keypoint], thr: float = 0.2) -> int:
return sum(1 for k in keypoints if k.conf >= thr)
def core_visible(keypoints: list[Keypoint], thr: float = 0.5) -> bool:
return all(keypoints[i].conf >= thr for i in CORE_JOINTS)
def height_of(bbox: BBox) -> float:
return bbox.height
def normalize(value: float, bbox_height: float) -> float:
return value / bbox_height if bbox_height else 0.0
def track_displacement(track) -> float:
"""Mean per-keypoint SPEED between the last two frames: displacement normalized by bbox
height AND by elapsed time (per second). Without the time term the value scales with the
frame rate, so the same physical stillness reads as 'moving' at low fps and 'still' at
high fps — flipping immobility/no_motion with the daemon's actual (load-dependent) fps.
Returns 1.0 (moving/unknown) with <2 frames or a non-positive dt.
NOTE: motion_thr in FallConfig/ImmobilityConfig is now a per-SECOND threshold; its default
predates this change and should be re-tuned on device."""
if len(track.frames) < 2:
return 1.0
a, b = track.frames[-2], track.frames[-1]
dt = b.t - a.t
if dt <= 0:
return 1.0
h = b.bbox.height or 1.0
d = sum(((p.x - q.x) ** 2 + (p.y - q.y) ** 2) ** 0.5
for p, q in zip(a.keypoints, b.keypoints)) / len(b.keypoints)
return normalize(d, h) / dt