Files
raspi-camera/app.py
T

251 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
)