Project Spec Sheet & Difficulty Rating
Streaming video from a Raspberry Pi to a browser used to mean relying on the legacy raspivid stack or wrestling with heavy WebRTC libraries. Today, the picamera2 Python library leverages the underlying libcamera framework to pull hardware-accelerated MJPEG streams directly from the ISP (Image Signal Processor). This project builds a custom, low-latency web interface for live monitoring, complete with a hardware status LED and a physical shutter button to capture stills while streaming.
Difficulty: Intermediate (Requires basic Linux CLI and Python threading knowledge)
Time to Build: 45 minutes
Estimated Cost: $105 - $125 (Board + Camera + accessories)
Target Board Variant: Raspberry Pi 5 (4GB or 8GB RAM) running Raspberry Pi OS (64-bit, Bookworm or later). Note: The code will run on a Pi 4 Model B, but the Pi 5's dedicated MIPI CSI-2 lanes yield significantly lower latency and higher framerates.
Hardware BOM and CSI Pin Mapping
Before writing code, we need to ensure the physical layer is correct. The Camera Module 3 uses a standard 15-pin Flat Flexible Cable (FFC). On the Raspberry Pi 5, the CSI connector is located near the USB-C power input. Ensure the cable's blue tape (or silver contacts, depending on the manufacturer) faces the correct direction: contacts face inward toward the board's center on both the Pi and the camera module.
Parts List
| Component | Exact Variant / Model | Notes |
|---|---|---|
| Microcontroller | Raspberry Pi 5 (4GB RAM) | Requires 27W USB-C PD power supply for full peripheral current. |
| Camera Module | Raspberry Pi Camera Module 3 (IMX708) | 12MP, supports PDAF (Phase Detection Auto Focus). |
| Ribbon Cable | 15-pin to 15-pin CSI FFC (200mm) | Use the 15-pin cable, not the 22-pin display cable. |
| Status LED | 5mm Red LED + 330Ω Resistor | Wired to GPIO 17 to indicate active streaming. |
| Shutter Button | 6x6mm Tactile Pushbutton | Wired to GPIO 27 for hardware-triggered still capture. |
15-Pin MIPI CSI-2 FFC Pinout
Understanding the physical pins helps when debugging I2C (CCI) communication failures between the Pi and the camera's onboard EEPROM.
| Pin | Function | Description |
|---|---|---|
| 1 | GND | Ground |
| 2 | CAM_D0_N | MIPI Data Lane 0 (Negative) |
| 3 | CAM_D0_P | MIPI Data Lane 0 (Positive) |
| 4 | GND | Ground |
| 5 | CAM_D1_N | MIPI Data Lane 1 (Negative) |
| 6 | CAM_D1_P | MIPI Data Lane 1 (Positive) |
| 7 | GND | Ground |
| 8 | CAM_CLK_N | MIPI Clock Lane (Negative) |
| 9 | CAM_CLK_P | MIPI Clock Lane (Positive) |
| 10 | GND | Ground |
| 11 | CAM_IOVDD | 1.8V I/O Power (Do not connect external voltage) |
| 12 | CAM_SDA | I2C Data (CCI) for camera config |
| 13 | CAM_SCL | I2C Clock (CCI) for camera config |
| 14 | CAM_GPIO | Camera Reset / Power Down control |
| 15 | GND | Ground |
Software Setup & Compilable Flask Code
We will use a threaded HTTP server to serve the MJPEG stream, which is significantly more performant on the Pi 5 than routing raw image bytes through a Flask development server. We will also use the RPi.GPIO library to manage our physical status LED and shutter button.
Step 1: OS Preparation
Do not use pip install picamera2 on standard Raspberry Pi OS; it often fails to compile the underlying C++ bindings. Instead, use the OS package manager to ensure the libcamera dependencies are met.
- Update your system:
sudo apt update && sudo apt upgrade -y - Install the library and GPIO tools:
sudo apt install python3-picamera2 python3-rpi.gpio python3-libcamera -y - Verify the camera is detected at the hardware level:
libcamera-hello --list-cameras. You should see/base/i2c@88000/imx708.
Step 2: The Python Web Server Script
Save the following code as camera_server.py. This script initializes the camera, starts a background thread to encode MJPEG frames, serves a minimal HTML wrapper, and handles hardware interrupts for the shutter button.
import io
import time
import logging
import socketserver
import threading
from http import server
from threading import Condition
import RPi.GPIO as GPIO
from picamera2 import Picamera2
from picamera2.encoders import JpegEncoder
from picamera2.outputs import FileOutput
# --- PIN DEFINITIONS ---
STATUS_LED_PIN = 17 # BCM 17 (Physical Pin 11)
SHUTTER_BTN_PIN = 27 # BCM 27 (Physical Pin 13)
# --- GPIO SETUP ---
GPIO.setmode(GPIO.BCM)
GPIO.setup(STATUS_LED_PIN, GPIO.OUT, initial=GPIO.LOW)
GPIO.setup(SHUTTER_BTN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
class StreamingOutput(io.BufferedIOBase):
def __init__(self):
self.frame = None
self.condition = Condition()
def write(self, buf):
with self.condition:
self.frame = buf
self.condition.notify_all()
class StreamingHandler(server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/':
self.send_response(301)
self.send_header('Location', '/index.html')
self.end_headers()
elif self.path == '/index.html':
content = PAGE.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.send_header('Content-Length', len(content))
self.end_headers()
self.wfile.write(content)
elif self.path == '/stream.mjpg':
self.send_response(200)
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:
with output.condition:
output.condition.wait()
frame = output.frame
self.wfile.write(b'--FRAME\n')
self.send_header('Content-Type', 'image/jpeg')
self.send_header('Content-Length', len(frame))
self.end_headers()
self.wfile.write(frame)
self.wfile.write(b'\n')
except Exception as e:
logging.warning(f'Removed streaming client {self.client_address}: {e}')
else:
self.send_error(404)
self.end_headers()
class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
# --- HTML INTERFACE ---
PAGE = """\
<html>
<head>
<title>Pi5 Camera Web Interface</title>
<style>
body { margin: 0; background: #121212; display: flex; justify-content: center; align-items: center; height: 100vh; }
img { max-width: 95vw; max-height: 90vh; border: 2px solid #333; border-radius: 8px; }
</style>
</head>
<body>
<img src="stream.mjpg">
</body>
</html>
"""
def shutter_callback(channel):
"""Hardware button debounce and still capture"""
time.sleep(0.05) # Debounce
if GPIO.input(SHUTTER_BTN_PIN) == GPIO.LOW:
logging.info("Shutter pressed. Capturing still...")
try:
picam2.capture_file(f"/home/pi/still_{int(time.time())}.jpg")
logging.info("Still captured successfully.")
except Exception as e:
logging.error(f"Capture failed: {e}")
if __name__ == '__main__':
output = StreamingOutput()
picam2 = None
httpd = None
try:
# Initialize Camera
picam2 = Picamera2()
video_config = picam2.create_video_configuration(main={"size": (1280, 720), "format": "RGB888"})
picam2.configure(video_config)
# Attach GPIO Interrupt
GPIO.add_event_detect(SHUTTER_BTN_PIN, GPIO.FALLING, callback=shutter_callback, bouncetime=200)
# Start Stream
picam2.start_recording(JpegEncoder(), FileOutput(output))
GPIO.output(STATUS_LED_PIN, GPIO.HIGH) # LED ON
# Start Web Server
address = ('', 8000)
httpd = StreamingServer(address, StreamingHandler)
logging.basicConfig(level=logging.INFO)
logging.info("Server started on http://[PI_IP_ADDRESS]:8000")
httpd.serve_forever()
except RuntimeError as e:
logging.critical(f"Camera Initialization Failed: {e}")
except KeyboardInterrupt:
logging.info("Shutting down gracefully...")
finally:
if picam2:
picam2.stop_recording()
picam2.close()
if httpd:
httpd.server_close()
GPIO.output(STATUS_LED_PIN, GPIO.LOW)
GPIO.cleanup()
logging.info("Resources released.")
Debugging: "Camera Not Found" and Stream Failures
When building embedded vision systems, hardware and software boundaries blur. If your script fails, check these first three things before rewriting code:
- Physical Seating: The FFC cable must be fully inserted. If the latch is closed but the cable is crooked by even 1mm, the I2C CCI pins (12/13) will fail to read the camera EEPROM, resulting in a "no cameras available" error.
- Legacy Stack Interference: Run
sudo raspi-config, navigate to Interface Options > Legacy Camera, and ensure it is Disabled. The legacy stack locks the/dev/video0node and blockslibcamera. - Power Brownouts: The Pi 5 and Camera Module 3 can spike to 3A+ during ISP initialization. If you are using a standard 15W phone charger, the Pi will throttle or drop the camera peripheral. Use the official 27W USB-C PD supply.
Common Error Strings and Ranked Causes
RuntimeError: Failed to open camera or libcamera.ERROR: *** no cameras available ***Ranked Causes:
1. Ribbon cable inserted backwards or not fully seated.
2. Legacy camera stack is enabled in
raspi-config.3. The I2C bus is locked by another process (run
sudo fuser -v /dev/video0 to check).
OSError: [Errno 98] Address already in use on port 8000.Ranked Causes:
1. A previous instance of the script crashed without releasing the socket. Fix: Run
sudo lsof -i :8000 and kill -9 [PID].2. Another service (like a default Flask app or OctoPrint) is bound to port 8000. Change the
address tuple in the code to 8080.
MemoryError: Failed to allocate buffersRanked Causes:
1. Requesting a resolution higher than the Pi's contiguous memory allocation (CMA). On a 4GB Pi 5, 1280x720 is safe. If you request 4K (4608x2592), you must increase the CMA allocation in
/boot/firmware/config.txt by adding dtoverlay=vc4-kms-v3d,cma-512.
Extending and Simplifying the Build
Depending on your end goal, you may want to scale this project up or strip it down.
How to Extend: Adding Pan/Tilt (PTZ)
To add physical movement, wire a PCA9685 I2C PWM servo driver to the Pi 5's GPIO 2 (SDA) and GPIO 3 (SCL). Because the Camera Module 3 uses a dedicated I2C bus (the CCI pins on the CSI ribbon) for its internal configuration, the primary hardware I2C bus on the 40-pin header remains completely free for servos and sensors. You can integrate the adafruit-circuitpython-pca9685 library into the shutter_callback or add HTML buttons to the PAGE string that trigger Flask API endpoints to move the servos.
How to Simplify: Dropping Python Entirely
If you don't need custom GPIO control or a bespoke HTML wrapper, you don't need Python at all. The rpicam-apps suite includes a pre-compiled C++ MJPEG streamer. Simply run:
rpicam-mjpeg --width 1280 --height 720 --framerate 30 --timeout 0 --listen -o tcp://0.0.0.0:8000
This pushes the encoding entirely to the Pi's hardware video core, dropping CPU usage to near zero, though you lose the ability to easily inject custom web UI elements or hardware button logic.
Raspberry Pi Camera Web Interface FAQ
How do I secure my Raspberry Pi camera web interface from outside access?
The raw HTTP server used in this script broadcasts in plain text with no authentication. To secure it for remote viewing over the internet, do not expose port 8000 directly via port forwarding. Instead, use a reverse proxy like Nginx with Basic Auth, or tunnel the interface through a secure overlay network like Tailscale or Cloudflare Tunnels. This keeps the stream encrypted via HTTPS and restricts access to your authorized devices without adding heavy authentication logic to the Python script itself.
Why is my Raspberry Pi camera web interface lagging over Wi-Fi?
MJPEG streams send full JPEG frames rather than calculating inter-frame deltas (like H.264). At 1080p and 30fps, an MJPEG stream can easily saturate a 2.4GHz Wi-Fi connection, causing buffer bloat and multi-second latency. To fix this: (1) Force the Pi 5 onto a 5GHz Wi-Fi network, (2) drop the resolution to 720p, or (3) switch the encoder from JpegEncoder to H264Encoder and use a WebRTC frontend (like aiortc) to handle the compressed video stream, which reduces bandwidth requirements by roughly 80%.
Can I use the legacy picamera library instead of picamera2 for my web stream?
No. The original picamera library relies on the deprecated MMAL (Multi-Media Abstraction Layer) stack, which is entirely disabled by default on Raspberry Pi OS Bookworm and is not supported on the Raspberry Pi 5's new RP1 I/O chip. You must use picamera2, which interfaces directly with the modern libcamera API. For more details on the architectural shift, refer to the official Raspberry Pi Camera Software documentation.






