Files
appRobotRender/doc/callibrate_scene_roadmap.md
2026-06-10 07:13:19 +02:00

358 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Roadmap: Szenen-Kalibrierung der Board-/Loose-Marker (`callibrate_scene`)
Status: **Vorschlag / zur Abstimmung**
Ort der Arbeit: `pipeline/` (nicht `approbot-pipeline/`, das bleibt die eingefrorene Kopie)
Datum: 2026-06-04
> Eingearbeitete Entscheidungen:
> 1. **Gelenkzustand UNBEKANNT** → wird mitgeschätzt (kein FK-Welt-Anker a priori).
> 2. **Set-Definition direkt in `robot.json`** über ein optionales `"set"`-Feld je Marker.
> Marker bleiben im bisherigen Format an ihrem Link. Gleiches `set` = ein starr zusammenhängendes
> Objekt mit **fixen relativen Bezügen**. **Kein `set` = loser Marker** (fix, aber Lage z.Zt. unbekannt).
> 3. **Welt = Roboter (Konvention 2)**, Roboter steht *nicht* bei 0/0/0.
> 4. **Primär eine Aufnahme (7 Bilder)**, *ohne* zusätzliche Base-Marker; mehrere Posen als Fallback.
> 5. **Ausgabe vorerst `robot.calibrated.json`** (Debugging); später in-place nach `robot.json`.
> 6. **Code generisch & `robot.json`-unabhängig.** `robot.json` ist nur ein Beispiel und wird später
> gegen ein anderes geprüft. KEINE festen Marker-IDs, Link-/Set-Namen, Achsen oder Gelenk-Variablen
> im Code (Auto-Discovery aus den Daten). Daten-spezifisches (z.B. die `set`-Zuordnung) gehört in
> `robot.json`, nicht in den Code.
> 7. **Aktuelle Marker-Positionen = brauchbare Startwerte.** Die relativen Bezüge innerhalb jedes Sets
> gelten als korrekt → direkte Grundlage für den Kabsch-Fit und die BA-Initialisierung.
---
## 0. Machbarkeit — Kurzurteil
**Ja, machbar** — die anspruchsvollere Variante, weil der Gelenkzustand mitgeschätzt wird und es
keinen a-priori Welt-Anker gibt (weder Board noch Arm). Vorgehen:
> **Erst ankerlos rekonstruieren** (metrisch, aus der bekannten Marker-Größe), **dann den Roboter
> in die Rekonstruktion einpassen** — der eingepasste Roboter definiert die Welt — **dann die Sets
> einpassen** und die Marker-Positionen aktualisieren.
Bausteine im Bestand: Per-Marker-PnP (`solve_single_marker_pose`), Eck-Triangulation
(`3b_corner_marker_poses.py`), Bündelausgleichung (`3_multiview_bundle_adjustment_v5...`),
FK + θ-Schätzung (`pose_estimation.py`, `robot_fk.py`), Kabsch-Fit (`rigid_transform_no_scale`).
**Ein Beobachtbarkeits-Knackpunkt** für „eine Aufnahme genügt ohne Base-Marker" steht in §7 — er ist
beherrschbar, aber bewusst zu entscheiden.
---
## 1. Problem & Zielbild
**Realität:** Höhe/Orientierung zwischen Board und Arm sind ungenau. Marker ~20105 liegen fix,
aber unbekannt: teils Board-Platte, teils darunterliegendes A0-Blatt, teils einzeln aufgeklebt.
**Vertrauenswürdig:** die *interne Geometrie* der Roboter-Links und die *relativen Bezüge innerhalb
jedes Sets* (beide in `robot.json` hinterlegt), sowie die *Marker-Kantenlänge* (25 mm → Maßstab).
**Unbekannt:** Gelenkwinkel, Kamera-Posen, Platzierung jedes Sets, Lage jedes losen Markers.
**Ziel:** Nach der Kalibrierung hat jedes Set (als starres Objekt korrekt platziert) und jeder lose
Marker (einzeln vermessen) eine korrekte Pose in einem roboter-verankerten Weltsystem.
### Marker-Klassen (aus dem `set`-Feld abgeleitet)
| Klasse | Erkennung | bekannt | zu schätzen |
|---|---|---|---|
| **Arm-Marker** (Roboter) | liegen an Arm-Links (Arm1…Finger) | Lage je Link | — definieren via Fit die Welt |
| **Set-Marker** (starr) | `"set": "A0"`, `"set":"Brett"`, … | interne Relativlage (fix) | 6-DoF-Platzierung je Set |
| **Lose Marker** | **kein** `set`-Feld | nur „fix vorhanden" | je Marker eigene 6-DoF-Pose |
---
## 2. Set-Definition: `set`-Feld am Marker (kein Strukturumbau)
Marker bleiben **wie bisher** in der `markers`-Liste ihres Links. Ein optionales `"set"` gruppiert sie:
```jsonc
"Board": {
"parent": null, "mountPosition": [0,0,0], "mountRotation": [0,0,0],
"markers": [
{ "id": 210, "position": [ 20,-20,0.3], "normal": [0,0,1], "set": "A0" },
{ "id": 211, "position": [250,-10,0.3], "normal": [0,0,1], "set": "A0" },
{ "id": 215, "position": [250,-90,0.3], "normal": [0,0,1], "set": "Brett" },
{ "id": 208, "position": [350,-90,0.3], "normal": [0,0,1], "set": "Brett" },
{ "id": 205, "position": [750,-90,0.3], "normal": [0,0,1] } // kein set -> lose
]
}
```
- **Gleiches `set` ⇒ ein starres Objekt.** Die relativen Bezüge der Marker im Set gelten als **fix**;
die Kalibrierung bestimmt nur die 6-DoF-Platzierung des ganzen Sets und schreibt die daraus
resultierenden Positionen zurück (Format unverändert, relative Anordnung erhalten).
- **Kein `set` ⇒ loser Marker.** Wird einzeln (Position + Normale + ggf. Spin) vermessen.
- **Arm-Marker** brauchen kein `set`: ihr Link ist bereits ein starrer Körper und dient als
Roboter-Referenz (sie werden *nicht* kalibriert, sondern definieren die Welt).
- **Auto-Discovery** (Projekt-Konvention): Sets ergeben sich aus den `set`-Werten, nichts hartkodiert.
Hinweis: `robot_fk.py` / `all_markers_world()` bleiben unverändert — das `set`-Feld ist reine
Zusatzinfo, die nur der Kalibrier-Treiber auswertet.
---
## 3. Algorithmus (Gelenkzustand unbekannt)
### Phase A — Ankerlose, metrische Rekonstruktion
1. **Detektion** (Schritt 1) → Ecken je Kamera.
2. **Per-Marker-PnP** je Kamera aus der bekannten Marker-Größe (`SOLVEPNP_IPPE_SQUARE`) → volle
Marker-Pose *relativ zur Kamera*. Kein Welt-Anker nötig.
3. **Relativer Posen-Graph:** gemeinsam gesehene Marker verknüpfen Kamerapaare → Init aller Kamera-
und Marker-Posen in einem *beliebigen* Szenen-Frame `S`.
4. **Globale Bündelausgleichung** (scipy `least_squares`, Huber): verfeinert alle Kamera- und
Marker-Posen über die Reprojektion aller Ecken. Maßstab fix durch Marker-Größe.
→ konsistente, metrische 3D-Szene (Arm- **und** Set-/Loose-Marker) in `S`.
### Phase B — Roboter einpassen = Welt definieren
5. Arm-Marker per ID → Link zuordnen. **Fit** von Gelenkwinkeln θ **und** der Platzierung der
FK-Wurzel in `S`, sodass `FK(θ)` der Arm-Marker die rekonstruierten Arm-Positionen trifft
(erweitert `pose_estimation.py` um eine freie Wurzel-Platzierung statt fixer Identität).
6. In das Weltsystem rücktransformieren. Welt-Ursprung = FK-Wurzel-Frame (= heutiges „Board"-Frame),
Roboter sitzt mit dem fertigen θ darin — *nicht* bei 0/0/0 (§6).
### Phase C — Set-Fit & Rückschreiben
7. **Set-Marker (pro `set`):** Kabsch (`rigid_transform_no_scale`) bildet die **fixe interne Lage**
auf die rekonstruierten Welt-Positionen ab → 6-DoF-Set-Platzierung → aktualisierte Positionen
(= Platzierung ∘ interne Lage). Auch nicht gesehene Set-Marker erhalten so eine Position, sofern
das Set über ≥3 nicht-kollineare Marker bestimmt ist.
**Lose Marker:** triangulierte Pose direkt übernehmen.
8. **Rückschreiben** nach `robot.calibrated.json` (Marker-Format unverändert, `set`-Felder erhalten)
+ `calibration_report.json` (je Set die explizite Verschiebung/Verdrehung + RMS; je Marker Status).
Nicht beobachtbare Größen → **`null`** (nie 0).
### Fallback — mehrere Posen (statische Kameras)
Mehrere Gelenkzustände bei festen Kameras: Kamera-Posen + Set-/Loose-Posen + Wurzel-Platzierung sind
**geteilte** Unbekannte, je Pose ein eigener θ-Satz. Löst die §7-Schwächen vollständig auf.
---
## 4. Eingaben & Ausgaben
**Eingaben:** `robot.json` (Arm-Geometrie + `set`-Felder + fixe interne Set-Lagen);
Szenen-Ordner mit `render_*.png` **oder** vorhandene `*_aruco_detection.json`.
(Gelenkzustand wird NICHT benötigt.)
**Ausgaben:** `robot.calibrated.json` (aktualisierte Marker-Positionen, Format wie bisher);
`calibration_report.json` (je Set: Verschiebung/Verdrehung, RMS, #Kameras/#Marker, Status;
je losem Marker: Pose oder `null`). Optional Viewer-Overlay Soll↔kalibriert.
---
## 5. Neue / geänderte Dateien
| Datei | Art | Inhalt |
|---|---|---|
| `pipeline/calibrate_scene.py` | **neu** | Treiber: Auto-Discovery Kameras+Sets, Phase A→B→C, schreibt `robot.calibrated.json`+Report |
| `pipeline/scene_reconstruct.py` | **neu** | Phase A: Per-Marker-PnP, Posen-Graph, globale BA (ankerlos) |
| `pipeline/robot_register.py` | **neu** | Phase B: Fit θ + freie Wurzel-Platzierung (nutzt `robot_fk`) |
| `pipeline/marker_sets.py` | **neu** | liest `set`-Felder aus `robot.json`; Klassifizierung Arm/Set/Lose |
| `3b_corner_marker_poses.py` | **erweitern** | volle Marker-**Rotation** (Normale + Spin) aus 4 Ecken |
| `pose_estimation.py` | **erweitern** | optionale freie Wurzel-Platzierung (für Phase B wiederverwendbar) |
`2_estimate_camera_from_observations.py` / `robot_fk.py`: voraussichtlich **unverändert**.
---
## 6. Weltursprung (Konvention 2, Roboter nicht bei 0/0/0)
- Ursprung = FK-Wurzel-Frame (heute „Board"). Der Roboter sitzt mit seinem modellierten Versatz
(`Base.jointToParent.origin` + Slider `x`) darin → **nicht** bei 0/0/0. Das deckt den Wunsch ab.
- „Welt durch Roboter definiert" wird dadurch realisiert, dass die **Kalibrierung am Roboter
verankert** (Fit θ + Wurzel-Platzierung über Arm-Marker), statt den Board-Markern zu vertrauen.
Die Board-Positionen werden konsistent *neu* abgeleitet.
- Der Kinematik-Baum bleibt unverändert. (Optionaler späterer Umbau „Base = Wurzel" möglich, aber
für die Kalibrierung nicht nötig.)
---
## 7. Beobachtbarkeit — Einzelaufnahme ohne Base-Marker (wichtig)
Verifiziert an `robot.json`: `Base`, `Hand`, `Palm` haben **keine** Marker; erster markierter Link
ist `Arm1`. Daraus folgt für EINE Pose mit unbekannten Gelenkwinkeln:
- **Slider `x` und `Joint1 y` sind nicht von der absoluten Roboter-Platzierung trennbar** (2-DoF-
Gauge-Freiheit): eine Verschiebung entlang der Schiene ≙ Änderung von `x`; eine Drehung um die
`Joint1`-Achse ≙ Änderung von `y`. Die Set-/Loose-Marker erben diese 2 Freiheitsgrade.
- **Gut bestimmt aus einer Pose:** `z, a, b, c, e` und damit die gesamte Szene *relativ*.
Da Base-Marker mechanisch unerwünscht sind, der empfohlene Weg:
- **Gauge per Konvention fixieren** (Default für Einzelaufnahme): `x`,`y` auf die gefittete
Konfiguration / nominale Schienen-Null setzen und die Welt so definieren. Ergebnis ist
**in sich konsistent** → für künftige Pose-Schätzung (Board als Anker) voll nutzbar; lediglich
der *absolute* Schienen-Nullpunkt und die `Joint1`-Null sind dann Konvention, kein Messwert.
- **Mehrere Posen** (Fallback), wenn die absolute Basis-Lage / absolute `x`,`y` wirklich gebraucht
werden — das löst die 2 Freiheitsgrade vollständig auf.
- *(optional, falls je möglich:* ein einzelner Base-/Schlitten-Marker würde Einzelaufnahme voll
beobachtbar machen — derzeit zurückgestellt.)*
QA: Reprojektions-RMS je Kamera; Set-Fit-Residuum (mm); Co-Visibility-Graph zusammenhängend?;
≥3 nicht-kollineare Marker je Set; ≥2 Kameras je losem Marker (sonst Status `partial`/`unobserved`).
---
## 8. Validierung (Sim zuerst)
`pose.json` liefert in der Simulation GT-Gelenkwinkel **und** Kamera-Pos/Targets:
1. Bekannte Sets künstlich verschieben/verdrehen → kalibrieren → Rück-Transform gegen Soll.
2. Gefittete θ gegen GT-θ; gefittete Kamera-Posen gegen GT.
3. Einzelaufnahme- vs. Mehrfach-Posen-Genauigkeit quantifizieren (belegt §7).
4. Erst danach `data/recorded/`-Szenen.
---
## 9. Phasen / Meilensteine
- **P0 — `set`-Felder & Parser:** `set`-Felder in `robot.json` ergänzen; `marker_sets.py`
(Arm/Set/Lose-Klassifizierung); FK-Welt-Positionen unverändert verifizieren.
- **P1 — Ankerlose Rekonstruktion (Phase A):** Per-Marker-PnP + Posen-Graph + globale BA; gegen
GT-Kamera-Posen (Sim) prüfen.
- **P2 — Roboter-Registrierung (Phase B):** Fit θ + freie Wurzel-Platzierung; gegen GT-θ; §7-Gauge.
- **P3 — Set-Fit & Rückschreiben (Phase C):** Kabsch + Loose → `robot.calibrated.json` + Report;
Sim-Validierung mit künstlichem Offset.
- **P4 — Mehrfach-Posen-Fallback.**
- **P5 — Reale Szenen + Viewer-Overlay; danach in-place nach `robot.json`.**
---
## 10. Verbleibende kleinere Punkte
1. **Gauge-Konvention für Einzelaufnahme** (§7): `x`,`y` = gefittet, oder Schiene/`Joint1` auf
nominal? (Beeinflusst nur den absoluten Nullpunkt, nicht die Set-Relativlagen.)
2. **Set-Namensraum:** sind `set`-Namen global eindeutig oder pro Link? (Vorschlag: global, z.B.
„A0", „Brett" — ein Set = ein physisches Objekt.)
3. **Lose-Marker-Orientierung:** reicht Position + Normale, oder wird der Spin (Drehung um die
Normale) gebraucht? (Bestimmt die nötige Genauigkeit von Phase C / 3b.)
---
## 11. Umsetzungs-Log: Mathematik & Anwendung
Pro abgeschlossener Phase: *was* gemacht wurde, *welche Mathematik* dahinter steckt, *wie* man es
anwendet. (Wird mit jeder Phase fortgeschrieben.)
### P0 — Marker-Klassifizierung & `set`-Tags — ✅ erledigt (2026-06-04)
**Was gemacht**
- Neu: `pipeline/marker_sets.py` — liest ein beliebiges `robot.json`, klassifiziert jeden Marker in
`arm` / `set` / `loose` und gibt einen Report aus. Vollständig generisch (keine festen Namen/IDs).
- Daten: `data/robot/robot.json` mit `set`-Tags versehen — `Brett` (9 Board-Oberflächen-Marker,
z≈0.3) und `A0` (60 Papier-Marker, z≈27.3). Chirurgisch eingefügt (kompaktes Custom-Format der
Datei bleibt erhalten, nur +1 Zeile), FK numerisch exakt invariant.
**Mathematik / Logik**
1) *Statisch (Welt) vs. beweglich (Roboter):* Ein Link `L` ist beweglich, wenn auf dem Pfad
`L → Wurzel` mindestens ein Gelenk vom Typ revolute/linear liegt:
```
movable(L) = ∃ A ∈ chain(L→root): type(jointToParent(A)) ∈ {revolute, linear}
```
Die Wurzel und nur über `fixed` angebundene Links sind statisch (= Welt). Generisch, da nur
Gelenk-Typen geprüft werden — keine Link-Namen.
2) *Rollen* eines Markers `m` auf Link `L`:
```
role(m) = arm falls movable(L) → Welt-Referenz, NICHT kalibriert
= set(s) falls ¬movable(L) ∧ m.set = s → starres Objekt, 6-DoF kalibrieren
= loose falls ¬movable(L) ∧ m hat kein set → einzeln vermessen
```
3) *Modell eines starren Sets `S`* (die eigentliche Kalibriergröße der Phase C):
Marker `i` hat eine **bekannte, fixe** lokale Lage `p_iˡᵒᵏ` (die vertrauten relativen Bezüge).
Das Set hat eine **unbekannte** starre Platzierung `(R_S, t_S) ∈ SE(3)`:
```
p_iʷᵉˡᵗ = R_S · p_iˡᵒᵏ + t_S R_S ∈ SO(3) (Verdrehung), t_S ∈ ℝ³ (Verschiebung)
```
`(R_S, t_S)` = die gesuchten 6 DoF je Set. Schätzung aus gemessenen Welt-Positionen `q_i`
per **Kabsch / orthogonalem Procrustes** (ohne Skalierung):
```
min_{R∈SO(3), t} Σ_i ‖ R·p_iˡᵒᵏ + t q_i ‖²
p̄=mean(pˡᵒᵏ), q̄=mean(q), H = Σ (p_iˡᵒᵏp̄)(q_iq̄)ᵀ, U Σ Vᵀ = svd(H)
R = V·diag(1,1,det(V Uᵀ))·Uᵀ, t = q̄ R·p̄
```
(vorhanden als `rigid_transform_no_scale`). Weil die aktuellen `robot.json`-Positionen brauchbare
Startwerte sind und die relativen Bezüge stimmen, gilt `p_iˡᵒᵏ` = aktuelle Set-Positionen
(ggf. zentriert) und `(R_S,t_S) ≈ Identität` als Initialwert.
4) *Loser Marker:* eigene unbekannte Pose `(R_m, t_m)`, einzeln aus den triangulierten Ecken
(Position = Eckmittel, Orientierung aus Eckebene + Eckreihenfolge).
5) *FK-Invarianz:* `set` ist reine Metadaten; `p^welt = T_Link(θ)·p^lok` bleibt unberührt
(verifiziert: max |Δ| = 0).
**Anwendung**
```
# Report (Mensch):
python pipeline/marker_sets.py -robot data/robot/robot.json
# Report (Maschine):
python pipeline/marker_sets.py -robot data/robot/robot.json --json
```
```python
from marker_sets import load_robot, classify_markers, get_sets, get_loose_markers, get_arm_markers
data = load_robot("data/robot/robot.json")
sets = get_sets(data) # {"A0":[MarkerInfo,...], "Brett":[...]}
loose = get_loose_markers(data) # [MarkerInfo,...]
arm = get_arm_markers(data) # {id: MarkerInfo}
```
*Neues `robot.json` vorbereiten:* an jeden Marker eines starren Objekts `"set": "<name>"` ergänzen
(Name frei wählbar, ein Set = ein physisches Objekt); lose Marker ohne `set`; Arm-Marker (an
beweglichen Links) brauchen keinen Eintrag. Der Code liest die Sets dann automatisch.
### P1 — Ankerlose, metrische Rekonstruktion (Phase A) — ✅ erledigt (2026-06-04)
**Was gemacht**
- Neu: `pipeline/scene_reconstruct.py` — rekonstruiert aus den ArUco-Eckbeobachtungen ALLER Kameras
die Kamera- und Marker-Posen in einem gemeinsamen Frame `S`, **ohne** Welt-Anker, **ohne**
`robot.json` (nur Marker-Kantenlänge nötig). Vollständig generisch.
**Mathematik**
Konventionen: `E_c` = world(S)→Kamera c, `G_m` = Marker-lokal→world(S), also `M_{c←m} = E_c · G_m`.
1) *Per-Marker-PnP (tentativ):* für jedes (Kamera c, Marker m) löst IPPE_SQUARE die Pose eines
Quadrats bekannter Größe. **Problem:** ein planares Quadrat hat eine **2-fache Flip-Ambiguität**
→ naive Verkettung liefert ~⅓ gespiegelte Knoten (empirisch verifiziert).
2) *Flip-robuste Initialisierung* (Referenzkamera `c0`, `E_{c0}=I`), iterativ:
- **Kamera-Pose per RANSAC-PnP** gegen die bereits platzierten 3D-Ecken
`X_S = G_m · corner_local`. RANSAC verwirft gespiegelte Marker als Ausreißer.
- **Marker-Ecken triangulieren** (lineares DLT über alle platzierten Kameras) — Triangulation
ist **flip-frei**; daraus Marker-Pose via Kabsch `local→tri`. Korrigiert Flips aus Schritt 1.
- Marker mit nur 1 Kamera sind nicht triangulierbar → als `insufficient_views` markiert
(unbeobachtbar, **nicht** rekonstruiert; Konvention „unbekannt = null").
3) *Globale Bündelausgleichung* (`scipy.least_squares`, robuste Huber-Loss, **dünnbesetzte**
Jacobi): minimiert die Reprojektion aller Ecken
```
min_{E_c, G_m} Σ_{(c,m)} Σ_{k=1..4} ρ( ‖ π(K_c, D_c; E_c·G_m·corner_k) u_{c,m,k} ‖ )
```
Gauge: Referenzkamera `E_{c0}=I` fix (entfernt die 6-DoF-Starrkörperfreiheit).
4) *Similarity-Gauge / Skala:* Ankerlose SfM bestimmt die Struktur nur bis auf eine **7-DoF-
Ähnlichkeit** (Rotation, Translation, **Skala**). Der absolute Maßstab kommt hier provisorisch aus
der angenommenen Markergröße — empirisch ~0.93× gegenüber der echten Welt (konsistent über alle
Szenen, also ein systematischer Markergrößen-/Rand-Versatz). **Die echte Skala + Lage fixiert
erst Phase B über die bekannte mm-Geometrie des Roboters** (robuster als die Markergröße). Die
*Form* ist bereits korrekt.
**Validierung (Sim, gegen FK-Ground-Truth)**
- Reprojektion median **0.71.6 px** über Scene5/6/8/10/11/12 — besser als die bestehende,
board-verankerte Pipeline (3.24.9 px), weil keine falschen Marker-Positionen angenommen werden.
- Form (nach Similarity-Ausrichtung): Residuum **median ~37 mm** = Sensor-Rauschboden der Renders
(`markerOffsetMaxMm: 4` + Rauschen); Übereinstimmung mit der bestehenden Triangulation **1.9 mm**.
- Skalenfaktor konsistent **0.920.94** (→ Phase B).
**Anwendung**
```
python pipeline/scene_reconstruct.py --evalDir data/evaluations/Scene8
# -> <evalDir>/scene_reconstruction.json (Kamera- & Marker-Posen in S, Reproj-Statistik,
# Liste insufficient_view_markers)
```
```python
import scene_reconstruct as sr
res = sr.reconstruct("data/evaluations/Scene8") # dict; res["cameras"], res["markers"]
```
Voraussetzung: `*_aruco_detection.json` je Kamera (Pipeline-Schritt 1). Marker-Kantenlänge wird
aus den Detektionen gelesen (Fallback `--markerSize`).