Pose estimation

Calculate joint angles from pose landmarks

A joint angle is the angle between the two body segments that meet at the joint. A pose model gives you the ends of those segments, so the angle is three landmarks and a little trigonometry. The maths is short; the details around it are where most implementations go wrong.

Three points make an angle

A joint angle measured at the knee, between the segment to the hip and the segment to the ankle77°hip — fromknee — vertexankle — tou = hip − knee, v = ankle − knee
The knee angle is measured at the knee, between the segment up to the hip and the segment down to the ankle.

Pick the joint as the vertex and one landmark either side of it. Subtract the vertex from each to get two vectors, u and v, pointing along the segments. The angle between u and v is the joint angle.

JointFromVertexToMediaPipe indices (left)
Shoulderelbowshoulderhip13 · 11 · 23
Elbowshoulderelbowwrist11 · 13 · 15
Wristelbowwristindex knuckle13 · 15 · 19
Hipshoulderhipknee11 · 23 · 25
Kneehipkneeankle23 · 25 · 27
Anklekneeankletoe25 · 27 · 31
Necknosemid-shouldersmid-hips0 · 11+12 · 23+24
Trunk leanmid-shouldersmid-hipsstraight down11+12 · 23+24 · —
The definitions Markerless uses. Right-side joints use the even index one below each odd one: right knee is 24 · 26 · 28. The midline joints take the midpoint of a pair, because MediaPipe has no neck or spine points.

The formula

The textbook route is the dot product: cos θ = (u·v) / (|u||v|), then take the arccosine. It works, but arccosine loses precision near 0° and 180° — exactly where a straight arm or leg sits — and it can only ever answer 0–180°, so it cannot tell a knee bending forwards from one bending backwards.

Use atan2 of the cross product and the dot product instead: θ = atan2(u×v, u·v). It is accurate everywhere, needs no division, and keeps the direction — the sign says which way v lies from u.

Python
import math

def joint_angle(a, b, c, width, height):
    """Angle at b, swept from a to c, in degrees 0–360.

    a, b, c are landmarks with normalized x and y (0–1, origin top-left).
    width and height are the frame's size in pixels.
    """
    ux, uy = (a.x - b.x) * width, (a.y - b.y) * height
    vx, vy = (c.x - b.x) * width, (c.y - b.y) * height
    cross = ux * vy - uy * vx
    dot = ux * vx + uy * vy
    return math.degrees(math.atan2(cross, dot)) % 360

def interior(angle):
    """The unsigned 0–180 angle, if you don't need the direction."""
    return min(angle, 360 - angle)

# Left knee: hip (23), knee (25), ankle (27)
p = result.pose_landmarks[0]
knee = joint_angle(p[23], p[25], p[27], frame_width, frame_height)
TypeScript
type Point = { x: number; y: number };

/** Angle at b, swept from a to c, in degrees 0–360. Points are normalized; w and h are pixels. */
export function jointAngle(a: Point, b: Point, c: Point, w: number, h: number): number {
  const ux = (a.x - b.x) * w, uy = (a.y - b.y) * h;
  const vx = (c.x - b.x) * w, vy = (c.y - b.y) * h;
  const deg = (Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy) * 180) / Math.PI;
  return deg < 0 ? deg + 360 : deg;
}

/** The unsigned 0–180 angle, if you don't need the direction. */
export const interior = (angle: number) => Math.min(angle, 360 - angle);

// Left knee: hip (23), knee (25), ankle (27)
const knee = jointAngle(p[23], p[25], p[27], video.videoWidth, video.videoHeight);

Pixel space, not normalized

MediaPipe's x and y are fractions of the frame's width and height. Those are different numbers unless the frame is square, so a segment that is really at 45° comes out at some other angle in normalized coordinates. Multiply x by the width and y by the height before taking any angle.

How much it matters

