Skip to content

Commit 035eff1

Browse files
larsonersnwnde
authored andcommitted
ENH: Add MEG sensor option to coreg (mne-tools#12098)
1 parent 373148b commit 035eff1

File tree

4 files changed

+112
-44
lines changed

4 files changed

+112
-44
lines changed

doc/changes/devel.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Enhancements
3737
- Add :class:`~mne.time_frequency.EpochsSpectrumArray` and :class:`~mne.time_frequency.SpectrumArray` to support creating power spectra from :class:`NumPy array <numpy.ndarray>` data (:gh:`11803` by `Alex Rockhill`_)
3838
- Add support for writing forward solutions to HDF5 and convenience function :meth:`mne.Forward.save` (:gh:`12036` by `Eric Larson`_)
3939
- Refactored internals of :func:`mne.read_annotations` (:gh:`11964` by `Paul Roujansky`_)
40+
- Add support for drawing MEG sensors in :ref:`mne coreg` (:gh:`12098` by `Eric Larson`_)
4041
- By default MNE-Python creates matplotlib figures with ``layout='constrained'`` rather than the default ``layout='tight'`` (:gh:`12050` by `Mathieu Scheltienne`_ and `Eric Larson`_)
4142
- Enhance :func:`~mne.viz.plot_evoked_field` with a GUI that has controls for time, colormap, and contour lines (:gh:`11942` by `Marijn van Vliet`_)
4243
- Add :class:`mne.viz.ui_events.UIEvent` linking for interactive colorbars, allowing users to link figures and change the colormap and limits interactively. This supports :func:`~mne.viz.plot_evoked_topomap`, :func:`~mne.viz.plot_ica_components`, :func:`~mne.viz.plot_tfr_topomap`, :func:`~mne.viz.plot_projs_topomap`, :meth:`~mne.Evoked.plot_image`, and :meth:`~mne.Epochs.plot_image` (:gh:`12057` by `Santeri Ruuskanen`_)
@@ -60,6 +61,7 @@ Bugs
6061
- Fix bug with :meth:`~mne.viz.Brain.add_annotation` when reading an annotation from a file with both hemispheres shown (:gh:`11946` by `Marijn van Vliet`_)
6162
- Fix bug with axis clip box boundaries in :func:`mne.viz.plot_evoked_topo` and related functions (:gh:`11999` by `Eric Larson`_)
6263
- Fix bug with ``subject_info`` when loading data from and exporting to EDF file (:gh:`11952` by `Paul Roujansky`_)
64+
- Fix rendering glitches when plotting Neuromag/TRIUX sensors in :func:`mne.viz.plot_alignment` and related functions (:gh:`12098` by `Eric Larson`_)
6365
- Fix bug with delayed checking of :class:`info["bads"] <mne.Info>` (:gh:`12038` by `Eric Larson`_)
6466
- Fix bug with :func:`mne.viz.plot_alignment` where ``sensor_colors`` were not handled properly on a per-channel-type basis (:gh:`12067` by `Eric Larson`_)
6567
- Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` :gh:`12044` by `Paul Roujansky`_)

mne/gui/_coreg.py

Lines changed: 98 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ class CoregistrationUI(HasTraits):
102102
If True, display the head shape points. Defaults to True.
103103
eeg_channels : bool
104104
If True, display the EEG channels. Defaults to True.
105+
meg_channels : bool
106+
If True, display the MEG channels. Defaults to False.
107+
fnirs_channels : bool
108+
If True, display the fNIRS channels. Defaults to True.
105109
orient_glyphs : bool
106110
If True, orient the sensors towards the head surface. Default to False.
107111
scale_by_distance : bool
@@ -153,6 +157,8 @@ class CoregistrationUI(HasTraits):
153157
_hpi_coils = Bool()
154158
_head_shape_points = Bool()
155159
_eeg_channels = Bool()
160+
_meg_channels = Bool()
161+
_fnirs_channels = Bool()
156162
_head_resolution = Bool()
157163
_head_opacity = Float()
158164
_helmet = Bool()
@@ -177,6 +183,8 @@ def __init__(
177183
hpi_coils=None,
178184
head_shape_points=None,
179185
eeg_channels=None,
186+
meg_channels=None,
187+
fnirs_channels=None,
180188
orient_glyphs=None,
181189
scale_by_distance=None,
182190
mark_inside=None,
@@ -231,6 +239,8 @@ def _get_default(var, val):
231239
hpi_coils=_get_default(hpi_coils, True),
232240
head_shape_points=_get_default(head_shape_points, True),
233241
eeg_channels=_get_default(eeg_channels, True),
242+
meg_channels=_get_default(meg_channels, False),
243+
fnirs_channels=_get_default(fnirs_channels, True),
234244
head_resolution=_get_default(head_resolution, True),
235245
head_opacity=_get_default(head_opacity, 0.8),
236246
helmet=False,
@@ -303,6 +313,8 @@ def _get_default(var, val):
303313
self._set_hpi_coils(self._defaults["hpi_coils"])
304314
self._set_head_shape_points(self._defaults["head_shape_points"])
305315
self._set_eeg_channels(self._defaults["eeg_channels"])
316+
self._set_meg_channels(self._defaults["meg_channels"])
317+
self._set_fnirs_channels(self._defaults["fnirs_channels"])
306318
self._set_head_resolution(self._defaults["head_resolution"])
307319
self._set_helmet(self._defaults["helmet"])
308320
self._set_grow_hair(self._defaults["grow_hair"])
@@ -351,7 +363,7 @@ def _get_default(var, val):
351363
True: dict(azimuth=90, elevation=90), # front
352364
False: dict(azimuth=180, elevation=90),
353365
} # left
354-
self._renderer.set_camera(distance=None, **views[self._lock_fids])
366+
self._renderer.set_camera(distance="auto", **views[self._lock_fids])
355367
self._redraw()
356368
# XXX: internal plotter/renderer should not be exposed
357369
if not self._immediate_redraw:
@@ -482,6 +494,12 @@ def _set_head_shape_points(self, state):
482494
def _set_eeg_channels(self, state):
483495
self._eeg_channels = bool(state)
484496

497+
def _set_meg_channels(self, state):
498+
self._meg_channels = bool(state)
499+
500+
def _set_fnirs_channels(self, state):
501+
self._fnirs_channels = bool(state)
502+
485503
def _set_head_resolution(self, state):
486504
self._head_resolution = bool(state)
487505

@@ -567,6 +585,8 @@ def _set_point_weight(self, weight, point):
567585
"hpi": "_set_hpi_coils",
568586
"hsp": "_set_head_shape_points",
569587
"eeg": "_set_eeg_channels",
588+
"meg": "_set_meg_channels",
589+
"fnirs": "_set_fnirs_channels",
570590
}
571591
if point in funcs.keys():
572592
getattr(self, funcs[point])(weight > 0)
@@ -611,6 +631,7 @@ def _lock_fids_changed(self, change=None):
611631
"save_mri_fids",
612632
# View options
613633
"helmet",
634+
"meg",
614635
"head_opacity",
615636
"high_res_head",
616637
# Digitization source
@@ -704,11 +725,11 @@ def _info_file_changed(self, change=None):
704725

705726
@observe("_orient_glyphs")
706727
def _orient_glyphs_changed(self, change=None):
707-
self._update_plot(["hpi", "hsp", "eeg"])
728+
self._update_plot(["hpi", "hsp", "sensors"])
708729

709730
@observe("_scale_by_distance")
710731
def _scale_by_distance_changed(self, change=None):
711-
self._update_plot(["hpi", "hsp", "eeg"])
732+
self._update_plot(["hpi", "hsp", "sensors"])
712733

713734
@observe("_mark_inside")
714735
def _mark_inside_changed(self, change=None):
@@ -724,7 +745,15 @@ def _head_shape_point_changed(self, change=None):
724745

725746
@observe("_eeg_channels")
726747
def _eeg_channels_changed(self, change=None):
727-
self._update_plot("eeg")
748+
self._update_plot("sensors")
749+
750+
@observe("_meg_channels")
751+
def _meg_channels_changed(self, change=None):
752+
self._update_plot("sensors")
753+
754+
@observe("_fnirs_channels")
755+
def _fnirs_channels_changed(self, change=None):
756+
self._update_plot("sensors")
728757

729758
@observe("_head_resolution")
730759
def _head_resolution_changed(self, change=None):
@@ -825,6 +854,7 @@ def _configure_legend(self):
825854
mri_fids_legend_actor = self._renderer.legend(labels=labels)
826855
self._update_actor("mri_fids_legend", mri_fids_legend_actor)
827856

857+
@safe_event
828858
@verbose
829859
def _redraw(self, *, verbose=None):
830860
if not self._redraws_pending:
@@ -834,7 +864,7 @@ def _redraw(self, *, verbose=None):
834864
mri_fids=self._add_mri_fiducials,
835865
hsp=self._add_head_shape_points,
836866
hpi=self._add_hpi_coils,
837-
eeg=self._add_eeg_fnirs_channels,
867+
sensors=self._add_channels,
838868
head_fids=self._add_head_fiducials,
839869
helmet=self._add_helmet,
840870
)
@@ -957,7 +987,7 @@ def _update_plot(self, changes="all", verbose=None):
957987
"mri_fids", # MRI first
958988
"hsp",
959989
"hpi",
960-
"eeg",
990+
"sensors",
961991
"head_fids", # then dig
962992
"helmet",
963993
)
@@ -1041,7 +1071,7 @@ def _follow_fiducial_view(self):
10411071
kwargs = dict(front=(90.0, 90.0), left=(180, 90), right=(0.0, 90))
10421072
kwargs = dict(zip(("azimuth", "elevation"), kwargs[view[fid]]))
10431073
if not self._lock_fids:
1044-
self._renderer.set_camera(distance=None, **kwargs)
1074+
self._renderer.set_camera(distance="auto", **kwargs)
10451075

10461076
def _update_fiducials(self):
10471077
fid = self._current_fiducial
@@ -1145,7 +1175,13 @@ def _forward_widget_command(
11451175
return ret
11461176

11471177
def _set_sensors_visibility(self, state):
1148-
sensors = ["head_fiducials", "hpi_coils", "head_shape_points", "eeg_channels"]
1178+
sensors = [
1179+
"head_fiducials",
1180+
"hpi_coils",
1181+
"head_shape_points",
1182+
"sensors",
1183+
"helmet",
1184+
]
11491185
for sensor in sensors:
11501186
if sensor in self._actors and self._actors[sensor] is not None:
11511187
actors = self._actors[sensor]
@@ -1156,7 +1192,13 @@ def _set_sensors_visibility(self, state):
11561192

11571193
def _update_actor(self, actor_name, actor):
11581194
# XXX: internal plotter/renderer should not be exposed
1159-
self._renderer.plotter.remove_actor(self._actors.get(actor_name), render=False)
1195+
# Work around PyVista sequential update bug with iterable until > 0.42.3 is req
1196+
# https://github.com/pyvista/pyvista/pull/5046
1197+
actors = self._actors.get(actor_name) or [] # convert None to list
1198+
if not isinstance(actors, list):
1199+
actors = [actors]
1200+
for this_actor in actors:
1201+
self._renderer.plotter.remove_actor(this_actor, render=False)
11601202
self._actors[actor_name] = actor
11611203

11621204
def _add_mri_fiducials(self):
@@ -1216,35 +1258,44 @@ def _add_head_shape_points(self):
12161258
hsp_actors = None
12171259
self._update_actor("head_shape_points", hsp_actors)
12181260

1219-
def _add_eeg_fnirs_channels(self):
1261+
def _add_channels(self):
1262+
plot_types = dict(eeg=False, meg=False, fnirs=False)
12201263
if self._eeg_channels:
1221-
eeg = ["original"]
1222-
picks = pick_types(self._info, eeg=(len(eeg) > 0), fnirs=True)
1223-
if len(picks) > 0:
1224-
actors = _plot_sensors(
1225-
self._renderer,
1226-
self._info,
1227-
self._to_cf_t,
1228-
picks,
1229-
meg=False,
1230-
eeg=eeg,
1231-
fnirs=["sources", "detectors"],
1232-
warn_meg=False,
1233-
head_surf=self._head_geo,
1234-
units="m",
1235-
sensor_opacity=self._defaults["sensor_opacity"],
1236-
orient_glyphs=self._orient_glyphs,
1237-
scale_by_distance=self._scale_by_distance,
1238-
surf=self._head_geo,
1239-
check_inside=self._check_inside,
1240-
nearest=self._nearest,
1241-
)
1242-
sens_actors = sum(actors.values(), list())
1243-
else:
1244-
sens_actors = None
1245-
else:
1246-
sens_actors = None
1247-
self._update_actor("eeg_channels", sens_actors)
1264+
plot_types["eeg"] = ["original"]
1265+
if self._meg_channels:
1266+
plot_types["meg"] = ["sensors"]
1267+
if self._fnirs_channels:
1268+
plot_types["fnirs"] = ["sources", "detectors"]
1269+
sens_actors = list()
1270+
# until opacity can be specified using a dict, we need to iterate
1271+
sensor_opacity = dict(
1272+
eeg=self._defaults["sensor_opacity"],
1273+
fnirs=self._defaults["sensor_opacity"],
1274+
meg=0.25,
1275+
)
1276+
for ch_type, plot_type in plot_types.items():
1277+
picks = pick_types(self._info, ref_meg=False, **{ch_type: True})
1278+
if not (len(picks) and plot_type):
1279+
continue
1280+
logger.debug(f"Drawing {ch_type} sensors")
1281+
these_actors = _plot_sensors(
1282+
self._renderer,
1283+
self._info,
1284+
self._to_cf_t,
1285+
picks=picks,
1286+
warn_meg=False,
1287+
head_surf=self._head_geo,
1288+
units="m",
1289+
sensor_opacity=sensor_opacity[ch_type],
1290+
orient_glyphs=self._orient_glyphs,
1291+
scale_by_distance=self._scale_by_distance,
1292+
surf=self._head_geo,
1293+
check_inside=self._check_inside,
1294+
nearest=self._nearest,
1295+
**plot_types,
1296+
)
1297+
sens_actors.extend(sum(these_actors.values(), list()))
1298+
self._update_actor("sensors", sens_actors)
12481299

12491300
def _add_head_surface(self):
12501301
bem = None
@@ -1335,7 +1386,7 @@ def _fits_icp(self):
13351386
def _fit_icp_real(self, *, update_head):
13361387
with self._lock(params=True, fitting=True):
13371388
self._current_icp_iterations = 0
1338-
updates = ["hsp", "hpi", "eeg", "head_fids", "helmet"]
1389+
updates = ["hsp", "hpi", "sensors", "head_fids", "helmet"]
13391390
if update_head:
13401391
updates.insert(0, "head")
13411392

@@ -1533,7 +1584,7 @@ def _configure_dock(self):
15331584
collapse = True # collapsible and collapsed
15341585
else:
15351586
collapse = None # not collapsible
1536-
self._renderer._dock_initialize(name="Input", area="left", max_width="350px")
1587+
self._renderer._dock_initialize(name="Input", area="left", max_width="375px")
15371588
mri_subject_layout = self._renderer._dock_add_group_box(
15381589
name="MRI Subject",
15391590
collapse=collapse,
@@ -1706,6 +1757,13 @@ def _configure_dock(self):
17061757
tooltip="Enable/Disable MEG helmet",
17071758
layout=view_options_layout,
17081759
)
1760+
self._widgets["meg"] = self._renderer._dock_add_check_box(
1761+
name="Show MEG sensors",
1762+
value=self._helmet,
1763+
callback=self._set_meg_channels,
1764+
tooltip="Enable/Disable MEG sensors",
1765+
layout=view_options_layout,
1766+
)
17091767
self._widgets["high_res_head"] = self._renderer._dock_add_check_box(
17101768
name="Show high-resolution head",
17111769
value=self._head_resolution,
@@ -1725,7 +1783,7 @@ def _configure_dock(self):
17251783
self._renderer._dock_add_stretch()
17261784

17271785
self._renderer._dock_initialize(
1728-
name="Parameters", area="right", max_width="350px"
1786+
name="Parameters", area="right", max_width="375px"
17291787
)
17301788
mri_scaling_layout = self._renderer._dock_add_group_box(
17311789
name="MRI Scaling",

mne/gui/tests/test_coreg.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,12 @@ def test_coreg_gui_pyvista_basic(tmp_path, monkeypatch, renderer_interactive_pyv
252252
coreg._redraw(verbose="debug")
253253
log = log.getvalue()
254254
assert "Drawing helmet" in log
255+
assert not coreg._meg_channels
256+
coreg._set_meg_channels(True)
257+
assert coreg._meg_channels
258+
with catch_logging() as log:
259+
coreg._redraw(verbose="debug")
260+
assert "Drawing meg sensors" in log.getvalue()
255261
assert coreg._orient_glyphs
256262
assert coreg._scale_by_distance
257263
assert coreg._mark_inside

mne/viz/_3d.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,7 +1598,7 @@ def _sensor_shape(coil):
15981598
except ImportError: # scipy < 1.8
15991599
from scipy.spatial.qhull import QhullError
16001600
id_ = coil["type"] & 0xFFFF
1601-
pad = True
1601+
z_value = 0
16021602
# Square figure eight
16031603
if id_ in (
16041604
FIFF.FIFFV_COIL_NM_122,
@@ -1624,6 +1624,8 @@ def _sensor_shape(coil):
16241624
tris = np.concatenate(
16251625
(_make_tris_fan(4), _make_tris_fan(4)[:, ::-1] + 4), axis=0
16261626
)
1627+
# Offset for visibility (using heuristic for sanely named Neuromag coils)
1628+
z_value = 0.001 * (1 + coil["chname"].endswith("2"))
16271629
# Square
16281630
elif id_ in (
16291631
FIFF.FIFFV_COIL_POINT_MAGNETOMETER,
@@ -1694,11 +1696,11 @@ def _sensor_shape(coil):
16941696
rr_rot = rrs @ u
16951697
tris = Delaunay(rr_rot[:, :2]).simplices
16961698
tris = np.concatenate((tris, tris[:, ::-1]))
1697-
pad = False
1699+
z_value = None
16981700

16991701
# Go from (x,y) -> (x,y,z)
1700-
if pad:
1701-
rrs = np.pad(rrs, ((0, 0), (0, 1)), mode="constant")
1702+
if z_value is not None:
1703+
rrs = np.pad(rrs, ((0, 0), (0, 1)), mode="constant", constant_values=z_value)
17021704
assert rrs.ndim == 2 and rrs.shape[1] == 3
17031705
return rrs, tris
17041706

0 commit comments

Comments
 (0)