Self-assessment guide: If you can build a simple pose graph and optimize it, you understand the core SLAM backend. The implementation exercises use Python — the concepts transfer directly to g2o/GTSAM.
A1. Explain why the Hessian $H = J^T \Omega J$ of a pose-graph problem is sparse. Draw the sparsity pattern for a chain of 4 poses with one loop closure between pose 0 and pose 3.
x0 x1 x2 x3
x0 [ X X . X ] x0 connects to x1 (odom), x3 (loop closure)
x1 [ X X X . ] x1 connects to x0, x2
x2 [ . X X X ] x2 connects to x1, x3
x3 [ X . X X ] x3 connects to x2, x0
X = 3×3 non-zero block (SE2 has 3 DOF)
. = 3×3 zero block
Without the loop closure (0→3), $H$ would be tri-diagonal (only adjacent blocks). The loop closure adds off-diagonal blocks at $(0,3)$ and $(3,0)$, creating **fill-in** during Cholesky factorization.
A2. What is the difference between a prior factor and a between factor in a factor graph? Why do you need at least one prior?
A3. A loop closure detector reports a match between pose 47 and pose 3, but the match is actually wrong (perceptual aliasing). What happens to the optimized map if you: 1. Use standard least squares? 2. Use Huber loss on edges? 3. Use Switchable Constraints?
B1. Build a 1D pose graph and optimize it.
Scenario: 5 poses on a line. Odometry says each step is 1.0m. A "loop closure" says pose 4 is 3.8m from pose 0 (not 4.0m — there was drift). Optimize the poses.
import numpy as np
def optimize_1d_pose_graph(odom_edges, loop_edges, prior_pose, prior_sigma,
odom_sigma, loop_sigma, max_iter=20):
"""
1D pose-graph optimization using Gauss-Newton.
odom_edges: list of (from, to, measurement)
loop_edges: list of (from, to, measurement)
prior_pose: (index, value)
Returns: optimized poses
"""
# TODO: implement
# 1. Initialize poses from odometry
# 2. Build Jacobian and residual vector
# 3. Solve H @ dx = -b
# 4. Update poses
# 5. Repeat until convergence
pass
# Odometry: 0→1: 1.0, 1→2: 1.0, 2→3: 1.0, 3→4: 1.0
odom_edges = [(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 4, 1.0)]
# Loop closure: 0→4: 3.8 (not 4.0 — accumulated drift)
loop_edges = [(0, 4, 3.8)]
# Prior: pose 0 at 0.0
prior = (0, 0.0)
poses = optimize_1d_pose_graph(odom_edges, loop_edges, prior,
prior_sigma=0.01, odom_sigma=0.1, loop_sigma=0.1)
# Expected: poses spread drift evenly → [0, 0.95, 1.90, 2.85, 3.80]
import numpy as np
def optimize_1d_pose_graph(odom_edges, loop_edges, prior_pose, prior_sigma,
odom_sigma, loop_sigma, max_iter=20):
n = max(max(e[1] for e in odom_edges),
max(e[1] for e in loop_edges)) + 1
# Initialize from odometry chain
poses = np.zeros(n)
for (i, j, z) in odom_edges:
if j > i:
poses[j] = poses[i] + z
prior_info = 1.0 / prior_sigma**2
odom_info = 1.0 / odom_sigma**2
loop_info = 1.0 / loop_sigma**2
for iteration in range(max_iter):
H = np.zeros((n, n))
b = np.zeros(n)
total_cost = 0
# Prior factor
idx, val = prior_pose
e = poses[idx] - val
H[idx, idx] += prior_info
b[idx] += prior_info * e
total_cost += 0.5 * prior_info * e**2
# Odometry factors
for (i, j, z) in odom_edges:
e = (poses[j] - poses[i]) - z # residual
# Jacobian: de/dxi = -1, de/dxj = +1
H[i, i] += odom_info
H[j, j] += odom_info
H[i, j] -= odom_info
H[j, i] -= odom_info
b[i] -= odom_info * e
b[j] += odom_info * e
total_cost += 0.5 * odom_info * e**2
# Loop closure factors
for (i, j, z) in loop_edges:
e = (poses[j] - poses[i]) - z
H[i, i] += loop_info
H[j, j] += loop_info
H[i, j] -= loop_info
H[j, i] -= loop_info
b[i] -= loop_info * e
b[j] += loop_info * e
total_cost += 0.5 * loop_info * e**2
# Solve
dx = np.linalg.solve(H, -b)
poses += dx
if np.linalg.norm(dx) < 1e-10:
break
return poses
odom_edges = [(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 4, 1.0)]
loop_edges = [(0, 4, 3.8)]
prior = (0, 0.0)
poses = optimize_1d_pose_graph(odom_edges, loop_edges, prior,
prior_sigma=0.01, odom_sigma=0.1, loop_sigma=0.1)
print("Optimized poses:", [f"{p:.3f}" for p in poses])
# [0.000, 0.950, 1.900, 2.850, 3.800] — drift spread evenly
The 0.2m total drift (4.0 expected vs 3.8 measured) is distributed evenly across the 4 odometry edges: each "loses" 0.05m.
B2. Extend B1 to 2D (SE2). Use 4 poses in a square with a loop closure. Each pose is $(x, y, \theta)$.
Hint: The residual for an SE2 between factor is: $$e_{ij} = \begin{bmatrix} R_i^T (t_j - t_i) - t_{ij}^{\text{meas}} \\ \theta_j - \theta_i - \theta_{ij}^{\text{meas}} \end{bmatrix}$$
C1. The OKS navigation estimator uses an EKF with sensorbar (landmark-based) corrections. In a factor-graph view: