The 2026 Standard: Picamera2 and the Pi 5 Hardware Stack

Building an IP camera on Raspberry Pi hardware used to mean relying on the legacy picamera Python library and raspistill binaries. With the shift to Raspberry Pi OS Bookworm and the introduction of the Raspberry Pi 5, that stack is dead. The modern standard relies on libcamera and the picamera2 Python bindings, which interface directly with the Pi's Image Signal Processor (ISP) via the MIPI CSI-2 lanes.

This guide targets the Raspberry Pi 5 (8GB variant) running 64-bit Raspberry Pi OS Bookworm. The 8GB model is non-negotiable if you plan to run a local MJPEG stream while simultaneously buffering high-resolution 12MP frames for motion-triggered snapshots, as the libcamera buffer allocations will easily consume 2-3GB of RAM.

Decision Path: Which Camera Module to Buy?

Do not just buy "a Pi camera." The sensor optics dictate your entire deployment. Use this decision tree to lock in your hardware:

  • Need long-distance optical zoom (license plates, distant gates)? Choose the Pi HQ Camera + 50mm telephoto lens.
  • Need absolute darkness capture without external IR illuminators? Choose the Pi Camera Module 3 NoIR (lacks the IR cut filter).
  • Need wide area coverage for a room, porch, or driveway? Choose the Pi Camera Module 3 Wide.

Default Pick: For 90% of DIY IP security camera builds, the Pi Camera Module 3 Wide (120° FOV, IMX708 sensor) is the correct choice. It provides enough distortion-free width to cover a standard room from a corner mount without requiring a multi-lens array.

Bill of Materials (BOM)

ComponentExact Variant / SpecApprox. Cost
Compute BoardRaspberry Pi 5 (8GB RAM)$80.00
Camera ModulePi Camera Module 3 Wide (IMX708)$35.00
CSI Ribbon Cable22-pin to 15-pin 0.5mm pitch FPC adapter (Critical for Pi 5)$4.00
Motion SensorAM312 Mini PIR Sensor (3.3V logic safe)$2.50
Thermal MgmtRaspberry Pi Active Cooler$5.00
Power Supply27W USB-C PD (5V/5A) Official Pi PSU$12.00
⚠️ Hardware Trap: The Pi 5 CSI Connector
The Raspberry Pi 5 uses two 22-pin 0.5mm pitch MIPI connectors. The Camera Module 3 ships with a standard 15-pin 1mm pitch cable. If you try to force the 15-pin cable into the Pi 5 with a generic adapter board, you will bend the pins. You must buy a dedicated 22-pin (Pi 5) to 15-pin (Camera) FPC ribbon cable.

Wiring the PIR Motion Trigger

To make this IP camera reactive rather than just a passive stream, we are adding a PIR (Passive Infrared) motion sensor. When the PIR detects a heat signature, the Python script will instantly dump a full-resolution 12MP JPEG to the local disk while maintaining the low-res MJPEG stream.

The 5V Logic Hazard

Most tutorials recommend the HC-SR501 PIR sensor. Do not use the HC-SR501 on a Raspberry Pi 5. The HC-SR501 requires 5V power, and its OUT pin outputs 5V when triggered. The Pi 5 GPIO pins are strictly 3.3V tolerant. Feeding 5V into GPIO 17 will fry the pin and potentially damage the RP1 southbridge chip. Instead, we use the AM312 Mini PIR, which operates natively at 3.3V and outputs safe 3.3V logic.

Pin Mapping Table

AM312 PIR PinRaspberry Pi 5 GPIOPhysical Pin #Wire Color (Typical)
VCC3.3V PowerPin 1Red
GNDGroundPin 6Black
OUTGPIO 17Pin 11Yellow/Orange

Keep the PIR sensor wires under 12 inches. The AM312 output signal is clean, but long unshielded wires near the Pi 5's switching regulators can induce false motion triggers.

The Python Streaming & Capture Code

The following script targets the Pi 5 8GB board. It initializes picamera2, spins up a threaded HTTP server to serve an MJPEG stream on port 8080, and uses gpiozero to listen for the AM312 interrupt. When motion is detected, it captures a full-resolution still image.

import io
import time
import logging
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from picamera2 import Picamera2
from picamera2.encoders import JpegEncoder
from picamera2.outputs import FileOutput
from gpiozero import MotionSensor

# --- Configuration & Pin Definitions ---
PIR_GPIO_PIN = 17
STREAM_PORT = 8080
SAVE_DIR = "/home/pi/captures/"

# Setup Logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# Initialize Camera
camera = Picamera2()
# Configure for 720p streaming to save ISP bandwidth, but keep full-res available for snapshots
video_config = camera.create_video_configuration(main={"size": (1280, 720), "format": "RGB888"})
camera.configure(video_config)

# Initialize PIR Sensor
# bounce_time prevents multiple triggers from a single person walking by
pir = MotionSensor(PIR_GPIO_PIN, bounce_time=3.0)

class StreamingHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/stream.mjpg':
            self.send_response(200)
            self.send_header('Age', 0)
            self.send_header('Cache-Control', 'no-cache, private')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME')
            self.end_headers()
            try:
                while True:
                    # Grab frame from the main stream buffer
                    frame = camera.capture_array()
                    # Encode to JPEG in memory
                    import cv2
                    _, jpeg = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
                    
                    self.wfile.write(b'--FRAME\r\n')
                    self.send_header('Content-Type', 'image/jpeg')
                    self.send_header('Content-Length', len(jpeg))
                    self.end_headers()
                    self.wfile.write(jpeg)
                    self.wfile.write(b'\r\n')
                    time.sleep(0.05) # ~20 FPS cap
            except Exception as e:
                logging.warning(f"Stream client disconnected: {e}")
        else:
            self.send_error(404)
            self.end_headers()

