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

+614
View File
@@ -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 [],
})