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,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())
|
||||
Reference in new issue
Block a user