# Step detection and IMU odometry > Double integration drifts fast; counting steps does not. A clear explanation of pedestrian dead reckoning in Wheria. 2026-02-04 · 20 min read · wheria, physics · by ntan (ntan) for uranashel Canonical HTML: https://uranashel.com/blog/step-detection-imu.html --- 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](https://uranashel.com/blog/why-gps-lies.html) covers the physics of that failure. So [Wheria](https://uranashel.com/apps/wheria.html) 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](https://uranashel.com/blog/cross-platform-two-codebases.html). ``` 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](https://uranashel.com/blog/compass-calibration-story.html). 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](https://uranashel.com/blog/barometer-parking-math.html) treats floor detection as statistics rather than arithmetic. The full pipeline is laid out in [indoor navigation](https://uranashel.com/blog/building-wheria-indoor.html), and the filter in [Kalman filters for parking](https://uranashel.com/blog/kalman-filter-parking.html) 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](https://uranashel.com/apps/phyzix.html) 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](https://uranashel.com/blog/on-device-first.html). --- uranashel · [Home](https://uranashel.com/) · [Apps](https://uranashel.com/apps.html) · [Lab](https://uranashel.com/lab.html) · [About](https://uranashel.com/about.html) · [Blog](https://uranashel.com/blog/) · [Developers](https://uranashel.com/developers/) · [API docs](https://uranashel.com/docs/) · [Privacy](https://uranashel.com/privacy.html) Machine-readable: [llms.txt](https://uranashel.com/llms.txt) · [sitemap.xml](https://uranashel.com/sitemap.xml) · [openapi.json](https://uranashel.com/openapi.json) · [API](https://uranashel.com/api/v1/)