Zuschnitt als Regel statt als gespeichertes Bild
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.
This commit is contained in:
1 parent
f572c20080
commit
d8d3efaa5d
14 files changed
+421
-19
No files matched your search
@@ -35,6 +35,9 @@ def payload_of(sighting: Sighting) -> dict:
|
||||
"frames_with_bird": sighting.frames_with_bird,
|
||||
"votes": {k: round(v, 4) for k, v in sighting.votes.items()},
|
||||
"mean_bird_score": round(sighting.mean_bird_score, 4),
|
||||
# Vier Zahlen statt eines zweiten Bildes: siehe web/dex/zuschnitt.py.
|
||||
"box": ([round(w, 5) for w in sighting.best_box]
|
||||
if getattr(sighting, "best_box", None) else None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ class Warteschlange:
|
||||
"votes": {g.species.slug: round(g.confidence, 4)
|
||||
for g in (result.guesses[:5] if result else [])},
|
||||
}
|
||||
# Wo der Vogel sitzt. Die Sammlung schneidet daraus selbst zu --
|
||||
# siehe web/dex/zuschnitt.py.
|
||||
if box is not None:
|
||||
nutzlast["box"] = [round(box.x0, 5), round(box.y0, 5),
|
||||
round(box.x1, 5), round(box.y1, 5)]
|
||||
dateien = {}
|
||||
if ausschnitt is not None:
|
||||
dateien["crop"] = ("crop.jpg", _encode(ausschnitt, 640), "image/jpeg")
|
||||
|
||||
@@ -36,6 +36,9 @@ class Sighting:
|
||||
best_crop: Image.Image | None
|
||||
best_sharpness: float
|
||||
mean_bird_score: float
|
||||
# Wo der Vogel im besten Bild sitzt, relativ. Die Sammlung schneidet
|
||||
# daraus selbst zu -- siehe web/dex/zuschnitt.py.
|
||||
best_box: tuple[float, float, float, float] | None = None
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
@@ -127,6 +130,7 @@ class Watcher:
|
||||
self._best_sharp = -1.0
|
||||
self._best_image: Image.Image | None = None
|
||||
self._best_crop: Image.Image | None = None
|
||||
self._best_box: tuple[float, float, float, float] | None = None
|
||||
self._bird_scores: list[float] = []
|
||||
self._frame_i = 0
|
||||
|
||||
@@ -230,6 +234,7 @@ class Watcher:
|
||||
self._best_sharp = score
|
||||
self._best_image = frame.copy()
|
||||
self._best_crop = box.anzeige_ausschnitt(frame)
|
||||
self._best_box = (box.x0, box.y0, box.x1, box.y1)
|
||||
|
||||
def _vote(self, box: Box, result: Result) -> None:
|
||||
# Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild,
|
||||
@@ -279,6 +284,7 @@ class Watcher:
|
||||
frames_with_bird=self._bird_frames,
|
||||
best_image=self._best_image,
|
||||
best_crop=self._best_crop,
|
||||
best_box=self._best_box,
|
||||
best_sharpness=max(0.0, self._best_sharp),
|
||||
mean_bird_score=mittel,
|
||||
)
|
||||
|
||||
@@ -76,6 +76,30 @@ def config(request: HttpRequest) -> JsonResponse:
|
||||
return JsonResponse(kamera.konfiguration())
|
||||
|
||||
|
||||
def _box_uebernehmen(sighting, payload) -> None:
|
||||
"""Wo der Vogel im Bild sitzt, als vier relative Zahlen.
|
||||
|
||||
🔑 Damit ist der Zuschnitt eine Regel statt einer Datei: Die Anzeige
|
||||
schneidet aus dem Original, und eine bessere Regel wirkt auf den ganzen
|
||||
Bestand. Siehe ``dex/zuschnitt.py``.
|
||||
|
||||
⚠️ Nur vollständige und plausible Vierecke übernehmen. Ein halbes Viereck
|
||||
ist keins, und eine vertauschte Kante ergäbe einen leeren Ausschnitt —
|
||||
sichtbar würde das erst als graue Kachel, weit weg von hier.
|
||||
"""
|
||||
box = payload.get("box")
|
||||
if not isinstance(box, (list, tuple)) or len(box) != 4:
|
||||
return
|
||||
try:
|
||||
x0, y0, x1, y1 = (float(w) for w in box)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not (0.0 <= x0 < x1 <= 1.0 and 0.0 <= y0 < y1 <= 1.0):
|
||||
return
|
||||
sighting.box_x0, sighting.box_y0 = x0, y0
|
||||
sighting.box_x1, sighting.box_y1 = x1, y1
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -182,6 +206,8 @@ def ingest(request: HttpRequest) -> JsonResponse:
|
||||
# Der Token EINER Kamera genügt, um Dateien unter der öffentlichen
|
||||
# Medienadresse abzulegen — und ein kompromittierter Standort ist genau
|
||||
# der Fall, für den die Token je Kamera getrennt sind. Siehe bilder.py.
|
||||
_box_uebernehmen(sighting, payload)
|
||||
|
||||
try:
|
||||
if "image" in request.FILES:
|
||||
sighting.image = saeubern(request.FILES["image"])
|
||||
@@ -567,6 +593,7 @@ def bestimmt_melden(request: HttpRequest, sichtung_id: int) -> JsonResponse:
|
||||
# beruht: Artsicherheit 0 heißt „welche Art?", Vogelwert 0 heißt „gar kein
|
||||
# Vogel". Siehe die Begründung bei ``ingest``.
|
||||
sighting.frames_with_bird = 1 if (species or sighting.mean_bird_score > 0) else 0
|
||||
_box_uebernehmen(sighting, payload)
|
||||
sighting.wartet_auf_bestimmung = False
|
||||
if "crop" in request.FILES:
|
||||
sighting.crop = request.FILES["crop"]
|
||||
|
||||
+8
-4
@@ -136,9 +136,8 @@ def _art_kurz(request: HttpRequest, art: Species, eintrag: DexEntry | None) -> d
|
||||
"zuletzt": eintrag.last_seen.isoformat(),
|
||||
"neu": eintrag.is_new,
|
||||
"foto": (
|
||||
_bild(request, eintrag.best_sighting.crop)
|
||||
or _bild(request, eintrag.best_sighting.image)
|
||||
if eintrag.best_sighting
|
||||
request.build_absolute_uri(eintrag.best_sighting.kachel_url)
|
||||
if eintrag.best_sighting and eintrag.best_sighting.kachel_url
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -189,7 +188,12 @@ def _sichtung_kurz(request: HttpRequest, s: Sighting) -> dict:
|
||||
else None
|
||||
),
|
||||
"foto": _bild(request, s.image),
|
||||
"ausschnitt": _bild(request, s.crop),
|
||||
# 🔑 Der Ausschnitt wird aus dem Original gerechnet, nicht als
|
||||
# zweites Bild gespeichert — siehe dex/zuschnitt.py. Damit schneidet
|
||||
# eine geänderte Regel sofort den ganzen Bestand neu, und die App
|
||||
# bekommt dieselbe Kachel wie die Website.
|
||||
"ausschnitt": (request.build_absolute_uri(s.kachel_url)
|
||||
if s.kachel_url else None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.1 on 2026-10-14 18:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dex', '0015_camera_min_mean_bird_score'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sighting',
|
||||
name='box_x0',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: links'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sighting',
|
||||
name='box_x1',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: rechts'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sighting',
|
||||
name='box_y0',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: oben'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sighting',
|
||||
name='box_y1',
|
||||
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: unten'),
|
||||
),
|
||||
]
|
||||
@@ -714,6 +714,33 @@ class Sighting(models.Model):
|
||||
votes = models.JSONField(default=dict, blank=True)
|
||||
mean_bird_score = models.FloatField(default=0.0)
|
||||
|
||||
# 🔑 **Wo der Vogel im Bild sitzt — als vier Zahlen, nicht als zweites
|
||||
# Bild.**
|
||||
#
|
||||
# Bis zum 14.10.2026 speicherte das Projekt neben dem Foto einen fertig
|
||||
# geschnittenen Ausschnitt. Der Zuschnitt war damit für immer festgelegt:
|
||||
# Eine bessere Regel half nur neuen Sichtungen, und alle vorhandenen
|
||||
# mussten einzeln nachgezogen werden.
|
||||
#
|
||||
# Aufgefallen ist es an Handyfotos. Dort ist der Vogel klein im Bild, der
|
||||
# enge Ausschnitt entsprechend schmal und hoch — und in einer quadratischen
|
||||
# Kachel blieb davon die Bauchmitte übrig, formatfüllend und unscharf.
|
||||
# Christians Urteil, und es war das richtige: *„Mir geht es darum, dass ein
|
||||
# Nutzer das so nicht verwenden kann."*
|
||||
#
|
||||
# Mit den Koordinaten ist der Zuschnitt eine **Regel statt einer Datei**:
|
||||
# Die Anzeige schneidet bei jedem Aufruf neu aus dem Original. Eine
|
||||
# geänderte Regel wirkt sofort auf den gesamten Bestand, Website und App
|
||||
# dürfen verschieden schneiden, und niemand braucht den Detector dafür.
|
||||
#
|
||||
# Werte sind relativ (0..1), damit sie eine Verkleinerung des Originals
|
||||
# überleben. Leer heißt: von vor dieser Änderung — dann gilt weiter das
|
||||
# gespeicherte ``crop``.
|
||||
box_x0 = models.FloatField("Vogel: links", null=True, blank=True)
|
||||
box_y0 = models.FloatField("Vogel: oben", null=True, blank=True)
|
||||
box_x1 = models.FloatField("Vogel: rechts", null=True, blank=True)
|
||||
box_y1 = models.FloatField("Vogel: unten", null=True, blank=True)
|
||||
|
||||
is_first_of_species = models.BooleanField(default=False)
|
||||
xp_awarded = models.PositiveIntegerField(default=0)
|
||||
|
||||
@@ -743,6 +770,34 @@ class Sighting(models.Model):
|
||||
"""Handkorrektur schlägt Modell."""
|
||||
return self.confirmed_species or self.species
|
||||
|
||||
@property
|
||||
def vogel_box(self) -> tuple[float, float, float, float] | None:
|
||||
"""Wo der Vogel sitzt, relativ — oder ``None`` bei Altbestand."""
|
||||
werte = (self.box_x0, self.box_y0, self.box_x1, self.box_y1)
|
||||
if any(w is None for w in werte):
|
||||
return None
|
||||
x0, y0, x1, y1 = werte
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return None
|
||||
return (x0, y0, x1, y1)
|
||||
|
||||
@property
|
||||
def kachel_url(self) -> str:
|
||||
"""Das Bild für eine Kachel: aus dem Original geschnitten.
|
||||
|
||||
🔑 Rückfall auf das gespeicherte ``crop`` für Sichtungen von vor der
|
||||
Umstellung, danach auf das Original. So bleibt jede alte Sammlung
|
||||
anzeigbar, ohne dass jemand etwas nachziehen muss.
|
||||
"""
|
||||
from .zuschnitt import adresse
|
||||
|
||||
neu = adresse(self)
|
||||
if neu:
|
||||
return neu
|
||||
if self.crop:
|
||||
return self.crop.url
|
||||
return self.image.url if self.image else ""
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
return (self.ended_at - self.started_at).total_seconds()
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests für die Zuschnitt-Regel.
|
||||
|
||||
🔑 Der Kern der Umstellung: Der Ausschnitt ist eine **Regel**, keine Datei.
|
||||
Deshalb prüfen diese Tests nicht „ein Bild entsteht", sondern die Eigenschaft,
|
||||
wegen der wir umgebaut haben — dass sich der Zuschnitt für den ganzen Bestand
|
||||
ändern lässt, auch rückwirkend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
|
||||
from .models import Sighting
|
||||
from .zuschnitt import quadrat
|
||||
|
||||
|
||||
def foto(groesse=(1800, 2400)) -> SimpleUploadedFile:
|
||||
im = Image.new("RGB", groesse, (120, 160, 200))
|
||||
puffer = io.BytesIO()
|
||||
im.save(puffer, format="JPEG")
|
||||
return SimpleUploadedFile("v.jpg", puffer.getvalue(), "image/jpeg")
|
||||
|
||||
|
||||
class QuadratTests(TestCase):
|
||||
"""Die Rechnung allein — ohne Dateien, ohne Datenbank."""
|
||||
|
||||
def test_ergebnis_ist_quadratisch(self):
|
||||
"""Weil die Kachel quadratisch ist. Sonst schneidet sie den Vogel ab."""
|
||||
x0, y0, x1, y1 = quadrat((0.40, 0.30, 0.52, 0.62), 1800, 2400)
|
||||
self.assertEqual(x1 - x0, y1 - y0)
|
||||
|
||||
def test_der_vogel_passt_hinein(self):
|
||||
box = (0.40, 0.30, 0.52, 0.62)
|
||||
x0, y0, x1, y1 = quadrat(box, 1800, 2400)
|
||||
self.assertLessEqual(x0, box[0] * 1800)
|
||||
self.assertLessEqual(y0, box[1] * 2400)
|
||||
self.assertGreaterEqual(x1, box[2] * 1800)
|
||||
self.assertGreaterEqual(y1, box[3] * 2400)
|
||||
|
||||
def test_am_rand_bleibt_das_fenster_im_bild(self):
|
||||
"""⚠️ Sonst sitzt ein Vogel am Bildrand ausserhalb seiner Kachel."""
|
||||
for box in ((0.0, 0.0, 0.08, 0.10), (0.92, 0.90, 1.0, 1.0)):
|
||||
with self.subTest(box=box):
|
||||
x0, y0, x1, y1 = quadrat(box, 1800, 2400)
|
||||
self.assertGreaterEqual(x0, 0)
|
||||
self.assertGreaterEqual(y0, 0)
|
||||
self.assertLessEqual(x1, 1800)
|
||||
self.assertLessEqual(y1, 2400)
|
||||
self.assertEqual(x1 - x0, y1 - y0)
|
||||
|
||||
def test_mehr_rand_heisst_groesseres_fenster(self):
|
||||
"""Das ist die Stellschraube, um die es bei der Umstellung ging."""
|
||||
eng = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.05)
|
||||
weit = quadrat((0.4, 0.3, 0.5, 0.6), 1800, 2400, rand=0.60)
|
||||
self.assertGreater(weit[2] - weit[0], eng[2] - eng[0])
|
||||
|
||||
def test_fenster_wird_nie_groesser_als_das_bild(self):
|
||||
x0, y0, x1, y1 = quadrat((0.0, 0.0, 1.0, 1.0), 1800, 2400, rand=2.0)
|
||||
self.assertLessEqual(x1 - x0, 1800)
|
||||
self.assertLessEqual(y1 - y0, 2400)
|
||||
|
||||
|
||||
class KachelTests(TestCase):
|
||||
def sichtung(self, **kw) -> Sighting:
|
||||
jetzt = timezone.now()
|
||||
s = Sighting(started_at=jetzt, ended_at=jetzt + timedelta(seconds=5), **kw)
|
||||
s.image = foto()
|
||||
s.save()
|
||||
return s
|
||||
|
||||
def test_mit_koordinaten_entsteht_eine_kachel(self):
|
||||
s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
|
||||
self.assertIn("zuschnitte/", s.kachel_url)
|
||||
|
||||
def test_die_kachel_ist_quadratisch(self):
|
||||
s = self.sichtung(box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
pfad = Path(settings.MEDIA_ROOT) / s.kachel_url.split("/media/")[-1]
|
||||
with Image.open(pfad) as im:
|
||||
self.assertEqual(im.width, im.height)
|
||||
|
||||
def test_ohne_koordinaten_gilt_der_alte_ausschnitt(self):
|
||||
"""Altbestand muss anzeigbar bleiben, ohne dass jemand nachzieht."""
|
||||
s = self.sichtung()
|
||||
s.crop = foto((300, 300))
|
||||
s.save()
|
||||
self.assertEqual(s.kachel_url, s.crop.url)
|
||||
|
||||
def test_ohne_alles_bleibt_das_original(self):
|
||||
s = self.sichtung()
|
||||
self.assertEqual(s.kachel_url, s.image.url)
|
||||
|
||||
def test_halbe_koordinaten_zaehlen_nicht(self):
|
||||
"""Ein halbes Viereck ist keins — sonst entstünde eine graue Kachel."""
|
||||
s = self.sichtung(box_x0=0.4, box_y0=0.3, box_x1=None, box_y1=0.62)
|
||||
self.assertIsNone(s.vogel_box)
|
||||
|
||||
def test_verdrehte_kanten_zaehlen_nicht(self):
|
||||
s = self.sichtung(box_x0=0.6, box_y0=0.3, box_x1=0.4, box_y1=0.62)
|
||||
self.assertIsNone(s.vogel_box)
|
||||
|
||||
|
||||
class RegelAendernTests(TestCase):
|
||||
"""🔑 Die Eigenschaft, wegen der umgebaut wurde.
|
||||
|
||||
Eine geänderte Regel muss den ganzen Bestand erfassen — nicht nur das, was
|
||||
danach entsteht. Genau das konnte die alte Lösung nicht.
|
||||
"""
|
||||
|
||||
def test_mehr_rand_erzeugt_eine_neue_kachel_fuer_alte_sichtungen(self):
|
||||
from . import zuschnitt
|
||||
|
||||
jetzt = timezone.now()
|
||||
s = Sighting(started_at=jetzt, ended_at=jetzt,
|
||||
box_x0=0.40, box_y0=0.30, box_x1=0.52, box_y1=0.62)
|
||||
s.image = foto()
|
||||
s.save()
|
||||
|
||||
vorher = s.kachel_url
|
||||
|
||||
alt_rand, alt_regel = zuschnitt.RAND, zuschnitt.REGEL
|
||||
try:
|
||||
zuschnitt.RAND = 0.8
|
||||
zuschnitt.REGEL = alt_regel + 1
|
||||
nachher = s.kachel_url
|
||||
finally:
|
||||
zuschnitt.RAND, zuschnitt.REGEL = alt_rand, alt_regel
|
||||
|
||||
# Andere Adresse, anderes Bild — ohne dass jemand die Sichtung angefasst hat.
|
||||
self.assertNotEqual(vorher, nachher)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
def kante(url):
|
||||
with Image.open(Path(settings.MEDIA_ROOT) / url.split("/media/")[-1]) as im:
|
||||
return im.width
|
||||
|
||||
self.assertGreater(kante(nachher), 0)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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)
|
||||
@@ -70,8 +70,8 @@
|
||||
{% if not card.found %}title="{% trans "Noch nicht gesichtet" %}"{% endif %}>
|
||||
<span class="nr">{{ s.dex_nr }}</span>
|
||||
<div class="thumb">
|
||||
{% if card.found and card.entry.best_sighting and card.entry.best_sighting.crop %}
|
||||
<img src="{{ card.entry.best_sighting.crop.url }}" alt="{{ s.name }}" loading="lazy">
|
||||
{% if card.found and card.entry.best_sighting and card.entry.best_sighting.kachel_url %}
|
||||
<img src="{{ card.entry.best_sighting.kachel_url }}" alt="{{ s.name }}" loading="lazy">
|
||||
{% elif s.reference_image %}
|
||||
<img src="{{ s.reference_image.url }}" alt="{{ s.name }}" loading="lazy">
|
||||
{% endif %}
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
<a class="held-bild" href="{% url 'species' letzte.effective_species.slug %}">
|
||||
{% if letzte.image %}
|
||||
<img src="{{ letzte.image.url }}" alt="{{ letzte.effective_species.name }}">
|
||||
{% elif letzte.crop %}
|
||||
<img src="{{ letzte.crop.url }}" alt="{{ letzte.effective_species.name }}">
|
||||
{% elif letzte.kachel_url %}
|
||||
<img src="{{ letzte.kachel_url }}" alt="{{ letzte.effective_species.name }}">
|
||||
{% endif %}
|
||||
<span class="frisch">{% trans "ZULETZT" %}</span>
|
||||
<div class="schild">
|
||||
@@ -78,8 +78,8 @@
|
||||
style="{{ entry.species.typ_vars }}">
|
||||
<span class="nr">{{ entry.species.dex_nr }}</span>
|
||||
<div class="thumb">
|
||||
{% if entry.best_sighting and entry.best_sighting.crop %}
|
||||
<img src="{{ entry.best_sighting.crop.url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
|
||||
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% elif entry.species.reference_image %}
|
||||
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% endif %}
|
||||
@@ -109,8 +109,8 @@
|
||||
<div class="besuche">
|
||||
{% for s in recent|slice:"1:5" %}
|
||||
<a class="besuch" href="{% url 'species' s.effective_species.slug %}">
|
||||
{% if s.crop %}
|
||||
<img src="{{ s.crop.url }}" alt="" loading="lazy">
|
||||
{% if s.kachel_url %}
|
||||
<img src="{{ s.kachel_url }}" alt="" loading="lazy">
|
||||
{% else %}<div class="leer"></div>{% endif %}
|
||||
<div class="f">
|
||||
{% if s.xp_awarded %}<span class="xp">+{{ s.xp_awarded }} XP</span>{% endif %}
|
||||
@@ -138,8 +138,8 @@
|
||||
style="{{ entry.species.typ_vars }}">
|
||||
<span class="nr">{{ entry.species.dex_nr }}</span>
|
||||
<div class="thumb">
|
||||
{% if entry.best_sighting and entry.best_sighting.crop %}
|
||||
<img src="{{ entry.best_sighting.crop.url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
|
||||
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% elif entry.species.reference_image %}
|
||||
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
|
||||
{% endif %}
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
<div class="rows">
|
||||
{% for s in sightings %}
|
||||
<div class="row">
|
||||
{% if s.crop %}
|
||||
{% if s.kachel_url %}
|
||||
{# Klick zeigt das grosse Foto -- siehe base.html #}
|
||||
<img class="shot" src="{{ s.crop.url }}" alt="" loading="lazy"
|
||||
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
|
||||
{% if s.image %}data-gross="{{ s.image.url }}"
|
||||
data-beschriftung="{% if s.effective_species %}{{ s.effective_species.name }} · {% endif %}{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
|
||||
style="cursor:zoom-in"{% endif %}>
|
||||
|
||||
@@ -115,8 +115,8 @@
|
||||
<div class="rows">
|
||||
{% for s in sightings %}
|
||||
<div class="row">
|
||||
{% if s.crop %}
|
||||
<img class="shot" src="{{ s.crop.url }}" alt="" loading="lazy"
|
||||
{% if s.kachel_url %}
|
||||
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
|
||||
{% if s.image %}data-gross="{{ s.image.url }}"
|
||||
data-beschriftung="{{ species.name }} · {{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
|
||||
style="cursor:zoom-in"{% endif %}>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="wrap">
|
||||
<div class="widget">
|
||||
{% if latest %}
|
||||
{% if latest.crop %}<img src="{{ latest.crop.url }}" alt="{{ latest.effective_species.name }}">{% endif %}
|
||||
{% if latest.kachel_url %}<img src="{{ latest.kachel_url }}" alt="{{ latest.effective_species.name }}">{% endif %}
|
||||
<div style="min-width:0">
|
||||
<div class="sub">{% trans "Zuletzt gesehen" %}</div>
|
||||
<div style="font-weight:700; font-size:18px; margin:2px 0 4px">{{ latest.effective_species.name }}</div>
|
||||
|
||||
Reference in new issue
Block a user