kinemotion 0.29.3__py3-none-any.whl → 0.31.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/api.py +35 -7
- kinemotion/cmj/analysis.py +6 -3
- kinemotion/cmj/cli.py +11 -6
- kinemotion/cmj/debug_overlay.py +3 -3
- kinemotion/cmj/kinematics.py +22 -7
- kinemotion/core/cli_utils.py +4 -2
- kinemotion/core/cmj_metrics_validator.py +134 -21
- kinemotion/core/cmj_validation_bounds.py +15 -1
- kinemotion/core/dropjump_metrics_validator.py +346 -0
- kinemotion/core/dropjump_validation_bounds.py +196 -0
- kinemotion/core/metadata.py +2 -1
- kinemotion/core/quality.py +5 -4
- kinemotion/core/smoothing.py +4 -2
- kinemotion/core/video_io.py +3 -2
- kinemotion/dropjump/analysis.py +12 -7
- kinemotion/dropjump/cli.py +7 -4
- kinemotion/dropjump/kinematics.py +18 -5
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/METADATA +1 -1
- kinemotion-0.31.0.dist-info/RECORD +35 -0
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/WHEEL +1 -1
- kinemotion-0.29.3.dist-info/RECORD +0 -33
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.29.3.dist-info → kinemotion-0.31.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Drop jump metrics validation using physiological bounds.
|
|
2
|
+
|
|
3
|
+
Comprehensive validation of Drop Jump metrics against biomechanical bounds,
|
|
4
|
+
consistency checks, and cross-validation of RSI calculation.
|
|
5
|
+
|
|
6
|
+
Provides severity levels (ERROR, WARNING, INFO) for different categories
|
|
7
|
+
of metric issues.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from kinemotion.core.dropjump_validation_bounds import (
|
|
14
|
+
AthleteProfile,
|
|
15
|
+
DropJumpBounds,
|
|
16
|
+
estimate_athlete_profile,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationSeverity(Enum):
|
|
21
|
+
"""Severity level for validation issues."""
|
|
22
|
+
|
|
23
|
+
ERROR = "ERROR" # Metrics invalid, likely data corruption
|
|
24
|
+
WARNING = "WARNING" # Metrics valid but unusual, needs review
|
|
25
|
+
INFO = "INFO" # Normal variation, informational only
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ValidationIssue:
|
|
30
|
+
"""Single validation issue."""
|
|
31
|
+
|
|
32
|
+
severity: ValidationSeverity
|
|
33
|
+
metric: str
|
|
34
|
+
message: str
|
|
35
|
+
value: float | None = None
|
|
36
|
+
bounds: tuple[float, float] | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ValidationResult:
|
|
41
|
+
"""Complete validation result for drop jump metrics."""
|
|
42
|
+
|
|
43
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
44
|
+
status: str = "PASS" # "PASS", "PASS_WITH_WARNINGS", "FAIL"
|
|
45
|
+
athlete_profile: AthleteProfile | None = None
|
|
46
|
+
rsi: float | None = None
|
|
47
|
+
contact_flight_ratio: float | None = None
|
|
48
|
+
height_kinematic_trajectory_consistency: float | None = None # % error
|
|
49
|
+
|
|
50
|
+
def add_error(
|
|
51
|
+
self,
|
|
52
|
+
metric: str,
|
|
53
|
+
message: str,
|
|
54
|
+
value: float | None = None,
|
|
55
|
+
bounds: tuple[float, float] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Add error-level issue."""
|
|
58
|
+
self.issues.append(
|
|
59
|
+
ValidationIssue(
|
|
60
|
+
severity=ValidationSeverity.ERROR,
|
|
61
|
+
metric=metric,
|
|
62
|
+
message=message,
|
|
63
|
+
value=value,
|
|
64
|
+
bounds=bounds,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def add_warning(
|
|
69
|
+
self,
|
|
70
|
+
metric: str,
|
|
71
|
+
message: str,
|
|
72
|
+
value: float | None = None,
|
|
73
|
+
bounds: tuple[float, float] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Add warning-level issue."""
|
|
76
|
+
self.issues.append(
|
|
77
|
+
ValidationIssue(
|
|
78
|
+
severity=ValidationSeverity.WARNING,
|
|
79
|
+
metric=metric,
|
|
80
|
+
message=message,
|
|
81
|
+
value=value,
|
|
82
|
+
bounds=bounds,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def add_info(
|
|
87
|
+
self,
|
|
88
|
+
metric: str,
|
|
89
|
+
message: str,
|
|
90
|
+
value: float | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Add info-level issue."""
|
|
93
|
+
self.issues.append(
|
|
94
|
+
ValidationIssue(
|
|
95
|
+
severity=ValidationSeverity.INFO,
|
|
96
|
+
metric=metric,
|
|
97
|
+
message=message,
|
|
98
|
+
value=value,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def finalize_status(self) -> None:
|
|
103
|
+
"""Determine final pass/fail status based on issues."""
|
|
104
|
+
has_errors = any(
|
|
105
|
+
issue.severity == ValidationSeverity.ERROR for issue in self.issues
|
|
106
|
+
)
|
|
107
|
+
has_warnings = any(
|
|
108
|
+
issue.severity == ValidationSeverity.WARNING for issue in self.issues
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if has_errors:
|
|
112
|
+
self.status = "FAIL"
|
|
113
|
+
elif has_warnings:
|
|
114
|
+
self.status = "PASS_WITH_WARNINGS"
|
|
115
|
+
else:
|
|
116
|
+
self.status = "PASS"
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
"""Convert validation result to JSON-serializable dictionary.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary with status, issues, and consistency metrics.
|
|
123
|
+
"""
|
|
124
|
+
return {
|
|
125
|
+
"status": self.status,
|
|
126
|
+
"issues": [
|
|
127
|
+
{
|
|
128
|
+
"severity": issue.severity.value,
|
|
129
|
+
"metric": issue.metric,
|
|
130
|
+
"message": issue.message,
|
|
131
|
+
"value": issue.value,
|
|
132
|
+
"bounds": issue.bounds,
|
|
133
|
+
}
|
|
134
|
+
for issue in self.issues
|
|
135
|
+
],
|
|
136
|
+
"athlete_profile": (
|
|
137
|
+
self.athlete_profile.value if self.athlete_profile else None
|
|
138
|
+
),
|
|
139
|
+
"rsi": self.rsi,
|
|
140
|
+
"contact_flight_ratio": self.contact_flight_ratio,
|
|
141
|
+
"height_kinematic_trajectory_consistency_percent": (
|
|
142
|
+
self.height_kinematic_trajectory_consistency
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DropJumpMetricsValidator:
|
|
148
|
+
"""Comprehensive drop jump metrics validator."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, assumed_profile: AthleteProfile | None = None):
|
|
151
|
+
"""Initialize validator.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
assumed_profile: If provided, validate against this specific profile.
|
|
155
|
+
Otherwise, estimate from metrics.
|
|
156
|
+
"""
|
|
157
|
+
self.assumed_profile = assumed_profile
|
|
158
|
+
|
|
159
|
+
def validate(self, metrics: dict) -> ValidationResult:
|
|
160
|
+
"""Validate drop jump metrics comprehensively.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
metrics: Dictionary with drop jump metric values
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
ValidationResult with all issues and status
|
|
167
|
+
"""
|
|
168
|
+
result = ValidationResult()
|
|
169
|
+
|
|
170
|
+
# Estimate athlete profile if not provided
|
|
171
|
+
if self.assumed_profile:
|
|
172
|
+
result.athlete_profile = self.assumed_profile
|
|
173
|
+
else:
|
|
174
|
+
result.athlete_profile = estimate_athlete_profile(metrics)
|
|
175
|
+
|
|
176
|
+
# Extract metric values (handle nested "data" structure)
|
|
177
|
+
data = metrics.get("data", metrics) # Support both structures
|
|
178
|
+
|
|
179
|
+
contact_time_ms = data.get("ground_contact_time_ms")
|
|
180
|
+
flight_time_ms = data.get("flight_time_ms")
|
|
181
|
+
jump_height_m = data.get("jump_height_m")
|
|
182
|
+
jump_height_kinematic_m = data.get("jump_height_kinematic_m")
|
|
183
|
+
jump_height_trajectory_m = data.get("jump_height_trajectory_normalized")
|
|
184
|
+
|
|
185
|
+
# Validate individual metrics
|
|
186
|
+
if contact_time_ms is not None:
|
|
187
|
+
self._check_contact_time(contact_time_ms, result)
|
|
188
|
+
|
|
189
|
+
if flight_time_ms is not None:
|
|
190
|
+
self._check_flight_time(flight_time_ms, result)
|
|
191
|
+
|
|
192
|
+
if jump_height_m is not None:
|
|
193
|
+
self._check_jump_height(jump_height_m, result)
|
|
194
|
+
|
|
195
|
+
# Cross-validation
|
|
196
|
+
if contact_time_ms is not None and flight_time_ms is not None:
|
|
197
|
+
self._check_rsi(contact_time_ms, flight_time_ms, result)
|
|
198
|
+
|
|
199
|
+
# Dual height validation (kinematic vs trajectory)
|
|
200
|
+
if jump_height_kinematic_m is not None and jump_height_trajectory_m is not None:
|
|
201
|
+
self._check_dual_height_consistency(
|
|
202
|
+
jump_height_kinematic_m, jump_height_trajectory_m, result
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Finalize status
|
|
206
|
+
result.finalize_status()
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
def _check_contact_time(
|
|
211
|
+
self, contact_time_ms: float, result: ValidationResult
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Validate contact time."""
|
|
214
|
+
contact_time_s = contact_time_ms / 1000.0
|
|
215
|
+
bounds = DropJumpBounds.CONTACT_TIME
|
|
216
|
+
|
|
217
|
+
if not bounds.is_physically_possible(contact_time_s):
|
|
218
|
+
result.add_error(
|
|
219
|
+
"contact_time",
|
|
220
|
+
f"Contact time {contact_time_s:.3f}s physically impossible",
|
|
221
|
+
value=contact_time_s,
|
|
222
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
223
|
+
)
|
|
224
|
+
elif result.athlete_profile and not bounds.contains(
|
|
225
|
+
contact_time_s, result.athlete_profile
|
|
226
|
+
):
|
|
227
|
+
profile_name = result.athlete_profile.value
|
|
228
|
+
result.add_warning(
|
|
229
|
+
"contact_time",
|
|
230
|
+
f"Contact time {contact_time_s:.3f}s unusual for "
|
|
231
|
+
f"{profile_name} athlete",
|
|
232
|
+
value=contact_time_s,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _check_flight_time(
|
|
236
|
+
self, flight_time_ms: float, result: ValidationResult
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Validate flight time."""
|
|
239
|
+
flight_time_s = flight_time_ms / 1000.0
|
|
240
|
+
bounds = DropJumpBounds.FLIGHT_TIME
|
|
241
|
+
|
|
242
|
+
if not bounds.is_physically_possible(flight_time_s):
|
|
243
|
+
result.add_error(
|
|
244
|
+
"flight_time",
|
|
245
|
+
f"Flight time {flight_time_s:.3f}s physically impossible",
|
|
246
|
+
value=flight_time_s,
|
|
247
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
248
|
+
)
|
|
249
|
+
elif result.athlete_profile and not bounds.contains(
|
|
250
|
+
flight_time_s, result.athlete_profile
|
|
251
|
+
):
|
|
252
|
+
profile_name = result.athlete_profile.value
|
|
253
|
+
result.add_warning(
|
|
254
|
+
"flight_time",
|
|
255
|
+
f"Flight time {flight_time_s:.3f}s unusual for {profile_name} athlete",
|
|
256
|
+
value=flight_time_s,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _check_jump_height(
|
|
260
|
+
self, jump_height_m: float, result: ValidationResult
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Validate jump height."""
|
|
263
|
+
bounds = DropJumpBounds.JUMP_HEIGHT
|
|
264
|
+
|
|
265
|
+
if not bounds.is_physically_possible(jump_height_m):
|
|
266
|
+
result.add_error(
|
|
267
|
+
"jump_height",
|
|
268
|
+
f"Jump height {jump_height_m:.3f}m physically impossible",
|
|
269
|
+
value=jump_height_m,
|
|
270
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
271
|
+
)
|
|
272
|
+
elif result.athlete_profile and not bounds.contains(
|
|
273
|
+
jump_height_m, result.athlete_profile
|
|
274
|
+
):
|
|
275
|
+
profile_name = result.athlete_profile.value
|
|
276
|
+
result.add_warning(
|
|
277
|
+
"jump_height",
|
|
278
|
+
f"Jump height {jump_height_m:.3f}m unusual for {profile_name} athlete",
|
|
279
|
+
value=jump_height_m,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def _check_rsi(
|
|
283
|
+
self, contact_time_ms: float, flight_time_ms: float, result: ValidationResult
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Validate RSI and cross-check consistency."""
|
|
286
|
+
contact_time_s = contact_time_ms / 1000.0
|
|
287
|
+
flight_time_s = flight_time_ms / 1000.0
|
|
288
|
+
|
|
289
|
+
if contact_time_s > 0 and flight_time_s > 0:
|
|
290
|
+
rsi = flight_time_s / contact_time_s
|
|
291
|
+
result.rsi = rsi
|
|
292
|
+
result.contact_flight_ratio = contact_time_s / flight_time_s
|
|
293
|
+
|
|
294
|
+
bounds = DropJumpBounds.RSI
|
|
295
|
+
|
|
296
|
+
if not bounds.is_physically_possible(rsi):
|
|
297
|
+
result.add_error(
|
|
298
|
+
"rsi",
|
|
299
|
+
f"RSI {rsi:.2f} physically impossible",
|
|
300
|
+
value=rsi,
|
|
301
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
302
|
+
)
|
|
303
|
+
elif result.athlete_profile and not bounds.contains(
|
|
304
|
+
rsi, result.athlete_profile
|
|
305
|
+
):
|
|
306
|
+
result.add_warning(
|
|
307
|
+
"rsi",
|
|
308
|
+
f"RSI {rsi:.2f} unusual for {result.athlete_profile.value} athlete",
|
|
309
|
+
value=rsi,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def _check_dual_height_consistency(
|
|
313
|
+
self,
|
|
314
|
+
jump_height_kinematic_m: float,
|
|
315
|
+
jump_height_trajectory_m: float,
|
|
316
|
+
result: ValidationResult,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Validate consistency between kinematic and trajectory-based heights.
|
|
319
|
+
|
|
320
|
+
Kinematic height (h = g*t²/8) comes from flight time (objective).
|
|
321
|
+
Trajectory height comes from position tracking (subject to landmark
|
|
322
|
+
detection noise).
|
|
323
|
+
|
|
324
|
+
Expected correlation: r > 0.95, absolute difference < 5% for quality video.
|
|
325
|
+
"""
|
|
326
|
+
if jump_height_kinematic_m <= 0 or jump_height_trajectory_m <= 0:
|
|
327
|
+
return # Skip if either value is missing or invalid
|
|
328
|
+
|
|
329
|
+
# Calculate percentage difference
|
|
330
|
+
avg_height = (jump_height_kinematic_m + jump_height_trajectory_m) / 2.0
|
|
331
|
+
if avg_height > 0:
|
|
332
|
+
abs_diff = abs(jump_height_kinematic_m - jump_height_trajectory_m)
|
|
333
|
+
percent_error = (abs_diff / avg_height) * 100.0
|
|
334
|
+
result.height_kinematic_trajectory_consistency = percent_error
|
|
335
|
+
|
|
336
|
+
# Allow 10% tolerance for typical video processing noise
|
|
337
|
+
if percent_error > 10.0:
|
|
338
|
+
result.add_warning(
|
|
339
|
+
"height_consistency",
|
|
340
|
+
f"Kinematic ({jump_height_kinematic_m:.3f}m) and trajectory "
|
|
341
|
+
f"({jump_height_trajectory_m:.3f}m) heights differ by "
|
|
342
|
+
f"{percent_error:.1f}%. May indicate landmark detection "
|
|
343
|
+
"issues or video quality problems.",
|
|
344
|
+
value=percent_error,
|
|
345
|
+
bounds=(0, 10),
|
|
346
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Drop jump metrics physiological bounds for validation testing.
|
|
2
|
+
|
|
3
|
+
This module defines realistic physiological bounds for Drop Jump metrics
|
|
4
|
+
based on biomechanical literature and real-world athlete performance.
|
|
5
|
+
|
|
6
|
+
Drop jump metrics differ from CMJ:
|
|
7
|
+
- Contact time (ground interaction during landing)
|
|
8
|
+
- Flight time (time in air after landing)
|
|
9
|
+
- RSI (Reactive Strength Index) = flight_time / contact_time
|
|
10
|
+
- Jump height (calculated from flight time)
|
|
11
|
+
|
|
12
|
+
References:
|
|
13
|
+
- Komi & Bosco (1978): Drop jump RSI and elastic properties
|
|
14
|
+
- Flanagan & Comyns (2008): RSI reliability and athlete assessment
|
|
15
|
+
- Covens et al. (2019): Drop jump kinetics across athletes
|
|
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., "s", "m", "ratio")
|
|
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 DropJumpBounds:
|
|
79
|
+
"""Collection of physiological bounds for all drop jump metrics."""
|
|
80
|
+
|
|
81
|
+
# GROUND CONTACT TIME (seconds, landing interaction)
|
|
82
|
+
CONTACT_TIME = MetricBounds(
|
|
83
|
+
absolute_min=0.08, # Physiological minimum: neural delay + deceleration
|
|
84
|
+
practical_min=0.15, # Extreme plyometric
|
|
85
|
+
recreational_min=0.35, # Typical landing
|
|
86
|
+
recreational_max=0.70, # Slower absorption
|
|
87
|
+
elite_min=0.20,
|
|
88
|
+
elite_max=0.50,
|
|
89
|
+
absolute_max=1.50,
|
|
90
|
+
unit="s",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# FLIGHT TIME (seconds, after landing)
|
|
94
|
+
FLIGHT_TIME = MetricBounds(
|
|
95
|
+
absolute_min=0.30,
|
|
96
|
+
practical_min=0.40, # Minimal jump
|
|
97
|
+
recreational_min=0.50,
|
|
98
|
+
recreational_max=0.85,
|
|
99
|
+
elite_min=0.65,
|
|
100
|
+
elite_max=1.10,
|
|
101
|
+
absolute_max=1.40,
|
|
102
|
+
unit="s",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# JUMP HEIGHT (meters, calculated from flight time)
|
|
106
|
+
JUMP_HEIGHT = MetricBounds(
|
|
107
|
+
absolute_min=0.05,
|
|
108
|
+
practical_min=0.10,
|
|
109
|
+
recreational_min=0.25,
|
|
110
|
+
recreational_max=0.65,
|
|
111
|
+
elite_min=0.50,
|
|
112
|
+
elite_max=1.00,
|
|
113
|
+
absolute_max=1.30,
|
|
114
|
+
unit="m",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# REACTIVE STRENGTH INDEX (RSI) = flight_time / contact_time (ratio, no unit)
|
|
118
|
+
RSI = MetricBounds(
|
|
119
|
+
absolute_min=0.30, # Very poor reactive ability
|
|
120
|
+
practical_min=0.50,
|
|
121
|
+
recreational_min=0.70,
|
|
122
|
+
recreational_max=1.80,
|
|
123
|
+
elite_min=1.50, # Elite: fast contact, long flight
|
|
124
|
+
elite_max=3.50,
|
|
125
|
+
absolute_max=5.00,
|
|
126
|
+
unit="ratio",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def estimate_athlete_profile(
|
|
131
|
+
metrics: dict, gender: str | None = None
|
|
132
|
+
) -> AthleteProfile:
|
|
133
|
+
"""Estimate athlete profile from drop jump metrics.
|
|
134
|
+
|
|
135
|
+
Uses jump_height and contact_time to classify athlete level.
|
|
136
|
+
|
|
137
|
+
NOTE: Bounds are calibrated for adult males. Female athletes typically achieve
|
|
138
|
+
60-70% of male heights due to lower muscle mass and strength. If analyzing
|
|
139
|
+
female athletes, interpret results one level lower than classification suggests.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
metrics: Dictionary with drop jump metric values
|
|
143
|
+
gender: Optional gender for context ("M"/"F"). Currently informational only.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Estimated AthleteProfile (ELDERLY, UNTRAINED, RECREATIONAL, TRAINED, or ELITE)
|
|
147
|
+
"""
|
|
148
|
+
jump_height = metrics.get("data", {}).get("jump_height_m")
|
|
149
|
+
contact_time = metrics.get("data", {}).get("ground_contact_time_ms")
|
|
150
|
+
|
|
151
|
+
if jump_height is None or contact_time is None:
|
|
152
|
+
return AthleteProfile.RECREATIONAL # Default
|
|
153
|
+
|
|
154
|
+
# Convert contact_time from ms to seconds
|
|
155
|
+
contact_time_s = contact_time / 1000.0
|
|
156
|
+
|
|
157
|
+
# Decision logic: Use weighted combination to avoid over-weighting single metrics
|
|
158
|
+
# Calculate profile scores based on each metric
|
|
159
|
+
height_score = 0.0
|
|
160
|
+
if jump_height < 0.25:
|
|
161
|
+
height_score = 0 # Elderly
|
|
162
|
+
elif jump_height < 0.35:
|
|
163
|
+
height_score = 1 # Untrained
|
|
164
|
+
elif jump_height < 0.50:
|
|
165
|
+
height_score = 2 # Recreational
|
|
166
|
+
elif jump_height < 0.70:
|
|
167
|
+
height_score = 3 # Trained
|
|
168
|
+
else:
|
|
169
|
+
height_score = 4 # Elite
|
|
170
|
+
|
|
171
|
+
contact_score = 0.0
|
|
172
|
+
if contact_time_s > 0.60:
|
|
173
|
+
contact_score = 0 # Elderly
|
|
174
|
+
elif contact_time_s > 0.50:
|
|
175
|
+
contact_score = 1 # Untrained
|
|
176
|
+
elif contact_time_s > 0.45:
|
|
177
|
+
contact_score = 2 # Recreational
|
|
178
|
+
elif contact_time_s > 0.40:
|
|
179
|
+
contact_score = 3 # Trained
|
|
180
|
+
else:
|
|
181
|
+
contact_score = 4 # Elite
|
|
182
|
+
|
|
183
|
+
# Weight height more heavily (70%) than contact time (30%)
|
|
184
|
+
# Height is more reliable indicator across populations
|
|
185
|
+
combined_score = (height_score * 0.70) + (contact_score * 0.30)
|
|
186
|
+
|
|
187
|
+
if combined_score < 1.0:
|
|
188
|
+
return AthleteProfile.ELDERLY
|
|
189
|
+
elif combined_score < 1.7:
|
|
190
|
+
return AthleteProfile.UNTRAINED
|
|
191
|
+
elif combined_score < 2.7:
|
|
192
|
+
return AthleteProfile.RECREATIONAL
|
|
193
|
+
elif combined_score < 3.7:
|
|
194
|
+
return AthleteProfile.TRAINED
|
|
195
|
+
else:
|
|
196
|
+
return AthleteProfile.ELITE
|
kinemotion/core/metadata.py
CHANGED
|
@@ -146,7 +146,8 @@ class AlgorithmConfig:
|
|
|
146
146
|
"""Complete algorithm configuration for reproducibility.
|
|
147
147
|
|
|
148
148
|
Attributes:
|
|
149
|
-
detection_method: Algorithm used ("backward_search" for CMJ,
|
|
149
|
+
detection_method: Algorithm used ("backward_search" for CMJ,
|
|
150
|
+
"forward_search" for drop)
|
|
150
151
|
tracking_method: Pose tracking method ("mediapipe_pose")
|
|
151
152
|
model_complexity: MediaPipe model complexity (0, 1, or 2)
|
|
152
153
|
smoothing: Smoothing configuration
|
kinemotion/core/quality.py
CHANGED
|
@@ -307,8 +307,9 @@ def _generate_warnings(
|
|
|
307
307
|
# Tracking stability warnings
|
|
308
308
|
if not indicators.tracking_stable:
|
|
309
309
|
warnings.append(
|
|
310
|
-
f"Unstable landmark tracking detected
|
|
311
|
-
"This may indicate
|
|
310
|
+
f"Unstable landmark tracking detected "
|
|
311
|
+
f"(variance {indicators.position_variance:.4f}). This may indicate "
|
|
312
|
+
"jitter or occlusion. Consider better lighting or camera position."
|
|
312
313
|
)
|
|
313
314
|
|
|
314
315
|
# Outlier warnings
|
|
@@ -349,8 +350,8 @@ def _generate_warnings(
|
|
|
349
350
|
# Overall confidence warning
|
|
350
351
|
if confidence == "low":
|
|
351
352
|
warnings.append(
|
|
352
|
-
"⚠️ LOW CONFIDENCE: Results may be unreliable. "
|
|
353
|
-
"
|
|
353
|
+
"⚠️ LOW CONFIDENCE: Results may be unreliable. Review quality "
|
|
354
|
+
"indicators and consider re-recording with better conditions."
|
|
354
355
|
)
|
|
355
356
|
elif confidence == "medium":
|
|
356
357
|
warnings.append(
|
kinemotion/core/smoothing.py
CHANGED
|
@@ -124,7 +124,8 @@ def _store_smoothed_landmarks(
|
|
|
124
124
|
)
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure
|
|
127
|
+
def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure
|
|
128
|
+
# capture in smoother_fn
|
|
128
129
|
landmark_sequence: LandmarkSequence,
|
|
129
130
|
window_length: int,
|
|
130
131
|
polyorder: int,
|
|
@@ -136,7 +137,8 @@ def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure captu
|
|
|
136
137
|
Args:
|
|
137
138
|
landmark_sequence: List of landmark dictionaries from each frame
|
|
138
139
|
window_length: Length of filter window (must be odd)
|
|
139
|
-
polyorder: Order of polynomial used to fit samples (captured by
|
|
140
|
+
polyorder: Order of polynomial used to fit samples (captured by
|
|
141
|
+
smoother_fn closure)
|
|
140
142
|
smoother_fn: Function that takes (x_coords, y_coords, valid_frames)
|
|
141
143
|
and returns (x_smooth, y_smooth)
|
|
142
144
|
|
kinemotion/core/video_io.py
CHANGED
|
@@ -46,8 +46,9 @@ class VideoProcessor:
|
|
|
46
46
|
self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
|
47
47
|
self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
|
48
48
|
|
|
49
|
-
# Extract rotation metadata from video (iPhones store rotation in
|
|
50
|
-
# OpenCV ignores rotation metadata, so we need to
|
|
49
|
+
# Extract rotation metadata from video (iPhones store rotation in
|
|
50
|
+
# side_data_list). OpenCV ignores rotation metadata, so we need to
|
|
51
|
+
# extract and apply it manually
|
|
51
52
|
self.rotation = 0 # Will be set by _extract_video_metadata()
|
|
52
53
|
|
|
53
54
|
# Extract codec information from video metadata
|
kinemotion/dropjump/analysis.py
CHANGED
|
@@ -98,7 +98,8 @@ def _find_stable_baseline(
|
|
|
98
98
|
"""Find first stable period and return baseline position.
|
|
99
99
|
|
|
100
100
|
Returns:
|
|
101
|
-
Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0)
|
|
101
|
+
Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0)
|
|
102
|
+
if not found.
|
|
102
103
|
"""
|
|
103
104
|
stable_window = min_stable_frames
|
|
104
105
|
|
|
@@ -159,8 +160,8 @@ def _find_drop_from_baseline(
|
|
|
159
160
|
f"{position_change_threshold:.4f}"
|
|
160
161
|
)
|
|
161
162
|
print(
|
|
162
|
-
f" avg_position: {avg_position:.4f} vs "
|
|
163
|
-
f"
|
|
163
|
+
f" avg_position: {avg_position:.4f} vs baseline: "
|
|
164
|
+
f"{baseline_position:.4f}"
|
|
164
165
|
)
|
|
165
166
|
|
|
166
167
|
return drop_frame
|
|
@@ -179,7 +180,8 @@ def detect_drop_start(
|
|
|
179
180
|
debug: bool = False,
|
|
180
181
|
) -> int:
|
|
181
182
|
"""
|
|
182
|
-
Detect when the drop jump actually starts by finding stable period then
|
|
183
|
+
Detect when the drop jump actually starts by finding stable period then
|
|
184
|
+
detecting drop.
|
|
183
185
|
|
|
184
186
|
Strategy:
|
|
185
187
|
1. Scan forward to find first STABLE period (low variance over N frames)
|
|
@@ -191,7 +193,8 @@ def detect_drop_start(
|
|
|
191
193
|
Args:
|
|
192
194
|
positions: Array of vertical positions (0-1 normalized, y increases downward)
|
|
193
195
|
fps: Video frame rate
|
|
194
|
-
min_stationary_duration: Minimum duration (seconds) of stable period
|
|
196
|
+
min_stationary_duration: Minimum duration (seconds) of stable period
|
|
197
|
+
(default: 1.0s)
|
|
195
198
|
position_change_threshold: Position change indicating start of drop
|
|
196
199
|
(default: 0.02 = 2% of frame)
|
|
197
200
|
smoothing_window: Window for computing position variance
|
|
@@ -832,9 +835,11 @@ def extract_foot_positions_and_visibilities(
|
|
|
832
835
|
smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
|
|
833
836
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
834
837
|
"""
|
|
835
|
-
Extract vertical positions and average visibilities from smoothed
|
|
838
|
+
Extract vertical positions and average visibilities from smoothed
|
|
839
|
+
landmarks.
|
|
836
840
|
|
|
837
|
-
This utility function eliminates code duplication between CLI and
|
|
841
|
+
This utility function eliminates code duplication between CLI and
|
|
842
|
+
programmatic usage.
|
|
838
843
|
|
|
839
844
|
Args:
|
|
840
845
|
smoothed_landmarks: Smoothed landmark sequence from tracking
|
kinemotion/dropjump/cli.py
CHANGED
|
@@ -133,7 +133,8 @@ class AnalysisParameters:
|
|
|
133
133
|
default=None,
|
|
134
134
|
help="[EXPERT] Override pose tracking confidence",
|
|
135
135
|
)
|
|
136
|
-
def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
|
|
136
|
+
def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
|
|
137
|
+
# parameters for each option
|
|
137
138
|
video_path: tuple[str, ...],
|
|
138
139
|
output: str | None,
|
|
139
140
|
json_output: str | None,
|
|
@@ -153,10 +154,12 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameter
|
|
|
153
154
|
tracking_confidence: float | None,
|
|
154
155
|
) -> None:
|
|
155
156
|
"""
|
|
156
|
-
Analyze drop-jump video(s) to estimate ground contact time, flight time,
|
|
157
|
+
Analyze drop-jump video(s) to estimate ground contact time, flight time,
|
|
158
|
+
and jump height.
|
|
157
159
|
|
|
158
|
-
Uses intelligent auto-tuning to select optimal parameters based on video
|
|
159
|
-
Parameters are automatically adjusted for frame rate,
|
|
160
|
+
Uses intelligent auto-tuning to select optimal parameters based on video
|
|
161
|
+
characteristics. Parameters are automatically adjusted for frame rate,
|
|
162
|
+
tracking quality, and analysis preset.
|
|
160
163
|
|
|
161
164
|
VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
|
|
162
165
|
(e.g., "videos/*.mp4").
|