← BUILKER / BLOG

Detección de Caídas mediante Deep Learning

Los métodos tradicionales para detectar caídas, como analizar cambios bruscos en la postura, calcular angulos o aceleración, suelen fallar con movimientos inesperados, generando muchas falsas alarmas.....

Example of fall detecion | Video from 50 Ways to Fall by Kevin Parry

Los métodos tradicionales para detectar caídas, como analizar cambios bruscos en la postura, calcular angulos o aceleración, suelen fallar con movimientos inesperados, generando muchas falsas alarmas. En este post, exploramos un enfoque distinto: combinamos YOLO Pose para extraer keypoints del cuerpo y una red GRU para analizar los movimientos en el tiempo, logrando una detección más inteligente y confiable.

Los métodos tradicionales para detectar caídas, como analizar cambios bruscos en la postura, calcular angulos o aceleración, suelen fallar con movimientos inesperados, generando muchas falsas alarmas. En este post, exploramos un enfoque distinto: combinamos YOLO Pose para extraer keypoints del cuerpo y una red GRU para analizar los movimientos en el tiempo, logrando una detección más inteligente y confiable.

¿Por qué GRU?

Una red GRU (Gated Recurrent Unit) es un tipo de red neuronal recurrente diseñada para procesar secuencias de datos de manera eficiente. A diferencia de las LSTM, las GRU tienen una arquitectura más simple, eliminando la celda de memoria y usando solo dos puertas (actualización y reinicio). Esto las hace más rápidas y ligeras, lo que es ideal para este caso, donde buscamos procesar movimientos en tiempo real sin comprometer el rendimiento. Además, las GRU suelen funcionar igual de bien que las LSTM en secuencias cortas como las de detección de caídas, por lo que ofrecen una solución más eficiente sin perder precisión.

RNN, LSTM and GRU architecture
A Journey Through RNN, LSTM, GRU, and Beyond medium.com

Para reconocer acciones con una GRU, representamos cada acción como una secuencia de poses individuales, nuestra unidad básica. Cada pose está compuesta por una lista de keypoints que corresponden a las articulaciones del cuerpo. Como los movimientos humanos suelen seguir trayectorias fluidas, la GRU aprende a capturar patrones en el tiempo sin necesidad de almacenar estados complejos, lo que la hace más eficiente para esta tarea.

Motion capture data showing how joints move fluidly over time.

Construyendo Dataset

Para construir el dataset, utilicé el video 50 Ways to Fall del youtuber Kevin Parry. Con FFmpeg, lo dividí en tres partes: caídas, momentos normales y el video completo. Para el entrenamiento, usé solo las secciones de caídas y movimientos normales, asegurando un mejor equilibrio en los datos. Puedes descargar los datos ya procesados y separados en el siguiente enlace: Enlace al dataset.

La extracción de poses se realizó con el modelo YOLOv11 Pose, que detecta 17 keypoints clave del cuerpo, proporcionando información detallada sobre la posición y movimiento de las articulaciones.

Example pose estimated by YOLO11x-pose | Photo by Alexy Almond

En lugar de utilizar una secuencia completa como punto de datos, opte por trabajar con subsecuencias más pequeñas. El reconocimiento de acciones humanas generalmente requiere solo unas pocas imágenes consecutivas (entre 1 y 10 cuadros) para funcionar de manera efectiva. En este caso, use una longitud de secuencia de 20 cuadros.

Si un video tiene menos de 20 cuadros, rellenamos la secuencia duplicando la última pose detectada. Si el video es más largo, tomamos únicamente los últimos 20 cuadros para capturar mejor la fase final de la caída, que es la más relevante para la detección.

extract_keypoints_from_video.pypython
import os
import cv2
import numpy as np
from ultralytics import YOLO

