Skip to content
43 changes: 43 additions & 0 deletions 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 @@ -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:
Comment thread
Asanto32 marked this conversation as resolved.
"""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.

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

x = points["x"].values
Comment thread
Asanto32 marked this conversation as resolved.
Outdated
y = points["y"].values

angles = []
for i in range(1, len(x) - 1):
Comment thread
Asanto32 marked this conversation as resolved.
Outdated
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))
37 changes: 37 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 @@ -45,3 +46,39 @@ 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


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."""
Comment thread
Asanto32 marked this conversation as resolved.
Outdated
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])
Comment thread
cgmaiorano marked this conversation as resolved.
Outdated
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