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
This commit is contained in:
@@ -6,14 +6,15 @@ Raspberry Pi 5.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ensure the v4l2 Python bindings are installed
|
# Install dependencies
|
||||||
sudo apt install -y python3-v4l2
|
sudo apt install -y python3-opencv python3-v4l2
|
||||||
|
|
||||||
# Run directly
|
# Run directly
|
||||||
python3 -m camera.app --host 0.0.0.0 --port 8080
|
python3 -m camera.app --host 0.0.0.0 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:8080 in a browser.
|
Open http://localhost:8080 in a browser. Faces visible in the camera view are marked
|
||||||
|
with green rectangles.
|
||||||
|
|
||||||
## Systemd service
|
## Systemd service
|
||||||
|
|
||||||
@@ -22,8 +23,8 @@ The app runs natively on the Pi via a systemd unit.
|
|||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install the v4l2 Python bindings
|
# Install dependencies
|
||||||
sudo apt install -y python3-v4l2
|
sudo apt install -y python3-opencv python3-v4l2
|
||||||
|
|
||||||
# Copy the service unit
|
# Copy the service unit
|
||||||
sudo cp camera-webui.service /etc/systemd/system/
|
sudo cp camera-webui.service /etc/systemd/system/
|
||||||
@@ -51,7 +52,7 @@ sudo systemctl daemon-reload
|
|||||||
```
|
```
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────┐
|
┌──────────────┐ ┌──────────────┐ ┌──────────┐
|
||||||
│ V4L2 mmap │───▶│ capture.py │───▶│ MJPEG │
|
│ V4L2 mmap │───▶│ capture.py │───▶│ MJPEG │
|
||||||
│ (V4L2 API) │ │ (frame loop)│ │ server │
|
│ (V4L2 API) │ │ + face det. │ │ server │
|
||||||
└──────────────┘ └──────────────┘ │ :8080 │
|
└──────────────┘ └──────────────┘ │ :8080 │
|
||||||
└──────────┘
|
└──────────┘
|
||||||
│
|
│
|
||||||
@@ -61,11 +62,11 @@ sudo systemctl daemon-reload
|
|||||||
└───────────┘
|
└───────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`capture.py`** opens `/dev/video0` via V4L2 mmap buffers, dequeues frames in a background
|
- **`capture.py`** opens `/dev/video0` via V4L2 mmap buffers, dequeues MJPEG frames in a
|
||||||
thread, and stores them in a shared variable.
|
background thread, runs face detection (haar cascades, downscaled for speed), draws
|
||||||
|
green rectangles, and stores the processed frame.
|
||||||
- **`mjpeg.py`** serves the MJPEG stream (`multipart/x-mixed-replace`) via Python's
|
- **`mjpeg.py`** serves the MJPEG stream (`multipart/x-mixed-replace`) via Python's
|
||||||
built-in `http.server` with threading. Each client gets its own thread, and only new frames
|
built-in `http.server` with threading. Only new frames are sent.
|
||||||
are sent.
|
|
||||||
- **`app.py`** is the entry point: argument parsing, signal handling, and server launch.
|
- **`app.py`** is the entry point: argument parsing, signal handling, and server launch.
|
||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
@@ -79,5 +80,6 @@ sudo systemctl daemon-reload
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `python3-v4l2` — V4L2 Python bindings (system package, `apt install python3-v4l2`)
|
- `python3-opencv` — OpenCV for face detection (haar cascades, JPEG encode/decode)
|
||||||
- No pip packages needed — the server uses Python's standard library only
|
- `python3-v4l2` — V4L2 Python bindings for camera access
|
||||||
|
- No pip packages needed
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ Wants=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=exec
|
Type=exec
|
||||||
User=mikkeli
|
User=mikkeli
|
||||||
|
Group=video
|
||||||
WorkingDirectory=/home/mikkeli/dev/usb-camera-webui
|
WorkingDirectory=/home/mikkeli/dev/usb-camera-webui
|
||||||
ExecStartPre=/usr/bin/python3 -c "import v4l2"
|
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
ExecStart=/usr/bin/python3 -m camera.app --host 0.0.0.0 --port 8080
|
ExecStart=/usr/bin/python3 -m camera.app --host 0.0.0.0 --port 8080
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
+103
-16
@@ -1,16 +1,30 @@
|
|||||||
"""V4L2 MJPEG capture via the v4l2 Python library (mmap).
|
"""V4L2 MJPEG capture with face detection via mmap buffers.
|
||||||
|
|
||||||
Uses memory-mapped buffers for efficient frame capture without subprocess
|
Opens /dev/video0 and uses memory-mapped V4L2 buffers for efficient frame
|
||||||
overhead. Each frame is a complete JPEG image ready for MJPEG streaming.
|
capture. Faces are detected on each frame using OpenCV haar cascades
|
||||||
|
(downscaled for speed) and drawn as rectangles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
import mmap
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
import v4l2
|
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
|
# Global camera singleton
|
||||||
_camera: "Capture | None" = None
|
_camera: "Capture | None" = None
|
||||||
_camera_lock = threading.Lock()
|
_camera_lock = threading.Lock()
|
||||||
@@ -22,26 +36,40 @@ def _ioctl(fd, request, arg):
|
|||||||
|
|
||||||
|
|
||||||
class Capture:
|
class Capture:
|
||||||
"""Captures MJPEG frames via V4L2 mmap buffers."""
|
"""Captures MJPEG frames with face detection via V4L2 mmap buffers."""
|
||||||
|
|
||||||
def __init__(self, device: str = "/dev/video0", width: int = 1280, height: int = 720) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
device: str = "/dev/video0",
|
||||||
|
width: int = 1280,
|
||||||
|
height: int = 720,
|
||||||
|
detect_faces: bool = True,
|
||||||
|
) -> None:
|
||||||
self._device = device
|
self._device = device
|
||||||
self._width = width
|
self._width = width
|
||||||
self._height = height
|
self._height = height
|
||||||
|
self._detect_faces = detect_faces
|
||||||
self._fd = -1
|
self._fd = -1
|
||||||
self._buffers: list[dict] = []
|
self._buffers: list[dict] = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._last_frame: bytes | None = None
|
self._last_frame: bytes | None = None
|
||||||
self._capture_thread: threading.Thread | None = None
|
self._capture_thread: threading.Thread | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._face_cascade: cv2.CascadeClassifier | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, device: str = "/dev/video0", width: int = 1280, height: int = 720) -> "Capture":
|
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."""
|
"""Return the global singleton, creating it if needed."""
|
||||||
global _camera
|
global _camera
|
||||||
with _camera_lock:
|
with _camera_lock:
|
||||||
if _camera is None:
|
if _camera is None:
|
||||||
_camera = cls(device, width, height)
|
_camera = cls(device, width, height, detect_faces)
|
||||||
return _camera
|
return _camera
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -49,6 +77,8 @@ class Capture:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
|
if self._detect_faces:
|
||||||
|
self._load_face_cascade()
|
||||||
self._open_device()
|
self._open_device()
|
||||||
self._setup_format()
|
self._setup_format()
|
||||||
self._allocate_buffers()
|
self._allocate_buffers()
|
||||||
@@ -68,6 +98,17 @@ class Capture:
|
|||||||
self._capture_thread.join(timeout=3)
|
self._capture_thread.join(timeout=3)
|
||||||
self._release()
|
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:
|
def _open_device(self) -> None:
|
||||||
self._fd = os.open(self._device, os.O_RDWR)
|
self._fd = os.open(self._device, os.O_RDWR)
|
||||||
cap = v4l2.v4l2_capability()
|
cap = v4l2.v4l2_capability()
|
||||||
@@ -79,10 +120,10 @@ class Capture:
|
|||||||
fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
fmt.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
||||||
fmt.fmt.pix.width = self._width
|
fmt.fmt.pix.width = self._width
|
||||||
fmt.fmt.pix.height = self._height
|
fmt.fmt.pix.height = self._height
|
||||||
fmt.fmt.pix.pixelformat = v4l2.v4l2_fourcc("MJPG")
|
fmt.fmt.pix.pixelformat = MJPG
|
||||||
fmt.fmt.pix.field = v4l2.V4L2_FIELD_ANY
|
fmt.fmt.pix.field = v4l2.V4L2_FIELD_ANY
|
||||||
_ioctl(self._fd, v4l2.VIDIOC_S_FMT, fmt)
|
_ioctl(self._fd, v4l2.VIDIOC_S_FMT, fmt)
|
||||||
print(f"Format set: {fmt.fmt.pix.width}x{fmt.fmt.pix.height} {v4l2.v4l2_fourcc2str(fmt.fmt.pix.pixelformat)}")
|
print(f"Format: {fmt.fmt.pix.width}x{fmt.fmt.pix.height} {v4l2.v4l2_fourcc2str(fmt.fmt.pix.pixelformat)}")
|
||||||
|
|
||||||
def _allocate_buffers(self) -> None:
|
def _allocate_buffers(self) -> None:
|
||||||
req = v4l2.v4l2_requestbuffers()
|
req = v4l2.v4l2_requestbuffers()
|
||||||
@@ -101,7 +142,7 @@ class Capture:
|
|||||||
|
|
||||||
length = buf.length
|
length = buf.length
|
||||||
offset = buf.m.offset
|
offset = buf.m.offset
|
||||||
data = os.mmap(0, length, os.PROT_READ, os.MAP_SHARED, self._fd, offset)
|
data = mmap.mmap(self._fd, length, mmap.MAP_SHARED, mmap.PROT_READ, 0, offset)
|
||||||
self._buffers.append({
|
self._buffers.append({
|
||||||
"index": i,
|
"index": i,
|
||||||
"length": length,
|
"length": length,
|
||||||
@@ -137,7 +178,7 @@ class Capture:
|
|||||||
self._fd = -1
|
self._fd = -1
|
||||||
|
|
||||||
def _capture_loop(self) -> None:
|
def _capture_loop(self) -> None:
|
||||||
"""Background loop: dequeue, copy, requeue buffers."""
|
"""Background loop: dequeue, detect faces, copy, requeue buffers."""
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
self._capture_one()
|
self._capture_one()
|
||||||
@@ -147,14 +188,21 @@ class Capture:
|
|||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
def _capture_one(self) -> None:
|
def _capture_one(self) -> None:
|
||||||
"""Dequeue one frame, copy it, and requeue the buffer."""
|
"""Dequeue one frame, detect faces, copy it, and requeue the buffer."""
|
||||||
buf = v4l2.v4l2_buffer()
|
buf = v4l2.v4l2_buffer()
|
||||||
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
||||||
buf.memory = v4l2.V4L2_MEMORY_MMAP
|
buf.memory = v4l2.V4L2_MEMORY_MMAP
|
||||||
_ioctl(self._fd, v4l2.VIDIOC_DQBUF, buf)
|
_ioctl(self._fd, v4l2.VIDIOC_DQBUF, buf)
|
||||||
|
|
||||||
info = self._buffers[buf.index]
|
info = self._buffers[buf.index]
|
||||||
frame_data = bytes(info["mmap"][:buf.bytesused])
|
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
|
self._last_frame = frame_data
|
||||||
|
|
||||||
# Requeue buffer
|
# Requeue buffer
|
||||||
@@ -163,11 +211,50 @@ class Capture:
|
|||||||
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
buf.type = v4l2.V4L2_BUF_TYPE_VIDEO_CAPTURE
|
||||||
_ioctl(self._fd, v4l2.VIDIOC_QBUF, buf)
|
_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:
|
def frame(self) -> bytes | None:
|
||||||
"""Return the most recently captured frame."""
|
"""Return the most recently captured frame (with face boxes if enabled)."""
|
||||||
return self._last_frame
|
return self._last_frame
|
||||||
|
|
||||||
|
|
||||||
def get_camera(device: str = "/dev/video0", width: int = 1280, height: int = 720) -> Capture:
|
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."""
|
"""Convenience accessor for the global camera singleton."""
|
||||||
return Capture.get(device, width, height)
|
return Capture.get(device, width, height, detect_faces)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user