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.

@@ -0,0 +1,431 @@
1
+ """Ground contact detection logic for drop-jump analysis."""
2
+
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+
7
+ from .smoothing import (
8
+ compute_acceleration_from_derivative,
9
+ compute_velocity_from_derivative,
10
+ )
11
+
12
+
13
+ class ContactState(Enum):
14
+ """States for foot-ground contact."""
15
+
16
+ IN_AIR = "in_air"
17
+ ON_GROUND = "on_ground"
18
+ UNKNOWN = "unknown"
19
+
20
+
21
+ def detect_ground_contact(
22
+ foot_positions: np.ndarray,
23
+ velocity_threshold: float = 0.02,
24
+ min_contact_frames: int = 3,
25
+ visibility_threshold: float = 0.5,
26
+ visibilities: np.ndarray | None = None,
27
+ ) -> list[ContactState]:
28
+ """
29
+ Detect when feet are in contact with ground based on vertical motion.
30
+
31
+ Args:
32
+ foot_positions: Array of foot y-positions (normalized, 0-1, where 1 is bottom)
33
+ velocity_threshold: Threshold for vertical velocity to consider stationary
34
+ min_contact_frames: Minimum consecutive frames to confirm contact
35
+ visibility_threshold: Minimum visibility score to trust landmark
36
+ visibilities: Array of visibility scores for each frame
37
+
38
+ Returns:
39
+ List of ContactState for each frame
40
+ """
41
+ n_frames = len(foot_positions)
42
+ states = [ContactState.UNKNOWN] * n_frames
43
+
44
+ if n_frames < 2:
45
+ return states
46
+
47
+ # Compute vertical velocity (positive = moving down in image coordinates)
48
+ velocities = np.diff(foot_positions, prepend=foot_positions[0])
49
+
50
+ # Detect potential contact frames based on low velocity
51
+ is_stationary = np.abs(velocities) < velocity_threshold
52
+
53
+ # Apply visibility filter
54
+ if visibilities is not None:
55
+ is_visible = visibilities > visibility_threshold
56
+ is_stationary = is_stationary & is_visible
57
+
58
+ # Apply minimum contact duration filter
59
+ contact_frames = []
60
+ current_run = []
61
+
62
+ for i, stationary in enumerate(is_stationary):
63
+ if stationary:
64
+ current_run.append(i)
65
+ else:
66
+ if len(current_run) >= min_contact_frames:
67
+ contact_frames.extend(current_run)
68
+ current_run = []
69
+
70
+ # Don't forget the last run
71
+ if len(current_run) >= min_contact_frames:
72
+ contact_frames.extend(current_run)
73
+
74
+ # Set states
75
+ for i in range(n_frames):
76
+ if visibilities is not None and visibilities[i] < visibility_threshold:
77
+ states[i] = ContactState.UNKNOWN
78
+ elif i in contact_frames:
79
+ states[i] = ContactState.ON_GROUND
80
+ else:
81
+ states[i] = ContactState.IN_AIR
82
+
83
+ return states
84
+
85
+
86
+ def find_contact_phases(
87
+ contact_states: list[ContactState],
88
+ ) -> list[tuple[int, int, ContactState]]:
89
+ """
90
+ Identify continuous phases of contact/flight.
91
+
92
+ Args:
93
+ contact_states: List of ContactState for each frame
94
+
95
+ Returns:
96
+ List of (start_frame, end_frame, state) tuples for each phase
97
+ """
98
+ phases: list[tuple[int, int, ContactState]] = []
99
+ if not contact_states:
100
+ return phases
101
+
102
+ current_state = contact_states[0]
103
+ phase_start = 0
104
+
105
+ for i in range(1, len(contact_states)):
106
+ if contact_states[i] != current_state:
107
+ # Phase transition
108
+ phases.append((phase_start, i - 1, current_state))
109
+ current_state = contact_states[i]
110
+ phase_start = i
111
+
112
+ # Don't forget the last phase
113
+ phases.append((phase_start, len(contact_states) - 1, current_state))
114
+
115
+ return phases
116
+
117
+
118
+ def interpolate_threshold_crossing(
119
+ vel_before: float,
120
+ vel_after: float,
121
+ velocity_threshold: float,
122
+ ) -> float:
123
+ """
124
+ Find fractional offset where velocity crosses threshold between two frames.
125
+
126
+ Uses linear interpolation assuming velocity changes linearly between frames.
127
+
128
+ Args:
129
+ vel_before: Velocity at frame boundary N (absolute value)
130
+ vel_after: Velocity at frame boundary N+1 (absolute value)
131
+ velocity_threshold: Threshold value
132
+
133
+ Returns:
134
+ Fractional offset from frame N (0.0 to 1.0)
135
+ """
136
+ # Handle edge cases
137
+ if abs(vel_after - vel_before) < 1e-9: # Velocity not changing
138
+ return 0.5
139
+
140
+ # Linear interpolation: at what fraction t does velocity equal threshold?
141
+ # vel(t) = vel_before + t * (vel_after - vel_before)
142
+ # Solve for t when vel(t) = threshold:
143
+ # threshold = vel_before + t * (vel_after - vel_before)
144
+ # t = (threshold - vel_before) / (vel_after - vel_before)
145
+
146
+ t = (velocity_threshold - vel_before) / (vel_after - vel_before)
147
+
148
+ # Clamp to [0, 1] range
149
+ return float(max(0.0, min(1.0, t)))
150
+
151
+
152
+ def find_interpolated_phase_transitions(
153
+ foot_positions: np.ndarray,
154
+ contact_states: list[ContactState],
155
+ velocity_threshold: float,
156
+ smoothing_window: int = 5,
157
+ ) -> list[tuple[float, float, ContactState]]:
158
+ """
159
+ Find contact phases with sub-frame interpolation for precise timing.
160
+
161
+ Uses derivative-based velocity from smoothed trajectory for interpolation.
162
+ This provides much smoother velocity estimates than frame-to-frame differences,
163
+ leading to more accurate threshold crossing detection.
164
+
165
+ Args:
166
+ foot_positions: Array of foot y-positions (normalized, 0-1)
167
+ contact_states: List of ContactState for each frame
168
+ velocity_threshold: Threshold used for contact detection
169
+ smoothing_window: Window size for velocity smoothing (must be odd)
170
+
171
+ Returns:
172
+ List of (start_frame, end_frame, state) tuples with fractional frame indices
173
+ """
174
+ # First get integer frame phases
175
+ phases = find_contact_phases(contact_states)
176
+ if not phases or len(foot_positions) < 2:
177
+ return []
178
+
179
+ # Compute velocities from derivative of smoothed trajectory
180
+ # This gives much smoother velocity estimates than simple frame differences
181
+ velocities = compute_velocity_from_derivative(
182
+ foot_positions, window_length=smoothing_window, polyorder=2
183
+ )
184
+
185
+ interpolated_phases: list[tuple[float, float, ContactState]] = []
186
+
187
+ for start_idx, end_idx, state in phases:
188
+ start_frac = float(start_idx)
189
+ end_frac = float(end_idx)
190
+
191
+ # Interpolate start boundary (transition INTO this phase)
192
+ if start_idx > 0 and start_idx < len(velocities):
193
+ vel_before = velocities[start_idx - 1] if start_idx > 0 else velocities[start_idx]
194
+ vel_at = velocities[start_idx]
195
+
196
+ # Check if we're crossing the threshold at this boundary
197
+ if state == ContactState.ON_GROUND:
198
+ # Transition air→ground: velocity dropping below threshold
199
+ if vel_before > velocity_threshold > vel_at:
200
+ # Interpolate between start_idx-1 and start_idx
201
+ offset = interpolate_threshold_crossing(
202
+ vel_before, vel_at, velocity_threshold
203
+ )
204
+ start_frac = (start_idx - 1) + offset
205
+ elif state == ContactState.IN_AIR:
206
+ # Transition ground→air: velocity rising above threshold
207
+ if vel_before < velocity_threshold < vel_at:
208
+ # Interpolate between start_idx-1 and start_idx
209
+ offset = interpolate_threshold_crossing(
210
+ vel_before, vel_at, velocity_threshold
211
+ )
212
+ start_frac = (start_idx - 1) + offset
213
+
214
+ # Interpolate end boundary (transition OUT OF this phase)
215
+ if end_idx < len(foot_positions) - 1 and end_idx + 1 < len(velocities):
216
+ vel_at = velocities[end_idx]
217
+ vel_after = velocities[end_idx + 1]
218
+
219
+ # Check if we're crossing the threshold at this boundary
220
+ if state == ContactState.ON_GROUND:
221
+ # Transition ground→air: velocity rising above threshold
222
+ if vel_at < velocity_threshold < vel_after:
223
+ # Interpolate between end_idx and end_idx+1
224
+ offset = interpolate_threshold_crossing(
225
+ vel_at, vel_after, velocity_threshold
226
+ )
227
+ end_frac = end_idx + offset
228
+ elif state == ContactState.IN_AIR:
229
+ # Transition air→ground: velocity dropping below threshold
230
+ if vel_at > velocity_threshold > vel_after:
231
+ # Interpolate between end_idx and end_idx+1
232
+ offset = interpolate_threshold_crossing(
233
+ vel_at, vel_after, velocity_threshold
234
+ )
235
+ end_frac = end_idx + offset
236
+
237
+ interpolated_phases.append((start_frac, end_frac, state))
238
+
239
+ return interpolated_phases
240
+
241
+
242
+ def refine_transition_with_curvature(
243
+ foot_positions: np.ndarray,
244
+ estimated_frame: float,
245
+ transition_type: str,
246
+ search_window: int = 3,
247
+ smoothing_window: int = 5,
248
+ ) -> float:
249
+ """
250
+ Refine phase transition timing using trajectory curvature analysis.
251
+
252
+ Looks for characteristic acceleration patterns near estimated transition:
253
+ - Landing: Large acceleration spike (rapid deceleration on impact)
254
+ - Takeoff: Acceleration change (transition from static to upward motion)
255
+
256
+ Args:
257
+ foot_positions: Array of foot y-positions (normalized, 0-1)
258
+ estimated_frame: Initial estimate of transition frame (from velocity)
259
+ transition_type: Type of transition ("landing" or "takeoff")
260
+ search_window: Number of frames to search around estimate
261
+ smoothing_window: Window size for acceleration computation
262
+
263
+ Returns:
264
+ Refined fractional frame index
265
+ """
266
+ if len(foot_positions) < smoothing_window:
267
+ return estimated_frame
268
+
269
+ # Compute acceleration (second derivative)
270
+ acceleration = compute_acceleration_from_derivative(
271
+ foot_positions, window_length=smoothing_window, polyorder=2
272
+ )
273
+
274
+ # Define search range around estimated transition
275
+ est_int = int(estimated_frame)
276
+ search_start = max(0, est_int - search_window)
277
+ search_end = min(len(acceleration), est_int + search_window + 1)
278
+
279
+ if search_end <= search_start:
280
+ return estimated_frame
281
+
282
+ # Extract acceleration in search window
283
+ accel_window = acceleration[search_start:search_end]
284
+
285
+ if len(accel_window) == 0:
286
+ return estimated_frame
287
+
288
+ if transition_type == "landing":
289
+ # Landing: Look for large magnitude acceleration (impact deceleration)
290
+ # Find frame with maximum absolute acceleration
291
+ peak_idx = np.argmax(np.abs(accel_window))
292
+ refined_frame = float(search_start + peak_idx)
293
+
294
+ elif transition_type == "takeoff":
295
+ # Takeoff: Look for acceleration magnitude change
296
+ # Find frame with large acceleration change (derivative of acceleration)
297
+ if len(accel_window) < 2:
298
+ return estimated_frame
299
+
300
+ accel_diff = np.abs(np.diff(accel_window))
301
+ peak_idx = np.argmax(accel_diff)
302
+ refined_frame = float(search_start + peak_idx)
303
+
304
+ else:
305
+ return estimated_frame
306
+
307
+ # Blend with original estimate (don't stray too far)
308
+ # 70% curvature-based, 30% velocity-based
309
+ blend_factor = 0.7
310
+ refined_frame = (
311
+ blend_factor * refined_frame + (1 - blend_factor) * estimated_frame
312
+ )
313
+
314
+ return refined_frame
315
+
316
+
317
+ def find_interpolated_phase_transitions_with_curvature(
318
+ foot_positions: np.ndarray,
319
+ contact_states: list[ContactState],
320
+ velocity_threshold: float,
321
+ smoothing_window: int = 5,
322
+ use_curvature: bool = True,
323
+ ) -> list[tuple[float, float, ContactState]]:
324
+ """
325
+ Find contact phases with sub-frame interpolation and curvature refinement.
326
+
327
+ Combines three methods for maximum accuracy:
328
+ 1. Velocity thresholding (coarse integer frame detection)
329
+ 2. Velocity interpolation (sub-frame precision)
330
+ 3. Curvature analysis (refinement based on acceleration patterns)
331
+
332
+ Args:
333
+ foot_positions: Array of foot y-positions (normalized, 0-1)
334
+ contact_states: List of ContactState for each frame
335
+ velocity_threshold: Threshold used for contact detection
336
+ smoothing_window: Window size for velocity/acceleration smoothing
337
+ use_curvature: Whether to apply curvature-based refinement
338
+
339
+ Returns:
340
+ List of (start_frame, end_frame, state) tuples with fractional frame indices
341
+ """
342
+ # Get interpolated phases using velocity
343
+ interpolated_phases = find_interpolated_phase_transitions(
344
+ foot_positions, contact_states, velocity_threshold, smoothing_window
345
+ )
346
+
347
+ if not use_curvature or len(interpolated_phases) == 0:
348
+ return interpolated_phases
349
+
350
+ # Refine phase boundaries using curvature analysis
351
+ refined_phases: list[tuple[float, float, ContactState]] = []
352
+
353
+ for start_frac, end_frac, state in interpolated_phases:
354
+ refined_start = start_frac
355
+ refined_end = end_frac
356
+
357
+ if state == ContactState.ON_GROUND:
358
+ # Refine landing (start of ground contact)
359
+ refined_start = refine_transition_with_curvature(
360
+ foot_positions,
361
+ start_frac,
362
+ "landing",
363
+ search_window=3,
364
+ smoothing_window=smoothing_window,
365
+ )
366
+ # Refine takeoff (end of ground contact)
367
+ refined_end = refine_transition_with_curvature(
368
+ foot_positions,
369
+ end_frac,
370
+ "takeoff",
371
+ search_window=3,
372
+ smoothing_window=smoothing_window,
373
+ )
374
+
375
+ elif state == ContactState.IN_AIR:
376
+ # For flight phases, takeoff is at start, landing is at end
377
+ refined_start = refine_transition_with_curvature(
378
+ foot_positions,
379
+ start_frac,
380
+ "takeoff",
381
+ search_window=3,
382
+ smoothing_window=smoothing_window,
383
+ )
384
+ refined_end = refine_transition_with_curvature(
385
+ foot_positions,
386
+ end_frac,
387
+ "landing",
388
+ search_window=3,
389
+ smoothing_window=smoothing_window,
390
+ )
391
+
392
+ refined_phases.append((refined_start, refined_end, state))
393
+
394
+ return refined_phases
395
+
396
+
397
+ def compute_average_foot_position(
398
+ landmarks: dict[str, tuple[float, float, float]],
399
+ ) -> tuple[float, float]:
400
+ """
401
+ Compute average foot position from ankle and foot landmarks.
402
+
403
+ Args:
404
+ landmarks: Dictionary of landmark positions
405
+
406
+ Returns:
407
+ (x, y) average foot position in normalized coordinates
408
+ """
409
+ foot_keys = [
410
+ "left_ankle",
411
+ "right_ankle",
412
+ "left_heel",
413
+ "right_heel",
414
+ "left_foot_index",
415
+ "right_foot_index",
416
+ ]
417
+
418
+ x_positions = []
419
+ y_positions = []
420
+
421
+ for key in foot_keys:
422
+ if key in landmarks:
423
+ x, y, visibility = landmarks[key]
424
+ if visibility > 0.5: # Only use visible landmarks
425
+ x_positions.append(x)
426
+ y_positions.append(y)
427
+
428
+ if not x_positions:
429
+ return (0.5, 0.5) # Default to center if no visible feet
430
+
431
+ return (float(np.mean(x_positions)), float(np.mean(y_positions)))