def extract_keypoints_from_video(video_path: str, model, sequence_length: int = 10, save: bool = False, output_path: str = 'keypoints.npy'):
    num_keypoints = 17 * 2  # Número de keypoints (17 puntos * 2 coordenadas x, y)
    
    if not os.path.exists(video_path):
        raise FileNotFoundError(f'El archivo de video {video_path} no existe')
    
    cap = cv2.VideoCapture(video_path)
    keypoints_buffer = []
    
    while True:
        ret, frame = cap.read()
        if not ret:
            break  # Video terminado
        
        results = model(frame)[0]
        
        if len(results.keypoints.xy) > 0:
            keypoints = results.keypoints.xy[0].numpy().flatten()
            if keypoints.shape[0] != num_keypoints:
                keypoints = np.pad(keypoints, (0, num_keypoints - keypoints.shape[0]))
        else:
            # Si no se detectan keypoints, usar ceros
            keypoints = np.zeros(num_keypoints, dtype=np.float32)
            
        keypoints_buffer.append(keypoints)
    
    cap.release()
    
    # Manejar casos donde el video es más corto o más largo que sequence_length
    if len(keypoints_buffer) < sequence_length:
        # Si el video es más corto, repetir el último frame hasta completar sequence_length
        last_frame = keypoints_buffer[-1]
        while len(keypoints_buffer) < sequence_length:
            keypoints_buffer.append(last_frame)
    elif len(keypoints_buffer) > sequence_length:
        # Si el video es más largo, tomar solo los últimos sequence_length frames
        keypoints_buffer = keypoints_buffer[-sequence_length:]
    
    keypoints_buffer = np.array(keypoints_buffer, dtype=np.float32)
    
    if save:
        np.save(output_path, keypoints_buffer)
        print(f'Guardado en {output_path}')
    
    return keypoints_buffer

Un problema común al extraer los datos de las poses es la falta de detección de algunas partes del cuerpo. Inicialmente, las coordenadas (x, y) de estos puntos se marcan como 0 y luego, en el postprocesamiento, se reemplazan por NaN. Sin embargo, las GRU no pueden manejar valores NaN, y dejar los ceros afectaba el rendimiento del modelo al introducir información errónea. Para solucionar esto, opté por imputar los valores faltantes con K-Nearest Neighbors (KNN), usando los 5 vecinos más cercanos. Esto permite una mejor adaptación a trayectorias no lineales, proporcionando datos de entrada más coherentes y mejorando la precisión del modelo.

Comparison of the left foot trajectory before and after data imputation using KNN.

Creando el Modelo GRU

Con los datos ya preprocesados, podemos construir un modelo basado en GRU para clasificar las acciones en dos clases: “caída” o “no caída”. Elegimos una arquitectura basada en GRU en lugar de LSTM porque GRU es más eficiente computacionalmente y maneja mejor secuencias cortas sin perder información relevante.

Para la implementación, utilizamos PyTorch y definimos un modelo secuencial. La primera capa es una GRU, que procesa las secuencias de keypoints extraídas de YOLO Pose. Luego, aplicamos una normalización en batch para estabilizar el entrenamiento, seguida de capas densas con activaciones ReLU para capturar patrones más complejos. También agregamos dropout para reducir el sobreajuste y una capa de salida con activación sigmoide, ya que estamos resolviendo un problema de clasificación binaria.

fall-detection_gru.pypython
import torch
import torch.nn as nn

