Marcus/Lidar/SLAM_LoopClosure.py
2026-04-12 18:50:22 +04:00

419 lines
16 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.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import numpy as np
def _as_bool(v: Any, default: bool) -> bool:
if v is None:
return default
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return bool(v)
return str(v).strip().lower() in ("1", "true", "yes", "on")
def _safe_float(v: Any, default: float) -> float:
try:
return float(v)
except Exception:
return float(default)
def _safe_int(v: Any, default: int) -> int:
try:
return int(v)
except Exception:
return int(default)
def _pose_delta(pose_a: np.ndarray, pose_b: np.ndarray) -> tuple[float, float]:
dp = pose_a[:3, 3] - pose_b[:3, 3]
trans = float(np.linalg.norm(dp))
r = pose_a[:3, :3] @ pose_b[:3, :3].T
ang = float(np.degrees(np.arccos(np.clip((np.trace(r) - 1.0) * 0.5, -1.0, 1.0))))
return trans, ang
def _voxel_downsample_numpy(points: np.ndarray, voxel: float) -> np.ndarray:
if points is None or len(points) == 0:
return np.zeros((0, 3), dtype=np.float32)
if voxel <= 0:
return points.astype(np.float32, copy=False)
keys = np.floor(points / voxel).astype(np.int32)
uniq, inv = np.unique(keys, axis=0, return_inverse=True)
out = np.zeros((len(uniq), 3), dtype=np.float64)
cnt = np.zeros((len(uniq),), dtype=np.int64)
np.add.at(out, inv, points.astype(np.float64, copy=False))
np.add.at(cnt, inv, 1)
out /= np.maximum(cnt[:, None], 1)
return out.astype(np.float32)
def _rigid_fit_svd(src_pts: np.ndarray, dst_pts: np.ndarray) -> Optional[np.ndarray]:
if src_pts is None or dst_pts is None:
return None
if len(src_pts) < 3 or len(dst_pts) < 3:
return None
a = np.asarray(src_pts, dtype=np.float64)
b = np.asarray(dst_pts, dtype=np.float64)
if a.shape != b.shape or a.ndim != 2 or a.shape[1] != 3:
return None
c_a = np.mean(a, axis=0)
c_b = np.mean(b, axis=0)
aa = a - c_a
bb = b - c_b
H = aa.T @ bb
try:
U, _, Vt = np.linalg.svd(H, full_matrices=False)
except Exception:
return None
R = Vt.T @ U.T
if np.linalg.det(R) < 0:
Vt[2, :] *= -1.0
R = Vt.T @ U.T
t = c_b - (R @ c_a)
T = np.eye(4, dtype=np.float64)
T[:3, :3] = R
T[:3, 3] = t
return T
@dataclass
class LoopClosureConfig:
enabled: bool = False
keyframe_every_n_frames: int = 25
min_keyframe_translation_m: float = 0.30
min_keyframe_rotation_deg: float = 8.0
loop_search_radius_m: float = 2.0
min_loop_frame_gap: int = 30
downsample_voxel_m: float = 0.20
icp_max_corr_m: float = 1.2
icp_max_iter: int = 40
accept_fitness: float = 0.30
accept_rmse: float = 0.35
optimize_every_n_keyframes: int = 8
max_keyframes: int = 500
max_correction_translation_m: float = 2.0
max_correction_rotation_deg: float = 20.0
@staticmethod
def from_dict(d: Dict[str, Any] | None) -> "LoopClosureConfig":
src = d or {}
return LoopClosureConfig(
enabled=_as_bool(src.get("enabled", False), False),
keyframe_every_n_frames=max(1, _safe_int(src.get("keyframe_every_n_frames", 25), 25)),
min_keyframe_translation_m=max(0.01, _safe_float(src.get("min_keyframe_translation_m", 0.30), 0.30)),
min_keyframe_rotation_deg=max(0.1, _safe_float(src.get("min_keyframe_rotation_deg", 8.0), 8.0)),
loop_search_radius_m=max(0.2, _safe_float(src.get("loop_search_radius_m", 2.0), 2.0)),
min_loop_frame_gap=max(5, _safe_int(src.get("min_loop_frame_gap", 30), 30)),
downsample_voxel_m=max(0.01, _safe_float(src.get("downsample_voxel_m", 0.20), 0.20)),
icp_max_corr_m=max(0.2, _safe_float(src.get("icp_max_corr_m", 1.2), 1.2)),
icp_max_iter=max(10, _safe_int(src.get("icp_max_iter", 40), 40)),
accept_fitness=np.clip(_safe_float(src.get("accept_fitness", 0.30), 0.30), 0.0, 1.0),
accept_rmse=max(0.01, _safe_float(src.get("accept_rmse", 0.35), 0.35)),
optimize_every_n_keyframes=max(2, _safe_int(src.get("optimize_every_n_keyframes", 8), 8)),
max_keyframes=max(50, _safe_int(src.get("max_keyframes", 500), 500)),
max_correction_translation_m=max(
0.01, _safe_float(src.get("max_correction_translation_m", 2.0), 2.0)
),
max_correction_rotation_deg=max(
0.1, _safe_float(src.get("max_correction_rotation_deg", 20.0), 20.0)
),
)
@dataclass
class LoopClosureResult:
keyframe_added: bool = False
loop_detected: bool = False
optimized: bool = False
correction: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float64))
info: Dict[str, Any] = field(default_factory=dict)
class LoopClosureBackend:
"""
Lightweight loop-closure backend using keyframes + pose-graph optimization.
"""
def __init__(self, cfg: LoopClosureConfig):
self.cfg = cfg
self.frame_index = 0
self.last_keyframe_frame = -10**9
self.keyframe_poses: List[np.ndarray] = [] # world_T_sensor
self.keyframe_clouds: List[np.ndarray] = [] # sensor-frame clouds
self._o3d = None
self._pg = None
self._loop_count = 0
self._opt_count = 0
self._last_opt_reject: Optional[Dict[str, Any]] = None
self._import_open3d()
def _import_open3d(self) -> None:
# SAFE mode: avoid Open3D pose-graph path (native crashes observed).
self._o3d = None
self._pg = None
def reset(self) -> None:
self.frame_index = 0
self.last_keyframe_frame = -10**9
self.keyframe_poses.clear()
self.keyframe_clouds.clear()
self._loop_count = 0
self._opt_count = 0
self._last_opt_reject = None
self._pg = None
def _should_add_keyframe(self, pose_world: np.ndarray) -> bool:
if len(self.keyframe_poses) == 0:
return True
if (self.frame_index - self.last_keyframe_frame) >= int(self.cfg.keyframe_every_n_frames):
return True
trans, ang = _pose_delta(pose_world, self.keyframe_poses[-1])
return (trans >= float(self.cfg.min_keyframe_translation_m)) or (ang >= float(self.cfg.min_keyframe_rotation_deg))
def _to_o3d_cloud(self, pts: np.ndarray):
o3d = self._o3d
if o3d is None:
return None
pc = o3d.geometry.PointCloud()
pc.points = o3d.utility.Vector3dVector(pts.astype(np.float64, copy=False))
return pc
def _relative_source_to_target(self, pose_source: np.ndarray, pose_target: np.ndarray) -> np.ndarray:
# T_source_to_target
return np.linalg.inv(pose_target) @ pose_source
def _find_candidate(self, idx_cur: int) -> Optional[int]:
if idx_cur <= int(self.cfg.min_loop_frame_gap):
return None
cur_t = self.keyframe_poses[idx_cur][:3, 3]
best = None
best_d = float("inf")
for i in range(0, idx_cur - int(self.cfg.min_loop_frame_gap)):
d = float(np.linalg.norm(cur_t - self.keyframe_poses[i][:3, 3]))
if d <= float(self.cfg.loop_search_radius_m) and d < best_d:
best = i
best_d = d
return best
def _run_icp(self, src_pts: np.ndarray, tgt_pts: np.ndarray, init: np.ndarray, max_corr: float) -> Optional[Dict[str, Any]]:
src0 = np.asarray(src_pts, dtype=np.float64)
tgt = np.asarray(tgt_pts, dtype=np.float64)
if src0.ndim != 2 or tgt.ndim != 2 or src0.shape[1] != 3 or tgt.shape[1] != 3:
return None
if len(src0) < 3 or len(tgt) < 3:
return None
try:
from scipy.spatial import cKDTree
except Exception:
return None
T = np.asarray(init, dtype=np.float64).copy() if np.asarray(init).shape == (4, 4) else np.eye(4, dtype=np.float64)
tree = cKDTree(tgt)
max_corr_m = float(max(0.05, max_corr))
min_corr = max(20, int(0.12 * len(src0)))
last_rmse = 9e9
last_inliers = 0
for _ in range(int(max(6, self.cfg.icp_max_iter))):
src_w = (src0 @ T[:3, :3].T) + T[:3, 3]
d, idx = tree.query(src_w, k=1)
keep = np.isfinite(d) & (d <= max_corr_m)
n_in = int(np.count_nonzero(keep))
if n_in < min_corr:
break
src_corr = src_w[keep]
tgt_corr = tgt[np.asarray(idx[keep], dtype=np.int32)]
delta = _rigid_fit_svd(src_corr, tgt_corr)
if delta is None:
break
T = np.asarray(delta, dtype=np.float64) @ T
prev_rmse = float(last_rmse)
rmse = float(np.sqrt(np.mean(np.square(d[keep]))))
last_rmse = rmse
last_inliers = n_in
if abs(prev_rmse - rmse) < 1e-5:
break
fitness = float(last_inliers) / max(1.0, float(len(src0)))
return {
"fitness": float(fitness),
"inlier_rmse": float(last_rmse),
"transformation": np.asarray(T, dtype=np.float64),
"inliers": int(last_inliers),
}
def _rebuild_graph_odometry_only(self) -> None:
self._pg = None
def _maybe_trim(self) -> None:
kmax = int(self.cfg.max_keyframes)
if len(self.keyframe_poses) <= kmax:
return
drop = len(self.keyframe_poses) - kmax
self.keyframe_poses = self.keyframe_poses[drop:]
self.keyframe_clouds = self.keyframe_clouds[drop:]
self._rebuild_graph_odometry_only()
@staticmethod
def _slerp_rotation(R0: np.ndarray, R1: np.ndarray, t: float) -> np.ndarray:
"""Spherical linear interpolation between two rotation matrices."""
dR = R0.T @ R1
cos_angle = np.clip((np.trace(dR) - 1.0) * 0.5, -1.0, 1.0)
angle = float(np.arccos(cos_angle))
if abs(angle) < 1e-8:
return R0.copy()
sin_a = np.sin(angle)
axis = np.array([
dR[2, 1] - dR[1, 2],
dR[0, 2] - dR[2, 0],
dR[1, 0] - dR[0, 1],
]) / (2.0 * sin_a)
angle_t = angle * float(t)
K = np.array([
[0.0, -axis[2], axis[1]],
[axis[2], 0.0, -axis[0]],
[-axis[1], axis[0], 0.0],
])
dRt = np.eye(3) + np.sin(angle_t) * K + (1.0 - np.cos(angle_t)) * (K @ K)
return R0 @ dRt
@staticmethod
def _interpolate_se3(T0: np.ndarray, T1: np.ndarray, alpha: float) -> np.ndarray:
"""Interpolate between two SE3 transforms; alpha=0 → T0, alpha=1 → T1."""
a = float(np.clip(alpha, 0.0, 1.0))
out = np.eye(4, dtype=np.float64)
out[:3, :3] = LoopClosureBackend._slerp_rotation(T0[:3, :3], T1[:3, :3], a)
out[:3, 3] = (1.0 - a) * T0[:3, 3] + a * T1[:3, 3]
return out
def _optimize(self, src_idx: int, dst_idx: int, T_meas: np.ndarray) -> Optional[List[np.ndarray]]:
"""
Distribute loop-closure error linearly across the keyframe chain.
Poses from dst_idx+1 to src_idx receive a fraction of the correction
proportional to their distance from dst_idx. All poses after src_idx
receive the full correction. Poses at or before dst_idx are unchanged.
Parameters
----------
src_idx : index of the current (drifted) keyframe
dst_idx : index of the matching earlier keyframe
T_meas : 4×4 relative transform — src expressed in dst's frame
(output of ICP; transforms src points into dst frame)
Returns
-------
List of updated 4×4 pose matrices, or None if inputs are invalid.
"""
n = len(self.keyframe_poses)
if n < 2:
return None
if not (0 <= dst_idx < src_idx < n):
return None
poses = [np.array(p, dtype=np.float64) for p in self.keyframe_poses]
# Where src *should* be in world frame if there were no drift:
T_src_expected = poses[dst_idx] @ T_meas
T_src_actual = poses[src_idx]
# Full correction needed to move src_actual → src_expected:
try:
T_correction = T_src_expected @ np.linalg.inv(T_src_actual)
except np.linalg.LinAlgError:
return None
# Sanity-check the correction magnitude
corr_t = float(np.linalg.norm(T_correction[:3, 3]))
_, corr_deg = _pose_delta(T_correction, np.eye(4, dtype=np.float64))
if corr_t > float(self.cfg.max_correction_translation_m) or corr_deg > float(self.cfg.max_correction_rotation_deg):
return None
T_identity = np.eye(4, dtype=np.float64)
chain_len = src_idx - dst_idx
# Linearly distribute from dst_idx+1 (alpha≈0) to src_idx (alpha=1)
for i in range(dst_idx + 1, src_idx + 1):
alpha = float(i - dst_idx) / float(chain_len)
T_partial = self._interpolate_se3(T_identity, T_correction, alpha)
poses[i] = T_partial @ poses[i]
# Propagate full correction to all poses after src_idx
for i in range(src_idx + 1, n):
poses[i] = T_correction @ poses[i]
return poses
def process_frame(self, points_sensor: np.ndarray, pose_world: np.ndarray) -> LoopClosureResult:
self.frame_index += 1
result = LoopClosureResult()
if not self.cfg.enabled:
return result
if points_sensor is None or len(points_sensor) < 40:
return result
if pose_world is None or np.asarray(pose_world).shape != (4, 4):
return result
pose = np.asarray(pose_world, dtype=np.float64)
if not self._should_add_keyframe(pose):
return result
ds = _voxel_downsample_numpy(points_sensor.astype(np.float32, copy=False), float(self.cfg.downsample_voxel_m))
if len(ds) < 40:
return result
self.keyframe_clouds.append(ds)
self.keyframe_poses.append(pose.copy())
self.last_keyframe_frame = self.frame_index
result.keyframe_added = True
idx = len(self.keyframe_poses) - 1
cand = self._find_candidate(idx)
if cand is not None:
init = self._relative_source_to_target(self.keyframe_poses[idx], self.keyframe_poses[cand])
reg = self._run_icp(
self.keyframe_clouds[idx],
self.keyframe_clouds[cand],
init=init,
max_corr=float(self.cfg.icp_max_corr_m),
)
if reg is not None:
fit = float(reg.get("fitness", 0.0))
rmse = float(reg.get("inlier_rmse", 9e9))
ok = (fit >= float(self.cfg.accept_fitness)) and (rmse <= float(self.cfg.accept_rmse))
result.info["loop_candidate"] = int(cand)
result.info["loop_fitness"] = fit
result.info["loop_rmse"] = rmse
if ok:
result.loop_detected = True
self._loop_count += 1
t_meas = np.asarray(reg.get("transformation", np.eye(4, dtype=np.float64)), dtype=np.float64)
optimized_poses = self._optimize(idx, cand, t_meas)
if optimized_poses is None:
self._last_opt_reject = {
"reason": "optimize_rejected",
"src_idx": int(idx),
"dst_idx": int(cand),
}
result.info["rejected"] = dict(self._last_opt_reject)
else:
# Correction seen at src_idx (for callers that need a global correction estimate)
pose_cur = np.asarray(self.keyframe_poses[idx], dtype=np.float64)
correction = optimized_poses[idx] @ np.linalg.inv(pose_cur)
result.optimized = True
result.correction = np.asarray(correction, dtype=np.float64)
self._opt_count += 1
self.keyframe_poses = optimized_poses
result.info["loop_count"] = int(self._loop_count)
result.info["opt_count"] = int(self._opt_count)
self._maybe_trim()
return result