Skip to content

Commit

Permalink
Normalized hand path area (#247)
Browse files Browse the repository at this point in the history
* add normalized area to statistic page

* remove redundant code

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* add type annotations to the function

* revert the code for statistic formatting

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
ZoeJacky and pre-commit-ci[bot] authored Aug 29, 2023
1 parent 117cc00 commit 0d275df
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 18 deletions.
14 changes: 9 additions & 5 deletions docs/reference/statistics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,14 @@ Area
The cursor location at a timestamp is given by a pair of :math:`(x, y)` coordinates,
where :math:`(0, 0)` corresponds to the center of the screen, and 1 in these units is equal to the height of the screen.

Given pairs of :math:`(x, y)` cursor locations to the target and to the center respectively, get the cursor coordinates of the polygon which is closed by the to target and to center :math:`(x, y)` cursor locations, use the build-in function to calculate the area of the polygon.
Given pairs of :math:`(x, y)` cursor locations, the following statistics are calculated, all in units of screen height:

In cases where the cursor movement results in intersecting paths, multiple polygons are formed, and their areas are summed.
Moreover, when the movement not only intersects but also leads to overlapping regions, the overlapped area is counted twice.
e.g.
* Area
* get the cursor coordinates of the polygon which is closed by the to target and to center :math:`(x, y)` cursor locations, use the build-in function to calculate the area of the polygon.
* In cases where the cursor movement results in intersecting paths, multiple polygons are formed, and their areas are summed.
* Moreover, when the movement not only intersects but also leads to overlapping regions, the overlapped area is counted twice.
* e.g.
* .. figure:: images/overlapping.svg

.. figure:: images/overlapping.svg
* Normalized Area
* (the area formed by paths) / (length of the paths)²
4 changes: 3 additions & 1 deletion src/vstt/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def default_display_options() -> vstt.vtypes.DisplayOptions:
"to_target_success": False,
"to_center_success": False,
"area": False,
"normalized_area": False,
"averages": True,
}

Expand All @@ -46,7 +47,8 @@ def display_options_labels() -> Dict[str, str]:
"to_center_rmse": "Statistic: RMSE movement to center",
"to_target_success": "Statistic: successful movement to target",
"to_center_success": "Statistic: successful movement to center",
"area": "Statistic: the area enclosed by the to target path and to center path",
"area": "Statistic: the area formed by the paths connecting the target and the center",
"normalized_area": "Statistic: (the area formed by paths) / (length of the paths)²",
"averages": "Also show statistics averaged over all targets",
}

Expand Down
85 changes: 75 additions & 10 deletions src/vstt/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def list_dest_stat_label_units() -> List[Tuple[str, List[Tuple[str, str, str]]]]
stats.append((f"to_{destination}_{base_stat}", label, unit))
list_dest_stats.append((destination, stats))
list_dest_stats.append(("", [("area", "Area", "")]))
list_dest_stats.append(("", [("normalized_area", "Normalized Area", "")]))
return list_dest_stats


Expand Down Expand Up @@ -167,6 +168,12 @@ def stats_dataframe(trial_handler: TrialHandlerExt) -> pd.DataFrame:
lambda x: _area(x["to_target_mouse_positions"], x["to_center_mouse_positions"]),
axis=1,
)
df["normalized_area"] = df.apply(
lambda x: _normalized_area(
x["to_target_mouse_positions"], x["to_center_mouse_positions"]
),
axis=1,
)
return df


