kinemotion 0.71.0__py3-none-any.whl → 0.72.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.

Files changed (36) hide show
  1. kinemotion/__init__.py +1 -1
  2. kinemotion/api.py +2 -2
  3. kinemotion/cli.py +1 -1
  4. kinemotion/cmj/analysis.py +2 -4
  5. kinemotion/cmj/api.py +9 -7
  6. kinemotion/cmj/debug_overlay.py +154 -286
  7. kinemotion/cmj/joint_angles.py +96 -31
  8. kinemotion/cmj/metrics_validator.py +22 -29
  9. kinemotion/cmj/validation_bounds.py +1 -18
  10. kinemotion/core/__init__.py +0 -2
  11. kinemotion/core/auto_tuning.py +95 -100
  12. kinemotion/core/debug_overlay_utils.py +142 -15
  13. kinemotion/core/experimental.py +55 -51
  14. kinemotion/core/filtering.py +15 -11
  15. kinemotion/core/overlay_constants.py +61 -0
  16. kinemotion/core/pipeline_utils.py +1 -1
  17. kinemotion/core/pose.py +47 -98
  18. kinemotion/core/smoothing.py +65 -51
  19. kinemotion/core/types.py +15 -0
  20. kinemotion/core/validation.py +6 -7
  21. kinemotion/core/video_io.py +14 -9
  22. kinemotion/{dropjump → dj}/__init__.py +2 -2
  23. kinemotion/{dropjump → dj}/analysis.py +192 -75
  24. kinemotion/{dropjump → dj}/api.py +13 -17
  25. kinemotion/{dropjump → dj}/cli.py +62 -78
  26. kinemotion/dj/debug_overlay.py +241 -0
  27. kinemotion/{dropjump → dj}/kinematics.py +106 -44
  28. kinemotion/{dropjump → dj}/metrics_validator.py +1 -1
  29. kinemotion/{dropjump → dj}/validation_bounds.py +1 -1
  30. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/METADATA +1 -1
  31. kinemotion-0.72.0.dist-info/RECORD +50 -0
  32. kinemotion/dropjump/debug_overlay.py +0 -182
  33. kinemotion-0.71.0.dist-info/RECORD +0 -49
  34. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/WHEEL +0 -0
  35. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/entry_points.txt +0 -0
  36. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/licenses/LICENSE +0 -0
@@ -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 = "left_" if side == "left" else "right_"
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 ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
147
+ if not _is_landmark_visible(landmarks, ankle_key):
100
148
  return None
101
- if knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
149
+ if not _is_landmark_visible(landmarks, knee_key):
102
150
  return None
103
151
 
104
- ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
105
- knee = (landmarks[knee_key][0], landmarks[knee_key][1])
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 foot_index_key in landmarks and landmarks[foot_index_key][2] > 0.5:
109
- foot_point = (landmarks[foot_index_key][0], landmarks[foot_index_key][1])
110
- return calculate_angle_3_points(foot_point, ankle, knee)
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 heel_key in landmarks and landmarks[heel_key][2] > 0.3:
114
- foot_point = (landmarks[heel_key][0], landmarks[heel_key][1])
115
- return calculate_angle_3_points(foot_point, ankle, knee)
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 = "left_" if side == "left" else "right_"
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 ankle_key not in landmarks or landmarks[ankle_key][2] < 0.3:
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 foot_key in landmarks and landmarks[foot_key][2] > 0.3:
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 knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
207
+ if not _is_landmark_visible(landmarks, knee_key):
155
208
  return None
156
- if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
209
+ if not _is_landmark_visible(landmarks, hip_key):
157
210
  return None
158
211
 
159
- ankle = (landmarks[ankle_key][0], landmarks[ankle_key][1])
160
- knee = (landmarks[knee_key][0], landmarks[knee_key][1])
161
- hip = (landmarks[hip_key][0], landmarks[hip_key][1])
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 = "left_" if side == "left" else "right_"
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 knee_key not in landmarks or landmarks[knee_key][2] < 0.3:
247
+ if not _is_landmark_visible(landmarks, knee_key):
192
248
  return None
193
- if hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
249
+ if not _is_landmark_visible(landmarks, hip_key):
194
250
  return None
195
- if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
251
+ if not _is_landmark_visible(landmarks, shoulder_key):
196
252
  return None
197
253
 
198
- knee = (landmarks[knee_key][0], landmarks[knee_key][1])
199
- hip = (landmarks[hip_key][0], landmarks[hip_key][1])
200
- shoulder = (landmarks[shoulder_key][0], landmarks[shoulder_key][1])
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 = "left_" if side == "left" else "right_"
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 hip_key not in landmarks or landmarks[hip_key][2] < 0.3:
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
- if shoulder_key not in landmarks or landmarks[shoulder_key][2] < 0.3:
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([landmarks[hip_key][0], landmarks[hip_key][1]])
235
- shoulder = np.array([landmarks[shoulder_key][0], landmarks[shoulder_key][1]])
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
- # If value is in seconds (legacy), use as-is; if in ms, convert
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
- # If value is in seconds (legacy), convert to ms first
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
- # If value is in seconds (legacy), use as-is; if in ms, convert
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
- # Convert to seconds if needed
497
- if flight_time_raw < 10: # Likely in seconds
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, gender: str | None = None
287
+ metrics_dict: MetricsDict, _gender: str | None = None
305
288
  ) -> AthleteProfile:
