Skip to content

Event detection

Turning processed gaze samples into fixations, saccades and blinks.

Pyxations ships the Engbert–Kliegl implementation and a REMoDNaV adapter, and can also reuse the events reported by EyeLink's own parser. Selecting a detector does not change the canonical BIDS storage layer, so results from different algorithms are stored side by side and can be compared directly.

eye_movement_detection

The abstract base class. Implement it to add support for another algorithm.

EyeMovementDetection

Bases: ABC

Base class for eye-movement detection adapters.

Subclasses wrap one detection algorithm and turn processed gaze samples into fixation, saccade and blink tables. Pyxations ships :class:~pyxations.EngbertDetection and :class:~pyxations.RemodnavDetection; EyeLink recordings can instead reuse the events reported by the vendor parser.

Implement :meth:detect_eye_movements to add support for another algorithm. The canonical BIDS storage layer does not change, so a new detector becomes usable across the whole analysis hierarchy without touching the conversion or export code.

Source code in pyxations/methods/eyemovement/eye_movement_detection.py
class EyeMovementDetection(ABC):
    """Base class for eye-movement detection adapters.

    Subclasses wrap one detection algorithm and turn processed gaze samples
    into fixation, saccade and blink tables. Pyxations ships
    :class:`~pyxations.EngbertDetection` and
    :class:`~pyxations.RemodnavDetection`; EyeLink recordings can instead reuse
    the events reported by the vendor parser.

    Implement :meth:`detect_eye_movements` to add support for another
    algorithm. The canonical BIDS storage layer does not change, so a new
    detector becomes usable across the whole analysis hierarchy without
    touching the conversion or export code.
    """

    @abstractmethod
    def detect_eye_movements(self, *args, **kwargs):
        """Return detected eye-movement events.

        Parameters
        ----------
        *args : object
            Positional detector-specific configuration values.
        **kwargs : object
            Keyword detector-specific configuration values.
        """

detect_eye_movements(*args, **kwargs) abstractmethod

Return detected eye-movement events.

Parameters:

Name Type Description Default
*args object

Positional detector-specific configuration values.

()
**kwargs object

Keyword detector-specific configuration values.

{}
Source code in pyxations/methods/eyemovement/eye_movement_detection.py
@abstractmethod
def detect_eye_movements(self, *args, **kwargs):
    """Return detected eye-movement events.

    Parameters
    ----------
    *args : object
        Positional detector-specific configuration values.
    **kwargs : object
        Keyword detector-specific configuration values.
    """

Engbert–Kliegl

Velocity-threshold detection following Engbert and Mergenthaler.

Polars-native Engbert–Kliegl eye-movement detection adapter.

EngbertDetection

Bases: EyeMovementDetection

Detect saccades and inter-saccadic fixations with Engbert–Kliegl.

Source code in pyxations/methods/eyemovement/engbert.py
class EngbertDetection(EyeMovementDetection):
    """Detect saccades and inter-saccadic fixations with Engbert–Kliegl."""

    def __init__(self, session_folder_path: Any, samples: Any):
        self.session_folder_path = session_folder_path
        self.out_folder = session_folder_path / "engbert_events"
        self.samples = samples

    def detect_eye_movements(
        self,
        vfac: float = 5.0,
        mindur_ms: float = 6.0,
        smoothlevel: int = 1,
        globalthresh: bool = True,
        degperpixel: float | None = None,
        screen_size_cm: float = 38.0,
        screen_width_px: int = 1920,
        screen_distance_cm: float = 60.0,
        sample_rate_fallback: float | None = None,
    ) -> tuple[Any, Any]:
        """Detect fixations and saccades, returning times in milliseconds.

        The returned dataframe type matches ``self.samples``.  Gaze samples may
        contain left/right columns (``LX``, ``LY``, ``RX``, ``RY``) or generic
        columns (``X``, ``Y``).  Pupil measurements are summarized when a
        corresponding pupil column is available; otherwise ``pupilAvg`` is NaN.

        Parameters
        ----------
        vfac : float, default 5.0
            Multiplier applied to the robust velocity threshold.
        mindur_ms : float, default 6.0
            Minimum saccade duration in milliseconds.
        smoothlevel : int, default 1
            Smoothing-kernel level used before calculating velocity.
        globalthresh : bool, default True
            If true, estimate one threshold per eye across all chunks;
            otherwise estimate thresholds separately for each chunk.
        degperpixel : float, optional
            Degrees of visual angle per pixel. When omitted, calculate it from
            the screen geometry.
        screen_size_cm : float, default 38.0
            Physical screen width in centimetres.
        screen_width_px : int, default 1920
            Screen width in pixels.
        screen_distance_cm : float, default 60.0
            Viewing distance in centimetres.
        sample_rate_fallback : float, optional
            Sampling rate used only when it cannot be measured from timestamps
            and is not present in ``Rate_recorded``.

        Returns
        -------
        fixations : DataFrame-like
            Detected fixation events, using the same dataframe library as the
            input samples.
        saccades : DataFrame-like
            Detected saccade events, using the same dataframe library as the
            input samples.

        Raises
        ------
        ValueError
            If a numeric configuration value is invalid or the sample rate
            cannot be determined.
        """
        if not np.isfinite(vfac) or vfac <= 0:
            raise ValueError("vfac must be finite and greater than zero.")
        if not np.isfinite(mindur_ms) or mindur_ms < 0:
            raise ValueError("mindur_ms must be finite and non-negative.")
        if not isinstance(smoothlevel, int) or smoothlevel < 0:
            raise ValueError("smoothlevel must be a non-negative integer.")

        columns = _column_names(self.samples)
        timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
        if timestamps.size == 0:
            return (
                _make_frame(self.samples, [], _FIXATION_COLUMNS),
                _make_frame(self.samples, [], _SACCADE_COLUMNS),
            )

        if degperpixel is None:
            degperpixel = _compute_px2deg(
                screen_size_cm, screen_distance_cm, screen_width_px
            )
        elif not np.isfinite(degperpixel) or degperpixel <= 0:
            raise ValueError("degperpixel must be finite and greater than zero.")

        recorded_rates = (
            _column_to_numpy(self.samples, "Rate_recorded", dtype=float)
            if "Rate_recorded" in columns
            else None
        )
        chunk_ids, sample_rates = _split_into_chunks(
            timestamps,
            recorded_rates,
            fallback_fs=sample_rate_fallback,
        )

        calibration = _column_to_numpy(
            self.samples, "Calib_index", required=False, default=np.nan
        )
        eyes_recorded = _column_to_numpy(
            self.samples, "Eyes_recorded", required=False, default=np.nan
        )

        eye_columns = _available_eye_columns(columns)
        coordinate_arrays: dict[
            str, tuple[np.ndarray, np.ndarray, np.ndarray | None]
        ] = {}
        if eye_columns["has_L"]:
            coordinate_arrays["L"] = (
                _column_to_numpy(self.samples, "LX", dtype=float),
                _column_to_numpy(self.samples, "LY", dtype=float),
                _column_to_numpy(self.samples, "LPupil", dtype=float)
                if "LPupil" in columns
                else None,
            )
        if eye_columns["has_R"]:
            coordinate_arrays["R"] = (
                _column_to_numpy(self.samples, "RX", dtype=float),
                _column_to_numpy(self.samples, "RY", dtype=float),
                _column_to_numpy(self.samples, "RPupil", dtype=float)
                if "RPupil" in columns
                else None,
            )
        if eye_columns["has_generic"]:
            coordinate_arrays["U"] = (
                _column_to_numpy(self.samples, "X", dtype=float),
                _column_to_numpy(self.samples, "Y", dtype=float),
                _column_to_numpy(self.samples, "Pupil", dtype=float)
                if "Pupil" in columns
                else None,
            )

        if not coordinate_arrays:
            raise ValueError(
                "No supported gaze coordinate columns found. Expected LX/LY, "
                "RX/RY, or X/Y."
            )

        saccade_records: list[dict[str, Any]] = []
        fixation_records: list[dict[str, Any]] = []

        for chunk_id in np.unique(chunk_ids):
            indices = np.flatnonzero(chunk_ids == chunk_id)
            if indices.size == 0:
                continue

            sample_rate = float(sample_rates[indices[0]])
            chunk_start_ms = float(timestamps[indices[0]])
            calib_value = calibration[indices[0]]
            eyes_value = eyes_recorded[indices[0]]

            streams: list[tuple[str, np.ndarray, np.ndarray | None]] = []
            for eye_label in ("L", "R"):
                if eye_label not in coordinate_arrays:
                    continue
                x_values, y_values, pupil_values = coordinate_arrays[eye_label]
                xy = np.column_stack([x_values[indices], y_values[indices]])
                if np.isfinite(xy).any():
                    streams.append(
                        (
                            eye_label,
                            xy,
                            pupil_values[indices] if pupil_values is not None else None,
                        )
                    )

            # Preserve the historical preference for explicit left/right streams.
            if not streams and "U" in coordinate_arrays:
                x_values, y_values, pupil_values = coordinate_arrays["U"]
                xy = np.column_stack([x_values[indices], y_values[indices]])
                if np.isfinite(xy).any():
                    streams.append(
                        (
                            "U",
                            xy,
                            pupil_values[indices] if pupil_values is not None else None,
                        )
                    )

            if not streams:
                continue

            global_sigmas: dict[str, tuple[float, float]] = {}
            if globalthresh:
                for eye_label, xy, _ in streams:
                    global_sigmas[eye_label] = velthresh(
                        vecvel(xy, sample_rate, smoothlevel=smoothlevel)
                    )

            minimum_samples = max(1, round(mindur_ms * sample_rate / 1000.0))

            for eye_label, xy, pupil_values in streams:
                valid_coordinates = np.isfinite(xy).all(axis=1)
                if not valid_coordinates.any():
                    continue

                velocity = vecvel(xy, sample_rate, smoothlevel=smoothlevel)
                if globalthresh:
                    sigma_x, sigma_y = global_sigmas[eye_label]
                else:
                    sigma_x, sigma_y = velthresh(velocity)

                if not np.isfinite(sigma_x) or sigma_x <= 1e-6:
                    sigma_x = _fallback_sigma(velocity[:, 0])
                if not np.isfinite(sigma_y) or sigma_y <= 1e-6:
                    sigma_y = _fallback_sigma(velocity[:, 1])

                saccades = microsacc_plugin(
                    xy,
                    velocity,
                    vfac=vfac,
                    mindur_samples=minimum_samples,
                    sdx=sigma_x,
                    sdy=sigma_y,
                )

                if saccades.size == 0:
                    valid_indices = np.flatnonzero(valid_coordinates)
                    _append_fixation_record(
                        fixation_records,
                        int(valid_indices[0]),
                        int(valid_indices[-1]),
                        coordinates=xy,
                        pupil_values=pupil_values,
                        chunk_start_ms=chunk_start_ms,
                        sample_rate=sample_rate,
                        eye_label=eye_label,
                        calibration=calib_value,
                        eyes_recorded=eyes_value,
                        chunk_id=chunk_id,
                    )
                    continue

                onset_indices = saccades[:, 0].astype(int)
                offset_indices = saccades[:, 1].astype(int)
                start_times = chunk_start_ms + (onset_indices / sample_rate) * 1000.0
                end_times = chunk_start_ms + (offset_indices / sample_rate) * 1000.0
                durations = (saccades[:, 2] / sample_rate) * 1000.0

                for event_index in range(saccades.shape[0]):
                    saccade_records.append(
                        {
                            "tStart": float(start_times[event_index]),
                            "tEnd": float(end_times[event_index]),
                            "duration": float(durations[event_index]),
                            "xStart": float(saccades[event_index, 10]),
                            "yStart": float(saccades[event_index, 11]),
                            "xEnd": float(saccades[event_index, 12]),
                            "yEnd": float(saccades[event_index, 13]),
                            "ampDeg": float(saccades[event_index, 7] * degperpixel),
                            "vPeak": float(saccades[event_index, 4] * degperpixel),
                            "distDeg": float(saccades[event_index, 5] * degperpixel),
                            "thetaDeg": float(
                                saccades[event_index, 6] * (180.0 / np.pi)
                            ),
                            "eye": eye_label,
                            "Calib_index": calib_value,
                            "Eyes_recorded": eyes_value,
                            "Rate_recorded": sample_rate,
                            "chunk": int(chunk_id),
                        }
                    )

                order = np.argsort(onset_indices)
                sorted_onsets = onset_indices[order]
                sorted_offsets = offset_indices[order]

                if sorted_onsets[0] > 0:
                    _append_fixation_record(
                        fixation_records,
                        0,
                        int(sorted_onsets[0] - 1),
                        coordinates=xy,
                        pupil_values=pupil_values,
                        chunk_start_ms=chunk_start_ms,
                        sample_rate=sample_rate,
                        eye_label=eye_label,
                        calibration=calib_value,
                        eyes_recorded=eyes_value,
                        chunk_id=chunk_id,
                    )

                for event_index in range(len(sorted_onsets) - 1):
                    _append_fixation_record(
                        fixation_records,
                        int(sorted_offsets[event_index] + 1),
                        int(sorted_onsets[event_index + 1] - 1),
                        coordinates=xy,
                        pupil_values=pupil_values,
                        chunk_start_ms=chunk_start_ms,
                        sample_rate=sample_rate,
                        eye_label=eye_label,
                        calibration=calib_value,
                        eyes_recorded=eyes_value,
                        chunk_id=chunk_id,
                    )

                last_offset = int(sorted_offsets[-1])
                if last_offset < (indices.size - 1):
                    _append_fixation_record(
                        fixation_records,
                        last_offset + 1,
                        indices.size - 1,
                        coordinates=xy,
                        pupil_values=pupil_values,
                        chunk_start_ms=chunk_start_ms,
                        sample_rate=sample_rate,
                        eye_label=eye_label,
                        calibration=calib_value,
                        eyes_recorded=eyes_value,
                        chunk_id=chunk_id,
                    )

        fixation_records.sort(key=lambda row: row["tEnd"])
        saccade_records.sort(key=lambda row: row["tEnd"])
        return (
            _make_frame(self.samples, fixation_records, _FIXATION_COLUMNS),
            _make_frame(self.samples, saccade_records, _SACCADE_COLUMNS),
        )

