Skip to content

Gestures, faces and AR: the traffic officer

Exercise gestures-face-ar-annex.pdf · PDF

King Midas asked for a gift: everything he touched would turn to gold. He touched his food. He touched his daughter. A gift you cannot switch off is a curse. Now open the first camera demo and talk to a friend while moving your hands as you normally do. The label jumps: Open_Palm, None, Pointing_Up, Victory. If each of those words ran a command, how many notes have you just deleted?

A camera never stops looking. The hard part of a gesture interface is not recognising the gesture. It is knowing when you did not mean it. This page solves that, then puts a label on your face, and ends with the two questions every camera app must answer: who is watching, and what if I cannot wave?

What you will learn

  • What MediaPipe returns for a hand: 21 landmarks and one of 7 gestures, with a score
  • How to defuse the Midas touch with a minimum score, a hold time and one action per hold
  • The difference between face detection and face recognition, and why you only do the first
  • What augmented reality is, and how to place HTML over a mirrored video with the right formula
  • Exactly where WebXR works, and how to check before you offer it
  • How to audit your own NUI for privacy and accessibility

The officer's signals

In 1990, Robert Jacob gave this problem its name, while studying interfaces controlled with the eyes: the Midas touch problem. Everything you do becomes a command.

Think of a traffic police officer at a crossroads. The officer uses few signals, big ones, different from each other, and holds each one until the drivers react. Scratching an ear is not a signal. And the traffic lights are still there: the officer is a second way to direct traffic, not the only one. That is your whole design brief.

MediaPipe Gesture Recognizer returns, for each hand, 21 landmarks and a gesture from a closed list, each with a score from 0 to 1.

The 21 landmarks number the hand from the wrist outwards:

flowchart LR
    W["<b>0</b><br>wrist"] --> T["<b>1 – 4</b><br>thumb<br>(4 = tip)"]
    W --> I["<b>5 – 8</b><br>index<br>(8 = tip)"]
    W --> M["<b>9 – 12</b><br>middle<br>(12 = tip)"]
    W --> R["<b>13 – 16</b><br>ring<br>(16 = tip)"]
    W --> P["<b>17 – 20</b><br>pinky<br>(20 = tip)"]

These are the signals the model knows:

Name The hand
Closed_Fist A closed fist
Open_Palm An open palm
Pointing_Up One finger pointing up
Thumb_Up Thumbs up
Thumb_Down Thumbs down
Victory The V sign
ILoveYou The "I love you" sign
None A hand, but no known gesture

Seven gestures, plus None. If there is no hand at all, the list is simply empty. Can you use "wave goodbye"? No: it is not on the list, and it is a movement, not a hand shape. Design with the signals the officer knows.

Hold to confirm

Three protections stand between the camera and your notes:

Protection What it stops In NoteBox
Minimum score The model's guesses MIN_SCORE = 0.6
Hold time Hands passing by HOLD_MS = 800
One action per hold A held fist deleting every note fired

Here is the referee from pose detection, with a stopwatch:

const vocabulary: Record<string, { label: string; run: () => void }> = {
    Thumb_Up: { label: 'Pin', run: () => box.togglePin() },
    Victory: { label: 'Next note', run: () => box.next() },
    Closed_Fist: { label: 'Delete', run: () => box.remove() },
    Open_Palm: { label: 'Undo', run: () => box.undo() }
};
const HOLD_MS = 800, MIN_SCORE = 0.6;

let candidate = 'None', since = 0, fired = false; // memory between frames
let label = $state(''), progress = $state(0); // what the screen shows

function frame(video: HTMLVideoElement, now: number) {
    const best = recognizer.recognizeForVideo(video, now).gestures[0]?.[0];
    const name = best && best.score >= MIN_SCORE ? best.categoryName : 'None';

    if (name !== candidate) { candidate = name; since = now; fired = false; } // new gesture: restart

    const action = vocabulary[candidate];
    label = action?.label ?? '';
    progress = action ? Math.min(1, (now - since) / HOLD_MS) : 0;

    if (action && progress === 1 && !fired) { action.run(); fired = true; } // once per hold
}

Read it slowly:

  • Why are only label and progress $state? Because the user must see that a gesture is being held, and for how long. That is the feedback. The rest is the referee's memory.
  • Hold a fist for five seconds. How many notes are deleted? One. To delete another, you have to lower your hand and start again.
  • How do you get the note back? Open palm, or the Undo button: forgiveness again.

