Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 2 additions & 3 deletions docs/source/mouse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ Clicks
------
From a dataset of mouse interaction points, there are several click-related metrics that can be computed to analyze user behavior and interaction patterns.

Two common click metrics are:
Common click metrics are:

1. **Number of Clicks**: This metric counts the total number of clicks recorded during a session. See the function implementation in :py:func:`~pywib.number_of_clicks`.
2. **Click Slip**: This metric measures the deviation of click positions from the intended targets, providing insights into user accuracy. See the function implementation in :py:func:`~pywib.click_slip`.
.. 3. **Click Duration**: This metric measures the time duration of each click action, providing insights into user interaction speed. See the function implementation in :py:func:`~pywib.click_duration`.
2. **Click Slip**: This metric measures the deviation of click positions from the intended targets, providing insights into user accuracy. See the function implementation in :py:func:`~pywib.click_slip`.
5 changes: 2 additions & 3 deletions src/pywib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
# Utility functions
"validate_dataframe",
"validate_dataframe_keyboard",
"extract_trace",
"visualize_trace",
"compute_space_time_diff",
"extract_traces_by_session",
Expand All @@ -45,7 +44,7 @@
"acceleration_metrics",
"jerkiness_metrics",
"deviations",
"auc"
"auc",

# Mouse functions
"number_of_clicks",
Expand All @@ -55,7 +54,7 @@
"typing_speed",
"typing_speed_metrics",
"backspace_usage",
"typing_durations"
"typing_durations",

# Timing
"pauses_metrics",
Expand Down
3 changes: 1 addition & 2 deletions src/pywib/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@
"jerkiness_metrics",
"click_slip",
"number_of_clicks",
"deviations"
"deviations",
"typing_speed",
"typing_speed_metrics",
"backspace_usage",
"typing_durations",
"typing_speed"
]
59 changes: 41 additions & 18 deletions src/pywib/core/movement/movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
from pywib.utils.movement import velocity_traces_parallel
from pywib.utils.validation import validate_any_not_none

def _traces_missing_column(traces: dict[str, list[pd.DataFrame]] | None, column_name: str) -> bool:
if traces is None:
return False
return any(
column_name not in trace.columns
for session_traces in traces.values()
for trace in session_traces
)

def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = True, parallel:bool = False, n_jobs: int = 2) -> dict[str, list[pd.DataFrame]]:
"""
Function to calculate velocity for either a single DataFrame or a traces dictionary.
Expand Down Expand Up @@ -44,7 +53,7 @@ def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = No
return velocity_traces(traces)


def velocity_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = None) -> dict:
def velocity_metrics(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None) -> dict:
"""
Calculate velocity metrics for the given DataFrame or traces.
This function computes the mean, max, and min velocity for each session.
Expand All @@ -59,11 +68,19 @@ def velocity_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = N

validate_any_not_none(df, traces)

if (df is not None) and (
(ColumnNames.VELOCITY not in df.columns) or
(traces is None) or
_traces_missing_column(traces, ColumnNames.VELOCITY)
):
validate_dataframe(df)
traces = velocity(df, per_traces=True)

return compute_metrics_from_traces(
df=df,
traces=traces,
column_name=ColumnNames.VELOCITY,
compute_traces_fn=velocity,
compute_traces_fn=lambda _: traces,
preprocess_fn=lambda s: s[s > 0] # Exclude zero velocities
)

Expand All @@ -88,7 +105,7 @@ def acceleration(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]]
# Compute acceleration for each trace
return acceleration_traces(traces)

def acceleration_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = None) -> dict:
def acceleration_metrics(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None) -> dict:
"""
Calculate acceleration metrics for the given DataFrame or traces.
This function computes the mean, max, and min acceleration for each session.
Expand All @@ -103,12 +120,15 @@ def acceleration_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]]

validate_any_not_none(df, traces)

if(df is not None):
if (ColumnNames.ACCELERATION not in df.columns) or (traces is None):
validate_dataframe(df)
if ColumnNames.VELOCITY not in df.columns:
traces = velocity(df, per_traces=True)
traces = acceleration(df, traces, per_traces=True)
if (df is not None) and (
(ColumnNames.ACCELERATION not in df.columns) or
(traces is None) or
_traces_missing_column(traces, ColumnNames.ACCELERATION)
):
validate_dataframe(df)
if ColumnNames.VELOCITY not in df.columns:
traces = velocity(df, per_traces=True)
traces = acceleration(df, traces, per_traces=True)

return compute_metrics_from_traces(
df=df,
Expand Down Expand Up @@ -164,15 +184,18 @@ def jerkiness_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] =
"""

validate_any_not_none(df, traces)

if(df is not None):
if((ColumnNames.JERKINESS not in df.columns) or (traces is None)):
validate_dataframe(df)
if(ColumnNames.ACCELERATION not in df.columns):
if(ColumnNames.VELOCITY not in df.columns):
traces = velocity(df, per_traces=True)
traces = acceleration(df, traces, per_traces=True)
traces = jerkiness(df, traces, per_traces=True)

