kinemotion 0.28.0__py3-none-any.whl → 0.29.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.
- kinemotion/__init__.py +1 -1
- kinemotion/cmj/analysis.py +43 -10
- kinemotion/cmj/joint_angles.py +23 -6
- kinemotion/core/cmj_metrics_validator.py +717 -0
- kinemotion/core/cmj_validation_bounds.py +380 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.1.dist-info}/METADATA +111 -49
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.1.dist-info}/RECORD +10 -8
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.1.dist-info}/WHEEL +0 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.1.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.28.0.dist-info → kinemotion-0.29.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""CMJ metrics physiological bounds for validation testing.
|
|
2
|
+
|
|
3
|
+
This module defines realistic physiological bounds for Counter Movement Jump (CMJ)
|
|
4
|
+
metrics based on biomechanical literature and real-world athlete performance.
|
|
5
|
+
|
|
6
|
+
These bounds are used to:
|
|
7
|
+
1. Prevent false positives from measurement noise
|
|
8
|
+
2. Catch real errors in video processing and phase detection
|
|
9
|
+
3. Provide athlete-profile-appropriate validation
|
|
10
|
+
4. Enable cross-validation of metric consistency
|
|
11
|
+
|
|
12
|
+
References:
|
|
13
|
+
- Nordez et al. (2009): CMJ depth-height relationships
|
|
14
|
+
- Cormie et al. (2011): Power generation characteristics
|
|
15
|
+
- Bogdanis (2012): Plyometric training effects
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import Enum
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AthleteProfile(Enum):
|
|
23
|
+
"""Athlete performance categories for metric bounds."""
|
|
24
|
+
|
|
25
|
+
ELDERLY = "elderly" # 70+, deconditioned
|
|
26
|
+
UNTRAINED = "untrained" # Sedentary, no training
|
|
27
|
+
RECREATIONAL = "recreational" # Fitness class, moderate activity
|
|
28
|
+
TRAINED = "trained" # Regular athlete, 3-5 years training
|
|
29
|
+
ELITE = "elite" # Competitive athlete, college/professional level
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MetricBounds:
|
|
34
|
+
"""Physiological bounds for a single metric.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
absolute_min: Absolute minimum value (error threshold)
|
|
38
|
+
practical_min: Practical minimum for weakest athletes
|
|
39
|
+
recreational_min: Minimum for recreational athletes
|
|
40
|
+
recreational_max: Maximum for recreational athletes
|
|
41
|
+
elite_min: Minimum for elite athletes
|
|
42
|
+
elite_max: Maximum for elite athletes
|
|
43
|
+
absolute_max: Absolute maximum value (error threshold)
|
|
44
|
+
unit: Unit of measurement (e.g., "m", "s", "m/s", "degrees")
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
absolute_min: float
|
|
48
|
+
practical_min: float
|
|
49
|
+
recreational_min: float
|
|
50
|
+
recreational_max: float
|
|
51
|
+
elite_min: float
|
|
52
|
+
elite_max: float
|
|
53
|
+
absolute_max: float
|
|
54
|
+
unit: str
|
|
55
|
+
|
|
56
|
+
def contains(self, value: float, profile: AthleteProfile) -> bool:
|
|
57
|
+
"""Check if value is within bounds for athlete profile."""
|
|
58
|
+
if profile == AthleteProfile.ELDERLY:
|
|
59
|
+
return self.practical_min <= value <= self.recreational_max
|
|
60
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
61
|
+
return self.practical_min <= value <= self.recreational_max
|
|
62
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
63
|
+
return self.recreational_min <= value <= self.recreational_max
|
|
64
|
+
elif profile == AthleteProfile.TRAINED:
|
|
65
|
+
# Trained athletes: midpoint between recreational and elite
|
|
66
|
+
trained_min = (self.recreational_min + self.elite_min) / 2
|
|
67
|
+
trained_max = (self.recreational_max + self.elite_max) / 2
|
|
68
|
+
return trained_min <= value <= trained_max
|
|
69
|
+
elif profile == AthleteProfile.ELITE:
|
|
70
|
+
return self.elite_min <= value <= self.elite_max
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def is_physically_possible(self, value: float) -> bool:
|
|
74
|
+
"""Check if value is within absolute physiological limits."""
|
|
75
|
+
return self.absolute_min <= value <= self.absolute_max
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CMJBounds:
|
|
79
|
+
"""Collection of physiological bounds for all CMJ metrics."""
|
|
80
|
+
|
|
81
|
+
# FLIGHT TIME (seconds)
|
|
82
|
+
FLIGHT_TIME = MetricBounds(
|
|
83
|
+
absolute_min=0.08, # Frame rate resolution limit
|
|
84
|
+
practical_min=0.15, # Minimum effort jump
|
|
85
|
+
recreational_min=0.25, # Untrained ~10cm
|
|
86
|
+
recreational_max=0.70, # Recreational ~30-50cm
|
|
87
|
+
elite_min=0.65, # Trained ~60cm
|
|
88
|
+
elite_max=1.10, # Elite >100cm
|
|
89
|
+
absolute_max=1.30,
|
|
90
|
+
unit="s",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# JUMP HEIGHT (meters)
|
|
94
|
+
JUMP_HEIGHT = MetricBounds(
|
|
95
|
+
absolute_min=0.02,
|
|
96
|
+
practical_min=0.05,
|
|
97
|
+
recreational_min=0.15, # Untrained with effort
|
|
98
|
+
recreational_max=0.60, # Good recreational
|
|
99
|
+
elite_min=0.65,
|
|
100
|
+
elite_max=1.00,
|
|
101
|
+
absolute_max=1.30,
|
|
102
|
+
unit="m",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# COUNTERMOVEMENT DEPTH (meters)
|
|
106
|
+
COUNTERMOVEMENT_DEPTH = MetricBounds(
|
|
107
|
+
absolute_min=0.05,
|
|
108
|
+
practical_min=0.08, # Minimal squat
|
|
109
|
+
recreational_min=0.20, # Shallow to parallel
|
|
110
|
+
recreational_max=0.55, # Normal to deep squat
|
|
111
|
+
elite_min=0.40,
|
|
112
|
+
elite_max=0.75,
|
|
113
|
+
absolute_max=1.10, # Only extreme tall athletes
|
|
114
|
+
unit="m",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# CONCENTRIC DURATION / CONTACT TIME (seconds)
|
|
118
|
+
CONCENTRIC_DURATION = MetricBounds(
|
|
119
|
+
absolute_min=0.08,
|
|
120
|
+
practical_min=0.10, # Extreme plyometric
|
|
121
|
+
recreational_min=0.40, # Moderate propulsion
|
|
122
|
+
recreational_max=0.90, # Slow push-off
|
|
123
|
+
elite_min=0.25,
|
|
124
|
+
elite_max=0.50,
|
|
125
|
+
absolute_max=1.80,
|
|
126
|
+
unit="s",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ECCENTRIC DURATION (seconds)
|
|
130
|
+
ECCENTRIC_DURATION = MetricBounds(
|
|
131
|
+
absolute_min=0.15,
|
|
132
|
+
practical_min=0.25,
|
|
133
|
+
recreational_min=0.35,
|
|
134
|
+
recreational_max=0.75,
|
|
135
|
+
elite_min=0.30,
|
|
136
|
+
elite_max=0.65,
|
|
137
|
+
absolute_max=1.30,
|
|
138
|
+
unit="s",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# TOTAL MOVEMENT TIME (seconds)
|
|
142
|
+
TOTAL_MOVEMENT_TIME = MetricBounds(
|
|
143
|
+
absolute_min=0.25,
|
|
144
|
+
practical_min=0.35,
|
|
145
|
+
recreational_min=0.75,
|
|
146
|
+
recreational_max=1.50,
|
|
147
|
+
elite_min=0.55,
|
|
148
|
+
elite_max=1.10,
|
|
149
|
+
absolute_max=2.20,
|
|
150
|
+
unit="s",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# PEAK ECCENTRIC VELOCITY (m/s, downward)
|
|
154
|
+
PEAK_ECCENTRIC_VELOCITY = MetricBounds(
|
|
155
|
+
absolute_min=0.10,
|
|
156
|
+
practical_min=0.20,
|
|
157
|
+
recreational_min=0.80,
|
|
158
|
+
recreational_max=2.00,
|
|
159
|
+
elite_min=2.00,
|
|
160
|
+
elite_max=3.50,
|
|
161
|
+
absolute_max=4.50,
|
|
162
|
+
unit="m/s",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# PEAK CONCENTRIC VELOCITY (m/s, upward)
|
|
166
|
+
PEAK_CONCENTRIC_VELOCITY = MetricBounds(
|
|
167
|
+
absolute_min=0.30,
|
|
168
|
+
practical_min=0.50,
|
|
169
|
+
recreational_min=1.80,
|
|
170
|
+
recreational_max=2.80,
|
|
171
|
+
elite_min=3.00,
|
|
172
|
+
elite_max=4.20,
|
|
173
|
+
absolute_max=5.00,
|
|
174
|
+
unit="m/s",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TripleExtensionBounds:
|
|
179
|
+
"""Physiological bounds for triple extension angles (degrees)."""
|
|
180
|
+
|
|
181
|
+
# HIP ANGLE at takeoff (close to 180° = full extension)
|
|
182
|
+
@staticmethod
|
|
183
|
+
def hip_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
184
|
+
"""Check if hip angle is valid for profile."""
|
|
185
|
+
if angle is None:
|
|
186
|
+
return True # May not be detectable
|
|
187
|
+
if angle < 120 or angle > 195:
|
|
188
|
+
return False # Outside physiological limits
|
|
189
|
+
if profile == AthleteProfile.ELDERLY:
|
|
190
|
+
return 150 <= angle <= 175
|
|
191
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
192
|
+
return 160 <= angle <= 180
|
|
193
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
194
|
+
return 170 <= angle <= 185
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# KNEE ANGLE at takeoff (close to 180° = full extension)
|
|
198
|
+
@staticmethod
|
|
199
|
+
def knee_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
200
|
+
"""Check if knee angle is valid for profile."""
|
|
201
|
+
if angle is None:
|
|
202
|
+
return True
|
|
203
|
+
if angle < 130 or angle > 200:
|
|
204
|
+
return False # Outside physiological limits
|
|
205
|
+
if profile == AthleteProfile.ELDERLY:
|
|
206
|
+
return 155 <= angle <= 175
|
|
207
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
208
|
+
return 165 <= angle <= 182
|
|
209
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
210
|
+
return 173 <= angle <= 190
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
# ANKLE ANGLE at takeoff (120-155° = plantarflexion, 90° = neutral)
|
|
214
|
+
@staticmethod
|
|
215
|
+
def ankle_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
216
|
+
"""Check if ankle angle is valid for profile."""
|
|
217
|
+
if angle is None:
|
|
218
|
+
return True # Often not detectable in side view
|
|
219
|
+
if angle < 90 or angle > 165:
|
|
220
|
+
return False # Outside physiological limits
|
|
221
|
+
if profile == AthleteProfile.ELDERLY:
|
|
222
|
+
return 100 <= angle <= 125
|
|
223
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
224
|
+
return 110 <= angle <= 140
|
|
225
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
226
|
+
return 125 <= angle <= 155
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
# TRUNK TILT (forward lean from vertical, degrees)
|
|
230
|
+
@staticmethod
|
|
231
|
+
def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
232
|
+
"""Check if trunk tilt is valid for profile."""
|
|
233
|
+
if angle is None:
|
|
234
|
+
return True
|
|
235
|
+
if angle < -15 or angle > 60:
|
|
236
|
+
return False # Outside reasonable range
|
|
237
|
+
# Most athletes show 10-30° forward lean during takeoff
|
|
238
|
+
return -10 <= angle <= 45
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class RSIBounds:
|
|
242
|
+
"""Reactive Strength Index bounds."""
|
|
243
|
+
|
|
244
|
+
# Calculated as: RSI = flight_time / contact_time
|
|
245
|
+
MIN_VALID = 0.30 # Below this: invalid metrics
|
|
246
|
+
MAX_VALID = 4.00 # Above this: invalid metrics
|
|
247
|
+
|
|
248
|
+
ELDERLY_RANGE = (0.15, 0.30)
|
|
249
|
+
UNTRAINED_RANGE = (0.30, 0.80)
|
|
250
|
+
RECREATIONAL_RANGE = (0.80, 1.50)
|
|
251
|
+
TRAINED_RANGE = (1.50, 2.40)
|
|
252
|
+
ELITE_RANGE = (2.20, 3.50)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def get_rsi_range(profile: AthleteProfile) -> tuple[float, float]:
|
|
256
|
+
"""Get expected RSI range for athlete profile."""
|
|
257
|
+
if profile == AthleteProfile.ELDERLY:
|
|
258
|
+
return RSIBounds.ELDERLY_RANGE
|
|
259
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
260
|
+
return RSIBounds.UNTRAINED_RANGE
|
|
261
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
262
|
+
return RSIBounds.RECREATIONAL_RANGE
|
|
263
|
+
elif profile == AthleteProfile.TRAINED:
|
|
264
|
+
return RSIBounds.TRAINED_RANGE
|
|
265
|
+
elif profile == AthleteProfile.ELITE:
|
|
266
|
+
return RSIBounds.ELITE_RANGE
|
|
267
|
+
return (RSIBounds.MIN_VALID, RSIBounds.MAX_VALID)
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def is_valid(rsi: float) -> bool:
|
|
271
|
+
"""Check if RSI is within physiological bounds."""
|
|
272
|
+
return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
|
|
276
|
+
"""Check if RSI is in expected range for profile."""
|
|
277
|
+
min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
|
|
278
|
+
return min_rsi <= rsi <= max_rsi
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class MetricConsistency:
|
|
282
|
+
"""Cross-validation tolerance for metric consistency checks."""
|
|
283
|
+
|
|
284
|
+
# Jump height from flight time: h = g*t²/8
|
|
285
|
+
# Allow 10% deviation for measurement noise
|
|
286
|
+
HEIGHT_FLIGHT_TIME_TOLERANCE = 0.10
|
|
287
|
+
|
|
288
|
+
# Peak velocity from jump height: v = sqrt(2*g*h)
|
|
289
|
+
# Allow 15% deviation (velocity harder to detect precisely)
|
|
290
|
+
VELOCITY_HEIGHT_TOLERANCE = 0.15
|
|
291
|
+
|
|
292
|
+
# Countermovement depth to jump height ratio
|
|
293
|
+
# Typically 0.5-1.2, flag if outside 0.3-1.5
|
|
294
|
+
DEPTH_HEIGHT_RATIO_MIN = 0.30
|
|
295
|
+
DEPTH_HEIGHT_RATIO_MAX = 1.50
|
|
296
|
+
|
|
297
|
+
# Contact time to countermovement depth ratio
|
|
298
|
+
# Should be roughly 1.0-1.5 s/m
|
|
299
|
+
CONTACT_DEPTH_RATIO_MIN = 0.50
|
|
300
|
+
CONTACT_DEPTH_RATIO_MAX = 2.50
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# Athlete profile examples with expected metric ranges
|
|
304
|
+
ATHLETE_PROFILES = {
|
|
305
|
+
"elderly_deconditioned": {
|
|
306
|
+
"label": "Elderly/Deconditioned (70+, sedentary)",
|
|
307
|
+
"profile": AthleteProfile.ELDERLY,
|
|
308
|
+
"expected": {
|
|
309
|
+
"jump_height_m": (0.10, 0.18),
|
|
310
|
+
"flight_time_s": (0.14, 0.19),
|
|
311
|
+
"countermovement_depth_m": (0.12, 0.20),
|
|
312
|
+
"concentric_duration_s": (0.80, 1.20),
|
|
313
|
+
"eccentric_duration_s": (0.60, 0.95),
|
|
314
|
+
"peak_eccentric_velocity_ms": (0.4, 0.7),
|
|
315
|
+
"peak_concentric_velocity_ms": (1.0, 1.5),
|
|
316
|
+
"rsi": (0.15, 0.25),
|
|
317
|
+
"hip_angle_deg": (150, 165),
|
|
318
|
+
"knee_angle_deg": (155, 170),
|
|
319
|
+
"ankle_angle_deg": (105, 125),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
"recreational": {
|
|
323
|
+
"label": "Recreational Athlete (fitness participant, 30-45 yrs)",
|
|
324
|
+
"profile": AthleteProfile.RECREATIONAL,
|
|
325
|
+
"expected": {
|
|
326
|
+
"jump_height_m": (0.35, 0.55),
|
|
327
|
+
"flight_time_s": (0.53, 0.67),
|
|
328
|
+
"countermovement_depth_m": (0.28, 0.45),
|
|
329
|
+
"concentric_duration_s": (0.45, 0.65),
|
|
330
|
+
"eccentric_duration_s": (0.40, 0.65),
|
|
331
|
+
"peak_eccentric_velocity_ms": (1.3, 1.9),
|
|
332
|
+
"peak_concentric_velocity_ms": (2.6, 3.3),
|
|
333
|
+
"rsi": (0.85, 1.25),
|
|
334
|
+
"hip_angle_deg": (168, 178),
|
|
335
|
+
"knee_angle_deg": (170, 182),
|
|
336
|
+
"ankle_angle_deg": (120, 138),
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
"elite_male": {
|
|
340
|
+
"label": "Elite Male Athlete (college/pro volleyball/basketball)",
|
|
341
|
+
"profile": AthleteProfile.ELITE,
|
|
342
|
+
"expected": {
|
|
343
|
+
"jump_height_m": (0.68, 0.88),
|
|
344
|
+
"flight_time_s": (0.74, 0.84),
|
|
345
|
+
"countermovement_depth_m": (0.42, 0.62),
|
|
346
|
+
"concentric_duration_s": (0.28, 0.42),
|
|
347
|
+
"eccentric_duration_s": (0.35, 0.55),
|
|
348
|
+
"peak_eccentric_velocity_ms": (2.1, 3.2),
|
|
349
|
+
"peak_concentric_velocity_ms": (3.6, 4.2),
|
|
350
|
+
"rsi": (1.85, 2.80),
|
|
351
|
+
"hip_angle_deg": (173, 185),
|
|
352
|
+
"knee_angle_deg": (176, 188),
|
|
353
|
+
"ankle_angle_deg": (132, 148),
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def estimate_athlete_profile(metrics_dict: dict) -> AthleteProfile:
|
|
360
|
+
"""Estimate athlete profile from metrics.
|
|
361
|
+
|
|
362
|
+
Uses jump height as primary classifier:
|
|
363
|
+
- <0.20m: Elderly
|
|
364
|
+
- 0.20-0.35m: Untrained
|
|
365
|
+
- 0.35-0.65m: Recreational
|
|
366
|
+
- 0.65-0.85m: Trained
|
|
367
|
+
- >0.85m: Elite
|
|
368
|
+
"""
|
|
369
|
+
jump_height = metrics_dict.get("jump_height", 0)
|
|
370
|
+
|
|
371
|
+
if jump_height < 0.20:
|
|
372
|
+
return AthleteProfile.ELDERLY
|
|
373
|
+
elif jump_height < 0.35:
|
|
374
|
+
return AthleteProfile.UNTRAINED
|
|
375
|
+
elif jump_height < 0.65:
|
|
376
|
+
return AthleteProfile.RECREATIONAL
|
|
377
|
+
elif jump_height < 0.85:
|
|
378
|
+
return AthleteProfile.TRAINED
|
|
379
|
+
else:
|
|
380
|
+
return AthleteProfile.ELITE
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinemotion
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.1
|
|
4
4
|
Summary: Video-based kinematic analysis for athletic performance
|
|
5
5
|
Project-URL: Homepage, https://github.com/feniix/kinemotion
|
|
6
6
|
Project-URL: Repository, https://github.com/feniix/kinemotion
|
|
@@ -67,7 +67,7 @@ Description-Content-Type: text/markdown
|
|
|
67
67
|
|
|
68
68
|
- **Ground contact detection** based on foot velocity and position
|
|
69
69
|
- **Automatic drop jump detection** - identifies box → drop → landing → jump phases
|
|
70
|
-
- **Metrics**: Ground contact time, flight time, jump height (
|
|
70
|
+
- **Metrics**: Ground contact time, flight time, jump height (calculated from flight time)
|
|
71
71
|
- **Reactive strength index** calculations
|
|
72
72
|
|
|
73
73
|
### Counter Movement Jump (CMJ) Analysis
|
|
@@ -267,27 +267,33 @@ kinemotion cmj-analyze videos/*.mp4 --batch --workers 4 \
|
|
|
267
267
|
--csv-summary summary.csv
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
### Quality
|
|
270
|
+
### Quality Assessment
|
|
271
271
|
|
|
272
|
-
All analysis outputs include automatic quality assessment to help you know when to trust results:
|
|
272
|
+
All analysis outputs include automatic quality assessment in the metadata section to help you know when to trust results:
|
|
273
273
|
|
|
274
274
|
```json
|
|
275
275
|
{
|
|
276
|
-
"
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
"quality_score": 87.3,
|
|
280
|
-
"quality_indicators": {
|
|
281
|
-
"avg_visibility": 0.89,
|
|
282
|
-
"min_visibility": 0.82,
|
|
283
|
-
"tracking_stable": true,
|
|
284
|
-
"phase_detection_clear": true,
|
|
285
|
-
"outliers_detected": 2,
|
|
286
|
-
"outlier_percentage": 1.5,
|
|
287
|
-
"position_variance": 0.0008,
|
|
288
|
-
"fps": 60.0
|
|
276
|
+
"data": {
|
|
277
|
+
"jump_height_m": 0.352,
|
|
278
|
+
"flight_time_ms": 534.2
|
|
289
279
|
},
|
|
290
|
-
"
|
|
280
|
+
"metadata": {
|
|
281
|
+
"quality": {
|
|
282
|
+
"confidence": "high",
|
|
283
|
+
"quality_score": 87.3,
|
|
284
|
+
"quality_indicators": {
|
|
285
|
+
"avg_visibility": 0.89,
|
|
286
|
+
"min_visibility": 0.82,
|
|
287
|
+
"tracking_stable": true,
|
|
288
|
+
"phase_detection_clear": true,
|
|
289
|
+
"outliers_detected": 2,
|
|
290
|
+
"outlier_percentage": 1.5,
|
|
291
|
+
"position_variance": 0.0008,
|
|
292
|
+
"fps": 60.0
|
|
293
|
+
},
|
|
294
|
+
"warnings": []
|
|
295
|
+
}
|
|
296
|
+
}
|
|
291
297
|
}
|
|
292
298
|
```
|
|
293
299
|
|
|
@@ -310,9 +316,9 @@ All analysis outputs include automatic quality assessment to help you know when
|
|
|
310
316
|
```python
|
|
311
317
|
# Only use high-confidence results
|
|
312
318
|
metrics = process_cmj_video("video.mp4")
|
|
313
|
-
if metrics.quality_assessment.confidence == "high":
|
|
319
|
+
if metrics.quality_assessment is not None and metrics.quality_assessment.confidence == "high":
|
|
314
320
|
print(f"Reliable jump height: {metrics.jump_height:.3f}m")
|
|
315
|
-
|
|
321
|
+
elif metrics.quality_assessment is not None:
|
|
316
322
|
print(f"Low quality - warnings: {metrics.quality_assessment.warnings}")
|
|
317
323
|
```
|
|
318
324
|
|
|
@@ -414,14 +420,9 @@ Kinemotion automatically optimizes parameters based on your video:
|
|
|
414
420
|
- **Quality-based adjustments**: Adapts smoothing based on MediaPipe tracking confidence
|
|
415
421
|
- **Always enabled**: Outlier rejection, curvature analysis, drop start detection
|
|
416
422
|
|
|
417
|
-
###
|
|
423
|
+
### Parameters
|
|
418
424
|
|
|
419
|
-
|
|
420
|
-
- Height of drop box/platform in meters (e.g., 0.40 for 40cm)
|
|
421
|
-
- Used for accurate calibration of jump height measurements
|
|
422
|
-
- Measure your box height accurately for best results
|
|
423
|
-
|
|
424
|
-
### Optional Parameters
|
|
425
|
+
All parameters are optional. Kinemotion uses intelligent auto-tuning to select optimal settings based on video characteristics.
|
|
425
426
|
|
|
426
427
|
- `--quality [fast|balanced|accurate]` (default: balanced)
|
|
427
428
|
|
|
@@ -460,32 +461,95 @@ For advanced users who need manual control:
|
|
|
460
461
|
|
|
461
462
|
## Output Format
|
|
462
463
|
|
|
463
|
-
### JSON
|
|
464
|
+
### Drop Jump JSON Output
|
|
464
465
|
|
|
465
466
|
```json
|
|
466
467
|
{
|
|
467
|
-
"
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
468
|
+
"data": {
|
|
469
|
+
"ground_contact_time_ms": 245.67,
|
|
470
|
+
"flight_time_ms": 456.78,
|
|
471
|
+
"jump_height_m": 0.339,
|
|
472
|
+
"jump_height_kinematic_m": 0.339,
|
|
473
|
+
"jump_height_trajectory_normalized": 0.0845,
|
|
474
|
+
"contact_start_frame": 45,
|
|
475
|
+
"contact_end_frame": 67,
|
|
476
|
+
"flight_start_frame": 68,
|
|
477
|
+
"flight_end_frame": 95,
|
|
478
|
+
"peak_height_frame": 82,
|
|
479
|
+
"contact_start_frame_precise": 45.234,
|
|
480
|
+
"contact_end_frame_precise": 67.891,
|
|
481
|
+
"flight_start_frame_precise": 68.123,
|
|
482
|
+
"flight_end_frame_precise": 94.567
|
|
483
|
+
},
|
|
484
|
+
"metadata": {
|
|
485
|
+
"quality": { },
|
|
486
|
+
"processing_info": { }
|
|
487
|
+
}
|
|
477
488
|
}
|
|
478
489
|
```
|
|
479
490
|
|
|
480
|
-
**Fields**:
|
|
491
|
+
**Data Fields**:
|
|
481
492
|
|
|
482
|
-
- `
|
|
483
|
-
- `
|
|
493
|
+
- `ground_contact_time_ms`: Duration of ground contact phase in milliseconds
|
|
494
|
+
- `flight_time_ms`: Duration of flight phase in milliseconds
|
|
495
|
+
- `jump_height_m`: Jump height calculated from flight time: h = g × t² / 8
|
|
496
|
+
- `jump_height_kinematic_m`: Kinematic estimate (same as `jump_height_m`)
|
|
484
497
|
- `jump_height_trajectory_normalized`: Position-based measurement in normalized coordinates (0-1 range)
|
|
485
|
-
- `
|
|
486
|
-
- `
|
|
498
|
+
- `contact_start_frame`: Frame index where contact begins (integer, for visualization)
|
|
499
|
+
- `contact_end_frame`: Frame index where contact ends (integer, for visualization)
|
|
500
|
+
- `flight_start_frame`: Frame index where flight begins (integer, for visualization)
|
|
501
|
+
- `flight_end_frame`: Frame index where flight ends (integer, for visualization)
|
|
502
|
+
- `peak_height_frame`: Frame index at maximum jump height (integer, for visualization)
|
|
503
|
+
- `contact_start_frame_precise`: Sub-frame precise timing for contact start (fractional, for calculations)
|
|
504
|
+
- `contact_end_frame_precise`: Sub-frame precise timing for contact end (fractional, for calculations)
|
|
505
|
+
- `flight_start_frame_precise`: Sub-frame precise timing for flight start (fractional, for calculations)
|
|
506
|
+
- `flight_end_frame_precise`: Sub-frame precise timing for flight end (fractional, for calculations)
|
|
507
|
+
|
|
508
|
+
**Note**: Integer frame indices are provided for visualization in debug videos. Precise fractional frames are used for all timing calculations and provide sub-frame accuracy (±10ms at 30fps).
|
|
509
|
+
|
|
510
|
+
### CMJ JSON Output
|
|
511
|
+
|
|
512
|
+
```json
|
|
513
|
+
{
|
|
514
|
+
"data": {
|
|
515
|
+
"jump_height_m": 0.352,
|
|
516
|
+
"flight_time_ms": 534.2,
|
|
517
|
+
"countermovement_depth_m": 0.285,
|
|
518
|
+
"eccentric_duration_ms": 612.5,
|
|
519
|
+
"concentric_duration_ms": 321.8,
|
|
520
|
+
"total_movement_time_ms": 934.3,
|
|
521
|
+
"peak_eccentric_velocity_m_s": -2.145,
|
|
522
|
+
"peak_concentric_velocity_m_s": 3.789,
|
|
523
|
+
"transition_time_ms": 125.4,
|
|
524
|
+
"standing_start_frame": 12.5,
|
|
525
|
+
"lowest_point_frame": 45.2,
|
|
526
|
+
"takeoff_frame": 67.8,
|
|
527
|
+
"landing_frame": 102.3,
|
|
528
|
+
"tracking_method": "foot"
|
|
529
|
+
},
|
|
530
|
+
"metadata": {
|
|
531
|
+
"quality": { },
|
|
532
|
+
"processing_info": { }
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
```
|
|
487
536
|
|
|
488
|
-
**
|
|
537
|
+
**Data Fields**:
|
|
538
|
+
|
|
539
|
+
- `jump_height_m`: Jump height calculated from flight time: h = g × t² / 8
|
|
540
|
+
- `flight_time_ms`: Duration of flight phase in milliseconds
|
|
541
|
+
- `countermovement_depth_m`: Maximum downward displacement during eccentric (descent) phase
|
|
542
|
+
- `eccentric_duration_ms`: Time from start of countermovement to lowest point
|
|
543
|
+
- `concentric_duration_ms`: Time from lowest point to takeoff
|
|
544
|
+
- `total_movement_time_ms`: Total time from countermovement start to takeoff
|
|
545
|
+
- `peak_eccentric_velocity_m_s`: Maximum downward velocity during descent (negative value)
|
|
546
|
+
- `peak_concentric_velocity_m_s`: Maximum upward velocity during propulsion (positive value)
|
|
547
|
+
- `transition_time_ms`: Duration at lowest point (amortization phase between descent and propulsion)
|
|
548
|
+
- `standing_start_frame`: Frame where standing phase ends and countermovement begins
|
|
549
|
+
- `lowest_point_frame`: Frame at the lowest point of the countermovement
|
|
550
|
+
- `takeoff_frame`: Frame where athlete leaves ground
|
|
551
|
+
- `landing_frame`: Frame where athlete lands after jump
|
|
552
|
+
- `tracking_method`: Tracking method used - "foot" (foot landmarks) or "com" (center of mass estimation)
|
|
489
553
|
|
|
490
554
|
### Debug Video
|
|
491
555
|
|
|
@@ -542,8 +606,7 @@ The debug video includes:
|
|
|
542
606
|
|
|
543
607
|
**Solutions**:
|
|
544
608
|
|
|
545
|
-
1. **
|
|
546
|
-
- Theoretically improves accuracy (⚠️ unvalidated)
|
|
609
|
+
1. **Check video quality**: Ensure video frame rate is adequate (30fps or higher recommended)
|
|
547
610
|
1. **Verify flight time detection**: Check `flight_start_frame` and `flight_end_frame` in JSON
|
|
548
611
|
1. **Compare measurements**: JSON output includes both `jump_height_m` (primary) and `jump_height_kinematic_m` (kinematic-only)
|
|
549
612
|
1. **Check for drop jump detection**: If doing a drop jump, ensure first phase is elevated enough (>5% of frame height)
|
|
@@ -581,8 +644,7 @@ The debug video includes:
|
|
|
581
644
|
1. **Metric Calculation**:
|
|
582
645
|
- Ground contact time = contact phase duration (using fractional frames)
|
|
583
646
|
- Flight time = flight phase duration (using fractional frames)
|
|
584
|
-
- Jump height =
|
|
585
|
-
- Fallback: kinematic estimate (g × t²) / 8 with optional empirical correction factor (⚠️ unvalidated)
|
|
647
|
+
- Jump height = kinematic estimate from flight time: (g × t²) / 8
|
|
586
648
|
|
|
587
649
|
## Development
|
|
588
650
|
|
|
@@ -593,7 +655,7 @@ This project enforces strict code quality standards:
|
|
|
593
655
|
- **Type safety**: Full pyright strict mode compliance with complete type annotations
|
|
594
656
|
- **Linting**: Comprehensive ruff checks (pycodestyle, pyflakes, isort, pep8-naming, etc.)
|
|
595
657
|
- **Formatting**: Black code style
|
|
596
|
-
- **Testing**: pytest with
|
|
658
|
+
- **Testing**: pytest with 261 comprehensive tests (74.27% coverage)
|
|
597
659
|
- **PEP 561 compliant**: Includes py.typed marker for type checking support
|
|
598
660
|
|
|
599
661
|
### Development Commands
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
kinemotion/__init__.py,sha256=
|
|
1
|
+
kinemotion/__init__.py,sha256=sxdDOekOrIgjxm842gy-6zfq7OWmGl9ShJtXCm4JI7c,723
|
|
2
2
|
kinemotion/api.py,sha256=tbkjXsfe0N0Bmik6XRIOYM7Nom4QqeQJSpDx7IoiuSA,38177
|
|
3
3
|
kinemotion/cli.py,sha256=cqYV_7URH0JUDy1VQ_EDLv63FmNO4Ns20m6s1XAjiP4,464
|
|
4
4
|
kinemotion/cmj/__init__.py,sha256=Ynv0-Oco4I3Y1Ubj25m3h9h2XFqeNwpAewXmAYOmwfU,127
|
|
5
|
-
kinemotion/cmj/analysis.py,sha256=
|
|
5
|
+
kinemotion/cmj/analysis.py,sha256=il7-sfM89ZetxLhmw9boViaP4E8Y3mlS_XI-B5txmMs,19795
|
|
6
6
|
kinemotion/cmj/cli.py,sha256=12FEfWrseG4kCUbgHHdBPkWp6zzVQ0VAzfgNJotArmM,10792
|
|
7
7
|
kinemotion/cmj/debug_overlay.py,sha256=D-y2FQKI01KY0WXFKTKg6p9Qj3AkXCE7xjau3Ais080,15886
|
|
8
|
-
kinemotion/cmj/joint_angles.py,sha256=
|
|
8
|
+
kinemotion/cmj/joint_angles.py,sha256=HmheIEiKcQz39cRezk4h-htorOhGNPsqKIR9RsAEKts,9960
|
|
9
9
|
kinemotion/cmj/kinematics.py,sha256=-iBFg2AkQR4LaThCQzO09fx6qJed27ZfMDQJgE7Si4k,9772
|
|
10
10
|
kinemotion/core/__init__.py,sha256=HsqolRa60cW3vrG8F9Lvr9WvWcs5hCmsTzSgo7imi-4,1278
|
|
11
11
|
kinemotion/core/auto_tuning.py,sha256=j6cul_qC6k0XyryCG93C1AWH2MKPj3UBMzuX02xaqfI,11235
|
|
12
12
|
kinemotion/core/cli_utils.py,sha256=Pq1JF7yvK1YbH0tOUWKjplthCbWsJQt4Lv7esPYH4FM,7254
|
|
13
|
+
kinemotion/core/cmj_metrics_validator.py,sha256=Jfh8oxhxz5BCBIPdeMRHa60tZsliDkN10RiLQYkmck4,27262
|
|
14
|
+
kinemotion/core/cmj_validation_bounds.py,sha256=WBMuJx6ewb-rYan3xmQu32m7bs9h8J5isa4LduZuZkI,13507
|
|
13
15
|
kinemotion/core/debug_overlay_utils.py,sha256=TyUb5okv5qw8oeaX3jsUO_kpwf1NnaHEAOTm-8LwTno,4587
|
|
14
16
|
kinemotion/core/filtering.py,sha256=f-m-aA59e4WqE6u-9MA51wssu7rI-Y_7n1cG8IWdeRQ,11241
|
|
15
17
|
kinemotion/core/formatting.py,sha256=G_3eqgOtym9RFOZVEwCxye4A2cyrmgvtQ214vIshowU,2480
|
|
@@ -24,8 +26,8 @@ kinemotion/dropjump/cli.py,sha256=ZyroaYPwz8TgfL39Wcaj6m68Awl6lYXC75ttaflU-c0,16
|
|
|
24
26
|
kinemotion/dropjump/debug_overlay.py,sha256=LkPw6ucb7beoYWS4L-Lvjs1KLCm5wAWDAfiznUeV2IQ,5668
|
|
25
27
|
kinemotion/dropjump/kinematics.py,sha256=Yr3G7AQwtYy1dxyeOAYfqqgd4pzoZwWQAhZzxI5RbnE,16658
|
|
26
28
|
kinemotion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
-
kinemotion-0.
|
|
28
|
-
kinemotion-0.
|
|
29
|
-
kinemotion-0.
|
|
30
|
-
kinemotion-0.
|
|
31
|
-
kinemotion-0.
|
|
29
|
+
kinemotion-0.29.1.dist-info/METADATA,sha256=fOQmDQ0rv47YYfH_wXRU6wUDk2gDnSfS__nHTOlTKXU,25810
|
|
30
|
+
kinemotion-0.29.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
31
|
+
kinemotion-0.29.1.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
|
|
32
|
+
kinemotion-0.29.1.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
|
|
33
|
+
kinemotion-0.29.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|