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.
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
# Ohne das landet die Entwicklungs-Datenbank im Image und überschreibt bei
|
||||||
|
# jedem Deploy die echte Sammlung.
|
||||||
|
web/db.sqlite3
|
||||||
|
web/db.sqlite3-*
|
||||||
|
web/media/
|
||||||
|
web/staticfiles/
|
||||||
|
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
benchmark_data/
|
||||||
|
testdata/
|
||||||
|
live/
|
||||||
|
models/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Vorlage fuer docker-compose.heim.yml.
|
||||||
|
#
|
||||||
|
# cp .env.beispiel .env
|
||||||
|
#
|
||||||
|
# Zwei Zeilen muessen stimmen, der Rest hat brauchbare Vorgaben.
|
||||||
|
|
||||||
|
# --- Pflicht -----------------------------------------------------------
|
||||||
|
|
||||||
|
# Wo liefert die Kamera ihr Bild? MJPEG-Stream, Einzelbild-URL, rtsp:// oder
|
||||||
|
# ein Ordner mit Bildern. Der Aufbau der Kameraseite steht in pi/README.md.
|
||||||
|
VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream
|
||||||
|
|
||||||
|
# Gemeinsames Geheimnis zwischen Detector und Sammlung. Beliebige lange
|
||||||
|
# Zeichenkette, einmal ausdenken:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
VOGELALBUM_INGEST_TOKEN=hier-ein-langes-zufaelliges-geheimnis-eintragen
|
||||||
|
|
||||||
|
# --- Optional ----------------------------------------------------------
|
||||||
|
|
||||||
|
# Port, unter dem die Sammlung im Browser erreichbar ist.
|
||||||
|
#VOGELALBUM_PORT=8000
|
||||||
|
|
||||||
|
# Was einen Besuch ausloest: 'detector' laesst das Modell entscheiden und
|
||||||
|
# ignoriert Bewegung, 'motion' spart Rechenzeit, setzt aber einen ruhigen
|
||||||
|
# Hintergrund voraus. Im Zweifel bei 'detector' bleiben.
|
||||||
|
#VOGELALBUM_GATE=detector
|
||||||
|
|
||||||
|
# Kamera mit Infrarot? Dann auf 1 setzen — sonst pausiert die Analyse, sobald
|
||||||
|
# das Bild zu dunkel zum Erkennen ist.
|
||||||
|
#VOGELALBUM_NACHTSICHT=
|
||||||
|
|
||||||
|
# Welche Region gilt: entscheidet, welche Arten der Klassifikator ueberhaupt
|
||||||
|
# erwaegt. Das Modell kennt 965 Arten und ist auf Nordamerika trainiert —
|
||||||
|
# ohne Einschraenkung wird aus einer Kohlmeise eine Carolina Chickadee.
|
||||||
|
#VOGELALBUM_REGION=de
|
||||||
|
|
||||||
|
# Sekunden zwischen zwei Abfragen der Einstellungen bei der Web-App.
|
||||||
|
# 0 schaltet es ab, dann gilt nur die Kommandozeile.
|
||||||
|
#VOGELALBUM_KONFIG_ALLE=300
|
||||||
|
|
||||||
|
# Live-Bild im Heimnetz. Die Kamera liefert nur einen Stream; wer direkt bei
|
||||||
|
# ihr zuschaut, nimmt ihn der Erkennung weg. Deshalb verteilt der Detector
|
||||||
|
# sein eigenes Bild weiter — beliebig viele Zuschauer, die Kamera merkt nichts.
|
||||||
|
# Angezeigt wird es erst, wenn die Adresse (http://<host>:8131/live) im
|
||||||
|
# Django-Admin bei der Kamera eingetragen und das Haekchen gesetzt ist.
|
||||||
|
#VOGELALBUM_LIVE_PORT=8131
|
||||||
|
|
||||||
|
# Sperre darueber, fuer eine oeffentliche Instanz. Auf 0 ist Live nicht
|
||||||
|
# einschaltbar — auch nicht mit einem Fehlklick im Admin. Ein Livebild aus dem
|
||||||
|
# eigenen Garten soll nicht an einem Haekchen haengen.
|
||||||
|
#VOGELALBUM_LIVE_ERLAUBT=1
|
||||||
|
|
||||||
|
# Wird beim ersten Start selbst erzeugt und aufs Volume gelegt. Nur setzen,
|
||||||
|
# wer einen eigenen Schluessel mitbringen will.
|
||||||
|
#DJANGO_SECRET_KEY=
|
||||||
|
|
||||||
|
# Nur noetig, wenn die Sammlung unter einem Domainnamen erreichbar sein soll.
|
||||||
|
#DJANGO_ALLOWED_HOSTS=vogelalbum.example.org
|
||||||
|
#DJANGO_CSRF_TRUSTED_ORIGINS=https://vogelalbum.example.org
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: 🔒 Sicherheitslücke
|
||||||
|
url: https://gitea.gaertner.onl/christian/vogelalbum/src/branch/main/SECURITY.md
|
||||||
|
about: Bitte nicht als Issue — Meldeweg steht in SECURITY.md
|
||||||
|
- name: 📖 Dokumentation
|
||||||
|
url: https://gitea.gaertner.onl/christian/vogelalbum/src/branch/main/docs
|
||||||
|
about: Installation, Kameraaufbau, Einstellungen, Betrieb
|
||||||
|
- name: 🐦 Die laufende Instanz
|
||||||
|
url: https://vogelalbum.gaertner.onl
|
||||||
|
about: So sieht es aus, wenn es läuft
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: "🐦 Art falsch bestimmt"
|
||||||
|
about: "Der Vogel war ein anderer, als Vogelalbum sagt"
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- erkennung
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Das ist die nützlichste Rückmeldung, die dieses Projekt bekommen kann: Das
|
||||||
|
Modell ist nordamerikanisch trainiert, und welche heimischen Arten es
|
||||||
|
verwechselt, lässt sich nur im Garten herausfinden.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Welche Art war es wirklich
|
||||||
|
|
||||||
|
## Was Vogelalbum gesagt hat
|
||||||
|
|
||||||
|
<!-- Name und Prozentzahl, beides steht an der Sichtung -->
|
||||||
|
|
||||||
|
## Das Bild
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Wenn möglich das Sichtungsfoto anhängen — ohne Bild lässt sich kaum etwas
|
||||||
|
machen. Es liegt unter `media/sightings/`, der Ausschnitt unter `media/crops/`.
|
||||||
|
Beide dürfen gern mit.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Wie oft
|
||||||
|
|
||||||
|
- [ ] einmalig
|
||||||
|
- [ ] kommt immer wieder bei dieser Art
|
||||||
|
|
||||||
|
## Deine Einstellungen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `VOGELALBUM_REGION` | Vorgabe ist `de` |
|
||||||
|
| `min_bird_score` | Vorgabe ist 0.05 |
|
||||||
|
| `detect_threshold` | Vorgabe ist 0.4 |
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Bekannt und nicht meldenswert: Acht heimische Arten kennt das Modell gar nicht
|
||||||
|
und ordnet sie Verwandten zu — Sumpf-, Weiden- und Haubenmeise,
|
||||||
|
Gartenbaumläufer, Kleinspecht, Bluthänfling, Kernbeißer, Waldkauz. Siehe
|
||||||
|
docs/erkennung.md. Eine Meldung lohnt sich trotzdem, wenn die Verwechslung
|
||||||
|
eine andere ist als die dort genannte.
|
||||||
|
-->
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: "🐞 Fehler melden"
|
||||||
|
about: "Etwas tut nicht, was es soll"
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- fehler
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Danke fürs Melden. Je konkreter, desto eher ist es behoben — Zahlen und
|
||||||
|
Logzeilen helfen mehr als Adjektive.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Was passiert
|
||||||
|
|
||||||
|
<!-- Was hast du gesehen? -->
|
||||||
|
|
||||||
|
## Was du erwartet hättest
|
||||||
|
|
||||||
|
## Wie man es auslöst
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Wo es passiert
|
||||||
|
|
||||||
|
- [ ] Web-App (die Sammlung)
|
||||||
|
- [ ] Detector (die Erkennung)
|
||||||
|
- [ ] Kameraseite Raspberry Pi (`pi/`)
|
||||||
|
- [ ] Kameraseite ESP32 (`esp32cam/`)
|
||||||
|
- [ ] Anbindung an Home Assistant
|
||||||
|
|
||||||
|
## Dein Aufbau
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Installiert per | Docker / ohne Docker |
|
||||||
|
| Stand | Ausgabe von `git rev-parse --short HEAD` |
|
||||||
|
| Betriebssystem | |
|
||||||
|
| Kamera | z.B. Pi Zero W + OV5647, ESP32-S3 + OV2640 |
|
||||||
|
|
||||||
|
## Ausgaben
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Hilfreich, je nachdem wo es klemmt:
|
||||||
|
|
||||||
|
docker compose -f docker-compose.heim.yml logs --tail=50 detector
|
||||||
|
curl -s http://<kamera>:8080/status # Raspberry Pi
|
||||||
|
curl -s http://<kamera>:8081/status # ESP32 — eigener Port, siehe esp32cam/README.md
|
||||||
|
|
||||||
|
⚠️ Bitte vorher Token und Passwörter herausnehmen.
|
||||||
|
|
||||||
|
⚠️ Und: NICHT mit `curl` am laufenden Stream (`/stream`) messen. Ein zweiter
|
||||||
|
Zuschauer sättigt die Funkstrecke und erzeugt genau die Timeouts, die du
|
||||||
|
vielleicht gerade suchst. `/status` fasst den Stream nicht an.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: "💡 Idee oder Wunsch"
|
||||||
|
about: "Etwas, das Vogelalbum können sollte"
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- idee
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worum geht es
|
||||||
|
|
||||||
|
## Was du damit vorhast
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Der wichtigste Teil. Eine Funktion ist leichter zu bauen, wenn klar ist, wofür
|
||||||
|
sie da ist — oft gibt es für denselben Zweck einen einfacheren Weg.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Wie du es heute löst
|
||||||
|
|
||||||
|
<!-- Falls es einen Umweg gibt: welchen? -->
|
||||||
|
|
||||||
|
## Würdest du daran mitarbeiten?
|
||||||
|
|
||||||
|
- [ ] Ja, ich würde es selbst bauen
|
||||||
|
- [ ] Ja, beim Testen
|
||||||
|
- [ ] Nein, ich melde es nur
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Zur Einordnung, bevor du tippst: Ein Artenkatalog für eine andere Region ist
|
||||||
|
ausdrücklich erwünscht und reine Datenarbeit — siehe docs/erkennung.md.
|
||||||
|
-->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!--
|
||||||
|
Danke. Die Spielregeln stehen in CONTRIBUTING.md — darin auch die
|
||||||
|
Rechteeinräumung, ohne die dieses Projekt nicht doppelt lizenziert werden
|
||||||
|
kann.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Was sich ändert
|
||||||
|
|
||||||
|
## Warum
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Das Interessantere von beidem. Der Code sagt, was passiert; die
|
||||||
|
Commit-Nachricht soll sagen, warum es nötig war.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Geprüft durch
|
||||||
|
|
||||||
|
- [ ] `./venv/bin/python -m unittest discover tests` (Detector)
|
||||||
|
- [ ] `cd web && ../venv/bin/python manage.py test` (Web-App)
|
||||||
|
- [ ] Seite im Browser angesehen — bei allem, was die Oberfläche berührt
|
||||||
|
- [ ] an echter Hardware ausprobiert — bei allem unter `pi/` oder `esp32cam/`
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Der dritte Punkt steht hier, weil er sich verdient hat: Zwei Fehler in diesem
|
||||||
|
Projekt sind durch alle Tests gekommen und erst beim Hinsehen aufgefallen.
|
||||||
|
|
||||||
|
Neue sichtbare Texte brauchen eine englische Fassung, sonst wird der
|
||||||
|
Übersetzungstest rot. Er druckt die fehlende Zeile kopierfertig aus.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Bezug
|
||||||
|
|
||||||
|
<!-- "Closes #123", falls es ein Issue schließt -->
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Modelle sind ~10 MB und stammen aus dem Netz — scripts/fetch_models.sh holt sie.
|
||||||
|
models/*.tflite
|
||||||
|
models/*.txt
|
||||||
|
|
||||||
|
web/db.sqlite3
|
||||||
|
web/db.sqlite3-*
|
||||||
|
web/media/
|
||||||
|
web/staticfiles/
|
||||||
|
|
||||||
|
testdata/
|
||||||
|
data/
|
||||||
|
# Testbilder von Commons — scripts/benchmark.py --hole lädt sie neu.
|
||||||
|
benchmark_data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
live/
|
||||||
|
|
||||||
|
# ESP-IDF: Buildartefakte, geholte Komponenten — und sdkconfig, weil dort das
|
||||||
|
# WLAN-Passwort aus 'idf.py menuconfig' landet. sdkconfig.defaults bleibt drin.
|
||||||
|
esp32cam/build/
|
||||||
|
esp32cam/managed_components/
|
||||||
|
esp32cam/sdkconfig
|
||||||
|
esp32cam/sdkconfig.old
|
||||||
|
esp32cam/dependencies.lock
|
||||||
|
# .mo wird aus .po gebaut — compilemessages beim Deploy
|
||||||
|
web/locale/*/LC_MESSAGES/*.mo
|
||||||
|
|
||||||
|
# Laufzeitdaten des zweiten Detectors (Traenke) -- Rohbilder,
|
||||||
|
# Protokoll, Treffer. Gehoert so wenig ins Repo wie live/.
|
||||||
|
live2/
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||||
|
Versionen nach [SemVer](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## 1.0.0 — 2026-10-18
|
||||||
|
|
||||||
|
Erste Veröffentlichung.
|
||||||
|
|
||||||
|
### Dabei
|
||||||
|
|
||||||
|
- **Arterkennung** aus einem Kamerastrom: ein Netz findet den Vogel im Bild,
|
||||||
|
ein zweites bestimmt die Art. Katalog mit 148 heimischen Arten, auf
|
||||||
|
Deutschland eingeschränkt und neu normiert — ohne diese Einschränkung wird
|
||||||
|
aus einer Kohlmeise eine nordamerikanische Schwesterart.
|
||||||
|
- **Sammlung** im Stil eines Klebebildchen-Albums: nummerierte Seiten,
|
||||||
|
gefundene Arten in Farbe, fehlende als Schatten. Erstfang, Punkte, Stufen
|
||||||
|
und 15 Abzeichen.
|
||||||
|
- **Läuft auf eigener Hardware.** Kein Cloud-Dienst, kein Konto, keine
|
||||||
|
Telemetrie. Die Bilder verlassen das eigene Netz nicht.
|
||||||
|
- **Zwei Teile, getrennt deploybar:** 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.
|
||||||
|
- **Kameras:** alles, was MJPEG oder Einzelbilder über HTTP liefert. Fertige
|
||||||
|
Firmware für Raspberry Pi (`pi/`) und ESP32-S3 (`esp32cam/`) liegt bei,
|
||||||
|
beide mit `/status` für die Diagnose, ohne den Bildstrom anzufassen.
|
||||||
|
- **Fotos von unterwegs:** Bilder lassen sich hochladen und werden von
|
||||||
|
derselben Erkennung bestimmt wie die der Kamera.
|
||||||
|
- **Home Assistant** über MQTT mit Autodiscovery.
|
||||||
|
- **Zweisprachig**, Deutsch und Englisch. Quelltext und Kommentare sind
|
||||||
|
deutsch.
|
||||||
|
- **Einstellungen je Kamera** aus der Oberfläche — Schwellwerte, Lebensräume,
|
||||||
|
Artenliste. Der Erkennungs-Dienst holt sie sich im Betrieb ab und übernimmt
|
||||||
|
sie, ohne das Modell neu zu laden.
|
||||||
|
- **Fehlalarme**: ein Mindestwert je Besuch verwirft Spiegelungen und
|
||||||
|
Bewegungen ohne Vogel, bevor daraus eine Sichtung wird.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Je Kamera ein eigener Token (als SHA-256 gespeichert), damit ein
|
||||||
|
kompromittierter Standort einzeln stillgelegt werden kann.
|
||||||
|
- Getrennte Token für Schreiben und Lesen.
|
||||||
|
- Hochgeladene Bilder werden **neu geschrieben statt nur geprüft**: Nur die
|
||||||
|
Pixel überleben, Metadaten fallen dabei weg. Das betrifft vor allem den Ort
|
||||||
|
in EXIF-Daten von Handyfotos.
|
||||||
|
- Im Betrieb: Cookies nur über HTTPS, HSTS, `nosniff` auf den Medien.
|
||||||
|
|
||||||
|
### Bekannte Grenzen
|
||||||
|
|
||||||
|
- Acht heimische Arten kennt das Modell nicht und ordnet sie Verwandten zu:
|
||||||
|
Sumpf-, Weiden- und Haubenmeise, Gartenbaumläufer, Kleinspecht,
|
||||||
|
Bluthänfling, Kernbeißer, Waldkauz.
|
||||||
|
- Der Vogel braucht **5 bis 8 % der Bildfläche**, sonst findet ihn die
|
||||||
|
Erkennung nicht. Das ist eine Frage des Bildwinkels, nicht der Auflösung —
|
||||||
|
näher montieren hilft, mehr Megapixel nicht.
|
||||||
|
- Der unterste Bildrand ist eine tote Zone. Die Landefläche gehört in die
|
||||||
|
untere Bildmitte, nicht an die Kante.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Mitmachen
|
||||||
|
|
||||||
|
Fehlerberichte, Verbesserungen und neue Kameraanbindungen sind willkommen.
|
||||||
|
Dieses Projekt ist zum Nachbauen gedacht — wer es aufbaut und dabei über eine
|
||||||
|
rauhe Kante stolpert, hilft mit einem Hinweis mehr als mit Schweigen.
|
||||||
|
|
||||||
|
## Fehler melden
|
||||||
|
|
||||||
|
Am nützlichsten ist ein Bericht, aus dem hervorgeht, **was du erwartet hast und
|
||||||
|
was stattdessen passiert ist**. Dazu:
|
||||||
|
|
||||||
|
- Welche Kamera (Pi Zero, ESP32-S3, etwas anderes) und welche Auflösung
|
||||||
|
- Die letzten Zeilen aus dem Protokoll des Detectors
|
||||||
|
- Bei Erkennungsfehlern: wenn möglich das Bild, um das es geht
|
||||||
|
|
||||||
|
⚠️ **Schwärze deine Zugangsdaten**, bevor du ein Protokoll einfügst — Tokens
|
||||||
|
stehen dort im Klartext.
|
||||||
|
|
||||||
|
## Code beisteuern
|
||||||
|
|
||||||
|
1. Bevor du etwas Größeres baust: ein Issue aufmachen und kurz abstimmen. Das
|
||||||
|
erspart dir die Arbeit, die ich vielleicht gerade selbst mache.
|
||||||
|
2. Die Tests müssen durchlaufen — **aus dem Ordner `web/` heraus**, sonst
|
||||||
|
findet Django keinen einzigen Test und meldet trotzdem Erfolg:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && ../venv/bin/python manage.py test
|
||||||
|
```
|
||||||
|
|
||||||
|
Es sind derzeit 105.
|
||||||
|
3. Kommentare und Oberfläche sind auf Deutsch, der Code folgt dem Stil der
|
||||||
|
Umgebung, in der er steht.
|
||||||
|
|
||||||
|
## Lizenz deines Beitrags
|
||||||
|
|
||||||
|
Dieses Projekt steht unter der **GNU AGPL-3.0** (siehe [LICENSE](LICENSE)), und
|
||||||
|
dein Beitrag wird unter derselben Lizenz veröffentlicht.
|
||||||
|
|
||||||
|
🔑 **Zusätzlich räumst du mir mit dem Einreichen eines Beitrags das Recht ein,
|
||||||
|
ihn auch unter anderen Lizenzbedingungen zu verwenden** — einschließlich einer
|
||||||
|
kommerziellen Lizenz. Das Recht ist einfach, zeitlich und räumlich unbeschränkt
|
||||||
|
und übertragbar. Die Urheberschaft bleibt bei dir, und dein Beitrag bleibt
|
||||||
|
selbstverständlich auch unter der AGPL verfügbar.
|
||||||
|
|
||||||
|
**Warum das nötig ist, offen gesagt:** Vogelalbum wird zusätzlich kommerziell
|
||||||
|
lizenziert — wer es in einem Produkt einsetzen will, ohne seinen eigenen
|
||||||
|
Quelltext offenlegen zu müssen, kann eine solche Lizenz erwerben. Doppelt
|
||||||
|
lizenzieren darf aber nur, wem alle Rechte am Werk zustehen. Ohne diese
|
||||||
|
Einräumung könnte ein einziger fremder Beitrag diesen Weg für das ganze
|
||||||
|
Projekt versperren, und das ließe sich später kaum noch reparieren, weil
|
||||||
|
dafür jeder Mitwirkende einzeln zustimmen müsste.
|
||||||
|
|
||||||
|
Wenn du damit nicht einverstanden bist, sag es bitte **vor** dem Einreichen.
|
||||||
|
Dann finden wir einen anderen Weg — ein Fehlerbericht mit genauer Beschreibung
|
||||||
|
ist oft genauso wertvoll wie fertiger Code, und für ihn gilt das alles nicht.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Nur die Web-App. Der Detector läuft auf dem Host, wo die Rechenleistung ist
|
||||||
|
# und wo die Kamera im selben Netz hängt — er gehört nicht in diesen Container.
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# gettext fuer 'compilemessages' weiter unten. Die .mo-Dateien sind
|
||||||
|
# Bauartefakte und liegen nicht im Repo; ohne diesen Schritt fiele die
|
||||||
|
# englische Fassung stillschweigend auf die deutschen Texte zurueck.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gettext \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements-web.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements-web.txt
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
# Der Artenkatalog wird von 'seed_dex' importiert.
|
||||||
|
#
|
||||||
|
# 🔴 Hier stand bis zum 09.10.2026 nur species_de.py — und damit hat jede
|
||||||
|
# frisch aufgesetzte Instanz **44 Arten ohne Steckbriefe** bekommen statt der
|
||||||
|
# 148, die README und Oberfläche nennen. seed_dex meldete das sogar
|
||||||
|
# ("Keine Steckbriefe gefunden"), nur hat es nie jemand gelesen: Die laufende
|
||||||
|
# Instanz wurde beim Umzug 1:1 kopiert und nie aus diesem Image geseedet.
|
||||||
|
#
|
||||||
|
# 🔑 Deshalb kommen alle fünf Datenquellen mit, in derselben Reihenfolge, in
|
||||||
|
# der seed_dex sie sucht:
|
||||||
|
# species_de.py 44 heimische Gartenvögel, dazu die Seltenheitsstufen
|
||||||
|
# species_extra.py der Rest auf 148
|
||||||
|
# species_info.py Steckbriefe
|
||||||
|
# species_info_en.py dieselben auf Englisch
|
||||||
|
# profiles/ die ausführlichen Zusätze
|
||||||
|
#
|
||||||
|
# Nach wie vor NICHT der ganze Detector: Alles hier ist reine Stdlib
|
||||||
|
# (dataclasses), zusammen 280 KB. classify.py und Konsorten bräuchten numpy
|
||||||
|
# und LiteRT und haben in diesem Bild nichts verloren.
|
||||||
|
COPY detector/__init__.py \
|
||||||
|
detector/species_de.py \
|
||||||
|
detector/species_extra.py \
|
||||||
|
detector/species_info.py \
|
||||||
|
detector/species_info_en.py \
|
||||||
|
/app/detector/
|
||||||
|
COPY detector/profiles/ /app/detector/profiles/
|
||||||
|
|
||||||
|
# Uebersetzungen binaer erzeugen. Zur Bauzeit, nicht beim Start: sie aendern
|
||||||
|
# sich nur mit dem Code.
|
||||||
|
RUN python manage.py compilemessages
|
||||||
|
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Der Detector als Image — fuer alle, die nicht erst einen Host einrichten
|
||||||
|
# wollen (venv, Modelle, systemd). Fuer den Dauerbetrieb auf einem eigenen
|
||||||
|
# Erkennungs-Host ist scripts/setup_detector_host.sh weiterhin der direktere
|
||||||
|
# Weg; dieses Image ist der kurze Einstieg.
|
||||||
|
#
|
||||||
|
# Achtung Architektur: ai-edge-litert liefert Wheels fuer x86_64 und arm64.
|
||||||
|
# Ein Raspberry Pi 4/5 mit 64-Bit-System geht damit, ein Pi Zero nicht — dem
|
||||||
|
# fehlt nicht das Wheel, sondern die Rechenleistung.
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# curl fuer fetch_models.sh. Bleibt drin, damit sich die Modelle in einem
|
||||||
|
# laufenden Container nachladen lassen, wenn eine Datei kaputt ist.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements-detector.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements-detector.txt
|
||||||
|
|
||||||
|
# Die Modelle liegen nicht im Repo (zusammen rund 10 MB) und aendern sich nie
|
||||||
|
# — deshalb beim Bauen holen und nicht bei jedem Start. Eigene Schicht: an den
|
||||||
|
# Modellen aendert sich nichts, wenn sich der Code aendert.
|
||||||
|
COPY scripts/fetch_models.sh scripts/
|
||||||
|
RUN bash scripts/fetch_models.sh
|
||||||
|
|
||||||
|
# Beispielfotos fuer 'detector karte' und 'detector test'.
|
||||||
|
#
|
||||||
|
# 🔴 Hier stand bis zum 09.10.2026 "COPY testdata/ ./testdata/" — und damit
|
||||||
|
# liess sich dieses Image aus einem frischen Klon ueberhaupt nicht bauen:
|
||||||
|
# testdata/ steht in .gitignore. Gemerkt hat es niemand, weil immer aus einer
|
||||||
|
# Arbeitskopie gebaut wurde, in der der Ordner lag.
|
||||||
|
#
|
||||||
|
# Die Fotos bleiben aus einem guten Grund draussen: Sie stammen von Wikimedia
|
||||||
|
# Commons, stehen unter CC-Lizenzen und verlangen Namensnennung. Das Skript
|
||||||
|
# holt sie samt Urheber und Lizenz.
|
||||||
|
#
|
||||||
|
# Mit '|| echo', weil ein Bauvorgang nicht daran scheitern soll, dass
|
||||||
|
# Wikimedia gerade drosselt. Ohne die Fotos fehlt nur der Standardwert von
|
||||||
|
# 'detector karte' — die Erkennung selbst braucht sie nicht.
|
||||||
|
COPY scripts/fetch_testdata.sh scripts/
|
||||||
|
RUN bash scripts/fetch_testdata.sh \
|
||||||
|
|| echo "WARNUNG: Beispielfotos nicht geholt — 'detector karte' braucht dann --vogel"
|
||||||
|
|
||||||
|
COPY detector/ ./detector/
|
||||||
|
|
||||||
|
# Sonst haengen die Ausgaben im Blockpuffer und 'docker logs' ist erst nach
|
||||||
|
# Stunden lesbar — genau dann, wenn man es nicht braucht.
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Rohbilder und Protokoll gehoeren auf ein Volume, sonst sind sie nach dem
|
||||||
|
# naechsten 'docker compose up --build' weg.
|
||||||
|
VOLUME ["/daten"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "-m", "detector"]
|
||||||
|
CMD ["watch"]
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# Vogelalbum
|
||||||
|
|
||||||
|
**Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
|
||||||
|
Sammlung, die sich von allein füllt.**
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](docs/installation.md)
|
||||||
|
[](#was-es-macht)
|
||||||
|
[](docs/erkennung.md)
|
||||||
|
|
||||||
|
**[→ Live ansehen: vogelalbum.gaertner.onl](https://vogelalbum.gaertner.onl)**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> **In English:** Vogelalbum identifies garden birds from a camera stream and turns
|
||||||
|
> the sightings into a collectible album that fills itself. It runs entirely on
|
||||||
|
> your own hardware — no cloud, no third-party services. The interface speaks
|
||||||
|
> German and English; the source code and its comments are German.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was es macht
|
||||||
|
|
||||||
|
Eine Kamera schaut aufs Futterhaus. Alles andere passiert von selbst:
|
||||||
|
|
||||||
|
- 🔍 **Erkennt die Art** — zwei neuronale Netze, eines findet den Vogel im Bild,
|
||||||
|
eines bestimmt ihn. Gemessen: 43 von 44 Arten richtig, keine falsch bestimmt.
|
||||||
|
- 🎴 **Sammelt** — jede neue Art ist ein Erstfang. 148 Arten, 15 Abzeichen, XP
|
||||||
|
und Level. Das ist der Teil, für den Kinder wiederkommen.
|
||||||
|
- 🏠 **Bleibt zu Hause** — kein Cloud-Dienst, keine Telemetrie, kein Konto. Die
|
||||||
|
Erkennung läuft auf deiner Hardware, die Bilder verlassen dein Netz nicht.
|
||||||
|
- 🔌 **Nimmt jede Kamera** — MJPEG, RTSP, Einzelbilder oder ein Ordner. Ein
|
||||||
|
Raspberry Pi Zero genügt, ein ESP32-S3 für 17 € auch.
|
||||||
|
- 🥔 **Läuft auf kleiner Hardware** — gemessen auf einem Raspberry Pi 4:
|
||||||
|
42 ms je bestimmtem Vogel, 1,1 GB Arbeitsspeicher. Zahlen für beide
|
||||||
|
getesteten Geräte in [docs/installation.md](docs/installation.md#getestet-auf).
|
||||||
|
- 📱 **Auch ohne Kamera** — Handyfotos lassen sich hochladen und werden genauso
|
||||||
|
bestimmt.
|
||||||
|
- 🏡 **Spricht mit Home Assistant** — über MQTT, mit Autodiscovery.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/startseite.png" width="49%" alt="Die Startseite">
|
||||||
|
<img src="docs/abzeichen.png" width="49%" alt="Die Abzeichen">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
Sammlung und Erkennung auf einer Maschine, ohne venv und ohne Modelle von Hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.gaertner.onl/christian/vogelalbum.git && cd vogelalbum
|
||||||
|
cp .env.beispiel .env # VOGELALBUM_SOURCE und ein Token eintragen
|
||||||
|
docker compose -f docker-compose.heim.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Sammlung läuft danach auf <http://localhost:8000>. Zwei Zeilen in der
|
||||||
|
`.env` müssen stimmen:
|
||||||
|
|
||||||
|
| Variable | was rein muss |
|
||||||
|
|---|---|
|
||||||
|
| `VOGELALBUM_SOURCE` | Stream-URL der Kamera, z.B. `http://vogelkamera.local:8080/stream` |
|
||||||
|
| `VOGELALBUM_INGEST_TOKEN` | ein selbst ausgedachtes Geheimnis, `openssl rand -base64 32` |
|
||||||
|
|
||||||
|
**Noch keine Kamera?** Es geht auch ohne — `manage.py demo_data` füllt die
|
||||||
|
Sammlung mit 21 Tagen Beispieldaten. Der Aufbau einer Kameraseite steht in
|
||||||
|
[pi/README.md](pi/README.md) (Raspberry Pi) oder
|
||||||
|
[esp32cam/README.md](esp32cam/README.md) (ESP32-S3, billiger).
|
||||||
|
|
||||||
|
Ohne Docker, Schritt für Schritt: **[docs/installation.md](docs/installation.md)**.
|
||||||
|
|
||||||
|
## Wie es aufgebaut ist
|
||||||
|
|
||||||
|
Zwei Teile, die unabhängig voneinander laufen und getrennt deploybar sind:
|
||||||
|
|
||||||
|
```
|
||||||
|
Heimnetz │ Internet
|
||||||
|
│
|
||||||
|
Kamera 1 ──MJPEG──▶ ┌──────────┐ │
|
||||||
|
Kamera 2 ──MJPEG──▶ │ detector │ ──POST────────▶ Django ──▶ Browser
|
||||||
|
Kamera n ──MJPEG──▶ │ │ Bearer │
|
||||||
|
└────┬─────┘ │
|
||||||
|
└──▶ Rohbilder │
|
||||||
|
(Training) │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die Verbindung geht nur in eine Richtung.** Der Detector meldet ausgehend an
|
||||||
|
`/api/sightings/`; die Web-App muss weder Kamera noch Erkennungs-Host jemals
|
||||||
|
erreichen. Damit muss im Heimnetz kein einziger Port nach außen offen sein —
|
||||||
|
kein Portforwarding, kein Tunnel, keine dynamische DNS. Genau deshalb kann die
|
||||||
|
Sammlung öffentlich stehen, während die Kameras es nicht sind.
|
||||||
|
|
||||||
|
Warum überhaupt getrennt? Ein Raspberry Pi Zero kann streamen, aber nicht
|
||||||
|
rechnen. Umgekehrt kennt die Web-App weder numpy noch LiteRT, was ihren
|
||||||
|
Container klein hält.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
| Kapitel | worum es geht |
|
||||||
|
|---|---|
|
||||||
|
| **[Installation](docs/installation.md)** | mit und ohne Docker, Schritt für Schritt |
|
||||||
|
| **[Die Kamera anschließen](docs/kamera.md)** | Stream-Formate, Schwellwerte — und wie groß ein Vogel im Bild sein muss |
|
||||||
|
| **[Wie die Erkennung funktioniert](docs/erkennung.md)** | drei Stufen, der Artenfilter, was das Modell nicht kann |
|
||||||
|
| **[Wie gut ist das wirklich](docs/genauigkeit.md)** | gemessene Zahlen, und was im Feld tatsächlich passiert ist |
|
||||||
|
| **[Einstellungen](docs/einstellungen.md)** | alle Variablen, und was stattdessen im Admin geht |
|
||||||
|
| **[Betrieb](docs/betrieb.md)** | mehrere Kameras, Live-Bild, Deployment |
|
||||||
|
| **[Entwicklung](docs/entwicklung.md)** | Tests, Übersetzungen, Artenkatalog erweitern |
|
||||||
|
| **[Datenquellen und Lizenzen](docs/datenquellen.md)** | woher Modelle und Fotos kommen |
|
||||||
|
|
||||||
|
Die Kameraseiten haben eigene Anleitungen: **[pi/](pi/README.md)** und
|
||||||
|
**[esp32cam/](esp32cam/README.md)**.
|
||||||
|
|
||||||
|
## Was es (noch) nicht kann
|
||||||
|
|
||||||
|
Ehrlicher als eine Merkmalsliste:
|
||||||
|
|
||||||
|
- ⚠️ **Der Katalog ist mitteleuropäisch.** 148 in Deutschland vorkommende Arten;
|
||||||
|
der Klassifikator rechnet seine Wahrscheinlichkeiten auf genau diese Liste
|
||||||
|
um. Das Modell selbst kennt rund 965 Arten weltweit — die Begrenzung ist der
|
||||||
|
Katalog, nicht die Erkennung. Die Region ist umschaltbar
|
||||||
|
(`VOGELALBUM_REGION`), es fehlen nur die Artenlisten anderer Regionen. Das ist
|
||||||
|
reine Datenarbeit und ein guter erster Beitrag.
|
||||||
|
- **Acht heimische Arten kennt das Modell nicht** und ordnet sie Verwandten zu
|
||||||
|
(Sumpf-, Weiden-, Haubenmeise, Gartenbaumläufer, Kleinspecht, Bluthänfling,
|
||||||
|
Kernbeißer, Waldkauz). Details in [docs/erkennung.md](docs/erkennung.md).
|
||||||
|
- **Der Aufbau entscheidet mehr als die Software.** Ein Vogel braucht 5–8 % der
|
||||||
|
Bildfläche, sonst findet ihn niemand. `python -m detector karte` misst das
|
||||||
|
vorher, statt es hinterher zu lernen — siehe [docs/kamera.md](docs/kamera.md).
|
||||||
|
|
||||||
|
## Mitmachen
|
||||||
|
|
||||||
|
Fehler, Ideen und Code sind willkommen. Die Spielregeln — und die
|
||||||
|
Rechteeinräumung, die für die Doppellizenzierung nötig ist — stehen in
|
||||||
|
**[CONTRIBUTING.md](CONTRIBUTING.md)**. Eine Sicherheitslücke bitte nicht als
|
||||||
|
Issue, sondern nach **[SECURITY.md](SECURITY.md)**.
|
||||||
|
|
||||||
|
Was sich wann geändert hat: **[CHANGELOG.md](CHANGELOG.md)**.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
**GNU Affero General Public License 3.0** ([LICENSE](LICENSE)). Benutzen,
|
||||||
|
verändern und weitergeben ist erlaubt; wer eine veränderte Fassung als Dienst
|
||||||
|
über ein Netz anbietet, muss deren Quelltext zugänglich machen. Für den Betrieb
|
||||||
|
im eigenen Garten ändert das nichts. Kommerzielle Nutzung ohne Offenlegung ist
|
||||||
|
über eine gesonderte Lizenz möglich: <webmaster@gaertner.onl>.
|
||||||
|
|
||||||
|
Modelle (Apache 2.0) und Referenzfotos aus Wikimedia Commons (meist CC BY-SA)
|
||||||
|
behalten ihre eigenen Lizenzen — Einzelheiten in
|
||||||
|
[docs/datenquellen.md](docs/datenquellen.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<sub>Kein Bild dieses Projekts verlässt das eigene Netz, außer man deployt die
|
||||||
|
Web-App selbst irgendwohin. Es gibt keine Telemetrie und keine Aufrufe an
|
||||||
|
Dritte außer den ausdrücklich angestoßenen Downloads von Modellen und
|
||||||
|
Referenzfotos.</sub>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Sicherheitslücken melden
|
||||||
|
|
||||||
|
**Bitte nicht als öffentliches Issue.** Eine Lücke, die in einem Issue steht,
|
||||||
|
ist eine Anleitung, solange sie nicht behoben ist.
|
||||||
|
|
||||||
|
**Melde sie an <webmaster@gaertner.onl>.** Vogelalbum ist ein Feierabendprojekt
|
||||||
|
einer einzelnen Person — eine Antwort kann ein paar Tage dauern. Kommt nach
|
||||||
|
einer Woche keine, gern noch einmal nachfassen; dann ist die Nachricht
|
||||||
|
untergegangen.
|
||||||
|
|
||||||
|
Hilfreich in der Meldung:
|
||||||
|
|
||||||
|
- was betroffen ist (Web-App, Detector, Kameraseite) und welche Fassung
|
||||||
|
- wie sich das Verhalten auslösen lässt, möglichst Schritt für Schritt
|
||||||
|
- was dabei herauskommt und warum das ein Problem ist
|
||||||
|
|
||||||
|
Wer eine Lücke meldet und bis zur Behebung nicht veröffentlicht, wird — auf
|
||||||
|
Wunsch — im Changelog namentlich genannt. Rechtliche Schritte gegen jemanden,
|
||||||
|
der auf diesem Weg und in gutem Glauben meldet, gibt es nicht.
|
||||||
|
|
||||||
|
## Was unterstützt wird
|
||||||
|
|
||||||
|
Vogelalbum hat noch keine Version. Gefixt wird der aktuelle Stand von `main`.
|
||||||
|
|
||||||
|
## Womit zu rechnen ist
|
||||||
|
|
||||||
|
Ein paar Dinge sind **Absicht** und keine Lücke — damit niemand Zeit damit
|
||||||
|
verliert:
|
||||||
|
|
||||||
|
- **Die Web-App hat keine Benutzerkonten.** Die Sammlung ist zum Ansehen da.
|
||||||
|
Wer sie öffentlich stellt, stellt sie öffentlich. Geschützt sind nur der
|
||||||
|
Django-Admin und das Upload-Formular.
|
||||||
|
- **Die API-Token stehen in Umgebungsvariablen**, nicht in einem Tresor. In
|
||||||
|
der Datenbank liegen sie als SHA-256.
|
||||||
|
- **Der Detector erreicht die Web-App, nicht umgekehrt.** Die Verbindung geht
|
||||||
|
nur in eine Richtung; das ist der Kern des Aufbaus und kein Versehen.
|
||||||
|
- **Die Kameraseiten (`pi/`, `esp32cam/`) kennen keine Authentifizierung.** Sie
|
||||||
|
gehören ins Heimnetz, nicht ans offene Internet. Wer den Stream nach außen
|
||||||
|
gibt, legt selbst etwas davor.
|
||||||
|
- **`VOGELALBUM_LIVE_ERLAUBT=0`** schaltet das Live-Bild hart ab, auch gegen einen
|
||||||
|
Fehlklick im Admin. Auf einer öffentlichen Instanz gehört die Variable auf
|
||||||
|
`0`.
|
||||||
|
|
||||||
|
## Was dagegen eine Meldung wert ist
|
||||||
|
|
||||||
|
- Lesen oder Schreiben von Sichtungen ohne gültigen Token
|
||||||
|
- Herauskommen aus dem Admin-Login oder aus dem Upload-Formular
|
||||||
|
- Pfade, über die sich Dateien außerhalb des Medienordners lesen oder
|
||||||
|
schreiben lassen
|
||||||
|
- alles, was aus einem hochgeladenen Bild Code macht
|
||||||
|
- ein Token, das in Logs, in der Prozessliste oder in der Oberfläche landet
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Die Web-App auf Coolify stellen
|
||||||
|
|
||||||
|
Für den Umzug der Sammlung von einem Rechner im Heimnetz auf eine öffentlich
|
||||||
|
erreichbare Instanz. Der Detector zieht **nicht** mit um — er braucht
|
||||||
|
Sichtverbindung zur Kamera und bleibt zu Hause; er bekommt am Ende nur eine
|
||||||
|
neue Adresse.
|
||||||
|
|
||||||
|
Vorlage ist `docker-compose.yml` im Wurzelverzeichnis, als
|
||||||
|
**Docker-Compose-Anwendung aus dem Git-Repository**.
|
||||||
|
|
||||||
|
## Was dieses Setup von einem Ein-Container-Deploy unterscheidet
|
||||||
|
|
||||||
|
Zwei Dienste, und der Eingang ist **nicht** die Django-App:
|
||||||
|
|
||||||
|
```
|
||||||
|
Traefik ──▶ medien (nginx, Port 8080) ──┬──▶ /media/… direkt vom Volume
|
||||||
|
└──▶ alles andere ▶ web (Gunicorn, 8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
🔑 **Die Domain muss auf `medien` zeigen, nicht auf `web`.** Django liefert
|
||||||
|
unter `DJANGO_DEBUG=0` keine Medien aus, und Coolify stellt keinen Webserver
|
||||||
|
davor, der das übernehmen könnte — Traefik routet auf einen Container und kennt
|
||||||
|
das Volume nicht. Zeigt die Domain auf `web`, läuft die Seite scheinbar
|
||||||
|
normal, aber **jedes Vogelfoto ist 404**: die Sammlung ist das Einzige, was
|
||||||
|
dieses Projekt ist, und sie wäre leer. Die ausführliche Begründung steht in
|
||||||
|
`nginx-media.conf`.
|
||||||
|
|
||||||
|
## 1. Anwendung anlegen
|
||||||
|
|
||||||
|
* Typ **Docker Compose**, Quelle das Git-Repository, Branch `main`,
|
||||||
|
Compose-Datei `docker-compose.yml`.
|
||||||
|
* Ist das Repository privat, muss Coolify über die Gitea-Anbindung
|
||||||
|
(GitHub-App-Äquivalent bzw. Deploy-Key) darauf zugreifen können — dasselbe
|
||||||
|
Verfahren wie bei den anderen Projekten auf derselben Instanz.
|
||||||
|
|
||||||
|
## 2. Domain auf den Dienst `medien`
|
||||||
|
|
||||||
|
In Coolify hat bei einer Compose-Anwendung **jeder Dienst sein eigenes
|
||||||
|
Domain-Feld**. Die Domain gehört an `medien` (Port 8080). `web` bekommt
|
||||||
|
**keine** Domain — der Dienst wird nur intern angesprochen.
|
||||||
|
|
||||||
|
Danach prüfen, dass der DNS-Eintrag der Domain auf die Coolify-Maschine zeigt.
|
||||||
|
|
||||||
|
## 3. Umgebungsvariablen
|
||||||
|
|
||||||
|
Pflicht:
|
||||||
|
|
||||||
|
| Variable | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `DJANGO_ALLOWED_HOSTS` | die Domain, z. B. `vogelalbum.gaertner.onl` |
|
||||||
|
| `DJANGO_CSRF_TRUSTED_ORIGINS` | dieselbe Domain **mit** `https://` |
|
||||||
|
|
||||||
|
Optional, aber meist gewollt:
|
||||||
|
|
||||||
|
| Variable | Wert |
|
||||||
|
|---|---|
|
||||||
|
| `DJANGO_SECRET_KEY` | leer lassen ist in Ordnung — der Entrypoint erzeugt einen und legt ihn aufs Volume, er bleibt über Neustarts stabil |
|
||||||
|
| `VOGELALBUM_INGEST_TOKEN` | **leer lassen**, wenn jede Kamera im Admin registriert ist und ihren eigenen Token hat. Leer schaltet den Sammel-Token ab, und das ist für eine öffentliche Instanz das Richtige |
|
||||||
|
| `VOGELALBUM_LESE_TOKEN` | nur nötig, wenn Home Assistant `/api/zustand/` abfragt. **Leer heißt: der Endpunkt antwortet mit 401** — wer die Einbindung zu Hause schon hat und das hier vergisst, sucht den Fehler im Heimnetz |
|
||||||
|
|
||||||
|
Nicht anfassen, die stehen fest in `docker-compose.yml`:
|
||||||
|
`DJANGO_DEBUG=0`, `VOGELALBUM_LIVE_ERLAUBT=0`, `SQLITE_DIR=/data`,
|
||||||
|
`MEDIA_ROOT=/data/media`.
|
||||||
|
|
||||||
|
⚠️ **Lässt man Coolify ein Geheimnis erzeugen, darf es kein `$` enthalten.**
|
||||||
|
Docker Compose liest `$xxx` als Variable und zerstört den Wert dabei still.
|
||||||
|
Selbst erzeugen mit `openssl rand -base64 32`.
|
||||||
|
|
||||||
|
🔒 `VOGELALBUM_LIVE_ERLAUBT=0` ist der Grund, warum auf der öffentlichen Instanz
|
||||||
|
kein Livebild einschaltbar ist — auch nicht mit einem Fehlklick im Admin. Ein
|
||||||
|
Bild aus dem eigenen Garten soll nicht an einem Häkchen hängen.
|
||||||
|
|
||||||
|
## 4. Volume
|
||||||
|
|
||||||
|
Das Compose-File deklariert das benannte Volume `vogelalbum-data` und hängt es in
|
||||||
|
beide Dienste (`web` beschreibbar, `medien` nur lesend). Coolify übernimmt das;
|
||||||
|
es muss nichts von Hand angelegt werden.
|
||||||
|
|
||||||
|
⚠️ **Dort liegen Datenbank *und* Fotos.** `SQLITE_DIR` und `MEDIA_ROOT` zeigen
|
||||||
|
beide auf `/data`. Fehlt das Volume oder zeigt eine der beiden Variablen
|
||||||
|
woandershin, ist die Sammlung nach jedem Deploy weg.
|
||||||
|
|
||||||
|
## 5. Erster Start
|
||||||
|
|
||||||
|
Der Entrypoint erledigt `migrate`, `collectstatic`, `seed_dex` und holt
|
||||||
|
anschließend die Referenzfotos der Arten von Wikipedia.
|
||||||
|
|
||||||
|
⏱️ **Der erste Start dauert deshalb mehrere Minuten** — rund 150 Arten, und
|
||||||
|
Wikimedia drosselt, weshalb zwischen den Abrufen gewartet wird. Das ist kein
|
||||||
|
hängender Deploy. Jeder weitere Start überspringt alle Arten, die schon ein
|
||||||
|
Foto haben, und kostet keine einzige Anfrage mehr.
|
||||||
|
|
||||||
|
Danach steht eine leere Sammlung: Artenkatalog vollständig, keine Sichtungen.
|
||||||
|
|
||||||
|
## 6. Eine bestehende Sammlung mitnehmen
|
||||||
|
|
||||||
|
Wer von einer Heiminstanz umzieht, bringt Datenbank und Fotos mit. Beides liegt
|
||||||
|
beim Umzugspaket im selben Layout, in dem es auf dem Volume liegen muss:
|
||||||
|
|
||||||
|
```
|
||||||
|
db.sqlite3
|
||||||
|
media/sightings/…
|
||||||
|
media/crops/…
|
||||||
|
media/referenz/…
|
||||||
|
```
|
||||||
|
|
||||||
|
🔑 **Eine 1:1-Kopie, keine Neueinlieferung über die API.** Nur so kommen die
|
||||||
|
Kamera-Tokens, die Schwellwerte je Kamera, Handbestimmungen, Abzeichen und
|
||||||
|
Erstfang-Vermerke unverändert mit. Die Kameras zu Hause liefern danach mit
|
||||||
|
**ihrem bisherigen Token** weiter — es muss nichts neu registriert werden.
|
||||||
|
|
||||||
|
Auf der Heiminstanz ein konsistentes Paket bauen (`VACUUM INTO` kopiert die
|
||||||
|
Datenbank sauber, auch während die App läuft):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /pfad/zum/vogelalbum
|
||||||
|
python - <<'PY'
|
||||||
|
import sqlite3
|
||||||
|
sqlite3.connect('web/db.sqlite3').execute("VACUUM INTO '/tmp/db.sqlite3'")
|
||||||
|
PY
|
||||||
|
mkdir -p /tmp/umzug/media
|
||||||
|
mv /tmp/db.sqlite3 /tmp/umzug/
|
||||||
|
cp -r web/media/sightings web/media/crops web/media/referenz /tmp/umzug/media/
|
||||||
|
tar czf /tmp/vogelalbum-daten.tgz -C /tmp/umzug db.sqlite3 media
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Paket auf die Coolify-Maschine legen, dann dort:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Anwendung anhalten (in Coolify: Stop) — SQLite nicht im Betrieb tauschen.
|
||||||
|
VOL=$(docker volume ls -q | grep vogelalbum-data | head -1)
|
||||||
|
docker run --rm -v "$VOL":/data -v /root:/rein:ro alpine \
|
||||||
|
sh -c 'tar xzf /rein/vogelalbum-daten.tgz -C /data && chown -R 0:0 /data && ls -la /data'
|
||||||
|
# Anwendung wieder starten.
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Container läuft als `root`, daher `chown -R 0:0`.
|
||||||
|
|
||||||
|
## 7. Nach dem Umzug prüfen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sI https://DEINE-DOMAIN/ | head -1 # 200
|
||||||
|
curl -sI https://DEINE-DOMAIN/sichtungen/ | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Und im Browser: **eine Sichtungskarte muss ihr Foto zeigen.** Ein 404 auf
|
||||||
|
`/media/…` heißt, die Domain hängt am falschen Dienst (Schritt 2).
|
||||||
|
|
||||||
|
## 8. Die Kameras zu Hause umstellen
|
||||||
|
|
||||||
|
Jeder Detector und jede weitere Anbindung zeigt noch auf die alte Adresse im
|
||||||
|
Heimnetz. Umzustellen ist:
|
||||||
|
|
||||||
|
* **jeder Detector**: `VOGELALBUM_API` auf `https://DEINE-DOMAIN` (in
|
||||||
|
`~/.vogelalbum.env` bzw. `--api`), danach neu starten. Laufen zwei Kameras,
|
||||||
|
gibt es zwei Detector-Prozesse — womöglich auf zwei verschiedenen Maschinen.
|
||||||
|
* **Home Assistant**, falls eingebunden: neue Adresse und `VOGELALBUM_LESE_TOKEN`.
|
||||||
|
|
||||||
|
Dass eine Kamera angekommen ist, sieht man im Admin an `letzter_kontakt` —
|
||||||
|
der Konfigurationsabruf setzt es, auch ohne einen einzigen Vogel.
|
||||||
|
|
||||||
|
## 9. Was bewusst nicht mit umzieht
|
||||||
|
|
||||||
|
* **Der Detector.** Er braucht die Modelle, Rechenzeit und das Kameranetz.
|
||||||
|
* **Das Livebild.** `VOGELALBUM_LIVE_ERLAUBT=0`, siehe oben.
|
||||||
|
* **Der Störungswächter** (`vogelalbum-stoerungen.service`). 🔑 Er darf nicht auf
|
||||||
|
derselben Maschine laufen wie das, was er überwacht, und nicht an derselben
|
||||||
|
Stromversorgung: beim Stromausfall am 30.09.2026 war er von 22:31 bis 06:19
|
||||||
|
stumm, weil er selbst tot war. Wer das Ausbleiben von Sichtungen bemerken
|
||||||
|
soll, muss von außen schauen.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Der Medien-Eingang, mit seiner Konfiguration fest im Image.
|
||||||
|
#
|
||||||
|
# 🔑 Warum nicht einfach die .conf hineinmounten: Einzeldatei-Bind-Mounts sind
|
||||||
|
# auf Coolify brüchig. Schlägt ein Deploy fehl, legt Docker die fehlende
|
||||||
|
# Mount-Quelle als leeres VERZEICHNIS auf dem Host an — der nächste Versuch
|
||||||
|
# mountet dann ein Verzeichnis auf eine Datei und der Container kommt in eine
|
||||||
|
# Absturzschleife, die nach einem Konfigurationsfehler aussieht und keiner
|
||||||
|
# ist. Das hat auf derselben Coolify-Instanz schon einmal einen Abend
|
||||||
|
# gekostet; deshalb hier gebacken statt gemountet.
|
||||||
|
#
|
||||||
|
# Preis dafür: eine Änderung an nginx-media.conf braucht einen Rebuild, nicht
|
||||||
|
# nur einen Neustart. Die Datei ändert sich praktisch nie.
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY deploy/nginx-media.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Eingang der oeffentlichen Instanz: liefert die Medien selbst aus und reicht
|
||||||
|
# alles andere an Gunicorn weiter.
|
||||||
|
#
|
||||||
|
# 🔑 Warum es diesen Dienst ueberhaupt gibt: Django liefert unter
|
||||||
|
# DJANGO_DEBUG=0 keine Medien aus (siehe web/vogelalbum/urls.py). Im Betrieb soll
|
||||||
|
# das der Webserver tun -- nur stellt Coolify keinen: Traefik routet auf einen
|
||||||
|
# Container und kennt das Volume nicht. Ohne diesen Dienst antwortet die
|
||||||
|
# oeffentliche Instanz auf jedes /media/... mit 404, und jede Karte der
|
||||||
|
# Sammlung haette ein kaputtes Bild.
|
||||||
|
#
|
||||||
|
# ⚠️ Whitenoise waere hier die falsche Loesung, obwohl es im Projekt schon
|
||||||
|
# steckt: es liest seinen Bestand beim Start ein. Vogelfotos kommen aber
|
||||||
|
# laufend dazu -- jede neue Sichtung waere bis zum naechsten Neustart
|
||||||
|
# unsichtbar.
|
||||||
|
|
||||||
|
# 🔴 **Das Schema kommt von Traefik, nicht von uns.**
|
||||||
|
#
|
||||||
|
# Hier stand `proxy_set_header X-Forwarded-Proto $scheme;` — und `$scheme` ist
|
||||||
|
# das Schema, mit dem jemand DIESEN nginx anspricht. Der lauscht auf einfachem
|
||||||
|
# HTTP hinter Traefik, also stand dort immer `http`, und zwar überschreibend:
|
||||||
|
# Traefik hatte den Kopf schon richtig auf `https` gesetzt, wir haben ihn
|
||||||
|
# wieder kaputtgemacht.
|
||||||
|
#
|
||||||
|
# Folge: Django hielt sich trotz SECURE_PROXY_SSL_HEADER für unverschlüsselt
|
||||||
|
# und baute **jede** absolute Adresse mit `http://` — Vogelfotos, Abzeichen-
|
||||||
|
# Pins, Links in Mails. Im Browser unsichtbar, weil Traefik auf `https`
|
||||||
|
# umleitet. **Auf dem Telefon ein Ausfall:** iOS blockt einfaches HTTP, ohne
|
||||||
|
# der Umleitung zu folgen; das Album bleibt bilderlos.
|
||||||
|
#
|
||||||
|
# Die Abbildung nimmt den Kopf von vorne, wenn es einen gibt, und fällt sonst
|
||||||
|
# auf das eigene Schema zurück — damit funktioniert dieselbe Konfiguration
|
||||||
|
# auch ohne Traefik davor (`docker compose` zu Hause).
|
||||||
|
#
|
||||||
|
# ⚠️ `map` gehört auf die http-Ebene, nicht in den server-Block. Diese Datei
|
||||||
|
# liegt als conf.d/default.conf genau dort.
|
||||||
|
map $http_x_forwarded_proto $eingangs_schema {
|
||||||
|
default $http_x_forwarded_proto;
|
||||||
|
"" $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# ⚠️ nginx erlaubt von Haus aus nur 1 MB pro Anfrage. Der Detector liefert
|
||||||
|
# seine Bilder per POST ein; bei hoher Aufloesung ist ein Vogelfoto
|
||||||
|
# schnell groesser, und die Kamera bekaeme ein 413 zurueck, waehrend im
|
||||||
|
# Django-Protokoll nichts stuende.
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
# Die Medien direkt vom Volume. Dieselbe Platte, die der Web-Dienst unter
|
||||||
|
# MEDIA_ROOT=/data/media beschreibt.
|
||||||
|
location /media/ {
|
||||||
|
alias /data/media/;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
|
||||||
|
# ⚠️ **Hier liegen fremde Dateien auf unserer Herkunft.** Die Medien
|
||||||
|
# kommen von Kameras und aus Uploads; sie werden zwar beim Annehmen
|
||||||
|
# neu geschrieben (web/dex/bilder.py), aber dieser Pfad liefert aus,
|
||||||
|
# was auf der Platte liegt — auch Altbestand von vorher.
|
||||||
|
#
|
||||||
|
# nosniff: Der Browser soll den Inhaltstyp nicht erraten. Ohne das
|
||||||
|
# kann eine Datei, die wie ein Bild heißt, als etwas anderes
|
||||||
|
# ausgeführt werden, und zwar same-origin mit dem Admin-Cookie.
|
||||||
|
# Content-Disposition: notfalls herunterladen statt anzeigen.
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://web:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# ⚠️ Ohne diesen Kopf haelt Django sich fuer unverschluesselt und weist
|
||||||
|
# jedes Formular mit einem CSRF-Fehler ab -- der Login im Admin
|
||||||
|
# waere nicht benutzbar. Siehe die Abbildung oben: hier stand
|
||||||
|
# $scheme, und das war genau die Luege, die es zu vermeiden galt.
|
||||||
|
proxy_set_header X-Forwarded-Proto $eingangs_schema;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Detector als Dienst auf dem dedizierten Erkennungs-Host.
|
||||||
|
#
|
||||||
|
# Wird von scripts/setup_detector_host.sh installiert. Von Hand:
|
||||||
|
# sudo cp deploy/vogelalbum-detector.service /etc/systemd/system/
|
||||||
|
# sudo systemctl daemon-reload && sudo systemctl enable --now vogelalbum-detector
|
||||||
|
#
|
||||||
|
# Zugangsdaten in /etc/vogelalbum.env (chmod 600, root:root):
|
||||||
|
# VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream
|
||||||
|
# VOGELALBUM_API=https://vogelalbum.gaertner.onl
|
||||||
|
# VOGELALBUM_TOKEN=<Token aus 'manage.py camera add'>
|
||||||
|
# VOGELALBUM_SAVE_TO=/var/lib/vogelalbum/rohdaten
|
||||||
|
#
|
||||||
|
# Für eine zweite Kamera: Datei als vogelalbum-detector@.service ablegen, %i im
|
||||||
|
# EnvironmentFile-Pfad verwenden und je Kamera eine /etc/vogelalbum-<name>.env
|
||||||
|
# anlegen. Dann läuft `systemctl enable --now vogelalbum-detector@nistkasten`.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelalbum Detector (Arterkennung am Vogelhaus)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vogelalbum
|
||||||
|
Group=vogelalbum
|
||||||
|
WorkingDirectory=/opt/vogelalbum
|
||||||
|
EnvironmentFile=/etc/vogelalbum.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Die Schwellwerte stehen bewusst hier und nicht im Code: sie gehören zur
|
||||||
|
# Kamera, nicht zum Programm. 'gate detector' ignoriert Bewegung — nötig,
|
||||||
|
# weil unsere Kamera auf eine Hecke schaut, die im Wind steht.
|
||||||
|
# --nachtsicht ergaenzen, wenn die Kamera Infrarot hat. Ohne den Schalter
|
||||||
|
# pausiert die Analyse, sobald das Bild zu dunkel zum Erkennen ist.
|
||||||
|
ExecStart=/opt/vogelalbum/venv/bin/python -m detector watch \
|
||||||
|
--gate detector \
|
||||||
|
--classify-every 2 \
|
||||||
|
--cooldown 5 \
|
||||||
|
--min-bird-frames 1 \
|
||||||
|
--verbose
|
||||||
|
|
||||||
|
# Der Stream reißt nachts gern mal ab; einfach wieder hochkommen.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
# Ein Bilderkenner braucht weder Schreibzugriff aufs System noch neue Rechte.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
ReadWritePaths=/var/lib/vogelalbum
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Ein Detector je Kamera. Der Name hinter dem @ waehlt die Umgebungsdatei:
|
||||||
|
#
|
||||||
|
# systemctl enable --now vogelalbum-detector@futterhaus
|
||||||
|
# -> liest /etc/vogelalbum-futterhaus.env
|
||||||
|
#
|
||||||
|
# Genau so vorgeschlagen im Kopf von vogelalbum-detector.service, umgesetzt am
|
||||||
|
# 24.09.2026 beim Aufsetzen von LXC 112.
|
||||||
|
#
|
||||||
|
# Zugangsdaten je Kamera in /etc/vogelalbum-<name>.env (chmod 600, root:root):
|
||||||
|
# VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream
|
||||||
|
# VOGELALBUM_API=http://sammlung.local:8000
|
||||||
|
# VOGELALBUM_TOKEN=<Token aus dem Backend>
|
||||||
|
# VOGELALBUM_SAVE_TO=/var/lib/vogelalbum/<name>/rohdaten
|
||||||
|
# VOGELALBUM_LIVE_PORT=8131
|
||||||
|
# VOGELALBUM_KLASSIFIZIERE=2
|
||||||
|
#
|
||||||
|
# 🔑 Ein Dienst je Kamera und nicht einer fuer beide: Der Detector liest
|
||||||
|
# genau einen Bildstrom. Abwechselnd zu lesen hiesse, die Aufmerksamkeit je
|
||||||
|
# Kamera zu halbieren -- und an der Traenke ist ein Vogel womoeglich kuerzer
|
||||||
|
# da als am Futterhaus.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelalbum-Detector fuer Kamera %i
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vogelalbum
|
||||||
|
Group=vogelalbum
|
||||||
|
WorkingDirectory=/opt/vogelalbum
|
||||||
|
EnvironmentFile=/etc/vogelalbum-%i.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Die Schwellwerte stehen bewusst hier und nicht im Code: sie gehören zur
|
||||||
|
# Kamera, nicht zum Programm. 'gate detector' ignoriert Bewegung — nötig,
|
||||||
|
# weil unsere Kamera auf eine Hecke schaut, die im Wind steht.
|
||||||
|
# --nachtsicht ergaenzen, wenn die Kamera Infrarot hat. Ohne den Schalter
|
||||||
|
# pausiert die Analyse, sobald das Bild zu dunkel zum Erkennen ist.
|
||||||
|
ExecStart=/opt/vogelalbum/venv/bin/python -m detector watch \
|
||||||
|
--gate detector \
|
||||||
|
--classify-every ${VOGELALBUM_KLASSIFIZIERE} \
|
||||||
|
--cooldown 5 \
|
||||||
|
--min-bird-frames 1 \
|
||||||
|
--verbose
|
||||||
|
|
||||||
|
# Der Stream reißt nachts gern mal ab; einfach wieder hochkommen.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
# ⚠ Die Kameras lassen nur EINEN Zuschauer zu. Beim Neustart muss der alte
|
||||||
|
# Prozess seine Verbindung erst loslassen, sonst bekommt der neue nichts.
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
# Ein Bilderkenner braucht weder Schreibzugriff aufs System noch neue Rechte.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
ReadWritePaths=/var/lib/vogelalbum
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Vorlage für die Zugangsdaten der Home-Assistant-Brücke.
|
||||||
|
#
|
||||||
|
# ⚠ Die echte Datei gehört NICHT ins Repo. Kopieren nach
|
||||||
|
# ~/.config/vogelalbum-ha.env und mit chmod 600 schützen.
|
||||||
|
|
||||||
|
VOGELALBUM_URL=http://127.0.0.1:8130
|
||||||
|
VOGELALBUM_LESE_TOKEN=
|
||||||
|
VOGELALBUM_INSTANZ=garten
|
||||||
|
VOGELALBUM_TAKT=10
|
||||||
|
|
||||||
|
MQTT_HOST=
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_BENUTZER=
|
||||||
|
MQTT_PASSWORT=
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Brücke von Vogelalbum nach Home Assistant (MQTT-Discovery).
|
||||||
|
#
|
||||||
|
# ⚠ Läuft dort, wo die WEB-ANWENDUNG steht — sie holt sich den Zustand
|
||||||
|
# über deren Schnittstelle. Zieht die Weboberfläche um, zieht diese
|
||||||
|
# Einheit mit.
|
||||||
|
#
|
||||||
|
# Die Zugangsdaten stehen in ~/.config/vogelalbum-ha.env, nicht hier und
|
||||||
|
# nicht im Repo.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelalbum: Bruecke nach Home Assistant
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/vogelalbum-web
|
||||||
|
EnvironmentFile=%h/.config/vogelalbum-ha.env
|
||||||
|
ExecStart=/opt/vogelalbum-web/venv/bin/python homeassistant/bruecke.py
|
||||||
|
# Stirbt sie, sagt der letzte Wille Home Assistant Bescheid -- und systemd
|
||||||
|
# holt sie zurueck. Beides zusammen heisst: kein stummer Ausfall.
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Prueft, ob sich die Kameras noch melden.
|
||||||
|
#
|
||||||
|
# ⚠ Laeuft dort, wo die WEB-ANWENDUNG steht -- nicht beim Detector. Der
|
||||||
|
# Befehl braucht die Datenbank, und ein abgestuerzter Detector kann
|
||||||
|
# seinen eigenen Ausfall ohnehin nicht melden. Wer das Ausbleiben
|
||||||
|
# bemerken soll, muss von aussen schauen.
|
||||||
|
#
|
||||||
|
# Zieht die Weboberflaeche nach Coolify um, zieht diese Einheit mit.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelalbum: melden, wenn eine Kamera schweigt
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/vogelalbum-web
|
||||||
|
ExecStart=/opt/vogelalbum-web/venv/bin/python web/manage.py stoerungen_pruefen
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Stoerungswache alle zehn Minuten
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Zehn Minuten sind ein Kompromiss: Haeufiger meldet nichts frueher, weil
|
||||||
|
# die Stille-Grenze im Backend ohnehin bei 30 Minuten liegt. Seltener
|
||||||
|
# hiesse, einen Ausfall spaeter zu bemerken als noetig.
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#
|
||||||
|
# Fotos bestimmen, die über die App oder das Formular hochgeladen wurden.
|
||||||
|
#
|
||||||
|
# 🔑 **Der Dienst holt sich die Arbeit SELBST ab.** Er fragt die Web-App
|
||||||
|
# jede Minute nach offenen Fotos (GET /api/offen/) und meldet das Ergebnis
|
||||||
|
# zurück (POST /api/offen/<id>/). Er braucht deshalb weder eine erreichbare
|
||||||
|
# Adresse noch einen offenen Port — dieselbe Richtung wie bei der
|
||||||
|
# Fernkonfiguration, und aus demselben Grund: Der Erkennungs-Host steht im
|
||||||
|
# Heimnetz hinter einem Router, die Web-App nicht.
|
||||||
|
#
|
||||||
|
# ⚠️ **Ohne diesen Dienst bleibt JEDES App-Foto unbestimmt liegen.** Die App
|
||||||
|
# zeigt dann dauerhaft "wird bestimmt …", und zwar ohne Fehler — der Upload
|
||||||
|
# war ja erfolgreich. Das sieht nach einem Fehler in der App aus und liegt
|
||||||
|
# auf einer anderen Maschine.
|
||||||
|
#
|
||||||
|
# Gehört auf denselben Host wie die Kamera-Detektoren: Dort liegen die
|
||||||
|
# Modelle und das venv schon.
|
||||||
|
#
|
||||||
|
# cp deploy/vogelalbum-warteschlange.service /etc/systemd/system/
|
||||||
|
# # Zugangsdaten anlegen (chmod 600!):
|
||||||
|
# cat > /etc/vogelalbum-warteschlange.env <<ENV
|
||||||
|
# VOGELALBUM_API=https://<deine-instanz>
|
||||||
|
# VOGELALBUM_TOKEN=<Token einer Kamera -- zum Zurueckmelden>
|
||||||
|
# VOGELALBUM_LESE_TOKEN=<Lese-Token -- zum Abrufen der offenen Fotos>
|
||||||
|
# ENV
|
||||||
|
# chmod 600 /etc/vogelalbum-warteschlange.env
|
||||||
|
# systemctl enable --now vogelalbum-warteschlange
|
||||||
|
#
|
||||||
|
# ✅ **Einmal von Hand auslösen, bevor man sich darauf verlässt**: ein Foto
|
||||||
|
# hochladen und `journalctl -u vogelalbum-warteschlange -f` mitlesen. Ein
|
||||||
|
# Dienst, der sauber läuft und nichts tut, sieht im Status genauso aus wie
|
||||||
|
# einer, der arbeitet.
|
||||||
|
#
|
||||||
|
# 🔑 Zum Prüfen ein Bild OHNE Vogel nehmen. Dann durchläuft es die ganze
|
||||||
|
# Kette, aber es entsteht keine Sichtung — ein Testvogel in einer echten
|
||||||
|
# Sammlung muss hinterher wieder heraus.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelalbum: Fotos aus der App bestimmen
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=vogelalbum
|
||||||
|
Group=vogelalbum
|
||||||
|
WorkingDirectory=/opt/vogelalbum
|
||||||
|
EnvironmentFile=/etc/vogelalbum-warteschlange.env
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# --alle 60: Häufiger bringt nichts. Ein gerade hochgeladenes Foto ist beim
|
||||||
|
# nächsten Durchgang dran, und die App wartet ohnehin mit wachsenden
|
||||||
|
# Abständen auf das Ergebnis.
|
||||||
|
ExecStart=/opt/vogelalbum/venv/bin/python -m detector warteschlange --alle 60
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=15
|
||||||
|
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
ReadWritePaths=/var/lib/vogelalbum
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Nur die Erkennung: der Rechner mit Sichtverbindung zur Kamera, der an eine
|
||||||
|
# Sammlung woanders liefert.
|
||||||
|
#
|
||||||
|
# cp .env.beispiel .env # Abschnitt "Nur der Detector" ausfuellen
|
||||||
|
# docker compose -f docker-compose.detector.yml up -d
|
||||||
|
#
|
||||||
|
# Der typische Fall: Die Sammlung steht oeffentlich im Internet, die Kameras
|
||||||
|
# stehen zu Hause. Dieser Container gehoert ins Heimnetz, in dasselbe Netz wie
|
||||||
|
# die Kamera.
|
||||||
|
#
|
||||||
|
# 🔑 **Nach aussen muss dafuer kein Port offen sein.** Die Verbindung geht nur
|
||||||
|
# in eine Richtung: Der Detector meldet ausgehend an die Sammlung, die
|
||||||
|
# Sammlung erreicht ihn nie. Kein Portforwarding, kein Tunnel, kein DynDNS.
|
||||||
|
#
|
||||||
|
# ⚠️ Architektur: ai-edge-litert gibt es als Wheel fuer x86_64 und arm64. Ein
|
||||||
|
# Raspberry Pi 4/5 mit 64-Bit-System traegt den Detector, ein Pi Zero nicht —
|
||||||
|
# dem fehlt nicht das Wheel, sondern die Rechenleistung.
|
||||||
|
#
|
||||||
|
# Fuer mehrere Kameras: diese Datei je Kamera mit eigenem Projektnamen und
|
||||||
|
# eigenem Token starten, siehe docs/betrieb.md.
|
||||||
|
|
||||||
|
name: vogelalbum-detector
|
||||||
|
|
||||||
|
services:
|
||||||
|
detector:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.detector
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
VOGELALBUM_SOURCE: ${VOGELALBUM_SOURCE:?Stream-URL der Kamera, siehe .env.beispiel}
|
||||||
|
# Wohin gemeldet wird. Anders als bei docker-compose.heim.yml steht die
|
||||||
|
# Sammlung hier NICHT im selben Compose-Netz, also die volle Adresse —
|
||||||
|
# mit https://, wenn sie oeffentlich steht.
|
||||||
|
VOGELALBUM_API: ${VOGELALBUM_API:?Adresse der Sammlung, z.B. https://vogelalbum.example.org}
|
||||||
|
# Der Token DIESER Kamera, angelegt mit 'manage.py camera add'. Auf
|
||||||
|
# einer oeffentlichen Instanz hat jede Kamera ihren eigenen; der
|
||||||
|
# gemeinsame VOGELALBUM_INGEST_TOKEN bleibt dort leer.
|
||||||
|
VOGELALBUM_TOKEN: ${VOGELALBUM_TOKEN:?Token dieser Kamera, siehe manage.py camera add}
|
||||||
|
VOGELALBUM_GATE: ${VOGELALBUM_GATE:-detector}
|
||||||
|
VOGELALBUM_SAVE_TO: /daten/rohdaten
|
||||||
|
VOGELALBUM_PROTOKOLL: /daten/protokoll.csv
|
||||||
|
VOGELALBUM_NACHTSICHT: ${VOGELALBUM_NACHTSICHT:-}
|
||||||
|
VOGELALBUM_REGION: ${VOGELALBUM_REGION:-de}
|
||||||
|
# Schwellwerte und Artenliste holt sich der Detector von der Sammlung.
|
||||||
|
# Faellt sie aus, laeuft er mit dem letzten Stand weiter und warnt nur.
|
||||||
|
VOGELALBUM_KONFIG_ALLE: ${VOGELALBUM_KONFIG_ALLE:-300}
|
||||||
|
VOGELALBUM_LIVE_PORT: ${VOGELALBUM_LIVE_PORT:-0}
|
||||||
|
command:
|
||||||
|
- watch
|
||||||
|
- --classify-every=2
|
||||||
|
- --cooldown=5
|
||||||
|
- --min-bird-frames=1
|
||||||
|
- --verbose
|
||||||
|
# Gueltige Portnummer als Vorgabe, nicht 0 — Compose weist "0:0" ab, siehe
|
||||||
|
# die ausfuehrliche Begruendung in docker-compose.heim.yml. Ob etwas
|
||||||
|
# lauscht, entscheidet VOGELALBUM_LIVE_PORT oben.
|
||||||
|
ports:
|
||||||
|
- "${VOGELALBUM_LIVE_PORT:-8131}:${VOGELALBUM_LIVE_PORT:-8131}"
|
||||||
|
volumes:
|
||||||
|
# Rohbilder und Protokoll. Die Rohbilder sind spaeter das Material fuer
|
||||||
|
# ein Nachtraining — deshalb ein Volume und kein tmpfs.
|
||||||
|
- vogelalbum-rohdaten:/daten
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vogelalbum-rohdaten:
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Vogelalbum zu Hause: Sammlung und Erkennung auf einer Maschine.
|
||||||
|
#
|
||||||
|
# cp .env.beispiel .env # VOGELALBUM_SOURCE eintragen
|
||||||
|
# docker compose -f docker-compose.heim.yml up -d
|
||||||
|
#
|
||||||
|
# Danach laeuft die Sammlung auf http://localhost:8000 und der Detector
|
||||||
|
# schaut in die Kamera. Kein venv, keine Modelle von Hand, kein systemd.
|
||||||
|
#
|
||||||
|
# Warum eine eigene Datei und nicht ein Profil in docker-compose.yml? Die
|
||||||
|
# andere Datei beschreibt das oeffentliche Hosting der Web-App allein (dort
|
||||||
|
# gibt es keine Kamera im Netz). Beide Faelle brauchen andere Vorgaben —
|
||||||
|
# in einer Datei mit Schaltern waeren beide schwerer zu lesen.
|
||||||
|
|
||||||
|
name: vogelalbum
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DJANGO_DEBUG: "0"
|
||||||
|
# Ohne gesetzten Schluessel erzeugt der Entrypoint beim ersten Start
|
||||||
|
# einen und legt ihn aufs Volume. Wer einen eigenen will, traegt ihn
|
||||||
|
# in die .env ein.
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-*}
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-}
|
||||||
|
# Derselbe Token, den der Detector als Bearer schickt. Fuer eine
|
||||||
|
# einzelne Kamera reicht das — 'manage.py camera add' braucht man erst
|
||||||
|
# ab der zweiten.
|
||||||
|
VOGELALBUM_INGEST_TOKEN: ${VOGELALBUM_INGEST_TOKEN:?bitte in der .env setzen}
|
||||||
|
# Beides MUSS aufs Volume zeigen, sonst sind Sammlung und Fotos nach
|
||||||
|
# jedem Neubau weg.
|
||||||
|
SQLITE_DIR: /data
|
||||||
|
MEDIA_ROOT: /data/media
|
||||||
|
volumes:
|
||||||
|
- vogelalbum-data:/data
|
||||||
|
ports:
|
||||||
|
- "${VOGELALBUM_PORT:-8000}:8000"
|
||||||
|
|
||||||
|
detector:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.detector
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
environment:
|
||||||
|
VOGELALBUM_SOURCE: ${VOGELALBUM_SOURCE:?Stream-URL der Kamera, siehe .env.beispiel}
|
||||||
|
# Im selben Compose-Netz erreichbar — nichts davon muss nach aussen offen sein.
|
||||||
|
VOGELALBUM_API: http://web:8000
|
||||||
|
VOGELALBUM_TOKEN: ${VOGELALBUM_INGEST_TOKEN:?bitte in der .env setzen}
|
||||||
|
# 'detector' ignoriert Bewegung und laesst das Modell entscheiden.
|
||||||
|
# Noetig, sobald die Kamera auf etwas dauerhaft Bewegtes schaut — eine
|
||||||
|
# Hecke im Wind loest sonst rund um die Uhr aus. Mit ruhigem Hintergrund
|
||||||
|
# spart 'motion' Rechenzeit.
|
||||||
|
VOGELALBUM_GATE: ${VOGELALBUM_GATE:-detector}
|
||||||
|
VOGELALBUM_SAVE_TO: /daten/rohdaten
|
||||||
|
VOGELALBUM_PROTOKOLL: /daten/protokoll.csv
|
||||||
|
# Kamera mit Infrarot? Dann hier "1" setzen, sonst pausiert die Analyse
|
||||||
|
# nachts von selbst.
|
||||||
|
VOGELALBUM_NACHTSICHT: ${VOGELALBUM_NACHTSICHT:-}
|
||||||
|
# Live-Bild: der Detector verteilt seinen eigenen Bildstrom weiter,
|
||||||
|
# damit Zuschauer der Kamera nichts wegnehmen. Leer = aus.
|
||||||
|
VOGELALBUM_LIVE_PORT: ${VOGELALBUM_LIVE_PORT:-0}
|
||||||
|
command:
|
||||||
|
- watch
|
||||||
|
- --classify-every=2
|
||||||
|
- --cooldown=5
|
||||||
|
- --min-bird-frames=1
|
||||||
|
- --verbose
|
||||||
|
# 🔴 Hier stand bis zum 09.10.2026 "${VOGELALBUM_LIVE_PORT:-0}:..." — und damit
|
||||||
|
# war der Schnellstart aus dem README fuer jeden kaputt, der die .env so
|
||||||
|
# liess wie sie ist: Compose weist "0:0" mit "missing a target port" ab,
|
||||||
|
# noch bevor irgendetwas gebaut wird.
|
||||||
|
#
|
||||||
|
# Aufgefallen ist es nie, weil jeder, der das je gestartet hat, den Port
|
||||||
|
# gesetzt hatte. Der Standardweg war der einzige ungetestete.
|
||||||
|
#
|
||||||
|
# Deshalb jetzt eine gueltige Portnummer als Vorgabe. Ob tatsaechlich
|
||||||
|
# etwas lauscht, entscheidet VOGELALBUM_LIVE_PORT in der Umgebung oben: auf 0
|
||||||
|
# startet der Detector keinen Live-Server, dann fuehrt diese
|
||||||
|
# Veroeffentlichung ins Leere — das stoert nicht. Wer den Port aendert,
|
||||||
|
# aendert beide Seiten mit derselben Variablen.
|
||||||
|
ports:
|
||||||
|
- "${VOGELALBUM_LIVE_PORT:-8131}:${VOGELALBUM_LIVE_PORT:-8131}"
|
||||||
|
volumes:
|
||||||
|
- vogelalbum-rohdaten:/daten
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Diese Namen sind neu und duerfen es sein: Wer mit dieser Datei anfaengt,
|
||||||
|
# hat noch keine Daten.
|
||||||
|
#
|
||||||
|
# ⚠️ Wer den Namen an einer LAUFENDEN Anlage aendert, muss die Daten
|
||||||
|
# mitnehmen — siehe die Anleitung in docker-compose.yml. Ein Volume-Name
|
||||||
|
# ist kein Text, sondern eine Adresse.
|
||||||
|
vogelalbum-data:
|
||||||
|
vogelalbum-rohdaten:
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Deployment der Web-App auf Coolify (vogelalbum.gaertner.onl).
|
||||||
|
#
|
||||||
|
# Der Detector läuft NICHT hier, sondern als systemd-Dienst auf dem Dev-Server:
|
||||||
|
# er braucht die Modelle, numpy und vor allem Sichtverbindung zur Kamera im
|
||||||
|
# WLAN-Segment. Siehe deploy/vogelalbum-detector.service.
|
||||||
|
#
|
||||||
|
# 🔑 Zwei Dienste, und der Eingang ist nginx, nicht Gunicorn. Grund steht
|
||||||
|
# ausführlich in deploy/nginx-media.conf: Django liefert unter DJANGO_DEBUG=0
|
||||||
|
# keine Medien aus, und Coolify stellt keinen Webserver davor, der das
|
||||||
|
# übernehmen könnte. Traefik muss deshalb auf `medien` zeigen — dort liegt
|
||||||
|
# Port 8080.
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DJANGO_DEBUG: "0"
|
||||||
|
# Leer ist der Normalfall: der Entrypoint erzeugt dann einen Schluessel
|
||||||
|
# und legt ihn aufs Volume, wo er ueber Neustarts stabil bleibt. Die
|
||||||
|
# Vorgabe `:-` muss sein, sonst warnt Compose bei jedem Deploy ueber eine
|
||||||
|
# nicht gesetzte Variable — eine Warnung, die nach Fehler aussieht.
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-vogelalbum.gaertner.onl}
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-https://vogelalbum.gaertner.onl}
|
||||||
|
# Der Sammel-Token für Kameras ohne eigene Registrierung. Leer lassen,
|
||||||
|
# wenn jede Kamera im Admin steht und ihren eigenen Token hat — dann ist
|
||||||
|
# dieser Weg zu, und das ist für eine öffentliche Instanz das Richtige.
|
||||||
|
# Leer ist ausdrücklich erlaubt und schaltet ihn ab (siehe dex/api.py).
|
||||||
|
VOGELALBUM_INGEST_TOKEN: ${VOGELALBUM_INGEST_TOKEN:-}
|
||||||
|
# Token zum LESEN nach außen (/api/zustand/, Home Assistant). Leer heißt
|
||||||
|
# zu. ⚠️ Wer Home Assistant angebunden hat, muss ihn hier setzen —
|
||||||
|
# sonst antwortet der Endpunkt nach dem Umzug mit 401, und die
|
||||||
|
# Einbindung zu Hause sieht aus wie ein Netzproblem.
|
||||||
|
VOGELALBUM_LESE_TOKEN: ${VOGELALBUM_LESE_TOKEN:-}
|
||||||
|
# ⚠️ Auf der öffentlichen Instanz bleibt das Livebild aus. Der Standard im
|
||||||
|
# Code ist "1" — ohne diese Zeile wäre es auf einer öffentlichen Seite
|
||||||
|
# also AN, und das Bild aus dem eigenen Garten hinge an einem Häkchen im
|
||||||
|
# Admin. Mit 0 ist Live gar nicht erst einschaltbar.
|
||||||
|
VOGELALBUM_LIVE_ERLAUBT: "0"
|
||||||
|
# Beides MUSS auf das Volume zeigen. Liegt die SQLite-Datei oder der
|
||||||
|
# Medienordner im Container-Dateisystem, sind Sammlung und Fotos nach
|
||||||
|
# jedem Deploy weg.
|
||||||
|
SQLITE_DIR: /data
|
||||||
|
MEDIA_ROOT: /data/media
|
||||||
|
volumes:
|
||||||
|
- vogelalbum-data:/data
|
||||||
|
# 🔑 Hier steht bewusst KEIN `ports` und KEIN `expose`. Coolify bietet für
|
||||||
|
# jeden Dienst, der eines von beidem hat, ein Domain-Feld an — und fragt
|
||||||
|
# dann nach einer Domain für `web`. Wer dort eine eintraegt, hat einen
|
||||||
|
# zweiten Eingang, der nginx umgeht: die Seite laedt, aber jedes Vogelfoto
|
||||||
|
# ist 404. Die Domain gehoert ausschliesslich an `medien`.
|
||||||
|
#
|
||||||
|
# Dass `medien` die App trotzdem erreicht, aendert das nicht: Container im
|
||||||
|
# selben Compose-Netz sprechen sich auf jedem Port an. `expose` ist reine
|
||||||
|
# Dokumentation und oeffnet nichts, was sonst zu waere.
|
||||||
|
|
||||||
|
medien:
|
||||||
|
# Die Konfiguration steckt im Image, nicht in einem Mount — Begründung
|
||||||
|
# steht in deploy/Dockerfile.nginx.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: deploy/Dockerfile.nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- web
|
||||||
|
volumes:
|
||||||
|
# Nur lesen. Geschrieben wird der Medienordner ausschließlich von der
|
||||||
|
# Web-App, die ihn unter MEDIA_ROOT bespielt.
|
||||||
|
- vogelalbum-data:/data:ro
|
||||||
|
# `expose`, nicht `ports`: Traefik erreicht den Dienst im Compose-Netz, und
|
||||||
|
# das genuegt. Ein `ports: "8080"` wuerde den Port zusaetzlich auf einem
|
||||||
|
# zufaelligen Host-Port veroeffentlichen — ein zweiter, unverschluesselter
|
||||||
|
# Eingang an der oeffentlichen IP, den niemand braucht.
|
||||||
|
expose:
|
||||||
|
- "8080"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# ⚠️ Ein Volume-Name ist kein Text, sondern eine ADRESSE.
|
||||||
|
#
|
||||||
|
# Wird er geaendert, legt Docker ein neues, LEERES Volume an. Die Seite
|
||||||
|
# kommt dann ohne Sammlung und ohne Fotos zurueck. Verloren ist nichts —
|
||||||
|
# die Daten liegen im alten Volume, an dem nur niemand mehr haengt —, aber
|
||||||
|
# fuer die Dauer eines Deploys sieht es aus wie Datenverlust.
|
||||||
|
#
|
||||||
|
# 🔑 Wer diesen Namen aendert, muss die Daten mitnehmen. Einmal kopieren,
|
||||||
|
# pruefen, dann erst umstellen:
|
||||||
|
#
|
||||||
|
# docker volume ls | grep <alt>
|
||||||
|
# docker stop <container>
|
||||||
|
# cp -a <alt-mountpoint>/. <neu-mountpoint>/
|
||||||
|
# diff -rq <alt> <neu>
|
||||||
|
#
|
||||||
|
# In dieselbe Klasse gehoeren MQTT-Topics und die Umgebungsvariablen
|
||||||
|
# laufender Anlagen: Adressen, keine Beschriftungen.
|
||||||
|
vogelalbum-data:
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Die Vogelalbum-Dokumentation
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md)</sub>
|
||||||
|
|
||||||
|
Das [README](../README.md) beantwortet, *was* Vogelalbum ist und wie man es in
|
||||||
|
drei Zeilen startet. Hier steht alles andere.
|
||||||
|
|
||||||
|
## Wie es aussieht
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Die Sammlung** — 148 Arten, gesehene in Farbe, der Rest als Schatten mit Fragezeichen | **Eine Art** — Foto, Steckbrief, Erstfang und alle Besuche |
|
||||||
|
|  |  |
|
||||||
|
| **Die Sichtungen** — jeder Besuch mit Foto, Dauer und Sicherheit | **Die Abzeichen** — fünfzehn Stück, vom Erstfang bis zu tausend Besuchen |
|
||||||
|
|  |  |
|
||||||
|
| **Dunkel** — folgt dem System, umschaltbar im Kopf | **Auf dem Telefon** — dieselbe Seite, keine eigene App nötig |
|
||||||
|
|
||||||
|
## Einrichten
|
||||||
|
|
||||||
|
| Kapitel | worum es geht |
|
||||||
|
|---|---|
|
||||||
|
| **[Installation](installation.md)** | mit und ohne Docker, Schritt für Schritt |
|
||||||
|
| **[Die Kamera anschließen](kamera.md)** | Stream-Formate, Schwellwerte, Nachtabschaltung — und wie groß ein Vogel im Bild sein muss |
|
||||||
|
| **[Einstellungen](einstellungen.md)** | alle Umgebungsvariablen, und was stattdessen im Admin geht |
|
||||||
|
|
||||||
|
Die Kameraseiten haben eigene Anleitungen, weil sie auf eigener Hardware
|
||||||
|
laufen: **[Raspberry Pi](../pi/README.md)** und **[ESP32-S3](../esp32cam/README.md)**.
|
||||||
|
|
||||||
|
## Verstehen
|
||||||
|
|
||||||
|
| Kapitel | worum es geht |
|
||||||
|
|---|---|
|
||||||
|
| **[Wie die Erkennung funktioniert](erkennung.md)** | drei Stufen, der Kniff mit dem Artenfilter, was das Modell nicht kann |
|
||||||
|
| **[Wie gut ist das wirklich](genauigkeit.md)** | gemessene Zahlen — und was im Feld tatsächlich passiert ist |
|
||||||
|
|
||||||
|
## Betreiben
|
||||||
|
|
||||||
|
| Kapitel | worum es geht |
|
||||||
|
|---|---|
|
||||||
|
| **[Betrieb](betrieb.md)** | mehrere Kameras, Live-Bild, Detector auf eigenem Host, Deployment |
|
||||||
|
| **[Coolify-Deployment](../deploy/COOLIFY.md)** | der ganze Weg inklusive Datenumzug |
|
||||||
|
|
||||||
|
## Mitarbeiten
|
||||||
|
|
||||||
|
| Kapitel | worum es geht |
|
||||||
|
|---|---|
|
||||||
|
| **[Entwicklung](entwicklung.md)** | Tests, Übersetzungen, Layoutprüfung, Artenkatalog erweitern |
|
||||||
|
| **[Datenquellen und Lizenzen](datenquellen.md)** | woher Modelle und Fotos kommen, und warum AGPL |
|
||||||
|
| **[CONTRIBUTING](../CONTRIBUTING.md)** | Spielregeln und Rechteeinräumung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<sub>Die Bilder in diesem Ordner sind Aufnahmen der Live-Instanz
|
||||||
|
<https://vogelalbum.gaertner.onl>, erzeugt mit Playwright. Nach einer Änderung an
|
||||||
|
der Oberfläche gehören sie neu gemacht — alte Screenshots sind das Erste, was
|
||||||
|
einem Besucher auffällt.</sub>
|
||||||
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,160 @@
|
|||||||
|
# Betrieb
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Live zuschauen, mehrere Kameras, der Detector auf einem eigenen Host und das
|
||||||
|
Deployen der Web-App. Also alles, was nach dem ersten erkannten Vogel kommt.
|
||||||
|
|
||||||
|
## Live zuschauen
|
||||||
|
|
||||||
|
Eine Kamera liefert **einen** Stream. Wer direkt bei ihr zuschaut, nimmt ihn
|
||||||
|
der Erkennung weg — auf einem Pi Zero W ist das keine Einstellung, sondern
|
||||||
|
Physik: die Funkstrecke trägt rund 2 Mbit/s, ein zweiter Client sättigt sie.
|
||||||
|
|
||||||
|
Der Detector liest den Stream aber ohnehin rund um die Uhr. Also verteilt er
|
||||||
|
ihn weiter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector watch --source http://kamera:8080/stream --live-port 8131
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit schauen beliebig viele Leute zu, und die Kamera merkt nichts davon — sie
|
||||||
|
hat weiterhin genau einen Zuschauer. Solange niemand hinsieht, wird auch nichts
|
||||||
|
umkodiert: bei MJPEG-Quellen gehen die JPEG-Bytes unverändert durch, und das
|
||||||
|
Kodieren für andere Quellen passiert im HTTP-Thread, einmal je Bild statt
|
||||||
|
einmal je Zuschauer. Die Erkennung wird davon nicht langsamer.
|
||||||
|
|
||||||
|
| Adresse | wozu |
|
||||||
|
|---|---|
|
||||||
|
| `http://<detector>:8131/live` | MJPEG, für mehrere Zuschauer |
|
||||||
|
| `http://<detector>:8131/bild.jpg` | Einzelbild |
|
||||||
|
| `http://<detector>:8131/` | Seite mit Livebild |
|
||||||
|
|
||||||
|
### In der Web-App
|
||||||
|
|
||||||
|
Eingetragen wird das **im Django-Admin bei der Kamera**: `Live-Adresse` auf
|
||||||
|
`http://<detector>:8131/live`, dann `Live-Bild zeigen` ankreuzen. Danach gibt
|
||||||
|
es die Seite `/live/` und einen Eintrag in der Navigation. Ohne Freigabe
|
||||||
|
existiert beides nicht — kein Link, keine Seite, 404.
|
||||||
|
|
||||||
|
Das Bild lädt der Browser direkt beim Verteiler; die Web-App reicht nichts
|
||||||
|
durch. Sonst hinge an jedem Zuschauer eine offene Verbindung im Webserver.
|
||||||
|
|
||||||
|
> **Für eine öffentliche Instanz: `VOGELALBUM_LIVE_ERLAUBT=0`.** Dann ist Live
|
||||||
|
> nicht einschaltbar, auch nicht mit einem Fehlklick im Admin, und die
|
||||||
|
> Admin-Liste zeigt „gesperrt". Ein Livebild aus einem Privatgarten soll nicht
|
||||||
|
> an einem Häkchen hängen — für einen Showcase ist die Sammlung ohnehin das
|
||||||
|
> Interessantere.
|
||||||
|
|
||||||
|
Der Verteiler selbst kennt keine Anmeldung. Er gehört ins Heimnetz.
|
||||||
|
|
||||||
|
## Mehrere Kameras
|
||||||
|
|
||||||
|
Jede Kamera bekommt einen eigenen Token. Der Token identifiziert sie — die
|
||||||
|
Web-App muss nie wissen, wo sie steht, und ein kompromittierter Standort lässt
|
||||||
|
sich einzeln stilllegen, ohne die anderen anzufassen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
manage.py camera add futterhaus --name "Futterhaus Garten" --location "Garten"
|
||||||
|
manage.py camera add nistkasten --name "Nistkasten Apfelbaum"
|
||||||
|
manage.py camera list
|
||||||
|
manage.py camera rotate futterhaus # Token erneuern
|
||||||
|
manage.py camera disable nistkasten
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Token wird **genau einmal** ausgegeben; gespeichert ist nur sein SHA-256.
|
||||||
|
Bei 256 Bit Entropie braucht es kein Salt, und wer die Datenbank in die Hand
|
||||||
|
bekommt, kann damit trotzdem nichts einliefern.
|
||||||
|
|
||||||
|
Pro Kamera läuft eine eigene `detector watch`-Instanz. Sie können auf
|
||||||
|
demselben Host laufen; rechne mit rund 40 % eines Kerns je Kamera bei
|
||||||
|
`--classify-every 2` und 4 fps.
|
||||||
|
|
||||||
|
Die Sammlung ist dabei **gemeinsam**: Findet die Gartenkamera die erste Amsel,
|
||||||
|
ist sie im Dex freigeschaltet — die Nistkastenkamera bekommt für dieselbe Art
|
||||||
|
keinen zweiten Erstfang. Das ist Absicht, es ist die Sammlung eines Menschen
|
||||||
|
und nicht die einer Kamera. Die Sichtungsliste lässt sich trotzdem nach Kamera
|
||||||
|
filtern.
|
||||||
|
|
||||||
|
`manage.py camera list` markiert Kameras, die sich über eine Stunde nicht
|
||||||
|
gemeldet haben — der schnellste Weg zu „warum kommt da nichts mehr".
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
### Detector auf einem eigenen Host
|
||||||
|
|
||||||
|
Ein frischer Debian- oder Ubuntu-Container, dann:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/setup_detector_host.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript legt den Dienstbenutzer `vogelalbum` an, klont nach `/opt/vogelalbum`,
|
||||||
|
baut das venv, holt die Modelle, schreibt `/etc/vogelalbum.env` und installiert
|
||||||
|
die systemd-Unit. Idempotent — ein zweiter Lauf aktualisiert nur.
|
||||||
|
|
||||||
|
Danach `/etc/vogelalbum.env` ausfüllen und `systemctl enable --now
|
||||||
|
vogelalbum-detector`.
|
||||||
|
|
||||||
|
**Dimensionierung**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| CPU | 1 Kern je Kamera (bei `--classify-every 2` und ~4 fps rund 40 % Last) |
|
||||||
|
| RAM | 512 MB je Instanz, 1 GB für Luft |
|
||||||
|
| Platte | 4 GB fürs System, plus ~150 kB je gespeicherter Sichtung |
|
||||||
|
|
||||||
|
**Netz:** ausgehend zur Kamera und zur Web-App. **Eingehend nichts.**
|
||||||
|
|
||||||
|
Achtung, wenn Kamera und Erkennungs-Host in verschiedenen Netzsegmenten
|
||||||
|
liegen: Die Route muss stehen. Bei uns hängt die Kamera im WLAN-Segment und
|
||||||
|
der Server im LAN — ein Scan des falschen Segments findet sie schlicht nicht.
|
||||||
|
|
||||||
|
**Zweite Kamera:** eigene `/etc/vogelalbum-<name>.env`, die Unit als
|
||||||
|
`vogelalbum-detector@.service` ablegen, dann `systemctl enable --now
|
||||||
|
vogelalbum-detector@nistkasten`.
|
||||||
|
|
||||||
|
## Die Web-App deployen
|
||||||
|
|
||||||
|
Nur die Web-App wird deployt. Der Detector bleibt im Heimnetz — er braucht
|
||||||
|
Sichtverbindung zur Kamera.
|
||||||
|
|
||||||
|
1. DNS auf den Host zeigen lassen.
|
||||||
|
2. Anwendung aus dem Repo anlegen, Build über `docker-compose.yml`.
|
||||||
|
⚠️ **Der Eingang ist `medien` auf Port 8080, nicht `web` auf 8000.** Das
|
||||||
|
compose startet zwei Dienste: Gunicorn und einen nginx davor, der die
|
||||||
|
Vogelfotos vom Volume ausliefert. Zeigt der Reverse Proxy direkt auf
|
||||||
|
`web`, lädt die Seite — aber **jede Karte der Sammlung hat ein kaputtes
|
||||||
|
Bild**, denn Django liefert unter `DJANGO_DEBUG=0` keine Medien aus.
|
||||||
|
Begründung ausführlich in [deploy/nginx-media.conf](../deploy/nginx-media.conf).
|
||||||
|
3. Persistentes Volume auf `/data` mounten. **Das ist der Schritt, den man
|
||||||
|
vergisst** — ohne ihn löscht jeder Deploy Sammlung und Fotos.
|
||||||
|
4. Umgebungsvariablen setzen (siehe [Einstellungen](einstellungen.md)).
|
||||||
|
5. Denselben Token auf dem Erkennungs-Host eintragen und `VOGELALBUM_API` auf die
|
||||||
|
öffentliche Adresse setzen.
|
||||||
|
6. Auf einer öffentlichen Instanz `VOGELALBUM_LIVE_ERLAUBT=0` setzen — steht im
|
||||||
|
`docker-compose.yml` schon drin. Ohne die Angabe wäre das Livebild aus dem
|
||||||
|
eigenen Garten erlaubt, denn der Standard im Code ist `1`.
|
||||||
|
|
||||||
|
Der erste Start dauert ein paar Minuten: Der Entrypoint holt einmalig die
|
||||||
|
Referenzfotos. Danach kostet der Schritt nichts mehr. `migrate`,
|
||||||
|
`collectstatic` und `seed_dex` erledigt er ebenfalls — neue Arten im Katalog
|
||||||
|
landen also mit dem nächsten Deploy automatisch in der Datenbank.
|
||||||
|
|
||||||
|
📘 **Schritt für Schritt mit Coolify, inklusive Umzug einer bestehenden
|
||||||
|
Sammlung: [deploy/COOLIFY.md](../deploy/COOLIFY.md).** Dort steht auch, wie
|
||||||
|
Datenbank und Fotos als 1:1-Kopie auf das Volume kommen — Kamera-Tokens,
|
||||||
|
Handbestimmungen und Abzeichen bleiben dabei erhalten, und die Kameras zu
|
||||||
|
Hause liefern mit ihrem bisherigen Token weiter.
|
||||||
|
|
||||||
|
### Einbinden auf einer anderen Seite
|
||||||
|
|
||||||
|
`/widget/` ist dafür da — keine Navigation, transparenter Hintergrund:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<iframe src="https://vogelalbum.example.org/widget/"
|
||||||
|
style="width:100%;height:110px;border:0" loading="lazy"></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wer lieber selbst gestaltet, nimmt `/api/stats/` — Level, Artenzahl und die
|
||||||
|
letzte Sichtung als JSON.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Datenquellen und Lizenzen
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Woher die Modelle und die Referenzfotos kommen, unter welcher Lizenz sie
|
||||||
|
stehen — und warum dieses Projekt die AGPL trägt und nicht die GPL.
|
||||||
|
|
||||||
|
| Was | Woher | Lizenz |
|
||||||
|
|---|---|---|
|
||||||
|
| Vogeldetektor | SSD-MobileNet v2 COCO, [google-coral/test_data](https://github.com/google-coral/test_data) | Apache 2.0 |
|
||||||
|
| Artklassifikator | MobileNet v2 auf iNaturalist-Vögeln, ebenda | Apache 2.0 |
|
||||||
|
| Referenzfotos | Wikipedia / Wikimedia Commons | überwiegend CC BY-SA, Urheber und Lizenz werden je Bild gespeichert und angezeigt |
|
||||||
|
| Artdaten und Texte | selbst geschrieben | AGPL-3.0, siehe unten |
|
||||||
|
|
||||||
|
Die Modelle liegen nicht im Repo; `scripts/fetch_models.sh` holt sie.
|
||||||
|
|
||||||
|
### Die Lizenz dieses Projekts
|
||||||
|
|
||||||
|
Vogelalbum steht unter der **GNU Affero General Public License 3.0**
|
||||||
|
([LICENSE](../LICENSE)). Du darfst es benutzen, verändern und weitergeben.
|
||||||
|
|
||||||
|
⚠️ **Das „Affero" ist der Unterschied zur gewöhnlichen GPL und hier Absicht:**
|
||||||
|
Wer eine veränderte Fassung **als Dienst über ein Netz anbietet**, muss den
|
||||||
|
Quelltext dieser Fassung den Nutzern zugänglich machen — auch ohne sie als
|
||||||
|
Programm weiterzugeben. Für den Betrieb im eigenen Garten ändert das nichts.
|
||||||
|
|
||||||
|
**Kommerzielle Nutzung ohne Offenlegung** ist über eine gesonderte Lizenz
|
||||||
|
möglich; Anfragen an <webmaster@gaertner.onl>. Das ist der Grund für die
|
||||||
|
Rechteeinräumung in [CONTRIBUTING.md](../CONTRIBUTING.md) — doppelt lizenzieren
|
||||||
|
kann nur, wem alle Rechte am Werk zustehen.
|
||||||
|
|
||||||
|
Die Referenzfotos aus Wikimedia Commons behalten ihre eigene Lizenz (CC BY-SA)
|
||||||
|
und sind von der AGPL nicht betroffen. Dasselbe gilt für die Modelle, die unter
|
||||||
|
Apache 2.0 stehen.
|
||||||
|
|
||||||
|
Die Referenzfotos werden mit Urheber, Lizenz und Quelle gespeichert und in der
|
||||||
|
Oberfläche angezeigt — die Namensnennung ist Lizenzbedingung, keine
|
||||||
|
Höflichkeit. Eigene Kameraaufnahmen sind davon nicht betroffen.
|
||||||
|
|
||||||
|
Die Gestaltung folgt der Idee eines **Sammelalbums**: nummerierte Einträge,
|
||||||
|
gesehene Arten in Farbe, fehlende als Schatten. Das ist so alt wie
|
||||||
|
Klebebildchen und gehört niemandem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<sub>Kein Bild dieses Projekts verlässt das eigene Netz, außer man deployt die
|
||||||
|
Web-App selbst irgendwohin. Es gibt keine Telemetrie und keine Aufrufe an
|
||||||
|
Dritte außer den ausdrücklich angestoßenen Downloads von Modellen und
|
||||||
|
Referenzfotos.</sub>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Einstellungen
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Alles, was Detector und Web-App über Umgebungsvariablen annehmen — und was
|
||||||
|
man stattdessen im Admin einstellen kann, ohne einen Prozess anzufassen.
|
||||||
|
|
||||||
|
### Detector
|
||||||
|
|
||||||
|
| Schalter | Standard | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `--source` | — | Stream-URL, RTSP-URL oder Ordner |
|
||||||
|
| `--api` / `--token` | — | Wohin gemeldet wird |
|
||||||
|
| `--save-to` | — | Ordner für Rohbilder je Sichtung |
|
||||||
|
| `--gate` | `motion` | `detector` ignoriert Bewegung — nötig bei unruhigem Hintergrund |
|
||||||
|
| `--habitats` | `garten` | Lebensräume, die in Frage kommen |
|
||||||
|
| `--min-bird-score` | `0.05` | Darunter gilt die Art als unbestimmt |
|
||||||
|
| `--detect-threshold` | `0.4` | Ab wann etwas als Vogel gilt |
|
||||||
|
| `--classify-every` | `3` | Nur jedes n-te Bild durchs Modell |
|
||||||
|
| `--cooldown` | `4.0` | Sekunden Ruhe, bis ein Besuch endet |
|
||||||
|
| `--min-bird-frames` | `2` | Weniger Treffer → verworfen |
|
||||||
|
| `--min-confidence` | `0.45` | Darunter „unbestimmt" statt geraten |
|
||||||
|
| `--nachtsicht` | aus | Nie bei Dunkelheit pausieren |
|
||||||
|
| `--dunkel-unter` / `--hell-ueber` | `15` / `30` | Helligkeitsschwellen 0–255 |
|
||||||
|
| `--motion-ratio` | `0.012` | Anteil bewegter Pixel für einen Besuch |
|
||||||
|
|
||||||
|
Alles auch als Umgebungsvariable mit Präfix `VOGELALBUM_`, etwa
|
||||||
|
`VOGELALBUM_SOURCE`, `VOGELALBUM_TOKEN`, `VOGELALBUM_HABITATS`.
|
||||||
|
|
||||||
|
### Web-App
|
||||||
|
|
||||||
|
| Variable | Zweck |
|
||||||
|
|----------|-------|
|
||||||
|
| `DJANGO_SECRET_KEY` | **Pflicht**, sobald `DJANGO_DEBUG=0`. Ohne startet die App nicht. |
|
||||||
|
| `DJANGO_DEBUG` | `0` im Betrieb |
|
||||||
|
| `DJANGO_ALLOWED_HOSTS` | kommagetrennt |
|
||||||
|
| `DJANGO_CSRF_TRUSTED_ORIGINS` | kommagetrennt, mit Schema |
|
||||||
|
| `VOGELALBUM_INGEST_TOKEN` | Sammel-Token; leer lassen, wenn jede Kamera einen eigenen hat |
|
||||||
|
| `SQLITE_DIR` | Datenbank auf ein Volume legen |
|
||||||
|
| `MEDIA_ROOT` | Fotos auf ein Volume legen |
|
||||||
|
|
||||||
|
## Einstellungen im Admin statt auf der Kommandozeile
|
||||||
|
|
||||||
|
Bis vor kurzem stand die Konfiguration des Detectors nur auf seiner
|
||||||
|
Kommandozeile: Stream-Adresse, Schwellen, Lebensräume. Wer eine Kamera
|
||||||
|
umstellen wollte, brauchte eine Shell auf dem Rechner, auf dem der Detector
|
||||||
|
läuft. Jetzt fragt der Detector die Web-App:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector watch --api http://vogelalbum:8000 --token <Kamera-Token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit holt er sich bei `/api/config/` alle 300 Sekunden, was für *seine*
|
||||||
|
Kamera gilt — und übernimmt Änderungen **ohne Neustart**. Einstellbar im
|
||||||
|
Django-Admin unter der Kamera:
|
||||||
|
|
||||||
|
| Feld | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Stream-Adresse | Woher die Bilder kommen. `--source` hat Vorrang. |
|
||||||
|
| Lebensräume | Welche Arten der Klassifikator erwägt (`garten`, `wald`, `offenland`, `wasser`, `gebirge`) |
|
||||||
|
| Schwelle Vogelerkennung | Ab welcher Sicherheit etwas als Vogel gilt |
|
||||||
|
| Mindestanteil heimischer Arten | Darunter gilt die Sichtung als unbestimmt |
|
||||||
|
|
||||||
|
Die **Artenliste kommt mit** — sie ist eine inhaltliche Entscheidung und
|
||||||
|
gehört in den Admin, nicht in eine Python-Datei. Der wissenschaftliche Name
|
||||||
|
ist dabei der Schlüssel ins Label-File des Modells; ein zweites Mapping
|
||||||
|
braucht es nicht.
|
||||||
|
|
||||||
|
Zwei Eigenschaften sind wichtiger als die Funktion selbst:
|
||||||
|
|
||||||
|
- **Die Kommandozeile bleibt gültig.** Ohne `--token` ändert sich nichts, und
|
||||||
|
ein ausdrücklich gesetztes `--source` gewinnt immer.
|
||||||
|
- **Ein Ausfall ändert nichts.** Antwortet die Web-App nicht, läuft die
|
||||||
|
Erkennung mit dem letzten bekannten Stand weiter. Wer zwei Wochen auf den
|
||||||
|
ersten Vogel wartet, will ihn nicht verpassen, weil ein Webserver neu
|
||||||
|
gestartet wurde.
|
||||||
|
|
||||||
|
`--konfig-alle 0` schaltet das Nachfragen ab.
|
||||||
|
|
||||||
|
### Regionen
|
||||||
|
|
||||||
|
Das iNat-Modell kennt 965 Arten und ist **auf Nordamerika trainiert** — ohne
|
||||||
|
Einschränkung wird aus einer Kohlmeise eine Carolina Chickadee. Welche Arten
|
||||||
|
gelten, entscheidet deshalb `VOGELALBUM_REGION` (Standard `de`) gegen das Feld
|
||||||
|
`regions` der Art.
|
||||||
|
|
||||||
|
Eine weitere Region ist damit kein Umbau mehr, sondern ein Katalog: Arten mit
|
||||||
|
dem passenden Kürzel anlegen und die Variable umstellen. Für Nordamerika
|
||||||
|
arbeitet das Modell sogar in seiner Trainingsverteilung, also besser als hier.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Entwicklung
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Tests, Übersetzungen, Layoutprüfung und das Erweitern des Artenkatalogs. Wer
|
||||||
|
etwas beitragen will, findet die Spielregeln in
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python -m unittest discover tests # Detector
|
||||||
|
cd web && ../venv/bin/python manage.py test dex # Web-App
|
||||||
|
```
|
||||||
|
|
||||||
|
### Übersetzungen
|
||||||
|
|
||||||
|
Oberflächentexte laufen über Djangos i18n, Artentexte über eigene Felder am
|
||||||
|
Modell (`beschreibung_en` und Geschwister). Abzeichen und Ränge liegen als
|
||||||
|
deutscher Text in der Datenbank und werden erst bei der Ausgabe übersetzt —
|
||||||
|
sie sind ein fester, im Code definierter Satz, und so braucht ein
|
||||||
|
Sprachwechsel keine Migration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && ../venv/bin/python manage.py makemessages -l en --ignore=venv
|
||||||
|
cd .. && ./venv/bin/python scripts/update_translations.py
|
||||||
|
cd web && ../venv/bin/python manage.py compilemessages -l en
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die .po-Datei nicht von Hand pflegen.** `makemessages` ruft `msgmerge` auf,
|
||||||
|
und das rät: Ein neuer Text, der einem alten ähnelt, erbt dessen Übersetzung
|
||||||
|
und wird als `#, fuzzy` markiert. So wurde aus „Sammler" ein „Collection" (von
|
||||||
|
„Sammlung"). `msgfmt` wirft Fuzzy-Einträge weg, im Betrieb sieht man also
|
||||||
|
deutschen Resttext — und wer dann „ersetze `msgstr`, wenn leer" anwendet,
|
||||||
|
greift daneben, weil leer eben nichts ist.
|
||||||
|
`scripts/update_translations.py` überschreibt deshalb immer und räumt die
|
||||||
|
Marke weg.
|
||||||
|
|
||||||
|
### Layout prüfen
|
||||||
|
|
||||||
|
**Layoutänderungen gehören in vier Breiten angesehen**, bevor sie rausgehen:
|
||||||
|
iPad hochkant (810), iPad quer (1180), iPhone (390), Desktop (1440). Blind
|
||||||
|
gebaute Layouts sehen auf genau einer Breite gut aus, und das ist selten die,
|
||||||
|
auf der jemand liest.
|
||||||
|
|
||||||
|
`color-mix()` gibt es erst ab Safari 16.2, `aspect-ratio` erst ab Safari 15.
|
||||||
|
Beides fällt auf älteren Geräten ersatzlos weg: Farben verschwinden, und
|
||||||
|
Bildkästen verlieren ihre Höhe, sodass jedes Foto in seiner natürlichen Größe
|
||||||
|
rendert. Deshalb werden Typfarben in `web/dex/species_meta.py` vorberechnet
|
||||||
|
und Bildverhältnisse über `padding-top` gelöst.
|
||||||
|
|
||||||
|
Fehlt Chromium und gibt es kein root, hilft `apt-get download` — das braucht
|
||||||
|
keine Rechte:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.local/chromium-deps && cd ~/.local/chromium-deps
|
||||||
|
for p in libatk1.0-0t64 libatk-bridge2.0-0t64 libatspi2.0-0t64 \
|
||||||
|
libxdamage1 libasound2t64 libasound2-data; do apt-get download "$p"; done
|
||||||
|
mkdir -p root && for d in *.deb; do dpkg -x "$d" root/; done
|
||||||
|
export LD_LIBRARY_PATH=~/.local/chromium-deps/root/usr/lib/x86_64-linux-gnu
|
||||||
|
```
|
||||||
|
|
||||||
|
Dasselbe funktioniert für `gettext`, falls `msgfmt` fehlt.
|
||||||
|
|
||||||
|
> `runserver --noreload` cached auch Templates. Nach Template-Änderungen neu
|
||||||
|
> starten, sonst sieht man weiter die alte Fassung.
|
||||||
|
|
||||||
|
### Den Katalog erweitern
|
||||||
|
|
||||||
|
`detector/species_de.py` enthält die Kernarten mit Erzähltext,
|
||||||
|
`detector/species_extra.py` die übrigen mit Strukturdaten,
|
||||||
|
`detector/profiles/` die Steckbriefe.
|
||||||
|
|
||||||
|
Jede neue Art muss im Label-File des Modells vorkommen, sonst kann sie
|
||||||
|
niemand freischalten — ein Test prüft das. Ebenso, dass jeder benutzte Typ in
|
||||||
|
der Web-App eine Farbe und einen englischen Namen hat.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Wie die Erkennung funktioniert
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Drei Stufen, ein Artenfilter und eine Abstimmung über den ganzen Besuch. Wer
|
||||||
|
nur wissen will, *ob* es funktioniert, ist in [Wie gut ist das
|
||||||
|
wirklich](genauigkeit.md) besser bedient — hier steht, *warum* es funktioniert.
|
||||||
|
|
||||||
|
Drei Stufen, jede filtert für die nächste vor.
|
||||||
|
|
||||||
|
**1. Bewegung** (`motion.py`, <1 ms) — Frame-Differencing gegen einen
|
||||||
|
gleitenden Hintergrund. Der zieht langsam nach, damit Sonnenstand und Wolken
|
||||||
|
keine Dauerbewegung auslösen; bei erkannter Bewegung *langsamer*, sonst frisst
|
||||||
|
sich ein still sitzender Vogel binnen Sekunden in den Hintergrund ein.
|
||||||
|
|
||||||
|
Diese Stufe lässt sich abschalten (`--gate detector`), und an unserer Kamera
|
||||||
|
ist das nötig: Sie schaut aus dem Futterhaus in eine Hecke, die im Wind steht.
|
||||||
|
Gemessen sind dort 6,9 % bewegte Pixel im Median und 95 % aller Bilder über der
|
||||||
|
Schwelle — als Vorfilter damit wertlos.
|
||||||
|
|
||||||
|
**2. Vogel ja/nein** (`classify.py`, ~15 ms) — SSD-MobileNet auf COCO. Sagt
|
||||||
|
nur, ob und wo ein Vogel im Bild ist. Verhindert, dass ein wackelnder Ast als
|
||||||
|
Kohlmeise in der Sammlung landet, und liefert den Ausschnitt für Stufe 3.
|
||||||
|
|
||||||
|
**3. Welche Art** (`classify.py`, ~25 ms) — MobileNetV2 auf iNaturalist,
|
||||||
|
965 Vogelarten, angewendet auf den zugeschnittenen Vogel statt aufs ganze Bild.
|
||||||
|
|
||||||
|
### Der Kniff mit dem Artenfilter
|
||||||
|
|
||||||
|
Das iNat-Modell ist Nordamerika-lastig. Ungefiltert wird aus einer Kohlmeise
|
||||||
|
gern eine *Carolina Chickadee* — die sieht ähnlich aus und ist im
|
||||||
|
Trainingsmaterial häufiger.
|
||||||
|
|
||||||
|
Der Katalog schränkt die Modellausgabe deshalb auf Arten ein, die hier
|
||||||
|
realistisch vorkommen, und normiert die Wahrscheinlichkeit neu. Der Anteil der
|
||||||
|
Wahrscheinlichkeitsmasse, der überhaupt auf heimische Arten fällt
|
||||||
|
(`bird_score`), ist dabei das eigentliche Gütemaß — nicht die Confidence des
|
||||||
|
Top-Treffers, denn die ist nach der Neunormierung per Konstruktion hoch. Liegt
|
||||||
|
`bird_score` unter `--min-bird-score`, sieht das Modell zwar sicher einen
|
||||||
|
Vogel, aber keinen von hier; die Sichtung wird dann als *unbestimmt*
|
||||||
|
gespeichert statt geraten.
|
||||||
|
|
||||||
|
`--habitats` engt weiter ein. Eine Kamera am Futterhaus soll gar nicht erst
|
||||||
|
erwägen, ob der Fleck auf dem Ast ein Kranich ist.
|
||||||
|
|
||||||
|
### Abstimmung über den ganzen Besuch
|
||||||
|
|
||||||
|
Eine einzelne Klassifikation ist wackelig: Von hinten ist eine Kohlmeise nur
|
||||||
|
eine olivgrüne Kugel. Die Einheit ist deshalb nicht das Bild, sondern der
|
||||||
|
**Besuch** — von der ersten Erkennung bis zu einigen Sekunden Ruhe.
|
||||||
|
|
||||||
|
Über den Besuch hinweg wird abgestimmt, gewichtet nach Detektor-Sicherheit,
|
||||||
|
Größe des Vogels im Bild und `bird_score`. Parallel wird das schärfste Foto
|
||||||
|
behalten (Varianz der Laplace-Antwort), damit in der Sammlung kein
|
||||||
|
verwackeltes Bild landet. Kippt die Abstimmung, wird die Sichtung als
|
||||||
|
unbestimmt gespeichert — eine Lücke im Dex ist besser als ein falscher
|
||||||
|
Eintrag, aus dem ein Kind etwas Falsches lernt.
|
||||||
|
|
||||||
|
### Was das Modell nicht kann
|
||||||
|
|
||||||
|
Sechs in Deutschland vorkommende Arten fehlen im Trainingsmaterial und werden
|
||||||
|
zwangsläufig auf verwandte Arten gemappt:
|
||||||
|
|
||||||
|
Sumpfmeise · Weidenmeise · Haubenmeise · Gartenbaumläufer · Kleinspecht ·
|
||||||
|
Waldkauz
|
||||||
|
|
||||||
|
`python -m detector species` listet das mit auf.
|
||||||
|
|
||||||
|
Der Weg dahin: eigene Bilder sammeln (`--save-to` tut genau das), im Admin von
|
||||||
|
Hand bestätigen (Feld *Von Hand bestätigt als*) und später ein eigenes Modell
|
||||||
|
nachtrainieren. Dann lernt es gleich die Beleuchtung und den Blickwinkel der
|
||||||
|
eigenen Kamera mit.
|
||||||
|
|
||||||
|
> **Beim Nachschlagen im Label-File aufpassen:** Es benutzt teils veraltete
|
||||||
|
> wissenschaftliche Namen. Der Bluthänfling steht dort als *Carduelis
|
||||||
|
> cannabina*, nicht als *Linaria cannabina* — wer nach dem heutigen Namen
|
||||||
|
> sucht, hält die Art fälschlich für eine Lücke.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Wie gut ist das wirklich
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Gemessene Zahlen statt Versprechen: 43 von 44 Arten richtig, keine falsch
|
||||||
|
bestimmt — und daneben das, was im Feld tatsächlich passiert ist, nämlich
|
||||||
|
wochenlang nichts. Beides gehört zusammen.
|
||||||
|
|
||||||
|
`scripts/benchmark.py` jagt echte Vogelfotos durch dieselbe Pipeline, die auch
|
||||||
|
an der Kamera läuft, und vergleicht mit der bekannten Art:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python scripts/benchmark.py --referenz web/media/referenz
|
||||||
|
./venv/bin/python scripts/benchmark.py --hole # mehr Bilder von Commons
|
||||||
|
```
|
||||||
|
|
||||||
|
Über 44 Referenzfotos, ein Bild je Gartenvogelart:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Richtig bestimmt | **43 / 44 — 97,7 %** |
|
||||||
|
| Als unbestimmt verworfen | 1 (Mauersegler, füllt 6 % des Bildes) |
|
||||||
|
| **Falsch bestimmt** | **0** |
|
||||||
|
|
||||||
|
Die Null bei den Falschbestimmungen ist das eigentliche Ergebnis. Bei
|
||||||
|
Unsicherheit schweigt das System, statt zu raten.
|
||||||
|
|
||||||
|
**Diese Zahl ist eine Obergrenze, keine Prognose.** Commons-Fotos sind gut
|
||||||
|
belichtete Nahaufnahmen von Fotografen, die den Vogel im Sucher hatten. Eine
|
||||||
|
feste Kamera am Futterhaus liefert Gegenlicht, Bewegungsunschärfe und halb
|
||||||
|
verdeckte Vögel.
|
||||||
|
|
||||||
|
### Was im Feld tatsächlich passiert ist
|
||||||
|
|
||||||
|
Die Null oben stammt aus Referenzfotos. **Am eigenen Futterhaus sieht es
|
||||||
|
anders aus** — hier die Fälle, die bis zum 30.09.2026 aufgetreten sind.
|
||||||
|
|
||||||
|
#### 🔴 Eine Falschbestimmung mit 94,5 % Sicherheit
|
||||||
|
|
||||||
|
Am 30.09.2026 bestimmte das Modell eine **Kohlmeise als Blaumeise** — und war
|
||||||
|
sich dabei sicherer als bei jeder richtigen Bestimmung davor.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Sicherheit | 0,945 |
|
||||||
|
| Bilder mit Vogel | 5 von 31 |
|
||||||
|
| Der Grund | **Der Kopf war verwischt** |
|
||||||
|
|
||||||
|
Die gelbe Brust mit dem dunklen Längsstreifen war zu sehen, die blaue Kappe
|
||||||
|
nicht — und genau die unterscheidet die beiden Arten. Das Modell musste ohne
|
||||||
|
das entscheidende Merkmal entscheiden und tat es trotzdem mit voller
|
||||||
|
Überzeugung.
|
||||||
|
|
||||||
|
🔑 **Die Sicherheit misst nicht, ob genug zu sehen war.** Sie sagt nur, wie gut
|
||||||
|
das Sichtbare zu einer Art passt. Bei Bewegungsunschärfe kann sie hoch sein und
|
||||||
|
trotzdem falsch liegen — genau der Fall, den der Absatz über die Obergrenze
|
||||||
|
vorhersagt.
|
||||||
|
|
||||||
|
**Korrigieren:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python web/manage.py sichtung_bestimmen 228 kohlmeise --trocken
|
||||||
|
./venv/bin/python web/manage.py sichtung_bestimmen 228 kohlmeise
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Nicht einfach das Feld im Admin ändern.** An einer Bestimmung hängt der
|
||||||
|
Dex-Eintrag der falschen Art, der Erstfang und die dreifachen Punkte. Der
|
||||||
|
Befehl räumt das mit auf und rechnet die Punkte mit derselben Formel neu, die
|
||||||
|
auch bei der Vergabe gilt.
|
||||||
|
|
||||||
|
#### 🔴 24 Sichtungen von Spiegelungen auf Wasser
|
||||||
|
|
||||||
|
Am 27.09.2026 lieferte die Kamera an der Vogeltränke zwischen **19:01 und
|
||||||
|
19:40** vierundzwanzig Sichtungen. Keine einzige davon war ein Vogel.
|
||||||
|
|
||||||
|
| | Tränke | Futterhaus |
|
||||||
|
|---|---|---|
|
||||||
|
| mittlerer Vogel-Wert | **0,000** | 0,431 |
|
||||||
|
| Bilder mit Vogel | 3,5 % | 25,7 % |
|
||||||
|
|
||||||
|
Die Diagnose am selben Tag: *„Die Fehlmeldungen sind Spiegelungen vom
|
||||||
|
Mond auf der Wasseroberfläche."* Die Uhrzeiten bestätigen es — **alle 24 in
|
||||||
|
derselben Stunde, keine einzige tagsüber.**
|
||||||
|
|
||||||
|
⚠️ **Die Vogelerkennung hat korrekt gearbeitet: Sie meldete null.** Gespeichert
|
||||||
|
wurde trotzdem. **Das ist der eigentliche Fehler** — ein Besuch wird angelegt,
|
||||||
|
obwohl die Erkennung ausdrücklich „kein Vogel" sagt.
|
||||||
|
|
||||||
|
📌 **Noch offen:** eine Untergrenze für den *mittleren* Vogel-Wert. `--min-bird-frames`
|
||||||
|
steht auf 1, `min_bird_score` auf 0,05 — aber nichts prüft den Durchschnitt über
|
||||||
|
den ganzen Besuch. Bei 0,000 dürfte nichts entstehen.
|
||||||
|
|
||||||
|
🔑 **Wasser ist ein anderer Fall als ein Futterhaus.** Eine Tränke spiegelt
|
||||||
|
Himmel, Sonne und Mond, und eine wandernde Lichtfläche sieht für eine
|
||||||
|
Bewegungserkennung aus wie ein Tier.
|
||||||
|
|
||||||
|
#### Das „beste Foto" ist nicht das mit der höchsten Sicherheit
|
||||||
|
|
||||||
|
Beim Korrigieren der Falschbestimmung fiel auf, dass dieselbe verwischte
|
||||||
|
Aufnahme zum besten Foto der Art geworden war:
|
||||||
|
|
||||||
|
```
|
||||||
|
#228 Sicherheit 0,945 5/31 Bilder ← war "bestes Foto"
|
||||||
|
#200 Sicherheit 0,936 23/67 Bilder ← die scharfe Aufnahme
|
||||||
|
```
|
||||||
|
|
||||||
|
**Die Sicherheit sagt, wie sicher sich das Modell bei der Art ist — nicht, ob
|
||||||
|
das Bild etwas taugt.** Seitdem entscheidet `scoring.bildguete`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Anteil der Bilder mit Vogel × mittlerer Vogel-Wert
|
||||||
|
```
|
||||||
|
|
||||||
|
Beides misst dasselbe: *Der Vogel war wirklich und deutlich da.* Wer lange klar
|
||||||
|
im Bild stand, hat auch ein gutes Einzelbild.
|
||||||
|
|
||||||
|
⚠️ **Das ist ein Hilfsmaß, keine Schärfemessung.** Richtig wäre die
|
||||||
|
Laplace-Varianz des gespeicherten Bildes, gemessen im Detector. Das hier kommt
|
||||||
|
ohne Bildverarbeitung aus und trifft in der Praxis dieselbe Wahl.
|
||||||
|
|
||||||
|
### Warum `--min-bird-score` bei 5 % steht
|
||||||
|
|
||||||
|
Gemessen, nicht geraten:
|
||||||
|
|
||||||
|
| `--min-bird-score` | richtig |
|
||||||
|
|---|---|
|
||||||
|
| 0.02 – 0.05 | 43 / 44 |
|
||||||
|
| 0.10 | 40 / 44 |
|
||||||
|
| 0.20 | 38 / 44 |
|
||||||
|
|
||||||
|
Ein zu hoher Wert wirft richtige Bestimmungen weg. Beim Mäusebussard gewinnt
|
||||||
|
global der amerikanische *Buteo lineatus* mit 57 %, unser *Buteo buteo* kommt
|
||||||
|
auf 5 % — innerhalb der deutschen Arten liegt er trotzdem klar vorn. Genau das
|
||||||
|
ist die Nordamerika-Schlagseite, und genau deshalb ist `bird_score` ein Maß
|
||||||
|
für „kenne ich diese Weltgegend", nicht für „ist das ein Vogel".
|
||||||
|
|
||||||
|
Der Katalogumfang kostet dabei nichts: 47 oder 148 Arten in der Whitelist
|
||||||
|
liefern dasselbe Ergebnis.
|
||||||
|
After Width: | Height: | Size: 930 KiB |
@@ -0,0 +1,146 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Zwei Wege zum selben Ziel. **Mit Docker** braucht es einen Befehl und keine
|
||||||
|
Python-Umgebung; **ohne Docker** sieht man mehr und kann einzelne Teile
|
||||||
|
austauschen.
|
||||||
|
|
||||||
|
## Welcher Weg ist meiner?
|
||||||
|
|
||||||
|
Vogelalbum besteht aus zwei Teilen, die getrennt laufen können: der **Sammlung**
|
||||||
|
(Django, zeigt Arten, XP und Abzeichen) und der **Erkennung** (schaut in die
|
||||||
|
Kamera und meldet Sichtungen). Daraus ergeben sich drei sinnvolle Aufbauten —
|
||||||
|
und für jeden gibt es genau eine Datei:
|
||||||
|
|
||||||
|
| Dein Fall | Datei | Was läuft |
|
||||||
|
|---|---|---|
|
||||||
|
| **Eine Kamera im Garten, ein Rechner im Keller.** Der Normalfall, und der richtige Anfang. | `docker-compose.heim.yml` | Sammlung **und** Erkennung auf einer Maschine |
|
||||||
|
| **Die Sammlung soll öffentlich erreichbar sein**, die Kamera bleibt zu Hause. | `docker-compose.yml` | nur die Sammlung, mit nginx für die Fotos |
|
||||||
|
| **Der Rechner mit Sicht auf die Kamera** liefert an eine Sammlung woanders. | `docker-compose.detector.yml` | nur die Erkennung |
|
||||||
|
|
||||||
|
Die letzten beiden gehören zusammen: Sie sind dieselbe Anlage, aufgeteilt auf
|
||||||
|
zwei Orte. So läuft auch <https://vogelalbum.gaertner.onl> — Sammlung bei einem
|
||||||
|
Hoster, Erkennung im Heimnetz.
|
||||||
|
|
||||||
|
🔑 **Nach außen muss dafür kein Port offen sein.** Die Verbindung geht nur in
|
||||||
|
eine Richtung: Die Erkennung meldet ausgehend an die Sammlung, die Sammlung
|
||||||
|
erreicht die Kamera nie. Kein Portforwarding, kein Tunnel, kein DynDNS.
|
||||||
|
|
||||||
|
⚠️ **Wo die Erkennung laufen kann:** `ai-edge-litert` gibt es als fertiges
|
||||||
|
Wheel für **x86_64 und arm64**. Ein **Pi Zero trägt sie nicht** — dem fehlt
|
||||||
|
nicht das Wheel, sondern die Rechenleistung; er ist die *Kamera*seite, siehe
|
||||||
|
[pi/README.md](../pi/README.md).
|
||||||
|
|
||||||
|
### Getestet auf
|
||||||
|
|
||||||
|
Zwei Geräte, wie sie in vielen Hausnetzen stehen. Beide mit dem Schnellstart
|
||||||
|
oben eingerichtet, aus einem frischen Klon und mit leerem Build-Zwischenspeicher:
|
||||||
|
|
||||||
|
| | **Intel N100** | **Raspberry Pi 4 Model B** |
|
||||||
|
|---|---|---|
|
||||||
|
| | 16 GB, Debian, x86_64 | 4 GB, Ubuntu 26.04, aarch64 |
|
||||||
|
| Images bauen (einmalig) | 69 s | 467 s (knapp 8 min) |
|
||||||
|
| Sammlung erreichbar nach | 5 s | 49 s |
|
||||||
|
| **Ein Bild bestimmen** | **~30 ms** | **42 ms** (Median aus 18 Messungen, 35–53 ms) |
|
||||||
|
| Arbeitsspeicher im Betrieb | — | 1,1 von 3,7 GB |
|
||||||
|
| Temperatur unter Last | — | 54 °C, keine Drosselung |
|
||||||
|
|
||||||
|
🔑 **Der Pi 4 langweilt sich dabei.** Bei 4 Bildern pro Sekunde und
|
||||||
|
`--classify-every=2` stehen rund **500 ms** je bestimmtem Bild zur Verfügung —
|
||||||
|
er braucht 42. Er könnte also jedes Bild bestimmen statt jedes zweite und wäre
|
||||||
|
immer noch bei einem Sechstel. Dass ARM hier so gut mithält, liegt an den
|
||||||
|
int8-quantisierten Modellen; gerechnet hatten wir mit einem Vielfachen.
|
||||||
|
|
||||||
|
Der Preis auf dem Pi sind die acht Minuten Bauzeit und die knappe Minute bis
|
||||||
|
zur ersten Seite. Beides fällt genau einmal an.
|
||||||
|
|
||||||
|
⚠️ **Ungetestet:** Raspberry Pi 5 (sollte schneller sein), Pi 4 mit nur 2 GB,
|
||||||
|
und 32-Bit-Systeme (dort gibt es kein `ai-edge-litert`-Wheel — Raspberry Pi OS
|
||||||
|
muss die 64-Bit-Ausgabe sein). Rückmeldungen zu anderer Hardware sind
|
||||||
|
willkommen, gern als Issue.
|
||||||
|
|
||||||
|
Wer gar kein Docker will: [ohne Docker](#schnellstart-ohne-docker) weiter
|
||||||
|
unten. Und wer nur schauen will, wie es aussieht, braucht nicht einmal eine
|
||||||
|
Kamera — siehe [Ohne Kamera ausprobieren](#ohne-kamera-ausprobieren).
|
||||||
|
|
||||||
|
## Schnellstart mit Docker
|
||||||
|
|
||||||
|
Der kürzeste Weg — Sammlung und Erkennung auf einer Maschine, ohne venv, ohne
|
||||||
|
Modelle von Hand, ohne systemd:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.gaertner.onl/christian/vogelalbum.git && cd vogelalbum
|
||||||
|
cp .env.beispiel .env # VOGELALBUM_SOURCE und ein Token eintragen
|
||||||
|
docker compose -f docker-compose.heim.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste Start baut beide Images (rund eine Minute) und legt den Artenkatalog
|
||||||
|
an: **148 Arten mit Steckbriefen und 15 Abzeichen.** Die Sammlung steht danach
|
||||||
|
sofort; die Referenzfotos aus Wikipedia holt der Container im Hintergrund nach
|
||||||
|
und tropfen über die nächsten Minuten ein.
|
||||||
|
|
||||||
|
Danach läuft die Sammlung auf <http://localhost:8000> und der Detector schaut
|
||||||
|
in die Kamera. Zwei Zeilen in der `.env` müssen stimmen:
|
||||||
|
|
||||||
|
| Variable | was rein muss |
|
||||||
|
|---|---|
|
||||||
|
| `VOGELALBUM_SOURCE` | Stream-URL der Kamera, z.B. `http://vogelkamera.local:8080/stream` |
|
||||||
|
| `VOGELALBUM_INGEST_TOKEN` | ein selbst ausgedachtes Geheimnis, `openssl rand -base64 32` |
|
||||||
|
|
||||||
|
Alles andere hat brauchbare Vorgaben; den Django-`SECRET_KEY` erzeugt der
|
||||||
|
Container beim ersten Start selbst und legt ihn aufs Volume.
|
||||||
|
|
||||||
|
Noch keine Kamera? Die Kameraseite steht in **[pi/README.md](../pi/README.md)** —
|
||||||
|
ein Raspberry Pi Zero W mit Kameramodul reicht. Alternativ ein ESP32-S3 mit
|
||||||
|
Kameramodul: **[esp32cam/README.md](../esp32cam/README.md)**, deutlich billiger
|
||||||
|
und mit externer Antenne, was der Bildrate mehr hilft als jede Auflösung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.heim.yml logs -f detector # was sieht er?
|
||||||
|
docker compose -f docker-compose.heim.yml down # anhalten
|
||||||
|
```
|
||||||
|
|
||||||
|
Sammlung und Rohbilder liegen in Docker-Volumes und überleben ein
|
||||||
|
`up --build`. Wer die Sammlung öffentlich hosten und nur die Erkennung zu
|
||||||
|
Hause laufen lassen will, nimmt die beiden anderen Dateien aus der
|
||||||
|
[Tabelle oben](#welcher-weg-ist-meiner): `docker-compose.yml` beim Hoster,
|
||||||
|
`docker-compose.detector.yml` zu Hause. Das Deployment der Sammlung steht in
|
||||||
|
[Betrieb](betrieb.md).
|
||||||
|
|
||||||
|
## Schnellstart ohne Docker
|
||||||
|
|
||||||
|
Voraussetzungen: Python 3.11+, rund 500 MB Platte, eine Kamera die MJPEG,
|
||||||
|
RTSP oder Einzelbilder liefert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.gaertner.onl/christian/vogelalbum.git && cd vogelalbum
|
||||||
|
python3 -m venv venv
|
||||||
|
./venv/bin/pip install -r requirements-detector.txt -r requirements-web.txt
|
||||||
|
./scripts/fetch_models.sh # ~10 MB, liegen nicht im Repo
|
||||||
|
./scripts/fetch_testdata.sh # drei Beispielfotos, optional
|
||||||
|
|
||||||
|
cd web
|
||||||
|
../venv/bin/python manage.py migrate
|
||||||
|
../venv/bin/python manage.py seed_dex # 148 Arten, 15 Abzeichen
|
||||||
|
../venv/bin/python manage.py fetch_reference_images # Wikipedia-Fotos, ~5 min
|
||||||
|
../venv/bin/python manage.py runserver 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann in einem zweiten Terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python -m detector watch --source "http://<kamera>:8080/stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ohne Kamera ausprobieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
../venv/bin/python manage.py demo_data # 21 Tage Beispieldaten
|
||||||
|
../venv/bin/python manage.py demo_data --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Demo-Sichtungen benutzen die Wikipedia-Referenzfotos, sind also klar als
|
||||||
|
Attrappe erkennbar. **Vor dem Echtbetrieb `--clear` laufen lassen**, sonst
|
||||||
|
mischen sich Demo- und Kameradaten.
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Die Kamera anschließen
|
||||||
|
|
||||||
|
<sub>[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md)</sub>
|
||||||
|
|
||||||
|
Vogelalbum braucht von der Kamera nur einen Stream. Hier steht, welche Formen
|
||||||
|
davon gelesen werden, wie man die Schwellwerte einstellt und — die Frage, an
|
||||||
|
der ein Aufbau wirklich scheitert — **wie groß ein Vogel im Bild sein muss**.
|
||||||
|
|
||||||
|
Der Aufbau der Kameraseite selbst steht getrennt: [Raspberry
|
||||||
|
Pi](../pi/README.md) oder [ESP32-S3](../esp32cam/README.md).
|
||||||
|
|
||||||
|
**Noch keine Kamera aufgebaut?** Es gibt zwei fertige Kameraseiten, beide mit
|
||||||
|
denselben Pfaden — der Detector merkt den Unterschied nicht:
|
||||||
|
|
||||||
|
- **[pi/README.md](../pi/README.md)** — Raspberry Pi mit Kameramodul, MJPEG-Server
|
||||||
|
als Dienst (`pi/vogelkamera.py`). Die bessere Optik.
|
||||||
|
- **[esp32cam/README.md](../esp32cam/README.md)** — ESP32-S3 mit Kameramodul
|
||||||
|
(`esp32cam/`). Ein Bruchteil des Preises, externe Antenne möglich, und
|
||||||
|
`/status` liefert Bildrate, Funkstärke und Temperatur, ohne den Stream
|
||||||
|
anzufassen.
|
||||||
|
|
||||||
|
Beide Anleitungen nennen die drei Dinge, die man dabei einmal falsch macht —
|
||||||
|
zweiter Zuschauer, Hitze im Kasten, Bildrate am Licht statt an der CPU.
|
||||||
|
|
||||||
|
Der Detector spricht MJPEG, RTSP, Einzelbild-URLs und Ordner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MJPEG (mjpg-streamer, motion, picamera2-Beispielserver)
|
||||||
|
python -m detector watch --source "http://kamera.local:8080/stream"
|
||||||
|
|
||||||
|
# Einzelbild im Sekundentakt — genügsamer, für einen Nistkasten meist genug
|
||||||
|
python -m detector watch --source "http://kamera.local/snapshot.jpg" --interval 1
|
||||||
|
|
||||||
|
# RTSP (braucht ffmpeg auf dem Host)
|
||||||
|
python -m detector watch --source "rtsp://kamera.local:8554/cam"
|
||||||
|
|
||||||
|
# Ordner mit Bildern, zum Ausprobieren ohne Kamera
|
||||||
|
python -m detector watch --source ./testbilder
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit Anbindung an die Web-App:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector watch \
|
||||||
|
--source "http://kamera.local:8080/stream" \
|
||||||
|
--api http://127.0.0.1:8000 \
|
||||||
|
--token "$VOGELALBUM_TOKEN" \
|
||||||
|
--save-to /var/lib/vogelalbum/rohdaten
|
||||||
|
```
|
||||||
|
|
||||||
|
`--save-to` schreibt Bild und JSON zusätzlich auf Platte. Das kostet fast
|
||||||
|
nichts und ist später das Trainingsmaterial fürs eigene Modell.
|
||||||
|
|
||||||
|
### Schwellwerte einstellen
|
||||||
|
|
||||||
|
Die Voreinstellungen passen zu *unserer* Kamera. An einer anderen gehören sie
|
||||||
|
gemessen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector tune --source "http://kamera.local:8080/stream"
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeigt live den Anteil bewegter Pixel und die Bildhelligkeit. `--motion-ratio`
|
||||||
|
gehört knapp über den Ruhepegel — hoch genug, dass Wind und Lichtwechsel nicht
|
||||||
|
auslösen, niedrig genug für eine Blaumeise am Bildrand. Liegt der Ruhepegel
|
||||||
|
ohnehin hoch, ist `--gate detector` die bessere Wahl.
|
||||||
|
|
||||||
|
Das ist aber nur die Bewegungsschwelle. Ob die Kamera überhaupt an der
|
||||||
|
richtigen Stelle hängt, beantwortet [`detector karte`](#hängt-die-kamera-richtig-detector-karte)
|
||||||
|
— und zwar bevor man Tage auf den ersten Vogel wartet.
|
||||||
|
|
||||||
|
### Abschaltung bei Dunkelheit
|
||||||
|
|
||||||
|
Eine Kamera ohne Infrarot liefert nachts ein schwarzes Bild. Es durch zwei
|
||||||
|
neuronale Netze zu schicken kostet Strom und bringt nichts, deshalb pausiert
|
||||||
|
der Detector von selbst.
|
||||||
|
|
||||||
|
Entschieden wird über die **Bildhelligkeit**, nicht über Sonnenauf- und
|
||||||
|
-untergang. Das braucht keine Koordinaten und 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.
|
||||||
|
|
||||||
|
Zwischen Abschalt- und Einschaltschwelle liegt bewusst Abstand — ohne diese
|
||||||
|
Hysterese flattert die Entscheidung in der Dämmerung im Sekundentakt.
|
||||||
|
|
||||||
|
Eine Infrarotkamera liefert nachts ein graues, aber helles Bild und läuft
|
||||||
|
damit von selbst weiter. `--nachtsicht` ist nur nötig, wenn die Automatik
|
||||||
|
danebenliegt.
|
||||||
|
|
||||||
|
Gemessen im Betrieb: statt rund 40 % eines Kerns bleiben nachts 9 %, und die
|
||||||
|
gehen fast vollständig für das Dekodieren des MJPEG-Stroms drauf.
|
||||||
|
|
||||||
|
### Dauerprotokoll
|
||||||
|
|
||||||
|
Sechs Minuten zuschauen beantwortet die Frage „übersehen wir Vögel?" nur für
|
||||||
|
diese sechs Minuten. Über Tage beantwortet sie ein Protokoll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector watch --source ... --protokoll /var/lib/vogelalbum/protokoll.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
Geschrieben werden zwei Sorten Zeilen:
|
||||||
|
|
||||||
|
* **jede Erkennung ab `--protokoll-ab`** (Standard 0.08, weit unter der
|
||||||
|
Betriebsschwelle von 0.4) — auch die, die für eine Sichtung nicht gereicht
|
||||||
|
hat. Genau daran sieht man, ob die Schwelle zu hoch steht oder die Vögel
|
||||||
|
schlicht zu klein im Bild sind.
|
||||||
|
* **ein Lebenszeichen je Viertelstunde**, immer, auch wenn nichts passiert
|
||||||
|
ist. Diese Zeilen sind die wichtigeren: Ein Protokoll, das nur bei Erfolg
|
||||||
|
schreibt, sieht bei einem Ausfall genauso aus wie bei einem ruhigen Tag.
|
||||||
|
|
||||||
|
### Wie groß muss ein Vogel im Bild sein
|
||||||
|
|
||||||
|
Gemessen, indem ein echter Vogelausschnitt in verschiedenen Größen in ein
|
||||||
|
Bild derselben Kamera montiert wurde:
|
||||||
|
|
||||||
|
| Anteil am Bild | Detektor-Score | |
|
||||||
|
|---|---|---|
|
||||||
|
| 15 % | 0.93 | ✅ |
|
||||||
|
| 10 % | 0.77 | ✅ |
|
||||||
|
| 5 % | 0.77 | ✅ |
|
||||||
|
| 3 % | 0.23 | ❌ unter der Schwelle |
|
||||||
|
| 1 % | 0.07 | ❌ |
|
||||||
|
|
||||||
|
Die Grenze liegt bei rund **5 % der Bildfläche** — bei 1280×720 also etwa
|
||||||
|
260×175 Pixel.
|
||||||
|
|
||||||
|
Aufschlussreich ist, was dabei *nicht* zusammenbricht: Die Artbestimmung
|
||||||
|
liegt selbst bei 1 % Fläche noch bei 100 %. Der Engpass ist also nicht, die
|
||||||
|
Art zu erkennen, sondern überhaupt zu merken, dass da ein Vogel ist. Für den
|
||||||
|
Aufbau heißt das: **näher heran schlägt höhere Auflösung.**
|
||||||
|
|
||||||
|
Die 5 % sind allerdings **kein fester Wert, sondern hängen am Hintergrund.**
|
||||||
|
Dieselbe Messung nach einem Umsetzen der Kamera — statt ruhigem Grund nun ein
|
||||||
|
Baum im Wind hinter dem Futter — ergab erst ab **8 %** eine zuverlässige
|
||||||
|
Erkennung. Deshalb misst man an der eigenen Kamera nach, statt eine Zahl aus
|
||||||
|
diesem README zu übernehmen. Genau dafür gibt es das nächste Kommando.
|
||||||
|
|
||||||
|
### Hängt die Kamera richtig? (`detector karte`)
|
||||||
|
|
||||||
|
Die Größe ist nur die halbe Antwort. Die andere ist, **wo** im Bild der Vogel
|
||||||
|
sitzt — und da gibt es Zonen, in denen der Detektor auch einen großen Vogel
|
||||||
|
nicht findet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector karte --source http://vogelhaus.local:8080/stream \
|
||||||
|
--vogel testdata/amsel.jpg --anteil 0.08 --ziel karte.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein echtes Vogelfoto wird mit weicher Maske über ein Raster von Positionen in
|
||||||
|
aktuelle Bilder der eigenen Kamera montiert und jedes Mal durch den Detektor
|
||||||
|
geschickt. Heraus kommt eine Ampel über dem eigenen Kamerabild — grün erkannt,
|
||||||
|
rot nicht — plus dieselbe Karte als Tabelle und ein Satz, was zu tun ist.
|
||||||
|
|
||||||
|
**Das beantwortet vor dem ersten echten Besuch, ob die Kamera richtig hängt.**
|
||||||
|
Ohne diese Karte wartet man Tage auf eine leere Sammlung und weiß danach immer
|
||||||
|
noch nicht, ob keine Vögel kamen oder ob sie an der falschen Stelle im Bild
|
||||||
|
saßen.
|
||||||
|
|
||||||
|
Am Vogelhaus in Herzogenrath sah das so aus:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fuss y x=0.18 x=0.32 x=0.46 x=0.60 x=0.74 x=0.88
|
||||||
|
0.55 OK 0.84 -- 0.44 OK 0.67 OK 0.67 OK 0.44 -- 0.44
|
||||||
|
0.65 OK 0.77 -- 0.44 OK 0.80 OK 0.56 -- 0.28 OK 0.62
|
||||||
|
0.75 OK 0.67 -- 0.38 OK 0.80 OK 0.72 -- 0.38 OK 0.56
|
||||||
|
0.85 OK 0.84 OK 0.67 OK 0.77 OK 0.84 OK 0.56 OK 0.72
|
||||||
|
0.95 -- 0.38 -- 0.16 -- 0.38 OK 0.62 -- 0.23 -- 0.20
|
||||||
|
```
|
||||||
|
|
||||||
|
Die unterste Reihe fällt fast geschlossen durch — und genau dort steht ein
|
||||||
|
Vogel, der auf dem Futterbrett landet, wenn die Kamera flach darüber hinweg
|
||||||
|
schaut. Derselbe Vogel in derselben Größe zehn Prozent höher im Bild: 0.84.
|
||||||
|
|
||||||
|
Daraus die Bauregel, die für jedes Futterhaus gilt: **die Landefläche gehört
|
||||||
|
in die untere Bildmitte, nicht an den unteren Bildrand.** Kamera so weit nach
|
||||||
|
unten neigen, bis unter dem Futter noch Bild übrig ist.
|
||||||
|
|
||||||
|
### Nachsehen, was angekommen ist
|
||||||
|
|
||||||
|
Nach den ersten Tagen ist die Frage nicht „läuft es", sondern „sehen wir Vögel,
|
||||||
|
und taugen die Bilder etwas". Dafür gibt es eine Auswertung der Rohdaten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector report /var/lib/vogelalbum/rohdaten
|
||||||
|
python -m detector report /var/lib/vogelalbum/rohdaten --kontaktbogen bogen.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Sie misst je Sichtung nach, statt zu schätzen: **Schärfe** (Varianz der
|
||||||
|
Laplace-Antwort, unter ~50 sichtbar verwackelt), **Fläche** des Vogels im Bild
|
||||||
|
und **Helligkeit**.
|
||||||
|
|
||||||
|
Die Fläche ist dabei der wichtigste Wert. Ein Vogel, der unter 3 % des Bildes
|
||||||
|
einnimmt, ist ein Fleck — daran ändert kein Schwellwert etwas, da muss die
|
||||||
|
Kamera näher heran oder enger eingestellt werden. Die Auswertung sagt das
|
||||||
|
ausdrücklich, wenn es auf mehr als ein Drittel der Sichtungen zutrifft.
|
||||||
|
|
||||||
|
Der Kontaktbogen legt alle Ausschnitte als ein Bild nebeneinander. Ob die
|
||||||
|
Qualität reicht, sieht man damit in zehn Sekunden statt in sechzig einzeln
|
||||||
|
geöffneten Dateien.
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 625 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Im Betrieb verlangen die Settings einen SECRET_KEY und verweigern sonst den
|
||||||
|
# Start. Fuer ein oeffentliches Deployment ist das richtig — dort gehoert der
|
||||||
|
# Schluessel in die Umgebung. Zu Hause ist es eine Huerde ohne Gegenwert:
|
||||||
|
# niemand soll sich ein Geheimnis ausdenken muessen, um Vögel zu zaehlen.
|
||||||
|
# Deshalb hier einmalig erzeugen und aufs Datenvolume legen. Er muss ueber
|
||||||
|
# Neustarts stabil bleiben, sonst sind alle Sitzungen jedes Mal ungueltig.
|
||||||
|
if [ -z "${DJANGO_SECRET_KEY:-}" ]; then
|
||||||
|
KEYFILE="${SQLITE_DIR:-/app}/secret_key"
|
||||||
|
if [ ! -s "$KEYFILE" ]; then
|
||||||
|
mkdir -p "$(dirname "$KEYFILE")"
|
||||||
|
python -c "from django.core.management.utils import get_random_secret_key as g; print(g())" > "$KEYFILE"
|
||||||
|
chmod 600 "$KEYFILE"
|
||||||
|
echo "SECRET_KEY erzeugt: $KEYFILE"
|
||||||
|
fi
|
||||||
|
DJANGO_SECRET_KEY="$(cat "$KEYFILE")"
|
||||||
|
export DJANGO_SECRET_KEY
|
||||||
|
fi
|
||||||
|
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Katalog und Abzeichen sind Stammdaten, keine Nutzerdaten — bei jedem Start
|
||||||
|
# abgleichen, damit neue Arten ohne manuellen Schritt ankommen.
|
||||||
|
python manage.py seed_dex
|
||||||
|
|
||||||
|
# Referenzfotos aus Wikipedia. Der Befehl überspringt Arten, die schon eins
|
||||||
|
# haben — nach dem ersten Start kostet er also keine einzige Anfrage mehr.
|
||||||
|
# Netzprobleme dürfen den Start nicht blockieren: ohne Referenzfotos ist die
|
||||||
|
# Sammlung hässlich, aber funktionsfähig.
|
||||||
|
#
|
||||||
|
# 🔑 **Im Hintergrund, und zwar seit dem 09.10.2026.** Davor lief der Abruf
|
||||||
|
# vor gunicorn — bei einer leeren Datenbank sind das rund 148 Anfragen an
|
||||||
|
# Wikipedia, und weil Wikimedia hart drosselt, liegen Pausen dazwischen. Der
|
||||||
|
# allererste Start hat damit minutenlang nichts ausgeliefert: kein Fehler,
|
||||||
|
# keine Seite, nur Stille. Wer dem Schnellstart aus dem README folgt, hält
|
||||||
|
# genau das für kaputt — und es war die Stelle, an der es am leichtesten
|
||||||
|
# passiert.
|
||||||
|
#
|
||||||
|
# Jetzt steht die Sammlung sofort, und die Fotos tropfen nach. Die Arten sind
|
||||||
|
# schon da (seed_dex oben), es fehlen anfangs nur ihre Bilder.
|
||||||
|
python manage.py fetch_reference_images \
|
||||||
|
|| echo "Referenzfotos übersprungen" &
|
||||||
|
|
||||||
|
exec gunicorn vogelalbum.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
project(vogelkamera)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Vogelkamera auf ESP32-S3
|
||||||
|
|
||||||
|
Kameraseite für Vogelalbum auf einem ESP32-S3 mit Kameramodul — das Gegenstück zu
|
||||||
|
[`../pi/`](../pi/README.md). Gleiche Pfade und gleiches JSON-Schema: der
|
||||||
|
Detector merkt nicht, ob ein Pi Zero oder ein ESP32 am anderen Ende hängt.
|
||||||
|
|
||||||
|
http://<kamera>:8080/stream MJPEG, ein Zuschauer
|
||||||
|
http://<kamera>:8080/ Vorschauseite
|
||||||
|
|
||||||
|
http://<kamera>:8081/status JSON: fps, RSSI, Temperatur, Laufzeit
|
||||||
|
http://<kamera>:8081/temp Chiptemperatur als Text
|
||||||
|
http://<kamera>:8081/gesund 200 oder 503
|
||||||
|
|
||||||
|
🔑 **Die Diagnose liegt auf einem eigenen Port — das ist der eine Unterschied
|
||||||
|
zum Pi.** Der Webserver im ESP32 arbeitet Anfragen mit *einem* Faden ab, und
|
||||||
|
der Stream gibt den nie zurück. Solange jemand zuschaut, käme auf 8080 niemand
|
||||||
|
mehr an `/status` — also genau dann nicht, wenn man es braucht. Am 23.09.2026
|
||||||
|
wirkte die Kamera deshalb tot, während sie brav streamte. Seitdem hat die
|
||||||
|
Diagnose ihren eigenen Port und ihren eigenen Faden.
|
||||||
|
|
||||||
|
⚠️ **Bis zum 09.10.2026 stand hier für alles `8080`.** Wer die Zeile kopierte,
|
||||||
|
bekam eine Anfrage, die stumm ins Zeitlimit lief — dasselbe Bild wie eine tote
|
||||||
|
Kamera. Der Port ist `VK_PORT + 1`; wer im `menuconfig` den Hauptport ändert,
|
||||||
|
verschiebt beide.
|
||||||
|
|
||||||
|
## Warum ESP32 statt Pi
|
||||||
|
|
||||||
|
Am Vogelhaus ist die Bildrate **bandbreitenbegrenzt, nicht rechenbegrenzt**:
|
||||||
|
der Pi Zero W drückt über seine Chip-Antenne rund 2 Mbit/s durchs WLAN, und
|
||||||
|
jeder gemessene Einbruch hing an der Funkstrecke. Ein ESP32-S3-Board mit
|
||||||
|
**externer Antenne** greift genau da an — und kostet ein Vielfaches weniger,
|
||||||
|
was für einen Bausatz den Unterschied zwischen verkaufbar und nicht
|
||||||
|
verkaufbar macht.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
Getestet wird gegen ein **ESP32-S3-CAM mit OV3660** (N16R8-Modul: 16 MB Flash,
|
||||||
|
8 MB PSRAM, externe Antenne). Die Firmware läuft aber auf jedem Board, dessen
|
||||||
|
Pinbelegung im `menuconfig` hinterlegt ist.
|
||||||
|
|
||||||
|
> ⚠️ **Das Funkmodul muss ein ESP32-S3-WROOM-1*U* sein** — das *U* steht für
|
||||||
|
> die Antennenbuchse. Bei manchen Klonen liegt eine Antenne bei, das Modul hat
|
||||||
|
> aber nur die Platinenantenne; dann ist das Kabel Deko.
|
||||||
|
|
||||||
|
> ⚠️ **OV5640 nicht verwenden.** Auf diesen Boards liefert er dokumentiert ein
|
||||||
|
> schwarzes Bild, und der Sensor überhitzt, weil die Versorgung für seinen
|
||||||
|
> internen 1,5-V-Regler zu hoch liegt. OV2640 und OV3660 laufen.
|
||||||
|
|
||||||
|
## Bauen und flashen
|
||||||
|
|
||||||
|
```sh
|
||||||
|
. ~/esp/esp-idf/export.sh
|
||||||
|
cd esp32cam
|
||||||
|
idf.py set-target esp32s3
|
||||||
|
idf.py menuconfig # Menü "Vogelkamera": WLAN, Board, Bildgröße
|
||||||
|
idf.py -p /dev/ttyACM0 flash monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
Die WLAN-Daten stehen bewusst **nicht** im Repo — sie landen über
|
||||||
|
`menuconfig` in `sdkconfig`, und das ist per `.gitignore` draußen.
|
||||||
|
|
||||||
|
Zwei USB-C-Buchsen am Board: eine hängt am USB-Seriell-Wandler (CH340), die
|
||||||
|
andere am nativen USB des Chips. Findet `idf.py flash` nichts, ist es die
|
||||||
|
andere Buchse. Je nach Board muss zum Flashen BOOT gehalten und RESET kurz
|
||||||
|
gedrückt werden.
|
||||||
|
|
||||||
|
### Wenn die Kamera nicht anspringt
|
||||||
|
|
||||||
|
`esp_camera_init` scheitert mit `ESP_ERR_NOT_FOUND` (0x105), wenn die
|
||||||
|
Pinbelegung nicht zum Board passt — dann ist die **Board-Auswahl im
|
||||||
|
`menuconfig`** falsch, nicht die Kamera defekt. Die Firmware bricht deshalb
|
||||||
|
nicht ab: der HTTP-Server läuft weiter und `/status` nennt den Fehlercode.
|
||||||
|
Ein Gerät im Garten, das bei einem Fehler stumm bleibt, kostet einen Weg
|
||||||
|
nach draußen.
|
||||||
|
|
||||||
|
Zweiter Kandidat: `CONFIG_SPIRAM_MODE_OCT` in `sdkconfig.defaults`. Octal ist
|
||||||
|
für die R8-Module richtig; ein R2- oder Quad-Modul braucht `QUAD`, sonst
|
||||||
|
findet die Kamera keinen Framebuffer.
|
||||||
|
|
||||||
|
## Ein Zuschauer, mit Absicht
|
||||||
|
|
||||||
|
Ein zweiter Client bekommt **503** statt eines halben Bildes. Der Grund steht
|
||||||
|
im Kopf von [`main/vogelkamera.c`](main/vogelkamera.c): ein zweiter Zuschauer
|
||||||
|
sättigt die Funkstrecke, der Detector fällt in Timeouts, und zwar ohne dass
|
||||||
|
irgendwo ein Fehler auftaucht — die Bildrate halbiert sich einfach. Genau das
|
||||||
|
ist bei der Entwicklung passiert: das Diagnosewerkzeug hat den Fehler erzeugt,
|
||||||
|
den es messen sollte.
|
||||||
|
|
||||||
|
Deshalb gibt es `/status` und `/temp`. **Beide fassen den Stream nicht an** und
|
||||||
|
sind der richtige Weg, die Kamera im Betrieb zu prüfen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s http://<kamera>:8081/status
|
||||||
|
{"kamera":"bereit","fehler":"0x0","laufzeit_s":615201,"bilder":3206804,
|
||||||
|
"fps":5.25,"letztes_bild_s":0.1,"zuschauer":true,"rssi":-71,
|
||||||
|
"temperatur_c":51.7,"speicher_frei":8104348,"psram_frei":7941536}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Echte Ausgabe einer Kamera an einer Vogeltränke nach sieben Tagen
|
||||||
|
Dauerbetrieb.) **Port 8081, nicht 8080** — siehe oben.
|
||||||
|
|
||||||
|
`ping` taugt dafür **nicht** — ICMP fällt bei gesättigtem WLAN als Erstes aus,
|
||||||
|
während TCP weiterläuft. Eine Entwarnung per `ping` ist Glück, keine Messung.
|
||||||
|
|
||||||
|
Was die Werte sagen:
|
||||||
|
|
||||||
|
| Feld | Gesund | Woran es sonst liegt |
|
||||||
|
|---|---|---|
|
||||||
|
| `fps` | stabil über Stunden | fällt → Funkstrecke, zweiter Zuschauer, Hitze |
|
||||||
|
| `rssi` | über −70 dBm | darunter Antenne ausrichten oder näher ran |
|
||||||
|
| `temperatur_c` | unter 70 °C | geschlossenes Gehäuse in der Sonne drosselt |
|
||||||
|
| `letztes_bild_s` | unter 1 s bei Zuschauer | steht → Sensor hängt, siehe unten |
|
||||||
|
| `zuschauer` | `true` im Normalbetrieb | `false`, obwohl der Detector läuft → er kommt nicht durch |
|
||||||
|
|
||||||
|
Bleibt der Sensor 30 Versuche am Stück stumm, startet das Gerät selbst neu.
|
||||||
|
Zwei Sekunden Neustart sind billiger als ein Weg in den Garten.
|
||||||
|
|
||||||
|
## Einstellungen, die wirklich etwas bringen
|
||||||
|
|
||||||
|
**Bildgröße und Qualität sind Bandbreite, nicht Schönheit.** Weniger Qualität
|
||||||
|
heißt mehr Bilder pro Sekunde, und mehr Bilder finden mehr Vögel als schönere.
|
||||||
|
Default ist XGA (1024x768) bei Qualität 12.
|
||||||
|
|
||||||
|
**Der begrenzende Faktor ist der Bildwinkel, nicht die Auflösung.** Am
|
||||||
|
Futterhaus scheitert die Erkennung daran, dass der Vogel unter den nötigen
|
||||||
|
5–8 % Bildfläche bleibt. Mehr Megapixel ändern daran nichts, nähere Montage
|
||||||
|
schon — 30–40 cm an die Landefläche, und die Landefläche in die untere
|
||||||
|
Bildmitte, nicht an den Bildrand (der unterste Bildrand ist eine tote Zone,
|
||||||
|
siehe `python -m detector karte`).
|
||||||
|
|
||||||
|
**Kein WLAN-Stromsparen.** `WIFI_PS_NONE` ist gesetzt: der Empfänger darf
|
||||||
|
zwischen zwei Bildern nicht schlafen. Die Bildrate ist der Zweck des Geräts,
|
||||||
|
nicht die Batterielaufzeit.
|
||||||
|
|
||||||
|
## Feste Adresse
|
||||||
|
|
||||||
|
Der Detector braucht eine gleichbleibende Adresse. Der Dev-Server löst **kein
|
||||||
|
mDNS** auf (kein avahi), `.local`-Namen helfen also nicht — im Router eine
|
||||||
|
feste DHCP-Lease für die MAC eintragen.
|
||||||
|
|
||||||
|
## Noch nicht getestet
|
||||||
|
|
||||||
|
Die Firmware **kompiliert** (ESP-IDF v5.3, 78 % der App-Partition frei), war
|
||||||
|
aber noch auf keiner Hardware. Ungeprüft sind damit: die Pinbelegungen, die
|
||||||
|
tatsächliche Bildrate über WLAN und die Sensorerkennung. Erwartet werden die
|
||||||
|
Boards am 14.09.2026.
|
||||||
|
|
||||||
|
OTA-Updates sind nicht eingebaut. Die Partitionstabelle lässt hinter der
|
||||||
|
4-MB-App genug Platz, um sie ohne Umbau nachzurüsten — für ein Gerät, das
|
||||||
|
später im Garten festsitzt, wäre das der nächste sinnvolle Schritt.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "vogelkamera.c" "netz.c"
|
||||||
|
INCLUDE_DIRS ""
|
||||||
|
# app_update liefert esp_ota_ops.h -- ohne das kein Update ueber WLAN.
|
||||||
|
REQUIRES esp_wifi esp_http_server esp_netif esp_event nvs_flash
|
||||||
|
esp_timer esp_driver_tsens lwip app_update
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
menu "Vogelkamera"
|
||||||
|
|
||||||
|
# ⚠ Hier standen bis zum 23.09.2026 WLAN-Name und Passwort. Sie sind
|
||||||
|
# bewusst entfernt: Zugangsdaten, die beim Bauen eingebacken werden,
|
||||||
|
# zwingen jeden Nutzer dazu, die Firmware selbst zu uebersetzen. Fuer
|
||||||
|
# ein veroeffentlichtes Repo ist das untauglich.
|
||||||
|
#
|
||||||
|
# Stattdessen: Das Geraet macht beim ersten Start ein eigenes WLAN auf
|
||||||
|
# und fragt im Browser danach. Siehe netz.c, uebernommen aus Hey Fred.
|
||||||
|
|
||||||
|
config VK_PORT
|
||||||
|
int "HTTP-Port"
|
||||||
|
default 8080
|
||||||
|
help
|
||||||
|
Gleicher Port wie am Pi. Der Detector erwartet
|
||||||
|
http://<kamera>:8080/stream.
|
||||||
|
|
||||||
|
choice VK_BOARD
|
||||||
|
prompt "Pinbelegung der Kamera"
|
||||||
|
default VK_BOARD_S3_WROOM
|
||||||
|
help
|
||||||
|
Die Pins haengen am Board, nicht am Sensor. Stimmt die Belegung
|
||||||
|
nicht, scheitert esp_camera_init mit 0x105 (ESP_ERR_NOT_FOUND) —
|
||||||
|
dann ist es die falsche Wahl hier, nicht die Kamera.
|
||||||
|
|
||||||
|
config VK_BOARD_S3_WROOM
|
||||||
|
bool "ESP32-S3-WROOM CAM (Freenove, Heemol und Klone)"
|
||||||
|
|
||||||
|
config VK_BOARD_XIAO_S3
|
||||||
|
bool "Seeed XIAO ESP32S3 Sense"
|
||||||
|
|
||||||
|
config VK_BOARD_AI_THINKER
|
||||||
|
bool "AI-Thinker ESP32-CAM (klassisch, ESP32 statt S3)"
|
||||||
|
endchoice
|
||||||
|
|
||||||
|
config VK_FRAMESIZE
|
||||||
|
int "Bildgroesse"
|
||||||
|
default 13
|
||||||
|
range 0 21
|
||||||
|
help
|
||||||
|
⚠ Die Zahlen sind Positionen in framesize_t aus dem
|
||||||
|
Kameratreiber -- und die haben sich schon einmal verschoben.
|
||||||
|
Am 23.09.2026 stand hier "9 = XGA 1024x768"; tatsaechlich war
|
||||||
|
9 laengst HVGA 480x320. Die Kamera lief daraufhin monatelang
|
||||||
|
auf einem Sechstel der Flaeche, ohne dass etwas gemeldet haette.
|
||||||
|
|
||||||
|
Nach einem Treiber-Update deshalb gegen sensor.h pruefen:
|
||||||
|
managed_components/espressif__esp32-camera/driver/include/sensor.h
|
||||||
|
|
||||||
|
Stand des Treibers vom 23.09.2026:
|
||||||
|
11 = SVGA 800x600
|
||||||
|
12 = XGA 1024x768
|
||||||
|
13 = HD 1280x720 (Vorgabe, wie der Pi am Futterhaus)
|
||||||
|
14 = SXGA 1280x1024
|
||||||
|
15 = UXGA 1600x1200
|
||||||
|
|
||||||
|
Mehr ist nicht besser: ueber WLAN ist die Bildrate
|
||||||
|
bandbreitenbegrenzt, nicht rechenbegrenzt. Aber zu klein
|
||||||
|
findet keine Voegel -- unter 5-8 % Bildflaeche erkennt das
|
||||||
|
Modell sie nicht mehr.
|
||||||
|
|
||||||
|
config VK_JPEG_QUALITAET
|
||||||
|
int "JPEG-Qualitaet (10 = gut, 63 = grob)"
|
||||||
|
default 12
|
||||||
|
range 4 63
|
||||||
|
help
|
||||||
|
Kleinere Zahl heisst besseres Bild und groessere Dateien. Bei
|
||||||
|
knapper Funkstrecke bringt eine hoehere Zahl mehr Bilder pro
|
||||||
|
Sekunde — und mehr Bilder finden mehr Voegel als schoenere.
|
||||||
|
|
||||||
|
config VK_HFLIP
|
||||||
|
bool "Bild horizontal spiegeln"
|
||||||
|
default n
|
||||||
|
|
||||||
|
config VK_VFLIP
|
||||||
|
bool "Bild vertikal spiegeln (Kamera haengt ueber Kopf)"
|
||||||
|
default n
|
||||||
|
|
||||||
|
config VK_MAX_FPS
|
||||||
|
int "Bilder je Sekunde hoechstens senden (0 = ohne Bremse)"
|
||||||
|
range 0 30
|
||||||
|
default 5
|
||||||
|
help
|
||||||
|
Ohne Bremse sendet die Kamera so schnell die Funkstrecke
|
||||||
|
hergibt -- gemessen 16 Bilder/s. Das heizt den Chip und
|
||||||
|
saettigt bei schwachem Empfang die Strecke.
|
||||||
|
|
||||||
|
Gebraucht wird es nicht: Ein Vogelbesuch dauert Sekunden bis
|
||||||
|
Minuten, und der Detector bestimmt nur jedes sechste Bild.
|
||||||
|
Fuenf sind reichlich.
|
||||||
|
|
||||||
|
Gemessen am 25.09.2026 an der Traenke, in der Sonne:
|
||||||
|
17 B/s -> Plateau 78,8 °C, Empfang -69 dBm
|
||||||
|
5 B/s -> Plateau 76,7 °C, Empfang -57 dBm
|
||||||
|
Zwei Grad kuehler, und zwoelf dB mehr Reserve auf der
|
||||||
|
Funkstrecke. Letzteres war nicht beabsichtigt und ist der
|
||||||
|
groessere Gewinn.
|
||||||
|
|
||||||
|
config VK_SAETTIGUNG
|
||||||
|
int "Farbsaettigung (-2 bis 2)"
|
||||||
|
range -2 2
|
||||||
|
default 2
|
||||||
|
help
|
||||||
|
Der Sensor liefert von Haus aus ein flaues Bild. Neben der
|
||||||
|
Pi-Kamera am Futterhaus faellt das sofort auf. Farbe ist hier
|
||||||
|
nicht nur Geschmack: Die Arterkennung arbeitet damit.
|
||||||
|
|
||||||
|
config VK_KONTRAST
|
||||||
|
int "Kontrast (-2 bis 2)"
|
||||||
|
range -2 2
|
||||||
|
default 1
|
||||||
|
help
|
||||||
|
Nicht ans Maximum. Zu viel Kontrast frisst die Zeichnung im
|
||||||
|
dunklen Gefieder — und genau die braucht der Erkenner.
|
||||||
|
|
||||||
|
config VK_HELLIGKEIT
|
||||||
|
int "Helligkeit (-2 bis 2)"
|
||||||
|
range -2 2
|
||||||
|
default 0
|
||||||
|
help
|
||||||
|
Nur anfassen, wenn das Bild durchweg zu dunkel oder zu hell
|
||||||
|
ist. Gegen Gegenlicht hilft eher das Ausrichten.
|
||||||
|
|
||||||
|
endmenu
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dependencies:
|
||||||
|
espressif/esp32-camera: "^2.0.11"
|
||||||
|
idf:
|
||||||
|
version: ">=5.1"
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/task.h"
|
||||||
|
#include "freertos/event_groups.h"
|
||||||
|
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "esp_wifi.h"
|
||||||
|
#include "esp_event.h"
|
||||||
|
#include "esp_netif.h"
|
||||||
|
#include "nvs_flash.h"
|
||||||
|
#include "nvs.h"
|
||||||
|
#include "esp_random.h"
|
||||||
|
#include "esp_sntp.h"
|
||||||
|
#include <time.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
|
||||||
|
#include "netz.h"
|
||||||
|
|
||||||
|
static const char *TAG = "netz";
|
||||||
|
|
||||||
|
// Der Name des Einrichtungsnetzes steht erst fest, wenn die MAC bekannt
|
||||||
|
// ist. Einmal gebaut, danach nur noch gelesen.
|
||||||
|
static char setup_ssid[33] = {0};
|
||||||
|
|
||||||
|
const char *netz_setup_ssid(void)
|
||||||
|
{
|
||||||
|
if (setup_ssid[0] == '\0') {
|
||||||
|
uint8_t mac[6] = {0};
|
||||||
|
esp_wifi_get_mac(WIFI_IF_AP, mac);
|
||||||
|
snprintf(setup_ssid, sizeof(setup_ssid), "%s-%02X%02X",
|
||||||
|
NETZ_SETUP_PRAEFIX, mac[4], mac[5]);
|
||||||
|
}
|
||||||
|
return setup_ssid;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define NVS_BEREICH "vogelkamera"
|
||||||
|
#define NVS_SSID "ssid"
|
||||||
|
#define NVS_PASS "pass"
|
||||||
|
#define NVS_OTA "ota"
|
||||||
|
|
||||||
|
// Wuerfelt beim ersten Aufruf ein Geheimnis und merkt es sich dauerhaft.
|
||||||
|
// esp_fill_random speist sich aus dem Hardware-Zufall des Chips -- nicht
|
||||||
|
// aus rand(), das auf jedem Geraet dieselbe Folge liefern wuerde.
|
||||||
|
const char *netz_ota_token(void)
|
||||||
|
{
|
||||||
|
static char token[49] = {0};
|
||||||
|
if (token[0] != '\0') return token;
|
||||||
|
|
||||||
|
nvs_handle_t h;
|
||||||
|
size_t laenge = sizeof(token);
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) == ESP_OK) {
|
||||||
|
esp_err_t r = nvs_get_str(h, NVS_OTA, token, &laenge);
|
||||||
|
nvs_close(h);
|
||||||
|
if (r == ESP_OK && token[0] != '\0') return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t roh[24];
|
||||||
|
esp_fill_random(roh, sizeof(roh));
|
||||||
|
for (size_t i = 0; i < sizeof(roh); i++) {
|
||||||
|
snprintf(token + i * 2, 3, "%02x", roh[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READWRITE, &h) == ESP_OK) {
|
||||||
|
nvs_set_str(h, NVS_OTA, token);
|
||||||
|
nvs_commit(h);
|
||||||
|
nvs_close(h);
|
||||||
|
ESP_LOGI(TAG, "Update-Geheimnis erzeugt und gespeichert");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Wie lange Fred beim Start um die Verbindung kämpft, bevor er die
|
||||||
|
// Einrichtung öffnet.
|
||||||
|
//
|
||||||
|
// Der erste Entwurf gab nach acht Anläufen auf — rund zwanzig Sekunden. Das
|
||||||
|
// war falsch gedacht: Ein Router, der nach einem Stromausfall langsamer
|
||||||
|
// hochkommt als Fred, hätte gereicht, damit ein Kindergerät im Regal
|
||||||
|
// plötzlich einen Hotspot aufmacht und niemand weiß warum. Drei Minuten
|
||||||
|
// überstehen jeden Neustart eines Zugangspunkts, und wer wirklich ein
|
||||||
|
// falsches Passwort eingetippt hat, wartet sie einmalig ab.
|
||||||
|
#define START_GEDULD_MS (3 * 60 * 1000)
|
||||||
|
|
||||||
|
// ⚠ Beim ALLERERSTEN Start, wenn noch nie Zugangsdaten da waren, gilt die
|
||||||
|
// lange Geduld nicht -- da wartet ein Mensch davor. Drei Minuten
|
||||||
|
// Schwarzsehen, bevor ueberhaupt ein Netz auftaucht, kosten Nerven und
|
||||||
|
// erzeugen den Eindruck, das Geraet sei kaputt. Am 23.09.2026 genau so
|
||||||
|
// erlebt.
|
||||||
|
#define ERSTSTART_GEDULD_MS (30 * 1000)
|
||||||
|
|
||||||
|
// Im laufenden Betrieb wird gar nicht mehr aufgegeben: Ein Aussetzer ist
|
||||||
|
// kein Grund, die gespeicherten Zugangsdaten in Frage zu stellen.
|
||||||
|
|
||||||
|
static EventGroupHandle_t s_ereignisse;
|
||||||
|
#define VERBUNDEN BIT0
|
||||||
|
|
||||||
|
static char s_ip[16];
|
||||||
|
static esp_netif_t *s_sta_netif;
|
||||||
|
static esp_netif_t *s_ap_netif;
|
||||||
|
|
||||||
|
static int s_trennungen;
|
||||||
|
static int s_letzter_grund;
|
||||||
|
static bool s_verbunden;
|
||||||
|
// Solange true, wird bei jedem Abriss sofort neu verbunden — auch endlos.
|
||||||
|
static bool s_dranbleiben;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- NVS
|
||||||
|
|
||||||
|
const char *netz_gespeicherte_ssid(void)
|
||||||
|
{
|
||||||
|
static char ssid[NETZ_SSID_MAX + 1];
|
||||||
|
ssid[0] = '\0';
|
||||||
|
|
||||||
|
nvs_handle_t h;
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return ssid;
|
||||||
|
size_t laenge = sizeof(ssid);
|
||||||
|
if (nvs_get_str(h, NVS_SSID, ssid, &laenge) != ESP_OK) ssid[0] = '\0';
|
||||||
|
nvs_close(h);
|
||||||
|
return ssid;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool netz_zugang_vorhanden(void)
|
||||||
|
{
|
||||||
|
nvs_handle_t h;
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return false;
|
||||||
|
|
||||||
|
size_t laenge = 0;
|
||||||
|
esp_err_t err = nvs_get_str(h, NVS_SSID, NULL, &laenge);
|
||||||
|
nvs_close(h);
|
||||||
|
|
||||||
|
return err == ESP_OK && laenge > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool zugang_lesen(char *ssid, char *passwort)
|
||||||
|
{
|
||||||
|
nvs_handle_t h;
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return false;
|
||||||
|
|
||||||
|
size_t l_ssid = NETZ_SSID_MAX + 1;
|
||||||
|
size_t l_pass = NETZ_PASS_MAX + 1;
|
||||||
|
esp_err_t a = nvs_get_str(h, NVS_SSID, ssid, &l_ssid);
|
||||||
|
esp_err_t b = nvs_get_str(h, NVS_PASS, passwort, &l_pass);
|
||||||
|
nvs_close(h);
|
||||||
|
|
||||||
|
// Ein offenes Netz hat kein Passwort — das ist kein Fehler.
|
||||||
|
if (b != ESP_OK) passwort[0] = '\0';
|
||||||
|
return a == ESP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool netz_zugang_speichern(const char *ssid, const char *passwort,
|
||||||
|
esp_err_t *fehler)
|
||||||
|
{
|
||||||
|
// ⚠ Hier stand ESP_ERROR_CHECK auf jedem Aufruf. Das beendet bei einem
|
||||||
|
// Fehler das ganze Geraet -- mitten in der Bearbeitung einer
|
||||||
|
// Web-Anfrage. Ergebnis: Der Browser wartet ewig auf eine Antwort,
|
||||||
|
// die nie kommt, nichts ist gespeichert, und der Nutzer sieht nur,
|
||||||
|
// dass "es nicht geht". Genau so stand jemand am 23.09.2026
|
||||||
|
// einen Abend lang davor.
|
||||||
|
//
|
||||||
|
// Ein Fehler beim Speichern gehoert gemeldet, nicht verschwiegen.
|
||||||
|
esp_err_t r;
|
||||||
|
nvs_handle_t h;
|
||||||
|
|
||||||
|
r = nvs_open(NVS_BEREICH, NVS_READWRITE, &h);
|
||||||
|
if (r != ESP_OK) { if (fehler) *fehler = r; return false; }
|
||||||
|
|
||||||
|
r = nvs_set_str(h, NVS_SSID, ssid);
|
||||||
|
if (r == ESP_OK) r = nvs_set_str(h, NVS_PASS, passwort ? passwort : "");
|
||||||
|
if (r == ESP_OK) r = nvs_commit(h);
|
||||||
|
nvs_close(h);
|
||||||
|
|
||||||
|
if (r != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Speichern fehlgeschlagen: %s", esp_err_to_name(r));
|
||||||
|
if (fehler) *fehler = r;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zurueckgelesen statt geglaubt: Erst wenn es wirklich drinsteht,
|
||||||
|
// duerfen wir neu starten.
|
||||||
|
if (!netz_zugang_vorhanden()) {
|
||||||
|
ESP_LOGE(TAG, "Nach dem Speichern nichts gefunden");
|
||||||
|
if (fehler) *fehler = ESP_ERR_NVS_NOT_FOUND;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Zugangsdaten für '%s' gespeichert", ssid);
|
||||||
|
if (fehler) *fehler = ESP_OK;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void netz_zugang_loeschen(void)
|
||||||
|
{
|
||||||
|
nvs_handle_t h;
|
||||||
|
if (nvs_open(NVS_BEREICH, NVS_READWRITE, &h) != ESP_OK) return;
|
||||||
|
nvs_erase_key(h, NVS_SSID);
|
||||||
|
nvs_erase_key(h, NVS_PASS);
|
||||||
|
nvs_commit(h);
|
||||||
|
nvs_close(h);
|
||||||
|
ESP_LOGW(TAG, "Zugangsdaten gelöscht");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
|
static void ereignis(void *arg, esp_event_base_t basis, int32_t id, void *daten)
|
||||||
|
{
|
||||||
|
if (basis == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
|
||||||
|
esp_wifi_connect();
|
||||||
|
} else if (basis == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||||
|
wifi_event_sta_disconnected_t *e = (wifi_event_sta_disconnected_t *)daten;
|
||||||
|
s_ip[0] = '\0';
|
||||||
|
s_verbunden = false;
|
||||||
|
s_trennungen++;
|
||||||
|
s_letzter_grund = e->reason;
|
||||||
|
|
||||||
|
// Der Grund ist die halbe Miete: 15 und 204 heißen falsches
|
||||||
|
// Passwort, 201 „Netz nicht gefunden", 2/3/4 zeigen auf
|
||||||
|
// Verschlüsselung und Management-Frames. Ohne diese Zahl rät man.
|
||||||
|
ESP_LOGW(TAG, "getrennt (Grund %d), Abriss Nr. %d", e->reason, s_trennungen);
|
||||||
|
|
||||||
|
// Im Ereignis nicht schlafen — der Handler blockiert sonst die
|
||||||
|
// gesamte Ereignisschleife. Ein Rückfall kommt ohnehin gleich wieder.
|
||||||
|
if (s_dranbleiben) esp_wifi_connect();
|
||||||
|
} else if (basis == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
|
||||||
|
ip_event_got_ip_t *e = (ip_event_got_ip_t *)daten;
|
||||||
|
snprintf(s_ip, sizeof(s_ip), IPSTR, IP2STR(&e->ip_info.ip));
|
||||||
|
ESP_LOGI(TAG, "verbunden, IP %s", s_ip);
|
||||||
|
s_verbunden = true;
|
||||||
|
xEventGroupSetBits(s_ereignisse, VERBUNDEN);
|
||||||
|
} else if (basis == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
|
||||||
|
ESP_LOGI(TAG, "jemand hat sich mit %s verbunden", netz_setup_ssid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Start
|
||||||
|
|
||||||
|
void netz_init(void)
|
||||||
|
{
|
||||||
|
s_ereignisse = xEventGroupCreate();
|
||||||
|
s_ip[0] = '\0';
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_netif_init());
|
||||||
|
ESP_ERROR_CHECK(esp_event_loop_create_default());
|
||||||
|
|
||||||
|
// Beide Schnittstellen anlegen, aber nur eine wird eingeschaltet.
|
||||||
|
s_sta_netif = esp_netif_create_default_wifi_sta();
|
||||||
|
s_ap_netif = esp_netif_create_default_wifi_ap();
|
||||||
|
|
||||||
|
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||||
|
WIFI_EVENT, ESP_EVENT_ANY_ID, &ereignis, NULL, NULL));
|
||||||
|
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||||
|
IP_EVENT, IP_EVENT_STA_GOT_IP, &ereignis, NULL, NULL));
|
||||||
|
|
||||||
|
// Die Zugangsdaten liegen bei uns im NVS, nicht in der WLAN-Ablage des
|
||||||
|
// Treibers. Sonst hätten wir zwei Wahrheiten, die auseinanderlaufen.
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool netz_verbinden(void)
|
||||||
|
{
|
||||||
|
char ssid[NETZ_SSID_MAX + 1] = {0};
|
||||||
|
char passwort[NETZ_PASS_MAX + 1] = {0};
|
||||||
|
|
||||||
|
if (!zugang_lesen(ssid, passwort)) {
|
||||||
|
ESP_LOGW(TAG, "keine Zugangsdaten im NVS");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
wifi_config_t wcfg = {0};
|
||||||
|
strlcpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid));
|
||||||
|
strlcpy((char *)wcfg.sta.password, passwort, sizeof(wcfg.sta.password));
|
||||||
|
|
||||||
|
// Geschützte Management-Frames. Durch die Null-Initialisierung stünde
|
||||||
|
// `capable` sonst auf false — und ein Netz, das PMF verlangt, weist
|
||||||
|
// Fred dann ab, obwohl das Passwort stimmt. Genau das ist bei
|
||||||
|
// IoT-Netzen von UniFi und FritzBox die Voreinstellung.
|
||||||
|
wcfg.sta.pmf_cfg.capable = true;
|
||||||
|
wcfg.sta.pmf_cfg.required = false;
|
||||||
|
|
||||||
|
// Erlaubt auch WPA3-Netze (und WPA2/WPA3 gemischt). Ohne das scheitert
|
||||||
|
// die Anmeldung an neueren Zugangspunkten ohne erkennbaren Grund.
|
||||||
|
wcfg.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
|
||||||
|
|
||||||
|
// Kein Mindestniveau erzwingen: Was der Zugangspunkt anbietet, nehmen wir.
|
||||||
|
wcfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
|
||||||
|
|
||||||
|
s_dranbleiben = true;
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "verbinde mit '%s' (bis zu %d s Geduld)", ssid, START_GEDULD_MS / 1000);
|
||||||
|
|
||||||
|
EventBits_t bits = xEventGroupWaitBits(
|
||||||
|
s_ereignisse, VERBUNDEN, pdFALSE, pdFALSE, pdMS_TO_TICKS(START_GEDULD_MS));
|
||||||
|
|
||||||
|
if (bits & VERBUNDEN) return true;
|
||||||
|
|
||||||
|
// Erst jetzt aufgeben — und das Nachfassen einstellen, damit der
|
||||||
|
// Einrichtungsmodus nicht gegen einen weiterlaufenden Verbindungsversuch
|
||||||
|
// arbeitet.
|
||||||
|
s_dranbleiben = false;
|
||||||
|
ESP_LOGE(TAG, "'%s' in %d s nicht erreicht (zuletzt Grund %d, %d Abrisse)"
|
||||||
|
" — Einrichtung wird geöffnet",
|
||||||
|
ssid, START_GEDULD_MS / 1000, s_letzter_grund, s_trennungen);
|
||||||
|
esp_wifi_stop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void netz_setup_ap_start(void)
|
||||||
|
{
|
||||||
|
const char *name = netz_setup_ssid();
|
||||||
|
wifi_config_t wcfg = {
|
||||||
|
.ap = {
|
||||||
|
.channel = 1,
|
||||||
|
.max_connection = 4,
|
||||||
|
// Offen mit Absicht: Wer hier steht, soll ohne Zettel und ohne
|
||||||
|
// App hineinkommen. Zu holen gibt es nichts — das Formular
|
||||||
|
// nimmt nur entgegen, es gibt nichts preis.
|
||||||
|
.authmode = WIFI_AUTH_OPEN,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// APSTA statt reinem AP: Nur so lässt sich im Einrichtungsmodus nach
|
||||||
|
// Netzen suchen, und genau das braucht das Formular für seine Liste.
|
||||||
|
strlcpy((char *)wcfg.ap.ssid, name, sizeof(wcfg.ap.ssid));
|
||||||
|
wcfg.ap.ssid_len = strlen(name);
|
||||||
|
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wcfg));
|
||||||
|
ESP_ERROR_CHECK(esp_wifi_start());
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Einrichtung offen: '%s' → http://192.168.4.1/", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#define FUNDE_VORRAT 20
|
||||||
|
static netz_fund_t s_funde[FUNDE_VORRAT];
|
||||||
|
static int s_funde_anzahl = -1; // -1 = noch nie gesucht
|
||||||
|
|
||||||
|
void netz_funde_auffrischen(void)
|
||||||
|
{
|
||||||
|
s_funde_anzahl = netz_suchen(s_funde, FUNDE_VORRAT);
|
||||||
|
ESP_LOGI(TAG, "%d Netze gefunden (einmalig, vor dem ersten Besucher)",
|
||||||
|
s_funde_anzahl);
|
||||||
|
}
|
||||||
|
|
||||||
|
int netz_funde_holen(netz_fund_t *ziel, int max)
|
||||||
|
{
|
||||||
|
if (s_funde_anzahl < 0) netz_funde_auffrischen();
|
||||||
|
int n = s_funde_anzahl < max ? s_funde_anzahl : max;
|
||||||
|
for (int i = 0; i < n; i++) ziel[i] = s_funde[i];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
int netz_suchen(netz_fund_t *ziel, int max)
|
||||||
|
{
|
||||||
|
wifi_scan_config_t cfg = { .show_hidden = false };
|
||||||
|
if (esp_wifi_scan_start(&cfg, true) != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Suche fehlgeschlagen");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t anzahl = 0;
|
||||||
|
esp_wifi_scan_get_ap_num(&anzahl);
|
||||||
|
if (anzahl == 0) return 0;
|
||||||
|
|
||||||
|
wifi_ap_record_t *roh = calloc(anzahl, sizeof(wifi_ap_record_t));
|
||||||
|
if (!roh) return 0;
|
||||||
|
esp_wifi_scan_get_ap_records(&anzahl, roh);
|
||||||
|
|
||||||
|
// Die Liste kommt nach Feldstärke sortiert. Deshalb genügt es, jede
|
||||||
|
// SSID beim ersten Auftreten zu übernehmen — Mesh-Netze tauchen sonst
|
||||||
|
// fünfmal auf und das Formular wird unlesbar.
|
||||||
|
int n = 0;
|
||||||
|
for (int i = 0; i < anzahl && n < max; i++) {
|
||||||
|
const char *ssid = (const char *)roh[i].ssid;
|
||||||
|
if (ssid[0] == '\0') continue;
|
||||||
|
|
||||||
|
bool schon_da = false;
|
||||||
|
for (int k = 0; k < n; k++) {
|
||||||
|
if (strcmp(ziel[k].ssid, ssid) == 0) { schon_da = true; break; }
|
||||||
|
}
|
||||||
|
if (schon_da) continue;
|
||||||
|
|
||||||
|
strlcpy(ziel[n].ssid, ssid, sizeof(ziel[n].ssid));
|
||||||
|
ziel[n].rssi = roh[i].rssi;
|
||||||
|
ziel[n].verschluesselt = roh[i].authmode != WIFI_AUTH_OPEN;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
free(roh);
|
||||||
|
ESP_LOGI(TAG, "%d Netze gefunden", n);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
void zeit_holen(void)
|
||||||
|
{
|
||||||
|
if (esp_sntp_enabled()) return;
|
||||||
|
|
||||||
|
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||||
|
esp_sntp_setservername(0, "pool.ntp.org");
|
||||||
|
esp_sntp_init();
|
||||||
|
|
||||||
|
// Mitteleuropa mit Sommerzeit. Die Regel steht in der Zeichenkette,
|
||||||
|
// deshalb stimmt die Umstellung auch ohne Netz — sobald die Uhr
|
||||||
|
// einmal gestellt ist.
|
||||||
|
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
|
||||||
|
tzset();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Uhrzeit wird geholt");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool zeit_steht(void)
|
||||||
|
{
|
||||||
|
time_t jetzt = time(NULL);
|
||||||
|
struct tm z;
|
||||||
|
localtime_r(&jetzt, &z);
|
||||||
|
// Vor der ersten Antwort steht die Uhr auf 1970.
|
||||||
|
return z.tm_year > 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
int netz_feldstaerke(void)
|
||||||
|
{
|
||||||
|
// Wie gut Fred den Zugangspunkt hoert, in dBm. Ueber -60 ist gut,
|
||||||
|
// unter -75 wird es unzuverlaessig — und Unzuverlaessigkeit ist
|
||||||
|
// genau das, was eine stockende Tonuebertragung erklaeren wuerde.
|
||||||
|
wifi_ap_record_t ap;
|
||||||
|
if (esp_wifi_sta_get_ap_info(&ap) != ESP_OK) return 0;
|
||||||
|
return ap.rssi;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *netz_ip(void)
|
||||||
|
{
|
||||||
|
return s_ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
int netz_trennungen(void)
|
||||||
|
{
|
||||||
|
return s_trennungen;
|
||||||
|
}
|
||||||
|
|
||||||
|
int netz_letzter_grund(void)
|
||||||
|
{
|
||||||
|
return s_letzter_grund;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool netz_verbunden(void)
|
||||||
|
{
|
||||||
|
return s_verbunden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// WLAN: Zugangsdaten im NVS, Verbindung als Station, Einrichtung als
|
||||||
|
// Zugangspunkt. Wer die Daten hat, verbindet sich; wer keine hat, macht
|
||||||
|
// sein eigenes Netz auf.
|
||||||
|
//
|
||||||
|
// Uebernommen aus Hey Fred (firmware/main/netz.c) am 23.09.2026. Dort ist
|
||||||
|
// es erprobt; hier geaendert wurden der NVS-Bereich und der Name des
|
||||||
|
// Einrichtungsnetzes.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "esp_err.h"
|
||||||
|
|
||||||
|
#define NETZ_SSID_MAX 32
|
||||||
|
#define NETZ_PASS_MAX 64
|
||||||
|
|
||||||
|
// Name des Zugangspunkts zur Einrichtung. ⚠ Anders als bei Fred NICHT
|
||||||
|
// fest: Die letzten beiden Bytes der MAC haengen hinten dran, also etwa
|
||||||
|
// "Vogelkamera-A4F2". Zwei Kameras im selben Garten -- Futterhaus und
|
||||||
|
// Traenke -- wuerden sich sonst denselben Namen teilen, und man wuesste
|
||||||
|
// beim Einrichten nicht, welche man gerade vor sich hat.
|
||||||
|
#define NETZ_SETUP_PRAEFIX "Vogelkamera"
|
||||||
|
|
||||||
|
// Der tatsaechliche Name, erst zur Laufzeit bekannt.
|
||||||
|
const char *netz_setup_ssid(void);
|
||||||
|
|
||||||
|
// Das Geheimnis fuer Updates ueber WLAN.
|
||||||
|
//
|
||||||
|
// ⚠ Bewusst NICHT einkompiliert (so macht es Hey Fred in secrets.h). Fuer
|
||||||
|
// ein Geraet, das als Bausatz weitergegeben wird, waere das derselbe
|
||||||
|
// Fehler wie eingebackene WLAN-Daten: Jeder muesste die Firmware selbst
|
||||||
|
// uebersetzen, und alle Geraete haetten dasselbe Geheimnis.
|
||||||
|
//
|
||||||
|
// Stattdessen: Beim ersten Start wuerfelt sich jedes Geraet sein eigenes
|
||||||
|
// und legt es im NVS ab. Zu sehen bekommt man es im Einrichtungsnetz --
|
||||||
|
// wer dort steht, steht ohnehin vor dem Geraet.
|
||||||
|
const char *netz_ota_token(void);
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char ssid[NETZ_SSID_MAX + 1];
|
||||||
|
int8_t rssi;
|
||||||
|
bool verschluesselt;
|
||||||
|
} netz_fund_t;
|
||||||
|
|
||||||
|
// Einmal beim Start aufrufen, vor allem anderen.
|
||||||
|
void netz_init(void);
|
||||||
|
|
||||||
|
// Liegen Zugangsdaten im NVS?
|
||||||
|
bool netz_zugang_vorhanden(void);
|
||||||
|
|
||||||
|
// Der gespeicherte Netzname, oder "" wenn keiner da ist. Fuer die Anzeige
|
||||||
|
// auf der Einrichtungsseite: Erst damit sieht man, ob das Speichern
|
||||||
|
// ueberhaupt ankommt -- ohne Kabel, ohne Raten.
|
||||||
|
const char *netz_gespeicherte_ssid(void);
|
||||||
|
|
||||||
|
// Gibt false zurueck, wenn das Schreiben scheitert -- der Grund steht dann
|
||||||
|
// in *fehler. ⚠ Frueher brach das Geraet bei einem Fehler einfach ab
|
||||||
|
// (ESP_ERROR_CHECK): Es startete mitten im Absenden neu, der Browser blieb
|
||||||
|
// haengen, nichts war gespeichert, und niemand erfuhr warum.
|
||||||
|
bool netz_zugang_speichern(const char *ssid, const char *passwort,
|
||||||
|
esp_err_t *fehler);
|
||||||
|
void netz_zugang_loeschen(void);
|
||||||
|
|
||||||
|
// Verbindet mit den gespeicherten Daten. Blockiert bis zum Erfolg oder bis
|
||||||
|
// die Versuche aufgebraucht sind. Gibt false zurück, wenn es nicht klappt —
|
||||||
|
// dann gehört Fred in den Einrichtungsmodus.
|
||||||
|
bool netz_verbinden(void);
|
||||||
|
|
||||||
|
// Öffnet den Zugangspunkt für die Einrichtung. Danach ist Fred unter
|
||||||
|
// 192.168.4.1 erreichbar.
|
||||||
|
void netz_setup_ap_start(void);
|
||||||
|
|
||||||
|
// Sucht Netze in der Umgebung. Schreibt bis `max` Funde nach `ziel` und gibt
|
||||||
|
// die Anzahl zurück. Doppelte SSIDs werden übersprungen, der stärkste
|
||||||
|
// Fund gewinnt.
|
||||||
|
int netz_suchen(netz_fund_t *ziel, int max);
|
||||||
|
|
||||||
|
// Sucht EINMAL und merkt sich das Ergebnis.
|
||||||
|
//
|
||||||
|
// ⚠ Der Grund ist unangenehm und kostet sonst Stunden: Eine WLAN-Suche
|
||||||
|
// wechselt den Funkkanal. Laeuft gleichzeitig der eigene Zugangspunkt,
|
||||||
|
// fliegen die verbundenen Geraete dabei raus. Sucht man also, waehrend
|
||||||
|
// jemand das Formular betrachtet, verliert genau dieser Jemand die
|
||||||
|
// Verbindung -- und sein Absenden kommt nie an. Am 23.09.2026 genau so
|
||||||
|
// passiert, an Handy wie an Mac.
|
||||||
|
//
|
||||||
|
// Deshalb: einmal suchen, bevor der erste Besucher da ist, und danach
|
||||||
|
// nur noch die Liste ausliefern.
|
||||||
|
int netz_funde_holen(netz_fund_t *ziel, int max);
|
||||||
|
void netz_funde_auffrischen(void);
|
||||||
|
|
||||||
|
// IP als Zeichenkette, leer wenn nicht verbunden.
|
||||||
|
const char *netz_ip(void);
|
||||||
|
|
||||||
|
// Wie oft die Verbindung seit dem Start abgerissen ist, und warum zuletzt.
|
||||||
|
// Der Grund ist der Code des WLAN-Treibers: 15 und 204 stehen für ein
|
||||||
|
// falsches Passwort, 201 für „Netz nicht gefunden", 2 bis 4 zeigen auf
|
||||||
|
// Verschlüsselung und Management-Frames.
|
||||||
|
int netz_trennungen(void);
|
||||||
|
int netz_letzter_grund(void);
|
||||||
|
|
||||||
|
// Läuft die Verbindung gerade?
|
||||||
|
bool netz_verbunden(void);
|
||||||
|
|
||||||
|
// Holt die Uhrzeit per SNTP und stellt sie auf deutsche Zeit. Blockiert
|
||||||
|
// nicht — das Ergebnis trudelt im Hintergrund ein.
|
||||||
|
void zeit_holen(void);
|
||||||
|
|
||||||
|
// Steht die Uhr schon auf einer plausiblen Zeit?
|
||||||
|
bool zeit_steht(void);
|
||||||
|
|
||||||
|
// Empfangsstaerke zum Zugangspunkt in dBm (negativ; naeher an null ist
|
||||||
|
// besser). 0 heisst: nicht ermittelbar.
|
||||||
|
int netz_feldstaerke(void);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Firmware über Funk aufspielen. Kein Kabel, kein Aufschrauben.
|
||||||
|
#
|
||||||
|
# ./ota.sh vogelkamera.local <schluessel>
|
||||||
|
#
|
||||||
|
# Den Schlüssel zeigt das Gerät beim Start auf der seriellen Leitung
|
||||||
|
# ("Schluessel fuer Updates: …") und die Einrichtungsseite. Er bleibt über
|
||||||
|
# Neustarts und Updates hinweg derselbe -- er liegt im NVS.
|
||||||
|
#
|
||||||
|
# ⚠ --limit-rate ist KEIN Schönheitsfehler, sondern Bedingung. Ohne
|
||||||
|
# Drosselung kommt der Chip mit dem Schreiben in den Flash nicht
|
||||||
|
# hinterher, hört auf vom Socket zu lesen, und beide Seiten warten
|
||||||
|
# aufeinander, bis jemand aufgibt. Am 24.09.2026 hat genau das zwei
|
||||||
|
# Anläufe gekostet, bevor klar war, dass es nicht am Endpunkt liegt.
|
||||||
|
#
|
||||||
|
# ⚠ Der Detector muss weg. Der Webserver auf der Kamera hat einen
|
||||||
|
# einzigen Arbeiter, und der Stream gibt ihn nie zurück:
|
||||||
|
#
|
||||||
|
# ssh root@erkennung.local systemctl stop vogelalbum-detector@traenke
|
||||||
|
# ./ota.sh …
|
||||||
|
# ssh root@erkennung.local systemctl start vogelalbum-detector@traenke
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ZIEL="$1"
|
||||||
|
SCHLUESSEL="$2"
|
||||||
|
ABBILD="${3:-build/vogelkamera.bin}"
|
||||||
|
|
||||||
|
if [ -z "$ZIEL" ] || [ -z "$SCHLUESSEL" ]; then
|
||||||
|
echo "Aufruf: $0 <adresse> <schluessel> [abbild]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -f "$ABBILD" ]; then
|
||||||
|
echo "Kein Abbild unter $ABBILD -- erst 'idf.py build'." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Vorher:"
|
||||||
|
curl -s -m 5 "http://$ZIEL:8081/status" || echo " (keine Antwort)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Spiele $(wc -c < "$ABBILD") Bytes auf $ZIEL auf …"
|
||||||
|
curl --fail --max-time 300 --limit-rate 150k \
|
||||||
|
-w '\n HTTP %{http_code} %{size_upload} Bytes in %{time_total}s\n' \
|
||||||
|
-X POST \
|
||||||
|
-H "Expect:" \
|
||||||
|
-H "X-Vogelkamera-Token: $SCHLUESSEL" \
|
||||||
|
--data-binary "@$ABBILD" \
|
||||||
|
"http://$ZIEL:8080/update"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Warte auf den Neustart …"
|
||||||
|
sleep 12
|
||||||
|
echo "Nachher:"
|
||||||
|
curl -s -m 8 "http://$ZIEL:8081/status" || echo " (noch nicht zurück -- gleich nochmal schauen)"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Zwei App-Bereiche, damit sich das Geraet ueber WLAN aktualisieren kann.
|
||||||
|
#
|
||||||
|
# 🔑 Grundsatz in diesem Projekt: JEDES Geraet muss OTA koennen. Diese Tabelle ist die
|
||||||
|
# Voraussetzung dafuer, und sie gehoert von Anfang an so angelegt -- sie
|
||||||
|
# nachtraeglich zu aendern kostet genau das, was OTA vermeiden soll: einen
|
||||||
|
# Gang zum Geraet mit dem Kabel. (Am 23.09.2026 genau deshalb nachgeholt.)
|
||||||
|
#
|
||||||
|
# Bei 16 MB Flash ist das muehelos: zwei mal 4 MB App, der Rest bleibt frei.
|
||||||
|
# Das Abbild liegt bei knapp 1 MB, also ist reichlich Luft.
|
||||||
|
#
|
||||||
|
# Name, Type, SubType, Offset, Size
|
||||||
|
nvs, data, nvs, 0x9000, 0x6000
|
||||||
|
otadata, data, ota, 0xf000, 0x2000
|
||||||
|
phy_init, data, phy, 0x11000, 0x1000
|
||||||
|
ota_0, app, ota_0, 0x20000, 4M
|
||||||
|
ota_1, app, ota_1, , 4M
|
||||||
|
@@ -0,0 +1,60 @@
|
|||||||
|
# Ziel ist der ESP32-S3 mit 8 MB PSRAM (N16R8-Module).
|
||||||
|
CONFIG_IDF_TARGET="esp32s3"
|
||||||
|
|
||||||
|
# Ohne PSRAM passt kein JPEG-Framebuffer in den Speicher. Octal ist bei den
|
||||||
|
# R8-Modulen richtig; bei einem R2/R8-Quad-Modul stattdessen QUAD waehlen,
|
||||||
|
# sonst findet die Kamera keinen Puffer.
|
||||||
|
CONFIG_SPIRAM=y
|
||||||
|
CONFIG_SPIRAM_MODE_OCT=y
|
||||||
|
CONFIG_SPIRAM_SPEED_80M=y
|
||||||
|
CONFIG_SPIRAM_USE_MALLOC=y
|
||||||
|
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
|
||||||
|
|
||||||
|
# 16 MB Flash (N16R8).
|
||||||
|
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||||
|
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||||
|
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||||
|
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
|
||||||
|
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
|
||||||
|
|
||||||
|
# Die Funkstrecke ist der Engpass, nicht die CPU: mehr und groessere
|
||||||
|
# Sendepuffer heben die Bildrate deutlich mehr als ein schnellerer Takt.
|
||||||
|
CONFIG_ESP_WIFI_STATIC_TX_BUFFER_NUM=16
|
||||||
|
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
|
||||||
|
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
|
||||||
|
CONFIG_ESP_WIFI_TX_BA_WIN=16
|
||||||
|
CONFIG_ESP_WIFI_RX_BA_WIN=16
|
||||||
|
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=65534
|
||||||
|
CONFIG_LWIP_TCP_WND_DEFAULT=65534
|
||||||
|
|
||||||
|
# Volle Taktrate — JPEG kommt fertig aus dem Sensor, aber das Umkopieren in
|
||||||
|
# die Sendepuffer laeuft auf der CPU.
|
||||||
|
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
|
||||||
|
|
||||||
|
# Der Stream haelt einen Socket dauerhaft offen; /status braucht daneben Platz.
|
||||||
|
CONFIG_LWIP_MAX_SOCKETS=10
|
||||||
|
|
||||||
|
# Ein Geraet im Garten soll sich selbst erholen statt stumm zu bleiben.
|
||||||
|
CONFIG_ESP_TASK_WDT_EN=y
|
||||||
|
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||||
|
CONFIG_ESP_INT_WDT=y
|
||||||
|
|
||||||
|
# Kameraframes gehoeren ins PSRAM, nicht ins knappe interne RAM.
|
||||||
|
CONFIG_CAMERA_CORE0=y
|
||||||
|
|
||||||
|
# Kamera haengt ueber Kopf: beide Spiegelungen zusammen ergeben 180 Grad.
|
||||||
|
# ⚠ Gilt fuer JEDES Geraet aus diesem Baum -- wer eine Kamera anders
|
||||||
|
# herum montiert, braucht hier einen eigenen Stand. Gemeldet am
|
||||||
|
# 24.09.2026, nachdem die Traenke haengt: "Bild muss um 180 grad gedreht
|
||||||
|
# werden."
|
||||||
|
# CONFIG_VK_HFLIP is not set
|
||||||
|
CONFIG_VK_VFLIP=y
|
||||||
|
|
||||||
|
# Farbe: der Sensor liefert von Haus aus ein flaues Bild.
|
||||||
|
CONFIG_VK_SAETTIGUNG=2
|
||||||
|
CONFIG_VK_KONTRAST=1
|
||||||
|
CONFIG_VK_HELLIGKEIT=0
|
||||||
|
|
||||||
|
# Bremse fuer den Bildstrom. Ohne sie sendet die Kamera mit Anschlag --
|
||||||
|
# das heizt und saettigt die Funkstrecke, ohne der Erkennung zu nuetzen.
|
||||||
|
CONFIG_VK_MAX_FPS=5
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Vogelalbum in Home Assistant
|
||||||
|
|
||||||
|
Zwei Teile:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/zustand/ in der Web-App — alles in einer Antwort
|
||||||
|
bruecke.py holt das und spiegelt es nach MQTT
|
||||||
|
```
|
||||||
|
|
||||||
|
Home Assistant braucht **kein Integrationspaket**: Die Brücke meldet die
|
||||||
|
Entitäten über MQTT-Discovery an, HA legt sie von selbst an.
|
||||||
|
|
||||||
|
## Voraussetzung: ein Broker
|
||||||
|
|
||||||
|
⚠️ **Ohne MQTT-Broker geht nichts.** In Home Assistant:
|
||||||
|
|
||||||
|
```
|
||||||
|
Einstellungen → Add-ons → Add-on Store → "Mosquitto broker"
|
||||||
|
→ Installieren → Starten
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann einen Benutzer anlegen (Mosquitto nimmt HA-Benutzer als
|
||||||
|
Zugangsdaten, es hat kein eigenes Benutzersystem):
|
||||||
|
|
||||||
|
```
|
||||||
|
Einstellungen → Personen → Benutzer → Hinzufügen
|
||||||
|
⚠ "Kann sich anmelden" an, Administrator AUS
|
||||||
|
```
|
||||||
|
|
||||||
|
🔑 **Dass es einen Broker braucht, ist der schwache Punkt dieses Weges.**
|
||||||
|
Wer Vogelalbum verteilt,
|
||||||
|
muss damit rechnen, dass viele Nutzer ebenfalls keinen haben. Deshalb ist
|
||||||
|
MQTT der Weg zum Ausprobieren, nicht der endgültige; eine Integration über
|
||||||
|
HACS käme ohne aus. Siehe Issue #4.
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VOGELALBUM_URL=http://sammlung.local:8000 \
|
||||||
|
VOGELALBUM_LESE_TOKEN=… \
|
||||||
|
MQTT_HOST=… MQTT_BENUTZER=vogelalbum MQTT_PASSWORT=… \
|
||||||
|
python homeassistant/bruecke.py
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Vorgabe | wofür |
|
||||||
|
|---|---|---|
|
||||||
|
| `VOGELALBUM_URL` | `http://127.0.0.1:8130` | wo die Web-App steht |
|
||||||
|
| `VOGELALBUM_LESE_TOKEN` | — | **Pflicht.** Leer heißt: Tür zu |
|
||||||
|
| `VOGELALBUM_INSTANZ` | `garten` | steckt in jeder Kennung |
|
||||||
|
| `VOGELALBUM_TAKT` | `10` | Sekunden zwischen zwei Abfragen |
|
||||||
|
| `MQTT_HOST` / `MQTT_PORT` | `127.0.0.1` / `1883` | der Broker |
|
||||||
|
| `MQTT_BENUTZER` / `MQTT_PASSWORT` | leer | falls der Broker sie verlangt |
|
||||||
|
| `VOGELALBUM_LIVE_<KENNUNG>` | leer | Livebild direkt vom Verteiler holen |
|
||||||
|
|
||||||
|
## Livebild, wenn die Web-App außer Haus steht
|
||||||
|
|
||||||
|
🔑 **Ein Riegel gegen die Öffentlichkeit darf nicht das eigene Haus
|
||||||
|
aussperren.** Steht die Web-App im Netz, gehört dort
|
||||||
|
`VOGELALBUM_LIVE_ERLAUBT=0` hin — ein Bild aus dem eigenen Garten ist nichts
|
||||||
|
für Fremde. Dann liefert `/api/zustand/` aber `bild_url: null`, und die
|
||||||
|
Brücke bekommt ebenfalls nichts, obwohl sie **im Heimnetz** läuft und den
|
||||||
|
Live-Verteiler des Detectors direkt erreicht.
|
||||||
|
|
||||||
|
Deshalb darf die Brücke die Adresse selbst kennen, je Kamera:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VOGELALBUM_LIVE_VOGELHAUS=http://erkennung.local:8131/bild.jpg
|
||||||
|
VOGELALBUM_LIVE_TRAENKE=http://erkennung.local:8132/bild.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Name entsteht aus der **Kennung** der Kamera in Großbuchstaben; alles,
|
||||||
|
was kein Buchstabe und keine Ziffer ist, wird zum Unterstrich
|
||||||
|
(`vogelhaus-2` → `VOGELALBUM_LIVE_VOGELHAUS_2`). Eine Adresse auf `/live`
|
||||||
|
wird selbst auf `/bild.jpg` umgeschrieben — `/live` ist der endlose
|
||||||
|
MJPEG-Strom, den darf die Brücke nicht lesen. Ohne Eintrag bleibt alles wie
|
||||||
|
bisher: Die Adresse kommt aus der Schnittstelle.
|
||||||
|
|
||||||
|
⚠️ **So sieht dieser Ausfall aus, wenn man ihn nicht kennt:** In Home
|
||||||
|
Assistant bleibt das zuletzt empfangene Bild stehen und sieht aus wie ein
|
||||||
|
Livebild. Es gibt keine Fehlermeldung — weder in der Brücke noch in HA.
|
||||||
|
Eine Anlage lief so acht Tage lang, ohne dass es jemandem auffiel.
|
||||||
|
|
||||||
|
## Was rauskommt
|
||||||
|
|
||||||
|
26 Entitäten bei zwei Kameras, **11 davon standardmäßig abgeschaltet**.
|
||||||
|
Die Linie ist *„möglichst viel anbieten, entscheiden soll der Nutzer"* — `enabled_by_default: false` ist genau dafür da.
|
||||||
|
|
||||||
|
**Sammlung:** letzte Art · Arten gesammelt · Sichtungen heute und gesamt ·
|
||||||
|
Stufe · Rang · Punkte · Abzeichen · letztes Abzeichen · letzte Sichtung
|
||||||
|
(mit allen Angaben als Attribute)
|
||||||
|
|
||||||
|
**Je Kamera:** erreichbar · still seit · letzte Sichtung · Signalstärke ·
|
||||||
|
Chiptemperatur · Bildrate · Laufzeit · Livebild
|
||||||
|
|
||||||
|
## Drei Dinge, die man wissen muss
|
||||||
|
|
||||||
|
⚠️ **Die Kennungen sind der Vertrag.** Anzeigenamen darf jeder ändern, die
|
||||||
|
`unique_id` nicht. Wer sie nachträglich ändert, bekommt in HA eine NEUE
|
||||||
|
Entität, während die alte als Leiche im Register bleibt — mitsamt den
|
||||||
|
Automationen, die daran hängen.
|
||||||
|
|
||||||
|
⚠️ **Das Livebild kommt vom DETECTOR, nie von der Kamera.** Die Kamera
|
||||||
|
erlaubt genau einen Zuschauer; klinkt sich HA dort ein, verliert der
|
||||||
|
Detector sein Bild und die Arterkennung steht. Das Symptom wäre *„seit ich
|
||||||
|
das Dashboard gebaut habe, erkennt er nichts mehr"* — mit der Ursache
|
||||||
|
woanders.
|
||||||
|
|
||||||
|
⚠️ **Signalstärke und Temperatur holt die Brücke direkt vom Gerät**, nicht
|
||||||
|
über die Web-App. Sie kennt nur das Gerät selbst; über die Datenbank zu
|
||||||
|
gehen hieße, sie erst zu speichern, und dann wären sie alt.
|
||||||
|
|
||||||
|
Der Port dafür wird **probiert und gemerkt**, nicht geraten: erst
|
||||||
|
Streamport + 1 (der ESP32 hat einen eigenen Diagnose-Port, weil sein
|
||||||
|
Webserver einen einzigen Arbeiter hat), dann der Streamport (der Pi hat das
|
||||||
|
Problem nicht).
|
||||||
|
|
||||||
|
📌 **Erledigt:** Der Pi am Futterhaus antwortete am 25.09.2026 noch mit 404
|
||||||
|
auf `/status` — dort lief ältere Software ohne den Endpunkt. Seit dem
|
||||||
|
Rollout am 09.10.2026 liefert er ihn; am 11.10.2026 nachgemessen
|
||||||
|
(4,63 B/s, RSSI −51 dBm, 39 °C). Signalstärke und Temperatur stehen damit
|
||||||
|
für **beide** Kameras in Home Assistant.
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Brücke von Vogelalbum nach Home Assistant, über MQTT.
|
||||||
|
|
||||||
|
Holt den Zustand aus ``GET /api/zustand/`` und veröffentlicht ihn als
|
||||||
|
MQTT-Discovery. Home Assistant legt die Entitäten daraufhin von selbst an —
|
||||||
|
es braucht dort **kein** Integrationspaket.
|
||||||
|
|
||||||
|
VOGELALBUM_URL=http://sammlung.local:8000 \\
|
||||||
|
VOGELALBUM_LESE_TOKEN=… MQTT_HOST=… MQTT_PASSWORT=… \\
|
||||||
|
python bruecke.py
|
||||||
|
|
||||||
|
🔑 **Warum über die Schnittstelle und nicht an die Datenbank:** Die Brücke
|
||||||
|
ist damit zugleich der Beweis, dass die Schnittstelle taugt — dieselbe
|
||||||
|
braucht später eine HACS-Integration. Eine Brücke mit
|
||||||
|
Datenbankzugriff hätte den Beweis umgangen und die Schnittstelle
|
||||||
|
ungetestet gelassen.
|
||||||
|
|
||||||
|
⚠️ **Die Kennungen sind der Vertrag.** Anzeigenamen darf jeder ändern; die
|
||||||
|
`unique_id` nicht. Wer sie nachträglich ändert, bekommt in Home Assistant
|
||||||
|
eine NEUE Entität, während die alte als Leiche im Register stehen bleibt —
|
||||||
|
mitsamt den Automationen, die daran hängen.
|
||||||
|
|
||||||
|
⚠️ **Das Livebild kommt vom DETECTOR, nicht von der Kamera.** Die Kamera
|
||||||
|
erlaubt genau einen Zuschauer. Klinkt sich Home Assistant dort ein,
|
||||||
|
verliert der Detector sein Bild und die Arterkennung steht still — mit
|
||||||
|
einer Ursache weit weg vom Symptom.
|
||||||
|
|
||||||
|
🔑 **Steht die Web-App außer Haus, kennt die Brücke die Adresse des
|
||||||
|
Verteilers selbst** — ``VOGELALBUM_LIVE_<KENNUNG>``, siehe
|
||||||
|
:func:`live_adresse`. Sonst sperrt ``VOGELALBUM_LIVE_ERLAUBT=0`` (richtig
|
||||||
|
gegen die Öffentlichkeit) auch das eigene Haus aus.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
# --- Stellschrauben --------------------------------------------------
|
||||||
|
|
||||||
|
# Diese Brücke läuft als einzelne Datei auf einem dritten Rechner, ohne das
|
||||||
|
# übrige Projekt. Deshalb steht diese kleine Funktion hier noch einmal — siehe
|
||||||
|
# die Begründung in detector/umgebung.py.
|
||||||
|
def umgebung(name, default=""):
|
||||||
|
"""Liest VOGELALBUM_<name>.
|
||||||
|
|
||||||
|
⚠️ Ein ausdruecklich leerer Wert schlaegt den Vorgabewert — ein leerer
|
||||||
|
Lesetoken heisst "Endpunkt zu", nicht "nimm den Standard". Dieselbe Regel
|
||||||
|
wie in detector/umgebung.py.
|
||||||
|
"""
|
||||||
|
wert = os.environ.get(f"VOGELALBUM_{name}")
|
||||||
|
if wert is None:
|
||||||
|
return default
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
|
VOGELALBUM_URL = umgebung("URL", "http://127.0.0.1:8130").rstrip("/")
|
||||||
|
LESE_TOKEN = umgebung("LESE_TOKEN", "")
|
||||||
|
|
||||||
|
MQTT_HOST = os.environ.get("MQTT_HOST", "127.0.0.1")
|
||||||
|
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||||
|
MQTT_BENUTZER = os.environ.get("MQTT_BENUTZER", "")
|
||||||
|
MQTT_PASSWORT = os.environ.get("MQTT_PASSWORT", "")
|
||||||
|
|
||||||
|
# Wie oft gefragt wird. Zehn Sekunden sind ein Kompromiss: Haeufiger belastet
|
||||||
|
# die Web-App, ohne dass jemand schneller einen Vogel sieht -- eine Sichtung
|
||||||
|
# dauert ohnehin Sekunden bis Minuten.
|
||||||
|
TAKT = float(umgebung("TAKT", "10"))
|
||||||
|
|
||||||
|
# Vorsilbe der MQTT-Themen. 'homeassistant' ist die Vorgabe, unter der HA
|
||||||
|
# nach Discovery-Nachrichten sucht.
|
||||||
|
DISCOVERY = os.environ.get("MQTT_DISCOVERY_PRAEFIX", "homeassistant")
|
||||||
|
|
||||||
|
# ⚠️ Wer eine bestehende Anlage umstellt, sollte MQTT_BASIS auf den alten Wert
|
||||||
|
# setzen, BEVOR er diese Fassung einspielt. Sonst legt Home Assistant alle
|
||||||
|
# Entitaeten unter neuen Namen an, und die bestehenden Dashboard-Karten zeigen
|
||||||
|
# ins Leere. Beim Umstellen danach die alten retained Topics mit abraeumen.
|
||||||
|
BASIS = os.environ.get("MQTT_BASIS", "vogelalbum")
|
||||||
|
|
||||||
|
# 🔑 Eine Anlage kann mehrere Vogelalbum-Instanzen haben (Garten, Ferienhaus).
|
||||||
|
# Die Kennung steckt deshalb in jeder unique_id.
|
||||||
|
INSTANZ = umgebung("INSTANZ", "garten")
|
||||||
|
|
||||||
|
ZEITGRENZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
def sagen(text: str) -> None:
|
||||||
|
print(text, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Vogelalbum fragen --------------------------------------------------
|
||||||
|
|
||||||
|
def zustand_holen() -> dict | None:
|
||||||
|
anfrage = urllib.request.Request(
|
||||||
|
f"{VOGELALBUM_URL}/api/zustand/",
|
||||||
|
headers={"Authorization": f"Bearer {LESE_TOKEN}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort:
|
||||||
|
return json.load(antwort)
|
||||||
|
except urllib.error.HTTPError as fehler:
|
||||||
|
sagen(f"Vogelalbum antwortet mit {fehler.code} — Token richtig?")
|
||||||
|
except (urllib.error.URLError, OSError, json.JSONDecodeError,
|
||||||
|
TimeoutError) as fehler:
|
||||||
|
sagen(f"Vogelalbum nicht erreicht: {fehler}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Welcher Port beim jeweiligen Gerät funktioniert hat. Einmal herausfinden,
|
||||||
|
# dann merken -- sonst klopft die Brücke alle zehn Sekunden zweimal an.
|
||||||
|
_statusport: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def geraetestatus_holen(geraet_url: str | None) -> dict | None:
|
||||||
|
"""Signalstärke, Temperatur, Bildrate — direkt vom Gerät.
|
||||||
|
|
||||||
|
⚠️ Diese Werte stehen NICHT in Vogelalbum, und das ist Absicht: Sie kennt
|
||||||
|
nur das Gerät selbst. Über die Datenbank zu gehen hieße, sie erst zu
|
||||||
|
speichern — dann wären sie alt.
|
||||||
|
|
||||||
|
🔑 **Zwei Ports, weil zwei Gerätearten.** Der ESP32 beantwortet
|
||||||
|
``/status`` auf einem eigenen Diagnose-Port (Streamport + 1): Sein
|
||||||
|
Webserver hat einen einzigen Arbeiter, und der Bildstrom gibt ihn nie
|
||||||
|
zurück. Der Pi hat das Problem nicht und antwortet auf dem Streamport.
|
||||||
|
|
||||||
|
Deshalb wird nicht geraten, sondern **probiert und gemerkt**: erst der
|
||||||
|
Diagnose-Port, dann der Streamport. Was antwortet, gilt.
|
||||||
|
"""
|
||||||
|
if not geraet_url:
|
||||||
|
return None
|
||||||
|
teile = urllib.parse.urlsplit(geraet_url)
|
||||||
|
if not teile.hostname:
|
||||||
|
return None
|
||||||
|
|
||||||
|
port = teile.port or 80
|
||||||
|
kandidaten = _statusport.get(geraet_url) or None
|
||||||
|
versuche = ([kandidaten] if kandidaten else
|
||||||
|
[f"{teile.scheme}://{teile.hostname}:{port + 1}/status",
|
||||||
|
f"{teile.scheme}://{teile.hostname}:{port}/status"])
|
||||||
|
|
||||||
|
for adresse in versuche:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(adresse, timeout=5) as antwort:
|
||||||
|
daten = json.load(antwort)
|
||||||
|
except (urllib.error.URLError, OSError, json.JSONDecodeError,
|
||||||
|
TimeoutError, ValueError):
|
||||||
|
continue
|
||||||
|
if isinstance(daten, dict):
|
||||||
|
_statusport[geraet_url] = adresse
|
||||||
|
return daten
|
||||||
|
|
||||||
|
# ⚠️ Merker loeschen: Vielleicht war das Geraet nur kurz weg, vielleicht
|
||||||
|
# wurde es getauscht. Beim naechsten Mal wieder beide probieren.
|
||||||
|
_statusport.pop(geraet_url, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bild_holen(bild_url: str | None) -> bytes | None:
|
||||||
|
"""Das Einzelbild vom DETECTOR — siehe Modulkopf, niemals von der Kamera."""
|
||||||
|
if not bild_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(bild_url, timeout=ZEITGRENZE) as antwort:
|
||||||
|
if antwort.status != 200:
|
||||||
|
return None
|
||||||
|
return antwort.read()
|
||||||
|
except (urllib.error.URLError, OSError, TimeoutError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def live_adresse(kamera: dict) -> str:
|
||||||
|
"""Woher das Livebild dieser Kamera kommt.
|
||||||
|
|
||||||
|
🔑 **Die oeffentliche Instanz darf kein Livebild kennen, Home Assistant
|
||||||
|
schon.** Auf der Instanz im Netz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, damit
|
||||||
|
ein Bild aus dem eigenen Garten nicht nach aussen geht. Dann liefert
|
||||||
|
``/api/zustand/`` aber ``bild_url: null`` -- und die Bruecke bekam bis zum
|
||||||
|
11.10.2026 ebenfalls nichts, obwohl sie **im Heimnetz** laeuft und den
|
||||||
|
Verteiler direkt erreicht. In Home Assistant stand das Livebild
|
||||||
|
seit dem Umzug nach Coolify (03.10.2026) still.
|
||||||
|
|
||||||
|
⚠️ **Ein Schalter, zwei Zwecke -- das war der Fehler.** Derselbe Riegel
|
||||||
|
sperrte das Bild nach draussen *und* im eigenen Haus aus. Deshalb trennt
|
||||||
|
die Bruecke das jetzt: Steht ``VOGELALBUM_LIVE_<KENNUNG>`` in ihrer
|
||||||
|
Umgebung, nimmt sie diese Adresse und fragt die Web-App gar nicht erst.
|
||||||
|
|
||||||
|
VOGELALBUM_LIVE_VOGELHAUS=http://erkennung.local:8131/bild.jpg
|
||||||
|
VOGELALBUM_LIVE_TRAENKE=http://erkennung.local:8132/bild.jpg
|
||||||
|
|
||||||
|
Ohne Eintrag bleibt alles wie bisher: ``bild_url`` aus der Schnittstelle.
|
||||||
|
"""
|
||||||
|
kennung = kamera.get("kennung") or ""
|
||||||
|
# Kennungen duerfen Zeichen tragen, die in einem Variablennamen nichts zu
|
||||||
|
# suchen haben ("vogelhaus-2"). Alles andere wird zum Unterstrich.
|
||||||
|
name = "".join(z if z.isalnum() else "_" for z in kennung).upper()
|
||||||
|
eigene = umgebung(f"LIVE_{name}", "") if name else ""
|
||||||
|
adresse = eigene or kamera.get("bild_url") or ""
|
||||||
|
if not adresse:
|
||||||
|
return ""
|
||||||
|
# Dieselbe Umrechnung wie in ``Camera.live_bild_url``: ``/live`` ist der
|
||||||
|
# endlose MJPEG-Strom, ``/bild.jpg`` das Einzelbild. Wer die Stromadresse
|
||||||
|
# eintraegt, soll nicht zehn Sekunden lang einen Film herunterladen.
|
||||||
|
adresse = adresse.rstrip("/")
|
||||||
|
if adresse.endswith("/live"):
|
||||||
|
adresse = adresse.removesuffix("/live") + "/bild.jpg"
|
||||||
|
return adresse
|
||||||
|
|
||||||
|
|
||||||
|
# --- Home Assistant beschreiben --------------------------------------
|
||||||
|
|
||||||
|
def geraet_kamera(kennung: str, name: str) -> dict:
|
||||||
|
return {
|
||||||
|
"identifiers": [f"{BASIS}_{INSTANZ}_{kennung}"],
|
||||||
|
"name": f"Vogelalbum {name}",
|
||||||
|
"manufacturer": "Vogelalbum",
|
||||||
|
"model": "Vogelkamera",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def geraet_sammlung() -> dict:
|
||||||
|
return {
|
||||||
|
"identifiers": [f"{BASIS}_{INSTANZ}"],
|
||||||
|
"name": "Vogelalbum",
|
||||||
|
"manufacturer": "Vogelalbum",
|
||||||
|
"model": "Sammlung",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _thema(art: str, kennung: str) -> str:
|
||||||
|
return f"{DISCOVERY}/{art}/{BASIS}_{INSTANZ}_{kennung}/config"
|
||||||
|
|
||||||
|
|
||||||
|
def entwurf(art: str, kennung: str, name: str, geraet: dict,
|
||||||
|
zustandsthema: str, wert: str, **rest) -> tuple[str, dict]:
|
||||||
|
"""Eine Discovery-Nachricht.
|
||||||
|
|
||||||
|
``kennung`` ist der Vertrag — siehe Modulkopf. ``name`` darf sich
|
||||||
|
jederzeit ändern.
|
||||||
|
"""
|
||||||
|
inhalt = {
|
||||||
|
"name": name,
|
||||||
|
"unique_id": f"{BASIS}_{INSTANZ}_{kennung}",
|
||||||
|
"state_topic": zustandsthema,
|
||||||
|
"value_template": wert,
|
||||||
|
"availability_topic": f"{BASIS}/{INSTANZ}/erreichbar",
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"device": geraet,
|
||||||
|
}
|
||||||
|
inhalt.update(rest)
|
||||||
|
return _thema(art, kennung), inhalt
|
||||||
|
|
||||||
|
|
||||||
|
def entwuerfe(zustand: dict) -> list[tuple[str, dict]]:
|
||||||
|
"""Alle Entitäten, die Vogelalbum anbietet.
|
||||||
|
|
||||||
|
🔑 Vieles kommt **standardmäßig abgeschaltet** heraus
|
||||||
|
(``enabled_by_default: false``). Die Linie ist *„möglichst
|
||||||
|
viel, Nutzer kann ja entscheiden was ihm taugt"* — und genau dafür ist
|
||||||
|
der Schalter da. Wer alles sehen will, schaltet an; alle anderen haben
|
||||||
|
kein zugemülltes Dashboard.
|
||||||
|
"""
|
||||||
|
sammlung = f"{BASIS}/{INSTANZ}/sammlung"
|
||||||
|
liste: list[tuple[str, dict]] = []
|
||||||
|
g = geraet_sammlung()
|
||||||
|
|
||||||
|
# --- Die Sammlung ---
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "letzte_art", "Letzte Art", g, sammlung,
|
||||||
|
"{{ value_json.letzte_sichtung.art | default('—') }}",
|
||||||
|
icon="mdi:bird"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "arten_gesammelt", "Arten gesammelt", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.arten_gesammelt }}",
|
||||||
|
icon="mdi:book-open-variant", state_class="total"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "sichtungen_heute", "Sichtungen heute", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.sichtungen_heute }}",
|
||||||
|
icon="mdi:counter", state_class="total"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "sichtungen_gesamt", "Sichtungen gesamt", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.sichtungen_gesamt }}",
|
||||||
|
icon="mdi:counter", state_class="total_increasing",
|
||||||
|
enabled_by_default=False))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "stufe", "Stufe", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.stufe }}", icon="mdi:star-circle"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "rang", "Rang", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.rang }}", icon="mdi:medal",
|
||||||
|
enabled_by_default=False))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "punkte", "Punkte", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.punkte }}", icon="mdi:numeric",
|
||||||
|
enabled_by_default=False))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "abzeichen", "Abzeichen", g, sammlung,
|
||||||
|
"{{ value_json.sammlung.abzeichen }}", icon="mdi:seal",
|
||||||
|
enabled_by_default=False))
|
||||||
|
# ⚠️ Abzeichen NICHT einzeln: Das wären Dutzende Entitäten, die sich
|
||||||
|
# fast nie ändern. Das zuletzt verdiente genügt fürs Wandtablet.
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "letztes_abzeichen", "Letztes Abzeichen", g, sammlung,
|
||||||
|
"{{ value_json.letztes_abzeichen.name | default('—') }}",
|
||||||
|
icon="mdi:seal-variant", enabled_by_default=False))
|
||||||
|
|
||||||
|
# Die letzte Sichtung mit allem Drum und Dran -- als Attribute, damit
|
||||||
|
# eine Automation sie ohne zehn weitere Entitaeten auslesen kann.
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", "letzte_sichtung", "Letzte Sichtung", g, sammlung,
|
||||||
|
"{{ value_json.letzte_sichtung.zeitpunkt | default('') }}",
|
||||||
|
device_class="timestamp",
|
||||||
|
json_attributes_topic=sammlung,
|
||||||
|
json_attributes_template="{{ value_json.letzte_sichtung | tojson }}"))
|
||||||
|
|
||||||
|
# Das Foto zur letzten Sichtung.
|
||||||
|
#
|
||||||
|
# 🔑 Als eigene Entität und nicht als Adresse im Attribut: So muss Home
|
||||||
|
# Assistant die Web-App gar nicht erreichen können. Das Bild kommt über
|
||||||
|
# denselben Weg wie alles andere.
|
||||||
|
liste.append((
|
||||||
|
_thema("image", "letztes_bild"),
|
||||||
|
{
|
||||||
|
"name": "Letzte Sichtung (Bild)",
|
||||||
|
"unique_id": f"{BASIS}_{INSTANZ}_letztes_bild",
|
||||||
|
"image_topic": f"{sammlung}/bild",
|
||||||
|
"image_encoding": "b64",
|
||||||
|
"availability_topic": f"{BASIS}/{INSTANZ}/erreichbar",
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"device": g,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Je Kamera ---
|
||||||
|
for kamera in zustand.get("kameras", []):
|
||||||
|
k = kamera["kennung"]
|
||||||
|
thema = f"{BASIS}/{INSTANZ}/kamera/{k}"
|
||||||
|
gk = geraet_kamera(k, kamera["name"])
|
||||||
|
|
||||||
|
liste.append(entwurf(
|
||||||
|
"binary_sensor", f"kamera_{k}_online", "Erreichbar", gk, thema,
|
||||||
|
"{{ 'ON' if value_json.online else 'OFF' }}",
|
||||||
|
device_class="connectivity", payload_on="ON", payload_off="OFF"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_still", "Still seit", gk, thema,
|
||||||
|
"{{ value_json.still_seit_min }}",
|
||||||
|
unit_of_measurement="min", icon="mdi:timer-sand",
|
||||||
|
enabled_by_default=False))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_letzte_sichtung", "Letzte Sichtung", gk,
|
||||||
|
thema, "{{ value_json.letzte_sichtung | default('') }}",
|
||||||
|
device_class="timestamp"))
|
||||||
|
|
||||||
|
# Was nur das Gerät selbst weiß. Fehlt es (Gerät aus, kein
|
||||||
|
# /status), bleiben die Werte leer statt falsch.
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_rssi", "Signalstärke", gk, thema,
|
||||||
|
"{{ value_json.geraet.rssi | default('') }}",
|
||||||
|
unit_of_measurement="dBm", device_class="signal_strength",
|
||||||
|
state_class="measurement", entity_category="diagnostic"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_temperatur", "Chiptemperatur", gk, thema,
|
||||||
|
"{{ value_json.geraet.temperatur_c | default('') }}",
|
||||||
|
unit_of_measurement="°C", device_class="temperature",
|
||||||
|
state_class="measurement", entity_category="diagnostic"))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_fps", "Bildrate", gk, thema,
|
||||||
|
"{{ value_json.geraet.fps | default('') }}",
|
||||||
|
unit_of_measurement="B/s", icon="mdi:speedometer",
|
||||||
|
state_class="measurement", entity_category="diagnostic",
|
||||||
|
enabled_by_default=False))
|
||||||
|
liste.append(entwurf(
|
||||||
|
"sensor", f"kamera_{k}_laufzeit", "Laufzeit", gk, thema,
|
||||||
|
"{{ value_json.geraet.laufzeit_s | default('') }}",
|
||||||
|
unit_of_measurement="s", device_class="duration",
|
||||||
|
state_class="total_increasing", entity_category="diagnostic",
|
||||||
|
enabled_by_default=False))
|
||||||
|
|
||||||
|
# ⚠️ Das Bild kommt vom DETECTOR. Siehe Modulkopf -- an der Kamera
|
||||||
|
# selbst wuerde Home Assistant die Erkennung lahmlegen.
|
||||||
|
if live_adresse(kamera):
|
||||||
|
liste.append((
|
||||||
|
_thema("camera", f"kamera_{k}_bild"),
|
||||||
|
{
|
||||||
|
"name": "Livebild",
|
||||||
|
"unique_id": f"{BASIS}_{INSTANZ}_kamera_{k}_bild",
|
||||||
|
"topic": f"{thema}/bild",
|
||||||
|
"image_encoding": "b64",
|
||||||
|
"availability_topic": f"{BASIS}/{INSTANZ}/erreichbar",
|
||||||
|
"payload_available": "online",
|
||||||
|
"payload_not_available": "offline",
|
||||||
|
"device": gk,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
# --- Der Lauf --------------------------------------------------------
|
||||||
|
|
||||||
|
_weiter = True
|
||||||
|
|
||||||
|
|
||||||
|
def _aufhoeren(signum, rahmen):
|
||||||
|
global _weiter
|
||||||
|
_weiter = False
|
||||||
|
sagen("Ende angefordert.")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not LESE_TOKEN:
|
||||||
|
sagen("VOGELALBUM_LESE_TOKEN fehlt — ohne den bleibt die Tür zu.")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _aufhoeren)
|
||||||
|
signal.signal(signal.SIGINT, _aufhoeren)
|
||||||
|
|
||||||
|
erreichbar = f"{BASIS}/{INSTANZ}/erreichbar"
|
||||||
|
klient = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id=f"{BASIS}-bruecke-{INSTANZ}")
|
||||||
|
if MQTT_BENUTZER:
|
||||||
|
klient.username_pw_set(MQTT_BENUTZER, MQTT_PASSWORT)
|
||||||
|
|
||||||
|
# 🔑 Letzter Wille: Stirbt die Brücke, sagt der Broker Home Assistant
|
||||||
|
# Bescheid. Ohne das stehen alle Entitäten auf ihrem letzten Wert und
|
||||||
|
# sehen aus, als sei alles in Ordnung — das ist schlimmer als ein
|
||||||
|
# offensichtlicher Ausfall.
|
||||||
|
klient.will_set(erreichbar, "offline", retain=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
klient.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||||
|
except (OSError, TimeoutError) as fehler:
|
||||||
|
sagen(f"Broker {MQTT_HOST}:{MQTT_PORT} nicht erreicht: {fehler}")
|
||||||
|
return 1
|
||||||
|
klient.loop_start()
|
||||||
|
sagen(f"Verbunden mit {MQTT_HOST}:{MQTT_PORT}, Instanz '{INSTANZ}'.")
|
||||||
|
|
||||||
|
angemeldet = False
|
||||||
|
letztes_bild_vom = None
|
||||||
|
while _weiter:
|
||||||
|
zustand = zustand_holen()
|
||||||
|
if zustand is None:
|
||||||
|
klient.publish(erreichbar, "offline", retain=True)
|
||||||
|
time.sleep(TAKT)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not angemeldet:
|
||||||
|
# Discovery einmal, und dauerhaft: Startet Home Assistant neu,
|
||||||
|
# findet es die Entitäten ohne dass die Brücke etwas merkt.
|
||||||
|
for thema, inhalt in entwuerfe(zustand):
|
||||||
|
klient.publish(thema, json.dumps(inhalt), retain=True)
|
||||||
|
sagen(f"{len(entwuerfe(zustand))} Entitäten angemeldet.")
|
||||||
|
angemeldet = True
|
||||||
|
|
||||||
|
klient.publish(erreichbar, "online", retain=True)
|
||||||
|
klient.publish(f"{BASIS}/{INSTANZ}/sammlung",
|
||||||
|
json.dumps(zustand), retain=True)
|
||||||
|
|
||||||
|
# Das Foto zur letzten Sichtung — nur wenn es ein NEUES ist.
|
||||||
|
#
|
||||||
|
# ⚠️ Hier ist Behalten richtig (anders als beim Livebild): Das Bild
|
||||||
|
# gehört zu einem Zeitpunkt in der Vergangenheit und bleibt gültig,
|
||||||
|
# bis der nächste Vogel kommt. Startet Home Assistant neu, soll es
|
||||||
|
# wieder da sein statt leer.
|
||||||
|
#
|
||||||
|
# Und nur bei Wechsel: Sonst lädt die Brücke alle zehn Sekunden
|
||||||
|
# dasselbe Bild und schiebt es durch den Broker.
|
||||||
|
letzte = zustand.get("letzte_sichtung") or {}
|
||||||
|
zeitpunkt = letzte.get("zeitpunkt")
|
||||||
|
if zeitpunkt and zeitpunkt != letztes_bild_vom:
|
||||||
|
foto = bild_holen(letzte.get("bild"))
|
||||||
|
if foto:
|
||||||
|
klient.publish(f"{BASIS}/{INSTANZ}/sammlung/bild",
|
||||||
|
base64.b64encode(foto), retain=True)
|
||||||
|
letztes_bild_vom = zeitpunkt
|
||||||
|
sagen(f"Bild zur Sichtung vom {zeitpunkt} veröffentlicht.")
|
||||||
|
|
||||||
|
for kamera in zustand.get("kameras", []):
|
||||||
|
k = kamera["kennung"]
|
||||||
|
thema = f"{BASIS}/{INSTANZ}/kamera/{k}"
|
||||||
|
|
||||||
|
# Die Werte, die nur das Gerät kennt, dazulegen.
|
||||||
|
kamera = dict(kamera)
|
||||||
|
kamera["geraet"] = geraetestatus_holen(kamera.get("geraet_url")) or {}
|
||||||
|
klient.publish(thema, json.dumps(kamera), retain=True)
|
||||||
|
|
||||||
|
# ⚠️ Das Bild NICHT behalten (retain=False): Ein aufbewahrtes
|
||||||
|
# Bild zeigt nach einem Neustart der Brücke den Stand von
|
||||||
|
# gestern und sieht aus wie ein Livebild. Genau diese
|
||||||
|
# Verwechslung hat am 24.09.2026 eine Stunde gekostet.
|
||||||
|
bild = bild_holen(live_adresse(kamera))
|
||||||
|
if bild:
|
||||||
|
klient.publish(f"{thema}/bild",
|
||||||
|
base64.b64encode(bild), retain=False)
|
||||||
|
|
||||||
|
time.sleep(TAKT)
|
||||||
|
|
||||||
|
klient.publish(erreichbar, "offline", retain=True)
|
||||||
|
klient.loop_stop()
|
||||||
|
klient.disconnect()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Die Kameraseite
|
||||||
|
|
||||||
|
Vogelalbum braucht von der Kamera nur eines: einen Stream, den der Detector
|
||||||
|
lesen kann. Alles andere passiert woanders.
|
||||||
|
|
||||||
|
**Der Pi erkennt nichts.** Ein Pi Zero W schafft das Streamen, aber keine
|
||||||
|
Inferenz — die beiden Modelle brauchen rund 30 ms auf einem N100 und ein
|
||||||
|
Vielfaches davon auf einem Zero. Deshalb streamt der Pi, und der Detector
|
||||||
|
läuft auf einer Maschine mit Rechenleistung im selben Netz.
|
||||||
|
|
||||||
|
## Was du brauchst
|
||||||
|
|
||||||
|
* Raspberry Pi mit Kameraanschluss — ein **Zero W** reicht und ist der
|
||||||
|
sparsamste Weg. Ein Pi 4/5 kann zusätzlich den Detector mitlaufen lassen.
|
||||||
|
* Ein Kameramodul. Getestet mit dem **OV5647** (das ältere 5-MP-Modul).
|
||||||
|
* Raspberry Pi OS (Bookworm oder neuer), WLAN eingerichtet.
|
||||||
|
|
||||||
|
## Einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp vogelkamera.py /opt/vogelkamera_server.py
|
||||||
|
sudo cp vogelkamera.service /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now vogelkamera
|
||||||
|
|
||||||
|
journalctl -u vogelkamera -f
|
||||||
|
```
|
||||||
|
|
||||||
|
🔑 **Kein zusätzliches Paket.** Der Server ruft `rpicam-vid` auf, das bei
|
||||||
|
Raspberry Pi OS ohnehin dabei ist, und liest dessen MJPEG-Ausgabe mit. Die
|
||||||
|
Arbeit passiert im C-Programm; Python schiebt nur Bytes weiter. Auf einem
|
||||||
|
Zero W ist das der Unterschied zwischen läuft und läuft nicht.
|
||||||
|
|
||||||
|
⚠️ **Bis zum 25.09.2026 stand hier `python3-picamera2`** — für eine Fassung,
|
||||||
|
die eleganter aussah und **nie auf einem Pi gestartet worden war**. Auf dem
|
||||||
|
Gerät am Vogelhaus lief die ganze Zeit etwas anderes, und picamera2 war dort
|
||||||
|
nicht einmal installiert. Auf den Punkt gebracht: *im Repo lag Code, der nie
|
||||||
|
getestet wurde.*
|
||||||
|
|
||||||
|
Seitdem steht in diesem Ordner, was tatsächlich läuft — und zwar seit dem
|
||||||
|
28.08.2026 ununterbrochen.
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
| Adresse | wozu |
|
||||||
|
|---|---|
|
||||||
|
| `http://<pi>:8080/stream` | der MJPEG-Stream — das kommt in `VOGELALBUM_SOURCE` |
|
||||||
|
| `http://<pi>:8080/` | Vorschauseite im Browser |
|
||||||
|
| `http://<pi>:8080/status` | Bildrate, Funkstärke, Temperatur, Laufzeit als JSON |
|
||||||
|
| `http://<pi>:8080/temp` | CPU-Temperatur |
|
||||||
|
| `http://<pi>:8080/gesund` | 200, sobald Bilder ankommen |
|
||||||
|
|
||||||
|
Die drei unteren fassen den Stream **nicht** an — das ist der richtige Weg,
|
||||||
|
eine laufende Kamera zu prüfen. `/status` liefert dieselben Felder wie die
|
||||||
|
ESP32-Kameraseite ([`../esp32cam/`](../esp32cam/README.md)), ein Werkzeug muss
|
||||||
|
also nicht wissen, was am anderen Ende hängt.
|
||||||
|
|
||||||
|
Hängt die Kamera über Kopf: `--vflip` und `--hflip` stehen im Skript bereits
|
||||||
|
im Aufruf von `rpicam-vid` (`capture_loop`). Beide zusammen ergeben die
|
||||||
|
180°-Drehung, einzeln spiegeln sie.
|
||||||
|
|
||||||
|
## Drei Dinge, die man einmal falsch macht
|
||||||
|
|
||||||
|
### Nur ein Zuschauer gleichzeitig
|
||||||
|
|
||||||
|
Ein Pi Zero W liefert über WLAN rund **2 Mbit/s**. Ein zweiter Client teilt
|
||||||
|
sich diese Strecke — und der Detector fällt in Timeouts, ohne dass irgendwo
|
||||||
|
ein Fehler auftaucht. Die Bildrate halbiert sich einfach.
|
||||||
|
|
||||||
|
Das ist bei der Entwicklung zweimal passiert: einmal durch ein `curl` auf den
|
||||||
|
Stream, mit dem eine Störung *diagnostiziert* werden sollte, und einmal, weil
|
||||||
|
die Vorschauseite im Browser offen stand. Gemessen: 0,9 statt 1,9 Bilder pro
|
||||||
|
Sekunde, solange ein zweites Fenster mitlas.
|
||||||
|
|
||||||
|
Deshalb weist `vogelkamera.py` einen zweiten Zuschauer mit **503** ab, statt
|
||||||
|
beide schlecht zu bedienen. Wer selbst zuschauen will, hält kurz den Detector
|
||||||
|
an — oder nimmt `/snapshot`, das kostet nur ein Bild.
|
||||||
|
|
||||||
|
⚠️ **Bis zum 09.10.2026 stand das hier und stimmte nicht.** Die Fassung, die
|
||||||
|
auf dem Gerät läuft, hat den zweiten Zuschauer nur **gezählt** und beide
|
||||||
|
bedient; abgewiesen hat ihn nur die ESP32-Kameraseite. Aufgefallen ist es erst
|
||||||
|
beim Nachziehen der Tests, die seit dem 25.09. auf die alte Fassung zeigten.
|
||||||
|
Seitdem tut der Code, was hier steht.
|
||||||
|
|
||||||
|
Ein Stream, der **15 Sekunden kein Bild** liefert, wird beendet. Das klingt
|
||||||
|
nebensächlich, ist aber die Bedingung dafür, dass die Absage überhaupt
|
||||||
|
funktionieren darf: Ohne neues Bild schreibt der Server nichts und merkt
|
||||||
|
deshalb nicht, dass der Zuschauer schon weg ist — der eine Platz bliebe für
|
||||||
|
immer besetzt und die Kamera wäre durch ihre eigene Schutzsperre dauerhaft
|
||||||
|
stumm. Der Detector verbindet von selbst neu.
|
||||||
|
|
||||||
|
### Die Temperatur im Blick behalten
|
||||||
|
|
||||||
|
Ein Vogelhaus in der Sonne ist ein geschlossener Kasten. Gemessen an einem
|
||||||
|
Septembernachmittag: **72 °C** auf einem Pi Zero W. Ab etwa 80 °C drosselt der
|
||||||
|
Pi von selbst, und die Bildrate fällt — ohne Fehlermeldung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<pi>:8080/temp
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Abfrage kostet den Stream nichts, im Gegensatz zu einem Blick auf das
|
||||||
|
Bild. **`ping` taugt hier nicht als Gesundheitsprüfung:** ICMP fällt bei
|
||||||
|
gesättigtem WLAN als Erstes aus, während TCP weiterläuft — eine Entwarnung per
|
||||||
|
`ping` ist Glück, keine Messung.
|
||||||
|
|
||||||
|
Das vollständige Bild gibt `/status`, ebenfalls ohne den Stream anzufassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://<pi>:8080/status
|
||||||
|
{"kamera":"bereit","bilder":1751409,"fps":3.84,"letztes_bild_s":0.2,
|
||||||
|
"fehler":"0x0","laufzeit_s":401238,"zuschauer":true,"rssi":-58,
|
||||||
|
"temperatur_c":55.1,"speicher_frei":218103808}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Gesund | Woran es sonst liegt |
|
||||||
|
|---|---|---|
|
||||||
|
| `fps` | stabil über Stunden | fällt → zweiter Zuschauer, Funkstrecke, Hitze |
|
||||||
|
| `rssi` | über −70 dBm | darunter näher ran oder Antenne ausrichten |
|
||||||
|
| `temperatur_c` | unter 70 °C | geschlossener Kasten in der Sonne drosselt |
|
||||||
|
| `letztes_bild_s` | unter 1 s | steht → Kamera liefert nicht mehr |
|
||||||
|
| `zuschauer` | `true` im Normalbetrieb | `false`, obwohl der Detector läuft → er kommt nicht durch |
|
||||||
|
|
||||||
|
`bilder` und `fps` zählen, was die **Kamera liefert**, nicht was ein Zuschauer
|
||||||
|
abholt. Ein `fps` über null bei `zuschauer: false` heißt also: die Kamera
|
||||||
|
arbeitet, es holt nur niemand ab.
|
||||||
|
|
||||||
|
Wer den Pi in einen geschlossenen Kasten baut: Schattenseite wählen, Öffnung
|
||||||
|
für Luft lassen, und die Kamera nicht direkt neben den SoC setzen.
|
||||||
|
|
||||||
|
### Die Bildrate hängt am Licht, nicht an der CPU
|
||||||
|
|
||||||
|
Über WLAN ist die Strecke bandbreitenbegrenzt. Ein helles, detailreiches Bild
|
||||||
|
ergibt größere JPEGs und damit **weniger** Bilder pro Sekunde. Gemessen über
|
||||||
|
neun Tage am selben Aufbau: nachts 6,2 fps (fast schwarzes Bild), tagsüber
|
||||||
|
3,7–4,2 fps.
|
||||||
|
|
||||||
|
Reicht die Bildrate nicht, ist `--qualitaet` der Hebel — nicht die Auflösung.
|
||||||
|
Die Auflösung braucht man, damit der Vogel groß genug im Bild ist.
|
||||||
|
|
||||||
|
## Wie groß muss der Vogel im Bild sein?
|
||||||
|
|
||||||
|
Das ist die Frage, an der ein Aufbau scheitert, und sie lässt sich vorher
|
||||||
|
beantworten — ohne auf einen echten Vogel zu warten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m detector karte --source http://<pi>:8080/stream --ziel karte.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Detector montiert ein Vogelfoto über ein Raster von Positionen in aktuelle
|
||||||
|
Bilder deiner Kamera und zeigt, wo er anspringt. Details im
|
||||||
|
[Kamera-Kapitel](../docs/kamera.md#hängt-die-kamera-richtig-detector-karte).
|
||||||
|
|
||||||
|
Die zwei Regeln, die dabei herausgekommen sind:
|
||||||
|
|
||||||
|
* Ein Vogel braucht **5–8 % der Bildfläche**. Vor ruhigem Hintergrund reichen
|
||||||
|
5 %, vor einem Baum im Wind sind es 8 %.
|
||||||
|
* Die **Landefläche gehört in die untere Bildmitte, nicht an den unteren
|
||||||
|
Bildrand.** Dort unten findet der Detektor auch einen großen Vogel nicht
|
||||||
|
zuverlässig. Eine Kamera, die flach über das Futterbrett hinwegschaut, legt
|
||||||
|
jeden Gast genau in diese tote Zone.
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MJPEG-Server fuer die Kameraseite. Laeuft auf dem Pi Zero W am Vogelhaus.
|
||||||
|
|
||||||
|
Der Pi streamt nur -- erkannt wird auf einer Maschine mit Rechenleistung.
|
||||||
|
Ein Zero W schafft das Streamen, aber keine Inferenz.
|
||||||
|
|
||||||
|
http://<pi>:8080/ Vorschauseite
|
||||||
|
http://<pi>:8080/stream MJPEG -- nur ein Zuschauer, der zweite bekommt 503
|
||||||
|
http://<pi>:8080/snapshot ein einzelnes Bild
|
||||||
|
http://<pi>:8080/status JSON: Bildrate, Funkstaerke, Temperatur
|
||||||
|
http://<pi>:8080/temp Chiptemperatur als Text
|
||||||
|
http://<pi>:8080/gesund 200 oder 503
|
||||||
|
|
||||||
|
🔑 **Die Felder von /status sind dieselben wie bei der ESP32-Kameraseite**
|
||||||
|
(esp32cam/). Damit kann ein Werkzeug beide abfragen, ohne zu wissen, was
|
||||||
|
am anderen Ende haengt -- die Bruecke nach Home Assistant tut genau das.
|
||||||
|
|
||||||
|
## Warum rpicam-vid und nicht picamera2
|
||||||
|
|
||||||
|
⚠️ **Weil es laeuft.** Diese Fassung streamt seit dem 28.08.2026
|
||||||
|
ununterbrochen am Vogelhaus. Im Repo lag bis zum 25.09.2026 eine
|
||||||
|
picamera2-Fassung, die eleganter aussah und **nie auf einem Pi gestartet
|
||||||
|
worden war** -- picamera2 ist auf dem Geraet nicht einmal installiert.
|
||||||
|
|
||||||
|
Der Finger in der Wunde: *im Repo lag Code, der nie getestet wurde.*
|
||||||
|
Zwei Wahrheiten, und die ungetestete stand im Repo. Seitdem steht hier, was auf dem Geraet laeuft.
|
||||||
|
|
||||||
|
rpicam-vid ist ausserdem das leichtere Verfahren: Die Arbeit passiert im
|
||||||
|
C-Programm, Python schiebt nur Bytes weiter. Auf einem Zero W zaehlt das.
|
||||||
|
|
||||||
|
## Englisch im Code
|
||||||
|
|
||||||
|
Der Code kam so vom Geraet und wurde bewusst NICHT uebersetzt. Eine
|
||||||
|
laufende Kamera umbenennt man nicht, um sie huebscher zu machen -- jede
|
||||||
|
Zeile, die man anfasst, kann sie stilllegen. Neue Teile sind auf Deutsch
|
||||||
|
kommentiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
WIDTH = 1280
|
||||||
|
HEIGHT = 720
|
||||||
|
# Aufnahmerate. Bis zum 25.09.2026 stand hier 15.
|
||||||
|
#
|
||||||
|
# ⚠️ **Die 15 waren ein Wunsch, kein Zustand.** Gemessen am Zaehler, der
|
||||||
|
# an der QUELLE sitzt: 3,79 Bilder/s vorher, 3,77 nachher. Der Pi hat die
|
||||||
|
# 15 nie erreicht.
|
||||||
|
#
|
||||||
|
# 🔑 Und der Grund ist nicht das WLAN, sondern der Chip selbst: Ein
|
||||||
|
# Zero W kodiert 1280x720 MJPEG nicht schneller. Er laeuft am Anschlag,
|
||||||
|
# egal was man ihm sagt -- rpicam-vid stand bei 60 % CPU, der Server bei
|
||||||
|
# 30 %, Lastmittel 3,8 auf EINEM Kern.
|
||||||
|
#
|
||||||
|
# Die ehrliche Zahl hier zu haben ist trotzdem besser als eine
|
||||||
|
# ausgedachte: Sie sagt jedem, was das Geraet wirklich tut. Gebracht hat
|
||||||
|
# die Aenderung zwei Grad (46,5 -> 44,4 °C), mehr nicht.
|
||||||
|
#
|
||||||
|
# 📌 Der echte Hebel waere die Aufloesung, nicht die Bildrate.
|
||||||
|
FPS = 5
|
||||||
|
QUALITY = 80
|
||||||
|
PORT = 8080
|
||||||
|
RPICAM_VID = "/usr/bin/rpicam-vid"
|
||||||
|
|
||||||
|
# Nach so vielen Sekunden ohne neues Bild wird ein Stream beendet. Siehe die
|
||||||
|
# Begruendung bei /stream -- der Wert haelt den Zuschauerplatz frei, wenn die
|
||||||
|
# Aufnahme haengt. Grosszuegig gewaehlt: bei 5 fps sind 15 s sehr viel.
|
||||||
|
STREAM_LEERLAUF = 15.0
|
||||||
|
|
||||||
|
def read_cpu_temp():
|
||||||
|
"""Read CPU temperature from thermal zone."""
|
||||||
|
try:
|
||||||
|
with open("/sys/class/thermal/thermal_zone0/temp") as f:
|
||||||
|
return int(f.read().strip()) / 1000.0
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Werte fuer /status ------------------------------------------------
|
||||||
|
#
|
||||||
|
# 🔑 Alles hier liest aus /proc oder /sys. Kein Aufruf fremder Programme,
|
||||||
|
# kein zusaetzliches Paket -- auf einem Zero W ist jeder Unterprozess
|
||||||
|
# teuer, und dieser Server soll vor allem eines: nicht im Weg stehen.
|
||||||
|
|
||||||
|
START_ZEIT = time.time()
|
||||||
|
bilder_gesamt = 0 # vom capture_loop hochgezaehlt
|
||||||
|
zuschauer = 0 # wie viele gerade am Stream haengen
|
||||||
|
_zaehler_sperre = threading.Lock()
|
||||||
|
|
||||||
|
# 🔑 **Nur ein Zuschauer gleichzeitig.** Ein Zero W liefert ueber WLAN rund
|
||||||
|
# 2 Mbit/s; ein zweiter Client teilt sich dieselbe Strecke, und der Detector
|
||||||
|
# faellt in Timeouts, ohne dass irgendwo ein Fehler steht -- die Bildrate
|
||||||
|
# halbiert sich einfach. Lieber eine klare Absage als zwei ruckelnde Bilder.
|
||||||
|
#
|
||||||
|
# ⚠️ Das stand von Anfang an in pi/README.md, im Code aber nicht: die
|
||||||
|
# rpicam-vid-Fassung vom Geraet hat den zweiten Zuschauer nur GEZAEHLT.
|
||||||
|
# Die ESP32-Kameraseite weist ihn ab, der Pi tat es nicht.
|
||||||
|
#
|
||||||
|
# Nicht blockierend genommen: ein zweiter Zuschauer soll eine Absage
|
||||||
|
# bekommen und nicht in einer Warteschlange haengen.
|
||||||
|
zuschauer_platz = threading.Semaphore(1)
|
||||||
|
|
||||||
|
|
||||||
|
def funkstaerke():
|
||||||
|
"""Empfangspegel in dBm, oder None.
|
||||||
|
|
||||||
|
/proc/net/wireless statt iwconfig: kein Unterprozess, und das Format
|
||||||
|
ist seit Jahrzehnten unveraendert. Die dritte Zahl ist der Pegel.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open("/proc/net/wireless") as f:
|
||||||
|
for zeile in f.read().splitlines()[2:]:
|
||||||
|
teile = zeile.split()
|
||||||
|
if len(teile) >= 4:
|
||||||
|
return int(float(teile[3].rstrip(".")))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def speicher_frei():
|
||||||
|
"""Freier Arbeitsspeicher in Bytes, oder None."""
|
||||||
|
try:
|
||||||
|
with open("/proc/meminfo") as f:
|
||||||
|
for zeile in f:
|
||||||
|
if zeile.startswith("MemAvailable:"):
|
||||||
|
return int(zeile.split()[1]) * 1024
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def status_json():
|
||||||
|
"""Dieselben Feldnamen wie die ESP32-Kameraseite. Siehe Modulkopf."""
|
||||||
|
frame, ts = frame_buffer.get()
|
||||||
|
jetzt = time.time()
|
||||||
|
laufzeit = jetzt - START_ZEIT
|
||||||
|
|
||||||
|
with _zaehler_sperre:
|
||||||
|
bilder = bilder_gesamt
|
||||||
|
schaut = zuschauer
|
||||||
|
|
||||||
|
return {
|
||||||
|
# 'bereit', sobald ueberhaupt ein Bild angekommen ist. Ein Server
|
||||||
|
# ohne Bild soll das sagen, statt still 503 zu liefern -- sonst
|
||||||
|
# steht man im Garten und raet.
|
||||||
|
"kamera": "bereit" if frame else "fehlt",
|
||||||
|
"fehler": "0x0" if frame else "0x1",
|
||||||
|
"laufzeit_s": int(laufzeit),
|
||||||
|
"bilder": bilder,
|
||||||
|
# Mittel ueber die gesamte Laufzeit -- kein gleitender Durchschnitt.
|
||||||
|
# Fuer 'laeuft es noch' genuegt das, und es kostet nichts.
|
||||||
|
"fps": round(bilder / laufzeit, 2) if laufzeit > 0 else 0.0,
|
||||||
|
"letztes_bild_s": round(jetzt - ts, 1) if ts else -1.0,
|
||||||
|
"zuschauer": schaut > 0,
|
||||||
|
"rssi": funkstaerke(),
|
||||||
|
"temperatur_c": read_cpu_temp(),
|
||||||
|
"speicher_frei": speicher_frei(),
|
||||||
|
}
|
||||||
|
|
||||||
|
class FrameBuffer:
|
||||||
|
"""Thread-safe circular frame buffer. Keeps only the latest frame."""
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._frame = None
|
||||||
|
self._timestamp = 0
|
||||||
|
|
||||||
|
def update(self, frame_bytes):
|
||||||
|
with self._lock:
|
||||||
|
self._frame = frame_bytes
|
||||||
|
self._timestamp = time.time()
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._frame, self._timestamp
|
||||||
|
|
||||||
|
frame_buffer = FrameBuffer()
|
||||||
|
|
||||||
|
def capture_loop():
|
||||||
|
"""Continuously capture MJPEG frames from rpicam-vid."""
|
||||||
|
cmd = [
|
||||||
|
RPICAM_VID,
|
||||||
|
"-t", "0", # infinite
|
||||||
|
"--codec", "mjpeg",
|
||||||
|
"--width", str(WIDTH),
|
||||||
|
"--height", str(HEIGHT),
|
||||||
|
"--framerate", str(FPS),
|
||||||
|
"--quality", str(QUALITY),
|
||||||
|
"--vflip",
|
||||||
|
"--hflip",
|
||||||
|
"--nopreview",
|
||||||
|
"-o", "-", # stdout
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[capture] Starting: {' '.join(cmd)}", flush=True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read MJPEG stream - frames are delimited by JPEG markers
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
chunk = proc.stdout.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
|
||||||
|
# Find JPEG frame boundaries (FFD8 = SOI, FFD9 = EOI)
|
||||||
|
while True:
|
||||||
|
soi = buf.find(b"\xff\xd8")
|
||||||
|
if soi == -1:
|
||||||
|
buf = b""
|
||||||
|
break
|
||||||
|
eoi = buf.find(b"\xff\xd9", soi + 2)
|
||||||
|
if eoi == -1:
|
||||||
|
# Incomplete frame, keep rest
|
||||||
|
buf = buf[soi:]
|
||||||
|
break
|
||||||
|
# Complete frame
|
||||||
|
frame = buf[soi:eoi + 2]
|
||||||
|
buf = buf[eoi + 2:]
|
||||||
|
frame_buffer.update(frame)
|
||||||
|
# Zaehlwerk fuer /status. Gemessen wird, was die
|
||||||
|
# Kamera LIEFERT -- nicht, was ein Zuschauer abholt.
|
||||||
|
# Damit sagt /status auch dann etwas, wenn niemand
|
||||||
|
# zuschaut.
|
||||||
|
global bilder_gesamt
|
||||||
|
with _zaehler_sperre:
|
||||||
|
bilder_gesamt += 1
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
print(f"[capture] rpicam-vid exited with code {proc.returncode}, restarting...", flush=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[capture] Error: {e}, restarting...", flush=True)
|
||||||
|
|
||||||
|
time.sleep(2) # Wait before restart
|
||||||
|
|
||||||
|
class MJPEGHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/" or self.path == "/index.html":
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html")
|
||||||
|
self.end_headers()
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Vogelkamera</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin:0; background:#1a1a2e; color:#fff; font-family:system-ui,sans-serif; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; }}
|
||||||
|
h1 {{ font-size:1.5em; margin:0.5em 0; }}
|
||||||
|
img {{ max-width:100%; max-height:80vh; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.5); }}
|
||||||
|
.footer {{ margin-top:1em; font-size:0.8em; opacity:0.6; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🐦 Vogelkamera</h1>
|
||||||
|
<img src="/stream" alt="Live Stream">
|
||||||
|
<div class="footer">Pi Zero W · OV5647 · {WIDTH}x{HEIGHT} · <span id="temp">--°C</span></div>
|
||||||
|
<script>
|
||||||
|
function updateTemp() {{
|
||||||
|
fetch('/temp').then(r => r.text()).then(t => {{
|
||||||
|
document.getElementById('temp').textContent = t;
|
||||||
|
}}).catch(() => {{}});
|
||||||
|
}}
|
||||||
|
updateTemp();
|
||||||
|
setInterval(updateTemp, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
self.wfile.write(html.encode())
|
||||||
|
|
||||||
|
elif self.path == "/temp":
|
||||||
|
temp = read_cpu_temp()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
if temp is not None:
|
||||||
|
self.wfile.write(f"{temp:.1f}°C".encode())
|
||||||
|
else:
|
||||||
|
self.wfile.write(b"N/A")
|
||||||
|
|
||||||
|
elif self.path == "/status":
|
||||||
|
# ⚠ Fasst den Stream NICHT an. Das ist der Punkt: eine
|
||||||
|
# laufende Kamera pruefen, ohne sie zu stoeren. 'ping' taugt
|
||||||
|
# dafuer nicht -- ICMP faellt bei gesaettigtem WLAN als
|
||||||
|
# Erstes aus, waehrend TCP weiterlaeuft.
|
||||||
|
koerper = json.dumps(status_json()).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.send_header("Content-Length", str(len(koerper)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(koerper)
|
||||||
|
|
||||||
|
elif self.path in ("/gesund", "/health"):
|
||||||
|
frame, ts = frame_buffer.get()
|
||||||
|
# Aelter als zehn Sekunden heisst: die Aufnahme haengt,
|
||||||
|
# auch wenn der Server noch antwortet.
|
||||||
|
gesund = bool(frame) and (time.time() - ts) < 10
|
||||||
|
self.send_response(200 if gesund else 503)
|
||||||
|
self.send_header("Content-Type", "text/plain")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"ok\n" if gesund else b"kein aktuelles Bild\n")
|
||||||
|
|
||||||
|
elif self.path == "/stream":
|
||||||
|
if not zuschauer_platz.acquire(blocking=False):
|
||||||
|
absage = (
|
||||||
|
"Es ist bereits ein Zuschauer verbunden.\n"
|
||||||
|
"Diese Kamera liefert nur einen Stream gleichzeitig -- ein zweiter\n"
|
||||||
|
"wuerde die Funkstrecke saettigen und beide Bilder ruckeln lassen.\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
self.send_response(503)
|
||||||
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(absage)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(absage)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||||
|
self.send_header("Cache-Control", "no-cache, private")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
global zuschauer
|
||||||
|
with _zaehler_sperre:
|
||||||
|
zuschauer += 1
|
||||||
|
|
||||||
|
last_ts = 0
|
||||||
|
leerlauf_seit = time.time()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
frame, ts = frame_buffer.get()
|
||||||
|
if frame and ts != last_ts:
|
||||||
|
last_ts = ts
|
||||||
|
leerlauf_seit = time.time()
|
||||||
|
self.wfile.write(b"--frame\r\n")
|
||||||
|
self.wfile.write(f"Content-Type: image/jpeg\r\nContent-Length: {len(frame)}\r\n\r\n".encode())
|
||||||
|
self.wfile.write(frame)
|
||||||
|
self.wfile.write(b"\r\n")
|
||||||
|
self.wfile.flush()
|
||||||
|
elif time.time() - leerlauf_seit > STREAM_LEERLAUF:
|
||||||
|
# 🔑 **Der Platz darf nicht an einer stehenden Kamera
|
||||||
|
# haengen bleiben.** Ohne neues Bild wird nichts
|
||||||
|
# geschrieben -- also merkt der Server auch nicht, dass
|
||||||
|
# der Zuschauer schon weg ist, und haelt den einen
|
||||||
|
# Platz fuer immer. Dann holt rpicam-vid sich zwar nach
|
||||||
|
# zwei Sekunden die Kamera zurueck, aber der Detector
|
||||||
|
# bekommt bei jedem Versuch 503 und die Kamera ist
|
||||||
|
# dauerhaft stumm -- ausgerechnet durch die Sperre, die
|
||||||
|
# sie schuetzen soll.
|
||||||
|
#
|
||||||
|
# Ein Stream, der 15 s nichts liefert, ist ohnehin
|
||||||
|
# nichts wert; der Detector verbindet von selbst neu.
|
||||||
|
break
|
||||||
|
# ⚠ Haeufiger nachsehen als aufgenommen wird: Sonst
|
||||||
|
# wartet der Zuschauer im schlechtesten Fall ein ganzes
|
||||||
|
# Bild lang, obwohl schon eines bereitliegt. Kostet
|
||||||
|
# nichts -- ohne neues Bild wird nichts gesendet.
|
||||||
|
time.sleep(0.5 / FPS)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass # Client disconnected
|
||||||
|
finally:
|
||||||
|
# ⚠ finally, nicht nach dem except: Auch ein Abbruch von
|
||||||
|
# aussen muss den Zaehler wieder senken, sonst steht dort
|
||||||
|
# nach ein paar Tagen eine Zahl, die nichts mehr bedeutet.
|
||||||
|
#
|
||||||
|
# Dasselbe gilt fuer den Platz: Bleibt er haengen, ist die
|
||||||
|
# Kamera nach einem geschlossenen Browserfenster dauerhaft
|
||||||
|
# dicht. Das Trennen merkt der Server erst beim naechsten
|
||||||
|
# Schreibversuch -- und genau der laeuft hier in den except.
|
||||||
|
with _zaehler_sperre:
|
||||||
|
zuschauer -= 1
|
||||||
|
zuschauer_platz.release()
|
||||||
|
|
||||||
|
elif self.path == "/snapshot":
|
||||||
|
frame, _ = frame_buffer.get()
|
||||||
|
if frame:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "image/jpeg")
|
||||||
|
self.send_header("Content-Length", str(len(frame)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(frame)
|
||||||
|
else:
|
||||||
|
self.send_response(503)
|
||||||
|
self.end_headers()
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # Suppress logs
|
||||||
|
|
||||||
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Start capture thread
|
||||||
|
capture_thread = threading.Thread(target=capture_loop, daemon=True)
|
||||||
|
capture_thread.start()
|
||||||
|
|
||||||
|
# Wait for first frame
|
||||||
|
print("[server] Waiting for first frame...", flush=True)
|
||||||
|
for i in range(30):
|
||||||
|
frame, _ = frame_buffer.get()
|
||||||
|
if frame:
|
||||||
|
print(f"[server] First frame received ({len(frame)} bytes)", flush=True)
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
print("[server] WARNING: No frame received within 30s, starting anyway", flush=True)
|
||||||
|
|
||||||
|
# Start HTTP server
|
||||||
|
server = ThreadedHTTPServer(("0.0.0.0", PORT), MJPEGHandler)
|
||||||
|
print(f"[server] MJPEG stream on http://0.0.0.0:{PORT}/", flush=True)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[server] Shutting down", flush=True)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Kameraseite als Dienst auf dem Raspberry Pi.
|
||||||
|
#
|
||||||
|
# sudo cp vogelkamera.py /opt/vogelkamera_server.py
|
||||||
|
# sudo cp vogelkamera.service /etc/systemd/system/
|
||||||
|
# sudo systemctl enable --now vogelkamera
|
||||||
|
# journalctl -u vogelkamera -f
|
||||||
|
#
|
||||||
|
# ⚠ Diese Datei ist die Abschrift dessen, was am Vogelhaus laeuft -- nicht
|
||||||
|
# ein Entwurf. Wer sie aendert, aendert ein Geraet im Garten.
|
||||||
|
#
|
||||||
|
# Der Benutzer muss in der Gruppe 'video' sein; bei Raspberry Pi OS ist der
|
||||||
|
# angelegte Erstbenutzer das bereits.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Vogelkamera MJPEG Stream Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 /opt/vogelkamera_server.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
User=admin
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Detector: läuft dort, wo Rechenleistung ist (Dev-Server, nicht der Pi Zero).
|
||||||
|
ai-edge-litert>=2.2.0
|
||||||
|
numpy>=2.0
|
||||||
|
pillow>=11.0
|
||||||
|
requests>=2.32
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Web-App: bewusst ohne numpy/LiteRT, damit der Container klein bleibt.
|
||||||
|
Django>=5.1
|
||||||
|
pillow>=11.0
|
||||||
|
# iPhones fotografieren HEIC. Ohne das Paket weist die Bildpruefung
|
||||||
|
# jedes Foto vom Telefon ab -- siehe web/dex/bilder.py.
|
||||||
|
pillow-heif>=1.0
|
||||||
|
whitenoise>=6.7
|
||||||
|
gunicorn>=22.0
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Misst, wie gut die Erkennung wirklich ist.
|
||||||
|
|
||||||
|
Holt für jede Art mehrere echte Fotos von Wikimedia Commons, jagt sie durch
|
||||||
|
dieselbe Pipeline, die auch am Vogelhaus läuft, und vergleicht die Bestimmung
|
||||||
|
mit der Kategorie, aus der das Bild stammt.
|
||||||
|
|
||||||
|
./venv/bin/python scripts/benchmark.py --hole # Bilder laden (einmalig)
|
||||||
|
./venv/bin/python scripts/benchmark.py # messen
|
||||||
|
|
||||||
|
Wichtige Einschränkung: Commons-Fotos sind in der Regel gut belichtete
|
||||||
|
Nahaufnahmen von Fotografen, die den Vogel im Sucher hatten. Eine feste Kamera
|
||||||
|
im Vogelhaus liefert schlechteres Material — Gegenlicht, Bewegungsunschärfe,
|
||||||
|
halb verdeckte Vögel. Die Werte hier sind eine Obergrenze, keine Prognose für
|
||||||
|
den Alltag. Die echte Zahl liefert erst die eigene Kamera.
|
||||||
|
|
||||||
|
Die Kategorien enthalten außerdem vereinzelt falsch einsortierte Bilder. Ein
|
||||||
|
paar Prozent Fehler gehen also auf Commons, nicht auf das Modell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from detector.classify import Pipeline # noqa: E402
|
||||||
|
from detector.species_de import SPECIES # noqa: E402
|
||||||
|
|
||||||
|
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
|
||||||
|
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
|
||||||
|
DATA = Path(__file__).resolve().parents[1] / "benchmark_data"
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimited(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _open(url: str, timeout: int = 45) -> bytes:
|
||||||
|
"""Ein Abruf mit Rücksicht auf Wikimedias Ratenbegrenzung.
|
||||||
|
|
||||||
|
Bei HTTP 429 wird gewartet statt weitergefeuert — erst ``Retry-After``,
|
||||||
|
sonst exponentiell. Wer trotz 429 weitermacht, wird zu Recht gesperrt.
|
||||||
|
"""
|
||||||
|
delay = 5.0
|
||||||
|
for attempt in range(5):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 429:
|
||||||
|
raise
|
||||||
|
wait = float(exc.headers.get("Retry-After") or delay)
|
||||||
|
print(f" 429 — warte {wait:.0f}s", flush=True)
|
||||||
|
time.sleep(min(wait, 120))
|
||||||
|
delay *= 2
|
||||||
|
raise RateLimited(f"nach 5 Versuchen weiterhin 429: {url[:80]}")
|
||||||
|
|
||||||
|
|
||||||
|
def api(params: dict) -> dict:
|
||||||
|
return json.loads(_open(f"{COMMONS_API}?{urllib.parse.urlencode(params)}", 30))
|
||||||
|
|
||||||
|
|
||||||
|
def image_urls(scientific: str, limit: int) -> list[str]:
|
||||||
|
"""Bilder aus der Commons-Kategorie zur Art."""
|
||||||
|
try:
|
||||||
|
data = api(
|
||||||
|
{
|
||||||
|
"action": "query",
|
||||||
|
"generator": "categorymembers",
|
||||||
|
"gcmtitle": f"Category:{scientific}",
|
||||||
|
"gcmtype": "file",
|
||||||
|
# Großzügig anfragen: ein Teil sind Zeichnungen, Eier, Federn
|
||||||
|
# oder Videos, die als Testbild nichts taugen.
|
||||||
|
"gcmlimit": str(limit * 4),
|
||||||
|
"prop": "imageinfo",
|
||||||
|
"iiprop": "url|mime",
|
||||||
|
"iiurlwidth": "900",
|
||||||
|
"format": "json",
|
||||||
|
"formatversion": "2",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
for page in data.get("query", {}).get("pages", []):
|
||||||
|
info = (page.get("imageinfo") or [{}])[0]
|
||||||
|
if info.get("mime") not in ("image/jpeg", "image/png"):
|
||||||
|
continue
|
||||||
|
title = page.get("title", "").lower()
|
||||||
|
# Eier, Federn, Skelette und Verbreitungskarten sind keine Vogelfotos.
|
||||||
|
if any(w in title for w in ("egg", "nest", "skull", "map", "range", "feather",
|
||||||
|
"distribution", "skeleton", "illustration",
|
||||||
|
"drawing", "plate", "stamp", "coin")):
|
||||||
|
continue
|
||||||
|
url = info.get("thumburl") or info.get("url")
|
||||||
|
if url:
|
||||||
|
urls.append(url)
|
||||||
|
if len(urls) >= limit:
|
||||||
|
break
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(args) -> None:
|
||||||
|
DATA.mkdir(exist_ok=True)
|
||||||
|
for i, species in enumerate(SPECIES, 1):
|
||||||
|
folder = DATA / species.slug
|
||||||
|
folder.mkdir(exist_ok=True)
|
||||||
|
have = len(list(folder.glob("*.jpg")))
|
||||||
|
if have >= args.pro_art:
|
||||||
|
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: {have} vorhanden")
|
||||||
|
continue
|
||||||
|
|
||||||
|
urls = image_urls(species.scientific, args.pro_art)
|
||||||
|
if not urls:
|
||||||
|
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: keine Bilder in der "
|
||||||
|
f"Kategorie '{species.scientific}'", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
got, failed = 0, 0
|
||||||
|
for n, url in enumerate(urls):
|
||||||
|
target = folder / f"{n:02d}.jpg"
|
||||||
|
if target.exists():
|
||||||
|
got += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
target.write_bytes(_open(url))
|
||||||
|
# Kaputte Downloads sofort aussortieren.
|
||||||
|
Image.open(target).verify()
|
||||||
|
got += 1
|
||||||
|
except Exception as exc:
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
failed += 1
|
||||||
|
if failed == 1: # nicht pro Bild jammern, einmal genügt
|
||||||
|
print(f" Fehler: {type(exc).__name__}: {exc}", flush=True)
|
||||||
|
time.sleep(args.delay)
|
||||||
|
|
||||||
|
note = f", {failed} fehlgeschlagen" if failed else ""
|
||||||
|
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: {got} Bilder{note}", flush=True)
|
||||||
|
time.sleep(args.delay)
|
||||||
|
|
||||||
|
|
||||||
|
def measure(args) -> int:
|
||||||
|
habitats = set(args.habitats.split(",")) if args.habitats else None
|
||||||
|
pipeline = Pipeline(min_bird_score=args.min_bird_score, habitats=habitats)
|
||||||
|
by_slug = {s.slug: s for s in SPECIES}
|
||||||
|
|
||||||
|
total = correct = top3 = no_bird = undetermined = 0
|
||||||
|
per_species: dict[str, list[bool]] = defaultdict(list)
|
||||||
|
confusion: Counter = Counter()
|
||||||
|
|
||||||
|
# Zwei Quellen: die Referenzfotos (ein Bild je Art, Dateiname = Slug) oder
|
||||||
|
# die Commons-Kategorien (ein Ordner je Art, mehrere Bilder).
|
||||||
|
if args.referenz:
|
||||||
|
ref = Path(args.referenz)
|
||||||
|
groups = [
|
||||||
|
(by_slug[p.stem], [p])
|
||||||
|
for p in sorted(ref.glob("*.jpg"))
|
||||||
|
if p.stem in by_slug
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
folders = sorted(p for p in DATA.iterdir() if p.is_dir()) if DATA.exists() else []
|
||||||
|
groups = [
|
||||||
|
(by_slug[f.name], sorted(f.glob("*.jpg")))
|
||||||
|
for f in folders
|
||||||
|
if f.name in by_slug
|
||||||
|
]
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
print("Keine Bilder. Erst mit --hole laden (oder --referenz angeben).")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for truth, paths in groups:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("RGB")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total += 1
|
||||||
|
box, result = pipeline.run(img)
|
||||||
|
if box is None:
|
||||||
|
no_bird += 1
|
||||||
|
per_species[truth.slug].append(False)
|
||||||
|
continue
|
||||||
|
if result is None or not result.guesses:
|
||||||
|
undetermined += 1
|
||||||
|
per_species[truth.slug].append(False)
|
||||||
|
continue
|
||||||
|
|
||||||
|
names = [g.species.slug for g in result.guesses[:3]]
|
||||||
|
hit = names[0] == truth.slug
|
||||||
|
correct += hit
|
||||||
|
top3 += truth.slug in names
|
||||||
|
per_species[truth.slug].append(hit)
|
||||||
|
if not hit:
|
||||||
|
confusion[(truth.german, result.guesses[0].species.german)] += 1
|
||||||
|
|
||||||
|
if not total:
|
||||||
|
print("Keine auswertbaren Bilder gefunden.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"\n{'='*62}\nERGEBNIS ({total} Bilder, {len(groups)} Arten)\n{'='*62}")
|
||||||
|
print(f" Richtig bestimmt (Top 1) : {correct:>4} / {total} {correct/total:>6.1%}")
|
||||||
|
print(f" Unter den Top 3 : {top3:>4} / {total} {top3/total:>6.1%}")
|
||||||
|
print(f" Kein Vogel gefunden : {no_bird:>4} / {total} {no_bird/total:>6.1%}")
|
||||||
|
print(f" Als unbestimmt verworfen : {undetermined:>4} / {total} {undetermined/total:>6.1%}")
|
||||||
|
|
||||||
|
rows = sorted(
|
||||||
|
((slug, sum(v), len(v)) for slug, v in per_species.items()),
|
||||||
|
key=lambda r: (r[1] / r[2] if r[2] else 0, r[2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n{'-'*62}\nSchwächste Arten\n{'-'*62}")
|
||||||
|
for slug, hits, n in rows[:12]:
|
||||||
|
bar = "█" * round(12 * hits / n) if n else ""
|
||||||
|
print(f" {by_slug[slug].german:<22} {hits:>2}/{n:<3} {hits/n:>6.0%} {bar}")
|
||||||
|
|
||||||
|
print(f"\n{'-'*62}\nStärkste Arten\n{'-'*62}")
|
||||||
|
for slug, hits, n in rows[-8:][::-1]:
|
||||||
|
bar = "█" * round(12 * hits / n) if n else ""
|
||||||
|
print(f" {by_slug[slug].german:<22} {hits:>2}/{n:<3} {hits/n:>6.0%} {bar}")
|
||||||
|
|
||||||
|
if confusion:
|
||||||
|
print(f"\n{'-'*62}\nHäufigste Verwechslungen\n{'-'*62}")
|
||||||
|
for (truth, guess), n in confusion.most_common(12):
|
||||||
|
print(f" {truth:<22} → {guess:<22} {n:>3}×")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Genauigkeit der Arterkennung messen")
|
||||||
|
p.add_argument("--hole", action="store_true", help="Testbilder von Commons laden")
|
||||||
|
p.add_argument("--pro-art", type=int, default=8)
|
||||||
|
p.add_argument("--delay", type=float, default=1.5,
|
||||||
|
help="Pause zwischen Abrufen. Wikimedia drosselt sonst.")
|
||||||
|
p.add_argument("--min-bird-score", type=float, default=0.05)
|
||||||
|
p.add_argument("--referenz", help="Ordner mit <slug>.jpg statt der Commons-Kategorien")
|
||||||
|
p.add_argument("--habitats", help="Lebensräume kommagetrennt, z.B. 'garten'")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
if args.hole:
|
||||||
|
fetch(args)
|
||||||
|
return 0
|
||||||
|
return measure(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Holt die beiden Modelle. Sie liegen nicht im Repo — zusammen rund 10 MB, und
|
||||||
|
# sie ändern sich nie.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/models"
|
||||||
|
BASE="https://github.com/google-coral/test_data/raw/master"
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
for f in \
|
||||||
|
mobilenet_v2_1.0_224_inat_bird_quant.tflite \
|
||||||
|
inat_bird_labels.txt \
|
||||||
|
ssd_mobilenet_v2_coco_quant_postprocess.tflite \
|
||||||
|
coco_labels.txt
|
||||||
|
do
|
||||||
|
if [[ -s "$DIR/$f" ]]; then
|
||||||
|
echo "vorhanden: $f"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "lade: $f"
|
||||||
|
curl -fsSL -o "$DIR/$f" "$BASE/$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Fertig. Modelle liegen in $DIR"
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Holt drei Beispielfotos als Vorlage fuer 'detector karte' und 'detector test'.
|
||||||
|
#
|
||||||
|
# Sie liegen nicht im Repo, und zwar nicht aus Platzgruenden: Fotos von
|
||||||
|
# Wikimedia Commons stehen unter eigenen Lizenzen (meist CC BY-SA) und
|
||||||
|
# verlangen Namensnennung. Ein Bild ohne seine Herkunft ins Repo zu legen
|
||||||
|
# waere genau der Fehler, den die Lizenz verhindern soll — deshalb kommen sie
|
||||||
|
# samt Urheber und Lizenz hierher, nachvollziehbar in HERKUNFT.txt.
|
||||||
|
#
|
||||||
|
# bash scripts/fetch_testdata.sh
|
||||||
|
#
|
||||||
|
# ⚠️ Wikimedia drosselt hart (HTTP 429). Deshalb die Pause zwischen den
|
||||||
|
# Abrufen; wer sie herausnimmt, steht fuer Minuten in der Strafbank.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/testdata"
|
||||||
|
API="https://de.wikipedia.org/w/api.php"
|
||||||
|
ABSTAND=2
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
|
||||||
|
# Artikelname → Dateiname. Die Arten sind so gewaehlt, dass der Detektor sie
|
||||||
|
# sicher findet: gross im Bild, freigestellt, typische Haltung.
|
||||||
|
ARTEN="Amsel:amsel Kohlmeise:kohlmeise Rotkehlchen:rotkehlchen"
|
||||||
|
|
||||||
|
: > "$DIR/HERKUNFT.txt"
|
||||||
|
cat >> "$DIR/HERKUNFT.txt" <<'KOPF'
|
||||||
|
Beispielfotos fuer Vogelalbum, geholt mit scripts/fetch_testdata.sh.
|
||||||
|
|
||||||
|
Diese Dateien gehoeren NICHT zum AGPL-lizenzierten Teil des Projekts. Jedes
|
||||||
|
Bild behaelt die Lizenz, unter der es bei Wikimedia Commons steht. Wer eines
|
||||||
|
davon weitergibt, nennt Urheber und Lizenz mit.
|
||||||
|
|
||||||
|
KOPF
|
||||||
|
|
||||||
|
for eintrag in $ARTEN; do
|
||||||
|
artikel="${eintrag%%:*}"
|
||||||
|
name="${eintrag##*:}"
|
||||||
|
ziel="$DIR/$name.jpg"
|
||||||
|
|
||||||
|
if [[ -s "$ziel" ]]; then
|
||||||
|
echo "vorhanden: $name.jpg"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "lade: $name.jpg ($artikel)"
|
||||||
|
|
||||||
|
# Erst das Lead-Bild des Artikels ermitteln, dann dessen Metadaten holen.
|
||||||
|
# Beides in einem Aufruf — pageimages gibt den Dateinamen, imageinfo die
|
||||||
|
# Lizenz und den Urheber.
|
||||||
|
antwort=$(curl -sS --get "$API" \
|
||||||
|
--data-urlencode "action=query" \
|
||||||
|
--data-urlencode "format=json" \
|
||||||
|
--data-urlencode "formatversion=2" \
|
||||||
|
--data-urlencode "prop=pageimages" \
|
||||||
|
--data-urlencode "piprop=thumbnail|name" \
|
||||||
|
--data-urlencode "pithumbsize=1200" \
|
||||||
|
--data-urlencode "titles=$artikel" \
|
||||||
|
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)")
|
||||||
|
|
||||||
|
# Zwei Zeilen: Bildadresse und Dateiname.
|
||||||
|
#
|
||||||
|
# 🔑 Den Dateinamen NICHT aus der Adresse schneiden. Dort steht er
|
||||||
|
# prozentkodiert ("Robin_%28950...%29.jpg"), und die API weist einen
|
||||||
|
# Seitentitel mit "%28" als ungueltig ab — beim Rotkehlchen genau so
|
||||||
|
# passiert, waehrend Amsel und Kohlmeise mangels Sonderzeichen
|
||||||
|
# funktionierten. Das Feld "pageimage" liefert ihn unkodiert.
|
||||||
|
gelesen=$(printf '%s' "$antwort" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
seiten = d.get("query", {}).get("pages", [])
|
||||||
|
s = seiten[0] if seiten else {}
|
||||||
|
print(s.get("thumbnail", {}).get("source", ""))
|
||||||
|
print(s.get("pageimage", ""))
|
||||||
|
')
|
||||||
|
url=$(printf '%s' "$gelesen" | sed -n 1p)
|
||||||
|
datei=$(printf '%s' "$gelesen" | sed -n 2p)
|
||||||
|
|
||||||
|
if [[ -z "$url" || -z "$datei" ]]; then
|
||||||
|
echo " FEHLER: kein Bild zu '$artikel' gefunden — uebersprungen" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
curl -sSL --fail -o "$ziel" "$url" \
|
||||||
|
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)"
|
||||||
|
|
||||||
|
sleep "$ABSTAND"
|
||||||
|
|
||||||
|
meta=$(curl -sS --get "$API" \
|
||||||
|
--data-urlencode "action=query" \
|
||||||
|
--data-urlencode "format=json" \
|
||||||
|
--data-urlencode "formatversion=2" \
|
||||||
|
--data-urlencode "prop=imageinfo" \
|
||||||
|
--data-urlencode "iiprop=extmetadata|url" \
|
||||||
|
--data-urlencode "titles=Datei:$datei" \
|
||||||
|
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)")
|
||||||
|
|
||||||
|
printf '%s' "$meta" | python3 -c '
|
||||||
|
import json, re, sys
|
||||||
|
name = sys.argv[1]
|
||||||
|
d = json.load(sys.stdin)
|
||||||
|
seiten = d.get("query", {}).get("pages", [])
|
||||||
|
info = (seiten[0].get("imageinfo") or [{}])[0] if seiten else {}
|
||||||
|
m = info.get("extmetadata", {})
|
||||||
|
|
||||||
|
def feld(schluessel):
|
||||||
|
wert = m.get(schluessel, {}).get("value", "")
|
||||||
|
wert = re.sub(r"<[^>]+>", " ", wert)
|
||||||
|
wert = re.sub(r"\s+", " ", wert).strip() # HTML aus der Angabe nehmen
|
||||||
|
return wert or "unbekannt"
|
||||||
|
|
||||||
|
urheber = feld("Artist")
|
||||||
|
lizenz = feld("LicenseShortName")
|
||||||
|
print(name + ".jpg")
|
||||||
|
print(" Urheber: " + urheber)
|
||||||
|
print(" Lizenz: " + lizenz)
|
||||||
|
print(" Quelle: " + str(info.get("descriptionurl", "unbekannt")))
|
||||||
|
print()
|
||||||
|
if "unbekannt" in (urheber, lizenz):
|
||||||
|
# Namensnennung ist Lizenzbedingung. Ein stilles "unbekannt" waere der
|
||||||
|
# Fehler, den diese Datei verhindern soll.
|
||||||
|
sys.stderr.write(" WARNUNG: Urheber oder Lizenz fuer " + name
|
||||||
|
+ " nicht ermittelt — bitte von Hand nachtragen\\n")
|
||||||
|
sys.exit(3)
|
||||||
|
' "$name" >> "$DIR/HERKUNFT.txt"
|
||||||
|
|
||||||
|
sleep "$ABSTAND"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Beispielfotos liegen in $DIR"
|
||||||
|
echo "Urheber und Lizenz: $DIR/HERKUNFT.txt"
|
||||||