Files
appRobotHoming/scripts/4_yAxis_rotation_reconstruction.py
2026-06-14 17:47:57 +02:00

350 lines
14 KiB
Python
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.
#!/usr/bin/env python3
"""
4_yAxis_rotation_reconstruction.py
====================================
Berechnet die Rotationsachse eines Gelenks aus drei Board-Erkennungs-Timestamps.
Gegeben drei Messungen (Pos A, B, C) in denen dieselben fremd-Marker
(link != 'Board') erkannt wurden bestimmt das Skript:
- Richtung der Rotationsachse (axisDir, Einheitsvektor)
- Referenzpunkt auf der Achse (axisPoint_mm)
- Residuen pro Punkt (Qualitätsmass)
Methode (doc/Kalibrierung.md → [4] Arm1, Verfahren B):
Jeder Marker bewegt sich auf einem Kreisbogen. Die Ebene des Kreises
steht senkrecht zur Rotationsachse → Normalenvektor = Achsenrichtung.
Der Umkreismittelpunkt des Dreiecks P1-P2-P3 liegt auf der Achse.
Statt nur dem Marker-Zentrum werden alle vier Ecken (corners_m) verwendet:
das liefert 4× mehr Datenpunkte und macht die Schätzung robuster.
Usage:
python3 4_yAxis_rotation_reconstruction.py <posA> <posB> <posC> [Optionen]
<posA/B/C> Verzeichnis mit aruco_marker_poses.json ODER direkter Dateipfad
Optionen:
--output, -o Ergebnis in JSON-Datei speichern
--pretty Eingerücktes JSON auf stdout
--link Marker-Link, der als "Referenz" gilt und NICHT verwendet wird
(Default: 'Board')
Output (stdout):
JSON mit axisDir, axisPoint_mm, residuals, tiltXY_deg, tiltYZ_deg, …
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
from typing import List, Optional, Tuple
import numpy as np
# ── Laden ─────────────────────────────────────────────────────────────────────
def load_poses(path: str) -> dict:
"""Lädt aruco_marker_poses.json akzeptiert Verzeichnis oder direkten Pfad."""
if os.path.isdir(path):
path = os.path.join(path, 'aruco_marker_poses.json')
if not os.path.exists(path):
raise FileNotFoundError(f'Nicht gefunden: {path}')
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def fremd_markers(data: dict, ref_link: str = 'Board') -> dict[int, dict]:
"""Gibt fremd-Marker als {marker_id: marker_dict} zurück."""
return {
m['marker_id']: m
for m in data.get('markers', [])
if m.get('link') != ref_link
}
def get_points_mm(marker: dict) -> List[np.ndarray]:
"""
Gibt alle 3D-Punkte eines Markers in mm zurück.
Bevorzugt corners_m (4 Ecken); Fallback: Zentrum position_mm.
"""
corners = marker.get('corners_m')
if corners:
return [np.array(c, dtype=float) * 1000.0 for c in corners]
pos = marker.get('position_mm')
if pos:
return [np.array(pos, dtype=float)]
pos_m = marker.get('position_m')
if pos_m:
return [np.array(pos_m, dtype=float) * 1000.0]
return []
# ── Geometrie ─────────────────────────────────────────────────────────────────
def circumcenter_and_normal(
P1: np.ndarray, P2: np.ndarray, P3: np.ndarray
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], float]:
"""
Umkreismittelpunkt + Normalenvektor des Dreiecks P1-P2-P3.
Rückgabe: (circumcenter_mm, normal, cross_length)
Bei Degeneration (kollinear / identisch): (None, None, cross_length)
"""
v1 = P2 - P1
v2 = P3 - P1
cross = np.cross(v1, v2)
cross_len = float(np.linalg.norm(cross))
if cross_len < 1e-6: # Punkte (fast) kollinear oder identisch
return None, None, cross_len
n = cross / cross_len
# Baryzentrische Gewichte → Umkreismittelpunkt
a2 = float(np.dot(P2 - P3, P2 - P3))
b2 = float(np.dot(P1 - P3, P1 - P3))
c2 = float(np.dot(P1 - P2, P1 - P2))
w1 = a2 * (b2 + c2 - a2)
w2 = b2 * (a2 + c2 - b2)
w3 = c2 * (a2 + b2 - c2)
w_sum = w1 + w2 + w3
if abs(w_sum) < 1e-12: # gleichseitiges / entartetes Dreieck
return None, None, cross_len
C = (w1 * P1 + w2 * P2 + w3 * P3) / w_sum
return C, n, cross_len
def point_line_distance(point: np.ndarray, line_pt: np.ndarray, line_dir: np.ndarray) -> float:
"""Abstand Punkt → Gerade (line_pt + t·line_dir, line_dir normiert)."""
diff = point - line_pt
return float(np.linalg.norm(diff - np.dot(diff, line_dir) * line_dir))
# ── Kernberechnung ────────────────────────────────────────────────────────────
def compute_rotation_axis(
posA: dict,
posB: dict,
posC: dict,
ref_link: str = 'Board',
min_radius_mm: float = 0.5,
min_movement_mm: float = 10.0,
) -> dict:
"""
Berechnet die Rotationsachse aus drei Messungen.
Parameter:
min_radius_mm Kreisradius unter dem ein Ecken-Triplet als degenerat gilt
min_movement_mm Minimale Zentren-Bewegung (max Paarweisabstand A/B/C)
Marker die sich weniger bewegen werden ignoriert.
Default 10 mm → filtert Board-nahe / fest stehende Marker.
"""
mA = fremd_markers(posA, ref_link)
mB = fremd_markers(posB, ref_link)
mC = fremd_markers(posC, ref_link)
common_ids = sorted(set(mA) & set(mB) & set(mC))
if not common_ids:
return {'ok': False, 'error': 'Keine gemeinsamen fremd-Marker in A+B+C'}
circumcenters: List[np.ndarray] = []
normals: List[np.ndarray] = []
skipped: List[dict] = []
marker_results: List[dict] = []
for mid in common_ids:
# ── Mindest-Bewegungs-Filter ───────────────────────────────────────────
# Marker die sich kaum bewegen liefern degenerate Umkreismittelpunkte.
# Wir vergleichen die Zentren (position_mm) der drei Messungen.
cA = np.array(mA[mid].get('position_mm', [0, 0, 0]), dtype=float)
cB = np.array(mB[mid].get('position_mm', [0, 0, 0]), dtype=float)
cC = np.array(mC[mid].get('position_mm', [0, 0, 0]), dtype=float)
max_movement = max(
np.linalg.norm(cB - cA),
np.linalg.norm(cC - cB),
np.linalg.norm(cC - cA),
)
if max_movement < min_movement_mm:
skipped.append({
'marker_id': mid,
'reason': 'Bewegung zu gering (kein rotierender Marker)',
'max_movement_mm': round(float(max_movement), 2),
'threshold_mm': min_movement_mm,
})
continue
pts_a = get_points_mm(mA[mid])
pts_b = get_points_mm(mB[mid])
pts_c = get_points_mm(mC[mid])
n_pts = min(len(pts_a), len(pts_b), len(pts_c))
ok_pts: List[np.ndarray] = []
ok_ns: List[np.ndarray] = []
for i in range(n_pts):
P1, P2, P3 = pts_a[i], pts_b[i], pts_c[i]
C, n, cross_len = circumcenter_and_normal(P1, P2, P3)
if C is None:
skipped.append({
'marker_id': mid, 'point_idx': i,
'reason': 'degenerat (kollinear)',
'cross_len': round(cross_len, 6),
})
continue
radius = float(np.linalg.norm(C - P1))
if radius < min_radius_mm:
skipped.append({
'marker_id': mid, 'point_idx': i,
'reason': 'radius zu klein (Bewegung zu gering)',
'radius_mm': round(radius, 4),
})
continue
ok_pts.append(C)
ok_ns.append(n)
circumcenters.append(C)
normals.append(n)
cc_arr = np.array(ok_pts) if ok_pts else np.empty((0, 3))
marker_results.append({
'marker_id': mid,
'n_points_used': len(ok_pts),
'n_points_total': n_pts,
'circumcenter_mean_mm': cc_arr.mean(axis=0).round(3).tolist()
if len(ok_pts) else None,
})
if not normals:
return {
'ok': False,
'error': 'Alle Triplets degenerat Bewegung zu gering oder Marker kollinear.',
'skipped': skipped,
}
normals_arr = np.array(normals) # (N, 3)
circumcenters_arr = np.array(circumcenters) # (N, 3)
# ── Achsenrichtung ─────────────────────────────────────────────────────────
# Alle Normalen auf dieselbe Halbkugel (Vorzeichenanpassung)
ref = normals_arr[0]
signs = np.where(normals_arr @ ref >= 0, 1.0, -1.0)
aligned = normals_arr * signs[:, np.newaxis]
mean_n = aligned.mean(axis=0)
axis_dir = mean_n / np.linalg.norm(mean_n)
# Streuung der Normalen (Winkelresidum)
cos_angles = np.clip(aligned @ axis_dir, -1, 1)
angle_residuals_deg = np.degrees(np.arccos(cos_angles))
# ── Referenzpunkt ──────────────────────────────────────────────────────────
axis_point = circumcenters_arr.mean(axis=0)
# ── Abstandsresiduen ───────────────────────────────────────────────────────
dist_residuals = np.array([
point_line_distance(C, axis_point, axis_dir)
for C in circumcenters_arr
])
# ── Abweichung von Y-Achse [0, 1, 0] in Roboter-Koordinaten ──────────────
ax, ay, az = axis_dir
tilt_xy_deg = math.degrees(math.atan2(ax, ay)) # Kippung in XY-Ebene
tilt_yz_deg = math.degrees(math.atan2(az, ay)) # Kippung in YZ-Ebene
used_ids = [r['marker_id'] for r in marker_results if r['n_points_used'] > 0]
return {
'ok': True,
'axisDir': axis_dir.round(6).tolist(),
'axisPoint_mm': axis_point.round(3).tolist(),
'tiltXY_deg': round(tilt_xy_deg, 4),
'tiltYZ_deg': round(tilt_yz_deg, 4),
'numMarkersUsed': len(used_ids),
'numMarkersCommon': len(common_ids),
'usedMarkerIds': used_ids,
'commonMarkerIds': common_ids,
'numPoints': len(normals),
'residual_dist_mean_mm': round(float(dist_residuals.mean()), 3),
'residual_dist_max_mm': round(float(dist_residuals.max()), 3),
'residual_dist_mm': dist_residuals.round(3).tolist(),
'residual_angle_mean_deg': round(float(angle_residuals_deg.mean()), 4),
'residual_angle_max_deg': round(float(angle_residuals_deg.max()), 4),
'markerResults': marker_results,
'skipped': skipped,
}
# ── CLI ───────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description='Rotationsachse aus drei Board-Timestamps berechnen'
)
parser.add_argument('posA', help='Timestamp A Verzeichnis oder aruco_marker_poses.json')
parser.add_argument('posB', help='Timestamp B Verzeichnis oder aruco_marker_poses.json')
parser.add_argument('posC', help='Timestamp C Verzeichnis oder aruco_marker_poses.json')
parser.add_argument('--output', '-o', metavar='FILE',
help='Ergebnis zusätzlich in JSON-Datei speichern')
parser.add_argument('--pretty', action='store_true',
help='Eingerücktes JSON auf stdout')
parser.add_argument('--link', default='Board',
help='Referenz-Link der Board-Marker (Default: Board)')
parser.add_argument('--min-radius', type=float, default=0.5,
help='Min. Kreisradius mm (Default 0.5)')
parser.add_argument('--min-movement', type=float, default=10.0,
help='Min. Marker-Zentrumsbewegung mm (Default 10.0)')
args = parser.parse_args()
try:
data_a = load_poses(args.posA)
data_b = load_poses(args.posB)
data_c = load_poses(args.posC)
except FileNotFoundError as e:
print(f'Fehler: {e}', file=sys.stderr)
sys.exit(1)
result = compute_rotation_axis(data_a, data_b, data_c,
ref_link=args.link,
min_radius_mm=args.min_radius,
min_movement_mm=args.min_movement)
indent = 2 if args.pretty else None
out_str = json.dumps(result, indent=indent, ensure_ascii=False)
print(out_str)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(out_str)
print(f'Ergebnis gespeichert: {args.output}', file=sys.stderr)
if result['ok']:
r = result
fmt = lambda v: f'{v:+.3f}°'
print('', file=sys.stderr)
print('─── Rotationsachse ───────────────────────────────────────', file=sys.stderr)
print(f" Richtung: [{', '.join(f'{v:.4f}' for v in r['axisDir'])}]", file=sys.stderr)
print(f" Referenzpunkt: [{', '.join(f'{v:.1f}' for v in r['axisPoint_mm'])}] mm", file=sys.stderr)
print(f" Abw. von Y: XY={fmt(r['tiltXY_deg'])} YZ={fmt(r['tiltYZ_deg'])}", file=sys.stderr)
print(f" Marker: {r['numMarkersUsed']} genutzt / {r['numMarkersCommon']} gemeinsam "
f"Punkte: {r['numPoints']}", file=sys.stderr)
print(f" Residuen Abstand: ⌀{r['residual_dist_mean_mm']:.2f} mm "
f"max={r['residual_dist_max_mm']:.2f} mm", file=sys.stderr)
print(f" Residuen Winkel: ⌀{r['residual_angle_mean_deg']:.3f}° "
f"max={r['residual_angle_max_deg']:.3f}°", file=sys.stderr)
print('──────────────────────────────────────────────────────────', file=sys.stderr)
else:
print(f"Fehler: {result['error']}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()