"""Erkennungskarte: wo im Bild wird ein Vogel gefunden — und wo nicht. Eine Kamera am Futterhaus richtet man sonst blind aus und wartet dann Tage darauf, ob etwas ankommt. Bleibt es leer, weiss man immer noch nicht, ob keine Voegel kamen oder ob sie an der falschen Stelle im Bild sassen. Diese Karte beantwortet das vorher. Ein echtes Vogelfoto wird mit weicher Maske ueber ein Raster von Positionen in aktuelle Kamerabilder montiert und jedes Mal durch den Detektor geschickt. Heraus kommt eine Ampel ueber dem eigenen Kamerabild: gruen erkannt, rot nicht. Am Vogelhaus in Herzogenrath hat sie gezeigt, dass der unterste Bildrand eine tote Zone ist — genau dort, wo ein Vogel auf dem Futterbrett steht. Derselbe Vogel zehn Prozent hoeher im Bild wurde zuverlaessig erkannt. Das war ohne diese Karte drei Wochen lang nicht zu sehen. Die Grenze ist kein Naturgesetz, sondern haengt am Hintergrund: vor ruhigem Grund reichten 5 % Bildflaeche, vor einem Baum im Wind erst 8 %. Deshalb misst man sie an der eigenen Kamera nach, statt eine Zahl zu uebernehmen. """ from __future__ import annotations from dataclasses import dataclass from PIL import Image, ImageDraw, ImageFilter # Voreinstellung des Rasters. Die Spalten lassen die aeussersten Prozent aus — # ein Vogel, der halb aus dem Bild haengt, ist kein sinnvoller Messpunkt. SPALTEN = (0.18, 0.32, 0.46, 0.60, 0.74, 0.88) ZEILEN = (0.55, 0.65, 0.75, 0.85, 0.95) GRUEN = (60, 200, 90) ROT = (220, 60, 60) def montiere( hintergrund: Image.Image, vogel: Image.Image, anteil: float, mitte_x: float = 0.5, fuss_y: float = 0.92, ) -> Image.Image: """Vogelfoto auf ``anteil`` der Bildflaeche skalieren und einsetzen. ``mitte_x`` und ``fuss_y`` sind relative Koordinaten: der Vogel steht mit den Fuessen auf ``fuss_y``, so wie er auf einem Brett staende. """ W, H = hintergrund.size vw, vh = vogel.size faktor = (anteil * W * H / (vw * vh)) ** 0.5 nw, nh = max(8, round(vw * faktor)), max(8, round(vh * faktor)) v = vogel.resize((nw, nh), Image.LANCZOS) # Weiche Ellipse statt harter Rechteckkante: die Kante gaebe es in # Wirklichkeit nicht, und der Detektor soll den Vogel finden, nicht den # Klebefleck. maske = Image.new("L", (nw, nh), 0) ImageDraw.Draw(maske).ellipse((1, 1, nw - 2, nh - 2), fill=255) maske = maske.filter(ImageFilter.GaussianBlur(max(1, nw // 20))) out = hintergrund.copy() out.paste(v, (round(mitte_x * W - nw / 2), round(fuss_y * H - nh)), maske) return out @dataclass class Zelle: mitte_x: float fuss_y: float treffer: int gesamt: int bester_score: float @property def erkannt(self) -> bool: """Nur einstimmig. Eine Position, die bei einem von drei Hintergruenden durchfaellt, taugt nicht als Landeplatz.""" return self.gesamt > 0 and self.treffer == self.gesamt def vermessen( pipeline, hintergruende: list[Image.Image], vogel: Image.Image, anteil: float = 0.08, spalten: tuple[float, ...] = SPALTEN, zeilen: tuple[float, ...] = ZEILEN, ) -> list[Zelle]: """Das Raster abfahren. Mehrere Hintergruende, damit ein zufaellig guenstiger Ast das Ergebnis nicht traegt.""" zellen = [] for fuss in zeilen: for mx in spalten: treffer = 0 best = 0.0 for hg in hintergruende: box, _ = pipeline.run(montiere(hg, vogel, anteil, mx, fuss)) if box is not None: best = max(best, box.score) if pipeline.zaehlt_als_vogel(box): treffer += 1 zellen.append(Zelle(mx, fuss, treffer, len(hintergruende), best)) return zellen def zeichne(hintergrund: Image.Image, zellen: list[Zelle]) -> Image.Image: """Ampel ueber das Kamerabild legen — halbtransparent, damit man sieht, worueber man urteilt.""" W, H = hintergrund.size lage = Image.new("RGBA", (W, H), (0, 0, 0, 0)) mal = ImageDraw.Draw(lage) r = max(12, round(min(W, H) * 0.047)) for z in zellen: farbe = GRUEN if z.erkannt else ROT cx, cy = z.mitte_x * W, z.fuss_y * H mal.ellipse((cx - r, cy - r, cx + r, cy + r), fill=farbe + (90,), outline=farbe + (220,), width=3) return Image.alpha_composite(hintergrund.convert("RGBA"), lage).convert("RGB") def tabelle(zellen: list[Zelle]) -> str: """Dieselbe Karte als Text — im Terminal oft schneller zu lesen als das Bild, und sie laesst sich in ein Issue kleben.""" zeilen_y = sorted({z.fuss_y for z in zellen}) spalten_x = sorted({z.mitte_x for z in zellen}) kopf = "Fuss y " + "".join(f" x={x:<5.2f}" for x in spalten_x) zeilen = [kopf] for y in zeilen_y: teile = [f"{y:>6.2f} "] for x in spalten_x: z = next(c for c in zellen if c.fuss_y == y and c.mitte_x == x) teile.append(f" {'OK' if z.erkannt else '--'} {z.bester_score:.2f}") zeilen.append("".join(teile)) return "\n".join(zeilen) def rat(zellen: list[Zelle]) -> str: """Aus der Karte einen Satz machen. Wer die Kamera ausrichtet, steht draussen auf der Leiter und will keine Tabelle deuten.""" if not zellen: return "Keine Messpunkte." unten = [z for z in zellen if z.fuss_y >= 0.9] oben = [z for z in zellen if z.fuss_y < 0.9] quote = sum(z.erkannt for z in zellen) / len(zellen) if quote == 0: return ("Nirgends erkannt. Der Vogel ist bei dieser Groesse zu klein " "fuers Modell — Kamera naeher ans Futter, oder mit einem " "groesseren --anteil pruefen, ab wann es kippt.") if unten and oben: q_unten = sum(z.erkannt for z in unten) / len(unten) q_oben = sum(z.erkannt for z in oben) / len(oben) if q_oben - q_unten > 0.4: return ("Der untere Bildrand ist eine tote Zone, weiter oben wird " "sauber erkannt. Steht das Futter unten am Rand, kippt die " "Kamera zu flach: nach unten neigen, bis die Landeflaeche " "in der unteren Bildmitte liegt.") if quote < 0.5: return ("Mehr als die Haelfte der Positionen faellt durch. Naeher ans " "Futter heran — mehr Aufloesung hilft hier weniger als " "weniger Abstand.") return ("Die Ausrichtung taugt: der Vogel wird fast ueberall im Bild " "gefunden. Bleibt die Frage, ob er in echt so gross im Bild ist.")