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.
185 lines
7.5 KiB
Python
185 lines
7.5 KiB
Python
"""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)
|