Spaces:
Running
Running
| import json | |
| import math | |
| import os | |
| import time | |
| import contextlib | |
| import io | |
| import warnings | |
| from dataclasses import asdict, dataclass | |
| from glob import glob | |
| from typing import Dict, List, Optional, Sequence, Tuple | |
| import cv2 | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| import torch | |
| from scipy.optimize import least_squares | |
| from scipy.sparse import lil_matrix | |
| from scipy.spatial.transform import Rotation | |
| from horizonstream.data import HorizonStreamDataLoader, HorizonStreamSequenceInfo | |
| from horizonstream.eval.io import frame_stems, read_opencv_camera_yml, read_pred_w2c_txt | |
| from horizonstream.eval.metrics import ate_rmse, transform_points | |
| from horizonstream.loop.pypose_optimizer import optimize_pose_graph_pypose | |
| from horizonstream.streaming.keyframe_selector import KeyframeSelector | |
| class LoopConfig: | |
| verbose: bool = True | |
| pose_graph_backend: str = "pypose" | |
| pose_graph_model: str = "se3" | |
| pose_graph_update_mode: str = "all" | |
| pose_graph_solver_verbose: bool = True | |
| keyframe_stride: int = 1 | |
| min_keyframe_gap: int = 10 | |
| dbow_temporal_exclusion: int = 10 | |
| loop_chunk_size: int = 20 | |
| retrieval_top_k: int = 5 | |
| dbow_num_repeat: int = 3 | |
| retrieval_score_thresh_dbow: float = 0.034 | |
| retrieval_score_thresh_salad: float = 0.85 | |
| nms_radius: int = 25 | |
| max_candidates_per_method: int = 1000 | |
| max_verified_loops_per_method: int = 1000 | |
| dbow_vocab_path: Optional[str] = None | |
| salad_ckpt_path: str = "weights/dino_salad.ckpt" | |
| salad_dino_weights_path: str = "weights/dinov2_vitb14_pretrain.pth" | |
| salad_backbone: str = "dinov2_vitb14" | |
| salad_image_size: Tuple[int, int] = (336, 336) | |
| salad_batch_size: int = 32 | |
| orb_features: int = 4096 | |
| patch_match_thresh: float = 0.40 | |
| max_pair_matches: int = 256 | |
| orb_ratio_test: float = 0.80 | |
| rigid_ransac_iters: int = 1 | |
| rigid_inlier_thresh: float = 0.1 | |
| rigid_min_inliers: int = 24 | |
| loop_edge_min_separation: int = 30 | |
| sim3_irls_delta: float = 0.1 | |
| sim3_irls_max_iters: int = 5 | |
| sim3_irls_tol: float = 1e-9 | |
| pose_graph_rot_weight: float = 1.0 | |
| pose_graph_trans_weight: float = 2.0 | |
| pose_graph_scale_weight: float = 1.0 | |
| pose_graph_loop_weight: float = 0.1 | |
| pose_graph_max_nfev: int = 200 | |
| pose_graph_max_iterations: int = 30 | |
| pose_graph_lambda_init: float = 1e-6 | |
| def _log(msg: str, enabled: bool = True) -> None: | |
| if not enabled: | |
| return | |
| stamp = time.strftime("%H:%M:%S") | |
| print(f"[loop_runtime {stamp}] {msg}", flush=True) | |
| def _log_every( | |
| label: str, | |
| index: int, | |
| total: int, | |
| enabled: bool = True, | |
| step: int = 10, | |
| ) -> None: | |
| if not enabled: | |
| return | |
| if total <= 0: | |
| return | |
| if index == 1 or index == total or index % max(1, step) == 0: | |
| _log(f"{label}: {index}/{total}", enabled=enabled) | |
| class SequenceArtifacts: | |
| seq_dir: str | |
| pose_variant: str | |
| pose_path: str | |
| intri_path: str | |
| image_paths: List[str] | |
| depth_paths: List[str] | |
| frame_ids: List[int] | |
| w2c: np.ndarray | |
| c2w: np.ndarray | |
| intrinsics: np.ndarray | |
| image_hw: Tuple[int, int] | |
| class LoopCandidate: | |
| src_pos: int | |
| dst_pos: int | |
| src_frame: int | |
| dst_frame: int | |
| score: float | |
| method: str | |
| class LoopEdge: | |
| src_pos: int | |
| dst_pos: int | |
| src_frame: int | |
| dst_frame: int | |
| score: float | |
| inliers: int | |
| method: str | |
| transform_ji: np.ndarray | |
| class PoseGraphSummary: | |
| method: str | |
| num_candidates: int | |
| num_verified_loops: int | |
| mean_loop_score: float | |
| mean_loop_inliers: float | |
| trajectory_path: str | |
| loop_plot_path: str | |
| class GroundTruthTrajectory: | |
| seq_name: str | |
| scene_root: str | |
| camera: Optional[str] | |
| c2w: np.ndarray | |
| valid_mask: np.ndarray | |
| def _read_intri_txt(path: str) -> Tuple[List[int], np.ndarray]: | |
| frames = [] | |
| intri = [] | |
| with open(path, "r") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| vals = [float(x) for x in line.split()] | |
| if len(vals) != 5: | |
| continue | |
| frame = int(vals[0]) | |
| K = np.eye(3, dtype=np.float64) | |
| K[0, 0] = vals[1] | |
| K[1, 1] = vals[2] | |
| K[0, 2] = vals[3] | |
| K[1, 2] = vals[4] | |
| frames.append(frame) | |
| intri.append(K) | |
| return frames, np.asarray(intri, dtype=np.float64) | |
| def _sorted_frame_files(path_pattern: str) -> List[str]: | |
| def frame_key(path: str) -> Tuple[int, str]: | |
| stem = os.path.splitext(os.path.basename(path))[0] | |
| digits = "".join(ch for ch in stem if ch.isdigit()) | |
| return (int(digits) if digits else -1, path) | |
| return sorted(glob(path_pattern), key=frame_key) | |
| def _resolve_pose_and_intri_paths( | |
| pose_dir: str, | |
| pose_variant: str = "main", | |
| ) -> Tuple[str, str, str]: | |
| variant = str(pose_variant or "main").strip().lower() | |
| if variant in {"", "main", "default"}: | |
| variant = "main" | |
| pose_path = os.path.join(pose_dir, "abs_pose.txt") | |
| intri_path = os.path.join(pose_dir, "intri.txt") | |
| else: | |
| pose_path = os.path.join(pose_dir, f"{variant}_abs_pose.txt") | |
| intri_path = os.path.join(pose_dir, f"{variant}_intri.txt") | |
| if not os.path.exists(pose_path): | |
| raise FileNotFoundError( | |
| f"Pose file not found for pose_variant='{variant}': {pose_path}" | |
| ) | |
| if not os.path.exists(intri_path): | |
| fallback_intri = os.path.join(pose_dir, "intri.txt") | |
| if os.path.exists(fallback_intri): | |
| intri_path = fallback_intri | |
| else: | |
| raise FileNotFoundError( | |
| f"Intrinsic file not found for pose_variant='{variant}': {intri_path}" | |
| ) | |
| return variant, pose_path, intri_path | |
| def load_sequence_artifacts( | |
| seq_dir: str, | |
| image_dir: Optional[str] = None, | |
| depth_dir: Optional[str] = None, | |
| pose_variant: str = "main", | |
| ) -> SequenceArtifacts: | |
| pose_dir = os.path.join(seq_dir, "poses") | |
| image_dir = image_dir or os.path.join(seq_dir, "images", "rgb") | |
| depth_dir = depth_dir or os.path.join(seq_dir, "depth", "dpt") | |
| pose_variant, pose_path, intri_path = _resolve_pose_and_intri_paths( | |
| pose_dir, | |
| pose_variant=pose_variant, | |
| ) | |
| frame_ids, w2c_list = read_pred_w2c_txt(pose_path) | |
| intri_frames, intri = _read_intri_txt(intri_path) | |
| if frame_ids != intri_frames: | |
| raise ValueError("Frame ids in abs_pose.txt and intri.txt do not match") | |
| image_paths = _sorted_frame_files(os.path.join(image_dir, "frame_*.*")) | |
| depth_paths = _sorted_frame_files(os.path.join(depth_dir, "frame_*.npy")) | |
| if len(image_paths) != len(frame_ids): | |
| raise ValueError( | |
| f"Expected {len(frame_ids)} RGB frames in {image_dir}, found {len(image_paths)}" | |
| ) | |
| if len(depth_paths) != len(frame_ids): | |
| raise ValueError( | |
| f"Expected {len(frame_ids)} depth frames in {depth_dir}, found {len(depth_paths)}" | |
| ) | |
| sample = cv2.imread(image_paths[0], cv2.IMREAD_COLOR) | |
| if sample is None: | |
| raise FileNotFoundError(image_paths[0]) | |
| h, w = sample.shape[:2] | |
| w2c = np.asarray(w2c_list, dtype=np.float64) | |
| c2w = np.linalg.inv(w2c) | |
| return SequenceArtifacts( | |
| seq_dir=seq_dir, | |
| pose_variant=pose_variant, | |
| pose_path=pose_path, | |
| intri_path=intri_path, | |
| image_paths=image_paths, | |
| depth_paths=depth_paths, | |
| frame_ids=frame_ids, | |
| w2c=w2c, | |
| c2w=c2w, | |
| intrinsics=intri, | |
| image_hw=(h, w), | |
| ) | |
| class SequenceCache: | |
| def __init__(self, artifacts: SequenceArtifacts): | |
| self.artifacts = artifacts | |
| self._rgb: Dict[int, np.ndarray] = {} | |
| self._gray: Dict[int, np.ndarray] = {} | |
| self._depth: Dict[int, np.ndarray] = {} | |
| def rgb(self, frame_idx: int) -> np.ndarray: | |
| if frame_idx not in self._rgb: | |
| image = cv2.imread(self.artifacts.image_paths[frame_idx], cv2.IMREAD_COLOR) | |
| if image is None: | |
| raise FileNotFoundError(self.artifacts.image_paths[frame_idx]) | |
| self._rgb[frame_idx] = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) | |
| return self._rgb[frame_idx] | |
| def gray(self, frame_idx: int) -> np.ndarray: | |
| if frame_idx not in self._gray: | |
| self._gray[frame_idx] = cv2.cvtColor(self.rgb(frame_idx), cv2.COLOR_RGB2GRAY) | |
| return self._gray[frame_idx] | |
| def depth(self, frame_idx: int) -> np.ndarray: | |
| if frame_idx not in self._depth: | |
| self._depth[frame_idx] = np.load(self.artifacts.depth_paths[frame_idx]).astype( | |
| np.float32 | |
| ) | |
| return self._depth[frame_idx] | |
| def select_keyframes(num_frames: int, stride: int) -> Tuple[np.ndarray, np.ndarray]: | |
| selector = KeyframeSelector( | |
| min_interval=stride, | |
| max_interval=stride, | |
| force_first=True, | |
| mode="fixed", | |
| ) | |
| is_keyframe, keyframe_indices = selector.select_keyframes( | |
| num_frames, batch_size=1, device=torch.device("cpu") | |
| ) | |
| return is_keyframe[0].numpy(), keyframe_indices[0].numpy() | |
| def pose_matrix_to_vec(pose: np.ndarray) -> np.ndarray: | |
| rotvec = Rotation.from_matrix(pose[:3, :3]).as_rotvec() | |
| return np.concatenate([rotvec, pose[:3, 3]], axis=0) | |
| def pose_vec_to_matrix(vec: np.ndarray) -> np.ndarray: | |
| pose = np.eye(4, dtype=np.float64) | |
| pose[:3, :3] = Rotation.from_rotvec(vec[:3]).as_matrix() | |
| pose[:3, 3] = vec[3:6] | |
| return pose | |
| def project_to_rotation(mat: np.ndarray) -> np.ndarray: | |
| u, _, vt = np.linalg.svd(np.asarray(mat, dtype=np.float64)) | |
| rot = u @ vt | |
| if np.linalg.det(rot) < 0: | |
| vt[-1] *= -1 | |
| rot = u @ vt | |
| return rot.astype(np.float64, copy=False) | |
| def similarity_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 similarity_matrix(scale: float, rot: np.ndarray, trans: np.ndarray) -> np.ndarray: | |
| out = np.eye(4, dtype=np.float64) | |
| out[:3, :3] = float(scale) * np.asarray(rot, dtype=np.float64) | |
| out[:3, 3] = np.asarray(trans, dtype=np.float64) | |
| return out | |
| def normalize_pose_matrix(mat: np.ndarray) -> np.ndarray: | |
| _, rot, trans = similarity_components(mat) | |
| out = np.eye(4, dtype=np.float64) | |
| out[:3, :3] = rot | |
| out[:3, 3] = trans | |
| return out | |
| def relative_measurement_from_c2w(src_c2w: np.ndarray, dst_c2w: np.ndarray) -> np.ndarray: | |
| return np.linalg.inv(dst_c2w) @ src_c2w | |
| def camera_centers_from_w2c(w2c: np.ndarray) -> np.ndarray: | |
| rot = w2c[:, :3, :3] | |
| t = w2c[:, :3, 3] | |
| return -(np.transpose(rot, (0, 2, 1)) @ t[..., None])[..., 0] | |
| def _top_down_xy(c2w: np.ndarray) -> np.ndarray: | |
| xyz = c2w[:, :3, 3] | |
| return xyz[:, [0, 2]] | |
| def _normalize_rows(x: np.ndarray, eps: float = 1e-8) -> np.ndarray: | |
| norms = np.linalg.norm(x, axis=1, keepdims=True) | |
| return x / np.clip(norms, eps, None) | |
| def _copy_loop_cfg(cfg: LoopConfig) -> LoopConfig: | |
| return LoopConfig(**asdict(cfg)) | |
| def _imagenet_normalize(images: torch.Tensor) -> torch.Tensor: | |
| mean = torch.tensor([0.485, 0.456, 0.406], device=images.device).view(1, 3, 1, 1) | |
| std = torch.tensor([0.229, 0.224, 0.225], device=images.device).view(1, 3, 1, 1) | |
| return (images - mean) / std | |
| def _resize_rgb_batch(batch: np.ndarray, image_size: Tuple[int, int]) -> np.ndarray: | |
| height, width = int(image_size[0]), int(image_size[1]) | |
| return np.stack( | |
| [ | |
| cv2.resize(image, (width, height), interpolation=cv2.INTER_LINEAR) | |
| for image in batch | |
| ], | |
| axis=0, | |
| ) | |
| def _batch_to_imagenet_tensor(batch: np.ndarray, device: torch.device) -> torch.Tensor: | |
| images = torch.from_numpy(batch).float().permute(0, 3, 1, 2).to(device) / 255.0 | |
| return _imagenet_normalize(images) | |
| def salad_log_otp_solver( | |
| log_a: torch.Tensor, | |
| log_b: torch.Tensor, | |
| scores: torch.Tensor, | |
| num_iters: int = 20, | |
| reg: float = 1.0, | |
| ) -> torch.Tensor: | |
| scores = scores / reg | |
| u = torch.zeros_like(log_a) | |
| v = torch.zeros_like(log_b) | |
| for _ in range(num_iters): | |
| u = log_a - torch.logsumexp(scores + v.unsqueeze(1), dim=2) | |
| v = log_b - torch.logsumexp(scores + u.unsqueeze(2), dim=1) | |
| return scores + u.unsqueeze(2) + v.unsqueeze(1) | |
| def salad_matching_probs( | |
| scores: torch.Tensor, | |
| dustbin_score: torch.Tensor, | |
| num_iters: int = 3, | |
| reg: float = 1.0, | |
| ) -> torch.Tensor: | |
| batch_size, m, n = scores.size() | |
| scores_aug = torch.empty(batch_size, m + 1, n, dtype=scores.dtype, device=scores.device) | |
| scores_aug[:, :m, :n] = scores | |
| scores_aug[:, m, :] = dustbin_score | |
| norm = -torch.tensor(math.log(n + m), device=scores.device) | |
| log_a = norm.expand(m + 1).contiguous() | |
| log_b = norm.expand(n).contiguous() | |
| log_a[-1] = log_a[-1] + math.log(n - m) | |
| log_a = log_a.expand(batch_size, -1) | |
| log_b = log_b.expand(batch_size, -1) | |
| return salad_log_otp_solver(log_a, log_b, scores_aug, num_iters=num_iters, reg=reg) - norm | |
| class SaladAggregator(torch.nn.Module): | |
| def __init__( | |
| self, | |
| num_channels: int = 768, | |
| num_clusters: int = 64, | |
| cluster_dim: int = 128, | |
| token_dim: int = 256, | |
| dropout: float = 0.3, | |
| ) -> None: | |
| super().__init__() | |
| dropout_layer: torch.nn.Module | |
| dropout_layer = torch.nn.Dropout(dropout) if dropout > 0 else torch.nn.Identity() | |
| self.num_channels = int(num_channels) | |
| self.num_clusters = int(num_clusters) | |
| self.cluster_dim = int(cluster_dim) | |
| self.token_dim = int(token_dim) | |
| self.token_features = torch.nn.Sequential( | |
| torch.nn.Linear(self.num_channels, 512), | |
| torch.nn.ReLU(), | |
| torch.nn.Linear(512, self.token_dim), | |
| ) | |
| self.cluster_features = torch.nn.Sequential( | |
| torch.nn.Conv2d(self.num_channels, 512, 1), | |
| dropout_layer, | |
| torch.nn.ReLU(), | |
| torch.nn.Conv2d(512, self.cluster_dim, 1), | |
| ) | |
| self.score = torch.nn.Sequential( | |
| torch.nn.Conv2d(self.num_channels, 512, 1), | |
| dropout_layer, | |
| torch.nn.ReLU(), | |
| torch.nn.Conv2d(512, self.num_clusters, 1), | |
| ) | |
| self.dust_bin = torch.nn.Parameter(torch.tensor(1.0)) | |
| def forward(self, features_and_token: Tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: | |
| features, token = features_and_token | |
| cluster_features = self.cluster_features(features).flatten(2) | |
| scores = self.score(features).flatten(2) | |
| token = self.token_features(token) | |
| probs = torch.exp(salad_matching_probs(scores, self.dust_bin, 3)) | |
| probs = probs[:, :-1, :] | |
| probs = probs.unsqueeze(1).repeat(1, self.cluster_dim, 1, 1) | |
| cluster_features = cluster_features.unsqueeze(2).repeat(1, 1, self.num_clusters, 1) | |
| descriptor = torch.cat( | |
| [ | |
| torch.nn.functional.normalize(token, p=2, dim=-1), | |
| torch.nn.functional.normalize( | |
| (cluster_features * probs).sum(dim=-1), p=2, dim=1 | |
| ).flatten(1), | |
| ], | |
| dim=-1, | |
| ) | |
| return torch.nn.functional.normalize(descriptor, p=2, dim=-1) | |
| class DinoV2BackboneForSalad(torch.nn.Module): | |
| CHANNELS = { | |
| "dinov2_vits14": 384, | |
| "dinov2_vitb14": 768, | |
| "dinov2_vitl14": 1024, | |
| "dinov2_vitg14": 1536, | |
| } | |
| def __init__(self, model_name: str, weights_path: Optional[str], num_trainable_blocks: int = 4): | |
| super().__init__() | |
| if model_name not in self.CHANNELS: | |
| raise ValueError(f"Unsupported SALAD backbone: {model_name}") | |
| self.model_name = model_name | |
| self.num_channels = int(self.CHANNELS[model_name]) | |
| self.num_trainable_blocks = int(num_trainable_blocks) | |
| with warnings.catch_warnings(), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): | |
| warnings.filterwarnings("ignore", message=".*xFormers is not available.*") | |
| self.model = torch.hub.load("facebookresearch/dinov2", model_name, pretrained=False) | |
| if weights_path and os.path.exists(weights_path): | |
| state = torch.load(weights_path, map_location="cpu") | |
| self.model.load_state_dict(state, strict=True) | |
| def forward(self, images: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: | |
| batch, _, height, width = images.shape | |
| tokens = self.model.prepare_tokens_with_masks(images) | |
| for block in self.model.blocks: | |
| tokens = block(tokens) | |
| tokens = self.model.norm(tokens) | |
| cls_token = tokens[:, 0] | |
| patch_tokens = tokens[:, 1:] | |
| feature_map = patch_tokens.reshape( | |
| batch, height // 14, width // 14, self.num_channels | |
| ).permute(0, 3, 1, 2) | |
| return feature_map, cls_token, patch_tokens | |
| class SaladVPRModel(torch.nn.Module): | |
| def __init__(self, backbone_name: str, dino_weights_path: Optional[str]): | |
| super().__init__() | |
| self.backbone = DinoV2BackboneForSalad(backbone_name, dino_weights_path) | |
| self.aggregator = SaladAggregator( | |
| num_channels=self.backbone.num_channels, | |
| num_clusters=64, | |
| cluster_dim=128, | |
| token_dim=256, | |
| ) | |
| def forward_features(self, images: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: | |
| feature_map, cls_token, patch_tokens = self.backbone(images) | |
| descriptor = self.aggregator((feature_map, cls_token)) | |
| return descriptor, torch.nn.functional.normalize(patch_tokens, dim=-1) | |
| def forward(self, images: torch.Tensor) -> torch.Tensor: | |
| descriptor, _ = self.forward_features(images) | |
| return descriptor | |
| def _load_salad_state_dict(model: torch.nn.Module, ckpt_path: str) -> List[str]: | |
| if not os.path.exists(ckpt_path): | |
| raise FileNotFoundError( | |
| f"SALAD checkpoint not found: {ckpt_path}. " | |
| "Download it from https://github.com/serizba/salad/releases/download/v1.0.0/dino_salad.ckpt" | |
| ) | |
| payload = torch.load(ckpt_path, map_location="cpu") | |
| state = payload.get("state_dict", payload) if isinstance(payload, dict) else payload | |
| cleaned = {} | |
| for key, value in state.items(): | |
| out_key = str(key) | |
| for prefix in ("model.", "module."): | |
| if out_key.startswith(prefix): | |
| out_key = out_key[len(prefix) :] | |
| cleaned[out_key] = value | |
| missing, unexpected = model.load_state_dict(cleaned, strict=False) | |
| missing = list(missing) | |
| missing_aggregator = [key for key in missing if key.startswith("aggregator.")] | |
| if missing_aggregator: | |
| raise RuntimeError( | |
| "SALAD checkpoint did not provide the expected aggregator weights: " | |
| + ", ".join(missing_aggregator[:5]) | |
| ) | |
| if unexpected: | |
| _log( | |
| "SALAD: ignored unexpected checkpoint keys: " | |
| + ", ".join(str(key) for key in unexpected[:5]), | |
| enabled=True, | |
| ) | |
| return missing | |
| _POPCOUNT_TABLE = np.unpackbits( | |
| np.arange(256, dtype=np.uint8)[:, None], axis=1 | |
| ).sum(axis=1).astype(np.uint8) | |
| class FlatORBVocabulary: | |
| def __init__(self, path: str): | |
| self.path = path | |
| self.centers, self.idf = self._load(path) | |
| self.num_words = int(self.centers.shape[0]) | |
| def _load(path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]: | |
| if not os.path.exists(path): | |
| raise FileNotFoundError(path) | |
| ext = os.path.splitext(path)[1].lower() | |
| if ext == ".npz": | |
| data = np.load(path) | |
| if "centers" not in data: | |
| raise ValueError(f"Expected 'centers' in vocabulary file: {path}") | |
| centers = np.asarray(data["centers"], dtype=np.float32) | |
| idf = np.asarray(data["idf"], dtype=np.float32) if "idf" in data else None | |
| elif ext == ".npy": | |
| centers = np.asarray(np.load(path), dtype=np.float32) | |
| idf = None | |
| else: | |
| try: | |
| centers = np.asarray(np.loadtxt(path), dtype=np.float32) | |
| except Exception as exc: | |
| raise ValueError( | |
| f"Unsupported ORB vocabulary text format in {path}. " | |
| "This loader expects a flat centers matrix (.npz/.npy/.txt), not a hierarchical DBoW2 ORBvoc.txt." | |
| ) from exc | |
| idf = None | |
| if centers.ndim != 2 or centers.shape[1] != 32: | |
| raise ValueError( | |
| f"Expected ORB vocabulary centers with shape [K, 32], got {tuple(centers.shape)} from {path}" | |
| ) | |
| if idf is not None: | |
| if idf.ndim != 1 or idf.shape[0] != centers.shape[0]: | |
| raise ValueError( | |
| f"Expected idf shape [{centers.shape[0]}], got {tuple(idf.shape)} from {path}" | |
| ) | |
| return centers.astype(np.float32, copy=False), idf | |
| def quantize(self, desc: np.ndarray) -> np.ndarray: | |
| diff = desc.astype(np.float32, copy=False)[:, None, :] - self.centers[None, :, :] | |
| dist2 = np.sum(diff * diff, axis=-1) | |
| return dist2.argmin(axis=1).astype(np.int64, copy=False) | |
| def histogram(self, desc: Optional[np.ndarray]) -> np.ndarray: | |
| hist = np.zeros(self.num_words, dtype=np.float32) | |
| if desc is None or len(desc) == 0: | |
| return hist | |
| words = self.quantize(desc) | |
| np.add.at(hist, words, 1.0) | |
| if self.idf is not None: | |
| hist *= self.idf | |
| return _normalize_rows(hist[None] + 1e-12)[0] | |
| def score_matrix(self, hists: np.ndarray) -> np.ndarray: | |
| hists = _normalize_rows(hists.astype(np.float32)) | |
| return hists @ hists.T | |
| class DBoWTextVocabulary: | |
| def __init__(self, path: str): | |
| self.path = path | |
| self.nodes: List[Dict[str, object]] = [ | |
| { | |
| "id": 0, | |
| "parent": -1, | |
| "children": [], | |
| "descriptor": None, | |
| "weight": 0.0, | |
| "word_id": -1, | |
| "is_leaf": False, | |
| } | |
| ] | |
| self.word_node_ids: List[int] = [] | |
| self.k = 0 | |
| self.L = 0 | |
| self.scoring = 0 | |
| self.weighting = 0 | |
| self._load(path) | |
| self.num_words = len(self.word_node_ids) | |
| def _load(self, path: str) -> None: | |
| if not os.path.exists(path): | |
| raise FileNotFoundError(path) | |
| with open(path, "r") as f: | |
| header = f.readline().strip().split() | |
| if len(header) != 4: | |
| raise ValueError(f"Invalid ORBvoc header in {path}: {' '.join(header)}") | |
| self.k, self.L, self.scoring, self.weighting = [int(x) for x in header] | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| parts = line.split() | |
| if len(parts) != 35: | |
| raise ValueError( | |
| f"Expected 35 fields per ORBvoc node, got {len(parts)} in {path}" | |
| ) | |
| parent = int(parts[0]) | |
| is_leaf = int(parts[1]) > 0 | |
| descriptor = np.asarray([int(x) for x in parts[2:34]], dtype=np.uint8) | |
| weight = float(parts[34]) | |
| node_id = len(self.nodes) | |
| node = { | |
| "id": node_id, | |
| "parent": parent, | |
| "children": [], | |
| "descriptor": descriptor, | |
| "weight": weight, | |
| "word_id": -1, | |
| "is_leaf": is_leaf, | |
| } | |
| self.nodes.append(node) | |
| self.nodes[parent]["children"].append(node_id) | |
| if is_leaf: | |
| node["word_id"] = len(self.word_node_ids) | |
| self.word_node_ids.append(node_id) | |
| if self.weighting != 0 or self.scoring != 0: | |
| raise ValueError( | |
| f"Unsupported ORBvoc scoring/weighting ({self.scoring}, {self.weighting}) in {path}. " | |
| "This loader currently supports the standard TF_IDF + L1_NORM ORBvoc only." | |
| ) | |
| def _hamming_distance(self, desc: np.ndarray, child_descs: np.ndarray) -> np.ndarray: | |
| xor = np.bitwise_xor(desc[:, None, :], child_descs[None, :, :]) | |
| return _POPCOUNT_TABLE[xor].sum(axis=2) | |
| def quantize(self, desc: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: | |
| current = np.zeros(len(desc), dtype=np.int64) | |
| while True: | |
| next_nodes = current.copy() | |
| active = False | |
| for parent_id in np.unique(current): | |
| idx = np.flatnonzero(current == parent_id) | |
| if idx.size == 0: | |
| continue | |
| children = self.nodes[parent_id]["children"] | |
| if not children: | |
| continue | |
| active = True | |
| child_descs = np.stack( | |
| [self.nodes[child_id]["descriptor"] for child_id in children], axis=0 | |
| ) | |
| dist = self._hamming_distance(desc[idx], child_descs) | |
| best = dist.argmin(axis=1) | |
| next_nodes[idx] = np.asarray(children, dtype=np.int64)[best] | |
| current = next_nodes | |
| if not active: | |
| break | |
| if all(bool(self.nodes[node_id]["is_leaf"]) for node_id in np.unique(current)): | |
| break | |
| word_ids = np.asarray( | |
| [int(self.nodes[node_id]["word_id"]) for node_id in current], dtype=np.int64 | |
| ) | |
| weights = np.asarray( | |
| [float(self.nodes[node_id]["weight"]) for node_id in current], dtype=np.float32 | |
| ) | |
| return word_ids, weights | |
| def sparse_histogram(self, desc: Optional[np.ndarray]) -> Dict[int, float]: | |
| hist: Dict[int, float] = {} | |
| if desc is None or len(desc) == 0: | |
| return hist | |
| word_ids, weights = self.quantize(desc) | |
| for word_id, weight in zip(word_ids.tolist(), weights.tolist()): | |
| hist[int(word_id)] = hist.get(int(word_id), 0.0) + float(weight) | |
| total = float(sum(hist.values())) | |
| if total > 0: | |
| hist = {word_id: value / total for word_id, value in hist.items()} | |
| return hist | |
| def score_matrix(self, hists: Sequence[Dict[int, float]]) -> np.ndarray: | |
| n = len(hists) | |
| sim = np.eye(n, dtype=np.float32) | |
| for i in range(n): | |
| hi = hists[i] | |
| for j in range(i): | |
| hj = hists[j] | |
| if len(hi) > len(hj): | |
| hi, hj = hj, hi | |
| score = 0.0 | |
| for word_id, value in hi.items(): | |
| other = hj.get(word_id) | |
| if other is not None: | |
| score += min(value, other) | |
| sim[i, j] = score | |
| sim[j, i] = score | |
| return sim | |
| def _load_orb_vocabulary(path: str): | |
| if os.path.basename(path) == "ORBvoc.txt": | |
| return DBoWTextVocabulary(path) | |
| return FlatORBVocabulary(path) | |
| def _resolve_gt_sequence_info( | |
| seq_dir: str, | |
| data_cfg: Optional[dict], | |
| seq_name_hint: Optional[str] = None, | |
| ) -> Optional[HorizonStreamSequenceInfo]: | |
| if not data_cfg: | |
| return None | |
| seq_names = [] | |
| if seq_name_hint: | |
| seq_names.append(str(seq_name_hint).replace("\\", "/").strip("/")) | |
| seq_names.append(str(os.path.basename(os.path.normpath(seq_dir))).replace("\\", "/").strip("/")) | |
| preferred_camera = data_cfg.get("camera", None) | |
| if preferred_camera is not None: | |
| preferred_camera = str(preferred_camera).strip() | |
| loader = HorizonStreamDataLoader(dict(data_cfg)) | |
| prefixed_matches: List[HorizonStreamSequenceInfo] = [] | |
| suffixed_matches: List[HorizonStreamSequenceInfo] = [] | |
| for seq_info in loader.iter_sequence_infos(): | |
| name_norm = str(seq_info.name).replace("\\", "/").strip("/") | |
| for seq_name_norm in seq_names: | |
| if name_norm == seq_name_norm: | |
| return seq_info | |
| parts = [part for part in name_norm.split("/") if part] | |
| for seq_name_norm in seq_names: | |
| if parts and parts[0] == seq_name_norm: | |
| prefixed_matches.append(seq_info) | |
| if parts and parts[-1] == seq_name_norm: | |
| suffixed_matches.append(seq_info) | |
| candidates = prefixed_matches or suffixed_matches | |
| if not candidates: | |
| return None | |
| if preferred_camera: | |
| for seq_info in candidates: | |
| if str(seq_info.camera or "") == preferred_camera: | |
| return seq_info | |
| for seq_info in candidates: | |
| if str(seq_info.camera or "") == "02": | |
| return seq_info | |
| candidates = sorted(candidates, key=lambda item: str(item.name)) | |
| if candidates: | |
| return candidates[0] | |
| return None | |
| def load_ground_truth_trajectory( | |
| seq_dir: str, | |
| data_cfg: Optional[dict], | |
| verbose: bool = True, | |
| seq_name_hint: Optional[str] = None, | |
| ) -> Optional[GroundTruthTrajectory]: | |
| seq_info = _resolve_gt_sequence_info(seq_dir, data_cfg, seq_name_hint=seq_name_hint) | |
| if seq_info is None: | |
| _log("GT: no matching sequence info found from data config", verbose) | |
| return None | |
| if seq_info.camera is not None: | |
| cam_dir = os.path.join(seq_info.scene_root, "cameras", seq_info.camera) | |
| extri_path = os.path.join(cam_dir, "extri.yml") | |
| intri_path = os.path.join(cam_dir, "intri.yml") | |
| else: | |
| extri_path = os.path.join(seq_info.scene_root, "extri.yml") | |
| intri_path = os.path.join(seq_info.scene_root, "intri.yml") | |
| if not os.path.exists(extri_path): | |
| _log(f"GT: missing pose file {extri_path}", verbose) | |
| return None | |
| extri, _, _ = read_opencv_camera_yml(extri_path, intri_path) | |
| stems = frame_stems(seq_info.image_paths) | |
| gt_c2w = [] | |
| valid_mask = [] | |
| for stem in stems: | |
| if stem in extri: | |
| gt_c2w.append(np.linalg.inv(extri[stem])) | |
| valid_mask.append(True) | |
| else: | |
| gt_c2w.append(np.eye(4, dtype=np.float64)) | |
| valid_mask.append(False) | |
| valid_mask = np.asarray(valid_mask, dtype=bool) | |
| if not np.any(valid_mask): | |
| _log( | |
| f"GT: pose file {extri_path} does not contain any matching frame ids", | |
| verbose, | |
| ) | |
| return None | |
| _log( | |
| f"GT: loaded {int(valid_mask.sum())}/{len(valid_mask)} poses from {seq_info.scene_root}" | |
| + (f" camera={seq_info.camera}" if seq_info.camera else ""), | |
| verbose, | |
| ) | |
| return GroundTruthTrajectory( | |
| seq_name=seq_info.name, | |
| scene_root=seq_info.scene_root, | |
| camera=seq_info.camera, | |
| c2w=np.asarray(gt_c2w, dtype=np.float64), | |
| valid_mask=valid_mask, | |
| ) | |
| def evaluate_trajectory_against_gt( | |
| pred_c2w: np.ndarray, | |
| frame_ids: Sequence[int], | |
| gt: Optional[GroundTruthTrajectory], | |
| ) -> Optional[Dict[str, object]]: | |
| if gt is None: | |
| return None | |
| pred_xyz = [] | |
| gt_xyz = [] | |
| used_frame_ids = [] | |
| for pred_idx, frame_id in enumerate(frame_ids): | |
| if frame_id < 0 or frame_id >= len(gt.c2w): | |
| continue | |
| if not gt.valid_mask[frame_id]: | |
| continue | |
| pred_xyz.append(pred_c2w[pred_idx, :3, 3]) | |
| gt_xyz.append(gt.c2w[frame_id, :3, 3]) | |
| used_frame_ids.append(int(frame_id)) | |
| if len(pred_xyz) < 3: | |
| return None | |
| pred_xyz = np.asarray(pred_xyz, dtype=np.float64) | |
| gt_xyz = np.asarray(gt_xyz, dtype=np.float64) | |
| metrics = ate_rmse(pred_xyz, gt_xyz, align_scale=True) | |
| scale = float(metrics["sim3_scale"]) | |
| rot = np.asarray(metrics["sim3_rotation"], dtype=np.float64) | |
| trans = np.asarray(metrics["sim3_translation"], dtype=np.float64) | |
| pred_xyz_aligned = transform_points(pred_xyz, scale, rot, trans) | |
| metrics["frame_ids"] = used_frame_ids | |
| metrics["pred_xyz_aligned"] = pred_xyz_aligned.tolist() | |
| metrics["gt_xyz"] = gt_xyz.tolist() | |
| return metrics | |
| def compact_gt_metrics(metrics: Optional[Dict[str, object]]) -> Optional[Dict[str, object]]: | |
| if metrics is None: | |
| return None | |
| out = dict(metrics) | |
| out.pop("pred_xyz_aligned", None) | |
| out.pop("gt_xyz", None) | |
| out.pop("frame_ids", None) | |
| return out | |
| class ORBBoWRetriever: | |
| def __init__(self, cfg: LoopConfig): | |
| self.cfg = cfg | |
| if not cfg.dbow_vocab_path: | |
| raise ValueError("LoopConfig.dbow_vocab_path must be set for ORB-BoW retrieval") | |
| self.orb = cv2.ORB_create( | |
| nfeatures=cfg.orb_features, | |
| scaleFactor=1.2, | |
| nlevels=8, | |
| edgeThreshold=31, | |
| patchSize=31, | |
| fastThreshold=20, | |
| ) | |
| self.keypoints: Dict[int, List[cv2.KeyPoint]] = {} | |
| self.descriptors: Dict[int, Optional[np.ndarray]] = {} | |
| self.histograms = None | |
| self.vocab = _load_orb_vocabulary(cfg.dbow_vocab_path) | |
| _log( | |
| f"ORB-BoW: loaded fixed vocabulary {cfg.dbow_vocab_path} " | |
| f"with {self.vocab.num_words} words", | |
| self.cfg.verbose, | |
| ) | |
| def fit(self, cache: SequenceCache, keyframe_ids: Sequence[int]) -> np.ndarray: | |
| _log( | |
| f"ORB-BoW: extracting ORB descriptors for {len(keyframe_ids)} keyframes", | |
| self.cfg.verbose, | |
| ) | |
| total = len(keyframe_ids) | |
| for idx, frame_idx in enumerate(keyframe_ids, start=1): | |
| gray = cache.gray(frame_idx) | |
| kps, desc = self.orb.detectAndCompute(gray, None) | |
| self.keypoints[frame_idx] = kps or [] | |
| self.descriptors[frame_idx] = desc | |
| _log_every("ORB keyframes", idx, total, self.cfg.verbose, step=20) | |
| if not any(desc is not None and len(desc) for desc in self.descriptors.values()): | |
| if isinstance(self.vocab, DBoWTextVocabulary): | |
| self.histograms = [{} for _ in keyframe_ids] | |
| else: | |
| self.histograms = np.zeros( | |
| (len(keyframe_ids), int(self.vocab.num_words)), dtype=np.float32 | |
| ) | |
| _log("ORB-BoW: no descriptors found, returning empty histograms", self.cfg.verbose) | |
| return self.histograms | |
| if isinstance(self.vocab, DBoWTextVocabulary): | |
| hists = [] | |
| for frame_idx in keyframe_ids: | |
| desc = self.descriptors[frame_idx] | |
| hists.append(self.vocab.sparse_histogram(desc)) | |
| self.histograms = hists | |
| mean_nnz = float(np.mean([len(hist) for hist in hists])) if hists else 0.0 | |
| _log( | |
| f"ORB-BoW: built sparse descriptor bank for {len(hists)} keyframes " | |
| f"(mean nnz={mean_nnz:.1f})", | |
| self.cfg.verbose, | |
| ) | |
| else: | |
| hists = [] | |
| for frame_idx in keyframe_ids: | |
| desc = self.descriptors[frame_idx] | |
| hists.append(self.vocab.histogram(desc)) | |
| self.histograms = np.asarray(hists, dtype=np.float32) | |
| _log( | |
| f"ORB-BoW: built descriptor bank with shape {tuple(self.histograms.shape)}", | |
| self.cfg.verbose, | |
| ) | |
| return self.histograms | |
| def score_matrix(self) -> np.ndarray: | |
| if self.histograms is None: | |
| raise RuntimeError("ORB-BoW histograms are not initialized") | |
| return self.vocab.score_matrix(self.histograms) | |
| def collect_3d_correspondences( | |
| self, | |
| cache: SequenceCache, | |
| artifacts: SequenceArtifacts, | |
| src_frame: int, | |
| dst_frame: int, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| desc_src = self.descriptors.get(src_frame) | |
| desc_dst = self.descriptors.get(dst_frame) | |
| if desc_src is None or desc_dst is None or len(desc_src) < 8 or len(desc_dst) < 8: | |
| return ( | |
| np.empty((0, 3), dtype=np.float64), | |
| np.empty((0, 3), dtype=np.float64), | |
| ) | |
| matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) | |
| knn_a = matcher.knnMatch(desc_src, desc_dst, k=2) | |
| knn_b = matcher.knnMatch(desc_dst, desc_src, k=2) | |
| def ratio_filter(knn_matches): | |
| passed = {} | |
| for pair in knn_matches: | |
| if len(pair) < 2: | |
| continue | |
| m, n = pair | |
| if m.distance < self.cfg.orb_ratio_test * n.distance: | |
| passed[m.queryIdx] = (m.trainIdx, float(m.distance)) | |
| return passed | |
| forward = ratio_filter(knn_a) | |
| backward = ratio_filter(knn_b) | |
| matches = [] | |
| for q_idx, (t_idx, dist) in forward.items(): | |
| rev = backward.get(t_idx) | |
| if rev is not None and rev[0] == q_idx: | |
| matches.append((q_idx, t_idx, dist)) | |
| if len(matches) < self.cfg.rigid_min_inliers: | |
| return ( | |
| np.empty((0, 3), dtype=np.float64), | |
| np.empty((0, 3), dtype=np.float64), | |
| ) | |
| kps_src = self.keypoints[src_frame] | |
| kps_dst = self.keypoints[dst_frame] | |
| uv_src = np.asarray([kps_src[q].pt for q, _, _ in matches], dtype=np.float32) | |
| uv_dst = np.asarray([kps_dst[t].pt for _, t, _ in matches], dtype=np.float32) | |
| return depth_correspondences_to_3d( | |
| uv_src, | |
| uv_dst, | |
| cache.depth(src_frame), | |
| cache.depth(dst_frame), | |
| artifacts.intrinsics[src_frame], | |
| artifacts.intrinsics[dst_frame], | |
| ) | |
| class SaladRetriever: | |
| def __init__(self, cfg: LoopConfig, device: str): | |
| self.cfg = cfg | |
| self.device = torch.device(device) | |
| if not os.path.exists(cfg.salad_ckpt_path): | |
| raise FileNotFoundError( | |
| f"SALAD checkpoint not found: {cfg.salad_ckpt_path}. " | |
| "Download it from https://github.com/serizba/salad/releases/download/v1.0.0/dino_salad.ckpt" | |
| ) | |
| self.model = SaladVPRModel( | |
| backbone_name=cfg.salad_backbone, | |
| dino_weights_path=cfg.salad_dino_weights_path, | |
| ) | |
| missing = _load_salad_state_dict(self.model, cfg.salad_ckpt_path) | |
| if any(key.startswith("backbone.") for key in missing) and not os.path.exists( | |
| cfg.salad_dino_weights_path | |
| ): | |
| raise FileNotFoundError( | |
| f"DINOv2 backbone weights not found: {cfg.salad_dino_weights_path}. " | |
| "Download it from https://dl.fbaipublicfiles.com/dinov2/dinov2_vitb14/dinov2_vitb14_pretrain.pth" | |
| ) | |
| self.model = self.model.to(self.device).eval() | |
| self.global_desc: Optional[np.ndarray] = None | |
| self.local_desc: Dict[int, np.ndarray] = {} | |
| self.patch_hw: Optional[Tuple[int, int]] = None | |
| self.patch_size = 14 | |
| def fit(self, cache: SequenceCache, keyframe_ids: Sequence[int]) -> np.ndarray: | |
| batch_size = max(1, int(self.cfg.salad_batch_size)) | |
| image_size = tuple(int(x) for x in self.cfg.salad_image_size) | |
| globals_out = [] | |
| use_amp = self.device.type == "cuda" | |
| with torch.no_grad(): | |
| for batch_idx, start in enumerate(range(0, len(keyframe_ids), batch_size), start=1): | |
| batch_ids = list(keyframe_ids[start : start + batch_size]) | |
| batch = np.stack([cache.rgb(i) for i in batch_ids], axis=0) | |
| resized = _resize_rgb_batch(batch, image_size) | |
| salad_images = _batch_to_imagenet_tensor(resized, self.device) | |
| local_images = _batch_to_imagenet_tensor(batch, self.device) | |
| with torch.autocast(device_type=self.device.type, dtype=torch.float16, enabled=use_amp): | |
| desc = self.model(salad_images) | |
| _, patch_tokens = self.model.forward_features(local_images) | |
| globals_out.append(desc.float().cpu().numpy().astype(np.float32)) | |
| patch_tokens_np = patch_tokens.float().cpu().numpy().astype(np.float32) | |
| for frame_idx, patch_desc in zip(batch_ids, patch_tokens_np): | |
| self.local_desc[frame_idx] = patch_desc | |
| if self.patch_hw is None: | |
| h, w = batch.shape[1:3] | |
| self.patch_hw = (h // self.patch_size, w // self.patch_size) | |
| self.global_desc = np.concatenate(globals_out, axis=0) | |
| self.global_desc = _normalize_rows(self.global_desc.astype(np.float32)) | |
| _log( | |
| f"SALAD: built descriptor bank with shape {tuple(self.global_desc.shape)}", | |
| self.cfg.verbose, | |
| ) | |
| return self.global_desc | |
| def collect_3d_correspondences( | |
| self, | |
| cache: SequenceCache, | |
| artifacts: SequenceArtifacts, | |
| src_frame: int, | |
| dst_frame: int, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| desc_src = self.local_desc.get(src_frame) | |
| desc_dst = self.local_desc.get(dst_frame) | |
| if desc_src is None or desc_dst is None: | |
| return ( | |
| np.empty((0, 3), dtype=np.float64), | |
| np.empty((0, 3), dtype=np.float64), | |
| ) | |
| sim = desc_src @ desc_dst.T | |
| dst_best = sim.argmax(axis=1) | |
| dst_score = sim[np.arange(sim.shape[0]), dst_best] | |
| src_best = sim.argmax(axis=0) | |
| matches = [] | |
| for src_idx, dst_idx in enumerate(dst_best.tolist()): | |
| if src_best[dst_idx] != src_idx: | |
| continue | |
| score = float(dst_score[src_idx]) | |
| if score < self.cfg.patch_match_thresh: | |
| continue | |
| matches.append((src_idx, dst_idx, score)) | |
| if len(matches) < self.cfg.rigid_min_inliers: | |
| return ( | |
| np.empty((0, 3), dtype=np.float64), | |
| np.empty((0, 3), dtype=np.float64), | |
| ) | |
| matches.sort(key=lambda item: item[2], reverse=True) | |
| matches = matches[: self.cfg.max_pair_matches] | |
| uv_src = np.asarray( | |
| [patch_index_to_uv(m[0], self.patch_hw, self.patch_size) for m in matches], | |
| dtype=np.float32, | |
| ) | |
| uv_dst = np.asarray( | |
| [patch_index_to_uv(m[1], self.patch_hw, self.patch_size) for m in matches], | |
| dtype=np.float32, | |
| ) | |
| return depth_correspondences_to_3d( | |
| uv_src, | |
| uv_dst, | |
| cache.depth(src_frame), | |
| cache.depth(dst_frame), | |
| artifacts.intrinsics[src_frame], | |
| artifacts.intrinsics[dst_frame], | |
| ) | |
| def patch_index_to_uv(index: int, patch_hw: Tuple[int, int], patch_size: int) -> Tuple[float, float]: | |
| h_patch, w_patch = patch_hw | |
| y = index // w_patch | |
| x = index % w_patch | |
| cx = (x + 0.5) * patch_size | |
| cy = (y + 0.5) * patch_size | |
| return float(cx), float(cy) | |
| def sample_depth_at_uv(depth: np.ndarray, uv: np.ndarray) -> np.ndarray: | |
| h, w = depth.shape[:2] | |
| x = np.clip(np.round(uv[:, 0]).astype(np.int32), 0, w - 1) | |
| y = np.clip(np.round(uv[:, 1]).astype(np.int32), 0, h - 1) | |
| return depth[y, x] | |
| def unproject_points(uv: np.ndarray, depth: np.ndarray, intrinsics: np.ndarray) -> np.ndarray: | |
| z = depth.astype(np.float64) | |
| x = (uv[:, 0] - intrinsics[0, 2]) / intrinsics[0, 0] * z | |
| y = (uv[:, 1] - intrinsics[1, 2]) / intrinsics[1, 1] * z | |
| return np.stack([x, y, z], axis=1) | |
| def depth_correspondences_to_3d( | |
| uv_src: np.ndarray, | |
| uv_dst: np.ndarray, | |
| depth_src: np.ndarray, | |
| depth_dst: np.ndarray, | |
| intr_src: np.ndarray, | |
| intr_dst: np.ndarray, | |
| ) -> Tuple[np.ndarray, np.ndarray]: | |
| z_src = sample_depth_at_uv(depth_src, uv_src) | |
| z_dst = sample_depth_at_uv(depth_dst, uv_dst) | |
| valid = np.isfinite(z_src) & np.isfinite(z_dst) & (z_src > 1e-4) & (z_dst > 1e-4) | |
| if not np.any(valid): | |
| return np.empty((0, 3), dtype=np.float64), np.empty((0, 3), dtype=np.float64) | |
| pts_src = unproject_points(uv_src[valid], z_src[valid], intr_src) | |
| pts_dst = unproject_points(uv_dst[valid], z_dst[valid], intr_dst) | |
| return pts_src, pts_dst | |
| def estimate_similarity_umeyama( | |
| src_pts: np.ndarray, | |
| dst_pts: np.ndarray, | |
| allow_scale: bool = True, | |
| weights: Optional[np.ndarray] = None, | |
| ) -> Tuple[float, np.ndarray, np.ndarray]: | |
| src = np.asarray(src_pts, dtype=np.float64) | |
| dst = np.asarray(dst_pts, dtype=np.float64) | |
| if src.shape != dst.shape or src.ndim != 2 or src.shape[1] != 3: | |
| raise ValueError("Expected src_pts and dst_pts with shape [N, 3]") | |
| if len(src) < 3: | |
| raise ValueError("At least 3 points are required") | |
| if weights is None: | |
| weights = np.ones(len(src), dtype=np.float64) | |
| else: | |
| weights = np.asarray(weights, dtype=np.float64).reshape(-1) | |
| if weights.shape[0] != src.shape[0]: | |
| raise ValueError("weights must have shape [N]") | |
| weights = np.clip(weights, 1e-12, None) | |
| weights /= weights.sum() | |
| src_mean = np.sum(src * weights[:, None], axis=0) | |
| dst_mean = np.sum(dst * weights[:, None], axis=0) | |
| src_centered = src - src_mean | |
| dst_centered = dst - dst_mean | |
| cov = (weights[:, None] * dst_centered).T @ src_centered | |
| u, singular_vals, vt = np.linalg.svd(cov) | |
| s_mat = np.eye(3, dtype=np.float64) | |
| if np.linalg.det(u) * np.linalg.det(vt) < 0: | |
| s_mat[-1, -1] = -1.0 | |
| rot = u @ s_mat @ vt | |
| if allow_scale: | |
| src_var = float(np.sum(weights * np.sum(src_centered * src_centered, axis=1))) | |
| if src_var < 1e-12: | |
| raise ValueError("Degenerate source variance for Sim3 estimation") | |
| scale = float(np.sum(singular_vals * np.diag(s_mat)) / src_var) | |
| else: | |
| scale = 1.0 | |
| trans = dst_mean - scale * (rot @ src_mean) | |
| return scale, rot.astype(np.float64, copy=False), trans.astype(np.float64, copy=False) | |
| def apply_similarity_to_points( | |
| pts: np.ndarray, | |
| scale: float, | |
| rot: np.ndarray, | |
| trans: np.ndarray, | |
| ) -> np.ndarray: | |
| return scale * (np.asarray(rot, dtype=np.float64) @ np.asarray(pts, dtype=np.float64).T).T + np.asarray( | |
| trans, dtype=np.float64 | |
| ) | |
| def robust_refine_similarity_transform( | |
| src_pts: np.ndarray, | |
| dst_pts: np.ndarray, | |
| cfg: LoopConfig, | |
| ) -> np.ndarray: | |
| allow_scale = cfg.pose_graph_model == "sim3" | |
| weights = np.ones(len(src_pts), dtype=np.float64) | |
| scale, rot, trans = estimate_similarity_umeyama( | |
| src_pts, dst_pts, allow_scale=allow_scale, weights=weights | |
| ) | |
| prev_cost = float("inf") | |
| delta = float(cfg.sim3_irls_delta) | |
| for _ in range(max(1, int(cfg.sim3_irls_max_iters))): | |
| pred = apply_similarity_to_points(src_pts, scale, rot, trans) | |
| resid = np.linalg.norm(pred - dst_pts, axis=1) | |
| huber = np.ones_like(resid) | |
| large = resid > delta | |
| huber[large] = delta / np.clip(resid[large], 1e-12, None) | |
| huber = np.clip(huber, 1e-6, None) | |
| scale_new, rot_new, trans_new = estimate_similarity_umeyama( | |
| src_pts, dst_pts, allow_scale=allow_scale, weights=huber | |
| ) | |
| current_cost = float( | |
| np.sum( | |
| np.where( | |
| resid <= delta, | |
| 0.5 * resid * resid, | |
| delta * (resid - 0.5 * delta), | |
| ) | |
| ) | |
| ) | |
| param_change = abs(scale_new - scale) + np.linalg.norm(trans_new - trans) | |
| rot_angle = Rotation.from_matrix(rot_new @ rot.T).magnitude() | |
| scale, rot, trans = scale_new, rot_new, trans_new | |
| if ( | |
| param_change < float(cfg.sim3_irls_tol) | |
| and rot_angle < np.deg2rad(0.1) | |
| ) or abs(prev_cost - current_cost) < float(cfg.sim3_irls_tol) * max(prev_cost, 1.0): | |
| break | |
| prev_cost = current_cost | |
| return similarity_matrix(scale, rot, trans) | |
| def estimate_similarity_transform_ransac( | |
| src_pts: np.ndarray, | |
| dst_pts: np.ndarray, | |
| cfg: LoopConfig, | |
| ) -> Optional[Tuple[np.ndarray, int]]: | |
| if len(src_pts) < cfg.rigid_min_inliers: | |
| return None | |
| allow_scale = cfg.pose_graph_model == "sim3" | |
| rng = np.random.default_rng(0) | |
| best_inliers: Optional[np.ndarray] = None | |
| best_count = 0 | |
| sample_size = 3 | |
| for _ in range(cfg.rigid_ransac_iters): | |
| sample = rng.choice(len(src_pts), size=sample_size, replace=False) | |
| try: | |
| scale, rot, trans = estimate_similarity_umeyama( | |
| src_pts[sample], dst_pts[sample], allow_scale=allow_scale | |
| ) | |
| except ValueError: | |
| continue | |
| pred = apply_similarity_to_points(src_pts, scale, rot, trans) | |
| err = np.linalg.norm(pred - dst_pts, axis=1) | |
| inliers = err < cfg.rigid_inlier_thresh | |
| count = int(inliers.sum()) | |
| if count > best_count: | |
| best_inliers = inliers | |
| best_count = count | |
| if best_inliers is None or best_count < cfg.rigid_min_inliers: | |
| return None | |
| refined = robust_refine_similarity_transform(src_pts[best_inliers], dst_pts[best_inliers], cfg) | |
| return refined, best_count | |
| def retrieve_dbow_candidates( | |
| sim: np.ndarray, | |
| keyframe_ids: Sequence[int], | |
| cfg: LoopConfig, | |
| ) -> List[LoopCandidate]: | |
| candidates: List[LoopCandidate] = [] | |
| found: List[Tuple[int, int, float]] = [] | |
| accepted_pairs: List[Tuple[int, int]] = [] | |
| repeat = max(1, int(cfg.dbow_num_repeat)) | |
| temporal_exclusion = max(1, int(cfg.dbow_temporal_exclusion)) | |
| nms = max(0, int(cfg.nms_radius)) | |
| for src_pos in range(len(keyframe_ids)): | |
| max_dst = src_pos - temporal_exclusion | |
| if max_dst < 0: | |
| continue | |
| prev_scores = sim[src_pos, : max_dst + 1] | |
| dst_pos = int(np.argmax(prev_scores)) | |
| score = float(prev_scores[dst_pos]) | |
| if score < cfg.retrieval_score_thresh_dbow: | |
| continue | |
| if accepted_pairs and nms > 0: | |
| dists_sq = [ | |
| (src_pos - prev_src) ** 2 + (dst_pos - prev_dst) ** 2 | |
| for prev_src, prev_dst in accepted_pairs | |
| ] | |
| if min(dists_sq, default=float("inf")) < nms * nms: | |
| continue | |
| found.append((src_pos, dst_pos, score)) | |
| if len(found) < repeat: | |
| continue | |
| latest = found[-repeat:] | |
| first_src = latest[0][0] | |
| if (1 + src_pos - first_src) != repeat: | |
| continue | |
| accepted_src, accepted_dst, accepted_score = latest[repeat // 2] | |
| accepted_pairs.append((accepted_src, accepted_dst)) | |
| candidates.append( | |
| LoopCandidate( | |
| src_pos=int(accepted_src), | |
| dst_pos=int(max(accepted_dst, 1)), | |
| src_frame=int(keyframe_ids[accepted_src]), | |
| dst_frame=int(keyframe_ids[max(accepted_dst, 1)]), | |
| score=float(accepted_score), | |
| method="dbow", | |
| ) | |
| ) | |
| found.clear() | |
| candidates.sort(key=lambda item: item.score, reverse=True) | |
| return candidates[: cfg.max_candidates_per_method] | |
| def non_max_suppress_loop_candidates( | |
| candidates: Sequence[LoopCandidate], | |
| nms_threshold: int, | |
| limit: int, | |
| ) -> List[LoopCandidate]: | |
| if not candidates or nms_threshold <= 0: | |
| return list(candidates)[:limit] | |
| sorted_candidates = sorted(candidates, key=lambda item: item.score, reverse=True) | |
| max_pos = max(max(item.src_pos, item.dst_pos) for item in sorted_candidates) | |
| filtered: List[LoopCandidate] = [] | |
| suppressed = set() | |
| for cand in sorted_candidates: | |
| low = min(cand.src_pos, cand.dst_pos) | |
| high = max(cand.src_pos, cand.dst_pos) | |
| if low in suppressed or high in suppressed: | |
| continue | |
| filtered.append(cand) | |
| start1 = max(0, low - nms_threshold) | |
| end1 = min(low + nms_threshold + 1, high) | |
| suppressed.update(range(start1, end1)) | |
| start2 = max(low + 1, high - nms_threshold) | |
| end2 = min(high + nms_threshold + 1, max_pos + 1) | |
| suppressed.update(range(start2, end2)) | |
| if len(filtered) >= limit: | |
| break | |
| return filtered | |
| def retrieve_vpr_candidates( | |
| descriptors: np.ndarray, | |
| keyframe_ids: Sequence[int], | |
| method: str, | |
| score_thresh: float, | |
| cfg: LoopConfig, | |
| ) -> List[LoopCandidate]: | |
| desc = _normalize_rows(descriptors.astype(np.float32)) | |
| sim = desc @ desc.T | |
| top_k = max(1, int(cfg.retrieval_top_k)) | |
| min_gap = max(1, int(cfg.min_keyframe_gap)) | |
| candidates_by_key: Dict[Tuple[int, int], LoopCandidate] = {} | |
| for src_pos in range(len(keyframe_ids)): | |
| order = np.argsort(sim[src_pos])[::-1] | |
| keep = 0 | |
| for dst_pos in order.tolist(): | |
| if dst_pos == src_pos: | |
| continue | |
| if keep >= top_k: | |
| break | |
| score = float(sim[src_pos, dst_pos]) | |
| if score <= score_thresh: | |
| break | |
| keep += 1 | |
| if abs(int(keyframe_ids[src_pos]) - int(keyframe_ids[dst_pos])) <= min_gap: | |
| continue | |
| high = max(src_pos, dst_pos) | |
| low = min(src_pos, dst_pos) | |
| key = (high, low) | |
| prev = candidates_by_key.get(key) | |
| if prev is not None and prev.score >= score: | |
| continue | |
| candidates_by_key[key] = LoopCandidate( | |
| src_pos=int(high), | |
| dst_pos=int(low), | |
| src_frame=int(keyframe_ids[high]), | |
| dst_frame=int(keyframe_ids[low]), | |
| score=score, | |
| method=method, | |
| ) | |
| return non_max_suppress_loop_candidates( | |
| list(candidates_by_key.values()), | |
| nms_threshold=max(0, int(cfg.nms_radius)), | |
| limit=max(1, int(cfg.max_candidates_per_method)), | |
| ) | |
| def centered_window_positions(center: int, size: int, total: int) -> np.ndarray: | |
| size = max(1, int(size)) | |
| total = max(0, int(total)) | |
| if total <= 0: | |
| return np.empty((0,), dtype=np.int64) | |
| if size >= total: | |
| return np.arange(total, dtype=np.int64) | |
| half = size // 2 | |
| start = center - half | |
| end = start + size | |
| if start < 0: | |
| start = 0 | |
| end = size | |
| if end > total: | |
| end = total | |
| start = total - size | |
| return np.arange(start, end, dtype=np.int64) | |
| def candidate_chunk_frame_pairs( | |
| candidate: LoopCandidate, | |
| keyframe_ids: Sequence[int], | |
| chunk_size: int, | |
| ) -> List[Tuple[int, int]]: | |
| src_pos = int(candidate.src_pos) | |
| dst_pos = int(candidate.dst_pos) | |
| src_window = centered_window_positions(src_pos, chunk_size, len(keyframe_ids)) | |
| dst_window = centered_window_positions(dst_pos, chunk_size, len(keyframe_ids)) | |
| span = min(len(src_window), len(dst_window)) | |
| if span <= 0: | |
| return [] | |
| return [ | |
| (int(keyframe_ids[src_window[idx]]), int(keyframe_ids[dst_window[idx]])) | |
| for idx in range(span) | |
| ] | |
| def verify_candidates( | |
| candidates: Sequence[LoopCandidate], | |
| verifier, | |
| cache: SequenceCache, | |
| artifacts: SequenceArtifacts, | |
| keyframe_ids: Sequence[int], | |
| cfg: LoopConfig, | |
| ) -> List[LoopEdge]: | |
| method = candidates[0].method if candidates else "unknown" | |
| _log( | |
| f"{method}: verifying {len(candidates)} loop candidates with geometry", | |
| cfg.verbose, | |
| ) | |
| verified: List[LoopEdge] = [] | |
| total = len(candidates) | |
| for idx, cand in enumerate(candidates, start=1): | |
| if abs(int(cand.src_pos) - int(cand.dst_pos)) < int(cfg.loop_edge_min_separation): | |
| _log_every( | |
| f"{method} verify", | |
| idx, | |
| total, | |
| cfg.verbose, | |
| step=5, | |
| ) | |
| continue | |
| frame_pairs = candidate_chunk_frame_pairs(cand, keyframe_ids, cfg.loop_chunk_size) | |
| pts_src_all = [] | |
| pts_dst_all = [] | |
| for src_frame, dst_frame in frame_pairs: | |
| pts_src, pts_dst = verifier.collect_3d_correspondences( | |
| cache, artifacts, src_frame, dst_frame | |
| ) | |
| if len(pts_src) == 0: | |
| continue | |
| pts_src_all.append(pts_src) | |
| pts_dst_all.append(pts_dst) | |
| if not pts_src_all: | |
| _log_every( | |
| f"{method} verify", | |
| idx, | |
| total, | |
| cfg.verbose, | |
| step=5, | |
| ) | |
| continue | |
| pts_src = np.concatenate(pts_src_all, axis=0) | |
| pts_dst = np.concatenate(pts_dst_all, axis=0) | |
| result = estimate_similarity_transform_ransac(pts_src, pts_dst, cfg) | |
| if result is None: | |
| _log_every( | |
| f"{method} verify", | |
| idx, | |
| total, | |
| cfg.verbose, | |
| step=5, | |
| ) | |
| continue | |
| transform_ji, inliers = result | |
| verified.append( | |
| LoopEdge( | |
| src_pos=cand.src_pos, | |
| dst_pos=cand.dst_pos, | |
| src_frame=cand.src_frame, | |
| dst_frame=cand.dst_frame, | |
| score=cand.score, | |
| inliers=inliers, | |
| method=cand.method, | |
| transform_ji=transform_ji, | |
| ) | |
| ) | |
| _log( | |
| f"{method}: accepted loop frame {cand.src_frame} -> {cand.dst_frame} " | |
| f"(score={cand.score:.3f}, inliers={inliers}, chunk={max(1, cfg.loop_chunk_size)})", | |
| cfg.verbose, | |
| ) | |
| _log_every( | |
| f"{method} verify", | |
| idx, | |
| total, | |
| cfg.verbose, | |
| step=5, | |
| ) | |
| verified.sort(key=lambda edge: (edge.inliers, edge.score), reverse=True) | |
| verified = verified[: cfg.max_verified_loops_per_method] | |
| _log( | |
| f"{method}: kept {len(verified)} verified loops after ranking", | |
| cfg.verbose, | |
| ) | |
| return verified | |
| def build_keyframe_odometry_edges(keyframe_c2w: np.ndarray) -> List[LoopEdge]: | |
| edges = [] | |
| for idx in range(1, len(keyframe_c2w)): | |
| meas = relative_measurement_from_c2w( | |
| keyframe_c2w[idx - 1], keyframe_c2w[idx] | |
| ) | |
| edges.append( | |
| LoopEdge( | |
| src_pos=idx - 1, | |
| dst_pos=idx, | |
| src_frame=idx - 1, | |
| dst_frame=idx, | |
| score=1.0, | |
| inliers=0, | |
| method="odometry", | |
| transform_ji=meas, | |
| ) | |
| ) | |
| return edges | |
| def optimize_keyframe_pose_graph( | |
| keyframe_c2w_init: np.ndarray, | |
| odom_edges: Sequence[LoopEdge], | |
| loop_edges: Sequence[LoopEdge], | |
| cfg: LoopConfig, | |
| ) -> np.ndarray: | |
| if cfg.pose_graph_backend == "pypose": | |
| all_edges = list(odom_edges) + list(loop_edges) | |
| edge_weights = [] | |
| for edge in all_edges: | |
| if edge.method == "odometry": | |
| edge_weights.append(1.0) | |
| else: | |
| edge_weights.append( | |
| cfg.pose_graph_loop_weight | |
| * math.sqrt(max(edge.inliers, 1) / max(cfg.rigid_min_inliers, 1)) | |
| ) | |
| return optimize_pose_graph_pypose( | |
| keyframe_c2w_init=keyframe_c2w_init, | |
| edge_src=[edge.src_pos for edge in all_edges], | |
| edge_dst=[edge.dst_pos for edge in all_edges], | |
| edge_measurements=[edge.transform_ji for edge in all_edges], | |
| edge_weights=edge_weights, | |
| model=cfg.pose_graph_model, | |
| update_mode=cfg.pose_graph_update_mode, | |
| trans_weight=cfg.pose_graph_trans_weight, | |
| rot_weight=cfg.pose_graph_rot_weight, | |
| scale_weight=cfg.pose_graph_scale_weight, | |
| max_iterations=cfg.pose_graph_max_iterations, | |
| lambda_init=cfg.pose_graph_lambda_init, | |
| verbose=cfg.pose_graph_solver_verbose, | |
| log_fn=lambda msg: _log(msg, cfg.verbose), | |
| ) | |
| if cfg.pose_graph_backend != "scipy": | |
| raise ValueError(f"Unsupported pose_graph_backend: {cfg.pose_graph_backend}") | |
| if cfg.pose_graph_model != "se3": | |
| raise ValueError("SciPy pose graph backend only supports pose_graph_model='se3'") | |
| if len(keyframe_c2w_init) <= 1: | |
| return keyframe_c2w_init.copy() | |
| num_nodes = len(keyframe_c2w_init) | |
| num_vars = (num_nodes - 1) * 6 | |
| num_edges = len(odom_edges) + len(loop_edges) | |
| num_residuals = num_edges * 6 | |
| _log( | |
| f"Pose graph: optimizing {num_nodes} keyframes with " | |
| f"{len(odom_edges)} odom edges and {len(loop_edges)} loop edges " | |
| f"({num_vars} vars, {num_residuals} residuals)", | |
| cfg.verbose, | |
| ) | |
| x0 = np.concatenate( | |
| [pose_matrix_to_vec(pose) for pose in keyframe_c2w_init[1:]], axis=0 | |
| ) | |
| def unpack_poses(x: np.ndarray) -> List[np.ndarray]: | |
| poses = [keyframe_c2w_init[0]] | |
| for idx in range(0, len(x), 6): | |
| poses.append(pose_vec_to_matrix(x[idx : idx + 6])) | |
| return poses | |
| def edge_residual(edge: LoopEdge, poses: Sequence[np.ndarray], loop_scale: float) -> np.ndarray: | |
| pred = np.linalg.inv(poses[edge.dst_pos]) @ poses[edge.src_pos] | |
| rot_err = Rotation.from_matrix( | |
| edge.transform_ji[:3, :3].T @ pred[:3, :3] | |
| ).as_rotvec() | |
| trans_err = pred[:3, 3] - edge.transform_ji[:3, 3] | |
| if edge.method == "odometry": | |
| w = 1.0 | |
| else: | |
| w = loop_scale * math.sqrt(max(edge.inliers, 1) / max(cfg.rigid_min_inliers, 1)) | |
| return np.concatenate( | |
| [ | |
| rot_err * cfg.pose_graph_rot_weight * w, | |
| trans_err * cfg.pose_graph_trans_weight * w, | |
| ], | |
| axis=0, | |
| ) | |
| def residual_fn(x: np.ndarray) -> np.ndarray: | |
| poses = unpack_poses(x) | |
| residuals = [] | |
| for edge in odom_edges: | |
| residuals.append(edge_residual(edge, poses, loop_scale=1.0)) | |
| for edge in loop_edges: | |
| residuals.append(edge_residual(edge, poses, cfg.pose_graph_loop_weight)) | |
| return np.concatenate(residuals, axis=0) | |
| def build_jac_sparsity() -> lil_matrix: | |
| sparsity = lil_matrix((num_residuals, num_vars), dtype=np.int8) | |
| row = 0 | |
| for edge in list(odom_edges) + list(loop_edges): | |
| for node_idx in (edge.src_pos, edge.dst_pos): | |
| if node_idx <= 0: | |
| continue | |
| col0 = (node_idx - 1) * 6 | |
| sparsity[row : row + 6, col0 : col0 + 6] = 1 | |
| row += 6 | |
| return sparsity | |
| jac_sparsity = build_jac_sparsity() | |
| _log( | |
| "Pose graph: built sparse Jacobian pattern for finite differences", | |
| cfg.verbose, | |
| ) | |
| result = least_squares( | |
| residual_fn, | |
| x0, | |
| loss="huber", | |
| f_scale=1.0, | |
| max_nfev=cfg.pose_graph_max_nfev, | |
| jac_sparsity=jac_sparsity, | |
| x_scale="jac", | |
| verbose=2 if cfg.pose_graph_solver_verbose else 0, | |
| ) | |
| poses = unpack_poses(result.x) | |
| _log( | |
| f"Pose graph: done status={result.status} cost={float(result.cost):.4f} " | |
| f"nfev={int(result.nfev)}", | |
| cfg.verbose, | |
| ) | |
| return np.asarray(poses, dtype=np.float64) | |
| def propagate_keyframe_corrections( | |
| c2w_base: np.ndarray, | |
| keyframe_ids: Sequence[int], | |
| frame_ref_keyframe_pos: np.ndarray, | |
| keyframe_c2w_opt: np.ndarray, | |
| ) -> np.ndarray: | |
| corrected = c2w_base.copy() | |
| keyframe_base = c2w_base[np.asarray(keyframe_ids)] | |
| correction = np.asarray( | |
| [ | |
| keyframe_c2w_opt[idx] @ np.linalg.inv(keyframe_base[idx]) | |
| for idx in range(len(keyframe_ids)) | |
| ], | |
| dtype=np.float64, | |
| ) | |
| for frame_idx in range(len(c2w_base)): | |
| ref_pos = int(frame_ref_keyframe_pos[frame_idx]) | |
| corrected[frame_idx] = normalize_pose_matrix(correction[ref_pos] @ c2w_base[frame_idx]) | |
| return corrected | |
| def frame_to_keyframe_position( | |
| keyframe_mask: np.ndarray, keyframe_indices: np.ndarray | |
| ) -> np.ndarray: | |
| key_positions = np.cumsum(keyframe_mask.astype(np.int64)) - 1 | |
| out = np.zeros_like(keyframe_indices, dtype=np.int64) | |
| for idx in range(len(keyframe_indices)): | |
| if keyframe_mask[idx]: | |
| out[idx] = key_positions[idx] | |
| else: | |
| ref_frame = int(keyframe_indices[idx]) | |
| out[idx] = key_positions[ref_frame] | |
| return out | |
| def save_w2c_txt(path: str, w2c: np.ndarray, frame_ids: Sequence[int]) -> None: | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| with open(path, "w") as f: | |
| f.write("# w2c\n") | |
| for idx, frame_id in enumerate(frame_ids): | |
| row = [frame_id] + w2c[idx, :3, :3].reshape(-1).tolist() + w2c[idx, :3, 3].tolist() | |
| f.write(" ".join(map(str, row)) + "\n") | |
| def save_loop_edges_json(path: str, edges: Sequence[LoopEdge]) -> None: | |
| os.makedirs(os.path.dirname(path), exist_ok=True) | |
| payload = [] | |
| for edge in edges: | |
| item = asdict(edge) | |
| item["transform_ji"] = edge.transform_ji.tolist() | |
| payload.append(item) | |
| with open(path, "w") as f: | |
| json.dump(payload, f, indent=2) | |
| def _xy_from_xyz(xyz: np.ndarray) -> np.ndarray: | |
| xyz = np.asarray(xyz, dtype=np.float64) | |
| return xyz[:, [0, 2]] | |
| def plot_trajectory_panels( | |
| output_path: str, | |
| baseline_c2w: np.ndarray, | |
| method_results: Dict[str, np.ndarray], | |
| loop_edges: Dict[str, Sequence[LoopEdge]], | |
| frame_ids: Sequence[int], | |
| gt_metrics: Optional[Dict[str, Dict[str, object]]] = None, | |
| selected_loop_weights: Optional[Dict[str, float]] = None, | |
| ) -> None: | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| extra_names = [name for name in method_results.keys() if name != "no_loop"] | |
| names = ["no_loop", *extra_names] | |
| titles = { | |
| "no_loop": "No Loop", | |
| "dbow": "ORB-BoW", | |
| "salad": "SALAD", | |
| } | |
| fig, axes = plt.subplots(1, len(names), figsize=(6 * len(names), 6)) | |
| if len(names) == 1: | |
| axes = [axes] | |
| base_metrics = (gt_metrics or {}).get("no_loop") | |
| if base_metrics is not None: | |
| base_xy = _xy_from_xyz(np.asarray(base_metrics["pred_xyz_aligned"], dtype=np.float64)) | |
| else: | |
| base_xy = _top_down_xy(baseline_c2w) | |
| for ax, name in zip(axes, names): | |
| method_metric = (gt_metrics or {}).get(name) | |
| if method_metric is not None: | |
| xy = _xy_from_xyz(np.asarray(method_metric["pred_xyz_aligned"], dtype=np.float64)) | |
| gt_xy = _xy_from_xyz(np.asarray(method_metric["gt_xyz"], dtype=np.float64)) | |
| frame_id_to_xy = { | |
| int(frame_id): xy[idx] | |
| for idx, frame_id in enumerate(method_metric["frame_ids"]) | |
| } | |
| ax.plot(gt_xy[:, 0], gt_xy[:, 1], color="#2ca02c", linewidth=2.0, label="gt") | |
| if name != "no_loop": | |
| ax.plot( | |
| base_xy[:, 0], | |
| base_xy[:, 1], | |
| color="#808080", | |
| linewidth=1.6, | |
| linestyle="--", | |
| label="no_loop", | |
| ) | |
| else: | |
| traj = method_results.get(name, baseline_c2w) | |
| xy = _top_down_xy(traj) | |
| gt_xy = None | |
| frame_id_to_xy = { | |
| int(frame_id): xy[idx] for idx, frame_id in enumerate(frame_ids) | |
| } | |
| if name != "no_loop": | |
| ax.plot( | |
| base_xy[:, 0], | |
| base_xy[:, 1], | |
| color="#808080", | |
| linewidth=1.6, | |
| linestyle="--", | |
| label="no_loop", | |
| ) | |
| ax.plot(xy[:, 0], xy[:, 1], color="#1f77b4", linewidth=2.0, label=name) | |
| if name != "no_loop": | |
| for edge in loop_edges.get(name, []): | |
| a = frame_id_to_xy.get(int(edge.src_frame)) | |
| b = frame_id_to_xy.get(int(edge.dst_frame)) | |
| if a is None or b is None: | |
| continue | |
| ax.plot( | |
| [a[0], b[0]], | |
| [a[1], b[1]], | |
| color="#d62728", | |
| alpha=0.35, | |
| linewidth=1.0, | |
| ) | |
| ax.set_aspect("equal") | |
| title = titles.get(name, name) | |
| if method_metric is not None: | |
| title += f"\nATE {float(method_metric['ate_rmse']):.2f} m" | |
| if name != "no_loop" and selected_loop_weights is not None and name in selected_loop_weights: | |
| title += f", w={float(selected_loop_weights[name]):.2f}" | |
| ax.set_title(title) | |
| ax.set_xlabel("x") | |
| ax.set_ylabel("z") | |
| ax.grid(True, alpha=0.3) | |
| ax.legend(loc="best", fontsize=8) | |
| plt.tight_layout() | |
| plt.savefig(output_path, dpi=220, bbox_inches="tight") | |
| plt.close(fig) | |
| def plot_overlay( | |
| output_path: str, | |
| baseline_c2w: np.ndarray, | |
| method_results: Dict[str, np.ndarray], | |
| gt_metrics: Optional[Dict[str, Dict[str, object]]] = None, | |
| ) -> None: | |
| os.makedirs(os.path.dirname(output_path), exist_ok=True) | |
| plt.figure(figsize=(7, 7)) | |
| if gt_metrics and gt_metrics.get("no_loop") is not None: | |
| gt_xy = _xy_from_xyz( | |
| np.asarray(gt_metrics["no_loop"]["gt_xyz"], dtype=np.float64) | |
| ) | |
| plt.plot(gt_xy[:, 0], gt_xy[:, 1], label="gt", linewidth=2.0, color="#2ca02c") | |
| color_map = { | |
| "no_loop": "#808080", | |
| "dbow": "#1f77b4", | |
| "salad": "#9467bd", | |
| } | |
| for name in ["no_loop", *[key for key in method_results.keys() if key != "no_loop"]]: | |
| metric = gt_metrics.get(name) | |
| if metric is None: | |
| continue | |
| xy = _xy_from_xyz(np.asarray(metric["pred_xyz_aligned"], dtype=np.float64)) | |
| plt.plot( | |
| xy[:, 0], | |
| xy[:, 1], | |
| label=name, | |
| linewidth=2.0, | |
| color=color_map.get(name, None), | |
| ) | |
| else: | |
| entries = [(baseline_c2w, "no_loop", "#808080")] | |
| if "dbow" in method_results: | |
| entries.append((method_results["dbow"], "orb_bow", "#1f77b4")) | |
| if "salad" in method_results: | |
| entries.append((method_results["salad"], "salad", "#9467bd")) | |
| for c2w, label, color in entries: | |
| xy = _top_down_xy(c2w) | |
| plt.plot(xy[:, 0], xy[:, 1], label=label, linewidth=2.0, color=color) | |
| plt.gca().set_aspect("equal") | |
| plt.xlabel("x") | |
| plt.ylabel("z") | |
| plt.title("Offline Loop Comparison") | |
| plt.grid(True, alpha=0.3) | |
| plt.legend() | |
| plt.tight_layout() | |
| plt.savefig(output_path, dpi=220, bbox_inches="tight") | |
| plt.close() | |
| def summarize_loop_edges( | |
| method: str, | |
| num_candidates: int, | |
| edges: Sequence[LoopEdge], | |
| trajectory_path: str, | |
| loop_plot_path: str, | |
| ) -> PoseGraphSummary: | |
| return PoseGraphSummary( | |
| method=method, | |
| num_candidates=num_candidates, | |
| num_verified_loops=len(edges), | |
| mean_loop_score=float(np.mean([edge.score for edge in edges])) if edges else 0.0, | |
| mean_loop_inliers=float(np.mean([edge.inliers for edge in edges])) if edges else 0.0, | |
| trajectory_path=trajectory_path, | |
| loop_plot_path=loop_plot_path, | |
| ) | |