diff --git a/src/pymovements/__init__.py b/src/pymovements/__init__.py index fd52b4827..d7591775a 100644 --- a/src/pymovements/__init__.py +++ b/src/pymovements/__init__.py @@ -21,6 +21,7 @@ from pymovements import _version from pymovements import datasets from pymovements import events +from pymovements import exceptions from pymovements import gaze from pymovements import plotting from pymovements import synthetic @@ -56,6 +57,7 @@ 'Screen', 'GazeDataFrame', + 'exceptions', 'plotting', 'synthetic', 'utils', diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py index 7eea58629..987d39a2f 100644 --- a/src/pymovements/dataset/dataset.py +++ b/src/pymovements/dataset/dataset.py @@ -20,7 +20,6 @@ """This module provides the base dataset class.""" from __future__ import annotations -import inspect from collections.abc import Callable from copy import deepcopy from pathlib import Path @@ -34,7 +33,6 @@ from pymovements.dataset.dataset_definition import DatasetDefinition from pymovements.dataset.dataset_library import DatasetLibrary from pymovements.dataset.dataset_paths import DatasetPaths -from pymovements.events.detection import EventDetectionLibrary from pymovements.events.frame import EventDataFrame from pymovements.events.processing import EventGazeProcessor from pymovements.gaze import GazeDataFrame @@ -360,7 +358,7 @@ def detect_events( self, method: Callable[..., EventDataFrame] | str, *, - eye: str | None = 'auto', + eye: str = 'auto', clear: bool = False, verbose: bool = True, **kwargs: Any, @@ -393,134 +391,19 @@ def detect_events( Dataset Returns self, useful for method cascading. """ - self._check_gaze_dataframe() - - if isinstance(method, str): - method = EventDetectionLibrary.get(method) - - # this is just a work-around until merged columns are standard behavior - # https://github.com/aeye-lab/pymovements/pull/443 - unnested_columns = {} - if 'position' in self.gaze[0].frame.columns: - unnested_columns_pos = [ - 'x_left_pos', 'y_left_pos', - 'x_right_pos', 'y_right_pos', - 'x_avg_pos', 'y_avg_pos', - ][:self.gaze[0].n_components] - unnested_columns['position'] = unnested_columns_pos - else: - raise pl.exceptions.ColumnNotFoundError( - f'Column \'position\' not found.' - f' Available columns are: {self.gaze[0].frame.columns}', - ) - - if 'velocity' in self.gaze[0].frame.columns: - unnested_columns_vel = [ - 'x_left_vel', 'y_left_vel', - 'x_right_vel', 'y_right_vel', - 'x_avg_vel', 'y_avg_vel', - ][:self.gaze[0].n_components] - unnested_columns['velocity'] = unnested_columns_vel - else: - raise pl.exceptions.ColumnNotFoundError( - f'Column \'velocity\' not found.' - f' Available columns are: {self.gaze[0].frame.columns}', - ) - - self.gaze[0].unnest('position', output_columns=unnested_columns['position']) - self.gaze[0].unnest('velocity', output_columns=unnested_columns['velocity']) - - if ( - isinstance(self.gaze[0].n_components, int) - and self.gaze[0].n_components < 4 - and eye not in [None, 'auto'] - ): - raise AttributeError() - - # Automatically infer eye to use for event detection. - if eye == 'auto': - if 'x_avg_pos' in self.gaze[0].columns: - eye = 'avg' - elif 'x_right_pos' in self.gaze[0].columns: - eye = 'right' - else: - eye = 'left' - - position_columns = [f'x_{eye}_pos', f'y_{eye}_pos'] - velocity_columns = [f'x_{eye}_vel', f'y_{eye}_vel'] - - # this is just a work-around until merged columns are standard behavior - # https://github.com/aeye-lab/pymovements/pull/443 - self.gaze[0].nest( - input_columns=unnested_columns['position'], - output_column='position', - ) - self.gaze[0].nest( - input_columns=unnested_columns['velocity'], - output_column='velocity', + return self.detect( + method=method, + eye=eye, + clear=clear, + verbose=verbose, + **kwargs, ) - disable_progressbar = not verbose - - if not self.events or clear: - self.events = [EventDataFrame() for _ in self.fileinfo.iter_rows()] - - for file_id, (gaze_df, fileinfo_row) in tqdm( - enumerate(zip(self.gaze, self.fileinfo.to_dicts())), disable=disable_progressbar, - ): - # this is just a work-around until merged columns are standard behavior - # https://github.com/aeye-lab/pymovements/pull/443 - gaze_df.unnest('position', output_columns=unnested_columns['position']) - gaze_df.unnest('velocity', output_columns=unnested_columns['velocity']) - - positions = gaze_df.frame.select(position_columns).to_numpy() - velocities = gaze_df.frame.select(velocity_columns).to_numpy() - timesteps = gaze_df.frame.get_column('time').to_numpy() - - method_args = inspect.getfullargspec(method).args - - if 'positions' in method_args: - kwargs['positions'] = positions - - if 'velocities' in method_args: - kwargs['velocities'] = velocities - - if 'events' in method_args: - kwargs['events'] = self.events[file_id] - - kwargs['timesteps'] = timesteps - - new_event_df = method(**kwargs) - - new_event_df.frame = dataset_files.add_fileinfo( - definition=self.definition, - df=new_event_df.frame, - fileinfo=fileinfo_row, - ) - - self.events[file_id].frame = pl.concat( - [self.events[file_id].frame, new_event_df.frame], - how='diagonal', - ) - - # this is just a work-around until merged columns are standard behavior - # https://github.com/aeye-lab/pymovements/pull/443 - gaze_df.nest( - input_columns=unnested_columns['position'], - output_column='position', - ) - gaze_df.nest( - input_columns=unnested_columns['velocity'], - output_column='velocity', - ) - - return self - def detect( self, method: Callable[..., EventDataFrame] | str, *, - eye: str | None = 'auto', + eye: str = 'auto', clear: bool = False, verbose: bool = True, **kwargs: Any, @@ -555,13 +438,24 @@ def detect( Dataset Returns self, useful for method cascading. """ - return self.detect_events( - method=method, - eye=eye, - clear=clear, - verbose=verbose, - **kwargs, - ) + self._check_gaze_dataframe() + + if not self.events: + self.events = [gaze.events for gaze in self.gaze] + + disable_progressbar = not verbose + for file_id, (gaze, fileinfo_row) in tqdm( + enumerate(zip(self.gaze, self.fileinfo.to_dicts())), disable=disable_progressbar, + ): + gaze.detect(method, eye=eye, clear=clear, **kwargs) + # workaround until events are fully part of the GazeDataFrame + gaze.events.frame = dataset_files.add_fileinfo( + definition=self.definition, + df=gaze.events.frame, + fileinfo=fileinfo_row, + ) + self.events[file_id] = gaze.events + return self def compute_event_properties( self, diff --git a/src/pymovements/dataset/dataset_files.py b/src/pymovements/dataset/dataset_files.py index 8dd3899f5..ef0149f44 100644 --- a/src/pymovements/dataset/dataset_files.py +++ b/src/pymovements/dataset/dataset_files.py @@ -351,7 +351,7 @@ def add_fileinfo( [ pl.lit(value).alias(column) for column, value in fileinfo.items() - if column != 'filepath' + if column != 'filepath' and column not in df.columns ] + [pl.all()], ) diff --git a/src/pymovements/events/detection/_ivt.py b/src/pymovements/events/detection/_ivt.py index 12cc8e848..fda854448 100644 --- a/src/pymovements/events/detection/_ivt.py +++ b/src/pymovements/events/detection/_ivt.py @@ -111,6 +111,9 @@ def ivt( if include_nan: candidates = filter_candidates_remove_nans(candidates=candidates, values=velocities) + # Remove empty candidates. + candidates = [candidate for candidate in candidates if len(candidate) > 0] + # Filter all candidates by minimum duration. candidates = [ candidate for candidate in candidates diff --git a/src/pymovements/events/processing.py b/src/pymovements/events/processing.py index 15a51bf86..5f8b57970 100644 --- a/src/pymovements/events/processing.py +++ b/src/pymovements/events/processing.py @@ -26,10 +26,10 @@ import polars as pl +import pymovements as pm # pylint: disable=cyclic-import from pymovements.events.frame import EventDataFrame from pymovements.events.properties import EVENT_PROPERTIES from pymovements.exceptions import InvalidProperty -from pymovements.gaze.gaze_dataframe import GazeDataFrame class EventProcessor: @@ -140,7 +140,7 @@ def __init__( def process( self, events: EventDataFrame, - gaze: GazeDataFrame, + gaze: pm.GazeDataFrame, identifiers: str | list[str], name: str | None = None, ) -> pl.DataFrame: diff --git a/src/pymovements/gaze/gaze_dataframe.py b/src/pymovements/gaze/gaze_dataframe.py index 5561b358c..29e53a50a 100644 --- a/src/pymovements/gaze/gaze_dataframe.py +++ b/src/pymovements/gaze/gaze_dataframe.py @@ -25,8 +25,10 @@ from copy import deepcopy from typing import Any +import numpy as np import polars as pl +import pymovements as pm # pylint: disable=cyclic-import from pymovements.gaze import transforms from pymovements.gaze.experiment import Experiment from pymovements.utils import checks @@ -79,6 +81,7 @@ def __init__( self, data: pl.DataFrame | None = None, experiment: Experiment | None = None, + events: pm.EventDataFrame | None = None, *, trial_columns: str | list[str] | None = None, time_column: str | None = None, @@ -95,6 +98,8 @@ def __init__( A dataframe to be transformed to a polars dataframe. experiment : Experiment The experiment definition. + events: EventDataFrame + A dataframe of events in the gaze signal. trial_columns: The name of the trial columns in the input data frame. If the list is empty or None, the input data frame is assumed to contain only one trial. If the list is not empty, @@ -192,28 +197,33 @@ def __init__( column_specifiers: list[list[str]] = [] if pixel_columns: - _check_component_columns(self.frame, pixel_columns=pixel_columns) + self._check_component_columns(pixel_columns=pixel_columns) self.nest(pixel_columns, output_column='pixel') column_specifiers.append(pixel_columns) if position_columns: - _check_component_columns(self.frame, position_columns=position_columns) + self._check_component_columns(position_columns=position_columns) self.nest(position_columns, output_column='position') column_specifiers.append(position_columns) if velocity_columns: - _check_component_columns(self.frame, velocity_columns=velocity_columns) + self._check_component_columns(velocity_columns=velocity_columns) self.nest(velocity_columns, output_column='velocity') column_specifiers.append(velocity_columns) if acceleration_columns: - _check_component_columns(self.frame, acceleration_columns=acceleration_columns) + self._check_component_columns(acceleration_columns=acceleration_columns) self.nest(acceleration_columns, output_column='acceleration') column_specifiers.append(acceleration_columns) - self.n_components = _infer_n_components(self.frame, column_specifiers) + self.n_components = self._infer_n_components(column_specifiers) self.experiment = experiment + if events is None: + self.events = pm.EventDataFrame() + else: + self.events = events.copy() + def transform( self, transform_method: str | Callable[..., pl.Expr], @@ -263,7 +273,7 @@ def transform( kwargs['sampling_rate'] = self.experiment.sampling_rate if 'n_components' in method_kwargs and 'n_components' not in kwargs: - _check_n_components(self.n_components) + self._check_n_components() kwargs['n_components'] = self.n_components if transform_method.__name__ in {'pos2vel', 'pos2acc'}: @@ -374,6 +384,44 @@ def pos2vel( """ self.transform('pos2vel', method=method, **kwargs) + def detect( + self, + method: Callable[..., pm.EventDataFrame] | str, + *, + eye: str = 'auto', + clear: bool = False, + **kwargs: Any, + ) -> None: + """Detect events by applying a specific event detection method. + + Parameters + ---------- + method : EventDetectionCallable + The event detection method to be applied. + eye : str + Select which eye to choose. Valid options are ``auto``, ``left``, ``right`` or ``None``. + If ``auto`` is passed, eye is inferred in the order ``['right', 'left', 'eye']`` from + the available :py:attr:`~.Dataset.gaze` dataframe columns. + clear : bool + If ``True``, event DataFrame will be overwritten with new DataFrame instead of being + merged into the existing one. + **kwargs : + Additional keyword arguments to be passed to the event detection method. + """ + if not self.events or clear: + self.events = pm.EventDataFrame() + + if isinstance(method, str): + method = pm.events.EventDetectionLibrary.get(method) + + method_kwargs = self._fill_event_detection_kwargs(method, eye, **kwargs) + new_events = method(**method_kwargs) + + self.events.frame = pl.concat( + [self.events.frame, new_events.frame], + how='diagonal', + ) + @property def schema(self) -> pl.type_aliases.SchemaDict: """Schema of event dataframe.""" @@ -400,7 +448,7 @@ def nest( output_column: Name of the resulting tuple column. """ - _check_component_columns(frame=self.frame, **{output_column: input_columns}) + self._check_component_columns(**{output_column: input_columns}) self.frame = self.frame.with_columns( pl.concat_list([pl.col(component) for component in input_columns]) @@ -440,7 +488,7 @@ def unnest( output_columns=output_columns, output_suffixes=output_suffixes, ) - _check_n_components(self.n_components) + self._check_n_components() col_names = output_columns if output_columns is not None else [] @@ -490,89 +538,191 @@ def _check_experiment(self) -> None: if self.experiment is None: raise AttributeError('experiment must not be None for this method to work') - -def _check_component_columns( - frame: pl.DataFrame, - **kwargs: list[str], -) -> None: - """Check if component columns are in valid format.""" - for component_type, columns in kwargs.items(): - if not isinstance(columns, list): - raise TypeError( - f'{component_type} must be of type list, but is of type {type(columns).__name__}', + def _check_n_components(self) -> None: + """Check that n_components is either 2, 4 or 6.""" + if self.n_components not in {2, 4, 6}: + raise AttributeError( + f'n_components must be either 2, 4 or 6 but is {self.n_components}', ) - for column in columns: - if not isinstance(column, str): + def _check_component_columns(self, **kwargs: list[str]) -> None: + """Check if component columns are in valid format.""" + for component_type, columns in kwargs.items(): + if not isinstance(columns, list): raise TypeError( - f'all elements in {component_type} must be of type str, ' - f'but one of the elements is of type {type(column).__name__}', + f'{component_type} must be of type list, ' + f'but is of type {type(columns).__name__}', ) - if len(columns) not in [2, 4, 6]: - raise ValueError( - f'{component_type} must contain either 2, 4 or 6 columns, but has {len(columns)}', - ) + for column in columns: + if not isinstance(column, str): + raise TypeError( + f'all elements in {component_type} must be of type str, ' + f'but one of the elements is of type {type(column).__name__}', + ) - for column in columns: - if column not in frame.columns: - raise pl.exceptions.ColumnNotFoundError( - f'column {column} from {component_type} is not available in dataframe', + if len(columns) not in [2, 4, 6]: + raise ValueError( + f'{component_type} must contain either 2, 4 or 6 columns, ' + f'but has {len(columns)}', ) - if len(set(frame[columns].dtypes)) != 1: - types_list = sorted([str(t) for t in set(frame[columns].dtypes)]) - raise ValueError( - f'all columns in {component_type} must be of same type, but types are {types_list}', - ) + for column in columns: + if column not in self.frame.columns: + raise pl.exceptions.ColumnNotFoundError( + f'column {column} from {component_type} is not available in dataframe', + ) + + if len(set(self.frame[columns].dtypes)) != 1: + types_list = sorted([str(t) for t in set(self.frame[columns].dtypes)]) + raise ValueError( + f'all columns in {component_type} must be of same type, ' + f'but types are {types_list}', + ) + + def _infer_n_components(self, column_specifiers: list[list[str]]) -> int | None: + """Infer number of components from DataFrame. + + Method checks nested columns `pixel`, `position`, `velocity` and `acceleration` for number + of components by getting their list lenghts, which must be equal for all else a ValueError + is raised. Additionally, a list of list of column specifiers is checked for consistency. + + Parameters + ---------- + column_specifiers: + List of list of column specifiers. + Returns + ------- + int or None + Number of components + + Raises + ------ + ValueError + If number of components is not equal for all considered columns and rows. + """ + all_considered_columns = ['pixel', 'position', 'velocity', 'acceleration'] + considered_columns = [ + column for column in all_considered_columns if column in self.frame.columns + ] -def _check_n_components(n_components: Any) -> None: - """Check that n_components is either 2, 4 or 6.""" - if n_components not in {2, 4, 6}: - raise AttributeError(f'n_components must be either 2, 4 or 6 but is {n_components}') + list_lengths = { + list_length + for column in considered_columns + for list_length in self.frame.get_column(column).list.lengths().unique().to_list() + } + for column_specifier_list in column_specifiers: + list_lengths.add(len(column_specifier_list)) -def _infer_n_components(frame: pl.DataFrame, column_specifiers: list[list[str]]) -> int | None: - """Infer number of components from DataFrame. + if len(list_lengths) > 1: + raise ValueError(f'inconsistent number of components inferred: {list_lengths}') - Method checks nested columns `pixel`, `position`, `velocity` and `acceleration` for number of - components by getting their list lenghts, which must be equal for all else a ValueError is - raised. Additionally, a list of list of column specifiers is checked for consistency. + if len(list_lengths) == 0: + return None - Parameters - ---------- - frame: pl.DataFrame - DataFrame to check. - column_specifiers: - List of list of column specifiers. + return next(iter(list_lengths)) - Returns - ------- - int or None - Number of components + def _infer_eye_components(self, eye: str) -> tuple[int, int]: + """Infer eye components from eye string. - Raises - ------ - ValueError - If number of components is not equal for all considered columns and rows. - """ - all_considered_columns = ['pixel', 'position', 'velocity', 'acceleration'] - considered_columns = [column for column in all_considered_columns if column in frame.columns] + Parameters + ---------- + eye: str + String specificer for inferring eye components. Supported values are: auto, mono, left + right, cyclops. Default: auto. + """ + self._check_n_components() - list_lengths = { - list_length - for column in considered_columns - for list_length in frame.get_column(column).list.lengths().unique().to_list() - } + if eye == 'auto': + # Order of inference: cyclops, right, left. + if self.n_components == 6: + eye_components = 4, 5 + elif self.n_components == 4: + eye_components = 2, 3 + else: # We already checked number of components, must be 2. + eye_components = 0, 1 + elif eye == 'left': + if isinstance(self.n_components, int) and self.n_components < 4: + # Left only makes sense if there are at least two eyes. + raise AttributeError( + 'left eye is only supported for data with at least 4 components', + ) + eye_components = 0, 1 + elif eye == 'right': + if isinstance(self.n_components, int) and self.n_components < 4: + # Right only makes sense if there are at least two eyes. + raise AttributeError( + 'right eye is only supported for data with at least 4 components', + ) + eye_components = 2, 3 + elif eye == 'cyclops': + if isinstance(self.n_components, int) and self.n_components < 6: + raise AttributeError( + 'cyclops eye is only supported for data with at least 6 components', + ) + eye_components = 4, 5 + else: + raise ValueError( + f"unknown eye '{eye}'. Supported values are: ['auto', 'left', 'right', 'cyclops']", + ) + + return eye_components + + def _fill_event_detection_kwargs( + self, + method: Callable[..., pm.EventDataFrame], + eye: str, + **kwargs: Any, + ) -> dict[str, Any]: + """Fill event detection kwargs with gaze attributes. + + Parameters + ---------- + method: Callable + The method for which the keyword argument dictionary will be filled. + eye: str + The string specifier for the eye to choose. + kwargs: + The source keyword arguments passed to the `GazeDataFrame.detect()` method. + """ + # Automatically infer eye to use for event detection. + method_args = inspect.getfullargspec(method).args + + if 'positions' in method_args: + if 'position' not in self.frame.columns: + raise pl.exceptions.ColumnNotFoundError( + f'Column \'position\' not found.' + f' Available columns are: {self.frame.columns}', + ) + eye_components = self._infer_eye_components(eye) + kwargs['positions'] = np.vstack( + [ + self.frame.get_column('position').list.get(eye_component) + for eye_component in eye_components + ], + ).transpose() + + if 'velocities' in method_args: + if 'velocity' not in self.frame.columns: + raise pl.exceptions.ColumnNotFoundError( + f'Column \'velocity\' not found.' + f' Available columns are: {self.frame.columns}', + ) - for column_specifier_list in column_specifiers: - list_lengths.add(len(column_specifier_list)) + eye_components = self._infer_eye_components(eye) + kwargs['velocities'] = np.vstack( + [ + self.frame.get_column('velocity').list.get(eye_component) + for eye_component in eye_components + ], + ).transpose() - if len(list_lengths) > 1: - raise ValueError(f'inconsistent number of components inferred: {list_lengths}') + if 'events' in method_args: + kwargs['events'] = self.events - if len(list_lengths) == 0: - return None + if 'timesteps' in method_args and 'time' in self.frame.columns: + kwargs['timesteps'] = self.frame.get_column('time').to_numpy() - return next(iter(list_lengths)) + return kwargs diff --git a/src/pymovements/gaze/integration.py b/src/pymovements/gaze/integration.py index ec7b5f70a..0d3554ccf 100644 --- a/src/pymovements/gaze/integration.py +++ b/src/pymovements/gaze/integration.py @@ -26,6 +26,7 @@ import pandas as pd import polars as pl +from pymovements.events.frame import EventDataFrame from pymovements.gaze.experiment import Experiment from pymovements.gaze.gaze_dataframe import GazeDataFrame from pymovements.utils import checks @@ -33,13 +34,15 @@ def from_numpy( data: np.ndarray | None = None, + experiment: Experiment | None = None, + events: EventDataFrame | None = None, + *, time: np.ndarray | None = None, pixel: np.ndarray | None = None, position: np.ndarray | None = None, velocity: np.ndarray | None = None, acceleration: np.ndarray | None = None, schema: list[str] | None = None, - experiment: Experiment | None = None, orient: Literal['col', 'row'] = 'col', time_column: str | None = None, pixel_columns: list[str] | None = None, @@ -63,6 +66,10 @@ def from_numpy( ---------- data: Two-dimensional data represented as a numpy ndarray. + experiment : Experiment + The experiment definition. + events: EventDataFrame + A dataframe of events in the gaze signal. time: Array of timestamps. pixel: @@ -77,9 +84,6 @@ def from_numpy( A list of column names. orient: Whether to interpret the two-dimensional data as columns or as rows. - experiment : Experiment - The experiment definition. - time_column: str | None = None, time_column: The name of the timestamp column in the input data frame. pixel_columns: @@ -200,6 +204,7 @@ def from_numpy( return GazeDataFrame( data=df, experiment=experiment, + events=events, time_column=time_column, pixel_columns=pixel_columns, position_columns=position_columns, @@ -245,6 +250,7 @@ def from_numpy( return GazeDataFrame( data=df, experiment=experiment, + events=events, time_column=time_column, pixel_columns=pixel_columns, position_columns=position_columns, @@ -256,6 +262,8 @@ def from_numpy( def from_pandas( data: pd.DataFrame, experiment: Experiment | None = None, + events: EventDataFrame | None = None, + *, time_column: str | None = None, pixel_columns: list[str] | None = None, position_columns: list[str] | None = None, @@ -270,6 +278,8 @@ def from_pandas( Data represented as a pandas DataFrame. experiment : Experiment The experiment definition. + events: EventDataFrame + A dataframe of events in the gaze signal. time_column: The name of the timestamp column in the input data frame. pixel_columns: @@ -289,6 +299,7 @@ def from_pandas( return GazeDataFrame( data=df, experiment=experiment, + events=events, time_column=time_column, pixel_columns=pixel_columns, position_columns=position_columns, diff --git a/src/pymovements/plotting/traceplot.py b/src/pymovements/plotting/traceplot.py index 4bacb1fe8..9b6b62988 100644 --- a/src/pymovements/plotting/traceplot.py +++ b/src/pymovements/plotting/traceplot.py @@ -30,6 +30,7 @@ from pymovements.gaze.gaze_dataframe import GazeDataFrame + # This is really a dirty workaround to use the Agg backend if runnning pytest. # This is needed as Windows workers on GitHub fail randomly with other backends. # Unfortunately the Agg module cannot show plots in jupyter notebooks. diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py index 9e2f99c1c..a287c39a1 100644 --- a/tests/dataset/dataset_test.py +++ b/tests/dataset/dataset_test.py @@ -756,7 +756,7 @@ def test_detect_events_multiple_calls( { 'method': 'microsaccades', 'threshold': 1, - 'eye': 'left', + 'eye': 'auto', 'clear': False, 'verbose': True, }, @@ -771,7 +771,7 @@ def test_detect_events_alias(dataset_configuration, detect_kwargs, monkeypatch): dataset.pos2vel() mock = Mock() - monkeypatch.setattr(dataset, 'detect_events', mock) + monkeypatch.setattr(dataset, 'detect', mock) dataset.detect(**detect_kwargs) mock.assert_called_with(**detect_kwargs) @@ -801,9 +801,8 @@ def test_detect_events_attribute_error(dataset_configuration): pytest.param( {'position': 'custom_position'}, { - 'method': pm.events.microsaccades, + 'method': pm.events.idt, 'threshold': 1, - 'eye': 'right', }, ( "Column 'position' not found. Available columns are: " @@ -816,7 +815,6 @@ def test_detect_events_attribute_error(dataset_configuration): { 'method': pm.events.microsaccades, 'threshold': 1, - 'eye': 'right', }, ( "Column 'velocity' not found. Available columns are: " @@ -834,7 +832,8 @@ def test_detect_events_raises_column_not_found_error( dataset.pix2deg() dataset.pos2vel() - dataset.gaze[0].frame = dataset.gaze[0].frame.rename(rename_arg) + for file_id, _ in enumerate(dataset.gaze): + dataset.gaze[file_id].frame = dataset.gaze[file_id].frame.rename(rename_arg) with pytest.raises(pl.exceptions.ColumnNotFoundError) as excinfo: dataset.detect_events(**detect_event_kwargs) diff --git a/tests/events/detection/idt_test.py b/tests/events/detection/idt_test.py index 47bea10cc..cd9e04d77 100644 --- a/tests/events/detection/idt_test.py +++ b/tests/events/detection/idt_test.py @@ -254,7 +254,22 @@ def test_idt_raises_error(kwargs, expected_error): onsets=[1000], offsets=[1099], ), - id='constant_position_single_fixation_with_timesteps_float', + id='constant_position_single_fixation_with_timesteps_float_no_decimal', + ), + pytest.param( + { + 'positions': step_function(length=100, steps=[0], values=[(0, 0)]), + 'timesteps': np.arange(1000, 1010, 0.1, dtype=float), + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='constant_position_single_fixation_with_timesteps_float_with_decimal', + marks=pytest.mark.xfail(reason='#532'), ), ], ) diff --git a/tests/gaze/detect_test.py b/tests/gaze/detect_test.py new file mode 100644 index 000000000..38420075f --- /dev/null +++ b/tests/gaze/detect_test.py @@ -0,0 +1,816 @@ +# Copyright (c) 2023 The pymovements Project Authors +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Test GazeDataFrame detect method.""" +import numpy as np +import polars as pl +import pytest +from polars.testing import assert_frame_equal + +import pymovements as pm +from pymovements.synthetic import step_function + + +@pytest.mark.parametrize( + ('method', 'kwargs', 'gaze', 'expected'), + [ + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 10, + }, + pm.gaze.from_numpy( + time=np.arange(0, 100, 1), + position=np.stack([np.arange(0, 200, 2), np.arange(0, 200, 2)], axis=0), + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(), + id='idt_constant_velocity_no_fixation', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + position=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(name='fixation', onsets=[0], offsets=[99]), + id='idt_constant_position_single_fixation', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + 'name': 'custom_fixation', + }, + pm.gaze.from_numpy( + position=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(name='custom_fixation', onsets=[0], offsets=[99]), + id='idt_constant_position_single_fixation_custom_name', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + position=step_function( + length=100, steps=[49, 50], values=[(9, 9), (1, 1)], start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0, 50], offsets=[49, 99]), + id='idt_three_steps_two_fixations', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + position=step_function( + length=100, steps=[10, 20, 90], + values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0, 20], offsets=[9, 89]), + id='idt_two_fixations_interrupted_by_nan', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + 'include_nan': True, + }, + pm.gaze.from_numpy( + position=step_function( + length=100, steps=[10, 20, 90], + values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0], offsets=[89]), + id='idt_one_fixation_including_nan', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1100, dtype=int), + position=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='idt_constant_position_single_fixation_with_timesteps_int', + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1010, 0.1, dtype=float), + position=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='idt_constant_position_single_fixation_with_timesteps_float', + marks=pytest.mark.xfail(reason='#532'), + ), + + pytest.param( + 'idt', + { + 'dispersion_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.reshape(np.arange(1000, 1100, dtype=int), (100, 1)), + position=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='idt_constant_position_single_fixation_with_timesteps_int_extra_dim', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 10, + }, + pm.gaze.from_numpy( + time=np.arange(0, 100, 1), + velocity=np.ones((2, 100)) * 20, + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(), + id='ivt_constant_velocity_no_fixation', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 1, + }, + pm.gaze.from_numpy( + velocity=np.zeros((2, 100)), + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(name='fixation', onsets=[0], offsets=[99]), + id='ivt_constant_position_single_fixation', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 1, + 'name': 'custom_fixation', + }, + pm.gaze.from_numpy( + velocity=np.zeros((2, 100)), + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(name='custom_fixation', onsets=[0], offsets=[99]), + id='ivt_constant_position_single_fixation_custom_name', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 1, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[49, 51], values=[(90, 90), (0, 0)], start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0, 51], offsets=[48, 99]), + id='ivt_three_steps_two_fixations', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 1, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[10, 20, 90], + values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0, 20], offsets=[9, 89]), + id='ivt_two_fixations_interrupted_by_nan', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 1, + 'include_nan': True, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[10, 20, 90], + values=[(np.nan, np.nan), (0, 0), (np.nan, np.nan)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame(name='fixation', onsets=[0], offsets=[89]), + id='ivt_one_fixation_including_nan', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1100, dtype=int), + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='ivt_constant_position_single_fixation_with_timesteps_int', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1010, 0.1, dtype=float), + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='ivt_constant_position_single_fixation_with_timesteps_float', + marks=pytest.mark.xfail(reason='#532'), + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + }, + pm.gaze.from_numpy( + time=np.reshape(np.arange(1000, 1100, dtype=int), (100, 1)), + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[1000], + offsets=[1099], + ), + id='ivt_constant_position_single_fixation_with_timesteps_int_extra_dim', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'auto', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_binocular_fixation_two_components_eye_auto', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'auto', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_binocular_fixation_four_components_eye_auto', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'auto', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0, 0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_binocular_fixation_six_components_eye_auto', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'left', + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[0, 10], values=[(0, 0, 1, 1, 1, 1), (0, 0, 0, 0, 0, 0)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_monocular_fixation_six_components_eye_left', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'right', + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[0, 10], values=[(1, 1, 0, 0, 1, 1), (0, 0, 0, 0, 0, 0)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_monocular_fixation_six_components_eye_right', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'cyclops', + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, steps=[0, 10], values=[(1, 1, 1, 1, 0, 0), (0, 0, 0, 0, 0, 0)], + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.events.EventDataFrame( + name='fixation', + onsets=[0], + offsets=[99], + ), + id='ivt_constant_position_monocular_fixation_six_components_eye_cyclops', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 10, + }, + pm.gaze.from_numpy( + time=np.reshape(np.arange(1000, 1100, dtype=int), (100, 1)), + velocity=step_function(length=100, steps=[40, 50], values=[(9, 9), (0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(), + id='microsaccades_two_steps_one_saccade_high_threshold_no_events', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1e-5, + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[40, 50], values=[(9, 9), (0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='saccade', + onsets=[40], + offsets=[49], + ), + id='microsaccades_two_steps_one_saccade', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1e-5, + 'name': 'custom_saccade', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[40, 50], values=[(9, 9), (0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='custom_saccade', + onsets=[40], + offsets=[49], + ), + id='microsaccades_two_steps_one_saccade_custom_name', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1e-5, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, + steps=[20, 30, 70, 80], + values=[(9, 9), (0, 0), (9, 9), (0, 0)], + start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='saccade', + onsets=[20, 70], + offsets=[29, 79], + ), + id='microsaccades_four_steps_two_saccades', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1, + 'include_nan': True, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, + steps=[20, 25, 28, 30, 70, 80], + values=[(9, 9), (np.nan, np.nan), (9, 9), (0, 0), (9, 9), (0, 0)], + start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='saccade', + onsets=[20, 70], + offsets=[29, 79], + ), + id='microsaccades_four_steps_two_saccades_nan_delete_ending_leading_nan', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1, + 'minimum_duration': 1, + }, + pm.gaze.from_numpy( + velocity=step_function( + length=100, + steps=[20, 25, 28, 30, 70, 80], + values=[(9, 9), (np.nan, np.nan), (9, 9), (0, 0), (9, 9), (0, 0)], + start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='saccade', + onsets=[20, 28, 70], + offsets=[24, 29, 79], + ), + id='microsaccades_three_saccades_nan_delete_ending_leading_nan', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 1e-5, + 'minimum_duration': 1, + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1100, dtype=int), + velocity=step_function( + length=100, + steps=[40, 50], + values=[(9, 9), (0, 0)], + start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame( + name='saccade', + onsets=[1040], + offsets=[1049], + ), + id='microsaccades_two_steps_one_saccade_timesteps', + ), + + pytest.param( + 'microsaccades', + { + 'threshold': 'std', + }, + pm.gaze.from_numpy( + time=np.arange(1000, 1100, dtype=int), + velocity=step_function( + length=100, + steps=[40, 50], + values=[(9, 9), (0, 0)], + start_value=(0, 0), + ), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + pm.EventDataFrame(), + id='microsaccades_two_steps_one_saccade_timesteps', + ), + + pytest.param( + 'fill', + {}, + pm.gaze.from_numpy( + time=np.arange(0, 100), + events=pm.EventDataFrame(name='fixation', onsets=[0], offsets=[100]), + ), + pm.EventDataFrame(name='fixation', onsets=[0], offsets=[100]), + id='fill_fixation_from_start_to_end_no_fill', + ), + + pytest.param( + 'fill', + {}, + pm.gaze.from_numpy( + time=np.arange(0, 100), + events=pm.EventDataFrame(name='fixation', onsets=[10], offsets=[100]), + ), + pm.EventDataFrame( + name=['fixation', 'unclassified'], + onsets=[10, 0], + offsets=[100, 9], + ), + id='fill_fixation_10_ms_after_start_to_end_single_fill', + ), + + pytest.param( + 'fill', + {}, + pm.gaze.from_numpy( + time=np.arange(0, 100), + events=pm.EventDataFrame(name='fixation', onsets=[0], offsets=[90]), + ), + pm.EventDataFrame( + name=['fixation', 'unclassified'], + onsets=[0, 90], + offsets=[90, 99], + ), + id='fill_fixation_from_start_to_10_ms_before_end_single_fill', + ), + + pytest.param( + 'fill', + {}, + pm.gaze.from_numpy( + time=np.arange(0, 100), + events=pm.EventDataFrame(name='fixation', onsets=[0, 50], offsets=[40, 100]), + ), + pm.EventDataFrame( + name=['fixation', 'fixation', 'unclassified'], + onsets=[0, 50, 40], + offsets=[40, 100, 49], + ), + id='fill_fixation_10_ms_break_at_40ms_single_fill', + ), + + pytest.param( + 'fill', + {}, + pm.gaze.from_numpy( + time=np.arange(0, 100), + events=pm.EventDataFrame( + name=['fixation', 'saccade'], onsets=[0, 50], offsets=[40, 100], + ), + ), + pm.EventDataFrame( + name=['fixation', 'saccade', 'unclassified'], + onsets=[0, 50, 40], + offsets=[40, 100, 49], + ), + id='fill_fixation_10_ms_break_then_saccade_until_end_single_fill', + ), + ], +) +def test_gaze_detect(method, kwargs, gaze, expected): + gaze.detect(method, **kwargs) + assert_frame_equal(gaze.events.frame, expected.frame) + + +@pytest.mark.parametrize( + ('method', 'kwargs', 'gaze', 'exception', 'exception_msg'), + [ + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'auto', + }, + pm.gaze.GazeDataFrame(None, pm.Experiment(1024, 768, 38, 30, 60, 'center', 10)), + pl.exceptions.ColumnNotFoundError, + "Column 'velocity' not found. Available columns are: []", + id='ivt_no_velocity_raises_column_not_found_error', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'left', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + AttributeError, + 'left eye is only supported for data with at least 4 components', + id='ivt_left_eye_two_components_raises_attribute_error', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'right', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + AttributeError, + 'right eye is only supported for data with at least 4 components', + id='ivt_right_eye_two_components_raises_attribute_error', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'cyclops', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + AttributeError, + 'cyclops eye is only supported for data with at least 6 components', + id='ivt_cyclops_eye_four_components_raises_attribute_error', + ), + + pytest.param( + 'ivt', + { + 'velocity_threshold': 1, + 'minimum_duration': 2, + 'eye': 'foobar', + }, + pm.gaze.from_numpy( + velocity=step_function(length=100, steps=[0], values=[(0, 0, 0, 0)]), + orient='row', + experiment=pm.Experiment(1024, 768, 38, 30, 60, 'center', 10), + ), + ValueError, + "unknown eye 'foobar'. Supported values are: ['auto', 'left', 'right', 'cyclops']", + id='ivt_cyclops_eye_four_components_raises_attribute_error', + ), + ], +) +def test_gaze_detect_raises_exception(method, kwargs, gaze, exception, exception_msg): + with pytest.raises(exception) as exc_info: + gaze.detect(method, **kwargs) + + msg, = exc_info.value.args + assert msg == exception_msg diff --git a/tests/gaze/gaze_init_test.py b/tests/gaze/gaze_init_test.py index f74b87c89..b0b510ee3 100644 --- a/tests/gaze/gaze_init_test.py +++ b/tests/gaze/gaze_init_test.py @@ -23,7 +23,7 @@ import pytest from polars.testing import assert_frame_equal -from pymovements.gaze.gaze_dataframe import GazeDataFrame +import pymovements as pm @pytest.mark.parametrize( @@ -704,7 +704,7 @@ ], ) def test_init_gaze_dataframe_has_expected_attrs(init_kwargs, expected_frame, expected_n_components): - gaze = GazeDataFrame(**init_kwargs) + gaze = pm.GazeDataFrame(**init_kwargs) assert_frame_equal(gaze.frame, expected_frame) assert gaze.n_components == expected_n_components @@ -1175,7 +1175,7 @@ def test_init_gaze_dataframe_has_expected_attrs(init_kwargs, expected_frame, exp ) def test_gaze_dataframe_init_exceptions(init_kwargs, exception, exception_msg): with pytest.raises(exception) as excinfo: - GazeDataFrame(**init_kwargs) + pm.GazeDataFrame(**init_kwargs) msg, = excinfo.value.args assert msg == exception_msg @@ -1187,9 +1187,71 @@ def test_gaze_copy_init_has_same_n_components(): Refers to issue #514. """ df_orig = pl.from_numpy(np.zeros((2, 1000)), orient='col', schema=['x', 'y']) - gaze = GazeDataFrame(df_orig, position_columns=['x', 'y']) + gaze = pm.GazeDataFrame(df_orig, position_columns=['x', 'y']) df_copy = gaze.frame.clone() - gaze_copy = GazeDataFrame(df_copy) + gaze_copy = pm.GazeDataFrame(df_copy) assert gaze.n_components == gaze_copy.n_components + + +@pytest.mark.parametrize( + ('events', 'init_kwargs'), + [ + pytest.param( + None, + { + 'data': pl.from_dict( + {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64}, + ), + 'position_columns': ['x', 'y'], + }, + id='data_with_no_events', + ), + + pytest.param( + pm.EventDataFrame(), + { + 'data': pl.from_dict( + {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64}, + ), + 'position_columns': ['x', 'y'], + }, + id='data_empty_events', + ), + + pytest.param( + pm.EventDataFrame(), + {}, + id='no_data_empty_events', + ), + + pytest.param( + pm.EventDataFrame(name='saccade', onsets=[0], offsets=[10]), + {}, + id='no_data_with_saccades', + ), + + pytest.param( + pm.EventDataFrame(name='fixation', onsets=[100], offsets=[910]), + { + 'data': pl.from_dict( + {'x': [1.23], 'y': [4.56]}, schema={'x': pl.Float64, 'y': pl.Float64}, + ), + 'position_columns': ['x', 'y'], + }, + id='data_with_fixations', + ), + ], +) +def test_gaze_init_events(events, init_kwargs): + if events is None: + expected_events = pm.EventDataFrame().frame + else: + expected_events = events.frame + + gaze = pm.GazeDataFrame(events=events, **init_kwargs) + + assert_frame_equal(gaze.events.frame, expected_events) + # We don't want the events point to the same reference. + assert gaze.events.frame is not expected_events diff --git a/tests/gaze/integration_numpy_test.py b/tests/gaze/integration_numpy_test.py index ae39855d2..ed61afedf 100644 --- a/tests/gaze/integration_numpy_test.py +++ b/tests/gaze/integration_numpy_test.py @@ -20,6 +20,7 @@ """Test from gaze.from_numpy.""" import numpy as np import polars as pl +import pytest from polars.testing import assert_frame_equal import pymovements as pm @@ -164,7 +165,7 @@ def test_from_numpy_explicit_columns(): assert gaze.n_components == 2 -def test_init_all_none(): +def test_from_numpy_all_none(): gaze = pm.gaze.from_numpy( data=None, schema=None, @@ -185,3 +186,41 @@ def test_init_all_none(): assert_frame_equal(gaze.frame, expected) assert gaze.n_components is None + + +@pytest.mark.parametrize( + 'events', + [ + pytest.param( + None, + id='events_none', + ), + + pytest.param( + pm.EventDataFrame(), + id='events_empty', + ), + + pytest.param( + pm.EventDataFrame(name='fixation', onsets=[123], offsets=[345]), + id='fixation', + ), + + pytest.param( + pm.EventDataFrame(name='saccade', onsets=[34123], offsets=[67345]), + id='saccade', + ), + + ], +) +def test_from_numpy_events(events): + if events is None: + expected_events = pm.EventDataFrame().frame + else: + expected_events = events.frame + + gaze = pm.gaze.from_numpy(events=events) + + assert_frame_equal(gaze.events.frame, expected_events) + # We don't want the events point to the same reference. + assert gaze.events.frame is not expected_events diff --git a/tests/gaze/integration_pandas_test.py b/tests/gaze/integration_pandas_test.py index 2d1dc2808..082c3b85b 100644 --- a/tests/gaze/integration_pandas_test.py +++ b/tests/gaze/integration_pandas_test.py @@ -20,6 +20,7 @@ """Test from gaze.from_pandas.""" import pandas as pd import polars as pl +import pytest from polars.testing import assert_frame_equal import pymovements as pm @@ -35,15 +36,7 @@ def test_from_pandas(): }, ) - experiment = pm.Experiment( - screen_width_px=1280, - screen_height_px=1024, - screen_width_cm=38, - screen_height_cm=30, - distance_cm=68, - origin='lower left', - sampling_rate=1000.0, - ) + experiment = pm.Experiment(1280, 1024, 38, 30, 68, 'lower left', 1000.0) gaze = pm.gaze.from_pandas( data=pandas_df, @@ -64,15 +57,7 @@ def test_from_pandas_explicit_columns(): }, ) - experiment = pm.Experiment( - screen_width_px=1280, - screen_height_px=1024, - screen_width_cm=38, - screen_height_cm=30, - distance_cm=68, - origin='lower left', - sampling_rate=1000.0, - ) + experiment = pm.Experiment(1280, 1024, 38, 30, 68, 'lower left', 1000.0) gaze = pm.gaze.from_pandas( data=pandas_df, @@ -87,3 +72,45 @@ def test_from_pandas_explicit_columns(): }) assert_frame_equal(gaze.frame, expected) + + +@pytest.mark.parametrize( + ('df', 'events'), + [ + pytest.param( + pd.DataFrame(), + None, + id='events_none', + ), + + pytest.param( + pd.DataFrame(), + pm.EventDataFrame(), + id='events_empty', + ), + + pytest.param( + pd.DataFrame(), + pm.EventDataFrame(name='fixation', onsets=[123], offsets=[345]), + id='fixation', + ), + + pytest.param( + pd.DataFrame(), + pm.EventDataFrame(name='saccade', onsets=[34123], offsets=[67345]), + id='saccade', + ), + + ], +) +def test_from_pandas_events(df, events): + if events is None: + expected_events = pm.EventDataFrame().frame + else: + expected_events = events.frame + + gaze = pm.gaze.from_pandas(data=df, events=events) + + assert_frame_equal(gaze.events.frame, expected_events) + # We don't want the events point to the same reference. + assert gaze.events.frame is not expected_events