342 lines
13 KiB
Markdown
342 lines
13 KiB
Markdown
# 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 |
|