Problem: Sensor Data is Noisy and Incomplete
Your GPS drifts. Your IMU accumulates error over time. Individually, neither sensor gives you a trustworthy position estimate — but fused together, they can.
The Extended Kalman Filter (EKF) is the industry-standard solution. It's used in autonomous vehicles, drones, robotics, and spacecraft navigation.
You'll learn:
- How the EKF differs from the standard Kalman Filter and why it matters
- How to implement a full EKF from scratch in Python using NumPy
- How to fuse GPS + IMU data into a smooth, accurate position estimate
Time: 45 min | Level: Advanced
Why This Happens
The standard Kalman Filter assumes your system's motion and sensor models are linear. Real systems almost never are — a robot turning, a drone banking, a car accelerating on a slope all involve nonlinear dynamics.
The EKF handles this by linearizing the nonlinear functions at each timestep using a first-order Taylor expansion (the Jacobian). It's an approximation, but it works well in practice for mildly nonlinear systems.
Common symptoms of needing an EKF:
- Position estimates drift or diverge over time
- Fusing sensors with different update rates causes jumps
- Standard KF covariance matrix becomes non-positive-definite
Background: EKF vs Standard KF
In a standard KF, state transition and measurement are linear matrices F and H. In an EKF:
- f(x) — nonlinear state transition function
- h(x) — nonlinear measurement function
- F = Jacobian of f(x) — linearizes motion for covariance propagation
- H = Jacobian of h(x) — linearizes observation for Kalman gain
The predict/update cycle:
Predict:
x̂⁻ = f(x̂)
P⁻ = F·P·Fᵀ + Q
Update:
K = P⁻·Hᵀ · (H·P⁻·Hᵀ + R)⁻¹
x̂ = x̂⁻ + K·(z - h(x̂⁻))
P = (I - K·H)·P⁻
Solution
We'll model a 2D robot with state [x, y, θ, v] (position, heading, speed), receiving noisy GPS measurements while an IMU provides velocity and heading rate.
Step 1: Set Up Dependencies
pip install numpy matplotlib
No exotic libraries needed — the EKF is pure linear algebra.
Step 2: Define the State and Noise Models
import numpy as np
# State vector: [x, y, theta, v]
DT = 0.1 # 10 Hz update rate
# Process noise Q — how much we trust the motion model
Q = np.diag([
0.1, # x position noise (m)
0.1, # y position noise (m)
0.01, # heading noise (rad)
0.5, # velocity noise (m/s)
]) ** 2
# Measurement noise R — how much we trust GPS
R = np.diag([
1.0, # GPS x noise (m)
1.0, # GPS y noise (m)
]) ** 2
Why this matters: Q/R ratio determines filter behavior. High Q → trusts sensors more. Low R → trusts sensors more. Start with datasheet values and tune from there.
Step 3: Define the Motion Model and Its Jacobian
def motion_model(x, u):
"""
Nonlinear state transition f(x, u).
u = [v_cmd, yaw_rate] from IMU.
"""
v_cmd, yaw_rate = u
return np.array([
x[0] + x[3] * np.cos(x[2]) * DT, # x += v·cos(θ)·dt
x[1] + x[3] * np.sin(x[2]) * DT, # y += v·sin(θ)·dt
x[2] + yaw_rate * DT, # θ += ω·dt
v_cmd, # trust IMU velocity directly
])
def jacobian_F(x, u):
"""
Jacobian of f(x) w.r.t. x — linearizes motion around current state.
Derived by taking partial derivatives of each motion_model output.
"""
theta, v = x[2], x[3]
return np.array([
[1, 0, -v * np.sin(theta) * DT, np.cos(theta) * DT],
[0, 1, v * np.cos(theta) * DT, np.sin(theta) * DT],
[0, 0, 1, 0 ],
[0, 0, 0, 1 ],
])
Step 4: Define the Measurement Model
def measurement_model(x):
"""GPS observes x, y position only."""
return np.array([x[0], x[1]])
# Jacobian H is constant here — GPS observation is already linear
H = np.array([
[1, 0, 0, 0],
[0, 1, 0, 0],
])
Step 5: Implement Predict and Update
def ekf_predict(x, P, u):
"""Propagate state and covariance through nonlinear model."""
F = jacobian_F(x, u)
x_pred = motion_model(x, u)
P_pred = F @ P @ F.T + Q # Linearized covariance propagation
return x_pred, P_pred
def ekf_update(x_pred, P_pred, z):
"""Correct prediction with incoming GPS measurement z."""
y = z - measurement_model(x_pred) # Innovation residual
S = H @ P_pred @ H.T + R # Innovation covariance
K = P_pred @ H.T @ np.linalg.inv(S) # Kalman gain
x_upd = x_pred + K @ y
P_upd = (np.eye(len(x_pred)) - K @ H) @ P_pred
return x_upd, P_upd
Expected: Diagonal of P should decrease after each ekf_update call — the filter grows more certain with each measurement.
If it fails:
LinAlgError: Singular matrix— R or Q has zero entries. Add small diagonal noise.- P grows unbounded — Jacobian F is wrong. Re-check your partial derivatives.
- State diverges after a few steps — Q is too small; increase process noise.
Step 6: Run the Simulation
def simulate():
"""Robot drives in a circle. We compare raw GPS to EKF estimate."""
x_true = np.array([0.0, 0.0, 0.0, 1.0])
x_est = np.array([0.0, 0.0, 0.0, 1.0])
P = np.eye(4) * 0.1
u = np.array([1.0, 0.1]) # 1 m/s forward, 0.1 rad/s turn
history_true, history_est, history_gps = [], [], []
for step in range(200):
x_true = motion_model(x_true, u) # Ground truth (no noise)
x_est, P = ekf_predict(x_est, P, u) # IMU-driven prediction
# GPS updates at 5 Hz (every other step)
if step % 2 == 0:
z = x_true[:2] + np.random.multivariate_normal([0, 0], R)
x_est, P = ekf_update(x_est, P, z)
history_gps.append(z)
history_true.append(x_true[:2].copy())
history_est.append(x_est[:2].copy())
return np.array(history_true), np.array(history_est), np.array(history_gps)
true_path, ekf_path, gps_readings = simulate()
Step 7: Visualize
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 8))
plt.plot(true_path[:, 0], true_path[:, 1], 'g-', lw=2, label='Ground Truth')
plt.plot(gps_readings[:, 0], gps_readings[:, 1], 'r.', ms=4, alpha=0.5, label='Raw GPS')
plt.plot(ekf_path[:, 0], ekf_path[:, 1], 'b-', lw=2, label='EKF Estimate')
plt.legend(fontsize=12)
plt.title('EKF Sensor Fusion: GPS + IMU')
plt.xlabel('X (m)'); plt.ylabel('Y (m)')
plt.axis('equal'); plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('ekf_result.png', dpi=150)
plt.show()
EKF (blue) hugs ground truth (green) while GPS scatter (red) shows ~1.4m noise
Verification
python ekf_fusion.py
You should see: EKF estimate closely follows the circular ground truth path.
Quantitative check:
gps_err = np.sqrt(np.mean(np.sum((gps_readings - true_path[::2])**2, axis=1)))
ekf_err = np.sqrt(np.mean(np.sum((ekf_path - true_path)**2, axis=1)))
print(f"GPS RMS: {gps_err:.3f} m | EKF RMS: {ekf_err:.3f} m | {gps_err/ekf_err:.1f}x better")
Typical result:
GPS RMS: 1.412 m | EKF RMS: 0.318 m | 4.4x better
What You Learned
- The EKF linearizes nonlinear systems via Jacobians — valid for mild nonlinearity, breaks down for severe
- Q/R tuning is the real work; start from sensor datasheets, not guesswork
- Predict every IMU step, update only when GPS arrives — this is standard practice for multi-rate fusion
- When nonlinearity is severe, look at the Unscented Kalman Filter (UKF) — it sigma-samples the distribution instead of linearizing
Limitation: EKF can diverge if initial state is far from truth or if Jacobians are derived incorrectly. Always watch the P diagonal — any blowup signals a modeling error.
When NOT to use this: Truly linear system → standard KF. Severe nonlinearity or non-Gaussian noise → Particle Filter or UKF.
Tested on Python 3.12, NumPy 1.26, Matplotlib 3.8 — macOS & Ubuntu