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.
205 lines
6.3 KiB
Python
205 lines
6.3 KiB
Python
"""Der Live-Verteiler.
|
|
|
|
Der Kern ist nicht der HTTP-Teil, sondern das Versprechen: die Erkennung darf
|
|
davon nichts merken. Deshalb pruefen die Tests vor allem, dass ohne Zuschauer
|
|
nicht kodiert wird und dass fertige JPEG-Bytes unveraendert durchlaufen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json # noqa: F401 - Symmetrie zu den anderen Testdateien
|
|
import threading
|
|
import unittest
|
|
import urllib.error
|
|
import urllib.request
|
|
from io import BytesIO
|
|
|
|
from PIL import Image
|
|
|
|
from detector.verteiler import Bildhalter, Handler, Verteiler
|
|
|
|
BILD = Image.new("RGB", (320, 180), (60, 110, 70))
|
|
|
|
|
|
def jpeg_bytes(bild: Image.Image = BILD) -> bytes:
|
|
puffer = BytesIO()
|
|
bild.save(puffer, format="JPEG")
|
|
return puffer.getvalue()
|
|
|
|
|
|
class BildhalterTests(unittest.TestCase):
|
|
def test_ohne_bild_kommt_nichts(self):
|
|
self.assertIsNone(Bildhalter().jpeg())
|
|
|
|
def test_fertige_bytes_laufen_unveraendert_durch(self):
|
|
"""Der haeufige Fall: die Quelle liefert MJPEG. Wird hier umkodiert,
|
|
zahlt der Detector Rechenzeit fuer nichts."""
|
|
roh = jpeg_bytes()
|
|
h = Bildhalter()
|
|
h.zeige(BILD, roh)
|
|
|
|
nummer, daten = h.jpeg()
|
|
self.assertEqual(nummer, 1)
|
|
self.assertIs(daten, roh)
|
|
|
|
def test_ohne_fertige_bytes_wird_kodiert(self):
|
|
"""RTSP und Bilderordner liefern kein fertiges JPEG — dann muss der
|
|
Verteiler selbst eines machen."""
|
|
h = Bildhalter()
|
|
h.zeige(BILD, None)
|
|
|
|
nummer, daten = h.jpeg()
|
|
self.assertEqual(nummer, 1)
|
|
self.assertEqual(daten[:2], b"\xff\xd8")
|
|
self.assertEqual(Image.open(BytesIO(daten)).size, BILD.size)
|
|
|
|
def test_kodiert_nur_einmal_je_bild(self):
|
|
"""Sonst zahlt jeder Zuschauer seine eigene Kodierung."""
|
|
h = Bildhalter()
|
|
h.zeige(BILD, None)
|
|
|
|
erst = h.jpeg()
|
|
nochmal = h.jpeg()
|
|
self.assertIs(erst[1], nochmal[1])
|
|
|
|
def test_neues_bild_wird_neu_kodiert(self):
|
|
h = Bildhalter()
|
|
h.zeige(BILD, None)
|
|
erst = h.jpeg()
|
|
|
|
h.zeige(Image.new("RGB", (320, 180), (200, 30, 30)), None)
|
|
neu = h.jpeg()
|
|
|
|
self.assertEqual(neu[0], erst[0] + 1)
|
|
self.assertIsNot(neu[1], erst[1])
|
|
|
|
def test_zeige_kodiert_nicht(self):
|
|
"""Das Versprechen an die Erkennung: anmelden kostet Zeigerarbeit.
|
|
Solange niemand abholt, entsteht kein JPEG."""
|
|
h = Bildhalter()
|
|
for _ in range(50):
|
|
h.zeige(BILD, None)
|
|
self.assertIsNone(h._kodiert)
|
|
self.assertEqual(h.nummer, 50)
|
|
|
|
def test_grosse_bilder_werden_verkleinert(self):
|
|
h = Bildhalter(max_breite=160)
|
|
h.zeige(BILD, None)
|
|
self.assertEqual(Image.open(BytesIO(h.jpeg()[1])).width, 160)
|
|
|
|
def test_warte_laeuft_ohne_bild_ab(self):
|
|
h = Bildhalter()
|
|
self.assertEqual(h.warte(0, timeout=0.05), 0)
|
|
|
|
def test_warte_kehrt_mit_neuem_bild_zurueck(self):
|
|
h = Bildhalter()
|
|
threading.Timer(0.05, lambda: h.zeige(BILD, None)).start()
|
|
self.assertEqual(h.warte(0, timeout=5.0), 1)
|
|
|
|
def test_warte_kehrt_sofort_zurueck_wenn_schon_neu(self):
|
|
h = Bildhalter()
|
|
h.zeige(BILD, None)
|
|
self.assertEqual(h.warte(0, timeout=5.0), 1)
|
|
|
|
|
|
class VerteilerTests(unittest.TestCase):
|
|
def setUp(self):
|
|
Handler._zuschauer = 0
|
|
self.v = Verteiler(port=0, bind="127.0.0.1")
|
|
self.v.start()
|
|
self.addCleanup(self.v.stop)
|
|
|
|
def url(self, pfad: str) -> str:
|
|
return f"http://127.0.0.1:{self.v.port}{pfad}"
|
|
|
|
def hole(self, pfad: str, timeout: float = 5.0):
|
|
return urllib.request.urlopen(self.url(pfad), timeout=timeout)
|
|
|
|
def test_seite_kommt(self):
|
|
with self.hole("/") as r:
|
|
self.assertEqual(r.status, 200)
|
|
self.assertIn(b"/live", r.read())
|
|
|
|
def test_einzelbild_erst_mit_bild(self):
|
|
with self.assertRaises(urllib.error.HTTPError) as fehler:
|
|
self.hole("/bild.jpg")
|
|
self.assertEqual(fehler.exception.code, 503)
|
|
|
|
self.v.zeige(BILD, jpeg_bytes())
|
|
with self.hole("/bild.jpg") as r:
|
|
self.assertEqual(r.status, 200)
|
|
self.assertEqual(r.headers["Content-Type"], "image/jpeg")
|
|
self.assertEqual(r.read()[:2], b"\xff\xd8")
|
|
|
|
def test_unbekannter_pfad_ist_404(self):
|
|
with self.assertRaises(urllib.error.HTTPError) as fehler:
|
|
self.hole("/gibtsnicht")
|
|
self.assertEqual(fehler.exception.code, 404)
|
|
|
|
def test_stream_liefert_multipart(self):
|
|
stop = threading.Event()
|
|
|
|
def speisen():
|
|
while not stop.is_set():
|
|
self.v.zeige(BILD, jpeg_bytes())
|
|
stop.wait(0.01)
|
|
|
|
threading.Thread(target=speisen, daemon=True).start()
|
|
self.addCleanup(stop.set)
|
|
|
|
strom = self.hole("/live")
|
|
self.addCleanup(strom.close)
|
|
self.assertIn("multipart/x-mixed-replace", strom.headers["Content-Type"])
|
|
anfang = strom.read(400)
|
|
self.assertIn(b"--BILDGRENZE", anfang)
|
|
self.assertIn(b"\xff\xd8", anfang)
|
|
|
|
def test_zwei_zuschauer_sind_erlaubt(self):
|
|
"""Der ganze Zweck: an der Kamera geht nur einer, hier beliebig viele."""
|
|
stop = threading.Event()
|
|
|
|
def speisen():
|
|
while not stop.is_set():
|
|
self.v.zeige(BILD, jpeg_bytes())
|
|
stop.wait(0.01)
|
|
|
|
threading.Thread(target=speisen, daemon=True).start()
|
|
self.addCleanup(stop.set)
|
|
|
|
erster = self.hole("/live")
|
|
self.addCleanup(erster.close)
|
|
erster.read(200)
|
|
|
|
zweiter = self.hole("/live")
|
|
self.addCleanup(zweiter.close)
|
|
self.assertEqual(zweiter.status, 200)
|
|
zweiter.read(200)
|
|
|
|
self.assertEqual(self.v.zuschauer, 2)
|
|
|
|
def test_obergrenze_weist_ab(self):
|
|
stop = threading.Event()
|
|
|
|
def speisen():
|
|
while not stop.is_set():
|
|
self.v.zeige(BILD, jpeg_bytes())
|
|
stop.wait(0.01)
|
|
|
|
threading.Thread(target=speisen, daemon=True).start()
|
|
self.addCleanup(stop.set)
|
|
|
|
Handler.max_zuschauer = 1
|
|
self.addCleanup(setattr, Handler, "max_zuschauer", 8)
|
|
|
|
erster = self.hole("/live")
|
|
self.addCleanup(erster.close)
|
|
erster.read(200)
|
|
|
|
with self.assertRaises(urllib.error.HTTPError) as fehler:
|
|
self.hole("/live")
|
|
self.assertEqual(fehler.exception.code, 503)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|