added point mode

This commit is contained in:
marcinator_2 committed 2026-10-15 20:31:02 +02:00
1 parent 044ad2afbe
commit fce8f62534
16 files changed
+1547 -10

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
"""Local colour-marker preview and deliberately armed camera servo test."""
from collections import deque
from io import BytesIO
from pathlib import Path
import base64
import json
import math
import os
import secrets
import tempfile
import threading
import time
from PIL import Image
DEFAULT = dict(hue=85, tolerance=18, pan_min=1400, pan_max=1600,
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False)
def validate(value):
if set(value) != set(DEFAULT):
raise ValueError('Unvollständige Nachführungseinstellungen')
result = {}
for key, default in DEFAULT.items():
v = value[key]
if isinstance(default, bool):
if type(v) is not bool:
raise ValueError(f'{key}: Ja/Nein erwartet')
else:
if isinstance(v, bool) or not isinstance(v, (float, int)) or not math.isfinite(v):
raise ValueError(f'{key}: gültige Zahl erwartet')
v = int(v)
result[key] = v
if not 0 <= result['hue'] <= 255 or not 5 <= result['tolerance'] <= 35:
raise ValueError('Ungültige Farbe oder Farbtoleranz')
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')
return result
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)
blobs = []
while mask:
seed = mask.pop()
queue, blob = deque([seed]), [seed]
while queue:
i = queue.popleft()
x, y = i % 160, i // 160
for n in ((i-1 if x else -1), (i+1 if x < 159 else -1),
(i-160 if y else -1), (i+160 if y < 119 else -1)):
if n in mask:
mask.remove(n); queue.append(n); blob.append(n)
if len(blob) >= 12:
blobs.append(blob)
blobs.sort(key=len, reverse=True)
if not blobs or len(blobs[0]) > 160*120*.35:
return None
if len(blobs) > 1 and len(blobs[1]) >= len(blobs[0])*.6:
return None
blob = blobs[0]
return {'x': sum(i % 160 for i in blob)/len(blob)/160,
'y': sum(i // 160 for i in blob)/len(blob)/120,
'radius': math.sqrt(len(blob)/math.pi)/160}
class PCA9685:
"""Dedicated camera board, bus 1/address 0x40, channels 0 and 1 only.
Register protocol: https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf
No I2C access until an explicit hardware start. No bus scanning/reset broadcast.
"""
def __init__(self, bus_factory=None):
if bus_factory is None:
from smbus2 import SMBus
bus_factory = SMBus
self.bus = bus_factory(1)
try:
self.off()
# Sleep, internal oscillator, auto-increment. 25 MHz / (4096*122).
self.bus.write_byte_data(0x40, 0x00, 0x30)
self.bus.write_byte_data(0x40, 0xFE, 121)
self.bus.write_byte_data(0x40, 0x01, 0x04) # non-inverted totem-pole
self.bus.write_byte_data(0x40, 0x00, 0x20)
time.sleep(.005)
except Exception:
self.close()
raise
def move(self, pan, tilt):
for channel, pulse in enumerate((pan, tilt)):
if not 1100 <= pulse <= 1900:
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,
[0, 0, count & 255, count >> 8])
def off(self):
failure = None
# Do not use a block write before auto-increment has been initialized.
for channel in (0, 1):
try:
self.bus.write_byte_data(0x40, 0x09+channel*4, 0x10)
except Exception as exc:
failure = exc
if failure:
raise failure
def close(self):
try:
self.off()
finally:
self.bus.close()
class FollowService:
def __init__(self, camera, directory, driver_factory=PCA9685, clock=time.monotonic):
self.camera, self.clock, self.factory = camera, clock, driver_factory
self.lock = threading.RLock()
self.path = Path(directory)/'follow.json'
self.config = dict(DEFAULT)
self.error = None
self.config_fault = False
try:
if self.path.exists():
self.config = validate(json.loads(self.path.read_text()))
except (OSError, ValueError, TypeError):
self.error = 'Nachführungseinstellungen beschädigt; neu speichern'
self.config_fault = True
self.active = False
self.operation = 'follow'
self.jog_direction = None
self.jog_until = 0.
self.jog_sequence = -1
self.driver = None
self.pan = self.tilt = 1500.
self.last_ping = self.clock()
self.last_result = self.last_seen = None
self.number = 0
self.target = None
self.jpeg = None
self.sample = None
self.sample_token = None
self.session = None
self.revision = 0
self.thread = None
self.quit = threading.Event()
def _save(self, value):
config = validate(value)
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=self.path.parent, prefix='.follow-')
try:
with os.fdopen(fd, 'w') as f:
json.dump(config, f)
os.replace(name, self.path)
finally:
if os.path.exists(name): os.unlink(name)
self.config, self.error, self.config_fault = config, None, False
self.revision += 1
return dict(config)
def configure(self, value):
with self.lock:
if self.active:
raise ValueError('Vor Einstellungsänderungen Nachführung stoppen')
return self._save(value)
def _direction(self, direction):
choices = {'left': ('pan', -1), 'right': ('pan', 1),
'up': ('tilt', -1), 'down': ('tilt', 1)}
if direction not in choices:
raise ValueError('Ungültige Richtung')
axis, sign = choices[direction]
return axis, -sign if self.config[axis+'_reverse'] else sign
def _require_setup(self, session):
self.watchdog()
if not self.active or self.operation != 'setup' or session != self.session:
raise ValueError('Einrichtsitzung beendet oder nicht freigegeben')
def jog(self, session, direction, sequence):
with self.lock:
self._require_setup(session)
if type(sequence) is not int or not 0 <= sequence <= 1_000_000_000:
raise ValueError('Ungültige Tastensequenz')
if direction is not None: self._direction(direction)
if sequence <= self.jog_sequence:
return {'accepted': False}
self.jog_sequence = sequence
self.jog_direction = direction
self.jog_until = self.clock()+.35 if direction else 0.
return {'accepted': True}
def remember(self, session, direction):
with self.lock:
self._require_setup(session)
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')
return self._save(dict(self.config, **{key:value}))
def capture(self):
with self.lock:
if self.active:
raise ValueError('Vor dem Farbfoto Nachführung stoppen')
_, jpeg, captured = self.camera.latest_timed()
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
raise ValueError('Kein frisches Kamerabild')
self.sample, self.sample_token = jpeg, secrets.token_hex(16)
return {'image_base64': base64.b64encode(jpeg).decode(), 'token': self.sample_token}
def select(self, token, x, y):
with self.lock:
if not self.sample or token != self.sample_token:
raise ValueError('Beispielfoto erneut aufnehmen')
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) and
math.isfinite(v) and 0 <= v <= 1 for v in (x, y)):
raise ValueError('Punkt außerhalb des Bildes')
image = Image.open(BytesIO(self.sample)).convert('HSV')
px, py = int(x*(image.width-1)), int(y*(image.height-1))
colors = list(image.crop((max(0,px-2), max(0,py-2),
min(image.width,px+3), min(image.height,py+3))).getdata())
colors = [(h,s,v) for h,s,v in colors if s >= 100 and v >= 60]
if len(colors) < 5:
raise ValueError('Bitte einen hellen, kräftig farbigen Punkt wählen')
# Circular mean avoids averaging red at both ends into cyan.
angle = math.atan2(sum(math.sin(h*2*math.pi/256) for h,_,_ in colors),
sum(math.cos(h*2*math.pi/256) for h,_,_ in colors))
config = dict(self.config, hue=round(angle*256/(2*math.pi)) % 256)
return self.configure(config)
def begin(self, hardware=False, acknowledged=False, operation='follow'):
with self.lock:
if operation not in ('follow', 'setup'):
raise ValueError('Unbekannter Kameramodus')
if self.active:
raise ValueError('Kameramodus läuft bereits')
if self.config_fault:
raise ValueError(self.error)
self.error = None
_, jpeg, captured = self.camera.latest_timed()
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
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.
try:
if hardware:
self.driver = self.factory()
self.driver.move(self.pan, self.tilt)
except Exception as exc:
self.halt(f'Servostart fehlgeschlagen: {exc}')
raise ValueError(self.error) from exc
self.active = True
self.operation = operation
self.jog_direction, self.jog_until, self.jog_sequence = None, 0., -1
self.session = secrets.token_hex(16)
self.last_ping = self.clock()
self.last_result = self.last_seen = self.clock()
self.jpeg = None
self.target = None
self.revision += 1
return self.session
def heartbeat(self, session):
with self.lock:
if not self.active or session != self.session:
raise ValueError('Nachführungssitzung beendet')
self.last_ping = self.clock()
def halt(self, error=None):
with self.lock:
self.active = False
self.session = None
self.jog_direction = None
self.jog_until = 0.
self.revision += 1
self.target = None
if self.driver:
driver, self.driver = self.driver, None
try:
driver.close()
except Exception as exc:
error = f'PWM-Abschaltung nicht bestätigt: {exc}; Servoversorgung ausschalten'
if error: self.error = error
def watchdog(self):
with self.lock:
if not self.active: return
now = self.clock()
_, _, captured = self.camera.latest_timed()
if self.camera.error or captured is None or now-captured > 1:
self.halt('Kamerabild ausgefallen; erneut starten')
elif now-self.last_ping > 1.5:
self.halt('Bedienverbindung verloren; erneut starten')
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):
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)))
self.last_result, self.number, self.jpeg, self.target = now, number, jpeg, target
if self.operation == 'setup':
self.target = None
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))
setattr(self,axis,value)
if self.driver: self.driver.move(self.pan,self.tilt)
else:
self.jog_direction = None
return
if 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
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')
def status(self):
with self.lock:
return {'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),
'target': self.target, 'frame_number': self.number,
'image_base64': base64.b64encode(self.jpeg).decode() if self.active and self.jpeg else None}
def start(self):
self.thread = threading.Thread(target=self._run, daemon=True, name='colour-follow')
self.thread.start()
def _run(self):
last = 0
while not self.quit.wait(.05):
self.watchdog()
with self.lock:
if not self.active: continue
revision, config, operation = self.revision, dict(self.config), self.operation
number, jpeg, captured = self.camera.latest_timed()
if number == last or jpeg is None: continue
last = number
try:
target = locate(jpeg, config) if operation == 'follow' else None
self.process(number, jpeg, captured, revision, target)
except Exception as exc:
with self.lock:
if revision == self.revision: self.halt(f'Nachführungsfehler: {exc}')
def stop(self):
self.quit.set()
self.halt()
if self.thread: self.thread.join(timeout=2)
+222 -1
View File
@@ -91,7 +91,7 @@
</select>
<button id="save-mode">Übernehmen</button>
</div>
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. 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 können im separaten Farbtest wirklich bewegt werden. 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>
@@ -103,6 +103,63 @@
</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>
<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>
@@ -403,6 +460,170 @@
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() {
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();
refreshDetection();
setInterval(drawDetection, 250);
refresh();
+36 -1
View File
@@ -31,7 +31,7 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
Internetfreigabe ist nicht beauftragt. Zugangsschutz bleibt offen.
- Laufzeitdaten liegen standardmäßig unter `data/` und werden weder
hochgeladen noch in Git aufgenommen.
- Echte GPIO-/Aktorsteuerung erst nach geklärter Mechanik, Versorgung,
- Echte GPIO-/Grabaktorsteuerung erst nach geklärter Mechanik, Versorgung,
sicherem Stopp und den Hardwaretests aus `PLAN.md` ergänzen.
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
@@ -93,3 +93,38 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
bestätigtes Bildlöschen; Ereignis/Profilzuordnung bleiben bestehen. Löschen
betrifft den aktiven Bestand, keine Upload-Sicherungen. Keine automatische
Alterslöschung von Bildern und keine Änderung der Auto-Profil-Frist.
## Kamera folgt einem Farbpunkt
- `follow.py` bietet lokale Pillow-HSV-Erkennung ohne OpenCV/Training und einen
optional bewusst gestarteten PCA9685-Kameratest. Vorschau und Demo greifen
niemals auf I²C zu; Demo lehnt Hardwarestart ab. Echte Ausgabe nur CH0/CH1,
Bus 1 / 0x40, nominal 50 Hz. Platine für diesen Test ausschließlich dafür nutzen.
- 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.
- 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.
- 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
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
lassen. Keine unabhängige OE-Sperre; nur beaufsichtigter Test.
- `operation=setup` ist ein eigener manueller Einrichtmodus derselben exklusiven
Kamerasitzung. Kein Farbzielsuchen/-verluststopp; Kamera und Heartbeat bleiben
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.
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.
+53 -1
View File
@@ -1,4 +1,4 @@
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
"""Lokale WebUI und MJPEG-Livebild; Grabaktoren simuliert, Kameraservos separat freigebbar."""
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -15,6 +15,7 @@ from .camera import CameraStream
from .controller import Controller, MODES
from .event_store import EventStore
from .detection import DetectionService
from .follow import FollowService
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
@@ -46,6 +47,9 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
events = EventStore(directory)
detection = DetectionService(camera, controller, events, directory)
follow = FollowService(camera, directory)
follow_api_lock = asyncio.Lock()
@asynccontextmanager
async def lifespan(app: FastAPI):
async def periodic_profile_cleanup():
@@ -59,16 +63,19 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
async def detection_watchdog():
while True:
detection.watchdog()
follow.watchdog()
await asyncio.sleep(0.1)
camera.start()
detection.start()
follow.start()
watchdog_task = asyncio.create_task(detection_watchdog())
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
try:
yield
finally:
controller.stop()
follow.stop()
watchdog_task.cancel()
with suppress(asyncio.CancelledError):
await watchdog_task
@@ -83,6 +90,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
app.state.controller = controller
app.state.events = events
app.state.detection = detection
app.state.follow = follow
@app.middleware("http")
async def security_headers(request: Request, call_next):
@@ -112,6 +120,8 @@ 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"):
raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen")
try:
return detection.configure(payload)
except (ValueError, TypeError, KeyError, IndexError) as exc:
@@ -119,6 +129,46 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
@app.get("/api/follow")
async def follow_status():
return follow.status()
@app.post("/api/follow/{action}", dependencies=[Depends(require_ui)])
async def follow_action(action: str, payload: dict):
async with follow_api_lock:
try:
if action == "config":
return follow.configure(payload)
if action == "capture":
return follow.capture()
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()
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 == "remember":
return follow.remember(payload.get("session"), payload.get("direction"))
if action == "heartbeat":
follow.heartbeat(payload.get("session"))
return {"ok": True}
if action == "stop":
follow.halt()
return follow.status()
raise HTTPException(status_code=404, detail="Unbekannte Aktion")
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except OSError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.get("/api/events")
async def recent_events(limit: int = Query(20, ge=1, le=100)):
try:
@@ -213,11 +263,13 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
@app.post("/api/stop", dependencies=[Depends(require_ui)])
async def stop():
follow.halt()
controller.stop()
return controller.status()
@app.post("/api/reset", dependencies=[Depends(require_ui)])
async def reset():
follow.halt()
controller.reset()
detection.reset()
return controller.status()