import torch from torch import Tensor from typing import * from .transforms import transform_points, make_affine_matrix from .utils import matrix_trace, vector_outer __all__ = ['kabasch', 'umeyama', 'affine_umeyama', 'solve_pose', 'segment_solve_pose', 'solve_poses_sequential', 'segment_solve_poses_sequential', 'pose_graph_optimization'] import torch class Kabsch(torch.autograd.Function): "Customized backward function for Kabsch (SVD) for rotation matrix from covariance matrix." @staticmethod def forward(ctx: torch.autograd.function.FunctionCtx, cov: Tensor, eps: float = 1e-12): U, S, Vh = torch.linalg.svd(cov) d = torch.where(torch.linalg.det(U) * torch.linalg.det(Vh) >= 0, 1., -1.) s = torch.ones(cov.shape[:-2] + (3,), dtype=cov.dtype, device=cov.device) s[..., -1] = d R = U @ (s[..., :, None] * Vh) ctx.save_for_backward(S, Vh, R, d) ctx.eps = eps return R @staticmethod def backward(ctx: torch.autograd.function.FunctionCtx, grad_R: Tensor): S, Vh, R, d = ctx.saved_tensors eps = ctx.eps V = Vh.transpose(-1, -2) M = R.transpose(-1, -2) @ grad_R M_skew_times_2 = M - M.transpose(-1, -2) V_M_skew_V = Vh @ M_skew_times_2 @ V S_mod = S.clone() S_mod[..., -1] *= d D = S_mod[..., :, None] + S_mod[..., None, :] D = D + eps Omega_hat = V_M_skew_V / D Omega = V @ Omega_hat @ Vh grad_cov = R @ Omega return grad_cov, None def _kabasch_classic(cov: Tensor, eps: float = 1e-12): """Reference implementation. Would encounter NaN gradients when singular values are too close. """ U, _, Vh = torch.linalg.svd(cov) det = torch.sign(torch.linalg.det(U) * torch.linalg.det(Vh)) ones = torch.ones_like(det) R = U @ (torch.stack([ones, ones, det], dim=-1)[..., :, None] * Vh) return R def kabasch(cov: Tensor, eps: float = 1e-12): """Backward gradients friendly Kabasch method (compute rotation from input covarience matrix). """ return Kabsch.apply(cov, eps) def umeyama(cov_yx: Tensor, cov_xx: Optional[Tensor] = None, cov_yy: Optional[Tensor] = None, mean_x: Optional[Tensor] = None, mean_y: Optional[Tensor] = None, eps: float = 1e-12) -> Tuple[Tensor, Tensor, Tensor]: """ Umeyama method to solve for scale `s`, rotation `R` and translation `t` such that `y_i ~= s R x_i + t`. Parameters ---- - `cov_yx`: (..., 3, 3) covariance matrix between y and x points. - `cov_xx`: (..., 3, 3) covariance matrix of x points. If None, no scaling is solved. - `cov_yy`: (..., 3, 3) covariance matrix of y points. If None, no scaling is solved. - `mean_x`: (..., 3) mean of x points. If None, no translation is solved. - `mean_y`: (..., 3) mean of y points. If None, no translation is solved. Specifically, based on provided inputs: - To solve the rotation `R`, `cov_yx` must be given. - To solve the scale `s`, at least one of `cov_xx` and `cov_yy` must be given. - (Recommended) If both `cov_xx` and `cov_yy` are given, the scale will be solved by minimizing a symmetric cost: `||s R X + t - Y||_F^2 / ||Y||_F^2 + ||s R^T (Y - t) - X||_F^2 / ||X||_F^2` - If only `cov_xx` is given, the scale will be solved by minimizing forward cost `||s R X + t - Y||_F^2` - If only `cov_yy` is given, the scale will be solved by minimizing inverse cost `||s R^T (Y - t) - X||_F^2` - To solve the translation `t`, provide `mean_x` and `mean_y`. Returns ---- - `s`: (...) scale factor. None if both cov_xx and cov_yy are None. - `R`: (..., 3, 3) rotation matrix. - `t`: (..., 3) translation vector. None if mean_x or mean_y is None. """ R = kabasch(cov_yx) if cov_xx is not None and cov_yy is None: s = matrix_trace(cov_yx @ R.swapaxes(-2, -1), dim1=-2, dim2=-1) / matrix_trace(cov_xx, dim1=-2, dim2=-1).clamp_min(eps) if cov_xx is None and cov_yy is not None: s = matrix_trace(cov_yy, dim1=-2, dim2=-1) / matrix_trace(cov_yx @ R.swapaxes(-2, -1), dim1=-2, dim2=-1).clamp_min(eps) elif cov_xx is not None and cov_yy is not None: x_fnorm = matrix_trace(cov_xx, dim1=-2, dim2=-1).clamp_min(eps) y_fnorm = matrix_trace(cov_yy, dim1=-2, dim2=-1).clamp_min(eps) s = torch.sqrt(y_fnorm / x_fnorm) else: s = None if mean_x is not None and mean_y is not None: if s is not None: t = mean_y - transform_points(mean_x, s[..., None, None] * R) else: t = mean_y - transform_points(mean_x, R) else: t = None return s, R, t @torch.no_grad() def affine_umeyama(cov_yx: Tensor, cov_xx: Tensor, cov_yy: Tensor, mean_x: Tensor, mean_y: Tensor, lam: float = 1e-2, niter: int = 8, eps: float = 1e-12) -> Tuple[Tensor, Tensor]: """ Extended Procrustes analysis to solve for affine transformation `A` and translation `t` such that `y_i ~= A x_i + t`. NOTE: This function is indifferentiable due to the iterative solving process. Parameters ---- - `cov_yx`: (..., 3, 3) covariance matrix between y and x points. - `cov_xx`: (..., 3, 3) covariance matrix of x points. - `cov_yy`: (..., 3, 3) covariance matrix of y points. - `mean_x`: (..., 3) mean of x points. - `mean_y`: (..., 3) mean of y points. - `lam`: rigidity regularization weight. - `gamma`: symmetricity regularization annealing factor. - `niter`: number of iterations for solving. Returns ---- - `A`: (..., 3, 3) affine transformation matrix. - `t`: (..., 3) translation vector. """ dtype = mean_x.dtype R = kabasch(cov_yx) tr_xx = matrix_trace(cov_xx, dim1=-2, dim2=-1).clamp_min(eps) tr_yy = matrix_trace(cov_yy, dim1=-2, dim2=-1).clamp_min(eps) cov_yx, cov_xy = cov_yx / tr_xx[..., None, None], cov_yx.swapaxes(-2, -1) / tr_yy[..., None, None] cov_xx, cov_yy = cov_xx / tr_xx[..., None, None], cov_yy / tr_yy[..., None, None] A, B = torch.zeros_like(R), torch.zeros_like(R) I = torch.eye(cov_yx.shape[-1], dtype=dtype, device=cov_yx.device) def _step(A, B, R, cov_yx, cov_xy, cov_xx, cov_yy, lam, gamma): A = (cov_yx + lam * R + gamma * B.swapaxes(-2, -1)) @ torch.linalg.inv(cov_xx + lam * I + gamma * (B @ B.swapaxes(-2, -1))) B = (cov_xy + lam * R.swapaxes(-2, -1) + gamma * A.swapaxes(-2, -1)) @ torch.linalg.inv(cov_yy + lam * I + gamma * (A @ A.swapaxes(-2, -1))) err = torch.square(A @ B - I).mean(axis=(-2, -1)) return A, B, err not_converged = torch.argwhere(torch.ones(R.shape[:-2], dtype=torch.bool, device=R.device)) for i in range(niter): gamma_i = 1.2 ** i - 1 non_converged_indices = tuple(not_converged.T) A[non_converged_indices], B[non_converged_indices], err = _step(*(x[non_converged_indices] for x in (A, B, R, cov_yx, cov_xy, cov_xx, cov_yy)), lam, gamma_i) not_converged = not_converged[err >= 1e-6] if len(not_converged) == 0: break t = mean_y - transform_points(mean_x, A) return A, t def solve_pose( p: Tensor, q: Tensor, w: Optional[Tensor] = None, *, mode: Literal['rigid', 'similar', 'affine'] = 'rigid', lam: float = 1e-2, niter: int = 5, eps: float = 1e-12 ) -> Tensor: """Solve for the pose (transformation from p to q) given weighted point correspondences. Parameters ---- - `p`: (..., N, 3) source points - `q`: (..., N, 3) target points - `w`: optional (..., N) weights for each point correspondence. If None, uniform weights are used. - `mode`: mode of transformation to apply. Can be 'rigid', 'similar', or 'affine'. - For 'rigid', only rotation and translation are allowed. - For 'similar', uniform scaling, rotation and translation are allowed. - For 'affine', full affine transformation is allowed. Using least squares. - `lam`: regularization weight for 'affine' mode. - `niter`: number of iterations for 'affine' mode. - `eps`: small value to prevent division by zero. Returns ---- - `pose`: (..., 4, 4) transformations matrix from p to q. """ if w is None: w = torch.ones(p.shape[:-1], dtype=p.dtype, device=p.device) w_sum = torch.sum(w, dim=-1).clamp_min(eps) p_mean = torch.sum(p * w[..., None], dim=-2) / w_sum[..., None] q_mean = torch.sum(q * w[..., None], dim=-2) / w_sum[..., None] p = p - p_mean[..., None, :] q = q - q_mean[..., None, :] pw = p * w[..., None] qw = q * w[..., None] cov_qp = torch.sum(vector_outer(qw, p), dim=-3) / w_sum[..., None, None] if mode == 'similar' or mode == 'affine': cov_pp = torch.sum(vector_outer(pw, p), dim=-3) / w_sum[..., None, None] cov_qq = torch.sum(vector_outer(qw, q), dim=-3) / w_sum[..., None, None] if mode == 'rigid': _, R, t = umeyama(cov_qp, mean_x=p_mean, mean_y=q_mean, eps=eps) pose = make_affine_matrix(R, t) elif mode == 'similar': s, R, t = umeyama(cov_qp, cov_xx=cov_pp, cov_yy=cov_qq, mean_x=p_mean, mean_y=q_mean, eps=eps) pose = make_affine_matrix(s * R, t) elif mode == 'affine': A, t = affine_umeyama(cov_qp, cov_pp, cov_qq, p_mean, q_mean, lam=lam, niter=niter, eps=eps) pose = make_affine_matrix(A, t) return pose def segment_solve_pose( p: Tensor, q: Tensor, w: Optional[Tensor] = None, *, offsets: Tensor, mode: Literal['rigid', 'similar', 'affine'] = 'rigid', lam: float = 1e-2, niter: int = 5, eps: float = 1e-12 ) -> Tensor: """Solve for the pose (transformation from p to q: q ≈ pose @ p) given weighted point correspondences. NOTE: Affine mode is solved by iterative method and may be indifferentiable. Use with `torch.no_grad()` if you don't need gradients. Parameters ---- - `p`: (N, 3) source points - `q`: (N, 3) target points - `w`: (N,) weights for each point correspondence - `offsets`: (S + 1,) segment offsets. Points in each segment belong to the same rigid / affine body. - `mode`: mode of transformation to apply. Can be 'rigid', 'similar', or 'affine'. - For 'rigid', only rotation and translation are allowed. - For 'similar', uniform scaling, rotation and translation are allowed. - For 'affine', full affine transformation is allowed. Using least squares. - `lam`: regularization weight for 'affine' mode. - `niter`: number of iterations for 'affine' mode. - `eps`: small value to prevent division by zero. Returns ---- - `pose`: (S, 4, 4) transformations matrix from p to q. """ if w is None: w = torch.ones(p.shape[:-1], device=p.device, dtype=p.dtype) lengths = torch.diff(offsets) w_sum = torch.segment_reduce(w, 'sum', offsets=offsets, axis=0).clamp_min(eps) p_mean = torch.segment_reduce(p * w[..., None], 'sum', offsets=offsets, axis=0) / w_sum[:, None] q_mean = torch.segment_reduce(q * w[..., None], 'sum', offsets=offsets, axis=0) / w_sum[:, None] p = p - torch.repeat_interleave(p_mean, lengths, dim=0) q = q - torch.repeat_interleave(q_mean, lengths, dim=0) pw = p * w[..., None] qw = q * w[..., None] cov_qp = torch.segment_reduce(vector_outer(qw, p), 'sum', offsets=offsets, axis=0) / w_sum[:, None, None] if mode == 'similar' or mode == 'affine': cov_pp = torch.segment_reduce(vector_outer(pw, p), 'sum', offsets=offsets, axis=0) / w_sum[:, None, None] cov_qq = torch.segment_reduce(vector_outer(qw, q), 'sum', offsets=offsets, axis=0) / w_sum[:, None, None] if mode == 'rigid': _, R, t = umeyama(cov_qp, mean_x=p_mean, mean_y=q_mean, eps=eps) pose = make_affine_matrix(R, t) elif mode == 'similar': s, R, t = umeyama(cov_qp, cov_xx=cov_pp, cov_yy=cov_qq, mean_x=p_mean, mean_y=q_mean, eps=eps) pose = make_affine_matrix(s * R, t) elif mode == 'affine': A, t = affine_umeyama(cov_qp, cov_pp, cov_qq, p_mean, q_mean, lam=lam, niter=niter, eps=eps) pose = make_affine_matrix(A, t) return pose def solve_poses_sequential( trajectories: Tensor, weights: Optional[Tensor] = None, *, accum: Optional[Tuple[Tensor, ...]] = None, min_valid_size: int = 3, mode: Literal['rigid', 'similar', 'affine'] = 'rigid', lam: float = 1e-2, niter: int = 8, eps: float = 1e-12, ) -> Tuple[Tensor, Tensor, Tuple[Tensor, Tensor, Tensor, Tensor], Tensor, Tensor, Tuple[Tensor, ...]]: """ Given trajectories of points over time, sequentially solve for the poses (transformations from canonical to each frame) of each body at each frame. Parameters ---- - `trajectories`: (T, ..., N, 3) posed points. T is number of frames. `...` is optional batch dimensions. N is number of points per group. - `weights`: (T, ..., N) quardratic error term weights for each point at each frame - `accum`: accumulated statistics from previous calls. If None, start fresh. - `min_valid_size`: minimum number of valid points in each frame to consider the segment / group valid. - `mode`: mode of transformation to apply. Can be 'rigid', 'similar', or 'affine'. - For 'rigid', only rotation and translation are allowed. - For 'similar', uniform scaling, rotation and translation are allowed. - For 'affine', full affine transformation is allowed. Using least squares. - `lam`: rigidity regularization weight for 'affine' mode. - `niter`: number of iterations for 'affine' mode. - `eps`: small value to prevent division by zero. Returns ---- - `poses`: (T, ..., 4, 4) transformations from canonical to each frame. - `valid`: (T, ...) boolean mask indicating valid segments - `stats`: canonical statistics of each group `(mu, cov, tot_w, nnz)`. - `canonical_points`: (..., N, 3) canonical points. - `err`: (..., N) per-point RMS error over all time. - `accum`: per-point accumulated statistics. Pass it to the next call for incremental solving. """ dtype = trajectories.dtype device = trajectories.device num_frames = trajectories.shape[0] num_points = trajectories.shape[-2] batch_shape = trajectories.shape[1:-2] if weights is None: weights = torch.ones((num_frames, *batch_shape, num_points), dtype=dtype, device=device) poses = torch.zeros((num_frames, *batch_shape, 4, 4), dtype=dtype, device=device) if accum is not None: accum_sqrtw, accum_sqrtwx, accum_sqrtwxx, accum_w, accum_wx, accum_wxx, accum_nnz = [a.clone() for a in accum] else: accum_sqrtw = torch.zeros((*batch_shape, num_points), dtype=dtype, device=device) accum_sqrtwx = torch.zeros((*batch_shape, num_points, 3), dtype=dtype, device=device) accum_sqrtwxx = torch.zeros((*batch_shape, num_points, 3, 3), dtype=dtype, device=device) accum_w = torch.zeros((*batch_shape, num_points), dtype=dtype, device=device) accum_wx = torch.zeros((*batch_shape, num_points, 3), dtype=dtype, device=device) accum_wxx = torch.zeros((*batch_shape, num_points, 3, 3), dtype=dtype, device=device) accum_nnz = torch.zeros((*batch_shape, num_points), dtype=dtype, device=device) for i in range(num_frames): mean_sqrtwx = accum_sqrtwx / accum_sqrtw.clamp_min(eps)[..., None] wi, yi = weights[i], trajectories[i] sqrtwi = torch.sqrt(wi) w = sqrtwi * accum_sqrtw sum_w = torch.sum(w, dim=-1).clamp_min(eps) center_x = torch.sum(sqrtwi[..., None] * accum_sqrtwx, dim=-2) / sum_w[..., None] center_y = torch.sum(w[..., None] * yi, dim=-2) / sum_w[..., None] xc = mean_sqrtwx - center_x[..., None, :] yc = yi - center_y[..., None, :] cov_yx = torch.einsum('...i,...ij,...ik->...jk', w, yc, xc) / sum_w[..., None, None] if mode == 'affine' or mode == 'similar': cov_xx = (torch.einsum('...i,...ij,...ik->...jk', w, xc, xc) + torch.einsum('...i,...ijk->...jk', sqrtwi, accum_sqrtwxx)) / sum_w[..., None, None] cov_yy = torch.einsum('...i,...ij,...ik->...jk', w, yc, yc) / sum_w[..., None, None] if mode == 'rigid': _, R, t = umeyama(cov_yx, mean_x=center_x, mean_y=center_y, eps=eps) poses[i] = make_affine_matrix(R, t) elif mode == 'similar': s, R, t = umeyama(cov_yx, cov_xx=cov_xx, mean_x=center_x, mean_y=center_y, eps=eps) poses[i] = make_affine_matrix(s * R, t) elif mode == 'affine': A, t = affine_umeyama(cov_yx, cov_xx, cov_yy, center_x, center_y, lam=lam, niter=niter, eps=eps) poses[i] = make_affine_matrix(A, t) xi = transform_points(yi, torch.linalg.inv(poses[i])[..., None, :, :]) # Update accum old_mean_sqrtwx, old_accum_sqrtw = mean_sqrtwx.clone(), accum_sqrtw.clone() accum_sqrtw = accum_sqrtw + sqrtwi accum_sqrtwx = accum_sqrtwx + sqrtwi[..., None] * xi mean_sqrtwx = accum_sqrtwx / accum_sqrtw.clamp_min(eps)[..., None] accum_sqrtwxx = accum_sqrtwxx + old_accum_sqrtw[..., None, None] * vector_outer(mean_sqrtwx - old_mean_sqrtwx) + sqrtwi[..., None, None] * vector_outer(xi - mean_sqrtwx) mean_wx = accum_wx / accum_w.clamp_min(eps)[..., None] old_mean_wx, old_accum_w = mean_wx.clone(), accum_w.clone() accum_w = accum_w + wi accum_wx = accum_wx + wi[..., None] * xi mean_wx = accum_wx / accum_w.clamp_min(eps)[..., None] accum_wxx = accum_wxx + old_accum_w[..., None, None] * vector_outer(mean_wx - old_mean_wx) + wi[..., None, None] * vector_outer(xi - mean_wx) accum_nnz = accum_nnz + (wi > 0).to(dtype) tot_w = torch.sum(accum_w, dim=-1) mu = torch.sum(accum_wx, dim=-2) / tot_w.clamp_min(eps)[..., None] mean_wx = accum_wx / accum_w.clamp_min(eps)[..., None] sigma = torch.sum(accum_wxx + accum_w[..., None, None] * vector_outer(mu[..., None, :] - mean_wx), dim=-3) / tot_w.clamp_min(eps)[..., None, None] nnz = torch.sum(accum_nnz, dim=-1) valid = torch.sum(weights > 0, dim=-1) >= min_valid_size err = torch.sqrt(matrix_trace(accum_wxx, dim1=-2, dim2=-1) / accum_nnz.clamp_min(eps)) return poses, valid, (mu, sigma, tot_w, nnz), mean_wx, err, (accum_sqrtw, accum_sqrtwx, accum_sqrtwxx, accum_w, accum_wx, accum_wxx, accum_nnz) def segment_solve_poses_sequential( trajectories: Tensor, weights: Optional[Tensor] = None, offsets: Tensor = None, *, accum: Optional[Tuple[Tensor, ...]] = None, min_valid_size: int = 3, mode: Literal['rigid', 'similar', 'affine'] = 'rigid', lam: float = 1e-2, niter: int = 8, eps: float = 1e-12, ) -> Tuple[Tensor, Tensor, Tuple[Tensor, Tensor, Tensor, Tensor], Tensor, Tensor, Tuple[Tensor, ...]]: """ Segment array mode for `solve_poses_sequential`. Parameters ---- - `trajectories`: (T, N, 3) posed points. - `weights`: (T, N) quardratic error term weights for each point at each frame - `offsets`: (S + 1,) segment offsets. Points in each segment belong to the same rigid / affine body. - `accum`: accumulated statistics from previous calls. If None, start fresh. - `min_valid_size`: minimum number of valid points in each frame to consider the segment / group valid. - `mode`: mode of transformation to apply. Can be 'rigid', 'similar', or 'affine'. - `lam`: rigidity regularization weight for 'affine' mode. - `niter`: number of iterations for 'affine' mode. - `eps`: small value to prevent division by zero. Returns ---- - `poses`: (T, S, 4, 4) transformations from canonical to each frame. - `valid`: (T, S) boolean mask indicating valid segments. - `stats`: canonical statistics `(mu, cov, tot_w, nnz)`. - `canonical_points`: (N, 3) canonical points. - `err`: (N,) per-point RMS error over all time. - `accum`: per-point accumulated statistics for incremental solving. """ dtype = trajectories.dtype device = trajectories.device num_frames = trajectories.shape[0] num_points = trajectories.shape[1] if weights is None: weights = torch.ones((num_frames, num_points), dtype=dtype, device=device) num_segments = offsets.shape[0] - 1 lengths = torch.diff(offsets) poses = torch.zeros((num_frames, num_segments, 4, 4), dtype=dtype, device=device) if accum is not None: accum_sqrtw, accum_sqrtwx, accum_sqrtwxx, accum_w, accum_wx, accum_wxx, accum_nnz = [a.clone() for a in accum] else: accum_sqrtw = torch.zeros((num_points,), dtype=dtype, device=device) accum_sqrtwx = torch.zeros((num_points, 3), dtype=dtype, device=device) accum_sqrtwxx = torch.zeros((num_points, 3, 3), dtype=dtype, device=device) accum_w = torch.zeros((num_points,), dtype=dtype, device=device) accum_wx = torch.zeros((num_points, 3), dtype=dtype, device=device) accum_wxx = torch.zeros((num_points, 3, 3), dtype=dtype, device=device) accum_nnz = torch.zeros((num_points,), dtype=dtype, device=device) for i in range(num_frames): mean_sqrtwx = accum_sqrtwx / accum_sqrtw.clamp_min(eps)[:, None] wi, yi = weights[i], trajectories[i] sqrtwi = torch.sqrt(wi) w = sqrtwi * accum_sqrtw sum_w = torch.segment_reduce(w, 'sum', offsets=offsets, axis=0).clamp_min(eps) center_x = torch.segment_reduce(sqrtwi[:, None] * accum_sqrtwx, 'sum', offsets=offsets, axis=0) / sum_w[:, None] center_y = torch.segment_reduce(w[:, None] * yi, 'sum', offsets=offsets, axis=0) / sum_w[:, None] center_x_broadcast = torch.repeat_interleave(center_x, lengths, dim=0) center_y_broadcast = torch.repeat_interleave(center_y, lengths, dim=0) xc = mean_sqrtwx - center_x_broadcast yc = yi - center_y_broadcast cov_yx = torch.segment_reduce(w[:, None, None] * vector_outer(yc, xc), 'sum', offsets=offsets, axis=0) / sum_w[:, None, None] if mode == 'affine' or mode == 'similar': cov_xx = torch.segment_reduce(sqrtwi[:, None, None] * accum_sqrtwxx + w[:, None, None] * vector_outer(xc), 'sum', offsets=offsets, axis=0) / sum_w[:, None, None] cov_yy = torch.segment_reduce(w[:, None, None] * vector_outer(yc), 'sum', offsets=offsets, axis=0) / sum_w[:, None, None] if mode == 'rigid': _, R, t = umeyama(cov_yx, mean_x=center_x, mean_y=center_y, eps=eps) poses[i] = make_affine_matrix(R, t) elif mode == 'similar': s, R, t = umeyama(cov_yx, cov_xx=cov_xx, mean_x=center_x, mean_y=center_y, eps=eps) poses[i] = make_affine_matrix(s * R, t) elif mode == 'affine': A, t = affine_umeyama(cov_yx, cov_xx, cov_yy, center_x, center_y, lam=lam, niter=niter, eps=eps) poses[i] = make_affine_matrix(A, t) xi = transform_points(yi, torch.repeat_interleave(torch.linalg.inv(poses[i]), lengths, dim=0)) # Update accum old_mean_sqrtwx, old_accum_sqrtw = mean_sqrtwx.clone(), accum_sqrtw.clone() accum_sqrtw = accum_sqrtw + sqrtwi accum_sqrtwx = accum_sqrtwx + sqrtwi[:, None] * xi mean_sqrtwx = accum_sqrtwx / accum_sqrtw.clamp_min(eps)[:, None] accum_sqrtwxx = accum_sqrtwxx + old_accum_sqrtw[:, None, None] * vector_outer(mean_sqrtwx - old_mean_sqrtwx) + sqrtwi[:, None, None] * vector_outer(xi - mean_sqrtwx) mean_wx = accum_wx / accum_w.clamp_min(eps)[:, None] old_mean_wx, old_accum_w = mean_wx.clone(), accum_w.clone() accum_w = accum_w + wi accum_wx = accum_wx + wi[:, None] * xi mean_wx = accum_wx / accum_w.clamp_min(eps)[:, None] accum_wxx = accum_wxx + old_accum_w[:, None, None] * vector_outer(mean_wx - old_mean_wx) + wi[:, None, None] * vector_outer(xi - mean_wx) accum_nnz = accum_nnz + (wi > 0).to(dtype) tot_w = torch.segment_reduce(accum_w, 'sum', offsets=offsets, axis=0) mu = torch.segment_reduce(accum_wx, 'sum', offsets=offsets, axis=0) / tot_w.clamp_min(eps)[:, None] mean_wx = accum_wx / accum_w.clamp_min(eps)[:, None] mu_broadcast = torch.repeat_interleave(mu, lengths, dim=0) sigma = torch.segment_reduce(accum_wxx + accum_w[:, None, None] * vector_outer(mu_broadcast - mean_wx), 'sum', offsets=offsets, axis=0) / tot_w.clamp_min(eps)[:, None, None] nnz = torch.segment_reduce(accum_nnz, 'sum', offsets=offsets, axis=0) # `torch.segment_reduce` requires axis == offsets.ndim - 1, so reduce along dim 0 by transposing. valid = torch.segment_reduce((weights > 0).to(dtype).transpose(0, 1).contiguous(), 'sum', offsets=offsets, axis=0).transpose(0, 1) >= min_valid_size err = torch.sqrt(matrix_trace(accum_wxx, dim1=-2, dim2=-1) / accum_nnz.clamp_min(eps)) return poses, valid, (mu, sigma, tot_w, nnz), mean_wx, err, (accum_sqrtw, accum_sqrtwx, accum_sqrtwxx, accum_w, accum_wx, accum_wxx, accum_nnz) def _pose_graph_optimization_construct_laplacian(edge: Tensor, num_nodes: int, R: Tensor, w: Tensor) -> Tensor: # For each edge i->j, contributes: # - `-w_ij * R_ij^T` to block (i, j) # - `-w_ij * R_ij` to block (j, i), # - `w_ij * I` to diagonal block (i, i) and `w_ij * I` to diagonal block (j, j). w_agg = torch.zeros(num_nodes, device=R.device, dtype=R.dtype).index_add(0, edge.reshape(-1), w.repeat_interleave(2)) diag_elements = w_agg.repeat_interleave(3) # (N * 3,) edge_elements = (-w[:, None, None] * R.mT).reshape(-1) # (E * 3 * 3,) laplacian_data = torch.cat([diag_elements, edge_elements.reshape(-1), edge_elements.reshape(-1)], dim=0) local3 = torch.arange(3, device=R.device) local3x3 = torch.stack(torch.meshgrid(local3, local3, indexing='ij'), dim=-1) # to get the local 3x3 block coordinates for each edge diag_coords = (torch.arange(num_nodes, device=R.device)[:, None].expand(num_nodes, 3) * 3 + local3[None, :]).reshape(-1, 1).expand(-1, 2) # (N * 3, 2) edge_coords = (edge[:, None, None, :] * 3 + local3x3[None, :, :, :]).reshape(-1, 2) # (E * 3 * 3, 2) laplacian_coords = torch.cat([diag_coords, edge_coords, edge_coords.flip(-1)], dim=0) # (2, N * 3 + 2 * E * 3 * 3) laplacian = torch.sparse_coo_tensor(laplacian_coords.T, laplacian_data, size=(num_nodes * 3, num_nodes * 3), check_invariants=False) return laplacian def _pose_graph_optimization_eigen_decomposition(laplacian: Tensor) -> Tensor: _, eigenvectors = torch.lobpcg(laplacian, k=3, largest=False, tol=1e-5) R_global = eigenvectors.reshape(-1, 3, 3) R_global = torch.cat([ R_global[:, :, :2], torch.sign(torch.linalg.det(R_global))[:, None, None] * R_global[:, :, 2:3] ], dim=-1) R_global = kabasch(R_global) # Ensure SO(3) return R_global def _pose_graph_optimization_procrustes_iteration(R_global: Tensor, edges: Tensor, R_rel: Tensor, w: Tensor, niter: int = 10) -> Tensor: DAMP = 0.4 edges_flat, edges_flat_swap = edges.reshape(-1), edges.flip(1).reshape(-1) w_R = w[:, None, None] * R_rel w_R_dual = torch.stack([w_R, w_R.mT], dim=1).reshape(-1, 3, 3) w_agg = torch.zeros(R_global.shape[0], device=R_global.device, dtype=R_global.dtype).index_add(0, edges_flat, w.repeat_interleave(2)) for _ in range(niter): M = (DAMP * w_agg[..., None, None] * R_global).index_add(0, edges_flat_swap, w_R_dual @ R_global.index_select(0, edges_flat)) R_global = kabasch(M) return R_global def pose_graph_optimization(num_nodes: int, edges: Tensor, poses: Tensor, w: Tensor | None = None, niter: int = 10) -> tuple[Tensor, Tensor, Tensor]: """Pose graph optimization to solve for global poses given relative poses (must be rigid transformations). Parameters ---- - `num_nodes`: number of nodes `N` in the pose graph. - `edges`: (E, 2) edge list of the pose graph. Each edge is represented by a pair of node indices `i -> j`. - `poses`: (E, 4, 4) relative poses of transformation from node `i` to node `j` for each edge. Must be rigid transformations. - `w`: (E,) optional weights for each edge. - `niter`: number of Procrustes iterations to refine global poses. If 0, only the initial solution by Laplacian SVD is returned. Returns ---- - `poses_global`: (N, 4, 4) global poses (world-to-camera, canonical-to-observation, global-to-node, etc.) for each node. `poses_relative[i->j] ≈ poses_global[j] @ poses_global[i].inv()` """ if w is None: w = torch.ones(edges.shape[0], device=poses.device, dtype=poses.dtype) if poses.shape[-1] == 3: # Rotation only R_relative, t_relative = poses[:, :3, :3], None else: # Rigid transformation R_relative, t_relative = poses[:, :3, :3], poses[:, :3, 3] # Solve initial global rotations by laplacian eigen-decomposition. laplacian = _pose_graph_optimization_construct_laplacian(edges, num_nodes, R_relative, w) R_global = _pose_graph_optimization_eigen_decomposition(laplacian) # Refine global rotations by Procrustes iterations. if niter > 0: R_global = _pose_graph_optimization_procrustes_iteration(R_global, edges, R_relative, w, niter=niter) # Solve global translations if t_relative is not None: w_t = w[:, None] * t_relative b = torch.zeros((num_nodes, 3), device=R_relative.device, dtype=R_relative.dtype).index_add(0, edges[:, 1], w_t).index_add(0, edges[:, 0], -(R_relative.mT @ w_t[:, :, None]).squeeze(-1)).reshape(-1) # NOTE: currently we have to use dense solver for translations since PyTorch doesn't support sparse linear solver well. t_global = torch.linalg.lstsq(laplacian.to_dense(), b).solution.reshape(num_nodes, 3) else: t_global = None if t_global is not None: poses_global = make_affine_matrix(R_global, t_global) else: poses_global = R_global return poses_global