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,423 @@
|
||||
"""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()),
|
||||
)
|
||||
Reference in new issue
Block a user