Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
232 lines
8.7 KiB
Python
232 lines
8.7 KiB
Python
"""Tests für das Säubern hochgeladener Bilder.
|
|
|
|
🔑 Der Kern ist **nicht** „erkennt kaputte Dateien", sondern **„gibt nur
|
|
weiter, was wirklich ein Bild war"**. Eine Prüfung lässt alles stehen, was sie
|
|
nicht kennt; das Neuschreiben nimmt nur die Pixel mit. Die Tests hier zielen
|
|
deshalb auf das, was NICHT mehr in der gespeicherten Datei steht.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.test import TestCase, override_settings
|
|
from PIL import Image
|
|
|
|
from .bilder import MAX_KANTE, KeinBild, saeubern
|
|
|
|
|
|
def bild_mit_exif(gps=True, drehung=None, groesse=(80, 50)) -> SimpleUploadedFile:
|
|
"""Ein JPEG mit EXIF — mit Ort, wie es ein Handy liefert."""
|
|
im = Image.new("RGB", groesse, (90, 140, 70))
|
|
exif = im.getexif()
|
|
exif[0x010E] = "Testaufnahme" # ImageDescription
|
|
if drehung is not None:
|
|
exif[0x0112] = drehung # Orientation
|
|
if gps:
|
|
# 52,52 N / 13,405 O. Pillow erwartet Grad, Minuten, Sekunden als
|
|
# einfache Zahlen — ein verschachteltes Zähler/Nenner-Paar lehnt es ab.
|
|
exif[0x8825] = {
|
|
1: "N",
|
|
2: (52.0, 31.0, 12.0),
|
|
3: "E",
|
|
4: (13.0, 24.0, 18.0),
|
|
}
|
|
|
|
puffer = io.BytesIO()
|
|
im.save(puffer, format="JPEG", exif=exif)
|
|
return SimpleUploadedFile("urlaub.jpg", puffer.getvalue(), "image/jpeg")
|
|
|
|
|
|
def einfaches_bild(format="PNG", groesse=(40, 40), modus="RGB") -> SimpleUploadedFile:
|
|
im = Image.new(modus, groesse, (10, 20, 30) if modus == "RGB" else 128)
|
|
puffer = io.BytesIO()
|
|
im.save(puffer, format=format)
|
|
endung = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}[format]
|
|
return SimpleUploadedFile(f"v.{endung}", puffer.getvalue(), f"image/{endung}")
|
|
|
|
|
|
def gelesen(datei) -> Image.Image:
|
|
datei.seek(0)
|
|
return Image.open(io.BytesIO(datei.read()))
|
|
|
|
|
|
class SaeubernTests(TestCase):
|
|
def test_exif_verschwindet_vollstaendig(self):
|
|
"""🔴 Der eigentliche Zweck: Der Ort darf nicht öffentlich werden.
|
|
|
|
Ein Handyfoto trägt GPS — bei dieser App den Garten, in dem ein Kind
|
|
steht. ``/media/`` ist ohne Anmeldung erreichbar.
|
|
"""
|
|
roh = bild_mit_exif(gps=True)
|
|
# Gegenprobe: im Original IST der Ort drin.
|
|
self.assertTrue(gelesen(roh).getexif().get_ifd(0x8825))
|
|
|
|
sauber = saeubern(roh)
|
|
exif = gelesen(sauber).getexif()
|
|
self.assertFalse(exif.get_ifd(0x8825), "GPS steht noch im Bild")
|
|
self.assertFalse(dict(exif), f"EXIF steht noch im Bild: {dict(exif)}")
|
|
|
|
def test_drehung_wird_angewandt_nicht_weggeworfen(self):
|
|
"""⚠️ EXIF wegwerfen ohne zu drehen legt jedes Hochkantfoto um.
|
|
|
|
Der Fehler fiele erst beim Anzeigen auf, weit weg von der Ursache.
|
|
"""
|
|
# Orientation 6 heißt: um 90° gedreht anzeigen. Also wird aus einem
|
|
# 80x50-Bild ein 50x80-Bild.
|
|
roh = bild_mit_exif(gps=False, drehung=6, groesse=(80, 50))
|
|
sauber = saeubern(roh)
|
|
self.assertEqual(gelesen(sauber).size, (50, 80))
|
|
|
|
def test_ohne_drehungsangabe_bleibt_die_groesse(self):
|
|
roh = bild_mit_exif(gps=False, drehung=None, groesse=(80, 50))
|
|
self.assertEqual(gelesen(saeubern(roh)).size, (80, 50))
|
|
|
|
def test_ergebnis_ist_immer_jpeg(self):
|
|
for format in ("PNG", "JPEG", "WEBP"):
|
|
with self.subTest(format=format):
|
|
sauber = saeubern(einfaches_bild(format))
|
|
self.assertEqual(gelesen(sauber).format, "JPEG")
|
|
self.assertTrue(sauber.name.endswith(".jpg"))
|
|
|
|
def test_durchsichtigkeit_wird_auf_weiss_gelegt(self):
|
|
"""Ein PNG mit Alphakanal lässt sich nicht als JPEG schreiben."""
|
|
im = Image.new("RGBA", (20, 20), (255, 0, 0, 0))
|
|
puffer = io.BytesIO()
|
|
im.save(puffer, format="PNG")
|
|
datei = SimpleUploadedFile("t.png", puffer.getvalue(), "image/png")
|
|
|
|
sauber = saeubern(datei)
|
|
self.assertEqual(gelesen(sauber).mode, "RGB")
|
|
|
|
def test_grosse_bilder_werden_verkleinert(self):
|
|
sauber = saeubern(einfaches_bild(groesse=(4000, 3000)))
|
|
breite, hoehe = gelesen(sauber).size
|
|
self.assertEqual(max(breite, hoehe), MAX_KANTE)
|
|
# Seitenverhältnis bleibt.
|
|
self.assertAlmostEqual(breite / hoehe, 4 / 3, places=2)
|
|
|
|
def test_kleine_bilder_werden_nicht_aufgeblasen(self):
|
|
self.assertEqual(gelesen(saeubern(einfaches_bild(groesse=(40, 40)))).size, (40, 40))
|
|
|
|
def test_keine_bilddatei_wird_abgewiesen(self):
|
|
datei = SimpleUploadedFile("boese.jpg", b"GIF89a<?php echo 1; ?>", "image/jpeg")
|
|
with self.assertRaises(KeinBild):
|
|
saeubern(datei)
|
|
|
|
def test_leere_datei_wird_abgewiesen(self):
|
|
with self.assertRaises(KeinBild):
|
|
saeubern(SimpleUploadedFile("leer.jpg", b"", "image/jpeg"))
|
|
|
|
def test_pfadanteile_im_namen_verschwinden(self):
|
|
"""⚠️ Ein hochgeladener Name darf keine Verzeichnisse mitbringen."""
|
|
datei = einfaches_bild()
|
|
datei.name = "../../etc/passwd.png"
|
|
self.assertEqual(saeubern(datei).name, "passwd.jpg")
|
|
|
|
def test_riesige_abmessungen_werden_abgewiesen(self):
|
|
"""Ein winziges Bild kann beim Öffnen Gigabyte belegen."""
|
|
from . import bilder
|
|
|
|
with self.settings():
|
|
alt = bilder.MAX_PIXEL
|
|
bilder.MAX_PIXEL = 100 # 10x10
|
|
try:
|
|
with self.assertRaises(KeinBild):
|
|
saeubern(einfaches_bild(groesse=(40, 40)))
|
|
finally:
|
|
bilder.MAX_PIXEL = alt
|
|
|
|
|
|
@override_settings(LESE_TOKEN="lesen")
|
|
class UploadWegTests(TestCase):
|
|
"""Dass das Säubern auch wirklich in den Upload-Wegen steckt."""
|
|
|
|
def setUp(self):
|
|
from .tests import make_camera
|
|
|
|
self.kamera = make_camera(token="kamera-geheim", mobil=True)
|
|
|
|
def test_app_upload_speichert_ohne_exif(self):
|
|
resp = self.client.post(
|
|
"/api/hochladen/",
|
|
{"bilder": bild_mit_exif(gps=True)},
|
|
HTTP_AUTHORIZATION="Bearer kamera-geheim",
|
|
)
|
|
self.assertEqual(resp.status_code, 201, resp.content)
|
|
|
|
from .models import Sighting
|
|
|
|
s = Sighting.objects.get(id=resp.json()["angelegt"][0])
|
|
with s.image.open("rb") as f:
|
|
exif = Image.open(f).getexif()
|
|
self.assertFalse(exif.get_ifd(0x8825), "GPS kam durch den Upload")
|
|
|
|
def test_app_upload_weist_unbrauchbares_ab(self):
|
|
resp = self.client.post(
|
|
"/api/hochladen/",
|
|
{"bilder": SimpleUploadedFile("x.jpg", b"kein bild", "image/jpeg")},
|
|
HTTP_AUTHORIZATION="Bearer kamera-geheim",
|
|
)
|
|
self.assertEqual(resp.status_code, 400)
|
|
|
|
from .models import Sighting
|
|
|
|
# ⚠️ Und es bleibt KEINE halbe Sichtung stehen.
|
|
self.assertEqual(Sighting.objects.count(), 0)
|
|
|
|
def test_mehrere_bilder_entweder_alle_oder_keins(self):
|
|
"""Sonst weiß der Hochladende nicht, was angekommen ist."""
|
|
resp = self.client.post(
|
|
"/api/hochladen/",
|
|
{"bilder": [
|
|
einfaches_bild(),
|
|
SimpleUploadedFile("kaputt.jpg", b"nein", "image/jpeg"),
|
|
]},
|
|
HTTP_AUTHORIZATION="Bearer kamera-geheim",
|
|
)
|
|
self.assertEqual(resp.status_code, 400)
|
|
|
|
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)
|