Skip to content

Commit 437d79b

Browse files
contsiliKonstantinos Tsilimparispre-commit-ci[bot]larsoner
authored
Axial to planar gradiometer transformation (#13196)
Co-authored-by: Konstantinos Tsilimparis <konstantinos@artinis.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent e1aa5ed commit 437d79b

12 files changed

Lines changed: 1168 additions & 139 deletions

File tree

doc/api/preprocessing.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Projections:
4949
read_dig_localite
5050
make_standard_montage
5151
read_custom_montage
52+
read_meg_canonical_info
5253
transform_to_head
5354
compute_dev_head_t
5455
read_layout
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for :meth:`mne.io.Raw.interpolate_to` and related methods (and :func:`mne.channels.read_meg_canonical_info`) for interpolating MEG data across systems, by :newcontrib:`Konstantinos Tsilimparis`.

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
.. _Katarina Slama: https://github.com/katarinaslama
171171
.. _Katia Al-Amir: https://github.com/katia-sentry
172172
.. _Keith Doelling: https://github.com/kdoelling1919
173+
.. _Konstantinos Tsilimparis: https://contsili.github.io/
173174
.. _Kostiantyn Maksymenko: https://github.com/makkostya
174175
.. _Kristijan Armeni: https://github.com/kristijanarmeni
175176
.. _Kyle Mathewson: https://github.com/kylemath

examples/preprocessing/interpolate_to.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,30 @@
22
.. _ex-interpolate-to-any-montage:
33
44
======================================================
5-
Interpolate EEG data to any montage
5+
Interpolate MEG or EEG data to any montage
66
======================================================
77
8-
This example demonstrates how to interpolate EEG channels to match a given montage.
9-
This can be useful for standardizing
8+
This example demonstrates both EEG montage interpolation and MEG system
9+
transformation.
10+
11+
For EEG, this can be useful for standardizing
1012
EEG channel layouts across different datasets (see :footcite:`MellotEtAl2024`).
1113
1214
- Using the field interpolation for EEG data.
1315
- Using the target montage "biosemi16".
16+
- Using the MNE interpolation for MEG data to transform from Neuromag
17+
(planar gradiometers and magnetometers) to CTF (axial gradiometers).
18+
1419
15-
In this example, the data from the original EEG channels will be
20+
In the first example, the data from the original EEG channels will be
1621
interpolated onto the positions defined by the "biosemi16" montage.
22+
23+
In the second example, we will interpolate MEG data from a 306-sensor Neuromag
24+
to 275-sensor CTF system.
1725
"""
1826

1927
# Authors: Antoine Collas <contact@antoinecollas.fr>
28+
# Konstantinos Tsilimparis <konstantinos.tsilimparis@outlook.com>
2029
# License: BSD-3-Clause
2130
# Copyright the MNE-Python contributors.
2231

@@ -30,6 +39,9 @@
3039
ylim = (-10, 10)
3140

3241
# %%
42+
# Part 1: EEG System Transformation
43+
# ==================================
44+
3345
# Load EEG data
3446
data_path = sample.data_path()
3547
eeg_file_path = data_path / "MEG" / "sample" / "sample_audvis-ave.fif"
@@ -75,6 +87,61 @@
7587
)
7688
axs[2].set_title("Interpolated to Standard 1020 Montage using MNE interpolation")
7789

90+
# %%
91+
# Part 2: MEG System Transformation
92+
# ==================================
93+
# We demonstrate transforming MEG data from Neuromag (planar gradiometers
94+
# and magnetometers) to CTF (axial gradiometers) sensor configuration.
95+
96+
# Load the full evoked data with MEG channels
97+
evoked_meg = mne.read_evokeds(
98+
eeg_file_path, condition="Left Auditory", baseline=(None, 0)
99+
)
100+
evoked_meg.pick("meg")
101+
102+
print("Original Neuromag system:")
103+
print(f" Number of magnetometers: {len(mne.pick_types(evoked_meg.info, meg='mag'))}")
104+
print(f" Number of gradiometers: {len(mne.pick_types(evoked_meg.info, meg='grad'))}")
105+
106+
# %%
107+
# Transform to CTF sensor configuration
108+
# ======================================
109+
110+
# Interpolate Neuromag to CTF
111+
evoked_ctf = evoked_meg.copy().interpolate_to("ctf275", mode="accurate")
112+
113+
print("\nTransformed to CTF system:")
114+
print(f" Number of MEG channels: {len(mne.pick_types(evoked_ctf.info, meg=True))}")
115+
print(f" Bad channels in original: {evoked_meg.info['bads']}")
116+
117+
# %%
118+
# Compare evoked responses: Original Neuromag vs Transformed CTF
119+
# The data should be similar but projected onto different sensor arrays
120+
121+
# Set consistent y-limits for comparison
122+
ylim_meg = dict(grad=[-300, 300], mag=[-600, 600], meg=[-300, 300])
123+
124+
fig, axes = plt.subplots(3, 1, figsize=(10, 8), layout="constrained")
125+
126+
# Plot original Neuromag gradiometers
127+
evoked_meg.copy().pick("grad").plot(
128+
axes=axes[0], show=False, spatial_colors=True, ylim=ylim_meg, time_unit="s"
129+
)
130+
axes[0].set_title("Original Neuromag Planar Gradiometers", fontsize=14)
131+
132+
133+
# Plot original Neuromag magnetometers
134+
evoked_meg.copy().pick("mag").plot(
135+
axes=axes[1], show=False, spatial_colors=True, ylim=ylim_meg, time_unit="s"
136+
)
137+
axes[1].set_title("Original Neuromag Magnetometers", fontsize=14)
138+
139+
# Plot transformed CTF gradiometers
140+
evoked_ctf.plot(
141+
axes=axes[2], show=False, spatial_colors=True, ylim=ylim_meg, time_unit="s"
142+
)
143+
axes[2].set_title("Transformed to CTF275 Axial Gradiometers", fontsize=14)
144+
78145
# %%
79146
# References
80147
# ----------

mne/channels/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ __all__ = [
3030
"read_dig_localite",
3131
"read_dig_polhemus_isotrak",
3232
"read_layout",
33+
"read_meg_canonical_info",
3334
"read_polhemus_fastscan",
3435
"read_vectorview_selection",
3536
"rename_channels",
@@ -75,6 +76,7 @@ from .montage import (
7576
read_dig_hpts,
7677
read_dig_localite,
7778
read_dig_polhemus_isotrak,
79+
read_meg_canonical_info,
7880
read_polhemus_fastscan,
7981
transform_to_head,
8082
)

mne/channels/channels.py

Lines changed: 76 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
pick_info,
4242
pick_types,
4343
)
44-
from .._fiff.proj import _has_eeg_average_ref_proj, setup_proj
44+
from .._fiff.proj import setup_proj
4545
from .._fiff.reference import add_reference_channels, set_eeg_reference
4646
from .._fiff.tag import _rename_list
4747
from ..bem import _check_origin
@@ -61,6 +61,8 @@
6161
warn,
6262
)
6363

64+
_ALLOWED_INTERPOLATION_MODES = ("accurate", "fast")
65+
6466

6567
def _get_meg_system(info):
6668
"""Educated guess for the helmet type based on channels."""
@@ -968,161 +970,102 @@ def interpolate_bads(
968970

969971
return self
970972

971-
def interpolate_to(self, sensors, origin="auto", method="spline", reg=0.0):
972-
"""Interpolate EEG data onto a new montage.
973+
def interpolate_to(
974+
self, sensors, origin="auto", method=None, mode="accurate", reg=0.0
975+
):
976+
"""Interpolate data onto a new sensor configuration.
973977
974-
.. warning::
975-
Be careful, only EEG channels are interpolated. Other channel types are
976-
not interpolated.
978+
This method can interpolate EEG data onto a new montage or transform
979+
MEG data to a different sensor configuration (e.g., Neuromag to CTF).
977980
978981
Parameters
979982
----------
980-
sensors : DigMontage
981-
The target montage containing channel positions to interpolate onto.
983+
sensors : DigMontage | str
984+
For EEG: A DigMontage object containing target channel positions.
985+
For MEG: A string specifying the target MEG system. Currently
986+
supported: ``'neuromag'``, ``'ctf151'`` or ``'ctf275'``.
982987
origin : array-like, shape (3,) | str
983988
Origin of the sphere in the head coordinate frame and in meters.
984989
Can be ``'auto'`` (default), which means a head-digitization-based
985-
origin fit.
986-
method : str
987-
Method to use for EEG channels.
988-
Supported methods are 'spline' (default) and 'MNE'.
990+
origin fit. Used for both EEG and MEG interpolation.
991+
method : str | None
992+
Interpolation method to use.
993+
For EEG: ``'spline'`` (default, same as None) or ``'MNE'``.
994+
For MEG: ``'MNE'`` (default, same as None).
995+
996+
.. versionchanged:: 1.10.0
997+
Added support for MEG interpolation.
998+
mode : str
999+
Either ``'accurate'`` (default) or ``'fast'``, determines the
1000+
quality of the Legendre polynomial expansion used for
1001+
interpolation of MEG channels using the minimum-norm method.
1002+
Only used for MEG interpolation.
9891003
reg : float
990-
The regularization parameter for the interpolation method
991-
(only used when the method is 'spline').
1004+
The regularization parameter for the interpolation method.
1005+
Only used when ``method='spline'`` for EEG channels.
9921006
9931007
Returns
9941008
-------
9951009
inst : instance of Raw, Epochs, or Evoked
996-
The instance with updated channel locations and data.
1010+
A new instance with interpolated data and updated channel
1011+
information.
9971012
9981013
Notes
9991014
-----
1000-
This method is useful for standardizing EEG layouts across datasets.
1001-
However, some attributes may be lost after interpolation.
1015+
**For EEG data:**
10021016
1003-
.. versionadded:: 1.10.0
1004-
"""
1005-
from ..epochs import BaseEpochs, EpochsArray
1006-
from ..evoked import Evoked, EvokedArray
1007-
from ..forward._field_interpolation import _map_meg_or_eeg_channels
1008-
from ..io import RawArray
1009-
from ..io.base import BaseRaw
1010-
from .interpolation import _make_interpolation_matrix
1011-
from .montage import DigMontage
1017+
This method interpolates EEG channels onto a new montage using
1018+
spherical splines or minimum-norm estimation. Non-EEG channels
1019+
are preserved without modification.
10121020
1013-
# Check that the method option is valid.
1014-
_check_option("method", method, ["spline", "MNE"])
1015-
_validate_type(sensors, DigMontage, "sensors")
1021+
**For MEG data:**
10161022
1017-
# Get target positions from the montage
1018-
ch_pos = sensors.get_positions().get("ch_pos", {})
1019-
target_ch_names = list(ch_pos.keys())
1020-
if not target_ch_names:
1021-
raise ValueError(
1022-
"The provided sensors configuration has no channel positions."
1023-
)
1023+
This method transforms MEG data to a different sensor configuration
1024+
using field interpolation.
10241025
1025-
# Get original channel order
1026-
orig_names = self.info["ch_names"]
1027-
1028-
# Identify EEG channel
1029-
picks_good_eeg = pick_types(self.info, meg=False, eeg=True, exclude="bads")
1030-
if len(picks_good_eeg) == 0:
1031-
raise ValueError("No good EEG channels available for interpolation.")
1032-
# Also get the full list of EEG channel indices (including bad channels)
1033-
picks_remove_eeg = pick_types(self.info, meg=False, eeg=True, exclude=[])
1034-
eeg_names_orig = [orig_names[i] for i in picks_remove_eeg]
1035-
1036-
# Identify non-EEG channels in original order
1037-
non_eeg_names_ordered = [ch for ch in orig_names if ch not in eeg_names_orig]
1038-
1039-
# Create destination info for new EEG channels
1040-
sfreq = self.info["sfreq"]
1041-
info_interp = create_info(
1042-
ch_names=target_ch_names,
1043-
sfreq=sfreq,
1044-
ch_types=["eeg"] * len(target_ch_names),
1045-
)
1046-
info_interp.set_montage(sensors)
1047-
info_interp["bads"] = [ch for ch in self.info["bads"] if ch in target_ch_names]
1048-
# Do not assign "projs" directly.
1049-
1050-
# Compute the interpolation mapping
1051-
if method == "spline":
1052-
origin_val = _check_origin(origin, self.info)
1053-
pos_from = self.info._get_channel_positions(picks_good_eeg) - origin_val
1054-
pos_to = np.stack(list(ch_pos.values()), axis=0)
1055-
1056-
def _check_pos_sphere(pos):
1057-
d = np.linalg.norm(pos, axis=-1)
1058-
d_norm = np.mean(d / np.mean(d))
1059-
if np.abs(1.0 - d_norm) > 0.1:
1060-
warn("Your spherical fit is poor; interpolation may be inaccurate.")
1061-
1062-
_check_pos_sphere(pos_from)
1063-
_check_pos_sphere(pos_to)
1064-
mapping = _make_interpolation_matrix(pos_from, pos_to, alpha=reg)
1026+
Common use cases for MEG transformation:
10651027
1066-
else:
1067-
assert method == "MNE"
1068-
info_eeg = pick_info(self.info, picks_good_eeg)
1069-
# If the original info has an average EEG reference projector but
1070-
# the destination info does not,
1071-
# update info_interp via a temporary RawArray.
1072-
if _has_eeg_average_ref_proj(self.info) and not _has_eeg_average_ref_proj(
1073-
info_interp
1074-
):
1075-
# Create dummy data: shape (n_channels, 1)
1076-
temp_data = np.zeros((len(info_interp["ch_names"]), 1))
1077-
temp_raw = RawArray(temp_data, info_interp, first_samp=0)
1078-
# Using the public API, add an average reference projector.
1079-
temp_raw.set_eeg_reference(
1080-
ref_channels="average", projection=True, verbose=False
1081-
)
1082-
# Extract the updated info.
1083-
info_interp = temp_raw.info
1084-
mapping = _map_meg_or_eeg_channels(
1085-
info_eeg, info_interp, mode="accurate", origin=origin
1086-
)
1028+
- Transform Neuromag data to CTF sensor layout for comparison
1029+
- Transform CTF data to Neuromag sensor layout
1030+
- Simulate what data would look like on a different MEG system
10871031
1088-
# Interpolate EEG data
1089-
data_good = self.get_data(picks=picks_good_eeg)
1090-
data_interp = mapping @ data_good
1032+
.. warning::
1033+
MEG field interpolation assumes that the head position relative
1034+
to the sensors is similar between systems. Large differences in
1035+
head position may affect interpolation accuracy.
10911036
1092-
# Create a new instance for the interpolated EEG channels
1093-
# TODO: Creating a new instance leads to a loss of information.
1094-
# We should consider updating the existing instance in the future
1095-
# by 1) drop channels, 2) add channels, 3) re-order channels.
1096-
if isinstance(self, BaseRaw):
1097-
inst_interp = RawArray(data_interp, info_interp, first_samp=self.first_samp)
1098-
elif isinstance(self, BaseEpochs):
1099-
inst_interp = EpochsArray(data_interp, info_interp)
1100-
else:
1101-
assert isinstance(self, Evoked)
1102-
inst_interp = EvokedArray(data_interp, info_interp)
1103-
1104-
# Merge only if non-EEG channels exist
1105-
if not non_eeg_names_ordered:
1106-
return inst_interp
1107-
1108-
inst_non_eeg = self.copy().pick(non_eeg_names_ordered).load_data()
1109-
inst_out = inst_non_eeg.add_channels([inst_interp], force_update_info=True)
1110-
1111-
# Reorder channels
1112-
# Insert the entire new EEG block at the position of the first EEG channel.
1113-
orig_names_arr = np.array(orig_names)
1114-
mask_eeg = np.isin(orig_names_arr, eeg_names_orig)
1115-
if mask_eeg.any():
1116-
first_eeg_index = np.where(mask_eeg)[0][0]
1117-
pre = orig_names_arr[:first_eeg_index]
1118-
new_eeg = np.array(info_interp["ch_names"])
1119-
post = orig_names_arr[first_eeg_index:]
1120-
post = post[~np.isin(orig_names_arr[first_eeg_index:], eeg_names_orig)]
1121-
new_order = np.concatenate((pre, new_eeg, post)).tolist()
1037+
.. versionadded:: 1.10.0
1038+
.. versionchanged:: 1.12.0
1039+
Added support for MEG interpolation to canonical systems.
1040+
"""
1041+
from .interpolation import _interpolate_to_eeg, _interpolate_to_meg
1042+
from .montage import DigMontage
1043+
1044+
_check_preload(self, "interpolation")
1045+
1046+
# Determine if we're doing EEG or MEG interpolation
1047+
_validate_type(sensors, (str, DigMontage), "sensors")
1048+
_validate_type(method, (str, None), "method")
1049+
is_meg_interpolation = isinstance(sensors, str)
1050+
is_eeg_interpolation = isinstance(sensors, DigMontage)
1051+
1052+
if is_eeg_interpolation:
1053+
valid_methods = ["spline", "MNE"]
1054+
func = partial(_interpolate_to_eeg, method=method, reg=reg)
1055+
kind = "eeg"
11221056
else:
1123-
new_order = orig_names
1124-
inst_out.reorder_channels(new_order)
1125-
return inst_out
1057+
assert is_meg_interpolation
1058+
valid_methods = ["MNE"]
1059+
_check_option("sensors", sensors, ["neuromag", "ctf151", "ctf275"])
1060+
func = partial(_interpolate_to_meg, mode=mode)
1061+
kind = "meg"
1062+
1063+
if method is None:
1064+
method = _handle_default("interpolation_method")[kind]
1065+
_check_option("mode", mode, _ALLOWED_INTERPOLATION_MODES)
1066+
extra = f"when doing {kind.upper()} interpolation"
1067+
_check_option("method", method, valid_methods, extra=extra)
1068+
return func(self, sensors, origin)
11261069

11271070

11281071
@verbose

0 commit comments

Comments
 (0)