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 # --- 추락 (fall_from_height) ---------------------------------------------------------- # # 같은 높이의 '넘어짐'과 높이에서의 '떨어짐'은 다른 사건이다. 떨어짐은 척추 손상 가능성이 # 있어 함부로 옮기면 안 되고, 그 주의가 통보에 실리려면 유형과 심각도가 갈라져야 한다. # 발보 경로는 두 갈래다: 프레임 안에서 확정 / 화각 밖으로 사라짐. from edge.detectors.height import DescentWatch, DescentConfig def _height_engine(zones=None): return EventEngine( FallDetector(FallConfig(prone_confirm_s=0.5)), ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), GhostIncidentDetector(GhostConfig(reappear_s=1.0)), ZoneIntrusionDetector(zones or [], ZoneConfig()), EngineConfig(camera_id="cam-01", cooldown_s=30.0), descent=DescentWatch(DescentConfig(drop_ratio=1.2, drop_window_s=1.5, link_s=3.0)), ) def _falling_track(track, t, y2, height=150.0, cx=150.0): """발 밑선 y2 로 서 있는 사람 프레임을 붙인다.""" track.frames.append(TrackFrame(t, BBox(cx - 20, y2 - height, cx + 20, y2), _standing_kps(int(cx)))) return track def test_visible_drop_then_prone_is_fall_from_height_not_fall(): eng = _height_engine() tr = Track(1, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) # 발판 위 _falling_track(tr, 0.5, y2=320.0) _falling_track(tr, 1.0, y2=420.0) # 220px 하강 = 키의 1.47배 eng.process([tr], [], now=1.0) _lying_track(tr, 1.2) eng.process([tr], [], now=1.2) # prone 시작 _lying_track(tr, 2.0) out = eng.process([tr], [], now=2.0) # 0.5초 지속 -> 확정 types = [e.event_type for e in out] assert EventType.FALL_FROM_HEIGHT in types, types assert EventType.FALL not in types, "한 번의 사고가 두 건으로 올라가면 안 된다" ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT) assert ev.severity == Severity.CRITICAL assert ev.metrics["drop_ratio"] >= 1.2 def test_plain_fall_without_descent_stays_fall(): eng = _height_engine() tr = Track(2, deque(maxlen=90)) _lying_track(tr, 0.0) eng.process([tr], [], now=0.0) _lying_track(tr, 0.6) out = eng.process([tr], [], now=0.6) assert [e.event_type for e in out] == [EventType.FALL] def test_drop_then_vanish_emits_fall_from_height_not_ghost(): # 화각 밖으로 떨어진 사람에게는 이쪽이 유일한 발보 경로다. eng = _height_engine() tr = Track(3, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) _falling_track(tr, 0.5, y2=330.0) _falling_track(tr, 0.9, y2=430.0) eng.process([tr], [], now=0.9) # 하강 확정 = ghost 무장 eng.process([], [tr], now=1.0) # 소실 out = eng.process([], [], now=3.0) # 재출현 대기 초과 types = [e.event_type for e in out] assert EventType.FALL_FROM_HEIGHT in types, types assert EventType.GHOST not in types ev = next(e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT) assert ev.metrics["exited_frame"] is True assert ev.metrics["armed_by"] == "descent" def test_standing_person_vanishing_without_descent_is_not_a_height_fall(): # 장애물 뒤로 걸어 들어간 사람까지 추락으로 올리면 안 된다. eng = _height_engine() tr = Track(4, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) _falling_track(tr, 1.0, y2=205.0) eng.process([tr], [], now=1.0) eng.process([], [tr], now=1.1) out = eng.process([], [], now=3.0) assert [e.event_type for e in out] == [] def test_elevated_zone_arms_without_a_visible_drop(): # 발판 위에서 화각 밖으로 사라지면 하강을 못 봤어도 추락 후보다. poly = [(0.0, 0.0), (400.0, 0.0), (400.0, 400.0), (0.0, 400.0)] zones = [Zone("zhigh", "restricted", poly, {"elevated": True})] eng = _height_engine(zones) tr = Track(5, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) _falling_track(tr, 1.0, y2=205.0) eng.process([tr], [], now=1.0) eng.process([], [tr], now=1.1) out = eng.process([], [], now=3.0) fh = [e for e in out if e.event_type == EventType.FALL_FROM_HEIGHT] assert len(fh) == 1, [e.event_type for e in out] assert fh[0].metrics["armed_by"] == "zone" def test_height_fall_upgrade_works_at_PRODUCTION_defaults(): """기본 설정 그대로 — 이 테스트가 없어서 승격이 죽어 있는 채로 커밋됐다. 첫 구현은 하강을 `now` 와 비교했다. 넘어짐은 prone_confirm_s(기본 3초) 지속 후에 확정되므로 확정 시점에는 하강으로부터 항상 3초 넘게 지나 있었고, link_s 도 3.0 이라 승격이 **한 번도** 일어나지 않았다. 다른 테스트들이 prone_confirm_s=0.5 를 써서 전부 초록이었다. 그래서 이 테스트는 감지기 설정을 건드리지 않는다. 기본값끼리 부딪히는 것이 요점이다. """ eng = EventEngine( FallDetector(FallConfig()), # ← 기본값. 절대 낮추지 말 것. ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)), GhostIncidentDetector(GhostConfig()), ZoneIntrusionDetector([], ZoneConfig()), EngineConfig(camera_id="cam-01", cooldown_s=30.0), descent=DescentWatch(DescentConfig()), # ← 기본값 ) tr = Track(77, deque(maxlen=90)) # 발판(y2=200)에서 바닥(y2=410)으로 1.17초 낙하 — 키 150px 대비 1.4배 for t, y2 in [(0.0, 200), (0.25, 250), (0.5, 310), (0.83, 370), (1.17, 410)]: _falling_track(tr, t, y2=float(y2)) eng.process([tr], [], now=t) # 착지 후 12fps 로 계속 누워 있음 t = 1.17 out = [] while t < 8.0 and not out: t = round(t + 1 / 12, 3) _lying_track(tr, t) out = eng.process([tr], [], now=t) types = [e.event_type for e in out] assert EventType.FALL_FROM_HEIGHT in types, f"기본 설정에서 승격 실패: {types}" assert EventType.FALL not in types def test_one_height_fall_does_not_also_fire_on_track_loss(): # 프레임 안에서 추락으로 냈는데 그 사람의 검출이 끊기면, 소실 경로가 같은 사고를 또 낸다. eng = _height_engine() tr = Track(88, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) _falling_track(tr, 0.5, y2=330.0) _falling_track(tr, 0.9, y2=430.0) eng.process([tr], [], now=0.9) _lying_track(tr, 1.0); eng.process([tr], [], now=1.0) _lying_track(tr, 1.6) first = eng.process([tr], [], now=1.6) assert EventType.FALL_FROM_HEIGHT in [e.event_type for e in first] eng.process([], [tr], now=2.0) # 검출 끊김 later = [e.event_type for e in eng.process([], [], now=5.0)] # 추락은 한 번만. ghost(위험 상태 소실)는 별개 사건이라 그대로 둔다 — 쓰러진 사람이 # 화면에서 사라진 것은 옮겨졌을 수 있다는 뜻이고, 1.0.0 부터의 동작이다. assert EventType.FALL_FROM_HEIGHT not in later, f"같은 사고가 두 번 올라갔다: {later}" assert later == [EventType.GHOST], later def test_forget_clears_height_arming_so_it_does_not_ride_to_another_person(): # 두 사람이 교차해 트랙이 뒤바뀌면 엔진이 forget 을 부른다. 무장이 남으면 살아남은 # 다른 사람이 나중에 화면 밖으로 걸어 나갈 때 추락 경보가 된다. eng = _height_engine() tr = Track(99, deque(maxlen=90)) _falling_track(tr, 0.0, y2=200.0) _falling_track(tr, 0.9, y2=430.0) eng.process([tr], [], now=0.9) eng.forget(99) eng.process([], [tr], now=1.0) assert eng.process([], [], now=5.0) == []