HEIC annehmen, und "Vogel ohne Art" nicht als "kein Vogel" melden

ZWEI FEHLER, BEIDE HEUTE ENTSTANDEN, BEIDE BEIM BENUTZEN GEFUNDEN.

1. 🔴 HEIC. Minuten nach dem Ausrollen der Bildpruefung wies die Instanz
   jedes Foto von Christians iPhone mit 400 ab. Die Pruefung war richtig --
   sie kannte nur das Format nicht, das die Haelfte aller Telefone erzeugt.
   Eine App fuer iOS, die kein HEIC annimmt, nimmt nichts an.

   pillow-heif in requirements-web.txt, Opener registriert, AVIF gleich mit.
   Fehlt das Paket, sagt die Meldung das jetzt ausdruecklich -- sonst sucht
   man den Fehler beim Telefon, und er liegt an einem fehlenden Paket auf
   dem Server.

2. 🔴 "VOGEL JA, ART NEIN" KAM ALS "KEIN VOGEL" AN. Pipeline.run gibt in
   drei Faellen kein Ergebnis zurueck: nichts gefunden, Beinahe-Treffer, und
   -- der interessante -- ein sicher erkannter Vogel, dessen Art keine
   heimische ist. Die Box unterscheidet die Faelle; die Warteschlange hat sie
   weggeworfen und 0.0 gemeldet.

   Folge: Ein Foto mit einem Vogel, den der Detektor mit 93 % gefunden hatte,
   kam in der App als "Kein Vogel erkannt. Auf dem Foto war nichts zu finden"
   an. Christian hat daraufhin zugeschnitten, wieder und wieder -- gegen ein
   Problem, das nie der Zuschnitt war.

   🔑 EINE FALSCHE AUSKUNFT IST TEURER ALS GAR KEINE. Sie schickt jemanden
   mit voller Ueberzeugung in die falsche Richtung.

   Jetzt traegt mean_bird_score den Wert der Box, und frames_with_bird haengt
   am Vogel statt an der Art.

7 neue Tests. 235 Web + 131 Detector gruen.
This commit is contained in:
Artemis committed 2026-10-14 18:09:06 +00:00
1 parent dbec78aa7a
commit f7184d193c
6 files changed
+161 -6

No files matched your search

