commit 2de90feafc73bd70821ed6edb62cac6f8aafceed Author: Joachim Hummel Date: Sat Oct 17 20:22:40 2026 +0200 Erster Commit: MJPEG-Relay, Stream-Tool und README diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba31a4e --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Kameras: eine Zeile pro Kamera, CAMERA_= +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d50a09f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..40831fe --- /dev/null +++ b/README.md @@ -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_` | 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 | +| `//` | Webseite mit Livebild einer Kamera | +| `//stream.mjpg` | Livestream (`multipart/x-mixed-replace`) | +| `//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) | diff --git a/relay.py b/relay.py new file mode 100644 index 0000000..20cb985 --- /dev/null +++ b/relay.py @@ -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 + // Webseite mit Livebild einer Kamera + //stream.mjpg Livestream (multipart/x-mixed-replace) + //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_= 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_= 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 = ("" + "Webcam" + "") + + +def single_page(name): + return (HEAD + f"{name}").encode() + + +def overview_page(): + cells = "".join(f"" + for n in CAMERAS) + return (HEAD + f"
{cells}
").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() diff --git a/webcam.py b/webcam.py new file mode 100644 index 0000000..e5288ba --- /dev/null +++ b/webcam.py @@ -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()