boardViewer

This commit is contained in:
chk
2026-06-19 06:43:06 +02:00
parent d36ef6189d
commit aa78116837
8 changed files with 593 additions and 73 deletions

View File

@@ -1,14 +1,25 @@
# robot.json Zugriff via appRobotDriver
> Stand: 2026-06-15
> Beschreibt die geplante Umstellung: robot.json kommt vom appRobotDriver, nicht
> mehr aus einer lokalen Datei.
> **Status: umgesetzt** (2026-06-17) — `server/robotConfig.js` ist aktiv.
> Dieses Dokument beschreibt Entwurf und Implementierung. Der Implementierungsplan
> (Schritte 13) ist vollständig abgearbeitet.
---
## Ist-Zustand
## Verhalten je Env-Variable
`appRobotHoming` liest und schreibt die Roboter-Konfiguration direkt aus einer
| Variable | nicht gesetzt | gesetzt |
|----------|--------------|---------|
| `ROBOT_URL` | Kein Driver-Kontakt; alle Lese-/Schreibvorgänge direkt auf die lokale Datei | `fetchRobot()` liest von `GET {ROBOT_URL}/api/robot/config`; `pushRobot()` schreibt nach `POST {ROBOT_URL}/api/robot/config` |
| `ROBOT_JSON` | Standardpfad `scripts/robot_1781069752019.json` | Angegebener Pfad wird als lokale Cache-Datei verwendet |
Beide Variablen nicht gesetzt = **reiner Lokal-Modus**, identisch zum Verhalten vor dem Umbau.
---
## Ehemaliger Ist-Zustand (vor 2026-06-17)
`appRobotHoming` las und schrieb die Roboter-Konfiguration direkt aus einer
lokalen Datei:
```
@@ -16,8 +27,8 @@ ROBOT_JSON = process.env.ROBOT_JSON
|| 'scripts/robot_1781069752019.json'
```
Die Python-Skripte erhalten den Dateipfad als CLI-Argument (`-robot`, `--robot`).
Alle Kalibrierungs-Endpoints schreiben ebenfalls in diese Datei.
Die Python-Skripte erhielten den Dateipfad als CLI-Argument (`-robot`, `--robot`).
Alle Kalibrierungs-Endpoints schrieben ebenfalls in diese Datei.
**Problem:** Der appRobotDriver besitzt die maßgebliche Konfiguration — nicht das
Homing-System. Nach einem Neustart könnten Konfiguration und Driver auseinanderlaufen.

View File

