"""USB UVC 소스 (실장비). v4l2 MJPEG. cv2.VideoCapture, 2개 USB3 컨트롤러 분산.""" import cv2 class UvcSource: def __init__(self, dev, mjpeg: bool = True, *, reopen_after: int = 30, size: tuple[int, int] = (640, 480), _open=None): # _open lets tests inject a fake capture; production uses cv2.VideoCapture. self._dev = dev self._mjpeg = mjpeg self._reopen_after = reopen_after # Pin the capture size to the CSI default. Left unset, a UVC cam opens at ITS default — # a 1080p webcam yields 6.2MB frames, and the 90-frame evidence ring then costs ~560MB # per camera (vs ~80MB at 640x480), which is an OOM on a 4-camera Pi. Memory must be a # property of OUR config, not of whichever webcam the site happened to plug in. self._size = size self._open = _open or (lambda d: cv2.VideoCapture(d)) self._fail_streak = 0 self.cap = self._open_cap() def _open_cap(self): cap = self._open(self._dev) # Fail LOUD: a device that never opens must not become a silently-dead capture that # returns (False, None) forever while the daemon looks healthy. build_runtimes catches # this and logs+skips the camera. if not cap.isOpened(): raise RuntimeError(f"UVC device {self._dev!r} failed to open") if self._mjpeg: cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG")) w, h = self._size cap.set(cv2.CAP_PROP_FRAME_WIDTH, w) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h) # Ask v4l2 to keep the shallowest queue it will honour. When the loop runs slower than # the camera's fps, a deep driver queue hands back frames from seconds ago — a fall # would be detected long after it happened. Not all backends honour this; harmless if # ignored (the drain-only tick in PipelineRuntime covers the rest). cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) return cap def read(self): ok, frame = self.cap.read() if ok: self._fail_streak = 0 return ok, frame # A USB cam that browns out / is unplugged returns (False, None) indefinitely. After a # streak, release and reopen so a recovered device resumes on its own rather than the # camera staying dead until someone restarts the service. self._fail_streak += 1 if self._fail_streak >= self._reopen_after: self._fail_streak = 0 try: self.cap.release() except Exception: pass try: self.cap = self._open_cap() except Exception: pass # still dead — the daemon's error/heartbeat path surfaces it return False, None