if (df is not None) and (
(ColumnNames.JERKINESS not in df.columns) or
(traces is None) or
_traces_missing_column(traces, ColumnNames.JERKINESS)
):
validate_dataframe(df)
if ColumnNames.ACCELERATION not in df.columns:
if ColumnNames.VELOCITY not in df.columns:
traces = velocity(df, per_traces=True)
traces = acceleration(df, traces, per_traces=True)
traces = jerkiness(df, traces, per_traces=True)

return compute_metrics_from_traces(
df=df,
Expand Down
8 changes: 4 additions & 4 deletions src/pywib/utils/movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ def acceleration_df(df: pd.DataFrame) -> pd.DataFrame:
if(ColumnNames.VELOCITY not in df.columns):
df = velocity_df(df)

df['acceleration'] = df['velocity'].diff().fillna(0) / df[ColumnNames.DT]
df['acceleration'] = df['acceleration'].fillna(0)
df[ColumnNames.ACCELERATION] = df[ColumnNames.VELOCITY].diff().fillna(0) / df[ColumnNames.DT]
df[ColumnNames.ACCELERATION] = df[ColumnNames.ACCELERATION].fillna(0)
return df


Expand Down Expand Up @@ -114,8 +114,8 @@ def jerkiness_df(df: pd.DataFrame) -> pd.DataFrame:
if(ColumnNames.ACCELERATION not in df.columns):
df = acceleration_df(df)

df['jerkiness'] = df['acceleration'].diff().fillna(0) / df[ColumnNames.DT]
df['jerkiness'] = df['jerkiness'].fillna(0)
df[ColumnNames.JERKINESS] = df[ColumnNames.ACCELERATION].diff().fillna(0) / df[ColumnNames.DT]
df[ColumnNames.JERKINESS] = df[ColumnNames.JERKINESS].fillna(0)
return df


Expand Down
12 changes: 9 additions & 3 deletions src/pywib/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ def compute_space_time_diff(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df.sort_values(by=[ColumnNames.TIME_STAMP], inplace=True)
df[ColumnNames.TIME_STAMP] = pd.to_numeric(df[ColumnNames.TIME_STAMP], errors='coerce')
df['dt'] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.TIME_STAMP].diff().fillna(0)
df['dx'] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.X].diff().fillna(0)
df['dy'] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.Y].diff().fillna(0)
df[ColumnNames.DT] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.TIME_STAMP].diff().fillna(0)
df[ColumnNames.DX] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.X].diff().fillna(0)
df[ColumnNames.DY] = df.groupby([ColumnNames.SESSION_ID])[ColumnNames.Y].diff().fillna(0)
return df

