AI & Computer Vision, lesson 44 of 52

Pose Estimation with MediaPipe

advanced28 min read

Introduction

A box tells you where a player is. It can't tell you whether they're kicking, jumping or about to fall. Pose estimation finds the joints of the body (shoulders, elbows, hips, knees, ankles), like the ends of a puppet's strings. Today you'll find them with MediaPipe, measure joint angles, and turn them into events like "kick!".

By the end of this lesson you'll be able to:

  • Explain what a landmark is, and what visibility means
  • Compute a joint angle from three points, in pixels
  • Smooth a noisy signal and keep going through missed frames
  • Write a simple rule for an event (arm raised, kick) and say how to tune it
  • Describe how to apply pose to a whole team (detect, track, crop, pose)
  • Explain why body data about children needs consent and limits

Working principle

From boxes to bodies

YOLO (lesson 23) tells us where a player is: a rectangle. Pose estimation tells us how the body is arranged: it finds landmarks, points on the shoulders, elbows, wrists, hips, knees, ankles and more. MediaPipe's Pose Landmarker finds 33 of them. Connect the dots and you have a skeleton.

What a landmark contains

FieldMeaning
x, yPosition as a fraction of the picture width and height (0 to 1)
zDepth, roughly, relative to the hips (less reliable)
visibilityHow sure the model is that the joint is seen (not hidden)
presenceHow sure it is that the joint is in the frame at all

The result also has world landmarks in metres from the hips. We use the picture ones.

Angles tell you what the body is doing

The angle at a joint is the angle between two bones that meet there. Three points give one angle: for the knee, hip, knee and ankle.

Landmarks are the joints. The angle at the knee (hip → knee → ankle) is 152°. A rule like "knee straightens past 160° while the leg swings forward" turns angles into an event: not yet.

hip
  \
   knee        straight leg = 180 degrees
  /            bent to a right angle = 90 degrees
ankle

The angle between two lines comes from a dot product and an arccosine. The code does that for you; what matters is the idea, and the units trap: measure it in pixels.

Simple rules make events

Once you have numbers per frame you can write rules, just like the goal trigger (lesson 31):

