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-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
"""Hochgeladene Fotos abholen, bestimmen und das Ergebnis zurückmelden.
Der zweite Weg, auf dem Bilder zur Erkennung kommen. Der erste ist die Kamera
am Haus: Der Detector holt sich dort einen Stream und entscheidet selbst, wann
ein Besuch beginnt und endet. Hier liegt das Bild schon in der Web-App — jemand
hat unterwegs fotografiert und hochgeladen.
🔑 **Die Richtung bleibt dieselbe: Der Detector fragt, niemand ruft ihn.** Das
ist keine Bequemlichkeit, sondern die Bedingung dafür, dass die Erkennung im
Heimnetz stehen kann: kein offener Port, keine erreichbare Adresse, keine
Portfreigabe im Router. Dieselbe Mechanik benutzt ``fernkonfig.py``, um alle
fünf Minuten die Konfiguration zu holen.
⚠️ **Keine Bewegungserkennung, keine Mittelung über Bilder.** Ein Foto ist ein
Foto. Die ganze Logik aus ``watcher.py`` — Bewegung als Auslöser, Besuch über
viele Einzelbilder, Mittelwert der Vogel-Werte — hat hier keine Entsprechung
und wäre bei einem Einzelbild sogar falsch. Benutzt wird deshalb direkt
``Pipeline.run()``, genau wie bei ``python -m detector test``.
"""
from __future__ import annotations
import io
import json
import time
from PIL import Image
from .classify import Pipeline
from .sink import _encode
class Warteschlange:
"""Fragt die Web-App nach offenen Fotos und arbeitet sie ab."""
def __init__(
self,
api: str,
lese_token: str,
schreib_token: str,
pipeline: Pipeline,
intervall: float = 60.0,
anzahl: int = 20,
timeout: int = 20,
):
self.basis = api.rstrip("/")
# ⚠️ Zwei verschiedene Token, und das ist Absicht: Das Auflisten ist
# ein Lesevorgang nach außen (derselbe Token wie für Home
# Assistant), das Zurückmelden ein Schreibvorgang wie das normale
# Einliefern. Wer nur zuschauen darf, soll keine Bestimmung
# eintragen können.
self.lese_token = lese_token
self.schreib_token = schreib_token
self.pipeline = pipeline
self.intervall = intervall
self.anzahl = anzahl
self.timeout = timeout
self._letzter_versuch: float | None = None
self._fehler_gemeldet = False
def faellig(self, jetzt: float) -> bool:
return (
self._letzter_versuch is None
or jetzt - self._letzter_versuch >= self.intervall
)
# -- Abruf ------------------------------------------------------------
def _offene(self) -> list[dict]:
import requests
resp = requests.get(
f"{self.basis}/api/offen/",
params={"anzahl": self.anzahl},
headers={"Authorization": f"Bearer {self.lese_token}"},
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json().get("fotos") or []
def _bild_holen(self, url: str) -> Image.Image:
import requests
resp = requests.get(url, timeout=self.timeout)
resp.raise_for_status()
return Image.open(io.BytesIO(resp.content)).convert("RGB")
# -- Bearbeiten -------------------------------------------------------
def _melden(self, foto_id: int, ausschnitt, result, box=None) -> None:
import requests
# ⚠️ ``Result.best`` ist eine Property, kein Aufruf — ``result.best()``
# scheitert erst zur Laufzeit und genau dann, wenn ein Vogel
# erkannt wurde.
bester = result.best if result else None
# 🔴 **„Vogel ja, Art nein" ist etwas anderes als „kein Vogel" — und
# ging hier verloren.**
#
# ``Pipeline.run`` gibt in DREI Fällen ``result = None`` zurück: kein
# Fund, Beinahe-Treffer unter der Schwelle, und — der interessante —
# ein sicher erkannter Vogel, dessen Art keine heimische ist. Die Box
# unterscheidet die Fälle, nur hat sie vorher niemand angesehen.
#
# Folge am 14.10.2026: Ein Foto mit einer Meise im Gegenlicht wurde
# mit 93 % als Vogel erkannt und kam in der App als „Kein Vogel
# erkannt. Auf dem Foto war nichts zu finden" an. Wer das liest,
# schneidet das Bild zu — gegen ein Problem, das nie der Zuschnitt
# war. **Eine falsche Auskunft ist teurer als keine.**
#
# ``box.score`` ist genau die Zahl, die „da ist ein Vogel" bedeutet;
# sie gehört in ``mean_bird_score``, dessen Sinn der Server selbst so
# beschreibt: Vogelwert 0 heißt „auf keinem Bild war ein Vogel".
vogelwert = 0.0
if result is not None:
vogelwert = result.bird_score
elif box is not None and self.pipeline.zaehlt_als_vogel(box):
vogelwert = box.score
nutzlast = {
"species": bester.species.slug if bester else None,
"confidence": round(bester.confidence, 4) if bester else 0.0,
"mean_bird_score": round(vogelwert, 4),
"votes": {g.species.slug: round(g.confidence, 4)
for g in (result.guesses[:5] if result else [])},
}
# Wo der Vogel sitzt. Die Sammlung schneidet daraus selbst zu --
# siehe web/dex/zuschnitt.py.
if box is not None:
nutzlast["box"] = [round(box.x0, 5), round(box.y0, 5),
round(box.x1, 5), round(box.y1, 5)]
dateien = {}
if ausschnitt is not None:
dateien["crop"] = ("crop.jpg", _encode(ausschnitt, 640), "image/jpeg")
resp = requests.post(
f"{self.basis}/api/offen/{foto_id}/",
data={"data": json.dumps(nutzlast)},
files=dateien or None,
headers={"Authorization": f"Bearer {self.schreib_token}"},
timeout=self.timeout,
)
if not resp.ok:
print(f"[warteschlange] {foto_id}: Rückmeldung {resp.status_code} "
f"— {resp.text[:160]}")
return
body = resp.json()
art = body.get("art") or "nichts erkannt"
teile = [f"Foto {foto_id}: {art}"]
if body.get("xp"):
teile.append(f"+{body['xp']} XP")
if body.get("erstfang"):
teile.append("ERSTFANG!")
for name in body.get("abzeichen") or []:
teile.append(f"Abzeichen: {name}")
print("[warteschlange] " + " · ".join(teile))
def durchlauf(self, jetzt: float | None = None) -> int:
"""Holt die offenen Fotos und bestimmt sie. Gibt die Anzahl zurück.
⚠️ Ein Fehler bei einem Foto darf die anderen nicht aufhalten. Sonst
blockiert ein einziges kaputtes Bild die ganze Warteschlange, und
niemand sieht, warum nichts mehr vorangeht.
"""
import requests
self._letzter_versuch = time.monotonic() if jetzt is None else jetzt
try:
offene = self._offene()
except requests.RequestException as exc:
if not self._fehler_gemeldet:
print(f"[warteschlange] nicht erreicht: {exc}")
self._fehler_gemeldet = True
return 0
if self._fehler_gemeldet:
print("[warteschlange] wieder erreichbar")
self._fehler_gemeldet = False
fertig = 0
for foto in offene:
if not foto.get("bild"):
continue
try:
bild = self._bild_holen(foto["bild"])
box, result = self.pipeline.run(bild)
# Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild
# zeigt wie bei einer Sichtung von der Kamera.
# ⚠️ `anzeige_ausschnitt`, nicht `crop`: Der enge Schnitt ist
# fuer den Klassifikator gedacht; in der fast quadratischen
# Kachel der Sammlung bleibt davon nur die Bauchmitte uebrig.
ausschnitt = box.anzeige_ausschnitt(bild) if box is not None else None
self._melden(foto["id"], ausschnitt, result, box)
fertig += 1
except Exception as exc: # absichtlich breit, siehe Docstring
print(f"[warteschlange] Foto {foto['id']} übersprungen: {exc}")
return fertig