Skip to content

Pose detection: 33 points and a referee

Exercise pose-detection-annex.pdf · PDF

Your hands are covered in flour and the recipe on the laptop has just scrolled off the screen. You raise your right hand. The next step appears. You keep your hand up and count to three. How many steps should it advance? One, of course. Getting that "one" right is the whole trick of this page.

In 2010, doing this took a Kinect, a special depth camera that sold 8 million units in 60 days. Now you will do it with a webcam. In the last camera demo the model told you a word: Thumb_Up. Here it tells you nothing but 33 points. The meaning is all yours.

What you will learn

  • What a pose landmarker returns: 33 landmarks per person, each with x, y, z and visibility
  • How to read landmark coordinates: 0 to 1, origin at the top-left corner, and y grows downwards
  • How to write a pose as a rule between landmarks
  • The difference between a state and an event, and how to code an edge trigger
  • How to add a pose page to your app that runs two of its actions, with the skeleton drawn over the video
  • The trap: if your condition is true, running the action means running it 30 times per second

The map of the body

A landmark is a numbered point the model finds on the body. A pose landmarker is the model that finds them: MediaPipe Pose Landmarker returns 33 landmarks for each person, on every frame.

flowchart TB
    N["<b>0</b> nose"] --> LS["<b>11</b> left shoulder"]
    N --> RS["<b>12</b> right shoulder"]
    LS --> LE["<b>13</b> left elbow"] --> LW["<b>15</b> left wrist"]
    RS --> RE["<b>14</b> right elbow"] --> RW["<b>16</b> right wrist"]
    LS --> LH["<b>23</b> left hip"] --> LK["<b>25</b> left knee"] --> LA["<b>27</b> left ankle"]
    RS --> RH["<b>24</b> right hip"] --> RK["<b>26</b> right knee"] --> RA["<b>28</b> right ankle"]

The full list:

Index Landmark Index Landmark
0 nose 17 – 22 hands: pinky, index, thumb (left, right)
1 – 6 eyes: left inner, left, left outer, right inner, right, right outer 23, 24 hips (left, right)
7, 8 ears (left, right) 25, 26 knees
9, 10 mouth (left, right) 27, 28 ankles
11, 12 shoulders (left, right) 29, 30 heels
13, 14 elbows 31, 32 foot index (toes)
15, 16 wrists

From 7 on, odd is left and even is right. And "left" is the person's own left.

Careful: left is not the left of the screen

CameraStage shows the video mirrored, using CSS. The model still receives the original picture, so landmark 16 is your right wrist, even if it appears on the side of the screen you did not expect. Trust the numbers, not the side.

Reading the coordinates

Draw a rectangle and mark the point (0, 0). If you pointed at the bottom-left corner, as in maths class, you have just found the most common bug on this page. On a screen, the origin is at the top-left corner.

Value Meaning
x 0 = left edge of the camera picture, 1 = right edge
y 0 = top edge, 1 = bottom edge. Higher in the picture means a smaller y
z Depth, with the midpoint of the hips as the origin. A smaller value means closer to the camera. You do not need it here
visibility 0 to 1: how likely it is that the point is visible in the picture, and not hidden

So if your nose is at y = 0.25 and your right wrist at y = 0.1, your hand is above your nose: 0.1 is nearer the top.

The numbers are fractions of the picture, not pixels. A value of 0.5 is the middle, whatever the size of the camera image. MediaPipe also returns worldLandmarks, in metres, with the origin at the hips. You will not need them for this page.

Careful: the model does not recognise 'hand raised'

There is no list of poses inside. It gives you 33 points, about 30 times per second, even when you are doing nothing. You write what a pose is, as a comparison between numbers.

A pose is a rule

Write the pose in words, then in code. "The right hand is raised" means "the right wrist is above the right shoulder":

Pose Rule
Right hand raised pose[16].y < pose[12].y
Left hand raised pose[15].y < pose[11].y
Squat pose[23].y > pose[25].y && pose[24].y > pose[26].y
Arms wide open Math.abs(pose[15].x - pose[16].x) > 2 * Math.abs(pose[11].x - pose[12].x)
Nobody in the picture result.landmarks.length === 0

Look at the squat: the hips are lower than the knees, so their y is bigger.

And use names, not magic numbers. RIGHT_WRIST tells the next reader what 16 never will.

The referee

Think of a football referee. The referee watches the ball all the time: on every frame. While the ball is out, it is out for several seconds. That is a state. But the whistle sounds once, at the moment the ball crosses the line. That is an event.

A state lasts: the hand is up. An event happens once: the hand went up.

A referee who whistled for as long as the ball was out would be unbearable. So would your app: the rule is true on 30 frames per second, so if (handUp) box.next() jumps 30 notes per second.

The referee needs a memory: what was true on the previous frame?

