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.
150 lines
5.4 KiB
Python
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)
|