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:
1 parent
c11fe9ab04
commit
dc7db4b603
4 files changed
+252
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user