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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,231 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user