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:
1 parent
dbec78aa7a
commit
f572c20080
9 files changed
+285
-8
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user