🏭 Caso de Uso

Detección de Landmarks con MediaPipe

Localización de puntos clave en rostros, manos y cuerpo humano con MediaPipe: detección de landmarks en tiempo real.

🐍 Python 📓 Jupyter Notebook

Detección de Landmarks con MediaPipe

Localización de puntos clave en rostros, manos y cuerpo humano

Presentación

La detección de landmarks (también llamada detección de puntos clave o keypoint detection) es una de las tareas más importantes de la visión por computador moderna. Consiste en localizar un conjunto predefinido de puntos anatómicos o geométricamente significativos sobre una imagen. A diferencia de la detección de objetos (que devuelve bounding boxes rectangulares) o la segmentación (que clasifica cada píxel), los landmarks proporcionan una representación estructural compacta y rica que codifica la geometría, la pose y la articulación del sujeto.

Objetivos de este notebook

  1. Comprender las bases teóricas de la detección de landmarks: formulación matemática, arquitecturas cascada (detector + regresor), enfoques basados en heatmaps vs. regresión directa, y funciones de pérdida específicas.
  2. Explorar tres modelos de MediaPipe sobre imágenes reales descargadas de internet:
    • Face Mesh — 478 landmarks 3D para reconstrucción facial completa.
    • Hand Landmarks — 21 puntos por mano con topología articular definida.
    • Pose Estimation — 33 landmarks corporales con scores de visibilidad.
  3. Analizar propiedades geométricas de los landmarks detectados: distribución de profundidad Z, visibilidad por región corporal y cálculo de ángulos articulares.
  4. Demostrar aplicaciones prácticas: segmentación multi-persona, análisis biomecánico y tabla comparativa de los modelos.

Bases teóricas

Formulación del problema

Un modelo de landmarks recibe una imagen $I \in \mathbb{R}^{H \times W \times 3}$ y predice un conjunto ordenado de $K$ puntos clave:

$$ f_\theta(I) = {(x_k, y_k, z_k, v_k)}_{k=1}^{K} $$

donde $(x_k, y_k) \in [0, 1]^2$ son las coordenadas 2D normalizadas, $z_k$ es la profundidad relativa (en modelos 3D) y $v_k \in [0, 1]$ es la confianza o visibilidad del punto.

Arquitectura en cascada: detector + regresor

Los sistemas modernos de landmarks (BlazeFace/BlazePose de MediaPipe, OpenPose, HRNet) suelen emplear un pipeline de dos etapas:

  1. Detector de ROI: una red ligera (SSD, BlazeFace) que localiza la región de interés (rostro, palma, persona) con una bounding box.
  2. Regresor de landmarks: una CNN que recibe el recorte (crop) de la ROI y predice directamente las $K$ coordenadas.

Esta separación permite que el regresor trabaje siempre a resolución fija (ej. 192×192 para Face Mesh), independientemente del tamaño de la imagen original.

Enfoques: heatmaps vs. regresión directa

Enfoque Idea Ventajas Modelos representativos
Heatmap Predice un mapa $H_k \in \mathbb{R}^{h \times w}$ por landmark, con un pico gaussiano centrado en la posición Mayor precisión subpíxel, gradientes más estables Hourglass, HRNet, SimpleBaseline
Regresión directa La red predice directamente $(x_k, y_k, z_k)$ como valores escalares Más eficiente, menos memoria, ideal para móvil MediaPipe, MoveNet, PIPNet

Función de pérdida

La pérdida más común es el error cuadrático medio ponderado por visibilidad:

$$ \mathcal{L} = \frac{1}{K} \sum_{k=1}^{K} v_k \left[ (\hat{x}_k - x_k)^2 + (\hat{y}_k - y_k)^2 + (\hat{z}_k - z_k)^2 \right] $$

donde $v_k$ pesa más los landmarks claramente visibles y reduce el impacto de los ocluidos.

MediaPipe: ML on-device de Google

MediaPipe es un framework de machine learning on-device desarrollado por Google que proporciona modelos optimizados para inferencia en tiempo real en CPU, GPU móvil y navegadores web. En este notebook usaremos tres soluciones:

Solución Landmarks Detector Resolución Descripción
Face Mesh 478 puntos 3D BlazeFace (~0.2 ms) 192×192 Malla facial completa con iris refinado
Hands 21 puntos 3D × mano Palm Detector 256×256 Articulaciones y puntas de cada dedo
Pose 33 puntos 3D Person Detector 256×256 Esqueleto corporal completo con visibilidad

Imágenes utilizadas

Usaremos imágenes descargadas de Unsplash (licencia libre) que contienen rostros, manos y cuerpos en diferentes poses. No se necesita ningún dataset estructurado: los modelos de MediaPipe están preentrenados y funcionan directamente sobre cualquier imagen.

