Files
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

436 lines
17 KiB
Python

#!/usr/bin/env python3
"""MJPEG-Server fuer die Kameraseite. Laeuft auf dem Pi Zero W am Vogelhaus.
Der Pi streamt nur -- erkannt wird auf einer Maschine mit Rechenleistung.
Ein Zero W schafft das Streamen, aber keine Inferenz.
http://<pi>:8080/ Vorschauseite
http://<pi>:8080/stream MJPEG -- nur ein Zuschauer, der zweite bekommt 503
http://<pi>:8080/snapshot ein einzelnes Bild
http://<pi>:8080/status JSON: Bildrate, Funkstaerke, Temperatur
http://<pi>:8080/temp Chiptemperatur als Text
http://<pi>:8080/gesund 200 oder 503
🔑 **Die Felder von /status sind dieselben wie bei der ESP32-Kameraseite**
(esp32cam/). Damit kann ein Werkzeug beide abfragen, ohne zu wissen, was
am anderen Ende haengt -- die Bruecke nach Home Assistant tut genau das.
## Warum rpicam-vid und nicht picamera2
⚠️ **Weil es laeuft.** Diese Fassung streamt seit dem 28.08.2026
ununterbrochen am Vogelhaus. Im Repo lag bis zum 25.09.2026 eine
picamera2-Fassung, die eleganter aussah und **nie auf einem Pi gestartet
worden war** -- picamera2 ist auf dem Geraet nicht einmal installiert.
Der Finger in der Wunde: *im Repo lag Code, der nie getestet wurde.*
Zwei Wahrheiten, und die ungetestete stand im Repo. Seitdem steht hier, was auf dem Geraet laeuft.
rpicam-vid ist ausserdem das leichtere Verfahren: Die Arbeit passiert im
C-Programm, Python schiebt nur Bytes weiter. Auf einem Zero W zaehlt das.
## Englisch im Code
Der Code kam so vom Geraet und wurde bewusst NICHT uebersetzt. Eine
laufende Kamera umbenennt man nicht, um sie huebscher zu machen -- jede
Zeile, die man anfasst, kann sie stilllegen. Neue Teile sind auf Deutsch
kommentiert.
"""
import json
import subprocess
import threading
import time
import sys
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
# Configuration
WIDTH = 1280
HEIGHT = 720
# Aufnahmerate. Bis zum 25.09.2026 stand hier 15.
#
# ⚠️ **Die 15 waren ein Wunsch, kein Zustand.** Gemessen am Zaehler, der
# an der QUELLE sitzt: 3,79 Bilder/s vorher, 3,77 nachher. Der Pi hat die
# 15 nie erreicht.
#
# 🔑 Und der Grund ist nicht das WLAN, sondern der Chip selbst: Ein
# Zero W kodiert 1280x720 MJPEG nicht schneller. Er laeuft am Anschlag,
# egal was man ihm sagt -- rpicam-vid stand bei 60 % CPU, der Server bei
# 30 %, Lastmittel 3,8 auf EINEM Kern.
#
# Die ehrliche Zahl hier zu haben ist trotzdem besser als eine
# ausgedachte: Sie sagt jedem, was das Geraet wirklich tut. Gebracht hat
# die Aenderung zwei Grad (46,5 -> 44,4 °C), mehr nicht.
#
# 📌 Der echte Hebel waere die Aufloesung, nicht die Bildrate.
FPS = 5
QUALITY = 80
PORT = 8080
RPICAM_VID = "/usr/bin/rpicam-vid"
# Nach so vielen Sekunden ohne neues Bild wird ein Stream beendet. Siehe die
# Begruendung bei /stream -- der Wert haelt den Zuschauerplatz frei, wenn die
# Aufnahme haengt. Grosszuegig gewaehlt: bei 5 fps sind 15 s sehr viel.
STREAM_LEERLAUF = 15.0
def read_cpu_temp():
"""Read CPU temperature from thermal zone."""
try:
with open("/sys/class/thermal/thermal_zone0/temp") as f:
return int(f.read().strip()) / 1000.0
except Exception:
return None
# --- Werte fuer /status ------------------------------------------------
#
# 🔑 Alles hier liest aus /proc oder /sys. Kein Aufruf fremder Programme,
# kein zusaetzliches Paket -- auf einem Zero W ist jeder Unterprozess
# teuer, und dieser Server soll vor allem eines: nicht im Weg stehen.
START_ZEIT = time.time()
bilder_gesamt = 0 # vom capture_loop hochgezaehlt
zuschauer = 0 # wie viele gerade am Stream haengen
_zaehler_sperre = threading.Lock()
# 🔑 **Nur ein Zuschauer gleichzeitig.** Ein Zero W liefert ueber WLAN rund
# 2 Mbit/s; ein zweiter Client teilt sich dieselbe Strecke, und der Detector
# faellt in Timeouts, ohne dass irgendwo ein Fehler steht -- die Bildrate
# halbiert sich einfach. Lieber eine klare Absage als zwei ruckelnde Bilder.
#
# ⚠️ Das stand von Anfang an in pi/README.md, im Code aber nicht: die
# rpicam-vid-Fassung vom Geraet hat den zweiten Zuschauer nur GEZAEHLT.
# Die ESP32-Kameraseite weist ihn ab, der Pi tat es nicht.
#
# Nicht blockierend genommen: ein zweiter Zuschauer soll eine Absage
# bekommen und nicht in einer Warteschlange haengen.
zuschauer_platz = threading.Semaphore(1)
def funkstaerke():
"""Empfangspegel in dBm, oder None.
/proc/net/wireless statt iwconfig: kein Unterprozess, und das Format
ist seit Jahrzehnten unveraendert. Die dritte Zahl ist der Pegel.
"""
try:
with open("/proc/net/wireless") as f:
for zeile in f.read().splitlines()[2:]:
teile = zeile.split()
if len(teile) >= 4:
return int(float(teile[3].rstrip(".")))
except Exception:
pass
return None
def speicher_frei():
"""Freier Arbeitsspeicher in Bytes, oder None."""
try:
with open("/proc/meminfo") as f:
for zeile in f:
if zeile.startswith("MemAvailable:"):
return int(zeile.split()[1]) * 1024
except Exception:
pass
return None
def status_json():
"""Dieselben Feldnamen wie die ESP32-Kameraseite. Siehe Modulkopf."""
frame, ts = frame_buffer.get()
jetzt = time.time()
laufzeit = jetzt - START_ZEIT
with _zaehler_sperre:
bilder = bilder_gesamt
schaut = zuschauer
return {
# 'bereit', sobald ueberhaupt ein Bild angekommen ist. Ein Server
# ohne Bild soll das sagen, statt still 503 zu liefern -- sonst
# steht man im Garten und raet.
"kamera": "bereit" if frame else "fehlt",
"fehler": "0x0" if frame else "0x1",
"laufzeit_s": int(laufzeit),
"bilder": bilder,
# Mittel ueber die gesamte Laufzeit -- kein gleitender Durchschnitt.
# Fuer 'laeuft es noch' genuegt das, und es kostet nichts.
"fps": round(bilder / laufzeit, 2) if laufzeit > 0 else 0.0,
"letztes_bild_s": round(jetzt - ts, 1) if ts else -1.0,
"zuschauer": schaut > 0,
"rssi": funkstaerke(),
"temperatur_c": read_cpu_temp(),
"speicher_frei": speicher_frei(),
}
class FrameBuffer:
"""Thread-safe circular frame buffer. Keeps only the latest frame."""
def __init__(self):
self._lock = threading.Lock()
self._frame = None
self._timestamp = 0
def update(self, frame_bytes):
with self._lock:
self._frame = frame_bytes
self._timestamp = time.time()
def get(self):
with self._lock:
return self._frame, self._timestamp
frame_buffer = FrameBuffer()
def capture_loop():
"""Continuously capture MJPEG frames from rpicam-vid."""
cmd = [
RPICAM_VID,
"-t", "0", # infinite
"--codec", "mjpeg",
"--width", str(WIDTH),
"--height", str(HEIGHT),
"--framerate", str(FPS),
"--quality", str(QUALITY),
"--vflip",
"--hflip",
"--nopreview",
"-o", "-", # stdout
]
print(f"[capture] Starting: {' '.join(cmd)}", flush=True)
while True:
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0
)
# Read MJPEG stream - frames are delimited by JPEG markers
buf = b""
while True:
chunk = proc.stdout.read(4096)
if not chunk:
break
buf += chunk
# Find JPEG frame boundaries (FFD8 = SOI, FFD9 = EOI)
while True:
soi = buf.find(b"\xff\xd8")
if soi == -1:
buf = b""
break
eoi = buf.find(b"\xff\xd9", soi + 2)
if eoi == -1:
# Incomplete frame, keep rest
buf = buf[soi:]
break
# Complete frame
frame = buf[soi:eoi + 2]
buf = buf[eoi + 2:]
frame_buffer.update(frame)
# Zaehlwerk fuer /status. Gemessen wird, was die
# Kamera LIEFERT -- nicht, was ein Zuschauer abholt.
# Damit sagt /status auch dann etwas, wenn niemand
# zuschaut.
global bilder_gesamt
with _zaehler_sperre:
bilder_gesamt += 1
proc.wait()
print(f"[capture] rpicam-vid exited with code {proc.returncode}, restarting...", flush=True)
except Exception as e:
print(f"[capture] Error: {e}, restarting...", flush=True)
time.sleep(2) # Wait before restart
class MJPEGHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path == "/index.html":
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vogelkamera</title>
<style>
body {{ margin:0; background:#1a1a2e; color:#fff; font-family:system-ui,sans-serif; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; }}
h1 {{ font-size:1.5em; margin:0.5em 0; }}
img {{ max-width:100%; max-height:80vh; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.5); }}
.footer {{ margin-top:1em; font-size:0.8em; opacity:0.6; }}
</style>
</head>
<body>
<h1>🐦 Vogelkamera</h1>
<img src="/stream" alt="Live Stream">
<div class="footer">Pi Zero W · OV5647 · {WIDTH}x{HEIGHT} · <span id="temp">--°C</span></div>
<script>
function updateTemp() {{
fetch('/temp').then(r => r.text()).then(t => {{
document.getElementById('temp').textContent = t;
}}).catch(() => {{}});
}}
updateTemp();
setInterval(updateTemp, 5000);
</script>
</body>
</html>"""
self.wfile.write(html.encode())
elif self.path == "/temp":
temp = read_cpu_temp()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
if temp is not None:
self.wfile.write(f"{temp:.1f}°C".encode())
else:
self.wfile.write(b"N/A")
elif self.path == "/status":
# ⚠ Fasst den Stream NICHT an. Das ist der Punkt: eine
# laufende Kamera pruefen, ohne sie zu stoeren. 'ping' taugt
# dafuer nicht -- ICMP faellt bei gesaettigtem WLAN als
# Erstes aus, waehrend TCP weiterlaeuft.
koerper = json.dumps(status_json()).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(koerper)))
self.end_headers()
self.wfile.write(koerper)
elif self.path in ("/gesund", "/health"):
frame, ts = frame_buffer.get()
# Aelter als zehn Sekunden heisst: die Aufnahme haengt,
# auch wenn der Server noch antwortet.
gesund = bool(frame) and (time.time() - ts) < 10
self.send_response(200 if gesund else 503)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok\n" if gesund else b"kein aktuelles Bild\n")
elif self.path == "/stream":
if not zuschauer_platz.acquire(blocking=False):
absage = (
"Es ist bereits ein Zuschauer verbunden.\n"
"Diese Kamera liefert nur einen Stream gleichzeitig -- ein zweiter\n"
"wuerde die Funkstrecke saettigen und beide Bilder ruckeln lassen.\n"
).encode("utf-8")
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(absage)))
self.end_headers()
self.wfile.write(absage)
return
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.end_headers()
global zuschauer
with _zaehler_sperre:
zuschauer += 1
last_ts = 0
leerlauf_seit = time.time()
try:
while True:
frame, ts = frame_buffer.get()
if frame and ts != last_ts:
last_ts = ts
leerlauf_seit = time.time()
self.wfile.write(b"--frame\r\n")
self.wfile.write(f"Content-Type: image/jpeg\r\nContent-Length: {len(frame)}\r\n\r\n".encode())
self.wfile.write(frame)
self.wfile.write(b"\r\n")
self.wfile.flush()
elif time.time() - leerlauf_seit > STREAM_LEERLAUF:
# 🔑 **Der Platz darf nicht an einer stehenden Kamera
# haengen bleiben.** Ohne neues Bild wird nichts
# geschrieben -- also merkt der Server auch nicht, dass
# der Zuschauer schon weg ist, und haelt den einen
# Platz fuer immer. Dann holt rpicam-vid sich zwar nach
# zwei Sekunden die Kamera zurueck, aber der Detector
# bekommt bei jedem Versuch 503 und die Kamera ist
# dauerhaft stumm -- ausgerechnet durch die Sperre, die
# sie schuetzen soll.
#
# Ein Stream, der 15 s nichts liefert, ist ohnehin
# nichts wert; der Detector verbindet von selbst neu.
break
# ⚠ Haeufiger nachsehen als aufgenommen wird: Sonst
# wartet der Zuschauer im schlechtesten Fall ein ganzes
# Bild lang, obwohl schon eines bereitliegt. Kostet
# nichts -- ohne neues Bild wird nichts gesendet.
time.sleep(0.5 / FPS)
except (BrokenPipeError, ConnectionResetError):
pass # Client disconnected
finally:
# ⚠ finally, nicht nach dem except: Auch ein Abbruch von
# aussen muss den Zaehler wieder senken, sonst steht dort
# nach ein paar Tagen eine Zahl, die nichts mehr bedeutet.
#
# Dasselbe gilt fuer den Platz: Bleibt er haengen, ist die
# Kamera nach einem geschlossenen Browserfenster dauerhaft
# dicht. Das Trennen merkt der Server erst beim naechsten
# Schreibversuch -- und genau der laeuft hier in den except.
with _zaehler_sperre:
zuschauer -= 1
zuschauer_platz.release()
elif self.path == "/snapshot":
frame, _ = frame_buffer.get()
if frame:
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(frame)))
self.end_headers()
self.wfile.write(frame)
else:
self.send_response(503)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress logs
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
if __name__ == "__main__":
# Start capture thread
capture_thread = threading.Thread(target=capture_loop, daemon=True)
capture_thread.start()
# Wait for first frame
print("[server] Waiting for first frame...", flush=True)
for i in range(30):
frame, _ = frame_buffer.get()
if frame:
print(f"[server] First frame received ({len(frame)} bytes)", flush=True)
break
time.sleep(1)
else:
print("[server] WARNING: No frame received within 30s, starting anyway", flush=True)
# Start HTTP server
server = ThreadedHTTPServer(("0.0.0.0", PORT), MJPEGHandler)
print(f"[server] MJPEG stream on http://0.0.0.0:{PORT}/", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
print("[server] Shutting down", flush=True)