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.
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}")
|