kinemotion 0.10.6__py3-none-any.whl → 0.67.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 (48) hide show
  1. kinemotion/__init__.py +31 -6
  2. kinemotion/api.py +39 -598
  3. kinemotion/cli.py +2 -0
  4. kinemotion/cmj/__init__.py +5 -0
  5. kinemotion/cmj/analysis.py +621 -0
  6. kinemotion/cmj/api.py +563 -0
  7. kinemotion/cmj/cli.py +324 -0
  8. kinemotion/cmj/debug_overlay.py +457 -0
  9. kinemotion/cmj/joint_angles.py +307 -0
  10. kinemotion/cmj/kinematics.py +360 -0
  11. kinemotion/cmj/metrics_validator.py +767 -0
  12. kinemotion/cmj/validation_bounds.py +341 -0
  13. kinemotion/core/__init__.py +28 -0
  14. kinemotion/core/auto_tuning.py +71 -37
  15. kinemotion/core/cli_utils.py +60 -0
  16. kinemotion/core/debug_overlay_utils.py +385 -0
  17. kinemotion/core/determinism.py +83 -0
  18. kinemotion/core/experimental.py +103 -0
  19. kinemotion/core/filtering.py +9 -6
  20. kinemotion/core/formatting.py +75 -0
  21. kinemotion/core/metadata.py +231 -0
  22. kinemotion/core/model_downloader.py +172 -0
  23. kinemotion/core/pipeline_utils.py +433 -0
  24. kinemotion/core/pose.py +298 -141
  25. kinemotion/core/pose_landmarks.py +67 -0
  26. kinemotion/core/quality.py +393 -0
  27. kinemotion/core/smoothing.py +250 -154
  28. kinemotion/core/timing.py +247 -0
  29. kinemotion/core/types.py +42 -0
  30. kinemotion/core/validation.py +201 -0
  31. kinemotion/core/video_io.py +135 -50
  32. kinemotion/dropjump/__init__.py +1 -1
  33. kinemotion/dropjump/analysis.py +367 -182
  34. kinemotion/dropjump/api.py +665 -0
  35. kinemotion/dropjump/cli.py +156 -466
  36. kinemotion/dropjump/debug_overlay.py +136 -206
  37. kinemotion/dropjump/kinematics.py +232 -255
  38. kinemotion/dropjump/metrics_validator.py +240 -0
  39. kinemotion/dropjump/validation_bounds.py +157 -0
  40. kinemotion/models/__init__.py +0 -0
  41. kinemotion/models/pose_landmarker_lite.task +0 -0
  42. kinemotion-0.67.0.dist-info/METADATA +726 -0
  43. kinemotion-0.67.0.dist-info/RECORD +47 -0
  44. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
  45. kinemotion-0.10.6.dist-info/METADATA +0 -561
  46. kinemotion-0.10.6.dist-info/RECORD +0 -20
  47. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
  48. {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,385 @@
1
+ """Shared debug overlay utilities for video rendering."""
2
+
3
+ # pyright: reportCallIssue=false
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import cv2
12
+ import numpy as np
13
+ from typing_extensions import Self
14
+
15
+ from .timing import NULL_TIMER, Timer
16
+
17
+ # Setup logging with structlog support for backend, fallback to standard logging for CLI
18
+ try:
19
+ import structlog
20
+
21
+ logger = structlog.get_logger(__name__)
22
+ _using_structlog = True
23
+ except ImportError:
24
+ import logging
25
+
26
+ logger = logging.getLogger(__name__)
27
+ _using_structlog = False
28
+
29
+
30
+ def _log(level: str, message: str, **kwargs: object) -> None:
31
+ """Log message with kwargs support for both structlog and standard logging."""
32
+ if _using_structlog:
33
+ getattr(logger, level)(message, **kwargs)
34
+ else:
35
+ # For standard logging, format kwargs as part of the message
36
+ if kwargs:
37
+ kwargs_str = " ".join(f"{k}={v}" for k, v in kwargs.items())
38
+ getattr(logger, level)(f"{message} {kwargs_str}")
39
+ else:
40
+ getattr(logger, level)(message)
41
+
42
+
43
+ def create_video_writer(
44
+ output_path: str,
45
+ width: int,
46
+ height: int,
47
+ display_width: int,
48
+ display_height: int,
49
+ fps: float,
50
+ ) -> tuple[cv2.VideoWriter, bool, str]:
51
+ """
52
+ Create a video writer with fallback codec support.
53
+
54
+ ⚠️ CRITICAL: DO NOT add "vp09" (VP9) to the codec list!
55
+ VP9 is not supported on iOS browsers (iPhone/iPad) and causes playback failures.
56
+ Regression test: tests/core/test_debug_overlay_utils.py::test_vp09_codec_never_in_codec_list
57
+
58
+ Args:
59
+ output_path: Path for output video
60
+ width: Encoded frame width (from source video)
61
+ height: Encoded frame height (from source video)
62
+ display_width: Display width (considering SAR)
63
+ display_height: Display height (considering SAR)
64
+ fps: Frames per second
65
+
66
+ Returns:
67
+ Tuple of (video_writer, needs_resize, used_codec)
68
+ """
69
+ needs_resize = (display_width != width) or (display_height != height)
70
+
71
+ # Try browser-compatible codecs first
72
+ # avc1: H.264 (Most compatible, including iOS)
73
+ # 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]] = []
78
+
79
+ for codec in codecs_to_try:
80
+ writer = _try_open_video_writer(
81
+ output_path, codec, fps, display_width, display_height, codec_attempt_log
82
+ )
83
+ if writer:
84
+ return writer, needs_resize, codec
85
+
86
+ _log(
87
+ "error",
88
+ "debug_video_writer_creation_failed",
89
+ output_path=output_path,
90
+ dimensions=f"{display_width}x{display_height}",
91
+ fps=fps,
92
+ codec_attempts=codec_attempt_log,
93
+ )
94
+ raise ValueError(
95
+ f"Failed to create video writer for {output_path} with dimensions "
96
+ f"{display_width}x{display_height}"
97
+ )
98
+
99
+
100
+ def _try_open_video_writer(
101
+ output_path: str,
102
+ codec: str,
103
+ fps: float,
104
+ width: int,
105
+ height: int,
106
+ attempt_log: list[dict[str, Any]],
107
+ ) -> cv2.VideoWriter | None:
108
+ """Attempt to open a video writer with a specific codec."""
109
+ try:
110
+ fourcc = cv2.VideoWriter_fourcc(*codec) # type: ignore[attr-defined]
111
+ writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))
112
+ if writer.isOpened():
113
+ attempt_log.append({"codec": codec, "status": "success"})
114
+ _log(
115
+ "info",
116
+ "debug_video_codec_selected",
117
+ codec=codec,
118
+ width=width,
119
+ height=height,
120
+ fps=fps,
121
+ )
122
+ if codec == "mp4v":
123
+ msg = (
124
+ "Using fallback MPEG-4 codec; will re-encode with ffmpeg for "
125
+ "browser compatibility"
126
+ )
127
+ _log("warning", "debug_video_fallback_codec", codec="mp4v", warning=msg)
128
+ return writer
129
+
130
+ attempt_log.append(
131
+ {"codec": codec, "status": "failed", "error": "isOpened() returned False"}
132
+ )
133
+ except Exception as e:
134
+ attempt_log.append({"codec": codec, "status": "failed", "error": str(e)})
135
+ _log("info", "debug_video_codec_attempt_failed", codec=codec, error=str(e))
136
+
137
+ return None
138
+
139
+
140
+ def write_overlay_frame(
141
+ writer: cv2.VideoWriter, frame: np.ndarray, width: int, height: int
142
+ ) -> None:
143
+ """
144
+ Write a frame to the video writer with dimension validation.
145
+
146
+ Args:
147
+ writer: Video writer instance
148
+ frame: Frame to write
149
+ width: Expected frame width
150
+ height: Expected frame height
151
+
152
+ Raises:
153
+ ValueError: If frame dimensions don't match expected dimensions
154
+ """
155
+ # Validate dimensions before writing
156
+ if frame.shape[0] != height or frame.shape[1] != width:
157
+ raise ValueError(
158
+ f"Frame dimensions {frame.shape[1]}x{frame.shape[0]} do not match "
159
+ f"expected dimensions {width}x{height}"
160
+ )
161
+ writer.write(frame)
162
+
163
+
164
+ class BaseDebugOverlayRenderer:
165
+ """Base class for debug overlay renderers with common functionality."""
166
+
167
+ def __init__(
168
+ self,
169
+ output_path: str,
170
+ width: int,
171
+ height: int,
172
+ display_width: int,
173
+ display_height: int,
174
+ fps: float,
175
+ timer: Timer | None = None,
176
+ ):
177
+ """
178
+ Initialize overlay renderer.
179
+
180
+ Args:
181
+ output_path: Path for output video
182
+ width: Encoded frame width (from source video)
183
+ height: Encoded frame height (from source video)
184
+ display_width: Display width (considering SAR)
185
+ display_height: Display height (considering SAR)
186
+ fps: Frames per second
187
+ timer: Optional Timer for measuring operations
188
+ """
189
+ self.output_path = output_path
190
+ self.width = width
191
+ self.height = height
192
+ self.timer = timer or NULL_TIMER
193
+
194
+ # Optimize debug video resolution: Cap max dimension to 720p
195
+ # Reduces software encoding time on single-core Cloud Run instances.
196
+ # 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)
200
+ # Ensure dimensions are even for codec compatibility
201
+ self.display_width = int(display_width * scale) // 2 * 2
202
+ self.display_height = int(display_height * scale) // 2 * 2
203
+ _log(
204
+ "info",
205
+ "debug_video_resolution_optimized",
206
+ original_width=display_width,
207
+ original_height=display_height,
208
+ optimized_width=self.display_width,
209
+ optimized_height=self.display_height,
210
+ scale_factor=round(scale, 2),
211
+ )
212
+ else:
213
+ self.display_width = display_width
214
+ self.display_height = display_height
215
+ _log(
216
+ "info",
217
+ "debug_video_resolution_native",
218
+ width=self.display_width,
219
+ height=self.display_height,
220
+ )
221
+
222
+ _log(
223
+ "info",
224
+ "debug_overlay_renderer_initialized",
225
+ output_path=output_path,
226
+ source_width=width,
227
+ source_height=height,
228
+ output_width=self.display_width,
229
+ output_height=self.display_height,
230
+ fps=fps,
231
+ )
232
+
233
+ # Duration of ffmpeg re-encoding (0.0 if not needed)
234
+ self.reencode_duration_s = 0.0
235
+ self.writer, self.needs_resize, self.used_codec = create_video_writer(
236
+ output_path, width, height, self.display_width, self.display_height, fps
237
+ )
238
+
239
+ def write_frame(self, frame: np.ndarray) -> None:
240
+ """
241
+ Write frame to output video.
242
+
243
+ Args:
244
+ frame: Video frame with shape (height, width, 3)
245
+
246
+ Raises:
247
+ ValueError: If frame dimensions don't match expected encoded dimensions
248
+ """
249
+ # Validate frame dimensions match expected encoded dimensions
250
+ frame_height, frame_width = frame.shape[:2]
251
+ if frame_height != self.height or frame_width != self.width:
252
+ raise ValueError(
253
+ f"Frame dimensions ({frame_width}x{frame_height}) don't match "
254
+ f"source dimensions ({self.width}x{self.height}). "
255
+ f"Aspect ratio must be preserved from source video."
256
+ )
257
+
258
+ # Resize to display dimensions if needed (to handle SAR)
259
+ if self.needs_resize:
260
+ with self.timer.measure("debug_video_resize"):
261
+ frame = cv2.resize(
262
+ frame,
263
+ (self.display_width, self.display_height),
264
+ interpolation=cv2.INTER_LINEAR,
265
+ )
266
+
267
+ with self.timer.measure("debug_video_write"):
268
+ write_overlay_frame(self.writer, frame, self.display_width, self.display_height)
269
+
270
+ def close(self) -> None:
271
+ """Release video writer and re-encode if possible."""
272
+ self.writer.release()
273
+ _log(
274
+ "info",
275
+ "debug_video_writer_released",
276
+ output_path=self.output_path,
277
+ codec=self.used_codec,
278
+ )
279
+
280
+ if self.used_codec != "mp4v":
281
+ _log(
282
+ "info",
283
+ "debug_video_ready_for_playback",
284
+ codec=self.used_codec,
285
+ path=self.output_path,
286
+ )
287
+ return
288
+
289
+ ffmpeg_path = shutil.which("ffmpeg")
290
+ if not ffmpeg_path:
291
+ _log(
292
+ "warning",
293
+ "debug_video_ffmpeg_not_available",
294
+ codec=self.used_codec,
295
+ output_path=self.output_path,
296
+ warning="Video may not play in all browsers",
297
+ )
298
+ return
299
+
300
+ self._reencode_to_h264()
301
+
302
+ def _reencode_to_h264(self) -> None:
303
+ """Re-encode video to H.264 for browser compatibility using ffmpeg."""
304
+ temp_path = str(
305
+ Path(self.output_path).with_suffix(".temp" + Path(self.output_path).suffix)
306
+ )
307
+
308
+ # Convert to H.264 with yuv420p pixel format for browser compatibility
309
+ # -y: Overwrite output file
310
+ # -vcodec libx264: Use H.264 codec
311
+ # -pix_fmt yuv420p: Required for wide browser support (Chrome, Safari, Firefox, iOS)
312
+ # -preset fast: Reasonable speed/compression tradeoff
313
+ # -crf 23: Standard quality
314
+ # -an: Remove audio (debug video has no audio)
315
+ cmd = [
316
+ "ffmpeg",
317
+ "-y",
318
+ "-i",
319
+ self.output_path,
320
+ "-vcodec",
321
+ "libx264",
322
+ "-pix_fmt",
323
+ "yuv420p",
324
+ "-preset",
325
+ "fast",
326
+ "-crf",
327
+ "23",
328
+ "-an",
329
+ temp_path,
330
+ ]
331
+
332
+ _log(
333
+ "info",
334
+ "debug_video_ffmpeg_reencoding_start",
335
+ input_file=self.output_path,
336
+ output_file=temp_path,
337
+ output_codec="libx264",
338
+ pixel_format="yuv420p",
339
+ reason="iOS_compatibility",
340
+ )
341
+
342
+ try:
343
+ reencode_start = time.time()
344
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
345
+ self.reencode_duration_s = time.time() - reencode_start
346
+
347
+ _log(
348
+ "info",
349
+ "debug_video_ffmpeg_reencoding_complete",
350
+ duration_ms=round(self.reencode_duration_s * 1000, 1),
351
+ )
352
+
353
+ os.replace(temp_path, self.output_path)
354
+ _log(
355
+ "info",
356
+ "debug_video_reencoded_file_replaced",
357
+ output_path=self.output_path,
358
+ final_codec="libx264",
359
+ pixel_format="yuv420p",
360
+ )
361
+ except Exception as e:
362
+ self._handle_reencode_error(e, temp_path)
363
+
364
+ def _handle_reencode_error(self, e: Exception, temp_path: str) -> None:
365
+ """Handle errors during ffmpeg re-encoding."""
366
+ if isinstance(e, subprocess.CalledProcessError):
367
+ stderr_msg = e.stderr.decode("utf-8", errors="ignore") if e.stderr else "N/A"
368
+ _log(
369
+ "warning",
370
+ "debug_video_ffmpeg_reencoding_failed",
371
+ error=str(e),
372
+ stderr=stderr_msg,
373
+ )
374
+ else:
375
+ _log("warning", "debug_video_post_processing_error", error=str(e))
376
+
377
+ if os.path.exists(temp_path):
378
+ os.remove(temp_path)
379
+ _log("info", "debug_video_temp_file_cleaned_up", temp_file=temp_path)
380
+
381
+ def __enter__(self) -> Self:
382
+ return self
383
+
384
+ def __exit__(self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore[no-untyped-def]
385
+ self.close()
@@ -0,0 +1,83 @@
1
+ """Determinism utilities for reproducible analysis.
2
+
3
+ Provides functions to set random seeds for NumPy, Python's random module,
4
+ and TensorFlow (used by MediaPipe) to ensure deterministic behavior.
5
+ """
6
+
7
+ import hashlib
8
+ import os
9
+ import random
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+
15
+ def get_video_hash_seed(video_path: str) -> int:
16
+ """Generate deterministic seed from video file path.
17
+
18
+ Uses video filename (not contents) to generate a consistent seed
19
+ for the same video across multiple runs.
20
+
21
+ Args:
22
+ video_path: Path to video file
23
+
24
+ Returns:
25
+ Integer seed value derived from filename
26
+ """
27
+ # Use filename only (not full path) for consistency
28
+ filename = Path(video_path).name
29
+ # Hash filename to get deterministic seed
30
+ hash_value = hashlib.md5(filename.encode()).hexdigest()
31
+ # Convert first 8 hex chars to integer
32
+ return int(hash_value[:8], 16)
33
+
34
+
35
+ def set_deterministic_mode(seed: int | None = None, video_path: str | None = None) -> None:
36
+ """Set random seeds for reproducible analysis.
37
+
38
+ Sets seeds for:
39
+ - Python's random module
40
+ - NumPy random number generator
41
+ - TensorFlow (via environment variable for TFLite)
42
+
43
+ Args:
44
+ seed: Random seed value. If None and video_path provided,
45
+ generates seed from video filename.
46
+ video_path: Optional video path to generate deterministic seed
47
+
48
+ Note:
49
+ This should be called before any MediaPipe or analysis operations
50
+ to ensure deterministic pose detection and metric calculation.
51
+ """
52
+ # Generate seed from video if not provided
53
+ if seed is None and video_path is not None:
54
+ seed = get_video_hash_seed(video_path)
55
+ elif seed is None:
56
+ seed = 42 # Default
57
+
58
+ # Python random
59
+ random.seed(seed)
60
+
61
+ # NumPy random
62
+ np.random.seed(seed)
63
+
64
+ # TensorFlow/TFLite (used by MediaPipe)
65
+ # Set via environment variable before TF is initialized
66
+ os.environ["PYTHONHASHSEED"] = str(seed)
67
+ os.environ["TF_DETERMINISTIC_OPS"] = "1"
68
+
69
+ # Try to set TensorFlow seed if available
70
+ try:
71
+ import tensorflow as tf
72
+
73
+ tf.random.set_seed(seed)
74
+
75
+ # Disable GPU non-determinism if CUDA is available
76
+ try:
77
+ tf.config.experimental.enable_op_determinism()
78
+ except AttributeError:
79
+ # Older TensorFlow versions don't have this
80
+ pass
81
+ except ImportError:
82
+ # TensorFlow not directly available (only via MediaPipe's bundled version)
83
+ pass
@@ -0,0 +1,103 @@
1
+ """Decorators for marking experimental and unused features.
2
+
3
+ These decorators help identify code that is implemented but not yet
4
+ integrated into the main pipeline, making it easier to track features
5
+ for future enhancement or cleanup.
6
+ """
7
+
8
+ import functools
9
+ import warnings
10
+ from collections.abc import Callable
11
+ from typing import TypeVar
12
+
13
+ F = TypeVar("F", bound=Callable)
14
+
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
+ def unused(
64
+ reason: str, *, remove_in: str | None = None, since: str | None = None
65
+ ) -> Callable[[F], F]:
66
+ """Mark a feature as implemented but not integrated into pipeline.
67
+
68
+ 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.
71
+
72
+ Use this for:
73
+ - Features awaiting CLI integration
74
+ - Alternative implementations not yet exposed
75
+ - Code kept for backward compatibility
76
+
77
+ Args:
78
+ reason: Why this is unused (e.g., "awaiting CLI parameter")
79
+ remove_in: Optional version when this might be removed if not integrated
80
+ since: Optional version when this became unused
81
+
82
+ Example:
83
+ >>> @unused("Not called by pipeline", remove_in="1.0.0", since="0.34.0")
84
+ ... def calculate_adaptive_threshold():
85
+ ... pass
86
+
87
+ Returns:
88
+ Original function with metadata attached (no runtime behavior change)
89
+ """
90
+
91
+ def decorator(func: F) -> F:
92
+ # Don't wrap - we don't want warnings when calling it
93
+ # Just attach metadata for documentation/cleanup tools
94
+ func.__unused__ = True # type: ignore[attr-defined]
95
+ func.__unused_reason__ = reason # type: ignore[attr-defined]
96
+ if remove_in:
97
+ func.__unused_remove_in__ = remove_in # type: ignore[attr-defined]
98
+ if since:
99
+ func.__unused_since__ = since # type: ignore[attr-defined]
100
+
101
+ return func
102
+
103
+ return decorator
@@ -3,6 +3,8 @@
3
3
  import numpy as np
4
4
  from scipy.signal import medfilt
5
5
 
6
+ from .experimental import unused
7
+
6
8
 
7
9
  def detect_outliers_ransac(
8
10
  positions: np.ndarray,
@@ -145,9 +147,7 @@ def remove_outliers(
145
147
 
146
148
  # Interpolate
147
149
  t = (idx - idx_before) / (idx_after - idx_before)
148
- positions_clean[idx] = (
149
- positions[idx_before] * (1 - t) + positions[idx_after] * t
150
- )
150
+ positions_clean[idx] = positions[idx_before] * (1 - t) + positions[idx_after] * t
151
151
  elif len(valid_before) > 0:
152
152
  # Use last valid value
153
153
  positions_clean[idx] = positions[valid_before[-1]]
@@ -217,15 +217,18 @@ def reject_outliers(
217
217
 
218
218
  # Remove/replace outliers
219
219
  if interpolate:
220
- cleaned_positions = remove_outliers(
221
- positions, outlier_mask, method="interpolate"
222
- )
220
+ cleaned_positions = remove_outliers(positions, outlier_mask, method="interpolate")
223
221
  else:
224
222
  cleaned_positions = positions.copy()
225
223
 
226
224
  return cleaned_positions, outlier_mask
227
225
 
228
226
 
227
+ @unused(
228
+ reason="Not called by analysis pipeline - alternative adaptive smoothing approach",
229
+ remove_in="1.0.0",
230
+ since="0.34.0",
231
+ )
229
232
  def adaptive_smooth_window(
230
233
  positions: np.ndarray,
231
234
  base_window: int = 5,
@@ -0,0 +1,75 @@
1
+ """Formatting utilities for consistent numeric output across jump analysis types.
2
+
3
+ This module provides shared helpers for formatting numeric values with appropriate
4
+ precision based on measurement type and capabilities of video-based analysis.
5
+ """
6
+
7
+ # Standard precision values for different measurement types
8
+ # These values are chosen based on:
9
+ # - Video analysis capabilities (30-240 fps)
10
+ # - Typical measurement uncertainty in video-based biomechanics
11
+ # - Balance between accuracy and readability
12
+
13
+ PRECISION_TIME_MS = 2 # Time in milliseconds: ±0.01ms (e.g., 534.12)
14
+ PRECISION_DISTANCE_M = 3 # Distance in meters: ±1mm (e.g., 0.352)
15
+ PRECISION_VELOCITY_M_S = 4 # Velocity in m/s: ±0.0001 m/s (e.g., 2.6340)
16
+ PRECISION_FRAME = 3 # Sub-frame interpolation precision (e.g., 154.342)
17
+ PRECISION_NORMALIZED = 4 # Normalized values 0-1 ratios (e.g., 0.0582)
18
+
19
+
20
+ def format_float_metric(
21
+ value: float | None,
22
+ multiplier: float = 1.0,
23
+ decimals: int = 2,
24
+ ) -> float | None:
25
+ """Format a float metric value with optional scaling and rounding.
26
+
27
+ This helper ensures consistent precision across all jump analysis outputs,
28
+ preventing false precision in measurements while maintaining appropriate
29
+ accuracy for the measurement type.
30
+
31
+ Args:
32
+ value: The value to format, or None
33
+ multiplier: Factor to multiply value by (e.g., 1000 for seconds→milliseconds)
34
+ decimals: Number of decimal places to round to
35
+
36
+ Returns:
37
+ Formatted value rounded to specified decimals, or None if input is None
38
+
39
+ Examples:
40
+ >>> format_float_metric(0.534123, 1000, 2) # seconds to ms
41
+ 534.12
42
+ >>> format_float_metric(0.3521234, 1, 3) # meters
43
+ 0.352
44
+ >>> format_float_metric(None, 1, 2)
45
+ None
46
+ >>> format_float_metric(-1.23456, 1, 4) # negative values preserved
47
+ -1.2346
48
+ """
49
+ if value is None:
50
+ return None
51
+ return round(value * multiplier, decimals)
52
+
53
+
54
+ def format_int_metric(value: float | int | None) -> int | None:
55
+ """Format a value as an integer.
56
+
57
+ Used for frame numbers and other integer-valued metrics.
58
+
59
+ Args:
60
+ value: The value to format, or None
61
+
62
+ Returns:
63
+ Value converted to int, or None if input is None
64
+
65
+ Examples:
66
+ >>> format_int_metric(42.7)
67
+ 42
68
+ >>> format_int_metric(None)
69
+ None
70
+ >>> format_int_metric(154)
71
+ 154
72
+ """
73
+ if value is None:
74
+ return None
75
+ return int(value)