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.
165 lines
6.4 KiB
Python
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.")
|