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