From f7184d193cca73d0f59cd29bc8f19e562e9bb532 Mon Sep 17 00:00:00 2001 From: Artemis Date: Wed, 14 Oct 2026 18:09:06 +0000 Subject: [PATCH] HEIC annehmen, und "Vogel ohne Art" nicht als "kein Vogel" melden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- detector/warteschlange.py | 30 +++++++++++++++++-- requirements-web.txt | 3 ++ web/dex/api.py | 8 ++++- web/dex/bilder.py | 25 ++++++++++++++-- web/dex/test_app_api.py | 63 +++++++++++++++++++++++++++++++++++++++ web/dex/test_bilder.py | 38 +++++++++++++++++++++++ 6 files changed, 161 insertions(+), 6 deletions(-) diff --git a/detector/warteschlange.py b/detector/warteschlange.py index 7d483b7..0ae1007 100644 --- a/detector/warteschlange.py +++ b/detector/warteschlange.py @@ -88,17 +88,41 @@ class Warteschlange: # -- Bearbeiten ------------------------------------------------------- - def _melden(self, foto_id: int, ausschnitt, result) -> None: + 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. 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 = { "species": bester.species.slug if bester else None, "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) for g in (result.guesses[:5] if result else [])}, } @@ -162,7 +186,7 @@ class Warteschlange: # Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild # zeigt wie bei einer Sichtung von der Kamera. 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 except Exception as exc: # absichtlich breit, siehe Docstring print(f"[warteschlange] Foto {foto['id']} übersprungen: {exc}") diff --git a/requirements-web.txt b/requirements-web.txt index bbb0924..36807a8 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,5 +1,8 @@ # Web-App: bewusst ohne numpy/LiteRT, damit der Container klein bleibt. Django>=5.1 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 gunicorn>=22.0 diff --git a/web/dex/api.py b/web/dex/api.py index 9cc6c1f..f2660c4 100644 --- a/web/dex/api.py +++ b/web/dex/api.py @@ -560,7 +560,13 @@ def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse: 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 {} - 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 if "crop" in request.FILES: sighting.crop = request.FILES["crop"] diff --git a/web/dex/bilder.py b/web/dex/bilder.py index f05035a..e24aaa8 100644 --- a/web/dex/bilder.py +++ b/web/dex/bilder.py @@ -28,6 +28,24 @@ import io from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile 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 # 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 @@ -40,7 +58,7 @@ MAX_KANTE = 2400 # Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein # 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): @@ -65,7 +83,10 @@ def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFi # unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten. bild.verify() 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) try: diff --git a/web/dex/test_app_api.py b/web/dex/test_app_api.py index 6e2a62a..7d1b276 100644 --- a/web/dex/test_app_api.py +++ b/web/dex/test_app_api.py @@ -579,3 +579,66 @@ class AbzeichenTests(AppTestBasis): 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) diff --git a/web/dex/test_bilder.py b/web/dex/test_bilder.py index eec91fe..8f7c264 100644 --- a/web/dex/test_bilder.py +++ b/web/dex/test_bilder.py @@ -191,3 +191,41 @@ class UploadWegTests(TestCase): from .models import Sighting 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)