move_detect/edge/engine.py
유창욱 a203c42bf3 feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):

- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
  A solo worker in a buddy-rule zone is a compliance violation and a rescue-
  delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
  A short detection dropout (<=5s grace) does NOT reset the dwell timer -
  without that, run_once's stale-track filter would quietly neuter every
  10-minute rule at real-world detection rates.

Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).

Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.

UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).

Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
2026-08-06 19:33:12 +09:00

129 lines
7.2 KiB
Python

from dataclasses import dataclass
from edge.tracking import Track
from edge.events import Event, EventType, severity_for
from edge.detectors.fall import FallDetector
from edge.detectors.immobility import ImmobilityWatchdog
from edge.detectors.ghost import GhostIncidentDetector
from edge.detectors.zone import ZoneIntrusionDetector
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
@dataclass
class EngineConfig:
camera_id: str = "cam"
cooldown_s: float = 30.0
_OCCUPANCY_TYPES = {"lone_worker": EventType.LONE_WORKER,
"overcapacity": EventType.OVERCAPACITY,
"overstay": EventType.OVERSTAY}
class EventEngine:
def __init__(self, fall: FallDetector, immobility: ImmobilityWatchdog,
ghost: GhostIncidentDetector, zone: ZoneIntrusionDetector,
cfg: EngineConfig, occupancy: OccupancyWatchdog | None = None):
self.fall = fall
self.immobility = immobility
self.ghost = ghost
self.zone = zone
# Keyword-with-default so the existing 5-arg constructors (runtime, tests) keep working.
self.occupancy = occupancy if occupancy is not None else OccupancyWatchdog(OccupancyConfig())
self.cfg = cfg
self._last_emit: dict[tuple, float] = {}
def _allow(self, key: tuple, now: float) -> bool:
last = self._last_emit.get(key)
if last is not None and now - last < self.cfg.cooldown_s:
return False
self._last_emit[key] = now
return True
def forget(self, track_id: int) -> None:
"""트랙 소실 시 하위 감지기 + dedup 캐시의 트랙별 상태를 정리 (24/7 장치 메모리 누수 방지)."""
self.fall.forget(track_id)
self.immobility.forget(track_id)
self.zone.forget(track_id)
self.occupancy.forget(track_id)
self._last_emit = {k: v for k, v in self._last_emit.items()
if not (len(k) >= 2 and k[1] == track_id)}
def _mk(self, etype: EventType, occurred_at: float, track_id=None, zone_id=None,
metrics=None, spatial=None) -> Event:
m = metrics or {}
duration_s = m.get("immobility_s") or m.get("waited_s") or m.get("dwell_s") \
or m.get("alone_s") or 0.0
return Event(etype, severity_for(etype), self.cfg.camera_id, track_id,
zone_id, occurred_at, duration_s, m, spatial)
def process(self, active_tracks: list[Track], lost_tracks: list[Track],
now: float, frame_wh=None, wall_now: float | None = None) -> list[Event]:
# `now` is a MONOTONIC clock (never jumps): it drives every detector timer, hysteresis,
# and cooldown, so an NTP step cannot instantly satisfy immobile_s / expire all tracks /
# reset a genuine immobility countdown. `wall_now` is real wall-clock time, used ONLY to
# stamp Event.occurred_at (the server correlates and retains by wall time). Defaults to
# `now` so single-clock callers (unit tests) keep their existing occurred_at behavior.
occurred_at = wall_now if wall_now is not None else now
events: list[Event] = []
self.ghost.on_lost(lost_tracks, now)
for tr in active_tracks:
# frame_wh lets the zone detector normalize the foot to the dashboard's 0..1 zone
# space (see ZoneIntrusionDetector._foot). run_once supplies the live frame size.
zone_hits = self.zone.update(tr, now, frame_wh)
in_zone = bool(self.zone.active_zone_ids(tr.track_id))
sig, meta = self.fall.update(tr, now)
state = self.fall._states.get(tr.track_id)
self.ghost.note_state(tr.track_id, state.name if state else "STANDING")
if sig == "fall" and self._allow((EventType.FALL, tr.track_id), now):
events.append(self._mk(EventType.FALL, occurred_at, tr.track_id, metrics=meta))
elif sig == "no_motion" and self._allow((EventType.NO_MOTION, tr.track_id), now):
events.append(self._mk(EventType.NO_MOTION, occurred_at, tr.track_id, metrics=meta))
fired, imeta = self.immobility.update(tr, now, in_danger_zone=in_zone)
if fired and self._allow((EventType.IMMOBILITY, tr.track_id), now):
events.append(self._mk(EventType.IMMOBILITY, occurred_at, tr.track_id, metrics=imeta))
for hit in zone_hits:
# 협착(pinch) is the highest-danger zone (machinery entrapment) and must emit
# PINCH/HIGH — the old `restricted else WARNING` silently downgraded it. The
# dashboard offers exactly these three levels (see zones.php).
etype = {"pinch": EventType.PINCH,
"restricted": EventType.ZONE_RESTRICTED}.get(
hit["level"], EventType.ZONE_WARNING)
if self._allow((etype, tr.track_id, hit["zone_id"]), now):
events.append(self._mk(etype, occurred_at, tr.track_id, zone_id=hit["zone_id"]))
# 존 규칙(2인 1조 · 정원 · 체류): 위 per-track 루프에서 zone.update가 갱신한 멤버십을
# 존별 인원으로 뒤집어 감시자에 넘긴다. stale 트랙은 run_once가 이미 걸러냈으므로
# 여기 인원수는 이번 틱에 실제로 검출된 사람만 센다.
zone_tracks: dict[str, set[int]] = {}
for tr in active_tracks:
for zid in self.zone.active_zone_ids(tr.track_id):
zone_tracks.setdefault(zid, set()).add(tr.track_id)
for o in self.occupancy.update(self.zone.zones, zone_tracks, now):
etype = _OCCUPANCY_TYPES[o["kind"]]
# 쿨다운 키에 zone_id 포함: 존 A의 위반이 존 B의 발보를 막지 않는다. track_id가
# None인 존 단위 이벤트의 키는 forget()이 지우지 못하지만, 존 수에 비례하는
# 소량이라 누수로 보지 않는다 (ghost와 달리 키 공간이 유한).
if self._allow((etype, o["track_id"], o["zone_id"]), now):
events.append(self._mk(etype, occurred_at, track_id=o["track_id"],
zone_id=o["zone_id"], metrics=o["metrics"]))
for g in self.ghost.update(active_tracks, now):
# No _allow cooldown for GHOST: ghost.update pops each incident from its candidates as it
# emits, and track ids are never reused, so a given track fires GHOST at most once ever.
# Routing it through _allow would only insert a permanent (GHOST, track_id) dedup key for
# a track that was already forgotten reappear_s ago — a slow 24/7 memory leak that
# forget() can never reclaim (the track never appears in lost_tracks again).
events.append(self._mk(EventType.GHOST, occurred_at, track_id=g["track_id"],
spatial=g["spatial"],
metrics={"last_state": g["last_state"],
"waited_s": g["waited_s"]}))
# ghost.on_lost already consumed lost tracks' state above, so forgetting here is safe.
for tr in lost_tracks:
self.forget(tr.track_id)
return events