"""OpenCV-backed capture source with optional background thread and reconnect.""" from __future__ import annotations import logging import os import sys import threading import time from dataclasses import dataclass from math import isfinite from typing import Any, Protocol, runtime_checkable import cv2 import numpy as np from .config import ( CaptureConfig, CaptureHealth, CaptureStatus, ReconnectPolicy, SourceType, ) logger = logging.getLogger(__name__) @dataclass(frozen=True) class CapturedFrame: """One image and the host clocks sampled when its capture read completed. ``sequence`` belongs to this source instance. ``media_time_ms`` is advisory file metadata and is never substituted for the host acquisition clocks. """ image: np.ndarray sequence: int host_mono_ns: int host_utc_ns: int media_time_ms: float | None = None @runtime_checkable class VideoCaptureLike(Protocol): """Minimal protocol so tests can inject fake captures.""" def isOpened(self) -> bool: ... def read(self) -> tuple[bool, Any]: ... def release(self) -> None: ... def get(self, propId: int) -> float: ... def set(self, propId: int, value: float) -> bool: ... def grab(self) -> bool: ... def normalize_source(source: SourceType | None) -> SourceType: """Accept camera index, numeric string, V4L path, or filesystem path.""" if source is None: return 0 if isinstance(source, str): stripped = source.strip() if not stripped: return 0 if stripped.isdigit(): return int(stripped) return stripped.rstrip("/\\") or stripped return source def is_live_camera(source: SourceType) -> bool: """True for camera indices and Linux V4L2 device paths.""" if isinstance(source, int): return True if isinstance(source, str) and source.startswith("/dev/video"): return True return False def _backend_candidates(preferred: str = "auto") -> list[tuple[str, int]]: preferred = (preferred or "auto").lower() candidates: list[tuple[str, int]] = [] def add(name: str, attr: str) -> None: if hasattr(cv2, attr): candidates.append((name, getattr(cv2, attr))) if preferred in ("dshow", "directshow") and sys.platform == "win32": add("DSHOW", "CAP_DSHOW") elif preferred in ("msmf",) and sys.platform == "win32": add("MSMF", "CAP_MSMF") elif preferred in ("v4l2", "v4l") and sys.platform.startswith("linux"): add("V4L2", "CAP_V4L2") elif preferred not in ("auto", "any", ""): pass if not candidates: if sys.platform == "win32": add("DSHOW", "CAP_DSHOW") add("MSMF", "CAP_MSMF") elif sys.platform.startswith("linux"): add("V4L2", "CAP_V4L2") if not candidates: candidates.append(("ANY", cv2.CAP_ANY)) return candidates def _fourcc_code(fourcc: str | None) -> int | None: if not fourcc: return None text = str(fourcc).strip() if len(text) != 4: return None return cv2.VideoWriter.fourcc(*text) def _decode_fourcc(value: float) -> str: fourcc_int = int(value) if fourcc_int <= 0: return "N/A" bytes_ = [(fourcc_int >> (8 * i)) & 0xFF for i in range(4)] return "".join(chr(b) if 32 <= b <= 126 else "." for b in bytes_) def _read_stream_meta(cap: VideoCaptureLike) -> tuple[int, int, float, str]: width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) fourcc = _decode_fourcc(cap.get(cv2.CAP_PROP_FOURCC) or 0) return width, height, fps, fourcc def _apply_stream_props( cap: VideoCaptureLike, *, width: int | None, height: int | None, fps: float | None, fourcc: str | None, ) -> None: """Apply live-camera stream properties (width/height before fourcc on DSHOW).""" code = _fourcc_code(fourcc) if width is not None: cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) if height is not None: cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) if code is not None: cap.set(cv2.CAP_PROP_FOURCC, code) if fps is not None: cap.set(cv2.CAP_PROP_FPS, float(fps)) # DSHOW often resets fourcc after size negotiation. if code is not None: cap.set(cv2.CAP_PROP_FOURCC, code) def _warmup_capture(cap: VideoCaptureLike, *, grabs: int = 3) -> None: for _ in range(max(0, grabs)): try: cap.grab() except Exception: break def open_opencv_capture( source: SourceType, *, backend: str = "auto", width: int | None = None, height: int | None = None, fps: float | None = None, fourcc: str | None = "MJPG", apply_stream_props: bool = True, ) -> tuple[VideoCaptureLike, str]: """Open a ``cv2.VideoCapture`` trying preferred backends in order. Returns ``(capture, backend_name)``. """ if sys.platform == "win32": os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") source = normalize_source(source) live = is_live_camera(source) last_error = "Failed to open video source." requested_fourcc = (fourcc or "").strip().upper() backends = _backend_candidates(backend) if live else [("ANY", cv2.CAP_ANY)] for backend_name, backend_api in backends: try: if live and backend_api != cv2.CAP_ANY: cap = cv2.VideoCapture(source, backend_api) else: cap = cv2.VideoCapture(source) except Exception as exc: # pragma: no cover - OpenCV quirks last_error = str(exc) continue if not cap.isOpened(): cap.release() last_error = f"Backend {backend_name} failed to open {source!r}" continue if live and apply_stream_props: _apply_stream_props( cap, width=width, height=height, fps=fps, fourcc=fourcc, ) _warmup_capture(cap) _, _, _, negotiated = _read_stream_meta(cap) if requested_fourcc and negotiated.upper() != requested_fourcc: # Retry once: some drivers need a second fourcc set after warmup. code = _fourcc_code(fourcc) if code is not None: cap.set(cv2.CAP_PROP_FOURCC, code) _warmup_capture(cap, grabs=2) return cap, backend_name raise RuntimeError(last_error) class CaptureSource: """Owns a video capture handle, optional background reader, and reconnect logic. Optionally accepts an injected ``factory`` for tests:: CaptureSource(config, factory=lambda cfg: (FakeCap(), "FAKE")) """ def __init__( self, config: CaptureConfig, *, factory=None, ): self.config = config self._factory = factory or self._default_factory self._cap: VideoCaptureLike | None = None self._backend_name = "" self._status = CaptureStatus(source=normalize_source(config.source)) self._reconnect_attempt = 0 self._lock = threading.Lock() self._frame_lock = threading.Lock() self._frame_cond = threading.Condition(self._frame_lock) self._latest_frame: np.ndarray | None = None self._latest_captured: CapturedFrame | None = None self._latest_seq = 0 self._read_seq = 0 self._last_delivered_seq = 0 self._thread: threading.Thread | None = None self._background_running = False @staticmethod def _default_factory(config: CaptureConfig) -> tuple[VideoCaptureLike, str]: return open_opencv_capture( config.source, backend=config.backend, width=config.width, height=config.height, fps=config.fps, fourcc=config.fourcc, apply_stream_props=is_live_camera(normalize_source(config.source)), ) # Compatibility with older CaptureWorker API. @property def source(self) -> CaptureSource: return self @property def running(self) -> bool: return self._background_running @property def status(self) -> CaptureStatus: with self._lock: return CaptureStatus( health=self._status.health, source=self._status.source, last_error=self._status.last_error, last_frame_time=self._status.last_frame_time, last_seq=self._status.last_seq, dropped_frames=self._status.dropped_frames, reconnect_attempts=self._status.reconnect_attempts, consecutive_failures=self._status.consecutive_failures, width=self._status.width, height=self._status.height, fps=self._status.fps, backend_name=self._status.backend_name, fourcc=self._status.fourcc, ) @property def cap(self) -> VideoCaptureLike | None: """Raw capture handle (compat with Sensor.cap).""" return self._cap def is_open(self) -> bool: cap = self._cap return cap is not None and cap.isOpened() def open(self) -> None: with self._lock: self._status.health = CaptureHealth.CONNECTING self._status.last_error = None try: cap, backend_name = self._factory(self.config) except Exception as exc: with self._lock: self._status.health = CaptureHealth.FAILED self._status.last_error = str(exc) raise with self._lock: self._release_unlocked() self._cap = cap self._backend_name = backend_name self._refresh_stream_meta_unlocked() self._status.health = CaptureHealth.CONNECTED self._status.backend_name = backend_name self._status.consecutive_failures = 0 self._reconnect_attempt = 0 if self.config.verbose: st = self.status print( f"OpenCV negotiated stream: FOURCC='{st.fourcc}', " f"size={st.width}x{st.height}, fps={st.fps:.2f}, backend={st.backend_name}" ) requested = (self.config.fourcc or "").strip().upper() if ( requested and is_live_camera(normalize_source(self.config.source)) and st.fourcc.upper() != requested ): print( f"Warning: requested FOURCC '{self.config.fourcc}' but camera " f"negotiated '{st.fourcc}'." ) def release(self) -> None: self.stop_background() with self._lock: self._release_unlocked() self._status.health = CaptureHealth.CLOSED def close(self) -> None: """Stop background thread and release the capture handle.""" self.release() def _release_unlocked(self) -> None: if self._cap is not None: try: self._cap.release() except Exception as exc: # A failed release is not fatal, but it must not be invisible: the # handle below is dropped either way, so a live-camera handle that # OpenCV refused to free would otherwise leak silently until the # process exits. Report it and keep tearing down. logger.warning( "capture: %r.release() failed: %s", self._backend_name or "capture", exc ) self._cap = None with self._frame_cond: self._latest_frame = None self._latest_captured = None self._latest_seq = 0 def _refresh_stream_meta_unlocked(self) -> None: cap = self._cap if cap is None: return try: width, height, fps, fourcc = _read_stream_meta(cap) self._status.width = width self._status.height = height self._status.fps = fps self._status.fourcc = fourcc except Exception as exc: # Metadata is advisory: a backend that cannot answer these get() calls # still delivers frames, so do not fail the open. Record why the # reported stream geometry is left at its previous/default values. logger.warning("capture: could not read stream metadata: %s", exc) def warmup_frames(self, count: int = 3) -> None: """Discard frames so the driver/format stabilizes (sync reads only).""" if self._background_running: return for _ in range(max(0, int(count))): self.read_raw() def read_raw_stamped(self) -> CapturedFrame | None: """Read a frame and bind its identity and clocks before another read.""" cap = self._cap if cap is None: return None try: ok, frame = cap.read() except Exception as exc: with self._lock: self._status.consecutive_failures += 1 self._status.last_error = str(exc) return None if not ok or frame is None: with self._lock: self._status.consecutive_failures += 1 return None host_mono_ns = time.perf_counter_ns() host_utc_ns = time.time_ns() media_time_ms = None if not is_live_camera(self._status.source): try: value = float(cap.get(cv2.CAP_PROP_POS_MSEC)) if isfinite(value) and value >= 0: media_time_ms = value except Exception as exc: # noqa: BLE001 - metadata must not stop capture logger.debug("capture: media timestamp unavailable: %s", exc) with self._lock: self._read_seq += 1 sequence = self._read_seq self._status.consecutive_failures = 0 self._status.last_frame_time = host_mono_ns / 1e9 if self._status.health != CaptureHealth.CONNECTED: self._status.health = CaptureHealth.CONNECTED return CapturedFrame(frame, sequence, host_mono_ns, host_utc_ns, media_time_ms) def read_raw(self) -> tuple[bool, np.ndarray | None]: """Compatibility read without capture metadata.""" captured = self.read_raw_stamped() return (captured is not None, None if captured is None else captured.image) def read_latest_stamped( self, *, copy: bool = True, wait_s: float = 0.0 ) -> CapturedFrame | None: """Return a frame and its own stamp atomically from the background slot.""" if not self._background_running: captured = self.read_raw_stamped() if captured is not None and copy: return CapturedFrame( captured.image.copy(), captured.sequence, captured.host_mono_ns, captured.host_utc_ns, captured.media_time_ms, ) return captured deadline = time.perf_counter() + max(0.0, wait_s) with self._frame_cond: while self._latest_captured is None: remaining = deadline - time.perf_counter() if remaining <= 0: return None self._frame_cond.wait(timeout=remaining) captured = self._latest_captured self._last_delivered_seq = captured.sequence if copy: return CapturedFrame( captured.image.copy(), captured.sequence, captured.host_mono_ns, captured.host_utc_ns, captured.media_time_ms, ) return captured def read_latest( self, *, copy: bool = True, wait_s: float = 0.0, ) -> tuple[bool, np.ndarray | None]: """Return the newest frame from the background thread, or read sync.""" captured = self.read_latest_stamped(copy=copy, wait_s=wait_s) return (captured is not None, None if captured is None else captured.image) def read(self, *, copy: bool = True, wait_s: float = 1.0) -> tuple[bool, np.ndarray | None]: """Compat alias used by finger runtime and older worker callers.""" wait = wait_s if wait_s > 0 else 1.0 return self.read_latest(copy=copy, wait_s=wait) def start_background(self) -> None: """Start a daemon thread that keeps only the latest frame.""" if self._thread is not None: return if not self.is_open(): self.open() self._background_running = True self._thread = threading.Thread( target=self._background_loop, daemon=True, name="orisys-capture", ) self._thread.start() if self.config.verbose: print("Capture background thread started.") def start(self) -> None: """Compat alias for ``start_background``.""" self.start_background() def stop_background(self, timeout: float = 1.0) -> None: self._background_running = False if self._thread is not None: self._thread.join(timeout=timeout) self._thread = None with self._frame_cond: self._latest_frame = None self._latest_captured = None def stop(self, timeout: float = 1.0) -> None: """Compat alias for ``stop_background``.""" self.stop_background(timeout=timeout) def _background_loop(self) -> None: while self._background_running: if self.should_reconnect(): if not self.reconnect(): time.sleep(0.05) if self.status.health == CaptureHealth.FAILED: time.sleep(0.2) continue captured = self.read_raw_stamped() if captured is None: if self.should_reconnect(): continue time.sleep(0.001) continue with self._frame_cond: if ( self._latest_captured is not None and self._latest_captured.sequence > self._last_delivered_seq ): with self._lock: self._status.dropped_frames += 1 self._latest_seq = captured.sequence self._latest_frame = captured.image self._latest_captured = captured with self._lock: self._status.last_seq = self._latest_seq self._frame_cond.notify_all() def note_published(self, seq: int) -> None: with self._lock: self._status.last_seq = int(seq) def note_dropped(self, n: int = 1) -> None: with self._lock: self._status.dropped_frames += int(n) def should_reconnect(self) -> bool: policy = self.config.reconnect if not policy.enabled: return False with self._lock: if self._status.health == CaptureHealth.CLOSED: return False if not self.is_open(): return True if self._status.consecutive_failures >= max(1, policy.fail_threshold): return True if policy.stale_timeout_s is not None and self._status.last_frame_time is not None: age = time.perf_counter() - self._status.last_frame_time if age >= policy.stale_timeout_s and self._status.health == CaptureHealth.CONNECTED: return True return False def reconnect(self) -> bool: """Attempt to reopen the capture. Returns True on success.""" policy: ReconnectPolicy = self.config.reconnect if not policy.enabled: return False if policy.max_attempts is not None and self._reconnect_attempt >= policy.max_attempts: with self._lock: self._status.health = CaptureHealth.FAILED self._status.last_error = "Reconnect attempts exhausted" return False with self._lock: self._status.health = CaptureHealth.RECONNECTING self._status.reconnect_attempts += 1 attempt = self._reconnect_attempt self._reconnect_attempt += 1 self._release_unlocked() delay = policy.next_backoff(attempt) if self.config.verbose: print( f"Reconnecting to {self.config.source!r} " f"(attempt {attempt + 1}, wait {delay:.2f}s)..." ) time.sleep(delay) try: self.open() return True except Exception as exc: with self._lock: self._status.last_error = str(exc) if policy.max_attempts is not None and self._reconnect_attempt >= policy.max_attempts: self._status.health = CaptureHealth.FAILED else: self._status.health = CaptureHealth.RECONNECTING return False # Backward-compatible alias for one release cycle. CaptureWorker = CaptureSource def open_capture( config: CaptureConfig, *, factory=None, start_background: bool | None = None, start_worker: bool | None = None, ) -> CaptureSource: """Open a capture source and optionally start its background thread. For live cameras, the background thread starts by default (``config.background``). File/video sources stay synchronous unless ``start_background=True``. """ source = CaptureSource(config, factory=factory) source.open() should_start = config.background if start_background is not None: should_start = start_background elif start_worker is not None: should_start = start_worker live = is_live_camera(normalize_source(config.source)) if should_start and live: source.start_background() return source