Files
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

227 lines
12 KiB
Python

"""Arten-Katalog: deutsche Gartenvögel.
Der iNat-Klassifikator kennt 965 Arten weltweit — überwiegend Nordamerika.
Für einen Nistkasten in Deutschland ist das viel zu breit: ohne Filter landet
eine Kohlmeise gern mal als "Carolina Chickadee". Deshalb schränken wir die
Ausgabe auf die Arten ein, die hier realistisch vor der Kamera sitzen, und
normalisieren die Wahrscheinlichkeiten neu.
Jeder Eintrag: wissenschaftlicher Name (= Schlüssel ins Label-File),
deutscher Name, Seltenheitsstufe, und ein Fakt fürs Vogelalbum.
"""
from __future__ import annotations
from dataclasses import dataclass
# Seltenheitsstufen -> XP beim Fang. Bezugsrahmen ist ein Garten in
# Deutschland, nicht die weltweite Bestandslage: ein Mäusebussard ist nicht
# bedroht, taucht aber im Vogelhaus praktisch nie auf.
RARITY_XP = {
"haeufig": 10,
"gelegentlich": 25,
"selten": 60,
"episch": 150,
"legendaer": 400,
}
RARITY_LABEL = {
"haeufig": "Häufig",
"gelegentlich": "Gelegentlich",
"selten": "Selten",
"episch": "Episch",
"legendaer": "Legendär",
}
# Farben für die UI (Sammelkarten-Logik: je seltener, desto heißer)
RARITY_COLOR = {
"haeufig": "#8b9a7b",
"gelegentlich": "#4f8fbf",
"selten": "#8b5cf6",
"episch": "#e0851f",
"legendaer": "#d4af37",
}
@dataclass(frozen=True)
class Species:
scientific: str
german: str
english: str
rarity: str
fact: str
size_cm: str = ""
# Die Kernliste sind per Definition die Arten, die am Futterhaus
# realistisch auftauchen — deshalb durchgehend "garten".
lebensraum: str = "garten"
@property
def xp(self) -> int:
return RARITY_XP[self.rarity]
@property
def slug(self) -> str:
return self.german.lower().replace("ä", "ae").replace("ö", "oe").replace(
"ü", "ue"
).replace("ß", "ss").replace(" ", "-")
# fmt: off
SPECIES: list[Species] = [
# ---------------- Häufig: die Stammgäste ----------------
Species("Parus major", "Kohlmeise", "Great Tit", "haeufig",
"Der schwarze Streifen auf dem Bauch verrät das Geschlecht: breit = Männchen, schmal = Weibchen.", "14 cm"),
Species("Cyanistes caeruleus", "Blaumeise", "Eurasian Blue Tit", "haeufig",
"Blaumeisen sehen UV-Licht — ihre Scheitelkappe leuchtet für Artgenossen viel greller als für uns.", "12 cm"),
Species("Passer domesticus", "Haussperling", "House Sparrow", "haeufig",
"Der Spatz lebt seit über 10.000 Jahren beim Menschen und kommt fast nirgends ohne uns vor.", "15 cm"),
Species("Turdus merula", "Amsel", "Eurasian Blackbird", "haeufig",
"Nur die Männchen sind schwarz mit gelbem Schnabel — Weibchen sind unauffällig braun.", "25 cm"),
Species("Erithacus rubecula", "Rotkehlchen", "European Robin", "haeufig",
"Rotkehlchen verteidigen ihr Revier so hart, dass sie sogar auf rote Federbüschel losgehen.", "14 cm"),
Species("Fringilla coelebs", "Buchfink", "Common Chaffinch", "haeufig",
"Buchfinken haben regionale Dialekte — ein Fink aus Bayern klingt anders als einer aus Hamburg.", "15 cm"),
Species("Chloris chloris", "Grünfink", "European Greenfinch", "haeufig",
"Sein dicker Schnabel knackt Sonnenblumenkerne, die für Meisen zu hart sind.", "15 cm"),
Species("Sturnus vulgaris", "Star", "European Starling", "haeufig",
"Stare imitieren Handyklingeln, Autoalarm und andere Vögel — echte Stimmenimitatoren.", "21 cm"),
Species("Pica pica", "Elster", "Eurasian Magpie", "haeufig",
"Elstern erkennen sich im Spiegel — das schaffen sonst nur Menschenaffen, Delfine und Elefanten.", "46 cm"),
Species("Columba palumbus", "Ringeltaube", "Common Wood-Pigeon", "haeufig",
"Der weiße Halsfleck fehlt Jungvögeln noch komplett.", "42 cm"),
Species("Corvus corone", "Rabenkrähe", "Carrion Crow", "haeufig",
"Krähen legen Nüsse auf Straßen, damit Autos sie knacken — und holen sie bei Rot ab.", "48 cm"),
# ---------------- Gelegentlich: gern gesehen ----------------
Species("Passer montanus", "Feldsperling", "Eurasian Tree Sparrow", "gelegentlich",
"Anders als beim Haussperling sehen Männchen und Weibchen gleich aus — beide mit braunem Käppi und Wangenfleck.", "14 cm"),
Species("Sitta europaea", "Kleiber", "Eurasian Nuthatch", "gelegentlich",
"Der einzige heimische Vogel, der kopfüber den Stamm hinunterlaufen kann.", "14 cm"),
Species("Carduelis carduelis", "Stieglitz", "European Goldfinch", "gelegentlich",
"Auch Distelfink genannt — sein spitzer Schnabel holt Samen aus Disteln, an die sonst keiner kommt.", "13 cm"),
Species("Troglodytes troglodytes", "Zaunkönig", "Eurasian Wren", "gelegentlich",
"Winzig, aber der lauteste Vogel im Verhältnis zur Größe — 90 Dezibel aus 10 Gramm.", "10 cm"),
Species("Prunella modularis", "Heckenbraunelle", "Dunnock", "gelegentlich",
"Unscheinbar grau, aber mit dem wildesten Liebesleben im Garten: mehrere Partner gleichzeitig.", "14 cm"),
Species("Phoenicurus ochruros", "Hausrotschwanz", "Black Redstart", "gelegentlich",
"Zittert ständig mit dem rostroten Schwanz — daran erkennt man ihn auf 50 Meter.", "14 cm"),
Species("Sylvia atricapilla", "Mönchsgrasmücke", "Eurasian Blackcap", "gelegentlich",
"Männchen tragen eine schwarze Kappe, Weibchen eine rotbraune.", "14 cm"),
Species("Periparus ater", "Tannenmeise", "Coal Tit", "gelegentlich",
"Die kleinste heimische Meise — legt Wintervorräte in Rindenritzen an.", "11 cm"),
Species("Dendrocopos major", "Buntspecht", "Great Spotted Woodpecker", "gelegentlich",
"Trommelt bis zu 20 Mal pro Sekunde — ein Stoßdämpfer im Schädel schützt sein Gehirn.", "23 cm"),
Species("Streptopelia decaocto", "Türkentaube", "Eurasian Collared-Dove", "gelegentlich",
"War vor 100 Jahren in Deutschland unbekannt und hat sich seitdem über ganz Europa ausgebreitet.", "32 cm"),
Species("Garrulus glandarius", "Eichelhäher", "Eurasian Jay", "gelegentlich",
"Vergräbt bis zu 5.000 Eicheln pro Herbst — die vergessenen werden zu Eichen.", "34 cm"),
Species("Corvus monedula", "Dohle", "Eurasian Jackdaw", "gelegentlich",
"Die hellblauen Augen sind einmalig unter unseren Rabenvögeln. Dohlen bleiben lebenslang zusammen.", "34 cm"),
Species("Motacilla alba", "Bachstelze", "White Wagtail", "gelegentlich",
"Wippt beim Laufen unaufhörlich mit dem Schwanz — niemand weiß sicher, warum.", "18 cm"),
Species("Aegithalos caudatus", "Schwanzmeise", "Long-tailed Tit", "gelegentlich",
"Der Schwanz ist länger als der ganze Körper. Im Winter schlafen sie in Reihen aneinandergedrängt.", "14 cm"),
# ---------------- Selten: Fototermin! ----------------
Species("Pyrrhula pyrrhula", "Gimpel", "Eurasian Bullfinch", "selten",
"Auch Dompfaff genannt. Das Männchen hat die knalligste rote Brust im Garten.", "16 cm"),
Species("Spinus spinus", "Erlenzeisig", "Eurasian Siskin", "selten",
"Kommt in manchen Wintern in Massen, in anderen fast gar nicht — je nach Erlensamen-Ernte.", "12 cm"),
Species("Turdus philomelos", "Singdrossel", "Song Thrush", "selten",
"Zertrümmert Schneckenhäuser an einem festen Stein, ihrer „Drosselschmiede\".", "22 cm"),
Species("Turdus pilaris", "Wacholderdrossel", "Fieldfare", "selten",
"Verteidigt ihr Nest, indem sie Angreifer gezielt mit Kot bombardiert.", "25 cm"),
Species("Phoenicurus phoenicurus", "Gartenrotschwanz", "Common Redstart", "selten",
"Zieht bis in die Sahelzone und zurück — 5.000 km pro Richtung.", "14 cm"),
Species("Ficedula hypoleuca", "Trauerschnäpper", "European Pied Flycatcher", "selten",
"Nimmt Nistkästen besonders gern an — der Klassiker für die Vogelhaus-Kamera.", "13 cm"),
Species("Muscicapa striata", "Grauschnäpper", "Spotted Flycatcher", "selten",
"Sitzt auf einer Warte, schnappt eine Fliege im Flug und kehrt exakt auf denselben Ast zurück.", "14 cm"),
Species("Phylloscopus collybita", "Zilpzalp", "Common Chiffchaff", "selten",
"Heißt so, weil er genau das singt: zilp-zalp-zilp-zalp.", "11 cm"),
Species("Emberiza citrinella", "Goldammer", "Yellowhammer", "selten",
"Ihr Gesang klingt wie „wie-wie-wie hab ich dich lieb\".", "17 cm"),
Species("Serinus serinus", "Girlitz", "European Serin", "selten",
"Der kleinste Fink Europas — nur 11 Gramm.", "11 cm"),
Species("Picus viridis", "Grünspecht", "European Green Woodpecker", "selten",
"Hackt kaum Holz, sondern gräbt mit 10 cm langer Zunge nach Ameisen im Rasen.", "32 cm"),
Species("Hirundo rustica", "Rauchschwalbe", "Barn Swallow", "selten",
"Frisst, trinkt und paart sich im Flug. Lange Schwanzspieße = fitter Partner.", "19 cm"),
Species("Delichon urbicum", "Mehlschwalbe", "Common House-Martin", "selten",
"Baut sein Nest aus rund 1.000 einzeln herangeflogenen Schlammkügelchen.", "13 cm"),
Species("Apus apus", "Mauersegler", "Common Swift", "selten",
"Bleibt nach dem Ausfliegen bis zu 10 Monate am Stück in der Luft — inklusive Schlafen.", "17 cm"),
# ---------------- Episch: Raubvögel am Futterhaus ----------------
Species("Accipiter nisus", "Sperber", "Eurasian Sparrowhawk", "episch",
"Jagt gezielt am Futterhaus. Wenn schlagartig alle Vögel weg sind, war er da.", "33 cm"),
Species("Falco tinnunculus", "Turmfalke", "Common Kestrel", "episch",
"Sieht den UV-reflektierenden Urin von Mäusen und findet daran ihre Wege.", "35 cm"),
Species("Buteo buteo", "Mäusebussard", "Common Buzzard", "episch",
"Der häufigste Greifvogel Deutschlands — nur eben selten im Vogelhaus.", "54 cm"),
Species("Corvus corax", "Kolkrabe", "Common Raven", "episch",
"Größter Singvogel der Welt. Löst Rätsel, die Vierjährige überfordern.", "64 cm"),
# ---------------- Legendär: einmal im Leben ----------------
Species("Athene noctua", "Steinkauz", "Little Owl", "legendaer",
"Der Vogel der Athene — deshalb sitzt eine Eule auf der griechischen Euro-Münze.", "22 cm"),
]
# fmt: on
BY_SCIENTIFIC: dict[str, Species] = {s.scientific: s for s in SPECIES}
BY_SLUG: dict[str, Species] = {s.slug: s for s in SPECIES}
def catalog(habitats: set[str] | None = None) -> list[Species]:
"""Kernliste plus Erweiterung, als eine Liste.
``habitats`` schränkt auf Lebensräume ein. Für eine Kamera am Futterhaus
ist ``{"garten"}`` richtig: der Klassifikator soll gar nicht erst
erwägen, ob der Fleck auf dem Ast ein Kranich sein könnte.
"""
arten = list(SPECIES)
try:
from .species_extra import EXTRA
except ImportError:
EXTRA = []
for e in EXTRA:
arten.append(
Species(
scientific=e.scientific,
german=e.german,
english=e.english,
rarity=e.rarity,
fact="",
size_cm=e.size_cm,
lebensraum=e.lebensraum,
)
)
if habitats:
arten = [a for a in arten if a.lebensraum in habitats]
return arten
# Arten, die der iNat-Klassifikator schlicht nicht kennt. Wenn eine davon vor
# der Linse sitzt, wird sie zwangsläufig auf eine verwandte Art gemappt.
# Ehrlicher, das offen zu dokumentieren, als es stillschweigend hinzunehmen.
NICHT_IM_MODELL = [
"Sumpfmeise (Poecile palustris)",
"Weidenmeise (Poecile montanus)",
"Haubenmeise (Lophophanes cristatus)",
"Gartenbaumläufer (Certhia brachydactyla)",
"Kleinspecht (Dryobates minor)",
"Waldkauz (Strix aluco)",
]
# Achtung beim Nachschlagen im Label-File: es benutzt teils veraltete
# wissenschaftliche Namen. Kernbeißer steht dort als Coccothraustes
# coccothraustes (richtig), Bluthänfling aber als "Carduelis cannabina"
# statt Linaria cannabina. Eine Suche nach dem heutigen Namen findet ihn
# nicht — beide Arten sind sehr wohl im Modell.
def label_to_scientific(label: str) -> str:
"""'Parus major (Great Tit)' -> 'Parus major'."""
return label.split(" (", 1)[0].strip()