306
289
  """Estimate athlete profile from metrics.
307
290
 
@@ -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",
@@ -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,48 @@ def auto_tune_parameters(
101
163
  fps = characteristics.fps
102
164
  quality = characteristics.tracking_quality
103
165
 
104
- # =================================================================
105
- # STEP 1: FPS-based baseline parameters
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)
117
-
118
- # Min contact frames: Scale with fps to maintain same time duration
119
- # Goal: ~100ms minimum contact (3 frames @ 30fps, 6 frames @ 60fps)
120
- # Formula: frames = round(3 * (fps / 30))
166
+ # Get preset configuration
167
+ preset = _PRESET_CONFIGS[quality_preset]
168
+
169
+ # Get quality-based adjustments
170
+ quality_adj = _QUALITY_ADJUSTMENTS[quality]
171
+
172
+ # Compute FPS-based baseline parameters
173
+ # Base velocity threshold: 0.012 at 30fps, scaled inversely by fps
174
+ # Must exceed typical MediaPipe landmark jitter (0.5-2% per frame)
175
+ # Previous value of 0.004 was below noise floor, causing false IN_AIR detections
176
+ base_velocity_threshold = 0.012 * (30.0 / fps)
121
177
  base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
122
178
 
123
179
  # 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
180
  if fps <= 30:
127
181
  base_smoothing_window = 5
128
- elif fps <= 60:
129
- base_smoothing_window = 3
130
182
  else:
131
- base_smoothing_window = 3 # Even at 120fps, 3 is minimum for Savitzky-Golay
132
-
133
- # =================================================================
134
- # STEP 2: Quality-based adjustments
135
- # Adapt smoothing and filtering based on tracking quality
136
- # =================================================================
137
-
138
- smoothing_adjustment = 0
139
- enable_bilateral = False
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
183
+ base_smoothing_window = 3 # 60fps+ use 3-frame window
184
+
185
+ # Apply preset modifiers and quality adjustments
186
+ velocity_threshold = base_velocity_threshold * preset.velocity_multiplier
187
+ min_contact_frames = max(2, int(base_min_contact_frames * preset.contact_frames_multiplier))
188
+
189
+ # Smoothing window = base + preset offset + quality adjustment
190
+ smoothing_window = base_smoothing_window + preset.smoothing_offset + quality_adj.smoothing_add
191
+ smoothing_window = max(3, min(11, smoothing_window))
185
192
 
186
193
  # Ensure smoothing window is odd (required for Savitzky-Golay)
187
194
  if smoothing_window % 2 == 0:
188
195
  smoothing_window += 1
189
196
 
190
- # =================================================================
191
- # STEP 4: Set fixed optimal values
192
- # These are always the same regardless of video characteristics
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
197
+ # Bilateral filtering: preset can override, otherwise use quality-based
198
+ if preset.force_bilateral is not None:
199
+ bilateral_filter = preset.force_bilateral
200
+ else:
201
+ bilateral_filter = quality_adj.enable_bilateral
200
202
 
201
- # Always enable proven accuracy features
202
- outlier_rejection = True # Removes tracking glitches (minimal cost)
203
- use_curvature = True # Trajectory curvature analysis (minimal cost)
203
+ # Fixed optimal values
204
+ polyorder = 2 # Quadratic - optimal for parabolic motion
205
+ visibility_threshold = 0.5 # Standard MediaPipe threshold
206
+ outlier_rejection = True # Removes tracking glitches
207
+ use_curvature = True # Trajectory curvature analysis
204
208
 
205
209
  return AnalysisParameters(
206
210
  smoothing_window=smoothing_window,
@@ -208,8 +212,8 @@ def auto_tune_parameters(
208
212
  velocity_threshold=velocity_threshold,
209
213
  min_contact_frames=min_contact_frames,
210
214
  visibility_threshold=visibility_threshold,
211
- detection_confidence=detection_confidence,
212
- tracking_confidence=tracking_confidence,
215
+ detection_confidence=preset.detection_confidence,
216
+ tracking_confidence=preset.tracking_confidence,
213
217
  outlier_rejection=outlier_rejection,
214
218
  bilateral_filter=bilateral_filter,
215
219
  use_curvature=use_curvature,
@@ -228,19 +232,10 @@ def _collect_foot_visibility_and_positions(
228
232
  Returns:
229
233
  Tuple of (visibility_scores, y_positions)
230
234
  """
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
235
  frame_vis = []
241
236
  frame_y_positions = []
242
237
 
243
- for key in foot_keys:
238
+ for key in FOOT_KEYS:
244
239
  if key in frame_landmarks:
245
240
  _, y, vis = frame_landmarks[key] # x not needed for analysis
246
241
  frame_vis.append(vis)