kinemotion 0.12.2__tar.gz → 0.12.3__tar.gz

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 (71) hide show
  1. {kinemotion-0.12.2 → kinemotion-0.12.3}/CHANGELOG.md +22 -0
  2. {kinemotion-0.12.2 → kinemotion-0.12.3}/PKG-INFO +1 -1
  3. {kinemotion-0.12.2 → kinemotion-0.12.3}/examples/programmatic_usage.py +3 -26
  4. {kinemotion-0.12.2 → kinemotion-0.12.3}/pyproject.toml +1 -1
  5. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/auto_tuning.py +66 -30
  6. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/video_io.py +53 -29
  7. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/dropjump/analysis.py +36 -0
  8. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/dropjump/cli.py +60 -55
  9. {kinemotion-0.12.2 → kinemotion-0.12.3}/uv.lock +1 -1
  10. {kinemotion-0.12.2 → kinemotion-0.12.3}/.dockerignore +0 -0
  11. {kinemotion-0.12.2 → kinemotion-0.12.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {kinemotion-0.12.2 → kinemotion-0.12.3}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  13. {kinemotion-0.12.2 → kinemotion-0.12.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {kinemotion-0.12.2 → kinemotion-0.12.3}/.github/pull_request_template.md +0 -0
  15. {kinemotion-0.12.2 → kinemotion-0.12.3}/.github/workflows/release.yml +0 -0
  16. {kinemotion-0.12.2 → kinemotion-0.12.3}/.gitignore +0 -0
  17. {kinemotion-0.12.2 → kinemotion-0.12.3}/.pre-commit-config.yaml +0 -0
  18. {kinemotion-0.12.2 → kinemotion-0.12.3}/.tool-versions +0 -0
  19. {kinemotion-0.12.2 → kinemotion-0.12.3}/CLAUDE.md +0 -0
  20. {kinemotion-0.12.2 → kinemotion-0.12.3}/CODE_OF_CONDUCT.md +0 -0
  21. {kinemotion-0.12.2 → kinemotion-0.12.3}/CONTRIBUTING.md +0 -0
  22. {kinemotion-0.12.2 → kinemotion-0.12.3}/Dockerfile +0 -0
  23. {kinemotion-0.12.2 → kinemotion-0.12.3}/GEMINI.md +0 -0
  24. {kinemotion-0.12.2 → kinemotion-0.12.3}/LICENSE +0 -0
  25. {kinemotion-0.12.2 → kinemotion-0.12.3}/README.md +0 -0
  26. {kinemotion-0.12.2 → kinemotion-0.12.3}/SECURITY.md +0 -0
  27. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/BULK_PROCESSING.md +0 -0
  28. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/CAMERA_SETUP.md +0 -0
  29. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/CAMERA_SETUP_ES.md +0 -0
  30. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/CMJ_GUIDE.md +0 -0
  31. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/ERRORS_FINDINGS.md +0 -0
  32. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/FRAMERATE.md +0 -0
  33. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/IMU_METADATA_PRESERVATION.md +0 -0
  34. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/PARAMETERS.md +0 -0
  35. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/REAL_TIME_ANALYSIS.md +0 -0
  36. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/TRIPLE_EXTENSION.md +0 -0
  37. {kinemotion-0.12.2 → kinemotion-0.12.3}/docs/VALIDATION_PLAN.md +0 -0
  38. {kinemotion-0.12.2 → kinemotion-0.12.3}/examples/bulk/README.md +0 -0
  39. {kinemotion-0.12.2 → kinemotion-0.12.3}/examples/bulk/bulk_processing.py +0 -0
  40. {kinemotion-0.12.2 → kinemotion-0.12.3}/examples/bulk/simple_example.py +0 -0
  41. {kinemotion-0.12.2 → kinemotion-0.12.3}/samples/cmjs/README.md +0 -0
  42. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/__init__.py +0 -0
  43. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/api.py +0 -0
  44. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cli.py +0 -0
  45. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/__init__.py +0 -0
  46. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/analysis.py +0 -0
  47. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/cli.py +0 -0
  48. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/debug_overlay.py +0 -0
  49. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/joint_angles.py +0 -0
  50. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/cmj/kinematics.py +0 -0
  51. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/__init__.py +0 -0
  52. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/cli_utils.py +0 -0
  53. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/debug_overlay_utils.py +0 -0
  54. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/filtering.py +0 -0
  55. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/pose.py +0 -0
  56. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/core/smoothing.py +0 -0
  57. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/dropjump/__init__.py +0 -0
  58. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/dropjump/debug_overlay.py +0 -0
  59. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/dropjump/kinematics.py +0 -0
  60. {kinemotion-0.12.2 → kinemotion-0.12.3}/src/kinemotion/py.typed +0 -0
  61. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/__init__.py +0 -0
  62. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_adaptive_threshold.py +0 -0
  63. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_api.py +0 -0
  64. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_aspect_ratio.py +0 -0
  65. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_cmj_analysis.py +0 -0
  66. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_cmj_kinematics.py +0 -0
  67. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_com_estimation.py +0 -0
  68. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_contact_detection.py +0 -0
  69. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_filtering.py +0 -0
  70. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_kinematics.py +0 -0
  71. {kinemotion-0.12.2 → kinemotion-0.12.3}/tests/test_polyorder.py +0 -0
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  <!-- version list -->
9
9
 
10
+ ## v0.12.3 (2025-11-06)
11
+
12
+ ### Bug Fixes
13
+
14
+ - Resolve SonarCloud cognitive complexity violations
15
+ ([`5b20c48`](https://github.com/feniix/kinemotion/commit/5b20c488e058ac3628b0e20847d3fe2539a687c4))
16
+
17
+ ### Refactoring
18
+
19
+ - **core**: Reduce cognitive complexity in video_io and auto_tuning
20
+ ([`14076fe`](https://github.com/feniix/kinemotion/commit/14076fe9d1f9b41ef2ff9bd643b17cf566e18654))
21
+
22
+ - **dropjump**: Add shared utility for foot position extraction
23
+ ([`5222cc4`](https://github.com/feniix/kinemotion/commit/5222cc471b9f4406116de0b7fc193f07d21cd88a))
24
+
25
+ - **dropjump**: Reduce cognitive complexity in CLI functions
26
+ ([`6fc887f`](https://github.com/feniix/kinemotion/commit/6fc887f6288e870a306aa1e3ffc7b8a46c21c3fc))
27
+
28
+ - **examples**: Simplify programmatic usage with shared utility
29
+ ([`5e1bc19`](https://github.com/feniix/kinemotion/commit/5e1bc194f5784a24cfcbc7e6372ebd26a95225aa))
30
+
31
+
10
32
  ## v0.12.2 (2025-11-06)
11
33
 
12
34
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kinemotion
3
- Version: 0.12.2
3
+ Version: 0.12.3
4
4
  Summary: Video-based kinematic analysis for athletic performance
5
5
  Project-URL: Homepage, https://github.com/feniix/kinemotion
6
6
  Project-URL: Repository, https://github.com/feniix/kinemotion
@@ -2,14 +2,12 @@
2
2
 
3
3
  from typing import Any
4
4
 
5
- import numpy as np
6
-
7
5
  from kinemotion.core.pose import PoseTracker
8
6
  from kinemotion.core.smoothing import smooth_landmarks
9
7
  from kinemotion.core.video_io import VideoProcessor
10
8
  from kinemotion.dropjump.analysis import (
11
- compute_average_foot_position,
12
9
  detect_ground_contact,
10
+ extract_foot_positions_and_visibilities,
13
11
  )
14
12
  from kinemotion.dropjump.kinematics import calculate_drop_jump_metrics
15
13
 
@@ -48,29 +46,8 @@ def analyze_video(video_path: str) -> dict[str, Any]:
48
46
  # Smooth landmarks
49
47
  smoothed = smooth_landmarks(landmarks_sequence, window_length=5)
50
48
 
51
- # Extract foot positions
52
- foot_positions_list: list[float] = []
53
- visibilities_list: list[float] = []
54
-
55
- for frame_landmarks in smoothed:
56
- if frame_landmarks:
57
- _, foot_y = compute_average_foot_position(frame_landmarks)
58
- foot_positions_list.append(foot_y)
59
-
60
- # Average foot visibility
61
- foot_vis = []
62
- for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
63
- if key in frame_landmarks:
64
- foot_vis.append(frame_landmarks[key][2])
65
- visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
66
- else:
67
- foot_positions_list.append(
68
- foot_positions_list[-1] if foot_positions_list else 0.5
69
- )
70
- visibilities_list.append(0.0)
71
-
72
- foot_positions: np.ndarray[Any, Any] = np.array(foot_positions_list)
73
- visibilities: np.ndarray[Any, Any] = np.array(visibilities_list)
49
+ # Extract foot positions and visibilities using shared utility
50
+ foot_positions, visibilities = extract_foot_positions_and_visibilities(smoothed)
74
51
 
75
52
  # Detect contact
76
53
  contact_states = detect_ground_contact(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kinemotion"
3
- version = "0.12.2"
3
+ version = "0.12.3"
4
4
  description = "Video-based kinematic analysis for athletic performance"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10,<3.13"
@@ -216,6 +216,59 @@ def auto_tune_parameters(
216
216
  )
217
217
 
218
218
 
219
+ def _collect_foot_visibility_and_positions(
220
+ frame_landmarks: dict[str, tuple[float, float, float]],
221
+ ) -> tuple[list[float], list[float]]:
222
+ """
223
+ Collect visibility scores and Y positions from foot landmarks.
224
+
225
+ Args:
226
+ frame_landmarks: Landmarks for a single frame
227
+
228
+ Returns:
229
+ Tuple of (visibility_scores, y_positions)
230
+ """
231
+ foot_keys = [
232
+ "left_ankle",
233
+ "right_ankle",
234
+ "left_heel",
235
+ "right_heel",
236
+ "left_foot_index",
237
+ "right_foot_index",
238
+ ]
239
+
240
+ frame_vis = []
241
+ frame_y_positions = []
242
+
243
+ for key in foot_keys:
244
+ if key in frame_landmarks:
245
+ _, y, vis = frame_landmarks[key] # x not needed for analysis
246
+ frame_vis.append(vis)
247
+ frame_y_positions.append(y)
248
+
249
+ return frame_vis, frame_y_positions
250
+
251
+
252
+ def _check_stable_period(positions: list[float]) -> bool:
253
+ """
254
+ Check if video has a stable period at the start.
255
+
256
+ A stable period (low variance in first 30 frames) indicates
257
+ the subject is standing on an elevated platform before jumping.
258
+
259
+ Args:
260
+ positions: List of average Y positions per frame
261
+
262
+ Returns:
263
+ True if stable period detected, False otherwise
264
+ """
265
+ if len(positions) < 30:
266
+ return False
267
+
268
+ first_30_std = float(np.std(positions[:30]))
269
+ return first_30_std < 0.01 # Very stable = on platform
270
+
271
+
219
272
  def analyze_video_sample(
220
273
  landmarks_sequence: list[dict[str, tuple[float, float, float]] | None],
221
274
  fps: float,
@@ -235,35 +288,22 @@ def analyze_video_sample(
235
288
  Returns:
236
289
  VideoCharacteristics with analyzed properties
237
290
  """
238
- # Calculate average landmark visibility
239
291
  visibilities = []
240
292
  positions = []
241
293
 
294
+ # Collect visibility and position data from all frames
242
295
  for frame_landmarks in landmarks_sequence:
243
- if frame_landmarks:
244
- # Collect visibility scores from foot landmarks
245
- foot_keys = [
246
- "left_ankle",
247
- "right_ankle",
248
- "left_heel",
249
- "right_heel",
250
- "left_foot_index",
251
- "right_foot_index",
252
- ]
253
-
254
- frame_vis = []
255
- frame_y_positions = []
256
-
257
- for key in foot_keys:
258
- if key in frame_landmarks:
259
- _, y, vis = frame_landmarks[key] # x not needed for analysis
260
- frame_vis.append(vis)
261
- frame_y_positions.append(y)
262
-
263
- if frame_vis:
264
- visibilities.append(float(np.mean(frame_vis)))
265
- if frame_y_positions:
266
- positions.append(float(np.mean(frame_y_positions)))
296
+ if not frame_landmarks:
297
+ continue
298
+
299
+ frame_vis, frame_y_positions = _collect_foot_visibility_and_positions(
300
+ frame_landmarks
301
+ )
302
+
303
+ if frame_vis:
304
+ visibilities.append(float(np.mean(frame_vis)))
305
+ if frame_y_positions:
306
+ positions.append(float(np.mean(frame_y_positions)))
267
307
 
268
308
  # Compute metrics
269
309
  avg_visibility = float(np.mean(visibilities)) if visibilities else 0.5
@@ -273,11 +313,7 @@ def analyze_video_sample(
273
313
  tracking_quality = analyze_tracking_quality(avg_visibility)
274
314
 
275
315
  # Check for stable period (indicates drop jump from elevated platform)
276
- # Simple check: do first 30 frames have low variance?
277
- has_stable_period = False
278
- if len(positions) >= 30:
279
- first_30_std = float(np.std(positions[:30]))
280
- has_stable_period = first_30_std < 0.01 # Very stable = on platform
316
+ has_stable_period = _check_stable_period(positions)
281
317
 
282
318
  return VideoCharacteristics(
283
319
  fps=fps,
@@ -65,6 +65,43 @@ class VideoProcessor:
65
65
  self.display_width,
66
66
  )
67
67
 
68
+ def _parse_sample_aspect_ratio(self, sar_str: str) -> None:
69
+ """
70
+ Parse SAR string and update display dimensions.
71
+
72
+ Args:
73
+ sar_str: SAR string in format "width:height" (e.g., "270:473")
74
+ """
75
+ if not sar_str or ":" not in sar_str:
76
+ return
77
+
78
+ sar_parts = sar_str.split(":")
79
+ sar_width = int(sar_parts[0])
80
+ sar_height = int(sar_parts[1])
81
+
82
+ # Calculate display dimensions if pixels are non-square
83
+ # DAR = (width * SAR_width) / (height * SAR_height)
84
+ if sar_width != sar_height:
85
+ self.display_width = int(self.width * sar_width / sar_height)
86
+ self.display_height = self.height
87
+
88
+ def _extract_rotation_from_stream(self, stream: dict) -> int: # type: ignore[type-arg]
89
+ """
90
+ Extract rotation metadata from video stream.
91
+
92
+ Args:
93
+ stream: ffprobe stream dictionary
94
+
95
+ Returns:
96
+ Rotation angle in degrees (0, 90, -90, 180)
97
+ """
98
+ side_data_list = stream.get("side_data_list", [])
99
+ for side_data in side_data_list:
100
+ if side_data.get("side_data_type") == "Display Matrix":
101
+ rotation = side_data.get("rotation", 0)
102
+ return int(rotation)
103
+ return 0
104
+
68
105
  def _extract_video_metadata(self) -> None:
69
106
  """
70
107
  Extract video metadata including SAR and rotation using ffprobe.
@@ -94,35 +131,22 @@ class VideoProcessor:
94
131
  timeout=5,
95
132
  )
96
133
 
97
- if result.returncode == 0:
98
- data = json.loads(result.stdout)
99
- if "streams" in data and len(data["streams"]) > 0:
100
- stream = data["streams"][0]
101
-
102
- # Extract SAR (Sample Aspect Ratio)
103
- sar_str = stream.get("sample_aspect_ratio", "1:1")
104
-
105
- # Parse SAR (e.g., "270:473")
106
- if sar_str and ":" in sar_str:
107
- sar_parts = sar_str.split(":")
108
- sar_width = int(sar_parts[0])
109
- sar_height = int(sar_parts[1])
110
-
111
- # Calculate display dimensions
112
- # DAR = (width * SAR_width) / (height * SAR_height)
113
- if sar_width != sar_height:
114
- self.display_width = int(
115
- self.width * sar_width / sar_height
116
- )
117
- self.display_height = self.height
118
-
119
- # Extract rotation from side_data_list (common for iPhone videos)
120
- side_data_list = stream.get("side_data_list", [])
121
- for side_data in side_data_list:
122
- if side_data.get("side_data_type") == "Display Matrix":
123
- rotation = side_data.get("rotation", 0)
124
- # Convert to int and normalize to 0, 90, -90, 180
125
- self.rotation = int(rotation)
134
+ if result.returncode != 0:
135
+ return
136
+
137
+ data = json.loads(result.stdout)
138
+ if "streams" not in data or len(data["streams"]) == 0:
139
+ return
140
+
141
+ stream = data["streams"][0]
142
+
143
+ # Extract and parse SAR (Sample Aspect Ratio)
144
+ sar_str = stream.get("sample_aspect_ratio", "1:1")
145
+ self._parse_sample_aspect_ratio(sar_str)
146
+
147
+ # Extract rotation from side_data_list (common for iPhone videos)
148
+ self.rotation = self._extract_rotation_from_stream(stream)
149
+
126
150
  except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
127
151
  # If ffprobe fails, keep original dimensions (square pixels)
128
152
  pass
@@ -752,3 +752,39 @@ def compute_average_foot_position(
752
752
  return (0.5, 0.5) # Default to center if no visible feet
753
753
 
754
754
  return (float(np.mean(x_positions)), float(np.mean(y_positions)))
755
+
756
+
757
+ def extract_foot_positions_and_visibilities(
758
+ smoothed_landmarks: list[dict[str, tuple[float, float, float]] | None],
759
+ ) -> tuple[np.ndarray, np.ndarray]:
760
+ """
761
+ Extract vertical positions and average visibilities from smoothed landmarks.
762
+
763
+ This utility function eliminates code duplication between CLI and programmatic usage.
764
+
765
+ Args:
766
+ smoothed_landmarks: Smoothed landmark sequence from tracking
767
+
768
+ Returns:
769
+ Tuple of (vertical_positions, visibilities) as numpy arrays
770
+ """
771
+ position_list: list[float] = []
772
+ visibilities_list: list[float] = []
773
+
774
+ for frame_landmarks in smoothed_landmarks:
775
+ if frame_landmarks:
776
+ _, foot_y = compute_average_foot_position(frame_landmarks)
777
+ position_list.append(foot_y)
778
+
779
+ # Average visibility of foot landmarks
780
+ foot_vis = []
781
+ for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
782
+ if key in frame_landmarks:
783
+ foot_vis.append(frame_landmarks[key][2])
784
+ visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
785
+ else:
786
+ # Fill missing frames with last known position or default
787
+ position_list.append(position_list[-1] if position_list else 0.5)
788
+ visibilities_list.append(0.0)
789
+
790
+ return np.array(position_list), np.array(visibilities_list)
@@ -28,8 +28,8 @@ from ..core.pose import PoseTracker
28
28
  from ..core.video_io import VideoProcessor
29
29
  from .analysis import (
30
30
  ContactState,
31
- compute_average_foot_position,
32
31
  detect_ground_contact,
32
+ extract_foot_positions_and_visibilities,
33
33
  )
34
34
  from .debug_overlay import DebugOverlayRenderer
35
35
  from .kinematics import DropJumpMetrics, calculate_drop_jump_metrics
@@ -258,26 +258,7 @@ def _extract_positions_and_visibilities(
258
258
  Tuple of (vertical_positions, visibilities)
259
259
  """
260
260
  click.echo("Extracting foot positions...", err=True)
261
-
262
- position_list: list[float] = []
263
- visibilities_list: list[float] = []
264
-
265
- for frame_landmarks in smoothed_landmarks:
266
- if frame_landmarks:
267
- _, foot_y = compute_average_foot_position(frame_landmarks)
268
- position_list.append(foot_y)
269
-
270
- # Average visibility of foot landmarks
271
- foot_vis = []
272
- for key in ["left_ankle", "right_ankle", "left_heel", "right_heel"]:
273
- if key in frame_landmarks:
274
- foot_vis.append(frame_landmarks[key][2])
275
- visibilities_list.append(float(np.mean(foot_vis)) if foot_vis else 0.0)
276
- else:
277
- position_list.append(position_list[-1] if position_list else 0.5)
278
- visibilities_list.append(0.0)
279
-
280
- return np.array(position_list), np.array(visibilities_list)
261
+ return extract_foot_positions_and_visibilities(smoothed_landmarks)
281
262
 
282
263
 
283
264
  def _create_debug_video(
@@ -567,6 +548,63 @@ def _compute_batch_statistics(results: list[VideoResult]) -> None:
567
548
  )
568
549
 
569
550
 
551
+ def _format_time_metric(value: float | None, multiplier: float = 1000.0) -> str:
552
+ """Format time metric for CSV output.
553
+
554
+ Args:
555
+ value: Time value in seconds
556
+ multiplier: Multiplier to convert to milliseconds (default: 1000.0)
557
+
558
+ Returns:
559
+ Formatted string or "N/A" if value is None
560
+ """
561
+ return f"{value * multiplier:.1f}" if value is not None else "N/A"
562
+
563
+
564
+ def _format_distance_metric(value: float | None) -> str:
565
+ """Format distance metric for CSV output.
566
+
567
+ Args:
568
+ value: Distance value in meters
569
+
570
+ Returns:
571
+ Formatted string or "N/A" if value is None
572
+ """
573
+ return f"{value:.3f}" if value is not None else "N/A"
574
+
575
+
576
+ def _create_csv_row_from_result(result: VideoResult) -> list[str]:
577
+ """Create CSV row from video processing result.
578
+
579
+ Args:
580
+ result: Video processing result
581
+
582
+ Returns:
583
+ List of formatted values for CSV row
584
+ """
585
+ video_name = Path(result.video_path).name
586
+ processing_time = f"{result.processing_time:.2f}"
587
+
588
+ if result.success and result.metrics:
589
+ return [
590
+ video_name,
591
+ _format_time_metric(result.metrics.ground_contact_time),
592
+ _format_time_metric(result.metrics.flight_time),
593
+ _format_distance_metric(result.metrics.jump_height),
594
+ processing_time,
595
+ "Success",
596
+ ]
597
+ else:
598
+ return [
599
+ video_name,
600
+ "N/A",
601
+ "N/A",
602
+ "N/A",
603
+ processing_time,
604
+ f"Failed: {result.error}",
605
+ ]
606
+
607
+
570
608
  def _write_csv_summary(
571
609
  csv_summary: str | None, results: list[VideoResult], successful: list[VideoResult]
572
610
  ) -> None:
@@ -600,40 +638,7 @@ def _write_csv_summary(
600
638
 
601
639
  # Data rows
602
640
  for result in results:
603
- if result.success and result.metrics:
604
- writer.writerow(
605
- [
606
- Path(result.video_path).name,
607
- (
608
- f"{result.metrics.ground_contact_time * 1000:.1f}"
609
- if result.metrics.ground_contact_time
610
- else "N/A"
611
- ),
612
- (
613
- f"{result.metrics.flight_time * 1000:.1f}"
614
- if result.metrics.flight_time
615
- else "N/A"
616
- ),
617
- (
618
- f"{result.metrics.jump_height:.3f}"
619
- if result.metrics.jump_height
620
- else "N/A"
621
- ),
622
- f"{result.processing_time:.2f}",
623
- "Success",
624
- ]
625
- )
626
- else:
627
- writer.writerow(
628
- [
629
- Path(result.video_path).name,
630
- "N/A",
631
- "N/A",
632
- "N/A",
633
- f"{result.processing_time:.2f}",
634
- f"Failed: {result.error}",
635
- ]
636
- )
641
+ writer.writerow(_create_csv_row_from_result(result))
637
642
 
638
643
  click.echo("CSV summary written successfully", err=True)
639
644
 
@@ -603,7 +603,7 @@ wheels = [
603
603
 
604
604
  [[package]]
605
605
  name = "kinemotion"
606
- version = "0.12.2"
606
+ version = "0.12.3"
607
607
  source = { editable = "." }
608
608
  dependencies = [
609
609
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes