← Back to list

Kalman Filter zero to hero, Math to Code.

After struggling through Milestone 1 with mathematical derivations, Now the question is — how does that mathematics become code that a…

Sagar Lekhraj · 2026-05-30 20:24 · 0 claps · 10.3 min read
#computer-science #computer-architecture #motion-tracking #kalman-filter #extended-kalman-filter
Open on Medium ↗
Wiki topics: 📐 · Mathematics 🔬 · Science · General 🏛️ · Architecture

Kalman Filter zero to hero, Math to Code.

After struggling through Milestone 1 with mathematical derivations, Now the question is — how does that mathematics become code that a machine actually runs?

Full code available here:

code

From milestone 1 what we have in hand, so those derivations can be used here, first of all list me those things.

Where We Left Off

In Milestone 1 we derived:

F   →   state transition matrix        (12 × 12)
Q   →   process noise covariance       (12 × 12)
H   →   measurement matrix             (3 × 12)
R   →   sensor noise covariance        (3 × 3)

And the full algorithm — predict, update — for both LKF and EKF.

Now we ask: how do we turn all of this into running C code?

We are given two dataset which are:

  • 3D Full Body Humain Gait Walking Dataset (Noisy Values)
  • 3D Full Body Humain Gait Walking Dataset (True Values)

What further. we have from these datasets,

3D Full Body Human Gait Walking Dataset
→   3,040 frames at 30 fps
→   23 skeletal joints
→   each frame: 69 position values (23 joints × 3 axes)
→   two versions: noisy and ground truth

What this means for our state:

per joint   →   12 states  [px, vx, ax, jx, py, vy, ay, jy, pz, vz, az, jz]
23 joints   →   23 × 12  =  276 total states
measurement →   23 × 3   =  69 total measurements

So our matrices scale up:

F   →   276 × 276
Q   →   276 × 276
H   →   69  × 276
P   →   276 × 276
x̂   →   276 × 1
z   →   69  × 1

What We Have and What We Need First: Matrix Structure

What we have: mathematical definitions of all matrices.

What we need: a way to represent and operate on matrices in C.

C has no built-in matrix type. So we build one.

typedef struct {
    int rows;
    int cols;
    double** data;
} Matrix;

Every matrix in the filter — F, Q, H, R, P, x_hat — is this struct. The data field is a 2D array of doubles allocated on the heap.

This architecture is also available at the end in words.

This architecture is also available at the end in words.

Take operations from the above equations.

Take operations from the above equations.

Collecting all the operations that are used in above equations, and make one file that has all these operations.

What we get: Matrixoperations.c — a complete matrix algebra library with:

matrix_create(r, c)        →   allocate a new matrix
matrix_multiply(A, B)      →   A × B
matrix_transpose(A)        →   Aᵀ
matrix_add(A, B)           →   A + B
matrix_inverse_3x3(A)      →   explicit 3×3 inverse (used in EKF)
cholesky_decompose(A)       →   for solving linear systems stably
matrix_set_block(dst, src) →   place a small block inside a large matrix

The most important one for us is matrix_set_block — because F and Q are block-diagonal, we build them by tiling small 4×4 blocks into large 276×276 matrices.

— Next, Next…

What We Have and What We Need: The KalmanFilter Struct

What we have: all matrix variables defined mathematically.

What we need: a single structure that holds everything the filter needs at every step.

typedef struct {
    Matrix* x_hat;   // 276×1  — state estimate
    Matrix* P;       // 276×276 — estimation uncertainty
    Matrix* F;       // 276×276 — state transition
    Matrix* Q;       // 276×276 — process noise
    Matrix* H;       // 69×276  — measurement (LKF only)
    Matrix* R;       // 69×69   — sensor noise
    int n_states;
    int n_meas;
} KalmanFilter;

All of this lives in Kalman2.h — the shared header. Every other file includes it. So in simple this file contains all the initializations of every matrix and every constant and variables that we are going to use throughout the code.

What We Have and What We Need: Building F in Code

What we have: the mathematical Faxis block:

Faxis = | 1   Δt   Δt²/2   Δt³/6  |
        | 0    1     Δt    Δt²/2  |
        | 0    0      1      Δt   |
        | 0    0      0       1   |

What we need: a 276×276 block-diagonal matrix with 69 copies of this block.

What we do in build_F_matrix():

// create one 4×4 block
F_block->data[0][0] = 1.0;
F_block->data[0][1] = dt;
F_block->data[0][2] = dt*dt/2.0;
F_block->data[0][3] = dt*dt*dt/6.0;
F_block->data[1][1] = 1.0;
F_block->data[1][2] = dt;
F_block->data[1][3] = dt*dt/2.0;
F_block->data[2][2] = 1.0;
F_block->data[2][3] = dt;
F_block->data[3][3] = 1.0;

