VMware ESXi and vSphere Cluster Management

Build a Raspberry Pi Camera Motion Detector with Python

Learn how to configure a Raspberry Pi Camera Module, compare frames with Python and Pillow, and save timestamped photos when visual motion is detected.

What this project does

This project uses a Raspberry Pi Camera Module to detect visual motion. The program continuously captures frames, compares each new frame with a previous reference frame, and measures how much the pixels changed. When the change exceeds a configured threshold, the program saves a timestamped still image.

A frame is one individual image captured from the camera. This is different from using a dedicated PIR sensor: a PIR sensor detects changes in infrared radiation, while this project detects changes visible in camera images.

Suitable uses include monitoring a room, observing wildlife at a feeder, and prototyping a camera-based physical-computing project. Use camera monitoring only where recording is permitted. Inform people when notification or consent is required, protect stored images, and do not expose camera services publicly without authentication.

Hardware and software requirements

Raspberry Pi — Runs the operating system and Python program. Use a model with a compatible camera connector or supported camera interface.

Raspberry Pi Camera Module — Captures still images. The ribbon cable must be fully seated and oriented correctly.

Reliable power supply — Prevents crashes and camera errors caused by undervoltage.

Mounting hardware or enclosure — Optional, but a stable mount is important for reliable comparisons.

Monitor and keyboard or SSH — Optional setup tools for accessing the Pi.

Current Raspberry Pi OS — Provides the modern libcamera camera stack and package manager.

Python 3, Picamera2, and Pillow — Capture frames and process image differences.

Connect and configure the camera

Connect the ribbon cable safely

Turn the Raspberry Pi off and disconnect its power before connecting or reseating the cable. Open the camera connector latch, insert the ribbon cable straight and completely, then close the latch. Check the connector orientation for your particular Pi model and camera module. A loose, reversed, or partially inserted cable is a common cause of camera failures.

Update Raspberry Pi OS

sudo apt update
sudo apt full-upgrade

Restart if the upgrade installs a new kernel, firmware, or camera-related packages.

Understand the camera software choices

Current Raspberry Pi OS — Uses the libcamera-based stack. The command-line test tool is usually rpicam-still, and Python programs commonly use Picamera2.

Older Raspberry Pi OS — May use the legacy MMAL-based stack and the older picamera Python interface. Code written for this interface is not interchangeable with Picamera2 code.

Migration — Prefer libcamera and Picamera2 for new projects. Do not mix imports, configuration methods, or examples from the two APIs.

Test the camera before debugging Python:

rpicam-still -o test.jpg

Some older releases use an earlier command name instead. A successful test image confirms basic camera connection and software support.

Install Python dependencies

Picamera2 is the Python interface commonly used with the current libcamera stack. Pillow is the maintained Python image-processing library compatible with the older PIL programming model. PIL means Python Imaging Library; the original PIL project is old, while Pillow is its actively maintained replacement.

sudo apt install python3-picamera2 python3-pil

Use the package names supplied by your Raspberry Pi OS release. On an older installation that still uses the legacy interface, the historically associated command may be:

sudo apt install python-picamera python-imaging-tk

Package availability varies. python-imaging-tk is not a general replacement for Pillow; it provides Tk integration and may be unavailable or unsuitable. For Python 3 image processing, prefer the available python3-pil package. Install a legacy picamera package only when the operating system and camera stack actually require it.

Check that the interpreter used to run the script is Python 3 and can import the packages:

python3 -c "from picamera2 import Picamera2; from PIL import Image; print('imports OK')"

Installing a package for one interpreter does not make it available to a different virtual environment or Python executable. Always run the script with the same interpreter you tested.

How frame-based motion detection works

  1. Start the camera and allow automatic exposure and white balance to settle.
  2. Capture an initial reference frame showing the normal scene.
  3. Wait for a selected interval and capture a subsequent frame.
  4. Resize or capture a small frame to reduce processing cost.
  5. Convert both images to grayscale when color is not needed.
  6. Compute an image difference: each difference pixel represents a change between the two frames.
  7. Measure the difference intensity and the fraction of pixels that changed significantly.
  8. Trigger only when the configured motion score and minimum changed area exceed their thresholds.
  9. Save an image, then wait through a cooldown period before allowing another trigger.
  10. Use the new frame as the next reference frame.

