kinemotion 0.11.4__py3-none-any.whl → 0.11.6__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
@@ -10,16 +10,15 @@ from typing import Any
10
10
  import click
11
11
  import numpy as np
12
12
 
13
- from ..core.auto_tuning import (
14
- AnalysisParameters as AutoTunedParams,
15
- )
16
13
  from ..core.auto_tuning import (
17
14
  QualityPreset,
18
15
  analyze_video_sample,
19
16
  auto_tune_parameters,
20
17
  )
21
18
  from ..core.cli_utils import (
19
+ apply_expert_param_overrides,
22
20
  determine_initial_confidence,
21
+ print_auto_tuned_params,
23
22
  smooth_landmark_sequence,
24
23
  track_all_frames,
25
24
  )
@@ -299,50 +298,6 @@ def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for
299
298
  sys.exit(1)
300
299
 
301
300
 
302
- def _apply_expert_param_overrides(
303
- params: AutoTunedParams, expert_params: AnalysisParameters
304
- ) -> AutoTunedParams:
305
- """Apply expert parameter overrides to auto-tuned parameters."""
306
- if expert_params.smoothing_window is not None:
307
- params.smoothing_window = expert_params.smoothing_window
308
- if expert_params.velocity_threshold is not None:
309
- params.velocity_threshold = expert_params.velocity_threshold
310
- if expert_params.min_contact_frames is not None:
311
- params.min_contact_frames = expert_params.min_contact_frames
312
- if expert_params.visibility_threshold is not None:
313
- params.visibility_threshold = expert_params.visibility_threshold
314
- return params
315
-
316
-
317
- def _print_auto_tuned_params(
318
- video: VideoProcessor,
319
- quality_preset: QualityPreset,
320
- params: AutoTunedParams,
321
- countermovement_threshold: float,
322
- ) -> None:
323
- """Print auto-tuned parameters in verbose mode."""
324
- click.echo("\n" + "=" * 60, err=True)
325
- click.echo("AUTO-TUNED PARAMETERS", err=True)
326
- click.echo("=" * 60, err=True)
327
- click.echo(f"Video FPS: {video.fps:.2f}", err=True)
328
- click.echo(f"Quality preset: {quality_preset.value}", err=True)
329
- click.echo("\nSelected parameters:", err=True)
330
- click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
331
- click.echo(f" polyorder: {params.polyorder}", err=True)
332
- click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
333
- click.echo(
334
- f" countermovement_threshold: {countermovement_threshold:.4f}", err=True
335
- )
336
- click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
337
- click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
338
- click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
339
- click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
340
- click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
341
- click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
342
- click.echo(f" use_curvature: {params.use_curvature}", err=True)
343
- click.echo("=" * 60 + "\n", err=True)
344
-
345
-
346
301
  def _get_foot_position(frame_landmarks: dict | None, last_position: float) -> float:
347
302
  """Extract average foot position from frame landmarks."""
348
303
  if not frame_landmarks:
@@ -419,7 +374,7 @@ def _process_single(
419
374
  landmarks_sequence, video.fps, video.frame_count
420
375
  )
421
376
  params = auto_tune_parameters(characteristics, quality_preset)
422
- params = _apply_expert_param_overrides(params, expert_params)
377
+ params = apply_expert_param_overrides(params, expert_params)
423
378
 
424
379
  # Calculate countermovement threshold (FPS-adjusted)
425
380
  # Base: +0.015 at 30fps (POSITIVE for downward motion in normalized coords)
