Files
vogelalbum/detector/classify.py
T
christianandClaude Opus 5 dc7db4b603 Zugeschnittene Fotos ohne Sucher bestimmen
Ein Handyfoto ist das Gegenteil einer Kameraaufnahme: Jemand zielt auf den
Vogel. Unterhalb von rund einem Viertel Bildflaeche findet SSD-MobileNet ihn
nicht mehr (12 % Flaeche: 38 % gefunden, 6 %: 10 %, 2 %: 0 %) — und auf einem
Zuschnitt, der nur noch aus Vogel besteht, meldet es sich ueberhaupt nicht.

run_zuschnitt() ueberspringt den Sucher. Gemessen an schwierigen Aufnahmen:
aus 'nichts gefunden' wird Haussperling 93 %.

Dabei faellt der Sucher als Torwaechter weg. Die erste Fassung liess
min_bird_score (0.05) entscheiden und gab fuer eine graue Wand Fischadler
20 % zurueck; ueber 148 Vogelbilder und 71 Hintergrundausschnitte kommen bei
dieser Schwelle 82 % der Hintergruende durch. Das Tor ist jetzt die
Sicherheit des besten Treffers (0.45: 96 % der Voegel, 14 % Hintergrund)
zusammen mit min_bird_score.

Alle Messungen stehen in docs/handyfotos.md, auch die dort als verworfen
dokumentierte Kachelsuche.

Die Pruefbilder liegen bewusst NICHT hier: Dieses Repo ist oeffentlich, die
Aufnahmen sind privat. Sie liegen in der Nextcloud unter
Projekte/Vogelalbum/Pruefbilder; 'pruefbilder/' steht in der .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:34:52 +00:00

