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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+204
View File
@@ -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()