Private
Public Access
345 lines
18 KiB
HTML
345 lines
18 KiB
HTML
<!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-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">Personenerkennung</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>
|
||
<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 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">Aufnahmen bleiben deaktiviert. Den Auslösebereich 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 Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
|
||
<div class="row">
|
||
<button id="zone-clear">Bereich frei (Test)</button>
|
||
<button id="test-trigger">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>
|
||
<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;
|
||
$('phase').textContent = c.phase;
|
||
$('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') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (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, 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;
|
||
}
|
||
}
|
||
$('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 = await api('/api/detection/config', detectionConfig);
|
||
detectionEpoch++; people = []; drawDetection();
|
||
$('message').textContent = '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; }
|
||
$('detection-status').textContent = status.error ||
|
||
(!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') +
|
||
' · '+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);
|
||
}
|
||
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; drawDetection();
|
||
}).catch(error => { $('detection-status').textContent = error.message; });
|
||
refreshDetection();
|
||
setInterval(drawDetection, 250);
|
||
refresh();
|
||
refreshDb();
|
||
setInterval(refresh, 1000);
|
||
setInterval(refreshDb, 15000);
|
||
</script>
|
||
</body>
|
||
</html>
|