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

+44
View File
@@ -68,6 +68,50 @@ class Box:
return img return img
return img.crop((x0, y0, x1, y1)) 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 @property
def area(self) -> float: def area(self) -> float:
return max(0.0, self.x1 - self.x0) * max(0.0, self.y1 - self.y0) return max(0.0, self.x1 - self.x0) * max(0.0, self.y1 - self.y0)
+31 -4
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 [])},
} }
@@ -161,8 +185,11 @@ class Warteschlange:
box, result = self.pipeline.run(bild) box, result = self.pipeline.run(bild)
# 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 # ⚠️ `anzeige_ausschnitt`, nicht `crop`: Der enge Schnitt ist
self._melden(foto["id"], ausschnitt, result) # 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 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}")
+4 -1
View File
@@ -221,12 +221,15 @@ class Watcher:
# Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein # Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein
# gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger # gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger
# als ein leicht weicheres, auf dem er ganz zu sehen ist. # 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) crop = box.crop(frame)
score = sharpness(crop) * (0.5 + 0.5 * box.score) score = sharpness(crop) * (0.5 + 0.5 * box.score)
if score > self._best_sharp: if score > self._best_sharp:
self._best_sharp = score self._best_sharp = score
self._best_image = frame.copy() self._best_image = frame.copy()
self._best_crop = crop self._best_crop = box.anzeige_ausschnitt(frame)
def _vote(self, box: Box, result: Result) -> None: def _vote(self, box: Box, result: Result) -> None:
# Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild, # Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild,
+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
+72
View File
@@ -885,3 +885,75 @@ class UebernimmTests(unittest.TestCase):
from detector.species_de import catalog from detector.species_de import catalog
self.assertEqual(self.pipeline.uebernimm(arten=catalog({"garten"})), []) 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)
+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)