#!/usr/bin/env python3 """ 4_robotState_estimation_v2.py Sequential robot-state estimation from optimized 3D ArUco marker positions. Mathematical idea ----------------- This script estimates a robot state q from already optimized marker positions p_i^obs. The robot state consists of: - a world pose for the root link (translation + rotation) - one scalar variable for each actuated joint Forward kinematics predicts each marker position: p_i^pred(q) = T_world(link(i); q) * p_i^local Instead of solving the whole robot at once, we solve it sequentially by kinematic prefix: stage 0: root link stage 1: root + first child layer stage 2: root + first three connected elements ... ... Each stage reuses the previous solution as initialization and only activates the joint variables that belong to the current kinematic prefix. The residual minimized at each stage is: sum_i w_i ||p_i^pred(q) - p_i^obs||^2 + weak priors on the active joint values + weak prior on the root pose This is intentionally conservative and debuggable. It is designed so that: - early links can be resolved first, - later links only refine an already plausible state, - one visible marker on an early rigid prefix can already fix a lot of state, - ambiguous branches can later be resolved by adding more links. Optional future extension: raw detections + camera poses can be added later as visibility / normal cues, but this script intentionally works directly from aruco_positions_optimized*.json. Dependencies ------------ numpy, scipy (optional but strongly recommended) """ from __future__ import annotations import argparse import json import math import os import sys import time from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple import numpy as np # ============================================================================= # JSON / path helpers # ============================================================================= def resolve_path(path: str) -> str: path = os.path.expanduser(path) if os.path.isabs(path): return path return os.path.abspath(path) def load_json(path: str) -> Any: with open(resolve_path(path), "r", encoding="utf-8") as f: return json.load(f) def save_json(path: str, data: Any) -> None: with open(resolve_path(path), "w", encoding="utf-8") as f: json.dump(data, f, indent=2) # ============================================================================= # Small math helpers # ============================================================================= def safe_norm(v: np.ndarray, eps: float = 1e-12) -> float: return float(np.linalg.norm(v) + eps) def normalize(v: np.ndarray, eps: float = 1e-12) -> np.ndarray: v = np.asarray(v, dtype=np.float64) return v / safe_norm(v, eps) def get_length_scale(robot_data: Dict[str, Any]) -> float: units = robot_data.get("units", {}) or {} length_unit = str(units.get("length", "")).strip().lower() if length_unit in ("mm", "millimeter", "millimeters"): return 1.0 / 1000.0 if length_unit in ("cm", "centimeter", "centimeters"): return 1.0 / 100.0 return 1.0 def rotation_matrix_xyz(rx: float, ry: float, rz: float, degrees: bool = False) -> np.ndarray: """Rotation order: X then Y then Z.""" if degrees: rx, ry, rz = math.radians(rx), math.radians(ry), math.radians(rz) cx, sx = math.cos(rx), math.sin(rx) cy, sy = math.cos(ry), math.sin(ry) cz, sz = math.cos(rz), math.sin(rz) rx_m = np.array([[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]], dtype=np.float64) ry_m = np.array([[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]], dtype=np.float64) rz_m = np.array([[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]], dtype=np.float64) return rz_m @ ry_m @ rx_m def axis_angle_rotation(axis: np.ndarray, angle_rad: float) -> np.ndarray: axis = normalize(axis) x, y, z = axis c = math.cos(angle_rad) s = math.sin(angle_rad) C = 1.0 - c return np.array([ [c + x * x * C, x * y * C - z * s, x * z * C + y * s], [y * x * C + z * s, c + y * y * C, y * z * C - x * s], [z * x * C - y * s, z * y * C + x * s, c + z * z * C] ], dtype=np.float64) def make_transform(R: Optional[np.ndarray] = None, t: Optional[np.ndarray] = None) -> np.ndarray: T = np.eye(4, dtype=np.float64) if R is not None: T[:3, :3] = np.asarray(R, dtype=np.float64) if t is not None: T[:3, 3] = np.asarray(t, dtype=np.float64).reshape(3) return T def transform_point(T: np.ndarray, p: np.ndarray) -> np.ndarray: p4 = np.ones(4, dtype=np.float64) p4[:3] = np.asarray(p, dtype=np.float64).reshape(3) return (T @ p4)[:3] def matrix_to_euler_xyz(R: np.ndarray) -> np.ndarray: """Return XYZ Euler angles in degrees.""" R = np.asarray(R, dtype=np.float64) sy = math.sqrt(R[0, 0] * R[0, 0] + R[1, 0] * R[1, 0]) singular = sy < 1e-9 if not singular: x = math.atan2(R[2, 1], R[2, 2]) y = math.atan2(-R[2, 0], sy) z = math.atan2(R[1, 0], R[0, 0]) else: x = math.atan2(-R[1, 2], R[1, 1]) y = math.atan2(-R[2, 0], sy) z = 0.0 return np.degrees([x, y, z]) def wrap_angle_rad(a: float) -> float: return (a + math.pi) % (2.0 * math.pi) - math.pi def kabsch(P: np.ndarray, Q: np.ndarray, W: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray]: """Find R, t such that R @ P + t ≈ Q.""" P = np.asarray(P, dtype=np.float64) Q = np.asarray(Q, dtype=np.float64) assert P.shape == Q.shape and P.shape[1] == 3 if W is None: W = np.ones(len(P), dtype=np.float64) W = np.asarray(W, dtype=np.float64).reshape(-1) W = np.maximum(W, 1e-12) W = W / np.sum(W) p_cent = np.sum(P * W[:, None], axis=0) q_cent = np.sum(Q * W[:, None], axis=0) P0 = P - p_cent Q0 = Q - q_cent H = (P0 * W[:, None]).T @ Q0 U, S, Vt = np.linalg.svd(H) R = Vt.T @ U.T if np.linalg.det(R) < 0: Vt[-1, :] *= -1.0 R = Vt.T @ U.T t = q_cent - R @ p_cent return R, t # ============================================================================= # Data structures # ============================================================================= @dataclass class MarkerInfo: marker_id: int link_name: str local_pos_m: np.ndarray size: Optional[float] = None normal: Optional[np.ndarray] = None name: str = "" @dataclass class LinkInfo: name: str parent: Optional[str] joint_type: Optional[str] joint_axis: Optional[np.ndarray] joint_origin_m: np.ndarray joint_rotation_deg: np.ndarray joint_variable: Optional[str] mount_position_m: np.ndarray mount_rotation_deg: np.ndarray markers: List[MarkerInfo] @dataclass class StageResult: depth: int active_links: List[str] active_joint_vars: List[str] mean_error_m: Optional[float] rms_error_m: Optional[float] worst_error_m: Optional[float] num_markers_used: int optimizer_info: Dict[str, Any] @dataclass class EstimationConfig: root_pose_prior_weight: float = 0.1 joint_prior_weight: float = 0.05 marker_base_weight: float = 1.0 robust_loss: str = "soft_l1" max_iterations: int = 120 include_joint_prior: bool = True include_root_prior: bool = True sequential: bool = True show_stage_reports: bool = True # ============================================================================= # Robot parsing # ============================================================================= def parse_robot(robot_data: Dict[str, Any]) -> Tuple[Dict[str, LinkInfo], Dict[int, MarkerInfo], List[str]]: length_scale = get_length_scale(robot_data) links = robot_data.get("links", {}) or {} link_infos: Dict[str, LinkInfo] = {} marker_by_id: Dict[int, MarkerInfo] = {} issues: List[str] = [] for link_name, link_data in links.items(): parent = link_data.get("parent", None) joint = link_data.get("jointToParent", {}) or {} joint_type = joint.get("type", None) axis = joint.get("axis", None) if axis is not None: axis = np.asarray(axis, dtype=np.float64) if safe_norm(axis) > 1e-12: axis = normalize(axis) else: axis = None joint_origin = np.asarray(joint.get("origin", [0, 0, 0]), dtype=np.float64) * float(length_scale) joint_rotation_deg = np.asarray(joint.get("rotation", [0, 0, 0]), dtype=np.float64) joint_variable = joint.get("variable", None) mount_position = np.asarray(link_data.get("mountPosition", [0, 0, 0]), dtype=np.float64) * float(length_scale) mount_rotation_deg = np.asarray(link_data.get("mountRotation", [0, 0, 0]), dtype=np.float64) markers_data = link_data.get("markers", []) or [] markers: List[MarkerInfo] = [] for idx, m in enumerate(markers_data): marker_id = int(m.get("id", -1)) pos = m.get("position", None) if marker_id < 0 or pos is None or len(pos) != 3: issues.append(f"[WARN] link='{link_name}': skipped invalid marker entry at index {idx}") continue if marker_id in marker_by_id: issues.append( f"[WARN] duplicate marker ID {marker_id} in link '{link_name}' " f"(already in '{marker_by_id[marker_id].link_name}'); using first occurrence" ) continue normal = None if "normal" in m and isinstance(m["normal"], list) and len(m["normal"]) == 3: normal = np.asarray(m["normal"], dtype=np.float64) marker = MarkerInfo( marker_id=marker_id, link_name=link_name, local_pos_m=np.asarray(pos, dtype=np.float64) * float(length_scale), size=m.get("size", None), normal=normal, name=str(m.get("name", f"marker_{marker_id}")), ) markers.append(marker) marker_by_id[marker_id] = marker link_infos[link_name] = LinkInfo( name=link_name, parent=parent, joint_type=joint_type, joint_axis=axis, joint_origin_m=joint_origin, joint_rotation_deg=joint_rotation_deg, joint_variable=joint_variable, mount_position_m=mount_position, mount_rotation_deg=mount_rotation_deg, markers=markers, ) return link_infos, marker_by_id, issues def topological_links(link_infos: Dict[str, LinkInfo]) -> List[str]: children = {name: [] for name in link_infos} roots = [] for name, info in link_infos.items(): if info.parent is None: roots.append(name) else: children.setdefault(info.parent, []).append(name) order: List[str] = [] queue = list(roots) seen = set() while queue: cur = queue.pop(0) if cur in seen: continue seen.add(cur) order.append(cur) for ch in children.get(cur, []): queue.append(ch) for name in link_infos: if name not in seen: order.append(name) return order def compute_link_depths(link_infos: Dict[str, LinkInfo]) -> Dict[str, int]: depths: Dict[str, int] = {} def depth_of(name: str) -> int: if name in depths: return depths[name] info = link_infos[name] if info.parent is None: depths[name] = 0 else: depths[name] = depth_of(info.parent) + 1 return depths[name] for name in link_infos: depth_of(name) return depths # ============================================================================= # Marker weights # ============================================================================= def marker_weight(marker_info: MarkerInfo, base_weight: float = 1.0, ref_size: float = 25.0) -> float: """ Weight the marker residuals lightly by marker size. Larger markers tend to be more stable in image space. """ w = base_weight if marker_info.size is not None: try: size = float(marker_info.size) if size > 0: w *= max(0.35, math.sqrt(size / ref_size)) except Exception: pass return w # ============================================================================= # Forward kinematics # ============================================================================= def link_static_transform(link: LinkInfo) -> np.ndarray: Rm = rotation_matrix_xyz(*link.mount_rotation_deg, degrees=True) return make_transform(Rm, link.mount_position_m) def joint_motion_transform(link: LinkInfo, q_value: float) -> np.ndarray: joint_type = (link.joint_type or "").strip().lower() if link.joint_axis is None: return np.eye(4, dtype=np.float64) axis = normalize(link.joint_axis) if joint_type == "linear": return make_transform(np.eye(3), axis * float(q_value)) if joint_type == "revolute": return make_transform(axis_angle_rotation(axis, float(q_value)), np.zeros(3)) return np.eye(4, dtype=np.float64) def world_to_link_transform( link_name: str, link_infos: Dict[str, LinkInfo], state: Dict[str, Any], cache: Dict[str, np.ndarray], ) -> np.ndarray: if link_name in cache: return cache[link_name] link = link_infos[link_name] if link.parent is None: T = make_transform(state["root_R"], state["root_t"]) cache[link_name] = T return T parent_T = world_to_link_transform(link.parent, link_infos, state, cache) # Convention: # parent frame -> joint origin -> joint motion -> static joint rotation -> child mount transform T = parent_T @ make_transform(np.eye(3), link.joint_origin_m) q = state["joint_values"].get(link.joint_variable, state["joint_defaults"].get(link.joint_variable, 0.0)) T = T @ joint_motion_transform(link, q) R_static = rotation_matrix_xyz(*link.joint_rotation_deg, degrees=True) T = T @ make_transform(R_static, np.zeros(3)) T = T @ link_static_transform(link) cache[link_name] = T return T def predict_marker_positions( link_infos: Dict[str, LinkInfo], state: Dict[str, Any], ) -> Dict[int, np.ndarray]: pred: Dict[int, np.ndarray] = {} cache: Dict[str, np.ndarray] = {} for link_name, link in link_infos.items(): T = world_to_link_transform(link_name, link_infos, state, cache) for marker in link.markers: pred[marker.marker_id] = transform_point(T, marker.local_pos_m) return pred # ============================================================================= # Initial parameters # ============================================================================= def infer_joint_defaults(robot_data: Dict[str, Any], link_infos: Dict[str, LinkInfo]) -> Dict[str, float]: defaults_raw = robot_data.get("defaultPosition", {}) or {} out: Dict[str, float] = {} for link in link_infos.values(): var = link.joint_variable if not var: continue raw_val = defaults_raw.get(var, 0.0) joint_type = (link.joint_type or "").strip().lower() if joint_type == "linear": out[var] = float(raw_val) * get_length_scale(robot_data) elif joint_type == "revolute": out[var] = math.radians(float(raw_val)) else: out[var] = float(raw_val) return out def initial_root_pose( link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], ) -> Tuple[np.ndarray, np.ndarray]: roots = [name for name, info in link_infos.items() if info.parent is None] if not roots: return np.eye(3, dtype=np.float64), np.zeros(3, dtype=np.float64) root = roots[0] root_info = link_infos[root] P = [] Q = [] W = [] for marker in root_info.markers: if marker.marker_id in observed_markers: obs = np.asarray(observed_markers[marker.marker_id]["position_m"], dtype=np.float64) P.append(marker.local_pos_m) Q.append(obs) W.append(marker_weight(marker, base_weight=1.0)) if len(P) >= 3: return kabsch(np.asarray(P), np.asarray(Q), np.asarray(W)) return np.eye(3, dtype=np.float64), np.zeros(3, dtype=np.float64) def build_initial_state( robot_data: Dict[str, Any], link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], ) -> Dict[str, Any]: root_R, root_t = initial_root_pose(link_infos, observed_markers) joint_defaults = infer_joint_defaults(robot_data, link_infos) return { "root_R": root_R, "root_t": root_t, "joint_defaults": joint_defaults, "joint_values": dict(joint_defaults), } # ============================================================================= # Stage helpers # ============================================================================= def active_links_for_depth(link_infos: Dict[str, LinkInfo], depths: Dict[str, int], max_depth: int) -> List[str]: ordered = topological_links(link_infos) return [name for name in ordered if depths.get(name, 0) <= max_depth] def active_joint_vars_for_links(link_infos: Dict[str, LinkInfo], active_links: List[str]) -> List[str]: vars_out: List[str] = [] seen = set() for name in active_links: link = link_infos[name] if not link.joint_variable: continue if link.joint_variable in seen: continue seen.add(link.joint_variable) vars_out.append(link.joint_variable) return vars_out def active_observations_for_links( link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], active_links: List[str], ) -> Dict[int, Dict[str, Any]]: allowed_links = set(active_links) out: Dict[int, Dict[str, Any]] = {} for marker_id, obs in observed_markers.items(): link_name = obs.get("link", None) if link_name in allowed_links: out[marker_id] = obs return out def stage_variables_to_vector( state: Dict[str, Any], active_joint_vars: List[str], ) -> np.ndarray: try: from scipy.spatial.transform import Rotation root_rvec = Rotation.from_matrix(state["root_R"]).as_rotvec() except Exception: root_rvec = np.zeros(3, dtype=np.float64) root_t = np.asarray(state["root_t"], dtype=np.float64).reshape(3) values = list(root_t) + list(np.asarray(root_rvec, dtype=np.float64)) for var in active_joint_vars: values.append(float(state["joint_values"].get(var, state["joint_defaults"].get(var, 0.0)))) return np.asarray(values, dtype=np.float64) def vector_to_stage_state( x: np.ndarray, template_state: Dict[str, Any], active_joint_vars: List[str], ) -> Dict[str, Any]: try: from scipy.spatial.transform import Rotation except Exception: Rotation = None x = np.asarray(x, dtype=np.float64).reshape(-1) out = { "root_t": x[0:3].copy(), "root_R": template_state["root_R"].copy(), "joint_defaults": dict(template_state["joint_defaults"]), "joint_values": dict(template_state["joint_values"]), } root_rvec = x[3:6].copy() if Rotation is not None: out["root_R"] = Rotation.from_rotvec(root_rvec).as_matrix() else: angle = safe_norm(root_rvec) if angle < 1e-12: out["root_R"] = np.eye(3, dtype=np.float64) else: out["root_R"] = axis_angle_rotation(root_rvec / angle, angle) idx = 6 for var in active_joint_vars: if idx >= len(x): break out["joint_values"][var] = float(x[idx]) idx += 1 return out def residuals_stage( x: np.ndarray, link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], template_state: Dict[str, Any], active_joint_vars: List[str], cfg: EstimationConfig, ) -> np.ndarray: state = vector_to_stage_state(x, template_state, active_joint_vars) pred = predict_marker_positions(link_infos, state) res: List[float] = [] # Marker residuals for marker_id, obs in observed_markers.items(): if marker_id not in pred: continue link_name = obs.get("link", None) marker = None if link_name is not None and link_name in link_infos: for m in link_infos[link_name].markers: if m.marker_id == marker_id: marker = m break if marker is None: continue w = marker_weight(marker, base_weight=cfg.marker_base_weight) sqrt_w = math.sqrt(max(1e-12, w)) p_obs = np.asarray(obs["position_m"], dtype=np.float64) p_pred = pred[marker_id] d = (p_pred - p_obs) * sqrt_w res.extend(d.tolist()) # Root prior if cfg.include_root_prior: root_t_prior = np.asarray(template_state["root_t"], dtype=np.float64) try: from scipy.spatial.transform import Rotation root_rvec_prior = Rotation.from_matrix(template_state["root_R"]).as_rotvec() except Exception: root_rvec_prior = np.zeros(3, dtype=np.float64) root_t = x[0:3] root_rvec = x[3:6] w = math.sqrt(max(1e-12, cfg.root_pose_prior_weight)) res.extend(((root_t - root_t_prior) * w).tolist()) res.extend(((root_rvec - root_rvec_prior) * w).tolist()) # Joint priors if cfg.include_joint_prior: idx = 6 w = math.sqrt(max(1e-12, cfg.joint_prior_weight)) for var in active_joint_vars: if idx >= len(x): break q = x[idx] q0 = template_state["joint_defaults"].get(var, 0.0) # Revolute angles should not wrap too aggressively during optimization, # but the prior itself should be short-arc. if abs(q0) <= math.pi * 2.5: dq = wrap_angle_rad(float(q - q0)) else: dq = float(q - q0) res.append(dq * w) idx += 1 return np.asarray(res, dtype=np.float64) def optimize_stage( link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], template_state: Dict[str, Any], active_joint_vars: List[str], cfg: EstimationConfig, ) -> Tuple[Dict[str, Any], Dict[str, Any]]: try: from scipy.optimize import least_squares except ImportError: print("[WARN] scipy not available; returning template state for this stage.") return template_state, {"success": False, "message": "scipy unavailable"} x0 = stage_variables_to_vector(template_state, active_joint_vars) print(f"[INFO] Stage variables: {len(x0)} (root pose + {len(active_joint_vars)} active joints)") result = least_squares( lambda x: residuals_stage(x, link_infos, observed_markers, template_state, active_joint_vars, cfg), x0, loss=cfg.robust_loss, f_scale=1.0, max_nfev=cfg.max_iterations, verbose=1, ) final_state = vector_to_stage_state(result.x, template_state, active_joint_vars) opt_info = { "cost": float(np.sum(result.fun ** 2)), "success": bool(result.success), "status": int(result.status), "message": str(result.message), "nfev": int(result.nfev), "njev": int(getattr(result, "njev", -1)), } final_state["_optimizer"] = opt_info return final_state, opt_info def compute_error_stats( link_infos: Dict[str, LinkInfo], state: Dict[str, Any], observed_markers: Dict[int, Dict[str, Any]], ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: pred = predict_marker_positions(link_infos, state) marker_reports: List[Dict[str, Any]] = [] errors = [] marker_to_link = {} marker_meta = {} for link in link_infos.values(): for m in link.markers: marker_to_link[m.marker_id] = link.name marker_meta[m.marker_id] = m for marker_id, obs in observed_markers.items(): if marker_id not in pred: continue p_obs = np.asarray(obs["position_m"], dtype=np.float64) p_pred = pred[marker_id] err = p_pred - p_obs err_norm = float(np.linalg.norm(err)) errors.append(err_norm) m = marker_meta.get(marker_id) marker_reports.append( { "marker_id": int(marker_id), "link": marker_to_link.get(marker_id, "unknown"), "observed_position_m": [float(x) for x in p_obs], "predicted_position_m": [float(x) for x in p_pred], "error_m": [float(x) for x in err], "error_norm_m": err_norm, "error_norm_mm": err_norm * 1000.0, "marker_size": None if m is None else m.size, } ) if errors: arr = np.asarray(errors, dtype=np.float64) stats = { "num_markers_used": int(len(errors)), "mean_error_m": float(np.mean(arr)), "median_error_m": float(np.median(arr)), "rms_error_m": float(np.sqrt(np.mean(arr ** 2))), "worst_error_m": float(np.max(arr)), "p80_error_m": float(np.quantile(arr, 0.80)), "p90_error_m": float(np.quantile(arr, 0.90)), } else: stats = { "num_markers_used": 0, "mean_error_m": None, "median_error_m": None, "rms_error_m": None, "worst_error_m": None, "p80_error_m": None, "p90_error_m": None, } return marker_reports, stats def print_stage_stats(stage_idx: int, depth: int, stage_result: StageResult) -> None: print( f"[INFO] Stage {stage_idx} (depth={depth}) | " f"active_links={len(stage_result.active_links)} | " f"active_joint_vars={len(stage_result.active_joint_vars)} | " f"markers={stage_result.num_markers_used}" ) if stage_result.mean_error_m is not None: print( f"[INFO] Stage {stage_idx} error: " f"mean={stage_result.mean_error_m*1000.0:.2f} mm | " f"rms={stage_result.rms_error_m*1000.0:.2f} mm | " f"worst={stage_result.worst_error_m*1000.0:.2f} mm" ) if stage_result.optimizer_info: print( f"[INFO] Stage {stage_idx} optimizer: " f"success={stage_result.optimizer_info.get('success', False)} | " f"nfev={stage_result.optimizer_info.get('nfev', -1)} | " f"cost={stage_result.optimizer_info.get('cost', 0.0):.6f}" ) def sequential_optimize_state( robot_data: Dict[str, Any], link_infos: Dict[str, LinkInfo], observed_markers: Dict[int, Dict[str, Any]], initial_state: Dict[str, Any], cfg: EstimationConfig, ) -> Tuple[Dict[str, Any], List[StageResult]]: """ Sequential prefix optimization: - solve links up to depth 0, - then depth 1, - then depth 2, ... Each new stage starts from the previous state and only activates the joint variables that belong to the current prefix. """ depths = compute_link_depths(link_infos) max_depth = max(depths.values()) if depths else 0 current_state = { "root_R": initial_state["root_R"].copy(), "root_t": initial_state["root_t"].copy(), "joint_defaults": dict(initial_state["joint_defaults"]), "joint_values": dict(initial_state["joint_values"]), } stage_results: List[StageResult] = [] for depth in range(0, max_depth + 1): active_links = active_links_for_depth(link_infos, depths, depth) active_joint_vars = active_joint_vars_for_links(link_infos, active_links) active_obs = active_observations_for_links(link_infos, observed_markers, active_links) # If a stage introduces no observations, we still may want the chain prefix, # but there is nothing to fit against. In that case just keep the current state. if len(active_obs) == 0: stage_state = current_state stage_res = StageResult( depth=depth, active_links=active_links, active_joint_vars=active_joint_vars, mean_error_m=None, rms_error_m=None, worst_error_m=None, num_markers_used=0, optimizer_info={"skipped": True, "reason": "no active observations"}, ) stage_results.append(stage_res) if cfg.show_stage_reports: print_stage_stats(len(stage_results) - 1, depth, stage_res) continue # Optimize only the active prefix variables. template_state = current_state optimized_state, opt_info = optimize_stage( link_infos=link_infos, observed_markers=active_obs, template_state=template_state, active_joint_vars=active_joint_vars, cfg=cfg, ) # Merge optimized active values into the running state. current_state["root_R"] = optimized_state["root_R"].copy() current_state["root_t"] = optimized_state["root_t"].copy() current_state["joint_values"].update(optimized_state["joint_values"]) # Evaluate the active prefix after the stage. marker_reports, stats = compute_error_stats(link_infos, current_state, active_obs) stage_res = StageResult( depth=depth, active_links=active_links, active_joint_vars=active_joint_vars, mean_error_m=stats["mean_error_m"], rms_error_m=stats["rms_error_m"], worst_error_m=stats["worst_error_m"], num_markers_used=stats["num_markers_used"], optimizer_info=opt_info, ) stage_results.append(stage_res) if cfg.show_stage_reports: print_stage_stats(len(stage_results) - 1, depth, stage_res) if stage_results: current_state["_optimizer"] = stage_results[-1].optimizer_info return current_state, stage_results # ============================================================================= # Output # ============================================================================= def state_to_json( robot_data: Dict[str, Any], link_infos: Dict[str, LinkInfo], state: Dict[str, Any], observed_markers: Dict[int, Dict[str, Any]], marker_reports: List[Dict[str, Any]], stats: Dict[str, Any], input_file: str, robot_file: str, stage_results: List[StageResult], ) -> Dict[str, Any]: movements = {} for link_name in topological_links(link_infos): link = link_infos[link_name] if not link.joint_variable: continue q = state["joint_values"].get(link.joint_variable, state["joint_defaults"].get(link.joint_variable, 0.0)) joint_type = (link.joint_type or "").strip().lower() if joint_type == "revolute": movements[link.joint_variable] = { "value_rad": float(q), "value_deg": float(math.degrees(q)), "joint_type": joint_type, "link": link_name, } elif joint_type == "linear": movements[link.joint_variable] = { "value_m": float(q), "value_mm": float(q * 1000.0), "joint_type": joint_type, "link": link_name, } else: movements[link.joint_variable] = { "value": float(q), "joint_type": joint_type, "link": link_name, } link_pose_entries = [] cache: Dict[str, np.ndarray] = {} for link_name in topological_links(link_infos): T = world_to_link_transform(link_name, link_infos, state, cache) R = T[:3, :3] t = T[:3, 3] link = link_infos[link_name] num_used = sum(1 for m in link.markers if m.marker_id in observed_markers) link_pose_entries.append( { "link": link_name, "parent": link.parent, "position_m": [float(x) for x in t], "rotation_matrix": [[float(v) for v in row] for row in R], "euler_xyz_deg": [float(x) for x in matrix_to_euler_xyz(R)], "num_observed_markers": int(num_used), "num_markers_total": int(len(link.markers)), } ) root_link = next((name for name, info in link_infos.items() if info.parent is None), None) root_T = make_transform(state["root_R"], state["root_t"]) out = { "schema_version": "2.0", "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "source": { "marker_positions_file": input_file, "robot_file": robot_file, }, "summary": { "num_links": int(len(link_infos)), "num_observed_markers": int(len(observed_markers)), "num_link_markers": int(sum(len(li.markers) for li in link_infos.values())), "root_link": root_link, "optimizer": state.get("_optimizer", {}), "fit_stats": stats, "stages": [ { "depth": int(s.depth), "active_links": s.active_links, "active_joint_vars": s.active_joint_vars, "mean_error_m": s.mean_error_m, "rms_error_m": s.rms_error_m, "worst_error_m": s.worst_error_m, "num_markers_used": int(s.num_markers_used), "optimizer_info": s.optimizer_info, } for s in stage_results ], }, "world_pose": { "root_translation_m": [float(x) for x in root_T[:3, 3]], "root_rotation_matrix": [[float(v) for v in row] for row in root_T[:3, :3]], "root_euler_xyz_deg": [float(x) for x in matrix_to_euler_xyz(root_T[:3, :3])], }, "movements": movements, "links": link_pose_entries, "markers": marker_reports, } return out # ============================================================================= # Main # ============================================================================= def main() -> None: parser = argparse.ArgumentParser( description="Sequential robot state estimation from optimized ArUco marker positions" ) parser.add_argument( "optimized_markers", help="aruco_positions_optimized*.json" ) parser.add_argument( "-robot", "--robot", required=True, help="robot.json" ) parser.add_argument( "-out", "--out", default=None, help="Output robot_state.json path" ) parser.add_argument( "--maxIterations", type=int, default=120, help="Maximum least-squares iterations per stage" ) parser.add_argument( "--jointPriorWeight", type=float, default=0.05, help="Prior weight for joint variables" ) parser.add_argument( "--rootPosePriorWeight", type=float, default=0.1, help="Prior weight for root pose" ) parser.add_argument( "--markerBaseWeight", type=float, default=1.0, help="Base weight for marker residuals" ) parser.add_argument( "--noJointPrior", action="store_true", help="Disable joint priors" ) parser.add_argument( "--noRootPrior", action="store_true", help="Disable root pose prior" ) parser.add_argument( "--noSequential", action="store_true", help="Disable sequential prefix optimization and do one global pass" ) args = parser.parse_args() print("[STEP 1] Load robot.json and optimized marker positions...") robot_data = load_json(args.robot) link_infos, marker_by_id, issues = parse_robot(robot_data) observed_markers = load_json(args.optimized_markers) markers = observed_markers.get("markers", []) if isinstance(observed_markers, dict) else [] observed_map: Dict[int, Dict[str, Any]] = {} for m in markers: marker_id = int(m["marker_id"]) observed_map[marker_id] = m for issue in issues: print(issue) print(f"[INFO] Links: {len(link_infos)}") print(f"[INFO] Known robot markers: {len(marker_by_id)}") print(f"[INFO] Observed optimized markers: {len(observed_map)}") print("\n[STEP 2] Build initial robot state...") initial_state = build_initial_state(robot_data, link_infos, observed_map) print(f"[INFO] Initial root translation (m): {initial_state['root_t']}") print(f"[INFO] Initial joint defaults: {initial_state['joint_defaults']}") cfg = EstimationConfig( root_pose_prior_weight=float(args.rootPosePriorWeight), joint_prior_weight=float(args.jointPriorWeight), marker_base_weight=float(args.markerBaseWeight), robust_loss="soft_l1", max_iterations=int(args.maxIterations), include_joint_prior=not args.noJointPrior, include_root_prior=not args.noRootPrior, sequential=not args.noSequential, show_stage_reports=True, ) print("\n[STEP 3] Estimate robot state...") if cfg.sequential: final_state, stage_results = sequential_optimize_state( robot_data=robot_data, link_infos=link_infos, observed_markers=observed_map, initial_state=initial_state, cfg=cfg, ) else: # Fallback: single global stage over the entire kinematic tree. active_links = topological_links(link_infos) active_joint_vars = active_joint_vars_for_links(link_infos, active_links) final_state, opt_info = optimize_stage( link_infos=link_infos, observed_markers=observed_map, template_state=initial_state, active_joint_vars=active_joint_vars, cfg=cfg, ) final_state["_optimizer"] = opt_info stage_results = [] print("\n[STEP 4] Build report and save robot_state.json...") marker_reports, stats = compute_error_stats(link_infos, final_state, observed_map) out_data = state_to_json( robot_data=robot_data, link_infos=link_infos, state=final_state, observed_markers=observed_map, marker_reports=marker_reports, stats=stats, input_file=args.optimized_markers, robot_file=args.robot, stage_results=stage_results, ) out_path = args.out if out_path is None: out_dir = os.path.dirname(args.optimized_markers) or "." out_path = os.path.join(out_dir, "robot_state.json") save_json(out_path, out_data) print(f"[INFO] Saved to {out_path}") if stats["mean_error_m"] is not None: print(f"[INFO] Mean marker error: {stats['mean_error_m']*1000.0:.2f} mm") print(f"[INFO] RMS marker error : {stats['rms_error_m']*1000.0:.2f} mm") print(f"[INFO] Worst marker error: {stats['worst_error_m']*1000.0:.2f} mm") else: print("[INFO] No marker error statistics available.") if __name__ == "__main__": main()