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:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
+614
@@ -0,0 +1,614 @@
|
||||
"""Ingest-Endpunkt: der Detector meldet fertige Sichtungen hierher.
|
||||
|
||||
Absichtlich ein simples multipart-POST statt DRF — es gibt genau eine
|
||||
Schreiboperation, und die Web-App soll ohne zusätzliche Abhängigkeiten laufen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from . import benachrichtigung, scoring
|
||||
from .bilder import KeinBild, saeubern
|
||||
from .models import Camera, Sighting, Species
|
||||
|
||||
# Marker für "Token stimmt, gehört aber zu keiner registrierten Kamera".
|
||||
# Das ist der Einzelkamera-Betrieb über VOGELALBUM_INGEST_TOKEN aus den Settings.
|
||||
LEGACY_CAMERA = object()
|
||||
|
||||
|
||||
def _authenticate(request: HttpRequest):
|
||||
"""Gibt die Kamera zurück, ``LEGACY_CAMERA`` oder ``None`` bei Ablehnung.
|
||||
|
||||
Jede Kamera hat ihren eigenen Token — damit ist klar, wer geliefert hat,
|
||||
und ein kompromittierter Standort lässt sich einzeln stilllegen, ohne die
|
||||
anderen anzufassen.
|
||||
"""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return None
|
||||
token = header[7:]
|
||||
|
||||
camera = Camera.by_token(token)
|
||||
if camera is not None:
|
||||
return camera
|
||||
|
||||
# Konstantzeit-Vergleich: der Token ist ein Geheimnis, kein Benutzername.
|
||||
# Ein leerer INGEST_TOKEN schaltet diesen Weg ab — dann geht nur noch über
|
||||
# registrierte Kameras etwas rein. Ohne die Prüfung auf leer würde ein
|
||||
# leerer Authorization-Header durchgehen.
|
||||
if settings.INGEST_TOKEN and hmac.compare_digest(token, settings.INGEST_TOKEN):
|
||||
return LEGACY_CAMERA
|
||||
return None
|
||||
|
||||
|
||||
@require_GET
|
||||
def config(request: HttpRequest) -> JsonResponse:
|
||||
"""Was für die anfragende Kamera gilt: Schwellen, Lebensräume, Artenliste.
|
||||
|
||||
Damit ist die Web-App die Wahrheit über die Erkennung, und nicht mehr die
|
||||
Kommandozeile auf dem Rechner, auf dem der Detector läuft. Wer eine Kamera
|
||||
umstellen will, braucht keine Shell mehr.
|
||||
|
||||
Im Einzelkamera-Betrieb über ``VOGELALBUM_INGEST_TOKEN`` gibt es keine
|
||||
Kamera-Zeile in der Datenbank. Dann gelten die Vorgaben des Modells —
|
||||
sonst müsste man erst eine Kamera anlegen, um überhaupt etwas zu bekommen.
|
||||
"""
|
||||
auth = _authenticate(request)
|
||||
if auth is None:
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
|
||||
kamera = auth if isinstance(auth, Camera) else Camera(slug="", habitats="garten")
|
||||
|
||||
# Der Abruf selbst ist das Lebenszeichen — siehe Camera.letzter_kontakt.
|
||||
if isinstance(auth, Camera) and auth.pk:
|
||||
Camera.objects.filter(pk=auth.pk).update(letzter_kontakt=timezone.now())
|
||||
|
||||
return JsonResponse(kamera.konfiguration())
|
||||
|
||||
|
||||
def _box_uebernehmen(sighting, payload) -> None:
|
||||
"""Wo der Vogel im Bild sitzt, als vier relative Zahlen.
|
||||
|
||||
🔑 Damit ist der Zuschnitt eine Regel statt einer Datei: Die Anzeige
|
||||
schneidet aus dem Original, und eine bessere Regel wirkt auf den ganzen
|
||||
Bestand. Siehe ``dex/zuschnitt.py``.
|
||||
|
||||
⚠️ Nur vollständige und plausible Vierecke übernehmen. Ein halbes Viereck
|
||||
ist keins, und eine vertauschte Kante ergäbe einen leeren Ausschnitt —
|
||||
sichtbar würde das erst als graue Kachel, weit weg von hier.
|
||||
"""
|
||||
box = payload.get("box")
|
||||
if not isinstance(box, (list, tuple)) or len(box) != 4:
|
||||
return
|
||||
try:
|
||||
x0, y0, x1, y1 = (float(w) for w in box)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not (0.0 <= x0 < x1 <= 1.0 and 0.0 <= y0 < y1 <= 1.0):
|
||||
return
|
||||
sighting.box_x0, sighting.box_y0 = x0, y0
|
||||
sighting.box_x1, sighting.box_y1 = x1, y1
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return parse_datetime(value)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def ingest(request: HttpRequest) -> JsonResponse:
|
||||
auth = _authenticate(request)
|
||||
if auth is None:
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
camera = auth if isinstance(auth, Camera) else None
|
||||
|
||||
raw = request.POST.get("data")
|
||||
if not raw:
|
||||
return JsonResponse({"fehler": "Feld 'data' fehlt"}, status=400)
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400)
|
||||
|
||||
started = _parse_dt(payload.get("started_at"))
|
||||
ended = _parse_dt(payload.get("ended_at"))
|
||||
if started is None or ended is None:
|
||||
return JsonResponse(
|
||||
{"fehler": "started_at/ended_at fehlen oder sind unlesbar"}, status=400
|
||||
)
|
||||
|
||||
species = None
|
||||
slug = payload.get("species")
|
||||
if slug:
|
||||
species = Species.objects.filter(slug=slug).first()
|
||||
if species is None:
|
||||
return JsonResponse(
|
||||
{"fehler": f"unbekannte Art '{slug}' — Katalog nicht geseedet?"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
vogelwert = float(payload.get("mean_bird_score") or 0.0)
|
||||
|
||||
# ⚠️ Ohne Vogel keine Sichtung. Die Grenze liegt am VOGELWERT, nicht an der
|
||||
# Artsicherheit -- die beiden werden leicht verwechselt und bedeuten
|
||||
# Verschiedenes: Artsicherheit 0 heißt "ich weiß nicht, welche Art",
|
||||
# was bei einem echten Vogel vorkommt, den man später nachbestimmen
|
||||
# will. Vogelwert 0 heißt "auf keinem einzigen Bild war ein Vogel".
|
||||
#
|
||||
# 🔑 Der Anlass: Am 27.09.2026 meldete die Vogeltränke abends 24 Besuche
|
||||
# in vierzig Minuten -- Spiegelungen des Mondes auf dem Wasser, je ein
|
||||
# Bild von 31 mit vermeintlicher Bewegung. Gezählt haben sie nie, aber
|
||||
# sie standen in der Liste und brachten das Abzeichen "Nachtschicht"
|
||||
# ein. Hier abzuweisen ist billiger, als die Folgen überall zu filtern.
|
||||
#
|
||||
# Kein Fehler für den Detector: er hat richtig gearbeitet und soll das
|
||||
# nicht als Störung protokollieren. Deshalb 200 und nicht 400.
|
||||
#
|
||||
# ⚠️ Nur abweisen, wenn das Feld auch GESCHICKT wurde. Web-App und Detector
|
||||
# sind getrennt deploybar (so ist dieses Projekt gebaut), die Fassungen
|
||||
# laufen also auseinander: Web auf dem Server aktualisiert, Detector zu
|
||||
# Hause noch alt. Eine Fassung, die den Wert gar nicht kennt, würde
|
||||
# sonst ab dem Deploy stumm keine Sichtung mehr loswerden — ein Ausfall,
|
||||
# der wie ein totes Vogelhaus aussieht und dessen Ursache auf einer
|
||||
# anderen Maschine liegt.
|
||||
# Je Kamera einstellbare Untergrenze. Ohne registrierte Kamera (Betrieb
|
||||
# über den Sammel-Token) bleibt es beim harten Boden von 0.
|
||||
grenze = camera.min_mean_bird_score if camera is not None else 0.0
|
||||
if "mean_bird_score" in payload and vogelwert <= max(grenze, 0.0):
|
||||
return JsonResponse(
|
||||
{
|
||||
"ignoriert": "kein Vogel erkannt",
|
||||
"mean_bird_score": vogelwert,
|
||||
"grenze": grenze,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
|
||||
# Ort, falls die Quelle einen kennt — ein Handyfoto vom Spaziergang tut
|
||||
# das, ein Vogelhaus nicht.
|
||||
#
|
||||
# ⚠️ Beide Werte nur zusammen übernehmen und nur im gültigen Bereich. Eine
|
||||
# halbe Koordinate ist keine, und ein verrutschtes Vorzeichen oder eine
|
||||
# 0/0-Vorgabe aus einem Formular landet sonst als Sichtung im Golf von
|
||||
# Guinea, ohne dass irgendwo etwas auffällt.
|
||||
breite = _koordinate(payload.get("latitude"), 90.0)
|
||||
laenge = _koordinate(payload.get("longitude"), 180.0)
|
||||
if breite is None or laenge is None:
|
||||
breite = laenge = None
|
||||
|
||||
sighting = Sighting(
|
||||
species=species,
|
||||
camera=camera,
|
||||
confidence=float(payload.get("confidence") or 0.0),
|
||||
started_at=started,
|
||||
ended_at=ended,
|
||||
frames_total=int(payload.get("frames_total") or 0),
|
||||
frames_with_bird=int(payload.get("frames_with_bird") or 0),
|
||||
votes=payload.get("votes") or {},
|
||||
mean_bird_score=vogelwert,
|
||||
latitude=breite,
|
||||
longitude=laenge,
|
||||
)
|
||||
|
||||
# 🔑 Auch hier neu schreiben, obwohl der Detector vertrauenswürdig ist.
|
||||
# Der Token EINER Kamera genügt, um Dateien unter der öffentlichen
|
||||
# Medienadresse abzulegen — und ein kompromittierter Standort ist genau
|
||||
# der Fall, für den die Token je Kamera getrennt sind. Siehe bilder.py.
|
||||
_box_uebernehmen(sighting, payload)
|
||||
|
||||
try:
|
||||
if "image" in request.FILES:
|
||||
sighting.image = saeubern(request.FILES["image"])
|
||||
if "crop" in request.FILES:
|
||||
sighting.crop = saeubern(request.FILES["crop"])
|
||||
except KeinBild as fehler:
|
||||
return JsonResponse({"fehler": str(fehler)}, status=400)
|
||||
sighting.save()
|
||||
|
||||
if camera is not None:
|
||||
# 🔑 Beides, und das ist kein Doppel: ``last_seen`` heißt „zuletzt
|
||||
# einen Vogel gesehen", ``letzter_kontakt`` heißt „die Kamera lebt".
|
||||
# Eine eingelieferte Sichtung beweist beides. Bis zum 01.10.2026 stand
|
||||
# hier nur ``last_seen``, und das Lebenszeichen kam ausschließlich vom
|
||||
# Konfigurationsabruf — wer den abschaltet (``--konfig-alle 0``), hätte
|
||||
# eine fleißig liefernde Kamera gehabt, die als still gilt.
|
||||
jetzt = timezone.now()
|
||||
camera.last_seen = jetzt
|
||||
camera.letzter_kontakt = jetzt
|
||||
camera.save(update_fields=["last_seen", "letzter_kontakt"])
|
||||
|
||||
reward = scoring.register(sighting)
|
||||
|
||||
# Meldung erst NACH dem Speichern und Bewerten: Erst hier steht fest,
|
||||
# ob es eine neue Art ist. Laeuft im Hintergrund und kann die Antwort
|
||||
# an den Detector nicht aufhalten.
|
||||
benachrichtigung.melde_sichtung(sighting, reward.is_first)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"id": sighting.id,
|
||||
"kamera": camera.name if camera else None,
|
||||
"art": species.german if species else None,
|
||||
"xp": reward.xp,
|
||||
"erstfang": reward.is_first,
|
||||
"level": reward.level_after,
|
||||
"level_up": reward.leveled_up,
|
||||
"abzeichen": [b.name for b in reward.badges],
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lesen nach außen
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# 🔑 Bis hierher konnte Vogelalbum nur ZUHÖREN: Kameras liefern ein, sonst
|
||||
# passiert nichts. Für Home Assistant — und später eine HACS-Integration —
|
||||
# braucht es den umgekehrten Weg.
|
||||
#
|
||||
# Eine einzige Antwort mit allem statt vieler kleiner Endpunkte. Wer alle
|
||||
# zehn Sekunden fragt, soll nicht sechsmal fragen müssen, und die Brücke
|
||||
# nach MQTT will ohnehin den ganzen Zustand auf einmal.
|
||||
|
||||
|
||||
# 🔑 **Gerundet wird beim SPEICHERN, nicht beim Anzeigen.** Zwei Nachkommastellen
|
||||
# sind rund ein Kilometer: genug für „am Waldrand bei X", zu grob für ein Haus.
|
||||
# Eine Stelle weniger wären elf Kilometer und für die Frage, wo der Vogel war,
|
||||
# wertlos.
|
||||
#
|
||||
# ⚠️ Der Unterschied ist nicht kosmetisch. Wer genau speichert und nur ungenau
|
||||
# anzeigt, hat die Wohnadresse trotzdem in der Datenbank — und damit in jeder
|
||||
# Sicherung, jedem Auszug und jeder späteren Auswertung, die von dieser
|
||||
# Anzeigeregel nichts weiß. Was nie gespeichert wurde, kann auch nicht
|
||||
# versehentlich herausfallen.
|
||||
ORT_STELLEN = 2
|
||||
|
||||
|
||||
def _koordinate(wert, grenze: float) -> float | None:
|
||||
"""Eine gerundete Koordinate oder ``None``. Unbrauchbares wird verworfen.
|
||||
|
||||
⚠️ Auch die exakte 0 gilt als unbrauchbar. Das ist streng genommen ein
|
||||
gültiger Ort im Atlantik — aber als Wert aus einer App ist es praktisch
|
||||
immer ein nicht gefülltes Feld, und eine Sammlung voller Sichtungen vor
|
||||
Westafrika fällt niemandem rechtzeitig auf.
|
||||
"""
|
||||
if wert is None:
|
||||
return None
|
||||
try:
|
||||
zahl = float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if zahl == 0.0 or not -grenze <= zahl <= grenze:
|
||||
return None
|
||||
return round(zahl, ORT_STELLEN)
|
||||
|
||||
|
||||
def _lesen_erlaubt(request: HttpRequest) -> bool:
|
||||
"""Eigener Token fürs Lesen, getrennt vom Einliefern.
|
||||
|
||||
⚠️ Leerer Token heißt ZU, nicht offen. Ohne diese Prüfung stünde die
|
||||
ganze Sammlung im Netz, sobald jemand die Umgebungsvariable vergisst.
|
||||
"""
|
||||
header = request.headers.get("Authorization", "")
|
||||
if not header.startswith("Bearer "):
|
||||
return False
|
||||
if not settings.LESE_TOKEN:
|
||||
return False
|
||||
return hmac.compare_digest(header[7:], settings.LESE_TOKEN)
|
||||
|
||||
|
||||
def _kamera_zustand(kamera: Camera, grenze_min: int, jetzt) -> dict:
|
||||
still_seit = None
|
||||
if kamera.letzter_kontakt is not None:
|
||||
still_seit = round((jetzt - kamera.letzter_kontakt).total_seconds() / 60, 1)
|
||||
|
||||
return {
|
||||
"name": kamera.name,
|
||||
"kennung": kamera.slug,
|
||||
"standort": kamera.location,
|
||||
"aktiv": kamera.active,
|
||||
# 🔑 Die zwei Zeitpunkte bedeuten verschiedene Dinge und werden
|
||||
# deshalb beide geliefert: 'letzter_kontakt' heißt "der Detector
|
||||
# lebt", 'letzte_sichtung' heißt "zuletzt war ein Vogel da". An
|
||||
# einem ruhigen Nachmittag ist das eine frisch und das andere alt.
|
||||
"letzter_kontakt": (kamera.letzter_kontakt.isoformat()
|
||||
if kamera.letzter_kontakt else None),
|
||||
"still_seit_min": still_seit,
|
||||
"online": still_seit is not None and still_seit < grenze_min,
|
||||
"letzte_sichtung": (kamera.last_seen.isoformat()
|
||||
if kamera.last_seen else None),
|
||||
# ⚠️ Das Einzelbild vom DETECTOR, nicht von der Kamera. Die Kamera
|
||||
# erlaubt genau einen Zuschauer; klinkt sich Home Assistant dort
|
||||
# ein, verliert der Detector sein Bild und die Erkennung steht.
|
||||
"bild_url": kamera.live_bild_url if kamera.shows_live else None,
|
||||
# 🔑 Die Adresse des GERÄTS, nicht des Detectors. Signalstärke,
|
||||
# Temperatur und Bildrate kennt nur die Kamera selbst; wer sie
|
||||
# haben will, muss sie dort abholen. Über die Datenbank zu gehen
|
||||
# hieße, sie erst zu speichern — und dann wären sie alt.
|
||||
"geraet_url": kamera.stream_url or None,
|
||||
"erkennungsschwelle": kamera.detect_threshold,
|
||||
"mindest_vogelwert": kamera.min_bird_score,
|
||||
}
|
||||
|
||||
|
||||
@require_GET
|
||||
def zustand(request: HttpRequest) -> JsonResponse:
|
||||
"""Alles, was Vogelalbum über sich weiß — in einer Antwort.
|
||||
|
||||
Was hier bewusst FEHLT: Signalstärke, Chiptemperatur und Bildrate der
|
||||
Kameras. Die weiß nur das Gerät selbst, und es beantwortet sie unter
|
||||
``/status`` auf seinem Diagnose-Port. Sie hier durchzureichen hieße,
|
||||
sie erst in die Datenbank zu schreiben — und dann wären sie alt.
|
||||
"""
|
||||
if not _lesen_erlaubt(request):
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
|
||||
from .models import Benachrichtigung, DexEntry, EarnedBadge
|
||||
|
||||
jetzt = timezone.now()
|
||||
grenze_min = Benachrichtigung.laden().stille_minuten
|
||||
|
||||
fortschritt = scoring.progress()
|
||||
heute = jetzt.astimezone().date()
|
||||
|
||||
letzte = (
|
||||
Sighting.objects.select_related("species", "confirmed_species", "camera")
|
||||
.exclude(species__isnull=True, confirmed_species__isnull=True)
|
||||
.order_by("-started_at")
|
||||
.first()
|
||||
)
|
||||
letztes_abzeichen = (
|
||||
EarnedBadge.objects.select_related("badge")
|
||||
.order_by("-earned_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
def bild(feld):
|
||||
return request.build_absolute_uri(feld.url) if feld else None
|
||||
|
||||
return JsonResponse({
|
||||
"kameras": [
|
||||
_kamera_zustand(k, grenze_min, jetzt)
|
||||
for k in Camera.objects.order_by("name")
|
||||
],
|
||||
"sammlung": {
|
||||
"arten_gesammelt": fortschritt.species_seen,
|
||||
"arten_gesamt": fortschritt.species_total,
|
||||
"sichtungen_gesamt": fortschritt.sightings,
|
||||
# ⚠️ bestimmt(): sonst meldet Home Assistant Spiegelungen als Besuch.
|
||||
"sichtungen_heute": Sighting.objects.bestimmt().filter(
|
||||
started_at__date=heute).count(),
|
||||
"abzeichen": fortschritt.badges,
|
||||
"punkte": fortschritt.xp,
|
||||
"stufe": fortschritt.level,
|
||||
"rang": fortschritt.rank,
|
||||
"fortschritt_stufe": round(fortschritt.level_progress, 3),
|
||||
},
|
||||
"letzte_sichtung": ({
|
||||
"art": letzte.effective_species.german,
|
||||
"art_wissenschaftlich": letzte.effective_species.scientific,
|
||||
"kamera": letzte.camera.slug if letzte.camera else None,
|
||||
"zeitpunkt": letzte.started_at.isoformat(),
|
||||
"sicherheit": round(letzte.confidence, 3),
|
||||
"neue_art": letzte.is_first_of_species,
|
||||
"bild": bild(letzte.crop) or bild(letzte.image),
|
||||
} if letzte else None),
|
||||
"letztes_abzeichen": ({
|
||||
"name": letztes_abzeichen.badge.name,
|
||||
"symbol": letztes_abzeichen.badge.emoji,
|
||||
"zeitpunkt": letztes_abzeichen.earned_at.isoformat(),
|
||||
} if letztes_abzeichen else None),
|
||||
"stand": jetzt.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fotos von unterwegs: hochladen, abholen, Ergebnis nachtragen
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# 🔑 **Warum nicht über `ingest`.** Der Ingest nimmt eine FERTIGE Sichtung
|
||||
# entgegen: Art, Vogelwert, Bildanzahl — alles schon bestimmt. Ein Foto vom
|
||||
# Spaziergang hat nichts davon, es wartet ja gerade erst auf die Bestimmung.
|
||||
# Beides in einen Endpunkt zu zwingen hieße, überall Sonderfälle zu prüfen;
|
||||
# insbesondere würde die Regel „Vogelwert 0 wird abgewiesen" jedes frisch
|
||||
# hochgeladene Foto verwerfen, weil es den Wert zwangsläufig noch nicht hat.
|
||||
#
|
||||
# 🔑 **Die Richtung bleibt, wie sie ist.** Der Detector holt sich die Arbeit
|
||||
# ab, sie wird ihm nicht zugestellt. Damit braucht er weiterhin keine
|
||||
# erreichbare Adresse und keinen offenen Port — dieselbe Mechanik wie bei
|
||||
# `fernkonfig.py`, die ihn alle fünf Minuten nach seiner Konfiguration fragen
|
||||
# lässt.
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def hochladen(request: HttpRequest) -> JsonResponse:
|
||||
"""Ein Foto zur Bestimmung annehmen. Von der App oder vom Formular.
|
||||
|
||||
Es entsteht eine Sichtung mit ``wartet_auf_bestimmung``, ohne Art und ohne
|
||||
Vogelwert. Den trägt der Detector nach, wenn er sie abgeholt hat.
|
||||
"""
|
||||
auth = _authenticate(request)
|
||||
if auth is None:
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
camera = auth if isinstance(auth, Camera) else None
|
||||
|
||||
bilder = request.FILES.getlist("bilder") or request.FILES.getlist("bild")
|
||||
if not bilder:
|
||||
return JsonResponse({"fehler": "kein Bild dabei"}, status=400)
|
||||
|
||||
try:
|
||||
angelegt = fotos_annehmen(
|
||||
bilder,
|
||||
camera=camera,
|
||||
aufgenommen=_parse_dt(request.POST.get("aufgenommen_am")),
|
||||
latitude=request.POST.get("latitude"),
|
||||
longitude=request.POST.get("longitude"),
|
||||
)
|
||||
except KeinBild as fehler:
|
||||
return JsonResponse({"fehler": str(fehler)}, status=400)
|
||||
return JsonResponse(
|
||||
{"angelegt": angelegt, "wartend": Sighting.objects.wartend().count()},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
def fotos_annehmen(dateien, camera=None, aufgenommen=None,
|
||||
latitude=None, longitude=None) -> list[int]:
|
||||
"""Legt je Bild eine wartende Sichtung an. Gibt die Kennungen zurück.
|
||||
|
||||
🔑 Eine Stelle für zwei Wege: die Schnittstelle für die App und das
|
||||
Formular in der Web-App. Zwei Kopien dieser Logik würden auseinander
|
||||
laufen, und zwar unbemerkt — der eine Weg legte dann Sichtungen an, die
|
||||
der andere nicht mehr findet.
|
||||
|
||||
⚠️ Je Bild EINE Sichtung. Von einem Spaziergang kommen zehn Fotos mit
|
||||
womöglich fünf Arten; eine Sichtung bleibt, was sie immer war — ein Vogel
|
||||
zu einer Zeit an einem Ort. Alles andere brächte ``bestimmt()``, die
|
||||
Abzeichen und den Erstfang durcheinander.
|
||||
"""
|
||||
aufgenommen = aufgenommen or timezone.now()
|
||||
breite = _koordinate(latitude, 90.0)
|
||||
laenge = _koordinate(longitude, 180.0)
|
||||
if breite is None or laenge is None:
|
||||
breite = laenge = None
|
||||
|
||||
# 🔑 **Erst alle Bilder säubern, dann speichern.** Von einem Spaziergang
|
||||
# kommen zehn Fotos auf einmal; wäre das achte unbrauchbar, stünden sonst
|
||||
# sieben Sichtungen in der Datenbank und der Hochladende bekäme einen
|
||||
# Fehler — er wüsste nicht, was angekommen ist, und lüde alles noch
|
||||
# einmal hoch. Entweder alle oder keins.
|
||||
sauber = [saeubern(d) for d in dateien]
|
||||
|
||||
angelegt = []
|
||||
for datei in sauber:
|
||||
s = Sighting(
|
||||
camera=camera,
|
||||
started_at=aufgenommen,
|
||||
ended_at=aufgenommen,
|
||||
# Ein Foto ist ein Bild — die Zählwerte des Dauerbetriebs haben
|
||||
# hier keine Entsprechung und bleiben bei null, bis der Detector
|
||||
# sein Ergebnis nachträgt.
|
||||
frames_total=1,
|
||||
wartet_auf_bestimmung=True,
|
||||
latitude=breite,
|
||||
longitude=laenge,
|
||||
)
|
||||
s.image = datei
|
||||
s.save()
|
||||
angelegt.append(s.id)
|
||||
|
||||
if camera is not None:
|
||||
camera.last_seen = timezone.now()
|
||||
camera.letzter_kontakt = timezone.now()
|
||||
camera.save(update_fields=["last_seen", "letzter_kontakt"])
|
||||
return angelegt
|
||||
|
||||
|
||||
@require_GET
|
||||
def offen(request: HttpRequest) -> JsonResponse:
|
||||
"""Was der Detector noch zu bestimmen hat. Älteste zuerst.
|
||||
|
||||
Geliefert werden Kennung und Bildadresse — das Bild selbst holt der
|
||||
Detector über die normale Medienadresse, die ohnehin ausgeliefert wird.
|
||||
"""
|
||||
if not _lesen_erlaubt(request):
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
|
||||
try:
|
||||
grenze = min(int(request.GET.get("anzahl", 20)), 100)
|
||||
except (TypeError, ValueError):
|
||||
grenze = 20
|
||||
|
||||
offene = Sighting.objects.wartend().select_related("camera")[:grenze]
|
||||
return JsonResponse({
|
||||
"fotos": [
|
||||
{
|
||||
"id": s.id,
|
||||
"bild": request.build_absolute_uri(s.image.url) if s.image else None,
|
||||
"aufgenommen_am": s.started_at.isoformat(),
|
||||
"kamera": s.camera.slug if s.camera else None,
|
||||
}
|
||||
for s in offene
|
||||
],
|
||||
"wartend_gesamt": Sighting.objects.wartend().count(),
|
||||
})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse:
|
||||
"""Das Ergebnis des Detectors zu einem hochgeladenen Foto.
|
||||
|
||||
⚠️ Der Wartezustand fällt IMMER weg, auch wenn nichts erkannt wurde. Sonst
|
||||
bliebe ein Foto ohne Vogel ewig in der Warteschlange, der Detector würde es
|
||||
bei jedem Durchgang erneut bearbeiten, und der Wächter meldete einen
|
||||
Stillstand, den es nicht gibt.
|
||||
"""
|
||||
auth = _authenticate(request)
|
||||
if auth is None:
|
||||
return JsonResponse({"fehler": "nicht autorisiert"}, status=401)
|
||||
|
||||
sighting = Sighting.objects.filter(
|
||||
id=sichtung_id, wartet_auf_bestimmung=True).first()
|
||||
if sighting is None:
|
||||
return JsonResponse(
|
||||
{"fehler": "keine wartende Sichtung mit dieser Kennung"}, status=404)
|
||||
|
||||
raw = request.POST.get("data") or "{}"
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
return JsonResponse({"fehler": f"ungültiges JSON: {exc}"}, status=400)
|
||||
|
||||
species = None
|
||||
slug = payload.get("species")
|
||||
if slug:
|
||||
species = Species.objects.filter(slug=slug).first()
|
||||
if species is None:
|
||||
return JsonResponse(
|
||||
{"fehler": f"unbekannte Art '{slug}'"}, status=400)
|
||||
|
||||
sighting.species = species
|
||||
sighting.confidence = float(payload.get("confidence") or 0.0)
|
||||
sighting.mean_bird_score = float(payload.get("mean_bird_score") or 0.0)
|
||||
sighting.votes = payload.get("votes") or {}
|
||||
# 🔑 **Ein Vogel ohne Art ist trotzdem ein Vogel.** Hier stand
|
||||
# ``1 if species else 0`` — damit galt ein sicher erkannter Vogel, dessen
|
||||
# Art sich nicht bestimmen ließ (Gegenlicht, Silhouette), als „nichts auf
|
||||
# dem Bild". Die Unterscheidung ist genau die, auf der das ganze Modul
|
||||
# beruht: Artsicherheit 0 heißt „welche Art?", Vogelwert 0 heißt „gar kein
|
||||
# Vogel". Siehe die Begründung bei ``ingest``.
|
||||
sighting.frames_with_bird = 1 if (species or sighting.mean_bird_score > 0) else 0
|
||||
_box_uebernehmen(sighting, payload)
|
||||
sighting.wartet_auf_bestimmung = False
|
||||
if "crop" in request.FILES:
|
||||
sighting.crop = request.FILES["crop"]
|
||||
sighting.save()
|
||||
|
||||
# Punkte und Abzeichen erst jetzt — vorher war ja nicht bekannt, ob
|
||||
# überhaupt ein Vogel auf dem Bild ist.
|
||||
reward = scoring.register(sighting) if species else None
|
||||
if reward is not None:
|
||||
benachrichtigung.melde_sichtung(sighting, reward.is_first)
|
||||
|
||||
return JsonResponse({
|
||||
"id": sighting.id,
|
||||
"art": species.german if species else None,
|
||||
"xp": reward.xp if reward else 0,
|
||||
"erstfang": reward.is_first if reward else False,
|
||||
"abzeichen": [b.name for b in reward.badges] if reward else [],
|
||||
})
|
||||
Reference in new issue
Block a user