Where we are in the build
In part 1 you set the ground rules for this project: tell visitors a camera is in use, only keep a face once someone agrees, and never point the camera at more of the street than you need to. In part 2 you chose the hardware: a Raspberry Pi 5 with 8 GB of RAM, an AI HAT+ or a Jetson Orin Nano as the alternative, a Camera Module 3 (the NoIR variant plus an IR illuminator for night use, which we will wire up properly in part 10), a small speaker, a PIR motion sensor, and an enclosure. Part 3 explained the theory behind face recognition in four stages: detection finds a face in an image, alignment straightens it, an embedding model turns it into a list of numbers, and matching compares those numbers against the people you already know. Part 4 got the Pi itself ready: Raspberry Pi OS, Python 3.11 or later, a virtual environment, picamera2 and OpenCV installed, and a first test photo taken with the camera.
This part builds the first stage from that theory, detection, and makes it run continuously against a live camera feed rather than a single still photo. By the end you will have a script that watches the camera, notices when the PIR sensor detects someone approaching, draws a box around any face it finds, saves a cropped image of that face to disk, and writes a short record of the event to a local database. There is no recognition yet. The script will not know who the person is, only that a face is present. That matters for how you should treat what it produces: a folder of face photos and a log of visits is already something to be careful with, even before any name is attached to it. We will come back to that in the responsible design section near the end.
What "real time" means for a doorstep camera
It is worth being precise about what "real time" needs to mean here, because it is easy to over-engineer this step. A security system watching a motorway for number plates might need tens of frames per second and very low latency. A doorstep camera does not. A visitor typically stands at your door for several seconds while they wait, ring the bell, or look for a letterbox. If your system notices their face within a second or two of them arriving, that is entirely real time for this purpose. There is no benefit in chasing a very high frame rate if nobody is moving fast enough to need it.
The more useful idea for this project is duty cycle: most of the time, nobody is at the door at all. A camera that runs full face detection on every frame, all day, burns CPU, drains power unnecessarily, and produces far more images than you will ever need, most of them showing an empty porch. The PIR sensor you chose in part 2 solves this neatly. It is a cheap, low-power component that detects movement from body heat, and it can sit there watching 24 hours a day for almost no energy cost. The plan for this part is therefore not "run the camera and the detector constantly", but "let the PIR sensor decide when it is worth waking the camera up, then run the detector only during that window". This is both more efficient and, as we will discuss later, friendlier to the privacy of people who simply walk past your house.
Choosing a face detector for the Pi
Detection and recognition are different jobs, and it helps to keep them separate in your head as you write code. A detector answers one question: is there a face in this image, and roughly where? It does not know whose face it is. Recognition, which we build in part 6, takes a detected face and turns it into a numerical fingerprint that can be compared against people you already know. For this part we only need a detector, and we want one that runs comfortably on the Pi 5's CPU without needing the AI HAT+ or any large download, so you can get something working today and improve on it later.
Haar cascades: the classic, dependency-free option
OpenCV ships with a face detector that has been around since the early 2000s, based on a method called Viola-Jones detection. It works by scanning an image with a cascade of simple rectangular feature tests, each one cheap to compute, arranged so that most non-face regions are rejected almost immediately and only promising regions go through the full cascade. The trained models for this are plain XML files, and opencv-python includes a set of them already, so there is nothing to download. You can find their location with cv2.data.haarcascades, which points at a folder containing files such as haarcascade_frontalface_default.xml.
The appeal here is convenience: it works offline, out of the box, the moment you install opencv-python, and it is fast enough to run several times a second on a Raspberry Pi. The cost is accuracy. Haar cascades were trained mostly on front-facing, well-lit faces, so they struggle more than modern detectors with faces turned to the side, faces partly in shadow, or faces at unusual angles, and they produce more false positives on textured backgrounds, such as a patterned doormat or a brick wall catching the light in a particular way. For a first working version of the pipeline, that trade-off is fine. We will tune it further in part 10, which deals specifically with difficult lighting.
A DNN-based detector: more accurate, a bit more setup
OpenCV's dnn module can also run a small neural network trained specifically for face detection, commonly distributed as a pair of files: a prototxt file describing the network structure, and a caffemodel file containing its trained weights. This model, often referred to by its architecture as an SSD (single shot detector) built on a ResNet-10 backbone, generally copes better than Haar cascades with faces at a slight angle and with dimmer lighting, at the cost of a little more computation per frame and a one-off download of a few megabytes. We will use it as a second option later in this part, and compare it directly against the Haar cascade on your own camera.
There are other detectors worth knowing about, even though we will not use them in this part. MediaPipe has a face detection model that many hobby projects use. The library we adopt for recognition in part 6, commonly InsightFace or a similarly maintained package, bundles its own detector (often one called SCRFD or RetinaFace) as part of the same pipeline that produces embeddings, which is convenient once you get there because you do not need to run two separate models. For now, keeping detection on its own with OpenCV lets you see exactly what is happening at each step, which is the point of this part of the build.
Setting up the working folder
Open a terminal on the Pi, or connect over SSH as you did in part 4, and go back into the project folder you created then. Activate the virtual environment, and add a couple of subfolders we will use in this part: one for face crops saved during a detection session, and one for the model files the DNN detector needs.
cd ~/door-greeter
source .venv/bin/activate
mkdir -p captures models
pip show picamera2 opencv-python numpy | grep -E "Name|Version"
pip install gpiozero
sudo apt update
sudo apt install -y python3-lgpio
The pip show command confirms the versions of picamera2, opencv-python and numpy you installed in part 4; it is worth noting these down, since behaviour can shift slightly between versions of these libraries and it helps to know what you were running if something looks different later. gpiozero is the library we will use to read the PIR sensor, and python3-lgpio is the low-level GPIO backend it needs on a Raspberry Pi 5, which we will say more about when we wire up the sensor.
Step 1: confirm the camera still works
Before building anything new, it is worth a thirty-second sanity check that the camera is still working the way you left it in part 4, especially if you have moved the Pi or changed the ribbon cable since then.
from picamera2 import Picamera2
import time
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480)})
picam2.configure(config)
picam2.start()
time.sleep(2)
picam2.capture_file("test.jpg")
picam2.stop()
print("saved test.jpg - open it to check focus and framing")
Copy test.jpg off the Pi (for example with scp, or by opening it through a file manager over the network) and look at it. Check that the doorway area is in frame, that it is in focus, and that the two-second pause before capture was enough for the sensor's automatic exposure and white balance to settle into something reasonable. If the image looks washed out or too dark, increase or decrease that sleep slightly and try again; we will return to exposure in more detail in part 10.
Step 2: detect faces in a live feed with Haar cascades
Now for the first real piece of this part: a script that reads frames from the camera continuously, runs the Haar cascade detector on each one, draws a box around any face it finds, and reports how fast it is actually running on your Pi.
import os
import time
import cv2
from picamera2 import Picamera2
FACE_CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
SNAPSHOT_DIR = "snapshots"
os.makedirs(SNAPSHOT_DIR, exist_ok=True)
def main():
face_cascade = cv2.CascadeClassifier(FACE_CASCADE_PATH)
if face_cascade.empty():
raise RuntimeError("Could not load Haar cascade file: check your OpenCV install")
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"})
picam2.configure(config)
picam2.start()
time.sleep(2) # let auto-exposure and white balance settle
have_display = bool(os.environ.get("DISPLAY"))
frame_count = 0
start_time = time.time()
last_fps_print = start_time
try:
while True:
frame = picam2.capture_array() # RGB888, shape (480, 640, 3)
gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
gray = cv2.equalizeHist(gray)
faces = face_cascade.detectMultiScale(
gray,
scaleFactor=1.1,
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)
frame_count += 1
now = time.time()
if now - last_fps_print >= 2.0:
fps = frame_count / (now - start_time)
print(f"faces seen: {len(faces)} running fps: {fps:.1f}")
last_fps_print = now
if have_display:
cv2.imshow("door camera (press q to quit)", cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))
if cv2.waitKey(1) & 0xFF == ord("q"):
break
else:
if len(faces) > 0 and frame_count % 30 == 0:
path = os.path.join(SNAPSHOT_DIR, f"frame_{frame_count:06d}.jpg")
cv2.imwrite(path, cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))
finally:
picam2.stop()
if have_display:
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
Reading the code
The cascade is loaded once, and the script checks face_cascade.empty() straight away, because a silently empty cascade is a common and confusing failure if the path is wrong. We configure the camera to output RGB888, a straightforward 8-bit-per-channel format, at 640x480, a resolution that is small enough to process quickly and large enough to make out a face clearly at typical doorstep distances.
Each frame is converted to grayscale, because the Haar cascade works on intensity patterns, not colour, and cv2.equalizeHist is applied to spread out the range of brightness values in the image. This is a simple contrast adjustment: a frame that is mostly mid-grey because of a dim porch light, or mostly bright because of a flash of sunlight on the door, gets stretched back towards using the full range from black to white, which generally helps the detector pick out facial features.
detectMultiScale does the actual work, and its three keyword arguments are worth understanding concretely rather than leaving at their defaults. scaleFactor controls how much smaller the image gets at each step as the detector searches for faces of different sizes; 1.1 means each step shrinks the image by about ten per cent, so a face that is too large to match the trained pattern at full size eventually becomes small enough to match as the search progresses. minNeighbors controls how many overlapping candidate rectangles need to agree before a detection is reported; 5 is a reasonable middle ground, low enough to catch real faces, high enough to reject one-off coincidental patterns. minSize=(60, 60) tells the detector to ignore anything smaller than 60 by 60 pixels, which in a 640-pixel-wide frame roughly rules out faces far in the background and keeps the focus on whoever is close to the door; the exact pixel size that corresponds to "someone standing at my door" depends on your camera's field of view and exactly where you mount it, so it is worth testing this value against your own setup once the camera is installed, rather than trusting a number from this article.
Running it with and without a screen
If you set up a desktop session or VNC in part 4, run this script from that session so the DISPLAY environment variable is set, and you will see a live window with green boxes drawn around any face. Press q in that window to quit cleanly. If you are working purely over SSH with no graphical session, DISPLAY will be unset, cv2.imshow will either fail outright or simply do nothing useful, so the script instead falls back to saving a snapshot to the snapshots folder every thirty frames whenever it sees at least one face, which you can copy off the Pi afterwards to check that detection is actually working.
What FPS to expect, and why it varies
It is tempting to want a number here, something like "expect 12 frames per second", but that would not be honest, because it depends on exactly which Pi 5 memory configuration you have, what else is running on the device, the resolution you chose, how many faces are in frame, and how you built your OpenCV install. What matters is that the script prints its own measured frame rate every two seconds, so you have real data from your own hardware rather than a borrowed number. As a general sense of direction, Haar cascade detection at 640x480 on a Raspberry Pi 5's CPU is usually fast enough to comfortably keep up with a person walking up to a door, since that takes several seconds, well within reach even at a modest frame rate. If your printed FPS is very low, in the low single digits, that is a signal to come back to the performance section later in this part.
Step 3: try the more accurate DNN detector
Haar cascades get you a working pipeline quickly, but it is worth seeing the difference a slightly more modern detector makes, especially once you start testing at the edges of good light or with faces not pointed straight at the lens.
Downloading the model files
cd ~/door-greeter/models
wget -O deploy.prototxt https://raw.githubusercontent.com/opencv/opencv/master/samples/dnn/face_detector/deploy.prototxt
wget -O res10_300x300_ssd_iter_140000.caffemodel https://raw.githubusercontent.com/opencv/opencv_3rdparty/dnn_samples_face_detector_20170830/res10_300x300_ssd_iter_140000.caffemodel
ls -lh
These links point at files that have been used in OpenCV's own sample code for a long time, but GitHub paths, branch names and repository structures do change over the years, so treat them as a starting point rather than a guarantee. If either download fails or returns an HTML error page instead of the real file, search the opencv and opencv_3rdparty repositories on GitHub for a folder called samples/dnn/face_detector, and take the matching prototxt and caffemodel pair from there. As a rough sanity check after downloading, the prototxt should be a small text file of a few kilobytes that you can open and read, describing network layers, while the caffemodel should be a binary file somewhere in the region of five to ten megabytes; if what you have downloaded is dramatically smaller than that, something went wrong and you have likely saved an error page instead of the model.
Reading the code
import os
import time
import cv2
import numpy as np
from picamera2 import Picamera2
MODEL_DIR = "models"
PROTOTXT = os.path.join(MODEL_DIR, "deploy.prototxt")
WEIGHTS = os.path.join(MODEL_DIR, "res10_300x300_ssd_iter_140000.caffemodel")
CONF_THRESHOLD = 0.6
net = cv2.dnn.readNetFromCaffe(PROTOTXT, WEIGHTS)
def detect_faces_dnn(frame_bgr):
h, w = frame_bgr.shape[:2]
blob = cv2.dnn.blobFromImage(
cv2.resize(frame_bgr, (300, 300)), 1.0, (300, 300), (104.0, 177.0, 123.0)
)
net.setInput(blob)
detections = net.forward()
boxes = []
for i in range(detections.shape[2]):
confidence = float(detections[0, 0, i, 2])
if confidence < CONF_THRESHOLD:
continue
box = detections[0, 0, i, 3:7] * np.array([w, h, w, h])
x1, y1, x2, y2 = box.astype("int")
boxes.append((x1, y1, x2 - x1, y2 - y1, confidence))
return boxes
def main():
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"})
picam2.configure(config)
picam2.start()
time.sleep(2)
have_display = bool(os.environ.get("DISPLAY"))
frame_count = 0
start_time = time.time()
try:
while True:
frame_rgb = picam2.capture_array()
frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
boxes = detect_faces_dnn(frame_bgr)
for (x, y, w, h, conf) in boxes:
cv2.rectangle(frame_bgr, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(
frame_bgr, f"{conf:.2f}", (x, max(y - 8, 0)),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1,
)
frame_count += 1
if frame_count % 30 == 0:
fps = frame_count / (time.time() - start_time)
print(f"faces: {len(boxes)} fps: {fps:.1f}")
if have_display:
cv2.imshow("door camera - DNN detector (q to quit)", frame_bgr)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
picam2.stop()
if have_display:
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
blobFromImage resizes the frame to 300 by 300 pixels, the fixed input size this particular model was trained on, and subtracts a fixed mean value from each colour channel, (104.0, 177.0, 123.0), a preprocessing step baked into how the model was originally trained; you do not need to tune these numbers, just keep them as they are for this specific model. The network's output is a list of candidate detections, each with a confidence score between 0 and 1, and we only keep the ones above CONF_THRESHOLD. Lowering this threshold towards 0.5 will surface more detections, including weaker and sometimes wrong ones, such as a reflection on a glass door; raising it towards 0.7 or higher trims those false guesses but can start to miss a face turned well to one side. Try a visitor standing at a natural angle to your door and watch how the printed confidence score moves as they turn their head, which gives you a concrete feel for where to set this value for your own doorway.
Haar vs DNN: which should you use at the door?
- Haar cascade: no download needed, lowest CPU cost per frame, best on faces looking roughly straight at the camera, more prone to stray false positives on patterned surfaces such as doormats or brickwork.
- DNN detector: a one-off download of a few megabytes, a little more CPU work per frame, generally more reliable on a face turned somewhat to the side or in dimmer light, fewer stray boxes once the confidence threshold is tuned.
- For today: use the Haar cascade to prove the whole pipeline end to end quickly, then switch to the DNN detector once you are tuning the system against your actual doorway and the real range of angles and light it sees.
- When part 6 arrives: the recognition library we adopt there typically brings its own detector bundled with the embedding model, so you may end up replacing both of these with that single pipeline; what you learn here about tuning thresholds and sizes carries over directly.
Step 4: wire up the PIR motion sensor
Running full face detection around the clock is wasteful, and it also means the camera is effectively analysing every person who ever walks past your house, not just the ones who approach your door. The PIR sensor from part 2 lets the system sit mostly idle and only wake the camera when something is actually happening nearby.
Wiring
A typical low-cost PIR module has three pins: power, ground, and a digital output that goes high when motion is detected. Connect power to a 5V pin on the Pi's GPIO header, ground to any ground pin, and the output pin to a GPIO input, for example GPIO17 (physical pin 11 on the 40-pin header). Before wiring anything, check the datasheet or product page for your specific module: most common PIR boards output a logic level compatible with the Pi's 3.3V GPIO pins even when powered from 5V, but a module that outputs a full 5V logic signal directly into a GPIO pin can damage it, so confirm this for the exact part you bought rather than assuming. Many PIR modules also have two small potentiometers, one for sensitivity (how far it detects motion) and one for the output delay (how long the output stays high after motion stops); turn the sensitivity down and the delay short to begin with, so the sensor covers roughly your doorstep and immediate approach rather than the pavement beyond your property line, in keeping with the filming rules from part 1.
Testing the sensor on its own
from gpiozero import MotionSensor
from signal import pause
pir = MotionSensor(17)
def motion_detected():
print("motion detected - someone is near the door")
def motion_stopped():
print("no motion - area is quiet again")
pir.when_motion = motion_detected
pir.when_no_motion = motion_stopped
pause()
Run this script, then walk towards and away from the sensor a few times. You should see the two messages print as you move in and out of range. If nothing prints, double-check the wiring, confirm you used the correct GPIO pin number in the code (gpiozero's MotionSensor takes the GPIO number, not the physical pin number), and try adjusting the sensitivity potentiometer. If the sensor triggers constantly even with nobody around, it may be picking up a heat source such as direct sunlight moving across it during the day, a radiator, or passing traffic if it is aimed too wide; narrowing its field of view with the sensitivity adjustment, or physically shielding part of its dome, usually helps.
A note on GPIO on the Raspberry Pi 5
The Raspberry Pi 5 handles its GPIO pins through a new companion chip, called RP1, rather than wiring them directly from the main processor the way earlier Pi models did. This means older low-level libraries, most notably the original RPi.GPIO package, may not work correctly on a Pi 5. gpiozero, the library used above, is still the right tool, but it needs a compatible backend, called a pin factory, to talk to RP1; on current Raspberry Pi OS releases this is usually lgpio, which is why we installed python3-lgpio earlier in this part. If you see an error mentioning a pin factory or failing to find a GPIO backend when you run the script above, confirm python3-lgpio is installed, and check the version of gpiozero you have with pip show gpiozero against its own documentation, since exact defaults can change between releases.
Step 5: put it together: watch, detect, log
Now we combine the pieces: the PIR sensor decides when to wake the camera, the Haar cascade detector watches for faces during that window, and each detection is logged to a small SQLite database alongside a saved crop of the face, ready for part 6 to turn into something more useful.
import os
import sqlite3
import time
from datetime import datetime, timezone
import cv2
from gpiozero import MotionSensor
from picamera2 import Picamera2
DB_PATH = "door_greeter.db"
CAPTURE_DIR = "captures"
PIR_PIN = 17
ACTIVE_SECONDS = 20 # how long to keep watching after motion starts
os.makedirs(CAPTURE_DIR, exist_ok=True)
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS detections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
seen_at TEXT NOT NULL,
faces_found INTEGER NOT NULL,
snapshot_path TEXT
)
"""
)
conn.commit()
return conn
def log_detection(conn, faces_found, snapshot_path):
conn.execute(
"INSERT INTO detections (seen_at, faces_found, snapshot_path) VALUES (?, ?, ?)",
(datetime.now(timezone.utc).isoformat(), faces_found, snapshot_path),
)
conn.commit()
def run_watch_session(picam2, face_cascade, conn, seconds):
end_time = time.time() + seconds
last_save = 0
while time.time() < end_time:
frame = picam2.capture_array()
gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
gray = cv2.equalizeHist(gray)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60))
if len(faces) > 0:
end_time = time.time() + seconds # someone is still there: extend the session
now = time.time()
if now - last_save > 2.0: # save at most one crop every 2 seconds
(x, y, w, h) = max(faces, key=lambda f: f[2] * f[3])
crop = frame[y:y + h, x:x + w]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
snapshot_path = os.path.join(CAPTURE_DIR, f"face_{timestamp}.jpg")
cv2.imwrite(snapshot_path, cv2.cvtColor(crop, cv2.COLOR_RGB2BGR))
log_detection(conn, len(faces), snapshot_path)
print(f"saved {snapshot_path} ({len(faces)} face(s) in frame)")
last_save = now
time.sleep(0.05)
def main():
face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml")
conn = init_db()
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480), "format": "RGB888"})
picam2.configure(config)
pir = MotionSensor(PIR_PIN)
print("waiting for motion at the door ... (Ctrl+C to stop)")
try:
while True:
pir.wait_for_motion()
print("motion detected: waking the camera")
picam2.start()
time.sleep(1) # let auto-exposure settle
run_watch_session(picam2, face_cascade, conn, ACTIVE_SECONDS)
picam2.stop()
print("session ended: back to waiting")
except KeyboardInterrupt:
pass
finally:
picam2.stop()
conn.close()
if __name__ == "__main__":
main()
Reading the combined script
init_db creates a single table called detections if it does not already exist, with a timestamp, how many faces were found in that frame, and the path to a saved image. This is intentionally simple: a basic activity log, not yet the identities database that part 6 will build. log_detection inserts one row per saved crop.
run_watch_session is the heart of the behaviour. While a face is visible, it keeps pushing the session's end time forward, so the camera does not switch off the moment the detector happens to miss one frame; a visitor standing still for fifteen seconds while they look for a doorbell will keep the session alive the whole time. When faces are present, it saves a crop no more often than once every two seconds, which stops the captures folder filling up with dozens of nearly identical photos of the same few seconds. It deliberately saves only the largest detected face in a frame, on the reasoning that the person closest to the camera is most likely the one actually at the door, while a smaller face further back might belong to someone walking past in the background; this is a simple heuristic, not a guarantee, and we will revisit handling multiple people properly in later parts.
main ties it together with pir.wait_for_motion(), a blocking call that does nothing until the PIR sensor reports movement. Only then does it start the camera, pause briefly for exposure to settle, run a watch session, and stop the camera again afterwards. Between visits, the Pi is doing essentially nothing, which is exactly the duty-cycle behaviour described earlier in this part.
Running it and checking what it saved
python doorbell_watch.py
Walk towards the camera from outside the PIR's detection range, as a real visitor would. You should see "motion detected", then, if your face is picked up by the cascade, a series of "saved ..." lines as crops land in the captures folder. Open a couple of those images and check they actually contain a reasonably clear face; if they are blurry, too dark, or missing the face entirely, that is useful information to carry into part 10 when we tackle lighting properly. You can also query the log directly from the command line.
sqlite3 door_greeter.db "SELECT seen_at, faces_found, snapshot_path FROM detections ORDER BY id DESC LIMIT 5;"
This should print your five most recent detections with their timestamps and the files they are linked to. If sqlite3 is not installed on your Pi, install it with sudo apt install sqlite3; the Python script itself does not need this command-line tool, since it talks to the database through Python's built-in sqlite3 module, but it is a handy way to inspect the file by hand while you are testing.
Making detection faster without losing accuracy
If the FPS you measured earlier felt low, there are a few practical adjustments worth trying before assuming the hardware is not up to the job. The simplest is to run detection on a smaller copy of the frame, then scale the resulting box back up to the original size for drawing or cropping.
scale = 0.5
small = cv2.resize(gray, (0, 0), fx=scale, fy=scale)
faces_small = face_cascade.detectMultiScale(small, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
faces = [
(int(x / scale), int(y / scale), int(w / scale), int(h / scale))
for (x, y, w, h) in faces_small
]
Halving each dimension cuts the number of pixels the detector has to examine to a quarter, often giving a noticeable speed-up, at some cost to how small a face it can still pick out reliably; minSize needs halving too, to (30, 30) here, since it is being applied to the shrunk image. A second option, once you need higher-quality saved crops for recognition in part 6 than you need for detection itself, is to configure picamera2 with two simultaneous streams: a small "lores" stream for fast detection, and a full-resolution "main" stream to crop the actual saved image from once a face is found.
config = picam2.create_preview_configuration(
main={"size": (1280, 720), "format": "RGB888"},
lores={"size": (320, 240), "format": "YUV420"},
)
picam2.configure(config)
picam2.start()
lores = picam2.capture_array("lores")
gray = cv2.cvtColor(lores, cv2.COLOR_YUV2GRAY_I420)
# run detectMultiScale on gray, then scale the box coordinates up
# by the ratio between the main and lores frame sizes before cropping
# from picam2.capture_array("main")
This is an optional refinement rather than something you need immediately; it is mentioned here because it fits naturally with what we have just built, and because detecting on a small frame while cropping from a sharp high-resolution one directly improves the quality of the face images that part 6 will need for building embeddings. Finally, it is worth checking whether other processes on the Pi are competing for CPU while this script runs, since a busy desktop session, a web browser left open, or another background task can all eat into the headroom a small, fast model like a Haar cascade needs to run smoothly.
Common mistakes and how to avoid them
- Calling cv2.imshow over a plain SSH session with no graphical display: it will fail or hang; use VNC or a desktop session for live preview, or rely on the headless snapshot fallback shown above.
- Treating a face detection as proof of a person's identity: a detector only says "a face is here", nothing more; that distinction matters, especially once alerts are involved in part 9.
- Skipping the camera warm-up pause after picam2.start(): the first frame or two can be dark or oddly coloured while auto-exposure and white balance settle, which can look like a bug but is just the sensor adjusting.
- Leaving minNeighbors too low: a value like 1 or 2 lets through a lot of false positives on patterned surfaces such as doormats, brick, or foliage moving in the wind.
- Using RPi.GPIO code examples written for older Raspberry Pi models: on the Pi 5 these can fail outright because of the new RP1 GPIO chip; stick with gpiozero and the lgpio backend.
- Letting the captures folder grow unchecked during testing: without a retention limit, which we add properly in part 12, repeated test walks to the door will quietly fill up the SD card with face photos.
- Aiming the PIR sensor or the camera wider than your own doorstep: this risks recording people on the public pavement or a neighbour's property, which part 1 specifically asked you to avoid.
- Assuming a fixed minSize or confidence threshold from this article will suit your doorway: the right values depend on your camera's mounting height, angle, and the distance visitors stand at, so test and adjust on your own setup.
Responsible design checkpoint
It is worth pausing here because this part is the first point in the build where the system actually produces and stores photographs of real people's faces, even though it does not yet know who they are. A folder of face crops and a log of timestamps is personal data the moment a face in it could plausibly be identified by a person looking at it, which a clear photo of someone's face almost always is. That means the same care applies here as to any later stage: keep the captures folder off anything you share or commit to version control, delete your test images once you are done experimenting rather than leaving them to accumulate, and make sure the visible sign about camera use that part 1 asked you to put up is actually in place before you run this system anywhere near a real front door, even during testing.
Keep the PIR sensor's range and the camera's framing limited to your own property, as discussed in part 1; a wide PIR range that triggers on every passer-by means you are building a log of strangers who never intended to interact with your door at all, which is both unnecessary for the project's purpose and the kind of over-collection that data protection rules such as the UK and EU GDPR, and United States state laws like Illinois' BIPA, are specifically concerned with. None of this article's code sends data anywhere; everything stays on the Pi's own storage, which keeps you in a much simpler position than if images were leaving the device, but local storage is not the same as no responsibility, since the data still exists and can still be lost, copied, or misused if the device itself is not secured.
It is also worth being honest about the limits of what you have built. Haar cascades, and to a lesser extent the DNN detector, were trained on particular datasets, and like most face-related models their accuracy is not perfectly even across different lighting conditions, skin tones, ages, or the angle a face is held at; a detector can miss a real face or flag a false one more often in some conditions than others. At this detection-only stage the consequence of an error is mild, a missed or extra box, but it is the same underlying issue that matters much more once matching against named individuals begins in part 6, where a wrong result could mean greeting the wrong person or flagging someone incorrectly. Keep that caveat in mind as the project grows; part 10 returns to it directly when we work through difficult lighting, and part 11 deals with another kind of error, a photograph being mistaken for a real visitor.
Summary and what's next
You now have a working, continuously running face detector on the Pi: two options, a Haar cascade that needs no download and a small DNN model that trades a modest download for somewhat better accuracy, both drawing boxes around faces in a live camera feed and reporting a measured frame rate rather than a borrowed one. You wired up the PIR motion sensor and used it to wake the camera only when someone is actually near the door, rather than running detection continuously. The combined script watches during that window, saves a crop of the most prominent face it finds no more often than once every two seconds, and logs each event to a small SQLite database, giving you a simple, inspectable record of what the system has seen so far.
None of this identifies anyone yet, and that is deliberate: this part was about getting detection solid and efficient before adding the harder and more sensitive step of recognition. In part 6, we take the face crops this script produces, or frames captured live in the same way, and turn each one into a numerical embedding using a maintained face recognition library, build a small store of the people you choose to add, and compare new faces against it to decide whether someone is known. The captures folder and the detections table you built here become the raw material for that next step, so it is worth leaving this part's scripts in place and tested before moving on.
Comments (0)
No comments yet. Be the first to share your thoughts.