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,204 @@
|
||||
"""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))
|
||||
Reference in new issue
Block a user