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 +6 -0
- commerge/__main__.py +5 -0
- commerge/baselines/__init__.py +0 -0
- commerge/baselines/automerge.py +157 -0
- commerge/cli.py +178 -0
- commerge/compare.py +135 -0
- commerge/merge.py +210 -0
- commerge/selection.py +200 -0
- commerge/session.py +172 -0
- commerge/solvers.py +163 -0
- commerge/submap.py +35 -0
- commerge/viz.py +93 -0
- commerge-2.0.0.dist-info/METADATA +196 -0
- commerge-2.0.0.dist-info/RECORD +18 -0
- commerge-2.0.0.dist-info/WHEEL +5 -0
- commerge-2.0.0.dist-info/entry_points.txt +3 -0
- commerge-2.0.0.dist-info/licenses/LICENSE +28 -0
- commerge-2.0.0.dist-info/top_level.txt +1 -0
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
|
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())
|