Natural user interfaces: the app gets senses¶
Exercise natural-user-interfaces-annex.pdf · PDF
Put both hands in your pockets. Could you still unlock your phone? Probably: it looks at your face. Could you set a timer? "Hey Siri" does that. Have you ever played a game with your whole body? No mouse, no keyboard, and the machine still understood you.
So far your apps have only felt one fingertip at a time: a click, a key. In this unit your app gets senses: an ear, a mouth and eyes. By the end of this page, a page of your own SvelteKit project will see your hand through the webcam, with about twenty lines of your code.
What you will learn
- What a natural user interface (NUI) is, and how it follows the CLI and the GUI
- How to sort any product into four families: voice, hands and face, whole body and augmented reality
- How to draw the pipeline sensor → model → rule → feedback, and which box is your own code
- Which machine learning tools a web interface can use, and what each one is for
- What "the model runs in the browser" really means for privacy, and what it costs
- How to run a webcam and MediaPipe demo with all the states of a camera
- The trap: "natural" does not mean you do not have to learn it
CLI, GUI, NUI¶
You met the first two in Apple's guidelines. With a command-line interface (CLI) you remember a command and type it. With a graphical user interface (GUI) you see the options and point at one. The third step removes the pointer too.
A natural user interface (NUI) is an interface you operate with what you already do as a person: speaking, moving your hands, moving your body, looking around.
It took decades to get from a lab to your pocket:
| Year | What happened |
|---|---|
| 1980 | "Put-That-There" (Richard Bolt, MIT). A man in a chair points at a wall-sized screen and says "put that... there". Voice and gesture together, in a room built for the purpose |
| 2007 | The first iPhone: pinch to zoom. A gesture on glass, no stylus |
| 2010 | Kinect for Xbox 360 (4 November): a depth camera that sees your whole body. 8 million sold in 60 days, a Guinness World Record |
| 2011 | Siri ships with the iPhone 4S (October): voice in millions of pockets |
| 2016 | Pokémon GO (July): augmented reality goes mainstream |
| 2019 | Google open-sources MediaPipe (June). Its later web versions bring hand and body tracking to a plain webcam, inside a browser tab |
Careful: natural does not mean you do not have to learn it
In 2010 the designer Don Norman wrote an article with a blunt title: Natural User Interfaces Are Not Natural. A menu shows you its options. A gesture is invisible. Nobody is born knowing that a fist means Delete. So every NUI needs a vocabulary the user can discover, and feedback that shows what the machine understood. You will write a vocabulary for every sense in this unit.
Four families¶
Every NUI product belongs to at least one of four families. Ask two questions: which sensor does it use, and what does the machine perceive?
| Family | Sensor | The machine perceives... | Example |
|---|---|---|---|
| Voice | Microphone (and speaker) | Words: speech to text, and the reverse, text to speech | "Set a timer for ten minutes" |
| Hands and face | Touch screen or camera | A gesture, or where a hand or a face is | Pinch to zoom, a thumbs-up to the webcam |
| Whole body | Camera | The posture: where the shoulders, elbows and knees are | Kinect, a squat counter |
| Augmented reality (AR) | Camera and motion sensors | Where the real world is, so virtual things can be placed on it | A sofa in your living room |
Some products sit in two families. An app that lets you try on glasses has to find your face (hands and face), then draws the glasses on it (AR). And some need no intelligence at all: a pinch is just two fingers on a touch screen.
Senses are not understanding¶
Your ear only catches air vibrating. A part of your brain turns that into words. Then you decide what to do about them. If a friend says "jump", your ear and your brain have worked perfectly, and you may still decide not to jump.
Every NUI has the same three parts, plus a fourth: it answers.
flowchart LR
S["<b>Sensor</b><br>webcam picture<br>(the eye)"] -->|"numbers"| M["<b>Model</b><br>Thumb_Up, 93 %<br>(the brain recognising)"]
M -->|"a label and a score"| R["<b>Rule</b><br>your code decides<br>(you deciding)"]
R -->|"what happened"| F["<b>Feedback</b><br>the note shows pinned<br>(you answering)"]
| Box | In the analogy | In NoteBox, the example app of this unit |
|---|---|---|
| Sensor | The ear, the eye | The webcam: a picture of 640 × 480 pixels, about 30 times per second. Only numbers |
| Model | The part of the brain that recognises | It answers: "Thumb_Up, 93 % sure" |
| Rule | You deciding | Your code: "if the thumb is held up for 800 ms, pin the note" |
| Feedback | You answering | A label over the video, and the note shows "(pinned)" |
A machine learning model is a program that nobody wrote rule by rule. It was trained with thousands of examples until it learnt to give the right answer by itself.
Careful: the AI does not understand what you want
The model gives a label and a score from 0 to 1, nothing more. It has never heard of your notes. A score of 0.93 means "probably", not "yes". Everything that makes it an interface (what the gesture means, how sure you need to be, what to show) is ordinary code, written by you.
The toolbox¶
You do not have to train anything. These tools already exist for web interfaces:
| Tool | Who | What it gives a web interface |
|---|---|---|
MediaPipe Tasks (@mediapipe/tasks-vision) |
Ready-made tasks: hand landmarks, gesture recognition, pose, face detection. The one this unit uses | |
| Web Speech API | Built into browsers | Speech to text and text to speech. Nothing to install. See Voice commands |
| WebXR Device API | Built into Chromium browsers | Real AR and VR sessions on phones and headsets. See Gestures, faces and AR |
| TensorFlow.js | A general library to run, or even train, models in JavaScript | |
| ONNX Runtime Web | Microsoft | Runs models saved in the ONNX format, whichever tool trained them |
| Transformers.js | Hugging Face | Ready models for text, speech and images from the Hugging Face hub, in the browser |
Careful with one common mix-up: MediaPipe is not "the AI of the browser". It is a library you install. What the browser itself gives you is the Web Speech API and WebXR.
Under the hood, the libraries lean on two engines of the browser: WebAssembly (a compact format that runs fast code on the processor) and WebGL or WebGPU (ways of using the graphics card).
Where does the picture go?¶
When you show your hand to a MediaPipe demo, where does the picture go? Most people guess "to Google". It does not.
MediaPipe runs in the browser. Your computer does the work, inside the tab, frame by frame. A lot of voice assistants do the opposite.
| In the browser (MediaPipe) | On a server (many voice assistants) | |
|---|---|---|
| Privacy | The library analyses the picture on your computer | The audio or picture travels to somebody else's computer |
| Speed | No round trip: answers in milliseconds | Depends on the connection |
| Cost | No server to pay for | Somebody pays for every request |
| Price to pay | The first download: about 12 MB of runtime, 8.4 MB for the gesture model. An old PC runs it slowly | The device can be weak, and the models can be far bigger |
That is why this approach suits sensitive images, and why it does not suit everything. The models in this unit weigh less than 10 MB. A large chatbot model weighs gigabytes.
What 'never uploaded' does and does not promise
Be exact about it, because your users cannot check it themselves.
- MediaPipe does not upload your frames. They are processed in the tab. But your page still downloads the runtime and the model from
cdn.jsdelivr.netandstorage.googleapis.com, so those servers see a request from your computer, as with any file on the web. If that matters, host both files yourself. - The promise is about your code, not about the library. The tab holds the picture, and any page can send it wherever it wants with one
fetch. "Never uploaded" is only true if you never write that line. - The camera light is not a privacy guarantee of your app. It tells you the camera is on. It cannot tell you what the page does with the picture.
Why your page might stutter
Google's guide warns that recognizeForVideo() is synchronous: it blocks the page while it thinks, and for a camera it recommends moving the work to a web worker (a background thread). We skip that here to keep the code short. If your interface stutters on an old PC, now you know why.
Your first demo¶
Now the eye opens. You need Chrome or Edge 139 or newer, a webcam and your own SvelteKit project, the one you built in Unit 3 and Unit 4.
1. Install the library, at an exact version.
Why exact? The library in node_modules and the WebAssembly runtime it downloads from the CDN must have the same version, or you get errors in the console. A plain npm install gives you whatever is newest, while the URL in vision.ts stays at 1.0.1. Never write @latest in that URL.
2. Add three ready-made files. They do the boring part: opening the camera, loading the model, keeping the camera's states. Read them at home: there is nothing here you have not seen.
src/lib/nui/camera.ts: open the webcam, name every failure
// Opening the webcam, and turning every way it can fail into one word the UI can show.
export type CameraProblem = 'unsupported' | 'denied' | 'no-camera' | 'busy' | 'unknown';
export async function openCamera(): Promise<MediaStream> {
// mediaDevices only exists in a secure context: https:// or http://localhost
if (!navigator.mediaDevices?.getUserMedia) throw new DOMException('No camera API', 'NotSupportedError');
return navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: 'user' },
audio: false
});
}
export function cameraProblem(error: unknown): CameraProblem {
const name = error instanceof DOMException ? error.name : '';
if (name === 'NotAllowedError' || name === 'SecurityError') return 'denied';
if (name === 'NotFoundError' || name === 'OverconstrainedError') return 'no-camera';
if (name === 'NotReadableError' || name === 'AbortError') return 'busy';
if (name === 'NotSupportedError') return 'unsupported';
return 'unknown';
}
export const cameraProblemText: Record<CameraProblem, string> = {
unsupported: 'This page cannot use the camera. Open it over https or on localhost.',
denied: 'Camera blocked. Allow it from the icon in the address bar, then press Try again.',
'no-camera': 'No camera found. Plug one in, or keep using the buttons.',
busy: 'The camera is in use by another app. Close it, then press Try again.',
unknown: 'The camera could not start. Keep using the buttons.'
};
src/lib/nui/vision.ts: load the runtime, create the task
// MediaPipe Tasks for web: load the WebAssembly runtime once, then create the task you need.
import { FilesetResolver, GestureRecognizer } from '@mediapipe/tasks-vision';
// Pin the same version as package.json: runtime and library must match.
const WASM = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm';
const MODELS = 'https://storage.googleapis.com/mediapipe-models';
let fileset: ReturnType<typeof FilesetResolver.forVisionTasks> | undefined;
const vision = () => (fileset ??= FilesetResolver.forVisionTasks(WASM));
export async function createGestureRecognizer() {
return GestureRecognizer.createFromOptions(await vision(), {
baseOptions: {
modelAssetPath: `${MODELS}/gesture_recognizer/gesture_recognizer/float16/latest/gesture_recognizer.task`,
delegate: 'GPU'
},
runningMode: 'VIDEO',
numHands: 1
});
}
src/lib/components/CameraStage.svelte: the camera component
<script lang="ts">
import type { Snippet } from 'svelte';
import { cameraProblem, cameraProblemText, openCamera, type CameraProblem } from '#lib/nui/camera.js';
type Status = 'off' | 'loading' | 'on' | CameraProblem;
type Props = {
/** Load the model here. Called once, the first time the camera is turned on. */
onload?: () => Promise<void>;
/** Called for every new video frame. `now` is a timestamp in milliseconds. */
onframe: (video: HTMLVideoElement, now: number, ctx: CanvasRenderingContext2D) => void;
/** Called when the camera stops, so the parent can reset what it shows. */
onstop?: () => void;
/** HTML drawn on top of the video (labels, AR stickers). */
children?: Snippet;
};
let { onload, onframe, onstop, children }: Props = $props();
let status = $state<Status>('off');
let video: HTMLVideoElement;
let canvas: HTMLCanvasElement;
let stream: MediaStream | null = null;
let frame = 0;
let loaded = false;
let lastTime = -1;
async function start() {
status = 'loading';
try {
stream = await openCamera(); // 1. permission prompt
if (!loaded) {
await onload?.(); // 2. download the model (a few MB, cached afterwards)
loaded = true;
}
video.srcObject = stream;
await video.play();
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
status = 'on';
frame = requestAnimationFrame(loop); // 3. one detection per frame
} catch (error) {
stop();
status = cameraProblem(error);
}
}
function loop() {
const ctx = canvas.getContext('2d');
// Only run the model when the camera has delivered a new frame.
if (ctx && video.currentTime !== lastTime) {
lastTime = video.currentTime;
ctx.clearRect(0, 0, canvas.width, canvas.height);
onframe(video, performance.now(), ctx);
}
frame = requestAnimationFrame(loop);
}
function stop() {
cancelAnimationFrame(frame);
stream?.getTracks().forEach((track) => track.stop()); // the camera light goes off
stream = null;
if (video) video.srcObject = null;
status = 'off';
onstop?.();
}
// Leaving the page must switch the camera off.
$effect(() => stop);
</script>
<section class="stage">
<div class="view" class:hidden={status !== 'on'}>
<!-- muted + playsinline: required for autoplay on phones -->
<video bind:this={video} muted playsinline aria-label="Your webcam, mirrored"></video>
<canvas bind:this={canvas} aria-hidden="true"></canvas>
{@render children?.()}
</div>
<div class="bar" role="status" aria-live="polite">
{#if status === 'off'}
<p>Camera off. The video is analysed on this computer and never uploaded.</p>
<button type="button" onclick={start}>Turn camera on</button>
{:else if status === 'loading'}
<p>Starting the camera and loading the model...</p>
{:else if status === 'on'}
<p><span class="dot" aria-hidden="true"></span> Camera on</p>
<button type="button" onclick={stop}>Turn camera off</button>
{:else}
<p class="problem">{cameraProblemText[status]}</p>
<button type="button" onclick={start}>Try again</button>
{/if}
</div>
</section>
<style>
.stage {
border: 3px solid #111;
max-width: 640px;
}
.view {
position: relative;
aspect-ratio: 4 / 3;
background: #111;
overflow: hidden;
}
.view.hidden {
display: none;
}
video,
canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* mirror, like a selfie camera */
}
.bar {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
padding: 0.6rem 0.8rem;
}
.bar p {
margin: 0;
}
.problem {
border-left: 6px solid #d62828;
padding-left: 0.5rem;
}
.dot {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
background: #d62828;
}
button {
font: inherit;
padding: 0.4rem 0.8rem;
border: 3px solid #111;
background: #fff;
cursor: pointer;
}
</style>
CameraStage is a component like the ones you built in Unit 4: props in, callbacks out. You give it two functions. onload runs once, to download the model. onframe runs for every new video frame.
3. Create the page. This is src/routes/demo/+page.svelte. Type it, do not paste it: you will write this loop again on every page of this unit.
<script lang="ts">
import type { GestureRecognizer } from '@mediapipe/tasks-vision';
import CameraStage from '#lib/components/CameraStage.svelte';
import { createGestureRecognizer } from '#lib/nui/vision.js';
let recognizer: GestureRecognizer;
let gesture = $state('None');
let score = $state(0);
async function load() {
// once: download the model
recognizer = await createGestureRecognizer();
}
function frame(video: HTMLVideoElement, now: number) {
// for every video frame
const result = recognizer.recognizeForVideo(video, now);
const best = result.gestures[0]?.[0]; // first hand, most likely gesture
gesture = best?.categoryName ?? 'None';
score = best?.score ?? 0;
}
</script>
<h1>First demo</h1>
<CameraStage onload={load} onframe={frame} onstop={() => (gesture = 'None')} />
<p>The model sees: <strong role="status">{gesture}</strong> ({Math.round(score * 100)} %)</p>
4. Run it. npm run dev, open localhost:5173/demo in Chrome or Edge, press Turn camera on, allow the camera, and show a thumbs-up, a fist, a V. The line under the video changes.
Find the four boxes of the pipeline in those lines:
| Box | Where it is |
|---|---|
| Sensor | Inside CameraStage |
| Model | recognizeForVideo(video, now). now is a timestamp in milliseconds: the model uses it to follow the hand between frames |
| Rule | There is none yet. We only show what the model says. Rules start in Pose detection |
| Feedback | Two $state variables and one line of HTML. They are $state because the screen must repaint when they change (Unit 3) |
Then press Turn camera off and look at the light next to your webcam. It goes out, because CameraStage calls track.stop() on every track of the stream. Hiding the video would not do that.
Why is only the gesture announced?
A live region like role="status" makes a screen reader announce every change. The score changes many times per second. Inside the region, it would make the reader talk over itself and become useless. Announce what the user needs to know, not everything that moves.
If something goes wrong¶
| What you see | Why, and what to do |
|---|---|
| "This page cannot use the camera" | You opened the page by IP address or over http:// from another machine. Cameras only work in a secure context: https:// or localhost |
| Nothing happens for ten seconds | The model is downloading. The status bar says so. The second time is much faster |
| Version errors in the console | package.json and the URL in vision.ts have different versions. Make them equal |
recognizer is undefined |
You put the logic at the top of the script instead of inside frame. Top-level code runs before the model is loaded, and also on the server, where there is no camera at all |
| WebGL errors in the console | Change delegate: 'GPU' to delegate: 'CPU' in vision.ts: slower, but more compatible |
| The gesture label flickers | That is normal. What would happen if each flicker deleted a note? Keep that worry: Gestures, faces and AR solves it |
| You have no webcam | Share a neighbour's to test. Write the page in your own project anyway: the "No camera found" state is part of the work |
Want more? Set numHands: 2 in vision.ts and show result.gestures with an {#each}.
The states of a camera¶
A camera can fail in ways a button never does, and people worry about it more than about anything else on the page. So the component has states, and each one tells the user what is going on. Break your demo on purpose and see them:
| State | How to get there | What the user reads |
|---|---|---|
| Off | Load the page | "Camera off. The video is analysed on this computer and never uploaded." + Turn camera on |
| Loading | Press the button the first time | "Starting the camera and loading the model..." |
| On | Allow the camera | The mirrored video, "Camera on", Turn camera off |
| Blocked | Camera icon in the address bar → block → reload → press the button | "Camera blocked. Allow it from the icon in the address bar, then press Try again." |
| No camera | Unplug the webcam, or use a PC without one | "No camera found. Plug one in, or keep using the buttons." |
camera.ts also has messages for a camera that another app is using and for a page that is not in a secure context.
The "Blocked" message is Nielsen's heuristic number 9: help users recognise, diagnose and recover from errors. The line "never uploaded" is visibility of system status, about the thing people fear most.
Key takeaways¶
- A natural user interface is operated by speaking and moving. It comes after the CLI and the GUI, and it is not natural: gestures are invisible, so it needs a visible vocabulary and feedback.
- There are four families: voice, hands and face, whole body and augmented reality.
- Every NUI is a pipeline: sensor → model → rule → feedback. The model gives a label and a score. The rule is your code.
- The toolbox: MediaPipe Tasks, Web Speech API, WebXR, TensorFlow.js, ONNX Runtime Web and Transformers.js.
- MediaPipe runs in the browser: frames are not uploaded by the library, at the price of a first download and of your computer's power. "Never uploaded" is a promise your own code has to keep.
- A camera needs a secure context and clear states, and turning it off means calling
track.stop().
Quick check¶
1. What is a natural user interface?
- An interface built only from natural-looking images and colours
- An interface that needs no learning, because it copies real life
- An interface you operate by speaking, moving your hands or moving your body
- An interface that replaces every button with an artificial intelligence
A NUI uses what you already do as a person. It does not mean "no learning": Don Norman's point in 2010 was that gestures are invisible, so people still need a vocabulary and feedback.
2. A note app pins a note when you hold a thumbs-up for 800 ms. Who decided that rule?
- The webcam, which sends the pin order with the picture
- The machine learning model, which learnt it during training
- The browser, which has a list of standard gestures
- The developer, who wrote it as ordinary code
The sensor only delivers numbers and the model only gives a label and a score such as Thumb_Up, 93 %. The rule that connects the label to an action in your app is your own code.
3. Which tool should you pick to add hand gesture recognition to a web page, with a ready-made model?
- MediaPipe Tasks, installed from npm as a library
- The WebXR Device API, built into Chromium browsers
- The Web Speech API, built into browsers
- ONNX Runtime Web, with no model of your own
MediaPipe Tasks ships ready-made tasks, including gesture recognition. WebXR gives AR and VR sessions, the Web Speech API handles speech, and ONNX Runtime Web only runs a model you bring.
4. Which statement about a MediaPipe demo running in a tab is true?
- Each video frame goes to Google for analysis and comes back as a label
- The frames are analysed on your computer, after the model is downloaded
- No download happens, because the model is part of the browser
- Your own code can no longer send the picture to any server
The analysis runs in the browser, on your processor and graphics card. The price is a first download of the runtime and the model. And nothing stops a page from sending the picture with fetch: "never uploaded" is a promise your code has to keep.
5. Your classmate opens your demo from another PC at http://192.168.1.20:5173/demo, and the camera will not start. Why?
- The model cannot be downloaded through an IP address
- Only the PC that runs
npm run devhas permission to use a camera - A camera needs a secure context, and that address is not one
- MediaPipe only recognises gestures on the machine that installed it
getUserMedia only exists in a secure context: https:// or localhost. Over plain http:// on another machine, navigator.mediaDevices is missing, so the page shows its "cannot use the camera" message.
6. What actually switches the camera off when the user presses the button?
- Hiding the video element with CSS
- Setting a
$statevariable to false - Stopping the calls to the model
- Calling
track.stop()on every track of the stream
Only stopping the tracks releases the camera, and that is what turns its light off. Hiding the video or skipping the model leaves the camera capturing while the user believes it is off.
7. A museum wants visitors to change a wall screen's language with a wave, using a camera. Pick the two sentences that are true.
- The wave needs a visible hint, because a gesture cannot be discovered on its own
- The screen also needs buttons, because not every visitor can wave or be seen
- The model will decide which language each visitor wants
- No feedback is needed, because natural gestures explain themselves
A gesture is invisible, so the vocabulary must be shown and the screen must give feedback. A NUI adds a second way in; it does not replace the buttons. And the model only reports what it sees: the meaning is your rule.
Your app can see. Next it gets an ear and a mouth, and it learns to say "I did not understand": Voice commands.