kinemotion 0.1.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.

dropjump/kinematics.py ADDED
@@ -0,0 +1,374 @@
1
+ """Kinematic calculations for drop-jump metrics."""
2
+
3
+
4
+ import numpy as np
5
+
6
+ from .contact_detection import (
7
+ ContactState,
8
+ find_contact_phases,
9
+ find_interpolated_phase_transitions_with_curvature,
10
+ )
11
+
12
+
13
+ class DropJumpMetrics:
14
+ """Container for drop-jump analysis metrics."""
15
+
16
+ def __init__(self) -> None:
17
+ self.ground_contact_time: float | None = None
18
+ self.flight_time: float | None = None
19
+ self.jump_height: float | None = None
20
+ self.jump_height_kinematic: float | None = None # From flight time
21
+ self.jump_height_trajectory: float | None = None # From position tracking
22
+ self.contact_start_frame: int | None = None
23
+ self.contact_end_frame: int | None = None
24
+ self.flight_start_frame: int | None = None
25
+ self.flight_end_frame: int | None = None
26
+ self.peak_height_frame: int | None = None
27
+ # Fractional frame indices for sub-frame precision timing
28
+ self.contact_start_frame_precise: float | None = None
29
+ self.contact_end_frame_precise: float | None = None
30
+ self.flight_start_frame_precise: float | None = None
31
+ self.flight_end_frame_precise: float | None = None
32
+
33
+ def to_dict(self) -> dict:
34
+ """Convert metrics to dictionary for JSON output."""
35
+ return {
36
+ "ground_contact_time_ms": (
37
+ round(self.ground_contact_time * 1000, 2)
38
+ if self.ground_contact_time is not None
39
+ else None
40
+ ),
41
+ "flight_time_ms": (
42
+ round(self.flight_time * 1000, 2)
43
+ if self.flight_time is not None
44
+ else None
45
+ ),
46
+ "jump_height_m": (
47
+ round(self.jump_height, 3) if self.jump_height is not None else None
48
+ ),
49
+ "jump_height_kinematic_m": (
50
+ round(self.jump_height_kinematic, 3)
51
+ if self.jump_height_kinematic is not None
52
+ else None
53
+ ),
54
+ "jump_height_trajectory_normalized": (
55
+ round(self.jump_height_trajectory, 4)
56
+ if self.jump_height_trajectory is not None
57
+ else None
58
+ ),
59
+ "contact_start_frame": (
60
+ int(self.contact_start_frame)
61
+ if self.contact_start_frame is not None
62
+ else None
63
+ ),
64
+ "contact_end_frame": (
65
+ int(self.contact_end_frame)
66
+ if self.contact_end_frame is not None
67
+ else None
68
+ ),
69
+ "flight_start_frame": (
70
+ int(self.flight_start_frame)
71
+ if self.flight_start_frame is not None
72
+ else None
73
+ ),
74
+ "flight_end_frame": (
75
+ int(self.flight_end_frame)
76
+ if self.flight_end_frame is not None
77
+ else None
78
+ ),
79
+ "peak_height_frame": (
80
+ int(self.peak_height_frame)
81
+ if self.peak_height_frame is not None
82
+ else None
83
+ ),
84
+ "contact_start_frame_precise": (
85
+ round(self.contact_start_frame_precise, 3)
86
+ if self.contact_start_frame_precise is not None
87
+ else None
88
+ ),
89
+ "contact_end_frame_precise": (
90
+ round(self.contact_end_frame_precise, 3)
91
+ if self.contact_end_frame_precise is not None
92
+ else None
93
+ ),
94
+ "flight_start_frame_precise": (
95
+ round(self.flight_start_frame_precise, 3)
96
+ if self.flight_start_frame_precise is not None
97
+ else None
98
+ ),
99
+ "flight_end_frame_precise": (
100
+ round(self.flight_end_frame_precise, 3)
101
+ if self.flight_end_frame_precise is not None
102
+ else None
103
+ ),
104
+ }
105
+
106
+
107
+ def calculate_drop_jump_metrics(
108
+ contact_states: list[ContactState],
109
+ foot_y_positions: np.ndarray,
110
+ fps: float,
111
+ drop_height_m: float | None = None,
112
+ velocity_threshold: float = 0.02,
113
+ smoothing_window: int = 5,
114
+ use_curvature: bool = True,
115
+ ) -> DropJumpMetrics:
116
+ """
117
+ Calculate drop-jump metrics from contact states and positions.
118
+
119
+ Args:
120
+ contact_states: Contact state for each frame
121
+ foot_y_positions: Vertical positions of feet (normalized 0-1)
122
+ fps: Video frame rate
123
+ drop_height_m: Known drop box/platform height in meters for calibration (optional)
124
+ velocity_threshold: Velocity threshold used for contact detection (for interpolation)
125
+ smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
126
+ use_curvature: Whether to use curvature analysis for refining transitions
127
+
128
+ Returns:
129
+ DropJumpMetrics object with calculated values
130
+ """
131
+ metrics = DropJumpMetrics()
132
+ phases = find_contact_phases(contact_states)
133
+
134
+ # Get interpolated phases with curvature-based refinement
135
+ # Combines velocity interpolation + acceleration pattern analysis
136
+ interpolated_phases = find_interpolated_phase_transitions_with_curvature(
137
+ foot_y_positions,
138
+ contact_states,
139
+ velocity_threshold,
140
+ smoothing_window,
141
+ use_curvature,
142
+ )
143
+
144
+ if not phases:
145
+ return metrics
146
+
147
+ # Find the main contact phase
148
+ # For drop jumps: find first ON_GROUND after first IN_AIR (the landing after drop)
149
+ # For regular jumps: use longest ON_GROUND phase
150
+ ground_phases = [
151
+ (start, end, i)
152
+ for i, (start, end, state) in enumerate(phases)
153
+ if state == ContactState.ON_GROUND
154
+ ]
155
+ air_phases_indexed = [
156
+ (start, end, i)
157
+ for i, (start, end, state) in enumerate(phases)
158
+ if state == ContactState.IN_AIR
159
+ ]
160
+
161
+ if not ground_phases:
162
+ return metrics
163
+
164
+ # Detect if this is a drop jump or regular jump
165
+ # Drop jump: first ground phase is elevated (lower y), followed by drop, then landing (higher y)
166
+ is_drop_jump = False
167
+ if air_phases_indexed and len(ground_phases) >= 2:
168
+ first_ground_start, first_ground_end, first_ground_idx = ground_phases[0]
169
+ first_air_idx = air_phases_indexed[0][2]
170
+
171
+ # Find ground phase after first air phase
172
+ ground_after_air = [
173
+ (start, end, idx) for start, end, idx in ground_phases if idx > first_air_idx
174
+ ]
175
+
176
+ if ground_after_air and first_ground_idx < first_air_idx:
177
+ # Check if first ground is at higher elevation (lower y) than ground after air
178
+ first_ground_y = float(
179
+ np.mean(foot_y_positions[first_ground_start : first_ground_end + 1])
180
+ )
181
+ second_ground_start, second_ground_end, _ = ground_after_air[0]
182
+ second_ground_y = float(
183
+ np.mean(foot_y_positions[second_ground_start : second_ground_end + 1])
184
+ )
185
+
186
+ # If first ground is significantly higher (>5% of frame), it's a drop jump
187
+ if second_ground_y - first_ground_y > 0.05:
188
+ is_drop_jump = True
189
+ contact_start, contact_end = second_ground_start, second_ground_end
190
+
191
+ if not is_drop_jump:
192
+ # Regular jump: use longest ground contact phase
193
+ contact_start, contact_end = max(
194
+ [(s, e) for s, e, _ in ground_phases], key=lambda p: p[1] - p[0]
195
+ )
196
+
197
+ # Store integer frame indices (for visualization)
198
+ metrics.contact_start_frame = contact_start
199
+ metrics.contact_end_frame = contact_end
200
+
201
+ # Find corresponding interpolated phase for precise timing
202
+ contact_start_frac = float(contact_start)
203
+ contact_end_frac = float(contact_end)
204
+
205
+ # Find the matching ground phase in interpolated_phases
206
+ for start_frac, end_frac, state in interpolated_phases:
207
+ # Match by checking if integer frames are within this phase
208
+ if (
209
+ state == ContactState.ON_GROUND
210
+ and int(start_frac) <= contact_start <= int(end_frac) + 1
211
+ and int(start_frac) <= contact_end <= int(end_frac) + 1
212
+ ):
213
+ contact_start_frac = start_frac
214
+ contact_end_frac = end_frac
215
+ break
216
+
217
+ # Calculate ground contact time using fractional frames
218
+ contact_frames_precise = contact_end_frac - contact_start_frac
219
+ metrics.ground_contact_time = contact_frames_precise / fps
220
+ metrics.contact_start_frame_precise = contact_start_frac
221
+ metrics.contact_end_frame_precise = contact_end_frac
222
+
223
+ # Calculate calibration scale factor from drop height if provided
224
+ scale_factor = 1.0
225
+ if drop_height_m is not None and len(phases) >= 2:
226
+ # Find the initial drop by looking for first IN_AIR phase
227
+ # This represents the drop from the box
228
+
229
+ if air_phases_indexed and ground_phases:
230
+ # Get first air phase (the drop)
231
+ first_air_start, first_air_end, _ = air_phases_indexed[0]
232
+
233
+ # Initial position: at start of drop (on the box)
234
+ # Look back a few frames to get stable position on box
235
+ lookback_start = max(0, first_air_start - 5)
236
+ if lookback_start < first_air_start:
237
+ initial_position = float(np.mean(foot_y_positions[lookback_start:first_air_start]))
238
+ else:
239
+ initial_position = float(foot_y_positions[first_air_start])
240
+
241
+ # Landing position: at the ground after drop
242
+ # Use position at end of first air phase
243
+ landing_position = float(foot_y_positions[first_air_end])
244
+
245
+ # Drop distance in normalized coordinates (y increases downward)
246
+ drop_normalized = landing_position - initial_position
247
+
248
+ if drop_normalized > 0.01: # Sanity check (at least 1% of frame height)
249
+ # Calculate scale factor: real_meters / normalized_distance
250
+ scale_factor = drop_height_m / drop_normalized
251
+
252
+ # Find flight phase after ground contact
253
+ flight_phases = [
254
+ (start, end)
255
+ for start, end, state in phases
256
+ if state == ContactState.IN_AIR and start > contact_end
257
+ ]
258
+
259
+ if flight_phases:
260
+ flight_start, flight_end = flight_phases[0]
261
+
262
+ # Store integer frame indices (for visualization)
263
+ metrics.flight_start_frame = flight_start
264
+ metrics.flight_end_frame = flight_end
265
+
266
+ # Find corresponding interpolated phase for precise timing
267
+ flight_start_frac = float(flight_start)
268
+ flight_end_frac = float(flight_end)
269
+
270
+ # Find the matching air phase in interpolated_phases
271
+ for start_frac, end_frac, state in interpolated_phases:
272
+ # Match by checking if integer frames are within this phase
273
+ if (
274
+ state == ContactState.IN_AIR
275
+ and int(start_frac) <= flight_start <= int(end_frac) + 1
276
+ and int(start_frac) <= flight_end <= int(end_frac) + 1
277
+ ):
278
+ flight_start_frac = start_frac
279
+ flight_end_frac = end_frac
280
+ break
281
+
282
+ # Calculate flight time using fractional frames
283
+ flight_frames_precise = flight_end_frac - flight_start_frac
284
+ metrics.flight_time = flight_frames_precise / fps
285
+ metrics.flight_start_frame_precise = flight_start_frac
286
+ metrics.flight_end_frame_precise = flight_end_frac
287
+
288
+ # Calculate jump height using flight time (kinematic method)
289
+ # h = (g * t^2) / 8, where t is total flight time
290
+ g = 9.81 # m/s^2
291
+ jump_height_kinematic = (g * metrics.flight_time**2) / 8
292
+
293
+ # Calculate jump height from trajectory (position-based method)
294
+ # This measures actual vertical displacement from takeoff to peak
295
+ takeoff_position = foot_y_positions[flight_start]
296
+ flight_positions = foot_y_positions[flight_start : flight_end + 1]
297
+
298
+ if len(flight_positions) > 0:
299
+ peak_idx = np.argmin(flight_positions)
300
+ metrics.peak_height_frame = int(flight_start + peak_idx)
301
+ peak_position = np.min(flight_positions)
302
+
303
+ # Height in normalized coordinates (0-1 range)
304
+ height_normalized = float(takeoff_position - peak_position)
305
+
306
+ # Store trajectory value (in normalized coordinates)
307
+ metrics.jump_height_trajectory = height_normalized
308
+
309
+ # Choose measurement method based on calibration availability
310
+ if drop_height_m is not None and scale_factor > 1.0:
311
+ # Use calibrated trajectory measurement (most accurate)
312
+ metrics.jump_height = height_normalized * scale_factor
313
+ metrics.jump_height_kinematic = jump_height_kinematic
314
+ else:
315
+ # Use empirical correction factor for kinematic method
316
+ # Testing shows kinematic method underestimates by ~29% due to:
317
+ # 1. Contact detection timing (detects landing slightly early)
318
+ # 2. Frame rate limitations (30 fps = 33ms intervals)
319
+ # 3. Foot position vs center of mass difference
320
+ kinematic_correction_factor = 1.35
321
+ metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
322
+ metrics.jump_height_kinematic = jump_height_kinematic
323
+ else:
324
+ # Fallback to kinematic if no position data
325
+ if drop_height_m is None:
326
+ kinematic_correction_factor = 1.35
327
+ metrics.jump_height = jump_height_kinematic * kinematic_correction_factor
328
+ else:
329
+ metrics.jump_height = jump_height_kinematic
330
+ metrics.jump_height_kinematic = jump_height_kinematic
331
+
332
+ return metrics
333
+
334
+
335
+ def estimate_jump_height_from_trajectory(
336
+ foot_y_positions: np.ndarray,
337
+ contact_start: int,
338
+ flight_start: int,
339
+ flight_end: int,
340
+ pixel_to_meter_ratio: float | None = None,
341
+ ) -> float:
342
+ """
343
+ Estimate jump height from position trajectory.
344
+
345
+ Args:
346
+ foot_y_positions: Vertical positions of feet (normalized or pixels)
347
+ contact_start: Frame where ground contact starts
348
+ flight_start: Frame where flight begins
349
+ flight_end: Frame where flight ends
350
+ pixel_to_meter_ratio: Conversion factor from pixels to meters
351
+
352
+ Returns:
353
+ Estimated jump height in meters (or normalized units if no calibration)
354
+ """
355
+ if flight_end < flight_start:
356
+ return 0.0
357
+
358
+ # Get position at takeoff (end of contact) and peak (minimum y during flight)
359
+ takeoff_position = foot_y_positions[flight_start]
360
+ flight_positions = foot_y_positions[flight_start : flight_end + 1]
361
+
362
+ if len(flight_positions) == 0:
363
+ return 0.0
364
+
365
+ peak_position = np.min(flight_positions)
366
+
367
+ # Height difference (in normalized coordinates, y increases downward)
368
+ height_diff = takeoff_position - peak_position
369
+
370
+ # Convert to meters if calibration available
371
+ if pixel_to_meter_ratio is not None:
372
+ return float(height_diff * pixel_to_meter_ratio)
373
+
374
+ return float(height_diff)
@@ -0,0 +1,74 @@
1
+ """Pose tracking using MediaPipe Pose."""
2
+
3
+
4
+ import cv2
5
+ import mediapipe as mp
6
+ import numpy as np
7
+
8
+
9
+ class PoseTracker:
10
+ """Tracks human pose landmarks in video frames using MediaPipe."""
11
+
12
+ def __init__(
13
+ self,
14
+ min_detection_confidence: float = 0.5,
15
+ min_tracking_confidence: float = 0.5,
16
+ ):
17
+ """
18
+ Initialize the pose tracker.
19
+
20
+ Args:
21
+ min_detection_confidence: Minimum confidence for pose detection
22
+ min_tracking_confidence: Minimum confidence for pose tracking
23
+ """
24
+ self.mp_pose = mp.solutions.pose
25
+ self.pose = self.mp_pose.Pose(
26
+ min_detection_confidence=min_detection_confidence,
27
+ min_tracking_confidence=min_tracking_confidence,
28
+ model_complexity=1,
29
+ )
30
+
31
+ def process_frame(
32
+ self, frame: np.ndarray
33
+ ) -> dict[str, tuple[float, float, float]] | None:
34
+ """
35
+ Process a single frame and extract pose landmarks.
36
+
37
+ Args:
38
+ frame: BGR image frame
39
+
40
+ Returns:
41
+ Dictionary mapping landmark names to (x, y, visibility) tuples,
42
+ or None if no pose detected. Coordinates are normalized (0-1).
43
+ """
44
+ # Convert BGR to RGB
45
+ rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
46
+
47
+ # Process the frame
48
+ results = self.pose.process(rgb_frame)
49
+
50
+ if not results.pose_landmarks:
51
+ return None
52
+
53
+ # Extract key landmarks for feet tracking
54
+ landmarks = {}
55
+ landmark_names = {
56
+ self.mp_pose.PoseLandmark.LEFT_ANKLE: "left_ankle",
57
+ self.mp_pose.PoseLandmark.RIGHT_ANKLE: "right_ankle",
58
+ self.mp_pose.PoseLandmark.LEFT_HEEL: "left_heel",
59
+ self.mp_pose.PoseLandmark.RIGHT_HEEL: "right_heel",
60
+ self.mp_pose.PoseLandmark.LEFT_FOOT_INDEX: "left_foot_index",
61
+ self.mp_pose.PoseLandmark.RIGHT_FOOT_INDEX: "right_foot_index",
62
+ self.mp_pose.PoseLandmark.LEFT_HIP: "left_hip",
63
+ self.mp_pose.PoseLandmark.RIGHT_HIP: "right_hip",
64
+ }
65
+
66
+ for landmark_id, name in landmark_names.items():
67
+ lm = results.pose_landmarks.landmark[landmark_id]
68
+ landmarks[name] = (lm.x, lm.y, lm.visibility)
69
+
70
+ return landmarks
71
+
72
+ def close(self) -> None:
73
+ """Release resources."""
74
+ self.pose.close()
dropjump/smoothing.py ADDED
@@ -0,0 +1,223 @@
1
+ """Landmark smoothing utilities to reduce jitter in pose tracking."""
2
+
3
+
4
+ import numpy as np
5
+ from scipy.signal import savgol_filter
6
+
7
+
8
+ def smooth_landmarks(
9
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
10
+ window_length: int = 5,
11
+ polyorder: int = 2,
12
+ ) -> list[dict[str, tuple[float, float, float]] | None]:
13
+ """
14
+ Smooth landmark trajectories using Savitzky-Golay filter.
15
+
16
+ Args:
17
+ landmark_sequence: List of landmark dictionaries from each frame
18
+ window_length: Length of filter window (must be odd, >= polyorder + 2)
19
+ polyorder: Order of polynomial used to fit samples
20
+
21
+ Returns:
22
+ Smoothed landmark sequence with same structure as input
23
+ """
24
+ if len(landmark_sequence) < window_length:
25
+ # Not enough frames to smooth effectively
26
+ return landmark_sequence
27
+
28
+ # Ensure window_length is odd
29
+ if window_length % 2 == 0:
30
+ window_length += 1
31
+
32
+ # Extract landmark names from first valid frame
33
+ landmark_names = None
34
+ for frame_landmarks in landmark_sequence:
35
+ if frame_landmarks is not None:
36
+ landmark_names = list(frame_landmarks.keys())
37
+ break
38
+
39
+ if landmark_names is None:
40
+ return landmark_sequence
41
+
42
+ # Build arrays for each landmark coordinate
43
+ smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
44
+
45
+ for landmark_name in landmark_names:
46
+ # Extract x, y coordinates for this landmark across all frames
47
+ x_coords = []
48
+ y_coords = []
49
+ valid_frames = []
50
+
51
+ for i, frame_landmarks in enumerate(landmark_sequence):
52
+ if frame_landmarks is not None and landmark_name in frame_landmarks:
53
+ x, y, vis = frame_landmarks[landmark_name]
54
+ x_coords.append(x)
55
+ y_coords.append(y)
56
+ valid_frames.append(i)
57
+
58
+ if len(x_coords) < window_length:
59
+ continue
60
+
61
+ # Apply Savitzky-Golay filter
62
+ x_smooth = savgol_filter(x_coords, window_length, polyorder)
63
+ y_smooth = savgol_filter(y_coords, window_length, polyorder)
64
+
65
+ # Store smoothed values back
66
+ for idx, frame_idx in enumerate(valid_frames):
67
+ if frame_idx >= len(smoothed_sequence):
68
+ smoothed_sequence.extend(
69
+ [{}] * (frame_idx - len(smoothed_sequence) + 1)
70
+ )
71
+
72
+ # Ensure smoothed_sequence[frame_idx] is a dict, not None
73
+ if smoothed_sequence[frame_idx] is None:
74
+ smoothed_sequence[frame_idx] = {}
75
+
76
+ if (
77
+ landmark_name not in smoothed_sequence[frame_idx] # type: ignore[operator]
78
+ and landmark_sequence[frame_idx] is not None
79
+ ):
80
+ # Keep original visibility
81
+ orig_vis = landmark_sequence[frame_idx][landmark_name][2] # type: ignore[index]
82
+ smoothed_sequence[frame_idx][landmark_name] = ( # type: ignore[index]
83
+ float(x_smooth[idx]),
84
+ float(y_smooth[idx]),
85
+ orig_vis,
86
+ )
87
+
88
+ # Fill in any missing frames with original data
89
+ for i in range(len(landmark_sequence)):
90
+ if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
91
+ if i < len(smoothed_sequence):
92
+ smoothed_sequence[i] = landmark_sequence[i]
93
+ else:
94
+ smoothed_sequence.append(landmark_sequence[i])
95
+
96
+ return smoothed_sequence
97
+
98
+
99
+ def compute_velocity(
100
+ positions: np.ndarray, fps: float, smooth_window: int = 3
101
+ ) -> np.ndarray:
102
+ """
103
+ Compute velocity from position data.
104
+
105
+ Args:
106
+ positions: Array of positions over time (n_frames, n_dims)
107
+ fps: Frames per second of the video
108
+ smooth_window: Window size for velocity smoothing
109
+
110
+ Returns:
111
+ Velocity array (n_frames, n_dims)
112
+ """
113
+ dt = 1.0 / fps
114
+ velocity = np.gradient(positions, dt, axis=0)
115
+
116
+ # Smooth velocity if we have enough data
117
+ if len(velocity) >= smooth_window and smooth_window > 1:
118
+ if smooth_window % 2 == 0:
119
+ smooth_window += 1
120
+ for dim in range(velocity.shape[1]):
121
+ velocity[:, dim] = savgol_filter(velocity[:, dim], smooth_window, 1)
122
+
123
+ return velocity # type: ignore[no-any-return]
124
+
125
+
126
+ def compute_velocity_from_derivative(
127
+ positions: np.ndarray,
128
+ window_length: int = 5,
129
+ polyorder: int = 2,
130
+ ) -> np.ndarray:
131
+ """
132
+ Compute velocity as derivative of smoothed position trajectory.
133
+
134
+ Uses Savitzky-Golay filter to compute the derivative directly, which provides
135
+ a much smoother and more accurate velocity estimate than frame-to-frame differences.
136
+
137
+ This method:
138
+ 1. Fits a polynomial to the position data in a sliding window
139
+ 2. Analytically computes the derivative of that polynomial
140
+ 3. Returns smooth velocity values
141
+
142
+ Args:
143
+ positions: 1D array of position values (e.g., foot y-positions)
144
+ window_length: Window size for smoothing (must be odd, >= polyorder + 2)
145
+ polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
146
+
147
+ Returns:
148
+ Array of absolute velocity values (magnitude of derivative)
149
+ """
150
+ if len(positions) < window_length:
151
+ # Fallback to simple differences for short sequences
152
+ return np.abs(np.diff(positions, prepend=positions[0])) # type: ignore[no-any-return]
153
+
154
+ # Ensure window_length is odd
155
+ if window_length % 2 == 0:
156
+ window_length += 1
157
+
158
+ # Compute derivative using Savitzky-Golay filter
159
+ # deriv=1: compute first derivative
160
+ # delta=1.0: frame spacing (velocity per frame)
161
+ # mode='interp': interpolate at boundaries
162
+ velocity = savgol_filter(
163
+ positions,
164
+ window_length,
165
+ polyorder,
166
+ deriv=1, # First derivative
167
+ delta=1.0, # Frame spacing
168
+ mode="interp",
169
+ )
170
+
171
+ # Return absolute velocity (magnitude only)
172
+ return np.abs(velocity) # type: ignore[no-any-return]
173
+
174
+
175
+ def compute_acceleration_from_derivative(
176
+ positions: np.ndarray,
177
+ window_length: int = 5,
178
+ polyorder: int = 2,
179
+ ) -> np.ndarray:
180
+ """
181
+ Compute acceleration as second derivative of smoothed position trajectory.
182
+
183
+ Uses Savitzky-Golay filter to compute the second derivative directly,
184
+ providing smooth acceleration (curvature) estimates for detecting
185
+ characteristic patterns at landing and takeoff.
186
+
187
+ Landing and takeoff events show distinctive acceleration patterns:
188
+ - Landing: Large acceleration spike as feet decelerate on impact
189
+ - Takeoff: Acceleration change as body accelerates upward
190
+ - In flight: Constant acceleration due to gravity
191
+ - On ground: Near-zero acceleration (stationary position)
192
+
193
+ Args:
194
+ positions: 1D array of position values (e.g., foot y-positions)
195
+ window_length: Window size for smoothing (must be odd, >= polyorder + 2)
196
+ polyorder: Polynomial order for Savitzky-Golay filter (typically 2 or 3)
197
+
198
+ Returns:
199
+ Array of acceleration values (second derivative of position)
200
+ """
201
+ if len(positions) < window_length:
202
+ # Fallback to simple second differences for short sequences
203
+ velocity = np.diff(positions, prepend=positions[0])
204
+ return np.diff(velocity, prepend=velocity[0])
205
+
206
+ # Ensure window_length is odd
207
+ if window_length % 2 == 0:
208
+ window_length += 1
209
+
210
+ # Compute second derivative using Savitzky-Golay filter
211
+ # deriv=2: compute second derivative (acceleration/curvature)
212
+ # delta=1.0: frame spacing
213
+ # mode='interp': interpolate at boundaries
214
+ acceleration = savgol_filter(
215
+ positions,
216
+ window_length,
217
+ polyorder,
218
+ deriv=2, # Second derivative
219
+ delta=1.0, # Frame spacing
220
+ mode="interp",
221
+ )
222
+
223
+ return acceleration # type: ignore[no-any-return]