class FallDetectionGRU(nn.Module):
    def __init__(self, input_size, hidden_size, num_layers, output_size, dropout_prob=0.5):
        super(FallDetectionGRU, self).__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        
        # Capa GRU (en vez de LSTM)
        self.gru = nn.GRU(input_size, hidden_size, num_layers, batch_first=True, dropout=dropout_prob if num_layers > 1 else 0)
        
        # Batch Normalization
        self.bn = nn.BatchNorm1d(hidden_size)
        
        # Capas fully connected adicionales
        self.fc1 = nn.Linear(hidden_size, hidden_size // 2)
        self.fc2 = nn.Linear(hidden_size // 2, hidden_size // 4)
        self.fc3 = nn.Linear(hidden_size // 4, output_size)
        
        # Dropout para regularización
        self.dropout = nn.Dropout(dropout_prob)
        
        # Funciones de activación
        self.relu = nn.ReLU()
        self.sigmoid = nn.Sigmoid()
        
        # Inicialización de pesos
        self._init_weights()
    
    def _init_weights(self):
        """Inicialización de pesos para mejorar la convergencia."""
        for name, param in self.gru.named_parameters():
            if 'weight_ih' in name or 'weight_hh' in name:
                nn.init.xavier_uniform_(param.data)
            elif 'bias' in name:
                param.data.fill_(0)

        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')
                m.bias.data.fill_(0.01)
    
    def forward(self, x):
        # Inicializar estado oculto (sin c0)
        h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device)
        
        # Pasar a través de la GRU
        out, _ = self.gru(x, h0)
        
        # Tomar la salida del último paso de tiempo
        out = out[:, -1, :]
        
        # Batch Normalization
        out = self.bn(out)
        
        # Capas fully connected con activaciones y dropout
        out = self.fc1(out)
        out = self.relu(out)
        out = self.dropout(out)
        
        out = self.fc2(out)
        out = self.relu(out)
        out = self.dropout(out)
        
        out = self.fc3(out)
        
        # Aplicar sigmoide para obtener probabilidades
        out = self.sigmoid(out)
        return out

Entrenamiento del modelo

Para el entrenamiento del modelo, utilicé BCELoss (Binary Cross Entropy Loss) como función de pérdida, ya que es adecuada para problemas de clasificación binaria como en este caso. Dado que la salida del modelo es una probabilidad (tras pasar por una activación sigmoide), BCELoss ayuda a minimizar la incertidumbre en la clasificación de caídas.

Como optimizador, elegí AdamW, una variante de Adam con decaimiento de peso (weight decay). AdamW es más robusto frente al sobreajuste que Adam estándar porque separa la regularización L2 de la actualización de los gradientes, permitiendo un mejor ajuste en redes profundas. Además, es más eficiente que métodos como SGD, ya que ajusta dinámicamente la tasa de aprendizaje para cada parámetro, acelerando la convergencia.

This thing called Weight Decay medium.com

Para evitar sobreentrenamiento, implementé early stopping con una paciencia de 10 épocas. Si la pérdida de validación deja de mejorar después de este número de iteraciones consecutivas, el entrenamiento se detiene automáticamente. Esto previene el sobreajuste y reduce el tiempo de entrenamiento innecesario. Además, guarda el mejor modelo basado en la menor pérdida de validación, asegurando que la versión final del modelo sea la más generalizable a nuevos datos.

train_model.pypython
import torch
import torch.nn as nn
import torch.optim as optim

def train_model(model, train_loader, val_loader, num_epochs=200, learning_rate=0.0001, 
                weight_decay=1e-4, patience=10, device="cuda" if torch.cuda.is_available() else "cpu"):
    """
    Entrena un modelo de detección de caídas usando GRU.
    Args:
        model (torch.nn.Module): Modelo a entrenar.
        train_loader (DataLoader): DataLoader para los datos de entrenamiento.
        val_loader (DataLoader): DataLoader para los datos de validación.
        num_epochs (int): Número de épocas de entrenamiento.
        learning_rate (float): Tasa de aprendizaje.
        weight_decay (float): Parámetro de regularización L2.
        patience (int): Número de épocas sin mejora antes de early stopping.
        device (str): Dispositivo a usar ("cuda" o "cpu").
    Returns:
        model: Modelo entrenado.
        history: Diccionario con métricas de entrenamiento y validación.
    """
    model.to(device)
    criterion = nn.BCELoss()  # Binary Cross Entropy Loss
    optimizer = optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)

    # Listas para almacenar métricas
    history = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}

    best_val_loss = float("inf")
    patience_counter = 0
    best_model = None

    for epoch in range(num_epochs):
        model.train()
        train_loss, correct, total = 0, 0, 0

        for batch_X, batch_y in train_loader:
            batch_X, batch_y = batch_X.to(device), batch_y.to(device)

            optimizer.zero_grad()
            outputs = model(batch_X).squeeze()
            loss = criterion(outputs, batch_y)

            loss.backward()
            optimizer.step()

            train_loss += loss.item()
            predicted = (outputs >= 0.5).float()
            correct += (predicted == batch_y).sum().item()
            total += batch_y.size(0)

        avg_train_loss = train_loss / len(train_loader)
        train_accuracy = correct / total
        history["train_loss"].append(avg_train_loss)
        history["train_acc"].append(train_accuracy)

        # Evaluación en validación
        model.eval()
        val_loss, correct, total = 0, 0, 0

        with torch.no_grad():
            for batch_X, batch_y in val_loader:
                batch_X, batch_y = batch_X.to(device), batch_y.to(device)

                outputs = model(batch_X).squeeze()
                loss = criterion(outputs, batch_y)
                val_loss += loss.item()

                predicted = (outputs >= 0.5).float()
                correct += (predicted == batch_y).sum().item()
                total += batch_y.size(0)

        avg_val_loss = val_loss / len(val_loader)
        val_accuracy = correct / total
        history["val_loss"].append(avg_val_loss)
        history["val_acc"].append(val_accuracy)

        # Guardar el mejor modelo
        if avg_val_loss < best_val_loss:
            best_val_loss = avg_val_loss
            best_model = model.state_dict()
            patience_counter = 0
        else:
            patience_counter += 1

        # Early Stopping
        if patience_counter >= patience:
            print(f"⏹️ Early stopping en la época {epoch + 1}")
            break

        if (epoch + 1) % 5 == 0:
            print(f'Epoch {epoch + 1}: '
                  f'Train Loss: {avg_train_loss:.4f}, Train Acc: {train_accuracy:.4f} | '
                  f'Val Loss: {avg_val_loss:.4f}, Val Acc: {val_accuracy:.4f}')

    # Restaurar el mejor modelo
    if best_model:
        model.load_state_dict(best_model)
        print("✔️ Modelo restaurado al mejor estado guardado.")

    return model, history