Expand Down Expand Up @@ -398,6 +405,43 @@ def _area(
return area


def _normalized_area(
to_target_mouse_positions: np.ndarray, to_center_mouse_positions: np.ndarray
) -> float:
"""
normalized area = (the area formed by paths) / (length of the paths)²
:param to_target_mouse_positions: x,y mouse positions moving towards the target
:param to_center_mouse_positions: x,y mouse positions moving towards the center
:return: normalized area
"""
area = _area(to_target_mouse_positions, to_center_mouse_positions)
movement_length = get_movement_length(
to_target_mouse_positions, to_center_mouse_positions
)

normalized_area = area / (movement_length**2) if movement_length != 0 else 0
return normalized_area


def get_movement_length(
to_target_mouse_positions: np.ndarray, to_center_mouse_positions: np.ndarray
) -> float:
"""
calculate the length of the paths connecting the target and the center
if only 1 path exists, another path is the distance between the head and tail of the existing path.
:param to_target_mouse_positions: x,y mouse positions moving towards the target
:param to_center_mouse_positions: x,y mouse positions moving towards the center
:return: length of the movement
"""
closed_polygon_coords = get_closed_polygon(
to_target_mouse_positions, to_center_mouse_positions
)
movement_length = _distance(closed_polygon_coords)
return movement_length


def get_closed_polygon(
to_target_mouse_positions: np.ndarray, to_center_mouse_positions: np.ndarray
) -> np.ndarray:
Expand All @@ -409,16 +453,23 @@ def get_closed_polygon(
:return: x,y mouse positions of the closed polygon
"""
to_target_mouse_positions = (
to_target_mouse_positions.reshape(0, 2)
if to_target_mouse_positions.size == 0
else to_target_mouse_positions
)
to_center_mouse_positions = (
to_center_mouse_positions.reshape(0, 2)
if to_center_mouse_positions.size == 0
else to_center_mouse_positions
)
to_target_mouse_positions = preprocess_mouse_positions(to_target_mouse_positions)
to_center_mouse_positions = preprocess_mouse_positions(to_center_mouse_positions)
if to_target_mouse_positions.size == 0:
return np.concatenate(
[
to_center_mouse_positions,
to_center_mouse_positions[0:1],
]
)
if to_center_mouse_positions.size == 0:
return np.concatenate(
[
to_target_mouse_positions,
to_target_mouse_positions[0:1],
]
)

coords = np.concatenate(
[
to_target_mouse_positions,
Expand All @@ -428,3 +479,17 @@ def get_closed_polygon(
]
)
return coords


def preprocess_mouse_positions(mouse_positions: np.ndarray) -> np.ndarray:
"""
reshape the mouse position to (0, 2) if the mouse position is empty, to prevent error happens in the following
concatenate() function
:param mouse_positions: x,y mouse positions during movement
:return: x,y mouse positions after preprocess
"""
mouse_positions = (
mouse_positions.reshape(0, 2) if mouse_positions.size == 0 else mouse_positions
)
return mouse_positions
4 changes: 2 additions & 2 deletions src/vstt/vis.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def _make_stats_txt(display_options: DisplayOptions, stats: pd.Series) -> str:
stat_str = f"{stats[stat]:.0%}"
else:
stat_str = f"{stats[stat] == 1}"
if stat == "area":
if stat == "area" or stat == "normalized_area":
txt_stats += f"{label}: {stat_str}\n"
else:
txt_stats += f"{label} (to {destination}): {stat_str}\n"
Expand All @@ -176,7 +176,7 @@ def _make_average_stats_txt(display_options: DisplayOptions, stats: pd.Series) -
stat_str = f"{stats[stat + '_trial']:.0%}"
else:
stat_str = f"{stats[stat]: .0%}"
if stat == "area":
if stat == "area" or stat == "normalized_area":
txt_stats += f"{label}: {stat_str}\n"
else:
txt_stats += f"{label} (to {destination}): {stat_str}\n"
Expand Down
1 change: 1 addition & 0 deletions src/vstt/vtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class DisplayOptions(TypedDict):
to_center_success: bool
averages: bool
area: bool
normalized_area: bool


class Metadata(TypedDict):
Expand Down
1 change: 1 addition & 0 deletions tests/test_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def test_import_display_options(caplog: pytest.LogCaptureFixture) -> None:
"to_target_success": False,
"to_center_success": False,
"area": False,
"normalized_area": False,
}
for key in default_display_options:
assert key in display_options_dict
Expand Down
35 changes: 35 additions & 0 deletions tests/test_stats.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import math

import numpy as np
import vstt
from vstt.experiment import Experiment
Expand Down Expand Up @@ -127,9 +129,42 @@ def test_area() -> None:
assert np.allclose(
vstt.stats._area(np.array([[0, 0], [0, 1], [1, 1]]), np.array([])), [0.5]
)
assert np.allclose(
vstt.stats._area(np.array([]), np.array([[0, 0], [0, 1], [1, 1]])), [0.5]
)
assert np.allclose(
vstt.stats._area(
np.array([[0, 1], [0, 0], [1, 1], [1, 0]]), np.array([[1, 0], [0, 1]])
),
[0.5],
)


def test_normalized_area() -> None:
assert np.allclose(vstt.stats._normalized_area(np.array([]), np.array([])), [0])
assert np.allclose(
vstt.stats._normalized_area(np.array([]), np.array([[1, 1], [0, 1]])), [0]
)
assert np.allclose(
vstt.stats._normalized_area(np.array([[0, 0], [0, 1]]), np.array([])), [0]
)
assert np.allclose(
vstt.stats._normalized_area(
np.array([[0, 0], [0, 1], [1, 1]]), np.array([[1, 1], [1, 0], [0, 0]])
),
[1 / 16],
)
assert np.allclose(
vstt.stats._normalized_area(np.array([[0, 0], [0, 1], [1, 1]]), np.array([])),
[1 / (12 + 8 * math.sqrt(2))],
)
assert np.allclose(
vstt.stats._normalized_area(np.array([]), np.array([[0, 0], [0, 1], [1, 1]])),
[1 / (12 + 8 * math.sqrt(2))],
)
assert np.allclose(
vstt.stats._normalized_area(
np.array([[0, 1], [0, 0], [1, 1], [1, 0]]), np.array([[1, 0], [0, 1]])
),
[1 / (24 + 16 * math.sqrt(2))],
)
2 changes: 2 additions & 0 deletions tests/test_vis.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ def test_display_results_nothing(
"to_target_success": False,
"to_center_success": False,
"area": False,
"normalized_area": False,
}
for all_trials_for_this_condition in [False, True]:
# trial 0: 0,1,2 are trials without auto-move to center
Expand Down Expand Up @@ -294,6 +295,7 @@ def test_display_results_everything(
"to_target_success": True,
"to_center_success": True,
"area": True,
"normalized_area": True,
}
for all_trials_for_this_condition in [False, True]:
# trial 0: 0,1,2 are trials without auto-move to center
Expand Down

0 comments on commit 0d275df

Please sign in to comment.