kinemotion 0.75.0__py3-none-any.whl → 0.76.1__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.

kinemotion/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """Kinemotion: Video-based kinematic analysis for athletic performance.
2
2
 
3
- Supports Counter Movement Jump (CMJ) and Drop Jump analysis using MediaPipe pose estimation.
3
+ Supports Counter Movement Jump (CMJ), Drop Jump, and Squat Jump (SJ) analysis
4
+ using MediaPipe pose estimation.
4
5
  """
5
6
 
6
7
  from .api import (
@@ -8,13 +9,18 @@ from .api import (
8
9
  CMJVideoResult,
9
10
  DropJumpVideoConfig,
10
11
  DropJumpVideoResult,
12
+ SJVideoConfig,
13
+ SJVideoResult,
11
14
  process_cmj_video,
12
15
  process_cmj_videos_bulk,
13
16
  process_dropjump_video,
14
17
  process_dropjump_videos_bulk,
18
+ process_sj_video,
19
+ process_sj_videos_bulk,
15
20
  )
16
21
  from .countermovement_jump.kinematics import CMJMetrics
17
22
  from .drop_jump.kinematics import DropJumpMetrics
23
+ from .squat_jump.kinematics import SJMetrics
18
24
 
19
25
  # Get version from package metadata (set in pyproject.toml)
20
26
  try:
@@ -38,5 +44,11 @@ __all__ = [
38
44
  "CMJVideoConfig",
39
45
  "CMJVideoResult",
40
46
  "CMJMetrics",
47
+ # Squat Jump API
48
+ "process_sj_video",
49
+ "process_sj_videos_bulk",
50
+ "SJVideoConfig",
51
+ "SJVideoResult",
52
+ "SJMetrics",
41
53
  "__version__",
42
54
  ]
kinemotion/api.py CHANGED
@@ -1,9 +1,10 @@
1
1
  """Public API for programmatic use of kinemotion analysis.
2
2
 
3
- This module provides a unified interface for both drop jump and CMJ video analysis.
3
+ This module provides a unified interface for drop jump, CMJ, and Squat Jump analysis.
4
4
  The actual implementations have been moved to their respective submodules:
5
5
  - Drop jump: kinemotion.drop_jump.api
6
6
  - CMJ: kinemotion.countermovement_jump.api
7
+ - Squat Jump: kinemotion.squat_jump.api
7
8
 
8
9
  """
9
10
 
@@ -28,6 +29,18 @@ from .drop_jump.api import (
28
29
  process_dropjump_videos_bulk,
29
30
  )
30
31
 
32
+ # Squat Jump API
33
+ from .squat_jump.api import (
34
+ AnalysisOverrides as SJAnalysisOverrides,
35
+ )
36
+ from .squat_jump.api import (
37
+ SJVideoConfig,
38
+ SJVideoResult,
39
+ process_sj_video,
40
+ process_sj_videos_bulk,
41
+ )
42
+ from .squat_jump.kinematics import SJMetrics
43
+
31
44
  __all__ = [
32
45
  # Drop jump
33
46
  "AnalysisOverrides",
@@ -42,4 +55,11 @@ __all__ = [
42
55
  "CMJVideoResult",
43
56
  "process_cmj_video",
44
57
  "process_cmj_videos_bulk",
58
+ # Squat Jump
59
+ "SJAnalysisOverrides",
60
+ "SJMetrics",
61
+ "SJVideoConfig",
62
+ "SJVideoResult",
63
+ "process_sj_video",
64
+ "process_sj_videos_bulk",
45
65
  ]
kinemotion/cli.py CHANGED
@@ -4,6 +4,7 @@ import click
4
4
 
5
5
  from .countermovement_jump.cli import cmj_analyze
6
6
  from .drop_jump.cli import dropjump_analyze
7
+ from .squat_jump.cli import sj_analyze
7
8
 
8
9
 
9
10
  @click.group()
@@ -17,6 +18,7 @@ def cli() -> None: # type: ignore[return]
17
18
  # Type ignore needed because @click.group() transforms cli into a click.Group
18
19
  cli.add_command(dropjump_analyze) # type: ignore[attr-defined]
19
20
  cli.add_command(cmj_analyze) # type: ignore[attr-defined]
21
+ cli.add_command(sj_analyze) # type: ignore[attr-defined]
20
22
 
21
23
 
22
24
  if __name__ == "__main__":
@@ -124,8 +124,8 @@ class CMJMetricsValidator(MetricsValidator):
124
124
  self._check_flight_time(data, result, profile)
125
125
  self._check_jump_height(data, result, profile)
126
126
  self._check_countermovement_depth(data, result, profile)
127
- self._check_concentric_duration(data, result, profile)
128
- self._check_eccentric_duration(data, result, profile)
127
+ self._check_concentric_duration(data, result)
128
+ self._check_eccentric_duration(data, result)
129
129
  self._check_peak_velocities(data, result, profile)
130
130
 
131
131
  # CROSS-VALIDATION CHECKS
@@ -220,7 +220,7 @@ class CMJMetricsValidator(MetricsValidator):
220
220
  )
221
221
 
222
222
  def _check_concentric_duration(
223
- self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
223
+ self, metrics: MetricsDict, result: CMJValidationResult
224
224
  ) -> None:
225
225
  """Validate concentric duration (contact time)."""
226
226
  duration_raw = self._get_metric_value(
@@ -256,9 +256,7 @@ class CMJMetricsValidator(MetricsValidator):
256
256
  value=duration,
257
257
  )
258
258
 
259
- def _check_eccentric_duration(
260
- self, metrics: MetricsDict, result: CMJValidationResult, profile: AthleteProfile
261
- ) -> None:
259
+ def _check_eccentric_duration(self, metrics: MetricsDict, result: CMJValidationResult) -> None:
262
260
  """Validate eccentric duration."""
263
261
  duration_raw = self._get_metric_value(
264
262
  metrics, "eccentric_duration_ms", "eccentric_duration"
@@ -0,0 +1,5 @@
1
+ """Squat Jump (SJ) analysis module."""
2
+
3
+ from .kinematics import SJMetrics
4
+
5
+ __all__ = ["SJMetrics"]
@@ -0,0 +1,377 @@
1
+ """Phase detection logic for Squat Jump (SJ) analysis."""
2
+
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+ from scipy.signal import savgol_filter
7
+
8
+ from ..core.types import FloatArray
9
+
10
+
11
+ class SJPhase(Enum):
12
+ """Phases of a squat jump."""
13
+
14
+ SQUAT_HOLD = "squat_hold"
15
+ CONCENTRIC = "concentric"
16
+ FLIGHT = "flight"
17
+ LANDING = "landing"
18
+ UNKNOWN = "unknown"
19
+
20
+
21
+ def detect_sj_phases(
22
+ positions: FloatArray,
23
+ fps: float,
24
+ velocity_threshold: float = 0.1,
25
+ window_length: int = 5,
26
+ polyorder: int = 2,
27
+ ) -> tuple[int, int, int, int] | None:
28
+ """Detect phases in a squat jump video.
29
+
30
+ Squat Jump phase detection follows this progression:
31
+ 1. Squat hold - static squat position
32
+ 2. Concentric - upward movement from squat
33
+ 3. Takeoff - feet leave ground
34
+ 4. Flight - in the air
35
+ 5. Landing - feet contact ground
36
+
37
+ Args:
38
+ positions: 1D array of vertical positions (normalized coordinates)
39
+ fps: Video frames per second
40
+ velocity_threshold: Threshold for detecting flight phase (m/s)
41
+ window_length: Window size for velocity smoothing
42
+ polyorder: Polynomial order for smoothing
43
+
44
+ Returns:
45
+ Tuple of (squat_hold_start, concentric_start, takeoff_frame, landing_frame)
46
+ or None if phases cannot be detected
47
+ """
48
+ if len(positions) < 20: # Minimum viable sequence length
49
+ return None
50
+
51
+ # Ensure window length is appropriate for derivative calculations
52
+ if window_length % 2 == 0:
53
+ window_length += 1
54
+
55
+ # Step 1: Detect squat hold start
56
+ squat_hold_start = detect_squat_start(
57
+ positions,
58
+ fps,
59
+ velocity_threshold=velocity_threshold,
60
+ window_length=window_length,
61
+ polyorder=polyorder,
62
+ )
63
+
64
+ if squat_hold_start is None:
65
+ # If no squat hold detected, start from reasonable beginning
66
+ squat_hold_start = len(positions) // 4
67
+
68
+ # Step 2: Compute signed velocities for phase detection
69
+ if len(positions) < window_length:
70
+ # Fallback for short sequences
71
+ velocities = np.diff(positions, prepend=positions[0])
72
+ else:
73
+ velocities = savgol_filter(
74
+ positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp"
75
+ )
76
+
77
+ # Step 3: Detect takeoff (this marks the start of concentric phase)
78
+ takeoff_frame = detect_takeoff(
79
+ positions, velocities, fps, velocity_threshold=velocity_threshold
80
+ )
81
+
82
+ if takeoff_frame is None:
83
+ return None
84
+
85
+ # Concentric start begins when upward movement starts after squat hold
86
+ # This is just before takeoff when velocity becomes significantly negative
87
+ concentric_start = takeoff_frame
88
+ # Look backward from takeoff to find where concentric phase truly begins
89
+ for i in range(takeoff_frame - 1, max(squat_hold_start, 0), -1):
90
+ if velocities[i] <= -velocity_threshold * 0.5: # Start of upward movement
91
+ concentric_start = i
92
+ break
93
+
94
+ # Step 4: Detect landing
95
+ landing_frame = detect_landing(
96
+ positions,
97
+ velocities,
98
+ fps,
99
+ velocity_threshold=velocity_threshold,
100
+ min_flight_frames=5,
101
+ landing_search_window_s=0.5,
102
+ window_length=window_length,
103
+ polyorder=polyorder,
104
+ )
105
+
106
+ if landing_frame is None:
107
+ # Fallback: estimate landing from peak height + typical flight time
108
+ # takeoff_frame is guaranteed to be an int here (we returned earlier if None)
109
+ peak_search_start = takeoff_frame
110
+ peak_search_end = min(len(positions), takeoff_frame + int(fps * 1.0))
111
+ if peak_search_end > peak_search_start:
112
+ flight_positions = positions[peak_search_start:peak_search_end]
113
+ peak_idx = int(np.argmin(flight_positions))
114
+ peak_frame = peak_search_start + peak_idx
115
+ # Estimate landing as 0.3s after peak (typical SJ flight time)
116
+ landing_frame = peak_frame + int(fps * 0.3)
117
+ else:
118
+ return None
119
+
120
+ # Validate the detected phases
121
+ if landing_frame <= takeoff_frame or takeoff_frame <= squat_hold_start:
122
+ # Invalid phase sequence
123
+ return None
124
+
125
+ # Return all detected phases
126
+ return (squat_hold_start, concentric_start, takeoff_frame, landing_frame)
127
+
128
+
129
+ def detect_squat_start(
130
+ positions: FloatArray,
131
+ fps: float,
132
+ velocity_threshold: float = 0.1,
133
+ min_hold_duration: float = 0.15,
134
+ min_search_frame: int = 20,
135
+ window_length: int = 5,
136
+ polyorder: int = 2,
137
+ ) -> int | None:
138
+ """Detect start of squat hold phase.
139
+
140
+ Squat hold is detected when the athlete maintains a relatively stable
141
+ position with low velocity for a minimum duration.
142
+
143
+ Args:
144
+ positions: 1D array of vertical positions (normalized coordinates)
145
+ fps: Video frames per second
146
+ velocity_threshold: Maximum velocity to consider stationary (m/s)
147
+ min_hold_duration: Minimum duration to maintain stable position (seconds)
148
+ min_search_frame: Minimum frame to start searching (avoid initial settling)
149
+ window_length: Window size for velocity smoothing
150
+ polyorder: Polynomial order for smoothing
151
+
152
+ Returns:
153
+ Frame index where squat hold begins, or None if not detected
154
+ """
155
+ if len(positions) < min_search_frame + 10:
156
+ return None
157
+
158
+ # Compute velocity to detect stationary periods
159
+ if len(positions) < window_length:
160
+ velocities = np.abs(np.diff(positions, prepend=positions[0]))
161
+ else:
162
+ if window_length % 2 == 0:
163
+ window_length += 1
164
+ velocities = np.abs(
165
+ savgol_filter(positions, window_length, polyorder, deriv=1, delta=1.0, mode="interp")
166
+ )
167
+
168
+ # Search for stable period with low velocity
169
+ min_hold_frames = int(min_hold_duration * fps)
170
+ start_search = min_search_frame
171
+ end_search = len(positions) - min_hold_frames
172
+
173
+ # Look for consecutive frames with velocity below threshold
174
+ for i in range(start_search, end_search):
175
+ # Check if we have a stable period of sufficient duration
176
+ stable_period = velocities[i : i + min_hold_frames]
177
+ if np.all(stable_period <= velocity_threshold):
178
+ # Found a stable period - return start of this period
179
+ return i
180
+
181
+ return None
182
+
183
+
184
+ def _find_takeoff_threshold_crossing(
185
+ velocities: FloatArray,
186
+ search_start: int,
187
+ search_end: int,
188
+ velocity_threshold: float,
189
+ min_duration_frames: int,
190
+ ) -> int | None:
191
+ """Find first frame where velocity exceeds threshold for minimum duration."""
192
+ above_threshold = velocities[search_start:search_end] <= -velocity_threshold
193
+ if not np.any(above_threshold):
194
+ return None
195
+
196
+ threshold_indices = np.nonzero(above_threshold)[0]
197
+ for idx in threshold_indices:
198
+ if idx + min_duration_frames < len(above_threshold):
199
+ if np.all(above_threshold[idx : idx + min_duration_frames]):
200
+ return search_start + idx
201
+ return None
202
+
203
+
204
+ def detect_takeoff(
205
+ positions: FloatArray,
206
+ velocities: FloatArray,
207
+ fps: float,
208
+ velocity_threshold: float = 0.1,
209
+ min_duration_frames: int = 5,
210
+ search_window_s: float = 0.3,
211
+ ) -> int | None:
212
+ """Detect takeoff frame in squat jump.
213
+
214
+ Takeoff occurs at peak upward velocity during the concentric phase.
215
+ For SJ, this is simply the maximum negative velocity after squat hold
216
+ before the upward movement.
217
+
218
+ Args:
219
+ positions: 1D array of vertical positions (normalized coordinates)
220
+ velocities: 1D array of SIGNED vertical velocities (negative = upward)
221
+ fps: Video frames per second
222
+ velocity_threshold: Minimum velocity threshold for takeoff (m/s)
223
+ min_duration_frames: Minimum frames above threshold to confirm takeoff
224
+ search_window_s: Time window to search for takeoff after squat hold (seconds)
225
+
226
+ Returns:
227
+ Frame index where takeoff occurs, or None if not detected
228
+ """
229
+ if len(positions) == 0 or len(velocities) == 0:
230
+ return None
231
+
232
+ # Find squat start to determine where to begin search
233
+ squat_start = detect_squat_start(positions, fps)
234
+ if squat_start is None:
235
+ # If no squat start detected, start from reasonable middle point
236
+ squat_start = len(positions) // 3
237
+
238
+ # Search for takeoff after squat start
239
+ search_start = squat_start
240
+ search_end = min(len(velocities), int(squat_start + search_window_s * fps))
241
+
242
+ if search_end <= search_start:
243
+ return None
244
+
245
+ # Focus on concentric phase where velocity becomes negative (upward)
246
+ concentric_velocities = velocities[search_start:search_end]
247
+
248
+ # Find the most negative velocity (peak upward velocity = takeoff)
249
+ takeoff_idx = int(np.argmin(concentric_velocities))
250
+ takeoff_frame = search_start + takeoff_idx
251
+
252
+ # Verify velocity exceeds threshold
253
+ if velocities[takeoff_frame] > -velocity_threshold:
254
+ # Velocity not high enough - actual takeoff may be later
255
+ # Look for frames where velocity exceeds threshold with duration filter
256
+ return _find_takeoff_threshold_crossing(
257
+ velocities, search_start, search_end, velocity_threshold, min_duration_frames
258
+ )
259
+
260
+ return takeoff_frame if velocities[takeoff_frame] <= -velocity_threshold else None
261
+
262
+
263
+ def _detect_impact_landing(
264
+ accelerations: FloatArray,
265
+ search_start: int,
266
+ search_end: int,
267
+ ) -> int | None:
268
+ """Detect landing by finding the maximum acceleration spike."""
269
+ landing_accelerations = accelerations[search_start:search_end]
270
+ if len(landing_accelerations) == 0:
271
+ return None
272
+
273
+ # Find maximum acceleration spike (impact)
274
+ landing_idx = int(np.argmax(landing_accelerations))
275
+ return search_start + landing_idx
276
+
277
+
278
+ def _refine_landing_by_velocity(
279
+ velocities: FloatArray,
280
+ landing_frame: int,
281
+ ) -> int:
282
+ """Refine landing frame by looking for positive (downward) velocity."""
283
+ if landing_frame < len(velocities) and velocities[landing_frame] < 0:
284
+ # Velocity still upward - landing might not be detected yet
285
+ # Look ahead for where velocity becomes positive
286
+ for i in range(landing_frame, min(landing_frame + 10, len(velocities))):
287
+ if velocities[i] >= 0:
288
+ return i
289
+ return landing_frame
290
+
291
+
292
+ def detect_landing(
293
+ positions: FloatArray,
294
+ velocities: FloatArray,
295
+ fps: float,
296
+ velocity_threshold: float = 0.1,
297
+ min_flight_frames: int = 5,
298
+ landing_search_window_s: float = 0.5,
299
+ window_length: int = 5,
300
+ polyorder: int = 2,
301
+ ) -> int | None:
302
+ """Detect landing frame in squat jump.
303
+
304
+ Landing occurs after peak height when feet contact the ground.
305
+ Similar to CMJ - find position peak, then detect maximum deceleration
306
+ which corresponds to impact.
307
+
308
+ Args:
309
+ positions: 1D array of vertical positions (normalized coordinates)
310
+ velocities: 1D array of SIGNED vertical velocities (negative = up, positive = down)
311
+ fps: Video frames per second
312
+ velocity_threshold: Maximum velocity threshold for landing detection
313
+ min_flight_frames: Minimum frames in flight before landing
314
+ landing_search_window_s: Time window to search for landing after peak (seconds)
315
+ window_length: Window size for velocity smoothing
316
+ polyorder: Polynomial order for smoothing
317
+
318
+ Returns:
319
+ Frame index where landing occurs, or None if not detected
320
+ """
321
+ if len(positions) == 0 or len(velocities) == 0:
322
+ return None
323
+
324
+ # Find takeoff first (needed to determine where to start peak search)
325
+ takeoff_frame = detect_takeoff(
326
+ positions, velocities, fps, velocity_threshold, 5, landing_search_window_s
327
+ )
328
+ if takeoff_frame is None:
329
+ return None
330
+
331
+ # Find peak height after takeoff
332
+ search_start = takeoff_frame
333
+ search_duration = int(fps * 0.7) # Search next 0.7 seconds for peak
334
+ search_end = min(len(positions), search_start + search_duration)
335
+
336
+ if search_end <= search_start:
337
+ return None
338
+
339
+ # Find peak height (minimum position value in normalized coords = highest point)
340
+ flight_positions = positions[search_start:search_end]
341
+ peak_frame = search_start + int(np.argmin(flight_positions))
342
+
343
+ # After peak, look for landing using impact detection
344
+ landing_search_start = peak_frame + min_flight_frames
345
+ landing_search_end = min(
346
+ len(positions),
347
+ landing_search_start + int(landing_search_window_s * fps),
348
+ )
349
+
350
+ if landing_search_end <= landing_search_start:
351
+ return None
352
+
353
+ # Compute accelerations for impact detection
354
+ if len(positions) >= 5:
355
+ landing_window = window_length
356
+ if landing_window % 2 == 0:
357
+ landing_window += 1
358
+ # Use polyorder for smoothing (must be at least 2 for deriv=2)
359
+ eff_polyorder = max(2, polyorder)
360
+ accelerations = np.abs(
361
+ savgol_filter(
362
+ positions, landing_window, eff_polyorder, deriv=2, delta=1.0, mode="interp"
363
+ )
364
+ )
365
+ else:
366
+ # Fallback for short sequences
367
+ velocities_abs = np.abs(velocities)
368
+ accelerations = np.abs(np.diff(velocities_abs, prepend=velocities_abs[0]))
369
+
370
+ # Find impact: maximum positive acceleration (deceleration spike)
371
+ landing_frame = _detect_impact_landing(accelerations, landing_search_start, landing_search_end)
372
+
373
+ if landing_frame is None:
374
+ return None
375
+
376
+ # Additional verification: velocity should be positive (downward) at landing
377
+ return _refine_landing_by_velocity(velocities, landing_frame)