"""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.")