#!/usr/bin/env python3 """ app.py – Raspberry Pi Camera MJPEG Stream Server Startet rpicam-vid als Singleton-Prozess und verteilt Frames per Flask. Erreichbar unter /stream und /stream.mjpg (vehicle-counter kompatibel). .env-Variablen: APP_HOST Bind-Adresse (default: 0.0.0.0) APP_PORT HTTP-Port (default: 8080) CAM_MODE Sensor-Modus (default: 1640:1232) CAM_WIDTH Ausgabebreite (default: 640) CAM_HEIGHT Ausgabehöhe (default: 480) CAM_FRAMERATE Ziel-FPS (default: 20) CAM_QUALITY JPEG-Qualität 1-100 (default: 30) CAM_ROTATION Rotation 0/90/180/270 (default: leer) """ import os import sys import time import signal import subprocess import threading from flask import Flask, Response, render_template_string try: from dotenv import load_dotenv load_dotenv() except ImportError: pass # ── Konfiguration ───────────────────────────────────────────────────────────── APP_HOST = os.getenv("APP_HOST", "0.0.0.0") APP_PORT = int(os.getenv("APP_PORT", "8080")) CAM_MODE = os.getenv("CAM_MODE", "1640:1232") CAM_WIDTH = os.getenv("CAM_WIDTH", "640") CAM_HEIGHT = os.getenv("CAM_HEIGHT", "480") CAM_FRAMERATE= os.getenv("CAM_FRAMERATE","20") CAM_QUALITY = os.getenv("CAM_QUALITY", "30") CAM_ROTATION = os.getenv("CAM_ROTATION","").strip() CAM_CODEC = "mjpeg" SOI = b"\xff\xd8" EOI = b"\xff\xd9" MAX_BUF = 2_000_000 # ── Singleton Camera Grabber ────────────────────────────────────────────────── class CameraGrabber: """ Startet rpicam-vid genau einmal. Frames werden per Condition an alle wartenden Viewer-Threads verteilt (Fan-Out). Bei Absturz: Auto-Restart. """ def __init__(self): self.frame = None self.seq = 0 self.lock = threading.Condition() self._interval = 1.0 / max(1, int(CAM_FRAMERATE)) t = threading.Thread(target=self._run, daemon=True) t.start() def _cmd(self): cmd = [ "rpicam-vid", "-t", "0", "-n", "--mode", CAM_MODE, "--width", CAM_WIDTH, "--height", CAM_HEIGHT, "--framerate", CAM_FRAMERATE, "--quality", CAM_QUALITY, "--codec", CAM_CODEC, "-o", "-", ] if CAM_ROTATION: cmd += ["--rotation", CAM_ROTATION] return cmd def _run(self): while True: cmd = self._cmd() print(f"[cam] Starte: {' '.join(cmd)}", flush=True) try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0, ) buf = b"" n_frames = 0 t_log = time.time() t_last = 0.0 while True: chunk = proc.stdout.read(4096) if not chunk: break buf += chunk if len(buf) > MAX_BUF: buf = buf[-MAX_BUF // 2:] # Alle vollständigen JPEGs im Buffer verarbeiten while True: s = buf.find(SOI) if s == -1: break e = buf.find(EOI, s + 2) if e == -1: break frame = buf[s:e + 2] buf = buf[e + 2:] # Rate-Limiter: überschüssige Frames überspringen now = time.time() if now - t_last < self._interval: continue t_last = now with self.lock: self.frame = frame self.seq += 1 self.lock.notify_all() n_frames += 1 if now - t_log >= 10: print( f"[cam] {n_frames / (now - t_log):.1f} fps " f"| {len(frame) / 1024:.1f} kB/frame", flush=True, ) n_frames = 0 t_log = now except Exception as exc: print(f"[cam] Fehler: {exc}", flush=True) finally: try: proc.terminate() proc.wait(timeout=3) except Exception: try: proc.kill() except Exception: pass print("[cam] Neustart in 2 s …", flush=True) time.sleep(2) def frames(self): """Generator: liefert JPEG-Bytes sobald ein neues Frame verfügbar ist.""" last_seq = -1 while True: with self.lock: ok = self.lock.wait_for( lambda: self.seq != last_seq, timeout=5, ) if not ok or self.frame is None: continue last_seq = self.seq yield self.frame # Globale Instanz – startet sofort beim Import grabber = CameraGrabber() # ── Flask ───────────────────────────────────────────────────────────────────── app = Flask(__name__) HTML = """\ Pi Camera

Pi Camera Stream

{{ width }}×{{ height }} @ {{ fps }} fps | Qualität {{ quality }}

""" def _mjpeg_response(): def gen(): for jpeg in grabber.frames(): yield ( b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n" ) return Response(gen(), mimetype="multipart/x-mixed-replace; boundary=frame") @app.route("/") def index(): return render_template_string( HTML, width=CAM_WIDTH, height=CAM_HEIGHT, fps=CAM_FRAMERATE, quality=CAM_QUALITY, ) # Beide Routen → gleicher Stream (vehicle-counter nutzt /stream.mjpg) @app.route("/stream") @app.route("/stream.mjpg") def stream(): return _mjpeg_response() # ── Start ───────────────────────────────────────────────────────────────────── def _shutdown(sig, frame): print("[cam] Beende …", flush=True) sys.exit(0) if __name__ == "__main__": signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) print(f"[cam] Webserver auf http://{APP_HOST}:{APP_PORT}", flush=True) print(f"[cam] Stream: http://{APP_HOST}:{APP_PORT}/stream.mjpg", flush=True) app.run( host=APP_HOST, port=APP_PORT, threaded=True, use_reloader=False, # verhindert doppelten Grabber-Start )