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.
205 lines
7.2 KiB
Python
205 lines
7.2 KiB
Python
"""Framequellen.
|
|
|
|
Der Pi Zero kann auf mehrere Arten streamen und wir wissen noch nicht, welche
|
|
Software drauf läuft. Deshalb vier Quellen hinter einem Interface — die
|
|
Pipeline interessiert sich nur für ``for frame in source``.
|
|
|
|
MJPEG und Snapshot brauchen nur die Stdlib. RTSP ruft ffmpeg auf; das muss
|
|
dann auf dem Host installiert sein (``apt install ffmpeg``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import subprocess
|
|
import time
|
|
import urllib.request
|
|
from abc import ABC, abstractmethod
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
from PIL import Image
|
|
|
|
JPEG_SOI = b"\xff\xd8"
|
|
JPEG_EOI = b"\xff\xd9"
|
|
|
|
|
|
class FrameSource(ABC):
|
|
restartable = True
|
|
"""Ob es sinnvoll ist, die Quelle nach dem Ende neu zu öffnen.
|
|
|
|
Bei Netzquellen ja — der Pi startet neu, das WLAN hustet. Bei einem
|
|
endlichen Ordner nein, sonst läuft ``watch`` in einer Endlosschleife.
|
|
"""
|
|
|
|
letztes_jpeg: bytes | None = None
|
|
"""Die unveränderten JPEG-Bytes des zuletzt gelieferten Bildes.
|
|
|
|
Wer das Bild weitergeben will — etwa der Live-Verteiler —, spart damit das
|
|
Umkodieren: bei MJPEG und Einzelbild-URLs liegen die Bytes ohnehin vor.
|
|
Quellen ohne fertiges JPEG lassen das Feld auf ``None``.
|
|
"""
|
|
|
|
@abstractmethod
|
|
def frames(self) -> Iterator[Image.Image]: ...
|
|
|
|
def __iter__(self) -> Iterator[Image.Image]:
|
|
return self.frames()
|
|
|
|
|
|
class MJPEGStream(FrameSource):
|
|
"""multipart/x-mixed-replace — was mjpg-streamer, motion und die meisten
|
|
picamera2-Beispiele ausliefern.
|
|
|
|
Wir suchen die JPEG-Marker direkt im Bytestrom, statt die MIME-Boundaries
|
|
zu parsen: Boundary-Formate sind zwischen den Servern uneinheitlich, die
|
|
Marker sind es nicht.
|
|
"""
|
|
|
|
def __init__(self, url: str, timeout: int = 15, max_buffer: int = 8 << 20):
|
|
self.url = url
|
|
self.timeout = timeout
|
|
self.max_buffer = max_buffer
|
|
|
|
def frames(self) -> Iterator[Image.Image]:
|
|
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
buf = bytearray()
|
|
while True:
|
|
chunk = resp.read(16384)
|
|
if not chunk:
|
|
return
|
|
buf.extend(chunk)
|
|
|
|
while True:
|
|
start = buf.find(JPEG_SOI)
|
|
if start < 0:
|
|
break
|
|
end = buf.find(JPEG_EOI, start + 2)
|
|
if end < 0:
|
|
break
|
|
raw = bytes(buf[start : end + 2])
|
|
del buf[: end + 2]
|
|
try:
|
|
bild = Image.open(io.BytesIO(raw)).convert("RGB")
|
|
except OSError:
|
|
continue # halbes Bild, nächstes nehmen
|
|
self.letztes_jpeg = raw
|
|
yield bild
|
|
|
|
# Reißt die Verbindung mitten im Frame ab, wächst der Puffer
|
|
# sonst unbegrenzt weiter.
|
|
if len(buf) > self.max_buffer:
|
|
del buf[: -(1 << 20)]
|
|
|
|
|
|
class SnapshotPoller(FrameSource):
|
|
"""Einzelbild-URL im Intervall abholen (z.B. /snapshot.jpg bei motion,
|
|
oder ein Cron-Job, der auf den Pi schreibt). Genügsamer als MJPEG und für
|
|
einen Nistkasten völlig ausreichend."""
|
|
|
|
def __init__(self, url: str, interval: float = 1.0, timeout: int = 10):
|
|
self.url = url
|
|
self.interval = interval
|
|
self.timeout = timeout
|
|
|
|
def frames(self) -> Iterator[Image.Image]:
|
|
req = urllib.request.Request(self.url, headers={"User-Agent": "vogelalbum/1.0"})
|
|
while True:
|
|
t0 = time.monotonic()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
raw = resp.read()
|
|
bild = Image.open(io.BytesIO(raw)).convert("RGB")
|
|
except (OSError, ValueError) as exc:
|
|
print(f"[stream] Snapshot fehlgeschlagen: {exc}")
|
|
time.sleep(min(self.interval * 5, 30))
|
|
continue
|
|
self.letztes_jpeg = raw
|
|
yield bild
|
|
time.sleep(max(0.0, self.interval - (time.monotonic() - t0)))
|
|
|
|
|
|
class RTSPStream(FrameSource):
|
|
"""RTSP über ffmpeg. Braucht ffmpeg auf dem Host."""
|
|
|
|
def __init__(self, url: str, fps: float = 2.0, width: int = 1280):
|
|
self.url = url
|
|
self.fps = fps
|
|
self.width = width
|
|
|
|
def frames(self) -> Iterator[Image.Image]:
|
|
cmd = [
|
|
"ffmpeg", "-loglevel", "error",
|
|
"-rtsp_transport", "tcp", "-i", self.url,
|
|
"-vf", f"fps={self.fps},scale={self.width}:-2",
|
|
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "4", "-",
|
|
]
|
|
try:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, bufsize=1 << 16)
|
|
except FileNotFoundError:
|
|
raise RuntimeError("ffmpeg nicht gefunden — 'apt install ffmpeg'") from None
|
|
|
|
try:
|
|
buf = bytearray()
|
|
assert proc.stdout is not None
|
|
while True:
|
|
chunk = proc.stdout.read(16384)
|
|
if not chunk:
|
|
return
|
|
buf.extend(chunk)
|
|
while True:
|
|
start = buf.find(JPEG_SOI)
|
|
end = buf.find(JPEG_EOI, start + 2) if start >= 0 else -1
|
|
if start < 0 or end < 0:
|
|
break
|
|
raw = bytes(buf[start : end + 2])
|
|
del buf[: end + 2]
|
|
try:
|
|
yield Image.open(io.BytesIO(raw)).convert("RGB")
|
|
except OSError:
|
|
continue
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
class FolderSource(FrameSource):
|
|
"""Bilder aus einem Ordner — zum Entwickeln und für Regressionstests ohne
|
|
laufende Kamera."""
|
|
|
|
def __init__(self, folder: str | Path, loop: bool = False, delay: float = 0.0):
|
|
self.folder = Path(folder)
|
|
self.loop = loop
|
|
self.delay = delay
|
|
self.restartable = loop
|
|
|
|
def frames(self) -> Iterator[Image.Image]:
|
|
exts = {".jpg", ".jpeg", ".png", ".webp"}
|
|
files = sorted(p for p in self.folder.iterdir() if p.suffix.lower() in exts)
|
|
if not files:
|
|
raise RuntimeError(f"Keine Bilder in {self.folder}")
|
|
while True:
|
|
for p in files:
|
|
yield Image.open(p).convert("RGB")
|
|
if self.delay:
|
|
time.sleep(self.delay)
|
|
if not self.loop:
|
|
return
|
|
|
|
|
|
def open_source(url: str, **kw) -> FrameSource:
|
|
"""Baut die passende Quelle aus einer URL bzw. einem Pfad."""
|
|
if url.startswith("rtsp://"):
|
|
return RTSPStream(url, fps=kw.get("fps", 2.0))
|
|
if url.startswith(("http://", "https://")):
|
|
# Heuristik: eine URL auf .jpg ist ein Einzelbild, alles andere ein Stream.
|
|
path = url.split("?", 1)[0].lower()
|
|
if path.endswith((".jpg", ".jpeg", ".png")) or kw.get("snapshot"):
|
|
return SnapshotPoller(url, interval=kw.get("interval", 1.0))
|
|
return MJPEGStream(url)
|
|
return FolderSource(url, loop=kw.get("loop", False), delay=kw.get("delay", 0.0))
|