kinemotion 0.10.12__py3-none-any.whl → 0.11.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/__init__.py +18 -1
- kinemotion/api.py +335 -0
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +548 -0
- kinemotion/cmj/cli.py +626 -0
- kinemotion/cmj/debug_overlay.py +514 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/METADATA +92 -124
- kinemotion-0.11.0.dist-info/RECORD +26 -0
- kinemotion-0.10.12.dist-info/RECORD +0 -20
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.10.12.dist-info → kinemotion-0.11.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/cmj/cli.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
"""Command-line interface for counter movement jump (CMJ) analysis."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..core.auto_tuning import (
|
|
14
|
+
AnalysisParameters as AutoTunedParams,
|
|
15
|
+
)
|
|
16
|
+
from ..core.auto_tuning import (
|
|
17
|
+
QualityPreset,
|
|
18
|
+
analyze_video_sample,
|
|
19
|
+
auto_tune_parameters,
|
|
20
|
+
)
|
|
21
|
+
from ..core.pose import PoseTracker
|
|
22
|
+
from ..core.smoothing import smooth_landmarks, smooth_landmarks_advanced
|
|
23
|
+
from ..core.video_io import VideoProcessor
|
|
24
|
+
from .analysis import detect_cmj_phases
|
|
25
|
+
from .debug_overlay import CMJDebugOverlayRenderer
|
|
26
|
+
from .kinematics import CMJMetrics, calculate_cmj_metrics
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AnalysisParameters:
|
|
31
|
+
"""Expert parameters for CMJ analysis customization."""
|
|
32
|
+
|
|
33
|
+
smoothing_window: int | None = None
|
|
34
|
+
velocity_threshold: float | None = None
|
|
35
|
+
countermovement_threshold: float | None = None
|
|
36
|
+
min_contact_frames: int | None = None
|
|
37
|
+
visibility_threshold: float | None = None
|
|
38
|
+
detection_confidence: float | None = None
|
|
39
|
+
tracking_confidence: float | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command(name="cmj-analyze")
|
|
43
|
+
@click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--output",
|
|
46
|
+
"-o",
|
|
47
|
+
type=click.Path(),
|
|
48
|
+
help="Path for debug video output (optional)",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--json-output",
|
|
52
|
+
"-j",
|
|
53
|
+
type=click.Path(),
|
|
54
|
+
help="Path for JSON metrics output (default: stdout)",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"--quality",
|
|
58
|
+
type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
|
|
59
|
+
default="balanced",
|
|
60
|
+
help=(
|
|
61
|
+
"Analysis quality preset: "
|
|
62
|
+
"fast (quick, less precise), "
|
|
63
|
+
"balanced (default, good for most cases), "
|
|
64
|
+
"accurate (research-grade, slower)"
|
|
65
|
+
),
|
|
66
|
+
show_default=True,
|
|
67
|
+
)
|
|
68
|
+
@click.option(
|
|
69
|
+
"--verbose",
|
|
70
|
+
"-v",
|
|
71
|
+
is_flag=True,
|
|
72
|
+
help="Show auto-selected parameters and analysis details",
|
|
73
|
+
)
|
|
74
|
+
# Batch processing options
|
|
75
|
+
@click.option(
|
|
76
|
+
"--batch",
|
|
77
|
+
is_flag=True,
|
|
78
|
+
help="Enable batch processing mode for multiple videos",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--workers",
|
|
82
|
+
type=int,
|
|
83
|
+
default=4,
|
|
84
|
+
help="Number of parallel workers for batch processing (default: 4)",
|
|
85
|
+
show_default=True,
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--output-dir",
|
|
89
|
+
type=click.Path(),
|
|
90
|
+
help="Directory for debug video outputs (batch mode only)",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--json-output-dir",
|
|
94
|
+
type=click.Path(),
|
|
95
|
+
help="Directory for JSON metrics outputs (batch mode only)",
|
|
96
|
+
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--csv-summary",
|
|
99
|
+
type=click.Path(),
|
|
100
|
+
help="Path for CSV summary export (batch mode only)",
|
|
101
|
+
)
|
|
102
|
+
# Expert parameters (hidden in help, but always available for advanced users)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--smoothing-window",
|
|
105
|
+
type=int,
|
|
106
|
+
default=None,
|
|
107
|
+
help="[EXPERT] Override auto-tuned smoothing window size",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--velocity-threshold",
|
|
111
|
+
type=float,
|
|
112
|
+
default=None,
|
|
113
|
+
help="[EXPERT] Override auto-tuned velocity threshold for flight detection",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--countermovement-threshold",
|
|
117
|
+
type=float,
|
|
118
|
+
default=None,
|
|
119
|
+
help="[EXPERT] Override auto-tuned countermovement threshold (negative value)",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--min-contact-frames",
|
|
123
|
+
type=int,
|
|
124
|
+
default=None,
|
|
125
|
+
help="[EXPERT] Override auto-tuned minimum contact frames",
|
|
126
|
+
)
|
|
127
|
+
@click.option(
|
|
128
|
+
"--visibility-threshold",
|
|
129
|
+
type=float,
|
|
130
|
+
default=None,
|
|
131
|
+
help="[EXPERT] Override visibility threshold",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--detection-confidence",
|
|
135
|
+
type=float,
|
|
136
|
+
default=None,
|
|
137
|
+
help="[EXPERT] Override pose detection confidence",
|
|
138
|
+
)
|
|
139
|
+
@click.option(
|
|
140
|
+
"--tracking-confidence",
|
|
141
|
+
type=float,
|
|
142
|
+
default=None,
|
|
143
|
+
help="[EXPERT] Override pose tracking confidence",
|
|
144
|
+
)
|
|
145
|
+
def cmj_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
|
|
146
|
+
video_path: tuple[str, ...],
|
|
147
|
+
output: str | None,
|
|
148
|
+
json_output: str | None,
|
|
149
|
+
quality: str,
|
|
150
|
+
verbose: bool,
|
|
151
|
+
batch: bool,
|
|
152
|
+
workers: int,
|
|
153
|
+
output_dir: str | None,
|
|
154
|
+
json_output_dir: str | None,
|
|
155
|
+
csv_summary: str | None,
|
|
156
|
+
smoothing_window: int | None,
|
|
157
|
+
velocity_threshold: float | None,
|
|
158
|
+
countermovement_threshold: float | None,
|
|
159
|
+
min_contact_frames: int | None,
|
|
160
|
+
visibility_threshold: float | None,
|
|
161
|
+
detection_confidence: float | None,
|
|
162
|
+
tracking_confidence: float | None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Analyze counter movement jump (CMJ) video(s) to estimate jump performance metrics.
|
|
166
|
+
|
|
167
|
+
Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
|
|
168
|
+
Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
|
|
169
|
+
|
|
170
|
+
VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
|
|
174
|
+
\\b
|
|
175
|
+
# Basic analysis
|
|
176
|
+
kinemotion cmj-analyze video.mp4
|
|
177
|
+
|
|
178
|
+
\\b
|
|
179
|
+
# With debug video output
|
|
180
|
+
kinemotion cmj-analyze video.mp4 --output debug.mp4
|
|
181
|
+
|
|
182
|
+
\\b
|
|
183
|
+
# Batch mode with glob pattern
|
|
184
|
+
kinemotion cmj-analyze videos/*.mp4 --batch --workers 4
|
|
185
|
+
|
|
186
|
+
\\b
|
|
187
|
+
# Batch with output directories
|
|
188
|
+
kinemotion cmj-analyze videos/*.mp4 --batch \\
|
|
189
|
+
--json-output-dir results/ --csv-summary summary.csv
|
|
190
|
+
"""
|
|
191
|
+
# Expand glob patterns and collect all video files
|
|
192
|
+
video_files: list[str] = []
|
|
193
|
+
for pattern in video_path:
|
|
194
|
+
expanded = glob.glob(pattern)
|
|
195
|
+
if expanded:
|
|
196
|
+
video_files.extend(expanded)
|
|
197
|
+
elif Path(pattern).exists():
|
|
198
|
+
video_files.append(pattern)
|
|
199
|
+
else:
|
|
200
|
+
click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
|
|
201
|
+
|
|
202
|
+
if not video_files:
|
|
203
|
+
click.echo("Error: No video files found", err=True)
|
|
204
|
+
sys.exit(1)
|
|
205
|
+
|
|
206
|
+
# Determine if batch mode should be used
|
|
207
|
+
use_batch = batch or len(video_files) > 1
|
|
208
|
+
|
|
209
|
+
quality_preset = QualityPreset(quality.lower())
|
|
210
|
+
|
|
211
|
+
# Group expert parameters
|
|
212
|
+
expert_params = AnalysisParameters(
|
|
213
|
+
smoothing_window=smoothing_window,
|
|
214
|
+
velocity_threshold=velocity_threshold,
|
|
215
|
+
countermovement_threshold=countermovement_threshold,
|
|
216
|
+
min_contact_frames=min_contact_frames,
|
|
217
|
+
visibility_threshold=visibility_threshold,
|
|
218
|
+
detection_confidence=detection_confidence,
|
|
219
|
+
tracking_confidence=tracking_confidence,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if use_batch:
|
|
223
|
+
click.echo(
|
|
224
|
+
f"Batch mode: Processing {len(video_files)} video(s) with {workers} workers",
|
|
225
|
+
err=True,
|
|
226
|
+
)
|
|
227
|
+
click.echo("Note: Batch processing not yet fully implemented", err=True)
|
|
228
|
+
click.echo("Processing videos sequentially...", err=True)
|
|
229
|
+
for video in video_files:
|
|
230
|
+
try:
|
|
231
|
+
click.echo(f"\nProcessing: {video}", err=True)
|
|
232
|
+
out_path = None
|
|
233
|
+
json_path = None
|
|
234
|
+
if output_dir:
|
|
235
|
+
out_path = str(Path(output_dir) / f"{Path(video).stem}_debug.mp4")
|
|
236
|
+
if json_output_dir:
|
|
237
|
+
json_path = str(Path(json_output_dir) / f"{Path(video).stem}.json")
|
|
238
|
+
_process_single(
|
|
239
|
+
video, out_path, json_path, quality_preset, verbose, expert_params
|
|
240
|
+
)
|
|
241
|
+
except Exception as e:
|
|
242
|
+
click.echo(f"Error processing {video}: {e}", err=True)
|
|
243
|
+
continue
|
|
244
|
+
else:
|
|
245
|
+
# Single video mode
|
|
246
|
+
try:
|
|
247
|
+
_process_single(
|
|
248
|
+
video_files[0],
|
|
249
|
+
output,
|
|
250
|
+
json_output,
|
|
251
|
+
quality_preset,
|
|
252
|
+
verbose,
|
|
253
|
+
expert_params,
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
click.echo(f"Error: {e}", err=True)
|
|
257
|
+
sys.exit(1)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _determine_initial_confidence(
|
|
261
|
+
quality_preset: QualityPreset,
|
|
262
|
+
expert_params: AnalysisParameters,
|
|
263
|
+
) -> tuple[float, float]:
|
|
264
|
+
"""Determine initial detection and tracking confidence levels."""
|
|
265
|
+
initial_detection_conf = 0.5
|
|
266
|
+
initial_tracking_conf = 0.5
|
|
267
|
+
|
|
268
|
+
if quality_preset == QualityPreset.FAST:
|
|
269
|
+
initial_detection_conf = 0.3
|
|
270
|
+
initial_tracking_conf = 0.3
|
|
271
|
+
elif quality_preset == QualityPreset.ACCURATE:
|
|
272
|
+
initial_detection_conf = 0.6
|
|
273
|
+
initial_tracking_conf = 0.6
|
|
274
|
+
|
|
275
|
+
# Override with expert values if provided
|
|
276
|
+
if expert_params.detection_confidence is not None:
|
|
277
|
+
initial_detection_conf = expert_params.detection_confidence
|
|
278
|
+
if expert_params.tracking_confidence is not None:
|
|
279
|
+
initial_tracking_conf = expert_params.tracking_confidence
|
|
280
|
+
|
|
281
|
+
return initial_detection_conf, initial_tracking_conf
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _track_all_frames(video: VideoProcessor, tracker: PoseTracker) -> tuple[list, list]:
|
|
285
|
+
"""Track pose landmarks in all video frames."""
|
|
286
|
+
click.echo("Tracking pose landmarks...", err=True)
|
|
287
|
+
landmarks_sequence = []
|
|
288
|
+
frames = []
|
|
289
|
+
|
|
290
|
+
bar: Any
|
|
291
|
+
with click.progressbar(length=video.frame_count, label="Processing frames") as bar:
|
|
292
|
+
while True:
|
|
293
|
+
frame = video.read_frame()
|
|
294
|
+
if frame is None:
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
frames.append(frame)
|
|
298
|
+
landmarks = tracker.process_frame(frame)
|
|
299
|
+
landmarks_sequence.append(landmarks)
|
|
300
|
+
|
|
301
|
+
bar.update(1)
|
|
302
|
+
|
|
303
|
+
tracker.close()
|
|
304
|
+
return frames, landmarks_sequence
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _apply_expert_param_overrides(
|
|
308
|
+
params: AutoTunedParams, expert_params: AnalysisParameters
|
|
309
|
+
) -> AutoTunedParams:
|
|
310
|
+
"""Apply expert parameter overrides to auto-tuned parameters."""
|
|
311
|
+
if expert_params.smoothing_window is not None:
|
|
312
|
+
params.smoothing_window = expert_params.smoothing_window
|
|
313
|
+
if expert_params.velocity_threshold is not None:
|
|
314
|
+
params.velocity_threshold = expert_params.velocity_threshold
|
|
315
|
+
if expert_params.min_contact_frames is not None:
|
|
316
|
+
params.min_contact_frames = expert_params.min_contact_frames
|
|
317
|
+
if expert_params.visibility_threshold is not None:
|
|
318
|
+
params.visibility_threshold = expert_params.visibility_threshold
|
|
319
|
+
return params
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _print_auto_tuned_params(
|
|
323
|
+
video: VideoProcessor,
|
|
324
|
+
quality_preset: QualityPreset,
|
|
325
|
+
params: AutoTunedParams,
|
|
326
|
+
countermovement_threshold: float,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""Print auto-tuned parameters in verbose mode."""
|
|
329
|
+
click.echo("\n" + "=" * 60, err=True)
|
|
330
|
+
click.echo("AUTO-TUNED PARAMETERS", err=True)
|
|
331
|
+
click.echo("=" * 60, err=True)
|
|
332
|
+
click.echo(f"Video FPS: {video.fps:.2f}", err=True)
|
|
333
|
+
click.echo(f"Quality preset: {quality_preset.value}", err=True)
|
|
334
|
+
click.echo("\nSelected parameters:", err=True)
|
|
335
|
+
click.echo(f" smoothing_window: {params.smoothing_window}", err=True)
|
|
336
|
+
click.echo(f" polyorder: {params.polyorder}", err=True)
|
|
337
|
+
click.echo(f" velocity_threshold: {params.velocity_threshold:.4f}", err=True)
|
|
338
|
+
click.echo(
|
|
339
|
+
f" countermovement_threshold: {countermovement_threshold:.4f}", err=True
|
|
340
|
+
)
|
|
341
|
+
click.echo(f" min_contact_frames: {params.min_contact_frames}", err=True)
|
|
342
|
+
click.echo(f" visibility_threshold: {params.visibility_threshold}", err=True)
|
|
343
|
+
click.echo(f" detection_confidence: {params.detection_confidence}", err=True)
|
|
344
|
+
click.echo(f" tracking_confidence: {params.tracking_confidence}", err=True)
|
|
345
|
+
click.echo(f" outlier_rejection: {params.outlier_rejection}", err=True)
|
|
346
|
+
click.echo(f" bilateral_filter: {params.bilateral_filter}", err=True)
|
|
347
|
+
click.echo(f" use_curvature: {params.use_curvature}", err=True)
|
|
348
|
+
click.echo("=" * 60 + "\n", err=True)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _smooth_landmark_sequence(
|
|
352
|
+
landmarks_sequence: list, params: AutoTunedParams
|
|
353
|
+
) -> list:
|
|
354
|
+
"""Apply smoothing to landmark sequence."""
|
|
355
|
+
if params.outlier_rejection or params.bilateral_filter:
|
|
356
|
+
if params.outlier_rejection:
|
|
357
|
+
click.echo("Smoothing landmarks with outlier rejection...", err=True)
|
|
358
|
+
if params.bilateral_filter:
|
|
359
|
+
click.echo(
|
|
360
|
+
"Using bilateral temporal filter for edge-preserving smoothing...",
|
|
361
|
+
err=True,
|
|
362
|
+
)
|
|
363
|
+
return smooth_landmarks_advanced(
|
|
364
|
+
landmarks_sequence,
|
|
365
|
+
window_length=params.smoothing_window,
|
|
366
|
+
polyorder=params.polyorder,
|
|
367
|
+
use_outlier_rejection=params.outlier_rejection,
|
|
368
|
+
use_bilateral=params.bilateral_filter,
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
click.echo("Smoothing landmarks...", err=True)
|
|
372
|
+
return smooth_landmarks(
|
|
373
|
+
landmarks_sequence,
|
|
374
|
+
window_length=params.smoothing_window,
|
|
375
|
+
polyorder=params.polyorder,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _extract_positions_from_landmarks(
|
|
380
|
+
smoothed_landmarks: list,
|
|
381
|
+
) -> tuple[np.ndarray, str]:
|
|
382
|
+
"""Extract vertical foot positions from landmarks.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
smoothed_landmarks: Smoothed landmark sequence
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Tuple of (positions array, tracking method name)
|
|
389
|
+
"""
|
|
390
|
+
click.echo("Extracting foot positions...", err=True)
|
|
391
|
+
position_list: list[float] = []
|
|
392
|
+
|
|
393
|
+
for frame_landmarks in smoothed_landmarks:
|
|
394
|
+
if frame_landmarks:
|
|
395
|
+
# Average foot position (ankles and heels)
|
|
396
|
+
foot_y_values = []
|
|
397
|
+
for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
|
|
398
|
+
if key in frame_landmarks:
|
|
399
|
+
foot_y_values.append(frame_landmarks[key][1])
|
|
400
|
+
|
|
401
|
+
if foot_y_values:
|
|
402
|
+
avg_y = float(np.mean(foot_y_values))
|
|
403
|
+
position_list.append(avg_y)
|
|
404
|
+
else:
|
|
405
|
+
position_list.append(position_list[-1] if position_list else 0.5)
|
|
406
|
+
else:
|
|
407
|
+
position_list.append(position_list[-1] if position_list else 0.5)
|
|
408
|
+
|
|
409
|
+
return np.array(position_list), "foot"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _process_single(
|
|
413
|
+
video_path: str,
|
|
414
|
+
output: str | None,
|
|
415
|
+
json_output: str | None,
|
|
416
|
+
quality_preset: QualityPreset,
|
|
417
|
+
verbose: bool,
|
|
418
|
+
expert_params: AnalysisParameters,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Process a single CMJ video."""
|
|
421
|
+
try:
|
|
422
|
+
with VideoProcessor(video_path) as video:
|
|
423
|
+
click.echo(
|
|
424
|
+
f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
|
|
425
|
+
f"{video.frame_count} frames",
|
|
426
|
+
err=True,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Determine confidence levels
|
|
430
|
+
detection_conf, tracking_conf = _determine_initial_confidence(
|
|
431
|
+
quality_preset, expert_params
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Track all frames
|
|
435
|
+
tracker = PoseTracker(
|
|
436
|
+
min_detection_confidence=detection_conf,
|
|
437
|
+
min_tracking_confidence=tracking_conf,
|
|
438
|
+
)
|
|
439
|
+
frames, landmarks_sequence = _track_all_frames(video, tracker)
|
|
440
|
+
|
|
441
|
+
if not landmarks_sequence:
|
|
442
|
+
click.echo("Error: No frames processed", err=True)
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
|
|
445
|
+
# Auto-tune parameters
|
|
446
|
+
characteristics = analyze_video_sample(
|
|
447
|
+
landmarks_sequence, video.fps, video.frame_count
|
|
448
|
+
)
|
|
449
|
+
params = auto_tune_parameters(characteristics, quality_preset)
|
|
450
|
+
params = _apply_expert_param_overrides(params, expert_params)
|
|
451
|
+
|
|
452
|
+
# Calculate countermovement threshold (FPS-adjusted)
|
|
453
|
+
# Base: +0.015 at 30fps (POSITIVE for downward motion in normalized coords)
|
|
454
|
+
countermovement_threshold = 0.015 * (30.0 / video.fps)
|
|
455
|
+
if expert_params.countermovement_threshold is not None:
|
|
456
|
+
countermovement_threshold = expert_params.countermovement_threshold
|
|
457
|
+
|
|
458
|
+
# Show parameters if verbose
|
|
459
|
+
if verbose:
|
|
460
|
+
_print_auto_tuned_params(
|
|
461
|
+
video, quality_preset, params, countermovement_threshold
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Apply smoothing
|
|
465
|
+
smoothed_landmarks = _smooth_landmark_sequence(landmarks_sequence, params)
|
|
466
|
+
|
|
467
|
+
# Extract foot positions
|
|
468
|
+
vertical_positions, tracking_method = _extract_positions_from_landmarks(
|
|
469
|
+
smoothed_landmarks
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Detect CMJ phases
|
|
473
|
+
click.echo("Detecting CMJ phases...", err=True)
|
|
474
|
+
phases = detect_cmj_phases(
|
|
475
|
+
vertical_positions,
|
|
476
|
+
video.fps,
|
|
477
|
+
velocity_threshold=params.velocity_threshold,
|
|
478
|
+
countermovement_threshold=countermovement_threshold,
|
|
479
|
+
min_contact_frames=params.min_contact_frames,
|
|
480
|
+
min_eccentric_frames=params.min_contact_frames,
|
|
481
|
+
use_curvature=params.use_curvature,
|
|
482
|
+
window_length=params.smoothing_window,
|
|
483
|
+
polyorder=params.polyorder,
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if phases is None:
|
|
487
|
+
click.echo("Error: Could not detect CMJ phases", err=True)
|
|
488
|
+
sys.exit(1)
|
|
489
|
+
|
|
490
|
+
standing_end, lowest_point, takeoff_frame, landing_frame = phases
|
|
491
|
+
|
|
492
|
+
# Calculate metrics
|
|
493
|
+
click.echo("Calculating metrics...", err=True)
|
|
494
|
+
|
|
495
|
+
# Compute SIGNED velocities for CMJ metrics (need direction info)
|
|
496
|
+
from .analysis import compute_signed_velocity
|
|
497
|
+
|
|
498
|
+
velocities = compute_signed_velocity(
|
|
499
|
+
vertical_positions,
|
|
500
|
+
window_length=params.smoothing_window,
|
|
501
|
+
polyorder=params.polyorder,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
metrics = calculate_cmj_metrics(
|
|
505
|
+
vertical_positions,
|
|
506
|
+
velocities,
|
|
507
|
+
standing_end,
|
|
508
|
+
lowest_point,
|
|
509
|
+
takeoff_frame,
|
|
510
|
+
landing_frame,
|
|
511
|
+
video.fps,
|
|
512
|
+
tracking_method=tracking_method,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Output results
|
|
516
|
+
_output_results(metrics, json_output)
|
|
517
|
+
|
|
518
|
+
# Generate debug video if requested
|
|
519
|
+
if output:
|
|
520
|
+
_create_debug_video(output, video, frames, smoothed_landmarks, metrics)
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
click.echo(f"Error processing video: {e}", err=True)
|
|
524
|
+
import traceback
|
|
525
|
+
|
|
526
|
+
traceback.print_exc()
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _create_debug_video(
|
|
531
|
+
output: str,
|
|
532
|
+
video: VideoProcessor,
|
|
533
|
+
frames: list,
|
|
534
|
+
smoothed_landmarks: list,
|
|
535
|
+
metrics: CMJMetrics,
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Generate debug video with overlays.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
output: Output video path
|
|
541
|
+
video: Video processor
|
|
542
|
+
frames: Video frames
|
|
543
|
+
smoothed_landmarks: Smoothed landmarks
|
|
544
|
+
metrics: Calculated metrics
|
|
545
|
+
"""
|
|
546
|
+
click.echo(f"Generating debug video: {output}", err=True)
|
|
547
|
+
if video.display_width != video.width or video.display_height != video.height:
|
|
548
|
+
click.echo(f"Source video encoded: {video.width}x{video.height}", err=True)
|
|
549
|
+
click.echo(
|
|
550
|
+
f"Output dimensions: {video.display_width}x{video.display_height} "
|
|
551
|
+
f"(respecting display aspect ratio)",
|
|
552
|
+
err=True,
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
click.echo(
|
|
556
|
+
f"Output dimensions: {video.width}x{video.height} "
|
|
557
|
+
f"(matching source video aspect ratio)",
|
|
558
|
+
err=True,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
with CMJDebugOverlayRenderer(
|
|
562
|
+
output,
|
|
563
|
+
video.width,
|
|
564
|
+
video.height,
|
|
565
|
+
video.display_width,
|
|
566
|
+
video.display_height,
|
|
567
|
+
video.fps,
|
|
568
|
+
) as renderer:
|
|
569
|
+
render_bar: Any
|
|
570
|
+
with click.progressbar(
|
|
571
|
+
length=len(frames), label="Rendering frames"
|
|
572
|
+
) as render_bar:
|
|
573
|
+
for i, frame in enumerate(frames):
|
|
574
|
+
annotated = renderer.render_frame(
|
|
575
|
+
frame, smoothed_landmarks[i], i, metrics
|
|
576
|
+
)
|
|
577
|
+
renderer.write_frame(annotated)
|
|
578
|
+
render_bar.update(1)
|
|
579
|
+
|
|
580
|
+
click.echo(f"Debug video saved: {output}", err=True)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _output_results(metrics: Any, json_output: str | None) -> None:
|
|
584
|
+
"""Output analysis results."""
|
|
585
|
+
results = metrics.to_dict()
|
|
586
|
+
|
|
587
|
+
# Output JSON
|
|
588
|
+
if json_output:
|
|
589
|
+
with open(json_output, "w") as f:
|
|
590
|
+
json.dump(results, f, indent=2)
|
|
591
|
+
click.echo(f"Metrics saved to: {json_output}", err=True)
|
|
592
|
+
else:
|
|
593
|
+
# Output to stdout
|
|
594
|
+
print(json.dumps(results, indent=2))
|
|
595
|
+
|
|
596
|
+
# Print summary
|
|
597
|
+
click.echo("\n" + "=" * 60, err=True)
|
|
598
|
+
click.echo("CMJ ANALYSIS RESULTS", err=True)
|
|
599
|
+
click.echo("=" * 60, err=True)
|
|
600
|
+
click.echo(f"Jump height: {metrics.jump_height:.3f} m", err=True)
|
|
601
|
+
click.echo(f"Flight time: {metrics.flight_time * 1000:.1f} ms", err=True)
|
|
602
|
+
click.echo(
|
|
603
|
+
f"Countermovement depth: {metrics.countermovement_depth:.3f} m", err=True
|
|
604
|
+
)
|
|
605
|
+
click.echo(
|
|
606
|
+
f"Eccentric duration: {metrics.eccentric_duration * 1000:.1f} ms", err=True
|
|
607
|
+
)
|
|
608
|
+
click.echo(
|
|
609
|
+
f"Concentric duration: {metrics.concentric_duration * 1000:.1f} ms", err=True
|
|
610
|
+
)
|
|
611
|
+
click.echo(
|
|
612
|
+
f"Total movement time: {metrics.total_movement_time * 1000:.1f} ms", err=True
|
|
613
|
+
)
|
|
614
|
+
click.echo(
|
|
615
|
+
f"Peak eccentric velocity: {abs(metrics.peak_eccentric_velocity):.3f} m/s (downward)",
|
|
616
|
+
err=True,
|
|
617
|
+
)
|
|
618
|
+
click.echo(
|
|
619
|
+
f"Peak concentric velocity: {metrics.peak_concentric_velocity:.3f} m/s (upward)",
|
|
620
|
+
err=True,
|
|
621
|
+
)
|
|
622
|
+
if metrics.transition_time is not None:
|
|
623
|
+
click.echo(
|
|
624
|
+
f"Transition time: {metrics.transition_time * 1000:.1f} ms", err=True
|
|
625
|
+
)
|
|
626
|
+
click.echo("=" * 60, err=True)
|