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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,287 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user