commerge 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
commerge/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Commerge: communication-efficient multi-robot LiDAR map merging."""
2
+ __version__ = "2.0.0"
3
+
4
+ from .selection import STEPS, Selection, cosine_affinity, enhance_affinity, extract_regions, select # noqa: E402,F401
5
+ from .session import Session, read_pcd, write_pcd # noqa: E402,F401
6
+ from .merge import POLICIES, alignment_error, choose_regions, register, register_exchanged, register_grid # noqa: E402,F401
commerge/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
File without changes
@@ -0,0 +1,157 @@
1
+ """AutoMerge-style region selection (our re-implementation, for comparison).
2
+
3
+ AutoMerge (P. Yin et al., "AutoMerge: A Framework for Map Assembling and Smoothing in City-Scale
4
+ Environments", IEEE T-RO 2023) finds overlapping sequences with SeqSLAM. This module follows that
5
+ idea on the same affinity matrix Commerge uses:
6
+
7
+ 1. local contrast enhancement of the difference matrix D = 1 - affinity (SeqSLAM);
8
+ 2. sequence matching along constant-velocity trajectories, with a ratio test;
9
+ 3. K-means on the good matches, a line RANSAC per cluster;
10
+ 4. each cluster's padded bounding box is a region; the best region is exchanged whole.
11
+
12
+ It is not the authors' code. All scans of the chosen region are sent, which is what Commerge's
13
+ selection is compared against.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from dataclasses import dataclass
19
+ from typing import List, Tuple
20
+
21
+ import numpy as np
22
+
23
+ from ..selection import Region, Selection
24
+
25
+
26
+ @dataclass
27
+ class AutoMergeParams:
28
+ min_area: int = 20000
29
+ contrast_r: int = 50 # rows of the local contrast window
30
+ ds: int = 50 # sequence length
31
+ vmin: float = 0.8 # trajectory slopes searched
32
+ vmax: float = 1.2
33
+ r_window: int = 10 # window excluded around the best match for the ratio test
34
+ ratio: float = 0.8 # keep matches with best / second-best below this
35
+ n_clusters: int = 5
36
+ ransac_iters: int = 100
37
+ padding: int = 20 # region padding (keyframes)
38
+ best_only: bool = True # exchange only the best region (as AutoMerge does)
39
+ seed: int = 0
40
+
41
+
42
+ def enhance_contrast(D: np.ndarray, r: int) -> np.ndarray:
43
+ """SeqSLAM local contrast enhancement along the rows (reference keyframes)."""
44
+ DD = np.empty_like(D, dtype=float)
45
+ n = D.shape[0]
46
+ for i in range(n):
47
+ a, b = max(0, i - r // 2), min(n, i + r // 2 + 1)
48
+ v = D[a:b]
49
+ std = v.std(axis=0, ddof=1) if b - a > 1 else np.ones(D.shape[1])
50
+ std[std == 0] = 1.0
51
+ DD[i] = (D[i] - v.mean(axis=0)) / std
52
+ return DD - DD.min()
53
+
54
+
55
+ def sequence_matches(DD: np.ndarray, p: AutoMergeParams) -> np.ndarray:
56
+ """(Nb, 2): for each query column, the best reference row and the ratio to the second best."""
57
+ ds = p.ds + (p.ds % 2)
58
+ rows, cols = DD.shape
59
+ matches = np.full((cols, 2), np.nan)
60
+ move = np.arange(int(p.vmin * ds), int(p.vmax * ds) + 1)
61
+ v = move / ds
62
+ steps = np.floor(np.arange(ds + 1)[None, :] * v[:, None]).astype(int) # (V, ds+1)
63
+ padded = np.vstack([DD, np.full((1, cols), np.inf)]) # out-of-bounds row
64
+ starts = np.arange(rows)
65
+ for n in range(ds // 2, cols - ds // 2):
66
+ x = np.arange(n - ds // 2, n + ds // 2 + 1) # ds+1 query columns
67
+ y = np.minimum(starts[:, None, None] + steps[None], rows) # (S, V, ds+1)
68
+ scores = padded[y, x[None, None, :]].sum(axis=2).min(axis=1) # (S,)
69
+ if not np.isfinite(scores).any():
70
+ continue
71
+ k = int(np.argmin(scores))
72
+ mask = np.ones(rows, bool)
73
+ mask[max(0, k - p.r_window // 2): min(rows, k + p.r_window // 2)] = False
74
+ second = scores[mask].min() if mask.any() else np.inf
75
+ matches[n] = (k + ds // 2, scores[k] / second if second > 0 else 1.0)
76
+ return matches
77
+
78
+
79
+ def _kmeans(points: np.ndarray, k: int, seed: int) -> np.ndarray:
80
+ try:
81
+ from sklearn.cluster import KMeans
82
+
83
+ return KMeans(n_clusters=k, random_state=seed, n_init=10).fit_predict(points)
84
+ except ImportError:
85
+ from scipy.cluster.vq import kmeans2
86
+
87
+ return kmeans2(points.astype(float), k, seed=seed, minit="++")[1]
88
+
89
+
90
+ def _ransac_line(pts: np.ndarray, iters: int, rng: np.random.Generator) -> np.ndarray:
91
+ if len(pts) < 4:
92
+ return np.arange(len(pts))
93
+ best = np.arange(0)
94
+ tol = max(10.0, 0.1 * len(pts))
95
+ for _ in range(iters):
96
+ i, j = rng.choice(len(pts), 2, replace=False)
97
+ if abs(pts[j, 0] - pts[i, 0]) < 1e-6:
98
+ continue
99
+ a = (pts[j, 1] - pts[i, 1]) / (pts[j, 0] - pts[i, 0])
100
+ b = pts[i, 1] - a * pts[i, 0]
101
+ inl = np.nonzero(np.abs(pts[:, 1] - (a * pts[:, 0] + b)) < tol)[0]
102
+ if len(inl) > len(best):
103
+ best = inl
104
+ return best if len(best) else np.arange(len(pts))
105
+
106
+
107
+ def automerge_regions(affinity: np.ndarray, p: AutoMergeParams = AutoMergeParams()
108
+ ) -> List[Tuple[Tuple[int, int, int, int], float]]:
109
+ """Regions (a0, a1, b0, b1) with their mean match ratio, best first."""
110
+ DD = enhance_contrast(np.clip(1.0 - affinity, 0, 1), p.contrast_r)
111
+ m = sequence_matches(DD, p)
112
+ good = [(int(m[q, 0]), q, m[q, 1]) for q in range(len(m)) if np.isfinite(m[q, 0]) and m[q, 1] < p.ratio]
113
+ if not good:
114
+ return []
115
+ pts = np.array([(a, b) for a, b, _ in good], dtype=float)
116
+ k = max(2, min(p.n_clusters, len(good) // 10))
117
+ labels = _kmeans(pts, k, p.seed) if len(good) >= k else np.zeros(len(good), int)
118
+ rng = np.random.default_rng(p.seed)
119
+ regions = []
120
+ for c in range(labels.max() + 1):
121
+ idx = np.nonzero(labels == c)[0]
122
+ if len(idx) <= 2:
123
+ continue
124
+ idx = idx[_ransac_line(pts[idx], p.ransac_iters, rng)]
125
+ if len(idx) < 3:
126
+ continue
127
+ a0, a1 = pts[idx, 0].min(), pts[idx, 0].max()
128
+ b0, b1 = pts[idx, 1].min(), pts[idx, 1].max()
129
+ box = (int(max(0, a0 - p.padding)), int(min(affinity.shape[0], a1 + p.padding)),
130
+ int(max(0, b0 - p.padding)), int(min(affinity.shape[1], b1 + p.padding)))
131
+ if (box[1] - box[0]) * (box[3] - box[2]) >= p.min_area:
132
+ regions.append((box, float(np.mean([good[i][2] for i in idx]))))
133
+
134
+ def score(r): # as in our comparison: similar sequences and large overlap first
135
+ (a0, a1, b0, b1), ratio = r
136
+ sim = float(affinity[a0:a1, b0:b1].mean())
137
+ return 0.7 * sim + 0.3 * min((a1 - a0) * (b1 - b0) / 50000.0, 1.0)
138
+
139
+ return sorted(regions, key=score, reverse=True)
140
+
141
+
142
+ def automerge_select(affinity: np.ndarray, p: AutoMergeParams = AutoMergeParams(), log=print) -> Selection:
143
+ """The chosen region(s), every keyframe of which is exchanged."""
144
+ t = time.perf_counter()
145
+ regions = automerge_regions(affinity, p)
146
+ if p.best_only:
147
+ regions = regions[:1]
148
+ out = []
149
+ for (a0, a1, b0, b1), _ in regions:
150
+ r = Region((a0, a1, b0, b1))
151
+ r.scans_a, r.scans_b = list(range(a0, a1)), list(range(b0, b1))
152
+ r.candidates = int((affinity[a0:a1, b0:b1] > 0).sum())
153
+ r.seconds["automerge"] = time.perf_counter() - t
154
+ out.append(r)
155
+ log(f"[automerge] {len(out)} region(s), {sum(len(r.keyframes_a) + len(r.keyframes_b) for r in out)} scans "
156
+ f"in {time.perf_counter() - t:.2f} s")
157
+ return Selection(out, "", "-")
commerge/cli.py ADDED
@@ -0,0 +1,178 @@
1
+ """commerge: select the scans two robots exchange to merge their maps.
2
+
3
+ commerge <session A> <session B> --out merged/ [--threshold 0.94 --min-area 10000]
4
+
5
+ Sessions are SOLiD outputs (LT-mapper format: Scans/, SOLiDs/, *_poses.txt). The affinity is
6
+ the cosine similarity of their SOLiD descriptors, or any (Na, Nb) matrix given with --affinity.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ from . import __version__
20
+ from .selection import STEPS, cosine_affinity, enhance_affinity, select
21
+ from .merge import POLICIES, alignment_error, choose_regions, register_exchanged
22
+ from .session import Session, read_kitti_poses
23
+ from .solvers import SOLVERS
24
+ from .submap import build_submap, save_submap
25
+
26
+
27
+ def parse_args(argv=None) -> argparse.Namespace:
28
+ p = argparse.ArgumentParser(prog="commerge", description=__doc__,
29
+ formatter_class=argparse.RawDescriptionHelpFormatter)
30
+ p.add_argument("session_a", type=Path, help="robot A: SOLiD session folder")
31
+ p.add_argument("session_b", type=Path, help="robot B: SOLiD session folder")
32
+ p.add_argument("--out", type=Path, required=True, help="output folder")
33
+ g = p.add_argument_group("affinity")
34
+ g.add_argument("--affinity", type=Path, help="(Na, Nb) .npy instead of the SOLiD descriptors")
35
+ g.add_argument("--descriptor", choices=("rsolid", "asolid", "both"), default="rsolid",
36
+ help="SOLiD part used for the cosine affinity (default: rsolid)")
37
+ g.add_argument("--poses", default="auto", help="auto | optimized | odom: keyframe poses to use")
38
+ g = p.add_argument_group("selection")
39
+ g.add_argument("--threshold", type=float, required=True, help="affinity threshold of a candidate pair")
40
+ g.add_argument("--enhance", type=int, default=0, metavar="L",
41
+ help="sequence-enhance the affinity over L keyframes before step (A) (row/column z-scores, "
42
+ "diagonal and anti-diagonal average; thresholds become z-scores, ~3), e.g. for "
43
+ "heterogeneous LiDARs; 0: off (default)")
44
+ g.add_argument("--min-area", type=int, required=True, help="smallest region (cells of its bounding box)")
45
+ g.add_argument("--consistency", type=float, default=3.0,
46
+ help="largest distance mismatch (m) between two consistent pairs (default: 3)")
47
+ g.add_argument("--steps", default="ABC",
48
+ help="steps to run, any subset of ABC (default): (A) " + STEPS["A"] + ", (B) " + STEPS["B"]
49
+ + ", (C) " + STEPS["C"])
50
+ g.add_argument("--solver", choices=SOLVERS, default="auto",
51
+ help="gurobi (paper, needs a license) | highs (SciPy, no license) | auto (default)")
52
+ g.add_argument("--time-limit", type=float, default=0.0, help="per-problem solver time limit, s (0: none)")
53
+ g = p.add_argument_group("exchange and merging")
54
+ g.add_argument("--policy", choices=POLICIES, default="score",
55
+ help="region(s) whose scans are exchanged: score (largest step-(C) clique weight, default), "
56
+ "area, candidates or all")
57
+ g.add_argument("--no-register", action="store_true",
58
+ help="do not register the exchanged submaps (registration needs kiss-matcher, small-gicp)")
59
+ g.add_argument("--reg-voxel", type=float, default=0.5, help="registration voxel size (m)")
60
+ g.add_argument("--registration", choices=("kiss", "grid"), default="kiss",
61
+ help="kiss: KISS-Matcher + GICP (default); grid: yaw and x-y search around the best selected "
62
+ "pair + ICP, for heterogeneous LiDARs")
63
+ g.add_argument("--gt-a", type=Path, help="ground-truth keyframe poses of A (KITTI, common frame): report the error")
64
+ g.add_argument("--gt-b", type=Path, help="ground-truth keyframe poses of B (same frame as --gt-a)")
65
+ g = p.add_argument_group("output")
66
+ g.add_argument("--no-submaps", action="store_true", help="only write the selection")
67
+ g.add_argument("--min-range", type=float, default=3.0, help="drop points closer than this (m)")
68
+ g.add_argument("--voxel", type=float, default=0.0, help="submap voxel size (m, 0: none)")
69
+ g.add_argument("--plot", action="store_true", help="save the affinity with regions and pairs")
70
+ g.add_argument("--verbose", action="store_true", help="solver logs")
71
+ p.add_argument("--version", action="version", version=f"commerge {__version__}")
72
+ return p.parse_args(argv)
73
+
74
+
75
+ def descriptor(s: Session, which: str) -> np.ndarray:
76
+ if s.rsolid is None:
77
+ raise SystemExit(f"{s.name}: no SOLiDs/ folder; give the affinity with --affinity")
78
+ if which == "rsolid":
79
+ return s.rsolid
80
+ if s.asolid is None:
81
+ raise SystemExit(f"{s.name}: SOLiDs/ has no A-SOLiD line")
82
+ return s.asolid if which == "asolid" else np.c_[s.rsolid, s.asolid]
83
+
84
+
85
+ def main(argv=None) -> int:
86
+ args = parse_args(argv)
87
+ out = args.out
88
+ out.mkdir(parents=True, exist_ok=True)
89
+
90
+ A = Session.load(args.session_a, args.poses)
91
+ B = Session.load(args.session_b, args.poses)
92
+ print(f"[commerge] {A.name}: {len(A)} keyframes | {B.name}: {len(B)} keyframes")
93
+ if args.affinity:
94
+ aff = np.load(args.affinity)
95
+ else:
96
+ t = time.perf_counter()
97
+ aff = cosine_affinity(descriptor(A, args.descriptor), descriptor(B, args.descriptor))
98
+ print(f"[commerge] affinity {aff.shape} from {args.descriptor} in {time.perf_counter() - t:.2f} s")
99
+ np.save(out / "affinity.npy", aff.astype(np.float32))
100
+
101
+ if args.enhance:
102
+ aff = enhance_affinity(aff, args.enhance)
103
+ print(f"[commerge] affinity sequence-enhanced over {args.enhance} keyframes")
104
+ sel = select(aff, A.positions, B.positions, args.threshold, args.min_area, args.consistency,
105
+ args.steps, args.solver, args.time_limit, args.verbose)
106
+
107
+ with open(out / "pairs.csv", "w", newline="") as f:
108
+ w = csv.writer(f)
109
+ w.writerow(["region", "keyframe_a", "keyframe_b", "affinity"])
110
+ for k, r in enumerate(sel.regions):
111
+ for a, b in r.pairs:
112
+ w.writerow([k, a, b, f"{aff[a, b]:.6f}"])
113
+
114
+ summary = {
115
+ "session_a": str(args.session_a), "session_b": str(args.session_b),
116
+ "keyframes": [len(A), len(B)], "threshold": args.threshold, "min_area": args.min_area,
117
+ "consistency": args.consistency, "steps": sel.steps, "solver": sel.solver,
118
+ "selected_scans": [len(sel.keyframes_a), len(sel.keyframes_b)],
119
+ "regions": [{"box_a": [r.box[0], r.box[1]], "box_b": [r.box[2], r.box[3]],
120
+ "A_candidates": r.candidates, "B_cover": [len(r.cover_a), len(r.cover_b)],
121
+ "C_pairs": len(r.pairs), "keyframes_a": r.keyframes_a, "keyframes_b": r.keyframes_b,
122
+ "seconds": r.seconds} for r in sel.regions],
123
+ }
124
+ # exchange policy: the region(s) whose selected scans the robots actually send
125
+ chosen = choose_regions(sel, aff, A.positions, B.positions, args.policy, args.consistency)
126
+ ka = sorted({a for k in chosen for a in sel.regions[k].keyframes_a})
127
+ kb = sorted({b for k in chosen for b in sel.regions[k].keyframes_b})
128
+ summary["exchange"] = {"policy": args.policy, "regions": chosen, "keyframes_a": ka, "keyframes_b": kb}
129
+ if ka and kb and not (args.no_submaps and args.no_register):
130
+ sub_a = build_submap(A, ka, args.min_range, args.voxel)
131
+ sub_b = build_submap(B, kb, args.min_range, args.voxel)
132
+ if not args.no_submaps:
133
+ save_submap(out / "submap_A.pcd", sub_a)
134
+ save_submap(out / "submap_B.pcd", sub_b)
135
+ if not args.no_register:
136
+ try:
137
+ reg = register_exchanged(sub_a, sub_b, A.poses, B.poses,
138
+ [p for k in chosen for p in sel.regions[k].pairs], aff,
139
+ args.registration, args.reg_voxel)
140
+ except ImportError as e:
141
+ reg = False
142
+ print(f"[commerge] registration skipped: {e}")
143
+ if reg is False:
144
+ pass
145
+ elif reg is None:
146
+ print("[commerge] registration failed (too few inliers); try another --policy")
147
+ else:
148
+ np.savetxt(out / "T_a_b.txt", reg.T_a_b, fmt="%.9f")
149
+ merged = np.einsum("ij,njk->nik", reg.T_a_b, B.poses)[:, :3, :4].reshape(len(B), 12)
150
+ np.savetxt(out / "poses_b_in_a.txt", merged, fmt="%.9f")
151
+ summary["registration"] = {"inliers": reg.inliers, "gicp_error": reg.fitness, "seconds": reg.seconds}
152
+ msg = (f"[commerge] registered B to A: {reg.inliers} KISS-Matcher inliers, GICP in {reg.seconds:.1f} s"
153
+ if args.registration == "kiss" else
154
+ f"[commerge] registered B to A (grid + ICP): fit {reg.fitness:.2f} in {reg.seconds:.1f} s")
155
+ if args.gt_a and args.gt_b:
156
+ et, er, em = alignment_error(reg.T_a_b, A.poses, B.poses, read_kitti_poses(args.gt_a),
157
+ read_kitti_poses(args.gt_b))
158
+ summary["registration"]["error"] = {"trans_m": et, "rot_deg": er, "merged_mean_m": em}
159
+ msg += f"; vs ground truth: {et:.2f} m, {er:.2f} deg (B keyframes {em:.2f} m off on average)"
160
+ print(msg)
161
+ (out / "selection.json").write_text(json.dumps(summary, indent=1))
162
+
163
+ if args.plot:
164
+ from .viz import plot_selection
165
+
166
+ plot_selection(aff, sel, out / "selection.png", args.threshold)
167
+
168
+ total = len(A) + len(B)
169
+ sent = len(ka) + len(kb)
170
+ mb = sum(A.scans[k].stat().st_size for k in ka) / 1e6 + sum(B.scans[k].stat().st_size for k in kb) / 1e6 \
171
+ if A.scans and B.scans else float("nan")
172
+ print(f"[commerge] exchanged {len(ka)} A + {len(kb)} B scans ({100.0 * sent / max(total, 1):.1f} % of {total}, "
173
+ f"{mb:.1f} MB) from region(s) {chosen} of {len(sel.regions)} -> {out}")
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
commerge/compare.py ADDED
@@ -0,0 +1,135 @@
1
+ """commerge-compare: Commerge, its ablations (without step (B) or (C), step (A) only) and AutoMerge
2
+ on the same two sessions.
3
+
4
+ commerge-compare <session A> <session B> --threshold 0.96 --min-area 2000 --out cmp/
5
+ [--gt-a gt_a.txt --gt-b gt_b.txt]
6
+
7
+ Reports, per method, the scans each robot sends (count, share of all keyframes, megabytes of the
8
+ keyframe clouds) and the run time. With ground-truth keyframe poses in a common frame
9
+ (KITTI format, one row per keyframe), it also reports the share of selected pairs that are
10
+ really the same place (within --gt-radius meters).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ from .baselines.automerge import AutoMergeParams, automerge_select
23
+ from .cli import descriptor
24
+ from .merge import alignment_error, choose_regions, register
25
+ from .selection import select
26
+ from .submap import build_submap
27
+ from .session import Session, read_kitti_poses
28
+ from .solvers import SOLVERS
29
+
30
+ METHODS = {
31
+ "Commerge (A+B+C)": "ABC",
32
+ "w/o (B)": "AC",
33
+ "w/o (C)": "AB",
34
+ "(A) only": "A",
35
+ }
36
+
37
+
38
+ def parse_args(argv=None):
39
+ p = argparse.ArgumentParser(prog="commerge-compare", description=__doc__,
40
+ formatter_class=argparse.RawDescriptionHelpFormatter)
41
+ p.add_argument("session_a", type=Path)
42
+ p.add_argument("session_b", type=Path)
43
+ p.add_argument("--out", type=Path, required=True)
44
+ p.add_argument("--affinity", type=Path, help="(Na, Nb) .npy instead of SOLiD descriptors")
45
+ p.add_argument("--descriptor", choices=("rsolid", "asolid", "both"), default="rsolid")
46
+ p.add_argument("--poses", default="auto")
47
+ p.add_argument("--threshold", type=float, required=True)
48
+ p.add_argument("--min-area", type=int, required=True)
49
+ p.add_argument("--consistency", type=float, default=3.0)
50
+ p.add_argument("--solver", choices=SOLVERS, default="auto")
51
+ p.add_argument("--time-limit", type=float, default=60.0,
52
+ help="per-problem solver limit, s (default 60: the ablations without the cover can be huge)")
53
+ p.add_argument("--automerge-min-area", type=int, help="AutoMerge region size (default: --min-area)")
54
+ p.add_argument("--gt-a", type=Path, help="ground-truth keyframe poses of A (KITTI, common frame)")
55
+ p.add_argument("--gt-b", type=Path, help="ground-truth keyframe poses of B (same frame as --gt-a)")
56
+ p.add_argument("--gt-radius", type=float, default=5.0)
57
+ p.add_argument("--policy", default="score", help="exchange policy of the Commerge variants (see commerge --help)")
58
+ p.add_argument("--no-register", action="store_true", help="skip registering each method's exchanged scans")
59
+ return p.parse_args(argv)
60
+
61
+
62
+ def scan_megabytes(s: Session, keyframes) -> float:
63
+ return sum(s.scans[k].stat().st_size for k in keyframes) / 1e6 if s.scans else float("nan")
64
+
65
+
66
+ def main(argv=None) -> int:
67
+ args = parse_args(argv)
68
+ args.out.mkdir(parents=True, exist_ok=True)
69
+ A, B = Session.load(args.session_a, args.poses), Session.load(args.session_b, args.poses)
70
+ aff = np.load(args.affinity) if args.affinity else \
71
+ __import__("commerge").cosine_affinity(descriptor(A, args.descriptor), descriptor(B, args.descriptor))
72
+ gt = gtT = None
73
+ if args.gt_a and args.gt_b:
74
+ gtT = (read_kitti_poses(args.gt_a), read_kitti_poses(args.gt_b))
75
+ gt = (gtT[0][:, :3, 3], gtT[1][:, :3, 3])
76
+
77
+ rows, selections = [], {}
78
+ quiet = lambda *a: None # noqa: E731
79
+ for name, steps in METHODS.items():
80
+ t = time.perf_counter()
81
+ sel = select(aff, A.positions, B.positions, args.threshold, args.min_area, args.consistency,
82
+ steps, args.solver, args.time_limit, log=quiet)
83
+ selections[name] = (sel, time.perf_counter() - t)
84
+ t = time.perf_counter()
85
+ am = AutoMergeParams(min_area=args.automerge_min_area or args.min_area)
86
+ selections["AutoMerge"] = (automerge_select(aff, am, log=quiet), time.perf_counter() - t)
87
+
88
+ total = len(A) + len(B)
89
+ for name, (sel, sec) in selections.items():
90
+ # what is actually exchanged: the chosen region (AutoMerge keeps its best region already)
91
+ chosen = choose_regions(sel, aff, A.positions, B.positions, args.policy if name != "AutoMerge" else "all")
92
+ ka = sorted({a for k in chosen for a in sel.regions[k].keyframes_a})
93
+ kb = sorted({b for k in chosen for b in sel.regions[k].keyframes_b})
94
+ pairs = [p for k in chosen for p in sel.regions[k].pairs]
95
+ row = {"method": name, "scans_a": len(ka), "scans_b": len(kb),
96
+ "share": 100.0 * (len(ka) + len(kb)) / total,
97
+ "megabytes": scan_megabytes(A, ka) + scan_megabytes(B, kb),
98
+ "pairs": len(pairs), "seconds": sec}
99
+ if gt is not None and pairs:
100
+ d = np.array([np.linalg.norm(gt[0][a] - gt[1][b]) for a, b in pairs])
101
+ row["pair_precision"] = float((d <= args.gt_radius).mean())
102
+ if not args.no_register and ka and kb:
103
+ try:
104
+ reg = register(build_submap(A, ka), build_submap(B, kb))
105
+ except ImportError:
106
+ args.no_register = True
107
+ print("[compare] kiss-matcher not installed: registration skipped")
108
+ reg = None
109
+ row["registered"] = reg is not None
110
+ if reg is not None and gtT is not None:
111
+ et, er, em = alignment_error(reg.T_a_b, A.poses, B.poses, *gtT)
112
+ row.update(trans_err=et, rot_err=er, merged_err=em)
113
+ rows.append(row)
114
+
115
+ print("| method | scans A + B | share of keyframes | data (MB) | pairs | pair precision | merge error | time (s) |")
116
+ print("| --- | --- | --- | --- | --- | --- | --- | --- |")
117
+ for r in rows:
118
+ prec = f"{100 * r['pair_precision']:.1f} %" if "pair_precision" in r else "-"
119
+ if "trans_err" in r:
120
+ err = f"{r['trans_err']:.2f} m, {r['rot_err']:.2f} deg"
121
+ else:
122
+ err = "failed" if r.get("registered") is False else "-"
123
+ print(f"| {r['method']} | {r['scans_a']} + {r['scans_b']} | {r['share']:.1f} % | {r['megabytes']:.1f} | "
124
+ f"{r['pairs'] or '-'} | {prec} | {err} | {r['seconds']:.2f} |")
125
+ (args.out / "comparison.json").write_text(json.dumps(rows, indent=1))
126
+
127
+ from .viz import plot_comparison
128
+
129
+ plot_comparison(aff, {k: v[0] for k, v in selections.items()}, args.out / "comparison.png", args.threshold, rows)
130
+ print(f"-> {args.out}/comparison.json, comparison.png")
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())