Step detection and IMU odometry

Phát hiện bước chân và IMU

Every smartphone carries an inertial measurement unit: a cluster of tiny MEMS chips that report how the device accelerates and rotates. The accelerometer measures specific force in m/s². The gyroscope measures angular rate in rad/s. Together they form the IMU, and the tempting first project in any sensor course is to integrate those signals into position — double-integrate acceleration for displacement, integrate the gyro for heading, draw a trail on a map. I ran exactly that experiment before Wheria existed, phone taped to a clipboard. Within 30 s the trail had wandered into the next building.

Consumer MEMS bias sits at milli-g scale, and integration squares time. That one sentence kills more indoor positioning ideas than anything else in this field. The rest of this post covers the workaround Wheria actually ships: counting footfalls.

Why double integration dies in under a minute

Work the numbers. A constant accelerometer bias b integrates to a velocity error b·t and a position error ½·b·t². Take 1 milli-g, about 0.0098 m/s², a respectable figure for a phone chip. After 30 s the position error is 0.5 · 0.0098 · 30² ≈ 4.4 m. Annoying. Survivable.

Attitude error is the real killer. The accelerometer senses gravity plus motion, and recovering motion means subtracting gravity along whatever direction the phone currently believes is down. Let the gyro drift the attitude estimate by a single degree and a slice of gravity leaks into the horizontal channels: g·sin(1°) ≈ 0.17 m/s², roughly 17 times the bias we just shrugged off. Double-integrate that over the same 30 s and you get about 77 m of phantom displacement. There is the next building. Worse, gyro drift keeps growing while you walk, so the leak widens and the error compounds faster than quadratically.

Underground, no external fix rescues you: GPS horizontal error in a garage often exceeds 30 m when the receiver reports anything at all, and why GPS lies covers the physics of that failure. So Wheria never tries to be a general-purpose inertial navigator. It uses pedestrian dead reckoning, which treats each footfall as a discrete event, not a continuous integral. Detect a step. Multiply by an estimated stride length, rotate by the current heading, advance a point on a local map. Error still accumulates, but it grows roughly with the square root of step count instead of exploding with elapsed time, and that difference is what makes a 4-minute garage walk usable instead of absurd.

What a step looks like to an accelerometer

Walking at a normal 1.2–1.5 m/s, your body bounces vertically once per footfall. The bounce repeats at a cadence of roughly 1.5–3 Hz, one complete up-down cycle every 0.33–0.67 s. Those peaks survive a phone sitting in a pocket at an odd angle, which is the entire reason PDR works on consumer hardware.

Orientation is the first problem. In a trouser pocket the axes point somewhere arbitrary, so the detector drops individual axes and works on the magnitude, |a| = √(ax² + ay² + az²). Magnitude is rotation-invariant; the walking peaks show up at any pocket angle. Direction information is lost, and that costs nothing here because heading comes from other sensors entirely.

The raw magnitude also carries a DC component near 9.81 m/s² from gravity, plus low-frequency arm swing and high-frequency hand tremor. A band-pass filter around the walking band strips both ends and leaves the gait peaks standing alone.

The detector in 20 lines

Peak picking sounds trivial until you meet the threshold question. A fixed threshold tuned for a hand-held phone misses steps in a padded pocket, where fabric damps the bounce. Our threshold adapts instead: over a sliding 2 s window the detector computes the local mean and standard deviation of the filtered magnitude, then accepts a peak that exceeds mean + k·σ, with k running from about 1.2 for a trouser pocket to about 1.8 for a phone held in a swinging hand. A minimum interval of 280 ms between accepted steps rejects shuffles and double-bounces; 280 ms corresponds to a cadence of 3.6 Hz, comfortably above anything a walking human produces.

Between steps the gyroscope z-axis, rotation about the vertical when the phone is upright, confirms turns. A heading change with matching step cadence is a real corner. Angular rate with no steps is someone fidgeting with their phone.

