Files
SignageSystem/src/camera.py
T
2026-10-02 22:56:17 +09:00

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