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.
Files changed (42) hide show
  1. peyes/_DataModels/DatasetLoader.py +559 -0
  2. peyes/_DataModels/Detector.py +1383 -0
  3. peyes/_DataModels/Event.py +434 -0
  4. peyes/_DataModels/EventLabelEnum.py +14 -0
  5. peyes/_DataModels/EventMatcher.py +298 -0
  6. peyes/_DataModels/UnparsedEventLabel.py +9 -0
  7. peyes/_DataModels/config.py +53 -0
  8. peyes/__init__.py +15 -0
  9. peyes/_base/create.py +241 -0
  10. peyes/_base/match.py +122 -0
  11. peyes/_base/parse.py +52 -0
  12. peyes/_base/postprocess_events.py +42 -0
  13. peyes/_utils/constants.py +106 -0
  14. peyes/_utils/event_utils.py +95 -0
  15. peyes/_utils/metric_utils.py +104 -0
  16. peyes/_utils/pixel_utils.py +180 -0
  17. peyes/_utils/vector_utils.py +107 -0
  18. peyes/_utils/visualization_utils.py +128 -0
  19. peyes/channel_metrics/__init__.py +3 -0
  20. peyes/channel_metrics/_signal_detection_metrics.py +166 -0
  21. peyes/channel_metrics/_timing_differences.py +92 -0
  22. peyes/datasets/__init__.py +52 -0
  23. peyes/datasets/get_metadata.py +45 -0
  24. peyes/datasets/load_dataset.py +33 -0
  25. peyes/event_metrics/__init__.py +44 -0
  26. peyes/event_metrics/_get_features.py +73 -0
  27. peyes/event_metrics/_rates_and_transitions.py +57 -0
  28. peyes/match_metrics/__init__.py +42 -0
  29. peyes/match_metrics/_get_features.py +59 -0
  30. peyes/match_metrics/_match_evaluation.py +142 -0
  31. peyes/sample_metrics/__init__.py +72 -0
  32. peyes/sample_metrics/_calculate_metrics.py +120 -0
  33. peyes/sample_metrics/_counts_and_matrices.py +52 -0
  34. peyes/visualize/__init__.py +6 -0
  35. peyes/visualize/_event_summary.py +279 -0
  36. peyes/visualize/_features.py +192 -0
  37. peyes/visualize/_gaze.py +239 -0
  38. peyes/visualize/_scarfplot.py +97 -0
  39. peyes/visualize/_video.py +122 -0
  40. peyes-0.0.7.dist-info/METADATA +60 -0
  41. peyes-0.0.7.dist-info/RECORD +42 -0
  42. 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