Repository: mne-tools/mne-python

Issue:
mne.labels_to_stc to incoporate volumetric labels?
#### Describe the new feature or enhancement
Currently, the labels_to_stc function seems to only support labels from the surface model in the specific label format. Could it be expanded to take labels from volumetric model?






Discussion:
This has been on my TODO list for a while, I should be able to get to it in the next couple of weeks

Resolving patch:
diff --git a/mne/label.py b/mne/label.py
--- a/mne/label.py
+++ b/mne/label.py
@@ -15,7 +15,8 @@
 from scipy import linalg, sparse
 
 from .parallel import parallel_func, check_n_jobs
-from .source_estimate import (SourceEstimate, _center_of_mass,
+from .source_estimate import (SourceEstimate, VolSourceEstimate,
+                              _center_of_mass, extract_label_time_course,
                               spatial_src_adjacency)
 from .source_space import add_source_space_distances, SourceSpaces
 from .stats.cluster_level import _find_clusters, _get_components
@@ -2138,7 +2139,8 @@ def morph_labels(labels, subject_to, subject_from=None, subjects_dir=None,
 
 
 @verbose
-def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, verbose=None):
+def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, src=None,
+                  verbose=None):
     """Convert a set of labels and values to a STC.
 
     This function is meant to work like the opposite of
@@ -2146,8 +2148,7 @@ def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, verbose=None):
 
     Parameters
     ----------
-    labels : list of Label
-        The labels. Must not overlap.
+    %(eltc_labels)s
     values : ndarray, shape (n_labels, ...)
         The values in each label. Can be 1D or 2D.
     tmin : float
@@ -2156,11 +2157,17 @@ def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, verbose=None):
         The tstep to use for the STC.
     subject : str | None
         The subject for which to create the STC.
+    %(eltc_src)s
+        Can be omitted if using a surface source space, in which case
+        the label vertices will determine the output STC vertices.
+        Required if using a volumetric source space.
+
+        .. versionadded:: 0.22
     %(verbose)s
 
     Returns
     -------
-    stc : instance of SourceEstimate
+    stc : instance of SourceEstimate | instance of VolSourceEstimate
         The values-in-labels converted to a STC.
 
     See Also
@@ -2173,16 +2180,47 @@ def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, verbose=None):
 
     .. versionadded:: 0.18
     """
-    subject = _check_labels_subject(labels, subject, 'subject')
     values = np.array(values, float)
     if values.ndim == 1:
         values = values[:, np.newaxis]
     if values.ndim != 2:
         raise ValueError('values must have 1 or 2 dimensions, got %s'
                          % (values.ndim,))
-    if len(labels) != len(values):
-        raise ValueError('values.shape[0] (%s) must match len(labels) (%s)'
-                         % (values.shape[0], len(labels)))
+    _validate_type(src, (SourceSpaces, None))
+    if src is None:
+        data, vertices = _labels_to_stc_surf(
+            labels, values, tmin, tstep, subject)
+        klass = SourceEstimate
+    else:
+        kind = src.kind
+        _check_option('source space kind', kind, ('surface', 'volume'))
+        if kind == 'volume':
+            klass = VolSourceEstimate
+        else:
+            klass = SourceEstimate
+        # Easiest way is to get a dot-able operator and use it
+        vertices = [s['vertno'].copy() for s in src]
+        stc = klass(
+            np.eye(sum(len(v) for v in vertices)), vertices, 0, 1, subject)
+        label_op = extract_label_time_course(
+            stc, labels, src=src, mode='mean', allow_empty=True)
+        _check_values_labels(values, label_op.shape[0])
+        rev_op = np.zeros(label_op.shape[::-1])
+        rev_op[np.arange(label_op.shape[1]), np.argmax(label_op, axis=0)] = 1.
+        data = rev_op @ values
+    return klass(data, vertices, tmin, tstep, subject, verbose)
+
+
+def _check_values_labels(values, n_labels):
+    if n_labels != len(values):
+        raise ValueError(
+            f'values.shape[0] ({values.shape[0]}) must match the number of '
+            f'labels ({n_labels})')
+
+
+def _labels_to_stc_surf(labels, values, tmin, tstep, subject):
+    subject = _check_labels_subject(labels, subject, 'subject')
+    _check_values_labels(values, len(labels))
     vertices = dict(lh=[], rh=[])
     data = dict(lh=[], rh=[])
     for li, label in enumerate(labels):
