Die Sichtung merkt sich jetzt, WO der Vogel im Foto sitzt (vier Zahlen); die Anzeige schneidet daraus bei jedem Aufruf. Eine geaenderte Regel wirkt damit sofort auf den ganzen Bestand. Altbestand faellt auf das alte Bild zurueck.
121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""Wie ein Vogelfoto in einer Kachel aussieht.
|
|
|
|
🔑 **Der Zuschnitt ist eine Regel, keine Datei.** Das Original liegt ohnehin
|
|
auf der Platte, und wo der Vogel darin sitzt, steht als vier Zahlen an der
|
|
Sichtung. Daraus lässt sich der Ausschnitt jederzeit neu rechnen — bei jedem
|
|
Aufruf, für jede Sichtung, auch für die von vorletztem Jahr.
|
|
|
|
Vorher war es umgekehrt: Der Detector schnitt ein zweites Bild zu und legte es
|
|
daneben. Damit war der Zuschnitt für immer festgelegt. Eine bessere Regel half
|
|
nur neuen Sichtungen, alle vorhandenen hätte man einzeln nachziehen müssen —
|
|
und das auf jeder Anlage, die es irgendwo gibt.
|
|
|
|
⚠️ **Der Unterschied ist nicht Bequemlichkeit, sondern Benutzbarkeit.** An
|
|
Handyfotos fiel auf, woran die alte Lösung scheitert: Dort ist der Vogel klein
|
|
im Bild, der enge Ausschnitt entsprechend schmal und hoch. In einer
|
|
quadratischen Kachel blieb davon die Bauchmitte übrig — formatfüllend und
|
|
unscharf, ohne Kopf und ohne Schwanz.
|
|
|
|
Das Ergebnis wird als Datei zwischengespeichert, weil Zuschneiden Rechenzeit
|
|
kostet und ein Album viele Kacheln hat. **Der Zwischenspeicher ist
|
|
wegwerfbar**: Löscht man ihn, entsteht beim nächsten Aufruf alles neu — und
|
|
genau das ist der Weg, eine geänderte Regel auszurollen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from PIL import Image
|
|
|
|
# Wie viel Luft um den Vogel. 0.35 heißt: Die Kante des Ausschnitts ist 35 %
|
|
# der Vogelgröße von ihm entfernt.
|
|
#
|
|
# 🔑 Diese Zahl ist der ganze Sinn der Umstellung. Sie zu ändern und den
|
|
# Zwischenspeicher zu leeren ändert jede Kachel im Bestand.
|
|
RAND = 0.35
|
|
|
|
# Kantenlänge des erzeugten Bildes. Die Kacheln sind kleiner; größer zu
|
|
# speichern lohnt nur für Bildschirme mit hoher Punktdichte.
|
|
KANTE = 600
|
|
|
|
# Unterordner im Medienverzeichnis. Nicht ins Repo, nicht in die Sicherung —
|
|
# alles hier lässt sich aus Original und Koordinaten neu erzeugen.
|
|
ORDNER = "zuschnitte"
|
|
|
|
# Steigt diese Zahl, bekommen alle Dateien neue Namen und werden neu
|
|
# gerechnet. So rollt man eine geänderte Regel aus, ohne etwas zu löschen.
|
|
REGEL = 1
|
|
|
|
|
|
def _ziel(sichtung) -> Path:
|
|
kennung = hashlib.sha1(
|
|
f"{sichtung.pk}-{sichtung.image.name}-{RAND}-{KANTE}-{REGEL}".encode()
|
|
).hexdigest()[:16]
|
|
return Path(settings.MEDIA_ROOT) / ORDNER / f"{sichtung.pk}-{kennung}.jpg"
|
|
|
|
|
|
def quadrat(box: tuple[float, float, float, float], breite: int, hoehe: int,
|
|
rand: float = RAND) -> tuple[int, int, int, int]:
|
|
"""Aus relativen Vogel-Koordinaten ein quadratisches Fenster in Pixeln.
|
|
|
|
⚠️ **Die Mitte wird nachgeführt, nicht der Ausschnitt gestaucht.** Sitzt
|
|
der Vogel am Bildrand, verschiebt sich das Fenster nach innen, statt
|
|
schmaler zu werden — sonst säße er außermittig in seiner eigenen Kachel.
|
|
"""
|
|
x0, y0, x1, y1 = box
|
|
bx0, by0, bx1, by1 = x0 * breite, y0 * hoehe, x1 * breite, y1 * hoehe
|
|
bw, bh = max(1.0, bx1 - bx0), max(1.0, by1 - by0)
|
|
|
|
seite = min(max(bw, bh) * (1 + 2 * rand), float(breite), float(hoehe))
|
|
mx = min(max((bx0 + bx1) / 2, seite / 2), breite - seite / 2)
|
|
my = min(max((by0 + by1) / 2, seite / 2), hoehe - seite / 2)
|
|
return (
|
|
int(mx - seite / 2), int(my - seite / 2),
|
|
int(mx + seite / 2), int(my + seite / 2),
|
|
)
|
|
|
|
|
|
def adresse(sichtung) -> str | None:
|
|
"""Adresse der Kachel für diese Sichtung — oder ``None``.
|
|
|
|
Erzeugt das Bild beim ersten Abruf. Gibt es keine Koordinaten (Sichtungen
|
|
von vor der Umstellung), fällt die Vorlage auf das gespeicherte ``crop``
|
|
zurück; dafür liefert diese Funktion ``None``.
|
|
"""
|
|
box = sichtung.vogel_box
|
|
if box is None or not sichtung.image:
|
|
return None
|
|
|
|
ziel = _ziel(sichtung)
|
|
if not ziel.exists():
|
|
try:
|
|
_erzeugen(sichtung, box, ziel)
|
|
except (OSError, ValueError):
|
|
# ⚠️ Ein fehlendes Original darf keine Seite zerreißen. Dann gilt
|
|
# der Rückfall, genau wie bei einer Sichtung ohne Koordinaten.
|
|
return None
|
|
|
|
return f"{settings.MEDIA_URL}{ORDNER}/{ziel.name}"
|
|
|
|
|
|
def _erzeugen(sichtung, box, ziel: Path) -> None:
|
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
|
with sichtung.image.open("rb") as f:
|
|
bild = Image.open(f)
|
|
bild.load()
|
|
if bild.mode != "RGB":
|
|
bild = bild.convert("RGB")
|
|
|
|
ausschnitt = bild.crop(quadrat(box, bild.width, bild.height))
|
|
if ausschnitt.width > KANTE:
|
|
ausschnitt = ausschnitt.resize((KANTE, KANTE), Image.LANCZOS)
|
|
|
|
# Erst daneben schreiben, dann umbenennen: Zwei gleichzeitige Abrufe
|
|
# dürfen sich nicht gegenseitig eine halbe Datei hinterlassen.
|
|
vorlaeufig = ziel.with_suffix(".teil")
|
|
ausschnitt.save(vorlaeufig, format="JPEG", quality=86, optimize=True)
|
|
vorlaeufig.replace(ziel)
|