Skip to content

Commit 2881652

Browse files
wmvanvlietlarsoner
andauthored
Add more options to sphere argument in topomap plots. (#13400)
Co-authored-by: Eric Larson <[email protected]>
1 parent 0836e42 commit 2881652

File tree

7 files changed

+282
-236
lines changed

7 files changed

+282
-236
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add more options for the ``sphere`` parameter of :func:`mne.viz.plot_sensors`, by `Marijn van Vliet`_

mne/bem.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1090,10 +1090,17 @@ def _fit_sphere_to_headshape(info, dig_kinds, *, verbose=None):
10901090
o_mm = origin_head * 1e3
10911091
o_d = origin_device * 1e3
10921092
if np.linalg.norm(origin_head[:2]) > 0.02:
1093-
warn(
1093+
msg = (
10941094
f"(X, Y) fit ({o_mm[0]:0.1f}, {o_mm[1]:0.1f}) "
10951095
"more than 20 mm from head frame origin"
10961096
)
1097+
if dig_kinds == "auto":
1098+
logger.info(msg)
1099+
logger.info("Trying again with all digitization points.")
1100+
return _fit_sphere_to_headshape(
1101+
info, dig_kinds=("extra", "eeg", "hpi", "cardinal"), verbose=verbose
1102+
)
1103+
warn(msg)
10971104
logger.info(
10981105
"Origin head coordinates:".ljust(30)
10991106
+ f"{o_mm[0]:0.1f} {o_mm[1]:0.1f} {o_mm[2]:0.1f} mm"

mne/source_space/tests/test_source_space.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ def test_volume_source_space(tmp_path):
371371
src = setup_volume_source_space(pos=10, sphere=(0.0, 0.0, 0.0, 0.09))
372372
src_new = setup_volume_source_space(pos=10, sphere=sphere)
373373
_compare_source_spaces(src, src_new, mode="exact")
374-
with pytest.raises(ValueError, match="sphere, if str"):
374+
with pytest.raises(ValueError, match="Invalid value for the 'sphere' parameter"):
375375
setup_volume_source_space(sphere="foo")
376376
# Need a radius
377377
sphere = make_sphere_model(head_radius=None)

mne/utils/check.py

Lines changed: 77 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,90 +1040,90 @@ def _check_sphere(sphere, info=None, sphere_units="m"):
10401040
sphere = "auto"
10411041

10421042
if isinstance(sphere, str):
1043-
if sphere not in ("auto", "eeglab"):
1044-
raise ValueError(
1045-
f'sphere, if str, must be "auto" or "eeglab", got {sphere}'
1046-
)
1043+
_check_option(
1044+
"sphere", sphere, ("auto", "eeglab", "extra", "eeg", "cardinal", "hpi")
1045+
)
1046+
1047+
if isinstance(sphere, str) and sphere == "eeglab":
10471048
assert info is not None
10481049

1049-
if sphere == "auto":
1050-
R, r0, _ = fit_sphere_to_headshape(
1051-
info, verbose=_verbose_safe_false(), units="m"
1050+
# We need coordinates for the 2D plane formed by
1051+
# Fpz<->Oz and T7<->T8, as this plane will be the horizon (i.e. it
1052+
# will determine the location of the head circle).
1053+
#
1054+
# We implement some special-handling in case Fpz is missing, as this seems to be
1055+
# a quite common situation in numerous EEG labs.
1056+
montage = info.get_montage()
1057+
if montage is None:
1058+
raise ValueError(
1059+
'No montage was set on your data, but sphere="eeglab" can only work if '
1060+
"digitization points for the EEG channels are available. Consider "
1061+
"calling set_montage() to apply a montage."
10521062
)
1053-
sphere = tuple(r0) + (R,)
1054-
sphere_units = "m"
1055-
elif sphere == "eeglab":
1056-
# We need coordinates for the 2D plane formed by
1057-
# Fpz<->Oz and T7<->T8, as this plane will be the horizon (i.e. it
1058-
# will determine the location of the head circle).
1059-
#
1060-
# We implement some special-handling in case Fpz is missing, as
1061-
# this seems to be a quite common situation in numerous EEG labs.
1062-
montage = info.get_montage()
1063-
if montage is None:
1064-
raise ValueError(
1065-
'No montage was set on your data, but sphere="eeglab" '
1066-
"can only work if digitization points for the EEG "
1067-
"channels are available. Consider calling set_montage() "
1068-
"to apply a montage."
1069-
)
1070-
ch_pos = montage.get_positions()["ch_pos"]
1071-
horizon_ch_names = ("Fpz", "Oz", "T7", "T8")
1072-
1073-
if "FPz" in ch_pos: # "fix" naming
1074-
ch_pos["Fpz"] = ch_pos["FPz"]
1075-
del ch_pos["FPz"]
1076-
elif "Fpz" not in ch_pos and "Oz" in ch_pos:
1077-
logger.info(
1078-
"Approximating Fpz location by mirroring Oz along the X and Y axes."
1063+
ch_pos = montage.get_positions()["ch_pos"]
1064+
horizon_ch_names = ("Fpz", "Oz", "T7", "T8")
1065+
1066+
if "FPz" in ch_pos: # "fix" naming
1067+
ch_pos["Fpz"] = ch_pos["FPz"]
1068+
del ch_pos["FPz"]
1069+
elif "Fpz" not in ch_pos and "Oz" in ch_pos:
1070+
logger.info(
1071+
"Approximating Fpz location by mirroring Oz along the X and Y axes."
1072+
)
1073+
# This assumes Fpz and Oz have the same Z coordinate
1074+
ch_pos["Fpz"] = ch_pos["Oz"] * [-1, -1, 1]
1075+
1076+
for ch_name in horizon_ch_names:
1077+
if ch_name not in ch_pos:
1078+
msg = (
1079+
f'sphere="eeglab" requires digitization points of the following '
1080+
f"electrode locations in the data: {', '.join(horizon_ch_names)}, "
1081+
f"but could not find: {ch_name}"
10791082
)
1080-
# This assumes Fpz and Oz have the same Z coordinate
1081-
ch_pos["Fpz"] = ch_pos["Oz"] * [-1, -1, 1]
1082-
1083-
for ch_name in horizon_ch_names:
1084-
if ch_name not in ch_pos:
1085-
msg = (
1086-
f'sphere="eeglab" requires digitization points of '
1087-
f"the following electrode locations in the data: "
1088-
f"{', '.join(horizon_ch_names)}, but could not find: "
1089-
f"{ch_name}"
1090-
)
1091-
if ch_name == "Fpz":
1092-
msg += ", and was unable to approximate its location from Oz"
1093-
raise ValueError(msg)
1094-
1095-
# Calculate the radius from: T7<->T8, Fpz<->Oz
1096-
radius = np.abs(
1083+
if ch_name == "Fpz":
1084+
msg += ", and was unable to approximate its location from Oz"
1085+
raise ValueError(msg)
1086+
1087+
# Calculate the radius from: T7<->T8, Fpz<->Oz
1088+
radius = np.abs(
1089+
[
1090+
ch_pos["T7"][0], # X axis
1091+
ch_pos["T8"][0], # X axis
1092+
ch_pos["Fpz"][1], # Y axis
1093+
ch_pos["Oz"][1], # Y axis
1094+
]
1095+
).mean()
1096+
1097+
# Calculate the center of the head sphere.
1098+
# Use 4 digpoints for each of the 3 axes to hopefully get a better approximation
1099+
# than when using just 2 digpoints.
1100+
sphere_locs = dict()
1101+
for idx, axis in enumerate(("X", "Y", "Z")):
1102+
sphere_locs[axis] = np.mean(
10971103
[
1098-
ch_pos["T7"][0], # X axis
1099-
ch_pos["T8"][0], # X axis
1100-
ch_pos["Fpz"][1], # Y axis
1101-
ch_pos["Oz"][1], # Y axis
1104+
ch_pos["T7"][idx],
1105+
ch_pos["T8"][idx],
1106+
ch_pos["Fpz"][idx],
1107+
ch_pos["Oz"][idx],
11021108
]
1103-
).mean()
1104-
1105-
# Calculate the center of the head sphere
1106-
# Use 4 digpoints for each of the 3 axes to hopefully get a better
1107-
# approximation than when using just 2 digpoints.
1108-
sphere_locs = dict()
1109-
for idx, axis in enumerate(("X", "Y", "Z")):
1110-
sphere_locs[axis] = np.mean(
1111-
[
1112-
ch_pos["T7"][idx],
1113-
ch_pos["T8"][idx],
1114-
ch_pos["Fpz"][idx],
1115-
ch_pos["Oz"][idx],
1116-
]
1117-
)
1118-
sphere = (sphere_locs["X"], sphere_locs["Y"], sphere_locs["Z"], radius)
1119-
sphere_units = "m"
1120-
del sphere_locs, radius, montage, ch_pos
1109+
)
1110+
sphere = (sphere_locs["X"], sphere_locs["Y"], sphere_locs["Z"], radius)
1111+
sphere_units = "m"
1112+
del sphere_locs, radius, montage, ch_pos
1113+
elif isinstance(sphere, str) or (
1114+
isinstance(sphere, list) and all(isinstance(s, str) for s in sphere)
1115+
):
1116+
# Fit a sphere to the head points.
1117+
R, r0, _ = fit_sphere_to_headshape(
1118+
info, dig_kinds=sphere, verbose=_verbose_safe_false(), units="m"
1119+
)
1120+
sphere = tuple(r0) + (R,)
1121+
sphere_units = "m"
11211122
elif isinstance(sphere, ConductorModel):
11221123
if not sphere["is_sphere"] or len(sphere["layers"]) == 0:
11231124
raise ValueError(
1124-
"sphere, if a ConductorModel, must be spherical "
1125-
"with multiple layers, not a BEM or single-layer "
1126-
f"sphere (got {sphere})"
1125+
"sphere, if a ConductorModel, must be spherical with multiple layers, "
1126+
f"not a BEM or single-layer sphere (got {sphere})"
11271127
)
11281128
sphere = tuple(sphere["r0"]) + (sphere["layers"][0]["rad"],)
11291129
sphere_units = "m"
@@ -1132,8 +1132,8 @@ def _check_sphere(sphere, info=None, sphere_units="m"):
11321132
sphere = np.concatenate([[0.0] * 3, [sphere]])
11331133
if sphere.shape != (4,):
11341134
raise ValueError(
1135-
"sphere must be float or 1D array of shape (4,), got "
1136-
f"array-like of shape {sphere.shape}"
1135+
"sphere must be float or 1D array of shape (4,), "
1136+
f"got array-like of shape {sphere.shape}"
11371137
)
11381138
_check_option("sphere_units", sphere_units, ("m", "mm"))
11391139
if sphere_units == "mm":

mne/utils/docs.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4217,21 +4217,40 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
42174217
"""
42184218

42194219
docdict["sphere_topomap_auto"] = f"""\
4220-
sphere : float | array-like | instance of ConductorModel | None | 'auto' | 'eeglab'
4221-
The sphere parameters to use for the head outline. Can be array-like of
4222-
shape (4,) to give the X/Y/Z origin and radius in meters, or a single float
4223-
to give just the radius (origin assumed 0, 0, 0). Can also be an instance
4224-
of a spherical :class:`~mne.bem.ConductorModel` to use the origin and
4225-
radius from that object. If ``'auto'`` the sphere is fit to digitization
4226-
points. If ``'eeglab'`` the head circle is defined by EEG electrodes
4227-
``'Fpz'``, ``'Oz'``, ``'T7'``, and ``'T8'`` (if ``'Fpz'`` is not present,
4228-
it will be approximated from the coordinates of ``'Oz'``). ``None`` (the
4229-
default) is equivalent to ``'auto'`` when enough extra digitization points
4230-
are available, and (0, 0, 0, {HEAD_SIZE_DEFAULT}) otherwise.
4220+
sphere : float | array-like of float | instance of ConductorModel | str | list of str | None
4221+
The sphere parameters to use for the head outline.
4222+
Can be array-like of shape (4,) to give the X/Y/Z origin and radius in meters, or a
4223+
single float to give just the radius (origin assumed 0, 0, 0).
4224+
Can also be an instance of a spherical :class:`~mne.bem.ConductorModel` to use the
4225+
origin and radius from that object.
4226+
Can also be a ``str``, in which case:
4227+
4228+
- ``'auto'``: the sphere is fit to external digitization points first, and to
4229+
external + EEG digitization points if the former fails.
4230+
4231+
- ``'eeglab'``: the head circle is defined by EEG electrodes ``'Fpz'``, ``'Oz'``,
4232+
``'T7'``, and ``'T8'`` (if ``'Fpz'`` is not present, it will be approximated from
4233+
the coordinates of ``'Oz'``).
4234+
4235+
- ``'extra'``: the sphere is fit to external digitization points.
4236+
4237+
- ``'eeg'``: the sphere is fit to EEG digitization points.
4238+
4239+
- ``'cardinal'``: the sphere is fit to cardinal digitization points.
4240+
4241+
- ``'hpi'``: the sphere is fit to HPI coil digitization points.
4242+
4243+
Can also be a list of ``str``, in which case the sphere is fit to the specified
4244+
digitization points, which can be any combination of ``'extra'``, ``'eeg'``,
4245+
``'cardinal'``, and ``'hpi'``, as specified above.
4246+
``None`` (the default) is equivalent to ``'auto'`` when enough extra digitization
4247+
points are available, and (0, 0, 0, {HEAD_SIZE_DEFAULT}) otherwise.
42314248
42324249
.. versionadded:: 0.20
42334250
.. versionchanged:: 1.1 Added ``'eeglab'`` option.
4234-
"""
4251+
.. versionchanged:: 1.11 Added ``'extra'``, ``'eeg'``, ``'cardinal'``, ``'hpi'`` and
4252+
list of ``str`` options.
4253+
""" # noqa E501
42354254

42364255
docdict["splash"] = """
42374256
splash : bool

mne/utils/tests/test_check.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111
import numpy as np
1212
import pytest
13+
from numpy.testing import assert_allclose, assert_equal
1314

1415
import mne
15-
from mne import pick_channels_cov, read_vectorview_selection
16+
from mne import create_info, pick_channels_cov, read_vectorview_selection
1617
from mne._fiff.pick import _picks_to_idx
1718
from mne.datasets import testing
1819
from mne.utils import (
@@ -364,15 +365,50 @@ def looks_stable(version):
364365

365366

366367
@testing.requires_testing_data
367-
def test_check_sphere_verbose():
368-
"""Test that verbose is handled properly in _check_sphere."""
368+
def test_check_sphere():
369+
"""Test the _check_sphere function."""
369370
info = mne.io.read_info(fname_raw)
370-
with info._unlock():
371-
info["dig"] = info["dig"][:20]
371+
info_eeglab = create_info(
372+
ch_names=["Fpz", "Oz", "T7", "T8"], sfreq=100, ch_types="eeg"
373+
)
374+
info_eeglab.set_montage("biosemi64")
375+
376+
# Test passing None.
377+
assert_equal(_check_sphere(None), [0, 0, 0, 0.095]) # default head pos
378+
assert not np.any(_check_sphere(None, info) == 0) # fit to dig points
379+
380+
# Test passing a 4-element array-like as sphere parameter.
381+
assert_equal(_check_sphere([1, 2, 3, 4], info), [1, 2, 3, 4])
382+
assert_equal(_check_sphere([1, 2, 3, 4], info=None), [1, 2, 3, 4])
383+
with pytest.raises(ValueError, match=r"1D array of shape \(4,\)"):
384+
_check_sphere([1, 2, 3], info)
385+
386+
# Test passing various string values for `sphere`.
387+
sphere_auto = _check_sphere("auto", info)
388+
sphere_eeglab = _check_sphere("eeglab", info_eeglab)
389+
sphere_extra = _check_sphere("extra", info)
390+
sphere_eeg = _check_sphere("eeg", info)
391+
with _record_warnings(), pytest.warns(RuntimeWarning, match="may be inaccurate"):
392+
sphere_hpi = _check_sphere("hpi", info)
393+
sphere_all = _check_sphere(["extra", "eeg", "cardinal", "hpi"], info)
394+
395+
assert_allclose(sphere_auto, sphere_extra)
396+
assert not np.allclose(sphere_auto, sphere_eeglab, rtol=1e-4, atol=1e-4)
397+
assert not np.allclose(sphere_auto, sphere_eeg, rtol=1e-4, atol=1e-4)
398+
assert not np.allclose(sphere_auto, sphere_hpi, rtol=1e-4, atol=1e-4)
399+
assert not np.allclose(sphere_auto, sphere_all, rtol=1e-4, atol=1e-4)
400+
401+
with pytest.raises(TypeError, match="Item must be an instance of Info"):
402+
_check_sphere("auto", info=None)
403+
404+
# Test that verbose is handled properly in _check_sphere.
405+
info_trunc = info.copy()
406+
with info_trunc._unlock():
407+
info_trunc["dig"] = info_trunc["dig"][:20]
372408
with _record_warnings(), pytest.warns(RuntimeWarning, match="may be inaccurate"):
373-
_check_sphere("auto", info)
409+
_check_sphere("auto", info_trunc)
374410
with mne.use_log_level("error"):
375-
_check_sphere("auto", info)
411+
_check_sphere("auto", info_trunc)
376412

377413

378414
def test_soft_import():

0 commit comments

Comments
 (0)