Sample-rate parity matters more than it sounds. iOS and Android deliver IMU samples at different native rates, anywhere from 50 to 200 Hz depending on device and power mode. Shared logic resamples both platforms to a fixed 100 Hz before the detector runs, so identical motion yields the same step count on an iPhone and a Pixel. Without that stage, ktuyen's cross-platform regression matrix would be comparing two different algorithms; how that shared core is split between Swift and Kotlin is the subject of the two-codebase post.

samples = resample(imu, 100 Hz)          // parity across platforms
mag     = sqrt(ax² + ay² + az²)          // orientation-free
band    = bandpass(mag, walking band)

on each new sample s:
    w   = last 2 s of band
    thr = mean(w) + k · std(w)           // k = 1.2 … 1.8
    if s is a local peak and s exceeds thr:
        if now − last_step ≥ 280 ms:
            steps += 1
            last_step = now
            advance(stride, heading)     // PDR position update

Bench notes, for honesty: 6 phones (3 iOS, 3 Android), 40 corridor walks of 20 m each, alternating hand and front pocket, ground truth counted from video. Worst miscount was 3 steps out of 27; the median run missed 0 or 1. Escalator rides were excluded on purpose, for reasons covered below.

Stride length is the hidden variable

A detected step says the user moved. It does not say how far. Distance comes from stride length, the horizontal span of one footfall, and stride is where PDR hides most of its error. Wheria starts from a default of about 0.72 m, which matches average adult walking on flat ground. Enter your height in settings and the app applies a common anthropometric estimate, stride ≈ 0.415 · height; a 1.70 m adult lands at 0.71 m, close to the default by design.

An optional calibration walk tightens this further. Traverse a known distance (20 m in a straight corridor works well) and the app solves stride = distance / step_count. Some research papers tie stride to step frequency through power-law models such as Weinberg's relation, where stride scales with the fourth root of the vertical acceleration swing per step; walk faster and your steps lengthen slightly. Wheria ships a modest version of that tie-in. It is not magic. A 5% stride error over an 80 m walk with 110 steps produces 4 m of longitudinal error before heading is even considered, and no cadence model removes a systematic 5%.

That arithmetic is why Wheria shows distance bands instead of fake precision, and why the app nudges people who regularly park in large structures toward the calibration walk. One 20 m walk, done once, buys back a 4 m bias on every later trip.

An honest error budget

Assemble the pieces for a concrete trip: 80 m through a spiral ramp, 110 detected steps, destination floor B2, pillar E9. Stride off by 5% puts you 4 m off along the path. Heading off by 10° at the end gives a lateral error of 80 · sin(10°) ≈ 14 m in the worst geometry. Neither number is pessimistic fiction; both fall straight out of ktuyen's garage test matrix whenever compass trust is low and the user skipped calibration.

The UI answers that uncertainty with design. The path polyline fades as it ages, so a stale trace looks stale. A confidence chip derived from the fusion covariance moves through green, yellow and red. Distances read "about 45 m" because "44.7 m" would claim a precision the sensors cannot deliver. And each new parked event resets the odometry state, keeping error bounded per trip instead of compounding across weeks of history.

What PDR cannot do

The limits are hard and we do not hide them. Escalators increment step count without equivalent horizontal travel. Moving walkways do the reverse and decouple footfalls from ground speed. Running breaks the cadence band the detector expects. A phone clamped to a shopping cart handle produces an acceleration profile that looks nothing like walking: vibration everywhere, no 2 Hz bounce. When accelerometer variance spikes in a pattern that fails the gait checks, Wheria flags unusual motion and stops drawing, because a silently wrong path costs more trust than an honest gap.

One layer in a taller stack

