File size: 6,074 Bytes
4948993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""Tests for the movement-analysis engine β€” no GPU, no model downloads."""
import math
import numpy as np

from formscout.types import Pose2DResult


def _pose_from(traj_by_joint, n, base_conf=0.9):
    """Build a Pose2DResult; traj_by_joint maps joint->callable(i)->(x,y)."""
    kps = []
    for i in range(n):
        frame = {}
        for j in range(17):
            if j in traj_by_joint:
                x, y = traj_by_joint[j](i)
            else:
                x, y = float(100 + j * 5), float(100 + j * 5)
            frame[j] = {"x": float(x), "y": float(y), "conf": base_conf}
        kps.append(frame)
    return Pose2DResult(keypoints=kps, fps=30.0, confidence=0.9)


# ── relevant_joints ────────────────────────────────────────────────────────────

def test_relevant_joints_and_primary_consistent():
    from formscout.analysis import relevant_joints as RJ
    for test in RJ.RELEVANT:
        joints = RJ.relevant_joints(test)
        angles = RJ.relevant_angles(test)
        prim = RJ.primary_angle(test)
        assert joints, f"{test} has no relevant joints"
        assert prim in angles, f"{test} primary angle not in angles"


def test_openness_label_monotonic():
    from formscout.analysis.relevant_joints import openness_label
    assert "open" in openness_label(175)
    assert openness_label(30).endswith("closed")


# ── timeseries ──────────────────────────────────────────────────────────────────

def test_angle_series_length_matches_frames():
    from formscout.analysis.timeseries import angle_series
    pose = _pose_from({}, n=8)
    series = angle_series(pose, "deep_squat")
    assert "left_knee_flexion" in series
    for name, vals in series.items():
        assert len(vals) == 8


def test_relevant_flexion_reports_degrees_and_openness():
    from formscout.analysis.timeseries import relevant_flexion_at
    # Straight leg: hip, knee, ankle collinear vertical β†’ ~180Β°
    straight = {
        11: lambda i: (200, 100), 13: lambda i: (200, 200), 15: lambda i: (200, 300),
        12: lambda i: (260, 100), 14: lambda i: (260, 200), 16: lambda i: (260, 300),
        5: lambda i: (200, 40), 6: lambda i: (260, 40),
    }
    pose = _pose_from(straight, n=5)
    flex = relevant_flexion_at(pose, "deep_squat", 2)
    assert "left_knee_flexion" in flex
    assert flex["left_knee_flexion"]["deg"] > 160
    assert "open" in flex["left_knee_flexion"]["openness"]


# ── laban ───────────────────────────────────────────────────────────────────────

def test_laban_factors_in_unit_range():
    from formscout.analysis.laban import compute_laban
    pose = _pose_from({13: lambda i: (100 + i * 8, 200)}, n=20)
    res = compute_laban(pose, "deep_squat", fps=30.0)
    for k, v in res["effort"].items():
        assert 0.0 <= v <= 1.0, f"{k}={v} out of range"
    assert set(res["labels"]) == {"space", "weight", "time", "flow"}


def test_laban_straight_line_is_direct():
    from formscout.analysis.laban import compute_laban
    # Knee travels in a straight horizontal line β†’ high directness (Space)
    pose = _pose_from({13: lambda i: (100 + i * 10, 200)}, n=20)
    res = compute_laban(pose, "deep_squat", fps=30.0)
    assert res["effort"]["space"] > 0.8


def test_laban_static_clip_low_energy():
    from formscout.analysis.laban import compute_laban
    pose = _pose_from({13: lambda i: (200, 200)}, n=20)  # no motion
    res = compute_laban(pose, "deep_squat", fps=30.0)
    assert res["effort"]["weight"] < 0.2


# ── charts ──────────────────────────────────────────────────────────────────────

def test_angle_over_time_chart(tmp_path):
    from formscout.analysis import charts
    out = str(tmp_path / "angle.png")
    series = {"left_knee_flexion": [90, 100, 110, 95], "right_knee_flexion": [88, 99, 108, 94]}
    p = charts.angle_over_time(series, "left_knee_flexion", 2, out)
    assert p == out
    import os
    assert os.path.getsize(out) > 0


def test_velocity_profile_chart(tmp_path):
    from formscout.analysis import charts
    out = str(tmp_path / "vel.png")
    kps = [{j: {"x": 100 + j + i * 3, "y": 100 + j, "conf": 0.9} for j in range(17)} for i in range(10)]
    p = charts.velocity_profile(kps, 30.0, [13, 14, 11, 12], out)
    import os
    assert p == out and os.path.getsize(out) > 0


def test_laban_radar_chart(tmp_path):
    from formscout.analysis import charts
    out = str(tmp_path / "radar.png")
    p = charts.laban_radar({"space": 0.8, "weight": 0.4, "time": 0.6, "flow": 0.3}, out)
    import os
    assert p == out and os.path.getsize(out) > 0


def test_flexion_bars_chart(tmp_path):
    from formscout.analysis import charts
    out = str(tmp_path / "flex.png")
    flex = {"left_knee_flexion": {"deg": 95.0, "openness": "flexed"},
            "left_hip_flexion": {"deg": 120.0, "openness": "mid-range"}}
    p = charts.flexion_bars(flex, out)
    import os
    assert p == out and os.path.getsize(out) > 0


def test_symmetry_bars_chart(tmp_path):
    from formscout.analysis import charts
    out = str(tmp_path / "sym.png")
    asym = [{"test": "hurdle_step", "left_score": 2, "right_score": 3, "delta": 1}]
    p = charts.symmetry_bars(asym, out)
    import os
    assert p == out and os.path.getsize(out) > 0


def test_charts_return_none_on_empty(tmp_path):
    from formscout.analysis import charts
    assert charts.angle_over_time({}, None, None, str(tmp_path / "a.png")) is None
    assert charts.flexion_bars({}, str(tmp_path / "f.png")) is None
    assert charts.symmetry_bars([], str(tmp_path / "s.png")) is None