"""모델 대기 감지기 — 보호구 미착용 · 중장비 근접 · 화재/연기. STATUS: 배선 완료, 모델 없음. 기본 비활성이며 이 상태에서는 어떤 이벤트도 발화하지 않는다. 왜 모델 없이 이 파일이 있는가 이 세 가지는 현장에서 가장 자주 요구되는 항목이고(특히 보호구), 나중에 급하게 끼워 넣으면 이벤트 계약·심각도·쿨다운·스냅샷 정책을 그때그때 즉흥적으로 정하게 된다. 계약을 지금 고정해 두면 모델이 확보되는 날 바꿀 것은 `Model` 구현 하나뿐이다. 무엇이 막고 있는가 (정직하게) ultralytics(YOLO) 계열은 AGPL 이라 이 프로젝트에서 사용 금지다(CLAUDE.md). 따라서 - 보호구/중장비: AGPL 이 아닌 라이선스의 가중치 + Hailo HEF 컴파일이 선행되어야 한다. - 화재/연기: 위와 같거나, 열화상 센서 같은 다른 감지 방식을 택해야 한다. 확보 전까지 `enabled=False` 이며, 켜더라도 `model=None` 이면 아무 일도 하지 않는다. "곧 됩니다" 상태로 켜 두는 것이 가장 위험하다 — 현장은 감지되고 있다고 믿는다. 모델 인터페이스 (구현체가 만족해야 하는 계약) detect(frame, persons) -> list[dict] 각 dict: {"kind": "ppe_missing"|"proximity"|"fire_smoke", "confidence": float 0..1, "track_id": int | None, "spatial": (x, y) | None, # 0..1 정규화 "metrics": dict} # 유형별 부가 수치 프레임당 한 번 호출되며, 예외를 던지면 그 틱만 건너뛴다(파이프라인은 계속 돈다). """ from dataclasses import dataclass, field _KINDS = ("ppe_missing", "proximity", "fire_smoke") @dataclass class PendingConfig: # 기본 False. 모델이 없는 채로 켜면 "감지되고 있다"는 잘못된 믿음만 만든다. enabled: bool = False # 유형별 최소 신뢰도. 보호구는 오탐이 곧 현장 반발이라 높게 잡는다. min_confidence: dict = field(default_factory=lambda: { "ppe_missing": 0.80, "proximity": 0.70, "fire_smoke": 0.85, }) # 유형별 재발행 억제(초). 엔진 쿨다운과 별개의 2차 방어 — 보호구 미착용은 한 사람이 # 한 시간을 그러고 있어도 사건 하나다. reemit_s: dict = field(default_factory=lambda: { "ppe_missing": 600.0, "proximity": 30.0, "fire_smoke": 60.0, }) class PendingDetectors: """모델이 주어지면 그 출력을 이벤트 후보로 정규화한다. 없으면 아무것도 하지 않는다.""" def __init__(self, cfg: PendingConfig | None = None, model=None, log=None): self.cfg = cfg or PendingConfig() self.model = model self._log = log or (lambda m: None) self._last: dict[tuple, float] = {} @property def active(self) -> bool: """켜져 있고 실제로 판정할 수단이 있는가. 대시보드/기동 로그가 이것을 보고한다.""" return bool(self.cfg.enabled and self.model is not None) def _allow(self, kind: str, track_id, now: float) -> bool: key = (kind, track_id) last = self._last.get(key) if last is not None and now - last < float(self.cfg.reemit_s.get(kind, 60.0)): return False self._last[key] = now return True def forget(self, track_id: int) -> None: self._last = {k: v for k, v in self._last.items() if k[1] != track_id} def observe(self, frame, persons, now: float) -> list[tuple[str, dict, object, object]]: """@return [(event_type, metrics, track_id, spatial), ...] — 보통 빈 리스트. 모델이 없거나 비활성이면 항상 빈 리스트다. 그것이 정상 상태다. """ if not self.active: return [] try: raw = self.model.detect(frame, persons) or [] except Exception as e: # 한 틱의 추론 실패로 넘어짐 감지까지 멈추면 안 된다. self._log(f"pending detector model failed: {type(e).__name__}: {e}") return [] out = [] for d in raw: if not isinstance(d, dict): continue kind = d.get("kind") if kind not in _KINDS: continue conf = float(d.get("confidence") or 0.0) if conf < float(self.cfg.min_confidence.get(kind, 1.0)): continue track_id = d.get("track_id") if not self._allow(kind, track_id, now): continue metrics = dict(d.get("metrics") or {}) metrics["confidence"] = round(conf, 3) out.append((kind, metrics, track_id, d.get("spatial"))) return out