Bewegungserkennung pro Kamera (Pillow), Bewegungsbilder in der Timelapse-Galerie mit Filter
This commit is contained in:
1 parent
420e8e1550
commit
ff3dc7ee2b
4 files changed
+125
-32
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user