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.

Página web que muestra fotograma exacto de usuario en el aire con la marca de tiempo. Después muestra otro fotograma del aterrizaje del usuario mostrando el frame y el tiempo

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

Software OBS con video de persona saltando. Usando OBS para simular cámara en JavaScript y detectar saltos

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.

Si el post ha sido de tu agrado te invito a que me sigas para saber cuando haya escrito un nuevo post, haya actualizado algún sistema o publicado un nuevo software. Facebook | X | Instagram | Telegram | También estoy a tus órdenes para cualquier contratación en mi página de contacto