How to Add Rep Counting to Your Fitness App
Updated July 8, 2026
Rep counting is not machine learning — it is a small state machine over one number. You take the pose keypoints you already have, turn three of them into a joint angle, smooth that angle, and count a rep each time the angle travels from "flexed" back to "extended". This guide shows the exact math and a production-ready counter in JavaScript/TypeScript. You have two paths: build this logic yourself on a free pose primitive (what we do below), or buy a fitness SDK that wraps rep counting and form feedback for you — see its docs for the exact API.
This guide assumes you already have per-frame landmarks from a pose model. If you don't yet, set that up first with our camera pose tracking guide, then come back — the code below plugs straight into that render loop.
What you'll need
- A pose model already emitting per-frame landmarks — MediaPipe Pose Landmarker (33 points) or MoveNet (17 points). See the camera pose tracking guide.
- Each landmark as an object with
x,y, and a confidence field (visibilityon MediaPipe,scoreon MoveNet). Normalized or pixel coordinates both work — the angle is scale-invariant, as long asxandyshare the same units. - Any JavaScript/TypeScript runtime (browser or React Native). No extra packages.
Step 1: Compute a joint angle from three keypoints
A rep is driven by one joint bending and straightening, so the core primitive is the interior angle at a joint. Given three points A–B–C, the angle at the vertex B is the angle between the vectors B→A and B→C. Use the atan2 of the cross and dot products — it returns a clean 0–180 degrees and, unlike bare acos, never throws a NaN from floating-point rounding near 0 or 180 degrees.
type Point = { x: number; y: number };
// Interior angle at vertex B, for the corner A-B-C, in degrees (0..180).
function angle(A: Point, B: Point, C: Point): number {
const bax = A.x - B.x, bay = A.y - B.y; // vector BA = A - B
const bcx = C.x - B.x, bcy = C.y - B.y; // vector BC = C - B
const dot = bax * bcx + bay * bcy;
const cross = bax * bcy - bay * bcx; // z of the 2D cross product
const rad = Math.atan2(Math.abs(cross), dot); // unsigned, 0..PI
return (rad * 180) / Math.PI; // 0..180 degrees
}
Why atan2(|cross|, dot) and not the dot-product acos form? They are mathematically equal, but acos(dot / (|BA|·|BC|)) needs its argument clamped to the range minus-1 to 1 or float error will push it out of domain and return NaN at exactly the straight-arm and fully-bent positions you care about. The atan2 form has no such edge case.
Step 2: Choose the driving joint for your exercise
Pick the joint whose angle best separates the two ends of the movement, then read its three landmarks by index. Below are the two most common, using MediaPipe's 33-point indices (MoveNet uses the same anatomical points at its own 17-point indices).
Bicep curl — elbow angle (A = shoulder, B = elbow, C = wrist). Left arm is indices 11/13/15, right arm is 12/14/16:
const lm = result.landmarks[0]; // 33 landmarks for the first pose
const elbow = angle(lm[11], lm[13], lm[15]); // left: shoulder, elbow, wrist
// Arm extended ~160-180 deg; fully flexed ~30-45 deg.
Squat — knee angle (A = hip, B = knee, C = ankle). Left leg is indices 23/25/27, right leg is 24/26/28:
const knee = angle(lm[23], lm[25], lm[27]); // left: hip, knee, ankle
// Standing ~170-180 deg; parallel squat ~90 deg; deep ~70 deg.
Two notes. The angle is viewpoint-dependent: a 2D angle only matches the true joint angle when the limb is roughly parallel to the camera plane, so film sagittal moves (squat, curl, lunge) from the side. And where both sides are visible, averaging left and right — or picking the side with higher confidence — reduces noise.
Step 3: Gate low-confidence frames and smooth the angle
Raw landmarks jitter, and occluded joints jump to garbage positions that can spike the angle across a threshold and fake a rep. Two cheap defenses fix almost all of it: a confidence gate that skips frames where the driving joints are unreliable, and an exponential moving average (EMA) that removes single-frame spikes before you threshold. Keep the EMA alpha modest (0.2 to 0.4) so you kill jitter without lagging enough to miss a fast rep.
const MIN_CONF = 0.6; // per-keypoint visibility/score gate; tune 0.5-0.7 per camera
const ALPHA = 0.3; // EMA weight on the newest sample
function minConf(...pts: { visibility?: number; score?: number }[]): number {
return Math.min(...pts.map(p => p.visibility ?? p.score ?? 0));
}
// EMA: smoothed = alpha * raw + (1 - alpha) * previousSmoothed
function ema(raw: number, prev: number | null): number {
return prev === null ? raw : ALPHA * raw + (1 - ALPHA) * prev;
}
Step 4: Count reps with a two-state hysteresis machine
Model the exercise as a flexed to extended toggle, and use two separate thresholds — one to enter the flexed state, a different one to enter the extended state. That gap is hysteresis: a dead-band that forces the angle to fully commit to the other extreme before the next transition, so noise chattering around a single line can't fire phantom counts. Count on one edge only (the flexed to extended completion); counting both edges double-counts. This is exactly what Google's MediaPipe and ML Kit fitness samples do with their enter/exit crossing.
type RepConfig = {
enterFlexed: number; // angle must drop BELOW this to become "flexed"
exitExtended: number; // angle must rise ABOVE this to become "extended"
minConf?: number;
alpha?: number;
};
class RepCounter {
reps = 0;
private state: "flexed" | "extended" = "extended";
private smoothed: number | null = null;
private cfg: Required<RepConfig>;
// Invariant for a "smaller angle = flexed" joint: enterFlexed < exitExtended.
constructor(cfg: RepConfig) {
this.cfg = { minConf: MIN_CONF, alpha: ALPHA, ...cfg };
}
// Call once per frame with the three joint points. Returns true on a counted rep.
update(A: any, B: any, C: any): boolean {
// 1) Confidence gate: hold state, do not measure on garbage.
if (minConf(A, B, C) < this.cfg.minConf) return false;
// 2) Smooth the scalar angle (seed on the first good frame).
const raw = angle(A, B, C);
this.smoothed = this.smoothed === null
? raw
: this.cfg.alpha * raw + (1 - this.cfg.alpha) * this.smoothed;
const a = this.smoothed;
// 3) Hysteresis state machine — count on the flexed -> extended edge only.
if (this.state === "extended" && a < this.cfg.enterFlexed) {
this.state = "flexed"; // reached the bottom; do NOT count yet
} else if (this.state === "flexed" && a > this.cfg.exitExtended) {
this.state = "extended";
this.reps += 1; // full round trip complete
return true;
}
return false;
}
}
For a joint where a larger angle means flexed, flip the two comparisons and keep the thresholds separated (enter > exit). The rule is invariant: the signal must traverse the whole dead-band to flip states.
Step 5: Wire the counter into your pose loop
Create one RepCounter per tracked exercise, feed it the driving joint's three points every frame inside the render loop from the pose guide, and update the UI when a rep lands.
// Squat: enter flexed below 100 deg, count back out above 160 deg.
const squat = new RepCounter({ enterFlexed: 100, exitExtended: 160 });
function onFrame(result: any) {
const lm = result.landmarks?.[0];
if (!lm) return; // no pose this frame
const counted = squat.update(lm[23], lm[25], lm[27]); // hip, knee, ankle
if (counted) {
repCountEl.textContent = String(squat.reps);
// fire your own haptic/audio cue or form check here
}
}
That is the whole pipeline: keypoints in, one smoothed angle, one debounced state machine, a count out.
Make it reliable
- Tune thresholds to real reps, not the extremes. Log the smoothed angle through a few honest and a few sloppy reps, then set
enterFlexedandexitExtendedinside the range real reps actually reach, with a comfortable gap between them. Too narrow a band re-admits noise; too wide and shallow reps never count. - The confidence gate is not optional. A single occluded frame can swing the angle 90 degrees. Gating (skip the frame, hold state) is what stops that from registering.
- Don't over-smooth. An
alphathat is too low lags the movement and can miss fast reps; 0.2 to 0.4 is a sensible starting range to tune per exercise and frame rate. - Optional debounce. For extra safety, ignore a completion that lands under roughly 300 ms after the previous one to reject impossible-speed double counts.
If your end user is exercising in front of a camera, one setup line goes a long way: clear some space, get the full body in frame with even, front-ish lighting, and use a side view for squats, curls, and lunges so the driving joint angle sits in the camera plane.
Frequently asked questions
- Do I need machine learning to count reps from pose keypoints?
- No. In the common case rep counting is a threshold state machine over one scalar signal — usually a joint angle — not a trained model. Google's own MediaPipe and ML Kit fitness samples count a rep when a signal crosses an enter threshold and then crosses back past an exit threshold, which is pure geometry plus a couple of thresholds.
- Why use two thresholds instead of one for counting reps?
- A single threshold lets sensor noise chatter across the line and fire many counts per real rep. Two separated thresholds create a dead-band, called hysteresis, that forces the angle to fully commit to the opposite extreme before the next transition, which debounces the count. This is the standard fix used in the Google fitness samples.
- Which joint angle should I track for each exercise?
- Track the joint that bends most between the two ends of the movement. For a bicep curl that is the elbow angle from shoulder, elbow, and wrist; for a squat it is the knee angle from hip, knee, and ankle. Film sagittal-plane moves from the side so the 2D angle matches the real joint angle.
- Why is my rep count jumping or double-counting?
- The usual causes are unsmoothed jitter, thresholds set too close together, or occluded low-confidence landmarks spiking the angle. Add EMA smoothing on the angle, widen the gap between the enter and exit thresholds, gate out frames below roughly 0.5 to 0.7 confidence, and optionally ignore any completion under about 300 ms after the last.
- Does this work with both MediaPipe and MoveNet?
- Yes. The angle math and state machine only need x and y plus a per-keypoint confidence value, which both provide (visibility on MediaPipe, score on MoveNet). The landmark indices differ — MediaPipe uses 33 points and MoveNet uses 17 — so map the same anatomical joints to each model's indices.
Keep reading
Independent comparison, last reviewed July 8, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All guides · by AIFitnessAPI