@@ -2200,8 +2238,7 @@ def labels_to_stc(labels, values, tmin=0, tstep=1, subject=None, verbose=None):
         data[hemi] = mat.dot(data[hemi])
     vertices = [vertices[hemi] for hemi in hemis]
     data = np.concatenate([data[hemi] for hemi in hemis], axis=0)
-    stc = SourceEstimate(data, vertices, tmin, tstep, subject, verbose)
-    return stc
+    return data, vertices
 
 
 _DEFAULT_TABLE_NAME = 'MNE-Python Colortable'
diff --git a/mne/morph.py b/mne/morph.py
--- a/mne/morph.py
+++ b/mne/morph.py
@@ -220,7 +220,8 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
             src_data['to_vox_map'] = (src_to[-1]['shape'], src_ras_t)
             vertices_to_vol = [s['vertno'] for s in src_to[surf_offset:]]
             zooms_src_to = np.diag(src_to[-1]['src_mri_t']['trans'])[:3] * 1000
-            assert (zooms_src_to[0] == zooms_src_to).all()
+            if (zooms_src_to[0] != zooms_src_to).any():
+                raise RuntimeError('Non-uniform zooms not supported')
             zooms_src_to = tuple(zooms_src_to)
 
         # pre-compute non-linear morph
diff --git a/tutorials/source-modeling/plot_visualize_stc.py b/tutorials/source-modeling/plot_visualize_stc.py
--- a/tutorials/source-modeling/plot_visualize_stc.py
+++ b/tutorials/source-modeling/plot_visualize_stc.py
@@ -150,6 +150,13 @@
     ax.spines[key].set_visible(False)
 fig.tight_layout()
 
+###############################################################################
+# And we can project these label time courses back to their original
+# locations and see how the plot has been smoothed:
+
+stc_back = mne.labels_to_stc(fname_aseg, label_tc, src=src)
+stc_back.plot(src, subjects_dir=subjects_dir, mode='glass_brain')
+
 ###############################################################################
 # Vector Source Estimates
 # -----------------------


Test patch:
diff --git a/mne/tests/test_source_estimate.py b/mne/tests/test_source_estimate.py
--- a/mne/tests/test_source_estimate.py
+++ b/mne/tests/test_source_estimate.py
@@ -16,6 +16,7 @@
 from scipy.optimize import fmin_cobyla
 from scipy.spatial.distance import cdist
 
+import mne
 from mne import (stats, SourceEstimate, VectorSourceEstimate,
                  VolSourceEstimate, Label, read_source_spaces,
                  read_evokeds, MixedSourceEstimate, find_events, Epochs,
@@ -27,20 +28,22 @@
                  SourceSpaces, VolVectorSourceEstimate, read_trans, pick_types,
                  MixedVectorSourceEstimate, setup_volume_source_space,
                  convert_forward_solution, pick_types_forward,
-                 compute_source_morph)
+                 compute_source_morph, labels_to_stc, scale_mri,
+                 read_freesurfer_lut, write_source_spaces)
 from mne.datasets import testing
 from mne.externals.h5io import write_hdf5
-from mne.fixes import fft, _get_img_fdata
+from mne.fixes import fft, _get_img_fdata, nullcontext
 from mne.io import read_info
 from mne.io.constants import FIFF
 from mne.source_estimate import grade_to_tris, _get_vol_mask
 from mne.source_space import _get_src_nn
+from mne.surface import _make_morph_map_hemi
 from mne.transforms import apply_trans, invert_transform, transform_surface_to
 from mne.minimum_norm import (read_inverse_operator, apply_inverse,
                               apply_inverse_epochs, make_inverse_operator)
 from mne.label import read_labels_from_annot, label_sign_flip
 from mne.utils import (requires_pandas, requires_sklearn, catch_logging,
-                       requires_h5py, requires_nibabel)
+                       requires_h5py, requires_nibabel, requires_version)
 from mne.io import read_raw_fif
 
 data_path = testing.data_path(download=False)
@@ -836,6 +839,37 @@ def eltc(*args, **kwargs):
                 else:
                     in_label = func(in_label)
                     assert_allclose(in_label, want, atol=1e-6, rtol=rtol)
