From ab7664cd513fa99153596c00784030be49ca83d2 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 10:41:10 -0700 Subject: [PATCH] feat: add OpenCV camera source wrapper --- bookmark/camera.py | 34 +++++++++++++++++++++++++++++ tests/test_camera.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 bookmark/camera.py create mode 100644 tests/test_camera.py diff --git a/bookmark/camera.py b/bookmark/camera.py new file mode 100644 index 0000000..fcc0415 --- /dev/null +++ b/bookmark/camera.py @@ -0,0 +1,34 @@ +from typing import Protocol + +import cv2 +import numpy as np + + +class FrameSource(Protocol): + def read_frame(self) -> np.ndarray: ... + + +class CameraSource: + """Thin wrapper around cv2.VideoCapture for a single physical camera.""" + + def __init__(self, device_index: int = 0) -> None: + self.device_index = device_index + self._capture: cv2.VideoCapture | None = None + + def open(self) -> None: + self._capture = cv2.VideoCapture(self.device_index) + if not self._capture.isOpened(): + raise RuntimeError(f"Could not open camera at index {self.device_index}") + + def read_frame(self) -> np.ndarray: + if self._capture is None: + raise RuntimeError("Camera not opened - call open() first") + ok, frame = self._capture.read() + if not ok: + raise RuntimeError("Failed to read frame from camera") + return frame + + def close(self) -> None: + if self._capture is not None: + self._capture.release() + self._capture = None diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..13f2d06 --- /dev/null +++ b/tests/test_camera.py @@ -0,0 +1,51 @@ +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from bookmark.camera import CameraSource + + +def test_open_raises_when_capture_not_opened(): + with patch("bookmark.camera.cv2.VideoCapture") as mock_video_capture: + mock_instance = MagicMock() + mock_instance.isOpened.return_value = False + mock_video_capture.return_value = mock_instance + + source = CameraSource(device_index=0) + with pytest.raises(RuntimeError, match="Could not open camera"): + source.open() + + +def test_read_frame_returns_frame_from_capture(): + with patch("bookmark.camera.cv2.VideoCapture") as mock_video_capture: + mock_instance = MagicMock() + mock_instance.isOpened.return_value = True + fake_frame = np.zeros((10, 10, 3), dtype=np.uint8) + mock_instance.read.return_value = (True, fake_frame) + mock_video_capture.return_value = mock_instance + + source = CameraSource(device_index=0) + source.open() + frame = source.read_frame() + + assert frame.shape == (10, 10, 3) + + +def test_read_frame_raises_when_capture_fails(): + with patch("bookmark.camera.cv2.VideoCapture") as mock_video_capture: + mock_instance = MagicMock() + mock_instance.isOpened.return_value = True + mock_instance.read.return_value = (False, None) + mock_video_capture.return_value = mock_instance + + source = CameraSource(device_index=0) + source.open() + with pytest.raises(RuntimeError, match="Failed to read frame"): + source.read_frame() + + +def test_read_frame_raises_if_not_opened(): + source = CameraSource(device_index=0) + with pytest.raises(RuntimeError, match="not opened"): + source.read_frame()