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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+231
View File
@@ -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)