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
|
@@ -6,12 +6,34 @@ import shutil
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
10
9
|
|
|
11
10
|
import cv2
|
|
12
11
|
import numpy as np
|
|
13
12
|
from typing_extensions import Self
|
|
14
13
|
|
|
14
|
+
from .overlay_constants import (
|
|
15
|
+
ANKLE_COLOR,
|
|
16
|
+
BLACK,
|
|
17
|
+
CODECS_TO_TRY,
|
|
18
|
+
CYAN,
|
|
19
|
+
FFMPEG_CRF,
|
|
20
|
+
FFMPEG_PIX_FMT,
|
|
21
|
+
FFMPEG_PRESET,
|
|
22
|
+
HIP_COLOR,
|
|
23
|
+
JOINT_CIRCLE_RADIUS,
|
|
24
|
+
JOINT_OUTLINE_RADIUS,
|
|
25
|
+
KNEE_COLOR,
|
|
26
|
+
MAX_VIDEO_DIMENSION,
|
|
27
|
+
NOSE_CIRCLE_RADIUS,
|
|
28
|
+
NOSE_OUTLINE_RADIUS,
|
|
29
|
+
TRUNK_COLOR,
|
|
30
|
+
VISIBILITY_THRESHOLD,
|
|
31
|
+
WHITE,
|
|
32
|
+
CodecAttemptLog,
|
|
33
|
+
Color,
|
|
34
|
+
Landmark,
|
|
35
|
+
LandmarkDict,
|
|
36
|
+
)
|
|
15
37
|
from .timing import NULL_TIMER, Timer
|
|
16
38
|
|
|
17
39
|
# Setup logging with structlog support for backend, fallback to standard logging for CLI
|
|
@@ -71,12 +93,10 @@ def create_video_writer(
|
|
|
71
93
|
# Try browser-compatible codecs first
|
|
72
94
|
# avc1: H.264 (Most compatible, including iOS)
|
|
73
95
|
# mp4v: MPEG-4 (Poor browser support, will trigger ffmpeg re-encoding for H.264)
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
codecs_to_try = ["avc1", "mp4v"]
|
|
77
|
-
codec_attempt_log: list[dict[str, Any]] = []
|
|
96
|
+
# See overlay_constants.py for codec list and critical VP9 exclusion notes
|
|
97
|
+
codec_attempt_log: CodecAttemptLog = []
|
|
78
98
|
|
|
79
|
-
for codec in
|
|
99
|
+
for codec in CODECS_TO_TRY:
|
|
80
100
|
writer = _try_open_video_writer(
|
|
81
101
|
output_path, codec, fps, display_width, display_height, codec_attempt_log
|
|
82
102
|
)
|
|
@@ -103,7 +123,7 @@ def _try_open_video_writer(
|
|
|
103
123
|
fps: float,
|
|
104
124
|
width: int,
|
|
105
125
|
height: int,
|
|
106
|
-
attempt_log:
|
|
126
|
+
attempt_log: CodecAttemptLog,
|
|
107
127
|
) -> cv2.VideoWriter | None:
|
|
108
128
|
"""Attempt to open a video writer with a specific codec."""
|
|
109
129
|
try:
|
|
@@ -194,9 +214,8 @@ class BaseDebugOverlayRenderer:
|
|
|
194
214
|
# Optimize debug video resolution: Cap max dimension to 720p
|
|
195
215
|
# Reduces software encoding time on single-core Cloud Run instances.
|
|
196
216
|
# while keeping sufficient quality for visual debugging.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
scale = max_dimension / max(display_width, display_height)
|
|
217
|
+
if max(display_width, display_height) > MAX_VIDEO_DIMENSION:
|
|
218
|
+
scale = MAX_VIDEO_DIMENSION / max(display_width, display_height)
|
|
200
219
|
# Ensure dimensions are even for codec compatibility
|
|
201
220
|
self.display_width = int(display_width * scale) // 2 * 2
|
|
202
221
|
self.display_height = int(display_height * scale) // 2 * 2
|
|
@@ -236,6 +255,114 @@ class BaseDebugOverlayRenderer:
|
|
|
236
255
|
output_path, width, height, self.display_width, self.display_height, fps
|
|
237
256
|
)
|
|
238
257
|
|
|
258
|
+
def _normalize_to_pixels(self, x: float, y: float) -> tuple[int, int]:
|
|
259
|
+
"""Convert normalized coordinates (0-1) to pixel coordinates.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
x: Normalized x coordinate (0-1)
|
|
263
|
+
y: Normalized y coordinate (0-1)
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Tuple of (pixel_x, pixel_y)
|
|
267
|
+
"""
|
|
268
|
+
return int(x * self.width), int(y * self.height)
|
|
269
|
+
|
|
270
|
+
def _landmark_to_pixel(self, landmark: Landmark) -> tuple[int, int]:
|
|
271
|
+
"""Convert normalized landmark coordinates to pixel coordinates."""
|
|
272
|
+
return self._normalize_to_pixels(landmark[0], landmark[1])
|
|
273
|
+
|
|
274
|
+
def _is_visible(self, landmark: Landmark, threshold: float = VISIBILITY_THRESHOLD) -> bool:
|
|
275
|
+
"""Check if a landmark has sufficient visibility."""
|
|
276
|
+
return landmark[2] > threshold
|
|
277
|
+
|
|
278
|
+
def _get_skeleton_segments(self, side_prefix: str) -> list[tuple[str, str, Color, int]]:
|
|
279
|
+
"""Get skeleton segments for one side of the body.
|
|
280
|
+
|
|
281
|
+
Returns list of (start_key, end_key, color, thickness) tuples.
|
|
282
|
+
"""
|
|
283
|
+
p = side_prefix # Shorter alias for readability
|
|
284
|
+
return [
|
|
285
|
+
(f"{p}heel", f"{p}ankle", ANKLE_COLOR, 3), # Foot
|
|
286
|
+
(f"{p}heel", f"{p}foot_index", ANKLE_COLOR, 2), # Alt foot
|
|
287
|
+
(f"{p}ankle", f"{p}knee", KNEE_COLOR, 4), # Shin
|
|
288
|
+
(f"{p}knee", f"{p}hip", HIP_COLOR, 4), # Femur
|
|
289
|
+
(f"{p}hip", f"{p}shoulder", TRUNK_COLOR, 4), # Trunk
|
|
290
|
+
(f"{p}shoulder", "nose", (150, 150, 255), 2), # Neck
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
def _draw_segment(
|
|
294
|
+
self,
|
|
295
|
+
frame: np.ndarray,
|
|
296
|
+
landmarks: LandmarkDict,
|
|
297
|
+
start_key: str,
|
|
298
|
+
end_key: str,
|
|
299
|
+
color: Color,
|
|
300
|
+
thickness: int,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Draw a single skeleton segment if both endpoints are visible."""
|
|
303
|
+
if start_key not in landmarks or end_key not in landmarks:
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
start_landmark = landmarks[start_key]
|
|
307
|
+
end_landmark = landmarks[end_key]
|
|
308
|
+
|
|
309
|
+
if not (self._is_visible(start_landmark) and self._is_visible(end_landmark)):
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
start_pt = self._landmark_to_pixel(start_landmark)
|
|
313
|
+
end_pt = self._landmark_to_pixel(end_landmark)
|
|
314
|
+
cv2.line(frame, start_pt, end_pt, color, thickness)
|
|
315
|
+
|
|
316
|
+
def _draw_joints(
|
|
317
|
+
self,
|
|
318
|
+
frame: np.ndarray,
|
|
319
|
+
landmarks: LandmarkDict,
|
|
320
|
+
side_prefix: str,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Draw joint circles for one side of the body."""
|
|
323
|
+
p = side_prefix
|
|
324
|
+
joint_keys = [
|
|
325
|
+
f"{p}heel",
|
|
326
|
+
f"{p}foot_index",
|
|
327
|
+
f"{p}ankle",
|
|
328
|
+
f"{p}knee",
|
|
329
|
+
f"{p}hip",
|
|
330
|
+
f"{p}shoulder",
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
for key in joint_keys:
|
|
334
|
+
if key not in landmarks:
|
|
335
|
+
continue
|
|
336
|
+
landmark = landmarks[key]
|
|
337
|
+
if not self._is_visible(landmark):
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
point = self._landmark_to_pixel(landmark)
|
|
341
|
+
cv2.circle(frame, point, JOINT_CIRCLE_RADIUS, WHITE, -1)
|
|
342
|
+
cv2.circle(frame, point, JOINT_OUTLINE_RADIUS, BLACK, 2)
|
|
343
|
+
|
|
344
|
+
def _draw_skeleton(self, frame: np.ndarray, landmarks: LandmarkDict) -> None:
|
|
345
|
+
"""Draw skeleton segments showing body landmarks.
|
|
346
|
+
|
|
347
|
+
Draws whatever landmarks are visible. In side-view videos, ankle/knee
|
|
348
|
+
may have low visibility, so we draw available segments.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
frame: Frame to draw on (modified in place)
|
|
352
|
+
landmarks: Pose landmarks
|
|
353
|
+
"""
|
|
354
|
+
# Draw segments and joints for both sides
|
|
355
|
+
for side_prefix in ["right_", "left_"]:
|
|
356
|
+
for start_key, end_key, color, thickness in self._get_skeleton_segments(side_prefix):
|
|
357
|
+
self._draw_segment(frame, landmarks, start_key, end_key, color, thickness)
|
|
358
|
+
self._draw_joints(frame, landmarks, side_prefix)
|
|
359
|
+
|
|
360
|
+
# Draw nose (head position) if visible
|
|
361
|
+
if "nose" in landmarks and self._is_visible(landmarks["nose"]):
|
|
362
|
+
point = self._landmark_to_pixel(landmarks["nose"])
|
|
363
|
+
cv2.circle(frame, point, NOSE_CIRCLE_RADIUS, CYAN, -1)
|
|
364
|
+
cv2.circle(frame, point, NOSE_OUTLINE_RADIUS, BLACK, 2)
|
|
365
|
+
|
|
239
366
|
def write_frame(self, frame: np.ndarray) -> None:
|
|
240
367
|
"""
|
|
241
368
|
Write frame to output video.
|
|
@@ -320,11 +447,11 @@ class BaseDebugOverlayRenderer:
|
|
|
320
447
|
"-vcodec",
|
|
321
448
|
"libx264",
|
|
322
449
|
"-pix_fmt",
|
|
323
|
-
|
|
450
|
+
FFMPEG_PIX_FMT,
|
|
324
451
|
"-preset",
|
|
325
|
-
|
|
452
|
+
FFMPEG_PRESET,
|
|
326
453
|
"-crf",
|
|
327
|
-
|
|
454
|
+
FFMPEG_CRF,
|
|
328
455
|
"-an",
|
|
329
456
|
temp_path,
|
|
330
457
|
]
|
|
@@ -335,7 +462,7 @@ class BaseDebugOverlayRenderer:
|
|
|
335
462
|
input_file=self.output_path,
|
|
336
463
|
output_file=temp_path,
|
|
337
464
|
output_codec="libx264",
|
|
338
|
-
pixel_format=
|
|
465
|
+
pixel_format=FFMPEG_PIX_FMT,
|
|
339
466
|
reason="iOS_compatibility",
|
|
340
467
|
)
|
|
341
468
|
|
|
@@ -356,7 +483,7 @@ class BaseDebugOverlayRenderer:
|
|
|
356
483
|
"debug_video_reencoded_file_replaced",
|
|
357
484
|
output_path=self.output_path,
|
|
358
485
|
final_codec="libx264",
|
|
359
|
-
pixel_format=
|
|
486
|
+
pixel_format=FFMPEG_PIX_FMT,
|
|
360
487
|
)
|
|
361
488
|
except Exception as e:
|
|
362
489
|
self._handle_reencode_error(e, temp_path)
|
kinemotion/core/experimental.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Decorator for marking unused features.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This decorator helps identify code that is implemented but not yet
|
|
4
4
|
integrated into the main pipeline, making it easier to track features
|
|
5
5
|
for future enhancement or cleanup.
|
|
6
6
|
"""
|
|
@@ -13,61 +13,14 @@ from typing import TypeVar
|
|
|
13
13
|
F = TypeVar("F", bound=Callable)
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def experimental(
|
|
17
|
-
reason: str, *, issue: int | None = None, since: str | None = None
|
|
18
|
-
) -> Callable[[F], F]:
|
|
19
|
-
"""Mark a feature as experimental/not fully integrated.
|
|
20
|
-
|
|
21
|
-
Experimental features are working implementations that haven't been
|
|
22
|
-
fully integrated into the main pipeline. They emit warnings when called
|
|
23
|
-
to alert developers they're using untested/unstable APIs.
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
reason: Why this is experimental (e.g., "API unstable", "needs validation")
|
|
27
|
-
issue: Optional GitHub issue number for tracking integration
|
|
28
|
-
since: Optional version when this became experimental
|
|
29
|
-
|
|
30
|
-
Example:
|
|
31
|
-
>>> @experimental("API may change", issue=123, since="0.34.0")
|
|
32
|
-
... def new_feature():
|
|
33
|
-
... pass
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
Decorated function that warns on use
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
def decorator(func: F) -> F:
|
|
40
|
-
@functools.wraps(func)
|
|
41
|
-
def wrapper(*args, **kwargs): # type: ignore
|
|
42
|
-
msg = f"{func.__name__} is experimental: {reason}"
|
|
43
|
-
if issue:
|
|
44
|
-
msg += f" (GitHub issue #{issue})"
|
|
45
|
-
if since:
|
|
46
|
-
msg += f" [since v{since}]"
|
|
47
|
-
warnings.warn(msg, FutureWarning, stacklevel=2)
|
|
48
|
-
return func(*args, **kwargs)
|
|
49
|
-
|
|
50
|
-
# Add metadata for documentation/tooling
|
|
51
|
-
wrapper.__experimental__ = True # type: ignore[attr-defined]
|
|
52
|
-
wrapper.__experimental_reason__ = reason # type: ignore[attr-defined]
|
|
53
|
-
if issue:
|
|
54
|
-
wrapper.__experimental_issue__ = issue # type: ignore[attr-defined]
|
|
55
|
-
if since:
|
|
56
|
-
wrapper.__experimental_since__ = since # type: ignore[attr-defined]
|
|
57
|
-
|
|
58
|
-
return wrapper # type: ignore[return-value]
|
|
59
|
-
|
|
60
|
-
return decorator
|
|
61
|
-
|
|
62
|
-
|
|
63
16
|
def unused(
|
|
64
17
|
reason: str, *, remove_in: str | None = None, since: str | None = None
|
|
65
18
|
) -> Callable[[F], F]:
|
|
66
19
|
"""Mark a feature as implemented but not integrated into pipeline.
|
|
67
20
|
|
|
68
21
|
Unused features are fully working implementations that aren't called
|
|
69
|
-
by the main analysis pipeline.
|
|
70
|
-
|
|
22
|
+
by the main analysis pipeline. These don't emit warnings when called
|
|
23
|
+
(they work fine), but are marked for tracking.
|
|
71
24
|
|
|
72
25
|
Use this for:
|
|
73
26
|
- Features awaiting CLI integration
|
|
@@ -101,3 +54,54 @@ def unused(
|
|
|
101
54
|
return func
|
|
102
55
|
|
|
103
56
|
return decorator
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def experimental(
|
|
60
|
+
reason: str, *, issue: int | None = None, since: str | None = None
|
|
61
|
+
) -> Callable[[F], F]:
|
|
62
|
+
"""Mark a feature as experimental/not fully integrated.
|
|
63
|
+
|
|
64
|
+
Experimental features are working implementations that may change
|
|
65
|
+
or be removed. They emit a warning when called to alert users.
|
|
66
|
+
|
|
67
|
+
Use this for:
|
|
68
|
+
- Features under active development
|
|
69
|
+
- APIs that may change
|
|
70
|
+
- Functionality that needs more testing
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
reason: Why this is experimental (e.g., "API may change")
|
|
74
|
+
issue: Optional GitHub issue number tracking this feature
|
|
75
|
+
since: Optional version when this became experimental
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> @experimental("API may change", issue=42, since="0.35.0")
|
|
79
|
+
... def new_analysis_method():
|
|
80
|
+
... pass
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Wrapped function that emits ExperimentalWarning when called
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def decorator(func: F) -> F:
|
|
87
|
+
@functools.wraps(func)
|
|
88
|
+
def wrapper(*args, **kwargs): # type: ignore[no-untyped-def]
|
|
89
|
+
issue_ref = f" (see issue #{issue})" if issue else ""
|
|
90
|
+
version_ref = f" since {since}" if since else ""
|
|
91
|
+
warnings.warn(
|
|
92
|
+
f"{func.__name__} is experimental{version_ref}: {reason}{issue_ref}",
|
|
93
|
+
category=FutureWarning,
|
|
94
|
+
stacklevel=2,
|
|
95
|
+
)
|
|
96
|
+
return func(*args, **kwargs)
|
|
97
|
+
|
|
98
|
+
wrapper.__experimental__ = True # type: ignore[attr-defined]
|
|
99
|
+
wrapper.__experimental_reason__ = reason # type: ignore[attr-defined]
|
|
100
|
+
if issue:
|
|
101
|
+
wrapper.__experimental_issue__ = issue # type: ignore[attr-defined]
|
|
102
|
+
if since:
|
|
103
|
+
wrapper.__experimental_since__ = since # type: ignore[attr-defined]
|
|
104
|
+
|
|
105
|
+
return wrapper # type: ignore[return-value]
|
|
106
|
+
|
|
107
|
+
return decorator
|
kinemotion/core/filtering.py
CHANGED
|
@@ -6,6 +6,18 @@ from scipy.signal import medfilt
|
|
|
6
6
|
from .experimental import unused
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def _ensure_odd_window_length(window_length: int) -> int:
|
|
10
|
+
"""Ensure window_length is odd (required for Savitzky-Golay filter).
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
window_length: Desired window length
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Odd window length (increments by 1 if even)
|
|
17
|
+
"""
|
|
18
|
+
return window_length + 1 if window_length % 2 == 0 else window_length
|
|
19
|
+
|
|
20
|
+
|
|
9
21
|
def detect_outliers_ransac(
|
|
10
22
|
positions: np.ndarray,
|
|
11
23
|
window_size: int = 15,
|
|
@@ -34,10 +46,7 @@ def detect_outliers_ransac(
|
|
|
34
46
|
if n < window_size:
|
|
35
47
|
return is_outlier
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
if window_size % 2 == 0:
|
|
39
|
-
window_size += 1
|
|
40
|
-
|
|
49
|
+
window_size = _ensure_odd_window_length(window_size)
|
|
41
50
|
half_window = window_size // 2
|
|
42
51
|
|
|
43
52
|
for i in range(n):
|
|
@@ -93,9 +102,7 @@ def detect_outliers_median(
|
|
|
93
102
|
if len(positions) < window_size:
|
|
94
103
|
return np.zeros(len(positions), dtype=bool)
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
if window_size % 2 == 0:
|
|
98
|
-
window_size += 1
|
|
105
|
+
window_size = _ensure_odd_window_length(window_size)
|
|
99
106
|
|
|
100
107
|
# Apply median filter
|
|
101
108
|
median_filtered = medfilt(positions, kernel_size=window_size)
|
|
@@ -315,10 +322,7 @@ def bilateral_temporal_filter(
|
|
|
315
322
|
n = len(positions)
|
|
316
323
|
filtered = np.zeros(n)
|
|
317
324
|
|
|
318
|
-
|
|
319
|
-
if window_size % 2 == 0:
|
|
320
|
-
window_size += 1
|
|
321
|
-
|
|
325
|
+
window_size = _ensure_odd_window_length(window_size)
|
|
322
326
|
half_window = window_size // 2
|
|
323
327
|
|
|
324
328
|
for i in range(n):
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared constants and type aliases for overlay renderers."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# Type aliases for overlay rendering
|
|
6
|
+
Color = tuple[int, int, int]
|
|
7
|
+
Landmark = tuple[float, float, float]
|
|
8
|
+
LandmarkDict = dict[str, Landmark]
|
|
9
|
+
CodecAttemptLog = list[dict[str, Any]]
|
|
10
|
+
|
|
11
|
+
# Visibility thresholds
|
|
12
|
+
VISIBILITY_THRESHOLD = 0.2
|
|
13
|
+
VISIBILITY_THRESHOLD_HIGH = 0.3
|
|
14
|
+
FOOT_VISIBILITY_THRESHOLD = 0.5
|
|
15
|
+
|
|
16
|
+
# Video encoding constants
|
|
17
|
+
MAX_VIDEO_DIMENSION = 720
|
|
18
|
+
CODECS_TO_TRY = ["avc1", "mp4v"]
|
|
19
|
+
FFMPEG_PRESET = "fast"
|
|
20
|
+
FFMPEG_CRF = "23"
|
|
21
|
+
FFMPEG_PIX_FMT = "yuv420p"
|
|
22
|
+
|
|
23
|
+
# Common colors (BGR format for OpenCV)
|
|
24
|
+
GREEN = (0, 255, 0)
|
|
25
|
+
RED = (0, 0, 255)
|
|
26
|
+
WHITE = (255, 255, 255)
|
|
27
|
+
BLACK = (0, 0, 0)
|
|
28
|
+
GRAY = (128, 128, 128)
|
|
29
|
+
CYAN = (255, 255, 0)
|
|
30
|
+
ORANGE = (0, 165, 255)
|
|
31
|
+
|
|
32
|
+
# Joint colors for triple extension
|
|
33
|
+
ANKLE_COLOR = (0, 255, 255) # Cyan
|
|
34
|
+
KNEE_COLOR = (255, 100, 100) # Light blue
|
|
35
|
+
HIP_COLOR = (100, 255, 100) # Light green
|
|
36
|
+
TRUNK_COLOR = (100, 100, 255) # Light red
|
|
37
|
+
|
|
38
|
+
# Angle thresholds
|
|
39
|
+
FULL_EXTENSION_ANGLE = 160
|
|
40
|
+
DEEP_FLEXION_ANGLE = 90
|
|
41
|
+
|
|
42
|
+
# Circle sizes
|
|
43
|
+
JOINT_CIRCLE_RADIUS = 6
|
|
44
|
+
JOINT_OUTLINE_RADIUS = 8
|
|
45
|
+
COM_CIRCLE_RADIUS = 15
|
|
46
|
+
COM_OUTLINE_RADIUS = 17
|
|
47
|
+
HIP_MARKER_RADIUS = 8
|
|
48
|
+
FOOT_CIRCLE_RADIUS = 10
|
|
49
|
+
FOOT_LANDMARK_RADIUS = 5
|
|
50
|
+
ANGLE_ARC_RADIUS = 25
|
|
51
|
+
NOSE_CIRCLE_RADIUS = 8
|
|
52
|
+
NOSE_OUTLINE_RADIUS = 10
|
|
53
|
+
|
|
54
|
+
# Box positioning
|
|
55
|
+
JOINT_ANGLES_BOX_X_OFFSET = 180
|
|
56
|
+
JOINT_ANGLES_BOX_HEIGHT = 150
|
|
57
|
+
METRICS_BOX_WIDTH = 320
|
|
58
|
+
|
|
59
|
+
# Phase label positioning
|
|
60
|
+
PHASE_LABEL_START_Y = 110
|
|
61
|
+
PHASE_LABEL_LINE_HEIGHT = 40
|
|
@@ -9,7 +9,7 @@ import cv2
|
|
|
9
9
|
import numpy as np
|
|
10
10
|
|
|
11
11
|
from ..cmj.analysis import compute_average_hip_position
|
|
12
|
-
from ..
|
|
12
|
+
from ..dj.analysis import compute_average_foot_position
|
|
13
13
|
from .auto_tuning import AnalysisParameters, QualityPreset, VideoCharacteristics
|
|
14
14
|
from .pose import MediaPipePoseTracker
|
|
15
15
|
from .smoothing import smooth_landmarks, smooth_landmarks_advanced
|
kinemotion/core/pose.py
CHANGED
|
@@ -198,55 +198,6 @@ class PoseTrackerFactory:
|
|
|
198
198
|
|
|
199
199
|
return MediaPipePoseTracker(**filtered_kwargs)
|
|
200
200
|
|
|
201
|
-
@classmethod
|
|
202
|
-
def get_available_backends(cls) -> list[str]:
|
|
203
|
-
"""Get list of available backends.
|
|
204
|
-
|
|
205
|
-
Returns:
|
|
206
|
-
List containing 'mediapipe'
|
|
207
|
-
"""
|
|
208
|
-
return ["mediapipe"]
|
|
209
|
-
|
|
210
|
-
@classmethod
|
|
211
|
-
def get_backend_info(cls, backend: str) -> dict[str, str]:
|
|
212
|
-
"""Get information about a backend.
|
|
213
|
-
|
|
214
|
-
Args:
|
|
215
|
-
backend: Backend name
|
|
216
|
-
|
|
217
|
-
Returns:
|
|
218
|
-
Dictionary with backend information
|
|
219
|
-
"""
|
|
220
|
-
if backend.lower() in ("mediapipe", "mp"):
|
|
221
|
-
return {
|
|
222
|
-
"name": "MediaPipe",
|
|
223
|
-
"description": "Pose tracking using MediaPipe Tasks API",
|
|
224
|
-
"performance": "~48 FPS",
|
|
225
|
-
"accuracy": "Reference (validated for jumps)",
|
|
226
|
-
"requirements": "mediapipe package",
|
|
227
|
-
}
|
|
228
|
-
return {}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def get_tracker_info(tracker: object) -> str:
|
|
232
|
-
"""Get detailed information about a pose tracker instance.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
tracker: Pose tracker instance
|
|
236
|
-
|
|
237
|
-
Returns:
|
|
238
|
-
Formatted string with tracker details
|
|
239
|
-
"""
|
|
240
|
-
tracker_class = type(tracker).__name__
|
|
241
|
-
module = type(tracker).__module__
|
|
242
|
-
|
|
243
|
-
info = f"{tracker_class} (from {module})"
|
|
244
|
-
|
|
245
|
-
if tracker_class == "MediaPipePoseTracker":
|
|
246
|
-
info += " [MediaPipe Tasks API]"
|
|
247
|
-
|
|
248
|
-
return info
|
|
249
|
-
|
|
250
201
|
|
|
251
202
|
def _extract_landmarks_from_results(
|
|
252
203
|
pose_landmarks: mp.tasks.vision.components.containers.NormalizedLandmark, # type: ignore[valid-type]
|
|
@@ -273,28 +224,6 @@ def _extract_landmarks_from_results(
|
|
|
273
224
|
return landmarks
|
|
274
225
|
|
|
275
226
|
|
|
276
|
-
# Legacy compatibility aliases for Solution API enum values
|
|
277
|
-
class _LegacyPoseLandmark:
|
|
278
|
-
"""Compatibility shim for Solution API enum values."""
|
|
279
|
-
|
|
280
|
-
LEFT_ANKLE = 27
|
|
281
|
-
RIGHT_ANKLE = 28
|
|
282
|
-
LEFT_HEEL = 29
|
|
283
|
-
RIGHT_HEEL = 30
|
|
284
|
-
LEFT_FOOT_INDEX = 31
|
|
285
|
-
RIGHT_FOOT_INDEX = 32
|
|
286
|
-
LEFT_HIP = 23
|
|
287
|
-
RIGHT_HIP = 24
|
|
288
|
-
LEFT_SHOULDER = 11
|
|
289
|
-
RIGHT_SHOULDER = 12
|
|
290
|
-
NOSE = 0
|
|
291
|
-
LEFT_KNEE = 25
|
|
292
|
-
RIGHT_KNEE = 26
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
PoseLandmark = _LegacyPoseLandmark
|
|
296
|
-
|
|
297
|
-
|
|
298
227
|
def compute_center_of_mass(
|
|
299
228
|
landmarks: dict[str, tuple[float, float, float]],
|
|
300
229
|
visibility_threshold: float = 0.5,
|
|
@@ -373,6 +302,37 @@ def compute_center_of_mass(
|
|
|
373
302
|
return (com_x, com_y, com_visibility)
|
|
374
303
|
|
|
375
304
|
|
|
305
|
+
def _compute_mean_landmark_position(
|
|
306
|
+
landmark_keys: list[str],
|
|
307
|
+
landmarks: dict[str, tuple[float, float, float]],
|
|
308
|
+
vis_threshold: float,
|
|
309
|
+
) -> tuple[float, float, float] | None:
|
|
310
|
+
"""Compute mean position and visibility from multiple landmarks.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
landmark_keys: List of landmark key names to average
|
|
314
|
+
landmarks: Dictionary of landmark positions
|
|
315
|
+
vis_threshold: Minimum visibility threshold
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
(x, y, visibility) tuple if any landmarks are visible, else None
|
|
319
|
+
"""
|
|
320
|
+
positions = [
|
|
321
|
+
(x, y, vis)
|
|
322
|
+
for key in landmark_keys
|
|
323
|
+
if key in landmarks
|
|
324
|
+
for x, y, vis in [landmarks[key]]
|
|
325
|
+
if vis > vis_threshold
|
|
326
|
+
]
|
|
327
|
+
if not positions:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
x = float(np.mean([p[0] for p in positions]))
|
|
331
|
+
y = float(np.mean([p[1] for p in positions]))
|
|
332
|
+
vis = float(np.mean([p[2] for p in positions]))
|
|
333
|
+
return (x, y, vis)
|
|
334
|
+
|
|
335
|
+
|
|
376
336
|
def _add_head_segment(
|
|
377
337
|
segments: list,
|
|
378
338
|
weights: list,
|
|
@@ -398,20 +358,17 @@ def _add_trunk_segment(
|
|
|
398
358
|
) -> None:
|
|
399
359
|
"""Add trunk segment (50% body mass) if visible."""
|
|
400
360
|
trunk_keys = ["left_shoulder", "right_shoulder", "left_hip", "right_hip"]
|
|
401
|
-
trunk_pos =
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
segments.append((trunk_x, trunk_y))
|
|
413
|
-
weights.append(0.50)
|
|
414
|
-
visibilities.append(trunk_vis)
|
|
361
|
+
trunk_pos = _compute_mean_landmark_position(trunk_keys, landmarks, vis_threshold)
|
|
362
|
+
|
|
363
|
+
if trunk_pos is not None:
|
|
364
|
+
# Require at least 2 visible landmarks for valid trunk
|
|
365
|
+
visible_count = sum(
|
|
366
|
+
1 for key in trunk_keys if key in landmarks and landmarks[key][2] > vis_threshold
|
|
367
|
+
)
|
|
368
|
+
if visible_count >= 2:
|
|
369
|
+
segments.append((trunk_pos[0], trunk_pos[1]))
|
|
370
|
+
weights.append(0.50)
|
|
371
|
+
visibilities.append(trunk_pos[2])
|
|
415
372
|
|
|
416
373
|
|
|
417
374
|
def _add_limb_segment(
|
|
@@ -451,17 +408,9 @@ def _add_foot_segment(
|
|
|
451
408
|
) -> None:
|
|
452
409
|
"""Add foot segment (1.5% body mass per foot) if visible."""
|
|
453
410
|
foot_keys = [f"{side}_ankle", f"{side}_heel", f"{side}_foot_index"]
|
|
454
|
-
foot_pos =
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
for x, y, vis in [landmarks[key]]
|
|
459
|
-
if vis > vis_threshold
|
|
460
|
-
]
|
|
461
|
-
if foot_pos:
|
|
462
|
-
foot_x = float(np.mean([p[0] for p in foot_pos]))
|
|
463
|
-
foot_y = float(np.mean([p[1] for p in foot_pos]))
|
|
464
|
-
foot_vis = float(np.mean([p[2] for p in foot_pos]))
|
|
465
|
-
segments.append((foot_x, foot_y))
|
|
411
|
+
foot_pos = _compute_mean_landmark_position(foot_keys, landmarks, vis_threshold)
|
|
412
|
+
|
|
413
|
+
if foot_pos is not None:
|
|
414
|
+
segments.append((foot_pos[0], foot_pos[1]))
|
|
466
415
|
weights.append(0.015)
|
|
467
|
-
visibilities.append(
|
|
416
|
+
visibilities.append(foot_pos[2])
|