Visión por computadora en juegos: pose detection con Sentis
MoveNet o BlazePose corriendo dentro de Unity con Sentis. Just Dance, juegos AR/cam y accesibilidad gestural sin servidor externo.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Un juego con webcam tipo Just Dance no quiere saber qué pixeles entran por la cámara; quiere saber dónde están los codos, las rodillas y las muñecas del jugador, treinta veces por segundo. Mandar cada frame a un servidor para que lo procese una GPU remota funciona en demos y se rompe en producción: la latencia mata el ritmo, el ancho de banda mata las pruebas A/B y el costo mata el margen.
La salida moderna es correr el modelo dentro del propio juego. Sentis (sucesor de Barracuda) ejecuta modelos ONNX en Unity sobre GPU o CPU, y para visión hay modelos pre-entrenados que pesan poco y van rápido: MoveNet de Google, BlazePose de MediaPipe, RTMPose de OpenMMLab. Todos hacen lo mismo: te devuelven una lista de puntos del cuerpo (keypoints) por frame.
Este tutorial es sobre pose detection. Casos típicos:
- Just Dance / clones rítmicos con cámara.
- Reemplazo de Kinect en fitness apps y rehab.
- Accesibilidad gestual: controlar el menú con la cabeza o el cursor con la mano cuando no puedes usar gamepad.
- Filtros AR estilo Snapchat dentro de un juego.
- Captura de animación casera sin trajes ni markers.
Lo que no cubre: tracking 3D estilo MoCap con depth-cameras, hand-pose con 21 keypoints por mano, face-mesh, ni segmentación. Cada uno es un modelo y un tutorial aparte.
1. Demo
2. Concepto y arquitectura
Pose detection es la tarea de identificar las posiciones (keypoints) de las articulaciones del cuerpo a partir de una imagen. MoveNet de Google y BlazePose de MediaPipe son los modelos estándar; ambos son exportables a ONNX y ejecutables con Sentis a 30+ fps en hardware modesto. El input es una imagen RGB; el output, una lista de puntos 2D con confianza por punto.
2.1 ¿Qué keypoints devuelve un modelo de pose?
Depende del modelo. Hay dos formatos dominantes:
- 17 keypoints (formato COCO): nariz, ojos, orejas, hombros, codos, muñecas, caderas, rodillas, tobillos. Lo que usa MoveNet y la mayoría de exportaciones genéricas.
- 33 keypoints (BlazePose Full): añade puntos del pecho, los dedos, los pulgares y el centro de las palmas. Útil para fitness y deportes donde la rotación del brazo importa.
Cada keypoint trae tres números: x, y y score (confianza). El x/y viene normalizado en [0, 1] (proporción de la imagen). El score está en [0, 1]: por debajo de 0.3 el modelo “no está seguro” y conviene ignorarlo o no dibujarlo.
Figura esquemática con los 17 puntos numerados. Líneas entre articulaciones; los keypoints detectados pulsan.
2.2 ¿Cómo es el pipeline desde la webcam?
Cada caja es una etapa. Las flechas animadas muestran cómo el frame se transforma en una lista de 17 puntos lista para gameplay.
La parte cara del pipeline es el resize y la inferencia. Las otras etapas son baratas. Si haces el resize en CPU, vas a sufrir; con RenderTexture.Blit y Sentis.TextureConverter todo queda en GPU y el coste extra es cercano a cero.
2.3 ¿MoveNet, BlazePose o YOLO-Pose?
Para single-person, tres candidatos. Pesos y FPS son orientativos con un GPU integrado moderno (Intel Iris Xe, Apple M1) y resolución típica del modelo.
| Modelo | Tamaño ONNX | FPS típico | Calidad | Licencia |
|---|---|---|---|---|
| MoveNet Lightning | ~4 MB | 60+ | Buena | Apache 2.0 |
| MoveNet Thunder | ~12 MB | 30-40 | Mejor | Apache 2.0 |
| BlazePose Lite | ~3 MB | 50+ | Buena, 33 keypoints | Apache 2.0 |
| BlazePose Full | ~9 MB | 30-40 | Excelente, 33 keypoints | Apache 2.0 |
| YOLO-Pose (n/s) | 20-50 MB | 15-25 | Excelente, multi-persona | AGPL-3.0 (ojo) |
Tres reglas prácticas:
- Empieza con MoveNet Lightning. Es el “Honda Civic” de pose detection: aburrido, fiable, no falla.
- Sube a Thunder si la calidad no alcanza. Solo si Lightning falla en poses complejas, no por defecto.
- YOLO-Pose tiene licencia AGPL. Si tu juego es comercial y cerrado, esto puede no ser una opción legal. Lee la licencia antes de empaquetarlo.
2.4 Pre-processing: el detalle que mata o salva el FPS
Cada modelo espera un input específico:
- MoveNet Lightning: 192×192, RGB, normalizado a
[0, 1](float32). - MoveNet Thunder: 256×256, RGB, normalizado a
[0, 1]. - BlazePose: 256×256, RGB, normalizado a
[-1, 1].
Equivocarte aquí es la causa número uno de “el modelo devuelve puntos aleatorios”. Si el ONNX espera [-1, 1] y le mandas [0, 1], no falla; simplemente predice basura. Verifica los metadatos del modelo (Netron es tu amigo).
El resize a la resolución del modelo lo haces con un RenderTexture cuadrado y un Graphics.Blit. La conversión RT → tensor con TextureConverter.ToTensor. Todo permanece en GPU.
2.5 ¿Cómo mapear keypoints a una acción de juego?
Tienes 17 puntos por frame. Tu juego quiere saber “el jugador hizo un jumping jack” o “levantó la mano derecha”. El error clásico es intentar reconocer eso desde los pixeles de la cámara; lo correcto es reconocerlo sobre los keypoints.
Tres niveles de complejidad:
- Pose instantánea (un frame): “¿La muñeca derecha está por encima del hombro derecho?” → un
ifsobre dosVector2. Resuelve “mano arriba”, “T-pose”, “manos juntas”. - Secuencia corta (1-2 segundos): “¿Hizo jumping jack?” = una máquina de estados sobre poses instantáneas. Estado A (brazos abajo y pies juntos) → estado B (brazos arriba y pies separados) → vuelta a A. Una FSM clásica con keypoints como input.
- Pose temporal larga (3+ segundos, baile): comparas la trayectoria de los keypoints contra una referencia (la coreografía objetivo). Métricas: DTW (Dynamic Time Warping) sobre vectores de ángulos articulares. Es lo que hace Just Dance bajo el capó.
La regla universal: la state machine va sobre keypoints, nunca sobre raw pixels.
2.6 ¿Por qué los keypoints tiemblan tanto?
Si dibujas los keypoints crudos en pantalla, vas a ver que tiemblan frame a frame aunque el jugador esté quieto. Eso es jitter: el modelo predice de cero en cada frame y pequeñas variaciones de iluminación o ruido del sensor mueven el punto unos pixeles. Para gameplay es inaceptable.
La cura barata es exponential moving average (EMA) por keypoint:
smooth_x = alpha * raw_x + (1 - alpha) * prev_smooth_x
Con alpha entre 0.3 y 0.6 consigues poses estables sin lag perceptible. Si el alpha es muy bajo (0.1) la pose va a ir “atrasada” respecto al jugador; si es muy alto (0.9) el jitter vuelve. Es el mismo tradeoff que un filtro pasa-bajo.
Una versión un escalón más sofisticada es el One Euro Filter: adapta alpha según la velocidad del keypoint. Quieto = mucho suavizado; movimiento rápido = poco suavizado. Para Just Dance compensa; para “levantar la mano” no.
2.7 ¿Y si hay más de una persona en el frame?
Los modelos single-person como MoveNet asumen una sola persona y devuelven el esqueleto del más prominente (o uno aleatorio si hay varios). Para multi-persona necesitas:
- Un detector de personas (YOLO, SSD-MobileNet) que devuelve N bounding boxes.
- Un crop de cada box.
- Una inferencia de pose por crop.
El costo escala lineal con el número de personas. Cuatro jugadores = cuatro inferencias por frame. Lightning lo aguanta en GPU integrada; Thunder ya no. Alternativa: un modelo multi-persona end-to-end como YOLO-Pose, asumiendo que la licencia AGPL te sirve.
2.8 Privacidad: un argumento de marketing real
En la UE, el feed de webcam es información personal sensible bajo GDPR. Mandar el video a un servidor exige consentimiento explícito, retención mínima, contrato de procesamiento, etc. Procesar localmente con Sentis evita todo eso: los pixeles nunca salen del dispositivo, solo los keypoints (y solo si tú decides enviarlos).
No es solo cumplimiento legal. Es una etiqueta “100% on-device. Tu cámara nunca sale de tu PC” en la página de la Store que cierra ventas. Sentis te la regala gratis.
3. Pseudocódigo
function detectPose(camFrame: Texture2D, worker: Worker) -> List<Keypoint>
# resize a la resolución del modelo (192 para MoveNet Lightning)
rt = preprocess(camFrame, 192, 192)
# tensor [1, 3, 192, 192] normalizado a [0, 1]
input = textureToTensor(rt, normalize=true)
worker.schedule(input)
# output shape: [1, 17, 3] -> 17 keypoints * (y, x, score)
output = worker.peekOutput()
keypoints = []
for i in 0..17
y = output[0, i, 0]
x = output[0, i, 1]
score = output[0, i, 2]
# ignoramos puntos con baja confianza; downstream los lee como null
if score > 0.3
keypoints.append(Keypoint(i, x, y, score))
else
keypoints.append(null)
return keypoints
function smoothKeypoints(prev: List<Keypoint>, curr: List<Keypoint>, alpha: Float = 0.4) -> List<Keypoint>
smoothed = []
for i in 0..curr.length
# primer frame o keypoint perdido: usa el actual sin filtrar
if prev[i] == null or curr[i] == null
smoothed.append(curr[i])
continue
# EMA por componente; score no se suaviza, viene del frame actual
sx = lerp(prev[i].x, curr[i].x, alpha)
sy = lerp(prev[i].y, curr[i].y, alpha)
smoothed.append(Keypoint(i, sx, sy, curr[i].score))
return smoothed
Dos ideas. La inferencia es una caja negra: das un tensor, recibes otro. Todo el “código del juego” vive en el post-proceso (umbral de confianza, suavizado, detección de gestos).
4. Implementación en Unity / C#
Cliente mínimo: una webcam corriendo, un Worker de Sentis con MoveNet Lightning, y un Vector3[] de 17 keypoints listo para que el resto del juego lo consuma. La gestión del modelo (carga del .onnx, dispose) se asume hecha en otro componente; este se enfoca en el bucle por frame.
using Unity.Sentis;
using UnityEngine;
public class PoseDetector : MonoBehaviour {
[SerializeField] ModelAsset modelAsset; // MoveNet Lightning .onnx
[SerializeField] RawImage debugOverlay; // opcional, ver el frame
[SerializeField] float scoreThreshold = 0.3f;
[SerializeField, Range(0f, 1f)] float smoothing = 0.4f;
const int INPUT_SIZE = 192;
WebCamTexture cam;
RenderTexture rt;
Worker worker;
Tensor<float> input;
public Vector3[] Keypoints { get; private set; } = new Vector3[17]; // x, y normalizados + score
Vector3[] prev = new Vector3[17];
void Start() {
cam = new WebCamTexture(640, 480, 30);
cam.Play();
rt = new RenderTexture(INPUT_SIZE, INPUT_SIZE, 0, RenderTextureFormat.ARGB32);
var model = ModelLoader.Load(modelAsset);
worker = new Worker(model, BackendType.GPUCompute);
}
void Update() {
if (!cam.didUpdateThisFrame) return;
// 1. resize la cámara a 192×192 cuadrado (en GPU)
Graphics.Blit(cam, rt);
// 2. textura -> tensor [1, 3, 192, 192] normalizado a [0, 1]
input?.Dispose();
input = TextureConverter.ToTensor(rt, INPUT_SIZE, INPUT_SIZE, 3);
// 3. inferencia
worker.Schedule(input);
// 4. lectura del output [1, 1, 17, 3] -> (y, x, score) por keypoint
using var output = (worker.PeekOutput() as Tensor<float>).ReadbackAndClone();
for (int i = 0; i < 17; i++) {
float y = output[0, 0, i, 0];
float x = output[0, 0, i, 1];
float score = output[0, 0, i, 2];
Vector3 curr = score > scoreThreshold ? new Vector3(x, y, score) : Vector3.zero;
// EMA por keypoint, sin suavizar el score
if (prev[i] != Vector3.zero && curr != Vector3.zero) {
curr.x = Mathf.Lerp(prev[i].x, curr.x, smoothing);
curr.y = Mathf.Lerp(prev[i].y, curr.y, smoothing);
}
Keypoints[i] = curr;
prev[i] = curr;
}
}
void OnDestroy() {
input?.Dispose();
worker?.Dispose();
if (cam != null) cam.Stop();
}
}
5. En otros engines
- Godot: ONNX Runtime tiene una GDExtension comunitaria que carga modelos
.onnx. El pipeline es idéntico (texture → tensor → infer → keypoints); cambia la API del worker. - Unreal: NNE (Neural Network Engine, oficial desde 5.4) reemplaza al viejo NNI. Soporta ONNX y DirectML/CUDA. Misma idea de tensor in/tensor out.
- JavaScript / web: TensorFlow.js trae MoveNet pre-empaquetado (
@tensorflow-models/pose-detection). Corre nativo en navegador con WebGL o WebGPU. Si tu juego está en HTML5, esto es lo más rápido para empezar.
La parte específica de cada engine es la API del runtime; la parte de pre/post-proceso, smoothing y mapping de gestos es portable tal cual.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu pose detection funciona pero los keypoints se sienten 'temblorosos' cuando el jugador está quieto. ¿Qué aplicas primero?
Tus FPS de inferencia se desploman al portar a una laptop con GPU integrada. ¿Qué decisión tomas primero?
Quieres detectar a 4 personas simultáneas en cámara. ¿Cuál es la arquitectura razonable?
Vendes un juego de fitness con cámara. ¿Por qué Sentis local es preferible a mandar el frame a un servidor?