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>
This commit is contained in:
christianandClaude Opus 5 committed 2026-10-24 17:34:52 +00:00
1 parent c11fe9ab04
commit dc7db4b603
4 files changed
+252

No files matched your search

+6
View File
@@ -32,3 +32,9 @@ web/locale/*/LC_MESSAGES/*.mo
# Laufzeitdaten des zweiten Detectors (Traenke) -- Rohbilder, # Laufzeitdaten des zweiten Detectors (Traenke) -- Rohbilder,
# Protokoll, Treffer. Gehoert so wenig ins Repo wie live/. # Protokoll, Treffer. Gehoert so wenig ins Repo wie live/.
live2/ 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/
+55
View File
@@ -266,6 +266,7 @@ class Pipeline:
restrict_to_germany: bool = True, restrict_to_germany: bool = True,
habitats: set[str] | None = None, habitats: set[str] | None = None,
report_threshold: float | None = None, report_threshold: float | None = None,
min_zuschnitt_sicherheit: float = 0.45,
): ):
# 0.05 ist gemessen, nicht geraten: scripts/benchmark.py über die # 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 # 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 restrict_to_germany=restrict_to_germany, habitats=habitats
) )
self.min_bird_score = min_bird_score 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: def zaehlt_als_vogel(self, box: Box | None) -> bool:
"""Reicht diese Box fuer eine Sichtung, oder ist sie nur Protokoll?""" """Reicht diese Box fuer eine Sichtung, oder ist sie nur Protokoll?"""
@@ -364,3 +369,53 @@ class Pipeline:
# eine falsche im Vogelalbum. # eine falsche im Vogelalbum.
return box, None return box, None
return box, result 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
+121
View File
@@ -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.
+70
View File
@@ -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) 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.width, 60)
self.assertLessEqual(a.height, 40) 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))
)
)