Step detection on its own produces a chain of guesses. Heading comes from gyroscope and magnetometer fusion, with every steel-induced failure mode described in the compass post. Floor hints come from the barometer: a garage floor is about 3.2 m tall, pressure drops roughly 12 Pa per metre climbed, and phone barometers carry 0.3–1 Pa RMS of noise, which is why barometer math treats floor detection as statistics rather than arithmetic. The full pipeline is laid out in indoor navigation, and the filter in Kalman filters for parking ties the measurements together so a single step update cannot make the map overconfident.

If you would rather see the peaks before trusting an app to count them, Phyzix exposes raw accelerometer and gyroscope traces. Walk down a corridor with the plot open; the 2 Hz bounce is unmistakable. Everything above runs on-device, with no account and no server round-trips, for the reasons in on-device first.

Điện thoại nào cũng mang một cụm IMU: mấy con chip MEMS bé xíu đo máy đang gia tốc và xoay ra sao. Cảm biến gia tốc trả lực riêng theo m/s². Gyro trả tốc độ góc theo rad/s. Ai học cảm biến cũng muốn thử trò kinh điển: tích phân hai lần gia tốc ra quãng đường, tích phân gyro ra hướng, vẽ vệt đi lên bản đồ. Mình thử rồi, từ trước khi có Wheria, điện thoại dán băng keo lên tấm clipboard. Chưa tới 30 s, vệt đường đã bò sang tòa nhà bên cạnh.

Bias của MEMS consumer cỡ milli-g. Tích phân lại khuếch lỗi theo bình phương thời gian. Hai câu đó giết nhiều ý tưởng định vị indoor hơn bất cứ thứ gì khác. Phần còn lại của bài nói về lối thoát mà Wheria thật sự ship: đếm bước chân.

Vì sao tích phân hai lần chết trong chưa đầy một phút

Thử tính. Bias không đổi b tích phân thành lỗi vận tốc b·t, rồi thành lỗi vị trí ½·b·t². Lấy 1 milli-g, khoảng 0,0098 m/s², mức khá tốt cho chip điện thoại. Sau 30 s: 0,5 · 0,0098 · 30² ≈ 4,4 m. Khó chịu. Nhưng sống được.

Thứ giết chết là lỗi attitude. Cảm biến gia tốc đo cả trọng trường lẫn chuyển động. Muốn tách chuyển động phải trừ trọng trường theo hướng máy đang tin là "xuống". Gyro drift làm ước lượng attitude lệch chỉ 1°, một lát trọng trường tràn sang kênh ngang: g·sin(1°) ≈ 0,17 m/s², gấp chừng 17 lần cái bias vừa bỏ qua. Tích phân hai lần trong đúng 30 s đó: khoảng 77 m dịch chuyển ảo. Tòa nhà bên cạnh là đây. Tệ hơn, drift còn tăng dần khi bạn đi, chỗ rò rộng ra, lỗi phình nhanh hơn cả bình phương.

Dưới hầm không có nguồn sửa nào cứu. GPS trong hầm sai ngang thường quá 30 m, khi máy còn chịu trả kết quả; vật lý của cú fail đó nằm trong vì sao GPS nói dối. Nên Wheria không cố làm bộ dẫn đường quán tính tổng quát. App dùng pedestrian dead reckoning: coi mỗi bước chân là một sự kiện rời rạc thay vì một tích phân liên tục. Phát hiện bước. Nhân với stride ước lượng, xoay theo heading hiện tại, đẩy một điểm trên bản đồ local. Lỗi vẫn cộng dồn, nhưng tăng cỡ căn bậc hai số bước chứ không nổ theo thời gian. Nhờ vậy một lượt đi hầm 4 phút mới dùng nổi.

Bước chân trong mắt cảm biến gia tốc

Đi bộ bình thường 1,2–1,5 m/s, người nảy dọc theo từng bước. Nhịp nảy nằm quanh 1,5–3 Hz, mỗi chu kỳ lên xuống mất 0,33–0,67 s. Đỉnh này sống sót cả khi máy nằm lệch góc trong túi quần. PDR chạy được trên phần cứng consumer là nhờ đúng điểm đó.

