Blind at the Bottom

August 5, 2026 · 12 min read

BDYWRK is an iOS app that counts squats, sit-ups, pull-ups, and push-ups using sensors in your phone. Three of those are tractable because the exercise moves the phone itself, so an accelerometer or a gyroscope gives you something to work with.

Push-ups can be counted clumsily from an accelerometer too, but vision is a more robust way to track them. The phone goes on the floor, screen up, and you do push-ups over it.

9:41

Push-ups Placement

Place the phone face up on the floor under your chest. Reps are tracked using the front camera.

This is an in-app tooltip a user can play before a set, to show them where to place the phone and what a rep should look like. That is all nine frames of it, which is roughly where my animation career ended.

Since the front camera points straight up at you, the first implementation used the obvious signal: as you lower yourself, your body covers the lens and the image goes dark, and when you push back up the light returns, so a bright to dark to bright cycle is one rep. It needed no pose estimation, no machine learning, and no model to ship, because the whole input was the average brightness of the frame.

That is what I built first, and this is the story of why it was not enough and of the rebuilds it took to fix it.

1.0: counting the dips

The first detector was a over per frame that is smoothed out with an .

The smoothed signal is compared against a slowly drifting baseline of what "uncovered" looks like. Falling far enough below the baseline means you are on the way down, and climbing back near enough means you have come up, which is a rep. The two cutoffs are deliberately different numbers, because a signal hovering at a single threshold would cross back and forth on noise alone and turn one slow push-up into a dozen. That gap between them is , the same trick a thermostat uses so it does not cycle the AC every few seconds.

Two timing constraints finish the job: a dip has to last a minimum duration, and consecutive reps have to be separated by a minimum gap. Both exist to rule out anything faster than a controlled push-up.

That is about forty lines of code, it runs in microseconds, and on a clean clip it genuinely works:

shaded = one labelled rep0.000.250.500.751.000s5s10s15s20scenter brightnessbrightness detector fired
One number a frame is the whole of what the 1.0 detector sees. This is a real set of 7 push-ups, and brightness swings from near 1.0 down to roughly 0.17 and back, once per rep. The red marks are the calls the detector actually made, 5 fires for 7 reps, one of them before the set even starts. Hover or use the arrow keys to scrub.

Even on a signal that clean, the thresholds still had to be tuned, so I tuned them hard with a over 20,580 parameter combinations. On the clips it was tuned against, all of them full of push-ups, the best configuration scored a perfect .

Then I pointed it at a clip where nothing happens.

Why it was (obviously) not enough

no labelled reps: nothing happens in this clip0.000.250.500.751.000s10s20s30s40scenter brightnessbrightness detector fired
This is the same signal over 47 seconds of an empty, dimly lit room. Nobody is exercising, and every red mark is a push-up the detector believed it saw. It counts 5.

No amount of tuning fixes this, because it is not a tuning problem. The detector's entire model of the world is "the frame got darker, then brighter," which fits a push-up, but also a passing shadow, or the auto-exposure hunting that is actually happening above. Across a limited labeled set it invents phantom reps in still rooms and around people moving without exercising. Counting the dips was the easy half, but deciding whether an oscillation is push-ups at all is the hard one, and one brightness channel could not make that call.

2.0: give it eyes

An obvious fix is to provide a signal to disambiguate the cases brightness could not: Apple's Vision framework hands you the position and confidence of every major body part, thirty times a second, for free.

The design was narrower than "find the push-up." Since there is nothing to see at the bottom of a push-up, pose never had to see the rep itself, only confirm the top. When you push back up, your body clears the lens and a shoulder should reappear, so requiring a at the up-transition checks that a real body returned, rather than just a dip in brightness.

The logic was sound, and the detector got worse anyway even after a similar tuning cycle: both fell, while phantom reps on still-room clips went from 1.2 to 2.6.

Understanding why is the part of this project I would keep if I had to throw away the rest.

1.2 percent: how often pose saw a body during a rep

The table below shows the fraction of frames on one labeled clip where any cleared .

When Frames with a keypoint
Inside a rep 1.2%
Between reps 52.3%
Nuisance clip: walking around with a water bottle 58.5%

The table shows pose failing in both directions at once. Inside a rep, pose is barely there: at the bottom of a push-up your chest is practically on top of the lens, and there is no body left to recognize. Between reps it fires no more often than it does for someone walking around with a water bottle, so my check was really answering "is a person in frame" rather than "did a rep just finish."

