Objetivo da Aula
Implementar um pipeline de detecção de gestos usando landmarks corporais com MediaPipe
Calcular métricas geométricas (distâncias, posições relativas) a partir de coordenadas de landmarks
Comparar arquitetura de ML local (browser) vs ML via API para casos de uso de tempo real
Aplicar model quantization e Web Workers para manter UI responsiva com inference em tempo real
Projetar um classificador de gestos baseado em regras geométricas a partir de landmarks
Por que isso importa
ML na web saiu de laboratório acadêmico para ser a base de produtos que bilhões de pessoas usam todos os dias: filtros de câmera do Instagram/Snapchat detectam face em tempo real; Google Meet aplica blur de fundo usando pose estimation; Apple FaceID usa um modelo ML correndo no Secure Enclave do iPhone sem internet.
O que esses sistemas têm em comum com o lab desta unidade: 1. ML local — sem roundtrip de rede, sem servidor, privacidade garantida 2. Latência sub-10ms — impossível com API remota para tempo real 3. Landmarks como representação intermediária — a câmera não entende “punho”, mas entende 21 pontos com coordenadas
Para você como dev: a diferença entre usar MediaPipe para classificar gestos e usar Claude para classificar sentimentos é a mesma diferença entre usar regras geométricas locais vs raciocínio contextual remoto. Você vai precisar dos dois tipos, e saber quando usar cada um é uma das habilidades mais valiosas de um engenheiro de IA aplicada.
Conceitos Fundamentais
O que é um Landmark?
Um landmark é um ponto de referência detectado em uma imagem — uma articulação, extremidade, ou ponto de interesse anatômico — com coordenadas no espaço da imagem:
Landmark de mão:
{
x: 0.35, // 35% da largura da imagem (0 = esquerda, 1 = direita)
y: 0.65, // 65% da altura da imagem (0 = topo, 1 = base)
z: -0.02, // profundidade relativa ao pulso (negativo = mais próximo da câmera)
}MediaPipe detecta 21 landmarks por mão, cada um correspondendo a uma articulação ou ponta de dedo específica:
Mapa de landmarks da mão (MediaPipe):
8 12 16 20
| | | |
7 | 11 | 15 | 19 |
| | | |
6 | 10 | 14 | 18 |
\ 9 / \ 13 / \ 17 /
\ / \ / \ /
4 5 -------- (palma) -------- 17
|
3
|
2
|
1
|
0 (WRIST)
Índices principais:
0 = WRIST (pulso)
4 = THUMB_TIP (ponta do polegar)
8 = INDEX_TIP (ponta do indicador)
12 = MIDDLE_TIP (ponta do médio)
16 = RING_TIP (ponta do anelar)
20 = PINKY_TIP (ponta do mínimo)
5, 9, 13, 17 = MCP joints (base dos dedos)Classificação de gestos por regras geométricas
Com 21 landmarks, podemos classificar gestos comparando posições relativas:
Dedo estendido: ponta do dedo (TIP) está acima do nó da base (MCP) — ou seja, tip.y < mcp.y (y aumenta para baixo no sistema de coordenadas da imagem)
function dedoEstendido(landmarks, tipIndex, mcpIndex) {
// y cresce para baixo — dedo estendido = ponta acima da base
return landmarks[tipIndex].y < landmarks[mcpIndex].y
}Mão aberta: todos os 4 dedos (indicador, médio, anelar, mínimo) estendidos
Punho: nenhum dos 4 dedos estendidos
Polegar para cima: polegar estendido + demais dedos fechados
Confidence Score: quando confiar no detector
MediaPipe retorna um score de confiança para cada detecção (0.0–1.0). Abaixo de ~0.5, o landmark pode ser ruído:
// Ignorar detecções com baixa confiança
if (resultado.score < 0.5) {
console.log('Detecção com baixa confiança — ignorando')
return null
}Em produção, você vai calibrar esse threshold com dados reais. Threshold alto = menos falsos positivos, mais falsos negativos. Para gesture control em UI, falsos positivos são piores (ações indesejadas).
Model Quantization: modelos menores para browser
Fundamento: Quantização de Modelos
Pesos de modelos são números com ponto flutuante. float32 usa 4 bytes por peso; float16 usa 2; int8 usa 1. Reduzir a precisão reduz memória proporcionalmente e aumenta throughput (operações int8 são mais rápidas em hardware especializado) com perda controlada de qualidade. Um modelo de 7B parâmetros em float32 ocupa ~28 GB de VRAM; em int8, ~7 GB. É a principal alavanca de FinOps para modelos self-hosted: você controla o trade-off qualidade/custo escolhendo a precisão de servir. Para modelos via API (Anthropic, OpenAI), quantização é responsabilidade deles — mas entender o conceito ajuda a avaliar modelos open-source para deploy próprio.
Modelos de ML são treinados em float32 (4 bytes por parâmetro). Quantization converte para int8 (1 byte):
Formato | Tamanho | Precisão | Velocidade |
float32 | 100% | Máxima | 1× |
float16 | 50% | Muito boa | 1.5× |
int8 | 25% | Boa | 2-4× |
MediaPipe usa modelos já quantizados para browser. O modelo de pose detection completo tem ~6 MB no disco — razoável para carregar via CDN.
Aprofundamento Técnico
Web Workers: inference sem travar a UI
JavaScript é single-threaded. Um modelo de ML processando frames de câmera no thread principal trava a UI (scroll, clicks, animações ficam lentos):
// ❌ Trava a UI durante inference
camera.addEventListener('frame', (frame) => {
const resultado = modelo.run(frame.imageData) // pode levar 20-50ms
desenharLandmarks(resultado)
// Durante esses 20ms, nada mais acontece na UI
})
// ✓ Inference em Web Worker separado
const worker = new Worker('inference.worker.js')
camera.addEventListener('frame', (frame) => {
// Envia para worker — não bloqueia
worker.postMessage({ imageData: frame.imageData })
})
worker.onmessage = (e) => {
// Recebe resultado do worker — também não bloqueia
desenharLandmarks(e.data.resultado)
}MediaPipe já usa Web Workers internamente. Você não precisa implementar manualmente — mas entender o porquê ajuda a diagnosticar problemas de performance.
Distância euclidiana vs posição relativa
Dois jeitos de comparar landmarks:
Posição relativa (mais robusto): comparar y.tip vs y.mcp não depende do tamanho da mão na imagem.
// Funciona com mão pequena (longe) e mão grande (perto)
const estendido = landmarks[TIP].y < landmarks[MCP].yDistância euclidiana (mais preciso, mais frágil): distância em pixels entre dois pontos depende do zoom.
function distancia(p1, p2) {
return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)
}
// Problema: distancia(thumbTip, wrist) = 0.4 com mão perto, 0.1 com mão longe
// Você precisaria normalizar pela distância de referência (ex: comprimento da palma)
const distNormalizada = distancia(thumbTip, wrist) / distancia(wrist, middleMcp)Para classificação simples de gestos em tempo real, posição relativa é suficiente e mais robusta. Para análise fina (ângulo exato de articulação), distância normalizada é necessária.
Rate limiting de processamento de frames
Câmera a 30 FPS → 30 frames por segundo. Para MediaPipe em laptop, isso pode ser demais:
let ultimoProcessamento = 0
const INTERVALO_MS = 100 // processar a 10 FPS é suficiente para gestos
function processarFrame(imageData) {
const agora = Date.now()
if (agora - ultimoProcessamento < INTERVALO_MS) return // skip frame
ultimoProcessamento = agora
const resultado = modelo.run(imageData)
classificarGesto(resultado.landmarks)
}10 FPS é mais que suficiente para detectar gestos humanos (humanos não mudam de gesto em menos de 100ms). Isso reduz CPU usage em 66%.
Exemplos Anotados
Exemplo 1: Classificador de gestos baseado em landmarks
const LANDMARKS = {
WRIST: 0,
THUMB_TIP: 4,
INDEX_TIP: 8, INDEX_MCP: 5,
MIDDLE_TIP: 12, MIDDLE_MCP: 9,
RING_TIP: 16, RING_MCP: 13,
PINKY_TIP: 20, PINKY_MCP: 17,
}
function dedoEstendido(landmarks, tipIdx, mcpIdx) {
// y cresce para baixo na imagem — dedo apontando para cima = y_tip < y_mcp
// Comparamos posição relativa, não distância absoluta
// Isso é robusto para mãos em diferentes distâncias da câmera
return landmarks[tipIdx].y < landmarks[mcpIdx].y
}
function classificarGesto(landmarks) {
if (!landmarks || landmarks.length < 21) return 'desconhecido'
const indicadorEstendido = dedoEstendido(landmarks, LANDMARKS.INDEX_TIP, LANDMARKS.INDEX_MCP)
const medioEstendido = dedoEstendido(landmarks, LANDMARKS.MIDDLE_TIP, LANDMARKS.MIDDLE_MCP)
const anularEstendido = dedoEstendido(landmarks, LANDMARKS.RING_TIP, LANDMARKS.RING_MCP)
const minimoEstendido = dedoEstendido(landmarks, LANDMARKS.PINKY_TIP, LANDMARKS.PINKY_MCP)
// Polegar: compara x em vez de y porque o polegar se move lateralmente
// Polegar da mão direita: extendido = thumb.x < index_mcp.x (para a esquerda)
const polegarEstendido = landmarks[LANDMARKS.THUMB_TIP].x < landmarks[LANDMARKS.INDEX_MCP].x
const dedos = [indicadorEstendido, medioEstendido, anularEstendido, minimoEstendido]
const quantosDedos = dedos.filter(Boolean).length
// Ordem de verificação importa: do mais específico para o mais geral
if (!indicadorEstendido && !medioEstendido && !anularEstendido && !minimoEstendido && polegarEstendido) {
return 'polegar_para_cima'
}
if (quantosDedos === 4) {
return 'mao_aberta'
}
if (quantosDedos === 0) {
return 'punho'
}
return `${quantosDedos}_dedos`
}Exemplo 2: Pipeline completo com dados mock
// Simula o que aconteceria com câmera real
function processarFramesMock(gestosMock) {
console.log('=== Detecção de Gestos ===\n')
for (const [nomeGesto, landmarks] of Object.entries(gestosMock)) {
// Em produção: landmarks viria do MediaPipe, não de dados estáticos
const classificado = classificarGesto(landmarks)
// Formatamos para mostrar tanto o gesto esperado quanto o detectado
const correto = nomeGesto.includes(classificado) || classificado.includes(nomeGesto)
const simbolo = correto ? '✓' : '✗'
console.log(`${simbolo} Gesto esperado: '${nomeGesto}' → Detectado: '${classificado}'`)
// Debug: mostramos estados individuais dos dedos para diagnóstico
if (!correto) {
const L = landmarks
console.log(` Debug: polegar=${L[4].x < L[5].x}, indicador=${L[8].y < L[5].y}`)
}
}
}
// Dados mock com 3 gestos diferentes
const gestosMock = {
maoAberta: [/* 21 landmarks com dedos estendidos */],
punho: [/* 21 landmarks com dedos fechados */],
polegar: [/* 21 landmarks com só polegar estendido */],
}
processarFramesMock(gestosMock)Output esperado:
=== Detecção de Gestos ===
✓ Gesto esperado: 'maoAberta' → Detectado: 'mao_aberta'
✓ Gesto esperado: 'punho' → Detectado: 'punho'
✓ Gesto esperado: 'polegar' → Detectado: 'polegar_para_cima'Padrões e Armadilhas
Padrões recomendados
Padrão 1: Normalize coordenadas de landmark antes de comparar Landmarks do MediaPipe são normalizados para [0,1] relativos ao tamanho do frame. Use-os diretamente para comparações — não converta para pixels primeiro, pois perderia a normalização.
Padrão 2: Debounce classificações antes de acionar ações Gestos mudam rápido mas ações de UI devem ser estáveis. Use debounce: só acione a ação se o mesmo gesto foi detectado por N frames consecutivos (ex: 3 frames = 300ms a 10 FPS).
Padrão 3: Defina fallback para gestos ambíguos Seu classificador não vai cobrir 100% dos casos. Defina um estado “desconhecido” explícito e trate-o graciosamente na UI (ex: mostrar indicador “gesto não reconhecido”) em vez de deixar o estado anterior travado.
Armadilhas comuns
⚠️ Armadilha 1: Assumir que y decresce para cima como em geometria euclidiana O que acontece: sua lógica de “dedo estendido” fica invertida e todos os gestos classificam errado. Versão correta: em coordenadas de imagem, y = 0 é o TOPO e y = 1 é a BASE. Dedo estendido = tip.y < mcp.y (ponta acima = y menor).
⚠️ Armadilha 2: Processar todos os frames sem rate limiting O que acontece: em dispositivos móveis, processar 30 FPS com modelo pesado aquece o aparelho e drena a bateria em minutos. Versão correta: limitar a 10-15 FPS com timestamp check. Gestos humanos não precisam de detecção acima de 15 FPS.
⚠️ Armadilha 3: Classificar gesto de polegar comparando só x O que acontece: em mãos esquerdas, a lógica thumb.x < index_mcp.x inverte (polegar da mão esquerda vai para a direita). Versão correta: detecte se é mão esquerda ou direita (MediaPipe retorna handedness) e inverta a lógica de polegar.
Se não for realizar o laboratório, pule para o próximo capítulo.
Ponte para o Lab
O starter tem 3 TODOs na função classificarGesto():
TODO 1 — Detectar mão aberta Seção de referência: “Conceitos Fundamentais → Classificação de gestos por regras geométricas”. Verifique se todos os 4 dedos (indicador, médio, anelar, mínimo) estão estendidos usando dedoEstendido() que já está implementada. Combine com &&.
TODO 2 — Detectar punho Seção de referência: “Exemplos Anotados → Exemplo 1”. Punho = nenhum dos 4 dedos estendido. Use !dedoEstendido() para cada dedo e combine com &&.
TODO 3 — Detectar polegar para cima Seção de referência: “Aprofundamento Técnico → Distância euclidiana vs posição relativa”. Polegar estendido + demais fechados. Para polegar: landmarks[THUMB_TIP].x < landmarks[INDEX_MCP].x (para mão direita). Esta é a lógica mais sutil — veja o Exemplo 1 anotado acima.
Dica para o TODO mais difícil (TODO 3): o polegar se move em X, não em Y. Se você tentar tip.y < mcp.y para o polegar como fez para os outros dedos, vai obter resultados errados para a maioria das posições da mão.
Agora você está pronto para o lab.