3D Vision and Depth

Visual SLAM

Visual SLAM builds a map while working out where the camera is inside it, and the thing that keeps it honest is recognising a place you have already been.

On this page 10
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. Why drift is unavoidable
  5. What loop closure does
  6. The two halves of a SLAM system
  7. Where you have already seen it
  8. The honest part
  9. Remember this
  10. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

SLAM means building a map of a place while at the same time working out where you are inside it.

SLAM stands for simultaneous localisation and mapping. Localisation means "where am I". Mapping means "what does this place look like".

The analogy you have already lived

Think of walking into an unfamiliar shopping mall with no map and no signal. You wander, and you build a rough picture in your head. This shop, then the escalator, then the food court.

You are doing both jobs at once, and each helps the other. Knowing roughly where you are lets you place a new shop on your mental map. The map lets you work out where you are.

Now the important part. Twenty minutes later you turn a corner and think "wait, I have been here before". Your whole mental map instantly shifts and snaps into shape.

That moment is called a loop closure, and it is what makes SLAM work.

Why it exists

Structure from motion, from the last lesson, works on a folder of photos with all the time in the world. SLAM has to work live, at thirty frames a second, on a robot that cannot see the future.

That changes everything. You cannot wait for all the data. You must produce an answer now, and correct it later.

Why drift is unavoidable

Each frame, you estimate how far the camera moved since the last one. Each estimate is slightly wrong — perhaps a centimetre.

Then you add them all up. Errors do not cancel. They accumulate.

In the run below, a robot drives a six-metre square and comes back to where it started. Every single step is out by about a centimetre. After forty-eight steps it believes it is 34 centimetres from where it actually is.

Nothing is broken. This is what adding up small errors does, and it is why plain step-by-step tracking, called odometry, always drifts.

What loop closure does

Then the camera recognises the starting place. That gives one new fact: pose 48 and pose 0 are the same place.

That single fact is enough to fix the whole path. The system slides all forty-eight poses slightly, spreading the correction backwards over the loop until everything is consistent.

   dead reckoning:   start ......................> ends 34 cm off
                          \                      /
   loop closure says:      these two are the same place
                          /                      \
   after adjusting:  start ......................> ends 0.3 cm off

In the run below the final drift falls from 34 centimetres to 3 millimetres. The average error over the whole path halves, with no new measurement of any middle pose.

The two halves of a SLAM system

The front end looks at pictures. Find features, match them to the last frame, estimate the motion, and spot when a place has been seen before.

The back end does the arithmetic. Hold all the poses and all the constraints, and find the arrangement that best satisfies them.

The code below is a back end, working end to end.

Where you have already seen it

  • A robot vacuum learning the shape of your home.
  • A VR headset that lets you walk around without external sensors.
  • Drones holding position indoors with no GPS.
  • AR apps that keep a virtual object in place as you walk away and come back.
  • Self-driving cars localising against a prior map.

The honest part

Loop closure can be wrong, and a wrong one is catastrophic.

Two identical corridors in an office building look the same to a camera. If the system decides they are the same place, it folds the map in half and the result is unrecoverable.

Real systems therefore check candidates hard before accepting them. Modern back ends can also switch a bad closure off after the fact.

Remember this

  • SLAM maps and localises at the same time, live.
  • Step-by-step tracking always drifts, because small errors add up.
  • Recognising an old place fixes the whole path at once, and a wrong recognition ruins it.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install numpy==1.26.4 scipy==1.14.1

The front end of a SLAM system is feature matching and pose estimation, which we have already built. This lesson builds the back end: a pose graph, and the optimisation that makes a loop closure work.

A pose graph, with drift and its cure

pose_graph.py
import numpy as np
from scipy.optimize import least_squares

rng = np.random.default_rng(4)
STEPS_PER_SIDE, SIDE = 12, 0.5          # a square loop, 6 m on a side


def wrap(a):
    return (a + np.pi) % (2 * np.pi) - np.pi


