Files
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

959 lines
35 KiB
Python

"""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)