Repository: aeye-lab/pymovements
Pull request #428 (2023-05-29 14:17:00)

feat(events): Add fill event detection method
## Description

This adds support for filling up the event dataframe with additional events for previously misclassified subsequences.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

- [x] tested the function directly
- [x] tested using dataset.detect_events

## 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/428?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`2afecda`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/2afecda611284d4429be8b1a9915e516988f4fd3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`636cd06`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

> :exclamation: Current head 636cd06 differs from pull request most recent head aa30550. Consider uploading reports for the commit aa30550 to get more accurate results

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


```diff
@@            Coverage Diff            @@
##              main      #428   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        56    +1     
  Lines         1908      1940   +32     
  Branches       425       433    +8     
=========================================
+ Hits          1908      1940   +32     
```


| [Impacted Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?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/428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2RhdGFzZXQvZGF0YXNldC5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/detection/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9kZXRlY3Rpb24vX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/events/detection/fill.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2V2ZW50cy9kZXRlY3Rpb24vZmlsbC5weQ==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/428?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](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/dataset/dataset.py b/src/pymovements/dataset/dataset.py
index fc633ae..87c62f7 100644
--- a/src/pymovements/dataset/dataset.py
+++ b/src/pymovements/dataset/dataset.py
@@ -20,6 +20,7 @@
 """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
@@ -424,34 +425,32 @@ class Dataset:
 
         disable_progressbar = not verbose
 
-        event_dfs: list[EventDataFrame] = []
+        if not self.events or clear:
+            self.events = [EventDataFrame() for _ in self.fileinfo.iter_rows()]
 
-        for gaze_df, fileinfo_row in tqdm(
-                zip(self.gaze, self.fileinfo.to_dicts()), disable=disable_progressbar,
+        for file_id, (gaze_df, fileinfo_row) in tqdm(
+                enumerate(zip(self.gaze, self.fileinfo.to_dicts())), disable=disable_progressbar,
         ):
 
             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()
 
-            event_df = method(
+            if 'events' in inspect.getfullargspec(method).args:
+                kwargs['events'] = self.events[file_id]
+
+            new_event_df = method(
                 positions=positions, velocities=velocities, timesteps=timesteps, **kwargs,
             )
 
-            event_df.frame = dataset_files.add_fileinfo(
+            new_event_df.frame = dataset_files.add_fileinfo(
                 definition=self.definition,
-                df=event_df.frame,
+                df=new_event_df.frame,
                 fileinfo=fileinfo_row,
             )
-            event_dfs.append(event_df)
-
-        if not self.events or clear:
-            self.events = event_dfs
-            return self
 
-        for file_id, event_df in enumerate(event_dfs):
             self.events[file_id].frame = pl.concat(
-                [self.events[file_id].frame, event_df.frame],
+                [self.events[file_id].frame, new_event_df.frame],
                 how='diagonal',
             )
         return self
diff --git a/src/pymovements/events/__init__.py b/src/pymovements/events/__init__.py
index 2bae2be..d305738 100644
--- a/src/pymovements/events/__init__.py
+++ b/src/pymovements/events/__init__.py
@@ -46,6 +46,7 @@
     pymovements.events.idt
     pymovements.events.ivt
     pymovements.events.microsaccades
+    pymovements.events.fill
 
 .. rubric:: Event Properties
 
@@ -60,6 +61,7 @@
     pymovements.events.event_properties.peak_velocity
     pymovements.events.event_properties.location
 """
+from pymovements.events.detection.fill import fill
 from pymovements.events.detection.idt import idt
 from pymovements.events.detection.ivt import ivt
 from pymovements.events.detection.microsaccades import microsaccades
@@ -72,6 +74,7 @@ __all__ = [
     'EventDataFrame',
     'EventGazeProcessor',
     'EventProcessor',
+    'fill',
     'idt',
     'ivt',
     'microsaccades',
diff --git a/src/pymovements/events/detection/__init__.py b/src/pymovements/events/detection/__init__.py
index b4527fc..e694bc8 100644
--- a/src/pymovements/events/detection/__init__.py
+++ b/src/pymovements/events/detection/__init__.py
@@ -25,17 +25,20 @@
    :toctree:
    :recursive:
 
+    pymovements.events.detection.fill
     pymovements.events.detection.idt
     pymovements.events.detection.ivt
     pymovements.events.detection.microsaccades
 
 """
+from pymovements.events.detection.fill import fill
 from pymovements.events.detection.idt import idt
 from pymovements.events.detection.ivt import ivt
 from pymovements.events.detection.microsaccades import microsaccades
 
 __all__ = [
-    'microsaccades',
+    'fill',
     'idt',
     'ivt',
+    'microsaccades',
 ]
diff --git a/src/pymovements/events/detection/fill.py b/src/pymovements/events/detection/fill.py
new file mode 100644
index 0000000..3e66ffe
--- /dev/null
+++ b/src/pymovements/events/detection/fill.py
@@ -0,0 +1,120 @@
+# Copyright (c) 2022-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.
+"""
+This module holds the implementation of the ivt algorithm.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from pymovements.events.events import EventDataFrame
+from pymovements.events.events import register_event_detection
+from pymovements.gaze.transforms import consecutive
+from pymovements.utils import checks
+
+
+@register_event_detection
+def fill(
+        events: EventDataFrame,
+        positions: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+        velocities: list[list[float]] | list[tuple[float, float]] | np.ndarray,
+        timesteps: list[int] | np.ndarray | None = None,
+        minimum_duration: int = 1,
+        name: str = 'unclassified',
+) -> EventDataFrame:
+    """
+    Classify all previously unclassified timesteps as events.
+
+    Parameters
+    ----------
+    events:
+        The already detected events.
+    positions: array-like, shape (N, 2)
+        Continuous 2D position time series.
+    velocities: array-like, shape (N, 2)
+        Corresponding continuous 2D velocity time series.
+    timesteps: array-like, shape (N, )
+        Corresponding continuous 1D timestep time series. If None, sample based timesteps are
+        assumed.
+    minimum_duration: int
+        Minimum fixation duration. The duration is specified in the units used in ``timesteps``.
+         If ``timesteps`` is None, then ``minimum_duration`` is specified in numbers of samples.
+    name:
+        Name for detected events in EventDataFrame.
+
+    Returns
+    -------
+    pl.DataFrame
+        A dataframe with detected fixations as rows.
+
+    Raises
+    ------
+    ValueError
+        If positions or velocities are None
+        If positions or velocities do not have shape (N, 2)
+        If positions and velocities have different shapes
+        If velocity threshold is None.
+        If velocity threshold is not greater than 0.
+    """
+    positions = np.array(positions)
+    velocities = np.array(velocities)
+
+    checks.check_shapes_positions_velocities(positions=positions, velocities=velocities)
+
+    if timesteps is None:
+        timesteps = np.arange(len(velocities), dtype=np.int64)
+    timesteps = np.array(timesteps)
+    checks.check_is_length_matching(velocities=velocities, timesteps=timesteps)
+
+    # Create binary mask where each existing event is marked.
+    events_mask = np.zeros(len(positions), dtype=bool)
+
+    for row in events.frame.iter_rows(named=True):
+        idx_onset = np.where(timesteps == row['onset'])[0][0]
+        idx_offset = np.where(timesteps == row['offset'] - 1)[0][0]
+
+        events_mask[idx_onset:idx_offset + 1] = True
+
+    # Mask all indices where there is no event.
+    candidate_mask = ~events_mask
+
+    # Get indices of true values in candidate mask.
+    candidate_indices = np.where(candidate_mask)[0]
+
+    # Get all fixation candidates by grouping all consecutive indices.
+    candidates = consecutive(arr=candidate_indices)
+
+    if len(candidates) == 1 and np.array_equal(candidates[0], np.array([], dtype=np.int64)):
+        return EventDataFrame()
+
+    # Filter all candidates by minimum duration.
+    candidates = [
+        candidate for candidate in candidates
+        if timesteps[candidate[-1]] - timesteps[candidate[0]] >= minimum_duration
+    ]
+
+    # Onset of each event candidate is first index in candidate indices.
+    onsets = timesteps[[candidate_indices[0] for candidate_indices in candidates]].flatten()
+    # Offset of each event candidate is last event in candidate indices.
+    offsets = timesteps[[candidate_indices[-1] for candidate_indices in candidates]].flatten()
+
+    # Create event dataframe from onsets and offsets.
+    event_df = EventDataFrame(name=name, onsets=onsets, offsets=offsets)
+    return event_df


Test patch:
diff --git a/tests/dataset/dataset_test.py b/tests/dataset/dataset_test.py
index 3bc11d5..4a71919 100644
--- a/tests/dataset/dataset_test.py
+++ b/tests/dataset/dataset_test.py
@@ -554,6 +554,13 @@ def test_pos2vel(dataset_configuration):
             },
             id='microsaccades_string',
         ),
