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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
"""Framequellen.
Der Pi Zero kann auf mehrere Arten streamen und wir wissen noch nicht, welche
Software drauf läuft. Deshalb vier Quellen hinter einem Interface — die
Pipeline interessiert sich nur für ``for frame in source``.
MJPEG und Snapshot brauchen nur die Stdlib. RTSP ruft ffmpeg auf; das muss
dann auf dem Host installiert sein (``apt install ffmpeg``).
"""
from __future__ import annotations
import io
import subprocess
import time
import urllib.request
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Iterator
from PIL import Image
JPEG_SOI = b"\xff\xd8"
JPEG_EOI = b"\xff\xd9"
class FrameSource(ABC):
restartable = True
"""Ob es sinnvoll ist, die Quelle nach dem Ende neu zu öffnen.
Bei Netzquellen ja — der Pi startet neu, das WLAN hustet. Bei einem
endlichen Ordner nein, sonst läuft ``watch`` in einer Endlosschleife.
"""
letztes_jpeg: bytes | None = None
"""Die unveränderten JPEG-Bytes des zuletzt gelieferten Bildes.
Wer das Bild weitergeben will — etwa der Live-Verteiler —, spart damit das
Umkodieren: bei MJPEG und Einzelbild-URLs liegen die Bytes ohnehin vor.
Quellen ohne fertiges JPEG lassen das Feld auf ``None``.
"""
@abstractmethod
def frames(self) -> Iterator[Image.Image]: ...
def __iter__(self) -> Iterator[Image.Image]:
return self.frames()
class MJPEGStream(FrameSource):
"""multipart/x-mixed-replace — was mjpg-streamer, motion und die meisten
picamera2-Beispiele ausliefern.
Wir suchen die JPEG-Marker direkt im Bytestrom, statt die MIME-Boundaries
zu parsen: Boundary-Formate sind zwischen den Servern uneinheitlich, die
Marker sind es nicht.
"""
def __init__(self, url: str, timeout: int = 15, max_buffer: int = 8 << 20):
self.url = url
self.timeout = timeout
self.max_buffer = max_buffer
def frames(self) -> Iterator[Image.Image]:
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
buf = bytearray()
while True:
chunk = resp.read(16384)
if not chunk:
return
buf.extend(chunk)
while True:
start = buf.find(JPEG_SOI)
if start < 0:
break
end = buf.find(JPEG_EOI, start + 2)
if end < 0:
break
raw = bytes(buf[start : end + 2])
del buf[: end + 2]
try:
bild = Image.open(io.BytesIO(raw)).convert("RGB")
except OSError:
continue # halbes Bild, nächstes nehmen
self.letztes_jpeg = raw
yield bild
# Reißt die Verbindung mitten im Frame ab, wächst der Puffer
# sonst unbegrenzt weiter.
if len(buf) > self.max_buffer:
del buf[: -(1 << 20)]
class SnapshotPoller(FrameSource):
"""Einzelbild-URL im Intervall abholen (z.B. /snapshot.jpg bei motion,
oder ein Cron-Job, der auf den Pi schreibt). Genügsamer als MJPEG und für
einen Nistkasten völlig ausreichend."""
def __init__(self, url: str, interval: float = 1.0, timeout: int = 10):
self.url = url
self.interval = interval
self.timeout = timeout
def frames(self) -> Iterator[Image.Image]:
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
while True:
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
bild = Image.open(io.BytesIO(raw)).convert("RGB")
except (OSError, ValueError) as exc:
print(f"[stream] Snapshot fehlgeschlagen: {exc}")
time.sleep(min(self.interval * 5, 30))
continue
self.letztes_jpeg = raw
yield bild
time.sleep(max(0.0, self.interval - (time.monotonic() - t0)))
class RTSPStream(FrameSource):
"""RTSP über ffmpeg. Braucht ffmpeg auf dem Host."""
def __init__(self, url: str, fps: float = 2.0, width: int = 1280):
self.url = url
self.fps = fps
self.width = width
def frames(self) -> Iterator[Image.Image]:
cmd = [
"ffmpeg", "-loglevel", "error",
"-rtsp_transport", "tcp", "-i", self.url,
"-vf", f"fps={self.fps},scale={self.width}:-2",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "4", "-",
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1 << 16)
except FileNotFoundError:
raise RuntimeError("ffmpeg nicht gefunden — 'apt install ffmpeg'") from None
try:
buf = bytearray()
assert proc.stdout is not None
while True:
chunk = proc.stdout.read(16384)
if not chunk:
return
buf.extend(chunk)
while True:
start = buf.find(JPEG_SOI)
end = buf.find(JPEG_EOI, start + 2) if start >= 0 else -1
if start < 0 or end < 0:
break
raw = bytes(buf[start : end + 2])
del buf[: end + 2]
try:
yield Image.open(io.BytesIO(raw)).convert("RGB")
except OSError:
continue
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
class FolderSource(FrameSource):
"""Bilder aus einem Ordner — zum Entwickeln und für Regressionstests ohne
laufende Kamera."""
def __init__(self, folder: str | Path, loop: bool = False, delay: float = 0.0):
self.folder = Path(folder)
self.loop = loop
self.delay = delay
self.restartable = loop
def frames(self) -> Iterator[Image.Image]:
exts = {".jpg", ".jpeg", ".png", ".webp"}
files = sorted(p for p in self.folder.iterdir() if p.suffix.lower() in exts)
if not files:
raise RuntimeError(f"Keine Bilder in {self.folder}")
while True:
for p in files:
yield Image.open(p).convert("RGB")
if self.delay:
time.sleep(self.delay)
if not self.loop:
return
def open_source(url: str, **kw) -> FrameSource:
"""Baut die passende Quelle aus einer URL bzw. einem Pfad."""
if url.startswith("rtsp://"):
return RTSPStream(url, fps=kw.get("fps", 2.0))
if url.startswith(("http://", "https://")):
# Heuristik: eine URL auf .jpg ist ein Einzelbild, alles andere ein Stream.
path = url.split("?", 1)[0].lower()
if path.endswith((".jpg", ".jpeg", ".png")) or kw.get("snapshot"):
return SnapshotPoller(url, interval=kw.get("interval", 1.0))
return MJPEGStream(url)
return FolderSource(url, loop=kw.get("loop", False), delay=kw.get("delay", 0.0))