# The truth: drive four sides of a square and come back to the start.
true_poses = [np.array([0.0, 0.0, 0.0])]
moves = []
for side in range(4):
    for step in range(STEPS_PER_SIDE):
        turn = np.pi / 2 if step == STEPS_PER_SIDE - 1 else 0.0
        moves.append(np.array([SIDE, 0.0, turn]))
for dx, dy, dth in moves:
    x, y, th = true_poses[-1]
    true_poses.append(np.array([x + dx * np.cos(th), y + dx * np.sin(th), wrap(th + dth)]))
true_poses = np.array(true_poses)
N = len(true_poses)
print(f"{N} poses around a {STEPS_PER_SIDE * SIDE:.0f} m square")
print(f"the robot ends where it started: {np.round(true_poses[-1], 3)}\n")

# What the robot actually measures: each step, with small errors that never cancel.
odom = np.array([m + rng.normal(0, [0.02, 0.01, 0.012]) for m in moves])

dead = [np.array([0.0, 0.0, 0.0])]
for dx, dy, dth in odom:
    x, y, th = dead[-1]
    dead.append(np.array([x + dx * np.cos(th) - dy * np.sin(th),
                          y + dx * np.sin(th) + dy * np.cos(th), wrap(th + dth)]))
dead = np.array(dead)
print("dead reckoning: add every measured step, trusting each one")
print(f"   final position {np.round(dead[-1, :2], 3)}, should be [0. 0.]")
print(f"   drift {np.linalg.norm(dead[-1, :2]):.3f} m after {N - 1} steps")
print(f"   heading off by {np.degrees(wrap(dead[-1, 2])):.2f} degrees\n")
print("   nothing is broken. Each step is out by a centimetre, and errors add up.\n")

# The loop closure: the camera recognises the starting place and measures the
# relative pose to it. One extra constraint, and it ties the whole loop together.
loop = np.array([0.0, 0.0, wrap(-2 * np.pi)]) + rng.normal(0, [0.02, 0.02, 0.01])
print(f"loop closure measured between pose {N - 1} and pose 0: {np.round(loop, 3)}\n")

edges = [(i, i + 1, odom[i], np.array([1 / 0.02, 1 / 0.01, 1 / 0.012])) for i in range(N - 1)]
edges.append((N - 1, 0, loop, np.array([1 / 0.02, 1 / 0.02, 1 / 0.01])))
print(f"pose graph: {N} nodes, {len(edges)} edges "
      f"({N - 1} odometry + 1 loop closure)")


def between(a, b):
    """Pose b expressed in the frame of pose a."""
    c, s = np.cos(a[2]), np.sin(a[2])
    d = b[:2] - a[:2]
    return np.array([c * d[0] + s * d[1], -s * d[0] + c * d[1], wrap(b[2] - a[2])])


def residuals(v):
    poses = np.vstack([[0.0, 0.0, 0.0], v.reshape(-1, 3)])   # pose 0 pinned at the origin
    out = []
    for i, j, z, w in edges:
        e = between(poses[i], poses[j]) - z
        e[2] = wrap(e[2])
        out.append(e * w)                                    # weight by measurement trust
    return np.concatenate(out)


start = dead[1:].ravel()
print(f"cost before optimising: {0.5 * (residuals(start) ** 2).sum():.1f}")
sol = least_squares(residuals, start, method="trf", xtol=1e-12, ftol=1e-12)
print(f"cost after  optimising: {0.5 * (sol.fun ** 2).sum():.1f}")
opt = np.vstack([[0.0, 0.0, 0.0], sol.x.reshape(-1, 3)])

print(f"\nafter optimisation:")
print(f"   final position {np.round(opt[-1, :2], 3)}")
print(f"   drift {np.linalg.norm(opt[-1, :2]):.3f} m")

for name, path in [("dead reckoning", dead), ("after loop closure", opt)]:
    e = np.linalg.norm(path[:, :2] - true_poses[:, :2], axis=1)
    print(f"\n{name}: error against the true path")
    print(f"   mean {e.mean():.3f} m   worst {e.max():.3f} m   final {e[-1]:.3f} m")

