Gesture AI Desk
Controlar o computador com a mão pela webcam: pausar música, mudar volume, tirar print. Nenhum quadro sai da sua máquina em nenhum momento.
O problema
Começou como uma pergunta prática: dá pra pausar uma música com a mão suja de farinha? Detectar a mão em si é problema resolvido, e bibliotecas fazem isso há anos.
O que continua difícil em interface por gesto é saber a intenção: o computador precisa distinguir entre você querendo alguma coisa e você passando a mão na frente da câmera pra coçar o nariz.
A decisão
Tudo offline: nenhuma API e nenhum serviço de visão na nuvem. Além da questão de privacidade, tem um motivo prático: uma webcam ligada mandando imagem pra fora é o tipo de coisa que a pessoa desinstala no segundo dia. O modelo do MediaPipe roda local, e o navegador só conversa com o meu próprio processo por WebSocket.
Três travas entre a mão e a ação
A 30 quadros por segundo, um detector ingênuo dispara trinta vezes por segundo. Você faz sinal de positivo por um segundo e o volume sobe trinta vezes. O que separa um brinquedo de uma ferramenta usável são as camadas que vieram depois da detecção.
Primeiro o estado dos dedos vira um número de cinco bits, um por dedo, e o gesto é uma consulta numa tabela: barato o bastante pra caber no orçamento de cada quadro. Depois um estabilizador só aceita a mudança quando o mesmo gesto aparece em cinco quadros seguidos, o que mata o tremor de detecção. Em cima disso, um temporizador exige meio segundo de gesto mantido antes de disparar, com um anel de progresso na tela pra você ver que está carregando e poder desistir. E depois de disparar, um segundo de descanso em que aquele gesto simplesmente não conta.
Sozinha, cada uma dessas quatro camadas é bem burra. Empilhadas, elas dão conta de transformar um fluxo barulhento em uma ação que a pessoa quis fazer.
# Gestures that bypass the hold mechanism entirely
_CONTINUOUS_GESTURES = {Gesture.PINCH, Gesture.SWIPE_LEFT, Gesture.SWIPE_RIGHT}
def update(self, gesture: Gesture, timestamp: float) -> dict:
# Continuous gestures skip hold entirely
if gesture in _CONTINUOUS_GESTURES:
return {"hold_progress": 0.0, "action_triggered": False, "in_cooldown": False}
in_cooldown = (timestamp - self._last_trigger_time) < self.cooldown
# Gesture changed — reset hold
if gesture != self._current_gesture:
self._current_gesture = gesture
self._hold_start = timestamp
self._triggered = False
return {"hold_progress": 0.0, "action_triggered": False, "in_cooldown": in_cooldown}
elapsed = timestamp - self._hold_start
progress = min(elapsed / self.hold_duration, 1.0)
# Already triggered for this hold — wait for gesture change
if self._triggered:
return {"hold_progress": 1.0, "action_triggered": False, "in_cooldown": in_cooldown}
if progress >= 1.0 and not in_cooldown:
self._triggered = True
self._last_trigger_time = timestamp
return {"hold_progress": 1.0, "action_triggered": True, "in_cooldown": False}
return {"hold_progress": progress, "action_triggered": False, "in_cooldown": in_cooldown}
A interface que eu tive que reconstruir
O repositório tinha uma pasta de frontend que na verdade era um ponteiro quebrado de submodule: o painel em React nunca esteve versionado ali. Descobri isso tentando rodar o projeto do zero, que é exatamente o que qualquer pessoa que clona faria.
Reescrevi como um arquivo HTML único, sem build e sem dependência: câmera, gesto atual com o anel de hold, lista de comandos, estado da conexão e log de ações disparadas. O esqueleto da mão é desenhado num canvas por cima da imagem, com as coordenadas normalizadas que o backend manda. Ficou mais simples de manter do que o que existia antes, e agora clonar o repositório e rodar funciona.
A parte difícil não era a do nome
A detecção de gestos, que é o que dá nome ao projeto, foi um dia de trabalho, porque o MediaPipe carrega o peso todo. O resto do tempo eu passei decidindo quando ignorar o que ele detectou, e essa parte não aparece em nenhuma demonstração.