kinemotion 0.11.5__py3-none-any.whl → 0.11.7__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.

kinemotion/cmj/cli.py CHANGED
@@ -17,6 +17,7 @@ from ..core.auto_tuning import (
17
17
  )
18
18
  from ..core.cli_utils import (
19
19
  apply_expert_param_overrides,
20
+ common_output_options,
20
21
  determine_initial_confidence,
21
22
  print_auto_tuned_params,
22
23
  smooth_landmark_sequence,
@@ -102,18 +103,7 @@ def _process_batch_videos(
102
103
 
103
104
  @click.command(name="cmj-analyze")
104
105
  @click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
105
- @click.option(
106
- "--output",
107
- "-o",
108
- type=click.Path(),
109
- help="Path for debug video output (optional)",
110
- )
111
- @click.option(
112
- "--json-output",
113
- "-j",
114
- type=click.Path(),
115
- help="Path for JSON metrics output (default: stdout)",
116
- )
106
+ @common_output_options
117
107
  @click.option(
118
108
  "--quality",
119
109
  type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
@@ -3,6 +3,7 @@
3
3
  import cv2
4
4
  import numpy as np
5
5
 
6
+ from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
6
7
  from .joint_angles import calculate_triple_extension
7
8
  from .kinematics import CMJMetrics
8
9
 
@@ -18,54 +19,9 @@ class CMJPhaseState:
18
19
  LANDING = "landing"
19
20
 
20
21
 
21
- class CMJDebugOverlayRenderer:
22
+ class CMJDebugOverlayRenderer(BaseDebugOverlayRenderer):
22
23
  """Renders debug information on CMJ video frames."""
23
24
 
24
- def __init__(
25
- self,
26
- output_path: str,
27
- width: int,
28
- height: int,
29
- display_width: int,
30
- display_height: int,
31
- fps: float,
32
- ):
33
- """
34
- Initialize overlay renderer.
35
-
36
- Args:
37
- output_path: Path for output video
38
- width: Encoded frame width (from source video)
39
- height: Encoded frame height (from source video)
40
- display_width: Display width (considering SAR)
41
- display_height: Display height (considering SAR)
42
- fps: Frames per second
43
- """
44
- self.width = width
45
- self.height = height
46
- self.display_width = display_width
47
- self.display_height = display_height
48
- self.needs_resize = (display_width != width) or (display_height != height)
49
-
50
- # Try H.264 codec first (better quality/compatibility), fallback to mp4v
51
- fourcc = cv2.VideoWriter_fourcc(*"avc1")
52
- self.writer = cv2.VideoWriter(
53
- output_path, fourcc, fps, (display_width, display_height)
54
- )
55
-
56
- # Check if writer opened successfully, fallback to mp4v if not
57
- if not self.writer.isOpened():
58
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
59
- self.writer = cv2.VideoWriter(
60
- output_path, fourcc, fps, (display_width, display_height)
61
- )
62
-
63
- if not self.writer.isOpened():
64
- raise ValueError(
65
- f"Failed to create video writer for {output_path} with dimensions "
66
- f"{display_width}x{display_height}"
67
- )
68
-
69
25
  def _determine_phase(self, frame_idx: int, metrics: CMJMetrics) -> str:
70
26
  """Determine which phase the current frame is in."""
71
27
  if metrics.standing_start_frame and frame_idx < metrics.standing_start_frame:
@@ -508,42 +464,3 @@ class CMJDebugOverlayRenderer:
508
464
  self._draw_metrics_summary(annotated, frame_idx, metrics)
509
465
 
510
466
  return annotated
511
-
512
- def write_frame(self, frame: np.ndarray) -> None:
513
- """
514
- Write frame to output video.
515
-
516
- Args:
517
- frame: Video frame with shape (height, width, 3)
518
-
519
- Raises:
520
- ValueError: If frame dimensions don't match expected encoded dimensions
521
- """
522
- # Validate frame dimensions match expected encoded dimensions
523
- frame_height, frame_width = frame.shape[:2]
524
- if frame_height != self.height or frame_width != self.width:
525
- raise ValueError(
526
- f"Frame dimensions ({frame_width}x{frame_height}) don't match "
527
- f"source dimensions ({self.width}x{self.height}). "
528
- f"Aspect ratio must be preserved from source video."
529
- )
530
-
531
- # Resize to display dimensions if needed (to handle SAR)
532
- if self.needs_resize:
533
- frame = cv2.resize(
534
- frame,
535
- (self.display_width, self.display_height),
536
- interpolation=cv2.INTER_LANCZOS4,
537
- )
538
-
539
- self.writer.write(frame)
540
-
541
- def close(self) -> None:
542
- """Release video writer."""
543
- self.writer.release()
544
-
545
- def __enter__(self) -> "CMJDebugOverlayRenderer":
546
- return self
547
-
548
- def __exit__(self, exc_type: type, exc_val: Exception, exc_tb: object) -> None:
549
- self.close()
@@ -1,5 +1,6 @@
1
1
  """Shared CLI utilities for drop jump and CMJ analysis."""
2
2
 
3
+ from collections.abc import Callable
3
4
  from typing import Any, Protocol
4
5
 
5
6
  import click
@@ -190,3 +191,116 @@ def smooth_landmark_sequence(landmarks_sequence: list, params: AutoTunedParams)
190
191
  window_length=params.smoothing_window,
191
192
  polyorder=params.polyorder,
192
193
  )
194
+
195
+
196
+ def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
197
+ """Add common output options to CLI command."""
198
+ func = click.option(
199
+ "--output",
200
+ "-o",
201
+ type=click.Path(),
202
+ help="Path for debug video output (optional)",
203
+ )(func)
204
+ func = click.option(
205
+ "--json-output",
206
+ "-j",
207
+ type=click.Path(),
208
+ help="Path for JSON metrics output (default: stdout)",
209
+ )(func)
210
+ return func
211
+
212
+
213
+ def common_quality_options(func: Callable) -> Callable: # type: ignore[type-arg]
214
+ """Add quality and verbose options to CLI command."""
215
+ func = click.option(
216
+ "--quality",
217
+ type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
218
+ default="balanced",
219
+ help=(
220
+ "Analysis quality preset: "
221
+ "fast (quick, less precise), "
222
+ "balanced (default, good for most cases), "
223
+ "accurate (research-grade, slower)"
224
+ ),
225
+ show_default=True,
226
+ )(func)
227
+ func = click.option(
228
+ "--verbose",
229
+ "-v",
230
+ is_flag=True,
231
+ help="Show auto-selected parameters and analysis details",
232
+ )(func)
233
+ return func
234
+
235
+
236
+ def common_batch_options(func: Callable) -> Callable: # type: ignore[type-arg]
237
+ """Add batch processing options to CLI command."""
238
+ func = click.option(
239
+ "--batch",
240
+ is_flag=True,
241
+ help="Enable batch processing mode for multiple videos",
242
+ )(func)
243
+ func = click.option(
244
+ "--workers",
245
+ type=int,
246
+ default=4,
247
+ help="Number of parallel workers for batch processing (default: 4)",
248
+ show_default=True,
249
+ )(func)
250
+ func = click.option(
251
+ "--output-dir",
252
+ type=click.Path(),
253
+ help="Directory for debug video outputs (batch mode only)",
254
+ )(func)
255
+ func = click.option(
256
+ "--json-output-dir",
257
+ type=click.Path(),
258
+ help="Directory for JSON metrics outputs (batch mode only)",
259
+ )(func)
260
+ func = click.option(
261
+ "--csv-summary",
262
+ type=click.Path(),
263
+ help="Path for CSV summary export (batch mode only)",
264
+ )(func)
265
+ return func
266
+
267
+
268
+ def common_expert_options(func: Callable) -> Callable: # type: ignore[type-arg]
269
+ """Add expert parameter options to CLI command."""
270
+ func = click.option(
271
+ "--smoothing-window",
272
+ type=int,
273
+ default=None,
274
+ help="[EXPERT] Override auto-tuned smoothing window size",
275
+ )(func)
276
+ func = click.option(
277
+ "--velocity-threshold",
278
+ type=float,
279
+ default=None,
280
+ help="[EXPERT] Override auto-tuned velocity threshold",
281
+ )(func)
282
+ func = click.option(
283
+ "--min-contact-frames",
284
+ type=int,
285
+ default=None,
286
+ help="[EXPERT] Override auto-tuned minimum contact frames",
287
+ )(func)
288
+ func = click.option(
289
+ "--visibility-threshold",
290
+ type=float,
291
+ default=None,
292
+ help="[EXPERT] Override visibility threshold for landmarks",
293
+ )(func)
294
+ func = click.option(
295
+ "--detection-confidence",
296
+ type=float,
297
+ default=None,
298
+ help="[EXPERT] Override MediaPipe detection confidence (0.0-1.0)",
299
+ )(func)
300
+ func = click.option(
301
+ "--tracking-confidence",
302
+ type=float,
303
+ default=None,
304
+ help="[EXPERT] Override MediaPipe tracking confidence (0.0-1.0)",
305
+ )(func)
306
+ return func
@@ -0,0 +1,166 @@
1
+ """Shared debug overlay utilities for video rendering."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+
7
+ def create_video_writer(
8
+ output_path: str,
9
+ width: int,
10
+ height: int,
11
+ display_width: int,
12
+ display_height: int,
13
+ fps: float,
14
+ ) -> tuple[cv2.VideoWriter, bool]:
15
+ """
16
+ Create a video writer with fallback codec support.
17
+
18
+ Args:
19
+ output_path: Path for output video
20
+ width: Encoded frame width (from source video)
21
+ height: Encoded frame height (from source video)
22
+ display_width: Display width (considering SAR)
23
+ display_height: Display height (considering SAR)
24
+ fps: Frames per second
25
+
26
+ Returns:
27
+ Tuple of (video_writer, needs_resize)
28
+ """
29
+ needs_resize = (display_width != width) or (display_height != height)
30
+
31
+ # Try H.264 codec first (better quality/compatibility), fallback to mp4v
32
+ fourcc = cv2.VideoWriter_fourcc(*"avc1")
33
+ writer = cv2.VideoWriter(output_path, fourcc, fps, (display_width, display_height))
34
+
35
+ # Check if writer opened successfully, fallback to mp4v if not
36
+ if not writer.isOpened():
37
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
38
+ writer = cv2.VideoWriter(
39
+ output_path, fourcc, fps, (display_width, display_height)
40
+ )
41
+
42
+ if not writer.isOpened():
43
+ raise ValueError(
44
+ f"Failed to create video writer for {output_path} with dimensions "
45
+ f"{display_width}x{display_height}"
46
+ )
47
+
48
+ return writer, needs_resize
49
+
50
+
51
+ def prepare_frame_for_overlay(
52
+ frame: np.ndarray, needs_resize: bool, display_width: int, display_height: int
53
+ ) -> np.ndarray:
54
+ """
55
+ Prepare frame for overlay rendering by resizing if needed.
56
+
57
+ Args:
58
+ frame: Original video frame
59
+ needs_resize: Whether frame needs resizing
60
+ display_width: Target display width
61
+ display_height: Target display height
62
+
63
+ Returns:
64
+ Prepared frame ready for overlay
65
+ """
66
+ # Apply SAR correction if needed
67
+ if needs_resize:
68
+ frame = cv2.resize(
69
+ frame, (display_width, display_height), interpolation=cv2.INTER_LINEAR
70
+ )
71
+ return frame
72
+
73
+
74
+ def write_overlay_frame(
75
+ writer: cv2.VideoWriter, frame: np.ndarray, width: int, height: int
76
+ ) -> None:
77
+ """
78
+ Write a frame to the video writer with dimension validation.
79
+
80
+ Args:
81
+ writer: Video writer instance
82
+ frame: Frame to write
83
+ width: Expected frame width
84
+ height: Expected frame height
85
+
86
+ Raises:
87
+ ValueError: If frame dimensions don't match expected dimensions
88
+ """
89
+ # Validate dimensions before writing
90
+ if frame.shape[0] != height or frame.shape[1] != width:
91
+ raise ValueError(
92
+ f"Frame dimensions {frame.shape[1]}x{frame.shape[0]} do not match "
93
+ f"expected dimensions {width}x{height}"
94
+ )
95
+ writer.write(frame)
96
+
97
+
98
+ class BaseDebugOverlayRenderer:
99
+ """Base class for debug overlay renderers with common functionality."""
100
+
101
+ def __init__(
102
+ self,
103
+ output_path: str,
104
+ width: int,
105
+ height: int,
106
+ display_width: int,
107
+ display_height: int,
108
+ fps: float,
109
+ ):
110
+ """
111
+ Initialize overlay renderer.
112
+
113
+ Args:
114
+ output_path: Path for output video
115
+ width: Encoded frame width (from source video)
116
+ height: Encoded frame height (from source video)
117
+ display_width: Display width (considering SAR)
118
+ display_height: Display height (considering SAR)
119
+ fps: Frames per second
120
+ """
121
+ self.width = width
122
+ self.height = height
123
+ self.display_width = display_width
124
+ self.display_height = display_height
125
+ self.writer, self.needs_resize = create_video_writer(
126
+ output_path, width, height, display_width, display_height, fps
127
+ )
128
+
129
+ def write_frame(self, frame: np.ndarray) -> None:
130
+ """
131
+ Write frame to output video.
132
+
133
+ Args:
134
+ frame: Video frame with shape (height, width, 3)
135
+
136
+ Raises:
137
+ ValueError: If frame dimensions don't match expected encoded dimensions
138
+ """
139
+ # Validate frame dimensions match expected encoded dimensions
140
+ frame_height, frame_width = frame.shape[:2]
141
+ if frame_height != self.height or frame_width != self.width:
142
+ raise ValueError(
143
+ f"Frame dimensions ({frame_width}x{frame_height}) don't match "
144
+ f"source dimensions ({self.width}x{self.height}). "
145
+ f"Aspect ratio must be preserved from source video."
146
+ )
147
+
148
+ # Resize to display dimensions if needed (to handle SAR)
149
+ if self.needs_resize:
150
+ frame = cv2.resize(
151
+ frame,
152
+ (self.display_width, self.display_height),
153
+ interpolation=cv2.INTER_LANCZOS4,
154
+ )
155
+
156
+ write_overlay_frame(self.writer, frame, self.display_width, self.display_height)
157
+
158
+ def close(self) -> None:
159
+ """Release video writer."""
160
+ self.writer.release()
161
+
162
+ def __enter__(self) -> "BaseDebugOverlayRenderer":
163
+ return self
164
+
165
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
166
+ self.close()
@@ -9,97 +9,199 @@ from .filtering import (
9
9
  )
10
10
 
11
11
 
12
- def smooth_landmarks(
12
+ def _extract_landmark_coordinates(
13
13
  landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
14
- window_length: int = 5,
15
- polyorder: int = 2,
16
- ) -> list[dict[str, tuple[float, float, float]] | None]:
14
+ landmark_name: str,
15
+ ) -> tuple[list[float], list[float], list[int]]:
17
16
  """
18
- Smooth landmark trajectories using Savitzky-Golay filter.
17
+ Extract x, y coordinates and valid frame indices for a specific landmark.
19
18
 
20
19
  Args:
21
20
  landmark_sequence: List of landmark dictionaries from each frame
22
- window_length: Length of filter window (must be odd, >= polyorder + 2)
23
- polyorder: Order of polynomial used to fit samples
21
+ landmark_name: Name of the landmark to extract
24
22
 
25
23
  Returns:
26
- Smoothed landmark sequence with same structure as input
24
+ Tuple of (x_coords, y_coords, valid_frames)
27
25
  """
28
- if len(landmark_sequence) < window_length:
29
- # Not enough frames to smooth effectively
30
- return landmark_sequence
26
+ x_coords = []
27
+ y_coords = []
28
+ valid_frames = []
29
+
30
+ for i, frame_landmarks in enumerate(landmark_sequence):
31
+ if frame_landmarks is not None and landmark_name in frame_landmarks:
32
+ x, y, _ = frame_landmarks[landmark_name] # vis not used
33
+ x_coords.append(x)
34
+ y_coords.append(y)
35
+ valid_frames.append(i)
36
+
37
+ return x_coords, y_coords, valid_frames
31
38
 
32
- # Ensure window_length is odd
33
- if window_length % 2 == 0:
34
- window_length += 1
35
39
 
36
- # Extract landmark names from first valid frame
37
- landmark_names = None
40
+ def _get_landmark_names(
41
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
42
+ ) -> list[str] | None:
43
+ """
44
+ Extract landmark names from first valid frame.
45
+
46
+ Args:
47
+ landmark_sequence: List of landmark dictionaries from each frame
48
+
49
+ Returns:
50
+ List of landmark names or None if no valid frame found
51
+ """
38
52
  for frame_landmarks in landmark_sequence:
39
53
  if frame_landmarks is not None:
40
- landmark_names = list(frame_landmarks.keys())
41
- break
54
+ return list(frame_landmarks.keys())
55
+ return None
56
+
57
+
58
+ def _fill_missing_frames(
59
+ smoothed_sequence: list[dict[str, tuple[float, float, float]] | None],
60
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
61
+ ) -> None:
62
+ """
63
+ Fill in any missing frames in smoothed sequence with original data.
64
+
65
+ Args:
66
+ smoothed_sequence: Smoothed sequence (modified in place)
67
+ landmark_sequence: Original sequence
68
+ """
69
+ for i in range(len(landmark_sequence)):
70
+ if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
71
+ if i < len(smoothed_sequence):
72
+ smoothed_sequence[i] = landmark_sequence[i]
73
+ else:
74
+ smoothed_sequence.append(landmark_sequence[i])
75
+
76
+
77
+ def _store_smoothed_landmarks(
78
+ smoothed_sequence: list[dict[str, tuple[float, float, float]] | None],
79
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
80
+ landmark_name: str,
81
+ x_smooth: np.ndarray,
82
+ y_smooth: np.ndarray,
83
+ valid_frames: list[int],
84
+ ) -> None:
85
+ """
86
+ Store smoothed landmark values back into the sequence.
87
+
88
+ Args:
89
+ smoothed_sequence: Sequence to store smoothed values into (modified in place)
90
+ landmark_sequence: Original sequence (for visibility values)
91
+ landmark_name: Name of the landmark being smoothed
92
+ x_smooth: Smoothed x coordinates
93
+ y_smooth: Smoothed y coordinates
94
+ valid_frames: Frame indices corresponding to smoothed values
95
+ """
96
+ for idx, frame_idx in enumerate(valid_frames):
97
+ if frame_idx >= len(smoothed_sequence):
98
+ smoothed_sequence.extend([{}] * (frame_idx - len(smoothed_sequence) + 1))
99
+
100
+ # Ensure smoothed_sequence[frame_idx] is a dict, not None
101
+ if smoothed_sequence[frame_idx] is None:
102
+ smoothed_sequence[frame_idx] = {}
103
+
104
+ # Type narrowing: after the check above, we know it's a dict
105
+ frame_dict = smoothed_sequence[frame_idx]
106
+ assert frame_dict is not None # for type checker
107
+
108
+ if landmark_name not in frame_dict and landmark_sequence[frame_idx] is not None:
109
+ # Keep original visibility
110
+ orig_landmarks = landmark_sequence[frame_idx]
111
+ assert orig_landmarks is not None # for type checker
112
+ orig_vis = orig_landmarks[landmark_name][2]
113
+ frame_dict[landmark_name] = (
114
+ float(x_smooth[idx]),
115
+ float(y_smooth[idx]),
116
+ orig_vis,
117
+ )
42
118
 
119
+
120
+ def _smooth_landmarks_core(
121
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
122
+ window_length: int,
123
+ polyorder: int,
124
+ smoother_fn, # type: ignore[no-untyped-def]
125
+ ) -> list[dict[str, tuple[float, float, float]] | None]:
126
+ """
127
+ Core smoothing logic shared by both standard and advanced smoothing.
128
+
129
+ Args:
130
+ landmark_sequence: List of landmark dictionaries from each frame
131
+ window_length: Length of filter window (must be odd)
132
+ polyorder: Order of polynomial used to fit samples
133
+ smoother_fn: Function that takes (x_coords, y_coords, valid_frames)
134
+ and returns (x_smooth, y_smooth)
135
+
136
+ Returns:
137
+ Smoothed landmark sequence
138
+ """
139
+ landmark_names = _get_landmark_names(landmark_sequence)
43
140
  if landmark_names is None:
44
141
  return landmark_sequence
45
142
 
46
- # Build arrays for each landmark coordinate
47
143
  smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
48
144
 
49
145
  for landmark_name in landmark_names:
50
- # Extract x, y coordinates for this landmark across all frames
51
- x_coords = []
52
- y_coords = []
53
- valid_frames = []
54
-
55
- for i, frame_landmarks in enumerate(landmark_sequence):
56
- if frame_landmarks is not None and landmark_name in frame_landmarks:
57
- x, y, _ = frame_landmarks[landmark_name] # vis not used
58
- x_coords.append(x)
59
- y_coords.append(y)
60
- valid_frames.append(i)
146
+ x_coords, y_coords, valid_frames = _extract_landmark_coordinates(
147
+ landmark_sequence, landmark_name
148
+ )
61
149
 
62
150
  if len(x_coords) < window_length:
63
151
  continue
64
152
 
65
- # Apply Savitzky-Golay filter
66
- x_smooth = savgol_filter(x_coords, window_length, polyorder)
67
- y_smooth = savgol_filter(y_coords, window_length, polyorder)
153
+ # Apply smoothing function
154
+ x_smooth, y_smooth = smoother_fn(x_coords, y_coords, valid_frames)
68
155
 
69
156
  # Store smoothed values back
70
- for idx, frame_idx in enumerate(valid_frames):
71
- if frame_idx >= len(smoothed_sequence):
72
- smoothed_sequence.extend(
73
- [{}] * (frame_idx - len(smoothed_sequence) + 1)
74
- )
75
-
76
- # Ensure smoothed_sequence[frame_idx] is a dict, not None
77
- if smoothed_sequence[frame_idx] is None:
78
- smoothed_sequence[frame_idx] = {}
79
-
80
- if (
81
- landmark_name not in smoothed_sequence[frame_idx]
82
- and landmark_sequence[frame_idx] is not None
83
- ):
84
- # Keep original visibility
85
- orig_vis = landmark_sequence[frame_idx][landmark_name][2]
86
- smoothed_sequence[frame_idx][landmark_name] = (
87
- float(x_smooth[idx]),
88
- float(y_smooth[idx]),
89
- orig_vis,
90
- )
157
+ _store_smoothed_landmarks(
158
+ smoothed_sequence,
159
+ landmark_sequence,
160
+ landmark_name,
161
+ x_smooth,
162
+ y_smooth,
163
+ valid_frames,
164
+ )
91
165
 
92
166
  # Fill in any missing frames with original data
93
- for i in range(len(landmark_sequence)):
94
- if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
95
- if i < len(smoothed_sequence):
96
- smoothed_sequence[i] = landmark_sequence[i]
97
- else:
98
- smoothed_sequence.append(landmark_sequence[i])
167
+ _fill_missing_frames(smoothed_sequence, landmark_sequence)
99
168
 
100
169
  return smoothed_sequence
101
170
 
102
171
 
172
+ def smooth_landmarks(
173
+ landmark_sequence: list[dict[str, tuple[float, float, float]] | None],
174
+ window_length: int = 5,
175
+ polyorder: int = 2,
176
+ ) -> list[dict[str, tuple[float, float, float]] | None]:
177
+ """
178
+ Smooth landmark trajectories using Savitzky-Golay filter.
179
+
180
+ Args:
181
+ landmark_sequence: List of landmark dictionaries from each frame
182
+ window_length: Length of filter window (must be odd, >= polyorder + 2)
183
+ polyorder: Order of polynomial used to fit samples
184
+
185
+ Returns:
186
+ Smoothed landmark sequence with same structure as input
187
+ """
188
+ if len(landmark_sequence) < window_length:
189
+ return landmark_sequence
190
+
191
+ # Ensure window_length is odd
192
+ if window_length % 2 == 0:
193
+ window_length += 1
194
+
195
+ def savgol_smoother(x_coords, y_coords, _valid_frames): # type: ignore[no-untyped-def]
196
+ x_smooth = savgol_filter(x_coords, window_length, polyorder)
197
+ y_smooth = savgol_filter(y_coords, window_length, polyorder)
198
+ return x_smooth, y_smooth
199
+
200
+ return _smooth_landmarks_core(
201
+ landmark_sequence, window_length, polyorder, savgol_smoother
202
+ )
203
+
204
+
103
205
  def compute_velocity(
104
206
  positions: np.ndarray, fps: float, smooth_window: int = 3
105
207
  ) -> np.ndarray:
@@ -259,42 +361,13 @@ def smooth_landmarks_advanced(
259
361
  Smoothed landmark sequence with same structure as input
260
362
  """
261
363
  if len(landmark_sequence) < window_length:
262
- # Not enough frames to smooth effectively
263
364
  return landmark_sequence
264
365
 
265
366
  # Ensure window_length is odd
266
367
  if window_length % 2 == 0:
267
368
  window_length += 1
268
369
 
269
- # Extract landmark names from first valid frame
270
- landmark_names = None
271
- for frame_landmarks in landmark_sequence:
272
- if frame_landmarks is not None:
273
- landmark_names = list(frame_landmarks.keys())
274
- break
275
-
276
- if landmark_names is None:
277
- return landmark_sequence
278
-
279
- # Build arrays for each landmark coordinate
280
- smoothed_sequence: list[dict[str, tuple[float, float, float]] | None] = []
281
-
282
- for landmark_name in landmark_names:
283
- # Extract x, y coordinates for this landmark across all frames
284
- x_coords = []
285
- y_coords = []
286
- valid_frames = []
287
-
288
- for i, frame_landmarks in enumerate(landmark_sequence):
289
- if frame_landmarks is not None and landmark_name in frame_landmarks:
290
- x, y, _ = frame_landmarks[landmark_name] # vis not used
291
- x_coords.append(x)
292
- y_coords.append(y)
293
- valid_frames.append(i)
294
-
295
- if len(x_coords) < window_length:
296
- continue
297
-
370
+ def advanced_smoother(x_coords, y_coords, _valid_frames): # type: ignore[no-untyped-def]
298
371
  x_array = np.array(x_coords)
299
372
  y_array = np.array(y_coords)
300
373
 
@@ -332,35 +405,8 @@ def smooth_landmarks_advanced(
332
405
  x_smooth = savgol_filter(x_array, window_length, polyorder)
333
406
  y_smooth = savgol_filter(y_array, window_length, polyorder)
334
407
 
335
- # Store smoothed values back
336
- for idx, frame_idx in enumerate(valid_frames):
337
- if frame_idx >= len(smoothed_sequence):
338
- smoothed_sequence.extend(
339
- [{}] * (frame_idx - len(smoothed_sequence) + 1)
340
- )
341
-
342
- # Ensure smoothed_sequence[frame_idx] is a dict, not None
343
- if smoothed_sequence[frame_idx] is None:
344
- smoothed_sequence[frame_idx] = {}
345
-
346
- if (
347
- landmark_name not in smoothed_sequence[frame_idx]
348
- and landmark_sequence[frame_idx] is not None
349
- ):
350
- # Keep original visibility
351
- orig_vis = landmark_sequence[frame_idx][landmark_name][2]
352
- smoothed_sequence[frame_idx][landmark_name] = (
353
- float(x_smooth[idx]),
354
- float(y_smooth[idx]),
355
- orig_vis,
356
- )
357
-
358
- # Fill in any missing frames with original data
359
- for i in range(len(landmark_sequence)):
360
- if i >= len(smoothed_sequence) or not smoothed_sequence[i]:
361
- if i < len(smoothed_sequence):
362
- smoothed_sequence[i] = landmark_sequence[i]
363
- else:
364
- smoothed_sequence.append(landmark_sequence[i])
408
+ return x_smooth, y_smooth
365
409
 
366
- return smoothed_sequence
410
+ return _smooth_landmarks_core(
411
+ landmark_sequence, window_length, polyorder, advanced_smoother
412
+ )
@@ -3,61 +3,15 @@
3
3
  import cv2
4
4
  import numpy as np
5
5
 
6
+ from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
6
7
  from ..core.pose import compute_center_of_mass
7
8
  from .analysis import ContactState, compute_average_foot_position
8
9
  from .kinematics import DropJumpMetrics
9
10
 
10
11
 
11
- class DebugOverlayRenderer:
12
+ class DebugOverlayRenderer(BaseDebugOverlayRenderer):
12
13
  """Renders debug information on video frames."""
13
14
 
14
- def __init__(
15
- self,
16
- output_path: str,
17
- width: int,
18
- height: int,
19
- display_width: int,
20
- display_height: int,
21
- fps: float,
22
- ):
23
- """
24
- Initialize overlay renderer.
25
-
26
- Args:
27
- output_path: Path for output video
28
- width: Encoded frame width (from source video)
29
- height: Encoded frame height (from source video)
30
- display_width: Display width (considering SAR)
31
- display_height: Display height (considering SAR)
32
- fps: Frames per second
33
- """
34
- self.width = width
35
- self.height = height
36
- self.display_width = display_width
37
- self.display_height = display_height
38
- self.needs_resize = (display_width != width) or (display_height != height)
39
-
40
- # Try H.264 codec first (better quality/compatibility), fallback to mp4v
41
- fourcc = cv2.VideoWriter_fourcc(*"avc1")
42
- # IMPORTANT: cv2.VideoWriter expects (width, height) tuple - NOT (height, width)
43
- # Write at display dimensions so video displays correctly without SAR metadata
44
- self.writer = cv2.VideoWriter(
45
- output_path, fourcc, fps, (display_width, display_height)
46
- )
47
-
48
- # Check if writer opened successfully, fallback to mp4v if not
49
- if not self.writer.isOpened():
50
- fourcc = cv2.VideoWriter_fourcc(*"mp4v")
51
- self.writer = cv2.VideoWriter(
52
- output_path, fourcc, fps, (display_width, display_height)
53
- )
54
-
55
- if not self.writer.isOpened():
56
- raise ValueError(
57
- f"Failed to create video writer for {output_path} with dimensions "
58
- f"{display_width}x{display_height}"
59
- )
60
-
61
15
  def render_frame(
62
16
  self,
63
17
  frame: np.ndarray,
@@ -211,42 +165,3 @@ class DebugOverlayRenderer:
211
165
  )
212
166
 
213
167
  return annotated
214
-
215
- def write_frame(self, frame: np.ndarray) -> None:
216
- """
217
- Write frame to output video.
218
-
219
- Args:
220
- frame: Video frame with shape (height, width, 3)
221
-
222
- Raises:
223
- ValueError: If frame dimensions don't match expected encoded dimensions
224
- """
225
- # Validate frame dimensions match expected encoded dimensions
226
- frame_height, frame_width = frame.shape[:2]
227
- if frame_height != self.height or frame_width != self.width:
228
- raise ValueError(
229
- f"Frame dimensions ({frame_width}x{frame_height}) don't match "
230
- f"source dimensions ({self.width}x{self.height}). "
231
- f"Aspect ratio must be preserved from source video."
232
- )
233
-
234
- # Resize to display dimensions if needed (to handle SAR)
235
- if self.needs_resize:
236
- frame = cv2.resize(
237
- frame,
238
- (self.display_width, self.display_height),
239
- interpolation=cv2.INTER_LANCZOS4,
240
- )
241
-
242
- self.writer.write(frame)
243
-
244
- def close(self) -> None:
245
- """Release video writer."""
246
- self.writer.release()
247
-
248
- def __enter__(self) -> "DebugOverlayRenderer":
249
- return self
250
-
251
- def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
252
- self.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.11.5
3
+ Version: 0.11.7
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
@@ -3,25 +3,26 @@ kinemotion/api.py,sha256=M6KqzdspOvtJ9Wl555HGe3ITzvRFhPK4xeqcX7IA98s,31463
3
3
  kinemotion/cli.py,sha256=cqYV_7URH0JUDy1VQ_EDLv63FmNO4Ns20m6s1XAjiP4,464
4
4
  kinemotion/cmj/__init__.py,sha256=Ynv0-Oco4I3Y1Ubj25m3h9h2XFqeNwpAewXmAYOmwfU,127
5
5
  kinemotion/cmj/analysis.py,sha256=4HYGn4VDIB6oExAees-VcPfpNgWOltpgwjyNTU7YAb4,18263
6
- kinemotion/cmj/cli.py,sha256=3CQPJOC3N00dFz_mrEgSHq9pIA-RyOynZoNUIk8RzD0,17040
7
- kinemotion/cmj/debug_overlay.py,sha256=ELrSYQ9LmLV81bJS5w9i2c4VwRS0EYAUnMehMHU7VGc,18724
6
+ kinemotion/cmj/cli.py,sha256=bmDvNvL7cu65-R8YkRIZYKD0nuTA0IJnWLcLlH_kFm0,16843
7
+ kinemotion/cmj/debug_overlay.py,sha256=5Uwtyx9FP-tKhQyUvFW2t_ULVBV7oMCvzcLzf4hFcUg,15910
8
8
  kinemotion/cmj/joint_angles.py,sha256=8ucpDGPvbt4iX3tx9eVxJEUv0laTm2Y58_--VzJCogE,9113
9
9
  kinemotion/cmj/kinematics.py,sha256=Xl_PlC2OqMoA-zOc3SRB_GqI0AgLlJol5FTPe5J_qLc,7573
10
10
  kinemotion/core/__init__.py,sha256=3yzDhb5PekDNjydqrs8aWGneUGJBt-lB0SoB_Y2FXqU,1010
11
11
  kinemotion/core/auto_tuning.py,sha256=cvmxUI-CbahpOJQtR2r5jOx4Q6yKPe3DO1o15hOQIdw,10508
12
- kinemotion/core/cli_utils.py,sha256=iI1bHZxgbzxW5-2X3tTHEy2mqR49o3oIlwFptZ6gCco,6726
12
+ kinemotion/core/cli_utils.py,sha256=Pmg8z0nGhkYJm0o-y3jyvzeRy9yvol14ddaHrp6f7cw,10161
13
+ kinemotion/core/debug_overlay_utils.py,sha256=E3krJ0SeIhSl6AM5WYiGuaONKsLNKL93P3aHqMgStY8,5235
13
14
  kinemotion/core/filtering.py,sha256=f-m-aA59e4WqE6u-9MA51wssu7rI-Y_7n1cG8IWdeRQ,11241
14
15
  kinemotion/core/pose.py,sha256=Wfd1RR-2ZznYpWeQUbySwcV3mvReqn8n3XO6S7pGq4M,8390
15
- kinemotion/core/smoothing.py,sha256=FON4qKtsSp1-03GnJrDkEUAePaACn4QPMJF0eTIYqR0,12925
16
+ kinemotion/core/smoothing.py,sha256=Zdhqw4NyCrZaEb-Jo3sASzP-QlEL5sVTgHoXU8zT_xU,14136
16
17
  kinemotion/core/video_io.py,sha256=z8Z0qbNaKbcdB40KnbNOBMzab3BbgnhBxp-mUBYeXgM,6577
17
18
  kinemotion/dropjump/__init__.py,sha256=yc1XiZ9vfo5h_n7PKVSiX2TTgaIfGL7Y7SkQtiDZj_E,838
18
19
  kinemotion/dropjump/analysis.py,sha256=HfJt2t9IsMBiBUz7apIzdxbRH9QqzlFnDVVWcKhU3ow,23291
19
20
  kinemotion/dropjump/cli.py,sha256=-iBgHNwW_dijHe6_JIEGSBUzvFb6tZV0aopbPd-9jC8,22402
20
- kinemotion/dropjump/debug_overlay.py,sha256=GMo-jCl5OPIv82uPxDbBVI7CsAMwATTvxZMeWfs8k8M,8701
21
+ kinemotion/dropjump/debug_overlay.py,sha256=2L4VAZwWFnaOQ7LAF3ALXCjEaVNzkfpLT5-h0qKL_6g,5707
21
22
  kinemotion/dropjump/kinematics.py,sha256=RM_O8Kdc6aEiPIu_99N4cu-4EhYSQxtBGASJF_dmQaU,19081
22
23
  kinemotion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- kinemotion-0.11.5.dist-info/METADATA,sha256=gsLG_2Z3qbeG2UpWqp7mfyqWDXj2SpwrsXG5cgh8oEo,18990
24
- kinemotion-0.11.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- kinemotion-0.11.5.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
26
- kinemotion-0.11.5.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
27
- kinemotion-0.11.5.dist-info/RECORD,,
24
+ kinemotion-0.11.7.dist-info/METADATA,sha256=7z0ZpUWjjZXDq-fe3006Pgy7CCZTucZ_cCRh9lbLjkU,18990
25
+ kinemotion-0.11.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
+ kinemotion-0.11.7.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
27
+ kinemotion-0.11.7.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
28
+ kinemotion-0.11.7.dist-info/RECORD,,