@@ -1,10 +1,12 @@
# Homing Offline-API
> Beschreibung eines geplanten HTTP-Endpunkts, der die vollständige Homing-Pipeline
> (Schritte 15) 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**.
> **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.
---
@@ -35,7 +37,7 @@ Ergebnisdateien zurückgeben.
|---|---|---|
| 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 |
| `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 |
@@ -119,6 +121,110 @@ 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)
```
@@ -179,7 +285,7 @@ holt, sondern aus dem vorab befüllten `{runDir}`.
---
## Umsetzungsplan
## Umsetzungsplan (abgeschlossen 2026-06-18)
### Schritt 1 — `runBoardPipelineOffline(runDir, send, opts)` (Kern)
@@ -260,7 +366,7 @@ 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`
`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.
@@ -329,15 +435,12 @@ dieser Implementierung).
---
## Offene Entscheidungen
## Offene Entscheidungen (getroffen)
- [ ] `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?
- [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)
---

98
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"multer": "^2.2.0"
},
"devDependencies": {
"jest": "^29.7.0",
@@ -1256,6 +1257,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -1524,9 +1531,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1754,6 +1771,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -3978,6 +4010,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -4554,6 +4605,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -4934,6 +4999,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -5161,6 +5243,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -5236,6 +5324,12 @@
"requires-port": "^1.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -15,7 +15,8 @@
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2"
"express": "^4.19.2",
"multer": "^2.2.0"
},
"devDependencies": {
"jest": "^29.7.0",

View File

@@ -367,20 +367,24 @@ function buildSkeletonFK(robot, angles) {
const markerSizeM = (m.size ?? 25) * S;
const [nx, ny, nz] = m.normal ?? [0, 0, 1];
// Marker-Orientierung ZUERST im lokalen Link-Frame bauen, DANN die volle
// childFrame-Rotation anwenden. So wird der Roll (Drehung des Markers um
// seine eigene Normale) korrekt mitgeführt — auch wenn die Link-Drehachse
// parallel zur Marker-Normale liegt (z.B. Marker 197: normal [-1,0,0] ∥
// Arm1-Achse [-1,0,0]). Eine reine Welt-Normalen-Rekonstruktion würde
// genau diesen Anteil verlieren.
const nLocal = new THREE.Vector3(nx, nz, -ny).normalize(); // robot→three.js
// Marker-Orientierung ZUERST im lokalen ROBOT-Frame bauen (rohe Normale:
// Minimal-Rotation [0,0,1]→Normale + Spin um die Normale), DANN über qView
// in three.js-Achsen und mit qFrame in die Welt drehen. Würde man die
// Normale wie früher schon VOR der Minimal-Rotation nach three.js drehen
// (nx,nz,-ny), verdreht eine schräg liegende Normale (z.B. [-1,0,1]) das
// Quadrat zusätzlich um ihren Azimut (~45°) um die eigene Achse; der Spin
// kann das nicht kompensieren. Der Link-Roll um die Normale bleibt
// erhalten, weil qFrame zuletzt wirkt (z.B. Marker 197: normal [-1,0,0] ∥
// Arm1-Achse). Gegen triangulierte Ecken geprüft (Capture 20260616_133151,
// Marker 146): diese Reihenfolge 0.8°, die alte 45.5°.
const nRobot = new THREE.Vector3(nx, ny, nz).normalize(); // rohe robot-Normale
const spinRad = ((m.spin ?? 0) * Math.PI) / 180;
const qNormalLoc = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), nLocal);
const qSpinLoc = new THREE.Quaternion().setFromAxisAngle(nLocal, spinRad);
const qMarkerLoc = qSpinLoc.multiply(qNormalLoc); // Q_spin ∘ Q_normal (lokal)
const qNormalLoc = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), nRobot);
const qSpinLoc = new THREE.Quaternion().setFromAxisAngle(nRobot, spinRad);
const qView = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); // robot→three.js
const qFrame = new THREE.Quaternion().setFromRotationMatrix(childFrame);
const qMarkerW = qFrame.clone().multiply(qMarkerLoc); // in Welt drehen
const normalW = nLocal.clone().applyQuaternion(qFrame).normalize();
const qMarkerW = qFrame.clone().multiply(qView).multiply(qSpinLoc.multiply(qNormalLoc)); // lokal → three.js → Welt
const normalW = new THREE.Vector3(nx, nz, -ny).applyQuaternion(qFrame).normalize();
// P1: orientiertes Quadrat (Normale + Roll + Spin in einem Quaternion).
// PlaneGeometry hat nativ die +Z-Normale, qMarkerW dreht +Z auf die

View File

@@ -483,39 +483,23 @@ function transformDirByT(T, dir) {
];
}
function makeMarkerSquare(pos, normal, size, color) {
// Marker-Quadrat mit vorab berechneter Orientierung (Quaternion). Die
// BoxGeometry ist dünn in lokal-Z, ihre Normale ist also lokal +Z — quat
// dreht +Z auf die Marker-Normale inkl. Link-Rotation und Spin.
//
// Die Orientierung MUSS im lokalen Link-Frame gebaut und erst danach in die
// Szene gedreht werden (siehe Aufrufer). Würde man wie früher
// setFromUnitVectors([0,0,1], welt_normale) NACH dem robot→three.js-
// Achsentausch anwenden, verdreht eine schräg liegende Normale (z.B.
// [-1,0,1]) das Quadrat zusätzlich um ihren Azimut (~45°) um die eigene
// Achse, und der Spin fehlt ganz. Gegen triangulierte Ecken geprüft
// (Capture 20260616_133151, Marker 146): lokale Variante 0.8°, alte 45.5°.
function makeMarkerSquareQuat(pos, quat, size, color) {
const geo = new THREE.BoxGeometry(size, size, size * 0.1);
const mat = new THREE.MeshPhongMaterial({
color,
shininess: 40
});
const mat = new THREE.MeshPhongMaterial({ color, shininess: 40 });
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
// Fallback falls keine gültige Normale vorhanden
let nx = 0, ny = 0, nz = 1;
if (Array.isArray(normal) && normal.length >= 3) {
nx = Number(normal[0]) || 0;
ny = Number(normal[1]) || 0;
nz = Number(normal[2]) || 1;
} else if (normal instanceof THREE.Vector3) {
nx = normal.x;
ny = normal.y;
nz = normal.z;
}
const n = new THREE.Vector3(nx, ny, nz);
if (n.lengthSq() > 1e-12) {
n.normalize();
m.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 0, 1),
n
);
}
m.quaternion.copy(quat);
return m;
}
@@ -837,8 +821,19 @@ function rebuild() {
// ── model markers + normals ──
const modelPositions = {};
const modelNormals = {};
// robot→three.js view rotation (x,y,z)->(x,z,-y) == Rot_x(-90°). Applied LAST,
// so the marker orientation can be built in the robot/link frame first.
const qView = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
for (const [lname, ld] of Object.entries(links)) {
const col = linkColor(lname);
// link rotation in the robot frame (from FK), as a quaternion
const Tl = T[lname] || I4();
const qLink = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().set(
Tl[0], Tl[1], Tl[2], 0,
Tl[4], Tl[5], Tl[6], 0,
Tl[8], Tl[9], Tl[10], 0,
0, 0, 0, 1
));
for (const m of (ld.markers||[])) {
if (!m.position) continue;
const mid = m.id;
@@ -849,8 +844,16 @@ function rebuild() {
modelPositions[mid] = wp;
modelNormals[mid] = nWorld;
const sq = makeMarkerSquare(r2vArr(wp), r2vDir(...nWorld), 0.022, col);
gModel.add(sq);
// Orientierung ZUERST im lokalen Link-Frame (robot): Minimal-Rotation
// [0,0,1]→Normale, dann Spin um diese Normale; DANN qLink (Link-Drehung)
// und qView (in die Szene). So bleibt der Roll des Links um die Normale
// erhalten und der Spin-Azimut-Twist entfällt (siehe makeMarkerSquareQuat).
const nLR = new THREE.Vector3(nLocal[0], nLocal[1], nLocal[2]).normalize();
const spinRad = ((m.spin ?? 0) * Math.PI) / 180;
const qNormal = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), nLR);
const qSpin = new THREE.Quaternion().setFromAxisAngle(nLR, spinRad);
const qMarker = qView.clone().multiply(qLink).multiply(qSpin.multiply(qNormal));
gModel.add(makeMarkerSquareQuat(r2vArr(wp), qMarker, 0.022, col));
// normal arrow (length = half a marker size = ~12.5mm → 0.0125m)
const arr = makeNormalArrow(r2vArr(wp), nWorld, 0.018, col);

View File

@@ -151,6 +151,108 @@ export async function runHoming({
}
}
/**
* Führt den Homing-Ablauf offline aus (Bilder und NPZ bereits im runDir).
* Identische 4b-Kette wie runHoming — ohne Webcam-Zugriff und ohne SSE-Stream.
* send() akkumuliert Logs; done-Event trägt den finalen State.
*
* @param {{
* robotJsonPath: string,
* runDir: string,
* send: (obj: object) => void,
* runScript: (args: string[], send: Function) => Promise<number>,
* runBoardPipelineOffline:(runDir: string, send: Function) => Promise<void>,
* SCRIPT_4B: string,
* SCRIPT_5POSE: string,
* }} opts
*/
export async function runHomingOffline({
robotJsonPath,
runDir,
send,
runScript,
runBoardPipelineOffline,
SCRIPT_4B,
SCRIPT_5POSE,
}) {
send({ type: 'log', text: `▶ Homing-Offline: ${path.basename(runDir)}` });
send({ type: 'log', text: `▶ Robot-JSON: ${robotJsonPath}` });
send({ type: 'log', text: '' });
// ── Schritt 1: Marker-Triangulierung (Bilder liegen bereits im runDir) ──────
send({ type: 'step', step: 1, total: 5, text: 'Marker-Triangulierung …' });
await runBoardPipelineOffline(runDir, send);
const arucoJson = path.join(runDir, 'aruco_marker_poses.json');
try {
await fsPromises.access(arucoJson);
} catch {
send({ type: 'error', text: '❌ aruco_marker_poses.json fehlt Script 3b hat nicht funktioniert.' });
send({ type: 'done', exitCode: -1 });
return;
}
// ── Schritt 2: X-Position schätzen ──────────────────────────────────────────
const xMm = estimateXFromMarkers(arucoJson, robotJsonPath);
send({ type: 'log', text: `▶ Geschätzte X-Position: ${xMm.toFixed(1)} mm` });
send({ type: 'analysis', key: 'x_mm', value: xMm });
// ── Schritte 35 (24): 4b-Kette Arm1 → Ellbow → Arm2 → Hand ───────────────
const links = ['Arm1', 'Ellbow', 'Arm2', 'Hand'];
let fromState = null;
let chainComplete = true;
for (let i = 0; i < links.length; i++) {
const link = links[i];
send({ type: 'step', step: 2 + i, total: 5, text: `Gelenkwinkel ${link}` });
send({ type: 'log', text: `\n─── 4b: ${link} ${'─'.repeat(35 - link.length)}` });
const outputPath = path.join(runDir, `state_${link}.json`);
const args = [SCRIPT_4B, '--robot', robotJsonPath, '--aruco', arucoJson, '--link', link, '--output', outputPath];
if (fromState) args.push('--from-state', fromState);
else args.push('--x-mm', String(xMm));
const exit = await runScript(args, send);
if (exit !== 0) {
send({ type: 'log', text: `⚠ 4b ${link} Exit ${exit} — falle auf 5_pose_estimation.py zurück` });
chainComplete = false;
break;
}
fromState = outputPath;
try {
const stateData = JSON.parse(await fsPromises.readFile(outputPath, 'utf8'));
send({ type: 'analysis', key: `state_${link}`, value: stateData.accumulated_state ?? stateData });
} catch { /* ignorieren */ }
}
// ── Endergebnis ──────────────────────────────────────────────────────────────
try {
let finalState;
if (chainComplete) {
const finalData = JSON.parse(await fsPromises.readFile(fromState, 'utf8'));
finalState = finalData.accumulated_state ?? finalData;
} else {
send({ type: 'step', step: 5, total: 5, text: '5_pose_estimation.py (Fallback) …' });
const poseOut = path.join(runDir, 'robot_state.json');
const args = [SCRIPT_5POSE, arucoJson, '-robot', robotJsonPath, '-out', poseOut];
if (fromState) args.push('--from-state', fromState);
const exit = await runScript(args, send);
if (exit !== 0) throw new Error(`5_pose_estimation.py Exit ${exit}`);
const poseData = JSON.parse(await fsPromises.readFile(poseOut, 'utf8'));
finalState = Object.fromEntries(
Object.entries(poseData.movements).map(([k, v]) => [k, v.value])
);
}
send({ type: 'log', text: '' });
send({ type: 'log', text: '✅ Homing-Offline abgeschlossen' });
send({ type: 'done', exitCode: 0, state: finalState });
} catch (err) {
send({ type: 'error', text: `❌ Endzustand konnte nicht gelesen werden: ${err.message}` });
send({ type: 'done', exitCode: -1 });
}
}
/** Timestamp-String YYYYMMDD_HHmmss */
function makeTimestamp() {
const now = new Date();

View File

@@ -9,7 +9,8 @@ import process from 'process';
import { spawn } from 'child_process';
import { WebcamClient } from './webcamClient.js';
import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId, adoptXAxis, assignFixedMarkersToLink, setJointOriginYZ, setArmMarkerSpin } from './editRobot.js';
import { runHoming } from './homingOrchestrator.js';
import multer from 'multer';
import { runHoming, runHomingOffline } from './homingOrchestrator.js';
import { fetchRobot, robotCachePath } from './robotConfig.js';
const __filename = fileURLToPath(import.meta.url);
@@ -438,8 +439,9 @@ app.post('/api/calibration/compute', async (req, res) => {
// ── Board-Erkennung ───────────────────────────────────────────────────────────
const boardDataDir = path.join(__dirname, '..', 'data', 'board');
const homingDataDir = path.join(__dirname, '..', 'data', 'homing');
const boardDataDir = path.join(__dirname, '..', 'data', 'board');
const homingDataDir = path.join(__dirname, '..', 'data', 'homing');
const homingOfflineDataDir = path.join(__dirname, '..', 'data', 'homing-offline');
const SCRIPT_1 = path.join(__dirname, '..', 'scripts', '1_detect_aruco_observations.py');
const SCRIPT_2 = path.join(__dirname, '..', 'scripts', '2_estimate_camera_from_observations.py');
const SCRIPT_3B = path.join(__dirname, '..', 'scripts', '3b_corner_marker_poses.py');
@@ -588,6 +590,120 @@ async function runBoardPipeline(runDir, send, { refSet } = {}) {
send({ type: 'log', text: '' });
}
/**
* Board-Pipeline für Offline-Homing: Bilder und NPZs liegen bereits im runDir.
* Kein Webcam-Zugriff, keine NPZ-Suche — Scripts 1, 2, 3b werden identisch aufgerufen.
*
* Dateinamen-Konvention im runDir:
* {cameraId}.jpg Kamerabild
* {cameraId}_calibration.npz Kalibrierung
* robot_run.json robot.json für diesen Lauf
*
* @param {string} runDir
* @param {Function} send
* @param {{ refSet?: string }} [opts]
*/
async function runBoardPipelineOffline(runDir, send, { refSet } = {}) {
const robotRunPath = path.join(runDir, 'robot_run.json');
const allFiles = await fsPromises.readdir(runDir);
const cameraIds = allFiles
.filter(f => /^[a-zA-Z0-9]+\.jpg$/.test(f))
.map(f => path.basename(f, '.jpg'))
.sort();
send({ type: 'log', text: `▶ Kameras: ${cameraIds.join(', ')}` });
send({ type: 'log', text: '' });
for (const camId of cameraIds) {
send({ type: 'log', text: `─── ${camId} ${'─'.repeat(40 - camId.length)}` });
const imgPath = path.join(runDir, `${camId}.jpg`);
const npzPath = path.join(runDir, `${camId}_calibration.npz`);
try { await fsPromises.access(npzPath); } catch {
send({ type: 'log', text: `⚠ Keine NPZ für ${camId} übersprungen` });
continue;
}
send({ type: 'log', text: '\n▷ 1_detect_aruco_observations' });
const exit1 = await runScript([
SCRIPT_1,
'-i', imgPath,
'-npz', npzPath,
'-robot', robotRunPath,
'-cameraId', camId,
'-outDir', runDir,
'--saveDebugImage',
], send);
if (exit1 !== 0) {
send({ type: 'log', text: `❌ Script 1 Exit ${exit1}` });
continue;
}
const detJson = path.join(runDir, `${camId}_aruco_detection.json`);
try { await fsPromises.access(detJson); } catch {
send({ type: 'log', text: '⚠ Detection-JSON fehlt Script 2 übersprungen' });
continue;
}
send({ type: 'log', text: '\n▷ 2_estimate_camera_from_observations' });
const script2Args = [SCRIPT_2, '-i', detJson, '-robot', robotRunPath, '-outDir', runDir];
if (refSet) script2Args.push('--refSet', refSet);
const exit2 = await runScript(script2Args, send);
if (exit2 !== 0) send({ type: 'log', text: `❌ Script 2 Exit ${exit2}` });
send({ type: 'log', text: '' });
}
send({ type: 'log', text: '─── 3b: Marker-Triangulierung ────────────────────────────' });
const runFiles3b = await fsPromises.readdir(runDir);
const numPoses = runFiles3b.filter(f => f.endsWith('_camera_pose.json')).length;
if (numPoses >= 2) {
send({ type: 'log', text: `▷ 3b_corner_marker_poses (${numPoses} Kamera-Posen)` });
const exit3b = await runScript([SCRIPT_3B, '--evalDir', runDir, '--robot', robotRunPath], send);
if (exit3b !== 0) send({ type: 'log', text: `❌ Script 3b Exit ${exit3b}` });
} else {
send({ type: 'log', text: `⚠ Nur ${numPoses} Kamera-Pose(n) Script 3b braucht ≥2 Kameras` });
}
send({ type: 'log', text: '' });
}
// ── Multer-Setup für Offline-Homing ──────────────────────────────────────────
async function prepareOfflineRunDir(req, res, next) {
try {
const ts = makeTimestamp();
const runDir = path.join(homingOfflineDataDir, ts);
await fsPromises.mkdir(runDir, { recursive: true });
req.offlineRunDir = runDir;
req.offlineTs = ts;
next();
} catch (err) {
res.status(500).json({ error: String(err) });
}
}
const offlineMulter = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, req.offlineRunDir),
filename: (req, file, cb) => {
const safe = path.basename(file.originalname).replace(/[^a-zA-Z0-9_.-]/g, '_');
cb(null, safe);
},
}),
}).fields([
{ name: 'images', maxCount: 10 },
{ name: 'calibrations', maxCount: 10 },
{ name: 'robot', maxCount: 1 },
]);
function runOfflineUpload(req, res, next) {
offlineMulter(req, res, (err) => {
if (err) return res.status(400).json({ error: `Upload-Fehler: ${err.message}` });
next();
});
}
/**
* POST /api/board/run
* 1. Erstellt data/board/{timestamp}/
@@ -866,6 +982,92 @@ app.get('/api/homing/run-data', async (req, res) => {
}
});
/**
* POST /api/homing/run-offline
* Vollständiger Homing-Ablauf ohne Live-Kameras.
* Bilder, NPZs und robot.json werden per multipart/form-data hochgeladen.
* Antwortet synchron mit { ok, runDir, state, files, log }.
*
* Felder:
* images ein oder mehrere JPEG-Dateien, Name muss {cameraId}.jpg sein
* calibrations je eine NPZ pro Kamera, Name muss {cameraId}_calibration.npz sein
* robot robot.json für diesen Lauf (einmalig, wird nicht dauerhaft gespeichert)
* refSet (Text, optional) Referenz-Set für Script 2, z. B. "A0"
*/
app.post('/api/homing/run-offline',
prepareOfflineRunDir,
runOfflineUpload,
async (req, res) => {
const runDir = req.offlineRunDir;
const ts = req.offlineTs;
const log = [];
// robot.json validieren und als robot_run.json speichern
const robotFile = req.files?.robot?.[0];
if (!robotFile) {
return res.status(400).json({ error: '"robot" fehlt robot.json muss hochgeladen werden', log });
}
let robotRunPath;
try {
const content = await fsPromises.readFile(robotFile.path, 'utf8');
JSON.parse(content); // Syntaxprüfung
robotRunPath = path.join(runDir, 'robot_run.json');
await fsPromises.rename(robotFile.path, robotRunPath);
} catch (err) {
return res.status(400).json({ error: `robot.json ungültig: ${err.message}`, log });
}
// Mindestens ein Bild erforderlich
if (!req.files?.images?.length) {
return res.status(400).json({ error: 'Mindestens ein Bild ("images") fehlt', log });
}
const refSet = req.body?.refSet || undefined;
// Logs und done-Event akkumulieren
let finalState = null;
let exitCode = -1;
const send = (obj) => {
if (obj.type === 'log') log.push(obj.text);
if (obj.type === 'done') { finalState = obj.state ?? null; exitCode = obj.exitCode; }
};
try {
await runHomingOffline({
robotJsonPath: robotRunPath,
runDir,
send,
runScript,
runBoardPipelineOffline: (dir, s) => runBoardPipelineOffline(dir, s, { refSet }),
SCRIPT_4B,
SCRIPT_5POSE,
});
} catch (err) {
console.error('homing/run-offline error:', err);
return res.status(500).json({ error: String(err), log });
}
// Zu wenige Kameras → aruco_marker_poses.json fehlt
if (exitCode !== 0) {
const arucoExists = await fsPromises.access(path.join(runDir, 'aruco_marker_poses.json'))
.then(() => true).catch(() => false);
const status = arucoExists ? 500 : 422;
return res.status(status).json({ error: 'Homing fehlgeschlagen', log });
}
// Alle JSON-Ausgabedateien einlesen (robot_run.json ausgenommen)
const allFiles = await fsPromises.readdir(runDir).catch(() => []);
const files = {};
for (const f of allFiles.sort()) {
if (f.endsWith('.json') && f !== 'robot_run.json') {
try { files[f] = JSON.parse(await fsPromises.readFile(path.join(runDir, f), 'utf8')); } catch {}
}
}
return res.json({ ok: true, runDir: ts, state: finalState, files, log });
}
);
// ── Robot-JSON bearbeiten ─────────────────────────────────────────────────────
/**