A false positive is a trigger caused by something other than the intended subject, such as a shadow, changing daylight, automatic exposure, or sensor noise. A sensitivity setting controls how easily the detector triggers; in general, a lower threshold means higher sensitivity.

Complete Picamera2 motion detector

Create a directory for captures:

mkdir -p ~/motion-images

Save the following source as motion_detector.py. This version uses Picamera2 for the current camera stack and Pillow for comparison and JPEG writing. It saves the captured RGB frame with Pillow, so the JPEG quality setting is applied reliably by Image.save() rather than being passed as an uncertain keyword to Picamera2.capture_file().

#!/usr/bin/env python3
from datetime import datetime
from pathlib import Path
import time

from picamera2 import Picamera2
from PIL import Image, ImageChops, ImageStat

OUTPUT_DIR = Path.home() / "motion-images"
FRAME_SIZE = (640, 480)
FRAME_INTERVAL = 1.0
SETTLE_SECONDS = 3.0
MOTION_THRESHOLD = 12.0
PIXEL_CHANGE_THRESHOLD = 25
MIN_CHANGED_FRACTION = 0.01
COOLDOWN_SECONDS = 10.0
JPEG_QUALITY = 85


def motion_score(previous, current):
    """Return average grayscale difference and changed-pixel fraction."""
    old_gray = previous.convert("L")
    new_gray = current.convert("L")
    difference = ImageChops.difference(old_gray, new_gray)
    average_difference = ImageStat.Stat(difference).mean[0]
    changed = difference.point(
        lambda value: 255 if value >= PIXEL_CHANGE_THRESHOLD else 0
    )
    changed_pixels = sum(changed.getdata()) // 255
    total_pixels = changed.width * changed.height
    changed_fraction = changed_pixels / total_pixels
    return average_difference, changed_fraction


def capture_frame(camera):
    array = camera.capture_array("main")
    return Image.fromarray(array).convert("RGB")


def main():
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    camera = Picamera2()
    configuration = camera.create_preview_configuration(
        main={"size": FRAME_SIZE, "format": "RGB888"}
    )
    camera.configure(configuration)
    camera.start()

    try:
        print(f"Allowing exposure to settle for {SETTLE_SECONDS} seconds")
        time.sleep(SETTLE_SECONDS)
        reference = capture_frame(camera)
        last_trigger = 0.0
        print("Motion detector running. Press Ctrl+C to stop.")

        while True:
            time.sleep(FRAME_INTERVAL)
            current = capture_frame(camera)
            average, changed_fraction = motion_score(reference, current)
            print(
                f"score={average:.2f}, "
                f"changed={changed_fraction:.3%}"
            )

            now = time.monotonic()
            triggered = (
                average >= MOTION_THRESHOLD
                and changed_fraction >= MIN_CHANGED_FRACTION
            )
            if triggered and now - last_trigger >= COOLDOWN_SECONDS:
                filename = datetime.now().strftime("motion-%Y%m%d-%H%M%S-%f.jpg")
                path = OUTPUT_DIR / filename
                current.save(path, format="JPEG", quality=JPEG_QUALITY)
                print(f"Motion detected; saved {path}")
                last_trigger = now

            reference = current
    except KeyboardInterrupt:
        print("Stopping motion detector")
    finally:
        camera.stop()


if __name__ == "__main__":
    main()

Important parts of the script

  • Picamera2 initializes the camera using the current Raspberry Pi camera stack.
  • FRAME_SIZE keeps comparisons small enough for a Pi to process efficiently.
  • motion_score() converts frames to grayscale, computes an image difference, and returns both average intensity and changed area.
  • MOTION_THRESHOLD rejects weak overall changes.
  • MIN_CHANGED_FRACTION rejects tiny regions of noise.
  • The filename includes the date, time, and microseconds, preventing overwrites when multiple events occur close together.
  • COOLDOWN_SECONDS is a debounce period: it prevents one person or animal from generating a large burst of duplicate images.
  • The finally block stops the camera during normal termination or a keyboard interrupt.

Run the detector

Run the source explicitly with Python 3:

