Files
vogelalbum/detector/watcher.py
T
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

297 lines
11 KiB
Python

"""Die Zustandsmaschine, die aus Einzelbildern eine "Sichtung" macht.
Eine Sichtung ist ein Besuch: Vogel landet, bleibt ein paar Sekunden, fliegt
weg. Über die Dauer sammeln wir Stimmen und behalten das schärfste Foto.
Warum abstimmen statt einfach das erste Ergebnis nehmen? Weil ein Vogel sich
dreht. Von hinten ist eine Kohlmeise nur eine olivgrüne Kugel; von vorn ist
sie eindeutig. Über 30 Frames gewinnt die richtige Art zuverlässig — eine
Einzelmessung kippt bei jedem Flügelschlag.
"""
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from PIL import Image
from .classify import Box, Pipeline, Result
from .motion import MotionDetector, sharpness
from .species_de import Species
@dataclass
class Sighting:
started_at: datetime
ended_at: datetime
species: Species | None
confidence: float
votes: dict[str, float]
frames_total: int
frames_with_bird: int
best_image: Image.Image | None
best_crop: Image.Image | None
best_sharpness: float
mean_bird_score: float
# Wo der Vogel im besten Bild sitzt, relativ. Die Sammlung schneidet
# daraus selbst zu -- siehe web/dex/zuschnitt.py.
best_box: tuple[float, float, float, float] | None = None
@property
def duration_s(self) -> float:
return (self.ended_at - self.started_at).total_seconds()
def summary(self) -> str:
who = self.species.german if self.species else "unbestimmt"
return (
f"{self.started_at:%H:%M:%S} {who} ({self.confidence:.0%}) "
f"{self.duration_s:.1f}s, {self.frames_with_bird}/{self.frames_total} Frames"
)
@dataclass
class WatcherConfig:
classify_every: int = 3
"""Nur jedes n-te bewegte Bild klassifizieren. Der Vogel ändert sich
zwischen zwei Frames kaum, das Modell aber kostet 25 ms."""
cooldown_s: float = 4.0
"""So lange ohne Bewegung, bis der Besuch als beendet gilt."""
max_visit_s: float = 120.0
"""Notbremse: dauerhafte Bewegung (Regen, schwankender Ast) soll nicht zu
einer stundenlangen Sichtung führen."""
min_bird_frames: int = 2
"""Weniger Treffer als das -> verwerfen. Ein einzelnes Frame ist Rauschen."""
min_confidence: float = 0.45
"""Darunter wird die Sichtung als 'unbestimmt' gespeichert statt geraten."""
min_mean_bird_score: float = 0.0
"""Mittlerer Vogelwert ueber den ganzen Besuch; darunter kein Besuch.
⚠ Nicht zu verwechseln mit ``Pipeline.min_bird_score``: der gilt je
EINZELBILD, dieser fuer den DURCHSCHNITT. Genau diese Luecke liess
Zufallstreffer durch -- ein einziges Bild von 73 mit einem Hauch
Vogelwert reichte fuer einen Besuch.
📌 Vorgabe 0: Der Wert will gemessen und nicht geraten sein. Aus den
Daten vom 01.10.2026 lagen echte Sichtungen bei 0,269 bis 0,600,
eine Befestigungsschraube bei 0,098 und Mondspiegelungen bei 0,000.
Die Web-App gibt den Wert je Kamera vor (``/api/config/``).
"""
gate: str = "motion"
"""Was einen Besuch beginnt und beendet — ``"motion"`` oder ``"detector"``.
``motion`` ist der sparsame Weg: Frame-Differencing kostet unter 1 ms und
hält das Modell aus dem Spiel, solange sich nichts rührt. Das setzt aber
einen ruhigen Hintergrund voraus.
``detector`` lässt den Vogeldetektor selbst entscheiden und ignoriert
Bewegung ganz. Nötig, sobald die Kamera auf etwas dauerhaft Bewegtes
schaut — eine Hecke im Wind löst sonst rund um die Uhr aus. Kostet bei
4 fps nur wenige Prozent CPU und ist damit fast immer bezahlbar.
"""
save_debug: bool = False
class Watcher:
def __init__(
self,
pipeline: Pipeline | None = None,
motion: MotionDetector | None = None,
config: WatcherConfig | None = None,
beobachter=None,
):
"""``beobachter`` wird nach jeder Analyse mit ``(box, result)``
aufgerufen — auch wenn nichts gefunden wurde. Damit kann ein
Protokoll mitschreiben, ohne dass die Zustandsmaschine davon weiß."""
self.pipeline = pipeline or Pipeline()
self.motion = motion or MotionDetector()
self.cfg = config or WatcherConfig()
self.beobachter = beobachter
self._reset_visit()
def _reset_visit(self) -> None:
self._active = False
self._start: datetime | None = None
self._last_motion = 0.0
self._votes: dict[str, float] = defaultdict(float)
self._species_by_slug: dict[str, Species] = {}
self._frames = 0
self._bird_frames = 0
self._best_sharp = -1.0
self._best_image: Image.Image | None = None
self._best_crop: Image.Image | None = None
self._best_box: tuple[float, float, float, float] | None = None
self._bird_scores: list[float] = []
self._frame_i = 0
def process(self, frame: Image.Image) -> Sighting | None:
"""Ein Bild einspeisen. Gibt eine Sichtung zurück, wenn ein Besuch
gerade zu Ende gegangen ist, sonst None."""
if self.cfg.gate == "detector":
return self._process_detector_gated(frame)
return self._process_motion_gated(frame)
def _process_motion_gated(self, frame: Image.Image) -> Sighting | None:
now = time.monotonic()
ratio = self.motion.update(frame)
moving = self.motion.is_motion(ratio)
if not self._active:
if not moving:
return None
self._active = True
self._start = datetime.now(timezone.utc)
self._last_motion = now
self._frames += 1
if moving:
self._last_motion = now
self._frame_i += 1
if self._frame_i % self.cfg.classify_every == 0:
self._analyse(frame)
return self._maybe_close(now)
def _process_detector_gated(self, frame: Image.Image) -> Sighting | None:
"""Der Vogeldetektor entscheidet selbst, wann ein Besuch läuft.
Für Kameras, die auf etwas dauerhaft Bewegtes schauen. Jedes n-te Bild
geht durchs Modell, unabhängig davon ob sich etwas bewegt hat — Wind in
der Hecke ist damit schlicht kein Thema mehr.
"""
now = time.monotonic()
self._frame_i += 1
if self._frame_i % self.cfg.classify_every != 0:
if self._active:
self._frames += 1
return None
before = self._bird_frames
# Beim ersten Treffer eines Besuchs steht der Startzeitpunkt noch nicht;
# _analyse zählt _bird_frames hoch, daran erkennen wir den Vogel.
pending_start = datetime.now(timezone.utc)
self._analyse(frame)
saw_bird = self._bird_frames > before
if not self._active:
if not saw_bird:
# Kein Vogel und kein laufender Besuch: die Zwischenergebnisse
# aus _analyse gehören zu nichts und müssen weg.
self._reset_visit()
return None
self._active = True
self._start = pending_start
self._last_motion = now
self._frames += 1
if saw_bird:
self._last_motion = now
return self._maybe_close(now)
def _maybe_close(self, now: float) -> Sighting | None:
too_long = self._start is not None and (
(datetime.now(timezone.utc) - self._start).total_seconds()
> self.cfg.max_visit_s
)
if (now - self._last_motion) > self.cfg.cooldown_s or too_long:
return self._close()
return None
def _analyse(self, frame: Image.Image) -> None:
box, result = self.pipeline.run(frame)
if self.beobachter is not None:
# Auch Beinahe-Treffer melden — das Protokoll lebt genau davon.
self.beobachter(box, result)
if not self.pipeline.zaehlt_als_vogel(box):
return
self._bird_frames += 1
if result is not None:
self._bird_scores.append(result.bird_score)
self._vote(box, result)
# Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein
# gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger
# als ein leicht weicheres, auf dem er ganz zu sehen ist.
# Schaerfe am ENGEN Ausschnitt messen: Umgebung soll die Bewertung
# nicht verwaessern. Gespeichert wird dann der weitere -- siehe
# Box.anzeige_ausschnitt.
crop = box.crop(frame)
score = sharpness(crop) * (0.5 + 0.5 * box.score)
if score > self._best_sharp:
self._best_sharp = score
self._best_image = frame.copy()
self._best_crop = box.anzeige_ausschnitt(frame)
self._best_box = (box.x0, box.y0, box.x1, box.y1)
def _vote(self, box: Box, result: Result) -> None:
# Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild,
# und wie viel Wahrscheinlichkeitsmasse liegt überhaupt auf heimischen
# Arten. Ein 20-Pixel-Fleck am Bildrand soll die Abstimmung nicht
# gewinnen.
size_w = min(1.0, box.area / 0.05)
weight = box.score * (0.4 + 0.6 * size_w) * result.bird_score
for guess in result.guesses[:3]:
self._votes[guess.species.slug] += weight * guess.confidence
self._species_by_slug[guess.species.slug] = guess.species
def _close(self) -> Sighting | None:
if self._bird_frames < self.cfg.min_bird_frames:
self._reset_visit()
return None
# ⚠ Der Durchschnitt ueber den ganzen Besuch, nicht das beste Bild.
# Hier faellt der Zufallstreffer durch: ein Bild von 73 mit einem
# Hauch Vogelwert zieht den Mittelwert kaum nach oben. Lieber hier
# verwerfen als senden -- ein Bild hochzuladen, das die Web-App
# ohnehin abweist, kostet eine Kamera am Gartenzaun echte Bandbreite.
mittel = (
sum(self._bird_scores) / len(self._bird_scores)
if self._bird_scores else 0.0
)
if mittel < self.cfg.min_mean_bird_score:
self._reset_visit()
return None
species: Species | None = None
confidence = 0.0
total = sum(self._votes.values())
if total > 0:
slug, score = max(self._votes.items(), key=lambda kv: kv[1])
confidence = score / total
if confidence >= self.cfg.min_confidence:
species = self._species_by_slug[slug]
sighting = Sighting(
started_at=self._start or datetime.now(timezone.utc),
ended_at=datetime.now(timezone.utc),
species=species,
confidence=confidence,
votes={k: v / total for k, v in self._votes.items()} if total else {},
frames_total=self._frames,
frames_with_bird=self._bird_frames,
best_image=self._best_image,
best_crop=self._best_crop,
best_box=self._best_box,
best_sharpness=max(0.0, self._best_sharp),
mean_bird_score=mittel,
)
self._reset_visit()
return sighting
def flush(self) -> Sighting | None:
"""Laufenden Besuch abschließen — beim Beenden des Dienstes."""
return self._close() if self._active else None