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

+2
View File
@@ -12,6 +12,8 @@ CAMERA_cam3=http://IP:PORT/pfad
# Zielordner (default: timelapse/ neben relay.py; eigener Pfad muss für den Dienstbenutzer beschreibbar sein) # Zielordner (default: timelapse/ neben relay.py; eigener Pfad muss für den Dienstbenutzer beschreibbar sein)
#TIMELAPSE_DIR=/pfad/zum/ordner #TIMELAPSE_DIR=/pfad/zum/ordner
#TIMELAPSE_KEEP_DAYS=30 #TIMELAPSE_KEEP_DAYS=30
# Bewegungserkennung (je Kamera im Frontend, braucht Pillow): Mindestabstand zwischen zwei Bildern in Sekunden
#MOTION_COOLDOWN=10
PORT=8000 PORT=8000
# Optional: Login (nur sinnvoll mit HTTPS in Zoraxy) # Optional: Login (nur sinnvoll mit HTTPS in Zoraxy)
+6 -3
View File
@@ -31,6 +31,7 @@ Zwei kleine Python-Tools für MJPEG-Kameras:
- Python 3 - Python 3
- [`requests`](https://pypi.org/project/requests/) - [`requests`](https://pypi.org/project/requests/)
- Optional: [`Pillow`](https://pypi.org/project/pillow/) für die Bewegungserkennung (`apt install python3-pil` oder `pip install pillow`)
- Optional: `ffmpeg` für den Video-Download der Timelapse als MP4 (`apt install ffmpeg`); ohne ffmpeg liefert das Relay ein MJPEG-AVI - Optional: `ffmpeg` für den Video-Download der Timelapse als MP4 (`apt install ffmpeg`); ohne ffmpeg liefert das Relay ein MJPEG-AVI
- Optional für `webcam.py --show`: `opencv-python` und `numpy` - Optional für `webcam.py --show`: `opencv-python` und `numpy`
@@ -55,6 +56,7 @@ cp .env.example .env
| `SETUP_<name>` | Optional: Konfigurations-URL der Kamera (z. B. `http://IP/control?var=framesize&val=11`). Das Relay ruft sie auf, wenn im Frontend „Setup“ geklickt wird. | – | | `SETUP_<name>` | Optional: Konfigurations-URL der Kamera (z. B. `http://IP/control?var=framesize&val=11`). Das Relay ruft sie auf, wenn im Frontend „Setup“ geklickt wird. | – |
| `TIMELAPSE_INTERVAL` | Optional: Standard-Intervall der Timelapse in Sekunden. Ein/aus und Intervall werden je Kamera im Frontend gesetzt und in `state.json` gespeichert. | `300` | | `TIMELAPSE_INTERVAL` | Optional: Standard-Intervall der Timelapse in Sekunden. Ein/aus und Intervall werden je Kamera im Frontend gesetzt und in `state.json` gespeichert. | `300` |
| `TIMELAPSE_DIR` | Zielordner, Struktur `<name>/<JJJJ-MM-TT>/<HHMMSS>.jpg`. | `timelapse/` neben `relay.py` | | `TIMELAPSE_DIR` | Zielordner, Struktur `<name>/<JJJJ-MM-TT>/<HHMMSS>.jpg`. | `timelapse/` neben `relay.py` |
| `MOTION_COOLDOWN` | Mindestabstand in Sekunden zwischen zwei Bewegungsbildern (Erkennung selbst je Kamera im Frontend, braucht Pillow). | `10` |
| `TIMELAPSE_KEEP_DAYS` | Tage aufbewahren, ältere Tagesordner werden gelöscht (0 = unbegrenzt). | `0` | | `TIMELAPSE_KEEP_DAYS` | Tage aufbewahren, ältere Tagesordner werden gelöscht (0 = unbegrenzt). | `0` |
| `PORT` | Port, auf dem der Relay lauscht | `8000` | | `PORT` | Port, auf dem der Relay lauscht | `8000` |
| `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus | | `AUTH_USER` / `AUTH_PASS` | Optional: Basic-Auth. Nur sinnvoll mit HTTPS (z. B. in Zoraxy). | aus |
@@ -109,10 +111,10 @@ journalctl -u webcam-relay -f
| `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) | | `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) |
| `/<name>/stream.mjpg` | Livestream (`multipart/x-mixed-replace`) | | `/<name>/stream.mjpg` | Livestream (`multipart/x-mixed-replace`) |
| `/<name>/snapshot.jpg` | Aktuelles Einzelbild | | `/<name>/snapshot.jpg` | Aktuelles Einzelbild |
| `POST /<name>/timelapse` | Timelapse steuern, Body `{"enabled":true,"interval":300}` | | `POST /<name>/timelapse` | Timelapse und Bewegungserkennung steuern, Body z. B. `{"enabled":true,"interval":300,"motion":true,"sensitivity":"mid"}` |
| `/<name>/timelapse.json` | Gespeicherte Timelapse-Bilder je Tag | | `/<name>/timelapse.json` | Gespeicherte Timelapse-Bilder je Tag |
| `/<name>/timelapse/<tag>/<datei>.jpg` | Einzelnes Timelapse-Bild | | `/<name>/timelapse/<tag>/<datei>.jpg` | Einzelnes Timelapse-Bild |
| `/<name>/timelapse/<tag>/video?fps=10` | Tagesvideo als Download (MP4 mit ffmpeg, sonst MJPEG-AVI); `fps` 1–60 | | `/<name>/timelapse/<tag>/video?fps=10` | Tagesvideo als Download (MP4 mit ffmpeg, sonst MJPEG-AVI); `fps` 1–60, mit `&kind=motion` aus den Bewegungsbildern |
| `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) | | `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) |
| `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar | | `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar |
@@ -122,7 +124,7 @@ Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten
- Dunkles Design, zwei Kameras nebeneinander (auf dem Handy untereinander); Klick auf den Namen öffnet die große Ansicht - Dunkles Design, zwei Kameras nebeneinander (auf dem Handy untereinander); Klick auf den Namen öffnet die große Ansicht
- Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden, Zähler „X / Y online“ - Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden, Zähler „X / Y online“
- Buttons pro Kamera: Start, Stop, Reload, Foto (Download), Vollbild; „Timelapse“ schaltet die Aufnahme je Kamera ein/aus, „Galerie“ zeigt die Bilder (Tag wählen, anklicken, abspielen; Intervall einstellbar) und lädt ein Tagesvideo herunter (5/10/25 fps) - Buttons pro Kamera: Start, Stop, Reload, Foto (Download), Vollbild; „Timelapse“ schaltet die Aufnahme je Kamera ein/aus, „Galerie“ zeigt die Bilder (Tag wählen, anklicken, abspielen; Intervall einstellbar) und lädt ein Tagesvideo herunter (5/10/25 fps); „Bewegung“ schaltet die Bewegungserkennung je Kamera ein/aus – Bewegungsbilder (orange markiert) erscheinen in derselben Galerie und lassen sich filtern
- Toolbar: Alle starten / stoppen / neu laden - Toolbar: Alle starten / stoppen / neu laden
- „Setup“ pro Kamera und „Setup alle“, sofern `SETUP_<name>` konfiguriert ist; das Relay ruft die URL auf, der Browser muss die Kamera nicht erreichen - „Setup“ pro Kamera und „Setup alle“, sofern `SETUP_<name>` konfiguriert ist; das Relay ruft die URL auf, der Browser muss die Kamera nicht erreichen
- Streams werden getrennt, solange der Tab im Hintergrund ist - Streams werden getrennt, solange der Tab im Hintergrund ist
@@ -134,6 +136,7 @@ Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten
- Bei Verbindungsfehlern wird nach 3 Sekunden automatisch neu verbunden. - Bei Verbindungsfehlern wird nach 3 Sekunden automatisch neu verbunden.
- Alle Zuschauer bekommen jeweils das neueste Frame – die Kamera wird unabhängig von der Zuschauerzahl nur einmal belastet. - Alle Zuschauer bekommen jeweils das neueste Frame – die Kamera wird unabhängig von der Zuschauerzahl nur einmal belastet.
- Der Header `X-Accel-Buffering: no` verhindert Pufferung durch den Proxy. - Der Header `X-Accel-Buffering: no` verhindert Pufferung durch den Proxy.
- Bewegungserkennung: Jede Sekunde wird das Frame auf 80×60 Graustufen verkleinert und mit einem langsam nachgeführten Referenzbild verglichen. Ändern sich mehr als 4 % / 2 % / 0,7 % der Pixel (Empfindlichkeit niedrig / mittel / hoch), wird das Bild als `HHMMSS_m.jpg` neben den Timelapse-Bildern gespeichert (danach `MOTION_COOLDOWN` Sekunden Pause). Das Nachführen des Referenzbilds fängt Tageslichtwechsel ab.
## Stream-Tool (`webcam.py`) ## Stream-Tool (`webcam.py`)
+35 -8
View File
@@ -78,6 +78,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
#tl .big .when{position:absolute;left:12px;bottom:10px;background:rgba(0,0,0,.6);padding:2px 10px;border-radius:999px;font-size:13px;font-variant-numeric:tabular-nums} #tl .big .when{position:absolute;left:12px;bottom:10px;background:rgba(0,0,0,.6);padding:2px 10px;border-radius:999px;font-size:13px;font-variant-numeric:tabular-nums}
#tl .thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px} #tl .thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px}
#tl .thumbs img{height:72px;aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:2px solid transparent;cursor:pointer;flex:none;background:#000} #tl .thumbs img{height:72px;aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:2px solid transparent;cursor:pointer;flex:none;background:#000}
#tl .thumbs img.mot{border-color:#f59e0b}
#tl .thumbs img.sel{border-color:var(--accent)} #tl .thumbs img.sel{border-color:var(--accent)}
#tl .empty{color:var(--muted);margin:auto} #tl .empty{color:var(--muted);margin:auto}
@media (max-width:700px){.grid{grid-template-columns:1fr}} @media (max-width:700px){.grid{grid-template-columns:1fr}}
@@ -125,6 +126,7 @@ const ICONS = {
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>', reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>', full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>', down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
motion: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>', rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>', film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>',
off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>' off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>'
@@ -180,7 +182,7 @@ function makeTile(name, mode, hasSetup) {
}, "alt"); }, "alt");
actions.append(setup); actions.append(setup);
} }
const o = { name, mode, t, img, state, ovText, rec: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null }; const o = { name, mode, t, img, state, ovText, rec: null, mot: null, tl: { enabled: false, interval: 300 }, stopped: false, online: null, timer: null, retry: null };
if (mode === "live") { if (mode === "live") {
actions.append( actions.append(
btn("Start", "play", () => startTile(o), "ok"), btn("Start", "play", () => startTile(o), "ok"),
@@ -188,9 +190,11 @@ function makeTile(name, mode, hasSetup) {
btn("Reload", "reload", () => reloadTile(o), "alt")); btn("Reload", "reload", () => reloadTile(o), "alt"));
} }
const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt"); const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt");
const mot = btn("Bewegung aus", "motion", () => setTimelapse(name, { motion: !tiles[name].tl.motion }), "alt");
const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt"); const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt");
o.rec = rec; o.rec = rec;
actions.append(snap, rec, gallery, full); o.mot = mot;
actions.append(snap, rec, mot, gallery, full);
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions); cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
t.append(view, cap); t.append(view, cap);
@@ -219,12 +223,21 @@ async function openTimelapse(name) {
TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v))); TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v)));
ivSel.value = tiles[name].tl.interval; ivSel.value = tiles[name].tl.interval;
ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value }); ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value });
const sensSel = el("select");
sensSel.title = "Empfindlichkeit der Bewegungserkennung";
[["low", "Bewegung: niedrig"], ["mid", "Bewegung: mittel"], ["high", "Bewegung: hoch"]].forEach(([v, t]) => sensSel.append(new Option(t, v)));
sensSel.value = tiles[name].tl.sensitivity;
sensSel.onchange = () => setTimelapse(name, { sensitivity: sensSel.value });
sensSel.hidden = !tiles[name].motionOk;
const kindSel = el("select");
kindSel.title = "Anzeige filtern";
[["all", "Alle Bilder"], ["lapse", "Nur Zeitraffer"], ["motion", "Nur Bewegung"]].forEach(([v, t]) => kindSel.append(new Option(t, v)));
const fpsSel = el("select"); const fpsSel = el("select");
fpsSel.title = "Bilder pro Sekunde im Video"; fpsSel.title = "Bilder pro Sekunde im Video";
[5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v))); [5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v)));
fpsSel.value = 10; fpsSel.value = 10;
const video = btn("Video", "down", () => {}, "alt"); const video = btn("Video", "down", () => {}, "alt");
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, fpsSel, video, el("span", "sp"), close); bar.append(el("h2", "", "Timelapse " + name), daySel, kindSel, play, ivSel, sensSel, fpsSel, video, el("span", "sp"), close);
bar.querySelector(".sp").style.flex = "1"; bar.querySelector(".sp").style.flex = "1";
const body = el("div", "body"); const body = el("div", "body");
tl.replaceChildren(bar, body); tl.replaceChildren(bar, body);
@@ -240,7 +253,7 @@ async function openTimelapse(name) {
} }
const days = Object.keys(data.days).filter(d => data.days[d].length); const days = Object.keys(data.days).filter(d => data.days[d].length);
if (!days.length) { if (!days.length) {
body.append(el("div", "empty", "Noch keine Bilder – Timelapse mit dem Button in der Kachel starten")); body.append(el("div", "empty", "Noch keine Bilder – Timelapse oder Bewegungserkennung in der Kachel starten"));
play.hidden = daySel.hidden = true; play.hidden = daySel.hidden = true;
return; return;
} }
@@ -255,7 +268,7 @@ async function openTimelapse(name) {
idx = (i + files.length) % files.length; idx = (i + files.length) % files.length;
const d = daySel.value, f = files[idx]; const d = daySel.value, f = files[idx];
bigImg.src = url(d, f); bigImg.src = url(d, f);
when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6); when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6) + (f.includes("_m") ? " · Bewegung" : "");
thumbs.querySelectorAll("img").forEach((im, k) => im.classList.toggle("sel", k === idx)); thumbs.querySelectorAll("img").forEach((im, k) => im.classList.toggle("sel", k === idx));
const sel = thumbs.children[idx]; const sel = thumbs.children[idx];
if (sel) sel.scrollIntoView({ block: "nearest", inline: "center" }); if (sel) sel.scrollIntoView({ block: "nearest", inline: "center" });
@@ -263,9 +276,15 @@ async function openTimelapse(name) {
const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; }; const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; };
const loadDay = () => { const loadDay = () => {
stop(); stop();
files = data.days[daySel.value]; files = data.days[daySel.value].filter(f => kindSel.value === "all" || (kindSel.value === "motion") === f.includes("_m"));
if (!files.length) {
thumbs.replaceChildren();
bigImg.removeAttribute("src");
when.textContent = "Keine Bilder dieser Art an diesem Tag";
return;
}
thumbs.replaceChildren(...files.map((f, i) => { thumbs.replaceChildren(...files.map((f, i) => {
const im = el("img"); const im = el("img", f.includes("_m") ? "mot" : "");
im.loading = "lazy"; im.loading = "lazy";
im.src = url(daySel.value, f); im.src = url(daySel.value, f);
im.onclick = () => { stop(); show(i); }; im.onclick = () => { stop(); show(i); };
@@ -274,12 +293,13 @@ async function openTimelapse(name) {
show(files.length - 1); show(files.length - 1);
}; };
daySel.onchange = loadDay; daySel.onchange = loadDay;
kindSel.onchange = loadDay;
video.onclick = async () => { video.onclick = async () => {
const label = video.querySelector("span"); const label = video.querySelector("span");
video.disabled = true; video.disabled = true;
label.textContent = "erstelle …"; label.textContent = "erstelle …";
try { try {
const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value); const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value + (kindSel.value === "motion" ? "&kind=motion" : ""));
if (!r.ok) throw new Error(r.status); if (!r.ok) throw new Error(r.status);
const a = el("a"); const a = el("a");
a.href = URL.createObjectURL(await r.blob()); a.href = URL.createObjectURL(await r.blob());
@@ -294,6 +314,7 @@ async function openTimelapse(name) {
video.disabled = false; video.disabled = false;
}; };
play.onclick = () => { play.onclick = () => {
if (!files.length) return;
if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; } if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; }
play.querySelector("span").textContent = "Pause"; play.querySelector("span").textContent = "Pause";
if (idx === files.length - 1) show(0); if (idx === files.length - 1) show(0);
@@ -311,6 +332,11 @@ function renderTimelapse(o) {
o.rec.classList.toggle("alt", !on); o.rec.classList.toggle("alt", !on);
o.rec.title = on ? "Timelapse läuft (alle " + fmtInterval(o.tl.interval) + ") – klicken zum Stoppen" : "Timelapse starten"; o.rec.title = on ? "Timelapse läuft (alle " + fmtInterval(o.tl.interval) + ") – klicken zum Stoppen" : "Timelapse starten";
o.rec.querySelector("span").textContent = on ? "Timelapse " + fmtInterval(o.tl.interval) : "Timelapse aus"; o.rec.querySelector("span").textContent = on ? "Timelapse " + fmtInterval(o.tl.interval) : "Timelapse aus";
o.mot.hidden = !o.motionOk;
o.mot.classList.toggle("rec", !!o.tl.motion);
o.mot.classList.toggle("alt", !o.tl.motion);
o.mot.title = o.tl.motion ? "Bewegungserkennung läuft – klicken zum Stoppen" : "Bewegungserkennung starten";
o.mot.querySelector("span").textContent = o.tl.motion ? "Bewegung an" : "Bewegung aus";
} }
async function setTimelapse(name, change) { async function setTimelapse(name, change) {
@@ -456,6 +482,7 @@ async function poll() {
data.cameras.forEach(c => { data.cameras.forEach(c => {
setOnline(tiles[c.name], c.online); setOnline(tiles[c.name], c.online);
tiles[c.name].tl = c.timelapse; tiles[c.name].tl = c.timelapse;
tiles[c.name].motionOk = c.motion_ok;
renderTimelapse(tiles[c.name]); renderTimelapse(tiles[c.name]);
}); });
const n = data.cameras.filter(c => c.online).length; const n = data.cameras.filter(c => c.online).length;
+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 TIMELAPSE_INTERVAL=<sek> Standard-Intervall der Timelapse (default 300); ein/aus und Intervall
stellt man pro Kamera im Frontend ein stellt man pro Kamera im Frontend ein
TIMELAPSE_DIR=<pfad> Zielordner (default: timelapse neben relay.py) 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) TIMELAPSE_KEEP_DAYS=<n> Tage aufbewahren (0 = unbegrenzt, default)
PORT (default 8000) PORT (default 8000)
AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy) AUTH_USER / AUTH_PASS optional: Basic-Auth (nur sinnvoll mit HTTPS in Zoraxy)
""" """
import base64 import base64
import hmac import hmac
import io
import json import json
import os import os
import re import re
@@ -42,6 +45,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import requests import requests
try:
from PIL import Image
except ImportError: # Bewegungserkennung ist optional
Image = None
def load_env(path): 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_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")) TL_KEEP_DAYS = int(os.environ.get("TIMELAPSE_KEEP_DAYS", "0"))
DAY_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") 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") FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
@@ -149,7 +159,10 @@ def load_tl_state():
except (OSError, ValueError): except (OSError, ValueError):
saved = {} saved = {}
return {n: {"enabled": bool(saved.get(n, {}).get("enabled")), 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() tl_state = load_tl_state()
@@ -165,9 +178,24 @@ def save_tl_state():
print(f"Timelapse-Zustand nicht gespeichert: {ex}", flush=True) 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): def timelapse(name, hub):
"""Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild """Speichert, solange für die Kamera aktiviert, im Intervall das aktuelle Bild."""
nach TL_DIR/<name>/<tag>/<HHMMSS>.jpg."""
last_seq, last_t = 0, 0.0 last_seq, last_t = 0, 0.0
while True: while True:
time.sleep(1) time.sleep(1)
@@ -178,18 +206,42 @@ def timelapse(name, hub):
if frame is None or seq == last_seq or not is_online(hub): if frame is None or seq == last_seq or not is_online(hub):
continue continue
last_seq, last_t = seq, time.time() last_seq, last_t = seq, time.time()
now = time.localtime() save_frame(name, frame)
folder = os.path.join(TL_DIR, name, time.strftime("%Y-%m-%d", now))
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: try:
os.makedirs(folder, exist_ok=True) cur = thumb(frame)
tmp = os.path.join(folder, ".tmp") except (OSError, ValueError):
with open(tmp, "wb") as f: continue
f.write(frame) if ref is None or len(ref) != len(cur):
os.replace(tmp, os.path.join(folder, time.strftime("%H%M%S", now) + ".jpg")) ref = [float(v) for v in cur]
if TL_KEEP_DAYS: continue
prune(os.path.join(TL_DIR, name)) changed = sum(1 for a, b in zip(ref, cur) if abs(a - b) > 25)
except OSError as ex: ref = [a + (b - a) * 0.1 for a, b in zip(ref, cur)]
print(f"{name}: Timelapse-Bild nicht gespeichert: {ex}", flush=True) 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): def prune(cam_dir):
@@ -245,11 +297,12 @@ def write_avi(path, files, fps):
video_lock = threading.Lock() 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.""" """Erzeugt das Tagesvideo; liefert (Pfad, Dateiendung) oder None, wenn es keine Bilder gibt."""
folder = os.path.join(TL_DIR, name, day) folder = os.path.join(TL_DIR, name, day)
try: 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: except FileNotFoundError:
files = [] files = []
if not files: if not files:
@@ -259,7 +312,7 @@ def make_video(name, day, fps):
fd, path = tempfile.mkstemp(suffix=".mp4") fd, path = tempfile.mkstemp(suffix=".mp4")
os.close(fd) os.close(fd)
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps), 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", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", path], "-pix_fmt", "yuv420p", "-movflags", "+faststart", path],
capture_output=True, timeout=600) capture_output=True, timeout=600)
@@ -324,7 +377,8 @@ class Handler(BaseHTTPRequestHandler):
return self.send_bytes(200, "text/html; charset=utf-8", page("", "")) return self.send_bytes(200, "text/html; charset=utf-8", page("", ""))
if parts == ["status.json"]: if parts == ["status.json"]:
body = json.dumps({"cameras": [{"name": n, "online": is_online(h), "setup": n in SETUPS, 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() for n, h in hubs.items()]}).encode()
return self.send_bytes(200, "application/json", body) return self.send_bytes(200, "application/json", body)
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: 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) 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 fps = int(q["fps"]) if q.get("fps", "").isdigit() and 1 <= int(q["fps"]) <= 60 else 10
try: 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: except (OSError, ValueError, subprocess.SubprocessError) as ex:
print(f"{name}: Video fehlgeschlagen: {ex}", flush=True) print(f"{name}: Video fehlgeschlagen: {ex}", flush=True)
return self.send_bytes(500, "text/plain", b"Video konnte nicht erstellt werden") 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_response(200)
self.send_header("Content-Type", "video/mp4" if ext == "mp4" else "video/x-msvideo") 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-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() self.end_headers()
with open(path, "rb") as f: with open(path, "rb") as f:
shutil.copyfileobj(f, self.wfile) shutil.copyfileobj(f, self.wfile)
@@ -396,6 +450,10 @@ class Handler(BaseHTTPRequestHandler):
st = tl_state[parts[0]] st = tl_state[parts[0]]
if "enabled" in req: if "enabled" in req:
st["enabled"] = bool(req["enabled"]) 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: if int(req.get("interval", st["interval"])) in TL_INTERVALS:
st["interval"] = int(req.get("interval", st["interval"])) st["interval"] = int(req.get("interval", st["interval"]))
save_tl_state() save_tl_state()
@@ -444,6 +502,9 @@ if __name__ == "__main__":
for n, u in CAMERAS.items(): for n, u in CAMERAS.items():
threading.Thread(target=pull_camera, args=(n, u, hubs[n]), daemon=True).start() 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=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"Relay läuft auf Port {PORT}, Kameras: {', '.join(CAMERAS)}", flush=True)
print(f"Timelapse-Ordner: {TL_DIR}", 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() Server(("0.0.0.0", PORT), Handler).serve_forever()