DEV Community

Cover image for A Hands-On Guide to kalbee: Your First Kalman Filter (and Beyond)
firefrog
firefrog

Posted on

A Hands-On Guide to kalbee: Your First Kalman Filter (and Beyond)

Everything you need to go from pip install to a working multi-object tracker, one runnable snippet at a time.


kalbee is a Python library for state estimation — the art of recovering a clean signal (position, velocity, temperature, whatever you're measuring) from noisy sensor data. This guide walks through it from the ground up. Every code block runs as-is; copy them into a file and follow along.

Install

pip install kalbee
Enter fullscreen mode Exit fullscreen mode

The only runtime dependencies are NumPy and SciPy. Optional extras add object-detection (pip install "kalbee[yolo]") and plotting (pip install "kalbee[viz]") support.

The one idea you need: predict and update

Every filter in kalbee works the same way. You alternate between two steps:

  • predict() — advance the state forward in time using a motion model ("where do I think the object is now?").
  • update(z) — correct that prediction with a new measurement z ("what does the sensor actually say?").

The filter tracks two things: the state x (your best estimate) and the covariance P (how uncertain that estimate is). You read them back via kf.x and kf.P.

Your first filter

Let's track an object moving at roughly constant velocity, measuring only its (noisy) position. Instead of hand-building matrices, we use kalbee's ready-made models:

import numpy as np
from kalbee import KalmanFilter, rmse
from kalbee.models import constant_velocity, position_measurement_model

dt = 1.0
# Motion model: state is [position, velocity]
F, Q = constant_velocity(dt=dt, process_var=0.01, n_dims=1)
# Measurement model: we observe position only, with noise variance 4.0
H, R = position_measurement_model(order=1, n_dims=1, measurement_var=4.0)

# Simulate a noisy trajectory
rng = np.random.default_rng(0)
pos, vel = 0.0, 1.0
truths, measurements = [], []
for _ in range(50):
    pos += vel * dt
    truths.append(pos)
    measurements.append(pos + rng.standard_normal() * 2.0)  # std 2.0 -> var 4.0

# Create the filter: start at zero with high uncertainty (P = 100 * I)
kf = KalmanFilter(np.zeros((2, 1)), np.eye(2) * 100, F, Q, H, R)

estimates = []
for z in measurements:
    kf.predict(dt=dt)
    kf.update(np.array([[z]]))
    estimates.append(kf.x[0, 0])   # estimated position

print("Raw measurement RMSE:", round(rmse(np.array(measurements), np.array(truths)), 3))
print("Filtered RMSE:       ", round(rmse(np.array(estimates), np.array(truths)), 3))
Enter fullscreen mode Exit fullscreen mode

Run it and you'll see the filtered error is meaningfully lower than the raw measurement error — the filter is smoothing out the noise. That's the whole game.

What the parameters mean

  • process_var — how much you trust the motion model. Higher = the filter reacts faster but is jumpier.
  • measurement_var (R) — how noisy your sensor is. Higher = the filter leans more on its predictions.
  • Initial P — your starting uncertainty. When in doubt, start large (like 100 * I); the filter converges quickly.

Tuning process_var vs measurement_var is the main knob you'll turn. (There's an automatic way to set them — see the EM section below.)

Going 2-D (and beyond)

Every model takes an n_dims argument. Want to track an object in a plane? Ask for two dimensions and the state becomes [x, vx, y, vy]:

F, Q = constant_velocity(dt=1.0, process_var=0.1, n_dims=2)   # 4x4
H, R = position_measurement_model(order=1, n_dims=2, measurement_var=0.5)
Enter fullscreen mode Exit fullscreen mode

Need acceleration too? Use constant_acceleration (state becomes [pos, vel, acc] per axis). Tracking something that turns? constant_turn gives you a coordinated-turn model.

Nonlinear systems: EKF and UKF

When your motion or measurement isn't linear, swap in the Extended or Unscented Kalman Filter. Instead of matrices, you pass functions:

import numpy as np
from kalbee import UnscentedKalmanFilter

def f(x, dt):          # state transition
    F = np.array([[1, dt], [0, 1]])
    return F @ x

def h(x):              # measurement function
    return x[:1]       # observe position only