+        if mode == 'mean' and not vector:  # check the reverse
+            if label_type is dict:
+                ctx = pytest.warns(RuntimeWarning, match='does not contain')
+            else:
+                ctx = nullcontext()
+            with ctx:
+                stc_back = labels_to_stc(labels, label_tc, src=src)
+            assert stc_back.data.shape == stcs[0].data.shape
+            corr = np.corrcoef(stc_back.data.ravel(),
+                               stcs[0].data.ravel())[0, 1]
+            assert 0.6 < corr < 0.63
+            assert_allclose(_varexp(label_tc, label_tc), 1.)
+            ve = _varexp(stc_back.data, stcs[0].data)
+            assert 0.83 < ve < 0.85
+            with pytest.warns(None):  # ignore warnings about no output
+                label_tc_rt = extract_label_time_course(
+                    stc_back, labels, src=src, mri_resolution=mri_res,
+                    allow_empty=True)
+            assert label_tc_rt.shape == label_tc.shape
+            corr = np.corrcoef(label_tc.ravel(), label_tc_rt.ravel())[0, 1]
+            lower, upper = (0.99, 0.999) if mri_res else (0.95, 0.97)
+            assert lower < corr < upper
+            ve = _varexp(label_tc_rt, label_tc)
+            lower, upper = (0.99, 0.999) if mri_res else (0.97, 0.99)
+            assert lower < ve < upper
+
+
+def _varexp(got, want):
+    return max(
+        1 - np.linalg.norm(got.ravel() - want.ravel()) ** 2 /
+        np.linalg.norm(want) ** 2, 0.)
 
 
 @testing.requires_testing_data
@@ -1548,3 +1582,160 @@ def test_stc_near_sensors(tmpdir):
     assert isinstance(stc_vol, VolSourceEstimate)
     log = log.getvalue()
     assert '4157 volume vertices' in log
