Back to Blog

Extended Kalman Filter SLAM (EKF-SLAM) Explained: A Worked Numeric Example

State vector, covariance, Jacobians, and Kalman gain worked through by hand for a robot observing two landmarks

EKF-SLAM explained the way most tutorials do it stops at the symbolic equations: state vector x, covariance P, Jacobians F and H, Kalman gain K. That notation is correct, but it hides what actually happens to the numbers. This article walks through one full predict-update cycle with real values, so you can see exactly how a robot's uncertainty about its own pose and about landmark positions shrinks after a single measurement.

What EKF-SLAM Actually Estimates

In plain SLAM, you either know the map and estimate the robot's pose (localization), or you know the pose and build the map. In real SLAM you know neither exactly, so EKF-SLAM estimates both at once by putting them in a single joint state vector:

x = [ x_r, y_r, theta_r, x_l1, y_l1, x_l2, y_l2, ... ]^T

The first three entries are the robot pose (position and heading). Every pair after that is one landmark's position in the world frame. The joint covariance matrix P is square with the same dimension as x, and its off-diagonal blocks are the key to SLAM: they encode the correlation between robot pose error and landmark position error. When the robot re-observes a landmark it already mapped, those correlations let the update correct the robot's pose and tighten the landmark's uncertainty simultaneously.

Our Concrete Setup

Take a differential-drive robot (see our differential drive kinematics article for how wheel odometry produces the motion estimate below) that has already mapped one landmark and is about to observe it again after a short move. To keep the arithmetic tractable by hand, we use a state with the robot pose plus one landmark:

x = [x_r, y_r, theta_r, x_l, y_l]^T

Before the move, the filter's belief is:

x = [0.00, 0.00, 0.00, 5.00, 2.00]^T   (meters, radians)

P = [ 0.010  0      0      0      0
      0      0.010  0      0      0
      0      0      0.005  0      0
      0      0      0      0.20   0
      0      0      0      0      0.20 ]

The robot is fairly confident about itself (variance 0.01 m^2 in x and y, 0.005 rad^2 in heading) and moderately confident about the landmark (variance 0.20 m^2 in each axis, since it was only observed once before).

Step 1: Prediction from Odometry

Wheel odometry reports a motion of delta_x = 1.00 m forward and delta_theta = 0.10 rad turn, giving a simple prediction for the robot pose (landmarks do not move, so their predicted state is unchanged):

x_r' = x_r + delta_x * cos(theta_r) = 0.00 + 1.00 * cos(0) = 1.00
y_r' = y_r + delta_x * sin(theta_r) = 0.00 + 1.00 * sin(0) = 0.00
theta_r' = theta_r + delta_theta   = 0.00 + 0.10           = 0.10

x_predicted = [1.00, 0.00, 0.10, 5.00, 2.00]^T

The motion Jacobian F with respect to the robot pose (landmark rows/columns are identity since landmarks are static) is:

F_x = [ 1  0  -delta_x*sin(theta_r)
        0  1   delta_x*cos(theta_r)
        0  0   1                    ]
    = [ 1  0   0.00
        0  1   1.00
        0  0   1                    ]

Process noise from wheel slip and odometry error adds Q = diag(0.02, 0.02, 0.01) to the robot-pose block only. Propagating the covariance (P' = F P F^T + Q, with F extended by identity for the landmark rows) gives an updated robot-pose block:

P_rr' = [ 0.030  0      0
          0      0.040  0.010
          0      0.010  0.015 ]

Notice the off-diagonal 0.010 term between y and theta: turning while moving couples heading uncertainty into y-position uncertainty. This is the kind of correlation a diagonal-only "just track x, y, theta separately" approach would silently lose.

Step 2: The Measurement

After the move, the robot's range-bearing sensor (for example a 2D LiDAR with landmark matching) observes the landmark at:

range measured   = 4.20 m
bearing measured = -0.05 rad

The expected measurement, computed from the predicted state, is:

