""" Lógica pura de análisis postural usando la MediaPipe Tasks API (>= 0.10.14). La API clásica mp.solutions fue eliminada en versiones recientes de MediaPipe. """ import cv2 import numpy as np import urllib.request from pathlib import Path import mediapipe as mp from mediapipe.tasks import python as mp_python from mediapipe.tasks.python import vision as mp_vision # ────────────────────────────────────────────── # Modelo de pose — se descarga la primera vez # ────────────────────────────────────────────── _MODEL_PATH = Path(__file__).parent / "pose_landmarker_lite.task" _MODEL_URL = ( "https://storage.googleapis.com/mediapipe-models/" "pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task" ) def _ensure_model() -> str: if not _MODEL_PATH.exists(): print(f"Descargando modelo MediaPipe Pose (~5 MB)... → {_MODEL_PATH}") urllib.request.urlretrieve(_MODEL_URL, _MODEL_PATH) print("Modelo descargado.") return str(_MODEL_PATH) # ────────────────────────────────────────────── # Conexiones del esqueleto (subset de los 33 landmarks) # ────────────────────────────────────────────── # Índices estándar de MediaPipe Pose Landmarker NOSE = 0 LEFT_EYE = 2 RIGHT_EYE = 5 LEFT_SHOULDER = 11 RIGHT_SHOULDER = 12 LEFT_HIP = 23 RIGHT_HIP = 24 LEFT_KNEE = 25 RIGHT_KNEE = 26 LEFT_ANKLE = 27 RIGHT_ANKLE = 28 POSE_CONNECTIONS = [ # Cabeza (NOSE, LEFT_EYE), (NOSE, RIGHT_EYE), # Tronco (LEFT_SHOULDER, RIGHT_SHOULDER), (LEFT_SHOULDER, LEFT_HIP), (RIGHT_SHOULDER, RIGHT_HIP), (LEFT_HIP, RIGHT_HIP), # Piernas (LEFT_HIP, LEFT_KNEE), (LEFT_KNEE, LEFT_ANKLE), (RIGHT_HIP, RIGHT_KNEE), (RIGHT_KNEE, RIGHT_ANKLE), ] # ────────────────────────────────────────────── # Rangos saludables para cada ángulo (en grados) # ────────────────────────────────────────────── ANGLE_THRESHOLDS = { "shoulder_tilt": { "min": -5.0, "max": 5.0, "label": "Inclinación de hombros", "correction": "Nivelá los hombros: mantenerlos a la misma altura.", }, "lateral_alignment": { "min": 160.0, "max": 180.0, "label": "Alineación lateral (hombro–cadera–tobillo)", "correction": "Corregí la alineación lateral: alineá hombro, cadera y tobillo.", }, "head_tilt": { "min": -10.0, "max": 10.0, "label": "Inclinación de cabeza", "correction": "Corregí la inclinación de la cabeza: mantenela centrada sobre los hombros.", }, } # ────────────────────────────────────────────── # Funciones públicas # ────────────────────────────────────────────── def detect_pose(image_rgb: np.ndarray): """ Ejecuta MediaPipe Pose sobre una imagen RGB (ndarray HxWx3). Retorna la lista de landmarks del primer cuerpo detectado, o None si no hay persona. """ model_path = _ensure_model() base_options = mp_python.BaseOptions(model_asset_path=model_path) options = mp_vision.PoseLandmarkerOptions( base_options=base_options, running_mode=mp_vision.RunningMode.IMAGE, # imagen estática, no video num_poses=1, min_pose_detection_confidence=0.5, min_pose_presence_confidence=0.5, min_tracking_confidence=0.5, ) with mp_vision.PoseLandmarker.create_from_options(options) as landmarker: mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb) result = landmarker.detect(mp_image) if not result.pose_landmarks: return None # Retornamos los landmarks del primer cuerpo detectado return result.pose_landmarks[0] def calculate_angle(a: np.ndarray, b: np.ndarray, c: np.ndarray) -> float: """ Ángulo en grados formado en el punto B, entre los vectores BA y BC. a, b, c: arrays [x, y] en cualquier sistema de coordenadas. """ ba = a - b bc = c - b cos_angle = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-6) return float(np.degrees(np.arccos(np.clip(cos_angle, -1.0, 1.0)))) def _lm_xy(landmark, img_w: int, img_h: int) -> np.ndarray: """Convierte un landmark normalizado a coordenadas de píxel [x, y].""" return np.array([landmark.x * img_w, landmark.y * img_h]) def analyze_posture(landmarks, img_w: int, img_h: int) -> dict: """ Calcula los 3 ángulos posturales y evalúa si están en rango saludable. """ ls = _lm_xy(landmarks[LEFT_SHOULDER], img_w, img_h) rs = _lm_xy(landmarks[RIGHT_SHOULDER], img_w, img_h) lh = _lm_xy(landmarks[LEFT_HIP], img_w, img_h) la = _lm_xy(landmarks[LEFT_ANKLE], img_w, img_h) nose = _lm_xy(landmarks[NOSE], img_w, img_h) # 1. Inclinación de hombros: ángulo del segmento RS→LS respecto a la horizontal delta_y = rs[1] - ls[1] delta_x = rs[0] - ls[0] shoulder_tilt_deg = float(np.degrees(np.arctan2(delta_y, delta_x))) # 2. Alineación lateral hombro–cadera–tobillo lateral_angle = calculate_angle(ls, lh, la) # 3. Inclinación de cabeza: nariz respecto al eje vertical del punto medio de hombros shoulder_mid = (ls + rs) / 2 vertical_ref = shoulder_mid - np.array([0, 50]) # punto 50px arriba (eje vertical) head_tilt_deg = calculate_angle(nose, shoulder_mid, vertical_ref) if nose[0] > shoulder_mid[0]: # añadimos signo según lado head_tilt_deg = -head_tilt_deg angles = { "shoulder_tilt": shoulder_tilt_deg, "lateral_alignment": lateral_angle, "head_tilt": head_tilt_deg, } results = {} all_ok = True for key, value in angles.items(): thresh = ANGLE_THRESHOLDS[key] in_range = thresh["min"] <= value <= thresh["max"] if not in_range: all_ok = False results[key] = { "angle": round(value, 1), "ok": in_range, "label": thresh["label"], "correction": thresh["correction"], } results["all_ok"] = all_ok return results def draw_skeleton(image_rgb: np.ndarray, landmarks, img_w: int, img_h: int) -> np.ndarray: """ Dibuja el skeleton (conexiones + puntos) sobre la imagen con OpenCV. En la Tasks API no hay drawing_utils, lo hacemos a mano. """ out = image_rgb.copy() # Puntos como píxeles pts = { i: (int(lm.x * img_w), int(lm.y * img_h)) for i, lm in enumerate(landmarks) } # Conexiones en celeste for (a, b) in POSE_CONNECTIONS: if a in pts and b in pts: cv2.line(out, pts[a], pts[b], (0, 200, 255), 2, cv2.LINE_AA) # Puntos en blanco for idx, pt in pts.items(): cv2.circle(out, pt, 4, (255, 255, 255), -1, cv2.LINE_AA) cv2.circle(out, pt, 4, (0, 150, 200), 1, cv2.LINE_AA) return out def annotate_angles(image_rgb: np.ndarray, analysis: dict, img_w: int, img_h: int) -> np.ndarray: """ Agrega texto con los valores de ángulo y un banner de resultado global. """ out = image_rgb.copy() GREEN = (34, 197, 94) RED = (239, 68, 68) y_start, line_h = 32, 30 for i, (key, data) in enumerate(analysis.items()): if key == "all_ok": continue color = GREEN if data["ok"] else RED status = "OK" if data["ok"] else "Revisar" text = f"{data['label']}: {data['angle']} [{status}]" cv2.putText(out, text, (10, y_start + i * line_h), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA) # Banner inferior banner_color = GREEN if analysis["all_ok"] else RED banner_text = "POSTURA CORRECTA" if analysis["all_ok"] else "POSTURA REQUIERE CORRECCION" cv2.rectangle(out, (0, img_h - 50), (img_w, img_h), banner_color, -1) cv2.putText(out, banner_text, (10, img_h - 15), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA) return out def generate_feedback(analysis: dict) -> str: """Genera el texto de feedback Markdown para mostrar en Gradio.""" lines = ["## Analisis Postural\n"] for key, data in analysis.items(): if key == "all_ok": continue icon = "OK" if data["ok"] else "Revisar" lines.append(f"**{data['label']}**: {data['angle']} [{icon}]") if not data["ok"]: lines.append(f" → {data['correction']}") lines.append("") if analysis["all_ok"]: lines.append("### Postura correcta") lines.append("Todos los angulos posturales estan dentro del rango saludable.") else: lines.append("### Postura requiere correccion") lines.append("Revisa las indicaciones marcadas con [Revisar].") return "\n".join(lines)