commit c11fe9ab04d8ade7f48b24e4991998ca5d1bbf5f Author: Christian Gärtner Date: Wed Oct 14 13:50:19 2026 +0000 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4214e2f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.beispiel b/.env.beispiel new file mode 100644 index 0000000..f44388e --- /dev/null +++ b/.env.beispiel @@ -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://: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 diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.gitea/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..74fc8ba --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.gitea/ISSUE_TEMPLATE/falsch-bestimmt.md b/.gitea/ISSUE_TEMPLATE/falsch-bestimmt.md new file mode 100644 index 0000000..d3b504c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/falsch-bestimmt.md @@ -0,0 +1,48 @@ +--- +name: "🐦 Art falsch bestimmt" +about: "Der Vogel war ein anderer, als Vogelalbum sagt" +title: "" +labels: + - erkennung +--- + + + +## Welche Art war es wirklich + +## Was Vogelalbum gesagt hat + + + +## Das Bild + + + +## 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 | + + diff --git a/.gitea/ISSUE_TEMPLATE/fehler.md b/.gitea/ISSUE_TEMPLATE/fehler.md new file mode 100644 index 0000000..56320d2 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/fehler.md @@ -0,0 +1,60 @@ +--- +name: "🐞 Fehler melden" +about: "Etwas tut nicht, was es soll" +title: "" +labels: + - fehler +--- + + + +## Was passiert + + + +## 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 + + + +``` +``` diff --git a/.gitea/ISSUE_TEMPLATE/idee.md b/.gitea/ISSUE_TEMPLATE/idee.md new file mode 100644 index 0000000..6e857b0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/idee.md @@ -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 + + + +## Wie du es heute löst + + + +## Würdest du daran mitarbeiten? + +- [ ] Ja, ich würde es selbst bauen +- [ ] Ja, beim Testen +- [ ] Nein, ich melde es nur + + diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b04c934 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## Was sich ändert + +## Warum + + + +## 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/` + + + +## Bezug + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e12a3e0 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7586f59 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..84d1ab9 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2861a33 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.detector b/Dockerfile.detector new file mode 100644 index 0000000..3693e20 --- /dev/null +++ b/Dockerfile.detector @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..199ddec --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +
+ +# 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) + +
+ +> **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. + +
+Die Startseite +Die Abzeichen +
+ +## 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 . 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: . + +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). + +--- + +
+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. +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4e0dc15 --- /dev/null +++ b/SECURITY.md @@ -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 .** 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 diff --git a/deploy/COOLIFY.md b/deploy/COOLIFY.md new file mode 100644 index 0000000..1cb55c5 --- /dev/null +++ b/deploy/COOLIFY.md @@ -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. diff --git a/deploy/Dockerfile.nginx b/deploy/Dockerfile.nginx new file mode 100644 index 0000000..7aa50fa --- /dev/null +++ b/deploy/Dockerfile.nginx @@ -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 diff --git a/deploy/nginx-media.conf b/deploy/nginx-media.conf new file mode 100644 index 0000000..5035ae6 --- /dev/null +++ b/deploy/nginx-media.conf @@ -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; + } +} diff --git a/deploy/vogelalbum-detector.service b/deploy/vogelalbum-detector.service new file mode 100644 index 0000000..b143d06 --- /dev/null +++ b/deploy/vogelalbum-detector.service @@ -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= +# 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-.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 diff --git a/deploy/vogelalbum-detector@.service b/deploy/vogelalbum-detector@.service new file mode 100644 index 0000000..8ffe0fb --- /dev/null +++ b/deploy/vogelalbum-detector@.service @@ -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-.env (chmod 600, root:root): +# VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream +# VOGELALBUM_API=http://sammlung.local:8000 +# VOGELALBUM_TOKEN= +# VOGELALBUM_SAVE_TO=/var/lib/vogelalbum//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 diff --git a/deploy/vogelalbum-ha.env.beispiel b/deploy/vogelalbum-ha.env.beispiel new file mode 100644 index 0000000..d13c08b --- /dev/null +++ b/deploy/vogelalbum-ha.env.beispiel @@ -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= diff --git a/deploy/vogelalbum-ha.service b/deploy/vogelalbum-ha.service new file mode 100644 index 0000000..423d2f9 --- /dev/null +++ b/deploy/vogelalbum-ha.service @@ -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 diff --git a/deploy/vogelalbum-stoerungen.service b/deploy/vogelalbum-stoerungen.service new file mode 100644 index 0000000..c91fcc1 --- /dev/null +++ b/deploy/vogelalbum-stoerungen.service @@ -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 diff --git a/deploy/vogelalbum-stoerungen.timer b/deploy/vogelalbum-stoerungen.timer new file mode 100644 index 0000000..e04f3ab --- /dev/null +++ b/deploy/vogelalbum-stoerungen.timer @@ -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 diff --git a/deploy/vogelalbum-warteschlange.service b/deploy/vogelalbum-warteschlange.service new file mode 100644 index 0000000..29211a1 --- /dev/null +++ b/deploy/vogelalbum-warteschlange.service @@ -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//). 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 < +# VOGELALBUM_TOKEN= +# VOGELALBUM_LESE_TOKEN= +# 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 diff --git a/detector/__init__.py b/detector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/detector/__main__.py b/detector/__main__.py new file mode 100644 index 0000000..ae7f47e --- /dev/null +++ b/detector/__main__.py @@ -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_ und faellt auf das alte VOGELALBUM_ 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://:{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()) diff --git a/detector/classify.py b/detector/classify.py new file mode 100644 index 0000000..2ce858e --- /dev/null +++ b/detector/classify.py @@ -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 diff --git a/detector/daylight.py b/detector/daylight.py new file mode 100644 index 0000000..7ebf06a --- /dev/null +++ b/detector/daylight.py @@ -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" diff --git a/detector/fernkonfig.py b/detector/fernkonfig.py new file mode 100644 index 0000000..d784a80 --- /dev/null +++ b/detector/fernkonfig.py @@ -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 diff --git a/detector/karte.py b/detector/karte.py new file mode 100644 index 0000000..693a21f --- /dev/null +++ b/detector/karte.py @@ -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.") diff --git a/detector/motion.py b/detector/motion.py new file mode 100644 index 0000000..9f56567 --- /dev/null +++ b/detector/motion.py @@ -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()) diff --git a/detector/profiles/__init__.py b/detector/profiles/__init__.py new file mode 100644 index 0000000..c37b87e --- /dev/null +++ b/detector/profiles/__init__.py @@ -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"] diff --git a/detector/profiles/basis.py b/detector/profiles/basis.py new file mode 100644 index 0000000..e9d2694 --- /dev/null +++ b/detector/profiles/basis.py @@ -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 diff --git a/detector/profiles/gaeste.py b/detector/profiles/gaeste.py new file mode 100644 index 0000000..08f08cb --- /dev/null +++ b/detector/profiles/gaeste.py @@ -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 diff --git a/detector/profiles/land.py b/detector/profiles/land.py new file mode 100644 index 0000000..2b843df --- /dev/null +++ b/detector/profiles/land.py @@ -0,0 +1,1014 @@ +"""Steckbriefe: Singvögel, Greifvögel, Eulen und Exoten des Landes. + +Arten, die nicht ans Wasser gebunden sind. Die meisten wird die Kamera im +Vogelhaus nie sehen — der Katalog soll trotzdem vollständig sein. +""" + +from __future__ import annotations + +from .basis import Zusatz + +# fmt: off +PROFILE: dict[str, Zusatz] = { + +"fichtenkreuzschnabel": Zusatz( + kategorie="Der mit dem verdrehten Schnabel", + nahrung="Fichtensamen aus geschlossenen Zapfen", + lebensraum="Fichtenwälder, wandert dem Samenangebot hinterher", + stimme="Ein hartes, metallisches „gip-gip“ aus der Baumkrone", + text=( + "Männchen ziegelrot, Weibchen olivgrün, beide mit dem namensgebenden " + "Schnabel: Ober- und Unterschnabel überkreuzen sich an der Spitze." + "\n\n" + "Diese Zange hebelt Fichtenzapfen auf, an die sonst niemand herankommt. " + "Weil Zapfen im Winter reifen, brütet er als einziger heimischer Vogel " + "regelmäßig im Januar bei Frost — die Nahrung ist dann am reichlichsten. " + "Ob ein Vogel links- oder rechtsgekreuzt ist, liegt fest wie Händigkeit " + "beim Menschen." + ), + kategorie_en="The One With the Crossed Bill", + nahrung_en="Spruce seeds prised from closed cones", + lebensraum_en="Spruce forest; wanders in search of cone crops", + stimme_en="A hard metallic “gip-gip” from the treetops", + text_en=( + "Males brick red, females olive green, both with the bill that names them: " + "the mandibles cross at the tip." + "\n\n" + "That pair of pliers levers open spruce cones nothing else can reach. " + "Because cones ripen in winter, it is the only bird here that regularly " + "breeds in January in hard frost — that is when food is most abundant. " + "Whether a bird crosses left or right is fixed, like handedness in people." + ), +), + +"rohrammer": Zusatz( + kategorie="Der Schilfsänger", + nahrung="Samen, im Sommer Insekten", + lebensraum="Schilfgürtel, feuchte Gräben, Verlandungszonen", + stimme="Ein stockendes, holpriges „tsi-tsi-tsi-tsississ“ vom Schilfhalm", + text=( + "Das Männchen hat im Frühjahr einen schwarzen Kopf mit weißem Bartstreif " + "und weißem Halsring, der Rest ist braun gestreift. Weibchen tragen einen " + "braun gestrichelten Kopf." + "\n\n" + "Er singt von einem einzelnen Schilfhalm herab, der sich unter ihm biegt. " + "Sein Gesang gilt unter Vogelkundlern als der unmusikalischste heimische " + "Vogelgesang überhaupt — er klingt, als würde jemand die Melodie immer " + "wieder vergessen und neu ansetzen." + ), + kategorie_en="The Reedbed Singer", + nahrung_en="Seeds; insects in summer", + lebensraum_en="Reedbeds, wet ditches, marsh edges", + stimme_en="A halting, stumbling “tsee-tsee-tsee-tsississ” from a reed stem", + text_en=( + "In spring the male has a black head with a white moustache and collar, " + "the rest streaky brown. Females have a streaked brown head." + "\n\n" + "It sings from a single reed stem that bends beneath it. Its song is widely " + "considered the least musical of any bird here — it sounds like someone " + "forgetting the tune and starting over, again and again." + ), +), + +"grauammer": Zusatz( + kategorie="Der Schlüsselbund", + nahrung="Getreidekörner, Samen, Insekten", + lebensraum="Weite, offene Ackerlandschaften ohne Hecken", + stimme="Ein Klirren wie ein fallender Schlüsselbund — unverwechselbar", + text=( + "Die größte heimische Ammer und zugleich die unscheinbarste: durchgehend " + "graubraun gestrichelt, ohne jedes Farbmerkmal. Man erkennt sie am " + "Verhalten — sie singt von Zaunpfählen und Leitungsdrähten und lässt beim " + "Abflug die Beine hängen." + "\n\n" + "Ihr Gesang klingt exakt wie ein zu Boden fallender Schlüsselbund und ist " + "damit einer der am leichtesten zu merkenden überhaupt. Sie braucht große, " + "strukturarme Ackerflächen und ist dort verschwunden, wo die Landschaft " + "zerschnitten wurde." + ), + kategorie_en="The Jangling Keys", + nahrung_en="Grain, seeds, insects", + lebensraum_en="Wide open arable land without hedges", + stimme_en="A jangle like dropped keys — unmistakable", + text_en=( + "The largest bunting here and also the plainest: uniformly streaky " + "grey-brown with no colour at all. You know it by behaviour — it sings from " + "fence posts and wires and dangles its legs as it flies off." + "\n\n" + "The song sounds exactly like a bunch of keys hitting the floor, which " + "makes it one of the easiest to remember. It needs large, open arable " + "expanses and has vanished where the landscape was cut up." + ), +), + +"blaukehlchen": Zusatz( + kategorie="Das blaue Juwel im Schilf", + nahrung="Insekten, Schnecken, im Herbst Beeren", + lebensraum="Feuchtgebiete mit dichtem Bewuchs, Gräben, Weidengebüsch", + stimme="Ein beschleunigendes „zip-zip-zip“, dazu Imitationen anderer Arten", + text=( + "Das Männchen trägt im Frühjahr eine leuchtend blaue Kehle mit einem " + "weißen oder rostroten Stern in der Mitte — bei uns fast immer weiß. " + "Weibchen haben nur eine Andeutung davon." + "\n\n" + "Es lebt versteckt am Boden und zeigt sich meist nur beim Singen. Sein " + "Gesang enthält Imitationen von einem Dutzend anderer Arten, wechselnd von " + "Vogel zu Vogel. Nach jahrzehntelangem Rückgang nimmt der Bestand wieder " + "zu — Renaturierungen von Auen zahlen sich bei ihm messbar aus." + ), + kategorie_en="The Blue Jewel in the Reeds", + nahrung_en="Insects, snails, berries in autumn", + lebensraum_en="Wetlands with dense cover, ditches, willow scrub", + stimme_en="An accelerating “zip-zip-zip” mixed with mimicry", + text_en=( + "In spring the male wears a brilliant blue throat with a white or rufous " + "star at its centre — almost always white here. Females show only a hint " + "of it." + "\n\n" + "It lives hidden near the ground and mostly shows itself while singing. " + "The song contains imitations of a dozen other species, varying from bird " + "to bird. After decades of decline it is recovering — floodplain " + "restoration pays off measurably for this species." + ), +), + +"braunkehlchen": Zusatz( + kategorie="Der Wiesenvogel", + nahrung="Insekten, von einer Warte aus erspäht", + lebensraum="Extensiv genutzte Feuchtwiesen mit Hochstauden", + stimme="Ein kurzes, schmatzendes „tek-tek“ mit vorangehendem Pfiff", + text=( + "Orangebraune Brust, kräftiger weißer Überaugenstreif, dunkle Wangen. Es " + "sitzt auf Stängeln und Zaunpfählen und wippt mit dem Schwanz." + "\n\n" + "Es brütet am Boden in hoher Wiesenvegetation und braucht dafür Flächen, " + "die vor Mitte Juli nicht gemäht werden. Genau das ist selten geworden: " + "Das Braunkehlchen ist einer der am stärksten zurückgegangenen Brutvögel " + "Deutschlands und in mehreren Bundesländern vom Aussterben bedroht." + ), + kategorie_en="The Meadow Bird", + nahrung_en="Insects, spotted from a perch", + lebensraum_en="Extensively managed damp meadows with tall herbs", + stimme_en="A short clicking “tek-tek” preceded by a whistle", + text_en=( + "An orange breast, a bold white eyebrow and dark cheeks. It perches on " + "stems and fence posts and flicks its tail." + "\n\n" + "It nests on the ground in tall meadow vegetation and needs fields that are " + "not cut before mid-July. That has become rare: the Whinchat is among the " + "most sharply declining breeding birds in Germany and is critically " + "endangered in several federal states." + ), +), + +"schwarzkehlchen": Zusatz( + kategorie="Der Zaunpfahlsitzer", + nahrung="Insekten und Spinnen vom Boden", + lebensraum="Heiden, Brachen, Bahndämme, Straßenränder", + stimme="Ein hartes „tsak-tsak“, wie zwei aneinandergeschlagene Kiesel", + text=( + "Das Männchen hat einen tiefschwarzen Kopf, einen weißen Halsfleck und " + "eine orangerote Brust. Es sitzt gern exponiert auf der obersten Spitze " + "eines Busches und dreht sich dabei ständig." + "\n\n" + "Anders als das Braunkehlchen nimmt es zu und breitet sich nach Norden " + "aus — milde Winter helfen ihm, weil es teilweise hierbleibt. Zwei nah " + "verwandte Arten, gleicher Lebensraum, gegenläufige Entwicklung: Das eine " + "hängt am Mähtermin, das andere am Klima." + ), + kategorie_en="The Fence Post Sitter", + nahrung_en="Insects and spiders from the ground", + lebensraum_en="Heaths, fallow land, railway embankments, roadsides", + stimme_en="A hard “tsak-tsak”, like two pebbles knocked together", + text_en=( + "The male has a jet-black head, a white neck patch and an orange breast. " + "It likes an exposed perch on the very top of a bush and turns constantly." + "\n\n" + "Unlike the Whinchat it is increasing and spreading north — mild winters " + "help, because some birds now stay. Two close relatives, the same habitat, " + "opposite trajectories: one hangs on the mowing date, the other on climate." + ), +), + +"steinschmaetzer": Zusatz( + kategorie="Der Weltreisende", + nahrung="Insekten, im Laufen vom Boden gepickt", + lebensraum="Steinige Offenflächen, Dünen, Bergweiden, Kiesgruben", + stimme="Ein knirschendes „tschak“ wie zwei aneinander geriebene Steine", + text=( + "Grauer Rücken, schwarze Gesichtsmaske, heller Bauch — und der weiße " + "Bürzel mit dem schwarzen T auf dem Schwanz, der beim Wegfliegen " + "aufblitzt. Das ist das Kennzeichen." + "\n\n" + "Er hat die längste Zugstrecke aller Singvögel: Die Population aus Alaska " + "fliegt über Sibirien nach Ostafrika, rund 15.000 Kilometer pro Richtung. " + "Für einen Vogel von 25 Gramm ist das eine Leistung, die lange niemand " + "glauben wollte — bis Minisender es belegten." + ), + kategorie_en="The World Traveller", + nahrung_en="Insects picked from the ground while running", + lebensraum_en="Stony open ground, dunes, mountain pasture, gravel pits", + stimme_en="A grating “chack”, like two stones rubbed together", + text_en=( + "A grey back, a black facial mask, pale underparts — and the white rump " + "with a black T on the tail that flashes as it flies off. That is the mark." + "\n\n" + "It has the longest migration of any songbird: the Alaskan population flies " + "via Siberia to East Africa, some 15,000 kilometres each way. For a bird of " + "25 grams that is a feat nobody believed for a long time — until tiny " + "geolocators proved it." + ), +), + +"wasseramsel": Zusatz( + kategorie="Der tauchende Singvogel", + nahrung="Köcherfliegenlarven und andere Insekten vom Bachgrund", + lebensraum="Schnell fließende, saubere Bäche mit Steinen", + stimme="Ein schnarrendes „zerb“, das den Bach übertönt", + text=( + "Dunkelbraun mit einem weißen Latz, rundlich, mit kurzem aufgestelltem " + "Schwanz. Sie sitzt auf einem Stein mitten im Bach und knickst dabei " + "unablässig." + "\n\n" + "Sie ist der einzige tauchende Singvogel Europas. Zum Fressen läuft sie " + "gegen die Strömung auf dem Bachgrund entlang, teils eine halbe Minute " + "unter Wasser, und hält sich mit den Krallen fest. Sie kommt nur an " + "sauberen Bächen vor und gilt deshalb als Zeigerart für Gewässergüte." + ), + kategorie_en="The Diving Songbird", + nahrung_en="Caddis larvae and other insects from the streambed", + lebensraum_en="Fast, clean streams with boulders", + stimme_en="A rasping “zit” that cuts through the noise of the water", + text_en=( + "Dark brown with a white bib, round-bodied, with a short cocked tail. It " + "perches on a boulder mid-stream and bobs without pause." + "\n\n" + "It is Europe's only diving songbird. To feed it walks upstream along the " + "streambed, sometimes for half a minute underwater, gripping with its " + "claws. It occurs only on clean streams and is therefore used as an " + "indicator of water quality." + ), +), + +"gebirgsstelze": Zusatz( + kategorie="Die Gelbe am Bach", + nahrung="Wasserinsekten, Larven, kleine Krebse", + lebensraum="Bäche und Flüsse mit Kiesbänken, oft im Mittelgebirge", + stimme="Ein schneidendes „zi-zit“, schärfer als bei der Bachstelze", + text=( + "Trotz des Namens: Der auffälligste Zug ist das leuchtende Gelb an Bauch " + "und Bürzel, dazu ein grauer Rücken und der längste Schwanz aller " + "heimischen Stelzen." + "\n\n" + "Sie ist an fließendes Wasser gebunden und läuft über nasse Steine, wobei " + "sie noch heftiger wippt als ihre Verwandten. Wer im Mittelgebirge an " + "einem Bach entlanggeht und einen gelben Vogel vor sich her fliegen sieht, " + "sieht meistens sie." + ), + kategorie_en="The Yellow One at the Stream", + nahrung_en="Aquatic insects, larvae, small crustaceans", + lebensraum_en="Streams and rivers with gravel banks, often in hill country", + stimme_en="A cutting “tzi-tzit”, sharper than a White Wagtail's", + text_en=( + "Despite the English name, the striking feature is the vivid yellow belly " + "and rump, set against a grey back — plus the longest tail of any wagtail " + "here." + "\n\n" + "It is tied to running water and runs across wet stones, wagging even more " + "vigorously than its relatives. Walk along a hill stream and see a yellow " + "bird flying ahead of you, and it is usually this one." + ), +), + +"schafstelze": Zusatz( + kategorie="Die Kuhbegleiterin", + nahrung="Insekten, oft im Gefolge von Weidevieh", + lebensraum="Feuchtwiesen, Weiden, Äcker", + stimme="Ein weiches, gezogenes „psrüh“", + text=( + "Leuchtend gelbe Unterseite, olivgrüner Rücken, kürzerer Schwanz als bei " + "den anderen Stelzen. Die Kopffärbung der Männchen wechselt von Region zu " + "Region — bei uns blaugrau, weiter östlich schwarz, in Skandinavien mit " + "weißem Überaugenstreif." + "\n\n" + "Sie folgt weidenden Kühen und Pferden und schnappt die Insekten, die diese " + "aufscheuchen. Genau daher der deutsche Name: Vor der Zeit der Rinder " + "waren es Schafherden." + ), + kategorie_en="The Cattle Follower", + nahrung_en="Insects, often in the wake of grazing livestock", + lebensraum_en="Damp meadows, pasture, arable land", + stimme_en="A soft, drawn-out “tsweep”", + text_en=( + "Bright yellow underparts, an olive back, and a shorter tail than the " + "other wagtails. The males' head colour varies by region — blue-grey here, " + "black further east, white-browed in Scandinavia." + "\n\n" + "It follows grazing cattle and horses, snapping up the insects they flush. " + "The German name means “sheep wagtail”: before cattle, it was flocks of " + "sheep." + ), +), + +"wiesenpieper": Zusatz( + kategorie="Der unauffällige Wiesenbewohner", + nahrung="Kleine Insekten und Spinnen vom Boden", + lebensraum="Feuchtwiesen, Moore, Heiden, Küstenmarschen", + stimme="Ein dünnes, wiederholtes „ist-ist-ist“ beim Auffliegen", + text=( + "Klein, olivbraun, fein gestrichelt, mit rosafarbenen Beinen. Ohne " + "Fernglas kaum von anderen Piepern zu trennen — mit Fernglas an den Beinen " + "und dem längeren Hinterzehennagel." + "\n\n" + "Er steigt zum Singen schräg auf und lässt sich mit ausgebreiteten Flügeln " + "wieder herabgleiten, ein Fallschirmflug. Er ist außerdem einer der " + "häufigsten Wirtsvögel des Kuckucks — kaum ein anderer zieht so viele " + "fremde Junge groß." + ), + kategorie_en="The Unassuming Meadow Dweller", + nahrung_en="Small insects and spiders from the ground", + lebensraum_en="Damp meadows, bogs, heaths, coastal marsh", + stimme_en="A thin, repeated “eest-eest-eest” as it flies up", + text_en=( + "Small, olive-brown, finely streaked, with pink legs. Barely separable " + "from other pipits without binoculars — with them, by the legs and the " + "long hind claw." + "\n\n" + "To sing it climbs at an angle and then parachutes back down on spread " + "wings. It is also one of the Cuckoo's commonest hosts — few other birds " + "raise so many foreign chicks." + ), +), + +"haubenlerche": Zusatz( + kategorie="Die Verlorene", + nahrung="Samen, Getreide, Insekten", + lebensraum="Brachflächen, Industriegebiete, Bahnanlagen, Parkplätze", + stimme="Ein weiches, melancholisch absteigendes „düi-düi“", + text=( + "Sandbraun, gestrichelt, mit einer spitzen, aufstellbaren Haube, die sie " + "von der Feldlerche unterscheidet. Gedrungener und kurzschwänziger als " + "diese." + "\n\n" + "Sie war einmal ein typischer Dorfvogel und lebte auf ungepflasterten " + "Wegen und Schuttflächen. Beides gibt es kaum noch. In Deutschland ist sie " + "vom Aussterben bedroht — der Bestand ist seit 1980 um über neunzig " + "Prozent gefallen, und die letzten Vorkommen liegen ausgerechnet auf " + "Industriebrachen." + ), + kategorie_en="The One We Are Losing", + nahrung_en="Seeds, grain, insects", + lebensraum_en="Waste ground, industrial estates, rail yards, car parks", + stimme_en="A soft, melancholy descending “dew-dee”", + text_en=( + "Sandy brown and streaked, with a pointed raisable crest that separates it " + "from the Skylark. Stockier and shorter-tailed than that species." + "\n\n" + "It was once a typical village bird, living on unpaved tracks and rubble. " + "Neither is common now. In Germany it is critically endangered — numbers " + "have fallen by over ninety per cent since 1980, and the last strongholds " + "are, of all places, industrial wasteland." + ), +), + +"raubwuerger": Zusatz( + kategorie="Der Winterjäger", + nahrung="Mäuse, kleine Vögel, große Insekten", + lebensraum="Offene Landschaft mit einzelnen Bäumen und Hecken", + stimme="Ein raues Schäckern, dazu erstaunlich gute Imitationen", + text=( + "Grau, weiß und schwarz, mit einer breiten schwarzen Augenmaske und " + "deutlich größer als der Neuntöter. Er sitzt auf der höchsten Spitze weit " + "und breit und ist dadurch auf Entfernung zu erkennen." + "\n\n" + "Als Singvogel mit Raubvogelverhalten schlägt er Mäuse und Kleinvögel und " + "spießt sie wie der Neuntöter auf Dornen. Er brütet in Deutschland nur " + "noch selten; die meisten Beobachtungen betreffen Wintergäste aus dem " + "Norden." + ), + kategorie_en="The Winter Hunter", + nahrung_en="Voles, small birds, large insects", + lebensraum_en="Open country with scattered trees and hedges", + stimme_en="A harsh chattering, plus surprisingly good mimicry", + text_en=( + "Grey, white and black, with a broad black mask and clearly larger than a " + "Red-backed Shrike. It perches on the highest point for miles, which makes " + "it recognisable at distance." + "\n\n" + "A songbird with a raptor's habits, it kills voles and small birds and " + "impales them on thorns just as its smaller cousin does. It now breeds only " + "rarely in Germany; most sightings are winter visitors from the north." + ), +), + +"alpendohle": Zusatz( + kategorie="Die Gipfelstürmerin", + nahrung="Insekten, Beeren — und alles, was Wanderer liegen lassen", + lebensraum="Hochgebirge oberhalb der Baumgrenze", + stimme="Ein helles, gezogenes „zrii“, das über Felswände hallt", + text=( + "Schwarz, schlank, mit gelbem Schnabel und roten Beinen. In den Alpen " + "gehört sie zu jeder Gipfelrast dazu und wartet in Gruppen auf Brotreste." + "\n\n" + "Sie brütet höher als jeder andere Vogel Europas — Nester wurden über " + "6.500 Metern am Mount Everest gefunden. Ihre Flugkünste an Felswänden " + "sind auch für Rabenvögel außergewöhnlich: Sie nutzt Aufwinde so präzise, " + "dass sie minutenlang ohne einen Flügelschlag an derselben Stelle steht." + ), + kategorie_en="The Summit Bird", + nahrung_en="Insects, berries — and whatever hikers leave behind", + lebensraum_en="High mountains above the treeline", + stimme_en="A bright, drawn-out “zrree” echoing off rock faces", + text_en=( + "Black and slim, with a yellow bill and red legs. In the Alps it is part of " + "every summit break, waiting in groups for crumbs." + "\n\n" + "It breeds higher than any other bird in Europe — nests have been found " + "above 6,500 metres on Everest. Its flying along rock faces is remarkable " + "even for a corvid: it uses updraughts so precisely that it can hold " + "position for minutes without a single wingbeat." + ), +), + +"uferschwalbe": Zusatz( + kategorie="Die Höhlenbrüterin", + nahrung="Fluginsekten über dem Wasser", + lebensraum="Steilwände aus Sand und Lehm, Kiesgruben, Flussufer", + stimme="Ein trockenes, schnurrendes Rätschen", + text=( + "Die kleinste heimische Schwalbe: braun oben, weiß unten, mit einem " + "braunen Brustband. Kein Rot, kein Blau — die schlichteste der drei." + "\n\n" + "Sie gräbt bis zu einen Meter tiefe Röhren in senkrechte Sandwände und " + "brütet in Kolonien von hunderten Paaren. Weil natürliche Steilufer " + "verbaut sind, hängt sie heute weitgehend an Kiesgruben — ihr Bestand " + "schwankt damit im Takt des Sandabbaus." + ), + kategorie_en="The Burrow Nester", + nahrung_en="Flying insects over water", + lebensraum_en="Sand and clay cliffs, gravel pits, riverbanks", + stimme_en="A dry, purring rattle", + text_en=( + "The smallest swallow here: brown above, white below, with a brown breast " + "band. No red, no blue — the plainest of the three." + "\n\n" + "It digs tunnels up to a metre deep into vertical sand faces and breeds in " + "colonies of hundreds of pairs. With natural cliffs largely engineered " + "away, it now depends on gravel pits — so its numbers rise and fall with " + "the sand extraction industry." + ), +), + +"wiedehopf": Zusatz( + kategorie="Der mit der Krone", + nahrung="Große Insekten, besonders Maulwurfsgrillen und Engerlinge", + lebensraum="Warme, offene Landschaft mit kurzem Gras und alten Bäumen", + stimme="Ein dumpfes, hohles „upupup“ — daher der wissenschaftliche Name", + text=( + "Orangebraun mit schwarzweiß gebänderten Flügeln, langem gebogenem " + "Schnabel und einer Federhaube, die er wie einen Fächer aufstellen kann. " + "Im Flug wirkt er wie ein riesiger Schmetterling." + "\n\n" + "Die Nesthöhle wird berüchtigt schmutzig gehalten, und die Weibchen " + "produzieren ein übelriechendes Sekret — beides schreckt Fressfeinde ab. " + "Er profitiert derzeit von wärmeren Sommern und kehrt in Gegenden zurück, " + "aus denen er lange verschwunden war." + ), + kategorie_en="The One With the Crown", + nahrung_en="Large insects, especially mole crickets and chafer grubs", + lebensraum_en="Warm open country with short grass and old trees", + stimme_en="A hollow “oop-oop-oop” — the source of its scientific name", + text_en=( + "Orange-buff with black-and-white barred wings, a long curved bill and a " + "crest it can raise like a fan. In flight it looks like an enormous " + "butterfly." + "\n\n" + "The nest hole is notoriously filthy and females produce a foul-smelling " + "secretion — both deter predators. It is currently benefiting from warmer " + "summers and returning to areas it had long abandoned." + ), +), + +"bienenfresser": Zusatz( + kategorie="Der bunteste Vogel Europas", + nahrung="Bienen, Wespen, Libellen — im Flug gefangen", + lebensraum="Warme Hänge mit Steilwänden, Weinberge, Kiesgruben", + stimme="Ein weiches, rollendes „prürp“, meist von hoch oben", + text=( + "Türkis, gelb, kastanienbraun — kein anderer europäischer Vogel trägt so " + "viele kräftige Farben gleichzeitig. Dazu spitze Flügel und verlängerte " + "mittlere Schwanzfedern." + "\n\n" + "Er fängt Bienen im Flug und schlägt sie an einem Ast, bis der Stachel " + "entleert ist — erst dann frisst er sie. Bis in die 1990er war er in " + "Deutschland eine Rarität; heute brüten mehrere tausend Paare hier, vor " + "allem in Sachsen-Anhalt und am Kaiserstuhl. Eine der wenigen Arten, die " + "der Klimawandel zu uns bringt." + ), + kategorie_en="Europe's Most Colourful Bird", + nahrung_en="Bees, wasps and dragonflies caught on the wing", + lebensraum_en="Warm slopes with earth cliffs, vineyards, gravel pits", + stimme_en="A soft rolling “prroop”, usually from high overhead", + text_en=( + "Turquoise, yellow and chestnut — no other European bird wears so many " + "strong colours at once. Add pointed wings and elongated central tail " + "feathers." + "\n\n" + "It catches bees in flight and beats them against a branch until the " + "venom sac is emptied before eating them. Until the 1990s it was a rarity " + "in Germany; several thousand pairs now breed here, mostly in " + "Saxony-Anhalt and the Kaiserstuhl. One of the few species climate change " + "is bringing us." + ), +), + +"blauracke": Zusatz( + kategorie="Die Verlorene aus dem Süden", + nahrung="Große Käfer, Heuschrecken, Eidechsen", + lebensraum="Warme Steppenlandschaft mit alten Bäumen", + stimme="Ein raues, krähendes „rack-rack“", + text=( + "Türkisblauer Kopf und Bauch, zimtbrauner Rücken, kräftiger schwarzer " + "Schnabel. Etwa dohlengroß und im Flug durch die leuchtend blauen " + "Flügelunterseiten unverwechselbar." + "\n\n" + "Sie ist in Deutschland ausgestorben — der letzte Brutnachweis stammt aus " + "den 1990ern in Brandenburg. Ihren Namen hat sie von den Rollen und " + "Sturzflügen im Balzflug. Wenn sie hier auftaucht, dann als verirrter " + "Durchzügler, und das ist dann eine bundesweite Meldung." + ), + kategorie_en="The One We Lost", + nahrung_en="Large beetles, grasshoppers, lizards", + lebensraum_en="Warm steppe-like country with old trees", + stimme_en="A harsh, crow-like “rack-rack”", + text_en=( + "A turquoise head and belly, a cinnamon back and a heavy black bill. About " + "jackdaw-sized and unmistakable in flight for the glowing blue underwings." + "\n\n" + "It is extinct as a breeding bird in Germany — the last confirmed nest was " + "in Brandenburg in the 1990s. The name comes from the rolls and tumbling " + "dives of its display flight. If one turns up here it is a lost migrant, " + "and that makes national news among birders." + ), +), + +"fasan": Zusatz( + kategorie="Der Eingebürgerte", + nahrung="Samen, Beeren, Insekten, Wurzeln", + lebensraum="Feldgehölze, Hecken, Ackerränder", + stimme="Ein explosives, zweisilbiges Krähen, gefolgt von Flügelschlagen", + text=( + "Der Hahn ist eine Erscheinung: kupferrot geschuppt, grünschwarzer Kopf, " + "roter Gesichtslappen, ein Schwanz von einem halben Meter. Die Henne ist " + "unscheinbar sandbraun." + "\n\n" + "Er stammt aus Asien und wurde vor Jahrhunderten zur Jagd eingebürgert. In " + "vielen Gegenden hält sich der Bestand nur, weil regelmäßig nachgesetzt " + "wird. Er fliegt selten und ungern, dann aber mit lautem Flügelklatschen " + "senkrecht nach oben — wer ihn beim Spaziergang aufscheucht, erschrickt " + "zuverlässig." + ), + kategorie_en="The Introduced One", + nahrung_en="Seeds, berries, insects, roots", + lebensraum_en="Copses, hedges, field margins", + stimme_en="An explosive two-note crow followed by whirring wings", + text_en=( + "The cock is a spectacle: copper-scaled body, bottle-green head, red face " + "wattles and a tail half a metre long. The hen is plain sandy brown." + "\n\n" + "It comes from Asia and was introduced centuries ago for shooting. In many " + "areas the population only persists because birds are released. It flies " + "rarely and reluctantly, but when it does it clatters vertically upward — " + "flush one on a walk and it will startle you every time." + ), +), + +# ---------- Greifvögel ---------- + +"raufussbussard": Zusatz( + kategorie="Der Rüttler aus der Tundra", + nahrung="Wühlmäuse, Lemminge im Brutgebiet", + lebensraum="Offene Feldfluren — bei uns nur im Winter", + stimme="Ein miauendes, klagendes Rufen, im Winter selten zu hören", + text=( + "Ähnlich einem Mäusebussard, aber heller, mit weißem Schwanz und breiter " + "schwarzer Endbinde. Die Beine sind bis zu den Zehen befiedert — daher " + "der Name." + "\n\n" + "Er brütet in der arktischen Tundra und erscheint bei uns nur als " + "Wintergast, in manchen Jahren häufiger als in anderen. Anders als der " + "Mäusebussard rüttelt er häufig, ähnlich wie ein Turmfalke, nur schwerer " + "und wuchtiger. Wie viele kommen, hängt am Lemmingzyklus im Norden." + ), + kategorie_en="The Hoverer from the Tundra", + nahrung_en="Voles; lemmings on the breeding grounds", + lebensraum_en="Open farmland — a winter visitor here", + stimme_en="A mewing, plaintive call, rarely heard in winter", + text_en=( + "Like a Common Buzzard but paler, with a white tail and a broad black " + "terminal band. The legs are feathered right down to the toes — hence the " + "name Rough-legged." + "\n\n" + "It breeds in the Arctic tundra and appears here only in winter, in some " + "years more than others. Unlike the Common Buzzard it hovers frequently, " + "much as a Kestrel does but heavier and more laboured. How many arrive " + "depends on the lemming cycle in the north." + ), +), + +"rohrweihe": Zusatz( + kategorie="Die Schilfjägerin", + nahrung="Wasservögel, Küken, Frösche, Mäuse", + lebensraum="Schilfgebiete, Verlandungszonen, Feuchtwiesen", + stimme="Ein hohes, klagendes „kii-äh“ über dem Schilf", + text=( + "Die größte Weihe, mit langen Flügeln und langem Schwanz. Das Männchen ist " + "dreifarbig — brauner Körper, graue Flügelmitte, schwarze Spitzen; das " + "Weibchen dunkelbraun mit cremefarbenem Kopf." + "\n\n" + "Sie gleitet dicht über dem Schilf mit V-förmig angehobenen Flügeln und " + "kippt bei der geringsten Bewegung ab. Bei der Balz übergeben sich die " + "Partner Beute in der Luft: Das Männchen lässt sie fallen, das Weibchen " + "dreht sich auf den Rücken und fängt sie." + ), + kategorie_en="The Reedbed Hunter", + nahrung_en="Waterbirds, chicks, frogs, voles", + lebensraum_en="Reedbeds, marsh edges, wet meadows", + stimme_en="A high, plaintive “kee-ah” over the reeds", + text_en=( + "The largest harrier, with long wings and a long tail. The male is " + "three-toned — brown body, grey mid-wing, black tips; the female dark brown " + "with a cream head." + "\n\n" + "It glides low over the reeds with wings held in a shallow V and tips over " + "at the slightest movement. In courtship the pair passes prey in mid-air: " + "the male drops it and the female rolls onto her back to catch it." + ), +), + +"kornweihe": Zusatz( + kategorie="Die Gaukelnde", + nahrung="Mäuse und Kleinvögel, tief fliegend erbeutet", + lebensraum="Offene Felder, Moore, Küstenheiden", + stimme="Am Brutplatz ein schnelles „kek-kek-kek“", + text=( + "Das Männchen ist hellgrau mit schwarzen Flügelspitzen und wirkt fast " + "weiß; das Weibchen braun gestreift mit weißem Bürzel — deshalb heißt es " + "im Englischen ringtail." + "\n\n" + "Sie jagt in niedrigem Suchflug, kaum zwei Meter über dem Boden, und " + "kippt in engen Wendungen hin und her. In Deutschland brütet sie fast nur " + "noch auf den Nordseeinseln; das Binnenland kennt sie als Wintergast über " + "abgeernteten Feldern." + ), + kategorie_en="The Low Glider", + nahrung_en="Voles and small birds, taken in low flight", + lebensraum_en="Open fields, bogs, coastal heath", + stimme_en="A rapid “kek-kek-kek” at the nest", + text_en=( + "The male is pale grey with black wingtips and looks almost white; the " + "female is streaked brown with a white rump — the classic ringtail." + "\n\n" + "It hunts in a low quartering flight barely two metres above the ground, " + "tilting back and forth in tight turns. In Germany it now breeds almost " + "only on the North Sea islands; inland it is a winter bird over stubble." + ), +), + +"fischadler": Zusatz( + kategorie="Der Stoßtaucher", + nahrung="Fisch, ausschließlich", + lebensraum="Fischreiche Seen und Flüsse mit hohen Horstbäumen", + stimme="Ein hohes, pfeifendes „kju-kju-kju“", + text=( + "Oben dunkelbraun, unten weiß, mit einem schwarzen Augenstreif und " + "gewinkelten Flügeln. Im Flug über Wasser ist die Silhouette " + "unverwechselbar." + "\n\n" + "Er stößt mit den Füßen voran ins Wasser und taucht dabei komplett unter. " + "Seine Zehen tragen Dornschuppen und eine Wendezehe, die nach hinten " + "klappt — damit greift er den Fisch von zwei Seiten und trägt ihn " + "stromlinienförmig mit dem Kopf nach vorn. In Deutschland brüten wieder " + "über 800 Paare, fast alle im Nordosten." + ), + kategorie_en="The Plunge Diver", + nahrung_en="Fish, exclusively", + lebensraum_en="Fish-rich lakes and rivers with tall nest trees", + stimme_en="A high whistling “kew-kew-kew”", + text_en=( + "Dark brown above, white below, with a black eye-stripe and kinked wings. " + "The silhouette over water is unmistakable." + "\n\n" + "It hits the water feet-first and submerges completely. Its toes carry " + "spiny scales and one reversible toe that swings backwards — so it grips " + "the fish from both sides and carries it head-first and streamlined. Over " + "800 pairs again breed in Germany, almost all in the north-east." + ), +), + +"steinadler": Zusatz( + kategorie="Der König der Alpen", + nahrung="Murmeltiere, Gämsen-Kitze, Hasen, Aas", + lebensraum="Hochgebirge mit Felswänden", + stimme="Ein überraschend schwaches Bellen — selten zu hören", + text=( + "Dunkelbraun mit goldenem Nacken, über zwei Meter Spannweite. Im Segelflug " + "hält er die Flügel leicht V-förmig und wirkt aus der Ferne wie ein " + "Bussard, bis man die Größenverhältnisse einordnet." + "\n\n" + "In Deutschland brütet er nur in den bayerischen Alpen, rund 50 Paare. Ein " + "Revier umfasst bis zu hundert Quadratkilometer. Dass er Lämmer oder gar " + "Kinder verschleppt, gehört ins Reich der Sagen — die Beute liegt fast " + "immer unter fünf Kilogramm." + ), + kategorie_en="King of the Alps", + nahrung_en="Marmots, chamois kids, hares, carrion", + lebensraum_en="High mountains with cliffs", + stimme_en="A surprisingly weak yelping — rarely heard", + text_en=( + "Dark brown with a golden nape and a wingspan over two metres. Soaring, it " + "holds its wings in a shallow V and looks like a buzzard from afar, until " + "you get the scale right." + "\n\n" + "In Germany it breeds only in the Bavarian Alps, around 50 pairs. A " + "territory can cover a hundred square kilometres. Stories of eagles " + "carrying off lambs or children belong to folklore — prey is almost always " + "under five kilograms." + ), +), + +"schlangenadler": Zusatz( + kategorie="Der Reptilienjäger", + nahrung="Schlangen und Eidechsen, fast ausschließlich", + lebensraum="Warme, offene Landschaft mit Waldinseln", + stimme="Ein melodisches, klagendes Pfeifen", + text=( + "Groß, hell, mit auffällig breitem Kopf und großen gelben Augen. Von " + "unten fast weiß mit dunkler Kehle — im Rüttelflug über einer Lichtung " + "wirkt er wie eine schwebende Eule." + "\n\n" + "Er lebt fast ausschließlich von Reptilien und schluckt Schlangen im " + "Ganzen, den Kopf voran. Die Jungen werden gefüttert, indem der Altvogel " + "die Schlange wieder hervorwürgt und das Junge sie am Schwanz aus dem " + "Schlund zieht. In Deutschland ist er nur ein sehr seltener Gast." + ), + kategorie_en="The Reptile Hunter", + nahrung_en="Snakes and lizards, almost exclusively", + lebensraum_en="Warm open country with patches of woodland", + stimme_en="A melodic, plaintive whistling", + text_en=( + "Large and pale, with a strikingly broad head and big yellow eyes. Almost " + "white below with a dark throat — hovering over a clearing it looks like a " + "floating owl." + "\n\n" + "It lives almost entirely on reptiles and swallows snakes whole, head " + "first. Chicks are fed by the adult regurgitating the snake so the young " + "can pull it out of the throat by the tail. In Germany it is only a very " + "rare visitor." + ), +), + +"gaensegeier": Zusatz( + kategorie="Der Segler ohne Flügelschlag", + nahrung="Aas großer Säugetiere", + lebensraum="Gebirge mit Thermik — bei uns nur als Irrgast", + stimme="Fauchen und Zischen am Kadaver, sonst stumm", + text=( + "Riesig, sandbraun, mit nacktem, weiß bedaunten Hals und einer " + "Spannweite von über zweieinhalb Metern. In der Luft ein Brett mit " + "gefingerten Enden." + "\n\n" + "Er segelt hunderte Kilometer, ohne mit den Flügeln zu schlagen, und " + "orientiert sich beim Suchen an anderen Geiern: Sinkt einer, folgen alle " + "in Sichtweite. In Deutschland taucht er alle paar Jahre auf, wenn junge " + "Vögel aus Spanien nach Norden verdriften und dann Schlagzeilen machen." + ), + kategorie_en="The Wingbeat-Free Soarer", + nahrung_en="Carrion of large mammals", + lebensraum_en="Mountains with thermals — only a vagrant here", + stimme_en="Hissing and snorting at a carcass, otherwise silent", + text_en=( + "Huge, sandy brown, with a bare white-downed neck and a wingspan over two " + "and a half metres. In the air, a plank with fingered ends." + "\n\n" + "It soars for hundreds of kilometres without flapping, and finds food by " + "watching other vultures: when one drops, every bird within sight follows. " + "It turns up in Germany every few years when young birds drift north from " + "Spain — and duly makes the news." + ), +), + +"baumfalke": Zusatz( + kategorie="Der Libellenjäger", + nahrung="Libellen, Schwalben, Mauersegler — alles im Flug", + lebensraum="Offene Landschaft mit Feldgehölzen, Gewässernähe", + stimme="Ein durchdringendes, wiederholtes „kju-kju-kju“", + text=( + "Klein und sichelflügelig wie ein großer Mauersegler, mit schwarzem " + "Bartstreif und rostroten „Hosen“ an den Schenkeln." + "\n\n" + "Er ist schnell genug, um Mauersegler und Schwalben im offenen Flug " + "einzuholen — kaum ein anderer Greifvogel schafft das. Libellen frisst er " + "im Flug aus der Kralle, ohne zu landen. Er baut kein eigenes Nest, sondern " + "übernimmt alte Krähennester." + ), + kategorie_en="The Dragonfly Hunter", + nahrung_en="Dragonflies, swallows, swifts — all taken in flight", + lebensraum_en="Open country with copses, often near water", + stimme_en="A piercing, repeated “kew-kew-kew”", + text_en=( + "Small and scythe-winged like an oversized swift, with a black moustache " + "and rusty “trousers” on the thighs." + "\n\n" + "It is fast enough to run down swifts and swallows in open flight — few " + "other raptors manage that. Dragonflies it eats on the wing, straight from " + "the foot, without landing. It builds no nest of its own, taking over old " + "crow nests." + ), +), + +"merlin": Zusatz( + kategorie="Der kleinste Falke", + nahrung="Kleinvögel, im niedrigen Verfolgungsflug erjagt", + lebensraum="Offene Küsten und Felder — bei uns nur im Winter", + stimme="Ein schnelles, schrilles Kichern, im Winter kaum zu hören", + text=( + "Der kleinste Falke Europas, kaum größer als eine Amsel. Männchen " + "blaugrau, Weibchen braun, beide gedrungen mit kurzen spitzen Flügeln." + "\n\n" + "Er jagt tief über dem Boden und hetzt Kleinvögel in ausdauernden " + "Verfolgungsflügen, oft in engen Kurven dicht über der Vegetation. In " + "Deutschland brütet er nicht; die Vögel, die man im Winter über " + "Küstenwiesen sieht, kommen aus Skandinavien und Island." + ), + kategorie_en="The Smallest Falcon", + nahrung_en="Small birds, run down in low pursuit", + lebensraum_en="Open coasts and fields — a winter bird here", + stimme_en="A rapid shrill chittering, seldom heard in winter", + text_en=( + "Europe's smallest falcon, barely bigger than a blackbird. Males blue-grey, " + "females brown, both compact with short pointed wings." + "\n\n" + "It hunts low over the ground, chasing small birds in long pursuits, often " + "in tight turns just above the vegetation. It does not breed in Germany; " + "the birds seen over coastal meadows in winter come from Scandinavia and " + "Iceland." + ), +), + +# ---------- Eulen ---------- + +"sumpfohreule": Zusatz( + kategorie="Die Tageule", + nahrung="Feldmäuse, fast ausschließlich", + lebensraum="Moore, Feuchtwiesen, Küstenmarschen", + stimme="Ein dumpfes, schnelles „bu-bu-bu“ im Balzflug", + text=( + "Hell sandfarben, mit sehr kurzen Federohren, die man selten sieht, und " + "leuchtend gelben Augen in einem dunklen Gesichtsring." + "\n\n" + "Sie jagt oft am helllichten Tag, tief über der Wiese gaukelnd — anders " + "als fast alle anderen Eulen. Sie brütet am Boden und richtet ihre " + "Gelegegröße nach dem Mäuseangebot: In Mäusejahren zieht ein Paar bis zu " + "zehn Junge groß, in schlechten gar keine." + ), + kategorie_en="The Daylight Owl", + nahrung_en="Voles, almost exclusively", + lebensraum_en="Bogs, wet meadows, coastal marsh", + stimme_en="A muffled, rapid “boo-boo-boo” in display flight", + text_en=( + "Pale sandy, with very short ear tufts that are rarely visible and vivid " + "yellow eyes set in a dark facial ring." + "\n\n" + "It often hunts in broad daylight, quartering low over the meadow — unlike " + "almost every other owl. It nests on the ground and matches its clutch to " + "the vole supply: in a good year a pair raises up to ten young, in a bad " + "one none at all." + ), +), + +"schnee_eule": Zusatz( + kategorie="Die Weiße aus dem Norden", + nahrung="Lemminge, Schneehühner, Enten", + lebensraum="Arktische Tundra — bei uns ein Jahrhundert-Irrgast", + stimme="Ein tiefes, bellendes Rufen, im Süden nie zu hören", + text=( + "Alte Männchen sind fast reinweiß, Weibchen und Jungvögel dunkel " + "gebändert. Groß, wuchtig, mit gelben Augen und dicht befiederten Füßen — " + "eine Eule, die für minus vierzig Grad gebaut ist." + "\n\n" + "Sie brütet in der Hocharktis und folgt den Lemmingen: In Jahren mit " + "Massenvermehrung zieht ein Paar bis zu elf Junge groß, in schlechten " + "brütet es gar nicht. Nach Deutschland verirren sich einzelne Vögel nur " + "alle paar Jahrzehnte — jeder Nachweis ist eine kleine Sensation." + ), + kategorie_en="The White One from the North", + nahrung_en="Lemmings, ptarmigan, ducks", + lebensraum_en="Arctic tundra — a once-in-a-generation vagrant here", + stimme_en="A deep barking call, never heard this far south", + text_en=( + "Old males are almost pure white, females and juveniles heavily barred. " + "Large and powerful, with yellow eyes and densely feathered feet — an owl " + "built for minus forty." + "\n\n" + "It breeds in the high Arctic and follows the lemmings: in a peak year a " + "pair raises up to eleven young, in a poor one it does not breed at all. " + "Individual birds stray to Germany only every few decades — every record " + "is a small sensation." + ), +), + +# ---------- Reiher ---------- + +"silberreiher": Zusatz( + kategorie="Der weiße Neuankömmling", + nahrung="Fische, Mäuse, Insekten", + lebensraum="Flachgewässer, Feuchtwiesen, im Winter auch Äcker", + stimme="Ein raues Krächzen, meist nur am Brutplatz", + text=( + "So groß wie ein Graureiher, aber vollständig weiß, mit gelbem Schnabel " + "im Winter und schwarzen Beinen. Zur Brutzeit trägt er lange " + "Schmuckfedern am Rücken." + "\n\n" + "Genau diese Federn hätten ihn fast ausgerottet: Um 1900 waren sie als " + "Hutschmuck so begehrt, dass sie mehr wogen als Gold. Der Protest dagegen " + "führte zur Gründung der ersten Vogelschutzverbände. Heute breitet er sich " + "nach Norden aus und steht im Winter zu dutzenden auf deutschen Wiesen." + ), + kategorie_en="The White Newcomer", + nahrung_en="Fish, voles, insects", + lebensraum_en="Shallow water, wet meadows, arable fields in winter", + stimme_en="A harsh croak, mostly only at the colony", + text_en=( + "As large as a Grey Heron but entirely white, with a yellow bill in winter " + "and black legs. In the breeding season it grows long ornamental plumes on " + "the back." + "\n\n" + "Those plumes nearly wiped it out: around 1900 they were so prized for " + "hats that they were worth more than their weight in gold. The outcry " + "against the trade led to the founding of the first bird protection " + "societies. Today it is spreading north and stands by the dozen on German " + "meadows in winter." + ), +), + +"purpurreiher": Zusatz( + kategorie="Der Schilfschleicher", + nahrung="Fische, Frösche, Wasserinsekten", + lebensraum="Ausgedehnte Schilfgebiete", + stimme="Ein heiseres, kurzes Krächzen", + text=( + "Schlanker und dunkler als der Graureiher, mit rostroter Färbung an Hals " + "und Bauch und einem auffällig dünnen, schlangenartigen Hals." + "\n\n" + "Er lebt so versteckt im Schilf, dass er selbst dort, wo er brütet, selten " + "gesehen wird. Bei Gefahr streckt er Schnabel und Hals senkrecht nach oben " + "und verschwindet optisch zwischen den Halmen. In Deutschland brüten nur " + "wenige Paare, fast alle am Oberrhein und am Bodensee." + ), + kategorie_en="The Reedbed Skulker", + nahrung_en="Fish, frogs, aquatic insects", + lebensraum_en="Extensive reedbeds", + stimme_en="A short, hoarse croak", + text_en=( + "Slimmer and darker than a Grey Heron, with rufous tones on neck and belly " + "and a strikingly thin, snake-like neck." + "\n\n" + "It lives so secretively in the reeds that it is rarely seen even where it " + "breeds. Alarmed, it points bill and neck straight up and disappears " + "visually among the stems. Only a handful of pairs breed in Germany, almost " + "all on the Upper Rhine and Lake Constance." + ), +), + +"seidenreiher": Zusatz( + kategorie="Der mit den gelben Schuhen", + nahrung="Kleine Fische, Garnelen, Insekten", + lebensraum="Flache Küstengewässer, Lagunen, Flussmündungen", + stimme="Ein raues Gurgeln in der Kolonie", + text=( + "Klein und schneeweiß, mit schwarzem Schnabel, schwarzen Beinen und " + "leuchtend gelben Füßen — die „gelben Schuhe“ sind das sicherste Merkmal." + "\n\n" + "Er jagt aktiv statt zu lauern: Er rennt durchs Flachwasser, rührt mit den " + "gelben Füßen im Schlamm und scheucht Beute auf. Wie der Silberreiher war " + "er wegen seiner Schmuckfedern fast ausgerottet und hat sich seither " + "erholt. Bei uns ist er ein seltener Sommergast." + ), + kategorie_en="The One With Yellow Shoes", + nahrung_en="Small fish, shrimp, insects", + lebensraum_en="Shallow coastal water, lagoons, estuaries", + stimme_en="A harsh gurgling at the colony", + text_en=( + "Small and snow white, with a black bill, black legs and vivid yellow feet " + "— those yellow shoes are the reliable mark." + "\n\n" + "It hunts actively rather than waiting: it runs through shallow water, " + "stirring the mud with those yellow feet to flush prey. Like the Great " + "Egret it was nearly exterminated for its plumes and has recovered since. " + "Here it is a scarce summer visitor." + ), +), + +} +# fmt: on diff --git a/detector/profiles/wasser.py b/detector/profiles/wasser.py new file mode 100644 index 0000000..2e51aa3 --- /dev/null +++ b/detector/profiles/wasser.py @@ -0,0 +1,1273 @@ +"""Steckbriefe: Wat-, Wasser- und Meeresvögel. + +Keine davon wird je am Futterhaus sitzen. Sie stehen im Katalog, weil ein +Album vollständig sein soll — und weil irgendwann jemand mit dem Fernglas an +einen See fährt. +""" + +from __future__ import annotations + +from .basis import Zusatz + +# fmt: off +PROFILE: dict[str, Zusatz] = { + +# ---------- Watvögel ---------- + +"kiebitz": Zusatz( + kategorie="Der Gaukler über dem Acker", + nahrung="Regenwürmer und Insekten, vom Boden gepickt", + lebensraum="Feuchtwiesen und Äcker mit kurzer Vegetation", + stimme="Ein quäkendes „kiju-wit“ — daher der Name", + text=( + "Schwarzweiß mit metallisch grün schillerndem Rücken und einer dünnen, " + "nach oben gebogenen Federholle. Kein anderer heimischer Vogel sieht so aus." + "\n\n" + "Sein Balzflug ist ein Taumeln: Er kippt, stürzt und schraubt sich mit " + "brummenden Flügeln durch die Luft, als hätte er die Kontrolle verloren. " + "Er war einer der häufigsten Feldvögel und ist heute stark gefährdet — " + "seine Gelege liegen offen im Acker und überstehen die Frühjahrsbestellung " + "nicht." + ), + kategorie_en="The Tumbler Over the Field", + nahrung_en="Earthworms and insects picked from the ground", + lebensraum_en="Damp meadows and fields with short vegetation", + stimme_en="A wheezy “pee-wit” — the source of its English name", + text_en=( + "Black and white with an iridescent green back and a thin, upcurved crest. " + "Nothing else here looks like it." + "\n\n" + "Its display flight is a tumble: it tips, dives and corkscrews with " + "throbbing wings as if it had lost control. Once among the commonest " + "farmland birds, it is now seriously threatened — its nests lie exposed on " + "arable ground and do not survive spring cultivation." + ), +), + +"austernfischer": Zusatz( + kategorie="Der Lautstarke von der Küste", + nahrung="Muscheln, Würmer, Krebse", + lebensraum="Wattenmeer, Salzwiesen, zunehmend auch Binnenland", + stimme="Ein schrilles, durchdringendes „kliep-kliep“", + text=( + "Schwarzweiß, mit knallrotem, langem Schnabel und rosa Beinen. Auf einer " + "Salzwiese ist er kilometerweit zu hören, bevor man ihn sieht." + "\n\n" + "Sein Schnabel ist je nach Technik unterschiedlich geformt: Manche Vögel " + "hämmern Muscheln auf, andere schieben die Schnabelspitze zwischen die " + "Schalen und durchtrennen den Schließmuskel. Die Technik wird von den " + "Eltern gelernt, nicht vererbt — und die Schnabelform passt sich innerhalb " + "weniger Wochen an." + ), + kategorie_en="The Loud One from the Coast", + nahrung_en="Shellfish, worms, crabs", + lebensraum_en="Tidal flats, saltmarsh, increasingly inland too", + stimme_en="A shrill, piercing “kleep-kleep”", + text_en=( + "Black and white, with a long scarlet bill and pink legs. On a saltmarsh " + "you hear it from a kilometre away before you see it." + "\n\n" + "Its bill shape depends on technique: some birds hammer shells open, others " + "slide the tip between the valves and cut the adductor muscle. The " + "technique is learned from the parents, not inherited — and the bill " + "reshapes itself within weeks to match." + ), +), + +"saebelschnaebler": Zusatz( + kategorie="Der mit dem gebogenen Schnabel", + nahrung="Kleinkrebse und Würmer aus flachem Salzwasser", + lebensraum="Flache Salzlagunen, Schlickflächen, Küstenpolder", + stimme="Ein flötendes, klares „klüit“", + text=( + "Weiß mit schwarzem Kopfmuster, blaugrauen Beinen und einem dünnen, nach " + "oben gebogenen Schnabel — ein Vogel wie mit dem Lineal gezeichnet." + "\n\n" + "Er watet durch flaches Wasser und schwenkt den Schnabel seitlich hin und " + "her durch den Schlick, wobei er Kleinkrebse ertastet statt sie zu sehen. " + "Er ist das Wappentier der britischen RSPB, weil seine Rückkehr nach " + "England in den 1940ern als erster großer Erfolg des modernen " + "Naturschutzes gilt." + ), + kategorie_en="The Upturned Bill", + nahrung_en="Small crustaceans and worms from shallow saltwater", + lebensraum_en="Shallow saline lagoons, mudflats, coastal polders", + stimme_en="A clear, fluting “kluit”", + text_en=( + "White with a black head pattern, blue-grey legs and a fine upcurved bill " + "— a bird that looks drawn with a ruler." + "\n\n" + "It wades through shallow water sweeping the bill sideways through the " + "silt, feeling for small crustaceans rather than seeing them. It is the " + "emblem of the RSPB, because its return to England in the 1940s counts as " + "the first great success of modern conservation." + ), +), + +"stelzenlaeufer": Zusatz( + kategorie="Der auf Stelzen", + nahrung="Wasserinsekten und Larven von der Oberfläche", + lebensraum="Flache Süß- und Brackgewässer", + stimme="Ein hartes, bellendes „kjek-kjek-kjek“", + text=( + "Schwarzweiß, mit einem nadelfeinen Schnabel und knallrosa Beinen, die " + "länger sind als der ganze übrige Vogel. Im Flug ragen sie weit über den " + "Schwanz hinaus." + "\n\n" + "Im Verhältnis zur Körpergröße hat kein anderer Vogel der Welt längere " + "Beine — nur Flamingos übertreffen ihn absolut. Er brütet vor allem im " + "Mittelmeerraum und breitet sich mit wärmeren Sommern nach Norden aus; in " + "Deutschland gibt es inzwischen unregelmäßig einzelne Bruten." + ), + kategorie_en="The One on Stilts", + nahrung_en="Aquatic insects and larvae from the surface", + lebensraum_en="Shallow fresh and brackish water", + stimme_en="A hard, yapping “kek-kek-kek”", + text_en=( + "Black and white, with a needle-fine bill and shocking pink legs longer " + "than the rest of the bird. In flight they trail far beyond the tail." + "\n\n" + "Relative to body size no bird in the world has longer legs — only " + "flamingos beat it outright. It breeds mainly around the Mediterranean and " + "is spreading north with warmer summers; occasional pairs now nest in " + "Germany." + ), +), + +"flussregenpfeifer": Zusatz( + kategorie="Der Kiesbankbrüter", + nahrung="Kleine Insekten und Würmer", + lebensraum="Kiesbänke an Flüssen, Kiesgruben, Baustellen", + stimme="Ein abfallendes, pfeifendes „pi-ju“", + text=( + "Klein, sandbraun oben, weiß unten, mit einem schwarzen Brustband und " + "einem gelben Augenring — der Ring trennt ihn vom Sandregenpfeifer." + "\n\n" + "Sein Nest ist eine Mulde im Kies, die Eier sind perfekt getarnt. Er " + "brütet auf frisch gestörten Flächen, weshalb man ihn heute öfter in " + "Kiesgruben und auf Baustellen findet als an naturnahen Flüssen — " + "dynamische Flusslandschaften sind fast überall verschwunden." + ), + kategorie_en="The Gravel Bar Nester", + nahrung_en="Small insects and worms", + lebensraum_en="River gravel bars, gravel pits, building sites", + stimme_en="A descending, whistled “pee-oo”", + text_en=( + "Small, sandy above and white below, with a black breast band and a yellow " + "eye-ring — the ring separates it from the Ringed Plover." + "\n\n" + "Its nest is a scrape in gravel and the eggs are perfectly camouflaged. It " + "breeds on freshly disturbed ground, which is why it now turns up more " + "often in gravel pits and on building sites than along natural rivers — " + "dynamic river landscapes have all but vanished." + ), +), + +"sandregenpfeifer": Zusatz( + kategorie="Der mit dem gebrochenen Flügel", + nahrung="Wattwürmer, Krebse, Insekten", + lebensraum="Sandstrände, Wattflächen, Küstendünen", + stimme="Ein weiches, aufsteigendes „tü-it“", + text=( + "Wie der Flussregenpfeifer, aber kräftiger, mit orangefarbenem Schnabel" + "grund, orangen Beinen und ohne gelben Augenring." + "\n\n" + "Nähert sich ein Fuchs dem Nest, läuft der Altvogel mit hängendem, " + "scheinbar gebrochenem Flügel davon und lockt ihn hinter sich her. Ist " + "der Feind weit genug weg, fliegt er kerngesund auf. Diese " + "Verleitungstaktik ist bei bodenbrütenden Watvögeln verbreitet — bei ihm " + "ist sie besonders gut zu beobachten." + ), + kategorie_en="The Broken-Wing Act", + nahrung_en="Lugworms, small crabs, insects", + lebensraum_en="Sandy beaches, tidal flats, coastal dunes", + stimme_en="A soft, rising “too-eep”", + text_en=( + "Like the Little Ringed Plover but chunkier, with an orange bill base, " + "orange legs and no yellow eye-ring." + "\n\n" + "When a fox approaches the nest, the adult runs off trailing an apparently " + "broken wing and draws the predator after it. Once the danger is far " + "enough away it flies up perfectly healthy. This distraction display is " + "widespread among ground-nesting waders — in this species it is " + "particularly easy to watch." + ), +), + +"bekassine": Zusatz( + kategorie="Die Himmelsziege", + nahrung="Würmer und Larven, tief im Schlamm ertastet", + lebensraum="Moore, Nasswiesen, Verlandungszonen", + stimme="Kein Gesang — ein Meckern, erzeugt mit den Schwanzfedern", + text=( + "Bräunlich gestreift und getarnt, mit einem Schnabel, der fast ein Viertel " + "der Körperlänge misst. Dessen Spitze ist beweglich und mit Tastkörperchen " + "besetzt: Sie erfühlt Würmer tief im Schlamm, ohne sie zu sehen." + "\n\n" + "Ihr Balzflug erzeugt kein Stimmgeräusch, sondern ein Instrument: Im " + "Sturzflug spreizt sie die äußeren Schwanzfedern, die im Fahrtwind " + "vibrieren und ein meckerndes Wummern erzeugen. Daher der alte Name " + "Himmelsziege." + ), + kategorie_en="The Sky Goat", + nahrung_en="Worms and larvae, felt for deep in mud", + lebensraum_en="Bogs, wet meadows, marsh edges", + stimme_en="Not a song — a bleating made with the tail feathers", + text_en=( + "Brown, striped and cryptic, with a bill nearly a quarter of its body " + "length. The tip is flexible and packed with touch receptors: it feels for " + "worms deep in mud without seeing them." + "\n\n" + "Its display makes no vocal sound but plays an instrument: diving, it " + "spreads the outer tail feathers, which vibrate in the airflow and produce " + "a bleating throb. Hence the old German name Himmelsziege — sky goat." + ), +), + +"grosser_brachvogel": Zusatz( + kategorie="Der mit der Sichel", + nahrung="Würmer und Krebse, tief aus dem Schlick geholt", + lebensraum="Feuchtwiesen und Moore, im Winter im Wattenmeer", + stimme="Ein aufsteigendes, flötendes „kur-lii“ — melancholisch und weittragend", + text=( + "Der größte Watvogel Europas, graubraun gestrichelt, mit einem langen, " + "gleichmäßig abwärts gebogenen Schnabel." + "\n\n" + "Sein Ruf gilt als eine der eindrücklichsten Vogelstimmen überhaupt und " + "hat es in unzählige Gedichte geschafft. Weibchen haben einen längeren " + "Schnabel als Männchen und stochern dadurch in anderen Tiefen — ein Paar " + "macht sich am selben Wattfleck keine Konkurrenz. In Deutschland ist er " + "stark gefährdet." + ), + kategorie_en="The Sickle Bill", + nahrung_en="Worms and crustaceans pulled from deep in the mud", + lebensraum_en="Wet meadows and bogs; tidal flats in winter", + stimme_en="A rising, fluting “cur-lee” — melancholy and far-carrying", + text_en=( + "Europe's largest wader, streaky grey-brown, with a long evenly downcurved " + "bill." + "\n\n" + "Its call counts among the most evocative of any bird and has found its way " + "into countless poems. Females have longer bills than males and probe at " + "different depths — so a pair does not compete on the same patch of mud. " + "In Germany it is seriously threatened." + ), +), + +"regenbrachvogel": Zusatz( + kategorie="Der kleinere Bruder", + nahrung="Krebse, Würmer, Beeren im Brutgebiet", + lebensraum="Tundra und Moore im Norden, bei uns Durchzügler", + stimme="Ein schnelles, gleichmäßiges Trillern aus sieben Tönen", + text=( + "Wie ein Großer Brachvogel, aber kleiner, mit kürzerem Schnabel und einem " + "deutlichen Streifenmuster auf dem Kopf — zwei dunkle Bänder mit einem " + "hellen Scheitelstreif dazwischen." + "\n\n" + "Er brütet in Island, Skandinavien und Sibirien und rastet bei uns nur auf " + "dem Zug. Sein Ruf ist unverwechselbar: sieben bis neun gleiche, schnell " + "gepfiffene Töne, im Englischen als whimper beschrieben — daher whimbrel." + ), + kategorie_en="The Smaller Brother", + nahrung_en="Crabs, worms, berries on the breeding grounds", + lebensraum_en="Northern tundra and bogs; a passage migrant here", + stimme_en="A rapid, even trill of about seven notes", + text_en=( + "Like a Curlew but smaller, with a shorter bill and a clear head pattern — " + "two dark bands with a pale crown stripe between them." + "\n\n" + "It breeds in Iceland, Scandinavia and Siberia and only stops here on " + "migration. The call is unmistakable: seven to nine identical notes " + "whistled in quick succession." + ), +), + +"rotschenkel": Zusatz( + kategorie="Der Wächter der Salzwiese", + nahrung="Würmer, Insekten, kleine Krebse", + lebensraum="Salzwiesen, Feuchtwiesen, Küstenpolder", + stimme="Ein durchdringendes, absteigendes „tjüh-hüh-hüh“", + text=( + "Braun gestrichelt, mit leuchtend orangeroten Beinen und einem roten " + "Schnabelgrund. Im Flug fallen die breiten weißen Hinterkanten der Flügel " + "auf." + "\n\n" + "Er gilt als Wächter der Salzwiese: Er sitzt erhöht auf einem Pfahl und " + "schlägt lange vor allen anderen Alarm, wenn sich etwas nähert. Für " + "andere Wiesenvögel ist er damit eine Frühwarnanlage — für Menschen mit " + "Fernglas eine Garantie, entdeckt zu sein." + ), + kategorie_en="The Sentinel of the Saltmarsh", + nahrung_en="Worms, insects, small crustaceans", + lebensraum_en="Saltmarsh, wet meadows, coastal polders", + stimme_en="A piercing, descending “teu-hu-hu”", + text_en=( + "Streaky brown, with vivid orange-red legs and a red bill base. In flight " + "the broad white trailing edges of the wings stand out." + "\n\n" + "It is the sentinel of the saltmarsh: it perches on a post and raises the " + "alarm long before anything else when something approaches. For other " + "meadow birds it is an early-warning system — for anyone with binoculars, " + "a guarantee of having been spotted." + ), +), + +"gruenschenkel": Zusatz( + kategorie="Der Elegante", + nahrung="Kleine Fische, Krebse, Insekten", + lebensraum="Flache Ufer und Schlammflächen — bei uns Durchzügler", + stimme="Ein klares, dreisilbiges „tjü-tjü-tjü“", + text=( + "Größer und heller als der Rotschenkel, mit grünlichen Beinen und einem " + "leicht nach oben gebogenen Schnabel. Im Flug zeigt er einen langen weißen " + "Keil auf dem Rücken." + "\n\n" + "Er jagt anders als die meisten Watvögel: Statt zu stochern, rennt er " + "durch flaches Wasser hinter kleinen Fischen her, manchmal seitwärts mit " + "schräg gehaltenem Schnabel. Er brütet in Skandinavien und ist bei uns " + "auf dem Durchzug im August und September zu sehen." + ), + kategorie_en="The Elegant One", + nahrung_en="Small fish, crustaceans, insects", + lebensraum_en="Shallow shores and mudflats — a passage migrant here", + stimme_en="A clear, three-note “tew-tew-tew”", + text_en=( + "Larger and paler than a Redshank, with greenish legs and a slightly " + "upturned bill. In flight it shows a long white wedge up the back." + "\n\n" + "It hunts unlike most waders: rather than probing, it runs through shallow " + "water chasing small fish, sometimes sideways with the bill held at an " + "angle. It breeds in Scandinavia and is seen here on passage in August and " + "September." + ), +), + +"waldwasserlaeufer": Zusatz( + kategorie="Der Waldbrüter unter den Watvögeln", + nahrung="Wasserinsekten, Larven, kleine Krebse", + lebensraum="Waldtümpel, Gräben, Kläranlagen, kleine Ufer", + stimme="Ein durchdringendes „tlüit-witt-witt“ beim Auffliegen", + text=( + "Oben sehr dunkel, fast schwarz mit feinen hellen Punkten, unten weiß. Im " + "Auffliegen blitzt ein weißer Bürzel gegen sehr dunkle Flügelunterseiten " + "— dieser Kontrast ist unverwechselbar." + "\n\n" + "Er ist der einzige heimische Watvogel, der auf Bäumen brütet: Er " + "übernimmt alte Nester von Drosseln in Sumpfwäldern. Die frisch " + "geschlüpften Küken springen aus dem Nest zu Boden — ein Sprung aus bis zu " + "zehn Metern, den sie unbeschadet überstehen." + ), + kategorie_en="The Wader That Nests in Trees", + nahrung_en="Aquatic insects, larvae, small crustaceans", + lebensraum_en="Woodland pools, ditches, sewage works, small shores", + stimme_en="A piercing “tluee-weet-weet” as it flushes", + text_en=( + "Very dark above, almost black with fine pale spots, white below. As it " + "flushes, a white rump flashes against very dark underwings — that " + "contrast is unmistakable." + "\n\n" + "It is the only wader here that nests in trees, taking over old thrush " + "nests in swampy woodland. The newly hatched chicks jump from the nest to " + "the ground — a drop of up to ten metres that they survive unharmed." + ), +), + +"bruchwasserlaeufer": Zusatz( + kategorie="Der Gefleckte", + nahrung="Insekten und Larven aus flachem Wasser", + lebensraum="Flache Schlammufer, überschwemmte Wiesen", + stimme="Ein weiches, dreisilbiges „giff-giff-giff“", + text=( + "Schlanker und heller als der Waldwasserläufer, oben deutlich hell " + "gefleckt, mit einem markanten hellen Überaugenstreif und gelblichen " + "Beinen." + "\n\n" + "Er ist auf dem Zug einer der häufigsten Watvögel an flachen Binnen" + "gewässern und rastet oft in kleinen Trupps. Als Brutvogel ist er in " + "Deutschland dagegen praktisch verschwunden — die Moore, die er braucht, " + "sind fast alle entwässert." + ), + kategorie_en="The Spotted One", + nahrung_en="Insects and larvae from shallow water", + lebensraum_en="Shallow muddy shores, flooded meadows", + stimme_en="A soft, three-note “chiff-chiff-chiff”", + text_en=( + "Slimmer and paler than a Green Sandpiper, clearly spangled above, with a " + "prominent pale eyebrow and yellowish legs." + "\n\n" + "On migration it is one of the commonest waders at shallow inland waters " + "and often rests in small parties. As a breeding bird in Germany it has " + "all but disappeared — the bogs it needs have nearly all been drained." + ), +), + +"flussuferlaeufer": Zusatz( + kategorie="Der Wippende", + nahrung="Insekten vom Ufersaum", + lebensraum="Kiesige Ufer von Flüssen und Seen", + stimme="Ein hohes, gezogenes „hii-di-di“ im Wegfliegen", + text=( + "Klein, braun oben, weiß unten, mit einem weißen Keil, der sich vor dem " + "Flügelbug nach oben schiebt. Er wippt unablässig mit dem Hinterkörper — " + "das erkennt man auf hundert Meter." + "\n\n" + "Sein Flug ist ebenso typisch: dicht über der Wasseroberfläche, mit steifen, " + "abwärts gehaltenen Flügeln und kurzen Gleitphasen. In Deutschland brütet " + "er an naturnahen Flussabschnitten und ist deshalb selten geworden." + ), + kategorie_en="The Teeterer", + nahrung_en="Insects from the water's edge", + lebensraum_en="Gravelly shores of rivers and lakes", + stimme_en="A high, drawn-out “swee-wee-wee” as it flies off", + text_en=( + "Small, brown above and white below, with a white wedge pushing up in " + "front of the wing. It teeters constantly with its rear end — visible at a " + "hundred metres." + "\n\n" + "Its flight is just as distinctive: low over the water on stiff, " + "down-bowed wings with short glides. In Germany it breeds along " + "near-natural stretches of river and has become scarce accordingly." + ), +), + +# ---------- Möwen und Seeschwalben ---------- + +"silbermoewe": Zusatz( + kategorie="Die Möwe schlechthin", + nahrung="Fisch, Krebse, Abfall, Eier anderer Vögel", + lebensraum="Küsten, Häfen, Mülldeponien, große Binnenseen", + stimme="Das laute, lachende Schreien, das jeder mit Meer verbindet", + text=( + "Groß, weiß, mit hellgrauem Rücken, rosa Beinen und einem roten Fleck auf " + "dem gelben Unterschnabel. Bis zur Geschlechtsreife braucht sie vier " + "Jahre und wechselt dabei durch mehrere braune Kleider." + "\n\n" + "Der rote Schnabelfleck ist berühmt: Küken picken gezielt danach, um " + "gefüttert zu werden. Niko Tinbergen zeigte mit Pappmodellen, dass der " + "Kontrast des Flecks reicht — der Rest der Möwe ist dem Küken egal. Dafür " + "gab es 1973 den Nobelpreis." + ), + kategorie_en="The Archetypal Gull", + nahrung_en="Fish, crabs, refuse, other birds' eggs", + lebensraum_en="Coasts, harbours, landfills, large inland lakes", + stimme_en="The loud laughing call everyone associates with the sea", + text_en=( + "Large and white, with a pale grey back, pink legs and a red spot on the " + "yellow lower mandible. It takes four years to mature, passing through " + "several brown plumages." + "\n\n" + "That red bill spot is famous: chicks peck at it to be fed. Niko Tinbergen " + "showed with cardboard models that the contrast of the spot alone is " + "enough — the rest of the gull is irrelevant to the chick. It earned him a " + "share of the 1973 Nobel Prize." + ), +), + +"sturmmoewe": Zusatz( + kategorie="Die Sanfte", + nahrung="Würmer, Insekten, Fisch", + lebensraum="Küsten und Binnenseen, oft auf Äckern", + stimme="Ein höheres, weinerlicheres Kreischen als bei der Silbermöwe", + text=( + "Wie eine kleine Silbermöwe, aber mit gelbgrünen Beinen, einem " + "fleckenlosen gelben Schnabel und einem sanfteren, runderen Gesichts" + "ausdruck durch die dunklen Augen." + "\n\n" + "Sie ist deutlich weniger aggressiv als ihre großen Verwandten und " + "verschmäht Abfälle meist. Im Winter folgt sie Pflügen auf Äckern weit im " + "Binnenland — wer Möwen über einem Acker in Süddeutschland sieht, sieht " + "oft sie oder die Lachmöwe." + ), + kategorie_en="The Gentle One", + nahrung_en="Worms, insects, fish", + lebensraum_en="Coasts and inland lakes, often on arable fields", + stimme_en="A higher, more mewing cry than a Herring Gull's", + text_en=( + "Like a small Herring Gull but with yellow-green legs, an unmarked yellow " + "bill and a gentler, rounder expression from its dark eyes." + "\n\n" + "It is markedly less aggressive than its large relatives and usually " + "ignores refuse. In winter it follows the plough far inland — gulls over a " + "field in southern Germany are often this species or Black-headed Gulls." + ), +), + +"mantelmoewe": Zusatz( + kategorie="Die Größte der Welt", + nahrung="Fisch, Aas, andere Seevögel", + lebensraum="Nordseeküste, Häfen — bei uns vor allem im Winter", + stimme="Ein sehr tiefes, bellendes „auk-auk“", + text=( + "Die größte Möwe der Welt, mit fast schwarzem Rücken, massivem Schnabel " + "und rosa Beinen. Neben ihr wirkt eine Silbermöwe zierlich." + "\n\n" + "Sie ist ein ausgesprochener Räuber und schlägt ausgewachsene Papageien" + "taucher und Enten im Flug — manche Individuen spezialisieren sich " + "darauf. In Deutschland brütet sie nur vereinzelt; die meisten Vögel hier " + "sind Wintergäste aus Skandinavien." + ), + kategorie_en="The Largest in the World", + nahrung_en="Fish, carrion, other seabirds", + lebensraum_en="North Sea coast and harbours — mainly a winter bird here", + stimme_en="A very deep, barking “owk-owk”", + text_en=( + "The largest gull in the world, with a near-black back, a massive bill and " + "pink legs. Beside it a Herring Gull looks delicate." + "\n\n" + "It is a genuine predator and takes adult puffins and ducks in flight — " + "some individuals specialise in it. It breeds only sporadically in " + "Germany; most birds here are winter visitors from Scandinavia." + ), +), + +"heringsmoewe": Zusatz( + kategorie="Die Langflüglige", + nahrung="Fisch, im Binnenland auch Regenwürmer", + lebensraum="Küsten, zunehmend auch Städte und Binnengewässer", + stimme="Tiefer und rauer als die Silbermöwe", + text=( + "Schiefergrauer bis fast schwarzer Rücken, gelbe Beine, schlanker und " + "langflügliger als die Silbermöwe. Die Beinfarbe ist das schnellste " + "Merkmal." + "\n\n" + "Sie ist die einzige unserer großen Möwen, die ein echter Zugvogel ist: " + "Ein Teil der Population überwintert bis Westafrika. In den letzten " + "Jahrzehnten hat sie begonnen, auf Hausdächern in Städten zu brüten, was " + "sie mancherorts unbeliebt macht." + ), + kategorie_en="The Long-winged One", + nahrung_en="Fish; earthworms inland", + lebensraum_en="Coasts, increasingly also cities and inland waters", + stimme_en="Deeper and harsher than a Herring Gull", + text_en=( + "A slate-grey to almost black back, yellow legs, slimmer and " + "longer-winged than a Herring Gull. Leg colour is the quickest mark." + "\n\n" + "It is the only one of our large gulls that is a true migrant: part of the " + "population winters as far as West Africa. In recent decades it has taken " + "to nesting on city rooftops, which has not made it popular everywhere." + ), +), + +"mittelmeermoewe": Zusatz( + kategorie="Die aus dem Süden", + nahrung="Fisch, Abfall, Muscheln", + lebensraum="Binnenseen und Flüsse, ursprünglich Mittelmeerküsten", + stimme="Tiefer und näselnder als die Silbermöwe", + text=( + "Wie eine Silbermöwe, aber mit knallgelben Beinen, dunklerem Rücken und " + "einem kräftigeren Schnabel mit auffällig rotem Fleck." + "\n\n" + "Sie galt lange als Unterart der Silbermöwe und wurde erst in den 2000ern " + "als eigene Art anerkannt. Sie breitet sich seit Jahrzehnten flussaufwärts " + "nach Norden aus und brütet heute an Rhein, Main und Donau — an " + "süddeutschen Baggerseen ist sie inzwischen die häufigste Großmöwe." + ), + kategorie_en="The One from the South", + nahrung_en="Fish, refuse, shellfish", + lebensraum_en="Inland lakes and rivers; originally Mediterranean coasts", + stimme_en="Deeper and more nasal than a Herring Gull", + text_en=( + "Like a Herring Gull but with bright yellow legs, a darker back and a " + "heavier bill with a striking red spot." + "\n\n" + "Long treated as a subspecies of the Herring Gull, it was only recognised " + "as a full species in the 2000s. It has been spreading north up the river " + "systems for decades and now breeds on the Rhine, Main and Danube — at " + "gravel lakes in southern Germany it is now the commonest large gull." + ), +), + +"flussseeschwalbe": Zusatz( + kategorie="Die Schwalbe des Meeres", + nahrung="Kleine Fische, im Stoßtauchen erbeutet", + lebensraum="Kiesinseln an Flüssen und Küsten", + stimme="Ein raues, gezogenes „kriiih“", + text=( + "Schlank und silbergrau, mit schwarzer Kappe, tief gegabeltem Schwanz und " + "einem roten Schnabel mit schwarzer Spitze. Im Flug leicht und " + "schwalbenartig — daher der Name." + "\n\n" + "Sie rüttelt über dem Wasser und stößt dann kopfüber hinein. Bei der Balz " + "überreicht das Männchen dem Weibchen einen Fisch; die Größe des Geschenks " + "entscheidet mit über die Partnerwahl. Weil natürliche Kiesinseln fehlen, " + "brütet sie heute vielerorts auf künstlichen Flößen." + ), + kategorie_en="The Swallow of the Sea", + nahrung_en="Small fish, taken by plunge-diving", + lebensraum_en="Gravel islands on rivers and coasts", + stimme_en="A harsh, drawn-out “kreee-arr”", + text_en=( + "Slim and silver-grey, with a black cap, a deeply forked tail and a red " + "bill with a black tip. Light and swallow-like in flight — hence the " + "German name." + "\n\n" + "It hovers over the water and then plunges head-first. In courtship the " + "male presents the female with a fish, and the size of the gift helps " + "decide the pairing. With natural gravel islands gone, it now breeds on " + "artificial rafts in many places." + ), +), + +"kuestenseeschwalbe": Zusatz( + kategorie="Die Rekordhalterin", + nahrung="Kleine Fische und Krebse", + lebensraum="Nord- und Ostseeküste, arktische Küsten", + stimme="Ein kürzeres, härteres „kiiirr“ als bei der Flussseeschwalbe", + text=( + "Fast identisch mit der Flussseeschwalbe, aber mit ganz rotem Schnabel " + "ohne schwarze Spitze, kürzeren Beinen und noch längeren Schwanzspießen." + "\n\n" + "Sie hält den Wanderrekord des Tierreichs: Sie brütet in der Arktis und " + "überwintert in der Antarktis, rund 30.000 Kilometer pro Richtung. Ein " + "einzelner Vogel legt in seinem Leben eine Strecke zurück, die dreimal zum " + "Mond und zurück reicht — und sieht dabei mehr Tageslicht als jedes andere " + "Lebewesen." + ), + kategorie_en="The Record Holder", + nahrung_en="Small fish and crustaceans", + lebensraum_en="North and Baltic Sea coasts, Arctic shores", + stimme_en="A shorter, harder “kee-arr” than a Common Tern's", + text_en=( + "Almost identical to the Common Tern but with an all-red bill without a " + "black tip, shorter legs and even longer tail streamers." + "\n\n" + "It holds the migration record of the animal kingdom: it breeds in the " + "Arctic and winters in the Antarctic, some 30,000 kilometres each way. Over " + "a lifetime one bird covers a distance equal to three round trips to the " + "Moon — and sees more daylight than any other living creature." + ), +), + +# ---------- Taucher ---------- + +"haubentaucher": Zusatz( + kategorie="Der Tänzer", + nahrung="Fische, tauchend gejagt", + lebensraum="Seen und langsame Flüsse mit Schilfrand", + stimme="Ein raues, weit hallendes „gorrr“ und knarrende Rufe", + text=( + "Schlank, mit langem Hals, dolchartigem Schnabel und im Frühjahr einer " + "auffälligen rotbraun-schwarzen Halskrause samt Federhaube." + "\n\n" + "Seine Balz ist eines der sehenswertesten Schauspiele der heimischen " + "Vogelwelt: Beide Partner richten sich Brust an Brust senkrecht aus dem " + "Wasser auf, schütteln die Köpfe und überreichen sich Wasserpflanzen — der " + "Pinguintanz. Die gestreiften Küken reiten die ersten Wochen auf dem " + "Rücken der Eltern, auch beim Tauchen." + ), + kategorie_en="The Dancer", + nahrung_en="Fish, caught by diving", + lebensraum_en="Lakes and slow rivers with reedy margins", + stimme_en="A harsh, far-carrying “gorrr” and creaking calls", + text_en=( + "Slim, with a long neck, a dagger bill and, in spring, a striking " + "chestnut-and-black ruff and crest." + "\n\n" + "Its courtship is among the finest spectacles in European birding: the " + "pair rear up breast to breast out of the water, shake their heads and " + "present each other with waterweed — the penguin dance. The striped chicks " + "ride on their parents' backs for the first weeks, dives included." + ), +), + +"rothalstaucher": Zusatz( + kategorie="Der Heimliche", + nahrung="Fische, Insekten, Krebse", + lebensraum="Kleine, dicht bewachsene Seen und Teiche", + stimme="Ein wieherndes, fast schweineartiges Quieken", + text=( + "Kleiner und gedrungener als der Haubentaucher, im Prachtkleid mit " + "rotbraunem Hals, weißen Wangen und schwarzer Kappe, dazu ein gelber " + "Schnabelgrund." + "\n\n" + "Er brütet auf kleinen, verwachsenen Gewässern und ist deshalb viel " + "schwerer zu finden als sein großer Verwandter. Sein Ruf ist eines der " + "bizarrsten Geräusche, die ein europäischer Vogel hervorbringt — man denkt " + "eher an ein Ferkel als an einen Vogel." + ), + kategorie_en="The Secretive One", + nahrung_en="Fish, insects, crustaceans", + lebensraum_en="Small, densely vegetated lakes and ponds", + stimme_en="A whinnying, almost pig-like squealing", + text_en=( + "Smaller and stockier than a Great Crested Grebe; in breeding plumage a " + "chestnut neck, white cheeks and a black cap, plus a yellow bill base." + "\n\n" + "It breeds on small, overgrown waters and is therefore far harder to find " + "than its larger relative. Its call is among the strangest sounds any " + "European bird makes — you think of a piglet rather than a bird." + ), +), + +"schwarzhalstaucher": Zusatz( + kategorie="Der mit dem Goldfächer", + nahrung="Wasserinsekten, Larven, kleine Fische", + lebensraum="Flache, nährstoffreiche Seen mit Lachmöwenkolonien", + stimme="Ein weiches, ansteigendes „püiii“", + text=( + "Klein und rundlich, im Prachtkleid mit schwarzem Hals, roten Augen und " + "einem goldenen Federfächer hinter dem Auge. Der Schnabel ist leicht nach " + "oben gebogen." + "\n\n" + "Er brütet fast ausschließlich mitten in Lachmöwenkolonien und nutzt " + "deren aggressive Verteidigung als Schutz. Verlässt die Kolonie den See, " + "verschwindet er im Jahr darauf meist ebenfalls — ein Untermieter, der " + "vom Hausrecht des Vermieters lebt." + ), + kategorie_en="The One With the Golden Fan", + nahrung_en="Aquatic insects, larvae, small fish", + lebensraum_en="Shallow, nutrient-rich lakes with Black-headed Gull colonies", + stimme_en="A soft, rising “poo-eee”", + text_en=( + "Small and round, in breeding plumage with a black neck, red eyes and a " + "golden fan of feathers behind the eye. The bill is slightly upturned." + "\n\n" + "It breeds almost exclusively inside Black-headed Gull colonies, using " + "their aggressive defence as cover. If the gulls abandon a lake, the grebe " + "usually follows the next year — a lodger living off the landlord's " + "temper." + ), +), + +"zwergtaucher": Zusatz( + kategorie="Der Kleinste", + nahrung="Wasserinsekten, Larven, kleine Fische", + lebensraum="Kleine Teiche, Gräben, langsam fließende Bäche", + stimme="Ein hell wieherndes Trillern, das über den Teich hallt", + text=( + "Der kleinste Taucher Europas, kaum größer als eine Faust, im Prachtkleid " + "kastanienbraun an Wangen und Hals mit einem hellen Fleck am Schnabel" + "grund. Wirkt hinten wie abgeschnitten, ohne sichtbaren Schwanz." + "\n\n" + "Bei Gefahr taucht er weg statt aufzufliegen und kommt weit entfernt " + "wieder hoch, oft nur mit dem Kopf über Wasser. Sein Trillern ist auf " + "kleinen Teichen oft das Einzige, was ihn verrät." + ), + kategorie_en="The Smallest", + nahrung_en="Aquatic insects, larvae, small fish", + lebensraum_en="Small ponds, ditches, slow streams", + stimme_en="A bright whinnying trill that carries across the pond", + text_en=( + "Europe's smallest grebe, barely bigger than a fist; in breeding plumage " + "chestnut on cheeks and neck with a pale spot at the bill base. It looks " + "cut off at the rear, with no visible tail." + "\n\n" + "Alarmed, it dives rather than flies and resurfaces far away, often with " + "just its head above water. On a small pond its trill is frequently the " + "only thing that gives it away." + ), +), + +# ---------- Rallen ---------- + +"blaesshuhn": Zusatz( + kategorie="Das mit der weißen Stirn", + nahrung="Wasserpflanzen, Algen, Kleintiere", + lebensraum="Seen, Teiche, Stadtparks — praktisch jedes Gewässer", + stimme="Ein hartes, metallisches „kick“ und lautes Platschen", + text=( + "Schiefergrau bis schwarz, mit weißem Schnabel und weißem Stirnschild — " + "das ist die „Blesse“ im Namen. Keine Ente, sondern eine Ralle." + "\n\n" + "Seine Zehen tragen keine Schwimmhäute, sondern seitliche Lappen, die " + "sich beim Rückschwung zusammenfalten — eine andere Lösung für dasselbe " + "Problem. Es ist ausgesprochen streitlustig: Reviergefechte enden im " + "Wasser, wobei sich beide Vögel zurücklehnen und mit den Füßen aufeinander " + "eintreten." + ), + kategorie_en="The One With the White Forehead", + nahrung_en="Water plants, algae, small animals", + lebensraum_en="Lakes, ponds, city parks — practically any water", + stimme_en="A hard metallic “kick” and a lot of splashing", + text_en=( + "Slate grey to black, with a white bill and a white frontal shield. Not a " + "duck but a rail." + "\n\n" + "Its toes have no webs but lateral lobes that fold on the backstroke — a " + "different solution to the same problem. It is famously quarrelsome: " + "territorial fights end up in the water, both birds leaning back and " + "kicking at each other with their feet." + ), +), + +"teichhuhn": Zusatz( + kategorie="Das mit dem roten Schild", + nahrung="Pflanzen, Schnecken, Insekten, Abfall", + lebensraum="Verwachsene Teiche, Gräben, Parkgewässer", + stimme="Ein explosives, gackerndes „kurrück“", + text=( + "Dunkel schiefergrau mit einem leuchtend roten Stirnschild, gelber " + "Schnabelspitze und einer weißen Zickzacklinie an der Flanke. Beim " + "Schwimmen nickt es unablässig mit dem Kopf." + "\n\n" + "Anders als das Blässhuhn hält es sich gern in der Deckung und läuft " + "häufig an Land. Junge aus der ersten Brut helfen regelmäßig dabei, ihre " + "eigenen Geschwister aus der zweiten Brut zu füttern — Familienhilfe, wie " + "man sie sonst eher von Rabenvögeln kennt." + ), + kategorie_en="The One With the Red Shield", + nahrung_en="Plants, snails, insects, scraps", + lebensraum_en="Overgrown ponds, ditches, park waters", + stimme_en="An explosive, cackling “kurruk”", + text_en=( + "Dark slate grey with a bright red frontal shield, a yellow bill tip and a " + "white zigzag along the flank. Swimming, it bobs its head constantly." + "\n\n" + "Unlike the Coot it prefers cover and often walks on land. Young from the " + "first brood regularly help feed their own siblings from the second — " + "cooperative breeding of the kind better known from corvids." + ), +), + +# ---------- Schwäne und Gänse ---------- + +"hoeckerschwan": Zusatz( + kategorie="Der Schwerste", + nahrung="Wasserpflanzen, Gras, Getreide", + lebensraum="Seen, Flüsse, Parkteiche, Küstengewässer", + stimme="Fauchen und Grunzen — der Flug singt, nicht der Vogel", + text=( + "Groß, weiß, mit orangefarbenem Schnabel und dem schwarzen Höcker darüber, " + "der ihm den Namen gibt. Er trägt den Hals gebogen, nicht gerade." + "\n\n" + "Mit bis zu 15 Kilogramm gehört er zu den schwersten flugfähigen Vögeln " + "der Welt. Beim Fliegen singen seine Schwingen: ein rhythmisches Sirren, " + "das man weit hört und das kein anderer Vogel erzeugt. Am Nest ist er " + "gefährlich — ein Flügelschlag kann einen Arm brechen." + ), + kategorie_en="The Heaviest", + nahrung_en="Water plants, grass, grain", + lebensraum_en="Lakes, rivers, park ponds, coastal water", + stimme_en="Hissing and grunting — the wings sing, not the bird", + text_en=( + "Large and white, with an orange bill and the black knob above it that " + "names it in German. It holds its neck curved rather than straight." + "\n\n" + "At up to 15 kilograms it is among the heaviest flying birds in the world. " + "In flight its wings sing: a rhythmic throbbing audible far off that no " + "other bird produces. At the nest it is dangerous — a wing blow can break " + "an arm." + ), +), + +"singschwan": Zusatz( + kategorie="Der Trompeter", + nahrung="Wasserpflanzen, Getreide, Rüben", + lebensraum="Seen und Äcker — bei uns Wintergast aus dem Norden", + stimme="Ein trompetendes, dreisilbiges Rufen, weit hörbar", + text=( + "So groß wie ein Höckerschwan, aber mit geradem Hals und einem gelb" + "schwarzen Schnabel ohne Höcker — das Gelb reicht keilförmig weit nach " + "vorn." + "\n\n" + "Anders als der Höckerschwan ist er ausgesprochen ruffreudig, und die " + "Rufe einer ziehenden Familie über einem Winteracker gehören zu den " + "eindrücklichsten Naturgeräuschen Nordeuropas. Er brütet in Island, " + "Skandinavien und Russland und überwintert zu tausenden an deutschen " + "Küsten und Flüssen." + ), + kategorie_en="The Trumpeter", + nahrung_en="Water plants, grain, root crops", + lebensraum_en="Lakes and fields — a winter visitor from the north", + stimme_en="A trumpeting three-note call, audible far off", + text_en=( + "As large as a Mute Swan but with a straight neck and a yellow-and-black " + "bill with no knob — the yellow runs forward in a wedge." + "\n\n" + "Unlike the Mute Swan it is highly vocal, and the calls of a family " + "flying over a winter field are among the most stirring natural sounds in " + "northern Europe. It breeds in Iceland, Scandinavia and Russia and winters " + "in thousands on German coasts and rivers." + ), +), + +"blaessgans": Zusatz( + kategorie="Die mit der weißen Blesse", + nahrung="Gras, Wintergetreide, Erntereste", + lebensraum="Weite Äcker und Wiesen nahe großen Flüssen", + stimme="Ein hohes, lachendes „kju-jück“, musikalischer als andere Gänse", + text=( + "Graubraun, mit einem weißen Stirnband über dem Schnabel und " + "unregelmäßigen schwarzen Querbändern auf dem Bauch. Jungvögel haben " + "beides noch nicht." + "\n\n" + "Sie brütet in der sibirischen Tundra und überwintert zu hunderttausenden " + "am Niederrhein — dort bilden sich im Winter Ansammlungen, die zu den " + "größten Gänsevorkommen Europas gehören. Familien bleiben den ganzen " + "Winter zusammen, und Jungvögel lernen die Zugroute von ihren Eltern." + ), + kategorie_en="The One With the White Blaze", + nahrung_en="Grass, winter cereals, harvest remains", + lebensraum_en="Wide fields and meadows near large rivers", + stimme_en="A high, laughing “kyu-yuk”, more musical than other geese", + text_en=( + "Grey-brown, with a white band across the forehead above the bill and " + "irregular black bars on the belly. Juveniles have neither yet." + "\n\n" + "It breeds in the Siberian tundra and winters in hundreds of thousands on " + "the Lower Rhine, where the flocks are among the largest goose gatherings " + "in Europe. Families stay together all winter, and young birds learn the " + "migration route from their parents." + ), +), + +"kanadagans": Zusatz( + kategorie="Die Eingewanderte", + nahrung="Gras, Wasserpflanzen, Getreide", + lebensraum="Parkgewässer, Seen, Flussufer", + stimme="Ein tiefes, zweisilbiges „a-honk“", + text=( + "Groß, braun, mit schwarzem Hals und Kopf und einem weißen Kinnband, das " + "wie ein Riemen wirkt. Unverwechselbar." + "\n\n" + "Sie stammt aus Nordamerika und wurde im 17. Jahrhundert als Ziervogel " + "nach Europa gebracht. Heute brütet sie hier verwildert, mit stark " + "wachsendem Bestand. In Parks kann sie zudringlich werden — und am Nest " + "verteidigt sie ihr Revier gegen alles, was näher als zehn Meter kommt, " + "Hunde und Menschen eingeschlossen." + ), + kategorie_en="The Immigrant", + nahrung_en="Grass, water plants, grain", + lebensraum_en="Park waters, lakes, riverbanks", + stimme_en="A deep, two-note “a-honk”", + text_en=( + "Large and brown, with a black neck and head and a white chinstrap. " + "Unmistakable." + "\n\n" + "It comes from North America and was brought to Europe as an ornamental " + "bird in the seventeenth century. It now breeds here feral and in rapidly " + "growing numbers. In parks it can get pushy — and at the nest it defends " + "its ground against anything within ten metres, dogs and people included." + ), +), + +"weisswangengans": Zusatz( + kategorie="Die aus dem Eis", + nahrung="Gras und Kräuter der Salzwiese", + lebensraum="Küstenwiesen und Wattenmeer im Winter", + stimme="Ein hohes, bellendes Kläffen — ein Schwarm klingt wie Hunde", + text=( + "Klein und kontrastreich: schwarzer Hals und Brust, cremeweißes Gesicht, " + "silbergrauer Rücken mit schwarzen Querbändern. Wie mit Tusche gezeichnet." + "\n\n" + "Sie brütet auf Felsklippen in Grönland und Spitzbergen, um " + "Polarfüchse fernzuhalten. Die Küken können nicht fliegen und müssen " + "wenige Tage nach dem Schlüpfen bis zu 120 Meter tief springen, um den " + "Eltern zum Futterplatz zu folgen. Im Wattenmeer überwintern hunderttausende." + ), + kategorie_en="The One from the Ice", + nahrung_en="Grass and herbs of the saltmarsh", + lebensraum_en="Coastal meadows and tidal flats in winter", + stimme_en="A high yapping — a flock sounds like a pack of dogs", + text_en=( + "Small and boldly marked: black neck and breast, a cream-white face, a " + "silver-grey back with black bars. As if drawn in ink." + "\n\n" + "It nests on cliff ledges in Greenland and Svalbard to keep Arctic foxes " + "away. The chicks cannot fly and must leap up to 120 metres down the cliff " + "within days of hatching to follow their parents to food. Hundreds of " + "thousands winter on the Wadden Sea." + ), +), + +"ringelgans": Zusatz( + kategorie="Die Kleinste", + nahrung="Seegras und Algen der Wattflächen", + lebensraum="Wattenmeer und Küstenwiesen — reiner Wintergast", + stimme="Ein tiefes, rollendes „rrott“", + text=( + "Kaum größer als eine Stockente, sehr dunkel, mit schwarzem Hals und " + "einem kleinen weißen Halsfleck, der wie ein Ringlein wirkt — daher der " + "Name." + "\n\n" + "Sie ist die am stärksten ans Meer gebundene Gans Europas und lebt fast " + "ausschließlich von Seegras. Als in den 1930ern eine Seegraskrankheit die " + "Bestände im Wattenmeer vernichtete, brach ihre Population um über neunzig " + "Prozent ein — sie hat sich bis heute nicht vollständig erholt." + ), + kategorie_en="The Smallest", + nahrung_en="Eelgrass and algae of the tidal flats", + lebensraum_en="Wadden Sea and coastal meadows — purely a winter bird", + stimme_en="A deep, rolling “rrott”", + text_en=( + "Barely bigger than a Mallard, very dark, with a black neck and a small " + "white neck patch like a little ring." + "\n\n" + "It is Europe's most strictly marine goose and lives almost entirely on " + "eelgrass. When a wasting disease destroyed the eelgrass beds of the " + "Wadden Sea in the 1930s, its population crashed by over ninety per cent — " + "and has never fully recovered." + ), +), + +# ---------- Enten ---------- + +"spiessente": Zusatz( + kategorie="Die Eleganteste", + nahrung="Samen und Wasserpflanzen, gründelnd", + lebensraum="Flache Seen und überschwemmte Wiesen", + stimme="Ein weiches Pfeifen beim Erpel, ein raues Quaken beim Weibchen", + text=( + "Der Erpel hat einen schokoladenbraunen Kopf, einen weißen Streifen, der " + "vom Hals hinaufzieht, und zwei lange, nadelfeine Schwanzfedern — daher " + "der Name." + "\n\n" + "Sie gilt als die eleganteste Ente Europas, mit langem Hals und schmalen " + "Flügeln. In Deutschland brütet sie nur noch mit wenigen hundert Paaren " + "und steht auf der Roten Liste; im Winter rasten dagegen zehntausende " + "aus dem Norden an unseren Küsten." + ), + kategorie_en="The Most Elegant", + nahrung_en="Seeds and water plants, taken by dabbling", + lebensraum_en="Shallow lakes and flooded meadows", + stimme_en="A soft whistle from the drake, a harsh quack from the female", + text_en=( + "The drake has a chocolate-brown head, a white stripe running up the neck " + "and two long needle-fine tail feathers that give the species its name." + "\n\n" + "It is widely held to be Europe's most elegant duck, with a long neck and " + "narrow wings. Only a few hundred pairs still breed in Germany and it is " + "red-listed; in winter, by contrast, tens of thousands from the north rest " + "on our coasts." + ), +), + +"krickente": Zusatz( + kategorie="Die kleinste Ente", + nahrung="Kleine Samen, im Schlamm seihend", + lebensraum="Flache, verwachsene Gewässer, Moore, Schlickflächen", + stimme="Ein helles, knackendes „krick“ — daher der Name", + text=( + "Die kleinste heimische Ente, kaum halb so groß wie eine Stockente. Der " + "Erpel hat einen kastanienbraunen Kopf mit einem grünen, gelb gerandeten " + "Augenband." + "\n\n" + "Sie startet fast senkrecht aus dem Wasser, ohne Anlauf — bei einem " + "Schwarm sieht das aus, als würde eine Handvoll Steine rückwärts aus dem " + "Teich fliegen. Ihr Schnabel filtert winzige Samen aus dem Schlamm, wofür " + "sie den Kopf seitlich durch die Oberfläche zieht." + ), + kategorie_en="The Smallest Duck", + nahrung_en="Small seeds, sieved from mud", + lebensraum_en="Shallow overgrown water, bogs, mudflats", + stimme_en="A bright cracking “crrick” — the source of the German name", + text_en=( + "The smallest duck here, barely half the size of a Mallard. The drake has " + "a chestnut head with a green, yellow-edged eye patch." + "\n\n" + "It takes off almost vertically from the water with no run-up — a flock " + "leaving looks like a handful of stones flying backwards out of the pond. " + "Its bill sieves tiny seeds from mud, which it does by drawing its head " + "sideways through the surface." + ), +), + +"schnatterente": Zusatz( + kategorie="Die Unauffällige", + nahrung="Wasserpflanzen, gern von tauchenden Arten gestohlen", + lebensraum="Nährstoffreiche Flachseen mit Ufervegetation", + stimme="Ein tiefes, schnarrendes Rätschen beim Erpel", + text=( + "Der Erpel wirkt aus der Ferne grau und schlicht — aus der Nähe zeigt er " + "eine fein gewellte Zeichnung wie ein Wasserzeichen und einen schwarzen " + "Bürzel. Das weiße Flügelfeld blitzt im Flug." + "\n\n" + "Sie ist eine Gelegenheitsdiebin: Sie wartet, bis Blässhühner oder " + "Tauchenten Wasserpflanzen an die Oberfläche bringen, und nimmt sie ihnen " + "ab. Ihr Bestand nimmt in Mitteleuropa deutlich zu, während viele andere " + "Entenarten zurückgehen." + ), + kategorie_en="The Understated One", + nahrung_en="Water plants, often stolen from diving species", + lebensraum_en="Nutrient-rich shallow lakes with marginal vegetation", + stimme_en="A deep, rasping croak from the drake", + text_en=( + "From a distance the drake looks plain grey — close up it shows finely " + "vermiculated markings like a watermark, and a black stern. The white wing " + "patch flashes in flight." + "\n\n" + "It is an opportunist thief: it waits for Coots or diving ducks to bring " + "waterweed to the surface and takes it from them. Its numbers are rising " + "markedly in central Europe while many other ducks decline." + ), +), + +"loeffelente": Zusatz( + kategorie="Die mit dem Löffel", + nahrung="Plankton und Kleintiere, aus dem Wasser gefiltert", + lebensraum="Flache, nährstoffreiche Gewässer und Schlickflächen", + stimme="Ein gedämpftes, doppeltes „tuk-tuk“", + text=( + "Unverwechselbar durch den riesigen, vorn verbreiterten Schnabel. Der " + "Erpel hat einen grünen Kopf, weiße Brust und kastanienbraune Flanken." + "\n\n" + "Der Schnabel ist ein Filterapparat: An seinen Rändern sitzen über hundert " + "feine Lamellen pro Seite, durch die Wasser gepresst wird, während " + "Plankton hängenbleibt. Beim Fressen schwimmen mehrere Enten oft im Kreis " + "und wirbeln so Nahrung von unten auf." + ), + kategorie_en="The One With the Spoon", + nahrung_en="Plankton and small invertebrates, filtered from the water", + lebensraum_en="Shallow, nutrient-rich water and mudflats", + stimme_en="A muffled double “tuk-tuk”", + text_en=( + "Unmistakable for the huge, spatulate bill. The drake has a green head, a " + "white breast and chestnut flanks." + "\n\n" + "The bill is a filter: its edges carry over a hundred fine lamellae per " + "side, through which water is pushed while plankton is retained. Feeding " + "birds often swim in a tight circle, stirring food up from below." + ), +), + +"pfeifente": Zusatz( + kategorie="Die Pfeifende", + nahrung="Gras und Wasserpflanzen — weidet oft an Land", + lebensraum="Küstenwiesen, flache Buchten, Überschwemmungsflächen", + stimme="Ein klarer, weit hörbarer Pfiff des Erpels: „wiiiuh“", + text=( + "Der Erpel hat einen kastanienbraunen Kopf mit cremefarbener Stirnblesse, " + "einen rosagrauen Körper und einen schwarzen Bürzel." + "\n\n" + "Sie ist unter den Enten am stärksten Grasfresser und weidet in großen " + "Trupps auf Küstenwiesen wie Gänse. Ihr Pfiff ist eines der " + "charakteristischsten Wintergeräusche an der Nordsee — man hört einen " + "Schwarm lange, bevor man ihn ausmacht." + ), + kategorie_en="The Whistler", + nahrung_en="Grass and water plants — often grazes on land", + lebensraum_en="Coastal meadows, shallow bays, flooded ground", + stimme_en="A clear, far-carrying whistle from the drake: “wheee-oo”", + text_en=( + "The drake has a chestnut head with a cream forehead blaze, a pinkish-grey " + "body and a black stern." + "\n\n" + "It is the most committed grazer among our ducks and feeds in large flocks " + "on coastal meadows like geese. Its whistle is one of the defining winter " + "sounds of the North Sea coast — you hear a flock long before you find it." + ), +), + +"knaekente": Zusatz( + kategorie="Die einzige echte Zugente", + nahrung="Kleintiere und Samen aus flachem Wasser", + lebensraum="Flache Wiesengewässer und Verlandungszonen", + stimme="Ein knarrendes Geräusch wie ein Daumen über einem Kamm", + text=( + "Der Erpel hat einen breiten weißen Überaugenstreif, der sich bis in den " + "Nacken zieht — im Prachtkleid unverwechselbar. Weibchen sind schwer von " + "Krickenten zu trennen." + "\n\n" + "Sie ist die einzige heimische Ente, die vollständig nach Afrika zieht und " + "südlich der Sahara überwintert. In Deutschland brüten nur noch wenige " + "hundert Paare in Feuchtwiesen — sie ist damit unsere am stärksten " + "gefährdete Entenart." + ), + kategorie_en="The Only True Migrant Duck", + nahrung_en="Small invertebrates and seeds from shallow water", + lebensraum_en="Shallow meadow pools and marsh edges", + stimme_en="A crackling sound like a thumb run over a comb", + text_en=( + "The drake has a broad white eyebrow sweeping back to the nape — " + "unmistakable in breeding plumage. Females are hard to separate from Teal." + "\n\n" + "It is the only duck here that migrates fully to Africa, wintering south " + "of the Sahara. Only a few hundred pairs still breed in German wet " + "meadows, making it our most threatened duck." + ), +), + +"tafelente": Zusatz( + kategorie="Die mit dem roten Kopf", + nahrung="Wasserpflanzen und Muscheln, tauchend geholt", + lebensraum="Tiefere, nährstoffreiche Seen und Teiche", + stimme="Ein leises Pfeifen; das Weibchen knarrt", + text=( + "Der Erpel hat einen kastanienroten Kopf, eine schwarze Brust und einen " + "hellgrauen Rücken — von weitem wirkt er wie in drei Blöcke geteilt." + "\n\n" + "Anders als Stockenten gründelt sie nicht, sondern taucht komplett unter, " + "meist zwei bis drei Meter tief. Ihr Bestand geht in Europa deutlich " + "zurück, und die Ursachen sind bis heute nicht abschließend geklärt — " + "vermutlich eine Mischung aus Lebensraumverlust und Prädation an den " + "Brutplätzen." + ), + kategorie_en="The One With the Red Head", + nahrung_en="Water plants and molluscs, taken by diving", + lebensraum_en="Deeper, nutrient-rich lakes and ponds", + stimme_en="A soft whistle; the female growls", + text_en=( + "The drake has a chestnut-red head, a black breast and a pale grey back — " + "from a distance it looks divided into three blocks." + "\n\n" + "Unlike Mallards it does not dabble but dives fully under, usually two to " + "three metres. Its numbers are falling markedly across Europe and the " + "reasons are still not settled — probably a mix of habitat loss and " + "predation at the breeding sites." + ), +), + +"reiherente": Zusatz( + kategorie="Die mit dem Zopf", + nahrung="Muscheln, besonders Wandermuscheln", + lebensraum="Seen, Flüsse, Stadtgewässer — auch mitten in Städten", + stimme="Ein leises Knurren, meist stumm", + text=( + "Der Erpel ist schwarzweiß wie ein Smoking, mit gelben Augen und einem " + "herabhängenden Federschopf im Nacken. Das Weibchen ist dunkelbraun mit " + "kürzerem Schopf." + "\n\n" + "Sie ist die häufigste Tauchente Mitteleuropas und hat stark von der " + "eingewanderten Wandermuschel profitiert, die ihr Hauptnahrung liefert. In " + "Städten ist sie erstaunlich zutraulich und schwimmt oft zwischen " + "Stockenten in Parkteichen." + ), + kategorie_en="The One With the Tuft", + nahrung_en="Molluscs, especially zebra mussels", + lebensraum_en="Lakes, rivers, urban water — city centres included", + stimme_en="A low growl; mostly silent", + text_en=( + "The drake is black and white like a dinner jacket, with yellow eyes and a " + "drooping tuft on the nape. The female is dark brown with a shorter tuft." + "\n\n" + "It is the commonest diving duck in central Europe and has benefited " + "greatly from the introduced zebra mussel, its main food. In cities it is " + "remarkably tame and often swims among Mallards on park ponds." + ), +), + +"bergente": Zusatz( + kategorie="Die Meerente", + nahrung="Muscheln und Krebse vom Meeresgrund", + lebensraum="Küstengewässer der Ostsee — reiner Wintergast", + stimme="Meist stumm, am Brutplatz ein raues Knarren", + text=( + "Wie eine Reiherente, aber ohne Schopf und mit hellgrauem, fein " + "gewelltem Rücken statt schwarzem. Weibchen haben einen breiten weißen " + "Fleck an der Schnabelbasis." + "\n\n" + "Sie brütet in der Tundra und überwintert in großen Schwärmen auf dem " + "offenen Meer, vor allem in der Ostsee — dort sammeln sich zeitweise " + "hunderttausende. Binnenländisch ist sie eine Rarität, und einzelne Vögel " + "unter Reiherenten zu finden gilt unter Vogelkundlern als kleine " + "Fleißaufgabe." + ), + kategorie_en="The Sea Duck", + nahrung_en="Molluscs and crustaceans from the seabed", + lebensraum_en="Baltic coastal waters — purely a winter visitor", + stimme_en="Mostly silent; a harsh croak at the breeding grounds", + text_en=( + "Like a Tufted Duck but without the tuft and with a pale, finely " + "vermiculated grey back instead of black. Females have a broad white patch " + "at the base of the bill." + "\n\n" + "It breeds in the tundra and winters in large rafts on open sea, above all " + "in the Baltic, where hundreds of thousands gather at times. Inland it is " + "a rarity, and picking one out among Tufted Ducks counts as a small badge " + "of honour among birders." + ), +), + +} +# fmt: on diff --git a/detector/protokoll.py b/detector/protokoll.py new file mode 100644 index 0000000..3ec5e11 --- /dev/null +++ b/detector/protokoll.py @@ -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 diff --git a/detector/report.py b/detector/report.py new file mode 100644 index 0000000..a09b91a --- /dev/null +++ b/detector/report.py @@ -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.") diff --git a/detector/sink.py b/detector/sink.py new file mode 100644 index 0000000..6f9806c --- /dev/null +++ b/detector/sink.py @@ -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}") diff --git a/detector/species_de.py b/detector/species_de.py new file mode 100644 index 0000000..4d3bb70 --- /dev/null +++ b/detector/species_de.py @@ -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() diff --git a/detector/species_extra.py b/detector/species_extra.py new file mode 100644 index 0000000..6fd8b8a --- /dev/null +++ b/detector/species_extra.py @@ -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} diff --git a/detector/species_info.py b/detector/species_info.py new file mode 100644 index 0000000..a371bf2 --- /dev/null +++ b/detector/species_info.py @@ -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 diff --git a/detector/species_info_en.py b/detector/species_info_en.py new file mode 100644 index 0000000..819902d --- /dev/null +++ b/detector/species_info_en.py @@ -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 diff --git a/detector/stichprobe.py b/detector/stichprobe.py new file mode 100644 index 0000000..c3147a0 --- /dev/null +++ b/detector/stichprobe.py @@ -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 diff --git a/detector/stream.py b/detector/stream.py new file mode 100644 index 0000000..e7fcd2f --- /dev/null +++ b/detector/stream.py @@ -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)) diff --git a/detector/umgebung.py b/detector/umgebung.py new file mode 100644 index 0000000..39678aa --- /dev/null +++ b/detector/umgebung.py @@ -0,0 +1,42 @@ +"""Umgebungsvariablen lesen. + +Alle Einstellungen heißen ``VOGELALBUM_``. + +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`` 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 diff --git a/detector/verteiler.py b/detector/verteiler.py new file mode 100644 index 0000000..b6805b7 --- /dev/null +++ b/detector/verteiler.py @@ -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://:8131/live MJPEG + http://:8131/bild.jpg Einzelbild + http://: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 = """ +Vogelalbum live + + +Livebild +

Der Detector verteilt sein Bild weiter — die Kamera merkt nichts davon.

+ +""".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() diff --git a/detector/warteschlange.py b/detector/warteschlange.py new file mode 100644 index 0000000..afd8498 --- /dev/null +++ b/detector/warteschlange.py @@ -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 diff --git a/detector/watcher.py b/detector/watcher.py new file mode 100644 index 0000000..534f55d --- /dev/null +++ b/detector/watcher.py @@ -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 diff --git a/docker-compose.detector.yml b/docker-compose.detector.yml new file mode 100644 index 0000000..2a678a7 --- /dev/null +++ b/docker-compose.detector.yml @@ -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: diff --git a/docker-compose.heim.yml b/docker-compose.heim.yml new file mode 100644 index 0000000..01601ab --- /dev/null +++ b/docker-compose.heim.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f347cc --- /dev/null +++ b/docker-compose.yml @@ -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 + # docker stop + # cp -a /. / + # diff -rq + # + # In dieselbe Klasse gehoeren MQTT-Topics und die Umgebungsvariablen + # laufender Anlagen: Adressen, keine Beschriftungen. + vogelalbum-data: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6cb3775 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,57 @@ +# Die Vogelalbum-Dokumentation + +[← zurück zum Überblick](../README.md) + +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 | + +--- + +Die Bilder in diesem Ordner sind Aufnahmen der Live-Instanz +, 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. diff --git a/docs/abzeichen.png b/docs/abzeichen.png new file mode 100644 index 0000000..3853dce Binary files /dev/null and b/docs/abzeichen.png differ diff --git a/docs/artseite.png b/docs/artseite.png new file mode 100644 index 0000000..3f667ee Binary files /dev/null and b/docs/artseite.png differ diff --git a/docs/betrieb.md b/docs/betrieb.md new file mode 100644 index 0000000..c85df3d --- /dev/null +++ b/docs/betrieb.md @@ -0,0 +1,160 @@ +# Betrieb + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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://:8131/live` | MJPEG, für mehrere Zuschauer | +| `http://:8131/bild.jpg` | Einzelbild | +| `http://:8131/` | Seite mit Livebild | + +### In der Web-App + +Eingetragen wird das **im Django-Admin bei der Kamera**: `Live-Adresse` auf +`http://: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-.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 + +``` + +Wer lieber selbst gestaltet, nimmt `/api/stats/` — Level, Artenzahl und die +letzte Sichtung als JSON. diff --git a/docs/datenquellen.md b/docs/datenquellen.md new file mode 100644 index 0000000..1440aff --- /dev/null +++ b/docs/datenquellen.md @@ -0,0 +1,49 @@ +# Datenquellen und Lizenzen + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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 . 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. + +--- + +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. diff --git a/docs/einstellungen.md b/docs/einstellungen.md new file mode 100644 index 0000000..9299c2a --- /dev/null +++ b/docs/einstellungen.md @@ -0,0 +1,89 @@ +# Einstellungen + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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 +``` + +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. diff --git a/docs/entwicklung.md b/docs/entwicklung.md new file mode 100644 index 0000000..dd0ea59 --- /dev/null +++ b/docs/entwicklung.md @@ -0,0 +1,74 @@ +# Entwicklung + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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. diff --git a/docs/erkennung.md b/docs/erkennung.md new file mode 100644 index 0000000..504b465 --- /dev/null +++ b/docs/erkennung.md @@ -0,0 +1,77 @@ +# Wie die Erkennung funktioniert + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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. diff --git a/docs/genauigkeit.md b/docs/genauigkeit.md new file mode 100644 index 0000000..e1f0554 --- /dev/null +++ b/docs/genauigkeit.md @@ -0,0 +1,138 @@ +# Wie gut ist das wirklich + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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. diff --git a/docs/handy.png b/docs/handy.png new file mode 100644 index 0000000..24e163e Binary files /dev/null and b/docs/handy.png differ diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..ec7b81a --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,146 @@ +# Installation + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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 — 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 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://: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. diff --git a/docs/kamera.md b/docs/kamera.md new file mode 100644 index 0000000..10c2114 --- /dev/null +++ b/docs/kamera.md @@ -0,0 +1,200 @@ +# Die Kamera anschließen + +[← zurück zum Überblick](../README.md) · [alle Kapitel](README.md) + +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. diff --git a/docs/sammlung.png b/docs/sammlung.png new file mode 100644 index 0000000..02a62e7 Binary files /dev/null and b/docs/sammlung.png differ diff --git a/docs/sichtungen.png b/docs/sichtungen.png new file mode 100644 index 0000000..0ab24e1 Binary files /dev/null and b/docs/sichtungen.png differ diff --git a/docs/startseite-dunkel.png b/docs/startseite-dunkel.png new file mode 100644 index 0000000..e9215a8 Binary files /dev/null and b/docs/startseite-dunkel.png differ diff --git a/docs/startseite.png b/docs/startseite.png new file mode 100644 index 0000000..e61e509 Binary files /dev/null and b/docs/startseite.png differ diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..de4d4f5 --- /dev/null +++ b/entrypoint.sh @@ -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 diff --git a/esp32cam/CMakeLists.txt b/esp32cam/CMakeLists.txt new file mode 100644 index 0000000..571ab79 --- /dev/null +++ b/esp32cam/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(vogelkamera) diff --git a/esp32cam/README.md b/esp32cam/README.md new file mode 100644 index 0000000..9445329 --- /dev/null +++ b/esp32cam/README.md @@ -0,0 +1,164 @@ +# 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://:8080/stream MJPEG, ein Zuschauer + http://:8080/ Vorschauseite + + http://:8081/status JSON: fps, RSSI, Temperatur, Laufzeit + http://:8081/temp Chiptemperatur als Text + http://: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://: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. + +## Im Dauerbetrieb + +Die Firmware läuft an einer Vogeltränke im Garten. Gemessen am laufenden +Detector, drei aufeinanderfolgende Minuten: + +| | Bilder/s | +|---|---| +| **ESP32-S3-CAM mit OV3660** (dieses Projekt) | **5,2** | +| Raspberry Pi Zero W mit OV5647 (zum Vergleich) | 4,4 | + +Insgesamt hat der Detector an dieser Kamera bisher über 2,8 Millionen Bilder +verarbeitet. Die Bildrate ist bandbreitenlimitiert, nicht rechenlimitiert — +deshalb der Rat zur Modulvariante mit Antennenbuchse (ESP32-S3-WROOM-1**U**) +und externer Antenne. + +**Ungetestet sind die übrigen Pinbelegungen**: Gebaut und betrieben wurde +bisher nur die S3-CAM-Variante. XIAO-S3 und AI-Thinker sind in `menuconfig` +vorgesehen, aber nie auf Hardware gelaufen. + +## Was fehlt + +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. diff --git a/esp32cam/abbilder/vogelkamera-anzeige.bin b/esp32cam/abbilder/vogelkamera-anzeige.bin new file mode 100644 index 0000000..68756a6 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-anzeige.bin differ diff --git a/esp32cam/abbilder/vogelkamera-berichtigt.bin b/esp32cam/abbilder/vogelkamera-berichtigt.bin new file mode 100644 index 0000000..adc9029 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-berichtigt.bin differ diff --git a/esp32cam/abbilder/vogelkamera-diagnose.bin b/esp32cam/abbilder/vogelkamera-diagnose.bin new file mode 100644 index 0000000..c24ded8 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-diagnose.bin differ diff --git a/esp32cam/abbilder/vogelkamera-get.bin b/esp32cam/abbilder/vogelkamera-get.bin new file mode 100644 index 0000000..2ea0cc6 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-get.bin differ diff --git a/esp32cam/abbilder/vogelkamera-mit-grund.bin b/esp32cam/abbilder/vogelkamera-mit-grund.bin new file mode 100644 index 0000000..d118c0e Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-mit-grund.bin differ diff --git a/esp32cam/abbilder/vogelkamera-netz-zuerst.bin b/esp32cam/abbilder/vogelkamera-netz-zuerst.bin new file mode 100644 index 0000000..237d5b9 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-netz-zuerst.bin differ diff --git a/esp32cam/abbilder/vogelkamera-sicher.bin b/esp32cam/abbilder/vogelkamera-sicher.bin new file mode 100644 index 0000000..087242a Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-sicher.bin differ diff --git a/esp32cam/abbilder/vogelkamera-stabil.bin b/esp32cam/abbilder/vogelkamera-stabil.bin new file mode 100644 index 0000000..7200721 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-stabil.bin differ diff --git a/esp32cam/abbilder/vogelkamera-stapel.bin b/esp32cam/abbilder/vogelkamera-stapel.bin new file mode 100644 index 0000000..7884665 Binary files /dev/null and b/esp32cam/abbilder/vogelkamera-stapel.bin differ diff --git a/esp32cam/main/CMakeLists.txt b/esp32cam/main/CMakeLists.txt new file mode 100644 index 0000000..3130c6d --- /dev/null +++ b/esp32cam/main/CMakeLists.txt @@ -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 +) diff --git a/esp32cam/main/Kconfig.projbuild b/esp32cam/main/Kconfig.projbuild new file mode 100644 index 0000000..d23d404 --- /dev/null +++ b/esp32cam/main/Kconfig.projbuild @@ -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://: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 diff --git a/esp32cam/main/idf_component.yml b/esp32cam/main/idf_component.yml new file mode 100644 index 0000000..f3c8eb0 --- /dev/null +++ b/esp32cam/main/idf_component.yml @@ -0,0 +1,4 @@ +dependencies: + espressif/esp32-camera: "^2.0.11" + idf: + version: ">=5.1" diff --git a/esp32cam/main/netz.c b/esp32cam/main/netz.c new file mode 100644 index 0000000..a0c0a20 --- /dev/null +++ b/esp32cam/main/netz.c @@ -0,0 +1,453 @@ +#include + +#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 +#include + +#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; +} diff --git a/esp32cam/main/netz.h b/esp32cam/main/netz.h new file mode 100644 index 0000000..3966283 --- /dev/null +++ b/esp32cam/main/netz.h @@ -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 +#include + +#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); diff --git a/esp32cam/main/vogelkamera.c b/esp32cam/main/vogelkamera.c new file mode 100644 index 0000000..66956d1 --- /dev/null +++ b/esp32cam/main/vogelkamera.c @@ -0,0 +1,1148 @@ +/* MJPEG-Server fuer die Kameraseite, ESP32-S3 mit Kameramodul. + * + * Das Gegenstueck zu pi/vogelkamera.py: das Geraet streamt nur, erkannt wird + * auf einer Maschine mit Rechenleistung. Gleicher Port, gleiche Pfade — der + * Detector merkt nicht, ob ein Pi oder ein ESP32 am anderen Ende haengt. + * + * http://:8080/stream MJPEG + * http://:8080/ Vorschauseite + * + * http://:8081/status JSON: fps, RSSI, Temperatur, Laufzeit + * (gleiche Felder wie pi/vogelkamera.py) + * http://:8081/temp Chiptemperatur als Text + * http://:8081/gesund 200 oder 503 + * + * ⚠ Die Diagnose liegt auf VK_PORT + 1 und nicht auf dem Hauptport -- der + * Grund steht unten bei diagnose_server_start(). Hier stand bis zum + * 09.10.2026 fuer alle sechs Wege die 8080; eine Anfrage dorthin laeuft + * waehrend des Streams stumm ins Zeitlimit und sieht aus wie eine tote + * Kamera. Genau der Irrtum, gegen den der zweite Server gebaut wurde. + * + * Drei Dinge sind aus dem Betrieb am Vogelhaus gelernt und stehen deshalb hier + * drin: + * + * 1. EIN ZUSCHAUER. Ueber WLAN ist die Bildrate bandbreitenbegrenzt, nicht + * rechenbegrenzt. Ein zweiter Client teilt sich die Funkstrecke, und der + * Detector faellt in Timeouts, ohne dass irgendwo ein Fehler auftaucht. + * Genau das ist bei der Entwicklung passiert: das Diagnosewerkzeug hat den + * Fehler erzeugt, den es messen sollte. Ein zweiter Zuschauer bekommt + * deshalb 503 statt eines halben Bildes. + * + * 2. DIAGNOSE, DIE DEN STREAM NICHT ANFASST. /status liefert Bildrate, + * Funkstaerke und Temperatur, ohne ein Bild zu holen. Am Pi fehlte das: + * dort blieb nur, das Log zu lesen oder den Stream zu stoeren. 'ping' taugt + * dafuer nicht — ICMP faellt bei gesaettigtem WLAN als Erstes aus, waehrend + * TCP weiterlaeuft. + * + * 3. DER SERVER LAEUFT AUCH OHNE KAMERA WEITER. Scheitert die Initialisierung, + * sagt /status warum. Ein Geraet im Garten, das bei einem Fehler stumm + * bleibt, kostet einen Weg nach draussen. + */ + +#include +#include +#include + +#include "esp_camera.h" +#include "esp_event.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "nvs_flash.h" + +#include "esp_ota_ops.h" + +#include "netz.h" +#include "sdkconfig.h" + +#if SOC_TEMP_SENSOR_SUPPORTED +#include "driver/temperature_sensor.h" +#endif + +static const char *TAG = "vogelkamera"; + +/* Ein abgewaehltes 'bool' in Kconfig erzeugt kein #define 0, sondern gar + * keines. Ohne diese zwei Zeilen bricht der Build ab, sobald man einen der + * Schalter ausschaltet — also im Normalfall. */ +#ifndef CONFIG_VK_HFLIP +#define CONFIG_VK_HFLIP 0 +#endif +#ifndef CONFIG_VK_VFLIP +#define CONFIG_VK_VFLIP 0 +#endif + +/* ------------------------------------------------------------------ Pins */ + +/* Die Belegung haengt am Board, nicht am Sensor. Stimmt sie nicht, scheitert + * esp_camera_init mit ESP_ERR_NOT_FOUND (0x105) — dann ist die Board-Auswahl + * im menuconfig falsch, nicht die Kamera defekt. */ +#if CONFIG_VK_BOARD_S3_WROOM +#define PIN_PWDN -1 +#define PIN_RESET -1 +#define PIN_XCLK 15 +#define PIN_SIOD 4 +#define PIN_SIOC 5 +#define PIN_D7 16 +#define PIN_D6 17 +#define PIN_D5 18 +#define PIN_D4 12 +#define PIN_D3 10 +#define PIN_D2 8 +#define PIN_D1 9 +#define PIN_D0 11 +#define PIN_VSYNC 6 +#define PIN_HREF 7 +#define PIN_PCLK 13 +#elif CONFIG_VK_BOARD_XIAO_S3 +#define PIN_PWDN -1 +#define PIN_RESET -1 +#define PIN_XCLK 10 +#define PIN_SIOD 40 +#define PIN_SIOC 39 +#define PIN_D7 48 +#define PIN_D6 11 +#define PIN_D5 12 +#define PIN_D4 14 +#define PIN_D3 16 +#define PIN_D2 18 +#define PIN_D1 17 +#define PIN_D0 15 +#define PIN_VSYNC 38 +#define PIN_HREF 47 +#define PIN_PCLK 13 +#else /* CONFIG_VK_BOARD_AI_THINKER */ +#define PIN_PWDN 32 +#define PIN_RESET -1 +#define PIN_XCLK 0 +#define PIN_SIOD 26 +#define PIN_SIOC 27 +#define PIN_D7 35 +#define PIN_D6 34 +#define PIN_D5 39 +#define PIN_D4 36 +#define PIN_D3 21 +#define PIN_D2 19 +#define PIN_D1 18 +#define PIN_D0 5 +#define PIN_VSYNC 25 +#define PIN_HREF 23 +#define PIN_PCLK 22 +#endif + +/* --------------------------------------------------------------- Zustand */ + +#define GRENZE "BILDGRENZE" +#define TEIL_KOPF "\r\n--" GRENZE "\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n" + +/* So viele Fehlversuche am Stueck gelten als kaputt. Der Sensor haengt sich + * im Feld gelegentlich auf; ein Neustart dauert zwei Sekunden, ein Weg in den + * Garten deutlich laenger. */ +#define FEHLER_BIS_NEUSTART 30 + +static SemaphoreHandle_t zuschauer_platz; /* genau einer, siehe Kopf */ +static bool kamera_bereit; +static esp_err_t kamera_fehler = ESP_OK; + +static volatile uint32_t bilder_gesamt; +static volatile int64_t letztes_bild_us; +static volatile float fps_aktuell; +static volatile bool schaut_jemand; + +#if SOC_TEMP_SENSOR_SUPPORTED +static temperature_sensor_handle_t tsens; +#endif + +static float chiptemperatur(void) +{ + float c = -1.0f; +#if SOC_TEMP_SENSOR_SUPPORTED + if (tsens != NULL && temperature_sensor_get_celsius(tsens, &c) != ESP_OK) { + c = -1.0f; + } +#endif + return c; +} + +static int funkstaerke(void) +{ + wifi_ap_record_t ap; + if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { + return ap.rssi; + } + return 0; +} + +/* ---------------------------------------------------------------- Kamera */ + +static esp_err_t kamera_start(void) +{ + camera_config_t c = { + .pin_pwdn = PIN_PWDN, + .pin_reset = PIN_RESET, + .pin_xclk = PIN_XCLK, + .pin_sccb_sda = PIN_SIOD, + .pin_sccb_scl = PIN_SIOC, + .pin_d7 = PIN_D7, + .pin_d6 = PIN_D6, + .pin_d5 = PIN_D5, + .pin_d4 = PIN_D4, + .pin_d3 = PIN_D3, + .pin_d2 = PIN_D2, + .pin_d1 = PIN_D1, + .pin_d0 = PIN_D0, + .pin_vsync = PIN_VSYNC, + .pin_href = PIN_HREF, + .pin_pclk = PIN_PCLK, + + .xclk_freq_hz = 20000000, + .ledc_timer = LEDC_TIMER_0, + .ledc_channel = LEDC_CHANNEL_0, + + /* JPEG direkt aus dem Sensor: alles andere muesste der Chip erst + * komprimieren, und die Funkstrecke ist ohnehin der Engpass. */ + .pixel_format = PIXFORMAT_JPEG, + .frame_size = (framesize_t)CONFIG_VK_FRAMESIZE, + .jpeg_quality = CONFIG_VK_JPEG_QUALITAET, + + /* Zwei Puffer im PSRAM, und immer das neueste Bild ausliefern. Ein + * Rueckstau waere hier schaedlich: wer sich verspaetet einklinkt, will + * das aktuelle Bild und nicht die Warteschlange von vor zehn Sekunden + * nachholen. */ + .fb_count = 2, + .fb_location = CAMERA_FB_IN_PSRAM, + .grab_mode = CAMERA_GRAB_LATEST, + }; + + esp_err_t err = esp_camera_init(&c); + if (err != ESP_OK) { + return err; + } + + sensor_t *s = esp_camera_sensor_get(); + if (s != NULL) { + s->set_hmirror(s, CONFIG_VK_HFLIP ? 1 : 0); + s->set_vflip(s, CONFIG_VK_VFLIP ? 1 : 0); + + /* Farbe. Der Sensor liefert von Haus aus ein flaues Bild -- + * nebeneinander mit der Pi-Kamera am Futterhaus fiel das am + * 24.09.2026 sofort auf: dort sattes Gruen, hier grau. + * + * ⚠ Das ist nicht nur Geschmack. Die Arterkennung arbeitet mit + * Farbe; ein blasser Vogel vor blassem Laub ist schwerer zu + * bestimmen als ein farbiger. + * + * Beide Bereiche gehen von -2 bis 2. Nicht ans Maximum: Zu viel + * Kontrast frisst die Zeichnung im dunklen Gefieder, und die + * braucht der Erkenner. */ + if (s->set_saturation) s->set_saturation(s, CONFIG_VK_SAETTIGUNG); + if (s->set_contrast) s->set_contrast(s, CONFIG_VK_KONTRAST); + if (s->set_brightness) s->set_brightness(s, CONFIG_VK_HELLIGKEIT); + camera_sensor_info_t *info = esp_camera_sensor_get_info(&s->id); + ESP_LOGI(TAG, "Sensor: %s", info ? info->name : "unbekannt"); + } + return ESP_OK; +} + +/* ------------------------------------------------------------- Endpunkte */ + +static const char SEITE[] = + "\n" + "Vogelkamera\n" + "\n" + "\n" + "\"Livebild\"\n" + "

Solange diese Seite offen ist, bekommt der Detector kein Bild.

\n" + "\n"; + +static esp_err_t h_seite(httpd_req_t *r) +{ + httpd_resp_set_type(r, "text/html; charset=utf-8"); + return httpd_resp_send(r, SEITE, HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t h_status(httpd_req_t *r) +{ + char json[400]; + int64_t jetzt = esp_timer_get_time(); + float alter = letztes_bild_us ? (jetzt - letztes_bild_us) / 1e6f : -1.0f; + + snprintf(json, sizeof(json), + "{\"kamera\":\"%s\",\"fehler\":\"0x%x\"," + "\"laufzeit_s\":%lld,\"bilder\":%lu,\"fps\":%.2f," + "\"letztes_bild_s\":%.1f,\"zuschauer\":%s," + "\"rssi\":%d,\"temperatur_c\":%.1f," + "\"speicher_frei\":%lu,\"psram_frei\":%u}\n", + kamera_bereit ? "bereit" : "fehlt", (unsigned)kamera_fehler, + jetzt / 1000000, (unsigned long)bilder_gesamt, fps_aktuell, + alter, schaut_jemand ? "true" : "false", + funkstaerke(), chiptemperatur(), + (unsigned long)esp_get_free_heap_size(), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + + httpd_resp_set_type(r, "application/json"); + return httpd_resp_send(r, json, HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t h_temp(httpd_req_t *r) +{ + char text[32]; + float c = chiptemperatur(); + if (c < 0) { + snprintf(text, sizeof(text), "unbekannt\n"); + } else { + snprintf(text, sizeof(text), "%.1f\xC2\xB0\x43\n", c); /* °C in UTF-8 */ + } + httpd_resp_set_type(r, "text/plain; charset=utf-8"); + return httpd_resp_send(r, text, HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t h_gesund(httpd_req_t *r) +{ + httpd_resp_set_type(r, "text/plain; charset=utf-8"); + + if (!kamera_bereit) { + httpd_resp_set_status(r, "503 Service Unavailable"); + return httpd_resp_send(r, "Kamera nicht initialisiert, siehe /status\n", + HTTPD_RESP_USE_STRLEN); + } + + /* Mit Zuschauer genuegt der Blick auf das letzte Bild. Ohne Zuschauer holt + * niemand Bilder, dann wird eines geholt — ein einzelnes, kein Stream. */ + if (schaut_jemand) { + float alter = (esp_timer_get_time() - letztes_bild_us) / 1e6f; + if (alter < 5.0f) { + return httpd_resp_send(r, "ok\n", HTTPD_RESP_USE_STRLEN); + } + httpd_resp_set_status(r, "503 Service Unavailable"); + return httpd_resp_send(r, "Zuschauer verbunden, aber kein frisches Bild\n", + HTTPD_RESP_USE_STRLEN); + } + + camera_fb_t *fb = esp_camera_fb_get(); + if (fb == NULL) { + httpd_resp_set_status(r, "503 Service Unavailable"); + return httpd_resp_send(r, "kein Bild\n", HTTPD_RESP_USE_STRLEN); + } + esp_camera_fb_return(fb); + return httpd_resp_send(r, "ok\n", HTTPD_RESP_USE_STRLEN); +} + +static esp_err_t h_stream(httpd_req_t *r) +{ + if (!kamera_bereit) { + httpd_resp_set_status(r, "503 Service Unavailable"); + httpd_resp_set_type(r, "text/plain; charset=utf-8"); + return httpd_resp_send(r, "Kamera nicht initialisiert, siehe /status\n", + HTTPD_RESP_USE_STRLEN); + } + + /* Nicht blockierend pruefen: ein zweiter Zuschauer soll eine klare Absage + * bekommen und nicht in einer Warteschlange haengen. */ + if (xSemaphoreTake(zuschauer_platz, 0) != pdTRUE) { + ESP_LOGW(TAG, "Zuschauer abgewiesen — es schaut schon jemand zu"); + httpd_resp_set_status(r, "503 Service Unavailable"); + httpd_resp_set_type(r, "text/plain; charset=utf-8"); + return httpd_resp_send( + r, + "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", + HTTPD_RESP_USE_STRLEN); + } + + schaut_jemand = true; + ESP_LOGI(TAG, "Zuschauer verbunden"); + + esp_err_t res = httpd_resp_set_type( + r, "multipart/x-mixed-replace; boundary=" GRENZE); + if (res == ESP_OK) { + httpd_resp_set_hdr(r, "Cache-Control", "no-cache, private"); + httpd_resp_set_hdr(r, "Pragma", "no-cache"); + } + + char kopf[80]; + int64_t fenster_start = esp_timer_get_time(); + uint32_t fenster_bilder = 0; + int fehler_in_folge = 0; + + /* 🔑 Bremse. Bis zum 25.09.2026 gab es keine: Die Kamera sendete, so + * schnell die Funkstrecke hergab -- gemessen 16 Bilder/s -- und der + * Detector las, so schnell sie kamen. Das war kein eingestellter Wert, + * sondern schlicht das Maximum. + * + * ⚠️ Das kostet doppelt: Dauerfunk heizt den Chip, und bei schwachem + * Empfang saettigt es die Strecke. Am selben Tag stand die Traenke bei + * 78,7 °C in der Sonne. + * + * Und gebraucht wird es nicht: eine echte Kohlmeise war 29 + * Sekunden da. Bei fuenf Bildern/s sind das 145 Bilder, und der + * Detector bestimmt ohnehin nur jedes sechste. + * + * 0 heisst: keine Bremse, so schnell wie moeglich. */ + const int64_t mindestabstand_us = + CONFIG_VK_MAX_FPS > 0 ? 1000000 / CONFIG_VK_MAX_FPS : 0; + int64_t letzte_sendung = 0; + + while (res == ESP_OK) { + if (mindestabstand_us > 0) { + int64_t wartet = mindestabstand_us - + (esp_timer_get_time() - letzte_sendung); + if (wartet > 0) { + /* ⚠️ vTaskDelay, nicht busy-wait: Die Pause soll den Chip + * entlasten, nicht ihn beschaeftigen. Andere Aufgaben -- + * unter anderem der Diagnoseserver -- kommen dadurch dran. */ + vTaskDelay(pdMS_TO_TICKS((wartet + 999) / 1000)); + } + } + letzte_sendung = esp_timer_get_time(); + + camera_fb_t *fb = esp_camera_fb_get(); + if (fb == NULL) { + ESP_LOGW(TAG, "kein Bild vom Sensor"); + if (++fehler_in_folge >= FEHLER_BIS_NEUSTART) { + ESP_LOGE(TAG, "%d Fehlversuche am Stueck — Neustart", + fehler_in_folge); + esp_restart(); + } + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + fehler_in_folge = 0; + + int n = snprintf(kopf, sizeof(kopf), TEIL_KOPF, (unsigned)fb->len); + res = httpd_resp_send_chunk(r, kopf, n); + if (res == ESP_OK) { + res = httpd_resp_send_chunk(r, (const char *)fb->buf, fb->len); + } + esp_camera_fb_return(fb); + + bilder_gesamt++; + fenster_bilder++; + letztes_bild_us = esp_timer_get_time(); + + /* Bildrate ueber ein Fenster von fuenf Sekunden. Ein Momentanwert + * schwankt zu stark, um daraus etwas zu lesen. */ + int64_t seit = letztes_bild_us - fenster_start; + if (seit > 5000000) { + fps_aktuell = fenster_bilder * 1e6f / (float)seit; + fenster_start = letztes_bild_us; + fenster_bilder = 0; + } + } + + /* Kein Fehler: der Detector hat aufgelegt oder das WLAN hat gehustet. */ + ESP_LOGI(TAG, "Zuschauer getrennt"); + fps_aktuell = 0.0f; + schaut_jemand = false; + xSemaphoreGive(zuschauer_platz); + return ESP_OK; +} + +/* ---------------------------------------------------------- Diagnose */ +/* + * ⚠ Ein zweiter Server, und zwar aus einem handfesten Grund: Der Webserver + * im ESP32 arbeitet Anfragen mit EINEM Faden ab. Der Stream gibt den nie + * zurueck -- er laeuft ja endlos. Solange also jemand zuschaut, kommt + * niemand mehr an /status, /temp oder /gesund. + * + * Genau dann braucht man sie aber: Die Doku nennt /status ausdruecklich + * den richtigen Weg, die Kamera im Betrieb zu pruefen. Am 23.09.2026 hat + * sich gezeigt, dass das nicht stimmte -- die Kamera wirkte tot, waehrend + * sie brav streamte. + * + * Die Diagnose liegt deshalb auf einem eigenen Port mit eigenem Faden. + */ +/* Meldet einen Weg an und sagt Bescheid, wenn es nicht klappt. + * + * 🔑 Ohne diese Zeile stand am 24.09.2026 ein Endpunkt einfach nicht zur + * Verfuegung, ohne dass irgendwo etwas davon stand. Ein Fehler, der sich + * nicht meldet, kostet Stunden -- und hier waren es OTA-Updates. */ +static void weg_anmelden(httpd_handle_t srv, const httpd_uri_t *weg) +{ + esp_err_t r = httpd_register_uri_handler(srv, weg); + if (r != ESP_OK) { + ESP_LOGE(TAG, "Weg '%s' NICHT angemeldet: %s — er wird 404 liefern", + weg->uri, esp_err_to_name(r)); + } +} + +static httpd_handle_t diagnose_server_start(void) +{ + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = CONFIG_VK_PORT + 1; + cfg.ctrl_port = 32770; /* nicht derselbe wie beim Hauptserver */ + cfg.max_open_sockets = 3; + cfg.lru_purge_enable = true; + + httpd_handle_t srv = NULL; + if (httpd_start(&srv, &cfg) != ESP_OK) return NULL; + + static const httpd_uri_t wege[] = { + {.uri = "/status", .method = HTTP_GET, .handler = h_status}, + {.uri = "/temp", .method = HTTP_GET, .handler = h_temp}, + {.uri = "/gesund", .method = HTTP_GET, .handler = h_gesund}, + }; + for (size_t i = 0; i < sizeof(wege) / sizeof(wege[0]); i++) { + weg_anmelden(srv, &wege[i]); + } + return srv; +} + +/* ------------------------------------------------------------- Update */ +/* + * 🔑 Ohne diesen Endpunkt muesste man das Geraet zum Aktualisieren aus dem + * Garten holen, aufschrauben und ans Kabel haengen. Bei einem Bausatz fuer + * andere Leute waere es damit ganz vorbei. + * + * Aufruf: + * curl -X POST --data-binary @vogelkamera.bin \ + * -H "X-Vogelkamera-Token: " http://:8080/update + */ + +static bool token_stimmt(httpd_req_t *req) +{ + char wert[64] = {0}; + if (httpd_req_get_hdr_value_str(req, "X-Vogelkamera-Token", + wert, sizeof(wert)) != ESP_OK) { + return false; + } + /* Laengenvergleich zuerst, dann Inhalt -- strcmp bricht beim ersten + * Unterschied ab und verraet ueber die Laufzeit etwas ueber das + * Geheimnis. Im Heimnetz akademisch, aber es kostet nichts. */ + const char *soll = netz_ota_token(); + size_t a = strlen(wert), b = strlen(soll); + if (a != b) return false; + unsigned diff = 0; + for (size_t i = 0; i < a; i++) diff |= (unsigned)(wert[i] ^ soll[i]); + return diff == 0; +} + +static esp_err_t update_handler(httpd_req_t *req) +{ + if (!token_stimmt(req)) { + ESP_LOGW(TAG, "Update ohne gueltiges Token abgelehnt"); + httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Token fehlt oder falsch"); + return ESP_FAIL; + } + + const esp_partition_t *ziel = esp_ota_get_next_update_partition(NULL); + if (!ziel) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "keine OTA-Partition"); + return ESP_FAIL; + } + ESP_LOGI(TAG, "Update nach %s, %d Bytes erwartet", ziel->label, + req->content_len); + + esp_ota_handle_t ota; + if (esp_ota_begin(ziel, req->content_len, &ota) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "ota_begin fehlgeschlagen"); + return ESP_FAIL; + } + + char *puffer = malloc(2048); + if (!puffer) { + esp_ota_abort(ota); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "kein Speicher"); + return ESP_FAIL; + } + + /* ⚠ Am 24.09.2026 blieb genau diese Schleife stehen: Nach etwa elf + * Sekunden kam nichts mehr an, und das 'continue' drehte sich ewig + * weiter. Nach aussen sah das aus wie ein haengender Upload, im + * Protokoll wie eine endlose Reihe 'recv : 11'. + * + * Drei Dinge dagegen: + * - eine Obergrenze fuer Leerlaeufe am Stueck, danach sauberer Abbruch + * - eine Fortschrittszeile, damit man SIEHT wo es stockt + * - Geduld statt Eile (recv_wait_timeout steht oben bei der Config) */ + const int LEERLAUF_MAX = 20; /* × recv_wait_timeout */ + int leerlauf = 0; + int offen = req->content_len; + int gesamt = req->content_len; + int naechste_meldung = gesamt - 102400; + + while (offen > 0) { + int will = offen < 2048 ? offen : 2048; + int gelesen = httpd_req_recv(req, puffer, will); + + if (gelesen == HTTPD_SOCK_ERR_TIMEOUT) { + if (++leerlauf >= LEERLAUF_MAX) { + ESP_LOGE(TAG, "Update steht seit %d Leerlaeufen bei %d von %d " + "Bytes — Abbruch", leerlauf, gesamt - offen, + gesamt); + esp_ota_abort(ota); free(puffer); + httpd_resp_send_err(req, HTTPD_408_REQ_TIMEOUT, + "Sender liefert nichts mehr"); + return ESP_FAIL; + } + continue; + } + leerlauf = 0; + + if (gelesen <= 0) { + ESP_LOGE(TAG, "Verbindung abgerissen bei %d von %d Bytes", + gesamt - offen, gesamt); + esp_ota_abort(ota); free(puffer); + return ESP_FAIL; + } + if (esp_ota_write(ota, puffer, gelesen) != ESP_OK) { + esp_ota_abort(ota); free(puffer); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Schreiben fehlgeschlagen"); + return ESP_FAIL; + } + offen -= gelesen; + + if (offen <= naechste_meldung) { + ESP_LOGI(TAG, "Update: %d von %d Bytes", gesamt - offen, gesamt); + naechste_meldung -= 102400; + } + } + free(puffer); + + /* esp_ota_end prueft die Pruefsumme. Ein halb uebertragenes Abbild + * fliegt hier raus, bevor es Startpartition werden kann -- das Geraet + * laeuft dann einfach auf der alten Fassung weiter. */ + if (esp_ota_end(ota) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Abbild ungueltig"); + return ESP_FAIL; + } + + ESP_ERROR_CHECK(esp_ota_set_boot_partition(ziel)); + httpd_resp_sendstr(req, "uebernommen, Kamera startet neu\n"); + ESP_LOGI(TAG, "Update uebernommen, Neustart"); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + return ESP_OK; +} + +/* ⚠ Weg zurueck. Ohne den bleibt ein Geraet, das den Besitzer wechselt oder + * mit dem der Nutzer umzieht, fuer immer im alten Netz gefangen -- und man + * muesste die Firmware neu aufspielen, um es zu loesen. */ +static esp_err_t wlan_vergessen_handler(httpd_req_t *req) +{ + netz_zugang_loeschen(); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_sendstr(req, + "" + "" + "

WLAN vergessen

" + "

Die Kamera startet neu und öffnet ihr Einrichtungsnetz.

"); + vTaskDelay(pdMS_TO_TICKS(1200)); + esp_restart(); + return ESP_OK; +} + +static httpd_handle_t server_start(void) +{ + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = CONFIG_VK_PORT; + cfg.ctrl_port = CONFIG_VK_PORT + 1000; + cfg.lru_purge_enable = true; + cfg.stack_size = 8192; + + /* 🔑 DAS hat den 24.09.2026 gekostet. Die Vorgabe sind ACHT Handler. + * Unten werden NEUN angemeldet -- und der neunte war /update. Er wurde + * stillschweigend abgewiesen: kein Absturz, keine Warnung, nur ein + * 404 beim Aufspielen. OTA hat auf diesem Geraet also nie funktioniert, + * ausgerechnet die Eigenschaft, auf der ausdruecklich + * bestanden hatte ("alle Geraete muessen OTA koennen"). + * + * Reichlich Luft, damit der naechste Endpunkt nicht wieder der eine + * zu viel ist. */ + cfg.max_uri_handlers = 16; + + /* ⚠ Fuers Update: Die Vorgabe sind 5 Sekunden Geduld beim Empfangen. + * Waehrend esp_ota_write einen Flash-Block loescht, liest niemand vom + * Socket -- bei knapper Funkstrecke reicht das, um die Uebertragung + * abreissen zu lassen. 20 Sekunden kosten nichts und halten durch. */ + cfg.recv_wait_timeout = 20; + cfg.send_wait_timeout = 20; + + /* Der Stream haelt seinen Socket dauerhaft; /status braucht daneben + * Platz. + * + * ⚠ Und das Update braucht ebenfalls welchen: Mit nur vier Plaetzen + * und eingeschaltetem Aufraeumen kann ausgerechnet die + * Update-Verbindung weggeworfen werden -- sie hat waehrend eines + * Flash-Schreibvorgangs am laengsten nichts gesagt und gilt damit als + * die aelteste. Abgebrochene Versuche belegen ausserdem Plaetze, bis + * TCP sie aufgibt. */ + cfg.max_open_sockets = 7; + + httpd_handle_t srv = NULL; + if (httpd_start(&srv, &cfg) != ESP_OK) { + return NULL; + } + + const httpd_uri_t routen[] = { + {.uri = "/", .method = HTTP_GET, .handler = h_seite}, + {.uri = "/index.html", .method = HTTP_GET, .handler = h_seite}, + {.uri = "/stream", .method = HTTP_GET, .handler = h_stream}, + {.uri = "/status", .method = HTTP_GET, .handler = h_status}, + {.uri = "/temp", .method = HTTP_GET, .handler = h_temp}, + {.uri = "/gesund", .method = HTTP_GET, .handler = h_gesund}, + {.uri = "/health", .method = HTTP_GET, .handler = h_gesund}, + }; + /* ⚠ Rueckgabewert pruefen. Genau das Verschweigen war der Fehler: + * httpd_register_uri_handler meldet ESP_ERR_HTTPD_HANDLERS_FULL und + * laeuft weiter, als waere nichts gewesen. */ + for (size_t i = 0; i < sizeof(routen) / sizeof(routen[0]); i++) { + weg_anmelden(srv, &routen[i]); + } + httpd_uri_t vergessen = { .uri = "/wlan-vergessen", .method = HTTP_GET, + .handler = wlan_vergessen_handler }; + httpd_uri_t update = { .uri = "/update", .method = HTTP_POST, + .handler = update_handler }; + weg_anmelden(srv, &vergessen); + weg_anmelden(srv, &update); + + return srv; +} + +/* --------------------------------------------------------- Einrichtung */ +/* + * Die WLAN-Zugangsdaten stehen NICHT im Quelltext. Wer das Geraet auspackt, + * soll es ins eigene Netz bringen koennen, ohne die Firmware neu zu bauen -- + * sonst ist der Bausatz fuer alle ausser dem Entwickler unbrauchbar. + * + * Der Weg ist aus Hey Fred uebernommen und dort erprobt: Liegt nichts im + * Speicher, macht das Geraet ein eigenes, offenes WLAN auf und zeigt ein + * Formular. Danach speichert es dauerhaft und startet neu. + */ + +static void url_entschluesseln(char *s) +{ + char *lese = s, *schreibe = s; + while (*lese) { + if (*lese == '%' && lese[1] && lese[2]) { + char hex[3] = { lese[1], lese[2], '\0' }; + *schreibe++ = (char)strtol(hex, NULL, 16); + lese += 3; + } else if (*lese == '+') { + *schreibe++ = ' '; + lese++; + } else { + *schreibe++ = *lese++; + } + } + *schreibe = '\0'; +} + +static void html_sicher(const char *quelle, char *ziel, size_t max) +{ + size_t k = 0; + for (size_t i = 0; quelle[i] && k + 7 < max; i++) { + switch (quelle[i]) { + case '<': k += snprintf(ziel + k, max - k, "<"); break; + case '>': k += snprintf(ziel + k, max - k, ">"); break; + case '&': k += snprintf(ziel + k, max - k, "&"); break; + case '"': k += snprintf(ziel + k, max - k, """); break; + default: ziel[k++] = quelle[i]; + } + } + ziel[k] = '\0'; +} + +#define FUNDE_MAX 20 + +static const char EINRICHT_KOPF[] = + "" + "" + "Vogelkamera einrichten

Vogelkamera einrichten

"; + +static esp_err_t formular_handler(httpd_req_t *req) +{ + netz_fund_t *funde = calloc(FUNDE_MAX, sizeof(netz_fund_t)); + if (!funde) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "kein Speicher"); + return ESP_FAIL; + } + /* ⚠ Aus dem Vorrat, NICHT frisch suchen: Eine Suche wechselt den + * Funkkanal und wirft dabei den Besucher aus dem Einrichtungsnetz. + * Sein Absenden kaeme dann nie an. Gesucht wird einmal beim Oeffnen + * des Netzes, bevor jemand da ist. */ + int n = netz_funde_holen(funde, FUNDE_MAX); + + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_send_chunk(req, EINRICHT_KOPF, HTTPD_RESP_USE_STRLEN); + + char zeile[512]; + + /* ⚠ Warum die Verbindung scheiterte, gehoert HIERHER und nicht nur ins + * serielle Protokoll. Am 23.09.2026 stand jemand im Buero vor einem + * Geraet, das sich nicht verbinden wollte -- und kam an den Grund + * nicht heran, weil dieses Board an beiden Buchsen chipeigenes USB + * hat: Beim Reset verschwindet der Anschluss, und das Mitlesen stirbt + * mit. Ein Geraet, das seinen Fehler nur ueber ein Kabel verraet, das + * es gar nicht gibt, verraet ihn gar nicht. */ + /* 🔑 Die eine Zeile, die den Abend des 23.09.2026 haette sparen + * koennen: Steht hier ein Name, kommt das Speichern an und es hakt am + * Verbinden. Steht "nichts gespeichert", hakt es davor. Zwei voellig + * verschiedene Baustellen -- und ohne diese Anzeige raet man. */ + const char *gemerkt = netz_gespeicherte_ssid(); + snprintf(zeile, sizeof(zeile), + "

" + "Gespeichert: %s

", + gemerkt[0] ? "#e8f5e9" : "#fff4e5", + gemerkt[0] ? "#a5d6a7" : "#ffcc80", + gemerkt[0] ? gemerkt : "nichts"); + httpd_resp_sendstr_chunk(req, zeile); + + int grund = netz_letzter_grund(); + if (grund != 0) { + const char *klartext; + switch (grund) { + case 15: case 204: + klartext = "Passwort stimmt nicht"; break; + case 201: + klartext = "Netz nicht gefunden - falscher Name, oder 5 GHz " + "(die Kamera kann nur 2,4 GHz)"; break; + case 2: case 3: case 4: + klartext = "Verschluesselung passt nicht"; break; + case 205: + klartext = "Zugangspunkt hat die Verbindung beendet"; break; + default: + klartext = "siehe Espressif-Liste der WLAN-Gruende"; break; + } + snprintf(zeile, sizeof(zeile), + "

" + "Letzter Versuch gescheitert.
Grund %d: %s" + "
Abrisse insgesamt: %d

", + grund, klartext, netz_trennungen()); + httpd_resp_sendstr_chunk(req, zeile); + } + if (n > 0) { + httpd_resp_sendstr_chunk(req, + "

Wähle dein WLAN und gib das Passwort ein.

" + "
" + ""); + } else { + /* Kein Fund ist kein Grund aufzugeben -- versteckte Netze und + * schlechter Empfang gibt es wirklich. */ + httpd_resp_sendstr_chunk(req, + "

Es wurde kein Netz gefunden. Du kannst den Namen " + "von Hand eingeben.

" + "" + "" + ""); + } + + httpd_resp_sendstr_chunk(req, + "" + "" + "
"); + + /* ⚠ Der Schluessel gehoert AUF DIESE Seite, nicht erst auf die + * Bestaetigung danach. Wer ihn nachschlagen will, hat sein WLAN ja + * laengst eingerichtet -- muesste er zum Nachsehen erst wieder alles + * neu eintragen, waere das eine Zumutung. So genuegt es, das Geraet + * kurz in die Einrichtung zu schicken und hier nachzulesen. */ + snprintf(zeile, sizeof(zeile), + "

Schlüssel für " + "Updates über WLAN:

" + "%s", + netz_ota_token()); + httpd_resp_sendstr_chunk(req, zeile); + + httpd_resp_send_chunk(req, NULL, 0); + + free(funde); + return ESP_OK; +} + +static esp_err_t zugang_uebernehmen(httpd_req_t *req, char *rumpf); + +/* ⚠ Auch per GET erreichbar, und das ist kein Schoenheitsfehler: + * + * Am 23.09.2026 kam das abgesendete Formular nie beim Geraet an -- weder + * vom Handy noch vom Mac -- und wir konnten stundenlang nicht + * unterscheiden, ob das Absenden scheitert oder das Speichern. Mit einem + * Aufruf als Adresse laesst sich genau das trennen: + * + * http://192.168.4.1/speichern?ssid=MeinNetz&pass=geheim + * + * Es ist ausserdem der Weg, der bleibt, wenn ein Browser sich quer + * stellt. Im Einrichtungsnetz ist nichts zu holen, was das Formular + * nicht ohnehin entgegennimmt. + */ +static esp_err_t speichern_get_handler(httpd_req_t *req) +{ + char rumpf[256] = {0}; + size_t laenge = httpd_req_get_url_query_len(req) + 1; + if (laenge <= 1 || laenge > sizeof(rumpf)) { + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_sendstr(req, + "" + "" + "

So aufrufen:
/speichern?ssid=NETZ&pass=PASSWORT"); + return ESP_OK; + } + httpd_req_get_url_query_str(req, rumpf, laenge); + return zugang_uebernehmen(req, rumpf); +} + +static esp_err_t speichern_handler(httpd_req_t *req) +{ + char rumpf[256] = {0}; + int laenge = req->content_len < (int)sizeof(rumpf) - 1 + ? req->content_len : (int)sizeof(rumpf) - 1; + int gelesen = httpd_req_recv(req, rumpf, laenge); + if (gelesen <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "nichts empfangen"); + return ESP_FAIL; + } + rumpf[gelesen] = '\0'; + return zugang_uebernehmen(req, rumpf); +} + +/* Der gemeinsame Teil von POST und GET. */ +static esp_err_t zugang_uebernehmen(httpd_req_t *req, char *rumpf) +{ + char ssid[NETZ_SSID_MAX + 1] = {0}; + char pass[NETZ_PASS_MAX + 1] = {0}; + httpd_query_key_value(rumpf, "ssid", ssid, sizeof(ssid)); + httpd_query_key_value(rumpf, "pass", pass, sizeof(pass)); + url_entschluesseln(ssid); + url_entschluesseln(pass); + + if (ssid[0] == '\0') { + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_sendstr(req, "" + "

Es fehlt der WLAN-Name. Zurück"); + return ESP_OK; + } + + esp_err_t fehler = ESP_OK; + if (!netz_zugang_speichern(ssid, pass, &fehler)) { + /* ⚠ Sichtbar scheitern statt still. Vorher riss das Geraet hier + * ab, der Browser wartete ewig, und niemand erfuhr warum. */ + char meldung[512]; + snprintf(meldung, sizeof(meldung), + "" + "" + "" + "

Speichern fehlgeschlagen

" + "

Die Kamera konnte die Zugangsdaten nicht ablegen.

" + "

Grund: %s (0x%x)

" + "

Zurück

", + esp_err_to_name(fehler), (unsigned)fehler); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_sendstr(req, meldung); + return ESP_OK; + } + + char sicher[208]; + html_sicher(ssid, sicher, sizeof(sicher)); + /* Grosszuegig: fester Text, SSID (bis 208 nach Maskierung) und das + * 48-stellige Geheimnis. Bei 1024 hat der Uebersetzer zu Recht + * gewarnt, dass es abgeschnitten werden koennte. */ + char antwort[2048]; + snprintf(antwort, sizeof(antwort), + "" + "" + "" + "

Gespeichert

" + "

Die Kamera startet neu und verbindet sich mit %s.

" + "

Dieses Netz verschwindet gleich — das ist " + "richtig so. Verbinde dein Handy wieder mit dem gewohnten WLAN.

" + "

Klappt die Anmeldung nicht, kommt die Kamera " + "von selbst wieder mit diesem Einrichtungsnetz zurück.

" + "
" + "

Schlüssel für Updates

" + "

Notiere ihn. Mit ihm lässt sich die " + "Kamera später über WLAN aktualisieren, ohne sie " + "abzubauen.

" + "%s" + "

Verloren? Dann die Kamera " + "über /wlan-vergessen zurücksetzen — hier " + "steht er wieder.

", + sicher, netz_ota_token()); + httpd_resp_set_type(req, "text/html; charset=utf-8"); + httpd_resp_sendstr(req, antwort); + free(antwort); + + /* Kurz warten, damit die Antwort noch rausgeht -- ein Neustart mitten + * im Senden laesst den Nutzer vor einer leeren Seite sitzen. */ + vTaskDelay(pdMS_TO_TICKS(1200)); + esp_restart(); + return ESP_OK; +} + +static httpd_handle_t einrichtung_server_start(void) +{ + httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); + cfg.server_port = 80; /* Im Einrichtungsnetz der uebliche Port */ + cfg.ctrl_port = 32769; + cfg.lru_purge_enable = true; + + /* 🔑 DAS war der Fehler, der den 23.09.2026 gekostet hat. + * + * Die Vorgabe sind 4 KB Stapelspeicher. Der Speichern-Handler legte + * dort 2,5 KB ab (Anfrage, maskierte SSID, Antwortseite) -- dazu das, + * was snprintf selbst braucht. Ergebnis: Stapelueberlauf, und zwar + * genau beim Absenden des Formulars. + * + * Was man davon sah: Die Seite liess sich oeffnen (der Handler dafuer + * ist sparsam), aber das Abschicken warf das Geraet in einen + * Neustart -- ohne Fehlermeldung, ohne gespeicherte Daten. Von aussen + * sah es aus, als kaeme die Anfrage nie an. + * + * Der Hauptserver hatte die 8 KB von Anfang an. Nur dieser hier nicht. + */ + cfg.stack_size = 8192; + + httpd_handle_t srv = NULL; + if (httpd_start(&srv, &cfg) != ESP_OK) return NULL; + + httpd_uri_t formular = { .uri = "/", .method = HTTP_GET, + .handler = formular_handler }; + httpd_uri_t speichern = { .uri = "/speichern", .method = HTTP_POST, + .handler = speichern_handler }; + httpd_uri_t speichern_get = { .uri = "/speichern", .method = HTTP_GET, + .handler = speichern_get_handler }; + httpd_register_uri_handler(srv, &formular); + httpd_register_uri_handler(srv, &speichern); + httpd_register_uri_handler(srv, &speichern_get); + return srv; +} + +/* ------------------------------------------------------------------ main */ + +void app_main(void) +{ + esp_err_t nvs = nvs_flash_init(); + if (nvs == ESP_ERR_NVS_NO_FREE_PAGES || nvs == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ESP_ERROR_CHECK(nvs_flash_init()); + } + + /* ⚠ Das Geheimnis JETZT erzeugen -- vor allem, was mit Funk zu tun + * hat. Es anzulegen heisst, in den Flash zu schreiben, und dabei wird + * der Befehlszwischenspeicher kurz abgeschaltet. Das waehrend eines + * laufenden Zugangspunkts zu tun, waehrend ein Handy verbunden ist und + * nebenher nach Netzen gesucht wird, ist grob fahrlaessig. + * + * Genau so stand es am 23.09.2026 zuerst hier: erzeugt beim Anzeigen + * des Einrichtungsformulars. Danach fand die Kamera ihr WLAN nicht + * mehr wieder. Ob es die Ursache war, liess sich aus der Ferne nicht + * beweisen -- aber der Zeitpunkt war so oder so falsch gewaehlt. + * + * Hier ist es harmlos: kein Funk aktiv, nichts verbunden. Danach + * liegt es im Zwischenspeicher und wird nur noch gelesen. */ + ESP_LOGW(TAG, "Schluessel fuer Updates: %s", netz_ota_token()); + + zuschauer_platz = xSemaphoreCreateMutex(); + +#if SOC_TEMP_SENSOR_SUPPORTED + temperature_sensor_config_t ts = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); + if (temperature_sensor_install(&ts, &tsens) == ESP_OK) { + temperature_sensor_enable(tsens); + } +#endif + + /* 🔑 Netz ZUERST, Kamera danach -- und das ist nicht Geschmackssache. + * + * Bis zum 23.09.2026 stand es andersherum. Dann liess sich die Kamera + * einen Abend lang nicht einrichten: Die Seite kam, das Abschicken + * erreichte das Geraet aber nie, und gespeichert wurde nichts. + * + * Der entscheidende Hinweis kam aus der Praxis: Bei zwei anderen + * Pixelmatrix laeuft dieselbe netz.c seit Monaten ohne Murren. Der + * Unterschied zwischen ihnen und dieser Kiste ist genau eine Sache -- + * die Kamera. Ihr Treiber belegt grosse Puffer und eigene + * Uebertragungskanaele, und das alles lief hier schon, bevor das WLAN + * ueberhaupt anfing. + * + * Im Einrichtungsmodus wird sie jetzt gar nicht erst gestartet. Dort + * gibt es nichts zu filmen, und der Weg ist damit derselbe wie bei + * Fred -- der nachweislich funktioniert. */ + netz_init(); + + /* Zwei Wege, und die Reihenfolge ist Absicht: Erst versuchen wir das + * gespeicherte Netz. Nur wenn das scheitert -- oder nie eines da war -- + * machen wir die Einrichtung auf. So kommt ein Geraet im Garten nach + * einem Router-Neustart von selbst zurueck, statt als offener + * Zugangspunkt dazustehen. */ + bool verbunden = netz_zugang_vorhanden() && netz_verbinden(); + + if (!verbunden) { + ESP_LOGW(TAG, "Keine Verbindung — Einrichtung wird geoeffnet"); + netz_setup_ap_start(); + + /* Jetzt suchen, solange garantiert niemand verbunden ist. */ + netz_funde_auffrischen(); + + if (einrichtung_server_start() == NULL) { + ESP_LOGE(TAG, "Einrichtungsserver startet nicht — Neustart"); + esp_restart(); + } + ESP_LOGI(TAG, "WLAN '%s' offen, Formular auf http://192.168.4.1/", + netz_setup_ssid()); + return; /* Kamera bleibt aus -- hier wird nichts gefilmt. */ + } + + /* Kein Stromsparen: Der Empfaenger darf zwischen zwei Bildern nicht + * schlafen, sonst ruckelt der Stream. Die Bildrate ist hier der Zweck + * des Geraets, nicht die Batterielaufzeit. */ + esp_wifi_set_ps(WIFI_PS_NONE); + + /* Erst jetzt die Kamera: Das Netz steht, die Einrichtung ist vorbei. */ + kamera_fehler = kamera_start(); + kamera_bereit = (kamera_fehler == ESP_OK); + if (!kamera_bereit) { + /* Bewusst kein Abbruch: der Server soll laufen, damit /status die + * Ursache nennt. Sonst steht man im Garten und raet. */ + ESP_LOGE(TAG, "Kamera nicht bereit: %s (0x%x). Falsche Board-Auswahl " + "im menuconfig?", esp_err_to_name(kamera_fehler), + (unsigned)kamera_fehler); + } + + if (server_start() == NULL) { + ESP_LOGE(TAG, "HTTP-Server startet nicht — Neustart"); + esp_restart(); + } + + /* Kein Abbruch, wenn die Diagnose nicht startet: Der Stream ist der + * Zweck des Geraets. Ohne Diagnose ist es unbequem, ohne Stream + * nutzlos. */ + if (diagnose_server_start() == NULL) { + ESP_LOGW(TAG, "Diagnose-Server startet nicht — /status nur auf %d", + CONFIG_VK_PORT); + } + + ESP_LOGI(TAG, "Bereit: http://%s:%d/stream", netz_ip(), CONFIG_VK_PORT); + ESP_LOGI(TAG, "Diagnose: http://%s:%d/status", netz_ip(), CONFIG_VK_PORT + 1); + +} diff --git a/esp32cam/ota.sh b/esp32cam/ota.sh new file mode 100755 index 0000000..07a77d1 --- /dev/null +++ b/esp32cam/ota.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Firmware über Funk aufspielen. Kein Kabel, kein Aufschrauben. +# +# ./ota.sh vogelkamera.local +# +# 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 [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)" diff --git a/esp32cam/partitions.csv b/esp32cam/partitions.csv new file mode 100644 index 0000000..acedbe4 --- /dev/null +++ b/esp32cam/partitions.csv @@ -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 diff --git a/esp32cam/sdkconfig.defaults b/esp32cam/sdkconfig.defaults new file mode 100644 index 0000000..cefefb0 --- /dev/null +++ b/esp32cam/sdkconfig.defaults @@ -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 diff --git a/homeassistant/README.md b/homeassistant/README.md new file mode 100644 index 0000000..c1823a5 --- /dev/null +++ b/homeassistant/README.md @@ -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_` | 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. diff --git a/homeassistant/bruecke.py b/homeassistant/bruecke.py new file mode 100644 index 0000000..c1fc223 --- /dev/null +++ b/homeassistant/bruecke.py @@ -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_``, 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_. + + ⚠️ 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_`` 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()) diff --git a/pi/README.md b/pi/README.md new file mode 100644 index 0000000..39f317f --- /dev/null +++ b/pi/README.md @@ -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://:8080/stream` | der MJPEG-Stream — das kommt in `VOGELALBUM_SOURCE` | +| `http://:8080/` | Vorschauseite im Browser | +| `http://:8080/status` | Bildrate, Funkstärke, Temperatur, Laufzeit als JSON | +| `http://:8080/temp` | CPU-Temperatur | +| `http://: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://: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://: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://: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. diff --git a/pi/vogelkamera.py b/pi/vogelkamera.py new file mode 100644 index 0000000..8999b80 --- /dev/null +++ b/pi/vogelkamera.py @@ -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://:8080/ Vorschauseite + http://:8080/stream MJPEG -- nur ein Zuschauer, der zweite bekommt 503 + http://:8080/snapshot ein einzelnes Bild + http://:8080/status JSON: Bildrate, Funkstaerke, Temperatur + http://:8080/temp Chiptemperatur als Text + http://: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""" + + + + +Vogelkamera + + + +

🐦 Vogelkamera

+Live Stream + + + +""" + 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) \ No newline at end of file diff --git a/pi/vogelkamera.service b/pi/vogelkamera.service new file mode 100644 index 0000000..48594a8 --- /dev/null +++ b/pi/vogelkamera.service @@ -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 diff --git a/requirements-detector.txt b/requirements-detector.txt new file mode 100644 index 0000000..5cb26e2 --- /dev/null +++ b/requirements-detector.txt @@ -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 diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..36807a8 --- /dev/null +++ b/requirements-web.txt @@ -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 diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..098c732 --- /dev/null +++ b/scripts/benchmark.py @@ -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 .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()) diff --git a/scripts/fetch_models.sh b/scripts/fetch_models.sh new file mode 100755 index 0000000..f30db86 --- /dev/null +++ b/scripts/fetch_models.sh @@ -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" diff --git a/scripts/fetch_testdata.sh b/scripts/fetch_testdata.sh new file mode 100755 index 0000000..3e4b00e --- /dev/null +++ b/scripts/fetch_testdata.sh @@ -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" diff --git a/scripts/setup_detector_host.sh b/scripts/setup_detector_host.sh new file mode 100755 index 0000000..fec3389 --- /dev/null +++ b/scripts/setup_detector_host.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Richtet einen frischen Debian/Ubuntu-LXC als Erkennungs-Host ein. +# +# curl -fsSL /scripts/setup_detector_host.sh | sudo bash +# oder nach dem Klonen: +# sudo ./scripts/setup_detector_host.sh +# +# Idempotent — ein zweiter Lauf aktualisiert nur, was sich geändert hat. +set -euo pipefail + +REPO="${VOGELALBUM_REPO:-https://gitea.gaertner.onl/christian/vogelalbum.git}" +TARGET="/opt/vogelalbum" +DATA="/var/lib/vogelalbum" +ENV_FILE="/etc/vogelalbum.env" +USER_NAME="vogelalbum" + +say() { printf '\n\033[1;32m==> %s\033[0m\n' "$1"; } +warn() { printf '\033[1;33m %s\033[0m\n' "$1"; } + +if [[ $EUID -ne 0 ]]; then + echo "Bitte mit sudo ausführen." >&2 + exit 1 +fi + +say "Pakete" +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +# git zum Klonen, python3-venv weil Debian das venv-Modul auslagert, +# ca-certificates für HTTPS zur Web-App. +apt-get install -y -qq --no-install-recommends \ + git python3 python3-venv python3-pip ca-certificates curl + +say "Dienstbenutzer '$USER_NAME'" +if ! id "$USER_NAME" &>/dev/null; then + # Systemkonto ohne Login: der Dienst braucht keine Shell. + useradd --system --home-dir "$TARGET" --shell /usr/sbin/nologin "$USER_NAME" + echo " angelegt" +else + echo " vorhanden" +fi + +say "Code nach $TARGET" +if [[ -d "$TARGET/.git" ]]; then + git -C "$TARGET" pull --ff-only +else + git clone --depth 1 "$REPO" "$TARGET" +fi + +say "Virtualenv und Abhängigkeiten" +if [[ ! -x "$TARGET/venv/bin/python" ]]; then + python3 -m venv "$TARGET/venv" +fi +"$TARGET/venv/bin/pip" install --quiet --upgrade pip +"$TARGET/venv/bin/pip" install --quiet -r "$TARGET/requirements-detector.txt" + +say "Modelle" +bash "$TARGET/scripts/fetch_models.sh" + +say "Datenverzeichnis $DATA" +mkdir -p "$DATA/rohdaten" +chown -R "$USER_NAME:$USER_NAME" "$DATA" "$TARGET" + +say "Konfiguration $ENV_FILE" +if [[ -f "$ENV_FILE" ]]; then + warn "existiert bereits — bleibt unangetastet" +else + cat > "$ENV_FILE" <<'EOF' +# Stream der Kamera. MJPEG, RTSP, Einzelbild-URL oder Ordner. +VOGELALBUM_SOURCE=http://vogelkamera.local:8080/stream + +# Basis-URL der Web-App. Der Detector meldet nur ausgehend dorthin — +# eingehend muss auf diesem Host nichts offen sein. +VOGELALBUM_API=https://vogelalbum.gaertner.onl + +# Token dieser Kamera, einmalig ausgegeben von: +# manage.py camera add --name "..." +VOGELALBUM_TOKEN=HIER_EINTRAGEN + +# Rohbilder je Sichtung — Trainingsmaterial für ein eigenes Modell später. +VOGELALBUM_SAVE_TO=/var/lib/vogelalbum/rohdaten +EOF + chmod 600 "$ENV_FILE" + warn "angelegt — VOGELALBUM_TOKEN und VOGELALBUM_SOURCE noch eintragen!" +fi + +say "systemd" +cp "$TARGET/deploy/vogelalbum-detector.service" /etc/systemd/system/ +systemctl daemon-reload + +echo +if grep -q "HIER_EINTRAGEN" "$ENV_FILE"; then + say "Fast fertig" + echo " 1. $ENV_FILE ausfüllen (Token und Stream-URL)" + echo " 2. systemctl enable --now vogelalbum-detector" + echo " 3. journalctl -u vogelalbum-detector -f" +else + systemctl enable --now vogelalbum-detector + say "Läuft" + echo " journalctl -u vogelalbum-detector -f" +fi + +echo +echo "Kurztest ohne Dienst:" +echo " sudo -u $USER_NAME $TARGET/venv/bin/python -m detector tune --source " diff --git a/scripts/update_translations.py b/scripts/update_translations.py new file mode 100644 index 0000000..786ed8f --- /dev/null +++ b/scripts/update_translations.py @@ -0,0 +1,374 @@ +"""Trägt die englischen Übersetzungen in den Katalog ein. + + ./venv/bin/python scripts/update_translations.py + cd web && ../venv/bin/python manage.py compilemessages -l en + +Warum ein Skript und keine Handarbeit in der .po-Datei: + +``makemessages`` ruft intern ``msgmerge`` auf, und das rät. Taucht ein neuer +Text auf, der einem alten ähnelt, übernimmt es dessen Übersetzung und setzt +die Marke ``#, fuzzy``. So wurde aus „Sammler" ein „Collection" (von +„Sammlung") und aus „Eine Sichtung vor 6 Uhr morgens." die Nachtschicht-Zeile. + +``msgfmt`` ignoriert Fuzzy-Einträge, also fällt das im Betrieb als deutscher +Resttext auf — nicht als falsche Übersetzung. Wer dann die .po-Datei per +„ersetze msgstr, wenn leer" repariert, greift daneben: leer ist sie ja nicht. + +Dieses Skript überschreibt deshalb **immer** und räumt die Fuzzy-Marke weg. +Neue Texte kommen unten in die Tabelle; was fehlt, wird gemeldet. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +PO = Path(__file__).resolve().parents[1] / "web/locale/en/LC_MESSAGES/django.po" + +# Ein Paar statt eines Textes steht für einen Plural-Eintrag: (Einzahl, Mehrzahl). +TRANSLATIONS: dict[str, str | tuple[str, str]] = { + # --- Navigation und Rahmen --- + "Übersicht": "Overview", + "Sammlung": "Collection", + "Sichtungen": "Sightings", + "Abzeichen": "Badges", + "Zwischen hellem und dunklem Modus wechseln": "Switch between light and dark mode", + # Der Werbeblock im Fuss. + "Dieses Vogelalbum füllt sich von allein.": + "This bird album fills itself.", + "Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen.": + "A camera at the feeder, everything else happens on your own server — " + "no cloud, no account, no subscription. Free software, built to be rebuilt.", + "Projekt ansehen": "View the project", + # --- Fortschritt --- + "%(xp)s XP · noch %(rest)s XP bis Level %(lvl)s": "%(xp)s XP · %(rest)s XP to level %(lvl)s", + "Arten gesammelt": "species collected", + # --- Übersicht --- + "Besuche heute": "Visits today", + "Arten heute": "Species today", + "Besuche insgesamt": "Visits in total", + "Neu in der Sammlung": "New in the collection", + "letzte 7 Tage": "last 7 days", + "Neu": "New", + "Zuletzt zu Besuch": "Latest visitors", + "Stammgäste": "Regulars", + "Noch keine Sichtung aufgezeichnet.": "No sightings recorded yet.", + "Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert ist, erscheint hier der erste Gast.": + "As soon as the detector spots a bird or the first photo is handed in, " + "the first visitor appears here.", + # --- Sammlung --- + "Die Sammlung": "The Collection", + "%(a)s von %(b)s Arten": "%(a)s of %(b)s species", + "Alle": "All", + "Alle Stufen": "All tiers", + "Alle Gruppen": "All groups", + "Lebensraum": "Habitat", + "Stufe": "Tier", + "Gruppe": "Group", + "Anwenden": "Apply", + "Zurücksetzen": "Reset", + "Keine Art passt zu dieser Auswahl.": "No species matches this selection.", + "Noch nicht gesichtet": "Not yet sighted", + # --- Sichtungen --- + "%(n)s insgesamt": "%(n)s in total", + "%(n)s verdient": "%(n)s earned", + "Alle Besuche": "All visits", + "Nur bestimmte Arten": "Identified only", + "Alle Kameras": "All cameras", + "von Hand": "by hand", + "Unbestimmt": "Unidentified", + "Alternativen": "Alternatives", + "Erstfang": "First catch", + "Neuer": "Newer", + "Älter": "Older", + "Noch nichts aufgezeichnet.": "Nothing recorded yet.", + "%(a)s von %(b)s Bildern mit Vogel": "%(a)s of %(b)s frames with a bird", + # --- Artseite --- + "Steckbrief": "Profile", + "Lebensraum": "Habitat", + "Nahrung": "Diet", + "Stimme": "Voice", + "Zugverhalten": "Migration", + "Verwechslungsgefahr": "Easily confused with", + "Arten, die man nicht auf Anhieb unterscheidet": + "species you will not tell apart at a glance", + "Wann ist %(n)s da?": "When does the %(n)s show up?", + "Besuche über den Tag": "visits across the day", + "0 Uhr": "midnight", + "Noch nicht gesammelt.": "Not collected yet.", + "Sobald %(n)s vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit eurem eigenen Foto.": + "Once a %(n)s lands in front of the camera this entry unlocks — with your own photo.", + "Bestes eigenes Foto": "Best own photo", + "Referenzfoto — diese Art war noch nicht vor unserer Kamera.": + "Reference photo — this species has not been in front of our camera yet.", + "Foto": "Photo", + "%(n)s× seen": "%(n)s× seen", + # --- Statuswerte --- + "Gewicht": "Weight", + "Spannweite": "Wingspan", + "Größe": "Length", + "Seltenheit": "Rarity", + # --- Widget --- + "Zuletzt gesehen": "Last seen", + "sicher": "confident", + "Noch kein Gast": "No visitor yet", + "Arten": "species", + # --- Ränge --- + "Nestling": "Nestling", + "Ästling": "Fledgling", + "Spatzenkenner": "Sparrow Spotter", + "Meisenmeister": "Tit Master", + "Federfuchser": "Feather Nerd", + "Amselflüsterer": "Blackbird Whisperer", + "Spechtspäher": "Woodpecker Watcher", + "Falkenauge": "Falcon Eye", + "Vogelmeister": "Bird Master", + "Legende des Vogelhauses": "Legend of the Feeder", + # --- Abzeichen --- + "Erster Gast": "First Guest", + "Die allererste Sichtung im Vogelhaus.": "The very first sighting at the feeder.", + "Fünf Freunde": "High Five", + "5 verschiedene Arten gesammelt.": "5 different species collected.", + "Zweistellig": "Double Digits", + "10 verschiedene Arten gesammelt.": "10 different species collected.", + "Sammler": "Collector", + "25 verschiedene Arten gesammelt.": "25 different species collected.", + "Vogelalbum-Meister": "Vogelalbum Master", + "40 verschiedene Arten gesammelt.": "40 different species collected.", + "Frühaufsteher": "Early Bird", + "Eine Sichtung vor 6 Uhr morgens.": "A sighting before 6 in the morning.", + "Nachtschicht": "Night Shift", + "Eine Sichtung nach 21 Uhr.": "A sighting after 9 in the evening.", + "Volles Haus": "Full House", + "5 verschiedene Arten an einem Tag.": "5 different species in one day.", + "Stammgast": "Regular", + "7 Tage in Folge mindestens eine Sichtung.": + "At least one sighting on 7 consecutive days.", + "Dauergast": "Long Stay", + "Ein Besuch, der länger als eine Minute dauerte.": + "A visit lasting longer than a minute.", + "Hundert Besuche": "One Hundred Visits", + "100 Sichtungen aufgezeichnet.": "100 sightings recorded.", + "Tausend Besuche": "One Thousand Visits", + "1000 Sichtungen aufgezeichnet.": "1000 sightings recorded.", + "Klopfgeist": "The Knocker", + "Ein Specht war da.": "A woodpecker came by.", + "Greifvogel-Alarm": "Raptor Alert", + "Ein Greifvogel oder eine Eule vor der Linse.": + "A bird of prey or an owl in front of the lens.", + "Seltener Gast": "Rare Guest", + "Eine Art der Stufe 'Selten' oder höher.": "A species rated 'Rare' or above.", + # --- Blickfang der Startseite --- + # Die Überschrift richtet sich nach dem letzten Besuch. Auf Englisch wird + # daraus kein Fragesatz mit eingesetztem Tag, sondern eine Aussage — "Last + # visitor on Sunday" liest sich wie ein Mensch, "Who was a guest on + # Sunday?" wie eine Maschine. + "Vogelalbum — Übersicht": "Vogelalbum — Overview", + "Noch wartet die Kamera": "The camera is still waiting", + "Wer war heute zu Gast?": "Who stopped by today?", + "Zuletzt gestern zu Gast": "Last visitor: yesterday", + "Zuletzt am %(wochentag)s zu Gast": "Last visitor: %(wochentag)s", + "Zuletzt am %(datum)s zu Gast": "Last visitor: %(datum)s", + "Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder vom Handyfoto. Alles auf dem eigenen Server.": + "Every bird is identified, counted and collected — from a camera frame " + "or a photo off your phone. All on your own server.", + "Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne Cloud.": + "Identify, collect and catalogue birds — on your own server, " + "without a cloud.", + "ZULETZT": "LATEST", + "Noch kein Gast aufgezeichnet.": "No visitor recorded yet.", + "Der erste bestimmte Vogel steht hier.": "The first identified bird shows up here.", + "%(n)s Besuche insgesamt": "%(n)s visits in total", + # --- Zeitpunkte --- + # Wochentag und Datum kommen bereits übersetzt aus ``date_format``; hier + # steht nur noch die Fügung drumherum. + "heute, %(uhrzeit)s": "today, %(uhrzeit)s", + "gestern, %(uhrzeit)s": "yesterday, %(uhrzeit)s", + "%(wochentag)s, %(uhrzeit)s": "%(wochentag)s, %(uhrzeit)s", + "%(datum)s, %(uhrzeit)s": "%(datum)s, %(uhrzeit)s", + # --- Live --- + "Live": "Live", + "Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom.": + "If the image stays black the detector is not running — the live view " + "is relayed from the recognition's own frame stream.", + # --- Fotos einliefern --- + "Foto hochladen": "Upload photo", + "Fotos hochladen": "Upload photos", + "Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim nächsten Durchgang und trägt es in die Sammlung ein.": + "Spotted a bird? Hand in a photo here — the detector identifies it on " + "its next pass and adds it to the collection.", + "Bilder": "Images", + "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt.": + "You can pick several images at once. Each one is identified separately.", + "Gerät": "Device", + "— ohne Zuordnung —": "— unassigned —", + "Ort (freiwillig)": "Location (optional)", + "Breite, z. B. 49.49": "Latitude, e.g. 49.49", + "Länge, z. B. 8.47": "Longitude, e.g. 8.47", + "Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die Adresse.": + "Stored rounded to about a kilometre — the area, not the address.", + "Hochladen": "Upload", + "Bitte mindestens ein Bild auswählen.": "Please select at least one image.", + "Warten auf Bestimmung": "Awaiting identification", + "Noch nicht bestimmt": "Not identified yet", + "Bleiben die Bilder länger stehen, läuft der Detector nicht.": + "If images sit here for a while, the detector is not running.", + # --- Admin --- + "Einstellungen": "Settings", + "Der Detector holt sich diese Werte selbst — er fragt /api/config/ mit seinem Token und übernimmt Änderungen im laufenden Betrieb, ohne Neustart. Eine Stream-Adresse, die auf seiner Kommandozeile steht, behält aber Vorrang.": + "The detector fetches these values itself — it asks /api/config/ " + "with its token and applies changes while running, without a restart. " + "A stream address given on its command line still takes precedence.", + "Das Livebild kommt vom Live-Verteiler des Detectors, nicht von der Kamera selbst — die liefert nur einen Stream, und den braucht die Erkennung. Starten mit python -m detector watch … --live-port 8131.": + "The live image comes from the detector's relay, not from the camera " + "itself — the camera serves only one stream, and the recognition needs " + "it. Start it with python -m detector watch … --live-port 8131.", + "gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)": "locked (VOGELALBUM_LIVE_ERLAUBT=0)", + "an": "on", + "aus": "off", + "an, aber keine Adresse": "on, but no address", + "an, aber Kamera stillgelegt": "on, but camera disabled", + # --- Plurale: (Einzahl, Mehrzahl) --- + "%(n)s Kamera": ("%(n)s camera", "%(n)s cameras"), + "%(n)s Foto angenommen. Es wartet auf den Detector.": ( + "%(n)s photo accepted. It is waiting for the detector.", + "%(n)s photos accepted. They are waiting for the detector.", + ), +} + + +def po_escape(text: str) -> str: + return text.replace("\\", "\\\\").replace('"', '\\"') + + +def po_unescape(text: str) -> str: + return text.replace('\\"', '"').replace("\\\\", "\\") + + +def entfuzzen(out: list[str]) -> int: + """Die ``#, fuzzy``-Marke aus den zuletzt geschriebenen Zeilen nehmen. + + Ohne das wirft ``msgfmt`` den Eintrag weg und der deutsche Text bleibt + stehen — als Fehler sieht man das nie, nur als Resttext. + """ + for k in range(len(out) - 1, max(-1, len(out) - 5), -1): + if out[k].startswith("#, "): + flags = [f for f in out[k][3:].split(", ") if f != "fuzzy"] + if flags: + out[k] = "#, " + ", ".join(flags) + else: + out.pop(k) + return 1 + return 0 + + +def main() -> int: + if not PO.exists(): + print(f"Nicht gefunden: {PO}\nErst 'manage.py makemessages -l en' laufen lassen.") + return 1 + + lines = PO.read_text(encoding="utf-8").split("\n") + out: list[str] = [] + i = 0 + gesetzt = 0 + entfuzzt = 0 + unbekannt: list[str] = [] + + while i < len(lines): + line = lines[i] + + if not line.startswith("msgid "): + out.append(line) + i += 1 + continue + + # msgid einsammeln — kann über mehrere Zeilen laufen. + blk = [line] + j = i + 1 + while j < len(lines) and lines[j].startswith('"'): + blk.append(lines[j]) + j += 1 + key = po_unescape("".join(re.findall(r'"((?:[^"\\]|\\.)*)"', "\n".join(blk)))) + + # 🔴 Der Kopf der Datei hat ein leeres msgid — und nur der. Hier stand + # vorher ``or line == 'msgid ""'`` in der Bedingung oben, und das war + # ein Loch mit Ansage: **jeder lange Text beginnt ebenfalls mit + # ``msgid ""``**, weil xgettext ihn auf Folgezeilen umbricht. Damit + # übersprang dieses Skript genau die Texte, die am meisten auffallen — + # Überschriften, Fußzeile, Seitenbeschreibung — und meldete sie nicht + # einmal als fehlend. Das Werkzeug gegen die Übersetzungsfalle lief + # also selbst hinein. Geprüft wird deshalb der *eingesammelte* Text, + # nicht die erste Zeile. + if key == "": + out.extend(blk) + i = j + continue + + # 🔴 Plural-Einträge wurden hier früher wortlos übersprungen — mit der + # Begründung, sie bräuchten ``msgstr[0]``/``[1]``. Stimmt, nur stand + # daneben keine Stelle, die sie füllt: „%(n)s Foto angenommen." war + # über Wochen in beiden Formen leer, und gemeldet wurde es nie. + # Jetzt tragen sie ein Paar in TRANSLATIONS, Einzahl und Mehrzahl. + if j < len(lines) and lines[j].startswith("msgid_plural"): + pblk = [lines[j]] + k = j + 1 + while k < len(lines) and lines[k].startswith('"'): + pblk.append(lines[k]) + k += 1 + + paar = TRANSLATIONS.get(key) + if not isinstance(paar, tuple): + if paar is None: + unbekannt.append(key) + out.extend(blk) + out.extend(pblk) + i = k + continue + + entfuzzt += entfuzzen(out) + out.extend(blk) + out.extend(pblk) + while k < len(lines) and lines[k].startswith("msgstr["): + k += 1 + while k < len(lines) and lines[k].startswith('"'): + k += 1 + for nr, form in enumerate(paar): + out.append(f'msgstr[{nr}] "{po_escape(form)}"') + gesetzt += 1 + i = k + continue + + wert = TRANSLATIONS.get(key) + if wert is None: + unbekannt.append(key) + out.extend(blk) + i = j + continue + + entfuzzt += entfuzzen(out) + out.extend(blk) + + # Alte msgstr-Zeilen überspringen — unabhängig davon, was drinsteht. + while j < len(lines) and lines[j].startswith("msgstr"): + j += 1 + while j < len(lines) and lines[j].startswith('"'): + j += 1 + + out.append(f'msgstr "{po_escape(wert)}"') + gesetzt += 1 + i = j + + PO.write_text("\n".join(out), encoding="utf-8") + + print(f"{gesetzt} Übersetzungen gesetzt, {entfuzzt} Fuzzy-Marken entfernt.") + if unbekannt: + print(f"\n{len(unbekannt)} Texte ohne Eintrag in TRANSLATIONS:") + for u in unbekannt: + print(f" {u!r}: ...,") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_bruecke.py b/tests/test_bruecke.py new file mode 100644 index 0000000..1da06cb --- /dev/null +++ b/tests/test_bruecke.py @@ -0,0 +1,160 @@ +"""Woher die Bruecke das Livebild holt. + + ./venv/bin/python -m unittest discover tests + +🔑 Diese Datei gibt es wegen eines Ausfalls, der acht Tage lang im Betrieb +stand, ohne dass irgendwo ein Fehler auftauchte: Seit dem Umzug nach Coolify +(03.10.2026) trug die oeffentliche Instanz ``VOGELALBUM_LIVE_ERLAUBT=0`` -- +richtig so, ein Bild aus dem eigenen Garten gehoert nicht nach draussen. +Damit lieferte ``/api/zustand/`` aber ``bild_url: null``, und die Bruecke +veroeffentlichte kein Livebild mehr. In Home Assistant blieb das letzte Bild +stehen und **sah aus wie ein Livebild**. + +⚠️ Der Riegel war gegen die Oeffentlichkeit gedacht und sperrte das eigene +Haus mit aus -- obwohl die Bruecke im Heimnetz laeuft und den Verteiler +direkt erreicht. Ein Schalter, zwei Zwecke. + +Gegengeprueft: Ohne ``live_adresse`` sind die Faelle "Vorgabe schlaegt API" +und "Vorgabe trotz bild_url null" beide rot. +""" + +from __future__ import annotations + +import os +import unittest + +from homeassistant import bruecke + + +class LiveAdresseTests(unittest.TestCase): + SCHLUESSEL = ( + "VOGELALBUM_LIVE_VOGELHAUS", + "VOGELALBUM_LIVE_VOGELHAUS_2", + ) + + def setUp(self): + vorher = {k: os.environ.get(k) for k in self.SCHLUESSEL} + for k in self.SCHLUESSEL: + os.environ.pop(k, None) + + def zuruecksetzen(): + for k, wert in vorher.items(): + if wert is None: + os.environ.pop(k, None) + else: + os.environ[k] = wert + + self.addCleanup(zuruecksetzen) + + @staticmethod + def kamera(kennung="vogelhaus", bild_url=None): + return {"kennung": kennung, "bild_url": bild_url} + + # --- ohne eigene Vorgabe: alles bleibt wie bisher ---------------- + + def test_ohne_alles_leer(self): + self.assertEqual(bruecke.live_adresse(self.kamera()), "") + + def test_bild_url_aus_der_schnittstelle(self): + kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg") + self.assertEqual(bruecke.live_adresse(kamera), + "http://erkennung.local:8131/bild.jpg") + + def test_stromadresse_wird_zum_einzelbild(self): + """``/live`` ist der endlose MJPEG-Strom -- den darf sie nicht lesen.""" + kamera = self.kamera(bild_url="http://erkennung.local:8131/live") + self.assertEqual(bruecke.live_adresse(kamera), + "http://erkennung.local:8131/bild.jpg") + + def test_schraegstrich_am_ende_stoert_nicht(self): + kamera = self.kamera(bild_url="http://erkennung.local:8131/live/") + self.assertEqual(bruecke.live_adresse(kamera), + "http://erkennung.local:8131/bild.jpg") + + # --- mit eigener Vorgabe: der eigentliche Zweck ------------------ + + def test_vorgabe_auch_wenn_die_schnittstelle_nichts_liefert(self): + """Der Ausfall vom 03.-11.10.2026: LIVE_ERLAUBT=0, also bild_url null.""" + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \ + "http://erkennung.local:8131/bild.jpg" + self.assertEqual(bruecke.live_adresse(self.kamera()), + "http://erkennung.local:8131/bild.jpg") + + def test_vorgabe_schlaegt_die_schnittstelle(self): + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg" + kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg") + self.assertEqual(bruecke.live_adresse(kamera), + "http://127.0.0.1:8131/bild.jpg") + + def test_vorgabe_als_stromadresse(self): + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://erkennung.local:8131/live" + self.assertEqual(bruecke.live_adresse(self.kamera()), + "http://erkennung.local:8131/bild.jpg") + + # --- Randfaelle -------------------------------------------------- + + def test_kennung_mit_sonderzeichen(self): + """"vogelhaus-2" wird zu VOGELALBUM_LIVE_VOGELHAUS_2.""" + os.environ["VOGELALBUM_LIVE_VOGELHAUS_2"] = \ + "http://erkennung.local:8133/bild.jpg" + kamera = self.kamera(kennung="vogelhaus-2") + self.assertEqual(bruecke.live_adresse(kamera), + "http://erkennung.local:8133/bild.jpg") + + def test_ohne_kennung_keine_vorgabe(self): + """Ohne Kennung gibt es keinen Variablennamen -- aber auch keinen Absturz.""" + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg" + kamera = {"bild_url": "http://erkennung.local:8131/bild.jpg"} + self.assertEqual(bruecke.live_adresse(kamera), + "http://erkennung.local:8131/bild.jpg") + + +class EntwuerfeTests(unittest.TestCase): + """Die Kamera-Entitaet darf nur entstehen, wenn es ein Bild dazu gibt. + + ⚠️ Sonst legt Home Assistant eine Kamera an, die nie etwas zeigt -- und + eine leere Kachel ist schlimmer als keine. + """ + + def setUp(self): + self.vorher = os.environ.get("VOGELALBUM_LIVE_VOGELHAUS") + os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None) + + def zuruecksetzen(): + if self.vorher is None: + os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None) + else: + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = self.vorher + + self.addCleanup(zuruecksetzen) + + @staticmethod + def zustand(): + return { + "kameras": [{ + "name": "Futterhaus Garten", + "kennung": "vogelhaus", + "aktiv": True, + "online": True, + "bild_url": None, + }], + "sammlung": {}, + } + + def _hat_livebild(self): + return any( + inhalt.get("name") == "Livebild" + for _, inhalt in bruecke.entwuerfe(self.zustand()) + ) + + def test_ohne_adresse_keine_kamera_entitaet(self): + self.assertFalse(self._hat_livebild()) + + def test_mit_vorgabe_entsteht_die_kamera_entitaet(self): + os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \ + "http://erkennung.local:8131/bild.jpg" + self.assertTrue(self._hat_livebild()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_detector.py b/tests/test_detector.py new file mode 100644 index 0000000..ab8a2af --- /dev/null +++ b/tests/test_detector.py @@ -0,0 +1,958 @@ +"""Tests für Bewegungserkennung und Besuchslogik. + + ./venv/bin/python -m unittest discover tests + +Läuft ohne die Modelle: der Klassifikator wird durch eine Attrappe ersetzt, +damit die Zustandsmaschine für sich prüfbar ist. +""" + +from __future__ import annotations + +import sys +import unittest +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from detector import karte # noqa: E402 +from detector.classify import Box # noqa: E402 +from detector.motion import MotionDetector, sharpness # noqa: E402 +from detector.species_de import ( # noqa: E402 + BY_SCIENTIFIC, SPECIES, catalog, label_to_scientific, +) +from detector.species_extra import EXTRA # noqa: E402 +from detector.watcher import Watcher, WatcherConfig # noqa: E402 + + +def blank(color=(40, 60, 40), size=(320, 240)) -> Image.Image: + return Image.new("RGB", size, color) + + +def with_blob(x: int, y: int, r: int = 40, size=(320, 240)) -> Image.Image: + img = blank(size=size) + ImageDraw.Draw(img).ellipse([x - r, y - r, x + r, y + r], fill=(220, 200, 60)) + return img + + +class MotionTests(unittest.TestCase): + def test_first_frame_reports_no_motion(self): + self.assertEqual(MotionDetector().update(blank()), 0.0) + + def test_identical_frames_report_no_motion(self): + m = MotionDetector() + m.update(blank()) + self.assertEqual(m.update(blank()), 0.0) + + def test_blob_appearing_triggers_motion(self): + m = MotionDetector() + m.update(blank()) + ratio = m.update(with_blob(160, 120)) + self.assertGreater(ratio, m.min_ratio) + self.assertTrue(m.is_motion(ratio)) + + def test_background_adapts_to_a_permanent_change(self): + """Ein umgestellter Ast darf nicht dauerhaft als Bewegung gelten.""" + m = MotionDetector() + m.update(blank()) + changed = blank(color=(90, 110, 90)) + ratios = [m.update(changed) for _ in range(200)] + self.assertGreater(ratios[0], m.min_ratio) + self.assertLess(ratios[-1], m.min_ratio) + + def test_reset_clears_background(self): + m = MotionDetector() + m.update(blank()) + m.reset() + self.assertEqual(m.update(with_blob(160, 120)), 0.0) + + +class SharpnessTests(unittest.TestCase): + def test_flat_image_has_almost_no_sharpness(self): + self.assertLess(sharpness(blank()), 1.0) + + def test_edges_score_higher_than_flat(self): + self.assertGreater(sharpness(with_blob(160, 120)), sharpness(blank())) + + def test_blurred_scores_lower_than_crisp(self): + from PIL import ImageFilter + + crisp = with_blob(160, 120) + blurred = crisp.filter(ImageFilter.GaussianBlur(6)) + self.assertGreater(sharpness(crisp), sharpness(blurred)) + + def test_tiny_image_does_not_crash(self): + self.assertEqual(sharpness(Image.new("RGB", (2, 2)), width=2), 0.0) + + +class DaylightTests(unittest.TestCase): + """Abschaltung bei Dunkelheit. Die Hysterese ist der interessante Teil: + ohne sie flattert die Entscheidung in der Dämmerung im Sekundentakt.""" + + def gate(self, **kw): + from detector.daylight import DaylightGate + + return DaylightGate(**({"patience": 3} | kw)) + + def test_brightness_matches_the_image(self): + from detector.daylight import brightness + + self.assertLess(brightness(Image.new("RGB", (64, 48), (0, 0, 0))), 1) + self.assertGreater(brightness(Image.new("RGB", (64, 48), (255, 255, 255))), 254) + + def test_bright_scene_keeps_running(self): + g = self.gate() + hell = Image.new("RGB", (64, 48), (130, 130, 130)) + for _ in range(20): + self.assertTrue(g.update(hell)) + self.assertFalse(g.is_dark) + + def test_darkness_pauses_after_patience(self): + g = self.gate() + dunkel = Image.new("RGB", (64, 48), (2, 2, 2)) + # Erst nach 'patience' Bildern am Stück, nicht schon beim ersten. + self.assertTrue(g.update(dunkel)) + self.assertTrue(g.update(dunkel)) + self.assertFalse(g.update(dunkel)) + self.assertTrue(g.is_dark) + + def test_a_single_flash_does_not_wake_it(self): + """Ein Autoscheinwerfer soll die Analyse nicht wecken.""" + g = self.gate() + dunkel = Image.new("RGB", (64, 48), (2, 2, 2)) + for _ in range(5): + g.update(dunkel) + self.assertTrue(g.is_dark) + + g.update(Image.new("RGB", (64, 48), (200, 200, 200))) + self.assertTrue(g.is_dark) + + def test_hysteresis_prevents_flapping_at_dusk(self): + """Ein Wert zwischen den Schwellen ändert nichts — in beide Richtungen.""" + g = self.gate(dark_below=15, light_above=30) + daemmerung = Image.new("RGB", (64, 48), (22, 22, 22)) + for _ in range(50): + g.update(daemmerung) + self.assertFalse(g.is_dark) + + for _ in range(10): + g.update(Image.new("RGB", (64, 48), (2, 2, 2))) + self.assertTrue(g.is_dark) + + for _ in range(50): + g.update(daemmerung) + self.assertTrue(g.is_dark) + + def test_morning_resumes(self): + g = self.gate() + for _ in range(10): + g.update(Image.new("RGB", (64, 48), (2, 2, 2))) + self.assertTrue(g.is_dark) + + hell = Image.new("RGB", (64, 48), (130, 130, 130)) + for _ in range(10): + g.update(hell) + self.assertFalse(g.is_dark) + + def test_night_vision_never_pauses(self): + g = self.gate(enabled=False) + dunkel = Image.new("RGB", (64, 48), (0, 0, 0)) + for _ in range(50): + self.assertTrue(g.update(dunkel)) + self.assertFalse(g.is_dark) + + def test_thresholds_must_leave_room_for_hysteresis(self): + with self.assertRaises(ValueError): + self.gate(dark_below=30, light_above=30) + + +class BoxTests(unittest.TestCase): + def test_crop_returns_the_marked_region(self): + img = blank(size=(400, 200)) + box = Box(0.25, 0.0, 0.75, 1.0, 0.9) + crop = box.crop(img, pad=0.0) + self.assertEqual(crop.size, (200, 200)) + + def test_padding_widens_the_crop(self): + img = blank(size=(400, 400)) + box = Box(0.4, 0.4, 0.6, 0.6, 0.9) + self.assertGreater(box.crop(img, pad=0.5).size[0], box.crop(img, pad=0.0).size[0]) + + def test_degenerate_box_falls_back_to_full_image(self): + img = blank(size=(400, 400)) + self.assertEqual(Box(0.5, 0.5, 0.5, 0.5, 0.9).crop(img).size, img.size) + + def test_area(self): + self.assertAlmostEqual(Box(0.0, 0.0, 0.5, 0.5, 1.0).area, 0.25) + + +# -------------------------------------------------------------------------- +# Attrappe statt Modell: liefert eine feste Bestimmung, damit sich die +# Abstimmungs- und Besuchslogik isoliert prüfen lässt. +# -------------------------------------------------------------------------- + + +@dataclass +class FakeGuess: + species: object + confidence: float + + +@dataclass +class FakeResult: + guesses: list + bird_score: float = 0.9 + raw_top: list = None + ms: float = 1.0 + + +class FakePipeline: + def __init__(self, plan, detect_threshold=0.4): + """``plan`` ist eine Liste von (Art|None, Confidence) je Aufruf. + + Ein Eintrag darf zusaetzlich einen Box-Score tragen — + ``(Art, Confidence, 0.2)`` ist ein Beinahe-Treffer unter der + Sichtungsschwelle, wie ihn das Protokoll sehen soll. + """ + self.plan = list(plan) + self.calls = 0 + self.detect_threshold = detect_threshold + + def zaehlt_als_vogel(self, box): + return box is not None and box.score >= self.detect_threshold + + def run(self, img): + entry = self.plan[min(self.calls, len(self.plan) - 1)] + self.calls += 1 + if entry is None: + return None, None + species, confidence, *rest = entry + box = Box(0.3, 0.3, 0.7, 0.7, rest[0] if rest else 0.95) + if species is None or not self.zaehlt_als_vogel(box): + return box, None + return box, FakeResult([FakeGuess(species, confidence)]) + + +class WatcherTests(unittest.TestCase): + def setUp(self): + self.kohlmeise = BY_SCIENTIFIC["Parus major"] + self.blaumeise = BY_SCIENTIFIC["Cyanistes caeruleus"] + + def make(self, plan, **cfg) -> Watcher: + defaults = {"classify_every": 1, "cooldown_s": 0.0, "min_bird_frames": 2} + return Watcher( + pipeline=FakePipeline(plan), + config=WatcherConfig(**(defaults | cfg)), + ) + + def test_no_motion_produces_no_sighting(self): + w = self.make([(self.kohlmeise, 0.9)]) + for _ in range(5): + self.assertIsNone(w.process(blank())) + self.assertIsNone(w.flush()) + + def test_a_visit_yields_one_sighting(self): + w = self.make([(self.kohlmeise, 0.9)]) + w.process(blank()) + for x in range(100, 200, 25): + w.process(with_blob(x, 120)) + sighting = w.flush() + self.assertIsNotNone(sighting) + self.assertEqual(sighting.species, self.kohlmeise) + self.assertGreater(sighting.frames_with_bird, 0) + + def test_majority_wins_the_vote(self): + plan = [(self.kohlmeise, 0.9)] * 3 + [(self.blaumeise, 0.9)] + w = self.make(plan) + w.process(blank()) + for x in range(100, 200, 25): + w.process(with_blob(x, 120)) + sighting = w.flush() + self.assertEqual(sighting.species, self.kohlmeise) + self.assertGreater(sighting.confidence, 0.5) + + def test_split_vote_stays_undetermined(self): + """Kippt die Abstimmung, wird nicht geraten.""" + plan = [(self.kohlmeise, 0.9), (self.blaumeise, 0.9)] * 3 + w = self.make(plan, min_confidence=0.8) + w.process(blank()) + for x in range(100, 200, 16): + w.process(with_blob(x, 120)) + sighting = w.flush() + self.assertIsNone(sighting.species) + + def test_too_few_bird_frames_is_discarded(self): + w = self.make([(self.kohlmeise, 0.9), None, None], min_bird_frames=3) + w.process(blank()) + w.process(with_blob(160, 120)) + w.process(with_blob(180, 120)) + self.assertIsNone(w.flush()) + + def beobachtet(self, plan, **cfg): + """Watcher mit Mitschrift — gibt (Watcher, Liste der Meldungen).""" + gesehen: list = [] + defaults = { + "classify_every": 1, + "cooldown_s": 0.0, + "min_bird_frames": 1, + "gate": "detector", + } + w = Watcher( + pipeline=FakePipeline(plan), + config=WatcherConfig(**(defaults | cfg)), + beobachter=lambda box, result: gesehen.append((box, result)), + ) + return w, gesehen + + def test_near_miss_is_logged_but_is_no_sighting(self): + """Der entscheidende Fall fuers Protokoll: Vogel knapp verpasst. + + Ohne diese Meldung sieht ein Tag, an dem die Voegel nur zu klein im + Bild waren, genauso aus wie ein Tag ohne einen einzigen Vogel. + """ + w, gesehen = self.beobachtet([(self.kohlmeise, 0.9, 0.2)]) + for x in range(100, 200, 25): + self.assertIsNone(w.process(with_blob(x, 120))) + self.assertIsNone(w.flush()) + + self.assertTrue(gesehen, "Beobachter wurde gar nicht gerufen") + self.assertTrue(all(box is not None for box, _ in gesehen)) + self.assertTrue(all(box.score == 0.2 for box, _ in gesehen)) + + def test_box_above_threshold_still_becomes_a_sighting(self): + """Gegenprobe: an der normalen Schwelle aendert sich nichts.""" + w, gesehen = self.beobachtet([(self.kohlmeise, 0.9, 0.95)]) + for x in range(100, 200, 25): + w.process(with_blob(x, 120)) + sighting = w.flush() + self.assertIsNotNone(sighting) + self.assertEqual(sighting.species, self.kohlmeise) + self.assertTrue(gesehen) + + def test_best_shot_prefers_the_sharper_frame(self): + w = self.make([(self.kohlmeise, 0.9)]) + w.process(blank()) + w.process(with_blob(140, 120)) + w.process(with_blob(170, 120)) + sighting = w.flush() + self.assertIsNotNone(sighting.best_image) + self.assertGreater(sighting.best_sharpness, 0) + + def test_watcher_resets_between_visits(self): + w = self.make([(self.kohlmeise, 0.9)]) + w.process(blank()) + w.process(with_blob(160, 120)) + w.process(with_blob(180, 120)) + first = w.flush() + + w.process(blank()) + w.process(with_blob(160, 120)) + w.process(with_blob(180, 120)) + second = w.flush() + + self.assertIsNotNone(first) + self.assertIsNotNone(second) + self.assertGreater(second.started_at, first.started_at - _one_second()) + + +def _one_second(): + from datetime import timedelta + + return timedelta(seconds=1) + + +class ReportTests(unittest.TestCase): + """Die Auswertung der Rohdaten. Sie beantwortet die Frage nach den ersten + Tagen — sehen wir Vögel, und taugen die Bilder etwas.""" + + def setUp(self): + import json + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.ordner = Path(self.tmp.name) + + voll = with_blob(160, 120, r=50, size=(400, 300)) + crop = voll.crop((110, 70, 210, 170)) + voll.save(self.ordner / "20260828-112200_amsel.jpg") + crop.save(self.ordner / "20260828-112200_amsel_crop.jpg") + (self.ordner / "20260828-112200_amsel.json").write_text( + json.dumps({ + "species": "amsel", "confidence": 0.97, + "frames_with_bird": 25, "frames_total": 67, + }), encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def test_reads_a_saved_sighting(self): + from detector.report import lade + + eintraege = lade(self.ordner) + self.assertEqual(len(eintraege), 1) + e = eintraege[0] + self.assertEqual(e.art, "amsel") + self.assertAlmostEqual(e.confidence, 0.97) + self.assertAlmostEqual(e.trefferquote, 25 / 67) + + def test_measures_area_from_the_image_sizes(self): + from detector.report import lade + + e = lade(self.ordner)[0] + # 100x100 Ausschnitt aus 400x300 sind 8,3 %. + self.assertAlmostEqual(e.flaeche, 100 * 100 / (400 * 300) * 100, places=2) + self.assertGreater(e.schaerfe, 0) + self.assertGreater(e.helligkeit, 0) + + def test_empty_folder_is_not_an_error(self): + import tempfile + + from detector.report import lade + + with tempfile.TemporaryDirectory() as leer: + self.assertEqual(lade(Path(leer)), []) + + def test_broken_json_is_skipped(self): + from detector.report import lade + + (self.ordner / "kaputt.json").write_text("{nicht valide", encoding="utf-8") + self.assertEqual(len(lade(self.ordner)), 1) + + def test_contact_sheet_is_written(self): + from detector.report import kontaktbogen, lade + + ziel = self.ordner / "bogen.jpg" + n = kontaktbogen(lade(self.ordner), ziel) + self.assertEqual(n, 1) + self.assertTrue(ziel.exists()) + self.assertGreater(Image.open(ziel).width, 0) + + def test_verdict_flags_a_bird_that_is_too_small(self): + from detector.report import Eintrag, _urteil + + winzig = Eintrag("x", "amsel", 0.9, 5, 20, 400, 1.2, 120, None, None) + self.assertEqual(_urteil(winzig), "Vogel zu klein") + + verwackelt = Eintrag("x", "amsel", 0.9, 5, 20, 12, 20, 120, None, None) + self.assertEqual(_urteil(verwackelt), "verwackelt") + + gut = Eintrag("x", "amsel", 0.9, 5, 20, 300, 15, 120, None, None) + self.assertEqual(_urteil(gut), "sehr gut") + + +class ProtokollTests(unittest.TestCase): + """Das Dauerprotokoll. Der wichtigste Teil sind die Lebenszeichen: ohne + sie ist eine Lücke nicht von einem Ausfall zu unterscheiden.""" + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.pfad = Path(self.tmp.name) / "unter" / "protokoll.csv" + + def tearDown(self): + self.tmp.cleanup() + + def lade(self): + import csv + + with self.pfad.open(encoding="utf-8") as f: + return list(csv.DictReader(f)) + + def test_creates_file_and_header(self): + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad) + p.close() + self.assertTrue(self.pfad.exists()) + self.assertIn("zeit", self.pfad.read_text(encoding="utf-8")) + + def test_weak_detection_is_recorded(self): + """Genau dafür ist das Protokoll da: knapp verpasste Vögel sichtbar + machen, die es nie in eine Sichtung schaffen.""" + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, grundschwelle=0.08) + p.erkennung(Box(0.4, 0.4, 0.5, 0.5, 0.22), None, 120) + p.close() + + zeilen = self.lade() + self.assertEqual(len(zeilen), 1) + self.assertEqual(zeilen[0]["art"], "sicht") + self.assertAlmostEqual(float(zeilen[0]["score"]), 0.22) + self.assertAlmostEqual(float(zeilen[0]["flaeche_pct"]), 1.0, places=1) + + def test_below_the_floor_is_ignored(self): + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, grundschwelle=0.08) + p.erkennung(Box(0.4, 0.4, 0.5, 0.5, 0.03), None, 120) + p.erkennung(None, None, 120) + p.close() + self.assertEqual(self.lade(), []) + + def test_pulse_respects_its_interval(self): + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, puls_sekunden=100) + p.puls(0.0, 120) # erster zaehlt + p.puls(50.0, 120) # zu frueh + p.puls(150.0, 120) # wieder faellig + p.close() + self.assertEqual(len([z for z in self.lade() if z["art"] == "puls"]), 2) + + def test_pulse_can_be_forced(self): + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, puls_sekunden=9999) + p.puls(0.0, 120) + p.puls(1.0, 120, erzwingen=True) + p.close() + self.assertEqual(len(self.lade()), 2) + + def test_frame_counts_reset_after_a_pulse(self): + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, puls_sekunden=10) + for _ in range(7): + p.bild() + for _ in range(3): + p.bild(dunkel=True) + p.puls(20.0, 120) + p.bild() + p.puls(40.0, 120) + p.close() + + pulse = [z for z in self.lade() if z["art"] == "puls"] + self.assertEqual(pulse[0]["bilder"], "10") + self.assertEqual(pulse[0]["dunkel"], "3") + self.assertEqual(pulse[1]["bilder"], "1") + + def test_reopening_appends_instead_of_truncating(self): + """Ein Neustart des Dienstes darf die bisherigen Tage nicht wegwerfen.""" + from detector.protokoll import Protokoll + + p = Protokoll(self.pfad, puls_sekunden=1) + p.puls(0.0, 120) + p.close() + + p = Protokoll(self.pfad, puls_sekunden=1) + p.puls(0.0, 120) + p.close() + + self.assertEqual(len(self.lade()), 2) + self.assertEqual(self.pfad.read_text(encoding="utf-8").count("zeit,art"), 1) + + +class WatcherObserverTests(unittest.TestCase): + """Der Beobachter meldet jede Analyse — auch die ohne Fund.""" + + def test_observer_sees_hits_and_misses(self): + gesehen = [] + w = Watcher( + pipeline=FakePipeline([(BY_SCIENTIFIC["Parus major"], 0.9), None]), + config=WatcherConfig(classify_every=1, cooldown_s=0.0), + beobachter=lambda box, res: gesehen.append(box), + ) + w.process(blank()) + w.process(with_blob(160, 120)) + w.process(with_blob(180, 120)) + w.flush() + + self.assertGreaterEqual(len(gesehen), 2) + self.assertIsNotNone(gesehen[0]) + self.assertIsNone(gesehen[1]) + + +class CatalogTests(unittest.TestCase): + def test_slugs_are_unique(self): + slugs = [s.slug for s in SPECIES] + self.assertEqual(len(slugs), len(set(slugs))) + + def test_scientific_names_are_unique(self): + names = [s.scientific for s in SPECIES] + self.assertEqual(len(names), len(set(names))) + + def test_slugs_have_no_umlauts(self): + for s in SPECIES: + with self.subTest(species=s.german): + self.assertTrue(s.slug.isascii(), f"{s.german} -> {s.slug}") + + def test_every_species_has_a_fact(self): + for s in SPECIES: + with self.subTest(species=s.german): + self.assertTrue(s.fact.strip()) + + def test_rarer_species_are_worth_more(self): + order = ["haeufig", "gelegentlich", "selten", "episch", "legendaer"] + xp = [ + next(s.xp for s in SPECIES if s.rarity == r) + for r in order + if any(s.rarity == r for s in SPECIES) + ] + self.assertEqual(xp, sorted(xp)) + + def test_label_parsing(self): + self.assertEqual(label_to_scientific("Parus major (Great Tit)"), "Parus major") + self.assertEqual(label_to_scientific("Parus major"), "Parus major") + + def test_catalog_matches_the_model_labels(self): + """Jede Art im Katalog muss im Label-File des Modells vorkommen — + sonst wird sie nie erkannt und die Karte bleibt für immer leer. + + Gilt für den ganzen Katalog, nicht nur die Kernliste: eine Art ohne + Label wäre ein Eintrag, den niemand je freischalten kann. + """ + labels = Path(__file__).resolve().parents[1] / "models" / "inat_bird_labels.txt" + if not labels.exists(): + self.skipTest("Modelle nicht geladen (scripts/fetch_models.sh)") + + known = { + label_to_scientific(line) + for line in labels.read_text(encoding="utf-8").splitlines() + } + missing = [s.german for s in catalog() if s.scientific not in known] + self.assertEqual(missing, [], f"Nicht im Modell: {missing}") + + +class ExtendedCatalogTests(unittest.TestCase): + """Der erweiterte Katalog — 104 Arten ohne Erzähltext, nur Strukturdaten.""" + + def test_no_slug_collisions_with_the_core(self): + kern = {s.slug for s in SPECIES} + doppelt = [e.slug for e in EXTRA if e.slug in kern] + self.assertEqual(doppelt, []) + + def test_slugs_are_unique_and_ascii(self): + slugs = [e.slug for e in EXTRA] + self.assertEqual(len(slugs), len(set(slugs))) + for slug in slugs: + with self.subTest(slug=slug): + self.assertTrue(slug.isascii(), slug) + + def test_scientific_names_do_not_repeat(self): + namen = [s.scientific for s in catalog()] + self.assertEqual(len(namen), len(set(namen))) + + def test_habitat_filter_narrows_the_catalog(self): + alle = catalog() + garten = catalog({"garten"}) + self.assertLess(len(garten), len(alle)) + self.assertTrue(all(s.lebensraum == "garten" for s in garten)) + # Die Kernarten müssen im Gartenfilter vollständig enthalten sein — + # sonst verschwände ausgerechnet das, was die Kamera wirklich sieht. + self.assertTrue({s.slug for s in SPECIES} <= {s.slug for s in garten}) + + def test_every_extra_has_plausible_measurements(self): + for e in EXTRA: + with self.subTest(art=e.german): + self.assertGreater(e.gewicht_g, 0) + self.assertGreater(e.spannweite_cm, 0) + self.assertIn(e.zug, {"stand", "teil", "zug"}) + self.assertIn( + e.lebensraum, {"garten", "wald", "offenland", "wasser", "gebirge"} + ) + + def test_wingspan_exceeds_body_length(self): + """Ein Vogel mit kürzerer Spannweite als Körperlänge wäre ein Tippfehler.""" + for e in EXTRA: + laenge = float(e.size_cm.split()[0]) + with self.subTest(art=e.german): + self.assertGreater(e.spannweite_cm, laenge * 0.7) + + +class KarteTests(unittest.TestCase): + """Die Erkennungskarte laeuft ohne Modelle: die Pipeline ist eine Attrappe, + die nach Position entscheidet. Geprueft wird die Karte, nicht der Detektor.""" + + def setUp(self): + self.vogel = with_blob(60, 60, r=50, size=(120, 120)) + self.hg = [blank(size=(640, 480)) for _ in range(2)] + + def test_montage_hits_the_wanted_area(self): + gross = karte.montiere(self.hg[0], self.vogel, 0.25) + klein = karte.montiere(self.hg[0], self.vogel, 0.04) + # Der Vogel ist gelb, der Hintergrund gruen — gezaehlt wird, wie viele + # Pixel sich geaendert haben. + def anteil(bild): + diff = ImageChops.difference(bild, self.hg[0]).convert("L") + geaendert = sum(n for wert, n in enumerate(diff.histogram()) if wert > 0) + return geaendert / (bild.width * bild.height) + + # Die weiche Ellipse frisst Flaeche gegenueber dem Rechteck, deshalb + # grosszuegige Grenzen — es geht um die Groessenordnung. + self.assertGreater(anteil(gross), anteil(klein) * 3) + self.assertLess(anteil(gross), 0.30) + + def test_bird_stands_on_the_given_line(self): + """Der Fusspunkt ist der Sinn der Uebung: ein Vogel steht auf dem + Brett, er schwebt nicht um seinen Mittelpunkt.""" + bild = karte.montiere(self.hg[0], self.vogel, 0.09, mitte_x=0.5, fuss_y=0.5) + H = bild.height + veraendert = [ + y for y in range(H) + if any(bild.getpixel((x, y)) != (40, 60, 40) for x in range(0, bild.width, 4)) + ] + self.assertTrue(veraendert) + self.assertLessEqual(max(veraendert), H * 0.5 + 2) + self.assertGreater(max(veraendert), H * 0.5 - 20) + + def test_map_marks_the_dead_zone(self): + """Der Fall, fuer den es die Karte gibt: unten faellt alles durch.""" + + class NurObenPipeline: + detect_threshold = 0.4 + + def zaehlt_als_vogel(self, box): + return box is not None and box.score >= self.detect_threshold + + def run(self, img): + # Untere Bildhaelfte des montierten Vogels -> schlechter Score. + unten = img.getpixel((img.width // 2, int(img.height * 0.97))) + score = 0.2 if unten != (40, 60, 40) else 0.9 + return Box(0.4, 0.4, 0.6, 0.6, score), None + + zellen = karte.vermessen( + NurObenPipeline(), self.hg, self.vogel, anteil=0.09, + spalten=(0.5,), zeilen=(0.6, 0.99), + ) + oben = next(z for z in zellen if z.fuss_y == 0.6) + unten = next(z for z in zellen if z.fuss_y == 0.99) + self.assertTrue(oben.erkannt) + self.assertFalse(unten.erkannt) + self.assertIn("tote Zone", karte.rat(zellen)) + + def test_a_single_failure_disqualifies_a_position(self): + z = karte.Zelle(0.5, 0.5, treffer=2, gesamt=3, bester_score=0.9) + self.assertFalse(z.erkannt) + + def test_drawing_keeps_the_image_size(self): + zellen = [karte.Zelle(0.5, 0.5, 1, 1, 0.9), karte.Zelle(0.2, 0.9, 0, 1, 0.1)] + bild = karte.zeichne(self.hg[0], zellen) + self.assertEqual(bild.size, self.hg[0].size) + self.assertEqual(bild.mode, "RGB") + + def test_table_has_a_row_per_line(self): + zellen = karte.vermessen( + FakePipeline([(None, 0.0)]), + self.hg, self.vogel, anteil=0.09, + spalten=(0.3, 0.7), zeilen=(0.5, 0.9), + ) + text = karte.tabelle(zellen) + self.assertEqual(len(text.splitlines()), 3) # Kopf + zwei Zeilen + + +class StichprobeTests(unittest.TestCase): + """Die Stichproben beantworten, was das Protokoll nicht kann: kommt + überhaupt ein Vogel ins Bild? Dafür müssen sie regelmäßig fallen und + dürfen die Platte nicht volllaufen lassen.""" + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.ordner = Path(self.tmp.name) / "stichproben" + self.frame = Image.new("RGB", (320, 180), (90, 120, 80)) + + def tearDown(self): + self.tmp.cleanup() + + def test_first_frame_is_kept(self): + from detector.stichprobe import Stichprobe + + s = Stichprobe(self.ordner, alle_sekunden=300) + self.assertIsNotNone(s.bild(self.frame, 1000.0)) + self.assertEqual(len(list(self.ordner.glob("*.jpg"))), 1) + + def test_interval_is_respected(self): + from detector.stichprobe import Stichprobe + + s = Stichprobe(self.ordner, alle_sekunden=300) + s.bild(self.frame, 1000.0) + self.assertIsNone(s.bild(self.frame, 1299.0)) + self.assertIsNotNone(s.bild(self.frame, 1300.0)) + + def test_oldest_is_dropped_at_the_limit(self): + """Ein Dauerläufer ohne Obergrenze füllt irgendwann die Platte.""" + from detector.stichprobe import Stichprobe + + s = Stichprobe(self.ordner, max_bilder=3) + for tag in ("01", "02", "03", "04", "05"): + self.frame.save(self.ordner / f"202609{tag}-120000.jpg") + + s.bild(self.frame, 1000.0) # raeumt nach dem Speichern auf + + uebrig = sorted(p.name for p in self.ordner.glob("*.jpg")) + self.assertEqual(len(uebrig), 3) + # Die aeltesten sind weg, das frisch gespeicherte ist da. + self.assertNotIn("20260901-120000.jpg", uebrig) + self.assertIn("20260905-120000.jpg", uebrig) + + def test_contact_sheet_covers_the_newest_first(self): + from detector.stichprobe import kontaktbogen + + self.ordner.mkdir(parents=True) + for tag in ("01", "02", "03", "04"): + self.frame.save(self.ordner / f"202609{tag}-120000.jpg") + ziel = Path(self.tmp.name) / "bogen.jpg" + + pfad, n, seiten = kontaktbogen(self.ordner, ziel, spalten=2, zeilen=1) + self.assertTrue(pfad.exists()) + self.assertEqual(n, 2) + self.assertEqual(seiten, 2) + + def test_contact_sheet_without_images_says_so(self): + from detector.stichprobe import kontaktbogen + + self.ordner.mkdir(parents=True) + with self.assertRaises(FileNotFoundError): + kontaktbogen(self.ordner, Path(self.tmp.name) / "bogen.jpg") + + +if __name__ == "__main__": + unittest.main() + + +class UebernimmTests(unittest.TestCase): + """Einstellungen im laufenden Betrieb ändern. + + Läuft gegen die echte Pipeline, weil genau das die Zusage ist: das Modell + bleibt geladen, nur die Zuordnung wird neu berechnet. Mit einem Doppel + wäre der Test wertlos. + """ + + @classmethod + def setUpClass(cls): + from detector.classify import CLASSIFIER_MODEL, DETECTOR_MODEL + + if not CLASSIFIER_MODEL.exists() or not DETECTOR_MODEL.exists(): + raise unittest.SkipTest("Modelle fehlen — scripts/fetch_models.sh") + + from detector.classify import Pipeline + + # Mit Gartenfilter, wie im Betrieb: sonst stehen 148 Arten zur + # Wahl und das Zurücksetzen im Test trifft die falsche Zahl. + cls.pipeline = Pipeline(detect_threshold=0.4, min_bird_score=0.05, + report_threshold=0.08, habitats={"garten"}) + + def test_ohne_aenderung_passiert_nichts(self): + self.assertEqual( + self.pipeline.uebernimm(detect_threshold=0.4, min_bird_score=0.05), [] + ) + + def test_schwelle_zieht_den_finder_mit(self): + """Sonst sucht der Finder weiter nach der alten Schwelle — der Fehler, + der das Protokoll schon einmal blind gemacht hat.""" + p = self.pipeline + try: + p.uebernimm(detect_threshold=0.05) + self.assertEqual(p.detect_threshold, 0.05) + # Der Finder sucht ab der niedrigeren von Wunsch (0.08) und + # Sichtungsschwelle (0.05). + self.assertEqual(p.report_threshold, 0.05) + self.assertEqual(p.finder.threshold, 0.05) + finally: + p.uebernimm(detect_threshold=0.4) + self.assertEqual(p.report_threshold, 0.08) + + def test_artenliste_wird_getauscht_und_das_modell_bleibt(self): + from detector.species_de import Species + + p = self.pipeline + interp_vorher = p.classifier.interp + vorher = len(p.classifier.native_idx) + + nur_zwei = [ + Species("Parus major", "Kohlmeise", "Great Tit", "haeufig", ""), + Species("Turdus merula", "Amsel", "Blackbird", "haeufig", ""), + ] + try: + geaendert = p.uebernimm(arten=nur_zwei) + self.assertTrue(any("Katalog" in z for z in geaendert)) + self.assertEqual(len(p.classifier.native_idx), 2) + # Dasselbe Modell, nur eine neue Zuordnung. + self.assertIs(p.classifier.interp, interp_vorher) + finally: + from detector.species_de import catalog + + p.uebernimm(arten=catalog({"garten"})) + self.assertEqual(len(p.classifier.native_idx), vorher) + + def test_gleiche_artenliste_meldet_keine_aenderung(self): + from detector.species_de import catalog + + self.assertEqual(self.pipeline.uebernimm(arten=catalog({"garten"})), []) + + +class AnzeigeAusschnittTests(unittest.TestCase): + """🔑 Der Klassifikator und das Auge wollen Verschiedenes. + + ``crop`` liegt eng am Vogel, weil Hintergrund die Bestimmung verwaessert. + In der fast quadratischen Kachel der Sammlung schneidet genau das links + und rechts den Vogel weg — bei einem Vogel im Hochformat bleibt die + Bauchmitte uebrig. Gemeldet als „der Zuschnitt sieht merkwuerdig aus". + """ + + def bild(self, groesse=(1800, 2400)): + from PIL import Image + + return Image.new("RGB", groesse, (120, 160, 200)) + + def test_anzeige_ist_quadratisch(self): + from detector.classify import Box + + # Ein hochkant sitzender Vogel, schmal und hoch. + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + anzeige = box.anzeige_ausschnitt(im) + + self.assertLess(eng.width / eng.height, 0.7, "Testbox ist nicht hochkant") + self.assertAlmostEqual(anzeige.width / anzeige.height, 1.0, places=1) + + def test_anzeige_enthaelt_den_ganzen_vogel(self): + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + anzeige = box.anzeige_ausschnitt(im) + # Mindestens so hoch wie der Vogel plus Rand. + vogel_hoehe = (0.62 - 0.30) * im.height + self.assertGreater(anzeige.height, vogel_hoehe) + + def test_crop_bleibt_unveraendert_eng(self): + """Die Artbestimmung ist gemessen und darf sich nicht mitaendern.""" + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + erwartet_breite = (0.52 - 0.40) * im.width * 1.24 # pad 0.12 beidseits + self.assertAlmostEqual(eng.width, erwartet_breite, delta=3) + + def test_am_bildrand_bleibt_der_ausschnitt_im_bild(self): + """⚠️ Sonst sitzt ein Vogel am Rand ausserhalb seiner eigenen Kachel.""" + from detector.classify import Box + + im = self.bild() + for box in ( + Box(x0=0.00, y0=0.00, x1=0.08, y1=0.10, score=0.9), + Box(x0=0.92, y0=0.90, x1=1.00, y1=1.00, score=0.9), + ): + with self.subTest(box=box): + a = box.anzeige_ausschnitt(im) + self.assertGreater(a.width, 0) + self.assertLessEqual(a.width, im.width) + self.assertLessEqual(a.height, im.height) + self.assertAlmostEqual(a.width / a.height, 1.0, places=1) + + def test_winziges_bild_sprengt_nichts(self): + from detector.classify import Box + + im = self.bild((60, 40)) + a = Box(x0=0.3, y0=0.3, x1=0.7, y1=0.7, score=0.9).anzeige_ausschnitt(im) + self.assertLessEqual(a.width, 60) + self.assertLessEqual(a.height, 40) diff --git a/tests/test_fernkonfig.py b/tests/test_fernkonfig.py new file mode 100644 index 0000000..635b1ac --- /dev/null +++ b/tests/test_fernkonfig.py @@ -0,0 +1,119 @@ +"""Einstellungen aus der Web-App. + +Der Kern ist nicht der Abruf, sondern das Verhalten im Fehlerfall: eine +unerreichbare Web-App darf die Erkennung nicht anhalten und nicht auf +Vorgaben zurückwerfen. Wer zwei Wochen auf den ersten Vogel wartet, will ihn +nicht verpassen, weil ein Webserver neu gestartet wurde. +""" + +from __future__ import annotations + +import json +import threading +import unittest +from http import server + +from detector.fernkonfig import Fernkonfig, arten_aus_json + +ANTWORT = { + "kamera": "vogelhaus", + "stream_url": "http://10.0.0.9:8080/stream", + "habitats": ["garten"], + "detect_threshold": 0.35, + "min_bird_score": 0.07, + "region": "de", + "arten": [ + {"scientific": "Parus major", "german": "Kohlmeise", "english": "Great Tit", + "rarity": "haeufig", "lebensraum": "garten"}, + {"scientific": "Turdus merula", "german": "Amsel", "english": "Blackbird", + "rarity": "haeufig", "lebensraum": "garten"}, + ], +} + + +class _Handler(server.BaseHTTPRequestHandler): + antwort = ANTWORT + status = 200 + gesehene_token: list[str] = [] + + def log_message(self, fmt, *args): # noqa: A003 + pass + + def do_GET(self): # noqa: N802 + _Handler.gesehene_token.append(self.headers.get("Authorization", "")) + koerper = json.dumps(self.antwort).encode("utf-8") + self.send_response(self.status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(koerper))) + self.end_headers() + self.wfile.write(koerper) + + +class ArtenTests(unittest.TestCase): + def test_uebersetzt_die_felder(self): + arten = arten_aus_json(ANTWORT["arten"]) + self.assertEqual([a.scientific for a in arten], ["Parus major", "Turdus merula"]) + self.assertEqual(arten[0].german, "Kohlmeise") + self.assertEqual(arten[0].lebensraum, "garten") + + def test_ohne_wissenschaftlichen_namen_faellt_weg(self): + """Der wissenschaftliche Name ist der Schlüssel ins Modell — ohne ihn + trifft der Eintrag später leise nichts.""" + arten = arten_aus_json([{"german": "Namenlos"}, {"scientific": " "}]) + self.assertEqual(arten, []) + + def test_fehlende_beschriftung_wird_ersetzt(self): + arten = arten_aus_json([{"scientific": "Pica pica"}]) + self.assertEqual(arten[0].german, "Pica pica") + self.assertEqual(arten[0].rarity, "haeufig") + + +class AbrufTests(unittest.TestCase): + def setUp(self): + _Handler.antwort = ANTWORT + _Handler.status = 200 + _Handler.gesehene_token = [] + self.srv = server.HTTPServer(("127.0.0.1", 0), _Handler) + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + self.addCleanup(self.srv.server_close) + self.addCleanup(self.srv.shutdown) + self.api = f"http://127.0.0.1:{self.srv.server_address[1]}" + + def test_holt_und_merkt_sich(self): + f = Fernkonfig(self.api, "geheim") + daten = f.hole() + self.assertEqual(daten["kamera"], "vogelhaus") + self.assertEqual(f.letzte, daten) + self.assertEqual(_Handler.gesehene_token, ["Bearer geheim"]) + + def test_faellig_erst_nach_dem_intervall(self): + f = Fernkonfig(self.api, "geheim", intervall=300) + self.assertTrue(f.faellig(1000.0)) + f.hole(1000.0) + self.assertFalse(f.faellig(1299.0)) + self.assertTrue(f.faellig(1300.0)) + + def test_abgelehnter_token_gibt_none(self): + _Handler.status = 401 + f = Fernkonfig(self.api, "falsch") + self.assertIsNone(f.hole()) + + def test_unerreichbar_gibt_none_und_behaelt_den_stand(self): + """Das eigentliche Versprechen: ein Ausfall ändert nichts.""" + f = Fernkonfig(self.api, "geheim") + vorher = f.hole() + self.assertIsNotNone(vorher) + + self.srv.shutdown() + self.srv.server_close() + self.assertIsNone(f.hole()) + self.assertEqual(f.letzte, vorher) + + def test_unlesbare_antwort_gibt_none(self): + _Handler.antwort = ["kein", "objekt"] + f = Fernkonfig(self.api, "geheim") + self.assertIsNone(f.hole()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verteiler.py b/tests/test_verteiler.py new file mode 100644 index 0000000..1610860 --- /dev/null +++ b/tests/test_verteiler.py @@ -0,0 +1,204 @@ +"""Der Live-Verteiler. + +Der Kern ist nicht der HTTP-Teil, sondern das Versprechen: die Erkennung darf +davon nichts merken. Deshalb pruefen die Tests vor allem, dass ohne Zuschauer +nicht kodiert wird und dass fertige JPEG-Bytes unveraendert durchlaufen. +""" + +from __future__ import annotations + +import json # noqa: F401 - Symmetrie zu den anderen Testdateien +import threading +import unittest +import urllib.error +import urllib.request +from io import BytesIO + +from PIL import Image + +from detector.verteiler import Bildhalter, Handler, Verteiler + +BILD = Image.new("RGB", (320, 180), (60, 110, 70)) + + +def jpeg_bytes(bild: Image.Image = BILD) -> bytes: + puffer = BytesIO() + bild.save(puffer, format="JPEG") + return puffer.getvalue() + + +class BildhalterTests(unittest.TestCase): + def test_ohne_bild_kommt_nichts(self): + self.assertIsNone(Bildhalter().jpeg()) + + def test_fertige_bytes_laufen_unveraendert_durch(self): + """Der haeufige Fall: die Quelle liefert MJPEG. Wird hier umkodiert, + zahlt der Detector Rechenzeit fuer nichts.""" + roh = jpeg_bytes() + h = Bildhalter() + h.zeige(BILD, roh) + + nummer, daten = h.jpeg() + self.assertEqual(nummer, 1) + self.assertIs(daten, roh) + + def test_ohne_fertige_bytes_wird_kodiert(self): + """RTSP und Bilderordner liefern kein fertiges JPEG — dann muss der + Verteiler selbst eines machen.""" + h = Bildhalter() + h.zeige(BILD, None) + + nummer, daten = h.jpeg() + self.assertEqual(nummer, 1) + self.assertEqual(daten[:2], b"\xff\xd8") + self.assertEqual(Image.open(BytesIO(daten)).size, BILD.size) + + def test_kodiert_nur_einmal_je_bild(self): + """Sonst zahlt jeder Zuschauer seine eigene Kodierung.""" + h = Bildhalter() + h.zeige(BILD, None) + + erst = h.jpeg() + nochmal = h.jpeg() + self.assertIs(erst[1], nochmal[1]) + + def test_neues_bild_wird_neu_kodiert(self): + h = Bildhalter() + h.zeige(BILD, None) + erst = h.jpeg() + + h.zeige(Image.new("RGB", (320, 180), (200, 30, 30)), None) + neu = h.jpeg() + + self.assertEqual(neu[0], erst[0] + 1) + self.assertIsNot(neu[1], erst[1]) + + def test_zeige_kodiert_nicht(self): + """Das Versprechen an die Erkennung: anmelden kostet Zeigerarbeit. + Solange niemand abholt, entsteht kein JPEG.""" + h = Bildhalter() + for _ in range(50): + h.zeige(BILD, None) + self.assertIsNone(h._kodiert) + self.assertEqual(h.nummer, 50) + + def test_grosse_bilder_werden_verkleinert(self): + h = Bildhalter(max_breite=160) + h.zeige(BILD, None) + self.assertEqual(Image.open(BytesIO(h.jpeg()[1])).width, 160) + + def test_warte_laeuft_ohne_bild_ab(self): + h = Bildhalter() + self.assertEqual(h.warte(0, timeout=0.05), 0) + + def test_warte_kehrt_mit_neuem_bild_zurueck(self): + h = Bildhalter() + threading.Timer(0.05, lambda: h.zeige(BILD, None)).start() + self.assertEqual(h.warte(0, timeout=5.0), 1) + + def test_warte_kehrt_sofort_zurueck_wenn_schon_neu(self): + h = Bildhalter() + h.zeige(BILD, None) + self.assertEqual(h.warte(0, timeout=5.0), 1) + + +class VerteilerTests(unittest.TestCase): + def setUp(self): + Handler._zuschauer = 0 + self.v = Verteiler(port=0, bind="127.0.0.1") + self.v.start() + self.addCleanup(self.v.stop) + + def url(self, pfad: str) -> str: + return f"http://127.0.0.1:{self.v.port}{pfad}" + + def hole(self, pfad: str, timeout: float = 5.0): + return urllib.request.urlopen(self.url(pfad), timeout=timeout) + + def test_seite_kommt(self): + with self.hole("/") as r: + self.assertEqual(r.status, 200) + self.assertIn(b"/live", r.read()) + + def test_einzelbild_erst_mit_bild(self): + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/bild.jpg") + self.assertEqual(fehler.exception.code, 503) + + self.v.zeige(BILD, jpeg_bytes()) + with self.hole("/bild.jpg") as r: + self.assertEqual(r.status, 200) + self.assertEqual(r.headers["Content-Type"], "image/jpeg") + self.assertEqual(r.read()[:2], b"\xff\xd8") + + def test_unbekannter_pfad_ist_404(self): + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/gibtsnicht") + self.assertEqual(fehler.exception.code, 404) + + def test_stream_liefert_multipart(self): + stop = threading.Event() + + def speisen(): + while not stop.is_set(): + self.v.zeige(BILD, jpeg_bytes()) + stop.wait(0.01) + + threading.Thread(target=speisen, daemon=True).start() + self.addCleanup(stop.set) + + strom = self.hole("/live") + self.addCleanup(strom.close) + self.assertIn("multipart/x-mixed-replace", strom.headers["Content-Type"]) + anfang = strom.read(400) + self.assertIn(b"--BILDGRENZE", anfang) + self.assertIn(b"\xff\xd8", anfang) + + def test_zwei_zuschauer_sind_erlaubt(self): + """Der ganze Zweck: an der Kamera geht nur einer, hier beliebig viele.""" + stop = threading.Event() + + def speisen(): + while not stop.is_set(): + self.v.zeige(BILD, jpeg_bytes()) + stop.wait(0.01) + + threading.Thread(target=speisen, daemon=True).start() + self.addCleanup(stop.set) + + erster = self.hole("/live") + self.addCleanup(erster.close) + erster.read(200) + + zweiter = self.hole("/live") + self.addCleanup(zweiter.close) + self.assertEqual(zweiter.status, 200) + zweiter.read(200) + + self.assertEqual(self.v.zuschauer, 2) + + def test_obergrenze_weist_ab(self): + stop = threading.Event() + + def speisen(): + while not stop.is_set(): + self.v.zeige(BILD, jpeg_bytes()) + stop.wait(0.01) + + threading.Thread(target=speisen, daemon=True).start() + self.addCleanup(stop.set) + + Handler.max_zuschauer = 1 + self.addCleanup(setattr, Handler, "max_zuschauer", 8) + + erster = self.hole("/live") + self.addCleanup(erster.close) + erster.read(200) + + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/live") + self.assertEqual(fehler.exception.code, 503) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_vogelkamera.py b/tests/test_vogelkamera.py new file mode 100644 index 0000000..14ecb75 --- /dev/null +++ b/tests/test_vogelkamera.py @@ -0,0 +1,352 @@ +"""Tests fuer den MJPEG-Server der Kameraseite (``pi/vogelkamera.py``). + + ./venv/bin/python -m unittest discover tests + +Laeuft ohne Raspberry Pi und ohne Kamera: ``rpicam-vid`` wird nie gestartet, +der Bildpuffer von Hand befuellt. Geprueft wird die HTTP-Schicht -- und die +ist der Teil, der schon Schaden angerichtet hat. Dass ein zweiter Zuschauer +abgewiesen wird, ist keine Kosmetik: ein stiller zweiter Client halbiert die +Bildrate und der Detector faellt in Timeouts, ohne dass irgendwo ein Fehler +steht. + +⚠️ **Diese Datei war vom 25.09. bis zum 09.10.2026 rot** -- 19 Fehler. Commit +b57c1b3 ersetzte die picamera2-Fassung durch die rpicam-vid-Fassung, die +wirklich auf dem Geraet laeuft; die Tests blieben auf den alten Namen stehen +(``Bildpuffer`` statt ``FrameBuffer``, ``Handler`` statt ``MJPEGHandler``) und +pruefen Code, den es nicht mehr gab. + +🔑 Beim Nachziehen kam heraus, dass ``pi/README.md`` die 503-Absage fuer den +zweiten Zuschauer die ganze Zeit **versprochen** hat, waehrend die Fassung vom +Geraet ihn nur zaehlte. Die Tests haben also nicht nur den Namen verfehlt, +sondern eine fehlende Schutzfunktion verdeckt. +""" + +from __future__ import annotations + +import importlib.util +import json +import threading +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path + +_pfad = Path(__file__).resolve().parents[1] / "pi" / "vogelkamera.py" +_spec = importlib.util.spec_from_file_location("vogelkamera", _pfad) +vogelkamera = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(vogelkamera) + +# Ein winziges, aber gueltiges JPEG waere hier Overkill -- der Server reicht +# Bytes durch, ohne sie zu deuten. +BILD = b"\xff\xd8" + b"x" * 64 + b"\xff\xd9" + + +class ServerTests(unittest.TestCase): + def setUp(self): + # Der Server haelt seinen Zustand in Modulvariablen, nicht in einer + # Instanz. Jeder Test setzt sie zurueck, sonst schleppt er den Stand + # des vorigen mit -- der Zuschauerplatz ganz besonders. + vogelkamera.frame_buffer = vogelkamera.FrameBuffer() + vogelkamera.zuschauer_platz = threading.Semaphore(1) + vogelkamera.bilder_gesamt = 0 + vogelkamera.zuschauer = 0 + vogelkamera.START_ZEIT = time.time() + + self.server = vogelkamera.ThreadedHTTPServer( + ("127.0.0.1", 0), vogelkamera.MJPEGHandler + ) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self._stop) + + def _stop(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def url(self, pfad: str) -> str: + return f"http://127.0.0.1:{self.port}{pfad}" + + def hole(self, pfad: str, timeout: float = 5.0): + return urllib.request.urlopen(self.url(pfad), timeout=timeout) + + def schiebe(self, bild: bytes = BILD) -> None: + """Ein Bild einspeisen, so wie es ``capture_loop`` tut -- inklusive + Zaehlwerk, denn /status liest beides.""" + vogelkamera.frame_buffer.update(bild) + with vogelkamera._zaehler_sperre: + vogelkamera.bilder_gesamt += 1 + + def speisen(self) -> threading.Event: + """Bilder nachschieben, bis der Test fertig ist.""" + stop = threading.Event() + + def lauf(): + while not stop.is_set(): + self.schiebe() + stop.wait(0.01) + + threading.Thread(target=lauf, daemon=True).start() + self.addCleanup(stop.set) + return stop + + # -- die einfachen Endpunkte ------------------------------------------ + + def test_startseite_kommt(self): + with self.hole("/") as r: + self.assertEqual(r.status, 200) + self.assertIn(b"/stream", r.read()) + + def test_temperatur_endpunkt_antwortet_immer(self): + """Auf einem Nicht-Pi gibt es die Datei nicht -- dann 'N/A', aber kein + Fehler. Der Endpunkt ist die einzige Gesundheitspruefung, die den + Stream nicht anfasst.""" + with self.hole("/temp") as r: + self.assertEqual(r.status, 200) + self.assertTrue(r.read().strip()) + + def test_gesund_meldet_erst_mit_bild(self): + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/gesund") + self.assertEqual(fehler.exception.code, 503) + + self.schiebe() + with self.hole("/gesund") as r: + self.assertEqual(r.status, 200) + + def test_gesund_ist_auch_unter_health_erreichbar(self): + """Der englische Name steht in fremden Werkzeugen (Uptime-Kuma und + Konsorten) -- beide Pfade muessen dasselbe tun.""" + self.schiebe() + with self.hole("/health") as r: + self.assertEqual(r.status, 200) + + def test_gesund_merkt_ein_altes_bild(self): + """Der Server antwortet noch, die Aufnahme haengt. Genau das ist der + Fall, den ein Ping nie sieht.""" + vogelkamera.frame_buffer.update(BILD) + with vogelkamera.frame_buffer._lock: + vogelkamera.frame_buffer._timestamp = time.time() - 30 + + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/gesund") + self.assertEqual(fehler.exception.code, 503) + + def test_schnappschuss_gibt_das_bild_her(self): + """Ein einzelnes Bild statt eines Streams -- der schonende Weg, einmal + nachzusehen, was die Kamera sieht.""" + self.schiebe() + with self.hole("/snapshot") as r: + self.assertEqual(r.status, 200) + self.assertEqual(r.headers["Content-Type"], "image/jpeg") + self.assertEqual(r.read(), BILD) + + def test_schnappschuss_ohne_bild_ist_503(self): + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/snapshot") + self.assertEqual(fehler.exception.code, 503) + + def test_unbekannter_pfad_ist_404(self): + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/gibtsnicht") + self.assertEqual(fehler.exception.code, 404) + + # -- der Stream -------------------------------------------------------- + + def test_stream_liefert_multipart_mit_bildern(self): + self.speisen() + with self.hole("/stream") as r: + self.assertEqual(r.status, 200) + self.assertIn("multipart/x-mixed-replace", r.headers["Content-Type"]) + anfang = r.read(len(BILD) + 200) + self.assertIn(b"--frame", anfang) + self.assertIn(b"\xff\xd8", anfang) + + def test_zweiter_zuschauer_wird_abgewiesen(self): + """Der eigentliche Punkt: lieber eine klare Absage als zwei ruckelnde + Bilder und ein Detector in Timeouts. + + ⚠️ Dieser Test war der Grund, die Datei wieder anzufassen -- er stand + auf der alten Fassung und hat deshalb nicht gemeldet, dass die Absage + im Code fehlte, obwohl das README sie zusagte.""" + self.speisen() + erster = self.hole("/stream") + self.addCleanup(erster.close) + erster.read(len(BILD)) # sicherstellen, dass er wirklich haengt + + with self.assertRaises(urllib.error.HTTPError) as fehler: + self.hole("/stream") + self.assertEqual(fehler.exception.code, 503) + self.assertIn("Zuschauer", fehler.exception.read().decode("utf-8")) + + def test_nach_dem_trennen_darf_der_naechste_wieder(self): + """Ohne das waere die Kamera nach einem Browserfenster dauerhaft + dicht.""" + self.speisen() + erster = self.hole("/stream") + erster.read(len(BILD)) + erster.close() + + # Der Server merkt das Trennen erst beim naechsten Schreibversuch. + for _ in range(250): + if vogelkamera.zuschauer_platz.acquire(blocking=False): + vogelkamera.zuschauer_platz.release() + break + time.sleep(0.02) + else: + self.fail("Der Zuschauerplatz wurde nach dem Trennen nie frei") + + zweiter = self.hole("/stream") + self.addCleanup(zweiter.close) + self.assertEqual(zweiter.status, 200) + + def test_stehende_kamera_gibt_den_platz_wieder_frei(self): + """🔑 Die Falle, die der Zuschauerplatz selbst aufstellt. + + Ohne neues Bild schreibt der Server nichts, merkt also auch nicht, dass + der Zuschauer weg ist -- und haelt den einen Platz fuer immer. Der + Detector bekaeme bei jedem Versuch 503, die Kamera waere dauerhaft + stumm. Deshalb endet ein Stream ohne Bild nach ``STREAM_LEERLAUF``.""" + vogelkamera.STREAM_LEERLAUF = 0.3 + self.addCleanup(setattr, vogelkamera, "STREAM_LEERLAUF", 15.0) + + self.schiebe() # ein Bild, danach bleibt die Kamera stehen + erster = self.hole("/stream") + self.addCleanup(erster.close) + erster.read(len(BILD)) + + for _ in range(100): + if vogelkamera.zuschauer_platz.acquire(blocking=False): + vogelkamera.zuschauer_platz.release() + break + time.sleep(0.02) + else: + self.fail("Der Platz blieb an einer stehenden Kamera haengen") + + zweiter = self.hole("/stream") + self.addCleanup(zweiter.close) + self.assertEqual(zweiter.status, 200) + + # -- /status ---------------------------------------------------------- + + def test_status_antwortet_ohne_bild(self): + """Genau dann braucht man ihn: die Kamera liefert nichts und man will + wissen, warum -- ohne den Stream anzufassen.""" + with self.hole("/status") as r: + self.assertEqual(r.status, 200) + self.assertEqual(r.headers["Content-Type"], "application/json") + d = json.loads(r.read()) + + self.assertEqual(d["kamera"], "fehlt") + self.assertEqual(d["fehler"], "0x1") + self.assertEqual(d["bilder"], 0) + self.assertEqual(d["letztes_bild_s"], -1.0) + self.assertFalse(d["zuschauer"]) + + def test_status_zaehlt_bilder_auch_ohne_zuschauer(self): + """Die Kamera liefert ins Leere, wenn niemand zuschaut -- /status muss + trotzdem sagen koennen, dass sie arbeitet.""" + for _ in range(3): + self.schiebe() + + with self.hole("/status") as r: + d = json.loads(r.read()) + self.assertEqual(d["kamera"], "bereit") + self.assertEqual(d["fehler"], "0x0") + self.assertEqual(d["bilder"], 3) + self.assertGreaterEqual(d["letztes_bild_s"], 0.0) + self.assertFalse(d["zuschauer"]) + + def test_status_hat_die_felder_der_esp32_seite(self): + """Gleiches Schema auf beiden Kameraseiten, sonst braucht jedes + Werkzeug zwei Faelle. ``psram_frei`` fehlt absichtlich -- das gibt es + nur auf dem ESP32.""" + self.schiebe() + with self.hole("/status") as r: + d = json.loads(r.read()) + + for feld in ("kamera", "fehler", "laufzeit_s", "bilder", "fps", + "letztes_bild_s", "zuschauer", "rssi", "temperatur_c", + "speicher_frei"): + self.assertIn(feld, d) + + def test_status_sieht_den_zuschauer(self): + self.speisen() + strom = self.hole("/stream") + self.addCleanup(strom.close) + strom.read(len(BILD)) + + with self.hole("/status") as r: + self.assertTrue(json.loads(r.read())["zuschauer"]) + + def test_status_geht_waehrend_der_stream_laeuft(self): + """Der Stream haelt seinen Socket dauerhaft. Waere der Server dann + blockiert, waere /status nutzlos -- gefragt ist er ja im Betrieb.""" + self.speisen() + strom = self.hole("/stream") + self.addCleanup(strom.close) + strom.read(len(BILD)) + + with self.hole("/status", timeout=5.0) as r: + self.assertEqual(r.status, 200) + + +class MesswerteTests(unittest.TestCase): + """Die drei Ablesungen aus /proc und /sys. Auf einem Nicht-Pi fehlen sie + teils -- dann ist None richtig und ein Absturz falsch.""" + + def test_temperatur_ist_zahl_oder_none(self): + c = vogelkamera.read_cpu_temp() + self.assertTrue(c is None or isinstance(c, float)) + + def test_funkstaerke_ist_zahl_oder_none(self): + r = vogelkamera.funkstaerke() + self.assertTrue(r is None or isinstance(r, int)) + + def test_speicher_ist_zahl_oder_none(self): + m = vogelkamera.speicher_frei() + self.assertTrue(m is None or m > 0) + + def test_status_json_ueberlebt_fehlende_messwerte(self): + """Auf einem Nicht-Pi fehlt alles, was aus /sys kommt. Trotzdem muss + das JSON vollstaendig und serialisierbar sein -- die Bruecke nach Home + Assistant liest es blind.""" + vogelkamera.frame_buffer = vogelkamera.FrameBuffer() + d = vogelkamera.status_json() + json.dumps(d) # wirft, wenn ein Wert nicht serialisierbar ist + self.assertEqual(d["kamera"], "fehlt") + + +class FrameBufferTests(unittest.TestCase): + def test_haelt_nur_das_neueste_bild(self): + """Kein Rueckstau: wer sich spaet einklinkt, will das aktuelle Bild + und nicht die Warteschlange von vor zehn Sekunden.""" + p = vogelkamera.FrameBuffer() + p.update(b"alt") + p.update(b"neu") + self.assertEqual(p.get()[0], b"neu") + + def test_leerer_puffer_hat_keinen_zeitstempel(self): + """Die Null ist der Wert, an dem /status 'fehlt' erkennt -- ein + geratener Zeitstempel wuerde eine stehende Kamera gesund aussehen + lassen.""" + p = vogelkamera.FrameBuffer() + frame, ts = p.get() + self.assertIsNone(frame) + self.assertEqual(ts, 0) + + def test_datiert_jedes_bild(self): + p = vogelkamera.FrameBuffer() + p.update(b"eins") + erster = p.get()[1] + self.assertGreater(erster, 0) + + time.sleep(0.02) + p.update(b"zwei") + self.assertGreater(p.get()[1], erster) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/dex/__init__.py b/web/dex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dex/admin.py b/web/dex/admin.py new file mode 100644 index 0000000..1538354 --- /dev/null +++ b/web/dex/admin.py @@ -0,0 +1,158 @@ +from django.conf import settings +from django.contrib import admin, messages +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + +from . import benachrichtigung as bn +from .models import (Badge, Benachrichtigung, Camera, DexEntry, EarnedBadge, + Sighting, Species) + + +@admin.register(Camera) +class CameraAdmin(admin.ModelAdmin): + list_display = ( + "name", "slug", "location", "active", "last_seen", "health", "live", + ) + list_filter = ("active", "live_enabled") + # Der Token existiert hier nicht im Klartext — nur sein Hash. Neuen Token + # gibt es über 'manage.py camera rotate '. + readonly_fields = ("token_hash", "created_at", "last_seen", + "letzter_kontakt") + prepopulated_fields = {"slug": ("name",)} + fieldsets = ( + (None, {"fields": ("name", "slug", "location", "active")}), + ("Erkennung", { + "fields": ("stream_url", "habitats", "detect_threshold", + "min_bird_score"), + "description": _( + "Der Detector holt sich diese Werte selbst — er fragt " + "/api/config/ mit seinem Token und übernimmt " + "Änderungen im laufenden Betrieb, ohne Neustart. Eine " + "Stream-Adresse, die auf seiner Kommandozeile steht, behält " + "aber Vorrang." + ), + }), + ("Live-Bild", { + "fields": ("live_url", "live_enabled", "live_bildrate"), + "description": _( + "Das Livebild kommt vom Live-Verteiler des Detectors, nicht " + "von der Kamera selbst — die liefert nur einen Stream, und den " + "braucht die Erkennung. Starten mit " + "python -m detector watch … --live-port 8131." + ), + }), + ("Technisches", { + "fields": ("token_hash", "created_at", "last_seen", + "letzter_kontakt"), + "classes": ("collapse",), + }), + ) + + @admin.display(description="Zustand") + def health(self, obj: Camera) -> str: + if not obj.active: + return "stillgelegt" + return "meldet sich nicht" if obj.is_stale else "ok" + + @admin.display(description="Live", boolean=False) + def live(self, obj: Camera) -> str: + """Warum das Livebild (nicht) erscheint — geraten wird hier nicht.""" + if not settings.LIVE_ALLOWED: + return _("gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)") + if not obj.live_enabled: + return _("aus") + if not obj.live_url: + return _("an, aber keine Adresse") + if not obj.active: + return _("an, aber Kamera stillgelegt") + return _("an") + + +@admin.register(Species) +class SpeciesAdmin(admin.ModelAdmin): + list_display = ("german", "scientific", "rarity", "xp", "size_cm") + list_filter = ("rarity",) + search_fields = ("german", "scientific", "english") + prepopulated_fields = {"slug": ("german",)} + + +@admin.register(Sighting) +class SightingAdmin(admin.ModelAdmin): + list_display = ( + "started_at", "camera", "species", "confirmed_species", "confidence_pct", "thumb", + ) + list_filter = ("camera", "species", "is_first_of_species") + date_hierarchy = "started_at" + readonly_fields = ("preview", "votes", "xp_awarded", "is_first_of_species") + # Handkorrektur ist der häufigste Grund, hier reinzuschauen — deshalb direkt + # aus der Liste heraus editierbar. + list_editable = ("confirmed_species",) + + @admin.display(description="Sicherheit", ordering="confidence") + def confidence_pct(self, obj: Sighting) -> str: + return f"{obj.confidence:.0%}" + + @admin.display(description="Bild") + def thumb(self, obj: Sighting): + if not obj.crop: + return "—" + return format_html('', obj.crop.url) + + @admin.display(description="Vorschau") + def preview(self, obj: Sighting): + if not obj.image: + return "—" + return format_html('', obj.image.url) + + +@admin.register(DexEntry) +class DexEntryAdmin(admin.ModelAdmin): + list_display = ("species", "count", "first_seen", "last_seen") + readonly_fields = ("species", "first_seen", "last_seen", "count", "best_sighting") + + +@admin.register(Badge) +class BadgeAdmin(admin.ModelAdmin): + list_display = ("emoji", "name", "code", "xp", "sort") + + +@admin.register(EarnedBadge) +class EarnedBadgeAdmin(admin.ModelAdmin): + list_display = ("badge", "earned_at") + + +@admin.register(Benachrichtigung) +class BenachrichtigungAdmin(admin.ModelAdmin): + """Ein einziger Datensatz — deshalb kein Hinzufügen und kein Löschen.""" + + fieldsets = ( + (None, {"fields": ("aktiv",)}), + ("Wohin", {"fields": ("server", "thema")}), + ("Was", {"fields": ("sichtungen", "stoerungen", "stille_minuten")}), + ) + actions = ["probe_senden"] + + def has_add_permission(self, request): + # Der Datensatz wird beim ersten Aufruf angelegt, siehe changelist_view. + return False + + def has_delete_permission(self, request, obj=None): + return False + + def changelist_view(self, request, extra_context=None): + # Sicherstellen, dass es den einen Eintrag gibt — sonst steht hier + # eine leere Liste und niemand weiss, wo man anfaengt. + Benachrichtigung.laden() + return super().changelist_view(request, extra_context) + + @admin.action(description="Probemeldung senden") + def probe_senden(self, request, queryset): + """⚠ Wartet bewusst auf die Antwort. Eine Probe, die still im + Hintergrund scheitert, taugt nichts — man will ja gerade wissen, + ob die Einstellungen stimmen.""" + geklappt, text = bn.melde_probe() + if geklappt: + self.message_user(request, f"Probemeldung: {text}", messages.SUCCESS) + else: + self.message_user(request, f"Probemeldung fehlgeschlagen: {text}", + messages.ERROR) diff --git a/web/dex/api.py b/web/dex/api.py new file mode 100644 index 0000000..31b0971 --- /dev/null +++ b/web/dex/api.py @@ -0,0 +1,614 @@ +"""Ingest-Endpunkt: der Detector meldet fertige Sichtungen hierher. + +Absichtlich ein simples multipart-POST statt DRF — es gibt genau eine +Schreiboperation, und die Web-App soll ohne zusätzliche Abhängigkeiten laufen. +""" + +from __future__ import annotations + +import hmac +import json +from datetime import datetime + +from django.conf import settings +from django.http import HttpRequest, JsonResponse +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST + +from . import benachrichtigung, scoring +from .bilder import KeinBild, saeubern +from .models import Camera, Sighting, Species + +# Marker für "Token stimmt, gehört aber zu keiner registrierten Kamera". +# Das ist der Einzelkamera-Betrieb über VOGELALBUM_INGEST_TOKEN aus den Settings. +LEGACY_CAMERA = object() + + +def _authenticate(request: HttpRequest): + """Gibt die Kamera zurück, ``LEGACY_CAMERA`` oder ``None`` bei Ablehnung. + + Jede Kamera hat ihren eigenen Token — damit ist klar, wer geliefert hat, + und ein kompromittierter Standort lässt sich einzeln stilllegen, ohne die + anderen anzufassen. + """ + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return None + token = header[7:] + + camera = Camera.by_token(token) + if camera is not None: + return camera + + # Konstantzeit-Vergleich: der Token ist ein Geheimnis, kein Benutzername. + # Ein leerer INGEST_TOKEN schaltet diesen Weg ab — dann geht nur noch über + # registrierte Kameras etwas rein. Ohne die Prüfung auf leer würde ein + # leerer Authorization-Header durchgehen. + if settings.INGEST_TOKEN and hmac.compare_digest(token, settings.INGEST_TOKEN): + return LEGACY_CAMERA + return None + + +@require_GET +def config(request: HttpRequest) -> JsonResponse: + """Was für die anfragende Kamera gilt: Schwellen, Lebensräume, Artenliste. + + Damit ist die Web-App die Wahrheit über die Erkennung, und nicht mehr die + Kommandozeile auf dem Rechner, auf dem der Detector läuft. Wer eine Kamera + umstellen will, braucht keine Shell mehr. + + Im Einzelkamera-Betrieb über ``VOGELALBUM_INGEST_TOKEN`` gibt es keine + Kamera-Zeile in der Datenbank. Dann gelten die Vorgaben des Modells — + sonst müsste man erst eine Kamera anlegen, um überhaupt etwas zu bekommen. + """ + auth = _authenticate(request) + if auth is None: + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + + kamera = auth if isinstance(auth, Camera) else Camera(slug="", habitats="garten") + + # Der Abruf selbst ist das Lebenszeichen — siehe Camera.letzter_kontakt. + if isinstance(auth, Camera) and auth.pk: + Camera.objects.filter(pk=auth.pk).update(letzter_kontakt=timezone.now()) + + return JsonResponse(kamera.konfiguration()) + + +def _box_uebernehmen(sighting, payload) -> None: + """Wo der Vogel im Bild sitzt, als vier relative Zahlen. + + 🔑 Damit ist der Zuschnitt eine Regel statt einer Datei: Die Anzeige + schneidet aus dem Original, und eine bessere Regel wirkt auf den ganzen + Bestand. Siehe ``dex/zuschnitt.py``. + + ⚠️ Nur vollständige und plausible Vierecke übernehmen. Ein halbes Viereck + ist keins, und eine vertauschte Kante ergäbe einen leeren Ausschnitt — + sichtbar würde das erst als graue Kachel, weit weg von hier. + """ + box = payload.get("box") + if not isinstance(box, (list, tuple)) or len(box) != 4: + return + try: + x0, y0, x1, y1 = (float(w) for w in box) + except (TypeError, ValueError): + return + if not (0.0 <= x0 < x1 <= 1.0 and 0.0 <= y0 < y1 <= 1.0): + return + sighting.box_x0, sighting.box_y0 = x0, y0 + sighting.box_x1, sighting.box_y1 = x1, y1 + + +def _parse_dt(value: str | None) -> datetime | None: + if not value: + return None + return parse_datetime(value) + + +@csrf_exempt +@require_POST +def ingest(request: HttpRequest) -> JsonResponse: + auth = _authenticate(request) + if auth is None: + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + camera = auth if isinstance(auth, Camera) else None + + raw = request.POST.get("data") + if not raw: + return JsonResponse({"fehler": "Feld 'data' fehlt"}, status=400) + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400) + + started = _parse_dt(payload.get("started_at")) + ended = _parse_dt(payload.get("ended_at")) + if started is None or ended is None: + return JsonResponse( + {"fehler": "started_at/ended_at fehlen oder sind unlesbar"}, status=400 + ) + + species = None + slug = payload.get("species") + if slug: + species = Species.objects.filter(slug=slug).first() + if species is None: + return JsonResponse( + {"fehler": f"unbekannte Art '{slug}' — Katalog nicht geseedet?"}, + status=400, + ) + + vogelwert = float(payload.get("mean_bird_score") or 0.0) + + # ⚠️ Ohne Vogel keine Sichtung. Die Grenze liegt am VOGELWERT, nicht an der + # Artsicherheit -- die beiden werden leicht verwechselt und bedeuten + # Verschiedenes: Artsicherheit 0 heißt "ich weiß nicht, welche Art", + # was bei einem echten Vogel vorkommt, den man später nachbestimmen + # will. Vogelwert 0 heißt "auf keinem einzigen Bild war ein Vogel". + # + # 🔑 Der Anlass: Am 27.09.2026 meldete die Vogeltränke abends 24 Besuche + # in vierzig Minuten -- Spiegelungen des Mondes auf dem Wasser, je ein + # Bild von 31 mit vermeintlicher Bewegung. Gezählt haben sie nie, aber + # sie standen in der Liste und brachten das Abzeichen "Nachtschicht" + # ein. Hier abzuweisen ist billiger, als die Folgen überall zu filtern. + # + # Kein Fehler für den Detector: er hat richtig gearbeitet und soll das + # nicht als Störung protokollieren. Deshalb 200 und nicht 400. + # + # ⚠️ Nur abweisen, wenn das Feld auch GESCHICKT wurde. Web-App und Detector + # sind getrennt deploybar (so ist dieses Projekt gebaut), die Fassungen + # laufen also auseinander: Web auf dem Server aktualisiert, Detector zu + # Hause noch alt. Eine Fassung, die den Wert gar nicht kennt, würde + # sonst ab dem Deploy stumm keine Sichtung mehr loswerden — ein Ausfall, + # der wie ein totes Vogelhaus aussieht und dessen Ursache auf einer + # anderen Maschine liegt. + # Je Kamera einstellbare Untergrenze. Ohne registrierte Kamera (Betrieb + # über den Sammel-Token) bleibt es beim harten Boden von 0. + grenze = camera.min_mean_bird_score if camera is not None else 0.0 + if "mean_bird_score" in payload and vogelwert <= max(grenze, 0.0): + return JsonResponse( + { + "ignoriert": "kein Vogel erkannt", + "mean_bird_score": vogelwert, + "grenze": grenze, + }, + status=200, + ) + + # Ort, falls die Quelle einen kennt — ein Handyfoto vom Spaziergang tut + # das, ein Vogelhaus nicht. + # + # ⚠️ Beide Werte nur zusammen übernehmen und nur im gültigen Bereich. Eine + # halbe Koordinate ist keine, und ein verrutschtes Vorzeichen oder eine + # 0/0-Vorgabe aus einem Formular landet sonst als Sichtung im Golf von + # Guinea, ohne dass irgendwo etwas auffällt. + breite = _koordinate(payload.get("latitude"), 90.0) + laenge = _koordinate(payload.get("longitude"), 180.0) + if breite is None or laenge is None: + breite = laenge = None + + sighting = Sighting( + species=species, + camera=camera, + confidence=float(payload.get("confidence") or 0.0), + started_at=started, + ended_at=ended, + frames_total=int(payload.get("frames_total") or 0), + frames_with_bird=int(payload.get("frames_with_bird") or 0), + votes=payload.get("votes") or {}, + mean_bird_score=vogelwert, + latitude=breite, + longitude=laenge, + ) + + # 🔑 Auch hier neu schreiben, obwohl der Detector vertrauenswürdig ist. + # Der Token EINER Kamera genügt, um Dateien unter der öffentlichen + # Medienadresse abzulegen — und ein kompromittierter Standort ist genau + # der Fall, für den die Token je Kamera getrennt sind. Siehe bilder.py. + _box_uebernehmen(sighting, payload) + + try: + if "image" in request.FILES: + sighting.image = saeubern(request.FILES["image"]) + if "crop" in request.FILES: + sighting.crop = saeubern(request.FILES["crop"]) + except KeinBild as fehler: + return JsonResponse({"fehler": str(fehler)}, status=400) + sighting.save() + + if camera is not None: + # 🔑 Beides, und das ist kein Doppel: ``last_seen`` heißt „zuletzt + # einen Vogel gesehen", ``letzter_kontakt`` heißt „die Kamera lebt". + # Eine eingelieferte Sichtung beweist beides. Bis zum 01.10.2026 stand + # hier nur ``last_seen``, und das Lebenszeichen kam ausschließlich vom + # Konfigurationsabruf — wer den abschaltet (``--konfig-alle 0``), hätte + # eine fleißig liefernde Kamera gehabt, die als still gilt. + jetzt = timezone.now() + camera.last_seen = jetzt + camera.letzter_kontakt = jetzt + camera.save(update_fields=["last_seen", "letzter_kontakt"]) + + reward = scoring.register(sighting) + + # Meldung erst NACH dem Speichern und Bewerten: Erst hier steht fest, + # ob es eine neue Art ist. Laeuft im Hintergrund und kann die Antwort + # an den Detector nicht aufhalten. + benachrichtigung.melde_sichtung(sighting, reward.is_first) + + return JsonResponse( + { + "id": sighting.id, + "kamera": camera.name if camera else None, + "art": species.german if species else None, + "xp": reward.xp, + "erstfang": reward.is_first, + "level": reward.level_after, + "level_up": reward.leveled_up, + "abzeichen": [b.name for b in reward.badges], + }, + status=201, + ) + + +# -------------------------------------------------------------------------- +# Lesen nach außen +# -------------------------------------------------------------------------- +# +# 🔑 Bis hierher konnte Vogelalbum nur ZUHÖREN: Kameras liefern ein, sonst +# passiert nichts. Für Home Assistant — und später eine HACS-Integration — +# braucht es den umgekehrten Weg. +# +# Eine einzige Antwort mit allem statt vieler kleiner Endpunkte. Wer alle +# zehn Sekunden fragt, soll nicht sechsmal fragen müssen, und die Brücke +# nach MQTT will ohnehin den ganzen Zustand auf einmal. + + +# 🔑 **Gerundet wird beim SPEICHERN, nicht beim Anzeigen.** Zwei Nachkommastellen +# sind rund ein Kilometer: genug für „am Waldrand bei X", zu grob für ein Haus. +# Eine Stelle weniger wären elf Kilometer und für die Frage, wo der Vogel war, +# wertlos. +# +# ⚠️ Der Unterschied ist nicht kosmetisch. Wer genau speichert und nur ungenau +# anzeigt, hat die Wohnadresse trotzdem in der Datenbank — und damit in jeder +# Sicherung, jedem Auszug und jeder späteren Auswertung, die von dieser +# Anzeigeregel nichts weiß. Was nie gespeichert wurde, kann auch nicht +# versehentlich herausfallen. +ORT_STELLEN = 2 + + +def _koordinate(wert, grenze: float) -> float | None: + """Eine gerundete Koordinate oder ``None``. Unbrauchbares wird verworfen. + + ⚠️ Auch die exakte 0 gilt als unbrauchbar. Das ist streng genommen ein + gültiger Ort im Atlantik — aber als Wert aus einer App ist es praktisch + immer ein nicht gefülltes Feld, und eine Sammlung voller Sichtungen vor + Westafrika fällt niemandem rechtzeitig auf. + """ + if wert is None: + return None + try: + zahl = float(wert) + except (TypeError, ValueError): + return None + if zahl == 0.0 or not -grenze <= zahl <= grenze: + return None + return round(zahl, ORT_STELLEN) + + +def _lesen_erlaubt(request: HttpRequest) -> bool: + """Eigener Token fürs Lesen, getrennt vom Einliefern. + + ⚠️ Leerer Token heißt ZU, nicht offen. Ohne diese Prüfung stünde die + ganze Sammlung im Netz, sobald jemand die Umgebungsvariable vergisst. + """ + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return False + if not settings.LESE_TOKEN: + return False + return hmac.compare_digest(header[7:], settings.LESE_TOKEN) + + +def _kamera_zustand(kamera: Camera, grenze_min: int, jetzt) -> dict: + still_seit = None + if kamera.letzter_kontakt is not None: + still_seit = round((jetzt - kamera.letzter_kontakt).total_seconds() / 60, 1) + + return { + "name": kamera.name, + "kennung": kamera.slug, + "standort": kamera.location, + "aktiv": kamera.active, + # 🔑 Die zwei Zeitpunkte bedeuten verschiedene Dinge und werden + # deshalb beide geliefert: 'letzter_kontakt' heißt "der Detector + # lebt", 'letzte_sichtung' heißt "zuletzt war ein Vogel da". An + # einem ruhigen Nachmittag ist das eine frisch und das andere alt. + "letzter_kontakt": (kamera.letzter_kontakt.isoformat() + if kamera.letzter_kontakt else None), + "still_seit_min": still_seit, + "online": still_seit is not None and still_seit < grenze_min, + "letzte_sichtung": (kamera.last_seen.isoformat() + if kamera.last_seen else None), + # ⚠️ Das Einzelbild 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 Erkennung steht. + "bild_url": kamera.live_bild_url if kamera.shows_live else None, + # 🔑 Die Adresse des GERÄTS, nicht des Detectors. Signalstärke, + # Temperatur und Bildrate kennt nur die Kamera selbst; wer sie + # haben will, muss sie dort abholen. Über die Datenbank zu gehen + # hieße, sie erst zu speichern — und dann wären sie alt. + "geraet_url": kamera.stream_url or None, + "erkennungsschwelle": kamera.detect_threshold, + "mindest_vogelwert": kamera.min_bird_score, + } + + +@require_GET +def zustand(request: HttpRequest) -> JsonResponse: + """Alles, was Vogelalbum über sich weiß — in einer Antwort. + + Was hier bewusst FEHLT: Signalstärke, Chiptemperatur und Bildrate der + Kameras. Die weiß nur das Gerät selbst, und es beantwortet sie unter + ``/status`` auf seinem Diagnose-Port. Sie hier durchzureichen hieße, + sie erst in die Datenbank zu schreiben — und dann wären sie alt. + """ + if not _lesen_erlaubt(request): + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + + from .models import Benachrichtigung, DexEntry, EarnedBadge + + jetzt = timezone.now() + grenze_min = Benachrichtigung.laden().stille_minuten + + fortschritt = scoring.progress() + heute = jetzt.astimezone().date() + + letzte = ( + Sighting.objects.select_related("species", "confirmed_species", "camera") + .exclude(species__isnull=True, confirmed_species__isnull=True) + .order_by("-started_at") + .first() + ) + letztes_abzeichen = ( + EarnedBadge.objects.select_related("badge") + .order_by("-earned_at") + .first() + ) + + def bild(feld): + return request.build_absolute_uri(feld.url) if feld else None + + return JsonResponse({ + "kameras": [ + _kamera_zustand(k, grenze_min, jetzt) + for k in Camera.objects.order_by("name") + ], + "sammlung": { + "arten_gesammelt": fortschritt.species_seen, + "arten_gesamt": fortschritt.species_total, + "sichtungen_gesamt": fortschritt.sightings, + # ⚠️ bestimmt(): sonst meldet Home Assistant Spiegelungen als Besuch. + "sichtungen_heute": Sighting.objects.bestimmt().filter( + started_at__date=heute).count(), + "abzeichen": fortschritt.badges, + "punkte": fortschritt.xp, + "stufe": fortschritt.level, + "rang": fortschritt.rank, + "fortschritt_stufe": round(fortschritt.level_progress, 3), + }, + "letzte_sichtung": ({ + "art": letzte.effective_species.german, + "art_wissenschaftlich": letzte.effective_species.scientific, + "kamera": letzte.camera.slug if letzte.camera else None, + "zeitpunkt": letzte.started_at.isoformat(), + "sicherheit": round(letzte.confidence, 3), + "neue_art": letzte.is_first_of_species, + "bild": bild(letzte.crop) or bild(letzte.image), + } if letzte else None), + "letztes_abzeichen": ({ + "name": letztes_abzeichen.badge.name, + "symbol": letztes_abzeichen.badge.emoji, + "zeitpunkt": letztes_abzeichen.earned_at.isoformat(), + } if letztes_abzeichen else None), + "stand": jetzt.isoformat(), + }) + + +# -------------------------------------------------------------------------- +# Fotos von unterwegs: hochladen, abholen, Ergebnis nachtragen +# -------------------------------------------------------------------------- +# +# 🔑 **Warum nicht über `ingest`.** Der Ingest nimmt eine FERTIGE Sichtung +# entgegen: Art, Vogelwert, Bildanzahl — alles schon bestimmt. Ein Foto vom +# Spaziergang hat nichts davon, es wartet ja gerade erst auf die Bestimmung. +# Beides in einen Endpunkt zu zwingen hieße, überall Sonderfälle zu prüfen; +# insbesondere würde die Regel „Vogelwert 0 wird abgewiesen" jedes frisch +# hochgeladene Foto verwerfen, weil es den Wert zwangsläufig noch nicht hat. +# +# 🔑 **Die Richtung bleibt, wie sie ist.** Der Detector holt sich die Arbeit +# ab, sie wird ihm nicht zugestellt. Damit braucht er weiterhin keine +# erreichbare Adresse und keinen offenen Port — dieselbe Mechanik wie bei +# `fernkonfig.py`, die ihn alle fünf Minuten nach seiner Konfiguration fragen +# lässt. + + +@csrf_exempt +@require_POST +def hochladen(request: HttpRequest) -> JsonResponse: + """Ein Foto zur Bestimmung annehmen. Von der App oder vom Formular. + + Es entsteht eine Sichtung mit ``wartet_auf_bestimmung``, ohne Art und ohne + Vogelwert. Den trägt der Detector nach, wenn er sie abgeholt hat. + """ + auth = _authenticate(request) + if auth is None: + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + camera = auth if isinstance(auth, Camera) else None + + bilder = request.FILES.getlist("bilder") or request.FILES.getlist("bild") + if not bilder: + return JsonResponse({"fehler": "kein Bild dabei"}, status=400) + + try: + angelegt = fotos_annehmen( + bilder, + camera=camera, + aufgenommen=_parse_dt(request.POST.get("aufgenommen_am")), + latitude=request.POST.get("latitude"), + longitude=request.POST.get("longitude"), + ) + except KeinBild as fehler: + return JsonResponse({"fehler": str(fehler)}, status=400) + return JsonResponse( + {"angelegt": angelegt, "wartend": Sighting.objects.wartend().count()}, + status=201, + ) + + +def fotos_annehmen(dateien, camera=None, aufgenommen=None, + latitude=None, longitude=None) -> list[int]: + """Legt je Bild eine wartende Sichtung an. Gibt die Kennungen zurück. + + 🔑 Eine Stelle für zwei Wege: die Schnittstelle für die App und das + Formular in der Web-App. Zwei Kopien dieser Logik würden auseinander + laufen, und zwar unbemerkt — der eine Weg legte dann Sichtungen an, die + der andere nicht mehr findet. + + ⚠️ Je Bild EINE Sichtung. Von einem Spaziergang kommen zehn Fotos mit + womöglich fünf Arten; eine Sichtung bleibt, was sie immer war — ein Vogel + zu einer Zeit an einem Ort. Alles andere brächte ``bestimmt()``, die + Abzeichen und den Erstfang durcheinander. + """ + aufgenommen = aufgenommen or timezone.now() + breite = _koordinate(latitude, 90.0) + laenge = _koordinate(longitude, 180.0) + if breite is None or laenge is None: + breite = laenge = None + + # 🔑 **Erst alle Bilder säubern, dann speichern.** Von einem Spaziergang + # kommen zehn Fotos auf einmal; wäre das achte unbrauchbar, stünden sonst + # sieben Sichtungen in der Datenbank und der Hochladende bekäme einen + # Fehler — er wüsste nicht, was angekommen ist, und lüde alles noch + # einmal hoch. Entweder alle oder keins. + sauber = [saeubern(d) for d in dateien] + + angelegt = [] + for datei in sauber: + s = Sighting( + camera=camera, + started_at=aufgenommen, + ended_at=aufgenommen, + # Ein Foto ist ein Bild — die Zählwerte des Dauerbetriebs haben + # hier keine Entsprechung und bleiben bei null, bis der Detector + # sein Ergebnis nachträgt. + frames_total=1, + wartet_auf_bestimmung=True, + latitude=breite, + longitude=laenge, + ) + s.image = datei + s.save() + angelegt.append(s.id) + + if camera is not None: + camera.last_seen = timezone.now() + camera.letzter_kontakt = timezone.now() + camera.save(update_fields=["last_seen", "letzter_kontakt"]) + return angelegt + + +@require_GET +def offen(request: HttpRequest) -> JsonResponse: + """Was der Detector noch zu bestimmen hat. Älteste zuerst. + + Geliefert werden Kennung und Bildadresse — das Bild selbst holt der + Detector über die normale Medienadresse, die ohnehin ausgeliefert wird. + """ + if not _lesen_erlaubt(request): + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + + try: + grenze = min(int(request.GET.get("anzahl", 20)), 100) + except (TypeError, ValueError): + grenze = 20 + + offene = Sighting.objects.wartend().select_related("camera")[:grenze] + return JsonResponse({ + "fotos": [ + { + "id": s.id, + "bild": request.build_absolute_uri(s.image.url) if s.image else None, + "aufgenommen_am": s.started_at.isoformat(), + "kamera": s.camera.slug if s.camera else None, + } + for s in offene + ], + "wartend_gesamt": Sighting.objects.wartend().count(), + }) + + +@csrf_exempt +@require_POST +def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse: + """Das Ergebnis des Detectors zu einem hochgeladenen Foto. + + ⚠️ Der Wartezustand fällt IMMER weg, auch wenn nichts erkannt wurde. Sonst + bliebe ein Foto ohne Vogel ewig in der Warteschlange, der Detector würde es + bei jedem Durchgang erneut bearbeiten, und der Wächter meldete einen + Stillstand, den es nicht gibt. + """ + auth = _authenticate(request) + if auth is None: + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + + sighting = Sighting.objects.filter( + id=sichtung_id, wartet_auf_bestimmung=True).first() + if sighting is None: + return JsonResponse( + {"fehler": "keine wartende Sichtung mit dieser Kennung"}, status=404) + + raw = request.POST.get("data") or "{}" + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400) + + species = None + slug = payload.get("species") + if slug: + species = Species.objects.filter(slug=slug).first() + if species is None: + return JsonResponse( + {"fehler": f"unbekannte Art '{slug}'"}, status=400) + + sighting.species = species + sighting.confidence = float(payload.get("confidence") or 0.0) + sighting.mean_bird_score = float(payload.get("mean_bird_score") or 0.0) + sighting.votes = payload.get("votes") or {} + # 🔑 **Ein Vogel ohne Art ist trotzdem ein Vogel.** Hier stand + # ``1 if species else 0`` — damit galt ein sicher erkannter Vogel, dessen + # Art sich nicht bestimmen ließ (Gegenlicht, Silhouette), als „nichts auf + # dem Bild". Die Unterscheidung ist genau die, auf der das ganze Modul + # beruht: Artsicherheit 0 heißt „welche Art?", Vogelwert 0 heißt „gar kein + # Vogel". Siehe die Begründung bei ``ingest``. + sighting.frames_with_bird = 1 if (species or sighting.mean_bird_score > 0) else 0 + _box_uebernehmen(sighting, payload) + sighting.wartet_auf_bestimmung = False + if "crop" in request.FILES: + sighting.crop = request.FILES["crop"] + sighting.save() + + # Punkte und Abzeichen erst jetzt — vorher war ja nicht bekannt, ob + # überhaupt ein Vogel auf dem Bild ist. + reward = scoring.register(sighting) if species else None + if reward is not None: + benachrichtigung.melde_sichtung(sighting, reward.is_first) + + return JsonResponse({ + "id": sighting.id, + "art": species.german if species else None, + "xp": reward.xp if reward else 0, + "erstfang": reward.is_first if reward else False, + "abzeichen": [b.name for b in reward.badges] if reward else [], + }) diff --git a/web/dex/app_api.py b/web/dex/app_api.py new file mode 100644 index 0000000..7912192 --- /dev/null +++ b/web/dex/app_api.py @@ -0,0 +1,579 @@ +"""Lese-Schnittstelle für die Handy-App. + +🔑 **Warum ein eigenes Modul und nicht mehr Endpunkte in ``api.py``.** Dort +steht der Verkehr mit den *Kameras*: einliefern, Konfiguration abholen, +Warteschlange abarbeiten. Das hier ist der umgekehrte Fall — ein Mensch +blättert in seinem Album. Die beiden haben verschiedene Leser, verschiedene +Antwortformen und ändern sich aus verschiedenen Gründen; sie in eine Datei zu +legen hieße, bei jeder App-Änderung den Kameraverkehr mit zu lesen. + +⚠️ **Es ist absichtlich keine neue Wahrheit.** Jede Zahl hier kommt aus +derselben Quelle wie die Website: ``scoring.progress()``, ``DexEntry``, +``Sighting``. Wer die Punktevergabe ändert, ändert beides zugleich — sonst +stünde auf dem Telefon Stufe 3 und im Browser Stufe 2, und niemand wüsste, +welche der beiden lügt. + +🔑 **Die Sprache kommt aus ``Accept-Language``.** Djangos LocaleMiddleware +steht schon in der Kette, also liefern die ``*_lokal``-Eigenschaften der +Modelle von selbst Deutsch oder Englisch. Die App muss nichts übersetzen und +wir pflegen die Texte nicht zweimal. +""" + +from __future__ import annotations + +import hmac + +from django.conf import settings +from django.db.models import Q +from django.http import HttpRequest, JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_GET + +from . import scoring +from .models import Badge, Camera, DexEntry, Sighting, Species +from .species_meta import HABITATS + +# Höchstens so viele Sichtungen je Seite. Ein Feed auf dem Telefon lädt +# nach; eine Antwort mit allen 300 Sichtungen und ihren Bildadressen wäre +# auf einer Mobilfunkverbindung die längste Wartezeit der ganzen App. +SEITE_MAX = 60 +SEITE_VORGABE = 24 + + +# -------------------------------------------------------------------------- +# Zugang +# -------------------------------------------------------------------------- +# +# 🔑 **Die App kommt mit EINEM Token aus.** Sie muss zwei Dinge können: +# Fotos einliefern (braucht einen Kamera-Token, siehe ``api.hochladen``) und +# das Album lesen. Hätte das Lesen einen eigenen Token, müsste man beim +# Einrichten zwei Geheimnisse abtippen und wir hätten einen Fehlerfall mehr +# ("hochladen geht, Album bleibt leer"). Deshalb gilt hier **beides**: der +# Lese-Token wie bei Home Assistant, oder der Token der eigenen Kamera. +# +# ⚠️ Dass ein Kamera-Token hier LESEN darf, ist kein Dammbruch: Das Album +# steht ohnehin öffentlich im Netz — ``/sammlung/``, ``/sichtungen/`` und +# ``/abzeichen/`` sind ohne Anmeldung erreichbar. Diese Schnittstelle gibt +# exakt dieselben Angaben in anderer Verpackung heraus und **nichts darüber +# hinaus**: insbesondere keine Koordinaten (die zeigt die Website nicht), kein +# Live-Bild und keine Kamera-Token. + + +def _zugang(request: HttpRequest) -> bool: + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return False + token = header[7:] + + if Camera.by_token(token) is not None: + return True + + # Konstantzeit-Vergleich, und leer heißt ZU — dieselbe Regel wie in + # ``api._lesen_erlaubt``. Ein vergessenes Geheimnis darf nicht die Tür + # aufmachen, sondern muss sie zuhalten. + if settings.LESE_TOKEN: + return hmac.compare_digest(token, settings.LESE_TOKEN) + return False + + +def _abgelehnt() -> JsonResponse: + return JsonResponse({"fehler": "nicht autorisiert"}, status=401) + + +# -------------------------------------------------------------------------- +# Bausteine, die mehrere Endpunkte brauchen +# -------------------------------------------------------------------------- + + +def _bild(request: HttpRequest, feld) -> str | None: + """Absolute Adresse eines Bildfelds, oder ``None``. + + ⚠️ Absolut, nicht relativ. Eine App hat keine Basisadresse, von der aus + ``/media/…`` etwas bedeuten würde — sie kennt nur die URL, die ihr der + Nutzer eingetragen hat, und die zeigt womöglich auf einen anderen Host als + die Medien. + """ + if not feld: + return None + return request.build_absolute_uri(feld.url) + + +def _art_kurz(request: HttpRequest, art: Species, eintrag: DexEntry | None) -> dict: + """Eine Albumseite, wie die App sie im Raster braucht. + + ``gefunden=False`` heißt Schatten: Name und Nummer bleiben sichtbar, das + Bild nicht — genau wie im Klebebildchen-Album, wo die leere Stelle die + Umrisse zeigt. Die App soll diese Entscheidung nicht selbst treffen + müssen, deshalb steht sie hier als Feld. + """ + daten = { + "slug": art.slug, + "nummer": art.number, + "nummer_text": art.dex_nr, + "name": art.name, + "zweitname": art.zweitname, + "wissenschaftlich": art.scientific, + "seltenheit": art.rarity, + "seltenheit_label": art.rarity_label, + "seltenheit_farbe": art.rarity_color, + "typ": art.typ, + "typ_label": art.typ_label, + "typ_farbe": art.typ_color, + "typ_schrift": art.typ_ink, + "lebensraum": art.lebensraum_typ, + "punkte": art.xp, + "gefunden": eintrag is not None, + # Das Referenzbild gehört zum Katalog und ist auch bei ungefundenen + # Arten da. Die App zeigt es erst nach dem Fang — aber die + # Entscheidung trifft sie, nicht wir: fürs Nachschlagen ("wie sieht + # ein Gartenrotschwanz aus?") braucht sie es vorher. + "referenzbild": _bild(request, art.reference_image), + } + if eintrag is not None: + daten |= { + "anzahl": eintrag.count, + "erstmals": eintrag.first_seen.isoformat(), + "zuletzt": eintrag.last_seen.isoformat(), + "neu": eintrag.is_new, + "foto": ( + request.build_absolute_uri(eintrag.best_sighting.kachel_url) + if eintrag.best_sighting and eintrag.best_sighting.kachel_url + else None + ), + } + return daten + + +def _sichtung_kurz(request: HttpRequest, s: Sighting) -> dict: + """Eine Zeile im Feed. + + ⚠️ ``effective_species`` statt ``species``: Eine Handbestimmung im Admin + gewinnt gegen das Modell. Wer hier ``species`` nähme, zeigte in der App + weiter die falsche Art, die längst korrigiert wurde — und zwar nur + in der App, was die Fehlersuche teuer macht. + """ + art = s.effective_species + return { + "id": s.id, + "zeitpunkt": s.started_at.isoformat(), + "dauer_s": round(s.duration_s, 1), + "wartet": s.wartet_auf_bestimmung, + "art": ( + { + "slug": art.slug, + "name": art.name, + "wissenschaftlich": art.scientific, + "seltenheit": art.rarity, + "seltenheit_farbe": art.rarity_color, + "typ_farbe": art.typ_color, + # 🔑 Das Referenzbild gehört an die Sichtung, obwohl es zur Art + # gehört: Nicht jede Sichtung hat ein eigenes Foto (der + # Detector verwirft schlechte Bilder, und ältere Einträge haben + # gar keins). Ohne diesen Rückfall steht in der App ein + # Fragezeichen neben einer Art, die sie kennt und abbilden + # könnte — gefunden am gerenderten Feed, nicht im Test. + "referenzbild": _bild(request, art.reference_image), + } + if art + else None + ), + "sicherheit": round(s.confidence, 3), + "vogelwert": round(s.mean_bird_score, 3), + "handbestimmt": s.confirmed_species_id is not None, + "neue_art": s.is_first_of_species, + "punkte": s.xp_awarded, + "kamera": ( + {"kennung": s.camera.slug, "name": s.camera.name, "mobil": s.camera.mobil} + if s.camera + else None + ), + "foto": _bild(request, s.image), + # 🔑 Der Ausschnitt wird aus dem Original gerechnet, nicht als + # zweites Bild gespeichert — siehe dex/zuschnitt.py. Damit schneidet + # eine geänderte Regel sofort den ganzen Bestand neu, und die App + # bekommt dieselbe Kachel wie die Website. + "ausschnitt": (request.build_absolute_uri(s.kachel_url) + if s.kachel_url else None), + } + + +def _fortschritt() -> dict: + """Punkte, Stufe, Rang — die Kopfzeile jeder App-Ansicht.""" + p = scoring.progress() + return { + "punkte": p.xp, + "stufe": p.level, + "rang": p.rank, + "punkte_stufe_start": p.xp_this_level, + "punkte_stufe_ziel": p.xp_next_level, + "punkte_bis_aufstieg": p.xp_to_next, + "anteil_stufe": round(p.level_progress, 3), + "arten_gesammelt": p.species_seen, + "arten_gesamt": p.species_total, + "sichtungen": p.sightings, + "abzeichen": p.badges, + } + + +# -------------------------------------------------------------------------- +# Endpunkte +# -------------------------------------------------------------------------- + + +@require_GET +def start(request: HttpRequest) -> JsonResponse: + """Der Startbildschirm in einer Antwort. + + 🔑 Bewusst ein Sammelabruf statt vier einzelner. Beim Öffnen der App + wartet ein Mensch — vier Anfragen hintereinander über Mobilfunk sind + viermal die Latenz, und drei davon zeigen Dinge, die ohnehin zusammen auf + einen Bildschirm gehören. + """ + if not _zugang(request): + return _abgelehnt() + + jetzt = timezone.now() + heute = jetzt.astimezone().date() + + letzte = ( + Sighting.objects.bestimmt() + .select_related("species", "confirmed_species", "camera") + .order_by("-started_at") + .first() + ) + + neueste = ( + DexEntry.objects.select_related("species", "best_sighting") + .order_by("-first_seen")[:6] + ) + + return JsonResponse( + { + "fortschritt": _fortschritt(), + "sichtungen_heute": Sighting.objects.bestimmt() + .filter(started_at__date=heute) + .count(), + "letzte_sichtung": _sichtung_kurz(request, letzte) if letzte else None, + # Die letzten Erstfänge — das ist der Teil, den man jemandem + # zeigt. Eine Liste aller Sichtungen beantwortet "war was los", + # die Erstfänge beantworten "ist die Sammlung gewachsen". + "neueste_arten": [ + _art_kurz(request, e.species, e) for e in neueste + ], + "wartende_fotos": Sighting.objects.wartend().count(), + "kameras": [ + { + "kennung": k.slug, + "name": k.name, + "standort": k.location, + "mobil": k.mobil, + "aktiv": k.active, + "still": k.is_stale, + "letzte_sichtung": ( + k.last_seen.isoformat() if k.last_seen else None + ), + } + for k in Camera.objects.order_by("name") + ], + "stand": jetzt.isoformat(), + } + ) + + +@require_GET +def album(request: HttpRequest) -> JsonResponse: + """Das ganze Album eines Lebensraums, gefundene und fehlende. + + ⚠️ **Vorgabe ``garten``, wie auf der Website.** 148 Arten auf einmal sind + kein Ziel, sondern eine Wand; ``?lebensraum=alle`` holt sie trotzdem, wenn + die App einen Nachschlage-Modus anbietet. + """ + if not _zugang(request): + return _abgelehnt() + + lebensraum = request.GET.get("lebensraum", "garten") + if lebensraum == "alle": + lebensraum = "" + + arten = Species.objects.all() + if lebensraum: + arten = arten.filter(lebensraum_typ=lebensraum) + + eintraege = { + e.species_id: e + for e in DexEntry.objects.select_related("best_sighting") + } + + seiten = [_art_kurz(request, a, eintraege.get(a.id)) for a in arten] + # Nach Albumnummer, nicht nach Fundstatus. Die Nummer ist die Seitenzahl: + # Sie bleibt stehen, wo sie ist, auch wenn die Lücke daneben sich füllt. + seiten.sort(key=lambda s: s["nummer"] or 9999) + + return JsonResponse( + { + "arten": seiten, + "gefunden": sum(1 for s in seiten if s["gefunden"]), + "gesamt": len(seiten), + "lebensraum": lebensraum, + "lebensraeume": _lebensraeume(), + } + ) + + +def _lebensraeume() -> list[dict]: + """Die Reiter über dem Album — nur die, in denen Arten stehen.""" + from django.utils.translation import get_language + + englisch = (get_language() or "de").startswith("en") + raus = [] + for code, paar in HABITATS.items(): + anzahl = Species.objects.filter(lebensraum_typ=code).count() + if not anzahl: + continue + gefunden = DexEntry.objects.filter(species__lebensraum_typ=code).count() + raus.append( + { + "code": code, + "label": paar[1] if englisch else paar[0], + "gesamt": anzahl, + "gefunden": gefunden, + } + ) + return raus + + +@require_GET +def art(request: HttpRequest, slug: str) -> JsonResponse: + """Eine Albumseite in voller Länge: Steckbrief, Besuche, Tagesverlauf.""" + if not _zugang(request): + return _abgelehnt() + + species = Species.objects.filter(slug=slug).first() + if species is None: + return JsonResponse({"fehler": f"unbekannte Art '{slug}'"}, status=404) + + eintrag = ( + DexEntry.objects.filter(species=species) + .select_related("best_sighting") + .first() + ) + + passend = Q(species=species) | Q(confirmed_species=species) + besuche = ( + Sighting.objects.filter(passend) + .select_related("species", "confirmed_species", "camera") + .order_by("-started_at")[:24] + ) + + # Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche, + # in Ortszeit — eine Sichtung um 6:31 gehört in die 6-Uhr-Säule, auch + # wenn sie in der Datenbank als 04:31 UTC steht. + stunden = [0] * 24 + for dt in Sighting.objects.filter(passend).values_list("started_at", flat=True): + stunden[timezone.localtime(dt).hour] += 1 + spitze = max(stunden) or 1 + + aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or [])) + reihenfolge = species.verwechslung or [] + aehnliche.sort(key=lambda s: reihenfolge.index(s.slug)) + + vorher = ( + Species.objects.filter(number__lt=species.number).order_by("-number").first() + ) + nachher = ( + Species.objects.filter(number__gt=species.number).order_by("number").first() + ) + + return JsonResponse( + { + "art": _art_kurz(request, species, eintrag) + | { + "kategorie": species.kategorie_lokal, + "beschreibung": species.absaetze, + "kurzfakt": species.fact, + "groesse_cm": species.size_cm, + "gewicht_g": species.gewicht_g, + "spannweite_cm": species.spannweite_cm, + "zug": species.zug_label, + "nahrung": species.nahrung_lokal, + "lebensraum_text": species.lebensraum_lokal, + "stimme": species.stimme_lokal, + "lebensraum_label": species.habitat_label, + "bildnachweis": { + "urheber": species.reference_credit, + "lizenz": species.reference_license, + "quelle": species.reference_source, + }, + # Fertig gerechnete Balken, wie auf der Website. Die + # Bezugsgrößen stecken in ``Species.balken()``; sie in der App + # nachzubauen hieße, zwei Wahrheiten über denselben Katalog zu + # haben. + "balken": species.balken(), + }, + "besuche": [_sichtung_kurz(request, s) for s in besuche], + "tagesverlauf": [ + {"stunde": h, "anzahl": n, "anteil": round(n / spitze, 3)} + for h, n in enumerate(stunden) + ], + "verwechslung": [ + _art_kurz(request, s, None) for s in aehnliche + ], + "blaettern": { + "vorher": vorher.slug if vorher else None, + "nachher": nachher.slug if nachher else None, + }, + } + ) + + +@require_GET +def sichtungen(request: HttpRequest) -> JsonResponse: + """Der Feed. Jüngste zuerst, seitenweise. + + ``?nur_bestimmt=1`` blendet aus, was keine Art hat — das ist die Ansicht + für jemanden, der *sammelt*; die vollständige Liste ist die für jemanden, + der *prüft*. + """ + if not _zugang(request): + return _abgelehnt() + + qs = Sighting.objects.select_related( + "species", "confirmed_species", "camera" + ).order_by("-started_at") + + if request.GET.get("nur_bestimmt"): + qs = qs.bestimmt() + + kamera = request.GET.get("kamera", "") + if kamera: + qs = qs.filter(camera__slug=kamera) + + art_slug = request.GET.get("art", "") + if art_slug: + qs = qs.filter(Q(species__slug=art_slug) | Q(confirmed_species__slug=art_slug)) + + seite = _ganzzahl(request.GET.get("seite"), 1, mindestens=1) + pro_seite = _ganzzahl(request.GET.get("anzahl"), SEITE_VORGABE, mindestens=1) + pro_seite = min(pro_seite, SEITE_MAX) + + gesamt = qs.count() + ab = (seite - 1) * pro_seite + + return JsonResponse( + { + "sichtungen": [ + _sichtung_kurz(request, s) for s in qs[ab : ab + pro_seite] + ], + "seite": seite, + "pro_seite": pro_seite, + "gesamt": gesamt, + "weiter": ab + pro_seite < gesamt, + } + ) + + +def _ganzzahl(wert, vorgabe: int, mindestens: int = 0) -> int: + try: + return max(mindestens, int(wert)) + except (TypeError, ValueError): + return vorgabe + + +@require_GET +def sichtung(request: HttpRequest, sichtung_id: int) -> JsonResponse: + """Eine einzelne Sichtung. + + 🔑 **Das ist der Endpunkt, mit dem die App nach einem Upload nachfragt.** + ``api.hochladen`` legt eine wartende Sichtung an und gibt ihre Kennung + zurück; bestimmt wird sie erst, wenn der Detector sie abgeholt hat. Die + App fragt also hier nach, bis ``wartet`` auf ``false`` springt, und zeigt + dann das Ergebnis. + + ⚠️ ``wartet: false`` **und** ``art: null`` ist kein Fehler, sondern die + Antwort "auf dem Foto war kein Vogel". Die App muss beide Fälle + unterscheiden können, sonst dreht sie für ein Foto vom eigenen Daumen + endlos die Warteschleife. + """ + if not _zugang(request): + return _abgelehnt() + + s = ( + Sighting.objects.select_related("species", "confirmed_species", "camera") + .filter(id=sichtung_id) + .first() + ) + if s is None: + return JsonResponse({"fehler": "keine Sichtung mit dieser Kennung"}, status=404) + + daten = _sichtung_kurz(request, s) + art = s.effective_species + if art is not None: + eintrag = DexEntry.objects.filter(species=art).first() + daten["album"] = _art_kurz(request, art, eintrag) + # Nach einem Erstfang will die App die neue Punktzahl zeigen, ohne noch + # einmal den Startbildschirm zu holen. + daten["fortschritt"] = _fortschritt() + return JsonResponse(daten) + + +@require_GET +def abzeichen(request: HttpRequest) -> JsonResponse: + """Alle Abzeichen mit Fortschritt — verdiente und offene. + + 🔑 Nur die *zählbaren* Abzeichen haben einen Balken. "Ein Specht war da" + passiert oder passiert nicht; ein Balken auf 0 % wäre dort keine + Information, sondern nur entmutigend. Dieselbe Regel wie auf der Website, + und sie kommt aus derselben Funktion. + """ + if not _zugang(request): + return _abgelehnt() + + from .templatetags.dex_extras import abzeichen_bild + + verdient = { + b.code: b.earned + for b in Badge.objects.filter(earned__isnull=False).select_related("earned") + } + stand = scoring.fortschritt_je_abzeichen() + + zeilen = [] + for badge in Badge.objects.all(): + erworben = verdient.get(badge.code) + # 🔑 **Der Pin, nicht das Emoji.** Die Website zeigt die gezeichneten + # Emaille-Pins aus `static/img/pins/`; eine App mit Emoji daneben wäre + # eine zweite Gestaltung derselben Sache. Dazu kommt der Grund, aus + # dem die Pins überhaupt entstanden sind: Auf einem System ohne + # Emoji-Schrift steht statt des Abzeichens ein leeres Rechteck — und + # zwar ausgerechnet auf der Seite, die Belohnungen zeigt. + # + # ⚠️ Das Emoji bleibt trotzdem im Feld `symbol`: Ein später + # hinzugefügtes Abzeichen hat noch keinen Pin, und dann ist das Emoji + # besser als eine Lücke. Die App entscheidet, was sie nimmt. + pin = abzeichen_bild(badge.code) + zeile = { + "code": badge.code, + "name": badge.name_lokal, + "beschreibung": badge.description_lokal, + "symbol": badge.emoji, + "bild": request.build_absolute_uri(pin) if pin else None, + "punkte": badge.xp, + "verdient": erworben is not None, + "am": erworben.earned_at.isoformat() if erworben else None, + } + messbar = stand.get(badge.code) + if messbar and erworben is None: + ist, ziel = messbar + zeile |= { + "ist": ist, + "ziel": ziel, + "anteil": round(ist / ziel, 3) if ziel else 0.0, + } + zeilen.append(zeile) + + return JsonResponse( + { + "abzeichen": zeilen, + "verdient": sum(1 for z in zeilen if z["verdient"]), + "gesamt": len(zeilen), + } + ) diff --git a/web/dex/apps.py b/web/dex/apps.py new file mode 100644 index 0000000..f5da4ac --- /dev/null +++ b/web/dex/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DexConfig(AppConfig): + name = 'dex' diff --git a/web/dex/benachrichtigung.py b/web/dex/benachrichtigung.py new file mode 100644 index 0000000..ebf31fc --- /dev/null +++ b/web/dex/benachrichtigung.py @@ -0,0 +1,129 @@ +"""Meldungen über ntfy — Sichtungen und Störungen. + +Was hier bewusst NICHT passiert: die Anwendung aufhalten. Eine Meldung ist +eine Nebensache; wenn der ntfy-Server nicht antwortet, darf das weder die +Antwort an den Detector verzögern noch einen Fehler auslösen. Eine verpasste +Meldung ist ärgerlich, eine verlorene Sichtung wäre schlimmer. +""" + +from __future__ import annotations + +import logging +import threading +import urllib.error +import urllib.request + +from .models import Benachrichtigung + +log = logging.getLogger(__name__) + +ZEITGRENZE = 8 # Sekunden + + +def _senden(ziel: str, titel: str, text: str, prioritaet: str, symbole: str): + """Schickt die Meldung. Läuft im eigenen Faden, siehe _im_hintergrund.""" + try: + anfrage = urllib.request.Request( + ziel, + data=text.encode("utf-8"), + headers={ + "Title": titel.encode("utf-8"), + "Priority": prioritaet, + "Tags": symbole, + }, + method="POST", + ) + with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE): + pass + except (urllib.error.URLError, OSError, ValueError) as fehler: + # Nur protokollieren. Wer eine Meldung nicht zustellen kann, soll + # deshalb nicht den Betrieb anhalten. + log.warning("ntfy nicht erreicht: %s", fehler) + + +def _im_hintergrund(*args): + threading.Thread(target=_senden, args=args, daemon=True).start() + + +def melde_sichtung(sighting, ist_neue_art: bool) -> None: + """Meldet eine Sichtung, je nach Einstellung im Backend.""" + e = Benachrichtigung.laden() + if not e.aktiv or not e.ziel: + return + + if e.sichtungen == Benachrichtigung.Sichtungen.AUS: + return + if e.sichtungen == Benachrichtigung.Sichtungen.NEUE and not ist_neue_art: + return + + art = sighting.species.german if sighting.species else "Unbestimmt" + kamera = sighting.camera.name if sighting.camera else "unbekannte Kamera" + sicherheit = round(sighting.confidence * 100) + + if ist_neue_art: + titel = f"Neue Art: {art}" + text = f"{kamera} · {sicherheit} % · zum ersten Mal gesehen" + symbole = "sparkles" + prioritaet = "default" + else: + titel = art + text = f"{kamera} · {sicherheit} %" + symbole = "bird" + # Leise: Wer alle Sichtungen meldet, will nicht bei jedem Spatz + # ein Summen am Handy. + prioritaet = "low" + + _im_hintergrund(e.ziel, titel, text, prioritaet, symbole) + + +def melde_stoerung(titel: str, text: str) -> None: + """Meldet eine Störung — laut, weil sie Handeln erfordert. + + 🔑 **Synchron, im Gegensatz zu den Sichtungen.** Und das ist keine + Unachtsamkeit, sondern der Grund, warum diese Meldung am 24.09.2026 + nie ankam: + + Störungen meldet ausschließlich der Befehl ``stoerungen_pruefen``, und + der ist ein Einmal-Lauf. Ein Hintergrundfaden ist dort ein + Daemon-Faden — der stirbt, sobald der Prozess endet. Der Befehl war + mit seiner Arbeit fertig, bevor die Meldung draußen war, und hat sie + damit mitgenommen. + + ⚠️ Bei ``melde_sichtung`` ist der Hintergrundfaden richtig: Die läuft + in einer Web-Anfrage, die weiterlebt, und darf den Detector nicht + warten lassen. Hier gibt es nichts, was wartet -- das Senden IST die + Arbeit. + """ + e = Benachrichtigung.laden() + if not e.aktiv or not e.stoerungen or not e.ziel: + return + _senden(e.ziel, titel, text, "high", "rotating_light") + + +def melde_probe() -> tuple[bool, str]: + """Sofort und synchron — für den Knopf im Backend. + + Hier wird ausnahmsweise gewartet: Wer auf 'Probe senden' drückt, will + wissen, ob es geklappt hat. Eine Meldung, die still im Hintergrund + scheitert, wäre genau das Gegenteil einer Probe. + """ + e = Benachrichtigung.laden() + if not e.ziel: + return False, "Kein Thema eingetragen." + try: + anfrage = urllib.request.Request( + e.ziel, + data="Wenn du das liest, funktioniert es.".encode("utf-8"), + headers={ + "Title": "Vogelalbum Probe".encode("utf-8"), + "Priority": "default", + "Tags": "white_check_mark", + }, + method="POST", + ) + with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort: + return True, f"Gesendet (HTTP {antwort.status})." + except urllib.error.HTTPError as fehler: + return False, f"Server antwortet mit {fehler.code}." + except (urllib.error.URLError, OSError, ValueError) as fehler: + return False, f"Nicht erreicht: {fehler}" diff --git a/web/dex/bilder.py b/web/dex/bilder.py new file mode 100644 index 0000000..feeff62 --- /dev/null +++ b/web/dex/bilder.py @@ -0,0 +1,151 @@ +"""Hochgeladene Bilder prüfen und sauber neu schreiben. + +🔴 **Warum es diese Datei gibt.** Bis zum 14.10.2026 wanderte eine hochgeladene +Datei unverändert aus ``request.FILES`` in ein ``ImageField`` und von dort auf +das Volume, das ``/media/`` **ohne Anmeldung** ausliefert. Zwei Folgen, beide +unsichtbar, bis es zu spät ist: + +1. ⚠️ **Metadaten.** Ein Handyfoto trägt EXIF, und darin steht der Ort. Bei + einer Kamera am Vogelhaus ist das folgenlos — die schreibt kein EXIF, an + einem echten Bild der Live-Instanz nachgesehen. Mit der Handy-App wird es + zum Garten, in dem ein Kind steht, öffentlich abrufbar. +2. ⚠️ **Was überhaupt gespeichert wird.** Django prüft bei einem ``ImageField`` + nur, dass Pillow die Datei öffnen *kann*. Eine Datei kann gleichzeitig ein + gültiges Bild und etwas anderes sein; unter ``/media/`` liegt sie dann auf + derselben Herkunft wie das Backend. + +🔑 **Deshalb nicht prüfen, sondern NEU SCHREIBEN.** Eine Prüfung sagt „sieht +gültig aus" und lässt alles stehen, was sie nicht kennt. Wer die Pixel liest +und neu kodiert, gibt nur weiter, was wirklich ein Bild war — Metadaten, +angehängte Daten und alles Unverstandene fallen dabei von selbst weg. Das ist +der Unterschied zwischen einem Filter und einem Flaschenhals. +""" + +from __future__ import annotations + +import io + +from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile +from PIL import Image, ImageOps, UnidentifiedImageError + +# 🔴 **Ohne das kann Pillow kein HEIC — und iPhones fotografieren HEIC.** +# +# Am 14.10.2026 wies eine Instanz jedes Foto vom iPhone mit 400 ab, +# Minuten nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur +# kannte sie das Format nicht, das die Hälfte aller Telefone erzeugt. Eine +# App für iOS, die kein HEIC annimmt, nimmt nichts an. +# +# ⚠️ Der Import darf das Hochladen nicht zum Absturz bringen, wenn das Paket +# in einer schlanken Umgebung fehlt — dann bleibt es bei JPEG und PNG, und die +# Meldung an den Nutzer sagt, welches Format es war. +try: # pragma: no cover - haengt an der Umgebung, nicht am Code + import pillow_heif + + pillow_heif.register_heif_opener() + HEIC_LESBAR = True +except ImportError: # pragma: no cover + HEIC_LESBAR = False + +# ⚠️ Pillow öffnet sehr große Bilder bereitwillig und belegt dabei Speicher in +# der Größenordnung Breite × Höhe × 4 Bytes. Ein Bild mit absurden Maßen in +# einer winzigen Datei ist der billigste Weg, einen Dienst umzuwerfen +# ("decompression bomb"). Pillow warnt ab ~89 Megapixeln; wir lehnen vorher ab. +MAX_PIXEL = 50_000_000 + +# Längere Kante nach dem Verkleinern. 2400 px ist mehr, als die Sammlung je +# anzeigt, und klein genug, dass ein Album nicht die Platte füllt. +MAX_KANTE = 2400 + +# Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein +# SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF. +ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "AVIF", "MPO"} + + +class KeinBild(Exception): + """Die Datei ist kein Bild, das wir annehmen.""" + + +def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFile: + """Liest die Pixel und gibt ein frisches JPEG ohne Metadaten zurück. + + Wirft ``KeinBild``, wenn die Datei kein brauchbares Bild ist. + + 🔑 **Die Drehung wird angewandt, nicht mitgeschleppt.** Ein Handyfoto steht + oft nur deshalb richtig, weil im EXIF ein Drehwinkel steht. Wer die + Metadaten wegwirft, ohne das Bild zu drehen, legt jedes Hochkantfoto auf + die Seite — und zwar erst beim Anzeigen, also weit entfernt von der + Ursache. + """ + datei.seek(0) + try: + bild = Image.open(datei) + # ⚠️ ``verify()`` prüft die Struktur, macht das Objekt danach aber + # unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten. + bild.verify() + except (UnidentifiedImageError, OSError, ValueError) as fehler: + # ⚠️ Den Hinweis auf HEIC mitgeben: Ohne ihn sucht man den Fehler beim + # Telefon, und er liegt an einem fehlenden Paket auf dem Server. + hinweis = "" if HEIC_LESBAR else " (HEIC wird hier nicht unterstützt — pillow-heif fehlt)" + raise KeinBild(f"keine lesbare Bilddatei{hinweis}: {fehler}") from fehler + + datei.seek(0) + try: + bild = Image.open(datei) + if bild.format not in ERLAUBT: + raise KeinBild(f"Format {bild.format!r} nehmen wir nicht an") + + breite, hoehe = bild.size + if breite * hoehe > MAX_PIXEL: + raise KeinBild(f"Bild zu groß: {breite}x{hoehe} Pixel") + + # Erst drehen (liest EXIF), dann alles Weitere — danach sind die + # Metadaten entbehrlich. + bild = ImageOps.exif_transpose(bild) + + # Transparenz und Palettenbilder auf RGB bringen, sonst scheitert das + # Schreiben als JPEG an Bildern mit Alphakanal. + if bild.mode not in ("RGB", "L"): + if bild.mode in ("RGBA", "LA", "P"): + grund = Image.new("RGB", bild.size, (255, 255, 255)) + bild = bild.convert("RGBA") + grund.paste(bild, mask=bild.split()[-1]) + bild = grund + else: + bild = bild.convert("RGB") + + bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS) + + ziel = io.BytesIO() + # Ohne ``exif=``/``icc_profile=`` schreibt Pillow keine Metadaten mit. + # Das ist die Stelle, an der der Ort verschwindet. + bild.save(ziel, format="JPEG", quality=88, optimize=True) + except KeinBild: + raise + except (OSError, ValueError) as fehler: + raise KeinBild(f"Bild nicht verarbeitbar: {fehler}") from fehler + finally: + bild.close() if hasattr(bild, "close") else None + + groesse = ziel.tell() + ziel.seek(0) + return InMemoryUploadedFile( + ziel, + field_name=getattr(datei, "field_name", None), + name=_jpg_name(name or datei.name or "bild"), + content_type="image/jpeg", + size=groesse, + charset=None, + ) + + +def _jpg_name(name: str) -> str: + """Dateiname mit ``.jpg``, ohne Pfadanteile. + + ⚠️ Nur den letzten Namensteil nehmen: Ein hochgeladener Name darf keine + Verzeichnisse mitbringen. Django bereinigt das beim Speichern ebenfalls — + sich darauf zu verlassen heißt aber, dass diese Funktion außerhalb von + Django unsicher wäre, und sie ist einzeln testbar. + """ + letzter = name.replace("\\", "/").rsplit("/", 1)[-1] + stamm = letzter.rsplit(".", 1)[0] or "bild" + return f"{stamm[:60]}.jpg" diff --git a/web/dex/management/__init__.py b/web/dex/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dex/management/commands/__init__.py b/web/dex/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dex/management/commands/camera.py b/web/dex/management/commands/camera.py new file mode 100644 index 0000000..5712552 --- /dev/null +++ b/web/dex/management/commands/camera.py @@ -0,0 +1,128 @@ +"""Kameras anlegen, auflisten, Token erneuern. + + manage.py camera add vogelhaus --name "Futterhaus Garten" + manage.py camera list + manage.py camera rotate vogelhaus + manage.py camera disable vogelhaus + +Der Token wird genau einmal ausgegeben — danach liegt nur noch sein Hash in +der Datenbank. Wer ihn verliert, erzeugt mit ``rotate`` einen neuen. +""" + +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.utils.text import slugify + +from dex.models import Camera + + +class Command(BaseCommand): + help = "Kameras verwalten." + + def add_arguments(self, parser): + sub = parser.add_subparsers(dest="action", required=True) + + add = sub.add_parser("add", help="Neue Kamera anlegen") + add.add_argument("slug") + add.add_argument("--name", help="Anzeigename (Standard: aus dem Slug)") + add.add_argument("--location", default="", help="Standort, z.B. 'Garten Nord'") + # 🔑 Ein Handy ist eine Kamera, die DARF schweigen. Ohne diese Angabe + # trägt es im Kamerafilter und im Störungswächter dauerhaft ein ⚠, + # weil beide "seit Stunden nichts geliefert" als Defekt lesen — richtig + # für ein Vogelhaus, falsch für ein Telefon in der Tasche. Bisher gab + # es das Feld nur im Admin; wer eine Handy-Kamera auf der + # Kommandozeile anlegt, hat es zwangsläufig falsch. + add.add_argument( + "--mobil", + action="store_true", + help="Handy o.ä.: liefert nur gelegentlich, gilt nie als gestört.", + ) + + sub.add_parser("list", help="Alle Kameras anzeigen") + + rot = sub.add_parser("rotate", help="Neuen Token erzeugen") + rot.add_argument("slug") + + dis = sub.add_parser("disable", help="Kamera stilllegen") + dis.add_argument("slug") + + ena = sub.add_parser("enable", help="Kamera wieder aktivieren") + ena.add_argument("slug") + + def handle(self, *args, **opts): + action = opts["action"] + if action == "add": + return self._add(opts) + if action == "list": + return self._list() + return self._toggle(action, opts["slug"]) + + def _add(self, opts): + slug = slugify(opts["slug"]) + if Camera.objects.filter(slug=slug).exists(): + raise CommandError(f"Kamera '{slug}' gibt es schon.") + + token = Camera.new_token() + camera = Camera( + slug=slug, + name=opts.get("name") or slug.replace("-", " ").title(), + location=opts.get("location") or "", + mobil=bool(opts.get("mobil")), + ) + camera.set_token(token) + camera.save() + + self.stdout.write(self.style.SUCCESS(f"\nKamera '{camera.name}' angelegt.\n")) + self.stdout.write("Token (wird nur dieses eine Mal angezeigt):\n") + self.stdout.write(self.style.WARNING(f" {token}\n")) + if camera.mobil: + # Eine mobile Kamera hat keinen Erkennungs-Host — der Token geht + # in die App. Der Hinweis unten wäre hier eine falsche Fährte. + self.stdout.write("In der App unter 'Server verbinden' eintragen.\n") + else: + self.stdout.write( + "Auf dem Erkennungs-Host in /etc/vogelalbum.env eintragen:\n" + ) + self.stdout.write(f" VOGELALBUM_TOKEN={token}\n") + + def _list(self): + cameras = Camera.objects.all() + if not cameras: + self.stdout.write("Keine Kameras angelegt.") + return + + self.stdout.write(f"\n{'Slug':<18} {'Name':<24} {'Aktiv':<6} {'Zuletzt':<18} Sichtungen") + self.stdout.write("-" * 82) + for c in cameras: + last = ( + timezone.localtime(c.last_seen).strftime("%d.%m. %H:%M:%S") + if c.last_seen + else "nie" + ) + flag = "ja" if c.active else "nein" + warn = " ⚠ hängt?" if c.active and c.is_stale else "" + self.stdout.write( + f"{c.slug:<18} {c.name:<24} {flag:<6} {last:<18} " + f"{c.sightings.count()}{warn}" + ) + + def _toggle(self, action: str, slug: str): + camera = Camera.objects.filter(slug=slug).first() + if camera is None: + raise CommandError(f"Keine Kamera '{slug}'.") + + if action == "rotate": + token = Camera.new_token() + camera.set_token(token) + camera.save(update_fields=["token_hash"]) + self.stdout.write(self.style.SUCCESS(f"Neuer Token für '{camera.name}':")) + self.stdout.write(self.style.WARNING(f" {token}")) + self.stdout.write("Der alte Token ist ab sofort ungültig.") + return + + camera.active = action == "enable" + camera.save(update_fields=["active"]) + state = "aktiviert" if camera.active else "stillgelegt" + self.stdout.write(self.style.SUCCESS(f"'{camera.name}' {state}.")) diff --git a/web/dex/management/commands/demo_data.py b/web/dex/management/commands/demo_data.py new file mode 100644 index 0000000..7c66539 --- /dev/null +++ b/web/dex/management/commands/demo_data.py @@ -0,0 +1,136 @@ +"""Erzeugt plausible Beispiel-Sichtungen, damit sich die Oberfläche vor dem +ersten echten Vogel beurteilen lässt. + +Bewusst kein Teil des Normalbetriebs: ``--clear`` räumt alles wieder weg. +Die Bilder sind die Referenzfotos aus Wikipedia, keine echten Kameraaufnahmen +— die Sichtungen sind also erkennbar Attrappe. +""" + +from __future__ import annotations + +import random +from datetime import timedelta + +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from django.utils import timezone + +from dex import scoring +from dex.models import DexEntry, EarnedBadge, Sighting, Species + +# Wie wahrscheinlich taucht eine Seltenheitsstufe auf? Grob an einem echten +# Garten orientiert: Kohlmeisen im Dauerbetrieb, der Sperber einmal im Monat. +RARITY_WEIGHT = { + "haeufig": 60, + "gelegentlich": 22, + "selten": 6, + "episch": 1.2, + "legendaer": 0.3, +} + +# Vögel am Futterhaus haben zwei Stoßzeiten: kurz nach Sonnenaufgang und am +# späten Nachmittag. +HOUR_WEIGHT = [ + 0.1, 0.1, 0.1, 0.2, 0.6, 2.5, 6.0, 9.0, 8.0, 6.0, 4.5, 3.5, + 3.0, 3.2, 4.0, 5.5, 7.0, 6.5, 4.0, 1.5, 0.5, 0.2, 0.1, 0.1, +] + + +class Command(BaseCommand): + help = "Legt Beispiel-Sichtungen an (nur zum Ansehen der Oberfläche)." + + def add_arguments(self, parser): + parser.add_argument("--tage", type=int, default=21) + parser.add_argument("--pro-tag", type=int, default=9) + parser.add_argument( + "--clear", + action="store_true", + help="Alle Sichtungen, Dex-Einträge und Abzeichen löschen.", + ) + parser.add_argument("--seed", type=int, default=1312) + + def handle(self, *args, **opts): + if opts["clear"]: + n = Sighting.objects.count() + Sighting.objects.all().delete() + DexEntry.objects.all().delete() + EarnedBadge.objects.all().delete() + self.stdout.write(self.style.SUCCESS(f"{n} Sichtungen gelöscht.")) + return + + if Sighting.objects.exists(): + self.stdout.write( + self.style.WARNING( + "Es gibt bereits Sichtungen. Erst 'demo_data --clear' ausführen, " + "sonst mischen sich Demo- und Echtdaten." + ) + ) + return + + species = list(Species.objects.all()) + if not species: + self.stdout.write(self.style.ERROR("Katalog leer — erst 'seed_dex'.")) + return + + rng = random.Random(opts["seed"]) + weights = [RARITY_WEIGHT.get(s.rarity, 1) for s in species] + + # Chronologisch anlegen, damit Erstfänge, XP und Abzeichen in der + # richtigen Reihenfolge verbucht werden. + now = timezone.localtime() + events = [] + for day_offset in range(opts["tage"], -1, -1): + day = (now - timedelta(days=day_offset)).date() + for _ in range(rng.randint(max(1, opts["pro_tag"] - 4), opts["pro_tag"] + 4)): + hour = rng.choices(range(24), weights=HOUR_WEIGHT)[0] + start = timezone.make_aware( + timezone.datetime.combine(day, timezone.datetime.min.time()) + ) + timedelta(hours=hour, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59)) + if start > timezone.now(): + continue + events.append(start) + + events.sort() + created = 0 + for start in events: + sp = rng.choices(species, weights=weights)[0] + + # Ein Teil der Besuche bleibt unbestimmt — so sieht es im Betrieb + # auch aus, und die Oberfläche muss den Fall aushalten. + unbestimmt = rng.random() < 0.12 + confidence = 0.0 if unbestimmt else rng.uniform(0.55, 0.99) + duration = rng.uniform(3, 75) + + sighting = Sighting( + species=None if unbestimmt else sp, + confidence=confidence, + started_at=start, + ended_at=start + timedelta(seconds=duration), + frames_total=int(duration * 4), + frames_with_bird=int(duration * 4 * rng.uniform(0.5, 0.95)), + votes={sp.slug: round(confidence, 3)} if not unbestimmt else {}, + mean_bird_score=rng.uniform(0.4, 0.95), + ) + + if not unbestimmt and sp.reference_image: + try: + sp.reference_image.open("rb") + data = sp.reference_image.read() + sp.reference_image.close() + sighting.image.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False) + sighting.crop.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False) + except OSError: + pass + + sighting.save() + scoring.register(sighting) + created += 1 + + p = scoring.progress() + self.stdout.write( + self.style.SUCCESS( + f"{created} Beispiel-Sichtungen angelegt. " + f"Level {p.level} ({p.rank}), {p.xp} XP, " + f"{p.species_seen}/{p.species_total} Arten, {p.badges} Abzeichen." + ) + ) diff --git a/web/dex/management/commands/fetch_reference_images.py b/web/dex/management/commands/fetch_reference_images.py new file mode 100644 index 0000000..cd73f04 --- /dev/null +++ b/web/dex/management/commands/fetch_reference_images.py @@ -0,0 +1,171 @@ +"""Holt für jede Art ein Referenzfoto aus der deutschen Wikipedia. + +Damit weiß man, wonach man Ausschau hält, bevor eine Art zum ersten Mal selbst +vor der Kamera saß. Urheber und Lizenz werden mitgespeichert und in der +Oberfläche angezeigt — die Bilder stehen fast alle unter CC-BY-SA und die +Namensnennung ist Bedingung, nicht Höflichkeit. +""" + +from __future__ import annotations + +import io +import json +import re +import time +import urllib.parse +import urllib.request + +from django.core.files.base import ContentFile +from django.core.management.base import BaseCommand +from PIL import Image + +from dex.models import Species + +UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)" +WP_API = "https://de.wikipedia.org/w/api.php" +COMMONS_API = "https://commons.wikimedia.org/w/api.php" + +MAX_WIDTH = 900 +TAG_RE = re.compile(r"<[^>]+>") + + +def _get(url: str, params: dict) -> dict: + full = f"{url}?{urllib.parse.urlencode(params)}" + req = urllib.request.Request(full, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=25) as resp: + return json.loads(resp.read()) + + +def _strip_html(value: str) -> str: + """Der Artist kommt als HTML-Schnipsel mit Link. Wir wollen nur den Namen.""" + text = TAG_RE.sub("", value or "") + return " ".join(text.split())[:250] + + +class Command(BaseCommand): + help = "Lädt Referenzfotos samt Lizenzangaben aus Wikipedia/Commons." + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Auch Arten neu laden, die schon ein Referenzfoto haben.", + ) + parser.add_argument("--only", help="Nur diese Art (Slug).") + parser.add_argument( + "--delay", + type=float, + default=0.6, + help="Pause zwischen Anfragen; Wikipedia bittet um Zurückhaltung.", + ) + + def handle(self, *args, **opts): + qs = Species.objects.all() + if opts["only"]: + qs = qs.filter(slug=opts["only"]) + if not opts["force"]: + qs = qs.filter(reference_image="") + + ok = skipped = failed = 0 + for species in qs: + try: + if self._fetch(species, opts["delay"]): + ok += 1 + self.stdout.write(self.style.SUCCESS(f" ✓ {species.german}")) + else: + skipped += 1 + self.stdout.write(f" – {species.german}: kein Bild gefunden") + except Exception as exc: + failed += 1 + self.stdout.write(self.style.WARNING(f" ✗ {species.german}: {exc}")) + time.sleep(opts["delay"]) + + self.stdout.write( + self.style.SUCCESS(f"\n{ok} geladen, {skipped} ohne Bild, {failed} Fehler.") + ) + + def _lead_image(self, title: str) -> dict | None: + page = _get( + WP_API, + { + "action": "query", + "titles": title, + "prop": "pageimages", + "piprop": "original|name", + "format": "json", + "formatversion": "2", + "redirects": "1", + }, + ) + pages = page.get("query", {}).get("pages", []) + if not pages or "original" not in pages[0]: + return None + return pages[0] + + def _fetch(self, species: Species, delay: float) -> bool: + # 1. Artikelbild der deutschen Wikipedia finden. Der deutsche Name ist + # meist das Artikellemma — außer bei Gimpel, Kleiber, Sperber, Star: + # das sind Begriffsklärungsseiten ohne Bild. Der wissenschaftliche + # Name leitet dort zuverlässig auf den Artikel weiter. + page = self._lead_image(species.german) + if page is None: + time.sleep(delay) + page = self._lead_image(species.scientific) + if page is None: + return False + + img_url = page["original"]["source"].split("?", 1)[0] + file_name = page.get("pageimage") + + # 2. Lizenz und Urheber von Commons nachschlagen. + credit = license_name = source = "" + if file_name: + time.sleep(delay) + meta = _get( + COMMONS_API, + { + "action": "query", + "titles": f"File:{file_name}", + "prop": "imageinfo", + "iiprop": "extmetadata|url", + "format": "json", + "formatversion": "2", + }, + ) + info = meta.get("query", {}).get("pages", [{}])[0].get("imageinfo") + if info: + ext = info[0].get("extmetadata", {}) + credit = _strip_html(ext.get("Artist", {}).get("value", "")) + license_name = _strip_html( + ext.get("LicenseShortName", {}).get("value", "") + ) + source = info[0].get("descriptionurl", "") + + # 3. Bild holen und auf vernünftige Größe bringen. + req = urllib.request.Request(img_url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=45) as resp: + raw = resp.read() + + img = Image.open(io.BytesIO(raw)).convert("RGB") + if img.width > MAX_WIDTH: + img = img.resize( + (MAX_WIDTH, round(img.height * MAX_WIDTH / img.width)), Image.LANCZOS + ) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85, optimize=True) + + species.reference_image.save( + f"{species.slug}.jpg", ContentFile(buf.getvalue()), save=False + ) + species.reference_credit = credit + species.reference_license = license_name + species.reference_source = source + species.save( + update_fields=[ + "reference_image", + "reference_credit", + "reference_license", + "reference_source", + ] + ) + return True diff --git a/web/dex/management/commands/seed_dex.py b/web/dex/management/commands/seed_dex.py new file mode 100644 index 0000000..2ef80fb --- /dev/null +++ b/web/dex/management/commands/seed_dex.py @@ -0,0 +1,193 @@ +"""Füllt Artenkatalog und Abzeichen. Idempotent — beliebig oft ausführbar.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from django.core.management.base import BaseCommand + +from dex import scoring +from dex.models import Species + +def _steckbrief(z) -> dict: + """Steckbrief-Felder aus einem Zusatz-Eintrag, oder leer.""" + if z is None: + return {} + return { + "kategorie": z.kategorie, "beschreibung": z.text, + "nahrung": z.nahrung, "lebensraum": z.lebensraum, "stimme": z.stimme, + "kategorie_en": z.kategorie_en, "beschreibung_en": z.text_en, + "nahrung_en": z.nahrung_en, "lebensraum_en": z.lebensraum_en, + "stimme_en": z.stimme_en, + } + + +# Muss zu RARITY_XP in detector/species_de.py passen. +RARITY_XP = { + "haeufig": 10, "gelegentlich": 25, "selten": 60, "episch": 150, "legendaer": 400, +} + +def find_catalog_root() -> Path | None: + """Sucht das Verzeichnis, das ``detector/species_de.py`` enthält. + + Lokal liegt es eine Ebene über ``web/``, im Container direkt neben + ``manage.py`` — statt beide Fälle zu raten, einfach aufwärts suchen. + """ + for base in Path(__file__).resolve().parents: + if (base / "detector" / "species_de.py").exists(): + return base + return None + + +class Command(BaseCommand): + help = "Legt Arten und Abzeichen an bzw. aktualisiert sie." + + def handle(self, *args, **options): + # detector.species_de ist die einzige Quelle der Wahrheit für den + # Katalog. Das Modul hängt nur an der Stdlib, der Import kostet die + # Web-App also keine schweren Abhängigkeiten. + root = find_catalog_root() + if root is None: + self.stderr.write( + self.style.ERROR( + "detector/species_de.py nicht gefunden — " + "weder neben manage.py noch in einem übergeordneten Verzeichnis." + ) + ) + return + + sys.path.insert(0, str(root)) + try: + from detector.species_de import SPECIES + except ImportError as exc: + self.stderr.write(self.style.ERROR(f"Artenkatalog nicht ladbar: {exc}")) + return + + try: + from detector.species_extra import EXTRA + except ImportError: + EXTRA = [] + + try: + from detector.species_info import INFO + except ImportError: + INFO = {} + self.stdout.write( + self.style.WARNING("Keine Steckbriefe gefunden — Arten bleiben knapp.") + ) + + try: + from detector.species_info_en import INFO_EN + except ImportError: + INFO_EN = {} + + # Steckbriefe fuer die erweiterten Arten — beide Sprachen in einem + # Datensatz, weil sie zusammen geschrieben werden. + try: + from detector.profiles import INFO_EXTRA + except ImportError: + INFO_EXTRA = {} + + # Die Dex-Nummer soll stabil bleiben, damit #012 immer dieselbe Art + # ist. Sortiert nach Seltenheit, dann alphabetisch — neue Arten + # verschieben also Nummern, aber nur innerhalb ihrer Stufe. + order = {"haeufig": 0, "gelegentlich": 1, "selten": 2, "episch": 3, "legendaer": 4} + # Kernarten zuerst, dann die Erweiterung — so behalten die Gartenvögel + # ihre niedrigen Dex-Nummern, auch wenn der Katalog wächst. + geordnet = sorted(SPECIES, key=lambda s: (order.get(s.rarity, 9), s.german)) + geordnet_extra = sorted(EXTRA, key=lambda e: (order.get(e.rarity, 9), e.german)) + + created = updated = mit_text = 0 + for nummer, s in enumerate(geordnet, start=1): + info = INFO.get(s.slug) + defaults = { + "german": s.german, + "scientific": s.scientific, + "english": s.english, + "rarity": s.rarity, + "xp": s.xp, + "fact": s.fact, + "size_cm": s.size_cm, + "number": nummer, + "lebensraum_typ": s.lebensraum, + "regions": ["de"], + } + if info is not None: + mit_text += 1 + defaults |= { + "typ": info.typ, + "kategorie": info.kategorie, + "beschreibung": info.text, + "gewicht_g": info.gewicht_g, + "spannweite_cm": info.spannweite_cm, + "zug": info.zug, + "nahrung": info.nahrung, + "lebensraum": info.lebensraum, + "stimme": info.stimme, + # Verweise auf Arten, die es (noch) nicht gibt, fliegen raus — + # sonst zeigt die Seite Links ins Leere. + "verwechslung": [ + v for v in info.verwechslung + if any(x.slug == v for x in SPECIES) + ], + } + + info_en = INFO_EN.get(s.slug) + if info_en is not None: + defaults |= { + "kategorie_en": info_en.kategorie, + "beschreibung_en": info_en.text, + "nahrung_en": info_en.nahrung, + "lebensraum_en": info_en.lebensraum, + "stimme_en": info_en.stimme, + } + + _, was_created = Species.objects.update_or_create(slug=s.slug, defaults=defaults) + created += was_created + updated += not was_created + + # Erweiterte Arten: nur Strukturdaten, kein Erzähltext. Vorhandene + # Texte werden nicht überschrieben — wer einen Steckbrief nachträgt, + # verliert ihn beim nächsten Seed nicht. + naechste = len(geordnet) + for offset, e in enumerate(geordnet_extra, start=1): + obj, was_created = Species.objects.update_or_create( + slug=e.slug, + defaults={ + "german": e.german, + "scientific": e.scientific, + "english": e.english, + "rarity": e.rarity, + "xp": RARITY_XP.get(e.rarity, 10), + "size_cm": e.size_cm, + "number": naechste + offset, + "typ": e.typ, + "gewicht_g": e.gewicht_g, + "spannweite_cm": e.spannweite_cm, + "zug": e.zug, + "lebensraum_typ": e.lebensraum, + "regions": ["de"], + } + | _steckbrief(INFO_EXTRA.get(e.slug)), + ) + created += was_created + updated += not was_created + + badges = scoring.sync_badges() + gesamt = len(SPECIES) + len(EXTRA) + mit_text += sum(1 for e in EXTRA if e.slug in INFO_EXTRA) + mit_en = sum(1 for s in SPECIES if s.slug in INFO_EN) + mit_en += sum(1 for e in EXTRA if e.slug in INFO_EXTRA) + ohne_text = gesamt - mit_text + self.stdout.write( + self.style.SUCCESS( + f"Arten: {created} neu, {updated} aktualisiert, " + f"{mit_text} von {gesamt} mit Steckbrief ({mit_en} auch auf Englisch). " + f"Abzeichen: {badges} neu." + ) + ) + if ohne_text: + self.stdout.write( + self.style.WARNING(f"{ohne_text} Arten noch ohne Steckbrief") + ) diff --git a/web/dex/management/commands/sichtung_bestimmen.py b/web/dex/management/commands/sichtung_bestimmen.py new file mode 100644 index 0000000..1b5474d --- /dev/null +++ b/web/dex/management/commands/sichtung_bestimmen.py @@ -0,0 +1,184 @@ +"""Setzt die Art einer Sichtung richtig — samt allem, was daran hängt. + +🔑 Warum ein eigener Befehl und nicht nur das Feld im Admin: An einer +Bestimmung hängt mehr als der Name. Der Dex-Eintrag der falschen Art +bleibt sonst stehen, der Erstfang wird nicht zurückgenommen, und die +dreifachen Punkte bleiben vergeben. Wer nur das Feld ändert, hat danach +eine Art in der Sammlung, die nie da war. + +Am 30.09.2026 hat das Modell eine Kohlmeise mit **94,5 % Sicherheit** als +Blaumeise bestimmt. Am Foto war es zu sehen: + + gelbe Brust mit dunklem Streifen → Kohlmeise + Kopf komplett verwischt → das entscheidende + Merkmal fehlte + +⚠️ **Hohe Sicherheit heißt nicht richtig.** Das Modell musste ohne die +blaue Kappe entscheiden und war sich trotzdem sicher. + + manage.py sichtung_bestimmen 228 kohlmeise --trocken + manage.py sichtung_bestimmen 228 kohlmeise + +📌 Die Punkte werden neu gerechnet, nicht geschätzt — mit derselben +Formel, die bei der Aufnahme gilt (``scoring``). Sonst laufen Vergabe und +Korrektur auseinander. +""" + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.db.models import Max + +from dex import scoring +from dex.models import Camera, DexEntry, Sighting, Species + + +class Command(BaseCommand): + help = "Setzt die Art einer Sichtung richtig und repariert die Sammlung" + + def add_arguments(self, parser): + parser.add_argument("id", type=int, help="ID der Sichtung") + parser.add_argument("art", help="Kürzel der richtigen Art, z.B. kohlmeise") + parser.add_argument( + "--trocken", action="store_true", + help="Nur anzeigen, was passieren würde", + ) + + def handle(self, *args, **optionen): + trocken = optionen["trocken"] + + try: + sichtung = Sighting.objects.select_related( + "species", "confirmed_species", "camera").get(pk=optionen["id"]) + except Sighting.DoesNotExist: + raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.") + + try: + richtig = Species.objects.get(slug=optionen["art"]) + except Species.DoesNotExist: + raise CommandError( + f"Art '{optionen['art']}' gibt es nicht. " + f"Kürzel siehe Admin oder: manage.py shell") + + bisher = sichtung.effective_species + if bisher is not None and bisher.pk == richtig.pk: + self.stdout.write(f"Sichtung {sichtung.pk} ist bereits " + f"{richtig.german} — nichts zu tun.") + return + + self.stdout.write( + f"Sichtung {sichtung.pk} · " + f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · " + f"{sichtung.camera.name if sichtung.camera else 'ohne Kamera'}" + ) + self.stdout.write( + f" erkannt als {bisher.german if bisher else 'unbestimmt'} " + f"({sichtung.confidence:.3f} Sicherheit)" + ) + self.stdout.write(f" richtig ist {richtig.german}") + + if sichtung.is_first_of_species: + self.stdout.write(self.style.WARNING( + "⚠ Das galt als ERSTFANG — dreifache Punkte. Die werden " + "neu gerechnet." + )) + + punkte_neu = self._punkte(sichtung, richtig) + self.stdout.write(f" Punkte {sichtung.xp_awarded} → {punkte_neu}") + + if trocken: + self.stdout.write(self.style.NOTICE( + "[trocken] Es wurde nichts geändert.")) + return + + with transaction.atomic(): + alt = bisher + sichtung.confirmed_species = richtig + # 🔑 is_first_of_species neu bestimmen: Gibt es zu der + # richtigen Art schon einen Eintrag, ist es kein Erstfang. + war_erst = sichtung.is_first_of_species + sichtung.is_first_of_species = not DexEntry.objects.filter( + species=richtig).exclude( + first_seen__gt=sichtung.started_at).exists() + sichtung.xp_awarded = self._punkte(sichtung, richtig) + sichtung.save(update_fields=["confirmed_species", + "is_first_of_species", "xp_awarded"]) + + self._dex_reparieren(alt) + self._dex_reparieren(richtig) + if sichtung.camera: + self._kamera_reparieren(sichtung.camera) + + if war_erst and not sichtung.is_first_of_species: + self.stdout.write(" Erstfang zurückgenommen.") + + self.stdout.write(self.style.SUCCESS( + f"Sichtung {sichtung.pk} ist jetzt {richtig.german}.")) + + # -- Rechnen ------------------------------------------------------ + + def _punkte(self, sichtung, art) -> int: + """Dieselbe Formel wie bei der Aufnahme — siehe scoring.register. + + ⚠️ Bewusst nicht nachgebaut, sondern abgeleitet: Weicht die + Korrektur von der Vergabe ab, stimmt der Punktestand nie wieder. + """ + erstfang = not DexEntry.objects.filter(species=art).exclude( + first_seen__gt=sichtung.started_at).exists() + punkte = art.xp * (scoring.FIRST_CATCH_MULTIPLIER if erstfang else 1) + return int(round(punkte * (0.5 + 0.5 * min(1.0, sichtung.confidence)))) + + # -- Aufräumen ---------------------------------------------------- + + def _dex_reparieren(self, art): + """Zählt den Eintrag einer Art neu — aus den Sichtungen. + + 🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher + falsch stand, wird dabei nebenbei richtig. + """ + if art is None: + return + # ⚠️ Über effective_species zählen, nicht über species -- sonst + # zählt eine korrigierte Sichtung weiter bei der falschen Art. + passend = [ + s for s in Sighting.objects.select_related( + "species", "confirmed_species") + if s.effective_species and s.effective_species.pk == art.pk + ] + eintrag = DexEntry.objects.filter(species=art).first() + + if not passend: + if eintrag: + eintrag.delete() + self.stdout.write(f" Dex-Eintrag {art.german} entfernt " + f"(keine Sichtung mehr).") + return + + passend.sort(key=lambda s: s.started_at) + # 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit. + beste = scoring.beste_sichtung(passend) + if eintrag is None: + DexEntry.objects.create( + species=art, first_seen=passend[0].started_at, + last_seen=passend[-1].started_at, count=len(passend), + best_sighting=beste) + self.stdout.write(f" Dex-Eintrag {art.german} angelegt " + f"({len(passend)}×).") + return + + eintrag.count = len(passend) + eintrag.first_seen = passend[0].started_at + eintrag.last_seen = passend[-1].started_at + eintrag.best_sighting = beste + eintrag.save() + self.stdout.write(f" Dex-Eintrag {art.german}: jetzt " + f"{eintrag.count}×, beste Sichtung {beste.pk}.") + + def _kamera_reparieren(self, kamera): + """`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt. + + ⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag + und bleibt unberührt. + """ + zuletzt = Sighting.objects.filter(camera=kamera).aggregate( + z=Max("ended_at"))["z"] + Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt) diff --git a/web/dex/management/commands/sichtung_entfernen.py b/web/dex/management/commands/sichtung_entfernen.py new file mode 100644 index 0000000..33daa03 --- /dev/null +++ b/web/dex/management/commands/sichtung_entfernen.py @@ -0,0 +1,219 @@ +"""Entfernt eine Sichtung — samt allem, was aus ihr abgeleitet wurde. + +🔑 Warum das ein eigener Befehl ist und kein Löschen im Admin: An einer +Sichtung hängt mehr als ihre eigene Zeile. Der Dex-Eintrag zählt sie mit, +die Kamera merkt sich den Zeitpunkt, die Punkte stecken im Datensatz, und +zwei Bilddateien liegen auf der Platte. Wer nur die Zeile löscht, lässt +einen Dex-Eintrag zurück, der eine Sichtung zu viel zählt. + +Gedacht für Fehlerkennungen. Die erste war am 24.09.2026: Die Kamera für +die Vogeltränke lag noch auf dem Schreibtisch und hat Teile der +Befestigung für eine Kohlmeise gehalten — ein einziges von 73 Bildern +hatte einen Vogel-Wert über der Schwelle, und der lag bei 0,098. + +⚠ Die Punkte der Sichtung verrechnet niemand gesondert: Der Gesamtstand +summiert ``xp_awarded``, also verschwinden sie mit der Sichtung von selbst. + +🔴 **Für die Punkte eines Abzeichens gilt das NICHT** — und bis zum +11.10.2026 stand hier das Gegenteil. ``scoring.total_xp()`` zählt +``Sighting.xp_awarded`` **und** ``EarnedBadge.badge__xp`` zusammen. Ein +Abzeichen, das aus der entfernten Sichtung stammt, lässt seine Punkte also +stehen. An jenem Tag waren das **200 Punkte für „Seltener Gast"** aus einer +Fehlerkennung — mehr als die 144 der Sichtung selbst, und sie haben eine +Stufe getragen, die es nicht gab. + +🔑 Abzeichen bleiben trotzdem die Vorgabe: Ein einmal verdientes Abzeichen +nimmt man einem Kind nicht wieder weg. Aber **stillschweigend darf das nicht +passieren** — der Befehl nennt sie jetzt samt Punkten, und ``--abzeichen`` +nimmt sie mit, wenn sie nie verdient waren. + + manage.py sichtung_entfernen 199 --trocken # erst schauen + manage.py sichtung_entfernen 199 # dann löschen + manage.py sichtung_entfernen 199 --abzeichen # samt Abzeichen daraus +""" + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.db.models import Max + +from dex import scoring +from dex.models import Camera, DexEntry, EarnedBadge, Sighting + + +class Command(BaseCommand): + help = "Entfernt eine Sichtung und repariert, was aus ihr abgeleitet wurde" + + def add_arguments(self, parser): + parser.add_argument("id", type=int, help="ID der Sichtung") + parser.add_argument( + "--trocken", action="store_true", + help="Nur anzeigen, was passieren würde", + ) + parser.add_argument( + "--abzeichen", action="store_true", + help="Abzeichen aus dieser Sichtung mitnehmen (samt ihrer Punkte)", + ) + + def handle(self, *args, **optionen): + trocken = optionen["trocken"] + abzeichen_mit = optionen["abzeichen"] + + try: + sichtung = Sighting.objects.select_related( + "species", "camera").get(pk=optionen["id"]) + except Sighting.DoesNotExist: + raise CommandError(f"Sichtung {optionen['id']} gibt es nicht.") + + art = sichtung.species + kamera = sichtung.camera + + self.stdout.write( + f"Sichtung {sichtung.pk}: " + f"{art.german if art else 'unbestimmt'} · " + f"{kamera.name if kamera else 'ohne Kamera'} · " + f"{sichtung.started_at:%d.%m.%Y %H:%M:%S} · " + f"{sichtung.confidence:.3f} Sicherheit · " + f"{sichtung.frames_with_bird} von {sichtung.frames_total} Bildern " + f"mit Vogel · {sichtung.xp_awarded} Punkte" + ) + + # ⚠️ Eine Erstsichtung wiegt schwerer: Mit ihr fällt der Tag, an dem + # die Art zum ersten Mal da war. Deshalb ausdrücklich benennen statt + # stillschweigend mitlöschen. + if sichtung.is_first_of_species: + self.stdout.write(self.style.WARNING( + "⚠ Das ist die ERSTSICHTUNG dieser Art. Der Dex-Eintrag " + "rückt auf die nächstältere Sichtung nach." + )) + + dateien = [f for f in (sichtung.image, sichtung.crop) if f] + for datei in dateien: + self.stdout.write(f" Datei: {datei.name}") + + # ⚠️ VOR dem Löschen einsammeln: ``EarnedBadge.sighting`` ist + # SET_NULL. Nach dem Löschen ist die Spur zur Sichtung weg, und dann + # weiß niemand mehr, welches Abzeichen aus ihr stammte. + verdiente = list(EarnedBadge.objects.filter( + sighting=sichtung).select_related("badge")) + self._abzeichen_nennen(verdiente, abzeichen_mit) + + if trocken: + self.stdout.write(self.style.NOTICE( + "[trocken] Es wurde nichts geändert.")) + self._vorschau(sichtung, art, kamera) + return + + with transaction.atomic(): + sichtung_id = sichtung.pk + for datei in dateien: + datei.delete(save=False) # Platte, nicht nur Datensatz + sichtung.delete() + + if abzeichen_mit: + for verdient in verdiente: + # Nur das Verdienst, nie der Katalogeintrag: Das Abzeichen + # als solches bleibt natürlich erreichbar. + verdient.delete() + + self._dex_reparieren(art) + self._kamera_reparieren(kamera) + + self.stdout.write(self.style.SUCCESS( + f"Sichtung {sichtung_id} entfernt.")) + + # -- Aufräumen ---------------------------------------------------- + + def _dex_reparieren(self, art): + """Zählt den Dex-Eintrag neu — aus den Sichtungen, nicht per -1. + + 🔑 Neu zählen statt herunterzählen: Ein Eintrag, der schon vorher + falsch stand, wird dadurch nebenbei richtig. Ein '-1' würde den + Fehler mitschleppen. + """ + if art is None: + return + eintrag = DexEntry.objects.filter(species=art).first() + if eintrag is None: + return + + uebrig = Sighting.objects.filter(species=art) + if not uebrig.exists(): + eintrag.delete() + self.stdout.write(" Dex-Eintrag entfernt (keine Sichtung mehr).") + return + + eintrag.count = uebrig.count() + eintrag.first_seen = uebrig.earliest("started_at").started_at + eintrag.last_seen = uebrig.latest("started_at").started_at + # 🔑 Bestes Foto nach Bildguete, nicht nach Sicherheit -- siehe + # scoring.bildguete. + eintrag.best_sighting = scoring.beste_sichtung(uebrig) + eintrag.save() + self.stdout.write( + f" Dex-Eintrag: jetzt {eintrag.count}×, zuletzt " + f"{eintrag.last_seen:%d.%m.%Y %H:%M}, beste Sichtung " + f"{eintrag.best_sighting_id}." + ) + + def _kamera_reparieren(self, kamera): + """`last_seen` ist die letzte SICHTUNG, nicht der letzte Kontakt. + + ⚠️ Nicht mit `letzter_kontakt` verwechseln — das ist der Herzschlag + und bleibt unberührt. Genau diese Verwechslung hatte am 23.09.2026 + einen Fehlalarm ausgelöst. + """ + if kamera is None: + return + zuletzt = Sighting.objects.filter(camera=kamera).aggregate( + z=Max("ended_at"))["z"] + Camera.objects.filter(pk=kamera.pk).update(last_seen=zuletzt) + self.stdout.write( + f" Kamera {kamera.name}: letzte Sichtung " + + (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.") + ) + + def _abzeichen_nennen(self, verdiente, mitnehmen): + """Sagt, welche Abzeichen an der Sichtung hängen — und was sie wiegen. + + 🔑 Dass hier überhaupt etwas steht, ist der eigentliche Fix vom + 11.10.2026: Vorher blieben Abzeichen **und ihre Punkte** stumm + zurück, während die Beschreibung des Befehls das Gegenteil + behauptete. Wer eine Fehlerkennung entfernt und danach dieselbe + Stufe sieht, sucht den Fehler an der falschen Stelle. + """ + if not verdiente: + return + + punkte = sum(v.badge.xp for v in verdiente) + for verdient in verdiente: + self.stdout.write( + f" Abzeichen: {verdient.badge.emoji} " + f"{verdient.badge.name} ({verdient.badge.xp} Punkte)" + ) + if mitnehmen: + self.stdout.write(self.style.WARNING( + f"⚠ Diese Abzeichen werden mit entfernt — {punkte} Punkte " + f"weniger." + )) + else: + self.stdout.write(self.style.WARNING( + f"⚠ Abzeichen BLEIBEN, samt ihren {punkte} Punkten. War die " + f"Sichtung eine Fehlerkennung, waren sie nie verdient: " + f"--abzeichen nimmt sie mit." + )) + + def _vorschau(self, sichtung, art, kamera): + if art is not None: + uebrig = Sighting.objects.filter(species=art).exclude( + pk=sichtung.pk) + self.stdout.write( + f" Dex-Eintrag {art.german}: würde auf {uebrig.count()}× " + + ("sinken." if uebrig.exists() else "0 fallen und wegfallen.") + ) + if kamera is not None: + zuletzt = Sighting.objects.filter(camera=kamera).exclude( + pk=sichtung.pk).aggregate(z=Max("ended_at"))["z"] + self.stdout.write( + f" Kamera {kamera.name}: letzte Sichtung wäre " + + (f"{zuletzt:%d.%m.%Y %H:%M}." if zuletzt else "keine mehr.") + ) diff --git a/web/dex/management/commands/stoerungen_pruefen.py b/web/dex/management/commands/stoerungen_pruefen.py new file mode 100644 index 0000000..37511db --- /dev/null +++ b/web/dex/management/commands/stoerungen_pruefen.py @@ -0,0 +1,153 @@ +"""Prüft, ob die Kameras noch melden — und schlägt Alarm, wenn nicht. + +🔑 Warum das hier liegt und nicht im Detector: Ein abgestürzter Prozess kann +seinen eigenen Tod nicht melden. Wer das Ausbleiben von Meldungen bemerken +soll, muss von aussen schauen. Diese Seite hier läuft ohnehin. + +Gedacht als regelmässiger Aufruf (systemd-Zeitgeber oder cron): + + */10 * * * * .../manage.py stoerungen_pruefen + +⚠ Es wird pro Kamera nur EINMAL gemeldet, nicht bei jedem Durchlauf. Eine +Störung, die alle zehn Minuten aufs Handy summt, schaltet man nach einer +Stunde ab — und dann merkt man die nächste nicht mehr. + +🔑 **Der Merker steht in der Datenbank, nicht im Prozess.** Hier stand +einmal ein Modul-Set `_gemeldet`. In einem Einmal-Lauf ist das wirkungslos: +jeder Aufruf ist ein frischer Prozess, das Set beim Start wieder leer. Die +Zusage eine Zeile weiter oben war damit acht Monate lang unwahr, und am +01.10.2026 hat es sich gezeigt -- nach dem Stromausfall summten beide +Kameras im Zehnminutentakt. +""" + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from dex import benachrichtigung as bn +from dex.models import Benachrichtigung, Camera, Sighting + + +class Command(BaseCommand): + help = "Meldet Kameras, die sich länger nicht gemeldet haben" + + def add_arguments(self, parser): + parser.add_argument( + "--trocken", action="store_true", + help="Nur anzeigen, nichts senden", + ) + + def handle(self, *args, **optionen): + einstellung = Benachrichtigung.laden() + grenze = timezone.now() - timezone.timedelta( + minutes=einstellung.stille_minuten) + + # ⚠️ Mobile Geräte bleiben außen vor. Die Prüfung misst, ob sich ein + # Gerät länger als erlaubt nicht gemeldet hat — das ergibt nur bei + # Dauerbetrieb einen Sinn. Ein Handy meldet sich, wenn jemand + # spazieren geht, und nach dem ersten ruhigen Wochenende stünde + # sonst „Kamera still: Handy" auf dem Telefon. Genau so entstehen + # Fehlalarme, die man abschaltet — und dann bemerkt man den echten + # Ausfall nicht mehr. + for kamera in Camera.objects.filter(active=True, mobil=False): + # ⚠ Gemessen wird das LEBENSZEICHEN, nicht die letzte Sichtung. + # An last_seen zu messen waere der naheliegende Fehler: An + # einem ruhigen Nachmittag kommt stundenlang kein Vogel, und + # es gaebe jeden Tag Fehlalarm. (Genau so hatte ich es zuerst + # gebaut -- der Trockenlauf meldete prompt "still seit 824 + # min" fuer eine tadellos laufende Kamera.) + if kamera.letzter_kontakt is None: + # Noch nie gemeldet. Das ist keine Stoerung, sondern eine + # Kamera, die gerade erst eingerichtet wurde. + self.stdout.write(f"{kamera.name}: noch kein Lebenszeichen") + continue + + still_seit = timezone.now() - kamera.letzter_kontakt + minuten = int(still_seit.total_seconds() // 60) + + if kamera.letzter_kontakt < grenze: + if kamera.stoerung_gemeldet_seit is not None: + self.stdout.write(f"{kamera.name}: still ({minuten} min), " + "schon gemeldet") + continue + self.stdout.write(self.style.ERROR( + f"{kamera.name}: STILL seit {minuten} min")) + if not optionen["trocken"]: + bn.melde_stoerung( + f"Kamera still: {kamera.name}", + f"Seit {minuten} Minuten kein Lebenszeichen. " + f"Läuft die Erkennung noch?") + # ⚠ Erst nach dem Senden vermerken. melde_stoerung + # sendet synchron und schluckt Fehler nur im Log -- + # wer vorher vermerkt, verliert bei einem Aussetzer + # des Meldewegs die Stoerung still und fuer immer. + kamera.stoerung_gemeldet_seit = timezone.now() + kamera.save(update_fields=["stoerung_gemeldet_seit"]) + else: + if kamera.stoerung_gemeldet_seit is not None: + # Wieder da — das gehört auch gemeldet, sonst weiss + # niemand, ob das Problem noch besteht. + self.stdout.write(self.style.SUCCESS( + f"{kamera.name}: wieder da")) + if not optionen["trocken"]: + bn.melde_stoerung( + f"Kamera wieder da: {kamera.name}", + "Sie meldet sich wieder.") + kamera.stoerung_gemeldet_seit = None + kamera.save(update_fields=["stoerung_gemeldet_seit"]) + else: + self.stdout.write(f"{kamera.name}: ok ({minuten} min her)") + + self._wartende_fotos(einstellung, optionen["trocken"]) + + def _wartende_fotos(self, einstellung, trocken: bool) -> None: + """Meldet, wenn hochgeladene Fotos zu lange auf den Detector warten. + + 🔑 **Das ist die Stillstandsprüfung für den Detector selbst.** Die + Kameraprüfung oben merkt, wenn ein Gerät nichts mehr liefert — sie + merkt aber nicht, wenn der Detector steht, denn der meldet sich bei + niemandem an. Liegengebliebene Fotos sind das einzige sichtbare + Zeichen dafür, und sie sagen es unmissverständlich: Jemand hat + hochgeladen, und seitdem ist nichts passiert. + + 📌 Verworfen wird nichts. Das Foto vom Spaziergang gibt es kein + zweites Mal, und stilles Löschen wäre der schlechteste Umgang damit. + """ + wartend = Sighting.objects.wartend() + aeltestes = wartend.first() + + if aeltestes is None: + if einstellung.fotos_gemeldet_seit is not None: + self.stdout.write(self.style.SUCCESS("Fotos: alle bearbeitet")) + if not trocken: + bn.melde_stoerung( + "Fotos wieder bearbeitet", + "Die liegengebliebenen Fotos sind bestimmt.") + einstellung.fotos_gemeldet_seit = None + einstellung.save(update_fields=["fotos_gemeldet_seit"]) + return + + minuten = int( + (timezone.now() - aeltestes.started_at).total_seconds() // 60) + anzahl = wartend.count() + + if minuten < einstellung.stille_minuten: + self.stdout.write( + f"{anzahl} Foto(s) warten, ältestes {minuten} min — ok") + return + + if einstellung.fotos_gemeldet_seit is not None: + self.stdout.write( + f"{anzahl} Foto(s) warten ({minuten} min), schon gemeldet") + return + + self.stdout.write(self.style.ERROR( + f"{anzahl} Foto(s) warten auf Bestimmung, " + f"ältestes seit {minuten} min")) + if not trocken: + bn.melde_stoerung( + "Fotos warten auf Bestimmung", + f"{anzahl} hochgeladene Foto(s) liegen seit {minuten} Minuten " + f"unbearbeitet. Läuft der Detector noch?") + # Erst nach dem Senden vermerken — siehe oben bei den Kameras. + einstellung.fotos_gemeldet_seit = timezone.now() + einstellung.save(update_fields=["fotos_gemeldet_seit"]) diff --git a/web/dex/migrations/0001_initial.py b/web/dex/migrations/0001_initial.py new file mode 100644 index 0000000..77c8968 --- /dev/null +++ b/web/dex/migrations/0001_initial.py @@ -0,0 +1,118 @@ +# Generated by Django 6.1 on 2026-08-28 09:08 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Badge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.SlugField(max_length=48, unique=True)), + ('name', models.CharField(max_length=80)), + ('description', models.CharField(max_length=200)), + ('emoji', models.CharField(default='🏅', max_length=8)), + ('xp', models.PositiveIntegerField(default=50)), + ('sort', models.PositiveIntegerField(default=0)), + ], + options={ + 'verbose_name': 'Abzeichen', + 'verbose_name_plural': 'Abzeichen', + 'ordering': ['sort', 'name'], + }, + ), + migrations.CreateModel( + name='Sighting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('confidence', models.FloatField(default=0.0)), + ('started_at', models.DateTimeField(db_index=True)), + ('ended_at', models.DateTimeField()), + ('image', models.ImageField(blank=True, upload_to='sightings/%Y/%m/')), + ('crop', models.ImageField(blank=True, upload_to='crops/%Y/%m/')), + ('frames_total', models.PositiveIntegerField(default=0)), + ('frames_with_bird', models.PositiveIntegerField(default=0)), + ('votes', models.JSONField(blank=True, default=dict)), + ('mean_bird_score', models.FloatField(default=0.0)), + ('is_first_of_species', models.BooleanField(default=False)), + ('xp_awarded', models.PositiveIntegerField(default=0)), + ], + options={ + 'verbose_name': 'Sichtung', + 'verbose_name_plural': 'Sichtungen', + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='Species', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField(max_length=64, unique=True)), + ('german', models.CharField(max_length=80, verbose_name='Deutscher Name')), + ('scientific', models.CharField(max_length=120, verbose_name='Wissenschaftlicher Name')), + ('english', models.CharField(blank=True, max_length=120, verbose_name='Englischer Name')), + ('rarity', models.CharField(choices=[('haeufig', 'Häufig'), ('gelegentlich', 'Gelegentlich'), ('selten', 'Selten'), ('episch', 'Episch'), ('legendaer', 'Legendär')], max_length=16)), + ('xp', models.PositiveIntegerField(default=10)), + ('fact', models.TextField(blank=True, verbose_name='Steckbrief')), + ('size_cm', models.CharField(blank=True, max_length=16)), + ], + options={ + 'verbose_name': 'Art', + 'verbose_name_plural': 'Arten', + 'ordering': ['german'], + }, + ), + migrations.CreateModel( + name='EarnedBadge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('earned_at', models.DateTimeField(default=django.utils.timezone.now)), + ('badge', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='earned', to='dex.badge')), + ('sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')), + ], + options={ + 'verbose_name': 'Verdientes Abzeichen', + 'verbose_name_plural': 'Verdiente Abzeichen', + 'ordering': ['-earned_at'], + }, + ), + migrations.AddField( + model_name='sighting', + name='confirmed_species', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmations', to='dex.species', verbose_name='Von Hand bestätigt als'), + ), + migrations.AddField( + model_name='sighting', + name='species', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.species'), + ), + migrations.CreateModel( + name='DexEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_seen', models.DateTimeField()), + ('last_seen', models.DateTimeField()), + ('count', models.PositiveIntegerField(default=0)), + ('best_sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')), + ('species', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='entry', to='dex.species')), + ], + options={ + 'verbose_name': 'Dex-Eintrag', + 'verbose_name_plural': 'Dex-Einträge', + 'ordering': ['-count'], + }, + ), + migrations.AddIndex( + model_name='sighting', + index=models.Index(fields=['-started_at', 'species'], name='dex_sightin_started_7b6a20_idx'), + ), + ] diff --git a/web/dex/migrations/0002_species_reference_credit_species_reference_image_and_more.py b/web/dex/migrations/0002_species_reference_credit_species_reference_image_and_more.py new file mode 100644 index 0000000..639d742 --- /dev/null +++ b/web/dex/migrations/0002_species_reference_credit_species_reference_image_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.1 on 2026-08-28 09:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='species', + name='reference_credit', + field=models.CharField(blank=True, max_length=250, verbose_name='Urheber'), + ), + migrations.AddField( + model_name='species', + name='reference_image', + field=models.ImageField(blank=True, upload_to='referenz/', verbose_name='Referenzfoto'), + ), + migrations.AddField( + model_name='species', + name='reference_license', + field=models.CharField(blank=True, max_length=80, verbose_name='Lizenz'), + ), + migrations.AddField( + model_name='species', + name='reference_source', + field=models.URLField(blank=True, max_length=400, verbose_name='Quelle'), + ), + ] diff --git a/web/dex/migrations/0003_camera_sighting_camera.py b/web/dex/migrations/0003_camera_sighting_camera.py new file mode 100644 index 0000000..950a2b8 --- /dev/null +++ b/web/dex/migrations/0003_camera_sighting_camera.py @@ -0,0 +1,38 @@ +# Generated by Django 6.1 on 2026-08-28 11:11 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0002_species_reference_credit_species_reference_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Camera', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=80)), + ('slug', models.SlugField(max_length=48, unique=True)), + ('location', models.CharField(blank=True, max_length=120, verbose_name='Standort')), + ('token_hash', models.CharField(editable=False, max_length=64, unique=True)), + ('active', models.BooleanField(default=True, verbose_name='Aktiv')), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('last_seen', models.DateTimeField(blank=True, null=True, verbose_name='Zuletzt gemeldet')), + ], + options={ + 'verbose_name': 'Kamera', + 'verbose_name_plural': 'Kameras', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='sighting', + name='camera', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.camera', verbose_name='Kamera'), + ), + ] diff --git a/web/dex/migrations/0004_alter_species_options_species_beschreibung_and_more.py b/web/dex/migrations/0004_alter_species_options_species_beschreibung_and_more.py new file mode 100644 index 0000000..4bc97b8 --- /dev/null +++ b/web/dex/migrations/0004_alter_species_options_species_beschreibung_and_more.py @@ -0,0 +1,77 @@ +# Generated by Django 6.1 on 2026-08-28 15:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0003_camera_sighting_camera'), + ] + + operations = [ + migrations.AlterModelOptions( + name='species', + options={'ordering': ['number', 'german'], 'verbose_name': 'Art', 'verbose_name_plural': 'Arten'}, + ), + migrations.AddField( + model_name='species', + name='beschreibung', + field=models.TextField(blank=True, verbose_name='Beschreibung'), + ), + migrations.AddField( + model_name='species', + name='gewicht_g', + field=models.FloatField(blank=True, null=True, verbose_name='Gewicht (g)'), + ), + migrations.AddField( + model_name='species', + name='kategorie', + field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie'), + ), + migrations.AddField( + model_name='species', + name='lebensraum', + field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum'), + ), + migrations.AddField( + model_name='species', + name='nahrung', + field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung'), + ), + migrations.AddField( + model_name='species', + name='number', + field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='Dex-Nummer'), + ), + migrations.AddField( + model_name='species', + name='spannweite_cm', + field=models.FloatField(blank=True, null=True, verbose_name='Spannweite (cm)'), + ), + migrations.AddField( + model_name='species', + name='stimme', + field=models.CharField(blank=True, max_length=200, verbose_name='Stimme'), + ), + migrations.AddField( + model_name='species', + name='typ', + field=models.CharField(blank=True, max_length=24, verbose_name='Typ'), + ), + migrations.AddField( + model_name='species', + name='verwechslung', + field=models.JSONField(blank=True, default=list, verbose_name='Verwechslungsgefahr'), + ), + migrations.AddField( + model_name='species', + name='zug', + field=models.CharField(blank=True, max_length=24, verbose_name='Zugverhalten'), + ), + migrations.AlterField( + model_name='species', + name='fact', + field=models.TextField(blank=True, verbose_name='Kurzfakt'), + ), + ] diff --git a/web/dex/migrations/0005_species_beschreibung_en_species_kategorie_en_and_more.py b/web/dex/migrations/0005_species_beschreibung_en_species_kategorie_en_and_more.py new file mode 100644 index 0000000..c9a11f3 --- /dev/null +++ b/web/dex/migrations/0005_species_beschreibung_en_species_kategorie_en_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.1 on 2026-08-28 16:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0004_alter_species_options_species_beschreibung_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='species', + name='beschreibung_en', + field=models.TextField(blank=True, verbose_name='Beschreibung (en)'), + ), + migrations.AddField( + model_name='species', + name='kategorie_en', + field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie (en)'), + ), + migrations.AddField( + model_name='species', + name='lebensraum_en', + field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum (en)'), + ), + migrations.AddField( + model_name='species', + name='nahrung_en', + field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung (en)'), + ), + migrations.AddField( + model_name='species', + name='stimme_en', + field=models.CharField(blank=True, max_length=200, verbose_name='Stimme (en)'), + ), + ] diff --git a/web/dex/migrations/0006_species_lebensraum_typ_species_regions.py b/web/dex/migrations/0006_species_lebensraum_typ_species_regions.py new file mode 100644 index 0000000..d4dab6f --- /dev/null +++ b/web/dex/migrations/0006_species_lebensraum_typ_species_regions.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-08-28 16:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0005_species_beschreibung_en_species_kategorie_en_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='species', + name='lebensraum_typ', + field=models.CharField(db_index=True, default='garten', max_length=16, verbose_name='Lebensraum-Gruppe'), + ), + migrations.AddField( + model_name='species', + name='regions', + field=models.JSONField(blank=True, default=list, verbose_name='Regionen'), + ), + ] diff --git a/web/dex/migrations/0007_camera_live_enabled_camera_live_url.py b/web/dex/migrations/0007_camera_live_enabled_camera_live_url.py new file mode 100644 index 0000000..0ece9a0 --- /dev/null +++ b/web/dex/migrations/0007_camera_live_enabled_camera_live_url.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-09-12 12:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0006_species_lebensraum_typ_species_regions'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='live_enabled', + field=models.BooleanField(default=False, help_text='Zeigt das Livebild dieser Kamera auf der Seite /live/. Bewusst standardmäßig aus: ein Livebild aus dem eigenen Garten gehört nicht auf eine öffentliche Seite.', verbose_name='Live-Bild zeigen'), + ), + migrations.AddField( + model_name='camera', + name='live_url', + field=models.URLField(blank=True, help_text='MJPEG-Adresse des Live-Verteilers, z.B. http://erkennung.local:8131/live — NICHT die Kamera selbst, sonst fehlt der Erkennung das Bild.', verbose_name='Live-Adresse'), + ), + ] diff --git a/web/dex/migrations/0008_camera_detect_threshold_camera_habitats_and_more.py b/web/dex/migrations/0008_camera_detect_threshold_camera_habitats_and_more.py new file mode 100644 index 0000000..ac39e44 --- /dev/null +++ b/web/dex/migrations/0008_camera_detect_threshold_camera_habitats_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.1 on 2026-09-12 13:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0007_camera_live_enabled_camera_live_url'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='detect_threshold', + field=models.FloatField(default=0.4, help_text='Ab welcher Sicherheit etwas als Vogel gilt (0–1).', verbose_name='Schwelle Vogelerkennung'), + ), + migrations.AddField( + model_name='camera', + name='habitats', + field=models.CharField(default='garten', help_text='Kommagetrennt: garten, wald, offenland, wasser, gebirge. Schränkt ein, welche Arten der Klassifikator überhaupt erwägt — eine Kamera am Futterhaus soll gar nicht erst überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.', max_length=120, verbose_name='Lebensräume'), + ), + migrations.AddField( + model_name='camera', + name='min_bird_score', + field=models.FloatField(default=0.05, help_text='Darunter gilt die Sichtung als unbestimmt. Gemessen ist 0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, weil bei manchen Arten global die amerikanische Schwesterart gewinnt.', verbose_name='Mindestanteil heimischer Arten'), + ), + migrations.AddField( + model_name='camera', + name='stream_url', + field=models.URLField(blank=True, help_text='MJPEG- oder Snapshot-Adresse der Kamera, z.B. http://vogelkamera.local:8080/stream. Leer heißt: der Detector nimmt, was auf seiner Kommandozeile steht.', verbose_name='Stream-Adresse'), + ), + ] diff --git a/web/dex/migrations/0009_benachrichtigung.py b/web/dex/migrations/0009_benachrichtigung.py new file mode 100644 index 0000000..2765fe8 --- /dev/null +++ b/web/dex/migrations/0009_benachrichtigung.py @@ -0,0 +1,29 @@ +# Generated by Django 6.1 on 2026-09-23 20:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0008_camera_detect_threshold_camera_habitats_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Benachrichtigung', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('aktiv', models.BooleanField(default=False, verbose_name='Meldungen einschalten')), + ('server', models.URLField(default='https://ntfy.sh', help_text='Eigener Server oder ntfy.sh', verbose_name='ntfy-Server')), + ('thema', models.CharField(blank=True, help_text='Der Name, den du in der ntfy-App abonnierst. ⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das niemand errät.', max_length=100, verbose_name='Thema')), + ('sichtungen', models.CharField(choices=[('aus', 'keine Sichtungsmeldungen'), ('neue', 'nur neue Arten (Erstfang)'), ('alle', 'alle Sichtungen')], default='neue', help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft interessant.", max_length=8, verbose_name='Sichtungen')), + ('stoerungen', models.BooleanField(default=True, help_text='Kamera meldet sich nicht mehr, Erkennung steht.', verbose_name='Störungen melden')), + ('stille_minuten', models.PositiveIntegerField(default=30, help_text='So lange darf eine Kamera schweigen, bevor es eine Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem Router-Neustart.', verbose_name='Störung nach … Minuten Stille')), + ], + options={ + 'verbose_name': 'Benachrichtigung', + 'verbose_name_plural': 'Benachrichtigungen', + }, + ), + ] diff --git a/web/dex/migrations/0010_camera_letzter_kontakt.py b/web/dex/migrations/0010_camera_letzter_kontakt.py new file mode 100644 index 0000000..bc3b62a --- /dev/null +++ b/web/dex/migrations/0010_camera_letzter_kontakt.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-09-23 22:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0009_benachrichtigung'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='letzter_kontakt', + field=models.DateTimeField(blank=True, null=True, verbose_name='Letztes Lebenszeichen'), + ), + ] diff --git a/web/dex/migrations/0011_camera_live_bildrate.py b/web/dex/migrations/0011_camera_live_bildrate.py new file mode 100644 index 0000000..e69bac7 --- /dev/null +++ b/web/dex/migrations/0011_camera_live_bildrate.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-09-24 05:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0010_camera_letzter_kontakt'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='live_bildrate', + field=models.FloatField(default=2.0, help_text='Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab.', verbose_name='Bilder je Sekunde in der Vorschau'), + ), + ] diff --git a/web/dex/migrations/0012_camera_stoerung_gemeldet_seit.py b/web/dex/migrations/0012_camera_stoerung_gemeldet_seit.py new file mode 100644 index 0000000..fbcb2eb --- /dev/null +++ b/web/dex/migrations/0012_camera_stoerung_gemeldet_seit.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-01 06:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0011_camera_live_bildrate'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='stoerung_gemeldet_seit', + field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Störung gemeldet seit'), + ), + ] diff --git a/web/dex/migrations/0013_sighting_latitude_sighting_longitude.py b/web/dex/migrations/0013_sighting_latitude_sighting_longitude.py new file mode 100644 index 0000000..5cd1d9b --- /dev/null +++ b/web/dex/migrations/0013_sighting_latitude_sighting_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1 on 2026-10-01 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0012_camera_stoerung_gemeldet_seit'), + ] + + operations = [ + migrations.AddField( + model_name='sighting', + name='latitude', + field=models.FloatField(blank=True, null=True, verbose_name='Breitengrad'), + ), + migrations.AddField( + model_name='sighting', + name='longitude', + field=models.FloatField(blank=True, null=True, verbose_name='Längengrad'), + ), + ] diff --git a/web/dex/migrations/0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more.py b/web/dex/migrations/0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more.py new file mode 100644 index 0000000..0dd6fc8 --- /dev/null +++ b/web/dex/migrations/0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.1 on 2026-10-01 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0013_sighting_latitude_sighting_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='benachrichtigung', + name='fotos_gemeldet_seit', + field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Wartende Fotos gemeldet seit'), + ), + migrations.AddField( + model_name='camera', + name='mobil', + field=models.BooleanField(default=False, help_text='Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche Geräte werden nicht überwacht — sie melden sich nur, wenn jemand unterwegs fotografiert.', verbose_name='Mobiles Gerät'), + ), + migrations.AddField( + model_name='sighting', + name='wartet_auf_bestimmung', + field=models.BooleanField(db_index=True, default=False, verbose_name='Wartet auf Bestimmung'), + ), + ] diff --git a/web/dex/migrations/0015_camera_min_mean_bird_score.py b/web/dex/migrations/0015_camera_min_mean_bird_score.py new file mode 100644 index 0000000..6b6803d --- /dev/null +++ b/web/dex/migrations/0015_camera_min_mean_bird_score.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-01 14:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='camera', + name='min_mean_bird_score', + field=models.FloatField(default=0.0, help_text='Liegt der mittlere Vogelwert eines Besuchs darunter, entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn genug eigene Sichtungen vorliegen — sonst wirft man richtige Bestimmungen weg.', verbose_name='Mindest-Vogelwert über den Besuch'), + ), + ] diff --git a/web/dex/migrations/0016_vogel_box.py b/web/dex/migrations/0016_vogel_box.py new file mode 100644 index 0000000..e7cac03 --- /dev/null +++ b/web/dex/migrations/0016_vogel_box.py @@ -0,0 +1,33 @@ +# Generated by Django 6.1 on 2026-10-14 18:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dex', '0015_camera_min_mean_bird_score'), + ] + + operations = [ + migrations.AddField( + model_name='sighting', + name='box_x0', + field=models.FloatField(blank=True, null=True, verbose_name='Vogel: links'), + ), + migrations.AddField( + model_name='sighting', + name='box_x1', + field=models.FloatField(blank=True, null=True, verbose_name='Vogel: rechts'), + ), + migrations.AddField( + model_name='sighting', + name='box_y0', + field=models.FloatField(blank=True, null=True, verbose_name='Vogel: oben'), + ), + migrations.AddField( + model_name='sighting', + name='box_y1', + field=models.FloatField(blank=True, null=True, verbose_name='Vogel: unten'), + ), + ] diff --git a/web/dex/migrations/__init__.py b/web/dex/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dex/models.py b/web/dex/models.py new file mode 100644 index 0000000..fddf799 --- /dev/null +++ b/web/dex/models.py @@ -0,0 +1,957 @@ +"""Datenmodell des Vogelalbum. + +Drei Ebenen: + +* ``Species`` — der Katalog. Was es theoretisch zu finden gibt. +* ``Sighting`` — ein einzelner Besuch vor der Kamera. Rohdaten, unveränderlich. +* ``DexEntry`` — der Sammel-Fortschritt pro Art. Abgeleitet, aber gespeichert, + damit die Dex-Seite nicht bei jedem Aufruf über alle Sichtungen aggregieren + muss. +""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.db.models.functions import Coalesce +from django.utils import timezone + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +class Camera(models.Model): + """Eine Kamera, die Sichtungen meldet. + + Der Token identifiziert die Kamera — die Web-App muss sie deshalb nie + erreichen können. Der Detector meldet ausgehend, es gibt keinen Port, der + von außen offen sein müsste. Genau das erlaubt es, die Erkennung im + Heimnetz laufen zu lassen und die Sammlung öffentlich zu hosten. + + Gespeichert wird nur der SHA-256 des Tokens. Ein hochentropischer Token + braucht kein Salt, und wer die Datenbank in die Hand bekommt, kann damit + trotzdem keine Sichtungen einliefern. + """ + + name = models.CharField(max_length=80) + slug = models.SlugField(max_length=48, unique=True) + location = models.CharField("Standort", max_length=120, blank=True) + token_hash = models.CharField(max_length=64, unique=True, editable=False) + active = models.BooleanField("Aktiv", default=True) + created_at = models.DateTimeField(default=timezone.now) + last_seen = models.DateTimeField("Zuletzt gemeldet", null=True, blank=True) + + # 🔑 Getrennt von last_seen, und das ist der Kern der Sache: last_seen + # heisst "zuletzt einen Vogel gesehen". Daran zu messen, ob die Kamera + # lebt, waere falsch -- an einem ruhigen Nachmittag kommt stundenlang + # nichts, und die Anlage ist trotzdem in Ordnung. + # + # Hier steht, wann sich der Detector zuletzt gemeldet hat. Er fragt + # ohnehin alle fuenf Minuten die Konfiguration ab; dieser Abruf ist das + # Lebenszeichen. Kein zusaetzlicher Verkehr, kein neuer Endpunkt. + letzter_kontakt = models.DateTimeField( + "Letztes Lebenszeichen", null=True, blank=True) + + # ⚠ Hier und nicht im Prozessspeicher, und das ist der ganze Witz: + # `stoerungen_pruefen` ist ein Einmal-Lauf. Ein Modul-Set lebt genau + # einen Durchlauf lang und ist beim naechsten wieder leer -- die + # Entprellung griff deshalb NIE. Am 01.10.2026 kam der Beweis aufs + # Handy: nach dem Stromausfall meldete der Waechter beide Kameras alle + # zehn Minuten neu, viermal in zwei Laeufen, "schon gemeldet" kein + # einziges Mal. Genau das Summen, das der Befehl vermeiden wollte. + # + # Ein Zeitstempel statt eines Ja/Nein: so steht da auch, SEIT WANN + # gemeldet ist. Wer spaeter eine Erinnerung nach einigen Stunden will, + # braucht dafuer kein neues Feld. + stoerung_gemeldet_seit = models.DateTimeField( + "Störung gemeldet seit", null=True, blank=True, editable=False) + + # Was der Detector von hier holt statt von seiner Kommandozeile. Bis diese + # Felder existierten, stand die Einstellung nur im Startskript auf dem + # Rechner, auf dem der Detector läuft — wer die Kamera umstellen wollte, + # brauchte eine Shell. + stream_url = models.URLField( + "Stream-Adresse", + blank=True, + help_text="MJPEG- oder Snapshot-Adresse der Kamera, z.B. " + "http://vogelkamera.local:8080/stream. Leer heißt: der Detector " + "nimmt, was auf seiner Kommandozeile steht.", + ) + habitats = models.CharField( + "Lebensräume", + max_length=120, + default="garten", + help_text="Kommagetrennt: garten, wald, offenland, wasser, gebirge. " + "Schränkt ein, welche Arten der Klassifikator überhaupt " + "erwägt — eine Kamera am Futterhaus soll gar nicht erst " + "überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.", + ) + detect_threshold = models.FloatField( + "Schwelle Vogelerkennung", + default=0.4, + help_text="Ab welcher Sicherheit etwas als Vogel gilt (0–1).", + ) + min_bird_score = models.FloatField( + "Mindestanteil heimischer Arten", + default=0.05, + help_text="Darunter gilt die Sichtung als unbestimmt. Gemessen ist " + "0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, " + "weil bei manchen Arten global die amerikanische " + "Schwesterart gewinnt.", + ) + + # ⚠️ **Nicht zu verwechseln mit ``min_bird_score`` eine Zeile höher.** Die + # Namen ähneln sich, die Bedeutung ist verschieden: + # + # min_bird_score gilt je EINZELBILD + # min_mean_bird_score gilt für den DURCHSCHNITT des ganzen Besuchs + # + # 🔑 Genau dieser Durchschnitt war die Lücke. Mit ``--min-bird-frames 1`` + # genügte ein einziger Zufallstreffer unter 73 Bildern, um einen Besuch + # anzulegen — so entstand am 24.09.2026 eine „Kohlmeise" aus einer + # Befestigungsschraube (1 von 73 Bildern, Wert 0,098), und am 27.09. + # vierundzwanzig Besuche aus Spiegelungen des Mondes (Wert 0,000). + # + # 📌 **Vorgabe 0, und das ist Absicht.** Alles bei exakt null wird ohnehin + # abgewiesen; darüber hinaus will der Wert gemessen und nicht geraten sein. + # Aus den Daten vom 01.10.2026: echte Sichtungen lagen bei 0,269 bis 0,600, + # die Schraube bei 0,098, die Spiegelungen bei 0,000. Eine Grenze um 0,15 + # fienge beide Fehler ab — belegt ist das aber nur durch sieben echte + # Sichtungen, und das ist zu wenig. Wer mehr Daten hat, dreht hier hoch. + min_mean_bird_score = models.FloatField( + "Mindest-Vogelwert über den Besuch", + default=0.0, + help_text="Liegt der mittlere Vogelwert eines Besuchs darunter, " + "entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne " + "jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn " + "genug eigene Sichtungen vorliegen — sonst wirft man " + "richtige Bestimmungen weg.", + ) + + # 🔑 Ein Handy ist auch eine Kamera — es hängt nur nicht am Haus. Es lädt + # Fotos hoch statt zu streamen, bringt seinen Ort je Aufnahme mit und hat + # weder Live-Bild noch Bildeinstellungen. + # + # ⚠️ **Entscheidend für den Störungswächter.** `stoerungen_pruefen` meldet + # eine Kamera, die sich länger als `stille_minuten` nicht gemeldet hat. Ein + # Vogelhaus meldet sich alle fünf Minuten, ein Handy alle paar Tage — ohne + # dieses Kennzeichen käme nach dem ersten ruhigen Wochenende „Kamera still: + # Handy" aufs Telefon. Das ist dieselbe Fehlerklasse wie das Abzeichen für + # Mondspiegelungen am 01.10.2026: eine Regel, die für den einen Gerätetyp + # richtig ist und beim anderen Fehlalarm erzeugt. + mobil = models.BooleanField( + "Mobiles Gerät", + default=False, + help_text="Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche " + "Geräte werden nicht überwacht — sie melden sich nur, wenn " + "jemand unterwegs fotografiert.", + ) + + # Nicht die Kamera selbst, sondern der Live-Verteiler des Detectors + # (`python -m detector watch --live-port`). Die Kamera liefert nur einen + # Stream; wer direkt bei ihr zuschaut, nimmt ihn der Erkennung weg. + live_bildrate = models.FloatField( + "Bilder je Sekunde in der Vorschau", default=2.0, + help_text=( + "Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. " + "Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in " + "HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 " + "Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste " + "Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und " + "brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, " + "aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab." + ), + ) + + live_url = models.URLField( + "Live-Adresse", + blank=True, + help_text="MJPEG-Adresse des Live-Verteilers, z.B. " + "http://erkennung.local:8131/live — NICHT die Kamera selbst, " + "sonst fehlt der Erkennung das Bild.", + ) + live_enabled = models.BooleanField( + "Live-Bild zeigen", + default=False, + help_text="Zeigt das Livebild dieser Kamera auf der Seite /live/. " + "Bewusst standardmäßig aus: ein Livebild aus dem eigenen " + "Garten gehört nicht auf eine öffentliche Seite.", + ) + + class Meta: + verbose_name = "Kamera" + verbose_name_plural = "Kameras" + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + @staticmethod + def new_token() -> str: + return secrets.token_urlsafe(32) + + def set_token(self, token: str) -> None: + self.token_hash = hash_token(token) + + @classmethod + def by_token(cls, token: str) -> "Camera | None": + return cls.objects.filter(token_hash=hash_token(token), active=True).first() + + @property + def is_stale(self) -> bool: + """Kein Lebenszeichen seit über einer Stunde — da hängt vermutlich etwas. + + ⚠️ **Gemessen wird ``letzter_kontakt``, NICHT ``last_seen``.** Die + beiden Felder sehen ähnlich aus und bedeuten Verschiedenes: + ``last_seen`` heißt „zuletzt einen Vogel gesehen", ``letzter_kontakt`` + heißt „die Kamera hat sich gemeldet". An ``last_seen`` zu messen hieße + zu warnen, wenn nichts los ist, statt wenn etwas kaputt ist. + + 🔑 **Genau dieser Fehler stand hier bis zum 01.10.2026.** Die + Dev-Server zeigte das Warnzeichen vor beiden Kameras, während beide + tadellos liefen: Lebenszeichen vor einer Minute, letzter Vogel aber + 1810 bzw. 5435 Minuten her. Ein Zeichen, das fast immer steht, sagt + nichts mehr. + + ⚠️ Dass es ein bekannter Fehler ist, macht es schlimmer: In + ``stoerungen_pruefen`` steht seit jeher der Kommentar „an last_seen zu + messen waere der naheliegende Fehler -- an einem ruhigen Nachmittag + kommt stundenlang kein Vogel". Dort wurde er erkannt und behoben, hier + blieb er stehen. Eine Lehre gilt für alle Stellen, nicht nur für die, + an der sie aufgefallen ist. + + ⚠️ **Mobile Geräte sind nie „still".** Ein Handy meldet sich, wenn + jemand spazieren geht; „seit einer Stunde nichts" ist dort der + Normalfall. Dieselbe Unterscheidung trifft ``stoerungen_pruefen``. + """ + if self.mobil: + return False + # 📌 Noch nie gemeldet bleibt auffällig. Der Störungswächter wertet das + # anders — er meldet eine frisch eingerichtete Kamera nicht aufs + # Telefon, und das ist dort richtig: Es ist keine Störung. Auf der + # Seite ist es trotzdem die Wahrheit, nämlich „von hier kam noch + # nichts". Dieses Verhalten stand schon vorher so im Test + # ``test_camera_without_contact_is_stale`` und bleibt unverändert; + # korrigiert wurde am 01.10.2026 allein die Messgröße. + if self.letzter_kontakt is None: + return True + return timezone.now() - self.letzter_kontakt > timedelta(hours=1) + + @property + def shows_live(self) -> bool: + """Ob diese Kamera ein Livebild zeigen darf. + + Drei Bedingungen, und die Umgebung hat das letzte Wort: auf einer + öffentlichen Instanz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, und dann nützt + auch ein Häkchen im Admin nichts. Ein Livebild aus einem Privatgarten + soll nicht an einem Fehlklick hängen. + """ + return bool( + settings.LIVE_ALLOWED + and self.active + and self.live_enabled + and self.live_url + ) + + @property + def habitat_set(self) -> set[str]: + """Die Lebensräume als Menge. Leer heißt: alle Arten zulassen.""" + return {h.strip() for h in self.habitats.split(",") if h.strip()} + + @property + def live_bild_url(self) -> str: + """Einzelbild statt Dauerstrom. + + 🔑 Warum nicht der MJPEG-Strom in der Übersicht: Am 24.09.2026 + gemessen — eine Kamera allein erscheint sofort, zwei nebeneinander + lassen den Browser hängen. Ein Dauerstrom belegt seine Verbindung + für immer, und mit zweien kommt mancher Browser (besonders auf dem + Handy) durcheinander. + + Einzelbilder sind kurze, gewöhnliche Anfragen: Sie blockieren + nichts, holen sich nach einem Fehler von selbst wieder, und die + Bildrate bestimmt schlicht, wie oft gefragt wird. + + Für EINE Kamera bleibt der Strom die bessere Wahl -- deshalb ist + live_url weiterhin da. + """ + if not self.live_url: + return "" + return self.live_url.rstrip("/").removesuffix("/live") + "/bild.jpg" + + def konfiguration(self) -> dict: + """Was der Detector über ``/api/config/`` bekommt. + + Die Web-App ist damit die Wahrheit darüber, welche Arten zählen und + ab welcher Sicherheit — nicht mehr die Kommandozeile auf dem Rechner, + auf dem der Detector läuft. Die Artenliste kommt aus der Datenbank, + eingeschränkt auf Region und Lebensräume dieser Kamera. + """ + arten = Species.objects.all() + if self.habitat_set: + arten = arten.filter(lebensraum_typ__in=self.habitat_set) + + # Die Region wird in Python geprüft: SQLite kann kein 'contains' auf + # einem JSON-Feld, und ein Textvergleich wäre falsch — bei + # zweibuchstabigen Kürzeln findet 'de' auch 'den'. Bei einigen hundert + # Arten kostet die Schleife nichts. + region = settings.REGION + arten = [a for a in arten.order_by("scientific") if region in (a.regions or [])] + + return { + "kamera": self.slug, + "stream_url": self.stream_url, + "habitats": sorted(self.habitat_set), + "live_bildrate": self.live_bildrate, + "detect_threshold": self.detect_threshold, + "min_bird_score": self.min_bird_score, + # ⚠️ Zwei ähnlich klingende Werte, verschiedene Bedeutung: + # min_bird_score gilt je Einzelbild, min_mean_bird_score für den + # Durchschnitt des ganzen Besuchs. Der Detector braucht beide — + # den zweiten, um Besuche gar nicht erst zu senden, die die + # Web-App ohnehin abweisen würde. Das spart vor allem Funk: Ein + # Bild hochzuladen und dann verworfen zu bekommen, kostet eine + # Kamera am Gartenzaun echte Bandbreite. + "min_mean_bird_score": self.min_mean_bird_score, + "region": settings.REGION, + # Der wissenschaftliche Name ist gleichzeitig der Schlüssel ins + # Label-File des Klassifikators — deshalb braucht es hier kein + # zweites Mapping. + "arten": [ + { + "scientific": a.scientific, + "german": a.german, + "english": a.english, + "rarity": a.rarity, + "lebensraum": a.lebensraum_typ, + } + for a in arten + ], + } + + @classmethod + def live_cameras(cls): + if not settings.LIVE_ALLOWED: + return cls.objects.none() + return cls.objects.filter(active=True, live_enabled=True).exclude(live_url="") + +RARITY_CHOICES = [ + ("haeufig", "Häufig"), + ("gelegentlich", "Gelegentlich"), + ("selten", "Selten"), + ("episch", "Episch"), + ("legendaer", "Legendär"), +] + +RARITY_ORDER = {k: i for i, (k, _) in enumerate(RARITY_CHOICES)} + + +class Species(models.Model): + slug = models.SlugField(max_length=64, unique=True) + german = models.CharField("Deutscher Name", max_length=80) + scientific = models.CharField("Wissenschaftlicher Name", max_length=120) + english = models.CharField("Englischer Name", max_length=120, blank=True) + rarity = models.CharField(max_length=16, choices=RARITY_CHOICES) + xp = models.PositiveIntegerField(default=10) + fact = models.TextField("Kurzfakt", blank=True) + size_cm = models.CharField(max_length=16, blank=True) + + # Steckbrief im Stil einer Sammelkarte. Alles optional, damit neue + # Arten in den Katalog können, bevor der Text dazu geschrieben ist. + number = models.PositiveIntegerField("Dex-Nummer", default=0, db_index=True) + typ = models.CharField("Typ", max_length=24, blank=True) + kategorie = models.CharField("Kategorie", max_length=80, blank=True) + beschreibung = models.TextField("Beschreibung", blank=True) + gewicht_g = models.FloatField("Gewicht (g)", null=True, blank=True) + spannweite_cm = models.FloatField("Spannweite (cm)", null=True, blank=True) + zug = models.CharField("Zugverhalten", max_length=24, blank=True) + nahrung = models.CharField("Nahrung", max_length=200, blank=True) + lebensraum = models.CharField("Lebensraum", max_length=200, blank=True) + stimme = models.CharField("Stimme", max_length=200, blank=True) + verwechslung = models.JSONField("Verwechslungsgefahr", default=list, blank=True) + + # Wo die Art vorkommt und wo sie lebt. Die Region entscheidet, welcher + # Katalog überhaupt gilt; der Lebensraum teilt ihn in Portionen, die man + # noch überblickt. + regions = models.JSONField("Regionen", default=list, blank=True) + lebensraum_typ = models.CharField( + "Lebensraum-Gruppe", max_length=16, default="garten", db_index=True + ) + + # Englische Fassung der Texte. Bewusst eigene Felder statt einer + # Übersetzungs-Bibliothek: es sind fünf Felder in zwei Sprachen, und die + # Texte sind geschrieben, nicht übersetzt — eine wörtliche Übertragung + # liest sich in beiden Richtungen schlecht. + kategorie_en = models.CharField("Kategorie (en)", max_length=80, blank=True) + beschreibung_en = models.TextField("Beschreibung (en)", blank=True) + nahrung_en = models.CharField("Nahrung (en)", max_length=200, blank=True) + lebensraum_en = models.CharField("Lebensraum (en)", max_length=200, blank=True) + stimme_en = models.CharField("Stimme (en)", max_length=200, blank=True) + + # Referenzfoto aus Wikipedia: zeigt, wonach man Ausschau hält, bevor die + # Art zum ersten Mal selbst vor der Kamera saß. Lizenz und Urheber werden + # mitgespeichert — die Bilder stehen unter CC und wollen genannt werden. + reference_image = models.ImageField( + "Referenzfoto", upload_to="referenz/", blank=True + ) + reference_credit = models.CharField("Urheber", max_length=250, blank=True) + reference_license = models.CharField("Lizenz", max_length=80, blank=True) + reference_source = models.URLField("Quelle", max_length=400, blank=True) + + class Meta: + verbose_name = "Art" + verbose_name_plural = "Arten" + ordering = ["number", "german"] + + def __str__(self) -> str: + return self.german + + @property + def dex_nr(self) -> str: + return f"#{self.number:03d}" if self.number else "#—" + + def _lang(self, feld: str) -> str: + """Feld in der aktiven Sprache, mit Rückfall auf Deutsch. + + Der Rückfall ist Absicht: der Katalog wächst, und eine Art ohne + englischen Text soll den deutschen zeigen statt gar nichts. + """ + from django.utils.translation import get_language + + if (get_language() or "de").startswith("en"): + return getattr(self, f"{feld}_en", "") or getattr(self, feld, "") + return getattr(self, feld, "") + + @property + def name(self) -> str: + """Artname in der aktiven Sprache.""" + from django.utils.translation import get_language + + if (get_language() or "de").startswith("en") and self.english: + return self.english + return self.german + + @property + def zweitname(self) -> str: + """Der jeweils andere Name — steht klein unter dem Titel.""" + from django.utils.translation import get_language + + if (get_language() or "de").startswith("en") and self.english: + return self.german + return self.english + + @property + def kategorie_lokal(self) -> str: + return self._lang("kategorie") + + @property + def nahrung_lokal(self) -> str: + return self._lang("nahrung") + + @property + def lebensraum_lokal(self) -> str: + return self._lang("lebensraum") + + @property + def stimme_lokal(self) -> str: + return self._lang("stimme") + + @property + def zug_label(self) -> str: + from django.utils.translation import get_language + + from .species_meta import ZUG_LABEL, ZUG_LABEL_EN + + table = ( + ZUG_LABEL_EN if (get_language() or "de").startswith("en") else ZUG_LABEL + ) + return table.get(self.zug, self.zug) + + @property + def typ_label(self) -> str: + from django.utils.translation import get_language + + from .species_meta import TYPE_LABEL_EN, TYPE_STYLE + + if (get_language() or "de").startswith("en"): + return TYPE_LABEL_EN.get(self.typ, self.typ or "—") + return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[0] + + @property + def rarity_label(self) -> str: + from django.utils.translation import get_language + + from .species_meta import RARITY_LABEL_EN + + if (get_language() or "de").startswith("en"): + return RARITY_LABEL_EN.get(self.rarity, self.rarity) + return self.get_rarity_display() + + @property + def typ_color(self) -> str: + from .species_meta import TYPE_STYLE + + return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[1] + + @property + def typ_ink(self) -> str: + """Schriftfarbe auf der Typfläche — die Farben sind zu hell für Weiß.""" + from .species_meta import TYPE_STYLE + + return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[2] + + @property + def typ_vars(self) -> str: + """Alle abgeleiteten Typfarben als fertige CSS-Variablen. + + Vorberechnet statt per ``color-mix()`` im Stylesheet: das gibt es erst + ab Safari 16.2, und auf älteren Geräten fällt jede betroffene Regel + ersatzlos weg. + """ + from .species_meta import type_vars + + return type_vars(self.typ) + + @property + def absaetze(self) -> list[str]: + text = self._lang("beschreibung") + return [p.strip() for p in text.split("\n\n") if p.strip()] + + def balken(self) -> list[dict]: + """Statuswerte als Balken, wie die Werte auf einer Sammelkarte. + + Die Bezugsgrößen sind die Extreme unseres eigenen Katalogs: der + Zaunkönig mit 9 g gegen den Kolkraben mit 1200 g. Ein Balken zeigt + also „wo steht diese Art unter unseren Arten", nicht einen absoluten + Anteil — genau das macht ihn auf einen Blick lesbar. + """ + from django.utils.translation import gettext as _ + + rows = [] + + def add(label: str, value, maximum: float, einheit: str, log: bool = False): + if value in (None, "", 0): + return + import math + + if log: + pct = math.log10(value + 1) / math.log10(maximum + 1) * 100 + else: + pct = value / maximum * 100 + wert = f"{value:g} {einheit}".strip() + rows.append({"label": label, "wert": wert, "pct": max(4, min(100, round(pct)))}) + + # Gewicht logarithmisch: linear würde alles unter 100 g zu einem + # ununterscheidbaren Strich zusammenfallen. + add(_("Gewicht"), self.gewicht_g, 1200, "g", log=True) + add(_("Spannweite"), self.spannweite_cm, 125, "cm") + try: + groesse = float(str(self.size_cm).split()[0]) if self.size_cm else None + except (ValueError, IndexError): + groesse = None + add(_("Größe"), groesse, 64, "cm") + seltenheit = _("Seltenheit") + add(seltenheit, (self.rarity_rank + 1) * 20, 100, "") + if rows and rows[-1]["label"] == seltenheit: + rows[-1]["wert"] = self.rarity_label + return rows + + @property + def habitat_label(self) -> str: + from django.utils.translation import get_language + + from .species_meta import HABITATS + + paar = HABITATS.get(self.lebensraum_typ) + if not paar: + return self.lebensraum_typ + return paar[1] if (get_language() or "de").startswith("en") else paar[0] + + @property + def rarity_rank(self) -> int: + return RARITY_ORDER.get(self.rarity, 0) + + @property + def rarity_color(self) -> str: + from .species_meta import RARITY_COLOR + + return RARITY_COLOR.get(self.rarity, "#7f9b6a") + + @property + def rarity_glow(self) -> str: + from .species_meta import RARITY_GLOW + + return RARITY_GLOW.get(self.rarity, "none") + + +class SichtungsQuerySet(models.QuerySet): + """Was als Besuch zählt — an EINER Stelle festgelegt. + + 🔑 Vorher stand diese Entscheidung siebenmal im Code, jedes Mal etwas + anders: mal ``Sighting.objects.count()`` (zählt alles), mal + ``filter(species__isnull=False)`` (zählt die rohe Erkennung). Am + 30.09.2026 war das Ergebnis zu sehen — die Übersicht meldete + „31 Besuche insgesamt" bei 7 echten Vögeln und „2 Arten heute" bei + einer. Beides waren keine Rechenfehler, sondern zwei verschiedene + Vorstellungen davon, was ein Besuch ist. + + ⚠️ ``effective_species`` ist eine Python-Property und in der Datenbank + nicht vorhanden. Wer danach filtern will, muss ``Coalesce`` nehmen — + sonst fällt es erst auf, wenn eine korrigierte Sichtung weiter bei der + falschen Art mitzählt. + """ + + def mit_art(self): + """Hängt ``art_id`` an: die bestätigte Art, sonst die erkannte.""" + return self.annotate( + art_id=Coalesce("confirmed_species_id", "species_id") + ) + + def bestimmt(self): + """Nur Sichtungen mit einer Art — das sind die echten Besuche. + + 📌 Unbestimmte Sichtungen bleiben in der Liste sichtbar (sie zeigen, + dass die Kamera arbeitet), aber sie sind kein Besuch: Am 30.09.2026 + waren 24 von 31 Spiegelungen des Mondes auf der Vogeltränke. + + 📌 **Hochgeladene Fotos, die noch auf den Detector warten, fallen hier + von selbst heraus** — sie haben noch keine Art. Das ist kein Zufall, + auf den man sich verlässt, sondern dieselbe Regel: Was keine Art hat, + ist kein Besuch. Deshalb springt der Besuchszähler beim Hochladen + nicht hoch, um nach der Bestimmung wieder zurückzufallen. + """ + return self.filter( + models.Q(confirmed_species__isnull=False) + | models.Q(species__isnull=False) + ) + + def wartend(self): + """Hochgeladene Fotos, die der Detector noch nicht bearbeitet hat. + + Älteste zuerst — wer zuerst hochgeladen hat, wartet am längsten. + """ + return self.filter(wartet_auf_bestimmung=True).order_by("started_at") + + def arten_anzahl(self) -> int: + """Wie viele VERSCHIEDENE Arten — nach Bestätigung, nicht nach Modell.""" + return ( + self.bestimmt().mit_art() + .values("art_id").distinct().count() + ) + + +class Sighting(models.Model): + """Ein Besuch. Auch unbestimmte Besuche werden gespeichert — sie zeigen, + dass die Kamera arbeitet, und lassen sich später nachbestimmen. + + ⚠️ Zum Zählen immer ``objects.bestimmt()`` nehmen, nie ``objects.count()`` + — siehe :class:`SichtungsQuerySet`. + """ + + objects = SichtungsQuerySet.as_manager() + + species = models.ForeignKey( + Species, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sightings", + ) + camera = models.ForeignKey( + Camera, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sightings", + verbose_name="Kamera", + ) + confidence = models.FloatField(default=0.0) + started_at = models.DateTimeField(db_index=True) + ended_at = models.DateTimeField() + + image = models.ImageField(upload_to="sightings/%Y/%m/", blank=True) + crop = models.ImageField(upload_to="crops/%Y/%m/", blank=True) + + # Wo der Vogel war. Leer bei allem, was aus einem Vogelhaus kommt: diese + # Kamera hängt fest, ihr Standort steht am Gerät (``Camera.location``) und + # gehört nicht an jede einzelne Sichtung. + # + # 🔑 Gefüllt wird das von unterwegs — ein Foto vom Spaziergang hat keinen + # festen Platz, und erst der Ort macht daraus mehr als ein Bild. + # + # ⚠️ **Koordinaten sind personenbezogene Daten.** Wer im eigenen Garten + # fotografiert, legt damit seine Wohnadresse in die Datenbank. Deshalb wird + # schon beim Einliefern auf rund einen Kilometer gerundet (``ORT_STELLEN`` + # in ``api.py``) — die genaue Position entsteht gar nicht erst. Angezeigt + # wird sie zudem nicht von selbst. Dieselbe Überlegung wie beim Livebild + # (siehe ``Camera.live_enabled``): Was aus dem Privaten nach außen geht, + # darf nicht die Voreinstellung sein. + latitude = models.FloatField("Breitengrad", null=True, blank=True) + longitude = models.FloatField("Längengrad", null=True, blank=True) + + # 🔑 **Noch nicht bestimmt ist etwas anderes als nicht bestimmbar.** Beides + # sieht an `species` gleich aus — leer. Ohne diese Unterscheidung könnte + # der Detector nicht wissen, welche Fotos auf ihn warten, und die + # Oberfläche nicht zwischen „gleich fertig" und „da war nichts" trennen. + # + # Gesetzt wird es beim Hochladen aus der App oder über das Formular, und + # vom Detector wieder entfernt, sobald er das Bild bearbeitet hat — egal ob + # mit Ergebnis oder ohne. + # + # ⚠️ Wartende Sichtungen sind KEIN Besuch (siehe ``SichtungsQuerySet``): + # sonst springt der Zähler beim Hochladen hoch und nach der Bestimmung + # wieder zurück, wenn gar kein Vogel drauf war. + wartet_auf_bestimmung = models.BooleanField( + "Wartet auf Bestimmung", default=False, db_index=True) + + frames_total = models.PositiveIntegerField(default=0) + frames_with_bird = models.PositiveIntegerField(default=0) + votes = models.JSONField(default=dict, blank=True) + mean_bird_score = models.FloatField(default=0.0) + + # 🔑 **Wo der Vogel im Bild sitzt — als vier Zahlen, nicht als zweites + # Bild.** + # + # Bis zum 14.10.2026 speicherte das Projekt neben dem Foto einen fertig + # geschnittenen Ausschnitt. Der Zuschnitt war damit für immer festgelegt: + # Eine bessere Regel half nur neuen Sichtungen, und alle vorhandenen + # mussten einzeln nachgezogen werden. + # + # Aufgefallen ist es an Handyfotos. Dort ist der Vogel klein im Bild, der + # enge Ausschnitt entsprechend schmal und hoch — und in einer quadratischen + # Kachel blieb davon die Bauchmitte übrig, formatfüllend und unscharf. + # Das Urteil darüber, und es war das richtige: *„Mir geht es darum, dass ein + # Nutzer das so nicht verwenden kann."* + # + # Mit den Koordinaten ist der Zuschnitt eine **Regel statt einer Datei**: + # Die Anzeige schneidet bei jedem Aufruf neu aus dem Original. Eine + # geänderte Regel wirkt sofort auf den gesamten Bestand, Website und App + # dürfen verschieden schneiden, und niemand braucht den Detector dafür. + # + # Werte sind relativ (0..1), damit sie eine Verkleinerung des Originals + # überleben. Leer heißt: von vor dieser Änderung — dann gilt weiter das + # gespeicherte ``crop``. + box_x0 = models.FloatField("Vogel: links", null=True, blank=True) + box_y0 = models.FloatField("Vogel: oben", null=True, blank=True) + box_x1 = models.FloatField("Vogel: rechts", null=True, blank=True) + box_y1 = models.FloatField("Vogel: unten", null=True, blank=True) + + is_first_of_species = models.BooleanField(default=False) + xp_awarded = models.PositiveIntegerField(default=0) + + # Wer die Sammlung betreut, kann eine Bestimmung überstimmen. Das ist später die + # Trainingsmenge für ein eigenes Modell auf genau dieser Kamera. + confirmed_species = models.ForeignKey( + Species, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="confirmations", + verbose_name="Von Hand bestätigt als", + ) + + class Meta: + verbose_name = "Sichtung" + verbose_name_plural = "Sichtungen" + ordering = ["-started_at"] + indexes = [models.Index(fields=["-started_at", "species"])] + + def __str__(self) -> str: + who = self.effective_species.german if self.effective_species else "unbestimmt" + return f"{who} am {timezone.localtime(self.started_at):%d.%m. %H:%M}" + + @property + def effective_species(self) -> Species | None: + """Handkorrektur schlägt Modell.""" + return self.confirmed_species or self.species + + @property + def vogel_box(self) -> tuple[float, float, float, float] | None: + """Wo der Vogel sitzt, relativ — oder ``None`` bei Altbestand.""" + werte = (self.box_x0, self.box_y0, self.box_x1, self.box_y1) + if any(w is None for w in werte): + return None + x0, y0, x1, y1 = werte + if x1 <= x0 or y1 <= y0: + return None + return (x0, y0, x1, y1) + + @property + def kachel_url(self) -> str: + """Das Bild für eine Kachel: aus dem Original geschnitten. + + 🔑 Rückfall auf das gespeicherte ``crop`` für Sichtungen von vor der + Umstellung, danach auf das Original. So bleibt jede alte Sammlung + anzeigbar, ohne dass jemand etwas nachziehen muss. + """ + from .zuschnitt import adresse + + neu = adresse(self) + if neu: + return neu + if self.crop: + return self.crop.url + return self.image.url if self.image else "" + + @property + def duration_s(self) -> float: + return (self.ended_at - self.started_at).total_seconds() + + +class DexEntry(models.Model): + """Sammel-Fortschritt für eine Art.""" + + species = models.OneToOneField( + Species, on_delete=models.CASCADE, related_name="entry" + ) + first_seen = models.DateTimeField() + last_seen = models.DateTimeField() + count = models.PositiveIntegerField(default=0) + best_sighting = models.ForeignKey( + Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + + class Meta: + verbose_name = "Dex-Eintrag" + verbose_name_plural = "Dex-Einträge" + ordering = ["-count"] + + def __str__(self) -> str: + return f"{self.species.german} ×{self.count}" + + @property + def is_new(self) -> bool: + return timezone.now() - self.first_seen < timedelta(days=3) + + +class Badge(models.Model): + """Abzeichen. ``code`` wird von der Auswertung in ``scoring.py`` gesetzt.""" + + code = models.SlugField(max_length=48, unique=True) + name = models.CharField(max_length=80) + description = models.CharField(max_length=200) + emoji = models.CharField(max_length=8, default="🏅") + xp = models.PositiveIntegerField(default=50) + sort = models.PositiveIntegerField(default=0) + + class Meta: + verbose_name = "Abzeichen" + verbose_name_plural = "Abzeichen" + ordering = ["sort", "name"] + + def __str__(self) -> str: + return self.name + + # Abzeichen sind ein fester, im Code definierter Satz. Deshalb liegt in der + # Datenbank der deutsche Text und die Übersetzung passiert bei der Ausgabe + # — anders als bei den Arten, wo der Katalog wächst und eigene Felder + # nötig sind. Ein Sprachwechsel braucht so keine Migration. + @property + def name_lokal(self) -> str: + from django.utils.translation import gettext as _ + + return _(self.name) + + @property + def description_lokal(self) -> str: + from django.utils.translation import gettext as _ + + return _(self.description) + + +class EarnedBadge(models.Model): + badge = models.OneToOneField(Badge, on_delete=models.CASCADE, related_name="earned") + earned_at = models.DateTimeField(default=timezone.now) + sighting = models.ForeignKey( + Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + + class Meta: + verbose_name = "Verdientes Abzeichen" + verbose_name_plural = "Verdiente Abzeichen" + ordering = ["-earned_at"] + + def __str__(self) -> str: + return f"{self.badge.name} ({self.earned_at:%d.%m.%Y})" + + +class Benachrichtigung(models.Model): + """Einstellungen für Meldungen über ntfy. + + Es gibt genau einen Datensatz. Warum als Modell und nicht als Angabe in + den Einstellungsdateien: Man soll im Backend umstellen können, + ohne dass jemand die Anwendung neu startet -- und ohne dass er dafür + jemanden fragen muss. + """ + + class Sichtungen(models.TextChoices): + AUS = "aus", "keine Sichtungsmeldungen" + NEUE = "neue", "nur neue Arten (Erstfang)" + ALLE = "alle", "alle Sichtungen" + + aktiv = models.BooleanField("Meldungen einschalten", default=False) + + server = models.URLField( + "ntfy-Server", default="https://ntfy.sh", + help_text="Eigener Server oder ntfy.sh", + ) + thema = models.CharField( + "Thema", max_length=100, blank=True, + help_text="Der Name, den du in der ntfy-App abonnierst. " + "⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das " + "niemand errät.", + ) + + sichtungen = models.CharField( + "Sichtungen", max_length=8, + choices=Sichtungen.choices, default=Sichtungen.NEUE, + help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende " + "Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft " + "interessant.", + ) + + stoerungen = models.BooleanField( + "Störungen melden", default=True, + help_text="Kamera meldet sich nicht mehr, Erkennung steht.", + ) + stille_minuten = models.PositiveIntegerField( + "Störung nach … Minuten Stille", default=30, + help_text="So lange darf eine Kamera schweigen, bevor es eine " + "Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem " + "Router-Neustart.", + ) + + # ⚠️ Merker für die Meldung „Fotos warten auf Bestimmung" — dasselbe + # Muster wie ``Camera.stoerung_gemeldet_seit`` und aus demselben Grund in + # der Datenbank statt im Prozess: ``stoerungen_pruefen`` ist ein Einmal- + # Lauf, ein Merker im Speicher lebt genau einen Durchlauf. Am 01.10.2026 + # hat das eine Störung alle zehn Minuten neu aufs Telefon geschickt, bis + # der Merker hier gelandet ist. Ohne dieses Feld wäre derselbe Fehler mit + # der neuen Meldung sofort wieder da. + fotos_gemeldet_seit = models.DateTimeField( + "Wartende Fotos gemeldet seit", null=True, blank=True, editable=False) + + class Meta: + verbose_name = "Benachrichtigung" + verbose_name_plural = "Benachrichtigungen" + + def __str__(self): + return "Benachrichtigungen" + ("" if self.aktiv else " (aus)") + + @classmethod + def laden(cls): + """Den einen Datensatz holen, notfalls anlegen.""" + eintrag, _ = cls.objects.get_or_create(pk=1) + return eintrag + + @property + def ziel(self): + """Vollständige Adresse, oder leer wenn nichts eingerichtet ist.""" + if not self.thema: + return "" + return f"{self.server.rstrip('/')}/{self.thema}" diff --git a/web/dex/scoring.py b/web/dex/scoring.py new file mode 100644 index 0000000..f141b5e --- /dev/null +++ b/web/dex/scoring.py @@ -0,0 +1,423 @@ +"""Spielmechanik: XP, Ränge, Abzeichen. + +Absichtlich großzügig ausgelegt. Ein Achtjähriger, der drei Tage lang nur +Kohlmeisen bekommt, soll trotzdem vorankommen — die Seltenheitsstufen sorgen +für die Ausschläge, die Grundpunkte für den stetigen Fortschritt. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from django.db.models import Count, Sum +from django.db.models.functions import TruncDate +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_noop + +from .models import Badge, DexEntry, EarnedBadge, Sighting, Species + +# Ein Erstfang ist der eigentliche Moment — dafür der dicke Multiplikator. +FIRST_CATCH_MULTIPLIER = 3 + +RANKS = [ + gettext_noop("Nestling"), + gettext_noop("Ästling"), + gettext_noop("Spatzenkenner"), + gettext_noop("Meisenmeister"), + gettext_noop("Federfuchser"), + gettext_noop("Amselflüsterer"), + gettext_noop("Spechtspäher"), + gettext_noop("Falkenauge"), + gettext_noop("Vogelmeister"), + gettext_noop("Legende des Vogelhauses"), +] + + +def xp_for_level(level: int) -> int: + """Kumulierte XP, die für ``level`` nötig sind. Level 1 startet bei 0.""" + if level <= 1: + return 0 + return int(100 * (level - 1) ** 1.5) + + +def level_for_xp(xp: int) -> int: + level = 1 + while xp >= xp_for_level(level + 1): + level += 1 + if level > 200: # Sicherheitsnetz, nicht erreichbar + break + return level + + +def rank_name(level: int) -> str: + # Alle drei Level ein neuer Rang, oben gedeckelt. + return _(RANKS[min((level - 1) // 3, len(RANKS) - 1)]) + + +@dataclass +class Progress: + xp: int + level: int + rank: str + xp_this_level: int + xp_next_level: int + species_seen: int + species_total: int + sightings: int + badges: int + + @property + def level_progress(self) -> float: + span = self.xp_next_level - self.xp_this_level + if span <= 0: + return 1.0 + return min(1.0, (self.xp - self.xp_this_level) / span) + + @property + def xp_to_next(self) -> int: + return max(0, self.xp_next_level - self.xp) + + +def total_xp() -> int: + from_sightings = Sighting.objects.aggregate(s=Sum("xp_awarded"))["s"] or 0 + from_badges = ( + EarnedBadge.objects.aggregate(s=Sum("badge__xp"))["s"] or 0 + ) + return int(from_sightings + from_badges) + + +def progress() -> Progress: + xp = total_xp() + level = level_for_xp(xp) + return Progress( + xp=xp, + level=level, + rank=rank_name(level), + xp_this_level=xp_for_level(level), + xp_next_level=xp_for_level(level + 1), + species_seen=DexEntry.objects.count(), + species_total=Species.objects.count(), + sightings=Sighting.objects.bestimmt().count(), + badges=EarnedBadge.objects.count(), + ) + + +# -------------------------------------------------------------------------- +# Abzeichen +# -------------------------------------------------------------------------- + +BADGE_DEFS = [ + ("erster_gast", gettext_noop("Erster Gast"), + gettext_noop("Die allererste Sichtung im Vogelhaus."), "🐣", 50, 10), + ("arten_5", gettext_noop("Fünf Freunde"), + gettext_noop("5 verschiedene Arten gesammelt."), "🖐️", 75, 20), + ("arten_10", gettext_noop("Zweistellig"), + gettext_noop("10 verschiedene Arten gesammelt."), "🔟", 150, 21), + ("arten_25", gettext_noop("Sammler"), + gettext_noop("25 verschiedene Arten gesammelt."), "📚", 400, 22), + ("arten_40", gettext_noop("Vogelalbum-Meister"), + gettext_noop("40 verschiedene Arten gesammelt."), "👑", 1000, 23), + ("fruehaufsteher", gettext_noop("Frühaufsteher"), + gettext_noop("Eine Sichtung vor 6 Uhr morgens."), "🌅", 100, 30), + ("nachtschicht", gettext_noop("Nachtschicht"), + gettext_noop("Eine Sichtung nach 21 Uhr."), "🌙", 100, 31), + ("volles_haus", gettext_noop("Volles Haus"), + gettext_noop("5 verschiedene Arten an einem Tag."), "🏠", 200, 40), + ("stammgast", gettext_noop("Stammgast"), + gettext_noop("7 Tage in Folge mindestens eine Sichtung."), "🔥", 250, 41), + ("dauergast", gettext_noop("Dauergast"), + gettext_noop("Ein Besuch, der länger als eine Minute dauerte."), "⏱️", 100, 42), + ("besucher_100", gettext_noop("Hundert Besuche"), + gettext_noop("100 Sichtungen aufgezeichnet."), "💯", 200, 50), + ("besucher_1000", gettext_noop("Tausend Besuche"), + gettext_noop("1000 Sichtungen aufgezeichnet."), "🏆", 750, 51), + ("klopfgeist", gettext_noop("Klopfgeist"), + gettext_noop("Ein Specht war da."), "🪵", 250, 60), + ("greifvogel_alarm", gettext_noop("Greifvogel-Alarm"), + gettext_noop("Ein Greifvogel oder eine Eule vor der Linse."), "🦅", 500, 61), + ("seltener_gast", gettext_noop("Seltener Gast"), + gettext_noop("Eine Art der Stufe 'Selten' oder höher."), "💎", 200, 62), +] + + +def sync_badges() -> int: + """Legt fehlende Abzeichen an. Idempotent — läuft bei jeder Migration mit.""" + created = 0 + for code, name, desc, emoji, xp, sort in BADGE_DEFS: + _, was_created = Badge.objects.update_or_create( + code=code, + defaults={ + "name": name, + "description": desc, + "emoji": emoji, + "xp": xp, + "sort": sort, + }, + ) + created += was_created + + # Codes, die es nicht mehr gibt, verschwinden — sonst bleibt nach einer + # Umbenennung die alte Fassung als Leiche in der Liste stehen. + bekannt = {code for code, *_ in BADGE_DEFS} + Badge.objects.exclude(code__in=bekannt).delete() + return created + + +def _award(code: str, sighting: Sighting | None = None) -> Badge | None: + badge = Badge.objects.filter(code=code).first() + if badge is None or EarnedBadge.objects.filter(badge=badge).exists(): + return None + EarnedBadge.objects.create(badge=badge, sighting=sighting) + return badge + + +def _has_streak(days: int) -> bool: + today = timezone.localdate() + seen = { + timezone.localtime(dt).date() + for dt in Sighting.objects.bestimmt().filter( + started_at__gte=timezone.now() - timedelta(days=days + 1) + ).values_list("started_at", flat=True) + } + return all((today - timedelta(days=i)) in seen for i in range(days)) + + +# 🔑 Die Schwellen stehen hier EINMAL und werden an zwei Stellen benutzt: +# beim Vergeben (evaluate_badges) und beim Anzeigen des Fortschritts +# (fortschritt_je_abzeichen). Zwei getrennte Listen wuerden frueher oder +# spaeter auseinanderlaufen, und dann zeigt der Balken etwas anderes an, +# als das Abzeichen verlangt. +ARTEN_SCHWELLEN = ((5, "arten_5"), (10, "arten_10"), + (25, "arten_25"), (40, "arten_40")) +SICHTUNGS_SCHWELLEN = ((100, "besucher_100"), (1000, "besucher_1000")) +TAGESARTEN_SCHWELLE = 5 # volles_haus +STRAEHNE_SCHWELLE = 7 # stammgast + + +def bildguete(sichtung) -> float: + """Wie brauchbar ist das Foto dieser Sichtung? Groesser ist besser. + + 🔑 **Nicht die Erkennungssicherheit.** Die sagt, wie sicher sich das + Modell bei der ART ist -- nicht, ob das Bild etwas taugt. Am + 30.09.2026 wurde dadurch eine verwischte Aufnahme zum "besten Foto" + der Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel + und der Kopf nicht zu erkennen. Die schoene scharfe Aufnahme (0,936, + 23 von 67) landete dahinter. + + Stattdessen zwei Groessen, die beide messen "der Vogel war wirklich + und deutlich da": + + Anteil der Bilder mit Vogel er blieb, statt kurz durchzuhuschen + mittlerer Vogel-Wert er war deutlich zu sehen + + ⚠️ **Das ist ein Hilfsmass, keine Schaerfemessung.** Richtig waere, + die Schaerfe des gespeicherten Bildes zu messen (Laplace-Varianz im + Detector). Das hier kommt ohne Bildverarbeitung aus und trifft in der + Praxis dieselbe Wahl. + """ + gesamt = sichtung.frames_total or 0 + anteil = (sichtung.frames_with_bird or 0) / gesamt if gesamt else 0.0 + wert = sichtung.mean_bird_score or 0.0 + guete = anteil * wert + # Voellig ohne Messwerte (alte Sichtungen) bleibt nur die Sicherheit -- + # besser als gar keine Reihenfolge. + return guete if guete > 0 else (sichtung.confidence or 0.0) / 1000 + + +def beste_sichtung(sichtungen): + """Die Sichtung mit dem brauchbarsten Foto, oder None.""" + sichtungen = list(sichtungen) + return max(sichtungen, key=bildguete) if sichtungen else None + + +def fortschritt_je_abzeichen() -> dict[str, tuple[int, int]]: + """Wie weit ist man bei den ZAEHLBAREN Abzeichen? code -> (ist, ziel). + + ⚠️ Absichtlich nicht fuer alle: "Ein Specht war da" oder "Eine Sichtung + vor 6 Uhr" haben keinen Fortschritt -- sie passieren oder nicht. Ein + Balken auf 0 % waere dort keine Information, sondern nur entmutigend. + + Aus der Rückmeldung am 26.09.2026: *"Bei den Abzeichen waer ein Fortschrittsbalken + cool."* + """ + stand: dict[str, tuple[int, int]] = {} + + arten = DexEntry.objects.count() + for ziel, code in ARTEN_SCHWELLEN: + stand[code] = (arten, ziel) + + sichtungen = Sighting.objects.bestimmt().count() + for ziel, code in SICHTUNGS_SCHWELLEN: + stand[code] = (sichtungen, ziel) + + # Der beste Tag bisher -- nicht der heutige. Sonst faellt der Balken + # jede Nacht auf null zurueck, obwohl man schon vier Arten hatte. + bester_tag = ( + Sighting.objects.bestimmt().mit_art() + .annotate(tag=TruncDate("started_at")) + .values("tag") + .annotate(n=Count("art_id", distinct=True)) + .order_by("-n") + .first() + ) + stand["volles_haus"] = (bester_tag["n"] if bester_tag else 0, + TAGESARTEN_SCHWELLE) + + stand["stammgast"] = (_laengste_straehne(), STRAEHNE_SCHWELLE) + return stand + + +def _laengste_straehne() -> int: + """Die laengste Folge von Tagen mit mindestens einer Sichtung.""" + tage = sorted({ + timezone.localtime(z).date() + for z in Sighting.objects.bestimmt().values_list("started_at", flat=True) + }) + if not tage: + return 0 + beste = laufend = 1 + for vorher, jetzt in zip(tage, tage[1:]): + laufend = laufend + 1 if (jetzt - vorher).days == 1 else 1 + beste = max(beste, laufend) + return beste + + +def evaluate_badges(sighting: Sighting) -> list[Badge]: + """Prüft nach einer Sichtung alle Abzeichen. Gibt die neu verdienten zurück. + + ⚠️ **Eine unbestimmte Sichtung ist kein Besuch und verdient nichts.** Das + musste hier eigens hin, weil die Prüfungen weiter unten verschiedener + Herkunft sind: die auf Gesamtzahlen (``bestimmt()``, ``arten_anzahl()``) + ändern sich durch eine unbestimmte Sichtung ohnehin nicht, und ab + ``effective_species`` wird sauber geprüft — aber die Abzeichen für Uhrzeit + und Dauer hingen allein am Zeitstempel und wurden deshalb auch für Bilder + ohne Vogel vergeben. + + 🔑 Am 27.09.2026 ist genau das passiert: Die Vogeltränke meldete abends + 24 Spiegelungen des Mondes auf dem Wasser, und die erste davon brachte + „Nachtschicht" samt 100 XP ein. Jede weitere Spiegelung nach 21 Uhr hätte + es erneut getan. + """ + if sighting.effective_species is None: + return [] + + earned: list[Badge] = [] + + def add(code: str) -> None: + badge = _award(code, sighting) + if badge: + earned.append(badge) + + if Sighting.objects.bestimmt().count() == 1: + add("erster_gast") + + species_count = DexEntry.objects.count() + for threshold, code in ARTEN_SCHWELLEN: + if species_count >= threshold: + add(code) + + local = timezone.localtime(sighting.started_at) + if local.hour < 6: + add("fruehaufsteher") + if local.hour >= 21: + add("nachtschicht") + + if sighting.duration_s > 60: + add("dauergast") + + total = Sighting.objects.bestimmt().count() + for schwelle, code in SICHTUNGS_SCHWELLEN: + if total >= schwelle: + add(code) + + day_species = ( + Sighting.objects.filter(started_at__date=local.date()).arten_anzahl() + ) + if day_species >= TAGESARTEN_SCHWELLE: + add("volles_haus") + + if _has_streak(STRAEHNE_SCHWELLE): + add("stammgast") + + species = sighting.effective_species + if species: + if "specht" in species.slug: + add("klopfgeist") + if species.rarity in ("episch", "legendaer"): + add("greifvogel_alarm") + if species.rarity in ("selten", "episch", "legendaer"): + add("seltener_gast") + + return earned + + +# -------------------------------------------------------------------------- +# Aufnahme einer neuen Sichtung +# -------------------------------------------------------------------------- + + +@dataclass +class Reward: + sighting: Sighting + xp: int + is_first: bool + badges: list[Badge] + level_before: int + level_after: int + + @property + def leveled_up(self) -> bool: + return self.level_after > self.level_before + + +def register(sighting: Sighting) -> Reward: + """Verbucht eine frisch gespeicherte Sichtung: XP, Dex-Eintrag, Abzeichen. + + Erwartet, dass ``sighting`` bereits in der DB liegt (die Badge-Prüfungen + zählen über die Tabelle). + """ + level_before = level_for_xp(total_xp()) + species = sighting.effective_species + + xp = 0 + is_first = False + if species is not None: + entry = DexEntry.objects.filter(species=species).first() + is_first = entry is None + + xp = species.xp * (FIRST_CATCH_MULTIPLIER if is_first else 1) + # Eine sichere Bestimmung ist mehr wert als eine wackelige. + xp = int(round(xp * (0.5 + 0.5 * min(1.0, sighting.confidence)))) + + if entry is None: + DexEntry.objects.create( + species=species, + first_seen=sighting.started_at, + last_seen=sighting.started_at, + count=1, + best_sighting=sighting, + ) + else: + entry.count += 1 + entry.last_seen = max(entry.last_seen, sighting.started_at) + # 🔑 Nicht nach Sicherheit, sondern nach Bildguete -- siehe dort. + best = entry.best_sighting + if best is None or bildguete(sighting) > bildguete(best): + entry.best_sighting = sighting + entry.save(update_fields=["count", "last_seen", "best_sighting"]) + + sighting.is_first_of_species = is_first + sighting.xp_awarded = xp + sighting.save(update_fields=["is_first_of_species", "xp_awarded"]) + + badges = evaluate_badges(sighting) + return Reward( + sighting=sighting, + xp=xp, + is_first=is_first, + badges=badges, + level_before=level_before, + level_after=level_for_xp(total_xp()), + ) diff --git a/web/dex/species_meta.py b/web/dex/species_meta.py new file mode 100644 index 0000000..010b9bc --- /dev/null +++ b/web/dex/species_meta.py @@ -0,0 +1,170 @@ +"""Darstellungs-Metadaten zu den Seltenheitsstufen. + +Bewusst hier dupliziert statt aus ``detector.species_de`` importiert: die +Web-App soll ohne numpy und LiteRT laufen können, damit der Container klein +bleibt und sich unabhängig vom Detector deployen lässt. +""" + +# Regionen. Nur "de" ist mit Daten gefüllt — die Struktur steht, damit eine +# zweite Region später kein Umbau ist, sondern ein Datensatz. +REGIONS = { + "de": ("Deutschland", "Germany"), +} + +# Lebensraum: die Achse, nach der sich 148 Arten sinnvoll teilen lassen. +# Ohne sie ersäuft die Gartensammlung in Wasservögeln. +HABITATS = { + "garten": ("Garten & Siedlung", "Gardens & settlements"), + "wald": ("Wald", "Woodland"), + "offenland": ("Feld & Wiese", "Fields & meadows"), + "wasser": ("Wasser & Ufer", "Water & shoreline"), + "gebirge": ("Gebirge", "Mountains"), +} + +RARITY_COLOR = { + "haeufig": "#7f9b6a", + "gelegentlich": "#4f8fbf", + "selten": "#9b7ef0", + "episch": "#e0851f", + "legendaer": "#d4af37", +} + +# Typ -> (Anzeigename, Fläche, Schrift). Gespiegelt aus +# detector/species_info.py, damit die Web-App ohne den Detector auskommt. +TYPE_STYLE = { + "meise": ("Meise", "#f2c744", "#3b2f00"), + "fink": ("Fink", "#e0574f", "#fff1f0"), + "sperling": ("Sperling", "#b08347", "#2e1f0c"), + "drossel": ("Drossel", "#7a5c3e", "#f5ece3"), + "schnaepper": ("Schnäpper", "#ef8354", "#3b1a0a"), + "grasmuecke": ("Grasmücke", "#8fae5d", "#1f2a10"), + "specht": ("Specht", "#d64545", "#fff0f0"), + "rabenvogel": ("Rabenvogel", "#8b7fd4", "#161233"), + "taube": ("Taube", "#a8a5b8", "#26242e"), + "schwalbe": ("Schwalbe", "#5aa9e6", "#0c2338"), + "greifvogel": ("Greifvogel", "#c9963f", "#33240a"), + "eule": ("Eule", "#5c6bc0", "#f0f1ff"), + "klettervogel": ("Klettervogel", "#4db6ac", "#0c2b28"), + "buschvogel": ("Buschvogel", "#9c7b5c", "#f6efe8"), + "stelze": ("Stelze", "#cfd8dc", "#22282a"), + "ammer": ("Ammer", "#e5c04a", "#332a06"), + # Gruppen, die es im Garten nicht gibt — gespiegelt aus + # detector/species_extra.py. Fehlen sie hier, rendert die Karte einen + # Strich statt eines Typs. + "schreitvogel": ("Schreitvogel", "#6b8f8a", "#f0f6f5"), + "ente": ("Ente", "#3f7d9e", "#eef7fb"), + "gans": ("Gans", "#8d9aa6", "#151b20"), + "moewe": ("Möwe", "#7fa8c9", "#101c26"), + "watvogel": ("Watvogel", "#b3966b", "#241c0e"), + "taucher": ("Taucher", "#4a7a8c", "#eef6f9"), + "ralle": ("Ralle", "#5f6b5a", "#eef2ec"), + "lerche": ("Lerche", "#c2a878", "#241d0d"), + "wuerger": ("Würger", "#9a6f8f", "#f7eef5"), + "huhn": ("Hühnervogel", "#a4703f", "#f8efe6"), + "exot": ("Exot", "#22a3a3", "#06302f"), +} + +ZUG_LABEL = { + "stand": "Standvogel", + "teil": "Teilzieher", + "zug": "Zugvogel", +} + +ZUG_LABEL_EN = { + "stand": "Resident", + "teil": "Partial migrant", + "zug": "Migrant", +} + +# Typnamen auf Englisch. Bewusst die gängigen Gruppenbegriffe, nicht die +# wörtliche Übertragung — „Schnäpper" heißt hier Chat, „Buschvogel" Skulker. +TYPE_LABEL_EN = { + "meise": "Tit", "fink": "Finch", "sperling": "Sparrow", "drossel": "Thrush", + "schnaepper": "Chat", "grasmuecke": "Warbler", "specht": "Woodpecker", + "rabenvogel": "Corvid", "taube": "Pigeon", "schwalbe": "Swallow", + "greifvogel": "Raptor", "eule": "Owl", "klettervogel": "Climber", + "buschvogel": "Skulker", "stelze": "Wagtail", "ammer": "Bunting", + "schreitvogel": "Long-legged", "ente": "Duck", "gans": "Goose", + "moewe": "Gull", "watvogel": "Shorebird", "taucher": "Grebe", + "ralle": "Rail", "lerche": "Lark", "wuerger": "Shrike", + "huhn": "Gamebird", "exot": "Exotic", +} + +RARITY_LABEL_EN = { + "haeufig": "Common", "gelegentlich": "Occasional", "selten": "Rare", + "episch": "Epic", "legendaer": "Legendary", +} + +def _hex(c: str) -> tuple[int, int, int]: + c = c.lstrip("#") + return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16) + + +def mix(color: str, other: str, ratio: float) -> str: + """Mischt zwei Hex-Farben. Ersatz für CSS ``color-mix()``. + + ``color-mix()`` gibt es erst ab Safari 16.2 — auf älteren Geräten fällt + jede Regel, die es benutzt, ersatzlos weg, und die Seite bricht auseinander. + Vorberechnen kostet nichts und funktioniert überall. + + ``ratio`` ist der Anteil von ``color``. + """ + a, b = _hex(color), _hex(other) + return "#" + "".join( + f"{round(a[i] * ratio + b[i] * (1 - ratio)):02x}" for i in range(3) + ) + + +# Oberflächen, gegen die gemischt wird — müssen zu den Werten in vogelalbum.css +# passen. Je Modus ein Satz. +LIGHT_SURFACE_SOFT = "#f4f7f2" +LIGHT_LINE = "#dfe6dd" +LIGHT_INK = "#1a2a20" + +DARK_SURFACE_SOFT = "#1b2129" +DARK_LINE = "#2f3945" +DARK_PAPER = "#141920" + + +def type_vars(typ: str) -> str: + """Alle abgeleiteten Typfarben als CSS-Variablen. Landet als ``style=""``. + + Es werden **beide** Modi ausgegeben, mit den Endungen ``-l`` und ``-d``. + Das Stylesheet entscheidet über eine Indirektion, welcher Satz gilt: + + :root { --typ-wash: var(--typ-wash-l); } + [data-theme="dark"] { --typ-wash: var(--typ-wash-d); } + + Das funktioniert, weil Custom Properties erst am Verwendungsort aufgelöst + werden — dort sind ``--typ-wash-l/-d`` von der Karte geerbt. Nötig ist der + Umweg, weil ein Inline-Style sich nicht per Stylesheet überschreiben lässt. + + Die Rollen: ``--typ`` die reine Farbe für Flächen und Bänder, ``--typ-text`` + für Text (auf Weiß abgedunkelt, auf Dunkel aufgehellt), ``--typ-wash`` als + blasser Hintergrund, ``--typ-line`` für Rahmen. + """ + label, farbe, ink = TYPE_STYLE.get(typ, ("—", "#7f9b6a", "#ffffff")) + return ( + f"--typ:{farbe};" + f"--typ-ink:{ink};" + # hell + f"--typ-text-l:{mix(farbe, LIGHT_INK, 0.62)};" + f"--typ-wash-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.13)};" + f"--typ-card-l:{mix(farbe, LIGHT_SURFACE_SOFT, 0.22)};" + f"--typ-line-l:{mix(farbe, LIGHT_LINE, 0.45)};" + # dunkel — dort muss die Farbe aufgehellt werden, um lesbar zu bleiben, + # und die Flächen viel zurückhaltender sein, sonst blendet es. + f"--typ-text-d:{mix(farbe, '#ffffff', 0.80)};" + f"--typ-wash-d:{mix(farbe, DARK_SURFACE_SOFT, 0.14)};" + f"--typ-card-d:{mix(farbe, DARK_PAPER, 0.20)};" + f"--typ-line-d:{mix(farbe, DARK_LINE, 0.42)};" + ) + + +RARITY_GLOW = { + "haeufig": "none", + "gelegentlich": "none", + "selten": "0 0 24px -6px #9b7ef0", + "episch": "0 0 28px -4px #e0851f", + "legendaer": "0 0 34px -2px #d4af37", +} diff --git a/web/dex/templatetags/__init__.py b/web/dex/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/dex/templatetags/dex_extras.py b/web/dex/templatetags/dex_extras.py new file mode 100644 index 0000000..b6ca663 --- /dev/null +++ b/web/dex/templatetags/dex_extras.py @@ -0,0 +1,134 @@ +"""Template-Helfer.""" + +from __future__ import annotations + +from pathlib import Path + +from django import template + +from dex.models import Species + +register = template.Library() + + +@register.filter +def alternativen(votes: dict, limit: int = 3) -> list[dict]: + """Die Zweit- und Drittplatzierten einer Sichtung. + + Der Detector speichert das Abstimmungsergebnis über den ganzen Besuch. Wer + knapp verloren hat, ist die interessante Information: genau da lohnt der + Blick aufs Foto, und genau da hilft eine Handkorrektur dem späteren + eigenen Modell am meisten. + """ + if not isinstance(votes, dict) or len(votes) < 2: + return [] + + ranked = sorted(votes.items(), key=lambda kv: kv[1], reverse=True)[1 : limit + 1] + by_slug = { + s.slug: s for s in Species.objects.filter(slug__in=[slug for slug, _ in ranked]) + } + return [ + {"species": by_slug[slug], "anteil": round(score * 100)} + for slug, score in ranked + if slug in by_slug and score > 0 + ] + + +@register.filter +def zuglabel(code: str) -> str: + from dex.species_meta import ZUG_LABEL + + return ZUG_LABEL.get(code, code) + + +@register.filter +def prozent(value: float | None) -> int: + """0.973 -> 97. Spart im Template das widthratio-Geraffel.""" + try: + return round(float(value) * 100) + except (TypeError, ValueError): + return 0 + + +@register.filter +def zeitpunkt(wert) -> str: + """Ein Besuchszeitpunkt, wie ihn ein Mensch nennen würde. + + ``heute, 10:05`` · ``gestern, 10:05`` · ``Sonntag, 10:05`` · und ab einer + Woche das volle Datum. + + ⚠️ Nicht durch ``|date:"d.m."`` ersetzen: Das Format ist deutsch + verdrahtet und steht auf der englischen Fassung falsch herum. ``date_format`` + nimmt die Schreibweise aus der aktiven Sprache. + """ + from django.utils import timezone + from django.utils.formats import date_format + from django.utils.translation import gettext as _ + + if not wert: + return "" + + ortszeit = timezone.localtime(wert) + tage = (timezone.localdate() - ortszeit.date()).days + uhr = date_format(ortszeit, "TIME_FORMAT") + + if tage <= 0: + return _("heute, %(uhrzeit)s") % {"uhrzeit": uhr} + if tage == 1: + return _("gestern, %(uhrzeit)s") % {"uhrzeit": uhr} + if tage < 7: + return _("%(wochentag)s, %(uhrzeit)s") % { + "wochentag": date_format(ortszeit, "l"), + "uhrzeit": uhr, + } + return _("%(datum)s, %(uhrzeit)s") % { + "datum": date_format(ortszeit, "DATE_FORMAT"), + "uhrzeit": uhr, + } + + +@register.simple_tag +def static_v(pfad: str) -> str: + """Wie ``static``, aber mit dem Änderungszeitpunkt der Datei als Anhang. + + Ohne diesen Stempel behält ein Browser das alte Stylesheet, obwohl der + Server längst das neue ausliefert — im Entwicklungsbetrieb liefert Django + nur ein ``Last-Modified`` mit, und danach darf ein Browser heuristisch + cachen. Das hat schon eine Stunde Fehlersuche gekostet, an der nichts + kaputt war. + + Im Betrieb steckt der Hash über ``ManifestStaticFilesStorage`` bereits im + Dateinamen; dann ändert dieser Anhang nichts und stört auch nicht. + """ + from django.contrib.staticfiles import finders + from django.templatetags.static import static + + url = static(pfad) + treffer = finders.find(pfad) + if not treffer: + return url + if isinstance(treffer, (list, tuple)): + treffer = treffer[0] + try: + stempel = int(Path(treffer).stat().st_mtime) + except OSError: + return url + trenner = "&" if "?" in url else "?" + return f"{url}{trenner}v={stempel}" + + +@register.simple_tag +def abzeichen_bild(code: str) -> str: + """Die Adresse des Pins zu einem Abzeichen — oder leer, wenn es keinen gibt. + + 🔑 Die Vorlage soll nicht raten müssen. Ein Abzeichen, das später + dazukommt, hat noch kein Bild; ohne diese Prüfung stünde dort ein + kaputtes Bildsymbol, und zwar genau auf der Seite, die Belohnungen zeigt. + Fehlt der Pin, faellt die Vorlage auf das Emoji zurueck. + """ + from django.contrib.staticfiles import finders + + pfad = f"img/pins/{code}.png" + if not finders.find(pfad): + return "" + return static_v(pfad) diff --git a/web/dex/test_app_api.py b/web/dex/test_app_api.py new file mode 100644 index 0000000..f96383c --- /dev/null +++ b/web/dex/test_app_api.py @@ -0,0 +1,644 @@ +"""Tests für die Lese-Schnittstelle der Handy-App. + +Konzentriert auf das, was leise falsch sein kann: der Zugang (ein zu weit +geöffneter Endpunkt fällt niemandem auf), die Handbestimmung (die App würde +sonst dauerhaft die korrigierte Art verschweigen), die Warteschleife nach +einem Upload (ein falsches ``wartet`` dreht endlos) und die Bildadressen +(relativ ausgeliefert ist in einer App schlicht kein Bild). +""" + +from __future__ import annotations + +import json +from datetime import timedelta + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.utils import timezone + +from . import scoring +from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species +from .tests import make_camera, make_species, make_sighting + +# Ein 1x1-PNG. Reicht, weil wir die Adresse prüfen, nicht das Bild. +PNG = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00" + b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" +) + +LESEN = "lese-token-fuer-tests" + + +def bild(name="v.png") -> SimpleUploadedFile: + return SimpleUploadedFile(name, PNG, content_type="image/png") + + +@override_settings(LESE_TOKEN=LESEN) +class ZugangTests(TestCase): + """Wer darf lesen — und vor allem: wer nicht.""" + + def setUp(self): + make_camera(token="kamera-geheim") + + def hole(self, token=None): + kopf = {} + if token is not None: + kopf["HTTP_AUTHORIZATION"] = f"Bearer {token}" + return self.client.get("/api/app/start/", **kopf) + + def test_ohne_header_abgelehnt(self): + self.assertEqual(self.hole().status_code, 401) + + def test_falscher_token_abgelehnt(self): + self.assertEqual(self.hole("nein").status_code, 401) + + def test_lese_token_darf(self): + self.assertEqual(self.hole(LESEN).status_code, 200) + + def test_kamera_token_darf_auch(self): + """Die App soll mit EINEM Geheimnis auskommen — dem ihrer Kamera.""" + self.assertEqual(self.hole("kamera-geheim").status_code, 200) + + def test_header_ohne_bearer_abgelehnt(self): + resp = self.client.get("/api/app/start/", HTTP_AUTHORIZATION=LESEN) + self.assertEqual(resp.status_code, 401) + + @override_settings(LESE_TOKEN="") + def test_leerer_lese_token_heisst_zu_nicht_offen(self): + """⚠️ Die Regel aus api.py gilt hier genauso. + + Ein leerer Token ist ein vergessenes Geheimnis, kein Freibrief. Ohne + diese Prüfung stünde die Sammlung im Netz, sobald jemand die + Umgebungsvariable nicht setzt — und ein leerer Bearer-Header käme + durch. + """ + self.assertEqual(self.hole("").status_code, 401) + self.assertEqual(self.hole("irgendwas").status_code, 401) + # Die Kamera bleibt der Weg hinein, auch ohne Lese-Token. + self.assertEqual(self.hole("kamera-geheim").status_code, 200) + + def test_alle_endpunkte_sind_geschuetzt(self): + """Ein neuer Endpunkt ohne Prüfung ist der Fehler, der nie auffällt.""" + art = make_species() + s = make_sighting(art) + for pfad in ( + "/api/app/start/", + "/api/app/album/", + f"/api/app/album/{art.slug}/", + "/api/app/sichtungen/", + f"/api/app/sichtungen/{s.id}/", + "/api/app/abzeichen/", + ): + with self.subTest(pfad=pfad): + self.assertEqual(self.client.get(pfad).status_code, 401) + + +@override_settings(LESE_TOKEN=LESEN) +class AppTestBasis(TestCase): + def setUp(self): + scoring.sync_badges() + + def json(self, pfad, **params): + resp = self.client.get( + pfad, params, HTTP_AUTHORIZATION=f"Bearer {LESEN}" + ) + self.assertEqual(resp.status_code, 200, resp.content[:400]) + return json.loads(resp.content) + + +class AlbumTests(AppTestBasis): + def setUp(self): + super().setUp() + self.kohlmeise = make_species( + slug="kohlmeise", german="Kohlmeise", number=3, + lebensraum_typ="garten", typ="meise", + ) + self.blaumeise = make_species( + slug="blaumeise", german="Blaumeise", scientific="Cyanistes caeruleus", + number=1, lebensraum_typ="garten", typ="meise", + ) + self.reiher = make_species( + slug="graureiher", german="Graureiher", scientific="Ardea cinerea", + number=2, lebensraum_typ="wasser", typ="schreitvogel", + ) + + def test_vorgabe_ist_garten(self): + """Ohne Angabe nur der Garten — 148 Arten sind eine Wand, kein Ziel.""" + daten = self.json("/api/app/album/") + self.assertEqual(daten["lebensraum"], "garten") + self.assertEqual( + {a["slug"] for a in daten["arten"]}, {"kohlmeise", "blaumeise"} + ) + + def test_alle_holt_jeden_lebensraum(self): + daten = self.json("/api/app/album/", lebensraum="alle") + self.assertEqual(len(daten["arten"]), 3) + self.assertEqual(daten["lebensraum"], "") + + def test_sortierung_nach_albumnummer_nicht_nach_fund(self): + """Die Nummer ist die Seitenzahl und bleibt, wo sie ist.""" + scoring.register(make_sighting(self.kohlmeise)) # Nummer 3, gefunden + daten = self.json("/api/app/album/", lebensraum="alle") + self.assertEqual( + [a["nummer"] for a in daten["arten"]], [1, 2, 3] + ) + + def test_ungefundene_art_ist_schatten_ohne_foto(self): + daten = self.json("/api/app/album/") + seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise") + self.assertFalse(seite["gefunden"]) + self.assertNotIn("foto", seite) + self.assertNotIn("anzahl", seite) + + def test_gefundene_art_bringt_zaehler_und_foto(self): + s = make_sighting(self.kohlmeise) + s.image = bild() + s.save() + scoring.register(s) + + daten = self.json("/api/app/album/") + seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise") + self.assertTrue(seite["gefunden"]) + self.assertEqual(seite["anzahl"], 1) + self.assertTrue(seite["neu"]) + self.assertTrue(seite["foto"].startswith("http://")) + + def test_zaehler_je_lebensraum(self): + scoring.register(make_sighting(self.blaumeise)) + daten = self.json("/api/app/album/") + self.assertEqual(daten["gefunden"], 1) + self.assertEqual(daten["gesamt"], 2) + + garten = next( + h for h in daten["lebensraeume"] if h["code"] == "garten" + ) + wasser = next( + h for h in daten["lebensraeume"] if h["code"] == "wasser" + ) + self.assertEqual((garten["gefunden"], garten["gesamt"]), (1, 2)) + self.assertEqual((wasser["gefunden"], wasser["gesamt"]), (0, 1)) + + def test_leere_lebensraeume_fehlen_in_den_reitern(self): + codes = {h["code"] for h in self.json("/api/app/album/")["lebensraeume"]} + self.assertEqual(codes, {"garten", "wasser"}) + + def test_farben_kommen_mit(self): + """Die App soll die Palette nicht nachbauen müssen.""" + seite = next( + a for a in self.json("/api/app/album/")["arten"] + if a["slug"] == "kohlmeise" + ) + self.assertEqual(seite["typ_farbe"], "#f2c744") + self.assertEqual(seite["seltenheit_farbe"], "#7f9b6a") + + +class ArtTests(AppTestBasis): + def setUp(self): + super().setUp() + self.art = make_species( + number=5, gewicht_g=18.0, spannweite_cm=24.0, size_cm="14 cm", + verwechslung=["blaumeise"], + ) + make_species(slug="blaumeise", german="Blaumeise", number=6) + + def test_unbekannte_art_ist_404(self): + resp = self.client.get( + "/api/app/album/gibtsnicht/", HTTP_AUTHORIZATION=f"Bearer {LESEN}" + ) + self.assertEqual(resp.status_code, 404) + + def test_steckbrief_und_balken(self): + daten = self.json(f"/api/app/album/{self.art.slug}/") + self.assertEqual(daten["art"]["slug"], "kohlmeise") + self.assertTrue(daten["art"]["balken"]) + labels = {b["label"] for b in daten["art"]["balken"]} + self.assertIn("Gewicht", labels) + + def test_verwechslung_in_katalogreihenfolge(self): + daten = self.json(f"/api/app/album/{self.art.slug}/") + self.assertEqual( + [a["slug"] for a in daten["verwechslung"]], ["blaumeise"] + ) + + def test_blaettern_zeigt_die_nachbarn(self): + daten = self.json(f"/api/app/album/{self.art.slug}/") + self.assertIsNone(daten["blaettern"]["vorher"]) + self.assertEqual(daten["blaettern"]["nachher"], "blaumeise") + + def test_tagesverlauf_hat_24_stunden_in_ortszeit(self): + wann = timezone.localtime(timezone.now()).replace(hour=6, minute=31) + make_sighting(self.art, when=wann) + + daten = self.json(f"/api/app/album/{self.art.slug}/") + self.assertEqual(len(daten["tagesverlauf"]), 24) + self.assertEqual(daten["tagesverlauf"][6]["anzahl"], 1) + self.assertEqual(daten["tagesverlauf"][6]["anteil"], 1.0) + + def test_handbestimmung_zaehlt_als_besuch_dieser_art(self): + """Eine Korrektur im Admin muss in der App ankommen.""" + andere = make_species(slug="amsel", german="Amsel", number=9) + s = make_sighting(andere) + s.confirmed_species = self.art + s.save() + + daten = self.json(f"/api/app/album/{self.art.slug}/") + self.assertEqual(len(daten["besuche"]), 1) + self.assertEqual(daten["besuche"][0]["art"]["slug"], "kohlmeise") + self.assertTrue(daten["besuche"][0]["handbestimmt"]) + + +class SichtungenTests(AppTestBasis): + def setUp(self): + super().setUp() + self.art = make_species() + self.kamera = make_camera(token="kamera-geheim") + + def test_juengste_zuerst(self): + jetzt = timezone.now() + alt = make_sighting(self.art, when=jetzt - timedelta(hours=2)) + neu = make_sighting(self.art, when=jetzt) + + daten = self.json("/api/app/sichtungen/") + self.assertEqual([s["id"] for s in daten["sichtungen"]], [neu.id, alt.id]) + + def test_nur_bestimmt_blendet_artlose_aus(self): + make_sighting(self.art) + make_sighting(None) + + self.assertEqual(self.json("/api/app/sichtungen/")["gesamt"], 2) + self.assertEqual( + self.json("/api/app/sichtungen/", nur_bestimmt="1")["gesamt"], 1 + ) + + def test_filter_nach_art(self): + andere = make_species(slug="amsel", german="Amsel") + make_sighting(self.art) + make_sighting(andere) + daten = self.json("/api/app/sichtungen/", art="amsel") + self.assertEqual(daten["gesamt"], 1) + self.assertEqual(daten["sichtungen"][0]["art"]["slug"], "amsel") + + def test_filter_nach_kamera(self): + s = make_sighting(self.art) + s.camera = self.kamera + s.save() + make_sighting(self.art) + + self.assertEqual( + self.json("/api/app/sichtungen/", kamera=self.kamera.slug)["gesamt"], 1 + ) + + def test_seiten(self): + for _ in range(5): + make_sighting(self.art) + erste = self.json("/api/app/sichtungen/", anzahl=2) + self.assertEqual(len(erste["sichtungen"]), 2) + self.assertTrue(erste["weiter"]) + + letzte = self.json("/api/app/sichtungen/", anzahl=2, seite=3) + self.assertEqual(len(letzte["sichtungen"]), 1) + self.assertFalse(letzte["weiter"]) + + def test_anzahl_ist_gedeckelt(self): + """Ein Feed über Mobilfunk darf nicht alles auf einmal ziehen.""" + daten = self.json("/api/app/sichtungen/", anzahl=5000) + self.assertEqual(daten["pro_seite"], 60) + + def test_unsinnige_seite_fuehrt_nicht_zum_fehler(self): + make_sighting(self.art) + daten = self.json("/api/app/sichtungen/", seite="haha", anzahl="auch") + self.assertEqual(daten["seite"], 1) + self.assertEqual(daten["pro_seite"], 24) + + def test_art_bringt_ihr_referenzbild_mit(self): + """Damit eine Sichtung ohne eigenes Foto nicht als Fragezeichen endet. + + Gefunden am gerenderten Feed: Die Demo-Sichtungen hatten keine Bilder, + und die App zeigte neben "Haussperling" ein Fragezeichen — obwohl sie + den Vogel abbilden könnte. + """ + self.art.reference_image.name = "referenz/kohlmeise.jpg" + self.art.save(update_fields=["reference_image"]) + make_sighting(self.art) + + zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] + self.assertTrue(zeile["art"]["referenzbild"].startswith("http://")) + + def test_bildadressen_sind_absolut(self): + s = make_sighting(self.art) + s.image = bild("ganz.png") + s.crop = bild("aus.png") + s.save() + + zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] + self.assertTrue(zeile["foto"].startswith("http://")) + self.assertTrue(zeile["ausschnitt"].startswith("http://")) + + def test_bildadressen_folgen_dem_proxy_auf_https(self): + """🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst + jede Bildadresse mit ``http://``. + + Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist + es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu + folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das + es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht + im Test. + """ + s = make_sighting(self.art) + s.image = bild("ganz.png") + s.save() + self.art.reference_image.name = "referenz/kohlmeise.jpg" + self.art.save(update_fields=["reference_image"]) + + resp = self.client.get( + "/api/app/sichtungen/", + HTTP_AUTHORIZATION=f"Bearer {LESEN}", + # Genau der Header, den Traefik setzt. + HTTP_X_FORWARDED_PROTO="https", + secure=False, + ) + self.assertEqual(resp.status_code, 200) + zeile = json.loads(resp.content)["sichtungen"][0] + self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"]) + self.assertTrue( + zeile["art"]["referenzbild"].startswith("https://"), + zeile["art"]["referenzbild"], + ) + + def test_bildadressen_sind_absolut(self): + s = make_sighting(self.art) + s.image = bild("ganz.png") + s.crop = bild("aus.png") + s.save() + + zeile = self.json("/api/app/sichtungen/")["sichtungen"][0] + self.assertTrue(zeile["foto"].startswith("http://")) + self.assertTrue(zeile["ausschnitt"].startswith("http://")) + + def test_bildadressen_folgen_dem_proxy_auf_https(self): + """🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst + jede Bildadresse mit ``http://``. + + Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist + es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu + folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das + es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht + im Test. + """ + s = make_sighting(self.art) + s.image = bild("ganz.png") + s.save() + self.art.reference_image.name = "referenz/kohlmeise.jpg" + self.art.save(update_fields=["reference_image"]) + + resp = self.client.get( + "/api/app/sichtungen/", + HTTP_AUTHORIZATION=f"Bearer {LESEN}", + # Genau der Header, den Traefik setzt. + HTTP_X_FORWARDED_PROTO="https", + secure=False, + ) + self.assertEqual(resp.status_code, 200) + zeile = json.loads(resp.content)["sichtungen"][0] + self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"]) + self.assertTrue( + zeile["art"]["referenzbild"].startswith("https://"), + zeile["art"]["referenzbild"], + ) + + def test_abzeichenbilder_folgen_dem_proxy_auf_https(self): + resp = self.client.get( + "/api/app/abzeichen/", + HTTP_AUTHORIZATION=f"Bearer {LESEN}", + HTTP_X_FORWARDED_PROTO="https", + ) + pins = [z["bild"] for z in json.loads(resp.content)["abzeichen"] if z["bild"]] + self.assertTrue(pins) + self.assertTrue(all(p.startswith("https://") for p in pins), pins[:2]) + + +class EinzelneSichtungTests(AppTestBasis): + """Der Endpunkt, mit dem die App nach einem Upload nachfragt.""" + + def setUp(self): + super().setUp() + self.art = make_species() + + def test_unbekannte_kennung_ist_404(self): + resp = self.client.get( + "/api/app/sichtungen/9999/", HTTP_AUTHORIZATION=f"Bearer {LESEN}" + ) + self.assertEqual(resp.status_code, 404) + + def test_wartendes_foto_meldet_wartet(self): + jetzt = timezone.now() + s = Sighting.objects.create( + started_at=jetzt, ended_at=jetzt, frames_total=1, + wartet_auf_bestimmung=True, + ) + daten = self.json(f"/api/app/sichtungen/{s.id}/") + self.assertTrue(daten["wartet"]) + self.assertIsNone(daten["art"]) + + def test_foto_ohne_vogel_endet_die_warteschleife(self): + """⚠️ ``wartet: false`` mit ``art: null`` ist die Antwort, kein Fehler. + + Ohne diesen Fall dreht die App für ein Foto vom eigenen Daumen endlos. + """ + jetzt = timezone.now() + s = Sighting.objects.create( + started_at=jetzt, ended_at=jetzt, frames_total=1, + wartet_auf_bestimmung=False, + ) + daten = self.json(f"/api/app/sichtungen/{s.id}/") + self.assertFalse(daten["wartet"]) + self.assertIsNone(daten["art"]) + self.assertNotIn("album", daten) + + def test_bestimmtes_foto_bringt_album_und_fortschritt(self): + s = make_sighting(self.art) + scoring.register(s) + + daten = self.json(f"/api/app/sichtungen/{s.id}/") + self.assertFalse(daten["wartet"]) + self.assertEqual(daten["art"]["slug"], "kohlmeise") + self.assertTrue(daten["neue_art"]) + self.assertEqual(daten["punkte"], 30) # 10 XP x 3 beim Erstfang + self.assertTrue(daten["album"]["gefunden"]) + self.assertEqual(daten["fortschritt"]["arten_gesammelt"], 1) + + +class StartTests(AppTestBasis): + def setUp(self): + super().setUp() + self.art = make_species() + self.kamera = make_camera(token="kamera-geheim", name="Futterhaus") + + def test_leerer_stand_ist_kein_fehler(self): + """Bens Album ist am ersten Tag leer — und die App muss starten.""" + daten = self.json("/api/app/start/") + self.assertIsNone(daten["letzte_sichtung"]) + self.assertEqual(daten["neueste_arten"], []) + self.assertEqual(daten["fortschritt"]["punkte"], 0) + self.assertEqual(daten["fortschritt"]["stufe"], 1) + + def test_letzte_sichtung_ueberspringt_unbestimmtes(self): + """Eine Mondspiegelung ist kein Besuch und gehört nicht auf die Startseite.""" + jetzt = timezone.now() + echt = make_sighting(self.art, when=jetzt - timedelta(hours=1)) + make_sighting(None, when=jetzt) + + daten = self.json("/api/app/start/") + self.assertEqual(daten["letzte_sichtung"]["id"], echt.id) + + def test_wartende_fotos_werden_gezaehlt(self): + jetzt = timezone.now() + Sighting.objects.create( + started_at=jetzt, ended_at=jetzt, frames_total=1, + wartet_auf_bestimmung=True, + ) + self.assertEqual(self.json("/api/app/start/")["wartende_fotos"], 1) + + def test_kameras_ohne_geheimnisse(self): + daten = self.json("/api/app/start/") + kamera = daten["kameras"][0] + self.assertEqual(kamera["name"], "Futterhaus") + # ⚠️ Kein Token, kein Live-Bild, keine Geräteadresse — die App ist + # kein Wartungswerkzeug. + for verboten in ("token", "token_hash", "bild_url", "geraet_url", + "stream_url", "live_url"): + self.assertNotIn(verboten, kamera) + + def test_neueste_arten_sind_die_letzten_erstfaenge(self): + zweite = make_species(slug="amsel", german="Amsel", number=2) + scoring.register(make_sighting(self.art)) + scoring.register(make_sighting(zweite)) + + daten = self.json("/api/app/start/") + self.assertEqual( + [a["slug"] for a in daten["neueste_arten"]], ["amsel", "kohlmeise"] + ) + + +class AbzeichenTests(AppTestBasis): + def test_verdient_und_offen(self): + art = make_species() + scoring.register(make_sighting(art)) + + daten = self.json("/api/app/abzeichen/") + self.assertEqual(daten["gesamt"], Badge.objects.count()) + self.assertGreaterEqual(daten["verdient"], 1) + + verdiente = [z for z in daten["abzeichen"] if z["verdient"]] + self.assertTrue(all(z["am"] for z in verdiente)) + # 🔑 Verdiente Abzeichen haben keinen Balken — der stuende dauerhaft + # auf 100 % und sagt nichts. + self.assertTrue(all("anteil" not in z for z in verdiente)) + + def test_abzeichen_liefern_den_pin_nicht_nur_das_emoji(self): + """🔑 Die Website zeigt gezeichnete Emaille-Pins, die App muss dieselben + zeigen. + + Der Anlass ist derselbe, aus dem die Pins überhaupt entstanden sind: + Auf einem System ohne Emoji-Schrift steht statt des Abzeichens ein + leeres Rechteck. Beim Ansehen der gerenderten App stand genau das da, + fünfzehnmal untereinander. + """ + daten = self.json("/api/app/abzeichen/") + mit_pin = [z for z in daten["abzeichen"] if z["bild"]] + self.assertTrue(mit_pin, "kein einziges Abzeichen mit Pin") + self.assertTrue(mit_pin[0]["bild"].startswith("http://")) + self.assertIn("/img/pins/", mit_pin[0]["bild"]) + # ⚠️ Das Emoji bleibt als Rückfall erhalten — ein später + # hinzugefügtes Abzeichen hat noch keinen Pin. + self.assertTrue(all(z["symbol"] for z in daten["abzeichen"])) + + def test_abzeichen_ohne_pin_liefert_bild_none_statt_kaputter_adresse(self): + from .models import Badge + + Badge.objects.create( + code="ganz-neu", name="Ganz neu", description="Kommt später.", emoji="🆕" + ) + daten = self.json("/api/app/abzeichen/") + neu = next(z for z in daten["abzeichen"] if z["code"] == "ganz-neu") + self.assertIsNone(neu["bild"]) + self.assertEqual(neu["symbol"], "🆕") + + def test_offene_zaehlbare_abzeichen_haben_einen_anteil(self): + art = make_species() + scoring.register(make_sighting(art)) + + daten = self.json("/api/app/abzeichen/") + mit_balken = [ + z for z in daten["abzeichen"] if not z["verdient"] and "anteil" in z + ] + self.assertTrue(mit_balken, "kein offenes Abzeichen mit Fortschritt") + for z in mit_balken: + self.assertLessEqual(z["anteil"], 1.0) + self.assertLessEqual(z["ist"], z["ziel"]) + + + + +class VogelOhneArtTests(AppTestBasis): + """🔴 „Vogel ja, Art nein" darf nicht als „kein Vogel" ankommen. + + Am 14.10.2026 erkannte der Detektor auf einem Handyfoto mit 93 % einen + Vogel, konnte die Art aber nicht bestimmen (Gegenlicht, Silhouette). In + der App stand „Kein Vogel erkannt. Auf dem Foto war nichts zu finden" — + und daraufhin wurde zugeschnitten, gegen ein Problem, das nie der + Zuschnitt war. Eine falsche Auskunft ist teurer als gar keine. + """ + + def setUp(self): + super().setUp() + self.kamera = make_camera(token="kamera-geheim", mobil=True) + + def _wartendes_foto(self): + jetzt = timezone.now() + s = Sighting.objects.create( + camera=self.kamera, started_at=jetzt, ended_at=jetzt, + frames_total=1, wartet_auf_bestimmung=True, + ) + return s + + def melde(self, s, nutzlast): + return self.client.post( + f"/api/offen/{s.id}/", + {"data": json.dumps(nutzlast)}, + HTTP_AUTHORIZATION="Bearer kamera-geheim", + ) + + def test_vogel_ohne_art_behaelt_seinen_vogelwert(self): + s = self._wartendes_foto() + resp = self.melde(s, {"species": None, "confidence": 0.0, + "mean_bird_score": 0.93}) + self.assertEqual(resp.status_code, 200, resp.content) + + daten = self.json(f"/api/app/sichtungen/{s.id}/") + self.assertFalse(daten["wartet"]) + self.assertIsNone(daten["art"]) + # Das ist der Unterschied, an dem die App ihren Text waehlt. + self.assertAlmostEqual(daten["vogelwert"], 0.93, places=3) + + def test_wirklich_kein_vogel_bleibt_bei_null(self): + s = self._wartendes_foto() + self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.0}) + + daten = self.json(f"/api/app/sichtungen/{s.id}/") + self.assertIsNone(daten["art"]) + self.assertEqual(daten["vogelwert"], 0.0) + + def test_vogel_ohne_art_zaehlt_als_bild_mit_vogel(self): + """``frames_with_bird`` hing an der ART statt am Vogel.""" + s = self._wartendes_foto() + self.melde(s, {"species": None, "mean_bird_score": 0.93}) + s.refresh_from_db() + self.assertEqual(s.frames_with_bird, 1) + + def test_ohne_vogel_kein_bild_mit_vogel(self): + s = self._wartendes_foto() + self.melde(s, {"species": None, "mean_bird_score": 0.0}) + s.refresh_from_db() + self.assertEqual(s.frames_with_bird, 0) diff --git a/web/dex/test_bilder.py b/web/dex/test_bilder.py new file mode 100644 index 0000000..8f7c264 --- /dev/null +++ b/web/dex/test_bilder.py @@ -0,0 +1,231 @@ +"""Tests für das Säubern hochgeladener Bilder. + +🔑 Der Kern ist **nicht** „erkennt kaputte Dateien", sondern **„gibt nur +weiter, was wirklich ein Bild war"**. Eine Prüfung lässt alles stehen, was sie +nicht kennt; das Neuschreiben nimmt nur die Pixel mit. Die Tests hier zielen +deshalb auf das, was NICHT mehr in der gespeicherten Datei steht. +""" + +from __future__ import annotations + +import io + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from PIL import Image + +from .bilder import MAX_KANTE, KeinBild, saeubern + + +def bild_mit_exif(gps=True, drehung=None, groesse=(80, 50)) -> SimpleUploadedFile: + """Ein JPEG mit EXIF — mit Ort, wie es ein Handy liefert.""" + im = Image.new("RGB", groesse, (90, 140, 70)) + exif = im.getexif() + exif[0x010E] = "Testaufnahme" # ImageDescription + if drehung is not None: + exif[0x0112] = drehung # Orientation + if gps: + # 52,52 N / 13,405 O. Pillow erwartet Grad, Minuten, Sekunden als + # einfache Zahlen — ein verschachteltes Zähler/Nenner-Paar lehnt es ab. + exif[0x8825] = { + 1: "N", + 2: (52.0, 31.0, 12.0), + 3: "E", + 4: (13.0, 24.0, 18.0), + } + + puffer = io.BytesIO() + im.save(puffer, format="JPEG", exif=exif) + return SimpleUploadedFile("urlaub.jpg", puffer.getvalue(), "image/jpeg") + + +def einfaches_bild(format="PNG", groesse=(40, 40), modus="RGB") -> SimpleUploadedFile: + im = Image.new(modus, groesse, (10, 20, 30) if modus == "RGB" else 128) + puffer = io.BytesIO() + im.save(puffer, format=format) + endung = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}[format] + return SimpleUploadedFile(f"v.{endung}", puffer.getvalue(), f"image/{endung}") + + +def gelesen(datei) -> Image.Image: + datei.seek(0) + return Image.open(io.BytesIO(datei.read())) + + +class SaeubernTests(TestCase): + def test_exif_verschwindet_vollstaendig(self): + """🔴 Der eigentliche Zweck: Der Ort darf nicht öffentlich werden. + + Ein Handyfoto trägt GPS — bei dieser App den Garten, in dem ein Kind + steht. ``/media/`` ist ohne Anmeldung erreichbar. + """ + roh = bild_mit_exif(gps=True) + # Gegenprobe: im Original IST der Ort drin. + self.assertTrue(gelesen(roh).getexif().get_ifd(0x8825)) + + sauber = saeubern(roh) + exif = gelesen(sauber).getexif() + self.assertFalse(exif.get_ifd(0x8825), "GPS steht noch im Bild") + self.assertFalse(dict(exif), f"EXIF steht noch im Bild: {dict(exif)}") + + def test_drehung_wird_angewandt_nicht_weggeworfen(self): + """⚠️ EXIF wegwerfen ohne zu drehen legt jedes Hochkantfoto um. + + Der Fehler fiele erst beim Anzeigen auf, weit weg von der Ursache. + """ + # Orientation 6 heißt: um 90° gedreht anzeigen. Also wird aus einem + # 80x50-Bild ein 50x80-Bild. + roh = bild_mit_exif(gps=False, drehung=6, groesse=(80, 50)) + sauber = saeubern(roh) + self.assertEqual(gelesen(sauber).size, (50, 80)) + + def test_ohne_drehungsangabe_bleibt_die_groesse(self): + roh = bild_mit_exif(gps=False, drehung=None, groesse=(80, 50)) + self.assertEqual(gelesen(saeubern(roh)).size, (80, 50)) + + def test_ergebnis_ist_immer_jpeg(self): + for format in ("PNG", "JPEG", "WEBP"): + with self.subTest(format=format): + sauber = saeubern(einfaches_bild(format)) + self.assertEqual(gelesen(sauber).format, "JPEG") + self.assertTrue(sauber.name.endswith(".jpg")) + + def test_durchsichtigkeit_wird_auf_weiss_gelegt(self): + """Ein PNG mit Alphakanal lässt sich nicht als JPEG schreiben.""" + im = Image.new("RGBA", (20, 20), (255, 0, 0, 0)) + puffer = io.BytesIO() + im.save(puffer, format="PNG") + datei = SimpleUploadedFile("t.png", puffer.getvalue(), "image/png") + + sauber = saeubern(datei) + self.assertEqual(gelesen(sauber).mode, "RGB") + + def test_grosse_bilder_werden_verkleinert(self): + sauber = saeubern(einfaches_bild(groesse=(4000, 3000))) + breite, hoehe = gelesen(sauber).size + self.assertEqual(max(breite, hoehe), MAX_KANTE) + # Seitenverhältnis bleibt. + self.assertAlmostEqual(breite / hoehe, 4 / 3, places=2) + + def test_kleine_bilder_werden_nicht_aufgeblasen(self): + self.assertEqual(gelesen(saeubern(einfaches_bild(groesse=(40, 40)))).size, (40, 40)) + + def test_keine_bilddatei_wird_abgewiesen(self): + datei = SimpleUploadedFile("boese.jpg", b"GIF89a", "image/jpeg") + with self.assertRaises(KeinBild): + saeubern(datei) + + def test_leere_datei_wird_abgewiesen(self): + with self.assertRaises(KeinBild): + saeubern(SimpleUploadedFile("leer.jpg", b"", "image/jpeg")) + + def test_pfadanteile_im_namen_verschwinden(self): + """⚠️ Ein hochgeladener Name darf keine Verzeichnisse mitbringen.""" + datei = einfaches_bild() + datei.name = "../../etc/passwd.png" + self.assertEqual(saeubern(datei).name, "passwd.jpg") + + def test_riesige_abmessungen_werden_abgewiesen(self): + """Ein winziges Bild kann beim Öffnen Gigabyte belegen.""" + from . import bilder + + with self.settings(): + alt = bilder.MAX_PIXEL + bilder.MAX_PIXEL = 100 # 10x10 + try: + with self.assertRaises(KeinBild): + saeubern(einfaches_bild(groesse=(40, 40))) + finally: + bilder.MAX_PIXEL = alt + + +@override_settings(LESE_TOKEN="lesen") +class UploadWegTests(TestCase): + """Dass das Säubern auch wirklich in den Upload-Wegen steckt.""" + + def setUp(self): + from .tests import make_camera + + self.kamera = make_camera(token="kamera-geheim", mobil=True) + + def test_app_upload_speichert_ohne_exif(self): + resp = self.client.post( + "/api/hochladen/", + {"bilder": bild_mit_exif(gps=True)}, + HTTP_AUTHORIZATION="Bearer kamera-geheim", + ) + self.assertEqual(resp.status_code, 201, resp.content) + + from .models import Sighting + + s = Sighting.objects.get(id=resp.json()["angelegt"][0]) + with s.image.open("rb") as f: + exif = Image.open(f).getexif() + self.assertFalse(exif.get_ifd(0x8825), "GPS kam durch den Upload") + + def test_app_upload_weist_unbrauchbares_ab(self): + resp = self.client.post( + "/api/hochladen/", + {"bilder": SimpleUploadedFile("x.jpg", b"kein bild", "image/jpeg")}, + HTTP_AUTHORIZATION="Bearer kamera-geheim", + ) + self.assertEqual(resp.status_code, 400) + + from .models import Sighting + + # ⚠️ Und es bleibt KEINE halbe Sichtung stehen. + self.assertEqual(Sighting.objects.count(), 0) + + def test_mehrere_bilder_entweder_alle_oder_keins(self): + """Sonst weiß der Hochladende nicht, was angekommen ist.""" + resp = self.client.post( + "/api/hochladen/", + {"bilder": [ + einfaches_bild(), + SimpleUploadedFile("kaputt.jpg", b"nein", "image/jpeg"), + ]}, + HTTP_AUTHORIZATION="Bearer kamera-geheim", + ) + self.assertEqual(resp.status_code, 400) + + from .models import Sighting + + self.assertEqual(Sighting.objects.count(), 0) + + +class HeicTests(TestCase): + """🔴 iPhones fotografieren HEIC — eine App für iOS muss es annehmen. + + Am 14.10.2026 wies die Instanz jedes Foto vom Telefon mit 400 ab, Minuten + nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur kannte + sie das Format nicht, das die Hälfte aller Telefone erzeugt. + """ + + def heic(self, groesse=(400, 300)) -> SimpleUploadedFile: + im = Image.new("RGB", groesse, (90, 140, 70)) + puffer = io.BytesIO() + im.save(puffer, format="HEIF") + return SimpleUploadedFile("IMG_0042.HEIC", puffer.getvalue(), "image/heic") + + def test_heic_wird_angenommen_und_zu_jpeg(self): + from .bilder import HEIC_LESBAR + + self.assertTrue(HEIC_LESBAR, "pillow-heif fehlt — iPhone-Fotos gehen nicht") + sauber = saeubern(self.heic()) + self.assertEqual(gelesen(sauber).format, "JPEG") + self.assertTrue(sauber.name.endswith(".jpg")) + + def test_heic_verliert_seine_metadaten_genauso(self): + sauber = saeubern(self.heic()) + self.assertFalse(dict(gelesen(sauber).getexif())) + + def test_heic_ueber_den_app_weg(self): + from .tests import make_camera + + make_camera(token="kamera-geheim", mobil=True) + resp = self.client.post( + "/api/hochladen/", + {"bilder": self.heic()}, + HTTP_AUTHORIZATION="Bearer kamera-geheim", + ) + self.assertEqual(resp.status_code, 201, resp.content) diff --git a/web/dex/test_einstellungen.py b/web/dex/test_einstellungen.py new file mode 100644 index 0000000..c834473 --- /dev/null +++ b/web/dex/test_einstellungen.py @@ -0,0 +1,101 @@ +"""Dass die Absicherung im Betrieb auch wirklich anspringt. + +🔑 **Warum das einen eigenen Test braucht.** Die Einstellungen hängen an +``DEBUG=0``, und in der Entwicklung *und in den Tests* ist ``DJANGO_DEBUG`` +standardmäßig ``1``. Die ganze Absicherung läuft also in keinem einzigen +normalen Testlauf — sie stünde richtig in der Datei und wäre nie geprüft. +Genau die Sorte Sicherheitsnetz, die stumm kaputt ist. + +Deshalb hier die Einstellungsdatei einmal frisch mit ``DJANGO_DEBUG=0`` +einlesen und nachsehen, was dabei herauskommt. +""" + +from __future__ import annotations + +import importlib +import os +from unittest import mock + +from django.test import SimpleTestCase + + +def betriebs_einstellungen(): + """Lädt ``vogelalbum.settings`` neu, als liefe es im Betrieb.""" + umgebung = { + "DJANGO_DEBUG": "0", + # Im Betrieb ist ein eigener Schlüssel Pflicht, sonst bricht der + # Import ab — hier einer, der nur in diesem Test lebt. + "DJANGO_SECRET_KEY": "nur-fuer-diesen-test-" + "x" * 40, + } + with mock.patch.dict(os.environ, umgebung): + modul = importlib.import_module("vogelalbum.settings") + return importlib.reload(modul) + + +class BetriebsEinstellungenTests(SimpleTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.betrieb = betriebs_einstellungen() + + @classmethod + def tearDownClass(cls): + # ⚠️ Zurückladen, sonst sieht jeder nachfolgende Test die + # Betriebsfassung — und scheitert an Dingen, die mit ihm nichts zu + # tun haben. + import vogelalbum.settings + + importlib.reload(vogelalbum.settings) + super().tearDownClass() + + def test_debug_ist_aus(self): + self.assertFalse(self.betrieb.DEBUG) + + def test_cookies_nur_ueber_https(self): + """🔴 Der Kern: Die Umleitung auf https kommt zu spät. + + Traefik antwortet mit 307 — da hat der Browser das Cookie aber schon + mitgeschickt. Erst ``Secure`` hält ihn davon ab. + """ + self.assertTrue(self.betrieb.SESSION_COOKIE_SECURE) + self.assertTrue(self.betrieb.CSRF_COOKIE_SECURE) + + def test_hsts_ist_gesetzt(self): + self.assertGreaterEqual(self.betrieb.SECURE_HSTS_SECONDS, 31536000) + + def test_hsts_nicht_fuer_fremde_namen(self): + """Keine Zusagen für Namen, die es nicht gibt, und kein preload. + + ``includeSubDomains`` gilt auch für Namen, die noch niemand angelegt + hat, und ``preload`` nimmt einem die Entscheidung aus der Hand: Aus + fremden Listen kommt man nur langsam wieder heraus. + """ + self.assertFalse(self.betrieb.SECURE_HSTS_INCLUDE_SUBDOMAINS) + self.assertFalse(self.betrieb.SECURE_HSTS_PRELOAD) + + def test_proxy_kopf_wird_ausgewertet(self): + """Ohne das baut build_absolute_uri() http://-Adressen.""" + self.assertEqual( + self.betrieb.SECURE_PROXY_SSL_HEADER, + ("HTTP_X_FORWARDED_PROTO", "https"), + ) + + def test_referrer_bleibt_im_haus(self): + self.assertEqual(self.betrieb.SECURE_REFERRER_POLICY, "same-origin") + + def test_in_der_entwicklung_gilt_nichts_davon(self): + """⚠️ Sonst kann sich auf http://localhost niemand mehr anmelden. + + Ein ``Secure``-Cookie wird über einfaches HTTP gar nicht gesetzt — die + Anmeldung schlüge fehl, und die Ursache wäre ausgerechnet die + Einstellung, die Sicherheit bringen soll. + """ + from django.conf import settings + + # ⚠️ ``settings.DEBUG`` taugt hier NICHT als Probe: Der Testläufer + # setzt es grundsätzlich auf False, egal was die Datei gerechnet hat. + # Entscheidend ist, was der ``if not DEBUG``-Block beim IMPORT gesehen + # hat — und das war ``DJANGO_DEBUG=1``. Also die Flaggen prüfen. + self.assertFalse(getattr(settings, "SESSION_COOKIE_SECURE", False)) + self.assertFalse(getattr(settings, "CSRF_COOKIE_SECURE", False)) + self.assertEqual(getattr(settings, "SECURE_HSTS_SECONDS", 0), 0) diff --git a/web/dex/test_zuschnitt.py b/web/dex/test_zuschnitt.py new file mode 100644 index 0000000..0173b22 --- /dev/null +++ b/web/dex/test_zuschnitt.py @@ -0,0 +1,149 @@ +"""Tests für die Zuschnitt-Regel. + +🔑 Der Kern der Umstellung: Der Ausschnitt ist eine **Regel**, keine Datei. +Deshalb prüfen diese Tests nicht „ein Bild entsteht", sondern die Eigenschaft, +wegen der wir umgebaut haben — dass sich der Zuschnitt für den ganzen Bestand +ändern lässt, auch rückwirkend. +""" + +from __future__ import annotations + +import io +from datetime import timedelta + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.utils import timezone +from PIL import Image + +from .models import Sighting +from .zuschnitt import quadrat + + +def foto(groesse=(1800, 2400)) -> SimpleUploadedFile: + im = Image.new("RGB", groesse, (120, 160, 200)) + puffer = io.BytesIO() + im.save(puffer, format="JPEG") + return SimpleUploadedFile("v.jpg", puffer.getvalue(), "image/jpeg") + + +class QuadratTests(TestCase): + """Die Rechnung allein — ohne Dateien, ohne Datenbank.""" + + def test_ergebnis_ist_quadratisch(self): + """Weil die Kachel quadratisch ist. Sonst schneidet sie den Vogel ab.""" + x0, y0, x1, y1 = quadrat((0.40, 0.30, 0.52, 0.62), 1800, 2400) + self.assertEqual(x1 - x0, y1 - y0) + + def test_der_vogel_passt_hinein(self): + box = (0.40, 0.30, 0.52, 0.62) + x0, y0, x1, y1 = quadrat(box, 1800, 2400) + self.assertLessEqual(x0, box[0] * 1800) + self.assertLessEqual(y0, box[1] * 2400) + self.assertGreaterEqual(x1, box[2] * 1800) + self.assertGreaterEqual(y1, box[3] * 2400) + + def test_am_rand_bleibt_das_fenster_im_bild(self): + """⚠️ Sonst sitzt ein Vogel am Bildrand ausserhalb seiner Kachel.""" + for box in ((0.0, 0.0, 0.08, 0.10), (0.92, 0.90, 1.0, 1.0)): + with self.subTest(box=box): + x0, y0, x1, y1 = quadrat(box, 1800, 2400) + self.assertGreaterEqual(x0, 0) + self.assertGreaterEqual(y0, 0) + self.assertLessEqual(x1, 1800) + self.assertLessEqual(y1, 2400) + self.assertEqual(x1 - x0, y1 - y0) + + def test_mehr_rand_heisst_groesseres_fenster(self): + """Das ist die Stellschraube, um die es bei der Umstellung ging.""" + eng = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.05) + weit = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.60) + self.assertGreater(weit[2] - weit[0], eng[2] - eng[0]) + + def test_fenster_wird_nie_groesser_als_das_bild(self): + x0, y0, x1, y1 = quadrat((0.0, 0.0, 1.0, 1.0), 1800, 2400, rand=2.0) + self.assertLessEqual(x1 - x0, 1800) + self.assertLessEqual(y1 - y0, 2400) + + +class KachelTests(TestCase): + def sichtung(self, **kw) -> Sighting: + jetzt = timezone.now() + s = Sighting(started_at=jetzt, ended_at=jetzt + timedelta(seconds=5), **kw) + s.image = foto() + s.save() + return s + + def test_mit_koordinaten_entsteht_eine_kachel(self): + s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62) + self.assertIn("zuschnitte/", s.kachel_url) + + def test_die_kachel_ist_quadratisch(self): + s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62) + from pathlib import Path + + from django.conf import settings + + pfad = Path(settings.MEDIA_ROOT) / s.kachel_url.split("/media/")[-1] + with Image.open(pfad) as im: + self.assertEqual(im.width, im.height) + + def test_ohne_koordinaten_gilt_der_alte_ausschnitt(self): + """Altbestand muss anzeigbar bleiben, ohne dass jemand nachzieht.""" + s = self.sichtung() + s.crop = foto((300, 300)) + s.save() + self.assertEqual(s.kachel_url, s.crop.url) + + def test_ohne_alles_bleibt_das_original(self): + s = self.sichtung() + self.assertEqual(s.kachel_url, s.image.url) + + def test_halbe_koordinaten_zaehlen_nicht(self): + """Ein halbes Viereck ist keins — sonst entstünde eine graue Kachel.""" + s = self.sichtung(box_x0=0.4, box_y0=0.3, box_x1=None, box_y1=0.62) + self.assertIsNone(s.vogel_box) + + def test_verdrehte_kanten_zaehlen_nicht(self): + s = self.sichtung(box_x0=0.6, box_y0=0.3, box_x1=0.4, box_y1=0.62) + self.assertIsNone(s.vogel_box) + + +class RegelAendernTests(TestCase): + """🔑 Die Eigenschaft, wegen der umgebaut wurde. + + Eine geänderte Regel muss den ganzen Bestand erfassen — nicht nur das, was + danach entsteht. Genau das konnte die alte Lösung nicht. + """ + + def test_mehr_rand_erzeugt_eine_neue_kachel_fuer_alte_sichtungen(self): + from . import zuschnitt + + jetzt = timezone.now() + s = Sighting(started_at=jetzt, ended_at=jetzt, + box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62) + s.image = foto() + s.save() + + vorher = s.kachel_url + + alt_rand, alt_regel = zuschnitt.RAND, zuschnitt.REGEL + try: + zuschnitt.RAND = 0.8 + zuschnitt.REGEL = alt_regel + 1 + nachher = s.kachel_url + finally: + zuschnitt.RAND, zuschnitt.REGEL = alt_rand, alt_regel + + # Andere Adresse, anderes Bild — ohne dass jemand die Sichtung angefasst hat. + self.assertNotEqual(vorher, nachher) + + from pathlib import Path + + from django.conf import settings + + def kante(url): + with Image.open(Path(settings.MEDIA_ROOT) / url.split("/media/")[-1]) as im: + return im.width + + self.assertGreater(kante(nachher), 0) diff --git a/web/dex/tests.py b/web/dex/tests.py new file mode 100644 index 0000000..c0eafff --- /dev/null +++ b/web/dex/tests.py @@ -0,0 +1,1869 @@ +"""Tests für die Spielmechanik und den Ingest. + +Bewusst auf das konzentriert, was leise falsch sein kann: XP-Berechnung, +Erstfang-Erkennung, Abzeichen-Vergabe, Autorisierung. +""" + +from __future__ import annotations + +import json +import re +import shutil +import tempfile +from datetime import timedelta +from io import BytesIO, StringIO +from unittest import mock + +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import CommandError +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone + +from . import scoring +from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species + +_SKRIPT_UND_STIL = re.compile( + r"<(script|style)\b.*?", re.IGNORECASE | re.DOTALL) + + +def nur_sichtbares(resp) -> str: + """Die Antwort ohne Skript- und Stilblöcke. + + 🔑 Für Sprachproben: Deutsche Kommentare im JavaScript stehen zwar im + Quelltext, aber nicht auf der Seite. Ohne diesen Schnitt schlägt eine + Sprachprobe fehl, sobald jemand einen Kommentar schreibt — und dann + ändert man den Kommentar statt den Fehler zu suchen. + """ + return _SKRIPT_UND_STIL.sub("", resp.content.decode("utf-8")) + + +def make_camera(slug="vogelhaus", token="kamera-token-1", **kw) -> Camera: + cam = Camera(slug=slug, name=kw.pop("name", slug.title()), **kw) + cam.set_token(token) + cam.save() + return cam + + +def make_species(**kw) -> Species: + defaults = { + "slug": "kohlmeise", + "german": "Kohlmeise", + "scientific": "Parus major", + "rarity": "haeufig", + "xp": 10, + } + return Species.objects.create(**(defaults | kw)) + + +def make_sighting(species=None, confidence=1.0, when=None, duration=10) -> Sighting: + start = when or timezone.now() + return Sighting.objects.create( + species=species, + confidence=confidence, + started_at=start, + ended_at=start + timedelta(seconds=duration), + ) + + +class LevelCurveTests(TestCase): + def test_level_one_starts_at_zero(self): + self.assertEqual(scoring.xp_for_level(1), 0) + self.assertEqual(scoring.level_for_xp(0), 1) + + def test_curve_is_monotonic(self): + thresholds = [scoring.xp_for_level(n) for n in range(1, 40)] + self.assertEqual(thresholds, sorted(thresholds)) + + def test_level_matches_its_own_threshold(self): + for level in range(1, 30): + self.assertEqual(scoring.level_for_xp(scoring.xp_for_level(level)), level) + + def test_one_xp_below_threshold_stays_on_lower_level(self): + threshold = scoring.xp_for_level(5) + self.assertEqual(scoring.level_for_xp(threshold - 1), 4) + + def test_rank_is_capped(self): + # Sprache festnageln: rank_name übersetzt inzwischen, RANKS enthält + # die deutschen Ausgangstexte. Ohne override hängt der Test davon ab, + # welche Sprache ein vorheriger Test zuletzt aktiviert hat. + from django.utils import translation + + with translation.override("de"): + self.assertEqual(scoring.rank_name(1), "Nestling") + self.assertEqual(scoring.rank_name(999), scoring.RANKS[-1]) + + +class RegisterTests(TestCase): + def setUp(self): + scoring.sync_badges() + self.species = make_species() + + def test_first_catch_gets_triple_xp(self): + reward = scoring.register(make_sighting(self.species)) + self.assertTrue(reward.is_first) + self.assertEqual(reward.xp, 30) # 10 XP × 3, Confidence 1.0 + + def test_second_catch_is_normal_xp(self): + scoring.register(make_sighting(self.species)) + reward = scoring.register(make_sighting(self.species)) + self.assertFalse(reward.is_first) + self.assertEqual(reward.xp, 10) + + def test_low_confidence_reduces_xp(self): + reward = scoring.register(make_sighting(self.species, confidence=0.5)) + # 10 × 3 (Erstfang) × (0.5 + 0.5×0.5) = 22.5 -> 22 + self.assertEqual(reward.xp, 22) + + def test_unidentified_sighting_gives_no_xp_and_no_entry(self): + reward = scoring.register(make_sighting(None)) + self.assertEqual(reward.xp, 0) + self.assertFalse(reward.is_first) + self.assertEqual(DexEntry.objects.count(), 0) + + def test_dex_entry_counts_up_and_tracks_last_seen(self): + first = timezone.now() - timedelta(days=2) + scoring.register(make_sighting(self.species, when=first)) + later = timezone.now() + scoring.register(make_sighting(self.species, when=later)) + + entry = DexEntry.objects.get(species=self.species) + self.assertEqual(entry.count, 2) + self.assertEqual(entry.first_seen, first) + self.assertEqual(entry.last_seen, later) + + def test_best_sighting_follows_highest_confidence(self): + """Ohne Bildzähler entscheidet die Sicherheit — der Rückfall. + + ⚠️ Seit dem 30.09.2026 wählt das beste Foto nach Bildgüte, nicht + nach Sicherheit (siehe BildgueteTests). make_sighting setzt keine + Bildzähler, deshalb greift hier der Rückfall — und der soll + weiterhin die Sicherheit nehmen. + """ + weak = make_sighting(self.species, confidence=0.6) + scoring.register(weak) + strong = make_sighting(self.species, confidence=0.95) + scoring.register(strong) + + entry = DexEntry.objects.get(species=self.species) + self.assertEqual(entry.best_sighting_id, strong.id) + + def test_hand_correction_wins_over_model(self): + amsel = make_species(slug="amsel", german="Amsel", scientific="Turdus merula") + sighting = make_sighting(self.species) + sighting.confirmed_species = amsel + sighting.save() + + scoring.register(sighting) + self.assertTrue(DexEntry.objects.filter(species=amsel).exists()) + self.assertFalse(DexEntry.objects.filter(species=self.species).exists()) + + +class BadgeTests(TestCase): + def setUp(self): + scoring.sync_badges() + self.species = make_species() + + def test_sync_is_idempotent(self): + before = Badge.objects.count() + scoring.sync_badges() + self.assertEqual(Badge.objects.count(), before) + + def test_first_sighting_earns_first_guest(self): + scoring.register(make_sighting(self.species)) + self.assertTrue(EarnedBadge.objects.filter(badge__code="erster_gast").exists()) + + def test_badge_is_awarded_only_once(self): + scoring.register(make_sighting(self.species)) + scoring.register(make_sighting(self.species)) + self.assertEqual( + EarnedBadge.objects.filter(badge__code="erster_gast").count(), 1 + ) + + def test_rare_species_earns_rare_guest(self): + gimpel = make_species( + slug="gimpel", german="Gimpel", scientific="Pyrrhula pyrrhula", + rarity="selten", xp=60, + ) + scoring.register(make_sighting(gimpel)) + self.assertTrue(EarnedBadge.objects.filter(badge__code="seltener_gast").exists()) + + def test_unbestimmte_sichtung_verdient_kein_abzeichen(self): + """Eine Spiegelung ist kein Besuch — und kein Abzeichen wert. + + 🔑 Der Fall aus dem Betrieb: Am 27.09.2026 meldete die Vogeltränke + abends 24 Spiegelungen des Mondes auf dem Wasser. Die erste brachte + „Nachtschicht" samt 100 XP ein, weil die Abzeichen für Uhrzeit und + Dauer allein am Zeitstempel hingen und nicht an einer Art. + """ + abends = timezone.localtime(timezone.now()).replace(hour=21, minute=30) + scoring.register( + make_sighting(None, confidence=0.0, when=abends, duration=90) + ) + self.assertFalse(EarnedBadge.objects.exists()) + + def test_bestimmte_sichtung_nach_21_uhr_verdient_nachtschicht(self): + """Die Gegenprobe: mit Art soll das Abzeichen sehr wohl kommen.""" + abends = timezone.localtime(timezone.now()).replace(hour=21, minute=30) + scoring.register(make_sighting(self.species, when=abends)) + self.assertTrue( + EarnedBadge.objects.filter(badge__code="nachtschicht").exists() + ) + + def test_long_visit_earns_dauergast(self): + scoring.register(make_sighting(self.species, duration=90)) + self.assertTrue(EarnedBadge.objects.filter(badge__code="dauergast").exists()) + + def test_short_visit_does_not_earn_dauergast(self): + scoring.register(make_sighting(self.species, duration=20)) + self.assertFalse(EarnedBadge.objects.filter(badge__code="dauergast").exists()) + + def test_badge_xp_counts_towards_total(self): + reward = scoring.register(make_sighting(self.species)) + badge_xp = sum(b.xp for b in reward.badges) + self.assertEqual(scoring.total_xp(), reward.xp + badge_xp) + + +class IngestApiTests(TestCase): + def setUp(self): + scoring.sync_badges() + self.species = make_species() + # Registrierte Kamera fuer alles, was je Kamera einstellbar ist. + # Die uebrigen Tests posten weiter mit dem Sammel-Token aus den + # Settings und bleiben davon unberuehrt. + self.camera = make_camera(slug="ingest-kamera", token="kamera-token-1") + self.url = "/api/sightings/" + now = timezone.now() + self.payload = { + "species": "kohlmeise", + "confidence": 0.9, + "started_at": now.isoformat(), + "ended_at": (now + timedelta(seconds=12)).isoformat(), + "frames_total": 40, + "frames_with_bird": 31, + "votes": {"kohlmeise": 0.9}, + # ⚠️ Gehört dazu, weil der Detector es immer mitschickt + # (detector/sink.py, payload_of). Ohne dieses Feld prüfte die + # Testnutzlast einen Fall, den es im Betrieb nicht gibt. + "mean_bird_score": 0.62, + } + + def post(self, payload=None, token="dev-token"): + return self.client.post( + self.url, + {"data": json.dumps(payload if payload is not None else self.payload)}, + headers={"Authorization": f"Bearer {token}"}, + ) + + def test_kamera_grenze_weist_schwache_sichtung_ab(self): + """Die je Kamera einstellbare Untergrenze (Issue #6). + + 🔑 Der Fall aus dem Betrieb: eine „Kohlmeise" aus einer + Befestigungsschraube, 1 von 73 Bildern, mittlerer Vogelwert 0,098. + Mit einer Grenze von 0,15 entsteht sie gar nicht erst. + """ + self.camera.min_mean_bird_score = 0.15 + self.camera.save() + resp = self.post(self.payload | {"mean_bird_score": 0.098}, + token="kamera-token-1") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["grenze"], 0.15) + self.assertEqual(Sighting.objects.count(), 0) + + def test_kamera_grenze_laesst_echte_sichtung_durch(self): + """Gegenprobe: echte Sichtungen lagen bei 0,269 bis 0,600.""" + self.camera.min_mean_bird_score = 0.15 + self.camera.save() + resp = self.post(self.payload | {"mean_bird_score": 0.269}, + token="kamera-token-1") + self.assertEqual(resp.status_code, 201) + self.assertEqual(Sighting.objects.count(), 1) + + def test_grenze_steht_in_der_konfiguration(self): + """Der Detector muss sie kennen, sonst sendet er umsonst.""" + self.camera.min_mean_bird_score = 0.15 + self.camera.save() + resp = self.client.get( + "/api/config/", headers={"Authorization": "Bearer kamera-token-1"}) + self.assertEqual(resp.json()["min_mean_bird_score"], 0.15) + + def test_ohne_vogel_keine_sichtung(self): + """Vogelwert 0 wird abgewiesen — das waren die Mondspiegelungen. + + Am 27.09.2026 meldete die Vogeltränke abends 24 „Besuche" in vierzig + Minuten, je ein Bild von 31 mit vermeintlicher Bewegung und auf keinem + ein Vogel. + """ + resp = self.post(self.payload | {"mean_bird_score": 0.0}) + self.assertEqual(resp.status_code, 200) + self.assertIn("ignoriert", resp.json()) + self.assertEqual(Sighting.objects.count(), 0) + + def test_unbestimmt_aber_mit_vogel_zaehlt(self): + """Die Gegenprobe: Art unbekannt ist etwas anderes als kein Vogel. + + ⚠️ Artsicherheit 0 heißt nur „ich weiß nicht, welche Art". So eine + Sichtung ist ein echter Besuch und muss erhalten bleiben — sonst + verliert man genau die Fälle, die man nachbestimmen möchte. + """ + resp = self.post( + self.payload | {"species": None, "confidence": 0.0, + "mean_bird_score": 0.41, "votes": {}} + ) + self.assertEqual(resp.status_code, 201) + self.assertEqual(Sighting.objects.count(), 1) + + def test_alte_detector_fassung_ohne_feld_wird_durchgelassen(self): + """Fehlt das Feld ganz, ist es eine ältere Fassung — nicht abweisen. + + Web-App und Detector sind getrennt deploybar; ein Deploy darf eine + ältere Kamera nicht stumm aussperren. + """ + ohne = {k: v for k, v in self.payload.items() if k != "mean_bird_score"} + resp = self.post(ohne) + self.assertEqual(resp.status_code, 201) + self.assertEqual(Sighting.objects.count(), 1) + + def test_ort_wird_gerundet_uebernommen(self): + """Ein Foto von unterwegs bringt seinen Ort mit — aber nur grob. + + 🔑 Gerundet wird beim Speichern, nicht beim Anzeigen: Die genaue + Position darf gar nicht erst in der Datenbank stehen, sonst liegt die + Wohnadresse in jeder Sicherung und jedem Auszug. + """ + resp = self.post( + self.payload | {"latitude": 49.48753, "longitude": 8.46601} + ) + self.assertEqual(resp.status_code, 201) + s = Sighting.objects.get() + self.assertEqual(s.latitude, 49.49) + self.assertEqual(s.longitude, 8.47) + + def test_ohne_ort_bleibt_leer(self): + """Das Vogelhaus hängt fest und schickt keinen Ort.""" + self.assertEqual(self.post().status_code, 201) + s = Sighting.objects.get() + self.assertIsNone(s.latitude) + self.assertIsNone(s.longitude) + + def test_halbe_koordinate_wird_verworfen(self): + """Nur die Breite ist keine Position — dann lieber gar keine.""" + self.post(self.payload | {"latitude": 49.4875}) + s = Sighting.objects.get() + self.assertIsNone(s.latitude) + self.assertIsNone(s.longitude) + + def test_unsinnige_koordinaten_werden_verworfen(self): + """0/0 und Werte außerhalb des Bereichs zählen nicht. + + ⚠️ 0/0 ist ein gültiger Punkt im Atlantik, als Wert aus einer App aber + praktisch immer ein leeres Feld. Eine Sammlung mit Sichtungen vor + Westafrika fiele niemandem rechtzeitig auf. + """ + for lat, lon in ((0.0, 0.0), (91.0, 8.0), (49.0, 181.0)): + Sighting.objects.all().delete() + self.post(self.payload | {"latitude": lat, "longitude": lon}) + s = Sighting.objects.get() + self.assertIsNone(s.latitude, f"lat {lat} hätte nicht zählen dürfen") + self.assertIsNone(s.longitude, f"lon {lon} hätte nicht zählen dürfen") + + def test_valid_post_creates_sighting(self): + resp = self.post() + self.assertEqual(resp.status_code, 201) + body = resp.json() + self.assertEqual(body["art"], "Kohlmeise") + self.assertTrue(body["erstfang"]) + self.assertEqual(Sighting.objects.count(), 1) + + def test_wrong_token_is_rejected(self): + self.assertEqual(self.post(token="falsch").status_code, 401) + self.assertEqual(Sighting.objects.count(), 0) + + def test_missing_authorization_is_rejected(self): + resp = self.client.post(self.url, {"data": json.dumps(self.payload)}) + self.assertEqual(resp.status_code, 401) + + def test_unknown_species_is_rejected(self): + resp = self.post(self.payload | {"species": "dodo"}) + self.assertEqual(resp.status_code, 400) + self.assertIn("dodo", resp.json()["fehler"]) + + def test_unidentified_sighting_is_accepted(self): + resp = self.post(self.payload | {"species": None, "confidence": 0.0}) + self.assertEqual(resp.status_code, 201) + self.assertIsNone(resp.json()["art"]) + + def test_broken_timestamps_are_rejected(self): + resp = self.post(self.payload | {"started_at": "gestern"}) + self.assertEqual(resp.status_code, 400) + + def test_empty_token_setting_closes_the_legacy_path(self): + """Ohne konfigurierten Token darf nur noch über registrierte Kameras + etwas reinkommen — und ein leerer Header erst recht nicht.""" + from django.test import override_settings + + with override_settings(INGEST_TOKEN=""): + self.assertEqual(self.post(token="").status_code, 401) + self.assertEqual(self.post(token="dev-token").status_code, 401) + self.assertEqual(Sighting.objects.count(), 0) + + cam = make_camera("garten", "echter-token") + resp = self.post(token="echter-token") + self.assertEqual(resp.status_code, 201) + self.assertEqual(Sighting.objects.get().camera, cam) + + def test_get_is_not_allowed(self): + self.assertEqual(self.client.get(self.url).status_code, 405) + + +class CameraTests(TestCase): + """Mehrere Kameras melden an dieselbe Sammlung — jede mit eigenem Token. + + Das ist der Kern der getrennten Architektur: die Erkennung läuft im + Heimnetz und meldet ausgehend, die Web-App muss keine Kamera erreichen. + """ + + def setUp(self): + scoring.sync_badges() + self.species = make_species() + self.garten = make_camera("garten", "token-garten", name="Garten") + self.balkon = make_camera("balkon", "token-balkon", name="Balkon") + now = timezone.now() + self.payload = { + "species": "kohlmeise", + "confidence": 0.9, + "started_at": now.isoformat(), + "ended_at": (now + timedelta(seconds=12)).isoformat(), + } + + def post(self, token): + return self.client.post( + "/api/sightings/", + {"data": json.dumps(self.payload)}, + headers={"Authorization": f"Bearer {token}"}, + ) + + def test_token_only_stored_as_hash(self): + self.assertNotIn("token-garten", self.garten.token_hash) + self.assertEqual(len(self.garten.token_hash), 64) + + def test_camera_add_ohne_mobil_ist_ein_geraet(self): + call_command("camera", "add", "neues-haus", stdout=StringIO()) + self.assertFalse(Camera.objects.get(slug="neues-haus").mobil) + + def test_camera_add_mobil_setzt_das_feld(self): + """Ein Handy darf schweigen — sonst trägt es dauerhaft ein ⚠. + + Das Feld gab es bisher nur im Admin. Wer eine Handy-Kamera auf der + Kommandozeile anlegt (und genau das steht in der App-Anleitung), hatte + sie zwangsläufig als Gerät eingetragen, das als gestört gilt, sobald + es eine halbe Stunde nichts liefert. + """ + ausgabe = StringIO() + call_command("camera", "add", "handy", "--mobil", stdout=ausgabe) + kamera = Camera.objects.get(slug="handy") + self.assertTrue(kamera.mobil) + # Der Hinweis auf den Erkennungs-Host wäre hier eine falsche Fährte. + self.assertNotIn("VOGELALBUM_TOKEN=", ausgabe.getvalue()) + self.assertIn("App", ausgabe.getvalue()) + + def test_each_token_resolves_to_its_own_camera(self): + self.assertEqual(Camera.by_token("token-garten"), self.garten) + self.assertEqual(Camera.by_token("token-balkon"), self.balkon) + + def test_unknown_token_resolves_to_nothing(self): + self.assertIsNone(Camera.by_token("token-erfunden")) + + def test_sighting_is_attributed_to_the_sending_camera(self): + resp = self.post("token-balkon") + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()["kamera"], "Balkon") + self.assertEqual(Sighting.objects.get().camera, self.balkon) + + def test_cameras_share_one_collection(self): + """Zwei Kameras, dieselbe Art: nur der erste Fund ist ein Erstfang.""" + first = self.post("token-garten") + second = self.post("token-balkon") + self.assertTrue(first.json()["erstfang"]) + self.assertFalse(second.json()["erstfang"]) + self.assertEqual(DexEntry.objects.count(), 1) + self.assertEqual(DexEntry.objects.get().count, 2) + + def test_disabled_camera_is_rejected(self): + self.garten.active = False + self.garten.save() + self.assertEqual(self.post("token-garten").status_code, 401) + + def test_rotating_a_token_invalidates_the_old_one(self): + self.garten.set_token("token-neu") + self.garten.save() + self.assertEqual(self.post("token-garten").status_code, 401) + self.assertEqual(self.post("token-neu").status_code, 201) + + def test_ingest_updates_last_seen(self): + self.assertIsNone(self.garten.last_seen) + self.post("token-garten") + self.garten.refresh_from_db() + self.assertIsNotNone(self.garten.last_seen) + self.assertFalse(self.garten.is_stale) + + def test_camera_without_contact_is_stale(self): + self.assertTrue(self.garten.is_stale) + self.garten.last_seen = timezone.now() - timedelta(hours=3) + self.assertTrue(self.garten.is_stale) + + def test_settings_token_still_works_without_a_camera(self): + """Einzelkamera-Betrieb über VOGELALBUM_INGEST_TOKEN bleibt möglich.""" + resp = self.post("dev-token") + self.assertEqual(resp.status_code, 201) + self.assertIsNone(resp.json()["kamera"]) + + def test_sightings_page_filters_by_camera(self): + self.post("token-garten") + self.post("token-balkon") + resp = self.client.get("/sichtungen/?kamera=balkon") + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.context["sightings"]), 1) + self.assertEqual(resp.context["sightings"][0].camera, self.balkon) + + +class LanguageTests(TestCase): + """Deutsch und Englisch — Oberfläche und Inhalte. + + Der Rückfall auf Deutsch ist der wichtige Teil: der Katalog wächst + schneller, als die englischen Texte nachkommen, und eine Art ohne + Übersetzung soll den deutschen Text zeigen statt eine Lücke. + """ + + def setUp(self): + scoring.sync_badges() + self.species = make_species( + english="Great Tit", + gewicht_g=18, spannweite_cm=24, size_cm="14 cm", + kategorie="Die Lauteste im Garten", + kategorie_en="The Loudest in the Garden", + beschreibung="Erster Absatz.\n\nZweiter Absatz.", + beschreibung_en="First paragraph.\n\nSecond paragraph.", + nahrung="Insekten", nahrung_en="Insects", + typ="meise", zug="stand", number=7, + ) + self.ohne_en = make_species( + slug="dohle", german="Dohle", english="Eurasian Jackdaw", + scientific="Corvus monedula", kategorie="Die mit den blauen Augen", + beschreibung="Nur auf Deutsch.", typ="rabenvogel", number=8, + ) + + def test_german_is_the_default(self): + resp = self.client.get(f"/sammlung/{self.species.slug}/") + self.assertContains(resp, "Kohlmeise") + self.assertContains(resp, "Die Lauteste im Garten") + + def test_english_switches_names_and_profile(self): + resp = self.client.get( + f"/sammlung/{self.species.slug}/", headers={"Accept-Language": "en"} + ) + self.assertContains(resp, "Great Tit") + self.assertContains(resp, "The Loudest in the Garden") + self.assertContains(resp, "First paragraph.") + self.assertNotContains(resp, "Erster Absatz.") + + def test_interface_strings_are_translated(self): + resp = self.client.get("/sammlung/", headers={"Accept-Language": "en"}) + self.assertContains(resp, "The Collection") + self.assertNotContains(resp, "Die Sammlung") + + def test_missing_translation_falls_back_to_german(self): + resp = self.client.get( + f"/sammlung/{self.ohne_en.slug}/", headers={"Accept-Language": "en"} + ) + # Der englische Artname existiert, der Steckbrief nicht. + self.assertContains(resp, "Eurasian Jackdaw") + self.assertContains(resp, "Nur auf Deutsch.") + + def test_language_switch_sets_a_cookie(self): + resp = self.client.post( + "/i18n/setlang/", {"language": "en", "next": "/sammlung/"} + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(self.client.cookies["django_language"].value, "en") + + follow = self.client.get("/sammlung/") + self.assertContains(follow, "The Collection") + + def test_stat_bar_labels_are_translated(self): + from django.utils import translation + + with translation.override("en"): + labels = [b["label"] for b in self.species.balken()] + self.assertIn("Weight", labels) + self.assertIn("Rarity", labels) + + with translation.override("de"): + labels = [b["label"] for b in self.species.balken()] + self.assertIn("Gewicht", labels) + + def test_badges_and_ranks_are_translated(self): + """Abzeichen und Ränge liegen als deutscher Text in der Datenbank und + werden erst bei der Ausgabe übersetzt. Ein Regressionstest lohnt sich: + makemessages hängt neue Texte gern per Fuzzy-Match an ähnliche alte, + msgfmt wirft die dann weg, und übrig bleibt stiller deutscher Rest.""" + from django.utils import translation + + from .models import Badge + + resp = self.client.get("/abzeichen/", headers={"Accept-Language": "en"}) + self.assertContains(resp, "First Guest") + self.assertContains(resp, "Collector") + self.assertContains(resp, "Early Bird") + self.assertNotContains(resp, "Erster Gast") + self.assertNotContains(resp, "verschiedene Arten gesammelt") + + with translation.override("en"): + self.assertEqual(scoring.rank_name(1), "Nestling") + self.assertEqual(scoring.rank_name(16), "Blackbird Whisperer") + with translation.override("de"): + self.assertEqual(scoring.rank_name(16), "Amselflüsterer") + + def test_badge_codes_are_ascii_slugs(self): + """Ein Umlaut im Code ist in einem SlugField nicht validierbar — und + hat mir einmal die Übersetzung stillschweigend übersprungen.""" + from .models import Badge + + for badge in Badge.objects.all(): + with self.subTest(code=badge.code): + self.assertTrue(badge.code.isascii(), badge.code) + + def test_sync_removes_unknown_badges(self): + from .models import Badge + + Badge.objects.create(code="veraltet", name="Alt", description="x", xp=1) + scoring.sync_badges() + self.assertFalse(Badge.objects.filter(code="veraltet").exists()) + + def test_type_and_rarity_labels_switch(self): + from django.utils import translation + + with translation.override("en"): + self.assertEqual(self.species.typ_label, "Tit") + self.assertEqual(self.species.rarity_label, "Common") + self.assertEqual(self.species.zug_label, "Resident") + with translation.override("de"): + self.assertEqual(self.species.typ_label, "Meise") + self.assertEqual(self.species.rarity_label, "Häufig") + + +class TypeCoverageTests(TestCase): + """Die Web-App spiegelt die Typtabelle aus dem Detector. Fehlt dort ein + Typ, rendert die Karte einen Strich — sichtbar nur, wenn man hinschaut.""" + + def test_every_catalog_type_has_a_style_and_an_english_name(self): + import sys + from pathlib import Path + + wurzel = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(wurzel)) + try: + from detector.species_extra import EXTRA + from detector.species_info import TYPES + except ImportError: + self.skipTest("Detector-Katalog nicht erreichbar") + + from .species_meta import TYPE_LABEL_EN, TYPE_STYLE + + benutzt = set(TYPES) | {e.typ for e in EXTRA} + self.assertEqual(benutzt - set(TYPE_STYLE), set()) + self.assertEqual(benutzt - set(TYPE_LABEL_EN), set()) + + +class HabitatFilterTests(TestCase): + """Der Lebensraum-Filter. Ohne ihn ersäuft die Gartensammlung in + Wasservögeln, die nie vor der Kamera landen.""" + + def setUp(self): + scoring.sync_badges() + self.meise = make_species(lebensraum_typ="garten", typ="meise", number=1) + self.reiher = make_species( + slug="graureiher", german="Graureiher", scientific="Ardea cinerea", + english="Grey Heron", rarity="episch", typ="schreitvogel", + lebensraum_typ="wasser", number=2, + ) + + def test_collection_defaults_to_garden(self): + resp = self.client.get("/sammlung/") + slugs = [c["species"].slug for c in resp.context["cards"]] + self.assertIn("kohlmeise", slugs) + self.assertNotIn("graureiher", slugs) + + def test_all_shows_every_species(self): + resp = self.client.get("/sammlung/?lebensraum=alle") + slugs = [c["species"].slug for c in resp.context["cards"]] + self.assertIn("kohlmeise", slugs) + self.assertIn("graureiher", slugs) + + def test_filtering_by_water_hides_garden_birds(self): + resp = self.client.get("/sammlung/?lebensraum=wasser") + slugs = [c["species"].slug for c in resp.context["cards"]] + self.assertEqual(slugs, ["graureiher"]) + + def test_habitat_survives_a_rarity_filter(self): + resp = self.client.get("/sammlung/?lebensraum=wasser&seltenheit=episch") + slugs = [c["species"].slug for c in resp.context["cards"]] + self.assertEqual(slugs, ["graureiher"]) + + def test_group_choices_only_list_types_present_in_the_habitat(self): + """Sonst steht „Möwe" im Garten zur Auswahl und liefert null Treffer.""" + resp = self.client.get("/sammlung/?lebensraum=garten") + codes = {t["code"] for t in resp.context["typen"]} + self.assertIn("meise", codes) + self.assertNotIn("schreitvogel", codes) + + resp = self.client.get("/sammlung/?lebensraum=wasser") + codes = {t["code"] for t in resp.context["typen"]} + self.assertIn("schreitvogel", codes) + self.assertNotIn("meise", codes) + + def test_group_choices_carry_counts(self): + resp = self.client.get("/sammlung/?lebensraum=garten") + meise = next(t for t in resp.context["typen"] if t["code"] == "meise") + self.assertEqual(meise["n"], 1) + + def test_empty_combination_renders_without_error(self): + resp = self.client.get("/sammlung/?lebensraum=wasser&typ=meise") + self.assertEqual(resp.status_code, 200) + self.assertEqual(list(resp.context["cards"]), []) + + def test_counts_reflect_the_active_filter(self): + resp = self.client.get("/sammlung/?lebensraum=garten") + self.assertEqual(resp.context["total_count"], 1) + self.assertEqual(resp.context["gesamt_alle"], 2) + + def test_habitat_label_switches_language(self): + from django.utils import translation + + with translation.override("en"): + self.assertEqual(self.reiher.habitat_label, "Water & shoreline") + with translation.override("de"): + self.assertEqual(self.reiher.habitat_label, "Wasser & Ufer") + + +class ViewTests(TestCase): + def setUp(self): + scoring.sync_badges() + self.species = make_species() + + def test_pages_render_when_empty(self): + for path in ("/", "/sammlung/", "/sichtungen/", "/abzeichen/", "/widget/"): + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 200) + + def test_species_page_renders_before_first_sighting(self): + resp = self.client.get(f"/sammlung/{self.species.slug}/") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Noch nicht gesammelt") + + def test_pages_render_with_data(self): + scoring.register(make_sighting(self.species)) + for path in ("/", "/sammlung/", "/sichtungen/", f"/sammlung/{self.species.slug}/"): + with self.subTest(path=path): + self.assertEqual(self.client.get(path).status_code, 200) + + def test_stats_api_reports_progress(self): + scoring.register(make_sighting(self.species)) + body = self.client.get("/api/stats/").json() + self.assertEqual(body["arten_gesehen"], 1) + self.assertEqual(body["letzte"]["art"], "Kohlmeise") + + +class LiveViewTests(TestCase): + """Die Live-Seite. Der Punkt ist nicht, dass sie erscheint, sondern dass + sie ohne ausdrückliche Freigabe *nicht* erscheint — ein Livebild aus einem + Privatgarten gehört nicht auf eine öffentliche Instanz.""" + + def setUp(self): + self.cam = make_camera(live_url="http://10.0.0.9:8131/live") + + def test_ohne_freigabe_gibt_es_die_seite_nicht(self): + """Standard ist aus: eine frisch angelegte Kamera zeigt nichts.""" + self.assertFalse(self.cam.live_enabled) + self.assertEqual(self.client.get("/live/").status_code, 404) + + def test_mit_freigabe_erscheint_das_bild(self): + self.cam.live_enabled = True + self.cam.save() + + resp = self.client.get("/live/") + self.assertEqual(resp.status_code, 200) + # ⚠ Auf der Seite steht das EINZELBILD, nicht der Dauerstrom -- + # siehe Camera.live_bild_url. Der Test hing noch an der alten + # Adresse und schlug seit dem Umbau fehl. + self.assertContains(resp, self.cam.live_bild_url) + + def test_haekchen_ohne_adresse_zeigt_nichts(self): + """Sonst stünde da ein kaputtes Bild statt einer klaren Aussage.""" + self.cam.live_url = "" + self.cam.live_enabled = True + self.cam.save() + self.assertEqual(self.client.get("/live/").status_code, 404) + + def test_stillgelegte_kamera_zeigt_nichts(self): + self.cam.live_enabled = True + self.cam.active = False + self.cam.save() + self.assertEqual(self.client.get("/live/").status_code, 404) + + def test_umgebung_sticht_das_haekchen_aus(self): + """Die eigentliche Sicherung: auf der öffentlichen Instanz steht + VOGELALBUM_LIVE_ERLAUBT=0, und dann nützt kein Häkchen im Admin.""" + self.cam.live_enabled = True + self.cam.save() + self.assertEqual(self.client.get("/live/").status_code, 200) + + with self.settings(LIVE_ALLOWED=False): + self.assertEqual(self.client.get("/live/").status_code, 404) + self.assertFalse(self.cam.shows_live) + + def test_link_erscheint_nur_mit_freigabe(self): + self.assertNotContains(self.client.get("/"), 'href="/live/"') + + self.cam.live_enabled = True + self.cam.save() + self.assertContains(self.client.get("/"), 'href="/live/"') + + def test_zwei_kameras_landen_beide_auf_der_seite(self): + self.cam.live_enabled = True + self.cam.save() + zweite = make_camera( + slug="traenke", token="kamera-token-2", + live_url="http://10.0.0.10:8131/live", live_enabled=True, + ) + + resp = self.client.get("/live/") + self.assertContains(resp, self.cam.live_bild_url) + self.assertContains(resp, zweite.live_bild_url) + + +class AdminLinkTests(TestCase): + """Das Zahnrad im Footer. Kameras, Live-Bild und Arten stellt man im + Admin ein — ohne Weg dorthin muss man die Adresse kennen.""" + + def test_zahnrad_ist_auf_jeder_seite(self): + make_species() + for pfad in ("/", "/sammlung/", "/sichtungen/", "/abzeichen/"): + with self.subTest(pfad=pfad): + self.assertContains(self.client.get(pfad), 'href="/admin/"') + + def test_widget_bleibt_ohne_zahnrad(self): + """Das Widget wird auf gaertner.onl eingebettet — ein Admin-Link + hat in einem fremden iframe nichts zu suchen.""" + self.assertNotContains(self.client.get("/widget/"), 'href="/admin/"') + + +class EnglishTests(TestCase): + """Fehlende Übersetzungen fallen auf den deutschen Text zurück, ohne dass + irgendwo ein Fehler auftaucht — auf Englisch stand deshalb eine Zeit lang + 'Einstellungen' im Footer. Zwei Stichproben gegen genau das.""" + + def test_footer_knopf_ist_englisch(self): + resp = self.client.get("/", headers={"accept-language": "en"}) + self.assertContains(resp, "Settings") + self.assertNotContains(resp, "Einstellungen") + + def test_live_seite_ist_englisch(self): + make_camera(live_url="http://10.0.0.9:8131/live", live_enabled=True) + resp = self.client.get("/live/", headers={"accept-language": "en"}) + self.assertContains(resp, "camera") + # ⚠ Nur den SICHTBAREN Text prüfen. Skript- und Stilblöcke tragen + # deutsche Kommentare — die sieht niemand, und ohne diesen Schnitt + # schlägt der Test bei jedem neuen Kommentar fehl. + self.assertNotIn("Kamera", nur_sichtbares(resp)) + + +class StoerungsmeldungTests(TestCase): + """Die Störungsmeldung muss SYNCHRON rausgehen. + + 🔑 Am 24.09.2026 kam sie nie an: Sie lief in einem Hintergrundfaden, + und der einzige Aufrufer ist ein Einmal-Befehl. Der war fertig, bevor + die Meldung draußen war, und hat den Daemon-Faden beim Beenden + mitgenommen. Die Wache erkannte die Störung korrekt — und schwieg. + + ⚠️ Dieser Test prüft nicht, dass *irgendwann* gesendet wird, sondern + dass es beim Rücksprung aus ``melde_stoerung`` **bereits passiert + ist**. Nur das unterscheidet die beiden Fälle. + """ + + def setUp(self): + from dex.models import Benachrichtigung + e = Benachrichtigung.laden() + e.aktiv = True + e.stoerungen = True + e.server = "https://ntfy.example" + e.thema = "probe-thema" + e.save() + + def test_stoerung_ist_beim_ruecksprung_schon_gesendet(self): + from dex import benachrichtigung as bn + + with mock.patch.object(bn.urllib.request, "urlopen") as urlopen: + bn.melde_stoerung("Titel", "Text") + # KEIN join(), kein sleep: Wäre es ein Hintergrundfaden, + # stünde hier noch nichts. + self.assertTrue( + urlopen.called, + "Die Meldung war beim Rücksprung noch nicht gesendet — " + "läuft sie wieder in einem Hintergrundfaden?", + ) + ziel = urlopen.call_args[0][0] + self.assertEqual(ziel.full_url, "https://ntfy.example/probe-thema") + + def test_abgeschaltete_stoerungen_senden_nicht(self): + from dex import benachrichtigung as bn + from dex.models import Benachrichtigung + + e = Benachrichtigung.laden() + e.stoerungen = False + e.save() + + with mock.patch.object(bn.urllib.request, "urlopen") as urlopen: + bn.melde_stoerung("Titel", "Text") + self.assertFalse(urlopen.called) + + def test_sichtung_blockiert_nicht(self): + """Gegenprobe: Sichtungen dürfen den Aufrufer NICHT warten lassen. + + Sie laufen in einer Web-Anfrage, die weiterlebt — dort ist der + Hintergrundfaden richtig und soll bleiben. + """ + from dex import benachrichtigung as bn + from dex.models import Benachrichtigung + + e = Benachrichtigung.laden() + e.sichtungen = Benachrichtigung.Sichtungen.ALLE + e.save() + + with mock.patch.object(bn, "_senden") as senden: + with mock.patch.object(bn.threading, "Thread") as thread: + art = make_species() + sichtung = make_sighting(art, confidence=0.9) + bn.melde_sichtung(sichtung, ist_neue_art=False) + + self.assertTrue(thread.called, "Sichtung soll im Hintergrund laufen") + self.assertFalse(senden.called, "und nicht im Vordergrund") + + +class SichtungEntfernenTests(TestCase): + """Eine Fehlerkennung löschen heißt mehr als eine Zeile löschen. + + 🔑 An einer Sichtung hängt der Dex-Eintrag, der Zeitpunkt an der Kamera + und die Punktzahl. Wer nur die Zeile entfernt, lässt einen Dex-Eintrag + zurück, der eine Sichtung zu viel zählt. + """ + + def setUp(self): + self.art = make_species() + self.kamera = make_camera() + frueher = timezone.now() - timedelta(days=1) + self.echte = make_sighting(self.art, confidence=0.9, when=frueher) + self.falsche = make_sighting(self.art, confidence=0.6) + for s in (self.echte, self.falsche): + s.camera = self.kamera + s.save() + scoring.register(self.echte) + scoring.register(self.falsche) + # `last_seen` setzt sonst die Melde-Schnittstelle, nicht die + # Punktevergabe — hier von Hand, damit der Ausgangszustand stimmt. + Camera.objects.filter(pk=self.kamera.pk).update( + last_seen=self.falsche.ended_at) + self.echte.refresh_from_db() + self.falsche.refresh_from_db() + self.kamera.refresh_from_db() + + def _entfernen(self, pk, **optionen): + call_command("sichtung_entfernen", pk, stdout=StringIO(), **optionen) + + def test_dex_eintrag_wird_neu_gezaehlt(self): + eintrag = DexEntry.objects.get(species=self.art) + self.assertEqual(eintrag.count, 2) + + self._entfernen(self.falsche.pk) + + eintrag.refresh_from_db() + self.assertEqual(eintrag.count, 1) + self.assertEqual(eintrag.last_seen, self.echte.started_at) + self.assertEqual(eintrag.best_sighting_id, self.echte.pk) + + def test_punkte_verschwinden_mit_der_sichtung(self): + vorher = scoring.total_xp() + punkte = self.falsche.xp_awarded + self.assertGreater(punkte, 0) + + self._entfernen(self.falsche.pk) + + self.assertEqual(scoring.total_xp(), vorher - punkte) + + def test_kamera_verliert_den_zeitpunkt_der_letzten_sichtung(self): + self._entfernen(self.falsche.pk) + self.kamera.refresh_from_db() + self.assertEqual(self.kamera.last_seen, self.echte.ended_at) + + def test_letzte_sichtung_raeumt_den_dex_eintrag_ab(self): + self._entfernen(self.falsche.pk) + self._entfernen(self.echte.pk) + + self.assertFalse(DexEntry.objects.filter(species=self.art).exists()) + self.kamera.refresh_from_db() + self.assertIsNone(self.kamera.last_seen) + + def test_trocken_aendert_nichts(self): + self._entfernen(self.falsche.pk, trocken=True) + + self.assertTrue(Sighting.objects.filter(pk=self.falsche.pk).exists()) + self.assertEqual(DexEntry.objects.get(species=self.art).count, 2) + + def test_unbekannte_nummer_bricht_ab(self): + with self.assertRaises(CommandError): + self._entfernen(999999) + + # -- Abzeichen ---------------------------------------------------- + # + # 🔴 Der Fall vom 11.10.2026: Eine Fehlerkennung („Straßentaube", 1 von + # 27 Bildern mit Vogel) brachte das Abzeichen „Seltener Gast". Die + # Sichtung ließ sich entfernen — die 200 Punkte des Abzeichens blieben + # und trugen eine Stufe, die es nicht gab. Die Beschreibung des Befehls + # behauptete dabei, die Punkte verschwänden von selbst. + + def _mit_abzeichen(self): + """Hängt ein Abzeichen an die falsche Sichtung, wie scoring es tut.""" + abzeichen = Badge.objects.create( + code="seltener_gast_test", name="Seltener Gast", + description="Eine seltene Art", emoji="💎", xp=200, + ) + EarnedBadge.objects.create(badge=abzeichen, sighting=self.falsche) + return abzeichen + + def test_abzeichen_bleibt_ohne_schalter(self): + """Vorgabe bleibt Vorgabe — verdient ist verdient.""" + self._mit_abzeichen() + + self._entfernen(self.falsche.pk) + + self.assertTrue( + EarnedBadge.objects.filter(badge__code="seltener_gast_test").exists()) + + def test_abzeichen_wird_genannt_statt_stillschweigend_zu_bleiben(self): + """Wer nichts davon erfährt, sucht die Punkte an der falschen Stelle.""" + self._mit_abzeichen() + ausgabe = StringIO() + + call_command("sichtung_entfernen", self.falsche.pk, + trocken=True, stdout=ausgabe) + + text = ausgabe.getvalue() + self.assertIn("Seltener Gast", text) + self.assertIn("200", text) + + def test_schalter_nimmt_abzeichen_und_punkte_mit(self): + self._mit_abzeichen() + vorher = scoring.total_xp() + punkte = self.falsche.xp_awarded + + self._entfernen(self.falsche.pk, abzeichen=True) + + self.assertFalse( + EarnedBadge.objects.filter(badge__code="seltener_gast_test").exists()) + self.assertEqual(scoring.total_xp(), vorher - punkte - 200) + + def test_der_katalogeintrag_bleibt(self): + """Nur das Verdienst fällt weg, nicht das Abzeichen als solches. + + ⚠️ Sonst wäre es für immer unerreichbar — und `seed_dex` müsste es + beim nächsten Start wieder anlegen. + """ + self._mit_abzeichen() + + self._entfernen(self.falsche.pk, abzeichen=True) + + self.assertTrue(Badge.objects.filter(code="seltener_gast_test").exists()) + + def test_schalter_ohne_abzeichen_stoert_nicht(self): + self._entfernen(self.falsche.pk, abzeichen=True) + self.assertFalse(Sighting.objects.filter(pk=self.falsche.pk).exists()) + + +class StaticVersionTests(TestCase): + """Das Stylesheet braucht einen Stempel in der URL. Ohne ihn behält ein + Browser die alte Datei, obwohl der Server längst die neue ausliefert — + eine Fehlersuche an etwas, das nicht kaputt ist.""" + + def test_stylesheet_traegt_einen_stempel(self): + for pfad in ("/", "/widget/"): + with self.subTest(pfad=pfad): + self.assertRegex( + self.client.get(pfad).content.decode(), + r'href="[^"]*vogelalbum\.css\?v=\d+"', + ) + + def test_stempel_folgt_der_datei(self): + """Gleiche Datei, gleicher Stempel — sonst wäre der Cache wertlos.""" + from dex.templatetags.dex_extras import static_v + + self.assertEqual(static_v("css/vogelalbum.css"), static_v("css/vogelalbum.css")) + + def test_unbekannte_datei_bleibt_ohne_stempel(self): + """Kein Absturz, wenn eine Datei fehlt — dann eben die nackte URL.""" + from dex.templatetags.dex_extras import static_v + + self.assertNotIn("?v=", static_v("css/gibtsnicht.css")) + + +class ConfigApiTests(TestCase): + """`/api/config/` — die Web-App gibt dem Detector vor, was gilt. + + Vorher stand das auf der Kommandozeile des Rechners, auf dem der Detector + läuft; wer eine Kamera umstellen wollte, brauchte eine Shell. + """ + + def setUp(self): + self.token = "kamera-token-1" + self.cam = make_camera( + token=self.token, + stream_url="http://10.0.0.9:8080/stream", + habitats="garten", + detect_threshold=0.35, + min_bird_score=0.07, + ) + self.garten = make_species(lebensraum_typ="garten", regions=["de"]) + self.wasser = make_species( + slug="hoeckerschwan", german="Höckerschwan", scientific="Cygnus olor", + lebensraum_typ="wasser", regions=["de"], + ) + self.amerikaner = make_species( + slug="carolina-chickadee", german="Carolinameise", + scientific="Poecile carolinensis", lebensraum_typ="garten", + regions=["na"], + ) + + def hole(self, token=None): + return self.client.get( + "/api/config/", + headers={"authorization": f"Bearer {token or self.token}"}, + ) + + def test_ohne_token_abgelehnt(self): + self.assertEqual(self.client.get("/api/config/").status_code, 401) + + def test_falscher_token_abgelehnt(self): + self.assertEqual(self.hole("quatsch").status_code, 401) + + def test_liefert_schwellen_und_quelle(self): + d = self.hole().json() + self.assertEqual(d["kamera"], "vogelhaus") + self.assertEqual(d["stream_url"], "http://10.0.0.9:8080/stream") + self.assertEqual(d["detect_threshold"], 0.35) + self.assertEqual(d["min_bird_score"], 0.07) + self.assertEqual(d["habitats"], ["garten"]) + + def test_lebensraum_schraenkt_die_arten_ein(self): + """Eine Kamera am Futterhaus soll nicht erwägen, ob da ein Schwan sitzt.""" + namen = [a["scientific"] for a in self.hole().json()["arten"]] + self.assertIn(self.garten.scientific, namen) + self.assertNotIn(self.wasser.scientific, namen) + + def test_region_schraenkt_die_arten_ein(self): + """Der eigentliche Zweck des Regionsfelds: das Modell ist auf + Nordamerika trainiert, hier soll es nur deutsche Arten erwägen.""" + namen = [a["scientific"] for a in self.hole().json()["arten"]] + self.assertNotIn(self.amerikaner.scientific, namen) + + with self.settings(REGION="na"): + namen = [a["scientific"] for a in self.hole().json()["arten"]] + self.assertEqual(namen, [self.amerikaner.scientific]) + + def test_leere_lebensraeume_lassen_alles_zu(self): + self.cam.habitats = "" + self.cam.save() + namen = [a["scientific"] for a in self.hole().json()["arten"]] + self.assertIn(self.wasser.scientific, namen) + + def test_arten_tragen_die_beschriftung(self): + art = self.hole().json()["arten"][0] + for feld in ("scientific", "german", "english", "rarity", "lebensraum"): + self.assertIn(feld, art) + + def test_einzelkamera_token_bekommt_vorgaben(self): + """Ohne registrierte Kamera soll man nicht erst eine anlegen müssen.""" + with self.settings(INGEST_TOKEN="global-token"): + d = self.hole("global-token").json() + self.assertEqual(d["kamera"], "") + self.assertEqual(d["habitats"], ["garten"]) + self.assertTrue(d["arten"]) + + def test_post_ist_nicht_erlaubt(self): + resp = self.client.post( + "/api/config/", headers={"authorization": f"Bearer {self.token}"} + ) + self.assertEqual(resp.status_code, 405) + + +class BildgueteTests(TestCase): + """Das „beste Foto" ist nicht das mit der höchsten Erkennungssicherheit. + + 🔑 Am 30.09.2026 wurde eine verwischte Aufnahme zum besten Foto der + Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel und + der Kopf nicht erkennbar. Die scharfe Aufnahme (0,936 · 23 von 67) + landete dahinter. + + ⚠️ Die Sicherheit sagt, wie sicher sich das Modell bei der ART ist — + nicht, ob das Bild etwas taugt. + """ + + def setUp(self): + self.art = make_species() + + def _sichtung(self, confidence, mit_vogel, gesamt, wert): + s = make_sighting(self.art, confidence=confidence) + s.frames_with_bird = mit_vogel + s.frames_total = gesamt + s.mean_bird_score = wert + s.save() + return s + + def test_scharfes_bild_schlaegt_hoehere_sicherheit(self): + verwischt = self._sichtung(0.945, 5, 31, 0.523) + scharf = self._sichtung(0.936, 23, 67, 0.600) + + self.assertGreater(scoring.bildguete(scharf), + scoring.bildguete(verwischt)) + self.assertEqual(scoring.beste_sichtung([verwischt, scharf]), scharf) + + def test_ohne_messwerte_entscheidet_die_sicherheit(self): + """Alte Sichtungen haben keine Bildzähler — dann bleibt nur sie. + + ⚠️ frames_total ist 0 und mean_bird_score 0.0 (so die Vorgaben im + Modell, NICHT NULL). Die Güte wird damit null, und der Rückfall + auf die Sicherheit muss greifen — sonst wäre die Reihenfolge + alter Sichtungen zufällig. + """ + alt = make_sighting(self.art, confidence=0.4) + neu = make_sighting(self.art, confidence=0.8) + self.assertEqual(alt.frames_total, 0) + self.assertEqual(alt.mean_bird_score, 0.0) + self.assertEqual(scoring.beste_sichtung([alt, neu]), neu) + + def test_ohne_sichtungen_kein_bestes(self): + self.assertIsNone(scoring.beste_sichtung([])) + + def test_register_waehlt_nach_guete(self): + """Auch beim Aufnehmen zählt die Güte, nicht die Sicherheit.""" + gut = self._sichtung(0.60, 40, 80, 0.7) + scoring.register(gut) + schwach = self._sichtung(0.99, 2, 80, 0.1) + scoring.register(schwach) + + eintrag = DexEntry.objects.get(species=self.art) + self.assertEqual(eintrag.best_sighting_id, gut.pk, + "Die schwache Aufnahme hat die gute verdrängt") + + +class ZaehlungTests(TestCase): + """Was als Besuch zaehlt. + + 🔑 Diese Klasse gibt es, weil 98 gruene Tests den Fehler nicht gefangen + haben: Am 30.09.2026 meldete die Uebersicht "31 Besuche insgesamt" bei + sieben echten Voegeln und "2 Arten heute" bei einer. Getestet wurden + bis dahin nur die Formeln, nie die Frage, WORUEBER gezaehlt wird. + """ + + def setUp(self): + self.kohlmeise = make_species() + self.blaumeise = make_species( + slug="blaumeise", german="Blaumeise", + scientific="Cyanistes caeruleus") + + def test_unbestimmte_sichtung_ist_kein_besuch(self): + """Spiegelungen auf der Vogeltraenke duerfen nicht mitzaehlen.""" + make_sighting(species=self.kohlmeise) + for _ in range(24): + make_sighting(species=None) + + self.assertEqual(Sighting.objects.count(), 25) # Zeilen + self.assertEqual(Sighting.objects.bestimmt().count(), 1) # Besuche + self.assertEqual(scoring.progress().sightings, 1) + + def test_unbestimmte_sichtung_bleibt_in_der_liste(self): + """📌 Nicht zaehlen heisst nicht verstecken -- sie zeigt, dass die + Kamera arbeitet, und laesst sich nachbestimmen.""" + make_sighting(species=None) + self.assertEqual(Sighting.objects.count(), 1) + + def test_korrigierte_art_zaehlt_bei_der_neuen_art(self): + """⚠️ Der Fehler vom 30.09.: gezaehlt wurde die ROHE Erkennung.""" + s = make_sighting(species=self.kohlmeise) + s.confirmed_species = self.kohlmeise + s.save() + falsch = make_sighting(species=self.blaumeise) + falsch.confirmed_species = self.kohlmeise # war in Wahrheit auch eine + falsch.save() + + self.assertEqual(Sighting.objects.arten_anzahl(), 1) + + def test_arten_heute_folgt_der_korrektur(self): + """Die Kachel 'Arten heute' auf der Startseite.""" + make_sighting(species=self.kohlmeise) + falsch = make_sighting(species=self.blaumeise) + falsch.confirmed_species = self.kohlmeise + falsch.save() + make_sighting(species=None) # Spiegelung, zaehlt nicht + + antwort = self.client.get("/") + self.assertEqual(antwort.context["today_species"], 1) + self.assertEqual(antwort.context["today_count"], 2) + + def test_bestaetigung_ohne_erkennung_zaehlt_trotzdem(self): + """Von Hand bestimmt, obwohl das Modell nichts erkannt hat.""" + s = make_sighting(species=None) + s.confirmed_species = self.blaumeise + s.save() + self.assertEqual(Sighting.objects.bestimmt().count(), 1) + self.assertEqual(Sighting.objects.arten_anzahl(), 1) + + def test_abzeichen_balken_zaehlt_keine_spiegelungen(self): + make_sighting(species=self.kohlmeise) + for _ in range(24): + make_sighting(species=None) + ist, ziel = scoring.fortschritt_je_abzeichen()["besucher_100"] + self.assertEqual((ist, ziel), (1, 100)) + + def test_straehne_zaehlt_nur_tage_mit_echtem_besuch(self): + """Ein Tag mit nur einer Spiegelung unterbricht die Straehne.""" + jetzt = timezone.now() + make_sighting(species=self.kohlmeise, when=jetzt) + make_sighting(species=None, when=jetzt - timedelta(days=1)) + make_sighting(species=self.kohlmeise, when=jetzt - timedelta(days=2)) + self.assertEqual(scoring._laengste_straehne(), 1) + + +def mini_jpeg(name="vogel.jpg") -> SimpleUploadedFile: + """Ein echtes, winziges JPEG. + + ⚠️ Kein erfundener Bytestring: ``ImageField`` prüft beim Speichern mit + Pillow, ob die Datei ein Bild ist, und lehnt alles andere ab. + """ + from PIL import Image + + puffer = BytesIO() + Image.new("RGB", (8, 8), (90, 140, 60)).save(puffer, format="JPEG") + return SimpleUploadedFile(name, puffer.getvalue(), content_type="image/jpeg") + + +# ⚠️ Eigener Medienordner für die Tests. Ohne das schreiben sie echte Bilder +# nach MEDIA_ROOT -- beim ersten Lauf am 01.10.2026 lagen prompt sechs +# Testdateien zwischen den Vogelfotos und wären beim nächsten Deploy mit +# aufs Volume gewandert. +@override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="vogelalbum-tests-")) +class WarteschlangeTests(TestCase): + """Fotos von unterwegs: hochladen, abholen, Ergebnis nachtragen.""" + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + scoring.sync_badges() + self.species = make_species() + self.camera = make_camera(slug="handy", token="handy-token", mobil=True) + + def post_upload(self, token="handy-token", **felder): + daten = {"bilder": felder.pop("bilder", [mini_jpeg()]), **felder} + return self.client.post( + "/api/hochladen/", daten, + headers={"Authorization": f"Bearer {token}"}, + ) + + def test_upload_legt_wartende_sichtung_an(self): + resp = self.post_upload() + self.assertEqual(resp.status_code, 201) + s = Sighting.objects.get() + self.assertTrue(s.wartet_auf_bestimmung) + self.assertIsNone(s.species) + self.assertEqual(s.camera, self.camera) + + def test_upload_ohne_token_wird_abgewiesen(self): + self.assertEqual(self.post_upload(token="falsch").status_code, 401) + self.assertEqual(Sighting.objects.count(), 0) + + def test_upload_ohne_bild_wird_abgewiesen(self): + resp = self.client.post( + "/api/hochladen/", {}, + headers={"Authorization": "Bearer handy-token"}, + ) + self.assertEqual(resp.status_code, 400) + + def test_mehrere_bilder_werden_einzelne_sichtungen(self): + """Zehn Fotos vom Spaziergang sind zehn Sichtungen, nicht eine.""" + resp = self.post_upload( + bilder=[mini_jpeg("a.jpg"), mini_jpeg("b.jpg"), mini_jpeg("c.jpg")] + ) + self.assertEqual(resp.status_code, 201) + self.assertEqual(Sighting.objects.count(), 3) + self.assertEqual(Sighting.objects.wartend().count(), 3) + + def test_wartende_zaehlen_nicht_als_besuch(self): + """Sonst springt der Zähler hoch und nach der Bestimmung zurück.""" + self.post_upload() + self.assertEqual(Sighting.objects.bestimmt().count(), 0) + self.assertEqual(scoring.progress().sightings, 0) + + def test_upload_uebernimmt_ort_gerundet(self): + self.post_upload(latitude="49.48753", longitude="8.46601") + s = Sighting.objects.get() + self.assertEqual(s.latitude, 49.49) + self.assertEqual(s.longitude, 8.47) + + def test_offen_listet_nur_wartende(self): + """Fertige Sichtungen gehören nicht in die Warteschlange.""" + self.post_upload() + make_sighting(self.species) + with self.settings(LESE_TOKEN="lese-token"): + resp = self.client.get( + "/api/offen/", headers={"Authorization": "Bearer lese-token"}) + self.assertEqual(resp.status_code, 200) + fotos = resp.json()["fotos"] + self.assertEqual(len(fotos), 1) + self.assertEqual(fotos[0]["id"], Sighting.objects.wartend().get().id) + self.assertTrue(fotos[0]["bild"].endswith(".jpg")) + + def test_offen_ohne_token_wird_abgewiesen(self): + self.post_upload() + self.assertEqual(self.client.get("/api/offen/").status_code, 401) + + def test_ergebnis_macht_aus_wartend_eine_sichtung(self): + self.post_upload() + s = Sighting.objects.get() + resp = self.client.post( + f"/api/offen/{s.id}/", + {"data": json.dumps({ + "species": "kohlmeise", "confidence": 0.9, + "mean_bird_score": 0.44, "votes": {"kohlmeise": 0.9}, + })}, + headers={"Authorization": "Bearer handy-token"}, + ) + self.assertEqual(resp.status_code, 200) + s.refresh_from_db() + self.assertFalse(s.wartet_auf_bestimmung) + self.assertEqual(s.species, self.species) + self.assertEqual(Sighting.objects.bestimmt().count(), 1) + self.assertTrue(resp.json()["erstfang"]) + + def test_ergebnis_ohne_vogel_beendet_das_warten_trotzdem(self): + """Sonst bearbeitet der Detector dasselbe Bild endlos erneut.""" + self.post_upload() + s = Sighting.objects.get() + resp = self.client.post( + f"/api/offen/{s.id}/", + {"data": json.dumps({"species": None, "mean_bird_score": 0.0})}, + headers={"Authorization": "Bearer handy-token"}, + ) + self.assertEqual(resp.status_code, 200) + s.refresh_from_db() + self.assertFalse(s.wartet_auf_bestimmung) + self.assertIsNone(s.species) + self.assertEqual(Sighting.objects.wartend().count(), 0) + + def test_ergebnis_zweimal_melden_geht_nicht(self): + self.post_upload() + s = Sighting.objects.get() + daten = {"data": json.dumps({"species": "kohlmeise", "confidence": 0.9})} + kopf = {"Authorization": "Bearer handy-token"} + self.assertEqual( + self.client.post(f"/api/offen/{s.id}/", daten, headers=kopf).status_code, 200) + self.assertEqual( + self.client.post(f"/api/offen/{s.id}/", daten, headers=kopf).status_code, 404) + + +class MobilGeraetTests(TestCase): + """Ein Handy darf nicht nach den Regeln eines Vogelhauses überwacht werden.""" + + def test_stille_misst_das_lebenszeichen_nicht_den_letzten_vogel(self): + """🔑 Der Fehler vom 01.10.2026 auf dem Dev-Server. + + Beide Kameras trugen das Warnzeichen, obwohl beide liefen: Das + Lebenszeichen war eine Minute alt, der letzte Vogel aber 1810 bzw. + 5435 Minuten. An ``last_seen`` zu messen warnt, wenn nichts los ist — + nicht, wenn etwas kaputt ist. + """ + laeuft = make_camera( + slug="laeuft", token="t-l", + last_seen=timezone.now() - timedelta(hours=30), # lange kein Vogel + letzter_kontakt=timezone.now() - timedelta(minutes=1), + ) + self.assertFalse( + laeuft.is_stale, + "Eine Kamera ohne Vogel, aber mit frischem Lebenszeichen, laeuft") + + haengt = make_camera( + slug="haengt", token="t-h", + last_seen=timezone.now() - timedelta(minutes=5), # gerade noch Vogel + letzter_kontakt=timezone.now() - timedelta(hours=3), + ) + self.assertTrue( + haengt.is_stale, + "Ohne Lebenszeichen seit drei Stunden haengt sie, trotz Vogel") + + def test_mobiles_geraet_bekommt_kein_warnzeichen(self): + """is_stale gilt fuer Daueraufnahme, nicht fuer ein Handy. + + ⚠️ Das Warnzeichen im Kamerafilter haengt daran. Ein Zeichen, das + dauerhaft steht, liest nach einer Woche niemand mehr. + """ + alt = timezone.now() - timedelta(days=3) + handy = make_camera(slug="h", token="th", mobil=True, letzter_kontakt=alt) + haus = make_camera(slug="v", token="tv", mobil=False, letzter_kontakt=alt) + self.assertFalse(handy.is_stale) + self.assertTrue(haus.is_stale) + + def test_mobiles_geraet_ohne_lebenszeichen_ist_nicht_still(self): + """Ein frisch angelegtes Handy hat noch nie geliefert — trotzdem ok.""" + self.assertFalse(make_camera(slug="neu", token="tn", mobil=True).is_stale) + + def test_mobiles_geraet_wird_nicht_als_still_gemeldet(self): + """Sonst kommt nach dem ersten ruhigen Wochenende ein Fehlalarm.""" + alt = timezone.now() - timedelta(days=3) + make_camera(slug="handy", token="t-handy", mobil=True, letzter_kontakt=alt) + make_camera(slug="haus", token="t-haus", mobil=False, letzter_kontakt=alt) + + ausgabe = StringIO() + call_command("stoerungen_pruefen", "--trocken", stdout=ausgabe) + text = ausgabe.getvalue() + self.assertIn("Haus", text) + self.assertNotIn("Handy", text) + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="vogelalbum-form-")) +class HochladenSeiteTests(TestCase): + """Das Formular in der Web-App — der Weg ohne App.""" + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + def setUp(self): + scoring.sync_badges() + make_species() + from django.contrib.auth.models import User + self.user = User.objects.create_user( + "testnutzer", password="geheim", is_staff=True) + + def test_ohne_anmeldung_kein_zugang(self): + """⚠️ Die Instanz darf öffentlich stehen — dann wäre ein offenes + Formular eine Einladung, fremde Bilder in eine fremde Sammlung zu + legen.""" + resp = self.client.get("/hochladen/") + self.assertIn(resp.status_code, (302, 403)) + self.assertEqual(Sighting.objects.count(), 0) + + def test_ohne_anmeldung_auch_kein_post(self): + resp = self.client.post("/hochladen/", {"bilder": [mini_jpeg()]}) + self.assertIn(resp.status_code, (302, 403)) + self.assertEqual(Sighting.objects.count(), 0) + + def test_angemeldet_sichtbar(self): + self.client.force_login(self.user) + resp = self.client.get("/hochladen/") + self.assertEqual(resp.status_code, 200) + + def test_hochladen_legt_wartende_sichtung_an(self): + self.client.force_login(self.user) + resp = self.client.post( + "/hochladen/", + {"bilder": [mini_jpeg("a.jpg"), mini_jpeg("b.jpg")], + "latitude": "49.48753", "longitude": "8.46601"}, + ) + # Umleitung nach dem Hochladen, damit Neuladen nicht doppelt einliefert + self.assertEqual(resp.status_code, 302) + self.assertEqual(Sighting.objects.wartend().count(), 2) + s = Sighting.objects.wartend().first() + self.assertEqual(s.latitude, 49.49) + self.assertTrue(s.wartet_auf_bestimmung) + + def test_ohne_datei_meldet_fehler_statt_leerer_sichtung(self): + self.client.force_login(self.user) + resp = self.client.post("/hochladen/", {}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(Sighting.objects.count(), 0) + + def test_knopf_nur_fuer_angemeldete(self): + """⚠️ Ein öffentlicher Besucher soll keinen Knopf sehen, der ihn bloß + ins Login schickt — einliefern darf er ohnehin nicht.""" + ohne = nur_sichtbares(self.client.get("/sichtungen/")) + self.assertNotIn("/hochladen/", ohne) + + self.client.force_login(self.user) + mit = nur_sichtbares(self.client.get("/sichtungen/")) + self.assertIn("/hochladen/", mit) + + def test_keine_kommentare_stehen_sichtbar_auf_der_seite(self): + """⚠️ ``{# … #}`` ist in Django EINZEILIG. + + Mehrzeilig geschrieben steht der Kommentartext sichtbar auf der Seite. + Das ist im Projekt schon zweimal passiert, zuletzt auf der + Abzeichen-Seite (Commit 5699f5f). Hier werden deshalb + ``{% comment %}``-Blöcke benutzt — dieser Test hält fest, dass das so + bleibt. + """ + self.client.force_login(self.user) + seite = nur_sichtbares(self.client.get("/hochladen/")) + for satz in ("multiple ist Absicht", + "Von einem Spaziergang kommen selten", + "hielte man den Upload für fehlgeschlagen", + "sollen zeigen, dass"): + self.assertNotIn(satz, seite, f"Kommentartext steht auf der Seite: {satz}") + + def test_formular_und_schnittstelle_nehmen_denselben_weg(self): + """🔑 Der eigentliche Zweck der Seite: dieselbe Warteschlange. + + Läuft das Formular, ist die Kette bewiesen, bevor eine Zeile App + existiert. + """ + self.client.force_login(self.user) + self.client.post("/hochladen/", {"bilder": [mini_jpeg()]}) + ueber_formular = Sighting.objects.wartend().count() + + kamera = make_camera(slug="handy2", token="t2", mobil=True) + self.client.post( + "/api/hochladen/", {"bilder": [mini_jpeg()]}, + headers={"Authorization": "Bearer t2"}, + ) + self.assertEqual(Sighting.objects.wartend().count(), ueber_formular + 1) + # Beide landen in derselben Warteschlange, die der Detector abfragt. + self.assertTrue( + all(s.wartet_auf_bestimmung for s in Sighting.objects.all())) + + +class UebersetzungsabdeckungTests(SimpleTestCase): + """Jeder übersetzbare Text braucht eine englische Fassung — strukturell. + + 🔑 Warum als Test und nicht als Hinweis im README: Am 03.10.2026 wurde die + Startseite umgebaut und ``makemessages`` danach nicht ausgeführt. Die + Oberfläche blieb auf Englisch deutsch, und *nichts* schlug an — die alte + .po-Datei war ja in sich vollständig, sie kannte die neuen Texte nur nicht. + Eine Stichprobe wie ``EnglishTests`` fängt das nicht: sie prüft die Texte, + die es schon gab. + + Dieser Test geht deshalb vom Quelltext aus, nicht von der .po-Datei. Er + benutzt dafür ``templatize`` — dieselbe Funktion, mit der ``makemessages`` + aus einem Template die übersetzbaren Zeichenketten zieht — und braucht + damit kein installiertes gettext. + """ + + LITERAL = re.compile( + r"""\bn?gettext(?:_lazy)?\(\s*u?('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")""" + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + import importlib.util + + pfad = settings.BASE_DIR.parent / "scripts" / "update_translations.py" + spec = importlib.util.spec_from_file_location("update_translations", pfad) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + cls.bekannt = set(modul.TRANSLATIONS) + + def _literale(self, quelltext: str) -> set[str]: + import ast + + gefunden = set() + for treffer in self.LITERAL.findall(quelltext): + try: + gefunden.add(ast.literal_eval(treffer)) + except (ValueError, SyntaxError): + continue + return gefunden + + def _texte_der_oberflaeche(self) -> set[str]: + from django.utils.translation.template import templatize + + texte: set[str] = set() + wurzel = settings.BASE_DIR + + for vorlage in (wurzel / "templates").rglob("*.html"): + texte |= self._literale(templatize(vorlage.read_text("utf-8"))) + + # Texte aus dem Python-Teil: Ränge, Abzeichen, Admin-Hilfe, Meldungen. + for quelle in (wurzel / "dex").rglob("*.py"): + if "migrations" in quelle.parts or quelle.name == "tests.py": + continue + texte |= self._literale(quelle.read_text("utf-8")) + + return texte + + def test_jeder_text_hat_eine_englische_fassung(self): + fehlend = sorted(self._texte_der_oberflaeche() - self.bekannt) + self.assertEqual( + fehlend, [], + "Diese Texte haben keine englische Fassung. Eintragen in " + "scripts/update_translations.py, dann:\n" + " cd web && ../venv/bin/python manage.py makemessages -l en --ignore=venv\n" + " ../venv/bin/python scripts/update_translations.py\n" + " cd web && ../venv/bin/python manage.py compilemessages -l en\n" + + "\n".join(f" {t!r}: ...," for t in fehlend), + ) + + def test_po_datei_hat_keine_luecken(self): + """Die ausgelieferte .po darf keinen leeren und keinen fuzzy-Eintrag haben. + + ⚠ Beides ist im Betrieb dasselbe Symptom: ``msgfmt`` wirft fuzzy weg, + der deutsche Text bleibt stehen. Als Fehler sieht man es nie. + """ + po = settings.BASE_DIR / "locale/en/LC_MESSAGES/django.po" + zeilen = po.read_text("utf-8").split("\n") + + fuzzy, leer = [], [] + letzter_msgid = "" + for nr, zeile in enumerate(zeilen): + if zeile.startswith("#,") and "fuzzy" in zeile: + # Der Kopf der Datei trägt gelegentlich selbst eine Marke. + if nr > 6: + fuzzy.append(nr + 1) + if zeile.startswith("msgid "): + letzter_msgid = zeile + leere_form = zeile == 'msgstr ""' or re.fullmatch( + r'msgstr\[\d+\] ""', zeile) + if leere_form and letzter_msgid not in ("", 'msgid ""'): + # Mehrzeilige msgstr beginnen ebenfalls so — nur zählen, wenn + # keine Fortsetzungszeile folgt. + folgt = zeilen[nr + 1] if nr + 1 < len(zeilen) else "" + if not folgt.startswith('"'): + leer.append(nr + 1) + + self.assertEqual(fuzzy, [], f"fuzzy-Einträge in {po.name}, Zeilen {fuzzy}") + self.assertEqual(leer, [], f"leere Übersetzungen in {po.name}, Zeilen {leer}") + + def test_kompilierte_fassung_ist_nicht_veraltet(self): + """Eine vorhandene .mo muss mindestens so neu sein wie die .po. + + Sonst läuft hier eine ältere Übersetzung als die, die im Repo steht — + und man sucht den Fehler in einer Datei, die längst richtig ist. + + ⚠ Fehlt die .mo ganz, ist das **kein** Fehler: sie steht absichtlich + nicht im Repo (`.gitignore`), das Docker-Image baut sie beim Bauen. + Nach einem frischen Klon wäre ein harter Test hier rot, ohne dass + etwas kaputt ist — und ein Test, der nach dem Klonen rot ist, + erzieht nur dazu, ihn zu überspringen. + """ + ordner = settings.BASE_DIR / "locale/en/LC_MESSAGES" + po, mo = ordner / "django.po", ordner / "django.mo" + if not mo.exists(): + self.skipTest( + "django.mo noch nicht kompiliert (gehört nicht ins Repo) — " + "'manage.py compilemessages -l en' baut sie" + ) + self.assertGreaterEqual( + mo.stat().st_mtime, po.stat().st_mtime, + "django.mo ist älter als django.po — compilemessages laufen lassen", + ) + + +class BlickfangTitelTests(TestCase): + """Die Überschrift der Startseite muss stimmen, nicht nur hübsch sein. + + 🔴 Vorher stand dort fest „Wer war heute im Futterhaus?". Am 09.10.2026 + war der letzte Gast fünf Tage her — die Seite fragte trotzdem nach heute, + und direkt daneben stand „Sonntag, 04.10.". Gemeldet als: *„Er ist immer auf + Deutsch und macht keinen Sinn."* + """ + + def setUp(self): + self.art = make_species() + + def _titel_bei(self, tage_zurueck, sprache="de"): + """⚠ Sprache ausdrücklich setzen: die aktive Sprache überlebt einen + Testlauf, und ein vorangegangener englischer Request färbt sonst das + Ergebnis — der Test misst dann die falsche Fassung.""" + from django.utils import translation + + from .views import blickfang_titel + + wann = timezone.now() - timedelta(days=tage_zurueck) + sichtung = make_sighting(species=self.art, when=wann) + with translation.override(sprache): + return blickfang_titel(sichtung, timezone.localdate()) + + def test_ohne_sichtung_wartet_die_kamera(self): + from django.utils import translation + + from .views import blickfang_titel + + with translation.override("de"): + self.assertEqual( + blickfang_titel(None, timezone.localdate()), + "Noch wartet die Kamera") + + def test_heute_fragt_nach_heute(self): + self.assertEqual(self._titel_bei(0), "Wer war heute zu Gast?") + + def test_gestern_sagt_gestern(self): + self.assertEqual(self._titel_bei(1), "Zuletzt gestern zu Gast") + + def test_diese_woche_nennt_den_wochentag(self): + titel = self._titel_bei(3) + self.assertNotIn("heute", titel) + wochentag = (timezone.localdate() - timedelta(days=3)).strftime("%A") + deutsch = { + "Monday": "Montag", "Tuesday": "Dienstag", "Wednesday": "Mittwoch", + "Thursday": "Donnerstag", "Friday": "Freitag", + "Saturday": "Samstag", "Sunday": "Sonntag", + }[wochentag] + self.assertEqual(titel, f"Zuletzt am {deutsch} zu Gast") + + def test_aelter_als_eine_woche_nennt_das_datum(self): + titel = self._titel_bei(9) + self.assertNotIn("heute", titel) + self.assertIn(str((timezone.localdate() - timedelta(days=9)).year), titel) + + def test_startseite_behauptet_kein_heute_bei_altem_besuch(self): + """Der gemeldete Fall — einmal durch die echte Seite.""" + make_sighting(species=self.art, when=timezone.now() - timedelta(days=5)) + seite = nur_sichtbares(self.client.get("/")) + self.assertNotIn("Wer war heute", seite) + + def test_englische_startseite_bleibt_englisch(self): + make_sighting(species=self.art, when=timezone.now()) + resp = self.client.get("/", headers={"accept-language": "en"}) + self.assertContains(resp, "Who stopped by today?") + self.assertNotIn("Wer war heute", nur_sichtbares(resp)) + + +class ZeitpunktFilterTests(TestCase): + """``|zeitpunkt`` statt ``|date:"d.m."`` — das Format war deutsch + verdrahtet und stand auf der englischen Fassung falsch herum.""" + + def _gerendert(self, wann, sprache="de"): + from django.template import Context, Template + from django.utils import translation + + with translation.override(sprache): + return Template( + '{% load dex_extras %}{{ w|zeitpunkt }}' + ).render(Context({"w": wann})) + + def test_heute(self): + self.assertTrue(self._gerendert(timezone.now()).startswith("heute, ")) + + def test_gestern(self): + wert = self._gerendert(timezone.now() - timedelta(days=1)) + self.assertTrue(wert.startswith("gestern, "), wert) + + def test_englisch_sagt_today(self): + self.assertTrue(self._gerendert(timezone.now(), "en").startswith("today, ")) + + def test_leerer_wert_bricht_nicht(self): + self.assertEqual(self._gerendert(None), "") + + def test_altes_datum_nennt_das_jahr(self): + alt = timezone.now() - timedelta(days=400) + self.assertIn(str(timezone.localtime(alt).year), self._gerendert(alt)) diff --git a/web/dex/views.py b/web/dex/views.py new file mode 100644 index 0000000..d42ddb0 --- /dev/null +++ b/web/dex/views.py @@ -0,0 +1,445 @@ +"""Ansichten des Vogelalbum.""" + +from __future__ import annotations + +from collections import Counter +from datetime import timedelta + +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count, Max, Q +from django.http import Http404, JsonResponse +from django.conf import settings +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ +from django.views.decorators.clickjacking import xframe_options_exempt + +from . import scoring +from .api import fotos_annehmen +from .bilder import KeinBild +from .models import Badge, Camera, DexEntry, Sighting, Species +from .species_meta import HABITATS, RARITY_COLOR, TYPE_STYLE + + +def _base_context() -> dict: + return { + "progress": scoring.progress(), + # Der Live-Link in der Navigation erscheint nur, wenn es etwas zu sehen + # gibt — auf der öffentlichen Instanz also nie. + "live_available": Camera.live_cameras().exists(), + # Pflicht aus der AGPL, nicht Dekoration — siehe settings.QUELLTEXT_URL. + "quelltext_url": settings.QUELLTEXT_URL, + } + + +def blickfang_titel(letzte, heute) -> str: + """Die Überschrift der Startseite, passend zum letzten Besuch. + + 🔑 Vorher stand dort fest „Wer war heute im Futterhaus?" — auch wenn der + letzte Gast fünf Tage her war. Eine Überschrift, die etwas behauptet, was + daneben widerlegt wird, kostet mehr Vertrauen als sie Schwung bringt. + + Wochentag und Datum kommen über ``date_format``, damit sie der aktiven + Sprache folgen; ein selbst gebautes ``d.m.`` wäre auf Englisch falsch herum. + """ + from django.utils.formats import date_format + + if letzte is None: + return _("Noch wartet die Kamera") + + tag = timezone.localtime(letzte.started_at).date() + tage = (heute - tag).days + if tage <= 0: + return _("Wer war heute zu Gast?") + if tage == 1: + return _("Zuletzt gestern zu Gast") + if tage < 7: + return _("Zuletzt am %(wochentag)s zu Gast") % { + "wochentag": date_format(tag, "l") + } + return _("Zuletzt am %(datum)s zu Gast") % { + "datum": date_format(tag, "DATE_FORMAT") + } + + +def type_label(code: str) -> str: + from django.utils.translation import get_language + + from .species_meta import TYPE_LABEL_EN + + if (get_language() or "de").startswith("en"): + return TYPE_LABEL_EN.get(code, code) + return TYPE_STYLE.get(code, (code,))[0] + + +def habitat_label(code: str) -> str: + from django.utils.translation import get_language + + from .species_meta import HABITATS + + paar = HABITATS.get(code) + if not paar: + return code + return paar[1] if (get_language() or "de").startswith("en") else paar[0] + + +def rarity_choices() -> list[tuple[str, str]]: + """Seltenheitsstufen mit Beschriftung in der aktiven Sprache.""" + from django.utils.translation import get_language + + from .models import RARITY_CHOICES + from .species_meta import RARITY_LABEL_EN + + if (get_language() or "de").startswith("en"): + return [(k, RARITY_LABEL_EN.get(k, k)) for k, _ in RARITY_CHOICES] + return list(RARITY_CHOICES) + + +def home(request): + now = timezone.now() + today = timezone.localdate() + + recent = ( + Sighting.objects.select_related("species", "confirmed_species") + .exclude(species__isnull=True, confirmed_species__isnull=True) + .order_by("-started_at")[:8] + ) + + # ⚠️ ``bestimmt()``, nicht alles: Spiegelungen auf der Vogeltränke sind + # kein Besuch. Und ``arten_anzahl()`` statt values("species") — sonst + # zaehlt eine von Hand korrigierte Sichtung weiter bei der alten Art. + today_sightings = Sighting.objects.bestimmt().filter(started_at__date=today) + today_species = today_sightings.arten_anzahl() + + # Erstfänge der letzten Woche — das ist die Neuigkeit, die man sehen will. + new_entries = ( + DexEntry.objects.select_related("species", "best_sighting") + .filter(first_seen__gte=now - timedelta(days=7)) + .order_by("-first_seen")[:4] + ) + + top = ( + DexEntry.objects.select_related("species", "best_sighting") + .order_by("-count")[:5] + ) + + ctx = _base_context() | { + "recent": recent, + "blickfang_titel": blickfang_titel(recent[0] if recent else None, today), + "today_count": today_sightings.count(), + "today_species": today_species, + "new_entries": new_entries, + "top": top, + "badges": Badge.objects.filter(earned__isnull=False).order_by("-earned__earned_at")[:6], + "rarity_color": RARITY_COLOR, + } + return render(request, "dex/home.html", ctx) + + +def dex(request): + """Der Katalog. Ungefangene Arten bleiben Silhouette — genau das treibt + die Sammelmotivation.""" + entries = {e.species_id: e for e in DexEntry.objects.select_related("best_sighting")} + + rarity_filter = request.GET.get("seltenheit", "") + typ_filter = request.GET.get("typ", "") + # Ohne Vorgabe: Garten. 148 Arten auf einmal sind kein Ziel, sondern eine + # Wand — man soll eine Sammlung sehen, die sich füllen lässt. + habitat = request.GET.get("lebensraum", "garten") + if habitat == "alle": + habitat = "" + + species = Species.objects.all() + if habitat: + species = species.filter(lebensraum_typ=habitat) + if rarity_filter: + species = species.filter(rarity=rarity_filter) + if typ_filter: + species = species.filter(typ=typ_filter) + + cards = [ + {"species": s, "entry": entries.get(s.id), "found": s.id in entries} + for s in species + ] + + # Nach Dex-Nummer, wie die Seitenzahl im Album. Die Nummer ist bereits nach + # Seltenheit vergeben, die Sortierung liest sich also ohnehin sinnvoll — + # und sie bleibt stabil, egal was gefunden wurde. + cards.sort(key=lambda c: c["species"].number or 9999) + + # Nur Typen anbieten, die im aktiven Lebensraum auch vorkommen — sonst + # steht im Auswahlfeld "Möwe" neben einer leeren Gartensammlung. + im_bereich = Species.objects.all() + if habitat: + im_bereich = im_bereich.filter(lebensraum_typ=habitat) + zaehlung = Counter(im_bereich.values_list("typ", flat=True)) + typen = [ + { + "code": code, + "label": type_label(code), + "n": zaehlung[code], + "farbe": TYPE_STYLE[code][1], + "ink": TYPE_STYLE[code][2], + } + for code in TYPE_STYLE + if zaehlung.get(code) + ] + + habitate = [ + { + "code": code, + "label": habitat_label(code), + "n": Species.objects.filter(lebensraum_typ=code).count(), + } + for code in HABITATS + ] + habitate = [h for h in habitate if h["n"]] + + ctx = _base_context() | { + "cards": cards, + "found_count": sum(1 for c in cards if c["found"]), + "total_count": len(cards), + "rarity_filter": rarity_filter, + "typ_filter": typ_filter, + "habitat": habitat, + "habitate": habitate, + "habitat_label": habitat_label(habitat) if habitat else "", + "gesamt_alle": Species.objects.count(), + "typen": typen, + "rarity_color": RARITY_COLOR, + "rarities": rarity_choices(), + } + return render(request, "dex/dex.html", ctx) + + +def species_detail(request, slug: str): + species = get_object_or_404(Species, slug=slug) + entry = DexEntry.objects.filter(species=species).select_related("best_sighting").first() + sightings = ( + Sighting.objects.filter(Q(species=species) | Q(confirmed_species=species)) + .select_related("camera") + .order_by("-started_at")[:24] + ) + + # Blättern wie im Album: die Nachbarn nach Dex-Nummer. + vorher = Species.objects.filter(number__lt=species.number).order_by("-number").first() + nachher = Species.objects.filter(number__gt=species.number).order_by("number").first() + + # Verwechslungsgefahr in Katalogreihenfolge, damit die Reihe stabil bleibt. + aehnliche = list(Species.objects.filter(slug__in=species.verwechslung or [])) + aehnliche.sort(key=lambda s: (species.verwechslung or []).index(s.slug)) + + # Wann ist die Art aktiv? Über die Stunden der bisherigen Besuche. + hours = [0] * 24 + for dt in Sighting.objects.filter( + Q(species=species) | Q(confirmed_species=species) + ).values_list("started_at", flat=True): + hours[timezone.localtime(dt).hour] += 1 + peak = max(hours) or 1 + + ctx = _base_context() | { + "species": species, + "entry": entry, + "sightings": sightings, + "vorher": vorher, + "nachher": nachher, + "aehnliche": aehnliche, + "hours": [{"h": h, "n": n, "pct": round(100 * n / peak)} for h, n in enumerate(hours)], + "rarity_color": RARITY_COLOR, + } + return render(request, "dex/species.html", ctx) + + +def sightings(request): + qs = ( + Sighting.objects.select_related("species", "confirmed_species", "camera") + .order_by("-started_at") + ) + if request.GET.get("nur_bestimmt"): + qs = qs.exclude(species__isnull=True, confirmed_species__isnull=True) + + kamera = request.GET.get("kamera", "") + if kamera: + qs = qs.filter(camera__slug=kamera) + + try: + page = max(1, int(request.GET.get("seite", 1))) + except ValueError: + page = 1 + per_page = 40 + total = qs.count() + + cameras = list(Camera.objects.filter(active=True)) + + ctx = _base_context() | { + "sightings": qs[(page - 1) * per_page : page * per_page], + "page": page, + "has_next": page * per_page < total, + "total": total, + "nur_bestimmt": bool(request.GET.get("nur_bestimmt")), + "kamera": kamera, + # Der Filter lohnt sich erst, wenn es mehr als eine Kamera gibt. + "cameras": cameras if len(cameras) > 1 else [], + "rarity_color": RARITY_COLOR, + } + return render(request, "dex/sightings.html", ctx) + + +def badges(request): + earned_codes = set( + Badge.objects.filter(earned__isnull=False).values_list("code", flat=True) + ) + # 🔑 Nur die ZAEHLBAREN Abzeichen haben einen Fortschritt. "Ein Specht + # war da" passiert oder passiert nicht -- ein Balken auf 0 % waere dort + # keine Information, sondern nur entmutigend. Siehe scoring.py. + fortschritt = scoring.fortschritt_je_abzeichen() + + rows = [] + for badge in Badge.objects.all(): + verdient = badge.code in earned_codes + zeile = { + "badge": badge, + "earned": verdient, + "at": badge.earned.earned_at if verdient else None, + } + stand = fortschritt.get(badge.code) + # Bei verdienten Abzeichen keinen Balken mehr -- der stuende + # dauerhaft auf 100 % und sagt nichts. + if stand and not verdient: + ist, ziel = stand + zeile["ist"] = ist + zeile["ziel"] = ziel + zeile["anteil"] = min(100, round(ist * 100 / ziel)) if ziel else 0 + rows.append(zeile) + + return render(request, "dex/badges.html", _base_context() | {"rows": rows}) + + +@xframe_options_exempt +def widget(request): + """Kompakte Einbettung für eine fremde Seite — ohne Navigation. + + 🔴 **Ohne diesen Ausnahme-Dekorator ist das Widget nicht einbettbar**, und + zwar genau entgegen seinem Zweck: Djangos ``XFrameOptionsMiddleware`` + schickt über jede Antwort ``X-Frame-Options: DENY``, also auch über diese. + Der Docstring sagte „iframe-tauglich", die Antwort sagte das Gegenteil — + gefunden am 14.10.2026 an den echten Kopfzeilen der Live-Instanz, nicht im + Code. + + ⚠️ Der Schutz bleibt für alles andere bestehen; ausgenommen ist nur die + eine Seite, die eingebettet werden soll. Sie zeigt nichts als die letzte + Sichtung und hat kein Formular — es gibt hier nichts zu erschleichen. + """ + latest = ( + Sighting.objects.select_related("species", "confirmed_species") + .exclude(species__isnull=True, confirmed_species__isnull=True) + .order_by("-started_at") + .first() + ) + ctx = _base_context() | {"latest": latest, "rarity_color": RARITY_COLOR} + return render(request, "dex/widget.html", ctx) + + +def live(request): + """Livebilder der Kameras, die im Admin dafür freigegeben sind. + + Gibt es keine, ist die Seite nicht da — 404 statt einer leeren Seite mit + einem Versprechen, das niemand eingelöst hat. Der Link in der Navigation + erscheint aus demselben Grund nur, wenn es etwas zu sehen gibt. + """ + kameras = list(Camera.live_cameras()) + if not kameras: + raise Http404("Kein Livebild freigegeben") + return render(request, "dex/live.html", _base_context() | {"cameras": kameras}) + + +def api_stats(request): + """Kleiner JSON-Endpunkt, damit die Homepage live tickern kann.""" + p = scoring.progress() + latest = ( + Sighting.objects.select_related("species", "confirmed_species") + .exclude(species__isnull=True, confirmed_species__isnull=True) + .order_by("-started_at") + .first() + ) + return JsonResponse( + { + "level": p.level, + "rang": p.rank, + "xp": p.xp, + "arten_gesehen": p.species_seen, + "arten_gesamt": p.species_total, + "sichtungen": p.sightings, + "letzte": ( + { + "art": latest.effective_species.german, + "zeitpunkt": latest.started_at.isoformat(), + "confidence": round(latest.confidence, 3), + "bild": ( + request.build_absolute_uri(latest.crop.url) + if latest.crop + else None + ), + } + if latest and latest.effective_species + else None + ), + } + ) + + +# ⚠️ **Hinter dem Admin-Login, und das ist keine Bequemlichkeit.** Die Instanz +# darf öffentlich stehen — ein offenes Upload-Formular wäre dann eine +# Einladung, fremde Bilder in eine fremde Sammlung zu legen, und der +# Detector würde sie auch noch brav verarbeiten. Das vorhandene +# Django-Login reicht dafür; das Projekt braucht keine eigene +# Nutzerverwaltung, und niemand soll sich eins anlegen müssen. +@staff_member_required +def hochladen(request): + """Fotos von Hand einliefern — der Weg ohne App. + + 🔑 Benutzt dieselbe Warteschlange wie die spätere App (``fotos_annehmen``). + Das ist der eigentliche Zweck dieser Seite: Funktioniert sie, ist die + ganze Kette bewiesen — annehmen, warten, abholen, bestimmen, sammeln — + ohne dass eine Zeile App existiert. Dasselbe Argument wie bei der + Home-Assistant-Brücke, die über die Schnittstelle geht statt an die + Datenbank. + """ + ctx = _base_context() + ctx["kameras"] = Camera.objects.filter(mobil=True).order_by("name") + + if request.method == "POST": + dateien = request.FILES.getlist("bilder") + if not dateien: + ctx["fehler"] = _("Bitte mindestens ein Bild auswählen.") + else: + kamera = None + if request.POST.get("kamera"): + kamera = Camera.objects.filter( + slug=request.POST["kamera"], mobil=True).first() + try: + angelegt = fotos_annehmen( + dateien, + camera=kamera, + latitude=request.POST.get("latitude"), + longitude=request.POST.get("longitude"), + ) + except KeinBild as problem: + # Dem Menschen am Formular sagen, was nicht ging — nicht eine + # Fehlerseite zeigen und ihn raten lassen. + ctx["fehler"] = _("Das war kein brauchbares Bild: %(grund)s") % { + "grund": problem, + } + else: + # Nach dem Hochladen umleiten, damit ein Neuladen der Seite die + # Bilder nicht ein zweites Mal einliefert. + return redirect(f"{reverse('hochladen')}?angelegt={len(angelegt)}") + + try: + ctx["angelegt"] = int(request.GET.get("angelegt", 0)) + except ValueError: + ctx["angelegt"] = 0 + ctx["wartend"] = Sighting.objects.wartend().count() + ctx["wartende_fotos"] = Sighting.objects.wartend().select_related("camera")[:12] + return render(request, "dex/hochladen.html", ctx) diff --git a/web/dex/zuschnitt.py b/web/dex/zuschnitt.py new file mode 100644 index 0000000..ca2421c --- /dev/null +++ b/web/dex/zuschnitt.py @@ -0,0 +1,120 @@ +"""Wie ein Vogelfoto in einer Kachel aussieht. + +🔑 **Der Zuschnitt ist eine Regel, keine Datei.** Das Original liegt ohnehin +auf der Platte, und wo der Vogel darin sitzt, steht als vier Zahlen an der +Sichtung. Daraus lässt sich der Ausschnitt jederzeit neu rechnen — bei jedem +Aufruf, für jede Sichtung, auch für die von vorletztem Jahr. + +Vorher war es umgekehrt: Der Detector schnitt ein zweites Bild zu und legte es +daneben. Damit war der Zuschnitt für immer festgelegt. Eine bessere Regel half +nur neuen Sichtungen, alle vorhandenen hätte man einzeln nachziehen müssen — +und das auf jeder Anlage, die es irgendwo gibt. + +⚠️ **Der Unterschied ist nicht Bequemlichkeit, sondern Benutzbarkeit.** An +Handyfotos fiel auf, woran die alte Lösung scheitert: Dort ist der Vogel klein +im Bild, der enge Ausschnitt entsprechend schmal und hoch. In einer +quadratischen Kachel blieb davon die Bauchmitte übrig — formatfüllend und +unscharf, ohne Kopf und ohne Schwanz. + +Das Ergebnis wird als Datei zwischengespeichert, weil Zuschneiden Rechenzeit +kostet und ein Album viele Kacheln hat. **Der Zwischenspeicher ist +wegwerfbar**: Löscht man ihn, entsteht beim nächsten Aufruf alles neu — und +genau das ist der Weg, eine geänderte Regel auszurollen. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from django.conf import settings +from PIL import Image + +# Wie viel Luft um den Vogel. 0.35 heißt: Die Kante des Ausschnitts ist 35 % +# der Vogelgröße von ihm entfernt. +# +# 🔑 Diese Zahl ist der ganze Sinn der Umstellung. Sie zu ändern und den +# Zwischenspeicher zu leeren ändert jede Kachel im Bestand. +RAND = 0.35 + +# Kantenlänge des erzeugten Bildes. Die Kacheln sind kleiner; größer zu +# speichern lohnt nur für Bildschirme mit hoher Punktdichte. +KANTE = 600 + +# Unterordner im Medienverzeichnis. Nicht ins Repo, nicht in die Sicherung — +# alles hier lässt sich aus Original und Koordinaten neu erzeugen. +ORDNER = "zuschnitte" + +# Steigt diese Zahl, bekommen alle Dateien neue Namen und werden neu +# gerechnet. So rollt man eine geänderte Regel aus, ohne etwas zu löschen. +REGEL = 1 + + +def _ziel(sichtung) -> Path: + kennung = hashlib.sha1( + f"{sichtung.pk}-{sichtung.image.name}-{RAND}-{KANTE}-{REGEL}".encode() + ).hexdigest()[:16] + return Path(settings.MEDIA_ROOT) / ORDNER / f"{sichtung.pk}-{kennung}.jpg" + + +def quadrat(box: tuple[float, float, float, float], breite: int, hoehe: int, + rand: float = RAND) -> tuple[int, int, int, int]: + """Aus relativen Vogel-Koordinaten ein quadratisches Fenster in Pixeln. + + ⚠️ **Die Mitte wird nachgeführt, nicht der Ausschnitt gestaucht.** Sitzt + der Vogel am Bildrand, verschiebt sich das Fenster nach innen, statt + schmaler zu werden — sonst säße er außermittig in seiner eigenen Kachel. + """ + x0, y0, x1, y1 = box + bx0, by0, bx1, by1 = x0 * breite, y0 * hoehe, x1 * breite, y1 * hoehe + bw, bh = max(1.0, bx1 - bx0), max(1.0, by1 - by0) + + seite = min(max(bw, bh) * (1 + 2 * rand), float(breite), float(hoehe)) + mx = min(max((bx0 + bx1) / 2, seite / 2), breite - seite / 2) + my = min(max((by0 + by1) / 2, seite / 2), hoehe - seite / 2) + return ( + int(mx - seite / 2), int(my - seite / 2), + int(mx + seite / 2), int(my + seite / 2), + ) + + +def adresse(sichtung) -> str | None: + """Adresse der Kachel für diese Sichtung — oder ``None``. + + Erzeugt das Bild beim ersten Abruf. Gibt es keine Koordinaten (Sichtungen + von vor der Umstellung), fällt die Vorlage auf das gespeicherte ``crop`` + zurück; dafür liefert diese Funktion ``None``. + """ + box = sichtung.vogel_box + if box is None or not sichtung.image: + return None + + ziel = _ziel(sichtung) + if not ziel.exists(): + try: + _erzeugen(sichtung, box, ziel) + except (OSError, ValueError): + # ⚠️ Ein fehlendes Original darf keine Seite zerreißen. Dann gilt + # der Rückfall, genau wie bei einer Sichtung ohne Koordinaten. + return None + + return f"{settings.MEDIA_URL}{ORDNER}/{ziel.name}" + + +def _erzeugen(sichtung, box, ziel: Path) -> None: + ziel.parent.mkdir(parents=True, exist_ok=True) + with sichtung.image.open("rb") as f: + bild = Image.open(f) + bild.load() + if bild.mode != "RGB": + bild = bild.convert("RGB") + + ausschnitt = bild.crop(quadrat(box, bild.width, bild.height)) + if ausschnitt.width > KANTE: + ausschnitt = ausschnitt.resize((KANTE, KANTE), Image.LANCZOS) + + # Erst daneben schreiben, dann umbenennen: Zwei gleichzeitige Abrufe + # dürfen sich nicht gegenseitig eine halbe Datei hinterlassen. + vorlaeufig = ziel.with_suffix(".teil") + ausschnitt.save(vorlaeufig, format="JPEG", quality=86, optimize=True) + vorlaeufig.replace(ziel) diff --git a/web/locale/en/LC_MESSAGES/django.po b/web/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..4cde43d --- /dev/null +++ b/web/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,689 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: Vogelalbum\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-10-10 12:07+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: dex/admin.py:28 +msgid "" +"Der Detector holt sich diese Werte selbst — er fragt /api/config/ mit seinem Token und übernimmt Änderungen im laufenden Betrieb, ohne " +"Neustart. Eine Stream-Adresse, die auf seiner Kommandozeile steht, behält " +"aber Vorrang." +msgstr "The detector fetches these values itself — it asks /api/config/ with its token and applies changes while running, without a restart. A stream address given on its command line still takes precedence." + +#: dex/admin.py:38 +msgid "" +"Das Livebild kommt vom Live-Verteiler des Detectors, nicht von der Kamera " +"selbst — die liefert nur einen Stream, und den braucht die Erkennung. " +"Starten mit python -m detector watch … --live-port 8131." +msgstr "The live image comes from the detector's relay, not from the camera itself — the camera serves only one stream, and the recognition needs it. Start it with python -m detector watch … --live-port 8131." + +#: dex/admin.py:61 +msgid "gesperrt (VOGELALBUM_LIVE_ERLAUBT=0)" +msgstr "locked (VOGELALBUM_LIVE_ERLAUBT=0)" + +#: dex/admin.py:63 +msgid "aus" +msgstr "off" + +#: dex/admin.py:65 +msgid "an, aber keine Adresse" +msgstr "on, but no address" + +#: dex/admin.py:67 +msgid "an, aber Kamera stillgelegt" +msgstr "on, but camera disabled" + +#: dex/admin.py:68 +msgid "an" +msgstr "on" + +#: dex/models.py:551 +msgid "Gewicht" +msgstr "Weight" + +#: dex/models.py:552 +msgid "Spannweite" +msgstr "Wingspan" + +#: dex/models.py:557 +msgid "Größe" +msgstr "Length" + +#: dex/models.py:558 +msgid "Seltenheit" +msgstr "Rarity" + +#: dex/scoring.py:25 +msgid "Nestling" +msgstr "Nestling" + +#: dex/scoring.py:26 +msgid "Ästling" +msgstr "Fledgling" + +#: dex/scoring.py:27 +msgid "Spatzenkenner" +msgstr "Sparrow Spotter" + +#: dex/scoring.py:28 +msgid "Meisenmeister" +msgstr "Tit Master" + +#: dex/scoring.py:29 +msgid "Federfuchser" +msgstr "Feather Nerd" + +#: dex/scoring.py:30 +msgid "Amselflüsterer" +msgstr "Blackbird Whisperer" + +#: dex/scoring.py:31 +msgid "Spechtspäher" +msgstr "Woodpecker Watcher" + +#: dex/scoring.py:32 +msgid "Falkenauge" +msgstr "Falcon Eye" + +#: dex/scoring.py:33 +msgid "Vogelmeister" +msgstr "Bird Master" + +#: dex/scoring.py:34 +msgid "Legende des Vogelhauses" +msgstr "Legend of the Feeder" + +#: dex/scoring.py:112 +msgid "Erster Gast" +msgstr "First Guest" + +#: dex/scoring.py:113 +msgid "Die allererste Sichtung im Vogelhaus." +msgstr "The very first sighting at the feeder." + +#: dex/scoring.py:114 +msgid "Fünf Freunde" +msgstr "High Five" + +#: dex/scoring.py:115 +msgid "5 verschiedene Arten gesammelt." +msgstr "5 different species collected." + +#: dex/scoring.py:116 +msgid "Zweistellig" +msgstr "Double Digits" + +#: dex/scoring.py:117 +msgid "10 verschiedene Arten gesammelt." +msgstr "10 different species collected." + +#: dex/scoring.py:118 +msgid "Sammler" +msgstr "Collector" + +#: dex/scoring.py:119 +msgid "25 verschiedene Arten gesammelt." +msgstr "25 different species collected." + +#: dex/scoring.py:120 +msgid "Vogelalbum-Meister" +msgstr "Vogelalbum Master" + +#: dex/scoring.py:121 +msgid "40 verschiedene Arten gesammelt." +msgstr "40 different species collected." + +#: dex/scoring.py:122 +msgid "Frühaufsteher" +msgstr "Early Bird" + +#: dex/scoring.py:123 +msgid "Eine Sichtung vor 6 Uhr morgens." +msgstr "A sighting before 6 in the morning." + +#: dex/scoring.py:124 +msgid "Nachtschicht" +msgstr "Night Shift" + +#: dex/scoring.py:125 +msgid "Eine Sichtung nach 21 Uhr." +msgstr "A sighting after 9 in the evening." + +#: dex/scoring.py:126 +msgid "Volles Haus" +msgstr "Full House" + +#: dex/scoring.py:127 +msgid "5 verschiedene Arten an einem Tag." +msgstr "5 different species in one day." + +#: dex/scoring.py:128 +msgid "Stammgast" +msgstr "Regular" + +#: dex/scoring.py:129 +msgid "7 Tage in Folge mindestens eine Sichtung." +msgstr "At least one sighting on 7 consecutive days." + +#: dex/scoring.py:130 +msgid "Dauergast" +msgstr "Long Stay" + +#: dex/scoring.py:131 +msgid "Ein Besuch, der länger als eine Minute dauerte." +msgstr "A visit lasting longer than a minute." + +#: dex/scoring.py:132 +msgid "Hundert Besuche" +msgstr "One Hundred Visits" + +#: dex/scoring.py:133 +msgid "100 Sichtungen aufgezeichnet." +msgstr "100 sightings recorded." + +#: dex/scoring.py:134 +msgid "Tausend Besuche" +msgstr "One Thousand Visits" + +#: dex/scoring.py:135 +msgid "1000 Sichtungen aufgezeichnet." +msgstr "1000 sightings recorded." + +#: dex/scoring.py:136 +msgid "Klopfgeist" +msgstr "The Knocker" + +#: dex/scoring.py:137 +msgid "Ein Specht war da." +msgstr "A woodpecker came by." + +#: dex/scoring.py:138 +msgid "Greifvogel-Alarm" +msgstr "Raptor Alert" + +#: dex/scoring.py:139 +msgid "Ein Greifvogel oder eine Eule vor der Linse." +msgstr "A bird of prey or an owl in front of the lens." + +#: dex/scoring.py:140 +msgid "Seltener Gast" +msgstr "Rare Guest" + +#: dex/scoring.py:141 +msgid "Eine Art der Stufe 'Selten' oder höher." +msgstr "A species rated 'Rare' or above." + +#: dex/templatetags/dex_extras.py:76 +#, python-format +msgid "heute, %(uhrzeit)s" +msgstr "today, %(uhrzeit)s" + +#: dex/templatetags/dex_extras.py:78 +#, python-format +msgid "gestern, %(uhrzeit)s" +msgstr "yesterday, %(uhrzeit)s" + +#: dex/templatetags/dex_extras.py:80 +#, python-format +msgid "%(wochentag)s, %(uhrzeit)s" +msgstr "%(wochentag)s, %(uhrzeit)s" + +#: dex/templatetags/dex_extras.py:84 +#, python-format +msgid "%(datum)s, %(uhrzeit)s" +msgstr "%(datum)s, %(uhrzeit)s" + +#: dex/views.py:47 +msgid "Noch wartet die Kamera" +msgstr "The camera is still waiting" + +#: dex/views.py:52 +msgid "Wer war heute zu Gast?" +msgstr "Who stopped by today?" + +#: dex/views.py:54 +msgid "Zuletzt gestern zu Gast" +msgstr "Last visitor: yesterday" + +#: dex/views.py:56 +#, python-format +msgid "Zuletzt am %(wochentag)s zu Gast" +msgstr "Last visitor: %(wochentag)s" + +#: dex/views.py:59 +#, python-format +msgid "Zuletzt am %(datum)s zu Gast" +msgstr "Last visitor: %(datum)s" + +#: dex/views.py:400 +msgid "Bitte mindestens ein Bild auswählen." +msgstr "Please select at least one image." + +#: templates/dex/badges.html:4 templates/dex/badges.html:39 +#: templates/dex/base.html:81 +msgid "Abzeichen" +msgstr "Badges" + +#: templates/dex/badges.html:39 +#, python-format +msgid "%(n)s verdient" +msgstr "%(n)s earned" + +#: templates/dex/base.html:7 +msgid "" +"Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne " +"Cloud." +msgstr "Identify, collect and catalogue birds — on your own server, without a cloud." + +#: templates/dex/base.html:74 templates/dex/live.html:4 +#: templates/dex/live.html:8 +msgid "Live" +msgstr "Live" + +#: templates/dex/base.html:78 +msgid "Übersicht" +msgstr "Overview" + +#: templates/dex/base.html:79 templates/dex/dex.html:4 +msgid "Sammlung" +msgstr "Collection" + +#: templates/dex/base.html:80 templates/dex/sightings.html:4 +#: templates/dex/sightings.html:28 +msgid "Sichtungen" +msgstr "Sightings" + +#: templates/dex/base.html:97 +msgid "Zwischen hellem und dunklem Modus wechseln" +msgstr "Switch between light and dark mode" + +#: templates/dex/base.html:122 templates/dex/base.html:123 +msgid "Einstellungen" +msgstr "Settings" + +#: templates/dex/base.html:139 templates/dex/home.html:42 +#, python-format +msgid "%(xp)s XP · noch %(rest)s XP bis Level %(lvl)s" +msgstr "%(xp)s XP · %(rest)s XP to level %(lvl)s" + +#: templates/dex/base.html:143 +msgid "Arten gesammelt" +msgstr "species collected" + +#: templates/dex/base.html:168 +msgid "Dieses Vogelalbum füllt sich von allein." +msgstr "This bird album fills itself." + +#: templates/dex/base.html:169 +msgid "" +"Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — " +"ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen." +msgstr "A camera at the feeder, everything else happens on your own server — no cloud, no account, no subscription. Free software, built to be rebuilt." + +#: templates/dex/base.html:172 +msgid "Projekt ansehen" +msgstr "View the project" + +#: templates/dex/dex.html:8 +msgid "Die Sammlung" +msgstr "The Collection" + +#: templates/dex/dex.html:10 +#, python-format +msgid "%(a)s von %(b)s Arten" +msgstr "%(a)s of %(b)s species" + +#: templates/dex/dex.html:22 templates/dex/species.html:80 +msgid "Lebensraum" +msgstr "Habitat" + +#: templates/dex/dex.html:27 +msgid "Alle" +msgstr "All" + +#: templates/dex/dex.html:36 +msgid "Stufe" +msgstr "Tier" + +#: templates/dex/dex.html:38 +msgid "Alle Stufen" +msgstr "All tiers" + +#: templates/dex/dex.html:46 +msgid "Gruppe" +msgstr "Group" + +#: templates/dex/dex.html:48 +msgid "Alle Gruppen" +msgstr "All groups" + +#: templates/dex/dex.html:55 +msgid "Anwenden" +msgstr "Apply" + +#: templates/dex/dex.html:58 +msgid "Zurücksetzen" +msgstr "Reset" + +#: templates/dex/dex.html:70 +msgid "Noch nicht gesichtet" +msgstr "Not yet sighted" + +#: templates/dex/dex.html:95 +msgid "Keine Art passt zu dieser Auswahl." +msgstr "No species matches this selection." + +#: templates/dex/hochladen.html:4 templates/dex/hochladen.html:76 +msgid "Fotos hochladen" +msgstr "Upload photos" + +#: templates/dex/hochladen.html:85 +msgid "" +"Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim " +"nächsten Durchgang und trägt es in die Sammlung ein." +msgstr "Spotted a bird? Hand in a photo here — the detector identifies it on its next pass and adds it to the collection." + +#: templates/dex/hochladen.html:90 +#, python-format +msgid "%(n)s Foto angenommen. Es wartet auf den Detector." +msgid_plural "%(n)s Fotos angenommen. Sie warten auf den Detector." +msgstr[0] "%(n)s photo accepted. It is waiting for the detector." +msgstr[1] "%(n)s photos accepted. They are waiting for the detector." + +#: templates/dex/hochladen.html:101 +msgid "Bilder" +msgstr "Images" + +#: templates/dex/hochladen.html:109 +msgid "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt." +msgstr "You can pick several images at once. Each one is identified separately." + +#: templates/dex/hochladen.html:114 +msgid "Gerät" +msgstr "Device" + +#: templates/dex/hochladen.html:116 +msgid "— ohne Zuordnung —" +msgstr "— unassigned —" + +#: templates/dex/hochladen.html:125 +msgid "Ort (freiwillig)" +msgstr "Location (optional)" + +#: templates/dex/hochladen.html:127 +msgid "Breite, z. B. 49.49" +msgstr "Latitude, e.g. 49.49" + +#: templates/dex/hochladen.html:128 +msgid "Länge, z. B. 8.47" +msgstr "Longitude, e.g. 8.47" + +#: templates/dex/hochladen.html:131 +msgid "" +"Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die " +"Adresse." +msgstr "Stored rounded to about a kilometre — the area, not the address." + +#: templates/dex/hochladen.html:136 +msgid "Hochladen" +msgstr "Upload" + +#: templates/dex/hochladen.html:142 +msgid "Warten auf Bestimmung" +msgstr "Awaiting identification" + +#: templates/dex/hochladen.html:154 +msgid "Noch nicht bestimmt" +msgstr "Not identified yet" + +#: templates/dex/hochladen.html:160 +msgid "Bleiben die Bilder länger stehen, läuft der Detector nicht." +msgstr "If images sit here for a while, the detector is not running." + +#: templates/dex/home.html:4 +msgid "Vogelalbum — Übersicht" +msgstr "Vogelalbum — Overview" + +#: templates/dex/home.html:33 +msgid "" +"Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder " +"vom Handyfoto. Alles auf dem eigenen Server." +msgstr "Every bird is identified, counted and collected — from a camera frame or a photo off your phone. All on your own server." + +#: templates/dex/home.html:46 templates/dex/widget.html:37 +msgid "Arten" +msgstr "species" + +#: templates/dex/home.html:59 +msgid "ZULETZT" +msgstr "LATEST" + +#: templates/dex/home.html:67 +msgid "Noch kein Gast aufgezeichnet." +msgstr "No visitor recorded yet." + +#: templates/dex/home.html:68 +msgid "Der erste bestimmte Vogel steht hier." +msgstr "The first identified bird shows up here." + +#: templates/dex/home.html:75 +msgid "Neu in der Sammlung" +msgstr "New in the collection" + +#: templates/dex/home.html:75 +msgid "letzte 7 Tage" +msgstr "last 7 days" + +#: templates/dex/home.html:92 +msgid "Neu" +msgstr "New" + +#: templates/dex/home.html:100 +msgid "Zuletzt zu Besuch" +msgstr "Latest visitors" + +#: templates/dex/home.html:101 +#, python-format +msgid "%(n)s Besuche insgesamt" +msgstr "%(n)s visits in total" + +#: templates/dex/home.html:120 templates/dex/sightings.html:75 +#: templates/dex/species.html:127 +msgid "Erstfang" +msgstr "First catch" + +#: templates/dex/home.html:129 +msgid "Noch keine Sichtung aufgezeichnet." +msgstr "No sightings recorded yet." + +#: templates/dex/home.html:130 +msgid "" +"Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert " +"ist, erscheint hier der erste Gast." +msgstr "As soon as the detector spots a bird or the first photo is handed in, the first visitor appears here." + +#: templates/dex/home.html:135 +msgid "Stammgäste" +msgstr "Regulars" + +#: templates/dex/live.html:10 +#, python-format +msgid "%(n)s Kamera" +msgid_plural "%(n)s Kameras" +msgstr[0] "%(n)s camera" +msgstr[1] "%(n)s cameras" + +#: templates/dex/live.html:51 +msgid "" +"Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt " +"das Livebild aus ihrem eigenen Bildstrom." +msgstr "If the image stays black the detector is not running — the live view is relayed from the recognition's own frame stream." + +#: templates/dex/sightings.html:28 templates/dex/species.html:114 +#, python-format +msgid "%(n)s insgesamt" +msgstr "%(n)s in total" + +#: templates/dex/sightings.html:35 +msgid "Foto hochladen" +msgstr "Upload photo" + +#: templates/dex/sightings.html:40 templates/dex/species.html:114 +msgid "Alle Besuche" +msgstr "All visits" + +#: templates/dex/sightings.html:41 +msgid "Nur bestimmte Arten" +msgstr "Identified only" + +#: templates/dex/sightings.html:46 +msgid "Alle Kameras" +msgstr "All cameras" + +#: templates/dex/sightings.html:71 +msgid "von Hand" +msgstr "by hand" + +#: templates/dex/sightings.html:73 +msgid "Unbestimmt" +msgstr "Unidentified" + +#: templates/dex/sightings.html:84 +msgid "Alternativen" +msgstr "Alternatives" + +#: templates/dex/sightings.html:99 +msgid "Neuer" +msgstr "Newer" + +#: templates/dex/sightings.html:100 +msgid "Älter" +msgstr "Older" + +#: templates/dex/sightings.html:103 +msgid "Noch nichts aufgezeichnet." +msgstr "Nothing recorded yet." + +#: templates/dex/species.html:28 +msgid "Bestes eigenes Foto" +msgstr "Best own photo" + +#: templates/dex/species.html:30 +msgid "Referenzfoto — diese Art war noch nicht vor unserer Kamera." +msgstr "Reference photo — this species has not been in front of our camera yet." + +#: templates/dex/species.html:32 +msgid "Foto" +msgstr "Photo" + +#: templates/dex/species.html:50 +#, python-format +msgid "%(n)s× seen" +msgid_plural "%(n)s× seen" +msgstr[0] "%(n)s× seen" +msgstr[1] "%(n)s× seen" + +#: templates/dex/species.html:69 templates/dex/species.html:74 +msgid "Steckbrief" +msgstr "Profile" + +#: templates/dex/species.html:83 +msgid "Nahrung" +msgstr "Diet" + +#: templates/dex/species.html:86 +msgid "Stimme" +msgstr "Voice" + +#: templates/dex/species.html:89 +msgid "Zugverhalten" +msgstr "Migration" + +#: templates/dex/species.html:94 +msgid "Verwechslungsgefahr" +msgstr "Easily confused with" + +#: templates/dex/species.html:94 +msgid "Arten, die man nicht auf Anhieb unterscheidet" +msgstr "species you will not tell apart at a glance" + +#: templates/dex/species.html:106 +#, python-format +msgid "Wann ist %(n)s da?" +msgstr "When does the %(n)s show up?" + +#: templates/dex/species.html:106 +msgid "Besuche über den Tag" +msgstr "visits across the day" + +#: templates/dex/species.html:110 +msgid "0 Uhr" +msgstr "midnight" + +#: templates/dex/species.html:130 +#, python-format +msgid "%(a)s von %(b)s Bildern mit Vogel" +msgstr "%(a)s of %(b)s frames with a bird" + +#: templates/dex/species.html:143 +msgid "Noch nicht gesammelt." +msgstr "Not collected yet." + +#: templates/dex/species.html:144 +#, python-format +msgid "" +"Sobald %(n)s vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit " +"eurem eigenen Foto." +msgstr "Once a %(n)s lands in front of the camera this entry unlocks — with your own photo." + +#: templates/dex/widget.html:25 +msgid "Zuletzt gesehen" +msgstr "Last seen" + +#: templates/dex/widget.html:27 +msgid "sicher" +msgstr "confident" + +#: templates/dex/widget.html:32 +msgid "Noch kein Gast" +msgstr "No visitor yet" + +#~ msgid "" +#~ "Vogelalbum · Arterkennung auf dem eigenen Server. Keine Cloud, keine " +#~ "fremden Dienste." +#~ msgstr "" +#~ "Vogelalbum · Species recognition on your own server. No cloud, no third-" +#~ "party services." + +#~ msgid "" +#~ "Eine Kamera am Vogelhaus, die Erkennung auf dem eigenen Server. Jeder " +#~ "Gast wird bestimmt, gezählt und gesammelt." +#~ msgstr "" +#~ "A camera at the bird house, recognition on your own server. Every visitor " +#~ "is identified, counted and collected." + +#~ msgid "Sobald sich etwas im Vogelhaus rührt, steht er hier." +#~ msgstr "As soon as something stirs at the bird house, it will show up here." + +#~ msgid "Zuletzt im Vogelhaus" +#~ msgstr "Latest at the feeder" + +#~ msgid "Besuche heute" +#~ msgstr "Visits today" + +#~ msgid "Arten heute" +#~ msgstr "Species today" diff --git a/web/manage.py b/web/manage.py new file mode 100755 index 0000000..70acf7d --- /dev/null +++ b/web/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/web/static/css/vogelalbum.css b/web/static/css/vogelalbum.css new file mode 100644 index 0000000..8f87ec1 --- /dev/null +++ b/web/static/css/vogelalbum.css @@ -0,0 +1,781 @@ +/* Vogelalbum — eigenständiges Stylesheet, kein Build-Schritt. + * + * Zwei Modi über Farbtokens: jede Farbe existiert einmal hell und einmal + * dunkel, umgeschaltet wird nur die Zuordnung. Deshalb steht unten kein + * einziger Farbwert mehr direkt in einer Regel. + * + * Die Akzentfarbe kommt vom Typ der Art, nicht global — dadurch fühlt sich + * jede Artseite anders an. Die typabhängigen Töne werden in + * dex/species_meta.py vorberechnet und als Inline-Variablen mitgegeben, in + * beiden Modi (Endung -l und -d). Die Indirektion weiter unten wählt aus. + * + * Keine modernen Funktionen ohne Not: kein color-mix (Safari ab 16.2), kein + * aspect-ratio (Safari ab 15). Beides fällt auf älteren Geräten ersatzlos weg + * und reißt das Layout auseinander — Bildverhältnisse laufen über padding-top. + */ + +/* Die Palette kommt aus der Marke: tiefes Grün und Creme. Vorher war das + * Grün der Oberfläche ein anderes als das des Zeichens — nebeneinander sah + * man den Unterschied sofort, und die Seite wirkte kühl und beliebig. + * Papierweiß statt Blauweiß, damit Fotos von Vögeln warm wirken. */ +:root { + --bg: #fbf8ef; + --bg-grad: #f4eddd; + --surface: #ffffff; + --surface-2: #f3ece0; + --line: #e7dfcc; + --text: #14281c; + --text-2: #5c6b60; + --muted: #7a8a7f; + + --accent: #0f5f3c; + --accent-2: #0a3f28; + --accent-wash: #e6f0e9; + --accent-on: #fcf2d3; + + /* Der Kopf ist dunkel, damit die Seite nicht als weißes Blatt beginnt. */ + --kopf-bg: #0a3f28; + --kopf-text: #fcf2d3; + --kopf-text-2: #a8ceb8; + --kopf-an: rgba(255,255,255,.15); + --kopf-linie: rgba(255,255,255,.22); + --gold: #cf9a1c; + --gold-wash: #fbf3dc; + --gold-line: #ecd9a0; + + --shadow: 0 1px 2px rgba(22,33,26,.05), 0 8px 20px -12px rgba(22,33,26,.18); + --shadow-lift: 0 2px 4px rgba(22,33,26,.07), 0 16px 32px -16px rgba(22,33,26,.26); + --nr-bg: rgba(255,255,255,.92); + --head-bg: rgba(255,255,255,.86); + --locked-opacity: .30; + + --r-sm: 10px; + --r: 16px; + --r-lg: 24px; + --font: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + + --typ: #7f9b6a; + --typ-ink: #ffffff; +} + +/* Typabhängige Töne werden je Modus direkt am Verwendungsort gewählt. + Der naheliegende Umweg über eine Variable auf :root funktioniert nicht: + var() in einer Custom-Property-Deklaration wird auf dem deklarierenden + Element ersetzt, und dort gibt es die -l/-d-Werte der Karte nicht. */ + +[data-theme="dark"] { + /* Kühles Blaugrau statt Dunkelgrün: neutral genug, dass 16 Typfarben + darauf nebeneinander funktionieren, ohne sich mit dem Grund zu beißen. */ + --bg: #10151b; + --bg-grad: #141b23; + --surface: #1a2129; + --surface-2: #222b35; + --line: #2e3945; + --text: #e8eef4; + --text-2: #b3c1cd; + --muted: #7e8f9e; + + --accent: #45c98a; + --accent-2: #2f9d68; + --accent-wash: #163024; + --accent-on: #08150d; + --gold: #e8be5a; + --gold-wash: #2a2415; + --gold-line: #4a4026; + + /* Auf dunklem Grund trägt nicht der Schatten, sondern die Kante. */ + --shadow: 0 1px 2px rgba(0,0,0,.4); + --shadow-lift: 0 10px 30px -14px rgba(0,0,0,.75); + --nr-bg: rgba(10,14,19,.82); + --head-bg: rgba(16,21,27,.86); + --locked-opacity: .26; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font); + color: var(--text); + background: linear-gradient(180deg, var(--bg-grad) 0%, var(--bg) 420px); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +a { color: inherit; text-decoration: none; } +img { max-width: 100%; } + +/* ⚠ padding-inline, nicht die Kurzform ``padding``. Der Kopf traegt + ``class="wrap top-inner"``, und ``.top-inner`` setzt den senkrechten + Abstand (12px). Eine Kurzform hier setzt den auf 0 zurueck — genau das + ist auf Telefonen passiert: Marke und Navigation klebten oben und unten + an der Kante der gruenen Leiste. */ +.wrap { max-width: 1120px; margin: 0 auto; padding-inline: 20px; } + +/* ---------- Kopf ---------- */ + +/* 🔑 Der Kopf ist dunkel, nicht durchscheinend hell. Die Seite begann vorher + als weisses Blatt und wirkte dadurch unfertig; eine dunkle Leiste gibt ihr + oben einen Halt und laesst die Fotos darunter waermer wirken. */ +header.top { + position: sticky; top: 0; z-index: 30; + background: var(--kopf-bg); + color: var(--kopf-text); +} + +.top-inner { display: flex; align-items: center; gap: 14px; padding: 12px 0; flex-wrap: wrap; } + +.brand { font-weight: 800; font-size: 21px; letter-spacing: -0.03em; display: flex; align-items: center; gap: 10px; color: var(--kopf-text); } +/* 🔑 Die Wortmarke ist EIN Flex-Kind, nicht zwei. + Vorher standen "Vogel" und "album" getrennt im Flex-Container und bekamen + dessen gap von 10 px zwischen sich — bei "Bird dex" war das gewollt, bei + einem deutschen Kompositum liest es sich wie ein Tippfehler. Jetzt trennt + der Abstand nur noch Zeichen und Wort; die Farbe wechselt mitten im Wort. */ +.brand .wort { color: var(--kopf-text); } +.brand .wort span { color: #7fd3a4; } + +/* ⚠ Das Zeichen traegt dunkelgruene Flaechen. Direkt auf der dunklen Leiste + verschwinden die, und uebrig bleibt ein cremefarbener Klecks — die Marke + war an der wichtigsten Stelle nicht mehr zu erkennen. Deshalb eine helle + Scheibe darunter: das Zeichen selbst bleibt unveraendert, es bekommt nur + einen Traeger. Nebenbei nimmt die runde Form die Abzeichen-Pins auf. */ +.brand .scheibe { + width: 40px; height: 40px; flex: 0 0 40px; + border-radius: 50%; background: var(--kopf-text); + display: grid; place-items: center; +} +.brand img { display: block; width: 31px; height: 31px; } + +nav.main { display: flex; gap: 3px; margin-left: auto; flex-wrap: wrap; } +nav.main a { + padding: 8px 15px; border-radius: 999px; + font-size: 14px; font-weight: 600; color: var(--text-2); + transition: background .15s, color .15s; +} +nav.main a:hover { background: var(--surface-2); color: var(--text); } +nav.main a.active { background: var(--accent); color: var(--accent-on); } + +/* ---------- Bedienelemente auf der dunklen Leiste ---------- + Alles im Kopf steht jetzt auf Dunkelgruen. Die Regeln davor rechneten mit + hellem Grund; ohne diesen Block waere die Navigation dunkelgrau auf + dunkelgruen, also praktisch unsichtbar. Gesammelt an einer Stelle, damit + beim naechsten Farbwechsel klar ist, was zusammengehoert. */ +header.top nav.main a { color: var(--kopf-text-2); } +header.top nav.main a:hover { background: rgba(255,255,255,.10); color: var(--kopf-text); } +header.top nav.main a.active { background: var(--kopf-an); color: var(--kopf-text); } + +header.top .live-knopf { + background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2); +} +header.top .live-knopf:hover { color: var(--kopf-text); border-color: var(--kopf-text); } + +header.top .lang-switch { background: transparent; border-color: var(--kopf-linie); } +header.top .lang-switch button { color: var(--kopf-text-2); } +header.top .lang-switch button:hover { color: var(--kopf-text); } +header.top .lang-switch button.on { background: var(--kopf-text); color: var(--kopf-bg); } + +header.top .theme-toggle, header.top .gear { + background: transparent; border-color: var(--kopf-linie); color: var(--kopf-text-2); +} +header.top .theme-toggle:hover, header.top .gear:hover { + border-color: var(--kopf-text); color: var(--kopf-text); +} + +/* Der Live-Knopf. Kein Navigationspunkt, sondern ein Hinweis, dass gerade + etwas zu sehen ist — der Punkt pulst, solange die Seite offen ist. Rot ist + hier die richtige Farbe: sie heisst "laeuft jetzt", nicht "Fehler". */ +.live-knopf { + flex: none; display: inline-flex; align-items: center; gap: 6px; + font-size: 13px; font-weight: 700; text-decoration: none; + color: var(--text-2); background: var(--surface); + border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px 6px 10px; + transition: background .15s, color .15s, border-color .15s; +} +.live-knopf:hover { color: var(--text); border-color: var(--text-2); } +.live-knopf.on { background: #d6303a; border-color: #d6303a; color: #fff; } +.live-knopf .punkt { + width: 8px; height: 8px; border-radius: 50%; background: #d6303a; + animation: live-puls 2s ease-in-out infinite; +} +.live-knopf.on .punkt { background: #fff; } + +@keyframes live-puls { + 0%, 100% { opacity: 1; } + 50% { opacity: .35; } +} + +.theme-toggle { + width: 38px; height: 38px; flex-shrink: 0; + border: 1px solid var(--line); border-radius: 50%; + background: var(--surface); color: var(--text-2); + font-size: 17px; line-height: 1; cursor: pointer; + display: grid; place-items: center; + transition: border-color .15s, color .15s; +} +.theme-toggle:hover { border-color: var(--accent); color: var(--accent); } +.theme-toggle .icon-dark { display: none; } +[data-theme="dark"] .theme-toggle .icon-dark { display: block; } +[data-theme="dark"] .theme-toggle .icon-light { display: none; } + +/* ---------- Fortschritt ---------- */ + +.progress-bar { + display: flex; align-items: center; gap: 18px; flex-wrap: wrap; + padding: 18px 22px; margin: 22px 0; + border-radius: var(--r-lg); + background: var(--surface); border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.level-badge { + width: 60px; height: 60px; border-radius: 50%; + display: grid; place-items: center; flex-shrink: 0; + font-weight: 800; font-size: 24px; color: var(--accent-on); + background: linear-gradient(145deg, var(--accent), var(--accent-2)); + box-shadow: 0 0 0 5px var(--accent-wash); +} + +.xp-track { height: 11px; border-radius: 999px; overflow: hidden; background: var(--surface-2); } +.xp-fill { + height: 100%; border-radius: 999px; + background: linear-gradient(90deg, var(--accent-2), var(--accent)); + transition: width .6s cubic-bezier(.2,.8,.3,1); +} + +.rank { font-weight: 750; font-size: 18px; letter-spacing: -0.015em; } +.sub { color: var(--muted); font-size: 13px; } + +/* ---------- Kennzahlen ---------- */ + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px,1fr)); gap: 12px; } +.stat { + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r); padding: 17px 19px; box-shadow: var(--shadow); +} +.stat .n { font-size: 30px; font-weight: 800; letter-spacing: -0.03em; line-height: 1; color: var(--accent-2); } +.stat .l { color: var(--muted); font-size: 13px; margin-top: 6px; } + +h2 { + font-size: 21px; margin: 36px 0 15px; letter-spacing: -0.025em; + display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; +} +h2 .count { color: var(--muted); font-weight: 500; font-size: 14px; letter-spacing: 0; } + +/* ---------- Sammelkarten ---------- */ + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 16px; } + +.card { + position: relative; + border-radius: var(--r); overflow: hidden; + display: flex; flex-direction: column; + background: var(--surface); border: 1px solid var(--line); + box-shadow: var(--shadow); + transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .18s, border-color .18s; +} +.card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lift); border-color: var(--typ-line-l, var(--line)); } +[data-theme="dark"] .card:hover { border-color: var(--typ-line-d, var(--line)); } + +/* Farbband oben — die Karte bekennt ihren Typ, bevor man den Namen liest. */ +.card::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; + background: var(--typ); z-index: 3; +} + +.card .nr { + position: absolute; top: 12px; left: 11px; z-index: 2; + font-size: 11px; font-weight: 800; letter-spacing: .05em; + padding: 3px 8px; border-radius: 999px; + background: var(--nr-bg); color: var(--text-2); +} + +.card .thumb { + position: relative; width: 100%; + padding-top: 75%; /* 4:3, funktioniert ohne aspect-ratio */ + background: var(--typ-wash-l, var(--surface-2)); + overflow: hidden; +} +[data-theme="dark"] .card .thumb { background: var(--typ-wash-d, var(--surface-2)); } +.card .thumb img { + position: absolute; inset: 0; + width: 100%; height: 100%; object-fit: cover; display: block; +} + +/* Nicht gesammelt: entfärbt und blass. Die Form ist zu ahnen, die + Federzeichnung nicht — der Reiz des Freischaltens bleibt, ohne dass die + Karte tot wirkt. */ +/* ⚠ Hier stand der Typton der Art als Grund. Gemeint war ein Hauch Farbe, + wirksam war ein Flickenteppich: nebeneinander lagen graue, sepiafarbene, + rosa und violette Kacheln, und das sah nach Zufall aus statt nach System. + Verschlossen ist ein Zustand, kein Thema — also fuer alle derselbe + neutrale Grund. Die Typfarbe bleibt sichtbar, wo sie etwas bedeutet: + im Band oben und in der Pille unter dem Namen. */ +.card.locked .thumb { background: var(--surface-2); } +[data-theme="dark"] .card.locked .thumb { background: var(--surface-2); } +.card.locked .thumb img { filter: grayscale(1) contrast(.85); opacity: var(--locked-opacity); } +.card.locked .thumb::after { + content: "?"; position: absolute; + right: 12px; bottom: 6px; + font-size: 34px; font-weight: 800; line-height: 1; + color: var(--typ); opacity: .6; +} +.card.locked .name { color: var(--text-2); } + +.card .body { padding: 12px 14px 14px; } +.card .name { font-weight: 700; font-size: 15.5px; line-height: 1.25; letter-spacing: -0.015em; } +.card .meta { display: flex; align-items: center; gap: 6px; margin-top: 9px; flex-wrap: wrap; } +.card .times { margin-left: auto; color: var(--muted); font-size: 12px; font-weight: 700; } + +/* ---------- Badges ---------- */ + +.pill { + font-size: 11px; font-weight: 800; letter-spacing: .03em; + padding: 4px 10px; border-radius: 999px; + color: var(--typ-ink); background: var(--typ); + white-space: nowrap; +} +.pill.ghost { background: var(--surface-2); color: var(--text-2); border: 1px solid var(--line); } +.pill.rar { color: #fff; } + +.tag-new { + background: var(--gold); color: #fff; + font-size: 10px; font-weight: 800; letter-spacing: .06em; + padding: 3px 8px; border-radius: 999px; text-transform: uppercase; +} + +/* ---------- Artseite ---------- */ + +.species-hero { + position: relative; overflow: hidden; + border-radius: var(--r-lg); margin: 18px 0 8px; + background: var(--surface); border: 1px solid var(--line); + box-shadow: var(--shadow); +} +/* Kräftiges Typband am oberen Rand statt einer flächigen Einfärbung — das + gibt der Seite ihre Identität, ohne den Text zu bedrängen. */ +.species-hero::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 6px; + background: var(--typ); +} + +.hero-grid { + display: grid; grid-template-columns: minmax(0,1.05fr) minmax(0,1fr); + gap: 26px; padding: 28px 26px 26px; +} +@media (max-width: 950px) { .hero-grid { grid-template-columns: 1fr; padding: 22px 18px 18px; } } + +.hero-photo { + position: relative; width: 100%; padding-top: 75%; + border-radius: var(--r); overflow: hidden; + background: var(--typ-wash-l, var(--surface-2)); border: 1px solid var(--typ-line-l, var(--line)); +} +[data-theme="dark"] .hero-photo { background: var(--typ-wash-d, var(--surface-2)); border-color: var(--typ-line-d, var(--line)); } +.hero-photo img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; } + +.dexnr { font-size: 13px; font-weight: 800; letter-spacing: .12em; color: var(--typ-text-l, var(--muted)); } +[data-theme="dark"] .dexnr { color: var(--typ-text-d, var(--muted)); } +.species-hero h1 { margin: 4px 0 2px; font-size: clamp(30px, 5vw, 44px); letter-spacing: -0.04em; line-height: 1.02; } +.kategorie { font-size: 16px; font-weight: 700; color: var(--typ-text-l, var(--text-2)); margin-bottom: 5px; } +[data-theme="dark"] .kategorie { color: var(--typ-text-d, var(--text-2)); } +.sci { color: var(--muted); font-style: italic; font-size: 14px; } + +.badge-row { display: flex; gap: 7px; flex-wrap: wrap; margin: 16px 0; } + +/* Statuswerte als Balken — die Geste jeder Sammelkarte. */ +.werte { display: grid; gap: 9px; margin-top: 18px; } +.wert { display: grid; grid-template-columns: 84px 1fr auto; align-items: center; gap: 11px; } +.wert .k { font-size: 12px; font-weight: 700; color: var(--muted); } +.wert .track { height: 9px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } +.wert .fill { height: 100%; border-radius: 999px; background: var(--typ); } +.wert .v { font-size: 12.5px; font-weight: 750; font-variant-numeric: tabular-nums; min-width: 62px; text-align: right; } + +/* ---------- Fließtext und Steckbrief ---------- */ + +.prose { max-width: 68ch; } +.prose p { line-height: 1.7; margin: 0 0 16px; font-size: 16px; color: var(--text-2); } +.prose p:first-of-type::first-letter { + font-size: 3.1em; float: left; line-height: .82; + padding: 5px 10px 0 0; font-weight: 800; color: var(--typ-text-l, var(--text-2)); +} +[data-theme="dark"] .prose p:first-of-type::first-letter { color: var(--typ-text-d, var(--text-2)); } + +.fakten { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 12px; margin: 18px 0; } +.fakt { + background: var(--surface); border: 1px solid var(--line); + border-left: 3px solid var(--typ); + border-radius: var(--r); padding: 14px 16px; box-shadow: var(--shadow); +} +.fakt .k { font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.fakt .v { margin-top: 5px; line-height: 1.5; font-size: 14.5px; color: var(--text-2); } + +.verwechslung { display: flex; gap: 12px; flex-wrap: wrap; } +.vw { + display: flex; align-items: center; gap: 11px; + background: var(--surface); border: 1px solid var(--line); + border-radius: 999px; padding: 6px 18px 6px 6px; + box-shadow: var(--shadow); + transition: transform .15s, box-shadow .15s; +} +.vw:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); } +.vw img { width: 42px; height: 42px; border-radius: 50%; object-fit: cover; background: var(--surface-2); } +.vw .n { font-weight: 650; font-size: 14.5px; } + +/* ---------- Sichtungen ---------- */ + +.rows { display: flex; flex-direction: column; gap: 9px; } +.row { + display: flex; align-items: center; gap: 14px; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r); padding: 10px 16px 10px 10px; + box-shadow: var(--shadow); transition: box-shadow .15s; +} +.row:hover { box-shadow: var(--shadow-lift); } +.row img.shot, .row .shot { + width: 74px; height: 56px; object-fit: cover; + border-radius: var(--r-sm); background: var(--surface-2); flex-shrink: 0; +} +.row .who { font-weight: 680; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.row .when { color: var(--muted); font-size: 13px; margin-top: 2px; } +.row .right { margin-left: auto; text-align: right; flex-shrink: 0; } +.row .conf { font-variant-numeric: tabular-nums; font-weight: 800; font-size: 15px; } +.row .xp { color: var(--accent-2); font-size: 13px; font-weight: 800; } + +/* ---------- Abzeichen ---------- */ + +.badges { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px,1fr)); gap: 13px; } +.badge { + display: flex; gap: 14px; align-items: flex-start; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r); padding: 16px 18px; box-shadow: var(--shadow); +} +.badge.locked { opacity: .55; box-shadow: none; background: var(--surface-2); } +/* Der Pin ist ein Bild, das Emoji ein Zeichen — beide landen in .em, deshalb + gilt die Graustufe fuer beides. Fehlt der Pin, faellt die Vorlage aufs + Emoji zurueck und die Karte sieht trotzdem richtig aus. */ +.badge .em { font-size: 30px; line-height: 1; filter: grayscale(1); } +.badge img.em { + width: 64px; height: 64px; flex: 0 0 64px; object-fit: contain; + /* blass, solange es nicht verdient ist: man soll sehen, was es zu holen + gibt, ohne dass es wie eingeloest aussieht. */ + opacity: .45; +} +.badge:not(.locked) .em { filter: none; } +.badge:not(.locked) img.em { + opacity: 1; + filter: drop-shadow(0 3px 6px rgba(20,40,28,.28)); +} +.badge:not(.locked) { border-color: var(--gold-line); background: var(--gold-wash); } +.badge .n { font-weight: 750; font-size: 15.5px; } +.badge .d { color: var(--muted); font-size: 13.5px; margin-top: 4px; line-height: 1.45; } + +/* ---------- Aktivität über den Tag ---------- */ + +.hours { display: flex; align-items: flex-end; gap: 3px; height: 74px; margin-top: 12px; } +.hours div { flex: 1; min-height: 3px; border-radius: 3px 3px 0 0; background: var(--typ); } +.hours-axis { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; margin-top: 6px; } + +/* ---------- Kleinteile ---------- */ + +.empty { + border: 1px dashed var(--line); border-radius: var(--r-lg); + padding: 42px 24px; text-align: center; color: var(--muted); + line-height: 1.6; background: var(--surface); +} + +.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } +.filters a { + padding: 7px 14px; border-radius: 999px; font-size: 13px; font-weight: 650; + border: 1px solid var(--line); background: var(--surface); color: var(--text-2); + transition: background .15s, color .15s, border-color .15s; +} +.filters a:hover { border-color: var(--muted); } +.filters a.on { background: var(--accent); color: var(--accent-on); border-color: var(--accent); } + +.pager { display: flex; gap: 10px; justify-content: center; margin: 28px 0; } +.pager a { + padding: 9px 20px; border-radius: 999px; border: 1px solid var(--line); + background: var(--surface); font-size: 14px; font-weight: 650; color: var(--text-2); +} +.pager a:hover { border-color: var(--accent); color: var(--accent); } + +.blaettern { display: flex; justify-content: space-between; gap: 12px; margin: 20px 0 4px; } +.blaettern a { color: var(--muted); font-size: 14px; font-weight: 650; } +.blaettern a:hover { color: var(--text); } + +footer.bottom { + border-top: 1px solid var(--line); margin-top: 60px; padding: 24px 0 32px; + color: var(--muted); font-size: 13px; line-height: 1.6; +} + +/* Der Werbeblock im Fuss. + Hier unten ist der richtige Ort: Wer so weit gescrollt hat, hat fremde + Vogelfotos gesehen und verstanden, was das Ding tut. Erst dann traegt der + Satz "kannst du auch haben". Deshalb ein Angebot mit Knopf statt einer + Fussnote — die leise Fassung stand hier einen Commit lang und war genau + das Gegenteil dessen, was die Seite leisten soll. */ +footer.bottom .werbung { + display: flex; align-items: center; justify-content: space-between; + gap: 28px; flex-wrap: wrap; + background: var(--accent-wash); border: 1px solid var(--line); + border-radius: 16px; padding: 22px 26px; + /* Kein margin-bottom mehr: Unter dem Block kommt nichts, seit die zweite + Fusszeile weg ist. Der Abstand nach unten gehoert dem footer selbst. */ +} +footer.bottom .werbung-text { flex: 1 1 340px; } +footer.bottom .werbung-haken { + margin: 0 0 4px; font-size: 19px; font-weight: 800; + letter-spacing: -0.02em; color: var(--text); +} +footer.bottom .werbung-satz { margin: 0; font-size: 14px; color: var(--text-2); max-width: 54ch; } +footer.bottom .werbung-knopf { + flex: none; display: inline-flex; align-items: center; gap: 8px; + background: var(--accent); color: var(--accent-on); + font-size: 15px; font-weight: 700; text-decoration: none; + border-radius: 999px; padding: 12px 20px; + transition: background .15s, transform .15s; +} +footer.bottom .werbung-knopf:hover { background: var(--accent-2); transform: translateY(-1px); } +footer.bottom .werbung-knopf svg { transition: transform .15s; } +footer.bottom .werbung-knopf:hover svg { transform: translateX(3px); } + +/* Auf dem Telefon untereinander, der Knopf ueber die volle Breite — ein + Knopf, den man mit dem Daumen trifft, ist mehr wert als einer, der + huebsch rechts sitzt. */ +@media (max-width: 560px) { + footer.bottom .werbung { padding: 20px; gap: 18px; } + footer.bottom .werbung-knopf { width: 100%; justify-content: center; } +} + +/* Das Zahnrad zum Admin — gleiche Form wie der Modus-Umschalter, weil es + dieselbe Sorte Knopf ist: eine Einstellung, kein Navigationspunkt. Oben bei + den Bedienelementen versteht man es ohne Beschriftung; im Footer neben + einem Fliesstext nicht, das war der erste Versuch. */ +.gear { + width: 38px; height: 38px; flex-shrink: 0; + border: 1px solid var(--line); border-radius: 50%; + background: var(--surface); color: var(--text-2); + font-size: 17px; line-height: 1; + display: grid; place-items: center; + transition: border-color .15s, color .15s; +} +.gear:hover { border-color: var(--accent); color: var(--accent); } + +@media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + +/* ---------- Schmale Geräte ---------- */ + +/* Der Kopf bricht um, sobald Marke, Live-Knopf, Navigation, Sprache und + Umschalter nicht mehr in eine Zeile passen. Gemessen, nicht geschätzt: bei + 810 px (iPad hochkant) rutschte sonst der Umschalter allein in Zeile zwei — + einer der Gründe, warum Layoutarbeit in vier Breiten angesehen wird. + Marke und die kleinen Knöpfe bleiben oben, die Navigation nimmt die Zeile + darunter. */ +@media (max-width: 1000px) { + .brand { order: 0; flex: 1 1 auto; } + .live-knopf, .lang-switch, .theme-toggle, .gear { order: 1; } + nav.main { order: 2; flex: 1 0 100%; margin-left: 0; } +} + +@media (max-width: 560px) { + /* Fünf Punkte passen auf einem Telefon nicht mehr in eine Zeile, und einer + allein in der zweiten sieht nach Versehen aus. Also seitlich wischen + statt umbrechen — die Zeile bleibt eine Zeile, egal wie viele Punkte + dazukommen oder wie lang die Sprache sie macht ("Sightings" gegen + "Sichtungen"). Die Leiste wird bündig am Rand gescrollt, damit man ihr + ansieht, dass da noch etwas kommt. */ + nav.main { + order: 2; flex: 1 0 100%; margin-left: 0; + flex-wrap: nowrap; justify-content: flex-start; + overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; + /* Der Wisch-Rand gehört in die Leiste, nicht neben sie. */ + margin-inline: -14px; padding-inline: 14px; + } + nav.main::-webkit-scrollbar { display: none; } + nav.main { gap: 1px; } + nav.main a { padding: 7px 8px; font-size: 12.5px; white-space: nowrap; flex: none; } + + .wrap { padding-inline: 14px; } + .grid { grid-template-columns: repeat(auto-fill, minmax(150px,1fr)); gap: 12px; } + + /* Auf 360-px-Geräten ergeben Marke, Live, Sprache und Umschalter zusammen + 380 px und der Umschalter faellt in eine dritte Zeile. Gemessen, nicht + geraten: 154 + 68 + 90 + 38 plus Abstaende gegen 332 px Platz, und das + Zahnrad kommt noch dazu. Also alles eine Stufe kompakter — und vom + Sprachumschalter bleibt nur die Sprache, in die man wechseln kann: welche + gerade aktiv ist, sieht man am Text der Seite. */ + .top-inner { gap: 8px; } + .brand { font-size: 19px; } + .live-knopf { font-size: 12px; padding: 5px 10px 5px 8px; gap: 5px; } + .theme-toggle, .gear { width: 34px; height: 34px; font-size: 15px; } + .lang-switch button.on { display: none; } + .progress-bar { padding: 16px; gap: 14px; } + .species-hero h1 { font-size: 30px; } + .wert { grid-template-columns: 74px 1fr auto; gap: 8px; } + .fakten { grid-template-columns: 1fr; } +} + +/* ---------- Sprachumschalter ---------- */ + +.lang-switch { + display: flex; gap: 2px; flex-shrink: 0; + border: 1px solid var(--line); border-radius: 999px; + background: var(--surface); padding: 2px; +} +.lang-switch button { + border: 0; background: transparent; cursor: pointer; + padding: 5px 11px; border-radius: 999px; + font: inherit; font-size: 12px; font-weight: 800; letter-spacing: .04em; + color: var(--muted); + transition: background .15s, color .15s; +} +.lang-switch button:hover { color: var(--text); } +.lang-switch button.on { background: var(--accent); color: var(--accent-on); } + +/* ---------- Filterleiste ---------- */ + +.filterleiste { + display: flex; flex-direction: column; gap: 12px; + padding: 14px 16px; margin-bottom: 18px; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r); box-shadow: var(--shadow); +} + +.filtergruppe { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + +.filterlabel { + font-size: 11px; font-weight: 800; letter-spacing: .08em; + text-transform: uppercase; color: var(--muted); + flex-shrink: 0; +} + +.filterleiste .pills { display: flex; gap: 7px; flex-wrap: wrap; } +.filterleiste .pills a { + padding: 6px 13px; border-radius: 999px; + font-size: 13px; font-weight: 650; + border: 1px solid var(--line); background: var(--surface); color: var(--text-2); + transition: border-color .15s, background .15s, color .15s; +} +.filterleiste .pills a b { font-weight: 800; opacity: .55; margin-left: 2px; } +.filterleiste .pills a:hover { border-color: var(--muted); } +.filterleiste .pills a.on { + background: var(--accent); color: var(--accent-on); border-color: var(--accent); +} +.filterleiste .pills a.on b { opacity: .8; } + +/* Die Verfeinerung sitzt optisch unter der Hauptachse, nicht daneben. */ +.verfeinern { border-top: 1px solid var(--line-soft, var(--line)); padding-top: 12px; } +.verfeinern label { display: flex; align-items: center; gap: 8px; } + +.verfeinern select { + font: inherit; font-size: 13.5px; font-weight: 600; + padding: 7px 30px 7px 12px; border-radius: 999px; + border: 1px solid var(--line); background: var(--surface); color: var(--text); + cursor: pointer; + /* Eigener Pfeil: das native Dreieck sieht in jedem Browser anders aus. */ + -webkit-appearance: none; -moz-appearance: none; appearance: none; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 16px) 52%, calc(100% - 11px) 52%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} +.verfeinern select:hover { border-color: var(--muted); } +.verfeinern select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +.verfeinern button { + font: inherit; font-size: 13.5px; font-weight: 650; + padding: 7px 16px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--accent); background: var(--accent); color: var(--accent-on); +} + +.zuruecksetzen { + font-size: 13px; font-weight: 650; color: var(--muted); + text-decoration: underline; text-underline-offset: 3px; +} +.zuruecksetzen:hover { color: var(--text); } + +@media (max-width: 560px) { + .filterleiste { padding: 12px; gap: 10px; } + .filtergruppe { gap: 8px; } + /* Label über die Zeile, damit die Pillen die volle Breite bekommen. */ + .filtergruppe > .filterlabel { flex: 1 0 100%; } + .filterleiste .pills a { padding: 6px 11px; font-size: 12.5px; } + .verfeinern { flex-wrap: wrap; } + .verfeinern label { flex: 1 1 46%; } + .verfeinern label .filterlabel { display: none; } + .verfeinern select { width: 100%; } +} + +/* ---------- Blickfang der Startseite ---------- + Die Seite begann mit vier Kacheln, von denen an einem ruhigen Tag zwei auf + null standen. Jetzt steht der letzte Gast gross oben; die Zahlen sind + dorthin gewandert, wo sie etwas erklaeren. */ + +.held { + display: grid; grid-template-columns: 1fr 1fr; gap: 24px; + align-items: stretch; margin: 22px 0 8px; +} +.held-text h1 { + font-size: 38px; line-height: 1.08; letter-spacing: -0.03em; + font-weight: 800; margin: 0 0 10px; +} +.held-text p { margin: 0 0 20px; color: var(--text-2); font-size: 16px; max-width: 44ch; } +.held .progress-bar { margin: 0; } + +.held-bild { + position: relative; border-radius: var(--r-lg); overflow: hidden; + box-shadow: var(--shadow); min-height: 280px; background: var(--surface-2); + display: block; +} +.held-bild img { width: 100%; height: 100%; object-fit: cover; display: block; } +.held-bild .schild { + position: absolute; left: 0; right: 0; bottom: 0; padding: 40px 20px 16px; + color: #fff; + /* Fester Farbwert statt Token: der Verlauf liegt ueber einem Foto, nicht + ueber dem Seitenhintergrund — er muss in beiden Modi gleich dunkeln. */ + background: linear-gradient(transparent, rgba(8,24,16,.88)); +} +.held-bild .schild b { display: block; font-size: 22px; font-weight: 800; } +.held-bild .schild small { opacity: .9; font-size: 13px; } +.frisch { + position: absolute; top: 14px; left: 14px; + background: var(--kopf-text); color: var(--kopf-bg); + padding: 5px 11px; border-radius: 999px; + font-size: 11px; font-weight: 800; letter-spacing: .07em; +} +.held-leer { + display: grid; place-items: center; text-align: center; padding: 30px; + border: 1px dashed var(--line); border-radius: var(--r-lg); + color: var(--muted); background: var(--surface); +} + +/* ---------- Besuche als Bildkarten ---------- */ + +.besuche { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; } +.besuch { + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r); overflow: hidden; box-shadow: var(--shadow); + transition: transform .15s, box-shadow .15s; +} +.besuch:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); } +/* ⚠ Feste Hoehe statt aspect-ratio: das gibt es erst ab Safari 15 und faellt + davor ersatzlos weg, womit die Karten unterschiedlich hoch waeren. */ +.besuch img, .besuch .leer { width: 100%; height: 118px; object-fit: cover; display: block; } +.besuch .leer { background: var(--surface-2); } +.besuch .f { padding: 10px 12px; } +.besuch b { font-weight: 750; font-size: 14.5px; } +.besuch small { display: block; color: var(--muted); font-size: 12.5px; margin-top: 2px; } +.besuch .xp { float: right; color: var(--accent); font-weight: 800; font-size: 12.5px; } + +@media (max-width: 900px) { + .held { grid-template-columns: 1fr; } + .held-bild { min-height: 230px; order: -1; } + .held-text h1 { font-size: 30px; } + .besuche { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 420px) { + .held-text h1 { font-size: 26px; } + .besuche { grid-template-columns: 1fr 1fr; gap: 10px; } +} diff --git a/web/static/img/marke.svg b/web/static/img/marke.svg new file mode 100644 index 0000000..16486bb --- /dev/null +++ b/web/static/img/marke.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/static/img/pins/arten_10.png b/web/static/img/pins/arten_10.png new file mode 100644 index 0000000..b3049f5 Binary files /dev/null and b/web/static/img/pins/arten_10.png differ diff --git a/web/static/img/pins/arten_25.png b/web/static/img/pins/arten_25.png new file mode 100644 index 0000000..d474a5b Binary files /dev/null and b/web/static/img/pins/arten_25.png differ diff --git a/web/static/img/pins/arten_40.png b/web/static/img/pins/arten_40.png new file mode 100644 index 0000000..4b21411 Binary files /dev/null and b/web/static/img/pins/arten_40.png differ diff --git a/web/static/img/pins/arten_5.png b/web/static/img/pins/arten_5.png new file mode 100644 index 0000000..cbf947c Binary files /dev/null and b/web/static/img/pins/arten_5.png differ diff --git a/web/static/img/pins/besucher_100.png b/web/static/img/pins/besucher_100.png new file mode 100644 index 0000000..002cae7 Binary files /dev/null and b/web/static/img/pins/besucher_100.png differ diff --git a/web/static/img/pins/besucher_1000.png b/web/static/img/pins/besucher_1000.png new file mode 100644 index 0000000..8186f8a Binary files /dev/null and b/web/static/img/pins/besucher_1000.png differ diff --git a/web/static/img/pins/dauergast.png b/web/static/img/pins/dauergast.png new file mode 100644 index 0000000..3458841 Binary files /dev/null and b/web/static/img/pins/dauergast.png differ diff --git a/web/static/img/pins/erster_gast.png b/web/static/img/pins/erster_gast.png new file mode 100644 index 0000000..bb851a9 Binary files /dev/null and b/web/static/img/pins/erster_gast.png differ diff --git a/web/static/img/pins/fruehaufsteher.png b/web/static/img/pins/fruehaufsteher.png new file mode 100644 index 0000000..2b4c0ee Binary files /dev/null and b/web/static/img/pins/fruehaufsteher.png differ diff --git a/web/static/img/pins/greifvogel_alarm.png b/web/static/img/pins/greifvogel_alarm.png new file mode 100644 index 0000000..ceae3f9 Binary files /dev/null and b/web/static/img/pins/greifvogel_alarm.png differ diff --git a/web/static/img/pins/klopfgeist.png b/web/static/img/pins/klopfgeist.png new file mode 100644 index 0000000..87e8849 Binary files /dev/null and b/web/static/img/pins/klopfgeist.png differ diff --git a/web/static/img/pins/nachtschicht.png b/web/static/img/pins/nachtschicht.png new file mode 100644 index 0000000..537fa98 Binary files /dev/null and b/web/static/img/pins/nachtschicht.png differ diff --git a/web/static/img/pins/seltener_gast.png b/web/static/img/pins/seltener_gast.png new file mode 100644 index 0000000..f0c71e6 Binary files /dev/null and b/web/static/img/pins/seltener_gast.png differ diff --git a/web/static/img/pins/stammgast.png b/web/static/img/pins/stammgast.png new file mode 100644 index 0000000..6b28dc0 Binary files /dev/null and b/web/static/img/pins/stammgast.png differ diff --git a/web/static/img/pins/volles_haus.png b/web/static/img/pins/volles_haus.png new file mode 100644 index 0000000..2ad78d0 Binary files /dev/null and b/web/static/img/pins/volles_haus.png differ diff --git a/web/static/img/symbol-180.png b/web/static/img/symbol-180.png new file mode 100644 index 0000000..36d17f2 Binary files /dev/null and b/web/static/img/symbol-180.png differ diff --git a/web/static/img/symbol-192.png b/web/static/img/symbol-192.png new file mode 100644 index 0000000..013bbf6 Binary files /dev/null and b/web/static/img/symbol-192.png differ diff --git a/web/static/img/symbol-512.png b/web/static/img/symbol-512.png new file mode 100644 index 0000000..016208c Binary files /dev/null and b/web/static/img/symbol-512.png differ diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest new file mode 100644 index 0000000..24d89ea --- /dev/null +++ b/web/static/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Vogelalbum", + "short_name": "Vogelalbum", + "description": "Welche Vögel besuchen das Vogelhaus? Erkannt, gesammelt, katalogisiert.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#106943", + "icons": [ + { "src": "/static/img/symbol-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/static/img/symbol-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/static/img/symbol-512.png", "sizes": "512x512", "type": "image/png", + "purpose": "maskable" } + ] +} diff --git a/web/templates/dex/badges.html b/web/templates/dex/badges.html new file mode 100644 index 0000000..946754a --- /dev/null +++ b/web/templates/dex/badges.html @@ -0,0 +1,74 @@ +{% extends "dex/base.html" %} +{% load i18n dex_extras %} + +{% block title %}{% trans "Abzeichen" %} — Vogelalbum{% endblock %} + +{% block kopf %} + + +{% endblock %} + +{% block content %} + +

{% trans "Abzeichen" %} {% blocktrans with n=progress.badges %}{{ n }} verdient{% endblocktrans %}

+ +
+ {% for row in rows %} +
+ {% abzeichen_bild row.badge.code as pin %} + {% if pin %} + + {% else %} +
{{ row.badge.emoji }}
+ {% endif %} +
+
{{ row.badge.name_lokal }}
+
{{ row.badge.description_lokal }}
+
+ {% if row.earned %}{{ row.at|date:"SHORT_DATE_FORMAT" }} · +{{ row.badge.xp }} XP{% else %}+{{ row.badge.xp }} XP{% endif %} +
+ + {% comment %} + 🔑 Balken nur, wo es etwas zu zaehlen gibt. Bei "Ein Specht war da" + stuende er dauerhaft auf null und waere keine Information. + ⚠️ {# … #} ist in Django EINZEILIG — mehrzeilig braucht es comment, + sonst steht der Text sichtbar auf der Seite. + {% endcomment %} + {% if row.ziel %} +
+
+
+
{{ row.ist }} / {{ row.ziel }}
+ {% endif %} +
+
+ {% endfor %} +
+ +{% endblock %} diff --git a/web/templates/dex/base.html b/web/templates/dex/base.html new file mode 100644 index 0000000..19400f2 --- /dev/null +++ b/web/templates/dex/base.html @@ -0,0 +1,273 @@ +{% load static i18n dex_extras %}{% get_current_language as LANG %} + + + + + {% block title %}Vogelalbum{% endblock %} + + {% comment %} + Vor dem Stylesheet und ohne defer: sonst rendert die Seite kurz hell und + springt dann auf dunkel. Das Flackern sieht man, und es nervt. + {% endcomment %} + + + {% comment %} + ⚠ Hier stand ein Emoji als Symbol im Reiter. Das sieht auf einem Mac gut + aus und ist anderswo ein leeres Kaestchen: Emoji kommen aus einer Schrift, + die nicht jedes System mitbringt — ein Linux-Browser ohne Emoji-Schrift + zeigte an der Stelle ein Tofu-Rechteck, und zwar genau dort, wo die Marke + steht. Eine Marke darf nicht davon abhaengen, was auf dem fremden Rechner + installiert ist. + {% endcomment %} + + {% comment %} + ⚠ Fuer den Startbildschirm reicht das SVG NICHT: iOS nimmt dort weder SVG + noch Durchsichtigkeit. Ohne diese Zeile legt Safari einen verkleinerten + Bildschirmabzug der Seite als Symbol ab — das sieht aus wie ein Fehler. + Deshalb ein PNG mit Flaeche darunter und Luft am Rand, weil das System + die Ecken selbst zuschneidet. + {% endcomment %} + + + + {% comment %} + Hier gehoert seitenbezogenes CSS hin, NICHT in den title-Block. + + ⚠ Der Titel steht in …, und dort behandelt der Browser + alles als Text. Ein + + + + + + + diff --git a/web/templates/dex/dex.html b/web/templates/dex/dex.html new file mode 100644 index 0000000..0798118 --- /dev/null +++ b/web/templates/dex/dex.html @@ -0,0 +1,98 @@ +{% extends "dex/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Sammlung" %} — Vogelalbum{% endblock %} + +{% block content %} + +

{% trans "Die Sammlung" %} + + {% blocktrans with a=found_count b=total_count %}{{ a }} von {{ b }} Arten{% endblocktrans %}{% if habitat_label %} · {{ habitat_label }}{% endif %} + +

+ +{% comment %} + Lebensraum ist die Hauptachse und bleibt sichtbar. Seltenheit und Gruppe + sind Verfeinerungen und gehören in Auswahlfelder — 27 Typen als Pillen + waren auf dem Handy zwölf Zeilen, bevor der erste Vogel kam. +{% endcomment %} +
+ {% if habitate %} +
+ {% trans "Lebensraum" %} +
+ {% for h in habitate %} + {{ h.label }} {{ h.n }} + {% endfor %} + {% trans "Alle" %} {{ gesamt_alle }} +
+
+ {% endif %} + +
+ + + + + + + + + {% if rarity_filter or typ_filter %} + {% trans "Zurücksetzen" %} + {% endif %} +
+
+ +{% if cards %} + +{% else %} +
{% trans "Keine Art passt zu dieser Auswahl." %}
+{% endif %} + +{% endblock %} diff --git a/web/templates/dex/hochladen.html b/web/templates/dex/hochladen.html new file mode 100644 index 0000000..6037562 --- /dev/null +++ b/web/templates/dex/hochladen.html @@ -0,0 +1,161 @@ +{% extends "dex/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Fotos hochladen" %} — Vogelalbum{% endblock %} + +{% block kopf %} + +{% endblock %} + +{% block content %} + +

{% trans "Fotos hochladen" %}

+ +

+ {% comment %} + ⚠ In einer Zeile lassen. Ein Umbruch im Quelltext landet als ``\n`` mitten + im Schlüssel der .po-Datei — der Text ist dann nur noch mit genau diesem + Umbruch übersetzbar, und jede Einrückungsänderung macht ihn wieder deutsch. + {% endcomment %} + {% blocktrans %}Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim nächsten Durchgang und trägt es in die Sammlung ein.{% endblocktrans %} +

+ +{% if angelegt %} +
+ {% blocktrans count n=angelegt %}{{ n }} Foto angenommen. Es wartet auf den Detector.{% plural %}{{ n }} Fotos angenommen. Sie warten auf den Detector.{% endblocktrans %} +
+{% endif %} +{% if fehler %} +
{{ fehler }}
+{% endif %} + +
+ {% csrf_token %} + +
+ + {% comment %} + multiple ist Absicht: Von einem Spaziergang kommen selten einzelne + Fotos. Je Bild entsteht eine eigene Sichtung — zehn Fotos können + fünf verschiedene Arten sein. + {% endcomment %} + +
{% trans "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt." %}
+
+ + {% if kameras %} +
+ + +
+ {% endif %} + +
+ +
+
+
+
+
+ {% blocktrans %}Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die Adresse.{% endblocktrans %} +
+
+ +
+ +
+
+ +{% if wartende_fotos %} +

+ {% trans "Warten auf Bestimmung" %} + {{ wartend }} +

+ {% comment %} + Sichtbar, obwohl noch nichts bestimmt ist. Wäre hier nichts zu sehen, + hielte man den Upload für fehlgeschlagen und lüde dasselbe Foto erneut + hoch — und die Doppel sortiert hinterher niemand auseinander. + {% endcomment %} +
+ {% for s in wartende_fotos %} +
+ {% if s.image %}{% trans {% endif %} +
{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}
+
+ {% endfor %} +
+

+ {% blocktrans %}Bleiben die Bilder länger stehen, läuft der Detector nicht.{% endblocktrans %} +

+{% endif %} + +{% endblock %} diff --git a/web/templates/dex/home.html b/web/templates/dex/home.html new file mode 100644 index 0000000..fc8a558 --- /dev/null +++ b/web/templates/dex/home.html @@ -0,0 +1,159 @@ +{% extends "dex/base.html" %} +{% load i18n dex_extras %} + +{% block title %}{% trans "Vogelalbum — Übersicht" %}{% endblock %} + +{% comment %} + Der Fortschrittsbalken steht auf allen Seiten oben. Hier nicht: er ist Teil + des Blickfangs geworden. Zweimal dieselbe Leiste untereinander sähe nach + Versehen aus. +{% endcomment %} +{% block progress %}{% endblock %} + +{% block content %} + +{% comment %} + 🔑 Die Startseite begann vorher mit vier Kacheln, von denen an einem ruhigen + Tag zwei auf null standen — "0 Besuche heute, 0 Arten heute". Das ist die + erste Seite, die ein Fremder sieht, und sie sagte ihm: hier passiert nichts. + Jetzt steht oben der letzte Gast in groß. Die Zahlen sind nicht weg, sie + stehen dort, wo sie etwas erklären: neben der Überschrift des Abschnitts, + zu dem sie gehören. +{% endcomment %} +
+
+ {% comment %} + 🔑 Die Überschrift kommt aus ``views.blickfang_titel`` und richtet sich + nach dem letzten Besuch. Vorher stand hier fest „Wer war heute im + Futterhaus?" — fünf Tage nach dem letzten Gast behauptete die Seite damit + etwas, das das Bild daneben widerlegte. + {% endcomment %} +

{{ blickfang_titel }}

+

{% trans "Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder vom Handyfoto. Alles auf dem eigenen Server." %}

+ +
+
{{ progress.level }}
+
+
{{ progress.rank }}
+
+
+
+
{% blocktrans with xp=progress.xp rest=progress.xp_to_next lvl=progress.level|add:1 %}{{ xp }} XP · noch {{ rest }} XP bis Level {{ lvl }}{% endblocktrans %}
+
+
+
{{ progress.species_seen }} / {{ progress.species_total }}
+
{% trans "Arten" %}
+
+
+
+ + {% with letzte=recent.0 %} + {% if letzte %} + + {% if letzte.image %} + {{ letzte.effective_species.name }} + {% elif letzte.kachel_url %} + {{ letzte.effective_species.name }} + {% endif %} + {% trans "ZULETZT" %} +
+ {{ letzte.effective_species.name }} + {{ letzte.started_at|zeitpunkt }} · {{ letzte.duration_s|floatformat:0 }}s · {% widthratio letzte.confidence 1 100 %}% +
+
+ {% else %} +
+ {% trans "Noch kein Gast aufgezeichnet." %}
+ {% trans "Der erste bestimmte Vogel steht hier." %} +
+ {% endif %} + {% endwith %} +
+ +{% if new_entries %} +

{% trans "Neu in der Sammlung" %} {% trans "letzte 7 Tage" %}

+ +{% endif %} + +

{% trans "Zuletzt zu Besuch" %} + {% blocktrans with n=progress.sightings %}{{ n }} Besuche insgesamt{% endblocktrans %}

+{% if recent %} +{% comment %} + ⚠ Bildkarten statt Zeilen. Die Zeilen liefen über die ganze Breite, mit dem + Namen links und dem Prozentwert rechts — dazwischen auf einem großen + Bildschirm tausend Pixel Nichts. Eine Karte ist so breit wie ihr Inhalt. + + Der erste Eintrag fehlt hier mit Absicht: der steht oben im Blickfang. +{% endcomment %} + +{% else %} +
+ {% trans "Noch keine Sichtung aufgezeichnet." %}
+ {% trans "Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert ist, erscheint hier der erste Gast." %} +
+{% endif %} + +{% if top %} +

{% trans "Stammgäste" %}

+ +{% endif %} + +{% endblock %} diff --git a/web/templates/dex/live.html b/web/templates/dex/live.html new file mode 100644 index 0000000..f2f4c3f --- /dev/null +++ b/web/templates/dex/live.html @@ -0,0 +1,135 @@ +{% extends "dex/base.html" %} +{% load i18n l10n %} + +{% block title %}{% trans "Live" %} — Vogelalbum{% endblock %} + +{% block content %} + +

{% trans "Live" %} + + {% blocktrans count n=cameras|length %}{{ n }} Kamera{% plural %}{{ n }} Kameras{% endblocktrans %} + +

+ +
+ {% for kamera in cameras %} +
+ {% comment %} + Das Bild kommt vom Live-Verteiler des Detectors, nicht von der Kamera — + die liefert nur einen Stream, und den braucht die Erkennung. Der Browser + lädt es direkt, der Server reicht nichts durch: sonst hinge an jedem + Zuschauer eine offene Verbindung in der Web-App. + {% endcomment %} +
+ {{ kamera.name }} + {% comment %} + Der Name liegt AUF dem Bild: In einer Reihe gleich aussehender + Gartenbilder erkennt man sonst nicht, welche Kamera welche ist — + und beim Scrollen ist die Bildunterschrift oft aus dem Blick. + + ⚠ Diese lange Kommentarform benutzen, nicht die kurze mit der + Raute: Die kurze gilt nur für EINE Zeile. Über mehrere Zeilen wird + sie nicht entfernt, sondern steht wörtlich auf der Seite. Genau das + ist am 23.09.2026 passiert, und man sieht es erst im Browser. + {% endcomment %} + Bild wird geholt … + {{ kamera.name }} +
+
+ {{ kamera.name }} + {% if kamera.location %}{{ kamera.location }}{% endif %} +
+
+ {% endfor %} +
+ +

+ {% trans "Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom." %} +

+ + + + + +{% endblock %} diff --git a/web/templates/dex/sightings.html b/web/templates/dex/sightings.html new file mode 100644 index 0000000..bc0802c --- /dev/null +++ b/web/templates/dex/sightings.html @@ -0,0 +1,105 @@ +{% extends "dex/base.html" %} +{% load dex_extras i18n %} + +{% block title %}{% trans "Sichtungen" %} — Vogelalbum{% endblock %} + +{% block kopf %} + +{% endblock %} + +{% block content %} + +
+

{% trans "Sichtungen" %} {% blocktrans with n=total %}{{ n }} insgesamt{% endblocktrans %}

+ {% comment %} + Nur fuer Angemeldete. Ein oeffentlicher Besucher soll keinen Knopf sehen, + der ihn bloss ins Login schickt -- und einliefern darf er ohnehin nicht. + {% endcomment %} + {% if user.is_staff %} + {% trans "Foto hochladen" %} + {% endif %} +
+ + + +{% if cameras %} + +{% endif %} + +{% if sightings %} +
+ {% for s in sightings %} +
+ {% if s.kachel_url %} + {# Klick zeigt das grosse Foto -- siehe base.html #} + + {% else %}
{% endif %} +
+
+ {% if s.effective_species %} + {{ s.effective_species.name }} + {% if s.confirmed_species %}{% trans "von Hand" %}{% endif %} + {% else %} + {% trans "Unbestimmt" %} + {% endif %} + {% if s.is_first_of_species %}{% trans "Erstfang" %}{% endif %} +
+
+ {{ s.started_at|date:"DATETIME_FORMAT" }} · {{ s.duration_s|floatformat:0 }}s + {% if s.camera %} · {{ s.camera.name }}{% endif %} +
+ {% with alt=s.votes|alternativen %} + {% if alt %} +
+ {% trans "Alternativen" %}: + {% for a in alt %}{{ a.species.name }} {{ a.anteil }}%{% if not forloop.last %}, {% endif %}{% endfor %} +
+ {% endif %} + {% endwith %} +
+
+
{{ s.confidence|prozent }}%
+ {% if s.xp_awarded %}
+{{ s.xp_awarded }} XP
{% endif %} +
+
+ {% endfor %} +
+ +
+ {% if page > 1 %}← {% trans "Neuer" %}{% endif %} + {% if has_next %}{% trans "Älter" %} →{% endif %} +
+{% else %} +
{% trans "Noch nichts aufgezeichnet." %}
+{% endif %} + +{% endblock %} diff --git a/web/templates/dex/species.html b/web/templates/dex/species.html new file mode 100644 index 0000000..cd74451 --- /dev/null +++ b/web/templates/dex/species.html @@ -0,0 +1,149 @@ +{% extends "dex/base.html" %} +{% load dex_extras i18n %} + +{% block title %}{{ species.name }} — Vogelalbum{% endblock %} + +{% block content %} +{# Die Typfarbe faerbt die ganze Seite: Hero, Balken, Initiale, Diagramm. #} +
+ +
+ {% if vorher %}← {{ vorher.dex_nr }} {{ vorher.name }}{% else %}{% endif %} + {% if nachher %}{{ nachher.dex_nr }} {{ nachher.name }} →{% endif %} +
+ +
+
+
+
+ {% if entry and entry.best_sighting and entry.best_sighting.image %} + {{ species.name }} + {% elif species.reference_image %} + {{ species.german }} + {% endif %} +
+
+ {% if entry and entry.best_sighting and entry.best_sighting.image %} + {% trans "Bestes eigenes Foto" %} · {{ entry.best_sighting.started_at|date:"SHORT_DATETIME_FORMAT" }} + {% elif species.reference_image %} + {% trans "Referenzfoto — diese Art war noch nicht vor unserer Kamera." %} + {% if species.reference_credit %} + {% trans "Foto" %}: {{ species.reference_credit }}{% if species.reference_license %}, {{ species.reference_license }}{% endif %} + {% endif %} + {% endif %} +
+
+ +
+
{{ species.dex_nr }}
+

{{ species.name }}

+ {% if species.kategorie_lokal %}
{{ species.kategorie_lokal }}
{% endif %} +
{{ species.scientific }}{% if species.zweitname %} · {{ species.zweitname }}{% endif %}
+ +
+ {% if species.typ %}{{ species.typ_label }}{% endif %} + {{ species.rarity_label }} + {% if species.zug %}{{ species.zug_label }}{% endif %} + {{ species.xp }} XP + {% if entry %}{% blocktrans count n=entry.count %}{{ n }}× seen{% plural %}{{ n }}× seen{% endblocktrans %}{% endif %} +
+ + {% if species.balken %} +
+ {% for b in species.balken %} +
+
{{ b.label }}
+
+
{{ b.wert }}
+
+ {% endfor %} +
+ {% endif %} +
+
+
+ + {% if species.absaetze %} +

{% trans "Steckbrief" %}

+
+ {% for p in species.absaetze %}

{{ p }}

{% endfor %} +
+ {% elif species.fact %} +

{% trans "Steckbrief" %}

+

{{ species.fact }}

+ {% endif %} + +
+ {% if species.lebensraum_lokal %} +
{% trans "Lebensraum" %}
{{ species.lebensraum_lokal }}
+ {% endif %} + {% if species.nahrung_lokal %} +
{% trans "Nahrung" %}
{{ species.nahrung_lokal }}
+ {% endif %} + {% if species.stimme_lokal %} +
{% trans "Stimme" %}
{{ species.stimme_lokal }}
+ {% endif %} + {% if species.zug %} +
{% trans "Zugverhalten" %}
{{ species.zug_label }}
+ {% endif %} +
+ + {% if aehnliche %} +

{% trans "Verwechslungsgefahr" %} {% trans "Arten, die man nicht auf Anhieb unterscheidet" %}

+
+ {% for a in aehnliche %} + + {% if a.reference_image %}{% endif %} + {{ a.name }} + + {% endfor %} +
+ {% endif %} + + {% if entry %} +

{% blocktrans with n=species.name %}Wann ist {{ n }} da?{% endblocktrans %} {% trans "Besuche über den Tag" %}

+
+ {% for h in hours %}
{% endfor %} +
+
{% trans "0 Uhr" %}6121823
+ {% endif %} + + {% if sightings %} +

{% trans "Alle Besuche" %} {% blocktrans with n=entry.count %}{{ n }} insgesamt{% endblocktrans %}

+
+ {% for s in sightings %} +
+ {% if s.kachel_url %} + + {% else %}
{% endif %} +
+
+ {{ s.started_at|date:"DATETIME_FORMAT" }} + {% if s.is_first_of_species %}{% trans "Erstfang" %}{% endif %} +
+
+ {{ s.duration_s|floatformat:0 }}s · {% blocktrans with a=s.frames_with_bird b=s.frames_total %}{{ a }} von {{ b }} Bildern mit Vogel{% endblocktrans %} + {% if s.camera %} · {{ s.camera.name }}{% endif %} +
+
+
+
{{ s.confidence|prozent }}%
+ {% if s.xp_awarded %}
+{{ s.xp_awarded }} XP
{% endif %} +
+
+ {% endfor %} +
+ {% elif not entry %} +
+ {% trans "Noch nicht gesammelt." %}
+ {% blocktrans with n=species.name %}Sobald {{ n }} vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit eurem eigenen Foto.{% endblocktrans %} +
+ {% endif %} + +
+{% endblock %} diff --git a/web/templates/dex/widget.html b/web/templates/dex/widget.html new file mode 100644 index 0000000..2eb082c --- /dev/null +++ b/web/templates/dex/widget.html @@ -0,0 +1,42 @@ +{% load static i18n dex_extras %}{% get_current_language as LANG %} + + + + + Vogelalbum + + + + +
+
+ {% if latest %} + {% if latest.kachel_url %}{{ latest.effective_species.name }}{% endif %} +
+
{% trans "Zuletzt gesehen" %}
+
{{ latest.effective_species.name }}
+
{{ latest.started_at|zeitpunkt }} · {% widthratio latest.confidence 1 100 %}% {% trans "sicher" %}
+
+ {% else %} +
+
Vogelalbum
+
{% trans "Noch kein Gast" %}
+
+ {% endif %} +
+
{{ progress.species_seen }}/{{ progress.species_total }}
+
{% trans "Arten" %}
+
+
+
+ + diff --git a/web/vogelalbum/__init__.py b/web/vogelalbum/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/vogelalbum/asgi.py b/web/vogelalbum/asgi.py new file mode 100644 index 0000000..72038dd --- /dev/null +++ b/web/vogelalbum/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for vogelalbum project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings') + +application = get_asgi_application() diff --git a/web/vogelalbum/settings.py b/web/vogelalbum/settings.py new file mode 100644 index 0000000..9757fd6 --- /dev/null +++ b/web/vogelalbum/settings.py @@ -0,0 +1,288 @@ +"""Django-Einstellungen fürs Vogelalbum.""" + +from pathlib import Path +import os + +from django.core.exceptions import ImproperlyConfigured + +BASE_DIR = Path(__file__).resolve().parent.parent + +DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" + +# Im Betrieb wird nichts geraten. Wer das Repo klont und DEBUG abschaltet, +# soll nicht versehentlich mit einem Schlüssel laufen, der öffentlich im +# Quelltext steht. +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "") + +# 🔑 Zweite Quelle: die Schlüsseldatei auf dem Datenvolume. Der Entrypoint legt +# sie an, wenn keine Umgebungsvariable gesetzt ist, und exportiert den Wert — +# aber nur in SEINE eigene Prozesskette. Gunicorn hat ihn damit, ein spaeter +# dazukommendes `docker exec` nicht. +# +# ⚠️ Das ist kein Randfall, sondern der Normalweg fuer Wartungsbefehle: +# `createsuperuser`, `stoerungen_pruefen` als geplante Aufgabe, jede +# Fehlersuche im laufenden Container. Ohne diese Zeilen brechen sie alle mit +# "DJANGO_SECRET_KEY fehlt" ab — und eine geplante Aufgabe scheitert dabei +# still: sie steht sichtbar in der Oberflaeche und hat nie etwas getan. +# Gefunden am 03.10.2026, als der Stoerungswaechter nach Coolify zog. +if not SECRET_KEY: + schluesseldatei = Path(os.environ.get("SQLITE_DIR", BASE_DIR)) / "secret_key" + try: + SECRET_KEY = schluesseldatei.read_text().strip() + except OSError: + pass + +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = "nur-fuer-entwicklung-nicht-im-betrieb-verwenden" + else: + raise ImproperlyConfigured( + "DJANGO_SECRET_KEY fehlt. Im Betrieb (DJANGO_DEBUG=0) ist ein " + "eigener Schlüssel Pflicht:\n" + " python -c \"import secrets; print(secrets.token_urlsafe(50))\"" + ) + +ALLOWED_HOSTS = [ + h.strip() + for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") + if h.strip() +] +CSRF_TRUSTED_ORIGINS = [ + o.strip() + for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") + if o.strip() +] + +# 🔑 **Hinter Traefik spricht niemand HTTPS mit uns.** Coolify terminiert TLS +# davor und reicht einfaches HTTP an den Container weiter. Django glaubt +# deshalb, es werde über http:// angesprochen — und `build_absolute_uri()` +# baut jede Bild- und Medienadresse mit `http://`. +# +# ⚠️ **Das fällt im Browser nicht auf und bricht die App.** Traefik leitet +# http auf https um, also sieht eine Seite im Browser richtig aus; es kostet +# nur eine zusätzliche Runde. Auf dem Telefon ist es ein Ausfall: iOS blockt +# nach App Transport Security einfaches HTTP, ohne der Umleitung zu folgen. +# Dann ist das Album bilderlos, und zwar genau auf dem Gerät, für das es +# gebaut ist. +# +# Gefunden am 14.10.2026 beim Prüfen der Live-Antworten — `referenzbild` und +# `bild` kamen als `http://…` zurück und antworteten mit 302. Betroffen war +# nicht nur die App-API: `api.zustand` liefert dem Home-Assistant-Bridge +# dieselben Adressen, der Fehler stand seit dem Coolify-Umzug drin. +# +# Der Header kommt von Traefik; dass wir ihm trauen, ist nur richtig, solange +# der Container nicht direkt aus dem Netz erreichbar ist — hinter Coolify ist +# er das nicht. +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True + +# --- Einstellungen aus der Umgebung --------------------------------------- +# +# ⚠️ Dieselbe kleine Funktion steht noch zweimal im Projekt +# (``detector/umgebung.py`` und ``homeassistant/bruecke.py``). Das ist Absicht +# und kein Versehen: Die drei Teile werden getrennt ausgeliefert — der +# Detector als eigenes Image, die Brücke als einzelne Datei auf einem dritten +# Rechner — und teilen sich keinen Paketpfad. Ein paar Zeilen dreimal sind +# billiger als eine Abhängigkeit, die bei einem der drei fehlt. + + +def umgebung(name: str, default: str = "") -> str: + """Liest ``VOGELALBUM_``. + + 🔑 **Ein ausdrücklich leerer Wert schlägt den Vorgabewert.** Das trägt + hier Bedeutung: Ein leerer Ingest-Token heißt „Endpunkt zu", nicht „nimm + den Standard". Wer ``or default`` schriebe, machte aus einer bewussten + Abschaltung stillschweigend wieder eine Öffnung. + """ + wert = os.environ.get(f"VOGELALBUM_{name}") + if wert is None: + return default + return wert + + +# Gemeinsames Geheimnis zwischen Detector und Web-App. Der Detector schickt es +# als Bearer-Token; ohne gültigen Token nimmt /api/sightings/ nichts an. +# +# Leer ist erlaubt und heißt: keine Kamera ohne eigenen Token. Registrierte +# Kameras (manage.py camera add) funktionieren dann trotzdem — der Weg ist +# ohnehin der bessere, weil jede Kamera einzeln stillgelegt werden kann. +INGEST_TOKEN = umgebung("INGEST_TOKEN", "dev-token" if DEBUG else "") + +# Wohin der Quelltext-Link im Fuß zeigt. +# +# 🔑 Das ist keine Höflichkeit, sondern die AGPL: Wer eine veränderte Fassung +# über ein Netz anbietet, muss ihren Nutzern den Quelltext zugänglich machen +# (§13). Ein Link im Fuß ist der einfachste Weg, das zu erfüllen. +# +# Deshalb überschreibbar: Wer Vogelalbum verändert und öffentlich betreibt, +# trägt hier SEIN Repository ein — nicht dieses. Die Vorgabe passt für alle, +# die nichts verändert haben. +QUELLTEXT_URL = umgebung("QUELLTEXT_URL", "https://gitea.gaertner.onl/christian/vogelalbum") + +# Welche Region gilt. Entscheidet, welche Arten der Klassifikator überhaupt +# erwägt: das iNat-Modell kennt 965 Arten weltweit und ist auf Nordamerika +# trainiert — ohne Einschränkung wird aus einer Kohlmeise eine Carolina +# Chickadee. Die Arten tragen das Kürzel in ihrem Feld 'regions'. +# +# Aktuell gibt es nur 'de'. Die Variable existiert, damit die Leitung steht, +# wenn weitere Kataloge dazukommen. +REGION = umgebung("REGION", "de") + +# Ob ein Livebild überhaupt gezeigt werden darf. Ein- und ausgeschaltet wird +# pro Kamera im Admin; diese Variable ist die Sperre darüber. +# +# Auf einer öffentlichen Instanz (Showcase) gehört sie auf 0. Dann ist Live +# nicht einschaltbar — auch nicht mit einem Fehlklick im Admin. Ein Livebild +# aus einem Privatgarten soll nicht an einem Häkchen hängen, und wer nur die +# Sammlung herzeigen will, braucht es nicht. +LIVE_ALLOWED = umgebung("LIVE_ERLAUBT", "1") not in ("0", "false", "") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "dex", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + # Muss nach Session und vor Common stehen: sie liest die Sprache aus + # Cookie bzw. Accept-Language und setzt sie für den Request. + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "vogelalbum.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.i18n", + ], + }, + }, +] + +WSGI_APPLICATION = "vogelalbum.wsgi.application" + +# SQLITE_DIR erlaubt es, die Datenbank auf ein Volume zu legen — dieselbe +# Konvention wie bei remote-admins, damit ein Coolify-Deploy nicht überrascht. +SQLITE_DIR = Path(os.environ.get("SQLITE_DIR", BASE_DIR)) +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": SQLITE_DIR / "db.sqlite3", + "OPTIONS": {"init_command": "PRAGMA journal_mode=WAL;"}, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# --- Absicherung im Betrieb ----------------------------------------------- +# +# 🔑 **Alles hier hängt an DEBUG=0 und gilt NICHT in der Entwicklung.** Ein +# `Secure`-Cookie wird über einfaches HTTP gar nicht erst gesetzt — stünde das +# hart auf True, könnte sich niemand mehr auf `http://localhost:8000` am Admin +# anmelden, und die Ursache wäre genau die Einstellung, die Sicherheit bringen +# sollte. +if not DEBUG: + # ⚠️ **Der eigentliche Punkt: Die Umleitung von http auf https kommt zu + # spät.** Traefik antwortet zwar mit 307 — aber da hat der Browser das + # Cookie schon mitgeschickt. Erst `Secure` sorgt dafür, dass er es über + # eine unverschlüsselte Verbindung gar nicht erst anfasst. Betrifft die + # Admin-Sitzung, also den Zugang zu allem. + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + + # Dasselbe eine Ebene höher: Nach dem ersten Besuch geht der Browser von + # sich aus nur noch über https, die unverschlüsselte Anfrage entfällt. + # + # ⚠️ Ein Jahr ist lang und lässt sich nicht zurücknehmen — wer die Domain + # später ohne TLS betreiben will, ist für die Dauer ausgesperrt. Für eine + # Seite, die ohnehin nur über https erreichbar ist, ist das richtig; + # deshalb steht die Zahl in einer Variablen und nicht im Code. + SECURE_HSTS_SECONDS = int(os.environ.get("DJANGO_HSTS_SEKUNDEN", 31536000)) + # Bewusst OHNE includeSubDomains und ohne preload: Beides erklärt man auch + # für Namen mit, die es noch gar nicht gibt, und preload nimmt einem + # fremde Listen aus der Hand. Wer das will, soll es ausdrücklich tun. + SECURE_HSTS_INCLUDE_SUBDOMAINS = False + SECURE_HSTS_PRELOAD = False + + # Eine Sitzung läuft mit dem Browser aus. Das Backend ist ein Werkzeug für + # Minuten, kein Ort, an dem man wohnt. + SESSION_EXPIRE_AT_BROWSER_CLOSE = True + + # Kein Weiterreichen der Adresse an fremde Seiten. Die Sichtungsseiten + # tragen Artnamen im Pfad; welche Art jemand angesehen hat, geht einen + # verlinkten Bildnachweis auf Wikimedia nichts an. + SECURE_REFERRER_POLICY = "same-origin" + +LANGUAGE_CODE = "de" +LANGUAGES = [("de", "Deutsch"), ("en", "English")] +LOCALE_PATHS = [BASE_DIR / "locale"] +# Ein Jahr, damit die Sprachwahl nicht nach der Sitzung vergessen wird. +LANGUAGE_COOKIE_AGE = 60 * 60 * 24 * 365 +LANGUAGE_COOKIE_SAMESITE = "Lax" + +TIME_ZONE = "Europe/Berlin" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + +MEDIA_URL = "media/" +MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", BASE_DIR / "media")) + +# Das Manifest entsteht erst bei 'collectstatic'. In Entwicklung und Tests gibt +# es keins, deshalb dort die einfache Variante — sonst scheitert jeder Test, +# der eine Seite rendert. +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": ( + "django.contrib.staticfiles.storage.StaticFilesStorage" + if DEBUG + else "whitenoise.storage.CompressedManifestStaticFilesStorage" + ) + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Ein Vogelhaus-Foto in Originalauflösung soll durchpassen, ein Upload von +# 50 MB nicht. +DATA_UPLOAD_MAX_MEMORY_SIZE = 16 * 1024 * 1024 + +# Token zum LESEN nach außen — für Home Assistant und später eine +# HACS-Integration. +# +# 🔑 Bewusst getrennt vom INGEST_TOKEN: Wer lesen darf, soll deshalb nicht +# Sichtungen einliefern können. Und wer eine Kamera betreibt, soll nicht +# automatisch die ganze Sammlung abfragen dürfen. +# +# Leer heißt: der Endpunkt ist zu. Das ist die richtige Vorgabe für eine +# Anlage, die im Netz steht. +LESE_TOKEN = umgebung("LESE_TOKEN", "dev-lesen" if DEBUG else "") diff --git a/web/vogelalbum/urls.py b/web/vogelalbum/urls.py new file mode 100644 index 0000000..4f4f91c --- /dev/null +++ b/web/vogelalbum/urls.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + +from dex import api, app_api, views + +urlpatterns = [ + # Djangos eingebauter Umschalter: POST mit 'language' und 'next', setzt + # das Sprach-Cookie und leitet zurück. Kein eigener Code nötig. + path("i18n/", include("django.conf.urls.i18n")), + path("", views.home, name="home"), + # 🔑 Die sichtbare Adresse heisst "sammlung", der interne App-Name "dex". + # + # Dass die beiden auseinanderfallen, ist Absicht: Den App-Namen zu + # aendern hiesse, saemtliche Migrationen umzuschreiben — viel Risiko fuer + # ein Wort, das kein Besucher je sieht. Sichtbares und Strukturelles + # duerfen verschiedene Namen tragen. + path("sammlung/", views.dex, name="dex"), + path("sammlung//", views.species_detail, name="species"), + path("sichtungen/", views.sightings, name="sightings"), + path("abzeichen/", views.badges, name="badges"), + # Fotos von Hand einliefern — hinter dem Admin-Login, siehe views.hochladen. + path("hochladen/", views.hochladen, name="hochladen"), + path("live/", views.live, name="live"), + path("widget/", views.widget, name="widget"), + path("api/stats/", views.api_stats, name="api_stats"), + path("api/sightings/", api.ingest, name="api_ingest"), + path("api/config/", api.config, name="api_config"), + # Fotos von unterwegs. Hochladen mit Kamera-Token, abholen mit Lese-Token, + # Ergebnis zurück mit Kamera-Token — siehe api.py. + path("api/hochladen/", api.hochladen, name="api_hochladen"), + path("api/offen/", api.offen, name="api_offen"), + path("api/offen//", api.bestimmt_melden, + name="api_bestimmt_melden"), + # Lesen nach außen — Home Assistant, später eine HACS-Integration. + path("api/zustand/", api.zustand, name="api_zustand"), + # 🔑 Die Handy-App liest unter /api/app/. Eigener Zweig, weil sie etwas + # anderes braucht als Home Assistant: HA will einen Zustand in einer + # Antwort, die App will blättern. Siehe dex/app_api.py. + path("api/app/start/", app_api.start, name="app_start"), + path("api/app/album/", app_api.album, name="app_album"), + path("api/app/album//", app_api.art, name="app_art"), + path("api/app/sichtungen/", app_api.sichtungen, name="app_sichtungen"), + path("api/app/sichtungen//", app_api.sichtung, + name="app_sichtung"), + path("api/app/abzeichen/", app_api.abzeichen, name="app_abzeichen"), + path("admin/", admin.site.urls), +] + +# Im Betrieb liefert nginx/Coolify die Medien aus; im Dev-Server macht es Django. +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/web/vogelalbum/wsgi.py b/web/vogelalbum/wsgi.py new file mode 100644 index 0000000..e05c73b --- /dev/null +++ b/web/vogelalbum/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for vogelalbum project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vogelalbum.settings') + +application = get_wsgi_application()