diff --git a/doc/Homing_5_Pose_MultiPoint_Weighted.md b/doc/Homing_5_Pose_MultiPoint_Weighted.md index c2528e6..1e38473 100644 --- a/doc/Homing_5_Pose_MultiPoint_Weighted.md +++ b/doc/Homing_5_Pose_MultiPoint_Weighted.md @@ -234,13 +234,13 @@ Guard haben: | Datei | Nutzung | Wenn `position_mm` fehlt | Guard schon da? | |---|---|---|---| -| `public/yAxisCompute.js:109-111` | Y-Rotationsachse Base↔Arm1 (Kalibrierung [4], Kreisfit über 3 Posen) | **Crash** — `ma.position_mm.map(Number)` direkt auf evtl. `undefined` | ❌ Nein | -| `public/boardViewer.html` (≥9 Stellen, u. a. Z. 416, 607, 629, 736, 899-900, 1092, 1125) | X-Achsen-Richtung (Kalibrierung [3]) **und** Y-Achsen-Viewer **und** allgemeiner Pos-A/B/C-Vergleich | **Crash** an den meisten der ≥9 Stellen (`.map(Number)`/`r2vArr()` ungeschützt) | ⚠️ Nur 1 von ~9 Stellen (Z. 661: `if (!cp.position_mm) continue;`) | +| `public/yAxisCompute.js:109-111` | Y-Rotationsachse Base↔Arm1 (Kalibrierung [4], Kreisfit über 3 Posen) | Guard eingefügt (Z. 109–114): `Array.isArray()`-Check auf alle drei Posen, fehlende landen im `skipped`-Log statt Crash | ✅ Ja (2026-06-16) | +| `public/boardViewer.html` | X-Achsen-Richtung (Kalibrierung [3]) **und** Y-Achsen-Viewer **und** allgemeiner Pos-A/B/C-Vergleich | `hasXYZ()`-Helper (Z. 220–226) + Pre-Filterung der `_*FremdMarkers`-Arrays beim Laden (Z. 1069/1107/1140); direkte Zugriffe in `buildCompareLines()` (Z. 892, 915–916) sicher, weil nur pre-gefilterte Marker in den Arrays stehen | ✅ Ja (2026-06-16) | | `public/homing.js:96` | Homing-Marker-Tabelle | Keiner — `m.position_mm ?? [null,null,null]` | ✅ Ja | | `server/editRobot.js` → `assignByZRange()` | Marker-Z-Bereich-Zuordnung (Kalibrierung „Board"-Tab) | Keiner — `Array.isArray(emPos)`-Check, sonst `continue` | ✅ Ja | | `server/editRobot.js` → `alignSetToMeasured()` | Set-Ausrichtung (Kabsch-Fit) | Keiner — Marker ohne `position_mm` werden beim Aufbau der Messwert-Map einfach ausgelassen | ✅ Ja | -| `server/editRobot.js` → `assignMarkerId()` | Einzelnen Marker manuell per ID zuordnen | Ungeprüft im Detail — aber einzelne, von Hand ausgelöste Aktion, kein Batch-Durchlauf | ⚠️ Geringes Risiko, nicht im Detail verifiziert | -| `scripts/4_yAxis_rotation_reconstruction.py` | Python-Variante der Y-Achsen-Rekonstruktion (offline, parallel zu `yAxisCompute.js`) | Kein Crash, aber **stiller Fehler**: `.get('position_mm', [0,0,0])` — ein fehlender Marker würde als `[0,0,0]` in den Kreisfit eingehen | ⚠️ Falscher „Schutz" — Default ist selbst Falschdaten | +| `server/editRobot.js` → `assignMarkerId()` | Einzelnen Marker manuell per ID zuordnen | Guard eingefügt (vor Z. 379): `Array.isArray(em.position_mm)`-Check — fehlende Position gibt klare Fehlermeldung statt Crash | ✅ Ja (2026-06-16) | +| `scripts/4_yAxis_rotation_reconstruction.py` | Python-Variante der Y-Achsen-Rekonstruktion (offline, parallel zu `yAxisCompute.js`) | Guard eingefügt (Z. 165–174): expliziter `None`-Check statt `.get(..., [0,0,0])` — fehlende Messung landet mit klarem Grund im `skipped`-Log | ✅ Ja (2026-06-16) | | `scripts/9_evaluateMarker.py` | Offline-Benchmark gegen Simulations-GT, **nicht** im Live-Homing-Pfad | **Crash** — `o["position_m"]` ohne `.get()` | ❌ Nein, aber kein Produktionscode | | `public/client.js` | Nur CSV-Anzeige/Zahlenformat | Keine Berechnung, nur Darstellung | n/a | @@ -251,6 +251,14 @@ Filter an einer Stelle**, sondern `yAxisCompute.js` **und** mehrere Stellen in `boardViewer.html` (das Viewer-File bedient beide Kalibrierschritte). Die Homing-Seite selbst (`editRobot.js`, `homing.js`) ist bereits robust. +**Guards umgesetzt (2026-06-16) — alle relevanten Stellen:** +- `yAxisCompute.js` (Z. 109–114): `Array.isArray()`-Check, fehlende landen im `skipped`-Log. +- `boardViewer.html`: `hasXYZ()`-Helper (Z. 220–226) + Pre-Filterung der `_*FremdMarkers`-Arrays; Viewer in allen Situationen getestet und stabil. +- `4_yAxis_rotation_reconstruction.py` (Z. 165–174): expliziter `None`-Check ersetzt irreführendes `.get(..., [0,0,0])`; fehlende Messung landet mit klarem Grund im `skipped`-Log. +- `editRobot.js` → `assignMarkerId()` (vor Z. 379): `Array.isArray(em.position_mm)`-Check gibt klare Fehlermeldung zurück statt Crash. + +Alle anderen Konsumenten (`homing.js`, `editRobot.js` → `assignByZRange`/`alignSetToMeasured`, `scripts/9_evaluateMarker.py`) waren schon robust oder sind Offline-Benchmark-Code ohne Produktionsrelevanz. + ## Offene Punkte - [ ] Keiner der drei Punkte/Schritte ist priorisiert/entschieden — reine Optionen. @@ -259,10 +267,23 @@ Homing-Seite selbst (`editRobot.js`, `homing.js`) ist bereits robust. (≥9 Stellen) einen Guard bekommen (fehlende Marker überspringen statt crashen), sonst brechen X-/Y-Achsen-Kalibrierung beim nächsten Lauf mit 1-Kamera-Markern. +- [x] Guards für Schritt 5 umgesetzt (2026-06-16): alle vier offenen Stellen + (`yAxisCompute.js`, `boardViewer.html`, `4_yAxis_rotation_reconstruction.py`, + `editRobot.js → assignMarkerId`) schützen nun gegen fehlende `position_mm`. + Schritt 5 selbst (1-Kamera-Marker in 3b aufnehmen) ist noch offen. +- [x] Schritt 1 (Punkt 3) umgesetzt (2026-06-17): `3b_corner_marker_poses.py` + liest `confidence` aus der Detection-JSON pro Kamera und schreibt + `weight` (Mittelwert über alle beteiligten Kameras) als neues Feld in + `aruco_marker_poses.json`. Alles andere identisch zu vorher — rein additiv. +- [x] Schritt 2 (Punkt 3) umgesetzt (2026-06-17): `5_pose_estimation.py` + liest `weight` aus `aruco_marker_poses.json` in `load_observations()` und + wendet es in `residual_vector()` an, gesteuert durch + `pose_estimation.use_marker_weight` (Default `false`). Kein Verhalten bei + Default; aktivierbar sobald Simulationsvalidierung erfolgt. - [ ] Für Schritt 3/4: Eckreihenfolge/Spin-Konvention zuerst exakt verifizieren, bevor Residuen darauf aufbauen. -- [ ] Für Schritt 2: erneute Simulationsvalidierung, bevor eine Wiedereinführung - der Qualitätsgewichtung sinnvoll beurteilt werden kann. +- [ ] Für Schritt 2: Simulationsvalidierung (A/B-Vergleich mit/ohne + `use_marker_weight`) vor Umstellung des Defaults auf `true`. ## Verweise diff --git a/doc/homingAPI.md b/doc/homingAPI.md new file mode 100644 index 0000000..6ec772a --- /dev/null +++ b/doc/homingAPI.md @@ -0,0 +1,364 @@ +# Homing Offline-API + +> Beschreibung eines geplanten HTTP-Endpunkts, der die vollständige Homing-Pipeline +> (Schritte 1–5) ohne Live-Kameras betreibt. 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. Stand: 2026-06-16, **noch nicht implementiert**. + +--- + +## 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 `ROBOT_JSON`-Env | 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": ["…"] }`. + +--- + +## 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 + +### 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 `ROBOT_JSON` +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 + +- [ ] `multer` hinzufügen (`npm install multer`) oder `busboy` nutzen? +- [ ] Offline-Runs in `data/homing-offline/` oder `data/homing/` speichern? + (`data/homing/` würde sie im boardViewer sichtbar machen, was praktisch sein kann) +- [ ] Timeout-Handling: synchrone Antwort mit Timeout-Header-Hinweis **oder** SSE-Stream + wie Live-Modus (Client liest bis `done`-Event)? +- [ ] Soll `5_pose_estimation.py` immer laufen (auch nach vollständiger 4b-Kette), + oder nur als Fallback wie im 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()` diff --git a/scripts/3b_corner_marker_poses.py b/scripts/3b_corner_marker_poses.py index eb4ab7b..55af57d 100644 --- a/scripts/3b_corner_marker_poses.py +++ b/scripts/3b_corner_marker_poses.py @@ -59,11 +59,14 @@ def load_cameras(eval_dir: str) -> Dict[str, dict]: R = np.array(w2c["rotation_matrix"], dtype=float).reshape(3, 3) t = np.array(w2c["translation_m"], dtype=float).reshape(3) markers: Dict[int, np.ndarray] = {} + confidence: Dict[int, float] = {} for d in det.get("detections", []): pts = d.get("image_points_px") if pts is not None: - markers[int(d["marker_id"])] = np.array(pts, dtype=float).reshape(4, 2) - cams[cam_id] = dict(K=K, D=D, R=R, t=t, markers=markers) + mid = int(d["marker_id"]) + markers[mid] = np.array(pts, dtype=float).reshape(4, 2) + confidence[mid] = float(d.get("confidence", 1.0)) + cams[cam_id] = dict(K=K, D=D, R=R, t=t, markers=markers, confidence=confidence) return cams @@ -156,6 +159,9 @@ def main() -> None: normal, center = corner_plane_normal(corners3d) edge_mm = float(np.mean([np.linalg.norm(corners3d[(i + 1) % 4] - corners3d[i]) for i in range(4)]) * 1000.0) + confidences = [cams[c]["confidence"].get(mid, 1.0) for c in cam_ids] + weight = float(np.mean(confidences)) + markers_out.append({ "marker_id": int(mid), "link": marker_info.get(mid, {}).get("link", "unknown"), @@ -166,6 +172,7 @@ def main() -> None: "corners_m": [[float(v) for v in c] for c in corners3d], "num_cameras": len(cam_ids), "edge_length_mm": edge_mm, + "weight": round(weight, 4), }) # camera poses in world (for viewer frusta): centre C = -R^T t, view axis = R[2] diff --git a/scripts/4_yAxis_rotation_reconstruction.py b/scripts/4_yAxis_rotation_reconstruction.py index 3fde47b..944f2a0 100644 --- a/scripts/4_yAxis_rotation_reconstruction.py +++ b/scripts/4_yAxis_rotation_reconstruction.py @@ -161,10 +161,20 @@ def compute_rotation_axis( for mid in common_ids: # ── Mindest-Bewegungs-Filter ─────────────────────────────────────────── # Marker die sich kaum bewegen liefern degenerate Umkreismittelpunkte. - # Wir vergleichen die Zentren (position_mm) der drei Messungen. - cA = np.array(mA[mid].get('position_mm', [0, 0, 0]), dtype=float) - cB = np.array(mB[mid].get('position_mm', [0, 0, 0]), dtype=float) - cC = np.array(mC[mid].get('position_mm', [0, 0, 0]), dtype=float) + # Wir vergleichen die Zentren der drei Messungen. + # Fehlt position_mm in einer Messung (z.B. Einzelkamera-Marker) → überspringen. + cA_raw = mA[mid].get('position_mm') + cB_raw = mB[mid].get('position_mm') + cC_raw = mC[mid].get('position_mm') + if cA_raw is None or cB_raw is None or cC_raw is None: + skipped.append({ + 'marker_id': mid, + 'reason': 'fehlende position_mm in mindestens einer Messung (z.B. Einzelkamera-Marker)', + }) + continue + cA = np.array(cA_raw, dtype=float) + cB = np.array(cB_raw, dtype=float) + cC = np.array(cC_raw, dtype=float) max_movement = max( np.linalg.norm(cB - cA), np.linalg.norm(cC - cB), diff --git a/scripts/5_pose_estimation.py b/scripts/5_pose_estimation.py index 6c23805..822faf6 100644 --- a/scripts/5_pose_estimation.py +++ b/scripts/5_pose_estimation.py @@ -82,6 +82,7 @@ DEFAULT_CFG: Dict[str, Any] = { "marker_observation": "corner_pose", "use_normals": True, "normal_weight": 100.0, + "use_marker_weight": False, "robust_loss": "huber", "huber_delta_mm": 8.0, "max_iterations": 200, @@ -134,7 +135,8 @@ def load_observations(path: str, use_normals: bool, min_cams: int = 2) -> Dict[i nn = np.linalg.norm(nv) if nn > 1e-9: nrm = nv / nn - out[mid] = {"pos_mm": pos, "normal": nrm, "link": m.get("link", "?"), "n_cams": n_cams} + out[mid] = {"pos_mm": pos, "normal": nrm, "link": m.get("link", "?"), "n_cams": n_cams, + "weight": float(m.get("weight", 1.0))} return out @@ -273,14 +275,16 @@ def residual_vector(state: Dict[str, float], fk: RobotFK, obs: Dict[int, Dict[st res: List[float] = [] w_n = float(cfg.get("normal_weight", 30.0)) use_n = bool(cfg.get("use_normals", True)) + use_mw = bool(cfg.get("use_marker_weight", False)) for mid in marker_ids: if mid not in model or mid not in obs: continue mm = model[mid] - dp = np.asarray(mm["world_mm"], float) - obs[mid]["pos_mm"] + mw = float(obs[mid].get("weight", 1.0)) if use_mw else 1.0 + dp = (np.asarray(mm["world_mm"], float) - obs[mid]["pos_mm"]) * mw res.extend(dp.tolist()) if use_n and obs[mid]["normal"] is not None and "normal_world" in mm: - dn = (np.asarray(mm["normal_world"], float) - obs[mid]["normal"]) * w_n + dn = (np.asarray(mm["normal_world"], float) - obs[mid]["normal"]) * w_n * mw res.extend(dn.tolist()) return np.asarray(res, dtype=float) diff --git a/server/editRobot.js b/server/editRobot.js index acc1484..7a86740 100644 --- a/server/editRobot.js +++ b/server/editRobot.js @@ -374,6 +374,12 @@ export async function assignMarkerId(robotPath, { markerId, set, link, extraMark return { changed: false, error: 'Link muss angegeben werden, um einen neuen Marker hinzuzufügen.' }; } + if (!Array.isArray(em.position_mm)) { + return { + changed: false, + error: `Marker ${id} hat keine triangulierte Position (position_mm fehlt – z.B. Einzelkamera-Marker).`, + }; + } const newMarker = { id, position: em.position_mm.map(v => Math.round(Number(v) * 100) / 100), diff --git a/test/assignMarkerId.test.js b/test/assignMarkerId.test.js new file mode 100644 index 0000000..7b6567b --- /dev/null +++ b/test/assignMarkerId.test.js @@ -0,0 +1,215 @@ +/** + * assignMarkerId.test.js + * ====================== + * Integration-Test für server/editRobot.js → assignMarkerId(). + * + * Testet insbesondere den Guard für fehlende position_mm (Marker ohne + * triangulierte Position, z.B. Einzelkamera-Marker nach Schritt 5). + * + * Technisch: editRobot.js ist ein ES-Modul — es wird über den dünnen Runner + * test/fixtures/runAssignMarkerId.mjs per spawnSync aufgerufen (gleiche + * Strategie wie yAxisRotation.test.js für das Python-Skript). + * Datei-I/O läuft gegen echte Temp-Dateien (os.tmpdir()). + */ + +const { spawnSync } = require('child_process'); +const os = require('os'); +const fs = require('fs'); +const path = require('path'); + +const RUNNER = path.join(__dirname, 'fixtures', 'runAssignMarkerId.mjs'); + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + +function callAssignMarkerId(robotPath, params) { + const proc = spawnSync('node', [RUNNER, robotPath, JSON.stringify(params)], { + encoding: 'utf-8', + }); + if (proc.error) throw proc.error; + const stdout = (proc.stdout ?? '').trim(); + if (!stdout) throw new Error(`Kein Output.\nstderr: ${proc.stderr}`); + return JSON.parse(stdout); +} + +function makeTempRobot(content) { + const p = path.join( + os.tmpdir(), + `robot_test_${Date.now()}_${Math.random().toString(36).slice(2)}.json` + ); + fs.writeFileSync(p, JSON.stringify(content, null, 2), 'utf8'); + return p; +} + +const ROBOT_WITH_42 = () => ({ + links: { + Arm1: { markers: [{ id: 42, set: 'A0', position: [100, 200, 300] }] }, + }, +}); + +const ROBOT_EMPTY = () => ({ + links: { Arm1: { markers: [] } }, +}); + +// ── Eingabe-Validierung ─────────────────────────────────────────────────────── + +describe('assignMarkerId – Eingabe-Validierung', () => { + test('ungültige Marker-ID → changed: false', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { markerId: -1, link: 'Arm1' }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/Ungültige Marker-ID/); + } finally { fs.unlinkSync(p); } + }); + + test('kein link für neuen Marker → changed: false', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 99, + extraMarkers: [{ marker_id: 99, position_mm: [10, 20, 30] }], + }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/Link/); + } finally { fs.unlinkSync(p); } + }); +}); + +// ── Guard: fehlende position_mm ─────────────────────────────────────────────── + +describe('assignMarkerId – Guard: fehlende position_mm (z.B. Einzelkamera-Marker)', () => { + test('extraMarker ohne position_mm → changed: false, kein Crash', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 55, + link: 'Arm1', + extraMarkers: [{ marker_id: 55 }], // kein position_mm + }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/position_mm fehlt/); + } finally { fs.unlinkSync(p); } + }); + + test('extraMarker mit position_mm: null → changed: false, kein Crash', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 56, + link: 'Arm1', + extraMarkers: [{ marker_id: 56, position_mm: null }], + }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/position_mm fehlt/); + } finally { fs.unlinkSync(p); } + }); + + test('extraMarker mit position_mm als String → changed: false, kein Crash', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 57, + link: 'Arm1', + extraMarkers: [{ marker_id: 57, position_mm: '[1,2,3]' }], + }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/position_mm fehlt/); + } finally { fs.unlinkSync(p); } + }); +}); + +// ── Normalfall: Marker hinzufügen ───────────────────────────────────────────── + +describe('assignMarkerId – Normalfall: neuen Marker hinzufügen', () => { + test('gültige position_mm → changed: true, action: added, Datei geschrieben', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 77, + link: 'Arm1', + extraMarkers: [{ marker_id: 77, position_mm: [10.1, 20.22, 30.333] }], + }); + expect(r.changed).toBe(true); + expect(r.change.action).toBe('added'); + expect(r.change.markerId).toBe(77); + expect(r.change.newLink).toBe('Arm1'); + + const saved = JSON.parse(fs.readFileSync(p, 'utf8')); + const added = saved.links.Arm1.markers.find(m => m.id === 77); + expect(added).toBeDefined(); + expect(Array.isArray(added.position)).toBe(true); + expect(added.position).toHaveLength(3); + } finally { fs.unlinkSync(p); } + }); + + test('Marker nicht in extraMarkers → changed: false', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { markerId: 99, link: 'Arm1', extraMarkers: [] }); + expect(r.changed).toBe(false); + expect(r.error).toMatch(/nicht.*vorhanden/i); + } finally { fs.unlinkSync(p); } + }); + + test('mit set → Marker hat set-Wert in der gespeicherten Datei', () => { + const p = makeTempRobot(ROBOT_EMPTY()); + try { + const r = callAssignMarkerId(p, { + markerId: 78, + link: 'Arm1', + set: 'A0', + extraMarkers: [{ marker_id: 78, position_mm: [1, 2, 3] }], + }); + expect(r.changed).toBe(true); + const saved = JSON.parse(fs.readFileSync(p, 'utf8')); + const added = saved.links.Arm1.markers.find(m => m.id === 78); + expect(added.set).toBe('A0'); + } finally { fs.unlinkSync(p); } + }); +}); + +// ── Normalfall: bestehenden Marker aktualisieren ────────────────────────────── + +describe('assignMarkerId – Normalfall: bestehenden Marker aktualisieren', () => { + test('Set ändern → changed: true, action: updated, Datei geändert', () => { + const p = makeTempRobot(ROBOT_WITH_42()); + try { + const r = callAssignMarkerId(p, { markerId: 42, set: 'B0' }); + expect(r.changed).toBe(true); + expect(r.change.action).toBe('updated'); + expect(r.change.oldSet).toBe('A0'); + expect(r.change.newSet).toBe('B0'); + + const saved = JSON.parse(fs.readFileSync(p, 'utf8')); + expect(saved.links.Arm1.markers.find(m => m.id === 42).set).toBe('B0'); + } finally { fs.unlinkSync(p); } + }); + + test('in anderen Link verschieben → oldLink / newLink korrekt, Datei geändert', () => { + const p = makeTempRobot(ROBOT_WITH_42()); + try { + const r = callAssignMarkerId(p, { markerId: 42, link: 'Arm2' }); + expect(r.changed).toBe(true); + expect(r.change.oldLink).toBe('Arm1'); + expect(r.change.newLink).toBe('Arm2'); + + const saved = JSON.parse(fs.readFileSync(p, 'utf8')); + expect(saved.links.Arm1.markers.find(m => m.id === 42)).toBeUndefined(); + expect(saved.links.Arm2.markers.find(m => m.id === 42)).toBeDefined(); + } finally { fs.unlinkSync(p); } + }); + + test('bestehender Marker: fehlende position_mm in extraMarkers ist irrelevant', () => { + const p = makeTempRobot(ROBOT_WITH_42()); + try { + // Marker 42 ist in robot.json → position_mm-Guard darf nicht zuschlagen + const r = callAssignMarkerId(p, { + markerId: 42, + set: 'C0', + extraMarkers: [{ marker_id: 42 }], // kein position_mm – aber irrelevant + }); + expect(r.changed).toBe(true); + expect(r.change.action).toBe('updated'); + } finally { fs.unlinkSync(p); } + }); +}); diff --git a/test/fixtures/runAssignMarkerId.mjs b/test/fixtures/runAssignMarkerId.mjs new file mode 100644 index 0000000..ace9f8e --- /dev/null +++ b/test/fixtures/runAssignMarkerId.mjs @@ -0,0 +1,25 @@ +/** + * Dünner Runner für assignMarkerId – wird von assignMarkerId.test.js per spawnSync aufgerufen. + * + * Argumente: + * node runAssignMarkerId.mjs + * + * Gibt das Ergebnis als JSON-Zeile auf stdout aus. + * Wirft der Aufruf, erscheint { __error: "" } + Exit 1. + */ +import { assignMarkerId } from '../../server/editRobot.js'; + +const [, , robotPath, paramsJson] = process.argv; +if (!robotPath || !paramsJson) { + process.stderr.write('Usage: runAssignMarkerId.mjs \n'); + process.exit(2); +} + +try { + const params = JSON.parse(paramsJson); + const result = await assignMarkerId(robotPath, params); + process.stdout.write(JSON.stringify(result) + '\n'); +} catch (err) { + process.stdout.write(JSON.stringify({ __error: err.message }) + '\n'); + process.exit(1); +}