python3 motion_detector.py

Move through the monitored scene after the settling period. The terminal prints the calculated score and changed-pixel fraction. Press Ctrl+C to stop safely, then inspect the files:

ls -lh ~/motion-images

You can optionally add executable permission and run the file directly:

chmod +x motion_detector.py
./motion_detector.py

Direct execution requires the shebang line at the top of the file. Executable permission is optional when using python3 motion_detector.py.

Saving and managing images

The example creates ~/motion-images and checks that it exists before starting. Saving under the running user's home directory normally avoids permission problems. An absolute path is clearer than relying on the terminal's current working directory.

JPEG quality 85 is a practical starting point. Higher quality produces larger files; lower quality saves storage but loses detail. Storage grows continuously, especially when the detector triggers frequently. Set a retention policy, such as deleting images older than a chosen number of days, rotating files, or moving selected images to protected storage. Make sure stored images have appropriate file permissions and are not published by an unauthenticated web or network service.

Calibration and reliability

Resolution — Increasing it can reveal smaller subjects but uses more CPU, memory, and storage. Reduce it when the Pi becomes slow.

Frame interval — Increasing it reduces processing load but can miss brief movement. Decrease it for faster events.

Motion threshold — Increasing it makes triggering harder and reduces false positives. Lower it when genuine movement is missed.

Minimum changed fraction — Increasing it requires a larger part of the scene to change. Raise it to ignore small noise; lower it for distant or small subjects.

Pixel change threshold — Increasing it ignores subtle pixel differences. Lower it when low-contrast movement is important.

Cooldown — Increasing it reduces duplicate captures but may suppress separate events that happen close together.

Mount the camera securely and keep the scene reasonably fixed. Test under the actual lighting conditions where the detector will run. Shadows, clouds, daylight changes, automatic exposure, foliage, and camera noise can all create false positives. Establish the baseline only after exposure has settled. For calibration, record scores for an unchanged scene and for deliberate movement, then choose a threshold between the typical no-motion and motion values.

For a doorway, establish an empty-room baseline and walk through once. For wildlife observation, aim at a feeder, use a moderate interval, and increase the minimum changed area if plant movement causes constant triggers. To reduce daylight-related triggers, use small grayscale frames, require more changed area, and add a cooldown.

Common errors and fixes

Camera test fails — The cable may be reversed or loose, camera support may be incomplete, or the hardware may be unsupported. Power off before reseating the cable, verify orientation and connector choice, update and reboot, then retry rpicam-still.

ImportError for picamera, picamera2, PIL, or Pillow — The package may be missing, installed for another interpreter, or mismatched with the camera API. Install the Python 3 packages with apt, run with python3, and do not mix legacy Picamera code with Picamera2.

Constant motion detection — The threshold may be too low, lighting may be changing, the camera may vibrate, or the compared frames may be unnecessarily large. Raise thresholds, allow settling, stabilize the mount, use grayscale and smaller frames, and add cooldown.

No photo after real movement — The threshold may be too high, the subject may occupy too few pixels, the interval may be too long, or lighting may be poor. Lower the threshold carefully, move the camera closer, shorten the interval, or improve stable lighting.

Files overwrite or cannot be found — A fixed filename, unexpected relative path, or write permission problem may be responsible. Use timestamped names, an absolute output directory, mkdir -p, and verify ownership and permissions.

The Pi is slow or storage fills — Processing may be too intensive, the loop may have no delay, or every minor change may save a full-size image. Reduce processing resolution, add an interval and cooldown, choose suitable JPEG quality, and rotate or delete old files.

Exam-relevant summary

  • Motion detection means identifying meaningful visual changes between frames, not detecting infrared radiation with a PIR sensor.
  • A reference frame is the earlier image used for comparison.
  • An image difference describes pixel changes between two frames.
  • A threshold is the required level of change before the program triggers.
  • A false positive is an unwanted trigger caused by noise, light, shadows, or other non-target changes.
  • A cooldown prevents repeated captures from one continuing event.
  • chmod changes Linux file permissions, including executable permission.
  • Use the modern libcamera/Picamera2 environment for new Raspberry Pi camera projects unless an older installation specifically requires legacy Picamera.