Skip to content

Rep counting

This page covers how a rep gets counted from the moment the user’s pose crosses a phase boundary to the moment repCount ticks. The internals sit one page deeper (debounce, anti-cheat gate, cache layer); see Rep counting internals once you’ve read this.

The two paths

MovementTracker.repCount (and TrackingResult.repCount) reads from one of two sources depending on what the loaded Movement contains.

ModeSourceWhen the source ticks
Rule-based (PoseFlow Studio default)PhaseStateMachine.completedRepsA phase visit history matches the authored repDetection.phases / phaseAlternatives sequence
Vector matching (legacy)Position-matcher rep counterA full reference-position sequence completes with frame validity passing

The flag is set automatically by MovementTracker.setUseVectorMatching based on what the movement carries:

  • Movement.positions.isNotEmpty AND phaseConfigs.isEmpty → vector matching ON (legacy .pose files built from a recorded reference pass).
  • Movement.positions.isEmpty AND phaseConfigs.isNotEmpty → rule-based path ON (PoseFlow Studio output, the default).
  • Both populated → rule-based runs; vector matching gates the events.

The consumer-visible API is identical regardless of mode: repCount, the onRepCompleted stream, and result.repCount all surface the same number.

Rule-based path — the current model

Every PoseFlow Studio-authored movement uses this path. Two things work together to decide when a rep ticks:

  1. Membership conditions on each PhaseConfig — the trainer- authored measurement tests that define being “in” that phase.
  2. RepDetectionConfig on the Movement — the strategy (cycle / sequence) and the expected ordered visit history.

Phases are membership predicates, not transitions

A phase is defined by its conditions (knee in 70–100°, hip in 80–110°, etc). The user is “in” phase X on a given frame iff every one of X’s conditions evaluates true for the current tracking values. If no phase’s conditions are met, the user is in no phase (the engine’s null phase).

There is no notion of a transition firing independently of the conditions — phase membership is a pure function of the current measurements. The only filter between “conditions met” and “phase becomes actual” is the debounce timer (see below).

phaseConfigs:
- id: up
conditions:
- trackingPoint: left_knee
operator: gt
value: 160
holdTime: 80 # ms — optional, defaults to 80
- id: down
conditions:
- trackingPoint: left_knee
operator: lt
value: 100
holdTime: 80
repDetection:
strategy: sequence
countOn: sequenceComplete
phases: [up, down, up]

How the rep ticks

On every frame, PhaseStateMachine.update(trackingValues):

  1. Re-evaluates every phase’s membership conditions.
  2. For each phase whose conditions are met right now, starts (or continues) a _candidateSatisfiedSince timer. For any whose conditions broke, clears it.
  3. Picks the new “actual phase” — the candidate that’s cleared its holdTime AND has been a candidate the longest (deterministic tie-break favouring established phases over brief secondary satisfaction).
  4. If the actual phase changed, emits PhaseChangeEvent and appends the new phase id to the per-rep visit list.
  5. Checks for rep completion against repDetection.

For strategy: sequence, the rep emits when the visit list’s tail matches the authored sequence. For strategy: cycle, the rep emits each time the user re-enters the primary phase having visited at least one non-primary phase since the last primary entry.

The closing-phase trick

Many sequences return to their start phase (up → down → up). The visit list NOW includes enteredPhaseId for the match check, so the closing up triggers the rep without the opening up’s slot being shadowed:

final candidate = <String>[..._visitedSinceLastPrimary, enteredPhaseId];
if (_matchesAnySequence(alternatives, candidate)) _completeRep(...);

The visit list clears once per rep (right after _completeRep), not on every primary re-entry.

Rep events

When either path completes a rep, the tracker emits one RepCompletedEvent on onRepCompleted:

class RepCompletedEvent {
final int repNumber;
final DateTime timestamp;
final Duration duration;
final RepQuality quality; // score + grade + form
}

quality.formScore is the per-rep form score (weighted across the duration of the rep). quality.score is the overall rep quality including tempo, range-of-motion, and form. quality.grade discretises it to perfect / great / good / okay / poor.

If your UX is reps-only (no form information surfaced), you can suppress the form-score channel at your state-management boundary. The tracker still computes it; your renderer just ignores it. See the rep counting parity doc for the full surface-by-surface contract.

Anti-cheat (RepScoringConfig) gate

Each Movement optionally carries a RepScoringConfig that gates whether an emitted rep increments the counter, based on its quality score:

repScoring:
enabled: true
minQualityScore: 60
weights:
formScore: 0.5
rangeOfMotion: 0.3
tempo: 0.2

The contract: when repScoring.enabled is false (the default when a movement doesn’t opt in), the rep counter is never gated. Every emitted rep counts. There is no hidden quality floor; there is no preset that silently re-enables it. The Studio’s “Anti-cheat” panel shows the exact same value the runtime uses.

When enabled is true, the rep additionally has to clear minQualityScore for the counter to tick. Reps that fall short appear in lastRepCleared = false on the live state for the trainer-facing UI, so the gate is visibly dropping them rather than silently.

MovementTracker.updateRepScoring(config) swaps the gate at runtime without a session restart — useful for trainer-facing tuning panels that adjust the threshold mid-session.

See Rep counting internals for the exact lines that enforce this and the failure modes to watch.