Vogelalbum 1.0.0

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

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

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

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

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

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

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
"""Misst, wie gut die Erkennung wirklich ist.
Holt für jede Art mehrere echte Fotos von Wikimedia Commons, jagt sie durch
dieselbe Pipeline, die auch am Vogelhaus läuft, und vergleicht die Bestimmung
mit der Kategorie, aus der das Bild stammt.
./venv/bin/python scripts/benchmark.py --hole # Bilder laden (einmalig)
./venv/bin/python scripts/benchmark.py # messen
Wichtige Einschränkung: Commons-Fotos sind in der Regel gut belichtete
Nahaufnahmen von Fotografen, die den Vogel im Sucher hatten. Eine feste Kamera
im Vogelhaus liefert schlechteres Material — Gegenlicht, Bewegungsunschärfe,
halb verdeckte Vögel. Die Werte hier sind eine Obergrenze, keine Prognose für
den Alltag. Die echte Zahl liefert erst die eigene Kamera.
Die Kategorien enthalten außerdem vereinzelt falsch einsortierte Bilder. Ein
paar Prozent Fehler gehen also auf Commons, nicht auf das Modell.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter, defaultdict
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from detector.classify import Pipeline # noqa: E402
from detector.species_de import SPECIES # noqa: E402
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
DATA = Path(__file__).resolve().parents[1] / "benchmark_data"
class RateLimited(Exception):
pass
def _open(url: str, timeout: int = 45) -> bytes:
"""Ein Abruf mit Rücksicht auf Wikimedias Ratenbegrenzung.
Bei HTTP 429 wird gewartet statt weitergefeuert — erst ``Retry-After``,
sonst exponentiell. Wer trotz 429 weitermacht, wird zu Recht gesperrt.
"""
delay = 5.0
for attempt in range(5):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
except urllib.error.HTTPError as exc:
if exc.code != 429:
raise
wait = float(exc.headers.get("Retry-After") or delay)
print(f" 429 — warte {wait:.0f}s", flush=True)
time.sleep(min(wait, 120))
delay *= 2
raise RateLimited(f"nach 5 Versuchen weiterhin 429: {url[:80]}")
def api(params: dict) -> dict:
return json.loads(_open(f"{COMMONS_API}?{urllib.parse.urlencode(params)}", 30))
def image_urls(scientific: str, limit: int) -> list[str]:
"""Bilder aus der Commons-Kategorie zur Art."""
try:
data = api(
{
"action": "query",
"generator": "categorymembers",
"gcmtitle": f"Category:{scientific}",
"gcmtype": "file",
# Großzügig anfragen: ein Teil sind Zeichnungen, Eier, Federn
# oder Videos, die als Testbild nichts taugen.
"gcmlimit": str(limit * 4),
"prop": "imageinfo",
"iiprop": "url|mime",
"iiurlwidth": "900",
"format": "json",
"formatversion": "2",
}
)
except OSError:
return []
urls = []
for page in data.get("query", {}).get("pages", []):
info = (page.get("imageinfo") or [{}])[0]
if info.get("mime") not in ("image/jpeg", "image/png"):
continue
title = page.get("title", "").lower()
# Eier, Federn, Skelette und Verbreitungskarten sind keine Vogelfotos.
if any(w in title for w in ("egg", "nest", "skull", "map", "range", "feather",
"distribution", "skeleton", "illustration",
"drawing", "plate", "stamp", "coin")):
continue
url = info.get("thumburl") or info.get("url")
if url:
urls.append(url)
if len(urls) >= limit:
break
return urls
def fetch(args) -> None:
DATA.mkdir(exist_ok=True)
for i, species in enumerate(SPECIES, 1):
folder = DATA / species.slug
folder.mkdir(exist_ok=True)
have = len(list(folder.glob("*.jpg")))
if have >= args.pro_art:
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: {have} vorhanden")
continue
urls = image_urls(species.scientific, args.pro_art)
if not urls:
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: keine Bilder in der "
f"Kategorie '{species.scientific}'", flush=True)
continue
got, failed = 0, 0
for n, url in enumerate(urls):
target = folder / f"{n:02d}.jpg"
if target.exists():
got += 1
continue
try:
target.write_bytes(_open(url))
# Kaputte Downloads sofort aussortieren.
Image.open(target).verify()
got += 1
except Exception as exc:
target.unlink(missing_ok=True)
failed += 1
if failed == 1: # nicht pro Bild jammern, einmal genügt
print(f" Fehler: {type(exc).__name__}: {exc}", flush=True)
time.sleep(args.delay)
note = f", {failed} fehlgeschlagen" if failed else ""
print(f"[{i:>2}/{len(SPECIES)}] {species.german}: {got} Bilder{note}", flush=True)
time.sleep(args.delay)
def measure(args) -> int:
habitats = set(args.habitats.split(",")) if args.habitats else None
pipeline = Pipeline(min_bird_score=args.min_bird_score, habitats=habitats)
by_slug = {s.slug: s for s in SPECIES}
total = correct = top3 = no_bird = undetermined = 0
per_species: dict[str, list[bool]] = defaultdict(list)
confusion: Counter = Counter()
# Zwei Quellen: die Referenzfotos (ein Bild je Art, Dateiname = Slug) oder
# die Commons-Kategorien (ein Ordner je Art, mehrere Bilder).
if args.referenz:
ref = Path(args.referenz)
groups = [
(by_slug[p.stem], [p])
for p in sorted(ref.glob("*.jpg"))
if p.stem in by_slug
]
else:
folders = sorted(p for p in DATA.iterdir() if p.is_dir()) if DATA.exists() else []
groups = [
(by_slug[f.name], sorted(f.glob("*.jpg")))
for f in folders
if f.name in by_slug
]
if not groups:
print("Keine Bilder. Erst mit --hole laden (oder --referenz angeben).")
return 1
for truth, paths in groups:
for path in paths:
try:
img = Image.open(path).convert("RGB")
except OSError:
continue
total += 1
box, result = pipeline.run(img)
if box is None:
no_bird += 1
per_species[truth.slug].append(False)
continue
if result is None or not result.guesses:
undetermined += 1
per_species[truth.slug].append(False)
continue
names = [g.species.slug for g in result.guesses[:3]]
hit = names[0] == truth.slug
correct += hit
top3 += truth.slug in names
per_species[truth.slug].append(hit)
if not hit:
confusion[(truth.german, result.guesses[0].species.german)] += 1
if not total:
print("Keine auswertbaren Bilder gefunden.")
return 1
print(f"\n{'='*62}\nERGEBNIS ({total} Bilder, {len(groups)} Arten)\n{'='*62}")
print(f" Richtig bestimmt (Top 1) : {correct:>4} / {total} {correct/total:>6.1%}")
print(f" Unter den Top 3 : {top3:>4} / {total} {top3/total:>6.1%}")
print(f" Kein Vogel gefunden : {no_bird:>4} / {total} {no_bird/total:>6.1%}")
print(f" Als unbestimmt verworfen : {undetermined:>4} / {total} {undetermined/total:>6.1%}")
rows = sorted(
((slug, sum(v), len(v)) for slug, v in per_species.items()),
key=lambda r: (r[1] / r[2] if r[2] else 0, r[2]),
)
print(f"\n{'-'*62}\nSchwächste Arten\n{'-'*62}")
for slug, hits, n in rows[:12]:
bar = "█" * round(12 * hits / n) if n else ""
print(f" {by_slug[slug].german:<22} {hits:>2}/{n:<3} {hits/n:>6.0%} {bar}")
print(f"\n{'-'*62}\nStärkste Arten\n{'-'*62}")
for slug, hits, n in rows[-8:][::-1]:
bar = "█" * round(12 * hits / n) if n else ""
print(f" {by_slug[slug].german:<22} {hits:>2}/{n:<3} {hits/n:>6.0%} {bar}")
if confusion:
print(f"\n{'-'*62}\nHäufigste Verwechslungen\n{'-'*62}")
for (truth, guess), n in confusion.most_common(12):
print(f" {truth:<22} → {guess:<22} {n:>3}×")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="Genauigkeit der Arterkennung messen")
p.add_argument("--hole", action="store_true", help="Testbilder von Commons laden")
p.add_argument("--pro-art", type=int, default=8)
p.add_argument("--delay", type=float, default=1.5,
help="Pause zwischen Abrufen. Wikimedia drosselt sonst.")
p.add_argument("--min-bird-score", type=float, default=0.05)
p.add_argument("--referenz", help="Ordner mit <slug>.jpg statt der Commons-Kategorien")
p.add_argument("--habitats", help="Lebensräume kommagetrennt, z.B. 'garten'")
args = p.parse_args()
if args.hole:
fetch(args)
return 0
return measure(args)
if __name__ == "__main__":
sys.exit(main())
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Holt die beiden Modelle. Sie liegen nicht im Repo — zusammen rund 10 MB, und
# sie ändern sich nie.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/models"
BASE="https://github.com/google-coral/test_data/raw/master"
mkdir -p "$DIR"
for f in \
mobilenet_v2_1.0_224_inat_bird_quant.tflite \
inat_bird_labels.txt \
ssd_mobilenet_v2_coco_quant_postprocess.tflite \
coco_labels.txt
do
if [[ -s "$DIR/$f" ]]; then
echo "vorhanden: $f"
continue
fi
echo "lade: $f"
curl -fsSL -o "$DIR/$f" "$BASE/$f"
done
echo "Fertig. Modelle liegen in $DIR"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Holt drei Beispielfotos als Vorlage fuer 'detector karte' und 'detector test'.
#
# Sie liegen nicht im Repo, und zwar nicht aus Platzgruenden: Fotos von
# Wikimedia Commons stehen unter eigenen Lizenzen (meist CC BY-SA) und
# verlangen Namensnennung. Ein Bild ohne seine Herkunft ins Repo zu legen
# waere genau der Fehler, den die Lizenz verhindern soll — deshalb kommen sie
# samt Urheber und Lizenz hierher, nachvollziehbar in HERKUNFT.txt.
#
# bash scripts/fetch_testdata.sh
#
# ⚠️ Wikimedia drosselt hart (HTTP 429). Deshalb die Pause zwischen den
# Abrufen; wer sie herausnimmt, steht fuer Minuten in der Strafbank.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/testdata"
API="https://de.wikipedia.org/w/api.php"
ABSTAND=2
mkdir -p "$DIR"
# Artikelname → Dateiname. Die Arten sind so gewaehlt, dass der Detektor sie
# sicher findet: gross im Bild, freigestellt, typische Haltung.
ARTEN="Amsel:amsel Kohlmeise:kohlmeise Rotkehlchen:rotkehlchen"
: > "$DIR/HERKUNFT.txt"
cat >> "$DIR/HERKUNFT.txt" <<'KOPF'
Beispielfotos fuer Vogelalbum, geholt mit scripts/fetch_testdata.sh.
Diese Dateien gehoeren NICHT zum AGPL-lizenzierten Teil des Projekts. Jedes
Bild behaelt die Lizenz, unter der es bei Wikimedia Commons steht. Wer eines
davon weitergibt, nennt Urheber und Lizenz mit.
KOPF
for eintrag in $ARTEN; do
artikel="${eintrag%%:*}"
name="${eintrag##*:}"
ziel="$DIR/$name.jpg"
if [[ -s "$ziel" ]]; then
echo "vorhanden: $name.jpg"
continue
fi
echo "lade: $name.jpg ($artikel)"
# Erst das Lead-Bild des Artikels ermitteln, dann dessen Metadaten holen.
# Beides in einem Aufruf — pageimages gibt den Dateinamen, imageinfo die
# Lizenz und den Urheber.
antwort=$(curl -sS --get "$API" \
--data-urlencode "action=query" \
--data-urlencode "format=json" \
--data-urlencode "formatversion=2" \
--data-urlencode "prop=pageimages" \
--data-urlencode "piprop=thumbnail|name" \
--data-urlencode "pithumbsize=1200" \
--data-urlencode "titles=$artikel" \
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)")
# Zwei Zeilen: Bildadresse und Dateiname.
#
# 🔑 Den Dateinamen NICHT aus der Adresse schneiden. Dort steht er
# prozentkodiert ("Robin_%28950...%29.jpg"), und die API weist einen
# Seitentitel mit "%28" als ungueltig ab — beim Rotkehlchen genau so
# passiert, waehrend Amsel und Kohlmeise mangels Sonderzeichen
# funktionierten. Das Feld "pageimage" liefert ihn unkodiert.
gelesen=$(printf '%s' "$antwort" | python3 -c '
import json, sys
d = json.load(sys.stdin)
seiten = d.get("query", {}).get("pages", [])
s = seiten[0] if seiten else {}
print(s.get("thumbnail", {}).get("source", ""))
print(s.get("pageimage", ""))
')
url=$(printf '%s' "$gelesen" | sed -n 1p)
datei=$(printf '%s' "$gelesen" | sed -n 2p)
if [[ -z "$url" || -z "$datei" ]]; then
echo " FEHLER: kein Bild zu '$artikel' gefunden — uebersprungen" >&2
continue
fi
curl -sSL --fail -o "$ziel" "$url" \
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)"
sleep "$ABSTAND"
meta=$(curl -sS --get "$API" \
--data-urlencode "action=query" \
--data-urlencode "format=json" \
--data-urlencode "formatversion=2" \
--data-urlencode "prop=imageinfo" \
--data-urlencode "iiprop=extmetadata|url" \
--data-urlencode "titles=Datei:$datei" \
-H "User-Agent: vogelalbum/1.0 (https://vogelalbum.gaertner.onl)")
printf '%s' "$meta" | python3 -c '
import json, re, sys
name = sys.argv[1]
d = json.load(sys.stdin)
seiten = d.get("query", {}).get("pages", [])
info = (seiten[0].get("imageinfo") or [{}])[0] if seiten else {}
m = info.get("extmetadata", {})
def feld(schluessel):
wert = m.get(schluessel, {}).get("value", "")
wert = re.sub(r"<[^>]+>", " ", wert)
wert = re.sub(r"\s+", " ", wert).strip() # HTML aus der Angabe nehmen
return wert or "unbekannt"
urheber = feld("Artist")
lizenz = feld("LicenseShortName")
print(name + ".jpg")
print(" Urheber: " + urheber)
print(" Lizenz: " + lizenz)
print(" Quelle: " + str(info.get("descriptionurl", "unbekannt")))
print()
if "unbekannt" in (urheber, lizenz):
# Namensnennung ist Lizenzbedingung. Ein stilles "unbekannt" waere der
# Fehler, den diese Datei verhindern soll.
sys.stderr.write(" WARNUNG: Urheber oder Lizenz fuer " + name
+ " nicht ermittelt — bitte von Hand nachtragen\\n")
sys.exit(3)
' "$name" >> "$DIR/HERKUNFT.txt"
sleep "$ABSTAND"
done
echo
echo "Beispielfotos liegen in $DIR"
echo "Urheber und Lizenz: $DIR/HERKUNFT.txt"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Richtet einen frischen Debian/Ubuntu-LXC als Erkennungs-Host ein.
#
# curl -fsSL <raw-url>/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 <slug> --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 <url>"
+374
View File
@@ -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 <code>/api/config/</code> 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 <code>/api/config/</code> "
"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 <code>python -m detector watch … --live-port 8131</code>.":
"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 <code>python -m detector watch … --live-port 8131</code>.",
"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())