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

+160
View File
@@ -0,0 +1,160 @@
"""Woher die Bruecke das Livebild holt.
./venv/bin/python -m unittest discover tests
🔑 Diese Datei gibt es wegen eines Ausfalls, der acht Tage lang im Betrieb
stand, ohne dass irgendwo ein Fehler auftauchte: Seit dem Umzug nach Coolify
(03.10.2026) trug die oeffentliche Instanz ``VOGELALBUM_LIVE_ERLAUBT=0`` --
richtig so, ein Bild aus dem eigenen Garten gehoert nicht nach draussen.
Damit lieferte ``/api/zustand/`` aber ``bild_url: null``, und die Bruecke
veroeffentlichte kein Livebild mehr. In Home Assistant blieb das letzte Bild
stehen und **sah aus wie ein Livebild**.
⚠️ Der Riegel war gegen die Oeffentlichkeit gedacht und sperrte das eigene
Haus mit aus -- obwohl die Bruecke im Heimnetz laeuft und den Verteiler
direkt erreicht. Ein Schalter, zwei Zwecke.
Gegengeprueft: Ohne ``live_adresse`` sind die Faelle "Vorgabe schlaegt API"
und "Vorgabe trotz bild_url null" beide rot.
"""
from __future__ import annotations
import os
import unittest
from homeassistant import bruecke
class LiveAdresseTests(unittest.TestCase):
SCHLUESSEL = (
"VOGELALBUM_LIVE_VOGELHAUS",
"VOGELALBUM_LIVE_VOGELHAUS_2",
)
def setUp(self):
vorher = {k: os.environ.get(k) for k in self.SCHLUESSEL}
for k in self.SCHLUESSEL:
os.environ.pop(k, None)
def zuruecksetzen():
for k, wert in vorher.items():
if wert is None:
os.environ.pop(k, None)
else:
os.environ[k] = wert
self.addCleanup(zuruecksetzen)
@staticmethod
def kamera(kennung="vogelhaus", bild_url=None):
return {"kennung": kennung, "bild_url": bild_url}
# --- ohne eigene Vorgabe: alles bleibt wie bisher ----------------
def test_ohne_alles_leer(self):
self.assertEqual(bruecke.live_adresse(self.kamera()), "")
def test_bild_url_aus_der_schnittstelle(self):
kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
def test_stromadresse_wird_zum_einzelbild(self):
"""``/live`` ist der endlose MJPEG-Strom -- den darf sie nicht lesen."""
kamera = self.kamera(bild_url="http://erkennung.local:8131/live")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
def test_schraegstrich_am_ende_stoert_nicht(self):
kamera = self.kamera(bild_url="http://erkennung.local:8131/live/")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
# --- mit eigener Vorgabe: der eigentliche Zweck ------------------
def test_vorgabe_auch_wenn_die_schnittstelle_nichts_liefert(self):
"""Der Ausfall vom 03.-11.10.2026: LIVE_ERLAUBT=0, also bild_url null."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \
"http://erkennung.local:8131/bild.jpg"
self.assertEqual(bruecke.live_adresse(self.kamera()),
"http://erkennung.local:8131/bild.jpg")
def test_vorgabe_schlaegt_die_schnittstelle(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg"
kamera = self.kamera(bild_url="http://erkennung.local:8131/bild.jpg")
self.assertEqual(bruecke.live_adresse(kamera),
"http://127.0.0.1:8131/bild.jpg")
def test_vorgabe_als_stromadresse(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://erkennung.local:8131/live"
self.assertEqual(bruecke.live_adresse(self.kamera()),
"http://erkennung.local:8131/bild.jpg")
# --- Randfaelle --------------------------------------------------
def test_kennung_mit_sonderzeichen(self):
""""vogelhaus-2" wird zu VOGELALBUM_LIVE_VOGELHAUS_2."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS_2"] = \
"http://erkennung.local:8133/bild.jpg"
kamera = self.kamera(kennung="vogelhaus-2")
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8133/bild.jpg")
def test_ohne_kennung_keine_vorgabe(self):
"""Ohne Kennung gibt es keinen Variablennamen -- aber auch keinen Absturz."""
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = "http://127.0.0.1:8131/bild.jpg"
kamera = {"bild_url": "http://erkennung.local:8131/bild.jpg"}
self.assertEqual(bruecke.live_adresse(kamera),
"http://erkennung.local:8131/bild.jpg")
class EntwuerfeTests(unittest.TestCase):
"""Die Kamera-Entitaet darf nur entstehen, wenn es ein Bild dazu gibt.
⚠️ Sonst legt Home Assistant eine Kamera an, die nie etwas zeigt -- und
eine leere Kachel ist schlimmer als keine.
"""
def setUp(self):
self.vorher = os.environ.get("VOGELALBUM_LIVE_VOGELHAUS")
os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None)
def zuruecksetzen():
if self.vorher is None:
os.environ.pop("VOGELALBUM_LIVE_VOGELHAUS", None)
else:
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = self.vorher
self.addCleanup(zuruecksetzen)
@staticmethod
def zustand():
return {
"kameras": [{
"name": "Futterhaus Garten",
"kennung": "vogelhaus",
"aktiv": True,
"online": True,
"bild_url": None,
}],
"sammlung": {},
}
def _hat_livebild(self):
return any(
inhalt.get("name") == "Livebild"
for _, inhalt in bruecke.entwuerfe(self.zustand())
)
def test_ohne_adresse_keine_kamera_entitaet(self):
self.assertFalse(self._hat_livebild())
def test_mit_vorgabe_entsteht_die_kamera_entitaet(self):
os.environ["VOGELALBUM_LIVE_VOGELHAUS"] = \
"http://erkennung.local:8131/bild.jpg"
self.assertTrue(self._hat_livebild())
if __name__ == "__main__":
unittest.main()
+958
View File
@@ -0,0 +1,958 @@
"""Tests für Bewegungserkennung und Besuchslogik.
./venv/bin/python -m unittest discover tests
Läuft ohne die Modelle: der Klassifikator wird durch eine Attrappe ersetzt,
damit die Zustandsmaschine für sich prüfbar ist.
"""
from __future__ import annotations
import sys
import unittest
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from detector import karte # noqa: E402
from detector.classify import Box # noqa: E402
from detector.motion import MotionDetector, sharpness # noqa: E402
from detector.species_de import ( # noqa: E402
BY_SCIENTIFIC, SPECIES, catalog, label_to_scientific,
)
from detector.species_extra import EXTRA # noqa: E402
from detector.watcher import Watcher, WatcherConfig # noqa: E402
def blank(color=(40, 60, 40), size=(320, 240)) -> Image.Image:
return Image.new("RGB", size, color)
def with_blob(x: int, y: int, r: int = 40, size=(320, 240)) -> Image.Image:
img = blank(size=size)
ImageDraw.Draw(img).ellipse([x - r, y - r, x + r, y + r], fill=(220, 200, 60))
return img
class MotionTests(unittest.TestCase):
def test_first_frame_reports_no_motion(self):
self.assertEqual(MotionDetector().update(blank()), 0.0)
def test_identical_frames_report_no_motion(self):
m = MotionDetector()
m.update(blank())
self.assertEqual(m.update(blank()), 0.0)
def test_blob_appearing_triggers_motion(self):
m = MotionDetector()
m.update(blank())
ratio = m.update(with_blob(160, 120))
self.assertGreater(ratio, m.min_ratio)
self.assertTrue(m.is_motion(ratio))
def test_background_adapts_to_a_permanent_change(self):
"""Ein umgestellter Ast darf nicht dauerhaft als Bewegung gelten."""
m = MotionDetector()
m.update(blank())
changed = blank(color=(90, 110, 90))
ratios = [m.update(changed) for _ in range(200)]
self.assertGreater(ratios[0], m.min_ratio)
self.assertLess(ratios[-1], m.min_ratio)
def test_reset_clears_background(self):
m = MotionDetector()
m.update(blank())
m.reset()
self.assertEqual(m.update(with_blob(160, 120)), 0.0)
class SharpnessTests(unittest.TestCase):
def test_flat_image_has_almost_no_sharpness(self):
self.assertLess(sharpness(blank()), 1.0)
def test_edges_score_higher_than_flat(self):
self.assertGreater(sharpness(with_blob(160, 120)), sharpness(blank()))
def test_blurred_scores_lower_than_crisp(self):
from PIL import ImageFilter
crisp = with_blob(160, 120)
blurred = crisp.filter(ImageFilter.GaussianBlur(6))
self.assertGreater(sharpness(crisp), sharpness(blurred))
def test_tiny_image_does_not_crash(self):
self.assertEqual(sharpness(Image.new("RGB", (2, 2)), width=2), 0.0)
class DaylightTests(unittest.TestCase):
"""Abschaltung bei Dunkelheit. Die Hysterese ist der interessante Teil:
ohne sie flattert die Entscheidung in der Dämmerung im Sekundentakt."""
def gate(self, **kw):
from detector.daylight import DaylightGate
return DaylightGate(**({"patience": 3} | kw))
def test_brightness_matches_the_image(self):
from detector.daylight import brightness
self.assertLess(brightness(Image.new("RGB", (64, 48), (0, 0, 0))), 1)
self.assertGreater(brightness(Image.new("RGB", (64, 48), (255, 255, 255))), 254)
def test_bright_scene_keeps_running(self):
g = self.gate()
hell = Image.new("RGB", (64, 48), (130, 130, 130))
for _ in range(20):
self.assertTrue(g.update(hell))
self.assertFalse(g.is_dark)
def test_darkness_pauses_after_patience(self):
g = self.gate()
dunkel = Image.new("RGB", (64, 48), (2, 2, 2))
# Erst nach 'patience' Bildern am Stück, nicht schon beim ersten.
self.assertTrue(g.update(dunkel))
self.assertTrue(g.update(dunkel))
self.assertFalse(g.update(dunkel))
self.assertTrue(g.is_dark)
def test_a_single_flash_does_not_wake_it(self):
"""Ein Autoscheinwerfer soll die Analyse nicht wecken."""
g = self.gate()
dunkel = Image.new("RGB", (64, 48), (2, 2, 2))
for _ in range(5):
g.update(dunkel)
self.assertTrue(g.is_dark)
g.update(Image.new("RGB", (64, 48), (200, 200, 200)))
self.assertTrue(g.is_dark)
def test_hysteresis_prevents_flapping_at_dusk(self):
"""Ein Wert zwischen den Schwellen ändert nichts — in beide Richtungen."""
g = self.gate(dark_below=15, light_above=30)
daemmerung = Image.new("RGB", (64, 48), (22, 22, 22))
for _ in range(50):
g.update(daemmerung)
self.assertFalse(g.is_dark)
for _ in range(10):
g.update(Image.new("RGB", (64, 48), (2, 2, 2)))
self.assertTrue(g.is_dark)
for _ in range(50):
g.update(daemmerung)
self.assertTrue(g.is_dark)
def test_morning_resumes(self):
g = self.gate()
for _ in range(10):
g.update(Image.new("RGB", (64, 48), (2, 2, 2)))
self.assertTrue(g.is_dark)
hell = Image.new("RGB", (64, 48), (130, 130, 130))
for _ in range(10):
g.update(hell)
self.assertFalse(g.is_dark)
def test_night_vision_never_pauses(self):
g = self.gate(enabled=False)
dunkel = Image.new("RGB", (64, 48), (0, 0, 0))
for _ in range(50):
self.assertTrue(g.update(dunkel))
self.assertFalse(g.is_dark)
def test_thresholds_must_leave_room_for_hysteresis(self):
with self.assertRaises(ValueError):
self.gate(dark_below=30, light_above=30)
class BoxTests(unittest.TestCase):
def test_crop_returns_the_marked_region(self):
img = blank(size=(400, 200))
box = Box(0.25, 0.0, 0.75, 1.0, 0.9)
crop = box.crop(img, pad=0.0)
self.assertEqual(crop.size, (200, 200))
def test_padding_widens_the_crop(self):
img = blank(size=(400, 400))
box = Box(0.4, 0.4, 0.6, 0.6, 0.9)
self.assertGreater(box.crop(img, pad=0.5).size[0], box.crop(img, pad=0.0).size[0])
def test_degenerate_box_falls_back_to_full_image(self):
img = blank(size=(400, 400))
self.assertEqual(Box(0.5, 0.5, 0.5, 0.5, 0.9).crop(img).size, img.size)
def test_area(self):
self.assertAlmostEqual(Box(0.0, 0.0, 0.5, 0.5, 1.0).area, 0.25)
# --------------------------------------------------------------------------
# Attrappe statt Modell: liefert eine feste Bestimmung, damit sich die
# Abstimmungs- und Besuchslogik isoliert prüfen lässt.
# --------------------------------------------------------------------------
@dataclass
class FakeGuess:
species: object
confidence: float
@dataclass
class FakeResult:
guesses: list
bird_score: float = 0.9
raw_top: list = None
ms: float = 1.0
class FakePipeline:
def __init__(self, plan, detect_threshold=0.4):
"""``plan`` ist eine Liste von (Art|None, Confidence) je Aufruf.
Ein Eintrag darf zusaetzlich einen Box-Score tragen —
``(Art, Confidence, 0.2)`` ist ein Beinahe-Treffer unter der
Sichtungsschwelle, wie ihn das Protokoll sehen soll.
"""
self.plan = list(plan)
self.calls = 0
self.detect_threshold = detect_threshold
def zaehlt_als_vogel(self, box):
return box is not None and box.score >= self.detect_threshold
def run(self, img):
entry = self.plan[min(self.calls, len(self.plan) - 1)]
self.calls += 1
if entry is None:
return None, None
species, confidence, *rest = entry
box = Box(0.3, 0.3, 0.7, 0.7, rest[0] if rest else 0.95)
if species is None or not self.zaehlt_als_vogel(box):
return box, None
return box, FakeResult([FakeGuess(species, confidence)])
class WatcherTests(unittest.TestCase):
def setUp(self):
self.kohlmeise = BY_SCIENTIFIC["Parus major"]
self.blaumeise = BY_SCIENTIFIC["Cyanistes caeruleus"]
def make(self, plan, **cfg) -> Watcher:
defaults = {"classify_every": 1, "cooldown_s": 0.0, "min_bird_frames": 2}
return Watcher(
pipeline=FakePipeline(plan),
config=WatcherConfig(**(defaults | cfg)),
)
def test_no_motion_produces_no_sighting(self):
w = self.make([(self.kohlmeise, 0.9)])
for _ in range(5):
self.assertIsNone(w.process(blank()))
self.assertIsNone(w.flush())
def test_a_visit_yields_one_sighting(self):
w = self.make([(self.kohlmeise, 0.9)])
w.process(blank())
for x in range(100, 200, 25):
w.process(with_blob(x, 120))
sighting = w.flush()
self.assertIsNotNone(sighting)
self.assertEqual(sighting.species, self.kohlmeise)
self.assertGreater(sighting.frames_with_bird, 0)
def test_majority_wins_the_vote(self):
plan = [(self.kohlmeise, 0.9)] * 3 + [(self.blaumeise, 0.9)]
w = self.make(plan)
w.process(blank())
for x in range(100, 200, 25):
w.process(with_blob(x, 120))
sighting = w.flush()
self.assertEqual(sighting.species, self.kohlmeise)
self.assertGreater(sighting.confidence, 0.5)
def test_split_vote_stays_undetermined(self):
"""Kippt die Abstimmung, wird nicht geraten."""
plan = [(self.kohlmeise, 0.9), (self.blaumeise, 0.9)] * 3
w = self.make(plan, min_confidence=0.8)
w.process(blank())
for x in range(100, 200, 16):
w.process(with_blob(x, 120))
sighting = w.flush()
self.assertIsNone(sighting.species)
def test_too_few_bird_frames_is_discarded(self):
w = self.make([(self.kohlmeise, 0.9), None, None], min_bird_frames=3)
w.process(blank())
w.process(with_blob(160, 120))
w.process(with_blob(180, 120))
self.assertIsNone(w.flush())
def beobachtet(self, plan, **cfg):
"""Watcher mit Mitschrift — gibt (Watcher, Liste der Meldungen)."""
gesehen: list = []
defaults = {
"classify_every": 1,
"cooldown_s": 0.0,
"min_bird_frames": 1,
"gate": "detector",
}
w = Watcher(
pipeline=FakePipeline(plan),
config=WatcherConfig(**(defaults | cfg)),
beobachter=lambda box, result: gesehen.append((box, result)),
)
return w, gesehen
def test_near_miss_is_logged_but_is_no_sighting(self):
"""Der entscheidende Fall fuers Protokoll: Vogel knapp verpasst.
Ohne diese Meldung sieht ein Tag, an dem die Voegel nur zu klein im
Bild waren, genauso aus wie ein Tag ohne einen einzigen Vogel.
"""
w, gesehen = self.beobachtet([(self.kohlmeise, 0.9, 0.2)])
for x in range(100, 200, 25):
self.assertIsNone(w.process(with_blob(x, 120)))
self.assertIsNone(w.flush())
self.assertTrue(gesehen, "Beobachter wurde gar nicht gerufen")
self.assertTrue(all(box is not None for box, _ in gesehen))
self.assertTrue(all(box.score == 0.2 for box, _ in gesehen))
def test_box_above_threshold_still_becomes_a_sighting(self):
"""Gegenprobe: an der normalen Schwelle aendert sich nichts."""
w, gesehen = self.beobachtet([(self.kohlmeise, 0.9, 0.95)])
for x in range(100, 200, 25):
w.process(with_blob(x, 120))
sighting = w.flush()
self.assertIsNotNone(sighting)
self.assertEqual(sighting.species, self.kohlmeise)
self.assertTrue(gesehen)
def test_best_shot_prefers_the_sharper_frame(self):
w = self.make([(self.kohlmeise, 0.9)])
w.process(blank())
w.process(with_blob(140, 120))
w.process(with_blob(170, 120))
sighting = w.flush()
self.assertIsNotNone(sighting.best_image)
self.assertGreater(sighting.best_sharpness, 0)
def test_watcher_resets_between_visits(self):
w = self.make([(self.kohlmeise, 0.9)])
w.process(blank())
w.process(with_blob(160, 120))
w.process(with_blob(180, 120))
first = w.flush()
w.process(blank())
w.process(with_blob(160, 120))
w.process(with_blob(180, 120))
second = w.flush()
self.assertIsNotNone(first)
self.assertIsNotNone(second)
self.assertGreater(second.started_at, first.started_at - _one_second())
def _one_second():
from datetime import timedelta
return timedelta(seconds=1)
class ReportTests(unittest.TestCase):
"""Die Auswertung der Rohdaten. Sie beantwortet die Frage nach den ersten
Tagen — sehen wir Vögel, und taugen die Bilder etwas."""
def setUp(self):
import json
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.ordner = Path(self.tmp.name)
voll = with_blob(160, 120, r=50, size=(400, 300))
crop = voll.crop((110, 70, 210, 170))
voll.save(self.ordner / "20260828-112200_amsel.jpg")
crop.save(self.ordner / "20260828-112200_amsel_crop.jpg")
(self.ordner / "20260828-112200_amsel.json").write_text(
json.dumps({
"species": "amsel", "confidence": 0.97,
"frames_with_bird": 25, "frames_total": 67,
}), encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def test_reads_a_saved_sighting(self):
from detector.report import lade
eintraege = lade(self.ordner)
self.assertEqual(len(eintraege), 1)
e = eintraege[0]
self.assertEqual(e.art, "amsel")
self.assertAlmostEqual(e.confidence, 0.97)
self.assertAlmostEqual(e.trefferquote, 25 / 67)
def test_measures_area_from_the_image_sizes(self):
from detector.report import lade
e = lade(self.ordner)[0]
# 100x100 Ausschnitt aus 400x300 sind 8,3 %.
self.assertAlmostEqual(e.flaeche, 100 * 100 / (400 * 300) * 100, places=2)
self.assertGreater(e.schaerfe, 0)
self.assertGreater(e.helligkeit, 0)
def test_empty_folder_is_not_an_error(self):
import tempfile
from detector.report import lade
with tempfile.TemporaryDirectory() as leer:
self.assertEqual(lade(Path(leer)), [])
def test_broken_json_is_skipped(self):
from detector.report import lade
(self.ordner / "kaputt.json").write_text("{nicht valide", encoding="utf-8")
self.assertEqual(len(lade(self.ordner)), 1)
def test_contact_sheet_is_written(self):
from detector.report import kontaktbogen, lade
ziel = self.ordner / "bogen.jpg"
n = kontaktbogen(lade(self.ordner), ziel)
self.assertEqual(n, 1)
self.assertTrue(ziel.exists())
self.assertGreater(Image.open(ziel).width, 0)
def test_verdict_flags_a_bird_that_is_too_small(self):
from detector.report import Eintrag, _urteil
winzig = Eintrag("x", "amsel", 0.9, 5, 20, 400, 1.2, 120, None, None)
self.assertEqual(_urteil(winzig), "Vogel zu klein")
verwackelt = Eintrag("x", "amsel", 0.9, 5, 20, 12, 20, 120, None, None)
self.assertEqual(_urteil(verwackelt), "verwackelt")
gut = Eintrag("x", "amsel", 0.9, 5, 20, 300, 15, 120, None, None)
self.assertEqual(_urteil(gut), "sehr gut")
class ProtokollTests(unittest.TestCase):
"""Das Dauerprotokoll. Der wichtigste Teil sind die Lebenszeichen: ohne
sie ist eine Lücke nicht von einem Ausfall zu unterscheiden."""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.pfad = Path(self.tmp.name) / "unter" / "protokoll.csv"
def tearDown(self):
self.tmp.cleanup()
def lade(self):
import csv
with self.pfad.open(encoding="utf-8") as f:
return list(csv.DictReader(f))
def test_creates_file_and_header(self):
from detector.protokoll import Protokoll
p = Protokoll(self.pfad)
p.close()
self.assertTrue(self.pfad.exists())
self.assertIn("zeit", self.pfad.read_text(encoding="utf-8"))
def test_weak_detection_is_recorded(self):
"""Genau dafür ist das Protokoll da: knapp verpasste Vögel sichtbar
machen, die es nie in eine Sichtung schaffen."""
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, grundschwelle=0.08)
p.erkennung(Box(0.4, 0.4, 0.5, 0.5, 0.22), None, 120)
p.close()
zeilen = self.lade()
self.assertEqual(len(zeilen), 1)
self.assertEqual(zeilen[0]["art"], "sicht")
self.assertAlmostEqual(float(zeilen[0]["score"]), 0.22)
self.assertAlmostEqual(float(zeilen[0]["flaeche_pct"]), 1.0, places=1)
def test_below_the_floor_is_ignored(self):
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, grundschwelle=0.08)
p.erkennung(Box(0.4, 0.4, 0.5, 0.5, 0.03), None, 120)
p.erkennung(None, None, 120)
p.close()
self.assertEqual(self.lade(), [])
def test_pulse_respects_its_interval(self):
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, puls_sekunden=100)
p.puls(0.0, 120) # erster zaehlt
p.puls(50.0, 120) # zu frueh
p.puls(150.0, 120) # wieder faellig
p.close()
self.assertEqual(len([z for z in self.lade() if z["art"] == "puls"]), 2)
def test_pulse_can_be_forced(self):
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, puls_sekunden=9999)
p.puls(0.0, 120)
p.puls(1.0, 120, erzwingen=True)
p.close()
self.assertEqual(len(self.lade()), 2)
def test_frame_counts_reset_after_a_pulse(self):
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, puls_sekunden=10)
for _ in range(7):
p.bild()
for _ in range(3):
p.bild(dunkel=True)
p.puls(20.0, 120)
p.bild()
p.puls(40.0, 120)
p.close()
pulse = [z for z in self.lade() if z["art"] == "puls"]
self.assertEqual(pulse[0]["bilder"], "10")
self.assertEqual(pulse[0]["dunkel"], "3")
self.assertEqual(pulse[1]["bilder"], "1")
def test_reopening_appends_instead_of_truncating(self):
"""Ein Neustart des Dienstes darf die bisherigen Tage nicht wegwerfen."""
from detector.protokoll import Protokoll
p = Protokoll(self.pfad, puls_sekunden=1)
p.puls(0.0, 120)
p.close()
p = Protokoll(self.pfad, puls_sekunden=1)
p.puls(0.0, 120)
p.close()
self.assertEqual(len(self.lade()), 2)
self.assertEqual(self.pfad.read_text(encoding="utf-8").count("zeit,art"), 1)
class WatcherObserverTests(unittest.TestCase):
"""Der Beobachter meldet jede Analyse — auch die ohne Fund."""
def test_observer_sees_hits_and_misses(self):
gesehen = []
w = Watcher(
pipeline=FakePipeline([(BY_SCIENTIFIC["Parus major"], 0.9), None]),
config=WatcherConfig(classify_every=1, cooldown_s=0.0),
beobachter=lambda box, res: gesehen.append(box),
)
w.process(blank())
w.process(with_blob(160, 120))
w.process(with_blob(180, 120))
w.flush()
self.assertGreaterEqual(len(gesehen), 2)
self.assertIsNotNone(gesehen[0])
self.assertIsNone(gesehen[1])
class CatalogTests(unittest.TestCase):
def test_slugs_are_unique(self):
slugs = [s.slug for s in SPECIES]
self.assertEqual(len(slugs), len(set(slugs)))
def test_scientific_names_are_unique(self):
names = [s.scientific for s in SPECIES]
self.assertEqual(len(names), len(set(names)))
def test_slugs_have_no_umlauts(self):
for s in SPECIES:
with self.subTest(species=s.german):
self.assertTrue(s.slug.isascii(), f"{s.german} -> {s.slug}")
def test_every_species_has_a_fact(self):
for s in SPECIES:
with self.subTest(species=s.german):
self.assertTrue(s.fact.strip())
def test_rarer_species_are_worth_more(self):
order = ["haeufig", "gelegentlich", "selten", "episch", "legendaer"]
xp = [
next(s.xp for s in SPECIES if s.rarity == r)
for r in order
if any(s.rarity == r for s in SPECIES)
]
self.assertEqual(xp, sorted(xp))
def test_label_parsing(self):
self.assertEqual(label_to_scientific("Parus major (Great Tit)"), "Parus major")
self.assertEqual(label_to_scientific("Parus major"), "Parus major")
def test_catalog_matches_the_model_labels(self):
"""Jede Art im Katalog muss im Label-File des Modells vorkommen —
sonst wird sie nie erkannt und die Karte bleibt für immer leer.
Gilt für den ganzen Katalog, nicht nur die Kernliste: eine Art ohne
Label wäre ein Eintrag, den niemand je freischalten kann.
"""
labels = Path(__file__).resolve().parents[1] / "models" / "inat_bird_labels.txt"
if not labels.exists():
self.skipTest("Modelle nicht geladen (scripts/fetch_models.sh)")
known = {
label_to_scientific(line)
for line in labels.read_text(encoding="utf-8").splitlines()
}
missing = [s.german for s in catalog() if s.scientific not in known]
self.assertEqual(missing, [], f"Nicht im Modell: {missing}")
class ExtendedCatalogTests(unittest.TestCase):
"""Der erweiterte Katalog — 104 Arten ohne Erzähltext, nur Strukturdaten."""
def test_no_slug_collisions_with_the_core(self):
kern = {s.slug for s in SPECIES}
doppelt = [e.slug for e in EXTRA if e.slug in kern]
self.assertEqual(doppelt, [])
def test_slugs_are_unique_and_ascii(self):
slugs = [e.slug for e in EXTRA]
self.assertEqual(len(slugs), len(set(slugs)))
for slug in slugs:
with self.subTest(slug=slug):
self.assertTrue(slug.isascii(), slug)
def test_scientific_names_do_not_repeat(self):
namen = [s.scientific for s in catalog()]
self.assertEqual(len(namen), len(set(namen)))
def test_habitat_filter_narrows_the_catalog(self):
alle = catalog()
garten = catalog({"garten"})
self.assertLess(len(garten), len(alle))
self.assertTrue(all(s.lebensraum == "garten" for s in garten))
# Die Kernarten müssen im Gartenfilter vollständig enthalten sein —
# sonst verschwände ausgerechnet das, was die Kamera wirklich sieht.
self.assertTrue({s.slug for s in SPECIES} <= {s.slug for s in garten})
def test_every_extra_has_plausible_measurements(self):
for e in EXTRA:
with self.subTest(art=e.german):
self.assertGreater(e.gewicht_g, 0)
self.assertGreater(e.spannweite_cm, 0)
self.assertIn(e.zug, {"stand", "teil", "zug"})
self.assertIn(
e.lebensraum, {"garten", "wald", "offenland", "wasser", "gebirge"}
)
def test_wingspan_exceeds_body_length(self):
"""Ein Vogel mit kürzerer Spannweite als Körperlänge wäre ein Tippfehler."""
for e in EXTRA:
laenge = float(e.size_cm.split()[0])
with self.subTest(art=e.german):
self.assertGreater(e.spannweite_cm, laenge * 0.7)
class KarteTests(unittest.TestCase):
"""Die Erkennungskarte laeuft ohne Modelle: die Pipeline ist eine Attrappe,
die nach Position entscheidet. Geprueft wird die Karte, nicht der Detektor."""
def setUp(self):
self.vogel = with_blob(60, 60, r=50, size=(120, 120))
self.hg = [blank(size=(640, 480)) for _ in range(2)]
def test_montage_hits_the_wanted_area(self):
gross = karte.montiere(self.hg[0], self.vogel, 0.25)
klein = karte.montiere(self.hg[0], self.vogel, 0.04)
# Der Vogel ist gelb, der Hintergrund gruen — gezaehlt wird, wie viele
# Pixel sich geaendert haben.
def anteil(bild):
diff = ImageChops.difference(bild, self.hg[0]).convert("L")
geaendert = sum(n for wert, n in enumerate(diff.histogram()) if wert > 0)
return geaendert / (bild.width * bild.height)
# Die weiche Ellipse frisst Flaeche gegenueber dem Rechteck, deshalb
# grosszuegige Grenzen — es geht um die Groessenordnung.
self.assertGreater(anteil(gross), anteil(klein) * 3)
self.assertLess(anteil(gross), 0.30)
def test_bird_stands_on_the_given_line(self):
"""Der Fusspunkt ist der Sinn der Uebung: ein Vogel steht auf dem
Brett, er schwebt nicht um seinen Mittelpunkt."""
bild = karte.montiere(self.hg[0], self.vogel, 0.09, mitte_x=0.5, fuss_y=0.5)
H = bild.height
veraendert = [
y for y in range(H)
if any(bild.getpixel((x, y)) != (40, 60, 40) for x in range(0, bild.width, 4))
]
self.assertTrue(veraendert)
self.assertLessEqual(max(veraendert), H * 0.5 + 2)
self.assertGreater(max(veraendert), H * 0.5 - 20)
def test_map_marks_the_dead_zone(self):
"""Der Fall, fuer den es die Karte gibt: unten faellt alles durch."""
class NurObenPipeline:
detect_threshold = 0.4
def zaehlt_als_vogel(self, box):
return box is not None and box.score >= self.detect_threshold
def run(self, img):
# Untere Bildhaelfte des montierten Vogels -> schlechter Score.
unten = img.getpixel((img.width // 2, int(img.height * 0.97)))
score = 0.2 if unten != (40, 60, 40) else 0.9
return Box(0.4, 0.4, 0.6, 0.6, score), None
zellen = karte.vermessen(
NurObenPipeline(), self.hg, self.vogel, anteil=0.09,
spalten=(0.5,), zeilen=(0.6, 0.99),
)
oben = next(z for z in zellen if z.fuss_y == 0.6)
unten = next(z for z in zellen if z.fuss_y == 0.99)
self.assertTrue(oben.erkannt)
self.assertFalse(unten.erkannt)
self.assertIn("tote Zone", karte.rat(zellen))
def test_a_single_failure_disqualifies_a_position(self):
z = karte.Zelle(0.5, 0.5, treffer=2, gesamt=3, bester_score=0.9)
self.assertFalse(z.erkannt)
def test_drawing_keeps_the_image_size(self):
zellen = [karte.Zelle(0.5, 0.5, 1, 1, 0.9), karte.Zelle(0.2, 0.9, 0, 1, 0.1)]
bild = karte.zeichne(self.hg[0], zellen)
self.assertEqual(bild.size, self.hg[0].size)
self.assertEqual(bild.mode, "RGB")
def test_table_has_a_row_per_line(self):
zellen = karte.vermessen(
FakePipeline([(None, 0.0)]),
self.hg, self.vogel, anteil=0.09,
spalten=(0.3, 0.7), zeilen=(0.5, 0.9),
)
text = karte.tabelle(zellen)
self.assertEqual(len(text.splitlines()), 3) # Kopf + zwei Zeilen
class StichprobeTests(unittest.TestCase):
"""Die Stichproben beantworten, was das Protokoll nicht kann: kommt
überhaupt ein Vogel ins Bild? Dafür müssen sie regelmäßig fallen und
dürfen die Platte nicht volllaufen lassen."""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.ordner = Path(self.tmp.name) / "stichproben"
self.frame = Image.new("RGB", (320, 180), (90, 120, 80))
def tearDown(self):
self.tmp.cleanup()
def test_first_frame_is_kept(self):
from detector.stichprobe import Stichprobe
s = Stichprobe(self.ordner, alle_sekunden=300)
self.assertIsNotNone(s.bild(self.frame, 1000.0))
self.assertEqual(len(list(self.ordner.glob("*.jpg"))), 1)
def test_interval_is_respected(self):
from detector.stichprobe import Stichprobe
s = Stichprobe(self.ordner, alle_sekunden=300)
s.bild(self.frame, 1000.0)
self.assertIsNone(s.bild(self.frame, 1299.0))
self.assertIsNotNone(s.bild(self.frame, 1300.0))
def test_oldest_is_dropped_at_the_limit(self):
"""Ein Dauerläufer ohne Obergrenze füllt irgendwann die Platte."""
from detector.stichprobe import Stichprobe
s = Stichprobe(self.ordner, max_bilder=3)
for tag in ("01", "02", "03", "04", "05"):
self.frame.save(self.ordner / f"202609{tag}-120000.jpg")
s.bild(self.frame, 1000.0) # raeumt nach dem Speichern auf
uebrig = sorted(p.name for p in self.ordner.glob("*.jpg"))
self.assertEqual(len(uebrig), 3)
# Die aeltesten sind weg, das frisch gespeicherte ist da.
self.assertNotIn("20260901-120000.jpg", uebrig)
self.assertIn("20260905-120000.jpg", uebrig)
def test_contact_sheet_covers_the_newest_first(self):
from detector.stichprobe import kontaktbogen
self.ordner.mkdir(parents=True)
for tag in ("01", "02", "03", "04"):
self.frame.save(self.ordner / f"202609{tag}-120000.jpg")
ziel = Path(self.tmp.name) / "bogen.jpg"
pfad, n, seiten = kontaktbogen(self.ordner, ziel, spalten=2, zeilen=1)
self.assertTrue(pfad.exists())
self.assertEqual(n, 2)
self.assertEqual(seiten, 2)
def test_contact_sheet_without_images_says_so(self):
from detector.stichprobe import kontaktbogen
self.ordner.mkdir(parents=True)
with self.assertRaises(FileNotFoundError):
kontaktbogen(self.ordner, Path(self.tmp.name) / "bogen.jpg")
if __name__ == "__main__":
unittest.main()
class UebernimmTests(unittest.TestCase):
"""Einstellungen im laufenden Betrieb ändern.
Läuft gegen die echte Pipeline, weil genau das die Zusage ist: das Modell
bleibt geladen, nur die Zuordnung wird neu berechnet. Mit einem Doppel
wäre der Test wertlos.
"""
@classmethod
def setUpClass(cls):
from detector.classify import CLASSIFIER_MODEL, DETECTOR_MODEL
if not CLASSIFIER_MODEL.exists() or not DETECTOR_MODEL.exists():
raise unittest.SkipTest("Modelle fehlen — scripts/fetch_models.sh")
from detector.classify import Pipeline
# Mit Gartenfilter, wie im Betrieb: sonst stehen 148 Arten zur
# Wahl und das Zurücksetzen im Test trifft die falsche Zahl.
cls.pipeline = Pipeline(detect_threshold=0.4, min_bird_score=0.05,
report_threshold=0.08, habitats={"garten"})
def test_ohne_aenderung_passiert_nichts(self):
self.assertEqual(
self.pipeline.uebernimm(detect_threshold=0.4, min_bird_score=0.05), []
)
def test_schwelle_zieht_den_finder_mit(self):
"""Sonst sucht der Finder weiter nach der alten Schwelle — der Fehler,
der das Protokoll schon einmal blind gemacht hat."""
p = self.pipeline
try:
p.uebernimm(detect_threshold=0.05)
self.assertEqual(p.detect_threshold, 0.05)
# Der Finder sucht ab der niedrigeren von Wunsch (0.08) und
# Sichtungsschwelle (0.05).
self.assertEqual(p.report_threshold, 0.05)
self.assertEqual(p.finder.threshold, 0.05)
finally:
p.uebernimm(detect_threshold=0.4)
self.assertEqual(p.report_threshold, 0.08)
def test_artenliste_wird_getauscht_und_das_modell_bleibt(self):
from detector.species_de import Species
p = self.pipeline
interp_vorher = p.classifier.interp
vorher = len(p.classifier.native_idx)
nur_zwei = [
Species("Parus major", "Kohlmeise", "Great Tit", "haeufig", ""),
Species("Turdus merula", "Amsel", "Blackbird", "haeufig", ""),
]
try:
geaendert = p.uebernimm(arten=nur_zwei)
self.assertTrue(any("Katalog" in z for z in geaendert))
self.assertEqual(len(p.classifier.native_idx), 2)
# Dasselbe Modell, nur eine neue Zuordnung.
self.assertIs(p.classifier.interp, interp_vorher)
finally:
from detector.species_de import catalog
p.uebernimm(arten=catalog({"garten"}))
self.assertEqual(len(p.classifier.native_idx), vorher)
def test_gleiche_artenliste_meldet_keine_aenderung(self):
from detector.species_de import catalog
self.assertEqual(self.pipeline.uebernimm(arten=catalog({"garten"})), [])
class AnzeigeAusschnittTests(unittest.TestCase):
"""🔑 Der Klassifikator und das Auge wollen Verschiedenes.
``crop`` liegt eng am Vogel, weil Hintergrund die Bestimmung verwaessert.
In der fast quadratischen Kachel der Sammlung schneidet genau das links
und rechts den Vogel weg — bei einem Vogel im Hochformat bleibt die
Bauchmitte uebrig. Gemeldet als „der Zuschnitt sieht merkwuerdig aus".
"""
def bild(self, groesse=(1800, 2400)):
from PIL import Image
return Image.new("RGB", groesse, (120, 160, 200))
def test_anzeige_ist_quadratisch(self):
from detector.classify import Box
# Ein hochkant sitzender Vogel, schmal und hoch.
box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9)
im = self.bild()
eng = box.crop(im)
anzeige = box.anzeige_ausschnitt(im)
self.assertLess(eng.width / eng.height, 0.7, "Testbox ist nicht hochkant")
self.assertAlmostEqual(anzeige.width / anzeige.height, 1.0, places=1)
def test_anzeige_enthaelt_den_ganzen_vogel(self):
from detector.classify import Box
box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9)
im = self.bild()
anzeige = box.anzeige_ausschnitt(im)
# Mindestens so hoch wie der Vogel plus Rand.
vogel_hoehe = (0.62 - 0.30) * im.height
self.assertGreater(anzeige.height, vogel_hoehe)
def test_crop_bleibt_unveraendert_eng(self):
"""Die Artbestimmung ist gemessen und darf sich nicht mitaendern."""
from detector.classify import Box
box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9)
im = self.bild()
eng = box.crop(im)
erwartet_breite = (0.52 - 0.40) * im.width * 1.24 # pad 0.12 beidseits
self.assertAlmostEqual(eng.width, erwartet_breite, delta=3)
def test_am_bildrand_bleibt_der_ausschnitt_im_bild(self):
"""⚠️ Sonst sitzt ein Vogel am Rand ausserhalb seiner eigenen Kachel."""
from detector.classify import Box
im = self.bild()
for box in (
Box(x0=0.00, y0=0.00, x1=0.08, y1=0.10, score=0.9),
Box(x0=0.92, y0=0.90, x1=1.00, y1=1.00, score=0.9),
):
with self.subTest(box=box):
a = box.anzeige_ausschnitt(im)
self.assertGreater(a.width, 0)
self.assertLessEqual(a.width, im.width)
self.assertLessEqual(a.height, im.height)
self.assertAlmostEqual(a.width / a.height, 1.0, places=1)
def test_winziges_bild_sprengt_nichts(self):
from detector.classify import Box
im = self.bild((60, 40))
a = Box(x0=0.3, y0=0.3, x1=0.7, y1=0.7, score=0.9).anzeige_ausschnitt(im)
self.assertLessEqual(a.width, 60)
self.assertLessEqual(a.height, 40)
+119
View File
@@ -0,0 +1,119 @@
"""Einstellungen aus der Web-App.
Der Kern ist nicht der Abruf, sondern das Verhalten im Fehlerfall: eine
unerreichbare Web-App darf die Erkennung nicht anhalten und nicht auf
Vorgaben zurückwerfen. Wer zwei Wochen auf den ersten Vogel wartet, will ihn
nicht verpassen, weil ein Webserver neu gestartet wurde.
"""
from __future__ import annotations
import json
import threading
import unittest
from http import server
from detector.fernkonfig import Fernkonfig, arten_aus_json
ANTWORT = {
"kamera": "vogelhaus",
"stream_url": "http://10.0.0.9:8080/stream",
"habitats": ["garten"],
"detect_threshold": 0.35,
"min_bird_score": 0.07,
"region": "de",
"arten": [
{"scientific": "Parus major", "german": "Kohlmeise", "english": "Great Tit",
"rarity": "haeufig", "lebensraum": "garten"},
{"scientific": "Turdus merula", "german": "Amsel", "english": "Blackbird",
"rarity": "haeufig", "lebensraum": "garten"},
],
}
class _Handler(server.BaseHTTPRequestHandler):
antwort = ANTWORT
status = 200
gesehene_token: list[str] = []
def log_message(self, fmt, *args): # noqa: A003
pass
def do_GET(self): # noqa: N802
_Handler.gesehene_token.append(self.headers.get("Authorization", ""))
koerper = json.dumps(self.antwort).encode("utf-8")
self.send_response(self.status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(koerper)))
self.end_headers()
self.wfile.write(koerper)
class ArtenTests(unittest.TestCase):
def test_uebersetzt_die_felder(self):
arten = arten_aus_json(ANTWORT["arten"])
self.assertEqual([a.scientific for a in arten], ["Parus major", "Turdus merula"])
self.assertEqual(arten[0].german, "Kohlmeise")
self.assertEqual(arten[0].lebensraum, "garten")
def test_ohne_wissenschaftlichen_namen_faellt_weg(self):
"""Der wissenschaftliche Name ist der Schlüssel ins Modell — ohne ihn
trifft der Eintrag später leise nichts."""
arten = arten_aus_json([{"german": "Namenlos"}, {"scientific": " "}])
self.assertEqual(arten, [])
def test_fehlende_beschriftung_wird_ersetzt(self):
arten = arten_aus_json([{"scientific": "Pica pica"}])
self.assertEqual(arten[0].german, "Pica pica")
self.assertEqual(arten[0].rarity, "haeufig")
class AbrufTests(unittest.TestCase):
def setUp(self):
_Handler.antwort = ANTWORT
_Handler.status = 200
_Handler.gesehene_token = []
self.srv = server.HTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=self.srv.serve_forever, daemon=True).start()
self.addCleanup(self.srv.server_close)
self.addCleanup(self.srv.shutdown)
self.api = f"http://127.0.0.1:{self.srv.server_address[1]}"
def test_holt_und_merkt_sich(self):
f = Fernkonfig(self.api, "geheim")
daten = f.hole()
self.assertEqual(daten["kamera"], "vogelhaus")
self.assertEqual(f.letzte, daten)
self.assertEqual(_Handler.gesehene_token, ["Bearer geheim"])
def test_faellig_erst_nach_dem_intervall(self):
f = Fernkonfig(self.api, "geheim", intervall=300)
self.assertTrue(f.faellig(1000.0))
f.hole(1000.0)
self.assertFalse(f.faellig(1299.0))
self.assertTrue(f.faellig(1300.0))
def test_abgelehnter_token_gibt_none(self):
_Handler.status = 401
f = Fernkonfig(self.api, "falsch")
self.assertIsNone(f.hole())
def test_unerreichbar_gibt_none_und_behaelt_den_stand(self):
"""Das eigentliche Versprechen: ein Ausfall ändert nichts."""
f = Fernkonfig(self.api, "geheim")
vorher = f.hole()
self.assertIsNotNone(vorher)
self.srv.shutdown()
self.srv.server_close()
self.assertIsNone(f.hole())
self.assertEqual(f.letzte, vorher)
def test_unlesbare_antwort_gibt_none(self):
_Handler.antwort = ["kein", "objekt"]
f = Fernkonfig(self.api, "geheim")
self.assertIsNone(f.hole())
if __name__ == "__main__":
unittest.main()
+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()
+352
View File
@@ -0,0 +1,352 @@
"""Tests fuer den MJPEG-Server der Kameraseite (``pi/vogelkamera.py``).
./venv/bin/python -m unittest discover tests
Laeuft ohne Raspberry Pi und ohne Kamera: ``rpicam-vid`` wird nie gestartet,
der Bildpuffer von Hand befuellt. Geprueft wird die HTTP-Schicht -- und die
ist der Teil, der schon Schaden angerichtet hat. Dass ein zweiter Zuschauer
abgewiesen wird, ist keine Kosmetik: ein stiller zweiter Client halbiert die
Bildrate und der Detector faellt in Timeouts, ohne dass irgendwo ein Fehler
steht.
⚠️ **Diese Datei war vom 25.09. bis zum 09.10.2026 rot** -- 19 Fehler. Commit
b57c1b3 ersetzte die picamera2-Fassung durch die rpicam-vid-Fassung, die
wirklich auf dem Geraet laeuft; die Tests blieben auf den alten Namen stehen
(``Bildpuffer`` statt ``FrameBuffer``, ``Handler`` statt ``MJPEGHandler``) und
pruefen Code, den es nicht mehr gab.
🔑 Beim Nachziehen kam heraus, dass ``pi/README.md`` die 503-Absage fuer den
zweiten Zuschauer die ganze Zeit **versprochen** hat, waehrend die Fassung vom
Geraet ihn nur zaehlte. Die Tests haben also nicht nur den Namen verfehlt,
sondern eine fehlende Schutzfunktion verdeckt.
"""
from __future__ import annotations
import importlib.util
import json
import threading
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
_pfad = Path(__file__).resolve().parents[1] / "pi" / "vogelkamera.py"
_spec = importlib.util.spec_from_file_location("vogelkamera", _pfad)
vogelkamera = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(vogelkamera)
# Ein winziges, aber gueltiges JPEG waere hier Overkill -- der Server reicht
# Bytes durch, ohne sie zu deuten.
BILD = b"\xff\xd8" + b"x" * 64 + b"\xff\xd9"
class ServerTests(unittest.TestCase):
def setUp(self):
# Der Server haelt seinen Zustand in Modulvariablen, nicht in einer
# Instanz. Jeder Test setzt sie zurueck, sonst schleppt er den Stand
# des vorigen mit -- der Zuschauerplatz ganz besonders.
vogelkamera.frame_buffer = vogelkamera.FrameBuffer()
vogelkamera.zuschauer_platz = threading.Semaphore(1)
vogelkamera.bilder_gesamt = 0
vogelkamera.zuschauer = 0
vogelkamera.START_ZEIT = time.time()
self.server = vogelkamera.ThreadedHTTPServer(
("127.0.0.1", 0), vogelkamera.MJPEGHandler
)
self.port = self.server.server_address[1]
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.addCleanup(self._stop)
def _stop(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
def url(self, pfad: str) -> str:
return f"http://127.0.0.1:{self.port}{pfad}"
def hole(self, pfad: str, timeout: float = 5.0):
return urllib.request.urlopen(self.url(pfad), timeout=timeout)
def schiebe(self, bild: bytes = BILD) -> None:
"""Ein Bild einspeisen, so wie es ``capture_loop`` tut -- inklusive
Zaehlwerk, denn /status liest beides."""
vogelkamera.frame_buffer.update(bild)
with vogelkamera._zaehler_sperre:
vogelkamera.bilder_gesamt += 1
def speisen(self) -> threading.Event:
"""Bilder nachschieben, bis der Test fertig ist."""
stop = threading.Event()
def lauf():
while not stop.is_set():
self.schiebe()
stop.wait(0.01)
threading.Thread(target=lauf, daemon=True).start()
self.addCleanup(stop.set)
return stop
# -- die einfachen Endpunkte ------------------------------------------
def test_startseite_kommt(self):
with self.hole("/") as r:
self.assertEqual(r.status, 200)
self.assertIn(b"/stream", r.read())
def test_temperatur_endpunkt_antwortet_immer(self):
"""Auf einem Nicht-Pi gibt es die Datei nicht -- dann 'N/A', aber kein
Fehler. Der Endpunkt ist die einzige Gesundheitspruefung, die den
Stream nicht anfasst."""
with self.hole("/temp") as r:
self.assertEqual(r.status, 200)
self.assertTrue(r.read().strip())
def test_gesund_meldet_erst_mit_bild(self):
with self.assertRaises(urllib.error.HTTPError) as fehler:
self.hole("/gesund")
self.assertEqual(fehler.exception.code, 503)
self.schiebe()
with self.hole("/gesund") as r:
self.assertEqual(r.status, 200)
def test_gesund_ist_auch_unter_health_erreichbar(self):
"""Der englische Name steht in fremden Werkzeugen (Uptime-Kuma und
Konsorten) -- beide Pfade muessen dasselbe tun."""
self.schiebe()
with self.hole("/health") as r:
self.assertEqual(r.status, 200)
def test_gesund_merkt_ein_altes_bild(self):
"""Der Server antwortet noch, die Aufnahme haengt. Genau das ist der
Fall, den ein Ping nie sieht."""
vogelkamera.frame_buffer.update(BILD)
with vogelkamera.frame_buffer._lock:
vogelkamera.frame_buffer._timestamp = time.time() - 30
with self.assertRaises(urllib.error.HTTPError) as fehler:
self.hole("/gesund")
self.assertEqual(fehler.exception.code, 503)
def test_schnappschuss_gibt_das_bild_her(self):
"""Ein einzelnes Bild statt eines Streams -- der schonende Weg, einmal
nachzusehen, was die Kamera sieht."""
self.schiebe()
with self.hole("/snapshot") as r:
self.assertEqual(r.status, 200)
self.assertEqual(r.headers["Content-Type"], "image/jpeg")
self.assertEqual(r.read(), BILD)
def test_schnappschuss_ohne_bild_ist_503(self):
with self.assertRaises(urllib.error.HTTPError) as fehler:
self.hole("/snapshot")
self.assertEqual(fehler.exception.code, 503)
def test_unbekannter_pfad_ist_404(self):
with self.assertRaises(urllib.error.HTTPError) as fehler:
self.hole("/gibtsnicht")
self.assertEqual(fehler.exception.code, 404)
# -- der Stream --------------------------------------------------------
def test_stream_liefert_multipart_mit_bildern(self):
self.speisen()
with self.hole("/stream") as r:
self.assertEqual(r.status, 200)
self.assertIn("multipart/x-mixed-replace", r.headers["Content-Type"])
anfang = r.read(len(BILD) + 200)
self.assertIn(b"--frame", anfang)
self.assertIn(b"\xff\xd8", anfang)
def test_zweiter_zuschauer_wird_abgewiesen(self):
"""Der eigentliche Punkt: lieber eine klare Absage als zwei ruckelnde
Bilder und ein Detector in Timeouts.
⚠️ Dieser Test war der Grund, die Datei wieder anzufassen -- er stand
auf der alten Fassung und hat deshalb nicht gemeldet, dass die Absage
im Code fehlte, obwohl das README sie zusagte."""
self.speisen()
erster = self.hole("/stream")
self.addCleanup(erster.close)
erster.read(len(BILD)) # sicherstellen, dass er wirklich haengt
with self.assertRaises(urllib.error.HTTPError) as fehler:
self.hole("/stream")
self.assertEqual(fehler.exception.code, 503)
self.assertIn("Zuschauer", fehler.exception.read().decode("utf-8"))
def test_nach_dem_trennen_darf_der_naechste_wieder(self):
"""Ohne das waere die Kamera nach einem Browserfenster dauerhaft
dicht."""
self.speisen()
erster = self.hole("/stream")
erster.read(len(BILD))
erster.close()
# Der Server merkt das Trennen erst beim naechsten Schreibversuch.
for _ in range(250):
if vogelkamera.zuschauer_platz.acquire(blocking=False):
vogelkamera.zuschauer_platz.release()
break
time.sleep(0.02)
else:
self.fail("Der Zuschauerplatz wurde nach dem Trennen nie frei")
zweiter = self.hole("/stream")
self.addCleanup(zweiter.close)
self.assertEqual(zweiter.status, 200)
def test_stehende_kamera_gibt_den_platz_wieder_frei(self):
"""🔑 Die Falle, die der Zuschauerplatz selbst aufstellt.
Ohne neues Bild schreibt der Server nichts, merkt also auch nicht, dass
der Zuschauer weg ist -- und haelt den einen Platz fuer immer. Der
Detector bekaeme bei jedem Versuch 503, die Kamera waere dauerhaft
stumm. Deshalb endet ein Stream ohne Bild nach ``STREAM_LEERLAUF``."""
vogelkamera.STREAM_LEERLAUF = 0.3
self.addCleanup(setattr, vogelkamera, "STREAM_LEERLAUF", 15.0)
self.schiebe() # ein Bild, danach bleibt die Kamera stehen
erster = self.hole("/stream")
self.addCleanup(erster.close)
erster.read(len(BILD))
for _ in range(100):
if vogelkamera.zuschauer_platz.acquire(blocking=False):
vogelkamera.zuschauer_platz.release()
break
time.sleep(0.02)
else:
self.fail("Der Platz blieb an einer stehenden Kamera haengen")
zweiter = self.hole("/stream")
self.addCleanup(zweiter.close)
self.assertEqual(zweiter.status, 200)
# -- /status ----------------------------------------------------------
def test_status_antwortet_ohne_bild(self):
"""Genau dann braucht man ihn: die Kamera liefert nichts und man will
wissen, warum -- ohne den Stream anzufassen."""
with self.hole("/status") as r:
self.assertEqual(r.status, 200)
self.assertEqual(r.headers["Content-Type"], "application/json")
d = json.loads(r.read())
self.assertEqual(d["kamera"], "fehlt")
self.assertEqual(d["fehler"], "0x1")
self.assertEqual(d["bilder"], 0)
self.assertEqual(d["letztes_bild_s"], -1.0)
self.assertFalse(d["zuschauer"])
def test_status_zaehlt_bilder_auch_ohne_zuschauer(self):
"""Die Kamera liefert ins Leere, wenn niemand zuschaut -- /status muss
trotzdem sagen koennen, dass sie arbeitet."""
for _ in range(3):
self.schiebe()
with self.hole("/status") as r:
d = json.loads(r.read())
self.assertEqual(d["kamera"], "bereit")
self.assertEqual(d["fehler"], "0x0")
self.assertEqual(d["bilder"], 3)
self.assertGreaterEqual(d["letztes_bild_s"], 0.0)
self.assertFalse(d["zuschauer"])
def test_status_hat_die_felder_der_esp32_seite(self):
"""Gleiches Schema auf beiden Kameraseiten, sonst braucht jedes
Werkzeug zwei Faelle. ``psram_frei`` fehlt absichtlich -- das gibt es
nur auf dem ESP32."""
self.schiebe()
with self.hole("/status") as r:
d = json.loads(r.read())
for feld in ("kamera", "fehler", "laufzeit_s", "bilder", "fps",
"letztes_bild_s", "zuschauer", "rssi", "temperatur_c",
"speicher_frei"):
self.assertIn(feld, d)
def test_status_sieht_den_zuschauer(self):
self.speisen()
strom = self.hole("/stream")
self.addCleanup(strom.close)
strom.read(len(BILD))
with self.hole("/status") as r:
self.assertTrue(json.loads(r.read())["zuschauer"])
def test_status_geht_waehrend_der_stream_laeuft(self):
"""Der Stream haelt seinen Socket dauerhaft. Waere der Server dann
blockiert, waere /status nutzlos -- gefragt ist er ja im Betrieb."""
self.speisen()
strom = self.hole("/stream")
self.addCleanup(strom.close)
strom.read(len(BILD))
with self.hole("/status", timeout=5.0) as r:
self.assertEqual(r.status, 200)
class MesswerteTests(unittest.TestCase):
"""Die drei Ablesungen aus /proc und /sys. Auf einem Nicht-Pi fehlen sie
teils -- dann ist None richtig und ein Absturz falsch."""
def test_temperatur_ist_zahl_oder_none(self):
c = vogelkamera.read_cpu_temp()
self.assertTrue(c is None or isinstance(c, float))
def test_funkstaerke_ist_zahl_oder_none(self):
r = vogelkamera.funkstaerke()
self.assertTrue(r is None or isinstance(r, int))
def test_speicher_ist_zahl_oder_none(self):
m = vogelkamera.speicher_frei()
self.assertTrue(m is None or m > 0)
def test_status_json_ueberlebt_fehlende_messwerte(self):
"""Auf einem Nicht-Pi fehlt alles, was aus /sys kommt. Trotzdem muss
das JSON vollstaendig und serialisierbar sein -- die Bruecke nach Home
Assistant liest es blind."""
vogelkamera.frame_buffer = vogelkamera.FrameBuffer()
d = vogelkamera.status_json()
json.dumps(d) # wirft, wenn ein Wert nicht serialisierbar ist
self.assertEqual(d["kamera"], "fehlt")
class FrameBufferTests(unittest.TestCase):
def test_haelt_nur_das_neueste_bild(self):
"""Kein Rueckstau: wer sich spaet einklinkt, will das aktuelle Bild
und nicht die Warteschlange von vor zehn Sekunden."""
p = vogelkamera.FrameBuffer()
p.update(b"alt")
p.update(b"neu")
self.assertEqual(p.get()[0], b"neu")
def test_leerer_puffer_hat_keinen_zeitstempel(self):
"""Die Null ist der Wert, an dem /status 'fehlt' erkennt -- ein
geratener Zeitstempel wuerde eine stehende Kamera gesund aussehen
lassen."""
p = vogelkamera.FrameBuffer()
frame, ts = p.get()
self.assertIsNone(frame)
self.assertEqual(ts, 0)
def test_datiert_jedes_bild(self):
p = vogelkamera.FrameBuffer()
p.update(b"eins")
erster = p.get()[1]
self.assertGreater(erster, 0)
time.sleep(0.02)
p.update(b"zwei")
self.assertGreater(p.get()[1], erster)
if __name__ == "__main__":
unittest.main()