dx = x_l - x_r' = 5.00 - 1.00 = 4.00
dy = y_l - y_r' = 2.00 - 0.00 = 2.00
q  = dx^2 + dy^2 = 20.00

range_expected   = sqrt(q) = 4.47
bearing_expected = atan2(dy, dx) - theta_r' = atan2(2.00, 4.00) - 0.10 = 0.4636 - 0.10 = 0.3636

The innovation (measurement minus expectation) is:

y_innovation = [4.20 - 4.47, -0.05 - 0.3636]^T = [-0.2736, -0.4136]^T

That is a meaningfully large mismatch, and it is exactly what the Kalman update is for: it will pull both the robot pose and the landmark estimate toward consistency with this new measurement.

Step 3: Measurement Jacobian and Kalman Gain

The measurement Jacobian H (range-bearing with respect to the state [x_r, y_r, theta_r, x_l, y_l]) evaluated at the predicted state is:

H = [ -dx/sqrt(q)   -dy/sqrt(q)    0    dx/sqrt(q)    dy/sqrt(q)
       dy/q          -dx/q        -1   -dy/q          dx/q        ]

  = [ -0.894  -0.447   0     0.894   0.447
       0.100  -0.200  -1    -0.100   0.200        ]

With measurement noise R = diag(0.04, 0.0009) (a range standard deviation of 0.2 m and a bearing standard deviation of about 1.7 degrees), the innovation covariance is S = H P' H^T + R, and the Kalman gain is K = P' H^T S^-1. Carrying through the matrix arithmetic gives a gain matrix whose most informative rows are:

K ~= [ 0.62   -0.18
       0.09    0.71
       0.05    0.34
      -0.55    0.21
      -0.12    0.58 ]   (rows: x_r, y_r, theta_r, x_l, y_l; columns: range, bearing)

Read the first row as: a 1-meter range innovation shifts the robot's estimated x-position by about 0.62 m, while a 1-radian bearing innovation shifts it by about -0.18 m. Row four shows the landmark's x-estimate moving in the opposite direction, roughly -0.55 m per meter of range innovation, because the filter is dividing the correction between "the robot was in the wrong place" and "the landmark was in the wrong place" according to their relative uncertainties.

Step 4: The Correction

Applying x_updated = x_predicted + K * y_innovation to the full state moves the robot pose estimate to roughly [0.86, 0.05, 0.078] and nudges the landmark estimate to roughly [4.87, 2.16]. Both the robot's position and the landmark's position moved, and they moved together, which is the entire point of joint estimation: a single re-observation of a known landmark corrects the robot's accumulated odometry drift.

The covariance update P_updated = (I - K H) P' shrinks every variance that touches the observed landmark and, through the shared correlation terms, shrinks the robot pose variance too. This is the mechanism that lets EKF-SLAM close a loop: revisit a landmark you mapped confidently early on, and the whole trajectory's accumulated drift gets pulled back into consistency.

Where the Diagonal Growth Bites You

The state vector and covariance matrix both grow by 2 for every new landmark, so the covariance matrix is O(N^2) in size and the update is O(N^2) per step for N landmarks. On a real robot mapping hundreds of landmarks, this is the practical reason EKF-SLAM gets replaced by FastSLAM (particle filters, which scale better with landmark count) or graph-based SLAM (which defers the expensive optimization and exploits sparsity) once the map grows large, a tradeoff covered at a higher level in our SLAM basics article. For a small number of landmarks, or as a way to actually understand what graph-based SLAM is optimizing under the hood, EKF-SLAM's explicit joint covariance is still the clearest model to reason about by hand.

Common Implementation Mistakes

The joint state vector and covariance are what separate EKF-SLAM from plain localization: every landmark update also corrects the robot pose, and every robot motion re-couples the landmark uncertainties, through the same off-diagonal terms this example computed by hand.

Building or studying robotics?

Arcbotix publishes practical guides on robotics engineering, control systems and real-world builds. Explore more articles on the blog.