EventRule
Arm raised (a keeper's save, a throw-in, a hand ball)Wrist is clearly above the shoulder
KickThe ankle moves faster than some leg-lengths per second
Deep squat or sprint stanceKnee angle below some threshold

These are starting points. Tune the numbers on real footage and write down the false alarms and misses (lesson 36, lesson 40).

Smoothing

Pose jumps a little every frame, even when the person is still. A simple fix is exponential smoothing: new value = a bit of the new measurement plus a bit of the old value. Too little smoothing looks jittery; too much makes quick movements late.

Whole teams are hard

MediaPipe's Pose Landmarker is designed for one or a few clearly visible people. On a pitch, players are small and often partly hidden. A practical design (and a good project):

YOLO + tracker (lesson 26) -> crop each player -> pose on the crop

Pose also needs the person to be big enough in the picture to see limbs; a player 40 pixels tall will not give a useful skeleton. There are also other tools, such as pose models in the Ultralytics family, that find several people at once.

Limits you should expect

  • Hidden joints (behind another player) give low visibility or wrong guesses.
  • Players in the far half of a wide shot are too small.
  • Clothing, loose kit and unusual poses are harder.
  • Fast motion blurs.
  • Accuracy may differ for different bodies and kit (lesson 43): test, don't assume.

Bodies are personal data

A skeleton is more abstract than a face, but it is still data about a person, and movement can be quite individual. It can reveal injuries, ability and habits. If you analyse a child's body movement:

  • You need the consent from lesson 14, and a clear reason (coaching, not ranking).
  • Keep the data on school machines, and keep it only as long as needed.
  • Do not use it to judge, compare or label children ("lazy", "slow").
  • Put it on the model card (lesson 43): what the system is not for.

Not a doctor

Joint angles from a video are rough. They cannot diagnose an injury or say whether a movement is "safe". Never use this course's tools for medical or fitness decisions about real people.

What you'll need

  • Your cv_course environment with MediaPipe: pip install mediapipe
  • A webcam, and space to kick (gently!)

Build it

Install: pip install mediapipe opencv-python. The code in this lesson was tested with mediapipe 0.10.32. In that version the old mp.solutions.pose shortcut that many older tutorials use no longer exists; we use the current "Tasks" interface (mp.tasks.vision.PoseLandmarker).

You also need a model file. It is not inside the package. Download pose_landmarker_lite.task from the Models section of Google's guide, "Pose landmark detection guide for Python" (search for that title, on developers.google.com/edge/mediapipe), and put it in your folder. "Lite" is the fastest; "full" and "heavy" are more accurate and slower.

Cell 1: joints, angles, movement rules

Works on any list of 33 landmarks. The tests use pretend people made of sticks, so you can check the maths without a camera or a model.

"""Joints, angles and simple movement rules from pose landmarks.
Works on any list of 33 landmarks with x, y, visibility (MediaPipe's NormalizedLandmark, or our pretend ones)."""
import math
 
import cv2
import numpy as np
 
# The numbers MediaPipe uses for its 33 landmarks (checked against mediapipe's own PoseLandmark list)
J = {"nose": 0, "l_shoulder": 11, "r_shoulder": 12, "l_elbow": 13, "r_elbow": 14, "l_wrist": 15, "r_wrist": 16,
     "l_hip": 23, "r_hip": 24, "l_knee": 25, "r_knee": 26, "l_ankle": 27, "r_ankle": 28}
 
BONES = [("l_shoulder", "r_shoulder"), ("l_shoulder", "l_elbow"), ("l_elbow", "l_wrist"),
         ("r_shoulder", "r_elbow"), ("r_elbow", "r_wrist"), ("l_shoulder", "l_hip"), ("r_shoulder", "r_hip"),
         ("l_hip", "r_hip"), ("l_hip", "l_knee"), ("l_knee", "l_ankle"), ("r_hip", "r_knee"), ("r_knee", "r_ankle")]
 
# Each angle is measured AT the middle joint, between the other two.
ANGLES = {"l_knee": ("l_hip", "l_knee", "l_ankle"), "r_knee": ("r_hip", "r_knee", "r_ankle"),
          "l_elbow": ("l_shoulder", "l_elbow", "l_wrist"), "r_elbow": ("r_shoulder", "r_elbow", "r_wrist")}
 
 
def point(lms, name, w, h, min_vis=0.5):
    """Landmark -> pixel position, or None if the model is not sure it can see that joint."""
    lm = lms[J[name]]
    vis = getattr(lm, "visibility", 1.0)
    if vis is None or vis < min_vis:
        return None
    return (lm.x * w, lm.y * h)                                  # normalized (0 to 1) -> pixels. Angles need PIXELS: see the lesson
 
 
def angle_at(a, b, c):
    """Angle in degrees at b, between the lines b->a and b->c. 180 = straight, 90 = right angle."""
    v1 = (a[0] - b[0], a[1] - b[1])
    v2 = (c[0] - b[0], c[1] - b[1])
    n1, n2 = math.hypot(*v1), math.hypot(*v2)
    if n1 == 0 or n2 == 0:
        return None
    cosang = (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)
    return math.degrees(math.acos(max(-1.0, min(1.0, cosang))))
 
 
def joint_angle(lms, name, w, h, min_vis=0.5):
    a, b, c = (point(lms, n, w, h, min_vis) for n in ANGLES[name])
    if a is None or b is None or c is None:
        return None
    return angle_at(a, b, c)
 
 
class Smoother:
    """Exponential smoothing: new = alpha*measurement + (1-alpha)*old. Pose jitters frame to frame."""
 
    def __init__(self, alpha=0.4):
        self.alpha, self.value = alpha, None
 
    def update(self, x):
        if x is None:
            return self.value                                    # keep the last good value through a missed frame
        self.value = x if self.value is None else self.alpha * x + (1 - self.alpha) * self.value
        return self.value
 
 
def arm_raised(lms, w, h, side="r", margin=0.05, min_vis=0.5):
    """Wrist clearly above the shoulder (smaller y is higher in a picture)."""
    wr, sh = point(lms, f"{side}_wrist", w, h, min_vis), point(lms, f"{side}_shoulder", w, h, min_vis)
    if wr is None or sh is None:
        return None
    return wr[1] < sh[1] - margin * h
 
 
def leg_length(lms, w, h, side="r"):
    hip, knee, ank = (point(lms, f"{side}_{n}", w, h, 0.0) for n in ("hip", "knee", "ankle"))
    return math.dist(hip, knee) + math.dist(knee, ank)
 
 
def ankle_speed_in_legs_per_second(history, fps, w, h, side="r"):
    """history: list of landmark lists, newest last. Speed is divided by leg length, so it does not
    depend on how far from the camera the player is. 1.0 means one leg length per second."""
    if len(history) < 2:
        return 0.0
    a0 = point(history[-2], f"{side}_ankle", w, h, 0.0)
    a1 = point(history[-1], f"{side}_ankle", w, h, 0.0)
    return math.dist(a0, a1) * fps / max(leg_length(history[-1], w, h, side), 1e-6)
 
 
def draw_skeleton(frame, lms, min_vis=0.5):
    out = frame.copy()
    h, w = out.shape[:2]
    for a, b in BONES:
        pa, pb = point(lms, a, w, h, min_vis), point(lms, b, w, h, min_vis)
        if pa and pb:
            cv2.line(out, (int(pa[0]), int(pa[1])), (int(pb[0]), int(pb[1])), (0, 255, 255), 3)
    for name in J:
        p = point(lms, name, w, h, min_vis)
        if p:
            cv2.circle(out, (int(p[0]), int(p[1])), 5, (0, 0, 255), -1)
    return out

Three ideas to notice:

  • Angles must be measured in pixels, not in MediaPipe's 0-to-1 numbers. The x and y numbers are fractions of the picture's width and height, which are different lengths, so angles computed from them are distorted. (A real 135-degree knee measured 143 degrees from the raw numbers in our test.)
  • A joint the model is not sure about (low visibility) gives None, not a guess.
  • Speed is divided by leg length, so a kick looks the same whether the player is near or far.

Cell 2: pretend people, for testing

"""A pretend person made of sticks, so we can test the maths without a camera or a model.
The landmarks have the same fields as MediaPipe's (x, y, z, visibility), and the same 33 slots."""
import math
from types import SimpleNamespace
 
from skeleton import J
 
 
def blank():
    return [SimpleNamespace(x=0.0, y=0.0, z=0.0, visibility=0.0, presence=0.0) for _ in range(33)]
 
 
def put(lms, name, x, y, vis=1.0):
    lms[J[name]] = SimpleNamespace(x=x, y=y, z=0.0, visibility=vis, presence=1.0)
 
 
def make_person(r_knee_deg=180.0, r_wrist_up=False, cx=0.5, w=640, h=480, thigh=0.22, shin=0.22):
    """Standing person seen from the front, in a w x h picture. Lengths are fractions of the picture HEIGHT,
    so a 'real' leg of equal length in pixels has the same length in both directions."""
    lms = blank()
    sh_y, hip_y = 0.25, 0.50
    put(lms, "l_shoulder", cx - 0.06 * h / w, sh_y); put(lms, "r_shoulder", cx + 0.06 * h / w, sh_y)
    put(lms, "l_hip", cx - 0.04 * h / w, hip_y);     put(lms, "r_hip", cx + 0.04 * h / w, hip_y)
    put(lms, "l_knee", cx - 0.04 * h / w, hip_y + thigh); put(lms, "l_ankle", cx - 0.04 * h / w, hip_y + thigh + shin)
    # right leg: thigh straight down, shin rotated so the angle AT THE KNEE is r_knee_deg
    kx, ky = cx + 0.04 * h / w, hip_y + thigh
    put(lms, "r_knee", kx, ky)
    bend = math.radians(180.0 - r_knee_deg)                      # 0 = straight
    # the shin points down, rotated sideways by `bend` (x measured in pixels, converted back to fractions)
    put(lms, "r_ankle", kx + (math.sin(bend) * shin * h) / w, ky + math.cos(bend) * shin)
    put(lms, "l_elbow", cx - 0.12 * h / w, 0.38); put(lms, "l_wrist", cx - 0.13 * h / w, 0.5)
    put(lms, "r_elbow", cx + 0.12 * h / w, 0.38)
    put(lms, "r_wrist", cx + 0.13 * h / w, 0.12 if r_wrist_up else 0.5)
    return lms

Cell 3: tests (no camera, no model)

import numpy as np
from fake_pose import blank, make_person, put
from skeleton import *
 
W, H = 640, 480
bad = 0
def check(name, ok, extra=""):
    global bad; bad += not ok; print("PASS" if ok else "FAIL", name, extra)
 
check("straight line is 180 degrees", abs(angle_at((0, 0), (1, 0), (2, 0)) - 180) < 1e-9)
check("right angle is 90 degrees", abs(angle_at((0, 1), (0, 0), (1, 0)) - 90) < 1e-9)
check("folded back is about 0 degrees", angle_at((1, 0), (0, 0), (1, 0.0001)) < 0.1)
check("zero-length arm returns None, no crash", angle_at((0, 0), (0, 0), (1, 1)) is None)
 
for deg in (180, 150, 120, 90, 60):
    got = joint_angle(make_person(r_knee_deg=deg), "r_knee", W, H)
    check(f"knee bent to {deg} degrees measured as {got:.1f}", abs(got - deg) < 0.5)
check("straight left leg is 180", abs(joint_angle(make_person(), "l_knee", W, H) - 180) < 0.5)
 
# the classic mistake: angles from the normalized numbers, not pixels
p = make_person(r_knee_deg=135)
hip, knee, ank = (p[J[n]] for n in ("r_hip", "r_knee", "r_ankle"))
wrong = angle_at((hip.x, hip.y), (knee.x, knee.y), (ank.x, ank.y))
check("using 0-1 numbers instead of pixels gives a WRONG angle", abs(wrong - 135) > 5, f"{wrong:.1f} instead of 135")
 
check("hidden joint gives None", joint_angle(make_person(), "r_knee", W, H, min_vis=1.1) is None)
lo = make_person(); lo[J["r_ankle"]].visibility = 0.2
check("low visibility ankle gives None", joint_angle(lo, "r_knee", W, H) is None)
 
check("arm down: not raised", arm_raised(make_person(r_wrist_up=False), W, H, "r") is False)
check("arm up: raised", arm_raised(make_person(r_wrist_up=True), W, H, "r") is True)
check("arm raised unknown if wrist hidden", arm_raised(blank(), W, H, "r") is None)
 
s = Smoother(0.5)
vals = [s.update(x) for x in [10, 10, 30, None, 30]]
check("smoother: first value kept, jump softened, None keeps last", vals[0] == 10 and 10 < vals[2] < 30 and vals[3] == vals[2], vals)
 
# kick: ankle speed in leg-lengths per second, independent of distance from the camera
fps = 30
still = [make_person(180) for _ in range(5)]
swing = [make_person(180), make_person(100)]
sp_still = ankle_speed_in_legs_per_second(still, fps, W, H)
sp_swing = ankle_speed_in_legs_per_second(swing, fps, W, H)
check("standing still: speed 0", sp_still == 0.0)
check("a quick knee snap is fast (more than 3 legs per second)", sp_swing > 3, f"{sp_swing:.1f}")
small = [make_person(180, thigh=0.11, shin=0.11), make_person(100, thigh=0.11, shin=0.11)]
sp_small = ankle_speed_in_legs_per_second(small, fps, W, H)
check("same kick, half the size (far away): about the same speed", abs(sp_small - sp_swing) / sp_swing < 0.1, f"{sp_small:.1f} vs {sp_swing:.1f}")
 
img = draw_skeleton(np.zeros((H, W, 3), np.uint8), make_person(r_wrist_up=True))
check("skeleton drawing changes the picture", img.sum() > 0)
print("failures:", bad)

All of them should print PASS. Try changing make_person(r_knee_deg=...) and predict the angle first.

Cell 4: the real thing, on a video

"""Run MediaPipe's Pose Landmarker on a video and write the knee angles to a CSV.
Needs:  pip install mediapipe opencv-python
        and a model file, e.g. pose_landmarker_lite.task, downloaded from the Models section of Google's
        'Pose landmark detection guide for Python' (see the lesson for the link)."""
import csv
import os
 
import cv2
 
from skeleton import Smoother, draw_skeleton, joint_angle
 
 
def landmarks_from_result(result, person=0):
    """The first person's 33 landmarks, or None if nobody was found in this frame."""
    if result is None or not result.pose_landmarks or len(result.pose_landmarks) <= person:
        return None
    return result.pose_landmarks[person]
 
 
def make_landmarker(model_path, num_poses=1):
    if not os.path.isfile(model_path):
        raise FileNotFoundError(
            f"Model file not found: {model_path}\n"
            "Download pose_landmarker_lite.task (see the lesson) and put it next to this script.")
    import mediapipe as mp
    BaseOptions = mp.tasks.BaseOptions
    PoseLandmarker = mp.tasks.vision.PoseLandmarker
    PoseLandmarkerOptions = mp.tasks.vision.PoseLandmarkerOptions
    RunningMode = mp.tasks.vision.RunningMode
    options = PoseLandmarkerOptions(base_options=BaseOptions(model_asset_path=model_path),
                                    running_mode=RunningMode.VIDEO, num_poses=num_poses)
    return PoseLandmarker.create_from_options(options)
 
 
def analyse_video(video_path, model_path, out_csv="knee_angles.csv", out_video=None):
    import mediapipe as mp
    landmarker = make_landmarker(model_path)
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
    w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    writer = cv2.VideoWriter(out_video, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h)) if out_video else None
    smooth_l, smooth_r = Smoother(0.4), Smoother(0.4)
    rows, i = [], 0
    with landmarker:
        while True:
            ok, frame = cap.read()
            if not ok:
                break
            rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
            image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
            result = landmarker.detect_for_video(image, int(i * 1000 / fps))        # timestamps must increase every frame
            lms = landmarks_from_result(result)
            if lms is not None:
                left = smooth_l.update(joint_angle(lms, "l_knee", w, h))
                right = smooth_r.update(joint_angle(lms, "r_knee", w, h))
                if writer:
                    frame = draw_skeleton(frame, lms)
            else:
                left = right = None
            rows.append([i, round(i / fps, 2), None if left is None else round(left, 1), None if right is None else round(right, 1)])
            if writer:
                writer.write(frame)
            i += 1
    cap.release()
    if writer:
        writer.release()
    with open(out_csv, "w", newline="") as f:
        wr = csv.writer(f)
        wr.writerow(["frame", "t", "left_knee_deg", "right_knee_deg"])
        wr.writerows(rows)
    return len(rows)
 
 
