From 6faf505f4b962cae3a4d463cfb17743ad7010e66 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 23 May 2026 10:39:31 +0200 Subject: [PATCH] Add PiCam network streamer with auto-setup for Pi 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial commit of the MJPEG streaming server for Raspberry Pi. Supports Pi Camera Module (via picamera2), USB webcams (via OpenCV), and auto-detection of camera type. Includes: - `stream_picamera2.py` — optimized Pi CSI camera streamer - `stream_usb.py` — generic USB camera streamer - `stream.py` — auto-detects camera and launches correct streamer - `image-setup/` — SD card imaging scripts and Pi 5 headless setup guide for first-boot automation with systemd service - `requirements.txt` — Python dependencies --- README.md | 81 +++++++++++++++++ image-setup/SETUP_GUIDE.md | 121 +++++++++++++++++++++++++ image-setup/autosetup.sh | 98 +++++++++++++++++++++ image-setup/config_camera.txt | 11 +++ image-setup/firstrun.sh | 20 +++++ image-setup/setup-pi.sh | 110 +++++++++++++++++++++++ requirements.txt | 3 + stream.py | 32 +++++++ stream_picamera2.py | 141 +++++++++++++++++++++++++++++ stream_usb.py | 161 ++++++++++++++++++++++++++++++++++ 10 files changed, 778 insertions(+) create mode 100644 README.md create mode 100644 image-setup/SETUP_GUIDE.md create mode 100644 image-setup/autosetup.sh create mode 100644 image-setup/config_camera.txt create mode 100644 image-setup/firstrun.sh create mode 100644 image-setup/setup-pi.sh create mode 100644 requirements.txt create mode 100644 stream.py create mode 100644 stream_picamera2.py create mode 100644 stream_usb.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1f6c39 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# PiCam Network Streamer + +A lightweight MJPEG streaming server for Raspberry Pi. It serves a webpage you can open on any device on your local network to view the live camera feed. + +## Pi 5 OS Image Setup + +For a fully automated first-boot setup on a **Raspberry Pi 5** with an **IMX219** camera, see the [`image-setup/`](image-setup/) directory. It includes: +- `SETUP_GUIDE.md` — Step-by-step SD card imaging instructions. +- `autosetup.sh` — One-command setup script (installs drivers, dependencies, and auto-starts the streamer as a systemd service). +- `config_camera.txt` — `config.txt` snippet to force-enable the IMX219 overlay. + +## Quick Start + +1. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the streamer** + + *Auto-detect (recommended):* + ```bash + python stream.py + ``` + + *For the official Raspberry Pi Camera Module (CSI):* + ```bash + python stream_picamera2.py + ``` + + *For a USB webcam:* + ```bash + python stream_usb.py + ``` + +3. **View the stream** + Open a browser on any device on the same network and go to: + ``` + http://:5000 + ``` + Replace `` with your Pi's IP address. + +## Options + +Both scripts accept the same command-line arguments: + +| Argument | Description | Default | +|----------|-------------|---------| +| `--host` | Network interface to bind to | `0.0.0.0` | +| `--port` | Port to serve on | `5000` | +| `--width` | Video width in pixels | `640` | +| `--height` | Video height in pixels | `480` | +| `--fps` | Target framerate | `30` | + +*USB streamer only:* +| `--device` | Video device index (e.g., `0` for `/dev/video0`) | `0` | + +### Examples + +Run at 720p on port 8080: +```bash +python stream_picamera2.py --width 1280 --height 720 --port 8080 +``` + +Use a second USB camera: +```bash +python stream_usb.py --device 1 +``` + +## Files + +- `stream_picamera2.py` — Optimized streamer for Raspberry Pi Camera Module v2/v3 using `picamera2`. +- `stream_usb.py` — Generic streamer for USB cameras using OpenCV. +- `stream.py` — Auto-detects camera type and launches the correct streamer. +- `requirements.txt` — Python dependencies. +- `image-setup/` — Scripts and guides for preparing a headless Pi 5 OS image with IMX219 support. + - `SETUP_GUIDE.md` — Full imaging and setup instructions. + - `setup-pi.sh` — Run this on a live Pi to install everything and auto-start the streamer. + - `autosetup.sh` — For embedding into a custom SD card image (runs on first boot). + - `firstrun.sh` — Raspberry Pi first-boot trigger script. + - `config_camera.txt` — Snippet to enable the IMX219 overlay. diff --git a/image-setup/SETUP_GUIDE.md b/image-setup/SETUP_GUIDE.md new file mode 100644 index 0000000..be0b61c --- /dev/null +++ b/image-setup/SETUP_GUIDE.md @@ -0,0 +1,121 @@ +# Pi 5 + IMX219 OS Image Setup Guide + +This guide walks you through setting up a **Raspberry Pi 5** with an **IMX219** camera module (Camera Module v2) to run the PiCam streamer. + +## Option A: Easy Method — Run Setup on a Live Pi (Recommended) + +If you already have Raspberry Pi OS running on your Pi 5: + +1. **Copy the project** to your Pi (via USB drive, `scp`, or `git clone`). +2. **Run the setup script:** + ```bash + cd piCam/image-setup + chmod +x setup-pi.sh + ./setup-pi.sh + ``` +3. **Done.** The streamer will start automatically and will auto-start on every boot. + +Open `http://:5000` in your browser. + +--- + +## Option B: Prepare an SD Card Image from Scratch + +### 1. Download Raspberry Pi OS + +Download the latest **Raspberry Pi OS (64-bit)** from the official site: + +``` +https://www.raspberrypi.com/software/operating-systems/ +``` + +Recommended: **Raspberry Pi OS with Desktop** or **Lite** if you want a headless server. + +### 2. Flash the OS to SD Card + +Use the official **Raspberry Pi Imager**: + +``` +https://www.raspberrypi.com/software/ +``` + +Steps in Imager: +1. Choose OS → Raspberry Pi OS (64-bit) +2. Choose Storage → Your SD card +3. Click the **gear icon (⚙️)** to open Advanced Options and configure: + - **Hostname**: `picam` (or your preference) + - **Enable SSH**: ☑️ (Use password authentication or public key) + - **Set username/password**: `pi` / your-password + - **Configure Wi-Fi**: Enter your SSID and password + - **Set locale**: Your timezone and keyboard layout +4. Click **Write** + +### 3. Enable Camera Hardware + +After flashing, the SD card will show a `bootfs` partition. You need to ensure camera support is enabled. + +#### For Raspberry Pi OS Bookworm and later: + +The IMX219 is usually auto-detected. However, if you need to force it, open `bootfs/config.txt` and add to the end: + +```ini +# IMX219 Camera Module v2 +dtoverlay=imx219 +``` + +> **Note:** On Pi 5, the camera uses the CSI/DSI connector. Make sure the flat cable is oriented correctly (blue tape facing away from the USB ports on Pi 5). + +### 4. Copy the Project Files + +Copy the contents of this `image-setup` folder to the SD card: + +```bash +# Linux/macOS example (adjust paths as needed) +cp -r /path/to/piCam/image-setup/* /media/YOUR_USERNAME/bootfs/ +``` + +Specifically, ensure these files are placed on the `bootfs` partition: +- `autosetup.sh` → root of `bootfs/` +- `firstrun.sh` → root of `bootfs/` + +### 5. Boot the Pi + +Insert the SD card into the Pi 5, connect power, and wait 2-3 minutes for the first-boot setup to complete. + +### 6. Access the Stream + +Once booted, find the Pi on your network: +```bash +ping picam.local +``` + +Then open in a browser: +``` +http://picam.local:5000 +``` + +--- + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| Camera not detected | Run `libcamera-hello --list-cameras` to verify hardware detection | +| Green tint on image | Normal under some lighting; adjust AWB in picamera2 config | +| Low framerate | Reduce resolution or ensure GPU memory isn't too low | +| `picamera2` not found | Run `sudo apt install -y python3-picamera2` | + +## Automated First-Boot Setup + +If you copied the provided files to `bootfs`, the system will: +1. Expand the filesystem +2. Update all packages +3. Install camera drivers, Python dependencies, and Flask +4. Enable the camera interface +5. Auto-start the streaming server on boot + +Monitor progress by SSHing in: +```bash +ssh pi@picam.local +tail -f /var/log/picam-setup.log +``` diff --git a/image-setup/autosetup.sh b/image-setup/autosetup.sh new file mode 100644 index 0000000..8666184 --- /dev/null +++ b/image-setup/autosetup.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# PiCam First-Boot Setup Script +# Run this on the Raspberry Pi after first boot to configure the IMX219 camera +# and install the streaming server. + +set -e + +LOGFILE="/var/log/picam-setup.log" +PROJECT_DIR="/home/pi/piCam" +REPO_URL="" # Leave empty if copying files manually to the SD card + +exec > >(tee -a "$LOGFILE") +exec 2>&1 + +echo "========================================" +echo "PiCam First-Boot Setup Starting" +echo "Date: $(date)" +echo "========================================" + +# Update system +echo "[1/6] Updating package lists..." +sudo apt-get update + +echo "[2/6] Installing system dependencies..." +sudo apt-get install -y \ + python3-pip \ + python3-venv \ + python3-picamera2 \ + libcamera-dev \ + libcap-dev \ + git \ + i2c-tools \ + v4l-utils + +# Ensure camera interface is enabled (legacy method, though Bookworm uses libcamera) +echo "[3/6] Ensuring camera interface is enabled..." +if command -v raspi-config &> /dev/null; then + sudo raspi-config nonint do_camera 0 || true +fi + +# Create project directory +echo "[4/6] Setting up project directory..." +mkdir -p "$PROJECT_DIR" + +# If files are already present (e.g., copied to SD card), use them. +# Otherwise, clone or create a placeholder. +if [ ! -f "$PROJECT_DIR/stream_picamera2.py" ]; then + echo "Stream scripts not found in $PROJECT_DIR." + echo "Please copy the project files manually:" + echo " scp -r /path/to/piCam/* pi@picam.local:/home/pi/piCam/" +fi + +# Setup Python virtual environment +echo "[5/6] Creating Python virtual environment..." +python3 -m venv "$PROJECT_DIR/venv" +source "$PROJECT_DIR/venv/bin/activate" + +# Install Python dependencies +echo "[6/6] Installing Python packages..." +pip install --upgrade pip +pip install Flask opencv-python-headless + +# Install systemd service for auto-start +echo "Installing systemd service..." +cat < >(tee -a "$LOGFILE") +exec 2>&1 + +echo "============================================" +echo " PiCam Setup Script" +echo " Running on: $(hostname)" +echo " Date: $(date)" +echo "============================================" + +# 1. System update +echo "[1/7] Updating package lists..." +sudo apt-get update + +# 2. Install system packages +echo "[2/7] Installing system packages..." +sudo apt-get install -y \ + python3-pip \ + python3-venv \ + python3-picamera2 \ + libcamera-dev \ + libcap-dev \ + git \ + i2c-tools \ + v4l-utils + +# 3. Detect camera +echo "[3/7] Detecting camera..." +if command -v libcamera-hello >/dev/null 2>&1; then + echo "libcamera devices:" + libcamera-hello --list-cameras || true +else + echo "libcamera-hello not found; relying on picamera2 detection." +fi + +# 4. Copy project files +echo "[4/7] Installing project files..." +mkdir -p "$PROJECT_DIR" + +if [ -d "$SCRIPT_DIR/../" ] && [ -f "$SCRIPT_DIR/../stream_picamera2.py" ]; then + # Running from cloned repo + cp "$SCRIPT_DIR/../stream_picamera2.py" "$PROJECT_DIR/" + cp "$SCRIPT_DIR/../stream_usb.py" "$PROJECT_DIR/" + cp "$SCRIPT_DIR/../stream.py" "$PROJECT_DIR/" + cp "$SCRIPT_DIR/../requirements.txt" "$PROJECT_DIR/" +else + echo "WARNING: Could not find project Python files." + echo "Please copy them manually to $PROJECT_DIR" +fi + +# 5. Python environment +echo "[5/7] Creating Python virtual environment..." +python3 -m venv "$PROJECT_DIR/venv" +source "$PROJECT_DIR/venv/bin/activate" + +pip install --upgrade pip +pip install Flask opencv-python-headless + +# 6. Create systemd service +echo "[6/7] Creating systemd service..." +cat <=2.0.0 +opencv-python-headless>=4.5.0 +picamera2>=0.3.9; platform_machine=='aarch64' or platform_machine=='armv7l' diff --git a/stream.py b/stream.py new file mode 100644 index 0000000..14b4d8f --- /dev/null +++ b/stream.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Auto-detect and start the best available streamer.""" + +import importlib.util +import subprocess +import sys + + +def main(): + # Check if picamera2 is available and a Pi camera is connected + if importlib.util.find_spec("picamera2") is not None: + try: + from picamera2 import Picamera2 + + # Attempt to create a camera to see if hardware is present + cam = Picamera2() + cam.close() + print("Detected Raspberry Pi camera. Starting picamera2 streamer...") + subprocess.run([sys.executable, "stream_picamera2.py"] + sys.argv[1:]) + return + except Exception as e: + print(f"picamera2 available but camera not detected ({e}).") + else: + print("picamera2 not installed.") + + # Fall back to USB / OpenCV + print("Falling back to USB camera streamer...") + subprocess.run([sys.executable, "stream_usb.py"] + sys.argv[1:]) + + +if __name__ == "__main__": + main() diff --git a/stream_picamera2.py b/stream_picamera2.py new file mode 100644 index 0000000..ec45988 --- /dev/null +++ b/stream_picamera2.py @@ -0,0 +1,141 @@ +from flask import Flask, Response, render_template_string +from picamera2 import Picamera2 +import io +import logging +import threading +import time + +app = Flask(__name__) + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + +class Camera: + def __init__(self, resolution=(640, 480), framerate=30): + self.resolution = resolution + self.framerate = framerate + self.picam2 = None + self.frame = None + self.lock = threading.Lock() + self.running = False + self.thread = None + + def start(self): + self.picam2 = Picamera2() + config = self.picam2.create_video_configuration( + main={"size": self.resolution, "format": "RGB888"}, + controls={"FrameRate": self.framerate} + ) + self.picam2.configure(config) + self.picam2.start() + # Allow camera to warm up + time.sleep(2) + self.running = True + self.thread = threading.Thread(target=self._capture, daemon=True) + self.thread.start() + logging.info("Camera started.") + + def _capture(self): + while self.running: + try: + arr = self.picam2.capture_array() + # Encode as JPEG + import cv2 + ret, buf = cv2.imencode('.jpg', arr) + if ret: + with self.lock: + self.frame = buf.tobytes() + time.sleep(0.001) # Brief sleep to yield + except Exception as e: + logging.error(f"Capture error: {e}") + time.sleep(0.1) + + def get_frame(self): + with self.lock: + return self.frame + + def stop(self): + self.running = False + if self.thread: + self.thread.join(timeout=2.0) + if self.picam2: + self.picam2.stop() + self.picam2.close() + logging.info("Camera stopped.") + +camera = None + +HTML_PAGE = """ + + + + + + PiCam Stream + + + +

