mozak.tech Engenharia de IA Corporativa 29%

Parte I — Alicerces da Inteligência Artificial Moderna

1.5 — Aplicações Práticas de ML na Web: Detecção de Gestos com MediaPipe (MediaPipe)

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].y

Distâ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.

⚗ Laboratório prático — mozak.tech
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.