From f572c200808ca74ff64b5db66ebc66154a3e5094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=A4rtner?= Date: Wed, 14 Oct 2026 18:16:08 +0000 Subject: [PATCH] HEIC-Fotos annehmen und den Ausschnitt weiter fassen iPhones fotografieren HEIC; ohne pillow-heif wies die Bildpruefung jedes Foto ab. Dazu: ein erkannter Vogel ohne bestimmbare Art wird nicht mehr als "kein Vogel" gemeldet, und der Ausschnitt fuer die Sammlung ist quadratisch. --- detector/classify.py | 44 ++++++++++++++++++++++++ detector/warteschlange.py | 35 ++++++++++++++++--- detector/watcher.py | 5 ++- requirements-web.txt | 3 ++ tests/test_detector.py | 72 +++++++++++++++++++++++++++++++++++++++ web/dex/api.py | 8 ++++- web/dex/bilder.py | 25 ++++++++++++-- web/dex/test_app_api.py | 63 ++++++++++++++++++++++++++++++++++ web/dex/test_bilder.py | 38 +++++++++++++++++++++ 9 files changed, 285 insertions(+), 8 deletions(-) diff --git a/detector/classify.py b/detector/classify.py index 19f251e..34bfafa 100644 --- a/detector/classify.py +++ b/detector/classify.py @@ -68,6 +68,50 @@ class Box: return img return img.crop((x0, y0, x1, y1)) + def anzeige_ausschnitt(self, img: Image.Image, pad: float = 0.35) -> Image.Image: + """Derselbe Vogel, aber als Bild zum Ansehen statt zum Bestimmen. + + 🔑 **Der Klassifikator und das Auge wollen Verschiedenes.** ``crop`` + liegt eng am Vogel, weil jedes Stück Hintergrund die Bestimmung + verwässert. Für die Sammlung ist genau das falsch: Dort steht der + Ausschnitt in einer fast quadratischen Kachel, und die schneidet links + und rechts weg, was über das Quadrat hinausragt. + + Bei einem Vogel im Hochformat — und so sitzt fast jeder — bleibt davon + die Bauchmitte übrig, formatfüllend und unscharf. Christian am + 14.10.2026 über seine erste Amsel: *„Der Zuschnitt der Fotos ist auch + etwas merkwürdig."* Er hatte recht: Vom Vogel war nur Gefieder zu + sehen, Kopf und Schwanz lagen außerhalb. + + Deshalb hier mehr Rand **und** ein Ausschnitt in Richtung Quadrat: Was + die Kachel abschneidet, ist dann Umgebung statt Vogel. + + ⚠️ Absichtlich eine eigene Methode. ``crop`` hier zu ändern hieße, die + Artbestimmung zu ändern — und die ist gemessen und soll so bleiben. + """ + w, h = img.size + bx0, by0 = self.x0 * w, self.y0 * h + bx1, by1 = self.x1 * w, self.y1 * h + bw, bh = bx1 - bx0, by1 - by0 + if bw <= 0 or bh <= 0: + return img + + # Erst Rand, dann die kürzere Seite auf die längere ziehen. + seite = max(bw, bh) * (1 + 2 * pad) + mx, my = (bx0 + bx1) / 2, (by0 + by1) / 2 + + # ⚠️ Nicht über das Bild hinaus, und die Mitte nachführen statt den + # Ausschnitt zu stauchen — sonst sitzt ein Vogel am Bildrand + # außermittig in seiner eigenen Kachel. + seite = min(seite, float(w), float(h)) + mx = min(max(mx, seite / 2), w - seite / 2) + my = min(max(my, seite / 2), h - seite / 2) + + return img.crop(( + int(mx - seite / 2), int(my - seite / 2), + int(mx + seite / 2), int(my + seite / 2), + )) + @property def area(self) -> float: return max(0.0, self.x1 - self.x0) * max(0.0, self.y1 - self.y0) diff --git a/detector/warteschlange.py b/detector/warteschlange.py index 7d483b7..354696c 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 [])}, } @@ -161,8 +185,11 @@ class Warteschlange: box, result = self.pipeline.run(bild) # 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) + # ⚠️ `anzeige_ausschnitt`, nicht `crop`: Der enge Schnitt ist + # fuer den Klassifikator gedacht; in der fast quadratischen + # Kachel der Sammlung bleibt davon nur die Bauchmitte uebrig. + ausschnitt = box.anzeige_ausschnitt(bild) if box is not None else None + 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/detector/watcher.py b/detector/watcher.py index cab6514..b778982 100644 --- a/detector/watcher.py +++ b/detector/watcher.py @@ -221,12 +221,15 @@ class Watcher: # Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein # gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger # als ein leicht weicheres, auf dem er ganz zu sehen ist. + # Schaerfe am ENGEN Ausschnitt messen: Umgebung soll die Bewertung + # nicht verwaessern. Gespeichert wird dann der weitere -- siehe + # Box.anzeige_ausschnitt. crop = box.crop(frame) score = sharpness(crop) * (0.5 + 0.5 * box.score) if score > self._best_sharp: self._best_sharp = score self._best_image = frame.copy() - self._best_crop = crop + self._best_crop = box.anzeige_ausschnitt(frame) def _vote(self, box: Box, result: Result) -> None: # Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild, 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/tests/test_detector.py b/tests/test_detector.py index d27ceef..87360be 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -885,3 +885,75 @@ class UebernimmTests(unittest.TestCase): from detector.species_de import catalog self.assertEqual(self.pipeline.uebernimm(arten=catalog({"garten"})), []) + + +class AnzeigeAusschnittTests(unittest.TestCase): + """🔑 Der Klassifikator und das Auge wollen Verschiedenes. + + ``crop`` liegt eng am Vogel, weil Hintergrund die Bestimmung verwaessert. + In der fast quadratischen Kachel der Sammlung schneidet genau das links + und rechts den Vogel weg — bei einem Vogel im Hochformat bleibt die + Bauchmitte uebrig. Christian am 14.10.2026: „Der Zuschnitt der Fotos ist + auch etwas merkwuerdig." + """ + + def bild(self, groesse=(1800, 2400)): + from PIL import Image + + return Image.new("RGB", groesse, (120, 160, 200)) + + def test_anzeige_ist_quadratisch(self): + from detector.classify import Box + + # Ein hochkant sitzender Vogel, schmal und hoch. + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + anzeige = box.anzeige_ausschnitt(im) + + self.assertLess(eng.width / eng.height, 0.7, "Testbox ist nicht hochkant") + self.assertAlmostEqual(anzeige.width / anzeige.height, 1.0, places=1) + + def test_anzeige_enthaelt_den_ganzen_vogel(self): + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + anzeige = box.anzeige_ausschnitt(im) + # Mindestens so hoch wie der Vogel plus Rand. + vogel_hoehe = (0.62 - 0.30) * im.height + self.assertGreater(anzeige.height, vogel_hoehe) + + def test_crop_bleibt_unveraendert_eng(self): + """Die Artbestimmung ist gemessen und darf sich nicht mitaendern.""" + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + erwartet_breite = (0.52 - 0.40) * im.width * 1.24 # pad 0.12 beidseits + self.assertAlmostEqual(eng.width, erwartet_breite, delta=3) + + def test_am_bildrand_bleibt_der_ausschnitt_im_bild(self): + """⚠️ Sonst sitzt ein Vogel am Rand ausserhalb seiner eigenen Kachel.""" + from detector.classify import Box + + im = self.bild() + for box in ( + Box(x0=0.00, y0=0.00, x1=0.08, y1=0.10, score=0.9), + Box(x0=0.92, y0=0.90, x1=1.00, y1=1.00, score=0.9), + ): + with self.subTest(box=box): + a = box.anzeige_ausschnitt(im) + self.assertGreater(a.width, 0) + self.assertLessEqual(a.width, im.width) + self.assertLessEqual(a.height, im.height) + self.assertAlmostEqual(a.width / a.height, 1.0, places=1) + + def test_winziges_bild_sprengt_nichts(self): + from detector.classify import Box + + im = self.bild((60, 40)) + a = Box(x0=0.3, y0=0.3, x1=0.7, y1=0.7, score=0.9).anzeige_ausschnitt(im) + self.assertLessEqual(a.width, 60) + self.assertLessEqual(a.height, 40) 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)