Part
6
  |  
From Prototype to Product
  |  
Chapter
23

Capstone: Smart Security Camera

A security camera system doesn't need expensive commercial hardware — it needs a $35 board, sixty lines of Python, and an engineer who understands the tradeoffs.
Reading Time
14
mins
BACK TO RASPBERRY PI MASTERCLASS

The trap is believing that a security camera system requires commercial hardware. I watch teams spend $2,000 on a Hikvision NVR, $400 per camera, and another $600 on a proprietary license server before they've even defined what "security event" means for their specific use case. Then the system ships with face detection models trained on datasets that don't represent their population, motion detection thresholds that trigger on shadows, and a web interface locked behind a vendor's cloud portal with a monthly fee. The hardware is expensive. The software is inflexible. And the engineer who deployed it has no ability to change the detection logic because the firmware is a black box.

The most expensive security camera system is the one you can't modify when the requirements change.

Meanwhile, a Raspberry Pi with a $15 camera module runs OpenCV, speaks MQTT, serves a Flask dashboard, and gives you full control over every line of detection logic. The total bill of materials is under $60. The total lines of Python are under 100. And when the client says "actually, we need to detect hard hats, not faces," you swap one cascade classifier file and redeploy. Try doing that with a Hikvision NVR.

This chapter builds a complete smart security camera system from scratch. It integrates patterns from across this entire book — the capstone project that proves the Pi is a production platform, not a prototype crutch.

The Architecture

