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-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

@@ -0,0 +1,184 @@
"""Setzt die Art einer Sichtung richtig — samt allem, was daran hängt.
🔑 Warum ein eigener Befehl und nicht nur das Feld im Admin: An einer
Bestimmung hängt mehr als der Name. Der Dex-Eintrag der falschen Art
bleibt sonst stehen, der Erstfang wird nicht zurückgenommen, und die
dreifachen Punkte bleiben vergeben. Wer nur das Feld ändert, hat danach
eine Art in der Sammlung, die nie da war.
Am 30.09.2026 hat das Modell eine Kohlmeise mit **94,5 % Sicherheit** als
Blaumeise bestimmt. Am Foto war es zu sehen:
gelbe Brust mit dunklem Streifen → Kohlmeise
Kopf komplett verwischt → das entscheidende
Merkmal fehlte
⚠️ **Hohe Sicherheit heißt nicht richtig.** Das Modell musste ohne die
blaue Kappe entscheiden und war sich trotzdem sicher.
manage.py sichtung_bestimmen 228 kohlmeise --trocken
manage.py sichtung_bestimmen 228 kohlmeise
📌 Die Punkte werden neu gerechnet, nicht geschätzt — mit derselben
Formel, die bei der Aufnahme gilt (``scoring``). Sonst laufen Vergabe und
Korrektur auseinander.
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Max
from dex import scoring
from dex.models import Camera, DexEntry, Sighting, Species
class Command(BaseCommand):
help = "Setzt die Art einer Sichtung richtig und repariert die Sammlung"
def add_arguments(self, parser):
parser.add_argument("id", type=int, help="ID der Sichtung")
parser.add_argument("art", help="Kürzel der richtigen Art, z.B. kohlmeise")
parser.add_argument(
"--trocken", action="store_true",
help="Nur anzeigen, was passieren würde",
)
def handle(self, *args, **optionen):
trocken = optionen["trocken"]
try:
sichtung = Sighting.objects.select_related(
"species", "confirmed_species", "camera").get(pk=optionen["id"])
except Sighting.DoesNotExist:
raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.")
try:
richtig = Species.objects.get(slug=optionen["art"])
except Species.DoesNotExist:
raise CommandError(
f"Art '{optionen['art']}' gibt es nicht. "
f"Kürzel siehe Admin oder: manage.py shell")
bisher = sichtung.effective_species
if bisher is not None and bisher.pk == richtig.pk:
self.stdout.write(f"Sichtung {sichtung.pk} ist bereits "
f"{richtig.german} — nichts zu tun.")
return
self.stdout.write(
f"Sichtung {sichtung.pk} · "
f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · "
f"{sichtung.camera.name if sichtung.camera else 'ohne Kamera'}"
)
self.stdout.write(
f" erkannt als {bisher.german if bisher else 'unbestimmt'} "
f"({sichtung.confidence:.3f} Sicherheit)"
)
self.stdout.write(f" richtig ist {richtig.german}")
if sichtung.is_first_of_species:
self.stdout.write(self.style.WARNING(
"⚠ Das galt als ERSTFANG — dreifache Punkte. Die werden "
"neu gerechnet."
))
punkte_neu = self._punkte(sichtung, richtig)
self.stdout.write(f" Punkte {sichtung.xp_awarded} → {punkte_neu}")
if trocken:
self.stdout.write(self.style.NOTICE(
"[trocken] Es wurde nichts geändert."))
return
with transaction.atomic():
alt = bisher
sichtung.confirmed_species = richtig
# 🔑 is_first_of_species neu bestimmen: Gibt es zu der
# richtigen Art schon einen Eintrag, ist es kein Erstfang.
war_erst = sichtung.is_first_of_species
sichtung.is_first_of_species = not DexEntry.objects.filter(
species=richtig).exclude(
first_seen__gt=sichtung.started_at).exists()
sichtung.xp_awarded = self._punkte(sichtung, richtig)
sichtung.save(update_fields=["confirmed_species",
"is_first_of_species", "xp_awarded"])
self._dex_reparieren(alt)
self._dex_reparieren(richtig)
if sichtung.camera:
self._kamera_reparieren(sichtung.camera)
if war_erst and not sichtung.is_first_of_species:
self.stdout.write(" Erstfang zurückgenommen.")
self.stdout.write(self.style.SUCCESS(
f"Sichtung {sichtung.pk} ist jetzt {richtig.german}."))
# -- Rechnen ------------------------------------------------------
def _punkte(self, sichtung, art) -> int:
"""Dieselbe Formel wie bei der Aufnahme — siehe scoring.register.
⚠️ Bewusst nicht nachgebaut, sondern abgeleitet: Weicht die
Korrektur von der Vergabe ab, stimmt der Punktestand nie wieder.
"""
erstfang = not DexEntry.objects.filter(species=art).exclude(
first_seen__gt=sichtung.started_at).exists()
punkte = art.xp * (scoring.FIRST_CATCH_MULTIPLIER if erstfang else 1)
return int(round(punkte * (0.5 + 0.5 * min(1.0, sichtung.confidence))))
# -- Aufräumen ----------------------------------------------------
def _dex_reparieren(self, art):
"""Zählt den Eintrag einer Art neu — aus den Sichtungen.
🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher
falsch stand, wird dabei nebenbei richtig.
"""
if art is None:
return
# ⚠️ Über effective_species zählen, nicht über species -- sonst
# zählt eine korrigierte Sichtung weiter bei der falschen Art.
passend = [
s for s in Sighting.objects.select_related(
"species", "confirmed_species")
if s.effective_species and s.effective_species.pk == art.pk
]
eintrag = DexEntry.objects.filter(species=art).first()
if not passend:
if eintrag:
eintrag.delete()
self.stdout.write(f" Dex-Eintrag {art.german} entfernt "
f"(keine Sichtung mehr).")
return
passend.sort(key=lambda s: s.started_at)
# 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit.
beste = scoring.beste_sichtung(passend)
if eintrag is None:
DexEntry.objects.create(
species=art, first_seen=passend[0].started_at,
last_seen=passend[-1].started_at, count=len(passend),
best_sighting=beste)
self.stdout.write(f" Dex-Eintrag {art.german} angelegt "
f"({len(passend)}×).")
return
eintrag.count = len(passend)
eintrag.first_seen = passend[0].started_at
eintrag.last_seen = passend[-1].started_at
eintrag.best_sighting = beste
eintrag.save()
self.stdout.write(f" Dex-Eintrag {art.german}: jetzt "
f"{eintrag.count}×, beste Sichtung {beste.pk}.")
def _kamera_reparieren(self, kamera):
"""`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt.
⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag
und bleibt unberührt.
"""
zuletzt = Sighting.objects.filter(camera=kamera).aggregate(
z=Max("ended_at"))["z"]
Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)