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

+206
View File
@@ -0,0 +1,206 @@
"""Auswertung der gespeicherten Sichtungen.
Beantwortet die Frage, die man nach den ersten Tagen wirklich hat: Sehen wir
Vögel, und taugen die Bilder etwas?
python -m detector report live/roh
python -m detector report live/roh --kontaktbogen bogen.jpg
Der Detector schreibt mit ``--save-to`` je Sichtung ein Vollbild, einen
Ausschnitt und eine JSON-Datei. Diese Auswertung liest den Ordner und misst
nach, statt zu schätzen:
* **Schärfe** — Varianz der Laplace-Antwort auf dem Ausschnitt. Nicht
kalibriert, aber zwischen Sichtungen derselben Kamera vergleichbar. Unter
etwa 50 ist ein Bild sichtbar verwackelt.
* **Fläche** — wie viel Prozent des Bildes der Vogel einnimmt. Der stärkste
Hebel für die Erkennungsqualität: Ein Vogel unter 3 % ist ein Fleck, und
daran ändert kein Schwellwert etwas. Dann muss die Kamera näher ran.
* **Helligkeit** — Gegenlicht und Dämmerung sichtbar machen.
Der Kontaktbogen legt die Ausschnitte nebeneinander. Ob die Bildqualität
reicht, sieht man in zehn Sekunden — und nicht, indem man sechzig Dateien
einzeln öffnet.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, ImageDraw
from .daylight import brightness
from .motion import sharpness
@dataclass
class Eintrag:
stamp: str
art: str
confidence: float
frames_mit: int
frames_gesamt: int
schaerfe: float
flaeche: float
helligkeit: float
bild: Path | None
crop: Path | None
@property
def trefferquote(self) -> float:
return self.frames_mit / self.frames_gesamt if self.frames_gesamt else 0.0
def lade(ordner: Path) -> list[Eintrag]:
eintraege: list[Eintrag] = []
for js in sorted(ordner.glob("*.json")):
try:
daten = json.loads(js.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
bild = js.with_suffix(".jpg")
crop = js.with_name(js.stem + "_crop.jpg")
bild = bild if bild.exists() else None
crop = crop if crop.exists() else None
schaerfe = flaeche = hell = 0.0
if crop is not None and bild is not None:
try:
c = Image.open(crop)
v = Image.open(bild)
schaerfe = sharpness(c)
hell = brightness(v)
# Flächenanteil aus den Maßen: der Ausschnitt stammt aus dem
# Vollbild, das Verhältnis ist also direkt ablesbar.
flaeche = (c.width * c.height) / (v.width * v.height) * 100
except OSError:
pass
eintraege.append(
Eintrag(
stamp=js.stem.split("_")[0],
art=daten.get("species") or "unbestimmt",
confidence=float(daten.get("confidence") or 0.0),
frames_mit=int(daten.get("frames_with_bird") or 0),
frames_gesamt=int(daten.get("frames_total") or 0),
schaerfe=schaerfe,
flaeche=flaeche,
helligkeit=hell,
bild=bild,
crop=crop,
)
)
return eintraege
def _urteil(e: Eintrag) -> str:
"""Kurzes Urteil zur Bildqualität. Die Schwellen sind Erfahrungswerte."""
if e.flaeche < 3:
return "Vogel zu klein"
if e.schaerfe < 50:
return "verwackelt"
if e.helligkeit < 40:
return "zu dunkel"
if e.flaeche >= 10 and e.schaerfe >= 150:
return "sehr gut"
return "brauchbar"
def kontaktbogen(eintraege: list[Eintrag], ziel: Path, spalten: int = 6,
kachel: int = 200) -> int:
"""Legt die Ausschnitte als Raster nebeneinander, mit Beschriftung."""
mit_bild = [e for e in eintraege if e.crop is not None]
if not mit_bild:
return 0
zeilen = (len(mit_bild) + spalten - 1) // spalten
beschriftung = 26
blatt = Image.new(
"RGB",
(spalten * kachel, zeilen * (kachel + beschriftung)),
(245, 247, 244),
)
zeichner = ImageDraw.Draw(blatt)
for i, e in enumerate(mit_bild):
x = (i % spalten) * kachel
y = (i // spalten) * (kachel + beschriftung)
try:
img = Image.open(e.crop).convert("RGB")
except OSError:
continue
img.thumbnail((kachel, kachel), Image.LANCZOS)
blatt.paste(img, (x + (kachel - img.width) // 2,
y + (kachel - img.height) // 2))
zeichner.text(
(x + 4, y + kachel + 6),
f"{e.stamp[-6:]} {e.art[:12]} {e.confidence:.0%}",
fill=(60, 81, 71),
)
ziel.parent.mkdir(parents=True, exist_ok=True)
blatt.save(ziel, quality=88)
return len(mit_bild)
def zeige(eintraege: list[Eintrag], ordner: Path) -> None:
if not eintraege:
print(f"Keine Sichtungen in {ordner}.")
print("Der Detector muss mit --save-to laufen, damit hier etwas landet.")
return
print(f"\n{len(eintraege)} Sichtungen in {ordner}\n")
print(f"{'Zeit':<16}{'Art':<20}{'Sich.':>6}{'Frames':>9}"
f"{'Fläche':>8}{'Schärfe':>9}{'Hell':>6} Urteil")
print("─" * 88)
for e in eintraege:
zeit = f"{e.stamp[6:8]}.{e.stamp[4:6]}. {e.stamp[9:11]}:{e.stamp[11:13]}" \
if len(e.stamp) >= 13 else e.stamp
print(
f"{zeit:<16}{e.art[:19]:<20}{e.confidence:>5.0%}"
f"{e.frames_mit:>5}/{e.frames_gesamt:<3}"
f"{e.flaeche:>7.1f}%{e.schaerfe:>9.0f}{e.helligkeit:>6.0f} {_urteil(e)}"
)
bestimmt = [e for e in eintraege if e.art != "unbestimmt"]
mit_mass = [e for e in eintraege if e.crop is not None]
print("\n" + "─" * 88)
print(f"Bestimmt {len(bestimmt):>4} von {len(eintraege)}"
f" ({len(bestimmt) / len(eintraege):.0%})")
if bestimmt:
arten = {}
for e in bestimmt:
arten[e.art] = arten.get(e.art, 0) + 1
top = ", ".join(f"{a} ×{n}" for a, n in
sorted(arten.items(), key=lambda kv: -kv[1])[:6])
print(f"Arten {len(arten)} verschiedene — {top}")
if mit_mass:
def mittel(f):
return sum(f(e) for e in mit_mass) / len(mit_mass)
print(f"Fläche im Bild {mittel(lambda e: e.flaeche):.1f} % im Mittel, "
f"kleinste {min(e.flaeche for e in mit_mass):.1f} %")
print(f"Schärfe {mittel(lambda e: e.schaerfe):.0f} im Mittel, "
f"schlechteste {min(e.schaerfe for e in mit_mass):.0f}")
print(f"Helligkeit {mittel(lambda e: e.helligkeit):.0f} von 255")
urteile: dict[str, int] = {}
for e in mit_mass:
u = _urteil(e)
urteile[u] = urteile.get(u, 0) + 1
print("\nBildqualität:")
for u, n in sorted(urteile.items(), key=lambda kv: -kv[1]):
print(f" {u:<16} {n:>3} {'█' * round(24 * n / len(mit_mass))}")
zu_klein = [e for e in mit_mass if e.flaeche < 3]
if len(zu_klein) > len(mit_mass) / 3:
print("\nMehr als ein Drittel der Vögel füllt unter 3 % des Bildes.")
print("Das ist der stärkste Hebel: Die Kamera gehört näher heran oder")
print("enger eingestellt — an Schwellwerten ist da nichts zu holen.")