detect_eye_movements(vfac=5.0, mindur_ms=6.0, smoothlevel=1, globalthresh=True, degperpixel=None, screen_size_cm=38.0, screen_width_px=1920, screen_distance_cm=60.0, sample_rate_fallback=None)

Detect fixations and saccades, returning times in milliseconds.

The returned dataframe type matches self.samples. Gaze samples may contain left/right columns (LX, LY, RX, RY) or generic columns (X, Y). Pupil measurements are summarized when a corresponding pupil column is available; otherwise pupilAvg is NaN.

Parameters:

Name Type Description Default
vfac float

Multiplier applied to the robust velocity threshold.

5.0
mindur_ms float

Minimum saccade duration in milliseconds.

6.0
smoothlevel int

Smoothing-kernel level used before calculating velocity.

1
globalthresh bool

If true, estimate one threshold per eye across all chunks; otherwise estimate thresholds separately for each chunk.

True
degperpixel float

Degrees of visual angle per pixel. When omitted, calculate it from the screen geometry.

None
screen_size_cm float

Physical screen width in centimetres.

38.0
screen_width_px int

Screen width in pixels.

1920
screen_distance_cm float

Viewing distance in centimetres.

60.0
sample_rate_fallback float

Sampling rate used only when it cannot be measured from timestamps and is not present in Rate_recorded.

None

Returns:

Name Type Description
fixations DataFrame - like

Detected fixation events, using the same dataframe library as the input samples.

saccades DataFrame - like

Detected saccade events, using the same dataframe library as the input samples.

Raises:

Type Description
ValueError

If a numeric configuration value is invalid or the sample rate cannot be determined.

