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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,164 @@
|
||||
"""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.")
|
||||
Reference in new issue
Block a user