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

+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())