244 lines
8.9 KiB
Python
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()
|