Files
vogelalbum/detector/report.py
T
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

207 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.")