peyes 0.0.7__py2.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.
- peyes/_DataModels/DatasetLoader.py +559 -0
- peyes/_DataModels/Detector.py +1383 -0
- peyes/_DataModels/Event.py +434 -0
- peyes/_DataModels/EventLabelEnum.py +14 -0
- peyes/_DataModels/EventMatcher.py +298 -0
- peyes/_DataModels/UnparsedEventLabel.py +9 -0
- peyes/_DataModels/config.py +53 -0
- peyes/__init__.py +15 -0
- peyes/_base/create.py +241 -0
- peyes/_base/match.py +122 -0
- peyes/_base/parse.py +52 -0
- peyes/_base/postprocess_events.py +42 -0
- peyes/_utils/constants.py +106 -0
- peyes/_utils/event_utils.py +95 -0
- peyes/_utils/metric_utils.py +104 -0
- peyes/_utils/pixel_utils.py +180 -0
- peyes/_utils/vector_utils.py +107 -0
- peyes/_utils/visualization_utils.py +128 -0
- peyes/channel_metrics/__init__.py +3 -0
- peyes/channel_metrics/_signal_detection_metrics.py +166 -0
- peyes/channel_metrics/_timing_differences.py +92 -0
- peyes/datasets/__init__.py +52 -0
- peyes/datasets/get_metadata.py +45 -0
- peyes/datasets/load_dataset.py +33 -0
- peyes/event_metrics/__init__.py +44 -0
- peyes/event_metrics/_get_features.py +73 -0
- peyes/event_metrics/_rates_and_transitions.py +57 -0
- peyes/match_metrics/__init__.py +42 -0
- peyes/match_metrics/_get_features.py +59 -0
- peyes/match_metrics/_match_evaluation.py +142 -0
- peyes/sample_metrics/__init__.py +72 -0
- peyes/sample_metrics/_calculate_metrics.py +120 -0
- peyes/sample_metrics/_counts_and_matrices.py +52 -0
- peyes/visualize/__init__.py +6 -0
- peyes/visualize/_event_summary.py +279 -0
- peyes/visualize/_features.py +192 -0
- peyes/visualize/_gaze.py +239 -0
- peyes/visualize/_scarfplot.py +97 -0
- peyes/visualize/_video.py +122 -0
- peyes-0.0.7.dist-info/METADATA +60 -0
- peyes-0.0.7.dist-info/RECORD +42 -0
- peyes-0.0.7.dist-info/WHEEL +5 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import io
|
|
3
|
+
import itertools
|
|
4
|
+
import zipfile as zp
|
|
5
|
+
import posixpath as psx
|
|
6
|
+
from typing import final, List, Tuple, Dict
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import requests as req
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
from scipy.io import loadmat
|
|
15
|
+
from scipy.interpolate import interp1d
|
|
16
|
+
import arff
|
|
17
|
+
|
|
18
|
+
import peyes._utils.constants as cnst
|
|
19
|
+
from peyes._utils.pixel_utils import calculate_pixel_size, visual_angle_to_pixels
|
|
20
|
+
from peyes._utils.event_utils import parse_label
|
|
21
|
+
from peyes._DataModels.EventLabelEnum import EventLabelEnum
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseDatasetLoader(ABC):
|
|
25
|
+
_NAME: str
|
|
26
|
+
_URL: str
|
|
27
|
+
_ARTICLES: List[str]
|
|
28
|
+
_LICENSE: str
|
|
29
|
+
_INDEXERS: List[str] = [
|
|
30
|
+
cnst.TRIAL_ID_STR, cnst.SUBJECT_ID_STR, cnst.STIMULUS_TYPE_STR, cnst.STIMULUS_NAME_STR
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
@final
|
|
35
|
+
def load(cls, directory: str, save: bool = False, verbose: bool = False) -> pd.DataFrame:
|
|
36
|
+
"""
|
|
37
|
+
Loads the dataset from the specified directory. If the dataset is not found, it is downloaded from the internet.
|
|
38
|
+
If `save` is True and the dataset was downloaded, it is saved to the specified directory.
|
|
39
|
+
if `verbose` is True, a progress bar is displayed while parsing the downloaded dataset.
|
|
40
|
+
:return: a DataFrame containing the dataset
|
|
41
|
+
:raises ValueError: if `save` is True and `directory` is not specified
|
|
42
|
+
"""
|
|
43
|
+
if save and not directory:
|
|
44
|
+
raise ValueError("Directory must be specified to save the dataset")
|
|
45
|
+
try:
|
|
46
|
+
dataset = pd.read_pickle(os.path.join(directory, f"{cls.name()}.pkl"))
|
|
47
|
+
except (FileNotFoundError, TypeError) as _e:
|
|
48
|
+
if verbose:
|
|
49
|
+
print(f"Dataset {cls.name()} not found in directory {directory}.\nDownloading...")
|
|
50
|
+
dataset = cls.download(verbose)
|
|
51
|
+
if save:
|
|
52
|
+
os.makedirs(directory, exist_ok=True)
|
|
53
|
+
file_path = os.path.join(directory, f"{cls.name()}.pkl")
|
|
54
|
+
if verbose:
|
|
55
|
+
print(f"Saved dataset to {file_path}")
|
|
56
|
+
dataset.to_pickle(file_path)
|
|
57
|
+
return dataset
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
@final
|
|
61
|
+
def download(cls, verbose: bool = False) -> pd.DataFrame:
|
|
62
|
+
""" Downloads the dataset from the internet, parses it and returns a DataFrame with cleaned data """
|
|
63
|
+
url = cls.url()
|
|
64
|
+
response = req.get(cls._URL)
|
|
65
|
+
code = response.status_code
|
|
66
|
+
if code != 200:
|
|
67
|
+
raise ConnectionError(
|
|
68
|
+
f"HTTP status code {code} when attempting to download dataset {cls.name()} from {url}"
|
|
69
|
+
)
|
|
70
|
+
df = cls._parse_response(response, verbose)
|
|
71
|
+
return cls._reorder_columns(df)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def _parse_response(cls, response: req.Response, verbose: bool = False) -> pd.DataFrame:
|
|
76
|
+
""" Parses the downloaded response and returns a DataFrame containing the raw dataset """
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
@final
|
|
81
|
+
def _reorder_columns(cls, df: pd.DataFrame) -> pd.DataFrame:
|
|
82
|
+
ordered_columns = sorted(df.columns, key=lambda col: cls.column_order().get(col, 10))
|
|
83
|
+
return df[ordered_columns]
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
@final
|
|
87
|
+
def name(cls) -> str:
|
|
88
|
+
""" Name of the dataset """
|
|
89
|
+
if not cls._NAME:
|
|
90
|
+
raise AttributeError(f"Class {cls.__name__} must implement class attribute `_NAME`")
|
|
91
|
+
return cls._NAME
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
@final
|
|
95
|
+
def url(cls) -> str:
|
|
96
|
+
""" URL of the dataset """
|
|
97
|
+
if not cls._URL:
|
|
98
|
+
raise AttributeError(f"Class {cls.__name__} must implement class attribute `_URL`")
|
|
99
|
+
return cls._URL
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
@final
|
|
103
|
+
def articles(cls) -> List[str]:
|
|
104
|
+
""" List of articles to cite when using this dataset """
|
|
105
|
+
if not cls._ARTICLES:
|
|
106
|
+
raise AttributeError(f"Class {cls.__name__} must implement class attribute `_{cnst.ARTICLES_STR}`")
|
|
107
|
+
return cls._ARTICLES
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
@final
|
|
111
|
+
def license(cls) -> str:
|
|
112
|
+
""" License of the dataset """
|
|
113
|
+
if not cls._LICENSE:
|
|
114
|
+
raise AttributeError(f"Class {cls.__name__} must implement class attribute `_LICENSE`")
|
|
115
|
+
return cls._LICENSE
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def column_order() -> Dict[str, float]:
|
|
119
|
+
return {
|
|
120
|
+
cnst.TRIAL_ID_STR: 0.1, cnst.SUBJECT_ID_STR: 0.2, cnst.STIMULUS_TYPE_STR: 0.3, cnst.STIMULUS_NAME_STR: 0.4,
|
|
121
|
+
cnst.T: 1.0, cnst.X: 1.1, cnst.Y: 1.2, cnst.PUPIL: 1.3,
|
|
122
|
+
cnst.LEFT_X: 2.1, cnst.LEFT_Y: 2.2, cnst.LEFT_PUPIL: 2.3,
|
|
123
|
+
cnst.RIGHT_X: 3.1, cnst.RIGHT_Y: 3.2, cnst.RIGHT_PUPIL: 3.3,
|
|
124
|
+
cnst.PIXEL_SIZE_STR: 4.2, cnst.VIEWER_DISTANCE_STR: 4.3
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
@final
|
|
129
|
+
def _extract_filename_and_extension(full_path: str) -> (str, str, str):
|
|
130
|
+
""" Splits a full path into its components: path, filename and extension """
|
|
131
|
+
path, extension = os.path.splitext(full_path)
|
|
132
|
+
path, filename = os.path.split(path)
|
|
133
|
+
return path, filename, extension
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Lund2013DatasetLoader(BaseDatasetLoader):
|
|
137
|
+
_NAME = "Lund2013"
|
|
138
|
+
_URL = 'https://github.com/richardandersson/EyeMovementDetectorEvaluation/archive/refs/heads/master.zip'
|
|
139
|
+
_LICENSE = "GNU GPL-3.0"
|
|
140
|
+
_ARTICLES = [
|
|
141
|
+
"Andersson, R., Larsson, L., Holmqvist, K., Stridh, M., & Nyström, M. (2017): One algorithm to rule them " +
|
|
142
|
+
"all? An evaluation and discussion of ten eye movement event-detection algorithms. Behavior Research " +
|
|
143
|
+
"Methods, 49(2), 616-637.",
|
|
144
|
+
"Zemblys, R., Niehorster, D. C., Komogortsev, O., & Holmqvist, K. (2018). Using machine learning to detect " +
|
|
145
|
+
"events in eye-tracking data. Behavior Research Methods, 50(1), 160–181."
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
__PREFIX = 'EyeMovementDetectorEvaluation-master/annotated_data/originally uploaded data/'
|
|
149
|
+
__ERRONEOUS_FILES = ['UH29_img_Europe_labelled_MN.mat']
|
|
150
|
+
__CORRECTION_FILES = [
|
|
151
|
+
'EyeMovementDetectorEvaluation-master/annotated_data/fix_by_Zemblys2018/UH29_img_Europe_labelled_FIX_MN.mat'
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def _parse_response(cls, response: req.Response, verbose: bool = False) -> pd.DataFrame:
|
|
156
|
+
zip_file = zp.ZipFile(io.BytesIO(response.content))
|
|
157
|
+
|
|
158
|
+
# list all files in the zip archive that are relevant to this dataset
|
|
159
|
+
# replaces erroneously labelled files with the corrected ones (see readme.md for more info)
|
|
160
|
+
is_valid_file = lambda f: f.startswith(cls.__PREFIX) and f.endswith('.mat') and f not in cls.__ERRONEOUS_FILES
|
|
161
|
+
file_names = [f for f in zip_file.namelist() if is_valid_file(f)]
|
|
162
|
+
file_names.extend(cls.__CORRECTION_FILES)
|
|
163
|
+
|
|
164
|
+
# read all files into a list of dataframes
|
|
165
|
+
trial_id = 0
|
|
166
|
+
dataframes = {}
|
|
167
|
+
for f in tqdm(file_names, desc="Processing Files", disable=not verbose):
|
|
168
|
+
file = zip_file.open(f)
|
|
169
|
+
gaze_data = cls.__read_eyetracker_data(file)
|
|
170
|
+
subject_id, stimulus_type, stimulus_name, rater = cls.__extract_metadata(file)
|
|
171
|
+
gaze_data.rename(columns={cnst.LABEL_STR: rater}, inplace=True)
|
|
172
|
+
|
|
173
|
+
# write the DF to a dict based on the subject id, stimulus type, stimulus name, or add to existing DF
|
|
174
|
+
existing_df = dataframes.get((subject_id, stimulus_type, stimulus_name), None)
|
|
175
|
+
if existing_df is None:
|
|
176
|
+
trial_id += 1
|
|
177
|
+
gaze_data[cnst.TRIAL_ID_STR] = trial_id
|
|
178
|
+
gaze_data[cnst.SUBJECT_ID_STR] = subject_id
|
|
179
|
+
gaze_data[cnst.STIMULUS_TYPE_STR] = stimulus_type
|
|
180
|
+
gaze_data[cnst.STIMULUS_NAME_STR] = stimulus_name
|
|
181
|
+
dataframes[(subject_id, stimulus_type, stimulus_name)] = gaze_data
|
|
182
|
+
else:
|
|
183
|
+
existing_df.loc[:, rater] = gaze_data.loc[:, rater]
|
|
184
|
+
return pd.concat(dataframes.values(), ignore_index=True, axis=0)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def __read_eyetracker_data(file) -> pd.DataFrame:
|
|
188
|
+
mat = loadmat(file)
|
|
189
|
+
eyetracking_data = mat["ETdata"]
|
|
190
|
+
eyetracking_data_dict = {name: eyetracking_data[name][0, 0] for name in eyetracking_data.dtype.names}
|
|
191
|
+
|
|
192
|
+
# extract singleton values and convert from meters to cm:
|
|
193
|
+
sampling_rate = eyetracking_data_dict['sampFreq'][0, 0]
|
|
194
|
+
view_dist = eyetracking_data_dict['viewDist'][0, 0] * 100
|
|
195
|
+
screen_width, screen_height = eyetracking_data_dict['screenDim'][0] * 100
|
|
196
|
+
screen_res = eyetracking_data_dict['screenRes'][0] # (1024, 768)
|
|
197
|
+
pixel_size = calculate_pixel_size(screen_width, screen_height, screen_res)
|
|
198
|
+
|
|
199
|
+
# extract gaze data:
|
|
200
|
+
samples_data = eyetracking_data_dict['pos']
|
|
201
|
+
right_x, right_y = samples_data[:, 3:5].T # only recording right eye
|
|
202
|
+
is_missing = (right_x == 0) & (right_y == 0) # missing samples are marked with (0, 0) coordinates
|
|
203
|
+
right_x[is_missing] = np.nan
|
|
204
|
+
right_y[is_missing] = np.nan
|
|
205
|
+
labels = pd.Series(samples_data[:, 5]).apply(lambda x: parse_label(x, safe=True))
|
|
206
|
+
if np.isnan(samples_data[:, 0]).any():
|
|
207
|
+
# if timestamps are NaN, re-populate them
|
|
208
|
+
timestamps = np.arange(len(right_x)) * cnst.MILLISECONDS_PER_SECOND / sampling_rate
|
|
209
|
+
else:
|
|
210
|
+
# timestamps are available but in microseconds
|
|
211
|
+
timestamps = samples_data[:, 0] - np.nanmin(samples_data[:, 0]) # start timestamps from 0
|
|
212
|
+
timestamps /= cnst.MICROSECONDS_PER_MILLISECOND
|
|
213
|
+
return pd.DataFrame(data={
|
|
214
|
+
cnst.T: timestamps, cnst.X: right_x, cnst.Y: right_y, cnst.PUPIL: np.nan,
|
|
215
|
+
cnst.LABEL_STR: labels, cnst.VIEWER_DISTANCE_STR: view_dist, cnst.PIXEL_SIZE_STR: pixel_size
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def __extract_metadata(file) -> Tuple[str, str, str, str]:
|
|
220
|
+
file_name = os.path.basename(file.name) # remove path
|
|
221
|
+
if not file_name.endswith(".mat"):
|
|
222
|
+
raise ValueError(f"Expected a `.mat` file, got: {file_name}")
|
|
223
|
+
# file_name fmt: `<subject_id>_<stimulus_type>_<stimulus_name_1>_ ... _<stimulus_name_N>_labelled_<rater_name>`
|
|
224
|
+
# moving-dot trials don't contain stimulus names
|
|
225
|
+
file_name = file_name.replace(".mat", "") # remove extension
|
|
226
|
+
split_name = file_name.split("_")
|
|
227
|
+
subject_id = split_name[0] # subject id is always 1st in the file name
|
|
228
|
+
stimulus_type = split_name[1] # stimulus type is always 2nd in the file name
|
|
229
|
+
rater = split_name[-1].upper() # rater is always last in the file name
|
|
230
|
+
stimulus_name = "_".join(split_name[2:-2]).removesuffix("_labelled") # everything between stim type and rater
|
|
231
|
+
if stimulus_type.startswith("trial"):
|
|
232
|
+
stimulus_type = cnst.MOVING_DOT_STR # moving-dot stimulus is labelled as "trial1", "trial2", etc.
|
|
233
|
+
if stimulus_type == "img":
|
|
234
|
+
stimulus_type = cnst.IMAGE_STR
|
|
235
|
+
return subject_id, stimulus_type, stimulus_name, rater
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class IRFDatasetLoader(BaseDatasetLoader):
|
|
239
|
+
"""
|
|
240
|
+
Loads the dataset from a replication study of the article:
|
|
241
|
+
Using machine learning to detect events in eye-tracking data. Zemblys et al. (2018).
|
|
242
|
+
See also about the repro study: https://github.com/r-zemblys/irf/blob/master/doc/IRF_replication_report.pdf
|
|
243
|
+
|
|
244
|
+
Note: binocular data was recorded but only one pair of (x, y) coordinates is provided.
|
|
245
|
+
|
|
246
|
+
This loader is based on a previous implementation, see article:
|
|
247
|
+
Startsev, M., Zemblys, R. Evaluating Eye Movement Event Detection: A Review of the State of the Art. Behav Res 55, 1653–1714 (2023)
|
|
248
|
+
See their implementation: https://github.com/r-zemblys/EM-event-detection-evaluation/blob/main/misc/data_parsers/humanFixationClassification.py
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
_NAME = "IRF"
|
|
252
|
+
_URL = r'https://github.com/r-zemblys/irf/archive/refs/heads/master.zip'
|
|
253
|
+
_LICENSE = "MIT"
|
|
254
|
+
_ARTICLES = [
|
|
255
|
+
"Zemblys, Raimondas and Niehorster, Diederick C and Komogortsev, Oleg and Holmqvist, Kenneth. Using machine " +
|
|
256
|
+
"learning to detect events in eye-tracking data. Behavior Research Methods, 50(1), 160–181 (2018)."
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
__PREFIX = 'irf-master/etdata/lookAtPoint_EL'
|
|
260
|
+
__STIMULUS_TYPE_VAL = "moving_dot" # all subjects were shown the same 13-point moving dot stimulus
|
|
261
|
+
__VIEWER_DISTANCE_CM_VAL = 56.5
|
|
262
|
+
__MONITOR_WIDTH_CM_VAL, __MONITOR_HEIGHT_CM_VAL = 37.5, 30.2
|
|
263
|
+
__MONITOR_RESOLUTION_VAL = (1280, 1024)
|
|
264
|
+
__PIXEL_SIZE_CM_VAL = calculate_pixel_size(
|
|
265
|
+
__MONITOR_WIDTH_CM_VAL, __MONITOR_HEIGHT_CM_VAL, __MONITOR_RESOLUTION_VAL
|
|
266
|
+
)
|
|
267
|
+
__RATER_NAME = "RZ"
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def _parse_response(cls, response: req.Response, verbose: bool = False) -> pd.DataFrame:
|
|
271
|
+
zip_file = zp.ZipFile(io.BytesIO(response.content))
|
|
272
|
+
gaze_file_names = [
|
|
273
|
+
f for f in zip_file.namelist() if
|
|
274
|
+
(f.startswith(psx.join(cls.__PREFIX, "lookAtPoint_EL_")) and f.endswith('.npy'))
|
|
275
|
+
]
|
|
276
|
+
gaze_dfs = []
|
|
277
|
+
for i, f in tqdm(enumerate(gaze_file_names), desc="Processing Files", disable=not verbose):
|
|
278
|
+
file = zip_file.open(f)
|
|
279
|
+
_, file_name, _ = cls._extract_filename_and_extension(f)
|
|
280
|
+
gaze_data = pd.DataFrame(np.load(file))
|
|
281
|
+
gaze_data['evt'] = gaze_data['evt'].apply(lambda x: parse_label(x, safe=True)) # convert labels
|
|
282
|
+
gaze_data[cnst.SUBJECT_ID_STR] = file_name.split('_')[-1] # format: "lookAtPoint_EL_S<subject_num>"
|
|
283
|
+
gaze_data[cnst.TRIAL_ID_STR] = i + 1
|
|
284
|
+
gaze_data[cnst.PUPIL] = np.nan
|
|
285
|
+
gaze_dfs.append(gaze_data)
|
|
286
|
+
df = pd.concat(gaze_dfs, ignore_index=True, axis=0)
|
|
287
|
+
|
|
288
|
+
# add metadata columns:
|
|
289
|
+
df[cnst.STIMULUS_TYPE_STR] = cls.__STIMULUS_TYPE_VAL
|
|
290
|
+
df[cnst.VIEWER_DISTANCE_STR] = cls.__VIEWER_DISTANCE_CM_VAL
|
|
291
|
+
df[cnst.PIXEL_SIZE_STR] = cls.__PIXEL_SIZE_CM_VAL
|
|
292
|
+
|
|
293
|
+
# remap columns to correct values
|
|
294
|
+
df.rename(
|
|
295
|
+
columns={"t": cnst.T, "evt": cls.__RATER_NAME, "x": cnst.X, "y": cnst.Y}, inplace=True
|
|
296
|
+
)
|
|
297
|
+
df = cls.__correct_coordinates(df)
|
|
298
|
+
return df
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def __correct_coordinates(cls, df: pd.DataFrame) -> pd.DataFrame:
|
|
302
|
+
new_df = df.copy()
|
|
303
|
+
nan_idxs = new_df[~new_df[cnst.STATUS_STR]].index
|
|
304
|
+
new_df.loc[nan_idxs, cnst.X] = np.nan
|
|
305
|
+
new_df.loc[nan_idxs, cnst.Y] = np.nan
|
|
306
|
+
new_df.drop(columns=[cnst.STATUS_STR], inplace=True)
|
|
307
|
+
|
|
308
|
+
pixel_width_cm = cls.__MONITOR_WIDTH_CM_VAL / cls.__MONITOR_RESOLUTION_VAL[0]
|
|
309
|
+
x = new_df[cnst.X].apply(
|
|
310
|
+
lambda ang: visual_angle_to_pixels(
|
|
311
|
+
angle=ang, d=cls.__VIEWER_DISTANCE_CM_VAL, pixel_size=pixel_width_cm, use_radians=False, keep_sign=True
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
x += cls.__MONITOR_RESOLUTION_VAL[0] // 2 # move x=0 coordinate to the left of the screen
|
|
315
|
+
pixel_height = cls.__MONITOR_HEIGHT_CM_VAL / cls.__MONITOR_RESOLUTION_VAL[1]
|
|
316
|
+
y = new_df[cnst.Y].apply(
|
|
317
|
+
lambda ang: visual_angle_to_pixels(
|
|
318
|
+
angle=ang, d=cls.__VIEWER_DISTANCE_CM_VAL, pixel_size=pixel_height, use_radians=False, keep_sign=True
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
y += cls.__MONITOR_RESOLUTION_VAL[1] // 2 # move y=0 coordinate to the top of the screen
|
|
322
|
+
new_df.loc[:, cnst.X] = x.astype("float32")
|
|
323
|
+
new_df.loc[:, cnst.Y] = y.astype("float32")
|
|
324
|
+
return new_df
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class HFCDatasetLoader(BaseDatasetLoader):
|
|
328
|
+
"""
|
|
329
|
+
Loads the two datasets presented in articles:
|
|
330
|
+
- (adults) Is human classification by experienced untrained observers a gold standard in fixation detection?
|
|
331
|
+
Hooge et al. (2018)
|
|
332
|
+
- (infants) An in-depth look at saccadic search in infancy.
|
|
333
|
+
Hessels et al. (2016)
|
|
334
|
+
|
|
335
|
+
This loader is based on a previous implementation, see article:
|
|
336
|
+
Startsev, M., Zemblys, R. Evaluating Eye Movement Event Detection: A Review of the State of the Art. Behav Res 55, 1653–1714 (2023)
|
|
337
|
+
See their implementation: https://github.com/r-zemblys/EM-event-detection-evaluation/blob/main/misc/data_parsers/humanFixationClassification.py
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
_NAME: str = "HFC"
|
|
341
|
+
_URL = r'https://github.com/dcnieho/humanFixationClassification/archive/refs/heads/master.zip'
|
|
342
|
+
_LICENSE = "CC NC-BY-SA 4.0"
|
|
343
|
+
_ARTICLES = [
|
|
344
|
+
"Hooge, I.T.C., Niehorster, D.C., Nyström, M., Andersson, R. & Hessels, R.S. (2018). Is human classification " +
|
|
345
|
+
"by experienced untrained observers a gold standard in fixation detection?",
|
|
346
|
+
"Hessels, R.S., Hooge, I.T.C., & Kemner, C. (2016). An in-depth look at saccadic search in infancy. " +
|
|
347
|
+
"Journal of Vision, 16(8), 10.",
|
|
348
|
+
"Startsev, M., Zemblys, R. Evaluating Eye Movement Event Detection: A Review of the State of the Art. " +
|
|
349
|
+
"Behav Res 55, 1653–1714 (2023)"
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
__PREFIX = 'humanFixationClassification-master/data'
|
|
353
|
+
__SUBJECT_GROUP_STR = "subject_group"
|
|
354
|
+
__INFANT_STR, __ADULT_STR = "infant", "adult"
|
|
355
|
+
__SEARCH_TASK_STR, __FREE_VIEWING_STR = "search_task", "free_viewing"
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def column_order() -> Dict[str, float]:
|
|
359
|
+
return {
|
|
360
|
+
**super(HFCDatasetLoader, HFCDatasetLoader).column_order(), HFCDatasetLoader.__SUBJECT_GROUP_STR: 6.1
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
@classmethod
|
|
364
|
+
def _parse_response(cls, response: req.Response, verbose: bool = False) -> pd.DataFrame:
|
|
365
|
+
zip_file = zp.ZipFile(io.BytesIO(response.content))
|
|
366
|
+
# extract gaze data:
|
|
367
|
+
gaze_file_names = [
|
|
368
|
+
f for f in zip_file.namelist() if
|
|
369
|
+
(f.startswith(psx.join(cls.__PREFIX, "ETdata")) and f.endswith('.txt'))
|
|
370
|
+
]
|
|
371
|
+
gaze_dfs = {}
|
|
372
|
+
for i, f in tqdm(enumerate(gaze_file_names), desc="Processing Files", disable=not verbose):
|
|
373
|
+
file = zip_file.open(f)
|
|
374
|
+
gaze_data = pd.read_csv(file, sep='\t')
|
|
375
|
+
gaze_data[cnst.PUPIL] = np.nan # no pupil data available
|
|
376
|
+
_, file_name, _ = cls._extract_filename_and_extension(file.name)
|
|
377
|
+
subject_group, subject_id = file_name.split('_') # format: "<subject_type>_<subject_id>"
|
|
378
|
+
gaze_data[cnst.SUBJECT_ID_STR] = subject_id
|
|
379
|
+
gaze_data[cls.__SUBJECT_GROUP_STR] = subject_group
|
|
380
|
+
gaze_data[cnst.STIMULUS_TYPE_STR] = cls.__FREE_VIEWING_STR if subject_group == cls.__ADULT_STR else cls.__SEARCH_TASK_STR
|
|
381
|
+
gaze_data[cnst.TRIAL_ID_STR] = i + 1
|
|
382
|
+
gaze_dfs[file_name] = gaze_data
|
|
383
|
+
|
|
384
|
+
# extract annotations:
|
|
385
|
+
coder_file_names = [
|
|
386
|
+
f for f in zip_file.namelist() if
|
|
387
|
+
(f.startswith(psx.join(cls.__PREFIX, "coderSettings")) and f.endswith('.txt'))
|
|
388
|
+
]
|
|
389
|
+
annotation_dfs = {}
|
|
390
|
+
for f in coder_file_names:
|
|
391
|
+
with zip_file.open(f) as open_file:
|
|
392
|
+
rater_data = pd.read_csv(open_file, sep='\t')
|
|
393
|
+
_, rater_name, _ = cls._extract_filename_and_extension(open_file.name)
|
|
394
|
+
annotation_dfs[rater_name.upper()] = rater_data
|
|
395
|
+
|
|
396
|
+
# merge annotations with gaze data:
|
|
397
|
+
merged_dfs = []
|
|
398
|
+
for key, data in gaze_dfs.items():
|
|
399
|
+
if data is None or len(data) == 0 or data.empty:
|
|
400
|
+
continue
|
|
401
|
+
l = len(data)
|
|
402
|
+
for rater_name in annotation_dfs.keys():
|
|
403
|
+
annotations = annotation_dfs.get(rater_name).query("Trial==@key")
|
|
404
|
+
if annotations is None or len(annotations) == 0 or annotations.empty:
|
|
405
|
+
labels = np.zeros(l, dtype=int)
|
|
406
|
+
else:
|
|
407
|
+
# reached here if there are annotations from this rater for this trial
|
|
408
|
+
labels = np.zeros(l, dtype=int)
|
|
409
|
+
for _, row in annotations.iterrows():
|
|
410
|
+
f = interp1d(
|
|
411
|
+
data["time"], range(l), kind="nearest", bounds_error=False, fill_value="extrapolate"
|
|
412
|
+
)
|
|
413
|
+
fixation_samples = itertools.chain(
|
|
414
|
+
*[range(int(s), int(e + 1)) for s, e in zip(
|
|
415
|
+
f(annotations["FixStart"]), f(annotations["FixEnd"])
|
|
416
|
+
)]
|
|
417
|
+
)
|
|
418
|
+
labels[list(fixation_samples)] = 1
|
|
419
|
+
data[rater_name] = labels
|
|
420
|
+
data[rater_name] = data[rater_name].apply(lambda x: parse_label(x, safe=True))
|
|
421
|
+
merged_dfs.append(data)
|
|
422
|
+
full_dataset = pd.concat(merged_dfs, ignore_index=True, axis=0)
|
|
423
|
+
full_dataset.rename(columns={"time": cnst.T, "x": cnst.X, "y": cnst.Y}, inplace=True)
|
|
424
|
+
return full_dataset
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class GazeComDatasetLoader(BaseDatasetLoader):
|
|
428
|
+
"""
|
|
429
|
+
Loads a labelled subset of the dataset presented in the article:
|
|
430
|
+
Michael Dorr, Thomas Martinetz, Karl Gegenfurtner, and Erhardt Barth. Variability of eye movements when viewing
|
|
431
|
+
dynamic natural scenes. Journal of Vision, 10(10):1-17, 2010.
|
|
432
|
+
|
|
433
|
+
Labels are from the article:
|
|
434
|
+
Agtzidis, I., Startsev, M., & Dorr, M. (2016a). In the pursuit of (ground) truth: A hand-labelling tool for eye
|
|
435
|
+
movements recorded during dynamic scene viewing. In 2016 IEEE second workshop on eye tracking and visualization
|
|
436
|
+
(ETVIS) (pp. 65–68).
|
|
437
|
+
|
|
438
|
+
Note 1: This dataset is extremely large and may take a long time to download. It is recommended to save it to local
|
|
439
|
+
storage after downloading it for faster access in the future. The method `load_zipfile` can be used to load the
|
|
440
|
+
dataset from a raw zip file stored in a local directory.
|
|
441
|
+
Note 2: This is only a subset of the full GazeCom Dataset, containing hand-labelled samples. The full dataset with
|
|
442
|
+
documentation can be found in https://www.inb.uni-luebeck.de/index.php?id=515.
|
|
443
|
+
Note 3: binocular data was recorded but only one pair of (x, y) coordinates is provided.
|
|
444
|
+
|
|
445
|
+
This loader is based on a previous implementation, see article:
|
|
446
|
+
Startsev, M., Zemblys, R. Evaluating Eye Movement Event Detection: A Review of the State of the Art. Behav Res 55, 1653–1714 (2023)
|
|
447
|
+
See their implementation: https://github.com/r-zemblys/EM-event-detection-evaluation/blob/main/misc/data_parsers/tum.py
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
_NAME: str = "GazeCom"
|
|
451
|
+
_URL = r'https://gin.g-node.org/ioannis.agtzidis/gazecom_annotations/archive/master.zip'
|
|
452
|
+
_LICENSE = "GNU GPL-3.0"
|
|
453
|
+
_ARTICLES = [
|
|
454
|
+
"Agtzidis, I., Startsev, M., & Dorr, M. (2016a). In the pursuit of (ground) truth: A hand-labelling tool for " +
|
|
455
|
+
"eye movements recorded during dynamic scene viewing. In 2016 IEEE second workshop on eye tracking and" +
|
|
456
|
+
"visualization (ETVIS) (pp. 65–68).",
|
|
457
|
+
|
|
458
|
+
"Michael Dorr, Thomas Martinetz, Karl Gegenfurtner, and Erhardt Barth. Variability of eye movements when " +
|
|
459
|
+
"viewing dynamic natural scenes. Journal of Vision, 10(10):1-17, 2010.",
|
|
460
|
+
"Startsev, M., Agtzidis, I., & Dorr, M. (2016). Smooth pursuit. http://michaeldorr.de/smoothpursuit/",
|
|
461
|
+
|
|
462
|
+
"Startsev, M., Zemblys, R. Evaluating Eye Movement Event Detection: A Review of the State of the Art. " +
|
|
463
|
+
"Behav Res 55, 1653–1714 (2023)"
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
__PREFIX = psx.join('gazecom_annotations', 'ground_truth')
|
|
467
|
+
__ZIPFILE_NAME = "gazecom_annotations-master.zip"
|
|
468
|
+
__HANDLABELLER_PREFIX = "HL"
|
|
469
|
+
__VIEWER_DISTANCE_CM_VAL = 56.5
|
|
470
|
+
__MONITOR_WIDTH_CM_VAL, __MONITOR_HEIGHT_CM_VAL = 40, 22.5
|
|
471
|
+
__MONITOR_RESOLUTION_VAL = (1280, 720)
|
|
472
|
+
__PIXEL_SIZE_CM_VAL = calculate_pixel_size(
|
|
473
|
+
__MONITOR_WIDTH_CM_VAL, __MONITOR_HEIGHT_CM_VAL, __MONITOR_RESOLUTION_VAL
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
__LABEL_MAP = {
|
|
477
|
+
0: EventLabelEnum.UNDEFINED,
|
|
478
|
+
1: EventLabelEnum.FIXATION,
|
|
479
|
+
2: EventLabelEnum.SACCADE,
|
|
480
|
+
3: EventLabelEnum.SMOOTH_PURSUIT,
|
|
481
|
+
4: EventLabelEnum.UNDEFINED # noise
|
|
482
|
+
}
|
|
483
|
+
__COLUMN_MAP = {
|
|
484
|
+
"time": cnst.T, "x": cnst.X, "y": cnst.Y, "handlabeller1": f"{__HANDLABELLER_PREFIX}1",
|
|
485
|
+
"handlabeller2": f"{__HANDLABELLER_PREFIX}2", "handlabeller_final": f"{__HANDLABELLER_PREFIX}_FINAL"
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
@classmethod
|
|
489
|
+
def load_zipfile(cls, root: str = None, verbose: bool = False) -> pd.DataFrame:
|
|
490
|
+
"""
|
|
491
|
+
Loads the dataset from a zip file stored in a local directory (so it doesn't need to be downloaded again).
|
|
492
|
+
:param root: path to the directory containing the zip file.
|
|
493
|
+
:param verbose: whether to display progress bars.
|
|
494
|
+
:return: DataFrame with the annotated gaze data.
|
|
495
|
+
"""
|
|
496
|
+
if not root or not psx.isdir(root):
|
|
497
|
+
raise NotADirectoryError(f"Invalid directory: {root}")
|
|
498
|
+
zip_file = psx.join(root, cls.__ZIPFILE_NAME)
|
|
499
|
+
if not psx.isfile(zip_file):
|
|
500
|
+
raise FileNotFoundError(f"File not found: {zip_file}")
|
|
501
|
+
with zp.ZipFile(zip_file, 'r') as zip_ref:
|
|
502
|
+
df = cls.__read_zipfile(zf=zip_ref, verbose=verbose)
|
|
503
|
+
return cls._reorder_columns(df)
|
|
504
|
+
|
|
505
|
+
@staticmethod
|
|
506
|
+
def column_order() -> Dict[str, float]:
|
|
507
|
+
handlabeller_scores = {
|
|
508
|
+
f"{GazeComDatasetLoader.__HANDLABELLER_PREFIX}1": 5.1,
|
|
509
|
+
f"{GazeComDatasetLoader.__HANDLABELLER_PREFIX}2": 5.2,
|
|
510
|
+
f"{GazeComDatasetLoader.__HANDLABELLER_PREFIX}_FINAL": 5.3
|
|
511
|
+
}
|
|
512
|
+
return {**super(GazeComDatasetLoader, GazeComDatasetLoader).column_order(), **handlabeller_scores}
|
|
513
|
+
|
|
514
|
+
@classmethod
|
|
515
|
+
def _parse_response(cls, response: req.Response, verbose: bool = False) -> pd.DataFrame:
|
|
516
|
+
zip_file = zp.ZipFile(io.BytesIO(response.content))
|
|
517
|
+
return cls.__read_zipfile(zf=zip_file, verbose=verbose)
|
|
518
|
+
|
|
519
|
+
@classmethod
|
|
520
|
+
def __read_zipfile(cls, zf: zp.ZipFile, verbose: bool = False) -> pd.DataFrame:
|
|
521
|
+
"""
|
|
522
|
+
Reads the contents of a zip file and returns a DataFrame with the annotated data.
|
|
523
|
+
:param zf: ZipFile object
|
|
524
|
+
:param verbose: whether to display progress bars
|
|
525
|
+
:return: DataFrame with annotated gaze data
|
|
526
|
+
"""
|
|
527
|
+
annotated_file_names = [f for f in zf.namelist() if (f.endswith('.arff') and cls.__PREFIX in f)]
|
|
528
|
+
gaze_dfs = []
|
|
529
|
+
for i, f in tqdm(enumerate(annotated_file_names), desc="Processing Files", disable=not verbose):
|
|
530
|
+
file = zf.open(f)
|
|
531
|
+
data = arff.loads(file.read().decode('utf-8'))
|
|
532
|
+
|
|
533
|
+
# parse gaze data:
|
|
534
|
+
df = pd.DataFrame(data['data'], columns=[attr[0] for attr in data['attributes']])
|
|
535
|
+
df[cnst.PUPIL] = np.nan # no pupil data available
|
|
536
|
+
invalid_idxs = np.where(np.all(df[["x", "y"]] == 0, axis=1) | (df["confidence"] < 0.5))[0]
|
|
537
|
+
df.iloc[invalid_idxs, df.columns.get_indexer(["x", "y"])] = np.nan
|
|
538
|
+
df['time'] = df['time'] / cnst.MILLISECONDS_PER_SECOND
|
|
539
|
+
df.drop(columns=['confidence'], inplace=True)
|
|
540
|
+
df.rename(columns=cls.__COLUMN_MAP, inplace=True)
|
|
541
|
+
for col in df.columns:
|
|
542
|
+
if col.startswith(cls.__HANDLABELLER_PREFIX):
|
|
543
|
+
df[col] = df[col].map(cls.__LABEL_MAP)
|
|
544
|
+
|
|
545
|
+
# add metadata columns:
|
|
546
|
+
_, file_name, _ = cls._extract_filename_and_extension(f)
|
|
547
|
+
subj_id = file_name.split('_')[0] # file_name: <subject_id>_<stimulus>_<name>_<with>_<underscores>.arff
|
|
548
|
+
stimulus = '_'.join(file_name.split('_')[1:])
|
|
549
|
+
df[cnst.SUBJECT_ID_STR] = subj_id
|
|
550
|
+
df[cnst.STIMULUS_NAME_STR] = stimulus
|
|
551
|
+
df[cnst.TRIAL_ID_STR] = i + 1
|
|
552
|
+
gaze_dfs.append(df)
|
|
553
|
+
|
|
554
|
+
# merge and add common metadata columns:
|
|
555
|
+
full_dataset = pd.concat(gaze_dfs, ignore_index=True, axis=0)
|
|
556
|
+
full_dataset[cnst.STIMULUS_TYPE_STR] = cnst.VIDEO_STR
|
|
557
|
+
full_dataset[cnst.VIEWER_DISTANCE_STR] = cls.__VIEWER_DISTANCE_CM_VAL
|
|
558
|
+
full_dataset[cnst.PIXEL_SIZE_STR] = cls.__PIXEL_SIZE_CM_VAL
|
|
559
|
+
return full_dataset
|