Skip to content
AF
Cookbook

Rep Counter: A State Machine and the Scorer That Guards It

Last verified August 12, 2026 · 16 min read

A copy-and-run rep counter and the scoring harness that keeps it honest. The counter is a two-phase finite state machine over one smoothed joint angle, with an injectable EMA constant, separate up and down entry thresholds so jitter at one boundary cannot double-fire, a minimum phase duration that rejects a spike rather than delaying it, a confidence gate, and rep events emitted with timestamps. The scorer matches predicted rep timestamps one-to-one and greedily against labelled ground truth inside a tolerance window and reports precision and recall per clip, with no aggregate and no F-score anywhere, because both let a miss and a phantom cancel out. Plain modern JavaScript, no dependencies, Node 20 and above, with a node:test suite that runs on synthetic angle streams rather than a camera.

Two things that belong together and are usually written apart: a rep-counting state machine, and the scorer that proves it still works after you touch its thresholds. The algorithm — why you reduce a pose to one oscillating scalar, why hysteresis beats a single threshold — is how rep counting works. Why a final-count assertion is worthless and what belongs in a labelled corpus is testing a rep counting algorithm. This page is the runnable version of both.

What the recipe does#

The counter is a two-phase finite state machine over one smoothed joint angle, with four gates:

  • EMA smoothing with an injectable alpha. Never run threshold logic on raw keypoints; alpha is a trade between lag and jitter, so it is a parameter rather than a constant.
  • Hysteresis. Separate entry thresholds for the up and down phases. The constructor throws if you pass a single threshold or an inverted pair, because that is the double-counting bug and it should not survive to the field.
  • A minimum phase duration. Note the semantics: a cycle whose up phase was shorter than the gate is rejected outright, not merely delayed. Blocking a transition and then firing it 250 ms later is still a wrong count.
  • A minimum amplitude and a confidence floor. Low-confidence frames are skipped before the smoother sees them, so an occluded joint does not poison the signal for the next second.

Reps are emitted as timestamped events at the return crossing, which is the moment the counter is contractually supposed to increment. Write that into your labelling policy — if truth marks the bottom of the movement and your prediction marks the top, every match either fails or succeeds by luck.

The scorer treats the counter as a classifier. Predicted rep timestamps are matched one-to-one and greedily against labelled ground truth inside a tolerance window, producing true positives, false positives and false negatives, and from those precision and recall per clip. There is no aggregate figure and no F-score anywhere in the file, on purpose: both let a gain in one error mode silently pay for a regression in the other.

The implementation#

This is the canonical file. It lives in the repo at cookbook/rep-counter.mjs and its test file runs in CI on every commit, so what you are reading is what passes.

/**
 * rep-counter.mjs
 *
 * WHAT THIS IMPLEMENTS
 *   Two things a camera fitness feature needs and that are usually written
 *   badly together:
 *
 *   1. A rep-counting finite state machine over a smoothed joint-angle stream.
 *      EMA smoothing with an injectable alpha, hysteresis (separate entry
 *      thresholds for the up and down phases, so jitter at one boundary cannot
 *      double-fire), a minimum phase duration, a minimum amplitude gate, a
 *      confidence gate, and rep events emitted with timestamps.
 *
 *   2. A scorer that treats the counter as a classifier: predicted rep
 *      timestamps matched one-to-one and greedily against labelled ground truth
 *      inside a tolerance window, producing precision and recall PER CLIP.
 *      There is deliberately no aggregate-count comparison and no F-score,
 *      because both let a miss and a phantom cancel each other out.
 *
 * WHICH aifitnessapi.com PAGES DOCUMENT THE PATTERN
 *   https://aifitnessapi.com/motion/how-rep-counting-works   (the state machine)
 *   https://aifitnessapi.com/test/rep-counting               (the scoring gate)
 *   https://aifitnessapi.com/cookbook/rep-counter            (this recipe)
 *
 * Node 20+. Zero runtime dependencies. No timers and no clock reads: every
 * frame carries its own timestamp, so a recorded keypoint sequence replays
 * deterministically.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

// ---------------------------------------------------------------------------
// Smoothing
// ---------------------------------------------------------------------------

/**
 * Exponential moving average. `alpha` is the weight of the newest sample:
 * 1 means no smoothing at all, small values mean heavy smoothing and more lag.
 *
 * Smoothing is not optional — never run threshold logic on raw keypoints — but
 * it is a trade, so the constant is injected rather than baked in.
 */
