SE(2) pose graph#
In this notebook, we solve a 2D pose graph optimization problem: estimating robot poses from noisy relative measurements.
Inputs: Relative pose measurements (odometry + loop closures) from g2o file
Outputs: Globally consistent robot trajectory
Features used:
SE2Varfor SE(2) robot poses@jaxls.Cost.factorywith batched edge constructionReal g2o dataset with ~3500 poses and loop closures
import pathlib
import jax
import jax.numpy as jnp
import jaxlie
import jaxls
import numpy as np
Loading the dataset#
Parse the g2o file to extract poses and edges. Edges include both odometry (consecutive poses) and loop closures (revisited locations):
@jax.jit
def parse_precision_matrix(components: jax.Array) -> jax.Array:
"""Convert upper triangular components to sqrt precision matrix.
Args:
components: Upper triangular components of the precision matrix (6,)
Returns:
Upper Cholesky factor of the precision matrix (3, 3)
"""
precision = jnp.zeros((3, 3))
triu_indices = jnp.triu_indices(3)
precision = precision.at[triu_indices].set(components)
precision = precision + precision.T - jnp.diag(jnp.diag(precision))
return jnp.linalg.cholesky(precision).T
def parse_g2o(path: pathlib.Path) -> dict:
"""Parse a 2D g2o file into poses and edges.
Args:
path: Path to the g2o file
Returns:
Dictionary with 'poses' (N, 3) array and 'edges' list of tuples
"""
with open(path) as f:
lines = f.readlines()
poses = [] # (x, y, theta)
edges = [] # (i, j, dx, dy, dtheta, precision_components)
for line in lines:
parts = line.strip().split()
if not parts:
continue
if parts[0] == "VERTEX_SE2":
_, idx, x, y, theta = parts
poses.append((float(x), float(y), float(theta)))
elif parts[0] == "EDGE_SE2":
_, i, j = parts[:3]
dx, dy, dtheta = map(float, parts[3:6])
precision_comps = list(map(float, parts[6:]))
edges.append((int(i), int(j), dx, dy, dtheta, np.array(precision_comps)))
return {"poses": np.array(poses), "edges": edges}
# Load the Manhattan 3500 dataset.
g2o_path = pathlib.Path("./data/input_M3500_g2o.g2o")
data = parse_g2o(g2o_path)
num_poses = len(data["poses"])
num_edges = len(data["edges"])
# Count odometry vs loop closure edges.
odometry_edges = [(i, j) for i, j, *_ in data["edges"] if j == i + 1]
loop_closure_edges = [(i, j) for i, j, *_ in data["edges"] if j != i + 1]
print(f"Poses: {num_poses}")
print(
f"Edges: {num_edges} ({len(odometry_edges)} odometry, {len(loop_closure_edges)} loop closures)"
)
Poses: 3500
Edges: 5453 (3499 odometry, 1954 loop closures)
Variables and costs#
Use SE2Var for poses on SE(2). The between factor measures the relative pose between two nodes, weighted by a precision matrix.
The sqrt_precision matrix is the upper Cholesky factor of the information (precision) matrix \(\Lambda = \Sigma^{-1}\), where \(\Sigma\) is the measurement covariance. By multiplying the residual by this factor, we get a whitened residual whose squared norm equals the Mahalanobis distance: \(r^T \Lambda \, r = \|L^T r\|^2\).
# Create batched pose variables.
pose_vars = jaxls.SE2Var(id=jnp.arange(num_poses))
@jaxls.Cost.factory
def between_cost(
vals: jaxls.VarValues,
var_a: jaxls.SE2Var,
var_b: jaxls.SE2Var,
measured: jaxlie.SE2,
sqrt_precision: jax.Array,
) -> jax.Array:
"""Cost for relative pose measurement between two poses."""
T_a = vals[var_a]
T_b = vals[var_b]
# Error: measured^{-1} @ (T_a^{-1} @ T_b)
residual = (measured.inverse() @ (T_a.inverse() @ T_b)).log()
return sqrt_precision @ residual
@jaxls.Cost.factory(kind="constraint_eq_zero")
def anchor_cost(
vals: jaxls.VarValues,
var: jaxls.SE2Var,
target: jaxlie.SE2,
) -> jax.Array:
"""Anchor the first pose to prevent gauge freedom."""
return (vals[var].inverse() @ target).log()
# Build edge arrays for batched cost construction.
edge_i = jnp.array([e[0] for e in data["edges"]])
edge_j = jnp.array([e[1] for e in data["edges"]])
# Measured relative poses.
measured_poses = jaxlie.SE2.from_xy_theta(
jnp.array([e[2] for e in data["edges"]]),
jnp.array([e[3] for e in data["edges"]]),
jnp.array([e[4] for e in data["edges"]]),
)
# Sqrt precision matrices.
precision_comps = jnp.array([e[5] for e in data["edges"]])
sqrt_precisions = jax.vmap(parse_precision_matrix)(precision_comps)
print(f"Batched edge arrays: {edge_i.shape[0]} edges")
Batched edge arrays: 5453 edges
Solving#
Problem structure#
The problem structure connects pose variables through between factors (odometry and loop closures). With ~3500 poses and ~5600 edges, the visualization automatically limits nodes for performance:
# Create costs using batched construction.
costs: list[jaxls.Cost] = [
# All between factors in one batched call.
between_cost(
jaxls.SE2Var(id=edge_i),
jaxls.SE2Var(id=edge_j),
measured_poses,
sqrt_precisions,
),
# Anchor first pose.
anchor_cost(
jaxls.SE2Var(id=0),
jaxlie.SE2.from_xy_theta(
data["poses"][0, 0], data["poses"][0, 1], data["poses"][0, 2]
),
),
]
# Visualize the problem structure structure.
problem = jaxls.LeastSquaresProblem(costs, [pose_vars])
problem.show()
# Initial values from g2o file (odometry integration).
initial_poses = jaxlie.SE2.from_xy_theta(
jnp.array(data["poses"][:, 0]),
jnp.array(data["poses"][:, 1]),
jnp.array(data["poses"][:, 2]),
)
initial_vals = jaxls.VarValues.make([pose_vars.with_value(initial_poses)])
# Analyze the problem.
problem = problem.analyze()
INFO | Building optimization problem with 5454 terms and 3500 variables: 5453 costs, 1 eq_zero, 0 leq_zero, 0 geq_zero
INFO | Vectorizing group with 5453 costs, 2 variables each: between_cost
INFO | Vectorizing constraint group with 1 constraints (constraint_eq_zero), 1 variables each: augmented_anchor_cost
# Solve with Gauss-Newton (no trust region needed for this well-conditioned problem)
solution = problem.solve(initial_vals, trust_region=None)
INFO | Augmented Lagrangian: initial snorm=0.0000e+00, csupn=0.0000e+00, max_rho=1.0000e+07, constraint_dim=3
INFO | step #0: cost=2634708.0000 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 2634708.00000 (avg 161.05557)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=3.32e+04 cost_prev=2634708.0000 cost_new=57103.6992
INFO | step #1: cost=57103.6797 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 57103.67969 (avg 3.49066)
INFO | - augmented_anchor_cost(1): 0.01626 (avg 0.00542)
INFO | accepted=True ATb_norm=3.59e+03 cost_prev=57103.6992 cost_new=11494.6016
INFO | step #2: cost=11494.5996 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 11494.59961 (avg 0.70265)
INFO | - augmented_anchor_cost(1): 0.00040 (avg 0.00013)
INFO | accepted=True ATb_norm=1.23e+03 cost_prev=11494.6016 cost_new=3330.8013
INFO | step #3: cost=3330.8010 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 3330.80103 (avg 0.20361)
INFO | - augmented_anchor_cost(1): 0.00016 (avg 0.00005)
INFO | accepted=True ATb_norm=4.14e+02 cost_prev=3330.8013 cost_new=11190.6309
INFO | step #4: cost=11190.6309 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 11190.63086 (avg 0.68407)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.02e+03 cost_prev=11190.6309 cost_new=2311.1602
INFO | step #5: cost=2311.1599 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 2311.15991 (avg 0.14128)
INFO | - augmented_anchor_cost(1): 0.00001 (avg 0.00000)
INFO | accepted=True ATb_norm=4.05e+02 cost_prev=2311.1602 cost_new=737.6276
INFO | step #6: cost=737.6276 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 737.62756 (avg 0.04509)
INFO | - augmented_anchor_cost(1): 0.00002 (avg 0.00001)
INFO | accepted=True ATb_norm=1.60e+02 cost_prev=737.6276 cost_new=1370.3214
INFO | step #7: cost=1370.3215 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 1370.32153 (avg 0.08377)
INFO | - augmented_anchor_cost(1): 0.00001 (avg 0.00000)
INFO | accepted=True ATb_norm=2.83e+02 cost_prev=1370.3214 cost_new=183.5986
INFO | step #8: cost=183.5986 lambd=0.0000 inexact_tol=1.0e-02
INFO | - between_cost(5453): 183.59865 (avg 0.01122)
INFO | - augmented_anchor_cost(1): 0.00001 (avg 0.00000)
INFO | accepted=True ATb_norm=2.77e+01 cost_prev=183.5986 cost_new=219.7089
INFO | step #9: cost=219.7089 lambd=0.0000 inexact_tol=8.6e-03
INFO | - between_cost(5453): 219.70892 (avg 0.01343)
INFO | - augmented_anchor_cost(1): 0.00002 (avg 0.00001)
INFO | accepted=True ATb_norm=8.32e+01 cost_prev=219.7089 cost_new=145.7681
INFO | step #10: cost=145.7681 lambd=0.0000 inexact_tol=8.6e-03
INFO | - between_cost(5453): 145.76811 (avg 0.00891)
INFO | - augmented_anchor_cost(1): 0.00002 (avg 0.00001)
INFO | accepted=True ATb_norm=1.88e+00 cost_prev=145.7681 cost_new=152.7706
INFO | step #11: cost=152.7706 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 152.77055 (avg 0.00934)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=2.68e+01 cost_prev=152.7706 cost_new=138.1770
INFO | step #12: cost=138.1770 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 138.17703 (avg 0.00845)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=3.85e+00 cost_prev=138.1770 cost_new=137.9452
INFO | step #13: cost=137.9452 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 137.94522 (avg 0.00843)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.12e+00 cost_prev=137.9452 cost_new=137.9238
INFO | step #14: cost=137.9238 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 137.92375 (avg 0.00843)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=6.71e-01 cost_prev=137.9238 cost_new=137.9173
INFO | step #15: cost=137.9173 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 137.91733 (avg 0.00843)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=6.42e-01 cost_prev=137.9173 cost_new=137.9153
INFO | step #16: cost=137.9153 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 137.91534 (avg 0.00843)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.19e-01 cost_prev=137.9153 cost_new=137.9170
INFO | step #17: cost=137.9170 lambd=0.0000 inexact_tol=4.6e-04
INFO | - between_cost(5453): 137.91702 (avg 0.00843)
INFO | - augmented_anchor_cost(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.04e-01 cost_prev=137.9170 cost_new=137.9174
INFO | AL update: snorm=7.7177e-09, csupn=7.7177e-09, max_rho=1.0000e+07
INFO | Terminated @ iteration #18: cost=137.9174 criteria=[1 0 0], term_deltas=3.1e-06,1.0e-01,6.6e-05 (solved in 0.7154 sec)
Visualization#
Compare the initial odometry-only trajectory with the optimized result. Loop closures correct drift accumulated from odometry:
initial_xy = np.array(initial_vals[pose_vars].translation())
optimized_xy = np.array(solution[pose_vars].translation())
The optimization corrects drift accumulated from odometry-only integration. Loop closures (shown in red) constrain revisited locations to be consistent, resulting in a globally coherent map.
For more on Lie group variables, see jaxls.SE2Var and jaxls.SE3Var.