kinemotion 0.18.1__tar.gz → 0.19.0__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.
- {kinemotion-0.18.1 → kinemotion-0.19.0}/CHANGELOG.md +21 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/PKG-INFO +10 -5
- {kinemotion-0.18.1 → kinemotion-0.19.0}/README.md +9 -4
- {kinemotion-0.18.1 → kinemotion-0.19.0}/pyproject.toml +1 -1
- kinemotion-0.19.0/tests/test_cmj_analysis.py +397 -0
- kinemotion-0.19.0/tests/test_joint_angles.py +613 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/uv.lock +1 -1
- kinemotion-0.18.1/tests/test_cmj_analysis.py +0 -158
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.dockerignore +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/pull_request_template.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/workflows/docs.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/workflows/release.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.github/workflows/test.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.gitignore +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.pre-commit-config.yaml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.readthedocs.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/.tool-versions +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/CLAUDE.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/CODE_OF_CONDUCT.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/CONTRIBUTING.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/Dockerfile +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/GEMINI.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/LICENSE +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/SECURITY.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/README.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/api/cmj.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/api/core.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/api/dropjump.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/api/overview.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/development/errors-findings.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/development/validation-plan.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/development/wallball-norep-detection.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/guides/bulk-processing.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/guides/camera-setup.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/guides/cmj-guide.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/index.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/reference/parameters.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/reference/pose-systems.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/research/sports-biomechanics-pose-estimation.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/technical/framerate.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/technical/imu-metadata.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/technical/real-time-analysis.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/technical/triple-extension.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/docs/translations/es/camera-setup.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/examples/bulk/README.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/examples/bulk/bulk_processing.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/examples/bulk/simple_example.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/examples/programmatic_usage.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/mkdocs.yml +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/requirements-docs.txt +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/samples/cmjs/README.md +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/sonar-project.properties +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/__init__.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/api.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cli.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/__init__.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/analysis.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/cli.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/debug_overlay.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/joint_angles.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/cmj/kinematics.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/__init__.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/auto_tuning.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/cli_utils.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/debug_overlay_utils.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/filtering.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/pose.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/smoothing.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/core/video_io.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/dropjump/__init__.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/dropjump/analysis.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/dropjump/cli.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/dropjump/debug_overlay.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/dropjump/kinematics.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/src/kinemotion/py.typed +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/__init__.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_adaptive_threshold.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_api.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_aspect_ratio.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_cli_imports.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_cmj_kinematics.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_com_estimation.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_contact_detection.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_filtering.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_kinematics.py +0 -0
- {kinemotion-0.18.1 → kinemotion-0.19.0}/tests/test_polyorder.py +0 -0
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
<!-- version list -->
|
|
9
9
|
|
|
10
|
+
## v0.19.0 (2025-11-10)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- Add comprehensive badge layout to README
|
|
15
|
+
([`e1e2ca3`](https://github.com/feniix/kinemotion/commit/e1e2ca38c67077092bfc1455acfbe8a424e5d4b4))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## v0.18.2 (2025-11-10)
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
- Ci build
|
|
23
|
+
([`5bbfc0f`](https://github.com/feniix/kinemotion/commit/5bbfc0fa610ff811e765dea2021602f09d02f9f8))
|
|
24
|
+
|
|
25
|
+
### Testing
|
|
26
|
+
|
|
27
|
+
- Add comprehensive test coverage for joint angles and CMJ analysis
|
|
28
|
+
([`815c9be`](https://github.com/feniix/kinemotion/commit/815c9be1019414acf61563312a5d58f6305a17a4))
|
|
29
|
+
|
|
30
|
+
|
|
10
31
|
## v0.18.1 (2025-11-10)
|
|
11
32
|
|
|
12
33
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kinemotion
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.0
|
|
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
|
|
@@ -30,13 +30,18 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
# Kinemotion
|
|
31
31
|
|
|
32
32
|
[](https://pypi.org/project/kinemotion/)
|
|
33
|
+
[](https://pypi.org/project/kinemotion/)
|
|
33
34
|
[](https://opensource.org/licenses/MIT)
|
|
34
|
-
|
|
35
|
-
[](https://github.com/feniix/kinemotion/actions)
|
|
37
|
+
[](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
|
|
38
|
+
[](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
|
|
39
|
+
|
|
36
40
|
[](https://github.com/astral-sh/ruff)
|
|
37
|
-
[](https://github.com/microsoft/pyright)
|
|
42
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
38
43
|
|
|
39
|
-
A video-based kinematic analysis tool for athletic performance. Analyzes vertical jump videos to estimate key performance metrics using MediaPipe pose tracking and advanced kinematics.
|
|
44
|
+
> A video-based kinematic analysis tool for athletic performance. Analyzes vertical jump videos to estimate key performance metrics using MediaPipe pose tracking and advanced kinematics.
|
|
40
45
|
|
|
41
46
|
**Supported jump types:**
|
|
42
47
|
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# Kinemotion
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/kinemotion/)
|
|
4
|
+
[](https://pypi.org/project/kinemotion/)
|
|
4
5
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
|
|
6
|
-
[](https://github.com/feniix/kinemotion/actions)
|
|
8
|
+
[](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
|
|
9
|
+
[](https://sonarcloud.io/summary/overall?id=feniix_kinemotion)
|
|
10
|
+
|
|
7
11
|
[](https://github.com/astral-sh/ruff)
|
|
8
|
-
[](https://github.com/microsoft/pyright)
|
|
13
|
+
[](https://github.com/pre-commit/pre-commit)
|
|
9
14
|
|
|
10
|
-
A video-based kinematic analysis tool for athletic performance. Analyzes vertical jump videos to estimate key performance metrics using MediaPipe pose tracking and advanced kinematics.
|
|
15
|
+
> A video-based kinematic analysis tool for athletic performance. Analyzes vertical jump videos to estimate key performance metrics using MediaPipe pose tracking and advanced kinematics.
|
|
11
16
|
|
|
12
17
|
**Supported jump types:**
|
|
13
18
|
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Tests for CMJ phase detection."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from kinemotion.cmj.analysis import (
|
|
6
|
+
detect_cmj_phases,
|
|
7
|
+
find_cmj_takeoff_from_velocity_peak,
|
|
8
|
+
find_countermovement_start,
|
|
9
|
+
find_lowest_point,
|
|
10
|
+
find_standing_phase,
|
|
11
|
+
interpolate_threshold_crossing,
|
|
12
|
+
refine_transition_with_curvature,
|
|
13
|
+
)
|
|
14
|
+
from kinemotion.core.smoothing import compute_velocity_from_derivative
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_find_standing_phase() -> None:
|
|
18
|
+
"""Test standing phase detection."""
|
|
19
|
+
# Create trajectory with clear standing period followed by consistent downward motion
|
|
20
|
+
fps = 30.0
|
|
21
|
+
|
|
22
|
+
# Standing (0-30): constant position
|
|
23
|
+
# Transition (30-35): very slow movement
|
|
24
|
+
# Movement (35-100): clear downward motion
|
|
25
|
+
positions = np.concatenate(
|
|
26
|
+
[
|
|
27
|
+
np.ones(30) * 0.5, # Standing
|
|
28
|
+
np.linspace(0.5, 0.51, 5), # Slow transition
|
|
29
|
+
np.linspace(0.51, 0.7, 65), # Clear movement
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
velocities = compute_velocity_from_derivative(
|
|
34
|
+
positions, window_length=5, polyorder=2
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
standing_end = find_standing_phase(
|
|
38
|
+
positions, velocities, fps, min_standing_duration=0.5, velocity_threshold=0.005
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Should detect standing phase (or may return None if no clear transition)
|
|
42
|
+
# This test verifies the function runs without error
|
|
43
|
+
if standing_end is not None:
|
|
44
|
+
assert 15 <= standing_end <= 40 # Allow wider tolerance
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_find_countermovement_start() -> None:
|
|
48
|
+
"""Test countermovement start detection."""
|
|
49
|
+
# Create trajectory with clear and fast downward motion
|
|
50
|
+
positions = np.concatenate(
|
|
51
|
+
[
|
|
52
|
+
np.ones(30) * 0.5, # Standing
|
|
53
|
+
np.linspace(0.5, 0.8, 30), # Fast downward (eccentric)
|
|
54
|
+
np.linspace(0.8, 0.5, 30), # Upward (concentric)
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
velocities = compute_velocity_from_derivative(
|
|
59
|
+
positions, window_length=5, polyorder=2
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
eccentric_start = find_countermovement_start(
|
|
63
|
+
velocities,
|
|
64
|
+
countermovement_threshold=-0.008, # More lenient threshold for test
|
|
65
|
+
min_eccentric_frames=3,
|
|
66
|
+
standing_start=30,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Should detect eccentric start (or may return None depending on smoothing)
|
|
70
|
+
# This test verifies the function runs without error
|
|
71
|
+
if eccentric_start is not None:
|
|
72
|
+
assert 25 <= eccentric_start <= 40
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_find_lowest_point() -> None:
|
|
76
|
+
"""Test lowest point detection."""
|
|
77
|
+
# Create trajectory with clear lowest point
|
|
78
|
+
positions = np.concatenate(
|
|
79
|
+
[
|
|
80
|
+
np.linspace(0.5, 0.7, 50), # Downward
|
|
81
|
+
np.linspace(0.7, 0.4, 50), # Upward
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
from kinemotion.cmj.analysis import compute_signed_velocity
|
|
86
|
+
|
|
87
|
+
velocities = compute_signed_velocity(positions, window_length=5, polyorder=2)
|
|
88
|
+
|
|
89
|
+
# New algorithm searches with min_search_frame=80 by default
|
|
90
|
+
# For this short test, use min_search_frame=0
|
|
91
|
+
lowest = find_lowest_point(positions, velocities, min_search_frame=0)
|
|
92
|
+
|
|
93
|
+
# Should detect lowest point around frame 50 (with new algorithm may vary)
|
|
94
|
+
assert 30 <= lowest <= 70 # Wider tolerance for new algorithm
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_detect_cmj_phases_full() -> None:
|
|
98
|
+
"""Test complete CMJ phase detection."""
|
|
99
|
+
# Create realistic CMJ trajectory with pronounced movements
|
|
100
|
+
positions = np.concatenate(
|
|
101
|
+
[
|
|
102
|
+
np.ones(20) * 0.5, # Standing
|
|
103
|
+
np.linspace(0.5, 0.8, 40), # Eccentric (deeper countermovement)
|
|
104
|
+
np.linspace(0.8, 0.4, 40), # Concentric (push up)
|
|
105
|
+
np.linspace(0.4, 0.2, 30), # Flight (clear airborne phase)
|
|
106
|
+
np.linspace(0.2, 0.5, 10), # Landing (return to ground)
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
fps = 30.0
|
|
111
|
+
|
|
112
|
+
result = detect_cmj_phases(
|
|
113
|
+
positions,
|
|
114
|
+
fps,
|
|
115
|
+
window_length=5,
|
|
116
|
+
polyorder=2,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert result is not None
|
|
120
|
+
_, lowest_point, takeoff, landing = result
|
|
121
|
+
|
|
122
|
+
# Verify phases are in correct order
|
|
123
|
+
assert lowest_point < takeoff
|
|
124
|
+
assert takeoff < landing
|
|
125
|
+
|
|
126
|
+
# Verify phases are detected (with wide tolerances for synthetic data)
|
|
127
|
+
# New algorithm works backward from peak, so lowest point may be later
|
|
128
|
+
assert 0 <= lowest_point <= 140 # Lowest point found
|
|
129
|
+
assert 40 <= takeoff <= 140 # Takeoff detected
|
|
130
|
+
assert 80 <= landing <= 150 # Landing after takeoff
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_cmj_phases_without_standing() -> None:
|
|
134
|
+
"""Test CMJ phase detection when no standing phase exists."""
|
|
135
|
+
# Create trajectory starting directly with countermovement (more pronounced)
|
|
136
|
+
# Add a very short standing period to help detection
|
|
137
|
+
positions = np.concatenate(
|
|
138
|
+
[
|
|
139
|
+
np.ones(5) * 0.5, # Brief start
|
|
140
|
+
np.linspace(0.5, 0.9, 40), # Eccentric (very deep)
|
|
141
|
+
np.linspace(0.9, 0.3, 50), # Concentric (strong push)
|
|
142
|
+
np.linspace(0.3, 0.1, 30), # Flight (very clear)
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
fps = 30.0
|
|
147
|
+
|
|
148
|
+
result = detect_cmj_phases(
|
|
149
|
+
positions,
|
|
150
|
+
fps,
|
|
151
|
+
window_length=5,
|
|
152
|
+
polyorder=2,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Result may be None with synthetic data - that's okay for this test
|
|
156
|
+
# The main goal is to verify the function handles edge cases without crashing
|
|
157
|
+
if result is not None:
|
|
158
|
+
_, lowest_point, takeoff, landing = result
|
|
159
|
+
# Basic sanity checks if phases were detected
|
|
160
|
+
assert lowest_point < takeoff
|
|
161
|
+
assert takeoff < landing
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_interpolate_threshold_crossing_normal() -> None:
|
|
165
|
+
"""Test interpolate_threshold_crossing with normal interpolation."""
|
|
166
|
+
# Velocity increases from 0.1 to 0.3, threshold at 0.2
|
|
167
|
+
vel_before = 0.1
|
|
168
|
+
vel_after = 0.3
|
|
169
|
+
threshold = 0.2
|
|
170
|
+
|
|
171
|
+
offset = interpolate_threshold_crossing(vel_before, vel_after, threshold)
|
|
172
|
+
|
|
173
|
+
# Should be 0.5 (halfway between 0.1 and 0.3)
|
|
174
|
+
assert abs(offset - 0.5) < 0.01
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_interpolate_threshold_crossing_edge_case_no_change() -> None:
|
|
178
|
+
"""Test interpolate_threshold_crossing when velocity is not changing."""
|
|
179
|
+
# Velocity same at both frames
|
|
180
|
+
vel_before = 0.5
|
|
181
|
+
vel_after = 0.5
|
|
182
|
+
threshold = 0.5
|
|
183
|
+
|
|
184
|
+
offset = interpolate_threshold_crossing(vel_before, vel_after, threshold)
|
|
185
|
+
|
|
186
|
+
# Should return 0.5 when velocity not changing
|
|
187
|
+
assert offset == 0.5
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_interpolate_threshold_crossing_clamp_below_zero() -> None:
|
|
191
|
+
"""Test interpolate_threshold_crossing clamps to [0, 1] range."""
|
|
192
|
+
# Threshold below vel_before (would give negative t)
|
|
193
|
+
vel_before = 0.5
|
|
194
|
+
vel_after = 0.8
|
|
195
|
+
threshold = 0.3 # Below vel_before
|
|
196
|
+
|
|
197
|
+
offset = interpolate_threshold_crossing(vel_before, vel_after, threshold)
|
|
198
|
+
|
|
199
|
+
# Should clamp to 0.0
|
|
200
|
+
assert offset == 0.0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_interpolate_threshold_crossing_clamp_above_one() -> None:
|
|
204
|
+
"""Test interpolate_threshold_crossing clamps to [0, 1] range."""
|
|
205
|
+
# Threshold above vel_after (would give t > 1)
|
|
206
|
+
vel_before = 0.2
|
|
207
|
+
vel_after = 0.5
|
|
208
|
+
threshold = 0.9 # Above vel_after
|
|
209
|
+
|
|
210
|
+
offset = interpolate_threshold_crossing(vel_before, vel_after, threshold)
|
|
211
|
+
|
|
212
|
+
# Should clamp to 1.0
|
|
213
|
+
assert offset == 1.0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_interpolate_threshold_crossing_at_boundary() -> None:
|
|
217
|
+
"""Test interpolate_threshold_crossing when threshold equals velocity."""
|
|
218
|
+
vel_before = 0.1
|
|
219
|
+
vel_after = 0.5
|
|
220
|
+
threshold = 0.1 # Exactly at vel_before
|
|
221
|
+
|
|
222
|
+
offset = interpolate_threshold_crossing(vel_before, vel_after, threshold)
|
|
223
|
+
|
|
224
|
+
# Should be 0.0 (at start)
|
|
225
|
+
assert offset == 0.0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_refine_transition_with_curvature_landing() -> None:
|
|
229
|
+
"""Test refine_transition_with_curvature for landing detection."""
|
|
230
|
+
# Create position data with clear impact spike
|
|
231
|
+
positions = np.concatenate(
|
|
232
|
+
[
|
|
233
|
+
np.linspace(0.3, 0.5, 20), # Falling
|
|
234
|
+
np.array([0.5, 0.52, 0.54, 0.55, 0.55]), # Impact
|
|
235
|
+
np.ones(10) * 0.55, # Stable
|
|
236
|
+
]
|
|
237
|
+
)
|
|
238
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
239
|
+
initial_frame = 20 # Around impact
|
|
240
|
+
|
|
241
|
+
result = refine_transition_with_curvature(
|
|
242
|
+
positions, velocities, initial_frame, transition_type="landing", search_radius=5
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Should refine near the impact point (blend of curvature and initial)
|
|
246
|
+
assert isinstance(result, float)
|
|
247
|
+
assert 15 <= result <= 25
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_refine_transition_with_curvature_takeoff() -> None:
|
|
251
|
+
"""Test refine_transition_with_curvature for takeoff detection."""
|
|
252
|
+
# Create position data with acceleration change at takeoff
|
|
253
|
+
positions = np.concatenate(
|
|
254
|
+
[
|
|
255
|
+
np.ones(15) * 0.5, # Static
|
|
256
|
+
np.array([0.5, 0.48, 0.45, 0.40, 0.35]), # Accelerating upward
|
|
257
|
+
np.linspace(0.35, 0.2, 10), # Flight
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
261
|
+
initial_frame = 15 # Around takeoff
|
|
262
|
+
|
|
263
|
+
result = refine_transition_with_curvature(
|
|
264
|
+
positions, velocities, initial_frame, transition_type="takeoff", search_radius=5
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Should refine near the takeoff point
|
|
268
|
+
assert isinstance(result, float)
|
|
269
|
+
assert 12 <= result <= 20
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_refine_transition_with_curvature_empty_search_window() -> None:
|
|
273
|
+
"""Test refine_transition_with_curvature with empty search window."""
|
|
274
|
+
positions = np.linspace(0.5, 0.3, 10)
|
|
275
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
276
|
+
initial_frame = 0 # At boundary
|
|
277
|
+
search_radius = 0 # No search radius
|
|
278
|
+
|
|
279
|
+
result = refine_transition_with_curvature(
|
|
280
|
+
positions,
|
|
281
|
+
velocities,
|
|
282
|
+
initial_frame,
|
|
283
|
+
transition_type="landing",
|
|
284
|
+
search_radius=search_radius,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Should return initial frame when search window is empty
|
|
288
|
+
assert result == float(initial_frame)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_refine_transition_with_curvature_invalid_type() -> None:
|
|
292
|
+
"""Test refine_transition_with_curvature with invalid transition type."""
|
|
293
|
+
positions = np.linspace(0.5, 0.3, 20)
|
|
294
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
295
|
+
initial_frame = 10
|
|
296
|
+
|
|
297
|
+
result = refine_transition_with_curvature(
|
|
298
|
+
positions,
|
|
299
|
+
velocities,
|
|
300
|
+
initial_frame,
|
|
301
|
+
transition_type="invalid", # Invalid type
|
|
302
|
+
search_radius=5,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Should return initial frame for invalid type
|
|
306
|
+
assert result == float(initial_frame)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_refine_transition_with_curvature_takeoff_empty_accel_change() -> None:
|
|
310
|
+
"""Test refine_transition_with_curvature takeoff with very small search window."""
|
|
311
|
+
# Create minimal data that results in empty acceleration change
|
|
312
|
+
positions = np.linspace(0.5, 0.4, 10)
|
|
313
|
+
velocities = np.diff(positions, prepend=positions[0])
|
|
314
|
+
initial_frame = 5
|
|
315
|
+
search_radius = 0 # Will create search window with just 1 element
|
|
316
|
+
|
|
317
|
+
result = refine_transition_with_curvature(
|
|
318
|
+
positions,
|
|
319
|
+
velocities,
|
|
320
|
+
initial_frame,
|
|
321
|
+
transition_type="takeoff",
|
|
322
|
+
search_radius=search_radius,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Should handle empty accel_change gracefully
|
|
326
|
+
assert isinstance(result, float)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_find_cmj_takeoff_from_velocity_peak_normal() -> None:
|
|
330
|
+
"""Test find_cmj_takeoff_from_velocity_peak with clear peak."""
|
|
331
|
+
# Create velocity data with clear upward peak (most negative)
|
|
332
|
+
positions = np.linspace(0.7, 0.3, 50) # Dummy positions
|
|
333
|
+
velocities = np.concatenate(
|
|
334
|
+
[
|
|
335
|
+
np.linspace(-0.01, -0.05, 10), # Accelerating upward
|
|
336
|
+
np.array([-0.08, -0.10, -0.09, -0.06]), # Peak at index 11
|
|
337
|
+
np.linspace(-0.05, -0.01, 10), # Decelerating
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
lowest_point_frame = 0
|
|
341
|
+
fps = 30.0
|
|
342
|
+
|
|
343
|
+
result = find_cmj_takeoff_from_velocity_peak(
|
|
344
|
+
positions, velocities, lowest_point_frame, fps
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Should find the peak around frame 11
|
|
348
|
+
assert isinstance(result, float)
|
|
349
|
+
assert 8 <= result <= 15
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_find_cmj_takeoff_from_velocity_peak_search_window_too_short() -> None:
|
|
353
|
+
"""Test find_cmj_takeoff_from_velocity_peak with search window at boundary."""
|
|
354
|
+
positions = np.linspace(0.5, 0.3, 10)
|
|
355
|
+
velocities = np.linspace(-0.01, -0.05, 10)
|
|
356
|
+
lowest_point_frame = 10 # Beyond array length
|
|
357
|
+
fps = 30.0
|
|
358
|
+
|
|
359
|
+
result = find_cmj_takeoff_from_velocity_peak(
|
|
360
|
+
positions, velocities, lowest_point_frame, fps
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Should return lowest_point_frame + 1 when search window too short
|
|
364
|
+
assert result == float(lowest_point_frame + 1)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_find_cmj_takeoff_from_velocity_peak_at_start() -> None:
|
|
368
|
+
"""Test find_cmj_takeoff_from_velocity_peak with peak at start of search."""
|
|
369
|
+
positions = np.linspace(0.5, 0.3, 30)
|
|
370
|
+
# Peak velocity right at the start
|
|
371
|
+
velocities = np.concatenate([np.array([-0.10]), np.linspace(-0.05, -0.01, 29)])
|
|
372
|
+
lowest_point_frame = 0
|
|
373
|
+
fps = 30.0
|
|
374
|
+
|
|
375
|
+
result = find_cmj_takeoff_from_velocity_peak(
|
|
376
|
+
positions, velocities, lowest_point_frame, fps
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Should find peak at or near frame 0
|
|
380
|
+
assert isinstance(result, float)
|
|
381
|
+
assert 0 <= result <= 3
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_find_cmj_takeoff_from_velocity_peak_constant_velocity() -> None:
|
|
385
|
+
"""Test find_cmj_takeoff_from_velocity_peak with constant velocity."""
|
|
386
|
+
positions = np.linspace(0.5, 0.3, 30)
|
|
387
|
+
velocities = np.ones(30) * -0.05 # Constant velocity
|
|
388
|
+
lowest_point_frame = 5
|
|
389
|
+
fps = 30.0
|
|
390
|
+
|
|
391
|
+
result = find_cmj_takeoff_from_velocity_peak(
|
|
392
|
+
positions, velocities, lowest_point_frame, fps
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Should find first frame (argmin of constant array returns 0)
|
|
396
|
+
assert isinstance(result, float)
|
|
397
|
+
assert result == float(lowest_point_frame)
|