Un détecteur de mouvement MQTT à base de caméra Raspberry Pi transforme un module caméra en source d'événements : au lieu de diffuser de la vidéo toute la journée, le Pi observe un flux basse résolution, enregistre un cliché lorsque quelque chose change et publie un petit événement JSON auquel n'importe quel client peut s'abonner. Dans ce tutoriel, vous construirez ce détecteur avec Picamera2 et Python, le réglerez pour éviter les fausses alertes et transmettrez les événements via un broker MQTT.
Comment fonctionne le détecteur
Picamera2 peut exploiter simultanément deux flux de la caméra : un flux main en pleine résolution et un minuscule flux lores. Le détecteur compare des images lores successives en niveaux de gris, ce qui coûte très peu de processeur, même sur un Pi Zero 2 W, et n'utilise le flux main que pour enregistrer un JPEG lorsqu'un mouvement est détecté.
Le déroulement est le suivant :
- Récupérer le plan de luminance de l'image
loresen 320x240. - Calculer l'écart quadratique moyen par rapport à l'image précédente.
- Si l'écart dépasse un seuil, démarrer un événement de mouvement, enregistrer un cliché pleine taille et publier un message MQTT contenant son lien.
- Ignorer les déclenchements suivants pendant une période de temporisation.
- Après une période calme, publier un événement « fin de mouvement » et remettre l'état retenu à
idle.
Envoyer des images via MQTT est possible, mais un broker est un piètre serveur de fichiers : les gros payloads sont lents, et le broker public de test limite les messages à 64 Ko. Le schéma robuste consiste à publier un événement avec un lien et à laisser l'image sur un serveur web ou un stockage objet que vous maîtrisez.
Matériel et logiciels
| Élément | Remarques |
|---|---|
| Raspberry Pi 4, 5 ou Zero 2 W | Raspberry Pi OS Bookworm |
| Camera Module 2, 3 ou caméra HQ | Le Pi 5 et le Zero nécessitent la nappe étroite correspondante |
| Picamera2 | Préinstallé sur Raspberry Pi OS |
paho-mqtt 2.x |
Installé dans un environnement virtuel |
Picamera2 et NumPy proviennent des paquets du système : créez donc l'environnement virtuel avec accès à ces paquets :
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"
Si rpicam-hello --list-cameras n'affiche aucune caméra, éteignez le Pi, remettez la nappe en place avec les contacts dans le bon sens et réessayez. Le manuel Picamera2 documente la configuration des flux utilisée ci-dessous.
Topics des événements
| Topic | Payload | Retenu |
|---|---|---|
connectmq/demo/e71b04/frontdoor/motion |
Événement JSON : début ou fin, score, URL du cliché | Non |
connectmq/demo/e71b04/frontdoor/motion/state |
active ou idle |
Oui |
connectmq/demo/e71b04/frontdoor/status |
online ou offline (Last Will) |
Oui |
Un événement de début typique ressemble à ceci :
{
"event": "motion_start",
"score": 18.4,
"snapshot": "http://192.168.1.50:8000/20261016-142233.jpg",
"ts": 1792160553
}
Le script de détection de mouvement MQTT pour caméra Raspberry Pi
Enregistrez-le sous 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 # secondes entre deux événements de début
QUIET_TIME = 15 # secondes sans mouvement avant "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) # laisser l'exposition et la balance des blancs se stabiliser
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()
La conversion astype(np.int16) est importante : soustraire deux tableaux d'entiers non signés sur 8 bits provoque un dépassement qui produit d'énormes écarts fictifs. Ne prendre que les w * h premiers octets du tampon YUV420 vous donne le plan de luminance en niveaux de gris, qui suffit à la détection de mouvement.
Servir les clichés
Pour une installation locale rapide, servez le dossier des clichés avec le serveur web intégré de Python dans un second terminal :
python3 -m http.server 8000 --directory ~/motion-mqtt/snapshots
Les liens de chaque événement s'ouvrent désormais sur n'importe quel appareil de votre réseau. Ce serveur n'a aucune authentification : gardez-le uniquement sur votre réseau local. Pour un accès depuis l'extérieur, envoyez les clichés depuis votre propre script vers un stockage que vous maîtrisez et définissez SNAP_BASE_URL sur cet emplacement ; le format des événements MQTT ne change pas. Ajoutez aussi une tâche cron ou un timer systemd pour supprimer les anciennes images, sinon la carte SD finira par se remplir.
Tester et régler
Observez les événements depuis n'importe quelle machine :
mosquitto_sub -h broker.connectmq.com -p 1883 -i viewer-e71b04 \
-t 'connectmq/demo/e71b04/frontdoor/#' -v
Agitez la main devant l'objectif : vous devriez voir motion_start avec un lien vers le cliché, puis motion_end après 15 secondes de calme. Le réglage porte surtout sur le seuil :
| Symptôme | Ajustement |
|---|---|
| Événements dus aux nuages, au scintillement ou aux changements d'exposition automatique | Augmenter MOTION_THRESHOLD |
| Les passants ne sont pas détectés | Baisser le seuil ou l'intervalle de pause |
| Événements de début et de fin incessants | Augmenter QUIET_TIME |
| Alertes nocturnes trop nombreuses | Ajouter un éclairage IR ou désactiver les alertes selon un horaire |
| Des arbres ou une route dans le champ déclenchent des alertes | Recadrer le tableau lores sur une zone d'intérêt avant la comparaison |
Affichez le score pendant quelques minutes sans rien qui bouge pour connaître le bruit de fond de votre scène, puis placez le seuil confortablement au-dessus.
Détection par caméra ou capteur PIR
Un capteur infrarouge passif (PIR) est l'autre moyen courant de détecter un mouvement, et il est utile de savoir quand choisir l'un ou l'autre. Un capteur PIR réagit à la chaleur corporelle, consomme presque rien et ignore les variations de lumière : il produit donc moins de fausses alertes dues aux ombres et aux nuages. En revanche, il ne peut pas dire ce qui a bougé et n'a aucune image à joindre. La différence d'images par caméra réagit à tout changement visuel, y compris un rideau qui bouge au vent, mais elle vous fournit un cliché et permet de limiter la détection à une zone précise de l'image.
Les deux se combinent bien. Raccordez un capteur PIR à une broche GPIO libre et n'acceptez un déclenchement de la caméra que si la ligne PIR est aussi à l'état haut dans la même seconde ou les deux secondes qui suivent. La caméra confirme et documente alors ce que le PIR a détecté, et l'événement MQTT peut inclure les deux signaux pour que les abonnés décident du degré de confiance à lui accorder. Gardez cette logique de confirmation sur le Pi afin que l'événement publié reste simple : un motion_start, un motion_end, un lien.
Réagir aux événements de mouvement
Comme le détecteur se contente de publier des événements, la réaction se trouve ailleurs et peut évoluer sans toucher au code de la caméra. Un flow Node-RED peut envoyer une notification push ; un petit abonné Python peut envoyer le cliché par e-mail, comme le montre l'article sur les alertes e-mail MQTT en Python ; et un second Pi peut allumer un projecteur avec le contrôleur relais MQTT sur Raspberry Pi en s'abonnant au topic motion/state. Dans une maison connectée, Home Assistant peut aussi consommer l'état retenu comme capteur binaire, et Jeedom et Domoticz savent eux aussi communiquer en MQTT. Si vous voulez un nœud caméra moins coûteux pour une seconde porte, le guide ESP32-CAM en MQTT applique le même principe d'événements sur un microcontrôleur.
Confidentialité et mise en production
Les événements de mouvement révèlent quand des personnes sont à la maison : dans une vraie installation, ils ne doivent jamais transiter par un broker public. Le serveur MQTT public gratuit broker.connectmq.com est lisible par tout le monde et n'est destiné qu'au premier test. Pour une vraie caméra, utilisez un broker privé ConnectMQ avec TLS et des identifiants générés :
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
Avec un broker MQTT managé, votre téléphone, un serveur domestique et la caméra se connectent tous en sortant vers le même point d'accès TLS : aucune redirection de ports n'est nécessaire. Exécutez le détecteur et le serveur de clichés comme services systemd afin qu'ils redémarrent après une coupure de courant ; le tutoriel MQTT sur Raspberry Pi inclut un modèle de service prêt à l'emploi.
Démarrer avec ConnectMQ
Offrez à votre caméra un endroit sûr où envoyer ses alertes. Créez un broker Serverless gratuit sur ConnectMQ, sans carte bancaire, et connectez-vous en TLS sur le port 8883. L'explorateur de topics en direct du portail affiche chaque événement de mouvement et chaque état retenu au moment où ils se produisent, ce qui facilite grandement le réglage des seuils.
Questions fréquentes
Peut-on envoyer l'image du cliché elle-même via MQTT ?
Vous pouvez publier les octets JPEG comme payload, mais c'est peu efficace et vous atteindrez vite les limites de taille des messages. Publier un lien vers l'image et stocker le fichier ailleurs passe beaucoup mieux à l'échelle.
Combien de processeur la détection de mouvement Picamera2 consomme-t-elle ?
Très peu, car la comparaison s'effectue sur un flux en niveaux de gris de 320x240. Même un Raspberry Pi Zero 2 W peut la faire tourner à côté du client MQTT.
Pourquoi reçois-je des événements de mouvement alors que rien ne bouge ?
Les changements d'exposition automatique, les éclairages qui scintillent et les ombres mouvantes modifient la valeur des pixels. Augmentez le seuil, recadrez sur une zone d'intérêt ou verrouillez l'exposition avec les contrôles de Picamera2.
Cela fonctionne-t-il avec le Raspberry Pi Camera Module 3 ?
Oui. Picamera2 prend en charge le Camera Module 2, le Camera Module 3 et la caméra HQ ; le code ne change pas.