const RIGHT_SHOULDER = 12, RIGHT_WRIST = 16; // names, not magic numbers
let rightUp = false; // memory: was the hand up in the previous frame?

// ...inside frame():
const rightNow = pose[RIGHT_WRIST].y < pose[RIGHT_SHOULDER].y; // above = smaller y
if (rightNow && !rightUp) box.next(); // the whistle: only when it BECOMES true
rightUp = rightNow; // remember for the next frame
Frame rightNow rightUp (before) Fires?
1. Hand down false false No
2. Hand goes up true false Yes: the whistle
3. Hand still up true true No
4. Hand still up true true No
5. Hand down false true No
6. Hand up again true false Yes

An edge trigger fires on the change from false to true, the "edge" between the two, and not while the condition stays true.

Why is rightUp a plain let and not $state? Nothing on the screen reads it. It is the referee's memory, not something to paint. It would still work with $state, but $state is for what the screen shows.

Your pose page

You need the camera demo of the first page working in your project, and the box and NoteList from Voice commands, or functions of your own app. Before you write code, write your two poses as a table: rule, action, feedback. The actions must be functions your app already has.

1. Add the task to vision.ts. It is the same pattern with another task and another model (5.8 MB). Add PoseLandmarker to the import at the top.

import { FilesetResolver, GestureRecognizer, PoseLandmarker } from '@mediapipe/tasks-vision';

export async function createPoseLandmarker() {
    return PoseLandmarker.createFromOptions(await vision(), {
        baseOptions: {
            modelAssetPath: `${MODELS}/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task`,
            delegate: 'GPU'
        },
        runningMode: 'VIDEO',
        numPoses: 1
    });
}

2. Create the page, src/routes/pose/+page.svelte. Each rule gets its own edge trigger, with its own memory.

<script lang="ts">
    import { DrawingUtils, PoseLandmarker } from '@mediapipe/tasks-vision';
    import CameraStage from '#lib/components/CameraStage.svelte';
    import NoteList from '#lib/components/NoteList.svelte';
    import { box } from '#lib/notes.svelte.js';
    import { createPoseLandmarker } from '#lib/nui/vision.js';

    // Indexes of the 33 pose landmarks we need (the person's own left and right).
    const LEFT_SHOULDER = 11;
    const RIGHT_SHOULDER = 12;
    const LEFT_WRIST = 15;
    const RIGHT_WRIST = 16;

    let landmarker: PoseLandmarker;
    let seen = $state(false);
    let rightUp = false; // plain variables: they remember the previous frame
    let leftUp = false;

    async function load() {
        landmarker = await createPoseLandmarker();
    }

    function frame(video: HTMLVideoElement, now: number, ctx: CanvasRenderingContext2D) {
        const result = landmarker.detectForVideo(video, now);
        const pose = result.landmarks[0]; // first person: 33 points, x and y from 0 to 1
        seen = pose !== undefined;
        if (!pose) return; // nobody in the picture

        // The skeleton, on the canvas that CameraStage gives you.
        const draw = new DrawingUtils(ctx);
        draw.drawConnectors(pose, PoseLandmarker.POSE_CONNECTIONS, { color: '#ffffff', lineWidth: 3 });
        draw.drawLandmarks(pose, { color: '#d62828', radius: 4 });

        // y grows downwards: "above" means a SMALLER y.
        const rightNow = pose[RIGHT_WRIST].y < pose[RIGHT_SHOULDER].y;
        const leftNow = pose[LEFT_WRIST].y < pose[LEFT_SHOULDER].y;

        // Fire once, on the frame the hand goes up (not 30 times per second while it stays up).
        if (rightNow && !rightUp) box.next();
        if (leftNow && !leftUp) box.previous();
        rightUp = rightNow;
        leftUp = leftNow;
    }
</script>

<h1>Pose</h1>
<CameraStage onload={load} onframe={frame} onstop={() => (seen = false)} />
<p role="status">
    {seen ? 'Raise your right hand for the next note, your left hand for the previous one.' : 'Step back until your shoulders are in the picture.'}
</p>
<NoteList />

Open localhost:5173/pose, turn the camera on and step back. A white skeleton appears over you. Stand about two metres away, with the light in front of you: a window behind you turns you into a silhouette, and the landmarks jump.

If something goes wrong

What you see Why, and what to do
The action fires when the hand goes down The comparison is upside down: > instead of <. Up means a smaller y
The action fires 30 times There is no edge trigger, or rightUp = rightNow is inside the if. It must run on every frame
Both poses interfere One memory variable for two poses. Each rule needs its own: rightUp, leftUp
"It does not see me" You are 40 cm from the laptop, and your wrists leave the picture when raised. Step back, or use a rule that fits a seated person: hands above the nose, landmark 0
Left and right look swapped The video is mirrored. Landmark 16 is still your right wrist
You have no webcam Share a neighbour's for testing. You can reason the rules on paper from the landmark table

