kinemotion 0.1.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.
- dropjump/__init__.py +3 -0
- dropjump/cli.py +294 -0
- dropjump/contact_detection.py +431 -0
- dropjump/kinematics.py +374 -0
- dropjump/pose_tracker.py +74 -0
- dropjump/smoothing.py +223 -0
- dropjump/video_io.py +337 -0
- kinemotion-0.1.0.dist-info/METADATA +381 -0
- kinemotion-0.1.0.dist-info/RECORD +12 -0
- kinemotion-0.1.0.dist-info/WHEEL +4 -0
- kinemotion-0.1.0.dist-info/entry_points.txt +2 -0
- kinemotion-0.1.0.dist-info/licenses/LICENSE +21 -0
dropjump/__init__.py
ADDED
dropjump/cli.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Command-line interface for kinemetry analysis."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .contact_detection import (
|
|
11
|
+
compute_average_foot_position,
|
|
12
|
+
detect_ground_contact,
|
|
13
|
+
)
|
|
14
|
+
from .kinematics import calculate_drop_jump_metrics
|
|
15
|
+
from .pose_tracker import PoseTracker
|
|
16
|
+
from .smoothing import smooth_landmarks
|
|
17
|
+
from .video_io import DebugOverlayRenderer, VideoProcessor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(package_name="dropjump-analyze")
|
|
22
|
+
def cli() -> None:
|
|
23
|
+
"""Kinemetry: Video-based kinematic analysis for athletic performance."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.command(name="dropjump-analyze")
|
|
28
|
+
@click.argument("video_path", type=click.Path(exists=True))
|
|
29
|
+
@click.option(
|
|
30
|
+
"--output",
|
|
31
|
+
"-o",
|
|
32
|
+
type=click.Path(),
|
|
33
|
+
help="Path for debug video output (optional)",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--json-output",
|
|
37
|
+
"-j",
|
|
38
|
+
type=click.Path(),
|
|
39
|
+
help="Path for JSON metrics output (default: stdout)",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--smoothing-window",
|
|
43
|
+
type=int,
|
|
44
|
+
default=5,
|
|
45
|
+
help="Smoothing window size (must be odd, >= 3)",
|
|
46
|
+
show_default=True,
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--velocity-threshold",
|
|
50
|
+
type=float,
|
|
51
|
+
default=0.02,
|
|
52
|
+
help="Velocity threshold for contact detection (normalized units)",
|
|
53
|
+
show_default=True,
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--min-contact-frames",
|
|
57
|
+
type=int,
|
|
58
|
+
default=3,
|
|
59
|
+
help="Minimum frames for valid ground contact",
|
|
60
|
+
show_default=True,
|
|
61
|
+
)
|
|
62
|
+
@click.option(
|
|
63
|
+
"--visibility-threshold",
|
|
64
|
+
type=float,
|
|
65
|
+
default=0.5,
|
|
66
|
+
help="Minimum landmark visibility score (0-1)",
|
|
67
|
+
show_default=True,
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--detection-confidence",
|
|
71
|
+
type=float,
|
|
72
|
+
default=0.5,
|
|
73
|
+
help="Pose detection confidence threshold (0-1)",
|
|
74
|
+
show_default=True,
|
|
75
|
+
)
|
|
76
|
+
@click.option(
|
|
77
|
+
"--tracking-confidence",
|
|
78
|
+
type=float,
|
|
79
|
+
default=0.5,
|
|
80
|
+
help="Pose tracking confidence threshold (0-1)",
|
|
81
|
+
show_default=True,
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--drop-height",
|
|
85
|
+
type=float,
|
|
86
|
+
default=None,
|
|
87
|
+
help="Height of drop box/platform in meters (e.g., 0.40 for 40cm) - used for calibration",
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--use-curvature/--no-curvature",
|
|
91
|
+
default=True,
|
|
92
|
+
help="Use trajectory curvature analysis for refining transitions (default: enabled)",
|
|
93
|
+
)
|
|
94
|
+
def dropjump_analyze(
|
|
95
|
+
video_path: str,
|
|
96
|
+
output: str | None,
|
|
97
|
+
json_output: str | None,
|
|
98
|
+
smoothing_window: int,
|
|
99
|
+
velocity_threshold: float,
|
|
100
|
+
min_contact_frames: int,
|
|
101
|
+
visibility_threshold: float,
|
|
102
|
+
detection_confidence: float,
|
|
103
|
+
tracking_confidence: float,
|
|
104
|
+
drop_height: float | None,
|
|
105
|
+
use_curvature: bool,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Analyze drop-jump video to estimate ground contact time, flight time, and jump height.
|
|
109
|
+
|
|
110
|
+
VIDEO_PATH: Path to the input video file
|
|
111
|
+
"""
|
|
112
|
+
click.echo(f"Analyzing video: {video_path}", err=True)
|
|
113
|
+
|
|
114
|
+
# Validate parameters
|
|
115
|
+
if smoothing_window < 3:
|
|
116
|
+
click.echo("Error: smoothing-window must be >= 3", err=True)
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
|
|
119
|
+
if smoothing_window % 2 == 0:
|
|
120
|
+
smoothing_window += 1
|
|
121
|
+
click.echo(
|
|
122
|
+
f"Adjusting smoothing-window to {smoothing_window} (must be odd)", err=True
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
# Initialize video processor
|
|
127
|
+
with VideoProcessor(video_path) as video:
|
|
128
|
+
click.echo(
|
|
129
|
+
f"Video: {video.width}x{video.height} @ {video.fps:.2f} fps, "
|
|
130
|
+
f"{video.frame_count} frames",
|
|
131
|
+
err=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Initialize pose tracker
|
|
135
|
+
tracker = PoseTracker(
|
|
136
|
+
min_detection_confidence=detection_confidence,
|
|
137
|
+
min_tracking_confidence=tracking_confidence,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Process all frames
|
|
141
|
+
click.echo("Tracking pose landmarks...", err=True)
|
|
142
|
+
landmarks_sequence = []
|
|
143
|
+
frames = []
|
|
144
|
+
|
|
145
|
+
frame_idx = 0
|
|
146
|
+
with click.progressbar(
|
|
147
|
+
length=video.frame_count, label="Processing frames"
|
|
148
|
+
) as bar:
|
|
149
|
+
while True:
|
|
150
|
+
frame = video.read_frame()
|
|
151
|
+
if frame is None:
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
frames.append(frame)
|
|
155
|
+
landmarks = tracker.process_frame(frame)
|
|
156
|
+
landmarks_sequence.append(landmarks)
|
|
157
|
+
|
|
158
|
+
frame_idx += 1
|
|
159
|
+
bar.update(1)
|
|
160
|
+
|
|
161
|
+
tracker.close()
|
|
162
|
+
|
|
163
|
+
if not landmarks_sequence:
|
|
164
|
+
click.echo("Error: No frames processed", err=True)
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
# Smooth landmarks
|
|
168
|
+
click.echo("Smoothing landmarks...", err=True)
|
|
169
|
+
smoothed_landmarks = smooth_landmarks(
|
|
170
|
+
landmarks_sequence, window_length=smoothing_window
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Extract foot positions
|
|
174
|
+
click.echo("Detecting ground contact...", err=True)
|
|
175
|
+
foot_positions_list: list[float] = []
|
|
176
|
+
visibilities_list: list[float] = []
|
|
177
|
+
|
|
178
|
+
for frame_landmarks in smoothed_landmarks:
|
|
179
|
+
if frame_landmarks:
|
|
180
|
+
foot_x, foot_y = compute_average_foot_position(frame_landmarks)
|
|
181
|
+
foot_positions_list.append(foot_y)
|
|
182
|
+
|
|
183
|
+
# Average visibility of foot landmarks
|
|
184
|
+
foot_vis = []
|
|
185
|
+
for key in [
|
|
186
|
+
"left_ankle",
|
|
187
|
+
"right_ankle",
|
|
188
|
+
"left_heel",
|
|
189
|
+
"right_heel",
|
|
190
|
+
]:
|
|
191
|
+
if key in frame_landmarks:
|
|
192
|
+
foot_vis.append(frame_landmarks[key][2])
|
|
193
|
+
visibilities_list.append(
|
|
194
|
+
float(np.mean(foot_vis)) if foot_vis else 0.0
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
# Use previous position if available, otherwise default
|
|
198
|
+
foot_positions_list.append(
|
|
199
|
+
foot_positions_list[-1] if foot_positions_list else 0.5
|
|
200
|
+
)
|
|
201
|
+
visibilities_list.append(0.0)
|
|
202
|
+
|
|
203
|
+
foot_positions: np.ndarray = np.array(foot_positions_list)
|
|
204
|
+
visibilities: np.ndarray = np.array(visibilities_list)
|
|
205
|
+
|
|
206
|
+
# Detect ground contact
|
|
207
|
+
contact_states = detect_ground_contact(
|
|
208
|
+
foot_positions,
|
|
209
|
+
velocity_threshold=velocity_threshold,
|
|
210
|
+
min_contact_frames=min_contact_frames,
|
|
211
|
+
visibility_threshold=visibility_threshold,
|
|
212
|
+
visibilities=visibilities,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Calculate metrics
|
|
216
|
+
click.echo("Calculating metrics...", err=True)
|
|
217
|
+
if drop_height:
|
|
218
|
+
click.echo(
|
|
219
|
+
f"Using drop height calibration: {drop_height}m ({drop_height*100:.0f}cm)",
|
|
220
|
+
err=True,
|
|
221
|
+
)
|
|
222
|
+
metrics = calculate_drop_jump_metrics(
|
|
223
|
+
contact_states,
|
|
224
|
+
foot_positions,
|
|
225
|
+
video.fps,
|
|
226
|
+
drop_height_m=drop_height,
|
|
227
|
+
velocity_threshold=velocity_threshold,
|
|
228
|
+
smoothing_window=smoothing_window,
|
|
229
|
+
use_curvature=use_curvature,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Output metrics as JSON
|
|
233
|
+
metrics_dict = metrics.to_dict()
|
|
234
|
+
metrics_json = json.dumps(metrics_dict, indent=2)
|
|
235
|
+
|
|
236
|
+
if json_output:
|
|
237
|
+
output_path = Path(json_output)
|
|
238
|
+
output_path.write_text(metrics_json)
|
|
239
|
+
click.echo(f"Metrics written to: {json_output}", err=True)
|
|
240
|
+
else:
|
|
241
|
+
click.echo(metrics_json)
|
|
242
|
+
|
|
243
|
+
# Generate debug video if requested
|
|
244
|
+
if output:
|
|
245
|
+
click.echo(f"Generating debug video: {output}", err=True)
|
|
246
|
+
if video.display_width != video.width or video.display_height != video.height:
|
|
247
|
+
click.echo(
|
|
248
|
+
f"Source video encoded: {video.width}x{video.height}",
|
|
249
|
+
err=True,
|
|
250
|
+
)
|
|
251
|
+
click.echo(
|
|
252
|
+
f"Output dimensions: {video.display_width}x{video.display_height} "
|
|
253
|
+
f"(respecting display aspect ratio)",
|
|
254
|
+
err=True,
|
|
255
|
+
)
|
|
256
|
+
else:
|
|
257
|
+
click.echo(
|
|
258
|
+
f"Output dimensions: {video.width}x{video.height} "
|
|
259
|
+
f"(matching source video aspect ratio)",
|
|
260
|
+
err=True,
|
|
261
|
+
)
|
|
262
|
+
with DebugOverlayRenderer(
|
|
263
|
+
output,
|
|
264
|
+
video.width,
|
|
265
|
+
video.height,
|
|
266
|
+
video.display_width,
|
|
267
|
+
video.display_height,
|
|
268
|
+
video.fps,
|
|
269
|
+
) as renderer:
|
|
270
|
+
with click.progressbar(
|
|
271
|
+
length=len(frames), label="Rendering frames"
|
|
272
|
+
) as bar:
|
|
273
|
+
for i, frame in enumerate(frames):
|
|
274
|
+
annotated = renderer.render_frame(
|
|
275
|
+
frame,
|
|
276
|
+
smoothed_landmarks[i],
|
|
277
|
+
contact_states[i],
|
|
278
|
+
i,
|
|
279
|
+
metrics,
|
|
280
|
+
)
|
|
281
|
+
renderer.write_frame(annotated)
|
|
282
|
+
bar.update(1)
|
|
283
|
+
|
|
284
|
+
click.echo(f"Debug video saved: {output}", err=True)
|
|
285
|
+
|
|
286
|
+
click.echo("Analysis complete!", err=True)
|
|
287
|
+
|
|
288
|
+
except Exception as e:
|
|
289
|
+
click.echo(f"Error: {str(e)}", err=True)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
if __name__ == "__main__":
|
|
294
|
+
cli()
|