Repository: aeye-lab/pymovements
Pull request #111 (2023-02-06 21:27:32)

Docs: Fix good first issues link
link was broken, the tag really uses spaces and no dashes


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/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_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)