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
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,204 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user