A Kalman filter sounds like graduate-school machinery. The idea underneath is almost embarrassingly practical. You keep a guess about where you are, plus an honest record of how wrong that guess might be. Take a step and the filter predicts a new position, deliberately widening the uncertainty, because steps are never perfect. When a sensor offers a measurement — a compass heading, a hint that you stopped moving — the filter blends it into the guess, weighted by how much each source deserves to be trusted. Replace with something concrete, e.g.: "The math is ordinary linear algebra; what makes it useful is that the filter keeps an honest record of its own error."
Wheria runs a reduced extended Kalman filter. It never runs alone: it sits between the step detector and the confidence chip you see in Find mode. Steps push it forward, the compass and barometer argue with it, and the covariance it carries ends up on screen as a color. What follows is the whole loop: state, predict, update, the tuning numbers from our garage logs, and the fancier alternatives we rejected.
The state: four numbers and their doubt
The state vector holds four elements: horizontal position x and y in local meters, heading θ in radians, and a slowly drifting gyroscope bias b_g. That is the entire world model. The filter earns the word extended because heading enters the position equations through cos θ and sin θ, which are nonlinear, so every predict linearizes around the current estimate. On a phone the cost is a handful of 4×4 matrix multiplies per step. My mid-range Android test unit finishes one predict-update cycle in about 11 µs, averaged over 10,000 cycles on the bench. The battery never notices the filter exists.
Next to the state lives the covariance matrix P, also 4×4. It encodes how uncertain each element is and how the errors correlate, and the correlations are where the value hides. Heading error and lateral position error are tightly coupled: walk 80 m with a heading estimate off by 10° and you end up roughly 80·sin 10° ≈ 14 m sideways from where the map thinks you are. The cross terms in P predict that drift before you feel lost, which is exactly what the UI needs to know.
Predict: every step makes you less sure
Each detected step triggers a prediction. Position advances by stride length L along the current heading, x ← x + L·cos θ and y ← y + L·sin θ, while heading advances by the integrated gyroscope rate minus the bias estimate. Then the doubt grows: P ← F·P·Fᵀ + Q, with F the Jacobian of the motion model and Q the process noise. The growth is the point. A filter that only ever tightened its uncertainty would be a liar with good posture.
Q did not come out of a textbook appendix. We tuned it from field logs: 3 phones, 14 garages, about 60 logged walks over two weeks, with predict and update traces dumped at every step and replayed offline. Stride uncertainty landed near 5 cm standard deviation per step. Heading process noise sits around 2° per step while compass trust is high, and closer to 8° per step in steel-heavy zones where magnetometer corrections cannot be believed.
That asymmetry matters. In an open-air slab garage, compass measurements arrive frequently and the filter tightens heading after almost every few steps. In a spiral ramp wrapped in rebar we inflate the magnetometer measurement noise by an order of magnitude, which quietly tells the filter to lean on gyroscope integration between steps and to treat any compass spike as a suspect.
Update: measurements and when to believe them
Magnetometer heading enters as the measurement z = atan2(m_y, m_x), the arctangent of the horizontal field components after tilt compensation. The update computes an innovation ν = z − θ, a gain K = P·Hᵀ·(H·P·Hᵀ + R)⁻¹, then nudges the state by K·ν and pulls P down. Everything hinges on R, the measurement noise. When the field magnitude and inclination look like Earth's expected range, R stays small and the correction bites hard. When they do not — steel columns can rotate effective north by 40° while the user walks dead straight — we grow R and the correction turns gentle instead of yanking the estimate around. The compass failure zoo has its own post: magnetometers and compass nightmares.
We also gate. Any innovation beyond 3 standard deviations of its predicted spread is rejected outright and logged. The worst bug in this filter's history lived one line away from that gate: an innovation computed across the ±π seam once produced a 359° correction that teleported the estimate across the garage. Angle wrapping is not optional. The wrapAngle call in the listing below is load-bearing.
Barometer floor hints get handled with more caution. One garage floor is about 3.2 m of height, and with Δh = ΔP/(ρg) giving roughly 12 Pa per metre near sea level, that is close to 38 Pa of signal. Phone barometers show 0.3–1 Pa RMS of noise at rest, so the floor change itself stands out clearly. The trouble is everything else that moves pressure: elevators, HVAC gusts, a weather front crossing mid-walk. So we surface barometer inference as parallel UI guidance, "likely B3" with a ±1 floor band, and keep altitude out of the Kalman state entirely, because a false floor lock would corrupt the horizontal confidence users rely on for distance-to-car. The derivation and the ugly pressure logs live in the barometer math post.
Zero-velocity updates are a classic pedestrian trick. No step for about 2 s usually means the user is standing still, so we damp any legacy velocity drift in integration paths that still carry speed states from earlier prototypes. A small stabilizer. Escalators still defeat it.
The loop, in about 20 lines
Stripped of platform code, the whole filter fits on one screen:
state = [x, y, theta, gyroBias]
P = initialCovariance()
onStep(L, gyroDelta, dt):
theta += gyroDelta - gyroBias * dt
x += L * cos(theta)
y += L * sin(theta)
F = jacobianAt(theta, L)
P = F * P * transpose(F) + Q(compassTrust)
onCompass(mx, my, fieldLooksEarthlike):
z = atan2(my, mx)
R = fieldLooksEarthlike ? R_low : R_high // ~10x apart
nu = wrapAngle(z - theta)
S = H * P * transpose(H) + R
if nu * nu > 9 * S: return // 3-sigma gate
K = P * transpose(H) / S
state += K * nu
P = (I - K * H) * P
onQuiet(t > 2 s):
dampLegacyVelocityDrift()
The Swift and Kotlin builds of this loop have to behave identically, down to the covariance trace, because ktuyen's regression matrix replays the same logged walks through both and diffs the outputs. Fixed evaluation order and explicit float widths keep the two platforms within about 1e-6 of each other over a full walk.
Why not a particle filter or full SLAM
Particle filters represent uncertainty as a cloud of hypothetical positions with weights. They shine when ambiguity is genuinely multimodal: you truly do not know whether the user turned left or right at a fork, and one Gaussian cannot hold both answers. They also burn CPU, and resampling injects randomness into a pipeline we keep deterministic for those cross-platform regression tests. For walks under roughly 120 m inside a single garage, an extended Kalman filter with adaptive measurement noise has been sufficient. We would revisit particles if we added map matching against painted parking lines in open lots, where fork ambiguity is common.
Full SLAM with camera or lidar sits even farther outside our constraints. Garages change signage, lighting, and parked-car layout daily, so yesterday's visual map is already partly fiction. Phone-only SLAM without infrastructure beacons also fails the offline, no-account, privacy-first requirements laid out in the on-device post. We picked the boring filter on purpose.
One walk, replayed
A concrete trace from the tuning set. Car at floor B2, pillar E9. The walk back to the stairs is 74 m with two 90° turns and one spiral ramp segment. At step 0 the position sigma is 1.2 m, inherited from the parking-spot anchor. Across the first 30 steps on the open slab, compass updates land steadily, heading stays tight, and position sigma climbs gently on stride noise alone. Then the ramp. R inflates 10×, heading rides on the gyroscope for about 40 steps, and its sigma climbs the whole way up. The chip drops from green to yellow near the top. Back on a clean slab, three compass updates pull heading in again and the chip recovers within 8 steps. Position sigma at the stairs: a bit over 2 m, most of it lateral, nearly all of it heading's fault.
What users actually see
The trace of the position covariance block maps straight to the green, yellow, or red confidence chip on the Find screen. Past a threshold, the UI stops drawing a precise arrow and switches to a coarse distance ring: walk about 60 m, then reassess. Outdoors, while a GPS segment is still valid, the 2×2 position block of P draws an uncertainty ellipse on the map, because a pinpoint dot at that error level would be a lie.
If you want to push these matrices around yourself, Phyzix ships a Kalman demonstration that steps through predict and update with sliders for Q and R. The full indoor pipeline is documented in indoor navigation when GPS dies, and the step odometry that drives every predict lives in step detection and IMU odometry. Bring a phone to a garage and watch the chip change color. The covariance is doing that.
Nghe tên Kalman filter là tưởng toán cao học. Ý tưởng gốc lại thực dụng đến bất ngờ. Giữ một phỏng đoán về vị trí, kèm mức sai có thể của phỏng đoán đó. Bước thêm một bước: filter dự đoán vị trí mới rồi cố tình nới rộng vùng nghi ngờ, vì bước chân chẳng bao giờ chuẩn. Cảm biến gửi về một phép đo — heading la bàn, hay dấu hiệu bạn vừa đứng lại — filter trộn nó vào phỏng đoán theo mức tin của từng nguồn. Phần toán là đại số tuyến tính. Phần triết lý là biết khiêm tốn.
Wheria chạy một EKF rút gọn. Nó không bao giờ chạy một mình: chỗ của nó nằm giữa bộ phát hiện bước và chip confidence trên màn Tìm xe. Bước chân đẩy nó tiến, la bàn với áp kế cãi nhau với nó, còn covariance nó mang theo hiện lên màn hình thành một màu. Bên dưới là trọn vòng lặp: state, predict, update, số liệu tune từ log hầm xe và mấy phương án xịn hơn mà team đã gạt đi.
State: bốn con số và mức nghi ngờ của chúng
Vector state có bốn phần tử: vị trí ngang x, y theo mét local, heading θ theo radian, cộng một bias con quay b_g trôi chậm. Toàn bộ mô hình thế giới chỉ có vậy. Chữ "mở rộng" đến từ chỗ heading đi vào phương trình vị trí qua cos θ và sin θ, vốn phi tuyến, nên mỗi lần predict phải tuyến tính hóa quanh ước lượng hiện tại. Trên điện thoại, giá phải trả là vài phép nhân ma trận 4×4 mỗi bước. Máy Android tầm trung của mình chạy một chu kỳ predict-update hết chừng 11 µs, đo trung bình trên 10.000 chu kỳ ở bench. Pin không hề biết filter tồn tại.
Bên cạnh state là ma trận covariance P, cũng 4×4, ghi lại từng phần tử sai đến đâu và các sai số kéo nhau thế nào. Giá trị thật nằm ở mấy số hạng chéo. Heading lệch kéo vị trí lệch ngang: đi 80 m với heading sai 10° thì trượt ngang cỡ 80·sin 10° ≈ 14 m so với chỗ bản đồ tưởng. Các số hạng chéo trong P đoán được cú trôi đó trước khi bạn kịp thấy lạc. UI cần đúng thông tin này.
Predict: mỗi bước đi là một lần bớt chắc
Mỗi bước được phát hiện kích hoạt một lần predict. Vị trí tiến theo sải chân L dọc heading hiện tại, x ← x + L·cos θ và y ← y + L·sin θ; heading cộng thêm tốc độ con quay đã tích phân trừ đi bias ước lượng. Rồi nghi ngờ phình ra: P ← F·P·Fᵀ + Q, với F là Jacobian của mô hình chuyển động còn Q là nhiễu quá trình. Chỗ phình ra mới là điểm chính. Filter nào chỉ biết siết chặt vùng tin của mình thì là Replace with a natural VI rendering, e.g.: "kẻ nói dối trông cực kỳ tự tin".
Q không lấy từ phụ lục giáo trình. Bọn mình tune từ log thực địa: 3 điện thoại, 14 hầm xe, khoảng 60 lượt đi trong hai tuần, trace predict với update ghi từng bước rồi replay offline. Sải chân chốt ở mức lệch chuẩn 5 cm mỗi bước. Nhiễu heading chừng 2° mỗi bước lúc la bàn đáng tin, lên gần 8° mỗi bước ở vùng nhiều thép, nơi số liệu từ kế không tin nổi.
Chỗ lệch nhau đó quan trọng. Hầm sàn thoáng: la bàn báo về dày, filter siết heading gần như sau vài bước một. Ramp xoắn bọc cốt thép: team đẩy nhiễu đo của từ kế lên một bậc độ lớn, tức là bảo filter cứ bám tích phân con quay giữa các bước, còn spike la bàn nào cũng coi như nghi phạm.
Update: phép đo và lúc nào nên tin
Heading từ kế vào filter dưới dạng z = atan2(m_y, m_x), arctangent của hai thành phần từ trường ngang sau khi bù nghiêng. Bước update tính innovation ν = z − θ, gain K = P·Hᵀ·(H·P·Hᵀ + R)⁻¹, đẩy state đi một đoạn K·ν rồi kéo P xuống. Mọi thứ xoay quanh R, nhiễu phép đo. Độ lớn trường với góc nghiêng nằm trong dải Trái Đất kỳ vọng: R nhỏ, cú chỉnh ăn sâu. Còn khi cột thép xoay "bắc hiệu dụng" đi 40° trong lúc người dùng đi thẳng băng, team phóng to R cho cú chỉnh nhẹ tay lại. Cả vườn thú lỗi la bàn nằm trong bài từ kế và ác mộng la bàn.
Có thêm một cái cổng. Innovation nào vượt 3 lần lệch chuẩn dự đoán là bị loại thẳng, kèm một dòng log. Bug tệ nhất lịch sử filter này từng nằm sát cái cổng đó: innovation tính vắt qua mối nối ±π sinh ra cú chỉnh 359°, dịch ước lượng bay ngang hầm xe. Gói góc không phải chuyện tùy hứng. Dòng wrapAngle trong đoạn code bên dưới gánh cả filter.
Gợi ý tầng từ áp kế được đối xử dè dặt hơn hẳn. Một tầng hầm cao chừng 3,2 m; theo Δh = ΔP/(ρg) với cỡ 12 Pa mỗi mét gần mực nước biển, tín hiệu rơi vào khoảng 38 Pa. Áp kế điện thoại nhiễu 0,3–1 Pa RMS lúc đứng yên, nên bản thân cú đổi tầng nổi rất rõ. Rắc rối là mọi thứ khác cũng làm áp suất nhúc nhích: thang máy, gió HVAC, front thời tiết quét ngang giữa chừng. Vậy nên suy luận áp kế chỉ hiện thành gợi ý song song trên UI, kiểu "nhiều khả năng B3" kèm dải ±1 tầng, còn cao độ thì đứng ngoài state Kalman hoàn toàn. Lock nhầm tầng sẽ phá luôn confidence ngang mà người dùng đang dựa vào để biết còn cách xe bao xa. Phần suy diễn với mớ log áp suất xấu xí nằm ở bài toán áp kế.
Zero-velocity update là mẹo kinh điển của dân pedestrian navigation. Chừng 2 s không thấy bước nào thì nhiều khả năng người dùng đang đứng yên, nên team triệt drift vận tốc còn sót trên mấy nhánh tích phân mang state tốc độ từ thời prototype. Một cái nêm nhỏ. Thang cuốn vẫn qua mặt được nó.
Trọn vòng lặp trong chừng 20 dòng
Bỏ hết code nền tảng, filter nằm gọn một màn hình:
state = [x, y, theta, gyroBias]
P = initialCovariance()
onStep(L, gyroDelta, dt):
theta += gyroDelta - gyroBias * dt
x += L * cos(theta)
y += L * sin(theta)
F = jacobianAt(theta, L)
P = F * P * transpose(F) + Q(compassTrust)
onCompass(mx, my, fieldLooksEarthlike):
z = atan2(my, mx)
R = fieldLooksEarthlike ? R_low : R_high // lệch ~10x
nu = wrapAngle(z - theta)
S = H * P * transpose(H) + R
if nu * nu > 9 * S: return // cổng 3-sigma
K = P * transpose(H) / S
state += K * nu
P = (I - K * H) * P
onQuiet(t > 2 s):
dampLegacyVelocityDrift()
Bản Swift với bản Kotlin của vòng lặp này phải cho kết quả trùng nhau tới tận trace covariance, vì ma trận regression của ktuyen replay cùng một bộ log qua cả hai rồi diff đầu ra. Cố định thứ tự tính, khai báo rõ độ rộng float: hai nền tảng lệch nhau dưới cỡ 1e-6 trên trọn một lượt đi.
Sao không dùng particle filter hay SLAM hẳn hoi
Particle filter tả vùng nghi ngờ bằng một đám mây vị trí giả định có trọng số. Chúng phát huy khi mơ hồ thật sự đa mode: không biết người dùng vừa rẽ trái hay phải ở ngã ba, mà một Gaussian thì không chứa nổi hai đáp án. Đổi lại chúng ngốn CPU; bước resample còn nhét ngẫu nhiên vào một pipeline team cố giữ tất định cho bộ test hai nền tảng. Đi dưới cỡ 120 m trong một hầm, EKF với nhiễu đo thích ứng là đủ. Nếu sau này thêm map matching theo vạch sơn ở bãi hở, nơi ngã ba mơ hồ đầy rẫy, team sẽ cân nhắc lại particle.
SLAM đầy đủ bằng camera hay lidar còn nằm xa vòng ràng buộc hơn nữa. Hầm xe đổi biển báo, đổi đèn, đổi cách xe đậu mỗi ngày; bản đồ hình ảnh hôm qua sang sáng nay đã thành nửa hư cấu. SLAM chỉ dùng điện thoại, không beacon hạ tầng, cũng trượt luôn bộ yêu cầu offline, không tài khoản, privacy-first trong bài on-device. Team chọn cái filter nhàm chán này một cách có chủ đích.
Replay một lượt đi
Một trace cụ thể trong bộ tune. Xe đậu tầng B2, cột E9. Đường về cầu thang dài 74 m, hai khúc quẹo 90° cộng một đoạn ramp xoắn. Ở bước 0, sigma vị trí là 1,2 m, thừa hưởng từ mốc neo chỗ đậu. 30 bước đầu trên sàn thoáng: update la bàn về đều, heading bó chặt, sigma vị trí chỉ nhích từ từ theo nhiễu sải chân. Rồi tới ramp. R phóng 10×, heading bám con quay suốt chừng 40 bước, sigma của nó leo suốt đoạn đó. Chip tụt từ xanh xuống vàng gần đỉnh ramp. Ra lại sàn sạch, ba cú update la bàn kéo heading về nếp trong vòng 8 bước, chip hồi màu. Sigma vị trí lúc tới cầu thang: hơn 2 m một chút, phần lớn là lệch ngang, mà lệch ngang thì gần như toàn lỗi của heading.
Người dùng thật sự thấy gì
Trace của khối covariance vị trí đổ thẳng ra chip confidence xanh, vàng hoặc đỏ trên màn Tìm xe. Vượt ngưỡng là UI thôi vẽ mũi tên chính xác, chuyển sang vòng khoảng cách thô: đi chừng 60 m rồi tính tiếp. Ngoài trời, lúc đoạn GPS còn giá trị, khối 2×2 vị trí của P vẽ ellipse nghi ngờ lên bản đồ; chấm định vị nhỏ xíu ở mức sai đó chỉ là nói dối cho đẹp.
Muốn tự tay vọc mấy ma trận này, Phyzix có sẵn demo Kalman chạy từng bước predict và update với slider cho Q và R. Trọn pipeline indoor nằm trong dẫn đường indoor khi GPS chết; odometry bước chân nuôi từng lần predict thì ở phát hiện bước và IMU. Mang điện thoại xuống hầm, nhìn chip đổi màu. Covariance đang làm đúng việc của nó.