"""V4L2 MJPEG capture with face detection via mmap buffers. Opens /dev/video0 and uses memory-mapped V4L2 buffers for efficient frame capture. Faces are detected on each frame using OpenCV haar cascades (downscaled for speed) and drawn as rectangles. """ import fcntl import os import time import threading import mmap import cv2 import numpy as np import v4l2 # Fourcc for Motion JPEG MJPG = v4l2.v4l2_fourcc("M", "J", "P", "G") # Face detection scale — detects at this fraction of original resolution # to keep CPU usage low. 0.3 ≈ 384x216 for 1280x720 input → ~150 FPS detection. FACE_DETECT_SCALE = 0.3 FACE_MIN_NEIGHBORS = 4 FACE_MIN_SIZE = 20 # Global camera singleton _camera: "Capture | None" = None _camera_lock = threading.Lock() def _ioctl(fd, request, arg): """Wrapper for fcntl.ioctl with v4l2 structs.""" return fcntl.ioctl(fd, request, arg) class Capture: """Captures MJPEG frames with face detection via V4L2 mmap buffers.""" def __init__( self, device: str = "/dev/video0", width: int = 1280, height: int = 720, detect_faces: bool = True, ) -> None: self._device = device self._width = width self._height = height self._detect_faces = detect_faces self._fd = -1 self._buffers: list[dict] = [] self._lock = threading.Lock() self._last_frame: bytes | None = None self._capture_thread: threading.Thread | None = None self._running = False self._face_cascade: cv2.CascadeClassifier | None = None @classmethod def get( cls, device: str = "/dev/video0", width: int = 1280, height: int = 720, detect_faces: bool = True, ) -> "Capture": """Return the global singleton, creating it if needed.""" global _camera with _camera_lock: if _camera is None: _camera = cls(device, width, height, detect_faces) return _camera def start(self) -> None: """Open the device, set up buffers, and begin capturing.""" with self._lock: if self._running: return if self._detect_faces: self._load_face_cascade() self._open_device() self._setup_format() self._allocate_buffers() self._queue_buffers() self._start_streaming() self._running = True self._capture_thread = threading.Thread( target=self._capture_loop, daemon=True, name="camera-capture" ) self._capture_thread.start() def stop(self) -> None: """Stop streaming and release resources.""" with self._lock: self._running = False if self._capture_thread: self._capture_thread.join(timeout=3) self._release() def _load_face_cascade(self) -> None: """Load the haar cascade XML for face detection.""" cascade_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "haarcascades", "haarcascade_frontalface_default.xml", ) self._face_cascade = cv2.CascadeClassifier() self._face_cascade.load(cascade_path) print("Face cascade loaded.") def _open_device(self) -> None: self._fd = os.open(self._device, os.O_RDWR) cap = v4l2.v4l2_capability() _ioctl(self._fd, v4l2.VIDIOC_QUERYCAP, cap) print(f"Camera: {cap.card.decode('utf-8', errors='replace')[:40]}") def _setup_format(self) -> None: fmt = v4l2.v4l2_format() fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE fmt.fmt.pix.width = self._width fmt.fmt.pix.height = self._height fmt.fmt.pix.pixelformat = MJPG fmt.fmt.pix.field = v4l2.V4L2_FIELD_ANY _ioctl(self._fd, v4l2.VIDIOC_S_FMT, fmt) print(f"Format: {fmt.fmt.pix.width}x{fmt.fmt.pix.height} {v4l2.v4l2_fourcc2str(fmt.fmt.pix.pixelformat)}") def _allocate_buffers(self) -> None: req = v4l2.v4l2_requestbuffers() req.count = 4 req.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE req.memory = v4l2.V4L2_MEMORY_MMAP _ioctl(self._fd, v4l2.VIDIOC_REQBUFS, req) self._buffers = [] for i in range(req.count): buf = v4l2.v4l2_buffer() buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE buf.memory = v4l2.V4L2_MEMORY_MMAP buf.index = i _ioctl(self._fd, v4l2.VIDIOC_QUERYBUF, buf) length = buf.length offset = buf.m.offset data = mmap.mmap(self._fd, length, mmap.MAP_SHARED, mmap.PROT_READ, 0, offset) self._buffers.append({ "index": i, "length": length, "mmap": data, }) def _queue_buffers(self) -> None: for info in self._buffers: buf = v4l2.v4l2_buffer() buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE buf.memory = v4l2.V4L2_MEMORY_MMAP buf.index = info["index"] _ioctl(self._fd, v4l2.VIDIOC_QBUF, buf) def _start_streaming(self) -> None: typ = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) _ioctl(self._fd, v4l2.VIDIOC_STREAMON, typ) def _stop_streaming(self) -> None: typ = v4l2.v4l2_buf_type(v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE) try: _ioctl(self._fd, v4l2.VIDIOC_STREAMOFF, typ) except Exception: pass def _release(self) -> None: self._stop_streaming() for info in self._buffers: info["mmap"].close() self._buffers = [] if self._fd >= 0: os.close(self._fd) self._fd = -1 def _capture_loop(self) -> None: """Background loop: dequeue, detect faces, copy, requeue buffers.""" while self._running: try: self._capture_one() except BlockingIOError: time.sleep(0.001) except Exception: time.sleep(0.01) def _capture_one(self) -> None: """Dequeue one frame, detect faces, copy it, and requeue the buffer.""" buf = v4l2.v4l2_buffer() buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE buf.memory = v4l2.V4L2_MEMORY_MMAP _ioctl(self._fd, v4l2.VIDIOC_DQBUF, buf) info = self._buffers[buf.index] raw_data = bytes(info["mmap"][:buf.bytesused]) # Process frame with face detection if enabled if self._detect_faces and self._face_cascade is not None: frame_data = self._draw_faces(raw_data) else: frame_data = raw_data self._last_frame = frame_data # Requeue buffer buf.index = info["index"] buf.memory = v4l2.V4L2_MEMORY_MMAP buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE _ioctl(self._fd, v4l2.VIDIOC_QBUF, buf) def _draw_faces(self, jpeg_bytes: bytes) -> bytes: """Decode JPEG, detect faces, draw rectangles, re-encode.""" # Decode the JPEG frame from V4L2 frame = cv2.imdecode(np.frombuffer(jpeg_bytes, dtype=np.uint8), cv2.IMREAD_COLOR) if frame is None: return jpeg_bytes h, w = frame.shape[:2] # Scale down for fast detection sw, sh = int(w * FACE_DETECT_SCALE), int(h * FACE_DETECT_SCALE) small = cv2.resize(frame, (sw, sh), interpolation=cv2.INTER_AREA) gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY) # Detect faces faces = self._face_cascade.detectMultiScale( gray, scaleFactor=1.1, minNeighbors=FACE_MIN_NEIGHBORS, minSize=(int(FACE_MIN_SIZE / FACE_DETECT_SCALE), int(FACE_MIN_SIZE / FACE_DETECT_SCALE)), ) # Draw rectangles at original scale for (x, y, fw, fh) in faces: x_orig = int(x / FACE_DETECT_SCALE) y_orig = int(y / FACE_DETECT_SCALE) fw_orig = int(fw / FACE_DETECT_SCALE) fh_orig = int(fh / FACE_DETECT_SCALE) cv2.rectangle(frame, (x_orig, y_orig), (x_orig + fw_orig, y_orig + fh_orig), (0, 255, 0), 2) # Re-encode as JPEG (quality 85 is a good balance) _, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) return bytes(encoded.tobytes()) def frame(self) -> bytes | None: """Return the most recently captured frame (with face boxes if enabled).""" return self._last_frame def get_camera( device: str = "/dev/video0", width: int = 1280, height: int = 720, detect_faces: bool = True, ) -> Capture: """Convenience accessor for the global camera singleton.""" return Capture.get(device, width, height, detect_faces)