Spotter app iconSpotter · by Michael Starkov, Calgary

Only real push-ups count.

Spotter stands on the floor beside you and counts push-ups through the front camera, but only the reps that meet the standard: elbows to 90°, arms locked out at the top, knees off the floor. When a rep doesn't count, it tells you why, out loud. Pose detection runs on the phone, and video never leaves it.

iPhone · TestFlight beta Expo · React Native · TypeScript 76 unit tests
90°elbow depth standard, judged on the arm facing the camera
~30/spose frames processed on the phone. Video is never uploaded
0 tapsduring a set: counting starts when you're in position, the set saves when you walk away
76unit tests on the counter, geometry, position check, timer and stats
Demo

A set, start to finish

A replay of the app drawn live in your browser, not a video. The person is simulated. Everything else, from the position check to the rep counter and the analytics, is the app's own TypeScript compiled to JavaScript. Use the buttons or the ← → keys. Sound is off until you turn it on.

9:41
Spotter
Start push-ups
0
Today
0
This week
0
Best set
Today
Analytics
0
Push-ups, 12 months
0
Active days
0
Current streak
0
Longest streak
0
Best day
0
Best set
Last 12 months
Tap a day to see its sets.
Workout
Analytics
Turn your phone sideways
The workout runs in landscape. The screen stays sideways until you finish, even if the phone tilts.
Cancel
Ready
Step 1 of 3Skip
Phone on the floor, sideways
Turn your phone to landscape and stand it on the floor. Lean it against a dumbbell, a plate or a water bottle.
1.5–2 m to your side
Put the phone 1.5–2 m (5–6 ft) away with the screen facing you. The front camera needs to see you side-on, from head to feet.
Watch and listen
Every counted rep beeps, says the number and flashes the screen green. Too shallow and you'll hear "Go lower". In a loud gym, just watch the screen.
Cancel
Next
0/6
Hold still…
arm — · ok 0.14–0.7
✕
90°
LEGS ?
⏱ 0:00
0
Straighten your arms to start
+
−
Finish
7
push-ups saved to your calendar
⏱ 0:19
Done
Another set
Phone says
How it counts

From camera frame to counted rep

The pose library is only a source of joint positions. Everything that decides whether a rep counts is plain TypeScript in src/core, with no React Native imports, so all of it runs in Jest.

  1. 1Front camera

    Phone in landscape on the floor, 1.5–2 m to your side.

    react-native-vision-camera v5
  2. 2Pose landmarks

    Apple Vision on iOS, ML Kit on Android, mapped to MediaPipe indices. On the device. Its built-in rep counter isn't used.

    src/pose/PoseCamera.tsx
  3. 3Geometry

    Pick the arm facing the camera. Elbow angle from shoulder → elbow → wrist, corrected for the aspect ratio. Legs: straight or on the knees.

    src/core/pose.ts
  4. 4Smoothing

    Exponential moving average of the angle, so one jittery frame can't flip the state.

    smoothing: 0.5
  5. 5State machine

    top → descending → bottom → top is one rep. Separate thresholds for entering and leaving each phase.

    src/core/pushupCounter.ts
  6. 6Feedback & storage

    Beep, spoken count, green flash, cues. Sets saved to SQLite on the phone, offline.

    src/feedback · src/db
WAITING TOP DESCENDING BOTTOM ≥ 155° · +1 rep < 140° ≤ 90° ≥ 155° without 90° · shallow or LOST
Counting starts only after you've been seen at the top. Lose the arm for over a second and the interrupted rep is dropped; counting resumes at the top.
Events it emits
readyattemptdepthrepshallow · almosttooFastbadForm · knees | legsHiddenlost

Each event drives a cue in the app, and each is a ready-made label to compare against a human's in a test session.

RuleValueWhy
Arms locked out≥ 155°Where every rep starts and ends.
Rep attempt starts< 140°The gap below the top stops a wobbling arm from starting a rep.
Depth standard≤ 90°The rep standard.
"Almost"≤ 105°A different cue: "Almost. Lower." instead of "Go lower."
Fastest rep500 msFaster is sensor noise, not a push-up.
Tracking lost1000 msThe interrupted rep is dropped.
Legs straightknee ≥ 145°, hips ≥ 140°, knees above handsJudged on every frame of a rep. Bent in ≥ 50% of frames → "Knees up, didn't count".
Joint visibleconfidence ≥ 0.3Apple Vision reports 0.3–0.6 for joints seen side-on.

Values on this page are read from the compiled PUSHUP_RULES and pose.ts constants, not typed in by hand.

Counter lab

Try to break the counter

The same step() function from src/core/pushupCounter.ts, fed with landmarks from a simulated person. Queue up reps, add sensor noise, or switch off smoothing and hysteresis to see why they're there. Every attempt is logged against what the person actually did, like a labelled test session.

0
Straighten your arms to start
LEGS ?
0reps
0shallow
0form rejects
—elbow now
Do a rep

Try: set noise to about 0.02 and queue “Almost” a few times: some start counting as full reps. Or turn off hysteresis and watch the wobble at the top trigger “Go lower”.

Elbow angle, last 10 smeasured smoothed (what the counter sees)
Attempt log: what the person did vs. what the counter said
No attempts yet.
#Person didCounter saidMin angleDurationLegs straightMatch
Data & validation

What this does and doesn't prove

A counter that looks right in a demo isn't validated. Here's where Spotter honestly stands, and how I'd turn it into something a coaching feature could rely on.

