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
labelandprogress$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_Palmis 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-arneeds AR hardware: an Android phone with ARCore or a Meta Quest. Not laptops, not Firefox, not Safari on iPhone. Ask withisSessionSupported, 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?
-
firedstays true until the gesture changes, so the action runs once -
HOLD_MSis 800, so the model stops looking after 800 ms -
MIN_SCOREblocks every result that comes after the first one -
progressis$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.