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-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
"""Stichproben aus dem laufenden Bildstrom.
Ein Detektor, der nur bei Erfolg speichert, hinterlässt nach zwei Wochen ohne
Sichtung genau nichts, woran man sehen könnte, woran es lag. Waren keine Vögel
da? Saßen sie im Bild und waren zu klein? Das Protokoll beantwortet die zweite
Frage, die erste beantwortet nur ein Blick auf echte Bilder.
Deshalb legt ``Stichprobe`` in festem Abstand ein Bild ab — unabhängig davon,
ob etwas erkannt wurde. Der Strom läuft ohnehin, es kostet keinen zweiten
Zuschauer am Stream (der Pi Zero verträgt keinen).
Der Ordner ist selbstbegrenzend: über ``max_bilder`` hinaus fliegt jeweils das
älteste Bild raus. Ein Dauerläufer darf keine Platte volllaufen lassen.
"""
from __future__ import annotations
import io
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw
JPEG_QUALITY = 80
MAX_WIDTH = 1280
def _encode(img: Image.Image, max_width: int = MAX_WIDTH) -> bytes:
if img.width > max_width:
h = round(img.height * max_width / img.width)
img = img.resize((max_width, h), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
return buf.getvalue()
class Stichprobe:
"""Legt in festem Abstand ein Bild aus dem Strom ab."""
def __init__(
self,
ordner: str | Path,
alle_sekunden: float = 300.0,
max_bilder: int = 500,
):
self.ordner = Path(ordner)
self.alle_sekunden = alle_sekunden
self.max_bilder = max_bilder
self.ordner.mkdir(parents=True, exist_ok=True)
self._letzte: float | None = None
def bild(self, frame: Image.Image, jetzt: float) -> Path | None:
"""Speichert, wenn das Intervall abgelaufen ist. Gibt den Pfad zurück,
sonst ``None``.
``jetzt`` ist eine monotone Uhr (``time.monotonic``) — der Abstand darf
nicht davon abhängen, ob jemand die Systemzeit stellt.
"""
if self._letzte is not None and jetzt - self._letzte < self.alle_sekunden:
return None
self._letzte = jetzt
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
ziel = self.ordner / f"{stamp}.jpg"
ziel.write_bytes(_encode(frame))
self._aufraeumen()
return ziel
def _aufraeumen(self) -> None:
bilder = sorted(self.ordner.glob("*.jpg"))
# Die Namen sind Zeitstempel, sortieren heisst also chronologisch.
for alt in bilder[: max(0, len(bilder) - self.max_bilder)]:
alt.unlink(missing_ok=True)
def kontaktbogen(
ordner: str | Path,
ziel: str | Path,
spalten: int = 5,
zeilen: int = 6,
kachel_breite: int = 400,
seite: int = 1,
) -> tuple[Path, int, int]:
"""Montiert die Stichproben zu einem Blatt, neueste zuerst.
Ein Ordner mit 120 Bildern wird auf dem Handy nicht durchgesehen, ein
einzelnes Blatt schon. Rückgabe: Zielpfad, Anzahl montierter Bilder und
Gesamtzahl der Seiten.
"""
ordner = Path(ordner)
ziel = Path(ziel)
bilder = sorted(ordner.glob("*.jpg"), reverse=True)
if not bilder:
raise FileNotFoundError(f"Keine Stichproben in {ordner}")
pro_seite = spalten * zeilen
seiten = (len(bilder) + pro_seite - 1) // pro_seite
seite = max(1, min(seite, seiten))
auswahl = bilder[(seite - 1) * pro_seite : seite * pro_seite]
with Image.open(auswahl[0]) as probe:
verhaeltnis = probe.height / probe.width
kh = round(kachel_breite * verhaeltnis)
beschriftung = 22
zeilen_echt = (len(auswahl) + spalten - 1) // spalten
blatt = Image.new(
"RGB",
(spalten * kachel_breite, zeilen_echt * (kh + beschriftung)),
(18, 18, 18),
)
mal = ImageDraw.Draw(blatt)
for i, pfad in enumerate(auswahl):
x = (i % spalten) * kachel_breite
y = (i // spalten) * (kh + beschriftung)
with Image.open(pfad) as im:
blatt.paste(im.convert("RGB").resize((kachel_breite, kh), Image.LANCZOS), (x, y))
# Aus "20260912-143005.jpg" wird "12.09. 14:30" — die Sekunde hilft
# beim Durchsehen nicht, das Datum schon.
name = pfad.stem
text = f"{name[6:8]}.{name[4:6]}. {name[9:11]}:{name[11:13]}"
mal.text((x + 6, y + kh + 4), text, fill=(210, 210, 210))
ziel.parent.mkdir(parents=True, exist_ok=True)
blatt.save(ziel, format="JPEG", quality=85, optimize=True)
return ziel, len(auswahl), seiten