Skip to content
50 changes: 49 additions & 1 deletion src/graphomotor/features/trails/drawing_metrics.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:
Comment thread
Asanto32 marked this conversation as resolved.
"""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.

Comment thread
Asanto32 marked this conversation as resolved.
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)))
62 changes: 62 additions & 0 deletions tests/unit/test_trails_drawing_metrics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test cases for drawing_metrics.py functions."""

import numpy as np
import pandas as pd
import pytest

Expand Down Expand Up @@ -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:
Comment thread
Asanto32 marked this conversation as resolved.
"""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)