Skip to content

Commit b310570

Browse files
authored
59 task write trails drawing feature functions calculate smoothness (#94)
* write function * unit test - less than 3 points * unit test - collinear lines * unit tests - one right angle * unit test - known result * unit tests - duplicate points * edits to original function and unit tests, added unit test for 180 degrees * remove unused import * ruff reformat * requested changes * rename arc_len
1 parent 7703e37 commit b310570

2 files changed

Lines changed: 111 additions & 1 deletion

File tree

src/graphomotor/features/trails/drawing_metrics.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Feature extraction module for drawing error-based metrics in trails drawing data."""
22

3+
import numpy as np
4+
import pandas as pd
5+
36
from graphomotor.core import models
47

58

@@ -35,9 +38,54 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]:
3538
raise ValueError(
3639
"DataFrame must contain 'correct_path' and 'actual_path' columns."
3740
)
38-
3941
return {
4042
"percent_accurate_paths": (
4143
(drawing.data["correct_path"] == drawing.data["actual_path"]).mean() * 100
4244
)
4345
}
46+
47+
48+
def calculate_smoothness(points: pd.DataFrame) -> float:
49+
"""Calculate path smoothness based on Root Mean Square (RMS) curvature.
50+
51+
Represents the curvature per unit arc length.
52+
Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns)
53+
and noisy corrections. Normalized by arc length to reduce sampling-rate dependence.
54+
55+
Args:
56+
points: DataFrame representing drawing points.
57+
58+
Returns:
59+
Smoothness metric as a float.
60+
"""
61+
if len(points) < 3:
62+
return 0.0
63+
64+
xy = points[["x", "y"]].to_numpy()
65+
66+
forward_vector = xy[1:-1] - xy[:-2]
67+
backward_vector = xy[2:] - xy[1:-1]
68+
69+
forward_norm = np.linalg.norm(forward_vector, axis=1)
70+
backward_norm = np.linalg.norm(backward_vector, axis=1)
71+
72+
valid = (forward_norm > 0) & (backward_norm > 0)
73+
if not np.any(valid):
74+
return 0.0
75+
76+
valid_forward_vector = forward_vector[valid]
77+
valid_backward_vector = backward_vector[valid]
78+
valid_forward_norm = forward_norm[valid]
79+
valid_backward_norm = backward_norm[valid]
80+
81+
cos_angle = (valid_forward_vector * valid_backward_vector).sum(axis=1) / (
82+
valid_forward_norm * valid_backward_norm
83+
)
84+
cos_angle = np.clip(cos_angle, -1.0, 1.0)
85+
86+
angles = np.arccos(cos_angle)
87+
88+
avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0
89+
curvatures = angles / avg_segment_length
90+
91+
return float(np.sqrt(np.mean(curvatures**2)))

tests/unit/test_trails_drawing_metrics.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test cases for drawing_metrics.py functions."""
22

3+
import numpy as np
34
import pandas as pd
45
import pytest
56

@@ -52,3 +53,64 @@ def test_percent_accurate_paths_sample_data() -> None:
5253

5354
result = drawing_metrics.percent_accurate_paths(drawing)
5455
assert result == {"percent_accurate_paths": 100.0}
56+
57+
58+
def test_smoothness_less_than_three_points() -> None:
59+
"""Less than 3 points cannot define curvature."""
60+
points = pd.DataFrame({"x": [0, 1], "y": [0, 1]})
61+
assert drawing_metrics.calculate_smoothness(points) == 0.0
62+
63+
64+
def test_smoothness_straight_line() -> None:
65+
"""Collinear points have zero curvature."""
66+
points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]})
67+
assert drawing_metrics.calculate_smoothness(points) == 0.0
68+
69+
70+
def test_smoothness_single_right_angle() -> None:
71+
"""A single 90-degree corner should produce a large smoothness value.
72+
73+
(non-zero), since sharp turns are penalized.
74+
"""
75+
points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]})
76+
expected = np.pi / 2
77+
smoothness = drawing_metrics.calculate_smoothness(points)
78+
assert np.isclose(smoothness, expected)
79+
80+
81+
def test_smoothness_varied_angles() -> None:
82+
"""Multiple angles should produce RMS curvature.
83+
84+
Path has a 90° turn followed by a 45° turn.
85+
"""
86+
points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]})
87+
c1 = np.pi / 2
88+
c2 = (np.pi / 4) / ((1 + np.sqrt(2)) / 2)
89+
expected = np.sqrt((c1**2 + c2**2) / 2)
90+
91+
smoothness = drawing_metrics.calculate_smoothness(points)
92+
93+
assert np.isclose(smoothness, expected)
94+
95+
96+
def test_smoothness_zero_length_segments() -> None:
97+
"""Zero-length segments should be skipped; no angles → smoothness 0."""
98+
points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]})
99+
smoothness = drawing_metrics.calculate_smoothness(points)
100+
assert smoothness == 0.0
101+
102+
103+
def test_smoothness_single_180_degree_turn() -> None:
104+
"""A single 180-degree turn should produce a very large smoothness value.
105+
106+
Since it represents maximal curvature.
107+
"""
108+
points = pd.DataFrame(
109+
{
110+
"x": [0, 1, 0],
111+
"y": [0, 0, 0],
112+
}
113+
)
114+
expected = np.pi
115+
smoothness = drawing_metrics.calculate_smoothness(points)
116+
assert np.isclose(smoothness, expected)

0 commit comments

Comments
 (0)