From 93f209ca12bcc8139fd8a85cc321972c10b1d3ec Mon Sep 17 00:00:00 2001 From: Artemis Date: Wed, 14 Oct 2026 18:11:11 +0000 Subject: [PATCH] Ausschnitt fuer die Sammlung weiter fassen Christian ueber seine erste Amsel: "Der Zuschnitt der Fotos ist auch etwas merkwuerdig." Er hatte recht -- in der Kachel war nur Gefieder zu sehen, Kopf und Schwanz lagen ausserhalb. URSACHE: Derselbe enge Ausschnitt diente zwei Zwecken. Fuer den Klassifikator ist eng richtig, jedes Stueck Hintergrund verwaessert die Bestimmung. Fuer die Sammlung ist eng falsch: Dort steht das Bild in einer fast quadratischen Kachel, und die schneidet weg, was ueber das Quadrat hinausragt. Bei einem Vogel im Hochformat -- und so sitzt fast jeder -- bleibt die Bauchmitte uebrig, formatfuellend und unscharf. Neu: Box.anzeige_ausschnitt, quadratisch und mit mehr Rand. Was die Kachel abschneidet, ist jetzt Umgebung statt Vogel. `crop` bleibt unveraendert -- die Artbestimmung ist gemessen und soll so bleiben. An Christians echter Amsel geprueft: vorher 446x857 (Verhaeltnis 0,52), nachher 1176x1175. Im Vergleichsbild sitzt der ganze Vogel auf dem First statt einer Nahaufnahme des Bauchs. 5 Tests, auch fuer Voegel am Bildrand (sonst sitzt der Vogel ausserhalb seiner eigenen Kachel). 136 Detector-Tests gruen. --- detector/classify.py | 44 ++++++++++++++++++++++++ detector/warteschlange.py | 5 ++- detector/watcher.py | 5 ++- tests/test_detector.py | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/detector/classify.py b/detector/classify.py index 19f251e..34bfafa 100644 --- a/detector/classify.py +++ b/detector/classify.py @@ -68,6 +68,50 @@ class Box: 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. Christian am + 14.10.2026 über seine erste Amsel: *„Der Zuschnitt der Fotos ist auch + etwas merkwürdig."* Er hatte recht: 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) diff --git a/detector/warteschlange.py b/detector/warteschlange.py index 0ae1007..354696c 100644 --- a/detector/warteschlange.py +++ b/detector/warteschlange.py @@ -185,7 +185,10 @@ class Warteschlange: box, result = self.pipeline.run(bild) # Den Ausschnitt mitschicken, damit die Sammlung dasselbe Bild # zeigt wie bei einer Sichtung von der Kamera. - ausschnitt = box.crop(bild) if box is not None else None + # ⚠️ `anzeige_ausschnitt`, nicht `crop`: Der enge Schnitt ist + # fuer den Klassifikator gedacht; in der fast quadratischen + # Kachel der Sammlung bleibt davon nur die Bauchmitte uebrig. + ausschnitt = box.anzeige_ausschnitt(bild) if box is not None else None self._melden(foto["id"], ausschnitt, result, box) fertig += 1 except Exception as exc: # absichtlich breit, siehe Docstring diff --git a/detector/watcher.py b/detector/watcher.py index cab6514..b778982 100644 --- a/detector/watcher.py +++ b/detector/watcher.py @@ -221,12 +221,15 @@ class Watcher: # Bestes Foto: Schärfe, gewichtet mit der Detektor-Sicherheit. Ein # gestochen scharfes Bild von einem halb verdeckten Vogel nützt weniger # als ein leicht weicheres, auf dem er ganz zu sehen ist. + # Schaerfe am ENGEN Ausschnitt messen: Umgebung soll die Bewertung + # nicht verwaessern. Gespeichert wird dann der weitere -- siehe + # Box.anzeige_ausschnitt. crop = box.crop(frame) score = sharpness(crop) * (0.5 + 0.5 * box.score) if score > self._best_sharp: self._best_sharp = score self._best_image = frame.copy() - self._best_crop = crop + self._best_crop = box.anzeige_ausschnitt(frame) def _vote(self, box: Box, result: Result) -> None: # Gewicht: wie sicher ist der Detektor, wie groß ist der Vogel im Bild, diff --git a/tests/test_detector.py b/tests/test_detector.py index d27ceef..87360be 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -885,3 +885,75 @@ class UebernimmTests(unittest.TestCase): from detector.species_de import catalog self.assertEqual(self.pipeline.uebernimm(arten=catalog({"garten"})), []) + + +class AnzeigeAusschnittTests(unittest.TestCase): + """🔑 Der Klassifikator und das Auge wollen Verschiedenes. + + ``crop`` liegt eng am Vogel, weil Hintergrund die Bestimmung verwaessert. + In der fast quadratischen Kachel der Sammlung schneidet genau das links + und rechts den Vogel weg — bei einem Vogel im Hochformat bleibt die + Bauchmitte uebrig. Christian am 14.10.2026: „Der Zuschnitt der Fotos ist + auch etwas merkwuerdig." + """ + + def bild(self, groesse=(1800, 2400)): + from PIL import Image + + return Image.new("RGB", groesse, (120, 160, 200)) + + def test_anzeige_ist_quadratisch(self): + from detector.classify import Box + + # Ein hochkant sitzender Vogel, schmal und hoch. + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + anzeige = box.anzeige_ausschnitt(im) + + self.assertLess(eng.width / eng.height, 0.7, "Testbox ist nicht hochkant") + self.assertAlmostEqual(anzeige.width / anzeige.height, 1.0, places=1) + + def test_anzeige_enthaelt_den_ganzen_vogel(self): + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + anzeige = box.anzeige_ausschnitt(im) + # Mindestens so hoch wie der Vogel plus Rand. + vogel_hoehe = (0.62 - 0.30) * im.height + self.assertGreater(anzeige.height, vogel_hoehe) + + def test_crop_bleibt_unveraendert_eng(self): + """Die Artbestimmung ist gemessen und darf sich nicht mitaendern.""" + from detector.classify import Box + + box = Box(x0=0.40, y0=0.30, x1=0.52, y1=0.62, score=0.9) + im = self.bild() + eng = box.crop(im) + erwartet_breite = (0.52 - 0.40) * im.width * 1.24 # pad 0.12 beidseits + self.assertAlmostEqual(eng.width, erwartet_breite, delta=3) + + def test_am_bildrand_bleibt_der_ausschnitt_im_bild(self): + """⚠️ Sonst sitzt ein Vogel am Rand ausserhalb seiner eigenen Kachel.""" + from detector.classify import Box + + im = self.bild() + for box in ( + Box(x0=0.00, y0=0.00, x1=0.08, y1=0.10, score=0.9), + Box(x0=0.92, y0=0.90, x1=1.00, y1=1.00, score=0.9), + ): + with self.subTest(box=box): + a = box.anzeige_ausschnitt(im) + self.assertGreater(a.width, 0) + self.assertLessEqual(a.width, im.width) + self.assertLessEqual(a.height, im.height) + self.assertAlmostEqual(a.width / a.height, 1.0, places=1) + + def test_winziges_bild_sprengt_nichts(self): + from detector.classify import Box + + im = self.bild((60, 40)) + 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)