251 lines
8.1 KiB
Python
251 lines
8.1 KiB
Python
#!/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).
|
||
Einzelbild des aktuellen Frames unter /snapshot.jpg.
|
||
|
||
.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/180 (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 = """\
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<title>Pi Camera</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<style>
|
||
body { background:#111; color:#eee; text-align:center;
|
||
font-family:sans-serif; padding:20px; margin:0 }
|
||
img { max-width:100%; border:2px solid #444 }
|
||
p { color:#aaa; font-size:14px; margin-top:10px }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Pi Camera Stream</h1>
|
||
<img src="/stream.mjpg">
|
||
<p>{{ width }}×{{ height }} @ {{ fps }} fps | Qualität {{ quality }}</p>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
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()
|
||
|
||
|
||
@app.route("/snapshot.jpg")
|
||
def snapshot():
|
||
with grabber.lock:
|
||
frame = grabber.frame
|
||
if frame is None:
|
||
return Response("Noch kein Frame verfügbar", status=503, mimetype="text/plain")
|
||
return Response(frame, mimetype="image/jpeg",
|
||
headers={"Cache-Control": "no-store"})
|
||
|
||
|
||
# ── 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
|
||
)
|