class StreamingServer(HTTPServer):
    allow_reuse_address = True
    daemon_threads = True

def motion_detected_callback():
    timestamp = time.strftime("%Y%m%d-%H%M%S")
    filename = f"{SAVE_DIR}motion_{timestamp}.jpg"
    logging.info(f"Motion detected! Saving full-res snapshot to {filename}")
    try:
        # Switch to full-res capture config temporarily
        full_config = camera.create_still_configuration(main={"size": (4608, 2592)})
        camera.switch_mode_and_capture_file(filename, full_config)
        # Switch back to video config for streaming
        camera.configure(video_config)
    except Exception as e:
        logging.error(f"Failed to save snapshot: {e}")

if __name__ == '__main__':
    import os
    import cv2
    os.makedirs(SAVE_DIR, exist_ok=True)
    
    try:
        camera.start()
        logging.info(f"Camera started. Streaming on http://0.0.0.0:{STREAM_PORT}/stream.mjpg")
        
        # Attach PIR callback
        pir.when_motion = motion_detected_callback
        
        # Start HTTP Server
        server = StreamingServer(('', STREAM_PORT), StreamingHandler)
        server_thread = threading.Thread(target=server.serve_forever)
        server_thread.daemon = True
        server_thread.start()
        
        # Keep main thread alive
        while True:
            time.sleep(1)
            
    except KeyboardInterrupt:
        logging.info("Shutting down...")
    except Exception as e:
        logging.critical(f"Fatal error: {e}")
    finally:
        server.shutdown()
        camera.stop()
💡 Pro-Tip: OpenCV Dependency
The script uses cv2 (OpenCV) for rapid in-memory JPEG encoding of the stream buffer. Install it via sudo apt install python3-opencv. Do not use pip install opencv-python on the Pi 5, as compiling from source wheels will take hours and often fail due to missing build dependencies.

Debugging: When the Camera Fails to Initialize

The most common point of failure when building an IP camera on Raspberry Pi 5 hardware is the physical layer and the libcamera I2C probe. If your script crashes on startup, you will likely see this exact error string in your terminal:

[0:02:15.123456789] ERROR Camera camera.cpp:1023 *** no cameras available ***

This is a generic catch-all error from libcamera meaning the ISP cannot communicate with the sensor's I2C address. Here is the ranked cause list and how to fix it:

The First 3 Things to Check

  1. Verify the FPC Cable Adapter (Physical Layer): Did you use a 22-pin to 15-pin adapter cable? Is the blue tape facing the correct direction? On the Pi 5 MIPI connector, the metal contacts must face inward toward the board, and the connector latch must be fully depressed. If the cable is seated at a slight angle, the I2C clock line (SCL) will fail to connect.
  2. Check the I2C Probe via dmesg: Run dmesg | grep -i imx708. If the camera is physically connected properly, you will see imx708: probed. If you see imx708: probe failed, the sensor is receiving power but the I2C data line is broken or pulled low. Reseat the ribbon cable.
  3. Check for Power Brownouts: The Pi 5 requires a 27W (5V/5A) USB-C PD power supply. If you are using an old 15W Pi 4 phone charger, the 5V rail will droop when the camera ISP powers up, causing the sensor to reset. Run vcgencmd get_throttled. If it returns 0x50000, you have an active or historical under-voltage event. Buy the official 27W Pi PSU.

Extending and Simplifying the Build

This architecture is modular. Depending on your deployment environment, you should scale the hardware and software accordingly.

How to Simplify (The Low-Power Remote Node)

If you are deploying this IP camera on a battery or solar setup, the Pi 5 is the wrong tool. It idles at ~2.5W.
The Fix: Downgrade to the Raspberry Pi Zero 2 W.
Code Adjustment: The Zero 2 W only has 512MB of RAM. You must reduce the MJPEG stream resolution to 640x480 in the Python script, and disable the full-res 12MP snapshot feature, opting instead to just save the 640x480 stream frame to disk when the PIR triggers. The Pi Zero 2 W uses the older 22-pin CSI connector natively, so you do not need the 22-to-15-pin adapter cable.

How to Extend (AI Object Detection & NVR Integration)

A raw MJPEG stream is fine for viewing in a browser, but it won't integrate with modern Network Video Recorder (NVR) software like Frigate or Blue Iris, which expect RTSP (Real-Time Streaming Protocol) and H.264/H.265 compression.
The Fix: Ditch the Python HTTP server entirely. Use the Pi 5's hardware H.264 encoder via libcamera-vid and pipe it into go2rtc or MediaMTX.

# Run this in a systemd service instead of the Python script
libcamera-vid -t 0 --inline --width 1920 --height 1080 --framerate 15 --codec mjpeg -o - | mediamtx -

This offloads the encoding to the Pi 5's dedicated hardware block, dropping CPU usage to near zero and outputting a standard RTSP stream on port 8554 that any NVR can ingest. You can then run the Frigate NVR Docker container on a separate desktop PC to handle the heavy YOLO object detection, using the Pi 5 strictly as a dumb, low-power camera node.

For deeper documentation on the underlying camera stack, refer to the official Raspberry Pi Camera Software Guide and the Picamera2 GitHub repository for the latest API changes and buffer management techniques.