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.
This commit is contained in:
commit
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user