* notes / python
Detectar lengua de señas con Python
— Detección de lengua de señas con MediaPipe: vectores de landmarks, normalización al origen de la muñeca, modelado temporal y una mirada honesta a los límites de un dataset pequeño.
¿Cuánto se puede avanzar en detección automática de lengua de señas con una webcam doméstica, Python y un fin de semana largo? Más allá del clasificador en sí, la experiencia me obligó a pensar en serio sobre representación, diversidad de señantes y las trampas del small data.
Contexto: por qué landmarks y no píxeles
La primera tentación, sobre todo si vienes de visión por computador clásica, es alimentar una red convolucional con los frames crudos y dejar que aprenda. Para un dataset masivo, anotado y diverso, es una estrategia válida; para mi dataset de unos pocos cientos de muestras grabadas en mi salón, era garantía de sobreajuste. La alternativa que adopté, y que es estándar en literatura reciente de SLR (sign language recognition), consiste en extraer landmarks de manos, cara y cuerpo, y entrenar el clasificador sobre esas coordenadas.
MediaPipe, el toolkit de Google para visión móvil/edge, ofrece tres soluciones que encajan como guante:
- Hands: 21 landmarks 3D por mano, hasta dos manos simultáneas, con anotación del modelo cinemático (muñeca, base de cada dedo, articulaciones, puntas).
- Face Mesh: 468 landmarks faciales que permiten estimar pose de cabeza, expresión y, sutil pero importante, cejas y boca, que en muchas lenguas de señas modulan el significado gramatical.
- Pose: 33 landmarks corporales (hombros, codos, caderas, etc.) imprescindibles para señas que recorren el espacio frente al torso.
El beneficio inmediato de trabajar con landmarks es doble. Primero, reduces brutalmente la dimensionalidad: un frame RGB de 640x480 son 921 600 valores; los landmarks (21 + 21 + 33 manos+cuerpo) por 3 coordenadas son 225 valores. Segundo, descartas el ruido visual: iluminación, fondo, ropa, color de piel. Tu modelo aprende geometría, no apariencia. Cabe destacar que esta abstracción tiene un coste (pierdes información de contacto, de tensión muscular, de microgestos faciales finos), pero a escala doméstica el intercambio merece la pena con creces.
Implementación: extracción, normalización y clasificador temporal
El primer bloque, extracción de landmarks con MediaPipe, es deliberadamente simple. Cada frame produce un vector de 63 valores por mano (21 puntos x 3 coordenadas) que normalizamos al origen de la muñeca y reescalamos por la distancia muñeca-base del dedo medio. Esta normalización hace al modelo invariante a posición de la mano en pantalla y al tamaño aparente, lo cual es crucial cuando entrenas con una sola persona y luego evalúas con otra a distinta distancia de la cámara.
import cv2
import numpy as np
import mediapipe as mp
mp_hands = mp.solutions.hands
hands = mp_hands.Hands(
static_image_mode=False,
max_num_hands=2,
min_detection_confidence=0.7,
min_tracking_confidence=0.6,
)
def normalize_hand(landmarks: np.ndarray) -> np.ndarray:
"""Recibe un array (21, 3) y devuelve uno normalizado a muñeca = origen."""
wrist = landmarks[0]
centered = landmarks - wrist
scale = np.linalg.norm(landmarks[9] - wrist) + 1e-8
return centered / scale
def extract_features(frame: np.ndarray) -> np.ndarray:
"""Devuelve un vector de 126 valores (dos manos x 21 x 3)."""
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
result = hands.process(rgb)
feats = np.zeros((2, 21, 3), dtype=np.float32)
if result.multi_hand_landmarks:
for i, hand_lm in enumerate(result.multi_hand_landmarks[:2]):
raw = np.array(
[[lm.x, lm.y, lm.z] for lm in hand_lm.landmark],
dtype=np.float32,
)
feats[i] = normalize_hand(raw)
return feats.reshape(-1)Para señas dinámicas (que son la mayoría: «buenos días», «gracias», «por favor»), un solo frame no basta. Necesitamos un modelo temporal. Probé dos arquitecturas: una LSTM clásica y una 1D CNN sobre ventanas de 30 frames (aproximadamente un segundo a 30 fps). Para mi dataset pequeño, la 1D CNN fue más estable y entrenó en menos tiempo; la LSTM aprendía más matiz pero sobreajustaba con facilidad. Comparto el esqueleto en PyTorch del clasificador definitivo:
import torch
import torch.nn as nn
class SignClassifier(nn.Module):
def __init__(self, num_classes: int, window: int = 30, feat_dim: int = 126):
super().__init__()
self.conv = nn.Sequential(
nn.Conv1d(feat_dim, 128, kernel_size=5, padding=2),
nn.ReLU(),
nn.BatchNorm1d(128),
nn.Conv1d(128, 64, kernel_size=5, padding=2),
nn.ReLU(),
nn.BatchNorm1d(64),
nn.AdaptiveAvgPool1d(1),
)
self.head = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.3),
nn.Linear(64, num_classes),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (batch, window, feat_dim) -> (batch, feat_dim, window)
x = x.transpose(1, 2)
x = self.conv(x)
return self.head(x)
def train_step(model, batch, optimizer, criterion):
x, y = batch
optimizer.zero_grad()
logits = model(x)
loss = criterion(logits, y)
loss.backward()
optimizer.step()
return loss.item()La pipeline de entrenamiento, omitida aquí por brevedad, alimentaba el modelo con ventanas deslizantes y aplicaba data augmentation sintético: pequeñas rotaciones afines de los landmarks (en el plano XY), jitter gaussiano de baja intensidad y time warping (acelerar o ralentizar la ventana en un ±15%). Sin augmentation, el modelo aprendía mi velocidad exacta de signado y fallaba con cualquier otra cadencia.
Hallazgos: lo que funcionó, lo que no, y la conversación incómoda
Tras una semana de iteración, el clasificador alcanzaba 88% de precisión sobre un vocabulario de doce señas, evaluado con validación cruzada por sujeto. Suena bien, pero conviene desinflar la cifra antes de sacar pecho.
Lo que funcionó. Las señas estáticas o casi estáticas (letras del alfabeto manual, números del uno al diez) se clasificaban con altísima fiabilidad (precisión por encima del 95%). La normalización al origen de la muñeca eliminó la sensibilidad a la distancia, y el aumento sintético sobre la ventana temporal mejoró notablemente la generalización entre velocidades. La 1D CNN entrenaba en menos de tres minutos en una GPU consumer, lo cual permitía iterar rapidísimo.
Lo que no funcionó. Las señas que dependen fuertemente de la expresión facial (y son muchísimas en la mayoría de lenguas de señas, donde las cejas, la boca o la mirada modulan negación, interrogación o aspecto verbal) quedaban fuera del alcance del modelo de manos puro. Cuando incorporé Face Mesh, el vector de entrada creció a casi 1 500 dimensiones y el modelo, con tan pocas muestras, empezó a sobreajustar visiblemente. Mi solución a corto plazo, reducir Face Mesh a un subconjunto de 40 landmarks centrados en cejas y boca, mitigó parcialmente el problema. La solución de fondo, evidentemente, es más datos.
La conversación incómoda. Todo mi dataset estaba grabado por mí: una persona, blanca, diestra, mediana edad, sin barba ni gafas. Cualquier sistema entrenado sobre estos datos heredará mi sesgo personal y fallará sistemáticamente con señantes zurdos, con tonos de piel diferentes (no por los landmarks, que son invariantes al color, sino porque la detección previa de manos de MediaPipe sí muestra sensibilidad documentada a iluminación y tono de piel), con señantes nativos cuya velocidad y prosodia es distinta a la mía (yo hago señas con torpeza, como aprendiz) y, sobre todo, con la inmensa variedad regional de lenguas de señas. La Lengua de Señas Colombiana y la Lengua de Señas Argentina, por ejemplo, son lenguas distintas, no dialectos. Un sistema serio exige colaborar desde el principio con la comunidad sorda, recoger datos con consentimiento informado y con representación amplia, y publicar las limitaciones del modelo en lugar de venderlo como solución acabada.
Closure
MediaPipe baja muchísimo la barrera de entrada: en un fin de semana pasas de cero a un clasificador de doce señas con resultados decentes. Pero esa facilidad engaña. La lengua de señas es una lengua, y las lenguas pertenecen a sus hablantes: cualquier sistema serio se construye con la comunidad sorda, no para ella.