Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""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://<detector>:8131/live MJPEG
|
|
http://<detector>:8131/bild.jpg Einzelbild
|
|
http://<detector>: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 = """<!doctype html>
|
|
<html lang="de"><head><meta charset="utf-8"><title>Vogelalbum live</title>
|
|
<style>body{margin:0;background:#111;color:#eee;font-family:system-ui,sans-serif}
|
|
img{display:block;width:100%;height:auto}p{padding:10px 14px;margin:0;font-size:14px}</style>
|
|
</head><body>
|
|
<img src="/live" alt="Livebild">
|
|
<p>Der Detector verteilt sein Bild weiter — die Kamera merkt nichts davon.</p>
|
|
</body></html>
|
|
""".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()
|