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.
207 lines
7.2 KiB
Python
207 lines
7.2 KiB
Python
"""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.")
|