"""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, ): # 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 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