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-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+721
View File
@@ -0,0 +1,721 @@
"""Kommandozeile des Detectors.
python -m detector watch --source http://vogelhaus.local:8080/?action=stream
python -m detector test testdata/
python -m detector tune --source ... # Schwellwerte einstellen
Konfiguration wahlweise per Flag oder Umgebungsvariable (VOGELALBUM_*), damit
der systemd-Dienst ohne lange Kommandozeile auskommt.
"""
from __future__ import annotations
import argparse
import os
import signal
import sys
import time
from pathlib import Path
from PIL import Image
from .classify import Pipeline
from .daylight import DaylightGate, brightness
from .motion import MotionDetector
from .sink import ConsoleSink, FileSink, HTTPSink, MultiSink
from . import umgebung
from .species_de import NICHT_IM_MODELL
from .stream import open_source
from .watcher import Watcher, WatcherConfig
def env(name: str, default: str | None = None) -> str | None:
# Liest VOGELALBUM_<name> und faellt auf das alte VOGELALBUM_<name> zurueck,
# damit laufende Anlagen die Umbenennung nicht merken. Siehe umgebung.py.
return umgebung.hole(name, default)
def build_watcher(args, beobachter=None) -> Watcher:
# Nur wenn wirklich protokolliert wird, lohnt sich die niedrigere Schwelle
# im Detektor — sonst suchte er umsonst nach Boxen, die niemand ansieht.
report_threshold = getattr(args, "protokoll_ab", None) if getattr(args, "protokoll", None) else None
return Watcher(
beobachter=beobachter,
pipeline=Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
report_threshold=report_threshold,
),
motion=MotionDetector(
threshold=args.motion_threshold, min_ratio=args.motion_ratio
),
config=WatcherConfig(
classify_every=args.classify_every,
cooldown_s=args.cooldown,
min_confidence=args.min_confidence,
min_bird_frames=args.min_bird_frames,
gate=args.gate,
),
)
def build_sink(args):
sinks = [ConsoleSink()]
if args.save_to:
sinks.append(FileSink(args.save_to))
if args.api:
sinks.append(HTTPSink(args.api, args.token or ""))
return MultiSink(*sinks)
def cmd_watch(args) -> int:
from .fernkonfig import Fernkonfig, arten_aus_json
from .protokoll import Protokoll
from .stichprobe import Stichprobe
from .verteiler import Verteiler
# Die Web-App darf die Einstellungen vorgeben, sobald sie erreichbar ist.
# Ohne Token geht das nicht — dann bleibt die Kommandozeile die Wahrheit.
fern = (
Fernkonfig(args.api, args.token, intervall=args.konfig_alle)
if args.api and args.token and args.konfig_alle > 0
else None
)
protokoll = Protokoll(args.protokoll, args.protokoll_ab) if args.protokoll else None
verteiler = None
if args.live_port:
try:
verteiler = Verteiler(
args.live_port, bind=args.live_bind, max_zuschauer=args.live_max
)
except OSError as exc:
# Meist laeuft schon ein Detector auf diesem Port. Das Live-Bild
# ist eine Zugabe — daran soll die Erkennung nicht scheitern.
print(f"[live] Port {args.live_port} nicht verfuegbar ({exc}) — "
f"laeuft schon ein Detector? Weiter ohne Live-Bild.",
file=sys.stderr)
stichprobe = (
Stichprobe(args.stichprobe, args.stichprobe_alle, args.stichprobe_max)
if args.stichprobe
else None
)
def mitschreiben(box, result):
protokoll.erkennung(box, result, gate.last_brightness)
sink = build_sink(args)
watcher = build_watcher(args, beobachter=mitschreiben if protokoll else None)
def uebernimm(daten: dict, beim_start: bool = False) -> None:
"""Was die Web-App sagt, im laufenden Betrieb umsetzen."""
arten = arten_aus_json(daten.get("arten") or [])
geaendert = watcher.pipeline.uebernimm(
detect_threshold=daten.get("detect_threshold"),
min_bird_score=daten.get("min_bird_score"),
# Eine leere Artenliste wird ignoriert: sie wuerde jede Erkennung
# unmoeglich machen, und das ist nie die Absicht.
arten=arten or None,
)
# Der Mittelwert-Schwellwert sitzt am Watcher, nicht an der Pipeline:
# er gilt fuer den ganzen Besuch, und der entsteht erst hier.
mittel = daten.get("min_mean_bird_score")
if mittel is not None and mittel != watcher.cfg.min_mean_bird_score:
geaendert.append(
f"Mindest-Vogelwert je Besuch "
f"{watcher.cfg.min_mean_bird_score:.2f} -> {mittel:.2f}"
)
watcher.cfg.min_mean_bird_score = mittel
# Bildrate der Vorschau: gilt sofort, auch im laufenden Betrieb.
# Wer sie im Backend umstellt, soll nicht neu starten muessen.
rate = daten.get("live_bildrate")
if rate is not None and verteiler is not None:
verteiler.bildrate_setzen(rate)
if beim_start:
print(f"[konfig] Vorschau-Bildrate: {rate}/s"
if rate else "[konfig] Vorschau ohne Begrenzung")
if beim_start:
kamera = daten.get("kamera") or "unbenannt"
print(f"[konfig] Aus der Web-App fuer '{kamera}': "
f"{len(arten)} Arten, Region {daten.get('region', '?')}, "
f"Schwellen {daten.get('detect_threshold')}/"
f"{daten.get('min_bird_score')}")
for zeile in geaendert:
print(f"[konfig] {zeile}")
if fern is not None:
erste = fern.hole()
if erste is not None:
# Die Stream-Adresse aus der Web-App gilt nur, wenn lokal keine
# steht: wer sie auf die Kommandozeile schreibt, meint sie auch.
if not args.source and erste.get("stream_url"):
args.source = erste["stream_url"]
print(f"[konfig] Quelle aus der Web-App: {args.source}")
uebernimm(erste, beim_start=True)
if not args.source:
print("[fehler] Keine Quelle — weder --source noch eine Stream-Adresse "
"im Admin der Web-App.", file=sys.stderr)
return 1
gate = DaylightGate(
dark_below=args.dunkel_unter,
light_above=args.hell_ueber,
patience=args.dunkel_geduld,
enabled=not args.nachtsicht,
)
print(f"[start] Quelle: {args.source}")
if args.nachtsicht:
print("[start] Nachtsicht: laeuft auch im Dunkeln durch")
else:
print(f"[start] Pausiert unter Helligkeit {args.dunkel_unter:.0f}/255, "
f"laeuft ab {args.hell_ueber:.0f} wieder an")
if args.api:
print(f"[start] Meldet an: {args.api}")
if args.save_to:
print(f"[start] Speichert nach: {args.save_to}")
if protokoll is not None:
print(f"[start] Protokoll: {protokoll.pfad} (ab Score {args.protokoll_ab})")
if stichprobe is not None:
print(f"[start] Stichproben: {stichprobe.ordner} "
f"(alle {args.stichprobe_alle:.0f}s, hoechstens {args.stichprobe_max})")
if verteiler is not None:
verteiler.start()
print(f"[start] Live-Bild: http://<dieser-rechner>:{verteiler.port}/ "
f"— fuers Heimnetz, nicht fuers Internet")
stop = False
def handle(signum, frame):
nonlocal stop
stop = True
print("\n[stop] Beende nach dem laufenden Besuch ...")
signal.signal(signal.SIGINT, handle)
signal.signal(signal.SIGTERM, handle)
frames = 0
dunkle_frames = 0
dunkel_seit = time.monotonic()
last_report = time.monotonic()
while not stop:
source = open_source(args.source, fps=args.fps, interval=args.interval)
try:
for frame in source:
frames += 1
war_dunkel = gate.is_dark
hell_genug = gate.update(frame)
if gate.is_dark != war_dunkel:
if gate.is_dark:
# Laufenden Besuch sauber abschliessen, bevor die
# Analyse aussetzt — sonst haengt er bis zum Morgen.
offen = watcher.flush()
if offen is not None:
sink.emit(offen)
print(f"[dunkel] Analyse pausiert "
f"(Helligkeit {gate.last_brightness:.0f}/255)")
dunkel_seit = time.monotonic()
else:
dauer = (time.monotonic() - dunkel_seit) / 3600
print(f"[hell] Analyse laeuft wieder "
f"(Helligkeit {gate.last_brightness:.0f}/255, "
f"{dauer:.1f} h pausiert)")
# Auch im Dunkeln weitergeben: dann zeigt das Livebild wenigstens,
# dass die Kamera noch laeuft. Kostet nur Zeigerarbeit, kodiert
# wird erst, wenn jemand zuschaut.
if verteiler is not None:
verteiler.zeige(frame, source.letztes_jpeg)
if hell_genug:
sighting = watcher.process(frame)
if sighting is not None:
sink.emit(sighting)
# Nur tagsueber: ein schwarzes Bild zeigt niemandem etwas.
if stichprobe is not None:
stichprobe.bild(frame, time.monotonic())
else:
dunkle_frames += 1
if protokoll is not None:
protokoll.bild(dunkel=not hell_genug)
protokoll.puls(time.monotonic(), gate.last_brightness)
now = time.monotonic()
# Nachladen im Betrieb: wer im Admin eine Schwelle verstellt
# oder eine Art dazunimmt, soll den Detector nicht neu starten
# muessen. Ein Ausfall der Web-App aendert hier nichts.
if fern is not None and fern.faellig(now):
neu = fern.hole(now)
if neu is not None:
uebernimm(neu)
if args.verbose and now - last_report > 60:
print(f"[lebt] {frames} Bilder, davon {dunkle_frames} im "
f"Dunkeln uebersprungen — {gate.status}")
last_report = now
if stop:
break
except (OSError, RuntimeError) as exc:
if stop:
break
# Der Pi startet neu, das WLAN hustet — einfach weiterversuchen.
print(f"[stream] Verbindung verloren ({exc}), neuer Versuch in 10s")
time.sleep(10)
continue
if not source.restartable:
break
if not stop:
print("[stream] Quelle beendet, öffne neu in 5s")
time.sleep(5)
final = watcher.flush()
if final is not None:
sink.emit(final)
if protokoll is not None:
protokoll.puls(time.monotonic(), gate.last_brightness, erzwingen=True)
protokoll.close()
if verteiler is not None:
verteiler.stop()
print(f"[ende] {frames} Bilder verarbeitet")
return 0
def cmd_test(args) -> int:
"""Einzelbilder durchklassifizieren, ohne Bewegungslogik."""
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
)
paths: list[Path] = []
for raw in args.paths:
p = Path(raw)
if p.is_dir():
paths += sorted(
q for q in p.iterdir() if q.suffix.lower() in {".jpg", ".jpeg", ".png"}
)
else:
paths.append(p)
if not paths:
print("Keine Bilder gefunden.")
return 1
for p in paths:
img = Image.open(p).convert("RGB")
box, result = pipeline.run(img)
print(f"\n{p.name} ({img.width}×{img.height})")
if box is None:
print(" kein Vogel erkannt")
continue
print(f" Vogel erkannt: {box.score:.0%} sicher, füllt {box.area:.0%} des Bildes")
if result is None:
print(" Art nicht bestimmbar (keine heimische Art wahrscheinlich)")
continue
for g in result.guesses[:3]:
bar = "█" * round(g.confidence * 24)
print(f" {g.species.german:<22} {g.confidence:>6.1%} {bar}")
print(f" [heimische Masse {result.bird_score:.0%}, {result.ms:.0f} ms]")
return 0
def cmd_warteschlange(args) -> int:
"""Hochgeladene Fotos abholen und bestimmen.
Der zweite Weg, auf dem Bilder hereinkommen — der erste ist die Kamera.
Laeuft entweder einmal (``--einmal``) oder als Dauerbetrieb.
"""
from .warteschlange import Warteschlange
if not args.api:
print("Ohne --api geht es nicht: Die Fotos liegen in der Web-App.")
return 1
if not args.lese_token:
print("Ohne --lese-token kann ich die offenen Fotos nicht abrufen.")
return 1
if not args.token:
print("Ohne --token kann ich kein Ergebnis zurueckmelden.")
return 1
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
)
schlange = Warteschlange(
api=args.api,
lese_token=args.lese_token,
schreib_token=args.token,
pipeline=pipeline,
intervall=args.alle,
anzahl=args.anzahl,
)
if args.einmal:
anzahl = schlange.durchlauf()
print(f"[warteschlange] {anzahl} Foto(s) bearbeitet.")
return 0
print(f"[start] Warteschlange an {args.api}, alle {args.alle:.0f}s")
try:
while True:
schlange.durchlauf()
time.sleep(args.alle)
except KeyboardInterrupt:
print("\n[ende] Warteschlange beendet.")
return 0
def cmd_tune(args) -> int:
"""Zeigt live die Bewegungswerte — damit lassen sich die Schwellwerte an
der echten Kamera einstellen, statt sie zu raten."""
motion = MotionDetector(threshold=args.motion_threshold, min_ratio=args.motion_ratio)
print("Bewegungsanteil pro Bild. Strg+C beendet.")
print(f"Schwelle steht auf {args.motion_ratio:.1%}.\n")
n = 0
peak = 0.0
try:
for frame in open_source(args.source, fps=args.fps, interval=args.interval):
ratio = motion.update(frame)
peak = max(peak, ratio)
n += 1
flag = "BEWEGUNG" if motion.is_motion(ratio) else " "
hell = brightness(frame)
bar = "▇" * min(30, round(ratio * 400))
print(f"{n:>6} {ratio:>7.2%} {flag} Helligkeit {hell:>5.1f} {bar}")
except KeyboardInterrupt:
print(f"\nHöchstwert war {peak:.2%}. "
f"Setze --motion-ratio etwas über dem Ruhepegel.")
return 0
def cmd_karte(args) -> int:
"""Erkennungskarte: taugt die Ausrichtung der Kamera?"""
from .karte import rat, tabelle, vermessen, zeichne
# Der Standardwert zeigt auf testdata/, und das steht nicht im Repo — die
# Fotos stammen von Wikimedia Commons und haengen an ihren Lizenzen. Ein
# blanker FileNotFoundError waere hier die schlechteste Antwort: Man will
# wissen, was zu tun ist, nicht wo es gekracht hat.
if not Path(args.vogel).exists():
print(f"Kein Vogelfoto unter {args.vogel}.\n")
print("Entweder die Beispielfotos holen:")
print(" bash scripts/fetch_testdata.sh\n")
print("Oder ein eigenes nehmen — je aehnlicher dem, was deine Kamera")
print("sieht, desto aussagekraeftiger die Karte:")
print(" python -m detector karte --vogel mein_vogel.jpg ...")
return 1
vogel = Image.open(args.vogel).convert("RGB")
quelle = Path(args.source)
if quelle.is_dir():
pfade = sorted(
p for p in quelle.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
)[: args.bilder]
if not pfade:
print(f"Keine Bilder in {quelle}")
return 1
hintergruende = [Image.open(p).convert("RGB") for p in pfade]
else:
# Der Pi Zero am Vogelhaus vertraegt genau einen Zuschauer: ein
# zweiter saettigt die Funkstrecke und der laufende Detector faellt in
# Timeouts. Das ist hier schon einmal passiert — das Messwerkzeug hat
# den Fehler erzeugt, den es messen sollte.
print("Hinweis: Viele Kameras liefern nur einen Stream gleichzeitig.")
print("Laeuft der Detector, halte ihn fuer die Aufnahme an.\n")
print(f"Nehme {args.bilder} Hintergrundbilder auf ...")
hintergruende = []
for frame in open_source(args.source, fps=args.fps, interval=args.interval):
hintergruende.append(frame)
if len(hintergruende) >= args.bilder:
break
print(f"{len(hintergruende)} Hintergruende, Vogel mit "
f"{args.anteil * 100:.0f} % Bildflaeche\n")
pipeline = Pipeline(
detect_threshold=args.detect_threshold,
min_bird_score=args.min_bird_score,
habitats=set(args.habitats.split(",")) if args.habitats else None,
report_threshold=0.02, # damit auch knappe Faelle einen Score zeigen
)
zellen = vermessen(pipeline, hintergruende, vogel, args.anteil)
print(tabelle(zellen))
print(f"\n{rat(zellen)}")
ziel = Path(args.ziel)
zeichne(hintergruende[0], zellen).save(ziel, quality=92)
print(f"\nKarte: {ziel}")
return 0
def cmd_report(args) -> int:
"""Auswertung der gespeicherten Rohdaten."""
from .report import kontaktbogen, lade, zeige
ordner = Path(args.ordner)
if not ordner.is_dir():
print(f"Kein Ordner: {ordner}")
return 1
eintraege = lade(ordner)
zeige(eintraege, ordner)
if args.kontaktbogen:
ziel = Path(args.kontaktbogen)
n = kontaktbogen(eintraege, ziel)
print(f"\nKontaktbogen mit {n} Ausschnitten: {ziel}")
return 0
def cmd_bogen(args) -> int:
"""Stichproben zu einem Blatt montieren."""
from .stichprobe import kontaktbogen
ordner = Path(args.ordner)
if not ordner.is_dir():
print(f"Kein Ordner: {ordner}")
return 1
try:
ziel, n, seiten = kontaktbogen(
ordner, args.ziel,
spalten=args.spalten, zeilen=args.zeilen, seite=args.seite,
)
except FileNotFoundError as exc:
print(exc)
return 1
print(f"Seite {min(max(1, args.seite), seiten)} von {seiten}, "
f"{n} Bilder: {ziel}")
return 0
def cmd_species(args) -> int:
from .species_de import RARITY_LABEL, catalog
habitats = set(args.habitats.split(",")) if args.habitats else None
SPECIES = catalog(habitats)
by_rarity: dict[str, list] = {}
for s in SPECIES:
by_rarity.setdefault(s.rarity, []).append(s)
for rarity in ("haeufig", "gelegentlich", "selten", "episch", "legendaer"):
items = by_rarity.get(rarity, [])
print(f"\n{RARITY_LABEL[rarity]} ({len(items)}):")
for s in items:
print(f" {s.german:<22} {s.scientific:<30} {s.xp:>4} XP")
print(f"\nInsgesamt {len(SPECIES)} Arten.")
print("\nDem Modell unbekannt (werden auf verwandte Arten gemappt):")
for name in NICHT_IM_MODELL:
print(f" {name}")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="detector", description="Vogelalbum-Detector")
sub = p.add_subparsers(dest="cmd", required=True)
def common(sp):
sp.add_argument("--detect-threshold", type=float, default=0.4,
help="Ab welcher Sicherheit gilt etwas als Vogel (Standard 0.4)")
sp.add_argument("--min-bird-score", type=float, default=0.05,
help="Mindestanteil heimischer Arten, sonst 'unbestimmt'")
sp.add_argument("--habitats", default=env("HABITATS", "garten"),
help="Lebensraeume kommagetrennt: garten, wald, offenland, "
"wasser, gebirge. Leer laesst alle 148 Arten zu. "
"Standard 'garten' — eine Kamera am Futterhaus soll "
"gar nicht erst erwaegen, ob da ein Kranich sitzt.")
def stream_opts(sp):
sp.add_argument("--source", default=env("SOURCE"),
help="MJPEG-/Snapshot-URL, rtsp://... oder ein Ordner")
sp.add_argument("--fps", type=float, default=2.0, help="nur für RTSP")
sp.add_argument("--interval", type=float, default=1.0,
help="Sekunden zwischen Snapshots")
sp.add_argument("--motion-threshold", type=int, default=22,
help="Helligkeitsunterschied, ab dem ein Pixel als bewegt gilt")
sp.add_argument("--motion-ratio", type=float, default=0.012,
help="Anteil bewegter Pixel, ab dem ein Besuch beginnt")
w = sub.add_parser("watch", help="Dauerbetrieb an der Kamera")
common(w)
stream_opts(w)
w.add_argument("--api", default=env("API"), help="Basis-URL der Web-App")
w.add_argument("--token", default=env("TOKEN"), help="Ingest-Token")
w.add_argument("--save-to", default=env("SAVE_TO"), help="Ordner für Rohdaten")
w.add_argument("--classify-every", type=int, default=3)
w.add_argument("--cooldown", type=float, default=4.0,
help="Sekunden ohne Bewegung, bis der Besuch endet")
w.add_argument("--min-confidence", type=float, default=0.45)
w.add_argument("--min-bird-frames", type=int, default=2,
help="So viele Bilder mit Vogel braucht ein Besuch, sonst wird er "
"verworfen. 1 faengt auch Sekundenbesuche — sinnvoll an einem "
"neuen Futterhaus, wo die ersten Gaeste noch scheu sind.")
w.add_argument("--gate", choices=("motion", "detector"), default=env("GATE", "motion"),
help="Was einen Besuch ausloest. 'motion' spart Rechenzeit und "
"setzt einen ruhigen Hintergrund voraus; 'detector' laesst das "
"Modell entscheiden und ist noetig, wenn die Kamera auf "
"Bewegtes schaut (Hecke im Wind).")
w.add_argument("--nachtsicht", action="store_true",
default=env("NACHTSICHT", "") not in ("", "0", "false"),
help="Kamera sieht im Dunkeln (Infrarot). Dann wird nie "
"pausiert. Ohne diesen Schalter setzt die Analyse aus, "
"sobald das Bild zu dunkel ist, um etwas zu erkennen.")
w.add_argument("--dunkel-unter", type=float, default=15.0,
help="Helligkeit 0-255, unter der pausiert wird. Gemessen am "
"Vogelhaus: tagsueber 111-131, nachts 1,7.")
w.add_argument("--hell-ueber", type=float, default=30.0,
help="Helligkeit, ab der es weitergeht. Muss ueber "
"--dunkel-unter liegen, sonst flattert es in der "
"Daemmerung.")
w.add_argument("--dunkel-geduld", type=int, default=20,
help="So viele Bilder am Stueck muessen fuer den Wechsel "
"sprechen. Verhindert, dass ein Autoscheinwerfer die "
"Analyse weckt.")
w.add_argument("--protokoll", default=env("PROTOKOLL"),
help="CSV fuer ein Dauerprotokoll: jede Erkennung ab "
"--protokoll-ab plus ein Lebenszeichen je Viertelstunde. "
"Beantwortet ueber Tage, ob Voegel knapp verpasst werden.")
w.add_argument("--protokoll-ab", type=float, default=0.08,
help="Ab welchem Score eine Erkennung ins Protokoll wandert. "
"Bewusst weit unter --detect-threshold.")
w.add_argument("--konfig-alle", type=float, default=float(env("KONFIG_ALLE", "300")),
help="Sekunden zwischen zwei Abfragen der Einstellungen bei "
"der Web-App (Standard 300, 0 = nie). Braucht --api und "
"--token. Was dort im Admin steht, gewinnt gegen die "
"Kommandozeile — ausser bei --source: eine ausdruecklich "
"angegebene Quelle bleibt.")
w.add_argument("--stichprobe", default=env("STICHPROBE"),
help="Ordner fuer regelmaessige Bilder aus dem Strom, "
"unabhaengig von jeder Erkennung. Beantwortet die Frage, "
"die das Protokoll nicht beantworten kann: kommt ueberhaupt "
"ein Vogel ins Bild?")
w.add_argument("--stichprobe-alle", type=float, default=300.0,
help="Sekunden zwischen zwei Stichproben (Standard 300).")
w.add_argument("--stichprobe-max", type=int, default=500,
help="So viele Bilder bleiben liegen, dann faellt das aelteste "
"raus. Ein Dauerlaeufer darf keine Platte volllaufen lassen.")
w.add_argument("--live-port", type=int, default=int(env("LIVE_PORT", "0")),
help="Port fuer ein Live-Bild, das der Detector aus seinem "
"eigenen Bildstrom verteilt. 0 = aus. Die Kamera "
"liefert nur einen Stream; wer direkt bei ihr zuschaut, "
"nimmt ihn der Erkennung weg. Hier schauen beliebig "
"viele zu, ohne dass die Kamera etwas merkt.")
w.add_argument("--live-bind", default=env("LIVE_BIND", "0.0.0.0"),
help="Adresse, auf der das Live-Bild lauscht. Gehoert ins "
"Heimnetz — es ist ein Bild aus dem eigenen Garten "
"ohne Anmeldung.")
w.add_argument("--live-max", type=int, default=8,
help="So viele Zuschauer gleichzeitig. Reine Vorsicht gegen "
"vergessene Browsertabs.")
w.add_argument("--verbose", action="store_true")
w.set_defaults(func=cmd_watch)
t = sub.add_parser("test", help="Einzelbilder klassifizieren")
common(t)
t.add_argument("paths", nargs="+")
t.set_defaults(func=cmd_test)
q = sub.add_parser(
"warteschlange",
help="Hochgeladene Fotos abholen und bestimmen",
description="Holt Fotos, die ueber die App oder das Formular "
"hochgeladen wurden, bestimmt sie und meldet das Ergebnis "
"zurueck. Fragt selbst nach — es braucht keinen offenen "
"Port und keine erreichbare Adresse.",
)
common(q)
q.add_argument("--api", default=env("API"), help="Basis-URL der Web-App")
q.add_argument("--token", default=env("TOKEN"),
help="Token zum Zurueckmelden (derselbe wie beim Einliefern)")
q.add_argument("--lese-token", default=env("LESE_TOKEN"),
help="Token zum Abrufen der offenen Fotos")
q.add_argument("--alle", type=float, default=60.0,
help="Sekunden zwischen zwei Abfragen (Standard 60)")
q.add_argument("--anzahl", type=int, default=20,
help="Hoechstens so viele Fotos je Durchlauf (Standard 20)")
q.add_argument("--einmal", action="store_true",
help="Einen Durchlauf machen und beenden")
q.set_defaults(func=cmd_warteschlange)
u = sub.add_parser("tune", help="Bewegungsschwelle an der Kamera einstellen")
stream_opts(u)
u.set_defaults(func=cmd_tune)
k = sub.add_parser(
"karte",
help="Erkennungskarte: wo im Bild wird ein Vogel gefunden",
description="Montiert ein Vogelfoto ueber ein Raster von Positionen in "
"aktuelle Kamerabilder und misst, wo der Detektor anspringt. "
"Beantwortet vor dem ersten echten Besuch, ob die Kamera "
"richtig haengt.",
)
common(k)
stream_opts(k)
k.add_argument("--vogel", default="testdata/amsel.jpg",
help="Vogelfoto zum Einmontieren. Am aussagekraeftigsten ist "
"ein Ausschnitt aus einer echten Sichtung der eigenen "
"Kamera; die Referenzfotos tun es auch.")
k.add_argument("--anteil", type=float, default=0.08,
help="Wie viel Bildflaeche der Vogel einnimmt (Standard 8 %%). "
"Vor ruhigem Hintergrund reichen 5 %%, vor einem Baum im "
"Wind eher 8 %% — genau deshalb misst man nach.")
k.add_argument("--bilder", type=int, default=3,
help="So viele Hintergrundbilder je Position, damit ein "
"zufaellig guenstiger Ast das Ergebnis nicht traegt")
k.add_argument("--ziel", default="karte.jpg", help="Bilddatei fuer die Karte")
k.set_defaults(func=cmd_karte)
r = sub.add_parser("report", help="Gespeicherte Sichtungen auswerten")
r.add_argument("ordner", help="Ordner aus --save-to")
r.add_argument("--kontaktbogen", help="Ausschnitte als ein Bild nebeneinander")
r.set_defaults(func=cmd_report)
b = sub.add_parser(
"bogen",
help="Stichproben als ein Blatt montieren",
description="Macht aus dem Stichprobenordner ein einzelnes Blatt, "
"neueste zuerst. 120 Einzelbilder sieht niemand durch, "
"ein Blatt schon.",
)
b.add_argument("ordner", help="Ordner aus --stichprobe")
b.add_argument("--ziel", default="bogen.jpg", help="Bilddatei fuer das Blatt")
b.add_argument("--spalten", type=int, default=5)
b.add_argument("--zeilen", type=int, default=6)
b.add_argument("--seite", type=int, default=1,
help="Welche Seite, 1 = die neuesten Bilder")
b.set_defaults(func=cmd_bogen)
s = sub.add_parser("species", help="Artenkatalog anzeigen")
s.add_argument("--habitats", default=None,
help="Lebensraeume kommagetrennt; leer zeigt alle")
s.set_defaults(func=cmd_species)
args = p.parse_args(argv)
if getattr(args, "source", "SKIP") is None:
# 'watch' darf die Adresse auch aus der Web-App holen; die Prüfung
# macht dort cmd_watch, wenn beides fehlt. Alle anderen Befehle
# brauchen sie sofort.
if args.cmd == "watch" and args.api and args.token:
args.source = ""
else:
p.error("--source fehlt (oder VOGELALBUM_SOURCE setzen)")
return args.func(args)
if __name__ == "__main__":
sys.exit(main())