+27 -3
View File
@@ -88,17 +88,41 @@ class Warteschlange:
# -- Bearbeiten ------------------------------------------------------- # -- Bearbeiten -------------------------------------------------------
def _melden(self, foto_id: int, ausschnitt, result) -> None: def _melden(self, foto_id: int, ausschnitt, result, box=None) -> None:
import requests import requests
# ⚠️ ``Result.best`` ist eine Property, kein Aufruf — ``result.best()`` # ⚠️ ``Result.best`` ist eine Property, kein Aufruf — ``result.best()``
# scheitert erst zur Laufzeit und genau dann, wenn ein Vogel # scheitert erst zur Laufzeit und genau dann, wenn ein Vogel
# erkannt wurde. # erkannt wurde.
bester = result.best if result else None 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. Christian hat
# daraufhin zugeschnitten — 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 = { nutzlast = {
"species": bester.species.slug if bester else None, "species": bester.species.slug if bester else None,
"confidence": round(bester.confidence, 4) if bester else 0.0, "confidence": round(bester.confidence, 4) if bester else 0.0,
"mean_bird_score": round(result.bird_score, 4) if result else 0.0, "mean_bird_score": round(vogelwert, 4),
"votes": {g.species.slug: round(g.confidence, 4) "votes": {g.species.slug: round(g.confidence, 4)
for g in (result.guesses[:5] if result else [])}, for g in (result.guesses[:5] if result else [])},
} }
@@ -162,7 +186,7 @@ class Warteschlange:
# Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild # Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild
# zeigt wie bei einer Sichtung von der Kamera. # zeigt wie bei einer Sichtung von der Kamera.
ausschnitt = box.crop(bild) if box is not None else None ausschnitt = box.crop(bild) if box is not None else None
self._melden(foto["id"], ausschnitt, result) self._melden(foto["id"], ausschnitt, result, box)
fertig += 1 fertig += 1
except Exception as exc: # absichtlich breit, siehe Docstring except Exception as exc: # absichtlich breit, siehe Docstring
print(f"[warteschlange] Foto {foto['id']} übersprungen: {exc}") print(f"[warteschlange] Foto {foto['id']} übersprungen: {exc}")
+3
View File
@@ -1,5 +1,8 @@
# Web-App: bewusst ohne numpy/LiteRT, damit der Container klein bleibt. # Web-App: bewusst ohne numpy/LiteRT, damit der Container klein bleibt.
Django>=5.1 Django>=5.1
pillow>=11.0 pillow>=11.0
# iPhones fotografieren HEIC. Ohne das Paket weist die Bildpruefung
# jedes Foto vom Telefon ab -- siehe web/dex/bilder.py.
pillow-heif>=1.0
whitenoise>=6.7 whitenoise>=6.7
gunicorn>=22.0 gunicorn>=22.0
+7 -1
View File
@@ -560,7 +560,13 @@ def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse:
sighting.confidence = float(payload.get("confidence") or 0.0) sighting.confidence = float(payload.get("confidence") or 0.0)
sighting.mean_bird_score = float(payload.get("mean_bird_score") or 0.0) sighting.mean_bird_score = float(payload.get("mean_bird_score") or 0.0)
sighting.votes = payload.get("votes") or {} sighting.votes = payload.get("votes") or {}
sighting.frames_with_bird = 1 if species else 0 # 🔑 **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
sighting.wartet_auf_bestimmung = False sighting.wartet_auf_bestimmung = False
if "crop" in request.FILES: if "crop" in request.FILES:
sighting.crop = request.FILES["crop"] sighting.crop = request.FILES["crop"]
+23 -2
View File
@@ -28,6 +28,24 @@ import io
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from PIL import Image, ImageOps, UnidentifiedImageError from PIL import Image, ImageOps, UnidentifiedImageError
# 🔴 **Ohne das kann Pillow kein HEIC — und iPhones fotografieren HEIC.**
#
# Am 14.10.2026 wies die Instanz jedes Foto von Christians iPhone mit 400 ab,
# Minuten nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur
# kannte sie das Format nicht, das die Hälfte aller Telefone erzeugt. Eine
# App für iOS, die kein HEIC annimmt, nimmt nichts an.
#
# ⚠️ Der Import darf das Hochladen nicht zum Absturz bringen, wenn das Paket
# in einer schlanken Umgebung fehlt — dann bleibt es bei JPEG und PNG, und die
# Meldung an den Nutzer sagt, welches Format es war.
try: # pragma: no cover - haengt an der Umgebung, nicht am Code
import pillow_heif
pillow_heif.register_heif_opener()
HEIC_LESBAR = True
except ImportError: # pragma: no cover
HEIC_LESBAR = False
# ⚠️ Pillow öffnet sehr große Bilder bereitwillig und belegt dabei Speicher in # ⚠️ Pillow öffnet sehr große Bilder bereitwillig und belegt dabei Speicher in
# der Größenordnung Breite × Höhe × 4 Bytes. Ein Bild mit absurden Maßen in # der Größenordnung Breite × Höhe × 4 Bytes. Ein Bild mit absurden Maßen in
# einer winzigen Datei ist der billigste Weg, einen Dienst umzuwerfen # einer winzigen Datei ist der billigste Weg, einen Dienst umzuwerfen
@@ -40,7 +58,7 @@ MAX_KANTE = 2400
# Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein # Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein
# SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF. # SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF.
ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "MPO"} ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "AVIF", "MPO"}
class KeinBild(Exception): class KeinBild(Exception):
@@ -65,7 +83,10 @@ def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFi
# unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten. # unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten.
bild.verify() bild.verify()
except (UnidentifiedImageError, OSError, ValueError) as fehler: except (UnidentifiedImageError, OSError, ValueError) as fehler:
raise KeinBild(f"keine lesbare Bilddatei: {fehler}") from fehler # ⚠️ Den Hinweis auf HEIC mitgeben: Ohne ihn sucht man den Fehler beim
# Telefon, und er liegt an einem fehlenden Paket auf dem Server.
hinweis = "" if HEIC_LESBAR else " (HEIC wird hier nicht unterstützt — pillow-heif fehlt)"
raise KeinBild(f"keine lesbare Bilddatei{hinweis}: {fehler}") from fehler
datei.seek(0) datei.seek(0)
try: try:
+63
View File
@@ -579,3 +579,66 @@ class AbzeichenTests(AppTestBasis):
self.assertLessEqual(z["ist"], z["ziel"]) self.assertLessEqual(z["ist"], z["ziel"])
class VogelOhneArtTests(AppTestBasis):
"""🔴 „Vogel ja, Art nein" darf nicht als „kein Vogel" ankommen.
Am 14.10.2026 erkannte der Detektor auf einem Handyfoto mit 93 % einen
Vogel, konnte die Art aber nicht bestimmen (Gegenlicht, Silhouette). In
der App stand „Kein Vogel erkannt. Auf dem Foto war nichts zu finden" —
und Christian hat daraufhin zugeschnitten, gegen ein Problem, das nie der
Zuschnitt war. Eine falsche Auskunft ist teurer als gar keine.
"""
def setUp(self):
super().setUp()
self.kamera = make_camera(token="kamera-geheim", mobil=True)
def _wartendes_foto(self):
jetzt = timezone.now()
s = Sighting.objects.create(
camera=self.kamera, started_at=jetzt, ended_at=jetzt,
frames_total=1, wartet_auf_bestimmung=True,
)
return s
def melde(self, s, nutzlast):
return self.client.post(
f"/api/offen/{s.id}/",
{"data": json.dumps(nutzlast)},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
def test_vogel_ohne_art_behaelt_seinen_vogelwert(self):
s = self._wartendes_foto()
resp = self.melde(s, {"species": None, "confidence": 0.0,
"mean_bird_score": 0.93})
self.assertEqual(resp.status_code, 200, resp.content)
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertFalse(daten["wartet"])
self.assertIsNone(daten["art"])
# Das ist der Unterschied, an dem die App ihren Text waehlt.
self.assertAlmostEqual(daten["vogelwert"], 0.93, places=3)
def test_wirklich_kein_vogel_bleibt_bei_null(self):
s = self._wartendes_foto()
self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.0})
daten = self.json(f"/api/app/sichtungen/{s.id}/")
self.assertIsNone(daten["art"])
self.assertEqual(daten["vogelwert"], 0.0)
def test_vogel_ohne_art_zaehlt_als_bild_mit_vogel(self):
"""``frames_with_bird`` hing an der ART statt am Vogel."""
s = self._wartendes_foto()
self.melde(s, {"species": None, "mean_bird_score": 0.93})
s.refresh_from_db()
self.assertEqual(s.frames_with_bird, 1)
def test_ohne_vogel_kein_bild_mit_vogel(self):
s = self._wartendes_foto()
self.melde(s, {"species": None, "mean_bird_score": 0.0})
s.refresh_from_db()
self.assertEqual(s.frames_with_bird, 0)
+38
View File
@@ -191,3 +191,41 @@ class UploadWegTests(TestCase):
from .models import Sighting from .models import Sighting
self.assertEqual(Sighting.objects.count(), 0) self.assertEqual(Sighting.objects.count(), 0)
class HeicTests(TestCase):
"""🔴 iPhones fotografieren HEIC — eine App für iOS muss es annehmen.
Am 14.10.2026 wies die Instanz jedes Foto vom Telefon mit 400 ab, Minuten
nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur kannte
sie das Format nicht, das die Hälfte aller Telefone erzeugt.
"""
def heic(self, groesse=(400, 300)) -> SimpleUploadedFile:
im = Image.new("RGB", groesse, (90, 140, 70))
puffer = io.BytesIO()
im.save(puffer, format="HEIF")
return SimpleUploadedFile("IMG_0042.HEIC", puffer.getvalue(), "image/heic")
def test_heic_wird_angenommen_und_zu_jpeg(self):
from .bilder import HEIC_LESBAR
self.assertTrue(HEIC_LESBAR, "pillow-heif fehlt — iPhone-Fotos gehen nicht")
sauber = saeubern(self.heic())
self.assertEqual(gelesen(sauber).format, "JPEG")
self.assertTrue(sauber.name.endswith(".jpg"))
def test_heic_verliert_seine_metadaten_genauso(self):
sauber = saeubern(self.heic())
self.assertFalse(dict(gelesen(sauber).getexif()))
def test_heic_ueber_den_app_weg(self):
from .tests import make_camera
make_camera(token="kamera-geheim", mobil=True)
resp = self.client.post(
"/api/hochladen/",
{"bilder": self.heic()},
HTTP_AUTHORIZATION="Bearer kamera-geheim",
)
self.assertEqual(resp.status_code, 201, resp.content)