Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5f40b12
wrote calculate_velocity_metrics
cgmaiorano Jan 19, 2026
de32337
add distance assign
cgmaiorano Jan 19, 2026
591a752
new classmethod for validating dataframe does not contain duplicate t…
cgmaiorano Feb 10, 2026
ac26494
added UTC_timestap check to validator and wrote unit test
cgmaiorano Feb 10, 2026
0952f4a
adjust unit test for validator
cgmaiorano Feb 10, 2026
3983c9c
fixing other unit tests by adding required columns
cgmaiorano Feb 10, 2026
3843b46
update validate data function to only apply to trails
cgmaiorano Feb 10, 2026
a3efd89
revert validator test back to only seconds
cgmaiorano Feb 10, 2026
e0cdea0
added new line checking for task name in metadata
cgmaiorano Feb 10, 2026
3489b85
remove validator function - will be its own new issue
cgmaiorano Feb 10, 2026
eb4b522
ruff reformat
cgmaiorano Feb 10, 2026
4e4ca3e
remove divide by 0 replacement
cgmaiorano Feb 10, 2026
a3a1918
unit tests for velocity function
cgmaiorano Feb 10, 2026
e6c7536
Merge branch 'main' into 96-task-write-trails-velocity-feature-functi…
cgmaiorano Feb 10, 2026
467553c
fix small bug from merge from main into branch
cgmaiorano Feb 10, 2026
10957f9
ruff reformat
cgmaiorano Feb 10, 2026
9e4883e
wrote detect_hesitations
cgmaiorano Feb 10, 2026
eded58d
adjusting the functions for ink_points which no longer will be in the…
cgmaiorano Feb 10, 2026
679cffc
made ink_points a class attribute again and fixed all unit tests
cgmaiorano Feb 10, 2026
e9f54eb
ruff reformat
cgmaiorano Feb 10, 2026
c5ce67c
move calculate smoothness into LineSegment, move smoothness unit test…
cgmaiorano Feb 11, 2026
0c40daf
cleande up compute_segment metrics, moved ink_point assigning out of …
cgmaiorano Feb 11, 2026
215f701
ruff reformat
cgmaiorano Feb 11, 2026
9d4e462
ruff remove unused imports
cgmaiorano Feb 11, 2026
abf56bd
ruff remove unused imports
cgmaiorano Feb 11, 2026
3c0dea4
ruff reformat and rename variables
cgmaiorano Feb 11, 2026
fa2a84c
unit tests for compute_segment_metrics
cgmaiorano Feb 11, 2026
8292a6c
ruff reformat, mypy errors fixing
cgmaiorano Feb 11, 2026
dc4a43f
trying to solve mypy error
cgmaiorano Feb 11, 2026
8838cbc
ruff and mypy and unit errors
cgmaiorano Feb 11, 2026
3b5907a
Merge branch 'main' into 60-task-write-trails-drawing-feature-functio…
cgmaiorano Feb 16, 2026
ecd4ea1
all my functions disappeared during merge from main so added back in …
cgmaiorano Feb 16, 2026
d33c221
ruff format
cgmaiorano Feb 16, 2026
de67b85
mypy error
cgmaiorano Feb 16, 2026
078743b
Merge branch 'main' into 60-task-write-trails-drawing-feature-functio…
cgmaiorano Feb 16, 2026
d9d382e
mypy and ruff errors
cgmaiorano Feb 16, 2026
faf5d73
import logger, remove extra unit test
cgmaiorano Mar 5, 2026
baa1638
correcting pytest.approx
cgmaiorano Mar 5, 2026
a6a02b3
rework if else chain
cgmaiorano Mar 5, 2026
384a139
ruff
cgmaiorano Mar 5, 2026
52d16fe
line too long
cgmaiorano Mar 5, 2026
7d2a165
update logger method to match one in config
cgmaiorano Mar 6, 2026
00e9968
fixing unused import
cgmaiorano Mar 6, 2026
0fbf5a5
ruff reformat
cgmaiorano Mar 6, 2026
a4ebe58
fix if else chain
cgmaiorano Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 139 additions & 6 deletions src/graphomotor/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import pydantic
import scipy.spatial.distance as dist

from graphomotor.core import config


