Skip to content

Form analysis

PoseFlow evaluates trainer-authored FormRules per frame and emits feedback events that the host UI surfaces however it wants, toast deck, audio cue, haptic burst, score badge, post-rep summary.

The shape of a rule

class FormRule {
final String id;
final String trackingPoint;
final ConditionOperator op;
final List<double> value;
final FormFeedback feedback;
final String? appliesToPhase;
final FeedbackTrigger trigger;
final FormRuleSeverity severity;
final RetrospectiveConfig? retrospective;
}

Concretely, a rule says “when the channel left_knee is outside the range [70°, 110°] during the down phase, fire Push your knees out as a warning cue”.

How rules flow through the runtime

Movement.formRules
v
┌──────────────────────────────────┐
│ MovementTracker.loadMovement │
│ - filters by current phase │
│ - filters by camera bucket │
│ - attaches per-rule baselines │
└──────────┬───────────────────────┘
v
┌──────────────────────────────────┐
│ FormServiceV2 (per frame) │
│ - evaluate every active rule │
│ - aggregate to current score │
│ - emit feedback events │
└──────────┬───────────────────────┘
v
onFeedback stream
FormFeedbackEvent

When a rule fires

The rule’s trigger controls when its feedback flows out:

  • onViolation (default), fires the moment compliance flips from in-range to out-of-range. One event per breach.
  • onCompliance: fires when the user moves BACK into range (positive reinforcement cues like “Good depth!”).
  • always: fires every frame the condition is evaluated. Used sparingly for live numeric readouts.

Each rule also carries a severity:

  • info, neutral coaching cue.
  • warning, needs attention but not a rep-killer.
  • error, disqualifying issue (e.g. catastrophic form break).

The host can filter the toast deck on severity via OverlaySettings.formFeedbackSeverities so trainers don’t see info chatter on top of warnings during a live test.

Phase scoping

Most rules only apply during one phase. The appliesToPhase field gates the evaluation:

FormRule(
id: 'knee-depth-down-only',
trackingPoint: 'left_knee',
op: ConditionOperator.lt,
value: [120],
appliesToPhase: 'down', // only checked during 'down'
feedback: FormFeedback(text: 'Squat deeper'),
);

When appliesToPhase is null, the rule applies to every phase.

Retrospective rules

Some form cues only make sense AFTER a phase completes, e.g. “average knee angle through the down phase was 130°, push deeper next time”. That’s a retrospective rule:

FormRule(
id: 'avg-depth',
trackingPoint: 'left_knee',
appliesToPhase: 'down',
retrospective: RetrospectiveConfig(
op: RetrospectiveOp.average,
mode: EvaluationMode.duringPhase,
threshold: 120,
),
feedback: FormFeedback(text: 'Average depth: 130°, go deeper'),
);

Retrospective rules don’t fire per frame; they fire ONCE at the phase boundary with the aggregated statistic (average, peak, min, max, …). The form service evaluates them in evaluatePhaseEnd(phaseId) which MovementTracker calls automatically.

Camera-bucket scoping

For view-dependent measurements (spine lean, knee valgus, shoulder abduction), a rule’s range bands can vary per camera bucket. The Movement.angleBands map carries per-bucket bands:

angleBands:
front_hip: [{phase: down, channel: spine_lean, min: -8, max: 8}]
90left_hip: [{phase: down, channel: spine_lean, min: -5, max: 15}]

The runtime detects the current bucket via CameraAngleDetector (see camera buckets) and picks whichever band matches. Front-on bands are stricter on lateral lean; side-on bands tolerate more forward lean because the geometry is different.

Compliance levels

Every evaluated rule produces a ComplianceLevel:

  • compliant, in range.
  • closeToLimit, within 10% of the boundary on either side. Drives soft amber cues like “watch your knees”.
  • violation, out of range. Drives the firm error cues.

Compliance feeds the score aggregator: weighted average across all active rules, with compliant = 100, closeToLimit = 60, violation = 0. The aggregator is what result.formScore reports.

Feedback throttling

The form service throttles rapid-fire events. A rule won’t re-fire within 800 ms of its last event for the same trigger, so a wobbly knee in the down phase doesn’t generate 20 cues per second.