A longer wait might sound like a slower app. It is not: with a visible progress bar, 800 ms feels deliberate.

Write your vocabulary

Before coding, choose 2 to 4 gestures for actions of your own app, and your own HOLD_MS and MIN_SCORE:

Gesture Action Feedback
Thumb_Up Pin or unpin Label "Pin" + progress bar. The row shows "(pinned)"
Victory Next note Label "Next note" + progress bar. The selected row changes
Closed_Fist Delete Label "Delete" + progress bar, then "‹title› deleted" with Undo
Open_Palm Undo Label "Undo" + progress bar. The note is back
None, or score below 0.6 Nothing No label. "Show a gesture to the camera."

The rules:

  • A gesture that looks like its meaning is easier to remember: thumb up, "I like it".
  • Never give a destructive action to a gesture people make by accident. Open_Palm is every hand at rest.
  • The more destructive the action, the longer the hold, and there must always be a way to undo. NoteBox keeps one hold time to stay short, and relies on Undo. Your app can do better.
  • Every action is also a button.

Your gesture page

This is src/routes/gestures/+page.svelte. You already have CameraStage and createGestureRecognizer from the first demo, and box and NoteList from Voice commands. Anything you put inside <CameraStage>...</CameraStage> is rendered on top of the video, through its children snippet.

<script lang="ts">
    import type { GestureRecognizer } 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 { createGestureRecognizer } from '#lib/nui/vision.js';

    const vocabulary: Record<string, { label: string; run: () => void }> = {
        Thumb_Up: { label: 'Pin', run: () => box.togglePin() },
        Victory: { label: 'Next note', run: () => box.next() },
        Closed_Fist: { label: 'Delete', run: () => box.remove() },
        Open_Palm: { label: 'Undo', run: () => box.undo() }
    };
    const HOLD_MS = 800, MIN_SCORE = 0.6;

    let recognizer: GestureRecognizer;
    let candidate = 'None', since = 0, fired = false; // memory between frames
    let label = $state(''), progress = $state(0); // what the screen shows

    async function load() {
        recognizer = await createGestureRecognizer();
    }

    function frame(video: HTMLVideoElement, now: number) {
        const best = recognizer.recognizeForVideo(video, now).gestures[0]?.[0];
        const name = best && best.score >= MIN_SCORE ? best.categoryName : 'None';

        if (name !== candidate) { candidate = name; since = now; fired = false; } // new gesture: restart

        const action = vocabulary[candidate];
        label = action?.label ?? '';
        progress = action ? Math.min(1, (now - since) / HOLD_MS) : 0;

        if (action && progress === 1 && !fired) { action.run(); fired = true; } // once per hold
    }

    function reset() {
        candidate = 'None';
        label = '';
        progress = 0;
    }
</script>

