"""OpenCV camera and video input abstraction.""" import logging from pathlib import Path import cv2 import numpy as np from .config_loader import InputSettings from .exceptions import CameraOpenError, FrameReadError logger = logging.getLogger(__name__) class Camera: """Read frames from a camera device or video file.""" def __init__(self, config: InputSettings) -> None: self.config = config self._capture: cv2.VideoCapture | None = None self._consecutive_failures = 0 def open(self) -> None: backend_map = {"auto": cv2.CAP_ANY, "dshow": cv2.CAP_DSHOW, "msmf": cv2.CAP_MSMF, "v4l2": cv2.CAP_V4L2} source: int | str if self.config.type == "camera": source = self.config.camera_id else: path = Path(self.config.video_path or "") if not path.is_file(): raise CameraOpenError(f"Video file does not exist: {path}") source = str(path) self._capture = cv2.VideoCapture(source, backend_map[self.config.backend]) if not self._capture.isOpened(): self.release() raise CameraOpenError(f"Could not open {self.config.type} input: {source}") if self.config.type == "camera": # The pixel format must be set before the resolution: many webcams only offer # Full HD at a usable frame rate in MJPG, and fall back to a lower mode otherwise. if self.config.fourcc: self._capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*self.config.fourcc)) self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width) self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height) self._capture.set(cv2.CAP_PROP_FPS, self.config.fps) actual_w, actual_h = self.get_actual_width(), self.get_actual_height() logger.info( "Camera opened: requested %dx%d@%.1f fourcc=%s, actual %dx%d@%.1f", self.config.width, self.config.height, self.config.fps, self.config.fourcc or "default", actual_w, actual_h, self.get_actual_fps(), ) if (actual_w, actual_h) != (self.config.width, self.config.height): logger.warning( "Camera resolution differs from the request: requested %dx%d, got %dx%d", self.config.width, self.config.height, actual_w, actual_h, ) def read(self) -> np.ndarray | None: if self._capture is None or not self._capture.isOpened(): raise CameraOpenError("Input is not open") ok, frame = self._capture.read() if ok and frame is not None: self._consecutive_failures = 0 return frame if self.config.type == "video": return None self._consecutive_failures += 1 if self._consecutive_failures >= self.config.max_consecutive_read_failures: raise FrameReadError(f"Camera read failed {self._consecutive_failures} consecutive times") return None def is_opened(self) -> bool: return self._capture is not None and self._capture.isOpened() def _property(self, property_id: int) -> float: return float(self._capture.get(property_id)) if self._capture is not None else 0.0 def get_actual_width(self) -> int: return round(self._property(cv2.CAP_PROP_FRAME_WIDTH)) def get_actual_height(self) -> int: return round(self._property(cv2.CAP_PROP_FRAME_HEIGHT)) def get_actual_fps(self) -> float: return self._property(cv2.CAP_PROP_FPS) def release(self) -> None: if self._capture is not None: self._capture.release() self._capture = None def __enter__(self) -> "Camera": self.open() return self def __exit__(self, *_args: object) -> None: self.release()