export function createEmaSmoother({ alpha }) {
  if (!(alpha > 0 && alpha <= 1)) throw new RangeError("alpha must be in (0, 1]");
  let value = null;
  return {
    get value() {
      return value;
    },
    push(x) {
      value = value === null ? x : alpha * x + (1 - alpha) * value;
      return value;
    },
    reset() {
      value = null;
    },
  };
}

// ---------------------------------------------------------------------------
// The state machine
// ---------------------------------------------------------------------------

export const PHASE = Object.freeze({ DOWN: "down", UP: "up" });

/**
 * A rep counter for one exercise on one joint angle.
 *
 * Convention follows the curl worked through on /motion/how-rep-counting-works:
 * a large angle is the extended, resting position (`down`) and a small angle is
 * the contracted position (`up`). A rep is counted on the COMPLETED cycle — the
 * moment the signal returns past `enterDownAbove` — not on either crossing
 * alone. For an exercise where the extremes are the other way round, feed in
 * the negated angle or an angle measured at the opposite joint; the state
 * machine does not care what the number means.
 *
 * @param {object} cfg
 * @param {number} cfg.enterUpBelow      cross below this to enter the up phase
 * @param {number} cfg.enterDownAbove    cross above this to complete the rep
 * @param {number} [cfg.alpha=0.4]       EMA smoothing weight
 * @param {number} [cfg.minPhaseMs=0]    debounce. The down phase must have lasted this long
 *   before it may end, and a cycle whose up phase was shorter than this is rejected as a
 *   spike rather than merely delayed — a delayed count is still a wrong count.
 * @param {number} [cfg.minAmplitude=0]  the cycle must span at least this much smoothed range
 * @param {number} [cfg.minConfidence=0] frames below this are skipped entirely
 */