Source code in pyxations/methods/eyemovement/engbert.py
def detect_eye_movements(
    self,
    vfac: float = 5.0,
    mindur_ms: float = 6.0,
    smoothlevel: int = 1,
    globalthresh: bool = True,
    degperpixel: float | None = None,
    screen_size_cm: float = 38.0,
    screen_width_px: int = 1920,
    screen_distance_cm: float = 60.0,
    sample_rate_fallback: float | None = None,
) -> tuple[Any, Any]:
    """Detect fixations and saccades, returning times in milliseconds.

    The returned dataframe type matches ``self.samples``.  Gaze samples may
    contain left/right columns (``LX``, ``LY``, ``RX``, ``RY``) or generic
    columns (``X``, ``Y``).  Pupil measurements are summarized when a
    corresponding pupil column is available; otherwise ``pupilAvg`` is NaN.

    Parameters
    ----------
    vfac : float, default 5.0
        Multiplier applied to the robust velocity threshold.
    mindur_ms : float, default 6.0
        Minimum saccade duration in milliseconds.
    smoothlevel : int, default 1
        Smoothing-kernel level used before calculating velocity.
    globalthresh : bool, default True
        If true, estimate one threshold per eye across all chunks;
        otherwise estimate thresholds separately for each chunk.
    degperpixel : float, optional
        Degrees of visual angle per pixel. When omitted, calculate it from
        the screen geometry.
    screen_size_cm : float, default 38.0
        Physical screen width in centimetres.
    screen_width_px : int, default 1920
        Screen width in pixels.
    screen_distance_cm : float, default 60.0
        Viewing distance in centimetres.
    sample_rate_fallback : float, optional
        Sampling rate used only when it cannot be measured from timestamps
        and is not present in ``Rate_recorded``.

    Returns
    -------
    fixations : DataFrame-like
        Detected fixation events, using the same dataframe library as the
        input samples.
    saccades : DataFrame-like
        Detected saccade events, using the same dataframe library as the
        input samples.

    Raises
    ------
    ValueError
        If a numeric configuration value is invalid or the sample rate
        cannot be determined.
    """
    if not np.isfinite(vfac) or vfac <= 0:
        raise ValueError("vfac must be finite and greater than zero.")
    if not np.isfinite(mindur_ms) or mindur_ms < 0:
        raise ValueError("mindur_ms must be finite and non-negative.")
    if not isinstance(smoothlevel, int) or smoothlevel < 0:
        raise ValueError("smoothlevel must be a non-negative integer.")

    columns = _column_names(self.samples)
    timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
    if timestamps.size == 0:
        return (
            _make_frame(self.samples, [], _FIXATION_COLUMNS),
            _make_frame(self.samples, [], _SACCADE_COLUMNS),
        )

    if degperpixel is None:
        degperpixel = _compute_px2deg(
            screen_size_cm, screen_distance_cm, screen_width_px
        )
    elif not np.isfinite(degperpixel) or degperpixel <= 0:
        raise ValueError("degperpixel must be finite and greater than zero.")

    recorded_rates = (
        _column_to_numpy(self.samples, "Rate_recorded", dtype=float)
        if "Rate_recorded" in columns
        else None
    )
    chunk_ids, sample_rates = _split_into_chunks(
        timestamps,
        recorded_rates,
        fallback_fs=sample_rate_fallback,
    )

    calibration = _column_to_numpy(
        self.samples, "Calib_index", required=False, default=np.nan
    )
    eyes_recorded = _column_to_numpy(
        self.samples, "Eyes_recorded", required=False, default=np.nan
    )

    eye_columns = _available_eye_columns(columns)
    coordinate_arrays: dict[
        str, tuple[np.ndarray, np.ndarray, np.ndarray | None]
    ] = {}
    if eye_columns["has_L"]:
        coordinate_arrays["L"] = (
            _column_to_numpy(self.samples, "LX", dtype=float),
            _column_to_numpy(self.samples, "LY", dtype=float),
            _column_to_numpy(self.samples, "LPupil", dtype=float)
            if "LPupil" in columns
            else None,
        )
    if eye_columns["has_R"]:
        coordinate_arrays["R"] = (
            _column_to_numpy(self.samples, "RX", dtype=float),
            _column_to_numpy(self.samples, "RY", dtype=float),
            _column_to_numpy(self.samples, "RPupil", dtype=float)
            if "RPupil" in columns
            else None,
        )
    if eye_columns["has_generic"]:
        coordinate_arrays["U"] = (
            _column_to_numpy(self.samples, "X", dtype=float),
            _column_to_numpy(self.samples, "Y", dtype=float),
            _column_to_numpy(self.samples, "Pupil", dtype=float)
            if "Pupil" in columns
            else None,
        )

    if not coordinate_arrays:
        raise ValueError(
            "No supported gaze coordinate columns found. Expected LX/LY, "
            "RX/RY, or X/Y."
        )

    saccade_records: list[dict[str, Any]] = []
    fixation_records: list[dict[str, Any]] = []

    for chunk_id in np.unique(chunk_ids):
        indices = np.flatnonzero(chunk_ids == chunk_id)
        if indices.size == 0:
            continue

        sample_rate = float(sample_rates[indices[0]])
        chunk_start_ms = float(timestamps[indices[0]])
        calib_value = calibration[indices[0]]
        eyes_value = eyes_recorded[indices[0]]

        streams: list[tuple[str, np.ndarray, np.ndarray | None]] = []
        for eye_label in ("L", "R"):
            if eye_label not in coordinate_arrays:
                continue
            x_values, y_values, pupil_values = coordinate_arrays[eye_label]
            xy = np.column_stack([x_values[indices], y_values[indices]])
            if np.isfinite(xy).any():
                streams.append(
                    (
                        eye_label,
                        xy,
                        pupil_values[indices] if pupil_values is not None else None,
                    )
                )

        # Preserve the historical preference for explicit left/right streams.
        if not streams and "U" in coordinate_arrays:
            x_values, y_values, pupil_values = coordinate_arrays["U"]
            xy = np.column_stack([x_values[indices], y_values[indices]])
            if np.isfinite(xy).any():
                streams.append(
                    (
                        "U",
                        xy,
                        pupil_values[indices] if pupil_values is not None else None,
                    )
                )

        if not streams:
            continue

        global_sigmas: dict[str, tuple[float, float]] = {}
        if globalthresh:
            for eye_label, xy, _ in streams:
                global_sigmas[eye_label] = velthresh(
                    vecvel(xy, sample_rate, smoothlevel=smoothlevel)
                )

        minimum_samples = max(1, round(mindur_ms * sample_rate / 1000.0))

        for eye_label, xy, pupil_values in streams:
            valid_coordinates = np.isfinite(xy).all(axis=1)
            if not valid_coordinates.any():
                continue

            velocity = vecvel(xy, sample_rate, smoothlevel=smoothlevel)
            if globalthresh:
                sigma_x, sigma_y = global_sigmas[eye_label]
            else:
                sigma_x, sigma_y = velthresh(velocity)

            if not np.isfinite(sigma_x) or sigma_x <= 1e-6:
                sigma_x = _fallback_sigma(velocity[:, 0])
            if not np.isfinite(sigma_y) or sigma_y <= 1e-6:
                sigma_y = _fallback_sigma(velocity[:, 1])

            saccades = microsacc_plugin(
                xy,
                velocity,
                vfac=vfac,
                mindur_samples=minimum_samples,
                sdx=sigma_x,
                sdy=sigma_y,
            )

            if saccades.size == 0:
                valid_indices = np.flatnonzero(valid_coordinates)
                _append_fixation_record(
                    fixation_records,
                    int(valid_indices[0]),
                    int(valid_indices[-1]),
                    coordinates=xy,
                    pupil_values=pupil_values,
                    chunk_start_ms=chunk_start_ms,
                    sample_rate=sample_rate,
                    eye_label=eye_label,
                    calibration=calib_value,
                    eyes_recorded=eyes_value,
                    chunk_id=chunk_id,
                )
                continue

            onset_indices = saccades[:, 0].astype(int)
            offset_indices = saccades[:, 1].astype(int)
            start_times = chunk_start_ms + (onset_indices / sample_rate) * 1000.0
            end_times = chunk_start_ms + (offset_indices / sample_rate) * 1000.0
            durations = (saccades[:, 2] / sample_rate) * 1000.0

            for event_index in range(saccades.shape[0]):
                saccade_records.append(
                    {
                        "tStart": float(start_times[event_index]),
                        "tEnd": float(end_times[event_index]),
                        "duration": float(durations[event_index]),
                        "xStart": float(saccades[event_index, 10]),
                        "yStart": float(saccades[event_index, 11]),
                        "xEnd": float(saccades[event_index, 12]),
                        "yEnd": float(saccades[event_index, 13]),
                        "ampDeg": float(saccades[event_index, 7] * degperpixel),
                        "vPeak": float(saccades[event_index, 4] * degperpixel),
                        "distDeg": float(saccades[event_index, 5] * degperpixel),
                        "thetaDeg": float(
                            saccades[event_index, 6] * (180.0 / np.pi)
                        ),
                        "eye": eye_label,
                        "Calib_index": calib_value,
                        "Eyes_recorded": eyes_value,
                        "Rate_recorded": sample_rate,
                        "chunk": int(chunk_id),
                    }
                )

            order = np.argsort(onset_indices)
            sorted_onsets = onset_indices[order]
            sorted_offsets = offset_indices[order]

            if sorted_onsets[0] > 0:
                _append_fixation_record(
                    fixation_records,
                    0,
                    int(sorted_onsets[0] - 1),
                    coordinates=xy,
                    pupil_values=pupil_values,
                    chunk_start_ms=chunk_start_ms,
                    sample_rate=sample_rate,
                    eye_label=eye_label,
                    calibration=calib_value,
                    eyes_recorded=eyes_value,
                    chunk_id=chunk_id,
                )

            for event_index in range(len(sorted_onsets) - 1):
                _append_fixation_record(
                    fixation_records,
                    int(sorted_offsets[event_index] + 1),
                    int(sorted_onsets[event_index + 1] - 1),
                    coordinates=xy,
                    pupil_values=pupil_values,
                    chunk_start_ms=chunk_start_ms,
                    sample_rate=sample_rate,
                    eye_label=eye_label,
                    calibration=calib_value,
                    eyes_recorded=eyes_value,
                    chunk_id=chunk_id,
                )

            last_offset = int(sorted_offsets[-1])
            if last_offset < (indices.size - 1):
                _append_fixation_record(
                    fixation_records,
                    last_offset + 1,
                    indices.size - 1,
                    coordinates=xy,
                    pupil_values=pupil_values,
                    chunk_start_ms=chunk_start_ms,
                    sample_rate=sample_rate,
                    eye_label=eye_label,
                    calibration=calib_value,
                    eyes_recorded=eyes_value,
                    chunk_id=chunk_id,
                )

    fixation_records.sort(key=lambda row: row["tEnd"])
    saccade_records.sort(key=lambda row: row["tEnd"])
    return (
        _make_frame(self.samples, fixation_records, _FIXATION_COLUMNS),
        _make_frame(self.samples, saccade_records, _SACCADE_COLUMNS),
    )

microsacc_plugin(pos_xy, vel_xy, vfac, mindur_samples, sdx, sdy)

Return detected saccades using the Engbert velocity criterion.

Columns are onset, offset, duration in samples, average velocity, peak velocity, travelled distance, angle, amplitude, direction, epoch, x0, y0, x1, and y1.

Parameters:

Name Type Description Default
pos_xy ndarray

Gaze coordinates with shape (n_samples, 2).

required
vel_xy ndarray

Gaze velocities with shape (n_samples, 2).

required
vfac float

Multiplier applied to the robust velocity thresholds.

required
mindur_samples int

Minimum number of consecutive samples required for a saccade.

required
sdx float

Robust horizontal velocity scale.

required
sdy float

Robust vertical velocity scale.

required

Returns:

Type Description
ndarray

One row per detected saccade and fourteen event columns. An empty (0, 14) array is returned when no saccades are found.

Source code in pyxations/methods/eyemovement/engbert.py
def microsacc_plugin(
    pos_xy: np.ndarray,
    vel_xy: np.ndarray,
    vfac: float,
    mindur_samples: int,
    sdx: float,
    sdy: float,
) -> np.ndarray:
    """Return detected saccades using the Engbert velocity criterion.

    Columns are onset, offset, duration in samples, average velocity, peak
    velocity, travelled distance, angle, amplitude, direction, epoch, x0, y0,
    x1, and y1.

    Parameters
    ----------
    pos_xy : numpy.ndarray
        Gaze coordinates with shape ``(n_samples, 2)``.
    vel_xy : numpy.ndarray
        Gaze velocities with shape ``(n_samples, 2)``.
    vfac : float
        Multiplier applied to the robust velocity thresholds.
    mindur_samples : int
        Minimum number of consecutive samples required for a saccade.
    sdx : float
        Robust horizontal velocity scale.
    sdy : float
        Robust vertical velocity scale.

    Returns
    -------
    numpy.ndarray
        One row per detected saccade and fourteen event columns. An empty
        ``(0, 14)`` array is returned when no saccades are found.
    """
    vx, vy = vel_xy[:, 0], vel_xy[:, 1]
    with np.errstate(invalid="ignore", divide="ignore"):
        criterion = (vx / sdx) ** 2 + (vy / sdy) ** 2
    runs = _find_runs(criterion > (vfac**2))

    saccades: list[list[float]] = []
    for start, end in runs:
        if (end - start + 1) < mindur_samples:
            continue
        segment_velocity = np.hypot(vx[start : end + 1], vy[start : end + 1])
        if not np.isfinite(segment_velocity).any():
            continue
        peak_velocity = float(np.nanmax(segment_velocity))
        average_velocity = float(np.nanmean(segment_velocity))

        x0, y0 = pos_xy[start, 0], pos_xy[start, 1]
        x1, y1 = pos_xy[end, 0], pos_xy[end, 1]
        amplitude = float(np.hypot(x1 - x0, y1 - y0))
        theta = float(np.arctan2(y1 - y0, x1 - x0))

        segment = pos_xy[start : end + 1]
        distance = float(
            np.nansum(np.hypot(np.diff(segment[:, 0]), np.diff(segment[:, 1])))
        )

        saccades.append(
            [
                float(start),
                float(end),
                float(end - start + 1),
                average_velocity,
                peak_velocity,
                distance,
                theta,
                amplitude,
                theta,
                np.nan,
                float(x0),
                float(y0),
                float(x1),
                float(y1),
            ]
        )
    return np.asarray(saccades, dtype=float).reshape(-1, 14)

vecvel(gaze_xy, fs, smoothlevel=1)

Calculate two-dimensional gaze velocity in pixels per second.

Parameters:

Name Type Description Default
gaze_xy ndarray

Gaze coordinates with shape (n_samples, 2).

required
fs float

Sampling rate in hertz.

required
smoothlevel int

Smoothing-kernel level. Zero disables smoothing; levels one and two use progressively wider kernels.

1

Returns:

Type Description
ndarray

Horizontal and vertical velocities with the same shape as gaze_xy.

Raises:

Type Description
ValueError

If the coordinates do not have two columns or fs is not positive.

