Saltar al contenido
CO
← Volver a proyectos
SaludIAEn producción

Gonio Shard

Mide el rango de movilidad articular con la cámara del navegador, sin goniómetro ni sensores. Empezó como mi tesis de 2024 — y al revalidarla encontré que el resultado que había reportado estaba mal.

El problema

Medir cuántos grados se mueve una articulación es rutina en kinesiología y traumatología, y se hace con un goniómetro manual: dos brazos de plástico, un transportador y el ojo del profesional. Es barato y es lento, depende de quién mide, y no deja registro más allá de un número escrito a mano.

La pregunta de mi tesis fue si una cámara común podía reemplazarlo. La respuesta corta, dos años después y con la validación rehecha, es todavía no — y esa respuesta resultó ser mucho más interesante que un sí.

Lo que encontré al revalidar mi propia tesis

La validación original reportaba un error de ángulo de 5,32°. Ese número estaba mal calculado. En validation/metrics.py el arctan2 se aplicaba elemento a elemento sobre las columnas x e y por separado, produciendo un array de forma (90,2) en vez de (90,). No era un ángulo: era dos columnas de números a las que llamé ángulo.

El error real por fotograma es 14,92°.

Rehice la medición sobre los mismos 90 fotogramas anotados a mano, con el pipeline actual:

Métrica Valor
Correlación con la anotación manual 0,991
Error medio por fotograma (MAE) 13,59°
— media con signo (sesgo) +13,59°
— desviación estándar (ruido) 5,34°
Límites de acuerdo 95% (Bland-Altman) [+3,12°, +24,06°]
Error de ROM, min/max crudo 8,92°
Error de ROM, percentiles 2/98 6,47°

El error es sesgo sistemático, no ruido. La media con signo coincide con el MAE: el modelo se equivoca siempre para el mismo lado. Es la mejor noticia posible, porque un desvío constante se corrige calibrando y el ruido aleatorio no. La correlación de 0,991 dice que la forma del movimiento ya se sigue casi perfecto.

Corregí también una afirmación intermedia mía. Una medición preliminar reportaba 3,28° de error usando “suavizado + percentiles”. Era un artefacto: el suavizado usaba np.convolve(..., 'valid'), que recorta fotogramas de los extremos, y en ese clip los picos del movimiento caen justo cerca de los bordes. No estaba suavizando, estaba descartando los datos que más costaban. Con Savitzky-Golay de verdad el suavizado no mejora el ROM — preserva los picos por diseño — así que compute_rom ya no suaviza por defecto. Todo el trabajo lo hacen los percentiles.

Por qué 14,92° no es un fracaso

La evaluación más completa publicada hasta hoy (Rode et al., Scientific Reports 15:38767, 2025) comparó 11 estimadores de pose contra un sistema Vicon de 27 cámaras sobre 2,2 millones de fotogramas. Reporta 16,3°–28,9° de error en ángulo de codo para estimación monocular, y concluye que ninguno alcanza los <5° que exigiría el uso clínico.

Los 14,92° no son un defecto de esta implementación. Son el estado del arte, y saberlo cambia qué vale la pena construir encima.

Decisiones que salen de la evidencia

  • Medición en 2D, deliberadamente. El mismo estudio mide 72–122 mm de error en el plano de la imagen contra 146–249 mm al incorporar profundidad. Las pose_world_landmarks 3D de MediaPipe no mejoran el ángulo en este caso, y a menudo lo empeoran. Mantener el movimiento en el plano de la cámara y calibrar el desvío le gana a perseguir un 3D poco confiable.
  • Calibración por pose de referencia. Un brazo extendido son 180° conocidos. Alcanza para medir el desvío de esta cámara en esta posición y restarlo del resto de la sesión.
  • Dos cálculos distintos para dos propósitos. El valor en pantalla usa un filtro causal de baja latencia (One Euro), que solo puede mirar al pasado. El ROM final se calcula al terminar la captura, sobre la ventana completa, con percentiles en lugar de mínimo y máximo.
  • Todo en el cliente. MediaPipe Tasks corre en el navegador vía WebAssembly. No hay subida de video ni backend. Son datos de salud, y lo más seguro que se puede hacer con ellos es no recibirlos nunca.

Dos bugs del script original

video.py:109 entregaba el fotograma en BGR a un modelo que espera RGB. La conversión estaba comentada en las líneas 107-108. Lo llamativo es que validation/mediapipe_estimation.py:38 sí convertía: validé un pipeline y despaché otro.

video.py:172-181 escribía el video anotado dentro del if results.pose_landmarks. Los fotogramas sin detección no llegaban al archivo, así que la salida quedaba más corta que la entrada y desincronizada. En gonio/cli.py el write() está fuera del condicional, y hay un test de regresión que verifica que el anotado tenga los mismos 90 fotogramas que el original.

Aparte: la API que usaba la tesis ya no existe. mediapipe 1.0.0 eliminó el módulo solutions completo, así que video.py no corre con una instalación actual. Migrar a Tasks dejó de ser una mejora opcional.

Cómo está construido

El paquete Python separa el cálculo del ángulo (angles.py) del backend de detección (pose.py), que es lo único atado a MediaPipe. rom.py hace el gating, el filtrado y la extracción del rango; report.py persiste la serie temporal completa en vez de tres números resumen — sin la serie no se puede auditar después qué pasó.

El sitio reimplementa angles.py y rom.py en JavaScript para correr en el navegador. Eso abre la posibilidad de que las dos implementaciones se separen en silencio, así que hay tests de paridad que corren el mismo input por Python y por JS y comparan las salidas. Son parte de los 60 tests del repositorio.

Qué falta

Ampliar el ground truth: 90 fotogramas anotados a mano de un solo sujeto y un solo movimiento no alcanzan para afirmar nada general. La herramienta de anotación (validation/medir_datos_a_mano.py) funciona y es por donde seguiría.

Y validar si la calibración por pose de referencia efectivamente elimina el sesgo de +13,59°. La hipótesis es sólida — el error es sistemático — pero hipótesis sólida no es resultado medido, y esa distinción es exactamente la que me costó este proyecto aprender.