Files
Halloween/halloween/index.html
T
2026-10-14 17:40:31 +02:00

415 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Halloween-Grab · Steuerung</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111718; color: #eef0e9; }
* { box-sizing: border-box; }
body { margin: 0; }
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #182122; border-bottom: 1px solid #3a4644; }
h1 { margin: 0; font-size: 1.5rem; }
header p { margin: .35rem 0 0; color: #c1cbc2; }
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
.camera-wrap { position: relative; aspect-ratio: 4/3; }
.camera-wrap .camera { position: absolute; inset: 0; height: 100%; }
#detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; }
button, select, a.download { cursor: pointer; }
button:hover, a.download:hover { background: #36504a; }
button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
button.danger:hover { background: #9c352c; }
button:disabled { opacity: .5; cursor: not-allowed; }
a.download { display: inline-block; text-decoration: none; }
.value { font-weight: 650; color: #ffca78; }
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
.muted { color: #bfcbc6; }
#message { min-height: 1.5rem; color: #ffca78; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
dt { color: #adbfba; } dd { margin: 0; }
.events { grid-column: 1 / -1; }
#events-list { margin: .5rem 0 0; padding-left: 1.4rem; }
#events-list li { margin: .35rem 0; }
#profiles-list li { margin: .35rem 0; }
.event-image { display: block; max-width: 200px; max-height: 150px; border-radius: 7px; margin: .5rem 0; }
.event-tools { margin: .3rem 0 .8rem; }
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>Halloween-Grab</h1>
<p>Lokale Kameraansicht und Steuerung</p>
</header>
<main>
<section>
<h2>Livebild</h2>
<div class="camera-wrap">
<img id="live-image" class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
<canvas id="detection-overlay" width="640" height="480" aria-label="Linie und Auslösebereich einstellen"></canvas>
</div>
<h2 style="margin-top:1rem">Automatische Auslösung</h2>
<p>Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.</p>
<label for="detection-method">Erkennung wählen</label>
<select id="detection-method">
<option value="person_line">Person überquert Linie</option>
<option value="motion_test">Test: Bewegung in Pfeilrichtung</option>
</select>
<p id="detection-help" class="muted"></p>
<div class="row">
<label><input type="checkbox" id="detection-enabled"> Automatisch auslösen</label>
<button id="reverse-direction">Richtung umkehren</button>
<button id="save-detection">Linie und Bereich speichern</button>
</div>
<p id="detection-status" class="muted" role="status">Erkennung lädt …</p>
<p id="motion-details" class="muted" role="status" hidden></p>
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
<p class="notice">Bei automatischer Auslösung wird der grüne Bereich als Bild lokal auf dem Pi gespeichert, bis du es löschst. Den Bereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.</p>
</section>
<section>
<h2>Betrieb</h2>
<dl>
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
<dt>Letzte Auslösung</dt><dd id="last-event">–</dd>
</dl>
<label for="mode">Modus wählen</label>
<div class="row" style="margin: .5rem 0 1rem">
<select id="mode">
<option value="voll">Voller Schreck</option>
<option value="sanft">Sanft</option>
<option value="atmosphaere">Atmosphäre</option>
<option value="aus">Aus</option>
</select>
<button id="save-mode">Übernehmen</button>
</div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
<div class="row">
<button id="zone-clear">Bereich frei (manueller Test)</button>
<button id="test-trigger">Manuelle Testauslösung</button>
</div>
<div class="row" style="margin-top: 1rem">
<button class="danger" id="stop">Stopp</button>
<button id="reset">Nach Stopp zurücksetzen</button>
</div>
<p id="message" role="status" aria-live="polite"></p>
</section>
<section class="events">
<h2>Gespeicherte Ereignisse</h2>
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
<button id="refresh-events">Ereignisse aktualisieren</button>
<ul id="events-list"><li>Lädt …</li></ul>
</section>
<section class="events">
<h2>Personenprofile</h2>
<p class="notice">Die automatische Wiedererkennung per Kamera ist noch nicht aktiv. Diese Profile sind derzeit nur manuell angelegte Kennungen; Bilder oder Gesichtsmerkmale werden nicht gespeichert.</p>
<div class="row">
<input id="profile-label" type="text" maxlength="80" aria-label="Profilkennung" placeholder="Profilkennung oder Spitzname">
<button id="create-profile">Profil anlegen</button>
</div>
<ul id="profiles-list"><li>Lädt …</li></ul>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
let profiles = [];
async function api(path, body = {}, method = 'POST') {
const response = await fetch(path, {
method, headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Anfrage fehlgeschlagen');
return data;
}
async function refresh() {
try {
const response = await fetch('/api/status', { cache: 'no-store' });
const data = await response.json();
const c = data.controller, cam = data.camera;
$('mode-label').textContent = names[c.mode] || c.mode;
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
const detection = data.detection;
let phase = c.phase;
// The manual zone-clear flag is not the automatic detection gate.
// Preserve running sequence, stop and cooldown labels from the controller.
if (detection.enabled && ['Bereit', 'Bereich frei abwarten'].includes(phase)) {
phase = !['sanft', 'voll'].includes(c.mode) ? 'Modus ohne Schreckauslösung'
: detection.reset_required ? 'Erkennung gesperrt; zurücksetzen'
: !detection.ready ? 'Auf Erkennung warten'
: detection.armed ? 'Bereit (Automatik)'
: detection.method === 'motion_test' ? 'Ruhiges Bild abwarten (Automatik)'
: 'Freien Bereich abwarten (Automatik)';
}
$('phase').textContent = phase;
$('zone-clear').title = 'Nur für manuelle Tests. Die Automatik prüft die Freigabe selbst.';
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
if (c.config_error) $('message').textContent = c.config_error;
} catch (error) {
$('camera-status').textContent = 'Server nicht erreichbar';
$('message').textContent = error.message;
}
}
async function action(path, body) {
try {
const data = await api(path, body);
$('message').textContent = data.reason || 'Einstellung übernommen';
await refresh();
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
}
async function refreshProfiles() {
const response = await fetch('/api/profiles', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Profile nicht verfügbar');
profiles = data.profiles;
const list = $('profiles-list');
list.replaceChildren();
if (!profiles.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Profile';
list.append(item);
}
for (const profile of profiles) {
const item = document.createElement('li');
const label = document.createElement('span');
label.textContent = '#' + profile.id + ' · ' + profile.label + ' · ' + profile.event_count + ' Ereignisse ';
const remove = document.createElement('button');
remove.textContent = 'Löschen';
remove.onclick = async () => {
if (!confirm('Profil und seine Zuordnungen entfernen? Die Ereignisse bleiben erhalten.')) return;
try {
await api('/api/profiles/' + profile.id, {}, 'DELETE');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
item.append(label, remove);
list.append(item);
}
}
async function refreshEvents() {
try {
const response = await fetch('/api/events', { cache: 'no-store' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ereignisse nicht verfügbar');
const list = $('events-list');
list.replaceChildren();
if (!data.events.length) {
const item = document.createElement('li');
item.textContent = 'Noch keine Auslösung gespeichert';
list.append(item);
}
for (const event of data.events) {
const item = document.createElement('li');
const summary = document.createElement('span');
summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + ({person_line_crossing: 'Linienüberquerung', motion_direction_test: 'Bewegungs-Test', test_trigger: 'Manueller Test'}[event.kind] || event.kind) + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
const tools = document.createElement('div');
tools.className = 'row event-tools';
const select = document.createElement('select');
select.setAttribute('aria-label', 'Profil für Ereignis ' + event.id);
select.add(new Option('Ohne Profil', ''));
for (const profile of profiles) select.add(new Option('#' + profile.id + ' · ' + profile.label, String(profile.id)));
select.value = event.profile_id === null ? '' : String(event.profile_id);
const assign = document.createElement('button');
assign.textContent = 'Zuordnen';
assign.onclick = async () => {
try {
await api('/api/events/' + event.id + '/profile', { profile_id: select.value ? Number(select.value) : null }, 'PATCH');
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
tools.append(select, assign);
item.append(summary);
if (event.image_url) {
const link = document.createElement('a');
link.href = event.image_url; link.target = '_blank'; link.rel = 'noopener';
link.textContent = 'Bild ansehen';
const preview = document.createElement('img');
preview.className = 'event-image'; preview.loading = 'lazy';
preview.src = event.image_url; preview.alt = 'Auslösebild zu Ereignis #' + event.id;
link.prepend(preview);
const removeImage = document.createElement('button');
removeImage.textContent = 'Bild löschen';
removeImage.onclick = async () => {
if (!confirm('Bild unwiderruflich löschen? Der Ereigniseintrag bleibt erhalten.')) return;
try {
await api(event.image_url, {}, 'DELETE');
await refreshEvents();
} catch (error) { $('message').textContent = error.message; }
};
item.append(link, removeImage);
} else {
const missing = document.createElement('span');
missing.className = 'muted'; missing.textContent = ' · Kein gespeichertes Bild';
item.append(missing);
}
item.append(tools);
list.append(item);
}
} catch (error) { $('events-list').textContent = error.message; }
}
async function refreshDb() {
try {
await refreshProfiles();
await refreshEvents();
} catch (error) {
$('profiles-list').textContent = error.message;
$('events-list').textContent = error.message;
}
}
$('refresh-events').onclick = refreshEvents;
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
$('zone-clear').onclick = () => action('/api/zone-clear');
$('test-trigger').onclick = () => action('/api/test-trigger');
$('stop').onclick = () => action('/api/stop');
$('reset').onclick = () => action('/api/reset');
$('create-profile').onclick = async () => {
try {
await api('/api/profiles', { label: $('profile-label').value });
$('profile-label').value = '';
await refreshDb();
} catch (error) { $('message').textContent = error.message; }
};
let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0;
let detectionEpoch = 0;
const overlay = $('detection-overlay'), context = overlay.getContext('2d');
function drawDetection() {
context.clearRect(0, 0, 640, 480);
if (!detectionConfig) return;
const c = detectionConfig, [x1,y1,x2,y2] = c.region;
context.lineWidth = 3; context.strokeStyle = '#78d9a2';
context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480);
const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480;
context.strokeStyle = '#ffb44f'; context.beginPath();
context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke();
const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240;
const nx = -dy/length*c.direction, ny = dx/length*c.direction;
context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40);
context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke();
for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) {
context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill();
}
if (performance.now()-lastImageAt > 2000) return;
for (const person of people) {
const [l,t,r,bottom] = person.box;
context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6';
context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480);
context.fillStyle = context.strokeStyle; context.font = '16px sans-serif';
context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5));
}
}
function pointer(event) {
const rect = overlay.getBoundingClientRect();
return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),
Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))];
}
overlay.onpointerdown = event => {
if (!detectionConfig) return;
const p = pointer(event), c = detectionConfig;
const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)];
const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480));
const index = distances.indexOf(Math.min(...distances));
if (distances[index] > 30) return;
drag = index; overlay.setPointerCapture(event.pointerId);
};
overlay.onpointermove = event => {
if (drag === null) return;
const p = pointer(event), c = detectionConfig;
if (drag < 2) c.line[drag] = p;
else c.region.splice((drag-2)*2,2,...p);
drawDetection();
};
overlay.onpointerup = overlay.onpointercancel = () => { drag = null; };
$('reverse-direction').onclick = () => {
if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); }
};
$('save-detection').onclick = async () => {
if (!detectionConfig) return;
try {
detectionConfig.enabled = $('detection-enabled').checked;
detectionConfig.method = $('detection-method').value;
detectionConfig = await api('/api/detection/config', detectionConfig);
detectionEpoch++; people = []; drawDetection();
$('message').textContent = detectionConfig.method === 'motion_test'
? 'Testmodus gespeichert; Bild zwei Sekunden ruhig halten. Bei Stopp bewusst zurücksetzen.'
: 'Gespeichert; freien Auslösebereich abwarten';
} catch (error) { $('message').textContent = error.message; }
};
async function refreshDetection() {
const epoch = detectionEpoch, requestedAt = performance.now();
try {
const response = await fetch('/api/detection', {cache:'no-store'});
if (!response.ok) throw new Error('Erkennung nicht erreichbar');
const data = await response.json(), status = data.status;
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
const motionTest = status.method === 'motion_test';
$('motion-details').hidden = !motionTest;
const motion = data.motion;
$('motion-details').textContent = motionTest && motion
? 'Bildänderung: '+(motion.changed_fraction*100).toLocaleString('de-DE', {maximumFractionDigits:2})+
' % · '+motion.note+' · Bestätigung '+motion.confirmed_frames+'/2'
: 'Warte auf Bewegungswerte';
const detail = motionTest && data.motion?.moving
? (data.motion.directed ? ' · Bewegung in Pfeilrichtung' : ' · Bewegung erkannt') : '';
$('detection-status').textContent = (motionTest ? 'Bewegungs-Test · ' : '') + (status.error ||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed
? (motionTest ? 'Bereit für Bewegung in Pfeilrichtung' : 'Bereit für Überquerung zum Grab')
: (motionTest ? 'Ruhiges Bild / Sperrzeit abwarten' : 'Freigabe / Sperrzeit abwarten'))) +
detail+' · '+status.fps+' Auswertungen/s';
if (data.image_base64) {
const image = new Image();
image.src = 'data:image/jpeg;base64,'+data.image_base64;
await image.decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
$('live-image').src = image.src;
await $('live-image').decode();
if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; }
processed = true; people = data.people;
// Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten.
lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000;
} else {
people = [];
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
}
drawDetection();
} catch (error) {
people = []; drawDetection();
if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; }
$('detection-status').textContent = error.message;
}
setTimeout(refreshDetection, 200);
}
function updateDetectionHelp() {
$('detection-help').textContent = $('detection-method').value === 'motion_test'
? 'Nur Test: Hand, Kopf oder Gegenstand im grünen Bereich in Pfeilrichtung bewegen. Keine Linienüberquerung nötig. Zuerst zwei Sekunden ruhig halten; danach zehn Sekunden Sperrzeit und erneut Ruhe. Benötigt OpenCV, kein Personenmodell. Auch Kamerabewegung kann auslösen. Ein ruhiges Bild bedeutet nicht, dass der Bereich menschenfrei ist.'
: 'Eine erkannte Person muss die Linie in Pfeilrichtung überqueren. Zuerst den Bereich zwei Sekunden freihalten. Benötigt OpenCV und das installierte Personenmodell.';
}
$('detection-method').onchange = updateDetectionHelp;
updateDetectionHelp();
fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => {
detectionConfig = c; $('detection-enabled').checked = c.enabled;
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
}).catch(error => { $('detection-status').textContent = error.message; });
refreshDetection();
setInterval(drawDetection, 250);
refresh();
refreshDb();
setInterval(refresh, 1000);
setInterval(refreshDb, 15000);
</script>
</body>
</html>