from edge.config import Config from edge.tracking import IouTracker from edge.smoothing import TrackSmootherPool 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.engine import EventEngine from edge.snapshot import RingBuffer, FaceBlurrer, Snapshotter from edge.models import BBox from edge.events import Event, EventType, severity_for # Evidence timeline offsets (seconds before the event) — 3 frames tell how it developed. # The server accepts at most 3 snapshots per event (EventValidator), so this is the cap. SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0) # 사진을 붙이지 않는 유형. # # 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은 # 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서 # 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다: # # ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다. # OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고, # 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로 # 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다. # DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다. # # 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가 # 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다. NO_EVIDENCE_TYPES = frozenset({ EventType.ERGO_REPORT, EventType.OVERSTAY, EventType.DEVICE_FAULT, EventType.MONITORING_DEGRADED, }) def _head_boxes(persons: list) -> list: # Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed # ~40x40 patch — a close worker fills far more than 40px, so a constant box left the face # fully identifiable. Centered on the nose when it is confident, else on the bbox's top # (the head is the upper part of the person) so a bad keypoint can't move the blur off it. boxes = [] for p in persons: bb = p.bbox nose = p.keypoints[0] half_w = max(8.0, 0.25 * bb.width) if nose.conf >= 0.3: cx, cy = nose.x, nose.y else: cx, cy = bb.cx, bb.y1 + 0.12 * bb.height boxes.append(BBox(cx - half_w, cy - 0.18 * bb.height, cx + half_w, cy + 0.08 * bb.height)) return boxes def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_fn, wall_fn=None, integrity=None): ok, frame = source.read() if not ok: # No frame this tick. Return None (not []) so the daemon can tell "camera produced no # frame" apart from "frame processed, no events" — a dead/unplugged USB camera returns # (False, None) forever, and counting it as a healthy frame made a monitoring outage # look perfectly green (fps~=target, cameras_erroring=0). return None now = now_fn() persons = pose.infer(frame) tracks = tracker.update(persons, now) # RAW keypoints; smoothing happens per-track below lost = tracker.lost() # Head boxes are computed EVERY tick and stored with the frame (not only when an event # fires): a past frame pulled from the ring later must be blurred with the head positions # of its own moment, not the current tick's. head_boxes = _head_boxes(persons) # A contended (crossing) match may be an id swap — FALLEN state, frozen baseline, or an # immobility timer would ride along to the wrong person. Reset that track's safety state; # a genuinely fallen worker still prone re-confirms within prone_confirm_s. for tid in tracker.ambiguous(): engine.forget(tid) pool.forget(tid) # A track not re-detected this tick still sits in the tracker (< max_age) with a frozen # latest frame. Feeding it downstream would re-smooth the same frame and let time-based # detector timers (prone confirm / no-motion / immobility) accrue on stale data — treat # it as no-data until it is re-detected or ages into lost(). fresh = [tr for tr in tracks if tr.latest.t == now] for tr in fresh: f = tr.latest f.keypoints = pool.smoother_for(tr.track_id).smooth(f.keypoints, now) ring.push(now, frame, head_boxes) h, w = frame.shape[:2] # `now` (monotonic) drives detector timers; wall time stamps the event (occurred_at). Off the # daemon (direct callers / tests) wall_fn is None → engine falls back to now, as before. wall_now = wall_fn() if wall_fn is not None else None events = engine.process(fresh, lost, now, frame_wh=(w, h), wall_now=wall_now) # Camera-integrity findings are about the FRAME, not about any track, so they are produced # here (where the frame is) rather than inside the engine (which only sees tracks). They # matter because a camera that has been knocked askew keeps producing perfectly healthy # frames while every zone drawn on it now points somewhere else — the failure that used to # be invisible until after an incident. if integrity is not None: stamp = wall_now if wall_now is not None else now for etype, metrics in integrity.observe(frame, now): et = EventType(etype) events.append(Event(et, severity_for(et), engine.cfg.camera_id, None, None, stamp, 0.0, metrics, None)) for tr in lost: pool.forget(tr.track_id) if events: # 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다. evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES] snaps = [] if evidential: # Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end # state (a fall's value is in the seconds before the person hit the floor). # Right after startup the ring is short and fewer frames go out — never zero, # since the current frame was pushed above. Each frame blurs with its own boxes. frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S) snaps = snapshotter.capture(frames, boxes) for e in events: sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps) return events def build_pipeline(config: Config, source, pose) -> dict: return { "source": source, "pose": pose, "tracker": IouTracker(), "pool": TrackSmootherPool(), "engine": EventEngine( FallDetector(config.fall), ImmobilityWatchdog(config.immobility), GhostIncidentDetector(config.ghost), ZoneIntrusionDetector([], config.zone), config.engine, ), "ring": RingBuffer(), "snapshotter": Snapshotter(FaceBlurrer()), }