kinemotion 0.12.1__tar.gz → 0.12.2__tar.gz

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 (72) hide show
  1. {kinemotion-0.12.1 → kinemotion-0.12.2}/CHANGELOG.md +22 -0
  2. {kinemotion-0.12.1 → kinemotion-0.12.2}/PKG-INFO +1 -1
  3. {kinemotion-0.12.1 → kinemotion-0.12.2}/pyproject.toml +1 -1
  4. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/api.py +0 -2
  5. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/pose.py +134 -95
  6. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/smoothing.py +2 -2
  7. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/dropjump/analysis.py +169 -123
  8. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/dropjump/cli.py +0 -2
  9. kinemotion-0.12.2/src/kinemotion/dropjump/debug_overlay.py +179 -0
  10. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/dropjump/kinematics.py +3 -6
  11. {kinemotion-0.12.1 → kinemotion-0.12.2}/uv.lock +1 -1
  12. kinemotion-0.12.1/src/kinemotion/dropjump/debug_overlay.py +0 -167
  13. {kinemotion-0.12.1 → kinemotion-0.12.2}/.dockerignore +0 -0
  14. {kinemotion-0.12.1 → kinemotion-0.12.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {kinemotion-0.12.1 → kinemotion-0.12.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {kinemotion-0.12.1 → kinemotion-0.12.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {kinemotion-0.12.1 → kinemotion-0.12.2}/.github/pull_request_template.md +0 -0
  18. {kinemotion-0.12.1 → kinemotion-0.12.2}/.github/workflows/release.yml +0 -0
  19. {kinemotion-0.12.1 → kinemotion-0.12.2}/.gitignore +0 -0
  20. {kinemotion-0.12.1 → kinemotion-0.12.2}/.pre-commit-config.yaml +0 -0
  21. {kinemotion-0.12.1 → kinemotion-0.12.2}/.tool-versions +0 -0
  22. {kinemotion-0.12.1 → kinemotion-0.12.2}/CLAUDE.md +0 -0
  23. {kinemotion-0.12.1 → kinemotion-0.12.2}/CODE_OF_CONDUCT.md +0 -0
  24. {kinemotion-0.12.1 → kinemotion-0.12.2}/CONTRIBUTING.md +0 -0
  25. {kinemotion-0.12.1 → kinemotion-0.12.2}/Dockerfile +0 -0
  26. {kinemotion-0.12.1 → kinemotion-0.12.2}/GEMINI.md +0 -0
  27. {kinemotion-0.12.1 → kinemotion-0.12.2}/LICENSE +0 -0
  28. {kinemotion-0.12.1 → kinemotion-0.12.2}/README.md +0 -0
  29. {kinemotion-0.12.1 → kinemotion-0.12.2}/SECURITY.md +0 -0
  30. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/BULK_PROCESSING.md +0 -0
  31. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/CAMERA_SETUP.md +0 -0
  32. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/CAMERA_SETUP_ES.md +0 -0
  33. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/CMJ_GUIDE.md +0 -0
  34. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/ERRORS_FINDINGS.md +0 -0
  35. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/FRAMERATE.md +0 -0
  36. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/IMU_METADATA_PRESERVATION.md +0 -0
  37. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/PARAMETERS.md +0 -0
  38. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/REAL_TIME_ANALYSIS.md +0 -0
  39. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/TRIPLE_EXTENSION.md +0 -0
  40. {kinemotion-0.12.1 → kinemotion-0.12.2}/docs/VALIDATION_PLAN.md +0 -0
  41. {kinemotion-0.12.1 → kinemotion-0.12.2}/examples/bulk/README.md +0 -0
  42. {kinemotion-0.12.1 → kinemotion-0.12.2}/examples/bulk/bulk_processing.py +0 -0
  43. {kinemotion-0.12.1 → kinemotion-0.12.2}/examples/bulk/simple_example.py +0 -0
  44. {kinemotion-0.12.1 → kinemotion-0.12.2}/examples/programmatic_usage.py +0 -0
  45. {kinemotion-0.12.1 → kinemotion-0.12.2}/samples/cmjs/README.md +0 -0
  46. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/__init__.py +0 -0
  47. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cli.py +0 -0
  48. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/__init__.py +0 -0
  49. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/analysis.py +0 -0
  50. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/cli.py +0 -0
  51. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/debug_overlay.py +0 -0
  52. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/joint_angles.py +0 -0
  53. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/cmj/kinematics.py +0 -0
  54. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/__init__.py +0 -0
  55. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/auto_tuning.py +0 -0
  56. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/cli_utils.py +0 -0
  57. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/debug_overlay_utils.py +0 -0
  58. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/filtering.py +0 -0
  59. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/core/video_io.py +0 -0
  60. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/dropjump/__init__.py +0 -0
  61. {kinemotion-0.12.1 → kinemotion-0.12.2}/src/kinemotion/py.typed +0 -0
  62. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/__init__.py +0 -0
  63. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_adaptive_threshold.py +0 -0
  64. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_api.py +0 -0
  65. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_aspect_ratio.py +0 -0
  66. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_cmj_analysis.py +0 -0
  67. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_cmj_kinematics.py +0 -0
  68. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_com_estimation.py +0 -0
  69. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_contact_detection.py +0 -0
  70. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_filtering.py +0 -0
  71. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_kinematics.py +0 -0
  72. {kinemotion-0.12.1 → kinemotion-0.12.2}/tests/test_polyorder.py +0 -0
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- version list -->
9
9
 
10
+ ## v0.12.2 (2025-11-06)
11
+
12
+ ### Bug Fixes
13
+
14
+ - **core**: Suppress false positive for polyorder parameter
15
+ ([`ae5ffea`](https://github.com/feniix/kinemotion/commit/ae5ffea708741592e1cd356cdf35dcc388cbe97f))
16
+
17
+ - **dropjump**: Remove unused parameters from calculate_drop_jump_metrics
18
+ ([`6130c11`](https://github.com/feniix/kinemotion/commit/6130c113be71dcd8c278b1f31a3b5e300a6b4532))
19
+
20
+ ### Refactoring
21
+
22
+ - **core**: Reduce cognitive complexity in pose.py
23
+ ([`f0a3805`](https://github.com/feniix/kinemotion/commit/f0a380561844e54b4372f57c93b82f8c8a1440ee))
24
+
25
+ - **dropjump**: Reduce cognitive complexity in analysis.py
26
+ ([`180bb37`](https://github.com/feniix/kinemotion/commit/180bb373f63675ef6ecacaea8e9ee9f63c3d3746))
27
+
28
+ - **dropjump**: Reduce cognitive complexity in debug_overlay.py
29
+ ([`076cb56`](https://github.com/feniix/kinemotion/commit/076cb560c55baaff0ba93d0631eb38d69f8a7d7b))
30
+
31
+
10
32
  ## v0.12.1 (2025-11-06)
11
33
 
12
34
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.12.1
3
+ Version: 0.12.2
4
4
  Summary: Video-based kinematic analysis for athletic performance
5
5
  Project-URL: Homepage, https://github.com/feniix/kinemotion
6
6
  Project-URL: Repository, https://github.com/feniix/kinemotion
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kinemotion"
3
- version = "0.12.1"
3
+ version = "0.12.2"
4
4
  description = "Video-based kinematic analysis for athletic performance"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10,<3.13"
@@ -463,13 +463,11 @@ def process_video(
463
463
  contact_states,
464
464
  vertical_positions,
465
465
  video.fps,
466
- drop_height_m=None,
467
466
  drop_start_frame=drop_start_frame,
468
467
  velocity_threshold=params.velocity_threshold,
469
468
  smoothing_window=params.smoothing_window,
470
469
  polyorder=params.polyorder,
471
470
  use_curvature=params.use_curvature,
472
- kinematic_correction_factor=1.0,
473
471
  )
474
472
 
475
473
  # Generate outputs (JSON and debug video)
@@ -81,6 +81,100 @@ class PoseTracker:
81
81
  self.pose.close()
82
82
 
83
83
 
84
+ def _add_head_segment(
85
+ segments: list,
86
+ weights: list,
87
+ visibilities: list,
88
+ landmarks: dict[str, tuple[float, float, float]],
89
+ vis_threshold: float,
90
+ ) -> None:
91
+ """Add head segment (8% body mass) if visible."""
92
+ if "nose" in landmarks:
93
+ x, y, vis = landmarks["nose"]
94
+ if vis > vis_threshold:
95
+ segments.append((x, y))
96
+ weights.append(0.08)
97
+ visibilities.append(vis)
98
+
99
+
100
+ def _add_trunk_segment(
101
+ segments: list,
102
+ weights: list,
103
+ visibilities: list,
104
+ landmarks: dict[str, tuple[float, float, float]],
105
+ vis_threshold: float,
106
+ ) -> None:
107
+ """Add trunk segment (50% body mass) if visible."""
108
+ trunk_keys = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
109
+ trunk_pos = [
110
+ (x, y, vis)
111
+ for key in trunk_keys
112
+ if key in landmarks
113
+ for x, y, vis in [landmarks[key]]
114
+ if vis > vis_threshold
115
+ ]
116
+ if len(trunk_pos) >= 2:
117
+ trunk_x = float(np.mean([p[0] for p in trunk_pos]))
118
+ trunk_y = float(np.mean([p[1] for p in trunk_pos]))
119
+ trunk_vis = float(np.mean([p[2] for p in trunk_pos]))
120
+ segments.append((trunk_x, trunk_y))
121
+ weights.append(0.50)
122
+ visibilities.append(trunk_vis)
123
+
124
+
125
+ def _add_limb_segment(
126
+ segments: list,
127
+ weights: list,
128
+ visibilities: list,
129
+ landmarks: dict[str, tuple[float, float, float]],
130
+ side: str,
131
+ proximal_key: str,
132
+ distal_key: str,
133
+ segment_weight: float,
134
+ vis_threshold: float,
135
+ ) -> None:
136
+ """Add a limb segment (thigh or lower leg) if both endpoints visible."""
137
+ prox_full = f"{side}_{proximal_key}"
138
+ dist_full = f"{side}_{distal_key}"
139
+
140
+ if prox_full in landmarks and dist_full in landmarks:
141
+ px, py, pvis = landmarks[prox_full]
142
+ dx, dy, dvis = landmarks[dist_full]
143
+ if pvis > vis_threshold and dvis > vis_threshold:
144
+ seg_x = (px + dx) / 2
145
+ seg_y = (py + dy) / 2
146
+ seg_vis = (pvis + dvis) / 2
147
+ segments.append((seg_x, seg_y))
148
+ weights.append(segment_weight)
149
+ visibilities.append(seg_vis)
150
+
151
+
152
+ def _add_foot_segment(
153
+ segments: list,
154
+ weights: list,
155
+ visibilities: list,
156
+ landmarks: dict[str, tuple[float, float, float]],
157
+ side: str,
158
+ vis_threshold: float,
159
+ ) -> None:
160
+ """Add foot segment (1.5% body mass per foot) if visible."""
161
+ foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
162
+ foot_pos = [
163
+ (x, y, vis)
164
+ for key in foot_keys
165
+ if key in landmarks
166
+ for x, y, vis in [landmarks[key]]
167
+ if vis > vis_threshold
168
+ ]
169
+ if foot_pos:
170
+ foot_x = float(np.mean([p[0] for p in foot_pos]))
171
+ foot_y = float(np.mean([p[1] for p in foot_pos]))
172
+ foot_vis = float(np.mean([p[2] for p in foot_pos]))
173
+ segments.append((foot_x, foot_y))
174
+ weights.append(0.015)
175
+ visibilities.append(foot_vis)
176
+
177
+
84
178
  def compute_center_of_mass(
85
179
  landmarks: dict[str, tuple[float, float, float]],
86
180
  visibility_threshold: float = 0.5,
@@ -106,114 +200,59 @@ def compute_center_of_mass(
106
200
  (x, y, visibility) tuple for estimated CoM position
107
201
  visibility = average visibility of all segments used
108
202
  """
109
- # Define segment representatives and their weights (as fraction of body mass)
110
- # Each segment uses midpoint or average of its bounding landmarks
111
- segments = []
112
- segment_weights = []
113
- visibilities = []
203
+ segments: list = []
204
+ weights: list = []
205
+ visibilities: list = []
114
206
 
115
- # Head segment: 8% (use nose as proxy)
116
- if "nose" in landmarks:
117
- x, y, vis = landmarks["nose"]
118
- if vis > visibility_threshold:
119
- segments.append((x, y))
120
- segment_weights.append(0.08)
121
- visibilities.append(vis)
207
+ # Add body segments
208
+ _add_head_segment(segments, weights, visibilities, landmarks, visibility_threshold)
209
+ _add_trunk_segment(segments, weights, visibilities, landmarks, visibility_threshold)
122
210
 
123
- # Trunk segment: 50% (midpoint between shoulders and hips)
124
- trunk_landmarks = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
125
- trunk_positions = [
126
- (x, y, vis)
127
- for key in trunk_landmarks
128
- if key in landmarks
129
- for x, y, vis in [landmarks[key]]
130
- if vis > visibility_threshold
131
- ]
132
- if len(trunk_positions) >= 2:
133
- trunk_x = float(np.mean([pos[0] for pos in trunk_positions]))
134
- trunk_y = float(np.mean([pos[1] for pos in trunk_positions]))
135
- trunk_vis = float(np.mean([pos[2] for pos in trunk_positions]))
136
- segments.append((trunk_x, trunk_y))
137
- segment_weights.append(0.50)
138
- visibilities.append(trunk_vis)
139
-
140
- # Thigh segment: 20% total (midpoint hip to knee for each leg)
211
+ # Add bilateral limb segments
141
212
  for side in ["left", "right"]:
142
- hip_key = f"{side}_hip"
143
- knee_key = f"{side}_knee"
144
- if hip_key in landmarks and knee_key in landmarks:
145
- hip_x, hip_y, hip_vis = landmarks[hip_key]
146
- knee_x, knee_y, knee_vis = landmarks[knee_key]
147
- if hip_vis > visibility_threshold and knee_vis > visibility_threshold:
148
- thigh_x = (hip_x + knee_x) / 2
149
- thigh_y = (hip_y + knee_y) / 2
150
- thigh_vis = (hip_vis + knee_vis) / 2
151
- segments.append((thigh_x, thigh_y))
152
- segment_weights.append(0.10) # 10% per leg
153
- visibilities.append(thigh_vis)
154
-
155
- # Lower leg segment: 10% total (midpoint knee to ankle for each leg)
156
- for side in ["left", "right"]:
157
- knee_key = f"{side}_knee"
158
- ankle_key = f"{side}_ankle"
159
- if knee_key in landmarks and ankle_key in landmarks:
160
- knee_x, knee_y, knee_vis = landmarks[knee_key]
161
- ankle_x, ankle_y, ankle_vis = landmarks[ankle_key]
162
- if knee_vis > visibility_threshold and ankle_vis > visibility_threshold:
163
- leg_x = (knee_x + ankle_x) / 2
164
- leg_y = (knee_y + ankle_y) / 2
165
- leg_vis = (knee_vis + ankle_vis) / 2
166
- segments.append((leg_x, leg_y))
167
- segment_weights.append(0.05) # 5% per leg
168
- visibilities.append(leg_vis)
169
-
170
- # Foot segment: 3% total (average of ankle, heel, foot_index)
171
- for side in ["left", "right"]:
172
- foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
173
- foot_positions = [
174
- (x, y, vis)
175
- for key in foot_keys
176
- if key in landmarks
177
- for x, y, vis in [landmarks[key]]
178
- if vis > visibility_threshold
179
- ]
180
- if foot_positions:
181
- foot_x = float(np.mean([pos[0] for pos in foot_positions]))
182
- foot_y = float(np.mean([pos[1] for pos in foot_positions]))
183
- foot_vis = float(np.mean([pos[2] for pos in foot_positions]))
184
- segments.append((foot_x, foot_y))
185
- segment_weights.append(0.015) # 1.5% per foot
186
- visibilities.append(foot_vis)
187
-
188
- # If no segments found, fall back to hip average
213
+ _add_limb_segment(
214
+ segments,
215
+ weights,
216
+ visibilities,
217
+ landmarks,
218
+ side,
219
+ "hip",
220
+ "knee",
221
+ 0.10,
222
+ visibility_threshold,
223
+ )
224
+ _add_limb_segment(
225
+ segments,
226
+ weights,
227
+ visibilities,
228
+ landmarks,
229
+ side,
230
+ "knee",
231
+ "ankle",
232
+ 0.05,
233
+ visibility_threshold,
234
+ )
235
+ _add_foot_segment(
236
+ segments, weights, visibilities, landmarks, side, visibility_threshold
237
+ )
238
+
239
+ # Fallback if no segments found
189
240
  if not segments:
190
241
  if "left_hip" in landmarks and "right_hip" in landmarks:
191
242
  lh_x, lh_y, lh_vis = landmarks["left_hip"]
192
243
  rh_x, rh_y, rh_vis = landmarks["right_hip"]
193
- return (
194
- (lh_x + rh_x) / 2,
195
- (lh_y + rh_y) / 2,
196
- (lh_vis + rh_vis) / 2,
197
- )
198
- # Ultimate fallback: center of frame
244
+ return ((lh_x + rh_x) / 2, (lh_y + rh_y) / 2, (lh_vis + rh_vis) / 2)
199
245
  return (0.5, 0.5, 0.0)
200
246
 
201
- # Normalize weights to sum to 1.0
202
- total_weight = sum(segment_weights)
203
- normalized_weights = [w / total_weight for w in segment_weights]
247
+ # Normalize weights and compute weighted average
248
+ total_weight = sum(weights)
249
+ normalized_weights = [w / total_weight for w in weights]
204
250
 
205
- # Compute weighted average of segment positions
206
251
  com_x = float(
207
- sum(
208
- pos[0] * weight
209
- for pos, weight in zip(segments, normalized_weights, strict=True)
210
- )
252
+ sum(p[0] * w for p, w in zip(segments, normalized_weights, strict=True))
211
253
  )
212
254
  com_y = float(
213
- sum(
214
- pos[1] * weight
215
- for pos, weight in zip(segments, normalized_weights, strict=True)
216
- )
255
+ sum(p[1] * w for p, w in zip(segments, normalized_weights, strict=True))
217
256
  )
218
257
  com_visibility = float(np.mean(visibilities)) if visibilities else 0.0
219
258
 
@@ -117,7 +117,7 @@ def _store_smoothed_landmarks(
117
117
  )
118
118
 
119
119
 
120
- def _smooth_landmarks_core(
120
+ def _smooth_landmarks_core( # NOSONAR(S1172) - polyorder used via closure capture in smoother_fn
121
121
  landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
122
122
  window_length: int,
123
123
  polyorder: int,
@@ -129,7 +129,7 @@ def _smooth_landmarks_core(
129
129
  Args:
130
130
  landmark_sequence: List of landmark dictionaries from each frame
131
131
  window_length: Length of filter window (must be odd)
132
- polyorder: Order of polynomial used to fit samples
132
+ polyorder: Order of polynomial used to fit samples (captured by smoother_fn closure)
133
133
  smoother_fn: Function that takes (x_coords, y_coords, valid_frames)
134
134
  and returns (x_smooth, y_smooth)
135
135
 
@@ -89,6 +89,87 @@ def calculate_adaptive_threshold(
89
89
  return adaptive_threshold
90
90
 
91
91
 
92
+ def _find_stable_baseline(
93
+ positions: np.ndarray,
94
+ min_stable_frames: int,
95
+ stability_threshold: float = 0.01,
96
+ debug: bool = False,
97
+ ) -> tuple[int, float]:
98
+ """Find first stable period and return baseline position.
99
+
100
+ Returns:
101
+ Tuple of (baseline_start_frame, baseline_position). Returns (-1, 0.0) if not found.
102
+ """
103
+ stable_window = min_stable_frames
104
+
105
+ for start_idx in range(0, len(positions) - stable_window, 5):
106
+ window = positions[start_idx : start_idx + stable_window]
107
+ window_std = float(np.std(window))
108
+
109
+ if window_std < stability_threshold:
110
+ baseline_start = start_idx
111
+ baseline_position = float(np.median(window))
112
+
113
+ if debug:
114
+ end_frame = baseline_start + stable_window - 1
115
+ print("[detect_drop_start] Found stable period:")
116
+ print(f" frames {baseline_start}-{end_frame}")
117
+ print(f" baseline_position: {baseline_position:.4f}")
118
+ print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
119
+
120
+ return baseline_start, baseline_position
121
+
122
+ if debug:
123
+ print(
124
+ f"[detect_drop_start] No stable period found "
125
+ f"(variance always > {stability_threshold:.4f})"
126
+ )
127
+ return -1, 0.0
128
+
129
+
130
+ def _find_drop_from_baseline(
131
+ positions: np.ndarray,
132
+ baseline_start: int,
133
+ baseline_position: float,
134
+ stable_window: int,
135
+ position_change_threshold: float,
136
+ smoothing_window: int,
137
+ debug: bool = False,
138
+ ) -> int:
139
+ """Find drop start after stable baseline period.
140
+
141
+ Returns:
142
+ Drop frame index, or 0 if not found.
143
+ """
144
+ search_start = baseline_start + stable_window
145
+ window_size = max(3, smoothing_window)
146
+
147
+ for i in range(search_start, len(positions) - window_size):
148
+ window_positions = positions[i : i + window_size]
149
+ avg_position = float(np.mean(window_positions))
150
+ position_change = avg_position - baseline_position
151
+
152
+ if position_change > position_change_threshold:
153
+ drop_frame = max(baseline_start, i - window_size)
154
+
155
+ if debug:
156
+ print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
157
+ print(
158
+ f" position_change: {position_change:.4f} > "
159
+ f"{position_change_threshold:.4f}"
160
+ )
161
+ print(
162
+ f" avg_position: {avg_position:.4f} vs "
163
+ f"baseline: {baseline_position:.4f}"
164
+ )
165
+
166
+ return drop_frame
167
+
168
+ if debug:
169
+ print("[detect_drop_start] No drop detected after stable period")
170
+ return 0
171
+
172
+
92
173
  def detect_drop_start(
93
174
  positions: np.ndarray,
94
175
  fps: float,
@@ -126,84 +207,32 @@ def detect_drop_start(
126
207
  - Returns: 119
127
208
  """
128
209
  min_stable_frames = int(fps * min_stationary_duration)
129
- if len(positions) < min_stable_frames + 30: # Need some frames after stable period
210
+ if len(positions) < min_stable_frames + 30:
130
211
  if debug:
131
- min_frames_needed = min_stable_frames + 30
132
212
  print(
133
- f"[detect_drop_start] Video too short: {len(positions)} < {min_frames_needed}"
213
+ f"[detect_drop_start] Video too short: {len(positions)} < "
214
+ f"{min_stable_frames + 30}"
134
215
  )
135
216
  return 0
136
217
 
137
- # STEP 1: Find first stable period by scanning forward
138
- # Look for window with low variance (< 1% of frame height)
139
- stability_threshold = 0.01 # 1% of frame height
140
- stable_window = min_stable_frames
141
-
142
- baseline_start = -1
143
- baseline_position = 0.0
144
-
145
- # Scan from start, looking for stable window
146
- for start_idx in range(0, len(positions) - stable_window, 5): # Step by 5 frames
147
- window = positions[start_idx : start_idx + stable_window]
148
- window_std = float(np.std(window))
149
-
150
- if window_std < stability_threshold:
151
- # Found stable period!
152
- baseline_start = start_idx
153
- baseline_position = float(np.median(window))
154
-
155
- if debug:
156
- end_frame = baseline_start + stable_window - 1
157
- print("[detect_drop_start] Found stable period:")
158
- print(f" frames {baseline_start}-{end_frame}")
159
- print(f" baseline_position: {baseline_position:.4f}")
160
- print(f" baseline_std: {window_std:.4f} < {stability_threshold:.4f}")
161
- break
218
+ # Find stable baseline period
219
+ baseline_start, baseline_position = _find_stable_baseline(
220
+ positions, min_stable_frames, debug=debug
221
+ )
162
222
 
163
223
  if baseline_start < 0:
164
- if debug:
165
- msg = (
166
- f"No stable period found (variance always > {stability_threshold:.4f})"
167
- )
168
- print(f"[detect_drop_start] {msg}")
169
224
  return 0
170
225
 
171
- # STEP 2: Find when position changes significantly from baseline
172
- # Start searching after stable period ends
173
- search_start = baseline_start + stable_window
174
- window_size = max(3, smoothing_window)
175
-
176
- for i in range(search_start, len(positions) - window_size):
177
- # Average position over small window to reduce noise
178
- window_positions = positions[i : i + window_size]
179
- avg_position = float(np.mean(window_positions))
180
-
181
- # Check if position has increased (dropped) significantly
182
- position_change = avg_position - baseline_position
183
-
184
- if position_change > position_change_threshold:
185
- # Found start of drop - back up slightly to catch beginning
186
- drop_frame_candidate = i - window_size
187
- if drop_frame_candidate < baseline_start:
188
- drop_frame = baseline_start
189
- else:
190
- drop_frame = drop_frame_candidate
191
-
192
- if debug:
193
- print(f"[detect_drop_start] Drop detected at frame {drop_frame}")
194
- print(
195
- f" position_change: {position_change:.4f} > {position_change_threshold:.4f}"
196
- )
197
- print(
198
- f" avg_position: {avg_position:.4f} vs baseline: {baseline_position:.4f}"
199
- )
200
-
201
- return drop_frame
202
-
203
- # No significant position change detected
204
- if debug:
205
- print("[detect_drop_start] No drop detected after stable period")
206
- return 0
226
+ # Find drop from baseline
227
+ return _find_drop_from_baseline(
228
+ positions,
229
+ baseline_start,
230
+ baseline_position,
231
+ min_stable_frames,
232
+ position_change_threshold,
233
+ smoothing_window,
234
+ debug,
235
+ )
207
236
 
208
237
 
209
238
  def detect_ground_contact(
@@ -349,6 +378,71 @@ def interpolate_threshold_crossing(
349
378
  return float(max(0.0, min(1.0, t)))
350
379
 
351
380
 
381
+ def _interpolate_phase_start(
382
+ start_idx: int,
383
+ state: ContactState,
384
+ velocities: np.ndarray,
385
+ velocity_threshold: float,
386
+ ) -> float:
387
+ """Interpolate start boundary of a phase with sub-frame precision.
388
+
389
+ Returns:
390
+ Fractional start frame, or float(start_idx) if no interpolation.
391
+ """
392
+ if start_idx <= 0 or start_idx >= len(velocities):
393
+ return float(start_idx)
394
+
395
+ vel_before = velocities[start_idx - 1]
396
+ vel_at = velocities[start_idx]
397
+
398
+ # Check threshold crossing based on state
399
+ is_landing = (
400
+ state == ContactState.ON_GROUND and vel_before > velocity_threshold > vel_at
401
+ )
402
+ is_takeoff = (
403
+ state == ContactState.IN_AIR and vel_before < velocity_threshold < vel_at
404
+ )
405
+
406
+ if is_landing or is_takeoff:
407
+ offset = interpolate_threshold_crossing(vel_before, vel_at, velocity_threshold)
408
+ return (start_idx - 1) + offset
409
+
410
+ return float(start_idx)
411
+
412
+
413
+ def _interpolate_phase_end(
414
+ end_idx: int,
415
+ state: ContactState,
416
+ velocities: np.ndarray,
417
+ velocity_threshold: float,
418
+ max_idx: int,
419
+ ) -> float:
420
+ """Interpolate end boundary of a phase with sub-frame precision.
421
+
422
+ Returns:
423
+ Fractional end frame, or float(end_idx) if no interpolation.
424
+ """
425
+ if end_idx >= max_idx - 1 or end_idx + 1 >= len(velocities):
426
+ return float(end_idx)
427
+
428
+ vel_at = velocities[end_idx]
429
+ vel_after = velocities[end_idx + 1]
430
+
431
+ # Check threshold crossing based on state
432
+ is_takeoff = (
433
+ state == ContactState.ON_GROUND and vel_at < velocity_threshold < vel_after
434
+ )
435
+ is_landing = (
436
+ state == ContactState.IN_AIR and vel_at > velocity_threshold > vel_after
437
+ )
438
+
439
+ if is_takeoff or is_landing:
440
+ offset = interpolate_threshold_crossing(vel_at, vel_after, velocity_threshold)
441
+ return end_idx + offset
442
+
443
+ return float(end_idx)
444
+
445
+
352
446
  def find_interpolated_phase_transitions(
353
447
  foot_positions: np.ndarray,
354
448
  contact_states: list[ContactState],
@@ -371,13 +465,10 @@ def find_interpolated_phase_transitions(
371
465
  Returns:
372
466
  List of (start_frame, end_frame, state) tuples with fractional frame indices
373
467
  """
374
- # First get integer frame phases
375
468
  phases = find_contact_phases(contact_states)
376
469
  if not phases or len(foot_positions) < 2:
377
470
  return []
378
471
 
379
- # Compute velocities from derivative of smoothed trajectory
380
- # This gives much smoother velocity estimates than simple frame differences
381
472
  velocities = compute_velocity_from_derivative(
382
473
  foot_positions, window_length=smoothing_window, polyorder=2
383
474
  )
@@ -385,57 +476,12 @@ def find_interpolated_phase_transitions(
385
476
  interpolated_phases: list[tuple[float, float, ContactState]] = []
386
477
 
387
478
  for start_idx, end_idx, state in phases:
388
- start_frac = float(start_idx)
389
- end_frac = float(end_idx)
390
-
391
- # Interpolate start boundary (transition INTO this phase)
392
- if start_idx > 0 and start_idx < len(velocities):
393
- vel_before = (
394
- velocities[start_idx - 1] if start_idx > 0 else velocities[start_idx]
395
- )
396
- vel_at = velocities[start_idx]
397
-
398
- # Check if we're crossing the threshold at this boundary
399
- if state == ContactState.ON_GROUND:
400
- # Transition air→ground: velocity dropping below threshold
401
- if vel_before > velocity_threshold > vel_at:
402
- # Interpolate between start_idx-1 and start_idx
403
- offset = interpolate_threshold_crossing(
404
- vel_before, vel_at, velocity_threshold
405
- )
406
- start_frac = (start_idx - 1) + offset
407
- elif state == ContactState.IN_AIR:
408
- # Transition ground→air: velocity rising above threshold
409
- if vel_before < velocity_threshold < vel_at:
410
- # Interpolate between start_idx-1 and start_idx
411
- offset = interpolate_threshold_crossing(
412
- vel_before, vel_at, velocity_threshold
413
- )
414
- start_frac = (start_idx - 1) + offset
415
-
416
- # Interpolate end boundary (transition OUT OF this phase)
417
- if end_idx < len(foot_positions) - 1 and end_idx + 1 < len(velocities):
418
- vel_at = velocities[end_idx]
419
- vel_after = velocities[end_idx + 1]
420
-
421
- # Check if we're crossing the threshold at this boundary
422
- if state == ContactState.ON_GROUND:
423
- # Transition ground→air: velocity rising above threshold
424
- if vel_at < velocity_threshold < vel_after:
425
- # Interpolate between end_idx and end_idx+1
426
- offset = interpolate_threshold_crossing(
427
- vel_at, vel_after, velocity_threshold
428
- )
429
- end_frac = end_idx + offset
430
- elif state == ContactState.IN_AIR:
431
- # Transition air→ground: velocity dropping below threshold
432
- if vel_at > velocity_threshold > vel_after:
433
- # Interpolate between end_idx and end_idx+1
434
- offset = interpolate_threshold_crossing(
435
- vel_at, vel_after, velocity_threshold
436
- )
437
- end_frac = end_idx + offset
438
-
479
+ start_frac = _interpolate_phase_start(
480
+ start_idx, state, velocities, velocity_threshold
481
+ )
482
+ end_frac = _interpolate_phase_end(
483
+ end_idx, state, velocities, velocity_threshold, len(foot_positions)
484
+ )
439
485
  interpolated_phases.append((start_frac, end_frac, state))
440
486
 
441
487
  return interpolated_phases