first commit
This commit is contained in:
commit
083b6ba25b
3 files changed
+241
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
@@ -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
|
||||||
|
)
|
||||||
Reference in new issue
Block a user