Source code in pyxations/methods/eyemovement/engbert.py
def vecvel(gaze_xy: np.ndarray, fs: float, smoothlevel: int = 1) -> np.ndarray:
    """Calculate two-dimensional gaze velocity in pixels per second.

    Parameters
    ----------
    gaze_xy : numpy.ndarray
        Gaze coordinates with shape ``(n_samples, 2)``.
    fs : float
        Sampling rate in hertz.
    smoothlevel : int, default 1
        Smoothing-kernel level. Zero disables smoothing; levels one and two
        use progressively wider kernels.

    Returns
    -------
    numpy.ndarray
        Horizontal and vertical velocities with the same shape as
        ``gaze_xy``.

    Raises
    ------
    ValueError
        If the coordinates do not have two columns or ``fs`` is not positive.
    """
    gaze_xy = np.asarray(gaze_xy, dtype=float)
    if gaze_xy.ndim != 2 or gaze_xy.shape[1] != 2:
        raise ValueError("gaze_xy must have shape (n_samples, 2).")
    if gaze_xy.shape[0] < 2:
        return np.full_like(gaze_xy, np.nan, dtype=float)
    if not np.isfinite(fs) or fs <= 0:
        raise ValueError("Sampling rate must be finite and greater than zero.")

    x = _smooth_1d(gaze_xy[:, 0], smoothlevel)
    y = _smooth_1d(gaze_xy[:, 1], smoothlevel)

    vx = np.empty_like(x)
    vy = np.empty_like(y)
    vx[1:-1] = (x[2:] - x[:-2]) * (fs / 2.0)
    vy[1:-1] = (y[2:] - y[:-2]) * (fs / 2.0)
    vx[0] = (x[1] - x[0]) * fs
    vy[0] = (y[1] - y[0]) * fs
    vx[-1] = (x[-1] - x[-2]) * fs
    vy[-1] = (y[-1] - y[-2]) * fs
    vx[~np.isfinite(vx)] = np.nan
    vy[~np.isfinite(vy)] = np.nan
    return np.column_stack([vx, vy])

velthresh(vxy)

Return robust horizontal and vertical velocity thresholds.

Parameters:

Name Type Description Default
vxy ndarray

Horizontal and vertical velocities with shape (n_samples, 2).

required

Returns:

Type Description
tuple of float

Robust standard deviations for the horizontal and vertical velocity components.

Source code in pyxations/methods/eyemovement/engbert.py
def velthresh(vxy: np.ndarray) -> tuple[float, float]:
    """Return robust horizontal and vertical velocity thresholds.

    Parameters
    ----------
    vxy : numpy.ndarray
        Horizontal and vertical velocities with shape ``(n_samples, 2)``.

    Returns
    -------
    tuple of float
        Robust standard deviations for the horizontal and vertical velocity
        components.
    """
    return _robust_std(vxy[:, 0]), _robust_std(vxy[:, 1])

REMoDNaV

Adapter for REMoDNaV. Requires the optional remodnav extra, installed with pip install 'pyxations[remodnav]'.

Polars-native REMoDNaV eye-movement detection adapter.

RemodnavDetection

Bases: EyeMovementDetection

Detect fixations and saccades with REMoDNaV.

