"""Wohin die fertigen Sichtungen gehen.""" from __future__ import annotations import io import json import time from abc import ABC, abstractmethod from pathlib import Path from PIL import Image from .watcher import Sighting JPEG_QUALITY = 88 MAX_WIDTH = 1600 def _encode(img: Image.Image, max_width: int = MAX_WIDTH) -> bytes: if img.width > max_width: h = round(img.height * max_width / img.width) img = img.resize((max_width, h), Image.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) return buf.getvalue() def payload_of(sighting: Sighting) -> dict: return { "species": sighting.species.slug if sighting.species else None, "confidence": round(sighting.confidence, 4), "started_at": sighting.started_at.isoformat(), "ended_at": sighting.ended_at.isoformat(), "frames_total": sighting.frames_total, "frames_with_bird": sighting.frames_with_bird, "votes": {k: round(v, 4) for k, v in sighting.votes.items()}, "mean_bird_score": round(sighting.mean_bird_score, 4), # Vier Zahlen statt eines zweiten Bildes: siehe web/dex/zuschnitt.py. "box": ([round(w, 5) for w in sighting.best_box] if getattr(sighting, "best_box", None) else None), } class Sink(ABC): @abstractmethod def emit(self, sighting: Sighting) -> None: ... class ConsoleSink(Sink): """Nur ausgeben — zum Einstellen der Schwellwerte an der echten Kamera.""" def emit(self, sighting: Sighting) -> None: print(f"[sichtung] {sighting.summary()}") if sighting.votes: top = sorted(sighting.votes.items(), key=lambda kv: -kv[1])[:3] print(" " + ", ".join(f"{k} {v:.0%}" for k, v in top)) class FileSink(Sink): """Bilder und JSON in einen Ordner. Nützlich, wenn die Web-App gerade nicht läuft, und als Trainingsmaterial fürs spätere eigene Modell.""" def __init__(self, folder: str | Path): self.folder = Path(folder) self.folder.mkdir(parents=True, exist_ok=True) def emit(self, sighting: Sighting) -> None: stamp = sighting.started_at.strftime("%Y%m%d-%H%M%S") name = sighting.species.slug if sighting.species else "unbestimmt" base = self.folder / f"{stamp}_{name}" if sighting.best_image is not None: base.with_suffix(".jpg").write_bytes(_encode(sighting.best_image)) if sighting.best_crop is not None: base.with_name(base.name + "_crop").with_suffix(".jpg").write_bytes( _encode(sighting.best_crop, 640) ) base.with_suffix(".json").write_text( json.dumps(payload_of(sighting), indent=2, ensure_ascii=False), encoding="utf-8", ) print(f"[datei] {base.name} — {sighting.summary()}") class HTTPSink(Sink): """Meldet an die Django-App. Bei Netzproblemen wird die Sichtung verworfen statt gepuffert — für einen Vogelzähler ist das in Ordnung, und ``FileSink`` als zweite Senke fängt es ohnehin auf.""" def __init__(self, base_url: str, token: str, timeout: int = 20, retries: int = 2): self.url = base_url.rstrip("/") + "/api/sightings/" self.token = token self.timeout = timeout self.retries = retries def emit(self, sighting: Sighting) -> None: import requests files = {} if sighting.best_image is not None: files["image"] = ("sighting.jpg", _encode(sighting.best_image), "image/jpeg") if sighting.best_crop is not None: files["crop"] = ("crop.jpg", _encode(sighting.best_crop, 640), "image/jpeg") data = {"data": json.dumps(payload_of(sighting))} headers = {"Authorization": f"Bearer {self.token}"} for attempt in range(self.retries + 1): try: resp = requests.post( self.url, data=data, files=files, headers=headers, timeout=self.timeout ) except requests.RequestException as exc: if attempt == self.retries: print(f"[http] Senden fehlgeschlagen: {exc}") return time.sleep(2 ** attempt) continue if resp.ok: body = resp.json() bits = [f"{body.get('art') or 'unbestimmt'}", f"+{body.get('xp', 0)} XP"] if body.get("erstfang"): bits.append("ERSTFANG!") if body.get("level_up"): bits.append(f"LEVEL {body['level']}!") for name in body.get("abzeichen") or []: bits.append(f"Abzeichen: {name}") print("[http] " + " · ".join(bits)) return # 4xx liegt an uns und wird durch Wiederholen nicht besser. if 400 <= resp.status_code < 500: print(f"[http] {resp.status_code}: {resp.text[:200]}") return if attempt == self.retries: print(f"[http] {resp.status_code}: {resp.text[:200]}") return time.sleep(2 ** attempt) class MultiSink(Sink): def __init__(self, *sinks: Sink): self.sinks = [s for s in sinks if s is not None] def emit(self, sighting: Sighting) -> None: for sink in self.sinks: try: sink.emit(sighting) except Exception as exc: # eine kaputte Senke darf den Lauf nicht beenden print(f"[sink] {type(sink).__name__} fehlgeschlagen: {exc}")