HorizonStream_Demo / horizonstream /loop /pypose_optimizer.py
Chong CHENG
Update HorizonStream demo space
5a0b216
Raw
History Blame Contribute Delete
9.99 kB
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