Source code in pyxations/methods/eyemovement/remodnav_detector.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
class RemodnavDetection(EyeMovementDetection):
    """Detect fixations and saccades with REMoDNaV."""

    def __init__(self, session_folder_path: Any, samples: Any):
        self.session_folder_path = session_folder_path
        self.out_folder = session_folder_path / "remodnav_events"
        self.samples = samples

    def detect_eye_movements(
        self,
        min_pursuit_dur: float = 10.0,
        max_pso_dur: float = 0.0,
        min_fix_dur: float = 0.05,
        sac_max_vel: float = 1000.0,
        fix_max_amp: float = 1.5,
        sac_time_thresh: float = 0.002,
        drop_fix_from_blink: bool = True,
        screen_size: float = 38.0,
        screen_width: int = 1920,
        screen_distance: float = 60.0,
        savgol_length: float = 0.195,
        lowpass_cutoff_freq: float | None = None,
    ) -> tuple[Any, Any]:
        """Detect eye movements in all continuous chunks and recorded eyes.

        Input timestamps are expected in milliseconds.  The returned dataframe
        type matches ``self.samples``.

        Parameters
        ----------
        min_pursuit_dur : float, default 10.0
            Minimum pursuit duration in seconds.
        max_pso_dur : float, default 0.0
            Maximum post-saccadic oscillation duration in seconds.
        min_fix_dur : float, default 0.05
            Minimum fixation duration in seconds.
        sac_max_vel : float, default 1000.0
            Maximum retained saccade peak velocity in degrees per second.
        fix_max_amp : float, default 1.5
            Maximum retained fixation amplitude in degrees.
        sac_time_thresh : float, default 0.002
            Temporal tolerance in seconds when associating fixations with
            preceding saccades.
        drop_fix_from_blink : bool, default True
            Whether to retain only fixations adjacent to a detected saccade.
        screen_size : float, default 38.0
            Physical screen width in centimetres.
        screen_width : int, default 1920
            Screen width in pixels.
        screen_distance : float, default 60.0
            Viewing distance in centimetres.
        savgol_length : float, default 0.195
            Savitzky-Golay filter window length in seconds.
        lowpass_cutoff_freq : float, optional
            Low-pass cutoff in hertz. By default, use the smaller of 4 Hz and
            40 percent of the measured sample rate.

        Returns
        -------
        fixations : DataFrame-like
            Detected fixation events, using the input dataframe library.
        saccades : DataFrame-like
            Detected saccade events, using the input dataframe library.

        Raises
        ------
        ValueError
            If timestamps and rates differ in length, or a recorded rate is
            non-finite or non-positive.
        """
        self.out_folder.mkdir(parents=True, exist_ok=True)

        timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
        sample_rates = _column_to_numpy(self.samples, "Rate_recorded", dtype=float)
        if timestamps.size == 0:
            return (
                _make_frame(self.samples, [], _FIXATION_OUTPUT_COLUMNS),
                _make_frame(self.samples, [], _SACCADE_OUTPUT_COLUMNS),
            )
        if timestamps.size != sample_rates.size:
            raise ValueError(
                "tSample and Rate_recorded must contain the same number of rows."
            )
        if not np.isfinite(sample_rates).all() or np.any(sample_rates <= 0):
            raise ValueError(
                "Rate_recorded values must be finite and greater than zero."
            )

        # Preserve the existing discontinuity rule while applying the expected
        # interval from the preceding sample when the rate changes.
        expected_intervals = 1000.0 / sample_rates[:-1]
        chunk_starts = np.flatnonzero(np.diff(timestamps) > expected_intervals) + 1
        chunk_indices = np.split(np.arange(timestamps.size), chunk_starts)

        fixation_records: list[dict[str, Any]] = []
        saccade_records: list[dict[str, Any]] = []

        for indices in chunk_indices:
            fixations, saccades = self.detect_on_chunk(
                indices,
                min_pursuit_dur=min_pursuit_dur,
                max_pso_dur=max_pso_dur,
                min_fix_dur=min_fix_dur,
                sac_max_vel=sac_max_vel,
                fix_max_amp=fix_max_amp,
                sac_time_thresh=sac_time_thresh,
                drop_fix_from_blink=drop_fix_from_blink,
                screen_size=screen_size,
                screen_width=screen_width,
                screen_distance=screen_distance,
                savgol_length=savgol_length,
                lowpass_cutoff_freq=lowpass_cutoff_freq,
            )
            fixation_records.extend(_frame_to_records(fixations))
            saccade_records.extend(_frame_to_records(saccades))

        fixation_records.sort(key=lambda row: row["tEnd"])
        saccade_records.sort(key=lambda row: row["tEnd"])
        return (
            _make_frame(self.samples, fixation_records, _FIXATION_OUTPUT_COLUMNS),
            _make_frame(self.samples, saccade_records, _SACCADE_OUTPUT_COLUMNS),
        )

    def run_eye_movement_from_samples(
        self,
        sample_rate: float,
        x_label: str = "X",
        y_label: str = "Y",
        config: Mapping[str, Any] | None = None,
        **kwargs: Any,
    ) -> tuple[Any, Any]:
        """Run REMoDNaV on two columns in ``self.samples``.

        Missing pupil measurements remain missing (NaN); they are no longer
        represented by synthetic zeros.

        Parameters
        ----------
        sample_rate : float
            Sampling rate in hertz.
        x_label : str, default "X"
            Name of the horizontal gaze-coordinate column.
        y_label : str, default "Y"
            Name of the vertical gaze-coordinate column.
        config : mapping, optional
            Detector options forwarded to :meth:`run_eye_movement`.
        **kwargs : object
            Additional detector options forwarded to
            :meth:`run_eye_movement`. These override neither duplicate
            entries nor Python's duplicate-key checks.

        Returns
        -------
        fixations : DataFrame-like
            Detected fixation events, using the input dataframe library.
        saccades : DataFrame-like
            Detected saccade events, using the input dataframe library.

        Raises
        ------
        ValueError
            If the rate is not positive or the gaze and timestamp columns
            differ in length.
        """
        if sample_rate <= 0:
            raise ValueError("sample_rate must be greater than zero.")

        options = dict(config or {})
        gazex_data = _column_to_numpy(self.samples, x_label, dtype=float)
        gazey_data = _column_to_numpy(self.samples, y_label, dtype=float)
        timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
        if not (gazex_data.size == gazey_data.size == timestamps.size):
            raise ValueError("Gaze coordinates and timestamps must have equal lengths.")

        starting_time = float(np.nanmin(timestamps))
        pupil_data = options.pop("pupil_data", None)
        if pupil_data is None:
            pupil_data = np.full(gazex_data.size, np.nan, dtype=float)

        times = (timestamps - starting_time) / 1_000.0
        return self.run_eye_movement(
            gazex_data,
            gazey_data,
            sample_rate,
            times=times,
            starting_time=starting_time,
            pupil_data=pupil_data,
            **options,
            **kwargs,
        )

    def run_eye_movement(
        self,
        gazex_data: Any,
        gazey_data: Any,
        sample_rate: float,
        min_pursuit_dur: float = 10.0,
        max_pso_dur: float = 0.0,
        min_fix_dur: float = 0.05,
        min_saccade_duration: float = 0.04,
        sac_max_vel: float = 1000.0,
        fix_max_amp: float = 1.5,
        sac_time_thresh: float = 0.002,
        drop_fix_from_blink: bool = True,
        screen_size: float = 38.0,
        screen_width: int = 1920,
        screen_distance: float = 60.0,
        calib_index: Any = 0,
        savgol_length: float = 0.19,
        eyes_recorded: Any = None,
        starting_time: float | None = None,
        times: Any = None,
        pupil_data: Any = None,
        eye: Any = None,
        lowpass_cutoff_freq: float | None = None,
    ) -> tuple[Any, Any]:
        """Run REMoDNaV for one eye stream and return event tables.

        Parameters
        ----------
        gazex_data : array-like
            Horizontal gaze coordinates.
        gazey_data : array-like
            Vertical gaze coordinates.
        sample_rate : float
            Sampling rate in hertz.
        min_pursuit_dur : float, default 10.0
            Minimum pursuit duration in seconds.
        max_pso_dur : float, default 0.0
            Maximum post-saccadic oscillation duration in seconds.
        min_fix_dur : float, default 0.05
            Minimum fixation duration in seconds.
        min_saccade_duration : float, default 0.04
            Minimum saccade duration in seconds.
        sac_max_vel : float, default 1000.0
            Maximum retained saccade peak velocity in degrees per second.
        fix_max_amp : float, default 1.5
            Maximum retained fixation amplitude in degrees.
        sac_time_thresh : float, default 0.002
            Temporal tolerance in seconds for fixation-saccade adjacency.
        drop_fix_from_blink : bool, default True
            Whether to retain only fixations adjacent to a detected saccade.
        screen_size : float, default 38.0
            Physical screen width in centimetres.
        screen_width : int, default 1920
            Screen width in pixels.
        screen_distance : float, default 60.0
            Viewing distance in centimetres.
        calib_index : object, default 0
            Calibration-block identifier copied to event rows.
        savgol_length : float, default 0.19
            Savitzky-Golay filter window length in seconds.
        eyes_recorded : object, optional
            Source eye-recording label copied to event rows.
        starting_time : float, optional
            Recording-time offset in milliseconds.
        times : array-like, optional
            Per-sample times in seconds relative to ``starting_time``.
        pupil_data : array-like, optional
            Pupil measurements aligned with the gaze arrays.
        eye : object, optional
            Eye label copied to event rows.
        lowpass_cutoff_freq : float, optional
            Low-pass cutoff in hertz. It must be below Nyquist.

        Returns
        -------
        fixations : DataFrame-like
            Detected fixation events, using the input dataframe library.
        saccades : DataFrame-like
            Detected saccade events, using the input dataframe library.

        Raises
        ------
        ValueError
            If configuration values are invalid or the sample arrays have
            inconsistent lengths.
        """
        if sample_rate <= 0:
            raise ValueError("sample_rate must be greater than zero.")
        if screen_size <= 0 or screen_width <= 0 or screen_distance <= 0:
            raise ValueError(
                "Screen size, width, and viewing distance must be positive."
            )

        gaze_x = np.asarray(gazex_data, dtype=float).reshape(-1)
        gaze_y = np.asarray(gazey_data, dtype=float).reshape(-1)
        if gaze_x.size != gaze_y.size:
            raise ValueError("gazex_data and gazey_data must have equal lengths.")

        if times is None:
            sample_times = np.arange(gaze_x.size, dtype=float) / float(sample_rate)
        else:
            sample_times = np.asarray(times, dtype=float).reshape(-1)
        if sample_times.size != gaze_x.size:
            raise ValueError("times must have the same length as the gaze arrays.")

        if pupil_data is None:
            pupil = np.full(gaze_x.size, np.nan, dtype=float)
        else:
            pupil = np.asarray(pupil_data, dtype=float).reshape(-1)
        if pupil.size != gaze_x.size:
            raise ValueError("pupil_data must have the same length as the gaze arrays.")

        time_offset_ms = 0.0 if starting_time is None else float(starting_time)
        eye_data = np.rec.fromarrays((gaze_x, gaze_y), names=("x", "y"))
        px2deg = math.degrees(math.atan2(0.5 * screen_size, screen_distance)) / (
            0.5 * screen_width
        )

        if lowpass_cutoff_freq is None:
            resolved_lowpass_cutoff = min(4.0, sample_rate * 0.4)
        else:
            resolved_lowpass_cutoff = float(lowpass_cutoff_freq)
        if (
            not np.isfinite(resolved_lowpass_cutoff)
            or resolved_lowpass_cutoff <= 0
            or resolved_lowpass_cutoff >= sample_rate / 2.0
        ):
            raise ValueError(
                "lowpass_cutoff_freq must be finite, greater than zero, and "
                "below the Nyquist frequency (sample_rate / 2)."
            )

        logger.info("Running REMoDNaV detection for %s eye", eye)
        classifier = EyegazeClassifier(
            px2deg=px2deg,
            sampling_rate=sample_rate,
            min_pursuit_duration=min_pursuit_dur,
            max_pso_duration=max_pso_dur,
            min_fixation_duration=min_fix_dur,
            min_saccade_duration=min_saccade_duration,
            lowpass_cutoff_freq=resolved_lowpass_cutoff,
        )
        preprocessed = classifier.preproc(eye_data, savgol_length=savgol_length)
        events = _normalise_remodnav_events(
            classifier(preprocessed, classify_isp=True, sort_events=True)
        )

        finite_times = sample_times[np.isfinite(sample_times)]
        if finite_times.size:
            recording_start = float(np.min(finite_times))
            recording_end = float(np.max(finite_times))
            bounded_events = []
            for event in events:
                bounded = dict(event)
                bounded["start_time"] = float(
                    np.clip(bounded["start_time"], recording_start, recording_end)
                )
                bounded["end_time"] = float(
                    np.clip(bounded["end_time"], recording_start, recording_end)
                )
                if bounded["end_time"] >= bounded["start_time"]:
                    bounded_events.append(bounded)
            events = bounded_events

        fixation_events = [event for event in events if event["label"] == "FIXA"]
        saccade_events = [
            event for event in events if event["label"] in {"SACC", "ISAC"}
        ]

        filtered_fixations = [
            event for event in fixation_events if event["amp"] <= fix_max_amp
        ]
        filtered_saccades = [
            event for event in saccade_events if event["peak_vel"] <= sac_max_vel
        ]
        # Preserve the former start-x ordering before the optional adjacency filter.
        filtered_fixations.sort(key=lambda event: event["start_x"])
        filtered_saccades.sort(key=lambda event: event["start_x"])

        logger.info(
            "Kept %d/%d fixations and %d/%d saccades after amplitude/velocity filtering",
            len(filtered_fixations),
            len(fixation_events),
            len(filtered_saccades),
            len(saccade_events),
        )

        if drop_fix_from_blink and filtered_fixations:
            saccade_ends = np.asarray(
                [event["end_time"] for event in filtered_saccades], dtype=float
            )
            filtered_fixations = [
                fixation
                for fixation in filtered_fixations
                if saccade_ends.size
                and np.any(
                    (saccade_ends > fixation["start_time"] - sac_time_thresh)
                    & (saccade_ends < fixation["start_time"] + sac_time_thresh)
                )
            ]

        fixation_rows: list[dict[str, Any]] = []
        for event in filtered_fixations:
            within = (sample_times > event["start_time"]) & (
                sample_times < event["end_time"]
            )
            row = self._event_row(
                event,
                time_offset_ms=time_offset_ms,
                sample_rate=sample_rate,
                calib_index=calib_index,
                eyes_recorded=eyes_recorded,
                eye=eye,
            )
            row.update(
                xAvg=_nanmean_or_nan(gaze_x[within]),
                yAvg=_nanmean_or_nan(gaze_y[within]),
                pupilAvg=_nanmean_or_nan(pupil[within]),
            )
            fixation_rows.append(row)

        saccade_rows = [
            self._event_row(
                event,
                time_offset_ms=time_offset_ms,
                sample_rate=sample_rate,
                calib_index=calib_index,
                eyes_recorded=eyes_recorded,
                eye=eye,
            )
            for event in filtered_saccades
        ]

        return (
            _make_frame(self.samples, fixation_rows, _FIXATION_OUTPUT_COLUMNS),
            _make_frame(self.samples, saccade_rows, _SACCADE_OUTPUT_COLUMNS),
        )

    @staticmethod
    def _event_row(
        event: Mapping[str, Any],
        *,
        time_offset_ms: float,
        sample_rate: float,
        calib_index: Any,
        eyes_recorded: Any,
        eye: Any,
    ) -> dict[str, Any]:
        return {
            "tStart": event["start_time"] * 1000.0 + time_offset_ms,
            "tEnd": event["end_time"] * 1000.0 + time_offset_ms,
            "xStart": event["start_x"],
            "yStart": event["start_y"],
            "xEnd": event["end_x"],
            "yEnd": event["end_y"],
            "ampDeg": event["amp"],
            "vPeak": event["peak_vel"],
            "med_vel": event["med_vel"],
            "avg_vel": event["avg_vel"],
            "duration": (event["end_time"] - event["start_time"]) * 1000.0,
            "Calib_index": calib_index,
            "Eyes_recorded": eyes_recorded,
            "Rate_recorded": sample_rate,
            "eye": eye,
        }

    def detect_on_chunk(
        self,
        indices: np.ndarray,
        min_pursuit_dur: float = 10.0,
        max_pso_dur: float = 0.0,
        min_fix_dur: float = 0.05,
        sac_max_vel: float = 1000.0,
        fix_max_amp: float = 1.5,
        sac_time_thresh: float = 0.002,
        drop_fix_from_blink: bool = True,
        screen_size: float = 38.0,
        screen_width: int = 1920,
        screen_distance: float = 60.0,
        savgol_length: float = 0.19,
        lowpass_cutoff_freq: float | None = None,
    ) -> tuple[Any, Any]:
        """Detect events for a continuous set of sample row indices.

        Parameters
        ----------
        indices : numpy.ndarray
            Row indices defining one continuous sample chunk.
        min_pursuit_dur : float, default 10.0
            Minimum pursuit duration in seconds.
        max_pso_dur : float, default 0.0
            Maximum post-saccadic oscillation duration in seconds.
        min_fix_dur : float, default 0.05
            Minimum fixation duration in seconds.
        sac_max_vel : float, default 1000.0
            Maximum retained saccade peak velocity in degrees per second.
        fix_max_amp : float, default 1.5
            Maximum retained fixation amplitude in degrees.
        sac_time_thresh : float, default 0.002
            Temporal tolerance in seconds for fixation-saccade adjacency.
        drop_fix_from_blink : bool, default True
            Whether to retain only fixations adjacent to a detected saccade.
        screen_size : float, default 38.0
            Physical screen width in centimetres.
        screen_width : int, default 1920
            Screen width in pixels.
        screen_distance : float, default 60.0
            Viewing distance in centimetres.
        savgol_length : float, default 0.19
            Savitzky-Golay filter window length in seconds.
        lowpass_cutoff_freq : float, optional
            Low-pass cutoff in hertz. By default, choose a Nyquist-safe value.

        Returns
        -------
        fixations : DataFrame-like
            Detected fixation events for the chunk.
        saccades : DataFrame-like
            Detected saccade events for the chunk.

        Raises
        ------
        ValueError
            If the chunk metadata are not constant or no supported gaze
            coordinate columns are available.
        """
        indices = np.asarray(indices, dtype=int)
        if indices.size == 0:
            return (
                _make_frame(self.samples, [], _FIXATION_OUTPUT_COLUMNS),
                _make_frame(self.samples, [], _SACCADE_OUTPUT_COLUMNS),
            )

        rates = _column_to_numpy(self.samples, "Rate_recorded", dtype=float)[indices]
        calib_values = _column_to_numpy(self.samples, "Calib_index")[indices]
        eyes_values = _column_to_numpy(self.samples, "Eyes_recorded")[indices]
        timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)[indices]

        sample_rate = float(_validate_constant(rates, "Rate_recorded"))
        calib_index = _validate_constant(calib_values, "Calib_index")
        eyes_recorded = _validate_constant(eyes_values, "Eyes_recorded")
        starting_time = float(timestamps[0])
        times = (timestamps - starting_time) / 1_000.0

        columns = set(_column_names(self.samples))
        streams: list[tuple[str, str, str, str | None]] = []
        if {"LX", "LY"}.issubset(columns):
            streams.append(("L", "LX", "LY", "LPupil" if "LPupil" in columns else None))
        if {"RX", "RY"}.issubset(columns):
            streams.append(("R", "RX", "RY", "RPupil" if "RPupil" in columns else None))
        if not streams and {"X", "Y"}.issubset(columns):
            streams.append(("U", "X", "Y", "Pupil" if "Pupil" in columns else None))
        if not streams:
            raise ValueError(
                "Samples must contain LX/LY, RX/RY, or generic X/Y gaze columns."
            )

        fixation_records: list[dict[str, Any]] = []
        saccade_records: list[dict[str, Any]] = []

        for eye, x_column, y_column, pupil_column in streams:
            gaze_x = _column_to_numpy(self.samples, x_column, dtype=float)[indices]
            gaze_y = _column_to_numpy(self.samples, y_column, dtype=float)[indices]
            if not np.isfinite(gaze_x).any() or not np.isfinite(gaze_y).any():
                continue

            pupil = (
                _column_to_numpy(self.samples, pupil_column, dtype=float)[indices]
                if pupil_column is not None
                else np.full(indices.size, np.nan, dtype=float)
            )
            fixations, saccades = self.run_eye_movement(
                gaze_x,
                gaze_y,
                sample_rate,
                min_pursuit_dur=min_pursuit_dur,
                max_pso_dur=max_pso_dur,
                min_fix_dur=min_fix_dur,
                min_saccade_duration=0.04,
                sac_max_vel=sac_max_vel,
                fix_max_amp=fix_max_amp,
                sac_time_thresh=sac_time_thresh,
                drop_fix_from_blink=drop_fix_from_blink,
                screen_size=screen_size,
                screen_width=screen_width,
                screen_distance=screen_distance,
                calib_index=calib_index,
                savgol_length=savgol_length,
                eyes_recorded=eyes_recorded,
                starting_time=starting_time,
                times=times,
                pupil_data=pupil,
                eye=eye,
                lowpass_cutoff_freq=lowpass_cutoff_freq,
            )
            fixation_records.extend(_frame_to_records(fixations))
            saccade_records.extend(_frame_to_records(saccades))

        return (
            _make_frame(self.samples, fixation_records, _FIXATION_OUTPUT_COLUMNS),
            _make_frame(self.samples, saccade_records, _SACCADE_OUTPUT_COLUMNS),
        )