On a portrait 1080×1920 clip, computing in normalized space put our own angles 8–16° out on average against the same angles in pixels. It is the single largest error in most hand-rolled implementations, and the easiest to fix.

Keep the direction: 0–360 rather than 0–180

The unsigned angle — 0 to 180° — says how bent a joint is but not which way. For a knee that is usually enough. For a shoulder, an arm 40° in front of the body and 40° behind it read the same. A directed sweep from one segment to the other, on a 0–360° scale, keeps them apart: one reads 40° and the other 320°.

Markerless reports every angle as a clockwise sweep on that 0–360° scale, so a straight limb reads 180°. Two consequences are worth knowing before you compare numbers:

  • Direction depends on the side facing the camera. The same posture filmed from the other side reads as its complement: 150° becomes 210°.
  • Image y points down. Engines with y pointing up — Apple Vision among them — reverse every sweep. Flip y (1 − y) before using the formula, or the left and right of everything swap.

Clinical measurements use yet another convention, with 0° at the neutral position. A knee bent 40° from straight is 40° of flexion to a physiotherapist and 140° here. Range of motion by joint gives the conversion for each joint.

Noise, occlusion and smoothing

  • Check visibility first. An angle is only as good as its worst point. Skip frames where any of the three has low visibility rather than computing an angle from a guess.
  • Expect jitter. Landmarks move by a few pixels from frame to frame even when the body does not, and short segments such as the hand and foot turn that into degrees quickly.
  • Smooth the angle series, not the decision. A low-pass filter over the angles removes jitter without delaying a peak, if it runs forwards and backwards — see the Butterworth filter and the other four.
  • Film square-on. Everything above measures the angle as the camera sees it. A limb moving towards the lens reads short, and no formula recovers that.

Checked against an engine

The definitions and the formula above are the ones Markerless runs. Computed from a clip's landmarks and compared with the QuickPose engine's own output for the same clip, all eight engine-measured joints agree to within 0.1° on every frame — the engine's own rounding. That says the maths is the same; it says nothing about how close the landmarks are to the true joint centres, which is a property of the pose model.

If you would rather not maintain this yourself, range of motion gives all fourteen angles on every frame of an uploaded clip, and a rep counter export ships the same maths ready-made in Swift, Kotlin, TypeScript and Python.

Questions

How do you calculate a joint angle from pose estimation?

Take three keypoints — the joint and one either side of it, such as hip, knee and ankle. Subtract the joint from the other two to get two vectors, convert them to pixels, and take atan2 of their cross product and dot product. The result is the angle at the joint in degrees.

Should I use acos or atan2?

atan2. Arccosine of the normalized dot product loses precision near 0° and 180°, where straight limbs sit, and cannot tell which way a joint bends. atan2 of the cross and dot products has neither problem.

Why does my knee angle read 200° instead of 160°?

You are using a directed 0–360 sweep and the person faces the other way, or your y axis points up. Both reverse the sweep, turning x into 360 − x. If you only need how bent the joint is, take the smaller of x and 360 − x.

Can I get 3D joint angles from a single camera?

MediaPipe's world landmarks are 3D, but their depth is inferred from one image. They are worth trying for movement out of the camera's plane, and worth checking against what you can see before trusting them.

References

  1. Wade, L., Needham, L., McGuigan, P., & Bilzon, J. (2022). Applications and limitations of current markerless motion capture methods for clinical gait biomechanics. PeerJ, 10, e12995.

    A review of where markerless capture stands, and of the noise that makes filtering a normal step rather than an optional one.

    doi:10.7717/peerj.12995

  2. Needham, L., Evans, M., Cosker, D. P., et al. (2021). The accuracy of several pose estimation methods for 3D joint centre localisation. Scientific Reports, 11, 20673.

    Measures how far pose-estimation joint centres sit from marker-based ones — the error a filter is and is not able to help with.

    doi:10.1038/s41598-021-00212-x

Next

Fourteen joint angles from your own clip, no pipeline to write.