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

CameraSensorNotes
Camera Module 3Sony IMX708Autofocus; standard, wide and NoIR versions
Camera Module 2Sony IMX219Fixed focus, widely available
High Quality CameraSony IMX477Interchangeable C/CS-mount lenses
USB webcamVariousWorks with OpenCV directly, not with Picamera2
  1. Shut down the Pi and unplug the power.
  2. Lift the plastic latch on the connector labelled CAMERA, between the HDMI ports and the audio jack.
  3. Insert the ribbon cable with the silver contacts facing the HDMI ports, then press the latch down evenly.
  4. Power on. Bookworm detects official cameras automatically through the camera_auto_detect=1 line in config.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.

motion_cam.py - Picamera2 frames analysed by OpenCV, snapshots saved on motion
#!/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

ParameterIncrease it whenDecrease it when
Threshold (25)Noise or flicker triggers savesSlow or low-contrast movement is missed
MIN_AREA (1500)Small objects like insects trigger savesPeople far from the camera are missed
Background weight (0.05)Lighting changes cause false triggersA person standing still blends into the background too fast
COOLDOWN (5 s)Too many near-identical images are savedYou 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-packages so it can see the apt-installed library.
  • Pipeline handler in use by another process: only one program can use the camera. Close rpicam-hello or any earlier script.
  • Colours look blue-tinted: the format and conversion do not match. With RGB888 use COLOR_BGR2GRAY and save directly with imwrite.
  • 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.