[7]
# --- Parche de compatibilidad: mediapipe 0.10.x con protobuf >= 7 ---
# MediaPipe 0.10.14 requiere protobuf<5, pero coexiste con TensorFlow (protobuf>=6.31).
# Parcheamos las incompatibilidades antes de importar mediapipe.

import sys, types

# 1) Mock tensorflow.tools.docs.doc_controls para evitar que mediapipe
#    intente importar TF completo (solo se usa para generar docs, no para inferencia)
for _mn in ['tensorflow', 'tensorflow.tools', 'tensorflow.tools.docs',
            'tensorflow.tools.docs.doc_controls']:
    if _mn not in sys.modules:
        _m = types.ModuleType(_mn)
        if _mn.endswith('doc_controls'):
            _m.do_not_generate_docs = lambda x: x
        sys.modules[_mn] = _m

import mediapipe

# Restaurar tensorflow real (para que otros imports no se vean afectados)
for _k in list(sys.modules.keys()):
    if _k == 'tensorflow' or _k.startswith('tensorflow.'):
        mod = sys.modules[_k]
        if isinstance(mod, types.ModuleType) and getattr(mod, '__file__', None) is None:
            if not hasattr(mod, '__path__'):
                del sys.modules[_k]

# 2) Parche GetPrototype → GetSymbol (eliminado en protobuf 7.x)
from google.protobuf import symbol_database as _sdb
if not hasattr(_sdb.Default(), 'GetPrototype'):
    _sdb.SymbolDatabase.GetPrototype = lambda self, desc: self.GetSymbol(desc.full_name)

# 3) Reemplazar _modify_calculator_options para usar is_repeated
#    en lugar de FieldDescriptor.label (eliminado en protobuf 7.x)
from mediapipe.python.solution_base import SolutionBase as _SB
from collections.abc import Iterable as _Iter
from google.protobuf import symbol_database as _sdb2

def _patched_modify(self, graph_config, calc_params):
    nested = {}
    for cname, val in calc_params.items():
        parts = cname.rsplit('.', 1)
        if len(parts) != 2:
            raise ValueError(f'Invalid key "{cname}"')
        nested.setdefault(parts[0], []).append((parts[1], val))

    def _set_fields(opts, flist):
        for fn, fv in flist:
            if fv is None:
                opts.ClearField(fn)
            elif opts.DESCRIPTOR.fields_by_name[fn].is_repeated:
                if not isinstance(fv, _Iter):
                    raise ValueError(f'{fn} is repeated but value is not iterable')
                opts.ClearField(fn)
                for e in fv:
                    getattr(opts, fn).append(e)
            else:
                setattr(opts, fn, fv)

    for node in graph_config.node:
        nn = node.name or node.calculator
        if nn not in nested:
            continue
        fl = nested[nn]
        done = False
        if node.node_options:
            for elem in node.node_options:
                tn = elem.type_url.split('/')[-1]
                try:
                    db = _sdb2.Default()
                    ot = db.GetSymbol(db.pool.FindMessageTypeByName(tn).full_name)
                except KeyError:
                    continue
                if tn == ot.DESCRIPTOR.full_name:
                    co = ot.FromString(elem.value)
                    _set_fields(co, fl)
                    elem.value = co.SerializeToString()
                    done = True
        if not done and node.HasField('options'):
            for ext in node.options.Extensions:
                _set_fields(node.options.Extensions[ext], fl)

_SB._modify_calculator_options = _patched_modify
print('✓ Parche protobuf/mediapipe aplicado')
✓ Parche protobuf/mediapipe aplicado
[8]
# Librerías y configuración

import cv2
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import urllib.request
import os

import mediapipe as mp

plt.rcParams['figure.figsize'] = (12, 7)

print(f'MediaPipe version: {mp.__version__}')
print(f'OpenCV version: {cv2.__version__}')
MediaPipe version: 0.10.14
OpenCV version: 4.11.0

1) Carga de imágenes de ejemplo

Descargamos imágenes de ejemplo desde URLs públicas. Usaremos imágenes con rostros visibles, manos y cuerpo completo para probar los tres modelos de landmarks.

[9]
# Utilidad para descargar y cargar imágenes desde URL

def load_image_from_url(url, filename=None):
    """Descarga una imagen desde URL y la devuelve en formato RGB."""
    if filename is None:
        filename = url.split('/')[-1].split('?')[0]
    
    cache_dir = '/tmp/landmarks_images'
    os.makedirs(cache_dir, exist_ok=True)
    filepath = os.path.join(cache_dir, filename)
    
    if not os.path.exists(filepath):
        print(f'Descargando {filename}...')
        urllib.request.urlretrieve(url, filepath)
    
    img = cv2.imread(filepath)
    if img is None:
        raise ValueError(f'No se pudo cargar la imagen: {filepath}')
    return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)