So pose did not sharpen the detector on its own, and it mostly complicated the rules instead. Should it gate brightness, veto it, or vote alongside it, and at what confidence floor? Adding another signal did not add a rule so much as complexity and ambiguity.

Here are both signals on one clip, a set of seven labeled reps over twenty-two seconds. It is a different clip from the table above, and the same pattern holds: about 2.5% of frames inside reps have a keypoint, against 29% between them.

shaded = one labelled rep0.000.250.500.751.000s5s10s15s20scenter brightnesscan Vision see a body?brightness detector fired
This is everything the 2.0 detector has to work with: brightness, and whether Vision can see a body. The body line drops to the floor inside every shaded rep. Hover or use the arrow keys to scrub, and click a legend entry to hide a series.

No better pose model fixes that, since nothing recognizes a body part closer than the lens's minimum focal distance. The failure is geometric, not algorithmic.

3.0: stop writing the rules

Both previous versions failed the same way: every rule I hand-wrote was a rule about one instant, such as "is it dark now" or "is a shoulder visible now." But a push-up is not an instant: it is a set of movements about a second and a half long. So instead of coding conditional patterns I allowed a deep learning model to learn from the labeled data which signals separate a rep from everything else.

I started with a simple baseline: a , which trains in seconds on minimal data. But per-frame decisions are fundamentally flawed: a single dark frame looks identical whether it belongs to a push-up or a passing shadow. Fixing this meant hand-crafting temporal features (perhaps for an model), which drops you right back into guessing which time windows matter and maintaining the pipeline to encode them.

That leaves the models that read a stretch of time natively. A small or a transformer re-reads a fresh window of frames on every new frame, while a carries one small running state and updates it once. Either family of models would likely have worked, but the recurrent one was simply the better fit for something that has to run live on the phone (as a CoreML streaming model), beside the camera and the pose estimator, at a few hundred kilobytes. A transformer also wants more training examples than I was confident I could supply, and examples were the thing I had least of.

So I built an that reads sixteen numbers per frame (brightness, plus the confidence and position of a handful of body landmarks) and emits a per-frame probability that a rep is in progress, which a threshold with hysteresis turns into counts. The same state machine sits at the end as before, the difference being that what feeds it has seen the last couple of seconds rather than the last frame. Handing it the pose landmarks after two sections spent showing how blind they are may look like a contradiction, and it is a fair one to raise: whether the model needs all sixteen numbers, or brightness plus two landmarks would do, is an ablation I have not run yet, and it is on the list.

And it finds things I never told it to look for. Nowhere did I say that a rep is a dark stretch of a certain length bracketed by light, or that the tracked body parts vanish on the way down and reappear at the top. It found that in the data, across seconds rather than frames, which is the view my hand-written rules never had. The price is that it wants examples rather than arguments.

Three generations, one clip set

The three detectors had never faced the same test, since each had been evaluated on whichever clips it happened to support. So I rebuilt the comparison as a fair one: all three versions, the same 89 clips, the same hand-labeled reps, and the same rule for what counts as a match, meaning a detected rep has to land within half a second of a labeled rep to be credited.

Same 89 clips, same labels, same tolerance1.0 brightness only2.0 + Apple Vision pose3.0 LSTMRep F1 on push-up clipshigher is better0.380.300.83Miscounted reps per cliplower is better7.407.452.00False reps on still-room clipslower is better1.202.600.20False reps on other-movement clipslower is better1.502.500.17Median detection latency (s)lower is better1.041.341.30
All three detectors ran on the same 89 clips. The top row carries the whole arc: rep F1 of 0.377 for brightness alone, 0.299 once pose is added, and 0.831 for the LSTM. Hover a row to see how far the LSTM moves it.

is a score that rewards finding the reps that happened and punishes inventing reps that did not, and 1.000 is perfect. Adding pose did not merely fail to help but actively made the score worse, and only the model that can see time gets anywhere near respectable.

Researchers care about F1, but users only care that the final rep count on their screen is right. Across the 67 clips that actually contain push-ups, averaging 10.6 reps each, brightness and the pose version both finish a set off by 7.4 reps. The LSTM, however, is off by an average of 2.0 reps, with a median miss of just 1.

