kinemotion 0.10.6__py3-none-any.whl → 0.67.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.
- kinemotion/__init__.py +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
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 kinemotion.core.types import MetricsDict
|
|
19
|
+
from kinemotion.core.validation import AthleteProfile, MetricBounds
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CMJBounds:
|
|
23
|
+
"""Collection of physiological bounds for all CMJ metrics."""
|
|
24
|
+
|
|
25
|
+
# FLIGHT TIME (seconds)
|
|
26
|
+
FLIGHT_TIME = MetricBounds(
|
|
27
|
+
absolute_min=0.08, # Frame rate resolution limit
|
|
28
|
+
practical_min=0.15, # Minimum effort jump
|
|
29
|
+
recreational_min=0.25, # Untrained ~10cm
|
|
30
|
+
recreational_max=0.70, # Recreational ~30-50cm
|
|
31
|
+
elite_min=0.65, # Trained ~60cm
|
|
32
|
+
elite_max=1.10, # Elite >100cm
|
|
33
|
+
absolute_max=1.30,
|
|
34
|
+
unit="s",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# JUMP HEIGHT (meters)
|
|
38
|
+
JUMP_HEIGHT = MetricBounds(
|
|
39
|
+
absolute_min=0.02,
|
|
40
|
+
practical_min=0.05,
|
|
41
|
+
recreational_min=0.15, # Untrained with effort
|
|
42
|
+
recreational_max=0.60, # Good recreational
|
|
43
|
+
elite_min=0.65,
|
|
44
|
+
elite_max=1.00,
|
|
45
|
+
absolute_max=1.30,
|
|
46
|
+
unit="m",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# COUNTERMOVEMENT DEPTH (meters)
|
|
50
|
+
COUNTERMOVEMENT_DEPTH = MetricBounds(
|
|
51
|
+
absolute_min=0.05,
|
|
52
|
+
practical_min=0.08, # Minimal squat
|
|
53
|
+
recreational_min=0.20, # Shallow to parallel
|
|
54
|
+
recreational_max=0.55, # Normal to deep squat
|
|
55
|
+
elite_min=0.40,
|
|
56
|
+
elite_max=0.75,
|
|
57
|
+
absolute_max=1.10, # Only extreme tall athletes
|
|
58
|
+
unit="m",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# CONCENTRIC DURATION / CONTACT TIME (seconds)
|
|
62
|
+
CONCENTRIC_DURATION = MetricBounds(
|
|
63
|
+
absolute_min=0.08,
|
|
64
|
+
practical_min=0.10, # Extreme plyometric
|
|
65
|
+
recreational_min=0.40, # Moderate propulsion
|
|
66
|
+
recreational_max=0.90, # Slow push-off
|
|
67
|
+
elite_min=0.25,
|
|
68
|
+
elite_max=0.50,
|
|
69
|
+
absolute_max=1.80,
|
|
70
|
+
unit="s",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# ECCENTRIC DURATION (seconds)
|
|
74
|
+
ECCENTRIC_DURATION = MetricBounds(
|
|
75
|
+
absolute_min=0.15,
|
|
76
|
+
practical_min=0.25,
|
|
77
|
+
recreational_min=0.35,
|
|
78
|
+
recreational_max=0.75,
|
|
79
|
+
elite_min=0.30,
|
|
80
|
+
elite_max=0.65,
|
|
81
|
+
absolute_max=1.30,
|
|
82
|
+
unit="s",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# TOTAL MOVEMENT TIME (seconds)
|
|
86
|
+
TOTAL_MOVEMENT_TIME = MetricBounds(
|
|
87
|
+
absolute_min=0.25,
|
|
88
|
+
practical_min=0.35,
|
|
89
|
+
recreational_min=0.75,
|
|
90
|
+
recreational_max=1.50,
|
|
91
|
+
elite_min=0.55,
|
|
92
|
+
elite_max=1.10,
|
|
93
|
+
absolute_max=2.20,
|
|
94
|
+
unit="s",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# PEAK ECCENTRIC VELOCITY (m/s, downward)
|
|
98
|
+
PEAK_ECCENTRIC_VELOCITY = MetricBounds(
|
|
99
|
+
absolute_min=0.10,
|
|
100
|
+
practical_min=0.20,
|
|
101
|
+
recreational_min=0.80,
|
|
102
|
+
recreational_max=2.00,
|
|
103
|
+
elite_min=2.00,
|
|
104
|
+
elite_max=3.50,
|
|
105
|
+
absolute_max=4.50,
|
|
106
|
+
unit="m/s",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# PEAK CONCENTRIC VELOCITY (m/s, upward)
|
|
110
|
+
PEAK_CONCENTRIC_VELOCITY = MetricBounds(
|
|
111
|
+
absolute_min=0.30,
|
|
112
|
+
practical_min=0.50,
|
|
113
|
+
recreational_min=1.80,
|
|
114
|
+
recreational_max=2.80,
|
|
115
|
+
elite_min=3.00,
|
|
116
|
+
elite_max=4.20,
|
|
117
|
+
absolute_max=5.00,
|
|
118
|
+
unit="m/s",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TripleExtensionBounds:
|
|
123
|
+
"""Physiological bounds for triple extension angles (degrees)."""
|
|
124
|
+
|
|
125
|
+
# HIP ANGLE at takeoff (close to 180° = full extension)
|
|
126
|
+
@staticmethod
|
|
127
|
+
def hip_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
128
|
+
"""Check if hip angle is valid for profile."""
|
|
129
|
+
if angle is None:
|
|
130
|
+
return True # May not be detectable
|
|
131
|
+
if angle < 120 or angle > 195:
|
|
132
|
+
return False # Outside physiological limits
|
|
133
|
+
if profile == AthleteProfile.ELDERLY:
|
|
134
|
+
return 150 <= angle <= 175
|
|
135
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
136
|
+
return 160 <= angle <= 180
|
|
137
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
138
|
+
return 170 <= angle <= 185
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
# KNEE ANGLE at takeoff (close to 180° = full extension)
|
|
142
|
+
@staticmethod
|
|
143
|
+
def knee_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
144
|
+
"""Check if knee angle is valid for profile."""
|
|
145
|
+
if angle is None:
|
|
146
|
+
return True
|
|
147
|
+
if angle < 130 or angle > 200:
|
|
148
|
+
return False # Outside physiological limits
|
|
149
|
+
if profile == AthleteProfile.ELDERLY:
|
|
150
|
+
return 155 <= angle <= 175
|
|
151
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
152
|
+
return 165 <= angle <= 182
|
|
153
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
154
|
+
return 173 <= angle <= 190
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
# ANKLE ANGLE at takeoff (120-155° = plantarflexion, 90° = neutral)
|
|
158
|
+
@staticmethod
|
|
159
|
+
def ankle_angle_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
160
|
+
"""Check if ankle angle is valid for profile."""
|
|
161
|
+
if angle is None:
|
|
162
|
+
return True # Often not detectable in side view
|
|
163
|
+
if angle < 90 or angle > 165:
|
|
164
|
+
return False # Outside physiological limits
|
|
165
|
+
if profile == AthleteProfile.ELDERLY:
|
|
166
|
+
return 100 <= angle <= 125
|
|
167
|
+
elif profile in (AthleteProfile.UNTRAINED, AthleteProfile.RECREATIONAL):
|
|
168
|
+
return 110 <= angle <= 140
|
|
169
|
+
elif profile in (AthleteProfile.TRAINED, AthleteProfile.ELITE):
|
|
170
|
+
return 125 <= angle <= 155
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
# TRUNK TILT (forward lean from vertical, degrees)
|
|
174
|
+
@staticmethod
|
|
175
|
+
def trunk_tilt_valid(angle: float | None, profile: AthleteProfile) -> bool:
|
|
176
|
+
"""Check if trunk tilt is valid for profile."""
|
|
177
|
+
if angle is None:
|
|
178
|
+
return True
|
|
179
|
+
if angle < -15 or angle > 60:
|
|
180
|
+
return False # Outside reasonable range
|
|
181
|
+
# Most athletes show 10-30° forward lean during takeoff
|
|
182
|
+
return -10 <= angle <= 45
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RSIBounds:
|
|
186
|
+
"""Reactive Strength Index bounds."""
|
|
187
|
+
|
|
188
|
+
# Calculated as: RSI = flight_time / contact_time
|
|
189
|
+
MIN_VALID = 0.30 # Below this: invalid metrics
|
|
190
|
+
MAX_VALID = 4.00 # Above this: invalid metrics
|
|
191
|
+
|
|
192
|
+
ELDERLY_RANGE = (0.15, 0.30)
|
|
193
|
+
UNTRAINED_RANGE = (0.30, 0.80)
|
|
194
|
+
RECREATIONAL_RANGE = (0.80, 1.50)
|
|
195
|
+
TRAINED_RANGE = (1.50, 2.40)
|
|
196
|
+
ELITE_RANGE = (2.20, 3.50)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def get_rsi_range(profile: AthleteProfile) -> tuple[float, float]:
|
|
200
|
+
"""Get expected RSI range for athlete profile."""
|
|
201
|
+
if profile == AthleteProfile.ELDERLY:
|
|
202
|
+
return RSIBounds.ELDERLY_RANGE
|
|
203
|
+
elif profile == AthleteProfile.UNTRAINED:
|
|
204
|
+
return RSIBounds.UNTRAINED_RANGE
|
|
205
|
+
elif profile == AthleteProfile.RECREATIONAL:
|
|
206
|
+
return RSIBounds.RECREATIONAL_RANGE
|
|
207
|
+
elif profile == AthleteProfile.TRAINED:
|
|
208
|
+
return RSIBounds.TRAINED_RANGE
|
|
209
|
+
elif profile == AthleteProfile.ELITE:
|
|
210
|
+
return RSIBounds.ELITE_RANGE
|
|
211
|
+
return (RSIBounds.MIN_VALID, RSIBounds.MAX_VALID)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def is_valid(rsi: float) -> bool:
|
|
215
|
+
"""Check if RSI is within physiological bounds."""
|
|
216
|
+
return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def in_range_for_profile(rsi: float, profile: AthleteProfile) -> bool:
|
|
220
|
+
"""Check if RSI is in expected range for profile."""
|
|
221
|
+
min_rsi, max_rsi = RSIBounds.get_rsi_range(profile)
|
|
222
|
+
return min_rsi <= rsi <= max_rsi
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class MetricConsistency:
|
|
226
|
+
"""Cross-validation tolerance for metric consistency checks."""
|
|
227
|
+
|
|
228
|
+
# Jump height from flight time: h = g*t²/8
|
|
229
|
+
# Allow 10% deviation for measurement noise
|
|
230
|
+
HEIGHT_FLIGHT_TIME_TOLERANCE = 0.10
|
|
231
|
+
|
|
232
|
+
# Peak velocity from jump height: v = sqrt(2*g*h)
|
|
233
|
+
# Allow 15% deviation (velocity harder to detect precisely)
|
|
234
|
+
VELOCITY_HEIGHT_TOLERANCE = 0.15
|
|
235
|
+
|
|
236
|
+
# Countermovement depth to jump height ratio
|
|
237
|
+
# Typically 0.5-1.2, flag if outside 0.3-1.5
|
|
238
|
+
DEPTH_HEIGHT_RATIO_MIN = 0.30
|
|
239
|
+
DEPTH_HEIGHT_RATIO_MAX = 1.50
|
|
240
|
+
|
|
241
|
+
# Contact time to countermovement depth ratio
|
|
242
|
+
# Should be roughly 1.0-1.5 s/m
|
|
243
|
+
CONTACT_DEPTH_RATIO_MIN = 0.50
|
|
244
|
+
CONTACT_DEPTH_RATIO_MAX = 2.50
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# Athlete profile examples with expected metric ranges
|
|
248
|
+
ATHLETE_PROFILES = {
|
|
249
|
+
"elderly_deconditioned": {
|
|
250
|
+
"label": "Elderly/Deconditioned (70+, sedentary)",
|
|
251
|
+
"profile": AthleteProfile.ELDERLY,
|
|
252
|
+
"expected": {
|
|
253
|
+
"jump_height_m": (0.10, 0.18),
|
|
254
|
+
"flight_time_s": (0.14, 0.19),
|
|
255
|
+
"countermovement_depth_m": (0.12, 0.20),
|
|
256
|
+
"concentric_duration_s": (0.80, 1.20),
|
|
257
|
+
"eccentric_duration_s": (0.60, 0.95),
|
|
258
|
+
"peak_eccentric_velocity_ms": (0.4, 0.7),
|
|
259
|
+
"peak_concentric_velocity_ms": (1.0, 1.5),
|
|
260
|
+
"rsi": (0.15, 0.25),
|
|
261
|
+
"hip_angle_deg": (150, 165),
|
|
262
|
+
"knee_angle_deg": (155, 170),
|
|
263
|
+
"ankle_angle_deg": (105, 125),
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
"recreational": {
|
|
267
|
+
"label": "Recreational Athlete (fitness participant, 30-45 yrs)",
|
|
268
|
+
"profile": AthleteProfile.RECREATIONAL,
|
|
269
|
+
"expected": {
|
|
270
|
+
"jump_height_m": (0.35, 0.55),
|
|
271
|
+
"flight_time_s": (0.53, 0.67),
|
|
272
|
+
"countermovement_depth_m": (0.28, 0.45),
|
|
273
|
+
"concentric_duration_s": (0.45, 0.65),
|
|
274
|
+
"eccentric_duration_s": (0.40, 0.65),
|
|
275
|
+
"peak_eccentric_velocity_ms": (1.3, 1.9),
|
|
276
|
+
"peak_concentric_velocity_ms": (2.6, 3.3),
|
|
277
|
+
"rsi": (0.85, 1.25),
|
|
278
|
+
"hip_angle_deg": (168, 178),
|
|
279
|
+
"knee_angle_deg": (170, 182),
|
|
280
|
+
"ankle_angle_deg": (120, 138),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
"elite_male": {
|
|
284
|
+
"label": "Elite Male Athlete (college/pro volleyball/basketball)",
|
|
285
|
+
"profile": AthleteProfile.ELITE,
|
|
286
|
+
"expected": {
|
|
287
|
+
"jump_height_m": (0.68, 0.88),
|
|
288
|
+
"flight_time_s": (0.74, 0.84),
|
|
289
|
+
"countermovement_depth_m": (0.42, 0.62),
|
|
290
|
+
"concentric_duration_s": (0.28, 0.42),
|
|
291
|
+
"eccentric_duration_s": (0.35, 0.55),
|
|
292
|
+
"peak_eccentric_velocity_ms": (2.1, 3.2),
|
|
293
|
+
"peak_concentric_velocity_ms": (3.6, 4.2),
|
|
294
|
+
"rsi": (1.85, 2.80),
|
|
295
|
+
"hip_angle_deg": (173, 185),
|
|
296
|
+
"knee_angle_deg": (176, 188),
|
|
297
|
+
"ankle_angle_deg": (132, 148),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def estimate_athlete_profile(
|
|
304
|
+
metrics_dict: MetricsDict, gender: str | None = None
|
|
305
|
+
) -> AthleteProfile:
|
|
306
|
+
"""Estimate athlete profile from metrics.
|
|
307
|
+
|
|
308
|
+
Uses jump height as primary classifier:
|
|
309
|
+
- <0.20m: Elderly
|
|
310
|
+
- 0.20-0.35m: Untrained
|
|
311
|
+
- 0.35-0.65m: Recreational
|
|
312
|
+
- 0.65-0.85m: Trained
|
|
313
|
+
- >0.85m: Elite
|
|
314
|
+
|
|
315
|
+
NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
|
|
316
|
+
60-70% of male heights due to lower muscle mass and strength. If analyzing
|
|
317
|
+
female athletes, interpret results one level lower than classification suggests.
|
|
318
|
+
Example: Female athlete with 0.45m jump = Recreational male = Trained female.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
metrics_dict: Dictionary with CMJ metric values
|
|
322
|
+
gender: Optional gender for context ("M"/"F"). Currently informational only.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Estimated AthleteProfile
|
|
326
|
+
"""
|
|
327
|
+
# Support both nested "data" structure and flat structure
|
|
328
|
+
# Extract with unit suffix as used in serialization, or without suffix (legacy)
|
|
329
|
+
data = metrics_dict.get("data", metrics_dict)
|
|
330
|
+
jump_height = data.get("jump_height_m") or data.get("jump_height", 0)
|
|
331
|
+
|
|
332
|
+
if jump_height < 0.20:
|
|
333
|
+
return AthleteProfile.ELDERLY
|
|
334
|
+
elif jump_height < 0.35:
|
|
335
|
+
return AthleteProfile.UNTRAINED
|
|
336
|
+
elif jump_height < 0.65:
|
|
337
|
+
return AthleteProfile.RECREATIONAL
|
|
338
|
+
elif jump_height < 0.85:
|
|
339
|
+
return AthleteProfile.TRAINED
|
|
340
|
+
else:
|
|
341
|
+
return AthleteProfile.ELITE
|
kinemotion/core/__init__.py
CHANGED
|
@@ -8,7 +8,15 @@ from .filtering import (
|
|
|
8
8
|
reject_outliers,
|
|
9
9
|
remove_outliers,
|
|
10
10
|
)
|
|
11
|
+
from .model_downloader import get_model_cache_dir, get_model_path
|
|
11
12
|
from .pose import PoseTracker, compute_center_of_mass
|
|
13
|
+
from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
|
|
14
|
+
from .quality import (
|
|
15
|
+
QualityAssessment,
|
|
16
|
+
QualityIndicators,
|
|
17
|
+
assess_jump_quality,
|
|
18
|
+
calculate_position_stability,
|
|
19
|
+
)
|
|
12
20
|
from .smoothing import (
|
|
13
21
|
compute_acceleration_from_derivative,
|
|
14
22
|
compute_velocity,
|
|
@@ -16,12 +24,22 @@ from .smoothing import (
|
|
|
16
24
|
smooth_landmarks,
|
|
17
25
|
smooth_landmarks_advanced,
|
|
18
26
|
)
|
|
27
|
+
from .timing import (
|
|
28
|
+
NULL_TIMER,
|
|
29
|
+
NullTimer,
|
|
30
|
+
PerformanceTimer,
|
|
31
|
+
Timer,
|
|
32
|
+
)
|
|
19
33
|
from .video_io import VideoProcessor
|
|
20
34
|
|
|
21
35
|
__all__ = [
|
|
22
36
|
# Pose tracking
|
|
23
37
|
"PoseTracker",
|
|
24
38
|
"compute_center_of_mass",
|
|
39
|
+
"LANDMARK_INDICES",
|
|
40
|
+
"KINEMOTION_LANDMARKS",
|
|
41
|
+
"get_model_path",
|
|
42
|
+
"get_model_cache_dir",
|
|
25
43
|
# Smoothing
|
|
26
44
|
"smooth_landmarks",
|
|
27
45
|
"smooth_landmarks_advanced",
|
|
@@ -35,6 +53,16 @@ __all__ = [
|
|
|
35
53
|
"reject_outliers",
|
|
36
54
|
"adaptive_smooth_window",
|
|
37
55
|
"bilateral_temporal_filter",
|
|
56
|
+
# Quality Assessment
|
|
57
|
+
"QualityAssessment",
|
|
58
|
+
"QualityIndicators",
|
|
59
|
+
"assess_jump_quality",
|
|
60
|
+
"calculate_position_stability",
|
|
61
|
+
# Timing
|
|
62
|
+
"PerformanceTimer",
|
|
63
|
+
"Timer",
|
|
64
|
+
"NullTimer",
|
|
65
|
+
"NULL_TIMER",
|
|
38
66
|
# Video I/O
|
|
39
67
|
"VideoProcessor",
|
|
40
68
|
]
|
kinemotion/core/auto_tuning.py
CHANGED
|
@@ -108,10 +108,12 @@ def auto_tune_parameters(
|
|
|
108
108
|
# =================================================================
|
|
109
109
|
|
|
110
110
|
# Velocity threshold: Scale inversely with fps
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
|
|
111
|
+
# Empirically validated with 45° oblique videos at 60fps:
|
|
112
|
+
# - Standing (stationary): ~0.001 mean, 0.0011 max
|
|
113
|
+
# - Flight/drop (moving): ~0.005-0.009
|
|
114
|
+
# Target threshold: 0.002 at 60fps for clear separation
|
|
115
|
+
# Formula: threshold = 0.004 * (30 / fps)
|
|
116
|
+
base_velocity_threshold = 0.004 * (30.0 / fps)
|
|
115
117
|
|
|
116
118
|
# Min contact frames: Scale with fps to maintain same time duration
|
|
117
119
|
# Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
|
|
@@ -166,9 +168,7 @@ def auto_tune_parameters(
|
|
|
166
168
|
elif quality_preset == QualityPreset.ACCURATE:
|
|
167
169
|
# Accurate: Maximize accuracy, accept slower processing
|
|
168
170
|
velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
|
|
169
|
-
min_contact_frames = (
|
|
170
|
-
base_min_contact_frames # Don't increase (would miss brief)
|
|
171
|
-
)
|
|
171
|
+
min_contact_frames = base_min_contact_frames # Don't increase (would miss brief)
|
|
172
172
|
smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
|
|
173
173
|
bilateral_filter = True # Always use for best accuracy
|
|
174
174
|
detection_confidence = 0.6
|
|
@@ -216,6 +216,59 @@ def auto_tune_parameters(
|
|
|
216
216
|
)
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
def _collect_foot_visibility_and_positions(
|
|
220
|
+
frame_landmarks: dict[str, tuple[float, float, float]],
|
|
221
|
+
) -> tuple[list[float], list[float]]:
|
|
222
|
+
"""
|
|
223
|
+
Collect visibility scores and Y positions from foot landmarks.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
frame_landmarks: Landmarks for a single frame
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Tuple of (visibility_scores, y_positions)
|
|
230
|
+
"""
|
|
231
|
+
foot_keys = [
|
|
232
|
+
"left_ankle",
|
|
233
|
+
"right_ankle",
|
|
234
|
+
"left_heel",
|
|
235
|
+
"right_heel",
|
|
236
|
+
"left_foot_index",
|
|
237
|
+
"right_foot_index",
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
frame_vis = []
|
|
241
|
+
frame_y_positions = []
|
|
242
|
+
|
|
243
|
+
for key in foot_keys:
|
|
244
|
+
if key in frame_landmarks:
|
|
245
|
+
_, y, vis = frame_landmarks[key] # x not needed for analysis
|
|
246
|
+
frame_vis.append(vis)
|
|
247
|
+
frame_y_positions.append(y)
|
|
248
|
+
|
|
249
|
+
return frame_vis, frame_y_positions
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_stable_period(positions: list[float]) -> bool:
|
|
253
|
+
"""
|
|
254
|
+
Check if video has a stable period at the start.
|
|
255
|
+
|
|
256
|
+
A stable period (low variance in first 30 frames) indicates
|
|
257
|
+
the subject is standing on an elevated platform before jumping.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
positions: List of average Y positions per frame
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
True if stable period detected, False otherwise
|
|
264
|
+
"""
|
|
265
|
+
if len(positions) < 30:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
first_30_std = float(np.std(positions[:30]))
|
|
269
|
+
return first_30_std < 0.01 # Very stable = on platform
|
|
270
|
+
|
|
271
|
+
|
|
219
272
|
def analyze_video_sample(
|
|
220
273
|
landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
|
|
221
274
|
fps: float,
|
|
@@ -235,35 +288,20 @@ def analyze_video_sample(
|
|
|
235
288
|
Returns:
|
|
236
289
|
VideoCharacteristics with analyzed properties
|
|
237
290
|
"""
|
|
238
|
-
# Calculate average landmark visibility
|
|
239
291
|
visibilities = []
|
|
240
292
|
positions = []
|
|
241
293
|
|
|
294
|
+
# Collect visibility and position data from all frames
|
|
242
295
|
for frame_landmarks in landmarks_sequence:
|
|
243
|
-
if frame_landmarks:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
]
|
|
253
|
-
|
|
254
|
-
frame_vis = []
|
|
255
|
-
frame_y_positions = []
|
|
256
|
-
|
|
257
|
-
for key in foot_keys:
|
|
258
|
-
if key in frame_landmarks:
|
|
259
|
-
_, y, vis = frame_landmarks[key] # x not needed for analysis
|
|
260
|
-
frame_vis.append(vis)
|
|
261
|
-
frame_y_positions.append(y)
|
|
262
|
-
|
|
263
|
-
if frame_vis:
|
|
264
|
-
visibilities.append(float(np.mean(frame_vis)))
|
|
265
|
-
if frame_y_positions:
|
|
266
|
-
positions.append(float(np.mean(frame_y_positions)))
|
|
296
|
+
if not frame_landmarks:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
frame_vis, frame_y_positions = _collect_foot_visibility_and_positions(frame_landmarks)
|
|
300
|
+
|
|
301
|
+
if frame_vis:
|
|
302
|
+
visibilities.append(float(np.mean(frame_vis)))
|
|
303
|
+
if frame_y_positions:
|
|
304
|
+
positions.append(float(np.mean(frame_y_positions)))
|
|
267
305
|
|
|
268
306
|
# Compute metrics
|
|
269
307
|
avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
|
|
@@ -273,11 +311,7 @@ def analyze_video_sample(
|
|
|
273
311
|
tracking_quality = analyze_tracking_quality(avg_visibility)
|
|
274
312
|
|
|
275
313
|
# Check for stable period (indicates drop jump from elevated platform)
|
|
276
|
-
|
|
277
|
-
has_stable_period = False
|
|
278
|
-
if len(positions) >= 30:
|
|
279
|
-
first_30_std = float(np.std(positions[:30]))
|
|
280
|
-
has_stable_period = first_30_std < 0.01 # Very stable = on platform
|
|
314
|
+
has_stable_period = _check_stable_period(positions)
|
|
281
315
|
|
|
282
316
|
return VideoCharacteristics(
|
|
283
317
|
fps=fps,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared CLI utilities for drop jump and CMJ analysis."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
11
|
+
"""Add common output options to CLI command."""
|
|
12
|
+
func = click.option(
|
|
13
|
+
"--output",
|
|
14
|
+
"-o",
|
|
15
|
+
type=click.Path(),
|
|
16
|
+
help="Path for debug video output (optional)",
|
|
17
|
+
)(func)
|
|
18
|
+
func = click.option(
|
|
19
|
+
"--json-output",
|
|
20
|
+
"-j",
|
|
21
|
+
type=click.Path(),
|
|
22
|
+
help="Path for JSON metrics output (default: stdout)",
|
|
23
|
+
)(func)
|
|
24
|
+
return func
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def collect_video_files(video_path: tuple[str, ...]) -> list[str]:
|
|
28
|
+
"""Expand glob patterns and collect all video files."""
|
|
29
|
+
video_files: list[str] = []
|
|
30
|
+
for pattern in video_path:
|
|
31
|
+
expanded = glob.glob(pattern)
|
|
32
|
+
if expanded:
|
|
33
|
+
video_files.extend(expanded)
|
|
34
|
+
elif Path(pattern).exists():
|
|
35
|
+
video_files.append(pattern)
|
|
36
|
+
else:
|
|
37
|
+
click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
|
|
38
|
+
return video_files
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_batch_output_paths(
|
|
42
|
+
video_path: str, output_dir: str | None, json_output_dir: str | None
|
|
43
|
+
) -> tuple[str | None, str | None]:
|
|
44
|
+
"""Generate output paths for debug video and JSON in batch mode.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
video_path: Path to source video
|
|
48
|
+
output_dir: Directory for debug video output (optional)
|
|
49
|
+
json_output_dir: Directory for JSON metrics output (optional)
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Tuple of (debug_video_path, json_output_path)
|
|
53
|
+
"""
|
|
54
|
+
out_path = None
|
|
55
|
+
json_path = None
|
|
56
|
+
if output_dir:
|
|
57
|
+
out_path = str(Path(output_dir) / f"{Path(video_path).stem}_debug.mp4")
|
|
58
|
+
if json_output_dir:
|
|
59
|
+
json_path = str(Path(json_output_dir) / f"{Path(video_path).stem}.json")
|
|
60
|
+
return out_path, json_path
|