Files
appRobotHoming/doc/homingAPI.md
2026-06-19 06:43:06 +02:00

468 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2060 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
2060 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. 12 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 13b (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()`