Files
vogelalbum/detector/karte.py
T
christian c11fe9ab04 Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
2026-10-15 14:30:02 +00:00

165 lines
6.4 KiB
Python

"""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.")