kinemotion 0.47.1__py3-none-any.whl → 0.47.3__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.

@@ -147,133 +147,219 @@ class CMJMetrics:
147
147
  return result
148
148
 
149
149
 
150
- def calculate_cmj_metrics(
150
+ def _calculate_scale_factor(
151
151
  positions: NDArray[np.float64],
152
- velocities: NDArray[np.float64],
153
- standing_start_frame: float | None,
154
- lowest_point_frame: float,
155
152
  takeoff_frame: float,
156
153
  landing_frame: float,
157
- fps: float,
158
- tracking_method: str = "foot",
159
- ) -> CMJMetrics:
160
- """Calculate all CMJ metrics from detected phases.
154
+ jump_height: float,
155
+ ) -> float:
156
+ """Calculate meters per normalized unit scaling factor from flight phase.
161
157
 
162
158
  Args:
163
- positions: Array of vertical positions (normalized coordinates)
164
- velocities: Array of vertical velocities
165
- standing_start_frame: Frame where countermovement begins (fractional)
166
- lowest_point_frame: Frame at lowest point (fractional)
167
- takeoff_frame: Frame at takeoff (fractional)
168
- landing_frame: Frame at landing (fractional)
169
- fps: Video frames per second
170
- tracking_method: Tracking method used ("foot" or "com")
159
+ positions: Array of vertical positions
160
+ takeoff_frame: Takeoff frame index
161
+ landing_frame: Landing frame index
162
+ jump_height: Calculated jump height in meters
171
163
 
172
164
  Returns:
173
- CMJMetrics object with all calculated metrics.
165
+ Scale factor (meters per normalized unit)
174
166
  """
175
- # Calculate flight time from takeoff to landing
176
- flight_time = (landing_frame - takeoff_frame) / fps
177
-
178
- # Calculate jump height from flight time using kinematic formula
179
- # h = g * t^2 / 8 (where t is total flight time)
180
- g = 9.81 # gravity in m/s^2
181
- jump_height = (g * flight_time**2) / 8
182
-
183
- # Determine scaling factor (meters per normalized unit)
184
- # We use the flight phase displacement in normalized units compared to
185
- # kinematic jump height
186
167
  flight_start_idx = int(takeoff_frame)
187
168
  flight_end_idx = int(landing_frame)
188
169
  flight_positions = positions[flight_start_idx:flight_end_idx]
189
170
 
190
- scale_factor = 0.0
191
- if len(flight_positions) > 0:
192
- # Peak height is minimum y value (highest point in frame)
193
- peak_flight_pos = np.min(flight_positions)
194
- takeoff_pos = positions[flight_start_idx]
195
- # Displacement is upward (takeoff_pos - peak_pos) because y decreases upward
196
- flight_displacement = takeoff_pos - peak_flight_pos
171
+ if len(flight_positions) == 0:
172
+ return 0.0
197
173
 
198
- if flight_displacement > 0.001: # Avoid division by zero or noise
199
- scale_factor = jump_height / flight_displacement
174
+ peak_flight_pos = np.min(flight_positions)
175
+ takeoff_pos = positions[flight_start_idx]
176
+ flight_displacement = takeoff_pos - peak_flight_pos
177
+
178
+ if flight_displacement > 0.001:
179
+ return jump_height / flight_displacement
180
+ return 0.0
200
181
 
201
- # Calculate countermovement depth
202
- if standing_start_frame is not None:
203
- standing_position = positions[int(standing_start_frame)]
204
- else:
205
- # Use position at start of recording if standing not detected
206
- standing_position = positions[0]
207
182
 
183
+ def _calculate_countermovement_depth(
184
+ positions: NDArray[np.float64],
185
+ standing_start_frame: float | None,
186
+ lowest_point_frame: float,
187
+ scale_factor: float,
188
+ ) -> float:
189
+ """Calculate countermovement depth in meters.
190
+
191
+ Args:
192
+ positions: Array of vertical positions
193
+ standing_start_frame: Standing phase end frame (or None)
194
+ lowest_point_frame: Lowest point frame index
195
+ scale_factor: Meters per normalized unit
196
+
197
+ Returns:
198
+ Countermovement depth in meters
199
+ """
200
+ standing_position = (
201
+ positions[int(standing_start_frame)]
202
+ if standing_start_frame is not None
203
+ else positions[0]
204
+ )
208
205
  lowest_position = positions[int(lowest_point_frame)]
209
- # Depth in normalized units
210
206
  depth_normalized = abs(standing_position - lowest_position)
211
- # Convert to meters
212
- countermovement_depth = depth_normalized * scale_factor
207
+ return depth_normalized * scale_factor
208
+
209
+
210
+ def _calculate_phase_durations(
211
+ standing_start_frame: float | None,
212
+ lowest_point_frame: float,
213
+ takeoff_frame: float,
214
+ fps: float,
215
+ ) -> tuple[float, float, float]:
216
+ """Calculate phase durations in seconds.
217
+
218
+ Args:
219
+ standing_start_frame: Standing phase end frame (or None)
220
+ lowest_point_frame: Lowest point frame index
221
+ takeoff_frame: Takeoff frame index
222
+ fps: Frames per second
213
223
 
214
- # Calculate phase durations
224
+ Returns:
225
+ Tuple of (eccentric_duration, concentric_duration, total_movement_time)
226
+ """
215
227
  if standing_start_frame is not None:
216
228
  eccentric_duration = (lowest_point_frame - standing_start_frame) / fps
217
229
  total_movement_time = (takeoff_frame - standing_start_frame) / fps
218
230
  else:
219
- # If no standing phase detected, measure from start
220
231
  eccentric_duration = lowest_point_frame / fps
221
232
  total_movement_time = takeoff_frame / fps
222
233
 
223
234
  concentric_duration = (takeoff_frame - lowest_point_frame) / fps
235
+ return eccentric_duration, concentric_duration, total_movement_time
224
236
 
225
- # Velocity scaling factor: units/frame -> meters/second
226
- # v_m_s = v_units_frame * fps * scale_factor
227
- velocity_scale = scale_factor * fps
228
237
 
229
- # Calculate peak velocities
230
- # Eccentric phase: Downward motion = Positive velocity in image coords
231
- if standing_start_frame is not None:
232
- eccentric_start_idx = int(standing_start_frame)
233
- else:
234
- eccentric_start_idx = 0
238
+ def _calculate_peak_velocities(
239
+ velocities: NDArray[np.float64],
240
+ standing_start_frame: float | None,
241
+ lowest_point_frame: float,
242
+ takeoff_frame: float,
243
+ velocity_scale: float,
244
+ ) -> tuple[float, float]:
245
+ """Calculate peak eccentric and concentric velocities.
235
246
 
247
+ Args:
248
+ velocities: Array of velocities
249
+ standing_start_frame: Standing phase end frame (or None)
250
+ lowest_point_frame: Lowest point frame index
251
+ takeoff_frame: Takeoff frame index
252
+ velocity_scale: Velocity scaling factor
253
+
254
+ Returns:
255
+ Tuple of (peak_eccentric_velocity, peak_concentric_velocity)
256
+ """
257
+ eccentric_start_idx = int(standing_start_frame) if standing_start_frame else 0
236
258
  eccentric_end_idx = int(lowest_point_frame)
237
259
  eccentric_velocities = velocities[eccentric_start_idx:eccentric_end_idx]
238
260
 
261
+ peak_eccentric_velocity = 0.0
239
262
  if len(eccentric_velocities) > 0:
240
- # Peak eccentric velocity is maximum positive value (fastest downward)
241
- # We take max and ensure it's positive (it should be)
242
- peak_eccentric_velocity = float(np.max(eccentric_velocities)) * velocity_scale
243
- # If max is negative (weird), it means no downward motion detected
244
- if peak_eccentric_velocity < 0:
245
- peak_eccentric_velocity = 0.0
246
- else:
247
- peak_eccentric_velocity = 0.0
263
+ peak = float(np.max(eccentric_velocities)) * velocity_scale
264
+ peak_eccentric_velocity = max(0.0, peak)
248
265
 
249
- # Concentric phase: Upward motion = Negative velocity in image coords
250
266
  concentric_start_idx = int(lowest_point_frame)
251
267
  concentric_end_idx = int(takeoff_frame)
252
268
  concentric_velocities = velocities[concentric_start_idx:concentric_end_idx]
253
269
 
270
+ peak_concentric_velocity = 0.0
254
271
  if len(concentric_velocities) > 0:
255
- # Peak concentric velocity is minimum value (most negative = fastest upward)
256
- # We take abs to report magnitude
257
272
  peak_concentric_velocity = (
258
273
  abs(float(np.min(concentric_velocities))) * velocity_scale
259
274
  )
260
- else:
261
- peak_concentric_velocity = 0.0
262
275
 
263
- # Estimate transition time (amortization phase)
264
- # Look for period around lowest point where velocity is near zero
265
- transition_threshold = 0.005 # Very low velocity threshold
266
- search_window = int(fps * 0.1) # Search within ±100ms
276
+ return peak_eccentric_velocity, peak_concentric_velocity
277
+
278
+
279
+ def _calculate_transition_time(
280
+ velocities: NDArray[np.float64],
281
+ lowest_point_frame: float,
282
+ fps: float,
283
+ ) -> float | None:
284
+ """Calculate transition/amortization time around lowest point.
285
+
286
+ Args:
287
+ velocities: Array of velocities
288
+ lowest_point_frame: Lowest point frame index
289
+ fps: Frames per second
290
+
291
+ Returns:
292
+ Transition time in seconds, or None if no transition detected
293
+ """
294
+ transition_threshold = 0.005
295
+ search_window = int(fps * 0.1)
267
296
 
268
297
  transition_start_idx = max(0, int(lowest_point_frame) - search_window)
269
298
  transition_end_idx = min(len(velocities), int(lowest_point_frame) + search_window)
270
299
 
271
- transition_frames = 0
272
- for i in range(transition_start_idx, transition_end_idx):
273
- if abs(velocities[i]) < transition_threshold:
274
- transition_frames += 1
300
+ transition_frames = sum(
301
+ 1
302
+ for i in range(transition_start_idx, transition_end_idx)
303
+ if abs(velocities[i]) < transition_threshold
304
+ )
305
+
306
+ return transition_frames / fps if transition_frames > 0 else None
307
+
308
+
309
+ def calculate_cmj_metrics(
310
+ positions: NDArray[np.float64],
311
+ velocities: NDArray[np.float64],
312
+ standing_start_frame: float | None,
313
+ lowest_point_frame: float,
314
+ takeoff_frame: float,
315
+ landing_frame: float,
316
+ fps: float,
317
+ tracking_method: str = "foot",
318
+ ) -> CMJMetrics:
319
+ """Calculate all CMJ metrics from detected phases.
320
+
321
+ Args:
322
+ positions: Array of vertical positions (normalized coordinates)
323
+ velocities: Array of vertical velocities
324
+ standing_start_frame: Frame where countermovement begins (fractional)
325
+ lowest_point_frame: Frame at lowest point (fractional)
326
+ takeoff_frame: Frame at takeoff (fractional)
327
+ landing_frame: Frame at landing (fractional)
328
+ fps: Video frames per second
329
+ tracking_method: Tracking method used ("foot" or "com")
330
+
331
+ Returns:
332
+ CMJMetrics object with all calculated metrics.
333
+ """
334
+ # Calculate jump height from flight time using kinematic formula: h = g*t²/8
335
+ g = 9.81
336
+ flight_time = (landing_frame - takeoff_frame) / fps
337
+ jump_height = (g * flight_time**2) / 8
338
+
339
+ # Calculate scaling factor and derived metrics
340
+ scale_factor = _calculate_scale_factor(
341
+ positions, takeoff_frame, landing_frame, jump_height
342
+ )
343
+ countermovement_depth = _calculate_countermovement_depth(
344
+ positions, standing_start_frame, lowest_point_frame, scale_factor
345
+ )
346
+
347
+ eccentric_duration, concentric_duration, total_movement_time = (
348
+ _calculate_phase_durations(
349
+ standing_start_frame, lowest_point_frame, takeoff_frame, fps
350
+ )
351
+ )
352
+
353
+ velocity_scale = scale_factor * fps
354
+ peak_eccentric_velocity, peak_concentric_velocity = _calculate_peak_velocities(
355
+ velocities,
356
+ standing_start_frame,
357
+ lowest_point_frame,
358
+ takeoff_frame,
359
+ velocity_scale,
360
+ )
275
361
 
276
- transition_time = transition_frames / fps if transition_frames > 0 else None
362
+ transition_time = _calculate_transition_time(velocities, lowest_point_frame, fps)
277
363
 
278
364
  return CMJMetrics(
279
365
  jump_height=jump_height,
@@ -449,7 +449,7 @@ class CMJMetricsValidator(MetricsValidator):
449
449
  # Convert ms to seconds
450
450
  flight_time = flight_time_ms / 1000.0
451
451
 
452
- # h = g * t^2 / 8
452
+ # Calculate expected height using kinematic formula: h = g*t²/8
453
453
  g = 9.81
454
454
  expected_height = (g * flight_time**2) / 8
455
455
  error_pct = abs(jump_height - expected_height) / expected_height
@@ -483,7 +483,7 @@ class CMJMetricsValidator(MetricsValidator):
483
483
  if velocity is None or jump_height is None:
484
484
  return
485
485
 
486
- # h = v^2 / (2*g)
486
+ # Calculate expected velocity using kinematic formula: v² = 2*g*h
487
487
  g = 9.81
488
488
  expected_velocity = (2 * g * jump_height) ** 0.5
489
489
  error_pct = abs(velocity - expected_velocity) / expected_velocity
@@ -24,9 +24,7 @@ from .smoothing import (
24
24
  )
25
25
  from .timing import (
26
26
  NULL_TIMER,
27
- CompositeTimer,
28
27
  NullTimer,
29
- OpenTelemetryTimer,
30
28
  PerformanceTimer,
31
29
  Timer,
32
30
  )
@@ -59,8 +57,6 @@ __all__ = [
59
57
  "Timer",
60
58
  "NullTimer",
61
59
  "NULL_TIMER",
62
- "CompositeTimer",
63
- "OpenTelemetryTimer",
64
60
  # Video I/O
65
61
  "VideoProcessor",
66
62
  ]
kinemotion/core/timing.py CHANGED
@@ -26,43 +26,9 @@ Example:
26
26
  """
27
27
 
28
28
  import time
29
- from contextlib import AbstractContextManager, ExitStack, contextmanager
29
+ from contextlib import AbstractContextManager
30
30
  from typing import Protocol, runtime_checkable
31
31
 
32
- # OpenTelemetry related imports, guarded by try-except for optional dependency
33
- _trace_module = None # This will hold the actual 'trace' module if imported
34
- _otel_tracer_class = None # This will hold the actual 'Tracer' class if imported
35
-
36
- try:
37
- import opentelemetry.trace as _trace_module_import # Import the module directly
38
-
39
- _otel_tracer_class = (
40
- _trace_module_import.Tracer
41
- ) # Get the Tracer class from the module
42
- _trace_module = (
43
- _trace_module_import # Expose the trace module globally after successful import
44
- )
45
- except ImportError:
46
- pass # No OTel, so these remain None
47
-
48
- # Now define the global/module-level variables used elsewhere
49
- # Conditionally expose 'trace' and 'Tracer' aliases
50
- trace = _trace_module # This will be the actual module or None
51
-
52
-
53
- class Tracer: # Dummy for type hints if actual Tracer is not available
54
- pass
55
-
56
-
57
- if _otel_tracer_class:
58
- Tracer = _otel_tracer_class # type: ignore # Override dummy if actual Tracer is available
59
-
60
- # This _OPENTELEMETRY_AVAILABLE variable is assigned only once,
61
- # after the try-except block
62
- _OPENTELEMETRY_AVAILABLE = bool(
63
- _otel_tracer_class
64
- ) # True if Tracer class was successfully loaded
65
-
66
32
 
67
33
  @runtime_checkable
68
34
  class Timer(Protocol):
@@ -152,11 +118,12 @@ class NullTimer:
152
118
  The context manager protocol (__enter__/__exit__) has minimal overhead.
153
119
 
154
120
  Args:
155
- name: Ignored - kept for protocol compatibility
121
+ name: Operation name (unused in no-op implementation)
156
122
 
157
123
  Returns:
158
124
  Singleton null context manager
159
125
  """
126
+ del name # Intentionally unused - satisfies Timer protocol
160
127
  return _NULL_CONTEXT
161
128
 
162
129
  def get_metrics(self) -> dict[str, float]:
@@ -278,105 +245,3 @@ class PerformanceTimer:
278
245
  A copy of the metrics dictionary to prevent external modification.
279
246
  """
280
247
  return self.metrics.copy()
281
-
282
-
283
- @contextmanager
284
- def _composite_context_manager(contexts: list[AbstractContextManager[None]]):
285
- """Helper to combine multiple context managers into one.
286
-
287
- Uses ExitStack to manage entering and exiting multiple contexts transparently.
288
- """
289
- with ExitStack() as stack:
290
- for ctx in contexts:
291
- stack.enter_context(ctx)
292
- yield
293
-
294
-
295
- class CompositeTimer:
296
- """Timer that delegates measurements to multiple underlying timers.
297
-
298
- Useful for enabling both local performance timing (for JSON output)
299
- and distributed tracing (OpenTelemetry) simultaneously.
300
- """
301
-
302
- __slots__ = ("timers",)
303
-
304
- def __init__(self, timers: list[Timer]) -> None:
305
- """Initialize composite timer.
306
-
307
- Args:
308
- timers: List of timer instances to delegate to
309
- """
310
- self.timers = timers
311
-
312
- def measure(self, name: str) -> AbstractContextManager[None]:
313
- """Measure using all underlying timers.
314
-
315
- Args:
316
- name: Name of the operation
317
-
318
- Returns:
319
- Context manager that manages all underlying timers
320
- """
321
- contexts = [timer.measure(name) for timer in self.timers]
322
- return _composite_context_manager(contexts)
323
-
324
- def get_metrics(self) -> dict[str, float]:
325
- """Get combined metrics from all timers.
326
-
327
- Returns:
328
- Merged dictionary of metrics
329
- """
330
- metrics = {}
331
- for timer in self.timers:
332
- metrics.update(timer.get_metrics())
333
- return metrics
334
-
335
-
336
- class OpenTelemetryTimer:
337
- """Timer implementation that creates OpenTelemetry spans.
338
-
339
- Maps 'measure' calls to OTel spans. Requires opentelemetry-api installed.
340
- """
341
-
342
- __slots__ = ("tracer",)
343
-
344
- def __init__(self, tracer: Tracer | None = None) -> None:
345
- """Initialize OTel timer.
346
-
347
- Args:
348
- tracer: Optional OTel tracer. If None, gets tracer for module name.
349
- """
350
- if not _OPENTELEMETRY_AVAILABLE:
351
- self.tracer = None # Always initialize self.tracer for __slots__
352
- return
353
-
354
- if trace is not None:
355
- self.tracer = tracer or trace.get_tracer(__name__)
356
- else:
357
- # This branch should ideally not be reached if _OPENTELEMETRY_AVAILABLE
358
- # is True but trace is None (meaning import succeeded but trace was not what
359
- # expected). Defensive programming: ensure self.tracer is set.
360
- self.tracer = None
361
-
362
- def measure(self, name: str) -> AbstractContextManager[None]:
363
- """Start an OpenTelemetry span.
364
-
365
- Args:
366
- name: Name of the span
367
-
368
- Returns:
369
- Span context manager (compatible with AbstractContextManager)
370
- """
371
- if not _OPENTELEMETRY_AVAILABLE or self.tracer is None:
372
- return _NULL_CONTEXT # Return the no-op context
373
-
374
- return self.tracer.start_as_current_span(name)
375
-
376
- def get_metrics(self) -> dict[str, float]:
377
- """Return empty metrics (OTel handles export asynchronously).
378
-
379
- Returns:
380
- Empty dictionary
381
- """
382
- return {}