Want more? Build a squat counter that counts once per squat. Or ignore points the model is not sure about, with pose[16].visibility > 0.5. And if the classroom PCs are fast, the full model (9.4 MB, replace pose_landmarker_lite with pose_landmarker_full twice in the URL) is steadier.

The buttons stay

A classmate has a broken arm in a cast. Can they still go to the next note in your app? They must: the Next and Previous buttons stay on the page. A pose is a second way to do something, never the only one. Standing two metres from a camera with both arms free is a lot to ask of anyone. The next page gives this rule its official name.

Pose detection does not identify people

The model finds joints. It does not know who you are, and the same 33 points come out for anybody. That does not make the numbers meaningless for privacy. As soon as your app stores them next to a name, for example a patient's exercises in a physiotherapy app, they are personal data about that person, and the health context makes them more sensitive still. The safest design is the one here: use the points to make a decision on this frame, then forget them.

Key takeaways

  • A pose landmarker returns 33 landmarks per person: shoulders 11, 12, wrists 15, 16, hips 23, 24. Odd is left and even is right from 7 on, and left is the person's left.
  • Coordinates go from 0 to 1 with the origin at the top-left, so up means a smaller y. visibility says how likely it is that the point is in view.
  • There is no list of poses. A pose is a rule: a comparison you write between landmarks.
  • A state lasts and an event happens once. An edge trigger fires only on the change from false to true, using a memory of the previous frame.
  • Memory between frames is a plain variable. $state is for what the screen shows.
  • A pose is a second way in. The buttons stay.

Quick check

1. What does MediaPipe Pose Landmarker give your code for each person it finds?
  • The name of the pose, chosen from a closed list
  • 33 numbered points, each with coordinates and a visibility
  • A photo of the person cut out from the background
  • The identity of the person, with a confidence score

It returns 33 landmarks, each with x, y, z and visibility. There is no list of poses and no identity: a pose is a rule you write as a comparison between these numbers.

2. The left wrist has y = 0.15 and the left shoulder has y = 0.5. Where is the wrist?
  • Below the shoulder, because 0.15 is the smaller number
  • At the same height, because both values are under 1
  • Above the shoulder, because y grows downwards from the top
  • It cannot be told without knowing the size of the video

The origin is at the top-left corner and y grows downwards, so a smaller y is higher in the picture. The values are fractions of the picture, so you do not need its size to compare them.

3. Which condition means “the left hand is raised”?
  • pose[16].y < pose[12].y
  • pose[15].y > pose[11].y
  • pose[16].y > pose[12].y
  • pose[15].y < pose[11].y

Left is odd: wrist 15 and shoulder 11. Above means a smaller y, so the comparison is <. Landmarks 16 and 12 are the right side.

4. A page runs if (handUp) box.next(); on every frame. What happens when the user holds a hand up for one second?
  • The app advances about 30 notes, one on each frame
  • The app advances one note, because the hand went up once
  • The app advances nothing until the hand comes down again
  • The model stops, because it cannot run the action and detect

handUp is a state: it stays true on every frame. To react to the event, you need an edge trigger, which fires only when the value changes from false to true.

5. Why must rightUp = rightNow; run outside the if?
  • Because Svelte only allows assignments at the top of a function
  • Because the model needs that value to find the next frame
  • Because the memory has to be updated on every frame, up or down
  • Because rightUp must be $state to be read on the next frame

Inside the if, the memory would only change when the action fires. It would stay true after the hand comes down, and the next raise would never count. A plain variable is enough, because nothing on the screen reads it.

6. On your screen, a raised hand appears on the left of the mirrored video, and the app reports landmark 16. Which hand is it?
  • The left hand of the person, as it appears on the left
  • The right hand of the person, as landmark 16 says
  • It depends on the size of the browser window
  • It cannot be told, because mirroring reverses the numbers

Landmark 16 is the person's own right wrist. Mirroring is done with CSS on what you see, and the model receives the original picture. So trust the numbers, not the side of the screen.

7. A presentation app moves to the next slide when the speaker raises both hands above their head. Pick the two things it needs.
  • An edge trigger, so that holding the pose advances only one slide
  • A Next button, so that the speaker can also advance without the pose
  • A $state variable to remember the previous frame between frames
  • The z coordinate of each wrist, to know that the hands are up

Holding a pose is a state, so the slide change needs an edge trigger. A pose is never the only way in, so the button stays. The memory is a plain variable, and comparing y values is enough to know the hands are up.

You can now read a whole body. Next you zoom in on hands and faces, and you stop a gesture from firing when nobody meant it: Gestures, faces and AR.