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.
194 lines
7.3 KiB
Python
194 lines
7.3 KiB
Python
"""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")
|
|
)
|