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:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -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())
|
||||
Executable
+24
@@ -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"
|
||||
Executable
+131
@@ -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"
|
||||
Executable
+104
@@ -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>"
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user