+
+
+def _make_morph_map_hemi_same(subject_from, subject_to, subjects_dir,
+                              reg_from, reg_to):
+    return _make_morph_map_hemi(subject_from, subject_from, subjects_dir,
+                                reg_from, reg_from)
+
+
+@pytest.mark.parametrize('kind', (
+    pytest.param('volume', marks=[requires_version('dipy')]),
+    'surface'))
+@pytest.mark.parametrize('scale', (1., 0.75, (1.0, 0.8, 1.2)))
+def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
+    """Test label extraction, morphing, and MRI scaling relationships."""
+    tempdir = str(tmpdir)
+    subject_from = 'sample'
+    subject_to = 'small'
+    testing_dir = op.join(subjects_dir, subject_from)
+    from_dir = op.join(tempdir, subject_from)
+    for root in ('mri', 'surf', 'label', 'bem'):
+        os.makedirs(op.join(from_dir, root), exist_ok=True)
+    for hemi in ('lh', 'rh'):
+        for root, fname in (('surf', 'sphere'), ('surf', 'white'),
+                            ('surf', 'sphere.reg'),
+                            ('label', 'aparc.annot')):
+            use_fname = op.join(root, f'{hemi}.{fname}')
+            copyfile(op.join(testing_dir, use_fname),
+                     op.join(from_dir, use_fname))
+    for root, fname in (('mri', 'aseg.mgz'), ('mri', 'brain.mgz')):
+        use_fname = op.join(root, fname)
+        copyfile(op.join(testing_dir, use_fname),
+                 op.join(from_dir, use_fname))
+    del testing_dir
+    if kind == 'surface':
+        src_from = read_source_spaces(fname_src_3)
+        assert src_from[0]['dist'] is None
+        assert src_from[0]['nearest'] is not None
+        # avoid patch calc
+        src_from[0]['nearest'] = src_from[1]['nearest'] = None
+        assert len(src_from) == 2
+        assert src_from[0]['nuse'] == src_from[1]['nuse'] == 258
+        klass = SourceEstimate
+        labels_from = read_labels_from_annot(
+            subject_from, subjects_dir=tempdir)
+        n_labels = len(labels_from)
+        write_source_spaces(op.join(tempdir, subject_from, 'bem',
+                                    f'{subject_from}-oct-4-src.fif'), src_from)
+    else:
+        assert kind == 'volume'
+        pytest.importorskip('dipy')
+        volume_labels = list(read_freesurfer_lut()[0])
+        src_from = setup_volume_source_space(
+            subject_from, 20., 'aseg.mgz',
+            volume_label=volume_labels, single_volume=True,
+            subjects_dir=tempdir)
+        labels_from = op.join(
+            tempdir, subject_from, 'mri', 'aseg.mgz')
+        n_labels = 46
+        assert op.isfile(labels_from)
+        klass = VolSourceEstimate
+        assert len(src_from) == 1
+        assert src_from[0]['nuse'] == 389
+        write_source_spaces(
+            op.join(from_dir, 'bem', 'sample-vol20-src.fif'), src_from)
+    scale_mri(subject_from, subject_to, scale, subjects_dir=tempdir,
+              annot=True, skip_fiducials=True, verbose=True,
+              overwrite=True)  # XXX
+    if kind == 'surface':
+        src_to = read_source_spaces(
+            op.join(tempdir, subject_to, 'bem',
+                    f'{subject_to}-oct-4-src.fif'))
+        labels_to = read_labels_from_annot(
+            subject_to, subjects_dir=tempdir)
+        # Save time since we know these subjects are identical
+        monkeypatch.setattr(mne.surface, '_make_morph_map_hemi',
+                            _make_morph_map_hemi_same)
+    else:
+        src_to = read_source_spaces(
+            op.join(tempdir, subject_to, 'bem',
+                    f'{subject_to}-vol20-src.fif'))
+        labels_to = op.join(
+            tempdir, subject_to, 'mri', 'aseg.mgz')
+    # 1. Label->STC->Label for the given subject should be identity
+    #    (for surfaces at least; for volumes it's not as clean as this
+    #     due to interpolation)
+    n_times = 50
+    rng = np.random.RandomState(0)
+    label_tc = rng.randn(n_labels, n_times)
+    # check that a random permutation of our labels yields a terrible
+    # correlation
+    corr = np.corrcoef(label_tc.ravel(),
+                       rng.permutation(label_tc).ravel())[0, 1]
+    assert -0.06 < corr < 0.06
+    # project label activations to full source space
+    stc = labels_to_stc(labels_from, label_tc, src=src_from)
+    assert isinstance(stc, klass)
+    label_tc_from = extract_label_time_course(
+        stc, labels_from, src_from, mode='mean')
+    if kind == 'surface':
+        assert_allclose(label_tc, label_tc_from, rtol=1e-12, atol=1e-12)
+    else:
+        corr = np.corrcoef(label_tc.ravel(), label_tc_from.ravel())[0, 1]
+        assert 0.66 < corr < 0.69
+
+    #
+    # 2. Changing STC subject to the surrogate and then extracting
+    #
+    stc.subject = subject_to
+    label_tc_to = extract_label_time_course(
+        stc, labels_to, src_to, mode='mean')
+    assert_allclose(label_tc_from, label_tc_to, rtol=1e-12, atol=1e-12)
+    stc.subject = subject_from
+
+    #
+    # 3. Morphing STC to new subject then extracting
+    #
+    if isinstance(scale, tuple) and kind == 'volume':
+        ctx = pytest.raises(RuntimeError, match='Non-uniform zooms')
+        test_morph = False
+    elif kind == 'surface':
+        ctx = pytest.warns(RuntimeWarning, match='not included')
+        test_morph = True
+    else:
+        ctx = nullcontext()
+        test_morph = True
+    with ctx:  # vertices not included
+        morph = compute_source_morph(
+            src_from, subject_to=subject_to, src_to=src_to,
+            subjects_dir=tempdir, niter_sdr=(), smooth=1, verbose=True)
+    if test_morph:
+        stc_to = morph.apply(stc)
+        label_tc_to_morph = extract_label_time_course(
+            stc_to, labels_to, src_to, mode='mean')
+        if kind == 'volume':
+            corr = np.corrcoef(
+                label_tc.ravel(), label_tc_to_morph.ravel())[0, 1]
+            if scale == 1.:
+                assert 0.42 < corr <= 0.47, corr
+            else:
+                # XXX even just a pure scaling does not work very well, bug...
+                assert -0.1 < corr < 0.1
+        else:
+            assert_allclose(
+                label_tc, label_tc_to_morph, atol=1e-12, rtol=1e-12)
+
+    #
+    # 4. The same round trip from (1) but in the warped space
+    #
+    stc = labels_to_stc(labels_to, label_tc, src=src_to)
+    assert isinstance(stc, klass)
+    label_tc_to = extract_label_time_course(
+        stc, labels_to, src_to, mode='mean')
+    if kind == 'surface':
+        assert_allclose(label_tc, label_tc_to, rtol=1e-12, atol=1e-12)
+    else:
+        corr = np.corrcoef(label_tc.ravel(), label_tc_to.ravel())[0, 1]
+        assert 0.66 < corr < 0.69