Files
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

446 lines
16 KiB
Python

"""Ansichten des Vogelalbum."""
from __future__ import annotations
from collections import Counter
from datetime import timedelta
from django.contrib.admin.views.decorators import staff_member_required
from django.db.models import Count, Max, Q
from django.http import Http404, JsonResponse
from django.conf import settings
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from . import scoring
from .api import fotos_annehmen
from .bilder import KeinBild
from .models import Badge, Camera, DexEntry, Sighting, Species
from .species_meta import HABITATS, RARITY_COLOR, TYPE_STYLE
def _base_context() -> dict:
return {
"progress": scoring.progress(),
# Der Live-Link in der Navigation erscheint nur, wenn es etwas zu sehen
# gibt — auf der öffentlichen Instanz also nie.
"live_available": Camera.live_cameras().exists(),
# Pflicht aus der AGPL, nicht Dekoration — siehe settings.QUELLTEXT_URL.
"quelltext_url": settings.QUELLTEXT_URL,
}
def blickfang_titel(letzte, heute) -> str:
"""Die Überschrift der Startseite, passend zum letzten Besuch.
🔑 Vorher stand dort fest „Wer war heute im Futterhaus?" — auch wenn der
letzte Gast fünf Tage her war. Eine Überschrift, die etwas behauptet, was
daneben widerlegt wird, kostet mehr Vertrauen als sie Schwung bringt.
Wochentag und Datum kommen über ``date_format``, damit sie der aktiven
Sprache folgen; ein selbst gebautes ``d.m.`` wäre auf Englisch falsch herum.
"""
from django.utils.formats import date_format
if letzte is None:
return _("Noch wartet die Kamera")
tag = timezone.localtime(letzte.started_at).date()
tage = (heute - tag).days
if tage <= 0:
return _("Wer war heute zu Gast?")
if tage == 1:
return _("Zuletzt gestern zu Gast")
if tage < 7:
return _("Zuletzt am %(wochentag)s zu Gast") % {
"wochentag": date_format(tag, "l")
}
return _("Zuletzt am %(datum)s zu Gast") % {
"datum": date_format(tag, "DATE_FORMAT")
}
def type_label(code: str) -> str:
from django.utils.translation import get_language
from .species_meta import TYPE_LABEL_EN
if (get_language() or "de").startswith("en"):
return TYPE_LABEL_EN.get(code, code)
return TYPE_STYLE.get(code, (code,))[0]
def habitat_label(code: str) -> str:
from django.utils.translation import get_language
from .species_meta import HABITATS
paar = HABITATS.get(code)
if not paar:
return code
return paar[1] if (get_language() or "de").startswith("en") else paar[0]
def rarity_choices() -> list[tuple[str, str]]:
"""Seltenheitsstufen mit Beschriftung in der aktiven Sprache."""
from django.utils.translation import get_language
from .models import RARITY_CHOICES
from .species_meta import RARITY_LABEL_EN
if (get_language() or "de").startswith("en"):
return [(k, RARITY_LABEL_EN.get(k, k)) for k, _ in RARITY_CHOICES]
return list(RARITY_CHOICES)
def home(request):
now = timezone.now()
today = timezone.localdate()
recent = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")[:8]
)
# ⚠️ ``bestimmt()``, nicht alles: Spiegelungen auf der Vogeltränke sind
# kein Besuch. Und ``arten_anzahl()`` statt values("species") — sonst
# zaehlt eine von Hand korrigierte Sichtung weiter bei der alten Art.
today_sightings = Sighting.objects.bestimmt().filter(started_at__date=today)
today_species = today_sightings.arten_anzahl()
# Erstfänge der letzten Woche — das ist die Neuigkeit, die man sehen will.
new_entries = (
DexEntry.objects.select_related("species", "best_sighting")
.filter(first_seen__gte=now - timedelta(days=7))
.order_by("-first_seen")[:4]
)
top = (
DexEntry.objects.select_related("species", "best_sighting")
.order_by("-count")[:5]
)
ctx = _base_context() | {
"recent": recent,
"blickfang_titel": blickfang_titel(recent[0] if recent else None, today),
"today_count": today_sightings.count(),
"today_species": today_species,
"new_entries": new_entries,
"top": top,
"badges": Badge.objects.filter(earned__isnull=False).order_by("-earned__earned_at")[:6],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/home.html", ctx)
def dex(request):
"""Der Katalog. Ungefangene Arten bleiben Silhouette — genau das treibt
die Sammelmotivation."""
entries = {e.species_id: e for e in DexEntry.objects.select_related("best_sighting")}
rarity_filter = request.GET.get("seltenheit", "")
typ_filter = request.GET.get("typ", "")
# Ohne Vorgabe: Garten. 148 Arten auf einmal sind kein Ziel, sondern eine
# Wand — man soll eine Sammlung sehen, die sich füllen lässt.
habitat = request.GET.get("lebensraum", "garten")
if habitat == "alle":
habitat = ""
species = Species.objects.all()
if habitat:
species = species.filter(lebensraum_typ=habitat)
if rarity_filter:
species = species.filter(rarity=rarity_filter)
if typ_filter:
species = species.filter(typ=typ_filter)
cards = [
{"species": s, "entry": entries.get(s.id), "found": s.id in entries}
for s in species
]
# Nach Dex-Nummer, wie die Seitenzahl im Album. Die Nummer ist bereits nach
# Seltenheit vergeben, die Sortierung liest sich also ohnehin sinnvoll —
# und sie bleibt stabil, egal was gefunden wurde.
cards.sort(key=lambda c: c["species"].number or 9999)
# Nur Typen anbieten, die im aktiven Lebensraum auch vorkommen — sonst
# steht im Auswahlfeld "Möwe" neben einer leeren Gartensammlung.
im_bereich = Species.objects.all()
if habitat:
im_bereich = im_bereich.filter(lebensraum_typ=habitat)
zaehlung = Counter(im_bereich.values_list("typ", flat=True))
typen = [
{
"code": code,
"label": type_label(code),
"n": zaehlung[code],
"farbe": TYPE_STYLE[code][1],
"ink": TYPE_STYLE[code][2],
}
for code in TYPE_STYLE
if zaehlung.get(code)
]
habitate = [
{
"code": code,
"label": habitat_label(code),
"n": Species.objects.filter(lebensraum_typ=code).count(),
}
for code in HABITATS
]
habitate = [h for h in habitate if h["n"]]
ctx = _base_context() | {
"cards": cards,
"found_count": sum(1 for c in cards if c["found"]),
"total_count": len(cards),
"rarity_filter": rarity_filter,
"typ_filter": typ_filter,
"habitat": habitat,
"habitate": habitate,
"habitat_label": habitat_label(habitat) if habitat else "",
"gesamt_alle": Species.objects.count(),
"typen": typen,
"rarity_color": RARITY_COLOR,
"rarities": rarity_choices(),
}
return render(request, "dex/dex.html", ctx)
def species_detail(request, slug: str):
species = get_object_or_404(Species, slug=slug)
entry = DexEntry.objects.filter(species=species).select_related("best_sighting").first()
sightings = (
Sighting.objects.filter(Q(species=species) | Q(confirmed_species=species))
.select_related("camera")
.order_by("-started_at")[:24]
)
# Blättern wie im Album: die Nachbarn nach Dex-Nummer.
vorher = Species.objects.filter(number__lt=species.number).order_by("-number").first()
nachher = Species.objects.filter(number__gt=species.number).order_by("number").first()
# Verwechslungsgefahr in Katalogreihenfolge, damit die Reihe stabil bleibt.
aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or []))
aehnliche.sort(key=lambda s: (species.verwechslung or []).index(s.slug))
# Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche.
hours = [0] * 24
for dt in Sighting.objects.filter(
Q(species=species) | Q(confirmed_species=species)
).values_list("started_at", flat=True):
hours[timezone.localtime(dt).hour] += 1
peak = max(hours) or 1
ctx = _base_context() | {
"species": species,
"entry": entry,
"sightings": sightings,
"vorher": vorher,
"nachher": nachher,
"aehnliche": aehnliche,
"hours": [{"h": h, "n": n, "pct": round(100 * n / peak)} for h, n in enumerate(hours)],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/species.html", ctx)
def sightings(request):
qs = (
Sighting.objects.select_related("species", "confirmed_species", "camera")
.order_by("-started_at")
)
if request.GET.get("nur_bestimmt"):
qs = qs.exclude(species__isnull=True, confirmed_species__isnull=True)
kamera = request.GET.get("kamera", "")
if kamera:
qs = qs.filter(camera__slug=kamera)
try:
page = max(1, int(request.GET.get("seite", 1)))
except ValueError:
page = 1
per_page = 40
total = qs.count()
cameras = list(Camera.objects.filter(active=True))
ctx = _base_context() | {
"sightings": qs[(page - 1) * per_page : page * per_page],
"page": page,
"has_next": page * per_page < total,
"total": total,
"nur_bestimmt": bool(request.GET.get("nur_bestimmt")),
"kamera": kamera,
# Der Filter lohnt sich erst, wenn es mehr als eine Kamera gibt.
"cameras": cameras if len(cameras) > 1 else [],
"rarity_color": RARITY_COLOR,
}
return render(request, "dex/sightings.html", ctx)
def badges(request):
earned_codes = set(
Badge.objects.filter(earned__isnull=False).values_list("code", flat=True)
)
# 🔑 Nur die ZAEHLBAREN Abzeichen haben einen Fortschritt. "Ein Specht
# war da" passiert oder passiert nicht -- ein Balken auf 0 % waere dort
# keine Information, sondern nur entmutigend. Siehe scoring.py.
fortschritt = scoring.fortschritt_je_abzeichen()
rows = []
for badge in Badge.objects.all():
verdient = badge.code in earned_codes
zeile = {
"badge": badge,
"earned": verdient,
"at": badge.earned.earned_at if verdient else None,
}
stand = fortschritt.get(badge.code)
# Bei verdienten Abzeichen keinen Balken mehr -- der stuende
# dauerhaft auf 100 % und sagt nichts.
if stand and not verdient:
ist, ziel = stand
zeile["ist"] = ist
zeile["ziel"] = ziel
zeile["anteil"] = min(100, round(ist * 100 / ziel)) if ziel else 0
rows.append(zeile)
return render(request, "dex/badges.html", _base_context() | {"rows": rows})
@xframe_options_exempt
def widget(request):
"""Kompakte Einbettung für eine fremde Seite — ohne Navigation.
🔴 **Ohne diesen Ausnahme-Dekorator ist das Widget nicht einbettbar**, und
zwar genau entgegen seinem Zweck: Djangos ``XFrameOptionsMiddleware``
schickt über jede Antwort ``X-Frame-Options: DENY``, also auch über diese.
Der Docstring sagte „iframe-tauglich", die Antwort sagte das Gegenteil —
gefunden am 14.10.2026 an den echten Kopfzeilen der Live-Instanz, nicht im
Code.
⚠️ Der Schutz bleibt für alles andere bestehen; ausgenommen ist nur die
eine Seite, die eingebettet werden soll. Sie zeigt nichts als die letzte
Sichtung und hat kein Formular — es gibt hier nichts zu erschleichen.
"""
latest = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")
.first()
)
ctx = _base_context() | {"latest": latest, "rarity_color": RARITY_COLOR}
return render(request, "dex/widget.html", ctx)
def live(request):
"""Livebilder der Kameras, die im Admin dafür freigegeben sind.
Gibt es keine, ist die Seite nicht da — 404 statt einer leeren Seite mit
einem Versprechen, das niemand eingelöst hat. Der Link in der Navigation
erscheint aus demselben Grund nur, wenn es etwas zu sehen gibt.
"""
kameras = list(Camera.live_cameras())
if not kameras:
raise Http404("Kein Livebild freigegeben")
return render(request, "dex/live.html", _base_context() | {"cameras": kameras})
def api_stats(request):
"""Kleiner JSON-Endpunkt, damit die Homepage live tickern kann."""
p = scoring.progress()
latest = (
Sighting.objects.select_related("species", "confirmed_species")
.exclude(species__isnull=True, confirmed_species__isnull=True)
.order_by("-started_at")
.first()
)
return JsonResponse(
{
"level": p.level,
"rang": p.rank,
"xp": p.xp,
"arten_gesehen": p.species_seen,
"arten_gesamt": p.species_total,
"sichtungen": p.sightings,
"letzte": (
{
"art": latest.effective_species.german,
"zeitpunkt": latest.started_at.isoformat(),
"confidence": round(latest.confidence, 3),
"bild": (
request.build_absolute_uri(latest.crop.url)
if latest.crop
else None
),
}
if latest and latest.effective_species
else None
),
}
)
# ⚠️ **Hinter dem Admin-Login, und das ist keine Bequemlichkeit.** Die Instanz
# darf öffentlich stehen — ein offenes Upload-Formular wäre dann eine
# Einladung, fremde Bilder in eine fremde Sammlung zu legen, und der
# Detector würde sie auch noch brav verarbeiten. Das vorhandene
# Django-Login reicht dafür; das Projekt braucht keine eigene
# Nutzerverwaltung, und niemand soll sich eins anlegen müssen.
@staff_member_required
def hochladen(request):
"""Fotos von Hand einliefern — der Weg ohne App.
🔑 Benutzt dieselbe Warteschlange wie die spätere App (``fotos_annehmen``).
Das ist der eigentliche Zweck dieser Seite: Funktioniert sie, ist die
ganze Kette bewiesen — annehmen, warten, abholen, bestimmen, sammeln —
ohne dass eine Zeile App existiert. Dasselbe Argument wie bei der
Home-Assistant-Brücke, die über die Schnittstelle geht statt an die
Datenbank.
"""
ctx = _base_context()
ctx["kameras"] = Camera.objects.filter(mobil=True).order_by("name")
if request.method == "POST":
dateien = request.FILES.getlist("bilder")
if not dateien:
ctx["fehler"] = _("Bitte mindestens ein Bild auswählen.")
else:
kamera = None
if request.POST.get("kamera"):
kamera = Camera.objects.filter(
slug=request.POST["kamera"], mobil=True).first()
try:
angelegt = fotos_annehmen(
dateien,
camera=kamera,
latitude=request.POST.get("latitude"),
longitude=request.POST.get("longitude"),
)
except KeinBild as problem:
# Dem Menschen am Formular sagen, was nicht ging — nicht eine
# Fehlerseite zeigen und ihn raten lassen.
ctx["fehler"] = _("Das war kein brauchbares Bild: %(grund)s") % {
"grund": problem,
}
else:
# Nach dem Hochladen umleiten, damit ein Neuladen der Seite die
# Bilder nicht ein zweites Mal einliefert.
return redirect(f"{reverse('hochladen')}?angelegt={len(angelegt)}")
try:
ctx["angelegt"] = int(request.GET.get("angelegt", 0))
except ValueError:
ctx["angelegt"] = 0
ctx["wartend"] = Sighting.objects.wartend().count()
ctx["wartende_fotos"] = Sighting.objects.wartend().select_related("camera")[:12]
return render(request, "dex/hochladen.html", ctx)