Vấn đề đầu tiên: hướng đặt máy. Trong túi, trục máy chỉ lung tung. Nên bỏ trục, dùng biên độ |a| = √(ax² + ay² + az²). Biên độ không đổi khi xoay máy, đỉnh bước hiện ra ở mọi góc túi. Mất thông tin phương, nhưng không sao: heading đã có cảm biến khác lo.

Biên độ thô còn dính thành phần DC quanh 9,81 m/s² do trọng trường, cộng thêm vung tay tần số thấp và run tay tần số cao. Một bộ lọc band-pass quanh dải đi bộ cắt hai đầu, chừa lại đỉnh gait đứng một mình.

Detector trong 20 dòng

Tìm peak nghe đơn giản, cho tới câu hỏi ngưỡng. Ngưỡng cố định hợp tay cầm sẽ sót bước trong túi quần, vì vải hãm bớt nhịp nảy. Nên ngưỡng phải thích ứng: trên cửa sổ trượt 2 s, detector tính mean và độ lệch chuẩn cục bộ của biên độ đã lọc, nhận peak khi vượt mean + k·σ. k chạy từ khoảng 1,2 trong túi quần tới khoảng 1,8 khi cầm tay vung. Hai bước liên tiếp phải cách nhau ít nhất 280 ms để loại bước lê và cú nảy đôi; 280 ms tương đương cadence 3,6 Hz, cao hơn hẳn mọi người đi bộ.

Giữa các bước, trục z của gyro — xoay quanh phương thẳng đứng khi máy dựng — xác nhận cú rẽ. Đổi heading kèm đúng nhịp bước là góc quẹo thật. Có tốc độ góc mà không có bước là ai đó đang nghịch máy.

Chuyện sample rate quan trọng hơn tưởng. iOS và Android trả sample IMU ở tốc độ gốc khác nhau, từ 50 tới 200 Hz tùy máy với chế độ pin. Trước khi detector chạy, lớp logic dùng chung resample cả hai nền tảng về 100 Hz cố định. Cùng một chuyển động, iPhone và Pixel phải ra cùng số bước. Thiếu tầng này, ma trận regression đa nền tảng của ktuyen thành ra so sánh hai thuật toán khác nhau; cách chia phần core chung giữa Swift và Kotlin nằm ở bài hai codebase.

samples = resample(imu, 100 Hz)          // parity across platforms
mag     = sqrt(ax² + ay² + az²)          // orientation-free
band    = bandpass(mag, walking band)

on each new sample s:
    w   = last 2 s of band
    thr = mean(w) + k · std(w)           // k = 1.2 … 1.8
    if s is a local peak and s exceeds thr:
        if now − last_step ≥ 280 ms:
            steps += 1
            last_step = now
            advance(stride, heading)     // PDR position update

Ghi chú bench cho sòng phẳng: 6 máy (3 iOS, 3 Android), 40 lượt đi hành lang 20 m, xen kẽ cầm tay với bỏ túi trước, đếm chuẩn bằng video. Lệch tệ nhất 3 bước trên 27. Lượt trung vị lệch 0 hoặc 1. Thang cuốn bị loại khỏi bài đo có chủ đích, lát nữa nói vì sao.

Stride là biến ẩn

Bước được phát hiện chỉ nói bạn đã di chuyển. Bao xa thì chưa. Quãng đường đến từ stride — độ dài ngang của một bước chân — và stride là chỗ PDR giấu phần lớn lỗi. Wheria mặc định khoảng 0,72 m, khớp người lớn đi trên nền phẳng. Nhập chiều cao trong settings, app áp ước lượng nhân trắc quen thuộc stride ≈ 0,415 · chiều cao; người 1,70 m ra 0,71 m, sát mặc định luôn, cố ý vậy.

