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,436 @@
|
||||
#!/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)
|
||||
Reference in new issue
Block a user