def compute_metrics_from_traces(
Expand Down Expand Up @@ -78,6 +78,12 @@ def compute_metrics_from_traces(
metrics = {}
for session_id, session_traces in traces.items():
if(len(session_traces) > 0):
for trace_index, trace in enumerate(session_traces):
if column_name not in trace.columns:
raise ValueError(
f"Missing required column '{column_name}' in "
f"session '{session_id}', trace index {trace_index}."
)
values = pd.concat([trace[column_name] for trace in session_traces])
if preprocess_fn:
values = preprocess_fn(values)
Expand Down
49 changes: 31 additions & 18 deletions src/pywib/utils/visualization.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,49 @@
from pywib.constants import ColumnNames
from pywib.utils.validation import validate_dataframe_keyboard

def visualize_trace(df, stroke_indices, stroke_id):
def visualize_trace(df, stroke_indices, stroke_id, plot_name: str = None, plot: bool = True, show_info: bool = False, show_optimal_line: bool = False):
"""
Generates a plot visualizing the trace of a stroke.
Generates (and optionally saves) a plot visualizing the trace of a stroke.

Parameters:
df (pd.DataFrame): DataFrame containing the stroke data with 'x', 'y and 'timeStamp' columns.
stroke_indices (list): List of indices corresponding to the stroke in the DataFrame. Can be obtained using df.index.
stroke_indices (list): List of indices corresponding to the stroke in the DataFrame. Can be obtained using df.index
stroke_id (str): Identifier for the stroke to be displayed in the title.
Returns:
None: Displays a plot of the stroke trace.
plot_name (str, optional): If provided, saves the plot to this file path.
plot (bool): Whether to display the plot.
"""
stroke_data = df.loc[stroke_indices]
plt.figure(figsize=(10, 8))
plt.plot(stroke_data[ColumnNames.X], stroke_data[ColumnNames.Y], 'b-o', linewidth=2, markersize=4, label='Real trace')

x_start, y_start = stroke_data[ColumnNames.X].iloc[0], stroke_data[ColumnNames.Y].iloc[0]
x_end, y_end = stroke_data[ColumnNames.X].iloc[-1], stroke_data[ColumnNames.Y].iloc[-1]
plt.plot([x_start, x_end], [y_start, y_end], 'r--', linewidth=2, label='Optimal trace')

plt.plot(x_start, y_start, 'go', markersize=8, label='Inicio')
plt.plot(x_end, y_end, 'ro', markersize=8, label='Fin')

duration = stroke_data['timeStamp'].iloc[-1] - stroke_data['timeStamp'].iloc[0]
plt.xlabel('X (píxeles)')
plt.ylabel('Y (píxeles)')
plt.title(f'Trace {stroke_id} - Duration: {duration:.0f}ms - Points: {len(stroke_data)}')
plt.legend()
plt.grid(True, alpha=0.3)
plt.gca().invert_yaxis()
plt.show()

if(show_optimal_line):
plt.plot([x_start, x_end], [y_start, y_end], 'r--', linewidth=2, label='Optimal trace')

plt.plot(x_start, y_start, 'go', markersize=8, label='Start')
plt.plot(x_end, y_end, 'ro', markersize=8, label='End')


if(show_info):
duration = stroke_data[ColumnNames.TIME_STAMP].iloc[-1] - stroke_data[ColumnNames.TIME_STAMP].iloc[0]
plt.xlabel('X (px)')
plt.ylabel('Y (px)')
plt.title(f'Trace {stroke_id} - Duration: {duration:.0f}ms - Points: {len(stroke_data)}')
plt.legend()
plt.grid(True, alpha=0.3)
plt.gca().invert_yaxis()
else:
plt.axis('off')

if plot_name:
plt.savefig(plot_name, bbox_inches='tight', dpi=300)

if plot:
plt.show()
else:
plt.close()

def video_from_trace(df, user_id, outfile: str, width=640, height=480, fps=30, colored=False):
"""
Expand Down
68 changes: 67 additions & 1 deletion test/test_movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import_pyModule()
from pywib import (velocity, acceleration, compute_space_time_diff,
velocity_metrics, acceleration_metrics,
jerkiness, jerkiness_metrics)
jerkiness, jerkiness_metrics, extract_traces_by_session)

# Cambiar a True solo al probar en desarrollo
DEBUG = True
Expand Down Expand Up @@ -86,6 +86,24 @@ def test_velocity_metrics_from_velocity_traces(self):
assert_between_zero_inf(self, session_id, 'max')
assert_between_zero_inf(self, session_id, 'min')

def test_velocity_metrics_recomputes_when_traces_missing_column(self):
"""Recompute velocity traces from df when provided traces lack velocity column."""
partial_traces = extract_traces_by_session(self.test_data.copy())
metrics = velocity_metrics(self.test_data.copy(), traces=partial_traces)

self.assertIsInstance(metrics, dict)
self.assertTrue(len(metrics) > 0)
for _, session in metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)

def test_velocity_metrics_raises_with_incomplete_traces_and_no_df(self):
"""Raise ValueError when traces lack velocity and no dataframe is available."""
partial_traces = extract_traces_by_session(self.test_data.copy())
with self.assertRaises(ValueError):
velocity_metrics(None, traces=partial_traces)

def test_acceleration(self):
"""Test acceleration calculation"""
df = compute_space_time_diff(self.test_data.copy())
Expand Down Expand Up @@ -143,6 +161,24 @@ def test_acceleration_metrics_from_aceleration_df(self):
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_acceleration_metrics_recomputes_when_traces_missing_column(self):
"""Recompute acceleration traces from df when provided traces lack acceleration."""
partial_traces = velocity(self.test_data.copy(), per_traces=True)
metrics = acceleration_metrics(self.test_data.copy(), traces=partial_traces)

self.assertIsInstance(metrics, dict)
self.assertTrue(len(metrics) > 0)
for _, session in metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)

def test_acceleration_metrics_raises_with_incomplete_traces_and_no_df(self):
"""Raise ValueError when traces lack acceleration and no dataframe is available."""
partial_traces = velocity(self.test_data.copy(), per_traces=True)
with self.assertRaises(ValueError):
acceleration_metrics(None, traces=partial_traces)

def test_jerkiness(self):
"""Test jerkiness calculation"""
jk_df = jerkiness(self.test_data.copy(), per_traces=True)
Expand Down Expand Up @@ -178,6 +214,36 @@ def test_jerkiness_from_df(self):
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_jerkiness_metrics_recomputes_when_traces_missing_column(self):
"""Recompute jerkiness traces from df when provided traces lack jerkiness."""
partial_traces = velocity(self.test_data.copy(), per_traces=True)
metrics = jerkiness_metrics(self.test_data.copy(), traces=partial_traces)

self.assertIsInstance(metrics, dict)
self.assertTrue(len(metrics) > 0)
for _, session in metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)

def test_jerkiness_metrics_raises_with_incomplete_traces_and_no_df(self):
"""Raise ValueError when traces lack jerkiness and no dataframe is available."""
partial_traces = velocity(self.test_data.copy(), per_traces=True)
with self.assertRaises(ValueError):
jerkiness_metrics(None, traces=partial_traces)

def test_jerkiness_metrics_with_precomputed_jerkiness_traces(self):
"""Use precomputed jerkiness traces directly without recomputation."""
full_traces = jerkiness(self.test_data.copy(), per_traces=True)
metrics = jerkiness_metrics(None, traces=full_traces)

self.assertIsInstance(metrics, dict)
self.assertTrue(len(metrics) > 0)
for _, session in metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)



if __name__ == '__main__':
Expand Down
Loading
Loading