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.
615 lines
25 KiB
Python
615 lines
25 KiB
Python
"""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 [],
|
|
})
|