// tile it 69 times (23 joints × 3 axes)
for (int joint = 0; joint < n_joints; joint++)
    for (int axis = 0; axis < 3; axis++)
        matrix_set_block(F_full, F_block,
            joint*12 + axis*4,
            joint*12 + axis*4);

What we get: a 276×276 matrix that is 97% zeros — only 69 small blocks on the diagonal carry data. This is the block-diagonal structure and it is the key to everything fast.

Building Q in Code

Q1D = σ²j ×  | Δt⁶/36   Δt⁵/12   Δt⁴/6   Δt³/6 |
             | Δt⁵/12   Δt⁴/4    Δt³/2   Δt²/2 |
             | Δt⁴/6    Δt³/2    Δt²     Δt    |
             | Δt³/6    Δt²/2    Δt      1     |

What we do in *build_Q_matrix()*: exactly the same tiling pattern as F — build one 4×4 block, scale it by σ²a, tile it 69 times.

Tuned parameter:

σa = 0.01 m/s³

Starting with 0.1 caused 4× worse RMSE. The smaller value means the filter trusts the smooth constant-jerk model more — which is correct for periodic walking gait.

Understand the 23 joints in a body.

Understand the 23 joints in a body.

We have 23 joints and one state vector is 12 and we have the 12*23 possibilities for all joints. and further we can consider for other matrices and use them as blocks.

Building H and R in Code

For LKF — H matrix:

What we have: H extracts px, py, pz from each joint’s 12-state block.

What we do in *build_H_matrix_lkf()*:

// For each joint j and axis α (0=x, 1=y, 2=z):
H->data[j*3 + α][j*12 + α*4] = 1.0;
// Everything else is zero

One line per joint per axis. 69 ones in a 69×276 matrix.

For LKF — R matrix:

// σr = 0.36 m  (measured from dataset)
R->data[i][i] = 0.36 * 0.36;   // diagonal, one per measurement

For EKF — R matrix (different):

The EKF sensor speaks in spherical coordinates — range, azimuth, elevation — which have completely different noise magnitudes:

// per joint, 3×3 diagonal block
R[m+0][m+0] = 0.55  * 0.55;    // range noise      (meters)
R[m+1][m+1] = 0.022 * 0.022;   // azimuth noise    (radians)
R[m+2][m+2] = 0.012 * 0.012;   // elevation noise  (radians)

This was the most critical bug found — using a uniform σ=0.05 for all three spherical components made the EKF wildly miscalibrated. Range noise is 11× larger than the original assumption. Once fixed, RMSE dropped from over 1 meter to under 0.2 meters.

Initialization

What we have: mathematical initialization:

x̂₀|₀  =  initial guess
P₀|₀   =  large uncertainty

What we do:

// P₀ = 100 × I  (large = we are very uncertain at start)
for (int i = 0; i < TOTAL_STATES; i++)
    kf->P->data[i][i] = 100.0;

// x̂₀ seeded from first frame measurement
kf->x_hat->data[j*12 + 0][0] = first_frame_px;
kf->x_hat->data[j*12 + 4][0] = first_frame_py;
kf->x_hat->data[j*12 + 8][0] = first_frame_pz;
// velocity, acceleration, jerk start at zero

Seeding from the first frame is important for EKF — starting at the origin causes division by zero in the Jacobian because r=0 and ρ=0.

LKF Predict in Code

What we have mathematically:

x̂n|n-1 = F · x̂n-1|n-1
Pn|n-1 = F · Pn-1|n-1 · Fᵀ + Q

The naive approach and its problem:

Multiplying full 276×276 matrices takes 27⁶³ = 21 million operations per frame. Over 3,040 frames that becomes slow.

What we actually do — exploit block-diagonal structure:

Since F is block-diagonal, the full matrix multiplication decomposes into 23 independent 12×12 operations per joint — on the stack, no heap allocation:

for (int j = 0; j < N_JOINTS; j++) {
    // extract 12-state slice for this joint
    // multiply: x_new = Fj × xj   (12×12 × 12×1)
    // multiply: Pnew = Fj×Pj×Fjᵀ + Qj  (12×12 operations)
    // write back
}

What we get:

Original naive approach:   25 seconds for 3,040 frames
Block-diagonal approach:    1.7 seconds
Speedup:                   15×
Zero heap allocations in the hot path

LKF Update in Code

What we have mathematically:

Kn = Pn|n-1 · Hᵀ · (H · Pn|n-1 · Hᵀ + R)⁻¹
x̂n|n = x̂n|n-1 + Kn · (zn - H·x̂n|n-1)
Pn|n = (I-KH)·Pn|n-1·(I-KH)ᵀ + K·R·Kᵀ

