Files
vogelalbum/web/dex/test_zuschnitt.py
T
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

150 lines
5.4 KiB
Python

"""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)