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

+579
View File
@@ -0,0 +1,579 @@
"""Lese-Schnittstelle für die Handy-App.
🔑 **Warum ein eigenes Modul und nicht mehr Endpunkte in ``api.py``.** Dort
steht der Verkehr mit den *Kameras*: einliefern, Konfiguration abholen,
Warteschlange abarbeiten. Das hier ist der umgekehrte Fall — ein Mensch
blättert in seinem Album. Die beiden haben verschiedene Leser, verschiedene
Antwortformen und ändern sich aus verschiedenen Gründen; sie in eine Datei zu
legen hieße, bei jeder App-Änderung den Kameraverkehr mit zu lesen.
⚠️ **Es ist absichtlich keine neue Wahrheit.** Jede Zahl hier kommt aus
derselben Quelle wie die Website: ``scoring.progress()``, ``DexEntry``,
``Sighting``. Wer die Punktevergabe ändert, ändert beides zugleich — sonst
stünde auf dem Telefon Stufe 3 und im Browser Stufe 2, und niemand wüsste,
welche der beiden lügt.
🔑 **Die Sprache kommt aus ``Accept-Language``.** Djangos LocaleMiddleware
steht schon in der Kette, also liefern die ``*_lokal``-Eigenschaften der
Modelle von selbst Deutsch oder Englisch. Die App muss nichts übersetzen und
wir pflegen die Texte nicht zweimal.
"""
from __future__ import annotations
import hmac
from django.conf import settings
from django.db.models import Q
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET
from . import scoring
from .models import Badge, Camera, DexEntry, Sighting, Species
from .species_meta import HABITATS
# Höchstens so viele Sichtungen je Seite. Ein Feed auf dem Telefon lädt
# nach; eine Antwort mit allen 300 Sichtungen und ihren Bildadressen wäre
# auf einer Mobilfunkverbindung die längste Wartezeit der ganzen App.
SEITE_MAX = 60
SEITE_VORGABE = 24
# --------------------------------------------------------------------------
# Zugang
# --------------------------------------------------------------------------
#
# 🔑 **Die App kommt mit EINEM Token aus.** Sie muss zwei Dinge können:
# Fotos einliefern (braucht einen Kamera-Token, siehe ``api.hochladen``) und
# das Album lesen. Hätte das Lesen einen eigenen Token, müsste man beim
# Einrichten zwei Geheimnisse abtippen und wir hätten einen Fehlerfall mehr
# ("hochladen geht, Album bleibt leer"). Deshalb gilt hier **beides**: der
# Lese-Token wie bei Home Assistant, oder der Token der eigenen Kamera.
#
# ⚠️ Dass ein Kamera-Token hier LESEN darf, ist kein Dammbruch: Das Album
# steht ohnehin öffentlich im Netz — ``/sammlung/``, ``/sichtungen/`` und
# ``/abzeichen/`` sind ohne Anmeldung erreichbar. Diese Schnittstelle gibt
# exakt dieselben Angaben in anderer Verpackung heraus und **nichts darüber
# hinaus**: insbesondere keine Koordinaten (die zeigt die Website nicht), kein
# Live-Bild und keine Kamera-Token.
def _zugang(request: HttpRequest) -> bool:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return False
token = header[7:]
if Camera.by_token(token) is not None:
return True
# Konstantzeit-Vergleich, und leer heißt ZU — dieselbe Regel wie in
# ``api._lesen_erlaubt``. Ein vergessenes Geheimnis darf nicht die Tür
# aufmachen, sondern muss sie zuhalten.
if settings.LESE_TOKEN:
return hmac.compare_digest(token, settings.LESE_TOKEN)
return False
def _abgelehnt() -> JsonResponse:
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
# --------------------------------------------------------------------------
# Bausteine, die mehrere Endpunkte brauchen
# --------------------------------------------------------------------------
def _bild(request: HttpRequest, feld) -> str | None:
"""Absolute Adresse eines Bildfelds, oder ``None``.
⚠️ Absolut, nicht relativ. Eine App hat keine Basisadresse, von der aus
``/media/…`` etwas bedeuten würde — sie kennt nur die URL, die ihr der
Nutzer eingetragen hat, und die zeigt womöglich auf einen anderen Host als
die Medien.
"""
if not feld:
return None
return request.build_absolute_uri(feld.url)
def _art_kurz(request: HttpRequest, art: Species, eintrag: DexEntry | None) -> dict:
"""Eine Albumseite, wie die App sie im Raster braucht.
``gefunden=False`` heißt Schatten: Name und Nummer bleiben sichtbar, das
Bild nicht — genau wie im Klebebildchen-Album, wo die leere Stelle die
Umrisse zeigt. Die App soll diese Entscheidung nicht selbst treffen
müssen, deshalb steht sie hier als Feld.
"""
daten = {
"slug": art.slug,
"nummer": art.number,
"nummer_text": art.dex_nr,
"name": art.name,
"zweitname": art.zweitname,
"wissenschaftlich": art.scientific,
"seltenheit": art.rarity,
"seltenheit_label": art.rarity_label,
"seltenheit_farbe": art.rarity_color,
"typ": art.typ,
"typ_label": art.typ_label,
"typ_farbe": art.typ_color,
"typ_schrift": art.typ_ink,
"lebensraum": art.lebensraum_typ,
"punkte": art.xp,
"gefunden": eintrag is not None,
# Das Referenzbild gehört zum Katalog und ist auch bei ungefundenen
# Arten da. Die App zeigt es erst nach dem Fang — aber die
# Entscheidung trifft sie, nicht wir: fürs Nachschlagen ("wie sieht
# ein Gartenrotschwanz aus?") braucht sie es vorher.
"referenzbild": _bild(request, art.reference_image),
}
if eintrag is not None:
daten |= {
"anzahl": eintrag.count,
"erstmals": eintrag.first_seen.isoformat(),
"zuletzt": eintrag.last_seen.isoformat(),
"neu": eintrag.is_new,
"foto": (
request.build_absolute_uri(eintrag.best_sighting.kachel_url)
if eintrag.best_sighting and eintrag.best_sighting.kachel_url
else None
),
}
return daten
def _sichtung_kurz(request: HttpRequest, s: Sighting) -> dict:
"""Eine Zeile im Feed.
⚠️ ``effective_species`` statt ``species``: Eine Handbestimmung im Admin
gewinnt gegen das Modell. Wer hier ``species`` nähme, zeigte in der App
weiter die falsche Art, die längst korrigiert wurde — und zwar nur
in der App, was die Fehlersuche teuer macht.
"""
art = s.effective_species
return {
"id": s.id,
"zeitpunkt": s.started_at.isoformat(),
"dauer_s": round(s.duration_s, 1),
"wartet": s.wartet_auf_bestimmung,
"art": (
{
"slug": art.slug,
"name": art.name,
"wissenschaftlich": art.scientific,
"seltenheit": art.rarity,
"seltenheit_farbe": art.rarity_color,
"typ_farbe": art.typ_color,
# 🔑 Das Referenzbild gehört an die Sichtung, obwohl es zur Art
# gehört: Nicht jede Sichtung hat ein eigenes Foto (der
# Detector verwirft schlechte Bilder, und ältere Einträge haben
# gar keins). Ohne diesen Rückfall steht in der App ein
# Fragezeichen neben einer Art, die sie kennt und abbilden
# könnte — gefunden am gerenderten Feed, nicht im Test.
"referenzbild": _bild(request, art.reference_image),
}
if art
else None
),
"sicherheit": round(s.confidence, 3),
"vogelwert": round(s.mean_bird_score, 3),
"handbestimmt": s.confirmed_species_id is not None,
"neue_art": s.is_first_of_species,
"punkte": s.xp_awarded,
"kamera": (
{"kennung": s.camera.slug, "name": s.camera.name, "mobil": s.camera.mobil}
if s.camera
else None
),
"foto": _bild(request, s.image),
# 🔑 Der Ausschnitt wird aus dem Original gerechnet, nicht als
# zweites Bild gespeichert — siehe dex/zuschnitt.py. Damit schneidet
# eine geänderte Regel sofort den ganzen Bestand neu, und die App
# bekommt dieselbe Kachel wie die Website.
"ausschnitt": (request.build_absolute_uri(s.kachel_url)
if s.kachel_url else None),
}
def _fortschritt() -> dict:
"""Punkte, Stufe, Rang — die Kopfzeile jeder App-Ansicht."""
p = scoring.progress()
return {
"punkte": p.xp,
"stufe": p.level,
"rang": p.rank,
"punkte_stufe_start": p.xp_this_level,
"punkte_stufe_ziel": p.xp_next_level,
"punkte_bis_aufstieg": p.xp_to_next,
"anteil_stufe": round(p.level_progress, 3),
"arten_gesammelt": p.species_seen,
"arten_gesamt": p.species_total,
"sichtungen": p.sightings,
"abzeichen": p.badges,
}
# --------------------------------------------------------------------------
# Endpunkte
# --------------------------------------------------------------------------
@require_GET
def start(request: HttpRequest) -> JsonResponse:
"""Der Startbildschirm in einer Antwort.
🔑 Bewusst ein Sammelabruf statt vier einzelner. Beim Öffnen der App
wartet ein Mensch — vier Anfragen hintereinander über Mobilfunk sind
viermal die Latenz, und drei davon zeigen Dinge, die ohnehin zusammen auf
einen Bildschirm gehören.
"""
if not _zugang(request):
return _abgelehnt()
jetzt = timezone.now()
heute = jetzt.astimezone().date()
letzte = (
Sighting.objects.bestimmt()
.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")
.first()
)
neueste = (
DexEntry.objects.select_related("species", "best_sighting")
.order_by("-first_seen")[:6]
)
return JsonResponse(
{
"fortschritt": _fortschritt(),
"sichtungen_heute": Sighting.objects.bestimmt()
.filter(started_at__date=heute)
.count(),
"letzte_sichtung": _sichtung_kurz(request, letzte) if letzte else None,
# Die letzten Erstfänge — das ist der Teil, den man jemandem
# zeigt. Eine Liste aller Sichtungen beantwortet "war was los",
# die Erstfänge beantworten "ist die Sammlung gewachsen".
"neueste_arten": [
_art_kurz(request, e.species, e) for e in neueste
],
"wartende_fotos": Sighting.objects.wartend().count(),
"kameras": [
{
"kennung": k.slug,
"name": k.name,
"standort": k.location,
"mobil": k.mobil,
"aktiv": k.active,
"still": k.is_stale,
"letzte_sichtung": (
k.last_seen.isoformat() if k.last_seen else None
),
}
for k in Camera.objects.order_by("name")
],
"stand": jetzt.isoformat(),
}
)
@require_GET
def album(request: HttpRequest) -> JsonResponse:
"""Das ganze Album eines Lebensraums, gefundene und fehlende.
⚠️ **Vorgabe ``garten``, wie auf der Website.** 148 Arten auf einmal sind
kein Ziel, sondern eine Wand; ``?lebensraum=alle`` holt sie trotzdem, wenn
die App einen Nachschlage-Modus anbietet.
"""
if not _zugang(request):
return _abgelehnt()
lebensraum = request.GET.get("lebensraum", "garten")
if lebensraum == "alle":
lebensraum = ""
arten = Species.objects.all()
if lebensraum:
arten = arten.filter(lebensraum_typ=lebensraum)
eintraege = {
e.species_id: e
for e in DexEntry.objects.select_related("best_sighting")
}
seiten = [_art_kurz(request, a, eintraege.get(a.id)) for a in arten]
# Nach Albumnummer, nicht nach Fundstatus. Die Nummer ist die Seitenzahl:
# Sie bleibt stehen, wo sie ist, auch wenn die Lücke daneben sich füllt.
seiten.sort(key=lambda s: s["nummer"] or 9999)
return JsonResponse(
{
"arten": seiten,
"gefunden": sum(1 for s in seiten if s["gefunden"]),
"gesamt": len(seiten),
"lebensraum": lebensraum,
"lebensraeume": _lebensraeume(),
}
)
def _lebensraeume() -> list[dict]:
"""Die Reiter über dem Album — nur die, in denen Arten stehen."""
from django.utils.translation import get_language
englisch = (get_language() or "de").startswith("en")
raus = []
for code, paar in HABITATS.items():
anzahl = Species.objects.filter(lebensraum_typ=code).count()
if not anzahl:
continue
gefunden = DexEntry.objects.filter(species__lebensraum_typ=code).count()
raus.append(
{
"code": code,
"label": paar[1] if englisch else paar[0],
"gesamt": anzahl,
"gefunden": gefunden,
}
)
return raus
@require_GET
def art(request: HttpRequest, slug: str) -> JsonResponse:
"""Eine Albumseite in voller Länge: Steckbrief, Besuche, Tagesverlauf."""
if not _zugang(request):
return _abgelehnt()
species = Species.objects.filter(slug=slug).first()
if species is None:
return JsonResponse({"fehler": f"unbekannte Art '{slug}'"}, status=404)
eintrag = (
DexEntry.objects.filter(species=species)
.select_related("best_sighting")
.first()
)
passend = Q(species=species) | Q(confirmed_species=species)
besuche = (
Sighting.objects.filter(passend)
.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")[:24]
)
# Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche,
# in Ortszeit — eine Sichtung um 6:31 gehört in die 6-Uhr-Säule, auch
# wenn sie in der Datenbank als 04:31 UTC steht.
stunden = [0] * 24
for dt in Sighting.objects.filter(passend).values_list("started_at", flat=True):
stunden[timezone.localtime(dt).hour] += 1
spitze = max(stunden) or 1
aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or []))
reihenfolge = species.verwechslung or []
aehnliche.sort(key=lambda s: reihenfolge.index(s.slug))
vorher = (
Species.objects.filter(number__lt=species.number).order_by("-number").first()
)
nachher = (
Species.objects.filter(number__gt=species.number).order_by("number").first()
)
return JsonResponse(
{
"art": _art_kurz(request, species, eintrag)
| {
"kategorie": species.kategorie_lokal,
"beschreibung": species.absaetze,
"kurzfakt": species.fact,
"groesse_cm": species.size_cm,
"gewicht_g": species.gewicht_g,
"spannweite_cm": species.spannweite_cm,
"zug": species.zug_label,
"nahrung": species.nahrung_lokal,
"lebensraum_text": species.lebensraum_lokal,
"stimme": species.stimme_lokal,
"lebensraum_label": species.habitat_label,
"bildnachweis": {
"urheber": species.reference_credit,
"lizenz": species.reference_license,
"quelle": species.reference_source,
},
# Fertig gerechnete Balken, wie auf der Website. Die
# Bezugsgrößen stecken in ``Species.balken()``; sie in der App
# nachzubauen hieße, zwei Wahrheiten über denselben Katalog zu
# haben.
"balken": species.balken(),
},
"besuche": [_sichtung_kurz(request, s) for s in besuche],
"tagesverlauf": [
{"stunde": h, "anzahl": n, "anteil": round(n / spitze, 3)}
for h, n in enumerate(stunden)
],
"verwechslung": [
_art_kurz(request, s, None) for s in aehnliche
],
"blaettern": {
"vorher": vorher.slug if vorher else None,
"nachher": nachher.slug if nachher else None,
},
}
)
@require_GET
def sichtungen(request: HttpRequest) -> JsonResponse:
"""Der Feed. Jüngste zuerst, seitenweise.
``?nur_bestimmt=1`` blendet aus, was keine Art hat — das ist die Ansicht
für jemanden, der *sammelt*; die vollständige Liste ist die für jemanden,
der *prüft*.
"""
if not _zugang(request):
return _abgelehnt()
qs = Sighting.objects.select_related(
"species", "confirmed_species", "camera"
).order_by("-started_at")
if request.GET.get("nur_bestimmt"):
qs = qs.bestimmt()
kamera = request.GET.get("kamera", "")
if kamera:
qs = qs.filter(camera__slug=kamera)
art_slug = request.GET.get("art", "")
if art_slug:
qs = qs.filter(Q(species__slug=art_slug) | Q(confirmed_species__slug=art_slug))
seite = _ganzzahl(request.GET.get("seite"), 1, mindestens=1)
pro_seite = _ganzzahl(request.GET.get("anzahl"), SEITE_VORGABE, mindestens=1)
pro_seite = min(pro_seite, SEITE_MAX)
gesamt = qs.count()
ab = (seite - 1) * pro_seite
return JsonResponse(
{
"sichtungen": [
_sichtung_kurz(request, s) for s in qs[ab : ab + pro_seite]
],
"seite": seite,
"pro_seite": pro_seite,
"gesamt": gesamt,
"weiter": ab + pro_seite < gesamt,
}
)
def _ganzzahl(wert, vorgabe: int, mindestens: int = 0) -> int:
try:
return max(mindestens, int(wert))
except (TypeError, ValueError):
return vorgabe
@require_GET
def sichtung(request: HttpRequest, sichtung_id: int) -> JsonResponse:
"""Eine einzelne Sichtung.
🔑 **Das ist der Endpunkt, mit dem die App nach einem Upload nachfragt.**
``api.hochladen`` legt eine wartende Sichtung an und gibt ihre Kennung
zurück; bestimmt wird sie erst, wenn der Detector sie abgeholt hat. Die
App fragt also hier nach, bis ``wartet`` auf ``false`` springt, und zeigt
dann das Ergebnis.
⚠️ ``wartet: false`` **und** ``art: null`` ist kein Fehler, sondern die
Antwort "auf dem Foto war kein Vogel". Die App muss beide Fälle
unterscheiden können, sonst dreht sie für ein Foto vom eigenen Daumen
endlos die Warteschleife.
"""
if not _zugang(request):
return _abgelehnt()
s = (
Sighting.objects.select_related("species", "confirmed_species", "camera")
.filter(id=sichtung_id)
.first()
)
if s is None:
return JsonResponse({"fehler": "keine Sichtung mit dieser Kennung"}, status=404)
daten = _sichtung_kurz(request, s)
art = s.effective_species
if art is not None:
eintrag = DexEntry.objects.filter(species=art).first()
daten["album"] = _art_kurz(request, art, eintrag)
# Nach einem Erstfang will die App die neue Punktzahl zeigen, ohne noch
# einmal den Startbildschirm zu holen.
daten["fortschritt"] = _fortschritt()
return JsonResponse(daten)
@require_GET
def abzeichen(request: HttpRequest) -> JsonResponse:
"""Alle Abzeichen mit Fortschritt — verdiente und offene.
🔑 Nur die *zählbaren* Abzeichen haben einen Balken. "Ein Specht war da"
passiert oder passiert nicht; ein Balken auf 0 % wäre dort keine
Information, sondern nur entmutigend. Dieselbe Regel wie auf der Website,
und sie kommt aus derselben Funktion.
"""
if not _zugang(request):
return _abgelehnt()
from .templatetags.dex_extras import abzeichen_bild
verdient = {
b.code: b.earned
for b in Badge.objects.filter(earned__isnull=False).select_related("earned")
}
stand = scoring.fortschritt_je_abzeichen()
zeilen = []
for badge in Badge.objects.all():
erworben = verdient.get(badge.code)
# 🔑 **Der Pin, nicht das Emoji.** Die Website zeigt die gezeichneten
# Emaille-Pins aus `static/img/pins/`; eine App mit Emoji daneben wäre
# eine zweite Gestaltung derselben Sache. Dazu kommt der Grund, aus
# dem die Pins überhaupt entstanden sind: Auf einem System ohne
# Emoji-Schrift steht statt des Abzeichens ein leeres Rechteck — und
# zwar ausgerechnet auf der Seite, die Belohnungen zeigt.
#
# ⚠️ Das Emoji bleibt trotzdem im Feld `symbol`: Ein später
# hinzugefügtes Abzeichen hat noch keinen Pin, und dann ist das Emoji
# besser als eine Lücke. Die App entscheidet, was sie nimmt.
pin = abzeichen_bild(badge.code)
zeile = {
"code": badge.code,
"name": badge.name_lokal,
"beschreibung": badge.description_lokal,
"symbol": badge.emoji,
"bild": request.build_absolute_uri(pin) if pin else None,
"punkte": badge.xp,
"verdient": erworben is not None,
"am": erworben.earned_at.isoformat() if erworben else None,
}
messbar = stand.get(badge.code)
if messbar and erworben is None:
ist, ziel = messbar
zeile |= {
"ist": ist,
"ziel": ziel,
"anteil": round(ist / ziel, 3) if ziel else 0.0,
}
zeilen.append(zeile)
return JsonResponse(
{
"abzeichen": zeilen,
"verdient": sum(1 for z in zeilen if z["verdient"]),
"gesamt": len(zeilen),
}
)