Muốn sát hơn thì calibrate. Đi một quãng biết trước, 20 m hành lang thẳng là đẹp, app tính stride = quãng đường / số bước. Vài paper buộc stride vào tần số bước qua mô hình lũy thừa, kiểu quan hệ Weinberg: stride tỉ lệ với căn bậc bốn của biên độ gia tốc dọc mỗi bước, đi nhanh thì bước dài ra chút. Wheria ship bản nhẹ của mối buộc đó. Không phải phép màu. Sai stride 5% trên quãng 80 m với 110 bước là lệch dọc 4 m, chưa đụng tới heading. Không mô hình cadence nào xóa nổi 5% hệ thống.

Vì phép tính đó, app hiện dải khoảng cách thay vì số giả chính xác, đồng thời nhắc người hay đỗ hầm lớn đi một lượt calibrate. Một lượt 20 m, làm đúng một lần, đổi lấy việc bỏ được 4 m bias mỗi chuyến sau.

Định mức lỗi trung thực

Ghép các mảnh cho một chuyến cụ thể: 80 m qua ramp xoắn, 110 bước, đích là tầng B2 cột E9. Stride sai 5%: lệch 4 m dọc đường. Heading cuối sai 10°: lỗi ngang 80 · sin(10°) ≈ 14 m trong hình học xấu nhất. Hai con số này không phải bi quan bịa ra. Ma trận test hầm của ktuyen cho ra đúng vậy mỗi khi la bàn kém tin và người dùng bỏ qua calibrate.

Giao diện trả lời độ bất định bằng thiết kế. Polyline phai dần theo tuổi, vệt cũ nhìn ra cũ. Chip confidence lấy từ covariance của fusion, chuyển xanh, vàng rồi đỏ. Khoảng cách ghi "khoảng 45 m", vì "44,7 m" là hứa một độ chính xác cảm biến không có. Mỗi lần đỗ mới reset trạng thái odometry, lỗi bị chặn trong từng chuyến chứ không cộng dồn qua nhiều tuần lịch sử.

PDR chịu thua ở đâu

Giới hạn cứng, team không giấu. Thang cuốn ghi thêm bước mà quãng ngang không tương xứng. Băng chuyền thì ngược lại, tách bước chân khỏi tốc độ mặt đất. Chạy bộ phá dải cadence detector đang chờ. Điện thoại kẹp vào tay đẩy xe hàng cho profile gia tốc chẳng giống đi bộ chút nào: toàn rung, không có nhịp nảy 2 Hz. Khi phương sai gia tốc tăng vọt theo kiểu trượt các phép kiểm tra gait, Wheria báo chuyển động bất thường và ngừng vẽ. Đường sai mà im lặng tốn niềm tin hơn một khoảng trống thật thà.

Một lớp trong stack cao hơn

Phát hiện bước đứng một mình chỉ cho ra chuỗi điểm đoán. Heading lấy từ fusion gyro với từ kế, đủ mọi kiểu fail vì thép trong bài la bàn. Gợi ý tầng nhờ áp kế: mỗi tầng hầm cao chừng 3,2 m, áp suất giảm chừng 12 Pa mỗi mét leo, còn áp kế điện thoại nhiễu 0,3–1 Pa RMS, nên toán áp kế xử phát hiện tầng như một bài thống kê chứ không phải phép trừ. Pipeline đầy đủ nằm trong bài dẫn đường indoor. Bộ lọc ở Kalman cho đỗ xe buộc các phép đo lại với nhau, để một cập nhật bước không làm bản đồ tự tin quá đà.

Muốn tự nhìn đỉnh trước khi tin app đếm hộ, mở Phyzix: app phơi trace thô của cảm biến gia tốc và gyro. Cầm máy đi dọc hành lang với đồ thị đang mở, nhịp 2 Hz hiện rõ không lẫn đâu được. Toàn bộ chạy on-device, không tài khoản, không gọi server, vì mấy lý do trong bài on-device first.