Skip to the content
FixTheRobot Explore Robotics / UAV / Module 4 / M4-T12

UAV Systems Engineering · Module 4

M4-T12: Pose graph optimization

Odometry drifts, loop closures pull it back: pose graph optimisation is the least-squares problem that reconciles them.

  • Lesson 12 of 15
  • SLAM and Autonomous Localization
  • Research-level
  • About 5 minutes to read

SLAM back-ends such as those in ORB-SLAM3 and VINS-Fusion correct drift by solving a graph of relative-pose constraints. The idea is small enough to implement in a page of code, and seeing it work explains both its power and its main failure mode, the false loop closure.

Learning objectives

After this lesson, you should be able to:

  • Define the pose graph, its edges and information matrices.
  • Write the least-squares objective and one Gauss–Newton step.
  • Explain the sparsity that makes large graphs tractable and the role of robust kernels.
  • Run a loop-closure optimisation and measure the drift removed.

Conceptual explanation

A pose graph has one node per estimated pose and one edge per measurement of a relative pose between two nodes: odometry edges between consecutive poses and loop-closure edges between poses that observed the same place. Odometry alone gives a chain whose errors accumulate; a loop closure supplies a constraint that does not depend on the path in between, and optimisation spreads the correction along the loop.

Pose graphPoses are nodes. Odometry constraints link consecutive poses. A loop-closure constraint links the last pose to the first, and optimisation pulls the drifted loop shut. loop closureodometry edges (drift accumulates) minimise Σ ‖ e_ij ‖²_Ωe_ij = measured − predictedrelative poseGauss–Newton on the sparsenormal equations
Odometry edges and one loop closure. The optimiser distributes the loop error over all the poses.
Animation of pose graph optimisation with an outlier. A square trajectory with good odometry and a correct loop closure is optimised well. A false loop closure that joins two unrelated places is then added: ordinary least squares tries to satisfy it and bends the whole trajectory out of shape. Switching to a robust loss reduces the weight of the inconsistent edge to almost zero, and the trajectory returns to its correct shape.
Pose graph optimisation minimises Σ ρ\Sigma \,\rho (‖rrᵢⱼ∥2\Vert ^{2}) over edges. With ρ(r2)=r2\rho (r^{2})=r^{2} one false loop closure distorts everything; a robust loss (Huber, Cauchy) down-weights edges with large residuals. Positions-only linear problem solved by iteratively reweighted least squares; the false loop is a 7 m inconsistency. (Looping, about 6 seconds; drawn and computed for this course with illustrative values.)

Engineering intuition

Picture a chain of springs, each with a rest length given by its measurement and a stiffness given by its information. Hold the first pose fixed and add the loop-closure spring between the last and the first: the chain stretches slightly everywhere to satisfy all springs at minimum total energy. Stiff (precise) edges move little; soft edges absorb the correction. A wrong loop closure is a spring attached to the wrong place: it distorts the whole map, which is why loop candidates are verified geometrically and why robust kernels are used.

Mathematical formulation

For poses xix_{i} and a measured relative pose zijz_{ij} with information matrix Ωij\Omega _{ij} (inverse covariance), the residual is the difference between the measurement and the predicted relative pose, and the problem is:

Pose-graph objective
x∗=arg⁡min⁡x∑(i,j)eij(x)⊤Ωij eij(x),eij(x)=zij⊖(xi−1⊕xj)\mathbf x^{*} = \arg\min_{\mathbf x}\sum_{(i,j)}\mathbf e_{ij}(\mathbf x)^{\top}\boldsymbol\Omega_{ij}\,\mathbf e_{ij}(\mathbf x), \qquad \mathbf e_{ij}(\mathbf x) = \mathbf z_{ij} \ominus \left(\mathbf x_i^{-1} \oplus \mathbf x_j\right)

In SE(2), with relative pose written in the frame of xix_{i}: ee = [RRᵢ⊤(pj−pi)−zp^{\top}(p_{j}-p_{i})-z_{p}, wrap(θj−θi−zθ)\mathrm{wrap}(\theta _{j}-\theta _{i}-z_{\theta })]. Gauss–Newton linearises e≈e0+J δxe\approx e_{0}+J\,\delta x and solves:

