Claude: Abweichungen bei 3 Kameras zählen

This commit is contained in:
chk
2026-06-03 06:34:26 +02:00
parent 6964f19b28
commit 5c71c00fb4
1397 changed files with 11773 additions and 11317 deletions

View File

@@ -1,7 +1,8 @@
# Kamera-Anzahl-Studie
Untersucht, wie sich die Pose-Schätzgenauigkeit verändert, wenn weniger Kameras
verwendet werden. Ergebnis ist eine Kurve **k Kameras → mittlerer Gelenkwinkelfehler (°)**.
verwendet werden. Hauptergebnis ist eine Kurve **k Kameras → Positionsfehler in mm**
an zwei Punkten des Roboters (Handgelenk und Finger).
Hintergrundfrage und Entscheidungslogik: [`doc/camera_number_roadmap.md`](../../doc/camera_number_roadmap.md)
@@ -19,6 +20,39 @@ Pro Szene müssen vorhanden sein:
| `data/robot/robot.json` | Robotermodell |
Neue Szenen werden **automatisch erkannt** — kein Skript anpassen nötig.
Für die Plots zusätzlich `matplotlib` (`pip install matplotlib`); ohne läuft
alles, nur das PNG entfällt.
---
## Metriken
Pro Kamera-Subset werden zwei Positionsfehler per Vorwärtskinematik berechnet —
der euklidische Abstand zwischen geschätzter und wahrer Position eines Punktes:
| Metrik | Punkt | Hängt ab von | Bedeutung |
|---|---|---|---|
| `wrist_error_mm` | Hand-Ursprung | Armgelenke `x,y,z,a` | Wie genau ist der Arm bis zum Handgelenk? |
| `finger_error_mm` | FingerA-Spitze | volle Kette `x..e` | Wie genau ist die ganze Pose inkl. Hand/Finger? |
**Unbeobachtbare Gelenke → leerer Wert (n/a).** Sieht ein Subset z. B. die Hand
nicht, ist `b/c/e` unbeobachtbar — dann ist die *wahre* Fingerposition schlicht
unbekannt und `finger_error_mm` bleibt **leer** (statt mit einer falschen 0
gefüllt zu werden). Das Handgelenk hängt nur von den Armgelenken ab und ist
deshalb meist auch mit wenigen Kameras bestimmbar.
Welche Gelenke einen Punkt bewegen, wird **numerisch** ermittelt (kleine
Auslenkung je Gelenk) — funktioniert daher auch, wenn sich das Robotermodell ändert.
Zusätzliche Spalten:
- `n_unobservable` — Anzahl der 7 Gelenke, die in diesem Subset unbeobachtbar waren.
- `mean_abs_deg` / `max_abs_deg` — Gelenkwinkelfehler (nur **beobachtbare** Gelenke).
- `mean_abs_mm` / `max_abs_mm` — Lineargelenkfehler (`x`, `e`).
> Hinweis: Winkelfehler zählen unbeobachtbare Gelenke **nicht** mit, die
> mm-Positionsfehler lassen sie als n/a aus. Beide blenden also dieselben Fälle
> aus — die `n/a`-Spalte zeigt, wie oft das passiert.
---
@@ -43,11 +77,14 @@ run\run_camera_study.bat --k-min 3 --k-max 5
REM Weniger Samples — schneller, weniger repräsentativ
run\run_camera_study.bat --samples 3
REM Existierende Ergebnisse überschreiben
REM Pipeline für vorhandene Subsets erneut laufen lassen
run\run_camera_study.bat --force
REM Alles kombinierbar
run\run_camera_study.bat --scenes Scene7 --k-min 3 --k-max 5 --samples 5 --force
REM VOLLSTÄNDIGER Neustart: alles löschen und neu rechnen
run\run_camera_study.bat --clean
REM Nur eine re-gerenderte Szene komplett neu rechnen (Rest bleibt erhalten)
run\run_camera_study.bat --clean --scenes Scene10
```
Oder direkt über Python (z. B. in einer Linux-Umgebung / Docker):
@@ -56,44 +93,58 @@ Oder direkt über Python (z. B. in einer Linux-Umgebung / Docker):
python benchmark/camera_count/run_camera_study.py --samples 10
```
**Laufzeit:** Pro Kamera-Subset läuft die volle Pipeline (Schritt 14).
Bei 3 Szenen × 5 k-Werte × 10 Samples = 150 Läufe — je nach Hardware mehrere Minuten.
**Laufzeit & Wiederholläufe:** Pro Kamera-Subset läuft die volle Pipeline
(Schritt 14). Bereits gerechnete Subsets werden **übersprungen** (kein `--force`
nötig für Fortsetzung) — die mm-Auswertung wird dabei trotzdem frisch berechnet,
ein erneuter Lauf ohne `--force` ist also schnell.
Bereits gerechnete Subsets werden **übersprungen** (kein `--force` nötig für Fortsetzung).
**Drei Stufen der Wiederholung:**
| Flag | Wirkung |
|---|---|
| *(kein Flag)* | Vorhandene `robot_state.json` werden wiederverwendet, nur die Auswertung neu gerechnet. Schnell. |
| `--force` | Pipeline läuft erneut, überschreibt `robot_state.json`. Für geänderte Pipeline-Logik. |
| `--clean` | Löscht Zwischenergebnisse vorher komplett. **Nötig nach Re-Rendering.** Ohne `--scenes` wird alles inkl. Ergebnisdateien zurückgesetzt; mit `--scenes` nur die genannten Szenen. |
**Merge:** Ein Lauf über einzelne Szenen (`--scenes`) aktualisiert nur deren
Zeilen in `camera_study.json/.csv` — die übrigen Szenen bleiben erhalten. Du
kannst also eine re-gerenderte Szene neu rechnen, ohne die Gesamtauswertung zu verlieren.
---
## Schritt 2 — Auswertung
```bash
python benchmark/camera_count/analyze.py
python benchmark/camera_count/analyze.py # finger_error_mm (Standard)
python benchmark/camera_count/analyze.py --metric wrist_error_mm
python benchmark/camera_count/analyze.py --metric mean_abs_deg
```
Ausgabe auf der Konsole:
Beispielausgabe (nur Scene10):
```
Kamera-Anzahl vs. mean_abs_deg
k | n | Mittel | Median | Std | Min | Max
-----------------------------------------------------------------
3 | 30 | 0.842 | 0.731 | 0.312 | 0.251 | 1.843
4 | 30 | 0.563 | 0.521 | 0.198 | 0.252 | 1.102
...
Kamera-Anzahl vs. finger_error_mm
k | n | n/a | Mittel | Median | Std | Min | Max
--------------------------------------------------------------------------
3 | 4 | 6 | 1.775 | 1.583 | 0.986 | 0.710 | 3.225
4 | 10 | 0 | 1.955 | 2.034 | 0.696 | 0.503 | 3.317
5 | 10 | 0 | 1.391 | 1.322 | 0.646 | 0.614 | 2.468
6 | 7 | 0 | 1.306 | 1.288 | 0.389 | 0.697 | 1.992
7 | 1 | 0 | 1.251 | 1.251 | 0.000 | 1.251 | 1.251
Beste / schlechteste Subsets je k:
k=3: best [Scene7] ace (0.251°) worst [Scene9] bdf (1.843°)
n/a = Subsets, bei denen dieser Punkt unbeobachtbar war (nicht eingerechnet).
Beste / schlechteste Subsets je k (finger_error_mm):
k=3: best [Scene10] bcd (0.710) worst [Scene10] acf (3.225)
...
```
Zusätzlich wird ein Boxplot gespeichert:
`benchmark/camera_count/results/camera_count_vs_error.png`
Hier sieht man sofort: bei **k=3 konnten 6 von 10 Subsets den Finger gar nicht
sehen** (n/a=6). Das ist die eigentliche Aussage — nicht „der Fehler ist klein",
sondern „die meisten 3-Kamera-Anordnungen verlieren die Hand komplett".
**Andere Metrik:**
```bash
python benchmark/camera_count/analyze.py --metric max_abs_deg
```
Verfügbare Metriken: `finger_error_mm` (Standard), `mean_abs_deg`, `max_abs_deg`, `mean_abs_mm`, `max_abs_mm`
Verfügbare Metriken: `finger_error_mm` (Standard), `wrist_error_mm`,
`mean_abs_deg`, `max_abs_deg`, `mean_abs_mm`, `max_abs_mm`.
---
@@ -101,9 +152,9 @@ Verfügbare Metriken: `finger_error_mm` (Standard), `mean_abs_deg`, `max_abs_deg
| Pfad | Inhalt |
|---|---|
| `benchmark/camera_count/results/camera_study.json` | Alle Einzelergebnisse (Szene, k, Subset, Fehler) |
| `benchmark/camera_count/results/camera_study.csv` | Dasselbe als CSV Spalten inkl. `finger_error_mm` |
| `benchmark/camera_count/results/camera_count_vs_error.png` | Boxplot |
| `benchmark/camera_count/results/camera_study.json` | Alle Einzelergebnisse |
| `benchmark/camera_count/results/camera_study.csv` | Dasselbe als CSV (Spalten inkl. `wrist_error_mm`, `finger_error_mm`, `n_unobservable`) |
| `benchmark/camera_count/results/camera_count_<metric>.png` | Boxplot je Metrik |
| `data/camera_study/SceneX/k3_abc/` | Pipeline-Zwischenergebnisse pro Subset |
Die Zwischenergebnisse in `data/camera_study/` sind groß — nicht committen.
@@ -112,18 +163,22 @@ Die Zwischenergebnisse in `data/camera_study/` sind groß — nicht committen.
## Ergebnis interpretieren
Die Studie beantwortet drei Fragen:
Die Studie beantwortet vier Fragen:
1. **Ab welchem k wird der Fehler stabil?**
1. **Wird die Hand überhaupt gesehen?**
`n/a`-Spalte bei `finger_error_mm`. Viele n/a bei k=3 heißt: zu wenige
Kameras verlieren die Hand systematisch.
2. **Ab welchem k wird der Fehler stabil?**
→ Knick in der Kurve zeigt den Sättigungspunkt.
2. **Welche Kamera-Kombination ist bei k=3 am besten?**
3. **Welche Kamera-Kombination ist bei kleinem k am besten?**
`analyze.py` gibt beste und schlechteste Subsets aus.
3. **Gibt es Szenen / Posen, bei denen wenige Kameras systematisch scheitern?**
→ CSV öffnen, nach `scene` gruppieren.
4. **Arm vs. Hand getrennt:** Das Handgelenk (`wrist_error_mm`) ist meist schon
mit 3 Kameras gut bestimmt — der kritische Teil ist die Hand/Finger-Orientierung.
Eine praktische Entscheidungsregel: 3 Kameras gelten als ausreichend, wenn
`mean_abs_deg` bei k=3 nicht mehr als ~20 % schlechter ist als beim Maximum,
und `max_abs_deg` keine kritischen Ausreißer zeigt.
(a) der Finger in fast allen Subsets beobachtbar ist (wenig n/a) **und**
(b) `finger_error_mm` bei k=3 nicht wesentlich schlechter ist als beim Maximum.
(Schwellwert je nach Anwendung anpassen.)

View File

@@ -4,13 +4,21 @@ benchmark/camera_count/analyze.py
===================================
Liest camera_study.json und erstellt:
- Konsolentabelle: k → Mittelwert / Median / Std / Min / Max des Fehlers
(inkl. n/a-Spalte: Subsets, bei denen der Punkt unbeobachtbar war)
- Beste und schlechteste Kamera-Subsets je k
- Boxplot: Anzahl Kameras vs. Gelenkwinkelfehler (PNG)
- Boxplot: Anzahl Kameras vs. Fehler (PNG)
Metriken:
finger_error_mm (Standard) — Fingerposition in mm (volle Kette x..e).
Leer, wenn Hand/Palm/Finger unbeobachtbar.
wrist_error_mm — Handgelenkposition in mm (nur Armgelenke x,y,z,a).
mean_abs_deg / max_abs_deg — Gelenkwinkelfehler in Grad (nur beobachtbare Gelenke).
mean_abs_mm / max_abs_mm — Lineargelenkfehler (x, e) in mm.
Aufruf:
python benchmark/camera_count/analyze.py
python benchmark/camera_count/analyze.py --input benchmark/camera_count/results/camera_study.json
python benchmark/camera_count/analyze.py --metric max_abs_deg
python benchmark/camera_count/analyze.py --metric wrist_error_mm
python benchmark/camera_count/analyze.py --metric mean_abs_deg
"""
from __future__ import annotations
@@ -29,28 +37,39 @@ def load(path: str) -> list[dict]:
return data
def group_by_k(data: list[dict], metric: str) -> dict[int, list[float]]:
def group_by_k(data: list[dict], metric: str) -> tuple[dict[int, list[float]], dict[int, int]]:
"""Werte je k (None übersprungen) plus Anzahl der n/a-Fälle je k."""
by_k: dict[int, list[float]] = {}
na_by_k: dict[int, int] = {}
for row in data:
k = row["k"]
v = row.get(metric)
if v is None:
na_by_k[k] = na_by_k.get(k, 0) + 1
continue
by_k.setdefault(row["k"], []).append(v)
return by_k
by_k.setdefault(k, []).append(v)
return by_k, na_by_k
def print_table(by_k: dict[int, list[float]], metric: str) -> None:
def print_table(by_k: dict[int, list[float]], na_by_k: dict[int, int], metric: str) -> None:
print(f"\nKamera-Anzahl vs. {metric}")
print(f"{'k':>4} | {'n':>5} | {'Mittel':>8} | {'Median':>8} | "
print(f"{'k':>4} | {'n':>5} | {'n/a':>5} | {'Mittel':>8} | {'Median':>8} | "
f"{'Std':>8} | {'Min':>8} | {'Max':>8}")
print("-" * 65)
for k in sorted(by_k):
vals = by_k[k]
med = statistics.median(vals)
std = statistics.pstdev(vals)
print(f"{k:>4} | {len(vals):>5} | "
f"{statistics.mean(vals):8.3f} | {med:8.3f} | "
f"{std:8.3f} | {min(vals):8.3f} | {max(vals):8.3f}")
print("-" * 74)
all_ks = sorted(set(by_k) | set(na_by_k))
for k in all_ks:
na = na_by_k.get(k, 0)
vals = by_k.get(k, [])
if not vals:
print(f"{k:>4} | {0:>5} | {na:>5} | {'':>8} | {'':>8} | "
f"{'':>8} | {'':>8} | {'':>8}")
continue
print(f"{k:>4} | {len(vals):>5} | {na:>5} | "
f"{statistics.mean(vals):8.3f} | {statistics.median(vals):8.3f} | "
f"{statistics.pstdev(vals):8.3f} | {min(vals):8.3f} | {max(vals):8.3f}")
if any(na_by_k.values()):
print("\n n/a = Subsets, bei denen dieser Punkt unbeobachtbar war "
"(Position unbekannt, nicht eingerechnet).")
def print_best_worst(data: list[dict], metric: str) -> None:
@@ -59,6 +78,7 @@ def print_best_worst(data: list[dict], metric: str) -> None:
for k in ks:
rows_k = [r for r in data if r["k"] == k and r.get(metric) is not None]
if not rows_k:
print(f" k={k}: keine beobachtbaren Werte")
continue
best = min(rows_k, key=lambda r: r[metric])
worst = max(rows_k, key=lambda r: r[metric])
@@ -67,7 +87,8 @@ def print_best_worst(data: list[dict], metric: str) -> None:
f"worst [{worst['scene']}] {worst['subset']} ({worst[metric]:.3f})")
def plot(by_k: dict[int, list[float]], metric: str, out_path: str) -> None:
def plot(by_k: dict[int, list[float]], na_by_k: dict[int, int],
metric: str, out_path: str) -> None:
try:
import matplotlib.pyplot as plt
except ImportError:
@@ -75,14 +96,25 @@ def plot(by_k: dict[int, list[float]], metric: str, out_path: str) -> None:
return
ks = sorted(by_k)
if not ks:
print("\n[INFO] Keine Werte zum Plotten (alle unbeobachtbar).")
return
data_plot = [by_k[k] for k in ks]
fig, ax = plt.subplots(figsize=(8, 5))
ax.boxplot(data_plot, labels=[str(k) for k in ks], patch_artist=True)
ax.set_xlabel("Anzahl Kameras")
ax.set_ylabel(f"{metric}")
ax.set_ylabel(metric)
ax.set_title("Anzahl Kameras vs. Pose-Schätzfehler")
ax.grid(True, axis="y", alpha=0.35)
# n und n/a je k über den Boxen annotieren
ymax = max(max(v) for v in data_plot)
for i, k in enumerate(ks, start=1):
na = na_by_k.get(k, 0)
note = f"n={len(by_k[k])}" + (f"\nn/a={na}" if na else "")
ax.text(i, ymax * 1.02, note, ha="center", va="bottom", fontsize=8, color="gray")
fig.tight_layout()
fig.savefig(out_path, dpi=150)
print(f"\n[INFO] Plot gespeichert: {out_path}")
@@ -92,26 +124,29 @@ def main() -> None:
ap = argparse.ArgumentParser(description="Auswertung der Kamera-Anzahl-Studie")
ap.add_argument("--input", default=str(RESULTS_DIR / "camera_study.json"))
ap.add_argument("--metric",
choices=["mean_abs_deg", "max_abs_deg", "mean_abs_mm", "max_abs_mm",
"finger_error_mm"],
choices=["finger_error_mm", "wrist_error_mm",
"mean_abs_deg", "max_abs_deg", "mean_abs_mm", "max_abs_mm"],
default="finger_error_mm",
help="Metrik für Tabelle und Plot (Standard: finger_error_mm)")
ap.add_argument("--out-plot",
default=str(RESULTS_DIR / "camera_count_vs_error.png"))
default=None,
help="Plot-Pfad (Standard: results/camera_count_<metric>.png)")
args = ap.parse_args()
data = load(args.input)
if not data:
return
by_k = group_by_k(data, args.metric)
if not by_k:
by_k, na_by_k = group_by_k(data, args.metric)
if not by_k and not na_by_k:
print(f"[ERROR] Keine Werte für Metrik '{args.metric}' gefunden.")
return
print_table(by_k, args.metric)
out_plot = args.out_plot or str(RESULTS_DIR / f"camera_count_{args.metric}.png")
print_table(by_k, na_by_k, args.metric)
print_best_worst(data, args.metric)
plot(by_k, args.metric, args.out_plot)
plot(by_k, na_by_k, args.metric, out_plot)
if __name__ == "__main__":

View File

@@ -26,6 +26,7 @@ import itertools
import json
import random
import re
import shutil
import subprocess
import sys
from pathlib import Path
@@ -101,7 +102,10 @@ def main() -> None:
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--robot", default=str(ROOT / "data" / "robot" / "robot.json"))
ap.add_argument("--force", action="store_true",
help="Existierende robot_state.json überschreiben")
help="Vorhandene robot_state.json neu rechnen (Pipeline erneut laufen)")
ap.add_argument("--clean", action="store_true",
help="Zwischenergebnisse vorher löschen und komplett neu rechnen. "
"Mit --scenes nur diese Szenen, ohne --scenes alles (inkl. Ergebnisdateien).")
ap.add_argument("--out", default=str(RESULTS_DIR / "camera_study.json"))
ap.add_argument("--csv", default=str(RESULTS_DIR / "camera_study.csv"))
args = ap.parse_args()
@@ -110,6 +114,7 @@ def main() -> None:
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
sim_dir = ROOT / "data" / "simulation"
study_root = ROOT / "data" / "camera_study"
scenes = discover_scenes(sim_dir)
if args.scenes:
want = {s if s.startswith("Scene") else f"Scene{s}" for s in args.scenes}
@@ -118,6 +123,25 @@ def main() -> None:
print("[ERROR] Keine Szenen mit pose.json und render_*.png gefunden.")
sys.exit(1)
# --clean: Zwischenergebnisse (und bei vollem Reset auch die Aggregat-Dateien) entfernen.
if args.clean:
if args.scenes:
for s in scenes:
d = study_root / s
if d.exists():
shutil.rmtree(d)
print(f"[CLEAN] entfernt {d}")
else:
if study_root.exists():
shutil.rmtree(study_root)
print(f"[CLEAN] entfernt {study_root}")
for f in (Path(args.out), Path(args.csv)):
if f.exists():
f.unlink()
print(f"[CLEAN] entfernt {f}")
# Nach dem Löschen muss die Pipeline zwangsläufig neu laufen.
args.force = True
print(f"[INFO] Szenen: {scenes}")
print(f"[INFO] Seed={args.seed} Samples/k={args.samples}\n")
@@ -168,32 +192,51 @@ def main() -> None:
"max_abs_deg": s.get("max_abs_deg"),
"mean_abs_mm": s.get("mean_abs_mm"),
"max_abs_mm": s.get("max_abs_mm"),
"wrist_error_mm": s.get("wrist_error_mm"),
"finger_error_mm": s.get("finger_error_mm"),
"n_unobservable": s.get("n_unobservable"),
}
all_results.append(row)
errs.append(row["mean_abs_deg"] or 0.0)
fe = row["finger_error_mm"]
fe_str = f" finger={fe:.2f}mm" if fe is not None else ""
print(f" {label}: mean={row['mean_abs_deg']:.3f}° max={row['max_abs_deg']:.3f}°{fe_str}")
we, fe = row["wrist_error_mm"], row["finger_error_mm"]
we_str = f"{we:.2f}mm" if we is not None else "n/a"
fe_str = f"{fe:.2f}mm" if fe is not None else "n/a"
print(f" {label}: mean={row['mean_abs_deg']:.3f}° "
f"wrist={we_str} finger={fe_str} unobs={row['n_unobservable']}")
if errs:
print(f" k={k} Zusammenfassung: mean={mean(errs):.3f}° "
f"min={min(errs):.3f}° max={max(errs):.3f}°")
# Ergebnisse schreiben
Path(args.out).write_text(json.dumps(all_results, indent=2), encoding="utf-8")
# Ergebnisse schreiben — andere Szenen erhalten (mergen statt überschreiben),
# damit ein Lauf über einzelne Szenen die übrigen nicht aus der Aggregat-Datei kippt.
out_path = Path(args.out)
processed = set(scenes)
merged: list[dict] = []
if out_path.exists():
try:
existing = json.loads(out_path.read_text(encoding="utf-8"))
merged = [r for r in existing if r.get("scene") not in processed]
except (json.JSONDecodeError, OSError):
merged = []
merged.extend(all_results)
merged.sort(key=lambda r: (r["scene"], r["k"], r["subset"]))
out_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
with open(args.csv, "w", encoding="utf-8") as f:
f.write("scene,k,subset,mean_abs_deg,max_abs_deg,mean_abs_mm,max_abs_mm,finger_error_mm\n")
for r in all_results:
f.write("scene,k,subset,mean_abs_deg,max_abs_deg,mean_abs_mm,max_abs_mm,"
"wrist_error_mm,finger_error_mm,n_unobservable\n")
for r in merged:
f.write(f"{r['scene']},{r['k']},{r['subset']},"
f"{r['mean_abs_deg'] or ''},"
f"{r['max_abs_deg'] or ''},"
f"{r['mean_abs_mm'] or ''},"
f"{r['max_abs_mm'] or ''},"
f"{r['finger_error_mm'] or ''}\n")
f"{r['wrist_error_mm'] if r['wrist_error_mm'] is not None else ''},"
f"{r['finger_error_mm'] if r['finger_error_mm'] is not None else ''},"
f"{r['n_unobservable'] if r['n_unobservable'] is not None else ''}\n")
print(f"\n[DONE] {len(all_results)} Ergebnisse -> {args.out}")
print(f"\n[DONE] {len(all_results)} neu, {len(merged)} gesamt -> {args.out}")
if __name__ == "__main__":

View File

@@ -9,6 +9,15 @@ Per-joint error:
revolute (y,z,a,b,c): angular error in degrees, wrap-aware (179 vs -179 = 2deg)
linear (x,e): error in millimetres
With --robot, additionally computes FK-based position errors (mm) at the points
in TRACK_POINTS — the euclidean distance between estimated and true world
position of a point on the robot:
wrist_error_mm : Hand origin (depends only on arm joints x,y,z,a)
finger_error_mm : FingerA tip (depends on the full chain x..e)
A point whose chain contains an UNOBSERVABLE joint yields None (the true
position is simply unknown) rather than a misleading value. n_unobservable
counts how many of the 7 joints were unobservable.
Prints a table and optionally writes a JSON summary. Returns nonzero if any
observable joint exceeds a tolerance (for scripted regression checks).
"""
@@ -27,7 +36,14 @@ from robot_fk import RobotFK # noqa: E402
LINEAR = {"x", "e"}
JOINTS = ["x", "y", "z", "a", "b", "c", "e"]
FINGER_LINK = "FingerA"
# Messpunkte entlang der Kette: name -> (link, lokaler Offset in mm).
# wrist = Hand-Ursprung (hängt nur von den Armgelenken x,y,z,a ab)
# finger = FingerA-Skelettspitze (hängt von der ganzen Kette ab)
TRACK_POINTS = {
"wrist": ("Hand", [0.0, 0.0, 0.0]),
"finger": ("FingerA", [0.0, -60.0, 0.0]),
}
def load_estimate(path: str) -> Dict[str, Dict[str, Any]]:
@@ -58,18 +74,45 @@ def joint_error(v: str, est: float, gt: float) -> float:
return abs(((est - gt + 180.0) % 360.0) - 180.0)
def finger_error_mm(est: Dict[str, Any], gt: Dict[str, float],
robot_path: Optional[str]) -> Optional[float]:
"""Euklidischer Abstand der FingerA-Position (FK) zwischen Schätzung und GT."""
if not robot_path:
def _point_world(fk: RobotFK, vals: Dict[str, float],
link: str, local: list) -> np.ndarray:
"""Weltposition eines lokalen Punktes auf einem Link (mm)."""
T = fk.compute(vals)
h = np.array([local[0], local[1], local[2], 1.0])
return (T[link] @ h)[:3]
def _dependent_joints(fk: RobotFK, gt_vals: Dict[str, float],
link: str, local: list,
eps: float = 5.0, thresh: float = 1e-6) -> set:
"""Gelenke, die diesen Punkt bewegen — numerisch per Perturbation am GT-Zustand.
Robust für beliebige Robotermodelle: ein Gelenk zählt als abhängig, wenn
eine kleine Auslenkung den Punkt messbar verschiebt. Die eigene Drehung des
Handgelenks z.B. bewegt den Hand-Ursprung nicht und wird korrekt ausgelassen.
"""
base = _point_world(fk, gt_vals, link, local)
deps = set()
for j in JOINTS:
v = dict(gt_vals)
v[j] = v[j] + eps
if float(np.linalg.norm(_point_world(fk, v, link, local) - base)) > thresh:
deps.add(j)
return deps
def point_error_mm(fk: RobotFK, est_vals: Dict[str, float], gt_vals: Dict[str, float],
observable: Dict[str, bool], link: str, local: list) -> Optional[float]:
"""Euklidischer Positionsfehler eines Punktes (mm).
Gibt None zurück, wenn irgendein Gelenk, von dem der Punkt abhängt,
unbeobachtbar ist — dann ist die wahre Position schlicht unbekannt.
"""
deps = _dependent_joints(fk, gt_vals, link, local)
if any(not observable.get(j, False) for j in deps):
return None
fk = RobotFK.from_file(robot_path)
est_vals = {v: est[v]["value"] for v in JOINTS}
gt_vals = {v: gt.get(v, 0.0) for v in JOINTS}
t_est = fk.compute(est_vals)
t_gt = fk.compute(gt_vals)
p_est = t_est[FINGER_LINK][:3, 3]
p_gt = t_gt[FINGER_LINK][:3, 3]
p_est = _point_world(fk, est_vals, link, local)
p_gt = _point_world(fk, gt_vals, link, local)
return float(np.linalg.norm(p_est - p_gt))
@@ -97,8 +140,19 @@ def evaluate(estimate_path: str, gt_path: str,
"max_abs_deg": max(ang_errs) if ang_errs else None,
"mean_abs_mm": (sum(lin_errs) / len(lin_errs)) if lin_errs else None,
"max_abs_mm": max(lin_errs) if lin_errs else None,
"finger_error_mm": finger_error_mm(est, gt, robot_path),
"n_unobservable": sum(1 for v in JOINTS if not est.get(v, {}).get("observable", False)),
}
# FK-basierte Positionsfehler (mm) je Messpunkt — nur wenn robot.json gegeben
if robot_path:
fk = RobotFK.from_file(robot_path)
est_vals = {v: est[v]["value"] for v in JOINTS}
gt_vals = {v: gt.get(v, 0.0) for v in JOINTS}
observable = {v: bool(est.get(v, {}).get("observable", False)) for v in JOINTS}
for name, (link, local) in TRACK_POINTS.items():
summary[f"{name}_error_mm"] = point_error_mm(
fk, est_vals, gt_vals, observable, link, local)
return {"rows": rows, "summary": summary}
@@ -128,9 +182,11 @@ def main() -> int:
mm = f"{s['mean_abs_mm']:.2f}" if s["mean_abs_mm"] is not None else "-"
xm = f"{s['max_abs_mm']:.2f}" if s["max_abs_mm"] is not None else "-"
print(f"angles: mean {md}deg / max {xd}deg | linear: mean {mm}mm / max {xm}mm")
fe = s.get("finger_error_mm")
if fe is not None:
print(f"finger ({FINGER_LINK}) position error: {fe:.2f} mm")
print(f"unobservable joints: {s.get('n_unobservable', 0)}")
for name in TRACK_POINTS:
pe = s.get(f"{name}_error_mm")
txt = f"{pe:.2f} mm" if pe is not None else "n/a (Gelenk unbeobachtbar)"
print(f"{name:>6} position error: {txt}")
if args.out:
json.dump(res, open(args.out, "w", encoding="utf-8"), indent=2)