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-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

Whitespace-only changes.
+128
View File
@@ -0,0 +1,128 @@
"""Kameras anlegen, auflisten, Token erneuern.
manage.py camera add vogelhaus --name "Futterhaus Garten"
manage.py camera list
manage.py camera rotate vogelhaus
manage.py camera disable vogelhaus
Der Token wird genau einmal ausgegeben — danach liegt nur noch sein Hash in
der Datenbank. Wer ihn verliert, erzeugt mit ``rotate`` einen neuen.
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from django.utils.text import slugify
from dex.models import Camera
class Command(BaseCommand):
help = "Kameras verwalten."
def add_arguments(self, parser):
sub = parser.add_subparsers(dest="action", required=True)
add = sub.add_parser("add", help="Neue Kamera anlegen")
add.add_argument("slug")
add.add_argument("--name", help="Anzeigename (Standard: aus dem Slug)")
add.add_argument("--location", default="", help="Standort, z.B. 'Garten Nord'")
# 🔑 Ein Handy ist eine Kamera, die DARF schweigen. Ohne diese Angabe
# trägt es im Kamerafilter und im Störungswächter dauerhaft ein ⚠,
# weil beide "seit Stunden nichts geliefert" als Defekt lesen — richtig
# für ein Vogelhaus, falsch für ein Telefon in der Tasche. Bisher gab
# es das Feld nur im Admin; wer eine Handy-Kamera auf der
# Kommandozeile anlegt, hat es zwangsläufig falsch.
add.add_argument(
"--mobil",
action="store_true",
help="Handy o.ä.: liefert nur gelegentlich, gilt nie als gestört.",
)
sub.add_parser("list", help="Alle Kameras anzeigen")
rot = sub.add_parser("rotate", help="Neuen Token erzeugen")
rot.add_argument("slug")
dis = sub.add_parser("disable", help="Kamera stilllegen")
dis.add_argument("slug")
ena = sub.add_parser("enable", help="Kamera wieder aktivieren")
ena.add_argument("slug")
def handle(self, *args, **opts):
action = opts["action"]
if action == "add":
return self._add(opts)
if action == "list":
return self._list()
return self._toggle(action, opts["slug"])
def _add(self, opts):
slug = slugify(opts["slug"])
if Camera.objects.filter(slug=slug).exists():
raise CommandError(f"Kamera '{slug}' gibt es schon.")
token = Camera.new_token()
camera = Camera(
slug=slug,
name=opts.get("name") or slug.replace("-", " ").title(),
location=opts.get("location") or "",
mobil=bool(opts.get("mobil")),
)
camera.set_token(token)
camera.save()
self.stdout.write(self.style.SUCCESS(f"\nKamera '{camera.name}' angelegt.\n"))
self.stdout.write("Token (wird nur dieses eine Mal angezeigt):\n")
self.stdout.write(self.style.WARNING(f" {token}\n"))
if camera.mobil:
# Eine mobile Kamera hat keinen Erkennungs-Host — der Token geht
# in die App. Der Hinweis unten wäre hier eine falsche Fährte.
self.stdout.write("In der App unter 'Server verbinden' eintragen.\n")
else:
self.stdout.write(
"Auf dem Erkennungs-Host in /etc/vogelalbum.env eintragen:\n"
)
self.stdout.write(f" VOGELALBUM_TOKEN={token}\n")
def _list(self):
cameras = Camera.objects.all()
if not cameras:
self.stdout.write("Keine Kameras angelegt.")
return
self.stdout.write(f"\n{'Slug':<18} {'Name':<24} {'Aktiv':<6} {'Zuletzt':<18} Sichtungen")
self.stdout.write("-" * 82)
for c in cameras:
last = (
timezone.localtime(c.last_seen).strftime("%d.%m. %H:%M:%S")
if c.last_seen
else "nie"
)
flag = "ja" if c.active else "nein"
warn = " ⚠ hängt?" if c.active and c.is_stale else ""
self.stdout.write(
f"{c.slug:<18} {c.name:<24} {flag:<6} {last:<18} "
f"{c.sightings.count()}{warn}"
)
def _toggle(self, action: str, slug: str):
camera = Camera.objects.filter(slug=slug).first()
if camera is None:
raise CommandError(f"Keine Kamera '{slug}'.")
if action == "rotate":
token = Camera.new_token()
camera.set_token(token)
camera.save(update_fields=["token_hash"])
self.stdout.write(self.style.SUCCESS(f"Neuer Token für '{camera.name}':"))
self.stdout.write(self.style.WARNING(f" {token}"))
self.stdout.write("Der alte Token ist ab sofort ungültig.")
return
camera.active = action == "enable"
camera.save(update_fields=["active"])
state = "aktiviert" if camera.active else "stillgelegt"
self.stdout.write(self.style.SUCCESS(f"'{camera.name}' {state}."))
+136
View File
@@ -0,0 +1,136 @@
"""Erzeugt plausible Beispiel-Sichtungen, damit sich die Oberfläche vor dem
ersten echten Vogel beurteilen lässt.
Bewusst kein Teil des Normalbetriebs: ``--clear`` räumt alles wieder weg.
Die Bilder sind die Referenzfotos aus Wikipedia, keine echten Kameraaufnahmen
— die Sichtungen sind also erkennbar Attrappe.
"""
from __future__ import annotations
import random
from datetime import timedelta
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
from dex import scoring
from dex.models import DexEntry, EarnedBadge, Sighting, Species
# Wie wahrscheinlich taucht eine Seltenheitsstufe auf? Grob an einem echten
# Garten orientiert: Kohlmeisen im Dauerbetrieb, der Sperber einmal im Monat.
RARITY_WEIGHT = {
"haeufig": 60,
"gelegentlich": 22,
"selten": 6,
"episch": 1.2,
"legendaer": 0.3,
}
# Vögel am Futterhaus haben zwei Stoßzeiten: kurz nach Sonnenaufgang und am
# späten Nachmittag.
HOUR_WEIGHT = [
0.1, 0.1, 0.1, 0.2, 0.6, 2.5, 6.0, 9.0, 8.0, 6.0, 4.5, 3.5,
3.0, 3.2, 4.0, 5.5, 7.0, 6.5, 4.0, 1.5, 0.5, 0.2, 0.1, 0.1,
]
class Command(BaseCommand):
help = "Legt Beispiel-Sichtungen an (nur zum Ansehen der Oberfläche)."
def add_arguments(self, parser):
parser.add_argument("--tage", type=int, default=21)
parser.add_argument("--pro-tag", type=int, default=9)
parser.add_argument(
"--clear",
action="store_true",
help="Alle Sichtungen, Dex-Einträge und Abzeichen löschen.",
)
parser.add_argument("--seed", type=int, default=1312)
def handle(self, *args, **opts):
if opts["clear"]:
n = Sighting.objects.count()
Sighting.objects.all().delete()
DexEntry.objects.all().delete()
EarnedBadge.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"{n} Sichtungen gelöscht."))
return
if Sighting.objects.exists():
self.stdout.write(
self.style.WARNING(
"Es gibt bereits Sichtungen. Erst 'demo_data --clear' ausführen, "
"sonst mischen sich Demo- und Echtdaten."
)
)
return
species = list(Species.objects.all())
if not species:
self.stdout.write(self.style.ERROR("Katalog leer — erst 'seed_dex'."))
return
rng = random.Random(opts["seed"])
weights = [RARITY_WEIGHT.get(s.rarity, 1) for s in species]
# Chronologisch anlegen, damit Erstfänge, XP und Abzeichen in der
# richtigen Reihenfolge verbucht werden.
now = timezone.localtime()
events = []
for day_offset in range(opts["tage"], -1, -1):
day = (now - timedelta(days=day_offset)).date()
for _ in range(rng.randint(max(1, opts["pro_tag"] - 4), opts["pro_tag"] + 4)):
hour = rng.choices(range(24), weights=HOUR_WEIGHT)[0]
start = timezone.make_aware(
timezone.datetime.combine(day, timezone.datetime.min.time())
) + timedelta(hours=hour, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59))
if start > timezone.now():
continue
events.append(start)
events.sort()
created = 0
for start in events:
sp = rng.choices(species, weights=weights)[0]
# Ein Teil der Besuche bleibt unbestimmt — so sieht es im Betrieb
# auch aus, und die Oberfläche muss den Fall aushalten.
unbestimmt = rng.random() < 0.12
confidence = 0.0 if unbestimmt else rng.uniform(0.55, 0.99)
duration = rng.uniform(3, 75)
sighting = Sighting(
species=None if unbestimmt else sp,
confidence=confidence,
started_at=start,
ended_at=start + timedelta(seconds=duration),
frames_total=int(duration * 4),
frames_with_bird=int(duration * 4 * rng.uniform(0.5, 0.95)),
votes={sp.slug: round(confidence, 3)} if not unbestimmt else {},
mean_bird_score=rng.uniform(0.4, 0.95),
)
if not unbestimmt and sp.reference_image:
try:
sp.reference_image.open("rb")
data = sp.reference_image.read()
sp.reference_image.close()
sighting.image.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
sighting.crop.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
except OSError:
pass
sighting.save()
scoring.register(sighting)
created += 1
p = scoring.progress()
self.stdout.write(
self.style.SUCCESS(
f"{created} Beispiel-Sichtungen angelegt. "
f"Level {p.level} ({p.rank}), {p.xp} XP, "
f"{p.species_seen}/{p.species_total} Arten, {p.badges} Abzeichen."
)
)
@@ -0,0 +1,171 @@
"""Holt für jede Art ein Referenzfoto aus der deutschen Wikipedia.
Damit weiß man, wonach man Ausschau hält, bevor eine Art zum ersten Mal selbst
vor der Kamera saß. Urheber und Lizenz werden mitgespeichert und in der
Oberfläche angezeigt — die Bilder stehen fast alle unter CC-BY-SA und die
Namensnennung ist Bedingung, nicht Höflichkeit.
"""
from __future__ import annotations
import io
import json
import re
import time
import urllib.parse
import urllib.request
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from PIL import Image
from dex.models import Species
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
WP_API = "https://de.wikipedia.org/w/api.php"
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
MAX_WIDTH = 900
TAG_RE = re.compile(r"<[^>]+>")
def _get(url: str, params: dict) -> dict:
full = f"{url}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(full, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=25) as resp:
return json.loads(resp.read())
def _strip_html(value: str) -> str:
"""Der Artist kommt als HTML-Schnipsel mit Link. Wir wollen nur den Namen."""
text = TAG_RE.sub("", value or "")
return " ".join(text.split())[:250]
class Command(BaseCommand):
help = "Lädt Referenzfotos samt Lizenzangaben aus Wikipedia/Commons."
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Auch Arten neu laden, die schon ein Referenzfoto haben.",
)
parser.add_argument("--only", help="Nur diese Art (Slug).")
parser.add_argument(
"--delay",
type=float,
default=0.6,
help="Pause zwischen Anfragen; Wikipedia bittet um Zurückhaltung.",
)
def handle(self, *args, **opts):
qs = Species.objects.all()
if opts["only"]:
qs = qs.filter(slug=opts["only"])
if not opts["force"]:
qs = qs.filter(reference_image="")
ok = skipped = failed = 0
for species in qs:
try:
if self._fetch(species, opts["delay"]):
ok += 1
self.stdout.write(self.style.SUCCESS(f" ✓ {species.german}"))
else:
skipped += 1
self.stdout.write(f" – {species.german}: kein Bild gefunden")
except Exception as exc:
failed += 1
self.stdout.write(self.style.WARNING(f" ✗ {species.german}: {exc}"))
time.sleep(opts["delay"])
self.stdout.write(
self.style.SUCCESS(f"\n{ok} geladen, {skipped} ohne Bild, {failed} Fehler.")
)
def _lead_image(self, title: str) -> dict | None:
page = _get(
WP_API,
{
"action": "query",
"titles": title,
"prop": "pageimages",
"piprop": "original|name",
"format": "json",
"formatversion": "2",
"redirects": "1",
},
)
pages = page.get("query", {}).get("pages", [])
if not pages or "original" not in pages[0]:
return None
return pages[0]
def _fetch(self, species: Species, delay: float) -> bool:
# 1. Artikelbild der deutschen Wikipedia finden. Der deutsche Name ist
# meist das Artikellemma — außer bei Gimpel, Kleiber, Sperber, Star:
# das sind Begriffsklärungsseiten ohne Bild. Der wissenschaftliche
# Name leitet dort zuverlässig auf den Artikel weiter.
page = self._lead_image(species.german)
if page is None:
time.sleep(delay)
page = self._lead_image(species.scientific)
if page is None:
return False
img_url = page["original"]["source"].split("?", 1)[0]
file_name = page.get("pageimage")
# 2. Lizenz und Urheber von Commons nachschlagen.
credit = license_name = source = ""
if file_name:
time.sleep(delay)
meta = _get(
COMMONS_API,
{
"action": "query",
"titles": f"File:{file_name}",
"prop": "imageinfo",
"iiprop": "extmetadata|url",
"format": "json",
"formatversion": "2",
},
)
info = meta.get("query", {}).get("pages", [{}])[0].get("imageinfo")
if info:
ext = info[0].get("extmetadata", {})
credit = _strip_html(ext.get("Artist", {}).get("value", ""))
license_name = _strip_html(
ext.get("LicenseShortName", {}).get("value", "")
)
source = info[0].get("descriptionurl", "")
# 3. Bild holen und auf vernünftige Größe bringen.
req = urllib.request.Request(img_url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=45) as resp:
raw = resp.read()
img = Image.open(io.BytesIO(raw)).convert("RGB")
if img.width > MAX_WIDTH:
img = img.resize(
(MAX_WIDTH, round(img.height * MAX_WIDTH / img.width)), Image.LANCZOS
)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85, optimize=True)
species.reference_image.save(
f"{species.slug}.jpg", ContentFile(buf.getvalue()), save=False
)
species.reference_credit = credit
species.reference_license = license_name
species.reference_source = source
species.save(
update_fields=[
"reference_image",
"reference_credit",
"reference_license",
"reference_source",
]
)
return True
+193
View File
@@ -0,0 +1,193 @@
"""Füllt Artenkatalog und Abzeichen. Idempotent — beliebig oft ausführbar."""
from __future__ import annotations
import sys
from pathlib import Path
from django.core.management.base import BaseCommand
from dex import scoring
from dex.models import Species
def _steckbrief(z) -> dict:
"""Steckbrief-Felder aus einem Zusatz-Eintrag, oder leer."""
if z is None:
return {}
return {
"kategorie": z.kategorie, "beschreibung": z.text,
"nahrung": z.nahrung, "lebensraum": z.lebensraum, "stimme": z.stimme,
"kategorie_en": z.kategorie_en, "beschreibung_en": z.text_en,
"nahrung_en": z.nahrung_en, "lebensraum_en": z.lebensraum_en,
"stimme_en": z.stimme_en,
}
# Muss zu RARITY_XP in detector/species_de.py passen.
RARITY_XP = {
"haeufig": 10, "gelegentlich": 25, "selten": 60, "episch": 150, "legendaer": 400,
}
def find_catalog_root() -> Path | None:
"""Sucht das Verzeichnis, das ``detector/species_de.py`` enthält.
Lokal liegt es eine Ebene über ``web/``, im Container direkt neben
``manage.py`` — statt beide Fälle zu raten, einfach aufwärts suchen.
"""
for base in Path(__file__).resolve().parents:
if (base / "detector" / "species_de.py").exists():
return base
return None
class Command(BaseCommand):
help = "Legt Arten und Abzeichen an bzw. aktualisiert sie."
def handle(self, *args, **options):
# detector.species_de ist die einzige Quelle der Wahrheit für den
# Katalog. Das Modul hängt nur an der Stdlib, der Import kostet die
# Web-App also keine schweren Abhängigkeiten.
root = find_catalog_root()
if root is None:
self.stderr.write(
self.style.ERROR(
"detector/species_de.py nicht gefunden — "
"weder neben manage.py noch in einem übergeordneten Verzeichnis."
)
)
return
sys.path.insert(0, str(root))
try:
from detector.species_de import SPECIES
except ImportError as exc:
self.stderr.write(self.style.ERROR(f"Artenkatalog nicht ladbar: {exc}"))
return
try:
from detector.species_extra import EXTRA
except ImportError:
EXTRA = []
try:
from detector.species_info import INFO
except ImportError:
INFO = {}
self.stdout.write(
self.style.WARNING("Keine Steckbriefe gefunden — Arten bleiben knapp.")
)
try:
from detector.species_info_en import INFO_EN
except ImportError:
INFO_EN = {}
# Steckbriefe fuer die erweiterten Arten — beide Sprachen in einem
# Datensatz, weil sie zusammen geschrieben werden.
try:
from detector.profiles import INFO_EXTRA
except ImportError:
INFO_EXTRA = {}
# Die Dex-Nummer soll stabil bleiben, damit #012 immer dieselbe Art
# ist. Sortiert nach Seltenheit, dann alphabetisch — neue Arten
# verschieben also Nummern, aber nur innerhalb ihrer Stufe.
order = {"haeufig": 0, "gelegentlich": 1, "selten": 2, "episch": 3, "legendaer": 4}
# Kernarten zuerst, dann die Erweiterung — so behalten die Gartenvögel
# ihre niedrigen Dex-Nummern, auch wenn der Katalog wächst.
geordnet = sorted(SPECIES, key=lambda s: (order.get(s.rarity, 9), s.german))
geordnet_extra = sorted(EXTRA, key=lambda e: (order.get(e.rarity, 9), e.german))
created = updated = mit_text = 0
for nummer, s in enumerate(geordnet, start=1):
info = INFO.get(s.slug)
defaults = {
"german": s.german,
"scientific": s.scientific,
"english": s.english,
"rarity": s.rarity,
"xp": s.xp,
"fact": s.fact,
"size_cm": s.size_cm,
"number": nummer,
"lebensraum_typ": s.lebensraum,
"regions": ["de"],
}
if info is not None:
mit_text += 1
defaults |= {
"typ": info.typ,
"kategorie": info.kategorie,
"beschreibung": info.text,
"gewicht_g": info.gewicht_g,
"spannweite_cm": info.spannweite_cm,
"zug": info.zug,
"nahrung": info.nahrung,
"lebensraum": info.lebensraum,
"stimme": info.stimme,
# Verweise auf Arten, die es (noch) nicht gibt, fliegen raus —
# sonst zeigt die Seite Links ins Leere.
"verwechslung": [
v for v in info.verwechslung
if any(x.slug == v for x in SPECIES)
],
}
info_en = INFO_EN.get(s.slug)
if info_en is not None:
defaults |= {
"kategorie_en": info_en.kategorie,
"beschreibung_en": info_en.text,
"nahrung_en": info_en.nahrung,
"lebensraum_en": info_en.lebensraum,
"stimme_en": info_en.stimme,
}
_, was_created = Species.objects.update_or_create(slug=s.slug, defaults=defaults)
created += was_created
updated += not was_created
# Erweiterte Arten: nur Strukturdaten, kein Erzähltext. Vorhandene
# Texte werden nicht überschrieben — wer einen Steckbrief nachträgt,
# verliert ihn beim nächsten Seed nicht.
naechste = len(geordnet)
for offset, e in enumerate(geordnet_extra, start=1):
obj, was_created = Species.objects.update_or_create(
slug=e.slug,
defaults={
"german": e.german,
"scientific": e.scientific,
"english": e.english,
"rarity": e.rarity,
"xp": RARITY_XP.get(e.rarity, 10),
"size_cm": e.size_cm,
"number": naechste + offset,
"typ": e.typ,
"gewicht_g": e.gewicht_g,
"spannweite_cm": e.spannweite_cm,
"zug": e.zug,
"lebensraum_typ": e.lebensraum,
"regions": ["de"],
}
| _steckbrief(INFO_EXTRA.get(e.slug)),
)
created += was_created
updated += not was_created
badges = scoring.sync_badges()
gesamt = len(SPECIES) + len(EXTRA)
mit_text += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
mit_en = sum(1 for s in SPECIES if s.slug in INFO_EN)
mit_en += sum(1 for e in EXTRA if e.slug in INFO_EXTRA)
ohne_text = gesamt - mit_text
self.stdout.write(
self.style.SUCCESS(
f"Arten: {created} neu, {updated} aktualisiert, "
f"{mit_text} von {gesamt} mit Steckbrief ({mit_en} auch auf Englisch). "
f"Abzeichen: {badges} neu."
)
)
if ohne_text:
self.stdout.write(
self.style.WARNING(f"{ohne_text} Arten noch ohne Steckbrief")
)
@@ -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)
@@ -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.")
)
@@ -0,0 +1,153 @@
"""Prüft, ob die Kameras noch melden — und schlägt Alarm, wenn nicht.
🔑 Warum das hier liegt und nicht im Detector: Ein abgestürzter Prozess kann
seinen eigenen Tod nicht melden. Wer das Ausbleiben von Meldungen bemerken
soll, muss von aussen schauen. Diese Seite hier läuft ohnehin.
Gedacht als regelmässiger Aufruf (systemd-Zeitgeber oder cron):
*/10 * * * * .../manage.py stoerungen_pruefen
⚠ Es wird pro Kamera nur EINMAL gemeldet, nicht bei jedem Durchlauf. Eine
Störung, die alle zehn Minuten aufs Handy summt, schaltet man nach einer
Stunde ab — und dann merkt man die nächste nicht mehr.
🔑 **Der Merker steht in der Datenbank, nicht im Prozess.** Hier stand
einmal ein Modul-Set `_gemeldet`. In einem Einmal-Lauf ist das wirkungslos:
jeder Aufruf ist ein frischer Prozess, das Set beim Start wieder leer. Die
Zusage eine Zeile weiter oben war damit acht Monate lang unwahr, und am
01.10.2026 hat es sich gezeigt -- nach dem Stromausfall summten beide
Kameras im Zehnminutentakt.
"""
from django.core.management.base import BaseCommand
from django.utils import timezone
from dex import benachrichtigung as bn
from dex.models import Benachrichtigung, Camera, Sighting
class Command(BaseCommand):
help = "Meldet Kameras, die sich länger nicht gemeldet haben"
def add_arguments(self, parser):
parser.add_argument(
"--trocken", action="store_true",
help="Nur anzeigen, nichts senden",
)
def handle(self, *args, **optionen):
einstellung = Benachrichtigung.laden()
grenze = timezone.now() - timezone.timedelta(
minutes=einstellung.stille_minuten)
# ⚠️ Mobile Geräte bleiben außen vor. Die Prüfung misst, ob sich ein
# Gerät länger als erlaubt nicht gemeldet hat — das ergibt nur bei
# Dauerbetrieb einen Sinn. Ein Handy meldet sich, wenn jemand
# spazieren geht, und nach dem ersten ruhigen Wochenende stünde
# sonst „Kamera still: Handy" auf dem Telefon. Genau so entstehen
# Fehlalarme, die man abschaltet — und dann bemerkt man den echten
# Ausfall nicht mehr.
for kamera in Camera.objects.filter(active=True, mobil=False):
# ⚠ Gemessen wird das LEBENSZEICHEN, nicht die letzte Sichtung.
# An last_seen zu messen waere der naheliegende Fehler: An
# einem ruhigen Nachmittag kommt stundenlang kein Vogel, und
# es gaebe jeden Tag Fehlalarm. (Genau so hatte ich es zuerst
# gebaut -- der Trockenlauf meldete prompt "still seit 824
# min" fuer eine tadellos laufende Kamera.)
if kamera.letzter_kontakt is None:
# Noch nie gemeldet. Das ist keine Stoerung, sondern eine
# Kamera, die gerade erst eingerichtet wurde.
self.stdout.write(f"{kamera.name}: noch kein Lebenszeichen")
continue
still_seit = timezone.now() - kamera.letzter_kontakt
minuten = int(still_seit.total_seconds() // 60)
if kamera.letzter_kontakt < grenze:
if kamera.stoerung_gemeldet_seit is not None:
self.stdout.write(f"{kamera.name}: still ({minuten} min), "
"schon gemeldet")
continue
self.stdout.write(self.style.ERROR(
f"{kamera.name}: STILL seit {minuten} min"))
if not optionen["trocken"]:
bn.melde_stoerung(
f"Kamera still: {kamera.name}",
f"Seit {minuten} Minuten kein Lebenszeichen. "
f"Läuft die Erkennung noch?")
# ⚠ Erst nach dem Senden vermerken. melde_stoerung
# sendet synchron und schluckt Fehler nur im Log --
# wer vorher vermerkt, verliert bei einem Aussetzer
# des Meldewegs die Stoerung still und fuer immer.
kamera.stoerung_gemeldet_seit = timezone.now()
kamera.save(update_fields=["stoerung_gemeldet_seit"])
else:
if kamera.stoerung_gemeldet_seit is not None:
# Wieder da — das gehört auch gemeldet, sonst weiss
# niemand, ob das Problem noch besteht.
self.stdout.write(self.style.SUCCESS(
f"{kamera.name}: wieder da"))
if not optionen["trocken"]:
bn.melde_stoerung(
f"Kamera wieder da: {kamera.name}",
"Sie meldet sich wieder.")
kamera.stoerung_gemeldet_seit = None
kamera.save(update_fields=["stoerung_gemeldet_seit"])
else:
self.stdout.write(f"{kamera.name}: ok ({minuten} min her)")
self._wartende_fotos(einstellung, optionen["trocken"])
def _wartende_fotos(self, einstellung, trocken: bool) -> None:
"""Meldet, wenn hochgeladene Fotos zu lange auf den Detector warten.
🔑 **Das ist die Stillstandsprüfung für den Detector selbst.** Die
Kameraprüfung oben merkt, wenn ein Gerät nichts mehr liefert — sie
merkt aber nicht, wenn der Detector steht, denn der meldet sich bei
niemandem an. Liegengebliebene Fotos sind das einzige sichtbare
Zeichen dafür, und sie sagen es unmissverständlich: Jemand hat
hochgeladen, und seitdem ist nichts passiert.
📌 Verworfen wird nichts. Das Foto vom Spaziergang gibt es kein
zweites Mal, und stilles Löschen wäre der schlechteste Umgang damit.
"""
wartend = Sighting.objects.wartend()
aeltestes = wartend.first()
if aeltestes is None:
if einstellung.fotos_gemeldet_seit is not None:
self.stdout.write(self.style.SUCCESS("Fotos: alle bearbeitet"))
if not trocken:
bn.melde_stoerung(
"Fotos wieder bearbeitet",
"Die liegengebliebenen Fotos sind bestimmt.")
einstellung.fotos_gemeldet_seit = None
einstellung.save(update_fields=["fotos_gemeldet_seit"])
return
minuten = int(
(timezone.now() - aeltestes.started_at).total_seconds() // 60)
anzahl = wartend.count()
if minuten < einstellung.stille_minuten:
self.stdout.write(
f"{anzahl} Foto(s) warten, ältestes {minuten} min — ok")
return
if einstellung.fotos_gemeldet_seit is not None:
self.stdout.write(
f"{anzahl} Foto(s) warten ({minuten} min), schon gemeldet")
return
self.stdout.write(self.style.ERROR(
f"{anzahl} Foto(s) warten auf Bestimmung, "
f"ältestes seit {minuten} min"))
if not trocken:
bn.melde_stoerung(
"Fotos warten auf Bestimmung",
f"{anzahl} hochgeladene Foto(s) liegen seit {minuten} Minuten "
f"unbearbeitet. Läuft der Detector noch?")
# Erst nach dem Senden vermerken — siehe oben bei den Kameras.
einstellung.fotos_gemeldet_seit = timezone.now()
einstellung.save(update_fields=["fotos_gemeldet_seit"])