if __name__ == "__main__":
    import sys
    print(analyse_video(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "pose_landmarker_lite.task", out_video="pose_out.mp4"), "frames")

Run python pose_run.py clip.mp4 pose_landmarker_lite.task. You get knee_angles.csv and pose_out.mp4 with the skeleton drawn on. This cell was not run in preparing the lesson (the model file could not be downloaded in our workspace), so try it on a short clip first.

Cell 5: tests for the MediaPipe glue

These need mediapipe installed but no model. They use MediaPipe's own result classes to check how we read results, that a missing model gives a helpful message, and that our joint numbers match MediaPipe's.

"""Checks the glue around MediaPipe using MediaPipe's own result classes. Needs mediapipe installed; no model needed."""
from mediapipe.tasks.python.components.containers.landmark import NormalizedLandmark
from mediapipe.tasks.python.vision import PoseLandmarkerResult
 
from pose_run import landmarks_from_result, make_landmarker
from skeleton import J, joint_angle
 
bad = 0
def check(name, ok, extra=""):
    global bad; bad += not ok; print("PASS" if ok else "FAIL", name, extra)
 
def result(n_people):
    people = [[NormalizedLandmark(x=0.5, y=0.5, z=0, visibility=1.0, presence=1.0) for _ in range(33)] for _ in range(n_people)]
    return PoseLandmarkerResult(pose_landmarks=people, pose_world_landmarks=[], segmentation_masks=None)
 
