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.
This commit is contained in:
christian committed 2026-10-14 18:16:08 +00:00
1 parent dbec78aa7a
commit f572c20080
9 files changed
+285 -8

No files matched your search

+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.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"]
+23 -2
View File
@@ -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:
+63
View File
@@ -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)
+38
View File
@@ -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)