Erster Commit: MJPEG-Relay, Stream-Tool und README
This commit is contained in:
commit
2de90feafc
5 files changed
+388
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Kameras: eine Zeile pro Kamera, CAMERA_<name>=<stream-url>
|
||||
# Der Name wird Teil der URL (/cam1/). Die erste Kamera ist auch unter /stream.mjpg erreichbar.
|
||||
CAMERA_cam1=http://IP:PORT/pfad
|
||||
CAMERA_cam2=http://IP:PORT/pfad
|
||||
CAMERA_cam3=http://IP:PORT/pfad
|
||||
|
||||
PORT=8000
|
||||
# Optional: Login (nur sinnvoll mit HTTPS in Zoraxy)
|
||||
#AUTH_USER=joachim
|
||||
#AUTH_PASS=geheim
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
__pycache__/
|
||||
@@ -0,0 +1,87 @@
|
||||
# Webcam
|
||||
|
||||
Zwei kleine Python-Tools für MJPEG-Kameras:
|
||||
|
||||
- **`relay.py`** – holt den MJPEG-Stream jeder Kamera nur **einmal** ab und stellt ihn für beliebig viele Zuschauer per HTTP bereit (gedacht für den Betrieb hinter einem Reverse Proxy wie Zoraxy).
|
||||
- **`webcam.py`** – Kommandozeilenwerkzeug, um einen MJPEG-Stream zu testen, Einzelbilder/Frames zu speichern oder live anzuzeigen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Python 3
|
||||
- [`requests`](https://pypi.org/project/requests/)
|
||||
- Optional für `webcam.py --show`: `opencv-python` und `numpy`
|
||||
|
||||
```bash
|
||||
pip install requests
|
||||
pip install opencv-python numpy # nur für --show
|
||||
```
|
||||
|
||||
## Relay (`relay.py`)
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Die Konfiguration erfolgt über eine `.env`-Datei neben `relay.py` oder über Umgebungsvariablen (echte Umgebungsvariablen haben Vorrang).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Variable | Beschreibung | Standard |
|
||||
|---|---|---|
|
||||
| `CAMERA_<name>` | Stream-URL einer Kamera, eine Zeile pro Kamera. Der Name wird Teil der URL (`/cam1/`). | – (mindestens eine nötig) |
|
||||
| `PORT` | Port, auf dem der Relay lauscht | `8000` |
|
||||
| `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus |
|
||||
|
||||
Beispiel:
|
||||
|
||||
```ini
|
||||
CAMERA_cam1=http://192.168.10.82:8080/stream.mjpg
|
||||
CAMERA_cam2=http://192.168.10.83:8080/stream.mjpg
|
||||
PORT=8000
|
||||
#AUTH_USER=joachim
|
||||
#AUTH_PASS=geheim
|
||||
```
|
||||
|
||||
> `.env` enthält ggf. Zugangsdaten – nicht ins Repository einchecken.
|
||||
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
python3 relay.py
|
||||
```
|
||||
|
||||
### Endpunkte
|
||||
|
||||
| Pfad | Beschreibung |
|
||||
|---|---|
|
||||
| `/` | Übersicht aller Kameras |
|
||||
| `/<name>/` | Webseite mit Livebild einer Kamera |
|
||||
| `/<name>/stream.mjpg` | Livestream (`multipart/x-mixed-replace`) |
|
||||
| `/<name>/snapshot.jpg` | Aktuelles Einzelbild |
|
||||
| `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) |
|
||||
| `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar |
|
||||
|
||||
### Funktionsweise
|
||||
|
||||
- Pro Kamera läuft ein Hintergrund-Thread, der den Stream liest und die JPEG-Frames anhand der Marker `FFD8`/`FFD9` ausschneidet.
|
||||
- Bei Verbindungsfehlern wird nach 3 Sekunden automatisch neu verbunden.
|
||||
- Alle Zuschauer bekommen jeweils das neueste Frame – die Kamera wird unabhängig von der Zuschauerzahl nur einmal belastet.
|
||||
- Der Header `X-Accel-Buffering: no` verhindert Pufferung durch den Proxy.
|
||||
|
||||
## Stream-Tool (`webcam.py`)
|
||||
|
||||
```bash
|
||||
python3 webcam.py # Verbindung testen, Frame-Infos ausgeben
|
||||
python3 webcam.py --snapshot bild.jpg # ein Einzelbild speichern
|
||||
python3 webcam.py --save frames/ -n 50 # 50 Frames als JPEG speichern
|
||||
python3 webcam.py --show # Live-Fenster (q oder Esc beendet)
|
||||
python3 webcam.py --url http://IP:PORT/stream.mjpg
|
||||
```
|
||||
|
||||
| Option | Beschreibung |
|
||||
|---|---|
|
||||
| `--url` | Stream-URL (Standard ist in `webcam.py` hinterlegt) |
|
||||
| `--snapshot DATEI` | Ein Frame speichern und beenden |
|
||||
| `--save ORDNER` | Frames im Ordner speichern |
|
||||
| `-n N` | Nach N Frames beenden (`0` = endlos) |
|
||||
| `--show` | Live-Fenster anzeigen (OpenCV) |
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Webcam-Relay: holt den MJPEG-Stream der Kamera einmal ab und stellt ihn
|
||||
per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy).
|
||||
|
||||
Endpunkte:
|
||||
/ Übersicht aller Kameras
|
||||
/<name>/ Webseite mit Livebild einer Kamera
|
||||
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
|
||||
/<name>/snapshot.jpg aktuelles Einzelbild
|
||||
/stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität)
|
||||
/health Status aller Kameras
|
||||
|
||||
Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen:
|
||||
CAMERA_<name>=<url> eine Zeile pro Kamera, z.B. CAMERA_cam1=http://...
|
||||
PORT (default 8000)
|
||||
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
|
||||
"""
|
||||
import base64
|
||||
import hmac
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
|
||||
def load_env(path):
|
||||
"""Liest KEY=VALUE-Zeilen aus .env; echte Umgebungsvariablen haben Vorrang."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip("\"'"))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
load_env(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
CAMERAS = {k[7:].lower(): v for k, v in os.environ.items() if k.startswith("CAMERA_") and v}
|
||||
if not CAMERAS:
|
||||
raise SystemExit("Keine Kameras konfiguriert: CAMERA_<name>=<url> in .env eintragen")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
AUTH_USER = os.environ.get("AUTH_USER", "")
|
||||
AUTH_PASS = os.environ.get("AUTH_PASS", "")
|
||||
BOUNDARY = "frame"
|
||||
|
||||
HEAD = ("<!doctype html><html lang=de><head><meta charset=utf-8>"
|
||||
"<meta name=viewport content='width=device-width,initial-scale=1'><title>Webcam</title>"
|
||||
"<style>html,body{margin:0;background:#111;color:#eee;font-family:sans-serif}"
|
||||
"img{display:block;width:100%}a{color:#8cf}"
|
||||
".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:8px;padding:8px}"
|
||||
"h3{margin:4px 0}</style></head><body>")
|
||||
|
||||
|
||||
def single_page(name):
|
||||
return (HEAD + f"<img src=stream.mjpg alt='{name}'></body></html>").encode()
|
||||
|
||||
|
||||
def overview_page():
|
||||
cells = "".join(f"<div><h3><a href='{n}/'>{n}</a></h3><img src='{n}/stream.mjpg' alt='{n}'></div>"
|
||||
for n in CAMERAS)
|
||||
return (HEAD + f"<div class=grid>{cells}</div></body></html>").encode()
|
||||
|
||||
|
||||
class Hub:
|
||||
"""Hält das neueste Frame; Zuschauer warten auf das jeweils nächste."""
|
||||
|
||||
def __init__(self):
|
||||
self.cond = threading.Condition()
|
||||
self.frame = None
|
||||
self.seq = 0
|
||||
self.last = 0.0
|
||||
|
||||
def publish(self, jpg):
|
||||
with self.cond:
|
||||
self.frame, self.seq, self.last = jpg, self.seq + 1, time.time()
|
||||
self.cond.notify_all()
|
||||
|
||||
def wait(self, seq, timeout=10):
|
||||
with self.cond:
|
||||
self.cond.wait_for(lambda: self.seq != seq, timeout)
|
||||
return self.frame, self.seq
|
||||
|
||||
|
||||
hubs = {name: Hub() for name in CAMERAS}
|
||||
|
||||
|
||||
def pull_camera(name, url, hub):
|
||||
"""Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf."""
|
||||
while True:
|
||||
try:
|
||||
with requests.get(url, stream=True, timeout=10) as r:
|
||||
r.raise_for_status()
|
||||
buf = b""
|
||||
for chunk in r.iter_content(chunk_size=8192):
|
||||
buf += chunk
|
||||
while True:
|
||||
s = buf.find(b"\xff\xd8")
|
||||
if s < 0:
|
||||
buf = b""
|
||||
break
|
||||
e = buf.find(b"\xff\xd9", s + 2)
|
||||
if e < 0:
|
||||
buf = buf[s:]
|
||||
break
|
||||
hub.publish(buf[s:e + 2])
|
||||
buf = buf[e + 2:]
|
||||
except requests.RequestException as ex:
|
||||
print(f"{name}: Kamera nicht erreichbar: {ex} - neuer Versuch in 3s", flush=True)
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def authorized(self):
|
||||
if not AUTH_USER:
|
||||
return True
|
||||
h = self.headers.get("Authorization", "")
|
||||
want = "Basic " + base64.b64encode(f"{AUTH_USER}:{AUTH_PASS}".encode()).decode()
|
||||
return hmac.compare_digest(h, want)
|
||||
|
||||
def send_bytes(self, code, ctype, body, extra=None):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
for k, v in (extra or {}).items():
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
parts = [p for p in self.path.split("?")[0].split("/") if p]
|
||||
if parts == ["health"]:
|
||||
now = time.time()
|
||||
lines = [f"{n}: {'ok' if h.last and now - h.last < 10 else 'keine Frames'}"
|
||||
for n, h in hubs.items()]
|
||||
ok = all(l.endswith(": ok") for l in lines)
|
||||
return self.send_bytes(200 if ok else 503, "text/plain", ("\n".join(lines) + "\n").encode())
|
||||
if not self.authorized():
|
||||
return self.send_bytes(401, "text/plain", b"Login erforderlich",
|
||||
{"WWW-Authenticate": 'Basic realm="Webcam"'})
|
||||
if not parts:
|
||||
return self.send_bytes(200, "text/html; charset=utf-8", overview_page())
|
||||
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
|
||||
parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera
|
||||
name = parts[0]
|
||||
if name not in hubs or len(parts) > 2:
|
||||
return self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||
hub = hubs[name]
|
||||
what = parts[1] if len(parts) == 2 else ""
|
||||
if what == "":
|
||||
if not self.path.split("?")[0].endswith("/"):
|
||||
return self.send_bytes(301, "text/plain", b"", {"Location": f"/{name}/"})
|
||||
return self.send_bytes(200, "text/html; charset=utf-8", single_page(name))
|
||||
if what == "snapshot.jpg":
|
||||
if hub.frame is None:
|
||||
return self.send_bytes(503, "text/plain", b"Noch kein Bild")
|
||||
return self.send_bytes(200, "image/jpeg", hub.frame)
|
||||
if what == "stream.mjpg":
|
||||
return self.stream(hub)
|
||||
self.send_bytes(404, "text/plain", b"Nicht gefunden")
|
||||
|
||||
def stream(self, hub):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", f"multipart/x-mixed-replace; boundary={BOUNDARY}")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Accel-Buffering", "no") # Proxy soll nicht puffern
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
seq = 0
|
||||
try:
|
||||
while True:
|
||||
frame, seq_new = hub.wait(seq)
|
||||
if frame is None or seq_new == seq:
|
||||
continue
|
||||
seq = seq_new
|
||||
self.wfile.write(
|
||||
f"--{BOUNDARY}\r\nContent-Type: image/jpeg\r\n"
|
||||
f"Content-Length: {len(frame)}\r\n\r\n".encode() + frame + b"\r\n")
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for n, u in CAMERAS.items():
|
||||
threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start()
|
||||
print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
|
||||
Server(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MJPEG-Stream auslesen.
|
||||
|
||||
Beispiele:
|
||||
python3 webcam.py # Verbindung testen, Frame-Infos ausgeben
|
||||
python3 webcam.py --snapshot bild.jpg # ein Einzelbild speichern
|
||||
python3 webcam.py --save frames/ -n 50 # 50 Frames als JPEG speichern
|
||||
python3 webcam.py --show # Live-Fenster (benötigt opencv-python + numpy)
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
URL = "http://192.168.10.82:8080/stream.mjpg"
|
||||
SOI, EOI = b"\xff\xd8", b"\xff\xd9"
|
||||
|
||||
|
||||
def frames(url, timeout=10):
|
||||
"""Generator: liefert jedes JPEG-Frame des MJPEG-Streams als bytes."""
|
||||
with requests.get(url, stream=True, timeout=timeout) as r:
|
||||
r.raise_for_status()
|
||||
buf = b""
|
||||
for chunk in r.iter_content(chunk_size=4096):
|
||||
buf += chunk
|
||||
while True:
|
||||
start = buf.find(SOI)
|
||||
if start < 0:
|
||||
buf = b""
|
||||
break
|
||||
end = buf.find(EOI, start + 2)
|
||||
if end < 0:
|
||||
buf = buf[start:]
|
||||
break
|
||||
yield buf[start:end + 2]
|
||||
buf = buf[end + 2:]
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--url", default=URL)
|
||||
p.add_argument("--snapshot", metavar="DATEI", help="ein Frame speichern und beenden")
|
||||
p.add_argument("--save", metavar="ORDNER", help="Frames in Ordner speichern")
|
||||
p.add_argument("-n", type=int, default=0, help="nach N Frames beenden (0 = endlos)")
|
||||
p.add_argument("--show", action="store_true", help="Live-Fenster anzeigen (OpenCV)")
|
||||
a = p.parse_args()
|
||||
|
||||
if a.show:
|
||||
import cv2
|
||||
import numpy as np
|
||||
if a.save:
|
||||
os.makedirs(a.save, exist_ok=True)
|
||||
|
||||
count, t0 = 0, time.time()
|
||||
try:
|
||||
for jpg in frames(a.url):
|
||||
count += 1
|
||||
if a.snapshot:
|
||||
with open(a.snapshot, "wb") as f:
|
||||
f.write(jpg)
|
||||
print(f"Gespeichert: {a.snapshot} ({len(jpg)} Bytes)")
|
||||
return
|
||||
if a.save:
|
||||
with open(os.path.join(a.save, f"frame_{count:06d}.jpg"), "wb") as f:
|
||||
f.write(jpg)
|
||||
if a.show:
|
||||
img = cv2.imdecode(np.frombuffer(jpg, np.uint8), cv2.IMREAD_COLOR)
|
||||
cv2.imshow("Webcam", img)
|
||||
if cv2.waitKey(1) & 0xFF in (ord("q"), 27):
|
||||
break
|
||||
fps = count / (time.time() - t0)
|
||||
print(f"\rFrame {count} {len(jpg)} Bytes {fps:.1f} fps", end="", flush=True)
|
||||
if a.n and count >= a.n:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except requests.RequestException as e:
|
||||
sys.exit(f"Verbindungsfehler: {e}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user