Vogelalbum 1.0.0

Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
This commit is contained in:
christian committed 2026-10-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

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

After

Width:  |  Height:  |  Size: 521 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+160
View File
@@ -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.
+49
View File
@@ -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>
+89
View File
@@ -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.
+74
View File
@@ -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.
+77
View File
@@ -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.
+138
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

+146
View File
@@ -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.
+200
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Executable
+47
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(vogelkamera)
+150
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -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
)
+124
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
dependencies:
espressif/esp32-camera: "^2.0.11"
idf:
version: ">=5.1"
+453
View File
@@ -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;
}
+116
View File
@@ -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);
File diff suppressed because it is too large. Load diff
+54
View File
@@ -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)"
+16
View File
@@ -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
1 # Zwei App-Bereiche, damit sich das Geraet ueber WLAN aktualisieren kann.
2 #
3 # 🔑 Grundsatz in diesem Projekt: JEDES Geraet muss OTA koennen. Diese Tabelle ist die
4 # Voraussetzung dafuer, und sie gehoert von Anfang an so angelegt -- sie
5 # nachtraeglich zu aendern kostet genau das, was OTA vermeiden soll: einen
6 # Gang zum Geraet mit dem Kabel. (Am 23.09.2026 genau deshalb nachgeholt.)
7 #
8 # Bei 16 MB Flash ist das muehelos: zwei mal 4 MB App, der Rest bleibt frei.
9 # Das Abbild liegt bei knapp 1 MB, also ist reichlich Luft.
10 #
11 # Name, Type, SubType, Offset, Size
12 nvs, data, nvs, 0x9000, 0x6000
13 otadata, data, ota, 0xf000, 0x2000
14 phy_init, data, phy, 0x11000, 0x1000
15 ota_0, app, ota_0, 0x20000, 4M
16 ota_1, app, ota_1, , 4M
+60
View File
@@ -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
+121
View File
@@ -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.
+515
View File
@@ -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())
+160
View File
@@ -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.
+436
View File
@@ -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)
+26
View File
@@ -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
+5
View File
@@ -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
+8
View File
@@ -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
+261
View File
@@ -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())
+24
View File
@@ -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"
+131
View File
@@ -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"
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more