# Imágenes de ejemplo (dominio público / licencia libre)
# Usamos imágenes de Unsplash y Pexels (free to use)
FACE_URL = 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=640'
HANDS_URL = 'https://images.unsplash.com/photo-1586348943529-beaae6c28db9?w=640'
POSE_URL = 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=640'
GROUP_URL = 'https://images.unsplash.com/photo-1529156069898-49953e39b3ac?w=640'

img_face = load_image_from_url(FACE_URL, 'face_portrait.jpg')
img_hands = load_image_from_url(HANDS_URL, 'hands_close.jpg')
img_pose = load_image_from_url(POSE_URL, 'pose_exercise.jpg')
img_group = load_image_from_url(GROUP_URL, 'group_people.jpg')

# Visualización
fig, axes = plt.subplots(1, 4, figsize=(20, 5))
titles = ['Rostro', 'Manos', 'Pose corporal', 'Grupo']
for ax, img, title in zip(axes, [img_face, img_hands, img_pose, img_group], titles):
    ax.imshow(img)
    ax.set_title(title, fontsize=13)
    ax.axis('off')
plt.suptitle('Imágenes de ejemplo para detección de landmarks', fontsize=15)
plt.tight_layout()
plt.show()
Output

2) Face Mesh: malla facial con 478 landmarks

El modelo Face Mesh de MediaPipe detecta 478 puntos 3D en cada rostro. Estos puntos forman una malla triangular que cubre toda la superficie facial, incluyendo contornos de ojos, cejas, nariz, labios y mandíbula.

Arquitectura interna

Face Mesh usa un pipeline de dos etapas:

  1. BlazeFace: detector ultraligero (~0.2 ms en móvil) que localiza la bounding box del rostro.
  2. Face Landmark Model: red de regresión que recibe el recorte facial (192×192) y predice 478 landmarks 3D.

El modelo es lo suficientemente eficiente para funcionar en tiempo real en dispositivos móviles, lo que lo hace ideal para aplicaciones de realidad aumentada.

Grupos de landmarks relevantes

Los 478 puntos se organizan en regiones anatómicas:

  • Contorno facial: mandíbula, frente
  • Ojos: párpados, iris (puntos refinados)
  • Cejas: arco superior
  • Nariz: puente, punta, aletas
  • Labios: contorno exterior e interior
[10]
# Inicializamos Face Mesh
mp_face_mesh = mp.solutions.face_mesh
mp_drawing = mp.solutions.drawing_utils
mp_drawing_styles = mp.solutions.drawing_styles

with mp_face_mesh.FaceMesh(
    static_image_mode=True,
    max_num_faces=1,
    refine_landmarks=True,  # Incluye landmarks del iris
    min_detection_confidence=0.5
) as face_mesh:
    results_face = face_mesh.process(img_face)

if results_face.multi_face_landmarks:
    face_landmarks = results_face.multi_face_landmarks[0]
    n_landmarks = len(face_landmarks.landmark)
    print(f'Rostro detectado con {n_landmarks} landmarks')
    
    # Mostramos algunos landmarks clave
    key_points = {
        'Punta de la nariz': 1,
        'Mentón': 152,
        'Ojo izquierdo (centro)': 468,
        'Ojo derecho (centro)': 473,
        'Comisura labio izq': 61,
        'Comisura labio der': 291,
    }
    
    h, w = img_face.shape[:2]
    print(f'\nLandmarks clave (coordenadas en píxeles):')
    for name, idx in key_points.items():
        lm = face_landmarks.landmark[idx]
        print(f'  {name} (#{idx}): x={lm.x*w:.0f}, y={lm.y*h:.0f}, z={lm.z:.4f}')
else:
    print('No se detectó ningún rostro')
Rostro detectado con 478 landmarks

