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.
| Mode | Source | When the source ticks |
|---|---|---|
| Rule-based (PoseFlow Studio default) | PhaseStateMachine.completedReps | A phase visit history matches the authored repDetection.phases / phaseAlternatives sequence |
| Vector matching (legacy) | Position-matcher rep counter | A 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.isNotEmptyANDphaseConfigs.isEmpty→ vector matching ON (legacy.posefiles built from a recorded reference pass).Movement.positions.isEmptyANDphaseConfigs.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:
- Membership conditions on each
PhaseConfig— the trainer- authored measurement tests that define being “in” that phase. RepDetectionConfigon theMovement— 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: 80repDetection: strategy: sequence countOn: sequenceComplete phases: [up, down, up]How the rep ticks
On every frame, PhaseStateMachine.update(trackingValues):
- Re-evaluates every phase’s membership conditions.
- For each phase whose conditions are met right now, starts (or
continues) a
_candidateSatisfiedSincetimer. For any whose conditions broke, clears it. - Picks the new “actual phase” — the candidate that’s cleared its
holdTimeAND has been a candidate the longest (deterministic tie-break favouring established phases over brief secondary satisfaction). - If the actual phase changed, emits
PhaseChangeEventand appends the new phase id to the per-rep visit list. - 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.2The 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.
Read next
- Rep counting internals — the
debounce model, the anti-cheat gate semantics, the
.posecache layer, and how each is made controllable for trainers. - Phase sequences — author multi-path reps (e.g. left + right lunge as alternatives for one rep).
PhaseStateMachineAPI.RepDetectionConfigmodel.- Rep counting parity across surfaces.