kinemotion 0.71.0__py3-none-any.whl → 0.72.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 +1 -1
- kinemotion/api.py +2 -2
- kinemotion/cli.py +1 -1
- kinemotion/cmj/analysis.py +2 -4
- kinemotion/cmj/api.py +9 -7
- kinemotion/cmj/debug_overlay.py +154 -286
- kinemotion/cmj/joint_angles.py +96 -31
- kinemotion/cmj/metrics_validator.py +22 -29
- kinemotion/cmj/validation_bounds.py +1 -18
- kinemotion/core/__init__.py +0 -2
- kinemotion/core/auto_tuning.py +95 -100
- kinemotion/core/debug_overlay_utils.py +142 -15
- kinemotion/core/experimental.py +55 -51
- kinemotion/core/filtering.py +15 -11
- kinemotion/core/overlay_constants.py +61 -0
- kinemotion/core/pipeline_utils.py +1 -1
- kinemotion/core/pose.py +47 -98
- kinemotion/core/smoothing.py +65 -51
- kinemotion/core/types.py +15 -0
- kinemotion/core/validation.py +6 -7
- kinemotion/core/video_io.py +14 -9
- kinemotion/{dropjump → dj}/__init__.py +2 -2
- kinemotion/{dropjump → dj}/analysis.py +192 -75
- kinemotion/{dropjump → dj}/api.py +13 -17
- kinemotion/{dropjump → dj}/cli.py +62 -78
- kinemotion/dj/debug_overlay.py +241 -0
- kinemotion/{dropjump → dj}/kinematics.py +106 -44
- kinemotion/{dropjump → dj}/metrics_validator.py +1 -1
- kinemotion/{dropjump → dj}/validation_bounds.py +1 -1
- {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/METADATA +1 -1
- kinemotion-0.72.0.dist-info/RECORD +50 -0
- kinemotion/dropjump/debug_overlay.py +0 -182
- kinemotion-0.71.0.dist-info/RECORD +0 -49
- {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/WHEEL +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/entry_points.txt +0 -0
- {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -48,10 +48,18 @@ from .analysis import (
|
|
|
48
48
|
detect_ground_contact,
|
|
49
49
|
find_contact_phases,
|
|
50
50
|
)
|
|
51
|
-
from .debug_overlay import
|
|
51
|
+
from .debug_overlay import DropJumpDebugOverlayRenderer
|
|
52
52
|
from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
|
|
53
53
|
from .metrics_validator import DropJumpMetricsValidator
|
|
54
54
|
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AnalysisOverrides",
|
|
57
|
+
"DropJumpVideoConfig",
|
|
58
|
+
"DropJumpVideoResult",
|
|
59
|
+
"process_dropjump_video",
|
|
60
|
+
"process_dropjump_videos_bulk",
|
|
61
|
+
]
|
|
62
|
+
|
|
55
63
|
|
|
56
64
|
@dataclass
|
|
57
65
|
class AnalysisOverrides:
|
|
@@ -305,7 +313,6 @@ def _tune_and_smooth(
|
|
|
305
313
|
characteristics = analyze_video_sample(landmarks_sequence, video_fps, frame_count)
|
|
306
314
|
params = auto_tune_parameters(characteristics, quality_preset)
|
|
307
315
|
|
|
308
|
-
# Apply overrides if provided
|
|
309
316
|
if overrides:
|
|
310
317
|
params = apply_expert_overrides(
|
|
311
318
|
params,
|
|
@@ -314,14 +321,6 @@ def _tune_and_smooth(
|
|
|
314
321
|
overrides.min_contact_frames,
|
|
315
322
|
overrides.visibility_threshold,
|
|
316
323
|
)
|
|
317
|
-
else:
|
|
318
|
-
params = apply_expert_overrides(
|
|
319
|
-
params,
|
|
320
|
-
None,
|
|
321
|
-
None,
|
|
322
|
-
None,
|
|
323
|
-
None,
|
|
324
|
-
)
|
|
325
324
|
|
|
326
325
|
smoothed_landmarks = apply_smoothing(landmarks_sequence, params, verbose, timer)
|
|
327
326
|
|
|
@@ -440,16 +439,13 @@ def _generate_debug_video(
|
|
|
440
439
|
timer = timer or NULL_TIMER
|
|
441
440
|
debug_h, debug_w = frames[0].shape[:2]
|
|
442
441
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
else:
|
|
446
|
-
debug_fps = video_fps
|
|
447
|
-
|
|
442
|
+
# Calculate debug FPS: cap at 30 for high-fps videos, use step if frame-sparse
|
|
443
|
+
debug_fps = min(video_fps, 30.0)
|
|
448
444
|
if len(frames) < len(smoothed_landmarks):
|
|
449
445
|
step = max(1, int(video_fps / 30.0))
|
|
450
446
|
debug_fps = video_fps / step
|
|
451
447
|
|
|
452
|
-
def _render_frames(renderer:
|
|
448
|
+
def _render_frames(renderer: DropJumpDebugOverlayRenderer) -> None:
|
|
453
449
|
for frame, idx in zip(frames, frame_indices, strict=True):
|
|
454
450
|
annotated = renderer.render_frame(
|
|
455
451
|
frame,
|
|
@@ -461,7 +457,7 @@ def _generate_debug_video(
|
|
|
461
457
|
)
|
|
462
458
|
renderer.write_frame(annotated)
|
|
463
459
|
|
|
464
|
-
renderer_context =
|
|
460
|
+
renderer_context = DropJumpDebugOverlayRenderer(
|
|
465
461
|
output_video,
|
|
466
462
|
debug_w,
|
|
467
463
|
debug_h,
|
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import sys
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
import click
|
|
10
11
|
|
|
@@ -19,6 +20,9 @@ from .api import (
|
|
|
19
20
|
process_dropjump_videos_bulk,
|
|
20
21
|
)
|
|
21
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .api import AnalysisOverrides
|
|
25
|
+
|
|
22
26
|
|
|
23
27
|
@dataclass
|
|
24
28
|
class AnalysisParameters:
|
|
@@ -225,6 +229,34 @@ def dropjump_analyze( # NOSONAR(S107) - Click CLI requires individual
|
|
|
225
229
|
)
|
|
226
230
|
|
|
227
231
|
|
|
232
|
+
def _create_overrides_if_needed(params: AnalysisParameters) -> "AnalysisOverrides | None":
|
|
233
|
+
"""Create AnalysisOverrides if any override parameters are set.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
params: Expert parameters from CLI
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
AnalysisOverrides if any relevant parameters are non-None, else None
|
|
240
|
+
"""
|
|
241
|
+
from .api import AnalysisOverrides
|
|
242
|
+
|
|
243
|
+
if any(
|
|
244
|
+
[
|
|
245
|
+
params.smoothing_window is not None,
|
|
246
|
+
params.velocity_threshold is not None,
|
|
247
|
+
params.min_contact_frames is not None,
|
|
248
|
+
params.visibility_threshold is not None,
|
|
249
|
+
]
|
|
250
|
+
):
|
|
251
|
+
return AnalysisOverrides(
|
|
252
|
+
smoothing_window=params.smoothing_window,
|
|
253
|
+
velocity_threshold=params.velocity_threshold,
|
|
254
|
+
min_contact_frames=params.min_contact_frames,
|
|
255
|
+
visibility_threshold=params.visibility_threshold,
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
228
260
|
def _process_single(
|
|
229
261
|
video_path: str,
|
|
230
262
|
output: str | None,
|
|
@@ -237,24 +269,7 @@ def _process_single(
|
|
|
237
269
|
click.echo(f"Analyzing video: {video_path}", err=True)
|
|
238
270
|
|
|
239
271
|
try:
|
|
240
|
-
|
|
241
|
-
from .api import AnalysisOverrides
|
|
242
|
-
|
|
243
|
-
overrides = None
|
|
244
|
-
if any(
|
|
245
|
-
[
|
|
246
|
-
expert_params.smoothing_window is not None,
|
|
247
|
-
expert_params.velocity_threshold is not None,
|
|
248
|
-
expert_params.min_contact_frames is not None,
|
|
249
|
-
expert_params.visibility_threshold is not None,
|
|
250
|
-
]
|
|
251
|
-
):
|
|
252
|
-
overrides = AnalysisOverrides(
|
|
253
|
-
smoothing_window=expert_params.smoothing_window,
|
|
254
|
-
velocity_threshold=expert_params.velocity_threshold,
|
|
255
|
-
min_contact_frames=expert_params.min_contact_frames,
|
|
256
|
-
visibility_threshold=expert_params.visibility_threshold,
|
|
257
|
-
)
|
|
272
|
+
overrides = _create_overrides_if_needed(expert_params)
|
|
258
273
|
|
|
259
274
|
# Call the API function (handles all processing logic)
|
|
260
275
|
metrics = process_dropjump_video(
|
|
@@ -328,24 +343,7 @@ def _create_video_configs(
|
|
|
328
343
|
video_file, output_dir, json_output_dir
|
|
329
344
|
)
|
|
330
345
|
|
|
331
|
-
|
|
332
|
-
from .api import AnalysisOverrides
|
|
333
|
-
|
|
334
|
-
overrides = None
|
|
335
|
-
if any(
|
|
336
|
-
[
|
|
337
|
-
expert_params.smoothing_window is not None,
|
|
338
|
-
expert_params.velocity_threshold is not None,
|
|
339
|
-
expert_params.min_contact_frames is not None,
|
|
340
|
-
expert_params.visibility_threshold is not None,
|
|
341
|
-
]
|
|
342
|
-
):
|
|
343
|
-
overrides = AnalysisOverrides(
|
|
344
|
-
smoothing_window=expert_params.smoothing_window,
|
|
345
|
-
velocity_threshold=expert_params.velocity_threshold,
|
|
346
|
-
min_contact_frames=expert_params.min_contact_frames,
|
|
347
|
-
visibility_threshold=expert_params.visibility_threshold,
|
|
348
|
-
)
|
|
346
|
+
overrides = _create_overrides_if_needed(expert_params)
|
|
349
347
|
|
|
350
348
|
config = DropJumpVideoConfig(
|
|
351
349
|
video_path=video_file,
|
|
@@ -380,35 +378,33 @@ def _compute_batch_statistics(results: list[DropJumpVideoResult]) -> None:
|
|
|
380
378
|
click.echo(f"Failed: {len(failed)}", err=True)
|
|
381
379
|
|
|
382
380
|
if successful:
|
|
383
|
-
# Calculate average metrics
|
|
384
|
-
|
|
385
|
-
r
|
|
381
|
+
# Calculate average metrics from results with non-None values
|
|
382
|
+
gct_values = [
|
|
383
|
+
r.metrics.ground_contact_time * 1000
|
|
384
|
+
for r in successful
|
|
385
|
+
if r.metrics and r.metrics.ground_contact_time is not None
|
|
386
|
+
]
|
|
387
|
+
flight_values = [
|
|
388
|
+
r.metrics.flight_time * 1000
|
|
389
|
+
for r in successful
|
|
390
|
+
if r.metrics and r.metrics.flight_time is not None
|
|
386
391
|
]
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
) / len(with_gct)
|
|
392
|
+
jump_values = [
|
|
393
|
+
r.metrics.jump_height
|
|
394
|
+
for r in successful
|
|
395
|
+
if r.metrics and r.metrics.jump_height is not None
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
if gct_values:
|
|
399
|
+
avg_gct = sum(gct_values) / len(gct_values)
|
|
396
400
|
click.echo(f"\nAverage ground contact time: {avg_gct:.1f} ms", err=True)
|
|
397
401
|
|
|
398
|
-
if
|
|
399
|
-
avg_flight = sum(
|
|
400
|
-
r.metrics.flight_time * 1000
|
|
401
|
-
for r in with_flight
|
|
402
|
-
if r.metrics and r.metrics.flight_time is not None
|
|
403
|
-
) / len(with_flight)
|
|
402
|
+
if flight_values:
|
|
403
|
+
avg_flight = sum(flight_values) / len(flight_values)
|
|
404
404
|
click.echo(f"Average flight time: {avg_flight:.1f} ms", err=True)
|
|
405
405
|
|
|
406
|
-
if
|
|
407
|
-
avg_jump = sum(
|
|
408
|
-
r.metrics.jump_height
|
|
409
|
-
for r in with_jump
|
|
410
|
-
if r.metrics and r.metrics.jump_height is not None
|
|
411
|
-
) / len(with_jump)
|
|
406
|
+
if jump_values:
|
|
407
|
+
avg_jump = sum(jump_values) / len(jump_values)
|
|
412
408
|
click.echo(
|
|
413
409
|
f"Average jump height: {avg_jump:.3f} m ({avg_jump * 100:.1f} cm)",
|
|
414
410
|
err=True,
|
|
@@ -453,38 +449,27 @@ def _create_csv_row_from_result(result: DropJumpVideoResult) -> list[str]:
|
|
|
453
449
|
processing_time = f"{result.processing_time:.2f}"
|
|
454
450
|
|
|
455
451
|
if result.success and result.metrics:
|
|
456
|
-
|
|
457
|
-
video_name,
|
|
452
|
+
metrics_data = [
|
|
458
453
|
_format_time_metric(result.metrics.ground_contact_time),
|
|
459
454
|
_format_time_metric(result.metrics.flight_time),
|
|
460
455
|
_format_distance_metric(result.metrics.jump_height),
|
|
461
|
-
processing_time,
|
|
462
|
-
"Success",
|
|
463
|
-
]
|
|
464
|
-
else:
|
|
465
|
-
return [
|
|
466
|
-
video_name,
|
|
467
|
-
"N/A",
|
|
468
|
-
"N/A",
|
|
469
|
-
"N/A",
|
|
470
|
-
processing_time,
|
|
471
|
-
f"Failed: {result.error}",
|
|
472
456
|
]
|
|
457
|
+
return [video_name, *metrics_data, processing_time, "Success"]
|
|
458
|
+
|
|
459
|
+
return [video_name, "N/A", "N/A", "N/A", processing_time, f"Failed: {result.error}"]
|
|
473
460
|
|
|
474
461
|
|
|
475
462
|
def _write_csv_summary(
|
|
476
463
|
csv_summary: str | None,
|
|
477
464
|
results: list[DropJumpVideoResult],
|
|
478
|
-
successful: list[DropJumpVideoResult],
|
|
479
465
|
) -> None:
|
|
480
466
|
"""Write CSV summary of batch processing results.
|
|
481
467
|
|
|
482
468
|
Args:
|
|
483
469
|
csv_summary: Path to CSV output file
|
|
484
470
|
results: All processing results
|
|
485
|
-
successful: Successful processing results
|
|
486
471
|
"""
|
|
487
|
-
if not csv_summary
|
|
472
|
+
if not csv_summary:
|
|
488
473
|
return
|
|
489
474
|
|
|
490
475
|
click.echo(f"\nExporting CSV summary to: {csv_summary}", err=True)
|
|
@@ -558,7 +543,6 @@ def _process_batch(
|
|
|
558
543
|
_compute_batch_statistics(results)
|
|
559
544
|
|
|
560
545
|
# Export CSV summary if requested
|
|
561
|
-
|
|
562
|
-
_write_csv_summary(csv_summary, results, successful)
|
|
546
|
+
_write_csv_summary(csv_summary, results)
|
|
563
547
|
|
|
564
548
|
click.echo("\nBatch processing complete!", err=True)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Debug overlay rendering for drop jump analysis."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..core.debug_overlay_utils import BaseDebugOverlayRenderer
|
|
7
|
+
from ..core.overlay_constants import (
|
|
8
|
+
BLACK,
|
|
9
|
+
COM_CIRCLE_RADIUS,
|
|
10
|
+
COM_OUTLINE_RADIUS,
|
|
11
|
+
CYAN,
|
|
12
|
+
FOOT_CIRCLE_RADIUS,
|
|
13
|
+
FOOT_LANDMARK_RADIUS,
|
|
14
|
+
FOOT_VISIBILITY_THRESHOLD,
|
|
15
|
+
GREEN,
|
|
16
|
+
HIP_MARKER_RADIUS,
|
|
17
|
+
METRICS_BOX_WIDTH,
|
|
18
|
+
ORANGE,
|
|
19
|
+
PHASE_LABEL_LINE_HEIGHT,
|
|
20
|
+
PHASE_LABEL_START_Y,
|
|
21
|
+
RED,
|
|
22
|
+
WHITE,
|
|
23
|
+
Color,
|
|
24
|
+
LandmarkDict,
|
|
25
|
+
)
|
|
26
|
+
from ..core.pose import compute_center_of_mass
|
|
27
|
+
from .analysis import ContactState, compute_average_foot_position
|
|
28
|
+
from .kinematics import DropJumpMetrics
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DropJumpDebugOverlayRenderer(BaseDebugOverlayRenderer):
|
|
32
|
+
"""Renders debug information on video frames."""
|
|
33
|
+
|
|
34
|
+
def _get_contact_state_color(self, contact_state: ContactState) -> Color:
|
|
35
|
+
"""Get color based on ground contact state."""
|
|
36
|
+
return GREEN if contact_state == ContactState.ON_GROUND else RED
|
|
37
|
+
|
|
38
|
+
def _draw_com_visualization(
|
|
39
|
+
self,
|
|
40
|
+
frame: np.ndarray,
|
|
41
|
+
landmarks: LandmarkDict,
|
|
42
|
+
contact_state: ContactState,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Draw center of mass visualization on frame."""
|
|
45
|
+
com_x, com_y, _ = compute_center_of_mass(landmarks)
|
|
46
|
+
px, py = self._normalize_to_pixels(com_x, com_y)
|
|
47
|
+
|
|
48
|
+
color = self._get_contact_state_color(contact_state)
|
|
49
|
+
cv2.circle(frame, (px, py), COM_CIRCLE_RADIUS, color, -1)
|
|
50
|
+
cv2.circle(frame, (px, py), COM_OUTLINE_RADIUS, WHITE, 2)
|
|
51
|
+
|
|
52
|
+
# Draw hip midpoint reference
|
|
53
|
+
if "left_hip" in landmarks and "right_hip" in landmarks:
|
|
54
|
+
lh_x, lh_y, _ = landmarks["left_hip"]
|
|
55
|
+
rh_x, rh_y, _ = landmarks["right_hip"]
|
|
56
|
+
hip_x, hip_y = self._normalize_to_pixels((lh_x + rh_x) / 2, (lh_y + rh_y) / 2)
|
|
57
|
+
cv2.circle(frame, (hip_x, hip_y), HIP_MARKER_RADIUS, ORANGE, -1)
|
|
58
|
+
cv2.line(frame, (hip_x, hip_y), (px, py), ORANGE, 2)
|
|
59
|
+
|
|
60
|
+
def _draw_foot_visualization(
|
|
61
|
+
self,
|
|
62
|
+
frame: np.ndarray,
|
|
63
|
+
landmarks: LandmarkDict,
|
|
64
|
+
contact_state: ContactState,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Draw foot position visualization on frame."""
|
|
67
|
+
foot_x, foot_y = compute_average_foot_position(landmarks)
|
|
68
|
+
px, py = self._normalize_to_pixels(foot_x, foot_y)
|
|
69
|
+
|
|
70
|
+
color = self._get_contact_state_color(contact_state)
|
|
71
|
+
cv2.circle(frame, (px, py), FOOT_CIRCLE_RADIUS, color, -1)
|
|
72
|
+
|
|
73
|
+
# Draw individual foot landmarks
|
|
74
|
+
foot_keys = ["left_ankle", "right_ankle", "left_heel", "right_heel"]
|
|
75
|
+
for key in foot_keys:
|
|
76
|
+
if key in landmarks:
|
|
77
|
+
x, y, vis = landmarks[key]
|
|
78
|
+
if vis > FOOT_VISIBILITY_THRESHOLD:
|
|
79
|
+
lx, ly = self._normalize_to_pixels(x, y)
|
|
80
|
+
cv2.circle(frame, (lx, ly), FOOT_LANDMARK_RADIUS, CYAN, -1)
|
|
81
|
+
|
|
82
|
+
def _draw_phase_labels(
|
|
83
|
+
self,
|
|
84
|
+
frame: np.ndarray,
|
|
85
|
+
frame_idx: int,
|
|
86
|
+
metrics: DropJumpMetrics,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Draw phase labels (ground contact, flight, peak) on frame."""
|
|
89
|
+
# Phase configurations: (start_frame, end_frame, label, color)
|
|
90
|
+
# For range-based phases (ground contact, flight)
|
|
91
|
+
range_phase_configs = [
|
|
92
|
+
(metrics.contact_start_frame, metrics.contact_end_frame, "GROUND CONTACT", GREEN),
|
|
93
|
+
(metrics.flight_start_frame, metrics.flight_end_frame, "FLIGHT PHASE", RED),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
y_offset = PHASE_LABEL_START_Y
|
|
97
|
+
for start_frame, end_frame, label, color in range_phase_configs:
|
|
98
|
+
if start_frame and end_frame and start_frame <= frame_idx <= end_frame:
|
|
99
|
+
cv2.putText(
|
|
100
|
+
frame,
|
|
101
|
+
label,
|
|
102
|
+
(10, y_offset),
|
|
103
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
104
|
+
0.7,
|
|
105
|
+
color,
|
|
106
|
+
2,
|
|
107
|
+
)
|
|
108
|
+
y_offset += PHASE_LABEL_LINE_HEIGHT
|
|
109
|
+
|
|
110
|
+
# Single-frame indicator (peak height)
|
|
111
|
+
if metrics.peak_height_frame == frame_idx:
|
|
112
|
+
cv2.putText(
|
|
113
|
+
frame,
|
|
114
|
+
"PEAK HEIGHT",
|
|
115
|
+
(10, y_offset),
|
|
116
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
117
|
+
0.7,
|
|
118
|
+
(255, 0, 255), # Magenta
|
|
119
|
+
2,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _draw_info_box(
|
|
123
|
+
self,
|
|
124
|
+
frame: np.ndarray,
|
|
125
|
+
top_left: tuple[int, int],
|
|
126
|
+
bottom_right: tuple[int, int],
|
|
127
|
+
border_color: Color,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Draw a filled box with border for displaying information."""
|
|
130
|
+
cv2.rectangle(frame, top_left, bottom_right, BLACK, -1)
|
|
131
|
+
cv2.rectangle(frame, top_left, bottom_right, border_color, 2)
|
|
132
|
+
|
|
133
|
+
def _draw_metrics_summary(
|
|
134
|
+
self, frame: np.ndarray, frame_idx: int, metrics: DropJumpMetrics
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Draw metrics summary in bottom right after flight phase ends."""
|
|
137
|
+
if metrics.flight_end_frame is None or frame_idx < metrics.flight_end_frame:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Build metrics text list
|
|
141
|
+
metrics_text: list[str] = []
|
|
142
|
+
|
|
143
|
+
if metrics.ground_contact_time is not None:
|
|
144
|
+
metrics_text.append(f"Contact Time: {metrics.ground_contact_time * 1000:.0f}ms")
|
|
145
|
+
|
|
146
|
+
if metrics.flight_time is not None:
|
|
147
|
+
metrics_text.append(f"Flight Time: {metrics.flight_time * 1000:.0f}ms")
|
|
148
|
+
|
|
149
|
+
if metrics.jump_height is not None:
|
|
150
|
+
metrics_text.append(f"Jump Height: {metrics.jump_height:.3f}m")
|
|
151
|
+
|
|
152
|
+
# Calculate RSI (Reactive Strength Index)
|
|
153
|
+
if (
|
|
154
|
+
metrics.jump_height is not None
|
|
155
|
+
and metrics.ground_contact_time is not None
|
|
156
|
+
and metrics.ground_contact_time > 0
|
|
157
|
+
):
|
|
158
|
+
rsi = metrics.jump_height / metrics.ground_contact_time
|
|
159
|
+
metrics_text.append(f"RSI: {rsi:.2f}")
|
|
160
|
+
|
|
161
|
+
if not metrics_text:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Calculate box dimensions
|
|
165
|
+
box_height = len(metrics_text) * 30 + 20
|
|
166
|
+
top_left = (self.width - METRICS_BOX_WIDTH, self.height - box_height - 10)
|
|
167
|
+
bottom_right = (self.width - 10, self.height - 10)
|
|
168
|
+
|
|
169
|
+
self._draw_info_box(frame, top_left, bottom_right, GREEN)
|
|
170
|
+
|
|
171
|
+
# Draw metrics text
|
|
172
|
+
text_x = self.width - METRICS_BOX_WIDTH + 10
|
|
173
|
+
text_y = self.height - box_height + 10
|
|
174
|
+
for text in metrics_text:
|
|
175
|
+
cv2.putText(frame, text, (text_x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, WHITE, 1)
|
|
176
|
+
text_y += 30
|
|
177
|
+
|
|
178
|
+
def render_frame(
|
|
179
|
+
self,
|
|
180
|
+
frame: np.ndarray,
|
|
181
|
+
landmarks: LandmarkDict | None,
|
|
182
|
+
contact_state: ContactState,
|
|
183
|
+
frame_idx: int,
|
|
184
|
+
metrics: DropJumpMetrics | None = None,
|
|
185
|
+
use_com: bool = False,
|
|
186
|
+
) -> np.ndarray:
|
|
187
|
+
"""
|
|
188
|
+
Render debug overlay on frame.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
frame: Original video frame
|
|
192
|
+
landmarks: Pose landmarks for this frame
|
|
193
|
+
contact_state: Ground contact state
|
|
194
|
+
frame_idx: Current frame index
|
|
195
|
+
metrics: Drop-jump metrics (optional)
|
|
196
|
+
use_com: Whether to visualize CoM instead of feet (optional)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Frame with debug overlay
|
|
200
|
+
"""
|
|
201
|
+
with self.timer.measure("debug_video_copy"):
|
|
202
|
+
annotated = frame.copy()
|
|
203
|
+
|
|
204
|
+
with self.timer.measure("debug_video_draw"):
|
|
205
|
+
# Draw skeleton and landmarks
|
|
206
|
+
if landmarks:
|
|
207
|
+
self._draw_skeleton(annotated, landmarks)
|
|
208
|
+
if use_com:
|
|
209
|
+
self._draw_com_visualization(annotated, landmarks, contact_state)
|
|
210
|
+
else:
|
|
211
|
+
self._draw_foot_visualization(annotated, landmarks, contact_state)
|
|
212
|
+
|
|
213
|
+
# Draw contact state
|
|
214
|
+
state_color = self._get_contact_state_color(contact_state)
|
|
215
|
+
cv2.putText(
|
|
216
|
+
annotated,
|
|
217
|
+
f"State: {contact_state.value}",
|
|
218
|
+
(10, 30),
|
|
219
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
220
|
+
1,
|
|
221
|
+
state_color,
|
|
222
|
+
2,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Draw frame number
|
|
226
|
+
cv2.putText(
|
|
227
|
+
annotated,
|
|
228
|
+
f"Frame: {frame_idx}",
|
|
229
|
+
(10, 70),
|
|
230
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
231
|
+
0.7,
|
|
232
|
+
WHITE,
|
|
233
|
+
2,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Draw phase labels and metrics summary
|
|
237
|
+
if metrics:
|
|
238
|
+
self._draw_phase_labels(annotated, frame_idx, metrics)
|
|
239
|
+
self._draw_metrics_summary(annotated, frame_idx, metrics)
|
|
240
|
+
|
|
241
|
+
return annotated
|