Repository: aeye-lab/pymovements
Pull request #511 (2023-09-08 15:29:42)

feat: Add GazeDataFrame.detect()
## Description

This is a major step towards a self-contained Gaze class.
We now do not need a Dataset object anymore to run event detection algorithms.

Depends on #553 and #554

## Implemented changes

- [x] implement GazeDataFrame.detect()
- [x] implement gazeDataFrame._infer_eye_components()
- [x] refactored some private functions in gazeframe module to private methods 
- [x] Reuse GazeDataFrame.detect() in Dataset.detect()

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- [x] Migrated all detection tests to call GazeDataFrame.detect() (old detection tests are left untouched)
- [x] Tested several eye configurations

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`18.18%`** and project coverage change: **`-62.45%`** :warning:
> Comparison is base [(`3b451a2`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/3b451a2cf525ea55b69defea5e7a32c8582c353f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`2036d80`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 37.55%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##              main     #511       +/-   ##
============================================
- Coverage   100.00%   37.55%   -62.45%     
============================================
  Files           48       48               
  Lines         2081     2069       -12     
  Branches       505      465       -40     
============================================
- Hits          2081      777     -1304     
- Misses           0     1229     +1229     
- Partials         0       63       +63     
```


| [Files Changed](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/dataset/dataset.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldC5weQ==) | `32.23% <0.00%> (-67.77%)` | :arrow_down: |
| [src/pymovements/gaze/integration.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvaW50ZWdyYXRpb24ucHk=) | `71.42% <ø> (-28.58%)` | :arrow_down: |
| [src/pymovements/gaze/gaze\_dataframe.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvZ2F6ZV9kYXRhZnJhbWUucHk=) | `34.56% <12.90%> (-65.44%)` | :arrow_down: |
| [src/pymovements/events/detection/\_library.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9kZXRlY3Rpb24vX2xpYnJhcnkucHk=) | `88.88% <50.00%> (-11.12%)` | :arrow_down: |
| [src/pymovements/events/processing.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9wcm9jZXNzaW5nLnB5) | `24.19% <100.00%> (-75.81%)` | :arrow_down: |

... and [33 files with indirect coverage changes](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/511?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   

:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

Patch:
diff --git a/src/pymovements/__init__.py b/src/pymovements/__init__.py
index fd52b48..d759177 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 @@ __all__ = [
     'Screen',
     'GazeDataFrame',
 
+    'exceptions',
     'plotting',
     'synthetic',
     'utils',
diff --git a/src/pymovements/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index 7eea586..987d39a 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 import dataset_files
 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 @@ class Dataset:
             self,
             method: Callable[..., EventDataFrame] | str,
             *,
-            eye: str | None = 'auto',
+            eye: str = 'auto',
             clear: bool = False,
             verbose: bool = True,
             **kwargs: Any,
@@ -393,134 +391,19 @@ class Dataset:
         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 @@ class Dataset:
         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 8dd3899..ef0149f 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 12cc8e8..fda8544 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 15a51bf..5f8b579 100644
--- a/src/pymovements/events/processing.py
+++ b/src/pymovements/events/processing.py
@@ -26,10 +26,10 @@ from typing import Any
 
 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 @@ class EventGazeProcessor:
     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 5561b35..29e53a5 100644
--- a/src/pymovements/gaze/gaze_dataframe.py
+++ b/src/pymovements/gaze/gaze_dataframe.py
@@ -25,8 +25,10 @@ from collections.abc import Callable
 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 @@ class GazeDataFrame:
             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 @@ class GazeDataFrame:
             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 @@ class GazeDataFrame:
         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 @@ class GazeDataFrame:
                 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 @@ class GazeDataFrame:
         """
         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 @@ class GazeDataFrame:
         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 @@ class GazeDataFrame:
             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 @@ class GazeDataFrame:
         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 ec7b5f7..0d3554c 100644
--- a/src/pymovements/gaze/integration.py
+++ b/src/pymovements/gaze/integration.py
@@ -26,6 +26,7 @@ import numpy as np
 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 @@ from pymovements.utils import checks
 
 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 4bacb1f..9b6b629 100644
--- a/src/pymovements/plotting/traceplot.py
+++ b/src/pymovements/plotting/traceplot.py
@@ -30,6 +30,7 @@ from matplotlib.collections import LineCollection
 
 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.


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index 9e2f99c..a287c39 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 47bea10..cd9e04d 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 0000000..3842007
--- /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 f74b87c..b0b510e 100644
--- a/tests/gaze/gaze_init_test.py
+++ b/tests/gaze/gaze_init_test.py
@@ -23,7 +23,7 @@ import polars as pl
 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 @@ from pymovements.gaze.gaze_dataframe import GazeDataFrame
     ],
 )
 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 ae39855..ed61afe 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 2d1dc28..082c3b8 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