The key insight: H has exactly one non-zero per row. So H·P·Hᵀ produces a block-diagonal 69×69 matrix — 23 independent 3×3 blocks. Each 3×3 block is inverted analytically (explicit formula, no Cholesky needed):

// For each joint — all on stack, 12×3 arrays
// 1. compute innovation:  y = z - H·x̂  (direct index lookup)
// 2. compute S = Pj[pos,pos] + Rj       (3×3)
// 3. invert S analytically              (3×3 explicit inverse)
// 4. compute K = Pj·Hᵀ·S⁻¹            (12×3)
// 5. update x̂ += K·y
// 6. update P using Joseph form

The Joseph form covariance update:

Pn|n = (I - K·H) · Pn|n-1 · (I - K·H)ᵀ + K·R·Kᵀ

Is used instead of the simpler (I-KH)·P because it keeps P symmetric and positive definite even when floating point errors accumulate over thousands of frames.

The EKF Problem: Converting Measurements

What we have: raw measurements in Cartesian coordinates from the dataset.

What EKF needs: measurements in spherical coordinates.

What we do in *cartesian_to_spherical()*:

*r     = my_sqrt(px*px + py*py + pz*pz);
*theta = my_arctan2(py, px);
*phi   = my_arctan2(pz, my_sqrt(px*px + py*py));

Notice my_sqrt and my_arctan2 — these are hand-implemented using Newton-Raphson (sqrt) and a 15-term Taylor series (arctan). This satisfies the requirement to implement core math functions from scratch.

Before every EKF update step, all 69 Cartesian measurements are converted to spherical:

Matrix* z_sph = measurements_to_spherical(data->frames[frame]);
ekf_predict(kf);
ekf_update(kf, z_sph);

EKF Update: The Jacobian in Code

What we have mathematically:

         | px/r       0 0 0   py/r      0 0 0  pz/r     0 0 0 |
Hn  =    | -py/ρ²     0 0 0   px/ρ²     0 0 0   0       0 0 0 |
         | -pxpz/r²ρ  0 0 0  -pypz/r²ρ  0 0 0  ρ/r²     0 0 0 |

What we do in compute_jacobian():

double r   = sqrt(px*px + py*py + pz*pz);
double rho = sqrt(px*px + py*py);

// guard against division by zero near origin
if (r   < 1e-6) r   = 1e-6;
if (rho < 1e-6) rho = 1e-6;

// row 0 — range
J[0][0] = px/r;      J[0][4] = py/r;      J[0][8] = pz/r;

// row 1 — azimuth
J[1][0] = -py/(rho*rho);   J[1][4] = px/(rho*rho);

// row 2 — elevation
J[2][0] = -(px*pz)/(r*r*rho);
J[2][4] = -(py*pz)/(r*r*rho);
J[2][8] =  rho/(r*r);

All other entries are zero. The Jacobian is recomputed fresh at every time step using the current predicted state.

What we get: Hn — the local linear approximation of the nonlinear sensor function. It replaces the constant H in all update equations.

The EKF innovation uses the actual nonlinear function:

// NOT:  y = z - H·x̂     (that is LKF)
// YES:  y = z - h(x̂)    (actual nonlinear conversion)
y[0] = z_r     - r_predicted;
y[1] = z_theta - theta_predicted;
y[2] = z_phi   - phi_predicted;

// angle wrapping — critical for correctness
while (y[1] >  M_PI) y[1] -= 2*M_PI;
while (y[1] < -M_PI) y[1] += 2*M_PI;

Angle wrapping prevents the innovation from jumping ±2π when the object crosses the ±180° azimuth boundary.

The Main Pipeline

The Main Pipeline

The Main Pipeline

What we have: all pieces built.

What the main loop does for each frame:

LKF pipeline:
─────────────────────────────────────────────
for each frame:
    lkf_predict(kf)              →  F·x̂ and F·P·Fᵀ+Q
    lkf_update(kf, z_cartesian)  →  K, innovation, x̂, P
    save estimate to CSV

EKF pipeline:
─────────────────────────────────────────────
for each frame:
    z_sph = cartesian_to_spherical(z)   →  convert measurement
    ekf_predict(kf)                     →  same as LKF predict
    ekf_update(kf, z_sph)               →  Jacobian, K, innovation, x̂, P
    save estimate to CSV

Output CSVs have 277 columns: one frame index + 276 state values per frame. State order per joint: px, vx, ax, jx, py, vy, ay, jy, pz, vz, az, jz.

Error Analysis

What we have: LKF estimates, EKF estimates, ground truth.

What we compute:

RMSE = √( (1/N) × Σ (true_position - estimated_position)² )

What we get:

Method       x-RMSE    y-RMSE    z-RMSE    3D Total
──────────────────────────────────────────────────────
Noisy input    —         —         —        0.627 m
LKF          0.159     0.104     0.020     0.190 m
EKF          0.155     0.106     0.025     0.189 m

