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,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.")
)