"""Live-Bild fuer das Heimnetz, ohne der Kamera etwas wegzunehmen. Die Kameraseite liefert **einen** Stream. Wer live zuschaut, nimmt ihn dem Detector weg — auf einem Pi Zero W ist das keine Einstellung, sondern Physik: die Funkstrecke traegt rund 2 Mbit/s, ein zweiter Client saettigt sie und der Detector faellt in Timeouts. Der Detector selbst liest den Stream aber ohnehin rund um die Uhr und hat jedes Bild schon im Speicher. Also verteilt er es weiter. Damit schauen beliebig viele Leute zu, und die Kamera merkt nichts davon — sie hat weiterhin genau einen Zuschauer. python -m detector watch ... --live-port 8131 http://:8131/live MJPEG http://:8131/bild.jpg Einzelbild http://:8131/ Seite mit Livebild Zwei Dinge halten die Erkennung dabei aus dem Weg: * **Nichts wird neu kodiert, solange niemand zuschaut.** Bei MJPEG-Quellen liegen die JPEG-Bytes ohnehin vor und werden unveraendert weitergegeben; nur fuer Quellen ohne fertiges JPEG (RTSP, Bilderordner) wird kodiert — und das erst, wenn wirklich jemand hinsieht, und einmal pro Bild statt einmal pro Zuschauer. * **Kodiert wird im HTTP-Thread**, nicht im Erkennungsthread. Ein langsamer Zuschauer bremst die Erkennung nicht. Das gehoert ins Heimnetz und nicht ins Internet: es ist ein Livebild aus dem eigenen Garten, ohne Anmeldung. """ from __future__ import annotations import io import socketserver import threading import time from http import server from PIL import Image GRENZE = "BILDGRENZE" SEITE = """ Vogelalbum live Livebild

Der Detector verteilt sein Bild weiter — die Kamera merkt nichts davon.

""".encode("utf-8") class Bildhalter: """Haelt das jeweils letzte Bild und gibt es als JPEG heraus. Die Kodierung passiert traege und genau einmal je Bild: der erste Zuschauer, der ein neues Bild abholt, kodiert es, alle weiteren bekommen dasselbe Ergebnis. """ def __init__(self, qualitaet: int = 75, max_breite: int = 1280): self.qualitaet = qualitaet self.max_breite = max_breite self._sperre = threading.Condition() self._nummer = 0 self._jpeg: bytes | None = None self._frame: Image.Image | None = None self._kodiert: tuple[int, bytes] | None = None # -- Schreibseite (Erkennungsthread) ---------------------------------- def zeige(self, frame: Image.Image, jpeg: bytes | None = None) -> None: """Ein neues Bild anmelden. Kostet nur Zeigerarbeit. ``jpeg`` sind die unveraenderten Bytes der Quelle, falls es sie gibt — bei MJPEG ist das der Normalfall und erspart jedes Umkodieren. """ with self._sperre: self._nummer += 1 self._frame = frame self._jpeg = jpeg self._sperre.notify_all() # -- Leseseite (HTTP-Threads) ----------------------------------------- @property def nummer(self) -> int: with self._sperre: return self._nummer def warte(self, seit: int, timeout: float = 5.0) -> int: """Wartet auf ein Bild neuer als ``seit``. Gibt dessen Nummer zurueck, oder ``seit``, wenn nichts kam.""" with self._sperre: if self._nummer > seit: return self._nummer self._sperre.wait(timeout) return self._nummer def jpeg(self) -> tuple[int, bytes] | None: """Das aktuelle Bild als JPEG, mit seiner Nummer.""" with self._sperre: nummer = self._nummer if nummer == 0: return None if self._kodiert is not None and self._kodiert[0] == nummer: return self._kodiert if self._jpeg is not None: # Der haeufige Fall: die Quelle liefert MJPEG, die Bytes sind # schon fertig. Nichts zu tun. self._kodiert = (nummer, self._jpeg) return self._kodiert frame = self._frame if frame is None: return None # Ausserhalb der Sperre kodieren: das ist der teure Teil, und der # Erkennungsthread soll dabei nicht warten muessen. if frame.width > self.max_breite: hoehe = round(frame.height * self.max_breite / frame.width) frame = frame.resize((self.max_breite, hoehe), Image.LANCZOS) puffer = io.BytesIO() frame.save(puffer, format="JPEG", quality=self.qualitaet) daten = puffer.getvalue() with self._sperre: self._kodiert = (nummer, daten) return self._kodiert class Handler(server.BaseHTTPRequestHandler): halter: Bildhalter max_zuschauer = 8 # Bilder je Sekunde fuer ZUSCHAUER. 0 heisst: alles durchreichen. # # 🔑 Warum das noetig ist: Am 24.09.2026 gemessen, zwei Kameras in einer # Live-Ansicht auf einem Handy -- 6,4 und 9,7 Mbit/s, zusammen ueber 16. # Fuer eine Vorschau ist das absurd viel, und auf dem Handy dauert es # entsprechend, bis ueberhaupt etwas erscheint. # # Ausgelassen wird nur fuer die Zuschauer. Die Erkennung bekommt # weiterhin JEDES Bild -- sie liest nicht ueber diesen Weg. max_bildrate = 0.0 _zuschauer = 0 _zaehlsperre = threading.Lock() def log_message(self, fmt, *args): # noqa: A003 - Signatur der Basisklasse pass def do_GET(self) -> None: # noqa: N802 - Signatur der Basisklasse # ⚠ self.path enthaelt den Anhang hinter dem Fragezeichen. Ein # exakter Vergleich scheitert deshalb an "/bild.jpg?t=123" -- # und genau so fragt ein Browser, der das Bild nicht aus seinem # Zwischenspeicher nehmen soll. Am 24.09.2026 hat mich das eine # Runde gekostet: von Hand aufgerufen kam das Bild, aus der # Seite heraus kam 404. weg = self.path.split("?", 1)[0] if weg in ("/", "/index.html"): self._sende(200, "text/html; charset=utf-8", SEITE) elif weg in ("/bild.jpg", "/snapshot.jpg"): self._einzelbild() elif weg.startswith("/live"): self._stream() else: self._sende(404, "text/plain; charset=utf-8", b"nicht gefunden\n") def _sende(self, code: int, typ: str, koerper: bytes) -> None: self.send_response(code) self.send_header("Content-Type", typ) self.send_header("Content-Length", str(len(koerper))) self.end_headers() self.wfile.write(koerper) def _einzelbild(self) -> None: bild = self.halter.jpeg() if bild is None: self._sende(503, "text/plain; charset=utf-8", b"noch kein Bild\n") return self._sende(200, "image/jpeg", bild[1]) def _stream(self) -> None: # Anders als an der Kamera ist hier eine Obergrenze reine Vorsicht: # der Detector laeuft auf einer richtigen Maschine. Sie verhindert # nur, dass ein vergessener Tab-Stapel Threads sammelt. with self._zaehlsperre: if Handler._zuschauer >= self.max_zuschauer: self._sende(503, "text/plain; charset=utf-8", b"zu viele Zuschauer\n") return Handler._zuschauer += 1 try: self.send_response(200) self.send_header("Cache-Control", "no-cache, private") self.send_header("Pragma", "no-cache") self.send_header( "Content-Type", f"multipart/x-mixed-replace; boundary={GRENZE}" ) self.end_headers() gesehen = 0 abstand = (1.0 / self.max_bildrate) if self.max_bildrate > 0 else 0.0 zuletzt = 0.0 while True: gesehen = self.halter.warte(gesehen) # Zu frueh? Bild auslassen statt zu warten -- warten wuerde # den Strom anhalten und die Verzoegerung aufsummieren. if abstand: jetzt = time.monotonic() if jetzt - zuletzt < abstand: continue zuletzt = jetzt bild = self.halter.jpeg() if bild is None: continue nummer, daten = bild gesehen = nummer self.wfile.write(f"--{GRENZE}\r\n".encode("ascii")) self.wfile.write(b"Content-Type: image/jpeg\r\n") self.wfile.write(f"Content-Length: {len(daten)}\r\n\r\n".encode("ascii")) self.wfile.write(daten) self.wfile.write(b"\r\n") except (BrokenPipeError, ConnectionResetError): pass finally: with self._zaehlsperre: Handler._zuschauer -= 1 @classmethod def zuschauer(cls) -> int: with cls._zaehlsperre: return cls._zuschauer class _Server(socketserver.ThreadingMixIn, server.HTTPServer): allow_reuse_address = True daemon_threads = True class Verteiler: """Der Live-Verteiler als Hintergrunddienst.""" def __init__( self, port: int, bind: str = "0.0.0.0", max_zuschauer: int = 8, qualitaet: int = 75, max_bildrate: float = 0.0, ): self.halter = Bildhalter(qualitaet=qualitaet) Handler.halter = self.halter Handler.max_zuschauer = max_zuschauer Handler.max_bildrate = max_bildrate self._server = _Server((bind, port), Handler) self.port = self._server.server_address[1] self._thread = threading.Thread( target=self._server.serve_forever, daemon=True, name="live" ) def bildrate_setzen(self, wert: float) -> None: """Zur Laufzeit umstellbar -- die Web-App ist die Wahrheit.""" Handler.max_bildrate = max(0.0, float(wert or 0.0)) def start(self) -> None: self._thread.start() def zeige(self, frame: Image.Image, jpeg: bytes | None = None) -> None: self.halter.zeige(frame, jpeg) @property def zuschauer(self) -> int: return Handler.zuschauer() def stop(self) -> None: self._server.shutdown() self._server.server_close()