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
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -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")
|
||||
)
|
||||
Reference in new issue
Block a user