kinemotion 0.76.3__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kinemotion might be problematic. Click here for more details.

Files changed (53) hide show
  1. kinemotion/__init__.py +3 -18
  2. kinemotion/api.py +7 -27
  3. kinemotion/cli.py +2 -4
  4. kinemotion/{countermovement_jump → cmj}/analysis.py +158 -16
  5. kinemotion/{countermovement_jump → cmj}/api.py +18 -46
  6. kinemotion/{countermovement_jump → cmj}/cli.py +46 -6
  7. kinemotion/cmj/debug_overlay.py +457 -0
  8. kinemotion/{countermovement_jump → cmj}/joint_angles.py +31 -96
  9. kinemotion/{countermovement_jump → cmj}/metrics_validator.py +271 -176
  10. kinemotion/{countermovement_jump → cmj}/validation_bounds.py +18 -1
  11. kinemotion/core/__init__.py +2 -11
  12. kinemotion/core/auto_tuning.py +107 -149
  13. kinemotion/core/cli_utils.py +0 -74
  14. kinemotion/core/debug_overlay_utils.py +15 -142
  15. kinemotion/core/experimental.py +51 -55
  16. kinemotion/core/filtering.py +56 -116
  17. kinemotion/core/pipeline_utils.py +2 -2
  18. kinemotion/core/pose.py +98 -47
  19. kinemotion/core/quality.py +6 -4
  20. kinemotion/core/smoothing.py +51 -65
  21. kinemotion/core/types.py +0 -15
  22. kinemotion/core/validation.py +7 -76
  23. kinemotion/core/video_io.py +27 -41
  24. kinemotion/{drop_jump → dropjump}/__init__.py +8 -2
  25. kinemotion/{drop_jump → dropjump}/analysis.py +120 -282
  26. kinemotion/{drop_jump → dropjump}/api.py +33 -59
  27. kinemotion/{drop_jump → dropjump}/cli.py +136 -70
  28. kinemotion/dropjump/debug_overlay.py +182 -0
  29. kinemotion/{drop_jump → dropjump}/kinematics.py +65 -175
  30. kinemotion/{drop_jump → dropjump}/metrics_validator.py +51 -25
  31. kinemotion/{drop_jump → dropjump}/validation_bounds.py +1 -1
  32. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +3 -0
  33. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +3 -0
  34. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/METADATA +26 -75
  35. kinemotion-2.0.0.dist-info/RECORD +49 -0
  36. kinemotion/core/overlay_constants.py +0 -61
  37. kinemotion/core/video_analysis_base.py +0 -132
  38. kinemotion/countermovement_jump/debug_overlay.py +0 -325
  39. kinemotion/drop_jump/debug_overlay.py +0 -241
  40. kinemotion/squat_jump/__init__.py +0 -5
  41. kinemotion/squat_jump/analysis.py +0 -377
  42. kinemotion/squat_jump/api.py +0 -610
  43. kinemotion/squat_jump/cli.py +0 -309
  44. kinemotion/squat_jump/debug_overlay.py +0 -163
  45. kinemotion/squat_jump/kinematics.py +0 -342
  46. kinemotion/squat_jump/metrics_validator.py +0 -438
  47. kinemotion/squat_jump/validation_bounds.py +0 -221
  48. kinemotion-0.76.3.dist-info/RECORD +0 -57
  49. /kinemotion/{countermovement_jump → cmj}/__init__.py +0 -0
  50. /kinemotion/{countermovement_jump → cmj}/kinematics.py +0 -0
  51. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/WHEEL +0 -0
  52. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/entry_points.txt +0 -0
  53. {kinemotion-0.76.3.dist-info → kinemotion-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,457 @@
1
+ """Debug overlay rendering for CMJ analysis."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
7
+ from .joint_angles import calculate_triple_extension
8
+ from .kinematics import CMJMetrics
9
+
10
+
11
+ class CMJPhaseState:
12
+ """States for CMJ phases."""
13
+
14
+ STANDING = "standing"
15
+ ECCENTRIC = "eccentric"
16
+ TRANSITION = "transition"
17
+ CONCENTRIC = "concentric"
18
+ FLIGHT = "flight"
19
+ LANDING = "landing"
20
+
21
+
22
+ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
23
+ """Renders debug information on CMJ video frames."""
24
+
25
+ def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> str:
26
+ """Determine which phase the current frame is in."""
27
+ if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
28
+ return CMJPhaseState.STANDING
29
+
30
+ if frame_idx < metrics.lowest_point_frame:
31
+ return CMJPhaseState.ECCENTRIC
32
+
33
+ # Brief transition at lowest point (±2 frames)
34
+ if abs(frame_idx - metrics.lowest_point_frame) < 2:
35
+ return CMJPhaseState.TRANSITION
36
+
37
+ if frame_idx < metrics.takeoff_frame:
38
+ return CMJPhaseState.CONCENTRIC
39
+
40
+ if frame_idx < metrics.landing_frame:
41
+ return CMJPhaseState.FLIGHT
42
+
43
+ return CMJPhaseState.LANDING
44
+
45
+ def _get_phase_color(self, phase: str) -> tuple[int, int, int]:
46
+ """Get color for each phase."""
47
+ colors = {
48
+ CMJPhaseState.STANDING: (255, 200, 100), # Light blue
49
+ CMJPhaseState.ECCENTRIC: (0, 165, 255), # Orange
50
+ CMJPhaseState.TRANSITION: (255, 0, 255), # Magenta/Purple
51
+ CMJPhaseState.CONCENTRIC: (0, 255, 0), # Green
52
+ CMJPhaseState.FLIGHT: (0, 0, 255), # Red
53
+ CMJPhaseState.LANDING: (255, 255, 255), # White
54
+ }
55
+ return colors.get(phase, (128, 128, 128))
56
+
57
+ def _get_skeleton_segments(
58
+ self, side_prefix: str
59
+ ) -> list[tuple[str, str, tuple[int, int, int], int]]:
60
+ """Get skeleton segments for one side of the body."""
61
+ return [
62
+ (f"{side_prefix}heel", f"{side_prefix}ankle", (0, 255, 255), 3), # Foot
63
+ (
64
+ f"{side_prefix}heel",
65
+ f"{side_prefix}foot_index",
66
+ (0, 255, 255),
67
+ 2,
68
+ ), # Alt foot
69
+ (f"{side_prefix}ankle", f"{side_prefix}knee", (255, 100, 100), 4), # Shin
70
+ (f"{side_prefix}knee", f"{side_prefix}hip", (100, 255, 100), 4), # Femur
71
+ (
72
+ f"{side_prefix}hip",
73
+ f"{side_prefix}shoulder",
74
+ (100, 100, 255),
75
+ 4,
76
+ ), # Trunk
77
+ (f"{side_prefix}shoulder", "nose", (150, 150, 255), 2), # Neck
78
+ ]
79
+
80
+ def _draw_segment(
81
+ self,
82
+ frame: np.ndarray,
83
+ landmarks: dict[str, tuple[float, float, float]],
84
+ start_key: str,
85
+ end_key: str,
86
+ color: tuple[int, int, int],
87
+ thickness: int,
88
+ ) -> None:
89
+ """Draw a single skeleton segment if both endpoints are visible."""
90
+ if start_key not in landmarks or end_key not in landmarks:
91
+ return
92
+
93
+ start_vis = landmarks[start_key][2]
94
+ end_vis = landmarks[end_key][2]
95
+
96
+ # Very low threshold to show as much as possible
97
+ if start_vis > 0.2 and end_vis > 0.2:
98
+ start_x = int(landmarks[start_key][0] * self.width)
99
+ start_y = int(landmarks[start_key][1] * self.height)
100
+ end_x = int(landmarks[end_key][0] * self.width)
101
+ end_y = int(landmarks[end_key][1] * self.height)
102
+
103
+ cv2.line(frame, (start_x, start_y), (end_x, end_y), color, thickness)
104
+
105
+ def _draw_joints(
106
+ self,
107
+ frame: np.ndarray,
108
+ landmarks: dict[str, tuple[float, float, float]],
109
+ side_prefix: str,
110
+ ) -> None:
111
+ """Draw joint circles for one side of the body."""
112
+ joint_keys = [
113
+ f"{side_prefix}heel",
114
+ f"{side_prefix}foot_index",
115
+ f"{side_prefix}ankle",
116
+ f"{side_prefix}knee",
117
+ f"{side_prefix}hip",
118
+ f"{side_prefix}shoulder",
119
+ ]
120
+ for key in joint_keys:
121
+ if key in landmarks and landmarks[key][2] > 0.2:
122
+ jx = int(landmarks[key][0] * self.width)
123
+ jy = int(landmarks[key][1] * self.height)
124
+ cv2.circle(frame, (jx, jy), 6, (255, 255, 255), -1)
125
+ cv2.circle(frame, (jx, jy), 8, (0, 0, 0), 2)
126
+
127
+ def _draw_skeleton(
128
+ self, frame: np.ndarray, landmarks: dict[str, tuple[float, float, float]]
129
+ ) -> None:
130
+ """Draw skeleton segments showing body landmarks.
131
+
132
+ Draws whatever landmarks are visible. In side-view videos, ankle/knee
133
+ may have low visibility, so we draw available segments.
134
+
135
+ Args:
136
+ frame: Frame to draw on (modified in place)
137
+ landmarks: Pose landmarks
138
+ """
139
+ # Try both sides and draw all visible segments
140
+ for side_prefix in ["right_", "left_"]:
141
+ segments = self._get_skeleton_segments(side_prefix)
142
+
143
+ # Draw ALL visible segments (not just one side)
144
+ for start_key, end_key, color, thickness in segments:
145
+ self._draw_segment(frame, landmarks, start_key, end_key, color, thickness)
146
+
147
+ # Draw joints as circles for this side
148
+ self._draw_joints(frame, landmarks, side_prefix)
149
+
150
+ # Always draw nose (head position) if visible
151
+ if "nose" in landmarks and landmarks["nose"][2] > 0.2:
152
+ nx = int(landmarks["nose"][0] * self.width)
153
+ ny = int(landmarks["nose"][1] * self.height)
154
+ cv2.circle(frame, (nx, ny), 8, (255, 255, 0), -1)
155
+ cv2.circle(frame, (nx, ny), 10, (0, 0, 0), 2)
156
+
157
+ def _draw_joint_angles(
158
+ self,
159
+ frame: np.ndarray,
160
+ landmarks: dict[str, tuple[float, float, float]],
161
+ phase_color: tuple[int, int, int],
162
+ ) -> None:
163
+ """Draw joint angles for triple extension analysis.
164
+
165
+ Args:
166
+ frame: Frame to draw on (modified in place)
167
+ landmarks: Pose landmarks
168
+ phase_color: Current phase color
169
+ """
170
+ # Try right side first, fallback to left
171
+ angles = calculate_triple_extension(landmarks, side="right")
172
+ side_used = "right"
173
+
174
+ if angles is None:
175
+ angles = calculate_triple_extension(landmarks, side="left")
176
+ side_used = "left"
177
+
178
+ if angles is None:
179
+ return
180
+
181
+ # Position for angle text display (right side of frame)
182
+ text_x = self.width - 180
183
+ text_y = 100
184
+
185
+ # Draw background box for angles
186
+ box_height = 150
187
+ cv2.rectangle(
188
+ frame,
189
+ (text_x - 10, text_y - 30),
190
+ (self.width - 10, text_y + box_height),
191
+ (0, 0, 0),
192
+ -1,
193
+ )
194
+ cv2.rectangle(
195
+ frame,
196
+ (text_x - 10, text_y - 30),
197
+ (self.width - 10, text_y + box_height),
198
+ phase_color,
199
+ 2,
200
+ )
201
+
202
+ # Title
203
+ cv2.putText(
204
+ frame,
205
+ "TRIPLE EXTENSION",
206
+ (text_x, text_y - 5),
207
+ cv2.FONT_HERSHEY_SIMPLEX,
208
+ 0.5,
209
+ (255, 255, 255),
210
+ 1,
211
+ )
212
+
213
+ # Draw available angles (show "N/A" for unavailable)
214
+ angle_data = [
215
+ ("Ankle", angles.get("ankle_angle"), (0, 255, 255)),
216
+ ("Knee", angles.get("knee_angle"), (255, 100, 100)),
217
+ ("Hip", angles.get("hip_angle"), (100, 255, 100)),
218
+ ("Trunk", angles.get("trunk_tilt"), (100, 100, 255)),
219
+ ]
220
+
221
+ y_offset = text_y + 25
222
+ for label, angle, color in angle_data:
223
+ # Angle text
224
+ if angle is not None:
225
+ angle_text = f"{label}: {angle:.0f}"
226
+ text_color = color
227
+ else:
228
+ angle_text = f"{label}: N/A"
229
+ text_color = (128, 128, 128) # Gray for unavailable
230
+
231
+ cv2.putText(
232
+ frame,
233
+ angle_text,
234
+ (text_x, y_offset),
235
+ cv2.FONT_HERSHEY_SIMPLEX,
236
+ 0.5,
237
+ text_color,
238
+ 2,
239
+ )
240
+ y_offset += 30
241
+
242
+ # Draw angle arcs at joints for visual feedback (only if angle is available)
243
+ ankle_angle = angles.get("ankle_angle")
244
+ if ankle_angle is not None:
245
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_ankle", ankle_angle)
246
+ knee_angle = angles.get("knee_angle")
247
+ if knee_angle is not None:
248
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_knee", knee_angle)
249
+ hip_angle = angles.get("hip_angle")
250
+ if hip_angle is not None:
251
+ self._draw_angle_arc(frame, landmarks, f"{side_used}_hip", hip_angle)
252
+
253
+ def _draw_angle_arc(
254
+ self,
255
+ frame: np.ndarray,
256
+ landmarks: dict[str, tuple[float, float, float]],
257
+ joint_key: str,
258
+ angle: float,
259
+ ) -> None:
260
+ """Draw a small arc at a joint to visualize the angle.
261
+
262
+ Args:
263
+ frame: Frame to draw on (modified in place)
264
+ landmarks: Pose landmarks
265
+ joint_key: Key of the joint landmark
266
+ angle: Angle value in degrees
267
+ """
268
+ if joint_key not in landmarks or landmarks[joint_key][2] < 0.3:
269
+ return
270
+
271
+ jx = int(landmarks[joint_key][0] * self.width)
272
+ jy = int(landmarks[joint_key][1] * self.height)
273
+
274
+ # Draw arc radius based on angle (smaller arc for more extended joints)
275
+ radius = 25
276
+
277
+ # Color based on extension: green for extended (>160°), red for flexed (<90°)
278
+ if angle > 160:
279
+ arc_color = (0, 255, 0) # Green - good extension
280
+ elif angle < 90:
281
+ arc_color = (0, 0, 255) # Red - deep flexion
282
+ else:
283
+ arc_color = (0, 165, 255) # Orange - moderate
284
+
285
+ # Draw arc (simplified as a circle for now)
286
+ cv2.circle(frame, (jx, jy), radius, arc_color, 2)
287
+
288
+ def _draw_foot_landmarks(
289
+ self,
290
+ frame: np.ndarray,
291
+ landmarks: dict[str, tuple[float, float, float]],
292
+ phase_color: tuple[int, int, int],
293
+ ) -> None:
294
+ """Draw foot landmarks and average position."""
295
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
296
+ foot_positions = []
297
+
298
+ for key in foot_keys:
299
+ if key in landmarks:
300
+ x, y, vis = landmarks[key]
301
+ if vis > 0.5:
302
+ lx = int(x * self.width)
303
+ ly = int(y * self.height)
304
+ foot_positions.append((lx, ly))
305
+ cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
306
+
307
+ # Draw average foot position with phase color
308
+ if foot_positions:
309
+ avg_x = int(np.mean([p[0] for p in foot_positions]))
310
+ avg_y = int(np.mean([p[1] for p in foot_positions]))
311
+ cv2.circle(frame, (avg_x, avg_y), 12, phase_color, -1)
312
+ cv2.circle(frame, (avg_x, avg_y), 14, (255, 255, 255), 2)
313
+
314
+ def _draw_phase_banner(
315
+ self, frame: np.ndarray, phase: str | None, phase_color: tuple[int, int, int]
316
+ ) -> None:
317
+ """Draw phase indicator banner."""
318
+ if not phase:
319
+ return
320
+
321
+ phase_text = f"Phase: {phase.upper()}"
322
+ text_size = cv2.getTextSize(phase_text, cv2.FONT_HERSHEY_SIMPLEX, 1, 2)[0]
323
+ cv2.rectangle(frame, (5, 5), (text_size[0] + 15, 45), phase_color, -1)
324
+ cv2.putText(frame, phase_text, (10, 35), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
325
+
326
+ def _draw_key_frame_markers(
327
+ self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
328
+ ) -> None:
329
+ """Draw markers for key frames (standing start, lowest, takeoff, landing)."""
330
+ y_offset = 120
331
+ markers = []
332
+
333
+ if metrics.standing_start_frame and frame_idx == int(metrics.standing_start_frame):
334
+ markers.append("COUNTERMOVEMENT START")
335
+
336
+ if frame_idx == int(metrics.lowest_point_frame):
337
+ markers.append("LOWEST POINT")
338
+
339
+ if frame_idx == int(metrics.takeoff_frame):
340
+ markers.append("TAKEOFF")
341
+
342
+ if frame_idx == int(metrics.landing_frame):
343
+ markers.append("LANDING")
344
+
345
+ for marker in markers:
346
+ cv2.putText(
347
+ frame,
348
+ marker,
349
+ (10, y_offset),
350
+ cv2.FONT_HERSHEY_SIMPLEX,
351
+ 0.7,
352
+ (255, 255, 0),
353
+ 2,
354
+ )
355
+ y_offset += 35
356
+
357
+ def _draw_metrics_summary(
358
+ self, frame: np.ndarray, frame_idx: int, metrics: CMJMetrics
359
+ ) -> None:
360
+ """Draw metrics summary in bottom right (last 30 frames)."""
361
+ total_frames = int(metrics.landing_frame) + 30
362
+ if frame_idx < total_frames - 30:
363
+ return
364
+
365
+ metrics_text = [
366
+ f"Jump Height: {metrics.jump_height:.3f}m",
367
+ f"Flight Time: {metrics.flight_time * 1000:.0f}ms",
368
+ f"CM Depth: {metrics.countermovement_depth:.3f}m",
369
+ f"Ecc Duration: {metrics.eccentric_duration * 1000:.0f}ms",
370
+ f"Con Duration: {metrics.concentric_duration * 1000:.0f}ms",
371
+ ]
372
+
373
+ # Draw background
374
+ box_height = len(metrics_text) * 30 + 20
375
+ cv2.rectangle(
376
+ frame,
377
+ (self.width - 320, self.height - box_height - 10),
378
+ (self.width - 10, self.height - 10),
379
+ (0, 0, 0),
380
+ -1,
381
+ )
382
+ cv2.rectangle(
383
+ frame,
384
+ (self.width - 320, self.height - box_height - 10),
385
+ (self.width - 10, self.height - 10),
386
+ (0, 255, 0),
387
+ 2,
388
+ )
389
+
390
+ # Draw metrics text
391
+ text_y = self.height - box_height + 10
392
+ for text in metrics_text:
393
+ cv2.putText(
394
+ frame,
395
+ text,
396
+ (self.width - 310, text_y),
397
+ cv2.FONT_HERSHEY_SIMPLEX,
398
+ 0.6,
399
+ (255, 255, 255),
400
+ 1,
401
+ )
402
+ text_y += 30
403
+
404
+ def render_frame(
405
+ self,
406
+ frame: np.ndarray,
407
+ landmarks: dict[str, tuple[float, float, float]] | None,
408
+ frame_idx: int,
409
+ metrics: CMJMetrics | None = None,
410
+ ) -> np.ndarray:
411
+ """
412
+ Render debug overlay on frame.
413
+
414
+ Args:
415
+ frame: Original video frame
416
+ landmarks: Pose landmarks for this frame
417
+ frame_idx: Current frame index
418
+ metrics: CMJ metrics (optional)
419
+
420
+ Returns:
421
+ Frame with debug overlay
422
+ """
423
+ annotated = frame.copy()
424
+
425
+ # Determine current phase if metrics available
426
+ phase = None
427
+ phase_color = (255, 255, 255)
428
+ if metrics:
429
+ phase = self._determine_phase(frame_idx, metrics)
430
+ phase_color = self._get_phase_color(phase)
431
+
432
+ # Draw skeleton and triple extension if landmarks available
433
+ if landmarks:
434
+ self._draw_skeleton(annotated, landmarks)
435
+ self._draw_joint_angles(annotated, landmarks, phase_color)
436
+ self._draw_foot_landmarks(annotated, landmarks, phase_color)
437
+
438
+ # Draw phase indicator banner
439
+ self._draw_phase_banner(annotated, phase, phase_color)
440
+
441
+ # Draw frame number
442
+ cv2.putText(
443
+ annotated,
444
+ f"Frame: {frame_idx}",
445
+ (10, 80),
446
+ cv2.FONT_HERSHEY_SIMPLEX,
447
+ 0.7,
448
+ (255, 255, 255),
449
+ 2,
450
+ )
451
+
452
+ # Draw key frame markers and metrics summary
453
+ if metrics:
454
+ self._draw_key_frame_markers(annotated, frame_idx, metrics)
455
+ self._draw_metrics_summary(annotated, frame_idx, metrics)
456
+
457
+ return annotated
@@ -5,54 +5,6 @@ import math
5
5
  import numpy as np
6
6
 
7
7
 
8
- def _get_side_prefix(side: str) -> str:
9
- """Get the landmark key prefix for a given side.
10
-
11
- Args:
12
- side: Which side ("left" or "right")
13
-
14
- Returns:
15
- The prefix string for landmark keys
16
- """
17
- return "left_" if side == "left" else "right_"
18
-
19
-
20
- def _is_landmark_visible(
21
- landmarks: dict[str, tuple[float, float, float]],
22
- key: str,
23
- threshold: float = 0.3,
24
- ) -> bool:
25
- """Check if a landmark meets the minimum visibility threshold.
26
-
27
- Args:
28
- landmarks: Pose landmarks dictionary
29
- key: Landmark key to check
30
- threshold: Minimum visibility threshold (default: 0.3)
31
-
32
- Returns:
33
- True if landmark exists and meets visibility threshold
34
- """
35
- return key in landmarks and landmarks[key][2] >= threshold
36
-
37
-
38
- def _get_landmark_xy(
39
- landmarks: dict[str, tuple[float, float, float]],
40
- key: str,
41
- ) -> tuple[float, float] | None:
42
- """Extract x, y coordinates from a landmark.
43
-
44
- Args:
45
- landmarks: Pose landmarks dictionary
46
- key: Landmark key to extract
47
-
48
- Returns:
49
- Tuple of (x, y) coordinates, or None if key not found
50
- """
51
- if key not in landmarks:
52
- return None
53
- return (landmarks[key][0], landmarks[key][1])
54
-
55
-
56
8
  def calculate_angle_3_points(
57
9
  point1: tuple[float, float],
58
10
  point2: tuple[float, float],
@@ -136,7 +88,7 @@ def calculate_ankle_angle(
136
88
  Returns:
137
89
  Ankle angle in degrees, or None if landmarks not available
138
90
  """
139
- prefix = _get_side_prefix(side)
91
+ prefix = "left_" if side == "left" else "right_"
140
92
 
141
93
  foot_index_key = f"{prefix}foot_index"
142
94
  heel_key = f"{prefix}heel"
@@ -144,28 +96,23 @@ def calculate_ankle_angle(
144
96
  knee_key = f"{prefix}knee"
145
97
 
146
98
  # Check ankle and knee visibility (required)
147
- if not _is_landmark_visible(landmarks, ankle_key):
99
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
148
100
  return None
149
- if not _is_landmark_visible(landmarks, knee_key):
101
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
150
102
  return None
151
103
 
152
- ankle = _get_landmark_xy(landmarks, ankle_key)
153
- knee = _get_landmark_xy(landmarks, knee_key)
154
-
155
- if ankle is None or knee is None:
156
- return None
104
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
105
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
157
106
 
158
107
  # Try foot_index first (primary: toe tip for plantarflexion accuracy)
159
- if _is_landmark_visible(landmarks, foot_index_key, threshold=0.5):
160
- foot_point = _get_landmark_xy(landmarks, foot_index_key)
161
- if foot_point is not None:
162
- return calculate_angle_3_points(foot_point, ankle, knee)
108
+ if foot_index_key in landmarks and landmarks[foot_index_key][2] > 0.5:
109
+ foot_point = (landmarks[foot_index_key][0], landmarks[foot_index_key][1])
110
+ return calculate_angle_3_points(foot_point, ankle, knee)
163
111
 
164
112
  # Fallback to heel if foot_index visibility is insufficient
165
- if _is_landmark_visible(landmarks, heel_key):
166
- foot_point = _get_landmark_xy(landmarks, heel_key)
167
- if foot_point is not None:
168
- return calculate_angle_3_points(foot_point, ankle, knee)
113
+ if heel_key in landmarks and landmarks[heel_key][2] > 0.3:
114
+ foot_point = (landmarks[heel_key][0], landmarks[heel_key][1])
115
+ return calculate_angle_3_points(foot_point, ankle, knee)
169
116
 
170
117
  # No valid foot landmark available
171
118
  return None
@@ -189,32 +136,29 @@ def calculate_knee_angle(
189
136
  Returns:
190
137
  Knee angle in degrees, or None if landmarks not available
191
138
  """
192
- prefix = _get_side_prefix(side)
139
+ prefix = "left_" if side == "left" else "right_"
193
140
 
194
141
  ankle_key = f"{prefix}ankle"
195
142
  knee_key = f"{prefix}knee"
196
143
  hip_key = f"{prefix}hip"
197
144
 
198
145
  # Check visibility
199
- if not _is_landmark_visible(landmarks, ankle_key):
146
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
200
147
  # Fallback: use foot_index if ankle not visible
201
148
  foot_key = f"{prefix}foot_index"
202
- if _is_landmark_visible(landmarks, foot_key):
149
+ if foot_key in landmarks and landmarks[foot_key][2] > 0.3:
203
150
  ankle_key = foot_key
204
151
  else:
205
152
  return None
206
153
 
207
- if not _is_landmark_visible(landmarks, knee_key):
154
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
208
155
  return None
209
- if not _is_landmark_visible(landmarks, hip_key):
156
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
210
157
  return None
211
158
 
212
- ankle = _get_landmark_xy(landmarks, ankle_key)
213
- knee = _get_landmark_xy(landmarks, knee_key)
214
- hip = _get_landmark_xy(landmarks, hip_key)
215
-
216
- if ankle is None or knee is None or hip is None:
217
- return None
159
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
160
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
161
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
218
162
 
219
163
  return calculate_angle_3_points(ankle, knee, hip)
220
164
 
@@ -237,26 +181,23 @@ def calculate_hip_angle(
237
181
  Returns:
238
182
  Hip angle in degrees, or None if landmarks not available
239
183
  """
240
- prefix = _get_side_prefix(side)
184
+ prefix = "left_" if side == "left" else "right_"
241
185
 
242
186
  knee_key = f"{prefix}knee"
243
187
  hip_key = f"{prefix}hip"
244
188
  shoulder_key = f"{prefix}shoulder"
245
189
 
246
190
  # Check visibility
247
- if not _is_landmark_visible(landmarks, knee_key):
191
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
248
192
  return None
249
- if not _is_landmark_visible(landmarks, hip_key):
193
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
250
194
  return None
251
- if not _is_landmark_visible(landmarks, shoulder_key):
195
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
252
196
  return None
253
197
 
254
- knee = _get_landmark_xy(landmarks, knee_key)
255
- hip = _get_landmark_xy(landmarks, hip_key)
256
- shoulder = _get_landmark_xy(landmarks, shoulder_key)
257
-
258
- if knee is None or hip is None or shoulder is None:
259
- return None
198
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
199
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
200
+ shoulder = (landmarks[shoulder_key][0], landmarks[shoulder_key][1])
260
201
 
261
202
  return calculate_angle_3_points(knee, hip, shoulder)
262
203
 
@@ -279,25 +220,19 @@ def calculate_trunk_tilt(
279
220
  Returns:
280
221
  Trunk tilt angle in degrees, or None if landmarks not available
281
222
  """
282
- prefix = _get_side_prefix(side)
223
+ prefix = "left_" if side == "left" else "right_"
283
224
 
284
225
  hip_key = f"{prefix}hip"
285
226
  shoulder_key = f"{prefix}shoulder"
286
227
 
287
228
  # Check visibility
288
- if not _is_landmark_visible(landmarks, hip_key):
289
- return None
290
- if not _is_landmark_visible(landmarks, shoulder_key):
229
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
291
230
  return None
292
-
293
- hip_xy = _get_landmark_xy(landmarks, hip_key)
294
- shoulder_xy = _get_landmark_xy(landmarks, shoulder_key)
295
-
296
- if hip_xy is None or shoulder_xy is None:
231
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
297
232
  return None
298
233
 
299
- hip = np.array([hip_xy[0], hip_xy[1]])
300
- shoulder = np.array([shoulder_xy[0], shoulder_xy[1]])
234
+ hip = np.array([landmarks[hip_key][0], landmarks[hip_key][1]])
235
+ shoulder = np.array([landmarks[shoulder_key][0], landmarks[shoulder_key][1]])
301
236
 
302
237
  # Vector from hip to shoulder
303
238
  trunk_vector = shoulder - hip