Files
vogelalbum/web/dex/test_bilder.py
T
christian dbec78aa7a Vogelalbum 1.0.0
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.
2026-10-14 13:52:09 +00:00

194 lines
7.3 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)