export function createRepCounter(cfg) {
  const {
    enterUpBelow,
    enterDownAbove,
    alpha = 0.4,
    minPhaseMs = 0,
    minAmplitude = 0,
    minConfidence = 0,
  } = cfg;

  if (!Number.isFinite(enterUpBelow) || !Number.isFinite(enterDownAbove)) {
    throw new TypeError("enterUpBelow and enterDownAbove are required numbers");
  }
  if (!(enterUpBelow < enterDownAbove)) {
    // A single threshold, or an inverted pair, is the double-counting bug.
    // Refuse at construction time rather than miscount in the field.
    throw new RangeError(
      "enterUpBelow must be strictly below enterDownAbove; the gap between them is the hysteresis",
    );
  }

  const ema = createEmaSmoother({ alpha });

  let phase = null;
  let phaseStartT = null;
  let phaseMin = Infinity;
  let phaseMax = -Infinity;
  let count = 0;
  let skipped = 0;
  const events = [];
  const rejected = [];

  function beginPhase(next, t, seed) {
    phase = next;
    phaseStartT = t;
    phaseMin = seed;
    phaseMax = seed;
  }

  return {
    get count() {
      return count;
    },
    get phase() {
      return phase;
    },
    /** Rep events emitted so far: `{ index, t, amplitude, peakAngle, troughAngle }`. */
    get events() {
      return events.slice();
    },
    /** Frames dropped by the confidence gate. Worth surfacing to the user. */
    get skippedFrames() {
      return skipped;
    },
    /** Cycles the gates threw away: `{ t, reason, upPhaseMs, amplitude }`. */
    get rejected() {
      return rejected.slice();
    },
    /** Predicted rep timestamps, the shape the scorer below wants. */
    repTimestamps() {
      return events.map((e) => e.t);
    },

    /**
     * Feed one frame.
     * @param {{t: number, angle: number, confidence?: number}} frame
     * @returns {Array<object>} rep events emitted by THIS frame (0 or 1)
     */
    push({ t, angle, confidence = 1 }) {
      if (!Number.isFinite(t) || !Number.isFinite(angle)) {
        throw new TypeError("frame needs a finite t and angle");
      }
      if (confidence < minConfidence) {
        // Occluded or low-confidence joints produce a garbage angle. Skipping
        // the frame is right; feeding it to the smoother is not.
        skipped += 1;
        return [];
      }

      const s = ema.push(angle);

      if (phase === null) {
        beginPhase(s <= enterUpBelow ? PHASE.UP : PHASE.DOWN, t, s);
        return [];
      }

      if (s < phaseMin) phaseMin = s;
      if (s > phaseMax) phaseMax = s;

      const phaseMs = t - phaseStartT;

      if (phase === PHASE.DOWN) {
        // Hysteresis: only a crossing of the LOW threshold opens a new cycle,
        // so jitter around the high one cannot re-fire. The time gate is a
        // debounce against re-entering immediately after a counted rep.
        if (s < enterUpBelow && phaseMs >= minPhaseMs) beginPhase(PHASE.UP, t, s);
        return [];
      }

      // phase === UP: the rep completes only on the return crossing, which is
      // the moment the counter is contractually supposed to increment. Label
      // your ground truth at the same moment or every match is luck.
      if (s > enterDownAbove) {
        const amplitude = s - phaseMin;
        const trough = phaseMin;
        beginPhase(PHASE.DOWN, t, s);

        if (phaseMs < minPhaseMs) {
          rejected.push({ t, reason: "phase_too_short", upPhaseMs: phaseMs, amplitude });
          return [];
        }
        if (amplitude < minAmplitude) {
          rejected.push({ t, reason: "amplitude_too_small", upPhaseMs: phaseMs, amplitude });
          return [];
        }

        count += 1;
        const event = Object.freeze({
          index: count,
          t,
          amplitude,
          peakAngle: s,
          troughAngle: trough,
        });
        events.push(event);
        return [event];
      }
      return [];
    },

    /**
     * Drain a whole recorded sequence. Replaying captured keypoints is the
     * cheap way to regression-test the state machine; scoring the pose model
     * against its own output is not a test at all.
     */
    pushAll(frames) {
      for (const f of frames) this.push(f);
      return this.events;
    },

    reset() {
      ema.reset();
      phase = null;
      phaseStartT = null;
      phaseMin = Infinity;
      phaseMax = -Infinity;
      count = 0;
      skipped = 0;
      events.length = 0;
      rejected.length = 0;
    },
  };
}

// ---------------------------------------------------------------------------
// Scoring — the counter is a classifier, so score it like one
// ---------------------------------------------------------------------------

/**
 * Guard the tolerance window against being wide enough to be meaningless.
 *
 * If the window exceeds half the closest gap between two labelled reps, one
 * prediction sits within range of two labels and the score stops measuring
 * anything. This is the check that stops your fastest clip — the one you most
 * want in the corpus — going quietly unscoreable the day someone widens the
 * window to make a flaky clip go green.
 *
 * @throws {RangeError}
 */
export function assertToleranceIsMeaningful(truth, toleranceMs) {
  const ordered = [...truth].sort((a, b) => a - b);
  for (let i = 1; i < ordered.length; i++) {
    const gap = ordered[i] - ordered[i - 1];
    if (gap <= 2 * toleranceMs) {
      throw new RangeError(
        `tolerance ${toleranceMs}ms exceeds half the closest labelled rep pair (${gap}ms apart); ` +
          "one prediction could satisfy two labels and the score would be meaningless",
      );
    }
  }
  return true;
}

