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
@@ -48,10 +48,18 @@ from .analysis import (
48
48
  detect_ground_contact,
49
49
  find_contact_phases,
50
50
  )
51
- from .debug_overlay import DebugOverlayRenderer
51
+ from .debug_overlay import DropJumpDebugOverlayRenderer
52
52
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
53
53
  from .metrics_validator import DropJumpMetricsValidator
54
54
 
55
+ __all__ = [
56
+ "AnalysisOverrides",
57
+ "DropJumpVideoConfig",
58
+ "DropJumpVideoResult",
59
+ "process_dropjump_video",
60
+ "process_dropjump_videos_bulk",
61
+ ]
62
+
55
63
 
56
64
  @dataclass
57
65
  class AnalysisOverrides:
@@ -305,7 +313,6 @@ def _tune_and_smooth(
305
313
  characteristics = analyze_video_sample(landmarks_sequence, video_fps, frame_count)
306
314
  params = auto_tune_parameters(characteristics, quality_preset)
307
315
 
308
- # Apply overrides if provided
309
316
  if overrides:
310
317
  params = apply_expert_overrides(
311
318
  params,
@@ -314,14 +321,6 @@ def _tune_and_smooth(
314
321
  overrides.min_contact_frames,
315
322
  overrides.visibility_threshold,
316
323
  )
317
- else:
318
- params = apply_expert_overrides(
319
- params,
320
- None,
321
- None,
322
- None,
323
- None,
324
- )
325
324
 
326
325
  smoothed_landmarks = apply_smoothing(landmarks_sequence, params, verbose, timer)
327
326
 
@@ -440,16 +439,13 @@ def _generate_debug_video(
440
439
  timer = timer or NULL_TIMER
441
440
  debug_h, debug_w = frames[0].shape[:2]
442
441
 
443
- if video_fps > 30:
444
- debug_fps = video_fps / (video_fps / 30.0)
445
- else:
446
- debug_fps = video_fps
447
-
442
+ # Calculate debug FPS: cap at 30 for high-fps videos, use step if frame-sparse
443
+ debug_fps = min(video_fps, 30.0)
448
444
  if len(frames) < len(smoothed_landmarks):
449
445
  step = max(1, int(video_fps / 30.0))
450
446
  debug_fps = video_fps / step
451
447
 
452
- def _render_frames(renderer: DebugOverlayRenderer) -> None:
448
+ def _render_frames(renderer: DropJumpDebugOverlayRenderer) -> None:
453
449
  for frame, idx in zip(frames, frame_indices, strict=True):
454
450
  annotated = renderer.render_frame(
455
451
  frame,
@@ -461,7 +457,7 @@ def _generate_debug_video(
461
457
  )
462
458
  renderer.write_frame(annotated)
463
459
 
464
- renderer_context = DebugOverlayRenderer(
460
+ renderer_context = DropJumpDebugOverlayRenderer(
465
461
  output_video,
466
462
  debug_w,
467
463
  debug_h,
@@ -5,6 +5,7 @@ import json
5
5
  import sys
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
+ from typing import TYPE_CHECKING
8
9
 
9
10
  import click
10
11
 
@@ -19,6 +20,9 @@ from .api import (
19
20
  process_dropjump_videos_bulk,
20
21
  )
21
22
 
23
+ if TYPE_CHECKING:
24
+ from .api import AnalysisOverrides
25
+
22
26
 
23
27
  @dataclass
24
28
  class AnalysisParameters:
@@ -225,6 +229,34 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
225
229
  )
226
230
 
227
231
 
232
+ def _create_overrides_if_needed(params: AnalysisParameters) -> "AnalysisOverrides | None":
233
+ """Create AnalysisOverrides if any override parameters are set.
234
+
235
+ Args:
236
+ params: Expert parameters from CLI
237
+
238
+ Returns:
239
+ AnalysisOverrides if any relevant parameters are non-None, else None
240
+ """
241
+ from .api import AnalysisOverrides
242
+
243
+ if any(
244
+ [
245
+ params.smoothing_window is not None,
246
+ params.velocity_threshold is not None,
247
+ params.min_contact_frames is not None,
248
+ params.visibility_threshold is not None,
249
+ ]
250
+ ):
251
+ return AnalysisOverrides(
252
+ smoothing_window=params.smoothing_window,
253
+ velocity_threshold=params.velocity_threshold,
254
+ min_contact_frames=params.min_contact_frames,
255
+ visibility_threshold=params.visibility_threshold,
256
+ )
257
+ return None
258
+
259
+
228
260
  def _process_single(
229
261
  video_path: str,
230
262
  output: str | None,
@@ -237,24 +269,7 @@ def _process_single(
237
269
  click.echo(f"Analyzing video: {video_path}", err=True)
238
270
 
239
271
  try:
240
- # Create AnalysisOverrides if any expert parameters are set
241
- from .api import AnalysisOverrides
242
-
243
- overrides = None
244
- if any(
245
- [
246
- expert_params.smoothing_window is not None,
247
- expert_params.velocity_threshold is not None,
248
- expert_params.min_contact_frames is not None,
249
- expert_params.visibility_threshold is not None,
250
- ]
251
- ):
252
- overrides = AnalysisOverrides(
253
- smoothing_window=expert_params.smoothing_window,
254
- velocity_threshold=expert_params.velocity_threshold,
255
- min_contact_frames=expert_params.min_contact_frames,
256
- visibility_threshold=expert_params.visibility_threshold,
257
- )
272
+ overrides = _create_overrides_if_needed(expert_params)
258
273
 
259
274
  # Call the API function (handles all processing logic)
260
275
  metrics = process_dropjump_video(
@@ -328,24 +343,7 @@ def _create_video_configs(
328
343
  video_file, output_dir, json_output_dir
329
344
  )
330
345
 
331
- # Create AnalysisOverrides if any expert parameters are set
332
- from .api import AnalysisOverrides
333
-
334
- overrides = None
335
- if any(
336
- [
337
- expert_params.smoothing_window is not None,
338
- expert_params.velocity_threshold is not None,
339
- expert_params.min_contact_frames is not None,
340
- expert_params.visibility_threshold is not None,
341
- ]
342
- ):
343
- overrides = AnalysisOverrides(
344
- smoothing_window=expert_params.smoothing_window,
345
- velocity_threshold=expert_params.velocity_threshold,
346
- min_contact_frames=expert_params.min_contact_frames,
347
- visibility_threshold=expert_params.visibility_threshold,
348
- )
346
+ overrides = _create_overrides_if_needed(expert_params)
349
347
 
350
348
  config = DropJumpVideoConfig(
351
349
  video_path=video_file,
@@ -380,35 +378,33 @@ def _compute_batch_statistics(results: list[DropJumpVideoResult]) -> None:
380
378
  click.echo(f"Failed: {len(failed)}", err=True)
381
379
 
382
380
  if successful:
383
- # Calculate average metrics
384
- with_gct = [
385
- r for r in successful if r.metrics and r.metrics.ground_contact_time is not None
381
+ # Calculate average metrics from results with non-None values
382
+ gct_values = [
383
+ r.metrics.ground_contact_time * 1000
384
+ for r in successful
385
+ if r.metrics and r.metrics.ground_contact_time is not None
386
+ ]
387
+ flight_values = [
388
+ r.metrics.flight_time * 1000
389
+ for r in successful
390
+ if r.metrics and r.metrics.flight_time is not None
386
391
  ]
387
- with_flight = [r for r in successful if r.metrics and r.metrics.flight_time is not None]
388
- with_jump = [r for r in successful if r.metrics and r.metrics.jump_height is not None]
389
-
390
- if with_gct:
391
- avg_gct = sum(
392
- r.metrics.ground_contact_time * 1000
393
- for r in with_gct
394
- if r.metrics and r.metrics.ground_contact_time is not None
395
- ) / len(with_gct)
392
+ jump_values = [
393
+ r.metrics.jump_height
394
+ for r in successful
395
+ if r.metrics and r.metrics.jump_height is not None
396
+ ]
397
+
398
+ if gct_values:
399
+ avg_gct = sum(gct_values) / len(gct_values)
396
400
  click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
397
401
 
398
- if with_flight:
399
- avg_flight = sum(
400
- r.metrics.flight_time * 1000
401
- for r in with_flight
402
- if r.metrics and r.metrics.flight_time is not None
403
- ) / len(with_flight)
402
+ if flight_values:
403
+ avg_flight = sum(flight_values) / len(flight_values)
404
404
  click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
405
405
 
406
- if with_jump:
407
- avg_jump = sum(
408
- r.metrics.jump_height
409
- for r in with_jump
410
- if r.metrics and r.metrics.jump_height is not None
411
- ) / len(with_jump)
406
+ if jump_values:
407
+ avg_jump = sum(jump_values) / len(jump_values)
412
408
  click.echo(
413
409
  f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
414
410
  err=True,
@@ -453,38 +449,27 @@ def _create_csv_row_from_result(result: DropJumpVideoResult) -> list[str]:
453
449
  processing_time = f"{result.processing_time:.2f}"
454
450
 
455
451
  if result.success and result.metrics:
456
- return [
457
- video_name,
452
+ metrics_data = [
458
453
  _format_time_metric(result.metrics.ground_contact_time),
459
454
  _format_time_metric(result.metrics.flight_time),
460
455
  _format_distance_metric(result.metrics.jump_height),
461
- processing_time,
462
- "Success",
463
- ]
464
- else:
465
- return [
466
- video_name,
467
- "N/A",
468
- "N/A",
469
- "N/A",
470
- processing_time,
471
- f"Failed: {result.error}",
472
456
  ]
457
+ return [video_name, *metrics_data, processing_time, "Success"]
458
+
459
+ return [video_name, "N/A", "N/A", "N/A", processing_time, f"Failed: {result.error}"]
473
460
 
474
461
 
475
462
  def _write_csv_summary(
476
463
  csv_summary: str | None,
477
464
  results: list[DropJumpVideoResult],
478
- successful: list[DropJumpVideoResult],
479
465
  ) -> None:
480
466
  """Write CSV summary of batch processing results.
481
467
 
482
468
  Args:
483
469
  csv_summary: Path to CSV output file
484
470
  results: All processing results
485
- successful: Successful processing results
486
471
  """
487
- if not csv_summary or not successful:
472
+ if not csv_summary:
488
473
  return
489
474
 
490
475
  click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
@@ -558,7 +543,6 @@ def _process_batch(
558
543
  _compute_batch_statistics(results)
559
544
 
560
545
  # Export CSV summary if requested
561
- successful = [r for r in results if r.success]
562
- _write_csv_summary(csv_summary, results, successful)
546
+ _write_csv_summary(csv_summary, results)
563
547
 
564
548
  click.echo("\nBatch processing complete!", err=True)
@@ -0,0 +1,241 @@
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.overlay_constants import (
8
+ BLACK,
9
+ COM_CIRCLE_RADIUS,
10
+ COM_OUTLINE_RADIUS,
11
+ CYAN,
12
+ FOOT_CIRCLE_RADIUS,
13
+ FOOT_LANDMARK_RADIUS,
14
+ FOOT_VISIBILITY_THRESHOLD,
15
+ GREEN,
16
+ HIP_MARKER_RADIUS,
17
+ METRICS_BOX_WIDTH,
18
+ ORANGE,
19
+ PHASE_LABEL_LINE_HEIGHT,
20
+ PHASE_LABEL_START_Y,
21
+ RED,
22
+ WHITE,
23
+ Color,
24
+ LandmarkDict,
25
+ )
26
+ from ..core.pose import compute_center_of_mass
27
+ from .analysis import ContactState, compute_average_foot_position
28
+ from .kinematics import DropJumpMetrics
29
+
30
+
31
+ class DropJumpDebugOverlayRenderer(BaseDebugOverlayRenderer):
32
+ """Renders debug information on video frames."""
33
+
34
+ def _get_contact_state_color(self, contact_state: ContactState) -> Color:
35
+ """Get color based on ground contact state."""
36
+ return GREEN if contact_state == ContactState.ON_GROUND else RED
37
+
38
+ def _draw_com_visualization(
39
+ self,
40
+ frame: np.ndarray,
41
+ landmarks: LandmarkDict,
42
+ contact_state: ContactState,
43
+ ) -> None:
44
+ """Draw center of mass visualization on frame."""
45
+ com_x, com_y, _ = compute_center_of_mass(landmarks)
46
+ px, py = self._normalize_to_pixels(com_x, com_y)
47
+
48
+ color = self._get_contact_state_color(contact_state)
49
+ cv2.circle(frame, (px, py), COM_CIRCLE_RADIUS, color, -1)
50
+ cv2.circle(frame, (px, py), COM_OUTLINE_RADIUS, WHITE, 2)
51
+
52
+ # Draw hip midpoint reference
53
+ if "left_hip" in landmarks and "right_hip" in landmarks:
54
+ lh_x, lh_y, _ = landmarks["left_hip"]
55
+ rh_x, rh_y, _ = landmarks["right_hip"]
56
+ hip_x, hip_y = self._normalize_to_pixels((lh_x + rh_x) / 2, (lh_y + rh_y) / 2)
57
+ cv2.circle(frame, (hip_x, hip_y), HIP_MARKER_RADIUS, ORANGE, -1)
58
+ cv2.line(frame, (hip_x, hip_y), (px, py), ORANGE, 2)
59
+
60
+ def _draw_foot_visualization(
61
+ self,
62
+ frame: np.ndarray,
63
+ landmarks: LandmarkDict,
64
+ contact_state: ContactState,
65
+ ) -> None:
66
+ """Draw foot position visualization on frame."""
67
+ foot_x, foot_y = compute_average_foot_position(landmarks)
68
+ px, py = self._normalize_to_pixels(foot_x, foot_y)
69
+
70
+ color = self._get_contact_state_color(contact_state)
71
+ cv2.circle(frame, (px, py), FOOT_CIRCLE_RADIUS, color, -1)
72
+
73
+ # Draw individual foot landmarks
74
+ foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
75
+ for key in foot_keys:
76
+ if key in landmarks:
77
+ x, y, vis = landmarks[key]
78
+ if vis > FOOT_VISIBILITY_THRESHOLD:
79
+ lx, ly = self._normalize_to_pixels(x, y)
80
+ cv2.circle(frame, (lx, ly), FOOT_LANDMARK_RADIUS, CYAN, -1)
81
+
82
+ def _draw_phase_labels(
83
+ self,
84
+ frame: np.ndarray,
85
+ frame_idx: int,
86
+ metrics: DropJumpMetrics,
87
+ ) -> None:
88
+ """Draw phase labels (ground contact, flight, peak) on frame."""
89
+ # Phase configurations: (start_frame, end_frame, label, color)
90
+ # For range-based phases (ground contact, flight)
91
+ range_phase_configs = [
92
+ (metrics.contact_start_frame, metrics.contact_end_frame, "GROUND CONTACT", GREEN),
93
+ (metrics.flight_start_frame, metrics.flight_end_frame, "FLIGHT PHASE", RED),
94
+ ]
95
+
96
+ y_offset = PHASE_LABEL_START_Y
97
+ for start_frame, end_frame, label, color in range_phase_configs:
98
+ if start_frame and end_frame and start_frame <= frame_idx <= end_frame:
99
+ cv2.putText(
100
+ frame,
101
+ label,
102
+ (10, y_offset),
103
+ cv2.FONT_HERSHEY_SIMPLEX,
104
+ 0.7,
105
+ color,
106
+ 2,
107
+ )
108
+ y_offset += PHASE_LABEL_LINE_HEIGHT
109
+
110
+ # Single-frame indicator (peak height)
111
+ if metrics.peak_height_frame == frame_idx:
112
+ cv2.putText(
113
+ frame,
114
+ "PEAK HEIGHT",
115
+ (10, y_offset),
116
+ cv2.FONT_HERSHEY_SIMPLEX,
117
+ 0.7,
118
+ (255, 0, 255), # Magenta
119
+ 2,
120
+ )
121
+
122
+ def _draw_info_box(
123
+ self,
124
+ frame: np.ndarray,
125
+ top_left: tuple[int, int],
126
+ bottom_right: tuple[int, int],
127
+ border_color: Color,
128
+ ) -> None:
129
+ """Draw a filled box with border for displaying information."""
130
+ cv2.rectangle(frame, top_left, bottom_right, BLACK, -1)
131
+ cv2.rectangle(frame, top_left, bottom_right, border_color, 2)
132
+
133
+ def _draw_metrics_summary(
134
+ self, frame: np.ndarray, frame_idx: int, metrics: DropJumpMetrics
135
+ ) -> None:
136
+ """Draw metrics summary in bottom right after flight phase ends."""
137
+ if metrics.flight_end_frame is None or frame_idx < metrics.flight_end_frame:
138
+ return
139
+
140
+ # Build metrics text list
141
+ metrics_text: list[str] = []
142
+
143
+ if metrics.ground_contact_time is not None:
144
+ metrics_text.append(f"Contact Time: {metrics.ground_contact_time * 1000:.0f}ms")
145
+
146
+ if metrics.flight_time is not None:
147
+ metrics_text.append(f"Flight Time: {metrics.flight_time * 1000:.0f}ms")
148
+
149
+ if metrics.jump_height is not None:
150
+ metrics_text.append(f"Jump Height: {metrics.jump_height:.3f}m")
151
+
152
+ # Calculate RSI (Reactive Strength Index)
153
+ if (
154
+ metrics.jump_height is not None
155
+ and metrics.ground_contact_time is not None
156
+ and metrics.ground_contact_time > 0
157
+ ):
158
+ rsi = metrics.jump_height / metrics.ground_contact_time
159
+ metrics_text.append(f"RSI: {rsi:.2f}")
160
+
161
+ if not metrics_text:
162
+ return
163
+
164
+ # Calculate box dimensions
165
+ box_height = len(metrics_text) * 30 + 20
166
+ top_left = (self.width - METRICS_BOX_WIDTH, self.height - box_height - 10)
167
+ bottom_right = (self.width - 10, self.height - 10)
168
+
169
+ self._draw_info_box(frame, top_left, bottom_right, GREEN)
170
+
171
+ # Draw metrics text
172
+ text_x = self.width - METRICS_BOX_WIDTH + 10
173
+ text_y = self.height - box_height + 10
174
+ for text in metrics_text:
175
+ cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, WHITE, 1)
176
+ text_y += 30
177
+
178
+ def render_frame(
179
+ self,
180
+ frame: np.ndarray,
181
+ landmarks: LandmarkDict | None,
182
+ contact_state: ContactState,
183
+ frame_idx: int,
184
+ metrics: DropJumpMetrics | None = None,
185
+ use_com: bool = False,
186
+ ) -> np.ndarray:
187
+ """
188
+ Render debug overlay on frame.
189
+
190
+ Args:
191
+ frame: Original video frame
192
+ landmarks: Pose landmarks for this frame
193
+ contact_state: Ground contact state
194
+ frame_idx: Current frame index
195
+ metrics: Drop-jump metrics (optional)
196
+ use_com: Whether to visualize CoM instead of feet (optional)
197
+
198
+ Returns:
199
+ Frame with debug overlay
200
+ """
201
+ with self.timer.measure("debug_video_copy"):
202
+ annotated = frame.copy()
203
+
204
+ with self.timer.measure("debug_video_draw"):
205
+ # Draw skeleton and landmarks
206
+ if landmarks:
207
+ self._draw_skeleton(annotated, landmarks)
208
+ if use_com:
209
+ self._draw_com_visualization(annotated, landmarks, contact_state)
210
+ else:
211
+ self._draw_foot_visualization(annotated, landmarks, contact_state)
212
+
213
+ # Draw contact state
214
+ state_color = self._get_contact_state_color(contact_state)
215
+ cv2.putText(
216
+ annotated,
217
+ f"State: {contact_state.value}",
218
+ (10, 30),
219
+ cv2.FONT_HERSHEY_SIMPLEX,
220
+ 1,
221
+ state_color,
222
+ 2,
223
+ )
224
+
225
+ # Draw frame number
226
+ cv2.putText(
227
+ annotated,
228
+ f"Frame: {frame_idx}",
229
+ (10, 70),
230
+ cv2.FONT_HERSHEY_SIMPLEX,
231
+ 0.7,
232
+ WHITE,
233
+ 2,
234
+ )
235
+
236
+ # Draw phase labels and metrics summary
237
+ if metrics:
238
+ self._draw_phase_labels(annotated, frame_idx, metrics)
239
+ self._draw_metrics_summary(annotated, frame_idx, metrics)
240
+
241
+ return annotated