check("one person: 33 landmarks", len(landmarks_from_result(result(1))) == 33)
check("nobody found: None", landmarks_from_result(result(0)) is None)
check("no result at all: None", landmarks_from_result(None) is None)
check("asking for a second person who is not there: None", landmarks_from_result(result(1), person=1) is None)
try:
    make_landmarker("definitely_missing.task"); check("missing model gives a clear error", False)
except FileNotFoundError as e:
    check("missing model gives a clear error", "Download" in str(e))
 
# our joint numbers match MediaPipe's own
from mediapipe.tasks.python.vision import PoseLandmark
names = {"nose": "NOSE", "l_shoulder": "LEFT_SHOULDER", "r_shoulder": "RIGHT_SHOULDER", "l_elbow": "LEFT_ELBOW", "r_elbow": "RIGHT_ELBOW",
         "l_wrist": "LEFT_WRIST", "r_wrist": "RIGHT_WRIST", "l_hip": "LEFT_HIP", "r_hip": "RIGHT_HIP", "l_knee": "LEFT_KNEE",
         "r_knee": "RIGHT_KNEE", "l_ankle": "LEFT_ANKLE", "r_ankle": "RIGHT_ANKLE"}
check("our joint numbers equal MediaPipe's PoseLandmark values", all(getattr(PoseLandmark, v).value == J[k] for k, v in names.items()))
print("failures:", bad)

