Case study · Embedded & signal processing
A rep counter out of a bottle-cap toy
A convenience-store promo puck contains an nRF52810 with an LSM6DSL IMU, and its wire format was already documented by the community. I built the counting on top of it: the detection algorithm, the evaluation on labelled recordings, and an app that runs entirely in the phone browser over Web Bluetooth — no server, no account, no app store, and the data never leaves the device.
What is mine and what is not
The BLE protocol was not my work. The frame layout, the button byte and the scaling factors were worked out by the community in TrikiScope and zabka-triki-hardware. I read the sensor using what they documented.
What I built is everything above the wire: the rep-detection algorithm, the measurement methodology that justifies it, exercise recognition, the browser app and its offline shell, the desktop capture tooling, tests and CI.
The problem
Counting your own reps mid-set is the one thing you cannot do while lifting. Existing trackers want a phone in your hand, an account, and a subscription. I wanted the sensor stuck to the weight and everything else to disappear.
Why the obvious algorithm does not work
The textbook approach is to integrate acceleration into velocity and look for movement cycles. It does not survive contact with real data. In one 888-second capture that integrator drifted to 15.4 m/s where a real barbell peaks near 2 — inter-frame gaps reached 423 ms while acceleration hit 10.7 g. Reps came out in bursts, several per second.
What the data forced instead:
vertical acceleration → band-pass (0.12 s vs 1.2 s) remove noise and drift → integrate → pseudo-velocity → band-pass again kill integrator drift → count zero-crossing cycles one cycle == one rep
Integration is essential for a non-obvious reason: vertical acceleration has two positive lobes per squat — you accelerate and then decelerate in each direction — so counting cycles on acceleration double-counts every rep. After integration there is exactly one cycle per rep.
Measured against labelled recordings, this cut total error from 26 to 10 and false positives while walking from 18 to 2.
Things only real data tells you
- Acceleration is zero at peak velocity, twice per rep. A naive "is the device still?" check therefore fires mid-rep and wrecks the filter state. Stillness has to be judged over a window.
- Gravity estimation must be adaptive — fast while still, so a new mounting orientation locks in immediately; slow while moving, so the filter does not track the rep itself and eat its amplitude. This is what makes the mount orientation-free.
- Off-by-one: the first upward crossing only opened a cycle, so ten reps always reported nine.
- Frame timing is ragged — roughly 60 Hz with occasional 400 ms gaps — so
dtis clamped and filters reset across a gap rather than integrating nonsense.
Sets are explicit, on purpose
Counting only runs between start and finish. Walking between machines, picking the weight up and sitting back down cannot be counted — by construction rather than by threshold tuning. In the labelled captures those transitions were the source of every single false positive.
The result I am most pleased with is a negative one
Each exercise can fit its own detector parameters from labelled sets. That fitting is gated on evidence, and the gate exists because of a measurement: leave-one-out over 17 labelled sets showed that fitting three parameters to 2–3 noisy sets made held-out accuracy worse than plain defaults — 80.9 % versus 87.0 %. So tuned parameters only replace defaults after at least four labelled sets and a clear in-sample margin. Corrections are always collected; they are simply not trusted early.
Six further ideas were tested against that dataset and all lost to plain defaults — rotation-aware thresholds, impact rejection, autocorrelation re-count, median-cadence rhythm repair, a per-exercise calibration ratio, and ungated parameter fitting. They are documented in the repository so nobody re-tries them blind. The bottleneck is labelled sets per exercise, not cleverness.
Exercise recognition
Each set's motion signature — rep cadence, rotation peak and mean, acceleration RMS and peak — identifies the exercise by nearest centroid. Leave-one-out over the same 17 sets: 82 % top-1, 100 % top-2, against a 17 % chance baseline across six exercises. The app pre-picks the exercise after a set; your choice always wins and updates the centroid.
Where it works and where it does not
Overall measured accuracy is ~87 % of reps across 34 labelled sets over two sessions, every label cross-checked against a manual log. Useful, but not good enough to trust blindly — which is why the app always asks you to confirm the count.
The split is instructive. On a weight stack the counter was exact on 7 of 7 sets; on a barbell it was around 83 %, exact on none of 9. The bar tilts, whips and bounces, so its vertical signal is genuinely noisier. The mount is fine; the movement is the hard part. Failure modes also point in opposite directions between exercises — a lying triceps extension roughly doubles, a hammer curl undercounts — so no single global rule fixes both.
Decisions & lessons
- Hardware-agnostic by design: the app speaks the open Nordic UART Service and reads any beacon streaming 14-byte IMU frames. The promo puck was the dev board, not the product.
- No backend at all: Web Bluetooth in the browser means no server to run, no account to create, and no privacy question to answer — the motion data physically never leaves the phone.
- Measure before you optimise. Every accepted change here is backed by a number on labelled recordings, and the ideas that lost are written down alongside the ones that won.
- Honest scope: 17 labelled sets is a small dataset, and the write-up says so. The gate on parameter fitting exists precisely because the dataset is small.