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
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,219 @@
|
||||
"""Entfernt eine Sichtung — samt allem, was aus ihr abgeleitet wurde.
|
||||
|
||||
🔑 Warum das ein eigener Befehl ist und kein Löschen im Admin: An einer
|
||||
Sichtung hängt mehr als ihre eigene Zeile. Der Dex-Eintrag zählt sie mit,
|
||||
die Kamera merkt sich den Zeitpunkt, die Punkte stecken im Datensatz, und
|
||||
zwei Bilddateien liegen auf der Platte. Wer nur die Zeile löscht, lässt
|
||||
einen Dex-Eintrag zurück, der eine Sichtung zu viel zählt.
|
||||
|
||||
Gedacht für Fehlerkennungen. Die erste war am 24.09.2026: Die Kamera für
|
||||
die Vogeltränke lag noch auf dem Schreibtisch und hat Teile der
|
||||
Befestigung für eine Kohlmeise gehalten — ein einziges von 73 Bildern
|
||||
hatte einen Vogel-Wert über der Schwelle, und der lag bei 0,098.
|
||||
|
||||
⚠ Die Punkte der Sichtung verrechnet niemand gesondert: Der Gesamtstand
|
||||
summiert ``xp_awarded``, also verschwinden sie mit der Sichtung von selbst.
|
||||
|
||||
🔴 **Für die Punkte eines Abzeichens gilt das NICHT** — und bis zum
|
||||
11.10.2026 stand hier das Gegenteil. ``scoring.total_xp()`` zählt
|
||||
``Sighting.xp_awarded`` **und** ``EarnedBadge.badge__xp`` zusammen. Ein
|
||||
Abzeichen, das aus der entfernten Sichtung stammt, lässt seine Punkte also
|
||||
stehen. An jenem Tag waren das **200 Punkte für „Seltener Gast"** aus einer
|
||||
Fehlerkennung — mehr als die 144 der Sichtung selbst, und sie haben eine
|
||||
Stufe getragen, die es nicht gab.
|
||||
|
||||
🔑 Abzeichen bleiben trotzdem die Vorgabe: Ein einmal verdientes Abzeichen
|
||||
nimmt man einem Kind nicht wieder weg. Aber **stillschweigend darf das nicht
|
||||
passieren** — der Befehl nennt sie jetzt samt Punkten, und ``--abzeichen``
|
||||
nimmt sie mit, wenn sie nie verdient waren.
|
||||
|
||||
manage.py sichtung_entfernen 199 --trocken # erst schauen
|
||||
manage.py sichtung_entfernen 199 # dann löschen
|
||||
manage.py sichtung_entfernen 199 --abzeichen # samt Abzeichen daraus
|
||||
"""
|
||||
|
||||
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, EarnedBadge, Sighting
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Entfernt eine Sichtung und repariert, was aus ihr abgeleitet wurde"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("id", type=int, help="ID der Sichtung")
|
||||
parser.add_argument(
|
||||
"--trocken", action="store_true",
|
||||
help="Nur anzeigen, was passieren würde",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--abzeichen", action="store_true",
|
||||
help="Abzeichen aus dieser Sichtung mitnehmen (samt ihrer Punkte)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **optionen):
|
||||
trocken = optionen["trocken"]
|
||||
abzeichen_mit = optionen["abzeichen"]
|
||||
|
||||
try:
|
||||
sichtung = Sighting.objects.select_related(
|
||||
"species", "camera").get(pk=optionen["id"])
|
||||
except Sighting.DoesNotExist:
|
||||
raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.")
|
||||
|
||||
art = sichtung.species
|
||||
kamera = sichtung.camera
|
||||
|
||||
self.stdout.write(
|
||||
f"Sichtung {sichtung.pk}: "
|
||||
f"{art.german if art else 'unbestimmt'} · "
|
||||
f"{kamera.name if kamera else 'ohne Kamera'} · "
|
||||
f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · "
|
||||
f"{sichtung.confidence:.3f} Sicherheit · "
|
||||
f"{sichtung.frames_with_bird} von {sichtung.frames_total} Bildern "
|
||||
f"mit Vogel · {sichtung.xp_awarded} Punkte"
|
||||
)
|
||||
|
||||
# ⚠️ Eine Erstsichtung wiegt schwerer: Mit ihr fällt der Tag, an dem
|
||||
# die Art zum ersten Mal da war. Deshalb ausdrücklich benennen statt
|
||||
# stillschweigend mitlöschen.
|
||||
if sichtung.is_first_of_species:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
"⚠ Das ist die ERSTSICHTUNG dieser Art. Der Dex-Eintrag "
|
||||
"rückt auf die nächstältere Sichtung nach."
|
||||
))
|
||||
|
||||
dateien = [f for f in (sichtung.image, sichtung.crop) if f]
|
||||
for datei in dateien:
|
||||
self.stdout.write(f" Datei: {datei.name}")
|
||||
|
||||
# ⚠️ VOR dem Löschen einsammeln: ``EarnedBadge.sighting`` ist
|
||||
# SET_NULL. Nach dem Löschen ist die Spur zur Sichtung weg, und dann
|
||||
# weiß niemand mehr, welches Abzeichen aus ihr stammte.
|
||||
verdiente = list(EarnedBadge.objects.filter(
|
||||
sighting=sichtung).select_related("badge"))
|
||||
self._abzeichen_nennen(verdiente, abzeichen_mit)
|
||||
|
||||
if trocken:
|
||||
self.stdout.write(self.style.NOTICE(
|
||||
"[trocken] Es wurde nichts geändert."))
|
||||
self._vorschau(sichtung, art, kamera)
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
sichtung_id = sichtung.pk
|
||||
for datei in dateien:
|
||||
datei.delete(save=False) # Platte, nicht nur Datensatz
|
||||
sichtung.delete()
|
||||
|
||||
if abzeichen_mit:
|
||||
for verdient in verdiente:
|
||||
# Nur das Verdienst, nie der Katalogeintrag: Das Abzeichen
|
||||
# als solches bleibt natürlich erreichbar.
|
||||
verdient.delete()
|
||||
|
||||
self._dex_reparieren(art)
|
||||
self._kamera_reparieren(kamera)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Sichtung {sichtung_id} entfernt."))
|
||||
|
||||
# -- Aufräumen ----------------------------------------------------
|
||||
|
||||
def _dex_reparieren(self, art):
|
||||
"""Zählt den Dex-Eintrag neu — aus den Sichtungen, nicht per -1.
|
||||
|
||||
🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher
|
||||
falsch stand, wird dadurch nebenbei richtig. Ein '-1' würde den
|
||||
Fehler mitschleppen.
|
||||
"""
|
||||
if art is None:
|
||||
return
|
||||
eintrag = DexEntry.objects.filter(species=art).first()
|
||||
if eintrag is None:
|
||||
return
|
||||
|
||||
uebrig = Sighting.objects.filter(species=art)
|
||||
if not uebrig.exists():
|
||||
eintrag.delete()
|
||||
self.stdout.write(" Dex-Eintrag entfernt (keine Sichtung mehr).")
|
||||
return
|
||||
|
||||
eintrag.count = uebrig.count()
|
||||
eintrag.first_seen = uebrig.earliest("started_at").started_at
|
||||
eintrag.last_seen = uebrig.latest("started_at").started_at
|
||||
# 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit -- siehe
|
||||
# scoring.bildguete.
|
||||
eintrag.best_sighting = scoring.beste_sichtung(uebrig)
|
||||
eintrag.save()
|
||||
self.stdout.write(
|
||||
f" Dex-Eintrag: jetzt {eintrag.count}×, zuletzt "
|
||||
f"{eintrag.last_seen:%d.%m.%Y %H:%M}, beste Sichtung "
|
||||
f"{eintrag.best_sighting_id}."
|
||||
)
|
||||
|
||||
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. Genau diese Verwechslung hatte am 23.09.2026
|
||||
einen Fehlalarm ausgelöst.
|
||||
"""
|
||||
if kamera is None:
|
||||
return
|
||||
zuletzt = Sighting.objects.filter(camera=kamera).aggregate(
|
||||
z=Max("ended_at"))["z"]
|
||||
Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt)
|
||||
self.stdout.write(
|
||||
f" Kamera {kamera.name}: letzte Sichtung "
|
||||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||||
)
|
||||
|
||||
def _abzeichen_nennen(self, verdiente, mitnehmen):
|
||||
"""Sagt, welche Abzeichen an der Sichtung hängen — und was sie wiegen.
|
||||
|
||||
🔑 Dass hier überhaupt etwas steht, ist der eigentliche Fix vom
|
||||
11.10.2026: Vorher blieben Abzeichen **und ihre Punkte** stumm
|
||||
zurück, während die Beschreibung des Befehls das Gegenteil
|
||||
behauptete. Wer eine Fehlerkennung entfernt und danach dieselbe
|
||||
Stufe sieht, sucht den Fehler an der falschen Stelle.
|
||||
"""
|
||||
if not verdiente:
|
||||
return
|
||||
|
||||
punkte = sum(v.badge.xp for v in verdiente)
|
||||
for verdient in verdiente:
|
||||
self.stdout.write(
|
||||
f" Abzeichen: {verdient.badge.emoji} "
|
||||
f"{verdient.badge.name} ({verdient.badge.xp} Punkte)"
|
||||
)
|
||||
if mitnehmen:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"⚠ Diese Abzeichen werden mit entfernt — {punkte} Punkte "
|
||||
f"weniger."
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f"⚠ Abzeichen BLEIBEN, samt ihren {punkte} Punkten. War die "
|
||||
f"Sichtung eine Fehlerkennung, waren sie nie verdient: "
|
||||
f"--abzeichen nimmt sie mit."
|
||||
))
|
||||
|
||||
def _vorschau(self, sichtung, art, kamera):
|
||||
if art is not None:
|
||||
uebrig = Sighting.objects.filter(species=art).exclude(
|
||||
pk=sichtung.pk)
|
||||
self.stdout.write(
|
||||
f" Dex-Eintrag {art.german}: würde auf {uebrig.count()}× "
|
||||
+ ("sinken." if uebrig.exists() else "0 fallen und wegfallen.")
|
||||
)
|
||||
if kamera is not None:
|
||||
zuletzt = Sighting.objects.filter(camera=kamera).exclude(
|
||||
pk=sichtung.pk).aggregate(z=Max("ended_at"))["z"]
|
||||
self.stdout.write(
|
||||
f" Kamera {kamera.name}: letzte Sichtung wäre "
|
||||
+ (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.")
|
||||
)
|
||||
Reference in new issue
Block a user