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

171 lines
6.7 KiB
Python

"""Darstellungs-Metadaten zu den Seltenheitsstufen.
Bewusst hier dupliziert statt aus ``detector.species_de`` importiert: die
Web-App soll ohne numpy und LiteRT laufen können, damit der Container klein
bleibt und sich unabhängig vom Detector deployen lässt.
"""
# Regionen. Nur "de" ist mit Daten gefüllt — die Struktur steht, damit eine
# zweite Region später kein Umbau ist, sondern ein Datensatz.
REGIONS = {
"de": ("Deutschland", "Germany"),
}
# Lebensraum: die Achse, nach der sich 148 Arten sinnvoll teilen lassen.
# Ohne sie ersäuft die Gartensammlung in Wasservögeln.
HABITATS = {
"garten": ("Garten & Siedlung", "Gardens & settlements"),
"wald": ("Wald", "Woodland"),
"offenland": ("Feld & Wiese", "Fields & meadows"),
"wasser": ("Wasser & Ufer", "Water & shoreline"),
"gebirge": ("Gebirge", "Mountains"),
}
RARITY_COLOR = {
"haeufig": "#7f9b6a",
"gelegentlich": "#4f8fbf",
"selten": "#9b7ef0",
"episch": "#e0851f",
"legendaer": "#d4af37",
}
# Typ -> (Anzeigename, Fläche, Schrift). Gespiegelt aus
# detector/species_info.py, damit die Web-App ohne den Detector auskommt.
TYPE_STYLE = {
"meise": ("Meise", "#f2c744", "#3b2f00"),
"fink": ("Fink", "#e0574f", "#fff1f0"),
"sperling": ("Sperling", "#b08347", "#2e1f0c"),
"drossel": ("Drossel", "#7a5c3e", "#f5ece3"),
"schnaepper": ("Schnäpper", "#ef8354", "#3b1a0a"),
"grasmuecke": ("Grasmücke", "#8fae5d", "#1f2a10"),
"specht": ("Specht", "#d64545", "#fff0f0"),
"rabenvogel": ("Rabenvogel", "#8b7fd4", "#161233"),
"taube": ("Taube", "#a8a5b8", "#26242e"),
"schwalbe": ("Schwalbe", "#5aa9e6", "#0c2338"),
"greifvogel": ("Greifvogel", "#c9963f", "#33240a"),
"eule": ("Eule", "#5c6bc0", "#f0f1ff"),
"klettervogel": ("Klettervogel", "#4db6ac", "#0c2b28"),
"buschvogel": ("Buschvogel", "#9c7b5c", "#f6efe8"),
"stelze": ("Stelze", "#cfd8dc", "#22282a"),
"ammer": ("Ammer", "#e5c04a", "#332a06"),
# Gruppen, die es im Garten nicht gibt — gespiegelt aus
# detector/species_extra.py. Fehlen sie hier, rendert die Karte einen
# Strich statt eines Typs.
"schreitvogel": ("Schreitvogel", "#6b8f8a", "#f0f6f5"),
"ente": ("Ente", "#3f7d9e", "#eef7fb"),
"gans": ("Gans", "#8d9aa6", "#151b20"),
"moewe": ("Möwe", "#7fa8c9", "#101c26"),
"watvogel": ("Watvogel", "#b3966b", "#241c0e"),
"taucher": ("Taucher", "#4a7a8c", "#eef6f9"),
"ralle": ("Ralle", "#5f6b5a", "#eef2ec"),
"lerche": ("Lerche", "#c2a878", "#241d0d"),
"wuerger": ("Würger", "#9a6f8f", "#f7eef5"),
"huhn": ("Hühnervogel", "#a4703f", "#f8efe6"),
"exot": ("Exot", "#22a3a3", "#06302f"),
}
ZUG_LABEL = {
"stand": "Standvogel",
"teil": "Teilzieher",
"zug": "Zugvogel",
}
ZUG_LABEL_EN = {
"stand": "Resident",
"teil": "Partial migrant",
"zug": "Migrant",
}
# Typnamen auf Englisch. Bewusst die gängigen Gruppenbegriffe, nicht die
# wörtliche Übertragung — „Schnäpper" heißt hier Chat, „Buschvogel" Skulker.
TYPE_LABEL_EN = {
"meise": "Tit", "fink": "Finch", "sperling": "Sparrow", "drossel": "Thrush",
"schnaepper": "Chat", "grasmuecke": "Warbler", "specht": "Woodpecker",
"rabenvogel": "Corvid", "taube": "Pigeon", "schwalbe": "Swallow",
"greifvogel": "Raptor", "eule": "Owl", "klettervogel": "Climber",
"buschvogel": "Skulker", "stelze": "Wagtail", "ammer": "Bunting",
"schreitvogel": "Long-legged", "ente": "Duck", "gans": "Goose",
"moewe": "Gull", "watvogel": "Shorebird", "taucher": "Grebe",
"ralle": "Rail", "lerche": "Lark", "wuerger": "Shrike",
"huhn": "Gamebird", "exot": "Exotic",
}
RARITY_LABEL_EN = {
"haeufig": "Common", "gelegentlich": "Occasional", "selten": "Rare",
"episch": "Epic", "legendaer": "Legendary",
}
def _hex(c: str) -> tuple[int, int, int]:
c = c.lstrip("#")
return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
def mix(color: str, other: str, ratio: float) -> str:
"""Mischt zwei Hex-Farben. Ersatz für CSS ``color-mix()``.
``color-mix()`` gibt es erst ab Safari 16.2 — auf älteren Geräten fällt
jede Regel, die es benutzt, ersatzlos weg, und die Seite bricht auseinander.
Vorberechnen kostet nichts und funktioniert überall.
``ratio`` ist der Anteil von ``color``.
"""
a, b = _hex(color), _hex(other)
return "#" + "".join(
f"{round(a[i] * ratio + b[i] * (1 - ratio)):02x}" for i in range(3)
)
# Oberflächen, gegen die gemischt wird — müssen zu den Werten in vogelalbum.css
# passen. Je Modus ein Satz.
LIGHT_SURFACE_SOFT = "#f4f7f2"
LIGHT_LINE = "#dfe6dd"
LIGHT_INK = "#1a2a20"
DARK_SURFACE_SOFT = "#1b2129"
DARK_LINE = "#2f3945"
DARK_PAPER = "#141920"
def type_vars(typ: str) -> str:
"""Alle abgeleiteten Typfarben als CSS-Variablen. Landet als ``style=""``.
Es werden **beide** Modi ausgegeben, mit den Endungen ``-l`` und ``-d``.
Das Stylesheet entscheidet über eine Indirektion, welcher Satz gilt:
:root { --typ-wash: var(--typ-wash-l); }
[data-theme="dark"] { --typ-wash: var(--typ-wash-d); }
Das funktioniert, weil Custom Properties erst am Verwendungsort aufgelöst
werden — dort sind ``--typ-wash-l/-d`` von der Karte geerbt. Nötig ist der
Umweg, weil ein Inline-Style sich nicht per Stylesheet überschreiben lässt.
Die Rollen: ``--typ`` die reine Farbe für Flächen und Bänder, ``--typ-text``
für Text (auf Weiß abgedunkelt, auf Dunkel aufgehellt), ``--typ-wash`` als
blasser Hintergrund, ``--typ-line`` für Rahmen.
"""
label, farbe, ink = TYPE_STYLE.get(typ, ("—", "#7f9b6a", "#ffffff"))
return (
f"--typ:{farbe};"
f"--typ-ink:{ink};"
# hell
f"--typ-text-l:{mix(farbe, LIGHT_INK, 0.62)};"
f"--typ-wash-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.13)};"
f"--typ-card-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.22)};"
f"--typ-line-l:{mix(farbe, LIGHT_LINE, 0.45)};"
# dunkel — dort muss die Farbe aufgehellt werden, um lesbar zu bleiben,
# und die Flächen viel zurückhaltender sein, sonst blendet es.
f"--typ-text-d:{mix(farbe, '#ffffff', 0.80)};"
f"--typ-wash-d:{mix(farbe, DARK_SURFACE_SOFT, 0.14)};"
f"--typ-card-d:{mix(farbe, DARK_PAPER, 0.20)};"
f"--typ-line-d:{mix(farbe, DARK_LINE, 0.42)};"
)
RARITY_GLOW = {
"haeufig": "none",
"gelegentlich": "none",
"selten": "0 0 24px -6px #9b7ef0",
"episch": "0 0 28px -4px #e0851f",
"legendaer": "0 0 34px -2px #d4af37",
}