The Three Bugs That Were Fixed

These are worth knowing because they show how sensitive the filter is to calibration:

Bug 1 — EKF R matrix (most critical):
    Wrong:  uniform σ=0.05 for range, azimuth, elevation
    Fixed:  σr=0.55m, σθ=0.022rad, σϕ=0.012rad per component
    Impact: RMSE dropped from >1m to 0.189m

Bug 2 — Process noise σa:
    Wrong:  σa = 0.1 m/s³  (10× too large)
    Fixed:  σa = 0.01 m/s³
    Impact: 0.04m RMSE reduction, smoother trajectories

Bug 3 — Error analysis column indices:
    Wrong:  comparing wrong CSV columns
    Fixed:  axis offsets {0,4,8} within each 12-state block
    Impact: error numbers were meaningless before fix

Software Architecture — What File Does What

Kalman2.h           →   all structs, constants, prototypes
                        Matrix, KalmanFilter, MotionData
                        N_JOINTS=23, STATES_PER_JOINT=12

Matrixoperations.c  →   complete matrix algebra library
                        create, multiply, transpose, inverse,
                        Cholesky, set_block, get_block

Test_matho.c        →   CSV file I/O
                        my_sqrt (Newton-Raphson)
                        my_arctan2 (Taylor series)
                        cartesian_to_spherical

Lkf.c               →   build_F_matrix, build_Q_matrix
                        build_H_matrix_lkf, build_R_matrix
                        lkf_predict, lkf_update

Ekf.c               →   compute_jacobian
                        ekf_predict (identical to LKF)
                        ekf_update (Jacobian + nonlinear innovation)

main.c              →   run_lkf_pipeline, run_ekf_pipeline
                        calculate_errors
                        main()

The Complete Chain — Math to Code to Result

MATH                    CODE                        RESULT
────────────────────────────────────────────────────────────────
State vector x          KalmanFilter.x_hat          276×1 estimate
F matrix                build_F_matrix()            276×276 block-diag
Q matrix                build_Q_matrix()            276×276 block-diag
H matrix                build_H_matrix_lkf()        69×276 sparse
R matrix                build_R_matrix()            69×69 diagonal
P matrix                init as 100×I               276×276 covariance

Predict step            lkf_predict() / ekf_predict()
  x̂ = F·x̂            →  per-joint 12×12 stack ops  →  predicted state
  P = FPFᵀ+Q          →  per-joint 12×12 stack ops  →  grown uncertainty

Update step LKF         lkf_update()
  z - H·x̂             →  direct index lookup        →  innovation
  K = PHᵀ(HPHᵀ+R)⁻¹  →  3×3 analytic inverse       →  Kalman gain
  x̂ += K·ν            →  12×3 stack multiply        →  corrected state
  Joseph form P        →  12×12 stack ops            →  shrunk uncertainty

Update step EKF         ekf_update()
  h(x̂)                →  cartesian_to_spherical()   →  predicted meas
  z - h(x̂)            →  spherical subtraction      →  innovation
  Hn = ∂h/∂x          →  compute_jacobian()         →  3×12 Jacobian
  K = PHnᵀ(HnPHnᵀ+R)⁻¹ → 3×3 analytic inverse      →  Kalman gain
  x̂ += K·ν            →  12×3 stack multiply        →  corrected state
  Joseph form P        →  12×12 stack ops            →  shrunk uncertainty

Error analysis          calculate_errors()
  RMSE per axis        →  compare with ground truth  →  0.190m (LKF)
                                                         0.189m (EKF)

As above is quite an intricate while full understanding so try to understand the maths behind it, while keeping the logic in mind if code doesn’t make sense on some places.

Converting pure math to C code in milestone 2 has come to end, and above fully understandable implementation is available on github account, you can follow the below given link.

What Remains

The math is implemented. The filters run. The results validate.

What comes next is assembly language — implementing the same predict and update steps in RISC-V assembly to go even deeper into how the machine executes these matrix operations at the instruction level.

For the full C source code, visit: Link.

If you like this article while reading clap for this article, reshare and comment to make it even better and simpler so some difficult spot can eliminated.

we finished the second milestone, congratulations.

bye bye thank you for reading.

clap and comment.


메타데이터
post_id
49d95693a8e6
slug
kalman-filter-zero-to-hero-math-to-code-49d95693a8e6
url
https://medium.com/@sagarkumarharwani19/kalman-filter-zero-to-hero-math-to-code-49d95693a8e6
canonical_url
https://medium.com/@sagarkumarharwani19/kalman-filter-zero-to-hero-math-to-code-49d95693a8e6
author_url
https://medium.com/@sagarkumarharwani19
status
ok
fetched_at
2026-06-17 08:20:12