move_detect/tests/test_engine.py
유창욱 b358822247 feat(ergo): posture-load exposure scoring (REBA bands) with anonymous reports
Same pose stream, new safety axis: musculoskeletal-load prevention. This is an
honest 2D reduction of REBA - band exposure TIME, not a precise REBA score.

Edge:
- geometry: arm_elevation_deg (shoulder->elbow vs vertical-down, best of both
  arms) and knee_flexion_deg (hip-knee-ankle interior angle, worst of both
  legs). Both return None when no confident side exists - "unmeasurable" must
  never read as "0 deg / straight".
- detectors/ergonomics.py: per-frame dt accumulation into bands (trunk 20-60
  and 60+ deg via the existing torso angle, arm 90+ deg, knee <110 deg), gated
  on confident core keypoints; detection gaps are dt-capped (2s) so a dropout
  never counts as exposure. Hourly per-camera report, skipped when nothing was
  observed (an empty-workshop report is noise).
- Engine: tracks in an incident state (FALLING/FALLEN/NO_MOTION) are excluded
  - a fallen worker's 90-deg trunk is an incident, not an ergonomic exposure.
  The report event (ergo_report/INFO) carries no track_id and no spatial:
  a camera-level anonymous aggregate, per the PIPA-first product stance.
- run_once: NO snapshots on ergo_report (a frame would turn a statistic into
  surveillance imagery); capture is skipped entirely when only ergo fired.

Server/UI: ergo_report accepted (validator + chk_events_type, edited in the
still-undeployed heatmap migration); detail view now renders event metrics
with Korean labels and seconds humanized - the ergo report's whole value IS
its metrics. Korean type label in list + detail.

Verified: pytest 232 pass (10 new) / PHPUnit 135 pass. NOTE: the detail-view
visual check is pending - the Chrome extension disconnected mid-verification;
harness is staged under the session scratchpad (events/1/view).
2026-08-06 21:46:39 +09:00

308 lines
15 KiB
Python

