A Raspberry Pi camera motion MQTT detector turns a camera module into an event source: instead of streaming video all day, the Pi watches a low-resolution feed, saves a snapshot when something changes and publishes a small JSON event that anything can subscribe to. In this tutorial you will build that detector with Picamera2 and Python, tune it to avoid false alarms, and deliver the events through an MQTT broker.
How the Detector Works
Picamera2 can run two streams from the camera at the same time: a full-resolution main stream and a tiny lores stream. The detector compares consecutive greyscale lores frames, which costs very little CPU even on a Pi Zero 2 W, and only touches the main stream to save a JPEG when motion is found.
The flow is:
- Grab the luminance plane of the 320x240
loresframe. - Compute the mean squared difference from the previous frame.
- If the difference exceeds a threshold, start a motion event, save a full-size snapshot and publish an MQTT message with its link.
- Ignore further triggers during a cooldown period.
- After a quiet period, publish a "motion ended" event and set the retained state back to
idle.
Sending images through MQTT is possible, but a broker is a poor file server: large payloads are slow, and the public test broker caps messages at 64 KB. The robust pattern is to publish an event with a link and let the image live on a web server or object storage you control.
Hardware and Software
| Item | Notes |
|---|---|
| Raspberry Pi 4, 5 or Zero 2 W | Raspberry Pi OS Bookworm |
| Camera Module 2, 3 or HQ camera | Pi 5 and Zero need the matching narrow ribbon cable |
| Picamera2 | Preinstalled on Raspberry Pi OS |
paho-mqtt 2.x |
Installed in a virtual environment |
Picamera2 and NumPy come from the OS packages, so create the virtual environment with access to them:
sudo apt update
sudo apt install -y python3-picamera2 python3-numpy python3-venv
rpicam-hello --list-cameras
mkdir -p ~/motion-mqtt/snapshots && cd ~/motion-mqtt
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install "paho-mqtt>=2.0"
If rpicam-hello --list-cameras shows no camera, power off, reseat the ribbon cable with the contacts facing the right way and try again. The Picamera2 manual documents the stream configuration used below.
Event Topics
| Topic | Payload | Retained |
|---|---|---|
connectmq/demo/e71b04/frontdoor/motion |
JSON event: start or end, score, snapshot URL | No |
connectmq/demo/e71b04/frontdoor/motion/state |
active or idle |
Yes |
connectmq/demo/e71b04/frontdoor/status |
online or offline (last will) |
Yes |
A typical start event looks like this:
{
"event": "motion_start",
"score": 18.4,
"snapshot": "http://192.168.1.50:8000/20261016-142233.jpg",
"ts": 1792160553
}
The Raspberry Pi Camera Motion MQTT Script
Save as motion_mqtt.py:
#!/usr/bin/env python3
"""Detect motion with Picamera2 and publish MQTT events with snapshot links."""
import json
import os
import socket
import time
import uuid
from datetime import datetime
from pathlib import Path
import numpy as np
import paho.mqtt.client as mqtt
from picamera2 import Picamera2
BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"cam-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("MQTT_PREFIX", "connectmq/demo/e71b04/frontdoor")
THRESHOLD = float(os.getenv("MOTION_THRESHOLD", "12"))
COOLDOWN = 10 # seconds between start events
QUIET_TIME = 15 # seconds without motion before "motion_end"
SNAP_DIR = Path(os.getenv("SNAP_DIR", str(Path.home() / "motion-mqtt" / "snapshots")))
HTTP_PORT = int(os.getenv("SNAP_HTTP_PORT", "8000"))
LORES = (320, 240)
def local_ip():
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
BASE_URL = os.getenv("SNAP_BASE_URL", f"http://{local_ip()}:{HTTP_PORT}")
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code.is_failure:
print(f"Connection refused: {reason_code}")
return
print(f"Connected as {CLIENT_ID}")
client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)
client.publish(f"{PREFIX}/motion/state", "idle", qos=1, retain=True)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
client.on_connect = on_connect
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=60)
if USERNAME:
client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
client.tls_set()
client.connect(BROKER, PORT, keepalive=30)
client.loop_start()
picam2 = Picamera2()
config = picam2.create_video_configuration(
main={"size": (1280, 720), "format": "RGB888"},
lores={"size": LORES, "format": "YUV420"},
)
picam2.configure(config)
picam2.start()
time.sleep(2) # let exposure and white balance settle
SNAP_DIR.mkdir(parents=True, exist_ok=True)
w, h = LORES
previous = None
active = False
last_start = 0.0
last_motion = 0.0
def publish_event(event, score, snapshot=None):
payload = {"event": event, "score": round(score, 1), "ts": int(time.time())}
if snapshot:
payload["snapshot"] = snapshot
client.publish(f"{PREFIX}/motion", json.dumps(payload), qos=1)
print(payload)
try:
while True:
frame = picam2.capture_buffer("lores")
current = frame[: w * h].reshape(h, w).astype(np.int16)
if previous is not None:
score = float(np.square(current - previous).mean())
now = time.time()
if score > THRESHOLD:
last_motion = now
if not active and now - last_start > COOLDOWN:
name = datetime.now().strftime("%Y%m%d-%H%M%S") + ".jpg"
picam2.capture_file(str(SNAP_DIR / name))
publish_event("motion_start", score, f"{BASE_URL}/{name}")
client.publish(f"{PREFIX}/motion/state", "active", qos=1, retain=True)
active = True
last_start = now
elif active and now - last_motion > QUIET_TIME:
publish_event("motion_end", score)
client.publish(f"{PREFIX}/motion/state", "idle", qos=1, retain=True)
active = False
previous = current
time.sleep(0.1)
except KeyboardInterrupt:
pass
finally:
picam2.stop()
client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True).wait_for_publish(2)
client.loop_stop()
client.disconnect()
The astype(np.int16) conversion matters: subtracting two unsigned 8-bit arrays wraps around and produces huge false differences. Taking only the first w * h bytes of the YUV420 buffer gives you the greyscale luminance plane, which is all motion detection needs.
Serving the Snapshots
For a quick local setup, serve the snapshot folder with Python's built-in web server in a second terminal:
python3 -m http.server 8000 --directory ~/motion-mqtt/snapshots
The links in each event now open on any device on your network. This server has no authentication, so keep it on your LAN only. For access from outside, upload snapshots from your own script to storage you control and set SNAP_BASE_URL to that location; the MQTT event format does not change. Also add a cron job or systemd timer to delete old images, or the SD card will fill up.
Testing and Tuning
Watch events from any machine:
mosquitto_sub -h broker.connectmq.com -p 1883 -i viewer-e71b04 \
-t 'connectmq/demo/e71b04/frontdoor/#' -v
Wave a hand in front of the lens and you should see motion_start with a snapshot link, then motion_end after 15 quiet seconds. Tuning is mostly about the threshold:
| Symptom | Adjustment |
|---|---|
| Events from clouds, flicker or auto-exposure changes | Raise MOTION_THRESHOLD |
| People walking past are missed | Lower the threshold or the sleep interval |
| Constant start and end events | Increase QUIET_TIME |
| Alerts at night are noisy | Add an IR light or disable alerts by schedule |
| Trees or a road in frame cause triggers | Crop the lores array to a region of interest before comparing |
Print the score for a few minutes with nothing moving to see your scene's noise floor, then set the threshold comfortably above it.
Camera Detection vs a PIR Sensor
A passive infrared (PIR) sensor is the other common way to detect movement, and it is worth knowing when each fits. A PIR sensor reacts to body heat, uses almost no power and ignores changes in light, so it produces fewer false alarms from shadows and clouds. It cannot tell you what moved, though, and it has no picture to attach. Frame differencing with the camera reacts to any visual change, including a curtain in the wind, but it gives you a snapshot and lets you restrict detection to a precise region of the image.
The two combine well. Wire a PIR sensor to a spare GPIO pin, and only accept a camera trigger when the PIR line is also high within the same second or two. The camera then confirms and documents what the PIR detected, and the MQTT event can include both signals so subscribers can decide how much to trust it. Keep that confirmation logic on the Pi so the published event stays simple: one motion_start, one motion_end, one link.
Reacting to Motion Events
Because the detector only publishes events, the reaction lives elsewhere and can change without touching the camera code. A Node-RED flow can send a push notification; a small Python subscriber can email the snapshot, as shown in MQTT email alerts with Python; and a second Pi can switch on a floodlight using the Raspberry Pi relay MQTT controller by subscribing to the motion/state topic. Home Assistant can also consume the retained state as a binary sensor. If you want a lower-cost camera node for a second door, the ESP32-CAM over MQTT guide uses the same event idea on a microcontroller.
Privacy and Production Setup
Motion events reveal when people are at home, so they should never travel over a public broker in a real installation. The public MQTT test broker at broker.connectmq.com is readable by everyone and is meant only for the first test. For a real camera, use a private broker from ConnectMQ with TLS and generated credentials:
export MQTT_PORT=8883
export MQTT_USERNAME=YOUR_USERNAME
export MQTT_PASSWORD=YOUR_PASSWORD
export MQTT_CLIENT_ID=YOUR_CLIENT_ID
export MQTT_PREFIX=home/frontdoor
python motion_mqtt.py
With a managed MQTT broker, your phone, a home server and the camera all connect outbound to the same TLS endpoint, so you do not need port forwarding. Run the detector and the snapshot server as systemd services so they start after a power cut; the Raspberry Pi MQTT tutorial includes a ready service template.
Get Started with ConnectMQ
Give your camera a secure place to send its alerts. Create a free Serverless broker on ConnectMQ, no credit card required, and connect with TLS on port 8883. The portal's live topic explorer shows every motion event and retained state as it happens, which makes tuning thresholds much easier.
Frequently Asked Questions
Can I send the snapshot image itself over MQTT?
You can publish JPEG bytes as a payload, but it is inefficient and hits message size limits quickly. Publishing a link to the image and storing the file elsewhere scales much better.
How much CPU does Picamera2 motion detection use?
Very little, because the comparison runs on a 320x240 greyscale stream. Even a Raspberry Pi Zero 2 W can run it alongside the MQTT client.
Why do I get motion events when nothing is moving?
Auto-exposure changes, flickering lights and moving shadows change pixel values. Raise the threshold, crop to a region of interest, or lock exposure with Picamera2 controls.
Does this work with the Raspberry Pi Camera Module 3?
Yes. Picamera2 supports Camera Module 2, Camera Module 3 and the HQ camera; the code does not change.



