diff --git a/README.md b/README.md index be6e8ed..bd86b34 100755 --- a/README.md +++ b/README.md @@ -1,105 +1,152 @@ # appRobotHoming -`appRobotHoming` ist die browserbasierte Bedienoberfläche für die WebCam-gestützte -Ermittlung der Roboterpose. Das Frontend bleibt der Einstieg; die eigentliche -Bildverarbeitung läuft hinter der Firewall auf eigenen Services (WebCam, -BodyTracker), die der Homing-Backend als schlanker Proxy anspricht. +Browserbasierte Bedienoberfläche für das kameragestützte **Homing** und die +**Kalibrierung** eines Roboterarms. Das Frontend kommuniziert mit einem Node.js-Backend +(BFF-Proxy), das Kamera-Bilder, ArUco-Erkennung und Gelenk-Winkel-Schätzung +über Python-Skripte orchestriert. -## Architektur im Überblick +## Architektur ``` -Browser ──HTTPS──▶ Reverse-Proxy ──HTTPS/WSS──▶ appRobotHoming-Backend -(statisches UI) (öffentliches TLS) (server/server.js, Port 2093) - │ - intern (hinter der Firewall, HTTP): - ├──▶ WebCam-Service (Bilder) - ├──▶ BodyTracker-Service (Pose) - └──▶ … weitere Schritte (später) +Browser ──HTTPS──▶ Reverse-Proxy ──HTTPS──▶ appRobotHoming-Backend (Port 2093) + │ server/server.js + intern (HTTP): │ + ├──▶ WebCam-Service (Bilder, NPZ) + └──▶ Robot-Driver (POST /api/state) ``` -- **Frontend (`public/`)** – statische Seite: zeigt Infos, Buttons und die - Rückmeldungen (Result als JSON + Tree-View, Snapshot-Tabelle, Bilder). Kein - 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**. +**Frontend (`public/`):** statische Seiten — Homing, Kalibrierung, Board-Viewer, +Scene-Viewer. Kein direkter Zugriff auf interne Services. -## 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`. -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). +## Funktionen -Fällt der BodyTracker aus, rechnet das Frontend ersatzweise lokal mit -`public/calculateAngles.js`. +| Seite | Pfad | Beschreibung | +|-------|------|--------------| +| 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) -kommen nur sauber durch den Proxy, wenn auch der Backend-Hop TLS spricht.** +``` +Foto alle Kameras + → 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`). - Das ist Absicht: Dieser Hop ist nur **Proxy ↔ Backend**, nie öffentlich. Die - vertrauenswürdige Kette stellt der vorgelagerte Reverse-Proxy bereit. -- Zugriff im internen Netz z. B. über `https://thinkcentre.local:2093/`. +SSE-Events (`log` / `step` / `analysis` / `done`) streamen den Fortschritt live +ins Frontend. Der Board-Viewer zeigt das Skeleton progressiv nach jedem erkannten Gelenk. -## 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 | -|---|---|---| -| `/api/health` | GET | Status + konfigurierte Service-URLs | -| `/api/latest-snapshot` | GET | Aktuelle Bilder/Daten (vom WebCam-Service bzw. lokalem Fallback) | -| `/api/estimate` | POST | Bilder an BodyTracker geben → Pose zurück | +|----------|---------|-------| +| `/api/robot` | GET | robot.json lesen | +| `/api/robot/set-arm-marker-spin` | POST | Spin eines Arm-Markers setzen | +| `/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 | -|---|---| -| `HTTPS_PORT` | Port des Backends (Default `2093`) | -| `WEBCAM_URL` | Basis-URL des internen WebCam-Services | -| `BODYTRACKER_URL` | Basis-URL des internen BodyTracker-Services | -| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` / `HTTPS_PASSPHRASE` | self-signed Cert für den Proxy-Hop | - -Ist `WEBCAM_URL` nicht gesetzt, nutzt der Backend lokale Dateien aus -`public/snapshots` als Fallback (Entwicklung ohne Kamera). +|----------|-----------| +| `HTTPS_PORT` | Port (Default `2093`) | +| `WEBCAM_URL` | Interner WebCam-Service | +| `ROBOT_URL` | Interner Robot-Driver | +| `ROBOT_JSON` | Pfad zu robot.json (Default `scripts/robot_1781069752019.json`) | +| `HTTPS_KEY_PATH` / `HTTPS_CERT_PATH` / `HTTPS_PASSPHRASE` | self-signed Cert | ## Dateien & Struktur -- `public/` – statisches Frontend (UI, Client-Logik, Anzeige). -- `server/server.js` – HTTPS-Backend / BFF-Proxy. -- `https/` – self-signed Zertifikate für den Proxy-Hop (nicht eingecheckt). -- `doc/README_WebCam.md` – WebCam-Service (Bildquelle). -- `doc/README_BodyTracker.md` – BodyTracker-Service (Pose-Ermittlung). -- `doc/ToDo.md` – offene Punkte & nächste Umsetzungsschritte. -- `test/` – Tests für Berechnung und Auswertung. +``` +public/ Frontend (HTML, JS, CSS) + boardViewer.html 3D-Viewer mit Three.js FK, Arm-Markern, Spin-Rendering + sceneViewer.html Standalone-Viewer (nur Datei-Upload) + calibration*.html Kalibrierungs-Tabs (lazy-geladen) + client.js Homing-Frontend-Logik + 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 ```bash npm install -npm test -npm start # startet den HTTPS-Backend auf Port 2093 +npm test # Jest-Tests (14+ Tests) +npm start # HTTPS-Backend auf Port 2093 ``` -Danach im internen Netz `https://:2093/` öffnen (self-signed → einmalige -Zertifikatswarnung im Browser bestätigen). +Danach: `https://:2093/` -> Hinweis: Das Frontend ist auf den Backend angewiesen – `/api/latest-snapshot` -> und `/api/estimate` funktionieren **nicht**, wenn man `index.html` rein -> 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). +> self-signed Zertifikat → einmalige Browser-Warnung bestätigen. +> Frontend benötigt laufendes Backend (API-Calls beim Laden). diff --git a/doc/Homing_ROADMAP.md b/doc/Homing.md similarity index 93% rename from doc/Homing_ROADMAP.md rename to doc/Homing.md index 8c9ade4..d575bae 100644 --- a/doc/Homing_ROADMAP.md +++ b/doc/Homing.md @@ -1,6 +1,6 @@ # Homing – appRobotHoming -> Stand: 2026-06-14 +> Stand: 2026-06-15 > Homing läuft bei **jedem Einschalten** — schnell, vollautomatisch, ohne mechanische Endschalter. --- @@ -44,7 +44,7 @@ Homing setzt eine abgeschlossene Kalibrierung voraus: | Board-Marker-Positionen | ✅ | | X-Achsen-Richtung | ✅ | | 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` | | 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 | -| 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}/` diff --git a/doc/Kalibrierung.md b/doc/Kalibrierung.md index 97e5147..196d567 100644 --- a/doc/Kalibrierung.md +++ b/doc/Kalibrierung.md @@ -1,6 +1,6 @@ # Kalibrierung – appRobotHoming -> Stand: 2026-06-14 +> Stand: 2026-06-15 > Einmaliger Vorgang — nur nach mechanischen Änderungen wiederholen. > Jede Stufe baut auf der vorherigen auf. @@ -9,7 +9,7 @@ ## Ü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 | @@ -18,7 +18,7 @@ | [2] Board | Board | `links.Board.markers[].position` in `robot.json` | ✅ | | [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` | ✅ | -| 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 ``` diff --git a/doc/Kalibrierung_Marker.md b/doc/Kalibrierung_Marker.md new file mode 100644 index 0000000..19d2b81 --- /dev/null +++ b/doc/Kalibrierung_Marker.md @@ -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 | diff --git a/doc/README_BodyTracker.md b/doc/README_BodyTracker.md deleted file mode 100644 index b246c6c..0000000 --- a/doc/README_BodyTracker.md +++ /dev/null @@ -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://: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) diff --git a/doc/accessRobotAPI.md b/doc/accessRobotAPI.md new file mode 100644 index 0000000..dc820f0 --- /dev/null +++ b/doc/accessRobotAPI.md @@ -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? diff --git a/public/boardViewer.html b/public/boardViewer.html index a4b5a50..4f41cee 100644 --- a/public/boardViewer.html +++ b/public/boardViewer.html @@ -338,18 +338,34 @@ function buildSkeletonFK(robot, angles) { 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) { const col = LINK_COLORS[linkName] ?? 0xffffff; for (const m of link.markers) { if (!m.position) continue; 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 [nx, ny, nz] = m.normal ?? [0, 0, 1]; - const normalWorld = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame); - gArmMarkers.add(makeMarkerSquareOriented(posWorld, normalWorld, markerSizeM, col)); + const normalW = new THREE.Vector3(nx, nz, -ny).transformDirection(childFrame).normalize(); + + // 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)); + + // 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)); } } } diff --git a/public/calibration.html b/public/calibration.html index d51cdd5..c17945f 100644 --- a/public/calibration.html +++ b/public/calibration.html @@ -24,6 +24,7 @@ + @@ -32,6 +33,7 @@
+
diff --git a/public/calibration.js b/public/calibration.js index f1caaf3..e7b8522 100644 --- a/public/calibration.js +++ b/public/calibration.js @@ -22,6 +22,7 @@ async function loadPanel(tab, src) { else if (tab === 'board') initBoard(); else if (tab === 'robot-x-axis') initXAxis(); else if (tab === 'arm1') initArm('arm1'); + else if (tab === 'marker') initMarker(); } catch (err) { 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 += ` + ${linkName} + ${m.id} + ${m.name ?? '–'} + ${pos} + ${norm} + ${m.size ?? '–'} + ${m.spin ?? 0}° + `; + } + } + + if (total === 0) { + tableWrap.innerHTML = '

Keine Arm-Marker in robot.json eingetragen.

'; + return; + } + + tableWrap.innerHTML = ` +

${total} Marker in Arm-Links

+ + + + + + + + + + + ${rows} +
LinkIDNamePosition [x,y,z] mmNormal [nx,ny,nz]Size mmSpin
`; + } + + // ── 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 = '' + + markers.map(m => ``).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 = `

Fehler: ${err}

`; + 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 = '⚠ Bitte zuerst einen Marker wählen.'; + 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 = 'Speichern …'; + 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 = `❌ ${data.error ?? `HTTP ${r.status}`}`; + return; + } + resultEl.innerHTML = + `✅ ${linkName} #${markerId}: spin ${data.oldSpin}° → ${data.newSpin}°`; + 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 = `❌ ${err}`; + } + } + + // ── 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(); +} diff --git a/public/calibration_marker.html b/public/calibration_marker.html new file mode 100644 index 0000000..3b25320 --- /dev/null +++ b/public/calibration_marker.html @@ -0,0 +1,96 @@ +
+ + +
+

Aktuelle Marker aus robot.json

+

+ Arm-Marker aller Links (Board-Marker ausgeblendet). Spin = Drehung um die Marker-Normale in Grad. +

+
+

(wird geladen …)

+
+
+ +
+
+ + +
+

Aktionen

+ + +
+ + + + + +
+ + +
+

+ Spin (Drehung um Marker-Normale) +

+
+ + + + → wird sofort in robot.json gespeichert und im Viewer aktualisiert +
+
+ + +
+
+ + +
+

Log

+ +
+ + +
+

Viewer

+

+ 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. +

+ +
+ +
diff --git a/server/editRobot.js b/server/editRobot.js index 4ef101c..acc1484 100644 --- a/server/editRobot.js +++ b/server/editRobot.js @@ -6,6 +6,8 @@ * überschrieben; bei Bedarf Backup-Strategie ergänzen). */ import fsPromises from 'fs/promises'; +import { createRequire } from 'module'; +const { normalizeSpinDeg } = createRequire(import.meta.url)('./spinNormalize.cjs'); // ── I/O ─────────────────────────────────────────────────────────────────────── @@ -609,3 +611,33 @@ export async function setJointOriginYZ(robotPath, { linkName, y, z }) { 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 }; +} diff --git a/server/server.js b/server/server.js index 9c2b2d2..6f78efd 100755 --- a/server/server.js +++ b/server/server.js @@ -8,7 +8,7 @@ import { fileURLToPath } from 'url'; import process from 'process'; import { spawn } from 'child_process'; 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'; 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 * Liest {camera}_calibration.npz aus der aktuellen Session und diff --git a/server/spinNormalize.cjs b/server/spinNormalize.cjs new file mode 100644 index 0000000..849f832 --- /dev/null +++ b/server/spinNormalize.cjs @@ -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 }; diff --git a/test/calculateAngles2.test.js b/test/calculateAngles2.test.js index 51018da..0879f81 100644 --- a/test/calculateAngles2.test.js +++ b/test/calculateAngles2.test.js @@ -23,7 +23,7 @@ describe("calculate() row223 Ellbow-Rotation Tests", () => { test('berechnet y-Durchschnitt für Base / Arm1 / Joint1', async () => { 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 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 () => { 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 jsonRobot = JSON.parse(fs.readFileSync(robotPath, 'utf8')); diff --git a/test/calculateAngles3_Calculate_DoubleMarker.test.js b/test/calculateAngles3_Calculate_DoubleMarker.test.js index dbc5859..20f9ae6 100644 --- a/test/calculateAngles3_Calculate_DoubleMarker.test.js +++ b/test/calculateAngles3_Calculate_DoubleMarker.test.js @@ -8,7 +8,7 @@ const { calculate } = require('../public/calculateAngles'); describe('calculateAngles minimal test', () => { it('should run calculate() with loaded JSON files', async () => { // 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( __dirname, '../test/snapshots/snapshot_video0_1778845508432_two_cam.json' diff --git a/test/fixtures/robot_legacy.json b/test/fixtures/robot_legacy.json new file mode 100644 index 0000000..cd4e59b --- /dev/null +++ b/test/fixtures/robot_legacy.json @@ -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"]} + ] +} diff --git a/test/optimizeRobot.test.js b/test/optimizeRobot.test.js index efe32f8..380498b 100644 --- a/test/optimizeRobot.test.js +++ b/test/optimizeRobot.test.js @@ -27,7 +27,7 @@ describe("calculate() row223 Ellbow-Rotation Tests", () => { 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 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 foundMarkers2 = JSON.parse(fs.readFileSync(markersPath2, 'utf8')); diff --git a/test/spinNormalize.test.js b/test/spinNormalize.test.js new file mode 100644 index 0000000..a1a6db5 --- /dev/null +++ b/test/spinNormalize.test.js @@ -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); + } + }); +});