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:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,296 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user