Counting Dog Steps Offline: One Unified Pipeline for a Collar Pedometer
A step counter on a dog's collar sounds like it should be a solved problem. After all, human pedometers have shipped in phones and watches for over a decade. But a dog is not a human walking upright with a phone in their pocket. The sensor sits on the neck, the animal moves through four distinct gaits, and the device has to do all of its work offline on a tiny microcontroller with no cloud to lean on. Our first design was a chain of hard-coded heuristics, and it broke the moment a dog did anything other than walk in a straight line.
This post walks through the redesigned pipeline that came out of our engineering review of the original step-count design doc. Instead of a stack of fixed thresholds and one-shot estimates, the new version is a single continuous pipeline that adapts to whatever the dog is doing: estimating attitude, tracking cadence, filtering around the live cadence, detecting events, and only then deciding which gait the animal is in. The whole thing runs from two entry points, one for raw signal in and one driver that pulls device data, and produces a footfall and stride count per gait class.
Why the First Design Failed
The original doc treated step counting as a one-shot estimation problem. It computed a single sagittal-plane orientation with a Wahba solve, low-pass filtered the signal at a fixed 4 Hz, applied a hard 2.25 to 3.75 Hz cadence gate borrowed from human locomotion, and then detected steps by looking for falling-flank zero-crossings. Every one of those choices assumed the dog was doing exactly one thing at a fixed speed.
Dogs do not cooperate with that assumption. They drift between a quiet stand, a walk, a trot, a canter, and a flat-out gallop, sometimes within a few seconds. A fixed cadence gate that fits a trot rejects a gallop. A one-shot attitude estimate drifts the moment the collar swings. And the falling-flank zero-crossing detector was, per Brajdic and Harle's 2013 survey, the worst performing step detector of the bunch. We needed something that tracked the dog rather than assuming it.
The Pipeline at a Glance
Raw three-axis acceleration, and optionally gyroscope, arrives at roughly 100 Hz. From there the signal flows through eight stages: a continuous attitude estimator, extraction of the dorso-ventral channel, cadence tracking, an adaptive band-pass, wavelet-based event detection, an autocorrelation rhythm gate, gait classification, and finally a footfall and stride report. Each stage feeds the next, and crucially the later stages adapt to the cadence the earlier stages measure rather than to a number we guessed at design time.

