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.
128 lines
4.4 KiB
Python
128 lines
4.4 KiB
Python
"""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
|