Normal equations (one Gauss–Newton step)
(∑J⊤Ω J)δx=−∑J⊤Ω e0\left(\sum \mathbf J^{\top}\boldsymbol\Omega\,\mathbf J\right)\delta\mathbf x = -\sum \mathbf J^{\top}\boldsymbol\Omega\,\mathbf e_0
x←x⊞δx\mathbf x \leftarrow \mathbf x \boxplus \delta\mathbf x

Each edge involves only two nodes, so JJ is sparse and J⊤ΩJJ^{\top}\Omega J is a sparse block matrix whose structure is the graph: large graphs (tens of thousands of poses) are solved with sparse Cholesky (g2o, GTSAM, Ceres). The solution is defined up to a global rigid transform, so one pose is fixed (a gauge constraint, the anchor).

Robust kernels

Replace the squared error by a robust function ρ(∥e∥)\rho (\Vert e\Vert ) (Huber, Cauchy, switchable constraints) so that one bad loop closure cannot dominate. Robustness reduces damage but does not replace geometric verification of loop candidates.

Architecture and data flow

Front-end: odometry (VIO or LIO) gives relative-pose edges; place recognition proposes loop candidates; geometric verification (registration or PnP with RANSAC) accepts or rejects them and produces relative-pose edges with covariance. Back-end: the optimiser updates all poses; the map (keyframes, points) is re-anchored. For a UAV in flight, a global correction changes the pose estimate abruptly: feed it to the controller through a smoothing layer, keep the local odometry frame continuous, and publish the correction as a transform between a map frame and an odom frame (as in the ROS convention).

Real-world UAV applications

  • Loop closure in ORB-SLAM3 and VINS-Fusion back-ends.
  • Merging maps from multiple flights or multiple vehicles.
  • Fusing GNSS as absolute-position edges when available.
  • Offline refinement of a mapping flight before using the map for localisation (lesson M4-T13).

Implementation example

This runs as shown: a 40-pose square loop with biased, noisy odometry. Optimising with one loop-closure edge removes most of the drift. Numerical Jacobians keep the example short; real systems use analytic ones and sparse solvers.

import numpy as np
rng = np.random.default_rng(1)
wrap = lambda a: (a + np.pi) % (2 * np.pi) - np.pi
def rel(a, b):                                   # pose b expressed in the frame of pose a
    d = b[:2] - a[:2]; c, s = np.cos(a[2]), np.sin(a[2])
    return np.array([c * d[0] + s * d[1], -s * d[0] + c * d[1], wrap(b[2] - a[2])])
def comp(a, u):                                  # pose a composed with the relative motion u
    c, s = np.cos(a[2]), np.sin(a[2])
    return np.array([a[0] + c * u[0] - s * u[1], a[1] + s * u[0] + c * u[1], wrap(a[2] + u[2])])

n = 40; truth = [np.zeros(3)]                    # square loop: 1 m steps, a 90-degree turn every 10 steps
for k in range(n): truth.append(comp(truth[-1], np.array([1.0, 0, np.pi / 2 if (k + 1) % 10 == 0 else 0.0])))
truth = np.array(truth)
odo = [rel(truth[k], truth[k + 1]) + np.array([0.02, 0.01, 0.01]) * rng.standard_normal(3) + np.array([0, 0, 0.004]) for k in range(n)]   # noise plus a heading bias
x = [np.zeros(3)]
for u in odo: x.append(comp(x[-1], u))
x = np.array(x)
w = np.array([1 / 0.02, 1 / 0.02, 1 / 0.01])
edges = [(k, k + 1, odo[k], w) for k in range(n)] + [(n, 0, rel(truth[n], truth[0]), 5 * w)]    # last edge: loop closure from place recognition