Cell 6: from angles to football

Open knee_angles.csv and plot the right knee angle against time. A kick shows up as the knee folding (angle falls) and snapping straight (angle rises). Use ankle_speed_in_legs_per_second to mark the fastest moment.

Questions: why does a missing frame produce an empty cell instead of 0? What is wrong with deciding that someone is "tired" because their knee angle changed? Where would this fail on a real pitch (hint: lesson 43)?

If it goes wrong

ErrorCauseFix
AttributeError: module 'mediapipe' has no attribute 'solutions'Older tutorial code; the old interface was removedUse mp.tasks.vision.PoseLandmarker as in Cell 4
FileNotFoundError: Model file not foundThe .task file is missingDownload it and put it next to the script
Angles look wrongComputed from the 0-to-1 numbersMultiply x by width and y by height first (point())
Angle jumps aroundPose jitterSmooth (alpha 0.3 to 0.5); ignore low visibility
No person foundToo small, too dark, or too many peopleCrop the player first; use a larger frame
Timestamp error in video modeTimestamps must increase every frameUse int(i * 1000 / fps) with a running frame counter
SlowHeavy model or large framesUse the lite model, smaller frames, or every second frame
Kick detector fires on runningThreshold too lowRaise it, or require a knee-angle pattern too

Try it

  • Pose on cropped players from the lesson 26 tracker
  • Add a hip-height jump detector
  • Compare lite and full models on the same clip

Check what you learnt

  1. What is a landmark's visibility value?

  2. Why work out angles in pixels, not in MediaPipe's 0-to-1 fractions?

  3. Why divide kick speed by leg length?

  4. Why not run pose on the whole pitch at once?

  5. Why is body data personal?

Conclusion

Pose estimation turns a person into landmarks: joints with x, y and visibility. Angles between joints show what the body is doing, simple rules turn angles into events, and smoothing stops jitter from causing false ones. Crop each player first, normalise by body size, and treat body data as personal.

What next?

Next: Speed & distance estimation. You'll measure how far and how fast players ran.