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 parameterEffectStarting value
scaleFactorHow much the window grows each pass; smaller is more thorough but slower1.1 to 1.3
minNeighborsHow many overlapping detections are needed to accept a face; higher means fewer false positives5
minSizeSmallest 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.

PartConnection
Camera Module 2 or 3CSI camera connector
LED anode via 330 ohm resistorGPIO17, physical pin 11
LED cathodeGND, 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.

face_stream.py - Haar cascade face detection, GPIO LED and MJPEG web stream
#!/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 scaleFactor and 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.

ChangeSpeedAccuracy effect
Lower resolution (320 x 240)FasterDistant faces may be missed
scaleFactor 1.1 to 1.3FasterSome face sizes skipped between passes
Larger minSizeFasterSmall or distant faces ignored
minNeighbors 3 to 7SimilarFewer false positives, slightly more misses
Detect on every second frameFasterBoxes 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 with find / -name 'haarcascade_frontalface*' 2>/dev/null.
  • Many false boxes on walls or curtains: raise minNeighbors to 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 -I and 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.