Private
Public Access
added point mode
This commit is contained in:
1 parent
044ad2afbe
commit
fce8f62534
16 files changed
+1547
-10
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user