kinemotion 0.17.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.

@@ -0,0 +1,290 @@
1
+ """Joint angle calculations for triple extension analysis."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+
7
+
8
+ def calculate_angle_3_points(
9
+ point1: tuple[float, float],
10
+ point2: tuple[float, float],
11
+ point3: tuple[float, float],
12
+ ) -> float:
13
+ """
14
+ Calculate angle at point2 formed by three points.
15
+
16
+ Uses the law of cosines to find the angle at the middle point.
17
+
18
+ Args:
19
+ point1: First point (x, y) - e.g., foot
20
+ point2: Middle point (x, y) - e.g., knee (vertex of angle)
21
+ point3: Third point (x, y) - e.g., hip
22
+
23
+ Returns:
24
+ Angle in degrees (0-180)
25
+
26
+ Example:
27
+ >>> # Calculate knee angle
28
+ >>> ankle = (0.5, 0.8)
29
+ >>> knee = (0.5, 0.6)
30
+ >>> hip = (0.5, 0.4)
31
+ >>> angle = calculate_angle_3_points(ankle, knee, hip)
32
+ >>> # angle ≈ 180 (straight leg)
33
+ """
34
+ # Convert points to numpy arrays
35
+ p1 = np.array(point1)
36
+ p2 = np.array(point2)
37
+ p3 = np.array(point3)
38
+
39
+ # Calculate vectors from point2 to point1 and point3
40
+ v1 = p1 - p2
41
+ v2 = p3 - p2
42
+
43
+ # Calculate angle using dot product
44
+ # cos(angle) = (v1 · v2) / (|v1| * |v2|)
45
+ dot_product = np.dot(v1, v2)
46
+ magnitude1 = np.linalg.norm(v1)
47
+ magnitude2 = np.linalg.norm(v2)
48
+
49
+ # Avoid division by zero
50
+ if magnitude1 < 1e-9 or magnitude2 < 1e-9:
51
+ return 0.0
52
+
53
+ # Calculate angle in radians, then convert to degrees
54
+ cos_angle = dot_product / (magnitude1 * magnitude2)
55
+
56
+ # Clamp to [-1, 1] to avoid numerical errors with arccos
57
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
58
+
59
+ angle_rad = math.acos(cos_angle)
60
+ angle_deg = math.degrees(angle_rad)
61
+
62
+ return float(angle_deg)
63
+
64
+
65
+ def calculate_ankle_angle(
66
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
67
+ ) -> float | None:
68
+ """
69
+ Calculate ankle angle (dorsiflexion/plantarflexion).
70
+
71
+ Angle formed by: heel -> ankle -> knee
72
+ - 90° = neutral (foot perpendicular to shin)
73
+ - < 90° = dorsiflexion (toes up)
74
+ - > 90° = plantarflexion (toes down)
75
+
76
+ Args:
77
+ landmarks: Pose landmarks dictionary
78
+ side: Which side to measure ("left" or "right")
79
+
80
+ Returns:
81
+ Ankle angle in degrees, or None if landmarks not available
82
+ """
83
+ prefix = "left_" if side == "left" else "right_"
84
+
85
+ heel_key = f"{prefix}heel"
86
+ ankle_key = f"{prefix}ankle"
87
+ knee_key = f"{prefix}knee"
88
+
89
+ # Check visibility threshold
90
+ if heel_key not in landmarks or landmarks[heel_key][2] < 0.3:
91
+ return None
92
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
93
+ return None
94
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
95
+ return None
96
+
97
+ heel = (landmarks[heel_key][0], landmarks[heel_key][1])
98
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
99
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
100
+
101
+ return calculate_angle_3_points(heel, ankle, knee)
102
+
103
+
104
+ def calculate_knee_angle(
105
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
106
+ ) -> float | None:
107
+ """
108
+ Calculate knee angle (flexion/extension).
109
+
110
+ Angle formed by: ankle -> knee -> hip
111
+ - 180° = full extension (straight leg)
112
+ - 90° = 90° flexion (deep squat)
113
+ - 0° = full flexion (not physiologically possible)
114
+
115
+ Args:
116
+ landmarks: Pose landmarks dictionary
117
+ side: Which side to measure ("left" or "right")
118
+
119
+ Returns:
120
+ Knee angle in degrees, or None if landmarks not available
121
+ """
122
+ prefix = "left_" if side == "left" else "right_"
123
+
124
+ ankle_key = f"{prefix}ankle"
125
+ knee_key = f"{prefix}knee"
126
+ hip_key = f"{prefix}hip"
127
+
128
+ # Check visibility
129
+ if ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
130
+ # Fallback: use foot_index if ankle not visible
131
+ foot_key = f"{prefix}foot_index"
132
+ if foot_key in landmarks and landmarks[foot_key][2] > 0.3:
133
+ ankle_key = foot_key
134
+ else:
135
+ return None
136
+
137
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
138
+ return None
139
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
140
+ return None
141
+
142
+ ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
143
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
144
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
145
+
146
+ return calculate_angle_3_points(ankle, knee, hip)
147
+
148
+
149
+ def calculate_hip_angle(
150
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
151
+ ) -> float | None:
152
+ """
153
+ Calculate hip angle (flexion/extension).
154
+
155
+ Angle formed by: knee -> hip -> shoulder
156
+ - 180° = standing upright (trunk and thigh aligned)
157
+ - 90° = 90° hip flexion (torso perpendicular to thigh)
158
+ - < 90° = deep flexion (squat position)
159
+
160
+ Args:
161
+ landmarks: Pose landmarks dictionary
162
+ side: Which side to measure ("left" or "right")
163
+
164
+ Returns:
165
+ Hip angle in degrees, or None if landmarks not available
166
+ """
167
+ prefix = "left_" if side == "left" else "right_"
168
+
169
+ knee_key = f"{prefix}knee"
170
+ hip_key = f"{prefix}hip"
171
+ shoulder_key = f"{prefix}shoulder"
172
+
173
+ # Check visibility
174
+ if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
175
+ return None
176
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
177
+ return None
178
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
179
+ return None
180
+
181
+ knee = (landmarks[knee_key][0], landmarks[knee_key][1])
182
+ hip = (landmarks[hip_key][0], landmarks[hip_key][1])
183
+ shoulder = (landmarks[shoulder_key][0], landmarks[shoulder_key][1])
184
+
185
+ return calculate_angle_3_points(knee, hip, shoulder)
186
+
187
+
188
+ def calculate_trunk_tilt(
189
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
190
+ ) -> float | None:
191
+ """
192
+ Calculate trunk tilt angle relative to vertical.
193
+
194
+ Measures forward/backward lean of the torso.
195
+ - 0° = perfectly vertical
196
+ - Positive = leaning forward
197
+ - Negative = leaning backward
198
+
199
+ Args:
200
+ landmarks: Pose landmarks dictionary
201
+ side: Which side to measure ("left" or "right")
202
+
203
+ Returns:
204
+ Trunk tilt angle in degrees, or None if landmarks not available
205
+ """
206
+ prefix = "left_" if side == "left" else "right_"
207
+
208
+ hip_key = f"{prefix}hip"
209
+ shoulder_key = f"{prefix}shoulder"
210
+
211
+ # Check visibility
212
+ if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
213
+ return None
214
+ if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
215
+ return None
216
+
217
+ hip = np.array([landmarks[hip_key][0], landmarks[hip_key][1]])
218
+ shoulder = np.array([landmarks[shoulder_key][0], landmarks[shoulder_key][1]])
219
+
220
+ # Vector from hip to shoulder
221
+ trunk_vector = shoulder - hip
222
+
223
+ # Vertical reference (in normalized coords, vertical is along y-axis)
224
+ # Negative y direction is up in frame coordinates
225
+ vertical = np.array([0, -1])
226
+
227
+ # Calculate angle from vertical
228
+ dot_product = np.dot(trunk_vector, vertical)
229
+ magnitude_trunk = np.linalg.norm(trunk_vector)
230
+
231
+ if magnitude_trunk < 1e-9:
232
+ return None
233
+
234
+ cos_angle = dot_product / magnitude_trunk
235
+ cos_angle = np.clip(cos_angle, -1.0, 1.0)
236
+
237
+ angle_rad = math.acos(cos_angle)
238
+ angle_deg = math.degrees(angle_rad)
239
+
240
+ # Determine if leaning forward or backward based on x-component
241
+ if trunk_vector[0] > 0: # Shoulder to the right of hip = leaning forward
242
+ return float(angle_deg)
243
+ else:
244
+ return float(-angle_deg)
245
+
246
+
247
+ def calculate_triple_extension(
248
+ landmarks: dict[str, tuple[float, float, float]], side: str = "right"
249
+ ) -> dict[str, float | None] | None:
250
+ """
251
+ Calculate all three joint angles for triple extension analysis.
252
+
253
+ Triple extension refers to simultaneous extension of ankle, knee, and hip joints
254
+ during the propulsive phase of jumping. This is a key indicator of proper technique.
255
+
256
+ NOTE: In side-view videos, ankle/knee may have low visibility from MediaPipe.
257
+ Returns partial results with None for unavailable angles.
258
+
259
+ Args:
260
+ landmarks: Pose landmarks dictionary
261
+ side: Which side to measure ("left" or "right")
262
+
263
+ Returns:
264
+ Dictionary with angle measurements:
265
+ - ankle_angle: Ankle angle or None
266
+ - knee_angle: Knee angle or None
267
+ - hip_angle: Hip angle or None
268
+ - trunk_tilt: Trunk lean angle or None
269
+ Returns None if NO angles can be calculated
270
+
271
+ Example:
272
+ >>> angles = calculate_triple_extension(landmarks, side="right")
273
+ >>> if angles and angles['knee_angle']:
274
+ ... print(f"Knee: {angles['knee_angle']:.0f}°")
275
+ """
276
+ ankle = calculate_ankle_angle(landmarks, side)
277
+ knee = calculate_knee_angle(landmarks, side)
278
+ hip = calculate_hip_angle(landmarks, side)
279
+ trunk = calculate_trunk_tilt(landmarks, side)
280
+
281
+ # Return results even if some are None (at least trunk should be available)
282
+ if ankle is None and knee is None and hip is None and trunk is None:
283
+ return None # No angles available at all
284
+
285
+ return {
286
+ "ankle_angle": ankle,
287
+ "knee_angle": knee,
288
+ "hip_angle": hip,
289
+ "trunk_tilt": trunk,
290
+ }
@@ -0,0 +1,191 @@
1
+ """Counter Movement Jump (CMJ) metrics calculation."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class CMJMetrics:
11
+ """Metrics for a counter movement jump analysis.
12
+
13
+ Attributes:
14
+ jump_height: Maximum jump height in meters
15
+ flight_time: Time spent in the air in seconds
16
+ countermovement_depth: Vertical distance traveled during eccentric phase in meters
17
+ eccentric_duration: Time from countermovement start to lowest point in seconds
18
+ concentric_duration: Time from lowest point to takeoff in seconds
19
+ total_movement_time: Total time from countermovement start to takeoff in seconds
20
+ peak_eccentric_velocity: Maximum downward velocity during countermovement in m/s
21
+ peak_concentric_velocity: Maximum upward velocity during propulsion in m/s
22
+ transition_time: Duration at lowest point (amortization phase) in seconds
23
+ standing_start_frame: Frame where standing phase ends (countermovement begins)
24
+ lowest_point_frame: Frame at lowest point of countermovement
25
+ takeoff_frame: Frame where athlete leaves ground
26
+ landing_frame: Frame where athlete lands
27
+ video_fps: Frames per second of the analyzed video
28
+ tracking_method: Method used for tracking ("foot" or "com")
29
+ """
30
+
31
+ jump_height: float
32
+ flight_time: float
33
+ countermovement_depth: float
34
+ eccentric_duration: float
35
+ concentric_duration: float
36
+ total_movement_time: float
37
+ peak_eccentric_velocity: float
38
+ peak_concentric_velocity: float
39
+ transition_time: float | None
40
+ standing_start_frame: float | None
41
+ lowest_point_frame: float
42
+ takeoff_frame: float
43
+ landing_frame: float
44
+ video_fps: float
45
+ tracking_method: str
46
+
47
+ def to_dict(self) -> dict[str, Any]:
48
+ """Convert metrics to JSON-serializable dictionary.
49
+
50
+ Returns:
51
+ Dictionary with all metrics, converting NumPy types to Python types.
52
+ """
53
+ return {
54
+ "jump_height_m": float(self.jump_height),
55
+ "flight_time_s": float(self.flight_time),
56
+ "countermovement_depth_m": float(self.countermovement_depth),
57
+ "eccentric_duration_s": float(self.eccentric_duration),
58
+ "concentric_duration_s": float(self.concentric_duration),
59
+ "total_movement_time_s": float(self.total_movement_time),
60
+ "peak_eccentric_velocity_m_s": float(self.peak_eccentric_velocity),
61
+ "peak_concentric_velocity_m_s": float(self.peak_concentric_velocity),
62
+ "transition_time_s": (
63
+ float(self.transition_time)
64
+ if self.transition_time is not None
65
+ else None
66
+ ),
67
+ "standing_start_frame": (
68
+ float(self.standing_start_frame)
69
+ if self.standing_start_frame is not None
70
+ else None
71
+ ),
72
+ "lowest_point_frame": float(self.lowest_point_frame),
73
+ "takeoff_frame": float(self.takeoff_frame),
74
+ "landing_frame": float(self.landing_frame),
75
+ "video_fps": float(self.video_fps),
76
+ "tracking_method": self.tracking_method,
77
+ }
78
+
79
+
80
+ def calculate_cmj_metrics(
81
+ positions: np.ndarray,
82
+ velocities: np.ndarray,
83
+ standing_start_frame: float | None,
84
+ lowest_point_frame: float,
85
+ takeoff_frame: float,
86
+ landing_frame: float,
87
+ fps: float,
88
+ tracking_method: str = "foot",
89
+ ) -> CMJMetrics:
90
+ """Calculate all CMJ metrics from detected phases.
91
+
92
+ Args:
93
+ positions: Array of vertical positions (normalized coordinates)
94
+ velocities: Array of vertical velocities
95
+ standing_start_frame: Frame where countermovement begins (fractional)
96
+ lowest_point_frame: Frame at lowest point (fractional)
97
+ takeoff_frame: Frame at takeoff (fractional)
98
+ landing_frame: Frame at landing (fractional)
99
+ fps: Video frames per second
100
+ tracking_method: Tracking method used ("foot" or "com")
101
+
102
+ Returns:
103
+ CMJMetrics object with all calculated metrics.
104
+ """
105
+ # Calculate flight time from takeoff to landing
106
+ flight_time = (landing_frame - takeoff_frame) / fps
107
+
108
+ # Calculate jump height from flight time using kinematic formula
109
+ # h = g * t^2 / 8 (where t is total flight time)
110
+ g = 9.81 # gravity in m/s^2
111
+ jump_height = (g * flight_time**2) / 8
112
+
113
+ # Calculate countermovement depth
114
+ if standing_start_frame is not None:
115
+ standing_position = positions[int(standing_start_frame)]
116
+ else:
117
+ # Use position at start of recording if standing not detected
118
+ standing_position = positions[0]
119
+
120
+ lowest_position = positions[int(lowest_point_frame)]
121
+ countermovement_depth = abs(standing_position - lowest_position)
122
+
123
+ # Calculate phase durations
124
+ if standing_start_frame is not None:
125
+ eccentric_duration = (lowest_point_frame - standing_start_frame) / fps
126
+ total_movement_time = (takeoff_frame - standing_start_frame) / fps
127
+ else:
128
+ # If no standing phase detected, measure from start
129
+ eccentric_duration = lowest_point_frame / fps
130
+ total_movement_time = takeoff_frame / fps
131
+
132
+ concentric_duration = (takeoff_frame - lowest_point_frame) / fps
133
+
134
+ # Calculate peak velocities
135
+ # Eccentric phase: negative velocities (downward)
136
+ if standing_start_frame is not None:
137
+ eccentric_start_idx = int(standing_start_frame)
138
+ else:
139
+ eccentric_start_idx = 0
140
+
141
+ eccentric_end_idx = int(lowest_point_frame)
142
+ eccentric_velocities = velocities[eccentric_start_idx:eccentric_end_idx]
143
+
144
+ if len(eccentric_velocities) > 0:
145
+ # Peak eccentric velocity is most negative value
146
+ peak_eccentric_velocity = float(np.min(eccentric_velocities))
147
+ else:
148
+ peak_eccentric_velocity = 0.0
149
+
150
+ # Concentric phase: positive velocities (upward)
151
+ concentric_start_idx = int(lowest_point_frame)
152
+ concentric_end_idx = int(takeoff_frame)
153
+ concentric_velocities = velocities[concentric_start_idx:concentric_end_idx]
154
+
155
+ if len(concentric_velocities) > 0:
156
+ peak_concentric_velocity = float(np.max(concentric_velocities))
157
+ else:
158
+ peak_concentric_velocity = 0.0
159
+
160
+ # Estimate transition time (amortization phase)
161
+ # Look for period around lowest point where velocity is near zero
162
+ transition_threshold = 0.005 # Very low velocity threshold
163
+ search_window = int(fps * 0.1) # Search within ±100ms
164
+
165
+ transition_start_idx = max(0, int(lowest_point_frame) - search_window)
166
+ transition_end_idx = min(len(velocities), int(lowest_point_frame) + search_window)
167
+
168
+ transition_frames = 0
169
+ for i in range(transition_start_idx, transition_end_idx):
170
+ if abs(velocities[i]) < transition_threshold:
171
+ transition_frames += 1
172
+
173
+ transition_time = transition_frames / fps if transition_frames > 0 else None
174
+
175
+ return CMJMetrics(
176
+ jump_height=jump_height,
177
+ flight_time=flight_time,
178
+ countermovement_depth=countermovement_depth,
179
+ eccentric_duration=eccentric_duration,
180
+ concentric_duration=concentric_duration,
181
+ total_movement_time=total_movement_time,
182
+ peak_eccentric_velocity=peak_eccentric_velocity,
183
+ peak_concentric_velocity=peak_concentric_velocity,
184
+ transition_time=transition_time,
185
+ standing_start_frame=standing_start_frame,
186
+ lowest_point_frame=lowest_point_frame,
187
+ takeoff_frame=takeoff_frame,
188
+ landing_frame=landing_frame,
189
+ video_fps=fps,
190
+ tracking_method=tracking_method,
191
+ )
@@ -0,0 +1,40 @@
1
+ """Core functionality shared across all jump analysis types."""
2
+
3
+ from .filtering import (
4
+ adaptive_smooth_window,
5
+ bilateral_temporal_filter,
6
+ detect_outliers_median,
7
+ detect_outliers_ransac,
8
+ reject_outliers,
9
+ remove_outliers,
10
+ )
11
+ from .pose import PoseTracker, compute_center_of_mass
12
+ from .smoothing import (
13
+ compute_acceleration_from_derivative,
14
+ compute_velocity,
15
+ compute_velocity_from_derivative,
16
+ smooth_landmarks,
17
+ smooth_landmarks_advanced,
18
+ )
19
+ from .video_io import VideoProcessor
20
+
21
+ __all__ = [
22
+ # Pose tracking
23
+ "PoseTracker",
24
+ "compute_center_of_mass",
25
+ # Smoothing
26
+ "smooth_landmarks",
27
+ "smooth_landmarks_advanced",
28
+ "compute_velocity",
29
+ "compute_velocity_from_derivative",
30
+ "compute_acceleration_from_derivative",
31
+ # Filtering
32
+ "detect_outliers_ransac",
33
+ "detect_outliers_median",
34
+ "remove_outliers",
35
+ "reject_outliers",
36
+ "adaptive_smooth_window",
37
+ "bilateral_temporal_filter",
38
+ # Video I/O
39
+ "VideoProcessor",
40
+ ]