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.
152 lines
6.4 KiB
Python
152 lines
6.4 KiB
Python
"""Hochgeladene Bilder prüfen und sauber neu schreiben.
|
||
|
||
🔴 **Warum es diese Datei gibt.** Bis zum 14.10.2026 wanderte eine hochgeladene
|
||
Datei unverändert aus ``request.FILES`` in ein ``ImageField`` und von dort auf
|
||
das Volume, das ``/media/`` **ohne Anmeldung** ausliefert. Zwei Folgen, beide
|
||
unsichtbar, bis es zu spät ist:
|
||
|
||
1. ⚠️ **Metadaten.** Ein Handyfoto trägt EXIF, und darin steht der Ort. Bei
|
||
einer Kamera am Vogelhaus ist das folgenlos — die schreibt kein EXIF, an
|
||
einem echten Bild der Live-Instanz nachgesehen. Mit der Handy-App wird es
|
||
zum Garten, in dem ein Kind steht, öffentlich abrufbar.
|
||
2. ⚠️ **Was überhaupt gespeichert wird.** Django prüft bei einem ``ImageField``
|
||
nur, dass Pillow die Datei öffnen *kann*. Eine Datei kann gleichzeitig ein
|
||
gültiges Bild und etwas anderes sein; unter ``/media/`` liegt sie dann auf
|
||
derselben Herkunft wie das Backend.
|
||
|
||
🔑 **Deshalb nicht prüfen, sondern NEU SCHREIBEN.** Eine Prüfung sagt „sieht
|
||
gültig aus" und lässt alles stehen, was sie nicht kennt. Wer die Pixel liest
|
||
und neu kodiert, gibt nur weiter, was wirklich ein Bild war — Metadaten,
|
||
angehängte Daten und alles Unverstandene fallen dabei von selbst weg. Das ist
|
||
der Unterschied zwischen einem Filter und einem Flaschenhals.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
|
||
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||
|
||
# 🔴 **Ohne das kann Pillow kein HEIC — und iPhones fotografieren HEIC.**
|
||
#
|
||
# Am 14.10.2026 wies eine Instanz jedes Foto vom 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
|
||
# 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
|
||
# ("decompression bomb"). Pillow warnt ab ~89 Megapixeln; wir lehnen vorher ab.
|
||
MAX_PIXEL = 50_000_000
|
||
|
||
# Längere Kante nach dem Verkleinern. 2400 px ist mehr, als die Sammlung je
|
||
# anzeigt, und klein genug, dass ein Album nicht die Platte füllt.
|
||
MAX_KANTE = 2400
|
||
|
||
# Was wir annehmen. Bewusst eine kurze Liste bekannter Rasterformate — kein
|
||
# SVG (das ist ein Dokument mit Skripten, kein Bild) und kein animiertes GIF.
|
||
ERLAUBT = {"JPEG", "PNG", "WEBP", "HEIF", "HEIC", "AVIF", "MPO"}
|
||
|
||
|
||
class KeinBild(Exception):
|
||
"""Die Datei ist kein Bild, das wir annehmen."""
|
||
|
||
|
||
def saeubern(datei: UploadedFile, name: str | None = None) -> InMemoryUploadedFile:
|
||
"""Liest die Pixel und gibt ein frisches JPEG ohne Metadaten zurück.
|
||
|
||
Wirft ``KeinBild``, wenn die Datei kein brauchbares Bild ist.
|
||
|
||
🔑 **Die Drehung wird angewandt, nicht mitgeschleppt.** Ein Handyfoto steht
|
||
oft nur deshalb richtig, weil im EXIF ein Drehwinkel steht. Wer die
|
||
Metadaten wegwirft, ohne das Bild zu drehen, legt jedes Hochkantfoto auf
|
||
die Seite — und zwar erst beim Anzeigen, also weit entfernt von der
|
||
Ursache.
|
||
"""
|
||
datei.seek(0)
|
||
try:
|
||
bild = Image.open(datei)
|
||
# ⚠️ ``verify()`` prüft die Struktur, macht das Objekt danach aber
|
||
# unbrauchbar. Deshalb danach neu öffnen statt weiterzuarbeiten.
|
||
bild.verify()
|
||
except (UnidentifiedImageError, OSError, ValueError) as 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)
|
||
try:
|
||
bild = Image.open(datei)
|
||
if bild.format not in ERLAUBT:
|
||
raise KeinBild(f"Format {bild.format!r} nehmen wir nicht an")
|
||
|
||
breite, hoehe = bild.size
|
||
if breite * hoehe > MAX_PIXEL:
|
||
raise KeinBild(f"Bild zu groß: {breite}x{hoehe} Pixel")
|
||
|
||
# Erst drehen (liest EXIF), dann alles Weitere — danach sind die
|
||
# Metadaten entbehrlich.
|
||
bild = ImageOps.exif_transpose(bild)
|
||
|
||
# Transparenz und Palettenbilder auf RGB bringen, sonst scheitert das
|
||
# Schreiben als JPEG an Bildern mit Alphakanal.
|
||
if bild.mode not in ("RGB", "L"):
|
||
if bild.mode in ("RGBA", "LA", "P"):
|
||
grund = Image.new("RGB", bild.size, (255, 255, 255))
|
||
bild = bild.convert("RGBA")
|
||
grund.paste(bild, mask=bild.split()[-1])
|
||
bild = grund
|
||
else:
|
||
bild = bild.convert("RGB")
|
||
|
||
bild.thumbnail((MAX_KANTE, MAX_KANTE), Image.LANCZOS)
|
||
|
||
ziel = io.BytesIO()
|
||
# Ohne ``exif=``/``icc_profile=`` schreibt Pillow keine Metadaten mit.
|
||
# Das ist die Stelle, an der der Ort verschwindet.
|
||
bild.save(ziel, format="JPEG", quality=88, optimize=True)
|
||
except KeinBild:
|
||
raise
|
||
except (OSError, ValueError) as fehler:
|
||
raise KeinBild(f"Bild nicht verarbeitbar: {fehler}") from fehler
|
||
finally:
|
||
bild.close() if hasattr(bild, "close") else None
|
||
|
||
groesse = ziel.tell()
|
||
ziel.seek(0)
|
||
return InMemoryUploadedFile(
|
||
ziel,
|
||
field_name=getattr(datei, "field_name", None),
|
||
name=_jpg_name(name or datei.name or "bild"),
|
||
content_type="image/jpeg",
|
||
size=groesse,
|
||
charset=None,
|
||
)
|
||
|
||
|
||
def _jpg_name(name: str) -> str:
|
||
"""Dateiname mit ``.jpg``, ohne Pfadanteile.
|
||
|
||
⚠️ Nur den letzten Namensteil nehmen: Ein hochgeladener Name darf keine
|
||
Verzeichnisse mitbringen. Django bereinigt das beim Speichern ebenfalls —
|
||
sich darauf zu verlassen heißt aber, dass diese Funktion außerhalb von
|
||
Django unsicher wäre, und sie ist einzeln testbar.
|
||
"""
|
||
letzter = name.replace("\\", "/").rsplit("/", 1)[-1]
|
||
stamm = letzter.rsplit(".", 1)[0] or "bild"
|
||
return f"{stamm[:60]}.jpg"
|