from __future__ import annotations import sys import threading import time import cv2 import numpy as np class CameraStream: """Threaded camera frame grabber.""" def __init__(self, index: int = 0, width: int = 1280, height: int = 720, fps: int = 60) -> None: backend = cv2.CAP_AVFOUNDATION if sys.platform == "darwin" else cv2.CAP_ANY cap = cv2.VideoCapture(index, backend) if not cap.isOpened(): cap = cv2.VideoCapture(index) if not cap.isOpened(): raise RuntimeError( f"Could not open camera {index}. " "On macOS grant camera access in System Settings, Privacy and " "Security, Camera, then restart." ) cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) cap.set(cv2.CAP_PROP_FPS, fps) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.cap = cap ok, frame = cap.read() if not ok or frame is None: cap.release() raise RuntimeError("Camera opened but returns no frames.") self._frame: np.ndarray = frame self._seq = 0 self._lock = threading.Lock() self._stop = threading.Event() self.failed = False self._thread = threading.Thread(target=self._loop, daemon=True) self._thread.start() @property def shape(self) -> tuple[int, int]: """Frame height and width.""" return self._frame.shape[0], self._frame.shape[1] @property def source_fps(self) -> float: """Camera reported FPS.""" v = self.cap.get(cv2.CAP_PROP_FPS) return float(v) if v and v > 0 else 0.0 def _loop(self) -> None: """Background capture loop.""" while not self._stop.is_set(): ok, frame = self.cap.read() if not ok or frame is None: self.failed = True break with self._lock: self._frame = frame self._seq += 1 def read(self) -> tuple[np.ndarray, int]: """Latest frame and sequence.""" with self._lock: return self._frame, self._seq def wait_next(self, last_seq: int, timeout: float = 1.0) -> tuple[np.ndarray, int]: """Wait for fresh frame.""" deadline = time.perf_counter() + timeout while True: frame, seq = self.read() if seq != last_seq or self.failed or time.perf_counter() > deadline: return frame, seq time.sleep(0.001) def release(self) -> None: """Stop thread and release.""" self._stop.set() self._thread.join(timeout=1.0) self.cap.release()