<h1>Gestures</h1>
<CameraStage onload={load} onframe={frame} onstop={reset}>
    {#if label}
        <div class="hud">
            <span>{label}</span>
            <progress value={progress} max="1" aria-label="Hold to confirm"></progress>
        </div>
    {/if}
</CameraStage>
<p role="status">{label ? `Hold to ${label.toLowerCase()}` : 'Show a gesture to the camera.'}</p>
<NoteList />

<style>
    .hud {
        position: absolute;
        left: 0.75rem;
        bottom: 0.75rem;
        display: grid;
        gap: 0.25rem;
        padding: 0.4rem 0.6rem;
        background: #fff;
        border: 3px solid #111;
    }
</style>
What you see Why, and what to do
The action runs many times fired is never set, or it is reset on every frame instead of only when the gesture changes
Nothing ever fires You compare progress > 1. It stops at exactly 1, because of Math.min
The hold never completes The gesture flickers to None for one frame and restarts the clock. Lower MIN_SCORE a little, or light your hand better. Do not remove the hold
The label appears mirrored You drew it on the canvas. Text goes in HTML. The canvas is for landmarks
The progress bar stays after the camera is off Use onstop to reset label and progress
You have no webcam Move the hold logic into a function handleGesture(name, now) and call it from a temporary button every 100 ms

Want more? Add ctx to frame and draw the hand with new DrawingUtils(ctx).drawConnectors(hand, GestureRecognizer.HAND_CONNECTIONS), where hand = result.landmarks[0]. Import both as values, without type.

Detect is not recognise

These three look similar. They are very different things, and one of them comes with legal weight:

It answers Tool
Face detection "Is there a face? Where?" A box and 6 key points MediaPipe FaceDetector (230 KB). This page
Face landmarks "Where are the eyes, the lips, the eyebrows?" 478 points, plus scores for expressions such as a smile or a blink MediaPipe FaceLandmarker (3.8 MB)
Face recognition "Whose face is it?" Not in MediaPipe. Not in this course

Face detection only finds a face-shaped region. The same box would appear for anyone in the room, so it knows nothing about who you are. If your app needs to know who, it asks for a login, not for a face.

Biometric data, in the GDPR (article 4.14), is personal data from a specific technical processing of a person's physical, physiological or behavioural characteristics, which allows or confirms their unique identification. Facial images and fingerprints are its examples.

What the law actually says

GDPR article 9 treats biometric data "for the purpose of uniquely identifying a natural person" as a special category: processing it is prohibited, unless one of a short list of exceptions applies, such as explicit consent.

Be exact about what that does and does not cover:

  • A photo of a face is not automatically biometric data. Recital 51 says photographs count only when processed with specific technical means that identify or authenticate a person. So face recognition falls under article 9, and the box from face detection, used as on this page, is not trying to identify anyone.
  • That does not make camera images free to use. A picture of a recognisable person is still personal data the moment you store or send it, and the GDPR applies to it. Our apps stay out of that problem by design: they never store or send a frame.
  • Reading emotions has its own rule. Using a smile as a button is a gesture. Using it to conclude that a student is bored is different: the EU AI Act (article 5.1.f) has prohibited AI systems that infer emotions in workplaces and education institutions since 2 February 2025, except for medical or safety reasons.

This is what a developer should know, not legal advice. In a real product, ask whoever handles data protection.

Glue on the real world

Augmented reality (AR) adds virtual elements to the real world, live, and places them where they belong.

In 1997, Ronald Azuma defined it with three conditions. It combines real and virtual. It is interactive in real time. And the virtual part is registered with the real world: it stays glued to its place.

A film with special effects fails the second condition. A logo in the corner of a video fails the third. A label that floats above your head and follows you passes all three.

The formula

The detector gives you a box in video pixels: originX, originY, width, height. (Careful: the 6 key points of a detection come normalised from 0 to 1, as landmarks do. The box does not.) Your sticker is an HTML element with position: absolute, sized in percentages. Say the video is 640 wide and the face is at originX = 200, with width = 160:

CSS Formula With these numbers
width width / videoWidth × 100 160 / 640 × 100 = 25 %
left 100 − (originX + width) / videoWidth × 100 100 − 360 / 640 × 100 = 43.75 %
top originY / videoHeight × 100 Not mirrored, so no subtraction

Why 100 −? The video is shown mirrored. A face on the right of the camera picture is on the left of the screen, and the box's right edge (originX + width) becomes the sticker's left edge.

Why percentages? The video can be any size on the screen, and percentages follow it. In pixels it would work at one window size and break at another.

A sticker on your face

Add the detector to vision.ts, and FaceDetector to the import at the top:

export async function createFaceDetector() {
    return FaceDetector.createFromOptions(await vision(), {
        baseOptions: {
            modelAssetPath: `${MODELS}/face_detector/blaze_face_short_range/float16/latest/blaze_face_short_range.tflite`,
            delegate: 'GPU'
        },
        runningMode: 'VIDEO'
    });
}

Then the page, src/routes/ar/+page.svelte. The sticker shows the current note. And a face triggers an action: with two faces in the picture, somebody may be reading over your shoulder, so the note blurs.

<script lang="ts">
    import type { FaceDetector } from '@mediapipe/tasks-vision';
    import CameraStage from '#lib/components/CameraStage.svelte';
    import { box } from '#lib/notes.svelte.js';
    import { createFaceDetector } from '#lib/nui/vision.js';

    type Sticker = { left: number; top: number; width: number }; // percentages of the video

    let detector: FaceDetector;
    let stickers = $state<Sticker[]>([]);

    async function load() {
        detector = await createFaceDetector();
    }

    function frame(video: HTMLVideoElement, now: number) {
        const result = detector.detectForVideo(video, now);
        stickers = result.detections.flatMap((detection) => {
            const face = detection.boundingBox; // video pixels, origin top-left
            if (!face) return [];
            const width = (face.width / video.videoWidth) * 100;
            const left = (face.originX / video.videoWidth) * 100;
            return { left: 100 - left - width, top: (face.originY / video.videoHeight) * 100, width };
        });
    }
</script>

<h1>AR</h1>
<CameraStage onload={load} onframe={frame} onstop={() => (stickers = [])}>
    {#each stickers as sticker, i (i)}
        <div class="sticker" style:left="{sticker.left}%" style:top="{sticker.top}%" style:width="{sticker.width}%">
            {box.current?.title ?? 'NoteBox'}
        </div>
    {/each}
</CameraStage>

<article class:blurred={stickers.length > 1}>
    <h2>{box.current?.title}</h2>
    <p>{box.current?.body}</p>
</article>
{#if stickers.length > 1}
    <p role="status">Two faces detected: the note is hidden.</p>
{/if}

<style>
    .sticker {
        position: absolute;
        transform: translateY(-110%);
        box-sizing: border-box;
        padding: 0.2rem 0.4rem;
        background: #ffd60a;
        border: 3px solid #111;
        text-align: center;
    }
    .blurred {
        filter: blur(8px);
    }
</style>
What you see Why, and what to do
The sticker moves the opposite way You forgot the 100 −. Step to the right and watch
The sticker is on your forehead top is the top of the box. translateY(-110%) lifts the sticker by its own height
It breaks when you resize the window You used pixels instead of percentages
The stickers never update stickers is not $state, so the {#each} never repaints

Treat the blur as a courtesy, not as security. A face on a poster may count as a second face, and the short-range model is made for selfie distance, so somebody far behind you may not count at all.

WebXR, the real thing

Your sticker is glued in 2D, on a picture. To put a sofa on your real floor, at real size, the device has to understand the room in 3D.

The WebXR Device API is the web standard for AR and VR. The browser gives the page the position of the device and the surfaces it sees. An AR session has the mode immersive-ar.

Where does it work? (Checked against MDN's compatibility data, November 2026. MDN marks WebXR as experimental, with limited availability.)

Browser and device WebXR
Chrome, Edge on a laptop The API exists (since version 79), but there is no AR hardware: immersive-ar is not supported
Chrome on an Android phone with ARCore immersive-ar works
Meta Quest Browser (headset) immersive-vr and immersive-ar
Safari on iPhone, iPad, Mac No WebXR
Safari on Apple Vision Pro WebXR for VR sessions (immersive-vr), not for immersive-ar
Firefox No WebXR

WebXR also needs a secure context, like the camera. So you ask before you offer. One call tells you:

// npm install -D @types/webxr   +   /// <reference types="webxr" />  in src/app.d.ts
const supported = await (navigator.xr?.isSessionSupported('immersive-ar') ?? Promise.resolve(false)).catch(() => false);

navigator.xr does not exist where there is no WebXR, and the promise can be rejected, for example when a permissions policy blocks it. In both cases, the answer for your interface is the same: no.

If it is false, do not show an "Enter AR" button that does nothing. Show the video overlay instead. That is your fallback. Starting a real AR session also needs a 3D engine such as three.js or A-Frame, which is out of scope for this course. And no, AR does not need a headset: a phone is enough for WebXR, and a webcam is enough for an overlay.

Two questions

Every camera or microphone app must answer two questions. Use this list to audit your own app, then swap with a classmate and audit theirs:

Who is watching? (privacy) What if I cannot? (accessibility)
Camera and microphone are off until a button is pressed Every voice, pose and gesture action also exists as a button
While on, the page says so and offers "off" Every button works with the keyboard
Off really stops the tracks: the camera light goes out Status changes are announced (role="status")
The page says where the data goes (video: this computer; speech in Chrome: a server) No state is shown by colour alone
Nothing is stored or sent. The app detects, never identifies A blocked permission or a missing device says what happened and what to do

Hiding the video does not turn the camera off. Only stopping every track does. And the promises on your page have to be true: if you say "never uploaded", your code must never upload.

These have official names in WCAG 2.2:

Criterion Level In plain words
2.5.4 Motion Actuation A What can be operated by moving can also be operated by ordinary controls, and responding to the motion can be switched off to prevent accidents. W3C's explanation explicitly includes gestures made to a camera
2.1.1 Keyboard A Everything works from a keyboard
4.1.3 Status Messages AA Status changes reach a screen reader without moving the focus

So 2.5.4 asks for two things, and the second is easy to forget. The camera being off by default, behind a button, is how your app meets it: turn the camera off, and the motion is ignored.

Careful: accessibility is not about a few users, and not about a demo

A broken arm, a noisy room, a library, a laptop with tape over the camera, a browser with no speech recognition. Everyone has been in one of those situations recently. The buttons are for everybody, in the many moments when a gesture is the wrong tool.

Key takeaways

  • Gesture Recognizer returns 21 landmarks and one of 7 gestures (or None) with a score. Few, big, different signals: the traffic officer.
  • The Midas touch problem is that everything you do becomes a command. Defuse it with a minimum score, a hold time with visible progress, and one action per hold.
  • Face detection says where a face is; face recognition says whose. Biometric data used to identify someone is a special category under GDPR article 9. Our apps detect, never identify, and never store or send a frame.
  • Augmented reality combines real and virtual, in real time, registered to the world. For a mirrored video: left = 100 − (originX + width) / videoWidth × 100, in percentages.
  • WebXR immersive-ar needs AR hardware: an Android phone with ARCore or a Meta Quest. Not laptops, not Firefox, not Safari on iPhone. Ask with isSessionSupported, and keep a fallback.
  • Audit with two questions: who is watching? and what if I cannot? WCAG 2.5.4 Motion Actuation requires a non-motion way in, and a way to switch the motion off.

Quick check

1. What does MediaPipe Gesture Recognizer return for each hand?
  • 33 landmarks and any gesture you decide to train it on
  • 478 landmarks and a score for every facial expression
  • 21 landmarks and a gesture from a closed list, with a score
  • 6 key points and the name of the person's dominant hand

A hand has 21 landmarks, and the canned model knows 7 gestures, plus None for a hand with no known gesture. 33 is the body, and 478 is the face.

2. A gesture page uses HOLD_MS, MIN_SCORE and a fired flag. You hold a closed fist for five seconds. Why is only one note deleted?
  • fired stays true until the gesture changes, so the action runs once
  • HOLD_MS is 800, so the model stops looking after 800 ms
  • MIN_SCORE blocks every result that comes after the first one
  • progress is $state, so Svelte only runs the action once

The hold time decides when the action may run, and fired makes sure it runs only once per hold. fired returns to false only when the gesture changes, which is why you must lower your hand to repeat it.

3. Which of these is an accurate statement about faces and the GDPR?
  • Every photo of a face is biometric data under article 9
  • Face detection identifies people, so it always needs explicit consent
  • A camera image stops being personal data once it runs in a browser
  • Biometric data used to uniquely identify a person is a special category

Article 9 covers biometric data used for uniquely identifying someone. Recital 51 says photographs are not automatically in that category. A detector finds where a face is, not whose it is. But a stored image of a recognisable person is still personal data.

4. A video is 800 pixels wide and shown mirrored. A face has originX = 100 and width = 100. Which left value places the sticker correctly?
  • 12.5 %
  • 25 %
  • 75 %
  • 87.5 %

100 − (100 + 100) / 800 × 100 = 100 − 25 = 75 %. The video is mirrored, so the right edge of the box in the camera picture becomes the left edge of the sticker on the screen.

5. Which of these meets all three of Azuma's conditions for augmented reality?
  • A film whose dragons were added months later by an effects studio
  • A TV channel logo that always stays in the corner of a live video
  • A virtual hat that follows your head in a live webcam picture
  • A 3D game world with no picture of the real world at all

AR combines real and virtual, is interactive in real time, and is registered with the real world. The film is not interactive, the logo is not glued to anything real, and the game has nothing real in it.

6. Your app runs on a laptop with Chrome. What does navigator.xr.isSessionSupported('immersive-ar') tell you there, and what should you do?
  • It gives true, because Chrome has WebXR, so show an Enter AR button
  • It gives false, as there is no AR hardware, so show the video overlay
  • It throws an error, as laptops have no navigator.xr, so reload the page
  • It gives true, but only with a webcam, so ask for the camera first

Chrome on a laptop has the WebXR API, but no hardware for immersive-ar. So ask first, never offer a button that does nothing, and use a fallback: the video overlay.

7. A smart kitchen display turns the page when you wave a recognised gesture at its camera. Which two features does WCAG 2.5.4 Motion Actuation ask for?
  • A way to turn the page with an ordinary control, such as a button
  • A way to switch off the response to motion, to prevent accidents
  • A contrast of at least 4.5:1 between the gesture hint and its background
  • A camera resolution high enough to recognise every user's hands

2.5.4 (level A) covers motion, including gestures to a camera: the same function must work from user interface components, and responding to motion must be able to be disabled. Contrast is a different criterion, and resolution is not a WCAG matter.

You have given your app senses and asked the two questions every sensor has to answer. In the next unit, the data comes back out, in a form people can read and print: Report structure.