# Homing Offline-API > **Status: implementiert** (2026-06-18). > `POST /api/homing/run-offline` ist aktiv in `server/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: 1. **Simulations-Validierung** — `appRobotRendering` rendert synthetische Bilder zu bekannten Gelenkwinkeln und will prüfen, wie gut die aktuelle Pose-Erkennung die Winkel zurückrechnet. Die Pipeline in `appRobotRendering` liegt lokal und braucht keine Live-Kamera. 2. **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.py` oder `5_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` ```json { "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 ```bash 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): ```bash 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) ```javascript 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) ```python 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()` statt `runBoardPipeline()` - `robotJsonPath` zeigt auf die hochgeladene, temporäre `robot_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`: 1. Multer-Middleware: Dateien in Temp-Verzeichnis 2. Validierung: mindestens 1 Kamera mit passendem `.jpg` + `.npz`-Pair; `robot`-Feld vorhanden 3. `runHomingOffline()` aufrufen 4. Alle JSON-Dateien aus `{runDir}` einlesen und in `files`-Objekt verpacken 5. 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) - [x] `multer` (v2.2.0) — installiert, `diskStorage` schreibt direkt in `{runDir}` - [x] Offline-Runs in `data/homing-offline/` (eigenes Verzeichnis, nicht im boardViewer) - [x] Synchrone Antwort — Pipeline läuft durch, dann JSON; Client-Timeout ≥ 120 s empfohlen - [x] `5_pose_estimation.py` nur als Fallback (identisch zum Live-Modus) --- ## Abhängigkeiten und Voraussetzungen Vor der Implementierung müssen vorhanden sein: - `multer` installiert (oder busboy-basierte Alternative entschieden) - `scipy` im Python-Environment verfügbar (lokal + Docker) - `scripts/5_pose_estimation.py` + `scripts/robot_fk.py` im Repo (beide vorhanden, ✅) - `homingOrchestrator.js` für Orientierung (vorhanden, ✅) - Test-Bilder + Test-NPZs für automatisierten Smoke-Test (aus `test/homing/` oder `test/y-axis-finder-examples/`; die NPZs müssen dazu noch als Testfixtures bereitgestellt werden) --- ## Verweise - [`Homing.md`](Homing.md) — Gesamtüberblick Homing-Ablauf - [`Homing_0_Camera.md`](Homing_0_Camera.md) — Schritte 1–3b (Board-Pipeline) - [`Homing_1_StepByStep.md`](Homing_1_StepByStep.md) — 4b-Kette (Gelenkwinkel-Schätzung) - [`Homing_5_Pose.md`](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ür `runHomingOffline()`