detect_eye_movements(min_pursuit_dur=10.0, max_pso_dur=0.0, min_fix_dur=0.05, sac_max_vel=1000.0, fix_max_amp=1.5, sac_time_thresh=0.002, drop_fix_from_blink=True, screen_size=38.0, screen_width=1920, screen_distance=60.0, savgol_length=0.195, lowpass_cutoff_freq=None)

Detect eye movements in all continuous chunks and recorded eyes.

Input timestamps are expected in milliseconds. The returned dataframe type matches self.samples.

Parameters:

Name Type Description Default
min_pursuit_dur float

Minimum pursuit duration in seconds.

10.0
max_pso_dur float

Maximum post-saccadic oscillation duration in seconds.

0.0
min_fix_dur float

Minimum fixation duration in seconds.

0.05
sac_max_vel float

Maximum retained saccade peak velocity in degrees per second.

1000.0
fix_max_amp float

Maximum retained fixation amplitude in degrees.

1.5
sac_time_thresh float

Temporal tolerance in seconds when associating fixations with preceding saccades.

0.002
drop_fix_from_blink bool

Whether to retain only fixations adjacent to a detected saccade.

True
screen_size float

Physical screen width in centimetres.

38.0
screen_width int

Screen width in pixels.

1920
screen_distance float

Viewing distance in centimetres.

60.0
savgol_length float

Savitzky-Golay filter window length in seconds.

0.195
lowpass_cutoff_freq float

Low-pass cutoff in hertz. By default, use the smaller of 4 Hz and 40 percent of the measured sample rate.

None

Returns:

Name Type Description
fixations DataFrame - like

Detected fixation events, using the input dataframe library.

saccades DataFrame - like

Detected saccade events, using the input dataframe library.

Raises:

Type Description
ValueError

If timestamps and rates differ in length, or a recorded rate is non-finite or non-positive.

Source code in pyxations/methods/eyemovement/remodnav_detector.py
def detect_eye_movements(
    self,
    min_pursuit_dur: float = 10.0,
    max_pso_dur: float = 0.0,
    min_fix_dur: float = 0.05,
    sac_max_vel: float = 1000.0,
    fix_max_amp: float = 1.5,
    sac_time_thresh: float = 0.002,
    drop_fix_from_blink: bool = True,
    screen_size: float = 38.0,
    screen_width: int = 1920,
    screen_distance: float = 60.0,
    savgol_length: float = 0.195,
    lowpass_cutoff_freq: float | None = None,
) -> tuple[Any, Any]:
    """Detect eye movements in all continuous chunks and recorded eyes.

    Input timestamps are expected in milliseconds.  The returned dataframe
    type matches ``self.samples``.

    Parameters
    ----------
    min_pursuit_dur : float, default 10.0
        Minimum pursuit duration in seconds.
    max_pso_dur : float, default 0.0
        Maximum post-saccadic oscillation duration in seconds.
    min_fix_dur : float, default 0.05
        Minimum fixation duration in seconds.
    sac_max_vel : float, default 1000.0
        Maximum retained saccade peak velocity in degrees per second.
    fix_max_amp : float, default 1.5
        Maximum retained fixation amplitude in degrees.
    sac_time_thresh : float, default 0.002
        Temporal tolerance in seconds when associating fixations with
        preceding saccades.
    drop_fix_from_blink : bool, default True
        Whether to retain only fixations adjacent to a detected saccade.
    screen_size : float, default 38.0
        Physical screen width in centimetres.
    screen_width : int, default 1920
        Screen width in pixels.
    screen_distance : float, default 60.0
        Viewing distance in centimetres.
    savgol_length : float, default 0.195
        Savitzky-Golay filter window length in seconds.
    lowpass_cutoff_freq : float, optional
        Low-pass cutoff in hertz. By default, use the smaller of 4 Hz and
        40 percent of the measured sample rate.

    Returns
    -------
    fixations : DataFrame-like
        Detected fixation events, using the input dataframe library.
    saccades : DataFrame-like
        Detected saccade events, using the input dataframe library.

    Raises
    ------
    ValueError
        If timestamps and rates differ in length, or a recorded rate is
        non-finite or non-positive.
    """
    self.out_folder.mkdir(parents=True, exist_ok=True)

    timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
    sample_rates = _column_to_numpy(self.samples, "Rate_recorded", dtype=float)
    if timestamps.size == 0:
        return (
            _make_frame(self.samples, [], _FIXATION_OUTPUT_COLUMNS),
            _make_frame(self.samples, [], _SACCADE_OUTPUT_COLUMNS),
        )
    if timestamps.size != sample_rates.size:
        raise ValueError(
            "tSample and Rate_recorded must contain the same number of rows."
        )
    if not np.isfinite(sample_rates).all() or np.any(sample_rates <= 0):
        raise ValueError(
            "Rate_recorded values must be finite and greater than zero."
        )

    # Preserve the existing discontinuity rule while applying the expected
    # interval from the preceding sample when the rate changes.
    expected_intervals = 1000.0 / sample_rates[:-1]
    chunk_starts = np.flatnonzero(np.diff(timestamps) > expected_intervals) + 1
    chunk_indices = np.split(np.arange(timestamps.size), chunk_starts)

    fixation_records: list[dict[str, Any]] = []
    saccade_records: list[dict[str, Any]] = []

    for indices in chunk_indices:
        fixations, saccades = self.detect_on_chunk(
            indices,
            min_pursuit_dur=min_pursuit_dur,
            max_pso_dur=max_pso_dur,
            min_fix_dur=min_fix_dur,
            sac_max_vel=sac_max_vel,
            fix_max_amp=fix_max_amp,
            sac_time_thresh=sac_time_thresh,
            drop_fix_from_blink=drop_fix_from_blink,
            screen_size=screen_size,
            screen_width=screen_width,
            screen_distance=screen_distance,
            savgol_length=savgol_length,
            lowpass_cutoff_freq=lowpass_cutoff_freq,
        )
        fixation_records.extend(_frame_to_records(fixations))
        saccade_records.extend(_frame_to_records(saccades))

    fixation_records.sort(key=lambda row: row["tEnd"])
    saccade_records.sort(key=lambda row: row["tEnd"])
    return (
        _make_frame(self.samples, fixation_records, _FIXATION_OUTPUT_COLUMNS),
        _make_frame(self.samples, saccade_records, _SACCADE_OUTPUT_COLUMNS),
    )

detect_on_chunk(indices, min_pursuit_dur=10.0, max_pso_dur=0.0, min_fix_dur=0.05, sac_max_vel=1000.0, fix_max_amp=1.5, sac_time_thresh=0.002, drop_fix_from_blink=True, screen_size=38.0, screen_width=1920, screen_distance=60.0, savgol_length=0.19, lowpass_cutoff_freq=None)

Detect events for a continuous set of sample row indices.

Parameters:

Name Type Description Default
indices ndarray

Row indices defining one continuous sample chunk.

