from typing import Callable, Optional, Sequence import numpy as np import pypose as pp import torch from scipy.sparse import coo_matrix from scipy.sparse.linalg import spsolve from scipy.spatial.transform import Rotation def _matrix_to_se3_data(mat: np.ndarray) -> np.ndarray: quat = Rotation.from_matrix(mat[:3, :3]).as_quat() return np.concatenate([mat[:3, 3], quat], axis=0).astype(np.float32, copy=False) def _project_to_rotation(mat: np.ndarray) -> np.ndarray: u, _, vt = np.linalg.svd(mat) rot = u @ vt if np.linalg.det(rot) < 0: vt[-1] *= -1 rot = u @ vt return rot.astype(np.float64, copy=False) def _matrix_to_sim3_components(mat: np.ndarray) -> tuple[float, np.ndarray, np.ndarray]: linear = np.asarray(mat[:3, :3], dtype=np.float64) trans = np.asarray(mat[:3, 3], dtype=np.float64) col_norms = np.linalg.norm(linear, axis=0) scale = float(np.mean(col_norms)) if not np.isfinite(scale) or scale < 1e-12: scale = 1.0 rot = _project_to_rotation(linear / scale) return scale, rot, trans def _matrix_to_sim3_data(mat: np.ndarray) -> np.ndarray: scale, rot, trans = _matrix_to_sim3_components(mat) quat = Rotation.from_matrix(rot).as_quat() return np.concatenate([trans, quat, [scale]], axis=0).astype(np.float32, copy=False) def _se3_to_matrix(se3: pp.LieTensor) -> np.ndarray: return se3.matrix().detach().cpu().numpy().astype(np.float64, copy=False) def _sim3_to_matrix(sim3: pp.LieTensor, normalize_rotation: bool = False) -> np.ndarray: data = sim3.data.detach().cpu().numpy().astype(np.float64, copy=False) trans = data[:3] quat = data[3:7] scale = float(data[7]) rot = Rotation.from_quat(quat).as_matrix().astype(np.float64, copy=False) out = np.eye(4, dtype=np.float64) out[:3, :3] = rot if normalize_rotation else (scale * rot) out[:3, 3] = trans return out def _solve_sparse_system( j_i: torch.Tensor, j_j: torch.Tensor, ii: torch.Tensor, jj: torch.Tensor, resid: torch.Tensor, lm: float, active_components: Optional[np.ndarray] = None, ) -> torch.Tensor: device = resid.device j_i = j_i.detach().cpu().numpy().astype(np.float64, copy=False) j_j = j_j.detach().cpu().numpy().astype(np.float64, copy=False) ii = ii.detach().cpu().numpy().astype(np.int64, copy=False) jj = jj.detach().cpu().numpy().astype(np.int64, copy=False) resid = resid.detach().cpu().numpy().astype(np.float64, copy=False) num_edges = resid.shape[0] dim = resid.shape[1] num_nodes = int(max(ii.max(), jj.max())) + 1 if num_edges > 0 else 0 rows = [] cols = [] data = [] for edge_idx in range(num_edges): src = ii[edge_idx] dst = jj[edge_idx] for r in range(dim): row = edge_idx * dim + r col_src = src * dim col_dst = dst * dim for c in range(dim): rows.append(row) cols.append(col_src + c) data.append(j_i[edge_idx, r, c]) rows.append(row) cols.append(col_dst + c) data.append(j_j[edge_idx, r, c]) J = coo_matrix((data, (rows, cols)), shape=(num_edges * dim, num_nodes * dim)).tocsc() resid_vec = resid.reshape(-1) b = -(J.T @ resid_vec) A = J.T @ J diag = A.diagonal() A.setdiag(diag * (1.0 + lm)) # Fix the first node as gauge anchor. delta = np.zeros(num_nodes * dim, dtype=np.float64) if active_components is None: active_components = np.ones(dim, dtype=bool) active_components = np.asarray(active_components, dtype=bool).reshape(dim) active_cols = [] for node_idx in range(1, num_nodes): base = node_idx * dim active_cols.extend((base + np.flatnonzero(active_components)).tolist()) if active_cols: active_cols = np.asarray(active_cols, dtype=np.int64) A_free = A[active_cols][:, active_cols].tocsc() b_free = b[active_cols] delta_free = spsolve(A_free, b_free) delta[active_cols] = delta_free return torch.from_numpy(delta.astype(np.float32)).view(num_nodes, dim).to(device) def _batch_jacobian( func: Callable[[pp.LieTensor, torch.Tensor, torch.Tensor], torch.Tensor], constants: pp.LieTensor, gi: torch.Tensor, gj: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: def _func_sum(c, x, y): return func(c, x, y).sum(dim=0) jac = torch.autograd.functional.jacobian( _func_sum, (constants, gi, gj), vectorize=True ) j_i = jac[1].permute(1, 0, 2).contiguous() j_j = jac[2].permute(1, 0, 2).contiguous() return j_i, j_j def optimize_pose_graph_pypose( keyframe_c2w_init: np.ndarray, edge_src: Sequence[int], edge_dst: Sequence[int], edge_measurements: Sequence[np.ndarray], edge_weights: Sequence[float], model: str = "se3", update_mode: str = "all", trans_weight: float = 1.0, rot_weight: float = 1.0, scale_weight: float = 1.0, max_iterations: int = 30, lambda_init: float = 1e-4, verbose: bool = True, log_fn: Optional[Callable[[str], None]] = None, ) -> np.ndarray: def log(msg: str) -> None: if verbose and log_fn is not None: log_fn(msg) if len(keyframe_c2w_init) <= 1: return keyframe_c2w_init.copy() if len(edge_measurements) == 0: return keyframe_c2w_init.copy() if model not in {"se3", "sim3"}: raise ValueError(f"Unsupported pose graph model: {model}") if model == "sim3": valid_update_modes = {"all", "translation_only", "translation_scale"} dim = 7 else: valid_update_modes = {"all", "translation_only"} dim = 6 if update_mode not in valid_update_modes: raise ValueError( f"Unsupported update_mode='{update_mode}' for model='{model}'. " f"Valid modes: {sorted(valid_update_modes)}" ) active_components = np.zeros(dim, dtype=bool) if update_mode == "all": active_components[:] = True elif update_mode == "translation_only": active_components[:3] = True elif update_mode == "translation_scale": active_components[:3] = True active_components[6] = True if model == "sim3": pose_data = np.stack([_matrix_to_sim3_data(pose) for pose in keyframe_c2w_init], axis=0) meas_data = np.stack([_matrix_to_sim3_data(meas) for meas in edge_measurements], axis=0) else: pose_data = np.stack([_matrix_to_se3_data(pose) for pose in keyframe_c2w_init], axis=0) meas_data = np.stack([_matrix_to_se3_data(meas) for meas in edge_measurements], axis=0) device = torch.device("cpu") if model == "sim3": poses = pp.Sim3(torch.from_numpy(pose_data).to(device)) constants = pp.Sim3(torch.from_numpy(meas_data).to(device)) else: poses = pp.SE3(torch.from_numpy(pose_data).to(device)) constants = pp.SE3(torch.from_numpy(meas_data).to(device)) ii = torch.as_tensor(edge_src, dtype=torch.long, device=device) jj = torch.as_tensor(edge_dst, dtype=torch.long, device=device) weights = torch.as_tensor(edge_weights, dtype=torch.float32, device=device).view(-1, 1) Ginv = poses.Inv().Log().detach() lm = float(lambda_init) residual_history: list[float] = [] def residual_fn(C: pp.LieTensor, Gi: torch.Tensor, Gj: torch.Tensor) -> torch.Tensor: out = C @ pp.Exp(Gi) @ pp.Exp(Gj).Inv() resid = out.Log().tensor() resid = resid.clone() resid[:, :3] *= float(trans_weight) resid[:, 3:6] *= float(rot_weight) if model == "sim3": resid[:, 6:7] *= float(scale_weight) return resid def evaluate(current_ginv: torch.Tensor) -> torch.Tensor: resid = residual_fn(constants, current_ginv[ii], current_ginv[jj]) return resid * weights log( f"PyPose pose graph ({model}, {update_mode}): {len(keyframe_c2w_init)} nodes, {len(edge_measurements)} edges, " f"weights=(t={trans_weight:g}, r={rot_weight:g}, s={scale_weight:g}), " f"max_iterations={max_iterations}, lambda_init={lambda_init:g}" ) for itr in range(max_iterations): resid_unweighted = residual_fn(constants, Ginv[ii], Ginv[jj]) j_i, j_j = _batch_jacobian(residual_fn, constants, Ginv[ii], Ginv[jj]) j_i = j_i * weights.unsqueeze(-1) j_j = j_j * weights.unsqueeze(-1) resid = resid_unweighted * weights current_cost = float(resid.square().mean().item()) residual_history.append(current_cost) delta = _solve_sparse_system(j_i, j_j, ii, jj, resid, lm, active_components=active_components) candidate = Ginv + delta new_resid = evaluate(candidate) new_cost = float(new_resid.square().mean().item()) accepted = new_cost <= current_cost + 1e-12 if accepted: Ginv = candidate lm = max(lm / 2.0, 1e-12) else: lm = min(lm * 2.0, 1e8) if verbose: status = "accepted" if accepted else "rejected" log( f"PyPose iter {itr + 1}/{max_iterations}: cost {current_cost:.8f} -> " f"{new_cost:.8f} ({status}, lambda={lm:.3e})" ) if current_cost < 1e-6 and itr >= 4 and len(residual_history) >= 5: denom = max(residual_history[-1], 1e-12) improvement = residual_history[-5] / denom if improvement < 1.2: log(f"PyPose pose graph: converged at iter {itr + 1}") break optimized = pp.Exp(Ginv).Inv() if model == "sim3": out = np.stack( [_sim3_to_matrix(optimized[idx], normalize_rotation=False) for idx in range(len(keyframe_c2w_init))], axis=0, ) else: out = np.stack([_se3_to_matrix(optimized[idx]) for idx in range(len(keyframe_c2w_init))], axis=0) return out