move_detect/edge/adapters/movenet_pose.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

43 lines
2 KiB
Python

import logging
import cv2
import numpy as np
from edge.models import Person, BBox, Keypoint
class MoveNetPoseAdapter:
"""MoveNet SinglePose Lightning (COCO-17) via onnxruntime. CPU dev/pilot PosePort.
출력 [1,1,17,3] = (y, x, score) normalized 0..1."""
def __init__(self, onnx_path: str, input_size: int = 192, session=None,
min_person_score: float = 0.2):
self.input_size = input_size
self.min_person_score = min_person_score
if session is None:
import onnxruntime # gated: bundled on build machine
session = onnxruntime.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
self.session = session
self._input_name = session.get_inputs()[0].name
# MoveNet SinglePose returns ONE person per frame. At a multi-worker site every worker
# but one is invisible to fall/zone/ghost detection with no other symptom — so say it
# loudly at startup. The Hailo backend (yolov8s_pose) is multi-person.
logging.getLogger("edge.pose").warning(
"MoveNet CPU fallback is SINGLE-PERSON: only one worker is tracked per frame; any "
"additional workers are invisible to fall/zone/ghost detection. Use the Hailo "
"backend (POSE_BACKEND=hailo) at multi-person sites.")
def infer(self, frame) -> list:
h, w = frame.shape[:2]
img = cv2.resize(frame, (self.input_size, self.input_size))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.int32)[None, ...]
out = self.session.run(None, {self._input_name: img})[0] # [1,1,17,3]
kp = out[0, 0] # [17,3] (y,x,score)
keypoints = [Keypoint(float(kp[j, 1] * w), float(kp[j, 0] * h), float(kp[j, 2]))
for j in range(17)]
vis = [k for k in keypoints if k.conf >= self.min_person_score]
if not vis:
return []
xs = [k.x for k in vis]; ys = [k.y for k in vis]
bbox = BBox(min(xs), min(ys), max(xs), max(ys))
return [Person(bbox, keypoints)]