"""카메라 무결성 감시 — 화각 이동 · 렌즈 가림 · 조도 저하. 이 감지기가 막으려는 것은 "조용한 실패"다: 카메라가 틀어져 존 판정이 통째로 무의미해졌는데 프레임은 계속 들어오고 대시보드는 초록불인 상태. 따라서 테스트의 초점은 (1) 진짜 화각 변화를 잡는가, (2) 사람이 지나가는 것을 화각 변화로 오인하지 않는가 두 가지다. """ import numpy as np from edge.detectors.integrity import CameraIntegrityWatch, IntegrityConfig def _scene(shade: int, h: int = 120, w: int = 160) -> np.ndarray: """단색 배경 위에 구조물이 있는 프레임. shade 로 '다른 장면'을 만든다.""" f = np.full((h, w, 3), shade, dtype=np.uint8) f[20:80, 20:60] = min(255, shade + 90) # 기계 블록 f[90:110, :] = max(0, shade - 60) # 바닥 띠 return f def _cfg(**over) -> IntegrityConfig: base = dict(warmup_s=1.0, sustain_s=3.0, obstructed_sustain_s=2.0, low_light_sustain_s=2.0) base.update(over) return IntegrityConfig(**base) def test_no_finding_while_the_view_is_unchanged(): w = CameraIntegrityWatch(_cfg()) frame = _scene(120) t = 0.0 for _ in range(40): assert w.observe(frame, t) == [] t += 1.0 def test_reports_camera_moved_once_the_change_persists(): w = CameraIntegrityWatch(_cfg()) before, after = _scene(120), _scene(20) t = 0.0 for _ in range(3): # warmup: 기준 프레임 확보 w.observe(before, t); t += 1.0 # 화각이 바뀐 직후에는 아직 확정하지 않는다 — 순간 변화와 구분해야 한다. assert w.observe(after, t) == [] t += 1.0 assert w.observe(after, t) == [] t += 3.0 found = [e for e, _ in w.observe(after, t)] assert "camera_moved" in found def test_a_person_walking_through_is_not_a_moved_camera(): # 사람이 지나가면 MAD 는 오르지만 지속되지 않는다. 이 구분이 없으면 이 감지기는 # 사람이 다니는 모든 현장에서 하루 종일 오탐을 낸다. w = CameraIntegrityWatch(_cfg()) empty = _scene(120) t = 0.0 for _ in range(3): w.observe(empty, t); t += 1.0 walker = empty.copy() walker[30:100, 70:95] = 240 # 화면 일부를 가로지르는 밝은 형체 for _ in range(2): # sustain_s(3.0) 미만으로만 머문다 assert w.observe(walker, t) == [] t += 1.0 for _ in range(6): assert w.observe(empty, t) == [] # 지나간 뒤 정상 복귀 t += 1.0 def test_baseline_is_promoted_so_a_moved_camera_does_not_alarm_forever(): w = CameraIntegrityWatch(_cfg(reemit_s=1.0)) before, after = _scene(120), _scene(20) t = 0.0 for _ in range(3): w.observe(before, t); t += 1.0 w.observe(after, t) # 변화 시작 시각을 기록만 한다 t += 5.0 assert any(e == "camera_moved" for e, _ in w.observe(after, t)) # 새 화각이 기준이 되었으므로, 그대로 유지되는 한 다시 울리지 않는다. for _ in range(20): t += 1.0 assert [e for e, _ in w.observe(after, t) if e == "camera_moved"] == [] def test_covered_lens_is_reported(): w = CameraIntegrityWatch(_cfg()) flat = np.full((120, 160, 3), 90, dtype=np.uint8) # 분산 ~0: 천으로 덮인 상태 t = 0.0 assert w.observe(flat, t) == [] t += 3.0 found = [e for e, _ in w.observe(flat, t)] assert "camera_obstructed" in found def test_dark_scene_is_reported_as_low_light_not_as_obstruction(): w = CameraIntegrityWatch(_cfg()) dark = _scene(12) # 어둡지만 구조는 남아 있다 (분산 존재) t = 0.0 w.observe(dark, t) t += 3.0 found = [e for e, _ in w.observe(dark, t)] assert "low_light" in found assert "camera_obstructed" not in found def test_findings_are_rate_limited(): w = CameraIntegrityWatch(_cfg(reemit_s=1000.0)) flat = np.full((120, 160, 3), 90, dtype=np.uint8) t = 0.0 w.observe(flat, t) t += 3.0 assert any(e == "camera_obstructed" for e, _ in w.observe(flat, t)) for _ in range(10): t += 1.0 assert [e for e, _ in w.observe(flat, t) if e == "camera_obstructed"] == [] def test_disabled_watch_reports_nothing(): w = CameraIntegrityWatch(IntegrityConfig(enabled=False)) flat = np.zeros((120, 160, 3), dtype=np.uint8) for t in range(200): assert w.observe(flat, float(t)) == [] def test_tolerates_odd_frames_without_raising(): # 파이프라인이 죽는 것이 오탐보다 나쁘다: 이상한 프레임은 조용히 무시한다. w = CameraIntegrityWatch(_cfg()) assert w.observe(None, 0.0) == [] assert w.observe(np.zeros((2, 2, 3), dtype=np.uint8), 1.0) == [] # 격자보다 작다 assert w.observe(np.zeros((0, 0, 3), dtype=np.uint8), 2.0) == [] assert w.observe(np.zeros((4, 4, 4, 3), dtype=np.uint8), 3.0) == [] # 차원 이상 def test_grayscale_frames_are_accepted(): w = CameraIntegrityWatch(_cfg()) gray = np.full((120, 160), 90, dtype=np.uint8) t = 0.0 w.observe(gray, t) t += 3.0 assert any(e == "camera_obstructed" for e, _ in w.observe(gray, t))