spin Marker Callibration
This commit is contained in:
197
README.md
197
README.md
@@ -1,105 +1,152 @@
|
|||||||
# appRobotHoming
|
# appRobotHoming
|
||||||
|
|
||||||
`appRobotHoming` ist die browserbasierte Bedienoberfläche für die WebCam-gestützte
|
Browserbasierte Bedienoberfläche für das kameragestützte **Homing** und die
|
||||||
Ermittlung der Roboterpose. Das Frontend bleibt der Einstieg; die eigentliche
|
**Kalibrierung** eines Roboterarms. Das Frontend kommuniziert mit einem Node.js-Backend
|
||||||
Bildverarbeitung läuft hinter der Firewall auf eigenen Services (WebCam,
|
(BFF-Proxy), das Kamera-Bilder, ArUco-Erkennung und Gelenk-Winkel-Schätzung
|
||||||
BodyTracker), die der Homing-Backend als schlanker Proxy anspricht.
|
über Python-Skripte orchestriert.
|
||||||
|
|
||||||
## Architektur im Überblick
|
## Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
Browser ──HTTPS──▶ Reverse-Proxy ──HTTPS/WSS──▶ appRobotHoming-Backend
|
Browser ──HTTPS──▶ Reverse-Proxy ──HTTPS──▶ appRobotHoming-Backend (Port 2093)
|
||||||
(statisches UI) (öffentliches TLS) (server/server.js, Port 2093)
|
│ server/server.js
|
||||||
│
|
intern (HTTP): │
|
||||||
intern (hinter der Firewall, HTTP):
|
├──▶ WebCam-Service (Bilder, NPZ)
|
||||||
├──▶ WebCam-Service (Bilder)
|
└──▶ Robot-Driver (POST /api/state)
|
||||||
├──▶ BodyTracker-Service (Pose)
|
|
||||||
└──▶ … weitere Schritte (später)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend (`public/`)** – statische Seite: zeigt Infos, Buttons und die
|
**Frontend (`public/`):** statische Seiten — Homing, Kalibrierung, Board-Viewer,
|
||||||
Rückmeldungen (Result als JSON + Tree-View, Snapshot-Tabelle, Bilder). Kein
|
Scene-Viewer. Kein direkter Zugriff auf interne Services.
|
||||||
direkter Zugriff auf die internen Services.
|
|
||||||
- **Backend (`server/server.js`)** – BFF-Proxy. Liefert das statische Frontend
|
|
||||||
aus und stellt eine kleine API bereit, über die das UI an die internen
|
|
||||||
Services kommt. Läuft auf **HTTPS, Port 2093**.
|
|
||||||
|
|
||||||
## Ablauf
|
**Backend (`server/server.js`):** HTTPS-BFF auf Port 2093. Liefert Frontend aus,
|
||||||
|
orchestriert Python-Skripte (SSE-Stream) und liest/schreibt `robot.json`.
|
||||||
|
|
||||||
1. Das UI lädt den aktuellen Stand über `GET /api/latest-snapshot`.
|
## Funktionen
|
||||||
2. **Bilder und Kamera-Intrinsics kommen vom WebCam-Service** (eigener Server
|
|
||||||
hinter der Firewall; die Kamera ist Source of Truth ihrer eigenen Kalibrierung).
|
|
||||||
3. Auf Knopfdruck schickt das UI eine Pose-Anfrage an `POST /api/estimate`.
|
|
||||||
4. Der Backend reicht **Bilder + Intrinsics** zur Verarbeitung an den
|
|
||||||
**BodyTracker** weiter und erhält die Roboterpose zurück.
|
|
||||||
5. Das Ergebnis wird im UI ausgegeben (JSON, Tree, Tabelle, annotierte Bilder).
|
|
||||||
6. **Eventuell folgen weitere Schritte** (z. B. Pose an `appRobotDriver` geben).
|
|
||||||
|
|
||||||
Fällt der BodyTracker aus, rechnet das Frontend ersatzweise lokal mit
|
| Seite | Pfad | Beschreibung |
|
||||||
`public/calculateAngles.js`.
|
|-------|------|--------------|
|
||||||
|
| Homing | `/` (`index.html`) | Homing-Run starten, Status, GCode-Ausgabe |
|
||||||
|
| Kalibrierung | `/calibration.html` | Tabs: Camera NPZ · Board · X-Achse · Arm1-Y · **Marker** |
|
||||||
|
| Board-Viewer | `/boardViewer.html` | 3D-Viewer: Board-Marker, Skeleton FK, Arm-Marker mit Spin |
|
||||||
|
| Scene-Viewer | `/sceneViewer.html` | Standalone-Viewer (Datei-Upload, keine Server-Abhängigkeit) |
|
||||||
|
| Homing-Detail | `/homing.html` | Detail-Ansicht eines Homing-Laufs |
|
||||||
|
|
||||||
## HTTPS (bewusste Entscheidung)
|
## Homing-Ablauf
|
||||||
|
|
||||||
Der Backend läuft selbst auf **HTTPS** – auch wenn davor schon ein Reverse-Proxy
|
```
|
||||||
die öffentliche TLS-Terminierung übernimmt. Grund: **WebSocket-Verbindungen (WSS)
|
Foto alle Kameras
|
||||||
kommen nur sauber durch den Proxy, wenn auch der Backend-Hop TLS spricht.**
|
→ 1_detect_aruco_observations.py (ArUco-Erkennung, pro Kamera)
|
||||||
|
→ 2_estimate_camera_from_observations.py (Kamera-Pose)
|
||||||
|
→ 3b_corner_marker_poses.py (Marker-Triangulierung)
|
||||||
|
→ X-Position schätzen (JS: server/homingXEstimate.cjs)
|
||||||
|
→ 4b_revolute_angle.py Arm1 / Ellbow / Arm2 / Hand (Gelenk-Winkel)
|
||||||
|
→ POST ROBOT_URL/api/state
|
||||||
|
```
|
||||||
|
|
||||||
- Das verwendete Zertifikat ist **self-signed** (`https/`, Passphrase `abcd`).
|
SSE-Events (`log` / `step` / `analysis` / `done`) streamen den Fortschritt live
|
||||||
Das ist Absicht: Dieser Hop ist nur **Proxy ↔ Backend**, nie öffentlich. Die
|
ins Frontend. Der Board-Viewer zeigt das Skeleton progressiv nach jedem erkannten Gelenk.
|
||||||
vertrauenswürdige Kette stellt der vorgelagerte Reverse-Proxy bereit.
|
|
||||||
- Zugriff im internen Netz z. B. über `https://thinkcentre.local:2093/`.
|
|
||||||
|
|
||||||
## API (Backend)
|
Details: [`doc/Homing_ROADMAP.md`](doc/Homing_ROADMAP.md)
|
||||||
|
|
||||||
|
## Kalibrierung
|
||||||
|
|
||||||
|
Einmaliger Vorgang nach mechanischen Änderungen:
|
||||||
|
|
||||||
|
| Schritt | Tab | Ergebnis |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| 1 Camera NPZ | Camera NPZ | Kamera-Intrinsics als `.npz` |
|
||||||
|
| 2 Board | Board | `links.Board.markers` in `robot.json` |
|
||||||
|
| 3 X-Achse | Robot X Axis | alle Marker-Positionen rotiert |
|
||||||
|
| 4 Arm1-Y | Arm1 – Y | `links.Arm1.jointToParent.origin[1,2]` |
|
||||||
|
| 5 Arm-Marker | Marker | Spin-Korrektur, Orientierungs-Verifikation |
|
||||||
|
|
||||||
|
Details: [`doc/Kalibrierung.md`](doc/Kalibrierung.md) ·
|
||||||
|
[`doc/Kalibrierung_Marker.md`](doc/Kalibrierung_Marker.md) ·
|
||||||
|
[`doc/accessRobotAPI.md`](doc/accessRobotAPI.md) (robot.json via Driver)
|
||||||
|
|
||||||
|
## robot.json
|
||||||
|
|
||||||
|
Zentrale Konfiguration aller Gelenke, Marker und Kinematik-Parameter.
|
||||||
|
|
||||||
|
```
|
||||||
|
ROBOT_JSON = process.env.ROBOT_JSON || 'scripts/robot_1781069752019.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
Enthält: `links.{Link}.markers[].{id, position, normal, size, spin}`,
|
||||||
|
`links.{Link}.jointToParent`, `defaultPosition`, `robot_test_poses`.
|
||||||
|
|
||||||
|
## API-Übersicht
|
||||||
|
|
||||||
| Endpoint | Methode | Zweck |
|
| Endpoint | Methode | Zweck |
|
||||||
|---|---|---|
|
|----------|---------|-------|
|
||||||
| `/api/health` | GET | Status + konfigurierte Service-URLs |
|
| `/api/robot` | GET | robot.json lesen |
|
||||||
| `/api/latest-snapshot` | GET | Aktuelle Bilder/Daten (vom WebCam-Service bzw. lokalem Fallback) |
|
| `/api/robot/set-arm-marker-spin` | POST | Spin eines Arm-Markers setzen |
|
||||||
| `/api/estimate` | POST | Bilder an BodyTracker geben → Pose zurück |
|
| `/api/robot/set-joint-origin` | POST | Joint-Origin Y/Z setzen |
|
||||||
|
| `/api/robot/assign-by-z` | POST | Marker nach Z-Bereich zuordnen |
|
||||||
|
| `/api/robot/adopt-x-axis` | POST | X-Achse übernehmen |
|
||||||
|
| `/api/board/run` | POST | Board-Pipeline starten (SSE) |
|
||||||
|
| `/api/board/latest` | GET | Letzter Board-Run (Marker + Robot) |
|
||||||
|
| `/api/homing/run` | POST | Homing-Lauf starten (SSE) |
|
||||||
|
| `/api/homing/send-state` | POST | State an Robot-Driver senden |
|
||||||
|
| `/api/homing/run-data` | GET | Debug-Daten eines Runs |
|
||||||
|
| `/api/calibration/*` | POST/GET | Kalibrierungs-Session verwalten |
|
||||||
|
|
||||||
## Konfiguration (Umgebungsvariablen)
|
## Konfiguration
|
||||||
|
|
||||||
| Variable | Bedeutung |
|
| Variable | Bedeutung |
|
||||||
|---|---|
|
|----------|-----------|
|
||||||
| `HTTPS_PORT` | Port des Backends (Default `2093`) |
|
| `HTTPS_PORT` | Port (Default `2093`) |
|
||||||
| `WEBCAM_URL` | Basis-URL des internen WebCam-Services |
|
| `WEBCAM_URL` | Interner WebCam-Service |
|
||||||
| `BODYTRACKER_URL` | Basis-URL des internen BodyTracker-Services |
|
| `ROBOT_URL` | Interner Robot-Driver |
|
||||||
| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` / `HTTPS_PASSPHRASE` | self-signed Cert für den Proxy-Hop |
|
| `ROBOT_JSON` | Pfad zu robot.json (Default `scripts/robot_1781069752019.json`) |
|
||||||
|
| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` / `HTTPS_PASSPHRASE` | self-signed Cert |
|
||||||
Ist `WEBCAM_URL` nicht gesetzt, nutzt der Backend lokale Dateien aus
|
|
||||||
`public/snapshots` als Fallback (Entwicklung ohne Kamera).
|
|
||||||
|
|
||||||
## Dateien & Struktur
|
## Dateien & Struktur
|
||||||
|
|
||||||
- `public/` – statisches Frontend (UI, Client-Logik, Anzeige).
|
```
|
||||||
- `server/server.js` – HTTPS-Backend / BFF-Proxy.
|
public/ Frontend (HTML, JS, CSS)
|
||||||
- `https/` – self-signed Zertifikate für den Proxy-Hop (nicht eingecheckt).
|
boardViewer.html 3D-Viewer mit Three.js FK, Arm-Markern, Spin-Rendering
|
||||||
- `doc/README_WebCam.md` – WebCam-Service (Bildquelle).
|
sceneViewer.html Standalone-Viewer (nur Datei-Upload)
|
||||||
- `doc/README_BodyTracker.md` – BodyTracker-Service (Pose-Ermittlung).
|
calibration*.html Kalibrierungs-Tabs (lazy-geladen)
|
||||||
- `doc/ToDo.md` – offene Punkte & nächste Umsetzungsschritte.
|
client.js Homing-Frontend-Logik
|
||||||
- `test/` – Tests für Berechnung und Auswertung.
|
calibration.js Kalibrierungs-Frontend-Logik
|
||||||
|
|
||||||
|
server/
|
||||||
|
server.js Express-Backend, alle API-Routes
|
||||||
|
editRobot.js robot.json lesen/schreiben
|
||||||
|
homingOrchestrator.js Homing-Ablauf (SSE-Stream)
|
||||||
|
homingXEstimate.cjs X-Schätzung (reine Geometrie, unit-getestet)
|
||||||
|
spinNormalize.cjs Spin-Normalisierung [0,360) (unit-getestet)
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
robot_1781069752019.json Haupt-Konfiguration (robot.json)
|
||||||
|
1_detect_aruco_observations.py
|
||||||
|
2_estimate_camera_from_observations.py
|
||||||
|
3b_corner_marker_poses.py
|
||||||
|
4b_revolute_angle.py
|
||||||
|
|
||||||
|
test/
|
||||||
|
homingXEstimate.test.js X-Schätzungs-Geometrie (9 Tests, inkl. Regression)
|
||||||
|
spinNormalize.test.js Spin-Normalisierung (5 Tests)
|
||||||
|
yAxisComputeJs.test.js Y-Achsen-Berechnung
|
||||||
|
yAxisRotation.test.js Rotations-Mathe
|
||||||
|
|
||||||
|
doc/
|
||||||
|
Homing_ROADMAP.md Homing-Ablauf und Implementierungs-Status
|
||||||
|
Kalibrierung.md Kalibrierungs-Schritte 1–4
|
||||||
|
Kalibrierung_Marker.md Arm-Marker: Datenmodell, Spin-Verifikation, Roadmap P1–P5
|
||||||
|
ToDo.md Offene Punkte
|
||||||
|
```
|
||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm test
|
npm test # Jest-Tests (14+ Tests)
|
||||||
npm start # startet den HTTPS-Backend auf Port 2093
|
npm start # HTTPS-Backend auf Port 2093
|
||||||
```
|
```
|
||||||
|
|
||||||
Danach im internen Netz `https://<host>:2093/` öffnen (self-signed → einmalige
|
Danach: `https://<host>:2093/`
|
||||||
Zertifikatswarnung im Browser bestätigen).
|
|
||||||
|
|
||||||
> Hinweis: Das Frontend ist auf den Backend angewiesen – `/api/latest-snapshot`
|
> self-signed Zertifikat → einmalige Browser-Warnung bestätigen.
|
||||||
> und `/api/estimate` funktionieren **nicht**, wenn man `index.html` rein
|
> Frontend benötigt laufendes Backend (API-Calls beim Laden).
|
||||||
> statisch öffnet. Immer über `npm start` (bzw. den Container) laufen lassen.
|
|
||||||
|
|
||||||
## Geplante Erweiterungen
|
|
||||||
|
|
||||||
1. Pose an `appRobotDriver` weitergeben.
|
|
||||||
2. Wenn die Hand nicht erkannt wird: Vorschlag für eine bessere Arm-/Foto-Position.
|
|
||||||
3. Manuelle Eingabe von `x, y, z, a, b, c, e`.
|
|
||||||
4. Erkennungsergebnis und Pose klarer im UI ausgeben.
|
|
||||||
|
|
||||||
Konkrete nächste Schritte und offene Schnittstellen-Fragen: siehe
|
|
||||||
[`doc/ToDo.md`](doc/ToDo.md).
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Homing – appRobotHoming
|
# Homing – appRobotHoming
|
||||||
|
|
||||||
> Stand: 2026-06-14
|
> Stand: 2026-06-15
|
||||||
> Homing läuft bei **jedem Einschalten** — schnell, vollautomatisch, ohne mechanische Endschalter.
|
> Homing läuft bei **jedem Einschalten** — schnell, vollautomatisch, ohne mechanische Endschalter.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,7 +44,7 @@ Homing setzt eine abgeschlossene Kalibrierung voraus:
|
|||||||
| Board-Marker-Positionen | ✅ |
|
| Board-Marker-Positionen | ✅ |
|
||||||
| X-Achsen-Richtung | ✅ |
|
| X-Achsen-Richtung | ✅ |
|
||||||
| Arm1 Joint-Origin Y/Z | ✅ Button vorhanden und ausführbar |
|
| Arm1 Joint-Origin Y/Z | ✅ Button vorhanden und ausführbar |
|
||||||
| Arm-Marker in robot.json | 🔶 Nutzer trägt ein (`links.Arm1/Ellbow/Arm2/Hand.markers`) |
|
| Arm-Marker in robot.json | 🔶 Position manuell eintragen; Spin-Verifikation via Kalibrierung → Tab „Marker" (→ `doc/Kalibrierung_Marker.md`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ X-Slider-Position über `--x-mm`.
|
|||||||
| State senden | `POST /api/homing/send-state` | Weiterleitung an `ROBOT_URL/api/state` |
|
| State senden | `POST /api/homing/send-state` | Weiterleitung an `ROBOT_URL/api/state` |
|
||||||
| Run-Daten | `GET /api/homing/run-data?run=ts` | Debug-Bilder (base64) + finalState |
|
| Run-Daten | `GET /api/homing/run-data?run=ts` | Debug-Bilder (base64) + finalState |
|
||||||
| Frontend | `public/index.html` + `public/client.js` | Homing-Buttons, Fortschrittsbalken, Tree View; schreibt Teil-Pose als `G92`-GCode ins Eingabefeld |
|
| Frontend | `public/index.html` + `public/client.js` | Homing-Buttons, Fortschrittsbalken, Tree View; schreibt Teil-Pose als `G92`-GCode ins Eingabefeld |
|
||||||
| Board-Viewer (Homing) | `public/boardViewer.html?mode=homing` | Skelett + Arm-Marker per FK (Three.js), gemessene Marker als Kugeln + Fehlerlinien; progressiver Update je erkanntem Gelenk |
|
| Board-Viewer (Homing) | `public/boardViewer.html?mode=homing` | Skelett + Arm-Marker per FK (Three.js): Marker-Quadrat spin-korrekt rotiert + Orientierungszeiger zu Ecke 0 (Modell-Seite); gemessene Marker als Kugeln + Fehlerlinien; progressiver Update je erkanntem Gelenk |
|
||||||
|
|
||||||
**Lauf-Verzeichnisse:** `data/homing/{timestamp}/`
|
**Lauf-Verzeichnisse:** `data/homing/{timestamp}/`
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Kalibrierung – appRobotHoming
|
# Kalibrierung – appRobotHoming
|
||||||
|
|
||||||
> Stand: 2026-06-14
|
> Stand: 2026-06-15
|
||||||
> Einmaliger Vorgang — nur nach mechanischen Änderungen wiederholen.
|
> Einmaliger Vorgang — nur nach mechanischen Änderungen wiederholen.
|
||||||
> Jede Stufe baut auf der vorherigen auf.
|
> Jede Stufe baut auf der vorherigen auf.
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
## Übersicht
|
## Übersicht
|
||||||
|
|
||||||
```
|
```
|
||||||
[1] Camera NPZ → [2] Board → [3] X-Achse → [4] Arm1 Y-Achse
|
[1] Camera NPZ → [2] Board → [3] X-Achse → [4] Arm1 Y-Achse → [5] Arm-Marker
|
||||||
```
|
```
|
||||||
|
|
||||||
| Schritt | UI-Tab | Ergebnis | Status |
|
| Schritt | UI-Tab | Ergebnis | Status |
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| [2] Board | Board | `links.Board.markers[].position` in `robot.json` | ✅ |
|
| [2] Board | Board | `links.Board.markers[].position` in `robot.json` | ✅ |
|
||||||
| [3] X-Achse | Robot X Axis | Alle Marker-Positionen in `robot.json` rotiert | ✅ |
|
| [3] X-Achse | Robot X Axis | Alle Marker-Positionen in `robot.json` rotiert | ✅ |
|
||||||
| [4] Arm1 Y-Achse | Arm1 – Y | `links.Arm1.jointToParent.origin[1,2]` in `robot.json` | ✅ |
|
| [4] Arm1 Y-Achse | Arm1 – Y | `links.Arm1.jointToParent.origin[1,2]` in `robot.json` | ✅ |
|
||||||
| Arm-Marker eintragen | — | `links.Arm1/Ellbow/Arm2/Hand.markers` | 🔶 Nutzer |
|
| [5] Arm-Marker | Marker | `links.*.markers[].{position,spin}` visuell prüfen und korrigieren | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -283,6 +283,33 @@ Die Implementierung in `public/yAxisCompute.js` setzt **Verfahren B** um:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [5] Arm-Marker — Spin-Verifikation und Korrektur
|
||||||
|
|
||||||
|
**Ziel:** Sicherstellen, dass die in `robot.json` eingetragenen Arm-Marker
|
||||||
|
(Position, Normale, Spin) mit dem realen Aufkleber übereinstimmen — insbesondere
|
||||||
|
die `spin`-Orientierung, die der Homing-Viewer korrekt darstellen muss.
|
||||||
|
|
||||||
|
**Ablauf:**
|
||||||
|
1. Arm-Marker physisch aufkleben und Position/Normale grob in `robot.json` eintragen
|
||||||
|
2. Homing-Run starten (Tab „Homing" oder `homing.html`)
|
||||||
|
3. Kalibrierung → Tab **„Marker"** öffnen:
|
||||||
|
- Tabelle zeigt alle Arm-Marker mit aktuellem `spin`-Wert
|
||||||
|
- Viewer zeigt 3D-Skeleton mit spin-orientiertem Marker-Quadrat und Orientierungszeiger (→ Ecke 0)
|
||||||
|
4. Visuelle Prüfung: zeigt die Viewer-Grafik dieselbe Orientierung wie der echte Aufkleber?
|
||||||
|
5. Falls nicht: Link und Marker-ID wählen → **−90° / +90° / 180°** klicken → Viewer lädt neu
|
||||||
|
6. Wiederholen bis Modell und Realität übereinstimmen
|
||||||
|
|
||||||
|
**Aktionen im Marker-Tab:**
|
||||||
|
- **Link-Dropdown + ID-Dropdown**: Marker auswählen
|
||||||
|
- **Spin-Buttons** (−90°, +90°, 180°): Spin in `robot.json` korrigieren und Viewer neu laden
|
||||||
|
- **Viewer** (boardViewer.html?mode=homing): zeigt Skeleton mit Marker-Quadraten und Orientierungszeigern
|
||||||
|
|
||||||
|
**Speichert:** `links.{Link}.markers[].spin` in `robot.json` (normalisiert auf [0, 360))
|
||||||
|
|
||||||
|
**Details und Roadmap:** [`doc/Kalibrierung_Marker.md`](Kalibrierung_Marker.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Dateistruktur
|
## Dateistruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
341
doc/Kalibrierung_Marker.md
Normal file
341
doc/Kalibrierung_Marker.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Kalibrierung – Arm-Marker
|
||||||
|
|
||||||
|
> Stand: 2026-06-15
|
||||||
|
> Ergänzung zu `doc/Kalibrierung.md` → Schritt „Arm-Marker eintragen und verifizieren"
|
||||||
|
> Dient als Programmier-Roadmap für den UI-Tab „Marker" und die Verifikations-Pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist ein Arm-Marker?
|
||||||
|
|
||||||
|
Ein ArUco-Aufkleber, der auf einem beweglichen Roboter-Glied (Arm1, Ellbow, Arm2, …)
|
||||||
|
befestigt ist. Arm-Marker unterscheiden sich von Board-Markern:
|
||||||
|
|
||||||
|
| | Board-Marker | Arm-Marker |
|
||||||
|
|--|--|--|
|
||||||
|
| Position | fest, kalibriert | bewegt sich mit dem Gelenk |
|
||||||
|
| Zweck | Referenz für Kamera-Pose | Gelenk-Winkel-Schätzung (Homing) |
|
||||||
|
| Position in robot.json | `links.Board.markers` | `links.{Link}.markers` |
|
||||||
|
| Koordinatensystem | Welt (Board) | lokal im Link-Frame |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Marker-Daten in robot.json
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"links": {
|
||||||
|
"Arm1": {
|
||||||
|
"markers": [
|
||||||
|
{
|
||||||
|
"id": 198, // ArUco-ID (eindeutig)
|
||||||
|
"name": "aruco_198", // optional, lesbar
|
||||||
|
"position": [0, -160, 35], // Mittelpunkt im lokalen Link-Frame [mm]
|
||||||
|
"normal": [0, 0, 1], // Normale der Marker-Fläche (Link-Frame)
|
||||||
|
"size": 25, // Kantenlänge mm
|
||||||
|
"spin": 0 // Drehung der Marker-Grafik um die Normale [°]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
|------|-----|-----------|
|
||||||
|
| `id` | int | ArUco-ID — muss mit gedrucktem Marker übereinstimmen |
|
||||||
|
| `position` | `[x,y,z]` mm | Mittelpunkt im **lokalen Link-Frame** (nicht Welt) |
|
||||||
|
| `normal` | `[nx,ny,nz]` | Flächennormale des Markers im Link-Frame; `[0,0,1]` = Marker schaut in +Z |
|
||||||
|
| `size` | mm | Kantenlänge des ArUco-Quadrats |
|
||||||
|
| `spin` | ° | Drehung der Aufkleber-Grafik um die Normale — 0 / 90 / 180 / 270 |
|
||||||
|
|
||||||
|
### Spin-Semantik
|
||||||
|
|
||||||
|
Ein ArUco-Aufkleber kann in 4 Lagen aufgeklebt werden. Physisch ist das egal — der
|
||||||
|
Detektor findet die ID unabhängig von der Orientierung, und auch die gemessene
|
||||||
|
Mittelpunkt-Position ist spin-unabhängig.
|
||||||
|
|
||||||
|
`spin` beschreibt nur die visuelle Darstellung im 3D-Viewer, damit die angezeigte
|
||||||
|
Marker-Grafik mit dem echten Aufkleber übereinstimmt. Das hilft beim visuellen
|
||||||
|
Abgleich: wenn die Grafik im Viewer verdreht zum Aufkleber steht, ist spin falsch.
|
||||||
|
|
||||||
|
> **In der aktuellen Homing-Pipeline (3b, 4b) wird `spin` nicht verwendet.**
|
||||||
|
> Relevant wird es, wenn der Viewer spin korrekt darstellt (→ offenes Todo unten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typischer Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ArUco-Aufkleber auf Roboter-Glied kleben
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
2. Position messen / schätzen → in robot.json eintragen
|
||||||
|
(position = Mittelpunkt im lokalen Link-Frame, normal = Flächen-Richtung)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
3. Homing-Run starten (homing.html)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
4. Kalibrierung → Tab "Marker" öffnen
|
||||||
|
│ · Tabelle zeigt alle Marker und ihren aktuellen spin
|
||||||
|
│ · Viewer zeigt Modell-Marker (Vierecke) + beobachtete Punkte (Kugeln)
|
||||||
|
│ · Fehler-Linien: Modell-Marker → beobachteter Punkt
|
||||||
|
▼
|
||||||
|
5. Prüfen:
|
||||||
|
· Linie kurz → Position stimmt gut
|
||||||
|
· Linie lang oder in falscher Richtung → position in robot.json korrigieren
|
||||||
|
· Viewer-Grafik verdreht → spin korrigieren (+90 / -90 / 180)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
6. Spin / Normal korrigieren → Viewer lädt neu → erneut prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI-Tab „Marker" (implementiert 2026-06-15)
|
||||||
|
|
||||||
|
### Datei: `public/calibration_marker.html`
|
||||||
|
|
||||||
|
Drei Abschnitte:
|
||||||
|
|
||||||
|
| Abschnitt | Inhalt |
|
||||||
|
|-----------|--------|
|
||||||
|
| Aktuelle Marker | Tabelle aller Arm-Marker (Link, ID, Name, Position, Normal, Size, **Spin**) aus robot.json |
|
||||||
|
| Aktionen | Link-Dropdown → Marker-ID-Dropdown → Spin-Buttons (−90°, +90°, 180°) |
|
||||||
|
| Viewer | `boardViewer.html?mode=homing` — Modell + letzter Homing-Run |
|
||||||
|
|
||||||
|
### JS: `initMarker()` in `public/calibration.js`
|
||||||
|
|
||||||
|
| Funktion | Beschreibung |
|
||||||
|
|----------|-------------|
|
||||||
|
| `loadRobot()` | Holt `/api/robot`, rendert Tabelle, befüllt ID-Dropdown |
|
||||||
|
| `renderTable(robot)` | Tabelle aller Arm-Marker mit farbig hervorgehobenem Spin |
|
||||||
|
| `updateMarkerDropdown()` | Link-Dropdown-Wechsel → ID-Dropdown neu befüllen |
|
||||||
|
| `applySpin(delta)` | `POST /api/robot/set-arm-marker-spin` → Viewer `reload`-Message |
|
||||||
|
|
||||||
|
### Backend: `POST /api/robot/set-arm-marker-spin`
|
||||||
|
|
||||||
|
```
|
||||||
|
Body: { linkName: string, markerId: number, spin: number }
|
||||||
|
Return: { changed, linkName, markerId, oldSpin, newSpin }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementiert in `server/server.js` → delegiert an `setArmMarkerSpin()` in `server/editRobot.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte (Programmier-Roadmap)
|
||||||
|
|
||||||
|
### [P1] Spin-Rendering im boardViewer
|
||||||
|
|
||||||
|
**Status:** ✅ implementiert (2026-06-15)
|
||||||
|
**Datei:** `public/boardViewer.html` → `buildSkeletonFK()`, Abschnitt „4. Arm-Marker"
|
||||||
|
|
||||||
|
`spin` wird als zusätzliche Rotation um die Marker-Normale (in Welt-Koordinaten)
|
||||||
|
auf das orientierte Quadrat angewendet via `premultiply`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const normalW = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame).normalize();
|
||||||
|
const markerMesh = makeMarkerSquareOriented(posWorld, normalW, markerSizeM, col);
|
||||||
|
const spinRad = ((m.spin ?? 0) * Math.PI) / 180;
|
||||||
|
if (Math.abs(spinRad) > 1e-6) {
|
||||||
|
markerMesh.quaternion.premultiply(
|
||||||
|
new THREE.Quaternion().setFromAxisAngle(normalW, spinRad)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`premultiply(Q_spin)` setzt `quaternion = Q_spin * Q_normal` → zuerst Normal-Alignment,
|
||||||
|
dann Spin-Rotation in Welt-Koordinaten um `normalW`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P2] Marker-Position verifizieren: Positions-Residuum
|
||||||
|
|
||||||
|
**Status:** ❌ noch nicht implementiert
|
||||||
|
|
||||||
|
Nach einem Homing-Run kennen wir für jeden erkannten Arm-Marker:
|
||||||
|
- `model_pos_world` = Modell-Mittelpunkt im Welt-Frame (via FK)
|
||||||
|
- `obs_pos_world` = triangulierter beobachteter Mittelpunkt
|
||||||
|
|
||||||
|
Das **Positions-Residuum** `|model_pos_world − obs_pos_world|` zeigt, wie gut die
|
||||||
|
eingetragene `position` (Mittelpunkt im lokalen Frame) stimmt.
|
||||||
|
|
||||||
|
> **⚠ Wichtig: Spin-Fehler sind damit NICHT erkennbar.**
|
||||||
|
> Der Mittelpunkt eines Markers ist spin-unabhängig — egal wie ein Aufkleber gedreht
|
||||||
|
> ist, das Zentrum bleibt dasselbe. Ein falscher `spin`-Wert erzeugt daher kein
|
||||||
|
> Positions-Residuum. Spin-Fehler erfordern → P3 (visuell) oder P4 (automatisch).
|
||||||
|
|
||||||
|
**Erweiterung im Marker-Tab:** Tabelle um Spalten ergänzen:
|
||||||
|
- `Δ mm` = Positions-Residuum des letzten Homing-Runs
|
||||||
|
- `Status` = ✅ < 5 mm / ⚠ 5–20 mm / ❌ > 20 mm
|
||||||
|
|
||||||
|
**Datenquelle:** `/api/homing/run-data?run={ts}` → `finalState` + `measuredMarkers`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P3] Python: Ecken-Position in aruco_marker_poses.json ausgeben
|
||||||
|
|
||||||
|
**Status:** ❌ noch nicht implementiert
|
||||||
|
**Datei:** `scripts/3b_corner_marker_poses.py`
|
||||||
|
|
||||||
|
Voraussetzung für P3b. Aktuell enthält `aruco_marker_poses.json` pro Marker nur
|
||||||
|
`position_mm` (Mittelpunkt) und `normal`. Die Spin-Orientierung geht verloren.
|
||||||
|
|
||||||
|
`3b` trianguliert den Mittelpunkt aus den Ecken der Kamera-Beobachtungen.
|
||||||
|
Dieselbe Triangulierung auf **Ecke 0** anwenden und zusätzlich ausgeben:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Zusätzliches Feld pro Marker in aruco_marker_poses.json:
|
||||||
|
"corner0_mm": [x, y, z] # triangulierte Welt-Position von Ecke 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Ecke 0 = top-left in OpenCVs ArUco-Konvention (Index 0 in `corners[0]`).
|
||||||
|
Zusammen mit `position_mm` (Mittelpunkt) definiert `corner0_mm` eindeutig die
|
||||||
|
Orientierung des Markers in 3D.
|
||||||
|
|
||||||
|
> Der Mittelpunkt bleibt spin-unabhängig. `corner0_mm` ist das einzige Feld,
|
||||||
|
> das den Spin kodiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P3b] boardViewer: Orientierungszeiger zeichnen
|
||||||
|
|
||||||
|
**Status:** ✅ Modell-Seite implementiert (2026-06-15) · Beobachtungs-Seite offen (→ P3)
|
||||||
|
**Datei:** `public/boardViewer.html` → `buildSkeletonFK()`
|
||||||
|
**Voraussetzungen:** P1 ✅, P3 (corner0_mm) für Beobachtungs-Zeiger noch offen
|
||||||
|
|
||||||
|
Für jeden Marker werden zwei kurze Linien vom Mittelpunkt zu Ecke 0 gezeichnet —
|
||||||
|
eine für das Modell, eine für die Beobachtung. Der Winkel zwischen beiden = Spin-Fehler.
|
||||||
|
|
||||||
|
**Modell-Zeiger** (implementiert — nutzt `markerMesh.quaternion` direkt):
|
||||||
|
|
||||||
|
`markerMesh.quaternion` kodiert bereits `Q_spin * Q_normal`, daher reicht:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Richtung zur Ecke 0: (1,1,0) normalisiert im lokalen Marker-Frame,
|
||||||
|
// transformiert durch die bereits berechnete Mesh-Rotation (Q_normal ∘ Q_spin)
|
||||||
|
const ptrDir = new THREE.Vector3(1, 1, 0).normalize().applyQuaternion(markerMesh.quaternion);
|
||||||
|
const corner0W = posWorld.clone().add(ptrDir.multiplyScalar(markerSizeM * Math.SQRT1_2));
|
||||||
|
gArmMarkers.add(makeLine(posWorld, corner0W, col, 0.9)); // Zeiger (Link-Farbe)
|
||||||
|
gArmMarkers.add(makeSphere(corner0W, 0.0008, col)); // Ecke 0 (Punkt)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Math.SQRT1_2 = 1/√2` weil der Abstand Mittelpunkt→Ecke bei einem Quadrat mit
|
||||||
|
Kantenlänge `size` genau `size/√2` beträgt (`(size/2)·√2 = size/√2`).
|
||||||
|
|
||||||
|
**Beobachtungs-Zeiger** (aus `corner0_mm` in `aruco_marker_poses.json`,
|
||||||
|
sobald Python das Feld liefert → P3):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// obs = beobachteter Marker aus _measuredMarkers
|
||||||
|
if (obs.corner0_mm) {
|
||||||
|
const corner0Obs = r2vArr(obs.corner0_mm); // robot→Three.js
|
||||||
|
gArmMarkers.add(makeLine(obsPosW, corner0Obs, 0xffffff, 0.6)); // Beobachtungs-Zeiger (dünn)
|
||||||
|
gArmMarkers.add(makeSphere(corner0Obs, 0.0006, 0xffffff)); // Beobachtungs-Ecke
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis im Viewer:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Modell-Mittelpunkt ●——▶ Modell-Ecke 0 (Link-Farbe, voll)
|
||||||
|
Obs-Mittelpunkt ●··▶ Obs-Ecke 0 (weiß, dünn)
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeigen beide Zeiger in dieselbe Richtung → spin korrekt.
|
||||||
|
90°-Unterschied → spin um 90° falsch → +90° oder −90° klicken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P3c] Homing-Check direkt im Marker-Tab starten
|
||||||
|
|
||||||
|
**Status:** ❌ noch nicht implementiert
|
||||||
|
|
||||||
|
Button „Homing-Check starten" im Marker-Tab triggert die vollständige Homing-Pipeline
|
||||||
|
(`POST /api/homing/run`) und zeigt:
|
||||||
|
- SSE-Log im Tab-internen Textfeld
|
||||||
|
- Fortschritt im Viewer via `postMessage({ type: 'homing-state', state })`
|
||||||
|
|
||||||
|
Kein struktureller Unterschied zu `homing.html` — Code-Duplizierung vermeiden
|
||||||
|
durch Extraktion eines gemeinsamen `runHomingStream(sendFn, frameFn)` aus `client.js`.
|
||||||
|
|
||||||
|
> Für Spin-Verifikation ist P3c nötig, damit frische Beobachtungsdaten im Viewer landen.
|
||||||
|
> Ohne P3 (corner0_mm) sieht man trotzdem nur Mittelpunkt-Fehlerlinien, keine Zeiger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P4] Spin automatisch berechnen und korrigieren
|
||||||
|
|
||||||
|
**Status:** ❌ noch nicht implementiert
|
||||||
|
**Voraussetzungen:** P3 (corner0_mm), P3b (Zeiger im Viewer)
|
||||||
|
|
||||||
|
Sobald `corner0_mm` aus Python vorliegt und der Viewer die Zeiger anzeigt (P3b),
|
||||||
|
kann der tatsächliche Spin-Wert rechnerisch bestimmt werden — ohne manuelles Raten.
|
||||||
|
|
||||||
|
**Berechnung im Browser** (in `initMarker()` oder `buildSkeletonFK()`):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modell-Richtung zu Ecke 0 bei spin=0 (im Welt-Frame, via FK):
|
||||||
|
const modelCorner0Dir = /* corner0World − posWorld, normalisiert */;
|
||||||
|
|
||||||
|
// Beobachtete Richtung zu Ecke 0:
|
||||||
|
const obsCorner0Dir = r2vArr(obs.corner0_mm).sub(obsPosW).normalize();
|
||||||
|
|
||||||
|
// Winkel zwischen beiden (um die Marker-Normale):
|
||||||
|
const cross = new THREE.Vector3().crossVectors(modelCorner0Dir, obsCorner0Dir);
|
||||||
|
const sinA = cross.dot(normalWorld); // Vorzeichen = Drehrichtung
|
||||||
|
const cosA = modelCorner0Dir.dot(obsCorner0Dir);
|
||||||
|
const deltaDeg = Math.round(Math.atan2(sinA, cosA) * 180 / Math.PI / 90) * 90;
|
||||||
|
// → rundet auf nächste 90°: 0 / 90 / -90 / 180
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI-Erweiterung im Marker-Tab:**
|
||||||
|
- Tabelle bekommt Spalte „Gemessener Spin" und „Soll-Spin (robot.json)"
|
||||||
|
- Unterschied → Badge „⚠ +90°" → Klick übernimmt die Korrektur direkt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [P5] Marker-Position aus Homing übernehmen
|
||||||
|
|
||||||
|
**Status:** ❌ offen
|
||||||
|
|
||||||
|
Wenn ein Arm-Marker nur grob eingemessen wurde, kann die triangulierte Welt-Position
|
||||||
|
aus dem Homing-Run dazu genutzt werden, die `position` in robot.json zu verfeinern:
|
||||||
|
|
||||||
|
```
|
||||||
|
position_local = inverse_FK(obs_world, current_state)
|
||||||
|
```
|
||||||
|
|
||||||
|
Setzt voraus, dass der Gelenk-Winkel für diesen Link bereits korrekt bestimmt wurde.
|
||||||
|
Iterativ einsetzbar: grobe Startposition → erster Homing → verfeinerte Position → …
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeits-Kette Spin-Verifikation
|
||||||
|
|
||||||
|
```
|
||||||
|
P1 boardViewer rendert spin korrekt → Modell-Viereck zeigt echte Orientierung
|
||||||
|
P3 3b gibt corner0_mm aus → Beobachtungs-Orientierung verfügbar
|
||||||
|
P3b boardViewer zeichnet Orientierungszeiger → Spin-Fehler sichtbar als Winkel
|
||||||
|
P3c Marker-Tab: Homing-Check-Button → frische Daten ohne Tab-Wechsel
|
||||||
|
P4 JS berechnet Δspin, schlägt Korrektur vor → kein manuelles Raten mehr
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateiübersicht
|
||||||
|
|
||||||
|
| Datei | Rolle |
|
||||||
|
|-------|-------|
|
||||||
|
| `public/calibration.html` | Tab-Button „Marker" |
|
||||||
|
| `public/calibration_marker.html` | Panel-HTML (Tabelle, Aktionen, Viewer) |
|
||||||
|
| `public/calibration.js` → `initMarker()` | Frontend-Logik des Tabs |
|
||||||
|
| `server/server.js` → `POST /api/robot/set-arm-marker-spin` | Spin-Endpoint ✅ |
|
||||||
|
| `server/editRobot.js` → `setArmMarkerSpin()` | robot.json schreiben ✅ |
|
||||||
|
| `public/boardViewer.html` → `buildSkeletonFK()` | Spin-Rendering (→ P1) + Zeiger (→ P3b) |
|
||||||
|
| `scripts/3b_corner_marker_poses.py` | corner0_mm ausgeben (→ P3) |
|
||||||
|
| `scripts/robot_1781069752019.json` → `links.*.markers` | Marker-Daten |
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# appRobotBodyTrack
|
|
||||||
|
|
||||||
3D-Body-Tracking für Roboter aus Mehrkamera-ArUco-Bildern.
|
|
||||||
|
|
||||||
**Input**
|
|
||||||
- Bilder: `render_*.png`
|
|
||||||
- Intrinsics: `render_*.npz`
|
|
||||||
- Konfiguration: `robot.json`
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
- Gelenke **R⁷** → `{x, y, z, a, b, c, e}` (mm / Grad)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interfaces
|
|
||||||
|
|
||||||
Eine Logik, drei Zugänge:
|
|
||||||
|
|
||||||
- **Python**
|
|
||||||
- **CLI**
|
|
||||||
- **REST (FastAPI)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quickstart
|
|
||||||
|
|
||||||
### Python
|
|
||||||
|
|
||||||
```python
|
|
||||||
from scripts import estimate_from_dir
|
|
||||||
|
|
||||||
result = estimate_from_dir("data/Scene8", robot_json="robot.json")
|
|
||||||
|
|
||||||
print(result.joints)
|
|
||||||
print(result.confidence)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
python -m scripts data/Scene8 --robot robot.json
|
|
||||||
python -m scripts data/Scene8 --robot robot.json --cameras a,b,d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### REST API
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
resp = requests.post(
|
|
||||||
"http://localhost:8446/v1/estimate",
|
|
||||||
files=[
|
|
||||||
("images", ("render_a.png", open("render_a.png", "rb"))),
|
|
||||||
("intrinsics", ("render_a.npz", open("render_a.npz", "rb"))),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
print(resp.json()["joints"])
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| Endpoint | Methode | Zweck |
|
|
||||||
|----------|--------|------|
|
|
||||||
| `/v1/estimate` | POST | Bilder → Gelenke |
|
|
||||||
| `/v1/health` | GET | Status |
|
|
||||||
| `/v1/config` | GET | aktive Konfiguration |
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"joints": {"x": 50.2, "y": -2.1, "z": 94.8, "a": 20.1},
|
|
||||||
"confidence": {"x": "high", "b": "low"},
|
|
||||||
"residual_rms": 1.45,
|
|
||||||
"n_markers": 56,
|
|
||||||
"processing_ms": 1240
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── scripts/
|
|
||||||
├── config/robot.json
|
|
||||||
├── tests/
|
|
||||||
└── docker-compose.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment (Docker / Portainer)
|
|
||||||
|
|
||||||
**Volume:**
|
|
||||||
```yaml
|
|
||||||
- /opt/approbot/config/robot.json:/config/robot.json:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
**Healthcheck:**
|
|
||||||
```bash
|
|
||||||
curl http://<host>:8446/v1/health
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
Zentrale Datei: **`robot.json`**
|
|
||||||
|
|
||||||
Verwendete Bereiche:
|
|
||||||
- `links`
|
|
||||||
- `pose_estimation`
|
|
||||||
- `vision_config`
|
|
||||||
- `movements`
|
|
||||||
- `units`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack (minimal)
|
|
||||||
|
|
||||||
- numpy
|
|
||||||
- scipy
|
|
||||||
- opencv (aruco)
|
|
||||||
- fastapi + uvicorn
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
|
|
||||||
- **BodyTrack** → Tracking (dynamisch) ✅
|
|
||||||
- **BodyMap** → Modell / Repräsentation
|
|
||||||
- **BodySense** → Wahrnehmung (low-level)
|
|
||||||
255
doc/accessRobotAPI.md
Normal file
255
doc/accessRobotAPI.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# robot.json – Zugriff via appRobotDriver
|
||||||
|
|
||||||
|
> Stand: 2026-06-15
|
||||||
|
> Beschreibt die geplante Umstellung: robot.json kommt vom appRobotDriver, nicht
|
||||||
|
> mehr aus einer lokalen Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ist-Zustand
|
||||||
|
|
||||||
|
`appRobotHoming` liest und schreibt die Roboter-Konfiguration direkt aus einer
|
||||||
|
lokalen Datei:
|
||||||
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Problem:** Der appRobotDriver besitzt die maßgebliche Konfiguration — nicht das
|
||||||
|
Homing-System. Nach einem Neustart könnten Konfiguration und Driver auseinanderlaufen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel-Zustand
|
||||||
|
|
||||||
|
```
|
||||||
|
Startup GET {ROBOT_URL}/api/robot/config → robot.json laden
|
||||||
|
Kalibrierung schreiben → lokal anpassen → POST {ROBOT_URL}/api/robot/config
|
||||||
|
Python-Skripte → weiterhin lokale Datei (Cache) (unverändert)
|
||||||
|
```
|
||||||
|
|
||||||
|
`appRobotDriver` ist die **Single Source of Truth**.
|
||||||
|
`appRobotHoming` hält eine **lokale Kopie** (Cache-Datei) nur für die Dauer eines
|
||||||
|
Laufs — Python-Skripte müssen nicht angepasst werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## appRobotDriver API (Platzhalter)
|
||||||
|
|
||||||
|
Die genaue API ist noch zu klären. Annahmen:
|
||||||
|
|
||||||
|
| Aktion | Endpoint | Body / Antwort |
|
||||||
|
|--------|----------|----------------|
|
||||||
|
| Konfiguration lesen | `GET {ROBOT_URL}/api/robot/config` | → JSON (robot.json-Inhalt) |
|
||||||
|
| Konfiguration schreiben | `POST {ROBOT_URL}/api/robot/config` | Body: JSON (robot.json-Inhalt), → `{ ok: true }` |
|
||||||
|
|
||||||
|
Sobald die echten Endpoints bekannt sind, diese Tabelle und die Implementierung
|
||||||
|
(`server/robotConfig.js`) entsprechend anpassen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementierungsplan
|
||||||
|
|
||||||
|
### Schritt 1 — `server/robotConfig.js` (neu)
|
||||||
|
|
||||||
|
Kapselt den gesamten robot.json-Zugriff. `server.js` und `editRobot.js` importieren
|
||||||
|
nur noch diese Funktionen — kein direktes `fsPromises.readFile` / `writeFile` mehr.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/robotConfig.js (ESM)
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const ROBOT_URL = process.env.ROBOT_URL || '';
|
||||||
|
// Lokale Cache-Datei: bleibt als Fallback und für Python-Skripte
|
||||||
|
const ROBOT_JSON = process.env.ROBOT_JSON
|
||||||
|
|| path.join(__dirname, '..', 'scripts', 'robot_1781069752019.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt robot.json.
|
||||||
|
* Reihenfolge: (1) ROBOT_URL/api/robot/config, (2) lokale Datei als Fallback.
|
||||||
|
* Schreibt das Ergebnis immer in die lokale Cache-Datei (für Python-Skripte).
|
||||||
|
*/
|
||||||
|
export async function fetchRobot() {
|
||||||
|
if (ROBOT_URL) {
|
||||||
|
const res = await fetch(new URL('/api/robot/config', ROBOT_URL));
|
||||||
|
if (!res.ok) throw new Error(`Driver ${res.status}: ${await res.text()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
// Cache für Python-Skripte aktualisieren
|
||||||
|
await fsPromises.writeFile(ROBOT_JSON, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
// Fallback: lokale Datei (Entwicklung ohne Driver)
|
||||||
|
return JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert robot.json.
|
||||||
|
* Schreibt immer in lokale Cache-Datei; sendet zusätzlich an Driver wenn konfiguriert.
|
||||||
|
*/
|
||||||
|
export async function pushRobot(data) {
|
||||||
|
await fsPromises.writeFile(ROBOT_JSON, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
if (ROBOT_URL) {
|
||||||
|
const res = await fetch(new URL('/api/robot/config', ROBOT_URL), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Driver ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pfad zur lokalen Cache-Datei – wird an Python-Skripte als -robot-Argument übergeben. */
|
||||||
|
export const robotCachePath = ROBOT_JSON;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 2 — `server/editRobot.js` anpassen
|
||||||
|
|
||||||
|
`readRobot()` und `writeRobot()` sind die einzigen I/O-Primitiven in `editRobot.js`.
|
||||||
|
Sie müssen auf `fetchRobot()` / `pushRobot()` umgestellt werden.
|
||||||
|
|
||||||
|
**Aktuell:**
|
||||||
|
```javascript
|
||||||
|
async function readRobot(robotPath) {
|
||||||
|
return JSON.parse(await fsPromises.readFile(robotPath, 'utf8'));
|
||||||
|
}
|
||||||
|
async function writeRobot(robotPath, data) {
|
||||||
|
await fsPromises.writeFile(robotPath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Neu:**
|
||||||
|
```javascript
|
||||||
|
import { fetchRobot, pushRobot } from './robotConfig.js';
|
||||||
|
|
||||||
|
async function readRobot(_robotPath) { // _robotPath ignoriert – Quelle ist Driver
|
||||||
|
return fetchRobot();
|
||||||
|
}
|
||||||
|
async function writeRobot(_robotPath, data) {
|
||||||
|
return pushRobot(data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle exportierten Funktionen (`assignByZRange`, `setArmMarkerSpin`, `adoptXAxis`,
|
||||||
|
`setJointOriginYZ`, …) bleiben **unverändert** — sie rufen intern `readRobot` /
|
||||||
|
`writeRobot` auf.
|
||||||
|
|
||||||
|
> Der `robotPath`-Parameter bleibt in den Signaturen erhalten (Kompatibilität),
|
||||||
|
> wird aber ignoriert. Alternativ: alle Aufrufer in `server.js` bereinigen und
|
||||||
|
> Parameter entfernen (Folgeschritt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Schritt 3 — `server/server.js` anpassen
|
||||||
|
|
||||||
|
#### 3a — Python-Skripte erhalten weiterhin die Cache-Datei
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Vorher:
|
||||||
|
import { ROBOT_JSON } from './config.js'; // oder const direkt
|
||||||
|
// '-robot', ROBOT_JSON
|
||||||
|
|
||||||
|
// Nachher:
|
||||||
|
import { robotCachePath } from './robotConfig.js';
|
||||||
|
// '-robot', robotCachePath
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Pipeline (`runBoardPipeline`, `runHoming`) fetcht robot.json **einmal vor dem
|
||||||
|
Lauf** via `fetchRobot()`, um den Cache zu aktualisieren:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { fetchRobot, robotCachePath } from './robotConfig.js';
|
||||||
|
|
||||||
|
async function runBoardPipeline(runDir, send, refSet) {
|
||||||
|
// Cache aktualisieren bevor Python startet
|
||||||
|
await fetchRobot();
|
||||||
|
|
||||||
|
// Python-Skripte erhalten robotCachePath wie bisher
|
||||||
|
const script1Args = [..., '-robot', robotCachePath, ...];
|
||||||
|
// …
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3b — `GET /api/robot` liest via `fetchRobot()`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Vorher:
|
||||||
|
app.get('/api/robot', async (req, res) => {
|
||||||
|
const robot = JSON.parse(await fsPromises.readFile(ROBOT_JSON, 'utf8'));
|
||||||
|
return res.json(robot);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nachher:
|
||||||
|
import { fetchRobot } from './robotConfig.js';
|
||||||
|
|
||||||
|
app.get('/api/robot', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const robot = await fetchRobot();
|
||||||
|
return res.json(robot);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({ error: `Driver nicht erreichbar: ${err.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3c — Kalibrierungs-Endpoints: kein Änderungsbedarf
|
||||||
|
|
||||||
|
Da `editRobot.js` intern `readRobot` / `writeRobot` verwendet und diese umgestellt
|
||||||
|
werden (Schritt 2), propagieren sich alle Kalibrierungs-Schreibvorgänge automatisch
|
||||||
|
zum Driver. Kein Änderungsbedarf in den einzelnen Endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Startup-Verhalten
|
||||||
|
|
||||||
|
Beim Start von `server.js` einmalig robot.json laden und cachen:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server.js – nach HTTPS-Server-Start
|
||||||
|
try {
|
||||||
|
await fetchRobot();
|
||||||
|
console.log('✅ robot.json vom Driver geladen und gecacht.');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠ Driver nicht erreichbar – nutze lokale Datei: ${err.message}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fallback-Verhalten
|
||||||
|
|
||||||
|
| Szenario | Verhalten |
|
||||||
|
|----------|-----------|
|
||||||
|
| `ROBOT_URL` nicht gesetzt | Nur lokale Datei — Entwicklungsmodus, Driver nicht nötig |
|
||||||
|
| Driver beim Start nicht erreichbar | Warnung, lokale Cache-Datei wird verwendet |
|
||||||
|
| Driver während Lauf nicht erreichbar | `pushRobot()` wirft Fehler → Kalibrierungs-Endpoint antwortet 502 |
|
||||||
|
| Python-Skript schlägt fehl | Kein push nötig (Python schreibt nicht in robot.json) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Datei-Übersicht nach Umbau
|
||||||
|
|
||||||
|
| Datei | Rolle |
|
||||||
|
|-------|-------|
|
||||||
|
| `server/robotConfig.js` *(neu)* | `fetchRobot()`, `pushRobot()`, `robotCachePath` |
|
||||||
|
| `server/editRobot.js` | `readRobot` / `writeRobot` delegieren an `robotConfig.js` |
|
||||||
|
| `server/server.js` | importiert `robotCachePath` statt lokalem `ROBOT_JSON`; ruft `fetchRobot()` vor Pipelines |
|
||||||
|
| `scripts/robot_1781069752019.json` | Bleibt als lokale Cache-Datei; **nicht** mehr primäre Quelle der Wahrheit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Fragen
|
||||||
|
|
||||||
|
- [ ] Genaue Endpoints des appRobotDriver für GET / POST robot.json bestätigen
|
||||||
|
- [ ] Soll der Driver eine Versions-/Konflikterkennung haben (z.B. ETag / `updatedAt`)?
|
||||||
|
- [ ] Soll `pushRobot()` bei Driver-Fehler still auf lokal-only zurückfallen, oder hard fail?
|
||||||
|
- [ ] Authentifizierung zwischen appRobotHoming und appRobotDriver nötig?
|
||||||
@@ -338,18 +338,34 @@ function buildSkeletonFK(robot, angles) {
|
|||||||
gSkeleton.add(makeSphere(jointW, 0.004, 0xc8cdd8));
|
gSkeleton.add(makeSphere(jointW, 0.004, 0xc8cdd8));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Arm-Marker zeichnen (Modellposition via FK, als orientiertes Quadrat)
|
// 4. Arm-Marker zeichnen (Modellposition via FK, orientiertes Quadrat + spin)
|
||||||
if (link.markers?.length > 0) {
|
if (link.markers?.length > 0) {
|
||||||
const col = LINK_COLORS[linkName] ?? 0xffffff;
|
const col = LINK_COLORS[linkName] ?? 0xffffff;
|
||||||
for (const m of link.markers) {
|
for (const m of link.markers) {
|
||||||
if (!m.position) continue;
|
if (!m.position) continue;
|
||||||
const [lx, ly, lz] = m.position;
|
const [lx, ly, lz] = m.position;
|
||||||
const posWorld = new THREE.Vector3(lx * S, lz * S, -ly * S).applyMatrix4(childFrame);
|
const posWorld = new THREE.Vector3(lx * S, lz * S, -ly * S).applyMatrix4(childFrame);
|
||||||
const markerSizeM = (m.size ?? 25) * S;
|
const markerSizeM = (m.size ?? 25) * S;
|
||||||
const [nx, ny, nz] = m.normal ?? [0, 0, 1];
|
const [nx, ny, nz] = m.normal ?? [0, 0, 1];
|
||||||
const normalWorld = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame);
|
const normalW = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame).normalize();
|
||||||
gArmMarkers.add(makeMarkerSquareOriented(posWorld, normalWorld, markerSizeM, col));
|
|
||||||
|
// P1: Quadrat mit spin-Rotation (um die Marker-Normale in Welt-Koordinaten)
|
||||||
|
const markerMesh = makeMarkerSquareOriented(posWorld, normalW, markerSizeM, col);
|
||||||
|
const spinRad = ((m.spin ?? 0) * Math.PI) / 180;
|
||||||
|
if (Math.abs(spinRad) > 1e-6) {
|
||||||
|
markerMesh.quaternion.premultiply(
|
||||||
|
new THREE.Quaternion().setFromAxisAngle(normalW, spinRad)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gArmMarkers.add(markerMesh);
|
||||||
gArmMarkers.add(makeSphere(posWorld, 0.0006, col));
|
gArmMarkers.add(makeSphere(posWorld, 0.0006, col));
|
||||||
|
|
||||||
|
// P3b (Modell-Seite): Orientierungszeiger zur Ecke 0 (top-left bei spin=0)
|
||||||
|
// markerMesh.quaternion kodiert bereits Q_normal ∘ Q_spin
|
||||||
|
const ptrDir = new THREE.Vector3(1, 1, 0).normalize().applyQuaternion(markerMesh.quaternion);
|
||||||
|
const corner0W = posWorld.clone().add(ptrDir.multiplyScalar(markerSizeM * Math.SQRT1_2));
|
||||||
|
gArmMarkers.add(makeLine(posWorld, corner0W, col, 0.9));
|
||||||
|
gArmMarkers.add(makeSphere(corner0W, 0.0008, col));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<button class="tab-btn" data-tab="board" data-src="/calibration_board.html">Board</button>
|
<button class="tab-btn" data-tab="board" data-src="/calibration_board.html">Board</button>
|
||||||
<button class="tab-btn" data-tab="robot-x-axis" data-src="/calibration_xaxis.html">Robot X Axis</button>
|
<button class="tab-btn" data-tab="robot-x-axis" data-src="/calibration_xaxis.html">Robot X Axis</button>
|
||||||
<button class="tab-btn" data-tab="arm1" data-src="/calibration_arm.html">Arm1 – Y</button>
|
<button class="tab-btn" data-tab="arm1" data-src="/calibration_arm.html">Arm1 – Y</button>
|
||||||
|
<button class="tab-btn" data-tab="marker" data-src="/calibration_marker.html">Marker</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- CONTENT (Panels werden lazy per fetch befüllt) -->
|
<!-- CONTENT (Panels werden lazy per fetch befüllt) -->
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<div class="tab-panel" id="tab-board"></div>
|
<div class="tab-panel" id="tab-board"></div>
|
||||||
<div class="tab-panel" id="tab-robot-x-axis"></div>
|
<div class="tab-panel" id="tab-robot-x-axis"></div>
|
||||||
<div class="tab-panel" id="tab-arm1"></div>
|
<div class="tab-panel" id="tab-arm1"></div>
|
||||||
|
<div class="tab-panel" id="tab-marker"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /.calib-body -->
|
</div><!-- /.calib-body -->
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async function loadPanel(tab, src) {
|
|||||||
else if (tab === 'board') initBoard();
|
else if (tab === 'board') initBoard();
|
||||||
else if (tab === 'robot-x-axis') initXAxis();
|
else if (tab === 'robot-x-axis') initXAxis();
|
||||||
else if (tab === 'arm1') initArm('arm1');
|
else if (tab === 'arm1') initArm('arm1');
|
||||||
|
else if (tab === 'marker') initMarker();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('tab-' + tab).innerHTML =
|
document.getElementById('tab-' + tab).innerHTML =
|
||||||
@@ -950,3 +951,163 @@ function initBoard() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tab: Marker ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initMarker() {
|
||||||
|
const logEl = document.getElementById('log-marker');
|
||||||
|
const tableWrap = document.getElementById('marker-table-wrap');
|
||||||
|
const linkSel = document.getElementById('marker-action-link');
|
||||||
|
const idSel = document.getElementById('marker-action-id');
|
||||||
|
const spinLabel = document.getElementById('marker-spin-current');
|
||||||
|
const resultEl = document.getElementById('marker-action-result');
|
||||||
|
const frameEl = document.getElementById('marker-viewer-frame');
|
||||||
|
|
||||||
|
const ARM_LINKS = ['Arm1', 'Ellbow', 'Arm2', 'Hand', 'Palm', 'FingerA', 'FingerB'];
|
||||||
|
|
||||||
|
let _robot = null;
|
||||||
|
|
||||||
|
function logM(msg) {
|
||||||
|
const ts = new Date().toLocaleTimeString('de-CH');
|
||||||
|
logEl.value += `[${ts}] ${msg}\n`;
|
||||||
|
logEl.scrollTop = logEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker-Tabelle rendern ────────────────────────────────────────────────
|
||||||
|
function renderTable(robot) {
|
||||||
|
if (!tableWrap) return;
|
||||||
|
const links = robot?.links ?? {};
|
||||||
|
const th = (a) => `style="text-align:${a};padding:3px 8px;border-bottom:1px solid #2a2d35;white-space:nowrap;background:#1e293b;color:#555b6e;font-weight:normal"`;
|
||||||
|
const td = (a, x = '') => `style="padding:2px 8px;border-bottom:1px solid #111418;text-align:${a};white-space:nowrap;${x}"`;
|
||||||
|
|
||||||
|
let rows = '';
|
||||||
|
let total = 0;
|
||||||
|
for (const linkName of ARM_LINKS) {
|
||||||
|
const markers = links[linkName]?.markers ?? [];
|
||||||
|
for (const m of markers) {
|
||||||
|
total++;
|
||||||
|
const pos = m.position ? m.position.map(v => Number(v).toFixed(1)).join(', ') : '–';
|
||||||
|
const norm = m.normal ? m.normal.map(v => Number(v).toFixed(2)).join(', ') : '–';
|
||||||
|
rows += `<tr>
|
||||||
|
<td ${td('left', 'color:#4a9eff')}>${linkName}</td>
|
||||||
|
<td ${td('right')}>${m.id}</td>
|
||||||
|
<td ${td('left', 'color:#888')}>${m.name ?? '–'}</td>
|
||||||
|
<td ${td('right')}>${pos}</td>
|
||||||
|
<td ${td('right', 'color:#aaa')}>${norm}</td>
|
||||||
|
<td ${td('right')}>${m.size ?? '–'}</td>
|
||||||
|
<td ${td('right', 'color:#f0a500;font-weight:bold')}>${m.spin ?? 0}°</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
tableWrap.innerHTML = '<p style="font-size:12px;color:var(--muted)">Keine Arm-Marker in robot.json eingetragen.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableWrap.innerHTML = `
|
||||||
|
<p style="font-size:10px;color:#555b6e;margin-bottom:4px">${total} Marker in Arm-Links</p>
|
||||||
|
<table style="border-collapse:collapse;font-size:11px;font-family:inherit;width:100%">
|
||||||
|
<thead><tr>
|
||||||
|
<th ${th('left')}>Link</th>
|
||||||
|
<th ${th('right')}>ID</th>
|
||||||
|
<th ${th('left')}>Name</th>
|
||||||
|
<th ${th('right')}>Position [x,y,z] mm</th>
|
||||||
|
<th ${th('right')}>Normal [nx,ny,nz]</th>
|
||||||
|
<th ${th('right')}>Size mm</th>
|
||||||
|
<th ${th('right')}>Spin</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marker-Dropdown für gewählten Link befüllen ───────────────────────────
|
||||||
|
function updateMarkerDropdown() {
|
||||||
|
if (!idSel || !_robot) return;
|
||||||
|
const linkName = linkSel?.value;
|
||||||
|
const markers = _robot.links?.[linkName]?.markers ?? [];
|
||||||
|
const prev = idSel.value;
|
||||||
|
idSel.innerHTML = '<option value="">– wählen –</option>' +
|
||||||
|
markers.map(m => `<option value="${m.id}">${m.id}${m.name ? ' – ' + m.name : ''}</option>`).join('');
|
||||||
|
if (markers.some(m => String(m.id) === prev)) idSel.value = prev;
|
||||||
|
updateSpinLabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpinLabel() {
|
||||||
|
if (!spinLabel || !_robot) { if (spinLabel) spinLabel.textContent = '–'; return; }
|
||||||
|
const linkName = linkSel?.value;
|
||||||
|
const markerId = idSel?.value;
|
||||||
|
if (!markerId) { spinLabel.textContent = '–'; return; }
|
||||||
|
const markers = _robot.links?.[linkName]?.markers ?? [];
|
||||||
|
const m = markers.find(mm => String(mm.id) === String(markerId));
|
||||||
|
spinLabel.textContent = m ? `Aktuell: spin = ${m.spin ?? 0}°` : '–';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Robot laden ───────────────────────────────────────────────────────────
|
||||||
|
async function loadRobot() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/robot');
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
_robot = await r.json();
|
||||||
|
renderTable(_robot);
|
||||||
|
updateMarkerDropdown();
|
||||||
|
} catch (err) {
|
||||||
|
if (tableWrap) tableWrap.innerHTML = `<p style="color:#f87171;font-size:12px">Fehler: ${err}</p>`;
|
||||||
|
logM(`❌ robot.json konnte nicht geladen werden: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spin-Aktion ausführen ─────────────────────────────────────────────────
|
||||||
|
async function applySpin(delta) {
|
||||||
|
if (!resultEl) return;
|
||||||
|
const linkName = linkSel?.value;
|
||||||
|
const markerId = idSel?.value;
|
||||||
|
if (!markerId) {
|
||||||
|
resultEl.innerHTML = '<span style="color:#f87171">⚠ Bitte zuerst einen Marker wählen.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const markers = _robot?.links?.[linkName]?.markers ?? [];
|
||||||
|
const current = markers.find(m => String(m.id) === String(markerId));
|
||||||
|
const oldSpin = current?.spin ?? 0;
|
||||||
|
const newSpin = ((oldSpin + delta) % 360 + 360) % 360;
|
||||||
|
|
||||||
|
resultEl.innerHTML = '<span style="color:#555b6e">Speichern …</span>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/robot/set-arm-marker-spin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ linkName, markerId: Number(markerId), spin: newSpin }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok || !data.changed) {
|
||||||
|
resultEl.innerHTML = `<span style="color:#f87171">❌ ${data.error ?? `HTTP ${r.status}`}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultEl.innerHTML =
|
||||||
|
`<span style="color:#22c55e">✅ ${linkName} #${markerId}: spin ${data.oldSpin}° → ${data.newSpin}°</span>`;
|
||||||
|
logM(`Spin ${linkName}#${markerId}: ${data.oldSpin}° → ${data.newSpin}°`);
|
||||||
|
|
||||||
|
// Lokales Modell aktualisieren
|
||||||
|
if (current) current.spin = data.newSpin;
|
||||||
|
updateSpinLabel();
|
||||||
|
renderTable(_robot);
|
||||||
|
|
||||||
|
// Viewer neu laden
|
||||||
|
if (frameEl?.contentWindow) frameEl.contentWindow.postMessage({ type: 'reload' }, '*');
|
||||||
|
} catch (err) {
|
||||||
|
resultEl.innerHTML = `<span style="color:#f87171">❌ ${err}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event-Listener ────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('btn-marker-reload')?.addEventListener('click', () => loadRobot());
|
||||||
|
linkSel?.addEventListener('change', () => updateMarkerDropdown());
|
||||||
|
idSel?.addEventListener('change', () => updateSpinLabel());
|
||||||
|
|
||||||
|
document.getElementById('btn-spin-minus90')?.addEventListener('click', () => applySpin(-90));
|
||||||
|
document.getElementById('btn-spin-plus90')?.addEventListener('click', () => applySpin(+90));
|
||||||
|
document.getElementById('btn-spin-180')?.addEventListener('click', () => applySpin(+180));
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadRobot();
|
||||||
|
}
|
||||||
|
|||||||
96
public/calibration_marker.html
Normal file
96
public/calibration_marker.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<div class="sections">
|
||||||
|
|
||||||
|
<!-- ── Aktuelle Marker ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Aktuelle Marker <span class="status-badge open">aus robot.json</span></h2>
|
||||||
|
<p style="font-size:12px;color:var(--muted);margin-top:8px;margin-bottom:10px">
|
||||||
|
Arm-Marker aller Links (Board-Marker ausgeblendet). Spin = Drehung um die Marker-Normale in Grad.
|
||||||
|
</p>
|
||||||
|
<div id="marker-table-wrap">
|
||||||
|
<p style="font-size:12px;color:var(--muted)">(wird geladen …)</p>
|
||||||
|
</div>
|
||||||
|
<div class="controls" style="margin-top:12px">
|
||||||
|
<button id="btn-marker-reload">Neu laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Aktionen ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Aktionen</h2>
|
||||||
|
|
||||||
|
<!-- Auswahl Link / Marker -->
|
||||||
|
<div style="margin-top:14px;display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:12px;color:var(--text)">
|
||||||
|
<label>Link
|
||||||
|
<select id="marker-action-link"
|
||||||
|
style="margin-left:6px;background:#1e293b;border:1px solid #334155;color:#c8cdd8;border-radius:3px;padding:3px 8px;font:inherit;font-size:12px;cursor:pointer">
|
||||||
|
<option value="Arm1">Arm1</option>
|
||||||
|
<option value="Ellbow">Ellbow</option>
|
||||||
|
<option value="Arm2">Arm2</option>
|
||||||
|
<option value="Hand">Hand</option>
|
||||||
|
<option value="Palm">Palm</option>
|
||||||
|
<option value="FingerA">FingerA</option>
|
||||||
|
<option value="FingerB">FingerB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Marker-ID
|
||||||
|
<select id="marker-action-id"
|
||||||
|
style="margin-left:6px;background:#1e293b;border:1px solid #334155;color:#c8cdd8;border-radius:3px;padding:3px 8px;font:inherit;font-size:12px;cursor:pointer">
|
||||||
|
<option value="">– wählen –</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span id="marker-spin-current" style="color:var(--muted);font-size:11px">–</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spin-Aktionen -->
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<p style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">
|
||||||
|
Spin (Drehung um Marker-Normale)
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
|
||||||
|
<button id="btn-spin-minus90"
|
||||||
|
style="background:#1e293b;color:#c8cdd8;border:1px solid #334155;border-radius:3px;padding:5px 14px;cursor:pointer;font:inherit;font-size:12px">
|
||||||
|
−90°
|
||||||
|
</button>
|
||||||
|
<button id="btn-spin-plus90"
|
||||||
|
style="background:#1e293b;color:#c8cdd8;border:1px solid #334155;border-radius:3px;padding:5px 14px;cursor:pointer;font:inherit;font-size:12px">
|
||||||
|
+90°
|
||||||
|
</button>
|
||||||
|
<button id="btn-spin-180"
|
||||||
|
style="background:#1e293b;color:#c8cdd8;border:1px solid #334155;border-radius:3px;padding:5px 14px;cursor:pointer;font:inherit;font-size:12px">
|
||||||
|
180°
|
||||||
|
</button>
|
||||||
|
<span style="font-size:10px;color:var(--muted)">→ wird sofort in robot.json gespeichert und im Viewer aktualisiert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ergebnis -->
|
||||||
|
<div id="marker-action-result" style="margin-top:10px;font-size:11px;min-height:18px;color:var(--muted)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Log ──────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Log</h2>
|
||||||
|
<textarea id="log-marker" readonly placeholder="(Aktionen erscheinen hier)"
|
||||||
|
style="height:100px"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Viewer ───────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="section full">
|
||||||
|
<h2>Viewer</h2>
|
||||||
|
<p style="font-size:12px;color:var(--muted);margin-bottom:10px">
|
||||||
|
Zeigt das Roboter-Modell mit den Arm-Markern in der aktuellen robot.json-Konfiguration.
|
||||||
|
Sind Homing-Messwerte vorhanden (aus letztem Homing-Run), werden auch die beobachteten Marker
|
||||||
|
als Kugeln und die Abweichungs-Linien dargestellt.
|
||||||
|
Nach einer Spin-Änderung wird der Viewer automatisch neu geladen.
|
||||||
|
</p>
|
||||||
|
<iframe
|
||||||
|
id="marker-viewer-frame"
|
||||||
|
src="/boardViewer.html?mode=homing"
|
||||||
|
style="width:100%;height:740px;border:1px solid #334155;border-radius:6px;background:#0d0f13;display:block"
|
||||||
|
title="Marker-Viewer"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
* überschrieben; bei Bedarf Backup-Strategie ergänzen).
|
* überschrieben; bei Bedarf Backup-Strategie ergänzen).
|
||||||
*/
|
*/
|
||||||
import fsPromises from 'fs/promises';
|
import fsPromises from 'fs/promises';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const { normalizeSpinDeg } = createRequire(import.meta.url)('./spinNormalize.cjs');
|
||||||
|
|
||||||
// ── I/O ───────────────────────────────────────────────────────────────────────
|
// ── I/O ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -609,3 +611,33 @@ export async function setJointOriginYZ(robotPath, { linkName, y, z }) {
|
|||||||
newOrigin: [...joint.origin],
|
newOrigin: [...joint.origin],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Aktion 8: Arm-Marker Spin setzen ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den `spin`-Wert eines Arm-Markers in robot.json.
|
||||||
|
* Body: { linkName, markerId, spin }
|
||||||
|
* spin: absolute Gradzahl (0 / 90 / 180 / 270) oder relativer Delta (+90, -90, +180)
|
||||||
|
* — hier wird immer die *neue absolute* Gradzahl erwartet.
|
||||||
|
*
|
||||||
|
* @returns {{ changed, linkName, markerId, oldSpin, newSpin } | { changed: false, error }}
|
||||||
|
*/
|
||||||
|
export async function setArmMarkerSpin(robotPath, { linkName, markerId, spin }) {
|
||||||
|
const robot = await readRobot(robotPath);
|
||||||
|
const linkData = robot.links?.[linkName];
|
||||||
|
if (!linkData) return { changed: false, error: `Link '${linkName}' nicht gefunden.` };
|
||||||
|
|
||||||
|
const markers = linkData.markers ?? [];
|
||||||
|
const idx = markers.findIndex(m => Number(m.id) === Number(markerId));
|
||||||
|
if (idx === -1) {
|
||||||
|
return { changed: false, error: `Marker ${markerId} in Link '${linkName}' nicht gefunden.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldSpin = markers[idx].spin ?? 0;
|
||||||
|
const newSpin = normalizeSpinDeg(spin);
|
||||||
|
markers[idx].spin = newSpin;
|
||||||
|
|
||||||
|
await writeRobot(robotPath, robot);
|
||||||
|
|
||||||
|
return { changed: true, linkName, markerId: Number(markerId), oldSpin, newSpin };
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import process from 'process';
|
import process from 'process';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { WebcamClient } from './webcamClient.js';
|
import { WebcamClient } from './webcamClient.js';
|
||||||
import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId, adoptXAxis, assignFixedMarkersToLink, setJointOriginYZ } from './editRobot.js';
|
import { assignByZRange, removeMarkerAssignment, alignSetToMeasured, assignMarkerId, adoptXAxis, assignFixedMarkersToLink, setJointOriginYZ, setArmMarkerSpin } from './editRobot.js';
|
||||||
import { runHoming } from './homingOrchestrator.js';
|
import { runHoming } from './homingOrchestrator.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -1096,6 +1096,27 @@ app.post('/api/robot/set-joint-origin', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/robot/set-arm-marker-spin
|
||||||
|
* Setzt den `spin`-Wert eines Arm-Markers in robot.json.
|
||||||
|
* Body: { linkName: string, markerId: number, spin: number }
|
||||||
|
*/
|
||||||
|
app.post('/api/robot/set-arm-marker-spin', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { linkName, markerId, spin } = req.body ?? {};
|
||||||
|
if (!linkName) return res.status(400).json({ error: '"linkName" muss angegeben werden.' });
|
||||||
|
if (markerId == null) return res.status(400).json({ error: '"markerId" muss angegeben werden.' });
|
||||||
|
if (!Number.isFinite(Number(spin))) return res.status(400).json({ error: '"spin" muss eine Zahl sein.' });
|
||||||
|
const result = await setArmMarkerSpin(ROBOT_JSON, { linkName, markerId, spin: Number(spin) });
|
||||||
|
if (!result.changed) return res.status(400).json({ error: result.error });
|
||||||
|
console.log(`robot/set-arm-marker-spin ${linkName}#${markerId}: ${result.oldSpin}° → ${result.newSpin}°`);
|
||||||
|
return res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('robot/set-arm-marker-spin error:', err);
|
||||||
|
return res.status(500).json({ error: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/calibration/upload-npz
|
* POST /api/calibration/upload-npz
|
||||||
* Liest {camera}_calibration.npz aus der aktuellen Session und
|
* Liest {camera}_calibration.npz aus der aktuellen Session und
|
||||||
|
|||||||
18
server/spinNormalize.cjs
Normal file
18
server/spinNormalize.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* spinNormalize.cjs
|
||||||
|
* Reine Spin-Normalisierung: [0, 360) ohne I/O.
|
||||||
|
* CommonJS damit Jest (CJS) und ESM-Server dieselbe Funktion nutzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisiert einen Spin-Winkel auf [0, 360).
|
||||||
|
* Negative Werte und Werte ≥ 360 werden korrekt behandelt.
|
||||||
|
*
|
||||||
|
* @param {number|string} spin Spin in Grad (kann negativ oder > 360 sein)
|
||||||
|
* @returns {number} Spin in Grad, 0 ≤ result < 360
|
||||||
|
*/
|
||||||
|
function normalizeSpinDeg(spin) {
|
||||||
|
return ((Number(spin) % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeSpinDeg };
|
||||||
@@ -23,7 +23,7 @@ describe("calculate() row223 Ellbow-Rotation Tests", () => {
|
|||||||
|
|
||||||
test('berechnet y-Durchschnitt für Base / Arm1 / Joint1', async () => {
|
test('berechnet y-Durchschnitt für Base / Arm1 / Joint1', async () => {
|
||||||
const markersPath = path.resolve('./test/snapshots/snapshot_video0_1778407171886_two_cam.json');
|
const markersPath = path.resolve('./test/snapshots/snapshot_video0_1778407171886_two_cam.json');
|
||||||
const robotPath = path.resolve('./public/robot.json');
|
const robotPath = path.resolve('./test/fixtures/robot_legacy.json');
|
||||||
|
|
||||||
const foundMarkers = JSON.parse(fs.readFileSync(markersPath, 'utf8'));
|
const foundMarkers = JSON.parse(fs.readFileSync(markersPath, 'utf8'));
|
||||||
const jsonRobot = JSON.parse(fs.readFileSync(robotPath, 'utf8'));
|
const jsonRobot = JSON.parse(fs.readFileSync(robotPath, 'utf8'));
|
||||||
@@ -40,7 +40,7 @@ describe("calculate() row223 Ellbow-Rotation Tests", () => {
|
|||||||
|
|
||||||
test('berechnet X-Durchschnitt für Base / Arm1 / Joint1', async () => {
|
test('berechnet X-Durchschnitt für Base / Arm1 / Joint1', async () => {
|
||||||
const markersPath = path.resolve('./test/snapshots/snapshot_video0_1775406055428_two_cam.json');
|
const markersPath = path.resolve('./test/snapshots/snapshot_video0_1775406055428_two_cam.json');
|
||||||
const robotPath = path.resolve('./public/robot.json');
|
const robotPath = path.resolve('./test/fixtures/robot_legacy.json');
|
||||||
|
|
||||||
const foundMarkers = JSON.parse(fs.readFileSync(markersPath, 'utf8'));
|
const foundMarkers = JSON.parse(fs.readFileSync(markersPath, 'utf8'));
|
||||||
const jsonRobot = JSON.parse(fs.readFileSync(robotPath, 'utf8'));
|
const jsonRobot = JSON.parse(fs.readFileSync(robotPath, 'utf8'));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { calculate } = require('../public/calculateAngles');
|
|||||||
describe('calculateAngles minimal test', () => {
|
describe('calculateAngles minimal test', () => {
|
||||||
it('should run calculate() with loaded JSON files', async () => {
|
it('should run calculate() with loaded JSON files', async () => {
|
||||||
// Pfade auflösen
|
// Pfade auflösen
|
||||||
const robotPath = path.resolve(__dirname, '../public/robot.json');
|
const robotPath = path.resolve(__dirname, '../test/fixtures/robot_legacy.json');
|
||||||
const snapshotPath = path.resolve(
|
const snapshotPath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../test/snapshots/snapshot_video0_1778845508432_two_cam.json'
|
'../test/snapshots/snapshot_video0_1778845508432_two_cam.json'
|
||||||
|
|||||||
39
test/fixtures/robot_legacy.json
vendored
Normal file
39
test/fixtures/robot_legacy.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"recognized":{"x":null, "y":null, "z": null, "a":null, "b":null, "c":null, "e": null},
|
||||||
|
"Elements":["Board","Base","Arm1","Joint1","Arm2","Finger1","Finger2"],
|
||||||
|
"ElementLength":{"Arm1":250, "Arm2":250, "Finger1":100, "Finger2":100},
|
||||||
|
"Joints":{
|
||||||
|
"jointA":{"name":"Slider", "type":"lninear", "axis":[1,0,0],"parent":"Board","child":"Base"},
|
||||||
|
"jointB":{"name":"Shoulder","type":"revolute","axis":[1,0,0],"parent":"Base","child":"Arm1","origin":[-89.5, 115, 52], "originSource":[null, "229_198_Foto_5_2026", "Fuson"]},
|
||||||
|
"jointC":{"name":"EllbowLift","type":"revolute","axis":[1,0,0],"parent":"Arm1","child":"Joint1", "origin":[null, null, null]},
|
||||||
|
"jointD":{"name":"EllbowTwist","type":"revolute","axis":[0,1,0],"parent":"Joint1","child":"Arm2", "origin":[null, null, null]}
|
||||||
|
},
|
||||||
|
"MarkerType":"DICT_4X4_250",
|
||||||
|
"Marker":[
|
||||||
|
{"id":205,"on":"Board","position":[0.80, -0.090, 0.0]},
|
||||||
|
{"id":207,"on":"Board","position":[0.80, 0.0, 0.0]},
|
||||||
|
{"id":208,"on":"Board","position":[0.50, -0.090, 0.0]},
|
||||||
|
{"id":210,"on":"Board","position":[0.00, 0.0, 0.0]},
|
||||||
|
{"id":211,"on":"Board","position":[0.20, 0.0, 0.0]},
|
||||||
|
{"id":214,"on":"Board","position":[0.40, 0.0, 0.0]},
|
||||||
|
{"id":215,"on":"Board","position":[0.20, -0.090, 0.0]},
|
||||||
|
{"id":217,"on":"Board","position":[0.60, -0.090, 0.0]},
|
||||||
|
|
||||||
|
{"id":200,"on":"Base","relPos":[-163.8, 6.5, 55], "relPosSource":["226_FotoAverage_5_2026",null,null]},
|
||||||
|
{"id":201,"on":"Base","relPos":[-164.8, 97.5, 74.5], "relPosSource":["226_FotoAverage_5_2026",null,null]},
|
||||||
|
{"id":204,"on":"Base","relPos":[-158.5,152.5,111]},
|
||||||
|
|
||||||
|
{"id":198,"on":"Arm1","relPos":[-89.5,-160, 35],"relPosSource":["Fusion",null,null]},
|
||||||
|
{"id":229,"on":"Arm1","relPos":[-89.5,-250, 35],"relPosSource":["Fusion",null,null]},
|
||||||
|
{"id":242,"on":"Arm1","relPos":[-89.5,-250,-35]},
|
||||||
|
{"id":243,"on":"Arm1","relPos":[-89.5,-285, 0]},
|
||||||
|
|
||||||
|
{"id":222,"on":"Joint1", "relPos":[0,0, -35]},
|
||||||
|
{"id":226,"on":"Joint1", "relPos":[0,0, 35]},
|
||||||
|
|
||||||
|
{"id":228,"on":"Arm2", "relPos":[-24.75, 112, 24.75], "relPosSource":["Fusion","Fusion","Fusion"]},
|
||||||
|
{"id":223,"on":"Arm2", "relPos":[-28.67,112,-20.08], "relPosSource":["Fusion","Fusion","Fusion"]},
|
||||||
|
{"id":218,"on":"Arm2", "relPos":[35,112,0], "relPosSource":["Fusion","Fusion","Fusion"]},
|
||||||
|
{"id":219,"on":"Arm2", "relPos":[35,219,0], "relPosSource":["Fusion","Fusion","Fusion"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ describe("calculate() row223 Ellbow-Rotation Tests", () => {
|
|||||||
const markersPath2 = path.resolve('./test/snapshots/snapshot_video0_1778406621349_two_cam.json');
|
const markersPath2 = path.resolve('./test/snapshots/snapshot_video0_1778406621349_two_cam.json');
|
||||||
const markersPath3 = path.resolve('./test/snapshots/snapshot_video0_1778407153025_two_cam.json');
|
const markersPath3 = path.resolve('./test/snapshots/snapshot_video0_1778407153025_two_cam.json');
|
||||||
const markersPath4 = path.resolve('./test/snapshots/snapshot_video0_1778407171886_two_cam.json');
|
const markersPath4 = path.resolve('./test/snapshots/snapshot_video0_1778407171886_two_cam.json');
|
||||||
const robotPath = path.resolve('./public/robot.json');
|
const robotPath = path.resolve('./test/fixtures/robot_legacy.json');
|
||||||
|
|
||||||
const foundMarkers1 = JSON.parse(fs.readFileSync(markersPath1, 'utf8'));
|
const foundMarkers1 = JSON.parse(fs.readFileSync(markersPath1, 'utf8'));
|
||||||
const foundMarkers2 = JSON.parse(fs.readFileSync(markersPath2, 'utf8'));
|
const foundMarkers2 = JSON.parse(fs.readFileSync(markersPath2, 'utf8'));
|
||||||
|
|||||||
46
test/spinNormalize.test.js
Normal file
46
test/spinNormalize.test.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* spinNormalize.test.js
|
||||||
|
* Unit-Tests für server/spinNormalize.cjs
|
||||||
|
*
|
||||||
|
* Sichert ab, dass der Spin-Normalisierer alle Randfälle korrekt behandelt,
|
||||||
|
* insbesondere negative Werte (z.B. 0 − 90 = −90 → 270).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { normalizeSpinDeg } = require('../server/spinNormalize.cjs');
|
||||||
|
|
||||||
|
describe('normalizeSpinDeg', () => {
|
||||||
|
test('Standardwerte 0 / 90 / 180 / 270 bleiben unverändert', () => {
|
||||||
|
expect(normalizeSpinDeg(0)).toBe(0);
|
||||||
|
expect(normalizeSpinDeg(90)).toBe(90);
|
||||||
|
expect(normalizeSpinDeg(180)).toBe(180);
|
||||||
|
expect(normalizeSpinDeg(270)).toBe(270);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wert 360 wird auf 0 normalisiert', () => {
|
||||||
|
expect(normalizeSpinDeg(360)).toBe(0);
|
||||||
|
expect(normalizeSpinDeg(720)).toBe(0);
|
||||||
|
expect(normalizeSpinDeg(450)).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Negative Werte werden korrekt umgerechnet', () => {
|
||||||
|
expect(normalizeSpinDeg(-90)).toBe(270); // 0 − 90 → 270
|
||||||
|
expect(normalizeSpinDeg(-180)).toBe(180);
|
||||||
|
expect(normalizeSpinDeg(-270)).toBe(90);
|
||||||
|
expect(normalizeSpinDeg(-360)).toBe(0);
|
||||||
|
expect(normalizeSpinDeg(-1)).toBe(359);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Strings werden als Zahlen interpretiert', () => {
|
||||||
|
expect(normalizeSpinDeg('90')).toBe(90);
|
||||||
|
expect(normalizeSpinDeg('-90')).toBe(270);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ergebnis liegt immer in [0, 360)', () => {
|
||||||
|
const inputs = [-720, -359, -1, 0, 1, 89, 90, 179, 270, 359, 360, 450, 720, 1080];
|
||||||
|
for (const v of inputs) {
|
||||||
|
const r = normalizeSpinDeg(v);
|
||||||
|
expect(r).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(r).toBeLessThan(360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user