commit 083b6ba25b7b0db6675c29e528ea8f1ef807b3c1 Author: Joachim Hummel Date: Mon Oct 19 18:33:48 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..6699f23 --- /dev/null +++ b/app.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +cam.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 + )