/**
 * One-to-one greedy nearest matching of predicted rep events to labelled ones.
 *
 * Walks the labels in time order; for each, takes the nearest unmatched
 * prediction inside the window and consumes it. Consuming is what makes a
 * double-count show up as a false positive instead of vanishing.
 *
 * @param {number[]} truth      labelled rep timestamps (ms)
 * @param {number[]} predicted  rep timestamps your counter emitted (ms)
 * @param {{toleranceMs: number}} opts
 * @returns {{tp:number, fp:number, fn:number, precision:number, recall:number,
 *            matches: Array<{truth:number, predicted:number, errorMs:number}>,
 *            falsePositives:number[], falseNegatives:number[]}}
 */
export function scoreRepEvents(truth, predicted, { toleranceMs }) {
  if (!Number.isFinite(toleranceMs) || toleranceMs < 0) {
    throw new TypeError("toleranceMs must be a non-negative number");
  }
  assertToleranceIsMeaningful(truth, toleranceMs);

  const unmatched = [...predicted].sort((a, b) => a - b);
  const matches = [];
  const falseNegatives = [];

  for (const t of [...truth].sort((a, b) => a - b)) {
    let bestIdx = -1;
    let bestErr = Infinity;
    for (let i = 0; i < unmatched.length; i++) {
      const err = Math.abs(unmatched[i] - t);
      if (err <= toleranceMs && err < bestErr) {
        bestErr = err;
        bestIdx = i;
      }
    }
    if (bestIdx === -1) {
      falseNegatives.push(t);
    } else {
      const [hit] = unmatched.splice(bestIdx, 1);
      matches.push({ truth: t, predicted: hit, errorMs: hit - t });
    }
  }

  const tp = matches.length;
  const fp = unmatched.length;
  const fn = falseNegatives.length;
  return {
    tp,
    fp,
    fn,
    // An empty denominator is "no evidence", not "perfect". Report null.
    precision: tp + fp === 0 ? null : tp / (tp + fp),
    recall: tp + fn === 0 ? null : tp / (tp + fn),
    matches,
    falsePositives: unmatched,
    falseNegatives,
  };
}

/**
 * Score a whole corpus. Per clip, and only per clip.
 *
 * There is no aggregate here on purpose: a pooled precision figure is dominated
 * by whichever exercise you filmed most of, and a change that breaks two clips
 * while fixing two others leaves it perfectly still.
 *
 * @param {Array<{clip: string, exercise?: string, tags?: string[],
 *                truth: number[], predicted: number[]}>} clips
 * @param {{toleranceMs: number}} opts
 */
export function scoreCorpus(clips, { toleranceMs }) {
  const results = {};
  for (const c of clips) {
    const s = scoreRepEvents(c.truth, c.predicted, { toleranceMs });
    results[c.clip] = {
      clip: c.clip,
      exercise: c.exercise ?? null,
      tags: c.tags ?? [],
      tp: s.tp,
      fp: s.fp,
      fn: s.fn,
      precision: s.precision,
      recall: s.recall,
    };
  }
  return results;
}

/**
 * The gate. Compare per-clip results against a committed baseline and report
 * every clip whose tp/fp/fn moved in EITHER direction.
 *
 * An improvement failing the build is the point: accepting it means updating
 * the baseline in the same pull request, which makes the diff the review
 * artifact and forces whoever widened the hysteresis gap to say which clips
 * moved and why the trade was worth it.
 *
 * @returns {{ok: boolean, changed: Array<object>, missing: string[], added: string[]}}
 */