+        pytest.param(
+            {
+                'method': 'fill',
+                'eye': 'auto',
+            },
+            id='fill_string',
+        ),
     ],
 )
 def test_detect_events_auto_eye(detect_event_kwargs, dataset_configuration):
diff --git a/tests/events/detection/event_detection_library_test.py b/tests/events/detection/event_detection_library_test.py
index 3e6f201..ec39607 100644
--- a/tests/events/detection/event_detection_library_test.py
+++ b/tests/events/detection/event_detection_library_test.py
@@ -32,6 +32,7 @@ import pymovements.events.events
         pytest.param(pm.events.idt, 'idt', id='idt'),
         pytest.param(pm.events.ivt, 'ivt', id='ivt'),
         pytest.param(pm.events.microsaccades, 'microsaccades', id='microsaccades'),
+        pytest.param(pm.events.fill, 'fill', id='fill'),
     ],
 )
 def test_transform_registered(method, name):
diff --git a/tests/events/detection/fill_test.py b/tests/events/detection/fill_test.py
new file mode 100644
index 0000000..2e3d180
--- /dev/null
+++ b/tests/events/detection/fill_test.py
@@ -0,0 +1,200 @@
+# Copyright (c) 2022-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.
+"""This module tests functionality of the IVT algorithm."""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+from polars.testing import assert_frame_equal
+
+from pymovements.events.detection.fill import fill
+from pymovements.events.events import EventDataFrame
+from pymovements.gaze.transforms import pos2vel
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'expected_error'),
+    [
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': None,
+                'velocities': np.ones((100, 2)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='positions_none_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 2)),
+                'velocities': None,
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='velocities_none_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': 1,
+                'velocities': np.ones((100, 2)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='positions_not_array_like_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 2)),
+                'velocities': 1,
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='velocities_not_array_like_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones(100),
+                'velocities': np.ones((100, 2)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='positions_not_2d_array_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones(100),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='velocities_not_2d_array_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 3)),
+                'velocities': np.ones((100, 2)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='positions_not_2_elements_in_second_dimension_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((100, 3)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='velocities_not_2_elements_in_second_dimension_raises_value_error',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(),
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((101, 2)),
+                'minimum_duration': 1,
+            },
+            ValueError,
+            id='positions_and_velocities_different_lengths_raises_value_error',
+        ),
+    ],
+)
+def test_fill_raise_error(kwargs, expected_error):
+    """Test if ivt raises expected error."""
+    with pytest.raises(expected_error):
+        fill(**kwargs)
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'expected'),
+    [
+        pytest.param(
+            {
+                'events': EventDataFrame(name='fixation', onsets=[0], offsets=[100]),
+                'positions': np.stack([np.arange(0, 100), np.arange(0, 100)], axis=1),
+            },
+            EventDataFrame(),
+            id='fixation_from_start_to_end_no_fill',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(name='fixation', onsets=[10], offsets=[100]),
+                'positions': np.stack([np.arange(0, 100), np.arange(0, 100)], axis=1),
+            },
+            EventDataFrame(
+                name='unclassified',
+                onsets=[0],
+                offsets=[9],
+            ),
+            id='fixation_10_ms_after_start_to_end_single_fill',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(name='fixation', onsets=[0], offsets=[90]),
+                'positions': np.stack([np.arange(0, 100), np.arange(0, 100)], axis=1),
+            },
+            EventDataFrame(
+                name='unclassified',
+                onsets=[90],
+                offsets=[99],
+            ),
+            id='fixation_from_start_to_10_ms_before_end_single_fill',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(name='fixation', onsets=[0, 50], offsets=[40, 100]),
+                'positions': np.stack([np.arange(0, 100), np.arange(0, 100)], axis=1),
+            },
+            EventDataFrame(
+                name='unclassified',
+                onsets=[40],
+                offsets=[49],
+            ),
+            id='fixation_10_ms_break_at_40ms_single_fill',
+        ),
+        pytest.param(
+            {
+                'events': EventDataFrame(
+                    name=['fixation', 'saccade'], onsets=[0, 50], offsets=[40, 100],
+                ),
+                'positions': np.stack([np.arange(0, 100), np.arange(0, 100)], axis=1),
+            },
+            EventDataFrame(
+                name='unclassified',
+                onsets=[40],
+                offsets=[49],
+            ),
+            id='fixation_10_ms_break_then_saccade_until_end_single_fill',
+        ),
+    ],
+)
+def test_fill_fills_events(kwargs, expected):
+    velocities = pos2vel(kwargs['positions'], sampling_rate=10, method='preceding')
+    events = fill(velocities=velocities, **kwargs)
+
+    assert_frame_equal(events.frame, expected.frame)