Files
usb-camera-webui/camera/capture.py
T
mikkeli 0f423fccd6 Add face detection to camera capture
- capture.py: detect faces with haar cascades (cv2.CascadeClassifier),
  draw green rectangles on frames before streaming
- capture/haarcascades/: bundled haar cascade XML
- README.md: document face detection feature and opencv dependency
2026-08-06 10:24:49 +09:00

261 lines
8.6 KiB
Python

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