from collections import deque
from edge.models import Keypoint, BBox
from edge.tracking import Track, TrackFrame
from edge.events import EventType, Severity
from edge.detectors.fall import FallDetector, FallConfig
from edge.detectors.immobility import ImmobilityWatchdog, ImmobilityConfig
from edge.detectors.ghost import GhostIncidentDetector, GhostConfig
from edge.detectors.zone import ZoneIntrusionDetector, ZoneConfig, Zone
from edge.engine import EventEngine, EngineConfig
def _lying_kps():
kps = [Keypoint(150, 300, 0.9) for _ in range(17)]
kps[5] = Keypoint(100, 300, 0.9); kps[6] = Keypoint(100, 310, 0.9)
kps[11] = Keypoint(200, 300, 0.9); kps[12] = Keypoint(200, 310, 0.9)
kps[0] = Keypoint(90, 320, 0.9)
return kps
def _lying_track(track, t):
track.frames.append(TrackFrame(t, BBox(90, 290, 210, 320), _lying_kps()))
return track
def _standing_kps(cx=150):
kps = [Keypoint(cx, 150, 0.9) for _ in range(17)]
kps[5] = Keypoint(cx - 10, 100, 0.9); kps[6] = Keypoint(cx + 10, 100, 0.9) # shoulders above
kps[11] = Keypoint(cx - 10, 200, 0.9); kps[12] = Keypoint(cx + 10, 200, 0.9) # hips below
kps[0] = Keypoint(cx, 60, 0.9) # head above hips
return kps
def _standing_track(track, t, cx=150):
track.frames.append(TrackFrame(t, BBox(cx - 20, 60, cx + 20, 210), _standing_kps(cx)))
return track
def _engine():
return EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
)
def test_fall_produces_fall_event():
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0)
eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6)
events = eng.process([tr], [], now=0.6)
types = [e.event_type for e in events]
assert EventType.FALL in types
assert events[0].camera_id == "cam-01"
def test_cooldown_suppresses_duplicate():
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # FALL 1회
_lying_track(tr, 0.7)
again = eng.process([tr], [], now=0.7)
assert all(e.event_type != EventType.FALL for e in again)
def test_no_motion_event_carries_duration():
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=0.5, no_motion_s=1.0, motion_thr=0.05)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01", cooldown_s=30.0),
)
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # -> fall
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0) # still
_lying_track(tr, 2.0)
events = eng.process([tr], [], now=2.0) # immobile >1.0s -> no_motion
no_motion_events = [e for e in events if e.event_type == EventType.NO_MOTION]
assert len(no_motion_events) == 1
e = no_motion_events[0]
assert e.duration_s > 0
assert e.duration_s == e.metrics["immobility_s"]
def test_forget_prunes_state_on_lost():
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6)
events = eng.process([tr], [], now=0.6) # FALL fires
assert any(e.event_type == EventType.FALL for e in events)
assert (EventType.FALL, 1) in eng._last_emit
assert 1 in eng.fall._states
# track is now lost -> engine must prune its per-track state
eng.process([], [tr], now=1.0)
assert not any(len(k) >= 2 and k[1] == 1 for k in eng._last_emit)
assert 1 not in eng.fall._states
def test_pinch_zone_emits_pinch_event_not_warning():
# A 협착(pinch) zone is the highest-danger zone type (machinery entrapment). It must fire
# as PINCH/HIGH, not be silently downgraded to a generic ZONE_WARNING.
poly = [(100, 100), (200, 100), (200, 200), (100, 200)]
zdet = ZoneIntrusionDetector([Zone("z1", "pinch", poly)], ZoneConfig(debounce_s=0.0))
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)), # keep fall/no_motion quiet
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0))
kps = [Keypoint(150, 150, 0.9) for _ in range(17)] # both ankles inside the zone
tr = Track(2, deque(maxlen=90))
tr.frames.append(TrackFrame(0.0, BBox(140, 140, 160, 160), kps))
events = eng.process([tr], [], now=0.0)
pinch = [e for e in events if e.event_type == EventType.PINCH]
assert len(pinch) == 1
assert pinch[0].severity == Severity.HIGH
assert pinch[0].zone_id == "z1"
assert not any(e.event_type == EventType.ZONE_WARNING for e in events)
def test_ghost_emit_does_not_leak_a_permanent_dedup_key():
# A GHOST fires reappear_s AFTER the track was lost (and already forgotten). Routing it through
# the engine cooldown used to insert a (GHOST, track_id) key that forget() could never reclaim
# (the track never appears in lost_tracks again) — a slow 24/7 memory leak. Emitting directly
# must leave no such key behind.
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6) # confirmed fallen -> dangerous state
eng.process([], [tr], now=1.0) # lost -> ghost candidate registered
events = eng.process([], [], now=12.0) # > reappear_s (10s), no reappearance
assert any(e.event_type == EventType.GHOST for e in events)
assert not any(k[0] == EventType.GHOST for k in eng._last_emit) # no leaked ghost dedup key
def test_two_simultaneous_tracks_have_independent_fall_state():
# Per-track detector state must not leak between two people monitored at once: a fall on track A
# must arm and emit for A only, never for a standing track B. All other engine/detector tests
# exercise a single person, so this pins the multi-person isolation the fall/immobility/zone
# timers rely on (they key every bit of state off track_id).
eng = _engine() # prone_confirm_s=0.5
a = Track(1, deque(maxlen=90)); b = Track(2, deque(maxlen=90))
_lying_track(a, 0.0); _standing_track(b, 0.0, cx=500)
eng.process([a, b], [], now=0.0)
_lying_track(a, 0.6); _standing_track(b, 0.6, cx=500)
events = eng.process([a, b], [], now=0.6)
falls = [e for e in events if e.event_type == EventType.FALL]
assert len(falls) == 1 # exactly one fall...
assert falls[0].track_id == 1 # ...for the lying track A, not the standing track B
def test_fall_fires_at_exactly_prone_confirm_s_inclusive_boundary():
# The confirm is `now - prone_since >= prone_confirm_s`, so a fall must fire at EXACTLY the
# threshold, not one frame later. Pins the inclusive boundary against a >= -> > regression that
# would silently delay every fall confirmation by a frame — up to +0.5s at a thermally throttled
# 2 fps, on the single most safety-critical event. Existing tests only feed comfortably-past
# values (0.6 vs 0.5), so none of them would catch that shift.
eng = _engine() # prone_confirm_s=0.5
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0) # prone_since = 0.0
_lying_track(tr, 0.5) # exactly prone_confirm_s elapsed
events = eng.process([tr], [], now=0.5)
assert any(e.event_type == EventType.FALL for e in events)
def test_occurred_at_uses_wall_clock_while_timers_use_monotonic():
# A fall is CONFIRMED by the monotonic timer (prone_confirm_s over the injected `now`), but the
# event must be STAMPED with wall time so an NTP step does not mis-timestamp evidence (and the
# server's wall-time retention purge does not delete it). Timer `now` and `wall_now` are
# independent axes here to prove occurred_at follows wall time, not the monotonic clock.
eng = _engine() # prone_confirm_s=0.5
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0)
eng.process([tr], [], now=0.0, wall_now=1_700_000_000.0)
_lying_track(tr, 0.6)
events = eng.process([tr], [], now=0.6, wall_now=1_700_000_000.6)
fall = [e for e in events if e.event_type == EventType.FALL]
assert len(fall) == 1
assert fall[0].occurred_at == 1_700_000_000.6 # wall time, NOT the monotonic 0.6
def test_process_defaults_occurred_at_to_now_when_no_wall_clock():
# Single-clock callers (unit tests, direct callers) omit wall_now — occurred_at must fall back
# to the timer `now` so existing behavior is unchanged.
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 0.6)
events = eng.process([tr], [], now=0.6)
fall = [e for e in events if e.event_type == EventType.FALL]
assert len(fall) == 1 and fall[0].occurred_at == 0.6
def test_lone_worker_rule_fires_through_engine():
# End-to-end wiring of the occupancy rules: a 2인 1조 zone with one standing worker inside
# past the confirm window must emit LONE_WORKER/HIGH with the zone id attached.
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
poly = [(400, 100), (600, 100), (600, 300), (400, 300)]
zdet = ZoneIntrusionDetector([Zone("z1", "restricted", poly, {"min_workers": 2})],
ZoneConfig(debounce_s=0.0))
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
zdet, EngineConfig(camera_id="cam-01", cooldown_s=30.0),
occupancy=OccupancyWatchdog(OccupancyConfig(lone_confirm_s=5.0)))
tr = Track(1, deque(maxlen=90))
_standing_track(tr, 0.0, cx=500) # ankles at (490,200)/(510,200) — inside the zone
events = eng.process([tr], [], now=0.0)
assert not any(e.event_type == EventType.LONE_WORKER for e in events)
_standing_track(tr, 6.0, cx=500)
events = eng.process([tr], [], now=6.0)
lone = [e for e in events if e.event_type == EventType.LONE_WORKER]
assert len(lone) == 1
assert lone[0].severity == Severity.HIGH
assert lone[0].zone_id == "z1" and lone[0].track_id == 1
assert lone[0].metrics["required"] == 2
def test_fall_event_carries_normalized_foot_spatial():
# Every per-track event now carries the foot point in the zones' 0..1 space — the raw
# material of the server-side heatmap. frame_wh drives the normalization.
eng = _engine() # prone_confirm_s=0.5
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360))
_lying_track(tr, 0.6)
events = eng.process([tr], [], now=0.6, frame_wh=(640, 360))
fall = [e for e in events if e.event_type == EventType.FALL][0]
assert fall.spatial is not None
x, y = fall.spatial
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0
def test_near_fall_emits_info_event():
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=5.0, falling_confirm_s=0.5, recover_grace_s=1.0)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01", cooldown_s=30.0))
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0)
_lying_track(tr, 1.0); eng.process([tr], [], now=1.0) # FALLING
_standing_track(tr, 2.0); eng.process([tr], [], now=2.0) # 회복 시작
_standing_track(tr, 3.5)
events = eng.process([tr], [], now=3.5) # grace 경과 → near_fall
near = [e for e in events if e.event_type == EventType.NEAR_FALL]
assert len(near) == 1
assert near[0].severity == Severity.INFO
assert near[0].metrics["prone_s"] == 2.0
def test_ghost_spatial_is_normalized_with_frame_size():
eng = _engine()
tr = Track(1, deque(maxlen=90))
_lying_track(tr, 0.0); eng.process([tr], [], now=0.0, frame_wh=(640, 360))
_lying_track(tr, 0.6); eng.process([tr], [], now=0.6, frame_wh=(640, 360)) # FALLEN
eng.process([], [tr], now=1.0, frame_wh=(640, 360)) # lost while dangerous
events = eng.process([], [], now=12.0, frame_wh=(640, 360))
ghost = [e for e in events if e.event_type == EventType.GHOST][0]
x, y = ghost.spatial
assert 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 # not raw pixels (150, 305)
def test_ergo_report_emitted_anonymous_and_info():
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.5)))
tr = Track(1, deque(maxlen=90))
_standing_track(tr, 0.0); eng.process([tr], [], now=0.0)
_standing_track(tr, 0.6)
events = eng.process([tr], [], now=0.6)
rep = [e for e in events if e.event_type == EventType.ERGO_REPORT]
assert len(rep) == 1
assert rep[0].severity == Severity.INFO
assert rep[0].track_id is None and rep[0].spatial is None # 익명 합계: 개인 좌표 없음
assert rep[0].metrics["observed_s"] == 0.6
def test_incident_state_track_does_not_accrue_ergo_exposure():
# 쓰러진 사람의 90° 몸통은 인체공학 노출이 아니라 사고다 — FALLING 이후는 집계 제외.
from edge.detectors.ergonomics import ErgoMonitor, ErgoConfig
eng = EventEngine(
FallDetector(FallConfig(prone_confirm_s=999, falling_confirm_s=0.05)),
ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
GhostIncidentDetector(GhostConfig()),
ZoneIntrusionDetector([], ZoneConfig()),
EngineConfig(camera_id="cam-01"),
ergonomics=ErgoMonitor(ErgoConfig(report_interval_s=0.2)))
tr = Track(1, deque(maxlen=90))
for t in (0.0, 0.1, 0.2, 0.3):
_lying_track(tr, t)
events = eng.process([tr], [], now=t)
assert not any(e.event_type == EventType.ERGO_REPORT for e in events)
def test_allow_cooldown_primitive():
eng = _engine() # EngineConfig(camera_id="cam-01", cooldown_s=30.0)
key = ("FALL", 1)
assert eng._allow(key, now=0.0) is True # first time allowed
assert eng._allow(key, now=5.0) is False # within cooldown -> suppressed
assert eng._allow(key, now=40.0) is True # after cooldown_s elapsed -> allowed again
# a different key is independent
assert eng._allow(("FALL", 2), now=5.0) is True