Para optimizar el rendimiento de nuestro modelo basado en GRU, ejuste varios hiperparámetros clave, incluyendo el número de épocas, el tamaño del lote (batch size) y la tasa de aprendizaje.

El número de épocas no fue un factor crítico gracias a la implementación de early stopping, por lo que se establecio en 300 épocas. En cuanto al tamaño del lote, evalué 8, 16, 32 y 64, y encontré que 8 proporcionaba mayor estabilidad en la convergencia del modelo. La tasa de aprendizaje de 0.0005 permitió un entrenamiento estable. Para mejorar la generalización y prevenir el sobreajuste, apliqué un weight decay de 1e-6, regulando los pesos sin afectar el desempeño. Finalmente, utilicé un dropout de 0.6, reduciendo la dependencia del modelo en patrones específicos y mejorando su capacidad para generalizar en nuevos datos.

Códigopython
from src.models import FallDetectionGRU
from src.training import train_model

batch_size = 8
train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)
val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)

model = FallDetectionGRU(
    input_size=34, 
    hidden_size=64, 
    num_layers=2, 
    output_size=1, 
    dropout_prob=0.6
)

trained_model, history = train_model(
    model, 
    train_loader, 
    val_loader, 
    num_epochs=300,
    learning_rate=.0005,
    weight_decay=1e-6, 
    patience=15
)
Train results of the GRU model

Algunos ejemplos del modelo en funcionamiento:

Results of the GRU model for fall detecion (Top, Center, Bottom: Video from 50 Ways to Fall by Kevin Parry)

Test Results

Los resultados en el conjunto de prueba fueron positivos, con una clasificación precisa de los datos disponibles. Sin embargo, es importante considerar que el dataset utilizado es pequeño y no abarca una amplia variedad de escenarios o poses, lo que podría afectar la capacidad del modelo para generalizar a nuevas situaciones.

Confusion Matrix GRU testing

Para descartar el sobreajuste, probé el modelo con videos de un dataset externo, como GMDCSA24: A Dataset for Human Fall Detection in Videos. La detección de caídas fue altamente precisa, identificando correctamente los videos de caídas y las poses asociadas. Sin embargo, en algunos casos marcó falsos positivos, detectando caídas en escenarios donde no ocurrían, como al agacharse para levantar un objeto, hacer flexiones en el suelo o estar acostado en una cama.

GMDCSA24: A Dataset for Human Fall Detection in Videos github.com
Example of false positive when lifting an object | Video of GMDCSA24: A Dataset for Human Fall Detection in Videos

Aplicaciones

La detección de caídas tiene múltiples aplicaciones en diversos escenarios donde puede brindar asistencia crucial. Algunos ejemplos incluyen:

  • Atención a adultos mayores: Permite monitorear de manera automática a personas con movilidad reducida, alertando a familiares o servicios médicos en caso de caída.
  • Hospitales y centros de rehabilitación: Ayuda a supervisar a pacientes con riesgo de caídas, reduciendo accidentes en habitaciones o pasillos.
  • Trabajadores en entornos peligrosos: Como en la construcción o la minería, donde detectar caídas a tiempo puede prevenir accidentes fatales.
  • Niños en el patio de recreo, donde los accidentes son frecuentes.

En situaciones críticas, una detección rápida de caídas puede marcar la diferencia entre una intervención oportuna y consecuencias graves, incluso fatales.

Conclusión

Aunque este experimento se basó en un dataset creado a partir de un solo video de YouTube, la implementación tiene un gran potencial en sectores críticos. Para mejorar su precisión y aplicabilidad, en el futuro se podría ampliar el dataset incorporando más videos y secuencias que incluyan situaciones similares a una caída, pero que no lo sean, como levantar un objeto, estar acostado en una cama o realizar ciertos ejercicios físicos. Esto ayudaría a reducir falsos positivos y hacer que el modelo sea más robusto en escenarios del mundo real.


Si deseas acceder al código fuente, los datos, los notebooks o descargar los pesos del modelo, puedes encontrarlos en el repositorio de este proyecto en GitHub https://github.com/Erik172/fall-detection-deep-learning

Fall Detection using Pose Estimation and GRU github.com