"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하. WHY THIS EXISTS 존(위험구역) 판정은 "카메라가 어제와 같은 곳을 보고 있다"는 전제 위에 통째로 서 있다. 청소하다 삼각대를 스치거나, 지게차가 브래킷을 치거나, 누가 각도를 조금 돌리면 제한구역 폴리곤은 엉뚱한 바닥을 가리키게 된다. 그런데 시스템은 아무 오류도 내지 않는다 — 프레임은 계속 들어오고, 사람은 계속 검출되고, 대시보드는 초록불이다. 침입 감지가 죽었다는 사실을 누군가 사고가 난 뒤에야 알게 되는 것이 이 제품의 가장 조용한 실패 모드였다. 접근 무거운 특징점 매칭 대신 저해상도 그레이스케일 요약본을 기준으로 삼는다. Pi 에서 매 프레임 돌아야 하고, 우리가 감지하려는 것은 미세한 흔들림이 아니라 "다른 곳을 보고 있다" 수준의 변화이기 때문에 이 해상도로 충분하다. 세 신호를 각각 다른 물리량으로 잡는다: moved — 기준 대비 평균 절대 차이(MAD)가 임계 초과. 각도가 바뀌면 화면 전체가 바뀐다. obstructed — 프레임 자체의 분산 급락. 렌즈를 천으로 덮으면 평탄한 회색이 된다. low_light — 평균 밝기 하강. 야간·조명 고장 시 검출력이 떨어졌음을 스스로 신고한다. 사람이 화면을 지나가도 MAD 는 오른다. 그래서 **지속 시간**을 요구한다: 순간 변화가 아니라 sustain_s 이상 유지될 때만 확정한다. 사람은 지나가고 화각 변화는 남는다는 성질을 쓴다. 기준 프레임 부팅 후 warmup_s 동안 조용히 관찰하다가 그 시점의 요약본을 기준으로 잡는다. 부팅 직후 한 장을 그냥 쓰면 마침 그때 사람이 서 있었을 경우 그 사람이 영원히 배경이 된다. 확정 이벤트를 낸 뒤에는 기준을 현재 화면으로 재설정한다 — 재설정하지 않으면 옮겨진 카메라가 cooldown 마다 같은 이벤트를 영원히 재발행한다. 의존성 없음: numpy 만 쓴다 (OpenCV 도 불필요). 폐쇄망 번들에 새 휠을 추가하지 않는다. """ from dataclasses import dataclass import numpy as np @dataclass class IntegrityConfig: enabled: bool = True # 기준 프레임을 잡기까지의 관찰 시간. 이 사이 화면에 사람이 있어도 평균이 희석된다. warmup_s: float = 20.0 # 요약본 해상도. 32x24 면 사람 하나가 화면의 몇 퍼센트인지 구분하기엔 충분하고, # 조명 깜빡임 같은 화소 단위 잡음은 자연히 뭉개진다. grid_w: int = 32 grid_h: int = 24 # 0..255 스케일의 평균 절대 차이. 30 은 "화면 절반 이상이 다른 밝기" 수준으로, # 사람 한 명이 지나가는 것(보통 5~15)과 명확히 갈린다. moved_mad: float = 30.0 # 화각 변화로 확정하기까지 필요한 지속 시간. 지나가는 사람은 이 시간을 못 채운다. sustain_s: float = 8.0 # 렌즈 가림: 프레임 분산이 이 밑이면 사실상 단색 화면이다. obstructed_var: float = 25.0 obstructed_sustain_s: float = 5.0 # 조도: 평균 밝기가 이 밑이면 포즈 추정 신뢰도가 급격히 떨어진다. low_light_mean: float = 40.0 low_light_sustain_s: float = 60.0 # 확정 후 같은 유형을 다시 낼 때까지의 최소 간격 (엔진 cooldown 과 별개의 2차 방어). reemit_s: float = 900.0 class CameraIntegrityWatch: """프레임을 받아 (event_type, metrics) 목록을 돌려준다. 이벤트 생성은 엔진이 한다.""" def __init__(self, cfg: IntegrityConfig | None = None): self.cfg = cfg or IntegrityConfig() self._baseline: np.ndarray | None = None self._first_seen: float | None = None # 각 상태가 "연속으로 참이기 시작한 시각". None 이면 지금은 정상. self._since: dict[str, float | None] = {"moved": None, "obstructed": None, "low_light": None} self._last_emit: dict[str, float] = {} # ---------------------------------------------------------------- helpers def _summarize(self, frame) -> np.ndarray | None: """프레임 -> (grid_h, grid_w) float32 그레이스케일 요약본.""" if frame is None: return None a = np.asarray(frame) if a.ndim == 3: # BGR/RGB 어느 쪽이든 평균 그레이면 충분하다 (우리가 재는 것은 구조 변화이지 색이 아니다). a = a.mean(axis=2) elif a.ndim != 2: return None if a.size == 0: return None h, w = a.shape[:2] gh, gw = self.cfg.grid_h, self.cfg.grid_w if h < gh or w < gw: return None # 정수 인덱싱으로 다운샘플: 보간 라이브러리 없이 균등 격자를 뽑는다. ys = (np.linspace(0, h - 1, gh)).astype(np.int32) xs = (np.linspace(0, w - 1, gw)).astype(np.int32) return a[np.ix_(ys, xs)].astype(np.float32) def _hold(self, key: str, active: bool, now: float, need_s: float) -> bool: """상태가 need_s 이상 연속 유지되면 True 를 한 번 돌려준다.""" if not active: self._since[key] = None return False if self._since[key] is None: self._since[key] = now return False if now - self._since[key] < need_s: return False last = self._last_emit.get(key) if last is not None and now - last < self.cfg.reemit_s: return False self._last_emit[key] = now return True # ---------------------------------------------------------------- main def observe(self, frame, now: float) -> list[tuple[str, dict]]: """@return [(event_type_value, metrics), ...] — 보통 빈 리스트.""" if not self.cfg.enabled: return [] summary = self._summarize(frame) if summary is None: return [] if self._first_seen is None: self._first_seen = now mean = float(summary.mean()) var = float(summary.var()) out: list[tuple[str, dict]] = [] # 조도와 가림은 기준 프레임이 없어도 판정할 수 있다 — 프레임 자체의 성질이다. if self._hold("obstructed", var < self.cfg.obstructed_var, now, self.cfg.obstructed_sustain_s): out.append(("camera_obstructed", {"variance": round(var, 2), "mean_luma": round(mean, 1)})) if self._hold("low_light", mean < self.cfg.low_light_mean, now, self.cfg.low_light_sustain_s): out.append(("low_light", {"mean_luma": round(mean, 1)})) # 화각 이동은 기준이 있어야 한다. if self._baseline is None: if now - self._first_seen >= self.cfg.warmup_s: self._baseline = summary return out mad = float(np.abs(summary - self._baseline).mean()) if self._hold("moved", mad > self.cfg.moved_mad, now, self.cfg.sustain_s): out.append(("camera_moved", {"mad": round(mad, 1), "threshold": self.cfg.moved_mad})) # 새 화각을 기준으로 승격한다. 하지 않으면 옮겨진 카메라가 영원히 같은 경보를 낸다. # (존은 여전히 무효 상태다 — 그래서 이 이벤트가 HIGH 이고, 사람이 존을 다시 그려야 한다.) self._baseline = summary self._since["moved"] = None return out def reset_baseline(self) -> None: """존을 다시 그린 뒤 등, 현재 화각을 정상으로 재선언할 때.""" self._baseline = None self._first_seen = None self._since = {k: None for k in self._since}