class Drawing(pydantic.BaseModel):
"""Class representing a drawing task, encapsulating both raw data and metadata.
Expand Down Expand Up @@ -248,12 +250,6 @@ def valid_ink_trajectory(
):
ink_end_idx = idx
break
if (
ink_start_idx is not None
and ink_end_idx is not None
and ink_end_idx > ink_start_idx
):
self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy()

return ink_start_idx, ink_end_idx

Expand Down Expand Up @@ -352,3 +348,140 @@ def detect_hesitations(self, threshold_percentile: int = 20) -> None:
self.hesitation_duration = np.sum(hesitations) * dt[0]

return

def calculate_smoothness(self) -> None:
"""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.
"""
if len(self.ink_points) < 3:
return

xy = self.ink_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

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

self.smoothness = float(np.sqrt(np.mean(curvatures**2)))

return

def compute_segment_metrics(
self, circles: dict[str, dict[str, CircleTarget]], trail_id: str
) -> None:
"""Compute all metrics for a line segment.

This function computes various metrics for the line segment, including ink time,
velocity metrics, path optimality, smoothness, and hesitation detection. It
first determines the valid ink trajectory between the start and end circles. If
a valid trajectory is found, it updates the ink_points attribute and calculates
the metrics.

Args:
circles: A dictionary mapping each trail type to dictionaries of
CircleTarget instances (output of load_scaled_circles in config).
trail_id: Trail identifier for circle lookup.
"""
logger = config.get_logger()
trail_circles = circles[trail_id]
points = self.points.copy()

if len(points) < 2:
logger.warning(
"Not enough points to calculate metrics for line segment: "
"start=%s end=%s",
self.start_label,
self.end_label,
)
return

if self.start_label not in trail_circles or self.end_label not in trail_circles:
logger.warning(
"Missing start/end labels: start=%s end=%s available=%s",
self.start_label,
self.end_label,
list(trail_circles.keys()),
)
return
Comment thread
cgmaiorano marked this conversation as resolved.

start_circle = trail_circles[self.start_label]
end_circle = trail_circles[self.end_label]

ink_start_idx, ink_end_idx = self.valid_ink_trajectory(start_circle, end_circle)

if ink_start_idx is None:
logger.warning(
Copy link
Copy Markdown
Collaborator

@Asanto32 Asanto32 Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should exit and return right? I prefer that over the long if/elif chain.

if `ink_start_idx` is None:
  logger.warning()
  return
if `ink_end_idx` is None:
  self.ink_points = ...
    if len > 2
      do stuff;
    else:
      logger.warning()
      return
etc.

Let me know if this still isn't clear, we can chat about it 1-1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was more compressed the other way, I still have to do these checks but since you wanted it to be more readable I broke it all up

"No valid ink trajectory found for line segment: start=%s end=%s",
self.start_label,
self.end_label,
)
return
if ink_end_idx is None:
self.ink_points = points.iloc[ink_start_idx:].copy()
if len(self.ink_points) < 2:
logger.warning(
"Not enough ink points to calculate metrics for line segment: "
"start=%s end=%s",
self.start_label,
self.end_label,
)
return
self.ink_time = (
self.ink_points.iloc[-1]["seconds"] - self.ink_points.iloc[0]["seconds"]
)
return
if ink_end_idx <= ink_start_idx:
logger.warning(
"Invalid ink trajectory: end index (%d) is not greater than "
"start index (%d) for line segment: start=%s end=%s",
ink_end_idx,
ink_start_idx,
self.start_label,
self.end_label,
)
return
self.ink_points = self.points.iloc[ink_start_idx : ink_end_idx + 1].copy()

if len(self.ink_points) < 2:
logger.warning(
"Not enough ink points to calculate metrics for line segment: "
"start=%s end=%s",
self.start_label,
self.end_label,
)
return

self.ink_time = (
self.ink_points.iloc[-1]["seconds"] - self.ink_points.iloc[0]["seconds"]
)
self.calculate_velocity_metrics()
self.calculate_path_optimality(start_circle, end_circle)
self.calculate_smoothness()
self.detect_hesitations()

return
49 changes: 0 additions & 49 deletions src/graphomotor/features/trails/drawing_metrics.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"""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 @@ -43,49 +40,3 @@ def percent_accurate_paths(drawing: models.Drawing) -> dict[str, float]:
(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)))
Loading