Private
Public Access
kamera geht
This commit is contained in:
1 parent
bfecfd0640
commit
d370f992ce
19 files changed
+1365
-1128
No files matched your search
+123
-8
@@ -15,6 +15,9 @@
|
||||
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; }
|
||||
@@ -46,10 +49,21 @@
|
||||
<main>
|
||||
<section>
|
||||
<h2>Livebild</h2>
|
||||
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||||
<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 und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
|
||||
<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>
|
||||
@@ -57,7 +71,7 @@
|
||||
<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>Letzter Test</dt><dd id="last-event">–</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">
|
||||
@@ -69,7 +83,7 @@
|
||||
</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. Es gibt noch keine automatische Personenauslösung.</p>
|
||||
<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>
|
||||
@@ -81,8 +95,8 @@
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<section class="events">
|
||||
<h2>Gespeicherte Testereignisse</h2>
|
||||
<p class="muted">Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||
<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">
|
||||
@@ -171,13 +185,13 @@
|
||||
list.replaceChildren();
|
||||
if (!data.events.length) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = 'Noch keine Testauslösung gespeichert';
|
||||
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') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : '');
|
||||
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');
|
||||
@@ -220,6 +234,107 @@
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user