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.
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""Abschaltung bei Dunkelheit.
|
||
|
||
Eine Kamera ohne Infrarot liefert nachts ein schwarzes Bild. Es durch zwei
|
||
neuronale Netze zu schicken kostet Strom und bringt nichts.
|
||
|
||
Entschieden wird über die **tatsächliche Bildhelligkeit**, nicht über
|
||
Sonnenauf- und -untergang. Zwei Gründe: Es braucht keine Koordinaten und
|
||
keine Zeitzonenrechnerei, und es misst genau das, worauf es ankommt — ob man
|
||
etwas sieht. Ein Futterhaus unter einer Straßenlaterne bleibt nutzbar, ein
|
||
zugewachsenes ist schon vor Sonnenuntergang zu dunkel. Der Kalender weiß das
|
||
nicht, das Bild schon.
|
||
|
||
Angenehmer Nebeneffekt: Eine Kamera mit Infrarot liefert nachts ein graues,
|
||
aber helles Bild. Sie läuft damit von selbst weiter, ohne dass jemand etwas
|
||
einstellen muss. Der ausdrückliche Schalter (``enabled=False``) bleibt
|
||
trotzdem, falls die Automatik danebenliegt.
|
||
|
||
Die Voreinstellungen sind an der Kamera am Vogelhaus gemessen, nicht geraten:
|
||
tagsüber 111–131, nachts 1,7 von 255.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
|
||
def brightness(img: Image.Image, width: int = 96) -> float:
|
||
"""Mittlere Helligkeit 0–255. Auf einem winzigen Graubild, das kostet nichts."""
|
||
h = max(1, round(img.height * width / img.width))
|
||
small = img.convert("L").resize((width, h), Image.BILINEAR)
|
||
return float(np.asarray(small, dtype=np.float32).mean())
|
||
|
||
|
||
class DaylightGate:
|
||
"""Entscheidet, ob es hell genug zum Analysieren ist.
|
||
|
||
Mit Hysterese: Der Wert zum Abschalten liegt deutlich unter dem zum
|
||
Wiedereinschalten. Ohne das flattert die Entscheidung in der Dämmerung im
|
||
Sekundentakt hin und her, und jede vorbeifahrende Autoscheinwerfer-Reflexion
|
||
startet einen neuen Besuch.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
dark_below: float = 15.0,
|
||
light_above: float = 30.0,
|
||
patience: int = 20,
|
||
enabled: bool = True,
|
||
):
|
||
if light_above <= dark_below:
|
||
raise ValueError(
|
||
"light_above muss über dark_below liegen, sonst gibt es keine "
|
||
"Hysterese und die Entscheidung flattert."
|
||
)
|
||
self.dark_below = dark_below
|
||
self.light_above = light_above
|
||
self.patience = patience
|
||
self.enabled = enabled
|
||
|
||
self.is_dark = False
|
||
self.last_brightness = 255.0
|
||
self._streak = 0
|
||
|
||
def update(self, img: Image.Image) -> bool:
|
||
"""Bild einspeisen. Gibt zurück, ob analysiert werden soll."""
|
||
if not self.enabled:
|
||
return True
|
||
|
||
self.last_brightness = brightness(img)
|
||
|
||
# Zählt nur, was für einen Wechsel spricht. Ein einzelnes helles oder
|
||
# dunkles Bild setzt die Strähne zurück.
|
||
will_wechseln = (
|
||
self.last_brightness >= self.light_above
|
||
if self.is_dark
|
||
else self.last_brightness < self.dark_below
|
||
)
|
||
self._streak = self._streak + 1 if will_wechseln else 0
|
||
|
||
if self._streak >= self.patience:
|
||
self.is_dark = not self.is_dark
|
||
self._streak = 0
|
||
|
||
return not self.is_dark
|
||
|
||
@property
|
||
def status(self) -> str:
|
||
if not self.enabled:
|
||
return "Helligkeitsabschaltung aus (Nachtsicht)"
|
||
zustand = "dunkel — pausiert" if self.is_dark else "hell genug"
|
||
return f"{zustand}, Helligkeit {self.last_brightness:.0f}/255"
|