ICEYOU/src/iceyou/camera.py

244 lines
8.9 KiB
Python

"""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()