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

+92
View File
@@ -0,0 +1,92 @@
"""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"