Bewegungserkennung pro Kamera (Pillow), Bewegungsbilder in der Timelapse-Galerie mit Filter

This commit is contained in:
Joachim Hummel committed 2026-10-18 14:08:09 +02:00
1 parent 420e8e1550
commit ff3dc7ee2b
4 files changed
+125 -32

No files matched your search

+82 -21
View File
@@ -23,12 +23,15 @@ Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen:
TIMELAPSE_INTERVAL=<sek> Standard-Intervall der Timelapse (default 300); ein/aus und Intervall
stellt man pro Kamera im Frontend ein
TIMELAPSE_DIR=<pfad> Zielordner (default: timelapse neben relay.py)
MOTION_COOLDOWN=<sek> Mindestabstand zwischen zwei Bewegungsbildern (default 10); die Erkennung
selbst (braucht Pillow) schaltet man pro Kamera im Frontend ein
TIMELAPSE_KEEP_DAYS=<n> Tage aufbewahren (0 = unbegrenzt, default)
PORT (default 8000)
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
"""
import base64
import hmac
import io
import json
import os
import re
@@ -42,6 +45,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests
try:
from PIL import Image
except ImportError: # Bewegungserkennung ist optional
Image = None
def load_env(path):
@@ -72,7 +80,9 @@ TL_INTERVAL = int(os.environ.get("TIMELAPSE_INTERVAL", "300"))
TL_DIR = os.path.abspath(os.environ.get("TIMELAPSE_DIR") or os.path.join(BASE_DIR, "timelapse"))
TL_KEEP_DAYS = int(os.environ.get("TIMELAPSE_KEEP_DAYS", "0"))
DAY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
FILE_RE = re.compile(r"^\d{6}\.jpg$")
FILE_RE = re.compile(r"^\d{6}(_m)?\.jpg$") # _m = Bewegungsbild
MOTION_COOLDOWN = int(os.environ.get("MOTION_COOLDOWN", "10"))
MOTION_LEVELS = {"low": 0.04, "mid": 0.02, "high": 0.007} # Anteil geänderter Pixel
FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
@@ -149,7 +159,10 @@ def load_tl_state():
except (OSError, ValueError):
saved = {}
return {n: {"enabled": bool(saved.get(n, {}).get("enabled")),
"interval": int(saved.get(n, {}).get("interval", TL_INTERVAL))} for n in CAMERAS}
"interval": int(saved.get(n, {}).get("interval", TL_INTERVAL)),
"motion": bool(saved.get(n, {}).get("motion")) and Image is not None,
"sensitivity": saved.get(n, {}).get("sensitivity") if saved.get(n, {}).get("sensitivity") in MOTION_LEVELS else "mid"}
for n in CAMERAS}
tl_state = load_tl_state()
@@ -165,9 +178,24 @@ def save_tl_state():
print(f"Timelapse-Zustand nicht gespeichert: {ex}", flush=True)
def save_frame(name, frame, suffix=""):
"""Legt das Bild als TL_DIR/<name>/<tag>/<HHMMSS><suffix>.jpg ab."""
now = time.localtime()
folder = os.path.join(TL_DIR, name, time.strftime("%Y-%m-%d", now))
try:
os.makedirs(folder, exist_ok=True)
tmp = os.path.join(folder, ".tmp" + suffix)
with open(tmp, "wb") as f:
f.write(frame)
os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + suffix + ".jpg"))
if TL_KEEP_DAYS:
prune(os.path.join(TL_DIR, name))
except OSError as ex:
print(f"{name}: Bild nicht gespeichert: {ex}", flush=True)
def timelapse(name, hub):
"""Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild
nach TL_DIR/<name>/<tag>/<HHMMSS>.jpg."""
"""Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild."""
last_seq, last_t = 0, 0.0
while True:
time.sleep(1)
@@ -178,18 +206,42 @@ def timelapse(name, hub):
if frame is None or seq == last_seq or not is_online(hub):
continue
last_seq, last_t = seq, time.time()
now = time.localtime()
folder = os.path.join(TL_DIR, name, time.strftime("%Y-%m-%d", now))
save_frame(name, frame)
def thumb(frame):
"""Graustufen-Miniatur (80x60) für den Bildvergleich."""
im = Image.open(io.BytesIO(frame))
im.draft("L", (160, 120)) # JPEG gleich verkleinert dekodieren
return list(im.convert("L").resize((80, 60)).tobytes())
def motion(name, hub):
"""Vergleicht jede Sekunde das Bild mit einem langsam nachgeführten Referenzbild
und speichert bei genug geänderten Pixeln ein Bewegungsbild (HHMMSS_m.jpg)."""
ref, last_seq, last_hit = None, 0, 0.0
while True:
time.sleep(1)
st = tl_state[name]
if not st["motion"] or Image is None:
ref = None
continue
frame, seq = hub.frame, hub.seq
if frame is None or seq == last_seq or not is_online(hub):
continue
last_seq = seq
try:
os.makedirs(folder, exist_ok=True)
tmp = os.path.join(folder, ".tmp")
with open(tmp, "wb") as f:
f.write(frame)
os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + ".jpg"))
if TL_KEEP_DAYS:
prune(os.path.join(TL_DIR, name))
except OSError as ex:
print(f"{name}: Timelapse-Bild nicht gespeichert: {ex}", flush=True)
cur = thumb(frame)
except (OSError, ValueError):
continue
if ref is None or len(ref) != len(cur):
ref = [float(v) for v in cur]
continue
changed = sum(1 for a, b in zip(ref, cur) if abs(a - b) > 25)
ref = [a + (b - a) * 0.1 for a, b in zip(ref, cur)]
if changed / len(cur) > MOTION_LEVELS[st["sensitivity"]] and time.time() - last_hit >= MOTION_COOLDOWN:
last_hit = time.time()
save_frame(name, frame, "_m")
def prune(cam_dir):
@@ -245,11 +297,12 @@ def write_avi(path, files, fps):
video_lock = threading.Lock()
def make_video(name, day, fps):
def make_video(name, day, fps, kind="lapse"):
"""Erzeugt das Tagesvideo; liefert (Pfad, Dateiendung) oder None, wenn es keine Bilder gibt."""
folder = os.path.join(TL_DIR, name, day)
try:
files = sorted(os.path.join(folder, f) for f in os.listdir(folder) if FILE_RE.match(f))
files = sorted(os.path.join(folder, f) for f in os.listdir(folder)
if FILE_RE.match(f) and f.endswith("_m.jpg") == (kind == "motion"))
except FileNotFoundError:
files = []
if not files:
@@ -259,7 +312,7 @@ def make_video(name, day, fps):
fd, path = tempfile.mkstemp(suffix=".mp4")
os.close(fd)
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps),
"-pattern_type", "glob", "-i", os.path.join(folder, "*.jpg"),
"-pattern_type", "glob", "-i", os.path.join(folder, "*_m.jpg" if kind == "motion" else "??????.jpg"),
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", path],
capture_output=True, timeout=600)
@@ -324,7 +377,8 @@ class Handler(BaseHTTPRequestHandler):
return self.send_bytes(200, "text/html; charset=utf-8", page("", ""))
if parts == ["status.json"]:
body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS,
"timelapse": tl_state[n]}
"timelapse": tl_state[n],
"motion_ok": Image is not None}
for n, h in hubs.items()]}).encode()
return self.send_bytes(200, "application/json", body)
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
@@ -338,7 +392,7 @@ class Handler(BaseHTTPRequestHandler):
q = dict(kv.split("=", 1) for kv in self.path.partition("?")[2].split("&") if "=" in kv)
fps = int(q["fps"]) if q.get("fps", "").isdigit() and 1 <= int(q["fps"]) <= 60 else 10
try:
res = make_video(name, parts[2], fps)
res = make_video(name, parts[2], fps, "motion" if q.get("kind") == "motion" else "lapse")
except (OSError, ValueError, subprocess.SubprocessError) as ex:
print(f"{name}: Video fehlgeschlagen: {ex}", flush=True)
return self.send_bytes(500, "text/plain", b"Video konnte nicht erstellt werden")
@@ -349,7 +403,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_response(200)
self.send_header("Content-Type", "video/mp4" if ext == "mp4" else "video/x-msvideo")
self.send_header("Content-Length", str(os.path.getsize(path)))
self.send_header("Content-Disposition", f'attachment; filename="{name}-{parts[2]}.{ext}"')
self.send_header("Content-Disposition", f'attachment; filename="{name}-{parts[2]}{"-bewegung" if q.get("kind") == "motion" else ""}.{ext}"')
self.end_headers()
with open(path, "rb") as f:
shutil.copyfileobj(f, self.wfile)
@@ -396,6 +450,10 @@ class Handler(BaseHTTPRequestHandler):
st = tl_state[parts[0]]
if "enabled" in req:
st["enabled"] = bool(req["enabled"])
if "motion" in req:
st["motion"] = bool(req["motion"]) and Image is not None
if req.get("sensitivity") in MOTION_LEVELS:
st["sensitivity"] = req["sensitivity"]
if int(req.get("interval", st["interval"])) in TL_INTERVALS:
st["interval"] = int(req.get("interval", st["interval"]))
save_tl_state()
@@ -444,6 +502,9 @@ if __name__ == "__main__":
for n, u in CAMERAS.items():
threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start()
threading.Thread(target=timelapse, args=(n, hubs[n]), daemon=True).start()
threading.Thread(target=motion, args=(n, hubs[n]), daemon=True).start()
print(f"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
print(f"Timelapse-Ordner: {TL_DIR}", flush=True)
if Image is None:
print("Pillow fehlt - Bewegungserkennung nicht verfügbar (apt install python3-pil)", flush=True)
Server(("0.0.0.0", PORT), Handler).serve_forever()