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