print("\nthe corner positions, before and after:")
print(f"   {'corner':<8}{'true':<20}{'dead reckoning':<22}{'optimised'}")
for k in range(1, 5):
    i = k * STEPS_PER_SIDE
    print(f"   {k:<8}{str(np.round(true_poses[i, :2], 2)):<20}"
          f"{str(np.round(dead[i, :2], 2)):<22}{np.round(opt[i, :2], 2)}")
print("\nno new measurement of the middle poses was taken. One constraint at the end")
print("redistributed the error backwards over the whole loop.")
Output
49 poses around a 6 m square
the robot ends where it started: [0. 0. 0.]

dead reckoning: add every measured step, trusting each one
   final position [0.116 0.318], should be [0. 0.]
   drift 0.338 m after 48 steps
   heading off by -3.97 degrees

   nothing is broken. Each step is out by a centimetre, and errors add up.

loop closure measured between pose 48 and pose 0: [-0.     0.002 -0.009]

pose graph: 49 nodes, 49 edges (48 odometry + 1 loop closure)
cost before optimising: 174.5
cost after  optimising: 1.6

after optimisation:
   final position [ 0.003 -0.001]
   drift 0.003 m

dead reckoning: error against the true path
   mean 0.253 m   worst 0.507 m   final 0.338 m

after loop closure: error against the true path
   mean 0.112 m   worst 0.223 m   final 0.003 m

the corner positions, before and after:
   corner  true                dead reckoning        optimised
   1       [6. 0.]             [ 6.05 -0.02]         [6.02 0.11]
   2       [6. 6.]             [6.28 6.02]           [5.83 6.14]
   3       [0. 6.]             [0.34 6.36]           [-0.15  6.05]
   4       [0. 0.]             [0.12 0.32]           [ 0. -0.]

no new measurement of the middle poses was taken. One constraint at the end
redistributed the error backwards over the whole loop.

Reading the output carefully

drift 0.338 m after 48 steps, from per-step noise of 2 cm and 0.7 degrees. That is drift growing faster than the per-step error, because a heading error at step 5 displaces every later pose. Angular error is the dangerous one in odometry, and it is why an IMU gyroscope is nearly always fused in.

heading off by -3.97 degrees after four right-angle turns. Each turn contributes about 0.7 degrees of error and they add.

Cost 174.5 down to 1.6. Before optimisation the loop-closure edge is violated by 34 cm, which is enormous relative to its 2 cm uncertainty, and it dominates the cost. After, every edge is satisfied to within its noise.

Final drift 0.338 m to 0.003 m, and mean path error 0.253 to 0.112. Look at both. The endpoint is nearly perfect because the loop closure constrains it directly. The middle of the path improved by roughly half, which is the real gain — the correction is spread over the whole loop rather than dumped at the join.

Corner 2 got slightly worse: [6.28 6.02] to [5.83 6.14], against a truth of [6. 6.]. Being honest about this matters. Pose-graph optimisation minimises total weighted error, and a specific pose can move away from the truth while the whole path improves. It is a global fit, not a per-pose correction.

49 nodes, 49 edges. One more edge than a chain has. That single extra edge is the entire difference between a drifting trajectory and a consistent map.

Weights are not decoration

Look at the weight vectors: 1/0.02 for forward motion, 1/0.01 for sideways, 1/0.012 for rotation. A wheeled robot knows its sideways motion better than its forward motion, because it barely moves sideways at all.

These are inverse standard deviations, so the residual is measured in units of "how many standard deviations off". Getting them wrong is the most common reason a pose graph produces a worse answer than dead reckoning: an over-trusted loop closure will bend a correct trajectory to reach it.

Doing it for real

Do not write a SLAM system. Use one.

