Private
Public Access
498 lines
28 KiB
HTML
498 lines
28 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: #070a10; color: #eef4ff; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; }
|
||
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #0b111c; border-bottom: 1px solid #22334d; }
|
||
h1 { margin: 0; font-size: 1.5rem; }
|
||
header p { margin: .35rem 0 0; color: #a7b7d0; }
|
||
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
|
||
section { background: #0d1522; border: 1px solid #243550; 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: #03060b; 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 #344b6c; border-radius: 7px; background: #152238; color: #f1f6ff; font: inherit; padding: .65rem .8rem; }
|
||
button, select, a.download { cursor: pointer; }
|
||
button:hover, a.download:hover { background: #1d3659; }
|
||
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; }
|
||
nav a[aria-current="page"] { background:#123c69; border:2px solid #5aafff; font-weight:700; }
|
||
.value { font-weight: 650; color: #8bd6ff; }
|
||
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #211c14; margin: .8rem 0; }
|
||
.muted { color: #acbdd4; }
|
||
#message { min-height: 1.5rem; color: #8bd6ff; }
|
||
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
|
||
dt { color: #a7b7d0; } 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; } }
|
||
[hidden] { display: none !important; }
|
||
header { padding: .8rem 2vw; display: grid; grid-template-columns: 1fr auto; gap: .25rem 1rem; align-items: center; position: sticky; top: 0; z-index: 20; }
|
||
header h1, header > p { grid-column: 1; }
|
||
header nav { grid-column: 2; grid-row: 1 / span 2; margin: 0 !important; }
|
||
#all-stop-status:empty { display: none; }
|
||
#all-stop-status { grid-column: 1 / -1; }
|
||
main { width: 100%; max-width: 1800px; padding: 1rem 2vw; grid-template-columns: minmax(0,1.35fr) minmax(360px,1fr); align-items: start; }
|
||
section { min-width: 0; }
|
||
p { line-height: 1.45; }
|
||
summary { cursor: pointer; font-weight: 650; padding: .65rem 0; }
|
||
summary:focus-visible { outline: 3px solid #ffb44f; outline-offset: 3px; }
|
||
|
||
.camera-wrap { max-width: 720px; margin: auto; }
|
||
.events { grid-column: auto; }
|
||
details > summary { font-size: 1.1rem; }
|
||
@media (max-width: 999px) {
|
||
header { position: static; }
|
||
main { grid-template-columns: minmax(0,1fr); }
|
||
}
|
||
@media (max-width: 580px) {
|
||
header { display: block; padding: .8rem; }
|
||
header nav { margin-top: .8rem !important; }
|
||
main { padding: .7rem; }
|
||
section { padding: .8rem; }
|
||
button, select, input { max-width: 100%; }
|
||
}
|
||
|
||
/* Static blue highlights: no animation loop, external fonts or graphics. */
|
||
:root { --accent: #62b4ff; --line: #294466; --muted: #acbdd4; }
|
||
body { background: radial-gradient(ellipse at 15% 0%, #101f39 0, transparent 48%), #070a10; }
|
||
header { background: #090f19; border-bottom: 1px solid #28466a; box-shadow: 0 5px 26px #0006; }
|
||
h1 { letter-spacing: -.035em; font-weight: 750; display: flex; align-items: center; gap: .65rem; }
|
||
h1::before { content: ''; display: inline-block; flex: 0 0 12px; width: 12px; height: 12px; border: 2px solid #89d7ff; border-radius: 4px; background: #1475c5; box-shadow: 0 0 16px #238bff70; }
|
||
header p { font-size: .92rem; }
|
||
section { background: linear-gradient(145deg, #101c2d, #0b121e 65%); border: 1px solid #29405f; border-radius: 16px; box-shadow: 0 10px 28px #0004, inset 0 1px 0 #a7cfff0a; }
|
||
h2 { display: flex; align-items: center; gap: .55rem; letter-spacing: -.015em; font-weight: 700; }
|
||
h2::before { content: ''; width: 3px; height: 1em; border-radius: 2px; background: var(--accent); box-shadow: 0 0 10px #339eff60; }
|
||
nav { gap: .6rem !important; }
|
||
nav a.download { border-radius: 10px; background: #101b2b; border-color: #293e5a; }
|
||
nav a[aria-current="page"] { background: linear-gradient(135deg,#174b7c,#122d50); border: 1px solid #69b6ff; box-shadow: 0 0 18px #1987ff28, inset 0 1px 0 #a0d7ff22; }
|
||
button, a.download, select, input { border-radius: 9px; }
|
||
button, a.download { transition: border-color .15s ease, background-color .15s ease; }
|
||
button:not(:disabled):hover, a.download:hover { border-color: #72bdff; background: #1b385a; box-shadow: 0 0 14px #278fff24; }
|
||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible, summary:focus-visible { outline: 3px solid #84d4ff; outline-offset: 3px; }
|
||
input[type="number"], input[type="text"], select { background: #080f1b; border-color: #3b5375; color: #f1f6ff; }
|
||
input[type="checkbox"] { accent-color: #459fff; width: 1.05em; height: 1.05em; flex-shrink: 0; }
|
||
button:disabled { opacity: .55; box-shadow: none; }
|
||
#follow-hardware, #save-mode, #save-detection { background: linear-gradient(135deg,#1768b5,#164880); border-color: #69b6ff; box-shadow: 0 0 20px #1684ff24; font-weight: 650; }
|
||
#follow-hardware:not(:disabled):hover, #save-mode:hover, #save-detection:hover { background: #1c6db5; box-shadow: 0 0 22px #1684ff45; }
|
||
button.danger { background: #381c27; border-color: #bf5d72; color: #ffe8ee; box-shadow: none; }
|
||
button.danger:not(:disabled):hover { background: #5b2433; border-color: #ff8da2; box-shadow: 0 0 14px #e94a6820; }
|
||
#follow-status { background: linear-gradient(110deg,#102b49,#0b1a2e); border-color: #3677ac; color: #c5e9ff; box-shadow: 0 0 18px #1684ff12; line-height: 1.5; }
|
||
#follow-camera-status, #camera-status { color: #a9c7e9; font-size: .92rem; font-variant-numeric: tabular-nums; }
|
||
.follow-picture, .camera-wrap { border-radius: 12px; box-shadow: 0 0 0 1px #34547c, 0 0 24px #1782ff12; overflow: hidden; }
|
||
.camera, .follow-canvas { border-radius: 12px; }
|
||
.value { color: #91d4ff; font-variant-numeric: tabular-nums; }
|
||
summary { color: #d8e9ff; }
|
||
summary::marker { color: #65b7ff; }
|
||
details[open] > summary { color: #8fd0ff; }
|
||
.settings-card { border-color: #294466; }
|
||
.calibration-card, .endpoint-grid > div { background: #0a1423; border-color: #2d4c70; }
|
||
.endpoint-grid > div { box-shadow: inset 0 1px 0 #9dd1ff0a; }
|
||
.notice { background: #211c14; color: #f1dbb5; border-left-color: #ddb361; border-radius: 0 8px 8px 0; }
|
||
dl { background: #0a1423; border: 1px solid #263c59; border-radius: 10px; padding: .9rem; }
|
||
a { color: #9bd6ff; }
|
||
@media (prefers-reduced-motion: reduce) { button, a.download { transition: none; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Halloween-Grab</h1>
|
||
<p>Auslösung, Ereignisse und Profile</p>
|
||
<nav class="row" aria-label="Bereich wählen" style="margin-top:1rem">
|
||
<a class="download" href="/grab" aria-current="page">Grab</a>
|
||
<a class="download" href="/kamera" >Kamera / Finger</a>
|
||
<button class="danger" id="all-stop">Alles stoppen</button>
|
||
</nav>
|
||
<p id="all-stop-status" role="status"></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>
|
||
<details style="margin-top:1rem"><summary>Automatische Auslösung einstellen</summary>
|
||
<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>
|
||
</details>
|
||
<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>Grabablauf nur simuliert.</strong> Hand, Atemmotor, Licht und Ton bleiben simuliert. Kameraservos lassen sich auf der Seite „Kamera / Finger“ bedienen. 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">Grab stoppen</button>
|
||
<button id="reset">Grab zurücksetzen</button>
|
||
</div>
|
||
<p id="message" role="status" aria-live="polite"></p>
|
||
</section>
|
||
<section class="events">
|
||
<details><summary>Gespeicherte Ereignisse</summary>
|
||
<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>
|
||
</details>
|
||
</section>
|
||
<section class="events">
|
||
<details><summary>Personenprofile</summary>
|
||
<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>
|
||
</details>
|
||
</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/grave/stop');
|
||
$('reset').onclick = () => action('/api/grave/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; });
|
||
|
||
$('all-stop').onclick = async () => {
|
||
try {
|
||
await api('/api/stop');
|
||
$('all-stop-status').textContent = 'Grab gestoppt und Kameraservo-PWM beendet.';
|
||
} catch(e) { $('all-stop-status').textContent = 'Stopp nicht bestätigt: '+e.message; }
|
||
};
|
||
refreshDetection();
|
||
setInterval(drawDetection, 250);
|
||
refresh();
|
||
refreshDb();
|
||
setInterval(refresh, 1000);
|
||
setInterval(refreshDb, 15000);
|
||
</script>
|
||
</body>
|
||
</html>
|