kinemotion 0.17.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 +31 -0
- kinemotion/api.py +946 -0
- kinemotion/cli.py +22 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +528 -0
- kinemotion/cmj/cli.py +543 -0
- kinemotion/cmj/debug_overlay.py +463 -0
- kinemotion/cmj/joint_angles.py +290 -0
- kinemotion/cmj/kinematics.py +191 -0
- kinemotion/core/__init__.py +40 -0
- kinemotion/core/auto_tuning.py +325 -0
- kinemotion/core/cli_utils.py +212 -0
- kinemotion/core/debug_overlay_utils.py +143 -0
- kinemotion/core/filtering.py +345 -0
- kinemotion/core/pose.py +259 -0
- kinemotion/core/smoothing.py +412 -0
- kinemotion/core/video_io.py +186 -0
- kinemotion/dropjump/__init__.py +29 -0
- kinemotion/dropjump/analysis.py +790 -0
- kinemotion/dropjump/cli.py +704 -0
- kinemotion/dropjump/debug_overlay.py +179 -0
- kinemotion/dropjump/kinematics.py +446 -0
- kinemotion/py.typed +0 -0
- kinemotion-0.17.0.dist-info/METADATA +529 -0
- kinemotion-0.17.0.dist-info/RECORD +28 -0
- kinemotion-0.17.0.dist-info/WHEEL +4 -0
- kinemotion-0.17.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.17.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""Command-line interface for drop jump analysis."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import glob
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..api import (
|
|
15
|
+
DropJumpVideoConfig,
|
|
16
|
+
DropJumpVideoResult,
|
|
17
|
+
process_dropjump_videos_bulk,
|
|
18
|
+
)
|
|
19
|
+
from ..core.auto_tuning import (
|
|
20
|
+
QualityPreset,
|
|
21
|
+
analyze_video_sample,
|
|
22
|
+
auto_tune_parameters,
|
|
23
|
+
)
|
|
24
|
+
from ..core.cli_utils import (
|
|
25
|
+
apply_expert_param_overrides,
|
|
26
|
+
determine_initial_confidence,
|
|
27
|
+
print_auto_tuned_params,
|
|
28
|
+
smooth_landmark_sequence,
|
|
29
|
+
track_all_frames,
|
|
30
|
+
)
|
|
31
|
+
from ..core.pose import PoseTracker
|
|
32
|
+
from ..core.video_io import VideoProcessor
|
|
33
|
+
from .analysis import (
|
|
34
|
+
ContactState,
|
|
35
|
+
detect_ground_contact,
|
|
36
|
+
extract_foot_positions_and_visibilities,
|
|
37
|
+
)
|
|
38
|
+
from .debug_overlay import DebugOverlayRenderer
|
|
39
|
+
from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AnalysisParameters:
|
|
44
|
+
"""Expert parameters for analysis customization."""
|
|
45
|
+
|
|
46
|
+
drop_start_frame: int | None = None
|
|
47
|
+
smoothing_window: int | None = None
|
|
48
|
+
velocity_threshold: float | None = None
|
|
49
|
+
min_contact_frames: int | None = None
|
|
50
|
+
visibility_threshold: float | None = None
|
|
51
|
+
detection_confidence: float | None = None
|
|
52
|
+
tracking_confidence: float | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@click.command(name="dropjump-analyze")
|
|
56
|
+
@click.argument("video_path", nargs=-1, type=click.Path(exists=False), required=True)
|
|
57
|
+
@click.option(
|
|
58
|
+
"--output",
|
|
59
|
+
"-o",
|
|
60
|
+
type=click.Path(),
|
|
61
|
+
help="Path for debug video output (optional)",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--json-output",
|
|
65
|
+
"-j",
|
|
66
|
+
type=click.Path(),
|
|
67
|
+
help="Path for JSON metrics output (default: stdout)",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--quality",
|
|
71
|
+
type=click.Choice(["fast", "balanced", "accurate"], case_sensitive=False),
|
|
72
|
+
default="balanced",
|
|
73
|
+
help=(
|
|
74
|
+
"Analysis quality preset: "
|
|
75
|
+
"fast (quick, less precise), "
|
|
76
|
+
"balanced (default, good for most cases), "
|
|
77
|
+
"accurate (research-grade, slower)"
|
|
78
|
+
),
|
|
79
|
+
show_default=True,
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--verbose",
|
|
83
|
+
"-v",
|
|
84
|
+
is_flag=True,
|
|
85
|
+
help="Show auto-selected parameters and analysis details",
|
|
86
|
+
)
|
|
87
|
+
# Batch processing options
|
|
88
|
+
@click.option(
|
|
89
|
+
"--batch",
|
|
90
|
+
is_flag=True,
|
|
91
|
+
help="Enable batch processing mode for multiple videos",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--workers",
|
|
95
|
+
type=int,
|
|
96
|
+
default=4,
|
|
97
|
+
help="Number of parallel workers for batch processing (default: 4)",
|
|
98
|
+
show_default=True,
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"--output-dir",
|
|
102
|
+
type=click.Path(),
|
|
103
|
+
help="Directory for debug video outputs (batch mode only)",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--json-output-dir",
|
|
107
|
+
type=click.Path(),
|
|
108
|
+
help="Directory for JSON metrics outputs (batch mode only)",
|
|
109
|
+
)
|
|
110
|
+
@click.option(
|
|
111
|
+
"--csv-summary",
|
|
112
|
+
type=click.Path(),
|
|
113
|
+
help="Path for CSV summary export (batch mode only)",
|
|
114
|
+
)
|
|
115
|
+
# Expert parameters (hidden in help, but always available for advanced users)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--drop-start-frame",
|
|
118
|
+
type=int,
|
|
119
|
+
default=None,
|
|
120
|
+
help="[EXPERT] Manually specify frame where drop begins (overrides auto-detection)",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--smoothing-window",
|
|
124
|
+
type=int,
|
|
125
|
+
default=None,
|
|
126
|
+
help="[EXPERT] Override auto-tuned smoothing window size",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--velocity-threshold",
|
|
130
|
+
type=float,
|
|
131
|
+
default=None,
|
|
132
|
+
help="[EXPERT] Override auto-tuned velocity threshold",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"--min-contact-frames",
|
|
136
|
+
type=int,
|
|
137
|
+
default=None,
|
|
138
|
+
help="[EXPERT] Override auto-tuned minimum contact frames",
|
|
139
|
+
)
|
|
140
|
+
@click.option(
|
|
141
|
+
"--visibility-threshold",
|
|
142
|
+
type=float,
|
|
143
|
+
default=None,
|
|
144
|
+
help="[EXPERT] Override visibility threshold",
|
|
145
|
+
)
|
|
146
|
+
@click.option(
|
|
147
|
+
"--detection-confidence",
|
|
148
|
+
type=float,
|
|
149
|
+
default=None,
|
|
150
|
+
help="[EXPERT] Override pose detection confidence",
|
|
151
|
+
)
|
|
152
|
+
@click.option(
|
|
153
|
+
"--tracking-confidence",
|
|
154
|
+
type=float,
|
|
155
|
+
default=None,
|
|
156
|
+
help="[EXPERT] Override pose tracking confidence",
|
|
157
|
+
)
|
|
158
|
+
def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual parameters for each option
|
|
159
|
+
video_path: tuple[str, ...],
|
|
160
|
+
output: str | None,
|
|
161
|
+
json_output: str | None,
|
|
162
|
+
quality: str,
|
|
163
|
+
verbose: bool,
|
|
164
|
+
batch: bool,
|
|
165
|
+
workers: int,
|
|
166
|
+
output_dir: str | None,
|
|
167
|
+
json_output_dir: str | None,
|
|
168
|
+
csv_summary: str | None,
|
|
169
|
+
drop_start_frame: int | None,
|
|
170
|
+
smoothing_window: int | None,
|
|
171
|
+
velocity_threshold: float | None,
|
|
172
|
+
min_contact_frames: int | None,
|
|
173
|
+
visibility_threshold: float | None,
|
|
174
|
+
detection_confidence: float | None,
|
|
175
|
+
tracking_confidence: float | None,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Analyze drop-jump video(s) to estimate ground contact time, flight time, and jump height.
|
|
179
|
+
|
|
180
|
+
Uses intelligent auto-tuning to select optimal parameters based on video characteristics.
|
|
181
|
+
Parameters are automatically adjusted for frame rate, tracking quality, and analysis preset.
|
|
182
|
+
|
|
183
|
+
VIDEO_PATH: Path(s) to video file(s). Supports glob patterns in batch mode
|
|
184
|
+
(e.g., "videos/*.mp4").
|
|
185
|
+
|
|
186
|
+
Examples:
|
|
187
|
+
|
|
188
|
+
\b
|
|
189
|
+
# Single video
|
|
190
|
+
kinemotion dropjump-analyze video.mp4
|
|
191
|
+
|
|
192
|
+
\b
|
|
193
|
+
# Batch mode with glob pattern
|
|
194
|
+
kinemotion dropjump-analyze videos/*.mp4 --batch --workers 4
|
|
195
|
+
|
|
196
|
+
\b
|
|
197
|
+
# Batch with output directories
|
|
198
|
+
kinemotion dropjump-analyze videos/*.mp4 --batch \\
|
|
199
|
+
--json-output-dir results/ --csv-summary summary.csv
|
|
200
|
+
"""
|
|
201
|
+
# Expand glob patterns and collect all video files
|
|
202
|
+
video_files: list[str] = []
|
|
203
|
+
for pattern in video_path:
|
|
204
|
+
expanded = glob.glob(pattern)
|
|
205
|
+
if expanded:
|
|
206
|
+
video_files.extend(expanded)
|
|
207
|
+
elif Path(pattern).exists():
|
|
208
|
+
# Direct path (not a glob pattern)
|
|
209
|
+
video_files.append(pattern)
|
|
210
|
+
else:
|
|
211
|
+
click.echo(f"Warning: No files found for pattern: {pattern}", err=True)
|
|
212
|
+
|
|
213
|
+
if not video_files:
|
|
214
|
+
click.echo("Error: No video files found", err=True)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
# Determine if batch mode should be used
|
|
218
|
+
use_batch = batch or len(video_files) > 1
|
|
219
|
+
|
|
220
|
+
# Group expert parameters
|
|
221
|
+
expert_params = AnalysisParameters(
|
|
222
|
+
drop_start_frame=drop_start_frame,
|
|
223
|
+
smoothing_window=smoothing_window,
|
|
224
|
+
velocity_threshold=velocity_threshold,
|
|
225
|
+
min_contact_frames=min_contact_frames,
|
|
226
|
+
visibility_threshold=visibility_threshold,
|
|
227
|
+
detection_confidence=detection_confidence,
|
|
228
|
+
tracking_confidence=tracking_confidence,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if use_batch:
|
|
232
|
+
_process_batch(
|
|
233
|
+
video_files,
|
|
234
|
+
quality,
|
|
235
|
+
workers,
|
|
236
|
+
output_dir,
|
|
237
|
+
json_output_dir,
|
|
238
|
+
csv_summary,
|
|
239
|
+
expert_params,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Single video mode (original behavior)
|
|
243
|
+
_process_single(
|
|
244
|
+
video_files[0],
|
|
245
|
+
output,
|
|
246
|
+
json_output,
|
|
247
|
+
quality,
|
|
248
|
+
verbose,
|
|
249
|
+
expert_params,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_positions_and_visibilities(
|
|
254
|
+
smoothed_landmarks: list,
|
|
255
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
256
|
+
"""Extract vertical positions and visibilities from landmarks.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
smoothed_landmarks: Smoothed landmark sequence
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Tuple of (vertical_positions, visibilities)
|
|
263
|
+
"""
|
|
264
|
+
click.echo("Extracting foot positions...", err=True)
|
|
265
|
+
return extract_foot_positions_and_visibilities(smoothed_landmarks)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _create_debug_video(
|
|
269
|
+
output: str,
|
|
270
|
+
video: VideoProcessor,
|
|
271
|
+
frames: list,
|
|
272
|
+
smoothed_landmarks: list,
|
|
273
|
+
contact_states: list[ContactState],
|
|
274
|
+
metrics: DropJumpMetrics,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Generate debug video with overlays.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
output: Output video path
|
|
280
|
+
video: Video processor
|
|
281
|
+
frames: Video frames
|
|
282
|
+
smoothed_landmarks: Smoothed landmarks
|
|
283
|
+
contact_states: Contact states
|
|
284
|
+
metrics: Calculated metrics
|
|
285
|
+
"""
|
|
286
|
+
click.echo(f"Generating debug video: {output}", err=True)
|
|
287
|
+
if video.display_width != video.width or video.display_height != video.height:
|
|
288
|
+
click.echo(f"Source video encoded: {video.width}x{video.height}", err=True)
|
|
289
|
+
click.echo(
|
|
290
|
+
f"Output dimensions: {video.display_width}x{video.display_height} "
|
|
291
|
+
f"(respecting display aspect ratio)",
|
|
292
|
+
err=True,
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
click.echo(
|
|
296
|
+
f"Output dimensions: {video.width}x{video.height} "
|
|
297
|
+
f"(matching source video aspect ratio)",
|
|
298
|
+
err=True,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
with DebugOverlayRenderer(
|
|
302
|
+
output,
|
|
303
|
+
video.width,
|
|
304
|
+
video.height,
|
|
305
|
+
video.display_width,
|
|
306
|
+
video.display_height,
|
|
307
|
+
video.fps,
|
|
308
|
+
) as renderer:
|
|
309
|
+
render_bar: Any
|
|
310
|
+
with click.progressbar(
|
|
311
|
+
length=len(frames), label="Rendering frames"
|
|
312
|
+
) as render_bar:
|
|
313
|
+
for i, frame in enumerate(frames):
|
|
314
|
+
annotated = renderer.render_frame(
|
|
315
|
+
frame,
|
|
316
|
+
smoothed_landmarks[i],
|
|
317
|
+
contact_states[i],
|
|
318
|
+
i,
|
|
319
|
+
metrics,
|
|
320
|
+
use_com=False,
|
|
321
|
+
)
|
|
322
|
+
renderer.write_frame(annotated)
|
|
323
|
+
render_bar.update(1)
|
|
324
|
+
|
|
325
|
+
click.echo(f"Debug video saved: {output}", err=True)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _process_single(
|
|
329
|
+
video_path: str,
|
|
330
|
+
output: str | None,
|
|
331
|
+
json_output: str | None,
|
|
332
|
+
quality: str,
|
|
333
|
+
verbose: bool,
|
|
334
|
+
expert_params: AnalysisParameters,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Process a single video (original CLI behavior)."""
|
|
337
|
+
click.echo(f"Analyzing video: {video_path}", err=True)
|
|
338
|
+
|
|
339
|
+
quality_preset = QualityPreset(quality.lower())
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
with VideoProcessor(video_path) as video:
|
|
343
|
+
click.echo(
|
|
344
|
+
f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
|
|
345
|
+
f"{video.frame_count} frames",
|
|
346
|
+
err=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Determine confidence levels
|
|
350
|
+
detection_conf, tracking_conf = determine_initial_confidence(
|
|
351
|
+
quality_preset, expert_params
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Track all frames
|
|
355
|
+
tracker = PoseTracker(
|
|
356
|
+
min_detection_confidence=detection_conf,
|
|
357
|
+
min_tracking_confidence=tracking_conf,
|
|
358
|
+
)
|
|
359
|
+
frames, landmarks_sequence = track_all_frames(video, tracker)
|
|
360
|
+
|
|
361
|
+
if not landmarks_sequence:
|
|
362
|
+
click.echo("Error: No frames processed", err=True)
|
|
363
|
+
sys.exit(1)
|
|
364
|
+
|
|
365
|
+
# Auto-tune parameters
|
|
366
|
+
characteristics = analyze_video_sample(
|
|
367
|
+
landmarks_sequence, video.fps, video.frame_count
|
|
368
|
+
)
|
|
369
|
+
params = auto_tune_parameters(characteristics, quality_preset)
|
|
370
|
+
params = apply_expert_param_overrides(params, expert_params)
|
|
371
|
+
|
|
372
|
+
# Show parameters if verbose
|
|
373
|
+
if verbose:
|
|
374
|
+
print_auto_tuned_params(video, quality_preset, params, characteristics)
|
|
375
|
+
|
|
376
|
+
# Apply smoothing
|
|
377
|
+
smoothed_landmarks = smooth_landmark_sequence(landmarks_sequence, params)
|
|
378
|
+
|
|
379
|
+
# Extract positions
|
|
380
|
+
vertical_positions, visibilities = _extract_positions_and_visibilities(
|
|
381
|
+
smoothed_landmarks
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Detect ground contact
|
|
385
|
+
contact_states = detect_ground_contact(
|
|
386
|
+
vertical_positions,
|
|
387
|
+
velocity_threshold=params.velocity_threshold,
|
|
388
|
+
min_contact_frames=params.min_contact_frames,
|
|
389
|
+
visibility_threshold=params.visibility_threshold,
|
|
390
|
+
visibilities=visibilities,
|
|
391
|
+
window_length=params.smoothing_window,
|
|
392
|
+
polyorder=params.polyorder,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Calculate metrics
|
|
396
|
+
click.echo("Calculating metrics...", err=True)
|
|
397
|
+
metrics = calculate_drop_jump_metrics(
|
|
398
|
+
contact_states,
|
|
399
|
+
vertical_positions,
|
|
400
|
+
video.fps,
|
|
401
|
+
drop_start_frame=expert_params.drop_start_frame,
|
|
402
|
+
velocity_threshold=params.velocity_threshold,
|
|
403
|
+
smoothing_window=params.smoothing_window,
|
|
404
|
+
polyorder=params.polyorder,
|
|
405
|
+
use_curvature=params.use_curvature,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Output metrics
|
|
409
|
+
metrics_json = json.dumps(metrics.to_dict(), indent=2)
|
|
410
|
+
if json_output:
|
|
411
|
+
Path(json_output).write_text(metrics_json)
|
|
412
|
+
click.echo(f"Metrics written to: {json_output}", err=True)
|
|
413
|
+
else:
|
|
414
|
+
click.echo(metrics_json)
|
|
415
|
+
|
|
416
|
+
# Generate debug video if requested
|
|
417
|
+
if output:
|
|
418
|
+
_create_debug_video(
|
|
419
|
+
output, video, frames, smoothed_landmarks, contact_states, metrics
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
click.echo("Analysis complete!", err=True)
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
click.echo(f"Error: {str(e)}", err=True)
|
|
426
|
+
sys.exit(1)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _setup_batch_output_dirs(
|
|
430
|
+
output_dir: str | None, json_output_dir: str | None
|
|
431
|
+
) -> None:
|
|
432
|
+
"""Create output directories for batch processing.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
output_dir: Debug video output directory
|
|
436
|
+
json_output_dir: JSON metrics output directory
|
|
437
|
+
"""
|
|
438
|
+
if output_dir:
|
|
439
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
440
|
+
click.echo(f"Debug videos will be saved to: {output_dir}", err=True)
|
|
441
|
+
|
|
442
|
+
if json_output_dir:
|
|
443
|
+
Path(json_output_dir).mkdir(parents=True, exist_ok=True)
|
|
444
|
+
click.echo(f"JSON metrics will be saved to: {json_output_dir}", err=True)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _create_video_configs(
|
|
448
|
+
video_files: list[str],
|
|
449
|
+
quality: str,
|
|
450
|
+
output_dir: str | None,
|
|
451
|
+
json_output_dir: str | None,
|
|
452
|
+
expert_params: AnalysisParameters,
|
|
453
|
+
) -> list[DropJumpVideoConfig]:
|
|
454
|
+
"""Build configuration objects for each video.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
video_files: List of video file paths
|
|
458
|
+
quality: Quality preset
|
|
459
|
+
output_dir: Debug video output directory
|
|
460
|
+
json_output_dir: JSON metrics output directory
|
|
461
|
+
expert_params: Expert parameter overrides
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
List of DropJumpVideoConfig objects
|
|
465
|
+
"""
|
|
466
|
+
configs: list[DropJumpVideoConfig] = []
|
|
467
|
+
for video_file in video_files:
|
|
468
|
+
video_name = Path(video_file).stem
|
|
469
|
+
|
|
470
|
+
debug_video = None
|
|
471
|
+
if output_dir:
|
|
472
|
+
debug_video = str(Path(output_dir) / f"{video_name}_debug.mp4")
|
|
473
|
+
|
|
474
|
+
json_file = None
|
|
475
|
+
if json_output_dir:
|
|
476
|
+
json_file = str(Path(json_output_dir) / f"{video_name}.json")
|
|
477
|
+
|
|
478
|
+
config = DropJumpVideoConfig(
|
|
479
|
+
video_path=video_file,
|
|
480
|
+
quality=quality,
|
|
481
|
+
output_video=debug_video,
|
|
482
|
+
json_output=json_file,
|
|
483
|
+
drop_start_frame=expert_params.drop_start_frame,
|
|
484
|
+
smoothing_window=expert_params.smoothing_window,
|
|
485
|
+
velocity_threshold=expert_params.velocity_threshold,
|
|
486
|
+
min_contact_frames=expert_params.min_contact_frames,
|
|
487
|
+
visibility_threshold=expert_params.visibility_threshold,
|
|
488
|
+
detection_confidence=expert_params.detection_confidence,
|
|
489
|
+
tracking_confidence=expert_params.tracking_confidence,
|
|
490
|
+
)
|
|
491
|
+
configs.append(config)
|
|
492
|
+
|
|
493
|
+
return configs
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _compute_batch_statistics(results: list[DropJumpVideoResult]) -> None:
|
|
497
|
+
"""Compute and display batch processing statistics.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
results: List of video processing results
|
|
501
|
+
"""
|
|
502
|
+
click.echo("\n" + "=" * 70, err=True)
|
|
503
|
+
click.echo("BATCH PROCESSING SUMMARY", err=True)
|
|
504
|
+
click.echo("=" * 70, err=True)
|
|
505
|
+
|
|
506
|
+
successful = [r for r in results if r.success]
|
|
507
|
+
failed = [r for r in results if not r.success]
|
|
508
|
+
|
|
509
|
+
click.echo(f"Total videos: {len(results)}", err=True)
|
|
510
|
+
click.echo(f"Successful: {len(successful)}", err=True)
|
|
511
|
+
click.echo(f"Failed: {len(failed)}", err=True)
|
|
512
|
+
|
|
513
|
+
if successful:
|
|
514
|
+
# Calculate average metrics
|
|
515
|
+
with_gct = [
|
|
516
|
+
r
|
|
517
|
+
for r in successful
|
|
518
|
+
if r.metrics and r.metrics.ground_contact_time is not None
|
|
519
|
+
]
|
|
520
|
+
with_flight = [
|
|
521
|
+
r for r in successful if r.metrics and r.metrics.flight_time is not None
|
|
522
|
+
]
|
|
523
|
+
with_jump = [
|
|
524
|
+
r for r in successful if r.metrics and r.metrics.jump_height is not None
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
if with_gct:
|
|
528
|
+
avg_gct = sum(
|
|
529
|
+
r.metrics.ground_contact_time * 1000
|
|
530
|
+
for r in with_gct
|
|
531
|
+
if r.metrics and r.metrics.ground_contact_time is not None
|
|
532
|
+
) / len(with_gct)
|
|
533
|
+
click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
|
|
534
|
+
|
|
535
|
+
if with_flight:
|
|
536
|
+
avg_flight = sum(
|
|
537
|
+
r.metrics.flight_time * 1000
|
|
538
|
+
for r in with_flight
|
|
539
|
+
if r.metrics and r.metrics.flight_time is not None
|
|
540
|
+
) / len(with_flight)
|
|
541
|
+
click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
|
|
542
|
+
|
|
543
|
+
if with_jump:
|
|
544
|
+
avg_jump = sum(
|
|
545
|
+
r.metrics.jump_height
|
|
546
|
+
for r in with_jump
|
|
547
|
+
if r.metrics and r.metrics.jump_height is not None
|
|
548
|
+
) / len(with_jump)
|
|
549
|
+
click.echo(
|
|
550
|
+
f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
|
|
551
|
+
err=True,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _format_time_metric(value: float | None, multiplier: float = 1000.0) -> str:
|
|
556
|
+
"""Format time metric for CSV output.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
value: Time value in seconds
|
|
560
|
+
multiplier: Multiplier to convert to milliseconds (default: 1000.0)
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Formatted string or "N/A" if value is None
|
|
564
|
+
"""
|
|
565
|
+
return f"{value * multiplier:.1f}" if value is not None else "N/A"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _format_distance_metric(value: float | None) -> str:
|
|
569
|
+
"""Format distance metric for CSV output.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
value: Distance value in meters
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Formatted string or "N/A" if value is None
|
|
576
|
+
"""
|
|
577
|
+
return f"{value:.3f}" if value is not None else "N/A"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _create_csv_row_from_result(result: DropJumpVideoResult) -> list[str]:
|
|
581
|
+
"""Create CSV row from video processing result.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
result: Video processing result
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
List of formatted values for CSV row
|
|
588
|
+
"""
|
|
589
|
+
video_name = Path(result.video_path).name
|
|
590
|
+
processing_time = f"{result.processing_time:.2f}"
|
|
591
|
+
|
|
592
|
+
if result.success and result.metrics:
|
|
593
|
+
return [
|
|
594
|
+
video_name,
|
|
595
|
+
_format_time_metric(result.metrics.ground_contact_time),
|
|
596
|
+
_format_time_metric(result.metrics.flight_time),
|
|
597
|
+
_format_distance_metric(result.metrics.jump_height),
|
|
598
|
+
processing_time,
|
|
599
|
+
"Success",
|
|
600
|
+
]
|
|
601
|
+
else:
|
|
602
|
+
return [
|
|
603
|
+
video_name,
|
|
604
|
+
"N/A",
|
|
605
|
+
"N/A",
|
|
606
|
+
"N/A",
|
|
607
|
+
processing_time,
|
|
608
|
+
f"Failed: {result.error}",
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _write_csv_summary(
|
|
613
|
+
csv_summary: str | None,
|
|
614
|
+
results: list[DropJumpVideoResult],
|
|
615
|
+
successful: list[DropJumpVideoResult],
|
|
616
|
+
) -> None:
|
|
617
|
+
"""Write CSV summary of batch processing results.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
csv_summary: Path to CSV output file
|
|
621
|
+
results: All processing results
|
|
622
|
+
successful: Successful processing results
|
|
623
|
+
"""
|
|
624
|
+
if not csv_summary or not successful:
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
|
|
628
|
+
Path(csv_summary).parent.mkdir(parents=True, exist_ok=True)
|
|
629
|
+
|
|
630
|
+
with open(csv_summary, "w", newline="") as f:
|
|
631
|
+
writer = csv.writer(f)
|
|
632
|
+
|
|
633
|
+
# Header
|
|
634
|
+
writer.writerow(
|
|
635
|
+
[
|
|
636
|
+
"Video",
|
|
637
|
+
"Ground Contact Time (ms)",
|
|
638
|
+
"Flight Time (ms)",
|
|
639
|
+
"Jump Height (m)",
|
|
640
|
+
"Processing Time (s)",
|
|
641
|
+
"Status",
|
|
642
|
+
]
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Data rows
|
|
646
|
+
for result in results:
|
|
647
|
+
writer.writerow(_create_csv_row_from_result(result))
|
|
648
|
+
|
|
649
|
+
click.echo("CSV summary written successfully", err=True)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _process_batch(
|
|
653
|
+
video_files: list[str],
|
|
654
|
+
quality: str,
|
|
655
|
+
workers: int,
|
|
656
|
+
output_dir: str | None,
|
|
657
|
+
json_output_dir: str | None,
|
|
658
|
+
csv_summary: str | None,
|
|
659
|
+
expert_params: AnalysisParameters,
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Process multiple videos in batch mode using parallel processing."""
|
|
662
|
+
click.echo(
|
|
663
|
+
f"\nBatch processing {len(video_files)} videos with {workers} workers", err=True
|
|
664
|
+
)
|
|
665
|
+
click.echo("=" * 70, err=True)
|
|
666
|
+
|
|
667
|
+
# Setup output directories
|
|
668
|
+
_setup_batch_output_dirs(output_dir, json_output_dir)
|
|
669
|
+
|
|
670
|
+
# Create video configurations
|
|
671
|
+
configs = _create_video_configs(
|
|
672
|
+
video_files, quality, output_dir, json_output_dir, expert_params
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# Progress callback
|
|
676
|
+
completed = 0
|
|
677
|
+
|
|
678
|
+
def show_progress(result: DropJumpVideoResult) -> None:
|
|
679
|
+
nonlocal completed
|
|
680
|
+
completed += 1
|
|
681
|
+
status = "✓" if result.success else "✗"
|
|
682
|
+
video_name = Path(result.video_path).name
|
|
683
|
+
click.echo(
|
|
684
|
+
f"[{completed}/{len(configs)}] {status} {video_name} "
|
|
685
|
+
f"({result.processing_time:.1f}s)",
|
|
686
|
+
err=True,
|
|
687
|
+
)
|
|
688
|
+
if not result.success:
|
|
689
|
+
click.echo(f" Error: {result.error}", err=True)
|
|
690
|
+
|
|
691
|
+
# Process all videos
|
|
692
|
+
click.echo("\nProcessing videos...", err=True)
|
|
693
|
+
results = process_dropjump_videos_bulk(
|
|
694
|
+
configs, max_workers=workers, progress_callback=show_progress
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
# Display statistics
|
|
698
|
+
_compute_batch_statistics(results)
|
|
699
|
+
|
|
700
|
+
# Export CSV summary if requested
|
|
701
|
+
successful = [r for r in results if r.success]
|
|
702
|
+
_write_csv_summary(csv_summary, results, successful)
|
|
703
|
+
|
|
704
|
+
click.echo("\nBatch processing complete!", err=True)
|