From dc7db4b603177dc60818b74ef4a0774188231032 Mon Sep 17 00:00:00 2001 From: Artemis Date: Sat, 24 Oct 2026 17:34:52 +0000 Subject: [PATCH] Zugeschnittene Fotos ohne Sucher bestimmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 6 ++ detector/classify.py | 55 +++++++++++++++++++ docs/handyfotos.md | 121 +++++++++++++++++++++++++++++++++++++++++ tests/test_detector.py | 70 ++++++++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 docs/handyfotos.md diff --git a/.gitignore b/.gitignore index e12a3e0..637a83d 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ web/locale/*/LC_MESSAGES/*.mo # Laufzeitdaten des zweiten Detectors (Traenke) -- Rohbilder, # Protokoll, Treffer. Gehoert so wenig ins Repo wie live/. live2/ + +# Pruefbilder fuer die Erkennung liegen NICHT im Repo. +# Dieses Repo ist oeffentlich; die Aufnahmen sind privat (Garten, Reisen, +# Zoo). Sie liegen in der Nextcloud unter Projekte/Vogelalbum/Pruefbilder, +# die Messungen dazu stehen in docs/handyfotos.md. +pruefbilder/ diff --git a/detector/classify.py b/detector/classify.py index 2ce858e..2b611ac 100644 --- a/detector/classify.py +++ b/detector/classify.py @@ -266,6 +266,7 @@ class Pipeline: 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 @@ -293,6 +294,10 @@ class Pipeline: 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?""" @@ -364,3 +369,53 @@ class Pipeline: # 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 diff --git a/docs/handyfotos.md b/docs/handyfotos.md new file mode 100644 index 0000000..71bdc26 --- /dev/null +++ b/docs/handyfotos.md @@ -0,0 +1,121 @@ +# Handyfotos: was gemessen wurde + +Stand 24.10.2026. Anlass war Christians Befürchtung, die Erkennungsrate bei +Handyaufnahmen werde „grauenhaft schlecht". Sie war berechtigt — nur aus +einem anderen Grund als zunächst vermutet. + +🔴 **Die Prüfbilder liegen NICHT im Repo** — es ist öffentlich, die +Aufnahmen sind private Fotos (Garten, Reisen, Zoo). Sie liegen in der +Nextcloud unter `Projekte/Vogelalbum/Pruefbilder`: `handy/` (18 Aufnahmen) +und `handy-schwer/` (4 bewusst schwierige). Zum Messen dorthin kopieren; +`pruefbilder/` steht in der `.gitignore`, damit sie nicht versehentlich +mitwandern. + +Wer an der Erkennung etwas ändert, misst gegen diesen Satz. + +## Was die Kette heute leistet + +18 echte Handyaufnahmen, Sollwerte von Hand gesetzt: + +| Verfahren | richtig | falsch | verpasst | +|---|---|---|---| +| Kette wie sie ist | 15/18 | 2 | 1 | +| nur ganzes Bild bestimmen | 14/18 | 4 | 0 | +| besser von beidem | 15/18 | 3 | 0 | + +Sechs der achtzehn zeigen Arten, die nicht im Katalog stehen (Pfau, Pelikan, +zwei Hähne, Huhn, eine amerikanische Möwe). Fünf davon lehnt die Kette +richtig ab — das ist kein Fehlschlag, sondern die gewünschte Zurückhaltung. + +🔑 **Der Sucher vor der Bestimmung ist kein Ballast.** Ihn wegzulassen und +immer das ganze Bild zu bestimmen macht das Ergebnis schlechter, nicht +besser. Beim Höckerschwan auf dem Teich wird daraus ein Silberreiher. + +## Wo es wirklich bricht: die Bildfläche + +Dieselben Referenzvögel, künstlich verkleinert in eine Szene gesetzt: + +| Vogel füllt | Sucher findet ihn | +|---|---| +| 81 % der Fläche | 98 % | +| 49 % | 93 % | +| 25 % | 75 % | +| 12 % | 38 % | +| 6 % | 10 % | +| 2 % | 0 % | + +Unterhalb von etwa einem Viertel Bildfläche bricht es weg. Der Grund ist +mechanisch: Der Sucher skaliert jedes Bild auf 300 × 300 Pixel. Ein Vogel bei +12 % der Breite ist danach 36 Pixel groß. + +**Das ist der Normalfall für Gartenvögel.** Eine Blaumeise aus fünf Metern +füllt ein bis zwei Prozent. + +## Die Lösung: zuschneiden und den Sucher überspringen + +Von Hand auf den Vogel gezogen, an den schwierigen Aufnahmen gemessen: + +| Bild | Kette heute | zugeschnitten | +|---|---|---| +| schwer-03 | nichts gefunden | **Haussperling 93 %** | +| schwer-04 | nichts gefunden | Haussperling 50 % | +| schwer-01 | Haussperling 17 % | Feldsperling, weiterhin wackelig | +| schwer-02 | nichts gefunden | nichts — Silhouette gegen Himmel, da ist keine Information drin | + +🔴 **Auf keinem dieser Zuschnitte meldet sich der Sucher.** SSD-MobileNet ist +auf Objekte in einer Szene trainiert, nicht auf formatfüllende Tiere. Ein +perfekt zugeschnittenes Bild bekäme also weiterhin „kein Vogel gefunden" — +deshalb `Pipeline.run_zuschnitt`, das ihn übergeht. + +### Und warum das ein eigenes Tor braucht + +Ohne Sucher fehlt dessen Torwächterrolle. Die erste Fassung ließ +`min_bird_score` (0,05) entscheiden und gab für eine **graue Wand** +„Fischadler 20 %" zurück. Über 148 Vogelbilder und 71 Hintergrundausschnitte +gemessen: + +| Schwelle auf die Sicherheit | Vögel durch | Hintergrund fälschlich durch | +|---|---|---| +| 0,33 | 100 % | 34 % | +| 0,40 | 97 % | 17 % | +| **0,45** | **96 %** | **14 %** | +| 0,50 | 93 % | 8 % | + +`min_bird_score` allein bei 0,05: 97 % der Vögel — aber auch **82 % der +Hintergründe**. Als Tor taugt er nicht; er beantwortet „welche Art", nicht +„überhaupt ein Vogel". + +Gewählt ist 0,45 **und** `bird_score ≥ 0,05`, weil die beiden Verschiedenes +prüfen. Die verbleibenden 14 % sind Laub und Geäst, das bei dieser Auflösung +wirklich wie ein Vogel aussieht — deshalb zeigt die App die Alternativen mit +an. + +## Verworfen: Kacheln + +Die Idee, das Bild in überlappende Kacheln zu zerlegen und in jeder zu +suchen, sah zunächst sehr gut aus: + +| Vogel füllt | heute | 2×2 | 3×3 | +|---|---|---|---| +| 12 % | 38 % | 85 % | 92 % | +| 6 % | 10 % | 72 % | 75 % | +| 2 % | 0 % | 5 % | 22 % | + +⚠️ **Diese Zahlen hingen am Hintergrund.** Die Vögel saßen auf neutralem +Grau; zoomt man dort hinein, steht der Vogel allein und scharf da. Vor echten +Hintergründen schrumpft der Gewinn auf ein schmales Band (bei 6 % Fläche +3 % → 40 %, darunter nichts), und an den 22 echten Fotos änderte sich **ein** +Ergebnis — zum Schlechteren: Die Kacheln zerschneiden eine Gänsegruppe und +machen daraus eine Straßentaube. + +Dazu zehnfache Rechenzeit (118 ms → 1362 ms). + +**Christians Entscheidung am 24.10.2026: nicht einbauen.** Der Weg über den +Zuschnitt von Hand deckt denselben Fall ab, ohne zu raten. Der Code ist +wieder entfernt; diese Notiz steht hier, damit die Idee nicht in einem halben +Jahr noch einmal von vorn ausprobiert wird. + +🔑 **Die Lehre, die über dieses Projekt hinausgeht:** Ein synthetischer +Prüfstand, der den Hintergrund wegvereinfacht, misst die Aufgabe nicht mehr, +die gelöst werden soll. Ich hatte aus ihm einen Gewinn von 38 auf 92 Prozent +abgeleitet und war bereit, danach zu bauen. diff --git a/tests/test_detector.py b/tests/test_detector.py index ab8a2af..83426df 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -956,3 +956,73 @@ class AnzeigeAusschnittTests(unittest.TestCase): a = Box(x0=0.3, y0=0.3, x1=0.7, y1=0.7, score=0.9).anzeige_ausschnitt(im) self.assertLessEqual(a.width, 60) self.assertLessEqual(a.height, 40) + + +class ZuschnittTests(unittest.TestCase): + """Das Tor fuer von Hand zugeschnittene Bilder. + + 🔑 Ohne Modelle gebaut: Geprueft wird die **Entscheidung**, nicht die + Erkennung. Genau dort lag der Fehler — die erste Fassung liess + ``min_bird_score`` allein entscheiden und gab fuer eine graue Wand + „Fischadler 20 %" zurueck. + """ + + def pipeline(self, sicherheit: float, bird_score: float): + """Eine Pipeline, die ohne LiteRT auskommt. + + ``Pipeline.__new__`` statt ``Pipeline(...)``: Der Konstruktor laedt + beide Modelle, und die haben mit dieser Frage nichts zu tun. + """ + from detector.classify import Guess, Pipeline, Result + from detector.species_de import BY_SCIENTIFIC + + art = BY_SCIENTIFIC["Parus major"] + + class FakeClassifier: + def classify(self, img): + return Result( + guesses=[Guess(species=art, confidence=sicherheit)], + raw_top=[], + ms=1.0, + bird_score=bird_score, + ) + + pipe = Pipeline.__new__(Pipeline) + pipe.classifier = FakeClassifier() + pipe.min_bird_score = 0.05 + pipe.min_zuschnitt_sicherheit = 0.45 + return pipe + + def test_sicherer_treffer_geht_durch(self): + ergebnis = self.pipeline(0.93, 0.11).run_zuschnitt(Image.new("RGB", (80, 80))) + self.assertIsNotNone(ergebnis) + self.assertEqual(ergebnis.best.species.german, "Kohlmeise") + + def test_unsicherer_treffer_wird_verworfen(self): + """Der Fall „graue Wand": bird_score reicht, Sicherheit nicht. + + Gemessen ueber 71 Hintergrundausschnitte: Bei 0.05 bird_score kaemen + 82 Prozent davon durch. Die Sicherheit ist hier das Tor. + """ + self.assertIsNone( + self.pipeline(0.20, 0.16).run_zuschnitt(Image.new("RGB", (80, 80))) + ) + + def test_fremde_art_wird_verworfen(self): + """Sicher erkannt, aber keine heimische Art — dafuer bleibt + bird_score zustaendig.""" + self.assertIsNone( + self.pipeline(0.95, 0.01).run_zuschnitt(Image.new("RGB", (80, 80))) + ) + + def test_beide_bedingungen_noetig(self): + """Keine der beiden Schwellen allein darf genuegen.""" + knapp_drueber = self.pipeline(0.46, 0.06) + self.assertIsNotNone(knapp_drueber.run_zuschnitt(Image.new("RGB", (80, 80)))) + for sicherheit, bird_score in ((0.44, 0.06), (0.46, 0.04)): + with self.subTest(sicherheit=sicherheit, bird_score=bird_score): + self.assertIsNone( + self.pipeline(sicherheit, bird_score).run_zuschnitt( + Image.new("RGB", (80, 80)) + ) + )