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.
121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""Wie ein Vogelfoto in einer Kachel aussieht.
|
|
|
|
🔑 **Der Zuschnitt ist eine Regel, keine Datei.** Das Original liegt ohnehin
|
|
auf der Platte, und wo der Vogel darin sitzt, steht als vier Zahlen an der
|
|
Sichtung. Daraus lässt sich der Ausschnitt jederzeit neu rechnen — bei jedem
|
|
Aufruf, für jede Sichtung, auch für die von vorletztem Jahr.
|
|
|
|
Vorher war es umgekehrt: Der Detector schnitt ein zweites Bild zu und legte es
|
|
daneben. Damit war der Zuschnitt für immer festgelegt. Eine bessere Regel half
|
|
nur neuen Sichtungen, alle vorhandenen hätte man einzeln nachziehen müssen —
|
|
und das auf jeder Anlage, die es irgendwo gibt.
|
|
|
|
⚠️ **Der Unterschied ist nicht Bequemlichkeit, sondern Benutzbarkeit.** An
|
|
Handyfotos fiel auf, woran die alte Lösung scheitert: Dort ist der Vogel klein
|
|
im Bild, der enge Ausschnitt entsprechend schmal und hoch. In einer
|
|
quadratischen Kachel blieb davon die Bauchmitte übrig — formatfüllend und
|
|
unscharf, ohne Kopf und ohne Schwanz.
|
|
|
|
Das Ergebnis wird als Datei zwischengespeichert, weil Zuschneiden Rechenzeit
|
|
kostet und ein Album viele Kacheln hat. **Der Zwischenspeicher ist
|
|
wegwerfbar**: Löscht man ihn, entsteht beim nächsten Aufruf alles neu — und
|
|
genau das ist der Weg, eine geänderte Regel auszurollen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from PIL import Image
|
|
|
|
# Wie viel Luft um den Vogel. 0.35 heißt: Die Kante des Ausschnitts ist 35 %
|
|
# der Vogelgröße von ihm entfernt.
|
|
#
|
|
# 🔑 Diese Zahl ist der ganze Sinn der Umstellung. Sie zu ändern und den
|
|
# Zwischenspeicher zu leeren ändert jede Kachel im Bestand.
|
|
RAND = 0.35
|
|
|
|
# Kantenlänge des erzeugten Bildes. Die Kacheln sind kleiner; größer zu
|
|
# speichern lohnt nur für Bildschirme mit hoher Punktdichte.
|
|
KANTE = 600
|
|
|
|
# Unterordner im Medienverzeichnis. Nicht ins Repo, nicht in die Sicherung —
|
|
# alles hier lässt sich aus Original und Koordinaten neu erzeugen.
|
|
ORDNER = "zuschnitte"
|
|
|
|
# Steigt diese Zahl, bekommen alle Dateien neue Namen und werden neu
|
|
# gerechnet. So rollt man eine geänderte Regel aus, ohne etwas zu löschen.
|
|
REGEL = 1
|
|
|
|
|
|
def _ziel(sichtung) -> Path:
|
|
kennung = hashlib.sha1(
|
|
f"{sichtung.pk}-{sichtung.image.name}-{RAND}-{KANTE}-{REGEL}".encode()
|
|
).hexdigest()[:16]
|
|
return Path(settings.MEDIA_ROOT) / ORDNER / f"{sichtung.pk}-{kennung}.jpg"
|
|
|
|
|
|
def quadrat(box: tuple[float, float, float, float], breite: int, hoehe: int,
|
|
rand: float = RAND) -> tuple[int, int, int, int]:
|
|
"""Aus relativen Vogel-Koordinaten ein quadratisches Fenster in Pixeln.
|
|
|
|
⚠️ **Die Mitte wird nachgeführt, nicht der Ausschnitt gestaucht.** Sitzt
|
|
der Vogel am Bildrand, verschiebt sich das Fenster nach innen, statt
|
|
schmaler zu werden — sonst säße er außermittig in seiner eigenen Kachel.
|
|
"""
|
|
x0, y0, x1, y1 = box
|
|
bx0, by0, bx1, by1 = x0 * breite, y0 * hoehe, x1 * breite, y1 * hoehe
|
|
bw, bh = max(1.0, bx1 - bx0), max(1.0, by1 - by0)
|
|
|
|
seite = min(max(bw, bh) * (1 + 2 * rand), float(breite), float(hoehe))
|
|
mx = min(max((bx0 + bx1) / 2, seite / 2), breite - seite / 2)
|
|
my = min(max((by0 + by1) / 2, seite / 2), hoehe - seite / 2)
|
|
return (
|
|
int(mx - seite / 2), int(my - seite / 2),
|
|
int(mx + seite / 2), int(my + seite / 2),
|
|
)
|
|
|
|
|
|
def adresse(sichtung) -> str | None:
|
|
"""Adresse der Kachel für diese Sichtung — oder ``None``.
|
|
|
|
Erzeugt das Bild beim ersten Abruf. Gibt es keine Koordinaten (Sichtungen
|
|
von vor der Umstellung), fällt die Vorlage auf das gespeicherte ``crop``
|
|
zurück; dafür liefert diese Funktion ``None``.
|
|
"""
|
|
box = sichtung.vogel_box
|
|
if box is None or not sichtung.image:
|
|
return None
|
|
|
|
ziel = _ziel(sichtung)
|
|
if not ziel.exists():
|
|
try:
|
|
_erzeugen(sichtung, box, ziel)
|
|
except (OSError, ValueError):
|
|
# ⚠️ Ein fehlendes Original darf keine Seite zerreißen. Dann gilt
|
|
# der Rückfall, genau wie bei einer Sichtung ohne Koordinaten.
|
|
return None
|
|
|
|
return f"{settings.MEDIA_URL}{ORDNER}/{ziel.name}"
|
|
|
|
|
|
def _erzeugen(sichtung, box, ziel: Path) -> None:
|
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
|
with sichtung.image.open("rb") as f:
|
|
bild = Image.open(f)
|
|
bild.load()
|
|
if bild.mode != "RGB":
|
|
bild = bild.convert("RGB")
|
|
|
|
ausschnitt = bild.crop(quadrat(box, bild.width, bild.height))
|
|
if ausschnitt.width > KANTE:
|
|
ausschnitt = ausschnitt.resize((KANTE, KANTE), Image.LANCZOS)
|
|
|
|
# Erst daneben schreiben, dann umbenennen: Zwei gleichzeitige Abrufe
|
|
# dürfen sich nicht gegenseitig eine halbe Datei hinterlassen.
|
|
vorlaeufig = ziel.with_suffix(".teil")
|
|
ausschnitt.save(vorlaeufig, format="JPEG", quality=86, optimize=True)
|
|
vorlaeufig.replace(ziel)
|