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

+86
View File
@@ -0,0 +1,86 @@
"""Bewegungserkennung und Bildschärfe — beides in numpy, ohne OpenCV.
Warum überhaupt Bewegungserkennung, wenn wir doch einen Vogeldetektor haben?
Weil der Detektor 25 ms kostet und die Kamera 24/7 läuft. Frame-Differencing
kostet unter 1 ms und filtert die 99 % leeres Vogelhaus weg.
"""
from __future__ import annotations
import numpy as np
from PIL import Image
def _gray_small(img: Image.Image, width: int = 96) -> np.ndarray:
h = max(1, round(img.height * width / img.width))
small = img.convert("L").resize((width, h), Image.BILINEAR)
return np.asarray(small, dtype=np.float32)
class MotionDetector:
"""Laufender Hintergrund als exponentieller Mittelwert.
Ein fixes Referenzbild funktioniert draußen nicht: Sonne wandert, Wolken
ziehen, abends wird es dunkel. Der gleitende Hintergrund zieht langsam
nach, ein Vogel ist dafür aber zu schnell.
"""
def __init__(
self,
threshold: int = 22,
min_ratio: float = 0.012,
adapt: float = 0.06,
width: int = 96,
):
self.threshold = threshold
self.min_ratio = min_ratio
self.adapt = adapt
self.width = width
self.background: np.ndarray | None = None
def update(self, img: Image.Image) -> float:
"""Gibt den Anteil bewegter Pixel zurück (0..1)."""
cur = _gray_small(img, self.width)
if self.background is None or self.background.shape != cur.shape:
self.background = cur
return 0.0
diff = np.abs(cur - self.background)
ratio = float(np.count_nonzero(diff > self.threshold) / diff.size)
# Bei Bewegung langsamer nachziehen, sonst frisst sich ein sitzender
# Vogel binnen Sekunden in den Hintergrund ein und gilt als "weg".
alpha = self.adapt * (0.15 if ratio > self.min_ratio else 1.0)
self.background = (1 - alpha) * self.background + alpha * cur
return ratio
def is_motion(self, ratio: float) -> bool:
return ratio >= self.min_ratio
def reset(self) -> None:
self.background = None
# 3x3-Laplace-Kern. Die Varianz der Antwort ist das Standardmaß für Schärfe:
# ein verwackeltes Bild hat kaum hohe Frequenzen und damit kaum Varianz.
_LAPLACE = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
def sharpness(img: Image.Image, width: int = 320) -> float:
"""Schärfemaß zur Auswahl des besten Fotos einer Sichtung.
Nicht kalibriert und nur innerhalb einer Sichtung vergleichbar — genau
dafür brauchen wir es.
"""
a = _gray_small(img, width)
if a.shape[0] < 3 or a.shape[1] < 3:
return 0.0
# Direkte 2D-Faltung über Slicing; für einen 3x3-Kern schneller und
# abhängigkeitsfrei gegenüber scipy.
out = np.zeros((a.shape[0] - 2, a.shape[1] - 2), dtype=np.float32)
for dy in range(3):
for dx in range(3):
k = _LAPLACE[dy, dx]
if k:
out += k * a[dy : dy + out.shape[0], dx : dx + out.shape[1]]
return float(out.var())