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