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

+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")
)