first commit

This commit is contained in:
Joachim Hummel committed 2026-10-19 18:33:48 +02:00
commit 083b6ba25b
3 files changed
+241

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
.env
__pycache__/
View File
Whitespace-only changes.
+239
View File
@@ -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 = """\
<!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()
# ── 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
)