Beyond counting real push-ups, the LSTM also handles phantom reps far better, though it does not eliminate them. The camera is easy to fool, whether by someone resting between sets or by a shadow cast from a person walking past. Watching a still room, brightness invents 1.2 reps per clip and the pose version 2.6, while the LSTM invents two reps total across the same clips. When someone is moving around but not exercising, the counts run 1.5, then 2.5, then two total across all twelve. That is a 6x reduction in phantom reps in a still room, and 9x when something else is moving. That gap matters more than the miss rate does, because a user forgives an occasional missed rep far more readily than reps their app added on its own.

That accuracy comes with a slight trade-off in speed. Because the LSTM needs context before it can decide, it takes a median 1.30 seconds to confirm a rep, against 1.04 for the bare brightness detector. Reducing that lag is on the list, but accuracy is the priority, and a count that lands a beat late is always better than one that lands wrong.

What four grid searches taught me about my dataset

The model exists to reject nuisance motion, so every fold contains a clip with real push-ups plus clips of nothing and clips without exercise. Then I ran the same search four times, at four sizes of the growing dataset: sixty-four configurations each round, with the top five retrained across six holdout splits so a winner could not be a lucky draw. The clip counts below are the dataset as it stood at each search, not the 89-clip set from the comparison above.

Mean validation loss across 6 holdout splitsVertical bars show the spread across splits. Every time the winning architecture changed, it changed in the same direction.0.400.500.600.700.57722 clips0.49849 clips0.57870 clips0.47386 clipsHover a point for the winning architecture at that dataset size.
Each point is the mean validation loss at one size of the growing dataset, with the architecture that won there. At 22 clips a single layer and a 1 second window won, and by 86 clips the wider model had stopped overfitting so dropout started helping for the first time.

Across all four searches the winner kept moving in one direction (barring the rise between the 49 and 70 clip searches, where added clips were more diverse and difficult). Otherwise, small datasets rewarded simpler models, and as the dataset grew to 86 clips, it could finally support a wider, more complex model, reined in by , without . Ultimately, these tests measured data volume rather than architecture.

The spread bars reveal a similar volatility: simply changing which clips land in the validation split causes a 2x performance gap, meaning any single run can flatten the results. The honest conclusion is that labeling more data is the only lever that matters, and the 89 clips and 709 hand-labeled reps I already have are an encouraging start.

Where it stands, and what comes next

This is the detector in the App Store build today: when the count on screen ticks up, this model moved it. The most valuable part of this baseline is exposing its precise limits: it fails entirely in the dark, and testing so far shows it generalizing across indoor lighting but not yet across different bodies. These known flaws yield a compounding, 26-item roadmap prioritizing data diversity, input cleanup, and incorporating other phone sensors for when vision drops out.

The post I ultimately want to write is about a model trained on a thousand clips. It requires the unglamorous step of manually labeling a thousand sessions, but putting in that foundation now guarantees the detector is as bad today as it will ever be. Once this baseline is established, it will bootstrap my autolabeler to rapidly scale and drive all future iterations.

The transferable part

The specific lesson here is that the model acts as a nuisance discriminator, not a counter. Forty lines of signal processing can count dips, while deciding whether those movements are actually push-ups is what requires a labeled dataset and a time-aware model. More broadly, architecture search cannot compensate for a lack of signal. Tuning simply ranks configurations against each other, which means even flawed features will produce a "winner" that creates the illusion of progress. The only way to catch that is to scrutinize your own inputs and look closely at the actual data, which here meant the 1.2% table above and then seeing for myself why pose went blind.

The workflow was simple: build fast, then refine. I pushed the most basic solution to its limit, forcing any added complexity to earn its keep. The meaningless perfect score early on served as a warning that evaluation metrics have to evolve alongside the detector they are grading. Moving from hand-written rules to a trained model ultimately paid off faster than I expected. The current performance is not the ceiling either, because every batch of new clips has moved it, and there is no reason for that to stop.


A privacy note: BDYWRK never records video or photos. Each camera frame is reduced on the spot to the sixteen numbers the model reads, then discarded, and the labeled clips behind this post came from a developer-only capture mode running on my own phone. See the Privacy page for more information.

BDYWRK counts push-ups, pull-ups, sit-ups, squats, and more, using your iPhone's sensors, with everything running on-device. Get BDYWRK now, exclusively on the App Store.

BlogHomeSupportPrivacy