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.

Files changed (36) hide show
  1. kinemotion/__init__.py +1 -1
  2. kinemotion/api.py +2 -2
  3. kinemotion/cli.py +1 -1
  4. kinemotion/cmj/analysis.py +2 -4
  5. kinemotion/cmj/api.py +9 -7
  6. kinemotion/cmj/debug_overlay.py +154 -286
  7. kinemotion/cmj/joint_angles.py +96 -31
  8. kinemotion/cmj/metrics_validator.py +22 -29
  9. kinemotion/cmj/validation_bounds.py +1 -18
  10. kinemotion/core/__init__.py +0 -2
  11. kinemotion/core/auto_tuning.py +95 -100
  12. kinemotion/core/debug_overlay_utils.py +142 -15
  13. kinemotion/core/experimental.py +55 -51
  14. kinemotion/core/filtering.py +15 -11
  15. kinemotion/core/overlay_constants.py +61 -0
  16. kinemotion/core/pipeline_utils.py +1 -1
  17. kinemotion/core/pose.py +47 -98
  18. kinemotion/core/smoothing.py +65 -51
  19. kinemotion/core/types.py +15 -0
  20. kinemotion/core/validation.py +6 -7
  21. kinemotion/core/video_io.py +14 -9
  22. kinemotion/{dropjump → dj}/__init__.py +2 -2
  23. kinemotion/{dropjump → dj}/analysis.py +192 -75
  24. kinemotion/{dropjump → dj}/api.py +13 -17
  25. kinemotion/{dropjump → dj}/cli.py +62 -78
  26. kinemotion/dj/debug_overlay.py +241 -0
  27. kinemotion/{dropjump → dj}/kinematics.py +106 -44
  28. kinemotion/{dropjump → dj}/metrics_validator.py +1 -1
  29. kinemotion/{dropjump → dj}/validation_bounds.py +1 -1
  30. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/METADATA +1 -1
  31. kinemotion-0.72.0.dist-info/RECORD +50 -0
  32. kinemotion/dropjump/debug_overlay.py +0 -182
  33. kinemotion-0.71.0.dist-info/RECORD +0 -49
  34. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/WHEEL +0 -0
  35. {kinemotion-0.71.0.dist-info → kinemotion-0.72.0.dist-info}/entry_points.txt +0 -0
  36. {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
- # ⚠️ 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
@@ -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 ..dropjump.analysis import compute_average_foot_position
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
- (x, y, vis)
403
- for key in trunk_keys
404
- if key in landmarks
405
- for x, y, vis in [landmarks[key]]
406
- if vis > vis_threshold
407
- ]
408
- if len(trunk_pos) >= 2:
409
- trunk_x = float(np.mean([p[0] for p in trunk_pos]))
410
- trunk_y = float(np.mean([p[1] for p in trunk_pos]))
411
- trunk_vis = float(np.mean([p[2] for p in trunk_pos]))
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
- (x, y, vis)
456
- for key in foot_keys
457
- if key in landmarks
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(foot_vis)
416
+ visibilities.append(foot_pos[2])