Repository: aeye-lab/pymovements
Pull request #53 (2022-10-30 14:25:44)

Add basic i-dt implementation
## Description

Basic implementation for I-DT Algorithm. Contribution to #19 


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b147977..8439889 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -57,7 +57,7 @@ Once a bug is reported, our development team will try to address the issue as qu
 If you're looking for things to help with, try browsing our [issue tracker](
 https://github.com/aeye-lab/pymovements/issues) first. In particular, look for:
 
-- [good first issues](https://github.com/aeye-lab/pymovements/labels/good-first-issue)
+- [good first issues](https://github.com/aeye-lab/pymovements/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)
 - [documentation issues](https://github.com/aeye-lab/pymovements/labels/documentation)
 
 You do not need to ask for permission to work on any of these issues. The current status of the
diff --git a/docs/source/bibliography.bib b/docs/source/bibliography.bib
index 2bdc864..67a48aa 100644
--- a/docs/source/bibliography.bib
+++ b/docs/source/bibliography.bib
@@ -33,4 +33,14 @@
     year = {2020},
     pages = {1-10},
     doi = {10.17605/OSF.IO/5ZPVK},
+}
+
+@article{SalvucciGoldberg2000,
+    author = {Salvucci, Dario D. and Goldberg, Joseph H.},
+    title = {Identifying Fixations and Saccades in Eye-Tracking Protocols},
+    journal = {Proceedings of the 2000 Symposium on Eye Tracking Research & Applications},
+    pages = {71–78},
+    year = {2000},
+    issn = {1581132808},
+    doi = {10.1145/355017.355028},
 }
\ No newline at end of file
diff --git a/src/pymovements/events/idt.py b/src/pymovements/events/idt.py
new file mode 100644
index 0000000..ecb8fad
--- /dev/null
+++ b/src/pymovements/events/idt.py
@@ -0,0 +1,116 @@
+"""
+This module holds the implementation for idt algorithm.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from pymovements.events import Fixation
+
+
+def dispersion(positions: list[list[float]] | np.ndarray) -> float:
+    """
+    Compute the dispersion of a group of consecutive points in a 2D position time series.
+
+    The dispersion is defined as the sum of the differences between
+    the points' maximum and minimum x and y values
+
+    Parameters
+    ----------
+    positions: array-like
+        Continuous 2D position time series.
+
+    Returns
+    -------
+    dispersion: float
+        Dispersion of the group of points.
+    """
+    return sum(np.max(positions, axis=0) - np.min(positions, axis=0))
+
+
+def idt(
+        positions: list[list[float]] | np.ndarray,
+        dispersion_threshold: float,
+        duration_threshold: int,
+) -> list[Fixation]:
+    """
+    Fixation identification based on dispersion threshold.
+
+    The algorithm identifies fixations by grouping consecutive points
+    within a maximum separation (dispersion) threshold and a minimum duration threshold.
+    The algorithm uses a moving window to check the dispersion of the points in the window.
+    If the dispersion is below the threshold, the window represents a fixation,
+    and the window is expanded until the dispersion is above threshold.
+
+    The implementation is based on the description and pseudocode
+    from Salvucci and Goldberg :cite:p:`SalvucciGoldberg2000`.
+
+    Parameters
+    ----------
+    positions: array-like, shape (N, 2)
+        Continuous 2D position time series
+    dispersion_threshold: float
+        Threshold for dispersion for a group of consecutive samples to be identified as fixation
+    duration_threshold: int
+        Minimum fixation duration in number of samples
+
+    Returns
+    -------
+    list[Fixation]:
+        List of Fixation events
+
+    Raises
+    ------
+    ValueError
+        If positions is not shaped (N, 2)
+        If dispersion_threshold is not greater than 0
+        If duration_threshold is not greater than 0
+    """
+
+    positions = np.array(positions)
+
+    # make sure positions is 2d
+    if positions.ndim != 2:
+        raise ValueError('positions needs to have shape (N, 2)')
+
+    # make sure positions has shape (N, 2)
+    if positions.shape[1] != 2:
+        raise ValueError('positions needs to have shape (N, 2)')
+
+    # Check if dispersion_threshold is greater 0
+    if dispersion_threshold <= 0:
+        raise ValueError('dispersion threshold must be greater than 0')
+
+    # Check if duration_threshold is greater 0
+    if duration_threshold <= 0:
+        raise ValueError('duration threshold must be greater than 0')
+
+    fixations = []
+
+    # Initialize window over first points to cover the duration threshold
+    win_start = 0
+    win_end = duration_threshold
+
+    while win_end < len(positions):
+        if dispersion(positions[win_start:win_end]) <= dispersion_threshold:
+            # Add additional points to the window until dispersion > threshold
+            while dispersion(positions[win_start:win_end]) < dispersion_threshold:
+                win_end += 1
+
+                # break if we reach end of input data
+                if win_end == len(positions) - 1:
+                    break
+
+            # Note a fixation at the centroid of the window points
+            centroid = np.mean(positions[win_start:win_end], axis=0)
+            centroid = (centroid[0], centroid[1])
+
+            fixations.append(Fixation(win_start, win_end, centroid))
+
+            # Initialize new window excluding the previous window
+            win_start = win_end
+            win_end = win_start + duration_threshold
+        else:
+            win_start += 1
+
+    return fixations
diff --git a/src/pymovements/events/ivt.py b/src/pymovements/events/ivt.py
new file mode 100644
index 0000000..8f15c0e
--- /dev/null
+++ b/src/pymovements/events/ivt.py
@@ -0,0 +1,108 @@
+"""
+This module holds the implementation of the ivt algorithm.
+"""
+from __future__ import annotations
+
+import numpy as np
+
+from pymovements.transforms import norm
+from pymovements.events import Fixation
+
+
+def ivt(
+        positions: list[list[float]] | np.ndarray,
+        velocities: list[list[float]] | np.ndarray,
+        velocity_threshold: float
+) -> list[Fixation]:
+    """
+    Identification of fixations based on velocity-threshold
+
+    The algorithm classifies each point as a fixation if the velocity is below
+    the given velocity threshold. Consecutive fixation points are merged into
+    one fixation.
+
+    The implementation is based on the description and pseudocode
+    from Salvucci and Goldberg :cite:p:`SalvucciGoldberg2000`.
+
+    Parameters
+    ----------
+    positions: array-like, shape (N, 2)
+        Continuous 2D position time series.
+    velocities: array-like, shape (N, 2)
+        Corresponding continuous 2D velocity time series.
+    velocity_threshold: float
+        Threshold for a point to be classified as a fixation. If the
+        velocity is below the threshold, the point is classified as a fixation.
+
+    Returns
+    -------
+    list[Fixation]:
+        List of Fixation events
+
+    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)
+
+    # make sure positions and velocities have shape (n, 2)
+    if positions.ndim != 2:
+        raise ValueError('positions need to have shape (N, 2)')
+
+    if positions.shape[1] != 2:
+        raise ValueError('positions need to have shape (N, 2)')
+
+    if velocities.ndim != 2:
+        raise ValueError('velocities need to have shape (N, 2)')
+
+    if velocities.shape[1] != 2:
+        raise ValueError('velocities need to have shape (N, 2)')
+
+    # Check matching shape for positions and velocities
+    if positions.shape != velocities.shape:
+        raise ValueError(f"shape of positions {positions.shape} doesn't match"
+                         f"shape of velocities {velocities.shape}")
+
+    # Check if threshold is None
+    if velocity_threshold is None:
+        raise ValueError('velocity threshold is None')
+
+    # Check if threshold is greater 0
+    if velocity_threshold <= 0:
+        raise ValueError('velocity threshold must be greater than 0')
+
+    velocity_norm = norm(velocities, axis=1)
+
+    # Map velocities lower than threshold to True and greater equals to False
+    fix_map = velocity_norm < velocity_threshold
+
+    # Find onsets for group of velocities
+    loc_group_onsets = np.empty(len(positions), dtype=bool)
+    loc_group_onsets[0] = True
+    np.not_equal(fix_map[:-1], fix_map[1:], out=loc_group_onsets[1:])
+    ind_group_onsets = np.nonzero(loc_group_onsets)[0]
+
+    # Find offsets for group of velocities
+    group_lengths = np.diff(np.append(ind_group_onsets, len(positions)))
+    ind_group_offsets = np.add(ind_group_onsets, group_lengths)
+
+    # Stack onsets and offsets and filter out fixation groups
+    groups = np.stack((ind_group_onsets, ind_group_offsets), axis=1)
+    fix_groups = groups[[fix_map[group[0]] == 1 for group in groups]]
+
+    fixations = []
+
+    for onset, offset in fix_groups:
+        fixation_points = positions[onset:offset]
+        centroid = np.mean(fixation_points, axis=0, dtype=np.float64)
+        centroid = (centroid[0], centroid[1])
+
+        fixations.append(Fixation(onset, offset, centroid))
+
+    return fixations


Test patch:
diff --git a/tests/events/test_idt.py b/tests/events/test_idt.py
new file mode 100644
index 0000000..0b58dcb
--- /dev/null
+++ b/tests/events/test_idt.py
@@ -0,0 +1,99 @@
+"""
+This module tests functionality of the IDT algorithm
+
+"""
+
+import pytest
+from pymovements.events.idt import idt
+
+
+@pytest.mark.parametrize(
+    'kwargs, expected_error',
+    [
+        pytest.param(
+            {
+                'positions': None,
+                'dispersion_threshold': 1,
+                'duration_threshold': 1,
+            },
+            ValueError,
+            id='positions_none_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2], [1, 2]],
+                'dispersion_threshold': None,
+                'duration_threshold': 1,
+            },
+            TypeError,
+            id='dispersion_threshold_none_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2], [1, 2]],
+                'dispersion_threshold': 1,
+                'duration_threshold': None,
+            },
+            TypeError,
+            id='duration_threshold_none_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'positions': 1,
+                'dispersion_threshold': 1,
+                'duration_threshold': 1,
+            },
+            ValueError,
+            id='positions_not_array_like_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [1, 2, 3],
+                'dispersion_threshold': 1,
+                'duration_threshold': 1,
+            },
+            ValueError,
+            id='positions_1d_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2, 3], [1, 2, 3]],
+                'dispersion_threshold': 1,
+                'duration_threshold': 1,
+            },
+            ValueError,
+            id='positions_not_2_elements_in_second_dimension_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2], [1, 2]],
+                'dispersion_threshold': 0,
+                'duration_threshold': 1,
+            },
+            ValueError,
+            id='dispersion_threshold_not_greater_than_0_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2], [1, 2]],
+                'dispersion_threshold': 1,
+                'duration_threshold': 0,
+            },
+            ValueError,
+            id='duration_threshold_not_greater_than_0_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': [[1, 2], [1, 2]],
+                'dispersion_threshold': 1,
+                'duration_threshold': 1.0,
+            },
+            TypeError,
+            id='duration_threshold_not_integer_raises_type_error'
+        )
+    ]
+)
+def test_idt_raises_error(kwargs, expected_error):
+    """Test if idt raises expected error."""
+    with pytest.raises(expected_error):
+        idt(**kwargs)
diff --git a/tests/events/test_ivt.py b/tests/events/test_ivt.py
new file mode 100644
index 0000000..c82d64c
--- /dev/null
+++ b/tests/events/test_ivt.py
@@ -0,0 +1,128 @@
+"""
+This module tests functionality of base event classes.
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from pymovements.events.ivt import ivt
+
+
+@pytest.mark.parametrize(
+    'kwargs, expected_error',
+    [
+        pytest.param(
+            {
+                'positions': None,
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='positions_none_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': None,
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='velocities_none_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': 1,
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='positions_not_array_like_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': 1,
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='velocities_not_array_like_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones(100),
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='positions_not_2d_array_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones(100),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='velocities_not_2d_array_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 3)),
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='positions_not_2_elements_in_second_dimension_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((100, 3)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='velocities_not_2_elements_in_second_dimension_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((101, 2)),
+                'velocity_threshold': 1.
+            },
+            ValueError,
+            id='positions_and_velocities_different_lengths_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': None
+            },
+            ValueError,
+            id='velocity_threshold_none_raises_value_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': '1.'
+            },
+            TypeError,
+            id='velocity_threshold_not_float_raises_type_error'
+        ),
+        pytest.param(
+            {
+                'positions': np.ones((100, 2)),
+                'velocities': np.ones((100, 2)),
+                'velocity_threshold': 0.
+            },
+            ValueError,
+            id='velocity_threshold_not_greater_than_0_raises_value_error'
+        ),
+    ]
+)
+def test_ivt_raise_error(kwargs, expected_error):
+    """Test if ivt raises expected error."""
+    with pytest.raises(expected_error):
+        ivt(**kwargs)