The system has five layers, each corresponding to a concept you've already learned:

  1. Capture — Pi Camera streams frames via picamera2 (Chapter 10's Resolution Tax applies here)
  2. Detection — OpenCV's Haar cascade classifier runs face detection on each frame (Chapter 12's Frame Budget)
  3. Storage — timestamped snapshots saved to local disk when a face is detected
  4. Alerting — MQTT messages published to a broker on every detection event (Chapter 16's MQTT Contract)
  5. Dashboard — Flask web app serves detection history and system status (Chapter 17's Edge API Pattern)
  6. Persistence — systemd service keeps everything running through reboots (Chapter 21's Systemd Contract)
Prior chapters referenced

This capstone intentionally doesn't introduce new frameworks. It integrates: The Resolution Tax (Ch10) for choosing capture resolution, The Frame Budget (Ch12) for balancing FPS against CPU, The Cascade Ladder (Ch14) for detection model selection, The Edge API Pattern (Ch17) for the local dashboard, and The Systemd Contract (Ch21) for process management. If any of those feel unfamiliar, skim the relevant chapter before building.

The mental model is a pipeline. Frames flow from left to right: capture → detect → decide → act. Each stage is a function. The whole application is a loop that calls those functions on every frame. That's it. No framework, no event loop library, no async runtime. A while True loop with four function calls.

The Complete Application

Here is the full application. I'm showing it in one block first because I want you to see how small a production security camera system actually is, then I'll walk through each section.

#!/usr/bin/env python3
"""Smart security camera — face detection, MQTT alerts, Flask dashboard."""

import io
import json
import time
import threading
from datetime import datetime
from pathlib import Path

import cv2
import numpy as np
import paho.mqtt.client as mqtt
from flask import Flask, jsonify, send_file, Response
from picamera2 import Picamera2

# ── Configuration ─────────────────────────────────────────────────────────
RESOLUTION = (640, 480)          # The Resolution Tax: lower = faster detection
DETECTION_INTERVAL = 0.5         # seconds between detection runs (Frame Budget)
SNAPSHOT_DIR = Path("/home/pi/security-snapshots")
MQTT_BROKER = "localhost"
MQTT_TOPIC = "security/detections"
CASCADE_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"

# ── Initialization ────────────────────────────────────────────────────────
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
face_cascade = cv2.CascadeClassifier(CASCADE_PATH)
camera = Picamera2()
camera.configure(camera.create_still_configuration(main={"size": RESOLUTION}))
camera.start()

mqtt_client = mqtt.Client(client_id="pi-security-cam")
mqtt_client.connect(MQTT_BROKER, 1883, 60)
mqtt_client.loop_start()

app = Flask(__name__)
recent_detections = []  # Last 50 detections, newest first

# ── Detection engine ──────────────────────────────────────────────────────
def detect_and_alert():
    """Capture a frame, run face detection, alert if faces found."""
    frame = camera.capture_array()
    gray = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)

    faces = face_cascade.detectMultiScale(
        gray,
        scaleFactor=1.3,
        minNeighbors=5,
        minSize=(30, 30)
    )

    if len(faces) > 0:
        timestamp = datetime.now().isoformat()
        filename = f"detection_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
        filepath = SNAPSHOT_DIR / filename

        # Draw rectangles on detected faces and save
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
        cv2.imwrite(str(filepath), cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))

        # Build and publish MQTT alert
        event = {
            "timestamp": timestamp,
            "faces_detected": len(faces),
            "snapshot": filename,
            "resolution": f"{RESOLUTION[0]}x{RESOLUTION[1]}"
        }
        mqtt_client.publish(MQTT_TOPIC, json.dumps(event), qos=1)

        # Update in-memory detection log
        recent_detections.insert(0, event)
        if len(recent_detections) > 50:
            recent_detections.pop()

        print(f"[{timestamp}] Detected {len(faces)} face(s) → {filename}")

def detection_loop():
    """Run detection at the configured interval."""
    while True:
        try:
            detect_and_alert()
        except Exception as e:
            print(f"Detection error: {e}")
        time.sleep(DETECTION_INTERVAL)

# ── Flask dashboard ───────────────────────────────────────────────────────
@app.route("/")
def index():
    return Response("""<!DOCTYPE html><html><head><title>Pi Security</title>
    <style>body{font-family:system-ui;max-width:800px;margin:2rem auto;padding:0 1rem}
    .det{border:1px solid #ddd;padding:1rem;margin:0.5rem 0;border-radius:4px}
    a{color:#0066cc}</style></head><body>
    <h1>Pi Security Camera</h1>
    <p>Status: <strong>Active</strong></p>
    <p><a href="/api/detections">View recent detections (JSON)</a></p>
    <div id="dets"></div>
    <script>
    fetch('/api/detections').then(function(r){return r.json()}).then(function(d){
      var container = document.getElementById('dets');
      d.detections.forEach(function(e){
        var div = document.createElement('div');
        div.className = 'det';
        var strong = document.createElement('strong');
        strong.textContent = e.timestamp;
        var link = document.createElement('a');
        link.href = '/snapshot/' + e.snapshot;
        link.textContent = 'View snapshot';
        div.appendChild(strong);
        div.appendChild(document.createTextNode(' — ' + e.faces_detected + ' face(s) '));
        div.appendChild(document.createElement('br'));
        div.appendChild(link);
        container.appendChild(div);
      });
    });
    </script></body></html>""", content_type="text/html")

@app.route("/api/detections")
def api_detections():
    return jsonify({"detections": recent_detections, "total": len(recent_detections)})

@app.route("/snapshot/<filename>")
def snapshot(filename):
    filepath = SNAPSHOT_DIR / filename
    if filepath.exists():
        return send_file(filepath, mimetype="image/jpeg")
    return jsonify({"error": "not found"}), 404

@app.route("/api/status")
def status():
    return jsonify({
        "camera": "active",
        "resolution": f"{RESOLUTION[0]}x{RESOLUTION[1]}",
        "detection_interval": DETECTION_INTERVAL,
        "total_detections": len(recent_detections),
        "uptime_start": startup_time
    })

# ── Entry point ───────────────────────────────────────────────────────────
if __name__ == "__main__":
    startup_time = datetime.now().isoformat()
    print(f"Starting security camera at {RESOLUTION[0]}x{RESOLUTION[1]}")
    print(f"Detection interval: {DETECTION_INTERVAL}s")
    print(f"Snapshots: {SNAPSHOT_DIR}")
    print(f"MQTT broker: {MQTT_BROKER}:{MQTT_TOPIC}")

    # Start detection in a background thread
    threading.Thread(target=detection_loop, daemon=True).start()

    # Start Flask on all interfaces
    app.run(host="0.0.0.0", port=5000, debug=False)

That's the entire system. Under 100 lines of logic (excluding imports and comments). It captures frames, detects faces, saves snapshots, publishes MQTT alerts, and serves a web dashboard. Every piece uses a standard Python library — no proprietary SDKs, no vendor lock-in, no monthly fees.

Key takeaway

A production security camera system is a while True loop with four function calls: capture, detect, store, alert. Everything else is configuration.

Walking Through the Tradeoffs

The configuration block at the top of the application isn't arbitrary. Every value represents a tradeoff you've learned about in earlier chapters.

Resolution: 640x480. This is where the Resolution Tax hits hardest. Face detection at 1920x1080 consumes roughly 8x the CPU cycles of 640x480 — and for a Haar cascade classifier, the accuracy improvement is marginal. The cascade works by scanning the image with sliding windows at multiple scales. More pixels means more windows. On a Pi 4, 1080p detection drops you to about 2 FPS. At 640x480, you get 8-12 FPS. For a security camera that needs to catch a person walking through a doorway, 640x480 detects them across a 3-meter room without breaking a sweat.

Detection interval: 0.5 seconds. This is the Frame Budget in action. Running detection on every frame at 30 FPS would consume 100% of one CPU core and still fall behind. At two detections per second, the Pi spends about 40% of one core on OpenCV and the rest is available for Flask, MQTT, and disk I/O. A person walks at roughly 1.4 meters per second. At two frames per second, you get multiple detections as they cross the field of view.

At two detections per second and 640x480, the Pi spends 40% of one core on vision and leaves the rest for everything else.

Haar cascade vs deep learning. The Cascade Ladder from Chapter 14 applies directly. Haar cascades are fast, free, and require no GPU. They also produce false positives on objects that happen to have face-like contrast patterns — shadows, posters, TV screens showing faces. For a home security camera, that's acceptable. You'll get a few false alerts per day. If you need higher accuracy, swap in a DNN-based detector:

# Upgrade path: swap Haar for a DNN model (Chapter 14's Cascade Ladder)
# net = cv2.dnn.readNetFromCaffe("deploy.prototxt", "res10_300x300_ssd.caffemodel")
# blob = cv2.dnn.blobFromImage(frame, 1.0, (300, 300), (104, 177, 123))
# net.setInput(blob)
# detections = net.forward()

The DNN model is more accurate but consumes roughly 3x the CPU. That's the Cascade Ladder — start with the cheapest detector that meets your accuracy requirements, and climb only when the false-positive rate forces you.

When to skip detection entirely

If all you need is motion detection — "did something change in the frame?" — skip face detection entirely. Use cv2.absdiff() between consecutive frames and threshold the result. This runs at full camera FPS on a Pi Zero and catches any movement, not just faces. Many commercial security cameras use exactly this approach and call it "AI-powered."

The MQTT Alert Contract

The MQTT message structure matters more than most engineers realize on their first IoT project. This system publishes a JSON payload to security/detections every time a face is detected:

{
  "timestamp": "2026-05-27T14:32:01.442",
  "faces_detected": 2,
  "snapshot": "detection_20260527_143201.jpg",
  "resolution": "640x480"
}

This is a contract. Any subscriber — a phone notification app, a Home Assistant automation, a logging pipeline, another Pi — can consume these messages without knowing anything about the camera's implementation. Want to add a second camera? Deploy another Pi running the same code with a different MQTT client ID. Both publish to the same topic. The subscriber sees events from both cameras. No configuration changes on the receiving end.

✕ Without MQTT
  • Camera pushes to one hardcoded endpoint
  • Adding a consumer means changing camera code
  • Camera failure takes down the alert pipeline
  • Tight coupling between detection and notification
✓ With MQTT
  • Camera publishes to a topic, any subscriber receives
  • Adding a consumer means subscribing — zero camera changes
  • Camera failure means no new events, subscribers stay up
  • Detection and notification are completely decoupled

The systemd Service

The application needs to survive reboots, recover from crashes, and start automatically. That's what the Systemd Contract is for. Create this service file:

# /etc/systemd/system/pi-security.service
[Unit]
Description=Pi Smart Security Camera
After=network-online.target mosquitto.service
Wants=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/security-camera
ExecStart=/home/pi/security-camera/venv/bin/python camera.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

# Resource limits — prevent runaway memory
MemoryMax=512M
CPUQuota=80%

[Install]
WantedBy=multi-user.target

Three details that matter. First, After=mosquitto.service ensures the MQTT broker starts before the camera application tries to connect. Without this ordering, the application starts, fails to connect, and enters a crash-restart loop until Mosquitto catches up. Second, Restart=always with RestartSec=5 means a crash recovers in five seconds — the camera drops a few frames and comes back. Third, MemoryMax=512M prevents a memory leak (unlikely but possible with OpenCV frame buffers) from consuming all system RAM and taking down the OS.

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable pi-security.service
sudo systemctl start pi-security.service

# Verify it's running
sudo systemctl status pi-security.service

# Watch logs in real time
journalctl -u pi-security.service -f
Key takeaway

A systemd service with Restart=always and resource limits is the difference between a demo that works when you're watching and a system that runs for months without intervention.

Mosquitto Configuration

The MQTT broker needs minimal configuration for a single-camera setup. Install and configure:

sudo apt install -y mosquitto mosquitto-clients

# Create a minimal config
sudo tee /etc/mosquitto/conf.d/local.conf << 'EOF'
listener 1883
allow_anonymous true
max_queued_messages 1000
EOF

sudo systemctl enable mosquitto
sudo systemctl restart mosquitto

# Test from another terminal
mosquitto_sub -h localhost -t "security/#" -v
Authentication in production

allow_anonymous true is fine for a single-Pi system on a private network. For anything exposed beyond your LAN — or any multi-device deployment — configure username/password authentication at minimum, TLS at best. Run mosquitto_passwd -c /etc/mosquitto/passwd pi-cam and reference the password file in your Mosquitto config. Chapter 16 covers this in detail.

For a production deployment, you'd add authentication, TLS, and possibly bridge the local broker to a cloud MQTT service. But for a single-camera system on a home network, anonymous access on localhost is pragmatic, not reckless.

Installation and Dependencies

Set up the complete environment:

# Create project directory
mkdir -p ~/security-camera && cd ~/security-camera

# Create virtual environment
python3 -m venv venv
source venv/bin/activate

# Install dependencies
pip install flask paho-mqtt opencv-python-headless numpy picamera2

# Enable the camera interface (if not already enabled)
sudo raspi-config nonint do_camera 0

# Create the snapshot directory
mkdir -p ~/security-snapshots

# Save the application code as camera.py
# (paste the full application from above)

opencv-python-headless is critical — the regular opencv-python package pulls in GUI dependencies (Qt, GTK) that a headless Pi doesn't need and that add 200+ MB to the install. The headless variant has identical functionality minus the cv2.imshow() window functions, which you'll never call on a headless deployment anyway.

What a Running System Looks Like

Once the service is active, you have:

  • Face detection running at 2 FPS on every frame from the Pi Camera
  • Snapshots saved to /home/pi/security-snapshots/ with green rectangles drawn around detected faces
  • MQTT alerts published to security/detections on every detection event
  • A web dashboard at http://<pi-ip>:5000 showing recent detections with links to snapshots
  • An API at http://<pi-ip>:5000/api/detections returning JSON for programmatic consumers
  • Automatic restart on crash, boot, or power cycle via systemd

Total hardware cost: $35 (Pi) + $15 (Camera Module) + $10 (power supply and SD card) = $60. Total recurring cost: $0. Total vendor lock-in: zero. Total lines of Python: 87.

Total hardware cost: $60. Total recurring cost: $0. Total vendor lock-in: zero. Total lines of application code: 87.

What to Do Monday Morning

Install dependencies and save the application code

Create the project directory, virtual environment, and install the packages listed above. Save the full application as camera.py. Don't modify anything yet — run the default configuration first and see what it produces.

Run the application manually before deploying as a service

Run python camera.py in your terminal and open http://<pi-ip>:5000 in a browser. Walk in front of the camera. Confirm that detections appear in the dashboard and snapshots are saved to disk. Check mosquitto_sub -t "security/#" to verify MQTT messages arrive.

Deploy as a systemd service

Copy the service file to /etc/systemd/system/, run daemon-reload, enable, and start. Reboot the Pi and confirm the camera starts automatically. Check journalctl -u pi-security.service for any startup errors.

Tune the resolution and detection interval for your environment

If detection feels sluggish, drop the resolution to 320x240 — the Haar cascade still detects faces at arm's length. If you're getting too many false positives, increase minNeighbors from 5 to 7. If you need to cover a larger room, increase resolution to 800x600 and accept the CPU tradeoff. Every change is one line in the configuration block.

Subscribe to MQTT from a second device

Install Mosquitto clients on your laptop or phone (MQTT Dashboard on iOS/Android works well). Subscribe to security/detections. Walk in front of the camera and watch the alert arrive on your phone in under a second. That moment — a $60 system alerting your phone with zero cloud infrastructure — is the point of this entire book.

The trap was believing this requires commercial hardware. It doesn't. It requires a Pi, a camera, and an engineer who understands that detection accuracy, frame rate, and CPU budget are tradeoffs you control — not specifications a vendor chose for you.