Stage 1 and 2: A Continuous Attitude Estimator
The first job is to know which way is up, continuously, so we can isolate the dorso-ventral (up and down) bounce that carries the step signal. The old one-shot Wahba solve gave us a single orientation and then drifted. We replaced it with a Mahony complementary filter running on SO(3), the rotation group, producing a quaternion for every sample. The clever part is an amplitude-adaptive gain: the filter trusts the accelerometer most when the magnitude is near one g, which happens during quiet moments, and de-weights it during footstrike transients when the magnitude swings far from one g. That keeps gravity tracking honest even while the dog is pounding the ground.
The gain falloff is a simple exponential in the deviation from one g, which means it degrades gracefully rather than switching on a hard threshold:
dev = abs(a_norm - 1.0)
kp = kp_base * math.exp(-4.0 * dev) # dev=0 -> full trust; dev=0.5 -> ~0.135x
err = np.cross(a_hat, v) # v = predicted gravity in body frame
omega = g_rad + kp * err + integral_err
q_next = q[i] + 0.5 * quat_mul(q[i], omega) * dt
q_next /= np.linalg.norm(q_next)With a quaternion for every sample, we rotate the body-frame acceleration into the world frame and subtract gravity. The vertical channel that remains is the dorso-ventral signal, the up-and-down bounce of the neck that every footfall pushes into. That single channel is what the rest of the pipeline works on.
Stage 3 to 5: Track Cadence, Then Filter and Detect Around It
This is the heart of the redesign. Instead of assuming a cadence band, we measure it. A four-second short-time Fourier transform locks the dominant peak in the 0.5 to 8 Hz range, which gives us f0, the live dorso-ventral cycle frequency. Everything downstream is parameterised by f0 rather than by a fixed constant we chose at design time.
Once we have f0, we apply a per-window band-pass at 0.6 to 2.5 times f0, a fourth-order zero-phase Butterworth, so the passband follows the dog. Then, instead of the old falling-flank zero-crossing detector, we run a continuous wavelet transform with a Mexican-hat (Ricker) wavelet tuned to the dorso-ventral period and pick its peaks. Wavelet peak detection was one of the strongest performers in Brajdic and Harle's evaluation, the opposite end of the table from where we started.
f0 = estimate_cadence_hz(seg, fs) # STFT-locked DV frequency
seg_bp = adaptive_bandpass(seg, fs, f0) # [0.6*f0, 2.5*f0] Butterworth
peakiness = autocorr_peakiness(seg_bp, fs, f0) # rhythm gate (Stage 6)
events = cwt_event_indices(seg_bp, fs, f0) # Ricker-wavelet peak pickingStage 6: An Honest Rhythm Gate
Finding peaks is not the same as confirming the dog is actually moving. Plenty of things are rhythmic, including a dog breathing while it lies still. The old design used a brittle seven-of-ten candidate rule to decide whether a window counted. We replaced it with an autocorrelation peakiness test: we compute the autocorrelation of the band-passed signal and check whether the peak at the stride lag is at least half the value at zero lag. A clean, repeating gait produces a tall peak at the stride lag; noise and one-off head shakes do not. It is a single, interpretable number rather than a vote count.
Stage 7 and 8: Gait Class, Footfalls, and Strides
Only at the end do we name the gait, and we do it from f0 combined with two sanity gates. A breathing dog at rest is rhythmic, so a peakiness gate alone would happily count its chest rising and falling. We require three things together: a peakiness above the threshold, a dorso-ventral residual amplitude above roughly 0.1 g (breathing sits near 0.01 to 0.03 g), and an f0 above 1 Hz. Clear all three and we map the stride frequency onto a gait: quiet, walk, trot, canter, or gallop.
if peakiness < 0.4 or dv_rms_g < 0.10 or f0 < 1.0:
return "quiet" # breathing / scratching rejected here
f_stride = f0 / 2.0 # DV cycles at ~2x stride freq (Pfau 2008)
if f_stride < 1.0: return "walk"
if f_stride < 1.4: return "trot"
if f_stride < 1.8: return "canter"
return "gallop"The final report separates two things that are easy to conflate. A footfall, in our user-facing convention, is one dorso-ventral cycle, the up-and-down impulse a single step pushes into the neck. A stride is one full locomotor cycle, which we read off the autocorrelation lag. The relationship between the two depends on the gait, because the neck does not bounce once per biological footfall. At a walk and trot the dorso-ventral channel shows two cycles per stride, at a canter one to two, and at a flat gallop just one. We fold that mapping into the count so the per-gait numbers stay honest rather than over-counting at the walk and under-counting at the gallop.

Two Entry Points
The whole pipeline lives behind two functions. The first, process_signal, takes a raw IMU segment plus a sample rate and returns the per-window breakdown: cadence, peakiness, gait, footfalls, and strides. It is pure signal in, structured result out, which makes it trivial to test against recorded bouts. The second, main, is the on-device driver: it pulls batches for a given device, stitches consecutive readings into contiguous segments wherever the timestamps stay monotonic, and runs the pipeline over each. Splitting the algorithm from the data plumbing means we can iterate on the signal processing without touching the device integration, and vice versa.
What We Took Away
The biggest shift was philosophical: stop assuming the dog and start tracking it. Every fixed constant in the original design, the cadence gate, the low-pass cutoff, the candidate vote, was a guess about an animal that refuses to be average. Replacing each guess with a quantity we measure live, the attitude, the cadence f0, the rhythm gate, and the amplitude floor, turned a brittle chain into something that follows the dog from a quiet stand to a gallop.
The gait classifier is still a rule-based stub, and we know it. The engineering review calls for a small int8 1D CNN in its place, and the design leaves a clean seam to drop one in when a trained .tflite exists. Until then, the rules are interpretable, debuggable, and good enough to ship, which is exactly what you want from a placeholder. If there is one lesson to pass on, it is that adaptive beats fixed almost every time a real animal is on the other end of the sensor.