Pi 4 Camera with Picamera2 and OpenCV: Motion-Triggered Recording
A camera turns the Raspberry Pi 4 into a security monitor, a wildlife trap, a barcode reader or the eyes of a robot. On Raspberry Pi OS Bookworm, the camera stack is built on libcamera. The old raspistill commands and the legacy picamera Python library no longer work. Their replacements are the rpicam-* command-line apps and the Picamera2 Python library.
In this tutorial you will connect a camera module, test it from the command line, grab frames from Python with Picamera2, and pass them to OpenCV to build a motion detector that saves a timestamped snapshot whenever something moves in front of the lens.
Cameras and Connection
| Camera | Sensor | Notes |
|---|---|---|
| Camera Module 3 | Sony IMX708 | Autofocus; standard, wide and NoIR versions |
| Camera Module 2 | Sony IMX219 | Fixed focus, widely available |
| High Quality Camera | Sony IMX477 | Interchangeable C/CS-mount lenses |
| USB webcam | Various | Works with OpenCV directly, not with Picamera2 |
- Shut down the Pi and unplug the power.
- Lift the plastic latch on the connector labelled CAMERA, between the HDMI ports and the audio jack.
- Insert the ribbon cable with the silver contacts facing the HDMI ports, then press the latch down evenly.
- Power on. Bookworm detects official cameras automatically through the
camera_auto_detect=1line inconfig.txt.
Testing from the Command Line
rpicam-hello --list-cameras # should list your sensor, e.g. imx708 rpicam-hello -t 5000 # 5-second preview (desktop only) rpicam-still -o test.jpg # take a full-resolution photo rpicam-vid -t 10000 -o clip.h264 # record 10 seconds of H.264 video
If --list-cameras reports no cameras, the problem is the cable or connector, not your code. Reseat both ends before going further. On older Bookworm images these tools were named libcamera-hello, libcamera-still and so on.
Install the Python libraries from apt, not pip, because Picamera2 depends on system libcamera bindings:
sudo apt install -y python3-picamera2 python3-opencv
The Motion Detector
The detector compares each new frame with a slowly updated background. Pixels that differ by more than a threshold are marked as changed, and if the changed area is large enough, the program saves a snapshot.
#!/usr/bin/env python3
# motion_cam.py - motion-triggered snapshots on a Raspberry Pi 4
import os
import time
from datetime import datetime
import cv2
from picamera2 import Picamera2
MIN_AREA = 1500 # pixels of change needed to count as motion
COOLDOWN = 5 # seconds between saved snapshots
SAVE_DIR = os.path.expanduser('~/motion')
os.makedirs(SAVE_DIR, exist_ok=True)
# Configure a 640x480 stream. In Picamera2, 'RGB888' gives pixels in
# B, G, R order in memory - exactly what OpenCV expects.
picam2 = Picamera2()
config = picam2.create_video_configuration(
main={'size': (640, 480), 'format': 'RGB888'})
picam2.configure(config)
picam2.start()
time.sleep(2) # let auto-exposure and white balance settle
background = None # running average of the scene (float image)
last_saved = 0.0
try:
while True:
frame = picam2.capture_array() # numpy array, 480x640x3
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0) # remove sensor noise
if background is None:
background = gray.astype('float')
continue
# Update the background slowly so lighting changes are absorbed
cv2.accumulateWeighted(gray, background, 0.05)
diff = cv2.absdiff(gray, cv2.convertScaleAbs(background))
# Pixels that changed by more than 25 grey levels become white
_, mask = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
mask = cv2.dilate(mask, None, iterations=2) # join nearby blobs
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
moving = [c for c in contours if cv2.contourArea(c) > MIN_AREA]
for c in moving: # draw boxes on the frame
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
now = time.time()
if moving and now - last_saved > COOLDOWN:
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
path = os.path.join(SAVE_DIR, f'motion_{stamp}.jpg')
cv2.putText(frame, stamp, (10, 25), cv2.FONT_HERSHEY_SIMPLEX,
0.7, (255, 255, 255), 2)
cv2.imwrite(path, frame)
print('Motion detected, saved', path)
last_saved = now
except KeyboardInterrupt:
print('Stopped')
finally:
picam2.stop()
How It Works
- create_video_configuration sets up a continuous stream optimised for repeated capture, rather than the still configuration used for single photos.
- capture_array() returns the latest frame as a NumPy array, which every OpenCV function accepts directly.
- GaussianBlur smooths out pixel noise so that tiny flickers are not detected as motion.
- accumulateWeighted keeps a running average of the scene. A weight of 0.05 means the background slowly adapts to clouds and changing daylight, while a person walking through still stands out.
- threshold and dilate turn the difference image into a clean black-and-white mask, and findContours finds each moving region.
- MIN_AREA filters out small movements such as leaves or insects. Increase it for outdoor scenes.
Processing 640 x 480 frames like this is comfortably within the Pi 4's capability. If you raise the resolution, analyse a downscaled copy of each frame and save the full-size version only when motion is found.
Tuning the Detector
| Parameter | Increase it when | Decrease it when |
|---|---|---|
| Threshold (25) | Noise or flicker triggers saves | Slow or low-contrast movement is missed |
| MIN_AREA (1500) | Small objects like insects trigger saves | People far from the camera are missed |
| Background weight (0.05) | Lighting changes cause false triggers | A person standing still blends into the background too fast |
| COOLDOWN (5 s) | Too many near-identical images are saved | You need a fuller sequence of an event |
Running It Unattended
For a real monitor, run the script as a systemd service so it starts at boot and restarts after a crash, and keep an eye on storage. A JPEG at 640 x 480 is small, but a busy scene can save thousands per day. A daily cron job such as find ~/motion -name '*.jpg' -mtime +7 -delete removes snapshots older than a week. If you need video clips instead of stills, Picamera2 provides encoder classes such as H264Encoder with a circular buffer output, which can keep the few seconds before motion was detected as well as what follows.
Troubleshooting
- ModuleNotFoundError: picamera2 inside a virtual environment: create the venv with
--system-site-packagesso it can see the apt-installed library. - Pipeline handler in use by another process: only one program can use the camera. Close
rpicam-helloor any earlier script. - Colours look blue-tinted: the format and conversion do not match. With RGB888 use
COLOR_BGR2GRAYand save directly withimwrite. - Constant false triggers: the camera is on a vibrating surface or pointing at a screen or moving trees. Raise the threshold or MIN_AREA.
- Old tutorial imports picamera: that library does not work with libcamera on Bookworm. Port it to Picamera2.
Summary
On a Bookworm Pi 4, test the camera with rpicam-hello --list-cameras, install python3-picamera2 and python3-opencv from apt, and use capture_array() to feed frames into OpenCV. A running-average background, a threshold and contour area filtering give a dependable motion detector in about fifty lines. The next article builds on the same capture loop to detect faces.