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:
christian committed 2026-10-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
"""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}")