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

+151
View File
@@ -0,0 +1,151 @@
"""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"