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,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
|
||||
Reference in new issue
Block a user