"""Multi-camera manager with dead-camera detection. Background ---------- On Windows DSHOW, `cv2.VideoCapture(idx, CAP_DSHOW)` for a phantom index (e.g. "OBS Virtual Camera" left registered with no provider, or an unplugged device) often returns `isOpened() == True` but the first `cap.read()` blocks forever waiting for data that will never arrive. To survive this we probe every requested index with a frame-read on a worker thread and a timeout; indices that don't deliver within `probe_timeout` are marked DEAD and permanently skipped. Public API ---------- - `Camera(config)` multi-stream manager - `camera.read_frame()` primary stream frame (back-compat) - `camera.read_all_frames()` dict {index: frame} - `camera.capture_snapshot(reason)` single file from primary (back-compat) - `camera.capture_all_snapshots(reason)` one file per alive stream -> list[str] - `camera.is_vision_obscured()` True only if ALL alive streams are obscured - `camera.alive_streams` list of CameraStream (use for per-cam consumers such as MotionRecorder) - `camera.release()` close everything """ import cv2 import threading from pathlib import Path from typing import List, Optional, Dict from .utils import get_timestamp from .config import Config class CameraStream: """Single webcam wrapper that survives dead-device probing.""" def __init__(self, index: int): self.index = int(index) self._cap: Optional[cv2.VideoCapture] = None self._lock = threading.Lock() self.is_alive = False def open(self, probe_timeout: float = 2.5) -> bool: """Open the capture and verify it actually delivers a frame within `probe_timeout` seconds. Returns True on success, False if dead.""" cap = cv2.VideoCapture(self.index, cv2.CAP_DSHOW) if not cap.isOpened(): try: cap.release() except Exception: pass self.is_alive = False return False # cap.read() can block forever on phantom DSHOW devices - probe in a # daemon thread and bail if it doesn't return in time. result = {"ok": False, "frame": None, "done": False} def reader(): try: ok, frame = cap.read() result["ok"] = bool(ok and frame is not None) result["frame"] = frame except Exception: result["ok"] = False finally: result["done"] = True t = threading.Thread(target=reader, daemon=True) t.start() t.join(timeout=probe_timeout) if not result["done"] or not result["ok"]: # Reader still blocked OR read returned no frame: dead camera. # We intentionally do NOT cap.release() here because the reader # may still be inside cap.read() and releasing concurrently can # crash the DSHOW driver. The leaked cap dies at process exit. self.is_alive = False return False self._cap = cap self.is_alive = True return True def read_frame(self): with self._lock: if self._cap is None: return None try: ok, frame = self._cap.read() if not ok or frame is None: return None return frame except Exception: return None def release(self) -> None: with self._lock: if self._cap is not None: try: self._cap.release() except Exception: pass self._cap = None self.is_alive = False class Camera: """Multi-camera manager. Backward compatible single-cam API + new multi-cam helpers.""" def __init__(self, config: Config): self.config = config self.snapshot_dir = Path(config.get("snapshot_dir", "snapshots")) self.snapshot_dir.mkdir(parents=True, exist_ok=True) # Resolve requested indices: prefer the new list, fall back to legacy single indices = config.get("camera_device_indices") if not indices: indices = [int(config.get("camera_device_index", 0))] # De-duplicate while preserving order seen = set() ordered = [] for i in indices: ii = int(i) if ii not in seen: seen.add(ii) ordered.append(ii) # Camera obstruction detection settings obs = config.get("camera_obscured_detection", {}) or {} self.obscured_enabled = bool(obs.get("enabled", True)) self.brightness_threshold = float(obs.get("brightness_threshold", 35)) self.variance_threshold = float(obs.get("variance_threshold", 10)) probe_timeout = float(config.get("camera_probe_timeout_seconds", 2.5)) self.streams: List[CameraStream] = [] for idx in ordered: stream = CameraStream(idx) if stream.open(probe_timeout=probe_timeout): print(f"[Camera] index {idx} OPEN") self.streams.append(stream) else: print(f"[Camera] index {idx} DEAD (skipped after {probe_timeout}s probe)") # keep the dead stream object out of the list entirely if not self.alive_streams: print("[Camera] WARNING: no working cameras detected") # ---- discovery / accessors ---- @property def alive_streams(self) -> List[CameraStream]: return [s for s in self.streams if s.is_alive] @property def alive_indices(self) -> List[int]: return [s.index for s in self.alive_streams] @property def primary(self) -> Optional[CameraStream]: return self.alive_streams[0] if self.alive_streams else None def is_camera_available(self) -> bool: return self.primary is not None # ---- frame I/O ---- def read_frame(self): """Back-compat: returns a frame from the PRIMARY (first alive) camera.""" p = self.primary return p.read_frame() if p else None def read_all_frames(self) -> Dict[int, object]: """Returns {camera_index: frame} for every alive stream (None if a read failed).""" return {s.index: s.read_frame() for s in self.alive_streams} # ---- snapshots ---- def capture_snapshot(self, reason: str = "trigger") -> Optional[str]: """Back-compat single snapshot from PRIMARY camera. Returns path or None.""" p = self.primary if p is None: return None frame = p.read_frame() if frame is None: return None ts = get_timestamp() suffix = f"cam{p.index}_" if len(self.alive_streams) > 1 else "" filename = f"snapshot_{ts}_{suffix}{reason}.jpg" filepath = self.snapshot_dir / filename return str(filepath) if cv2.imwrite(str(filepath), frame) else None def capture_all_snapshots(self, reason: str = "trigger") -> List[str]: """Snap from every alive camera. Returns list of saved file paths.""" out: List[str] = [] ts = get_timestamp() for s in self.alive_streams: frame = s.read_frame() if frame is None: continue filename = f"snapshot_{ts}_cam{s.index}_{reason}.jpg" filepath = self.snapshot_dir / filename if cv2.imwrite(str(filepath), frame): out.append(str(filepath)) return out # ---- obscured detection ---- def is_vision_obscured(self) -> bool: """True only if EVERY alive camera reports obscured vision. One unblocked camera = scene is visible to ICEYOU; don't trigger lockdown. """ if not self.obscured_enabled: return False alive = self.alive_streams if not alive: return False # No cameras = can't decide; don't escalate obscured_all = True for s in alive: frame = s.read_frame() if frame is None: continue try: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) except Exception: continue mean_b = float(gray.mean()) var = float(gray.std(ddof=0)) if mean_b >= self.brightness_threshold and var >= self.variance_threshold: obscured_all = False break if obscured_all: print(f"[Camera] All {len(alive)} alive camera(s) report obscured") return obscured_all # ---- lifecycle ---- def release(self) -> None: for s in self.streams: s.release()