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.
- kinemotion/__init__.py +31 -6
- kinemotion/api.py +39 -598
- kinemotion/cli.py +2 -0
- kinemotion/cmj/__init__.py +5 -0
- kinemotion/cmj/analysis.py +621 -0
- kinemotion/cmj/api.py +563 -0
- kinemotion/cmj/cli.py +324 -0
- kinemotion/cmj/debug_overlay.py +457 -0
- kinemotion/cmj/joint_angles.py +307 -0
- kinemotion/cmj/kinematics.py +360 -0
- kinemotion/cmj/metrics_validator.py +767 -0
- kinemotion/cmj/validation_bounds.py +341 -0
- kinemotion/core/__init__.py +28 -0
- kinemotion/core/auto_tuning.py +71 -37
- kinemotion/core/cli_utils.py +60 -0
- kinemotion/core/debug_overlay_utils.py +385 -0
- kinemotion/core/determinism.py +83 -0
- kinemotion/core/experimental.py +103 -0
- kinemotion/core/filtering.py +9 -6
- kinemotion/core/formatting.py +75 -0
- kinemotion/core/metadata.py +231 -0
- kinemotion/core/model_downloader.py +172 -0
- kinemotion/core/pipeline_utils.py +433 -0
- kinemotion/core/pose.py +298 -141
- kinemotion/core/pose_landmarks.py +67 -0
- kinemotion/core/quality.py +393 -0
- kinemotion/core/smoothing.py +250 -154
- kinemotion/core/timing.py +247 -0
- kinemotion/core/types.py +42 -0
- kinemotion/core/validation.py +201 -0
- kinemotion/core/video_io.py +135 -50
- kinemotion/dropjump/__init__.py +1 -1
- kinemotion/dropjump/analysis.py +367 -182
- kinemotion/dropjump/api.py +665 -0
- kinemotion/dropjump/cli.py +156 -466
- kinemotion/dropjump/debug_overlay.py +136 -206
- kinemotion/dropjump/kinematics.py +232 -255
- kinemotion/dropjump/metrics_validator.py +240 -0
- kinemotion/dropjump/validation_bounds.py +157 -0
- kinemotion/models/__init__.py +0 -0
- kinemotion/models/pose_landmarker_lite.task +0 -0
- kinemotion-0.67.0.dist-info/METADATA +726 -0
- kinemotion-0.67.0.dist-info/RECORD +47 -0
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/WHEEL +1 -1
- kinemotion-0.10.6.dist-info/METADATA +0 -561
- kinemotion-0.10.6.dist-info/RECORD +0 -20
- {kinemotion-0.10.6.dist-info → kinemotion-0.67.0.dist-info}/entry_points.txt +0 -0
- {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
|
kinemotion/core/filtering.py
CHANGED
|
@@ -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)
|