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

View File
Whitespace-only changes.
+721
View File
@@ -0,0 +1,721 @@
"""Kommandozeile des Detectors.
python -m detector watch --source http://vogelhaus.local:8080/?action=stream
python -m detector test testdata/
python -m detector tune --source ... # Schwellwerte einstellen
Konfiguration wahlweise per Flag oder Umgebungsvariable (VOGELALBUM_*), damit
der systemd-Dienst ohne lange Kommandozeile auskommt.
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from pathlib import Path
from PIL import Image
from .classify import Pipeline
from .daylight import DaylightGate, brightness
from .motion import MotionDetector
from .sink import ConsoleSink, FileSink, HTTPSink, MultiSink
from . import umgebung
from .species_de import NICHT_IM_MODELL
from .stream import open_source
from .watcher import Watcher, WatcherConfig
def env(name: str, default: str | None = None) -> str | None:
# Liest VOGELALBUM_<name> und faellt auf das alte VOGELALBUM_<name> zurueck,
# damit laufende Anlagen die Umbenennung nicht merken. Siehe umgebung.py.
return umgebung.hole(name, default)
def build_watcher(args, beobachter=None) -> Watcher:
# Nur wenn wirklich protokolliert wird, lohnt sich die niedrigere Schwelle
# im Detektor — sonst suchte er umsonst nach Boxen, die niemand ansieht.
report_threshold = getattr(args, "protokoll_ab", None) if getattr(args, "protokoll", None) else None
return Watcher(
beobachter=beobachter,
pipeline=Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
report_threshold=report_threshold,
),
motion=MotionDetector(
threshold=args.motion_threshold, min_ratio=args.motion_ratio
),
config=WatcherConfig(
classify_every=args.classify_every,
cooldown_s=args.cooldown,
min_confidence=args.min_confidence,
min_bird_frames=args.min_bird_frames,
gate=args.gate,
),
)
def build_sink(args):
sinks = [ConsoleSink()]
if args.save_to:
sinks.append(FileSink(args.save_to))
if args.api:
sinks.append(HTTPSink(args.api, args.token or ""))
return MultiSink(*sinks)
def cmd_watch(args) -> int:
from .fernkonfig import Fernkonfig, arten_aus_json
from .protokoll import Protokoll
from .stichprobe import Stichprobe
from .verteiler import Verteiler
# Die Web-App darf die Einstellungen vorgeben, sobald sie erreichbar ist.
# Ohne Token geht das nicht — dann bleibt die Kommandozeile die Wahrheit.
fern = (
Fernkonfig(args.api, args.token, intervall=args.konfig_alle)
if args.api and args.token and args.konfig_alle > 0
else None
)
protokoll = Protokoll(args.protokoll, args.protokoll_ab) if args.protokoll else None
verteiler = None
if args.live_port:
try:
verteiler = Verteiler(
args.live_port, bind=args.live_bind, max_zuschauer=args.live_max
)
except OSError as exc:
# Meist laeuft schon ein Detector auf diesem Port. Das Live-Bild
# ist eine Zugabe — daran soll die Erkennung nicht scheitern.
print(f"[live] Port {args.live_port} nicht verfuegbar ({exc}) — "
f"laeuft schon ein Detector? Weiter ohne Live-Bild.",
file=sys.stderr)
stichprobe = (
Stichprobe(args.stichprobe, args.stichprobe_alle, args.stichprobe_max)
if args.stichprobe
else None
)
def mitschreiben(box, result):
protokoll.erkennung(box, result, gate.last_brightness)
sink = build_sink(args)
watcher = build_watcher(args, beobachter=mitschreiben if protokoll else None)
def uebernimm(daten: dict, beim_start: bool = False) -> None:
"""Was die Web-App sagt, im laufenden Betrieb umsetzen."""
arten = arten_aus_json(daten.get("arten") or [])
geaendert = watcher.pipeline.uebernimm(
detect_threshold=daten.get("detect_threshold"),
min_bird_score=daten.get("min_bird_score"),
# Eine leere Artenliste wird ignoriert: sie wuerde jede Erkennung
# unmoeglich machen, und das ist nie die Absicht.
arten=arten or None,
)
# Der Mittelwert-Schwellwert sitzt am Watcher, nicht an der Pipeline:
# er gilt fuer den ganzen Besuch, und der entsteht erst hier.
mittel = daten.get("min_mean_bird_score")
if mittel is not None and mittel != watcher.cfg.min_mean_bird_score:
geaendert.append(
f"Mindest-Vogelwert je Besuch "
f"{watcher.cfg.min_mean_bird_score:.2f} -> {mittel:.2f}"
)
watcher.cfg.min_mean_bird_score = mittel
# Bildrate der Vorschau: gilt sofort, auch im laufenden Betrieb.
# Wer sie im Backend umstellt, soll nicht neu starten muessen.
rate = daten.get("live_bildrate")
if rate is not None and verteiler is not None:
verteiler.bildrate_setzen(rate)
if beim_start:
print(f"[konfig] Vorschau-Bildrate: {rate}/s"
if rate else "[konfig] Vorschau ohne Begrenzung")
if beim_start:
kamera = daten.get("kamera") or "unbenannt"
print(f"[konfig] Aus der Web-App fuer '{kamera}': "
f"{len(arten)} Arten, Region {daten.get('region', '?')}, "
f"Schwellen {daten.get('detect_threshold')}/"
f"{daten.get('min_bird_score')}")
for zeile in geaendert:
print(f"[konfig] {zeile}")
if fern is not None:
erste = fern.hole()
if erste is not None:
# Die Stream-Adresse aus der Web-App gilt nur, wenn lokal keine
# steht: wer sie auf die Kommandozeile schreibt, meint sie auch.
if not args.source and erste.get("stream_url"):
args.source = erste["stream_url"]
print(f"[konfig] Quelle aus der Web-App: {args.source}")
uebernimm(erste, beim_start=True)
if not args.source:
print("[fehler] Keine Quelle — weder --source noch eine Stream-Adresse "
"im Admin der Web-App.", file=sys.stderr)
return 1
gate = DaylightGate(
dark_below=args.dunkel_unter,
light_above=args.hell_ueber,
patience=args.dunkel_geduld,
enabled=not args.nachtsicht,
)
print(f"[start] Quelle: {args.source}")
if args.nachtsicht:
print("[start] Nachtsicht: laeuft auch im Dunkeln durch")
else:
print(f"[start] Pausiert unter Helligkeit {args.dunkel_unter:.0f}/255, "
f"laeuft ab {args.hell_ueber:.0f} wieder an")
if args.api:
print(f"[start] Meldet an: {args.api}")
if args.save_to:
print(f"[start] Speichert nach: {args.save_to}")
if protokoll is not None:
print(f"[start] Protokoll: {protokoll.pfad} (ab Score {args.protokoll_ab})")
if stichprobe is not None:
print(f"[start] Stichproben: {stichprobe.ordner} "
f"(alle {args.stichprobe_alle:.0f}s, hoechstens {args.stichprobe_max})")
if verteiler is not None:
verteiler.start()
print(f"[start] Live-Bild: http://<dieser-rechner>:{verteiler.port}/ "
f"— fuers Heimnetz, nicht fuers Internet")
stop = False
def handle(signum, frame):
nonlocal stop
stop = True
print("\n[stop] Beende nach dem laufenden Besuch ...")
signal.signal(signal.SIGINT, handle)
signal.signal(signal.SIGTERM, handle)
frames = 0
dunkle_frames = 0
dunkel_seit = time.monotonic()
last_report = time.monotonic()
while not stop:
source = open_source(args.source, fps=args.fps, interval=args.interval)
try:
for frame in source:
frames += 1
war_dunkel = gate.is_dark
hell_genug = gate.update(frame)
if gate.is_dark != war_dunkel:
if gate.is_dark:
# Laufenden Besuch sauber abschliessen, bevor die
# Analyse aussetzt — sonst haengt er bis zum Morgen.
offen = watcher.flush()
if offen is not None:
sink.emit(offen)
print(f"[dunkel] Analyse pausiert "
f"(Helligkeit {gate.last_brightness:.0f}/255)")
dunkel_seit = time.monotonic()
else:
dauer = (time.monotonic() - dunkel_seit) / 3600
print(f"[hell] Analyse laeuft wieder "
f"(Helligkeit {gate.last_brightness:.0f}/255, "
f"{dauer:.1f} h pausiert)")
# Auch im Dunkeln weitergeben: dann zeigt das Livebild wenigstens,
# dass die Kamera noch laeuft. Kostet nur Zeigerarbeit, kodiert
# wird erst, wenn jemand zuschaut.
if verteiler is not None:
verteiler.zeige(frame, source.letztes_jpeg)
if hell_genug:
sighting = watcher.process(frame)
if sighting is not None:
sink.emit(sighting)
# Nur tagsueber: ein schwarzes Bild zeigt niemandem etwas.
if stichprobe is not None:
stichprobe.bild(frame, time.monotonic())
else:
dunkle_frames += 1
if protokoll is not None:
protokoll.bild(dunkel=not hell_genug)
protokoll.puls(time.monotonic(), gate.last_brightness)
now = time.monotonic()
# Nachladen im Betrieb: wer im Admin eine Schwelle verstellt
# oder eine Art dazunimmt, soll den Detector nicht neu starten
# muessen. Ein Ausfall der Web-App aendert hier nichts.
if fern is not None and fern.faellig(now):
neu = fern.hole(now)
if neu is not None:
uebernimm(neu)
if args.verbose and now - last_report > 60:
print(f"[lebt] {frames} Bilder, davon {dunkle_frames} im "
f"Dunkeln uebersprungen — {gate.status}")
last_report = now
if stop:
break
except (OSError, RuntimeError) as exc:
if stop:
break
# Der Pi startet neu, das WLAN hustet — einfach weiterversuchen.
print(f"[stream] Verbindung verloren ({exc}), neuer Versuch in 10s")
time.sleep(10)
continue
if not source.restartable:
break
if not stop:
print("[stream] Quelle beendet, öffne neu in 5s")
time.sleep(5)
final = watcher.flush()
if final is not None:
sink.emit(final)
if protokoll is not None:
protokoll.puls(time.monotonic(), gate.last_brightness, erzwingen=True)
protokoll.close()
if verteiler is not None:
verteiler.stop()
print(f"[ende] {frames} Bilder verarbeitet")
return 0
def cmd_test(args) -> int:
"""Einzelbilder durchklassifizieren, ohne Bewegungslogik."""
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
)
paths: list[Path] = []
for raw in args.paths:
p = Path(raw)
if p.is_dir():
paths += sorted(
q for q in p.iterdir() if q.suffix.lower() in {".jpg", ".jpeg", ".png"}
)
else:
paths.append(p)
if not paths:
print("Keine Bilder gefunden.")
return 1
for p in paths:
img = Image.open(p).convert("RGB")
box, result = pipeline.run(img)
print(f"\n{p.name} ({img.width}×{img.height})")
if box is None:
print(" kein Vogel erkannt")
continue
print(f" Vogel erkannt: {box.score:.0%} sicher, füllt {box.area:.0%} des Bildes")
if result is None:
print(" Art nicht bestimmbar (keine heimische Art wahrscheinlich)")
continue
for g in result.guesses[:3]:
bar = "█" * round(g.confidence * 24)
print(f" {g.species.german:<22} {g.confidence:>6.1%} {bar}")
print(f" [heimische Masse {result.bird_score:.0%}, {result.ms:.0f} ms]")
return 0
def cmd_warteschlange(args) -> int:
"""Hochgeladene Fotos abholen und bestimmen.
Der zweite Weg, auf dem Bilder hereinkommen — der erste ist die Kamera.
Laeuft entweder einmal (``--einmal``) oder als Dauerbetrieb.
"""
from .warteschlange import Warteschlange
if not args.api:
print("Ohne --api geht es nicht: Die Fotos liegen in der Web-App.")
return 1
if not args.lese_token:
print("Ohne --lese-token kann ich die offenen Fotos nicht abrufen.")
return 1
if not args.token:
print("Ohne --token kann ich kein Ergebnis zurueckmelden.")
return 1
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
)
schlange = Warteschlange(
api=args.api,
lese_token=args.lese_token,
schreib_token=args.token,
pipeline=pipeline,
intervall=args.alle,
anzahl=args.anzahl,
)
if args.einmal:
anzahl = schlange.durchlauf()
print(f"[warteschlange] {anzahl} Foto(s) bearbeitet.")
return 0
print(f"[start] Warteschlange an {args.api}, alle {args.alle:.0f}s")
try:
while True:
schlange.durchlauf()
time.sleep(args.alle)
except KeyboardInterrupt:
print("\n[ende] Warteschlange beendet.")
return 0
def cmd_tune(args) -> int:
"""Zeigt live die Bewegungswerte — damit lassen sich die Schwellwerte an
der echten Kamera einstellen, statt sie zu raten."""
motion = MotionDetector(threshold=args.motion_threshold, min_ratio=args.motion_ratio)
print("Bewegungsanteil pro Bild. Strg+C beendet.")
print(f"Schwelle steht auf {args.motion_ratio:.1%}.\n")
n = 0
peak = 0.0
try:
for frame in open_source(args.source, fps=args.fps, interval=args.interval):
ratio = motion.update(frame)
peak = max(peak, ratio)
n += 1
flag = "BEWEGUNG" if motion.is_motion(ratio) else " "
hell = brightness(frame)
bar = "▇" * min(30, round(ratio * 400))
print(f"{n:>6} {ratio:>7.2%} {flag} Helligkeit {hell:>5.1f} {bar}")
except KeyboardInterrupt:
print(f"\nHöchstwert war {peak:.2%}. "
f"Setze --motion-ratio etwas über dem Ruhepegel.")
return 0
def cmd_karte(args) -> int:
"""Erkennungskarte: taugt die Ausrichtung der Kamera?"""
from .karte import rat, tabelle, vermessen, zeichne
# Der Standardwert zeigt auf testdata/, und das steht nicht im Repo — die
# Fotos stammen von Wikimedia Commons und haengen an ihren Lizenzen. Ein
# blanker FileNotFoundError waere hier die schlechteste Antwort: Man will
# wissen, was zu tun ist, nicht wo es gekracht hat.
if not Path(args.vogel).exists():
print(f"Kein Vogelfoto unter {args.vogel}.\n")
print("Entweder die Beispielfotos holen:")
print(" bash scripts/fetch_testdata.sh\n")
print("Oder ein eigenes nehmen — je aehnlicher dem, was deine Kamera")
print("sieht, desto aussagekraeftiger die Karte:")
print(" python -m detector karte --vogel mein_vogel.jpg ...")
return 1
vogel = Image.open(args.vogel).convert("RGB")
quelle = Path(args.source)
if quelle.is_dir():
pfade = sorted(
p for p in quelle.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
)[: args.bilder]
if not pfade:
print(f"Keine Bilder in {quelle}")
return 1
hintergruende = [Image.open(p).convert("RGB") for p in pfade]
else:
# Der Pi Zero am Vogelhaus vertraegt genau einen Zuschauer: ein
# zweiter saettigt die Funkstrecke und der laufende Detector faellt in
# Timeouts. Das ist hier schon einmal passiert — das Messwerkzeug hat
# den Fehler erzeugt, den es messen sollte.
print("Hinweis: Viele Kameras liefern nur einen Stream gleichzeitig.")
print("Laeuft der Detector, halte ihn fuer die Aufnahme an.\n")
print(f"Nehme {args.bilder} Hintergrundbilder auf ...")
hintergruende = []
for frame in open_source(args.source, fps=args.fps, interval=args.interval):
hintergruende.append(frame)
if len(hintergruende) >= args.bilder:
break
print(f"{len(hintergruende)} Hintergruende, Vogel mit "
f"{args.anteil * 100:.0f} % Bildflaeche\n")
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
report_threshold=0.02, # damit auch knappe Faelle einen Score zeigen
)
zellen = vermessen(pipeline, hintergruende, vogel, args.anteil)
print(tabelle(zellen))
print(f"\n{rat(zellen)}")
ziel = Path(args.ziel)
zeichne(hintergruende[0], zellen).save(ziel, quality=92)
print(f"\nKarte: {ziel}")
return 0
def cmd_report(args) -> int:
"""Auswertung der gespeicherten Rohdaten."""
from .report import kontaktbogen, lade, zeige
ordner = Path(args.ordner)
if not ordner.is_dir():
print(f"Kein Ordner: {ordner}")
return 1
eintraege = lade(ordner)
zeige(eintraege, ordner)
if args.kontaktbogen:
ziel = Path(args.kontaktbogen)
n = kontaktbogen(eintraege, ziel)
print(f"\nKontaktbogen mit {n} Ausschnitten: {ziel}")
return 0
def cmd_bogen(args) -> int:
"""Stichproben zu einem Blatt montieren."""
from .stichprobe import kontaktbogen
ordner = Path(args.ordner)
if not ordner.is_dir():
print(f"Kein Ordner: {ordner}")
return 1
try:
ziel, n, seiten = kontaktbogen(
ordner, args.ziel,
spalten=args.spalten, zeilen=args.zeilen, seite=args.seite,
)
except FileNotFoundError as exc:
print(exc)
return 1
print(f"Seite {min(max(1, args.seite), seiten)} von {seiten}, "
f"{n} Bilder: {ziel}")
return 0
def cmd_species(args) -> int:
from .species_de import RARITY_LABEL, catalog
habitats = set(args.habitats.split(",")) if args.habitats else None
SPECIES = catalog(habitats)
by_rarity: dict[str, list] = {}
for s in SPECIES:
by_rarity.setdefault(s.rarity, []).append(s)
for rarity in ("haeufig", "gelegentlich", "selten", "episch", "legendaer"):
items = by_rarity.get(rarity, [])
print(f"\n{RARITY_LABEL[rarity]} ({len(items)}):")
for s in items:
print(f" {s.german:<22} {s.scientific:<30} {s.xp:>4} XP")
print(f"\nInsgesamt {len(SPECIES)} Arten.")
print("\nDem Modell unbekannt (werden auf verwandte Arten gemappt):")
for name in NICHT_IM_MODELL:
print(f" {name}")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="detector", description="Vogelalbum-Detector")
sub = p.add_subparsers(dest="cmd", required=True)
def common(sp):
sp.add_argument("--detect-threshold", type=float, default=0.4,
help="Ab welcher Sicherheit gilt etwas als Vogel (Standard 0.4)")
sp.add_argument("--min-bird-score", type=float, default=0.05,
help="Mindestanteil heimischer Arten, sonst 'unbestimmt'")
sp.add_argument("--habitats", default=env("HABITATS", "garten"),
help="Lebensraeume kommagetrennt: garten, wald, offenland, "
"wasser, gebirge. Leer laesst alle 148 Arten zu. "
"Standard 'garten' — eine Kamera am Futterhaus soll "
"gar nicht erst erwaegen, ob da ein Kranich sitzt.")
def stream_opts(sp):
sp.add_argument("--source", default=env("SOURCE"),
help="MJPEG-/Snapshot-URL, rtsp://... oder ein Ordner")
sp.add_argument("--fps", type=float, default=2.0, help="nur für RTSP")
sp.add_argument("--interval", type=float, default=1.0,
help="Sekunden zwischen Snapshots")
sp.add_argument("--motion-threshold", type=int, default=22,
help="Helligkeitsunterschied, ab dem ein Pixel als bewegt gilt")
sp.add_argument("--motion-ratio", type=float, default=0.012,
help="Anteil bewegter Pixel, ab dem ein Besuch beginnt")
w = sub.add_parser("watch", help="Dauerbetrieb an der Kamera")
common(w)
stream_opts(w)
w.add_argument("--api", default=env("API"), help="Basis-URL der Web-App")
w.add_argument("--token", default=env("TOKEN"), help="Ingest-Token")
w.add_argument("--save-to", default=env("SAVE_TO"), help="Ordner für Rohdaten")
w.add_argument("--classify-every", type=int, default=3)
w.add_argument("--cooldown", type=float, default=4.0,
help="Sekunden ohne Bewegung, bis der Besuch endet")
w.add_argument("--min-confidence", type=float, default=0.45)
w.add_argument("--min-bird-frames", type=int, default=2,
help="So viele Bilder mit Vogel braucht ein Besuch, sonst wird er "
"verworfen. 1 faengt auch Sekundenbesuche — sinnvoll an einem "
"neuen Futterhaus, wo die ersten Gaeste noch scheu sind.")
w.add_argument("--gate", choices=("motion", "detector"), default=env("GATE", "motion"),
help="Was einen Besuch ausloest. 'motion' spart Rechenzeit und "
"setzt einen ruhigen Hintergrund voraus; 'detector' laesst das "
"Modell entscheiden und ist noetig, wenn die Kamera auf "
"Bewegtes schaut (Hecke im Wind).")
w.add_argument("--nachtsicht", action="store_true",
default=env("NACHTSICHT", "") not in ("", "0", "false"),
help="Kamera sieht im Dunkeln (Infrarot). Dann wird nie "
"pausiert. Ohne diesen Schalter setzt die Analyse aus, "
"sobald das Bild zu dunkel ist, um etwas zu erkennen.")
w.add_argument("--dunkel-unter", type=float, default=15.0,
help="Helligkeit 0-255, unter der pausiert wird. Gemessen am "
"Vogelhaus: tagsueber 111-131, nachts 1,7.")
w.add_argument("--hell-ueber", type=float, default=30.0,
help="Helligkeit, ab der es weitergeht. Muss ueber "
"--dunkel-unter liegen, sonst flattert es in der "
"Daemmerung.")
w.add_argument("--dunkel-geduld", type=int, default=20,
help="So viele Bilder am Stueck muessen fuer den Wechsel "
"sprechen. Verhindert, dass ein Autoscheinwerfer die "
"Analyse weckt.")
w.add_argument("--protokoll", default=env("PROTOKOLL"),
help="CSV fuer ein Dauerprotokoll: jede Erkennung ab "
"--protokoll-ab plus ein Lebenszeichen je Viertelstunde. "
"Beantwortet ueber Tage, ob Voegel knapp verpasst werden.")
w.add_argument("--protokoll-ab", type=float, default=0.08,
help="Ab welchem Score eine Erkennung ins Protokoll wandert. "
"Bewusst weit unter --detect-threshold.")
w.add_argument("--konfig-alle", type=float, default=float(env("KONFIG_ALLE", "300")),
help="Sekunden zwischen zwei Abfragen der Einstellungen bei "
"der Web-App (Standard 300, 0 = nie). Braucht --api und "
"--token. Was dort im Admin steht, gewinnt gegen die "
"Kommandozeile — ausser bei --source: eine ausdruecklich "
"angegebene Quelle bleibt.")
w.add_argument("--stichprobe", default=env("STICHPROBE"),
help="Ordner fuer regelmaessige Bilder aus dem Strom, "
"unabhaengig von jeder Erkennung. Beantwortet die Frage, "
"die das Protokoll nicht beantworten kann: kommt ueberhaupt "
"ein Vogel ins Bild?")
w.add_argument("--stichprobe-alle", type=float, default=300.0,
help="Sekunden zwischen zwei Stichproben (Standard 300).")
w.add_argument("--stichprobe-max", type=int, default=500,
help="So viele Bilder bleiben liegen, dann faellt das aelteste "
"raus. Ein Dauerlaeufer darf keine Platte volllaufen lassen.")
w.add_argument("--live-port", type=int, default=int(env("LIVE_PORT", "0")),
help="Port fuer ein Live-Bild, das der Detector aus seinem "
"eigenen Bildstrom verteilt. 0 = aus. Die Kamera "
"liefert nur einen Stream; wer direkt bei ihr zuschaut, "
"nimmt ihn der Erkennung weg. Hier schauen beliebig "
"viele zu, ohne dass die Kamera etwas merkt.")
w.add_argument("--live-bind", default=env("LIVE_BIND", "0.0.0.0"),
help="Adresse, auf der das Live-Bild lauscht. Gehoert ins "
"Heimnetz — es ist ein Bild aus dem eigenen Garten "
"ohne Anmeldung.")
w.add_argument("--live-max", type=int, default=8,
help="So viele Zuschauer gleichzeitig. Reine Vorsicht gegen "
"vergessene Browsertabs.")
w.add_argument("--verbose", action="store_true")
w.set_defaults(func=cmd_watch)
t = sub.add_parser("test", help="Einzelbilder klassifizieren")
common(t)
t.add_argument("paths", nargs="+")
t.set_defaults(func=cmd_test)
q = sub.add_parser(
"warteschlange",
help="Hochgeladene Fotos abholen und bestimmen",
description="Holt Fotos, die ueber die App oder das Formular "
"hochgeladen wurden, bestimmt sie und meldet das Ergebnis "
"zurueck. Fragt selbst nach — es braucht keinen offenen "
"Port und keine erreichbare Adresse.",
)
common(q)
q.add_argument("--api", default=env("API"), help="Basis-URL der Web-App")
q.add_argument("--token", default=env("TOKEN"),
help="Token zum Zurueckmelden (derselbe wie beim Einliefern)")
q.add_argument("--lese-token", default=env("LESE_TOKEN"),
help="Token zum Abrufen der offenen Fotos")
q.add_argument("--alle", type=float, default=60.0,
help="Sekunden zwischen zwei Abfragen (Standard 60)")
q.add_argument("--anzahl", type=int, default=20,
help="Hoechstens so viele Fotos je Durchlauf (Standard 20)")
q.add_argument("--einmal", action="store_true",
help="Einen Durchlauf machen und beenden")
q.set_defaults(func=cmd_warteschlange)
u = sub.add_parser("tune", help="Bewegungsschwelle an der Kamera einstellen")
stream_opts(u)
u.set_defaults(func=cmd_tune)
k = sub.add_parser(
"karte",
help="Erkennungskarte: wo im Bild wird ein Vogel gefunden",
description="Montiert ein Vogelfoto ueber ein Raster von Positionen in "
"aktuelle Kamerabilder und misst, wo der Detektor anspringt. "
"Beantwortet vor dem ersten echten Besuch, ob die Kamera "
"richtig haengt.",
)
common(k)
stream_opts(k)
k.add_argument("--vogel", default="testdata/amsel.jpg",
help="Vogelfoto zum Einmontieren. Am aussagekraeftigsten ist "
"ein Ausschnitt aus einer echten Sichtung der eigenen "
"Kamera; die Referenzfotos tun es auch.")
k.add_argument("--anteil", type=float, default=0.08,
help="Wie viel Bildflaeche der Vogel einnimmt (Standard 8 %%). "
"Vor ruhigem Hintergrund reichen 5 %%, vor einem Baum im "
"Wind eher 8 %% — genau deshalb misst man nach.")
k.add_argument("--bilder", type=int, default=3,
help="So viele Hintergrundbilder je Position, damit ein "
"zufaellig guenstiger Ast das Ergebnis nicht traegt")
k.add_argument("--ziel", default="karte.jpg", help="Bilddatei fuer die Karte")
k.set_defaults(func=cmd_karte)
r = sub.add_parser("report", help="Gespeicherte Sichtungen auswerten")
r.add_argument("ordner", help="Ordner aus --save-to")
r.add_argument("--kontaktbogen", help="Ausschnitte als ein Bild nebeneinander")
r.set_defaults(func=cmd_report)
b = sub.add_parser(
"bogen",
help="Stichproben als ein Blatt montieren",
description="Macht aus dem Stichprobenordner ein einzelnes Blatt, "
"neueste zuerst. 120 Einzelbilder sieht niemand durch, "
"ein Blatt schon.",
)
b.add_argument("ordner", help="Ordner aus --stichprobe")
b.add_argument("--ziel", default="bogen.jpg", help="Bilddatei fuer das Blatt")
b.add_argument("--spalten", type=int, default=5)
b.add_argument("--zeilen", type=int, default=6)
b.add_argument("--seite", type=int, default=1,
help="Welche Seite, 1 = die neuesten Bilder")
b.set_defaults(func=cmd_bogen)
s = sub.add_parser("species", help="Artenkatalog anzeigen")
s.add_argument("--habitats", default=None,
help="Lebensraeume kommagetrennt; leer zeigt alle")
s.set_defaults(func=cmd_species)
args = p.parse_args(argv)
if getattr(args, "source", "SKIP") is None:
# 'watch' darf die Adresse auch aus der Web-App holen; die Prüfung
# macht dort cmd_watch, wenn beides fehlt. Alle anderen Befehle
# brauchen sie sofort.
if args.cmd == "watch" and args.api and args.token:
args.source = ""
else:
p.error("--source fehlt (oder VOGELALBUM_SOURCE setzen)")
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+366
View File
@@ -0,0 +1,366 @@
"""Artbestimmung per LiteRT (ehemals TFLite).
Zwei Stufen:
1. ``BirdFinder`` — SSD-MobileNet auf COCO. Beantwortet nur: "ist da überhaupt
ein Vogel, und wo?". Verhindert, dass ein wackelnder Ast als Kohlmeise
in die Sammlung wandert, und liefert den Ausschnitt fürs Zuschneiden.
2. ``SpeciesClassifier`` — MobileNetV2 auf iNaturalist-Vögeln (965 Arten).
Läuft auf dem zugeschnittenen Vogel, nicht auf dem ganzen Bild.
Beide Modelle sind int8-quantisiert und laufen auf dem N100 in wenigen
Dutzend Millisekunden — reicht dicke, weil nur bei Bewegung klassifiziert wird.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image
from .species_de import BY_SCIENTIFIC, Species, catalog, label_to_scientific
MODELS = Path(__file__).resolve().parent.parent / "models"
CLASSIFIER_MODEL = MODELS / "mobilenet_v2_1.0_224_inat_bird_quant.tflite"
CLASSIFIER_LABELS = MODELS / "inat_bird_labels.txt"
DETECTOR_MODEL = MODELS / "ssd_mobilenet_v2_coco_quant_postprocess.tflite"
COCO_BIRD_CLASS = 15 # in diesem Modell ist 15 = "bird"
def _interpreter(path: Path):
from ai_edge_litert.interpreter import Interpreter
if not path.exists():
raise FileNotFoundError(
f"Modell fehlt: {path}\nHol es mit: ./scripts/fetch_models.sh"
)
interp = Interpreter(model_path=str(path), num_threads=4)
interp.allocate_tensors()
return interp
@dataclass
class Box:
"""Bounding Box in relativen Koordinaten (0..1)."""
x0: float
y0: float
x1: float
y1: float
score: float
def crop(self, img: Image.Image, pad: float = 0.12) -> Image.Image:
"""Schneidet den Vogel aus — mit etwas Rand, sonst fehlen Schwanz und
Schnabelspitze, und genau daran hängt oft die Artbestimmung."""
w, h = img.size
bw, bh = (self.x1 - self.x0) * w, (self.y1 - self.y0) * h
x0 = max(0, int(self.x0 * w - bw * pad))
y0 = max(0, int(self.y0 * h - bh * pad))
x1 = min(w, int(self.x1 * w + bw * pad))
y1 = min(h, int(self.y1 * h + bh * pad))
if x1 <= x0 or y1 <= y0:
return img
return img.crop((x0, y0, x1, y1))
def anzeige_ausschnitt(self, img: Image.Image, pad: float = 0.35) -> Image.Image:
"""Derselbe Vogel, aber als Bild zum Ansehen statt zum Bestimmen.
🔑 **Der Klassifikator und das Auge wollen Verschiedenes.** ``crop``
liegt eng am Vogel, weil jedes Stück Hintergrund die Bestimmung
verwässert. Für die Sammlung ist genau das falsch: Dort steht der
Ausschnitt in einer fast quadratischen Kachel, und die schneidet links
und rechts weg, was über das Quadrat hinausragt.
Bei einem Vogel im Hochformat — und so sitzt fast jeder — bleibt davon
die Bauchmitte übrig, formatfüllend und unscharf. Gemeldet wurde es als
*„der Zuschnitt sieht merkwürdig aus"*, und das traf es: Vom Vogel war nur Gefieder zu
sehen, Kopf und Schwanz lagen außerhalb.
Deshalb hier mehr Rand **und** ein Ausschnitt in Richtung Quadrat: Was
die Kachel abschneidet, ist dann Umgebung statt Vogel.
⚠️ Absichtlich eine eigene Methode. ``crop`` hier zu ändern hieße, die
Artbestimmung zu ändern — und die ist gemessen und soll so bleiben.
"""
w, h = img.size
bx0, by0 = self.x0 * w, self.y0 * h
bx1, by1 = self.x1 * w, self.y1 * h
bw, bh = bx1 - bx0, by1 - by0
if bw <= 0 or bh <= 0:
return img
# Erst Rand, dann die kürzere Seite auf die längere ziehen.
seite = max(bw, bh) * (1 + 2 * pad)
mx, my = (bx0 + bx1) / 2, (by0 + by1) / 2
# ⚠️ Nicht über das Bild hinaus, und die Mitte nachführen statt den
# Ausschnitt zu stauchen — sonst sitzt ein Vogel am Bildrand
# außermittig in seiner eigenen Kachel.
seite = min(seite, float(w), float(h))
mx = min(max(mx, seite / 2), w - seite / 2)
my = min(max(my, seite / 2), h - seite / 2)
return img.crop((
int(mx - seite / 2), int(my - seite / 2),
int(mx + seite / 2), int(my + seite / 2),
))
@property
def area(self) -> float:
return max(0.0, self.x1 - self.x0) * max(0.0, self.y1 - self.y0)
class BirdFinder:
"""Findet Vögel im Bild. Sagt nichts über die Art."""
def __init__(self, model: Path = DETECTOR_MODEL, threshold: float = 0.4):
self.interp = _interpreter(model)
self.threshold = threshold
inp = self.interp.get_input_details()[0]
self.index = inp["index"]
self.size = (inp["shape"][2], inp["shape"][1])
self.out = self.interp.get_output_details()
def find(self, img: Image.Image) -> list[Box]:
arr = np.asarray(
img.convert("RGB").resize(self.size, Image.BILINEAR), dtype=np.uint8
)
self.interp.set_tensor(self.index, arr[None, ...])
self.interp.invoke()
boxes = self.interp.get_tensor(self.out[0]["index"])[0]
classes = self.interp.get_tensor(self.out[1]["index"])[0]
scores = self.interp.get_tensor(self.out[2]["index"])[0]
count = int(self.interp.get_tensor(self.out[3]["index"])[0])
found = []
for i in range(min(count, len(scores))):
if int(classes[i]) != COCO_BIRD_CLASS or scores[i] < self.threshold:
continue
y0, x0, y1, x1 = boxes[i]
found.append(
Box(float(x0), float(y0), float(x1), float(y1), float(scores[i]))
)
found.sort(key=lambda b: b.score, reverse=True)
return found
@dataclass
class Guess:
species: Species
confidence: float # nach Neunormierung auf die deutsche Artenliste
def __repr__(self) -> str: # praktisch beim Debuggen im REPL
return f"<{self.species.german} {self.confidence:.1%}>"
@dataclass
class Result:
guesses: list[Guess]
raw_top: list[tuple[str, float]] # ungefiltert, alle 965 Arten
ms: float
bird_score: float = 0.0
"""Wie viel der Gesamtwahrscheinlichkeit fällt auf heimische Arten?
Ein niedriger Wert heißt: das Modell ist sich sicher, einen Vogel zu sehen
— nur eben einen, den es hier nicht gibt. Das ist das Signal für eine
Fehlbestimmung, nicht die Confidence des Top-Treffers, denn die ist nach
der Neunormierung per Konstruktion hoch.
"""
@property
def best(self) -> Guess | None:
return self.guesses[0] if self.guesses else None
class SpeciesClassifier:
def __init__(
self,
model: Path = CLASSIFIER_MODEL,
labels: Path = CLASSIFIER_LABELS,
restrict_to_germany: bool = True,
habitats: set[str] | None = None,
):
"""``habitats`` schränkt den Katalog ein, z.B. ``{"garten"}``.
Je enger die Liste, desto weniger kann das Modell danebengreifen —
ohne Einschränkung stehen 148 Arten zur Wahl, mit Gartenfilter 47.
"""
self.interp = _interpreter(model)
inp = self.interp.get_input_details()[0]
self.index = inp["index"]
self.size = (inp["shape"][2], inp["shape"][1])
out = self.interp.get_output_details()[0]
self.out_index = out["index"]
self.scale, self.zero = out["quantization"]
self.labels = labels.read_text(encoding="utf-8").splitlines()
self.restrict = restrict_to_germany
self.setze_arten(catalog(habitats))
def setze_arten(self, arten: list[Species]) -> None:
"""Den gültigen Katalog austauschen.
Getrennt vom Konstruktor, weil die Web-App die Artenliste vorgibt und
sie sich im Betrieb ändern darf. Das Modell bleibt dabei geladen — neu
berechnet wird nur die Zuordnung der 965 Ausgaben auf den Katalog, und
das kostet Millisekunden statt Sekunden.
"""
nach_name = {a.scientific: a for a in arten}
# Welche der 965 Ausgaben entsprechen Arten aus unserem Katalog?
self.native_idx: dict[int, Species] = {}
for i, label in enumerate(self.labels):
sci = label_to_scientific(label)
if sci in nach_name:
self.native_idx[i] = nach_name[sci]
def classify(self, img: Image.Image, top_k: int = 5) -> Result:
t0 = time.perf_counter()
arr = np.asarray(
img.convert("RGB").resize(self.size, Image.BILINEAR), dtype=np.uint8
)
self.interp.set_tensor(self.index, arr[None, ...])
self.interp.invoke()
raw = self.interp.get_tensor(self.out_index)[0].astype(np.float32)
probs = (raw - self.zero) * self.scale
ms = (time.perf_counter() - t0) * 1000
order = np.argsort(probs)[::-1][:top_k]
raw_top = [(self.labels[i], float(probs[i])) for i in order]
native = [(i, float(probs[i])) for i in self.native_idx]
native.sort(key=lambda t: t[1], reverse=True)
native_mass = sum(p for _, p in native)
if not self.restrict:
guesses = [Guess(self.native_idx[i], p) for i, p in native[:top_k] if p > 0]
return Result(guesses, raw_top, ms, native_mass)
# Neunormierung: die Wahrscheinlichkeitsmasse der ~45 heimischen Arten
# auf 1.0 skalieren. Ohne das liegt jede Confidence im einstelligen
# Prozentbereich, weil sich 965 Klassen die Masse teilen.
guesses = []
if native_mass > 0:
for i, p in native[:top_k]:
if p <= 0:
continue
guesses.append(Guess(self.native_idx[i], p / native_mass))
return Result(guesses, raw_top, ms, native_mass)
class Pipeline:
"""Detektor + Klassifikator als eine Einheit."""
def __init__(
self,
detect_threshold: float = 0.4,
min_bird_score: float = 0.05,
restrict_to_germany: bool = True,
habitats: set[str] | None = None,
report_threshold: float | None = None,
):
# 0.05 ist gemessen, nicht geraten: scripts/benchmark.py über die
# 44 Referenzfotos ergibt 43/44 richtig bei 0.02–0.05, aber nur 40/44
# bei 0.10. Der Grund ist die Nordamerika-Schlagseite des Modells —
# beim Mäusebussard gewinnt global der amerikanische Buteo lineatus,
# die heimische Masse fällt auf 5 %, obwohl Buteo buteo innerhalb der
# deutschen Arten klar vorn liegt. Ein zu hoher Schwellwert wirft also
# richtige Bestimmungen weg.
self.detect_threshold = detect_threshold
# Der Detektor sucht ab der *niedrigeren* der beiden Schwellen, damit
# das Protokoll die Beinahe-Treffer ueberhaupt zu sehen bekommt. Mit
# nur einer Schwelle war `--protokoll-ab 0.08` wirkungslos: was der
# Finder bei 0.4 verwarf, kam nie beim Beobachter an — man konnte
# danach nicht unterscheiden zwischen "kein Vogel da" und "Vogel zu
# klein im Bild". Genau diese Frage sollte das Protokoll beantworten.
# Der Wunschwert wird gemerkt, weil 'uebernimm' die Schwelle spaeter
# neu mischen muss: aendert sich detect_threshold, gilt weiterhin die
# niedrigere von beiden.
self._report_wunsch = (
detect_threshold if report_threshold is None else report_threshold
)
self.report_threshold = min(detect_threshold, self._report_wunsch)
self.finder = BirdFinder(threshold=self.report_threshold)
self.classifier = SpeciesClassifier(
restrict_to_germany=restrict_to_germany, habitats=habitats
)
self.min_bird_score = min_bird_score
def zaehlt_als_vogel(self, box: Box | None) -> bool:
"""Reicht diese Box fuer eine Sichtung, oder ist sie nur Protokoll?"""
return box is not None and box.score >= self.detect_threshold
def uebernimm(
self,
detect_threshold: float | None = None,
min_bird_score: float | None = None,
arten: list[Species] | None = None,
) -> list[str]:
"""Einstellungen im laufenden Betrieb aendern.
Gibt zurueck, was sich geaendert hat — leer, wenn nichts. Gebraucht
wird das, weil die Einstellungen aus der Web-App kommen: wer dort eine
Schwelle verstellt, soll nicht den Detector neu starten muessen.
Das Modell bleibt geladen. Die Schwelle des Finders wird mitgezogen,
sonst sucht er weiter nach der alten.
"""
geaendert: list[str] = []
if detect_threshold is not None and detect_threshold != self.detect_threshold:
geaendert.append(
f"Vogelschwelle {self.detect_threshold:.2f} -> {detect_threshold:.2f}"
)
self.detect_threshold = detect_threshold
# Der Finder sucht ab der niedrigeren der beiden Schwellen; das
# Protokoll soll die Beinahe-Treffer weiter sehen.
self.report_threshold = min(self._report_wunsch, detect_threshold)
self.finder.threshold = self.report_threshold
if min_bird_score is not None and min_bird_score != self.min_bird_score:
geaendert.append(
f"Artenschwelle {self.min_bird_score:.2f} -> {min_bird_score:.2f}"
)
self.min_bird_score = min_bird_score
if arten is not None:
alt = {a.scientific for a in self.classifier.native_idx.values()}
neu = {a.scientific for a in arten}
if alt != neu:
self.classifier.setze_arten(arten)
erkannt = len(self.classifier.native_idx)
geaendert.append(
f"Katalog {len(alt)} -> {len(neu)} Arten "
f"({erkannt} davon kennt das Modell)"
)
return geaendert
def run(self, img: Image.Image) -> tuple[Box | None, Result | None]:
"""Beste Box im Bild, ggf. mit Artbestimmung.
Die Box kann unter ``detect_threshold`` liegen — dann ist sie fuers
Protokoll gedacht und nicht fuer eine Sichtung; ``result`` bleibt in
dem Fall ``None``, weil sich die Klassifikation fuer einen
Beinahe-Treffer nicht lohnt. ``zaehlt_als_vogel`` entscheidet.
"""
boxes = self.finder.find(img)
if not boxes:
return None, None
box = boxes[0]
if not self.zaehlt_als_vogel(box):
return box, None
result = self.classifier.classify(box.crop(img))
if result.bird_score < self.min_bird_score:
# Vogel ja, heimische Art nein — lieber gar keine Bestimmung als
# eine falsche im Vogelalbum.
return box, None
return box, result
+92
View File
@@ -0,0 +1,92 @@
"""Abschaltung bei Dunkelheit.
Eine Kamera ohne Infrarot liefert nachts ein schwarzes Bild. Es durch zwei
neuronale Netze zu schicken kostet Strom und bringt nichts.
Entschieden wird über die **tatsächliche Bildhelligkeit**, nicht über
Sonnenauf- und -untergang. Zwei Gründe: Es braucht keine Koordinaten und
keine Zeitzonenrechnerei, und es misst genau das, worauf es ankommt — ob man
etwas sieht. Ein Futterhaus unter einer Straßenlaterne bleibt nutzbar, ein
zugewachsenes ist schon vor Sonnenuntergang zu dunkel. Der Kalender weiß das
nicht, das Bild schon.
Angenehmer Nebeneffekt: Eine Kamera mit Infrarot liefert nachts ein graues,
aber helles Bild. Sie läuft damit von selbst weiter, ohne dass jemand etwas
einstellen muss. Der ausdrückliche Schalter (``enabled=False``) bleibt
trotzdem, falls die Automatik danebenliegt.
Die Voreinstellungen sind an der Kamera am Vogelhaus gemessen, nicht geraten:
tagsüber 111–131, nachts 1,7 von 255.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
def brightness(img: Image.Image, width: int = 96) -> float:
"""Mittlere Helligkeit 0–255. Auf einem winzigen Graubild, das kostet nichts."""
h = max(1, round(img.height * width / img.width))
small = img.convert("L").resize((width, h), Image.BILINEAR)
return float(np.asarray(small, dtype=np.float32).mean())
class DaylightGate:
"""Entscheidet, ob es hell genug zum Analysieren ist.
Mit Hysterese: Der Wert zum Abschalten liegt deutlich unter dem zum
Wiedereinschalten. Ohne das flattert die Entscheidung in der Dämmerung im
Sekundentakt hin und her, und jede vorbeifahrende Autoscheinwerfer-Reflexion
startet einen neuen Besuch.
"""
def __init__(
self,
dark_below: float = 15.0,
light_above: float = 30.0,
patience: int = 20,
enabled: bool = True,
):
if light_above <= dark_below:
raise ValueError(
"light_above muss über dark_below liegen, sonst gibt es keine "
"Hysterese und die Entscheidung flattert."
)
self.dark_below = dark_below
self.light_above = light_above
self.patience = patience
self.enabled = enabled
self.is_dark = False
self.last_brightness = 255.0
self._streak = 0
def update(self, img: Image.Image) -> bool:
"""Bild einspeisen. Gibt zurück, ob analysiert werden soll."""
if not self.enabled:
return True
self.last_brightness = brightness(img)
# Zählt nur, was für einen Wechsel spricht. Ein einzelnes helles oder
# dunkles Bild setzt die Strähne zurück.
will_wechseln = (
self.last_brightness >= self.light_above
if self.is_dark
else self.last_brightness < self.dark_below
)
self._streak = self._streak + 1 if will_wechseln else 0
if self._streak >= self.patience:
self.is_dark = not self.is_dark
self._streak = 0
return not self.is_dark
@property
def status(self) -> str:
if not self.enabled:
return "Helligkeitsabschaltung aus (Nachtsicht)"
zustand = "dunkel — pausiert" if self.is_dark else "hell genug"
return f"{zustand}, Helligkeit {self.last_brightness:.0f}/255"
+122
View File
@@ -0,0 +1,122 @@
"""Einstellungen aus der Web-App holen.
Bis hierher stand die Konfiguration des Detectors auf seiner Kommandozeile —
Stream-Adresse, Schwellen, Lebensräume. Wer die Kamera umstellen wollte,
brauchte eine Shell auf dem Rechner, auf dem der Detector läuft. Für ein
Werkzeug, das andere Leute benutzen sollen, ist das die falsche Stelle.
Jetzt fragt der Detector die Web-App: ``GET /api/config/`` mit seinem Token,
und bekommt zurück, was für diese Kamera gilt — samt Artenliste, denn welche
Arten überhaupt zählen, ist eine inhaltliche Entscheidung und gehört in den
Admin, nicht in eine Python-Datei.
Zwei Eigenschaften sind wichtiger als die Funktion selbst:
* **Die Kommandozeile bleibt gültig.** Was dort steht, gilt weiter, solange
die Web-App nichts anderes sagt. Ein Detector muss auch ohne erreichbare
Web-App starten können.
* **Ein Ausfall ändert nichts.** Antwortet die Web-App nicht, läuft die
Erkennung mit dem letzten bekannten Stand weiter, statt stehenzubleiben
oder auf Vorgaben zurückzufallen.
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from .species_de import Species
def arten_aus_json(rohe: list[dict]) -> list[Species]:
"""Die Artenliste der Web-App in Katalogeinträge übersetzen.
Der wissenschaftliche Name ist der Schlüssel ins Label-File des Modells;
alles andere ist Beschriftung. Einträge ohne wissenschaftlichen Namen sind
wertlos und fliegen raus, statt später leise nichts zu treffen.
"""
arten: list[Species] = []
for a in rohe:
sci = (a.get("scientific") or "").strip()
if not sci:
continue
arten.append(
Species(
scientific=sci,
german=a.get("german") or sci,
english=a.get("english") or "",
rarity=a.get("rarity") or "haeufig",
fact="",
size_cm="",
lebensraum=a.get("lebensraum") or "garten",
)
)
return arten
class Fernkonfig:
"""Holt die Konfiguration und sagt, wann es sich lohnt, wieder zu fragen."""
def __init__(self, api: str, token: str, intervall: float = 300.0, timeout: int = 10):
self.url = api.rstrip("/") + "/api/config/"
self.token = token
self.intervall = intervall
self.timeout = timeout
self.letzte: dict | None = None
self._letzter_versuch: float | None = None
self._fehler_gemeldet = False
# -- Abruf ------------------------------------------------------------
def faellig(self, jetzt: float) -> bool:
return (
self._letzter_versuch is None
or jetzt - self._letzter_versuch >= self.intervall
)
def hole(self, jetzt: float | None = None) -> dict | None:
"""Einmal fragen. ``None`` heißt: nicht erreichbar oder unbrauchbar.
Der Aufrufer behandelt ``None`` als "nichts ändert sich" — nicht als
Fehler, der den Betrieb anhält.
"""
self._letzter_versuch = time.monotonic() if jetzt is None else jetzt
req = urllib.request.Request(
self.url,
headers={
"Authorization": f"Bearer {self.token}",
"User-Agent": "vogelalbum-detector/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
daten = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
# 401 ist kein Netzproblem, sondern ein falscher Token — das soll
# man sehen, aber es darf die Erkennung nicht anhalten.
self._melde(f"Konfiguration abgelehnt (HTTP {exc.code})")
return None
except (OSError, ValueError) as exc:
self._melde(f"Konfiguration nicht erreichbar ({exc})")
return None
if not isinstance(daten, dict):
self._melde("Konfiguration unlesbar (kein Objekt)")
return None
if self._fehler_gemeldet:
print("[konfig] Web-App wieder erreichbar")
self._fehler_gemeldet = False
self.letzte = daten
return daten
def _melde(self, text: str) -> None:
"""Einmal warnen, nicht in jeder Runde — sonst ersäuft das Log."""
if not self._fehler_gemeldet:
print(f"[konfig] {text} — weiter mit dem bisherigen Stand")
self._fehler_gemeldet = True
+164
View File
@@ -0,0 +1,164 @@
"""Erkennungskarte: wo im Bild wird ein Vogel gefunden — und wo nicht.
Eine Kamera am Futterhaus richtet man sonst blind aus und wartet dann Tage
darauf, ob etwas ankommt. Bleibt es leer, weiss man immer noch nicht, ob keine
Voegel kamen oder ob sie an der falschen Stelle im Bild sassen.
Diese Karte beantwortet das vorher. Ein echtes Vogelfoto wird mit weicher
Maske ueber ein Raster von Positionen in aktuelle Kamerabilder montiert und
jedes Mal durch den Detektor geschickt. Heraus kommt eine Ampel ueber dem
eigenen Kamerabild: gruen erkannt, rot nicht.
Am Vogelhaus in Herzogenrath hat sie gezeigt, dass der unterste Bildrand eine
tote Zone ist — genau dort, wo ein Vogel auf dem Futterbrett steht. Derselbe
Vogel zehn Prozent hoeher im Bild wurde zuverlaessig erkannt. Das war ohne
diese Karte drei Wochen lang nicht zu sehen.
Die Grenze ist kein Naturgesetz, sondern haengt am Hintergrund: vor ruhigem
Grund reichten 5 % Bildflaeche, vor einem Baum im Wind erst 8 %. Deshalb
misst man sie an der eigenen Kamera nach, statt eine Zahl zu uebernehmen.
"""
from __future__ import annotations
from dataclasses import dataclass
from PIL import Image, ImageDraw, ImageFilter
# Voreinstellung des Rasters. Die Spalten lassen die aeussersten Prozent aus —
# ein Vogel, der halb aus dem Bild haengt, ist kein sinnvoller Messpunkt.
SPALTEN = (0.18, 0.32, 0.46, 0.60, 0.74, 0.88)
ZEILEN = (0.55, 0.65, 0.75, 0.85, 0.95)
GRUEN = (60, 200, 90)
ROT = (220, 60, 60)
def montiere(
hintergrund: Image.Image,
vogel: Image.Image,
anteil: float,
mitte_x: float = 0.5,
fuss_y: float = 0.92,
) -> Image.Image:
"""Vogelfoto auf ``anteil`` der Bildflaeche skalieren und einsetzen.
``mitte_x`` und ``fuss_y`` sind relative Koordinaten: der Vogel steht mit
den Fuessen auf ``fuss_y``, so wie er auf einem Brett staende.
"""
W, H = hintergrund.size
vw, vh = vogel.size
faktor = (anteil * W * H / (vw * vh)) ** 0.5
nw, nh = max(8, round(vw * faktor)), max(8, round(vh * faktor))
v = vogel.resize((nw, nh), Image.LANCZOS)
# Weiche Ellipse statt harter Rechteckkante: die Kante gaebe es in
# Wirklichkeit nicht, und der Detektor soll den Vogel finden, nicht den
# Klebefleck.
maske = Image.new("L", (nw, nh), 0)
ImageDraw.Draw(maske).ellipse((1, 1, nw - 2, nh - 2), fill=255)
maske = maske.filter(ImageFilter.GaussianBlur(max(1, nw // 20)))
out = hintergrund.copy()
out.paste(v, (round(mitte_x * W - nw / 2), round(fuss_y * H - nh)), maske)
return out
@dataclass
class Zelle:
mitte_x: float
fuss_y: float
treffer: int
gesamt: int
bester_score: float
@property
def erkannt(self) -> bool:
"""Nur einstimmig. Eine Position, die bei einem von drei Hintergruenden
durchfaellt, taugt nicht als Landeplatz."""
return self.gesamt > 0 and self.treffer == self.gesamt
def vermessen(
pipeline,
hintergruende: list[Image.Image],
vogel: Image.Image,
anteil: float = 0.08,
spalten: tuple[float, ...] = SPALTEN,
zeilen: tuple[float, ...] = ZEILEN,
) -> list[Zelle]:
"""Das Raster abfahren. Mehrere Hintergruende, damit ein zufaellig
guenstiger Ast das Ergebnis nicht traegt."""
zellen = []
for fuss in zeilen:
for mx in spalten:
treffer = 0
best = 0.0
for hg in hintergruende:
box, _ = pipeline.run(montiere(hg, vogel, anteil, mx, fuss))
if box is not None:
best = max(best, box.score)
if pipeline.zaehlt_als_vogel(box):
treffer += 1
zellen.append(Zelle(mx, fuss, treffer, len(hintergruende), best))
return zellen
def zeichne(hintergrund: Image.Image, zellen: list[Zelle]) -> Image.Image:
"""Ampel ueber das Kamerabild legen — halbtransparent, damit man sieht,
worueber man urteilt."""
W, H = hintergrund.size
lage = Image.new("RGBA", (W, H), (0, 0, 0, 0))
mal = ImageDraw.Draw(lage)
r = max(12, round(min(W, H) * 0.047))
for z in zellen:
farbe = GRUEN if z.erkannt else ROT
cx, cy = z.mitte_x * W, z.fuss_y * H
mal.ellipse((cx - r, cy - r, cx + r, cy + r),
fill=farbe + (90,), outline=farbe + (220,), width=3)
return Image.alpha_composite(hintergrund.convert("RGBA"), lage).convert("RGB")
def tabelle(zellen: list[Zelle]) -> str:
"""Dieselbe Karte als Text — im Terminal oft schneller zu lesen als das
Bild, und sie laesst sich in ein Issue kleben."""
zeilen_y = sorted({z.fuss_y for z in zellen})
spalten_x = sorted({z.mitte_x for z in zellen})
kopf = "Fuss y " + "".join(f" x={x:<5.2f}" for x in spalten_x)
zeilen = [kopf]
for y in zeilen_y:
teile = [f"{y:>6.2f} "]
for x in spalten_x:
z = next(c for c in zellen if c.fuss_y == y and c.mitte_x == x)
teile.append(f" {'OK' if z.erkannt else '--'} {z.bester_score:.2f}")
zeilen.append("".join(teile))
return "\n".join(zeilen)
def rat(zellen: list[Zelle]) -> str:
"""Aus der Karte einen Satz machen. Wer die Kamera ausrichtet, steht
draussen auf der Leiter und will keine Tabelle deuten."""
if not zellen:
return "Keine Messpunkte."
unten = [z for z in zellen if z.fuss_y >= 0.9]
oben = [z for z in zellen if z.fuss_y < 0.9]
quote = sum(z.erkannt for z in zellen) / len(zellen)
if quote == 0:
return ("Nirgends erkannt. Der Vogel ist bei dieser Groesse zu klein "
"fuers Modell — Kamera naeher ans Futter, oder mit einem "
"groesseren --anteil pruefen, ab wann es kippt.")
if unten and oben:
q_unten = sum(z.erkannt for z in unten) / len(unten)
q_oben = sum(z.erkannt for z in oben) / len(oben)
if q_oben - q_unten > 0.4:
return ("Der untere Bildrand ist eine tote Zone, weiter oben wird "
"sauber erkannt. Steht das Futter unten am Rand, kippt die "
"Kamera zu flach: nach unten neigen, bis die Landeflaeche "
"in der unteren Bildmitte liegt.")
if quote < 0.5:
return ("Mehr als die Haelfte der Positionen faellt durch. Naeher ans "
"Futter heran — mehr Aufloesung hilft hier weniger als "
"weniger Abstand.")
return ("Die Ausrichtung taugt: der Vogel wird fast ueberall im Bild "
"gefunden. Bleibt die Frage, ob er in echt so gross im Bild ist.")
+86
View File
@@ -0,0 +1,86 @@
"""Bewegungserkennung und Bildschärfe — beides in numpy, ohne OpenCV.
Warum überhaupt Bewegungserkennung, wenn wir doch einen Vogeldetektor haben?
Weil der Detektor 25 ms kostet und die Kamera 24/7 läuft. Frame-Differencing
kostet unter 1 ms und filtert die 99 % leeres Vogelhaus weg.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
def _gray_small(img: Image.Image, width: int = 96) -> np.ndarray:
h = max(1, round(img.height * width / img.width))
small = img.convert("L").resize((width, h), Image.BILINEAR)
return np.asarray(small, dtype=np.float32)
class MotionDetector:
"""Laufender Hintergrund als exponentieller Mittelwert.
Ein fixes Referenzbild funktioniert draußen nicht: Sonne wandert, Wolken
ziehen, abends wird es dunkel. Der gleitende Hintergrund zieht langsam
nach, ein Vogel ist dafür aber zu schnell.
"""
def __init__(
self,
threshold: int = 22,
min_ratio: float = 0.012,
adapt: float = 0.06,
width: int = 96,
):
self.threshold = threshold
self.min_ratio = min_ratio
self.adapt = adapt
self.width = width
self.background: np.ndarray | None = None
def update(self, img: Image.Image) -> float:
"""Gibt den Anteil bewegter Pixel zurück (0..1)."""
cur = _gray_small(img, self.width)
if self.background is None or self.background.shape != cur.shape:
self.background = cur
return 0.0
diff = np.abs(cur - self.background)
ratio = float(np.count_nonzero(diff > self.threshold) / diff.size)
# Bei Bewegung langsamer nachziehen, sonst frisst sich ein sitzender
# Vogel binnen Sekunden in den Hintergrund ein und gilt als "weg".
alpha = self.adapt * (0.15 if ratio > self.min_ratio else 1.0)
self.background = (1 - alpha) * self.background + alpha * cur
return ratio
def is_motion(self, ratio: float) -> bool:
return ratio >= self.min_ratio
def reset(self) -> None:
self.background = None
# 3x3-Laplace-Kern. Die Varianz der Antwort ist das Standardmaß für Schärfe:
# ein verwackeltes Bild hat kaum hohe Frequenzen und damit kaum Varianz.
_LAPLACE = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
def sharpness(img: Image.Image, width: int = 320) -> float:
"""Schärfemaß zur Auswahl des besten Fotos einer Sichtung.
Nicht kalibriert und nur innerhalb einer Sichtung vergleichbar — genau
dafür brauchen wir es.
"""
a = _gray_small(img, width)
if a.shape[0] < 3 or a.shape[1] < 3:
return 0.0
# Direkte 2D-Faltung über Slicing; für einen 3x3-Kern schneller und
# abhängigkeitsfrei gegenüber scipy.
out = np.zeros((a.shape[0] - 2, a.shape[1] - 2), dtype=np.float32)
for dy in range(3):
for dx in range(3):
k = _LAPLACE[dy, dx]
if k:
out += k * a[dy : dy + out.shape[0], dx : dx + out.shape[1]]
return float(out.var())
+38
View File
@@ -0,0 +1,38 @@
"""Steckbriefe für die erweiterten Arten, nach Gruppen aufgeteilt.
Eine einzige Datei mit 104 Einträgen wäre unwartbar. Die Aufteilung folgt
nicht der Systematik, sondern der Frage, wie wahrscheinlich eine Art vor der
Kamera landet:
* ``gaeste`` — Gartenvögel und alles, was über den Garten fliegt
* ``land`` — Singvögel, Greifvögel, Eulen und Exoten des Landes
* ``wasser`` — Wat-, Wasser- und Meeresvögel
Doppelte Schlüssel sind ein Fehler und werden beim Import gemeldet, nicht
stillschweigend überschrieben — sonst gewinnt zufällig die zuletzt geladene
Datei.
"""
from __future__ import annotations
from .basis import Zusatz
from . import gaeste, land, wasser
_MODULE = (gaeste, land, wasser)
INFO_EXTRA: dict[str, Zusatz] = {}
_doppelt: dict[str, list[str]] = {}
for _modul in _MODULE:
for _slug, _eintrag in _modul.PROFILE.items():
if _slug in INFO_EXTRA:
_doppelt.setdefault(_slug, []).append(_modul.__name__)
INFO_EXTRA[_slug] = _eintrag
if _doppelt:
raise ImportError(
"Steckbriefe doppelt vergeben: "
+ ", ".join(f"{k} ({', '.join(v)})" for k, v in _doppelt.items())
)
__all__ = ["INFO_EXTRA", "Zusatz"]
+25
View File
@@ -0,0 +1,25 @@
"""Gemeinsame Struktur der Steckbriefe.
Deutsch und Englisch stehen in einem Datensatz, weil sie zusammen geschrieben
werden — nicht übersetzt. Eine wörtliche Übertragung liest sich in beiden
Richtungen schlecht: „Streuobstwiese" hat kein Gegenstück, und „Stormcock"
ist im Deutschen kein Begriff.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Zusatz:
kategorie: str
nahrung: str
lebensraum: str
stimme: str
text: str
kategorie_en: str
nahrung_en: str
lebensraum_en: str
stimme_en: str
text_en: str
+906
View File
@@ -0,0 +1,906 @@
"""Steckbriefe: Gäste, die realistisch ins Bild geraten.
Gartenvögel aus der Erweiterung und alles, was über den Garten **fliegt** —
ein Rotmilan landet nie am Futterhaus, steht aber sehr wohl irgendwann im
Bild. Dasselbe gilt für Kranichzug, Graureiher, Kormoran, Weißstorch.
"""
from __future__ import annotations
from .basis import Zusatz
# fmt: off
PROFILE: dict[str, Zusatz] = {
"bergfink": Zusatz(
kategorie="Der Wintergast aus dem Norden",
nahrung="Bucheckern, Sämereien, im Sommer Insekten",
lebensraum="Buchenwälder und Futterhäuser — bei uns nur im Winter",
stimme="Ein nasales, gezogenes „chääh“, klagender als der Buchfink",
text=(
"Wie ein Buchfink, aber wärmer: orangebraune Brust und Schulter, weißer "
"Bürzel, der im Auffliegen aufblitzt. Im Winter ist der Kopf des Männchens "
"grau gescheckt, im Frühjahr wird er tiefschwarz."
"\n\n"
"Er brütet in Skandinavien und kommt nur zum Überwintern. Wie viele kommen, "
"hängt an der Bucheckernernte: In guten Mastjahren sammeln sich in "
"Mitteleuropa Schlafgesellschaften von Millionen Vögeln an einem einzigen "
"Ort — die größten Vogelansammlungen, die es in Europa gibt."
),
kategorie_en="The Winter Guest from the North",
nahrung_en="Beech mast and seeds, insects in summer",
lebensraum_en="Beech woodland and feeders — a winter bird here",
stimme_en="A nasal, drawn-out “tsweek”, more plaintive than a Chaffinch",
text_en=(
"Like a Chaffinch but warmer: an orange breast and shoulder, and a white "
"rump that flashes as it takes off. In winter the male's head is mottled "
"grey; by spring it turns jet black."
"\n\n"
"It breeds in Scandinavia and only comes here to winter. How many arrive "
"depends on the beech mast crop: in a good year, roosts of several million "
"birds gather at a single site in central Europe — the largest "
"concentrations of any bird in Europe."
),
),
"seidenschwanz": Zusatz(
kategorie="Der Beerenräuber mit Haube",
nahrung="Beeren, vor allem Eberesche und Weißdorn",
lebensraum="Beerensträucher in Siedlungen — unregelmäßiger Wintergast",
stimme="Ein feines, klingelndes Trillern wie eine winzige Glocke",
text=(
"Ein rosabeiger Vogel mit aufstellbarer Haube, schwarzer Kehle und roten "
"Siegellackpunkten auf den Flügeln. Wer ihn einmal gesehen hat, verwechselt "
"ihn nie wieder."
"\n\n"
"Er brütet in der Taiga und erscheint bei uns nur in manchen Wintern, dann "
"aber in Trupps, die einen Ebereschenbaum in Stunden abernten. Ein "
"einzelner Vogel frisst am Tag ein Vielfaches seines Körpergewichts an "
"Beeren. Weil vergorene Früchte Alkohol enthalten, gibt es tatsächlich "
"beschwipste Seidenschwänze — mancherorts stellt man Auffangkörbe unter "
"die Bäume."
),
kategorie_en="The Berry Raider with a Crest",
nahrung_en="Berries, above all rowan and hawthorn",
lebensraum_en="Berry bushes in settlements — an irregular winter visitor",
stimme_en="A fine ringing trill, like a tiny bell",
text_en=(
"A pinkish-buff bird with a raisable crest, a black throat and red "
"sealing-wax spots on the wing. Seen once, never mistaken again."
"\n\n"
"It breeds in the taiga and appears here only in some winters — but then in "
"flocks that strip a rowan tree within hours. A single bird eats several "
"times its own body weight in berries per day. Because fermented fruit "
"contains alcohol, tipsy Waxwings are a genuine phenomenon; in some places "
"people put out baskets to catch the fallers."
),
),
"strassentaube": Zusatz(
kategorie="Die verwilderte Haustaube",
nahrung="Sämereien, Brot, alles Essbare",
lebensraum="Städte, Bahnhöfe, Plätze — überall, wo Menschen essen",
stimme="Das bekannte gurrende „ruh-kuh-ruh“",
text=(
"Kein wilder Vogel, sondern die verwilderte Nachfahrin der Haustaube, die "
"wiederum von der Felsentaube abstammt. Deshalb ist keine wie die andere: "
"grau, schwarz, weiß gescheckt, rotbraun — alles kommt vor."
"\n\n"
"Häuserschluchten sind für sie das, wofür ihre Vorfahren gebaut sind: "
"Felswände. Sie findet über das Magnetfeld der Erde nach Hause, und wer "
"Brieftauben kennt, weiß wie gut das funktioniert. Am Futterhaus ist sie "
"eher unbeliebt, weil sie in Gruppen kommt und alles wegräumt."
),
kategorie_en="The Feral Pigeon",
nahrung_en="Seeds, bread, anything edible",
lebensraum_en="Cities, stations and squares — wherever people eat",
stimme_en="The familiar cooing “oo-roo-coo”",
text_en=(
"Not a wild bird but the feral descendant of the domestic pigeon, itself "
"descended from the Rock Dove. That is why no two look alike: grey, black, "
"pied, chestnut — anything goes."
"\n\n"
"Streets lined with buildings are exactly what its ancestors were built "
"for: cliff faces. It navigates home using the Earth's magnetic field, and "
"anyone who knows racing pigeons knows how well that works. At a feeder it "
"is rarely welcome, since it arrives in groups and clears the table."
),
),
"kernbeisser": Zusatz(
kategorie="Der Nussknacker",
nahrung="Kirschkerne, Bucheckern, harte Samen",
lebensraum="Laubwälder und Parks mit alten Bäumen",
stimme="Ein hartes, metallisches „zicks“, oft aus der Baumkrone",
text=(
"Ein wuchtiger Fink mit übergroßem, silbrigem Schnabel und Stiernacken. "
"Kastanienbraun mit grauem Nacken und schwarzem Kinnlatz."
"\n\n"
"Sein Schnabel bringt über fünfzig Kilogramm Druck auf — genug, um "
"Kirschkerne und Pflaumensteine zu knacken, an denen jeder andere heimische "
"Vogel scheitert. Trotz seiner Größe ist er scheu und bleibt gern in der "
"Krone; man hört ihn viel öfter, als man ihn sieht."
),
kategorie_en="The Nutcracker",
nahrung_en="Cherry stones, beech mast, hard seeds",
lebensraum_en="Broadleaf woodland and parks with mature trees",
stimme_en="A hard metallic “tzick”, usually from high in a tree",
text_en=(
"A bull-necked finch with an outsized silvery bill. Chestnut-brown with a "
"grey nape and a black bib."
"\n\n"
"That bill exerts over fifty kilograms of force — enough to crack cherry "
"and plum stones that defeat every other bird here. For all its bulk it is "
"shy and stays in the canopy; you hear it far more often than you see it."
),
),
"bluthaenfling": Zusatz(
kategorie="Der Rotbrüstige vom Feldrand",
nahrung="Kleine Samen von Kräutern, besonders Kreuzblütler",
lebensraum="Hecken, Brachen, Feldränder",
stimme="Ein zwitscherndes Geplauder, weich und schwatzhaft",
text=(
"Das Männchen trägt im Frühjahr eine karminrote Stirn und Brust, den Rest "
"des Jahres ist es unauffällig braun gestrichelt. Weibchen bleiben immer "
"schlicht."
"\n\n"
"Er war einmal ein häufiger Feldvogel und ist heute auf der Roten Liste. "
"Der Grund ist banal und bitter: Er lebt fast ausschließlich von Wildkräuter"
"samen, und Felder ohne Wildkräuter sind für ihn leer. Wer eine ungemähte "
"Ecke im Garten stehen lässt, tut mehr für ihn als jedes Futterhaus."
),
kategorie_en="The Red-breasted One from the Field Edge",
nahrung_en="Small seeds of wild herbs, especially crucifers",
lebensraum_en="Hedges, fallow ground, field margins",
stimme_en="A soft, chattering twitter",
text_en=(
"In spring the male wears a crimson forehead and breast; the rest of the "
"year it is plain streaky brown. Females stay plain throughout."
"\n\n"
"It was once a common farmland bird and is now red-listed. The reason is "
"mundane and bitter: it lives almost entirely on the seeds of wild herbs, "
"and a field without weeds is an empty field to it. Leaving one unmown "
"corner of a garden does more for it than any feeder."
),
),
"wintergoldhaehnchen": Zusatz(
kategorie="Der kleinste Vogel Europas",
nahrung="Winzige Insekten und Spinnen aus Nadelzweigen",
lebensraum="Fichten und Tannen, im Winter auch Gärten",
stimme="Ein extrem hohes „sisisi“ — viele Erwachsene hören es nicht mehr",
text=(
"Fünfeinhalb Gramm, kleiner als ein Zaunkönig, mit einem gelben Scheitel"
"streifen und großen dunklen Augen in einem hellen Gesicht. Der kleinste "
"Vogel Europas."
"\n\n"
"Sein Ruf liegt so hoch, dass viele Menschen ihn ab dem mittleren "
"Lebensalter nicht mehr wahrnehmen — Vogelkundler merken daran, dass ihr "
"Gehör nachlässt. Im Winter muss ein Goldhähnchen praktisch ununterbrochen "
"fressen; eine einzige zu kalte Nacht ohne volle Reserven überlebt es nicht."
),
kategorie_en="Europe's Smallest Bird",
nahrung_en="Tiny insects and spiders from conifer needles",
lebensraum_en="Spruce and fir, gardens in winter",
stimme_en="An extremely high “see-see-see” — many adults can no longer hear it",
text_en=(
"Five and a half grams, smaller than a Wren, with a yellow crown stripe and "
"large dark eyes in a pale face. The smallest bird in Europe."
"\n\n"
"Its call sits so high that many people stop hearing it in middle age — "
"birders use it to notice their own hearing fading. In winter a Goldcrest "
"must feed almost without pause; one cold night on empty reserves is enough "
"to kill it."
),
),
"sommergoldhaehnchen": Zusatz(
kategorie="Der mit dem Augenstreif",
nahrung="Kleinstinsekten, bevorzugt aus Laub- und Mischwald",
lebensraum="Misch- und Nadelwald, Parks mit Eiben",
stimme="Ein hohes Zirpen, etwas tiefer und rhythmischer als beim Wintergoldhähnchen",
text=(
"Fast identisch mit dem Wintergoldhähnchen und genauso winzig. Der "
"Unterschied steht im Gesicht: ein deutlicher weißer Überaugenstreif und "
"ein schwarzer Strich durchs Auge. Das Wintergoldhähnchen hat ein "
"ausdrucksloses helles Gesicht."
"\n\n"
"Es ist die südlichere der beiden Arten und breitet sich mit den wärmeren "
"Wintern nach Norden aus. Beide Arten leben oft im selben Baum, nutzen aber "
"unterschiedliche Zweigbereiche — so kommen sie sich beim Futter nicht in "
"die Quere."
),
kategorie_en="The One With the Eyebrow",
nahrung_en="Tiny insects, mostly in mixed woodland",
lebensraum_en="Mixed and conifer woodland, parks with yew",
stimme_en="A high chirping, slightly lower and more rhythmic than a Goldcrest",
text_en=(
"Almost identical to the Goldcrest and just as tiny. The difference is in "
"the face: a clear white eyebrow and a black line through the eye. The "
"Goldcrest has a blank pale face."
"\n\n"
"It is the more southerly of the two and is spreading north as winters "
"warm. Both species often share a tree but work different parts of the "
"branches, which keeps them out of each other's way."
),
),
"misteldrossel": Zusatz(
kategorie="Die Sturmdrossel",
nahrung="Beeren, besonders Misteln, dazu Würmer und Insekten",
lebensraum="Lichte Wälder, Parks, Streuobst",
stimme="Ein hartes, schnarrendes Rätschen, wie eine Ratsche",
text=(
"Die größte heimische Drossel, deutlich größer als eine Singdrossel, mit "
"grauerem Ton, runden statt pfeilförmigen Flecken und weißen Schwanzecken "
"im Flug."
"\n\n"
"Sie singt bei Sturm und Regen von der höchsten Baumspitze, wenn alles "
"andere schweigt — im Englischen heißt sie deshalb Stormcock. Im Winter "
"verteidigt ein einzelner Vogel einen ganzen Mistel- oder Ebereschenbaum "
"gegen alle Rivalen und lebt wochenlang von diesem einen Vorrat."
),
kategorie_en="The Stormcock",
nahrung_en="Berries, mistletoe above all, plus worms and insects",
lebensraum_en="Open woodland, parks, old orchards",
stimme_en="A hard, rasping rattle, like a football rattle",
text_en=(
"The largest thrush here, clearly bigger than a Song Thrush, greyer in "
"tone, with round rather than arrowhead spots and white tail corners in "
"flight."
"\n\n"
"It sings from the highest treetop in wind and rain when everything else "
"has fallen silent — hence the old English name Stormcock. In winter a "
"single bird will defend an entire mistletoe or rowan tree against all "
"comers and live off that one larder for weeks."
),
),
"dorngrasmuecke": Zusatz(
kategorie="Die aus der Brombeerhecke",
nahrung="Insekten, im Spätsommer Beeren",
lebensraum="Dornhecken, Brombeergestrüpp, verbuschte Brachen",
stimme="Ein hastiges, kratziges Geplapper, oft im steilen Singflug",
text=(
"Grauer Kopf, rostbraune Flügel, weiße Kehle, die bei Erregung aufgeplustert "
"wird. Sie sitzt selten offen, sondern schimpft aus dem Dickicht."
"\n\n"
"Ihr Bestand brach in den 1970ern europaweit ein — die Ursache lag nicht "
"hier, sondern in der Sahelzone, wo eine mehrjährige Dürre die "
"Winterquartiere austrocknete. Ein Lehrstück darüber, dass Vogelschutz an "
"der Landesgrenze nicht endet."
),
kategorie_en="The One in the Bramble",
nahrung_en="Insects, berries in late summer",
lebensraum_en="Thorn hedges, bramble thickets, scrubby fallow ground",
stimme_en="A hurried, scratchy chatter, often in a bouncing song flight",
text_en=(
"A grey head, rusty wings and a white throat that puffs out when it is "
"agitated. It rarely sits in the open, preferring to scold from cover."
"\n\n"
"Its numbers crashed across Europe in the 1970s — the cause was not here "
"but in the Sahel, where a multi-year drought dried out its wintering "
"grounds. A lesson in how bird conservation does not stop at a border."
),
),
"nebelkraehe": Zusatz(
kategorie="Die Krähe mit der Weste",
nahrung="Allesfresser wie die Rabenkrähe",
lebensraum="Offene Landschaft und Siedlungen östlich der Elbe",
stimme="Wie die Rabenkrähe, ein raues „krah“",
text=(
"Dieselbe Art wie die Rabenkrähe in allem außer der Farbe: grauer Rumpf, "
"schwarzer Kopf, schwarze Flügel. Wie eine Krähe mit Weste."
"\n\n"
"Die beiden Formen treffen sich in einem schmalen Streifen entlang der "
"Elbe, wo sie sich mischen und fruchtbare Nachkommen bekommen. Warum die "
"Zone trotzdem seit Jahrzehnten schmal bleibt, statt sich zu verwischen, "
"ist eine der schöneren offenen Fragen der Evolutionsbiologie."
),
kategorie_en="The Crow in a Waistcoat",
nahrung_en="An omnivore, like the Carrion Crow",
lebensraum_en="Open country and settlements east of the Elbe",
stimme_en="Like a Carrion Crow, a harsh “caw”",
text_en=(
"The same species as the Carrion Crow in everything but colour: a grey "
"body with a black head and wings. A crow in a waistcoat."
"\n\n"
"The two forms meet in a narrow band along the river Elbe, where they "
"interbreed and produce fertile young. Why that zone has nonetheless "
"stayed narrow for decades instead of blurring away is one of the more "
"enjoyable open questions in evolutionary biology."
),
),
"saatkraehe": Zusatz(
kategorie="Die Gesellige",
nahrung="Regenwürmer, Larven, Getreide",
lebensraum="Ackerland und Stadtparks — brütet in lauten Kolonien",
stimme="Ein höheres, gequetschteres „kaah“ als bei der Rabenkrähe",
text=(
"Schwarz mit violettem Glanz und einem hellen, unbefiederten Schnabelgrund "
"— das ist das sichere Kennzeichen gegenüber der Rabenkrähe. Jungvögel "
"haben es noch nicht."
"\n\n"
"Anders als die Rabenkrähe brütet sie in Kolonien von oft hunderten "
"Nestern, was ihr in Städten wenig Freunde macht. Sie war lange verfolgt "
"und ist seit den 1980ern wieder häufiger. Ihr Schnabel ist ein "
"Bodenwerkzeug: Sie stochert damit tief nach Larven, wofür der befiederte "
"Ansatz nur im Weg wäre."
),
kategorie_en="The Sociable One",
nahrung_en="Earthworms, grubs, grain",
lebensraum_en="Farmland and city parks — breeds in noisy colonies",
stimme_en="A higher, more strangled “caah” than a Carrion Crow",
text_en=(
"Black with a purple gloss and a pale bare patch at the base of the bill — "
"the reliable mark against a Carrion Crow. Juveniles do not have it yet."
"\n\n"
"Unlike the Carrion Crow it breeds in colonies of often hundreds of nests, "
"which wins it few friends in towns. Long persecuted, it has been "
"recovering since the 1980s. That bill is a ground tool: it probes deep for "
"grubs, and feathering at the base would only get in the way."
),
),
"turteltaube": Zusatz(
kategorie="Die Zierlichste",
nahrung="Samen von Wildkräutern, Getreide",
lebensraum="Feldgehölze, Waldränder, Streuobst",
stimme="Ein schnurrendes, rollendes „turr-turr“ — daher der Name",
text=(
"Die kleinste und zierlichste heimische Taube: rostbraun geschuppte "
"Flügel, ein schwarzweiß gestreifter Halsfleck, rosa Brust."
"\n\n"
"Sie ist unser einziger Langstreckenzieher unter den Tauben und "
"überwintert südlich der Sahara. In Europa ist ihr Bestand seit 1980 um "
"rund achtzig Prozent eingebrochen — sie ist eine der am schnellsten "
"verschwindenden Vogelarten des Kontinents. Wer sie noch hört, sollte "
"hinhören."
),
kategorie_en="The Most Delicate",
nahrung_en="Seeds of wild herbs, grain",
lebensraum_en="Copses, woodland edges, old orchards",
stimme_en="A purring, rolling “turr-turr” — the source of its name",
text_en=(
"The smallest and most delicate pigeon here: rust-scalloped wings, a "
"black-and-white striped neck patch and a pink breast."
"\n\n"
"It is our only long-distance migrant among the pigeons, wintering south "
"of the Sahara. Its European population has fallen by around eighty per "
"cent since 1980, making it one of the fastest-disappearing birds on the "
"continent. If you can still hear one, listen."
),
),
"feldlerche": Zusatz(
kategorie="Die Sängerin über dem Acker",
nahrung="Samen und Insekten vom Boden",
lebensraum="Offene Äcker und Wiesen ohne Bäume",
stimme="Ein endloser, perlender Gesang aus großer Höhe, oft minutenlang",
text=(
"Unscheinbar braun gestrichelt mit einer kleinen aufstellbaren Haube. Man "
"sieht sie am Boden kaum — und hört sie über sich, oft ohne sie zu finden."
"\n\n"
"Ihr Singflug führt bis zweihundert Meter hoch, und sie singt dabei "
"ununterbrochen, teils über zehn Minuten. Der Bestand ist seit 1990 um "
"mehr als ein Drittel gefallen: Wintergetreide steht im Frühjahr schon zu "
"dicht, um darin zu brüten. Ein Vogel, der nicht an Nahrung scheitert, "
"sondern am Aussaatkalender."
),
kategorie_en="The Singer Above the Field",
nahrung_en="Seeds and insects from the ground",
lebensraum_en="Open arable land and meadows without trees",
stimme_en="An endless, rippling song from high overhead, often for minutes",
text_en=(
"Plain streaky brown with a small raisable crest. You rarely see it on the "
"ground — and hear it above you, often without finding it."
"\n\n"
"Its song flight climbs to two hundred metres and it sings throughout, "
"sometimes for over ten minutes. Numbers have fallen by more than a third "
"since 1990: winter-sown cereals are already too dense in spring to nest "
"in. A bird undone not by food but by the sowing calendar."
),
),
"neuntoeter": Zusatz(
kategorie="Der Vorratsspießer",
nahrung="Große Insekten, gelegentlich Mäuse und Jungvögel",
lebensraum="Dornhecken in offener Landschaft",
stimme="Ein raues Schäckern, dazu leise Imitationen anderer Arten",
text=(
"Das Männchen ist auffällig gezeichnet: grauer Kopf mit schwarzer "
"Räubermaske, rotbrauner Rücken, rosa angehauchte Brust. Weibchen sind "
"braun und fein gewellt."
"\n\n"
"Seinen Namen hat er von einer echten Angewohnheit: Er spießt Beute auf "
"Dornen auf, als Vorrat und weil ihm die Kraft fehlt, sie im Greifen zu "
"zerlegen. Solche Spießplätze findet man an Schlehen und Weißdorn. Neun "
"Opfer am Tag sind allerdings Volksmund, nicht Biologie."
),
kategorie_en="The Larder Keeper",
nahrung_en="Large insects, occasionally mice and nestlings",
lebensraum_en="Thorn hedges in open country",
stimme_en="A harsh chattering, mixed with quiet mimicry of other species",
text_en=(
"The male is boldly marked: a grey head with a black bandit mask, a "
"chestnut back and a pink-flushed breast. Females are brown and finely "
"barred."
"\n\n"
"Its German name means “nine-killer”, from a real habit: it impales prey on "
"thorns, both as a larder and because it lacks the strength to tear food "
"while holding it. Such larders turn up on blackthorn and hawthorn. The "
"nine victims a day, however, are folklore rather than biology."
),
),
"kuckuck": Zusatz(
kategorie="Der Brutschmarotzer",
nahrung="Behaarte Raupen, die kaum ein anderer Vogel frisst",
lebensraum="Halboffene Landschaft mit Wirtsvögeln in der Nähe",
stimme="Der zweisilbige Ruf, den jeder kennt — nur das Männchen ruft so",
text=(
"Schlank, grau, mit gebändertem Bauch und spitzen Flügeln. Im Flug wird er "
"regelmäßig für einen Sperber gehalten, und das ist kein Zufall: Die "
"Ähnlichkeit verschafft ihm Zugang zu Nestern, weil Kleinvögel in Deckung "
"gehen statt anzugreifen."
"\n\n"
"Das Weibchen legt bis zu 25 Eier in fremde Nester, jedes einzeln und "
"farblich an das Gelege des Wirts angepasst. Es gibt Kuckuckslinien, die "
"auf bestimmte Wirtsarten spezialisiert sind. Das frisch geschlüpfte Junge "
"wirft die übrigen Eier aus dem Nest — blind und nackt, in den ersten "
"Lebensstunden."
),
kategorie_en="The Brood Parasite",
nahrung_en="Hairy caterpillars that almost no other bird will eat",
lebensraum_en="Semi-open country with host species nearby",
stimme_en="The two-note call everyone knows — only the male gives it",
text_en=(
"Slim and grey with a barred belly and pointed wings. In flight it is "
"regularly taken for a Sparrowhawk, and that is no accident: the "
"resemblance buys it access to nests, because small birds take cover "
"instead of attacking."
"\n\n"
"The female lays up to 25 eggs in other birds' nests, one at a time, each "
"colour-matched to the host's clutch. Distinct cuckoo lineages specialise "
"on particular hosts. The newly hatched chick heaves the remaining eggs out "
"of the nest — blind and naked, within hours of hatching."
),
),
"habicht": Zusatz(
kategorie="Der Waldjäger",
nahrung="Tauben, Krähen, Eichhörnchen",
lebensraum="Geschlossene Wälder, zunehmend auch Stadtparks",
stimme="Ein schnelles, hackendes „gik-gik-gik“ in der Brutzeit",
text=(
"Wie ein Sperber, nur eine Nummer größer und schwerer gebaut, mit "
"breiterem Bürzel und einem hellen Überaugenstreif. Weibchen erreichen "
"fast Bussardgröße."
"\n\n"
"Er jagt aus dem Hinterhalt und schlägt Beute bis zur eigenen "
"Körpergröße. In mehreren europäischen Städten hat er sich angesiedelt und "
"lebt dort von Straßentauben — Berlin hat eine der dichtesten "
"Habichtpopulationen der Welt, mitten in der Stadt."
),
kategorie_en="The Forest Hunter",
nahrung_en="Pigeons, crows, squirrels",
lebensraum_en="Closed woodland, increasingly also city parks",
stimme_en="A rapid chopping “gek-gek-gek” while breeding",
text_en=(
"Like a Sparrowhawk but a size up and more heavily built, with a broader "
"rear end and a pale eyebrow. Females approach Buzzard size."
"\n\n"
"It hunts from ambush and takes prey up to its own body size. It has "
"colonised several European cities, living off feral pigeons — Berlin has "
"one of the densest Goshawk populations in the world, right in the middle "
"of town."
),
),
"rotmilan": Zusatz(
kategorie="Der Gabelschwanz",
nahrung="Aas, Regenwürmer, Mäuse — oft hinter dem Mähwerk",
lebensraum="Offene Landschaft mit Feldgehölzen",
stimme="Ein wieherndes, gezogenes „wiiiuh“",
text=(
"Rostrot, mit langen, schmalen Flügeln und einem tief gegabelten Schwanz, "
"den er im Flug ständig als Ruder verdreht. Einmal gesehen, nie mehr "
"verwechselt."
"\n\n"
"Über die Hälfte des Weltbestands brütet in Deutschland — für kaum eine "
"andere Art tragen wir so viel Verantwortung. Er folgt Traktoren und "
"Mähwerken, weil dabei Mäuse und Insekten freigelegt werden. Wer im "
"Frühsommer ein Feld mähen sieht, sieht oft auch ihn."
),
kategorie_en="The Forked Tail",
nahrung_en="Carrion, earthworms, voles — often behind the mower",
lebensraum_en="Open country with copses",
stimme_en="A drawn-out, whinnying “weee-oo”",
text_en=(
"Rusty red, with long narrow wings and a deeply forked tail that it twists "
"constantly as a rudder. Seen once, never mistaken again."
"\n\n"
"More than half the world population breeds in Germany — there is hardly "
"another species for which the country carries so much responsibility. It "
"follows tractors and mowers, which turn up voles and insects. Watch a "
"field being cut in early summer and you will often see one."
),
),
"schwarzmilan": Zusatz(
kategorie="Der Wasserjäger",
nahrung="Tote Fische, Aas, Abfall",
lebensraum="Flüsse, Seen, Auwälder",
stimme="Ein trillerndes, meckerndes Pfeifen",
text=(
"Dunkler und einheitlicher braun als der Rotmilan, mit nur schwach "
"gegabeltem Schwanz. Auf Entfernung ist die Schwanzform das sicherste "
"Merkmal."
"\n\n"
"Er hält sich fast immer in Gewässernähe auf und sammelt tote Fische von "
"der Oberfläche ab. In Afrika, wo er überwintert, gehört er zu den "
"häufigsten Greifvögeln überhaupt und ist an Städten und Märkten ein "
"gewohnter Anblick."
),
kategorie_en="The Water Hunter",
nahrung_en="Dead fish, carrion, refuse",
lebensraum_en="Rivers, lakes, floodplain woodland",
stimme_en="A trilling, bleating whistle",
text_en=(
"Darker and more uniformly brown than a Red Kite, with only a shallow fork "
"in the tail. At distance the tail shape is the reliable mark."
"\n\n"
"It stays close to water and picks dead fish off the surface. In Africa, "
"where it winters, it is among the commonest raptors of all and a familiar "
"sight around towns and markets."
),
),
"wanderfalke": Zusatz(
kategorie="Das schnellste Tier der Welt",
nahrung="Vögel, ausschließlich im Flug geschlagen",
lebensraum="Felsen, Steinbrüche, Kirchtürme, Hochhäuser",
stimme="Ein durchdringendes „kjäk-kjäk-kjäk“ am Horst",
text=(
"Kompakt und kräftig, mit spitzen Flügeln, schiefergrauem Rücken und einem "
"breiten schwarzen Bartstreif, der wie ein Helmriemen wirkt."
"\n\n"
"Im Sturzflug erreicht er über 320 km/h und ist damit das schnellste Tier "
"der Erde. Damit ihm bei dem Tempo die Luft nicht die Lunge sprengt, hat "
"er Höcker in den Nasenlöchern, die den Luftstrom bremsen — Ingenieure "
"haben sich davon bei Düsentriebwerken etwas abgeschaut. In den 1970ern war "
"er durch DDT fast ausgerottet; sein Comeback ist einer der größten Erfolge "
"des Artenschutzes."
),
kategorie_en="The Fastest Animal on Earth",
nahrung_en="Birds, taken exclusively in flight",
lebensraum_en="Cliffs, quarries, church towers, tower blocks",
stimme_en="A piercing “kek-kek-kek” at the eyrie",
text_en=(
"Compact and powerful, with pointed wings, a slate-grey back and a broad "
"black moustache that looks like a helmet strap."
"\n\n"
"In a stoop it exceeds 320 km/h, making it the fastest animal on Earth. So "
"that the airflow does not burst its lungs at that speed, it has bony "
"tubercles in its nostrils that slow the air — engineers borrowed the idea "
"for jet engine intakes. DDT almost wiped it out in the 1970s; its recovery "
"is one of conservation's great successes."
),
),
"schleiereule": Zusatz(
kategorie="Das weiße Gesicht",
nahrung="Mäuse, fast ausschließlich",
lebensraum="Scheunen, Kirchtürme, alte Gebäude in offener Landschaft",
stimme="Kein Ruf, sondern ein heiseres Kreischen — schauerlich in der Nacht",
text=(
"Goldbraun mit einem herzförmigen, leuchtend weißen Gesichtsschleier und "
"schwarzen Augen. Im Scheinwerferlicht über einer Landstraße ist sie "
"unverwechselbar."
"\n\n"
"Der Gesichtsschleier ist kein Schmuck, sondern eine Parabolantenne: Er "
"bündelt Schall auf die Ohren, die zudem asymmetrisch sitzen — eines höher "
"als das andere. Dadurch ortet sie eine Maus im völligen Dunkeln allein "
"nach Gehör, auf wenige Zentimeter genau."
),
kategorie_en="The White Face",
nahrung_en="Voles and mice, almost exclusively",
lebensraum_en="Barns, church towers, old buildings in open country",
stimme_en="Not a hoot but a hoarse shriek — unnerving at night",
text_en=(
"Golden-buff with a heart-shaped, brilliant white facial disc and black "
"eyes. Caught in headlights over a country road it is unmistakable."
"\n\n"
"That facial disc is not decoration but a parabolic dish: it funnels sound "
"to the ears, which sit asymmetrically — one higher than the other. That "
"lets it locate a mouse in total darkness by hearing alone, to within a "
"few centimetres."
),
),
"waldohreule": Zusatz(
kategorie="Die mit den Federohren",
nahrung="Wühlmäuse, gelegentlich Kleinvögel",
lebensraum="Feldgehölze und Waldränder, im Winter Schlafbäume in Orten",
stimme="Ein dumpfes, weiches „huh“ in langen Abständen",
text=(
"Schlank, rindenfarben gemustert, mit langen Federohren und orangeroten "
"Augen. Die Federohren sind kein Gehör, sondern Stimmung und Tarnung — "
"aufgestellt sieht sie aus wie ein abgebrochener Ast."
"\n\n"
"Sie baut kein eigenes Nest, sondern übernimmt alte Krähen- und "
"Elsternester. Im Winter sammeln sich Waldohreulen an gemeinsamen "
"Schlafplätzen, oft in Ortsmitten — es gibt Kiefern und Fichten, in denen "
"über zwanzig Eulen tagsüber sitzen, während darunter Menschen einkaufen."
),
kategorie_en="The One With Ear Tufts",
nahrung_en="Voles, occasionally small birds",
lebensraum_en="Copses and woodland edges; winter roosts in towns",
stimme_en="A soft, muffled “hoo” at long intervals",
text_en=(
"Slim, bark-patterned, with long ear tufts and orange eyes. The tufts have "
"nothing to do with hearing — they signal mood and aid camouflage. Raised, "
"the bird looks like a broken branch."
"\n\n"
"It builds no nest of its own, taking over old crow and magpie nests "
"instead. In winter Long-eared Owls gather at communal roosts, often in "
"town centres — there are pines and spruces holding twenty or more owls by "
"day while people shop underneath."
),
),
"graureiher": Zusatz(
kategorie="Der Geduldige",
nahrung="Fische, Frösche, Mäuse — auch Teichfische im Garten",
lebensraum="Gewässer aller Art, Wiesen, gelegentlich Gartenteiche",
stimme="Ein raues, krächzendes „chräik“, meist im Auffliegen",
text=(
"Grau, groß, mit langem Hals und dolchartigem Schnabel. Im Flug zieht er "
"den Hals zu einem S zusammen — daran unterscheidet man Reiher auf "
"Entfernung von Störchen und Kranichen, die ihn gestreckt tragen."
"\n\n"
"Er steht regungslos im Flachwasser, teils eine halbe Stunde, bis etwas in "
"Reichweite kommt. Wer einen Gartenteich hat, kennt ihn eventuell als "
"ungebetenen Gast — er kalkuliert Entfernungen zu Menschen erstaunlich "
"genau und kommt wieder, sobald niemand hinsieht."
),
kategorie_en="The Patient One",
nahrung_en="Fish, frogs, voles — garden pond fish included",
lebensraum_en="Water of any kind, meadows, occasionally garden ponds",
stimme_en="A harsh croaking “frarnk”, usually as it takes off",
text_en=(
"Grey and large, with a long neck and a dagger of a bill. In flight it "
"folds the neck into an S — that is how you tell herons from storks and "
"cranes at distance, which fly with the neck extended."
"\n\n"
"It stands motionless in shallow water, sometimes for half an hour, until "
"something comes within reach. Anyone with a garden pond may know it as an "
"uninvited guest — it judges distances to people remarkably well and comes "
"back the moment nobody is looking."
),
),
"weissstorch": Zusatz(
kategorie="Der Dachbewohner",
nahrung="Frösche, Mäuse, Regenwürmer, Insekten",
lebensraum="Feuchtwiesen und Dörfer mit Horsten auf Dächern",
stimme="Kein Gesang — er klappert mit dem Schnabel",
text=(
"Weiß mit schwarzen Schwungfedern, rotem Schnabel und roten Beinen. Im "
"Flug mit gestrecktem Hals, was ihn vom Reiher unterscheidet."
"\n\n"
"Er hat keine Stimme im eigentlichen Sinn und verständigt sich durch "
"Klappern. Auf dem Zug meidet er das offene Mittelmeer, weil er auf "
"Thermik angewiesen ist, die über Wasser fehlt — deshalb ziehen alle "
"Störche entweder über Gibraltar oder über den Bosporus. Wo ein Storch "
"geboren wurde, entscheidet, welche der beiden Routen er sein Leben lang "
"fliegt."
),
kategorie_en="The Rooftop Resident",
nahrung_en="Frogs, voles, earthworms, insects",
lebensraum_en="Wet meadows and villages with nests on roofs",
stimme_en="No song — it clatters its bill",
text_en=(
"White with black flight feathers, a red bill and red legs. It flies with "
"the neck extended, which separates it from a heron."
"\n\n"
"It has no voice to speak of and communicates by bill-clattering. On "
"migration it avoids the open Mediterranean because it depends on thermals, "
"which do not form over water — so every stork crosses either at Gibraltar "
"or at the Bosphorus. Where a stork is born decides which of the two routes "
"it will fly for the rest of its life."
),
),
"kranich": Zusatz(
kategorie="Der Vogel des Glücks",
nahrung="Sämereien, Wurzeln, Insekten, kleine Wirbeltiere",
lebensraum="Moore und Feuchtwiesen; im Zug über fast jedem Garten",
stimme="Ein weit hallendes, trompetendes „krruh“ — kilometerweit hörbar",
text=(
"Hochbeinig, grau, mit rotem Scheitelfleck und einem schwarzweißen "
"Kopfmuster. Am Boden trägt er ein Federbüschel über dem Schwanz, das wie "
"ein Federschweif wirkt."
"\n\n"
"Am ehesten bekommt ihr ihn im Überflug ins Bild: Im Oktober und März "
"ziehen hunderttausende in Keilformation über Deutschland, und man hört sie "
"lange bevor man sie sieht. Kranichpaare bleiben ein Leben lang zusammen "
"und begrüßen sich mit einem synchronen Duett — in vielen Kulturen steht er "
"deshalb für Treue und langes Leben."
),
kategorie_en="The Bird of Fortune",
nahrung_en="Seeds, roots, insects, small vertebrates",
lebensraum_en="Bogs and wet meadows; on migration over almost any garden",
stimme_en="A far-carrying, trumpeting “krooh” — audible for kilometres",
text_en=(
"Long-legged and grey, with a red crown patch and a black-and-white head "
"pattern. On the ground a bustle of drooping feathers hangs over the tail."
"\n\n"
"You are most likely to catch one overhead: in October and March hundreds "
"of thousands cross Germany in V-formation, and you hear them long before "
"you see them. Crane pairs stay together for life and greet each other with "
"a synchronised duet — which is why many cultures take the bird as a symbol "
"of fidelity and long life."
),
),
"kormoran": Zusatz(
kategorie="Der Flügeltrockner",
nahrung="Fisch, tauchend erbeutet",
lebensraum="Seen, Flüsse, Küsten — Brutkolonien in Bäumen",
stimme="Nur am Nest, ein tiefes Grunzen",
text=(
"Schwarz, gänsegroß, mit hakenbewehrtem Schnabel. Man erkennt ihn an der "
"Haltung: Er sitzt aufrecht auf einem Pfahl und breitet die Flügel aus."
"\n\n"
"Das tut er, weil sein Gefieder kaum eingefettet ist — anders als bei "
"Enten saugt es sich voll. Was nach Konstruktionsfehler klingt, ist ein "
"Vorteil: Weniger Auftrieb bedeutet leichteres Tauchen. Er bezahlt dafür "
"mit der Trockenzeit an Land. Zwischen Anglern und Kormoranen herrscht "
"seit Jahrzehnten Streit; die Studienlage ist deutlich uneindeutiger als "
"die Debatte."
),
kategorie_en="The Wing Drier",
nahrung_en="Fish, caught by diving",
lebensraum_en="Lakes, rivers, coasts — breeds colonially in trees",
stimme_en="Only at the nest, a deep grunting",
text_en=(
"Black and goose-sized, with a hooked bill. You know it by the posture: "
"upright on a post with wings spread wide."
"\n\n"
"It does that because its plumage is barely oiled — unlike a duck's, it "
"soaks through. What sounds like a design fault is an advantage: less "
"buoyancy means easier diving. It pays for that with drying time ashore. "
"Anglers and cormorants have been in dispute for decades; the evidence is "
"considerably less clear-cut than the argument."
),
),
"eisvogel": Zusatz(
kategorie="Der fliegende Edelstein",
nahrung="Kleinfische, Kaulquappen, Wasserinsekten",
lebensraum="Klare, langsam fließende Gewässer mit Steilufern",
stimme="Ein scharfes, hohes „tiht“ im Vorbeiflug",
text=(
"Türkisblauer Rücken, orangeroter Bauch, kurzer Schwanz, riesiger Schnabel. "
"Meist sieht man nur einen blauen Blitz über dem Wasser."
"\n\n"
"Das Blau ist kein Farbstoff: Die Federstruktur bricht das Licht, ähnlich "
"wie bei einer Seifenblase. Zerriebene Eisvogelfedern sind deshalb braun. "
"Er stößt aus dem Ansitz ins Wasser und muss dabei die Lichtbrechung an der "
"Wasseroberfläche mit einrechnen — die Beute ist nicht dort, wo sie zu sein "
"scheint. Harte Winter, in denen Gewässer zufrieren, können ganze "
"Populationen auslöschen."
),
kategorie_en="The Flying Jewel",
nahrung_en="Small fish, tadpoles, aquatic insects",
lebensraum_en="Clear, slow-flowing water with steep banks",
stimme_en="A sharp, high “tseee” as it passes",
text_en=(
"A turquoise back, orange underparts, a stubby tail and an enormous bill. "
"Usually all you see is a blue flash over the water."
"\n\n"
"The blue is not a pigment: the feather structure refracts light, much like "
"a soap bubble. Ground-up kingfisher feathers are brown. It dives from a "
"perch and has to allow for the refraction at the water's surface — the "
"prey is not where it appears to be. Hard winters that freeze the water can "
"wipe out whole populations."
),
),
"lachmoewe": Zusatz(
kategorie="Die Binnenlandmöwe",
nahrung="Insekten, Würmer, Fisch, Abfall",
lebensraum="Seen, Flüsse, Ackerland, Stadtparks",
stimme="Ein kreischendes, lachendes „kräääh“ — daher der Name",
text=(
"Die Möwe, die man auch weit vom Meer sieht. Im Sommer mit "
"schokoladenbraunem Kopf, im Winter weiß mit einem dunklen Ohrfleck — "
"zwei so verschiedene Erscheinungen, dass man sie für zwei Arten hält."
"\n\n"
"Sie folgt dem Pflug wie ein Rotmilan dem Mähwerk und sammelt Würmer aus "
"der frischen Furche. Der braune Kopf ist übrigens keine Kapuze bis in den "
"Nacken, sondern endet vorn — anders als bei den echten Kapuzenmöwen."
),
kategorie_en="The Inland Gull",
nahrung_en="Insects, worms, fish, refuse",
lebensraum_en="Lakes, rivers, farmland, city parks",
stimme_en="A screeching, laughing “kreeah” — hence the German name",
text_en=(
"The gull you see far from the sea. In summer it has a chocolate-brown "
"hood, in winter a white head with a dark ear spot — two appearances so "
"different that people take them for two species."
"\n\n"
"It follows the plough the way a Red Kite follows the mower, picking worms "
"from the fresh furrow. The brown hood, incidentally, stops short of the "
"nape rather than running down it, unlike the true hooded gulls."
),
),
"stockente": Zusatz(
kategorie="Die Stammente",
nahrung="Wasserpflanzen, Samen, Schnecken, Brot (nicht gut für sie)",
lebensraum="Jedes Gewässer, vom Stadtteich bis zum Fluss",
stimme="Das bekannte „quak“ — nur das Weibchen, der Erpel raunzt leise",
text=(
"Der Erpel hat einen flaschengrünen Kopf, weißen Halsring und braune Brust; "
"das Weibchen ist unscheinbar braun gesprenkelt. Beide zeigen den blauen "
"Flügelspiegel."
"\n\n"
"Von ihr stammen fast alle Hausenten ab. Im Hochsommer trägt auch der Erpel "
"ein tarnfarbenes Schlichtkleid und ist dann kaum vom Weibchen zu "
"unterscheiden — in dieser Zeit wechselt er alle Schwungfedern gleichzeitig "
"und ist für Wochen flugunfähig. Brot ist für sie übrigens schädlich; "
"Haferflocken oder Erbsen sind besser."
),
kategorie_en="The Original Duck",
nahrung_en="Water plants, seeds, snails, bread (bad for them)",
lebensraum_en="Any water, from a town pond to a river",
stimme_en="The familiar quack — the female only; the drake rasps quietly",
text_en=(
"The drake has a bottle-green head, a white collar and a brown breast; the "
"female is plain mottled brown. Both show the blue wing speculum."
"\n\n"
"Almost all domestic ducks descend from it. In high summer the drake also "
"wears drab eclipse plumage and is then hard to tell from the female — "
"during that period it moults all its flight feathers at once and cannot "
"fly for weeks. Bread, incidentally, is bad for them; oats or peas are "
"better."
),
),
"graugans": Zusatz(
kategorie="Die Stammgans",
nahrung="Gras, Getreide, Wasserpflanzen",
lebensraum="Seen und Feuchtwiesen, im Winter auf Äckern",
stimme="Das laute, nasale „gaga-gack“ der Hausgans",
text=(
"Groß, grau, mit orangefarbenem Schnabel und rosa Beinen. Im Flug fällt "
"der sehr helle Vorderflügel auf."
"\n\n"
"Von ihr stammt die Hausgans ab, weshalb sie so vertraut klingt. Konrad "
"Lorenz erforschte an Graugänsen die Prägung: Frisch geschlüpfte Küken "
"folgen dem ersten großen bewegten Objekt, das sie sehen — in seinem Fall "
"ihm selbst. Familienverbände bleiben über Monate zusammen, und Paare oft "
"über Jahre."
),
kategorie_en="The Ancestral Goose",
nahrung_en="Grass, grain, water plants",
lebensraum_en="Lakes and wet meadows, arable fields in winter",
stimme_en="The loud nasal honking of a farmyard goose",
text_en=(
"Large and grey, with an orange bill and pink legs. In flight the very pale "
"forewing stands out."
"\n\n"
"The domestic goose descends from it, which is why it sounds so familiar. "
"Konrad Lorenz studied imprinting in Greylags: newly hatched goslings "
"follow the first large moving object they see — in his case, himself. "
"Family groups stay together for months, and pairs often for years."
),
),
}
# fmt: on
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
"""Dauerprotokoll der Erkennung.
Sechs Minuten zuschauen beantwortet die Frage „übersehen wir Vögel?" nur für
diese sechs Minuten. Über Tage beantwortet sie ein Protokoll.
Geschrieben wird als CSV, damit man es in eine Tabelle ziehen kann:
* **jede Erkennung ab einer niedrigen Grundschwelle** — auch die, die für eine
Sichtung nicht gereicht hat. Genau daran erkennt man, ob die Schwelle zu
hoch steht oder die Vögel zu klein im Bild sind.
* **eine Zeile je Viertelstunde**, immer, auch wenn nichts passiert ist. Ohne
diese Zeilen weiß man später nicht, ob eine Lücke bedeutet „nichts gesehen"
oder „lief gar nicht".
Die zweite Sorte ist die wichtigere. Ein Protokoll, das nur bei Erfolg
schreibt, sieht bei einem Ausfall genauso aus wie bei einem ruhigen Tag.
"""
from __future__ import annotations
import csv
from datetime import datetime, timezone
from pathlib import Path
KOPF = [
"zeit",
"art", # "puls" = Lebenszeichen, "sicht" = Erkennung
"score", # Sicherheit des Vogeldetektors
"flaeche_pct", # wie viel des Bildes der Vogel einnimmt
"helligkeit",
"top_art", # beste Artbestimmung, falls vorhanden
"confidence",
"bird_score", # Anteil heimischer Arten
"bilder", # verarbeitete Bilder seit dem letzten Puls
"dunkel", # davon im Dunkeln uebersprungen
]
class Protokoll:
"""Schreibt Erkennungen und regelmäßige Lebenszeichen in eine CSV."""
def __init__(
self,
pfad: str | Path,
grundschwelle: float = 0.08,
puls_sekunden: float = 900.0,
):
self.pfad = Path(pfad)
self.grundschwelle = grundschwelle
self.puls_sekunden = puls_sekunden
self.pfad.parent.mkdir(parents=True, exist_ok=True)
neu = not self.pfad.exists() or self.pfad.stat().st_size == 0
# Anhängen, nicht überschreiben: ein Neustart des Dienstes darf die
# bisherigen Tage nicht wegwerfen.
self._datei = self.pfad.open("a", newline="", encoding="utf-8")
self._csv = csv.writer(self._datei)
if neu:
self._csv.writerow(KOPF)
self._datei.flush()
self._letzter_puls: float | None = None
self._bilder = 0
self._dunkel = 0
# -- Zählen ------------------------------------------------------------
def bild(self, dunkel: bool = False) -> None:
self._bilder += 1
if dunkel:
self._dunkel += 1
# -- Schreiben ---------------------------------------------------------
def erkennung(self, box, result, helligkeit: float) -> None:
"""Eine Detektion festhalten — auch eine, die für nichts gereicht hat."""
if box is None or box.score < self.grundschwelle:
return
top, conf, bird = "", "", ""
if result is not None and result.guesses:
top = result.guesses[0].species.slug
conf = f"{result.guesses[0].confidence:.3f}"
bird = f"{result.bird_score:.3f}"
self._schreibe(
"sicht",
f"{box.score:.3f}",
f"{box.area * 100:.2f}",
f"{helligkeit:.0f}",
top,
conf,
bird,
)
def puls(self, jetzt: float, helligkeit: float, erzwingen: bool = False) -> None:
"""Lebenszeichen. Ohne diese Zeilen ist eine Lücke im Protokoll
nicht von einem Ausfall zu unterscheiden."""
if (
not erzwingen
and self._letzter_puls is not None
and jetzt - self._letzter_puls < self.puls_sekunden
):
return
self._letzter_puls = jetzt
self._schreibe("puls", "", "", f"{helligkeit:.0f}", "", "", "")
self._bilder = 0
self._dunkel = 0
def _schreibe(self, art: str, *felder: str) -> None:
self._csv.writerow(
[datetime.now(timezone.utc).isoformat(timespec="seconds"), art, *felder,
self._bilder, self._dunkel]
)
# Sofort auf Platte: Wer nach einem Absturz ins Protokoll schaut, will
# die letzten Minuten sehen und nicht einen halbleeren Puffer.
self._datei.flush()
def close(self) -> None:
try:
self._datei.close()
except OSError:
pass
+206
View File
@@ -0,0 +1,206 @@
"""Auswertung der gespeicherten Sichtungen.
Beantwortet die Frage, die man nach den ersten Tagen wirklich hat: Sehen wir
Vögel, und taugen die Bilder etwas?
python -m detector report live/roh
python -m detector report live/roh --kontaktbogen bogen.jpg
Der Detector schreibt mit ``--save-to`` je Sichtung ein Vollbild, einen
Ausschnitt und eine JSON-Datei. Diese Auswertung liest den Ordner und misst
nach, statt zu schätzen:
* **Schärfe** — Varianz der Laplace-Antwort auf dem Ausschnitt. Nicht
kalibriert, aber zwischen Sichtungen derselben Kamera vergleichbar. Unter
etwa 50 ist ein Bild sichtbar verwackelt.
* **Fläche** — wie viel Prozent des Bildes der Vogel einnimmt. Der stärkste
Hebel für die Erkennungsqualität: Ein Vogel unter 3 % ist ein Fleck, und
daran ändert kein Schwellwert etwas. Dann muss die Kamera näher ran.
* **Helligkeit** — Gegenlicht und Dämmerung sichtbar machen.
Der Kontaktbogen legt die Ausschnitte nebeneinander. Ob die Bildqualität
reicht, sieht man in zehn Sekunden — und nicht, indem man sechzig Dateien
einzeln öffnet.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, ImageDraw
from .daylight import brightness
from .motion import sharpness
@dataclass
class Eintrag:
stamp: str
art: str
confidence: float
frames_mit: int
frames_gesamt: int
schaerfe: float
flaeche: float
helligkeit: float
bild: Path | None
crop: Path | None
@property
def trefferquote(self) -> float:
return self.frames_mit / self.frames_gesamt if self.frames_gesamt else 0.0
def lade(ordner: Path) -> list[Eintrag]:
eintraege: list[Eintrag] = []
for js in sorted(ordner.glob("*.json")):
try:
daten = json.loads(js.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
bild = js.with_suffix(".jpg")
crop = js.with_name(js.stem + "_crop.jpg")
bild = bild if bild.exists() else None
crop = crop if crop.exists() else None
schaerfe = flaeche = hell = 0.0
if crop is not None and bild is not None:
try:
c = Image.open(crop)
v = Image.open(bild)
schaerfe = sharpness(c)
hell = brightness(v)
# Flächenanteil aus den Maßen: der Ausschnitt stammt aus dem
# Vollbild, das Verhältnis ist also direkt ablesbar.
flaeche = (c.width * c.height) / (v.width * v.height) * 100
except OSError:
pass
eintraege.append(
Eintrag(
stamp=js.stem.split("_")[0],
art=daten.get("species") or "unbestimmt",
confidence=float(daten.get("confidence") or 0.0),
frames_mit=int(daten.get("frames_with_bird") or 0),
frames_gesamt=int(daten.get("frames_total") or 0),
schaerfe=schaerfe,
flaeche=flaeche,
helligkeit=hell,
bild=bild,
crop=crop,
)
)
return eintraege
def _urteil(e: Eintrag) -> str:
"""Kurzes Urteil zur Bildqualität. Die Schwellen sind Erfahrungswerte."""
if e.flaeche < 3:
return "Vogel zu klein"
if e.schaerfe < 50:
return "verwackelt"
if e.helligkeit < 40:
return "zu dunkel"
if e.flaeche >= 10 and e.schaerfe >= 150:
return "sehr gut"
return "brauchbar"
def kontaktbogen(eintraege: list[Eintrag], ziel: Path, spalten: int = 6,
kachel: int = 200) -> int:
"""Legt die Ausschnitte als Raster nebeneinander, mit Beschriftung."""
mit_bild = [e for e in eintraege if e.crop is not None]
if not mit_bild:
return 0
zeilen = (len(mit_bild) + spalten - 1) // spalten
beschriftung = 26
blatt = Image.new(
"RGB",
(spalten * kachel, zeilen * (kachel + beschriftung)),
(245, 247, 244),
)
zeichner = ImageDraw.Draw(blatt)
for i, e in enumerate(mit_bild):
x = (i % spalten) * kachel
y = (i // spalten) * (kachel + beschriftung)
try:
img = Image.open(e.crop).convert("RGB")
except OSError:
continue
img.thumbnail((kachel, kachel), Image.LANCZOS)
blatt.paste(img, (x + (kachel - img.width) // 2,
y + (kachel - img.height) // 2))
zeichner.text(
(x + 4, y + kachel + 6),
f"{e.stamp[-6:]} {e.art[:12]} {e.confidence:.0%}",
fill=(60, 81, 71),
)
ziel.parent.mkdir(parents=True, exist_ok=True)
blatt.save(ziel, quality=88)
return len(mit_bild)
def zeige(eintraege: list[Eintrag], ordner: Path) -> None:
if not eintraege:
print(f"Keine Sichtungen in {ordner}.")
print("Der Detector muss mit --save-to laufen, damit hier etwas landet.")
return
print(f"\n{len(eintraege)} Sichtungen in {ordner}\n")
print(f"{'Zeit':<16}{'Art':<20}{'Sich.':>6}{'Frames':>9}"
f"{'Fläche':>8}{'Schärfe':>9}{'Hell':>6} Urteil")
print("─" * 88)
for e in eintraege:
zeit = f"{e.stamp[6:8]}.{e.stamp[4:6]}. {e.stamp[9:11]}:{e.stamp[11:13]}" \
if len(e.stamp) >= 13 else e.stamp
print(
f"{zeit:<16}{e.art[:19]:<20}{e.confidence:>5.0%}"
f"{e.frames_mit:>5}/{e.frames_gesamt:<3}"
f"{e.flaeche:>7.1f}%{e.schaerfe:>9.0f}{e.helligkeit:>6.0f} {_urteil(e)}"
)
bestimmt = [e for e in eintraege if e.art != "unbestimmt"]
mit_mass = [e for e in eintraege if e.crop is not None]
print("\n" + "─" * 88)
print(f"Bestimmt {len(bestimmt):>4} von {len(eintraege)}"
f" ({len(bestimmt) / len(eintraege):.0%})")
if bestimmt:
arten = {}
for e in bestimmt:
arten[e.art] = arten.get(e.art, 0) + 1
top = ", ".join(f"{a} ×{n}" for a, n in
sorted(arten.items(), key=lambda kv: -kv[1])[:6])
print(f"Arten {len(arten)} verschiedene — {top}")
if mit_mass:
def mittel(f):
return sum(f(e) for e in mit_mass) / len(mit_mass)
print(f"Fläche im Bild {mittel(lambda e: e.flaeche):.1f} % im Mittel, "
f"kleinste {min(e.flaeche for e in mit_mass):.1f} %")
print(f"Schärfe {mittel(lambda e: e.schaerfe):.0f} im Mittel, "
f"schlechteste {min(e.schaerfe for e in mit_mass):.0f}")
print(f"Helligkeit {mittel(lambda e: e.helligkeit):.0f} von 255")
urteile: dict[str, int] = {}
for e in mit_mass:
u = _urteil(e)
urteile[u] = urteile.get(u, 0) + 1
print("\nBildqualität:")
for u, n in sorted(urteile.items(), key=lambda kv: -kv[1]):
print(f" {u:<16} {n:>3} {'█' * round(24 * n / len(mit_mass))}")
zu_klein = [e for e in mit_mass if e.flaeche < 3]
if len(zu_klein) > len(mit_mass) / 3:
print("\nMehr als ein Drittel der Vögel füllt unter 3 % des Bildes.")
print("Das ist der stärkste Hebel: Die Kamera gehört näher heran oder")
print("enger eingestellt — an Schwellwerten ist da nichts zu holen.")
+151
View File
@@ -0,0 +1,151 @@
"""Wohin die fertigen Sichtungen gehen."""
from __future__ import annotations
import io
import json
import time
from abc import ABC, abstractmethod
from pathlib import Path
from PIL import Image
from .watcher import Sighting
JPEG_QUALITY = 88
MAX_WIDTH = 1600
def _encode(img: Image.Image, max_width: int = MAX_WIDTH) -> bytes:
if img.width > max_width:
h = round(img.height * max_width / img.width)
img = img.resize((max_width, h), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
return buf.getvalue()
def payload_of(sighting: Sighting) -> dict:
return {
"species": sighting.species.slug if sighting.species else None,
"confidence": round(sighting.confidence, 4),
"started_at": sighting.started_at.isoformat(),
"ended_at": sighting.ended_at.isoformat(),
"frames_total": sighting.frames_total,
"frames_with_bird": sighting.frames_with_bird,
"votes": {k: round(v, 4) for k, v in sighting.votes.items()},
"mean_bird_score": round(sighting.mean_bird_score, 4),
# Vier Zahlen statt eines zweiten Bildes: siehe web/dex/zuschnitt.py.
"box": ([round(w, 5) for w in sighting.best_box]
if getattr(sighting, "best_box", None) else None),
}
class Sink(ABC):
@abstractmethod
def emit(self, sighting: Sighting) -> None: ...
class ConsoleSink(Sink):
"""Nur ausgeben — zum Einstellen der Schwellwerte an der echten Kamera."""
def emit(self, sighting: Sighting) -> None:
print(f"[sichtung] {sighting.summary()}")
if sighting.votes:
top = sorted(sighting.votes.items(), key=lambda kv: -kv[1])[:3]
print(" " + ", ".join(f"{k} {v:.0%}" for k, v in top))
class FileSink(Sink):
"""Bilder und JSON in einen Ordner. Nützlich, wenn die Web-App gerade
nicht läuft, und als Trainingsmaterial fürs spätere eigene Modell."""
def __init__(self, folder: str | Path):
self.folder = Path(folder)
self.folder.mkdir(parents=True, exist_ok=True)
def emit(self, sighting: Sighting) -> None:
stamp = sighting.started_at.strftime("%Y%m%d-%H%M%S")
name = sighting.species.slug if sighting.species else "unbestimmt"
base = self.folder / f"{stamp}_{name}"
if sighting.best_image is not None:
base.with_suffix(".jpg").write_bytes(_encode(sighting.best_image))
if sighting.best_crop is not None:
base.with_name(base.name + "_crop").with_suffix(".jpg").write_bytes(
_encode(sighting.best_crop, 640)
)
base.with_suffix(".json").write_text(
json.dumps(payload_of(sighting), indent=2, ensure_ascii=False),
encoding="utf-8",
)
print(f"[datei] {base.name} — {sighting.summary()}")
class HTTPSink(Sink):
"""Meldet an die Django-App. Bei Netzproblemen wird die Sichtung
verworfen statt gepuffert — für einen Vogelzähler ist das in Ordnung, und
``FileSink`` als zweite Senke fängt es ohnehin auf."""
def __init__(self, base_url: str, token: str, timeout: int = 20, retries: int = 2):
self.url = base_url.rstrip("/") + "/api/sightings/"
self.token = token
self.timeout = timeout
self.retries = retries
def emit(self, sighting: Sighting) -> None:
import requests
files = {}
if sighting.best_image is not None:
files["image"] = ("sighting.jpg", _encode(sighting.best_image), "image/jpeg")
if sighting.best_crop is not None:
files["crop"] = ("crop.jpg", _encode(sighting.best_crop, 640), "image/jpeg")
data = {"data": json.dumps(payload_of(sighting))}
headers = {"Authorization": f"Bearer {self.token}"}
for attempt in range(self.retries + 1):
try:
resp = requests.post(
self.url, data=data, files=files, headers=headers, timeout=self.timeout
)
except requests.RequestException as exc:
if attempt == self.retries:
print(f"[http] Senden fehlgeschlagen: {exc}")
return
time.sleep(2 ** attempt)
continue
if resp.ok:
body = resp.json()
bits = [f"{body.get('art') or 'unbestimmt'}", f"+{body.get('xp', 0)} XP"]
if body.get("erstfang"):
bits.append("ERSTFANG!")
if body.get("level_up"):
bits.append(f"LEVEL {body['level']}!")
for name in body.get("abzeichen") or []:
bits.append(f"Abzeichen: {name}")
print("[http] " + " · ".join(bits))
return
# 4xx liegt an uns und wird durch Wiederholen nicht besser.
if 400 <= resp.status_code < 500:
print(f"[http] {resp.status_code}: {resp.text[:200]}")
return
if attempt == self.retries:
print(f"[http] {resp.status_code}: {resp.text[:200]}")
return
time.sleep(2 ** attempt)
class MultiSink(Sink):
def __init__(self, *sinks: Sink):
self.sinks = [s for s in sinks if s is not None]
def emit(self, sighting: Sighting) -> None:
for sink in self.sinks:
try:
sink.emit(sighting)
except Exception as exc: # eine kaputte Senke darf den Lauf nicht beenden
print(f"[sink] {type(sink).__name__} fehlgeschlagen: {exc}")
+226
View File
@@ -0,0 +1,226 @@
"""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()
+335
View File
@@ -0,0 +1,335 @@
"""Der erweiterte Artenkatalog.
``species_de.py`` enthält die 44 Arten, die realistisch am Futterhaus sitzen —
mit Erzähltext, Verwechslungsliste und allem. Hier stehen die übrigen rund
100 Arten, die in Deutschland regelmäßig vorkommen und die das Modell kennt.
Bewusst nur Strukturdaten, kein Fließtext. Ein Steckbrief für den
Purpurreiher, der nie vor der Kamera landen wird, ist verschwendete Mühe —
er wächst nach, sobald eine Art zum ersten Mal auftaucht. Die Oberfläche
kommt mit fehlendem Text zurecht.
Die Seltenheit misst, wie wahrscheinlich eine Art **vor dieser Kamera**
erscheint, nicht ihre Bestandslage. Der Kiebitz ist deutschlandweit weit
häufiger als der Steinkauz — an einem Futterhaus sieht man trotzdem eher den
Kauz. Damit die Sammlung dadurch nicht in einem Meer aus Wasservögeln
untergeht, hat jede Art zusätzlich einen ``lebensraum``, nach dem sich
filtern lässt.
"""
from __future__ import annotations
from dataclasses import dataclass
# Lebensraum — die Achse, nach der die Sammlung sinnvoll teilbar ist.
HABITATS = {
"garten": ("Garten & Siedlung", "Gardens & settlements"),
"wald": ("Wald", "Woodland"),
"offenland": ("Feld & Wiese", "Fields & meadows"),
"wasser": ("Wasser & Ufer", "Water & shoreline"),
"gebirge": ("Gebirge", "Mountains"),
}
# Ergänzende Typen für die Gruppen, die im Garten nicht vorkommen.
EXTRA_TYPES = {
"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", "#f0ffff"),
}
EXTRA_TYPE_NAMES_EN = {
"schreitvogel": "Wader (long-legged)",
"ente": "Duck",
"gans": "Goose",
"moewe": "Gull",
"watvogel": "Shorebird",
"taucher": "Grebe",
"ralle": "Rail",
"lerche": "Lark",
"wuerger": "Shrike",
"huhn": "Gamebird",
"exot": "Exotic",
}
@dataclass(frozen=True)
class Extra:
slug: str
german: str
scientific: str
english: str
typ: str
rarity: str
size_cm: str
gewicht_g: float
spannweite_cm: float
zug: str
lebensraum: str
def _(slug, german, scientific, english, typ, rarity, size, gewicht, spann, zug, hab):
return Extra(slug, german, scientific, english, typ, rarity,
f"{size} cm", gewicht, spann, zug, hab)
# fmt: off
EXTRA: list[Extra] = [
# ---------- Finken und Ammern ----------
_("bergfink", "Bergfink", "Fringilla montifringilla", "Brambling",
"fink", "selten", 15, 24, 26, "zug", "garten"),
_("bluthaenfling", "Bluthänfling", "Carduelis cannabina", "Eurasian Linnet",
"fink", "selten", 13, 19, 24, "teil", "offenland"),
_("kernbeisser", "Kernbeißer", "Coccothraustes coccothraustes", "Hawfinch",
"fink", "selten", 18, 55, 31, "stand", "wald"),
_("fichtenkreuzschnabel", "Fichtenkreuzschnabel", "Loxia curvirostra", "Red Crossbill",
"fink", "episch", 17, 40, 29, "stand", "wald"),
_("rohrammer", "Rohrammer", "Emberiza schoeniclus", "Reed Bunting",
"ammer", "episch", 15, 19, 23, "teil", "wasser"),
_("grauammer", "Grauammer", "Emberiza calandra", "Corn Bunting",
"ammer", "episch", 18, 45, 29, "stand", "offenland"),
# ---------- Schnäpper und Verwandte ----------
_("blaukehlchen", "Blaukehlchen", "Luscinia svecica", "Bluethroat",
"schnaepper", "episch", 14, 18, 23, "zug", "wasser"),
_("braunkehlchen", "Braunkehlchen", "Saxicola rubetra", "Whinchat",
"schnaepper", "episch", 13, 17, 23, "zug", "offenland"),
_("schwarzkehlchen", "Schwarzkehlchen", "Saxicola rubicola", "European Stonechat",
"schnaepper", "episch", 12, 15, 20, "teil", "offenland"),
_("steinschmaetzer", "Steinschmätzer", "Oenanthe oenanthe", "Northern Wheatear",
"schnaepper", "episch", 15, 24, 30, "zug", "offenland"),
# ---------- Drosseln ----------
_("misteldrossel", "Misteldrossel", "Turdus viscivorus", "Mistle Thrush",
"drossel", "selten", 27, 120, 45, "teil", "wald"),
_("wasseramsel", "Wasseramsel", "Cinclus cinclus", "White-throated Dipper",
"drossel", "episch", 18, 60, 28, "stand", "gebirge"),
# ---------- Grasmücken und Goldhähnchen ----------
_("dorngrasmuecke", "Dorngrasmücke", "Sylvia communis", "Greater Whitethroat",
"grasmuecke", "selten", 14, 15, 21, "zug", "offenland"),
_("wintergoldhaehnchen", "Wintergoldhähnchen", "Regulus regulus", "Goldcrest",
"grasmuecke", "selten", 9, 5.5, 14, "teil", "wald"),
_("sommergoldhaehnchen", "Sommergoldhähnchen", "Regulus ignicapilla", "Common Firecrest",
"grasmuecke", "selten", 9, 5.5, 14, "teil", "wald"),
# ---------- Stelzen, Pieper, Lerchen ----------
_("gebirgsstelze", "Gebirgsstelze", "Motacilla cinerea", "Grey Wagtail",
"stelze", "selten", 19, 18, 26, "teil", "gebirge"),
_("schafstelze", "Schafstelze", "Motacilla flava", "Western Yellow Wagtail",
"stelze", "episch", 17, 17, 25, "zug", "offenland"),
_("wiesenpieper", "Wiesenpieper", "Anthus pratensis", "Meadow Pipit",
"lerche", "episch", 15, 18, 24, "teil", "offenland"),
_("feldlerche", "Feldlerche", "Alauda arvensis", "Eurasian Skylark",
"lerche", "episch", 18, 38, 33, "teil", "offenland"),
_("haubenlerche", "Haubenlerche", "Galerida cristata", "Crested Lark",
"lerche", "legendaer", 17, 42, 32, "stand", "offenland"),
# ---------- Würger ----------
_("neuntoeter", "Neuntöter", "Lanius collurio", "Red-backed Shrike",
"wuerger", "episch", 17, 30, 26, "zug", "offenland"),
_("raubwuerger", "Raubwürger", "Lanius excubitor", "Northern Shrike",
"wuerger", "legendaer", 25, 65, 33, "teil", "offenland"),
# ---------- Rabenvögel ----------
_("nebelkraehe", "Nebelkrähe", "Corvus cornix", "Hooded Crow",
"rabenvogel", "selten", 48, 510, 98, "stand", "offenland"),
_("saatkraehe", "Saatkrähe", "Corvus frugilegus", "Rook",
"rabenvogel", "selten", 46, 460, 90, "teil", "offenland"),
_("alpendohle", "Alpendohle", "Pyrrhocorax graculus", "Alpine Chough",
"rabenvogel", "legendaer", 38, 210, 78, "stand", "gebirge"),
# ---------- Tauben ----------
_("strassentaube", "Straßentaube", "Columba livia", "Rock Pigeon",
"taube", "selten", 33, 300, 65, "stand", "garten"),
_("turteltaube", "Turteltaube", "Streptopelia turtur", "European Turtle-Dove",
"taube", "episch", 27, 150, 50, "zug", "offenland"),
# ---------- Schwalben ----------
_("uferschwalbe", "Uferschwalbe", "Riparia riparia", "Bank Swallow",
"schwalbe", "selten", 12, 14, 28, "zug", "wasser"),
# ---------- Auffällige Einzelgänger ----------
_("seidenschwanz", "Seidenschwanz", "Bombycilla garrulus", "Bohemian Waxwing",
"exot", "selten", 20, 60, 34, "zug", "garten"),
_("kuckuck", "Kuckuck", "Cuculus canorus", "Common Cuckoo",
"exot", "episch", 33, 115, 58, "zug", "wald"),
_("wiedehopf", "Wiedehopf", "Upupa epops", "Common Hoopoe",
"exot", "legendaer", 27, 65, 45, "zug", "offenland"),
_("eisvogel", "Eisvogel", "Alcedo atthis", "Common Kingfisher",
"exot", "legendaer", 17, 40, 25, "stand", "wasser"),
_("bienenfresser", "Bienenfresser", "Merops apiaster", "European Bee-Eater",
"exot", "legendaer", 28, 55, 46, "zug", "offenland"),
_("blauracke", "Blauracke", "Coracias garrulus", "European Roller",
"exot", "legendaer", 31, 130, 70, "zug", "offenland"),
# ---------- Greifvögel ----------
_("habicht", "Habicht", "Accipiter gentilis", "Northern Goshawk",
"greifvogel", "episch", 55, 900, 105, "stand", "wald"),
_("raufussbussard", "Raufußbussard", "Buteo lagopus", "Rough-legged Hawk",
"greifvogel", "legendaer", 55, 950, 130, "zug", "offenland"),
_("rotmilan", "Rotmilan", "Milvus milvus", "Red Kite",
"greifvogel", "episch", 65, 1000, 160, "teil", "offenland"),
_("schwarzmilan", "Schwarzmilan", "Milvus migrans", "Black Kite",
"greifvogel", "episch", 58, 850, 150, "zug", "wasser"),
_("rohrweihe", "Rohrweihe", "Circus aeruginosus", "Western Marsh-Harrier",
"greifvogel", "episch", 52, 650, 120, "zug", "wasser"),
_("kornweihe", "Kornweihe", "Circus cyaneus", "Northern Harrier",
"greifvogel", "legendaer", 47, 450, 110, "teil", "offenland"),
_("fischadler", "Fischadler", "Pandion haliaetus", "Osprey",
"greifvogel", "legendaer", 57, 1500, 160, "zug", "wasser"),
_("steinadler", "Steinadler", "Aquila chrysaetos", "Golden Eagle",
"greifvogel", "legendaer", 85, 4500, 210, "stand", "gebirge"),
_("schlangenadler", "Schlangenadler", "Circaetus gallicus", "Short-toed Snake-Eagle",
"greifvogel", "legendaer", 67, 1800, 180, "zug", "offenland"),
_("gaensegeier", "Gänsegeier", "Gyps fulvus", "Eurasian Griffon",
"greifvogel", "legendaer", 100, 8000, 260, "teil", "gebirge"),
_("wanderfalke", "Wanderfalke", "Falco peregrinus", "Peregrine Falcon",
"greifvogel", "episch", 45, 800, 105, "stand", "gebirge"),
_("baumfalke", "Baumfalke", "Falco subbuteo", "Eurasian Hobby",
"greifvogel", "episch", 33, 230, 80, "zug", "offenland"),
_("merlin", "Merlin", "Falco columbarius", "Merlin",
"greifvogel", "legendaer", 28, 190, 60, "zug", "offenland"),
# ---------- Eulen ----------
_("schleiereule", "Schleiereule", "Tyto alba", "Barn Owl",
"eule", "episch", 35, 320, 90, "stand", "offenland"),
_("waldohreule", "Waldohreule", "Asio otus", "Long-eared Owl",
"eule", "episch", 36, 280, 92, "stand", "wald"),
_("sumpfohreule", "Sumpfohreule", "Asio flammeus", "Short-eared Owl",
"eule", "legendaer", 37, 330, 100, "zug", "offenland"),
_("schnee_eule", "Schnee-Eule", "Bubo scandiacus", "Snowy Owl",
"eule", "legendaer", 60, 2000, 150, "zug", "offenland"),
# ---------- Schreitvögel ----------
_("graureiher", "Graureiher", "Ardea cinerea", "Grey Heron",
"schreitvogel", "episch", 95, 1700, 180, "teil", "wasser"),
_("silberreiher", "Silberreiher", "Ardea alba", "Great Egret",
"schreitvogel", "legendaer", 95, 1200, 165, "teil", "wasser"),
_("purpurreiher", "Purpurreiher", "Ardea purpurea", "Purple Heron",
"schreitvogel", "legendaer", 85, 900, 130, "zug", "wasser"),
_("seidenreiher", "Seidenreiher", "Egretta garzetta", "Little Egret",
"schreitvogel", "legendaer", 60, 500, 95, "zug", "wasser"),
_("weissstorch", "Weißstorch", "Ciconia ciconia", "White Stork",
"schreitvogel", "episch", 105, 3400, 200, "zug", "offenland"),
_("kranich", "Kranich", "Grus grus", "Common Crane",
"schreitvogel", "legendaer", 115, 5500, 220, "zug", "offenland"),
# ---------- Hühnervögel ----------
_("fasan", "Fasan", "Phasianus colchicus", "Ring-necked Pheasant",
"huhn", "episch", 80, 1200, 80, "stand", "offenland"),
# ---------- Watvögel ----------
_("kiebitz", "Kiebitz", "Vanellus vanellus", "Northern Lapwing",
"watvogel", "episch", 30, 220, 75, "teil", "offenland"),
_("austernfischer", "Austernfischer", "Haematopus ostralegus", "Eurasian Oystercatcher",
"watvogel", "legendaer", 43, 520, 85, "teil", "wasser"),
_("saebelschnaebler", "Säbelschnäbler", "Recurvirostra avosetta", "Pied Avocet",
"watvogel", "legendaer", 44, 300, 78, "zug", "wasser"),
_("stelzenlaeufer", "Stelzenläufer", "Himantopus himantopus", "Black-winged Stilt",
"watvogel", "legendaer", 37, 180, 75, "zug", "wasser"),
_("flussregenpfeifer", "Flussregenpfeifer", "Charadrius dubius", "Little Ringed Plover",
"watvogel", "legendaer", 16, 40, 45, "zug", "wasser"),
_("sandregenpfeifer", "Sandregenpfeifer", "Charadrius hiaticula", "Common Ringed Plover",
"watvogel", "legendaer", 19, 65, 50, "zug", "wasser"),
_("bekassine", "Bekassine", "Gallinago gallinago", "Common Snipe",
"watvogel", "legendaer", 26, 110, 45, "teil", "wasser"),
_("grosser_brachvogel", "Großer Brachvogel", "Numenius arquata", "Eurasian Curlew",
"watvogel", "legendaer", 55, 800, 90, "teil", "wasser"),
_("regenbrachvogel", "Regenbrachvogel", "Numenius phaeopus", "Whimbrel",
"watvogel", "legendaer", 42, 400, 82, "zug", "wasser"),
_("rotschenkel", "Rotschenkel", "Tringa totanus", "Common Redshank",
"watvogel", "legendaer", 28, 130, 62, "teil", "wasser"),
_("gruenschenkel", "Grünschenkel", "Tringa nebularia", "Common Greenshank",
"watvogel", "legendaer", 32, 180, 70, "zug", "wasser"),
_("waldwasserlaeufer", "Waldwasserläufer", "Tringa ochropus", "Green Sandpiper",
"watvogel", "legendaer", 22, 80, 58, "zug", "wasser"),
_("bruchwasserlaeufer", "Bruchwasserläufer", "Tringa glareola", "Wood Sandpiper",
"watvogel", "legendaer", 20, 62, 56, "zug", "wasser"),
_("flussuferlaeufer", "Flussuferläufer", "Actitis hypoleucos", "Common Sandpiper",
"watvogel", "legendaer", 20, 50, 40, "zug", "wasser"),
# ---------- Möwen und Seeschwalben ----------
_("lachmoewe", "Lachmöwe", "Chroicocephalus ridibundus", "Black-headed Gull",
"moewe", "episch", 38, 280, 105, "teil", "wasser"),
_("silbermoewe", "Silbermöwe", "Larus argentatus", "Herring Gull",
"moewe", "episch", 60, 1100, 145, "teil", "wasser"),
_("sturmmoewe", "Sturmmöwe", "Larus canus", "Mew Gull",
"moewe", "legendaer", 42, 400, 115, "teil", "wasser"),
_("mantelmoewe", "Mantelmöwe", "Larus marinus", "Great Black-backed Gull",
"moewe", "legendaer", 70, 1700, 160, "teil", "wasser"),
_("heringsmoewe", "Heringsmöwe", "Larus fuscus", "Lesser Black-backed Gull",
"moewe", "legendaer", 55, 800, 140, "zug", "wasser"),
_("mittelmeermoewe", "Mittelmeermöwe", "Larus michahellis", "Yellow-legged Gull",
"moewe", "legendaer", 60, 1100, 145, "teil", "wasser"),
_("flussseeschwalbe", "Flussseeschwalbe", "Sterna hirundo", "Common Tern",
"moewe", "legendaer", 34, 130, 82, "zug", "wasser"),
_("kuestenseeschwalbe", "Küstenseeschwalbe", "Sterna paradisaea", "Arctic Tern",
"moewe", "legendaer", 35, 110, 78, "zug", "wasser"),
# ---------- Taucher und Kormoran ----------
_("kormoran", "Kormoran", "Phalacrocorax carbo", "Great Cormorant",
"taucher", "episch", 90, 2500, 140, "teil", "wasser"),
_("haubentaucher", "Haubentaucher", "Podiceps cristatus", "Great Crested Grebe",
"taucher", "legendaer", 48, 900, 88, "teil", "wasser"),
_("rothalstaucher", "Rothalstaucher", "Podiceps grisegena", "Red-necked Grebe",
"taucher", "legendaer", 45, 800, 82, "zug", "wasser"),
_("schwarzhalstaucher", "Schwarzhalstaucher", "Podiceps nigricollis", "Eared Grebe",
"taucher", "legendaer", 30, 300, 58, "zug", "wasser"),
_("zwergtaucher", "Zwergtaucher", "Tachybaptus ruficollis", "Little Grebe",
"taucher", "legendaer", 27, 150, 42, "teil", "wasser"),
# ---------- Rallen ----------
_("blaesshuhn", "Blässhuhn", "Fulica atra", "Eurasian Coot",
"ralle", "episch", 38, 750, 75, "teil", "wasser"),
_("teichhuhn", "Teichhuhn", "Gallinula chloropus", "Eurasian Moorhen",
"ralle", "episch", 33, 320, 55, "teil", "wasser"),
# ---------- Schwäne und Gänse ----------
_("hoeckerschwan", "Höckerschwan", "Cygnus olor", "Mute Swan",
"gans", "episch", 150, 11000, 230, "stand", "wasser"),
_("singschwan", "Singschwan", "Cygnus cygnus", "Whooper Swan",
"gans", "legendaer", 150, 10000, 235, "zug", "wasser"),
_("graugans", "Graugans", "Anser anser", "Greylag Goose",
"gans", "episch", 82, 3300, 160, "teil", "wasser"),
_("blaessgans", "Blässgans", "Anser albifrons", "Greater White-fronted Goose",
"gans", "legendaer", 72, 2500, 145, "zug", "wasser"),
_("kanadagans", "Kanadagans", "Branta canadensis", "Canada Goose",
"gans", "episch", 95, 4500, 170, "teil", "wasser"),
_("weisswangengans", "Weißwangengans", "Branta leucopsis", "Barnacle Goose",
"gans", "legendaer", 65, 1700, 135, "zug", "wasser"),
_("ringelgans", "Ringelgans", "Branta bernicla", "Brant",
"gans", "legendaer", 58, 1400, 115, "zug", "wasser"),
# ---------- Enten ----------
_("stockente", "Stockente", "Anas platyrhynchos", "Mallard",
"ente", "episch", 58, 1100, 90, "teil", "wasser"),
_("spiessente", "Spießente", "Anas acuta", "Northern Pintail",
"ente", "legendaer", 60, 900, 88, "zug", "wasser"),
_("krickente", "Krickente", "Anas crecca", "Green-winged Teal",
"ente", "legendaer", 36, 330, 60, "teil", "wasser"),
_("schnatterente", "Schnatterente", "Anas strepera", "Gadwall",
"ente", "legendaer", 50, 850, 88, "teil", "wasser"),
_("loeffelente", "Löffelente", "Anas clypeata", "Northern Shoveler",
"ente", "legendaer", 48, 650, 78, "zug", "wasser"),
_("pfeifente", "Pfeifente", "Anas penelope", "Eurasian Wigeon",
"ente", "legendaer", 48, 750, 82, "zug", "wasser"),
_("knaekente", "Knäkente", "Anas querquedula", "Garganey",
"ente", "legendaer", 38, 350, 63, "zug", "wasser"),
_("tafelente", "Tafelente", "Aythya ferina", "Common Pochard",
"ente", "legendaer", 46, 900, 77, "teil", "wasser"),
_("reiherente", "Reiherente", "Aythya fuligula", "Tufted Duck",
"ente", "legendaer", 43, 700, 70, "teil", "wasser"),
_("bergente", "Bergente", "Aythya marila", "Greater Scaup",
"ente", "legendaer", 46, 1000, 78, "zug", "wasser"),
]
# fmt: on
BY_SLUG = {e.slug: e for e in EXTRA}
+931
View File
@@ -0,0 +1,931 @@
"""Steckbriefe für die Artseiten.
Bewusst getrennt von ``species_de.py``: dort steht, was die Erkennung braucht
(wissenschaftlicher Name, Seltenheit, XP). Hier steht, was der Mensch davon
hat. Der Detector importiert diese Datei nie.
Aufbau wie ein Sammelkarten-Steckbrief: eine Kategorie („Der Höhlenbrüter“),
ein Typ, harte Werte als Balken, zwei Absätze Fließtext und die Arten, mit
denen man sie verwechselt. ``verwechslung`` ist dabei nicht Deko — es ist die
Liste, die man beim Bestimmen tatsächlich durchgeht, und sie deckt sich mit
den Verwechslungen, die auch das Modell macht.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# Die „Typen“. Eine Handvoll Familien, jede mit eigener Farbe,
# damit die Sammlung auf einen Blick Struktur bekommt.
TYPES = {
"meise": ("Meise", "#f2c744", "#3b2f00"),
"fink": ("Fink", "#e0574f", "#3b0d0a"),
"sperling": ("Sperling", "#b08347", "#2e1f0c"),
"drossel": ("Drossel", "#7a5c3e", "#241a10"),
"schnaepper": ("Schnäpper", "#ef8354", "#3b1a0a"),
"grasmuecke": ("Grasmücke", "#8fae5d", "#1f2a10"),
"specht": ("Specht", "#d64545", "#3b0e0e"),
"rabenvogel": ("Rabenvogel", "#8b7fd4", "#1b1638"),
"taube": ("Taube", "#a8a5b8", "#26242e"),
"schwalbe": ("Schwalbe", "#5aa9e6", "#0c2338"),
"greifvogel": ("Greifvogel", "#c9963f", "#33240a"),
"eule": ("Eule", "#5c6bc0", "#141a3b"),
"klettervogel": ("Klettervogel", "#4db6ac", "#0c2b28"),
"buschvogel": ("Buschvogel", "#9c7b5c", "#291d13"),
"stelze": ("Stelze", "#cfd8dc", "#22282a"),
"ammer": ("Ammer", "#e5c04a", "#332a06"),
}
ZUG = {
"stand": "Standvogel",
"teil": "Teilzieher",
"zug": "Zugvogel",
}
@dataclass(frozen=True)
class Info:
"""Steckbrief einer Art.
``groesse``/``gewicht``/``spannweite`` sind Mittelwerte — Vögel schwanken
saisonal erheblich, im Winter wiegt dieselbe Meise gut ein Zehntel mehr.
"""
typ: str
kategorie: str # „Der Höhlenbrüter“ — die Schlagzeile des Eintrags
gewicht_g: float
spannweite_cm: float
zug: str
nahrung: str
lebensraum: str
stimme: str
text: str # zwei Absätze, durch \n\n getrennt
verwechslung: list[str] = field(default_factory=list)
# fmt: off
INFO: dict[str, Info] = {
"kohlmeise": Info(
typ="meise", kategorie="Die Lauteste im Garten",
gewicht_g=18, spannweite_cm=24, zug="stand",
nahrung="Insekten und Raupen im Sommer, Samen und Fett im Winter",
lebensraum="Gärten, Parks, Wälder — überall, wo es Höhlen zum Brüten gibt",
stimme="Ein helles „zi-zi-bäh“, das klingt wie eine quietschende Fahrradpumpe",
text=(
"Die Kohlmeise ist der Vogel, den fast jeder zuerst am Futterhaus sieht. "
"Sie ist die größte heimische Meise, sofort erkennbar am schwarzen Kopf mit "
"den weißen Wangen und dem gelben Bauch. Der schwarze Streifen, der über "
"diesen Bauch läuft, verrät das Geschlecht: breit und bis zwischen die Beine "
"reichend beim Männchen, schmal und oft unterbrochen beim Weibchen."
"\n\n"
"Kohlmeisen haben ein erstaunlich großes Stimmrepertoire — über 40 "
"verschiedene Laute sind beschrieben, weshalb Vogelkundler sagen: „Was du "
"nicht kennst, ist wahrscheinlich eine Kohlmeise.“ Sie sind außerdem "
"Problemlöser. In England lernten sie in den 1920ern, die Alufolie von "
"Milchflaschen zu öffnen, und die Technik verbreitete sich über das ganze "
"Land, indem ein Vogel sie vom anderen abschaute."
),
verwechslung=["blaumeise", "tannenmeise"],
),
"blaumeise": Info(
typ="meise", kategorie="Der UV-Leuchter",
gewicht_g=11, spannweite_cm=19, zug="stand",
nahrung="Blattläuse, Raupen, im Winter Samen und Nüsse",
lebensraum="Laubwälder und Gärten mit alten Bäumen",
stimme="Ein schnelles, klingelndes „tsi-tsi-tsirrr“",
text=(
"Kleiner und runder als die Kohlmeise, mit blauer Kappe, blauen Flügeln und "
"einem schwarzen Strich durchs Auge, der wie eine Zorro-Maske wirkt. Der "
"Bauch ist gelb, aber blasser als bei der Kohlmeise."
"\n\n"
"Was wir sehen, ist nur die halbe Wahrheit: Die Scheitelkappe reflektiert "
"ultraviolettes Licht, und Blaumeisen können UV sehen. Für einen Artgenossen "
"leuchtet dieser Fleck also deutlich greller, als er für uns aussieht — und "
"je greller, desto attraktiver. Weibchen wählen ihre Partner tatsächlich "
"nach einer Farbe aus, die für menschliche Augen unsichtbar ist."
),
verwechslung=["kohlmeise", "tannenmeise"],
),
"tannenmeise": Info(
typ="meise", kategorie="Der Vorratshalter",
gewicht_g=9, spannweite_cm=18, zug="stand",
nahrung="Fichtensamen, kleine Insekten",
lebensraum="Nadelwälder, im Winter auch Gärten",
stimme="Ein hohes, zweisilbiges „sitü-sitü“, schneller als die Kohlmeise",
text=(
"Die kleinste heimische Meise, nur neun Gramm schwer. Schwarzer Kopf wie die "
"Kohlmeise, aber mit einem weißen Fleck im Nacken — das ist das sichere "
"Kennzeichen. Der Bauch ist beige statt gelb."
"\n\n"
"Tannenmeisen legen Wintervorräte an und verstecken Samen einzeln in "
"Rindenritzen. Sie merken sich hunderte Verstecke über Wochen hinweg. Der "
"Teil des Gehirns, der dafür zuständig ist, wächst im Herbst messbar an und "
"schrumpft im Frühjahr wieder."
),
verwechslung=["kohlmeise", "blaumeise"],
),
"schwanzmeise": Info(
typ="meise", kategorie="Der Wattebausch am Stiel",
gewicht_g=8, spannweite_cm=18, zug="stand",
nahrung="Kleinstinsekten, Spinnen, Eier von Blattläusen",
lebensraum="Gebüsch, Waldränder, Hecken",
stimme="Ein feines „srrih-srrih“, meist aus einer ganzen Gruppe gleichzeitig",
text=(
"Ein Federball mit einem Schwanz, der länger ist als der ganze restliche "
"Vogel. Schwanzmeisen kommen fast nie allein — wenn eine da ist, sind acht "
"weitere im Anflug."
"\n\n"
"Ihr Nest ist eines der aufwendigsten Bauwerke der heimischen Vogelwelt: ein "
"geschlossener Beutel aus Moos, Flechten und bis zu 2.000 Federn, "
"zusammengehalten von Spinnenseide. Die dehnt sich mit, wenn die Jungen "
"wachsen. Im Winter schlafen sie dicht aneinandergedrängt in einer Reihe auf "
"einem Ast — anders überstehen acht Gramm eine Frostnacht nicht."
),
verwechslung=["blaumeise"],
),
"haussperling": Info(
typ="sperling", kategorie="Der Mitbewohner",
gewicht_g=30, spannweite_cm=24, zug="stand",
nahrung="Samen, Brotkrumen, im Sommer Insekten für die Jungen",
lebensraum="Dörfer und Städte — praktisch immer in Menschennähe",
stimme="Das bekannte „tschilp“, meist im Chor aus einer Hecke",
text=(
"Der Spatz. Männchen tragen eine graue Kappe und einen schwarzen Latz, "
"Weibchen sind schlicht braun mit einem hellen Streifen hinterm Auge. Er "
"lebt seit über 10.000 Jahren beim Menschen und kommt fast nirgends vor, wo "
"keine Menschen sind."
"\n\n"
"Trotz seiner Häufigkeit ist er in Deutschland auf der Vorwarnliste: "
"Sanierte Fassaden lassen keine Nischen mehr zum Brüten, und versiegelte "
"Flächen bieten keine Insekten für die Jungen. In manchen Großstädten ist "
"der Bestand seit 1990 um mehr als die Hälfte eingebrochen."
),
verwechslung=["feldsperling", "heckenbraunelle"],
),
"feldsperling": Info(
typ="sperling", kategorie="Der mit dem Ohrfleck",
gewicht_g=24, spannweite_cm=21, zug="stand",
nahrung="Sämereien, Getreide, Insekten in der Brutzeit",
lebensraum="Feldränder, Obstgärten, Dorfränder",
stimme="Wie der Haussperling, aber höher und schärfer",
text=(
"Auf den ersten Blick ein Spatz, auf den zweiten deutlich eleganter: braune "
"statt graue Kappe, und ein sauberer schwarzer Fleck mitten auf der weißen "
"Wange. Diesen Ohrfleck hat kein anderer heimischer Vogel."
"\n\n"
"Anders als beim Haussperling sehen Männchen und Weibchen gleich aus. Der "
"Feldsperling ist scheuer und hält mehr Abstand zu Häusern — wer ihn am "
"Futterhaus hat, hat meist Feld oder Streuobst in der Nähe."
),
verwechslung=["haussperling"],
),
"amsel": Info(
typ="drossel", kategorie="Der Frühaufsteher",
gewicht_g=100, spannweite_cm=36, zug="teil",
nahrung="Regenwürmer, Insekten, im Herbst Beeren und Fallobst",
lebensraum="Gärten, Parks, Wälder — einer der anpassungsfähigsten Vögel",
stimme="Ein volles, flötendes Lied von einer erhöhten Warte, oft schon vor Sonnenaufgang",
text=(
"Männchen sind tiefschwarz mit leuchtend gelbem Schnabel und gelbem Augenring, "
"Weibchen unscheinbar dunkelbraun mit gesprenkelter Brust. Wer eine „braune "
"Amsel“ sieht, sieht ein Weibchen — kein anderer Vogel."
"\n\n"
"Die Amsel war ursprünglich ein scheuer Waldvogel. Erst im 19. Jahrhundert "
"zog sie in die Städte, und dort verhält sie sich messbar anders als ihre "
"Verwandten im Wald: Sie singt höher, um den Verkehrslärm zu übertönen, "
"beginnt früher am Morgen, weil Straßenlaternen den Tag vorziehen, und lässt "
"Menschen auf zwei Meter herankommen."
),
verwechslung=["singdrossel", "star"],
),
"singdrossel": Info(
typ="drossel", kategorie="Der Schneckenknacker",
gewicht_g=70, spannweite_cm=34, zug="zug",
nahrung="Schnecken, Würmer, Beeren",
lebensraum="Wälder mit dichtem Unterholz, große Gärten",
stimme="Kurze Motive, jedes zwei- bis viermal wiederholt — daran erkennt man sie sicher",
text=(
"Braun oben, cremeweiß unten mit dunklen, pfeilförmigen Flecken. Kleiner und "
"zierlicher als die Amsel, mit warmerem Braunton."
"\n\n"
"Singdrosseln benutzen Werkzeuge — genauer gesagt einen Amboss. Sie tragen "
"Schneckenhäuser zu einem festen Stein und schlagen sie dort auf. Wer im "
"Garten einen Stein mit Schneckenhausscherben drumherum findet, hat eine "
"Drosselschmiede gefunden, und die wird oft über Jahre von denselben Vögeln "
"benutzt."
),
verwechslung=["wacholderdrossel", "amsel"],
),
"wacholderdrossel": Info(
typ="drossel", kategorie="Der Wintergast",
gewicht_g=100, spannweite_cm=42, zug="teil",
nahrung="Beeren, Fallobst, Würmer",
lebensraum="Offene Landschaft, im Winter in großen Schwärmen in Gärten",
stimme="Ein raues „schack-schack-schack“, meist im Flug",
text=(
"Grauer Kopf, kastanienbrauner Rücken, gelbliche gefleckte Brust. Im Winter "
"fallen sie in Trupps über Apfelbäume und Beerensträucher her — wer im "
"Januar plötzlich zwanzig Drosseln im Garten hat, hat Wacholderdrosseln."
"\n\n"
"Sie verteidigen ihre Nester mit einer Methode, die man einmal gesehen haben "
"muss: Der ganze Brutkoloniechor bombardiert Krähen und Greifvögel gezielt "
"mit Kot. Das verklebt das Gefieder so gründlich, dass angegriffene Vögel "
"flugunfähig werden können."
),
verwechslung=["singdrossel", "star"],
),
"star": Info(
typ="drossel", kategorie="Der Stimmenimitator",
gewicht_g=80, spannweite_cm=40, zug="teil",
nahrung="Insekten, Larven, im Herbst massenhaft Beeren",
lebensraum="Offenes Land mit Bäumen, Siedlungen, Viehweiden",
stimme="Ein wildes Gemisch aus Pfiffen, Knarren und nachgeahmten Geräuschen",
text=(
"Aus der Ferne schwarz, aus der Nähe metallisch grün und violett schillernd "
"mit hellen Punkten. Im Winter ist er dicht gepunktet, im Frühjahr nutzen "
"sich die hellen Federspitzen ab und er wird fast einfarbig glänzend."
"\n\n"
"Stare imitieren, was sie hören: andere Vogelarten, Handyklingeltöne, "
"Autoalarmanlagen, Rasenmäher. Berühmt sind ihre Schwarmflüge, die "
"Murmurationen — zehntausende Vögel, die sich wie eine einzige Wolke bewegen. "
"Jeder einzelne Vogel orientiert sich dabei nur an seinen sieben nächsten "
"Nachbarn; daraus entsteht die Form des ganzen Schwarms."
),
verwechslung=["amsel", "wacholderdrossel"],
),
"rotkehlchen": Info(
typ="schnaepper", kategorie="Der Revierverteidiger",
gewicht_g=18, spannweite_cm=22, zug="teil",
nahrung="Insekten, Würmer, Beeren — folgt gern dem Spaten",
lebensraum="Unterholz, Hecken, Gärten mit Deckung",
stimme="Ein perlendes, melancholisches Lied, auch im Winter und in der Dämmerung",
text=(
"Die orangerote Brust reicht bis ins Gesicht, der Rest ist olivbraun. "
"Männchen und Weibchen sind nicht unterscheidbar. Junge Rotkehlchen haben "
"noch gar kein Rot — das schützt sie davor, von den eigenen Eltern als "
"Rivale angegriffen zu werden."
"\n\n"
"Denn Rotkehlchen sind trotz ihres freundlichen Rufs kompromisslos: Sie "
"verteidigen ihr Revier das ganze Jahr über, auch gegen das eigene "
"Spiegelbild. In Versuchen greifen sie sogar ein bloßes Büschel roter Federn "
"an. Bis zu zehn Prozent der erwachsenen Rotkehlchen sterben in Kämpfen mit "
"Artgenossen."
),
verwechslung=["hausrotschwanz", "gartenrotschwanz"],
),
"hausrotschwanz": Info(
typ="schnaepper", kategorie="Der Dachrinnenbewohner",
gewicht_g=17, spannweite_cm=25, zug="zug",
nahrung="Insekten, im Herbst auch Beeren",
lebensraum="Gebäude, Steinbrüche, Industriegelände — ursprünglich Felsen",
stimme="Ein kratziges Lied, das klingt, als würde jemand Kies schütteln",
text=(
"Dunkelgrau bis fast schwarz, mit rostrotem Schwanz, den er unablässig zittern "
"lässt. Dieses Zittern ist das sicherste Erkennungszeichen auf Entfernung. "
"Weibchen sind einfarbig graubraun, aber mit demselben roten Schwanz."
"\n\n"
"Ursprünglich brütete er in Felswänden im Gebirge. Häuser, Scheunen und "
"Fabrikhallen sind für ihn Felsen mit besserer Statik — heute lebt der "
"Großteil der Population in Siedlungen. Er ist einer der ersten, die morgens "
"singen, oft noch vor der Amsel."
),
verwechslung=["gartenrotschwanz", "rotkehlchen"],
),
"gartenrotschwanz": Info(
typ="schnaepper", kategorie="Der Weitstreckenflieger",
gewicht_g=15, spannweite_cm=23, zug="zug",
nahrung="Insekten, die er im Flug oder vom Boden schnappt",
lebensraum="Streuobstwiesen, lichte Wälder, alte Gärten",
stimme="Ein kurzes, wehmütiges Lied mit einem charakteristischen Knick am Ende",
text=(
"Deutlich bunter als sein Verwandter: Das Männchen hat eine schwarze Kehle, "
"eine weiße Stirn, einen grauen Rücken und eine leuchtend orange Brust. Ein "
"Vogel, den man einmal gesehen haben muss."
"\n\n"
"Er überwintert in der Sahelzone südlich der Sahara — rund 5.000 Kilometer "
"pro Richtung, für einen Vogel von 15 Gramm. Seine Bestände sind stark "
"zurückgegangen, weil ihm zwei Dinge gleichzeitig fehlen: alte Obstbäume mit "
"Höhlen hier, und Regen in der Sahelzone dort."
),
verwechslung=["hausrotschwanz", "trauerschnaepper"],
),
"trauerschnaepper": Info(
typ="schnaepper", kategorie="Der Nistkastengast",
gewicht_g=13, spannweite_cm=23, zug="zug",
nahrung="Fliegende Insekten, im Flug gefangen",
lebensraum="Laubwälder, Parks, Gärten mit Nistkästen",
stimme="Ein einfaches, etwas hölzernes „zick-zick-tru-tru“",
text=(
"Das Männchen ist im Frühjahr kontrastreich schwarzweiß, mit einem weißen "
"Stirnfleck und weißem Flügelfeld. Weibchen und Herbstmännchen sind braun — "
"dieselbe Art, völlig anderer Eindruck."
"\n\n"
"Er nimmt Nistkästen so bereitwillig an wie kaum eine andere Art und ist "
"deshalb einer der bestuntersuchten Vögel Europas. Sein Problem heißt "
"Klimawandel: Er richtet seinen Abflug aus Afrika nach der Tageslänge, aber "
"die Raupen, von denen seine Jungen leben, schlüpfen wegen der Wärme immer "
"früher. Er kommt inzwischen oft zu spät zum eigenen Buffet."
),
verwechslung=["grauschnaepper", "gartenrotschwanz"],
),
"grauschnaepper": Info(
typ="schnaepper", kategorie="Der Ansitzjäger",
gewicht_g=15, spannweite_cm=25, zug="zug",
nahrung="Fliegen, Mücken, Falter — ausschließlich im Flug erbeutet",
lebensraum="Waldränder, Gärten, Friedhöfe",
stimme="Ein unauffälliges, dünnes „tsri“ — leicht zu überhören",
text=(
"Unscheinbar graubraun mit fein gestrichelter Brust. Wer ihn übersieht, "
"erkennt ihn trotzdem am Verhalten: Er sitzt aufrecht auf einer freien Warte, "
"startet blitzschnell, schnappt eine Fliege und kehrt exakt auf denselben "
"Ast zurück. Immer wieder."
"\n\n"
"Man hört beim Zuschnappen manchmal ein leises Klacken des Schnabels. Er "
"gehört zu den letzten Rückkehrern im Frühjahr — vor Mitte Mai ist kaum einer "
"da, weil vorher schlicht zu wenig fliegt."
),
verwechslung=["trauerschnaepper", "heckenbraunelle"],
),
"buchfink": Info(
typ="fink", kategorie="Der mit dem Dialekt",
gewicht_g=22, spannweite_cm=27, zug="teil",
nahrung="Samen, im Sommer Insekten für die Jungen",
lebensraum="Wälder, Gärten, Parks — einer der häufigsten Vögel Europas",
stimme="Eine absteigende Strophe mit einem Schnörkel am Ende, der „Finkenschlag“",
text=(
"Das Männchen hat eine blaugraue Kappe, eine rostrote Brust und zwei "
"auffällige weiße Flügelbinden. Weibchen sind olivbraun, zeigen aber "
"dieselben weißen Binden — daran erkennt man sie im Flug."
"\n\n"
"Buchfinken haben regionale Dialekte. Ein Fink aus Bayern singt messbar "
"anders als einer aus Schleswig-Holstein, und die Grenzen dieser Dialekte "
"lassen sich auf der Karte einzeichnen. Junge Finken lernen den Gesang von "
"den Nachbarn; wer isoliert aufwächst, bringt nur ein Gestammel zustande."
),
verwechslung=["gruenfink", "haussperling"],
),
"gruenfink": Info(
typ="fink", kategorie="Der Kernknacker",
gewicht_g=28, spannweite_cm=26, zug="stand",
nahrung="Große, harte Samen — Sonnenblumenkerne sind sein Lieblingsfutter",
lebensraum="Gärten, Parks, Friedhöfe",
stimme="Ein langgezogenes, nasales „dschüüü“, das klingt wie ein Quietschen",
text=(
"Olivgrün mit gelben Flügel- und Schwanzkanten, die im Flug aufblitzen. "
"Männchen sind kräftiger gelbgrün, Weibchen matter und grauer."
"\n\n"
"Sein massiger Kegelschnabel knackt Sonnenblumenkerne, an denen Meisen "
"scheitern. Am Futterhaus ist er dominant und vertreibt Kleinere von der "
"besten Stelle. Achtung bei Futterstellen: Grünfinken sind besonders anfällig "
"für Trichomonaden, einen Erreger, der sich über verschmutztes Futter und "
"Wasser verbreitet. Regelmäßig reinigen hilft."
),
verwechslung=["buchfink", "girlitz"],
),
"stieglitz": Info(
typ="fink", kategorie="Der Distelspezialist",
gewicht_g=16, spannweite_cm=23, zug="teil",
nahrung="Distel- und Kardensamen, Erlen- und Birkensamen",
lebensraum="Offene Flächen mit Stauden, Streuobst, Gärten mit Wildecke",
stimme="Ein klingelndes, fröhliches „stiglit“ — daher der Name",
text=(
"Der bunteste heimische Fink: leuchtend rotes Gesicht, schwarzweißer Kopf, "
"breite goldgelbe Flügelbinde. Männchen und Weibchen sehen fast gleich aus."
"\n\n"
"Sein spitzer, langer Schnabel holt Samen aus Disteln und Karden, an die "
"sonst niemand herankommt. Wer Stieglitze im Garten will, muss deshalb vor "
"allem eines tun: verblühte Stauden über den Winter stehen lassen. Ein "
"aufgeräumter Garten ist für ihn eine leere Speisekammer."
),
verwechslung=["girlitz", "erlenzeisig"],
),
"erlenzeisig": Info(
typ="fink", kategorie="Der Invasionsgast",
gewicht_g=13, spannweite_cm=21, zug="teil",
nahrung="Erlen- und Birkensamen, Fichtensamen",
lebensraum="Nadel- und Mischwälder, im Winter an Erlen und am Futterhaus",
stimme="Ein wetzendes „dsüü“ und ein plapperndes Geschwätz im Trupp",
text=(
"Klein, gelbgrün, fein gestrichelt, mit schwarzer Kappe beim Männchen. Sie "
"hängen kopfüber an Erlenzapfen wie Meisen an einem Knödel."
"\n\n"
"In manchen Wintern sind sie überall, in anderen fehlen sie fast völlig. Das "
"hängt an der Samenernte der Fichten und Erlen im Norden: Fällt sie aus, "
"ziehen ganze Populationen weiter nach Süden. Solche Jahre heißen "
"Invasionsjahre, und man kann sie nicht vorhersagen."
),
verwechslung=["girlitz", "stieglitz"],
),
"gimpel": Info(
typ="fink", kategorie="Der Dompfaff",
gewicht_g=25, spannweite_cm=25, zug="stand",
nahrung="Knospen, Samen, Beeren — im Frühjahr gern Obstbaumknospen",
lebensraum="Nadelwälder, Gärten mit Hecken, Friedhöfe",
stimme="Ein weiches, trauriges „djü“ — leise und leicht zu überhören",
text=(
"Das Männchen hat die kräftigste Farbe im ganzen Winterhalbjahr: leuchtend "
"rosarote Brust, schwarze Kappe, grauer Rücken, weißer Bürzel. Weibchen sind "
"graubraun statt rot, sonst identisch gezeichnet."
"\n\n"
"Gimpel sind auffällig ruhig und wenig scheu — sie lassen sich lange "
"beobachten und fliegen selten hastig davon. Paare bleiben oft ein Leben lang "
"zusammen und halten auch im Winterschwarm engen Kontakt. Der alte Name "
"Dompfaff kommt vom Aussehen: schwarze Kappe und roter Talar."
),
verwechslung=["buchfink"],
),
"girlitz": Info(
typ="fink", kategorie="Der Kleinste",
gewicht_g=11, spannweite_cm=20, zug="zug",
nahrung="Winzige Samen von Kräutern und Gräsern",
lebensraum="Siedlungen mit Nadelbäumen, Weinberge, Friedhöfe",
stimme="Ein hastiges, klirrendes Geschwirr wie ein aufgezogenes Uhrwerk",
text=(
"Der kleinste Fink Europas, gerade elf Gramm. Gelbgrün, kräftig gestrichelt, "
"mit einem sehr kurzen, stumpfen Schnabel — das unterscheidet ihn vom "
"Erlenzeisig."
"\n\n"
"Er stammt aus dem Mittelmeerraum und hat sich erst im 19. Jahrhundert nach "
"Norden ausgebreitet. Sein Singflug ist sehenswert: Er flattert wie ein "
"Falter mit langsamen, übertriebenen Flügelschlägen über sein Revier."
),
verwechslung=["erlenzeisig", "gruenfink"],
),
"kleiber": Info(
typ="klettervogel", kategorie="Der Kopfüberläufer",
gewicht_g=23, spannweite_cm=26, zug="stand",
nahrung="Insekten aus der Rinde, Nüsse und Samen im Winter",
lebensraum="Alte Laubwälder, Parks, Gärten mit dicken Bäumen",
stimme="Ein lautes, durchdringendes „twit-twit-twit“, erstaunlich weit hörbar",
text=(
"Blaugrauer Rücken, orangebeiger Bauch, schwarzer Augenstreif, kurzer Schwanz "
"und ein kräftiger, spitzer Schnabel. Er wirkt wie ein kleiner Specht ohne "
"Stützschwanz."
"\n\n"
"Er ist der einzige heimische Vogel, der Baumstämme kopfüber hinunterlaufen "
"kann — Spechte und Baumläufer schaffen nur aufwärts. Seinen Namen hat er "
"vom Kleben: Ist ihm das Einflugloch seiner Bruthöhle zu groß, mauert er es "
"mit Lehm auf die passende Größe zu. Deshalb heißt er auch Spechtmeise, "
"obwohl er weder Specht noch Meise ist."
),
verwechslung=["buntspecht"],
),
"zaunkoenig": Info(
typ="buschvogel", kategorie="Der Lauteste pro Gramm",
gewicht_g=9, spannweite_cm=15, zug="stand",
nahrung="Spinnen und kleine Insekten aus dem Bodengestrüpp",
lebensraum="Dichtes Gebüsch, Reisighaufen, Bachufer, Gartenecken",
stimme="Ein überraschend lautes, schmetterndes Trillern — bis zu 90 Dezibel",
text=(
"Winzig, rundlich, rostbraun, mit dem typisch steil aufgestellten Schwanz. "
"Nur neun Gramm schwer und trotzdem einer der lautesten Vögel Europas — "
"gemessen an der Körpergröße ist er der lauteste überhaupt."
"\n\n"
"Das Männchen baut mehrere Nester und führt das Weibchen herum, damit es "
"eines aussuchen kann; die übrigen dienen im Winter als Schlafplatz. Bei "
"Frost drängen sich manchmal über ein Dutzend Zaunkönige in einen einzigen "
"solchen Ball. Er lebt im Bodendickicht — wer den Garten komplett aufräumt, "
"verliert ihn."
),
verwechslung=["heckenbraunelle"],
),
"heckenbraunelle": Info(
typ="buschvogel", kategorie="Die Unauffällige",
gewicht_g=20, spannweite_cm=20, zug="teil",
nahrung="Kleine Insekten, im Winter feine Sämereien",
lebensraum="Hecken, Gebüsch, Gärten mit dichter Bepflanzung",
stimme="Ein schnelles, gleichförmiges Zwitschern ohne erkennbare Melodie",
text=(
"Auf den ersten Blick ein Spatz, auf den zweiten deutlich feiner: schlanker "
"Schnabel statt Kegelschnabel, grauer Kopf und graue Brust, brauner "
"gestrichelter Rücken. Sie trippelt meist mausartig am Boden unter Büschen "
"herum."
"\n\n"
"Hinter der grauen Fassade steckt das komplizierteste Liebesleben im Garten: "
"Heckenbraunellen leben oft zu dritt oder in noch größeren Konstellationen, "
"Weibchen mit mehreren Männchen und umgekehrt. Die Männchen füttern die "
"Jungen anteilig danach, wie wahrscheinlich sie der Vater sind."
),
verwechslung=["haussperling", "zaunkoenig"],
),
"moenchsgrasmuecke": Info(
typ="grasmuecke", kategorie="Die Schwarzkappe",
gewicht_g=18, spannweite_cm=22, zug="teil",
nahrung="Insekten im Sommer, Beeren und Holunder im Herbst",
lebensraum="Unterholz, Hecken, Gärten mit Sträuchern",
stimme="Ein überschlagender, flötender Schluss nach leisem Geplauder",
text=(
"Grau, unauffällig — bis auf die Kappe. Männchen tragen sie schwarz, "
"Weibchen rotbraun. Ein seltener Fall, in dem beide Geschlechter eindeutig "
"und trotzdem gleich hübsch sind."
"\n\n"
"Ihr Zugverhalten ändert sich gerade vor unseren Augen. Ein wachsender Teil "
"der mitteleuropäischen Population fliegt inzwischen nach Großbritannien "
"statt nach Spanien — angelockt von Winterfütterung. Diese Vögel kommen im "
"Frühjahr früher zurück und bekommen die besten Reviere. Evolution im "
"Zeitraffer, ausgelöst durch Meisenknödel."
),
verwechslung=["zilpzalp", "heckenbraunelle"],
),
"zilpzalp": Info(
typ="grasmuecke", kategorie="Der sich selbst benennt",
gewicht_g=9, spannweite_cm=18, zug="zug",
nahrung="Kleinste Insekten und Larven aus dem Blattwerk",
lebensraum="Wälder mit Unterholz, Gärten, Parks",
stimme="Genau das, wonach er heißt: „zilp-zalp-zilp-zalp“, monoton und unverwechselbar",
text=(
"Ein winziger, olivgrüner Vogel ohne besondere Merkmale — mit bloßem Auge "
"kaum vom Fitis zu unterscheiden. Ein Kennzeichen gibt es doch: dunkle Beine. "
"Der Fitis hat helle."
"\n\n"
"Zum Glück muss man ihn nicht sehen. Sein Gesang ist so eindeutig, dass er "
"seinen Namen daraus bekommen hat, und er ist im Frühjahr einer der ersten "
"Rückkehrer. Wer im März das erste „zilp-zalp\“ hört, weiß: Der Winter ist "
"vorbei."
),
verwechslung=["moenchsgrasmuecke"],
),
"buntspecht": Info(
typ="specht", kategorie="Der Trommler",
gewicht_g=85, spannweite_cm=38, zug="stand",
nahrung="Larven aus dem Holz, Samen aus Zapfen, im Winter Fettfutter",
lebensraum="Alle Waldtypen, Parks, Gärten mit alten Bäumen",
stimme="Ein scharfes „kick“, und im Frühjahr das schnelle Trommeln auf totem Holz",
text=(
"Schwarzweiß gemustert mit leuchtend rotem Unterschwanz. Das Männchen hat "
"einen roten Nackenfleck, das Weibchen nicht, Jungvögel haben eine ganz rote "
"Kappe."
"\n\n"
"Beim Trommeln schlägt er bis zu zwanzigmal pro Sekunde zu, mit einer "
"Verzögerung, die dem 1000-fachen der Erdbeschleunigung entspricht. Sein "
"Schädel funktioniert als Stoßdämpfer: schwammartiger Knochen an der Stirn, "
"ein Zungenbein, das einmal um den ganzen Schädel läuft, und ein Gehirn, das "
"so eng sitzt, dass es nicht schwingen kann. Getrommelt wird übrigens nicht "
"zum Fressen, sondern zum Reden — es ist sein Reviergesang."
),
verwechslung=["gruenspecht", "kleiber"],
),
"gruenspecht": Info(
typ="specht", kategorie="Der Ameisenjäger",
gewicht_g=180, spannweite_cm=45, zug="stand",
nahrung="Fast ausschließlich Ameisen, Sommer wie Winter",
lebensraum="Streuobstwiesen, Parks, Gärten mit kurzem Rasen",
stimme="Ein lautes, gellendes Lachen — der „Yaffle“, weit über den Garten hörbar",
text=(
"Groß, grün, mit roter Kappe und schwarzer Gesichtsmaske. Beim Männchen hat "
"der Bartstreif einen roten Kern, beim Weibchen ist er ganz schwarz."
"\n\n"
"Er verhält sich für einen Specht untypisch: Statt Holz zu hacken sitzt er "
"meist auf dem Rasen und gräbt Ameisennester auf. Seine Zunge ist bis zu "
"zehn Zentimeter lang, klebrig und mit Widerhaken besetzt. Im Winter schlägt "
"er Tunnel in den Schnee, um an die Nester zu kommen — harte Frostperioden "
"sind für ihn gefährlicher als für die meisten anderen Arten."
),
verwechslung=["buntspecht"],
),
"elster": Info(
typ="rabenvogel", kategorie="Die Selbsterkennerin",
gewicht_g=220, spannweite_cm=55, zug="stand",
nahrung="Allesfresser — Insekten, Aas, Abfall, Eier, Früchte",
lebensraum="Siedlungen, offene Landschaft mit einzelnen Bäumen",
stimme="Ein hartes, ratterndes „schackerack“",
text=(
"Schwarzweiß mit langem Schwanz, der im Sonnenlicht metallisch grün und "
"violett schillert. Unverwechselbar."
"\n\n"
"Elstern bestehen den Spiegeltest: Man klebt ihnen einen farbigen Punkt an "
"den Hals, den sie nur im Spiegel sehen können — und sie versuchen, ihn zu "
"entfernen. Sie erkennen also sich selbst. Das schaffen sonst nur "
"Menschenaffen, Delfine und Elefanten. Der Ruf als Eierdieb ist übrigens "
"übertrieben: Untersuchungen zeigen, dass Elstern die Singvogelbestände nicht "
"messbar drücken — das tun Lebensraumverlust und Hauskatzen."
),
verwechslung=["eichelhaeher", "dohle"],
),
"eichelhaeher": Info(
typ="rabenvogel", kategorie="Der Waldgärtner",
gewicht_g=170, spannweite_cm=55, zug="stand",
nahrung="Eicheln und Bucheckern, im Sommer Insekten und Eier",
lebensraum="Laub- und Mischwälder, im Winter auch Gärten",
stimme="Ein rauer, kreischender Schrei — die Alarmanlage des Waldes",
text=(
"Rosabraun mit schwarzweißem Kopf, weißem Bürzel und einem leuchtend "
"blau-schwarz gebänderten Flügelfleck, der zu den schönsten Federn Europas "
"gehört."
"\n\n"
"Im Herbst vergräbt ein einzelner Eichelhäher bis zu 5.000 Eicheln als "
"Wintervorrat, jede an einer anderen Stelle. Die vergessenen keimen — er ist "
"damit einer der wichtigsten Waldpflanzer überhaupt und trägt Eichen "
"kilometerweit bergauf, wohin sie von selbst nie kämen. Außerdem ist er ein "
"guter Imitator und ahmt Mäusebussarde nach."
),
verwechslung=["elster"],
),
"dohle": Info(
typ="rabenvogel", kategorie="Die mit den blauen Augen",
gewicht_g=240, spannweite_cm=70, zug="stand",
nahrung="Insekten, Samen, Abfälle — sehr anpassungsfähig",
lebensraum="Kirchtürme, Ruinen, alte Bäume, Städte",
stimme="Ein helles, kurzes „kja“ — daher der wissenschaftliche Name monedula",
text=(
"Der kleinste heimische Rabenvogel: schiefergrau mit schwarzer Kappe und "
"silbergrauem Nacken. Das Auffälligste sind die Augen — hell eisblau, "
"einmalig unter unseren Rabenvögeln."
"\n\n"
"Dohlen sind streng monogam und bleiben ein Leben lang zusammen, oft über "
"zehn Jahre. Innerhalb der Kolonie gibt es eine feste Rangordnung, und ein "
"Weibchen übernimmt beim Verpaaren automatisch den Rang seines Partners. "
"Konrad Lorenz erforschte an Dohlen soziales Verhalten und beschrieb sie als "
"die politischsten Vögel, die er kenne."
),
verwechslung=["rabenkraehe", "elster"],
),
"rabenkraehe": Info(
typ="rabenvogel", kategorie="Die Werkzeugmacherin",
gewicht_g=520, spannweite_cm=98, zug="stand",
nahrung="Wirklich alles — Aas, Insekten, Getreide, Abfall, Eier",
lebensraum="Überall: Felder, Wälder, Städte, Parkplätze",
stimme="Das bekannte raue „krah-krah-krah“, meist dreifach",
text=(
"Komplett schwarz, kräftiger Schnabel, im Flug an den gefingerten "
"Flügelspitzen erkennbar. Nicht zu verwechseln mit der Saatkrähe, die einen "
"hellen, unbefiederten Schnabelgrund hat."
"\n\n"
"Krähen gehören zu den intelligentesten Tieren überhaupt. Sie legen Nüsse auf "
"Straßen, damit Autos sie knacken, und holen sie an Fußgängerampeln bei Rot "
"ab. Sie erkennen einzelne Menschengesichter wieder, merken sich, wer ihnen "
"übel wollte, und geben diese Information an andere Krähen weiter — auch an "
"solche, die nie dabei waren."
),
verwechslung=["dohle", "kolkrabe"],
),
"kolkrabe": Info(
typ="rabenvogel", kategorie="Der größte Singvogel",
gewicht_g=1200, spannweite_cm=125, zug="stand",
nahrung="Aas, Kleinsäuger, Eier, Abfall",
lebensraum="Wälder, Felsen, offene Landschaft — meidet Siedlungen eher",
stimme="Ein tiefes, sonores „korrk“, ganz anders als das Krächzen der Krähe",
text=(
"Deutlich größer als eine Krähe, fast bussardgroß, mit keilförmigem Schwanz, "
"einem massigen Schnabel und einem struppigen Kehlgefieder. Im Flug segelt "
"und rollt er, was Krähen nicht tun."
"\n\n"
"Der größte Singvogel der Welt — Rabenvögel gehören trotz ihrer Größe zu den "
"Singvögeln. Kolkraben lösen mehrstufige Aufgaben, die vierjährige Kinder "
"überfordern, planen für die Zukunft und tauschen Gegenstände gegen bessere "
"ein. In Deutschland war er im 19. Jahrhundert fast ausgerottet und hat sich "
"erst in den letzten Jahrzehnten wieder ausgebreitet."
),
verwechslung=["rabenkraehe"],
),
"ringeltaube": Info(
typ="taube", kategorie="Die Größte ihrer Art",
gewicht_g=500, spannweite_cm=76, zug="teil",
nahrung="Sämereien, Getreide, Knospen, Eicheln",
lebensraum="Wälder, Felder, zunehmend auch Stadtparks und Gärten",
stimme="Ein dumpfes, fünfsilbiges „ruh-ruuh-ruh, ru-ruh“, oft im Sommer den ganzen Tag",
text=(
"Die größte heimische Taube. Graublau mit rosa Brust, einem weißen Fleck "
"seitlich am Hals und im Flug auffälligen weißen Flügelbinden."
"\n\n"
"Der weiße Halsfleck — der „Ring\“ im Namen — fehlt Jungvögeln komplett und "
"wächst erst im ersten Winter. Ringeltauben füttern ihre Jungen mit "
"Kropfmilch, einem nährstoffreichen Sekret aus der Kropfwand. Beide "
"Elternteile bilden sie, und das macht die Taube unabhängig von der "
"Insektensaison: Sie brütet deshalb bis in den Oktober."
),
verwechslung=["tuerkentaube"],
),
"tuerkentaube": Info(
typ="taube", kategorie="Die Eroberin",
gewicht_g=200, spannweite_cm=50, zug="stand",
nahrung="Sämereien, Getreide, Brotreste",
lebensraum="Dörfer, Stadtränder, Gärten mit Nadelbäumen",
stimme="Ein monotones, dreisilbiges „du-duuu-du“, endlos wiederholt",
text=(
"Schlank, sandfarben-beige, mit einem schmalen schwarzen Halbring im Nacken. "
"Deutlich kleiner und heller als die Ringeltaube."
"\n\n"
"Eine der schnellsten Arealausweitungen, die je dokumentiert wurde: 1930 war "
"sie in Europa auf den Balkan beschränkt, 1945 erreichte sie Deutschland, "
"1960 Großbritannien. In dreißig Jahren hat sie einen Kontinent besiedelt, "
"ohne dass jemand sie ausgesetzt hätte. Warum sie plötzlich loszog, ist bis "
"heute nicht abschließend geklärt."
),
verwechslung=["ringeltaube"],
),
"rauchschwalbe": Info(
typ="schwalbe", kategorie="Die Glücksbringerin",
gewicht_g=19, spannweite_cm=33, zug="zug",
nahrung="Fluginsekten, ausschließlich in der Luft gefangen",
lebensraum="Ställe, Scheunen, offene Landschaft mit Vieh",
stimme="Ein zwitscherndes Geplauder, das mit einem schnarrenden Laut endet",
text=(
"Metallisch blauschwarzer Rücken, rostrote Kehle und Stirn, tief gegabelter "
"Schwanz mit langen Spießen. Sie brütet innen — in Ställen und Scheunen, "
"nicht an der Außenwand."
"\n\n"
"Rauchschwalben tun fast alles im Flug: fressen, trinken, baden, sogar paaren. "
"Die Länge der Schwanzspieße zeigt die Fitness an; Weibchen wählen Männchen "
"mit den längsten. Ihr Bestand hängt direkt an der Landwirtschaft — ohne "
"offene Ställe und ohne Insekten über den Weiden verschwindet sie, und "
"genau das passiert seit Jahrzehnten."
),
verwechslung=["mehlschwalbe", "mauersegler"],
),
"mehlschwalbe": Info(
typ="schwalbe", kategorie="Die Lehmmaurerin",
gewicht_g=18, spannweite_cm=28, zug="zug",
nahrung="Fluginsekten, meist in größerer Höhe als die Rauchschwalbe",
lebensraum="Hauswände, Brücken, Kolonien an Gebäuden",
stimme="Ein trockenes, schnarrendes Zwitschern",
text=(
"Blauschwarzer Rücken, komplett weiße Unterseite und ein auffälliger weißer "
"Bürzel, der sie im Flug sicher von der Rauchschwalbe trennt. Der Schwanz ist "
"nur leicht gegabelt, ohne Spieße."
"\n\n"
"Ihr geschlossenes Nest unter dem Dachvorsprung besteht aus rund tausend "
"einzeln herangeflogenen Schlammkügelchen. Pfützen und offene Lehmstellen "
"sind deshalb ihre Lebensgrundlage — in versiegelten Neubaugebieten findet "
"sie schlicht kein Baumaterial mehr. Ihre Nester stehen ganzjährig unter "
"Schutz, auch wenn sie leer sind."
),
verwechslung=["rauchschwalbe", "mauersegler"],
),
"mauersegler": Info(
typ="schwalbe", kategorie="Der Dauerflieger",
gewicht_g=42, spannweite_cm=42, zug="zug",
nahrung="Luftplankton — kleine Insekten und Spinnen aus der Höhe",
lebensraum="Hohe Gebäude, Nischen unter Dächern, Städte",
stimme="Ein schrilles, langgezogenes „sriiih“, im Sommerabend über den Dächern",
text=(
"Sichelförmige Flügel, kurzer gegabelter Schwanz, einheitlich dunkelbraun. "
"Trotz der Ähnlichkeit ist er keine Schwalbe, sondern gehört zu den Seglern — "
"seine nächsten Verwandten sind Kolibris."
"\n\n"
"Nach dem Ausfliegen bleibt ein junger Mauersegler bis zu zehn Monate "
"ununterbrochen in der Luft: Er frisst, trinkt, paart sich und schläft im "
"Flug, mit jeweils einer Gehirnhälfte. Erst zum ersten Brüten landet er "
"wieder. Seine Beine sind so verkümmert, dass er vom flachen Boden kaum "
"starten kann. Bei uns ist er nur rund 100 Tage im Jahr, Anfang Mai bis "
"Anfang August."
),
verwechslung=["rauchschwalbe", "mehlschwalbe"],
),
"bachstelze": Info(
typ="stelze", kategorie="Die Wipperin",
gewicht_g=22, spannweite_cm=28, zug="teil",
nahrung="Insekten, die sie laufend vom Boden aufpickt",
lebensraum="Offene Flächen, Ufer, Parkplätze, Hausdächer",
stimme="Ein helles „ziwitt“, meist im wellenförmigen Flug",
text=(
"Schwarzweiß mit langem Schwanz und grauem Rücken. Sie läuft trippelnd über "
"offene Flächen, statt zu hüpfen, und wippt dabei unablässig mit dem Schwanz."
"\n\n"
"Warum sie wippt, ist bis heute nicht sicher geklärt. Die überzeugendste "
"Erklärung: Es signalisiert Fressfeinden „ich habe dich gesehen, ein Angriff "
"lohnt nicht“. Im Winter schlafen Bachstelzen gern gemeinsam an warmen "
"Orten — Gewächshäuser, Industrieanlagen, beleuchtete Innenstädte."
),
verwechslung=[],
),
"goldammer": Info(
typ="ammer", kategorie="Die mit dem Merksatz",
gewicht_g=28, spannweite_cm=26, zug="stand",
nahrung="Sämereien, im Sommer Insekten",
lebensraum="Feldränder, Hecken, offene Landschaft mit Büschen",
stimme="„zi-zi-zi-zi-zi-dääh“ — merkbar als „wie-wie-wie hab ich dich lieb“",
text=(
"Das Männchen hat einen leuchtend gelben Kopf und eine gelbe Brust, dazu "
"einen rotbraunen Bürzel. Weibchen sind matter und stärker gestrichelt."
"\n\n"
"Sie singt von exponierten Warten aus, oft stundenlang bei größter "
"Mittagshitze, wenn sonst alles schweigt. Ihr Gesang endet mit einem "
"gedehnten Ton, der die Art unverwechselbar macht. Am Futterhaus ist sie "
"selten — sie frisst lieber am Boden darunter und braucht offene Felder in "
"der Nähe."
),
verwechslung=["gruenfink"],
),
"sperber": Info(
typ="greifvogel", kategorie="Der Überraschungsjäger",
gewicht_g=200, spannweite_cm=65, zug="teil",
nahrung="Kleinvögel, im Flug erbeutet",
lebensraum="Wälder, Gärten, Siedlungen — jagt gezielt an Futterstellen",
stimme="Ein schnelles „kew-kew-kew“, meist nur in der Brutzeit",
text=(
"Kurze, breite Flügel und ein langer Schwanz — gebaut, um zwischen Ästen zu "
"wenden, nicht um schnell geradeaus zu fliegen. Männchen sind mit 150 Gramm "
"deutlich kleiner als Weibchen mit bis zu 300 Gramm, ein extremer Unterschied "
"selbst für Greifvögel."
"\n\n"
"Wenn am Futterhaus schlagartig alle Vögel weg sind und es minutenlang still "
"bleibt, war meist ein Sperber da. Er jagt aus der Deckung, nutzt Hecken und "
"Hausecken als Sichtschutz und ist erst im letzten Moment zu sehen. Das ist "
"kein Grund, die Fütterung einzustellen — er gehört dazu, und ein Futterhaus "
"mit Deckung in der Nähe gibt den Kleinen eine faire Chance."
),
verwechslung=["turmfalke", "maeusebussard"],
),
"turmfalke": Info(
typ="greifvogel", kategorie="Der Rüttler",
gewicht_g=200, spannweite_cm=76, zug="stand",
nahrung="Mäuse, dazu Großinsekten und kleine Vögel",
lebensraum="Offene Landschaft, Kirchtürme, Hochhäuser, Autobahnränder",
stimme="Ein schnelles, schrilles „ki-ki-ki-ki“",
text=(
"Rostbrauner Rücken mit dunklen Flecken, spitze Flügel, langer Schwanz. Das "
"Männchen hat einen grauen Kopf und einen grauen Schwanz mit schwarzer "
"Endbinde, das Weibchen ist durchgehend braun gebändert."
"\n\n"
"Sein Rüttelflug ist unverwechselbar: Er steht auf der Stelle in der Luft, "
"den Kopf absolut ruhig, während der Körper arbeitet. Was er dabei sucht, "
"können wir nicht sehen — Mäuseurin reflektiert UV-Licht, und Turmfalken "
"sehen UV. Für sie sind die Wechsel der Mäuse leuchtende Spuren im Gras."
),
verwechslung=["sperber", "maeusebussard"],
),
"maeusebussard": Info(
typ="greifvogel", kategorie="Der Häufigste am Himmel",
gewicht_g=900, spannweite_cm=125, zug="stand",
nahrung="Mäuse, Regenwürmer, Aas — oft am Straßenrand",
lebensraum="Feldgehölze, Waldränder, überall in der Kulturlandschaft",
stimme="Ein miauendes „hiäh“, das oft für einen Rotmilan gehalten wird",
text=(
"Breite Flügel, kurzer runder Schwanz, in der Färbung extrem variabel — von "
"fast weiß bis fast schwarz. Kreisend über dem Feld mit leicht V-förmig "
"angehobenen Flügeln ist er der häufigste Greifvogel Deutschlands."
"\n\n"
"Im Vogelhaus sitzt er natürlich nie. Wenn ihn die Kamera erwischt, dann "
"beim Überflug oder auf einem Zaunpfahl in der Nähe. Genau deshalb steht er "
"in dieser Sammlung weit oben: nicht weil er selten ist, sondern weil er "
"selten vor diese Linse gerät."
),
verwechslung=["sperber", "turmfalke"],
),
"steinkauz": Info(
typ="eule", kategorie="Der Vogel der Athene",
gewicht_g=180, spannweite_cm=56, zug="stand",
nahrung="Mäuse, Käfer, Regenwürmer",
lebensraum="Streuobstwiesen, Kopfweiden, alte Scheunen",
stimme="Ein klagendes, aufsteigendes „guhk“, nachts weit hörbar",
text=(
"Klein, gedrungen, mit flachem Kopf und leuchtend gelben Augen unter "
"auffälligen weißen Überaugenstreifen, die ihm einen strengen Blick geben. "
"Nur gut zwanzig Zentimeter groß."
"\n\n"
"Er war der heilige Vogel der Athene und ist deshalb bis heute auf der "
"griechischen Ein-Euro-Münze zu sehen — „Eulen nach Athen tragen\“ geht auf "
"ihn zurück. Anders als die meisten Eulen ist er oft auch tagsüber aktiv. In "
"Deutschland ist er stark gefährdet, weil ihm Streuobstwiesen und alte "
"Kopfweiden fehlen. Vor der Kamera ist er der größte denkbare Fang."
),
verwechslung=[],
),
}
# fmt: on
+810
View File
@@ -0,0 +1,810 @@
"""English species profiles.
Deliberately written rather than translated. A profile that reads like a
translation reads badly — the German text leans on words like „Futterhaus"
and „Streuobstwiese" that have no clean equivalent, and the facts worth
telling differ slightly for an English-speaking reader.
Same shape as ``species_info.py``: category, two paragraphs, and the short
fields. Keys must match the slugs in ``species_de.py``.
"""
from __future__ import annotations
from dataclasses import dataclass
# Type names as shown in the interface. The keys match TYPES in species_info.
TYPE_NAMES_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",
}
ZUG_EN = {
"stand": "Resident",
"teil": "Partial migrant",
"zug": "Migrant",
}
RARITY_EN = {
"haeufig": "Common",
"gelegentlich": "Occasional",
"selten": "Rare",
"episch": "Epic",
"legendaer": "Legendary",
}
@dataclass(frozen=True)
class InfoEN:
kategorie: str
nahrung: str
lebensraum: str
stimme: str
text: str
# fmt: off
INFO_EN: dict[str, InfoEN] = {
"kohlmeise": InfoEN(
kategorie="The Loudest in the Garden",
nahrung="Caterpillars and insects in summer, seeds and fat in winter",
lebensraum="Gardens, parks and woodland — anywhere with a cavity to nest in",
stimme="A bright “tea-cher, tea-cher”, like a squeaky bicycle pump",
text=(
"The bird most people notice first at a feeder. It is the largest tit in "
"Europe, unmistakable with its black head, white cheeks and yellow belly. "
"The black stripe running down that belly tells you the sex: broad and "
"reaching between the legs on a male, narrow and often broken on a female."
"\n\n"
"Great Tits have a remarkably large vocabulary — more than forty distinct "
"calls have been described, which is why birders say that anything you "
"cannot place is probably a Great Tit. They are also problem solvers. In "
"1920s Britain they learned to pierce the foil caps of doorstep milk "
"bottles, and the trick spread across the country as one bird copied another."
),
),
"blaumeise": InfoEN(
kategorie="The Ultraviolet Signal",
nahrung="Aphids and caterpillars, seeds and nuts in winter",
lebensraum="Broadleaf woodland and mature gardens",
stimme="A quick, tinkling “tsee-tsee-tsirrr”",
text=(
"Smaller and rounder than a Great Tit, with a blue cap, blue wings and a "
"dark line through the eye that looks like a bandit's mask. The belly is "
"yellow, though paler than its larger cousin's."
"\n\n"
"What we see is only half the story. The blue crown reflects ultraviolet "
"light, and Blue Tits can see into the ultraviolet. To another Blue Tit "
"that patch blazes far brighter than it does to us — and the brighter it "
"is, the more attractive. Females choose mates partly on a colour that "
"human eyes cannot detect at all."
),
),
"tannenmeise": InfoEN(
kategorie="The Hoarder",
nahrung="Spruce seeds and small insects",
lebensraum="Conifer woodland, gardens in winter",
stimme="A high, two-note “see-too see-too”, faster than a Great Tit",
text=(
"The smallest tit here at just nine grams. The head is black like a Great "
"Tit's, but with a white patch on the nape — that is the reliable mark. "
"The underparts are buff rather than yellow."
"\n\n"
"Coal Tits cache food for winter, hiding seeds one at a time in bark "
"crevices, and remember hundreds of sites for weeks. The part of the brain "
"responsible measurably grows in autumn and shrinks again in spring."
),
),
"schwanzmeise": InfoEN(
kategorie="The Cotton Ball on a Stick",
nahrung="Tiny insects, spiders, aphid eggs",
lebensraum="Scrub, hedgerows and woodland edges",
stimme="A thin “srrih-srrih”, usually from a whole passing party",
text=(
"A ball of feathers with a tail longer than the rest of the bird. "
"Long-tailed Tits almost never travel alone — where there is one, eight "
"more are on the way."
"\n\n"
"Their nest is among the finest structures built by any European bird: a "
"closed pouch of moss and lichen lined with up to two thousand feathers "
"and bound with spider silk, which stretches as the chicks grow. In winter "
"they roost pressed together in a row along a branch — eight grams cannot "
"survive a frosty night any other way."
),
),
"haussperling": InfoEN(
kategorie="The Lodger",
nahrung="Seeds and scraps, insects for the chicks",
lebensraum="Villages and cities — almost always alongside people",
stimme="The familiar “cheep”, usually a chorus from inside a hedge",
text=(
"The house sparrow. Males wear a grey cap and a black bib; females are "
"plain brown with a pale stripe behind the eye. The species has lived "
"alongside humans for over ten thousand years and is scarcely found "
"anywhere people are not."
"\n\n"
"Despite its abundance it is in trouble. Renovated facades leave no "
"crevices to nest in and sealed surfaces produce no insects for the young. "
"In some European cities the population has more than halved since 1990."
),
),
"feldsperling": InfoEN(
kategorie="The One With the Ear Spot",
nahrung="Seeds and grain, insects while breeding",
lebensraum="Field margins, orchards, village edges",
stimme="Like a House Sparrow but higher and sharper",
text=(
"At a glance a sparrow, at second glance considerably smarter: a chestnut "
"cap instead of grey, and a clean black spot in the middle of a white "
"cheek. No other bird here has that ear patch."
"\n\n"
"Unlike the House Sparrow, males and females look identical. The Tree "
"Sparrow is shyer and keeps its distance from buildings — if one turns up "
"at your feeder, there is usually farmland or an old orchard nearby."
),
),
"amsel": InfoEN(
kategorie="The Early Riser",
nahrung="Earthworms and insects, berries and windfall fruit in autumn",
lebensraum="Gardens, parks and woodland — one of the great adapters",
stimme="A rich, fluting song from a high perch, often before sunrise",
text=(
"Males are jet black with a bright yellow bill and eye-ring; females are "
"an unassuming dark brown with a mottled breast. Anyone who sees a “brown "
"blackbird” is looking at a female, not another species."
"\n\n"
"The Blackbird was originally a shy forest bird. It only moved into towns "
"in the nineteenth century, and city birds now behave measurably "
"differently from their woodland relatives: they sing at a higher pitch to "
"cut through traffic noise, start earlier because street lights bring the "
"day forward, and let people approach within two metres."
),
),
"singdrossel": InfoEN(
kategorie="The Snail Smasher",
nahrung="Snails, worms and berries",
lebensraum="Woodland with dense undergrowth, larger gardens",
stimme="Short phrases, each repeated two to four times — the giveaway",
text=(
"Brown above, cream below with dark arrowhead spots. Smaller and neater "
"than a Blackbird, and a warmer shade of brown."
"\n\n"
"Song Thrushes use a tool, or more precisely an anvil. They carry snails "
"to a favoured stone and hammer the shells open on it. A stone ringed with "
"broken snail shells is a thrush's anvil, and the same birds often use it "
"for years."
),
),
"wacholderdrossel": InfoEN(
kategorie="The Winter Visitor",
nahrung="Berries, fallen fruit, worms",
lebensraum="Open country; large winter flocks in gardens",
stimme="A harsh “shack-shack-shack”, usually in flight",
text=(
"Grey head, chestnut back, spotted yellowish breast. In winter they "
"descend on apple trees and berry bushes in flocks — twenty thrushes "
"appearing in a garden in January are almost certainly Fieldfares."
"\n\n"
"They defend their nests with a method worth seeing once: the whole colony "
"bombards crows and raptors with droppings, aimed and in volume. It mats "
"the plumage so thoroughly that an attacker can be grounded."
),
),
"star": InfoEN(
kategorie="The Impressionist",
nahrung="Insects and grubs, masses of berries in autumn",
lebensraum="Open country with trees, towns, pasture",
stimme="A wild medley of whistles, rattles and borrowed sounds",
text=(
"Black from a distance; up close a metallic green and purple sheen flecked "
"with pale spots. Heavily spotted in winter, the pale feather tips wear "
"away by spring and the bird turns almost uniformly glossy."
"\n\n"
"Starlings mimic whatever they hear: other species, phone ringtones, car "
"alarms, lawnmowers. Their murmurations are famous — tens of thousands of "
"birds moving as one cloud. Each individual tracks only its seven nearest "
"neighbours; the shape of the whole flock emerges from that alone."
),
),
"rotkehlchen": InfoEN(
kategorie="The Territory Holder",
nahrung="Insects, worms and berries — follows the spade",
lebensraum="Undergrowth, hedges, gardens with cover",
stimme="A wistful, silvery song, sung through winter and at dusk",
text=(
"The orange-red breast runs up into the face; the rest is olive-brown. "
"Males and females are identical. Young Robins have no red at all, which "
"keeps their own parents from treating them as rivals."
"\n\n"
"For despite the friendly reputation, Robins are uncompromising. They hold "
"territory year-round and will attack their own reflection. In experiments "
"they attack a bare tuft of red feathers. Up to a tenth of adult Robins "
"die in fights with their own kind."
),
),
"hausrotschwanz": InfoEN(
kategorie="The Gutter Dweller",
nahrung="Insects, some berries in autumn",
lebensraum="Buildings, quarries, industrial sites — originally cliffs",
stimme="A scratchy song that sounds like gravel being shaken",
text=(
"Dark grey to almost black with a rust-red tail that it shivers "
"constantly. That shivering is the surest way to name it at fifty metres. "
"Females are plain grey-brown but share the same red tail."
"\n\n"
"It originally nested on mountain cliffs. Houses, barns and factory halls "
"are cliffs with better engineering, and most of the population now lives "
"in settlements. It is among the first to sing in the morning, often "
"before the Blackbird."
),
),
"gartenrotschwanz": InfoEN(
kategorie="The Long-Haul Flier",
nahrung="Insects taken in flight or from the ground",
lebensraum="Old orchards, open woodland, mature gardens",
stimme="A short, melancholy phrase with a distinctive twist at the end",
text=(
"Far more colourful than its cousin: the male has a black throat, white "
"forehead, grey back and a burning orange breast. A bird worth going out "
"of your way for."
"\n\n"
"It winters in the Sahel south of the Sahara — some five thousand "
"kilometres each way for a bird of fifteen grams. Numbers have fallen "
"sharply because two things failed at once: old orchard trees with "
"cavities here, and rainfall in the Sahel there."
),
),
"trauerschnaepper": InfoEN(
kategorie="The Nest Box Tenant",
nahrung="Flying insects caught on the wing",
lebensraum="Broadleaf woodland, parks, gardens with nest boxes",
stimme="A simple, slightly wooden “zick-zick tru-tru”",
text=(
"In spring the male is crisply black and white with a white forehead spot "
"and a bold wing panel. Females and autumn males are brown — the same "
"species, an entirely different impression."
"\n\n"
"It takes to nest boxes more readily than almost any other species, which "
"has made it one of the best-studied birds in Europe. Its problem is "
"climate change: it times its departure from Africa by day length, but the "
"caterpillars its chicks depend on now hatch earlier in the warmth. It "
"increasingly arrives after its own banquet has ended."
),
),
"grauschnaepper": InfoEN(
kategorie="The Sit-and-Wait Hunter",
nahrung="Flies, midges and moths — taken exclusively in flight",
lebensraum="Woodland edges, gardens, churchyards",
stimme="A thin, unremarkable “tsri” that is easy to miss",
text=(
"Nondescript grey-brown with fine streaking on the breast. Easy to "
"overlook, but the behaviour gives it away: it sits upright on an open "
"perch, launches, snaps up a fly and returns to exactly the same twig. "
"Again and again."
"\n\n"
"You can sometimes hear the bill snap shut. It is among the last migrants "
"to return in spring — rarely before mid-May, because before then there "
"simply is not enough flying."
),
),
"buchfink": InfoEN(
kategorie="The One With an Accent",
nahrung="Seeds, insects for the chicks",
lebensraum="Woodland, gardens and parks — one of Europe's commonest birds",
stimme="A descending rattle finishing with a flourish",
text=(
"The male has a blue-grey cap, a russet breast and two bold white wing "
"bars. Females are olive-brown but show the same white bars, which is how "
"you name them in flight."
"\n\n"
"Chaffinches have regional dialects. A bird from Bavaria sings measurably "
"differently from one in Schleswig-Holstein, and the boundaries between "
"dialects can be drawn on a map. Young finches learn the song from their "
"neighbours; one raised in isolation manages only a stammer."
),
),
"gruenfink": InfoEN(
kategorie="The Seed Cracker",
nahrung="Large, hard seeds — sunflower hearts above all",
lebensraum="Gardens, parks, churchyards",
stimme="A drawn-out, nasal “dzweee” like a slow squeak",
text=(
"Olive-green with yellow flashes along wing and tail that catch the light "
"in flight. Males are a stronger yellow-green, females greyer."
"\n\n"
"Its heavy conical bill cracks sunflower seeds that defeat the tits. At a "
"feeder it is dominant and drives smaller birds off the best spot. One "
"caution: Greenfinches are particularly prone to trichomonosis, which "
"spreads through soiled food and water. Cleaning feeders regularly matters."
),
),
"stieglitz": InfoEN(
kategorie="The Thistle Specialist",
nahrung="Thistle and teasel seeds, alder and birch seed",
lebensraum="Rough ground with tall herbs, orchards, wilder gardens",
stimme="A bright, tinkling call, cheerful and unmistakable",
text=(
"The most colourful finch here: a scarlet face, a black-and-white head and "
"a broad golden wing bar. Males and females look nearly the same."
"\n\n"
"Its long, fine bill reaches seeds inside thistles and teasels that "
"nothing else can get at. So if you want Goldfinches, the single most "
"useful thing is to leave dead flower heads standing over winter. A tidy "
"garden is an empty larder to them."
),
),
"erlenzeisig": InfoEN(
kategorie="The Irruption Guest",
nahrung="Alder and birch seed, spruce seed",
lebensraum="Conifer and mixed woodland; alders and feeders in winter",
stimme="A wheezy “tsooee” and constant chatter within the flock",
text=(
"Small, yellow-green and finely streaked, the male with a black cap. They "
"hang upside down from alder cones the way tits hang from a fat ball."
"\n\n"
"Some winters they are everywhere, others almost absent. It depends on the "
"spruce and alder seed crop further north: when it fails, whole "
"populations move south. Such irruption years cannot be predicted."
),
),
"gimpel": InfoEN(
kategorie="The Bullfinch",
nahrung="Buds, seeds and berries — fruit tree buds in spring",
lebensraum="Conifer woodland, gardens with hedges, churchyards",
stimme="A soft, mournful “deu” — quiet and easy to miss",
text=(
"The male carries the strongest colour of the whole winter half-year: a "
"glowing rose-red breast, black cap, grey back and a white rump. Females "
"are grey-brown instead of red but marked identically."
"\n\n"
"Bullfinches are strikingly calm and not especially shy — they can be "
"watched at length and rarely flush in a hurry. Pairs often stay together "
"for life and keep close contact even within a winter flock."
),
),
"girlitz": InfoEN(
kategorie="The Smallest",
nahrung="Very small seeds of herbs and grasses",
lebensraum="Settlements with conifers, vineyards, churchyards",
stimme="A hurried, jangling whirr like clockwork running down",
text=(
"Europe's smallest finch at eleven grams. Yellow-green, heavily streaked, "
"with a very short blunt bill — that is what separates it from a Siskin."
"\n\n"
"It spread north from the Mediterranean only in the nineteenth century. "
"Its song flight is worth watching: it flutters over its territory like a "
"butterfly, with slow, exaggerated wingbeats."
),
),
"kleiber": InfoEN(
kategorie="The Upside-Down Walker",
nahrung="Insects from bark, nuts and seeds in winter",
lebensraum="Mature broadleaf woodland, parks, gardens with big trees",
stimme="A loud, ringing “twit-twit-twit”, carrying surprisingly far",
text=(
"Blue-grey above, buff-orange below, with a black eye-stripe, a short tail "
"and a strong pointed bill. It looks like a small woodpecker without the "
"prop of a stiff tail."
"\n\n"
"It is the only bird here that can walk head-first down a tree trunk — "
"woodpeckers and treecreepers only manage upwards. Its German name comes "
"from plastering: if the entrance to its nest hole is too wide, it walls "
"it in with mud to the size it wants."
),
),
"zaunkoenig": InfoEN(
kategorie="The Loudest per Gram",
nahrung="Spiders and small insects from ground cover",
lebensraum="Dense scrub, brush piles, stream banks, wild garden corners",
stimme="A startlingly loud, ringing trill — up to 90 decibels",
text=(
"Tiny, round and rust-brown, with the tail cocked steeply upward. Nine "
"grams, and one of the loudest birds in Europe — relative to body size, "
"the loudest of all."
"\n\n"
"The male builds several nests and leads the female round so she can pick "
"one; the rest serve as winter roosts. In hard frost more than a dozen "
"Wrens have been found packed into a single such ball. It lives in ground "
"cover, so a garden cleared of every bramble loses it."
),
),
"heckenbraunelle": InfoEN(
kategorie="The Understated One",
nahrung="Small insects, fine seeds in winter",
lebensraum="Hedges, scrub, densely planted gardens",
stimme="A quick, even warble with no clear melody",
text=(
"At first glance a sparrow; at second glance far finer — a slender bill "
"instead of a stout cone, a grey head and breast, a streaked brown back. "
"It creeps mouse-like along the ground beneath bushes."
"\n\n"
"Behind that grey exterior sits the most complicated love life in the "
"garden. Dunnocks often live in threes or larger arrangements, females "
"with several males and the reverse. Males feed the chicks in proportion "
"to how likely they are to be the father."
),
),
"moenchsgrasmuecke": InfoEN(
kategorie="The Black Cap",
nahrung="Insects in summer, berries and elder in autumn",
lebensraum="Undergrowth, hedges, gardens with shrubs",
stimme="Quiet chatter building to a clear, fluting finish",
text=(
"Grey and unremarkable except for the cap: black on males, red-brown on "
"females. A rare case where the sexes are unambiguous and both handsome."
"\n\n"
"Its migration is changing in front of us. A growing share of the central "
"European population now winters in Britain rather than Spain, drawn by "
"garden feeding. Those birds return earlier in spring and take the best "
"territories. Evolution at speed, set off by bird feeders."
),
),
"zilpzalp": InfoEN(
kategorie="The One That Says Its Name",
nahrung="Tiny insects and larvae gleaned from foliage",
lebensraum="Woodland with undergrowth, gardens, parks",
stimme="Exactly what it is named for: “chiff-chaff-chiff-chaff”",
text=(
"A tiny olive-green bird with no obvious features — barely separable from "
"a Willow Warbler by eye. There is one mark: dark legs. The Willow Warbler "
"has pale ones."
"\n\n"
"Fortunately you do not need to see it. The song is so distinctive that "
"the bird is named after it in several languages, and it is among the "
"first migrants back in spring. The first chiff-chaff in March means "
"winter is over."
),
),
"buntspecht": InfoEN(
kategorie="The Drummer",
nahrung="Grubs from deadwood, conifer seeds, fat in winter",
lebensraum="All woodland types, parks, gardens with old trees",
stimme="A sharp “kick”, and in spring a fast drum roll on dead wood",
text=(
"Black and white with a vivid red vent. The male has a red patch on the "
"nape, the female none; juveniles have an entirely red crown."
"\n\n"
"Drumming, it strikes up to twenty times a second at a deceleration of "
"around a thousand times gravity. The skull works as a shock absorber: "
"spongy bone at the forehead, a hyoid that wraps right around the "
"braincase, and a brain packed so tightly it cannot slosh. The drumming "
"is not feeding, incidentally — it is song, and it marks territory."
),
),
"gruenspecht": InfoEN(
kategorie="The Ant Hunter",
nahrung="Ants, almost exclusively, summer and winter",
lebensraum="Old orchards, parks, gardens with short grass",
stimme="A loud ringing laugh — the “yaffle”, audible across the garden",
text=(
"Large and green with a red crown and a black facial mask. The male's "
"moustache has a red centre; the female's is solid black."
"\n\n"
"It behaves unusually for a woodpecker: rather than chiselling wood it "
"spends most of its time on the lawn, digging out ant nests. Its tongue is "
"up to ten centimetres long, sticky and barbed. In winter it tunnels "
"through snow to reach nests, which makes hard frosts more dangerous for "
"it than for most species."
),
),
"elster": InfoEN(
kategorie="The Self-Recogniser",
nahrung="Anything — insects, carrion, scraps, eggs, fruit",
lebensraum="Settlements and open country with scattered trees",
stimme="A hard, rattling “chack-a-chack-chack”",
text=(
"Black and white with a long tail that shows metallic green and violet in "
"sunlight. Unmistakable."
"\n\n"
"Magpies pass the mirror test: given a coloured mark on the throat that "
"they can only see in a mirror, they try to remove it. They recognise "
"themselves — something otherwise shown only in great apes, dolphins and "
"elephants. The reputation as an egg thief is overstated: studies find no "
"measurable effect on songbird populations. Habitat loss and domestic cats "
"do that."
),
),
"eichelhaeher": InfoEN(
kategorie="The Forest Planter",
nahrung="Acorns and beech mast, insects and eggs in summer",
lebensraum="Broadleaf and mixed woodland, gardens in winter",
stimme="A harsh, screeching alarm — the burglar alarm of the woods",
text=(
"Pinkish-brown with a black-and-white head, a white rump, and a barred "
"blue-and-black wing patch that is among the finest feathers in Europe."
"\n\n"
"A single Jay buries up to five thousand acorns each autumn, each in a "
"different spot. The ones it forgets germinate, which makes it one of the "
"most important planters of oak woodland, carrying acorns uphill for "
"kilometres to places they would never reach alone. It is also a fine "
"mimic, and does a convincing Buzzard."
),
),
"dohle": InfoEN(
kategorie="The One With Pale Eyes",
nahrung="Insects, seeds and scraps — highly adaptable",
lebensraum="Church towers, ruins, old trees, city centres",
stimme="A bright, clipped “kya” — the source of its scientific name",
text=(
"The smallest corvid here: slate grey with a black cap and a silver-grey "
"nape. The striking feature is the eye — pale ice blue, unique among our "
"crows."
"\n\n"
"Jackdaws are strictly monogamous and stay together for life, often more "
"than ten years. Colonies have a firm pecking order, and a female "
"automatically assumes her partner's rank on pairing. Konrad Lorenz "
"studied social behaviour in Jackdaws and called them the most political "
"birds he knew."
),
),
"rabenkraehe": InfoEN(
kategorie="The Toolmaker",
nahrung="Genuinely anything — carrion, insects, grain, refuse, eggs",
lebensraum="Everywhere: fields, woods, cities, car parks",
stimme="The familiar harsh “caw”, usually three times",
text=(
"All black with a heavy bill, recognisable in flight by the fingered "
"wingtips. Not to be confused with the Rook, which has a pale bare patch "
"at the base of the bill."
"\n\n"
"Crows are among the most intelligent animals alive. They drop nuts onto "
"roads for cars to crack and collect them at pedestrian crossings when the "
"lights are red. They recognise individual human faces, remember who "
"wished them harm, and pass that information to other crows — including "
"ones that were never there."
),
),
"kolkrabe": InfoEN(
kategorie="The Largest Songbird",
nahrung="Carrion, small mammals, eggs, refuse",
lebensraum="Woodland, crags and open country; avoids settlements",
stimme="A deep, resonant “kronk”, nothing like a crow's caw",
text=(
"Noticeably larger than a Carrion Crow, nearly the size of a Buzzard, with "
"a wedge-shaped tail, a massive bill and shaggy throat feathers. In flight "
"it soars and rolls, which crows do not."
"\n\n"
"The largest songbird in the world — corvids count as passerines despite "
"their size. Ravens solve multi-step puzzles that defeat four-year-old "
"children, plan for the future, and trade objects for better ones. In "
"nineteenth-century Germany the species was almost exterminated and has "
"only spread back in recent decades."
),
),
"ringeltaube": InfoEN(
kategorie="The Largest of Its Kind",
nahrung="Seeds, grain, buds and acorns",
lebensraum="Woodland and farmland, increasingly city parks and gardens",
stimme="A muffled five-note coo, often all day in summer",
text=(
"The largest pigeon here. Blue-grey with a pink breast, a white patch on "
"the side of the neck and bold white wing bars in flight."
"\n\n"
"That white neck patch — the “ring” of the name — is entirely absent in "
"juveniles and grows in over the first winter. Wood Pigeons feed their "
"young on crop milk, a nutrient-rich secretion produced by both parents. "
"That frees them from the insect season, which is why they go on breeding "
"into October."
),
),
"tuerkentaube": InfoEN(
kategorie="The Coloniser",
nahrung="Seeds, grain, bread scraps",
lebensraum="Villages, suburbs, gardens with conifers",
stimme="A monotonous three-note coo, repeated without end",
text=(
"Slim and sandy-buff with a narrow black half-collar on the nape. "
"Distinctly smaller and paler than a Wood Pigeon."
"\n\n"
"One of the fastest range expansions ever documented: in 1930 it was "
"confined to the Balkans, by 1945 it had reached Germany, by 1960 Britain. "
"It colonised a continent in thirty years without anyone releasing it. Why "
"it suddenly set off has never been fully explained."
),
),
"rauchschwalbe": InfoEN(
kategorie="The Good Omen",
nahrung="Flying insects, caught exclusively in the air",
lebensraum="Barns and stables, open country with livestock",
stimme="A twittering chatter ending in a dry rattle",
text=(
"A metallic blue-black back, rust-red throat and forehead, and a deeply "
"forked tail with long streamers. It nests inside — in stables and barns, "
"not on an outside wall."
"\n\n"
"Barn Swallows do almost everything on the wing: feed, drink, bathe, even "
"mate. Streamer length signals fitness, and females choose the longest. "
"Their numbers hang directly on farming — without open stables and insects "
"over pasture they disappear, and that is exactly what has been happening."
),
),
"mehlschwalbe": InfoEN(
kategorie="The Mud Mason",
nahrung="Flying insects, usually higher up than a Barn Swallow",
lebensraum="House walls, bridges, colonies on buildings",
stimme="A dry, rasping twitter",
text=(
"Blue-black above, entirely white below, with a conspicuous white rump "
"that separates it from a Barn Swallow at any distance. The tail is only "
"shallowly forked, with no streamers."
"\n\n"
"Its closed nest under the eaves is built from roughly a thousand mud "
"pellets, each flown in separately. Puddles and bare mud are therefore its "
"livelihood — in a sealed new-build estate it simply cannot find building "
"material. The nests are protected year-round, even when empty."
),
),
"mauersegler": InfoEN(
kategorie="The Endless Flier",
nahrung="Aerial plankton — tiny insects and ballooning spiders",
lebensraum="Tall buildings, cavities under roofs, towns",
stimme="A shrill, drawn-out scream over the rooftops on summer evenings",
text=(
"Scythe-shaped wings, a short forked tail, uniformly dark brown. Despite "
"the resemblance it is not a swallow at all but a swift — its closest "
"relatives are hummingbirds."
"\n\n"
"After leaving the nest a young Swift may stay airborne for up to ten "
"months without landing once: it feeds, drinks, mates and sleeps in "
"flight, one brain hemisphere at a time. It only lands to breed. Its legs "
"are so reduced it can barely take off from flat ground. It is here for "
"about a hundred days a year, early May to early August."
),
),
"bachstelze": InfoEN(
kategorie="The Tail Wagger",
nahrung="Insects picked from the ground while walking",
lebensraum="Open ground, watersides, car parks, rooftops",
stimme="A bright “chissick”, usually given in bounding flight",
text=(
"Black and white with a long tail and grey back. It walks briskly across "
"open ground rather than hopping, wagging its tail without pause."
"\n\n"
"Why it wags has never been settled. The most convincing explanation is "
"that it signals to a predator: I have seen you, an attack is not worth "
"it. In winter Wagtails like to roost communally somewhere warm — "
"glasshouses, industrial plants, lit city centres."
),
),
"goldammer": InfoEN(
kategorie="The One With the Mnemonic",
nahrung="Seeds, insects in summer",
lebensraum="Field margins, hedges, open country with bushes",
stimme="Remembered in English as “a little bit of bread and no cheeeese”",
text=(
"The male has a brilliant yellow head and breast with a chestnut rump. "
"Females are duller and more heavily streaked."
"\n\n"
"It sings from exposed perches, often for hours in the midday heat when "
"everything else has fallen silent. The song ends on a drawn-out note that "
"makes the species unmistakable. It is uncommon at feeders — it prefers to "
"feed on the ground beneath and needs open fields nearby."
),
),
"sperber": InfoEN(
kategorie="The Ambush Hunter",
nahrung="Small birds, taken in flight",
lebensraum="Woodland, gardens, settlements — hunts feeders deliberately",
stimme="A rapid “kew-kew-kew”, mostly while breeding",
text=(
"Short broad wings and a long tail — built to turn between branches, not "
"to fly fast in a straight line. Males at 150 grams are far smaller than "
"females at up to 300, an extreme difference even for a raptor."
"\n\n"
"When every bird at a feeder vanishes at once and it stays silent for "
"minutes, a Sparrowhawk has usually just been through. It hunts from "
"cover, using hedges and house corners as screens, and is visible only at "
"the last moment. That is no reason to stop feeding — it belongs there, "
"and a feeder with cover nearby gives the small birds a fair chance."
),
),
"turmfalke": InfoEN(
kategorie="The Hoverer",
nahrung="Voles, plus large insects and small birds",
lebensraum="Open country, church towers, tower blocks, motorway verges",
stimme="A rapid, shrill “kee-kee-kee”",
text=(
"A rusty-brown back with dark spots, pointed wings and a long tail. The "
"male has a grey head and a grey tail with a black band; the female is "
"barred brown throughout."
"\n\n"
"Its hovering flight is unmistakable: it holds station in the air with the "
"head absolutely still while the body works. What it is looking for we "
"cannot see — vole urine reflects ultraviolet light, and Kestrels see into "
"the ultraviolet. To them the runs through the grass glow."
),
),
"maeusebussard": InfoEN(
kategorie="The Commonest in the Sky",
nahrung="Voles, earthworms, carrion — often at the roadside",
lebensraum="Copses, woodland edges, farmland everywhere",
stimme="A mewing “peee-ow”, often mistaken for a Red Kite",
text=(
"Broad wings, a short rounded tail, and extremely variable plumage from "
"nearly white to nearly black. Circling over a field with wings held in a "
"shallow V, it is the commonest raptor in Germany."
"\n\n"
"It will of course never perch in a bird house. If the camera catches one "
"it will be overhead or on a fence post nearby. That is exactly why it "
"ranks high in this collection: not because it is rare, but because it is "
"rarely in front of this lens."
),
),
"steinkauz": InfoEN(
kategorie="The Bird of Athena",
nahrung="Voles, beetles, earthworms",
lebensraum="Old orchards, pollarded willows, old barns",
stimme="A plaintive, rising “goooek”, carrying far at night",
text=(
"Small and squat with a flat head and piercing yellow eyes under bold "
"white brows that give it a permanently stern expression. Barely twenty "
"centimetres tall."
"\n\n"
"It was the sacred bird of Athena, which is why an owl still appears on "
"the Greek one-euro coin — “carrying owls to Athens” traces back to it. "
"Unlike most owls it is often active by day. In Germany it is "
"endangered through the loss of old orchards and pollarded willows. In "
"front of a camera it is the greatest catch imaginable."
),
),
}
# fmt: on
+127
View File
@@ -0,0 +1,127 @@
"""Stichproben aus dem laufenden Bildstrom.
Ein Detektor, der nur bei Erfolg speichert, hinterlässt nach zwei Wochen ohne
Sichtung genau nichts, woran man sehen könnte, woran es lag. Waren keine Vögel
da? Saßen sie im Bild und waren zu klein? Das Protokoll beantwortet die zweite
Frage, die erste beantwortet nur ein Blick auf echte Bilder.
Deshalb legt ``Stichprobe`` in festem Abstand ein Bild ab — unabhängig davon,
ob etwas erkannt wurde. Der Strom läuft ohnehin, es kostet keinen zweiten
Zuschauer am Stream (der Pi Zero verträgt keinen).
Der Ordner ist selbstbegrenzend: über ``max_bilder`` hinaus fliegt jeweils das
älteste Bild raus. Ein Dauerläufer darf keine Platte volllaufen lassen.
"""
from __future__ import annotations
import io
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw
JPEG_QUALITY = 80
MAX_WIDTH = 1280
def _encode(img: Image.Image, max_width: int = MAX_WIDTH) -> bytes:
if img.width > max_width:
h = round(img.height * max_width / img.width)
img = img.resize((max_width, h), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True)
return buf.getvalue()
class Stichprobe:
"""Legt in festem Abstand ein Bild aus dem Strom ab."""
def __init__(
self,
ordner: str | Path,
alle_sekunden: float = 300.0,
max_bilder: int = 500,
):
self.ordner = Path(ordner)
self.alle_sekunden = alle_sekunden
self.max_bilder = max_bilder
self.ordner.mkdir(parents=True, exist_ok=True)
self._letzte: float | None = None
def bild(self, frame: Image.Image, jetzt: float) -> Path | None:
"""Speichert, wenn das Intervall abgelaufen ist. Gibt den Pfad zurück,
sonst ``None``.
``jetzt`` ist eine monotone Uhr (``time.monotonic``) — der Abstand darf
nicht davon abhängen, ob jemand die Systemzeit stellt.
"""
if self._letzte is not None and jetzt - self._letzte < self.alle_sekunden:
return None
self._letzte = jetzt
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
ziel = self.ordner / f"{stamp}.jpg"
ziel.write_bytes(_encode(frame))
self._aufraeumen()
return ziel
def _aufraeumen(self) -> None:
bilder = sorted(self.ordner.glob("*.jpg"))
# Die Namen sind Zeitstempel, sortieren heisst also chronologisch.
for alt in bilder[: max(0, len(bilder) - self.max_bilder)]:
alt.unlink(missing_ok=True)
def kontaktbogen(
ordner: str | Path,
ziel: str | Path,
spalten: int = 5,
zeilen: int = 6,
kachel_breite: int = 400,
seite: int = 1,
) -> tuple[Path, int, int]:
"""Montiert die Stichproben zu einem Blatt, neueste zuerst.
Ein Ordner mit 120 Bildern wird auf dem Handy nicht durchgesehen, ein
einzelnes Blatt schon. Rückgabe: Zielpfad, Anzahl montierter Bilder und
Gesamtzahl der Seiten.
"""
ordner = Path(ordner)
ziel = Path(ziel)
bilder = sorted(ordner.glob("*.jpg"), reverse=True)
if not bilder:
raise FileNotFoundError(f"Keine Stichproben in {ordner}")
pro_seite = spalten * zeilen
seiten = (len(bilder) + pro_seite - 1) // pro_seite
seite = max(1, min(seite, seiten))
auswahl = bilder[(seite - 1) * pro_seite : seite * pro_seite]
with Image.open(auswahl[0]) as probe:
verhaeltnis = probe.height / probe.width
kh = round(kachel_breite * verhaeltnis)
beschriftung = 22
zeilen_echt = (len(auswahl) + spalten - 1) // spalten
blatt = Image.new(
"RGB",
(spalten * kachel_breite, zeilen_echt * (kh + beschriftung)),
(18, 18, 18),
)
mal = ImageDraw.Draw(blatt)
for i, pfad in enumerate(auswahl):
x = (i % spalten) * kachel_breite
y = (i // spalten) * (kh + beschriftung)
with Image.open(pfad) as im:
blatt.paste(im.convert("RGB").resize((kachel_breite, kh), Image.LANCZOS), (x, y))
# Aus "20260912-143005.jpg" wird "12.09. 14:30" — die Sekunde hilft
# beim Durchsehen nicht, das Datum schon.
name = pfad.stem
text = f"{name[6:8]}.{name[4:6]}. {name[9:11]}:{name[11:13]}"
mal.text((x + 6, y + kh + 4), text, fill=(210, 210, 210))
ziel.parent.mkdir(parents=True, exist_ok=True)
blatt.save(ziel, format="JPEG", quality=85, optimize=True)
return ziel, len(auswahl), seiten
+204
View File
@@ -0,0 +1,204 @@
"""Framequellen.
Der Pi Zero kann auf mehrere Arten streamen und wir wissen noch nicht, welche
Software drauf läuft. Deshalb vier Quellen hinter einem Interface — die
Pipeline interessiert sich nur für ``for frame in source``.
MJPEG und Snapshot brauchen nur die Stdlib. RTSP ruft ffmpeg auf; das muss
dann auf dem Host installiert sein (``apt install ffmpeg``).
"""
from __future__ import annotations
import io
import subprocess
import time
import urllib.request
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Iterator
from PIL import Image
JPEG_SOI = b"\xff\xd8"
JPEG_EOI = b"\xff\xd9"
class FrameSource(ABC):
restartable = True
"""Ob es sinnvoll ist, die Quelle nach dem Ende neu zu öffnen.
Bei Netzquellen ja — der Pi startet neu, das WLAN hustet. Bei einem
endlichen Ordner nein, sonst läuft ``watch`` in einer Endlosschleife.
"""
letztes_jpeg: bytes | None = None
"""Die unveränderten JPEG-Bytes des zuletzt gelieferten Bildes.
Wer das Bild weitergeben will — etwa der Live-Verteiler —, spart damit das
Umkodieren: bei MJPEG und Einzelbild-URLs liegen die Bytes ohnehin vor.
Quellen ohne fertiges JPEG lassen das Feld auf ``None``.
"""
@abstractmethod
def frames(self) -> Iterator[Image.Image]: ...
def __iter__(self) -> Iterator[Image.Image]:
return self.frames()
class MJPEGStream(FrameSource):
"""multipart/x-mixed-replace — was mjpg-streamer, motion und die meisten
picamera2-Beispiele ausliefern.
Wir suchen die JPEG-Marker direkt im Bytestrom, statt die MIME-Boundaries
zu parsen: Boundary-Formate sind zwischen den Servern uneinheitlich, die
Marker sind es nicht.
"""
def __init__(self, url: str, timeout: int = 15, max_buffer: int = 8 << 20):
self.url = url
self.timeout = timeout
self.max_buffer = max_buffer
def frames(self) -> Iterator[Image.Image]:
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
buf = bytearray()
while True:
chunk = resp.read(16384)
if not chunk:
return
buf.extend(chunk)
while True:
start = buf.find(JPEG_SOI)
if start < 0:
break
end = buf.find(JPEG_EOI, start + 2)
if end < 0:
break
raw = bytes(buf[start : end + 2])
del buf[: end + 2]
try:
bild = Image.open(io.BytesIO(raw)).convert("RGB")
except OSError:
continue # halbes Bild, nächstes nehmen
self.letztes_jpeg = raw
yield bild
# Reißt die Verbindung mitten im Frame ab, wächst der Puffer
# sonst unbegrenzt weiter.
if len(buf) > self.max_buffer:
del buf[: -(1 << 20)]
class SnapshotPoller(FrameSource):
"""Einzelbild-URL im Intervall abholen (z.B. /snapshot.jpg bei motion,
oder ein Cron-Job, der auf den Pi schreibt). Genügsamer als MJPEG und für
einen Nistkasten völlig ausreichend."""
def __init__(self, url: str, interval: float = 1.0, timeout: int = 10):
self.url = url
self.interval = interval
self.timeout = timeout
def frames(self) -> Iterator[Image.Image]:
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
while True:
t0 = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
bild = Image.open(io.BytesIO(raw)).convert("RGB")
except (OSError, ValueError) as exc:
print(f"[stream] Snapshot fehlgeschlagen: {exc}")
time.sleep(min(self.interval * 5, 30))
continue
self.letztes_jpeg = raw
yield bild
time.sleep(max(0.0, self.interval - (time.monotonic() - t0)))
class RTSPStream(FrameSource):
"""RTSP über ffmpeg. Braucht ffmpeg auf dem Host."""
def __init__(self, url: str, fps: float = 2.0, width: int = 1280):
self.url = url
self.fps = fps
self.width = width
def frames(self) -> Iterator[Image.Image]:
cmd = [
"ffmpeg", "-loglevel", "error",
"-rtsp_transport", "tcp", "-i", self.url,
"-vf", f"fps={self.fps},scale={self.width}:-2",
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "4", "-",
]
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1 << 16)
except FileNotFoundError:
raise RuntimeError("ffmpeg nicht gefunden — 'apt install ffmpeg'") from None
try:
buf = bytearray()
assert proc.stdout is not None
while True:
chunk = proc.stdout.read(16384)
if not chunk:
return
buf.extend(chunk)
while True:
start = buf.find(JPEG_SOI)
end = buf.find(JPEG_EOI, start + 2) if start >= 0 else -1
if start < 0 or end < 0:
break
raw = bytes(buf[start : end + 2])
del buf[: end + 2]
try:
yield Image.open(io.BytesIO(raw)).convert("RGB")
except OSError:
continue
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
class FolderSource(FrameSource):
"""Bilder aus einem Ordner — zum Entwickeln und für Regressionstests ohne
laufende Kamera."""
def __init__(self, folder: str | Path, loop: bool = False, delay: float = 0.0):
self.folder = Path(folder)
self.loop = loop
self.delay = delay
self.restartable = loop
def frames(self) -> Iterator[Image.Image]:
exts = {".jpg", ".jpeg", ".png", ".webp"}
files = sorted(p for p in self.folder.iterdir() if p.suffix.lower() in exts)
if not files:
raise RuntimeError(f"Keine Bilder in {self.folder}")
while True:
for p in files:
yield Image.open(p).convert("RGB")
if self.delay:
time.sleep(self.delay)
if not self.loop:
return
def open_source(url: str, **kw) -> FrameSource:
"""Baut die passende Quelle aus einer URL bzw. einem Pfad."""
if url.startswith("rtsp://"):
return RTSPStream(url, fps=kw.get("fps", 2.0))
if url.startswith(("http://", "https://")):
# Heuristik: eine URL auf .jpg ist ein Einzelbild, alles andere ein Stream.
path = url.split("?", 1)[0].lower()
if path.endswith((".jpg", ".jpeg", ".png")) or kw.get("snapshot"):
return SnapshotPoller(url, interval=kw.get("interval", 1.0))
return MJPEGStream(url)
return FolderSource(url, loop=kw.get("loop", False), delay=kw.get("delay", 0.0))
+42
View File
@@ -0,0 +1,42 @@
"""Umgebungsvariablen lesen.
Alle Einstellungen heißen ``VOGELALBUM_<NAME>``.
Diese Datei kennt bewusst **keine** Django-Abhängigkeit — sie wird vom
Detektor, von der Web-App und von der Home-Assistant-Brücke gleichermaßen
benutzt, und die drei haben nicht dieselben Pakete installiert.
"""
from __future__ import annotations
import os
PRAEFIX = "VOGELALBUM"
def hole(name: str, default: str | None = None) -> str | None:
"""Liest ``VOGELALBUM_<name>``.
``name`` ist der Teil hinter dem Präfix, also ``"SOURCE"`` für
``VOGELALBUM_SOURCE``.
🔑 **Ein ausdrücklich leerer Wert schlägt den Vorgabewert.** Das ist kein
Zufall, sondern trägt eine Bedeutung: Ein leerer Ingest-Token heißt
„Endpunkt zu", nicht „nimm den Standard". Wer hier ``or default``
schriebe, machte aus einer bewussten Abschaltung stillschweigend wieder
eine Öffnung.
"""
wert = os.environ.get(f"{PRAEFIX}_{name}")
if wert is None:
return default
return wert
def gesetzt(name: str) -> bool:
"""Ob die Variable überhaupt vorhanden ist.
Nötig, weil ein **leerer** Wert etwas anderes bedeutet als ein fehlender:
Ein leerer Ingest-Token schaltet den Endpunkt ab, ein fehlender lässt den
Vorgabewert gelten.
"""
return os.environ.get(f"{PRAEFIX}_{name}") is not None
+287
View File
@@ -0,0 +1,287 @@
"""Live-Bild fuer das Heimnetz, ohne der Kamera etwas wegzunehmen.
Die Kameraseite liefert **einen** Stream. Wer live zuschaut, nimmt ihn dem
Detector weg — auf einem Pi Zero W ist das keine Einstellung, sondern Physik:
die Funkstrecke traegt rund 2 Mbit/s, ein zweiter Client saettigt sie und der
Detector faellt in Timeouts.
Der Detector selbst liest den Stream aber ohnehin rund um die Uhr und hat jedes
Bild schon im Speicher. Also verteilt er es weiter. Damit schauen beliebig
viele Leute zu, und die Kamera merkt nichts davon — sie hat weiterhin genau
einen Zuschauer.
python -m detector watch ... --live-port 8131
http://<detector>:8131/live MJPEG
http://<detector>:8131/bild.jpg Einzelbild
http://<detector>:8131/ Seite mit Livebild
Zwei Dinge halten die Erkennung dabei aus dem Weg:
* **Nichts wird neu kodiert, solange niemand zuschaut.** Bei MJPEG-Quellen
liegen die JPEG-Bytes ohnehin vor und werden unveraendert weitergegeben; nur
fuer Quellen ohne fertiges JPEG (RTSP, Bilderordner) wird kodiert — und das
erst, wenn wirklich jemand hinsieht, und einmal pro Bild statt einmal pro
Zuschauer.
* **Kodiert wird im HTTP-Thread**, nicht im Erkennungsthread. Ein langsamer
Zuschauer bremst die Erkennung nicht.
Das gehoert ins Heimnetz und nicht ins Internet: es ist ein Livebild aus dem
eigenen Garten, ohne Anmeldung.
"""
from __future__ import annotations
import io
import socketserver
import threading
import time
from http import server
from PIL import Image
GRENZE = "BILDGRENZE"
SEITE = """<!doctype html>
<html lang="de"><head><meta charset="utf-8"><title>Vogelalbum live</title>
<style>body{margin:0;background:#111;color:#eee;font-family:system-ui,sans-serif}
img{display:block;width:100%;height:auto}p{padding:10px 14px;margin:0;font-size:14px}</style>
</head><body>
<img src="/live" alt="Livebild">
<p>Der Detector verteilt sein Bild weiter — die Kamera merkt nichts davon.</p>
</body></html>
""".encode("utf-8")
class Bildhalter:
"""Haelt das jeweils letzte Bild und gibt es als JPEG heraus.
Die Kodierung passiert traege und genau einmal je Bild: der erste
Zuschauer, der ein neues Bild abholt, kodiert es, alle weiteren bekommen
dasselbe Ergebnis.
"""
def __init__(self, qualitaet: int = 75, max_breite: int = 1280):
self.qualitaet = qualitaet
self.max_breite = max_breite
self._sperre = threading.Condition()
self._nummer = 0
self._jpeg: bytes | None = None
self._frame: Image.Image | None = None
self._kodiert: tuple[int, bytes] | None = None
# -- Schreibseite (Erkennungsthread) ----------------------------------
def zeige(self, frame: Image.Image, jpeg: bytes | None = None) -> None:
"""Ein neues Bild anmelden. Kostet nur Zeigerarbeit.
``jpeg`` sind die unveraenderten Bytes der Quelle, falls es sie gibt —
bei MJPEG ist das der Normalfall und erspart jedes Umkodieren.
"""
with self._sperre:
self._nummer += 1
self._frame = frame
self._jpeg = jpeg
self._sperre.notify_all()
# -- Leseseite (HTTP-Threads) -----------------------------------------
@property
def nummer(self) -> int:
with self._sperre:
return self._nummer
def warte(self, seit: int, timeout: float = 5.0) -> int:
"""Wartet auf ein Bild neuer als ``seit``. Gibt dessen Nummer zurueck,
oder ``seit``, wenn nichts kam."""
with self._sperre:
if self._nummer > seit:
return self._nummer
self._sperre.wait(timeout)
return self._nummer
def jpeg(self) -> tuple[int, bytes] | None:
"""Das aktuelle Bild als JPEG, mit seiner Nummer."""
with self._sperre:
nummer = self._nummer
if nummer == 0:
return None
if self._kodiert is not None and self._kodiert[0] == nummer:
return self._kodiert
if self._jpeg is not None:
# Der haeufige Fall: die Quelle liefert MJPEG, die Bytes sind
# schon fertig. Nichts zu tun.
self._kodiert = (nummer, self._jpeg)
return self._kodiert
frame = self._frame
if frame is None:
return None
# Ausserhalb der Sperre kodieren: das ist der teure Teil, und der
# Erkennungsthread soll dabei nicht warten muessen.
if frame.width > self.max_breite:
hoehe = round(frame.height * self.max_breite / frame.width)
frame = frame.resize((self.max_breite, hoehe), Image.LANCZOS)
puffer = io.BytesIO()
frame.save(puffer, format="JPEG", quality=self.qualitaet)
daten = puffer.getvalue()
with self._sperre:
self._kodiert = (nummer, daten)
return self._kodiert
class Handler(server.BaseHTTPRequestHandler):
halter: Bildhalter
max_zuschauer = 8
# Bilder je Sekunde fuer ZUSCHAUER. 0 heisst: alles durchreichen.
#
# 🔑 Warum das noetig ist: Am 24.09.2026 gemessen, zwei Kameras in einer
# Live-Ansicht auf einem Handy -- 6,4 und 9,7 Mbit/s, zusammen ueber 16.
# Fuer eine Vorschau ist das absurd viel, und auf dem Handy dauert es
# entsprechend, bis ueberhaupt etwas erscheint.
#
# Ausgelassen wird nur fuer die Zuschauer. Die Erkennung bekommt
# weiterhin JEDES Bild -- sie liest nicht ueber diesen Weg.
max_bildrate = 0.0
_zuschauer = 0
_zaehlsperre = threading.Lock()
def log_message(self, fmt, *args): # noqa: A003 - Signatur der Basisklasse
pass
def do_GET(self) -> None: # noqa: N802 - Signatur der Basisklasse
# ⚠ self.path enthaelt den Anhang hinter dem Fragezeichen. Ein
# exakter Vergleich scheitert deshalb an "/bild.jpg?t=123" --
# und genau so fragt ein Browser, der das Bild nicht aus seinem
# Zwischenspeicher nehmen soll. Am 24.09.2026 hat mich das eine
# Runde gekostet: von Hand aufgerufen kam das Bild, aus der
# Seite heraus kam 404.
weg = self.path.split("?", 1)[0]
if weg in ("/", "/index.html"):
self._sende(200, "text/html; charset=utf-8", SEITE)
elif weg in ("/bild.jpg", "/snapshot.jpg"):
self._einzelbild()
elif weg.startswith("/live"):
self._stream()
else:
self._sende(404, "text/plain; charset=utf-8", b"nicht gefunden\n")
def _sende(self, code: int, typ: str, koerper: bytes) -> None:
self.send_response(code)
self.send_header("Content-Type", typ)
self.send_header("Content-Length", str(len(koerper)))
self.end_headers()
self.wfile.write(koerper)
def _einzelbild(self) -> None:
bild = self.halter.jpeg()
if bild is None:
self._sende(503, "text/plain; charset=utf-8", b"noch kein Bild\n")
return
self._sende(200, "image/jpeg", bild[1])
def _stream(self) -> None:
# Anders als an der Kamera ist hier eine Obergrenze reine Vorsicht:
# der Detector laeuft auf einer richtigen Maschine. Sie verhindert
# nur, dass ein vergessener Tab-Stapel Threads sammelt.
with self._zaehlsperre:
if Handler._zuschauer >= self.max_zuschauer:
self._sende(503, "text/plain; charset=utf-8",
b"zu viele Zuschauer\n")
return
Handler._zuschauer += 1
try:
self.send_response(200)
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.send_header(
"Content-Type", f"multipart/x-mixed-replace; boundary={GRENZE}"
)
self.end_headers()
gesehen = 0
abstand = (1.0 / self.max_bildrate) if self.max_bildrate > 0 else 0.0
zuletzt = 0.0
while True:
gesehen = self.halter.warte(gesehen)
# Zu frueh? Bild auslassen statt zu warten -- warten wuerde
# den Strom anhalten und die Verzoegerung aufsummieren.
if abstand:
jetzt = time.monotonic()
if jetzt - zuletzt < abstand:
continue
zuletzt = jetzt
bild = self.halter.jpeg()
if bild is None:
continue
nummer, daten = bild
gesehen = nummer
self.wfile.write(f"--{GRENZE}\r\n".encode("ascii"))
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(daten)}\r\n\r\n".encode("ascii"))
self.wfile.write(daten)
self.wfile.write(b"\r\n")
except (BrokenPipeError, ConnectionResetError):
pass
finally:
with self._zaehlsperre:
Handler._zuschauer -= 1
@classmethod
def zuschauer(cls) -> int:
with cls._zaehlsperre:
return cls._zuschauer
class _Server(socketserver.ThreadingMixIn, server.HTTPServer):
allow_reuse_address = True
daemon_threads = True
class Verteiler:
"""Der Live-Verteiler als Hintergrunddienst."""
def __init__(
self,
port: int,
bind: str = "0.0.0.0",
max_zuschauer: int = 8,
qualitaet: int = 75,
max_bildrate: float = 0.0,
):
self.halter = Bildhalter(qualitaet=qualitaet)
Handler.halter = self.halter
Handler.max_zuschauer = max_zuschauer
Handler.max_bildrate = max_bildrate
self._server = _Server((bind, port), Handler)
self.port = self._server.server_address[1]
self._thread = threading.Thread(
target=self._server.serve_forever, daemon=True, name="live"
)
def bildrate_setzen(self, wert: float) -> None:
"""Zur Laufzeit umstellbar -- die Web-App ist die Wahrheit."""
Handler.max_bildrate = max(0.0, float(wert or 0.0))
def start(self) -> None:
self._thread.start()
def zeige(self, frame: Image.Image, jpeg: bytes | None = None) -> None:
self.halter.zeige(frame, jpeg)
@property
def zuschauer(self) -> int:
return Handler.zuschauer()
def stop(self) -> None:
self._server.shutdown()
self._server.server_close()
+201
View File
@@ -0,0 +1,201 @@
"""Hochgeladene Fotos abholen, bestimmen und das Ergebnis zurückmelden.
Der zweite Weg, auf dem Bilder zur Erkennung kommen. Der erste ist die Kamera
am Haus: Der Detector holt sich dort einen Stream und entscheidet selbst, wann
ein Besuch beginnt und endet. Hier liegt das Bild schon in der Web-App — jemand
hat unterwegs fotografiert und hochgeladen.
🔑 **Die Richtung bleibt dieselbe: Der Detector fragt, niemand ruft ihn.** Das
ist keine Bequemlichkeit, sondern die Bedingung dafür, dass die Erkennung im
Heimnetz stehen kann: kein offener Port, keine erreichbare Adresse, keine
Portfreigabe im Router. Dieselbe Mechanik benutzt ``fernkonfig.py``, um alle
fünf Minuten die Konfiguration zu holen.
⚠️ **Keine Bewegungserkennung, keine Mittelung über Bilder.** Ein Foto ist ein
Foto. Die ganze Logik aus ``watcher.py`` — Bewegung als Auslöser, Besuch über
viele Einzelbilder, Mittelwert der Vogel-Werte — hat hier keine Entsprechung
und wäre bei einem Einzelbild sogar falsch. Benutzt wird deshalb direkt
``Pipeline.run()``, genau wie bei ``python -m detector test``.
"""
from __future__ import annotations
import io
import json
import time
from PIL import Image
from .classify import Pipeline
from .sink import _encode
class Warteschlange:
"""Fragt die Web-App nach offenen Fotos und arbeitet sie ab."""
def __init__(
self,
api: str,
lese_token: str,
schreib_token: str,
pipeline: Pipeline,
intervall: float = 60.0,
anzahl: int = 20,
timeout: int = 20,
):
self.basis = api.rstrip("/")
# ⚠️ Zwei verschiedene Token, und das ist Absicht: Das Auflisten ist
# ein Lesevorgang nach außen (derselbe Token wie für Home
# Assistant), das Zurückmelden ein Schreibvorgang wie das normale
# Einliefern. Wer nur zuschauen darf, soll keine Bestimmung
# eintragen können.
self.lese_token = lese_token
self.schreib_token = schreib_token
self.pipeline = pipeline
self.intervall = intervall
self.anzahl = anzahl
self.timeout = timeout
self._letzter_versuch: float | None = None
self._fehler_gemeldet = False
def faellig(self, jetzt: float) -> bool:
return (
self._letzter_versuch is None
or jetzt - self._letzter_versuch >= self.intervall
)
# -- Abruf ------------------------------------------------------------
def _offene(self) -> list[dict]:
import requests
resp = requests.get(
f"{self.basis}/api/offen/",
params={"anzahl": self.anzahl},
headers={"Authorization": f"Bearer {self.lese_token}"},
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json().get("fotos") or []
def _bild_holen(self, url: str) -> Image.Image:
import requests
resp = requests.get(url, timeout=self.timeout)
resp.raise_for_status()
return Image.open(io.BytesIO(resp.content)).convert("RGB")
# -- Bearbeiten -------------------------------------------------------
def _melden(self, foto_id: int, ausschnitt, result, box=None) -> None:
import requests
# ⚠️ ``Result.best`` ist eine Property, kein Aufruf — ``result.best()``
# scheitert erst zur Laufzeit und genau dann, wenn ein Vogel
# erkannt wurde.
bester = result.best if result else None
# 🔴 **„Vogel ja, Art nein" ist etwas anderes als „kein Vogel" — und
# ging hier verloren.**
#
# ``Pipeline.run`` gibt in DREI Fällen ``result = None`` zurück: kein
# Fund, Beinahe-Treffer unter der Schwelle, und — der interessante —
# ein sicher erkannter Vogel, dessen Art keine heimische ist. Die Box
# unterscheidet die Fälle, nur hat sie vorher niemand angesehen.
#
# Folge am 14.10.2026: Ein Foto mit einer Meise im Gegenlicht wurde
# mit 93 % als Vogel erkannt und kam in der App als „Kein Vogel
# erkannt. Auf dem Foto war nichts zu finden" an. Wer das liest,
# schneidet das Bild zu — gegen ein Problem, das nie der Zuschnitt
# war. **Eine falsche Auskunft ist teurer als keine.**
#
# ``box.score`` ist genau die Zahl, die „da ist ein Vogel" bedeutet;
# sie gehört in ``mean_bird_score``, dessen Sinn der Server selbst so
# beschreibt: Vogelwert 0 heißt „auf keinem Bild war ein Vogel".
vogelwert = 0.0
if result is not None:
vogelwert = result.bird_score
elif box is not None and self.pipeline.zaehlt_als_vogel(box):
vogelwert = box.score
nutzlast = {
"species": bester.species.slug if bester else None,
"confidence": round(bester.confidence, 4) if bester else 0.0,
"mean_bird_score": round(vogelwert, 4),
"votes": {g.species.slug: round(g.confidence, 4)
for g in (result.guesses[:5] if result else [])},
}
# Wo der Vogel sitzt. Die Sammlung schneidet daraus selbst zu --
# siehe web/dex/zuschnitt.py.
if box is not None:
nutzlast["box"] = [round(box.x0, 5), round(box.y0, 5),
round(box.x1, 5), round(box.y1, 5)]
dateien = {}
if ausschnitt is not None:
dateien["crop"] = ("crop.jpg", _encode(ausschnitt, 640), "image/jpeg")
resp = requests.post(
f"{self.basis}/api/offen/{foto_id}/",
data={"data": json.dumps(nutzlast)},
files=dateien or None,
headers={"Authorization": f"Bearer {self.schreib_token}"},
timeout=self.timeout,
)
if not resp.ok:
print(f"[warteschlange] {foto_id}: Rückmeldung {resp.status_code} "
f"— {resp.text[:160]}")
return
body = resp.json()
art = body.get("art") or "nichts erkannt"
teile = [f"Foto {foto_id}: {art}"]
if body.get("xp"):
teile.append(f"+{body['xp']} XP")
if body.get("erstfang"):
teile.append("ERSTFANG!")
for name in body.get("abzeichen") or []:
teile.append(f"Abzeichen: {name}")
print("[warteschlange] " + " · ".join(teile))
def durchlauf(self, jetzt: float | None = None) -> int:
"""Holt die offenen Fotos und bestimmt sie. Gibt die Anzahl zurück.
⚠️ Ein Fehler bei einem Foto darf die anderen nicht aufhalten. Sonst
blockiert ein einziges kaputtes Bild die ganze Warteschlange, und
niemand sieht, warum nichts mehr vorangeht.
"""
import requests
self._letzter_versuch = time.monotonic() if jetzt is None else jetzt
try:
offene = self._offene()
except requests.RequestException as exc:
if not self._fehler_gemeldet:
print(f"[warteschlange] nicht erreicht: {exc}")
self._fehler_gemeldet = True
return 0
if self._fehler_gemeldet:
print("[warteschlange] wieder erreichbar")
self._fehler_gemeldet = False
fertig = 0
for foto in offene:
if not foto.get("bild"):
continue
try:
bild = self._bild_holen(foto["bild"])
box, result = self.pipeline.run(bild)
# Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild
# zeigt wie bei einer Sichtung von der Kamera.
# ⚠️ `anzeige_ausschnitt`, nicht `crop`: Der enge Schnitt ist
# fuer den Klassifikator gedacht; in der fast quadratischen
# Kachel der Sammlung bleibt davon nur die Bauchmitte uebrig.
ausschnitt = box.anzeige_ausschnitt(bild) if box is not None else None
self._melden(foto["id"], ausschnitt, result, box)
fertig += 1
except Exception as exc: # absichtlich breit, siehe Docstring
print(f"[warteschlange] Foto {foto['id']} übersprungen: {exc}")
return fertig
+296
View File
@@ -0,0 +1,296 @@
"""Die Zustandsmaschine, die aus Einzelbildern eine "Sichtung" macht.
Eine Sichtung ist ein Besuch: Vogel landet, bleibt ein paar Sekunden, fliegt
weg. Über die Dauer sammeln wir Stimmen und behalten das schärfste Foto.
Warum abstimmen statt einfach das erste Ergebnis nehmen? Weil ein Vogel sich
dreht. Von hinten ist eine Kohlmeise nur eine olivgrüne Kugel; von vorn ist
sie eindeutig. Über 30 Frames gewinnt die richtige Art zuverlässig — eine
Einzelmessung kippt bei jedem Flügelschlag.
"""
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from PIL import Image
from .classify import Box, Pipeline, Result
from .motion import MotionDetector, sharpness
from .species_de import Species
@dataclass
class Sighting:
started_at: datetime
ended_at: datetime
species: Species | None
confidence: float
votes: dict[str, float]
frames_total: int
frames_with_bird: int
best_image: Image.Image | None
best_crop: Image.Image | None
best_sharpness: float
mean_bird_score: float
# Wo der Vogel im besten Bild sitzt, relativ. Die Sammlung schneidet
# daraus selbst zu -- siehe web/dex/zuschnitt.py.
best_box: tuple[float, float, float, float] | None = None
@property
def duration_s(self) -> float:
return (self.ended_at - self.started_at).total_seconds()
def summary(self) -> str:
who = self.species.german if self.species else "unbestimmt"
return (
f"{self.started_at:%H:%M:%S} {who} ({self.confidence:.0%}) "
f"{self.duration_s:.1f}s, {self.frames_with_bird}/{self.frames_total} Frames"
)
@dataclass
class WatcherConfig:
classify_every: int = 3
"""Nur jedes n-te bewegte Bild klassifizieren. Der Vogel ändert sich
zwischen zwei Frames kaum, das Modell aber kostet 25 ms."""
cooldown_s: float = 4.0
"""So lange ohne Bewegung, bis der Besuch als beendet gilt."""
max_visit_s: float = 120.0
"""Notbremse: dauerhafte Bewegung (Regen, schwankender Ast) soll nicht zu
einer stundenlangen Sichtung führen."""
min_bird_frames: int = 2
"""Weniger Treffer als das -> verwerfen. Ein einzelnes Frame ist Rauschen."""
min_confidence: float = 0.45
"""Darunter wird die Sichtung als 'unbestimmt' gespeichert statt geraten."""
min_mean_bird_score: float = 0.0
"""Mittlerer Vogelwert ueber den ganzen Besuch; darunter kein Besuch.
⚠ Nicht zu verwechseln mit ``Pipeline.min_bird_score``: der gilt je
EINZELBILD, dieser fuer den DURCHSCHNITT. Genau diese Luecke liess
Zufallstreffer durch -- ein einziges Bild von 73 mit einem Hauch
Vogelwert reichte fuer einen Besuch.
📌 Vorgabe 0: Der Wert will gemessen und nicht geraten sein. Aus den
Daten vom 01.10.2026 lagen echte Sichtungen bei 0,269 bis 0,600,
eine Befestigungsschraube bei 0,098 und Mondspiegelungen bei 0,000.
Die Web-App gibt den Wert je Kamera vor (``/api/config/``).
"""
gate: str = "motion"
"""Was einen Besuch beginnt und beendet — ``"motion"`` oder ``"detector"``.
``motion`` ist der sparsame Weg: Frame-Differencing kostet unter 1 ms und
hält das Modell aus dem Spiel, solange sich nichts rührt. Das setzt aber
einen ruhigen Hintergrund voraus.
``detector`` lässt den Vogeldetektor selbst entscheiden und ignoriert
Bewegung ganz. Nötig, sobald die Kamera auf etwas dauerhaft Bewegtes
schaut — eine Hecke im Wind löst sonst rund um die Uhr aus. Kostet bei
4 fps nur wenige Prozent CPU und ist damit fast immer bezahlbar.
"""
save_debug: bool = False
class Watcher:
def __init__(
self,
pipeline: Pipeline | None = None,
motion: MotionDetector | None = None,
config: WatcherConfig | None = None,
beobachter=None,
):
"""``beobachter`` wird nach jeder Analyse mit ``(box, result)``
aufgerufen — auch wenn nichts gefunden wurde. Damit kann ein
Protokoll mitschreiben, ohne dass die Zustandsmaschine davon weiß."""
self.pipeline = pipeline or Pipeline()
self.motion = motion or MotionDetector()
self.cfg = config or WatcherConfig()
self.beobachter = beobachter
self._reset_visit()
def _reset_visit(self) -> None:
self._active = False
self._start: datetime | None = None
self._last_motion = 0.0
self._votes: dict[str, float] = defaultdict(float)
self._species_by_slug: dict[str, Species] = {}
self._frames = 0
self._bird_frames = 0
self._best_sharp = -1.0
self._best_image: Image.Image | None = None
self._best_crop: Image.Image | None = None
self._best_box: tuple[float, float, float, float] | None = None
self._bird_scores: list[float] = []
self._frame_i = 0
def process(self, frame: Image.Image) -> Sighting | None:
"""Ein Bild einspeisen. Gibt eine Sichtung zurück, wenn ein Besuch
gerade zu Ende gegangen ist, sonst None."""
if self.cfg.gate == "detector":
return self._process_detector_gated(frame)
return self._process_motion_gated(frame)
def _process_motion_gated(self, frame: Image.Image) -> Sighting | None:
now = time.monotonic()
ratio = self.motion.update(frame)
moving = self.motion.is_motion(ratio)
if not self._active:
if not moving:
return None
self._active = True
self._start = datetime.now(timezone.utc)
self._last_motion = now
self._frames += 1
if moving:
self._last_motion = now
self._frame_i += 1
if self._frame_i % self.cfg.classify_every == 0:
self._analyse(frame)
return self._maybe_close(now)
def _process_detector_gated(self, frame: Image.Image) -> Sighting | None:
"""Der Vogeldetektor entscheidet selbst, wann ein Besuch läuft.
Für Kameras, die auf etwas dauerhaft Bewegtes schauen. Jedes n-te Bild
geht durchs Modell, unabhängig davon ob sich etwas bewegt hat — Wind in
der Hecke ist damit schlicht kein Thema mehr.
"""
now = time.monotonic()
self._frame_i += 1
if self._frame_i % self.cfg.classify_every != 0:
if self._active:
self._frames += 1
return None
before = self._bird_frames
# Beim ersten Treffer eines Besuchs steht der Startzeitpunkt noch nicht;
# _analyse zählt _bird_frames hoch, daran erkennen wir den Vogel.
pending_start = datetime.now(timezone.utc)
self._analyse(frame)
saw_bird = self._bird_frames > before
if not self._active:
if not saw_bird:
# Kein Vogel und kein laufender Besuch: die Zwischenergebnisse
# aus _analyse gehören zu nichts und müssen weg.
self._reset_visit()
return None
self._active = True
self._start = pending_start
self._last_motion = now
self._frames += 1
if saw_bird:
self._last_motion = now
return self._maybe_close(now)
def _maybe_close(self, now: float) -> Sighting | None:
too_long = self._start is not None and (
(datetime.now(timezone.utc) - self._start).total_seconds()
> self.cfg.max_visit_s
)
if (now - self._last_motion) > self.cfg.cooldown_s or too_long:
return self._close()
return None
def _analyse(self, frame: Image.Image) -> None:
box, result = self.pipeline.run(frame)
if self.beobachter is not None:
# Auch Beinahe-Treffer melden — das Protokoll lebt genau davon.
self.beobachter(box, result)
if not self.pipeline.zaehlt_als_vogel(box):
return
self._bird_frames += 1
if result is not None:
self._bird_scores.append(result.bird_score)
self._vote(box, result)
# Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein
# gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger
# als ein leicht weicheres, auf dem er ganz zu sehen ist.
# Schaerfe am ENGEN Ausschnitt messen: Umgebung soll die Bewertung
# nicht verwaessern. Gespeichert wird dann der weitere -- siehe
# Box.anzeige_ausschnitt.
crop = box.crop(frame)
score = sharpness(crop) * (0.5 + 0.5 * box.score)
if score > self._best_sharp:
self._best_sharp = score
self._best_image = frame.copy()
self._best_crop = box.anzeige_ausschnitt(frame)
self._best_box = (box.x0, box.y0, box.x1, box.y1)
def _vote(self, box: Box, result: Result) -> None:
# Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild,
# und wie viel Wahrscheinlichkeitsmasse liegt überhaupt auf heimischen
# Arten. Ein 20-Pixel-Fleck am Bildrand soll die Abstimmung nicht
# gewinnen.
size_w = min(1.0, box.area / 0.05)
weight = box.score * (0.4 + 0.6 * size_w) * result.bird_score
for guess in result.guesses[:3]:
self._votes[guess.species.slug] += weight * guess.confidence
self._species_by_slug[guess.species.slug] = guess.species
def _close(self) -> Sighting | None:
if self._bird_frames < self.cfg.min_bird_frames:
self._reset_visit()
return None
# ⚠ Der Durchschnitt ueber den ganzen Besuch, nicht das beste Bild.
# Hier faellt der Zufallstreffer durch: ein Bild von 73 mit einem
# Hauch Vogelwert zieht den Mittelwert kaum nach oben. Lieber hier
# verwerfen als senden -- ein Bild hochzuladen, das die Web-App
# ohnehin abweist, kostet eine Kamera am Gartenzaun echte Bandbreite.
mittel = (
sum(self._bird_scores) / len(self._bird_scores)
if self._bird_scores else 0.0
)
if mittel < self.cfg.min_mean_bird_score:
self._reset_visit()
return None
species: Species | None = None
confidence = 0.0
total = sum(self._votes.values())
if total > 0:
slug, score = max(self._votes.items(), key=lambda kv: kv[1])
confidence = score / total
if confidence >= self.cfg.min_confidence:
species = self._species_by_slug[slug]
sighting = Sighting(
started_at=self._start or datetime.now(timezone.utc),
ended_at=datetime.now(timezone.utc),
species=species,
confidence=confidence,
votes={k: v / total for k, v in self._votes.items()} if total else {},
frames_total=self._frames,
frames_with_bird=self._bird_frames,
best_image=self._best_image,
best_crop=self._best_crop,
best_box=self._best_box,
best_sharpness=max(0.0, self._best_sharp),
mean_bird_score=mittel,
)
self._reset_visit()
return sighting
def flush(self) -> Sighting | None:
"""Laufenden Besuch abschließen — beim Beenden des Dienstes."""
return self._close() if self._active else None