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.
297 lines
11 KiB
Python
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
|