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
48 lines
2.4 KiB
Python
48 lines
2.4 KiB
Python
from collections import deque
|
|
from edge.models import Keypoint, BBox
|
|
from edge.tracking import Track, TrackFrame
|
|
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
|
|
|
|
def _track(tid, x, y, t):
|
|
fr = TrackFrame(t, BBox(x, y, x + 20, y + 60), [Keypoint(x, y, 0.9) for _ in range(17)])
|
|
return Track(tid, deque([fr], maxlen=90))
|
|
|
|
def test_lost_dangerous_track_becomes_ghost_after_wait():
|
|
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0))
|
|
d.note_state(1, "FALLEN")
|
|
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
|
|
assert d.update([], now=0.5) == [] # 아직 대기
|
|
ghosts = d.update([], now=1.5) # 재출현 없음 > 1.0s
|
|
assert len(ghosts) == 1
|
|
assert ghosts[0]["last_state"] == "FALLEN"
|
|
assert ghosts[0]["spatial"] == (110.0, 130.0) # bbox 중심
|
|
assert ghosts[0]["track_id"] == 1
|
|
|
|
def test_non_dangerous_lost_track_is_ignored():
|
|
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0))
|
|
d.note_state(1, "STANDING")
|
|
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
|
|
assert d.update([], now=2.0) == []
|
|
|
|
def test_reappearance_near_location_cancels_ghost():
|
|
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0))
|
|
d.note_state(1, "FALLEN")
|
|
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
|
|
# 인근에 새 트랙(손실 시점엔 없던 id) 재출현 -> 같은 사람이 다시 잡힌 것 -> 취소
|
|
assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == []
|
|
assert d.update([], now=2.0) == [] # 취소되어 방출 없음
|
|
|
|
|
|
def test_bystander_present_at_loss_does_not_cancel_ghost():
|
|
# A colleague already being tracked when the victim was lost must NOT cancel the incident
|
|
# by walking over the loss point — otherwise a worker dragged/collapsed under a machine,
|
|
# with a co-worker nearby, is never reported. Only a track that did NOT exist at loss time
|
|
# (a genuine re-appearance of the victim) may cancel.
|
|
d = GhostIncidentDetector(GhostConfig(reappear_s=1.0, reappear_dist=2.0))
|
|
d.note_state(1, "FALLEN")
|
|
d.note_state(2, "STANDING") # colleague id 2 is active at loss time
|
|
d.on_lost([_track(1, 100, 100, 0.0)], now=0.0)
|
|
assert d.update([_track(2, 105, 105, 0.5)], now=0.5) == [] # bystander near, not cancelled
|
|
ghosts = d.update([_track(2, 105, 105, 1.5)], now=1.5)
|
|
assert len(ghosts) == 1 # incident still fires
|
|
assert ghosts[0]["track_id"] == 1
|