Private
Public Access
working camera movement
This commit is contained in:
1 parent
fce8f62534
commit
11e0875a20
21 files changed
+2117
-334
No files matched your search
@@ -0,0 +1,510 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kamera / Finger · Halloween</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; }
|
||||
nav a[aria-current="page"] { background:#46695b; border:2px solid #c4e0cb; font-weight:700; }
|
||||
.endpoint-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.8rem; }
|
||||
@media (max-width:520px) { .endpoint-grid { grid-template-columns:1fr; } }
|
||||
.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; }
|
||||
main { grid-template-columns: minmax(0,1fr); max-width: 1000px; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Wide work area, natural page scrolling and readable controls. */
|
||||
[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; }
|
||||
.follow-picture { width: min(100%, calc((100vh - 380px)*1.3333)); min-width: 220px; margin: auto; }
|
||||
.follow-picture img, .follow-canvas { display: block; width: 100%; height: auto; aspect-ratio: 4/3; border-radius: 8px; background: #050808; }
|
||||
.follow-canvas { cursor: crosshair; }
|
||||
#follow-status { padding: .75rem; background: #111b1a; border: 1px solid #759c8f; border-radius: 8px; color: #ffca78; font-weight: 600; }
|
||||
.start-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
|
||||
#follow-stop { grid-column: 1 / -1; }
|
||||
#follow-hardware { background: #365b49; border-color: #91bda3; }
|
||||
.settings-card { margin-top: 1rem; border-top: 1px solid #5c6c67; }
|
||||
.settings-card > label { display: inline-flex; align-items: center; gap: .4rem; margin: .6rem .5rem .6rem 0; }
|
||||
.settings-card .hint, #follow-settings-status { color: #bfcbc6; }
|
||||
.help { margin-top: .6rem; }
|
||||
.help summary { color: #c5d3cc; }
|
||||
.calibration-panel { grid-column: 1 / -1; }
|
||||
.calibration-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 1.2rem; margin-top: .8rem; }
|
||||
.calibration-card { min-width: 0; padding: 1rem; border: 1px solid #759c8f; border-radius: 8px; }
|
||||
.calibration-card label { display: inline-flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
||||
button { min-height: 44px; }
|
||||
@media (max-width: 999px) {
|
||||
header { position: static; }
|
||||
main, .calibration-grid { grid-template-columns: minmax(0,1fr); }
|
||||
.follow-picture { width: min(100%,640px); min-width: 0; }
|
||||
.control-panel { grid-row: 2; }
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
header { display: block; padding: .8rem; }
|
||||
header nav { margin-top: .8rem !important; }
|
||||
main { padding: .7rem; }
|
||||
section { padding: .8rem; }
|
||||
.start-actions { grid-template-columns: 1fr; }
|
||||
.calibration-card { padding: .65rem; }
|
||||
.calibration-panel summary span { display: block; }
|
||||
button, select { max-width: 100%; overflow-wrap: anywhere; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kamera / Finger</h1>
|
||||
<p>Farbpunkt verfolgen und Kameraservos einrichten</p>
|
||||
<nav class="row" aria-label="Bereich wählen" style="margin-top:1rem">
|
||||
<a class="download" href="/grab" >Grab</a>
|
||||
<a class="download" href="/kamera" aria-current="page">Kamera / Finger</a>
|
||||
<button class="danger" id="all-stop">Alles stoppen</button>
|
||||
</nav>
|
||||
<p id="all-stop-status" role="status"></p>
|
||||
</header>
|
||||
<main>
|
||||
<section class="live-panel" id="follow-panel">
|
||||
<h2>Kamerabild & Farbpunkt</h2>
|
||||
<div class="row">
|
||||
<button id="follow-green">Grün wählen</button><button id="follow-red">Rot wählen</button>
|
||||
<button id="follow-capture">Beispielfoto aufnehmen</button>
|
||||
</div>
|
||||
<p>Eigene Farbe: Foto aufnehmen und den Farbpunkt antippen. Das Foto bleibt ungespeichert; die gewählte Farbe bleibt bis zu deiner nächsten Farbwahl gespeichert.</p>
|
||||
<p class="muted" id="follow-camera-status" role="status"></p>
|
||||
<div class="follow-picture">
|
||||
<img id="follow-live" class="camera" src="/stream.mjpg" alt="Livebild für Kamera und Farbpunkt" >
|
||||
<canvas hidden id="follow-canvas" width="640" height="480" class="follow-canvas" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="control-panel" aria-label="Kamerasteuerung">
|
||||
<h2>Nachführung starten & stoppen</h2>
|
||||
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
|
||||
<p class="muted">Vorschau zeigt die Erkennung. Echte Nachführung bewegt die Kamera. Dafür muss die Grabautomatik ausgeschaltet sein.</p>
|
||||
<p><label><input id="follow-ack" type="checkbox"> Mittelstellung und Bewegungsraum geprüft, Kamerakabel hat Spiel; Servoversorgung kann ich sofort abziehen.</label></p>
|
||||
<div class="start-actions">
|
||||
<button id="follow-preview">Nur Farbvorschau starten</button>
|
||||
<button id="follow-hardware">Echte Kameranachführung starten</button>
|
||||
<button id="follow-stop" class="danger">Kameraservos stoppen</button>
|
||||
</div>
|
||||
<details class="settings-card">
|
||||
<summary>Farbe, Drehrichtung, Tempo und Suche einstellen</summary>
|
||||
<div class="row">
|
||||
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:.6rem">
|
||||
<label><input id="follow-tilt-reverse" type="checkbox"> TILT umkehren</label>
|
||||
</div>
|
||||
<label>Farbtoleranz <input id="follow-tolerance" type="number" min="5" max="35" value="18" style="width:6em"></label>
|
||||
<label>Nachführtempo <input id="follow-speed" type="number" min="40" max="800" step="10" value="80" style="width:6em"> µs/s</label>
|
||||
<label><input id="follow-search" type="checkbox"> Bei Punktverlust automatisch suchen (nur echte Nachführung)</label>
|
||||
<button id="follow-save">Farbe, Richtung, Tempo und Suche speichern</button>
|
||||
<p class="hint">Nachführtempo zum Testen: 400 µs/s. Bei Pendeln reduzieren; manuelle Einrichtung bleibt langsam.</p>
|
||||
<p id="follow-settings-status" role="status">Endpositionen separat über „Bewegungsgrenzen“ oder die vier Endpunktkarten speichern. Änderungen hier sind nur bei gestoppter Kamera möglich.</p>
|
||||
<details class="help"><summary>Hinweise zu Tempo und Suche</summary> <p class="notice">Nach erster Erkennung: bei Punktverlust nach 1,5 Sekunden innerhalb der gespeicherten Bewegungsgrenzen suchen. Höchstens 200 µs/s und 60 Sekunden, danach ohne Suchbewegung auf denselben Punkt warten. Wiedererkennung beendet die Suche. Kameraservos stoppen bricht jederzeit ab.</p>
|
||||
</details>
|
||||
</details>
|
||||
<details class="help"><summary>Hinweise zum sicheren Start und Stopp</summary>
|
||||
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt jede Achse auf die Mitte ihrer gespeicherten Bewegungsgrenzen; dabei kann die Halterung springen. Beim ersten Test Servohörner/Gestänge lösen, Mittelstellung prüfen und danach spannungsfrei wieder montieren. Grenzen nur schrittweise erweitern.</p>
|
||||
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Suchbewegung nur mit gespeicherter Option nach erster Punkterkennung. Bei Punktverlust bleibt die Farbe gespeichert. Ohne Suche wartet die Kamera an ihrer Position. Nach einem Suchdurchlauf oder spätestens 60 Sekunden wartet sie ebenfalls; bei Wiedererkennung folgt sie weiter. Die Servos halten dabei ihre letzte Position. Bei verlorener Bedienverbindung oder Kamerafehler werden die PWM-Signale abgeschaltet, soweit die Verbindung zum Treiber funktioniert. Die Stromversorgung bleibt eingeschaltet. Bei einem Hardwarefehler Servoversorgung abziehen.</p>
|
||||
</details>
|
||||
</section>
|
||||
<section class="calibration-panel">
|
||||
<details id="calibration-details">
|
||||
<summary>Endpunkte & manuelle Einrichtung <span class="muted">· Grenzen ändern, Richtungstasten, Mittelstellung</span></summary>
|
||||
<div class="calibration-grid">
|
||||
<div class="calibration-card">
|
||||
<h2>Bewegungsgrenzen manuell eingeben</h2>
|
||||
<p>Erst Kameraservos stoppen, Werte ändern und ausdrücklich speichern. Die Bewegungsgrenzen gelten für die Nachführung; den erlaubten Fahrweg zum Ermitteln der Endpunkte stellst du darunter ein.</p>
|
||||
<div class="row">
|
||||
<label>PAN Minimum <input id="follow-pan-min" type="number" min="500" max="2500" step="1" value="1400" style="width:7em"></label>
|
||||
<label>PAN Maximum <input id="follow-pan-max" type="number" min="500" max="2500" step="1" value="1600" style="width:7em"></label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:.6rem">
|
||||
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="500" max="2500" step="1" value="1400" style="width:7em"></label>
|
||||
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="500" max="2500" step="1" value="1600" style="width:7em"></label>
|
||||
</div>
|
||||
<h3>Einrichtbereich zum Anfahren der Endpunkte</h3>
|
||||
<p>Hier kannst du die bisherige Grenze von 1100–1900 µs erweitern. Nur schrittweise erweitern und mit den Richtungstasten prüfen. 500–2500 µs ist nur die technische Eingabegrenze, kein bestätigter Servoweg. Vor mechanischem Anschlag loslassen.</p>
|
||||
<div class="row" style="margin-top:.6rem"><label>PAN Einrichtung Minimum <input id="follow-pan-setup-min" type="number" min="500" max="2500" step="1" value="1100" style="width:7em"></label><label>PAN Einrichtung Maximum <input id="follow-pan-setup-max" type="number" min="500" max="2500" step="1" value="1900" style="width:7em"></label></div>
|
||||
<div class="row" style="margin-top:.6rem"><label>TILT Einrichtung Minimum <input id="follow-tilt-setup-min" type="number" min="500" max="2500" step="1" value="1100" style="width:7em"></label><label>TILT Einrichtung Maximum <input id="follow-tilt-setup-max" type="number" min="500" max="2500" step="1" value="1900" style="width:7em"></label></div>
|
||||
<p id="start-center" class="value" role="status"></p>
|
||||
<p id="manual-limits-saved" class="value"></p>
|
||||
<div class="row"><button id="manual-limits-save">Bewegungs- und Einrichtgrenzen speichern</button><button id="manual-limits-discard">Eingaben verwerfen</button></div>
|
||||
<p id="manual-limits-status" role="status">Werte geladen; keine ungespeicherten Änderungen.</p>
|
||||
<p class="muted">Einheit µs, keine Winkel. Nachführgrenzen müssen innerhalb des Einrichtbereichs liegen. Minimum darf nicht größer als Maximum sein. Die Startmitte ergibt sich je Achse aus den gespeicherten Bewegungsgrenzen. Die vier Endpunktkarten unten bleiben ebenfalls nutzbar.</p>
|
||||
</div>
|
||||
<div class="calibration-card">
|
||||
<h2>Endpunkte einrichten · manuell</h2>
|
||||
<p>Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.</p>
|
||||
<div class="row"><button id="setup-start">Einrichtmodus starten</button>
|
||||
<button id="setup-center" disabled>In Mittelstellung fahren</button></div>
|
||||
<p id="setup-center-status" role="status">Im Einrichtmodus: einmal klicken, langsam zur gespeicherten Mitte fahren. „Bewegung anhalten“ bricht ab.</p>
|
||||
<p id="setup-note" role="status">1. Einrichtmodus starten. 2. Richtung halten und loslassen. 3. Bei dieser Richtung die Position speichern.</p>
|
||||
<p id="setup-position" class="value" role="status">Sollposition: lädt …</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,minmax(80px,150px));gap:.7rem;max-width:480px">
|
||||
<span></span><button id="setup-up" style="font-size:1.3rem;touch-action:none;user-select:none">▲ Hoch halten</button><span></span>
|
||||
<button id="setup-left" style="font-size:1.3rem;touch-action:none;user-select:none">◀ Links halten</button>
|
||||
<button id="setup-release" class="danger">Bewegung anhalten</button>
|
||||
<button id="setup-right" style="font-size:1.3rem;touch-action:none;user-select:none">Rechts halten ▶</button>
|
||||
<span></span><button id="setup-down" style="font-size:1.3rem;touch-action:none;user-select:none">▼ Runter halten</button><span></span>
|
||||
</div>
|
||||
<p>Taste gedrückt halten: langsame Bewegung. Loslassen: Zielposition bleibt stehen. Bei fehlendem Tastensignal endet die Bewegung spätestens nach 0,35 s, sofern Pi und Treiber reagieren.</p>
|
||||
<div class="endpoint-grid">
|
||||
<div style="border:1px solid #759c8f;border-radius:8px;padding:.8rem">
|
||||
<h3 style="margin:0 0 .5rem">Links</h3>
|
||||
<p id="setup-value-left" class="value">Gespeichert: lädt …</p>
|
||||
<button id="setup-save-left">Diese Position als Links speichern</button>
|
||||
<p id="setup-hint-left" class="muted"></p>
|
||||
</div>
|
||||
<div style="border:1px solid #759c8f;border-radius:8px;padding:.8rem">
|
||||
<h3 style="margin:0 0 .5rem">Rechts</h3>
|
||||
<p id="setup-value-right" class="value">Gespeichert: lädt …</p>
|
||||
<button id="setup-save-right">Diese Position als Rechts speichern</button>
|
||||
<p id="setup-hint-right" class="muted"></p>
|
||||
</div>
|
||||
<div style="border:1px solid #759c8f;border-radius:8px;padding:.8rem">
|
||||
<h3 style="margin:0 0 .5rem">Oben</h3>
|
||||
<p id="setup-value-up" class="value">Gespeichert: lädt …</p>
|
||||
<button id="setup-save-up">Diese Position als Oben speichern</button>
|
||||
<p id="setup-hint-up" class="muted"></p>
|
||||
</div>
|
||||
<div style="border:1px solid #759c8f;border-radius:8px;padding:.8rem">
|
||||
<h3 style="margin:0 0 .5rem">Unten</h3>
|
||||
<p id="setup-value-down" class="value">Gespeichert: lädt …</p>
|
||||
<button id="setup-save-down">Diese Position als Unten speichern</button>
|
||||
<p id="setup-hint-down" class="muted"></p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gilt der oben gespeicherte Einrichtbereich, auch außerhalb der Nachführgrenzen. Mehr Weg nötig? Stoppen, den Einrichtbereich schrittweise erweitern und erneut starten. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet in der Mitte der dann gespeicherten Bewegungsgrenzen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
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;
|
||||
}
|
||||
$('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; }
|
||||
};
|
||||
// Camera marker test has its own frame/overlay, never the trigger geometry.
|
||||
let followConfig = null, followSession = null, followSample = null, followBusy = false;
|
||||
let followLatest = null, configEpoch = 0, manualLimitsDirty = false;
|
||||
let followEpoch = 0;
|
||||
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
|
||||
function fillManualLimits(c) {
|
||||
$('start-center').textContent = 'Beim nächsten Start: PAN '+Math.floor((c.pan_min+c.pan_max+1)/2)+' / TILT '+Math.floor((c.tilt_min+c.tilt_max+1)/2)+' µs (Mitte der gespeicherten Bewegungsgrenzen).';
|
||||
$('manual-limits-saved').textContent = 'Gespeichert: PAN '+c.pan_min+'–'+c.pan_max+' / TILT '+c.tilt_min+'–'+c.tilt_max+' µs · Einrichtung PAN '+c.pan_setup_min+'–'+c.pan_setup_max+' / TILT '+c.tilt_setup_min+'–'+c.tilt_setup_max+' µs';
|
||||
for (const axis of ['pan','tilt']) for (const bound of ['min','max','setup-min','setup-max'])
|
||||
$(`follow-${axis}-${bound}`).value = c[axis+'_'+bound.replace('-','_')];
|
||||
}
|
||||
async function saveManualLimits() {
|
||||
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
|
||||
const c = {...followConfig};
|
||||
for (const axis of ['pan','tilt']) {
|
||||
for (const bound of ['min','max','setup-min','setup-max'])
|
||||
c[axis+'_'+bound.replace('-','_')] = Number($(`follow-${axis}-${bound}`).value);
|
||||
if (!['min','max','setup_min','setup_max'].every(b => Number.isInteger(c[axis+'_'+b])) ||
|
||||
!(500 <= c[axis+'_setup_min'] && c[axis+'_setup_min'] <= c[axis+'_min'] &&
|
||||
c[axis+'_min'] <= c[axis+'_max'] &&
|
||||
c[axis+'_max'] <= c[axis+'_setup_max'] && c[axis+'_setup_max'] <= 2500))
|
||||
throw new Error(axis.toUpperCase()+': Einrichtbereich 500–2500 µs; Minimum darf nicht größer als Maximum sein; Bewegungsgrenzen müssen im Einrichtbereich liegen.');
|
||||
}
|
||||
const saved = await api('/api/follow/config',c);
|
||||
followConfig = saved; configEpoch++; manualLimitsDirty = false;
|
||||
fillManualLimits(saved);
|
||||
if (followLatest) renderEndpoints({...followLatest,config:saved});
|
||||
$('manual-limits-status').textContent = 'Bewegungsgrenzen gespeichert. Farbe, Drehrichtung und Tempo bleiben unverändert.';
|
||||
}
|
||||
// Manual limit form bindings.
|
||||
$('manual-limits-save').onclick = () => followAction(saveManualLimits,'manual-limits-status');
|
||||
$('manual-limits-discard').onclick = () => {
|
||||
if (!followConfig) return;
|
||||
manualLimitsDirty = false; fillManualLimits(followConfig);
|
||||
$('manual-limits-status').textContent = 'Eingaben verworfen; gespeicherte Grenzen wiederhergestellt.';
|
||||
};
|
||||
for (const axis of ['pan','tilt']) for (const bound of ['min','max','setup-min','setup-max']) {
|
||||
$(`follow-${axis}-${bound}`).oninput = () => {
|
||||
manualLimitsDirty = ['pan','tilt'].some(a => ['min','max','setup-min','setup-max'].some(b => Number($(`follow-${a}-${b}`).value) !== followConfig?.[a+'_'+b.replace('-','_')]));
|
||||
$('manual-limits-status').textContent = manualLimitsDirty
|
||||
? 'Noch nicht gespeichert. „Bewegungs- und Einrichtgrenzen speichern“ drücken oder Eingaben verwerfen.'
|
||||
: 'Eingaben entsprechen den gespeicherten Grenzen.';
|
||||
};
|
||||
}
|
||||
function fillFollow(c) {
|
||||
followConfig = c; configEpoch++;
|
||||
if (!manualLimitsDirty) fillManualLimits(c);
|
||||
for (const axis of ['pan','tilt']) {
|
||||
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
|
||||
}
|
||||
$('follow-tolerance').value = c.tolerance;
|
||||
$('follow-speed').value = c.follow_speed ?? 80;
|
||||
$('follow-search').checked = c.search_enabled ?? false;
|
||||
}
|
||||
async function showFollowImage(b64, target, epoch) {
|
||||
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
|
||||
await image.decode();
|
||||
if (epoch !== followEpoch) return;
|
||||
followCanvas.hidden = false; $('follow-live').hidden = true;
|
||||
followCtx.drawImage(image,0,0,640,480);
|
||||
followCtx.strokeStyle = '#fff'; followCtx.lineWidth = 2;
|
||||
followCtx.beginPath(); followCtx.moveTo(305,240); followCtx.lineTo(335,240);
|
||||
followCtx.moveTo(320,225); followCtx.lineTo(320,255); followCtx.stroke();
|
||||
if (target) {
|
||||
followCtx.strokeStyle = '#ffca78'; followCtx.lineWidth = 5;
|
||||
followCtx.beginPath(); followCtx.arc(target.x*640,target.y*480,Math.max(15,target.radius*640),0,Math.PI*2); followCtx.stroke();
|
||||
}
|
||||
}
|
||||
async function followAction(fn, statusId = 'follow-status') {
|
||||
if (followBusy) return;
|
||||
followBusy = true;
|
||||
try { await fn(); }
|
||||
catch (e) { $(statusId).textContent = e.message; }
|
||||
finally { followBusy = false; }
|
||||
}
|
||||
async function saveFollow() {
|
||||
const c = {...followConfig};
|
||||
for (const axis of ['pan','tilt']) {
|
||||
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
|
||||
}
|
||||
c.tolerance = Number($('follow-tolerance').value);
|
||||
c.follow_speed = Number($('follow-speed').value);
|
||||
c.search_enabled = $('follow-search').checked;
|
||||
if (!Number.isFinite(c.follow_speed) || c.follow_speed < 40 || c.follow_speed > 800) throw new Error('Nachführtempo: 40–800 µs/s.');
|
||||
fillFollow(await api('/api/follow/config',c));
|
||||
}
|
||||
for (const [id,hue] of [['follow-green',85],['follow-red',0]]) {
|
||||
$(id).onclick = () => followAction(async () => {
|
||||
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
|
||||
fillFollow(await api('/api/follow/config',{...followConfig,hue}));
|
||||
$('follow-status').textContent = 'Farbe gespeichert. Vorschau starten.';
|
||||
});
|
||||
}
|
||||
$('follow-save').onclick = () => followAction(async () => {
|
||||
await saveFollow(); $('follow-settings-status').textContent = 'Farbe, Richtung, Tempo und Suche gespeichert. Endpositionen bleiben unverändert.';
|
||||
},'follow-settings-status');
|
||||
$('follow-capture').onclick = () => followAction(async () => {
|
||||
const photo = await api('/api/follow/capture');
|
||||
followSample = photo.token; followEpoch++;
|
||||
await showFollowImage(photo.image_base64,null,followEpoch);
|
||||
$('follow-status').textContent = 'Standbild: Jetzt auf die Mitte des Farbpunkts tippen.';
|
||||
});
|
||||
followCanvas.onclick = e => followAction(async () => {
|
||||
if (!followSample) return;
|
||||
const rect = followCanvas.getBoundingClientRect();
|
||||
const config = await api('/api/follow/select',{token:followSample,
|
||||
x:(e.clientX-rect.left)/rect.width,y:(e.clientY-rect.top)/rect.height});
|
||||
fillFollow(config); $('follow-status').textContent = 'Eigene Farbe übernommen. Vorschau starten.';
|
||||
});
|
||||
async function beginFollow(hardware, operation = 'follow') {
|
||||
if (hardware && !$('follow-ack').checked) throw new Error('Bitte zuerst Mittelstellung und freien Bewegungsraum prüfen und bestätigen.');
|
||||
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
|
||||
if (manualLimitsDirty) throw new Error('Bewegungsgrenzen sind noch nicht gespeichert. Bitte dort speichern oder Eingaben verwerfen.');
|
||||
if (['pan','tilt'].some(axis => $(`follow-${axis}-reverse`).checked !== followConfig[axis+'_reverse']) ||
|
||||
Number($('follow-tolerance').value) !== followConfig.tolerance || Number($('follow-speed').value) !== (followConfig.follow_speed ?? 80) || $('follow-search').checked !== (followConfig.search_enabled ?? false))
|
||||
throw new Error('Farbe, Richtung, Tempo oder Suche wurden geändert. Bitte zuerst dort speichern.');
|
||||
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
|
||||
if (operation === 'setup') $('setup-note').textContent = 'Einrichtmodus aktiv: Richtung halten, loslassen und die passende Position speichern.';
|
||||
releaseJog(); jogSequence = 0; followSession = result.session; followSample = null; followEpoch++;
|
||||
$('follow-status').textContent = hardware ? 'Echte Servos aktiv' : 'Farbvorschau ohne Servobewegung';
|
||||
}
|
||||
$('follow-preview').onclick = () => followAction(() => beginFollow(false));
|
||||
$('follow-hardware').onclick = () => followAction(() => beginFollow(true));
|
||||
$('follow-stop').onclick = async () => {
|
||||
releaseJog(); setupReady = false; followSession = null; followSample = null; followEpoch++;
|
||||
try { await api('/api/follow/stop'); $('follow-status').textContent = 'Kameraservos gestoppt'; }
|
||||
catch(e) { $('follow-status').textContent = 'Stopp nicht bestätigt – Servoversorgung abziehen. '+e.message; }
|
||||
};
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && followSession) $('follow-stop').click();
|
||||
});
|
||||
// Hold-to-jog: separate short motion lease; sequence numbers reject late requests.
|
||||
let jogTimer = null, jogSequence = 0, setupReady = false, followDemo = false;
|
||||
async function sendJog(direction) {
|
||||
if (!followSession) return;
|
||||
return api('/api/follow/jog',{session:followSession,direction,sequence:++jogSequence});
|
||||
}
|
||||
function releaseJog() {
|
||||
if (jogTimer === null) return;
|
||||
clearInterval(jogTimer); jogTimer = null;
|
||||
sendJog(null).catch(e => { $('follow-status').textContent = 'Tastenstopp nicht bestätigt: '+e.message; });
|
||||
}
|
||||
function pressJog(direction) {
|
||||
if (!setupReady || !followSession || jogTimer !== null) return;
|
||||
const send = () => sendJog(direction).catch(e => {
|
||||
releaseJog(); $('follow-status').textContent = e.message;
|
||||
});
|
||||
jogTimer = setInterval(send,100); send();
|
||||
}
|
||||
for (const direction of ['left','right','up','down']) {
|
||||
const button = $('setup-'+direction);
|
||||
button.disabled = true;
|
||||
button.onpointerdown = e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); button.setPointerCapture(e.pointerId); pressJog(direction);
|
||||
};
|
||||
button.onpointerup = button.onpointercancel = button.onlostpointercapture = releaseJog;
|
||||
button.onkeydown = e => {
|
||||
if ([' ','Enter'].includes(e.key)) { e.preventDefault(); if (!e.repeat) pressJog(direction); }
|
||||
};
|
||||
button.onkeyup = e => { if ([' ','Enter'].includes(e.key)) { e.preventDefault(); releaseJog(); } };
|
||||
button.onblur = releaseJog;
|
||||
$('setup-save-'+direction).disabled = true;
|
||||
$('setup-save-'+direction).onclick = () => followAction(async () => {
|
||||
releaseJog();
|
||||
await sendJog(null);
|
||||
const config = await api('/api/follow/remember',{session:followSession,direction});
|
||||
fillFollow(config);
|
||||
if (followLatest) renderEndpoints({...followLatest,config});
|
||||
const names = {left:'Links',right:'Rechts',up:'Oben',down:'Unten'};
|
||||
const item = endpointState({...followLatest,config},direction);
|
||||
$('setup-note').textContent = names[direction]+' gespeichert: '+item.saved+' µs. 10 µs Abstand nach innen sind berücksichtigt.';
|
||||
},'setup-note');
|
||||
}
|
||||
async function centerSetup() {
|
||||
if (!setupReady || !followSession) throw new Error('Zuerst Einrichtmodus starten.');
|
||||
releaseJog();
|
||||
const result = await api('/api/follow/center',{session:followSession,sequence:++jogSequence});
|
||||
if (!result.accepted) throw new Error('Mittelstellungsfahrt durch neueren Bedienbefehl abgebrochen.');
|
||||
$('setup-note').textContent = 'Mittelstellungsfahrt beauftragt. „Bewegung anhalten“ bricht ab.';
|
||||
}
|
||||
async function stopSetupMotion() {
|
||||
releaseJog();
|
||||
if (!setupReady || !followSession) return;
|
||||
await sendJog(null);
|
||||
$('setup-note').textContent = 'Bewegung angehalten; Sollposition wird gehalten.';
|
||||
}
|
||||
$('setup-center').onclick = () => followAction(centerSetup,'setup-note');
|
||||
$('setup-release').onclick = () => followAction(stopSetupMotion,'setup-note');
|
||||
document.addEventListener('pointerup',releaseJog);
|
||||
document.addEventListener('pointercancel',releaseJog);
|
||||
window.addEventListener('blur',releaseJog);
|
||||
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'),'setup-note');
|
||||
|
||||
function endpointState(f, direction) {
|
||||
const axis = ['left','right'].includes(direction) ? 'pan' : 'tilt';
|
||||
let sign = ['left','up'].includes(direction) ? -1 : 1;
|
||||
if (f.config[axis+'_reverse']) sign = -sign;
|
||||
const proposed = Math.round(f[axis+'_us'])+(sign < 0 ? 10 : -10);
|
||||
const origin = f.setup_origin?.[axis] ?? Math.floor((f.config[axis+'_min']+f.config[axis+'_max']+1)/2);
|
||||
const sideOK = sign < 0 ? proposed <= origin : proposed >= origin;
|
||||
const saved = f.config[axis+(sign < 0 ? '_min' : '_max')];
|
||||
return {saved, proposed, canSave:setupReady && !f.centering && sideOK,
|
||||
hint: !setupReady ? 'Zuerst Einrichtmodus starten.' : f.centering ? 'Mittelstellungsfahrt zuerst anhalten oder abwarten.' : !sideOK
|
||||
? 'Zuerst in diese Richtung von der Startmitte dieser Sitzung weg fahren.'
|
||||
: 'Beim Speichern: '+proposed+' µs (10 µs nach innen).'};
|
||||
}
|
||||
function renderEndpoints(f) {
|
||||
for (const direction of ['left','right','up','down']) {
|
||||
const item = endpointState(f,direction);
|
||||
$('setup-value-'+direction).textContent = 'Gespeichert: '+item.saved+' µs';
|
||||
$('setup-save-'+direction).disabled = !item.canSave;
|
||||
$('setup-hint-'+direction).textContent = item.hint;
|
||||
}
|
||||
}
|
||||
async function pollFollow() {
|
||||
try {
|
||||
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
|
||||
const requestEpoch = configEpoch;
|
||||
const r = await fetch('/api/follow',{cache:'no-store'});
|
||||
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
|
||||
const f = await r.json();
|
||||
if (requestEpoch !== configEpoch) { setTimeout(pollFollow,100); return; }
|
||||
followLatest = f;
|
||||
$('follow-camera-status').textContent = f.camera.error || (f.camera.ready ? f.camera.source+' · '+(f.camera.fps ?? '–')+' Bilder/s · Nachführung '+(f.fps ?? '–')+' Auswertungen/s'+(f.analysis_ms == null ? '' : ' · Farberkennung '+f.analysis_ms+' ms') : 'Warte auf Kamerabild');
|
||||
if (!f.active && !followSample) { followCanvas.hidden = true; $('follow-live').hidden = false; }
|
||||
followDemo = f.demo;
|
||||
$('setup-start').textContent = followDemo ? 'Einrichtmodus als Demo starten' : 'Einrichtmodus starten';
|
||||
setupReady = f.active && f.operation === 'setup' && !!followSession;
|
||||
if (!setupReady) releaseJog();
|
||||
for (const direction of ['left','right','up','down']) {
|
||||
$('setup-'+direction).disabled = !setupReady;
|
||||
|
||||
}
|
||||
$('setup-center').disabled = !setupReady || f.centering;
|
||||
$('setup-center-status').textContent = f.centering
|
||||
? 'Fährt langsam zur Mitte der gespeicherten Endpunkte. „Bewegung anhalten“ bricht ab.'
|
||||
: 'Im Einrichtmodus: einmal klicken, langsam zur gespeicherten Mitte fahren. „Bewegung anhalten“ bricht ab.';
|
||||
renderEndpoints(f);
|
||||
for (const id of ['follow-pan-reverse','follow-tilt-reverse','follow-tolerance','follow-speed','follow-search','follow-save','follow-green','follow-red','follow-capture','follow-preview','follow-hardware','setup-start']) $(id).disabled = f.active;
|
||||
if (followConfig) followConfig = {...f.config};
|
||||
if (!manualLimitsDirty) fillManualLimits(f.config);
|
||||
for (const id of ['follow-pan-min','follow-pan-max','follow-tilt-min','follow-tilt-max','follow-pan-setup-min','follow-pan-setup-max','follow-tilt-setup-min','follow-tilt-setup-max','manual-limits-save','manual-limits-discard']) $(id).disabled = f.active;
|
||||
$('manual-limits-saved').textContent = 'Gespeichert: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs · Einrichtung PAN '+f.config.pan_setup_min+'–'+f.config.pan_setup_max+' / TILT '+f.config.tilt_setup_min+'–'+f.config.tilt_setup_max+' µs';
|
||||
$('start-center').textContent = 'Beim nächsten Start: PAN '+Math.floor((f.config.pan_min+f.config.pan_max+1)/2)+' / TILT '+Math.floor((f.config.tilt_min+f.config.tilt_max+1)/2)+' µs (Mitte der gespeicherten Bewegungsgrenzen).';
|
||||
$('setup-position').textContent = 'Sollposition: PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||
if (!followConfig) {
|
||||
fillFollow(f.config);
|
||||
$('follow-status').textContent = 'Bereit. Farbe wählen oder Beispielfoto aufnehmen.';
|
||||
}
|
||||
if (!f.active) followSession = null;
|
||||
if (f.active || f.error) $('follow-status').textContent = f.error ||
|
||||
(f.operation === 'setup' ? (f.hardware ? 'Einrichtmodus · echte Servos' : 'Einrichtmodus · nur Demo')
|
||||
: (f.hardware ? 'Echte Servos aktiv' : 'Vorschau ohne Bewegung'))+' · '+
|
||||
(f.operation === 'setup' ? (f.centering ? 'Mittelstellung wird angefahren' : 'Richtungstaste gedrückt halten') : (f.reacquiring ? 'Punkt wieder sichtbar · bestätige Erkennung' : f.waiting_for_target ? 'Warte auf gespeicherten Farbpunkt · keine Suchbewegung' : f.searching ? 'Suche nach Farbpunkt · '+f.search_seconds+' / 60 s' : f.target ? 'Punkt erkannt' : 'Farbpunkt gerade nicht sichtbar · Auswahl bleibt gespeichert'))+' · PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||
if (f.active && f.image_base64 && !followSample) await showFollowImage(f.image_base64,f.target,followEpoch);
|
||||
} catch(e) {
|
||||
if (followSession) {
|
||||
releaseJog(); setupReady = false; followSession = null;
|
||||
$('follow-status').textContent = 'Verbindung verloren; Nachführung wird beendet. '+e.message;
|
||||
}
|
||||
}
|
||||
setTimeout(pollFollow,100);
|
||||
}
|
||||
pollFollow();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-2
@@ -1,5 +1,6 @@
|
||||
"""Ein Kameraproduzent für mehrere Livebild-Betrachter."""
|
||||
|
||||
from collections import deque
|
||||
from io import BytesIO
|
||||
import threading
|
||||
import time
|
||||
@@ -9,6 +10,7 @@ from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class CameraStream:
|
||||
TARGET_FPS = 15
|
||||
def __init__(self, demo: bool = False):
|
||||
self.demo = demo
|
||||
self._condition = threading.Condition()
|
||||
@@ -16,6 +18,7 @@ class CameraStream:
|
||||
self._stop = threading.Event()
|
||||
self._frame = None
|
||||
self._number = 0
|
||||
self._frame_times = deque(maxlen=45)
|
||||
self._time = None
|
||||
self.error = None
|
||||
|
||||
@@ -43,6 +46,7 @@ class CameraStream:
|
||||
self._frame = frame
|
||||
self._number += 1
|
||||
self._time = time.monotonic()
|
||||
self._frame_times.append(self._time)
|
||||
self._condition.notify_all()
|
||||
|
||||
def _run(self):
|
||||
@@ -75,15 +79,17 @@ class CameraStream:
|
||||
try:
|
||||
# BGR888 liefert laut Picamera2-Dokumentation RGB-Arrays für PIL.
|
||||
config = camera.create_video_configuration(
|
||||
main={"size": (640, 480), "format": "BGR888"}
|
||||
main={"size": (640, 480), "format": "BGR888"},
|
||||
controls={"FrameDurationLimits": (66667, 66667)}, queue=False
|
||||
)
|
||||
camera.configure(config)
|
||||
camera.start()
|
||||
started = True
|
||||
while not self._stop.is_set():
|
||||
began = time.monotonic()
|
||||
pixels = camera.capture_array("main")
|
||||
self._publish(self._jpeg(Image.fromarray(pixels, "RGB")))
|
||||
self._stop.wait(0.2)
|
||||
self._stop.wait(max(0., 1/self.TARGET_FPS-(time.monotonic()-began)))
|
||||
finally:
|
||||
try:
|
||||
if started:
|
||||
@@ -114,7 +120,12 @@ class CameraStream:
|
||||
def status(self):
|
||||
with self._condition:
|
||||
age = None if self._time is None else round(time.monotonic() - self._time, 1)
|
||||
times = self._frame_times
|
||||
fps = ((len(times)-1)/(times[-1]-times[0])
|
||||
if len(times)>1 and times[-1]>times[0] and age is not None and age<1 else 0.)
|
||||
return {
|
||||
"fps": round(fps, 1),
|
||||
"target_fps": 5 if self.demo else self.TARGET_FPS,
|
||||
"source": "Demo" if self.demo else "Pi-Kamera",
|
||||
"ready": self._frame is not None and age is not None and age < 3,
|
||||
"frame_age_seconds": age,
|
||||
|
||||
+186
-29
@@ -6,18 +6,32 @@ import base64
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
DEFAULT = dict(hue=85, tolerance=18, pan_min=1400, pan_max=1600,
|
||||
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False)
|
||||
FOLLOW_GAIN = 600 # µs/s per unit of image offset (= old behaviour at 300 µs/s)
|
||||
DEFAULT = dict(hue=85, tolerance=18, follow_speed=400, search_enabled=False, pan_min=1400, pan_max=1600,
|
||||
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False,
|
||||
pan_setup_min=1100, pan_setup_max=1900,
|
||||
tilt_setup_min=1100, tilt_setup_max=1900)
|
||||
|
||||
|
||||
def validate(value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('Ungültige Nachführungseinstellungen')
|
||||
# Preserve the previous speed when loading existing configurations.
|
||||
value = dict(value)
|
||||
value.setdefault('follow_speed', 80)
|
||||
value.setdefault('search_enabled', False)
|
||||
# Old saved configurations retain their tested setup envelope.
|
||||
setup_keys = {a+"_setup_"+b for a in ("pan", "tilt") for b in ("min", "max")}
|
||||
if isinstance(value, dict) and set(value) == set(DEFAULT)-setup_keys:
|
||||
value = {**{k: DEFAULT[k] for k in setup_keys}, **value}
|
||||
if set(value) != set(DEFAULT):
|
||||
raise ValueError('Unvollständige Nachführungseinstellungen')
|
||||
result = {}
|
||||
@@ -33,21 +47,48 @@ def validate(value):
|
||||
result[key] = v
|
||||
if not 0 <= result['hue'] <= 255 or not 5 <= result['tolerance'] <= 35:
|
||||
raise ValueError('Ungültige Farbe oder Farbtoleranz')
|
||||
if not 40 <= result['follow_speed'] <= 800:
|
||||
raise ValueError('Nachführtempo muss zwischen 40 und 800 µs/s liegen')
|
||||
for axis in ('pan', 'tilt'):
|
||||
if not 1100 <= result[axis+'_min'] <= 1500 <= result[axis+'_max'] <= 1900:
|
||||
raise ValueError('Grenzen müssen zwischen 1100 und 1900 µs liegen und 1500 einschließen')
|
||||
if not 500 <= result[axis+'_setup_min'] <= result[axis+'_min'] <= result[axis+'_max'] <= result[axis+'_setup_max'] <= 2500:
|
||||
raise ValueError('Einrichtbereich: 500–2500 µs; Minimum muss kleiner oder gleich Maximum sein; Bewegungsgrenzen müssen im Einrichtbereich liegen')
|
||||
return result
|
||||
|
||||
|
||||
def midpoints(config):
|
||||
"""Stored follow endpoints determine each start, rounded to whole µs."""
|
||||
return {axis: (config[axis+'_min'] + config[axis+'_max'] + 1)//2
|
||||
for axis in ('pan', 'tilt')}
|
||||
|
||||
|
||||
def locate(jpeg, config):
|
||||
"""Largest unambiguous saturated blob, circular hue distance (also red)."""
|
||||
image = Image.open(BytesIO(jpeg)).convert('RGB').resize((160, 120))
|
||||
pixels = list(image.convert('HSV').getdata())
|
||||
mask = set()
|
||||
for i, (h, s, v) in enumerate(pixels):
|
||||
delta = abs(h-config['hue'])
|
||||
if min(delta, 256-delta) <= config['tolerance'] and s >= 100 and v >= 60:
|
||||
mask.add(i)
|
||||
"""Largest unambiguous saturated blob, circular hue distance (also red).
|
||||
|
||||
Strict hue/saturation masks run first so a strongly coloured marker wins
|
||||
over similar but paler surroundings (e.g. bluish paper); weaker masks only
|
||||
apply when the strict ones find no unambiguous blob.
|
||||
"""
|
||||
image = Image.open(BytesIO(jpeg))
|
||||
image.draft('RGB', (160, 120)) # JPEG decodes directly at reduced size
|
||||
image = image.convert('RGB')
|
||||
if image.size != (160, 120):
|
||||
image = image.resize((160, 120))
|
||||
planes = image.convert('HSV').split()
|
||||
tolerance = config['tolerance']
|
||||
for factor, min_saturation in ((.5, 140), (.75, 120), (1, 100)):
|
||||
result = _find_blob(planes, config['hue'], max(5, tolerance*factor), min_saturation)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _find_blob(planes, centre, tolerance, min_saturation):
|
||||
hue, saturation, value = planes
|
||||
hue_table = [255 if min(abs(h-centre), 256-abs(h-centre)) <= tolerance else 0 for h in range(256)]
|
||||
mask_image = ImageChops.darker(
|
||||
ImageChops.darker(hue.point(hue_table), saturation.point([255 if i >= min_saturation else 0 for i in range(256)])),
|
||||
value.point([255 if i >= 60 else 0 for i in range(256)]))
|
||||
mask = {m.start() for m in re.finditer(b'\xff', mask_image.tobytes())}
|
||||
blobs = []
|
||||
while mask:
|
||||
seed = mask.pop()
|
||||
@@ -97,7 +138,7 @@ class PCA9685:
|
||||
|
||||
def move(self, pan, tilt):
|
||||
for channel, pulse in enumerate((pan, tilt)):
|
||||
if not 1100 <= pulse <= 1900:
|
||||
if not math.isfinite(pulse) or not 500 <= pulse <= 2500:
|
||||
raise ValueError('Puls außerhalb der absoluten Testgrenze')
|
||||
count = round(pulse * 25 / 122) # microseconds -> oscillator ticks / prescale
|
||||
self.bus.write_i2c_block_data(0x40, 0x06+4*channel,
|
||||
@@ -140,10 +181,15 @@ class FollowService:
|
||||
self.jog_direction = None
|
||||
self.jog_until = 0.
|
||||
self.jog_sequence = -1
|
||||
self.center_target = None
|
||||
self.driver = None
|
||||
self.pan = self.tilt = 1500.
|
||||
self.setup_origin = midpoints(self.config)
|
||||
self.pan, self.tilt = self.setup_origin['pan'], self.setup_origin['tilt']
|
||||
self.last_ping = self.clock()
|
||||
self.last_result = self.last_seen = None
|
||||
self.result_times = deque(maxlen=45)
|
||||
self.analysis_ms = None
|
||||
self._reset_search()
|
||||
self.number = 0
|
||||
self.target = None
|
||||
self.jpeg = None
|
||||
@@ -154,6 +200,52 @@ class FollowService:
|
||||
self.thread = None
|
||||
self.quit = threading.Event()
|
||||
|
||||
def _reset_search(self):
|
||||
self.search_started = None
|
||||
self.search_path = deque()
|
||||
self.last_target = None
|
||||
self.reacquire_count = 0
|
||||
self.waiting_for_target = False
|
||||
|
||||
def _wait_for_target(self):
|
||||
# Keep the selected colour and the armed session, but issue no motion.
|
||||
# Existing pulses hold position; halt/watchdogs still release the driver.
|
||||
self.search_started = None
|
||||
self.search_path.clear()
|
||||
self.reacquire_count = 0
|
||||
self.waiting_for_target = True
|
||||
|
||||
def _search_step(self, now, dt):
|
||||
"""Finite sweep of stored follow limits; never use the setup envelope."""
|
||||
if self.search_started is None:
|
||||
self.search_started = now
|
||||
# First look towards the last image position, respecting reversal.
|
||||
first = {}
|
||||
other = {}
|
||||
for axis, coordinate in (('pan', 'x'), ('tilt', 'y')):
|
||||
sign = 1 if self.last_target[coordinate] >= .5 else -1
|
||||
if self.config[axis+'_reverse']: sign *= -1
|
||||
first[axis] = self.config[axis+('_max' if sign > 0 else '_min')]
|
||||
other[axis] = self.config[axis+('_min' if sign > 0 else '_max')]
|
||||
self.search_path = deque([
|
||||
(first['pan'], self.tilt), (other['pan'], self.tilt),
|
||||
(other['pan'], first['tilt']), (first['pan'], first['tilt']),
|
||||
(first['pan'], other['tilt']), (other['pan'], other['tilt'])])
|
||||
if now-self.search_started >= 60:
|
||||
self._wait_for_target()
|
||||
return
|
||||
while self.search_path and (self.pan, self.tilt) == self.search_path[0]:
|
||||
self.search_path.popleft()
|
||||
if not self.search_path:
|
||||
self._wait_for_target()
|
||||
return
|
||||
step = min(200, self.config['follow_speed'])*dt
|
||||
for axis, goal in zip(('pan', 'tilt'), self.search_path[0]):
|
||||
position = getattr(self, axis)
|
||||
value = position+max(-step, min(step, goal-position))
|
||||
setattr(self, axis, max(self.config[axis+'_min'], min(self.config[axis+'_max'], value)))
|
||||
self.driver.move(self.pan, self.tilt)
|
||||
|
||||
def _save(self, value):
|
||||
config = validate(value)
|
||||
with self.lock:
|
||||
@@ -197,21 +289,33 @@ class FollowService:
|
||||
if sequence <= self.jog_sequence:
|
||||
return {'accepted': False}
|
||||
self.jog_sequence = sequence
|
||||
self.center_target = None
|
||||
self.jog_direction = direction
|
||||
self.jog_until = self.clock()+.35 if direction else 0.
|
||||
return {'accepted': True}
|
||||
|
||||
def center(self, session, sequence):
|
||||
"""Explicit slow recentering within an already armed setup session."""
|
||||
with self.lock:
|
||||
accepted = self.jog(session, None, sequence)
|
||||
if accepted['accepted']:
|
||||
self.center_target = midpoints(self.config)
|
||||
return accepted
|
||||
|
||||
def remember(self, session, direction):
|
||||
with self.lock:
|
||||
self._require_setup(session)
|
||||
if self.center_target is not None:
|
||||
raise ValueError('Mittelstellungsfahrt zuerst anhalten oder abwarten')
|
||||
if self.jog_direction and self.clock() < self.jog_until:
|
||||
raise ValueError('Richtungstaste vor dem Speichern loslassen')
|
||||
axis, sign = self._direction(direction)
|
||||
# Store a slightly inward commanded limit, never an inferred physical stop.
|
||||
value = round(getattr(self, axis)) + (10 if sign < 0 else -10)
|
||||
key = axis+('_min' if sign < 0 else '_max')
|
||||
if sign < 0 and value > 1500 or sign > 0 and value < 1500:
|
||||
raise ValueError('Position liegt zu nahe an oder auf der anderen Seite der Mitte')
|
||||
origin = self.setup_origin[axis]
|
||||
if sign < 0 and value > origin or sign > 0 and value < origin:
|
||||
raise ValueError('Für diese Grenze zuerst in die zugehörige Richtung von der Startmitte dieser Sitzung weg fahren; mindestens 10 µs Abstand zur Mitte nötig')
|
||||
return self._save(dict(self.config, **{key:value}))
|
||||
|
||||
def capture(self):
|
||||
@@ -258,7 +362,8 @@ class FollowService:
|
||||
raise ValueError('Kein frisches Kamerabild')
|
||||
if hardware and (self.camera.demo or not acknowledged):
|
||||
raise ValueError('Echte Kamera und bestätigter freier Servotest erforderlich')
|
||||
self.pan = self.tilt = 1500.
|
||||
self.setup_origin = midpoints(self.config)
|
||||
self.pan, self.tilt = self.setup_origin['pan'], self.setup_origin['tilt']
|
||||
try:
|
||||
if hardware:
|
||||
self.driver = self.factory()
|
||||
@@ -269,9 +374,13 @@ class FollowService:
|
||||
self.active = True
|
||||
self.operation = operation
|
||||
self.jog_direction, self.jog_until, self.jog_sequence = None, 0., -1
|
||||
self.center_target = None
|
||||
self.session = secrets.token_hex(16)
|
||||
self.last_ping = self.clock()
|
||||
self.last_result = self.last_seen = self.clock()
|
||||
self._reset_search()
|
||||
self.result_times.clear()
|
||||
self.analysis_ms = None
|
||||
self.jpeg = None
|
||||
self.target = None
|
||||
self.revision += 1
|
||||
@@ -286,7 +395,9 @@ class FollowService:
|
||||
def halt(self, error=None):
|
||||
with self.lock:
|
||||
self.active = False
|
||||
self._reset_search()
|
||||
self.session = None
|
||||
self.center_target = None
|
||||
self.jog_direction = None
|
||||
self.jog_until = 0.
|
||||
self.revision += 1
|
||||
@@ -311,45 +422,89 @@ class FollowService:
|
||||
elif self.last_result is not None and now-self.last_result > 1:
|
||||
self.halt('Bildauswertung ausgefallen; erneut starten')
|
||||
|
||||
def process(self, number, jpeg, captured, revision, target):
|
||||
def process(self, number, jpeg, captured, revision, target, analysis_ms=None):
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
if not self.active or revision != self.revision or captured is None or now-captured > 1:
|
||||
return
|
||||
self.watchdog()
|
||||
if not self.active: return
|
||||
dt = min(.25, max(0, now-(self.last_result or now)))
|
||||
# Do not catch up a delayed frame with a large movement jump.
|
||||
previous = self.last_result if self.last_result is not None else now
|
||||
max_dt = .10 if self.operation == 'follow' else .25
|
||||
dt = min(max_dt, max(0, now-previous))
|
||||
self.result_times.append(now)
|
||||
self.analysis_ms = analysis_ms
|
||||
self.last_result, self.number, self.jpeg, self.target = now, number, jpeg, target
|
||||
if self.operation == 'setup':
|
||||
self.target = None
|
||||
if self.center_target is not None:
|
||||
for axis, goal in self.center_target.items():
|
||||
position = getattr(self, axis)
|
||||
setattr(self, axis, position + max(-40*dt, min(40*dt, goal-position)))
|
||||
if self.driver: self.driver.move(self.pan, self.tilt)
|
||||
if all(getattr(self, a) == goal for a, goal in self.center_target.items()):
|
||||
self.center_target = None
|
||||
return
|
||||
if self.jog_direction and now < self.jog_until:
|
||||
axis, sign = self._direction(self.jog_direction)
|
||||
value = max(1100, min(1900, getattr(self,axis)+sign*40*dt))
|
||||
value = max(self.config[axis+'_setup_min'], min(self.config[axis+'_setup_max'], getattr(self,axis)+sign*40*dt))
|
||||
setattr(self,axis,value)
|
||||
if self.driver: self.driver.move(self.pan,self.tilt)
|
||||
else:
|
||||
self.jog_direction = None
|
||||
return
|
||||
if self.search_started is not None and now-self.search_started >= 60:
|
||||
self._wait_for_target()
|
||||
return
|
||||
if target:
|
||||
if self.search_started is not None or self.waiting_for_target:
|
||||
# Stop searching immediately; require two fresh detections
|
||||
# before returning control to ordinary tracking.
|
||||
self.reacquire_count += 1
|
||||
if self.reacquire_count < 2: return
|
||||
self.search_started = None
|
||||
self.search_path.clear()
|
||||
self.waiting_for_target = False
|
||||
self.reacquire_count = 0
|
||||
self.last_target = dict(target)
|
||||
self.last_seen = now
|
||||
for axis, coordinate in (('pan','x'), ('tilt','y')):
|
||||
error = target[coordinate]-.5
|
||||
if abs(error) < .06: continue
|
||||
sign = -1 if self.config[axis+'_reverse'] else 1
|
||||
value = getattr(self, axis) + sign*error*160*dt
|
||||
# The tempo is a speed limit; the loop gain stays capped because
|
||||
# camera/servo delay makes a higher gain swing back and forth.
|
||||
speed = self.config['follow_speed']
|
||||
value = getattr(self, axis) + sign*max(-speed, min(speed, error*FOLLOW_GAIN))*dt
|
||||
setattr(self, axis, max(self.config[axis+'_min'], min(self.config[axis+'_max'], value)))
|
||||
if self.driver: self.driver.move(self.pan, self.tilt)
|
||||
# No searching or recentring when the marker disappears. End the
|
||||
# hardware session after one second so old pulses do not hold forever.
|
||||
elif self.driver and self.last_seen is not None and now-self.last_seen > 1:
|
||||
self.halt('Farbpunkt verloren; erneut starten')
|
||||
elif self.driver and self.last_seen is not None:
|
||||
self.reacquire_count = 0
|
||||
if self.waiting_for_target:
|
||||
return
|
||||
if self.config['search_enabled'] and self.last_target is not None:
|
||||
if now-self.last_seen >= 1.5:
|
||||
self._search_step(now, dt)
|
||||
else:
|
||||
self._wait_for_target()
|
||||
|
||||
def status(self):
|
||||
with self.lock:
|
||||
return {'active': self.active, 'hardware': self.driver is not None,
|
||||
times = self.result_times
|
||||
fps = ((len(times)-1)/(times[-1]-times[0])
|
||||
if self.active and len(times)>1 and times[-1]>times[0] and self.clock()-times[-1]<1 else 0.)
|
||||
return {'fps': round(fps, 1), 'analysis_ms': self.analysis_ms,
|
||||
'searching': self.search_started is not None,
|
||||
'reacquiring': (self.search_started is not None or self.waiting_for_target) and self.reacquire_count > 0,
|
||||
'waiting_for_target': self.waiting_for_target,
|
||||
'search_seconds': round(self.clock()-self.search_started, 1) if self.search_started is not None else 0,
|
||||
'active': self.active, 'hardware': self.driver is not None,
|
||||
'operation': self.operation, 'demo': self.camera.demo,
|
||||
'error': self.error, 'config': dict(self.config),
|
||||
'pan_us': round(self.pan), 'tilt_us': round(self.tilt),
|
||||
'setup_origin': dict(self.setup_origin),
|
||||
'centering': self.center_target is not None,
|
||||
'target': self.target, 'frame_number': self.number,
|
||||
'image_base64': base64.b64encode(self.jpeg).decode() if self.active and self.jpeg else None}
|
||||
|
||||
@@ -359,7 +514,7 @@ class FollowService:
|
||||
|
||||
def _run(self):
|
||||
last = 0
|
||||
while not self.quit.wait(.05):
|
||||
while not self.quit.wait(.01):
|
||||
self.watchdog()
|
||||
with self.lock:
|
||||
if not self.active: continue
|
||||
@@ -368,8 +523,10 @@ class FollowService:
|
||||
if number == last or jpeg is None: continue
|
||||
last = number
|
||||
try:
|
||||
began = time.monotonic()
|
||||
target = locate(jpeg, config) if operation == 'follow' else None
|
||||
self.process(number, jpeg, captured, revision, target)
|
||||
analysis_ms = round((time.monotonic()-began)*1000, 1)
|
||||
self.process(number, jpeg, captured, revision, target, analysis_ms)
|
||||
except Exception as exc:
|
||||
with self.lock:
|
||||
if revision == self.revision: self.halt(f'Nachführungsfehler: {exc}')
|
||||
|
||||
+50
-228
@@ -27,6 +27,7 @@
|
||||
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:#46695b; border:2px solid #c4e0cb; font-weight:700; }
|
||||
.value { font-weight: 650; color: #ffca78; }
|
||||
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
|
||||
.muted { color: #bfcbc6; }
|
||||
@@ -40,12 +41,44 @@
|
||||
.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%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Halloween-Grab</h1>
|
||||
<p>Lokale Kameraansicht und Steuerung</p>
|
||||
<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>
|
||||
@@ -54,7 +87,7 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -67,6 +100,7 @@
|
||||
<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>
|
||||
@@ -91,89 +125,34 @@
|
||||
</select>
|
||||
<button id="save-mode">Übernehmen</button>
|
||||
</div>
|
||||
<p class="notice"><strong>Grabablauf nur simuliert.</strong> Hand, Atemmotor, Licht und Ton bleiben simuliert. Kameraservos können im separaten Farbtest wirklich bewegt werden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
|
||||
<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">Stopp</button>
|
||||
<button id="reset">Nach Stopp zurücksetzen</button>
|
||||
<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" id="follow-panel">
|
||||
<h2>Kamera folgt einem Farbpunkt · Testmodus</h2>
|
||||
<p>Halte einen kräftig roten oder grünen Aufkleber vor die Kamera. Ein Punkt am Finger geht auch. Kein Training und kein Internet nötig. Zuerst die automatische Auslösung ausschalten und speichern.</p>
|
||||
<div class="row">
|
||||
<button id="follow-green">Grün wählen</button><button id="follow-red">Rot wählen</button>
|
||||
<button id="follow-capture">Beispielfoto aufnehmen</button>
|
||||
</div>
|
||||
<p>Eigene Farbe: Beispielfoto aufnehmen und dort auf die Mitte des Farbpunkts tippen. Das Foto wird nicht gespeichert.</p>
|
||||
<canvas id="follow-canvas" width="640" height="480" style="width:100%;max-width:720px;background:#050808;cursor:crosshair" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
|
||||
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
|
||||
<details>
|
||||
<summary>Servogrenzen und Richtung einstellen</summary>
|
||||
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt beide Servos auf 1500 µs; dabei kann die Halterung springen. Beim ersten Test Servohörner/Gestänge lösen, Mittelstellung prüfen und danach spannungsfrei wieder montieren. Grenzen nur schrittweise erweitern.</p>
|
||||
<div class="row">
|
||||
<label>PAN Minimum <input id="follow-pan-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
|
||||
<label>PAN Maximum <input id="follow-pan-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
|
||||
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
|
||||
</div>
|
||||
<div class="row" style="margin-top:.6rem">
|
||||
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
|
||||
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
|
||||
<label><input id="follow-tilt-reverse" type="checkbox"> TILT umkehren</label>
|
||||
</div>
|
||||
<label>Farbtoleranz <input id="follow-tolerance" type="number" min="5" max="35" value="18" style="width:6em"></label>
|
||||
<button id="follow-save">Einstellungen speichern</button>
|
||||
</details>
|
||||
<p><label><input id="follow-ack" type="checkbox"> Mittelstellung und Bewegungsraum geprüft, Kamerakabel hat Spiel; Servoversorgung kann ich sofort abziehen.</label></p>
|
||||
<div class="row">
|
||||
<button id="follow-preview">Nur Farbvorschau starten</button>
|
||||
<button id="follow-hardware">Echte Kameranachführung starten</button>
|
||||
<button id="follow-stop" class="danger">Kameraservos stoppen</button>
|
||||
</div>
|
||||
<div style="border:2px solid #759c8f;border-radius:8px;padding:1rem;margin-top:1rem">
|
||||
<h2>Endpunkte einrichten · manuell</h2>
|
||||
<p>Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.</p>
|
||||
<button id="setup-start">Einrichtmodus starten</button>
|
||||
<p id="setup-note" role="status">Noch keine Grenze in dieser Sitzung gespeichert.</p>
|
||||
<p id="setup-limits" class="muted"></p>
|
||||
<p id="setup-position" class="value" role="status">Sollposition: PAN 1500 / TILT 1500 µs</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,minmax(80px,150px));gap:.7rem;max-width:480px">
|
||||
<span></span><button id="setup-up" style="font-size:1.3rem;touch-action:none;user-select:none">▲ Hoch halten</button><span></span>
|
||||
<button id="setup-left" style="font-size:1.3rem;touch-action:none;user-select:none">◀ Links halten</button>
|
||||
<button id="setup-release" class="danger">Bewegung anhalten</button>
|
||||
<button id="setup-right" style="font-size:1.3rem;touch-action:none;user-select:none">Rechts halten ▶</button>
|
||||
<span></span><button id="setup-down" style="font-size:1.3rem;touch-action:none;user-select:none">▼ Runter halten</button><span></span>
|
||||
</div>
|
||||
<p>Taste gedrückt halten: langsame Bewegung. Loslassen: Zielposition bleibt stehen. Bei fehlendem Tastensignal endet die Bewegung spätestens nach 0,35 s, sofern Pi und Treiber reagieren.</p>
|
||||
<div class="row">
|
||||
<button id="setup-save-left">Links-Grenze speichern</button>
|
||||
<button id="setup-save-right">Rechts-Grenze speichern</button>
|
||||
<button id="setup-save-up">Oben-Grenze speichern</button>
|
||||
<button id="setup-save-down">Unten-Grenze speichern</button>
|
||||
</div>
|
||||
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gelten 1100–1900 µs, auch außerhalb der bisherigen Farbtest-Grenzen. Falls das nicht reicht: stoppen, nichts erzwingen. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet wieder bei 1500 µs.</p>
|
||||
</div>
|
||||
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Bei Punktverlust keine Suchbewegung; nach einer Sekunde wird die Servositzung beendet. Bei verlorener Bedienverbindung oder Kamerafehler werden die PWM-Signale abgeschaltet, soweit die Verbindung zum Treiber funktioniert. Die Stromversorgung bleibt eingeschaltet. Bei einem Hardwarefehler Servoversorgung abziehen.</p>
|
||||
</section>
|
||||
<section class="events">
|
||||
<h2>Gespeicherte Ereignisse</h2>
|
||||
<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">
|
||||
<h2>Personenprofile</h2>
|
||||
<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>
|
||||
@@ -330,8 +309,8 @@
|
||||
$('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');
|
||||
$('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 });
|
||||
@@ -460,170 +439,13 @@
|
||||
detectionConfig = c; $('detection-enabled').checked = c.enabled;
|
||||
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
|
||||
}).catch(error => { $('detection-status').textContent = error.message; });
|
||||
// Camera marker test has its own frame/overlay, never the trigger geometry.
|
||||
let followConfig = null, followSession = null, followSample = null, followBusy = false;
|
||||
let followEpoch = 0;
|
||||
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
|
||||
function fillFollow(c) {
|
||||
followConfig = c;
|
||||
for (const axis of ['pan','tilt']) {
|
||||
$( `follow-${axis}-min`).value = c[axis+'_min'];
|
||||
$( `follow-${axis}-max`).value = c[axis+'_max'];
|
||||
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
|
||||
}
|
||||
$('follow-tolerance').value = c.tolerance;
|
||||
}
|
||||
async function showFollowImage(b64, target, epoch) {
|
||||
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
|
||||
await image.decode();
|
||||
if (epoch !== followEpoch) return;
|
||||
followCtx.drawImage(image,0,0,640,480);
|
||||
followCtx.strokeStyle = '#fff'; followCtx.lineWidth = 2;
|
||||
followCtx.beginPath(); followCtx.moveTo(305,240); followCtx.lineTo(335,240);
|
||||
followCtx.moveTo(320,225); followCtx.lineTo(320,255); followCtx.stroke();
|
||||
if (target) {
|
||||
followCtx.strokeStyle = '#ffca78'; followCtx.lineWidth = 5;
|
||||
followCtx.beginPath(); followCtx.arc(target.x*640,target.y*480,Math.max(15,target.radius*640),0,Math.PI*2); followCtx.stroke();
|
||||
}
|
||||
}
|
||||
async function followAction(fn) {
|
||||
if (followBusy) return;
|
||||
followBusy = true;
|
||||
try { await fn(); }
|
||||
catch (e) { $('follow-status').textContent = e.message; }
|
||||
finally { followBusy = false; }
|
||||
}
|
||||
async function saveFollow() {
|
||||
const c = {...followConfig};
|
||||
for (const axis of ['pan','tilt']) {
|
||||
c[axis+'_min'] = Number($(`follow-${axis}-min`).value);
|
||||
c[axis+'_max'] = Number($(`follow-${axis}-max`).value);
|
||||
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
|
||||
}
|
||||
c.tolerance = Number($('follow-tolerance').value);
|
||||
fillFollow(await api('/api/follow/config',c));
|
||||
}
|
||||
for (const [id,hue] of [['follow-green',85],['follow-red',0]]) {
|
||||
$(id).onclick = () => followAction(async () => {
|
||||
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
|
||||
fillFollow(await api('/api/follow/config',{...followConfig,hue}));
|
||||
$('follow-status').textContent = 'Farbe gespeichert. Vorschau starten.';
|
||||
});
|
||||
}
|
||||
$('follow-save').onclick = () => followAction(async () => {
|
||||
await saveFollow(); $('follow-status').textContent = 'Grenzen und Richtung gespeichert';
|
||||
});
|
||||
$('follow-capture').onclick = () => followAction(async () => {
|
||||
const photo = await api('/api/follow/capture');
|
||||
followSample = photo.token; followEpoch++;
|
||||
await showFollowImage(photo.image_base64,null,followEpoch);
|
||||
$('follow-status').textContent = 'Standbild: Jetzt auf die Mitte des Farbpunkts tippen.';
|
||||
});
|
||||
followCanvas.onclick = e => followAction(async () => {
|
||||
if (!followSample) return;
|
||||
const rect = followCanvas.getBoundingClientRect();
|
||||
const config = await api('/api/follow/select',{token:followSample,
|
||||
x:(e.clientX-rect.left)/rect.width,y:(e.clientY-rect.top)/rect.height});
|
||||
fillFollow(config); $('follow-status').textContent = 'Eigene Farbe übernommen. Vorschau starten.';
|
||||
});
|
||||
async function beginFollow(hardware, operation = 'follow') {
|
||||
if (hardware && !$('follow-ack').checked) throw new Error('Bitte zuerst Mittelstellung und freien Bewegungsraum prüfen und bestätigen.');
|
||||
await saveFollow();
|
||||
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
|
||||
releaseJog(); jogSequence = 0; followSession = result.session; followSample = null; followEpoch++;
|
||||
$('follow-status').textContent = hardware ? 'Echte Servos aktiv' : 'Farbvorschau ohne Servobewegung';
|
||||
}
|
||||
$('follow-preview').onclick = () => followAction(() => beginFollow(false));
|
||||
$('follow-hardware').onclick = () => followAction(() => beginFollow(true));
|
||||
$('follow-stop').onclick = async () => {
|
||||
releaseJog(); setupReady = false; followSession = null; followEpoch++;
|
||||
try { await api('/api/follow/stop'); $('follow-status').textContent = 'Kameraservos gestoppt'; }
|
||||
catch(e) { $('follow-status').textContent = 'Stopp nicht bestätigt – Servoversorgung abziehen. '+e.message; }
|
||||
};
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && followSession) $('follow-stop').click();
|
||||
});
|
||||
// Hold-to-jog: separate short motion lease; sequence numbers reject late requests.
|
||||
let jogTimer = null, jogSequence = 0, setupReady = false, followDemo = false;
|
||||
async function sendJog(direction) {
|
||||
if (!followSession) return;
|
||||
return api('/api/follow/jog',{session:followSession,direction,sequence:++jogSequence});
|
||||
}
|
||||
function releaseJog() {
|
||||
if (jogTimer === null) return;
|
||||
clearInterval(jogTimer); jogTimer = null;
|
||||
sendJog(null).catch(e => { $('follow-status').textContent = 'Tastenstopp nicht bestätigt: '+e.message; });
|
||||
}
|
||||
function pressJog(direction) {
|
||||
if (!setupReady || !followSession || jogTimer !== null) return;
|
||||
const send = () => sendJog(direction).catch(e => {
|
||||
releaseJog(); $('follow-status').textContent = e.message;
|
||||
});
|
||||
jogTimer = setInterval(send,100); send();
|
||||
}
|
||||
for (const direction of ['left','right','up','down']) {
|
||||
const button = $('setup-'+direction);
|
||||
button.disabled = true;
|
||||
button.onpointerdown = e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); button.setPointerCapture(e.pointerId); pressJog(direction);
|
||||
};
|
||||
button.onpointerup = button.onpointercancel = button.onlostpointercapture = releaseJog;
|
||||
button.onkeydown = e => {
|
||||
if ([' ','Enter'].includes(e.key)) { e.preventDefault(); if (!e.repeat) pressJog(direction); }
|
||||
};
|
||||
button.onkeyup = e => { if ([' ','Enter'].includes(e.key)) { e.preventDefault(); releaseJog(); } };
|
||||
button.onblur = releaseJog;
|
||||
$('setup-save-'+direction).disabled = true;
|
||||
$('setup-save-'+direction).onclick = () => followAction(async () => {
|
||||
releaseJog();
|
||||
await sendJog(null);
|
||||
fillFollow(await api('/api/follow/remember',{session:followSession,direction}));
|
||||
$('setup-note').textContent = 'Grenze gespeichert – mit 10 µs Abstand nach innen.';
|
||||
});
|
||||
}
|
||||
$('setup-release').onclick = releaseJog;
|
||||
document.addEventListener('pointerup',releaseJog);
|
||||
document.addEventListener('pointercancel',releaseJog);
|
||||
window.addEventListener('blur',releaseJog);
|
||||
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'));
|
||||
|
||||
async function pollFollow() {
|
||||
$('all-stop').onclick = async () => {
|
||||
try {
|
||||
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
|
||||
const r = await fetch('/api/follow',{cache:'no-store'});
|
||||
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
|
||||
const f = await r.json();
|
||||
followDemo = f.demo;
|
||||
$('setup-start').textContent = followDemo ? 'Einrichtmodus als Demo starten' : 'Einrichtmodus starten';
|
||||
setupReady = f.active && f.operation === 'setup' && !!followSession;
|
||||
if (!setupReady) releaseJog();
|
||||
for (const direction of ['left','right','up','down']) {
|
||||
$('setup-'+direction).disabled = !setupReady;
|
||||
$('setup-save-'+direction).disabled = !setupReady;
|
||||
}
|
||||
$('setup-limits').textContent = 'Gespeicherte Grenzen: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs';
|
||||
$('setup-position').textContent = 'Sollposition: PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||
if (!followConfig) {
|
||||
fillFollow(f.config);
|
||||
$('follow-status').textContent = 'Bereit. Farbe wählen oder Beispielfoto aufnehmen.';
|
||||
}
|
||||
if (!f.active) followSession = null;
|
||||
if (f.active || f.error) $('follow-status').textContent = f.error ||
|
||||
(f.operation === 'setup' ? (f.hardware ? 'Einrichtmodus · echte Servos' : 'Einrichtmodus · nur Demo')
|
||||
: (f.hardware ? 'Echte Servos aktiv' : 'Vorschau ohne Bewegung'))+' · '+
|
||||
(f.operation === 'setup' ? 'Richtungstaste gedrückt halten' : (f.target ? 'Punkt erkannt' : 'Kein eindeutiger Farbpunkt'))+' · PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||
if (f.active && f.image_base64 && !followSample) await showFollowImage(f.image_base64,f.target,followEpoch);
|
||||
} catch(e) {
|
||||
if (followSession) {
|
||||
releaseJog(); setupReady = false; followSession = null;
|
||||
$('follow-status').textContent = 'Verbindung verloren; Nachführung wird beendet. '+e.message;
|
||||
}
|
||||
}
|
||||
setTimeout(pollFollow,300);
|
||||
}
|
||||
pollFollow();
|
||||
|
||||
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();
|
||||
|
||||
+101
-10
@@ -9,7 +9,7 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
|
||||
Effekt-Zustandslogik. Stopp bleibt bis zum Rücksetzen bestehen; nach Start
|
||||
oder Rücksetzen muss erst der Bereich frei sein und dann ein neuer Trigger
|
||||
kommen. Beschädigte Einstellungen schalten auf `aus`.
|
||||
- `web.py` bietet WebUI und API; `index.html` ist die Oberfläche. `run.py`
|
||||
- `web.py` bietet WebUI und API; `index.html` ist die Grabseite, `camera.html` die getrennte Kameraseite. `run.py`
|
||||
bindet an `127.0.0.1`, Zugriff vom Laptop erfolgt per SSH-Tunnel.
|
||||
Für den vom Nutzer gewünschten direkten Heimnetzzugriff kann Uvicorn
|
||||
`halloween.web:create_app` als Factory auf `0.0.0.0:8000` starten;
|
||||
@@ -104,16 +104,38 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
|
||||
- Farbwert, Toleranz, Achsrichtungen und Pulsgrenzen atomar in data/follow.json;
|
||||
Beispielfoto und Analysebilder nur flüchtig. Bildauswahl nutzt Token und exakt
|
||||
das fotografierte Bild; keine neuen Ereignisse, Profile oder Trainingsbilder.
|
||||
- Automatische Auslösung und Follow-Sitzung gegenseitig ausschließen. Follow
|
||||
hält Controller an; allgemeiner Stopp und Reset müssen zuerst Follow stoppen.
|
||||
Danach bleibt bewusste Rücksetzung des Grabablaufs nötig.
|
||||
- `/` und `/grab` liefern nur Grabsteuerung/-Polling; `/kamera` liefert nur
|
||||
Kamera/Farbpunkt/Einrichtung mit eigenem Polling und sofortigem MJPEG-Livebild.
|
||||
Beide nutzen denselben Kameraproduzenten. Keine versteckten Fremdbedienelemente.
|
||||
- Farbvorschau und Grabautomatik dürfen gleichzeitig laufen. Nur echte
|
||||
Kamerabewegung und automatische Grab-Auslösung gegenseitig ausschließen;
|
||||
Start bei Konflikt ablehnen, keine gespeicherten Einstellungen umschalten.
|
||||
- Follow verändert Controller/Modus nicht. Grab-Stopp/-Reset unter
|
||||
`/api/grave/stop` und `/api/grave/reset` verändert Follow nicht. Kamera-Stopp
|
||||
wirkt nur auf Follow. Explizites „Alles stoppen“ nutzt `/api/stop`; bisheriges
|
||||
`/api/reset` bleibt für alte Clients als globale Abschaltung erhalten.
|
||||
- Start braucht frische Kamera und explizite Hardware-/Mechanikbestätigung.
|
||||
Beide Achsen starten bei 1500 µs (möglicher Sprung, vorab entkoppelt prüfen).
|
||||
Defaults 1400–1600, absolute Eingabegrenzen 1100–1900 inklusive 1500,
|
||||
maximal 80 µs/s, keine Suchfahrt; Richtungswechsel nur gestoppt.
|
||||
Jede Achse startet beim Mittel ihrer gespeicherten Followgrenzen (halbe µs
|
||||
aufrunden; möglicher Sprung, vorab entkoppelt prüfen).
|
||||
Defaults Nachführung 1400–1600, Einrichtung 1100–1900,
|
||||
Nachführtempo 40–800 µs/s (neu 400; alte gespeicherte Konfiguration ohne
|
||||
Tempo behält 80). Tempo/Farbe/Richtung nur gestoppt speichern. Nachführung
|
||||
begrenzt den Zeitschritt auf 100 ms statt große Bildlücken nachzuholen;
|
||||
Einrichtung/Mittelstellungsbutton bleiben 40 µs/s. Regelverstärkung ist mit
|
||||
`FOLLOW_GAIN` (600 µs/s je Bildanteil) begrenzt, Tempo nur Obergrenze: Delay
|
||||
der Kamera ließ 800 µs/s im Stillstand pendeln (Simulation, Pi nicht gemessen).
|
||||
- Pi-Kamera zielt auf 15 Bilder/s (FrameDurationLimits, keine Altbild-Queue),
|
||||
Demo bleibt bei ca. 5. Producer-FPS und Follow-Auswertungsrate gleitend messen;
|
||||
`locate()` prüft Masken von streng (Sättigung ≥140) nach locker (≥100); der
|
||||
kräftigste eindeutige Fleck gewinnt, blasse ähnliche Flächen (bläuliches Papier)
|
||||
verdecken ihn nicht. `locate()` dekodiert per JPEG-`draft` direkt auf 160×120 und maskiert ohne
|
||||
Python-Pixelschleife; Ergebnisse müssen mit den Tests übereinstimmen.
|
||||
Farberkennungsdauer separat ausgeben, keine CPU-/Sensor-FPS daraus ableiten.
|
||||
MJPEG prüft neue Frames alle 20 ms, Follow-UI fragt alle 100 ms nach einer
|
||||
abgeschlossenen Antwort erneut ab. Kein garantierter Pi-Durchsatz.
|
||||
- Sitzung startet nie automatisch. Startender Browser erneuert geheimen
|
||||
Sitzungsschlüssel; 1,5 s ohne Heartbeat, 1 s ohne Bild/Auswertung oder 1 s
|
||||
ohne eindeutigen Marker im Hardwarebetrieb schaltet PWM aus. Stale Resultate
|
||||
Sitzungsschlüssel; 1,5 s ohne Heartbeat oder 1 s ohne Bild/Auswertung
|
||||
schaltet PWM aus. Reiner Punktverlust beendet die Sitzung nicht. Stale Resultate
|
||||
und alte Heartbeats dürfen keine neu gestartete Sitzung steuern.
|
||||
- PWM-Abschaltfehler sichtbar melden; keine sichere Hardwareabschaltung aus
|
||||
Softwaretests ableiten. I²C-Ausfall/harter Prozessabbruch können PWM stehen
|
||||
@@ -124,7 +146,76 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
|
||||
erforderlich. Richtungstaste halten: maximal 40 µs/s, Lease 0,35 s und steigende
|
||||
Sequenznummer gegen verspätete Befehle. Loslassen hält die Sollposition per
|
||||
PWM; globaler/Kamerastopp beendet PWM.
|
||||
- Einrichtung darf bisherige Follow-Grenzen überschreiten, niemals 1100–1900 µs.
|
||||
- Einrichtung darf Follow-Grenzen überschreiten, begrenzt durch gespeicherte
|
||||
pan_setup_min/max und tilt_setup_min/max.
|
||||
Grenzspeicherung braucht passende Sitzung und beendete Bewegung; Richtungs-
|
||||
umkehr beachten, 10 µs nach innen speichern, atomar in follow.json. Keine
|
||||
Anschlagerkennung behaupten; nur Sollwerte, beide Achsen vor Anschlag stoppen.
|
||||
|
||||
- Endpunkt-UI: vier Richtungs-Karten zeigen
|
||||
ausschließlich Serverwerte und den Kandidaten mit Innenabstand. Speichern
|
||||
nur auf passender Seite der festen Startmitte dieser Sitzung (setup_origin), Fehler/Bestätigung im Einrichtbereich.
|
||||
Farb-/Richtungsspeicherung darf Endpunkte nicht aus alten Formularfeldern
|
||||
überschreiben. Start speichert nichts; Änderungen vorher explizit speichern.
|
||||
Verspätete Statusantworten nach einer Speicherung verwerfen (configEpoch).
|
||||
|
||||
- Zusätzliche sichtbare Min-/Max-Eingabe für PAN/TILT mit eigenem Speichern und
|
||||
Verwerfen. Nur gestoppt bedienbar; Entwurf und gespeicherte Werte getrennt
|
||||
anzeigen. Polling darf ungespeicherte Eingaben nicht überschreiben; Start
|
||||
verlangt deren Speichern/Verwerfen. Farben/Drehrichtung separat erhalten.
|
||||
Einrichtgrenzen pro Achse gestoppt im WebUI einstellbar (technische Hülle
|
||||
500–2500 µs, kein bestätigter mechanischer Weg). Nachführgrenzen müssen darin
|
||||
liegen; Minimum <= Maximum, keine Bindung an 1500 µs. Setup langsam erweitern, keine Bewegung
|
||||
beim Speichern. Alte Konfiguration ohne Setupfelder erhält 1100–1900;
|
||||
keine automatische Erweiterung. PCA-Treiber prüft ebenfalls 500–2500.
|
||||
|
||||
- Bedienabläufe/Bezeichnungen bei Änderungen mit README.md und SOFTWARE.md
|
||||
abgleichen; aktuelle Grenze im PLAN.md von historischen Zwischenständen
|
||||
unterscheiden. Anschlussänderungen in VERDRAHTUNG.md/SVG synchron halten.
|
||||
|
||||
- `setup_origin` wird beim expliziten Start aus gespeicherten Followgrenzen
|
||||
berechnet und im Status geliefert. Endpunktspeichern ändert ihn nicht und
|
||||
bewegt keine Achse. Erneuter Start berechnet neu; Dienststart bleibt inaktiv.
|
||||
WebUI zeigt zusätzlich die nächste Startmitte aus gespeicherter Konfiguration.
|
||||
|
||||
- `center(session, sequence)` nur in freigegebener Setup-Sitzung: aktuellen
|
||||
Endpunktmittelwert als flüchtiges Ziel setzen, im Prozesszyklus beide Achsen
|
||||
mit höchstens 40 µs/s ohne Überschwingen annähern. Gleiche Sequenzfolge wie
|
||||
Jog; neuere Jog-/Loslassbefehle brechen ab, alte Centerbefehle dürfen nichts
|
||||
reaktivieren. Kamera/Heartbeat/Auswertungswatchdog gelten weiterhin.
|
||||
`centering` im Status; Remember während Fahrt ablehnen, UI-Speichern sperren.
|
||||
Der feste Sitzungsbezug setup_origin und gespeicherte Konfiguration bleiben.
|
||||
Expliziter „Bewegung anhalten“-Knopf sendet auch ohne Jog-Timer einen Stopp.
|
||||
|
||||
- `search_enabled` wird als bool persistiert, fehlendes Feld migriert zu false.
|
||||
Nur echte Follow-Sitzung und zuvor erkanntes Ziel erlauben Suche nach 1,5 s
|
||||
Verlust. Ein Durchlauf auf aktueller TILT-Höhe und beiden TILT-Endpunkten,
|
||||
erste PAN-/TILT-Richtung aus letztem Bildpunkt und Achsumkehr. Jeder Sollwert
|
||||
auf Follow-Min/Max begrenzen, niemals Setupgrenzen. Tempo min(follow_speed,200),
|
||||
vorhandener 100-ms-Zeitschritt bleibt. Kein Endloslauf: Durchlaufende oder 60 s
|
||||
seit Suchbeginn beendet nur die Suchfahrt, danach auf denselben Farbpunkt
|
||||
warten; bestehende Watchdogs haben Vorrang.
|
||||
- Erste Wiedererkennung hält Suchbewegung an, zwei aufeinanderfolgende Resultate
|
||||
übernehmen Tracking. Fehlresultat setzt Bestätigungszähler zurück. Neuer
|
||||
Verlust darf neuen Durchlauf beginnen. Start/Halt löschen gesamten Suchzustand;
|
||||
kein Fund seit Start: an der Startposition warten. Preview/Setup suchen nie.
|
||||
- Status `searching`, `reacquiring`, `search_seconds`, `waiting_for_target` im UI anzeigen. Suchoption
|
||||
nur gestoppt speichern, ungespeicherte Änderung blockiert Start. Bestehende
|
||||
Grenzen und Tempo erhalten. Suchfeldabdeckung und reale Wiedererkennung
|
||||
erst nach Pi-Test bestätigen; die Farberkennung ist keine Objektidentität.
|
||||
|
||||
- Responsive Bedienoberfläche: beide Seiten bis 1800 px breit, ab 1000 px
|
||||
Bild und Bedienung nebeneinander; schmal einspaltig ohne horizontales Scrollen.
|
||||
Kamera: Status/Start/Stopp rechts, Farbe/Tempo/Suche aufklappbar; Endpunkte
|
||||
und manuelle Einrichtung gemeinsam darunter, geöffnet zweispaltig.
|
||||
Grab: Auslöseeinstellungen, Ereignisse und Profile aufklappbar. IDs, API und
|
||||
Canvas-Koordinaten unverändert; Bild und Canvas immer im selben 4:3-Rahmen.
|
||||
Desktopkopf mit Alles-Stopp bleibt sichtbar; mobil normale Seitenrolle.
|
||||
|
||||
- Gewählte Farbe bleibt in follow.json bis zur bewussten Farbwahl, auch nach
|
||||
Punktverlust, Stopp und Neustart. Kein Foto/Objektprofil speichern. Nach
|
||||
beendetem Suchdurchlauf oder 60 s sowie ohne Suchoption bleibt die gesunde
|
||||
Hardware-Sitzung in `waiting_for_target` aktiv, ohne neue Bewegungsbefehle;
|
||||
vorhandene PWM hält Position. Zwei aufeinanderfolgende Funde setzen Tracking
|
||||
fort. Keine automatischen weiteren Suchrunden ohne erneuten Fund.
|
||||
Stopp/Fehler/Neustart beenden weiterhin die Freigabe und lassen die Farbe stehen.
|
||||
+25
-7
@@ -19,6 +19,7 @@ from .follow import FollowService
|
||||
|
||||
|
||||
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||
CAMERA_PAGE = (Path(__file__).parent / "camera.html").read_text(encoding="utf-8")
|
||||
PROFILE_CLEANUP_INTERVAL_SECONDS = 3600
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,10 +102,15 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
|
||||
@app.get("/grab", response_class=HTMLResponse)
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return PAGE
|
||||
|
||||
@app.get("/kamera", response_class=HTMLResponse)
|
||||
async def camera_page():
|
||||
return CAMERA_PAGE
|
||||
|
||||
@app.get("/api/status")
|
||||
async def status():
|
||||
return {"controller": controller.status(), "camera": camera.status(),
|
||||
@@ -120,7 +126,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
|
||||
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
|
||||
async def configure_detection(payload: dict):
|
||||
if follow.active and payload.get("enabled"):
|
||||
if follow.driver is not None and payload.get("enabled"):
|
||||
raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen")
|
||||
try:
|
||||
return detection.configure(payload)
|
||||
@@ -131,7 +137,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
|
||||
@app.get("/api/follow")
|
||||
async def follow_status():
|
||||
return follow.status()
|
||||
return {**follow.status(), "camera": camera.status()}
|
||||
|
||||
@app.post("/api/follow/{action}", dependencies=[Depends(require_ui)])
|
||||
async def follow_action(action: str, payload: dict):
|
||||
@@ -144,17 +150,18 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
if action == "select":
|
||||
return follow.select(payload.get("token"), payload.get("x"), payload.get("y"))
|
||||
if action == "start":
|
||||
if detection.get_config()["enabled"]:
|
||||
raise ValueError("Zuerst automatische Auslösung ausschalten und speichern")
|
||||
if type(payload.get("hardware", False)) is not bool:
|
||||
raise ValueError("Ungültige Servofreigabe")
|
||||
controller.stop()
|
||||
if payload.get("hardware", False) and detection.get_config()["enabled"]:
|
||||
raise HTTPException(status_code=409, detail="Für echte Kamerabewegung zuerst auf der Grab-Seite die automatische Auslösung ausschalten und speichern")
|
||||
session = follow.begin(payload.get("hardware", False),
|
||||
payload.get("acknowledged") is True,
|
||||
payload.get("operation", "follow"))
|
||||
return {"session": session}
|
||||
if action == "jog":
|
||||
return follow.jog(payload.get("session"), payload.get("direction"), payload.get("sequence"))
|
||||
if action == "center":
|
||||
return follow.center(payload.get("session"), payload.get("sequence"))
|
||||
if action == "remember":
|
||||
return follow.remember(payload.get("session"), payload.get("direction"))
|
||||
if action == "heartbeat":
|
||||
@@ -261,6 +268,17 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
) from exc
|
||||
return {"accepted": accepted, "reason": reason, "controller": controller.status()}
|
||||
|
||||
@app.post("/api/grave/stop", dependencies=[Depends(require_ui)])
|
||||
async def grave_stop():
|
||||
controller.stop()
|
||||
return controller.status()
|
||||
|
||||
@app.post("/api/grave/reset", dependencies=[Depends(require_ui)])
|
||||
async def grave_reset():
|
||||
controller.reset()
|
||||
detection.reset()
|
||||
return controller.status()
|
||||
|
||||
@app.post("/api/stop", dependencies=[Depends(require_ui)])
|
||||
async def stop():
|
||||
follow.halt()
|
||||
@@ -297,7 +315,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
if camera.error:
|
||||
return
|
||||
if frame is None or number == last_number:
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.02)
|
||||
continue
|
||||
last_number = number
|
||||
yield (
|
||||
@@ -305,7 +323,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
+ str(len(frame)).encode("ascii")
|
||||
+ b"\r\n\r\n" + frame + b"\r\n"
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
return StreamingResponse(
|
||||
frames(), media_type="multipart/x-mixed-replace; boundary=frame"
|
||||
|
||||
Reference in new issue
Block a user