"""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())