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,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())
|
||||
Reference in new issue
Block a user