PiCam Live Stream

+

{{ resolution }} @ {{ framerate }} fps

+ Live stream + + +""" + +@app.route('/') +def index(): + return render_template_string(HTML_PAGE, resolution=camera.resolution, framerate=camera.framerate) + +@app.route('/video_feed') +def video_feed(): + def generate(): + while True: + frame = camera.get_frame() + if frame is None: + time.sleep(0.01) + continue + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame') + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser(description='PiCam MJPEG Streamer using picamera2') + parser.add_argument('--host', default='0.0.0.0', help='Interface to bind (default: 0.0.0.0)') + parser.add_argument('--port', type=int, default=5000, help='Port to bind (default: 5000)') + parser.add_argument('--width', type=int, default=640, help='Capture width') + parser.add_argument('--height', type=int, default=480, help='Capture height') + parser.add_argument('--fps', type=int, default=30, help='Target framerate') + args = parser.parse_args() + + camera = Camera(resolution=(args.width, args.height), framerate=args.fps) + camera.start() + + try: + logging.info(f"Starting server on http://{args.host}:{args.port}") + app.run(host=args.host, port=args.port, threaded=True, debug=False) + except KeyboardInterrupt: + pass + finally: + camera.stop() diff --git a/stream_usb.py b/stream_usb.py new file mode 100644 index 0000000..051cde2 --- /dev/null +++ b/stream_usb.py @@ -0,0 +1,161 @@ +import logging +import threading +import time + +import cv2 +from flask import Flask, Response, render_template_string + +app = Flask(__name__) + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + + +class UsbCamera: + def __init__(self, device_index=0, resolution=(640, 480), framerate=30): + self.device_index = device_index + self.resolution = resolution + self.framerate = framerate + self.cap = None + self.frame = None + self.lock = threading.Lock() + self.running = False + self.thread = None + + def start(self): + self.cap = cv2.VideoCapture(self.device_index) + if not self.cap.isOpened(): + raise RuntimeError(f"Cannot open camera device {self.device_index}") + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.resolution[0]) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.resolution[1]) + self.cap.set(cv2.CAP_PROP_FPS, self.framerate) + # Give the driver a moment to apply settings + time.sleep(0.5) + self.running = True + self.thread = threading.Thread(target=self._capture, daemon=True) + self.thread.start() + logging.info("USB camera started.") + + def _capture(self): + while self.running: + ret, img = self.cap.read() + if not ret: + logging.warning("Failed to read frame from camera.") + time.sleep(0.05) + continue + ret2, buf = cv2.imencode(".jpg", img) + if ret2: + with self.lock: + self.frame = buf.tobytes() + # Throttle slightly to avoid maxing CPU + time.sleep(1.0 / max(self.framerate, 1)) + + def get_frame(self): + with self.lock: + return self.frame + + def stop(self): + self.running = False + if self.thread: + self.thread.join(timeout=2.0) + if self.cap: + self.cap.release() + logging.info("USB camera stopped.") + + +camera = None + +HTML_PAGE = """ + + + + + + PiCam Stream + + + +

