neurotask 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neurotask/__init__.py +0 -0
- neurotask/tmt/__init__.py +0 -0
- neurotask/tmt/config.py +8 -0
- neurotask/tmt/crosses/__init__.py +0 -0
- neurotask/tmt/crosses/crosses.py +131 -0
- neurotask/tmt/crosses/crosses_metric_calculator.py +20 -0
- neurotask/tmt/cut_criteria/__init__.py +0 -0
- neurotask/tmt/cut_criteria/cut_criteria.py +6 -0
- neurotask/tmt/cut_criteria/cut_implementation.py +146 -0
- neurotask/tmt/invalid_cause.py +17 -0
- neurotask/tmt/mapper/__init__.py +0 -0
- neurotask/tmt/mapper/mapper.py +21 -0
- neurotask/tmt/metrics/__init__.py +0 -0
- neurotask/tmt/metrics/area_calculation.py +82 -0
- neurotask/tmt/metrics/base_metric.py +37 -0
- neurotask/tmt/metrics/difference_from_ideal_distance.py +38 -0
- neurotask/tmt/metrics/distance_calculation.py +61 -0
- neurotask/tmt/metrics/intra_and_inter_target_time.py +148 -0
- neurotask/tmt/metrics/metrics_calculator.py +417 -0
- neurotask/tmt/metrics/sampling_period.py +89 -0
- neurotask/tmt/metrics/speed_metrics.py +228 -0
- neurotask/tmt/metrics/targets_touched.py +253 -0
- neurotask/tmt/metrics/zig_zag_amplitud.py +52 -0
- neurotask/tmt/model/__init__.py +0 -0
- neurotask/tmt/model/tmt_model.py +165 -0
- neurotask/tmt/preprocessing/__init__.py +0 -0
- neurotask/tmt/preprocessing/interpolation.py +62 -0
- neurotask/tmt/segmentation/__init__.py +0 -0
- neurotask/tmt/segmentation/segmentation.py +539 -0
- neurotask/tmt/segmentation/segmentation_metric.py +27 -0
- neurotask/tmt/tmt_analyzer.py +188 -0
- neurotask/tmt/visualization/__init__.py +0 -0
- neurotask/tmt/visualization/utils.py +63 -0
- neurotask/tmt/visualization/visualization.py +345 -0
- neurotask-0.2.0.dist-info/METADATA +240 -0
- neurotask-0.2.0.dist-info/RECORD +39 -0
- neurotask-0.2.0.dist-info/WHEEL +5 -0
- neurotask-0.2.0.dist-info/licenses/LICENSE +21 -0
- neurotask-0.2.0.dist-info/top_level.txt +1 -0
neurotask/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
neurotask/tmt/config.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from typing import List, Tuple
|
|
2
|
+
|
|
3
|
+
from ..model.tmt_model import TMTTrial, Coordinate
|
|
4
|
+
|
|
5
|
+
def calculate_crosses_for_trial(
|
|
6
|
+
trial: TMTTrial,
|
|
7
|
+
crosses_time_threshold: float,
|
|
8
|
+
) -> Tuple[int, List[Tuple[Tuple[Coordinate, Coordinate], Tuple[Coordinate, Coordinate], float]]]:
|
|
9
|
+
"""
|
|
10
|
+
Calculates the number of times the cursor trail crosses itself, excluding segments that are very near in time.
|
|
11
|
+
|
|
12
|
+
Parameters:
|
|
13
|
+
- trial: TMTTrial object containing the cursor_trail.
|
|
14
|
+
- crosses_time_threshold: float, the minimum time gap (in milliseconds) between segments to consider them for intersection.
|
|
15
|
+
Segments with gap < threshold are excluded as "too close in time".
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
- num_crosses: int, the number of times the cursor trail crosses itself.
|
|
19
|
+
- cross_segments: list of tuples: (seg1, seg2, time_gap) for each detected crossing.
|
|
20
|
+
time_gap is in milliseconds.
|
|
21
|
+
"""
|
|
22
|
+
cursor_trail = trial.get_cursor_trail_from_start()
|
|
23
|
+
|
|
24
|
+
# Not enough points to form non-adjacent segments
|
|
25
|
+
if len(cursor_trail) < 4:
|
|
26
|
+
return 0, []
|
|
27
|
+
|
|
28
|
+
# Build segments with endpoint times
|
|
29
|
+
segments: List[Tuple[Tuple[Coordinate, float], Tuple[Coordinate, float]]] = []
|
|
30
|
+
for i in range(len(cursor_trail) - 1):
|
|
31
|
+
p1 = cursor_trail[i].position
|
|
32
|
+
p2 = cursor_trail[i + 1].position
|
|
33
|
+
t1 = cursor_trail[i].time
|
|
34
|
+
t2 = cursor_trail[i + 1].time
|
|
35
|
+
segments.append(((p1, t1), (p2, t2)))
|
|
36
|
+
|
|
37
|
+
num_crosses = 0
|
|
38
|
+
cross_segments: List[Tuple[Tuple[Coordinate, Coordinate], Tuple[Coordinate, Coordinate], float]] = []
|
|
39
|
+
|
|
40
|
+
for i in range(len(segments)):
|
|
41
|
+
for j in range(i + 1, len(segments)):
|
|
42
|
+
# Skip adjacent segments (they share a point)
|
|
43
|
+
if j == i + 1:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
# Time interval for each segment
|
|
47
|
+
t_start_i = min(segments[i][0][1], segments[i][1][1])
|
|
48
|
+
t_end_i = max(segments[i][0][1], segments[i][1][1])
|
|
49
|
+
t_start_j = min(segments[j][0][1], segments[j][1][1])
|
|
50
|
+
t_end_j = max(segments[j][0][1], segments[j][1][1])
|
|
51
|
+
|
|
52
|
+
# Compute gap between time intervals (0 if overlapping)
|
|
53
|
+
if t_end_i < t_start_j:
|
|
54
|
+
time_gap = t_start_j - t_end_i
|
|
55
|
+
elif t_end_j < t_start_i:
|
|
56
|
+
time_gap = t_start_i - t_end_j
|
|
57
|
+
else:
|
|
58
|
+
time_gap = 0.0
|
|
59
|
+
|
|
60
|
+
# Skip if segments are too close in time
|
|
61
|
+
if time_gap < crosses_time_threshold:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
seg1 = (segments[i][0][0], segments[i][1][0])
|
|
65
|
+
seg2 = (segments[j][0][0], segments[j][1][0])
|
|
66
|
+
|
|
67
|
+
if segments_intersect(seg1[0], seg1[1], seg2[0], seg2[1]):
|
|
68
|
+
num_crosses += 1
|
|
69
|
+
cross_segments.append((seg1, seg2, time_gap))
|
|
70
|
+
|
|
71
|
+
return num_crosses, cross_segments
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def orientation(p: Coordinate, q: Coordinate, r: Coordinate) -> int:
|
|
76
|
+
"""
|
|
77
|
+
Determines the orientation of the triplet (p, q, r).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
- 0 : Colinear
|
|
81
|
+
- 1 : Clockwise
|
|
82
|
+
- 2 : Counterclockwise
|
|
83
|
+
"""
|
|
84
|
+
val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)
|
|
85
|
+
if abs(val) < 1e-10:
|
|
86
|
+
return 0 # Colinear
|
|
87
|
+
elif val > 0:
|
|
88
|
+
return 1 # Clockwise
|
|
89
|
+
else:
|
|
90
|
+
return 2 # Counterclockwise
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def on_segment(p: Coordinate, q: Coordinate, r: Coordinate) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Checks if point q lies on segment pr.
|
|
96
|
+
"""
|
|
97
|
+
if min(p.x, r.x) <= q.x <= max(p.x, r.x) and \
|
|
98
|
+
min(p.y, r.y) <= q.y <= max(p.y, r.y):
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def segments_intersect(p1: Coordinate, q1: Coordinate, p2: Coordinate, q2: Coordinate) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Checks if two line segments (p1,q1) and (p2,q2) intersect.
|
|
106
|
+
"""
|
|
107
|
+
# Find the four orientations needed for general and special cases
|
|
108
|
+
o1 = orientation(p1, q1, p2)
|
|
109
|
+
o2 = orientation(p1, q1, q2)
|
|
110
|
+
o3 = orientation(p2, q2, p1)
|
|
111
|
+
o4 = orientation(p2, q2, q1)
|
|
112
|
+
|
|
113
|
+
# General case
|
|
114
|
+
if o1 != o2 and o3 != o4:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
# Special Cases
|
|
118
|
+
# p1, q1 and p2 are colinear and p2 lies on segment p1q1
|
|
119
|
+
if o1 == 0 and on_segment(p1, p2, q1):
|
|
120
|
+
return True
|
|
121
|
+
# p1, q1 and q2 are colinear and q2 lies on segment p1q1
|
|
122
|
+
if o2 == 0 and on_segment(p1, q2, q1):
|
|
123
|
+
return True
|
|
124
|
+
# p2, q2 and p1 are colinear and p1 lies on segment p2q2
|
|
125
|
+
if o3 == 0 and on_segment(p2, p1, q2):
|
|
126
|
+
return True
|
|
127
|
+
# p2, q2 and q1 are colinear and q1 lies on segment p2q2
|
|
128
|
+
if o4 == 0 and on_segment(p2, q1, q2):
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
return False
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from neurotask.tmt.crosses.crosses import calculate_crosses_for_trial
|
|
3
|
+
from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
|
|
4
|
+
from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CrossesMetricCalculator(BaseMetricCalculator):
|
|
8
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
9
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
10
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
11
|
+
crosses_time_threshold: float) -> dict:
|
|
12
|
+
metric_key = self.get_metric_name("number_of_crosses")
|
|
13
|
+
|
|
14
|
+
if calculate_crosses:
|
|
15
|
+
num_crosses, _ = calculate_crosses_for_trial(trial, crosses_time_threshold)
|
|
16
|
+
metrics[metric_key] = num_crosses
|
|
17
|
+
else:
|
|
18
|
+
metrics[metric_key] = np.nan
|
|
19
|
+
|
|
20
|
+
return metrics
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from neurotask.tmt.cut_criteria.cut_criteria import CutCriteria
|
|
4
|
+
from neurotask.tmt.metrics.intra_and_inter_target_time import get_intra_target_intervals
|
|
5
|
+
from neurotask.tmt.metrics.targets_touched import count_correctly_touched_targets
|
|
6
|
+
from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cut_trial(
|
|
10
|
+
trial: TMTTrial,
|
|
11
|
+
correct_targets_minimum: Optional[int],
|
|
12
|
+
subject: TMTSubject,
|
|
13
|
+
subject_id: str,
|
|
14
|
+
cut_criteria: CutCriteria,
|
|
15
|
+
target_radius_multiplier: float
|
|
16
|
+
) -> TMTTrial:
|
|
17
|
+
"""
|
|
18
|
+
Process the given trial based on the specified cut criteria.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
trial (TMTTrial): The trial to process.
|
|
22
|
+
correct_targets_minimum (Optional[int]): The minimum correct target touches required.
|
|
23
|
+
subject (TMTSubject): The subject associated with the trial.
|
|
24
|
+
subject_id (str): Unique identifier for the subject.
|
|
25
|
+
cut_criteria (CutCriteria): The criteria used to cut the trial.
|
|
26
|
+
target_radius_multiplier (float): Multiplier to apply to the target radius.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
TMTTrial: The processed (cut) trial.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the cut criteria is invalid or if required parameters are missing.
|
|
33
|
+
"""
|
|
34
|
+
if cut_criteria == CutCriteria.MINIMUM_TARGETS:
|
|
35
|
+
if correct_targets_minimum is None:
|
|
36
|
+
raise ValueError("Minimum targets criteria requires a minimum number of correct targets.")
|
|
37
|
+
return cut_trial_at_minimum_targets(correct_targets_minimum, subject, subject_id, trial, target_radius_multiplier)
|
|
38
|
+
|
|
39
|
+
raise ValueError(f"Invalid cut criteria: {cut_criteria}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cut_trial_at_minimum_targets(
|
|
43
|
+
correct_targets_minimum: int,
|
|
44
|
+
subject: TMTSubject,
|
|
45
|
+
subject_id: str,
|
|
46
|
+
trial: TMTTrial,
|
|
47
|
+
target_radius_multiplier: float
|
|
48
|
+
) -> TMTTrial:
|
|
49
|
+
"""
|
|
50
|
+
Cuts the trial at the point where the minimum number of correct target touches is reached.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
correct_targets_minimum (int): The minimum number of correct touches required.
|
|
54
|
+
subject (TMTSubject): The subject associated with the trial.
|
|
55
|
+
subject_id (str): Unique identifier for the subject.
|
|
56
|
+
trial (TMTTrial): The trial to be processed.
|
|
57
|
+
target_radius_multiplier (float): Multiplier to apply to the target radius.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
TMTTrial: The trial cut at the minimum correct target touch.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If the trial does not meet the required number of correct target touches.
|
|
64
|
+
"""
|
|
65
|
+
correct_targets_touches = count_correctly_touched_targets(trial, subject.target_radius, target_radius_multiplier)
|
|
66
|
+
|
|
67
|
+
if correct_targets_touches < correct_targets_minimum:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Trial {trial.id} of subject {subject_id} has {correct_targets_touches} correct target touches, "
|
|
70
|
+
f"but the minimum required is {correct_targets_minimum}.")
|
|
71
|
+
|
|
72
|
+
return cut_trial_at_minimum_correct_targets(trial, correct_targets_minimum, subject, target_radius_multiplier)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cut_trial_at_minimum_correct_targets(
|
|
76
|
+
trial: TMTTrial,
|
|
77
|
+
correct_targets_minimum: int,
|
|
78
|
+
subject: TMTSubject,
|
|
79
|
+
target_radius_multiplier: float
|
|
80
|
+
) -> TMTTrial:
|
|
81
|
+
"""
|
|
82
|
+
Cuts the trial at the time corresponding to reaching the minimum correct target touches.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
trial (TMTTrial): The trial to be cut.
|
|
86
|
+
correct_targets_minimum (int): The required number of correct touches.
|
|
87
|
+
subject (TMTSubject): The subject with target_radius used to determine correct touches.
|
|
88
|
+
target_radius_multiplier (float): The radius multiplier to use for target detection.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
TMTTrial: The trial cut at the appropriate time.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If the trial does not contain the required number of correct target segments.
|
|
95
|
+
"""
|
|
96
|
+
intra_target_interval: list[tuple[TMTTarget, CursorInfo, CursorInfo]] = get_intra_target_intervals(
|
|
97
|
+
trial, subject.target_radius, target_radius_multiplier
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if len(intra_target_interval) < correct_targets_minimum:
|
|
101
|
+
raise ValueError(f"Trial {trial.id} has less than {correct_targets_minimum} correct targets.")
|
|
102
|
+
|
|
103
|
+
# Get the Nth interval (where N = correct_targets_minimum)
|
|
104
|
+
# Since correct_touched_targets_for_every_cursor_point ensures targets are touched in order,
|
|
105
|
+
# the Nth interval corresponds to the Nth target in trial.stimuli
|
|
106
|
+
cutoff_interval = intra_target_interval[correct_targets_minimum - 1]
|
|
107
|
+
|
|
108
|
+
# Use the start cursor (index 1) to cut when the target is first touched
|
|
109
|
+
cursor_info = cutoff_interval[1]
|
|
110
|
+
|
|
111
|
+
return cut_at_time(trial, cursor_info.time, correct_targets_minimum)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cut_at_time(
|
|
115
|
+
trial: TMTTrial,
|
|
116
|
+
cutoff_time: float,
|
|
117
|
+
correct_targets_minimum: int
|
|
118
|
+
) -> TMTTrial:
|
|
119
|
+
"""
|
|
120
|
+
Cuts the trial at the specified cutoff time.
|
|
121
|
+
|
|
122
|
+
This function creates a new trial instance where the cursor trail is truncated
|
|
123
|
+
at the given cutoff time and only the first 'correct_targets_minimum' stimuli are retained.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
trial (TMTTrial): The trial to be cut.
|
|
127
|
+
cutoff_time (float): The time at which to cut the trial.
|
|
128
|
+
correct_targets_minimum (int): The number of stimuli to retain.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
TMTTrial: A new trial instance cut at the specified time.
|
|
132
|
+
"""
|
|
133
|
+
new_cursor_trail = [cursor for cursor in trial.cursor_trail if cursor.time <= cutoff_time]
|
|
134
|
+
new_rt = cutoff_time # Ensure this behavior is as intended.
|
|
135
|
+
new_stimuli = trial.stimuli[:correct_targets_minimum]
|
|
136
|
+
|
|
137
|
+
return TMTTrial(
|
|
138
|
+
id=trial.id,
|
|
139
|
+
stimuli=new_stimuli,
|
|
140
|
+
cursor_trail=new_cursor_trail,
|
|
141
|
+
rt=new_rt,
|
|
142
|
+
trial_type=trial.trial_type,
|
|
143
|
+
order_of_appearance=trial.order_of_appearance,
|
|
144
|
+
with_custom_start=trial.with_custom_start,
|
|
145
|
+
start=trial.start
|
|
146
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvalidCause(Enum):
|
|
5
|
+
"""
|
|
6
|
+
Enumeration of possible causes of invalid trials in TMT.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
CUT_CRITERIA_ERROR = "CUT_CRITERIA_ERROR"
|
|
10
|
+
UNKNOWN_ERROR = "UNKNOWN_ERROR"
|
|
11
|
+
UNDER_CORRECT_TARGETS_MINIMUM = "UNDER_CORRECT_TARGETS_MINIMUM"
|
|
12
|
+
INVALID_LENGTH = "INVALID_LENGTH"
|
|
13
|
+
INVALID_START_CONFIGURATION = "INVALID_START_CONFIGURATION"
|
|
14
|
+
INVALID_MODEL = "INVALID_MODEL"
|
|
15
|
+
UNABLE_TO_DETERMINE_START = "UNABLE_DETERMINE_START"
|
|
16
|
+
INVALID_SPEED = "INVALID_SPEED"
|
|
17
|
+
NON_MONOTONIC_TIME = "NON_MONOTONIC_TIME"
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import abstractmethod, ABC
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ..model.tmt_model import TMTExperiment
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TMTMapper(ABC):
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def map(self, data_path: str) -> TMTExperiment:
|
|
11
|
+
"""
|
|
12
|
+
Método abstracto para mapear los datos (y opcionalmente la metadata) a una instancia de TMTExperiment.
|
|
13
|
+
|
|
14
|
+
Parameters:
|
|
15
|
+
- data_path: str -> La ruta al archivo de datos.
|
|
16
|
+
- metadata_path: Optional[str] -> La ruta al archivo de metadata. (Opcional)
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
- TMTExperiment -> Instancia de TMTExperiment mapeada a partir de los datos (y la metadata si aplica).
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
|
|
5
|
+
from neurotask.tmt.model.tmt_model import CursorInfo, TMTTarget, TMTTrial, TMTSubject
|
|
6
|
+
|
|
7
|
+
# np.trapz se eliminó en numpy 2.4; np.trapezoid existe desde numpy 2.0
|
|
8
|
+
_trapezoid = getattr(np, "trapezoid", None) or np.trapz
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def area_between_real_and_ideal(segment: List[CursorInfo]) -> float:
|
|
12
|
+
# 1) Extraer coordenadas (x, y) de los puntos reales
|
|
13
|
+
point_coords = np.array([[cursor_info.position.x, cursor_info.position.y] for cursor_info in segment], dtype=float)
|
|
14
|
+
|
|
15
|
+
return area_between_real_and_ideal_points(point_coords)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def area_between_real_and_ideal_points(point_coords: np.ndarray) -> float:
|
|
19
|
+
"""
|
|
20
|
+
Integra la distancia perpendicular absoluta entre la trayectoria real
|
|
21
|
+
y la línea recta ideal que une el primer y último punto del segmento.
|
|
22
|
+
|
|
23
|
+
:param segment: lista de CursorInfo reales
|
|
24
|
+
:return: área bajo la curva de desviación, >= 0
|
|
25
|
+
"""
|
|
26
|
+
num_points = len(point_coords)
|
|
27
|
+
if num_points < 2:
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
# 2) Definir el vector ideal desde el primer al último punto
|
|
31
|
+
start_point = point_coords[0]
|
|
32
|
+
end_point = point_coords[-1]
|
|
33
|
+
ideal_vector = end_point - start_point
|
|
34
|
+
ideal_length_sq = np.dot(ideal_vector, ideal_vector)
|
|
35
|
+
ideal_length = np.sqrt(ideal_length_sq)
|
|
36
|
+
|
|
37
|
+
# 3) Proyectar cada punto real sobre la línea ideal
|
|
38
|
+
# a) desplazamientos desde el inicio
|
|
39
|
+
offsets = point_coords - start_point[np.newaxis, :]
|
|
40
|
+
# b) factor de proyección t ∈ [0,1] sobre el vector ideal
|
|
41
|
+
projection_factors = (offsets @ ideal_vector) / ideal_length_sq
|
|
42
|
+
# c) puntos proyectados en la línea ideal
|
|
43
|
+
projection_points = start_point + projection_factors[:, np.newaxis] * ideal_vector
|
|
44
|
+
|
|
45
|
+
# 4) Calcular distancia perpendicular de cada punto real a la ideal
|
|
46
|
+
perpendicular_distances = np.linalg.norm(point_coords - projection_points, axis=1)
|
|
47
|
+
|
|
48
|
+
# 5) Coordenadas escalarizadas a lo largo de la línea ideal
|
|
49
|
+
# (distancia desde el inicio a cada proyección)
|
|
50
|
+
line_positions = projection_factors * ideal_length
|
|
51
|
+
|
|
52
|
+
# 6) Integración por la regla del trapecio
|
|
53
|
+
area = _trapezoid(perpendicular_distances, line_positions)
|
|
54
|
+
|
|
55
|
+
return float(area)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DifferenceFromIdealArea(BaseMetricCalculator):
|
|
59
|
+
|
|
60
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
61
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
62
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
63
|
+
crosses_time_threshold: float) -> dict:
|
|
64
|
+
|
|
65
|
+
areas = []
|
|
66
|
+
for trail in trails_between_targets:
|
|
67
|
+
target, cursor_trail = trail
|
|
68
|
+
if target is None:
|
|
69
|
+
continue
|
|
70
|
+
area = area_between_real_and_ideal(cursor_trail)
|
|
71
|
+
areas.append(area)
|
|
72
|
+
|
|
73
|
+
if areas:
|
|
74
|
+
metrics[self.get_metric_name('area_difference_from_ideal')] = float(np.mean(areas))
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Cannot calculate area_difference_from_ideal: No valid target segments found. "
|
|
78
|
+
f"Trial {trial.id} has {len(trails_between_targets)} trails_between_targets, "
|
|
79
|
+
f"but none had valid targets (all targets were None or segments were invalid)."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return metrics
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from neurotask.tmt.model.tmt_model import TMTTrial, TMTSubject, TMTTarget, CursorInfo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseMetricCalculator(ABC):
|
|
8
|
+
def __init__(self, prefix: Optional[str] = None):
|
|
9
|
+
self.prefix = prefix
|
|
10
|
+
|
|
11
|
+
def get_metric_name(self, metric_name: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Devuelve el nombre de la métrica con el prefijo si se ha definido.
|
|
14
|
+
"""
|
|
15
|
+
if self.prefix:
|
|
16
|
+
return f'{self.prefix}{metric_name}'
|
|
17
|
+
return metric_name
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
21
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
22
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
23
|
+
crosses_time_threshold: float) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Añade las claves/valores de esta métrica al dict `metrics`.
|
|
26
|
+
`trial` es tu objeto TMTTrial (o las estructuras que uses).
|
|
27
|
+
"""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReactionTimeCalculator(BaseMetricCalculator):
|
|
32
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
33
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
34
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
35
|
+
crosses_time_threshold: float) -> dict:
|
|
36
|
+
metrics[self.get_metric_name('rt')] = trial.rt
|
|
37
|
+
return metrics
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from neurotask.tmt.metrics.base_metric import BaseMetricCalculator
|
|
3
|
+
from neurotask.tmt.metrics.distance_calculation import calculate_distance, calculate_total_distance_from_segment
|
|
4
|
+
from neurotask.tmt.model.tmt_model import TMTTrial, CursorInfo, TMTTarget, TMTSubject
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DifferenceFromIdealDistance(BaseMetricCalculator):
|
|
8
|
+
|
|
9
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
10
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
11
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
12
|
+
crosses_time_threshold: float) -> dict:
|
|
13
|
+
|
|
14
|
+
differences = []
|
|
15
|
+
for trail in trails_between_targets:
|
|
16
|
+
target, cursor_trail = trail
|
|
17
|
+
if target is None:
|
|
18
|
+
continue
|
|
19
|
+
difference = self.calculate_distance_difference_from_ideal(cursor_trail)
|
|
20
|
+
differences.append(difference)
|
|
21
|
+
|
|
22
|
+
if differences:
|
|
23
|
+
metrics[self.get_metric_name('distance_difference_from_ideal')] = float(np.mean(differences))
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"Cannot calculate distance_difference_from_ideal: No valid target segments found. "
|
|
27
|
+
f"Trial {trial.id} has {len(trails_between_targets)} trails_between_targets, "
|
|
28
|
+
f"but none had valid targets (all targets were None or segments were invalid)."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return metrics
|
|
32
|
+
|
|
33
|
+
def calculate_distance_difference_from_ideal(self, segment: list[CursorInfo]) -> float:
|
|
34
|
+
|
|
35
|
+
segment_distance = calculate_total_distance_from_segment(segment)
|
|
36
|
+
ideal_distance = calculate_distance(segment[0].position, segment[-1].position)
|
|
37
|
+
|
|
38
|
+
return abs(segment_distance - ideal_distance)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from .base_metric import BaseMetricCalculator
|
|
5
|
+
from ..model.tmt_model import Coordinate, TMTTrial, CursorInfo, TMTSubject, TMTTarget
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TotalDistanceCalculator(BaseMetricCalculator):
|
|
9
|
+
def add_metrics(self, metrics: dict, trial: TMTTrial, subject: TMTSubject,
|
|
10
|
+
trails_between_targets: list[tuple[TMTTarget, list[CursorInfo]]], calculate_crosses: bool,
|
|
11
|
+
speed_threshold, consecutive_points, target_radius_multiplier: float,
|
|
12
|
+
crosses_time_threshold: float) -> dict:
|
|
13
|
+
metrics[self.get_metric_name('total_distance')] = calculate_total_distance(trial)
|
|
14
|
+
return metrics
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def calculate_distance(pos1: Coordinate, pos2: Coordinate) -> float:
|
|
18
|
+
dx = pos1.x - pos2.x
|
|
19
|
+
dy = pos1.y - pos2.y
|
|
20
|
+
return math.hypot(dx, dy)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_inside_target(
|
|
24
|
+
cursor_pos: Coordinate,
|
|
25
|
+
target: TMTTarget,
|
|
26
|
+
target_radius: float,
|
|
27
|
+
multiplier: float
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Determine if the cursor is inside the target's effective radius.
|
|
31
|
+
|
|
32
|
+
:param cursor_pos: The cursor position.
|
|
33
|
+
:param target: The target to check.
|
|
34
|
+
:param target_radius: The base target radius.
|
|
35
|
+
:param multiplier: The radius multiplier.
|
|
36
|
+
:return: True if cursor is inside the target's effective radius.
|
|
37
|
+
"""
|
|
38
|
+
effective_radius = target_radius * multiplier
|
|
39
|
+
distance = calculate_distance(cursor_pos, target.position)
|
|
40
|
+
return distance < effective_radius
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def calculate_total_distance(trial: TMTTrial):
|
|
44
|
+
"""
|
|
45
|
+
Calculate the total distance of the cursor trail in a TMT trial.
|
|
46
|
+
|
|
47
|
+
:param trial: TMTTrial object containing the cursor trail.
|
|
48
|
+
:return: Total distance traveled by the cursor.
|
|
49
|
+
"""
|
|
50
|
+
cursor_trail_from_first_click = trial.get_cursor_trail_from_start()
|
|
51
|
+
return sum(
|
|
52
|
+
calculate_distance(cursor_trail_from_first_click[i].position, cursor_trail_from_first_click[i + 1].position)
|
|
53
|
+
for i in range(len(cursor_trail_from_first_click) - 1)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def calculate_total_distance_from_segment(segment_trial: List[CursorInfo]) -> float:
|
|
58
|
+
return sum(
|
|
59
|
+
calculate_distance(segment_trial[i].position, segment_trial[i + 1].position)
|
|
60
|
+
for i in range(len(segment_trial) - 1)
|
|
61
|
+
)
|