ukf = UnscentedKalmanFilter(
    state=np.zeros((2, 1)),
    covariance=np.eye(2) * 100,
    transition_covariance=np.eye(2) * 0.01,
    measurement_covariance=np.array([[4.0]]),
    transition_function=f,
    measurement_function=h,
)

ukf.predict(dt=1.0)
ukf.update(np.array([[1.2]]))
print(ukf.x)
Enter fullscreen mode Exit fullscreen mode

The UKF needs no derivatives; the EKF (ExtendedKalmanFilter) is similar but also takes Jacobian functions. The same predict/update loop applies to all ten filters kalbee ships — including Particle, Ensemble, Information, Square-Root, and Vectorized filters.

Not sure which filter? Compare them

kalbee has a built-in experiment runner that races several filters on a synthetic signal:

from kalbee import run_experiment

report = run_experiment(
    signal="sine",                       # or "linear", "step", "maneuver"
    filters=["kf", "ekf", "ukf", "pf"],
    noise_std=0.5,
    seed=42,
)
print(report.summary())
Enter fullscreen mode Exit fullscreen mode

You get a ranked table of position/velocity RMSE and a consistency metric (NEES) for each filter — a quick way to pick the right tool before committing.

Tracking many objects

To track multiple objects (people in a video, blips on a radar), wrap a filter in a MultiObjectTracker. You supply a small factory that builds a fresh filter for each new object:

import numpy as np
from kalbee import KalmanFilter, MultiObjectTracker
from kalbee.models import constant_velocity, position_measurement_model

F, Q = constant_velocity(dt=1.0, process_var=0.05, n_dims=2)
H, R = position_measurement_model(order=1, n_dims=2, measurement_var=0.5)

def new_track(z):
    # Seed state [x, vx, y, vy] at the detection, zero initial velocity.
    x0 = np.array([[z[0]], [0.0], [z[1]], [0.0]])
    return KalmanFilter(x0, np.eye(4) * 10.0, F, Q, H, R)

tracker = MultiObjectTracker(new_track, n_init=3, max_age=5, gate=6.0)

# Each frame, pass a (D, 2) array of detected positions:
for detections in detection_stream:
    confirmed = tracker.update(detections)
    for t in confirmed:
        print(f"id={t.id}  pos=({t.state[0,0]:.1f}, {t.state[2,0]:.1f})")
Enter fullscreen mode Exit fullscreen mode

The tracker handles the hard parts for you: matching detections to existing tracks (Hungarian algorithm with distance gating) and managing each track's lifecycle. The knobs:

  • n_init — how many consecutive detections before a track is "confirmed" (filters out flicker).
  • max_age — how many missed frames before a track is deleted (handles occlusion).
  • gate — the maximum distance for a valid match.

Only confirmed tracks come back from update(). Feed it the box centers from a detector like YOLO and you have a complete tracking pipeline.

Let the data pick your noise values

Struggling to choose Q and R? Learn them from a recording with EM (Expectation-Maximization):

from kalbee import em_kalman
from kalbee.models import constant_velocity, position_measurement_model

F, _ = constant_velocity(dt=1.0, n_dims=1)
H, _ = position_measurement_model(order=1, n_dims=1)

# measurements: array of shape (T, m)
result = em_kalman(measurements, F, H, n_iter=50)

print("Learned Q:\n", result.Q)
print("Learned R:\n", result.R)
print("Converged:", result.converged)
Enter fullscreen mode Exit fullscreen mode

Fit once on representative data, then plug result.Q and result.R into a live KalmanFilter. It's a principled alternative to hand-tuning.

A few tips

  • Shapes matter. States are column vectors (n, 1); measurements are (m, 1). When in doubt, reshape(-1, 1).
  • Start uncertain. A large initial P lets the filter trust early measurements and converge fast.
  • Watch consistency, not just error. If the average NEES is far from your state dimension, your Q/R are mistuned — the metrics module (nees, nis, log_likelihood) tells you.
  • Reproducibility is built in. Sampling filters (particle, ensemble) and the signal generators accept a seed/rng argument, so runs are deterministic when you want them to be.

Where to go next

  • The documentation has a dedicated page per filter with the underlying math and worked examples.
  • The examples/ folder has runnable scripts, including a full multi-object tracking demo and YOLO integration.
  • Everything shares the same predict/update interface — so once you've done this tutorial, the rest of the library is just variations on what you already know.

Happy estimating.

Top comments (0)