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
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -0,0 +1,445 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user