kinemotion 0.17.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,179 @@
1
+ """Debug overlay rendering for drop jump analysis."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
7
+ from ..core.pose import compute_center_of_mass
8
+ from .analysis import ContactState, compute_average_foot_position
9
+ from .kinematics import DropJumpMetrics
10
+
11
+
12
+ class DebugOverlayRenderer(BaseDebugOverlayRenderer):
13
+ """Renders debug information on video frames."""
14
+
15
+ def _draw_com_visualization(
16
+ self,
17
+ frame: np.ndarray,
18
+ landmarks: dict[str, tuple[float, float, float]],
19
+ contact_state: ContactState,
20
+ ) -> None:
21
+ """Draw center of mass visualization on frame."""
22
+ com_x, com_y, _ = compute_center_of_mass(landmarks)
23
+ px = int(com_x * self.width)
24
+ py = int(com_y * self.height)
25
+
26
+ color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
27
+ cv2.circle(frame, (px, py), 15, color, -1)
28
+ cv2.circle(frame, (px, py), 17, (255, 255, 255), 2)
29
+
30
+ # Draw hip midpoint reference
31
+ if "left_hip" in landmarks and "right_hip" in landmarks:
32
+ lh_x, lh_y, _ = landmarks["left_hip"]
33
+ rh_x, rh_y, _ = landmarks["right_hip"]
34
+ hip_x = int((lh_x + rh_x) / 2 * self.width)
35
+ hip_y = int((lh_y + rh_y) / 2 * self.height)
36
+ cv2.circle(frame, (hip_x, hip_y), 8, (255, 165, 0), -1)
37
+ cv2.line(frame, (hip_x, hip_y), (px, py), (255, 165, 0), 2)
38
+
39
+ def _draw_foot_visualization(
40
+ self,
41
+ frame: np.ndarray,
42
+ landmarks: dict[str, tuple[float, float, float]],
43
+ contact_state: ContactState,
44
+ ) -> None:
45
+ """Draw foot position visualization on frame."""
46
+ foot_x, foot_y = compute_average_foot_position(landmarks)
47
+ px = int(foot_x * self.width)
48
+ py = int(foot_y * self.height)
49
+
50
+ color = (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
51
+ cv2.circle(frame, (px, py), 10, color, -1)
52
+
53
+ # Draw individual foot landmarks
54
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
55
+ for key in foot_keys:
56
+ if key in landmarks:
57
+ x, y, vis = landmarks[key]
58
+ if vis > 0.5:
59
+ lx = int(x * self.width)
60
+ ly = int(y * self.height)
61
+ cv2.circle(frame, (lx, ly), 5, (255, 255, 0), -1)
62
+
63
+ def _draw_phase_labels(
64
+ self,
65
+ frame: np.ndarray,
66
+ frame_idx: int,
67
+ metrics: DropJumpMetrics,
68
+ ) -> None:
69
+ """Draw phase labels (ground contact, flight, peak) on frame."""
70
+ y_offset = 110
71
+
72
+ # Ground contact phase
73
+ if (
74
+ metrics.contact_start_frame
75
+ and metrics.contact_end_frame
76
+ and metrics.contact_start_frame <= frame_idx <= metrics.contact_end_frame
77
+ ):
78
+ cv2.putText(
79
+ frame,
80
+ "GROUND CONTACT",
81
+ (10, y_offset),
82
+ cv2.FONT_HERSHEY_SIMPLEX,
83
+ 0.7,
84
+ (0, 255, 0),
85
+ 2,
86
+ )
87
+ y_offset += 40
88
+
89
+ # Flight phase
90
+ if (
91
+ metrics.flight_start_frame
92
+ and metrics.flight_end_frame
93
+ and metrics.flight_start_frame <= frame_idx <= metrics.flight_end_frame
94
+ ):
95
+ cv2.putText(
96
+ frame,
97
+ "FLIGHT PHASE",
98
+ (10, y_offset),
99
+ cv2.FONT_HERSHEY_SIMPLEX,
100
+ 0.7,
101
+ (0, 0, 255),
102
+ 2,
103
+ )
104
+ y_offset += 40
105
+
106
+ # Peak height
107
+ if metrics.peak_height_frame == frame_idx:
108
+ cv2.putText(
109
+ frame,
110
+ "PEAK HEIGHT",
111
+ (10, y_offset),
112
+ cv2.FONT_HERSHEY_SIMPLEX,
113
+ 0.7,
114
+ (255, 0, 255),
115
+ 2,
116
+ )
117
+
118
+ def render_frame(
119
+ self,
120
+ frame: np.ndarray,
121
+ landmarks: dict[str, tuple[float, float, float]] | None,
122
+ contact_state: ContactState,
123
+ frame_idx: int,
124
+ metrics: DropJumpMetrics | None = None,
125
+ use_com: bool = False,
126
+ ) -> np.ndarray:
127
+ """
128
+ Render debug overlay on frame.
129
+
130
+ Args:
131
+ frame: Original video frame
132
+ landmarks: Pose landmarks for this frame
133
+ contact_state: Ground contact state
134
+ frame_idx: Current frame index
135
+ metrics: Drop-jump metrics (optional)
136
+ use_com: Whether to visualize CoM instead of feet (optional)
137
+
138
+ Returns:
139
+ Frame with debug overlay
140
+ """
141
+ annotated = frame.copy()
142
+
143
+ # Draw landmarks
144
+ if landmarks:
145
+ if use_com:
146
+ self._draw_com_visualization(annotated, landmarks, contact_state)
147
+ else:
148
+ self._draw_foot_visualization(annotated, landmarks, contact_state)
149
+
150
+ # Draw contact state
151
+ state_color = (
152
+ (0, 255, 0) if contact_state == ContactState.ON_GROUND else (0, 0, 255)
153
+ )
154
+ cv2.putText(
155
+ annotated,
156
+ f"State: {contact_state.value}",
157
+ (10, 30),
158
+ cv2.FONT_HERSHEY_SIMPLEX,
159
+ 1,
160
+ state_color,
161
+ 2,
162
+ )
163
+
164
+ # Draw frame number
165
+ cv2.putText(
166
+ annotated,
167
+ f"Frame: {frame_idx}",
168
+ (10, 70),
169
+ cv2.FONT_HERSHEY_SIMPLEX,
170
+ 0.7,
171
+ (255, 255, 255),
172
+ 2,
173
+ )
174
+
175
+ # Draw phase labels
176
+ if metrics:
177
+ self._draw_phase_labels(annotated, frame_idx, metrics)
178
+
179
+ return annotated
@@ -0,0 +1,446 @@
1
+ """Kinematic calculations for drop-jump metrics."""
2
+
3
+ import numpy as np
4
+
5
+ from ..core.smoothing import compute_acceleration_from_derivative
6
+ from .analysis import (
7
+ ContactState,
8
+ detect_drop_start,
9
+ find_contact_phases,
10
+ find_interpolated_phase_transitions_with_curvature,
11
+ find_landing_from_acceleration,
12
+ )
13
+
14
+
15
+ class DropJumpMetrics:
16
+ """Container for drop-jump analysis metrics."""
17
+
18
+ def __init__(self) -> None:
19
+ self.ground_contact_time: float | None = None
20
+ self.flight_time: float | None = None
21
+ self.jump_height: float | None = None
22
+ self.jump_height_kinematic: float | None = None # From flight time
23
+ self.jump_height_trajectory: float | None = None # From position tracking
24
+ self.contact_start_frame: int | None = None
25
+ self.contact_end_frame: int | None = None
26
+ self.flight_start_frame: int | None = None
27
+ self.flight_end_frame: int | None = None
28
+ self.peak_height_frame: int | None = None
29
+ # Fractional frame indices for sub-frame precision timing
30
+ self.contact_start_frame_precise: float | None = None
31
+ self.contact_end_frame_precise: float | None = None
32
+ self.flight_start_frame_precise: float | None = None
33
+ self.flight_end_frame_precise: float | None = None
34
+
35
+ def to_dict(self) -> dict:
36
+ """Convert metrics to dictionary for JSON output."""
37
+ return {
38
+ "ground_contact_time_ms": (
39
+ round(self.ground_contact_time * 1000, 2)
40
+ if self.ground_contact_time is not None
41
+ else None
42
+ ),
43
+ "flight_time_ms": (
44
+ round(self.flight_time * 1000, 2)
45
+ if self.flight_time is not None
46
+ else None
47
+ ),
48
+ "jump_height_m": (
49
+ round(self.jump_height, 3) if self.jump_height is not None else None
50
+ ),
51
+ "jump_height_kinematic_m": (
52
+ round(self.jump_height_kinematic, 3)
53
+ if self.jump_height_kinematic is not None
54
+ else None
55
+ ),
56
+ "jump_height_trajectory_normalized": (
57
+ round(self.jump_height_trajectory, 4)
58
+ if self.jump_height_trajectory is not None
59
+ else None
60
+ ),
61
+ "contact_start_frame": (
62
+ int(self.contact_start_frame)
63
+ if self.contact_start_frame is not None
64
+ else None
65
+ ),
66
+ "contact_end_frame": (
67
+ int(self.contact_end_frame)
68
+ if self.contact_end_frame is not None
69
+ else None
70
+ ),
71
+ "flight_start_frame": (
72
+ int(self.flight_start_frame)
73
+ if self.flight_start_frame is not None
74
+ else None
75
+ ),
76
+ "flight_end_frame": (
77
+ int(self.flight_end_frame)
78
+ if self.flight_end_frame is not None
79
+ else None
80
+ ),
81
+ "peak_height_frame": (
82
+ int(self.peak_height_frame)
83
+ if self.peak_height_frame is not None
84
+ else None
85
+ ),
86
+ "contact_start_frame_precise": (
87
+ round(self.contact_start_frame_precise, 3)
88
+ if self.contact_start_frame_precise is not None
89
+ else None
90
+ ),
91
+ "contact_end_frame_precise": (
92
+ round(self.contact_end_frame_precise, 3)
93
+ if self.contact_end_frame_precise is not None
94
+ else None
95
+ ),
96
+ "flight_start_frame_precise": (
97
+ round(self.flight_start_frame_precise, 3)
98
+ if self.flight_start_frame_precise is not None
99
+ else None
100
+ ),
101
+ "flight_end_frame_precise": (
102
+ round(self.flight_end_frame_precise, 3)
103
+ if self.flight_end_frame_precise is not None
104
+ else None
105
+ ),
106
+ }
107
+
108
+
109
+ def _determine_drop_start_frame(
110
+ drop_start_frame: int | None,
111
+ foot_y_positions: np.ndarray,
112
+ fps: float,
113
+ smoothing_window: int,
114
+ ) -> int:
115
+ """Determine the drop start frame for analysis.
116
+
117
+ Args:
118
+ drop_start_frame: Manual drop start frame or None for auto-detection
119
+ foot_y_positions: Vertical positions array
120
+ fps: Video frame rate
121
+ smoothing_window: Smoothing window size
122
+
123
+ Returns:
124
+ Drop start frame (0 if not detected/provided)
125
+ """
126
+ if drop_start_frame is None:
127
+ # Auto-detect where drop jump actually starts (skip initial stationary period)
128
+ return detect_drop_start(
129
+ foot_y_positions,
130
+ fps,
131
+ min_stationary_duration=0.5,
132
+ position_change_threshold=0.005,
133
+ smoothing_window=smoothing_window,
134
+ )
135
+ return drop_start_frame
136
+
137
+
138
+ def _filter_phases_after_drop(
139
+ phases: list[tuple[int, int, ContactState]],
140
+ interpolated_phases: list[tuple[float, float, ContactState]],
141
+ drop_start_frame: int,
142
+ ) -> tuple[
143
+ list[tuple[int, int, ContactState]], list[tuple[float, float, ContactState]]
144
+ ]:
145
+ """Filter phases to only include those after drop start.
146
+
147
+ Args:
148
+ phases: Integer frame phases
149
+ interpolated_phases: Sub-frame precision phases
150
+ drop_start_frame: Frame where drop starts
151
+
152
+ Returns:
153
+ Tuple of (filtered_phases, filtered_interpolated_phases)
154
+ """
155
+ if drop_start_frame <= 0:
156
+ return phases, interpolated_phases
157
+
158
+ filtered_phases = [
159
+ (start, end, state) for start, end, state in phases if end >= drop_start_frame
160
+ ]
161
+ filtered_interpolated = [
162
+ (start, end, state)
163
+ for start, end, state in interpolated_phases
164
+ if end >= drop_start_frame
165
+ ]
166
+ return filtered_phases, filtered_interpolated
167
+
168
+
169
+ def _identify_main_contact_phase(
170
+ phases: list[tuple[int, int, ContactState]],
171
+ ground_phases: list[tuple[int, int, int]],
172
+ air_phases_indexed: list[tuple[int, int, int]],
173
+ foot_y_positions: np.ndarray,
174
+ ) -> tuple[int, int, bool]:
175
+ """Identify the main contact phase and determine if it's a drop jump.
176
+
177
+ Args:
178
+ phases: All phase tuples
179
+ ground_phases: Ground phases with indices
180
+ air_phases_indexed: Air phases with indices
181
+ foot_y_positions: Vertical position array
182
+
183
+ Returns:
184
+ Tuple of (contact_start, contact_end, is_drop_jump)
185
+ """
186
+ # Initialize with first ground phase as fallback
187
+ contact_start, contact_end = ground_phases[0][0], ground_phases[0][1]
188
+ is_drop_jump = False
189
+
190
+ # Detect if this is a drop jump or regular jump
191
+ if air_phases_indexed and len(ground_phases) >= 2:
192
+ first_ground_start, first_ground_end, first_ground_idx = ground_phases[0]
193
+ first_air_idx = air_phases_indexed[0][2]
194
+
195
+ # Find ground phase after first air phase
196
+ ground_after_air = [
197
+ (start, end, idx)
198
+ for start, end, idx in ground_phases
199
+ if idx > first_air_idx
200
+ ]
201
+
202
+ if ground_after_air and first_ground_idx < first_air_idx:
203
+ # Check if first ground is at higher elevation (lower y) than ground after air
204
+ first_ground_y = float(
205
+ np.mean(foot_y_positions[first_ground_start : first_ground_end + 1])
206
+ )
207
+ second_ground_start, second_ground_end, _ = ground_after_air[0]
208
+ second_ground_y = float(
209
+ np.mean(foot_y_positions[second_ground_start : second_ground_end + 1])
210
+ )
211
+
212
+ # If first ground is significantly higher (>5% of frame), it's a drop jump
213
+ if second_ground_y - first_ground_y > 0.05:
214
+ is_drop_jump = True
215
+ contact_start, contact_end = second_ground_start, second_ground_end
216
+
217
+ if not is_drop_jump:
218
+ # Regular jump: use longest ground contact phase
219
+ contact_start, contact_end = max(
220
+ [(s, e) for s, e, _ in ground_phases], key=lambda p: p[1] - p[0]
221
+ )
222
+
223
+ return contact_start, contact_end, is_drop_jump
224
+
225
+
226
+ def _find_precise_phase_timing(
227
+ contact_start: int,
228
+ contact_end: int,
229
+ interpolated_phases: list[tuple[float, float, ContactState]],
230
+ ) -> tuple[float, float]:
231
+ """Find precise sub-frame timing for contact phase.
232
+
233
+ Args:
234
+ contact_start: Integer contact start frame
235
+ contact_end: Integer contact end frame
236
+ interpolated_phases: Sub-frame precision phases
237
+
238
+ Returns:
239
+ Tuple of (contact_start_frac, contact_end_frac)
240
+ """
241
+ contact_start_frac = float(contact_start)
242
+ contact_end_frac = float(contact_end)
243
+
244
+ # Find the matching ground phase in interpolated_phases
245
+ for start_frac, end_frac, state in interpolated_phases:
246
+ if (
247
+ state == ContactState.ON_GROUND
248
+ and int(start_frac) <= contact_start <= int(end_frac) + 1
249
+ and int(start_frac) <= contact_end <= int(end_frac) + 1
250
+ ):
251
+ contact_start_frac = start_frac
252
+ contact_end_frac = end_frac
253
+ break
254
+
255
+ return contact_start_frac, contact_end_frac
256
+
257
+
258
+ def _analyze_flight_phase(
259
+ metrics: DropJumpMetrics,
260
+ phases: list[tuple[int, int, ContactState]],
261
+ interpolated_phases: list[tuple[float, float, ContactState]],
262
+ contact_end: int,
263
+ foot_y_positions: np.ndarray,
264
+ fps: float,
265
+ smoothing_window: int,
266
+ polyorder: int,
267
+ ) -> None:
268
+ """Analyze flight phase and calculate jump height metrics.
269
+
270
+ Uses acceleration-based landing detection (like CMJ) for accurate flight time,
271
+ then calculates jump height using kinematic formula h = g*t²/8.
272
+
273
+ Args:
274
+ metrics: DropJumpMetrics object to populate
275
+ phases: All phase tuples
276
+ interpolated_phases: Sub-frame precision phases
277
+ contact_end: End of contact phase
278
+ foot_y_positions: Vertical position array
279
+ fps: Video frame rate
280
+ smoothing_window: Window size for acceleration computation
281
+ polyorder: Polynomial order for Savitzky-Golay filter
282
+ """
283
+ # Find takeoff frame (end of ground contact)
284
+ flight_start = contact_end
285
+
286
+ # Compute accelerations for landing detection
287
+ accelerations = compute_acceleration_from_derivative(
288
+ foot_y_positions, window_length=smoothing_window, polyorder=polyorder
289
+ )
290
+
291
+ # Use acceleration-based landing detection (like CMJ)
292
+ # This finds the actual ground impact, not just when velocity drops
293
+ flight_end = find_landing_from_acceleration(
294
+ foot_y_positions, accelerations, flight_start, fps, search_duration=0.7
295
+ )
296
+
297
+ # Store integer frame indices
298
+ metrics.flight_start_frame = flight_start
299
+ metrics.flight_end_frame = flight_end
300
+
301
+ # Find precise sub-frame timing for takeoff
302
+ flight_start_frac = float(flight_start)
303
+ flight_end_frac = float(flight_end)
304
+
305
+ for start_frac, end_frac, state in interpolated_phases:
306
+ if (
307
+ state == ContactState.ON_GROUND
308
+ and int(start_frac) <= flight_start <= int(end_frac) + 1
309
+ ):
310
+ # Use end of ground contact as precise takeoff
311
+ flight_start_frac = end_frac
312
+ break
313
+
314
+ # Calculate flight time
315
+ flight_frames_precise = flight_end_frac - flight_start_frac
316
+ metrics.flight_time = flight_frames_precise / fps
317
+ metrics.flight_start_frame_precise = flight_start_frac
318
+ metrics.flight_end_frame_precise = flight_end_frac
319
+
320
+ # Calculate jump height using kinematic method (like CMJ)
321
+ # h = g * t² / 8
322
+ g = 9.81 # m/s^2
323
+ jump_height_kinematic = (g * metrics.flight_time**2) / 8
324
+
325
+ # Always use kinematic method for jump height (like CMJ)
326
+ metrics.jump_height = jump_height_kinematic
327
+ metrics.jump_height_kinematic = jump_height_kinematic
328
+
329
+ # Calculate trajectory-based height for reference
330
+ takeoff_position = foot_y_positions[flight_start]
331
+ flight_positions = foot_y_positions[flight_start : flight_end + 1]
332
+
333
+ if len(flight_positions) > 0:
334
+ peak_idx = np.argmin(flight_positions)
335
+ metrics.peak_height_frame = int(flight_start + peak_idx)
336
+ peak_position = np.min(flight_positions)
337
+
338
+ height_normalized = float(takeoff_position - peak_position)
339
+ metrics.jump_height_trajectory = height_normalized
340
+
341
+
342
+ def calculate_drop_jump_metrics(
343
+ contact_states: list[ContactState],
344
+ foot_y_positions: np.ndarray,
345
+ fps: float,
346
+ drop_start_frame: int | None = None,
347
+ velocity_threshold: float = 0.02,
348
+ smoothing_window: int = 5,
349
+ polyorder: int = 2,
350
+ use_curvature: bool = True,
351
+ ) -> DropJumpMetrics:
352
+ """
353
+ Calculate drop-jump metrics from contact states and positions.
354
+
355
+ Jump height is calculated from flight time using kinematic formula: h = g × t² / 8
356
+
357
+ Args:
358
+ contact_states: Contact state for each frame
359
+ foot_y_positions: Vertical positions of feet (normalized 0-1)
360
+ fps: Video frame rate
361
+ drop_start_frame: Optional manual drop start frame
362
+ velocity_threshold: Velocity threshold used for contact detection (for interpolation)
363
+ smoothing_window: Window size for velocity/acceleration smoothing (must be odd)
364
+ polyorder: Polynomial order for Savitzky-Golay filter (default: 2)
365
+ use_curvature: Whether to use curvature analysis for refining transitions
366
+
367
+ Returns:
368
+ DropJumpMetrics object with calculated values
369
+ """
370
+ metrics = DropJumpMetrics()
371
+
372
+ # Determine drop start frame
373
+ drop_start_frame_value = _determine_drop_start_frame(
374
+ drop_start_frame, foot_y_positions, fps, smoothing_window
375
+ )
376
+
377
+ # Find contact phases
378
+ phases = find_contact_phases(contact_states)
379
+ interpolated_phases = find_interpolated_phase_transitions_with_curvature(
380
+ foot_y_positions,
381
+ contact_states,
382
+ velocity_threshold,
383
+ smoothing_window,
384
+ polyorder,
385
+ use_curvature,
386
+ )
387
+
388
+ if not phases:
389
+ return metrics
390
+
391
+ # Filter phases to only include those after drop start
392
+ phases, interpolated_phases = _filter_phases_after_drop(
393
+ phases, interpolated_phases, drop_start_frame_value
394
+ )
395
+
396
+ if not phases:
397
+ return metrics
398
+
399
+ # Separate ground and air phases
400
+ ground_phases = [
401
+ (start, end, i)
402
+ for i, (start, end, state) in enumerate(phases)
403
+ if state == ContactState.ON_GROUND
404
+ ]
405
+ air_phases_indexed = [
406
+ (start, end, i)
407
+ for i, (start, end, state) in enumerate(phases)
408
+ if state == ContactState.IN_AIR
409
+ ]
410
+
411
+ if not ground_phases:
412
+ return metrics
413
+
414
+ # Identify main contact phase
415
+ contact_start, contact_end, _ = _identify_main_contact_phase(
416
+ phases, ground_phases, air_phases_indexed, foot_y_positions
417
+ )
418
+
419
+ # Store integer frame indices
420
+ metrics.contact_start_frame = contact_start
421
+ metrics.contact_end_frame = contact_end
422
+
423
+ # Find precise timing for contact phase
424
+ contact_start_frac, contact_end_frac = _find_precise_phase_timing(
425
+ contact_start, contact_end, interpolated_phases
426
+ )
427
+
428
+ # Calculate ground contact time
429
+ contact_frames_precise = contact_end_frac - contact_start_frac
430
+ metrics.ground_contact_time = contact_frames_precise / fps
431
+ metrics.contact_start_frame_precise = contact_start_frac
432
+ metrics.contact_end_frame_precise = contact_end_frac
433
+
434
+ # Analyze flight phase and calculate jump height
435
+ _analyze_flight_phase(
436
+ metrics,
437
+ phases,
438
+ interpolated_phases,
439
+ contact_end,
440
+ foot_y_positions,
441
+ fps,
442
+ smoothing_window,
443
+ polyorder,
444
+ )
445
+
446
+ return metrics
kinemotion/py.typed ADDED
File without changes