Landmarks clave (coordenadas en píxeles):
  Punta de la nariz (#1): x=300, y=429, z=-0.1000
  Mentón (#152): x=305, y=563, z=0.0690
  Ojo izquierdo (centro) (#468): x=239, y=329, z=-0.0102
  Ojo derecho (centro) (#473): x=357, y=325, z=-0.0098
  Comisura labio izq (#61): x=245, y=460, z=0.0421
  Comisura labio der (#291): x=363, y=455, z=0.0423
I0000 00:00:1773746515.957774 3340639 gl_context_egl.cc:85] Successfully initialized EGL. Major : 1 Minor: 5
I0000 00:00:1773746515.986043 3340902 gl_context.cc:357] GL version: 3.2 (OpenGL ES 3.2 NVIDIA 580.126.09), renderer: NVIDIA GeForce RTX 4090/PCIe/SSE2
W0000 00:00:1773746515.987380 3340873 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
W0000 00:00:1773746515.994815 3340870 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.

Visualización de la malla facial

Dibujamos la malla triangular completa sobre la imagen original. MediaPipe proporciona utilidades de dibujo que conectan los landmarks según la topología facial definida (tesselation).

[11]
# Dibujamos la malla facial completa
fig, axes = plt.subplots(1, 3, figsize=(20, 7))

# 1) Imagen original
axes[0].imshow(img_face)
axes[0].set_title('Imagen original', fontsize=13)
axes[0].axis('off')

# 2) Solo landmarks (puntos)
img_points = img_face.copy()
if results_face.multi_face_landmarks:
    h, w = img_points.shape[:2]
    for lm in face_landmarks.landmark:
        cx, cy = int(lm.x * w), int(lm.y * h)
        cv2.circle(img_points, (cx, cy), 1, (0, 255, 0), -1)
axes[1].imshow(img_points)
axes[1].set_title(f'478 landmarks (puntos)', fontsize=13)
axes[1].axis('off')

# 3) Malla completa (tesselation)
img_mesh = img_face.copy()
if results_face.multi_face_landmarks:
    mp_drawing.draw_landmarks(
        image=img_mesh,
        landmark_list=face_landmarks,
        connections=mp_face_mesh.FACEMESH_TESSELATION,
        landmark_drawing_spec=None,
        connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_tesselation_style()
    )
    # Añadimos contornos de ojos y labios
    mp_drawing.draw_landmarks(
        image=img_mesh,
        landmark_list=face_landmarks,
        connections=mp_face_mesh.FACEMESH_CONTOURS,
        landmark_drawing_spec=None,
        connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_contours_style()
    )
axes[2].imshow(img_mesh)
axes[2].set_title('Malla facial + contornos', fontsize=13)
axes[2].axis('off')

plt.suptitle('Face Mesh: 478 landmarks 3D', fontsize=15)
plt.tight_layout()
plt.show()
Output

Análisis de la distribución 3D de landmarks

Una característica interesante de Face Mesh es que predice coordenadas 3D. La componente $z$ representa la profundidad relativa: valores negativos indican puntos más cercanos a la cámara (como la punta de la nariz) y valores positivos indican puntos más lejanos (como las orejas).

[12]
# Extraemos coordenadas 3D de todos los landmarks faciales
if results_face.multi_face_landmarks:
    coords = np.array([(lm.x, lm.y, lm.z) for lm in face_landmarks.landmark])
    
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))
    
    # Scatter 2D coloreado por profundidad Z
    scatter = axes[0].scatter(
        coords[:, 0], 1 - coords[:, 1],  # Invertimos Y para que coincida con la imagen
        c=coords[:, 2], cmap='coolwarm', s=3, alpha=0.8
    )
    axes[0].set_title('Landmarks 2D coloreados por profundidad Z', fontsize=12)
    axes[0].set_xlabel('X normalizado')
    axes[0].set_ylabel('Y normalizado')
    axes[0].set_aspect('equal')
    plt.colorbar(scatter, ax=axes[0], label='Profundidad Z')
    
    # Histograma de valores Z
    axes[1].hist(coords[:, 2], bins=40, color='steelblue', edgecolor='white', alpha=0.8)
    axes[1].axvline(x=0, color='red', linestyle='--', alpha=0.7, label='Z=0 (plano de referencia)')
    axes[1].set_title('Distribución de profundidad Z', fontsize=12)
    axes[1].set_xlabel('Valor Z')
    axes[1].set_ylabel('Número de landmarks')
    axes[1].legend()
    
    plt.tight_layout()
    plt.show()
    
    print(f'Rango Z: [{coords[:, 2].min():.4f}, {coords[:, 2].max():.4f}]')
    print(f'Nariz (idx 1) Z = {coords[1, 2]:.4f} (punto más cercano esperado)')
Output
Rango Z: [-0.1098, 0.1989]
Nariz (idx 1) Z = -0.1000 (punto más cercano esperado)

3) Hand Landmarks: 21 puntos por mano

El modelo de manos de MediaPipe detecta 21 landmarks por cada mano visible. Estos puntos corresponden a las articulaciones de cada dedo más la muñeca.

Topología de los 21 landmarks

        8   12  16  20         (puntas de los dedos)
        |   |   |   |
        7   11  15  19
        |   |   |   |
        6   10  14  18
        |   |   |   |
    4   5   9   13  17         (nudillos / base)
    |       |            
    3       |            
    |       |            
    2 ------+            
    |                    
    1                          (base del pulgar)
    |                    
    0                          (muñeca)

Cada landmark tiene índices bien definidos:

  • 0: Muñeca
  • 1–4: Pulgar (CMC, MCP, IP, punta)
  • 5–8: Índice (MCP, PIP, DIP, punta)
  • 9–12: Medio
  • 13–16: Anular
  • 17–20: Meñique

Pipeline de detección

Similar a Face Mesh, usa dos etapas:

  1. Palm Detector: localiza la palma (más fácil de detectar que dedos individuales).
  2. Hand Landmark Model: predice 21 puntos 3D desde el recorte de la palma.
[13]
# Inicializamos Hand Landmarks
mp_hands = mp.solutions.hands

with mp_hands.Hands(
    static_image_mode=True,
    max_num_hands=2,
    min_detection_confidence=0.5
) as hands:
    results_hands = hands.process(img_hands)

if results_hands.multi_hand_landmarks:
    n_hands = len(results_hands.multi_hand_landmarks)
    print(f'Manos detectadas: {n_hands}')
    
    for i, (hand_lms, handedness) in enumerate(
        zip(results_hands.multi_hand_landmarks, results_hands.multi_handedness)
    ):
        label = handedness.classification[0].label
        score = handedness.classification[0].score
        print(f'\nMano {i+1}: {label} (confianza: {score:.2%})')
        
        # Puntas de los dedos
        finger_tips = {
            'Pulgar': 4, 'Índice': 8, 'Medio': 12, 'Anular': 16, 'Meñique': 20
        }
        h, w = img_hands.shape[:2]
        for name, idx in finger_tips.items():
            lm = hand_lms.landmark[idx]
            print(f'  Punta {name} (#{idx}): x={lm.x*w:.0f}, y={lm.y*h:.0f}')
else:
    print('No se detectaron manos. Prueba con otra imagen.')
No se detectaron manos. Prueba con otra imagen.
I0000 00:00:1773746516.351244 3340639 gl_context_egl.cc:85] Successfully initialized EGL. Major : 1 Minor: 5
I0000 00:00:1773746516.377709 3340935 gl_context.cc:357] GL version: 3.2 (OpenGL ES 3.2 NVIDIA 580.126.09), renderer: NVIDIA GeForce RTX 4090/PCIe/SSE2
W0000 00:00:1773746516.383802 3340905 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
W0000 00:00:1773746516.394173 3340933 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
[14]
# Visualización de landmarks de manos
fig, axes = plt.subplots(1, 2, figsize=(16, 7))

