Files
vogelalbum/web/dex/scoring.py
T
christian cc9e0786c2 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-14 18:55:23 +00:00

424 lines
15 KiB
Python

"""Spielmechanik: XP, Ränge, Abzeichen.
Absichtlich großzügig ausgelegt. Ein Achtjähriger, der drei Tage lang nur
Kohlmeisen bekommt, soll trotzdem vorankommen — die Seltenheitsstufen sorgen
für die Ausschläge, die Grundpunkte für den stetigen Fortschritt.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from django.db.models import Count, Sum
from django.db.models.functions import TruncDate
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop
from .models import Badge, DexEntry, EarnedBadge, Sighting, Species
# Ein Erstfang ist der eigentliche Moment — dafür der dicke Multiplikator.
FIRST_CATCH_MULTIPLIER = 3
RANKS = [
gettext_noop("Nestling"),
gettext_noop("Ästling"),
gettext_noop("Spatzenkenner"),
gettext_noop("Meisenmeister"),
gettext_noop("Federfuchser"),
gettext_noop("Amselflüsterer"),
gettext_noop("Spechtspäher"),
gettext_noop("Falkenauge"),
gettext_noop("Vogelmeister"),
gettext_noop("Legende des Vogelhauses"),
]
def xp_for_level(level: int) -> int:
"""Kumulierte XP, die für ``level`` nötig sind. Level 1 startet bei 0."""
if level <= 1:
return 0
return int(100 * (level - 1) ** 1.5)
def level_for_xp(xp: int) -> int:
level = 1
while xp >= xp_for_level(level + 1):
level += 1
if level > 200: # Sicherheitsnetz, nicht erreichbar
break
return level
def rank_name(level: int) -> str:
# Alle drei Level ein neuer Rang, oben gedeckelt.
return _(RANKS[min((level - 1) // 3, len(RANKS) - 1)])
@dataclass
class Progress:
xp: int
level: int
rank: str
xp_this_level: int
xp_next_level: int
species_seen: int
species_total: int
sightings: int
badges: int
@property
def level_progress(self) -> float:
span = self.xp_next_level - self.xp_this_level
if span <= 0:
return 1.0
return min(1.0, (self.xp - self.xp_this_level) / span)
@property
def xp_to_next(self) -> int:
return max(0, self.xp_next_level - self.xp)
def total_xp() -> int:
from_sightings = Sighting.objects.aggregate(s=Sum("xp_awarded"))["s"] or 0
from_badges = (
EarnedBadge.objects.aggregate(s=Sum("badge__xp"))["s"] or 0
)
return int(from_sightings + from_badges)
def progress() -> Progress:
xp = total_xp()
level = level_for_xp(xp)
return Progress(
xp=xp,
level=level,
rank=rank_name(level),
xp_this_level=xp_for_level(level),
xp_next_level=xp_for_level(level + 1),
species_seen=DexEntry.objects.count(),
species_total=Species.objects.count(),
sightings=Sighting.objects.bestimmt().count(),
badges=EarnedBadge.objects.count(),
)
# --------------------------------------------------------------------------
# Abzeichen
# --------------------------------------------------------------------------
BADGE_DEFS = [
("erster_gast", gettext_noop("Erster Gast"),
gettext_noop("Die allererste Sichtung im Vogelhaus."), "🐣", 50, 10),
("arten_5", gettext_noop("Fünf Freunde"),
gettext_noop("5 verschiedene Arten gesammelt."), "🖐️", 75, 20),
("arten_10", gettext_noop("Zweistellig"),
gettext_noop("10 verschiedene Arten gesammelt."), "🔟", 150, 21),
("arten_25", gettext_noop("Sammler"),
gettext_noop("25 verschiedene Arten gesammelt."), "📚", 400, 22),
("arten_40", gettext_noop("Vogelalbum-Meister"),
gettext_noop("40 verschiedene Arten gesammelt."), "👑", 1000, 23),
("fruehaufsteher", gettext_noop("Frühaufsteher"),
gettext_noop("Eine Sichtung vor 6 Uhr morgens."), "🌅", 100, 30),
("nachtschicht", gettext_noop("Nachtschicht"),
gettext_noop("Eine Sichtung nach 21 Uhr."), "🌙", 100, 31),
("volles_haus", gettext_noop("Volles Haus"),
gettext_noop("5 verschiedene Arten an einem Tag."), "🏠", 200, 40),
("stammgast", gettext_noop("Stammgast"),
gettext_noop("7 Tage in Folge mindestens eine Sichtung."), "🔥", 250, 41),
("dauergast", gettext_noop("Dauergast"),
gettext_noop("Ein Besuch, der länger als eine Minute dauerte."), "⏱️", 100, 42),
("besucher_100", gettext_noop("Hundert Besuche"),
gettext_noop("100 Sichtungen aufgezeichnet."), "💯", 200, 50),
("besucher_1000", gettext_noop("Tausend Besuche"),
gettext_noop("1000 Sichtungen aufgezeichnet."), "🏆", 750, 51),
("klopfgeist", gettext_noop("Klopfgeist"),
gettext_noop("Ein Specht war da."), "🪵", 250, 60),
("greifvogel_alarm", gettext_noop("Greifvogel-Alarm"),
gettext_noop("Ein Greifvogel oder eine Eule vor der Linse."), "🦅", 500, 61),
("seltener_gast", gettext_noop("Seltener Gast"),
gettext_noop("Eine Art der Stufe 'Selten' oder höher."), "💎", 200, 62),
]
def sync_badges() -> int:
"""Legt fehlende Abzeichen an. Idempotent — läuft bei jeder Migration mit."""
created = 0
for code, name, desc, emoji, xp, sort in BADGE_DEFS:
_, was_created = Badge.objects.update_or_create(
code=code,
defaults={
"name": name,
"description": desc,
"emoji": emoji,
"xp": xp,
"sort": sort,
},
)
created += was_created
# Codes, die es nicht mehr gibt, verschwinden — sonst bleibt nach einer
# Umbenennung die alte Fassung als Leiche in der Liste stehen.
bekannt = {code for code, *_ in BADGE_DEFS}
Badge.objects.exclude(code__in=bekannt).delete()
return created
def _award(code: str, sighting: Sighting | None = None) -> Badge | None:
badge = Badge.objects.filter(code=code).first()
if badge is None or EarnedBadge.objects.filter(badge=badge).exists():
return None
EarnedBadge.objects.create(badge=badge, sighting=sighting)
return badge
def _has_streak(days: int) -> bool:
today = timezone.localdate()
seen = {
timezone.localtime(dt).date()
for dt in Sighting.objects.bestimmt().filter(
started_at__gte=timezone.now() - timedelta(days=days + 1)
).values_list("started_at", flat=True)
}
return all((today - timedelta(days=i)) in seen for i in range(days))
# 🔑 Die Schwellen stehen hier EINMAL und werden an zwei Stellen benutzt:
# beim Vergeben (evaluate_badges) und beim Anzeigen des Fortschritts
# (fortschritt_je_abzeichen). Zwei getrennte Listen wuerden frueher oder
# spaeter auseinanderlaufen, und dann zeigt der Balken etwas anderes an,
# als das Abzeichen verlangt.
ARTEN_SCHWELLEN = ((5, "arten_5"), (10, "arten_10"),
(25, "arten_25"), (40, "arten_40"))
SICHTUNGS_SCHWELLEN = ((100, "besucher_100"), (1000, "besucher_1000"))
TAGESARTEN_SCHWELLE = 5 # volles_haus
STRAEHNE_SCHWELLE = 7 # stammgast
def bildguete(sichtung) -> float:
"""Wie brauchbar ist das Foto dieser Sichtung? Groesser ist besser.
🔑 **Nicht die Erkennungssicherheit.** Die sagt, wie sicher sich das
Modell bei der ART ist -- nicht, ob das Bild etwas taugt. Am
30.09.2026 wurde dadurch eine verwischte Aufnahme zum "besten Foto"
der Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel
und der Kopf nicht zu erkennen. Die schoene scharfe Aufnahme (0,936,
23 von 67) landete dahinter.
Stattdessen zwei Groessen, die beide messen "der Vogel war wirklich
und deutlich da":
Anteil der Bilder mit Vogel er blieb, statt kurz durchzuhuschen
mittlerer Vogel-Wert er war deutlich zu sehen
⚠️ **Das ist ein Hilfsmass, keine Schaerfemessung.** Richtig waere,
die Schaerfe des gespeicherten Bildes zu messen (Laplace-Varianz im
Detector). Das hier kommt ohne Bildverarbeitung aus und trifft in der
Praxis dieselbe Wahl.
"""
gesamt = sichtung.frames_total or 0
anteil = (sichtung.frames_with_bird or 0) / gesamt if gesamt else 0.0
wert = sichtung.mean_bird_score or 0.0
guete = anteil * wert
# Voellig ohne Messwerte (alte Sichtungen) bleibt nur die Sicherheit --
# besser als gar keine Reihenfolge.
return guete if guete > 0 else (sichtung.confidence or 0.0) / 1000
def beste_sichtung(sichtungen):
"""Die Sichtung mit dem brauchbarsten Foto, oder None."""
sichtungen = list(sichtungen)
return max(sichtungen, key=bildguete) if sichtungen else None
def fortschritt_je_abzeichen() -> dict[str, tuple[int, int]]:
"""Wie weit ist man bei den ZAEHLBAREN Abzeichen? code -> (ist, ziel).
⚠️ Absichtlich nicht fuer alle: "Ein Specht war da" oder "Eine Sichtung
vor 6 Uhr" haben keinen Fortschritt -- sie passieren oder nicht. Ein
Balken auf 0 % waere dort keine Information, sondern nur entmutigend.
Aus der Rückmeldung am 26.09.2026: *"Bei den Abzeichen waer ein Fortschrittsbalken
cool."*
"""
stand: dict[str, tuple[int, int]] = {}
arten = DexEntry.objects.count()
for ziel, code in ARTEN_SCHWELLEN:
stand[code] = (arten, ziel)
sichtungen = Sighting.objects.bestimmt().count()
for ziel, code in SICHTUNGS_SCHWELLEN:
stand[code] = (sichtungen, ziel)
# Der beste Tag bisher -- nicht der heutige. Sonst faellt der Balken
# jede Nacht auf null zurueck, obwohl man schon vier Arten hatte.
bester_tag = (
Sighting.objects.bestimmt().mit_art()
.annotate(tag=TruncDate("started_at"))
.values("tag")
.annotate(n=Count("art_id", distinct=True))
.order_by("-n")
.first()
)
stand["volles_haus"] = (bester_tag["n"] if bester_tag else 0,
TAGESARTEN_SCHWELLE)
stand["stammgast"] = (_laengste_straehne(), STRAEHNE_SCHWELLE)
return stand
def _laengste_straehne() -> int:
"""Die laengste Folge von Tagen mit mindestens einer Sichtung."""
tage = sorted({
timezone.localtime(z).date()
for z in Sighting.objects.bestimmt().values_list("started_at", flat=True)
})
if not tage:
return 0
beste = laufend = 1
for vorher, jetzt in zip(tage, tage[1:]):
laufend = laufend + 1 if (jetzt - vorher).days == 1 else 1
beste = max(beste, laufend)
return beste
def evaluate_badges(sighting: Sighting) -> list[Badge]:
"""Prüft nach einer Sichtung alle Abzeichen. Gibt die neu verdienten zurück.
⚠️ **Eine unbestimmte Sichtung ist kein Besuch und verdient nichts.** Das
musste hier eigens hin, weil die Prüfungen weiter unten verschiedener
Herkunft sind: die auf Gesamtzahlen (``bestimmt()``, ``arten_anzahl()``)
ändern sich durch eine unbestimmte Sichtung ohnehin nicht, und ab
``effective_species`` wird sauber geprüft — aber die Abzeichen für Uhrzeit
und Dauer hingen allein am Zeitstempel und wurden deshalb auch für Bilder
ohne Vogel vergeben.
🔑 Am 27.09.2026 ist genau das passiert: Die Vogeltränke meldete abends
24 Spiegelungen des Mondes auf dem Wasser, und die erste davon brachte
„Nachtschicht" samt 100 XP ein. Jede weitere Spiegelung nach 21 Uhr hätte
es erneut getan.
"""
if sighting.effective_species is None:
return []
earned: list[Badge] = []
def add(code: str) -> None:
badge = _award(code, sighting)
if badge:
earned.append(badge)
if Sighting.objects.bestimmt().count() == 1:
add("erster_gast")
species_count = DexEntry.objects.count()
for threshold, code in ARTEN_SCHWELLEN:
if species_count >= threshold:
add(code)
local = timezone.localtime(sighting.started_at)
if local.hour < 6:
add("fruehaufsteher")
if local.hour >= 21:
add("nachtschicht")
if sighting.duration_s > 60:
add("dauergast")
total = Sighting.objects.bestimmt().count()
for schwelle, code in SICHTUNGS_SCHWELLEN:
if total >= schwelle:
add(code)
day_species = (
Sighting.objects.filter(started_at__date=local.date()).arten_anzahl()
)
if day_species >= TAGESARTEN_SCHWELLE:
add("volles_haus")
if _has_streak(STRAEHNE_SCHWELLE):
add("stammgast")
species = sighting.effective_species
if species:
if "specht" in species.slug:
add("klopfgeist")
if species.rarity in ("episch", "legendaer"):
add("greifvogel_alarm")
if species.rarity in ("selten", "episch", "legendaer"):
add("seltener_gast")
return earned
# --------------------------------------------------------------------------
# Aufnahme einer neuen Sichtung
# --------------------------------------------------------------------------
@dataclass
class Reward:
sighting: Sighting
xp: int
is_first: bool
badges: list[Badge]
level_before: int
level_after: int
@property
def leveled_up(self) -> bool:
return self.level_after > self.level_before
def register(sighting: Sighting) -> Reward:
"""Verbucht eine frisch gespeicherte Sichtung: XP, Dex-Eintrag, Abzeichen.
Erwartet, dass ``sighting`` bereits in der DB liegt (die Badge-Prüfungen
zählen über die Tabelle).
"""
level_before = level_for_xp(total_xp())
species = sighting.effective_species
xp = 0
is_first = False
if species is not None:
entry = DexEntry.objects.filter(species=species).first()
is_first = entry is None
xp = species.xp * (FIRST_CATCH_MULTIPLIER if is_first else 1)
# Eine sichere Bestimmung ist mehr wert als eine wackelige.
xp = int(round(xp * (0.5 + 0.5 * min(1.0, sighting.confidence))))
if entry is None:
DexEntry.objects.create(
species=species,
first_seen=sighting.started_at,
last_seen=sighting.started_at,
count=1,
best_sighting=sighting,
)
else:
entry.count += 1
entry.last_seen = max(entry.last_seen, sighting.started_at)
# 🔑 Nicht nach Sicherheit, sondern nach Bildguete -- siehe dort.
best = entry.best_sighting
if best is None or bildguete(sighting) > bildguete(best):
entry.best_sighting = sighting
entry.save(update_fields=["count", "last_seen", "best_sighting"])
sighting.is_first_of_species = is_first
sighting.xp_awarded = xp
sighting.save(update_fields=["is_first_of_species", "xp_awarded"])
badges = evaluate_badges(sighting)
return Reward(
sighting=sighting,
xp=xp,
is_first=is_first,
badges=badges,
level_before=level_before,
level_after=level_for_xp(total_xp()),
)