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.
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.
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.
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.
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.
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.
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.comPara 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.
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.
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
) Algunos ejemplos del modelo en funcionamiento:
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.
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.comAplicaciones
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