axes[0].imshow(img_hands)
axes[0].set_title('Imagen original', fontsize=13)
axes[0].axis('off')

img_hands_draw = img_hands.copy()
if results_hands.multi_hand_landmarks:
    for hand_lms in results_hands.multi_hand_landmarks:
        mp_drawing.draw_landmarks(
            img_hands_draw,
            hand_lms,
            mp_hands.HAND_CONNECTIONS,
            mp_drawing_styles.get_default_hand_landmarks_style(),
            mp_drawing_styles.get_default_hand_connections_style()
        )

axes[1].imshow(img_hands_draw)
axes[1].set_title('Hand Landmarks (21 puntos por mano)', fontsize=13)
axes[1].axis('off')

plt.tight_layout()
plt.show()
Output

4) Pose Estimation: 33 landmarks corporales

El modelo de Pose de MediaPipe detecta 33 landmarks que cubren el esqueleto corporal completo: cabeza, hombros, codos, muñecas, caderas, rodillas, tobillos y pies.

Topología del esqueleto

Los 33 puntos se distribuyen así:

  • 0–10: Rostro (nariz, ojos, orejas, boca)
  • 11–12: Hombros (izquierdo, derecho)
  • 13–14: Codos
  • 15–16: Muñecas
  • 17–22: Manos (meñique, índice, pulgar de cada mano)
  • 23–24: Caderas
  • 25–26: Rodillas
  • 27–28: Tobillos
  • 29–32: Pies (talón, punta)

Utilidad de la visibilidad

Cada landmark incluye un score de visibilidad $v_k \in [0, 1]$. Esto es crucial en aplicaciones prácticas: si una persona está de perfil, los landmarks del lado oculto tendrán baja visibilidad. El modelo es capaz de inferir posiciones de puntos ocluidos (predicción, no observación directa), pero con menor confianza.

[15]
# Inicializamos Pose
mp_pose = mp.solutions.pose

with mp_pose.Pose(
    static_image_mode=True,
    model_complexity=2,  # 0=lite, 1=full, 2=heavy (más preciso)
    min_detection_confidence=0.5
) as pose:
    results_pose = pose.process(img_pose)