@@ -429,8 +384,13 @@ def _process_single(
429
384
 
430
385
  # Show parameters if verbose
431
386
  if verbose:
432
- _print_auto_tuned_params(
433
- video, quality_preset, params, countermovement_threshold
387
+ print_auto_tuned_params(
388
+ video,
389
+ quality_preset,
390
+ params,
391
+ extra_params={
392
+ "countermovement_threshold": countermovement_threshold
393
+ },
434
394
  )
435
395
 
436
396
  # Apply smoothing
@@ -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()
@@ -4,7 +4,7 @@ from typing import Any, Protocol
4
4
 
5
5
  import click
6
6
 
7
- from .auto_tuning import AutoTunedParams, QualityPreset
7
+ from .auto_tuning import AutoTunedParams, QualityPreset, VideoCharacteristics
8
8
  from .pose import PoseTracker
9
9
  from .smoothing import smooth_landmarks, smooth_landmarks_advanced
10
10
  from .video_io import VideoProcessor
@@ -15,6 +15,10 @@ class ExpertParameters(Protocol):
15
15
 
16
16
  detection_confidence: float | None
17
17
  tracking_confidence: float | None
18
+ smoothing_window: int | None
19
+ velocity_threshold: float | None
20
+ min_contact_frames: int | None
21
+ visibility_threshold: float | None
18
22
 
19
23
 
20
24
  def determine_initial_confidence(
@@ -79,6 +83,81 @@ def track_all_frames(video: VideoProcessor, tracker: PoseTracker) -> tuple[list,
79
83
  return frames, landmarks_sequence
80
84
 
81
85
 
86
+ def apply_expert_param_overrides(
87
+ params: AutoTunedParams, expert_params: ExpertParameters
88
+ ) -> AutoTunedParams:
89
+ """Apply expert parameter overrides to auto-tuned parameters.
90
+
91
+ Args:
92
+ params: Auto-tuned parameters
93
+ expert_params: Expert overrides
94
+
95
+ Returns:
96
+ Modified params object (mutated in place)
97
+ """
98
+ if expert_params.smoothing_window is not None:
99
+ params.smoothing_window = expert_params.smoothing_window
100
+ if expert_params.velocity_threshold is not None:
101
+ params.velocity_threshold = expert_params.velocity_threshold
102
+ if expert_params.min_contact_frames is not None:
103
+ params.min_contact_frames = expert_params.min_contact_frames
104
+ if expert_params.visibility_threshold is not None:
105
+ params.visibility_threshold = expert_params.visibility_threshold
106
+ return params
107
+
108
+
109
+ def print_auto_tuned_params(
110
+ video: VideoProcessor,
111
+ quality_preset: QualityPreset,
112
+ params: AutoTunedParams,
113
+ characteristics: VideoCharacteristics | None = None,
114
+ extra_params: dict[str, Any] | None = None,
115
+ ) -> None:
116
+ """Print auto-tuned parameters in verbose mode.
117
+
118
+ Args:
119
+ video: Video processor
120
+ quality_preset: Quality preset
121
+ params: Auto-tuned parameters
122
+ characteristics: Optional video characteristics (for tracking quality display)
123
+ extra_params: Optional extra parameters to display (e.g., countermovement_threshold)
124
+ """
125
+ click.echo("\n" + "=" * 60, err=True)
126
+ click.echo("AUTO-TUNED PARAMETERS", err=True)
127
+ click.echo("=" * 60, err=True)
128
+ click.echo(f"Video FPS: {video.fps:.2f}", err=True)
129
+
130
+ if characteristics:
131
+ click.echo(
132
+ f"Tracking quality: {characteristics.tracking_quality} "
133
+ f"(avg visibility: {characteristics.avg_visibility:.2f})",
134
+ err=True,
135
+ )
136
+
137
+ click.echo(f"Quality preset: {quality_preset.value}", err=True)
138
+ click.echo("\nSelected parameters:", err=True)
139
+ click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
140
+ click.echo(f" polyorder: {params.polyorder}", err=True)
141
+ click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
142
+
143
+ # Print extra parameters if provided
144
+ if extra_params:
145
+ for key, value in extra_params.items():
146
+ if isinstance(value, float):
147
+ click.echo(f" {key}: {value:.4f}", err=True)
148
+ else:
149
+ click.echo(f" {key}: {value}", err=True)
150
+
151
+ click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
152
+ click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
153
+ click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
154
+ click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
155
+ click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
156
+ click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
157
+ click.echo(f" use_curvature: {params.use_curvature}", err=True)
158
+ click.echo("=" * 60 + "\n", err=True)
159
+
160
+
82
161
  def smooth_landmark_sequence(landmarks_sequence: list, params: AutoTunedParams) -> list:
83
162
  """Apply smoothing to landmark sequence.
84
163
 
@@ -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
+ )
@@ -12,17 +12,15 @@ import click
12
12
  import numpy as np
13
13
 
14
14
  from ..api import VideoConfig, VideoResult, process_videos_bulk
15
- from ..core.auto_tuning import (
16
- AnalysisParameters as AutoTunedParams,
17
- )
18
15
  from ..core.auto_tuning import (
19
16
  QualityPreset,
20
- VideoCharacteristics,
21
17
  analyze_video_sample,
22
18
  auto_tune_parameters,
23
19
  )
24
20
  from ..core.cli_utils import (
21
+ apply_expert_param_overrides,
25
22
  determine_initial_confidence,
23
+ print_auto_tuned_params,
26
24
  smooth_landmark_sequence,
27
25
  track_all_frames,
28
26
  )
@@ -260,67 +258,6 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameter
260
258
  )
261
259
 
262
260
 
263
- def _apply_expert_param_overrides(
264
- params: AutoTunedParams, expert_params: AnalysisParameters
265
- ) -> AutoTunedParams:
266
- """Apply expert parameter overrides to auto-tuned parameters.
267
-
268
- Args:
269
- params: Auto-tuned parameters
270
- expert_params: Expert overrides
271
-
272
- Returns:
273
- Modified params object (mutated in place)
274
- """
275
- if expert_params.smoothing_window is not None:
276
- params.smoothing_window = expert_params.smoothing_window
277
- if expert_params.velocity_threshold is not None:
278
- params.velocity_threshold = expert_params.velocity_threshold
279
- if expert_params.min_contact_frames is not None:
280
- params.min_contact_frames = expert_params.min_contact_frames
281
- if expert_params.visibility_threshold is not None:
282
- params.visibility_threshold = expert_params.visibility_threshold
283
- return params
284
-
285
-
286
- def _print_auto_tuned_params(
287
- video: VideoProcessor,
288
- characteristics: VideoCharacteristics,
289
- quality_preset: QualityPreset,
290
- params: AutoTunedParams,
291
- ) -> None:
292
- """Print auto-tuned parameters in verbose mode.
293
-
294
- Args:
295
- video: Video processor
296
- characteristics: Video characteristics
297
- quality_preset: Quality preset
298
- params: Auto-tuned parameters
299
- """
300
- click.echo("\n" + "=" * 60, err=True)
301
- click.echo("AUTO-TUNED PARAMETERS", err=True)
302
- click.echo("=" * 60, err=True)
303
- click.echo(f"Video FPS: {video.fps:.2f}", err=True)
304
- click.echo(
305
- f"Tracking quality: {characteristics.tracking_quality} "
306
- f"(avg visibility: {characteristics.avg_visibility:.2f})",
307
- err=True,
308
- )
309
- click.echo(f"Quality preset: {quality_preset.value}", err=True)
310
- click.echo("\nSelected parameters:", err=True)
311
- click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
312
- click.echo(f" polyorder: {params.polyorder}", err=True)
313
- click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
314
- click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
315
- click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
316
- click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
317
- click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
318
- click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
319
- click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
320
- click.echo(f" use_curvature: {params.use_curvature}", err=True)
321
- click.echo("=" * 60 + "\n", err=True)
322
-
323
-
324
261
  def _extract_positions_and_visibilities(
325
262
  smoothed_landmarks: list,
326
263
  ) -> tuple[np.ndarray, np.ndarray]:
@@ -458,11 +395,11 @@ def _process_single(
458
395
  landmarks_sequence, video.fps, video.frame_count
459
396
  )
460
397
  params = auto_tune_parameters(characteristics, quality_preset)
461
- params = _apply_expert_param_overrides(params, expert_params)
398
+ params = apply_expert_param_overrides(params, expert_params)
462
399
 
463
400
  # Show parameters if verbose
464
401
  if verbose:
465
- _print_auto_tuned_params(video, characteristics, quality_preset, params)
402
+ print_auto_tuned_params(video, quality_preset, params, characteristics)
466
403
 
467
404
  # Apply smoothing
468
405
  smoothed_landmarks = smooth_landmark_sequence(landmarks_sequence, params)
@@ -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.4
3
+ Version: 0.11.6
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=lVVlh8teFHXbDzaFdDfq3xSwRf4kVwyYyd6FU8ta_Ec,19044
7
- kinemotion/cmj/debug_overlay.py,sha256=ELrSYQ9LmLV81bJS5w9i2c4VwRS0EYAUnMehMHU7VGc,18724
6
+ kinemotion/cmj/cli.py,sha256=3CQPJOC3N00dFz_mrEgSHq9pIA-RyOynZoNUIk8RzD0,17040
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=hNf2-_LIbi-ntXAkovjqcuWifYLazikqJBzeTN9YmZc,3492
12
+ kinemotion/core/cli_utils.py,sha256=iI1bHZxgbzxW5-2X3tTHEy2mqR49o3oIlwFptZ6gCco,6726
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
- kinemotion/dropjump/cli.py,sha256=eXO-9H9z0g-EJUD1uIT37KIMgfyje4fPAO2FgZiEZzk,24985
20
- kinemotion/dropjump/debug_overlay.py,sha256=GMo-jCl5OPIv82uPxDbBVI7CsAMwATTvxZMeWfs8k8M,8701
20
+ kinemotion/dropjump/cli.py,sha256=-iBgHNwW_dijHe6_JIEGSBUzvFb6tZV0aopbPd-9jC8,22402
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.4.dist-info/METADATA,sha256=0NPASGHoBcuSIKGlfsKsflT7j6o0DiHzMqhBm2-k2EM,18990
24
- kinemotion-0.11.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- kinemotion-0.11.4.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
26
- kinemotion-0.11.4.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
27
- kinemotion-0.11.4.dist-info/RECORD,,
24
+ kinemotion-0.11.6.dist-info/METADATA,sha256=UsgmtoeTQaucbGCimul6F5wuGllK2cHFw-QUUo4WWUs,18990
25
+ kinemotion-0.11.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
+ kinemotion-0.11.6.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
27
+ kinemotion-0.11.6.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
28
+ kinemotion-0.11.6.dist-info/RECORD,,