Files
christian c11fe9ab04 Vogelalbum 1.0.0
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.
2026-10-15 14:30:02 +00:00

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()