✓ Verified
  • 76 unit tests on synthetic angle and landmark sequences: full, shallow and “almost” reps, too-fast reps, dropouts, lost tracking, knee push-ups, position-check smoothing, streaks and calendar edges.
  • Strict TypeScript. npm run typecheck and npm test gate every change.
  • Run on a real iPhone through TestFlight. A TestFlight crash log was traced to the camera thread and fixed.
  • Tested in a gym, and the feedback changed the design: no “Start” tap mid-plank, sets save themselves.
! Not proven yet
  • Count accuracy against human labels. There is no labelled dataset yet, so there's no error rate to quote.
  • Thresholds are hand-tuned from my own reps: one body, one phone, one camera angle.
  • How often knee push-ups slip through, or good reps get rejected, across people, clothing and lighting.
  • Android phones. ML Kit landmarks behave differently from Apple Vision.
→ Next: dataset, then evaluation
  1. Log per-frame landmarks, elbow angle, legs reading and counter events for each set, opt-in, with the full setup.
  2. Label: a second person marks every rep from video without seeing the app's count.
  3. Split by participant, then by session. Nobody appears in both training and test data.
  4. Baseline = today's rule-based counter. Report count error per set, rep-level precision and recall, and knee push-up false accepts, by distance and placement.
  5. Try a learned model only if it beats the baseline on held-out people. Keep the negative results.

One labelled rep, traceable end to end

Every rep should carry who, where, with what setup, under which consent, which app and rule version judged it, and who labelled it. The lab above already logs the left half of this: the attempt, its depth, duration and legs reading, next to the ground truth.

The same loop carries over to a wearable: swap the elbow-angle signal for a wrist or bar IMU stream. Participant IDs, setup metadata, reference labels, separated splits and a simple baseline stay exactly the same.

Proposed format · not built yet
{
  "schema": "spotter.rep/0.1",
  "participant_id": "P07",
  "session_id": "2026-11-02-gym-a-01",
  "consent": { "version": "v1", "landmarks": true, "video": false },
  "setup": {
    "exercise": "push_up",
    "device": "iPhone, front camera",
    "placement": "floor, landscape, left side",
    "distance_m": 1.8,
    "pose_model": "apple-vision",
    "fps": 30
  },
  "app": { "version": "1.0.0 (6)", "rules": "PUSHUP_RULES@895d290" },
  "rep": {
    "index": 4,
    "t_start_ms": 18342,
    "t_end_ms": 19911,
    "min_elbow_deg": 84.2,
    "legs_frames": { "straight": 41, "bent": 2, "unknown": 1 },
    "app_outcome": "rep",
    "label": "full",
    "label_source": "video_review",
    "labeler": "L02"
  }
}
Build notes

Where it broke, and what changed

Most of the work wasn't the happy path. These are the problems found on a real phone, in a real gym and in the pose data itself, and what changed.

Crash on TestFlight

aaa96b5

The camera screen crashed on TestFlight builds. The crash log showed SIGABRT on the camera's frame thread: an uncaught JS exception inside the frame worklet. Now the worklet never calls into React. It writes samples to a shared value that the UI reads ~30 times a second, and any error is shown on screen instead of killing the app.

The checklist never went green

5c28938

Every check had to pass in a single frame, and side-on the feet often aren't detected at all. Distance is now measured from the arm, the ankles aren't required, and each check is smoothed over 1.2 s: 60% of recent frames to pass, 75% to start counting.

Low confidence is normal side-on

pose.ts

Apple Vision reports 0.3–0.6 confidence for joints seen from the side, so a typical 0.5 cut-off would throw away good frames. The threshold is 0.3, and the side facing the camera is the arm the model is most sure about.

Normalised landmarks skew angles

pose.ts

Landmarks are normalised separately on x and y, so in a 16:9 frame raw angles come out wrong. x is scaled by the aspect ratio before any angle or distance is computed.

Tapping “Start” broke the plank

dd6f819

Gym feedback: you're already in position when you'd have to press a button 2 m away. Counting now starts the moment the position check passes, and the set saves itself after 12 s out of view or 25 s without a rep attempt.

Knee push-ups were counted

895d290

Legs are judged on every frame of a rep: knee angle, hip line, and knee height above the hands. A rep is rejected only if the legs were bent in at least half its frames, so one noisy frame doesn't throw out a good rep.

Built with Claude Code, checked by me

Every commit in Spotter is co-authored with Claude Code. I review what it writes, and I keep the parts that have to be right easy to check:

  • Rep counting, position checks and calendar math live in src/core as pure TypeScript, covered by Jest.
  • Change a threshold → update or add a test.
  • Anything touching the camera gets tried on a real phone. A simulator can't run it.
  • This page runs the same src/core, compiled by a script that stamps each file with the commit it came from.
“Done means: npm run typecheck and npm test pass, and camera changes were tried on a device.”From the project's CLAUDE.md

Four days, plan to TestFlight to gym

  1. Plan. Counter state machine with tests. First prototype: camera, position check, cues, SQLite calendar.
  2. App icon, TestFlight build. Crash traced from the TestFlight log and fixed. Position check made reliable, hints spoken aloud.
  3. Gym feedback → hands-free flow and auto-save. Knee push-ups rejected. Analytics tab.
  4. Set timer, landscape lock, rotate prompt. This page.
Expo SDK 58React Native 0.88TypeScriptExpo RouterVision Camera v5Apple Vision / ML KitReanimated 4expo-sqliteexpo-speechJestEAS · TestFlight

Want to see it on a real phone?

I'll bring it. Happy to walk through the counter, the code, or how I'd validate it.