422 lines
17 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.
"""Artbestimmung per LiteRT (ehemals TFLite).
Zwei Stufen:
1. ``BirdFinder`` — SSD-MobileNet auf COCO. Beantwortet nur: "ist da überhaupt
ein Vogel, und wo?". Verhindert, dass ein wackelnder Ast als Kohlmeise
in die Sammlung wandert, und liefert den Ausschnitt fürs Zuschneiden.
2. ``SpeciesClassifier`` — MobileNetV2 auf iNaturalist-Vögeln (965 Arten).
Läuft auf dem zugeschnittenen Vogel, nicht auf dem ganzen Bild.
Beide Modelle sind int8-quantisiert und laufen auf dem N100 in wenigen
Dutzend Millisekunden — reicht dicke, weil nur bei Bewegung klassifiziert wird.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from PIL import Image
from .species_de import BY_SCIENTIFIC, Species, catalog, label_to_scientific
MODELS = Path(__file__).resolve().parent.parent / "models"
CLASSIFIER_MODEL = MODELS / "mobilenet_v2_1.0_224_inat_bird_quant.tflite"
CLASSIFIER_LABELS = MODELS / "inat_bird_labels.txt"
DETECTOR_MODEL = MODELS / "ssd_mobilenet_v2_coco_quant_postprocess.tflite"
COCO_BIRD_CLASS = 15 # in diesem Modell ist 15 = "bird"
def _interpreter(path: Path):
from ai_edge_litert.interpreter import Interpreter
if not path.exists():
raise FileNotFoundError(
f"Modell fehlt: {path}\nHol es mit: ./scripts/fetch_models.sh"
)
interp = Interpreter(model_path=str(path), num_threads=4)
interp.allocate_tensors()
return interp
@dataclass
class Box:
"""Bounding Box in relativen Koordinaten (0..1)."""
x0: float
y0: float
x1: float
y1: float
score: float
def crop(self, img: Image.Image, pad: float = 0.12) -> Image.Image:
"""Schneidet den Vogel aus — mit etwas Rand, sonst fehlen Schwanz und
Schnabelspitze, und genau daran hängt oft die Artbestimmung."""
w, h = img.size
bw, bh = (self.x1 - self.x0) * w, (self.y1 - self.y0) * h
x0 = max(0, int(self.x0 * w - bw * pad))
y0 = max(0, int(self.y0 * h - bh * pad))
x1 = min(w, int(self.x1 * w + bw * pad))
y1 = min(h, int(self.y1 * h + bh * pad))
if x1 <= x0 or y1 <= y0:
return img
return img.crop((x0, y0, x1, y1))
def anzeige_ausschnitt(self, img: Image.Image, pad: float = 0.35) -> Image.Image:
"""Derselbe Vogel, aber als Bild zum Ansehen statt zum Bestimmen.
🔑 **Der Klassifikator und das Auge wollen Verschiedenes.** ``crop``
liegt eng am Vogel, weil jedes Stück Hintergrund die Bestimmung
verwässert. Für die Sammlung ist genau das falsch: Dort steht der
Ausschnitt in einer fast quadratischen Kachel, und die schneidet links
und rechts weg, was über das Quadrat hinausragt.
Bei einem Vogel im Hochformat — und so sitzt fast jeder — bleibt davon
die Bauchmitte übrig, formatfüllend und unscharf. Gemeldet wurde es als
*„der Zuschnitt sieht merkwürdig aus"*, und das traf es: Vom Vogel war nur Gefieder zu
sehen, Kopf und Schwanz lagen außerhalb.
Deshalb hier mehr Rand **und** ein Ausschnitt in Richtung Quadrat: Was
die Kachel abschneidet, ist dann Umgebung statt Vogel.
⚠️ Absichtlich eine eigene Methode. ``crop`` hier zu ändern hieße, die
Artbestimmung zu ändern — und die ist gemessen und soll so bleiben.
"""
w, h = img.size
bx0, by0 = self.x0 * w, self.y0 * h
bx1, by1 = self.x1 * w, self.y1 * h
bw, bh = bx1 - bx0, by1 - by0
if bw <= 0 or bh <= 0:
return img
# Erst Rand, dann die kürzere Seite auf die längere ziehen.
seite = max(bw, bh) * (1 + 2 * pad)
mx, my = (bx0 + bx1) / 2, (by0 + by1) / 2
# ⚠️ Nicht über das Bild hinaus, und die Mitte nachführen statt den
# Ausschnitt zu stauchen — sonst sitzt ein Vogel am Bildrand
# außermittig in seiner eigenen Kachel.
seite = min(seite, float(w), float(h))
mx = min(max(mx, seite / 2), w - seite / 2)
my = min(max(my, seite / 2), h - seite / 2)
return img.crop((
int(mx - seite / 2), int(my - seite / 2),
int(mx + seite / 2), int(my + seite / 2),
))
@property
def area(self) -> float:
return max(0.0, self.x1 - self.x0) * max(0.0, self.y1 - self.y0)
class BirdFinder:
"""Findet Vögel im Bild. Sagt nichts über die Art."""
def __init__(self, model: Path = DETECTOR_MODEL, threshold: float = 0.4):
self.interp = _interpreter(model)
self.threshold = threshold
inp = self.interp.get_input_details()[0]
self.index = inp["index"]
self.size = (inp["shape"][2], inp["shape"][1])
self.out = self.interp.get_output_details()
def find(self, img: Image.Image) -> list[Box]:
arr = np.asarray(
img.convert("RGB").resize(self.size, Image.BILINEAR), dtype=np.uint8
)
self.interp.set_tensor(self.index, arr[None, ...])
self.interp.invoke()
boxes = self.interp.get_tensor(self.out[0]["index"])[0]
classes = self.interp.get_tensor(self.out[1]["index"])[0]
scores = self.interp.get_tensor(self.out[2]["index"])[0]
count = int(self.interp.get_tensor(self.out[3]["index"])[0])
found = []
for i in range(min(count, len(scores))):
if int(classes[i]) != COCO_BIRD_CLASS or scores[i] < self.threshold:
continue
y0, x0, y1, x1 = boxes[i]
found.append(
Box(float(x0), float(y0), float(x1), float(y1), float(scores[i]))
)
found.sort(key=lambda b: b.score, reverse=True)
return found
@dataclass
class Guess:
species: Species
confidence: float # nach Neunormierung auf die deutsche Artenliste
def __repr__(self) -> str: # praktisch beim Debuggen im REPL
return f"<{self.species.german} {self.confidence:.1%}>"
@dataclass
class Result:
guesses: list[Guess]
raw_top: list[tuple[str, float]] # ungefiltert, alle 965 Arten
ms: float
bird_score: float = 0.0
"""Wie viel der Gesamtwahrscheinlichkeit fällt auf heimische Arten?
Ein niedriger Wert heißt: das Modell ist sich sicher, einen Vogel zu sehen
— nur eben einen, den es hier nicht gibt. Das ist das Signal für eine
Fehlbestimmung, nicht die Confidence des Top-Treffers, denn die ist nach
der Neunormierung per Konstruktion hoch.
"""
@property
def best(self) -> Guess | None:
return self.guesses[0] if self.guesses else None
class SpeciesClassifier:
def __init__(
self,
model: Path = CLASSIFIER_MODEL,
labels: Path = CLASSIFIER_LABELS,
restrict_to_germany: bool = True,
habitats: set[str] | None = None,
):
"""``habitats`` schränkt den Katalog ein, z.B. ``{"garten"}``.
Je enger die Liste, desto weniger kann das Modell danebengreifen —
ohne Einschränkung stehen 148 Arten zur Wahl, mit Gartenfilter 47.
"""
self.interp = _interpreter(model)
inp = self.interp.get_input_details()[0]
self.index = inp["index"]
self.size = (inp["shape"][2], inp["shape"][1])
out = self.interp.get_output_details()[0]
self.out_index = out["index"]
self.scale, self.zero = out["quantization"]
self.labels = labels.read_text(encoding="utf-8").splitlines()
self.restrict = restrict_to_germany
self.setze_arten(catalog(habitats))
def setze_arten(self, arten: list[Species]) -> None:
"""Den gültigen Katalog austauschen.
Getrennt vom Konstruktor, weil die Web-App die Artenliste vorgibt und
sie sich im Betrieb ändern darf. Das Modell bleibt dabei geladen — neu
berechnet wird nur die Zuordnung der 965 Ausgaben auf den Katalog, und
das kostet Millisekunden statt Sekunden.
"""
nach_name = {a.scientific: a for a in arten}
# Welche der 965 Ausgaben entsprechen Arten aus unserem Katalog?
self.native_idx: dict[int, Species] = {}
for i, label in enumerate(self.labels):
sci = label_to_scientific(label)
if sci in nach_name:
self.native_idx[i] = nach_name[sci]
def classify(self, img: Image.Image, top_k: int = 5) -> Result:
t0 = time.perf_counter()
arr = np.asarray(
img.convert("RGB").resize(self.size, Image.BILINEAR), dtype=np.uint8
)
self.interp.set_tensor(self.index, arr[None, ...])
self.interp.invoke()
raw = self.interp.get_tensor(self.out_index)[0].astype(np.float32)
probs = (raw - self.zero) * self.scale
ms = (time.perf_counter() - t0) * 1000
order = np.argsort(probs)[::-1][:top_k]
raw_top = [(self.labels[i], float(probs[i])) for i in order]
native = [(i, float(probs[i])) for i in self.native_idx]
native.sort(key=lambda t: t[1], reverse=True)
native_mass = sum(p for _, p in native)
if not self.restrict:
guesses = [Guess(self.native_idx[i], p) for i, p in native[:top_k] if p > 0]
return Result(guesses, raw_top, ms, native_mass)
# Neunormierung: die Wahrscheinlichkeitsmasse der ~45 heimischen Arten
# auf 1.0 skalieren. Ohne das liegt jede Confidence im einstelligen
# Prozentbereich, weil sich 965 Klassen die Masse teilen.
guesses = []
if native_mass > 0:
for i, p in native[:top_k]:
if p <= 0:
continue
guesses.append(Guess(self.native_idx[i], p / native_mass))
return Result(guesses, raw_top, ms, native_mass)
class Pipeline:
"""Detektor + Klassifikator als eine Einheit."""
def __init__(
self,
detect_threshold: float = 0.4,
min_bird_score: float = 0.05,
restrict_to_germany: bool = True,
habitats: set[str] | None = None,
report_threshold: float | None = None,
min_zuschnitt_sicherheit: float = 0.45,
):
# 0.05 ist gemessen, nicht geraten: scripts/benchmark.py über die
# 44 Referenzfotos ergibt 43/44 richtig bei 0.02–0.05, aber nur 40/44
# bei 0.10. Der Grund ist die Nordamerika-Schlagseite des Modells —
# beim Mäusebussard gewinnt global der amerikanische Buteo lineatus,
# die heimische Masse fällt auf 5 %, obwohl Buteo buteo innerhalb der
# deutschen Arten klar vorn liegt. Ein zu hoher Schwellwert wirft also
# richtige Bestimmungen weg.
self.detect_threshold = detect_threshold
# Der Detektor sucht ab der *niedrigeren* der beiden Schwellen, damit
# das Protokoll die Beinahe-Treffer ueberhaupt zu sehen bekommt. Mit
# nur einer Schwelle war `--protokoll-ab 0.08` wirkungslos: was der
# Finder bei 0.4 verwarf, kam nie beim Beobachter an — man konnte
# danach nicht unterscheiden zwischen "kein Vogel da" und "Vogel zu
# klein im Bild". Genau diese Frage sollte das Protokoll beantworten.
# Der Wunschwert wird gemerkt, weil 'uebernimm' die Schwelle spaeter
# neu mischen muss: aendert sich detect_threshold, gilt weiterhin die
# niedrigere von beiden.
self._report_wunsch = (
detect_threshold if report_threshold is None else report_threshold
)
self.report_threshold = min(detect_threshold, self._report_wunsch)
self.finder = BirdFinder(threshold=self.report_threshold)
self.classifier = SpeciesClassifier(
restrict_to_germany=restrict_to_germany, habitats=habitats
)
self.min_bird_score = min_bird_score
# Nur fuer `run_zuschnitt` — dort faellt der Sucher als Torwaechter
# weg und diese Schwelle nimmt seinen Platz ein. Begruendung und
# Messung stehen bei der Methode.
self.min_zuschnitt_sicherheit = min_zuschnitt_sicherheit
def zaehlt_als_vogel(self, box: Box | None) -> bool:
"""Reicht diese Box fuer eine Sichtung, oder ist sie nur Protokoll?"""
return box is not None and box.score >= self.detect_threshold
def uebernimm(
self,
detect_threshold: float | None = None,
min_bird_score: float | None = None,
arten: list[Species] | None = None,
) -> list[str]:
"""Einstellungen im laufenden Betrieb aendern.
Gibt zurueck, was sich geaendert hat — leer, wenn nichts. Gebraucht
wird das, weil die Einstellungen aus der Web-App kommen: wer dort eine
Schwelle verstellt, soll nicht den Detector neu starten muessen.
Das Modell bleibt geladen. Die Schwelle des Finders wird mitgezogen,
sonst sucht er weiter nach der alten.
"""
geaendert: list[str] = []
if detect_threshold is not None and detect_threshold != self.detect_threshold:
geaendert.append(
f"Vogelschwelle {self.detect_threshold:.2f} -> {detect_threshold:.2f}"
)
self.detect_threshold = detect_threshold
# Der Finder sucht ab der niedrigeren der beiden Schwellen; das
# Protokoll soll die Beinahe-Treffer weiter sehen.
self.report_threshold = min(self._report_wunsch, detect_threshold)
self.finder.threshold = self.report_threshold
if min_bird_score is not None and min_bird_score != self.min_bird_score:
geaendert.append(
f"Artenschwelle {self.min_bird_score:.2f} -> {min_bird_score:.2f}"
)
self.min_bird_score = min_bird_score
if arten is not None:
alt = {a.scientific for a in self.classifier.native_idx.values()}
neu = {a.scientific for a in arten}
if alt != neu:
self.classifier.setze_arten(arten)
erkannt = len(self.classifier.native_idx)
geaendert.append(
f"Katalog {len(alt)} -> {len(neu)} Arten "
f"({erkannt} davon kennt das Modell)"
)
return geaendert
def run(self, img: Image.Image) -> tuple[Box | None, Result | None]:
"""Beste Box im Bild, ggf. mit Artbestimmung.
Die Box kann unter ``detect_threshold`` liegen — dann ist sie fuers
Protokoll gedacht und nicht fuer eine Sichtung; ``result`` bleibt in
dem Fall ``None``, weil sich die Klassifikation fuer einen
Beinahe-Treffer nicht lohnt. ``zaehlt_als_vogel`` entscheidet.
"""
boxes = self.finder.find(img)
if not boxes:
return None, None
box = boxes[0]
if not self.zaehlt_als_vogel(box):
return box, None
result = self.classifier.classify(box.crop(img))
if result.bird_score < self.min_bird_score:
# Vogel ja, heimische Art nein — lieber gar keine Bestimmung als
# eine falsche im Vogelalbum.
return box, None
return box, result
# ---- Weg fuer einzelne Fotos ---------------------------------------
def run_zuschnitt(self, img: Image.Image) -> Result | None:
"""Ein Bild bestimmen, das schon auf den Vogel gezogen ist.
🔴 **Der Sucher wird hier absichtlich uebersprungen.** Auf einem
Zuschnitt, der nur noch aus Vogel besteht, meldet SSD-MobileNet gar
nichts — es ist auf Objekte in einer Szene trainiert, nicht auf
formatfuellende Tiere. Gemessen am 24.10.2026: Ein Bild, bei dem die
Kette „nichts gefunden" sagte, wird so zu *Haussperling 93 %*.
Den Sucher trotzdem laufen zu lassen hiesse: Der Mensch hat den Vogel
gefunden und zeigt darauf — und die Maschine sagt, da sei keiner.
🔴 **Ohne Sucher fehlt aber auch dessen Torwaechterrolle**, und das
ist die Falle dieser Methode. Die erste Fassung liess
``min_bird_score`` (0.05) entscheiden — und gab fuer eine **graue
Wand** „Fischadler 20 %" zurueck. Gemessen ueber 148 Vogelbilder und
71 Hintergrundausschnitte: Bei 0.05 kommen 97 % der Voegel durch,
aber auch **82 % der Hintergruende**. Als Tor taugt der Wert hier
nicht; er misst „welche Art", nicht „ueberhaupt ein Vogel".
Was trennt, ist die **Sicherheit des besten Treffers nach der
Einschraenkung**: Ein echter Vogel konzentriert die Masse auf eine
Art, eine strukturlose Flaeche verteilt sie ueber viele. Gemessen::
Schwelle Voegel Hintergrund
0.33 100 % 34 %
0.40 97 % 17 %
0.45 96 % 14 % <- gewaehlt
0.50 93 % 8 %
Beide Bedingungen zusammen, weil sie Verschiedenes pruefen: die
Sicherheit, ob ueberhaupt etwas erkannt wurde, ``bird_score``, ob es
eine heimische Art ist.
⚠️ Die verbleibenden 14 % sind Laub und Geaest, das bei dieser
Aufloesung wirklich wie ein Vogel aussieht. Deshalb zeigt die App die
Alternativen mit an — die letzte Instanz ist der Mensch, der
hingesehen hat.
"""
ergebnis = self.classifier.classify(img)
if ergebnis.best is None:
return None
if ergebnis.best.confidence < self.min_zuschnitt_sicherheit:
return None
if ergebnis.bird_score < self.min_bird_score:
return None
return ergebnis