move_detect/edge/geometry.py
유창욱 9386dfc580 feat(delivery): install-path fixes + cafe24 package + delivery-audit hardening
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).

## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
  StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
  real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
  8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.

## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
  (docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
  walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
  scoped to the mount.

## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
  allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
  heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
  offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
  misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
  ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).

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

76 lines
2.7 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]) -> bool:
_, hy = _hip_center(keypoints)
return keypoints[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