SystemTypeNotes
ORB-SLAM3Feature-based, mono / stereo / RGB-D, visual-inertialThe classical reference. Robust, CPU-only, mature
VINS-FusionOptimisation-based visual-inertialStrong on drones and phones
RTAB-MapRGB-D graph SLAMPractical, ROS-friendly, good appearance-based loop closure
DROID-SLAMLearned, denseVery robust, needs a GPU
OpenVSLAM / stella_vslamFeature-basedCommunity-maintained ORB-SLAM successor

For the back end alone, g2o, GTSAM and Ceres are the standard solvers, and all three exploit the sparsity that our scipy version ignores.

Common mistakes

Not fixing a gauge. The graph is invariant to a global rigid motion, so the system is rank-deficient by 3 in 2D and 6 in 3D. Our code pins pose 0. Without that, the solver wanders.

Forgetting to wrap angles. A residual of +3.13 and one of -3.15 radians are nearly the same rotation. Without wrap, the optimiser sees a difference of 6.28 and tears the graph apart. This is the single most common bug in hand-written pose-graph code.

Computing residuals in the global frame. The error must be expressed in the frame of the first pose of the edge, as between does. A global-frame difference gives the wrong Jacobian and converges badly.

Accepting a loop closure on appearance alone. Verify geometrically: match features, fit a pose with RANSAC, and require a healthy inlier count. Then verify temporally: require several consecutive frames to agree.

Treating a pose graph as the whole of SLAM. Pose-graph optimisation ignores the landmarks. Full bundle adjustment over poses and points is more accurate and far more expensive, which is why real systems do local bundle adjustment on a window of keyframes plus global pose-graph optimisation on loop closure.

Try it yourself

Set the loop-closure weight to [1/0.5, 1/0.5, 1/0.5], telling the optimiser that measurement is poor. Rerun. The final drift will barely improve, because the odometry chain now outvotes it. Then set it to [1/0.001]*3 and watch the corners distort as the solver bends everything to satisfy one over-trusted edge.

What to learn next

Researcher — Mathematics and papers.

The estimation problem

SLAM is maximum a posteriori inference over a trajectory $\mathcal{X} = {\mathbf{x}_0 \dots \mathbf{x}_T}$ and a map $\mathcal{M} = {\mathbf{m}_1 \dots \mathbf{m}_K}$ given measurements $\mathcal{Z}$:

$$ \mathcal{X}^, \mathcal{M}^ = \arg\max \; p(\mathcal{X}, \mathcal{M} \mid \mathcal{Z}) $$

With Gaussian noise this becomes nonlinear least squares:

$$ \min_{\mathcal{X}, \mathcal{M}} \sum_{k} \bigl\lVert h_k(\mathcal{X}, \mathcal{M}) - \mathbf{z}k \bigr\rVert^2{\Sigma_k} $$

where $\lVert \mathbf{e} \rVert^2_{\Sigma} = \mathbf{e}^\top \Sigma^{-1} \mathbf{e}$ is the Mahalanobis norm. The weights in the code are $\Sigma^{-1/2}$, folded into the residual so an ordinary least-squares solver produces the correct answer.

Pose-graph SLAM marginalises out the landmarks, leaving only relative-pose constraints between poses. It is cheaper and less accurate than full bundle adjustment, and it is the right structure for large-scale loop closure.

Manifolds

Poses live on $SE(2)$ or $SE(3)$, which are Lie groups, not vector spaces. Two consequences.

Composition is not addition. The relative pose is $\mathbf{z}_{ij} = \mathbf{x}_i^{-1} \circ \mathbf{x}j$, which is exactly what between computes. The residual is $\log\bigl(\hat{\mathbf{z}}{ij}^{-1} \circ \mathbf{x}_i^{-1} \circ \mathbf{x}_j\bigr)$, using the logarithm map into the Lie algebra $\mathfrak{se}(3)$.

Increments must be applied on the manifold. The optimiser works in a local tangent space and retracts via the exponential map, $\mathbf{x} \leftarrow \mathbf{x} \circ \exp(\delta)$. Adding a Euclidean increment to a rotation matrix leaves the manifold.

