En estos días he estado experimentando con la visión artificial para detectar cuando una persona realiza un salto para, más adelante, contarlos y llevar un registro.

Lo que he logrado es detectar un salto real mirando a través de la cámara del usuario usando JavaScript.
Aunque parece una tarea fácil realmente me llevó más tiempo del esperado, pero al final tengo el algoritmo que detecta cuando el usuario despega, cuando aterriza (salto completo) así como el tiempo que tardó entre cada cosa y un umbral configurable de tiempo para saltar y altura mínima
He usado las Tareas de visión de Google AI, específicamente las que detectan las poses de un usuario y te las dan como coordenadas: https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker?hl=es-419
Solo para que quede claro este código detecta saltos con o sin cuerda, pero obviamente se puede pulir para que obligue a usar una cuerda fijándose en la posición de las manos.
Cámara de prueba

Con este código vamos a usar JS y el navegador web para acceder a la cámara y detectar la pose del usuario.
Yo he grabado vídeos de mí saltando, los he recortado, pasado a OBS e iniciado la cámara virtual, así puedo hacer pruebas más controladas.
Haré pruebas más adelante y las publicaré de ser posible.
¿Qué es un salto?
Yo defino un salto como cuando la coordenada Y de la cadera de una persona disminuye cierto valor y luego vuelve a aumentar en un período corto de tiempo.
Tasks Vision te da los puntos del usuario en valores de 0 a 1 en donde el 0 es la parte superior y el 1 es la parte inferior.
if (estaArriba) {
const ahora = Date.now();
const msTranscurridosDesdeDespegue = ahora - horaDeUltimoDespegue;
if (msTranscurridosDesdeDespegue <= minimoTiempoParaAterrizar) {
loguear(`Salto completado. Pasaron ${msTranscurridosDesdeDespegue}ms desde despegue, tu acumulador es ${acumulador}`)
saltos++;
$cantidad.textContent = saltos;
} else {
loguear(`Aterrizaste pero tardaste ${msTranscurridosDesdeDespegue}ms, que es más de ${minimoTiempoParaAterrizar}`)
}
// Independientemente de si es un salto o no, actualizamos porque ya está en el suelo
estaArriba = false;
}
Supongamos que la cadera está en Y=0.5 cuando el usuario está en el suelo. Luego disminuye a Y=0.35 (o sea, el usuario se elevó) y de ahí aumenta a Y=0.5; todo esto en menos de 500ms. Entonces eso es un salto.
No podemos fiarnos de que simplemente se eleve porque puede estar caminando hacia atrás; tampoco podemos confiar en que la cadera baje porque puede estarse agachando. Y necesitamos medir el tiempo porque un salto es muy rápido.
También he usado la cadera como la fuente más confiable para detectar la elevación.
Usando el torso como medida
Al saltar la cuerda es muy probable que el usuario cambie de posición moviéndose hacia adelante o hacia atrás, así que la medida mínima para contar como un salto puede variar.
// Extraemos lo que necesitamos (Coordenada Y)
const noseY = points[INDICE_NARIZ].y;
const alturaCaderaActual = (points[INDICE_CADERA_IZQUIERDA].y + points[INDICE_CADERA_DERECHA].y) / 2;
const sizeTorso = Math.abs(noseY - alturaCaderaActual);
const distanciaMovimiento = alturaCaderaAnterior - alturaCaderaActual;
// Uso distancia relativa porque así no importa qué tan lejos esté el usuario
const distanciaSaltoRelativo = distanciaMovimiento / sizeTorso;
Lo que he hecho es usar un porcentaje de la medida del torso para contarlo como salto. Este valor es ajustable, si lo dejas muy bajo puede que cuente saltos cuando no los hay y si lo dejas muy alto puede que solo detecte saltos muy largos.
Si lo dejara como una medida arbitraria entonces el usuario no podría alejarse de la cámara, así que básicamente este ajuste dice qué tanto porcentaje de tu torso te elevaste.
Eliminar ruido
Aunque el usuario esté quieto, se sigue detectando su posición como lo hemos discutido en el apartado de la tasa de frames. Sin embargo MediaPipe nos va a informar nuevas posiciones aunque el usuario esté congelado, pero se estabilizan solas.
// Si la posición de la cadera actual es menor (o sea, está más arriba)
// que la última vez significa que estamos subiendo aunque sea poco, así que lo sumo
if (alturaCaderaActual < alturaCaderaAnterior) {
// Subió un poquito o tal vez todo un salto, no sabemos
acumulador += Math.abs(distanciaSaltoRelativo);
} else {
// Si anterior es 9 y actual es 10 bajó 1, es anterior - actual (-1)
// Bajó un poquito o tal vez todo un salto, no sabemos
acumulador -= Math.abs(distanciaSaltoRelativo);
}
Nota: ya sé, podría dejar de usar Math.abs pero así lo puse por los nervios y así lo he dejado.
Fotogramas variables
Detectar la posición del usuario es una tarea un poco intensiva. No tenemos un frame rate constante. En ocasiones me da hasta 30 FPS, en otras menos.
Así que es importante tomar eso en cuenta ya que para medir un salto necesitaremos varios fotogramas. Lo bueno es que siempre podemos llevar un acumulador para saber cuánto se elevó y cuánto se agachó.
Obviamente un salto no toma solo 1 fotograma.
Lo del acumulador ya lo he mostrado en el apartado anterior.
Registrando fotograma exacto, acumulador y tiempo
Para hacer análisis de tiempo y poder saber si mi algoritmo funcionaba diseñé la siguiente función que registra la detección: captura el fotograma actual, la duración y el acumulador:
const obtenerFrame = () => {
const $video = document.querySelector("#webcam");
const $canvas = document.querySelector("#temporal");
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
const contexto = $canvas.getContext("2d");
contexto.drawImage($video, 0, 0, $video.videoWidth, $video.videoHeight);
return $canvas.toDataURL("image/png");
}
const loguear = (mensaje) => {
$log.innerHTML += `<div style="border: 1px solid red; margin: 1rem;"><strong>${(new Date()).toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
})}. ${mensaje}</strong><br><img style="max-width: 30"; src="${obtenerFrame()}"></div>`
}
Ya has visto los resultados de esta función en la primera imagen que acompaña al artículo.
La bandera para saber si está arriba
Recordemos que son FPS variables. A veces el acumulador puede superar el umbral y disparar el evento de despegue pero el usuario puede saltar todavía un poco más arriba y eso es válido.
Para ello es que existe la bandera estaArriba que evita que se registre
un doble despegue, misma que se cambia al despegar y al aterrizar.
if (!estaArriba) {
horaDeUltimoDespegue = Date.now();
loguear(`Despegaste, tu acumulador es ${acumulador}`)
// Ok esto es nuevo y no sé si tenga efecto pero es porque a veces detecta 2 despegues
estaArriba = true;
}
Lo que se puede lograr a partir de aquí
Seamos sinceros: la tarea más compleja ya la realiza MediaPipe. Sin embargo a partir de aquí podemos llevar un registro completo de los saltos, medir kcal quemadas, gráficas por días, sincronización en la nube, batalla local (porque según tengo entendido MediaPipe permite detectar más de una persona) y hasta un Battle Royale
Pero bueno, primero lo primero: definir bien el umbral, que según yo primero era 0.1 y he bajado hasta 0.6
Un poco más de código
He hecho esto con Vite. Obviamente funciona sin Node una vez compilado. El package.json se ve así (chart.js no es necesario, lo usé para graficar la curva de los saltos):
{
"name": "detectar-saltos",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.2.4"
},
"dependencies": {
"@mediapipe/tasks-vision": "^0.10.32",
"@rollup/rollup-win32-x64-msvc": "^4.56.0",
"chart.js": "^4.5.1"
}
}
La función que detecta el salto es la siguiente, dejando los comentarios para que sea históricamente correcto:
const procesarSalto2 = detections => {
if (!detections || !detections.landmarks || detections.landmarks.length === 0) {
conteoFramesSinUsuario++;
if (conteoFramesSinUsuario > maximaCantidadFramesSinActividadParaReiniciar) {
//loguear("No hay usuario en detections, reseteamos")
estaArriba = false;
acumulador = 0;
}
return;
}
// Obtenemos los puntos de la primera persona detectada (la segunda estaría en landmarks[1])
const points = detections.landmarks[0];
const detectadoCuerpoCompleto = cuerpoCompleto(points);
$cuerpoDetectado.textContent = detectadoCuerpoCompleto;
if (!detectadoCuerpoCompleto) {
conteoFramesSinUsuario++;
if (conteoFramesSinUsuario > maximaCantidadFramesSinActividadParaReiniciar) {
//loguear("No hay usuario con cuerpo completo, reseteamos")
estaArriba = false;
acumulador = 0;
}
return;
}
// Si llegamos hasta aquí es porque sí hay usuario
conteoFramesSinUsuario = 0;
// Extraemos lo que necesitamos (Coordenada Y)
const noseY = points[INDICE_NARIZ].y;
const alturaCaderaActual = (points[INDICE_CADERA_IZQUIERDA].y + points[INDICE_CADERA_DERECHA].y) / 2;
// Primer frame
if (alturaCaderaAnterior === null) {
alturaCaderaAnterior = alturaCaderaActual;
return;
}
const sizeTorso = Math.abs(noseY - alturaCaderaActual);
const distanciaMovimiento = alturaCaderaAnterior - alturaCaderaActual;
// Uso distancia relativa porque así no importa qué tan lejos esté el usuario
const distanciaSaltoRelativo = distanciaMovimiento / sizeTorso;
/*
Ok entonces nos han informado que hubo un cambio, puede ser
que haya saltado completo, que esté en proceso de salto, que esté bajando o que ya haya bajado.
Asumimos que al inicio de todo está en el suelo
*/
// Si la posición de la cadera actual es menor (o sea, está más arriba)
// que la última vez significa que estamos subiendo aunque sea poco, así que lo sumo
if (alturaCaderaActual < alturaCaderaAnterior) {
// Subió un poquito o tal vez todo un salto, no sabemos
acumulador += Math.abs(distanciaSaltoRelativo);
} else {
// Si anterior es 9 y actual es 10 bajó 1, es anterior - actual (-1)
// Bajó un poquito o tal vez todo un salto, no sabemos
acumulador -= Math.abs(distanciaSaltoRelativo);
}
// Idealmente si acumulador es negativo es porque ya aterrizó
// Si es positivo es porque está arriba
alturaCaderaAnterior = alturaCaderaActual;
// Este valor lo saqué de pruebas, no es matemático. Es la distancia relativa que debe saltar, digamos está expresada
// en porcentaje del cuerpo, no en pixeles o cm
const umbralMinimoSaltoAhoraSiPorMaradonaMax = 0.20;
// Si te moviste más allá del umbral, o sea, seguiste una dirección positiva o negativa muy grande
// (ya sea hacia abajo o hacia arriba) has llamado mi atención
// Reiniciamos según XD
if (Math.abs(acumulador) < 0.01) {
}
if (Math.abs(acumulador) > alturaMinimaParaContarComoSalto) {
// Pero mi amigo, cuánto tiempo te tomó llegar hasta aquí?
const ahora = new Date().getTime();
if (acumulador < 0) {
// Bien bien aquí estamos seguros de que el usuario se elevó mucho o al menos lo suficiente para que se detecte como un salto.
// pero ojo: puede que solo esté caminando hacia atrás
// NO sé por qué si acumulador < 0 es porque aterrizó XD ah ha de ser porque no se ha reiniciado, pero
// juro por Maradona que sí, aquí en este if es el aterrizaje
if (estaArriba) {
const ahora = Date.now();
const msTranscurridosDesdeDespegue = ahora - horaDeUltimoDespegue;
if (msTranscurridosDesdeDespegue <= minimoTiempoParaAterrizar) {
loguear(`Salto completado. Pasaron ${msTranscurridosDesdeDespegue}ms desde despegue, tu acumulador es ${acumulador}`)
saltos++;
$cantidad.textContent = saltos;
} else {
loguear(`Aterrizaste pero tardaste ${msTranscurridosDesdeDespegue}ms, que es más de ${minimoTiempoParaAterrizar}`)
}
// Independientemente de si es un salto o no, actualizamos porque ya está en el suelo
estaArriba = false;
}
} else {
if (!estaArriba) {
horaDeUltimoDespegue = Date.now();
loguear(`Despegaste, tu acumulador es ${acumulador}`)
// Ok esto es nuevo y no sé si tenga efecto pero es porque a veces detecta 2 despegues
estaArriba = true;
}
}
acumulador = 0;
}
}
Es un código muy sucio, pido disculpas. Lo limpiaré más adelante, por ahora estoy emocionado por haber logrado lo que quería.