Pi Zero 2 W Security Camera with Motion Detection and Picamera2

A Pi Zero 2 W with a camera module costs far less than most commercial smart cameras, stores footage locally instead of on someone else's cloud, and can be programmed to do exactly what you want. In this project you will build a motion-triggered recorder: the camera watches continuously, and when something moves it saves a video clip that includes a few seconds from before the movement started.

The program uses Picamera2, the official Python camera library on Raspberry Pi OS Bookworm. It takes advantage of two features of the Raspberry Pi camera pipeline: a small low-resolution stream for cheap motion analysis, and the hardware H.264 encoder for recording, so the CPU does very little of the heavy work.

How the Motion Detector Works

The camera produces two streams from the same sensor at the same time. The main stream at 1280x720 goes into the H.264 encoder. The lores stream at 320x240 is used only for analysis. Analysing a 320x240 greyscale image means handling 76,800 pixels per frame instead of over 900,000, which is what makes motion detection practical on a 512 MB board.

Detection uses frame differencing. Each new lores frame is compared with the previous one by computing the mean squared error (MSE) of the pixel brightness values. A static scene gives a small MSE caused only by sensor noise; a person walking through gives a much larger value. When the MSE exceeds a threshold, recording starts.

MSE = (1 / N) x sum over all pixels of (current - previous)^2

N = 320 x 240 = 76800 pixels (Y plane of the lores frame)
Motion if MSE > THRESHOLD

The encoder writes into a CircularOutput, a rolling buffer in memory that always holds the last few seconds of compressed video. When motion is detected, the buffer contents are written to a file first and then live recording continues. That is why each clip shows the moment just before the trigger, rather than starting halfway through the event.

Hardware and Setup

ItemNotes
Raspberry Pi Zero 2 WThe four cores help; a Zero W can work at lower frame rates
Camera Module 3 (or Camera Module 2)The NoIR version with infrared LEDs allows night viewing
Zero camera cable22-pin to 15-pin
5 V supply, 2.5 A recommendedRecording and Wi-Fi together raise the current draw
High-endurance microSD cardContinuous video writing wears ordinary cards faster
Install Picamera2, NumPy and ffmpeg, and check the camera
sudo apt update
sudo apt install -y python3-picamera2 python3-numpy ffmpeg --no-install-recommends

# The camera should be listed (e.g. imx708 for Camera Module 3)
rpicam-hello --list-cameras

mkdir -p ~/clips

The Motion Recorder Program

motion_cam.py: record H.264 clips with pre-trigger buffer when motion is detected
#!/usr/bin/env python3
"""Motion-triggered recorder for Raspberry Pi Zero 2 W using Picamera2."""
import os
import time
from datetime import datetime

import numpy as np
from picamera2 import Picamera2
from picamera2.encoders import H264Encoder
from picamera2.outputs import CircularOutput

CLIP_DIR = os.path.expanduser('~/clips')
MAIN_SIZE = (1280, 720)   # recorded resolution
LORES_SIZE = (320, 240)   # analysis resolution
THRESHOLD = 7.0           # MSE level that counts as motion; tune for your scene
POST_MOTION_S = 5.0       # keep recording this long after motion stops
BITRATE = 2_000_000       # 2 Mbit/s H.264

picam2 = Picamera2()
config = picam2.create_video_configuration(
    main={'size': MAIN_SIZE},
    lores={'size': LORES_SIZE, 'format': 'YUV420'},
)
picam2.configure(config)

encoder = H264Encoder(bitrate=BITRATE, repeat=True)  # repeat headers so clips start cleanly
circular = CircularOutput()   # default buffer holds roughly the last few seconds
encoder.output = circular

picam2.start()
picam2.start_encoder(encoder)
time.sleep(2)  # let exposure settle before comparing frames

w, h = LORES_SIZE
previous = None
recording = False
last_motion = 0.0
print('Watching for motion. Ctrl+C to stop.')

try:
    while True:
        # The first w*h bytes of a YUV420 buffer are the Y (brightness) plane
        frame = picam2.capture_buffer('lores')[:w * h].reshape(h, w)
        # Use int32 so subtraction and squaring cannot overflow like uint8 would
        frame = frame.astype(np.int32)
        if previous is not None:
            mse = np.square(frame - previous).mean()
            if mse > THRESHOLD:
                if not recording:
                    name = datetime.now().strftime('%Y%m%d_%H%M%S') + '.h264'
                    circular.fileoutput = os.path.join(CLIP_DIR, name)
                    circular.start()   # flushes the buffer, then keeps writing
                    recording = True
                    print(f'Motion (MSE {mse:.1f}) -> recording {name}')
                last_motion = time.time()
            elif recording and time.time() - last_motion > POST_MOTION_S:
                circular.stop()
                recording = False
                print('Motion ended, clip saved')
        previous = frame
except KeyboardInterrupt:
    pass
finally:
    if recording:
        circular.stop()
    picam2.stop_encoder()
    picam2.stop()

Tuning and Using the Clips

Lighting changes are the main source of false alarms. A cloud passing the sun, a tube light warming up or the camera's auto exposure adjusting all change the brightness of every pixel slightly, which raises the MSE across the whole frame even though nothing moved. Ignoring frames where the average brightness jumps suddenly, or requiring the MSE to stay above the threshold for two consecutive frames, removes most of these triggers without missing a person walking through.

Before relying on the camera, find a good threshold for your location. Temporarily add a print(mse) inside the loop and watch the values for a minute with nothing moving, then walk past. Set THRESHOLD comfortably above the idle values and below the walking values. Outdoor scenes with trees and changing sunlight need a higher threshold than a quiet corridor.

The clips are raw H.264 streams, which many players cannot seek through. Wrap them in an MP4 container without re-encoding; this is fast even on the Zero because no video is decoded:

cd ~/clips
for f in *.h264; do ffmpeg -loglevel error -framerate 30 -i "$f" -c copy "${f%.h264}.mp4" && rm "$f"; done

Set -framerate to the rate the camera actually ran at; 30 fps is the usual default for a 1280x720 video configuration. To watch clips remotely, copy them with scp, serve the folder with the Flask dashboard from this series, or sync it to a network share. Run the recorder as a systemd service so it starts automatically after a power cut.

Troubleshooting

  • Recording triggers constantly: threshold too low, auto exposure flicker under tube lights, or a moving curtain in view. Raise THRESHOLD or reposition the camera.
  • Never triggers: threshold too high, or the int32 conversion was removed so uint8 differences wrap around. Print the MSE to check.
  • ImportError for CircularOutput: an old Picamera2 version. Update with sudo apt update && sudo apt full-upgrade.
  • SD card fills up: add a cron job or systemd timer that deletes clips older than a few days with find ~/clips -mtime +3 -delete.
  • Choppy clips on a Zero W: lower MAIN_SIZE and the frame rate; the single core struggles to analyse frames quickly enough.

Summary

Using a low-resolution analysis stream, mean squared error between frames and a circular H.264 buffer, a Pi Zero 2 W becomes a capable motion-triggered camera that records the lead-up to each event. Tune the threshold for your scene, convert clips with ffmpeg -c copy, run the script under systemd and manage disk space with a cleanup job.