97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
"""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()
|