PiCam Live Stream

+

{{ resolution }} @ {{ framerate }} fps

+ Live stream + + +""" + + +@app.route("/") +def index(): + return render_template_string( + HTML_PAGE, resolution=camera.resolution, framerate=camera.framerate + ) + + +@app.route("/video_feed") +def video_feed(): + def generate(): + while True: + frame = camera.get_frame() + if frame is None: + time.sleep(0.01) + continue + yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n") + + return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser( + description="PiCam MJPEG Streamer using OpenCV (USB camera)" + ) + parser.add_argument( + "--host", default="0.0.0.0", help="Interface to bind (default: 0.0.0.0)" + ) + parser.add_argument( + "--port", type=int, default=5000, help="Port to bind (default: 5000)" + ) + parser.add_argument( + "--device", type=int, default=0, help="Video device index (default: 0)" + ) + parser.add_argument("--width", type=int, default=640, help="Capture width") + parser.add_argument("--height", type=int, default=480, help="Capture height") + parser.add_argument("--fps", type=int, default=30, help="Target framerate") + args = parser.parse_args() + + camera = UsbCamera( + device_index=args.device, + resolution=(args.width, args.height), + framerate=args.fps, + ) + camera.start() + + try: + logging.info(f"Starting server on http://{args.host}:{args.port}") + app.run(host=args.host, port=args.port, threaded=True, debug=False) + except KeyboardInterrupt: + pass + finally: + camera.stop()