export function compareToBaseline(results, baseline) {
  const changed = [];
  const missing = [];
  const added = [];

  for (const clip of Object.keys(baseline)) {
    if (!(clip in results)) {
      missing.push(clip);
      continue;
    }
    const a = baseline[clip];
    const b = results[clip];
    const delta = { tp: b.tp - a.tp, fp: b.fp - a.fp, fn: b.fn - a.fn };
    if (delta.tp || delta.fp || delta.fn) {
      changed.push({
        clip,
        baseline: { tp: a.tp, fp: a.fp, fn: a.fn },
        current: { tp: b.tp, fp: b.fp, fn: b.fn },
        delta,
        // "regressed" only in the narrow sense of more errors; the reviewer
        // still has to look. Both directions fail the gate.
        regressed: delta.fp > 0 || delta.fn > 0,
      });
    }
  }
  for (const clip of Object.keys(results)) if (!(clip in baseline)) added.push(clip);

  return { ok: changed.length === 0 && missing.length === 0 && added.length === 0, changed, missing, added };
}

/**
 * ANTI-PATTERN — DO NOT GATE ON THIS. Exported so a test can demonstrate that
 * it passes on a counter that is wrong twice. A miss and a phantom cancel, the
 * totals agree, and the suite reports coverage it does not have.
 */
export function totalsAgreeAntiPattern(truth, predicted) {
  return truth.length === predicted.length;
}

Adapting it#

Thresholds and joint are per exercise, always. The recipe uses elbow angle with a curl's convention: a large angle is the extended resting position, a small angle is the contraction. A squat is the knee or hip angle; a jumping jack is usually a keypoint position rather than a joint angle at all. Where an exercise runs the other way round, negate the signal before pushing it — the state machine does not care what the number means. Build a library of small per-exercise definitions rather than one universal detector, and expect that to be ongoing work. Adding rep counting covers wiring the joint selection to a real camera feed.

Calibrate to the person. Fixed absolute thresholds that work for one body may never trigger for another. Capturing a reference rep at the start of a set, or adapting the thresholds to the observed range, is the usual fix; the state machine takes its two thresholds at construction time so you can build a new counter per set once you know the user's range.

Smooth harder, or differently. The EMA here is the cheapest thing that works and it introduces systematic lag, which the end-to-end test measures at about 52 ms on a two-second rep. If your signal is noisier than the EMA can handle, a One Euro filter is the common next step; keep the seam, because the counter only ever sees the smoothed value. Signal quality upstream is pose estimation accuracy, and when the corpus goes red, check that layer before you touch a threshold.

Feed it recorded keypoints. Every frame carries its own timestamp and nothing reads a clock, so a captured keypoint sequence replays deterministically and a whole corpus scores in milliseconds. Replaying real recordings is legitimate and cheap. Generating the keypoints from the same pose model you are testing is not a test at all — it scores your state machine against your own model's opinion, and the corpus exists to catch the cases where that opinion is wrong.

Gate the build on the per-clip diff. compareToBaseline fails on any clip whose true positives, false positives or false negatives moved in either direction, including improvements. Failing on an improvement is deliberate: accepting it means updating the committed baseline in the same pull request, which makes the diff the review artifact and forces whoever widened the hysteresis gap to say which clips moved and why the trade was worth it.

The test contract#

The suite in cookbook/rep-counter.test.mjs asserts:

  • A clean sinusoid counts exactly N, for N of 1, 5, 8 and 12, with zero rejected cycles and rep events spaced one period apart.
  • Jitter parked on a threshold counts once. With smoothing disabled so the raw noise reaches the state machine, a signal that crosses 60 degrees eight times and 140 degrees six times produces exactly one rep.
  • An abandoned half-rep counts zero, in both directions: a descent that never reaches the low threshold, and a rep that goes all the way down and never fully re-extends. The second case leaves the counter honestly in the up phase rather than guessing.
  • A twitch is rejected, not delayed. A full-range spike travelled in 66 ms is recorded as phase_too_short and never becomes a count once the debounce elapses.
  • Low-confidence frames are skipped rather than smoothed in, so three clean reps stay three when every seventh frame returns a garbage angle.
  • The scorer catches the compensating-error case. One miss plus one phantom gives a total that matches the label count exactly — the anti-pattern assertion passes — while precision and recall both come out at 0.75.
  • Matching is one-to-one, so a counter that fires twice for one rep produces a false positive rather than a free match.
  • A tolerance window wider than half the closest labelled rep gap is refused with a RangeError, before it can score anything.
  • An empty prediction set scores recall 0 and precision null, because no evidence is not the same as perfect, and a clip of somebody not exercising scores zero false positives.
  • The corpus gate fails on a per-clip move the pooled figure hides: one clip loses a rep, another gains a phantom, the aggregate does not budge, and the gate reports both clips with signed deltas.
  • Labelling at the wrong moment scores zero. The same perfect counter, scored against labels placed at the peak instead of the return crossing, gets precision 0 and recall 0 — which is the convention trap, made visible.

