kinemotion 0.71.0__py3-none-any.whl → 0.71.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/cmj/analysis.py +2 -4
- kinemotion/cmj/api.py +9 -7
- kinemotion/cmj/debug_overlay.py +154 -286
- kinemotion/cmj/joint_angles.py +96 -31
- kinemotion/cmj/metrics_validator.py +22 -29
- kinemotion/cmj/validation_bounds.py +1 -18
- kinemotion/core/__init__.py +0 -2
- kinemotion/core/auto_tuning.py +91 -99
- kinemotion/core/debug_overlay_utils.py +142 -15
- kinemotion/core/experimental.py +55 -51
- kinemotion/core/filtering.py +15 -11
- kinemotion/core/overlay_constants.py +61 -0
- kinemotion/core/pose.py +47 -98
- kinemotion/core/smoothing.py +65 -51
- kinemotion/core/types.py +15 -0
- kinemotion/core/validation.py +6 -7
- kinemotion/core/video_io.py +14 -9
- kinemotion/dropjump/__init__.py +2 -2
- kinemotion/dropjump/analysis.py +67 -44
- kinemotion/dropjump/api.py +5 -17
- kinemotion/dropjump/cli.py +62 -78
- kinemotion/dropjump/debug_overlay.py +124 -65
- kinemotion/dropjump/validation_bounds.py +1 -1
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/METADATA +1 -1
- kinemotion-0.71.1.dist-info/RECORD +50 -0
- kinemotion-0.71.0.dist-info/RECORD +0 -49
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/WHEEL +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.71.1.dist-info}/licenses/LICENSE +0 -0
kinemotion/cmj/joint_angles.py
CHANGED
|
@@ -5,6 +5,54 @@ import math
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def _get_side_prefix(side: str) -> str:
|
|
9
|
+
"""Get the landmark key prefix for a given side.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
side: Which side ("left" or "right")
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The prefix string for landmark keys
|
|
16
|
+
"""
|
|
17
|
+
return "left_" if side == "left" else "right_"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_landmark_visible(
|
|
21
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
22
|
+
key: str,
|
|
23
|
+
threshold: float = 0.3,
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Check if a landmark meets the minimum visibility threshold.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
landmarks: Pose landmarks dictionary
|
|
29
|
+
key: Landmark key to check
|
|
30
|
+
threshold: Minimum visibility threshold (default: 0.3)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
True if landmark exists and meets visibility threshold
|
|
34
|
+
"""
|
|
35
|
+
return key in landmarks and landmarks[key][2] >= threshold
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_landmark_xy(
|
|
39
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
40
|
+
key: str,
|
|
41
|
+
) -> tuple[float, float] | None:
|
|
42
|
+
"""Extract x, y coordinates from a landmark.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
landmarks: Pose landmarks dictionary
|
|
46
|
+
key: Landmark key to extract
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Tuple of (x, y) coordinates, or None if key not found
|
|
50
|
+
"""
|
|
51
|
+
if key not in landmarks:
|
|
52
|
+
return None
|
|
53
|
+
return (landmarks[key][0], landmarks[key][1])
|
|
54
|
+
|
|
55
|
+
|
|
8
56
|
def calculate_angle_3_points(
|
|
9
57
|
point1: tuple[float, float],
|
|
10
58
|
point2: tuple[float, float],
|
|
@@ -88,7 +136,7 @@ def calculate_ankle_angle(
|
|
|
88
136
|
Returns:
|
|
89
137
|
Ankle angle in degrees, or None if landmarks not available
|
|
90
138
|
"""
|
|
91
|
-
prefix =
|
|
139
|
+
prefix = _get_side_prefix(side)
|
|
92
140
|
|
|
93
141
|
foot_index_key = f"{prefix}foot_index"
|
|
94
142
|
heel_key = f"{prefix}heel"
|
|
@@ -96,23 +144,28 @@ def calculate_ankle_angle(
|
|
|
96
144
|
knee_key = f"{prefix}knee"
|
|
97
145
|
|
|
98
146
|
# Check ankle and knee visibility (required)
|
|
99
|
-
if
|
|
147
|
+
if not _is_landmark_visible(landmarks, ankle_key):
|
|
100
148
|
return None
|
|
101
|
-
if
|
|
149
|
+
if not _is_landmark_visible(landmarks, knee_key):
|
|
102
150
|
return None
|
|
103
151
|
|
|
104
|
-
ankle = (landmarks
|
|
105
|
-
knee = (landmarks
|
|
152
|
+
ankle = _get_landmark_xy(landmarks, ankle_key)
|
|
153
|
+
knee = _get_landmark_xy(landmarks, knee_key)
|
|
154
|
+
|
|
155
|
+
if ankle is None or knee is None:
|
|
156
|
+
return None
|
|
106
157
|
|
|
107
158
|
# Try foot_index first (primary: toe tip for plantarflexion accuracy)
|
|
108
|
-
if
|
|
109
|
-
foot_point = (landmarks
|
|
110
|
-
|
|
159
|
+
if _is_landmark_visible(landmarks, foot_index_key, threshold=0.5):
|
|
160
|
+
foot_point = _get_landmark_xy(landmarks, foot_index_key)
|
|
161
|
+
if foot_point is not None:
|
|
162
|
+
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
111
163
|
|
|
112
164
|
# Fallback to heel if foot_index visibility is insufficient
|
|
113
|
-
if
|
|
114
|
-
foot_point = (landmarks
|
|
115
|
-
|
|
165
|
+
if _is_landmark_visible(landmarks, heel_key):
|
|
166
|
+
foot_point = _get_landmark_xy(landmarks, heel_key)
|
|
167
|
+
if foot_point is not None:
|
|
168
|
+
return calculate_angle_3_points(foot_point, ankle, knee)
|
|
116
169
|
|
|
117
170
|
# No valid foot landmark available
|
|
118
171
|
return None
|
|
@@ -136,29 +189,32 @@ def calculate_knee_angle(
|
|
|
136
189
|
Returns:
|
|
137
190
|
Knee angle in degrees, or None if landmarks not available
|
|
138
191
|
"""
|
|
139
|
-
prefix =
|
|
192
|
+
prefix = _get_side_prefix(side)
|
|
140
193
|
|
|
141
194
|
ankle_key = f"{prefix}ankle"
|
|
142
195
|
knee_key = f"{prefix}knee"
|
|
143
196
|
hip_key = f"{prefix}hip"
|
|
144
197
|
|
|
145
198
|
# Check visibility
|
|
146
|
-
if
|
|
199
|
+
if not _is_landmark_visible(landmarks, ankle_key):
|
|
147
200
|
# Fallback: use foot_index if ankle not visible
|
|
148
201
|
foot_key = f"{prefix}foot_index"
|
|
149
|
-
if
|
|
202
|
+
if _is_landmark_visible(landmarks, foot_key):
|
|
150
203
|
ankle_key = foot_key
|
|
151
204
|
else:
|
|
152
205
|
return None
|
|
153
206
|
|
|
154
|
-
if
|
|
207
|
+
if not _is_landmark_visible(landmarks, knee_key):
|
|
155
208
|
return None
|
|
156
|
-
if
|
|
209
|
+
if not _is_landmark_visible(landmarks, hip_key):
|
|
157
210
|
return None
|
|
158
211
|
|
|
159
|
-
ankle = (landmarks
|
|
160
|
-
knee = (landmarks
|
|
161
|
-
hip = (landmarks
|
|
212
|
+
ankle = _get_landmark_xy(landmarks, ankle_key)
|
|
213
|
+
knee = _get_landmark_xy(landmarks, knee_key)
|
|
214
|
+
hip = _get_landmark_xy(landmarks, hip_key)
|
|
215
|
+
|
|
216
|
+
if ankle is None or knee is None or hip is None:
|
|
217
|
+
return None
|
|
162
218
|
|
|
163
219
|
return calculate_angle_3_points(ankle, knee, hip)
|
|
164
220
|
|
|
@@ -181,23 +237,26 @@ def calculate_hip_angle(
|
|
|
181
237
|
Returns:
|
|
182
238
|
Hip angle in degrees, or None if landmarks not available
|
|
183
239
|
"""
|
|
184
|
-
prefix =
|
|
240
|
+
prefix = _get_side_prefix(side)
|
|
185
241
|
|
|
186
242
|
knee_key = f"{prefix}knee"
|
|
187
243
|
hip_key = f"{prefix}hip"
|
|
188
244
|
shoulder_key = f"{prefix}shoulder"
|
|
189
245
|
|
|
190
246
|
# Check visibility
|
|
191
|
-
if
|
|
247
|
+
if not _is_landmark_visible(landmarks, knee_key):
|
|
192
248
|
return None
|
|
193
|
-
if
|
|
249
|
+
if not _is_landmark_visible(landmarks, hip_key):
|
|
194
250
|
return None
|
|
195
|
-
if
|
|
251
|
+
if not _is_landmark_visible(landmarks, shoulder_key):
|
|
196
252
|
return None
|
|
197
253
|
|
|
198
|
-
knee = (landmarks
|
|
199
|
-
hip = (landmarks
|
|
200
|
-
shoulder = (landmarks
|
|
254
|
+
knee = _get_landmark_xy(landmarks, knee_key)
|
|
255
|
+
hip = _get_landmark_xy(landmarks, hip_key)
|
|
256
|
+
shoulder = _get_landmark_xy(landmarks, shoulder_key)
|
|
257
|
+
|
|
258
|
+
if knee is None or hip is None or shoulder is None:
|
|
259
|
+
return None
|
|
201
260
|
|
|
202
261
|
return calculate_angle_3_points(knee, hip, shoulder)
|
|
203
262
|
|
|
@@ -220,19 +279,25 @@ def calculate_trunk_tilt(
|
|
|
220
279
|
Returns:
|
|
221
280
|
Trunk tilt angle in degrees, or None if landmarks not available
|
|
222
281
|
"""
|
|
223
|
-
prefix =
|
|
282
|
+
prefix = _get_side_prefix(side)
|
|
224
283
|
|
|
225
284
|
hip_key = f"{prefix}hip"
|
|
226
285
|
shoulder_key = f"{prefix}shoulder"
|
|
227
286
|
|
|
228
287
|
# Check visibility
|
|
229
|
-
if
|
|
288
|
+
if not _is_landmark_visible(landmarks, hip_key):
|
|
289
|
+
return None
|
|
290
|
+
if not _is_landmark_visible(landmarks, shoulder_key):
|
|
230
291
|
return None
|
|
231
|
-
|
|
292
|
+
|
|
293
|
+
hip_xy = _get_landmark_xy(landmarks, hip_key)
|
|
294
|
+
shoulder_xy = _get_landmark_xy(landmarks, shoulder_key)
|
|
295
|
+
|
|
296
|
+
if hip_xy is None or shoulder_xy is None:
|
|
232
297
|
return None
|
|
233
298
|
|
|
234
|
-
hip = np.array([
|
|
235
|
-
shoulder = np.array([
|
|
299
|
+
hip = np.array([hip_xy[0], hip_xy[1]])
|
|
300
|
+
shoulder = np.array([shoulder_xy[0], shoulder_xy[1]])
|
|
236
301
|
|
|
237
302
|
# Vector from hip to shoulder
|
|
238
303
|
trunk_vector = shoulder - hip
|
|
@@ -81,6 +81,23 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
81
81
|
"""
|
|
82
82
|
return data.get(key_with_suffix) or data.get(key_without_suffix)
|
|
83
83
|
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _convert_raw_duration_to_seconds(value_raw: float) -> float:
|
|
86
|
+
"""Convert raw duration value to seconds.
|
|
87
|
+
|
|
88
|
+
Handles legacy values that may be in seconds (<10) vs milliseconds (>10).
|
|
89
|
+
This heuristic works because no CMJ duration metric is between 10ms and 10s.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
value_raw: Raw duration value (may be seconds or milliseconds)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Duration in seconds
|
|
96
|
+
"""
|
|
97
|
+
if value_raw < 10: # Likely in seconds
|
|
98
|
+
return value_raw
|
|
99
|
+
return value_raw / 1000.0
|
|
100
|
+
|
|
84
101
|
def validate(self, metrics: MetricsDict) -> CMJValidationResult:
|
|
85
102
|
"""Validate CMJ metrics comprehensively.
|
|
86
103
|
|
|
@@ -136,12 +153,7 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
136
153
|
if flight_time_raw is None:
|
|
137
154
|
return
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
if flight_time_raw < 10: # Likely in seconds
|
|
141
|
-
flight_time = flight_time_raw
|
|
142
|
-
else: # In milliseconds
|
|
143
|
-
flight_time = flight_time_raw / 1000.0
|
|
144
|
-
|
|
156
|
+
flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
|
|
145
157
|
bounds = CMJBounds.FLIGHT_TIME
|
|
146
158
|
|
|
147
159
|
if not bounds.is_physically_possible(flight_time):
|
|
@@ -268,13 +280,7 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
268
280
|
if duration_raw is None:
|
|
269
281
|
return
|
|
270
282
|
|
|
271
|
-
|
|
272
|
-
# Values >10 are assumed to be in ms, <10 assumed to be in seconds
|
|
273
|
-
if duration_raw < 10: # Likely in seconds
|
|
274
|
-
duration = duration_raw
|
|
275
|
-
else: # In milliseconds
|
|
276
|
-
duration = duration_raw / 1000.0
|
|
277
|
-
|
|
283
|
+
duration = self._convert_raw_duration_to_seconds(duration_raw)
|
|
278
284
|
bounds = CMJBounds.CONCENTRIC_DURATION
|
|
279
285
|
|
|
280
286
|
if not bounds.is_physically_possible(duration):
|
|
@@ -311,12 +317,7 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
311
317
|
if duration_raw is None:
|
|
312
318
|
return
|
|
313
319
|
|
|
314
|
-
|
|
315
|
-
if duration_raw < 10: # Likely in seconds
|
|
316
|
-
duration = duration_raw
|
|
317
|
-
else: # In milliseconds
|
|
318
|
-
duration = duration_raw / 1000.0
|
|
319
|
-
|
|
320
|
+
duration = self._convert_raw_duration_to_seconds(duration_raw)
|
|
320
321
|
bounds = CMJBounds.ECCENTRIC_DURATION
|
|
321
322
|
|
|
322
323
|
if not bounds.is_physically_possible(duration):
|
|
@@ -493,16 +494,8 @@ class CMJMetricsValidator(MetricsValidator):
|
|
|
493
494
|
):
|
|
494
495
|
return
|
|
495
496
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
flight_time = flight_time_raw
|
|
499
|
-
else: # In milliseconds
|
|
500
|
-
flight_time = flight_time_raw / 1000.0
|
|
501
|
-
|
|
502
|
-
if concentric_duration_raw < 10: # Likely in seconds
|
|
503
|
-
concentric_duration = concentric_duration_raw
|
|
504
|
-
else: # In milliseconds
|
|
505
|
-
concentric_duration = concentric_duration_raw / 1000.0
|
|
497
|
+
flight_time = self._convert_raw_duration_to_seconds(flight_time_raw)
|
|
498
|
+
concentric_duration = self._convert_raw_duration_to_seconds(concentric_duration_raw)
|
|
506
499
|
|
|
507
500
|
rsi = flight_time / concentric_duration
|
|
508
501
|
result.rsi = rsi
|
|
@@ -170,17 +170,6 @@ class TripleExtensionBounds:
|
|
|
170
170
|
return 125 <= angle <= 155
|
|
171
171
|
return True
|
|
172
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
173
|
|
|
185
174
|
class RSIBounds:
|
|
186
175
|
"""Reactive Strength Index bounds."""
|
|
@@ -215,12 +204,6 @@ class RSIBounds:
|
|
|
215
204
|
"""Check if RSI is within physiological bounds."""
|
|
216
205
|
return RSIBounds.MIN_VALID <= rsi <= RSIBounds.MAX_VALID
|
|
217
206
|
|
|
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
207
|
|
|
225
208
|
class MetricConsistency:
|
|
226
209
|
"""Cross-validation tolerance for metric consistency checks."""
|
|
@@ -301,7 +284,7 @@ ATHLETE_PROFILES = {
|
|
|
301
284
|
|
|
302
285
|
|
|
303
286
|
def estimate_athlete_profile(
|
|
304
|
-
metrics_dict: MetricsDict,
|
|
287
|
+
metrics_dict: MetricsDict, _gender: str | None = None
|
|
305
288
|
) -> AthleteProfile:
|
|
306
289
|
"""Estimate athlete profile from metrics.
|
|
307
290
|
|
kinemotion/core/__init__.py
CHANGED
|
@@ -13,7 +13,6 @@ from .pose import (
|
|
|
13
13
|
MediaPipePoseTracker,
|
|
14
14
|
PoseTrackerFactory,
|
|
15
15
|
compute_center_of_mass,
|
|
16
|
-
get_tracker_info,
|
|
17
16
|
)
|
|
18
17
|
from .pose_landmarks import KINEMOTION_LANDMARKS, LANDMARK_INDICES
|
|
19
18
|
from .quality import (
|
|
@@ -42,7 +41,6 @@ __all__ = [
|
|
|
42
41
|
"MediaPipePoseTracker",
|
|
43
42
|
"PoseTrackerFactory",
|
|
44
43
|
"compute_center_of_mass",
|
|
45
|
-
"get_tracker_info",
|
|
46
44
|
"LANDMARK_INDICES",
|
|
47
45
|
"KINEMOTION_LANDMARKS",
|
|
48
46
|
"get_model_path",
|
kinemotion/core/auto_tuning.py
CHANGED
|
@@ -5,6 +5,28 @@ from enum import Enum
|
|
|
5
5
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
|
|
8
|
+
from .types import FOOT_KEYS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _PresetConfig:
|
|
13
|
+
"""Configuration modifiers for quality presets."""
|
|
14
|
+
|
|
15
|
+
velocity_multiplier: float # Multiplier for velocity threshold
|
|
16
|
+
contact_frames_multiplier: float # Multiplier for min contact frames
|
|
17
|
+
smoothing_offset: int # Offset to smoothing window (added to base)
|
|
18
|
+
force_bilateral: bool | None # None means use quality-based, True=force on, False=force off
|
|
19
|
+
detection_confidence: float
|
|
20
|
+
tracking_confidence: float
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class _QualityAdjustment:
|
|
25
|
+
"""Smoothing adjustments based on tracking quality."""
|
|
26
|
+
|
|
27
|
+
smoothing_add: int # Frames to add to smoothing window
|
|
28
|
+
enable_bilateral: bool # Whether to enable bilateral filtering
|
|
29
|
+
|
|
8
30
|
|
|
9
31
|
class QualityPreset(str, Enum):
|
|
10
32
|
"""Quality presets for analysis."""
|
|
@@ -14,6 +36,46 @@ class QualityPreset(str, Enum):
|
|
|
14
36
|
ACCURATE = "accurate" # Research-grade analysis, slower
|
|
15
37
|
|
|
16
38
|
|
|
39
|
+
# Quality preset configurations
|
|
40
|
+
# FAST: Speed over accuracy
|
|
41
|
+
# BALANCED: Default (uses quality-based settings)
|
|
42
|
+
# ACCURATE: Maximum accuracy
|
|
43
|
+
_PRESET_CONFIGS: dict[QualityPreset, _PresetConfig] = {
|
|
44
|
+
QualityPreset.FAST: _PresetConfig(
|
|
45
|
+
velocity_multiplier=1.5,
|
|
46
|
+
contact_frames_multiplier=0.67,
|
|
47
|
+
smoothing_offset=-2,
|
|
48
|
+
force_bilateral=False,
|
|
49
|
+
detection_confidence=0.3,
|
|
50
|
+
tracking_confidence=0.3,
|
|
51
|
+
),
|
|
52
|
+
QualityPreset.BALANCED: _PresetConfig(
|
|
53
|
+
velocity_multiplier=1.0,
|
|
54
|
+
contact_frames_multiplier=1.0,
|
|
55
|
+
smoothing_offset=0,
|
|
56
|
+
force_bilateral=None,
|
|
57
|
+
detection_confidence=0.5,
|
|
58
|
+
tracking_confidence=0.5,
|
|
59
|
+
),
|
|
60
|
+
QualityPreset.ACCURATE: _PresetConfig(
|
|
61
|
+
velocity_multiplier=0.5,
|
|
62
|
+
contact_frames_multiplier=1.0,
|
|
63
|
+
smoothing_offset=2,
|
|
64
|
+
force_bilateral=True,
|
|
65
|
+
detection_confidence=0.6,
|
|
66
|
+
tracking_confidence=0.6,
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Quality-based adjustments
|
|
72
|
+
_QUALITY_ADJUSTMENTS: dict[str, _QualityAdjustment] = {
|
|
73
|
+
"low": _QualityAdjustment(smoothing_add=2, enable_bilateral=True),
|
|
74
|
+
"medium": _QualityAdjustment(smoothing_add=1, enable_bilateral=True),
|
|
75
|
+
"high": _QualityAdjustment(smoothing_add=0, enable_bilateral=False),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
17
79
|
@dataclass
|
|
18
80
|
class VideoCharacteristics:
|
|
19
81
|
"""Characteristics extracted from video analysis."""
|
|
@@ -101,106 +163,45 @@ def auto_tune_parameters(
|
|
|
101
163
|
fps = characteristics.fps
|
|
102
164
|
quality = characteristics.tracking_quality
|
|
103
165
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
# These scale automatically with frame rate to maintain consistent
|
|
107
|
-
# temporal resolution and sensitivity
|
|
108
|
-
# =================================================================
|
|
109
|
-
|
|
110
|
-
# Velocity threshold: Scale inversely with fps
|
|
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)
|
|
166
|
+
# Get preset configuration
|
|
167
|
+
preset = _PRESET_CONFIGS[quality_preset]
|
|
117
168
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
169
|
+
# Get quality-based adjustments
|
|
170
|
+
quality_adj = _QUALITY_ADJUSTMENTS[quality]
|
|
171
|
+
|
|
172
|
+
# Compute FPS-based baseline parameters
|
|
173
|
+
base_velocity_threshold = 0.004 * (30.0 / fps)
|
|
121
174
|
base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
|
|
122
175
|
|
|
123
176
|
# Smoothing window: Decrease with higher fps for better temporal resolution
|
|
124
|
-
# Lower fps (30fps): 5-frame window = 167ms
|
|
125
|
-
# Higher fps (60fps): 3-frame window = 50ms (same temporal resolution)
|
|
126
177
|
if fps <= 30:
|
|
127
178
|
base_smoothing_window = 5
|
|
128
|
-
elif fps <= 60:
|
|
129
|
-
base_smoothing_window = 3
|
|
130
179
|
else:
|
|
131
|
-
base_smoothing_window = 3 #
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if quality == "low":
|
|
142
|
-
# Poor tracking quality: aggressive smoothing and filtering
|
|
143
|
-
smoothing_adjustment = +2
|
|
144
|
-
enable_bilateral = True
|
|
145
|
-
elif quality == "medium":
|
|
146
|
-
# Moderate quality: slight smoothing increase
|
|
147
|
-
smoothing_adjustment = +1
|
|
148
|
-
enable_bilateral = True
|
|
149
|
-
else: # high quality
|
|
150
|
-
# Good tracking: preserve detail, minimal smoothing
|
|
151
|
-
smoothing_adjustment = 0
|
|
152
|
-
enable_bilateral = False
|
|
153
|
-
|
|
154
|
-
# =================================================================
|
|
155
|
-
# STEP 3: Apply quality preset modifiers
|
|
156
|
-
# User can choose speed vs accuracy tradeoff
|
|
157
|
-
# =================================================================
|
|
158
|
-
|
|
159
|
-
if quality_preset == QualityPreset.FAST:
|
|
160
|
-
# Fast: Trade accuracy for speed
|
|
161
|
-
velocity_threshold = base_velocity_threshold * 1.5 # Less sensitive
|
|
162
|
-
min_contact_frames = max(2, int(base_min_contact_frames * 0.67))
|
|
163
|
-
smoothing_window = max(3, base_smoothing_window - 2 + smoothing_adjustment)
|
|
164
|
-
bilateral_filter = False # Skip expensive filtering
|
|
165
|
-
detection_confidence = 0.3
|
|
166
|
-
tracking_confidence = 0.3
|
|
167
|
-
|
|
168
|
-
elif quality_preset == QualityPreset.ACCURATE:
|
|
169
|
-
# Accurate: Maximize accuracy, accept slower processing
|
|
170
|
-
velocity_threshold = base_velocity_threshold * 0.5 # More sensitive
|
|
171
|
-
min_contact_frames = base_min_contact_frames # Don't increase (would miss brief)
|
|
172
|
-
smoothing_window = min(11, base_smoothing_window + 2 + smoothing_adjustment)
|
|
173
|
-
bilateral_filter = True # Always use for best accuracy
|
|
174
|
-
detection_confidence = 0.6
|
|
175
|
-
tracking_confidence = 0.6
|
|
176
|
-
|
|
177
|
-
else: # QualityPreset.BALANCED (default)
|
|
178
|
-
# Balanced: Good accuracy, reasonable speed
|
|
179
|
-
velocity_threshold = base_velocity_threshold
|
|
180
|
-
min_contact_frames = base_min_contact_frames
|
|
181
|
-
smoothing_window = max(3, base_smoothing_window + smoothing_adjustment)
|
|
182
|
-
bilateral_filter = enable_bilateral
|
|
183
|
-
detection_confidence = 0.5
|
|
184
|
-
tracking_confidence = 0.5
|
|
180
|
+
base_smoothing_window = 3 # 60fps+ use 3-frame window
|
|
181
|
+
|
|
182
|
+
# Apply preset modifiers and quality adjustments
|
|
183
|
+
velocity_threshold = base_velocity_threshold * preset.velocity_multiplier
|
|
184
|
+
min_contact_frames = max(2, int(base_min_contact_frames * preset.contact_frames_multiplier))
|
|
185
|
+
|
|
186
|
+
# Smoothing window = base + preset offset + quality adjustment
|
|
187
|
+
smoothing_window = base_smoothing_window + preset.smoothing_offset + quality_adj.smoothing_add
|
|
188
|
+
smoothing_window = max(3, min(11, smoothing_window))
|
|
185
189
|
|
|
186
190
|
# Ensure smoothing window is odd (required for Savitzky-Golay)
|
|
187
191
|
if smoothing_window % 2 == 0:
|
|
188
192
|
smoothing_window += 1
|
|
189
193
|
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# Polyorder: Always 2 (quadratic) - optimal for jump physics (parabolic motion)
|
|
196
|
-
polyorder = 2
|
|
197
|
-
|
|
198
|
-
# Visibility threshold: Standard MediaPipe threshold
|
|
199
|
-
visibility_threshold = 0.5
|
|
194
|
+
# Bilateral filtering: preset can override, otherwise use quality-based
|
|
195
|
+
if preset.force_bilateral is not None:
|
|
196
|
+
bilateral_filter = preset.force_bilateral
|
|
197
|
+
else:
|
|
198
|
+
bilateral_filter = quality_adj.enable_bilateral
|
|
200
199
|
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
# Fixed optimal values
|
|
201
|
+
polyorder = 2 # Quadratic - optimal for parabolic motion
|
|
202
|
+
visibility_threshold = 0.5 # Standard MediaPipe threshold
|
|
203
|
+
outlier_rejection = True # Removes tracking glitches
|
|
204
|
+
use_curvature = True # Trajectory curvature analysis
|
|
204
205
|
|
|
205
206
|
return AnalysisParameters(
|
|
206
207
|
smoothing_window=smoothing_window,
|
|
@@ -208,8 +209,8 @@ def auto_tune_parameters(
|
|
|
208
209
|
velocity_threshold=velocity_threshold,
|
|
209
210
|
min_contact_frames=min_contact_frames,
|
|
210
211
|
visibility_threshold=visibility_threshold,
|
|
211
|
-
detection_confidence=detection_confidence,
|
|
212
|
-
tracking_confidence=tracking_confidence,
|
|
212
|
+
detection_confidence=preset.detection_confidence,
|
|
213
|
+
tracking_confidence=preset.tracking_confidence,
|
|
213
214
|
outlier_rejection=outlier_rejection,
|
|
214
215
|
bilateral_filter=bilateral_filter,
|
|
215
216
|
use_curvature=use_curvature,
|
|
@@ -228,19 +229,10 @@ def _collect_foot_visibility_and_positions(
|
|
|
228
229
|
Returns:
|
|
229
230
|
Tuple of (visibility_scores, y_positions)
|
|
230
231
|
"""
|
|
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
232
|
frame_vis = []
|
|
241
233
|
frame_y_positions = []
|
|
242
234
|
|
|
243
|
-
for key in
|
|
235
|
+
for key in FOOT_KEYS:
|
|
244
236
|
if key in frame_landmarks:
|
|
245
237
|
_, y, vis = frame_landmarks[key] # x not needed for analysis
|
|
246
238
|
frame_vis.append(vis)
|