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:
christian committed 2026-10-14 18:41:52 +00:00
1 parent f572c20080
commit a97f066e92
14 files changed
+421 -19

No files matched your search

+3
View File
@@ -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),
}
+5
View File
@@ -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")
+6
View File
@@ -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,
)
+27
View File
@@ -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
View File
@@ -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),
}
+33
View File
@@ -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'),
),
]
+55
View File
@@ -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()
+149
View File
@@ -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)
+120
View File
@@ -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)
+2 -2
View File
@@ -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 %}
+8 -8
View File
@@ -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 %}
+2 -2
View File
@@ -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 %}>
+2 -2
View File
@@ -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 %}>
+1 -1
View File
@@ -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>