def residual(xf):
    X = xf.reshape(-1, 3); r = []
    for i, j, z, wt in edges:
        e = rel(X[i], X[j]) - z; e[2] = wrap(e[2]); r.append(e * np.sqrt(wt))
    r.append(X[0] * 1e3)                          # anchor the first pose (gauge)
    return np.concatenate(r)

xf = x.copy().ravel()
for it in range(20):                              # Gauss-Newton with numerical Jacobians
    r = residual(xf); J = np.zeros((len(r), len(xf)))
    for k in range(len(xf)):
        xp = xf.copy(); xp[k] += 1e-6; J[:, k] = (residual(xp) - r) / 1e-6
    dx = np.linalg.solve(J.T @ J + 1e-9 * np.eye(len(xf)), -J.T @ r); xf += dx
    if np.linalg.norm(dx) < 1e-8: break
X = xf.reshape(-1, 3)
err = lambda P: np.mean(np.linalg.norm(P[:, :2] - truth[:, :2], axis=1))
print(f"mean position error: odometry {err(x):.3f} m -> optimised {err(X):.3f} m; end-point error {np.linalg.norm(x[-1,:2]-truth[-1,:2]):.3f} -> {np.linalg.norm(X[-1,:2]-truth[-1,:2]):.4f} m")
assert err(X) < err(x) / 4 and np.linalg.norm(X[-1, :2] - truth[-1, :2]) < 0.01
SE(2) pose-graph optimisation with one loop closure.

Common engineering mistakes

Accepting loop closures without geometric verification

A single false loop can fold the whole map; verify and use robust kernels.

Forgetting the anchor

The system is under-constrained (gauge freedom): the solver drifts or fails.

Wrong information weighting

Edges with inflated confidence dominate; derive weights from sensor covariance.

Wrapping angles incorrectly

Heading residuals must be wrapped to (−π, π](-\pi ,\,\pi ].

Applying the correction as a jump to the controller

Publish it as a map-to-odom transform and smooth it.

Failure modes

Failure modes, symptoms and causes
Failure modeHow it shows upLikely cause
False loop closureMap suddenly distorted or foldedPerceptual aliasing in repeated structures, bad verification
No correction at the endDrift persists after a return to the startNo loop detected: place recognition failed or viewpoint changed
Slow solveOptimisation time grows with the mapDense solver on a large graph; use sparse solvers and keyframe pruning
Local minimumOptimisation gets stuck with large initial errorPoor initial guess with large heading error; use a robust initialisation

Debugging approach

  1. Plot the graph before and after optimisation with edge residuals coloured.
  2. Check each loop edge’s residual after convergence: large values flag bad closures.
  3. Run with and without the loop edge and compare the end-point error.
  4. Test the optimiser on synthetic data with known truth, as above.

Practical exercises

  1. Add a second, deliberately wrong loop closure and observe the damage, then add a Huber kernel.
  2. Replace numerical Jacobians with analytic ones and compare run time.
  3. Make the heading bias 0.02 rad per step and see how the end-point error changes.
  4. Count the non-zero blocks of J⊤JJ^{\top}J for the example and explain the sparsity pattern.

Interview questions

Write an answer in your own words, then reveal the model answer. Nothing is sent anywhere.

Question 1

What is a pose graph and why does it help?

Model answer

Nodes are poses, edges are relative-pose measurements; loop closures add constraints independent of the drifted path, and least-squares optimisation distributes the correction along the loop.

Question 2

Why is a false loop closure so harmful?

Model answer

It is a strong constraint pulling two unrelated places together, distorting every pose between them; hence geometric verification and robust kernels.

Question 3

Why is the problem sparse?

Model answer

Each edge connects only two nodes, so the Jacobian and normal matrix have a structure mirroring the graph, enabling sparse solvers for very large maps.

References and further reading

Primary papers, official documentation and standards. Each claim above that is specific to a tool or version points here.

What’s next

Mark this lesson

Saved in this browser and synced to your account when you are signed in.

Practice this lesson

M4-L5: Analyze trajectory drift

Compute ATE and RPE.

Next lesson

M4-T13: Map-based localization

Localise against a prior map with scan-to-map registration, an initial pose and a failure check.