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.

Undici capitoli · codice vero, quello che gira davvero qui · serve un po' di JavaScript e nient'altro

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
Perché conta l'ordine

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

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
Prova tu

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.

↑ Indice

1 Preparare il progetto

Dieci minuti di impalcatura, e poi non ci pensi più.

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.

Perché Vite, e perché "vanilla"

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
Perché cinque file e non uno

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.

index.html
<!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>
Attenzione

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.

src/main.ts
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é raccogliere gli elementi in un oggetto

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.

↑ Indice

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.

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.

src/camera.ts
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
  throw new CameraError("La camera funziona solo su una pagina HTTPS.");
}
Conseguenza pratica

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

src/camera.ts
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
});
Perché 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.

src/camera.ts
/** 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
Perché una classe 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

src/camera.ts
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

L'errore numero uno

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.

src/camera.ts
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:

src/main.ts
// 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.

Prova tu

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.

↑ Indice

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.

La libreria

npm install @mediapipe/tasks-vision@1.0.1
Perché MediaPipe, e perché la versione è fissata esatta

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.

Perché la 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

src/tracker.ts
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.

Perché 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

src/tracker.ts
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:

forma del risultato
{
  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
Nonenessuno dei precedenti → si mostra solo ✋
src/tracker.ts
/** 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.

src/tracker.ts — creazione con ripiego
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…

src/tracker.ts — misura del tempo
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:

src/main.ts — rete di sicurezza
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:

  1. 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.
  2. Stabilità. @latest significa "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.

scripts/prepare-assets.mjs
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ù:

package.json
"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"
}
Perché 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.

↑ Indice

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:

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.

Riassunto delle frequenze

~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)

src/main.ts
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

src/main.ts
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");
  }
}
Il bug dei due cicli

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.

src/main.ts
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

src/main.ts
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é vale la pena scrivere questa funzione noiosa

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.

↑ Indice

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.

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.

src/main.ts
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:

src/tracker.ts
// 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:

src/style.css
.preview video,
.preview canvas {
  object-fit: cover;
  transform: scaleX(-1);   /* effetto specchio, SOLO visivo */
}
Ricordatelo, servirà fra poco

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
Perché Three.js e non WebGL "nudo"

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

src/hand3d.ts
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);
Perché tre luci e non una

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:

src/hand3d.ts
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:

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

L'idea che tiene in piedi tutto il file

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.

src/hand3d.ts
// 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:

src/hand3d.ts
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

src/hand3d.ts
// 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

src/hand3d.ts
/** 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
Due righe che salvano ore: 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

src/hand3d.ts
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

src/hand3d.ts
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".

↑ Indice

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.

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.

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

src/filter.ts — 1/3: il passa-basso elementare
/**
 * 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;
  }
}
src/filter.ts — 2/3: da hertz a α
/**
 * 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));
src/filter.ts — 3/3: il cuore
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
Come si tarano

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

src/filter.ts
/**
 * 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),
    }));
  }
}
Un'istanza per mano, buttata quando la mano sparisce

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:

src/main.ts
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

src/main.ts
/**
 * 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";
}
Come ci si accorge di un bug del genere

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

src/main.ts
// 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.

↑ Indice

7 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".

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/
Perché 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.

server.mjs — le parti che contano
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);
Il tipo del .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/
Caddyfile
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
Perché 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.

vite.config.ts
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 },
});
Una patch fragile, di proposito

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.

↑ Indice

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».

Perché non test unitari

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

tests/e2e/run.mjs
// 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-*:

src/main.ts
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é è spento fuori dai test

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
tests/e2e/run.mjs — la prova sulla riservatezza
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.

↑ Indice

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)
Il filo comune

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.

↑ Indice

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
Cosa cambia davvero passando al corpo intero

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

  1. Cambia i colori. Una costante in hand3d.ts. Serve a prendere confidenza senza rischi.
  2. 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.
  3. 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.
  4. Misura la distanza pollice-indice e usala come cursore: alza e abbassa un valore stringendo le dita. È il classico "pinch" senza touchscreen.
  5. 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.
  6. 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

Un consiglio finale

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.

↑ Indice