diff --git a/README.md b/README.md index 7e0a51d..c5e6959 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The Eyelink Data Format (EDF; not to be confused with the [European Data Format] Dependencies ============ -Strictly speaking, EyeLinkIO only requires Numpy, and that the user has the [EyeLink Developers Kit]() installed on their machine (One must create a login on the forum to access the download). We also plan to create helper functions for converting data to pandas `DataFrames` or MNE-Python Raw instances, after reading the data in. These functions would require the user to have those packages installed. +Strictly speaking, EyeLinkIO only requires Numpy, and that the user has the [EyeLink Developers Kit]() installed on their machine (One must create a login on the forum to access the download). We also provide helper functions for converting data to pandas `DataFrames` or MNE-Python Raw instances, after reading the data in. These functions require the user to have those respective packages installed. Example Usage ============= @@ -29,7 +29,7 @@ print(edf_file) ``` ``` - + Version: EYELINK II 1 Eye: RIGHT_EYE Pupil unit: PUPIL_AREA @@ -38,6 +38,12 @@ print(edf_file) Length: 66.827 seconds ``` +``` +# Convert to a pandas DataFrame or an MNE Raw instance +dfs = edf_file.to_data_frame() +raw = edf_file.to_mne() +``` + Acknowledgements ================ diff --git a/eyelinkio/io/edf/read_edf.py b/eyelinkio/io/edf/read_edf.py index 46764b2..9887973 100644 --- a/eyelinkio/io/edf/read_edf.py +++ b/eyelinkio/io/edf/read_edf.py @@ -35,7 +35,7 @@ has_edfapi = False why_not = str(exp) -from ...utils import to_data_frame +from ...utils import to_data_frame, to_mne from . import _defines as defines from ._defines import event_constants @@ -106,6 +106,15 @@ def to_data_frame(self): """ return to_data_frame(self) + def to_mne(self): + """Convert an EDF object to an MNE object. + + Returns + ------- + raw : :class:`mne.io.Raw` + """ + return to_mne(self) + class _edf_open: """Context manager for opening EDF files.""" diff --git a/eyelinkio/io/tests/test_edf.py b/eyelinkio/io/tests/test_edf.py index b18d6be..273ecec 100644 --- a/eyelinkio/io/tests/test_edf.py +++ b/eyelinkio/io/tests/test_edf.py @@ -37,3 +37,16 @@ def test_to_data_frame(): assert all(isinstance(df, pd.DataFrame) for df in dfs.values()) np.testing.assert_equal(dfs["blinks"]["eye"].unique(), "LEFT_EYE") assert dfs["messages"]["msg"][0] == "RECCFG CR 1000 2 1 L" + +pytest.importorskip('mne') +def test_to_mne(): + """Test converting EDF to MNE.""" + import mne + + fname = _get_test_fnames()[0] + edf_file = read_edf(fname) + raw = edf_file.to_mne() + assert isinstance(raw, mne.io.RawArray) + assert raw.info["sfreq"] == edf_file["info"]["sfreq"] + tz = raw.info["meas_date"].tzinfo + assert raw.info["meas_date"] == edf_file["info"]["meas_date"].replace(tzinfo=tz) diff --git a/eyelinkio/utils/__init__.py b/eyelinkio/utils/__init__.py index 0d05787..df235f2 100644 --- a/eyelinkio/utils/__init__.py +++ b/eyelinkio/utils/__init__.py @@ -1 +1 @@ -from .utils import to_data_frame \ No newline at end of file +from .utils import to_data_frame, to_mne \ No newline at end of file diff --git a/eyelinkio/utils/check.py b/eyelinkio/utils/check.py index 4a2ae6a..686a3cd 100644 --- a/eyelinkio/utils/check.py +++ b/eyelinkio/utils/check.py @@ -22,7 +22,11 @@ def requires_edfapi(func): def _check_pandas_installed(strict=True): """Aux function.""" - return _soft_import("pandas", "dataframe integration", strict=strict) + return _soft_import("pandas", "converting to dataframes", strict=strict) + +def _check_mne_installed(strict=True): + """Aux function.""" + return _soft_import("mne", "exporting to MNE", strict=strict) def _soft_import(name, purpose, strict=True): diff --git a/eyelinkio/utils/utils.py b/eyelinkio/utils/utils.py index 1e45874..25a318f 100644 --- a/eyelinkio/utils/utils.py +++ b/eyelinkio/utils/utils.py @@ -1,7 +1,9 @@ +from datetime import timedelta, timezone from pathlib import Path +from warnings import warn from ..io.edf import _defines -from .check import _check_pandas_installed +from .check import _check_mne_installed, _check_pandas_installed def _get_test_fnames(): @@ -58,3 +60,54 @@ def _convert_discrete_data(data, field_name): else: df["eye"] = (df["eye"]).map(_defines.eye_constants) return df + + +def to_mne(edf_obj): + """Convert an EDF object to an MNE object. + + Parameters + ---------- + edf_obj : :class:`EDF` + The EDF object to convert to an MNE object. + + Returns + ------- + raw : :class:`mne.io.Raw + """ + mne = _check_mne_installed(strict=True) + + # in mne we need to specify the eye in the ch name, or pick functions will fail + eye = edf_obj["info"]["eye"].split("_")[0].lower() + ch_names = edf_obj["info"]["sample_fields"] + ch_names = [f"{ch}_{eye}" for ch in ch_names] + ch_types = [] + more_info = {} + for ch in ch_names: + if ch.startswith(("xpos", "ypos")): + ch_types.append("eyegaze") + if ch.startswith("x"): + more_info[f"{ch}"] = ("eyegaze", "px", f"{eye}", "x") + elif ch.startswith("y"): + more_info[f"{ch}"] = ("eyegaze", "px", f"{eye}", "y") + elif ch.startswith("ps"): + ch_types.append("pupil") + more_info[f"{ch}"] = ("pupil", "au", f"{eye}") + else: + warn(f"Unknown channel type: {ch}. Setting to misc.") + ch_types.append("misc") + + # Create the info structure + info = mne.create_info(ch_names=ch_names, + sfreq=edf_obj["info"]["sfreq"], + ch_types=ch_types) + # force timezone to UTC + dt = edf_obj["info"]["meas_date"] + tz = timezone(timedelta(hours=0)) + dt = dt.replace(tzinfo=tz) + info.set_meas_date(dt) + + # Create the raw object + raw = mne.io.RawArray(edf_obj["samples"], info) + # This will set the loc array etc. + mne.preprocessing.eyetracking.set_channel_types_eyetrack(raw, more_info) + return raw diff --git a/pyproject.toml b/pyproject.toml index 58837e4..e349af7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ doc = [ "sphinx-design", ] -full = ["pandas"] +full = ["pandas", "mne"] dev = ["eyelinkio[test,doc,full]"] # [project.urls]