required
min_pursuit_dur float

Minimum pursuit duration in seconds.

10.0
max_pso_dur float

Maximum post-saccadic oscillation duration in seconds.

0.0
min_fix_dur float

Minimum fixation duration in seconds.

0.05
sac_max_vel float

Maximum retained saccade peak velocity in degrees per second.

1000.0
fix_max_amp float

Maximum retained fixation amplitude in degrees.

1.5
sac_time_thresh float

Temporal tolerance in seconds for fixation-saccade adjacency.

0.002
drop_fix_from_blink bool

Whether to retain only fixations adjacent to a detected saccade.

True
screen_size float

Physical screen width in centimetres.

38.0
screen_width int

Screen width in pixels.

1920
screen_distance float

Viewing distance in centimetres.

60.0
savgol_length float

Savitzky-Golay filter window length in seconds.

0.19
lowpass_cutoff_freq float

Low-pass cutoff in hertz. By default, choose a Nyquist-safe value.

None

Returns:

Name Type Description
fixations DataFrame - like

Detected fixation events for the chunk.

saccades DataFrame - like

Detected saccade events for the chunk.

Raises:

Type Description
ValueError

If the chunk metadata are not constant or no supported gaze coordinate columns are available.

Source code in pyxations/methods/eyemovement/remodnav_detector.py
def detect_on_chunk(
    self,
    indices: np.ndarray,
    min_pursuit_dur: float = 10.0,
    max_pso_dur: float = 0.0,
    min_fix_dur: float = 0.05,
    sac_max_vel: float = 1000.0,
    fix_max_amp: float = 1.5,
    sac_time_thresh: float = 0.002,
    drop_fix_from_blink: bool = True,
    screen_size: float = 38.0,
    screen_width: int = 1920,
    screen_distance: float = 60.0,
    savgol_length: float = 0.19,
    lowpass_cutoff_freq: float | None = None,
) -> tuple[Any, Any]:
    """Detect events for a continuous set of sample row indices.

    Parameters
    ----------
    indices : numpy.ndarray
        Row indices defining one continuous sample chunk.
    min_pursuit_dur : float, default 10.0
        Minimum pursuit duration in seconds.
    max_pso_dur : float, default 0.0
        Maximum post-saccadic oscillation duration in seconds.
    min_fix_dur : float, default 0.05
        Minimum fixation duration in seconds.
    sac_max_vel : float, default 1000.0
        Maximum retained saccade peak velocity in degrees per second.
    fix_max_amp : float, default 1.5
        Maximum retained fixation amplitude in degrees.
    sac_time_thresh : float, default 0.002
        Temporal tolerance in seconds for fixation-saccade adjacency.
    drop_fix_from_blink : bool, default True
        Whether to retain only fixations adjacent to a detected saccade.
    screen_size : float, default 38.0
        Physical screen width in centimetres.
    screen_width : int, default 1920
        Screen width in pixels.
    screen_distance : float, default 60.0
        Viewing distance in centimetres.
    savgol_length : float, default 0.19
        Savitzky-Golay filter window length in seconds.
    lowpass_cutoff_freq : float, optional
        Low-pass cutoff in hertz. By default, choose a Nyquist-safe value.

    Returns
    -------
    fixations : DataFrame-like
        Detected fixation events for the chunk.
    saccades : DataFrame-like
        Detected saccade events for the chunk.

    Raises
    ------
    ValueError
        If the chunk metadata are not constant or no supported gaze
        coordinate columns are available.
    """
    indices = np.asarray(indices, dtype=int)
    if indices.size == 0:
        return (
            _make_frame(self.samples, [], _FIXATION_OUTPUT_COLUMNS),
            _make_frame(self.samples, [], _SACCADE_OUTPUT_COLUMNS),
        )

    rates = _column_to_numpy(self.samples, "Rate_recorded", dtype=float)[indices]
    calib_values = _column_to_numpy(self.samples, "Calib_index")[indices]
    eyes_values = _column_to_numpy(self.samples, "Eyes_recorded")[indices]
    timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)[indices]

    sample_rate = float(_validate_constant(rates, "Rate_recorded"))
    calib_index = _validate_constant(calib_values, "Calib_index")
    eyes_recorded = _validate_constant(eyes_values, "Eyes_recorded")
    starting_time = float(timestamps[0])
    times = (timestamps - starting_time) / 1_000.0

    columns = set(_column_names(self.samples))
    streams: list[tuple[str, str, str, str | None]] = []
    if {"LX", "LY"}.issubset(columns):
        streams.append(("L", "LX", "LY", "LPupil" if "LPupil" in columns else None))
    if {"RX", "RY"}.issubset(columns):
        streams.append(("R", "RX", "RY", "RPupil" if "RPupil" in columns else None))
    if not streams and {"X", "Y"}.issubset(columns):
        streams.append(("U", "X", "Y", "Pupil" if "Pupil" in columns else None))
    if not streams:
        raise ValueError(
            "Samples must contain LX/LY, RX/RY, or generic X/Y gaze columns."
        )

    fixation_records: list[dict[str, Any]] = []
    saccade_records: list[dict[str, Any]] = []

    for eye, x_column, y_column, pupil_column in streams:
        gaze_x = _column_to_numpy(self.samples, x_column, dtype=float)[indices]
        gaze_y = _column_to_numpy(self.samples, y_column, dtype=float)[indices]
        if not np.isfinite(gaze_x).any() or not np.isfinite(gaze_y).any():
            continue

        pupil = (
            _column_to_numpy(self.samples, pupil_column, dtype=float)[indices]
            if pupil_column is not None
            else np.full(indices.size, np.nan, dtype=float)
        )
        fixations, saccades = self.run_eye_movement(
            gaze_x,
            gaze_y,
            sample_rate,
            min_pursuit_dur=min_pursuit_dur,
            max_pso_dur=max_pso_dur,
            min_fix_dur=min_fix_dur,
            min_saccade_duration=0.04,
            sac_max_vel=sac_max_vel,
            fix_max_amp=fix_max_amp,
            sac_time_thresh=sac_time_thresh,
            drop_fix_from_blink=drop_fix_from_blink,
            screen_size=screen_size,
            screen_width=screen_width,
            screen_distance=screen_distance,
            calib_index=calib_index,
            savgol_length=savgol_length,
            eyes_recorded=eyes_recorded,
            starting_time=starting_time,
            times=times,
            pupil_data=pupil,
            eye=eye,
            lowpass_cutoff_freq=lowpass_cutoff_freq,
        )
        fixation_records.extend(_frame_to_records(fixations))
        saccade_records.extend(_frame_to_records(saccades))

    return (
        _make_frame(self.samples, fixation_records, _FIXATION_OUTPUT_COLUMNS),
        _make_frame(self.samples, saccade_records, _SACCADE_OUTPUT_COLUMNS),
    )

run_eye_movement(gazex_data, gazey_data, sample_rate, min_pursuit_dur=10.0, max_pso_dur=0.0, min_fix_dur=0.05, min_saccade_duration=0.04, sac_max_vel=1000.0, fix_max_amp=1.5, sac_time_thresh=0.002, drop_fix_from_blink=True, screen_size=38.0, screen_width=1920, screen_distance=60.0, calib_index=0, savgol_length=0.19, eyes_recorded=None, starting_time=None, times=None, pupil_data=None, eye=None, lowpass_cutoff_freq=None)

Run REMoDNaV for one eye stream and return event tables.

Parameters:

Name Type Description Default
gazex_data array - like

Horizontal gaze coordinates.

required
gazey_data array - like

Vertical gaze coordinates.

required
sample_rate float

Sampling rate in hertz.

required
min_pursuit_dur float

Minimum pursuit duration in seconds.

10.0
max_pso_dur float

Maximum post-saccadic oscillation duration in seconds.

0.0
min_fix_dur float

Minimum fixation duration in seconds.

0.05
min_saccade_duration float

Minimum saccade duration in seconds.

0.04
sac_max_vel float

Maximum retained saccade peak velocity in degrees per second.

1000.0
fix_max_amp float

Maximum retained fixation amplitude in degrees.

1.5
sac_time_thresh float

Temporal tolerance in seconds for fixation-saccade adjacency.

0.002
drop_fix_from_blink bool

Whether to retain only fixations adjacent to a detected saccade.

True
screen_size float

Physical screen width in centimetres.

38.0
screen_width int

Screen width in pixels.

1920
screen_distance float

Viewing distance in centimetres.

60.0
calib_index object

Calibration-block identifier copied to event rows.

0
savgol_length float

Savitzky-Golay filter window length in seconds.

0.19
eyes_recorded object

Source eye-recording label copied to event rows.

None
starting_time float

Recording-time offset in milliseconds.

None
times array - like

Per-sample times in seconds relative to starting_time.

None
pupil_data array - like

Pupil measurements aligned with the gaze arrays.

None
eye object

Eye label copied to event rows.

None
lowpass_cutoff_freq float

Low-pass cutoff in hertz. It must be below Nyquist.

None

Returns:

Name Type Description
fixations DataFrame - like

Detected fixation events, using the input dataframe library.

saccades DataFrame - like

Detected saccade events, using the input dataframe library.

Raises:

Type Description
ValueError

If configuration values are invalid or the sample arrays have inconsistent lengths.

