File size: 9,549 Bytes
09d639d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""
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)