Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
Whitespace-only changes.
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user