if results_pose.pose_landmarks:
    pose_landmarks = results_pose.pose_landmarks
    print(f'Pose detectada con {len(pose_landmarks.landmark)} landmarks')
    
    # Landmarks corporales principales
    body_parts = {
        'Nariz': 0, 'Hombro izq': 11, 'Hombro der': 12,
        'Codo izq': 13, 'Codo der': 14,
        'Muñeca izq': 15, 'Muñeca der': 16,
        'Cadera izq': 23, 'Cadera der': 24,
        'Rodilla izq': 25, 'Rodilla der': 26,
        'Tobillo izq': 27, 'Tobillo der': 28,
    }
    
    h, w = img_pose.shape[:2]
    print(f'\nLandmarks principales:')
    for name, idx in body_parts.items():
        lm = pose_landmarks.landmark[idx]
        vis = '✓' if lm.visibility > 0.5 else '✗'
        print(f'  {name} (#{idx}): x={lm.x*w:.0f}, y={lm.y*h:.0f}, vis={lm.visibility:.2f} {vis}')
else:
    print('No se detectó pose corporal')
Downloading model to /home/nuberu/xuan/naux/.venv/lib/python3.10/site-packages/mediapipe/modules/pose_landmark/pose_landmark_heavy.tflite
Pose detectada con 33 landmarks

