Files
vogelalbum/detector/warteschlange.py
T
christianandClaude Opus 5 83ec6ad6c1 Greifvogel-Alarm berichtigt, Wartezeit von 60 auf 5 Sekunden
Das Abzeichen hing an der Seltenheit statt an der Vogelgruppe: "Ein
Greifvogel oder eine Eule vor der Linse" wurde fuer jede epische oder
legendaere Art vergeben. Von 148 Arten sind 21 Greifvoegel oder Eulen, 96
sind episch oder legendaer. Aufgefallen an einem Weissstorch.

Die Warteschlange fragte alle 60 Sekunden nach Arbeit. Das war nie eine
Hintergrundaufgabe, sondern die Zeit, die ein Mensch vor "Der Detektor
schaut sich das an …" wartet, nachdem er ein Foto geschickt hat. Jetzt 5
Sekunden; die Abfrage ist ein kurzes JSON gegen die eigene Instanz.

Drei Pruefungen fuer das Abzeichen, eine davon mit dem Storch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 20:25:24 +00:00

214 lines
8.7 KiB
Python

"""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``.
🔴 **Das Intervall ist eine Wartezeit vor den Augen eines Menschen, keine
Hintergrundaufgabe.** Es stand auf 60 Sekunden — gedacht als schonende
Hintergrundabfrage, tatsächlich aber die Zeit, die jemand vor dem Bildschirm
„Der Detektor schaut sich das an …" wartet, nachdem er ein Foto geschickt
hat. Christian hat das zweimal bemängelt, bevor es jemand als Fehler gelesen
hat.
Jetzt 5 Sekunden, und ein Durchlauf, der etwas gefunden hat, fragt **sofort**
noch einmal nach: Mehrere Fotos hintereinander sollen nicht je 5 Sekunden
kosten. Die Abfrage ist ein kurzes JSON gegen die eigene Instanz — bei 5
Sekunden sind das 720 Anfragen in der Stunde, für einen Django-Server nichts.
"""
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 = 5.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