What this does not do#

It does not tell you whether the rep was any good. A counter has one integer and no way to explain itself; judging depth or bar path is form feedback, which has a channel for telling the user why. Our opinion, from the testing page: a shallow rep should usually be counted and flagged rather than silently dropped, because dropping it makes a form problem present to the user as a counting bug and the count is the thing they can check.

It also produces no comparable accuracy number. Whatever precision and recall your corpus reports describe your clips, your filming and your bodies. Publishing them as an accuracy claim is measuring your own office and calling it a fact about the world.

Frequently asked questions

What smoothing constant should the EMA use for a joint angle?
There is no right value to copy, which is why it is a constructor argument here rather than a baked-in number. It is a straight trade: a heavier filter kills jitter and adds lag, and the lag is visible in this recipe's own end-to-end test as a systematic 52 millisecond delay on a two-second rep. Pick it against your frame rate and your movement tempo, then hold it still and let the corpus tell you when a change was worth it. Two practical notes. Setting it to 1 disables smoothing entirely, which is useful in tests where you want raw noise to reach the state machine. And if your signal is too noisy for an exponential average to handle, replace the filter behind the same seam rather than loosening the thresholds to compensate.
How do I choose the two hysteresis thresholds for a new exercise?
Start from the joint whose angle swings most cleanly through the movement in the camera plane, then set the entry thresholds inside each extreme rather than at it, leaving a gap wide enough that ordinary keypoint noise cannot cross both. The gap is the hysteresis and it is the only thing preventing a double-count. This implementation throws at construction time if the two values are equal or inverted, so a mistake surfaces in your setup code rather than as a miscount in the field. Then calibrate: range of motion varies by body and mobility, so a pair that works for one person may never trigger for another, and the usual fix is a reference rep captured at the start of a set. Build the counter per set once you know the user's range.
Can I score the counter against keypoints my own pose model produced?
Replaying recorded keypoint sequences captured from a real device run is exactly right, and this recipe is built for it: every frame carries its own timestamp, nothing reads a clock, so a whole corpus scores in milliseconds and the state machine regression-tests cheaply. What is circular is generating the clips themselves from the same pose model you are testing. That scores your state machine against your own model's opinion, and catching the cases where that opinion is wrong is the entire reason the corpus exists. The test is honest when the thing under test sits downstream of the recording, and dishonest when it is the recorder.
Why does the scorer return a null precision instead of a perfect score?
Because a clip where nothing was predicted has no evidence about precision, and reporting 1.0 there would let a counter that fired zero times look flawless. The scorer returns null for a metric whose denominator is empty, and the same reasoning drives the rest of its output: no F-score, because collapsing the two lets a gain in one silently pay for a regression in the other, and no pooled figure across clips, because a pooled number is dominated by whichever exercise you filmed the most of. Under-counting and over-counting also do not feel remotely alike to a user, so they get two floors rather than one target. Gate the build on the per-clip diff instead, and let an improvement fail too, so accepting it means committing an updated baseline in the same change.

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed August 12, 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 cookbook · by AIFitnessAPI