Skip to content

Commit 5094cd9

Browse files
authored
Add tests for Gaussian prior fusion on SE(2) and Tr(2) x SO(2) (#1947)
1 parent 5c0fad7 commit 5094cd9

1 file changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
#=
2+
Reference & regression test: fusing (multiplying) several Gaussian priors on a 2D pose.
3+
4+
This file compares three independent ways of computing the product of N Gaussian
5+
priors placed on a 2D pose manifold, and cross-checks them against each other:
6+
7+
- `prod_by_fg` : build a 1-variable factor graph with N `ManifoldPrior` factors
8+
and let the parametric solver fuse them.
9+
- `prod_by_bruteforce` : evaluate the product density on a dense grid, take its mode,
10+
then fit a Gaussian to the (weighted) grid samples. Slow, but
11+
makes no linearization assumptions -- treated as ground truth.
12+
- `prod_by_amp` : the closed-form on-manifold Gaussian product used internally
13+
by ApproxManifoldProducts (`calcProductGaussians`).
14+
15+
It also compares two different *manifolds* for representing a 2D pose:
16+
17+
- `Pose2_SE2` : the true SE(2) Lie group (semidirect product, right-variant).
18+
- `Pose2` : the product manifold `TranslationGroup(2) × SpecialOrthogonalGroup(2)`
19+
20+
`check_mean_invariance` makes the difference concrete: it perturbs a set of poses by a
21+
fixed rigid transform `h` (left- and right-multiplication, and inversion) and checks
22+
whether "fuse-then-transform" equals "transform-then-fuse". For `Pose2_SE2`, SE(2) has
23+
no bi-invariant metric, so this only holds exactly on the side matching the group's
24+
trivialization. For `Pose2`, `compose` is not a real rigid-body transform at all, so the check
25+
holds trivially on both sides -- that "pass" is not a sign of correctness, see the
26+
testset below for details.
27+
=#
28+
29+
using Test
30+
using IncrementalInference
31+
using DistributedFactorGraphs
32+
using Distributions
33+
using LieGroups
34+
using LinearAlgebra
35+
using StaticArrays
36+
import ApproxManifoldProducts
37+
import Rotations as _Rot
38+
39+
##==============================================================================
40+
## Two state types representing a 2D pose, for comparison
41+
##==============================================================================
42+
43+
# True SE(2) Lie group: translation and rotation are coupled through `compose`.
44+
DFG.@defStateType(
45+
Pose2_SE2,
46+
SpecialEuclideanGroup(2; variant = :right),
47+
ArrayPartition(@SVector([0.0, 0.0]), @SMatrix([1.0 0.0; 0.0 1.0])),
48+
)
49+
50+
# Product manifold: translation and rotation are independent under `compose`.
51+
# (This mirrors how `RoME.Pose2` is defined -- kept local here so this file has no
52+
# dependency on RoME.)
53+
DFG.@defStateType(
54+
Pose2,
55+
TranslationGroup(2) × SpecialOrthogonalGroup(2),
56+
ArrayPartition(@SVector([0.0, 0.0]), @SMatrix([1.0 0.0; 0.0 1.0])),
57+
)
58+
59+
##==============================================================================
60+
## Product-of-Gaussians via each method
61+
##==============================================================================
62+
63+
"""
64+
prod_by_fg(statekind, points, covars)
65+
66+
Fuse Gaussian priors `(points[i], covars[i])` by building a 1-variable factor graph
67+
(one `ManifoldPrior` per input) and running the parametric solver. Returns `(μ, Σ)`.
68+
"""
69+
function prod_by_fg(statekind, points, covars)
70+
length(points) == length(covars) || error("points and covars must have the same length")
71+
!isempty(points) || error("points and covars must be non-empty")
72+
73+
fg = initfg()
74+
fg.solverParams.graphinit = false
75+
addVariable!(fg, :x0, statekind)
76+
77+
G = getManifold(fg, :x0)
78+
for (pt, Σ) in zip(points, covars)
79+
addFactor!(fg, [:x0], ManifoldPrior(G, pt, MvNormal(Σ)))
80+
end
81+
82+
IIF.solveGraphParametric!(
83+
fg;
84+
init = true,
85+
is_sparse = false,
86+
finiteDiffCovariance = true,
87+
jacobian_method = :forwarddiff,
88+
damping_term_min = 1e-3,
89+
)
90+
91+
μ = DFG.refMeans(getState(fg, :x0, :parametric))[1]
92+
Σ_μ = DFG.refCovariances(getState(fg, :x0, :parametric))[1]
93+
return μ, Σ_μ
94+
end
95+
96+
"""
97+
prod_by_amp(statekind, points, covars)
98+
99+
Fuse Gaussian priors using ApproxManifoldProducts' closed-form on-manifold Gaussian
100+
product (`calcProductGaussians`), i.e. no iterative solve. Returns `(μ, Σ)`.
101+
"""
102+
function prod_by_amp(statekind, points, covars)
103+
G = getManifold(statekind)
104+
m, Σ = ApproxManifoldProducts.calcProductGaussians(G, points, covars)
105+
return m, Σ
106+
end
107+
108+
"""
109+
prod_by_bruteforce(statekind, points, covars; xs, ys, θs, x_step, y_step, θ_step)
110+
111+
Ground-truth-ish product: evaluate the product of the input densities on a dense
112+
(x, y, θ) grid, take the mode, then fit a Gaussian (in the tangent space at the mode)
113+
to the weighted grid samples, re-linearizing once at the fitted mean. No search-window
114+
kwarg needs to be given -- defaults are estimated from the input points/covariances --
115+
but pass `xs`/`ys`/`θs` explicitly to keep the grid (and therefore runtime) small.
116+
Returns `(μ, Σ, details)` where `details` exposes the grid and per-point densities.
117+
"""
118+
function prod_by_bruteforce(statekind, points, covars; xs = nothing, ys = nothing, θs = nothing, x_step = 0.1, y_step = 0.1, θ_step = 0.01)
119+
120+
length(points) == length(covars) || error("points and covars must have the same length")
121+
!isempty(points) || error("points and covars must be non-empty")
122+
123+
G = getManifold(statekind)
124+
lieG = LieAlgebra(G)
125+
126+
# Estimate default x/y search windows from point locations and covariance spread.
127+
tx = map(pt -> pt.x[1][1], points)
128+
ty = map(pt -> pt.x[1][2], points)
129+
x_center = sum(tx) / length(tx)
130+
y_center = sum(ty) / length(ty)
131+
σx_max = maximum(map-> sqrt(max(Σ[1, 1], eps(Float64))), covars))
132+
σy_max = maximum(map-> sqrt(max(Σ[2, 2], eps(Float64))), covars))
133+
x_span = maximum(tx) - minimum(tx)
134+
y_span = maximum(ty) - minimum(ty)
135+
136+
if isnothing(xs)
137+
halfspan_x = max(3.0, 0.5 * x_span + 4.0 * σx_max)
138+
xs = (x_center - halfspan_x):x_step:(x_center + halfspan_x)
139+
end
140+
if isnothing(ys)
141+
halfspan_y = max(3.0, 0.5 * y_span + 4.0 * σy_max)
142+
ys = (y_center - halfspan_y):y_step:(y_center + halfspan_y)
143+
end
144+
145+
# Center the default theta search window around the circular mean of input headings.
146+
if isnothing(θs)
147+
angles = map(points) do pt
148+
R = pt.x[2]
149+
atan(R[2, 1], R[1, 1])
150+
end
151+
θ_center = atan(sum(sin, angles), sum(cos, angles))
152+
153+
# Expand theta support using both heading spread and covariance scale.
154+
angle_offsets = map(a -> atan(sin(a - θ_center), cos(a - θ_center)), angles)
155+
θ_spread = maximum(abs, angle_offsets)
156+
σθ_max = maximum(map-> sqrt(max(Σ[3, 3], eps(Float64))), covars))
157+
θ_halfspan = min(pi, max(1.0, θ_spread + 5.0 * σθ_max))
158+
159+
θs = (θ_center - θ_halfspan):θ_step:(θ_center + θ_halfspan)
160+
end
161+
162+
grid_points = map(Iterators.product(xs, ys, θs)) do (x, y, θ)
163+
ArrayPartition(SA[x, y], _Rot.RotMatrix2(θ))
164+
end
165+
166+
pdf_terms = map(zip(points, covars)) do (pt, Σ)
167+
map(grid_points) do gp
168+
X = log(G, pt, gp)
169+
Xc_e = vee(lieG, X)
170+
pdf(MvNormal(Σ), Xc_e)
171+
end
172+
end
173+
174+
pdf_prod = ones(size(grid_points))
175+
for pdf_i in pdf_terms
176+
pdf_prod .*= pdf_i
177+
end
178+
179+
_, mode_index = findmax(pdf_prod)
180+
mode_point = grid_points[mode_index]
181+
182+
tangent_samples = map(grid_points) do gp
183+
vee(lieG, log(G, mode_point, gp))
184+
end
185+
X = reduce(hcat, tangent_samples)
186+
w = (pdf_prod ./ sum(pdf_prod))[:]
187+
fit_mvn = fit_mle(MvNormal, X, w)
188+
189+
δμ = fit_mvn.μ
190+
μ0 = exp(G, mode_point, hat(lieG, SA[δμ...], ArrayPartition))
191+
192+
# Re-linearize at μ0 to get a more reliable covariance, especially on theta.
193+
tangent_samples_μ0 = map(grid_points) do gp
194+
vee(lieG, log(G, μ0, gp))
195+
end
196+
X_μ0 = reduce(hcat, tangent_samples_μ0)
197+
fit_mvn_μ0 = fit_mle(MvNormal, X_μ0, w)
198+
199+
δμ_μ0 = fit_mvn_μ0.μ
200+
μ = exp(G, μ0, hat(lieG, SA[δμ_μ0...], ArrayPartition))
201+
Σ_μ = Matrix(cov(fit_mvn_μ0))
202+
203+
details = (; xs, ys, θs, grid_points, pdf_terms, pdf_prod, mode_index, mode_point, fit_mvn, fit_mvn_μ0)
204+
return μ, Σ_μ, details
205+
end
206+
207+
##==============================================================================
208+
## Equivariance check: does fusion commute with a rigid-body transform?
209+
##==============================================================================
210+
211+
"""
212+
check_mean_invariance(G, points, covars, h, prod_method; atol, label)
213+
214+
`prod_method(points, covars) -> μ` (or `(μ, Σ, ...)`, only `μ` is used) fuses a set of
215+
poses to a single mean. Checks that fusing commutes with composing every input by a
216+
fixed transform `h`, both on the left (`h * pt`) and right (`pt * h`), and with
217+
inversion (`inv(pt)`). Prints a pass/fail per check and returns a `NamedTuple`.
218+
"""
219+
function check_mean_invariance(G, points, covars, h, prod_method; atol = 1e-6, label = "")
220+
221+
_mean(points_, covars_) = begin
222+
out = prod_method(points_, covars_)
223+
out isa Tuple ? out[1] : out
224+
end
225+
226+
m = _mean(points, covars)
227+
228+
points_left = map(pt -> compose(G, h, pt), points)
229+
m_left_fused = _mean(points_left, covars)
230+
m_left_expected = compose(G, h, m)
231+
left_check = isapprox(G, m_left_fused, m_left_expected; atol)
232+
233+
points_right = map(pt -> compose(G, pt, h), points)
234+
m_right_fused = _mean(points_right, covars)
235+
m_right_expected = compose(G, m, h)
236+
right_check = isapprox(G, m_right_fused, m_right_expected; atol)
237+
238+
points_inv = map(pt -> inv(G, pt), points)
239+
m_inv_fused = _mean(points_inv, covars)
240+
m_inv_expected = inv(G, m)
241+
inv_check = isapprox(G, m_inv_fused, m_inv_expected; atol)
242+
243+
if !isempty(label)
244+
println("--- ", label, " ---")
245+
end
246+
println("Left-Invariance Pass? -> ", left_check)
247+
println("Right-Invariance Pass? -> ", right_check)
248+
println("Inverse-Invariance Pass? -> ", inv_check)
249+
250+
return (; mean = m, left = left_check, right = right_check, inverse = inv_check)
251+
end
252+
253+
##==============================================================================
254+
## Tests
255+
##==============================================================================
256+
257+
@testset "SE(2) pose fusion: FG solve vs brute-force vs AMP closed-form" begin
258+
p = ArrayPartition(SA[1.0, 2.0], _Rot.RotMatrix2(0.3))
259+
Σp = diagm(SA[0.20, 0.20, 0.05] .^ 2)
260+
261+
q = ArrayPartition(SA[1.4, 1.6], _Rot.RotMatrix2(0.6))
262+
R2 = _Rot.RotMatrix2(pi / 6)
263+
Rblock = [R2 zeros(2, 1); zeros(1, 2) 1.0]
264+
Σq = Rblock * diagm([0.15, 0.30, 0.07] .^ 2) * Rblock'
265+
266+
xs = -1.0:0.05:3.5
267+
ys = 0.5:0.05:3.5
268+
θs = 0.0:0.01:1.0
269+
270+
@testset "Pose2_SE2 (coupled SE(2) Lie group)" begin
271+
G = getManifold(Pose2_SE2)
272+
μ_fg, Σ_fg = prod_by_fg(Pose2_SE2, [p, q], [Σp, Σq])
273+
μ_amp, Σ_amp = prod_by_amp(Pose2_SE2, [p, q], [Σp, Σq])
274+
μ_bf, Σ_bf, _ = prod_by_bruteforce(Pose2_SE2, [p, q], [Σp, Σq]; xs, ys, θs)
275+
276+
@test isapprox(G, μ_fg, μ_bf; atol = 5e-2)
277+
@test isapprox(G, μ_amp, μ_bf; atol = 5e-2)
278+
@test isapprox(Σ_fg, Σ_bf; atol = 5e-2)
279+
@test isapprox(Σ_amp, Σ_bf; atol = 5e-2)
280+
end
281+
282+
@testset "Pose2 (decoupled product manifold)" begin
283+
G = getManifold(Pose2)
284+
μ_fg, Σ_fg = prod_by_fg(Pose2, [p, q], [Σp, Σq])
285+
#TODO needs AMP v0.15.7
286+
# μ_amp, Σ_amp = prod_by_amp(Pose2, [p, q], [Σp, Σq])
287+
μ_bf, Σ_bf, _ = prod_by_bruteforce(Pose2, [p, q], [Σp, Σq]; xs, ys, θs)
288+
289+
@test isapprox(G, μ_fg, μ_bf; atol = 5e-2)
290+
@test isapprox(Σ_fg, Σ_bf; atol = 5e-2)
291+
@test_broken isapprox(G, μ_amp, μ_bf; atol = 5e-2)
292+
@test_broken isapprox(Σ_amp, Σ_bf; atol = 5e-2)
293+
end
294+
end
295+
296+
@testset "SE(2) pose fusion: equivariance under rigid-body transform" begin
297+
p = ArrayPartition(SA[10.0, 18.0], _Rot.RotMatrix2(0.0))
298+
Σp = diagm(SA[1.0, 1.0, 1.0] .^ 2)
299+
300+
q = ArrayPartition(SA[10.0, 24.0], _Rot.RotMatrix2(1.0))
301+
Σq = diagm(SA[1.0, 1.0, 1.0] .^ 2)
302+
303+
h = ArrayPartition(SA[2.5, -4.0], _Rot.RotMatrix2(0.5))
304+
305+
@testset "Pose2_SE2 (coupled SE(2) group, :right variant): left-equivariant only" begin
306+
G = getManifold(Pose2_SE2)
307+
fg_mean_method = (pts, Σs) -> prod_by_fg(Pose2_SE2, pts, Σs)
308+
r = check_mean_invariance(G, [q, p], [Σq, Σp], h, fg_mean_method; label = "Pose2_SE2 FG mean")
309+
# SE(2) has no bi-invariant metric, so a Gaussian-on-manifold fusion can only be
310+
# equivariant on the side that matches the group's chosen trivialization. `Pose2_SE2`
311+
# LieGroups.jl is left-trivialized.
312+
@test r.left
313+
@test !r.right
314+
@test !r.inverse
315+
end
316+
317+
@testset "Pose2 (decoupled product manifold): equivariant on both sides, trivially" begin
318+
G = getManifold(Pose2)
319+
fg_mean_method = (pts, Σs) -> prod_by_fg(Pose2, pts, Σs)
320+
r = check_mean_invariance(G, [q, p], [Σq, Σp], h, fg_mean_method; label = "Pose2 FG mean")
321+
# `compose` on the product manifold adds translations and composes rotations independently
322+
@test r.left
323+
@test r.right
324+
@test r.inverse
325+
end
326+
end
327+
328+
##==============================================================================
329+
## Reference (not run): visualize the brute-force product density and geodesics
330+
##==============================================================================
331+
# Paste this block into a Makie-loaded REPL (`using GLMakie` or `using CairoMakie`)
332+
# to see what `prod_by_bruteforce` is integrating over, and how the Lie-group geodesic
333+
# between `p` and `q` compares to the base-manifold (Riemannian) geodesic.
334+
if false
335+
using GLMakie
336+
337+
p = ArrayPartition(SA[10.0, 18.0], _Rot.RotMatrix2(0.0))
338+
Σp = diagm(SA[3.0, 3.0, 1.0] .^ 2)
339+
q = ArrayPartition(SA[10.0, 24.0], _Rot.RotMatrix2(1.0))
340+
Σq = diagm(SA[3.0, 3.0, 1.0] .^ 2)
341+
342+
G = getManifold(Pose2_SE2)
343+
_, _, bf_details = prod_by_bruteforce(Pose2_SE2, [p, q], [Σp, Σq])
344+
xs, ys, θs = bf_details.xs, bf_details.ys, bf_details.θs
345+
pdf_ps, pdf_qs = bf_details.pdf_terms
346+
pdf_pqs = bf_details.pdf_prod # brute-force product
347+
348+
cols = Makie.wong_colors()
349+
contour(xs, ys, sum(pdf_pqs; dims=3)[:, :, 1]; color = cols[1], linewidth = 2, levels = 15, axis =(aspect=DataAspect(),))
350+
contour!(xs, ys, sum(pdf_ps; dims=3)[:, :, 1]; color = cols[2], alpha = 0.5)
351+
contour!(xs, ys, sum(pdf_qs; dims=3)[:, :, 1]; color = cols[3], alpha = 0.5)
352+
353+
# Lie-group geodesic between p and q (on G)
354+
t_geo = range(0.0, 1.0; length = 100)
355+
pq_geo = map(t_geo) do t
356+
exp(G, p, t * log(G, p, q))
357+
end
358+
pq_geo_x = [first(g.x)[1] for g in pq_geo]
359+
pq_geo_y = [first(g.x)[2] for g in pq_geo]
360+
lines!(pq_geo_x, pq_geo_y; color = cols[4], linewidth = 3) # magenta
361+
362+
# untested to plot the Riemannian geodesic (straight line), we use the base manifold
363+
M = base_manifold(G)
364+
t_geo = range(0.0, 1.0; length = 100)
365+
pq_geo = map(t_geo) do t
366+
exp(M, p, t * log(M, p, q))
367+
end
368+
pq_geo_x = [first(g.x)[1] for g in pq_geo]
369+
pq_geo_y = [first(g.x)[2] for g in pq_geo]
370+
lines!(pq_geo_x, pq_geo_y; color = cols[5], linewidth = 3)
371+
end

0 commit comments

Comments
 (0)