Skip to content

Camera angle buckets

PoseFlow classifies the camera’s position relative to the user into one of 16 canonical buckets every frame. This drives two things:

  1. Which slice of angleBands the form service uses for view-dependent measurements (spine lean reads differently from front vs side).
  2. Trainer UX, the Studio’s “where you’re standing” chip + grid highlight, the consumer app’s “rotate your phone” hints.

The 16 buckets

A semi-sphere orbit around the user:

90° Left45° LeftFront45° Right90° Right
Head (camera at face level)90left_overhead45left_overheadfront_overhead45right_overhead90right_overhead
Hip (camera at hip level, default)90left_hip45left_hipfront_hip45right_hip90right_hip
Feet (camera on the ground)90left_floor45left_floorfront_floor45right_floor90right_floor

Plus a 16th specialised bucket: behind_hip for movements that authentically need a back view (rare).

The bucket IDs use overhead and floor for backward-compatibility with .pose files in the wild; the user-facing labels are Head and Feet. See CameraBucket.displayLabel.

How the detector works

CameraAngleDetector.detectBucket(pose) runs every frame and aggregates evidence across 11 paired anatomical landmarks:

PairWeightWhy
Shoulders1.0Trunk anchor, always paired, spans rotation axis
Hips1.0Trunk anchor
Ears0.8Sharp signal at ±90° (far ear occludes)
Eyes0.6Backup face evidence
Eye inner / outer0.4 eachCatch subtle head turn
Knees0.6Survives upper-body occlusion
Ankles0.5Lower-body fallback
Heels0.4Last-resort lower body
Elbows0.4Static arm rotations
Wrists0.3Lowest, swing during movement

Each pair contributes either:

  • 3D world-z delta (preferred when Pose.hasWorldLandmarks). The subject’s near-camera shoulder has smaller world z; the sign of left.z − right.z is unambiguous regardless of mirror state.
  • 2D visibility asymmetry (always available). When vis(left) > vis(right), the subject’s left side faces the camera → camera is on their left → negative azimuth.

The final sign is the sign of the weighted sum of all contributing pairs. Confidence drops proportionally to how unanimous the vote was; if pairs cancel, the detector falls back to neutral and the temporal smoother holds the previously-committed direction.

Why image-space x-deltas aren’t used

The legacy detector relied on right_shoulder.x − left_shoulder.x as a sign signal. We dropped this because:

  1. Mirror-dependent, the live PoseFlow Studio camera is a selfie / mirror surface; the pose engine emits anatomy-labeled landmarks; the x convention is opposite to the raw camera output.
  2. The shoulders don’t swap on rotation, they converge toward image-center. The image-space sign is dominated by perspective noise, not direction.

The new multi-landmark aggregator uses signals that are convention- invariant: world-z is in camera-relative depth (mirror doesn’t affect it), visibility is anatomy-labeled.

Occlusion resilience

The detector keeps working when:

  • Legs are out of frame (zoom on upper body): 8 upper-body pairs still vote.
  • Face is occluded by a hat or headphones: trunk + arm + lower-body pairs still vote.
  • One ear is hidden by hair: the other 10 pairs outvote it.
  • Extreme zoom on torso (face + limbs all out of frame): shoulders
    • hips alone are enough at full weight.

The CameraAngleDetector occlusion tests inside the SDK pin this behaviour.

Temporal smoothing

StableCameraAngleDetector wraps the stateless detector with a 10-frame rolling window + hysteresis. The runtime tracker uses the smoother (not the raw detector) so the chosen bucket is stable across frame-to-frame jitter. UI surfaces (Studio chip, picker grid highlight) read from the same smoother instance per session.

The smoother only switches the committed bucket when a different bucket has been the raw pick for at least 3 consecutive frames AND those dissents agreed with each other, flickering between two candidates doesn’t flip the output.

angleBands, per-bucket measurement ranges

When a movement’s authored measurements behave differently from different angles (spine lean, knee valgus, shoulder abduction), the trainer authors multiple bands per channel, one per bucket:

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

At runtime, MovementTracker._resolveBucketOverrides:

  1. Asks the smoother for the current bucket.
  2. Finds the bucket’s authored bands for the current phase.
  3. Overrides the form rule’s FormCondition.value with that bucket’s range.

If the current bucket has no band for a channel, the runtime interpolates between the two nearest authored buckets (by azimuth distance on the ±180° ring). Falls back to the primary front_hip band when confidence is too low.

View-invariant channels (joint flexion angles, distances, ratios) ignore angleBands entirely, those numbers are intrinsic and don’t change with camera position.

Authoring multiple buckets

In the Studio:

  1. Stand in your default position (e.g. front, hip-level).
  2. Drop measurements + conditions as usual.
  3. Open the Camera angles panel. Tap a different bucket cell to make it the authoring target.
  4. The conditions panel re-renders showing the bands authored for the new bucket. Re-drop or re-tune as needed.
  5. Save. The output .pose file carries one angleBand entry per authored channel × bucket combination.

The Studio’s detected-bucket chip shows where you ARE physically; the grid highlight shows where you’re AUTHORING. They’re independent on purpose, you can stand front-on while authoring side-on bands by typing the numbers in directly.

Movement.cameraAngle

Movement.cameraAngle (front / side / any) is a high-level authoring hint that gets baked into the .pose file. It does NOT pick the camera lens, every consumer uses the front- facing camera. Its only runtime role: when bucket detection confidence is low, it biases the bucket pick toward the authored default.