Source code in pyxations/methods/eyemovement/remodnav_detector.py
def run_eye_movement(
    self,
    gazex_data: Any,
    gazey_data: Any,
    sample_rate: float,
    min_pursuit_dur: float = 10.0,
    max_pso_dur: float = 0.0,
    min_fix_dur: float = 0.05,
    min_saccade_duration: float = 0.04,
    sac_max_vel: float = 1000.0,
    fix_max_amp: float = 1.5,
    sac_time_thresh: float = 0.002,
    drop_fix_from_blink: bool = True,
    screen_size: float = 38.0,
    screen_width: int = 1920,
    screen_distance: float = 60.0,
    calib_index: Any = 0,
    savgol_length: float = 0.19,
    eyes_recorded: Any = None,
    starting_time: float | None = None,
    times: Any = None,
    pupil_data: Any = None,
    eye: Any = None,
    lowpass_cutoff_freq: float | None = None,
) -> tuple[Any, Any]:
    """Run REMoDNaV for one eye stream and return event tables.

    Parameters
    ----------
    gazex_data : array-like
        Horizontal gaze coordinates.
    gazey_data : array-like
        Vertical gaze coordinates.
    sample_rate : float
        Sampling rate in hertz.
    min_pursuit_dur : float, default 10.0
        Minimum pursuit duration in seconds.
    max_pso_dur : float, default 0.0
        Maximum post-saccadic oscillation duration in seconds.
    min_fix_dur : float, default 0.05
        Minimum fixation duration in seconds.
    min_saccade_duration : float, default 0.04
        Minimum saccade duration in seconds.
    sac_max_vel : float, default 1000.0
        Maximum retained saccade peak velocity in degrees per second.
    fix_max_amp : float, default 1.5
        Maximum retained fixation amplitude in degrees.
    sac_time_thresh : float, default 0.002
        Temporal tolerance in seconds for fixation-saccade adjacency.
    drop_fix_from_blink : bool, default True
        Whether to retain only fixations adjacent to a detected saccade.
    screen_size : float, default 38.0
        Physical screen width in centimetres.
    screen_width : int, default 1920
        Screen width in pixels.
    screen_distance : float, default 60.0
        Viewing distance in centimetres.
    calib_index : object, default 0
        Calibration-block identifier copied to event rows.
    savgol_length : float, default 0.19
        Savitzky-Golay filter window length in seconds.
    eyes_recorded : object, optional
        Source eye-recording label copied to event rows.
    starting_time : float, optional
        Recording-time offset in milliseconds.
    times : array-like, optional
        Per-sample times in seconds relative to ``starting_time``.
    pupil_data : array-like, optional
        Pupil measurements aligned with the gaze arrays.
    eye : object, optional
        Eye label copied to event rows.
    lowpass_cutoff_freq : float, optional
        Low-pass cutoff in hertz. It must be below Nyquist.

    Returns
    -------
    fixations : DataFrame-like
        Detected fixation events, using the input dataframe library.
    saccades : DataFrame-like
        Detected saccade events, using the input dataframe library.

    Raises
    ------
    ValueError
        If configuration values are invalid or the sample arrays have
        inconsistent lengths.
    """
    if sample_rate <= 0:
        raise ValueError("sample_rate must be greater than zero.")
    if screen_size <= 0 or screen_width <= 0 or screen_distance <= 0:
        raise ValueError(
            "Screen size, width, and viewing distance must be positive."
        )

    gaze_x = np.asarray(gazex_data, dtype=float).reshape(-1)
    gaze_y = np.asarray(gazey_data, dtype=float).reshape(-1)
    if gaze_x.size != gaze_y.size:
        raise ValueError("gazex_data and gazey_data must have equal lengths.")

    if times is None:
        sample_times = np.arange(gaze_x.size, dtype=float) / float(sample_rate)
    else:
        sample_times = np.asarray(times, dtype=float).reshape(-1)
    if sample_times.size != gaze_x.size:
        raise ValueError("times must have the same length as the gaze arrays.")

    if pupil_data is None:
        pupil = np.full(gaze_x.size, np.nan, dtype=float)
    else:
        pupil = np.asarray(pupil_data, dtype=float).reshape(-1)
    if pupil.size != gaze_x.size:
        raise ValueError("pupil_data must have the same length as the gaze arrays.")

    time_offset_ms = 0.0 if starting_time is None else float(starting_time)
    eye_data = np.rec.fromarrays((gaze_x, gaze_y), names=("x", "y"))
    px2deg = math.degrees(math.atan2(0.5 * screen_size, screen_distance)) / (
        0.5 * screen_width
    )

    if lowpass_cutoff_freq is None:
        resolved_lowpass_cutoff = min(4.0, sample_rate * 0.4)
    else:
        resolved_lowpass_cutoff = float(lowpass_cutoff_freq)
    if (
        not np.isfinite(resolved_lowpass_cutoff)
        or resolved_lowpass_cutoff <= 0
        or resolved_lowpass_cutoff >= sample_rate / 2.0
    ):
        raise ValueError(
            "lowpass_cutoff_freq must be finite, greater than zero, and "
            "below the Nyquist frequency (sample_rate / 2)."
        )

    logger.info("Running REMoDNaV detection for %s eye", eye)
    classifier = EyegazeClassifier(
        px2deg=px2deg,
        sampling_rate=sample_rate,
        min_pursuit_duration=min_pursuit_dur,
        max_pso_duration=max_pso_dur,
        min_fixation_duration=min_fix_dur,
        min_saccade_duration=min_saccade_duration,
        lowpass_cutoff_freq=resolved_lowpass_cutoff,
    )
    preprocessed = classifier.preproc(eye_data, savgol_length=savgol_length)
    events = _normalise_remodnav_events(
        classifier(preprocessed, classify_isp=True, sort_events=True)
    )

    finite_times = sample_times[np.isfinite(sample_times)]
    if finite_times.size:
        recording_start = float(np.min(finite_times))
        recording_end = float(np.max(finite_times))
        bounded_events = []
        for event in events:
            bounded = dict(event)
            bounded["start_time"] = float(
                np.clip(bounded["start_time"], recording_start, recording_end)
            )
            bounded["end_time"] = float(
                np.clip(bounded["end_time"], recording_start, recording_end)
            )
            if bounded["end_time"] >= bounded["start_time"]:
                bounded_events.append(bounded)
        events = bounded_events

    fixation_events = [event for event in events if event["label"] == "FIXA"]
    saccade_events = [
        event for event in events if event["label"] in {"SACC", "ISAC"}
    ]

    filtered_fixations = [
        event for event in fixation_events if event["amp"] <= fix_max_amp
    ]
    filtered_saccades = [
        event for event in saccade_events if event["peak_vel"] <= sac_max_vel
    ]
    # Preserve the former start-x ordering before the optional adjacency filter.
    filtered_fixations.sort(key=lambda event: event["start_x"])
    filtered_saccades.sort(key=lambda event: event["start_x"])

    logger.info(
        "Kept %d/%d fixations and %d/%d saccades after amplitude/velocity filtering",
        len(filtered_fixations),
        len(fixation_events),
        len(filtered_saccades),
        len(saccade_events),
    )

    if drop_fix_from_blink and filtered_fixations:
        saccade_ends = np.asarray(
            [event["end_time"] for event in filtered_saccades], dtype=float
        )
        filtered_fixations = [
            fixation
            for fixation in filtered_fixations
            if saccade_ends.size
            and np.any(
                (saccade_ends > fixation["start_time"] - sac_time_thresh)
                & (saccade_ends < fixation["start_time"] + sac_time_thresh)
            )
        ]

    fixation_rows: list[dict[str, Any]] = []
    for event in filtered_fixations:
        within = (sample_times > event["start_time"]) & (
            sample_times < event["end_time"]
        )
        row = self._event_row(
            event,
            time_offset_ms=time_offset_ms,
            sample_rate=sample_rate,
            calib_index=calib_index,
            eyes_recorded=eyes_recorded,
            eye=eye,
        )
        row.update(
            xAvg=_nanmean_or_nan(gaze_x[within]),
            yAvg=_nanmean_or_nan(gaze_y[within]),
            pupilAvg=_nanmean_or_nan(pupil[within]),
        )
        fixation_rows.append(row)

    saccade_rows = [
        self._event_row(
            event,
            time_offset_ms=time_offset_ms,
            sample_rate=sample_rate,
            calib_index=calib_index,
            eyes_recorded=eyes_recorded,
            eye=eye,
        )
        for event in filtered_saccades
    ]

    return (
        _make_frame(self.samples, fixation_rows, _FIXATION_OUTPUT_COLUMNS),
        _make_frame(self.samples, saccade_rows, _SACCADE_OUTPUT_COLUMNS),
    )

run_eye_movement_from_samples(sample_rate, x_label='X', y_label='Y', config=None, **kwargs)

Run REMoDNaV on two columns in self.samples.

Missing pupil measurements remain missing (NaN); they are no longer represented by synthetic zeros.

Parameters:

Name Type Description Default
sample_rate float

Sampling rate in hertz.

required
x_label str

Name of the horizontal gaze-coordinate column.

"X"
y_label str

Name of the vertical gaze-coordinate column.

"Y"
config mapping

Detector options forwarded to :meth:run_eye_movement.

None
**kwargs object

Additional detector options forwarded to :meth:run_eye_movement. These override neither duplicate entries nor Python's duplicate-key checks.

{}

Returns:

Name Type Description
fixations DataFrame - like

Detected fixation events, using the input dataframe library.

saccades DataFrame - like

Detected saccade events, using the input dataframe library.

Raises:

Type Description
ValueError

If the rate is not positive or the gaze and timestamp columns differ in length.

Source code in pyxations/methods/eyemovement/remodnav_detector.py
def run_eye_movement_from_samples(
    self,
    sample_rate: float,
    x_label: str = "X",
    y_label: str = "Y",
    config: Mapping[str, Any] | None = None,
    **kwargs: Any,
) -> tuple[Any, Any]:
    """Run REMoDNaV on two columns in ``self.samples``.

    Missing pupil measurements remain missing (NaN); they are no longer
    represented by synthetic zeros.

    Parameters
    ----------
    sample_rate : float
        Sampling rate in hertz.
    x_label : str, default "X"
        Name of the horizontal gaze-coordinate column.
    y_label : str, default "Y"
        Name of the vertical gaze-coordinate column.
    config : mapping, optional
        Detector options forwarded to :meth:`run_eye_movement`.
    **kwargs : object
        Additional detector options forwarded to
        :meth:`run_eye_movement`. These override neither duplicate
        entries nor Python's duplicate-key checks.

    Returns
    -------
    fixations : DataFrame-like
        Detected fixation events, using the input dataframe library.
    saccades : DataFrame-like
        Detected saccade events, using the input dataframe library.

    Raises
    ------
    ValueError
        If the rate is not positive or the gaze and timestamp columns
        differ in length.
    """
    if sample_rate <= 0:
        raise ValueError("sample_rate must be greater than zero.")

    options = dict(config or {})
    gazex_data = _column_to_numpy(self.samples, x_label, dtype=float)
    gazey_data = _column_to_numpy(self.samples, y_label, dtype=float)
    timestamps = _column_to_numpy(self.samples, "tSample", dtype=float)
    if not (gazex_data.size == gazey_data.size == timestamps.size):
        raise ValueError("Gaze coordinates and timestamps must have equal lengths.")

    starting_time = float(np.nanmin(timestamps))
    pupil_data = options.pop("pupil_data", None)
    if pupil_data is None:
        pupil_data = np.full(gazex_data.size, np.nan, dtype=float)

    times = (timestamps - starting_time) / 1_000.0
    return self.run_eye_movement(
        gazex_data,
        gazey_data,
        sample_rate,
        times=times,
        starting_time=starting_time,
        pupil_data=pupil_data,
        **options,
        **kwargs,
    )