Real-Time Face Detection on Raspberry Pi 4 with OpenCV and Picamera2
Face detection answers one question: is there a face in this image, and where? It is the first step for attendance systems, smart doorbells, people counters and robots that turn to look at you. It is different from face recognition, which identifies whose face it is and needs a further model on top of detection.
The Raspberry Pi 4 is fast enough to run the classic Haar cascade detector from OpenCV in real time at modest resolution. In this tutorial you will capture frames with Picamera2, detect faces, draw boxes around them, light an LED on a GPIO pin while a face is visible, and stream the annotated video to any browser on your network so the Pi can run headless.
How Haar Cascade Detection Works
A Haar cascade is a pre-trained classifier stored as an XML file. It scans the image with a sliding window at many sizes, and at each position evaluates simple rectangular brightness patterns, such as the eye region being darker than the cheeks. The checks are arranged as a cascade of stages: cheap tests reject most windows immediately, and only promising windows reach the more expensive later stages. This is why it runs well on a CPU without a GPU or neural accelerator.
| detectMultiScale parameter | Effect | Starting value |
|---|---|---|
| scaleFactor | How much the window grows each pass; smaller is more thorough but slower | 1.1 to 1.3 |
| minNeighbors | How many overlapping detections are needed to accept a face; higher means fewer false positives | 5 |
| minSize | Smallest face size in pixels to look for; larger is faster | (60, 60) |
Haar cascades work best on frontal, evenly lit faces. Side profiles, strong backlight and masks reduce accuracy. For harder conditions, OpenCV's DNN-based FaceDetectorYN (YuNet) is more robust, at a higher CPU cost.
Setup and Wiring
sudo apt install -y python3-picamera2 python3-opencv opencv-data ls /usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml
The opencv-data package provides the cascade XML files. The apt build of OpenCV does not include the cv2.data helper that pip builds have, so the code uses the full path shown above.
| Part | Connection |
|---|---|
| Camera Module 2 or 3 | CSI camera connector |
| LED anode via 330 ohm resistor | GPIO17, physical pin 11 |
| LED cathode | GND, physical pin 9 |
Face Detector with Browser Stream
The program runs two threads: one captures and analyses frames, and a small HTTP server built from Python's standard library sends the latest annotated JPEG as an MJPEG stream. Open http://<pi-ip>:8000/ in a browser to watch it.
#!/usr/bin/env python3
# face_stream.py - live face detection on a Raspberry Pi 4
# View at http://<pi-ip>:8000/ Stop with Ctrl+C
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cv2
from gpiozero import LED
from picamera2 import Picamera2
CASCADE = '/usr/share/opencv4/haarcascades/haarcascade_frontalface_default.xml'
detector = cv2.CascadeClassifier(CASCADE)
if detector.empty():
raise SystemExit('Cascade file not found - install opencv-data')
led = LED(17)
latest_jpeg = None # most recent annotated frame as JPEG bytes
lock = threading.Lock()
def capture_loop():
global latest_jpeg
picam2 = Picamera2()
picam2.configure(picam2.create_video_configuration(
main={'size': (640, 480), 'format': 'RGB888'})) # BGR order for OpenCV
picam2.start()
frames, t0 = 0, time.time()
while True:
frame = picam2.capture_array()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.equalizeHist(gray) # improve contrast in poor light
faces = detector.detectMultiScale(gray, scaleFactor=1.2,
minNeighbors=5, minSize=(60, 60))
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
# LED on while at least one face is visible
led.value = 1 if len(faces) else 0
# Simple frames-per-second counter, updated every 30 frames
frames += 1
if frames == 30:
fps = 30 / (time.time() - t0)
print(f'{fps:.1f} fps, faces: {len(faces)}')
frames, t0 = 0, time.time()
ok, jpg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
if ok:
with lock:
latest_jpeg = jpg.tobytes()
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != '/':
self.send_error(404)
return
# multipart/x-mixed-replace makes the browser replace each image
self.send_response(200)
self.send_header('Content-Type',
'multipart/x-mixed-replace; boundary=frame')
self.end_headers()
try:
while True:
with lock:
jpg = latest_jpeg
if jpg:
self.wfile.write(b'--frame\r\n')
self.wfile.write(b'Content-Type: image/jpeg\r\n\r\n')
self.wfile.write(jpg + b'\r\n')
time.sleep(0.05)
except (BrokenPipeError, ConnectionResetError):
pass # browser tab closed
def log_message(self, *args):
pass # keep the console quiet
if __name__ == '__main__':
threading.Thread(target=capture_loop, daemon=True).start()
server = ThreadingHTTPServer(('0.0.0.0', 8000), StreamHandler)
print('Streaming on port 8000')
try:
server.serve_forever()
except KeyboardInterrupt:
led.off()
Notes on the Code
- equalizeHist spreads the grey levels across the full range. It noticeably improves detection indoors under tube lights or in dim rooms.
- The detector runs on a grey image because Haar features only use brightness. Colour adds work without adding accuracy.
- minSize=(60, 60) skips tiny windows. At 640 x 480 this suits faces within a few metres; reduce it to detect people further away.
- The capture thread and the web server share only
latest_jpeg, protected by a lock, so a slow browser never slows the detector. - The printed frame rate tells you the real performance on your board. If it is low, try 320 x 240 or a larger
scaleFactorand compare.
Tuning for Speed and Accuracy
Detection time grows with the number of window positions and sizes checked, so every parameter is a trade-off. Change one value at a time, watch the printed frame rate, and note how many real faces are found and how many false boxes appear. A simple test is to stand at the distances your project needs, such as one metre for a doorbell or three metres for a classroom, and record the results in a small table.
| Change | Speed | Accuracy effect |
|---|---|---|
| Lower resolution (320 x 240) | Faster | Distant faces may be missed |
| scaleFactor 1.1 to 1.3 | Faster | Some face sizes skipped between passes |
| Larger minSize | Faster | Small or distant faces ignored |
| minNeighbors 3 to 7 | Similar | Fewer false positives, slightly more misses |
| Detect on every second frame | Faster | Boxes update less smoothly |
Another useful trick is to detect on a downscaled grey copy of the frame and multiply the box coordinates by the scale factor before drawing on the full-size image. You keep a sharp stream for viewing while the detector works on far fewer pixels.
Troubleshooting
- Cascade file not found: install
opencv-data, or search for it withfind / -name 'haarcascade_frontalface*' 2>/dev/null. - Many false boxes on walls or curtains: raise
minNeighborsto 6 or 7. - Faces missed when tilted: Haar frontal cascades tolerate only small rotation. Use YuNet if this matters for your project.
- Browser shows nothing: confirm the Pi's IP with
hostname -Iand that nothing else uses port 8000. - Low frame rate: check
vcgencmd get_throttled. A hot, throttled Pi 4 can lose a large part of its speed during sustained vision work.
Summary
Haar cascades give the Pi 4 practical real-time face detection on the CPU. Capture with Picamera2 in RGB888, convert to grey, equalise, and call detectMultiScale with tuned scaleFactor, minNeighbors and minSize. Adding a GPIO output and a standard-library MJPEG server turns the detector into a headless device you can monitor from a phone, and a good base for recognition or people-counting projects.