Landmarks principales:
  Nariz (#0): x=252, y=95, vis=1.00 ✓
  Hombro izq (#11): x=265, y=127, vis=1.00 ✓
  Hombro der (#12): x=189, y=128, vis=1.00 ✓
  Codo izq (#13): x=303, y=185, vis=0.30 ✗
  Codo der (#14): x=182, y=203, vis=1.00 ✓
  Muñeca izq (#15): x=345, y=214, vis=0.21 ✗
  Muñeca der (#16): x=234, y=223, vis=1.00 ✓
  Cadera izq (#23): x=239, y=235, vis=1.00 ✓
  Cadera der (#24): x=199, y=243, vis=1.00 ✓
  Rodilla izq (#25): x=288, y=245, vis=0.95 ✓
  Rodilla der (#26): x=222, y=352, vis=0.99 ✓
  Tobillo izq (#27): x=305, y=328, vis=0.94 ✓
  Tobillo der (#28): x=155, y=334, vis=0.76 ✓
I0000 00:00:1773746518.397536 3340639 gl_context_egl.cc:85] Successfully initialized EGL. Major : 1 Minor: 5
I0000 00:00:1773746518.423568 3340968 gl_context.cc:357] GL version: 3.2 (OpenGL ES 3.2 NVIDIA 580.126.09), renderer: NVIDIA GeForce RTX 4090/PCIe/SSE2
W0000 00:00:1773746518.457279 3340939 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
W0000 00:00:1773746518.508638 3340959 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
[16]
# Visualización de pose
fig, axes = plt.subplots(1, 2, figsize=(16, 8))

axes[0].imshow(img_pose)
axes[0].set_title('Imagen original', fontsize=13)
axes[0].axis('off')

img_pose_draw = img_pose.copy()
if results_pose.pose_landmarks:
    mp_drawing.draw_landmarks(
        img_pose_draw,
        pose_landmarks,
        mp_pose.POSE_CONNECTIONS,
        landmark_drawing_spec=mp_drawing_styles.get_default_pose_landmarks_style()
    )

axes[1].imshow(img_pose_draw)
axes[1].set_title('Pose Landmarks (33 puntos)', fontsize=13)
axes[1].axis('off')

plt.tight_layout()
plt.show()
Output

Análisis de visibilidad por región corporal

Veamos qué partes del cuerpo detecta el modelo con mayor confianza. Los puntos con alta visibilidad están claramente expuestos en la imagen, mientras que los de baja visibilidad podrían estar ocluidos o en un ángulo desfavorable.

[17]
# Análisis de visibilidad
if results_pose.pose_landmarks:
    landmark_names = [
        'nariz', 'ojo_int_izq', 'ojo_izq', 'ojo_ext_izq', 'ojo_int_der',
        'ojo_der', 'ojo_ext_der', 'oreja_izq', 'oreja_der', 'boca_izq',
        'boca_der', 'hombro_izq', 'hombro_der', 'codo_izq', 'codo_der',
        'muñeca_izq', 'muñeca_der', 'meñique_izq', 'meñique_der',
        'índice_izq', 'índice_der', 'pulgar_izq', 'pulgar_der',
        'cadera_izq', 'cadera_der', 'rodilla_izq', 'rodilla_der',
        'tobillo_izq', 'tobillo_der', 'talón_izq', 'talón_der',
        'pie_izq', 'pie_der'
    ]
    
    visibilities = [lm.visibility for lm in pose_landmarks.landmark]
    
    # Coloreamos por visibilidad
    colors = ['forestgreen' if v > 0.7 else 'orange' if v > 0.3 else 'crimson' for v in visibilities]
    
    plt.figure(figsize=(14, 6))
    plt.barh(range(33), visibilities, color=colors, edgecolor='white', height=0.7)
    plt.yticks(range(33), landmark_names, fontsize=8)
    plt.xlabel('Visibilidad')
    plt.title('Visibilidad de cada landmark de pose')
    plt.axvline(x=0.5, color='gray', linestyle='--', alpha=0.5, label='Umbral 0.5')
    
    # Leyenda
    patches = [
        mpatches.Patch(color='forestgreen', label='Alta (>0.7)'),
        mpatches.Patch(color='orange', label='Media (0.3–0.7)'),
        mpatches.Patch(color='crimson', label='Baja (<0.3)')
    ]
    plt.legend(handles=patches, loc='lower right')
    plt.tight_layout()
    plt.show()
Output

5) Aplicación combinada: múltiples personas

Una aplicación realista implica procesar imágenes con múltiples personas. Face Mesh y Hands permiten detectar múltiples instancias, mientras que Pose detecta una persona a la vez (se puede iterar con un detector de personas previo).

Probamos Face Mesh en la imagen de grupo para ver cómo maneja múltiples rostros.

[18]
# Face Mesh en imagen de grupo
with mp_face_mesh.FaceMesh(
    static_image_mode=True,
    max_num_faces=10,
    refine_landmarks=True,
    min_detection_confidence=0.5
) as face_mesh:
    results_group = face_mesh.process(img_group)

img_group_draw = img_group.copy()
n_faces = 0

if results_group.multi_face_landmarks:
    n_faces = len(results_group.multi_face_landmarks)
    for face_lms in results_group.multi_face_landmarks:
        mp_drawing.draw_landmarks(
            image=img_group_draw,
            landmark_list=face_lms,
            connections=mp_face_mesh.FACEMESH_CONTOURS,
            landmark_drawing_spec=None,
            connection_drawing_spec=mp_drawing_styles.get_default_face_mesh_contours_style()
        )

fig, axes = plt.subplots(1, 2, figsize=(18, 7))
axes[0].imshow(img_group)
axes[0].set_title('Imagen original', fontsize=13)
axes[0].axis('off')

axes[1].imshow(img_group_draw)
axes[1].set_title(f'Face Mesh: {n_faces} rostros detectados', fontsize=13)
axes[1].axis('off')

plt.tight_layout()
plt.show()

print(f'Rostros detectados: {n_faces}')
I0000 00:00:1773746518.888538 3340639 gl_context_egl.cc:85] Successfully initialized EGL. Major : 1 Minor: 5
I0000 00:00:1773746518.915730 3341001 gl_context.cc:357] GL version: 3.2 (OpenGL ES 3.2 NVIDIA 580.126.09), renderer: NVIDIA GeForce RTX 4090/PCIe/SSE2
W0000 00:00:1773746518.916983 3340969 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
W0000 00:00:1773746518.923359 3340997 inference_feedback_manager.cc:114] Feedback manager requires a model with a single signature inference. Disabling support for feedback tensors.
Output
Rostros detectados: 0

6) Cálculo de ángulos articulares a partir de landmarks

Una aplicación práctica importante de los landmarks es el cálculo de ángulos articulares. Por ejemplo, en fisioterapia o análisis deportivo se mide el ángulo del codo, rodilla u hombro.

Dado tres landmarks $A$, $B$, $C$ donde $B$ es la articulación, el ángulo se calcula como:

$$ \theta = \arccos\left(\frac{\vec{BA} \cdot \vec{BC}}{|\vec{BA}| , |\vec{BC}|}\right) $$

donde $\vec{BA} = A - B$ y $\vec{BC} = C - B$.

[19]
def calculate_angle(a, b, c):
    """Calcula el ángulo en grados en el punto B, formado por A-B-C."""
    a = np.array(a)
    b = np.array(b)
    c = np.array(c)
    
    ba = a - b
    bc = c - b
    
    cosine = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-8)
    angle = np.degrees(np.arccos(np.clip(cosine, -1, 1)))
    return angle


# Calculamos ángulos articulares si tenemos pose
if results_pose.pose_landmarks:
    lms = results_pose.pose_landmarks.landmark
    
    def get_coords(idx):
        return [lms[idx].x, lms[idx].y]
    
    # Ángulos articulares clave
    angles = {
        'Codo izquierdo': calculate_angle(get_coords(11), get_coords(13), get_coords(15)),
        'Codo derecho': calculate_angle(get_coords(12), get_coords(14), get_coords(16)),
        'Hombro izquierdo': calculate_angle(get_coords(13), get_coords(11), get_coords(23)),
        'Hombro derecho': calculate_angle(get_coords(14), get_coords(12), get_coords(24)),
        'Rodilla izquierda': calculate_angle(get_coords(23), get_coords(25), get_coords(27)),
        'Rodilla derecha': calculate_angle(get_coords(24), get_coords(26), get_coords(28)),
        'Cadera izquierda': calculate_angle(get_coords(11), get_coords(23), get_coords(25)),
        'Cadera derecha': calculate_angle(get_coords(12), get_coords(24), get_coords(26)),
    }
    
    print('Ángulos articulares estimados:')
    for name, angle in angles.items():
        print(f'  {name}: {angle:.1f}°')
    
    # Visualización de ángulos
    plt.figure(figsize=(10, 5))
    names = list(angles.keys())
    values = list(angles.values())
    colors = ['#3498db' if 'izq' in n else '#e74c3c' for n in names]
    
    plt.barh(names, values, color=colors, edgecolor='white')
    plt.xlabel('Ángulo (grados)')
    plt.title('Ángulos articulares estimados a partir de landmarks de pose')
    plt.axvline(x=90, color='gray', linestyle='--', alpha=0.5, label='90°')
    plt.axvline(x=180, color='gray', linestyle=':', alpha=0.5, label='180°')
    plt.legend()
    plt.tight_layout()
    plt.show()
else:
    print('No hay datos de pose disponibles para calcular ángulos')
Ángulos articulares estimados:
  Codo izquierdo: 159.9°
  Codo derecho: 116.7°
  Hombro izquierdo: 32.8°
  Hombro derecho: 6.7°
  Rodilla izquierda: 114.3°
  Rodilla derecha: 59.7°
  Cadera izquierda: 97.5°
  Cadera derecha: 175.4°
Output

7) Comparativa de los tres modelos

Resumimos las características de los tres modelos de landmarks que hemos explorado.

[20]
import pandas as pd

comparison = pd.DataFrame({
    'Modelo': ['Face Mesh', 'Hand Landmarks', 'Pose Estimation'],
    'Landmarks': [478, 21, 33],
    'Dimensiones': ['3D (x, y, z)', '3D (x, y, z)', '3D (x, y, z) + visibilidad'],
    'Multi-instancia': ['Sí (max_num_faces)', 'Sí (max_num_hands=2)', 'No (1 persona)'],
    'Detector previo': ['BlazeFace', 'Palm Detector', 'Person Detector'],
    'Aplicaciones típicas': [
        'AR, expresiones, identidad',
        'Gestos, lenguaje signos, control',
        'Deportes, fisioterapia, animación'
    ]
})

display(comparison)
Modelo Landmarks Dimensiones Multi-instancia Detector previo Aplicaciones típicas
0 Face Mesh 478 3D (x, y, z) Sí (max_num_faces) BlazeFace AR, expresiones, identidad
1 Hand Landmarks 21 3D (x, y, z) Sí (max_num_hands=2) Palm Detector Gestos, lenguaje signos, control
2 Pose Estimation 33 3D (x, y, z) + visibilidad No (1 persona) Person Detector Deportes, fisioterapia, animación

Conclusiones y siguientes pasos

Conclusiones

  1. Los landmarks proporcionan representaciones estructurales ricas que van mucho más allá de las bounding boxes de la detección de objetos. Permiten entender la geometría y articulación del sujeto.
  2. MediaPipe ofrece modelos optimizados que funcionan en tiempo real incluso en CPU, gracias a su arquitectura ligera de cascada detector + regresor.
  3. Face Mesh (478 puntos) permite aplicaciones sofisticadas de realidad aumentada y análisis facial. La componente Z añade información 3D valiosa.
  4. Hand Landmarks (21 puntos) con su topología bien definida facilita el reconocimiento de gestos y la estimación de la configuración de la mano.
  5. Pose Estimation (33 puntos) proporciona un esqueleto corporal completo con scores de visibilidad, esencial para análisis de movimiento y biomecánica.
  6. Los ángulos articulares calculados a partir de landmarks tienen aplicación directa en deportes, rehabilitación y ergonomía.

Qué podrías explorar después

  • Procesar vídeo en tiempo real con la webcam usando estos mismos modelos.
  • Implementar un clasificador de gestos de mano usando los landmarks como features.
  • Comparar con otros modelos de landmarks: OpenPose, HRNet, MMPose.
  • Usar landmarks faciales para transferencia de expresiones entre caras.
  • Entrenar un modelo de action recognition a partir de secuencias temporales de landmarks de pose.

Idea final: los landmarks son el puente entre la percepción visual (qué hay en la imagen) y la comprensión estructural (cómo está organizado). Son fundamentales en aplicaciones que requieren interacción humano-computador.