kinemotion 0.8.3__py3-none-any.whl → 0.10.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 +11 -0
- kinemotion/api.py +428 -0
- kinemotion/dropjump/cli.py +319 -4
- kinemotion/py.typed +0 -0
- {kinemotion-0.8.3.dist-info → kinemotion-0.10.0.dist-info}/METADATA +1 -1
- {kinemotion-0.8.3.dist-info → kinemotion-0.10.0.dist-info}/RECORD +9 -7
- {kinemotion-0.8.3.dist-info → kinemotion-0.10.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.8.3.dist-info → kinemotion-0.10.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.8.3.dist-info → kinemotion-0.10.0.dist-info}/licenses/LICENSE +0 -0
kinemotion/__init__.py
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
"""Kinemotion: Video-based kinematic analysis for athletic performance."""
|
|
2
2
|
|
|
3
|
+
from .api import VideoConfig, VideoResult, process_video, process_videos_bulk
|
|
4
|
+
from .dropjump.kinematics import DropJumpMetrics
|
|
5
|
+
|
|
3
6
|
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"process_video",
|
|
10
|
+
"process_videos_bulk",
|
|
11
|
+
"VideoConfig",
|
|
12
|
+
"VideoResult",
|
|
13
|
+
"DropJumpMetrics",
|
|
14
|
+
]
|
kinemotion/api.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Public API for programmatic use of kinemotion analysis."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .core.auto_tuning import (
|
|
12
|
+
QualityPreset,
|
|
13
|
+
analyze_video_sample,
|
|
14
|
+
auto_tune_parameters,
|
|
15
|
+
)
|
|
16
|
+
from .core.pose import PoseTracker
|
|
17
|
+
from .core.smoothing import smooth_landmarks, smooth_landmarks_advanced
|
|
18
|
+
from .core.video_io import VideoProcessor
|
|
19
|
+
from .dropjump.analysis import compute_average_foot_position, detect_ground_contact
|
|
20
|
+
from .dropjump.debug_overlay import DebugOverlayRenderer
|
|
21
|
+
from .dropjump.kinematics import DropJumpMetrics, calculate_drop_jump_metrics
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class VideoResult:
|
|
26
|
+
"""Result of processing a single video."""
|
|
27
|
+
|
|
28
|
+
video_path: str
|
|
29
|
+
success: bool
|
|
30
|
+
metrics: DropJumpMetrics | None = None
|
|
31
|
+
error: str | None = None
|
|
32
|
+
processing_time: float = 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class VideoConfig:
|
|
37
|
+
"""Configuration for processing a single video."""
|
|
38
|
+
|
|
39
|
+
video_path: str
|
|
40
|
+
drop_height: float
|
|
41
|
+
quality: str = "balanced"
|
|
42
|
+
output_video: str | None = None
|
|
43
|
+
json_output: str | None = None
|
|
44
|
+
drop_start_frame: int | None = None
|
|
45
|
+
smoothing_window: int | None = None
|
|
46
|
+
velocity_threshold: float | None = None
|
|
47
|
+
min_contact_frames: int | None = None
|
|
48
|
+
visibility_threshold: float | None = None
|
|
49
|
+
detection_confidence: float | None = None
|
|
50
|
+
tracking_confidence: float | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def process_video(
|
|
54
|
+
video_path: str,
|
|
55
|
+
drop_height: float,
|
|
56
|
+
quality: str = "balanced",
|
|
57
|
+
output_video: str | None = None,
|
|
58
|
+
json_output: str | None = None,
|
|
59
|
+
drop_start_frame: int | None = None,
|
|
60
|
+
smoothing_window: int | None = None,
|
|
61
|
+
velocity_threshold: float | None = None,
|
|
62
|
+
min_contact_frames: int | None = None,
|
|
63
|
+
visibility_threshold: float | None = None,
|
|
64
|
+
detection_confidence: float | None = None,
|
|
65
|
+
tracking_confidence: float | None = None,
|
|
66
|
+
verbose: bool = False,
|
|
67
|
+
) -> DropJumpMetrics:
|
|
68
|
+
"""
|
|
69
|
+
Process a single drop jump video and return metrics.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
video_path: Path to the input video file
|
|
73
|
+
drop_height: Height of drop box/platform in meters (e.g., 0.40 for 40cm)
|
|
74
|
+
quality: Analysis quality preset ("fast", "balanced", or "accurate")
|
|
75
|
+
output_video: Optional path for debug video output
|
|
76
|
+
json_output: Optional path for JSON metrics output
|
|
77
|
+
drop_start_frame: Optional manual drop start frame
|
|
78
|
+
smoothing_window: Optional override for smoothing window
|
|
79
|
+
velocity_threshold: Optional override for velocity threshold
|
|
80
|
+
min_contact_frames: Optional override for minimum contact frames
|
|
81
|
+
visibility_threshold: Optional override for visibility threshold
|
|
82
|
+
detection_confidence: Optional override for pose detection confidence
|
|
83
|
+
tracking_confidence: Optional override for pose tracking confidence
|
|
84
|
+
verbose: Print processing details
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
DropJumpMetrics object containing analysis results
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If video cannot be processed or parameters are invalid
|
|
91
|
+
FileNotFoundError: If video file does not exist
|
|
92
|
+
"""
|
|
93
|
+
if not Path(video_path).exists():
|
|
94
|
+
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
95
|
+
|
|
96
|
+
# Convert quality string to enum
|
|
97
|
+
try:
|
|
98
|
+
quality_preset = QualityPreset(quality.lower())
|
|
99
|
+
except ValueError as e:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid quality preset: {quality}. Must be 'fast', 'balanced', or 'accurate'"
|
|
102
|
+
) from e
|
|
103
|
+
|
|
104
|
+
# Initialize video processor
|
|
105
|
+
with VideoProcessor(video_path) as video:
|
|
106
|
+
if verbose:
|
|
107
|
+
print(
|
|
108
|
+
f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
|
|
109
|
+
f"{video.frame_count} frames"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Determine initial detection/tracking confidence from quality preset
|
|
113
|
+
initial_detection_conf = 0.5
|
|
114
|
+
initial_tracking_conf = 0.5
|
|
115
|
+
|
|
116
|
+
if quality_preset == QualityPreset.FAST:
|
|
117
|
+
initial_detection_conf = 0.3
|
|
118
|
+
initial_tracking_conf = 0.3
|
|
119
|
+
elif quality_preset == QualityPreset.ACCURATE:
|
|
120
|
+
initial_detection_conf = 0.6
|
|
121
|
+
initial_tracking_conf = 0.6
|
|
122
|
+
|
|
123
|
+
# Override with expert values if provided
|
|
124
|
+
if detection_confidence is not None:
|
|
125
|
+
initial_detection_conf = detection_confidence
|
|
126
|
+
if tracking_confidence is not None:
|
|
127
|
+
initial_tracking_conf = tracking_confidence
|
|
128
|
+
|
|
129
|
+
# Initialize pose tracker
|
|
130
|
+
tracker = PoseTracker(
|
|
131
|
+
min_detection_confidence=initial_detection_conf,
|
|
132
|
+
min_tracking_confidence=initial_tracking_conf,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Process all frames
|
|
136
|
+
if verbose:
|
|
137
|
+
print("Tracking pose landmarks...")
|
|
138
|
+
|
|
139
|
+
landmarks_sequence = []
|
|
140
|
+
frames = []
|
|
141
|
+
|
|
142
|
+
while True:
|
|
143
|
+
frame = video.read_frame()
|
|
144
|
+
if frame is None:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
frames.append(frame)
|
|
148
|
+
landmarks = tracker.process_frame(frame)
|
|
149
|
+
landmarks_sequence.append(landmarks)
|
|
150
|
+
|
|
151
|
+
tracker.close()
|
|
152
|
+
|
|
153
|
+
if not landmarks_sequence:
|
|
154
|
+
raise ValueError("No frames could be processed from video")
|
|
155
|
+
|
|
156
|
+
# Analyze video characteristics and auto-tune parameters
|
|
157
|
+
characteristics = analyze_video_sample(
|
|
158
|
+
landmarks_sequence, video.fps, video.frame_count
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
params = auto_tune_parameters(characteristics, quality_preset)
|
|
162
|
+
|
|
163
|
+
# Apply expert overrides if provided
|
|
164
|
+
if smoothing_window is not None:
|
|
165
|
+
params.smoothing_window = smoothing_window
|
|
166
|
+
if velocity_threshold is not None:
|
|
167
|
+
params.velocity_threshold = velocity_threshold
|
|
168
|
+
if min_contact_frames is not None:
|
|
169
|
+
params.min_contact_frames = min_contact_frames
|
|
170
|
+
if visibility_threshold is not None:
|
|
171
|
+
params.visibility_threshold = visibility_threshold
|
|
172
|
+
|
|
173
|
+
# Show selected parameters if verbose
|
|
174
|
+
if verbose:
|
|
175
|
+
print("\n" + "=" * 60)
|
|
176
|
+
print("AUTO-TUNED PARAMETERS")
|
|
177
|
+
print("=" * 60)
|
|
178
|
+
print(f"Video FPS: {video.fps:.2f}")
|
|
179
|
+
print(
|
|
180
|
+
f"Tracking quality: {characteristics.tracking_quality} "
|
|
181
|
+
f"(avg visibility: {characteristics.avg_visibility:.2f})"
|
|
182
|
+
)
|
|
183
|
+
print(f"Quality preset: {quality_preset.value}")
|
|
184
|
+
print("\nSelected parameters:")
|
|
185
|
+
print(f" smoothing_window: {params.smoothing_window}")
|
|
186
|
+
print(f" polyorder: {params.polyorder}")
|
|
187
|
+
print(f" velocity_threshold: {params.velocity_threshold:.4f}")
|
|
188
|
+
print(f" min_contact_frames: {params.min_contact_frames}")
|
|
189
|
+
print(f" visibility_threshold: {params.visibility_threshold}")
|
|
190
|
+
print(f" detection_confidence: {params.detection_confidence}")
|
|
191
|
+
print(f" tracking_confidence: {params.tracking_confidence}")
|
|
192
|
+
print(f" outlier_rejection: {params.outlier_rejection}")
|
|
193
|
+
print(f" bilateral_filter: {params.bilateral_filter}")
|
|
194
|
+
print(f" use_curvature: {params.use_curvature}")
|
|
195
|
+
print("=" * 60 + "\n")
|
|
196
|
+
|
|
197
|
+
# Apply smoothing with auto-tuned parameters
|
|
198
|
+
if params.outlier_rejection or params.bilateral_filter:
|
|
199
|
+
if verbose:
|
|
200
|
+
if params.outlier_rejection:
|
|
201
|
+
print("Smoothing landmarks with outlier rejection...")
|
|
202
|
+
if params.bilateral_filter:
|
|
203
|
+
print("Using bilateral temporal filter...")
|
|
204
|
+
smoothed_landmarks = smooth_landmarks_advanced(
|
|
205
|
+
landmarks_sequence,
|
|
206
|
+
window_length=params.smoothing_window,
|
|
207
|
+
polyorder=params.polyorder,
|
|
208
|
+
use_outlier_rejection=params.outlier_rejection,
|
|
209
|
+
use_bilateral=params.bilateral_filter,
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
if verbose:
|
|
213
|
+
print("Smoothing landmarks...")
|
|
214
|
+
smoothed_landmarks = smooth_landmarks(
|
|
215
|
+
landmarks_sequence,
|
|
216
|
+
window_length=params.smoothing_window,
|
|
217
|
+
polyorder=params.polyorder,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Extract vertical positions from feet
|
|
221
|
+
if verbose:
|
|
222
|
+
print("Extracting foot positions...")
|
|
223
|
+
|
|
224
|
+
position_list: list[float] = []
|
|
225
|
+
visibilities_list: list[float] = []
|
|
226
|
+
|
|
227
|
+
for frame_landmarks in smoothed_landmarks:
|
|
228
|
+
if frame_landmarks:
|
|
229
|
+
_, foot_y = compute_average_foot_position(frame_landmarks)
|
|
230
|
+
position_list.append(foot_y)
|
|
231
|
+
|
|
232
|
+
# Average visibility of foot landmarks
|
|
233
|
+
foot_vis = []
|
|
234
|
+
for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
|
|
235
|
+
if key in frame_landmarks:
|
|
236
|
+
foot_vis.append(frame_landmarks[key][2])
|
|
237
|
+
visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
|
|
238
|
+
else:
|
|
239
|
+
position_list.append(position_list[-1] if position_list else 0.5)
|
|
240
|
+
visibilities_list.append(0.0)
|
|
241
|
+
|
|
242
|
+
vertical_positions: np.ndarray = np.array(position_list)
|
|
243
|
+
visibilities: np.ndarray = np.array(visibilities_list)
|
|
244
|
+
|
|
245
|
+
# Detect ground contact
|
|
246
|
+
contact_states = detect_ground_contact(
|
|
247
|
+
vertical_positions,
|
|
248
|
+
velocity_threshold=params.velocity_threshold,
|
|
249
|
+
min_contact_frames=params.min_contact_frames,
|
|
250
|
+
visibility_threshold=params.visibility_threshold,
|
|
251
|
+
visibilities=visibilities,
|
|
252
|
+
window_length=params.smoothing_window,
|
|
253
|
+
polyorder=params.polyorder,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Calculate metrics
|
|
257
|
+
if verbose:
|
|
258
|
+
print("Calculating metrics...")
|
|
259
|
+
print(
|
|
260
|
+
f"Using drop height calibration: {drop_height}m ({drop_height*100:.0f}cm)"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
metrics = calculate_drop_jump_metrics(
|
|
264
|
+
contact_states,
|
|
265
|
+
vertical_positions,
|
|
266
|
+
video.fps,
|
|
267
|
+
drop_height_m=drop_height,
|
|
268
|
+
drop_start_frame=drop_start_frame,
|
|
269
|
+
velocity_threshold=params.velocity_threshold,
|
|
270
|
+
smoothing_window=params.smoothing_window,
|
|
271
|
+
polyorder=params.polyorder,
|
|
272
|
+
use_curvature=params.use_curvature,
|
|
273
|
+
kinematic_correction_factor=1.0,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Save JSON if requested
|
|
277
|
+
if json_output:
|
|
278
|
+
import json
|
|
279
|
+
|
|
280
|
+
output_path = Path(json_output)
|
|
281
|
+
output_path.write_text(json.dumps(metrics.to_dict(), indent=2))
|
|
282
|
+
if verbose:
|
|
283
|
+
print(f"Metrics written to: {json_output}")
|
|
284
|
+
|
|
285
|
+
# Generate debug video if requested
|
|
286
|
+
if output_video:
|
|
287
|
+
if verbose:
|
|
288
|
+
print(f"Generating debug video: {output_video}")
|
|
289
|
+
|
|
290
|
+
with DebugOverlayRenderer(
|
|
291
|
+
output_video,
|
|
292
|
+
video.width,
|
|
293
|
+
video.height,
|
|
294
|
+
video.display_width,
|
|
295
|
+
video.display_height,
|
|
296
|
+
video.fps,
|
|
297
|
+
) as renderer:
|
|
298
|
+
for i, frame in enumerate(frames):
|
|
299
|
+
annotated = renderer.render_frame(
|
|
300
|
+
frame,
|
|
301
|
+
smoothed_landmarks[i],
|
|
302
|
+
contact_states[i],
|
|
303
|
+
i,
|
|
304
|
+
metrics,
|
|
305
|
+
use_com=False,
|
|
306
|
+
)
|
|
307
|
+
renderer.write_frame(annotated)
|
|
308
|
+
|
|
309
|
+
if verbose:
|
|
310
|
+
print(f"Debug video saved: {output_video}")
|
|
311
|
+
|
|
312
|
+
if verbose:
|
|
313
|
+
print("Analysis complete!")
|
|
314
|
+
|
|
315
|
+
return metrics
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def process_videos_bulk(
|
|
319
|
+
configs: list[VideoConfig],
|
|
320
|
+
max_workers: int = 4,
|
|
321
|
+
progress_callback: Callable[[VideoResult], None] | None = None,
|
|
322
|
+
) -> list[VideoResult]:
|
|
323
|
+
"""
|
|
324
|
+
Process multiple videos in parallel using ProcessPoolExecutor.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
configs: List of VideoConfig objects specifying video paths and parameters
|
|
328
|
+
max_workers: Maximum number of parallel workers (default: 4)
|
|
329
|
+
progress_callback: Optional callback function called after each video completes.
|
|
330
|
+
Receives VideoResult object.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
List of VideoResult objects, one per input video, in completion order
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
>>> configs = [
|
|
337
|
+
... VideoConfig("video1.mp4", drop_height=0.40),
|
|
338
|
+
... VideoConfig("video2.mp4", drop_height=0.30, quality="accurate"),
|
|
339
|
+
... VideoConfig("video3.mp4", drop_height=0.50, output_video="debug3.mp4"),
|
|
340
|
+
... ]
|
|
341
|
+
>>> results = process_videos_bulk(configs, max_workers=4)
|
|
342
|
+
>>> for result in results:
|
|
343
|
+
... if result.success:
|
|
344
|
+
... print(f"{result.video_path}: {result.metrics.jump_height_m:.3f}m")
|
|
345
|
+
... else:
|
|
346
|
+
... print(f"{result.video_path}: FAILED - {result.error}")
|
|
347
|
+
"""
|
|
348
|
+
results: list[VideoResult] = []
|
|
349
|
+
|
|
350
|
+
# Use ProcessPoolExecutor for CPU-bound video processing
|
|
351
|
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
352
|
+
# Submit all jobs
|
|
353
|
+
future_to_config = {
|
|
354
|
+
executor.submit(_process_video_wrapper, config): config
|
|
355
|
+
for config in configs
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# Process results as they complete
|
|
359
|
+
for future in as_completed(future_to_config):
|
|
360
|
+
config = future_to_config[future]
|
|
361
|
+
result: VideoResult
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
result = future.result()
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
# Handle unexpected errors
|
|
367
|
+
result = VideoResult(
|
|
368
|
+
video_path=config.video_path,
|
|
369
|
+
success=False,
|
|
370
|
+
error=f"Unexpected error: {str(exc)}",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
results.append(result)
|
|
374
|
+
|
|
375
|
+
# Call progress callback if provided
|
|
376
|
+
if progress_callback:
|
|
377
|
+
progress_callback(result)
|
|
378
|
+
|
|
379
|
+
return results
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _process_video_wrapper(config: VideoConfig) -> VideoResult:
|
|
383
|
+
"""
|
|
384
|
+
Wrapper function for parallel processing. Must be picklable (top-level function).
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
config: VideoConfig object with processing parameters
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
VideoResult object with metrics or error information
|
|
391
|
+
"""
|
|
392
|
+
start_time = time.time()
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
metrics = process_video(
|
|
396
|
+
video_path=config.video_path,
|
|
397
|
+
drop_height=config.drop_height,
|
|
398
|
+
quality=config.quality,
|
|
399
|
+
output_video=config.output_video,
|
|
400
|
+
json_output=config.json_output,
|
|
401
|
+
drop_start_frame=config.drop_start_frame,
|
|
402
|
+
smoothing_window=config.smoothing_window,
|
|
403
|
+
velocity_threshold=config.velocity_threshold,
|
|
404
|
+
min_contact_frames=config.min_contact_frames,
|
|
405
|
+
visibility_threshold=config.visibility_threshold,
|
|
406
|
+
detection_confidence=config.detection_confidence,
|
|
407
|
+
tracking_confidence=config.tracking_confidence,
|
|
408
|
+
verbose=False, # Disable verbose in parallel mode
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
processing_time = time.time() - start_time
|
|
412
|
+
|
|
413
|
+
return VideoResult(
|
|
414
|
+
video_path=config.video_path,
|
|
415
|
+
success=True,
|
|
416
|
+
metrics=metrics,
|
|
417
|
+
processing_time=processing_time,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
processing_time = time.time() - start_time
|
|
422
|
+
|
|
423
|
+
return VideoResult(
|
|
424
|
+
video_path=config.video_path,
|
|
425
|
+
success=False,
|
|
426
|
+
error=str(e),
|
|
427
|
+
processing_time=processing_time,
|
|
428
|
+
)
|
kinemotion/dropjump/cli.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Command-line interface for drop jump analysis."""
|
|
2
2
|
|
|
3
|
+
import csv
|
|
4
|
+
import glob
|
|
3
5
|
import json
|
|
4
6
|
import sys
|
|
5
7
|
from pathlib import Path
|
|
@@ -8,6 +10,7 @@ from typing import Any
|
|
|
8
10
|
import click
|
|
9
11
|
import numpy as np
|
|
10
12
|
|
|
13
|
+
from ..api import VideoConfig, VideoResult, process_videos_bulk
|
|
11
14
|
from ..core.auto_tuning import (
|
|
12
15
|
QualityPreset,
|
|
13
16
|
analyze_video_sample,
|
|
@@ -25,7 +28,7 @@ from .kinematics import calculate_drop_jump_metrics
|
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
@click.command(name="dropjump-analyze")
|
|
28
|
-
@click.argument("video_path", type=click.Path(exists=True)
|
|
31
|
+
@click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
|
|
29
32
|
@click.option(
|
|
30
33
|
"--output",
|
|
31
34
|
"-o",
|
|
@@ -65,6 +68,34 @@ from .kinematics import calculate_drop_jump_metrics
|
|
|
65
68
|
is_flag=True,
|
|
66
69
|
help="Show auto-selected parameters and analysis details",
|
|
67
70
|
)
|
|
71
|
+
# Batch processing options
|
|
72
|
+
@click.option(
|
|
73
|
+
"--batch",
|
|
74
|
+
is_flag=True,
|
|
75
|
+
help="Enable batch processing mode for multiple videos",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--workers",
|
|
79
|
+
type=int,
|
|
80
|
+
default=4,
|
|
81
|
+
help="Number of parallel workers for batch processing (default: 4)",
|
|
82
|
+
show_default=True,
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--output-dir",
|
|
86
|
+
type=click.Path(),
|
|
87
|
+
help="Directory for debug video outputs (batch mode only)",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--json-output-dir",
|
|
91
|
+
type=click.Path(),
|
|
92
|
+
help="Directory for JSON metrics outputs (batch mode only)",
|
|
93
|
+
)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--csv-summary",
|
|
96
|
+
type=click.Path(),
|
|
97
|
+
help="Path for CSV summary export (batch mode only)",
|
|
98
|
+
)
|
|
68
99
|
# Expert parameters (hidden in help, but always available for advanced users)
|
|
69
100
|
@click.option(
|
|
70
101
|
"--drop-start-frame",
|
|
@@ -109,12 +140,17 @@ from .kinematics import calculate_drop_jump_metrics
|
|
|
109
140
|
help="[EXPERT] Override pose tracking confidence",
|
|
110
141
|
)
|
|
111
142
|
def dropjump_analyze(
|
|
112
|
-
video_path: str,
|
|
143
|
+
video_path: tuple[str, ...],
|
|
113
144
|
output: str | None,
|
|
114
145
|
json_output: str | None,
|
|
115
146
|
drop_height: float,
|
|
116
147
|
quality: str,
|
|
117
148
|
verbose: bool,
|
|
149
|
+
batch: bool,
|
|
150
|
+
workers: int,
|
|
151
|
+
output_dir: str | None,
|
|
152
|
+
json_output_dir: str | None,
|
|
153
|
+
csv_summary: str | None,
|
|
118
154
|
drop_start_frame: int | None,
|
|
119
155
|
smoothing_window: int | None,
|
|
120
156
|
velocity_threshold: float | None,
|
|
@@ -124,13 +160,101 @@ def dropjump_analyze(
|
|
|
124
160
|
tracking_confidence: float | None,
|
|
125
161
|
) -> None:
|
|
126
162
|
"""
|
|
127
|
-
Analyze drop-jump video to estimate ground contact time, flight time, and jump height.
|
|
163
|
+
Analyze drop-jump video(s) to estimate ground contact time, flight time, and jump height.
|
|
128
164
|
|
|
129
165
|
Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
|
|
130
166
|
Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
|
|
131
167
|
|
|
132
|
-
VIDEO_PATH: Path to
|
|
168
|
+
VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
|
|
169
|
+
(e.g., "videos/*.mp4").
|
|
170
|
+
|
|
171
|
+
Examples:
|
|
172
|
+
|
|
173
|
+
\b
|
|
174
|
+
# Single video
|
|
175
|
+
kinemotion dropjump-analyze video.mp4 --drop-height 0.40
|
|
176
|
+
|
|
177
|
+
\b
|
|
178
|
+
# Batch mode with glob pattern
|
|
179
|
+
kinemotion dropjump-analyze videos/*.mp4 --batch --drop-height 0.40 --workers 4
|
|
180
|
+
|
|
181
|
+
\b
|
|
182
|
+
# Batch with output directories
|
|
183
|
+
kinemotion dropjump-analyze videos/*.mp4 --batch --drop-height 0.40 \\
|
|
184
|
+
--json-output-dir results/ --csv-summary summary.csv
|
|
133
185
|
"""
|
|
186
|
+
# Expand glob patterns and collect all video files
|
|
187
|
+
video_files: list[str] = []
|
|
188
|
+
for pattern in video_path:
|
|
189
|
+
expanded = glob.glob(pattern)
|
|
190
|
+
if expanded:
|
|
191
|
+
video_files.extend(expanded)
|
|
192
|
+
elif Path(pattern).exists():
|
|
193
|
+
# Direct path (not a glob pattern)
|
|
194
|
+
video_files.append(pattern)
|
|
195
|
+
else:
|
|
196
|
+
click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
|
|
197
|
+
|
|
198
|
+
if not video_files:
|
|
199
|
+
click.echo("Error: No video files found", err=True)
|
|
200
|
+
sys.exit(1)
|
|
201
|
+
|
|
202
|
+
# Determine if batch mode should be used
|
|
203
|
+
use_batch = batch or len(video_files) > 1
|
|
204
|
+
|
|
205
|
+
if use_batch:
|
|
206
|
+
_process_batch(
|
|
207
|
+
video_files,
|
|
208
|
+
drop_height,
|
|
209
|
+
quality,
|
|
210
|
+
workers,
|
|
211
|
+
output_dir,
|
|
212
|
+
json_output_dir,
|
|
213
|
+
csv_summary,
|
|
214
|
+
drop_start_frame,
|
|
215
|
+
smoothing_window,
|
|
216
|
+
velocity_threshold,
|
|
217
|
+
min_contact_frames,
|
|
218
|
+
visibility_threshold,
|
|
219
|
+
detection_confidence,
|
|
220
|
+
tracking_confidence,
|
|
221
|
+
verbose,
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
# Single video mode (original behavior)
|
|
225
|
+
_process_single(
|
|
226
|
+
video_files[0],
|
|
227
|
+
output,
|
|
228
|
+
json_output,
|
|
229
|
+
drop_height,
|
|
230
|
+
quality,
|
|
231
|
+
verbose,
|
|
232
|
+
drop_start_frame,
|
|
233
|
+
smoothing_window,
|
|
234
|
+
velocity_threshold,
|
|
235
|
+
min_contact_frames,
|
|
236
|
+
visibility_threshold,
|
|
237
|
+
detection_confidence,
|
|
238
|
+
tracking_confidence,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _process_single(
|
|
243
|
+
video_path: str,
|
|
244
|
+
output: str | None,
|
|
245
|
+
json_output: str | None,
|
|
246
|
+
drop_height: float,
|
|
247
|
+
quality: str,
|
|
248
|
+
verbose: bool,
|
|
249
|
+
drop_start_frame: int | None,
|
|
250
|
+
smoothing_window: int | None,
|
|
251
|
+
velocity_threshold: float | None,
|
|
252
|
+
min_contact_frames: int | None,
|
|
253
|
+
visibility_threshold: float | None,
|
|
254
|
+
detection_confidence: float | None,
|
|
255
|
+
tracking_confidence: float | None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Process a single video (original CLI behavior)."""
|
|
134
258
|
click.echo(f"Analyzing video: {video_path}", err=True)
|
|
135
259
|
|
|
136
260
|
# Convert quality string to enum
|
|
@@ -414,3 +538,194 @@ def dropjump_analyze(
|
|
|
414
538
|
except Exception as e:
|
|
415
539
|
click.echo(f"Error: {str(e)}", err=True)
|
|
416
540
|
sys.exit(1)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _process_batch(
|
|
544
|
+
video_files: list[str],
|
|
545
|
+
drop_height: float,
|
|
546
|
+
quality: str,
|
|
547
|
+
workers: int,
|
|
548
|
+
output_dir: str | None,
|
|
549
|
+
json_output_dir: str | None,
|
|
550
|
+
csv_summary: str | None,
|
|
551
|
+
drop_start_frame: int | None,
|
|
552
|
+
smoothing_window: int | None,
|
|
553
|
+
velocity_threshold: float | None,
|
|
554
|
+
min_contact_frames: int | None,
|
|
555
|
+
visibility_threshold: float | None,
|
|
556
|
+
detection_confidence: float | None,
|
|
557
|
+
tracking_confidence: float | None,
|
|
558
|
+
verbose: bool,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Process multiple videos in batch mode using parallel processing."""
|
|
561
|
+
click.echo(
|
|
562
|
+
f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
|
|
563
|
+
)
|
|
564
|
+
click.echo("=" * 70, err=True)
|
|
565
|
+
|
|
566
|
+
# Create output directories if specified
|
|
567
|
+
if output_dir:
|
|
568
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
569
|
+
click.echo(f"Debug videos will be saved to: {output_dir}", err=True)
|
|
570
|
+
|
|
571
|
+
if json_output_dir:
|
|
572
|
+
Path(json_output_dir).mkdir(parents=True, exist_ok=True)
|
|
573
|
+
click.echo(f"JSON metrics will be saved to: {json_output_dir}", err=True)
|
|
574
|
+
|
|
575
|
+
# Build configurations for each video
|
|
576
|
+
configs: list[VideoConfig] = []
|
|
577
|
+
for video_file in video_files:
|
|
578
|
+
video_name = Path(video_file).stem
|
|
579
|
+
|
|
580
|
+
# Determine output paths
|
|
581
|
+
debug_video = None
|
|
582
|
+
if output_dir:
|
|
583
|
+
debug_video = str(Path(output_dir) / f"{video_name}_debug.mp4")
|
|
584
|
+
|
|
585
|
+
json_file = None
|
|
586
|
+
if json_output_dir:
|
|
587
|
+
json_file = str(Path(json_output_dir) / f"{video_name}.json")
|
|
588
|
+
|
|
589
|
+
config = VideoConfig(
|
|
590
|
+
video_path=video_file,
|
|
591
|
+
drop_height=drop_height,
|
|
592
|
+
quality=quality,
|
|
593
|
+
output_video=debug_video,
|
|
594
|
+
json_output=json_file,
|
|
595
|
+
drop_start_frame=drop_start_frame,
|
|
596
|
+
smoothing_window=smoothing_window,
|
|
597
|
+
velocity_threshold=velocity_threshold,
|
|
598
|
+
min_contact_frames=min_contact_frames,
|
|
599
|
+
visibility_threshold=visibility_threshold,
|
|
600
|
+
detection_confidence=detection_confidence,
|
|
601
|
+
tracking_confidence=tracking_confidence,
|
|
602
|
+
)
|
|
603
|
+
configs.append(config)
|
|
604
|
+
|
|
605
|
+
# Progress callback
|
|
606
|
+
completed = 0
|
|
607
|
+
|
|
608
|
+
def show_progress(result: VideoResult) -> None:
|
|
609
|
+
nonlocal completed
|
|
610
|
+
completed += 1
|
|
611
|
+
status = "✓" if result.success else "✗"
|
|
612
|
+
video_name = Path(result.video_path).name
|
|
613
|
+
click.echo(
|
|
614
|
+
f"[{completed}/{len(configs)}] {status} {video_name} "
|
|
615
|
+
f"({result.processing_time:.1f}s)",
|
|
616
|
+
err=True,
|
|
617
|
+
)
|
|
618
|
+
if not result.success:
|
|
619
|
+
click.echo(f" Error: {result.error}", err=True)
|
|
620
|
+
|
|
621
|
+
# Process all videos
|
|
622
|
+
click.echo("\nProcessing videos...", err=True)
|
|
623
|
+
results = process_videos_bulk(
|
|
624
|
+
configs, max_workers=workers, progress_callback=show_progress
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Generate summary
|
|
628
|
+
click.echo("\n" + "=" * 70, err=True)
|
|
629
|
+
click.echo("BATCH PROCESSING SUMMARY", err=True)
|
|
630
|
+
click.echo("=" * 70, err=True)
|
|
631
|
+
|
|
632
|
+
successful = [r for r in results if r.success]
|
|
633
|
+
failed = [r for r in results if not r.success]
|
|
634
|
+
|
|
635
|
+
click.echo(f"Total videos: {len(results)}", err=True)
|
|
636
|
+
click.echo(f"Successful: {len(successful)}", err=True)
|
|
637
|
+
click.echo(f"Failed: {len(failed)}", err=True)
|
|
638
|
+
|
|
639
|
+
if successful:
|
|
640
|
+
# Calculate average metrics
|
|
641
|
+
with_gct = [
|
|
642
|
+
r
|
|
643
|
+
for r in successful
|
|
644
|
+
if r.metrics and r.metrics.ground_contact_time is not None
|
|
645
|
+
]
|
|
646
|
+
with_flight = [
|
|
647
|
+
r for r in successful if r.metrics and r.metrics.flight_time is not None
|
|
648
|
+
]
|
|
649
|
+
with_jump = [
|
|
650
|
+
r for r in successful if r.metrics and r.metrics.jump_height is not None
|
|
651
|
+
]
|
|
652
|
+
|
|
653
|
+
if with_gct:
|
|
654
|
+
avg_gct = sum(r.metrics.ground_contact_time * 1000 for r in with_gct) / len(
|
|
655
|
+
with_gct
|
|
656
|
+
)
|
|
657
|
+
click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
|
|
658
|
+
|
|
659
|
+
if with_flight:
|
|
660
|
+
avg_flight = sum(r.metrics.flight_time * 1000 for r in with_flight) / len(
|
|
661
|
+
with_flight
|
|
662
|
+
)
|
|
663
|
+
click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
|
|
664
|
+
|
|
665
|
+
if with_jump:
|
|
666
|
+
avg_jump = sum(r.metrics.jump_height for r in with_jump) / len(with_jump)
|
|
667
|
+
click.echo(
|
|
668
|
+
f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
|
|
669
|
+
err=True,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Export CSV summary if requested
|
|
673
|
+
if csv_summary and successful:
|
|
674
|
+
click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
|
|
675
|
+
Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
|
|
676
|
+
|
|
677
|
+
with open(csv_summary, "w", newline="") as f:
|
|
678
|
+
writer = csv.writer(f)
|
|
679
|
+
|
|
680
|
+
# Header
|
|
681
|
+
writer.writerow(
|
|
682
|
+
[
|
|
683
|
+
"Video",
|
|
684
|
+
"Ground Contact Time (ms)",
|
|
685
|
+
"Flight Time (ms)",
|
|
686
|
+
"Jump Height (m)",
|
|
687
|
+
"Processing Time (s)",
|
|
688
|
+
"Status",
|
|
689
|
+
]
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# Data rows
|
|
693
|
+
for result in results:
|
|
694
|
+
if result.success and result.metrics:
|
|
695
|
+
writer.writerow(
|
|
696
|
+
[
|
|
697
|
+
Path(result.video_path).name,
|
|
698
|
+
(
|
|
699
|
+
f"{result.metrics.ground_contact_time * 1000:.1f}"
|
|
700
|
+
if result.metrics.ground_contact_time
|
|
701
|
+
else "N/A"
|
|
702
|
+
),
|
|
703
|
+
(
|
|
704
|
+
f"{result.metrics.flight_time * 1000:.1f}"
|
|
705
|
+
if result.metrics.flight_time
|
|
706
|
+
else "N/A"
|
|
707
|
+
),
|
|
708
|
+
(
|
|
709
|
+
f"{result.metrics.jump_height:.3f}"
|
|
710
|
+
if result.metrics.jump_height
|
|
711
|
+
else "N/A"
|
|
712
|
+
),
|
|
713
|
+
f"{result.processing_time:.2f}",
|
|
714
|
+
"Success",
|
|
715
|
+
]
|
|
716
|
+
)
|
|
717
|
+
else:
|
|
718
|
+
writer.writerow(
|
|
719
|
+
[
|
|
720
|
+
Path(result.video_path).name,
|
|
721
|
+
"N/A",
|
|
722
|
+
"N/A",
|
|
723
|
+
"N/A",
|
|
724
|
+
f"{result.processing_time:.2f}",
|
|
725
|
+
f"Failed: {result.error}",
|
|
726
|
+
]
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
click.echo("CSV summary written successfully", err=True)
|
|
730
|
+
|
|
731
|
+
click.echo("\nBatch processing complete!", err=True)
|
kinemotion/py.typed
ADDED
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinemotion
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
kinemotion/__init__.py,sha256=
|
|
1
|
+
kinemotion/__init__.py,sha256=Z85xg29NA-r4IjrSbAkJpMFigyxACFGUb-37AiMp6YY,350
|
|
2
|
+
kinemotion/api.py,sha256=3Hswx5lfyWc-EanS6iV-4MPUa_uB5t8BpGe4EB4gIIQ,15453
|
|
2
3
|
kinemotion/cli.py,sha256=2IFA2_TE9a5zBtmGVzv5SnX39w7yPuBlw42dL7ca25U,402
|
|
3
4
|
kinemotion/core/__init__.py,sha256=3yzDhb5PekDNjydqrs8aWGneUGJBt-lB0SoB_Y2FXqU,1010
|
|
4
5
|
kinemotion/core/auto_tuning.py,sha256=cvmxUI-CbahpOJQtR2r5jOx4Q6yKPe3DO1o15hOQIdw,10508
|
|
@@ -8,11 +9,12 @@ kinemotion/core/smoothing.py,sha256=VVv95auiuah_GPG3jxiQPyiYXF5i3B4fF9UGI5FLX-Q,
|
|
|
8
9
|
kinemotion/core/video_io.py,sha256=z8Z0qbNaKbcdB40KnbNOBMzab3BbgnhBxp-mUBYeXgM,6577
|
|
9
10
|
kinemotion/dropjump/__init__.py,sha256=yc1XiZ9vfo5h_n7PKVSiX2TTgaIfGL7Y7SkQtiDZj_E,838
|
|
10
11
|
kinemotion/dropjump/analysis.py,sha256=HfJt2t9IsMBiBUz7apIzdxbRH9QqzlFnDVVWcKhU3ow,23291
|
|
11
|
-
kinemotion/dropjump/cli.py,sha256=
|
|
12
|
+
kinemotion/dropjump/cli.py,sha256=Wc0Z3w8RBaozEQ-Oc8_zTIUrfsgRwhD97kH4P8ZozzQ,25759
|
|
12
13
|
kinemotion/dropjump/debug_overlay.py,sha256=hmEtadqYP8K-kGr_Q03KDQyl1152-YSPeRJzEXMyuhs,8687
|
|
13
14
|
kinemotion/dropjump/kinematics.py,sha256=RceIH2HndpHQpcOQd56MmEdXQNEst-CWXfBKPJk2g3Y,17659
|
|
14
|
-
kinemotion
|
|
15
|
-
kinemotion-0.
|
|
16
|
-
kinemotion-0.
|
|
17
|
-
kinemotion-0.
|
|
18
|
-
kinemotion-0.
|
|
15
|
+
kinemotion/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
kinemotion-0.10.0.dist-info/METADATA,sha256=mwCvLjVVaKjlIptGUxPKeQD5BH6IAs5luVm7OIUd1SU,16319
|
|
17
|
+
kinemotion-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
18
|
+
kinemotion-0.10.0.dist-info/entry_points.txt,sha256=zaqnAnjLvcdrk1Qvj5nvXZCZ2gp0prS7it1zTJygcIY,50
|
|
19
|
+
kinemotion-0.10.0.dist-info/licenses/LICENSE,sha256=KZajvqsHw0NoOHOi2q0FZ4NBe9HdV6oey-IPYAtHXfg,1088
|
|
20
|
+
kinemotion-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|