19 KiB
Homing Offline-API
Status: implementiert (2026-06-18).
POST /api/homing/run-offlineist aktiv inserver/server.js. Abhängigkeit:multer(npm-Package, bereits installiert).Gedacht für die Simulations-Pipeline (
appRobotRendering), die synthetische oder aufgezeichnete Bilder liefert und die aktuelle Pose-Erkennung von appRobotHoming nutzen möchte — ohne den Umweg über echte Kameras und den WebCam-Service.
Motivation
Die Live-Homing-Pipeline (POST /api/homing/run) setzt voraus, dass WEBCAM_URL auf
einen laufenden WebCam-Service zeigt, der Bilder auf Abruf liefert. Das ist für zwei
Szenarien unpraktisch:
-
Simulations-Validierung —
appRobotRenderingrendert synthetische Bilder zu bekannten Gelenkwinkeln und will prüfen, wie gut die aktuelle Pose-Erkennung die Winkel zurückrechnet. Die Pipeline inappRobotRenderingliegt lokal und braucht keine Live-Kamera. -
Offline-Replay — früher aufgenommene Bildsätze sollen mit dem aktuellen Stand der Algorithmen neu ausgewertet werden (z. B. nach Verbesserungen an
4b_revolute_angle.pyoder5_pose_estimation.py).
In beiden Fällen sind Bilder und Kalibrierungsdaten bereits vorhanden. Die API soll diese entgegennehmen, die Pipeline identisch zum Live-Modus durchlaufen und die Ergebnisdateien zurückgeben.
Abgrenzung zum Live-Modus
| Aspekt | Live (/api/homing/run) |
Offline (diese API) |
|---|---|---|
| Bilder | WebCam-Service liefert auf Abruf | Caller liefert im Request |
| NPZ | Server sucht neueste Session in data/calibration/ |
Caller liefert im Request |
robot.json |
Server nutzt robotConfig.js (Driver oder lokale Cache-Datei) |
Caller liefert im Request |
| Pipeline (1→2→3b→4b→5) | identisch | identisch |
| Antwort | SSE-Stream während Lauf | Synchrones JSON mit allen Ergebnisdateien |
| Zweck | Produktions-Homing | Simulation / Replay |
Die Pipeline-Skripte (1_detect_aruco_observations.py bis 5_pose_estimation.py)
werden unverändert aufgerufen — die Offline-API ist nur eine andere Eingangsschicht.
Neue Algorithmen erscheinen automatisch in beiden Pfaden.
API-Beschreibung
POST /api/homing/run-offline
Content-Type: multipart/form-data
Felder
| Feld | Typ | Pflicht | Beschreibung |
|---|---|---|---|
images |
Datei(en), image/jpeg |
ja | Ein oder mehrere JPEG-Bilder. Dateiname muss {cameraId}.jpg sein (z. B. cam0.jpg, cam1.jpg). |
calibrations |
Datei(en), application/octet-stream |
ja | Je eine .npz-Datei pro Kamera. Dateiname muss {cameraId}_calibration.npz sein (z. B. cam0_calibration.npz). |
robot |
Datei, application/json |
ja | robot.json für diesen Lauf. Wird nur für diesen Aufruf verwendet, nicht dauerhaft gespeichert. |
refSet |
Text | nein | Optionaler Referenz-Set-Name für Script 2 (--refSet), z. B. A0. |
Das Pairing {cameraId}.jpg ↔ {cameraId}_calibration.npz erfolgt rein über den
Dateinamen-Prefix vor dem ersten _ bzw. vor .jpg. Kamera-IDs ohne passende NPZ
werden wie im Live-Modus übersprungen (Log-Eintrag, kein Fehler).
Antwort (Erfolg): 200 OK
{
"ok": true,
"runDir": "20260616_183042",
"state": {
"x": 312.4,
"y": 44.8,
"z": -12.1,
"a": 7.3,
"b": 0.0,
"c": null,
"e": null
},
"files": {
"aruco_marker_poses.json": { /* Inhalt */ },
"robot_state.json": { /* Inhalt */ },
"state_Arm1.json": { /* Inhalt */ },
"state_Ellbow.json": { /* Inhalt */ },
"state_Arm2.json": { /* Inhalt */ },
"state_Hand.json": { /* Inhalt */ },
"cam0_aruco_detection.json": { /* Inhalt */ },
"cam0_camera_pose.json": { /* Inhalt */ },
"cam1_aruco_detection.json": { /* Inhalt */ },
"cam1_camera_pose.json": { /* Inhalt */ }
},
"log": [
"▶ Homing-Run: 20260616_183042",
"▶ cam0: 14 Marker erkannt",
"…"
]
}
state entspricht dem accumulated_state aus der 4b-Kette (wenn erfolgreich) oder
den movements-Werten aus robot_state.json (wenn 4b abbricht und 5_pose_estimation
einspringt). null-Werte bedeuten: Gelenk nicht beobachtbar (wie im Live-Modus).
files enthält alle JSON-Ausgabedateien des Laufs als geparste Objekte — kein Base64,
da es sich ausschliesslich um JSON handelt.
Antwort (Fehler)
| Code | Bedeutung |
|---|---|
400 |
Pflichtfelder fehlen, Dateinamen-Convention verletzt, robot.json ungültig |
422 |
Pipeline abgebrochen: Script 3b konnte aruco_marker_poses.json nicht erzeugen (zu wenige Kameras o. ä.) |
500 |
Unerwarteter Server-Fehler (Python nicht gefunden, Datei-I/O-Fehler, …) |
Fehlerresponse immer { "error": "…", "log": ["…"] }.
Aufruf-Beispiele
curl
curl -X POST https://thinkcentre.local:2093/api/homing/run-offline \
-F "images=@cam0.jpg" \
-F "images=@cam1.jpg" \
-F "images=@cam2.jpg" \
-F "calibrations=@cam0_calibration.npz" \
-F "calibrations=@cam1_calibration.npz" \
-F "calibrations=@cam2_calibration.npz" \
-F "robot=@robot_1781069752019.json;type=application/json" \
-F "refSet=A0"
Ohne refSet (alle Board-Marker als Referenz):
curl -X POST https://thinkcentre.local:2093/api/homing/run-offline \
-F "images=@cam0.jpg" -F "images=@cam1.jpg" -F "images=@cam2.jpg" \
-F "calibrations=@cam0_calibration.npz" -F "calibrations=@cam1_calibration.npz" \
-F "calibrations=@cam2_calibration.npz" \
-F "robot=@robot_1781069752019.json;type=application/json"
JavaScript / fetch (Browser oder Node)
const formData = new FormData();
// Bilder – Dateiname MUSS {cameraId}.jpg sein
formData.append('images', cam0Blob, 'cam0.jpg');
formData.append('images', cam1Blob, 'cam1.jpg');
formData.append('images', cam2Blob, 'cam2.jpg');
// Kalibrierungen – Dateiname MUSS {cameraId}_calibration.npz sein
formData.append('calibrations', cam0NpzBlob, 'cam0_calibration.npz');
formData.append('calibrations', cam1NpzBlob, 'cam1_calibration.npz');
formData.append('calibrations', cam2NpzBlob, 'cam2_calibration.npz');
// robot.json
formData.append('robot', new Blob([JSON.stringify(robotJson)], { type: 'application/json' }), 'robot.json');
// optional
formData.append('refSet', 'A0');
const res = await fetch('/api/homing/run-offline', { method: 'POST', body: formData });
const data = await res.json();
// data.state → { x, y, z, a, b, c, e }
// data.files → { "aruco_marker_poses.json": {...}, "state_Arm1.json": {...}, … }
// data.log → ["▶ Kameras: cam0, cam1, cam2", …]
// data.runDir → "20260618_143022"
Python (requests)
import requests
url = 'https://thinkcentre.local:2093/api/homing/run-offline'
files = [
('images', ('cam0.jpg', open('cam0.jpg', 'rb'), 'image/jpeg')),
('images', ('cam1.jpg', open('cam1.jpg', 'rb'), 'image/jpeg')),
('images', ('cam2.jpg', open('cam2.jpg', 'rb'), 'image/jpeg')),
('calibrations', ('cam0_calibration.npz', open('cam0_calibration.npz', 'rb'), 'application/octet-stream')),
('calibrations', ('cam1_calibration.npz', open('cam1_calibration.npz', 'rb'), 'application/octet-stream')),
('calibrations', ('cam2_calibration.npz', open('cam2_calibration.npz', 'rb'), 'application/octet-stream')),
('robot', ('robot.json', open('robot.json', 'rb'), 'application/json')),
]
data = {'refSet': 'A0'} # optional
resp = requests.post(url, files=files, data=data, verify=False, timeout=120)
result = resp.json()
print(result['state']) # {'x': 312.4, 'y': 44.8, 'z': -12.1, ...}
Wichtige Regeln für Dateinamen
| Feld | Pflichtformat | Beispiel |
|---|---|---|
images |
{cameraId}.jpg |
cam0.jpg, cam1.jpg |
calibrations |
{cameraId}_calibration.npz |
cam0_calibration.npz |
robot |
beliebig (wird intern als robot_run.json gespeichert) |
robot.json |
Das Pairing Bild ↔ NPZ erfolgt rein über den cameraId-Prefix.
Eine Kamera ohne passende NPZ wird übersprungen (kein Fehler, aber Log-Eintrag).
HTTP-Statuscodes
| Code | Bedeutung |
|---|---|
200 |
Erfolg: { ok: true, runDir, state, files, log } |
400 |
Pflichtfelder fehlen oder robot.json ist kein valides JSON |
422 |
Pipeline abgebrochen: aruco_marker_poses.json nicht erzeugt (< 2 Kamera-Posen) |
500 |
Homing fehlgeschlagen (4b-Kette + Fallback gescheitert) |
Timeout-Hinweis
Die Pipeline kann 20–60 s dauern. Client-Timeout auf ≥ 120 s setzen
(curl: --max-time 120, requests: timeout=120, nginx/Caddy: proxy_read_timeout 120s).
Datenfluss (detailliert)
multipart/form-data
images: cam0.jpg, cam1.jpg
calibrations: cam0_calibration.npz, cam1_calibration.npz
robot: robot_xxx.json
│
▼
Server: Temp-Verzeichnis anlegen data/homing-offline/{timestamp}/
→ Bilder speichern: cam0.jpg, cam1.jpg
→ NPZ speichern: cam0_calibration.npz, cam1_calibration.npz
→ robot.json speichern: robot_run.json (nur für diesen Lauf)
│
▼ für jede Kamera:
1_detect_aruco_observations.py
-i cam0.jpg -npz cam0_calibration.npz -robot robot_run.json
-cameraId cam0 -outDir {runDir}
→ cam0_aruco_detection.json
2_estimate_camera_from_observations.py
-i cam0_aruco_detection.json -robot robot_run.json
-outDir {runDir} [--refSet A0]
→ cam0_camera_pose.json
│
▼ nach allen Kameras:
3b_corner_marker_poses.py
--evalDir {runDir} --robot robot_run.json
→ aruco_marker_poses.json
│
▼ X-Position schätzen (homingXEstimate)
x_mm ← estimateXFromMarkers(aruco_marker_poses.json, robot_run.json)
│
▼ 4b-Kette sequenziell Arm1→Ellbow→Arm2→Hand:
4b_revolute_angle.py
--robot robot_run.json --aruco aruco_marker_poses.json
--link Arm1 --x-mm {x_mm} --output state_Arm1.json
4b_revolute_angle.py --link Ellbow --from-state state_Arm1.json …
…
→ state_Arm1.json, state_Ellbow.json, state_Arm2.json, state_Hand.json
│
▼ (falls 4b-Kette vollständig)
accumulated_state → state in der Antwort
│
▼ (Verfeinerung / Fallback bei abgebrochener 4b-Kette)
5_pose_estimation.py
aruco_marker_poses.json -robot robot_run.json
--from-state state_Hand.json -out robot_state.json
→ robot_state.json
│
▼
Antwort: { ok, state, files: { alle JSON-Dateien }, log }
Der Ablauf ist identisch zu runHoming() in homingOrchestrator.js, mit dem
einzigen Unterschied, dass runBoardPipeline() die Bilder nicht vom WebCam-Service
holt, sondern aus dem vorab befüllten {runDir}.
Umsetzungsplan (abgeschlossen 2026-06-18)
Schritt 1 — runBoardPipelineOffline(runDir, send, opts) (Kern)
Was: Neue Funktion in server/server.js oder server/homingOrchestrator.js, analog
zu runBoardPipeline(). Unterschied: statt WEBCAM_URL und findLatestNpzForCamera()
leitet sie die bereits-im-Verzeichnis-liegenden Dateien direkt weiter.
Konkret: Der einzige geänderte Teil in runBoardPipeline() ist die
Kamera-Schleife — statt Snapshot + NPZ-Suche wird einfach geprüft, ob
{camId}.jpg und {camId}_calibration.npz im runDir existieren. Danach
rufts Script 1 und 2 identisch auf.
Risiko: keines — die Script-Aufrufe selbst bleiben unverändert. Nur der Pfad zur
NPZ ändert sich von data/calibration/{session}/ zu {runDir}/.
Testbar: Einzel-Test mit einer Kamera, geprüft, ob cam0_aruco_detection.json
korrekt erzeugt wird.
Schritt 2 — Multipart-Upload-Handling
Was: Parsing von multipart/form-data für den neuen Endpoint. Express verarbeitet
application/json und urlencoded nativ, aber nicht multipart. Benötigt entweder:
- Option A —
multer(npm-Package), etabliert, 0 Boilerplate - Option B — manuell mit dem Node
busboy-Parser (bereits in Node 18+, kein Extra-Package)
Empfehlung: multer — es ist bereits multer in vielen Express-Projekten Standard,
klar dokumentiert, und diskStorage legt Dateien direkt in {runDir} ab. Vermeidet
Buffer-Accumulation für grosse NPZ-Dateien.
Risiko: Dateinamen-Sanitising — Multer übergibt den Originaldateinamen. Vor dem
Speichern: path.basename() und nur Zeichen [a-zA-Z0-9_.-] zulassen, sonst 400.
Testbar: curl-Upload-Test mit zwei kleinen Dummy-Dateien, geprüft, ob sie im richtigen Verzeichnis landen.
Schritt 3 — runHomingOffline() Orchestrator
Was: Analoge Funktion zu runHoming() in homingOrchestrator.js, jedoch:
- kein SSE-Stream, sondern Log-Akkumulation in ein Array
runBoardPipelineOffline()stattrunBoardPipeline()robotJsonPathzeigt auf die hochgeladene, temporärerobot_run.json- Rückgabewert:
{ state, files, log }statt SSE-Events
Die 4b-Kette und der 5_pose-Aufruf bleiben unverändert — gleiche Args, gleiche
Exit-Code-Logik, gleiche accumulated_state-Extraktion.
Risiko: 5_pose_estimation.py braucht scipy. In der lokalen Entwicklungsumgebung
muss scipy im Python-Umfeld vorhanden sein (in docker-compose.yaml ist es bereits
eingetragen, lokal muss pip install scipy geprüft werden).
Schritt 4 — Endpoint POST /api/homing/run-offline
Was: Express-Route in server.js:
- Multer-Middleware: Dateien in Temp-Verzeichnis
- Validierung: mindestens 1 Kamera mit passendem
.jpg+.npz-Pair;robot-Feld vorhanden runHomingOffline()aufrufen- Alle JSON-Dateien aus
{runDir}einlesen und infiles-Objekt verpacken - Temp-Verzeichnis aufräumen (oder unter
data/homing-offline/für Replay behalten?)
Aufräumen vs. Behalten: Empfehlung — Verzeichnis behalten, wie bei Live-Homing-Runs. So ist Replay/Debug möglich. Ein periodischer Aufräum-Cron ist eine separate Aufgabe.
Risiko: Timeouts — bei vielen Kameras oder langsamer Maschine kann die Pipeline 20–60 Sekunden dauern. Express hat kein Default-Timeout, aber ein vorgeschalteter Reverse-Proxy (nginx, Caddy) schon. In der Doku festhalten: Client soll Timeout ≥ 120 s setzen. Alternativ: SSE-Variante (gleiche Daten, aber inkrementell gestreamt).
Schritt 5 — Aufräumen temporärer robot.json
Was: Die hochgeladene robot.json wird nur für diesen Lauf im {runDir} als
robot_run.json gespeichert. Sie liegt nicht an der Stelle von robotCachePath (aus robotConfig.js)
und wird daher nie vom Live-Modus versehentlich gelesen. Kein Konflikt.
Risiko: keines — rein additive Datei in einem neuen Verzeichnis.
Testplan
| Test | Was wird geprüft | Werkzeug |
|---|---|---|
| Smoke-Test Upload | Endpoint antwortet 200, runDir im Response vorhanden |
curl mit zwei Dummy-JPEGs + NPZs + robot.json |
| Kamera-Pairing | .jpg ohne passende NPZ wird übersprungen (kein 500) |
curl mit fehlendem NPZ |
| Dateinamen-Sanitising | ../../../evil.npz → 400 |
curl mit bösem Dateinamen |
| Simulations-Roundtrip | appRobotRendering rendert 10 Posen mit bekannten GT-Winkeln, API gibt state zurück, Abweichung < Toleranz |
automatisiert aus appRobotRendering-Pipeline |
| Identität Live vs. Offline | Denselben Bildsatz einmal per Live-Run und einmal per Offline-API auswerten → state-Differenz ≈ 0 |
manuell mit aufgezeichnetem Homing-Run |
| Fallback-Pfad | Script 3b schlägt fehl (< 2 Kameras) → 422 mit log | curl mit nur einem Bild |
| Timeout-Robustheit | 4b bricht ab → 5_pose_estimation greift ein, 200 wird trotzdem zurückgegeben | simulierter Abbruch durch fehlende Marker |
Bekannte Risiken und Probleme
Dateinamen-Convention ist implizit
Die Kamera-ID wird aus dem Dateinamen abgeleitet — kein explizites Metadaten-Feld.
Das funktioniert solange die Namenskonvention ({cameraId}.jpg / {cameraId}_calibration.npz)
eingehalten wird. Abweichungen (z. B. frame_cam0_001.jpg) führen zu einem ungematchten
Bild, das still übersprungen wird — kein Fehler, aber unerwartetes Verhalten.
Mitigation: Explizite Validierung und Fehlermeldung wenn kein Match gefunden wird.
Alternativ: Metadaten-Feld cameraMappings: {"cam0": {"image": "frame_cam0_001.jpg", "npz": "cam0_calibration.npz"}}.
Für den Simulations-Use-Case ist die einfache Konvention ausreichend.
robot.json-Versionskonflikt
Simulation und Live-Homing teilen sich robot.json konzeptionell, aber die Datei
entwickelt sich weiter (Kalibrierung, neue Marker, geänderte Positionen). Eine veraltete
robot.json aus appRobotRendering kann zu systematisch falschen Posen führen, die
schwer von Algorithmus-Fehlern zu unterscheiden sind.
Mitigation: Im Simulations-Roundtrip-Test die robot.json-Version (z. B. Timestamp
im Dateinamen) protokollieren und mit dem API-Response abgleichen.
Gleichzeitige Anfragen
Mehrere Offline-Runs gleichzeitig schreiben in separate {timestamp}-Verzeichnisse —
kein Konflikt. Aber: Python-Subprozesse multiplizieren sich. Bei parallelen Requests aus
der Simulations-Pipeline könnte die CPU-Last (scipy + ArUco) den Server blockieren.
Mitigation (optional): Request-Queue mit max. 1–2 parallelen Runs. Für den Simulations-Use-Case wird sequenzieller Aufruf empfohlen.
scipy-Abhängigkeit auf Deployment-Maschine
5_pose_estimation.py braucht scipy. In docker-compose.yaml ist es vorhanden.
Lokal (Entwicklung ohne Docker) muss pip install scipy sichergestellt sein, sonst
schlägt Schritt 5 stumm fehl (Exit 1, aber Schritt 4 hat bereits ein Ergebnis).
Festplattenverbrauch
Jeder Offline-Run erzeugt ein Verzeichnis mit JPEGs + NPZs + ca. 10 JSON-Dateien. Bei intensiver Simulations-Nutzung (100 Posen/Tag) kann das summieren.
Mitigation: TTL-basiertes Aufräumen als gelegentliche Wartungsaufgabe (kein Teil dieser Implementierung).
Offene Entscheidungen (getroffen)
multer(v2.2.0) — installiert,diskStorageschreibt direkt in{runDir}- Offline-Runs in
data/homing-offline/(eigenes Verzeichnis, nicht im boardViewer) - Synchrone Antwort — Pipeline läuft durch, dann JSON; Client-Timeout ≥ 120 s empfohlen
5_pose_estimation.pynur als Fallback (identisch zum Live-Modus)
Abhängigkeiten und Voraussetzungen
Vor der Implementierung müssen vorhanden sein:
multerinstalliert (oder busboy-basierte Alternative entschieden)scipyim Python-Environment verfügbar (lokal + Docker)scripts/5_pose_estimation.py+scripts/robot_fk.pyim Repo (beide vorhanden, ✅)homingOrchestrator.jsfür Orientierung (vorhanden, ✅)- Test-Bilder + Test-NPZs für automatisierten Smoke-Test (aus
test/homing/odertest/y-axis-finder-examples/; die NPZs müssen dazu noch als Testfixtures bereitgestellt werden)
Verweise
Homing.md— Gesamtüberblick Homing-AblaufHoming_0_Camera.md— Schritte 1–3b (Board-Pipeline)Homing_1_StepByStep.md— 4b-Kette (Gelenkwinkel-Schätzung)Homing_5_Pose.md— 5_pose_estimation.py (Bundle-Adjustment)server/server.js— bestehende Endpoints (/api/homing/run,runBoardPipeline())server/homingOrchestrator.js—runHoming(), Vorlage fürrunHomingOffline()