diff --git a/src/graphomotor/features/trails/drawing_metrics.py b/src/graphomotor/features/trails/drawing_metrics.py index 752287c..98a2a80 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 @@ -35,9 +38,54 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]: raise ValueError( "DataFrame must contain 'correct_path' and 'actual_path' columns." ) - return { "percent_accurate_paths": ( (drawing.data["correct_path"] == drawing.data["actual_path"]).mean() * 100 ) } + + +def calculate_smoothness(points: pd.DataFrame) -> float: + """Calculate path smoothness based on Root Mean Square (RMS) curvature. + + 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. + + Args: + points: DataFrame representing drawing points. + + Returns: + Smoothness metric as a float. + """ + if len(points) < 3: + return 0.0 + + xy = points[["x", "y"]].to_numpy() + + forward_vector = xy[1:-1] - xy[:-2] + backward_vector = xy[2:] - xy[1:-1] + + forward_norm = np.linalg.norm(forward_vector, axis=1) + backward_norm = np.linalg.norm(backward_vector, axis=1) + + valid = (forward_norm > 0) & (backward_norm > 0) + if not np.any(valid): + return 0.0 + + 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 = (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) + + avg_segment_length = (valid_forward_norm + valid_backward_norm) / 2.0 + curvatures = angles / avg_segment_length + + 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 cd87758..19e13ad 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 @@ -52,3 +53,64 @@ 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]}) + assert drawing_metrics.calculate_smoothness(points) == 0.0 + + +def test_smoothness_straight_line() -> None: + """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 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 np.isclose(smoothness, expected) + + +def test_smoothness_varied_angles() -> None: + """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) + + 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 + + +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)