Our 2D code hides this: the wrap call is exactly the $SO(2)$ logarithm map, and the rest happens to work because $SO(2)$ is one-dimensional and commutative. In 3D it must be done properly, which is why GTSAM and Sophus exist.

Sparsity

The information matrix $\Lambda = J^\top \Sigma^{-1} J$ has non-zeros only where two poses share a constraint. For a trajectory with a few loop closures it is banded plus a few off-diagonal entries, so a sparse Cholesky factorisation under a good variable ordering is close to linear in the number of poses.

Kaess et al. (2008, 2012), iSAM and iSAM2, exploit this incrementally: new measurements update the factorisation rather than rebuilding it, and only the affected variables are relinearised — using a data structure called the Bayes tree. This is what makes real-time back ends possible.

Historically, EKF-SLAM maintained a dense covariance over poses and landmarks at $O(K^2)$ cost per update, and became intractable beyond a few hundred landmarks. The move to sparse smoothing around 2006-2010 is the single biggest step change in the field's history.

Loop closure and its failure

The front end proposes closures by appearance. Two lineages:

  • Bag of visual words. DBoW2 (Gálvez-López and Tardós, T-RO 2012) quantises ORB descriptors into a vocabulary tree and retrieves similar images by inverted index. Fast, CPU-only, and what ORB-SLAM uses.
  • Learned place recognition. NetVLAD and its successors produce a global descriptor per image, more robust to lighting and season change, at GPU cost.

FAB-MAP (Cummins and Newman, IJRR 2008) is the important theoretical contribution: a probabilistic appearance model that accounts for the fact that visual words co-occur, so that seeing a generic word carries little evidence. Without such a correction, perceptual aliasing dominates.

A false closure is catastrophic, so the back end needs protection. Switchable constraints (Sünderhauf and Protzel, 2012) attach a learned switch variable $s_{ij} \in [0,1]$ to each loop edge, scaling its information matrix and penalising deviation from 1. The optimiser can then turn a bad closure off. Dynamic covariance scaling and max-mixture models achieve similar robustness. Any deployed system needs one of these.

Visual-inertial fusion

An IMU changes the problem qualitatively rather than incrementally.

  • It makes scale observable. A monocular camera alone cannot recover metric scale, exactly as in structure from motion. An accelerometer measures metres per second squared, which supplies it.
  • It makes roll and pitch observable through the gravity direction.
  • It bridges short tracking failures — motion blur, a covered lens, a textureless wall.

The technical key is IMU pre-integration (Forster et al., T-RO 2017), which integrates high-rate IMU measurements between two keyframes into a single relative-motion factor whose value does not need recomputing when the linearisation point changes. Without it, every optimiser iteration would re-integrate thousands of samples.

ORB-SLAM3 (Campos et al., T-RO 2021) combines this with multi-map handling: when tracking is lost, it starts a new map and merges it later if a place is recognised, rather than discarding everything.

Learned SLAM

DROID-SLAM (Teed and Deng, NeurIPS 2021) replaces the feature front end with dense optical flow from a RAFT-style recurrent network, and iteratively updates poses and per-pixel depth through a differentiable dense bundle-adjustment layer. It is markedly more robust than feature-based systems on hard sequences, and it requires a substantial GPU.

The broader 2025-2026 trend is toward feed-forward geometry models used as SLAM front ends, giving dense pose and depth without correspondence search. The back end — sparse nonlinear least squares over a graph — has not been displaced, and the reason is worth stating: the graph is where the consistency constraint lives, and consistency is what a learned per-frame predictor cannot supply.

Benchmarks

EuRoC MAV (drone, visual-inertial), TUM RGB-D (handheld indoor), KITTI Odometry (driving), TUM-VI and 4Seasons (long-term, changing conditions).

The standard metrics come from Sturm et al. (IROS 2012): ATE, absolute trajectory error after aligning the estimate to ground truth by a rigid or similarity transform, and RPE, relative pose error over fixed intervals. Report both. ATE is dominated by drift and loop closure; RPE measures local accuracy and is the number that matters for control.

What to learn next