From fc25496ce16a95f70c50a7a6c9ae6c0d1f6eaaa9 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:36:54 -0500 Subject: [PATCH 01/11] write function --- .../features/trails/drawing_metrics.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 5e2fa76..dcd332c 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -1,5 +1,8 @@ """Feature extraction module for drawing error-based metrics in trails drawing data.""" +import numpy as np +import pandas as pd + from graphomotor.core import models @@ -29,3 +32,43 @@ def get_total_errors(drawing: models.Drawing) -> dict[str, float]: "Drawing data does not contain 'total_number_of_errors' column." ) return {"total_errors": drawing.data["total_number_of_errors"].iloc[0]} + + +def calculate_smoothness(points: pd.DataFrame) -> float: + """Calculate path smoothness based on curvature changes. + + Lower values indicate smoother paths. This function calculates angles between + consecutive segments and returns the standard deviation of these angles as a + measure of smoothness. + + Args: + points: DataFrame representing drawing points. + + Returns: + Smoothness metric as a float. + """ + if len(points) < 3: + return 0.0 + + x = points["x"].values + y = points["y"].values + + angles = [] + for i in range(1, len(x) - 1): + v1 = np.array([x[i] - x[i - 1], y[i] - y[i - 1]]) + v2 = np.array([x[i + 1] - x[i], y[i + 1] - y[i]]) + + if np.linalg.norm(v1) == 0 or np.linalg.norm(v2) == 0: + continue + + v1 = v1 / np.linalg.norm(v1) + v2 = v2 / np.linalg.norm(v2) + + cos_angle = np.clip(np.dot(v1, v2), -1.0, 1.0) + angle = np.arccos(cos_angle) + angles.append(angle) + + if not angles: + return 0.0 + + return np.std(np.degrees(angles)) From 4169961669ff3211a10a8bd925c77fb5731fe68d Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:38:22 -0500 Subject: [PATCH 02/11] unit test - less than 3 points --- tests/unit/test_trails_drawing_metrics.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 56718c4..1bd7b00 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -45,3 +45,9 @@ def test_valid_total_errors() -> None: result = drawing_metrics.get_total_errors(drawing) assert result == {"total_errors": 1.0} + + +def test_smoothness_less_than_three_points() -> None: + """Test smoothness calculation with less than three points.""" + points = pd.DataFrame({"x": [0, 1], "y": [0, 1]}) + assert drawing_metrics.calculate_smoothness(points) == 0.0 From 196cfa8ea4f41cca5bff37753c4053ebaac66dd2 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:39:06 -0500 Subject: [PATCH 03/11] unit test - collinear lines --- tests/unit/test_trails_drawing_metrics.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 1bd7b00..f4bea75 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -51,3 +51,9 @@ def test_smoothness_less_than_three_points() -> None: """Test smoothness calculation with less than three points.""" points = pd.DataFrame({"x": [0, 1], "y": [0, 1]}) assert drawing_metrics.calculate_smoothness(points) == 0.0 + + +def test_smoothness_straight_line() -> None: + """Collinear points should produce zero smoothness.""" + points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]}) + assert drawing_metrics.calculate_smoothness(points) == 0.0 From 6fc32103fdfcc07ce47b4730e18c5c88b7d7d707 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:40:21 -0500 Subject: [PATCH 04/11] unit tests - one right angle --- tests/unit/test_trails_drawing_metrics.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index f4bea75..0feb0f8 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -57,3 +57,10 @@ def test_smoothness_straight_line() -> None: """Collinear points should produce zero smoothness.""" points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]}) assert drawing_metrics.calculate_smoothness(points) == 0.0 + + +def test_smoothness_single_right_angle() -> None: + """A single 90-degree turn should produce an std of 0.""" + points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]}) + smoothness = drawing_metrics.calculate_smoothness(points) + assert smoothness == 0.0 From 2c90b16c69850753eebb2f6117e2fbae2b18968f Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:43:02 -0500 Subject: [PATCH 05/11] unit test - known result --- tests/unit/test_trails_drawing_metrics.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 0feb0f8..ee4a25e 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -1,5 +1,6 @@ """Test cases for drawing_metrics.py functions.""" +import numpy as np import pandas as pd import pytest @@ -64,3 +65,13 @@ def test_smoothness_single_right_angle() -> None: points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]}) smoothness = drawing_metrics.calculate_smoothness(points) assert smoothness == 0.0 + + +def test_smoothness_varied_angles() -> None: + """Test smoothness with two different angles to ensure std is computed correctly.""" + points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]}) + + smoothness = drawing_metrics.calculate_smoothness(points) + + expected = np.std([90, 45]) + assert np.isclose(smoothness, expected) From 361289b9649223fc7ef699ec2ba839983d0a1c0e Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Tue, 9 Dec 2025 10:44:02 -0500 Subject: [PATCH 06/11] unit tests - duplicate points --- tests/unit/test_trails_drawing_metrics.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index ee4a25e..79e5757 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -75,3 +75,10 @@ def test_smoothness_varied_angles() -> None: expected = np.std([90, 45]) assert np.isclose(smoothness, expected) + + +def test_smoothness_zero_length_segments() -> None: + """Zero-length segments should be skipped; no angles → smoothness 0.""" + points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]}) + smoothness = drawing_metrics.calculate_smoothness(points) + assert smoothness == 0.0 From e35e098ed92f25a0306c4e4964a4aab149ab7550 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 15 Jan 2026 11:35:29 -0500 Subject: [PATCH 07/11] edits to original function and unit tests, added unit test for 180 degrees --- .../features/trails/drawing_metrics.py | 45 ++++++++++--------- tests/unit/test_trails_drawing_metrics.py | 35 ++++++++++++--- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index dcd332c..b70deae 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +from shapely import points from graphomotor.core import models @@ -35,11 +36,11 @@ def get_total_errors(drawing: models.Drawing) -> dict[str, float]: def calculate_smoothness(points: pd.DataFrame) -> float: - """Calculate path smoothness based on curvature changes. + """Calculate path smoothness based on RMS curvature. - Lower values indicate smoother paths. This function calculates angles between - consecutive segments and returns the standard deviation of these angles as a - measure of smoothness. + Represants the curvature per unit arc length. + Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) + and noisy corrections. Normalized by arc length to reduce sampling-rate dependence. Args: points: DataFrame representing drawing points. @@ -50,25 +51,29 @@ def calculate_smoothness(points: pd.DataFrame) -> float: if len(points) < 3: return 0.0 - x = points["x"].values - y = points["y"].values + xy = points[["x", "y"]].to_numpy() - angles = [] - for i in range(1, len(x) - 1): - v1 = np.array([x[i] - x[i - 1], y[i] - y[i - 1]]) - v2 = np.array([x[i + 1] - x[i], y[i + 1] - y[i]]) + v1 = xy[1:-1] - xy[:-2] + v2 = xy[2:] - xy[1:-1] - if np.linalg.norm(v1) == 0 or np.linalg.norm(v2) == 0: - continue + l1 = np.linalg.norm(v1, axis=1) + l2 = np.linalg.norm(v2, axis=1) - v1 = v1 / np.linalg.norm(v1) - v2 = v2 / np.linalg.norm(v2) + valid = (l1 > 0) & (l2 > 0) + if not np.any(valid): + return 0.0 - cos_angle = np.clip(np.dot(v1, v2), -1.0, 1.0) - angle = np.arccos(cos_angle) - angles.append(angle) + v1 = v1[valid] + v2 = v2[valid] + l1 = l1[valid] + l2 = l2[valid] - if not angles: - return 0.0 + cos_angle = np.einsum("ij,ij->i", v1, v2) / (l1 * l2) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + + angles = np.arccos(cos_angle) + + arc_len = (l1 + l2) / 2.0 + curvatures = angles / arc_len - return np.std(np.degrees(angles)) + return float(np.sqrt(np.mean(curvatures**2))) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index 79e5757..6f77945 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -49,31 +49,40 @@ def test_valid_total_errors() -> None: def test_smoothness_less_than_three_points() -> None: - """Test smoothness calculation with less than three points.""" + """Less than 3 points cannot define curvature.""" points = pd.DataFrame({"x": [0, 1], "y": [0, 1]}) assert drawing_metrics.calculate_smoothness(points) == 0.0 def test_smoothness_straight_line() -> None: - """Collinear points should produce zero smoothness.""" + """Collinear points have zero curvature.""" points = pd.DataFrame({"x": [0, 1, 2, 3], "y": [0, 0, 0, 0]}) assert drawing_metrics.calculate_smoothness(points) == 0.0 def test_smoothness_single_right_angle() -> None: - """A single 90-degree turn should produce an std of 0.""" + """A single 90-degree corner should produce a large smoothness value. + + (non-zero), since sharp turns are penalized. + """ points = pd.DataFrame({"x": [0, 1, 1], "y": [0, 0, 1]}) + expected = np.pi / 2 smoothness = drawing_metrics.calculate_smoothness(points) - assert smoothness == 0.0 + assert np.isclose(smoothness, expected) def test_smoothness_varied_angles() -> None: - """Test smoothness with two different angles to ensure std is computed correctly.""" + """Multiple angles should produce RMS curvature. + + Path has a 90° turn followed by a 45° turn. + """ points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 1, 2]}) + c1 = np.pi / 2 + c2 = (np.pi / 4) / ((1 + np.sqrt(2)) / 2) + expected = np.sqrt((c1**2 + c2**2) / 2) smoothness = drawing_metrics.calculate_smoothness(points) - expected = np.std([90, 45]) assert np.isclose(smoothness, expected) @@ -82,3 +91,17 @@ def test_smoothness_zero_length_segments() -> None: points = pd.DataFrame({"x": [0, 1, 1, 2], "y": [0, 0, 0, 0]}) smoothness = drawing_metrics.calculate_smoothness(points) assert smoothness == 0.0 + + +def test_smoothness_single_180_degree_turn() -> None: + """A single 180-degree turn should produce a very large smoothness value. + + Since it represents maximal curvature. + """ + points = pd.DataFrame({ + "x": [0, 1, 0], + "y": [0, 0, 0], + }) + expected = np.pi + smoothness = drawing_metrics.calculate_smoothness(points) + assert np.isclose(smoothness, expected) From 06b1ee4202a3caf6470c983f9337302156d37a7a Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 15 Jan 2026 11:36:05 -0500 Subject: [PATCH 08/11] remove unused import --- src/graphomotor/features/trails/drawing_metrics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index b70deae..4a48f09 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -2,7 +2,6 @@ import numpy as np import pandas as pd -from shapely import points from graphomotor.core import models From 46c68d177560061b4e3b8762b63e80493dfae8ce Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 15 Jan 2026 11:43:20 -0500 Subject: [PATCH 09/11] ruff reformat --- tests/unit/test_trails_drawing_metrics.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_trails_drawing_metrics.py b/tests/unit/test_trails_drawing_metrics.py index fa38b35..19e13ad 100644 --- a/tests/unit/test_trails_drawing_metrics.py +++ b/tests/unit/test_trails_drawing_metrics.py @@ -31,6 +31,7 @@ def test_valid_total_errors() -> None: result = drawing_metrics.get_total_errors(drawing) assert result == {"total_errors": 1.0} + def test_percent_accurate_paths_missing_columns() -> None: """Test ValueError when required columns are missing.""" invalid_df = pd.DataFrame({"some_other_column": [0, 1, 2]}) @@ -53,7 +54,7 @@ def test_percent_accurate_paths_sample_data() -> None: result = drawing_metrics.percent_accurate_paths(drawing) assert result == {"percent_accurate_paths": 100.0} - + def test_smoothness_less_than_three_points() -> None: """Less than 3 points cannot define curvature.""" points = pd.DataFrame({"x": [0, 1], "y": [0, 1]}) @@ -104,10 +105,12 @@ def test_smoothness_single_180_degree_turn() -> None: Since it represents maximal curvature. """ - points = pd.DataFrame({ - "x": [0, 1, 0], - "y": [0, 0, 0], - }) + points = pd.DataFrame( + { + "x": [0, 1, 0], + "y": [0, 0, 0], + } + ) expected = np.pi smoothness = drawing_metrics.calculate_smoothness(points) assert np.isclose(smoothness, expected) From 850224ce7f6a4d937a133c543fbbfc74568d2777 Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Thu, 5 Feb 2026 10:12:26 -0500 Subject: [PATCH 10/11] requested changes --- .../features/trails/drawing_metrics.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 5f8e04e..0f25d07 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -46,7 +46,7 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]: def calculate_smoothness(points: pd.DataFrame) -> float: - """Calculate path smoothness based on RMS curvature. + """Calculate path smoothness based on Root Mean Square (RMS) curvature. Represants the curvature per unit arc length. Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) @@ -63,27 +63,29 @@ def calculate_smoothness(points: pd.DataFrame) -> float: xy = points[["x", "y"]].to_numpy() - v1 = xy[1:-1] - xy[:-2] - v2 = xy[2:] - xy[1:-1] + forward_vector = xy[1:-1] - xy[:-2] + backward_vector = xy[2:] - xy[1:-1] - l1 = np.linalg.norm(v1, axis=1) - l2 = np.linalg.norm(v2, axis=1) + forward_norm = np.linalg.norm(forward_vector, axis=1) + backward_norm = np.linalg.norm(backward_vector, axis=1) - valid = (l1 > 0) & (l2 > 0) + valid = (forward_norm > 0) & (backward_norm > 0) if not np.any(valid): return 0.0 - v1 = v1[valid] - v2 = v2[valid] - l1 = l1[valid] - l2 = l2[valid] + valid_forward_vector = forward_vector[valid] + valid_backward_vector = backward_vector[valid] + valid_forward_norm = forward_norm[valid] + valid_backward_norm = backward_norm[valid] - cos_angle = np.einsum("ij,ij->i", v1, v2) / (l1 * l2) + cos_angle = (valid_forward_vector * valid_backward_vector).sum(axis=1) / ( + valid_forward_norm * valid_backward_norm + ) cos_angle = np.clip(cos_angle, -1.0, 1.0) angles = np.arccos(cos_angle) - arc_len = (l1 + l2) / 2.0 + arc_len = (valid_forward_norm + valid_backward_norm) / 2.0 curvatures = angles / arc_len return float(np.sqrt(np.mean(curvatures**2))) From 6045ce143d2da31a6435c1c29e72bf348b3caa4b Mon Sep 17 00:00:00 2001 From: cgmaiorano Date: Mon, 9 Feb 2026 12:50:28 -0500 Subject: [PATCH 11/11] rename arc_len --- src/graphomotor/features/trails/drawing_metrics.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 0f25d07..98a2a80 100644 --- a/src/graphomotor/features/trails/drawing_metrics.py +++ b/src/graphomotor/features/trails/drawing_metrics.py @@ -48,7 +48,7 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]: def calculate_smoothness(points: pd.DataFrame) -> float: """Calculate path smoothness based on Root Mean Square (RMS) curvature. - Represants the curvature per unit arc length. + Represents the curvature per unit arc length. Lower values indicate smoother drawings. Penalizes sharp corners (e.g., 90° turns) and noisy corrections. Normalized by arc length to reduce sampling-rate dependence. @@ -85,7 +85,7 @@ def calculate_smoothness(points: pd.DataFrame) -> float: angles = np.arccos(cos_angle) - arc_len = (valid_forward_norm + valid_backward_norm) / 2.0 - curvatures = angles / arc_len + avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0 + curvatures = angles / avg_segment_length return float(np.sqrt(np.mean(curvatures**2)))