Skip to content

Commit e35e098

Browse files
committed
edits to original function and unit tests, added unit test for 180 degrees
1 parent 361289b commit e35e098

2 files changed

Lines changed: 54 additions & 26 deletions

File tree

src/graphomotor/features/trails/drawing_metrics.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import numpy as np
44
import pandas as pd
5+
from shapely import points
56

67
from graphomotor.core import models
78

@@ -35,11 +36,11 @@ def get_total_errors(drawing: models.Drawing) -> dict[str, float]:
3536

3637

3738
def calculate_smoothness(points: pd.DataFrame) -> float:
38-
"""Calculate path smoothness based on curvature changes.
39+
"""Calculate path smoothness based on RMS curvature.
3940
40-
Lower values indicate smoother paths. This function calculates angles between
41-
consecutive segments and returns the standard deviation of these angles as a
42-
measure of smoothness.
41+
Represants the curvature per unit arc length.
42+
Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns)
43+
and noisy corrections. Normalized by arc length to reduce sampling-rate dependence.
4344
4445
Args:
4546
points: DataFrame representing drawing points.
@@ -50,25 +51,29 @@ def calculate_smoothness(points: pd.DataFrame) -> float:
5051
if len(points) < 3:
5152
return 0.0
5253

53-
x = points["x"].values
54-
y = points["y"].values
54+
xy = points[["x", "y"]].to_numpy()
5555

56-
angles = []
57-
for i in range(1, len(x) - 1):
58-
v1 = np.array([x[i] - x[i - 1], y[i] - y[i - 1]])
59-
v2 = np.array([x[i + 1] - x[i], y[i + 1] - y[i]])
56+
v1 = xy[1:-1] - xy[:-2]
57+
v2 = xy[2:] - xy[1:-1]
6058

61-
if np.linalg.norm(v1) == 0 or np.linalg.norm(v2) == 0:
62-
continue
59+
l1 = np.linalg.norm(v1, axis=1)
60+
l2 = np.linalg.norm(v2, axis=1)
6361

64-
v1 = v1 / np.linalg.norm(v1)
65-
v2 = v2 / np.linalg.norm(v2)
62+
valid = (l1 > 0) & (l2 > 0)
63+
if not np.any(valid):
64+
return 0.0
6665

67-
cos_angle = np.clip(np.dot(v1, v2), -1.0, 1.0)
68-
angle = np.arccos(cos_angle)
69-
angles.append(angle)
66+
v1 = v1[valid]
67+
v2 = v2[valid]
68+
l1 = l1[valid]
69+
l2 = l2[valid]
7070

71-
if not angles:
72-
return 0.0
71+
cos_angle = np.einsum("ij,ij->i", v1, v2) / (l1 * l2)
72+
cos_angle = np.clip(cos_angle, -1.0, 1.0)
73+
74+
angles = np.arccos(cos_angle)
75+
76+
arc_len = (l1 + l2) / 2.0
77+
curvatures = angles / arc_len
7378

74-
return np.std(np.degrees(angles))
79+
return float(np.sqrt(np.mean(curvatures**2)))

tests/unit/test_trails_drawing_metrics.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,31 +49,40 @@ def test_valid_total_errors() -> None:
4949

5050

5151
def test_smoothness_less_than_three_points() -> None:
52-
"""Test smoothness calculation with less than three points."""
52+
"""Less than 3 points cannot define curvature."""
5353
points = pd.DataFrame({"x": [0, 1], "y": [0, 1]})
5454
assert drawing_metrics.calculate_smoothness(points) == 0.0
5555

5656

5757
def test_smoothness_straight_line() -> None:
58-
"""Collinear points should produce zero smoothness."""
58+
"""Collinear points have zero curvature."""
5959
points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]})
6060
assert drawing_metrics.calculate_smoothness(points) == 0.0
6161

6262

6363
def test_smoothness_single_right_angle() -> None:
64-
"""A single 90-degree turn should produce an std of 0."""
64+
"""A single 90-degree corner should produce a large smoothness value.
65+
66+
(non-zero), since sharp turns are penalized.
67+
"""
6568
points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]})
69+
expected = np.pi / 2
6670
smoothness = drawing_metrics.calculate_smoothness(points)
67-
assert smoothness == 0.0
71+
assert np.isclose(smoothness, expected)
6872

6973

7074
def test_smoothness_varied_angles() -> None:
71-
"""Test smoothness with two different angles to ensure std is computed correctly."""
75+
"""Multiple angles should produce RMS curvature.
76+
77+
Path has a 90° turn followed by a 45° turn.
78+
"""
7279
points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]})
80+
c1 = np.pi / 2
81+
c2 = (np.pi / 4) / ((1 + np.sqrt(2)) / 2)
82+
expected = np.sqrt((c1**2 + c2**2) / 2)
7383

7484
smoothness = drawing_metrics.calculate_smoothness(points)
7585

76-
expected = np.std([90, 45])
7786
assert np.isclose(smoothness, expected)
7887

7988

@@ -82,3 +91,17 @@ def test_smoothness_zero_length_segments() -> None:
8291
points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]})
8392
smoothness = drawing_metrics.calculate_smoothness(points)
8493
assert smoothness == 0.0
94+
95+
96+
def test_smoothness_single_180_degree_turn() -> None:
97+
"""A single 180-degree turn should produce a very large smoothness value.
98+
99+
Since it represents maximal curvature.
100+
"""
101+
points = pd.DataFrame({
102+
"x": [0, 1, 0],
103+
"y": [0, 0, 0],
104+
})
105+
expected = np.pi
106+
smoothness = drawing_metrics.calculate_smoothness(points)
107+
assert np.isclose(smoothness, expected)

0 commit comments

Comments
 (0)