Die Sichtung merkt sich jetzt, WO der Vogel im Foto sitzt (vier Zahlen); die Anzeige schneidet daraus bei jedem Aufruf. Eine geaenderte Regel wirkt damit sofort auf den ganzen Bestand. Altbestand faellt auf das alte Bild zurueck.
152 lines
5.5 KiB
Python
152 lines
5.5 KiB
Python
"""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}")
|