Tutorial
Dalla webcam a una mano animata sullo schermo
Costruiamo insieme, da zero, l'applicazione che gira su questo sito: il browser accende la webcam, riconosce la tua mano trenta volte al secondo e ne disegna una copia stilizzata in tre dimensioni. Tutto dentro la pagina: nessun server, nessuna immagine che esce dal tuo computer.
0 Cosa costruiamo, e cosa ti serve sapere
Prima di scrivere una riga, mettiamo a fuoco il risultato e la catena di pezzi che lo produce. Se hai chiara questa catena, ogni capitolo che segue è solo "adesso facciamo funzionare questo pezzo".
Il risultato
Una pagina web. Ci si arriva con un indirizzo, si preme un pulsante, il browser chiede il permesso di usare la telecamera. Da quel momento muovi la mano davanti alla webcam e sullo schermo compare una mano stilizzata — sfere alle articolazioni, bastoncini per le ossa, un palmo velato — che segue la tua, dita comprese. In basso compare il nome del gesto quando ne riconosce uno: pugno, pollice su, vittoria.
Nessuna installazione da parte di chi guarda, nessun account, nessun server che elabora nulla. Il server fa una cosa sola: consegnare dei file. Il resto avviene dentro il browser.
La catena, in cinque anelli
| # | Anello | Cosa entra | Cosa esce |
|---|---|---|---|
| 1 | Webcam | il permesso dell'utente | un flusso video dentro un elemento <video> |
| 2 | Riconoscitore | un fotogramma | 21 punti per ogni mano, più il nome del gesto |
| 3 | Filtro | 21 punti che tremolano | 21 punti stabili, senza ritardo percepibile |
| 4 | Scena 3D | 21 punti | sfere e cilindri piazzati e ruotati |
| 5 | Ciclo | il battito dello schermo | ripete i punti 1-4 sessanta volte al secondo |
Ogni anello ha un solo compito e non sa nulla degli altri. Il riconoscitore non sa che esiste una scena 3D, la scena 3D non sa che i punti vengono da una webcam. È la ragione per cui, al capitolo 10, sostituire "mano" con "corpo intero" costerà mezz'ora e non una riscrittura: cambia un anello solo.
Cosa devi già sapere
-
JavaScript di base: funzioni,
const, oggetti, array,async/await. Se sai cosa fa una Promise, sei a posto. -
Un po' di HTML e CSS: sapere che un
<button>esiste e che si può cercare perid. -
Il terminale: entrare in una cartella e lanciare
npm.
Non serve sapere nulla di intelligenza artificiale, di WebGL, di matrici di rotazione né di server. Le tre cose meno ovvie — la matematica del filtro, la rotazione dei cilindri e la configurazione del reverse proxy — le spiego riga per riga quando arrivano.
Cosa ti serve installato
| Serve | Versione | Perché |
|---|---|---|
| Node.js | 20 o successiva |
fa girare gli strumenti di sviluppo (Vite) e, alla fine, il piccolo server che
pubblica il sito. Include npm.
|
| Un editor | qualunque | VS Code ha il vantaggio di capire TypeScript da solo e di segnalarti gli errori mentre scrivi. |
| Un browser recente | Chrome, Edge, Firefox, Safari 16+ | servono WebAssembly (per il modello) e WebGL (per il 3D). Li hanno tutti da anni. |
| Una webcam | — | anche quella integrata del portatile. Al capitolo 8 vedremo come lavorare senza, con una telecamera finta. |
Controlla di avere Node:
node --version
# v20.x o superiore. Se il comando non esiste: nodejs.org
Prima di leggere oltre, apri la pagina Mano di questo sito e accendi la camera. Ti servirà avere in testa il risultato mentre costruisci i pezzi: è molto più facile capire perché serve un filtro anti-tremolio se hai visto la mano ferma e stabile.
1 Preparare il progetto
Dieci minuti di impalcatura, e poi non ci pensi più.
- package.json
- vite.config.ts
- index.html
- src/main.ts
Creare lo scheletro
# Crea un progetto Vite con TypeScript, senza framework
npm create vite@latest mano -- --template vanilla-ts
cd mano
npm install
npm run dev
# → il terminale stampa: Local: http://localhost:5173/
Apri quell'indirizzo: c'è una pagina di esempio. Da qui in avanti ogni file che salvi si ricarica nel browser in meno di un secondo.
Vite fa due mestieri. In sviluppo serve i file al browser senza
ricompilare l'intero progetto a ogni salvataggio: modifichi, salvi, vedi. In produzione
costruisce un sito statico, minificato, con i nomi dei file firmati dal contenuto
(index-a1b2c3.js): se il contenuto cambia cambia il nome, quindi si possono
tenere in cache per sempre senza mai servire una versione vecchia.
Niente React, Vue o Svelte. L'interfaccia di questa applicazione è fatta di nove elementi che cambiano stato tutti insieme, in un punto solo del codice. Un framework aggiungerebbe un pacchetto, una fase di compilazione e un modello mentale in più per risparmiare venti righe. Lo stesso vale per il CSS: nessun Tailwind, duecento righe scritte a mano.
TypeScript sì, però. Questo codice è pieno di coordinate, indici e array di 21 elementi. Con i tipi, un errore di battitura diventa un errore di compilazione invece di una mano storta sullo schermo che non sai spiegarti.
La struttura a cui arriveremo
mano/
├── index.html la pagina (una sola)
├── package.json dipendenze e comandi
├── vite.config.ts configurazione della build
├── scripts/
│ └── prepare-assets.mjs scarica il modello e copia il runtime (capitolo 3)
├── public/ file serviti così come sono, senza passare dal bundler
│ ├── favicon.svg
│ └── mediapipe/ wasm + modello (generati: non si toccano a mano)
└── src/
├── main.ts il regista: stato della pagina e ciclo dei fotogrammi
├── camera.ts accensione e spegnimento della webcam
├── tracker.ts MediaPipe: dai pixel ai 21 punti
├── filter.ts il filtro anti-tremolio
├── hand3d.ts la mano 3D con Three.js
└── style.css l'aspetto
Non è pignoleria: camera.ts è la parte che tocca i dati più delicati (le
immagini di una persona). Tenerla in trenta righe da sola significa che chiunque, in
dieci secondi, può verificare che lì dentro non ci sia nessun fetch, nessun
MediaRecorder, nessun salvataggio. In un file da mille righe la stessa
verifica è un atto di fede.
La pagina
Sostituisci index.html con questo. La struttura è: una tela a tutto schermo per
il 3D, e sopra di essa quattro riquadri che galleggiano.
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<!-- senza questo, sul telefono la pagina viene rimpicciolita come un sito desktop -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mano</title>
</head>
<!-- data-state lo aggiorna main.ts: idle | loading | running | error -->
<body data-state="idle">
<canvas id="scene" aria-label="Riproduzione 3D della mano"></canvas>
<header class="topbar">
<div class="brand">✋ Mano</div>
<div class="status" id="status" role="status" aria-live="polite">Camera spenta</div>
</header>
<!-- La camera si accende SOLO da qui: è una scelta esplicita di chi guarda,
mai un effetto collaterale dell'apertura della pagina. -->
<section class="intro" id="intro">
<h1>Muovi la mano davanti alla webcam</h1>
<button type="button" class="primary" id="start">Accendi la camera</button>
<p class="privacy">Tutto avviene nel tuo browser: le immagini non vengono inviate
a nessun server né salvate.</p>
<p class="error" id="error" hidden></p>
</section>
<!-- Anteprima della webcam con i puntini sopra: serve a capire cosa "vede" il modello.
muted e playsinline: senza, iOS si rifiuta di riprodurre il video dentro la pagina. -->
<aside class="preview" id="preview" hidden>
<video id="video" autoplay muted playsinline></video>
<canvas id="overlay"></canvas>
</aside>
<footer class="hud" id="hud" hidden>
<div class="gestures" id="gestures"></div>
<div class="controls">
<span class="metric" id="fps">– fps</span>
<button type="button" id="toggle-preview" aria-pressed="true">Nascondi camera</button>
<button type="button" id="stop">Spegni camera</button>
</div>
</footer>
<!-- L'unico script della pagina. type="module" permette gli import; in produzione
Vite lo sostituisce con il file compilato. -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Nota che non c'è nessun <script> con codice scritto dentro la
pagina, e nessun attributo style="…". Al capitolo 7 attiveremo una regola di
sicurezza (la CSP) che li vieta: se prendi l'abitudine adesso, non dovrai riscrivere
niente dopo.
Il primo main.ts
Per ora basta raccogliere gli elementi della pagina in un punto solo e collegare il pulsante. Il resto arriva nei capitoli seguenti.
import "./style.css";
/**
* Scorciatoia per prendere un elemento dall'HTML.
* Il `as T` è una promessa fatta a TypeScript ("fidati, esiste ed è di questo tipo"):
* qui è lecita perché gli id sono scritti a mano in index.html e non cambiano.
*/
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
/** Tutti i nodi della pagina in un posto solo: se cambia un id, si corregge qui. */
const ui = {
scene: $<HTMLCanvasElement>("scene"),
intro: $("intro"),
start: $<HTMLButtonElement>("start"),
error: $("error"),
status: $("status"),
preview: $("preview"),
video: $<HTMLVideoElement>("video"),
overlay: $<HTMLCanvasElement>("overlay"),
hud: $("hud"),
gestures: $("gestures"),
fps: $("fps"),
togglePreview: $<HTMLButtonElement>("toggle-preview"),
stop: $<HTMLButtonElement>("stop"),
};
ui.start.addEventListener("click", () => console.log("via!"));
Perché document.getElementById("start") sparso in dieci punti del codice
significa dieci punti da correggere il giorno che rinomini un id — e nove li trovi solo
quando qualcosa smette di funzionare, a runtime. Qui l'errore lo vedi subito, in un posto
solo.
2 Accendere la webcam
Trenta righe, e quasi tutte servono a gestire i casi in cui qualcosa va storto. È normale: la webcam è hardware di qualcun altro, e può dire di no in cinque modi diversi.
- src/camera.ts
La riga che conta
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
video.srcObject = stream;
await video.play();
Tutto qui: chiedi un flusso, lo attacchi a un elemento <video>, lo
riproduci. Il resto del capitolo riguarda le quattro cose che possono andare male.
Il contesto sicuro: HTTPS o localhost
I browser danno accesso alla telecamera solo in "contesto sicuro": una
pagina servita da https://, oppure da localhost. Su un
http:// qualunque, navigator.mediaDevices semplicemente
non esiste, e il tuo codice fallisce con un oscuro
Cannot read properties of undefined.
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
throw new CameraError("La camera funziona solo su una pagina HTTPS.");
}
In sviluppo funziona tutto, perché npm run dev ti serve la pagina su
localhost. Il giorno che la pubblichi su un indirizzo IP nudo o su un
dominio senza certificato, la camera non parte e basta. È il motivo per cui questo
progetto ha avuto bisogno di un dominio vero fin dal primo giorno: lo affrontiamo al
capitolo 7.
I vincoli: cosa chiedere alla telecamera
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "user", // camera frontale sui telefoni
width: { ideal: 640 },
height: { ideal: 480 },
},
audio: false, // il microfono non serve: non va nemmeno chiesto
});
ideal e non exact
Con exact, una webcam che non fa esattamente 640×480 fa fallire la richiesta
con OverconstrainedError. Con ideal il browser sceglie il
formato più vicino che ha. Praticamente sempre quello che vuoi.
E perché proprio 640×480? Perché il riconoscitore del capitolo 3 non diventa più preciso con immagini più grandi — ridimensiona comunque internamente — ma diventa più lento. Chiedere Full HD qui significa solo scaldare il processore.
audio: false non è una formalità: se chiedi audio, il
browser chiede all'utente il permesso anche per il microfono, e la spia
corrispondente si accende. Non chiedere quello che non ti serve.
Gli errori, tradotti in frasi utili
Quando getUserMedia fallisce, lancia una DOMException con un nome
standard. Mostrare quel nome all'utente è inutile; tradurlo costa cinque righe.
/** Errore "previsto", con un messaggio già scritto per l'utente. Lo distingue da un bug vero. */
export class CameraError extends Error {}
const MESSAGES: Record<string, string> = {
NotAllowedError: "Permesso negato: consenti l'uso della camera dalle impostazioni del browser e riprova.",
NotFoundError: "Nessuna webcam trovata su questo dispositivo.",
NotReadableError: "La webcam è occupata da un'altra applicazione.",
OverconstrainedError: "La webcam non supporta la risoluzione richiesta.",
};
// ...
} catch (e) {
const name = e instanceof DOMException ? e.name : "";
throw new CameraError(MESSAGES[name] ?? "Impossibile accendere la camera.");
}
| Errore | Cosa è successo davvero | Cosa può fare l'utente |
|---|---|---|
NotAllowedError |
ha premuto "Blocca", o il permesso era già negato per questo sito | riabilitarlo dall'icona del lucchetto nella barra degli indirizzi |
NotFoundError |
nessuna telecamera collegata | collegarne una |
NotReadableError |
la webcam è già usata da Zoom, Teams, un'altra scheda… | chiudere l'altra applicazione |
OverconstrainedError |
i vincoli chiesti sono impossibili per quella telecamera | niente: è colpa tua, usa ideal |
CameraError tutta sua
Perché più in alto, in main.ts, vuoi distinguere due situazioni molto
diverse: un problema previsto, con un messaggio già pronto da mostrare, e un bug
del tuo codice, di cui è meglio mostrare il testo tecnico. Una riga:
e instanceof CameraError ? e.message : `Errore durante l'avvio: …`.
Un dettaglio che morde: le dimensioni arrivano dopo
video.srcObject = stream;
await video.play();
// Subito dopo play() le dimensioni possono essere ancora 0. A noi servono, per dare alla
// scena 3D le proporzioni giuste: quindi si aspetta l'evento che le annuncia.
if (video.videoWidth === 0) {
await new Promise<void>((resolve) =>
video.addEventListener("loadedmetadata", () => resolve(), { once: true }),
);
}
Senza questa attesa ti ritrovi, ogni tanto e solo su certe macchine, con una scena 3D dalle
proporzioni NaN. Il classico bug che non si riproduce sul tuo computer.
Spegnere la camera davvero
Fermare l'elemento <video> non spegne la telecamera. Il
flusso resta aperto e la spia verde del portatile resta accesa: dal punto di vista di chi
usa il sito, lo stai ancora riprendendo. Va fermata ogni singola traccia.
export function stopCamera(video: HTMLVideoElement, stream: MediaStream | null): void {
stream?.getTracks().forEach((t) => t.stop()); // ← questa è la riga che spegne la spia
video.pause();
video.srcObject = null;
}
Le tre attenzioni che quasi nessuno mette
Non basta un pulsante "spegni". Ci sono tre momenti in cui la camera deve spegnersi da sola, e li scopri solo se ci pensi apposta:
// 1. L'utente ha cambiato scheda MENTRE si accendeva la camera: non deve restare accesa.
if (document.hidden) { stop(); return; }
// 2. L'utente cambia scheda a camera accesa.
document.addEventListener("visibilitychange", () => {
if (document.hidden && stream) stop();
});
// 3. L'utente lascia la pagina: si libera anche il riconoscitore (memoria e contesto grafico).
window.addEventListener("pagehide", () => {
stop();
tracker?.close();
tracker = null;
});
Il primo caso è quello che non viene in mente: fra il momento in cui l'utente concede il permesso e il momento in cui il flusso arriva passano centinaia di millisecondi, e in quel tempo può benissimo essere passato ad altro.
Accendi la camera, cambia scheda, torna indietro: la spia deve essersi spenta e la pagina
deve essere tornata allo stato iniziale. Poi accendi la camera e apri Zoom: dovresti
vedere NotReadableError tradotto nella tua frase.
3 Riconoscere la mano
Qui succede la magia, e sono venti righe. Tutto il lavoro difficile lo ha già fatto Google: noi carichiamo un modello e gli passiamo fotogrammi.
- src/tracker.ts
- scripts/prepare-assets.mjs
La libreria
npm install @mediapipe/tasks-vision@1.0.1
MediaPipe Tasks Vision è la libreria con cui Google fa girare i propri modelli di visione dentro il browser, compilati in WebAssembly. Un solo modello dà sia i 21 punti della mano sia il nome di otto gesti. Le alternative — TensorFlow.js con handpose, o un modello ONNX su onnxruntime-web — sono più lente o richiedono di scrivere a mano il riconoscimento dei gesti.
1.0.1 e non ^1.0.1: il runtime e il file del
modello devono combaciare, e (lo vedremo al capitolo 7) una patch di sicurezza si appoggia
su una stringa precisa del file compilato. Un aggiornamento automatico romperebbe la
build — ed è esattamente quello che vogliamo: meglio una build che si ferma di un
comportamento che cambia in silenzio.
I 21 punti
Il modello restituisce, per ogni mano vista, un array di 21 oggetti. Sono sempre nello stesso ordine, ed è quell'ordine a dare significato ai numeri:
| Indici | Parte |
|---|---|
| 0 | polso |
| 1 · 2 · 3 · 4 | pollice, dalla base alla punta |
| 5 · 6 · 7 · 8 | indice (5 = nocca, 8 = punta) |
| 9 · 10 · 11 · 12 | medio |
| 13 · 14 · 15 · 16 | anulare |
| 17 · 18 · 19 · 20 | mignolo |
Ogni punto ha tre numeri:
| Significato | Intervallo | |
|---|---|---|
x |
posizione orizzontale sull'immagine | 0 (bordo sinistro) … 1 (bordo destro) |
y |
posizione verticale | 0 (alto) … 1 (basso) |
z |
profondità rispetto al polso | stessa scala della x; negativa = più vicino alla camera |
Due cose importanti. Primo: le coordinate sono normalizzate, cioè non sono
pixel. Se la webcam cambia risoluzione, i numeri non cambiano — ottima notizia per il
codice che li usa. Secondo: la z è la meno affidabile delle tre, perché è
stimata da una sola telecamera. Basta per capire che un dito è puntato in avanti; non fidarti
per misurare distanze.
y cresce verso il basso
Perché è la convenzione delle immagini: l'origine è l'angolo in alto a sinistra,
come per un canvas HTML. Nella grafica 3D, invece, la y cresce verso l'alto.
Fra i due mondi c'è una conversione, e la faremo al capitolo 5. Segnatelo: è la sorgente
di metà dei bug "la mano è sottosopra".
Caricare il modello
import { FilesetResolver, GestureRecognizer, type GestureRecognizerResult } from "@mediapipe/tasks-vision";
const WASM_PATH = "/mediapipe/wasm";
const MODEL_PATH = "/mediapipe/gesture_recognizer.task";
// FilesetResolver guarda se il browser supporta le istruzioni SIMD del processore e sceglie
// da solo fra vision_wasm_internal.* e la variante "nosimd". Entrambe devono essere presenti.
const vision = await FilesetResolver.forVisionTasks(WASM_PATH);
const recognizer = await GestureRecognizer.createFromOptions(vision, {
baseOptions: { modelAssetPath: MODEL_PATH, delegate: "GPU" },
runningMode: "VIDEO", // "IMAGE" ripartirebbe da zero a ogni fotogramma
numHands: 2,
minHandDetectionConfidence: 0.5, // per accorgersi che c'è una mano
minHandPresenceConfidence: 0.5, // per dire che c'è ancora
minTrackingConfidence: 0.5, // per continuare a seguirla senza ricercarla da capo
});
FilesetResolver è il pezzo che spiazza tutti la prima volta: non carica il
modello, carica il motore. È il runtime WebAssembly che sa eseguire le reti neurali
di MediaPipe; il modello (il file .task) è il "contenuto" che quel motore
esegue. Sono due download separati: circa 11 MB il primo, circa 8 MB il secondo, e poi
restano nella cache del browser.
runningMode: "VIDEO" cambia tutto
In modalità IMAGE ogni fotogramma viene analizzato da zero: il modello cerca
la mano in tutta l'immagine, ogni volta. In modalità VIDEO il modello sa che
i fotogrammi sono consecutivi e insegue la mano da un fotogramma
all'altro, cercandola solo dove si aspetta di trovarla. Risultato: più veloce, più
stabile, meno "salti".
Le soglie a 0.5 sono quelle consigliate da Google. Alzarle riduce i falsi positivi, ma la mano viene "persa" più spesso — e una mano che sparisce e ricompare è molto più fastidiosa di una mano leggermente imprecisa.
Analizzare un fotogramma
detect(video: HTMLVideoElement, now: number): GestureRecognizerResult {
// MediaPipe in modalità VIDEO usa l'istante per capire quanto tempo è passato dal
// fotogramma precedente, e INTERROMPE CON UN ERRORE se riceve un tempo uguale o
// minore dell'ultimo. Con performance.now() non dovrebbe mai capitare, ma il "+1"
// di sicurezza costa nulla ed evita un errore fatale dove non te lo aspetti.
const ts = now > this.lastTimestamp ? now : this.lastTimestamp + 1;
this.lastTimestamp = ts;
return this.recognizer.recognizeForVideo(video, ts);
}
Nota una cosa controintuitiva: recognizeForVideo è sincrona.
Non restituisce una Promise: fa il calcolo subito e blocca il thread principale per la sua
durata — pochi millisecondi con la scheda grafica. È il motivo per cui, al capitolo 4, non
troverai await dentro il ciclo dei fotogrammi.
Il risultato che ottieni:
{
landmarks: [ [ {x,y,z} × 21 ], … ], // un array per mano vista
worldLandmarks: [ … ], // gli stessi punti in metri, origine al centro della mano
handedness: [ [ { categoryName: "Left" | "Right", score } ], … ],
gestures: [ [ { categoryName: "Thumb_Up", score } ], … ],
}
Gli otto gesti
| Nome dal modello | Etichetta mostrata |
|---|---|
Closed_Fist | ✊ Pugno |
Open_Palm | 🖐️ Mano aperta |
Pointing_Up | ☝️ Indice in su |
Thumb_Down | 👎 Pollice giù |
Thumb_Up | 👍 Pollice su |
Victory | ✌️ Vittoria |
ILoveYou | 🤟 Ti voglio bene |
None | nessuno dei precedenti → si mostra solo ✋ |
/** Il codice ragiona in inglese, l'interfaccia parla italiano. */
export const GESTURE_LABELS: Record<string, string> = {
Closed_Fist: "✊ Pugno",
Open_Palm: "🖐️ Mano aperta",
Pointing_Up: "☝️ Indice in su",
Thumb_Down: "👎 Pollice giù",
Thumb_Up: "👍 Pollice su",
Victory: "✌️ Vittoria",
ILoveYou: "🤟 Ti voglio bene",
};
Scheda grafica o processore: il ripiego automatico
delegate: "GPU" dice a MediaPipe di fare i conti sulla scheda grafica. Di
norma è la scelta giusta. Su alcune configurazioni, però, succede il contrario — e in modo
spettacolare: dentro un browser senza scheda grafica vera (per esempio in un test
automatico su un server) la GPU emulata può impiegare 3-5 secondi per
fotogramma, mentre la CPU ne fa 4-6 al secondo.
static async create(onlyCpu = false): Promise<HandTracker> {
// `?delegate=cpu` nell'indirizzo forza la CPU: utile per diagnosticare senza toccare il codice.
const forceCpu = onlyCpu || new URLSearchParams(location.search).get("delegate")?.toLowerCase() === "cpu";
const vision = await FilesetResolver.forVisionTasks(WASM_PATH);
const options = (delegate: "GPU" | "CPU") => ({ /* …come sopra… */ });
if (forceCpu) return new HandTracker(await GestureRecognizer.createFromOptions(vision, options("CPU")), "CPU");
try {
return new HandTracker(await GestureRecognizer.createFromOptions(vision, options("GPU")), "GPU");
} catch {
// Niente WebGL utilizzabile: la CPU è più lenta ma funziona ovunque,
// ed è meglio di una pagina che non parte.
return new HandTracker(await GestureRecognizer.createFromOptions(vision, options("CPU")), "CPU");
}
}
Il try/catch copre il caso "WebGL non c'è". Non copre il caso "WebGL c'è ma è
lentissimo": quello si misura. Teniamo la durata media di una rilevazione…
const t0 = performance.now();
const result = this.recognizer.recognizeForVideo(video, ts);
const ms = performance.now() - t0;
// Media mobile esponenziale: pesa di più le misure recenti e non richiede di tenere
// uno storico. Due variabili invece di un array.
this.detections++;
this.avgDetectMs = this.detections === 1 ? ms : this.avgDetectMs * 0.8 + ms * 0.2;
…e, se la media sfonda la soglia, ricreiamo il riconoscitore su CPU:
async function fallbackToCpuIfSlow(): Promise<void> {
if (!tracker || switching || tracker.delegate !== "GPU" ||
tracker.detections < 8 || tracker.avgDetectMs < 150) return;
switching = true;
const slow = tracker;
tracker = null; // il ciclo salta le rilevazioni finché non è pronto
try {
tracker = await HandTracker.create(true); // true = solo CPU
slow.close(); // il vecchio va chiuso: tiene memoria e contesto grafico
console.info(`GPU troppo lenta (${Math.round(slow.avgDetectMs)} ms): passo alla CPU`);
} catch {
tracker = slow; // se fallisce, si continua con quello di prima
} finally {
switching = false;
}
}
detections < 8 evita di giudicare sui primi fotogrammi, che sono sempre
lenti (compilazione degli shader, cache fredde). La soglia di 150 ms corrisponde a meno di 7
fotogrammi al secondo: sotto quella cifra l'applicazione è già inutilizzabile, quindi vale
la pena rischiare un cambio.
Runtime e modello: dal tuo sito, non da una CDN
La documentazione ufficiale di MediaPipe ti fa caricare wasm e modello da una CDN, con un
indirizzo che contiene @latest. Per un esempio va benissimo. Per un sito vero
ci sono due buoni motivi per non farlo:
- Riservatezza. Al capitolo 7 imposteremo una regola che consente alla pagina di contattare solo il proprio dominio. È quella regola a rendere verificabile la frase "le immagini non escono dal tuo browser". Se il modello arrivasse da una CDN, andrebbe allargata.
-
Stabilità.
@latestsignifica "la versione di oggi". Il giorno che ne esce una nuova, il tuo sito cambia da solo, magari rompendosi, senza che tu abbia toccato niente.
La soluzione è uno script che gira prima di ogni build: copia il runtime da
node_modules (quindi della stessa versione del pacchetto) e scarica il modello
verificandone il checksum.
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// I moduli ES non hanno __dirname: si ricava dall'indirizzo del file stesso.
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const out = join(root, "public", "mediapipe");
// Le due varianti del runtime: SIMD (più veloce) e nosimd (ripiego). Sceglie MediaPipe
// quale usare, ma deve trovarle entrambe nella cartella.
const WASM_FILES = [
"vision_wasm_internal.js",
"vision_wasm_internal.wasm",
"vision_wasm_nosimd_internal.js",
"vision_wasm_nosimd_internal.wasm",
];
const wasmSrc = join(root, "node_modules", "@mediapipe", "tasks-vision", "wasm");
mkdirSync(join(out, "wasm"), { recursive: true });
for (const f of WASM_FILES) copyFileSync(join(wasmSrc, f), join(out, "wasm", f));
// Il modello (~8 MB). L'indirizzo punta alla versione "1", non a "latest".
const MODEL = {
url: "https://storage.googleapis.com/mediapipe-models/gesture_recognizer/gesture_recognizer/float16/1/gesture_recognizer.task",
file: join(out, "gesture_recognizer.task"),
sha256: "97952348cf6a6a4915c2ea1496b4b37ebabc50cbbf80571435643c455f2b0482",
};
const sha = (buf) => createHash("sha256").update(buf).digest("hex");
if (!existsSync(MODEL.file) || sha(readFileSync(MODEL.file)) !== MODEL.sha256) {
const res = await fetch(MODEL.url);
if (!res.ok) throw new Error(`download del modello fallito: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
// Se il contenuto non è quello atteso la build si ferma: meglio un errore chiaro adesso
// che un file sbagliato pubblicato sul sito.
if (sha(buf) !== MODEL.sha256) throw new Error("checksum del modello diverso da quello atteso");
writeFileSync(MODEL.file, buf);
}
console.log("asset MediaPipe pronti in public/mediapipe/");
Poi lo agganci ai comandi, così non devi ricordartene mai più:
"scripts": {
"assets": "node scripts/prepare-assets.mjs",
"dev": "npm run assets && vite",
"build": "npm run assets && tsc && vite build",
"test:e2e": "node tests/e2e/run.mjs"
}
public/
Tutto ciò che sta in public/ finisce nella build così com'è, senza
passare dal bundler e senza che il nome cambi. È esattamente ciò che serve per file che
devono essere raggiungibili a un indirizzo fisso, come
/mediapipe/gesture_recognizer.task.
4 Il ciclo dei fotogrammi
Dodici righe che decidono se l'applicazione è fluida o se scalda il portatile.
- src/main.ts
let rafId = 0; // id dell'ultimo requestAnimationFrame, per poterlo annullare
let lastVideoTime = -1; // istante dell'ultimo fotogramma già analizzato
function loop(): void {
rafId = requestAnimationFrame(loop); // richiamami prima del prossimo ridisegno
const video = ui.video;
// readyState >= 2 significa "ci sono dati per il fotogramma corrente".
if (tracker && video.readyState >= 2 && video.currentTime !== lastVideoTime) {
lastVideoTime = video.currentTime;
const now = performance.now();
const result = tracker.detect(video, now); // ← il riconoscimento
// …qui useremo i punti: filtro, scena 3D, anteprima 2D…
}
scene.render(); // ← il disegno, SEMPRE, anche senza fotogrammi nuovi
}
Tre dettagli che sembrano minuzie e non lo sono
requestAnimationFrame e non setInterval
Il browser chiama loop appena prima di ridisegnare lo schermo. Tre
conseguenze, tutte buone:
- il disegno è sincronizzato con il monitor: niente immagini "tagliate" a metà aggiornamento;
- se il monitor va a 120 Hz il ciclo ci va dietro da solo, senza che tu cambi una costante;
-
se la scheda passa in secondo piano il browser mette in pausa il ciclo,
invece di lasciarlo consumare batteria di nascosto.
setIntervalcontinuerebbe a girare, accumulando fotogrammi che nessuno guarda.
Saltare il fotogramma già analizzato
Lo schermo si aggiorna 60 volte al secondo, la webcam produce 30 fotogrammi al secondo: metà dei giri del ciclo troverebbe lo stesso identico fotogramma. Analizzarlo due volte è lavoro sprecato — e comunque MediaPipe, in modalità video, rifiuta due analisi con lo stesso istante (capitolo 3).
Il trucco è video.currentTime: cambia solo quando arriva un fotogramma nuovo.
Confrontarlo con il valore precedente costa un confronto e dimezza il lavoro.
Il disegno sta fuori dall'if
Il riconoscimento gira a 30 Hz, il disegno a 60. Se mettessi scene.render()
dentro l'if, le dissolvenze delle mani che entrano ed escono andrebbero a
scatti. Fuori, la scena continua ad animarsi anche quando non arriva niente di nuovo dalla
camera.
~60 volte al secondo: il ciclo gira e la scena 3D si ridisegna.
~30 volte al secondo: arriva un fotogramma nuovo e il modello lavora.
1 volta al secondo: si aggiorna il contatore degli fps.
Contare i fotogrammi (quelli giusti)
frames++;
if (now - fpsWindowStart >= 1000) {
ui.fps.textContent = `${Math.round((frames * 1000) / (now - fpsWindowStart))} fps · ${tracker.delegate}`;
frames = 0;
fpsWindowStart = now;
}
Nota dove sta questo blocco: dentro l'if. Conta i fotogrammi
analizzati, non quelli disegnati. Il numero dei disegni sarebbe sempre 60 e
non direbbe nulla; il numero delle analisi ti dice se l'applicazione "sta dietro" alla mano.
È l'unica metrica che conta davvero, ed è anche il primo posto dove guardare quando qualcosa
va lento.
Avvio e spegnimento
async function start(): Promise<void> {
ui.error.hidden = true;
setState("loading", "Avvio…");
try {
// L'ORDINE CONTA: prima il modello (pesante, ~8 MB, ma non chiede niente all'utente),
// poi la webcam. Così il permesso viene chiesto quando manca poco al via, e non si tiene
// la telecamera accesa per secondi mentre si scarica il modello.
// Il `??=` fa sì che alla seconda accensione il modello già in memoria venga riusato.
tracker ??= await HandTracker.create();
stream = await startCamera(ui.video);
if (document.hidden) { stop(); return; }
// Le proporzioni reali del video decidono l'area utile della scena 3D.
scene.setFrameAspect(ui.video.videoWidth / ui.video.videoHeight);
lastVideoTime = -1;
setState("running", "● Camera accesa");
cancelAnimationFrame(rafId); // per sicurezza: mai due cicli in parallelo
loop();
} catch (e) {
stop();
ui.error.textContent = e instanceof CameraError
? e.message
: `Errore durante l'avvio: ${(e as Error).message ?? e}`;
ui.error.hidden = false;
setState("error", "Camera spenta");
}
}
cancelAnimationFrame(rafId) prima di loop() sembra inutile. Non
lo è: basta un doppio clic sul pulsante di avvio, o una riaccensione mentre il primo
start() è ancora in attesa della camera, e ti ritrovi due cicli che girano in
parallelo. Il sintomo è bizzarro — l'applicazione consuma il doppio e la mano scatta — e la
causa è difficile da immaginare guardando il codice del disegno.
function stop(): void {
cancelAnimationFrame(rafId);
stopCamera(ui.video, stream);
stream = null;
scene.update("Right", null);
scene.update("Left", null);
streak.Right = streak.Left = 0;
smoothers.Right = smoothers.Left = null;
// Il ciclo principale è fermo, ma la dissolvenza della mano dura ancora un decimo di
// secondo: si continua a disegnare per una ventina di fotogrammi, poi basta.
let n = 20;
const fade = () => { scene.render(); if (--n > 0) requestAnimationFrame(fade); };
fade();
setState("idle", "Camera spenta");
}
Il riconoscitore, nota, non viene chiuso: resta in memoria pronto per una riaccensione istantanea. Si chiude solo quando si lascia la pagina. La webcam invece si spegne subito: è la telecamera, non un modello.
Un punto solo decide cosa si vede
type State = "idle" | "loading" | "running" | "error";
function setState(state: State, status: string): void {
document.body.dataset.state = state;
ui.status.textContent = status;
ui.status.classList.toggle("live", state === "running");
ui.intro.hidden = state === "running";
ui.hud.hidden = state !== "running";
ui.preview.hidden = state !== "running" || ui.togglePreview.getAttribute("aria-pressed") !== "true";
ui.start.disabled = state === "loading";
ui.start.textContent = state === "loading" ? "Avvio in corso…" : "Accendi la camera";
}
Perché evita la classe di bug più comune in un'interfaccia: due elementi che raccontano stati diversi. Il pulsante dice "Accendi la camera" mentre l'etichetta dice "● Camera accesa", perché una delle due è stata aggiornata in un ramo di codice e l'altra no. Con un solo punto che decide tutto, quella situazione è impossibile per costruzione. È anche il motivo per cui questa applicazione non ha bisogno di React.
5 Disegnare: prima in 2D, poi in 3D
Cominciamo dalla versione che funziona in dieci righe, per vedere subito qualcosa di vivo. Poi passiamo al 3D.
- src/main.ts
- src/hand3d.ts
La versione facile: un canvas 2D
MediaPipe include un'utilità che disegna punti e collegamenti su un contesto 2D. Serve per l'anteprima — quel riquadro con la webcam e i puntini sopra — ed è il modo più rapido per verificare che il riconoscimento funzioni.
import { DrawingUtils } from "@mediapipe/tasks-vision";
const overlayCtx = ui.overlay.getContext("2d")!;
const drawing = new DrawingUtils(overlayCtx);
// …dentro il ciclo, per ogni mano trovata:
// La tela ha le dimensioni REALI del video (es. 640×480); il CSS poi la rimpicciolisce.
// Così i punti disegnati coincidono con l'immagine che c'è sotto.
if (ui.overlay.width !== video.videoWidth) {
ui.overlay.width = video.videoWidth;
ui.overlay.height = video.videoHeight;
}
overlayCtx.clearRect(0, 0, ui.overlay.width, ui.overlay.height);
drawing.drawConnectors(landmarks, HAND_CONNECTIONS, { color: "#38bdf8", lineWidth: 3 });
drawing.drawLandmarks(landmarks, { color: "#ffffff", radius: 2.5, lineWidth: 1 });
HAND_CONNECTIONS è la tabella delle 21 coppie di punti da collegare
(polso→pollice, nocca→falange e così via). La fornisce MediaPipe:
// Riesportata da qui perché la usano sia l'anteprima 2D sia la scena 3D, e nessuna delle
// due deve importare MediaPipe solo per questo.
export const HAND_CONNECTIONS = GestureRecognizer.HAND_CONNECTIONS;
Nel CSS, l'anteprima si specchia:
.preview video,
.preview canvas {
object-fit: cover;
transform: scaleX(-1); /* effetto specchio, SOLO visivo */
}
Quel ribaltamento è solo visivo: serve a far sembrare l'anteprima uno specchio. I numeri che arrivano da MediaPipe restano quelli della ripresa vera, non specchiata. Da qui nasce il bug destra/sinistra del capitolo 6.
Il passaggio al 3D: Three.js
npm install three@0.186.0
npm install --save-dev @types/three@0.186.0 # stessa versione, o descrive funzioni che non esistono
WebGL diretto significa scrivere shader, matrici di proiezione e gestione del contesto grafico: centinaia di righe prima ancora di vedere una sfera. Three.js è la libreria standard per questo, non ha dipendenze proprie, e tutta la scena di questo progetto sta in un file solo da trecento righe, commenti compresi. In cambio pesa circa mezzo megabyte compresso: per un'applicazione 3D è il prezzo giusto.
La scena minima
import * as THREE from "three";
// `alpha: true`: lo sfondo lo mette il CSS (una sfumatura), la tela resta trasparente.
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
// Su schermi ad alta densità si disegna al massimo al doppio della risoluzione: oltre non si
// vede la differenza e si spreca lavoro (e batteria).
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
// fov 35°, aspect provvisorio, piani di taglio vicino/lontano
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
// Telecamera FERMA davanti all'origine: si muove la mano, non l'inquadratura.
// È ciò che rende immediato l'effetto specchio.
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);
// Tre luci:
scene.add(new THREE.HemisphereLight(0xdbeafe, 0x1e1b4b, 1.4)); // diffusa cielo/terra: niente ombre nere
const key = new THREE.DirectionalLight(0xffffff, 2.2);
key.position.set(3, 5, 8); // principale: dà volume
scene.add(key);
const rim = new THREE.DirectionalLight(0x818cf8, 1.2);
rim.position.set(-4, -2, -6); // radente violacea: stacca dallo sfondo
scene.add(rim);
Con una sola luce direzionale, la faccia in ombra di ogni sfera diventa nera e la mano sembra ritagliata. La luce emisferica riempie le ombre con un colore invece che col nero; la principale dà il volume; quella radente da dietro traccia un bordo luminoso che stacca la figura dallo sfondo scuro. È lo schema classico a tre punti della fotografia, e in 3D funziona uguale.
La conversione delle coordinate
MediaPipe parla in coordinate d'immagine (0…1, origine in alto a sinistra, y verso il basso). Three.js parla in coordinate di mondo (origine al centro, y verso l'alto, unità arbitrarie). Il ponte fra i due mondi è questa funzione:
const DEPTH_GAIN = 1.6;
toWorld(p: Point3): Point3 {
const width = this.frameAspect * this.frameHeight;
return {
x: (0.5 - p.x) * width, // centra E specchia
y: (0.5 - p.y) * this.frameHeight, // centra E rovescia (in 3D la y cresce in alto)
z: -p.z * width * DEPTH_GAIN, // verso l'osservatore, un po' esagerata
};
}
Lo 0.5 - fa due lavori in una sottrazione. Sposta l'origine
dall'angolo dell'immagine al centro, e inverte il verso dell'asse:
-
sulla
xl'inversione è l'effetto specchio: muovi la mano a destra, quella sullo schermo va a destra, come davanti a uno specchio. Senza, il movimento risulterebbe ribaltato e l'esperienza diventa subito sgradevole; -
sulla
yè la conversione fra la convenzione delle immagini e quella della grafica 3D. Se la salti, la mano è sottosopra.
DEPTH_GAIN = 1.6 amplifica la profondità: senza, avvicinare la mano alla camera
quasi non si noterebbe, perché la z di MediaPipe è piccola rispetto alle altre
due coordinate. Il valore è scelto a occhio — sopra 2 la mano comincia a sembrare deformata.
Un oggetto per articolazione, uno per osso
Gli oggetti si creano una volta sola nel costruttore; a ogni fotogramma si spostano soltanto. Creare e distruggere 40 oggetti 60 volte al secondo farebbe lavorare di continuo il raccoglitore di memoria, e si vedrebbe: scatti a intervalli irregolari, di quelli che non riesci a correlare a niente.
// Due geometrie per TUTTA la scena: una sfera di raggio 1 e un cilindro alto 1.
// Ogni oggetto le riusa cambiando solo posizione, rotazione e scala.
const jointGeo = new THREE.SphereGeometry(1, 20, 14);
const boneGeo = new THREE.CylinderGeometry(1, 1, 1, 14);
// Tre materiali condivisi, non uno per oggetto: cambiare la trasparenza di un materiale
// la cambia per tutti gli oggetti che lo usano — ed è così che funziona la dissolvenza.
this.solid = new THREE.MeshStandardMaterial({ color, roughness: 0.35, metalness: 0.1, transparent: true });
this.tips = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: color, // "emette" luce propria: le punte si notano anche in ombra
emissiveIntensity: 0.9,
roughness: 0.3,
transparent: true,
});
for (let i = 0; i < 21; i++) {
const joint = new THREE.Mesh(jointGeo, FINGERTIPS.has(i) ? this.tips : this.solid);
this.joints.push(joint);
this.group.add(joint);
}
for (let i = 0; i < HAND_CONNECTIONS.length; i++) {
const bone = new THREE.Mesh(boneGeo, this.solid);
this.bones.push(bone);
this.group.add(bone);
}
/** Posizioni di lavoro riusate a ogni fotogramma, per non allocare 21 vettori ogni volta. */
private readonly world = Array.from({ length: 21 }, () => new THREE.Vector3());
MeshStandardMaterial è un materiale "fisico": reagisce alle luci, quindi le
sfere sembrano volumi e non cerchi piatti. Le punte delle dita usano un materiale che emette
luce propria, così restano leggibili anche quando finiscono in ombra.
Come si piazza un osso
Questo è l'unico pezzo di matematica vera del disegno. Hai un cilindro alto 1 e largo 1, centrato nell'origine e orientato lungo l'asse Y. Devi portarlo da un punto A a un punto B. Tre operazioni:
const up = new THREE.Vector3(0, 1, 0);
const dir = new THREE.Vector3();
HAND_CONNECTIONS.forEach(({ start, end }, i) => {
const a = this.world[start];
const b = this.world[end];
const bone = this.bones[i];
dir.subVectors(b, a);
const len = dir.length();
// 1. POSIZIONE: il punto medio fra A e B
bone.position.addVectors(a, b).multiplyScalar(0.5);
// 2. ROTAZIONE: quella che porta l'asse Y (verso naturale del cilindro) sulla direzione A→B.
// `divideScalar(len)` normalizza la direzione; il controllo evita la divisione per zero
// quando due punti coincidono — cosa che capita nei primi fotogrammi.
if (len > 1e-6) bone.quaternion.setFromUnitVectors(up, dir.divideScalar(len));
// 3. SCALA: raggio sui lati, lunghezza del segmento in altezza
bone.scale.set(boneR, len, boneR);
});
setFromUnitVectors è la funzione che ti risparmia di imparare i quaternioni:
dati due versori, calcola la rotazione che porta il primo sul secondo. Tu devi solo
ricordarti che i due vettori vanno normalizzati, cioè lunghi 1 — ed è
proprio quello che fa divideScalar(len).
Dimensioni proporzionali alla mano
// Distanza polso → nocca del medio: è la "taglia" della mano in questo fotogramma.
const size = this.world[0].distanceTo(this.world[9]);
const jointR = size * 0.075;
const boneR = size * 0.05;
this.joints.forEach((j, i) => {
j.position.copy(this.world[i]);
// Polso un po' più grosso, punte leggermente più grandi: dà carattere alla figura.
j.scale.setScalar(FINGERTIPS.has(i) ? jointR * 1.15 : i === 0 ? jointR * 1.4 : jointR);
});
Se i raggi fossero costanti, la mano lontana sembrerebbe fatta di palloni e quella vicina di spilli. Legandoli a una distanza interna alla mano, la figura resta credibile a qualunque distanza dalla camera, gratis.
Il palmo
/** Polso (0), base del pollice (1) e le quattro nocche (5, 9, 13, 17). */
const PALM = [0, 1, 5, 9, 13, 17];
const palmGeo = new THREE.BufferGeometry();
palmGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(PALM.length * 3), 3));
// Un ventaglio di triangoli che parte dal polso:
// (polso, pollice, indice), (polso, indice, medio), e così via.
palmGeo.setIndex([0, 1, 2, 0, 2, 3, 0, 3, 4, 0, 4, 5]);
this.palm = new THREE.Mesh(palmGeo, this.palmMaterial);
// I vertici cambiano a ogni fotogramma, ma Three.js calcola il volume d'ingombro una volta
// sola. Senza questa riga, ogni tanto decide che il palmo è fuori inquadratura e lo salta.
this.palm.frustumCulled = false;
// …e a ogni fotogramma si riscrivono i sei vertici nel buffer già allocato:
const pos = this.palm.geometry.getAttribute("position") as THREE.BufferAttribute;
PALM.forEach((idx, k) => pos.setXYZ(k, this.world[idx].x, this.world[idx].y, this.world[idx].z));
pos.needsUpdate = true; // senza questo i dati non tornano alla scheda grafica
this.palm.geometry.computeVertexNormals(); // ricalcola l'orientamento per l'illuminazione
frustumCulled e needsUpdate
frustumCulled = false. Three.js, per non disegnare oggetti
fuori inquadratura, confronta un volume d'ingombro calcolato una volta sola. Se i vertici
si spostano, quel volume diventa sbagliato e il palmo sparisce a intermittenza.
Sintomo confondente: sembra un problema di trasparenza.
pos.needsUpdate = true. Senza, Three.js non rimanda i dati
alla scheda grafica: il palmo resta congelato nella posizione del primo fotogramma mentre
le dita si muovono. Anche questo è un classico.
Le dissolvenze
tick(dt: number): void {
// `dt * 12` rende la dissolvenza indipendente dagli fps: circa un decimo di secondo
// su qualunque macchina, che gira a 30, 60 o 144 Hz.
this.opacity += (this.targetOpacity - this.opacity) * Math.min(1, dt * 12);
if (this.opacity < 0.01 && this.targetOpacity === 0) this.opacity = 0;
// A zero l'oggetto si spegne del tutto: Three.js salta i gruppi non visibili.
this.group.visible = this.opacity > 0;
this.solid.opacity = this.opacity;
this.tips.opacity = this.opacity;
this.palmMaterial.opacity = this.opacity * 0.35; // il palmo resta comunque velato
}
Senza dissolvenza, ogni volta che il modello perde la mano per un fotogramma la figura scompare e riappare di colpo. Con un decimo di secondo di transizione, la stessa perdita diventa invisibile.
Adattarsi alla finestra
private resize(): void {
const w = this.canvas.clientWidth || window.innerWidth;
const h = this.canvas.clientHeight || window.innerHeight;
this.renderer.setSize(w, h, false); // false = non toccare la dimensione CSS
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix(); // OBBLIGATORIO dopo aver cambiato aspect o fov
// Quanto mondo entra in verticale alla distanza della mano.
const visibleH = 2 * this.camera.position.z * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
// Si sceglie il lato che "stringe" di più, così il rettangolo del video entra sempre tutto.
const fit = this.camera.aspect > this.frameAspect ? visibleH : (visibleH * this.camera.aspect) / this.frameAspect;
// Telefono in verticale: senza ingrandimento la mano sarebbe minuscola fra due bande vuote.
// Si accetta che vicino ai bordi laterali possa uscire dall'inquadratura.
const zoom = Math.min(1.4, Math.max(1, this.frameAspect / this.camera.aspect));
this.frameHeight = fit * 0.92 * zoom; // 0.92 = un margine perché la mano non tocchi i bordi
}
Se dimentichi updateProjectionMatrix() la scena resta disegnata con le
proporzioni vecchie: la mano appare schiacciata o stirata dopo ogni ridimensionamento della
finestra. È il primo posto dove guardare quando "il 3D è storto".
6 Farla sembrare fluida
A questo punto funziona tutto e sembra fatto male. Tre problemi, tre cure — e la terza è la più elegante di tutto il progetto.
- src/filter.ts
- src/main.ts
Problema 1: i punti tremano
Tieni la mano perfettamente ferma e guarda i puntini nell'anteprima: ballano. Non è un tuo difetto, è il modello: ogni fotogramma è un'immagine leggermente diversa (rumore del sensore, compressione, illuminazione che cambia di poco), e la rete neurale risponde con numeri leggermente diversi. Sullo schermo, una mano che vibra.
La cura ovvia, e perché non basta
La media con i fotogrammi precedenti. Toglie il tremolio, ma introduce ritardo: muovi la mano di scatto e quella sullo schermo arriva dopo, come se fosse nella melassa. È il compromesso classico fra stabilità e reattività, e con un filtro a forza fissa non si può vincere: se filtri abbastanza da fermare il tremolio, filtri troppo per inseguire un movimento.
Il One Euro filter, spiegato a parole
L'idea (Casiez, Roussel, Vogel — CHI 2012) è sfacciatamente semplice: cambia la forza del filtro in base a quanto il punto si sta muovendo.
- punto quasi fermo → filtra molto → niente vibrazione;
- punto veloce → filtra poco → niente ritardo percepibile.
Funziona perché i due difetti non si presentano mai insieme. Quando la mano è ferma, il ritardo non si vede (non c'è niente che si muove per cui sia in ritardo). Quando la mano corre, il tremolio non si vede (è coperto dal movimento). Il filtro sfrutta proprio questo: corregge sempre il difetto che in quel momento è visibile.
Il nome viene dal titolo dello studio: un filtro che «costa un euro», cioè semplicissimo.
Il codice, in tre pezzi
/**
* Filtro passa-basso elementare: `y = α·x + (1-α)·y_precedente`.
* Con α vicino a 1 segue fedelmente il valore; vicino a 0 lo insegue lentamente.
*/
class LowPass {
private y: number | null = null;
filter(x: number, alpha: number): number {
this.y = this.y === null ? x : alpha * x + (1 - alpha) * this.y;
return this.y;
}
last(): number | null {
return this.y;
}
}
/**
* Converte una "frequenza di taglio" in hertz nel coefficiente α del passa-basso, tenendo
* conto di quanto tempo è passato DAVVERO fra due fotogrammi: così il filtro si comporta
* allo stesso modo a 30 o a 60 fotogrammi al secondo.
*/
const alpha = (cutoff: number, dt: number) => 1 / (1 + 1 / (2 * Math.PI * cutoff * dt));
export class OneEuroFilter {
private x = new LowPass(); // filtra il valore
private dx = new LowPass(); // filtra la sua velocità
private lastTime: number | null = null;
constructor(minCutoff = 1.2, beta = 8, dCutoff = 1) { /* … */ }
filter(value: number, timeMs: number): number {
const prev = this.x.last();
// Tempo trascorso in secondi. Al primo giro si ipotizzano 30 fps; il minimo di 1 ms
// evita una divisione per zero se arrivassero due misure con lo stesso istante.
const dt = this.lastTime === null ? 1 / 30 : Math.max((timeMs - this.lastTime) / 1000, 1e-3);
this.lastTime = timeMs;
// Velocità stimata del punto (unità al secondo), a sua volta filtrata perché
// altrimenti salterebbe come il dato grezzo da cui è calcolata.
const derivative = prev === null ? 0 : (value - prev) / dt;
const edx = this.dx.filter(derivative, alpha(this.dCutoff, dt));
// ← IL CUORE DEL METODO: la soglia di filtro cresce con la velocità.
const cutoff = this.minCutoff + this.beta * Math.abs(edx);
return this.x.filter(value, alpha(cutoff, dt));
}
}
Tre parametri, tre manopole:
| Parametro | Valore | Cosa regola |
|---|---|---|
minCutoff |
1.2 | quanto filtrare a mano ferma: più basso = più stabile ma più lento |
beta |
8 | quanto "aprire" il filtro con la velocità: più alto = insegue meglio i movimenti rapidi |
dCutoff |
1 | filtro sulla stima della velocità, perché non saltelli |
A occhio, in quest'ordine. Prima metti beta a 0 e abbassa
minCutoff finché la mano ferma non è perfettamente stabile (sarà lentissima
nei movimenti). Poi alza beta finché i movimenti rapidi tornano immediati. Se
ricompare il tremolio, sei andato troppo su.
Applicarlo ai 21 punti
/**
* Ogni coordinata ha il proprio filtro indipendente: 21 punti × 3 assi = 63 filtri,
* ognuno con due numeri di stato. Costa niente e mantiene il codice banale.
*/
export class LandmarkSmoother {
private filters = Array.from({ length: 21 * 3 }, () => new OneEuroFilter());
smooth(points: { x: number; y: number; z: number }[], timeMs: number) {
return points.map((p, i) => ({
x: this.filters[i * 3].filter(p.x, timeMs),
y: this.filters[i * 3 + 1].filter(p.y, timeMs),
z: this.filters[i * 3 + 2].filter(p.z, timeMs),
}));
}
}
Se riusi lo stesso LandmarkSmoother quando la mano riappare, il filtro
"ricorda" dove stava prima e la mano arriva strisciando dalla vecchia posizione.
Un effetto molto strano da vedere e difficilissimo da attribuire al filtro.
Problema 2: la mano fantasma
Nei cambi bruschi d'immagine, MediaPipe ogni tanto "vede" per un solo fotogramma una seconda mano che non c'è. Nei test di questo progetto succedeva 2 volte su 117: sullo schermo era un lampo rosa fastidioso.
La cura è aspettare una conferma:
const CONFIRM_FRAMES = 3;
const streak: Record<Side, number> = { Right: 0, Left: 0 };
result.landmarks.forEach((landmarks, i) => {
let side = realSide(result.handedness[i]?.[0]?.categoryName);
// Se MediaPipe attribuisce lo stesso lato a entrambe le mani (succede raramente), la
// seconda finisce nell'altro contenitore: meglio un'etichetta sbagliata che una mano
// che sparisce.
if (seen.has(side)) side = side === "Right" ? "Left" : "Right";
seen.add(side);
streak[side]++;
// Il filtro lavora ANCHE durante i fotogrammi di conferma, così quando la mano appare
// è già stabile invece di assestarsi sotto gli occhi di chi guarda.
smoothers[side] ??= new LandmarkSmoother();
const smoothed = smoothers[side]!.smooth(landmarks, now);
if (streak[side] < CONFIRM_FRAMES) return; // non ancora confermata: niente da disegnare
scene.update(side, smoothed);
// …
});
// Le mani non viste in questo fotogramma svaniscono e i loro contatori si azzerano.
for (const side of ["Right", "Left"] as Side[]) {
if (!seen.has(side)) {
streak[side] = 0;
scene.update(side, null);
smoothers[side] = null;
}
}
Costo: un decimo di secondo di ritardo alla comparsa della mano, che non si nota. Beneficio: zero mani fantasma. Nota il dettaglio del filtro che gira anche durante i fotogrammi di conferma — è gratis e fa comparire la mano già ferma.
Problema 3: destra e sinistra sono scambiate
/**
* MediaPipe decide destra/sinistra dando per scontato che l'immagine sia GIÀ SPECCHIATA,
* come in una videochiamata. Il fotogramma grezzo della webcam però non lo è, quindi la
* sua etichetta va ribaltata.
*/
function realSide(label: string | undefined): Side {
return label === "Left" ? "Right" : "Left";
}
Non guardandolo: guardando la propria mano è facilissimo convincersi che sia giusto. Serve una prova indipendente. In questo progetto è stata una foto di una mano sinistra presa dai test di MediaPipe, data in pasto al modello: la libreria rispondeva "Right". Fine della discussione.
Al capitolo 8 questo controllo è diventato uno degli otto test automatici, proprio perché è un bug facilissimo da reintrodurre senza accorgersene.
Un'ultima sottigliezza: chi usa i punti filtrati
// 1) la mano 3D, con i punti ADDOLCITI dal filtro
scene.update(side, smoothed);
// 2) l'anteprima 2D, con i punti GREZZI: lì serve la corrispondenza esatta con l'immagine
drawing.drawConnectors(landmarks, HAND_CONNECTIONS, { color: side === "Right" ? "#38bdf8" : "#f472b6", lineWidth: 3 });
drawing.drawLandmarks(landmarks, { color: "#ffffff", radius: 2.5, lineWidth: 1 });
Nell'anteprima i puntini stanno sopra l'immagine della webcam: se fossero filtrati, si vedrebbero scivolare rispetto alle dita vere, e sembrerebbe un errore di calibrazione. Nel 3D non c'è niente con cui confrontarli, e il filtro è puro guadagno.
↑ Indice7 Andare online
Serve HTTPS (la webcam non parte altrimenti) e serve una regola che renda verificabile, e non solo promessa, la frase "le immagini non escono dal tuo browser".
- vite.config.ts
- server.mjs
- Caddyfile
La build
npm run build
# 1. prepare-assets.mjs → wasm e modello in public/mediapipe/
# 2. tsc → controlla i tipi, non produce niente
# 3. vite build → impacchetta, minifica e scrive in dist/
tsc in mezzo, se non produce file
Perché Vite non controlla i tipi: li cancella e basta, che è il motivo per cui è veloce. Senza quel passaggio, un errore di tipo passerebbe in produzione e diventerebbe un errore a runtime. Metterlo nella catena della build significa che una build con errori di tipo semplicemente non si completa.
In dist/ trovi l'index.html, una cartella assets/ con
i file firmati dal contenuto e la copia di public/. È tutto statico: qualunque
server può servirlo.
Un server statico di ottanta righe
Se puoi far leggere la cartella direttamente al tuo reverse proxy, fallo e salta questo
paragrafo. In questo progetto non si poteva: il proxy gira con un utente che non può entrare
nella cartella del progetto, e copiare il sito altrove avrebbe sparpagliato pezzi del
progetto fuori dalla sua cartella. Quindi: un processo Node che serve
dist/ e ascolta solo sulla macchina stessa.
import { createReadStream, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
const ROOT = resolve(process.env.ROOT ?? "app/dist");
const PORT = +(process.env.PORT ?? 8791);
/** Solo interfaccia locale: nessuno può raggiungere questo processo scavalcando il proxy. */
const HOST = process.env.HOST ?? "127.0.0.1";
/**
* Il tipo dichiarato per ogni estensione. Non è un dettaglio: il file .wasm deve arrivare
* come `application/wasm`, altrimenti il browser si rifiuta di compilarlo al volo e
* MediaPipe non parte affatto.
*/
const TYPES = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".wasm": "application/wasm",
".task": "application/octet-stream", // il modello dei gesti
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
// SICUREZZA: si ricostruisce il percorso assoluto e si pretende che stia dentro ROOT.
// `normalize` risolve i "..", il controllo blocca i tentativi tipo /../../etc/passwd.
// È l'unico punto davvero delicato di questo file.
let path = resolve(join(ROOT, normalize(decodeURIComponent(url.pathname))));
if (path !== ROOT && !path.startsWith(ROOT + "/")) {
res.writeHead(403).end("Forbidden");
return;
}
let info;
try {
info = statSync(path);
if (info.isDirectory()) { path = join(path, "index.html"); info = statSync(path); }
} catch {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Non trovato");
return;
}
// ETag da dimensione e data: se il browser ha già il file identico rispondiamo 304 e
// non rispediamo 11 MB di WebAssembly a ogni ricarica.
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`;
if (req.headers["if-none-match"] === etag) { res.writeHead(304).end(); return; }
res.writeHead(200, {
"content-type": TYPES[extname(path)] ?? "application/octet-stream",
"content-length": info.size,
etag,
// I file in assets/ hanno l'impronta del contenuto nel nome: se cambiano, cambia il
// nome, quindi si possono tenere in cache per sempre.
"cache-control": path.includes("/assets/") ? "public, max-age=31536000, immutable" : "public, max-age=300",
});
createReadStream(path).pipe(res); // a pezzi: nessun file grande tenuto in memoria
});
server.listen(PORT, HOST);
.wasm
Se il server risponde application/octet-stream per un file
.wasm, il browser rifiuta di compilarlo al volo e MediaPipe non parte. Il
messaggio d'errore parla di streaming compilation e non nomina mai il content-type: è uno
dei quindici minuti persi più frequenti in questo genere di progetti.
Il reverse proxy, con HTTPS
Davanti al server statico mettiamo Caddy, che si occupa del certificato (lo ottiene e lo rinnova da solo), della compressione e delle intestazioni di sicurezza. La catena completa:
internet → Caddy (HTTPS, intestazioni) → 127.0.0.1:8791 → server.mjs → dist/
mano.esempio.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8791
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
# La camera solo per questa origine; microfono e geolocalizzazione per nessuno.
Permissions-Policy "camera=(self), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
}
# Nessun dato personale transita dal server, ma non serve nemmeno tenere log degli accessi.
log {
output discard
}
}
La CSP, direttiva per direttiva
| Direttiva | Cosa fa |
|---|---|
default-src 'none' |
parti dal divieto totale, poi riapri solo ciò che serve |
script-src 'self' 'wasm-unsafe-eval' |
JavaScript solo dal tuo dominio; il permesso wasm serve a compilare il runtime di
MediaPipe e non abilita eval() di JavaScript
|
style-src 'self' |
solo fogli di stile tuoi: niente <style> in pagina, niente style="…" |
img-src 'self' data: blob: |
immagini tue, o generate dalla pagina stessa |
media-src 'self' blob: |
il flusso della webcam arriva alla pagina come blob:: senza questo l'anteprima resta nera |
connect-src 'self' |
la riga che conta — vedi sotto |
frame-ancestors 'none' |
nessuno può incorniciare il tuo sito dentro il proprio per rubare i clic |
connect-src 'self' è la vera garanzia
Fino a qui, "le immagini della webcam non escono dal browser" è una promessa: tu hai letto
il codice e non hai trovato fetch, e ti fidi. Ma nel sito ci sono anche
centinaia di kilobyte di libreria compilata che nessuno legge riga per riga.
connect-src 'self' cambia la natura dell'affermazione. Dice al browser:
questa pagina può fare richieste di rete solo verso il proprio dominio. Un
fetch verso qualunque altro indirizzo — scritto da te per errore, nascosto in
una dipendenza, iniettato da un attacco — viene bloccato dal browser e
segnalato nella console. Non è più il codice a promettere: è il browser a rifiutare.
È una promessa che chiunque può verificare in trenta secondi, senza leggere il tuo codice: apre gli strumenti di sviluppo, scheda Rete, e guarda che non ci sia nulla verso l'esterno.
La telemetria nascosta
Ed ecco un caso reale in cui quella regola ha guadagnato il suo posto.
@mediapipe/tasks-vision crea dentro ogni task un registratore che ogni 60
secondi manda a un endpoint di Google alcune metriche d'uso: tipo di task, modalità, tempi,
sistema operativo. Nessuna immagine — ma è comunque una chiamata a terzi, e la libreria non
offre alcun modo per spegnerla. L'abbiamo scoperta proprio perché la CSP l'ha
bloccata e il browser l'ha segnalata in console.
function disableMediapipeTelemetry(): Plugin {
const pattern = "flush(t,e){if(this.error)";
return {
name: "disable-mediapipe-telemetry",
enforce: "pre", // prima degli altri plugin: il codice dev'essere ancora l'originale
transform(code, id) {
if (!id.includes("@mediapipe/tasks-vision/vision_bundle")) return null;
if (!code.includes(pattern)) {
// Se un aggiornamento cambia quella stringa, la build si FERMA con un messaggio
// chiaro, invece di lasciar passare in silenzio la telemetria riaccesa. È voluto.
this.error("telemetria MediaPipe: pattern non trovato, ricontrollare la patch");
}
// `this.h=[]` svuota la coda dei messaggi, `t?.()` avvisa il chiamante che è andata bene.
// Dopo la sostituzione il minificatore si accorge che il resto della funzione è
// irraggiungibile e cancella anche l'indirizzo.
return { code: code.replace(pattern, "flush(t,e){this.h=[];t?.();return;if(this.error)"), map: null };
},
};
}
export default defineConfig({
plugins: [disableMediapipeTelemetry()],
// Di norma Vite pre-impacchetta le dipendenze prima di servirle in sviluppo, e in quella
// fase i plugin di trasformazione non passano. Escludendo MediaPipe, la patch vale anche
// con `npm run dev`.
optimizeDeps: { exclude: ["@mediapipe/tasks-vision"] },
build: { chunkSizeWarningLimit: 900 },
});
Sostituire una stringa esatta dentro un file minificato è fragile e lo sappiamo. Ma la
fragilità qui è una funzionalità: il giorno che la libreria cambia quella stringa,
la build fallisce con un messaggio esplicito e qualcuno va a guardare. L'alternativa
silenziosa — un try/catch che ignora il problema — riaccenderebbe la
telemetria senza che nessuno lo sappia mai.
Controlli automatici prima di pubblicare
La pubblicazione è uno script che fa la build, poi controlla, e si ferma al primo controllo fallito lasciando online la versione precedente, che funziona:
✓ nessuno <script> o style= scritto dentro le pagine di dist/
✓ nessun indirizzo esterno nei file prodotti
✓ nessuna traccia dell'endpoint di telemetria
✓ wasm e modello presenti e della dimensione attesa
✓ la configurazione del proxy è valida
✓ il servizio risponde dopo il riavvio
Sono sei grep e due curl. Valgono più di qualunque promemoria
mentale, perché non si dimenticano mai.
8 Verificare senza webcam
Il progetto vive su un server senza schermo e senza telecamera. E la cosa più importante da verificare è proprio «la camera si accende e la mano viene seguita».
- tests/e2e/run.mjs
- tests/e2e/make-video.sh
Le cose che possono rompersi qui sono: i permessi della camera, il caricamento del WebAssembly, la CSP, WebGL. Nessuna di queste esiste fuori da un browser vero. Simularle con dei mock darebbe un test che passa mentre il sito è guasto — il peggiore dei mondi possibili, perché ti fa anche stare tranquillo.
Pezzo 1: una webcam finta
Chromium sa usare un file video al posto della telecamera. Basta costruire un filmato MJPEG con una mano che si muove — in questo progetto quattro foto pubbliche dai test di MediaPipe (vittoria, pollice su, indice in su, pugno) montate con ffmpeg in modo che la mano oscilli sullo sfondo — e passarlo al browser all'avvio:
--use-fake-ui-for-media-stream # concede il permesso senza mostrare il pop-up
--use-fake-device-for-media-stream # usa una telecamera simulata…
--use-file-for-fake-video-capture=…/hands.mjpeg # …alimentata dal filmato
--enable-unsafe-swiftshader # WebGL via software: il server non ha scheda grafica
Pezzo 2: lo stesso proxy della produzione
// Si riusa il frammento di configurazione DI PRODUZIONE cambiando due sole cose: il nome
// del sito diventa localhost su HTTP (niente certificati nel test). Tutte le intestazioni
// restano identiche: è il punto di questo test.
const snippet = readFileSync(join(app, "../deploy/Caddyfile.snippet"), "utf8")
.replace(/^mano\.esempio\.com \{/m, `http://localhost:${PORT} {`);
writeFileSync(join(cache, "Caddyfile"), `{\n admin off\n}\n${snippet}`);
const caddy = spawn("caddy", ["run", "--adapter", "caddyfile", "--config", join(cache, "Caddyfile")]);
Così anche la CSP è quella vera: se una regola di sicurezza rompe il sito, lo scopri qui e non il giorno della pubblicazione.
Pezzo 3: Playwright, e una porta di servizio
Un test non può "vedere" una scena 3D. Quindi la pagina, e solo quando glielo si chiede,
scrive quello che sta vedendo dentro attributi data-*:
const E2E = new URLSearchParams(location.search).has("e2e");
// …dentro il ciclo:
if (E2E) {
document.body.dataset.hands = String(shown.length);
document.body.dataset.sides = shown.join(",");
document.body.dataset.gestures = shownGestures.join(",");
document.body.dataset.wrist = result.landmarks[0] ? result.landmarks[0][0].x.toFixed(3) : "";
}
Perché sono pur sempre dati sulla mano di chi usa il sito, e qualunque estensione del
browser potrebbe leggerli dalla pagina. Una porta di servizio per i test va aperta solo
quando serve, e la chiave è nell'indirizzo: ?e2e.
Cosa vale la pena controllare
Otto verifiche. Nessuna riguarda "la funzione X restituisce Y": tutte riguardano l'applicazione funziona.
✓ la risposta ha la CSP di produzione
✓ la camera si accende e il modello si carica
✓ almeno 10 rilevazioni con la mano
✓ almeno 3 dei 4 gesti del video sono riconosciuti
✓ la mano del video (sinistra) è indicata come sinistra
✓ il movimento viene seguito (il polso si sposta di oltre 0,15)
✓ dopo «Spegni camera» lo stream è rilasciato
✓ nessuna richiesta verso altri domini, nessun errore in console
| Controllo | Cosa protegge |
|---|---|
| la mano sinistra è chiamata sinistra | l'inversione del capitolo 6, facilissima da rompere senza accorgersene |
| il polso si sposta di oltre 0,15 | che l'applicazione segua, non solo che riconosca: un bug nel ciclo dei fotogrammi darebbe una mano corretta ma immobile |
| lo stream è rilasciato | la spia della webcam che resta accesa dopo lo spegnimento |
| zero richieste esterne | la promessa sulla riservatezza, misurata invece che dichiarata |
const external = []; // richieste verso domini diversi da localhost: devono restare zero
page.on("request", (r) => {
const u = new URL(r.url());
if (u.protocol.startsWith("http") && u.hostname !== "localhost") external.push(r.url());
});
// …alla fine:
check(external.length === 0, `nessuna richiesta esterna (trovate: ${external.length})`);
Il test finisce stampando E2E OK e lascia una schermata su disco, da guardare a
occhio: l'automazione verifica i numeri, l'occhio verifica che non sia venuta fuori una mano
viola a rovescio.
9 Gli errori in cui cadrai
Non è pessimismo: sono i problemi veri incontrati costruendo questa applicazione, con il sintomo da riconoscere. Quasi ogni riga strana del codice viene da uno di questi.
| Sintomo | Causa | Cura |
|---|---|---|
| La mano destra viene chiamata sinistra | MediaPipe assume che l'immagine sia già specchiata, come in una videochiamata; il fotogramma grezzo della webcam non lo è |
realSide() che ribalta l'etichetta, e un test con una foto di mano nota
(capitolo 6)
|
| Un lampo di seconda mano che non esiste | nei cambi bruschi d'immagine il modello "vede" per un fotogramma una mano fantasma — 2 volte su 117 nei test | conferma su 3 fotogrammi consecutivi prima di disegnare (capitolo 6) |
| Tutto lentissimo: 3-5 secondi per fotogramma | GPU emulata (macchina virtuale, server senza scheda grafica, driver problematico): la GPU è più lenta della CPU |
misura del tempo medio e passaggio automatico a CPU oltre i 150 ms; ?delegate=cpu
per forzare (capitolo 3)
|
| Il palmo sparisce e riappare a intermittenza | Three.js calcola il volume d'ingombro una volta sola, ma i vertici del palmo cambiano a ogni fotogramma: ogni tanto decide che è fuori inquadratura | palm.frustumCulled = false (capitolo 5) |
| Il palmo resta fermo mentre le dita si muovono | i dati modificati non vengono rimandati alla scheda grafica | pos.needsUpdate = true dopo aver scritto i vertici (capitolo 5) |
| La mano è sottosopra, o si muove al contrario |
mancata conversione fra la y verso il basso delle immagini e la
y verso l'alto della grafica 3D, oppure specchio dimenticato sulla
x
|
lo 0.5 - in toWorld() (capitolo 5) |
| La mano appare schiacciata dopo aver ridimensionato la finestra | la telecamera virtuale continua a proiettare con le proporzioni vecchie | camera.updateProjectionMatrix() dopo ogni cambio di aspect |
| Richieste bloccate verso un dominio di Google | la telemetria nascosta di MediaPipe, che parte da sola ogni 60 secondi | il plugin Vite che svuota la funzione di invio (capitolo 7) |
| MediaPipe non parte, l'errore parla di compilazione streaming | il server risponde con il content-type sbagliato per il file .wasm |
application/wasm nella tabella dei tipi del server (capitolo 7) |
Cannot read properties of undefined su
navigator.mediaDevices
|
la pagina non è in contesto sicuro: http:// su un indirizzo IP |
HTTPS, oppure localhost in sviluppo (capitolo 2) |
| La spia della webcam resta accesa dopo lo "spegni" | è stato fermato l'elemento <video>, non il flusso |
stream.getTracks().forEach((t) => t.stop()) (capitolo 2) |
| L'applicazione consuma il doppio e la mano scatta | due cicli requestAnimationFrame in parallelo, da un doppio avvio |
cancelAnimationFrame(rafId) prima di far ripartire il ciclo (capitolo 4) |
| La mano arriva "strisciando" quando riappare | il filtro riusato si ricorda la posizione precedente | buttare il filtro quando la mano sparisce (capitolo 6) |
| Sul telefono la mano è minuscola fra due bande vuote | il video è 4:3 orizzontale, lo schermo è verticale: l'area utile si restringe moltissimo | ingrandimento fino a 1,4× in verticale, e una media query a 600px (capitolo 5) |
Proporzioni NaN nella scena, a volte |
video.videoWidth letto prima che i metadati fossero disponibili
|
attendere loadedmetadata (capitolo 2) |
Guarda quanti di questi bug non sono errori di logica, ma assunzioni tacite che non valgono: "l'immagine è specchiata", "i vertici non si spostano", "la GPU è più veloce", "le dimensioni del video sono già pronte". È il genere di problema che non si previene leggendo il codice: si previene provandolo su una macchina diversa dalla propria.
10 Dove andare avanti
Hai una pipeline che funziona: video → punti → filtro → figura. Cambiare il pezzo centrale apre un intero catalogo.
Gli altri task di MediaPipe
La stessa libreria @mediapipe/tasks-vision contiene molto più del riconoscitore
di mani, e tutti seguono la identica forma: FilesetResolver,
createFromOptions, …ForVideo(video, timestamp).
| Task | Cosa restituisce | Idea |
|---|---|---|
PoseLandmarker |
33 punti del corpo: testa, spalle, gomiti, anche, ginocchia, caviglie | un pupazzo a stecchi che ti imita. È la seconda demo di questo sito: la pagina Corpo |
FaceLandmarker |
478 punti del viso, più 52 "blendshape" (sorriso, occhio chiuso…) | una maschera che segue la mimica, o un avatar che ti copia |
ImageSegmenter |
una maschera che separa la persona dallo sfondo | sfondo sfocato o sostituito, come nelle videochiamate |
ObjectDetector |
riquadri ed etichette degli oggetti inquadrati | contare cose, o far reagire la scena a un oggetto |
Un file: quello che parla con MediaPipe. Il ciclo dei fotogrammi, il filtro anti-tremolio, la conversione delle coordinate e l'impalcatura della scena 3D restano identici — il filtro diventa 33×3 invece di 21×3, e la tabella delle connessioni cambia. È esattamente il vantaggio che si compra separando i moduli al capitolo 1: la fatica la fai una volta.
Esercizi, dal più facile
-
Cambia i colori. Una costante in
hand3d.ts. Serve a prendere confidenza senza rischi. - Aggiungi un gesto tuo. Senza addestrare nulla: guarda le distanze fra le punte delle dita e il polso. "Tutte le punte lontane dal palmo e il pollice staccato" è una mano aperta; "punta dell'indice vicinissima a quella del pollice" è il gesto OK.
-
Fai reagire la scena. Con il pugno chiuso la mano diventa rossa, con la
vittoria compaiono delle particelle. Il valore è già lì, in
result.gestures[i][0].categoryName. - Misura la distanza pollice-indice e usala come cursore: alza e abbassa un valore stringendo le dita. È il classico "pinch" senza touchscreen.
- Disegna nell'aria. Traccia una scia dietro la punta dell'indice, e cancella con il pugno. Serve una lista di punti passati e una geometria a linea.
- Mano + corpo insieme. Due riconoscitori sullo stesso fotogramma. Attenzione: raddoppia il costo, ed è il momento in cui il contatore degli fps del capitolo 4 diventa il tuo migliore amico.
Documentazione ufficiale
- MediaPipe — Gesture Recognizer per il web le opzioni del riconoscitore, la forma esatta dei risultati, i gesti previsti
- MediaPipe — Hand Landmarker la mappa dei 21 punti, se i gesti non ti servono
- MediaPipe — Pose Landmarker i 33 punti del corpo intero: il prossimo passo naturale
-
MDN —
getUserMedia()vincoli, errori, contesto sicuro: la fonte da cui viene il capitolo 2 - MDN — Content Security Policy tutte le direttive, con esempi di cosa si rompe se sbagli
- Three.js — il manuale scena, telecamere, materiali, luci: spiegato con esempi interattivi
-
Vite — la guida
configurazione, build, cartella
public/, plugin - Playwright — documentazione per scrivere il test del capitolo 8
- Caddy — il Caddyfile reverse proxy, HTTPS automatico, intestazioni
- One Euro filter — lo studio originale la matematica del capitolo 6, con una demo interattiva
Non costruire tutta la catena prima di provarla. Fai partire la webcam e fermati lì. Poi stampa in console il numero di mani viste, e fermati lì. Poi disegna i puntini in 2D, e fermati lì. Ogni pezzo che funziona da solo è un pezzo che non dovrai sospettare quando qualcosa andrà storto — e qualcosa andrà storto, perché il capitolo 9 esiste.