kinemotion 0.73.0__py3-none-any.whl → 0.75.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.
- kinemotion/core/__init__.py +11 -0
- kinemotion/core/auto_tuning.py +74 -27
- kinemotion/core/cli_utils.py +74 -0
- kinemotion/core/filtering.py +101 -45
- kinemotion/core/quality.py +4 -6
- kinemotion/core/validation.py +70 -0
- kinemotion/core/video_analysis_base.py +132 -0
- kinemotion/core/video_io.py +27 -18
- kinemotion/countermovement_jump/analysis.py +0 -97
- kinemotion/countermovement_jump/api.py +37 -11
- kinemotion/countermovement_jump/cli.py +6 -46
- kinemotion/countermovement_jump/metrics_validator.py +143 -229
- kinemotion/drop_jump/analysis.py +82 -37
- kinemotion/drop_jump/api.py +46 -16
- kinemotion/drop_jump/cli.py +8 -58
- kinemotion/drop_jump/kinematics.py +98 -50
- kinemotion/drop_jump/metrics_validator.py +24 -50
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/METADATA +1 -1
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/RECORD +22 -21
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.73.0.dist-info → kinemotion-0.75.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/core/__init__.py
CHANGED
|
@@ -34,9 +34,20 @@ from .timing import (
|
|
|
34
34
|
PerformanceTimer,
|
|
35
35
|
Timer,
|
|
36
36
|
)
|
|
37
|
+
from .video_analysis_base import (
|
|
38
|
+
AnalysisOverrides,
|
|
39
|
+
JumpAnalysisPipeline,
|
|
40
|
+
VideoAnalysisConfig,
|
|
41
|
+
VideoAnalysisResult,
|
|
42
|
+
)
|
|
37
43
|
from .video_io import VideoProcessor
|
|
38
44
|
|
|
39
45
|
__all__ = [
|
|
46
|
+
# Video Analysis Base
|
|
47
|
+
"AnalysisOverrides",
|
|
48
|
+
"JumpAnalysisPipeline",
|
|
49
|
+
"VideoAnalysisConfig",
|
|
50
|
+
"VideoAnalysisResult",
|
|
40
51
|
# Pose tracking
|
|
41
52
|
"MediaPipePoseTracker",
|
|
42
53
|
"PoseTrackerFactory",
|
kinemotion/core/auto_tuning.py
CHANGED
|
@@ -137,6 +137,73 @@ def analyze_tracking_quality(avg_visibility: float) -> str:
|
|
|
137
137
|
return "high"
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _compute_fps_baseline_parameters(fps: float) -> tuple[float, int, int]:
|
|
141
|
+
"""Compute FPS-based baseline parameters.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
fps: Video frame rate
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Tuple of (base_velocity_threshold, base_min_contact_frames, base_smoothing_window)
|
|
148
|
+
"""
|
|
149
|
+
# Base velocity threshold: 0.012 at 30fps, scaled inversely by fps
|
|
150
|
+
# Must exceed typical MediaPipe landmark jitter (0.5-2% per frame)
|
|
151
|
+
# Previous value of 0.004 was below noise floor, causing false IN_AIR detections
|
|
152
|
+
base_velocity_threshold = 0.012 * (30.0 / fps)
|
|
153
|
+
base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
|
|
154
|
+
|
|
155
|
+
# Smoothing window: Decrease with higher fps for better temporal resolution
|
|
156
|
+
base_smoothing_window = 3 if fps > 30 else 5
|
|
157
|
+
|
|
158
|
+
return base_velocity_threshold, base_min_contact_frames, base_smoothing_window
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _compute_smoothing_window(
|
|
162
|
+
fps: float,
|
|
163
|
+
preset: _PresetConfig,
|
|
164
|
+
quality_adj: _QualityAdjustment,
|
|
165
|
+
) -> int:
|
|
166
|
+
"""Compute smoothing window from FPS, preset, and quality adjustments.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
fps: Video frame rate
|
|
170
|
+
preset: Quality preset configuration
|
|
171
|
+
quality_adj: Quality-based adjustments
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Odd smoothing window size (required for Savitzky-Golay filter)
|
|
175
|
+
"""
|
|
176
|
+
_, _, base_smoothing_window = _compute_fps_baseline_parameters(fps)
|
|
177
|
+
|
|
178
|
+
# Smoothing window = base + preset offset + quality adjustment
|
|
179
|
+
smoothing_window = base_smoothing_window + preset.smoothing_offset + quality_adj.smoothing_add
|
|
180
|
+
smoothing_window = max(3, min(11, smoothing_window))
|
|
181
|
+
|
|
182
|
+
# Ensure smoothing window is odd (required for Savitzky-Golay)
|
|
183
|
+
if smoothing_window % 2 == 0:
|
|
184
|
+
smoothing_window += 1
|
|
185
|
+
|
|
186
|
+
return smoothing_window
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_bilateral_filter(
|
|
190
|
+
preset: _PresetConfig,
|
|
191
|
+
quality_adj: _QualityAdjustment,
|
|
192
|
+
) -> bool:
|
|
193
|
+
"""Resolve whether to enable bilateral filtering.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
preset: Quality preset configuration
|
|
197
|
+
quality_adj: Quality-based adjustments
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
True if bilateral filtering should be enabled
|
|
201
|
+
"""
|
|
202
|
+
if preset.force_bilateral is not None:
|
|
203
|
+
return preset.force_bilateral
|
|
204
|
+
return quality_adj.enable_bilateral
|
|
205
|
+
|
|
206
|
+
|
|
140
207
|
def auto_tune_parameters(
|
|
141
208
|
characteristics: VideoCharacteristics,
|
|
142
209
|
quality_preset: QualityPreset = QualityPreset.BALANCED,
|
|
@@ -163,42 +230,22 @@ def auto_tune_parameters(
|
|
|
163
230
|
fps = characteristics.fps
|
|
164
231
|
quality = characteristics.tracking_quality
|
|
165
232
|
|
|
166
|
-
# Get preset configuration
|
|
233
|
+
# Get preset configuration and quality-based adjustments
|
|
167
234
|
preset = _PRESET_CONFIGS[quality_preset]
|
|
168
|
-
|
|
169
|
-
# Get quality-based adjustments
|
|
170
235
|
quality_adj = _QUALITY_ADJUSTMENTS[quality]
|
|
171
236
|
|
|
172
237
|
# Compute FPS-based baseline parameters
|
|
173
|
-
|
|
174
|
-
# Must exceed typical MediaPipe landmark jitter (0.5-2% per frame)
|
|
175
|
-
# Previous value of 0.004 was below noise floor, causing false IN_AIR detections
|
|
176
|
-
base_velocity_threshold = 0.012 * (30.0 / fps)
|
|
177
|
-
base_min_contact_frames = max(2, round(3.0 * (fps / 30.0)))
|
|
178
|
-
|
|
179
|
-
# Smoothing window: Decrease with higher fps for better temporal resolution
|
|
180
|
-
if fps <= 30:
|
|
181
|
-
base_smoothing_window = 5
|
|
182
|
-
else:
|
|
183
|
-
base_smoothing_window = 3 # 60fps+ use 3-frame window
|
|
238
|
+
base_velocity_threshold, base_min_contact_frames, _ = _compute_fps_baseline_parameters(fps)
|
|
184
239
|
|
|
185
|
-
# Apply preset modifiers
|
|
240
|
+
# Apply preset modifiers
|
|
186
241
|
velocity_threshold = base_velocity_threshold * preset.velocity_multiplier
|
|
187
242
|
min_contact_frames = max(2, int(base_min_contact_frames * preset.contact_frames_multiplier))
|
|
188
243
|
|
|
189
|
-
#
|
|
190
|
-
smoothing_window =
|
|
191
|
-
smoothing_window = max(3, min(11, smoothing_window))
|
|
244
|
+
# Compute smoothing window with preset and quality adjustments
|
|
245
|
+
smoothing_window = _compute_smoothing_window(fps, preset, quality_adj)
|
|
192
246
|
|
|
193
|
-
#
|
|
194
|
-
|
|
195
|
-
smoothing_window += 1
|
|
196
|
-
|
|
197
|
-
# Bilateral filtering: preset can override, otherwise use quality-based
|
|
198
|
-
if preset.force_bilateral is not None:
|
|
199
|
-
bilateral_filter = preset.force_bilateral
|
|
200
|
-
else:
|
|
201
|
-
bilateral_filter = quality_adj.enable_bilateral
|
|
247
|
+
# Resolve bilateral filtering setting
|
|
248
|
+
bilateral_filter = _resolve_bilateral_filter(preset, quality_adj)
|
|
202
249
|
|
|
203
250
|
# Fixed optimal values
|
|
204
251
|
polyorder = 2 # Quadratic - optimal for parabolic motion
|
kinemotion/core/cli_utils.py
CHANGED
|
@@ -24,6 +24,80 @@ def common_output_options(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
|
24
24
|
return func
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def quality_option(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
28
|
+
"""Add quality preset option to CLI command."""
|
|
29
|
+
return click.option(
|
|
30
|
+
"--quality",
|
|
31
|
+
type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
|
|
32
|
+
default="balanced",
|
|
33
|
+
help=(
|
|
34
|
+
"Analysis quality preset: "
|
|
35
|
+
"fast (quick, less precise), "
|
|
36
|
+
"balanced (default, good for most cases), "
|
|
37
|
+
"accurate (research-grade, slower)"
|
|
38
|
+
),
|
|
39
|
+
show_default=True,
|
|
40
|
+
)(func)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verbose_option(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
44
|
+
"""Add verbose flag to CLI command."""
|
|
45
|
+
return click.option(
|
|
46
|
+
"--verbose",
|
|
47
|
+
"-v",
|
|
48
|
+
is_flag=True,
|
|
49
|
+
help="Show auto-selected parameters and analysis details",
|
|
50
|
+
)(func)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def batch_processing_options(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
54
|
+
"""Add batch processing options to CLI command."""
|
|
55
|
+
func = click.option(
|
|
56
|
+
"--batch",
|
|
57
|
+
is_flag=True,
|
|
58
|
+
help="Enable batch processing mode for multiple videos",
|
|
59
|
+
)(func)
|
|
60
|
+
func = click.option(
|
|
61
|
+
"--workers",
|
|
62
|
+
type=int,
|
|
63
|
+
default=4,
|
|
64
|
+
help="Number of parallel workers for batch processing (default: 4)",
|
|
65
|
+
show_default=True,
|
|
66
|
+
)(func)
|
|
67
|
+
func = click.option(
|
|
68
|
+
"--output-dir",
|
|
69
|
+
type=click.Path(),
|
|
70
|
+
help="Directory for debug video outputs (batch mode only)",
|
|
71
|
+
)(func)
|
|
72
|
+
func = click.option(
|
|
73
|
+
"--json-output-dir",
|
|
74
|
+
type=click.Path(),
|
|
75
|
+
help="Directory for JSON metrics outputs (batch mode only)",
|
|
76
|
+
)(func)
|
|
77
|
+
func = click.option(
|
|
78
|
+
"--csv-summary",
|
|
79
|
+
type=click.Path(),
|
|
80
|
+
help="Path for CSV summary export (batch mode only)",
|
|
81
|
+
)(func)
|
|
82
|
+
return func
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def common_analysis_options(func: Callable) -> Callable: # type: ignore[type-arg]
|
|
86
|
+
"""Add all common analysis options (output, quality, verbose, batch).
|
|
87
|
+
|
|
88
|
+
Combines:
|
|
89
|
+
- common_output_options (--output, --json-output)
|
|
90
|
+
- quality_option (--quality)
|
|
91
|
+
- verbose_option (--verbose)
|
|
92
|
+
- batch_processing_options (--batch, --workers, --output-dir, etc.)
|
|
93
|
+
"""
|
|
94
|
+
func = common_output_options(func)
|
|
95
|
+
func = quality_option(func)
|
|
96
|
+
func = verbose_option(func)
|
|
97
|
+
func = batch_processing_options(func)
|
|
98
|
+
return func
|
|
99
|
+
|
|
100
|
+
|
|
27
101
|
def collect_video_files(video_path: tuple[str, ...]) -> list[str]:
|
|
28
102
|
"""Expand glob patterns and collect all video files."""
|
|
29
103
|
video_files: list[str] = []
|
kinemotion/core/filtering.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Advanced filtering techniques for robust trajectory processing."""
|
|
2
2
|
|
|
3
3
|
import numpy as np
|
|
4
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
5
|
+
from scipy.ndimage import convolve1d
|
|
4
6
|
from scipy.signal import medfilt
|
|
5
7
|
|
|
6
8
|
from .experimental import unused
|
|
@@ -31,6 +33,8 @@ def detect_outliers_ransac(
|
|
|
31
33
|
from a polynomial fit of nearby points. This catches MediaPipe tracking glitches
|
|
32
34
|
where landmarks jump to incorrect positions.
|
|
33
35
|
|
|
36
|
+
Vectorized implementation using convolution for 10-20x speedup.
|
|
37
|
+
|
|
34
38
|
Args:
|
|
35
39
|
positions: 1D array of position values (e.g., y-coordinates)
|
|
36
40
|
window_size: Size of sliding window for local fitting
|
|
@@ -49,35 +53,79 @@ def detect_outliers_ransac(
|
|
|
49
53
|
window_size = _ensure_odd_window_length(window_size)
|
|
50
54
|
half_window = window_size // 2
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
# For centered quadratic fit, we can compute the predicted value at
|
|
57
|
+
# the window center using convolution. This is much faster than
|
|
58
|
+
# calling np.polyfit for each window.
|
|
59
|
+
#
|
|
60
|
+
# For a quadratic fit y = ax² + bx + c with centered window:
|
|
61
|
+
# - Predicted value at center (x=0) is just the intercept c
|
|
62
|
+
# - c can be computed from sum(y) and sum(x²*y) using precomputed constants
|
|
63
|
+
#
|
|
64
|
+
# The key insight: sum(y) and sum(x²*y) are convolution operations!
|
|
65
|
+
|
|
66
|
+
# Window indices (centered at 0)
|
|
67
|
+
x = np.arange(-half_window, half_window + 1)
|
|
68
|
+
|
|
69
|
+
# Precompute constants for the normal equations
|
|
70
|
+
sum_x2 = np.sum(x**2)
|
|
71
|
+
sum_x4 = np.sum(x**4)
|
|
72
|
+
det = window_size * sum_x4 - sum_x2**2
|
|
73
|
+
|
|
74
|
+
# Handle edge case where determinant is zero (shouldn't happen with valid window)
|
|
75
|
+
if det == 0:
|
|
76
|
+
return is_outlier
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
# Kernels for convolution
|
|
79
|
+
ones_kernel = np.ones(window_size)
|
|
80
|
+
x2_kernel = x**2
|
|
81
|
+
|
|
82
|
+
# Pad positions for boundary handling (use edge padding like original)
|
|
83
|
+
pad_width = half_window
|
|
84
|
+
padded = np.pad(positions, pad_width, mode="edge")
|
|
85
|
+
|
|
86
|
+
# Compute sums via convolution
|
|
87
|
+
# sum_y[i] = sum of positions in window centered at i
|
|
88
|
+
# sum_x2y[i] = sum of (x² * positions) in window centered at i
|
|
89
|
+
sum_y = convolve1d(padded, ones_kernel, mode="constant")
|
|
90
|
+
sum_x2y = convolve1d(padded, x2_kernel, mode="constant")
|
|
91
|
+
|
|
92
|
+
# Remove padding to match original positions length
|
|
93
|
+
sum_y = sum_y[pad_width:-pad_width]
|
|
94
|
+
sum_x2y = sum_x2y[pad_width:-pad_width]
|
|
95
|
+
|
|
96
|
+
# Compute predicted values at window centers
|
|
97
|
+
# For centered fit: predicted = c = (sum_x4 * sum_y - sum_x2 * sum_x2y) / det
|
|
98
|
+
predicted = (sum_x4 * sum_y - sum_x2 * sum_x2y) / det
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
# Calculate residuals
|
|
101
|
+
residuals = np.abs(positions - predicted)
|
|
102
|
+
|
|
103
|
+
# Mark outliers based on threshold
|
|
104
|
+
outlier_candidates = residuals > threshold
|
|
105
|
+
|
|
106
|
+
if not np.any(outlier_candidates):
|
|
107
|
+
return is_outlier
|
|
108
|
+
|
|
109
|
+
# RANSAC criterion: point is outlier if most OTHER points in window are inliers
|
|
110
|
+
# Compute fraction of inliers in each window using convolution
|
|
111
|
+
inlier_mask = (residuals <= threshold).astype(float)
|
|
112
|
+
inliers_in_window = convolve1d(
|
|
113
|
+
np.pad(inlier_mask, pad_width, mode="edge"),
|
|
114
|
+
ones_kernel,
|
|
115
|
+
mode="constant",
|
|
116
|
+
)
|
|
117
|
+
inliers_in_window = inliers_in_window[pad_width:-pad_width]
|
|
118
|
+
|
|
119
|
+
# Account for variable window sizes at boundaries
|
|
120
|
+
# At boundaries, windows are smaller, so we need to adjust the count
|
|
121
|
+
for i in range(n):
|
|
122
|
+
actual_window_size = min(i + half_window + 1, n) - max(0, i - half_window)
|
|
123
|
+
if actual_window_size < 3:
|
|
80
124
|
continue
|
|
125
|
+
if outlier_candidates[i]:
|
|
126
|
+
inlier_fraction = inliers_in_window[i] / actual_window_size
|
|
127
|
+
if inlier_fraction >= min_inliers:
|
|
128
|
+
is_outlier[i] = True
|
|
81
129
|
|
|
82
130
|
return is_outlier
|
|
83
131
|
|
|
@@ -310,6 +358,8 @@ def bilateral_temporal_filter(
|
|
|
310
358
|
1. Temporal distance (like regular smoothing)
|
|
311
359
|
2. Intensity similarity (preserves edges)
|
|
312
360
|
|
|
361
|
+
Vectorized implementation using sliding_window_view for 10-30x speedup.
|
|
362
|
+
|
|
313
363
|
Args:
|
|
314
364
|
positions: 1D array of position values
|
|
315
365
|
window_size: Temporal window size (must be odd)
|
|
@@ -320,33 +370,39 @@ def bilateral_temporal_filter(
|
|
|
320
370
|
Filtered position array
|
|
321
371
|
"""
|
|
322
372
|
n = len(positions)
|
|
323
|
-
|
|
373
|
+
if n == 0:
|
|
374
|
+
return np.array([])
|
|
324
375
|
|
|
325
376
|
window_size = _ensure_odd_window_length(window_size)
|
|
326
377
|
half_window = window_size // 2
|
|
327
378
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
379
|
+
# Pad edges with boundary values to maintain consistent window size
|
|
380
|
+
# This provides context for boundary positions while preserving edge information
|
|
381
|
+
padded = np.pad(positions, half_window, mode="edge")
|
|
382
|
+
|
|
383
|
+
# Create all sliding windows at once: shape (n, window_size)
|
|
384
|
+
# Each row represents the window centered at the corresponding input position
|
|
385
|
+
windows = sliding_window_view(padded, window_size)
|
|
332
386
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
387
|
+
# Precompute spatial weights (only depends on distance from center)
|
|
388
|
+
temporal_indices = np.arange(-half_window, half_window + 1)
|
|
389
|
+
spatial_weights = np.exp(-(temporal_indices**2) / (2 * sigma_spatial**2))
|
|
336
390
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
391
|
+
# Extract center positions for intensity weight computation
|
|
392
|
+
center_positions = windows[:, half_window] # Shape: (n,)
|
|
393
|
+
center_positions = center_positions.reshape(-1, 1) # Shape: (n, 1) for broadcast
|
|
340
394
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
395
|
+
# Compute intensity weights (data-dependent, varies by window)
|
|
396
|
+
# intensity_diff[i, j] = windows[i, j] - windows[i, center]
|
|
397
|
+
intensity_diff = windows - center_positions # Broadcasting: (n, window_size)
|
|
398
|
+
intensity_weights = np.exp(-(intensity_diff**2) / (2 * sigma_intensity**2))
|
|
344
399
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
400
|
+
# Combine weights: spatial_weights broadcasts to (n, window_size)
|
|
401
|
+
weights = spatial_weights * intensity_weights
|
|
402
|
+
# Normalize each window's weights to sum to 1
|
|
403
|
+
weights /= weights.sum(axis=1, keepdims=True)
|
|
348
404
|
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
# Compute weighted average for each window
|
|
406
|
+
filtered = (weights * windows).sum(axis=1)
|
|
351
407
|
|
|
352
408
|
return filtered
|
kinemotion/core/quality.py
CHANGED
|
@@ -86,13 +86,11 @@ def calculate_position_stability(
|
|
|
86
86
|
if len(positions) < window_size:
|
|
87
87
|
return float(np.var(positions))
|
|
88
88
|
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
for i in range(len(positions) - window_size + 1):
|
|
92
|
-
window = positions[i : i + window_size]
|
|
93
|
-
rolling_vars.append(np.var(window))
|
|
89
|
+
# Vectorized rolling variance using sliding window view
|
|
90
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
94
91
|
|
|
95
|
-
|
|
92
|
+
windows = sliding_window_view(positions, window_size)
|
|
93
|
+
return float(np.mean(np.var(windows, axis=1)))
|
|
96
94
|
|
|
97
95
|
|
|
98
96
|
def assess_tracking_quality(
|
kinemotion/core/validation.py
CHANGED
|
@@ -198,3 +198,73 @@ class MetricsValidator(ABC):
|
|
|
198
198
|
ValidationResult with all issues and status
|
|
199
199
|
"""
|
|
200
200
|
pass
|
|
201
|
+
|
|
202
|
+
def _validate_metric_with_bounds(
|
|
203
|
+
self,
|
|
204
|
+
name: str,
|
|
205
|
+
value: float,
|
|
206
|
+
bounds: MetricBounds,
|
|
207
|
+
profile: AthleteProfile | None,
|
|
208
|
+
result: ValidationResult,
|
|
209
|
+
error_suffix: str = "physically impossible",
|
|
210
|
+
format_str: str = "{value}",
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Generic validation for metrics with physical and profile bounds.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
name: Metric name for messages
|
|
216
|
+
value: Metric value
|
|
217
|
+
bounds: Bounds definition
|
|
218
|
+
profile: Athlete profile for expected ranges (can be None)
|
|
219
|
+
result: Validation result to add issues to
|
|
220
|
+
error_suffix: Description for out-of-bounds errors
|
|
221
|
+
format_str: Format string for value display
|
|
222
|
+
"""
|
|
223
|
+
formatted_value = format_str.format(value=value)
|
|
224
|
+
display_name = name.replace("_", " ").title()
|
|
225
|
+
|
|
226
|
+
if not bounds.is_physically_possible(value):
|
|
227
|
+
result.add_error(
|
|
228
|
+
name,
|
|
229
|
+
f"{display_name} {formatted_value} {error_suffix}",
|
|
230
|
+
value=value,
|
|
231
|
+
bounds=(bounds.absolute_min, bounds.absolute_max),
|
|
232
|
+
)
|
|
233
|
+
elif profile is not None and bounds.contains(value, profile):
|
|
234
|
+
result.add_info(
|
|
235
|
+
name,
|
|
236
|
+
f"{display_name} {formatted_value} within expected range for {profile.value}",
|
|
237
|
+
value=value,
|
|
238
|
+
)
|
|
239
|
+
elif profile is not None:
|
|
240
|
+
expected_min, expected_max = self._get_profile_range(profile, bounds)
|
|
241
|
+
result.add_warning(
|
|
242
|
+
name,
|
|
243
|
+
f"{display_name} {formatted_value} outside typical range "
|
|
244
|
+
f"[{expected_min:.3f}-{expected_max:.3f}] for {profile.value}",
|
|
245
|
+
value=value,
|
|
246
|
+
bounds=(expected_min, expected_max),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _get_profile_range(profile: AthleteProfile, bounds: MetricBounds) -> tuple[float, float]:
|
|
251
|
+
"""Get min/max bounds for specific profile.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
profile: Athlete profile
|
|
255
|
+
bounds: Metric bounds definition
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Tuple of (min, max) bounds for the profile
|
|
259
|
+
"""
|
|
260
|
+
profile_ranges = {
|
|
261
|
+
AthleteProfile.ELDERLY: (bounds.practical_min, bounds.recreational_max),
|
|
262
|
+
AthleteProfile.UNTRAINED: (bounds.practical_min, bounds.recreational_max),
|
|
263
|
+
AthleteProfile.RECREATIONAL: (bounds.recreational_min, bounds.recreational_max),
|
|
264
|
+
AthleteProfile.TRAINED: (
|
|
265
|
+
(bounds.recreational_min + bounds.elite_min) / 2,
|
|
266
|
+
(bounds.recreational_max + bounds.elite_max) / 2,
|
|
267
|
+
),
|
|
268
|
+
AthleteProfile.ELITE: (bounds.elite_min, bounds.elite_max),
|
|
269
|
+
}
|
|
270
|
+
return profile_ranges.get(profile, (bounds.absolute_min, bounds.absolute_max))
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Base types and patterns for video analysis APIs.
|
|
2
|
+
|
|
3
|
+
This module defines shared infrastructure for jump-type-specific analysis modules.
|
|
4
|
+
Each jump type (CMJ, Drop Jump, etc.) has its own analysis algorithms, but they
|
|
5
|
+
share common patterns for:
|
|
6
|
+
|
|
7
|
+
1. Configuration (VideoConfig dataclass)
|
|
8
|
+
2. Results (VideoResult dataclass)
|
|
9
|
+
3. Parameter overrides (AnalysisOverrides dataclass)
|
|
10
|
+
4. Bulk processing utilities
|
|
11
|
+
|
|
12
|
+
To add a new jump type:
|
|
13
|
+
1. Create a new module: src/kinemotion/{jump_type}/
|
|
14
|
+
2. Implement analysis algorithms in {jump_type}/analysis.py
|
|
15
|
+
3. Use the patterns in this module for API structure
|
|
16
|
+
4. Import process_videos_bulk_generic from pipeline_utils for bulk processing
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..auto_tuning import QualityPreset
|
|
26
|
+
from ..timing import Timer
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AnalysisOverrides",
|
|
30
|
+
"VideoAnalysisConfig",
|
|
31
|
+
"VideoAnalysisResult",
|
|
32
|
+
"JumpAnalysisPipeline",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class AnalysisOverrides:
|
|
38
|
+
"""Optional overrides for analysis parameters.
|
|
39
|
+
|
|
40
|
+
Allows fine-tuning of specific analysis parameters beyond quality presets.
|
|
41
|
+
If None, values will be determined by the quality preset.
|
|
42
|
+
|
|
43
|
+
Common overrides across all jump types:
|
|
44
|
+
- smoothing_window: Number of frames for Savitzky-Golay smoothing
|
|
45
|
+
- velocity_threshold: Threshold for phase detection
|
|
46
|
+
- min_contact_frames: Minimum frames for ground contact
|
|
47
|
+
- visibility_threshold: Minimum landmark visibility (0-1)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
smoothing_window: int | None = None
|
|
51
|
+
velocity_threshold: float | None = None
|
|
52
|
+
min_contact_frames: int | None = None
|
|
53
|
+
visibility_threshold: float | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class VideoAnalysisConfig:
|
|
58
|
+
"""Base configuration for video analysis.
|
|
59
|
+
|
|
60
|
+
Subclasses should add jump-type-specific fields (e.g., drop_start_frame
|
|
61
|
+
for Drop Jump, or additional overrides for CMJ).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
video_path: str
|
|
65
|
+
quality: str = "balanced"
|
|
66
|
+
output_video: str | None = None
|
|
67
|
+
json_output: str | None = None
|
|
68
|
+
overrides: AnalysisOverrides | None = None
|
|
69
|
+
detection_confidence: float | None = None
|
|
70
|
+
tracking_confidence: float | None = None
|
|
71
|
+
verbose: bool = False
|
|
72
|
+
timer: "Timer | None" = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class VideoAnalysisResult:
|
|
77
|
+
"""Base result for video analysis.
|
|
78
|
+
|
|
79
|
+
Subclasses should add jump-type-specific fields.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
video_path: str
|
|
83
|
+
success: bool
|
|
84
|
+
metrics: object | None = None # Will be CMJMetrics, DropJumpMetrics, etc.
|
|
85
|
+
error: str | None = None
|
|
86
|
+
processing_time: float = 0.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class JumpAnalysisPipeline(ABC):
|
|
90
|
+
"""Abstract base class for jump analysis pipelines.
|
|
91
|
+
|
|
92
|
+
Defines the common structure for processing jump videos. Each jump type
|
|
93
|
+
implements the specific analysis logic while following this pattern.
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
class CMJPipeline(JumpAnalysisPipeline):
|
|
97
|
+
def analyze(self) -> CMJMetrics:
|
|
98
|
+
# CMJ-specific analysis (backward search algorithm)
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
class DropJumpPipeline(JumpAnalysisPipeline):
|
|
102
|
+
def analyze(self) -> DropJumpMetrics:
|
|
103
|
+
# Drop jump-specific analysis (forward search algorithm)
|
|
104
|
+
...
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
video_path: str,
|
|
110
|
+
quality_preset: "QualityPreset",
|
|
111
|
+
overrides: AnalysisOverrides | None,
|
|
112
|
+
timer: "Timer",
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Initialize the analysis pipeline."""
|
|
115
|
+
self.video_path = video_path
|
|
116
|
+
self.quality_preset = quality_preset
|
|
117
|
+
self.overrides = overrides
|
|
118
|
+
self.timer = timer
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
def analyze(self) -> object:
|
|
122
|
+
"""Run the jump-specific analysis algorithm.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Metrics object with jump-type-specific results.
|
|
126
|
+
"""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
def validate_video_exists(self) -> None:
|
|
130
|
+
"""Validate that the input video file exists."""
|
|
131
|
+
if not Path(self.video_path).exists():
|
|
132
|
+
raise FileNotFoundError(f"Video file not found: {self.video_path}")
|