kinemotion 0.70.1__py3-none-any.whl → 0.71.1__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.
Files changed (35) hide show
  1. kinemotion/__init__.py +4 -1
  2. kinemotion/cmj/analysis.py +79 -30
  3. kinemotion/cmj/api.py +16 -39
  4. kinemotion/cmj/cli.py +0 -21
  5. kinemotion/cmj/debug_overlay.py +154 -286
  6. kinemotion/cmj/joint_angles.py +96 -31
  7. kinemotion/cmj/metrics_validator.py +30 -51
  8. kinemotion/cmj/validation_bounds.py +1 -18
  9. kinemotion/core/__init__.py +0 -2
  10. kinemotion/core/auto_tuning.py +91 -99
  11. kinemotion/core/debug_overlay_utils.py +142 -15
  12. kinemotion/core/experimental.py +55 -51
  13. kinemotion/core/filtering.py +15 -11
  14. kinemotion/core/overlay_constants.py +61 -0
  15. kinemotion/core/pose.py +67 -499
  16. kinemotion/core/smoothing.py +65 -51
  17. kinemotion/core/types.py +15 -0
  18. kinemotion/core/validation.py +6 -7
  19. kinemotion/core/video_io.py +14 -9
  20. kinemotion/dropjump/__init__.py +2 -2
  21. kinemotion/dropjump/analysis.py +67 -44
  22. kinemotion/dropjump/api.py +12 -44
  23. kinemotion/dropjump/cli.py +63 -105
  24. kinemotion/dropjump/debug_overlay.py +124 -65
  25. kinemotion/dropjump/validation_bounds.py +1 -1
  26. kinemotion/models/rtmpose-s_simcc-body7_pt-body7-halpe26_700e-256x192-7f134165_20230605.onnx +0 -0
  27. kinemotion/models/yolox_tiny_8xb8-300e_humanart-6f3252f9.onnx +0 -0
  28. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/METADATA +1 -5
  29. kinemotion-0.71.1.dist-info/RECORD +50 -0
  30. kinemotion/core/rtmpose_cpu.py +0 -626
  31. kinemotion/core/rtmpose_wrapper.py +0 -190
  32. kinemotion-0.70.1.dist-info/RECORD +0 -51
  33. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/WHEEL +0 -0
  34. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.dist-info}/entry_points.txt +0 -0
  35. {kinemotion-0.70.1.dist-info → kinemotion-0.71.1.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
- # ⚠️ CRITICAL: VP9 (vp09) is EXCLUDED - not supported on iOS/iPhone/iPad browsers!
75
- # Adding VP9 will break debug video playback on all iOS devices.
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 codecs_to_try:
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: list[dict[str, Any]],
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
- max_dimension = 720
198
- if max(display_width, display_height) > max_dimension:
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
- "yuv420p",
450
+ FFMPEG_PIX_FMT,
324
451
  "-preset",
325
- "fast",
452
+ FFMPEG_PRESET,
326
453
  "-crf",
327
- "23",
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="yuv420p",
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="yuv420p",
486
+ pixel_format=FFMPEG_PIX_FMT,
360
487
  )
361
488
  except Exception as e:
362
489
  self._handle_reencode_error(e, temp_path)
@@ -1,6 +1,6 @@
1
- """Decorators for marking experimental and unused features.
1
+ """Decorator for marking unused features.
2
2
 
3
- These decorators help identify code that is implemented but not yet
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. Unlike @experimental, these don't emit
70
- warnings when called (they work fine), but are marked for tracking.
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
@@ -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
- # Ensure window size is odd
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
- # Ensure window size is odd
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
- # Ensure window size is odd
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