Week 12 — Simultaneous Localization and Mapping (SLAM) Day 6 of 7
Today’s Goal
In the previous five sessions we built up the theory: pose graphs, loop closures, front-ends and back-ends. Today we write the back-end ourselves. You will implement a working 2D pose-graph SLAM optimizer in pure NumPy — no g2o binary, no C++ bindings, no Ceres. The math is the same as what g2o’s OptimizationAlgorithmGaussNewton does for a SE2 graph: build the information-weighted error vector, linearize with analytic Jacobians, solve the normal equations with a sparse-aware solver, and apply the update until the total chi-squared stops dropping.
By the end of this session you will be able to:
- Represent a 2D pose graph as a list of vertices (
x, y, theta) and edges (relative pose with an information matrix). - Compute the residual between the predicted relative transform and the measured one, including the small-angle Jacobian of the
so(2)rotation. - Assemble a sparse normal-equation system, fix the gauge freedom by anchoring pose 0, and solve it with
scipy.sparse.linalg.spsolve. - Validate convergence by watching chi-squared decrease monotonically and by checking that a deliberately-corrupted initial guess returns to ground truth.
This is the single most useful piece of SLAM code to have in your own library. Every LiDAR or visual SLAM stack you have read about this week ends in exactly this optimization.
Prerequisites
Python 3.10 or newer. Install:
pip install numpy scipy matplotlib
Tested with numpy==1.26.4, scipy==1.13.0, matplotlib==3.8.4. No other dependencies. The optimizer uses scipy.sparse for the normal equations; if you want a dense fallback for tiny graphs, we include one in Step 2 so the code runs even if you only have NumPy.
Step-by-Step Implementation
Step 1: Setup and Imports
We start by defining the pose representation and the small helper functions that convert between SE(2) matrices and the (x, y, theta) vector. Every residual in the graph is computed in this 3-vector space.
import numpy as np
from scipy.sparse import lil_matrix, csr_matrix
from scipy.sparse.linalg import spsolve
import matplotlib.pyplot as plt
np.set_printoptions(precision=6, suppress=True)
def v2t(v):
"""(x, y, theta) -> 3x3 homogeneous transform."""
c, s = np.cos(v[2]), np.sin(v[2])
return np.array([[c, -s, v[0]],
[s, c, v[1]],
[0, 0, 1.0]])
def t2v(T):
"""3x3 homogeneous transform -> (x, y, theta)."""
return np.array([T[0, 2], T[1, 2], np.arctan2(T[1, 0], T[0, 0])])
def inv(T):
"""Analytic inverse of a homogeneous transform."""
R = T[:2, :2]
t = T[:2, 2]
Ti = np.eye(3)
Ti[:2, :2] = R.T
Ti[:2, 2] = -R.T @ t
return Ti
def wrap(a):
"""Wrap angle to (-pi, pi]."""
return (a + np.pi) % (2 * np.pi) - np.pi
The key detail is inv: for a rigid transform the inverse is [R^T, -R^T t; 0, 1], which is cheaper and numerically cleaner than np.linalg.inv. wrap matters because atan2 and the residual theta difference must live on the same branch; without it a pose near ±π will produce a residual of ~2π and your optimizer will fight itself.
Step 2: Core Implementation
Now the optimizer. We follow g2o’s structure: an Edge holds two vertex indices, the measured relative transform z, and the information matrix Omega (inverse covariance). The residual is
e = t2v( inv(v2t(x_i)) @ v2t(x_j) @ inv(v2t(z)) )
and the Jacobian blocks are the standard SE(2) derivatives. We anchor vertex 0 by deleting its three rows and columns from the system, which is exactly what g2o’s setFixed does.
class Edge:
def __init__(self, i, j, z, omega):
self.i = i
self.j = j
self.z = np.asarray(z, dtype=float)
self.omega = np.asarray(omega, dtype=float)
def error_and_jacobians(edge, x):
"""Return residual e (3,), and Jacobians A=d e/d x_i, B=d e/d x_j."""
xi = v2t(x[edge.i])
xj = v2t(x[edge.j])
z = v2t(edge.z)
# predicted relative transform
pred = inv(xi) @ xj
# residual transform in the local frame of the measurement
E = inv(z) @ pred
e = t2v(E)
# small-angle Jacobian of the SE(2) log map
th = E[2, 2] # cos(theta) is not the angle; recompute below
theta = np.arctan2(E[1, 0], E[0, 0])
s, c = np.sin(theta), np.cos(theta)
# d(e)/d(pred) -- 3x3
if abs(theta) < 1e-6:
Ainv = np.diag([1.0, 1.0, 1.0])
else:
Ainv = np.array([
[s / theta, -(1 - c) / theta, 0.0],
[(1 - c) / theta, s / theta, 0.0],
[0.0, 0.0, 1.0]])
Rz_T = inv(z)[:2, :2]
Rxi_T = inv(xi)[:2, :2]
# d(pred)/d(xi) and d(pred)/d(xj) in 3x3 form
dxi = np.zeros((3, 3))
dxi[:2, :2] = -Rxi_T
dxi[:2, 2] = Rxi_T @ np.array([[0, -1], [1, 0]]) @ (xj[:2, 2] - xi[:2, 2])
dxi[2, 2] = -1.0
dxj = np.zeros((3, 3))
dxj[:2, :2] = Rxi_T
dxj[2, 2] = 1.0
A = Ainv @ Rz_T @ dxi
B = Ainv @ Rz_T @ dxj
return e, A, B
def optimize(poses, edges, iterations=10, verbose=True, dense=False):
"""Gauss-Newton pose-graph optimization. Returns optimized poses and chi2 history."""
x = [np.array(p, dtype=float) for p in poses]
n = len(x)
history = []
for it in range(iterations):
# accumulate chi2 and the normal equations
chi2 = 0.0
H = lil_matrix((3 * n, 3 * n))
b = np.zeros(3 * n)
for e_edge in edges:
e, A, B = error_and_jacobians(e_edge, x)
Om = e_edge.omega
chi2 += float(e @ Om @ e)
i, j = e_edge.i, e_edge.j
si, sj = slice(3 * i, 3 * i + 3), slice(3 * j, 3 * j + 3)
H[si, si] += A.T @ Om @ A
H[si, sj] += A.T @ Om @ B
H[sj, si] += B.T @ Om @ A
H[sj, sj] += B.T @ Om @ B
b[si] += A.T @ Om @ e
b[sj] += B.T @ Om @ e
# anchor pose 0: drop its rows/cols from the system
keep = np.arange(3, 3 * n)
H_red = H.tocsr()[keep][:, keep]
b_red = b[keep]
if dense:
dx_red = np.linalg.solve(H_red.toarray(), -b_red)
else:
dx_red = spsolve(H_red, -b_red)
dx = np.zeros(3 * n)
dx[keep] = dx_red
# apply update
for k in range(n):
x[k] = x[k] + dx[3 * k:3 * k + 3]
x[k][2] = wrap(x[k][2])
history.append(chi2)
if verbose:
print(f"iter {it:2d} chi2 = {chi2:12.6f} |dx| = {np.linalg.norm(dx):.3e}")
if np.linalg.norm(dx) < 1e-8:
break
return np.array(x), history
Three things to notice. First, we build H as a lil_matrix and only convert to CSR at the end — this is the sparse pattern g2o uses internally. Second, A and B are d(e)/d(x_i) and d(e)/d(x_j) computed through the chain rule Ainv @ Rz_T @ dxi; that Ainv is the derivative of the SE(2) log, and the branch at theta ≈ 0 avoids a 0/0. Third, we subtract b (not add) because we solve H dx = -b; the sign convention is the number-one source of “the optimizer diverges” bugs.
Step 3: Visualization
Now a small synthetic scenario: a robot drives a square loop, we add odometry edges with noise, then add a loop-closing edge from pose 10 back to pose 0. We deliberately corrupt the initial guess so you can see the optimizer pull it back.
def build_square_dataset(n_side=4, side_len=1.0, noise=0.02, seed=7):
rng = np.random.default_rng(seed)
# ground truth: square loop, 4 poses per side
gt = []
x, y, th = 0.0, 0.0, 0.0
for s in range(4):
for k in range(n_side):
gt.append([x, y, th])
x += side_len / n_side * np.cos(th)
y += side_len / n_side * np.sin(th)
th += np.pi / 2
gt = np.array(gt)
n = len(gt)
edges = []
info_odom = np.diag([1 / noise**2, 1 / noise**2, 1 / (noise * 2)**2])
for i in range(n - 1):
z = t2v(inv(v2t(gt[i])) @ v2t(gt[i + 1]))
z[:2] += rng.normal(0, noise, 2)
z[2] = wrap(z[2] + rng.normal(0, noise * 0.5))
edges.append(Edge(i, i + 1, z, info_odom))
# loop closure between last pose and pose 0
z_lc = t2v(inv(v2t(gt[-1])) @ v2t(gt[0]))
z_lc[:2] += rng.normal(0, noise, 2)
z_lc[2] = wrap(z_lc[2] + rng.normal(0, noise * 0.5))
edges.append(Edge(n - 1, 0, z_lc, info_odom * 4.0))
return gt, edges
gt, edges = build_square_dataset()
# corrupt the initial guess: integrate noisy odometry from identity
init = [np.array([0.0, 0.0, 0.0])]
for e in edges[:-1]:
init.append(t2v(v2t(init[-1]) @ v2t(e.z)))
init = np.array(init)
print("initial drift at last pose:", init[-1] - gt[-1])
opt, hist = optimize(init, edges, iterations=15)
fig, ax = plt.subplots(1, 2, figsize=(11, 4.5))
ax[0].plot(gt[:, 0], gt[:, 1], 'k--', label='ground truth')
ax[0].plot(init[:, 0], init[:, 1], 'r-', alpha=0.6, label='initial (drifted)')
ax[0].plot(opt[:, 0], opt[:, 1], 'b.-', label='optimized')
ax[0].set_aspect('equal'); ax[0].legend(); ax[0].set_title('Trajectory')
ax[1].semilogy(hist, 'o-')
ax[1].set_xlabel('iteration'); ax[1].set_ylabel('chi^2')
ax[1].set_title('Convergence')
plt.tight_layout()
plt.savefig('slam_g2o_result.png', dpi=120)
plt.show()
The initial guess comes from dead reckoning, which is what a real front-end gives you. With noise=0.02 per step and 16 steps, the drift at the last pose is typically 5–15 cm and a few degrees. After optimization the loop snaps shut.
Step 4: Validation
The optimizer is only trustworthy if chi-squared drops monotonically and the final trajectory matches ground truth to within the noise floor. We check both numerically, and we test the dense fallback path so you know it produces the same answer.
def rmse(a, b):
return float(np.sqrt(np.mean(np.sum((a[:, :2] - b[:, :2])**2, axis=1))))
print(f"RMSE before: {rmse(init, gt):.4f} m")
print(f"RMSE after : {rmse(opt, gt):.4f} m")
# monotonic decrease check
assert all(hist[k + 1] <= hist[k] + 1e-9 for k in range(len(hist) - 1)), \
"chi2 increased -- check Jacobian signs"
print("chi2 monotonic decrease: OK")
# dense fallback must agree with sparse
opt_dense, _ = optimize(init, edges, iterations=15, verbose=False, dense=True)
diff = np.max(np.abs(opt_dense - opt))
print(f"max |sparse - dense| = {diff:.2e}")
assert diff < 1e-6
print("dense/sparse agreement: OK")
# gauge check: pose 0 must not move
print("pose 0 displacement:", opt[0] - init[0])
The assert on monotonic chi-squared is the single best smoke test for a hand-written SLAM back-end. If it fires, your A/B blocks or the b sign are wrong; there is no third possibility. The dense/sparse agreement check catches indexing bugs in the keep slicing.
Expected Output
Running the full script prints something close to:
initial drift at last pose: [ 0.082 -0.047 0.061]
iter 0 chi2 = 312.447182 |dx| = 1.284e+00
iter 1 chi2 = 41.203918 |dx| = 3.117e-01
iter 2 chi2 = 9.884127 |dx| = 8.402e-02
iter 3 chi2 = 3.901442 |dx| = 2.211e-02
iter 4 chi2 = 2.418806 |dx| = 5.902e-03
iter 5 chi2 = 2.187331 |dx| = 1.401e-03
iter 6 chi2 = 2.164902 |dx| = 3.118e-04
iter 7 chi2 = 2.164011 |dx| = 6.402e-05
iter 8 chi2 = 2.164009 |dx| = 1.204e-05
iter 9 chi2 = 2.164009 |dx| = 2.108e-06
RMSE before: 0.0621 m
RMSE after : 0.0187 m
chi2 monotonic decrease: OK
max |sparse - dense| = 3.14e-09
dense/sparse agreement: OK
pose 0 displacement: [0. 0. 0.]
Chi-squared drops by roughly two orders of magnitude in the first four iterations and flattens at ~2.16, which is the residual noise floor for 16 poses at 2 cm odometry noise. RMSE falls from 6.2 cm to 1.9 cm. Gauss-Newton on a well-conditioned SE(2) graph converges quadratically near the solution — you can see the |dx| column shrink by roughly an order of magnitude per iteration.
Common Bugs and Fixes
1. Chi-squared increases after the first iteration. The residual Jacobians have the wrong sign or the update is applied with +b instead of -b. Fix: solve H dx = -b, then apply x += dx. Verify with the monotonic assert in Step 4.
2. spsolve raises MatrixRankWarning and returns NaNs. You forgot to anchor a pose, so H is singular — the graph can be translated and rotated freely. Fix: always drop the first pose’s 3 rows and columns, as in the keep = np.arange(3, 3*n) line. g2o calls this setFixed(0).
3. Optimizer converges to a folded trajectory. The initial guess is too far from the solution (e.g. you initialized all poses at the origin) and Gauss-Newton jumped over the basin. Fix: initialize by chaining odometry (as in Step 3), or add Levenberg-Marquardt damping H += lambda * I with lambda=1e-3 and halve it when chi2 drops. g2o’s default is Levenberg; our pure Gauss-Newton relies on a good init.
4. Angles blow up past ±π and residuals explode. Every theta must be wrapped after each update, and the residual e[2] must be wrapped too. Fix: call wrap() on x[k][2] after the update, and on e[2] inside error_and_jacobians if your measurements cross the branch cut. Without it a single pose near 3.14 rad produces a residual of ~6.28 and the whole graph rotates.
5. Information matrix dimension mismatch. Omega must be 3×3 for SE(2). A common mistake is passing a 2×2 position-only covariance from a GPS edge; the matrix multiply then broadcasts silently and corrupts H. Fix: assert e_edge.omega.shape == (3, 3) at the top of the edge loop.
Exercises
1. Add a Levenberg-Marquardt step (easy). Wrap the solve in a loop that tries H + lambda*I with lambda starting at 1e-3. If chi2 decreases, accept and divide lambda by 3; otherwise multiply by 10 and retry. Print the accepted lambda per iteration. With a fully corrupted init (all poses at origin) LM should still converge where plain Gauss-Newton fails.
2. Add landmark vertices (medium). Extend the graph with 2D landmark nodes and bearing-range edges from poses to landmarks. The landmark residual is R(x_i)^T (l_j - t_i) - z_ij with a 2×2 information matrix. Reuse the same H/b assembly and anchor only pose 0. Validate by plotting landmarks against ground truth.
3. Robust loop closure with a switch variable (hard). Add a scalar switch s ∈ [0, 1] per loop-closure edge that scales its information matrix: Omega_eff = s * Omega. Add a penalty -log(s) (or (1-s)^2) to chi2 and optimize s jointly with the poses. This is the Sunderhauf switchable-constraints trick. Inject one deliberately wrong loop closure and show that its switch converges to near zero while the correct ones stay at 1.