Repository: mne-tools/mne-python

Issue:
Using surface source spaces with SourceSpaces.export_volume writes out all possible surface vertices
## Describe the bug
Not necessarily a bug, but unexpected behaviour (at least for me):
Writing out surface source spaces from a mixed source space using export_volume always writes out the full list of vertices in the surface used to create the source space, not the vertices that are "inuse", i.e. sources. Is this intentional? If yes, would it be sensible to include an option to write only  source vertices in use?


#### Steps to reproduce
'''
data_path = mne.datasets.sample.data_path()
subjects_dir = f'{data_path}/subjects'
spacing = 'ico2'
n_jobs=1
sphere = (0,0,0,0.12)
surf_src = mne.setup_source_space('fsaverage', spacing=spacing,
                                   subjects_dir=subjects_dir,
                                   n_jobs=n_jobs, add_dist=False)
lh_cereb= mne.setup_volume_source_space("fsaverage", mri=file_path+"/fsaverage/mri/aseg.mgz", sphere=sphere,
                                        volume_label="Left-Cerebellum-Cortex", sphere_units="m")
rh_cereb= mne.setup_volume_source_space("fsaverage", mri=file_path+"/fsaverage/mri/aseg.mgz", sphere=sphere,
                                        volume_label="Right-Cerebellum-Cortex", sphere_units="m")
src=surf_src + lh_cereb + rh_cereb
nii_name=file_path+"/MNESource_cm2.nii"
src.export_volume(nii_name, mri_resolution=True, use_lut=False, overwrite=True)

'''

#### Expected results
When exporting surface source spaces along with volumetric ones I would expect it to export only the surface vertices that are in use, and label voxels within the volume that do not coincide with the location of a source vertex that is in use as background/ 0 

#### Actual results
When exporting surface sources along with volume sources the full vertex list gets written, regardless of how many vertices are in use






Discussion:
I confirm it's surprising ico2 plays no role.

I am sure @larsoner has an explanation

>

Just looks to me like mis-translation of code 6 years ago, and these functions are rarely used so it has been missed...
Hmm, it might actually be because the vertices are quite difficult to see if you only include the ones in the source space. Also it creates an inconsistency with `mri_resolution=True` because the volume vertices will occupy the entire volume. But I think that's probably actually a bug, and it should only "color in" the voxels at the locations of the vertices, not the entire space that that voxel represents in the low-resolution / higher-spacing/zooms MRI.
Basically the question is, in `mri_resolution=True`, which voxels should be colored in? I would say "only those closest to where the vertex is in 3-space", but currently the answer is "all voxels in the regions represented by the low-resolution MRI and decimated surface meshes".
(I'm going to add a `mri_resolution="sparse"` option to allow either behavior)

Resolving patch:
diff --git a/examples/inverse/plot_mixed_source_space_inverse.py b/examples/inverse/plot_mixed_source_space_inverse.py
--- a/examples/inverse/plot_mixed_source_space_inverse.py
+++ b/examples/inverse/plot_mixed_source_space_inverse.py
@@ -3,7 +3,7 @@
 Compute MNE inverse solution on evoked data in a mixed source space
 ===================================================================
 
-Create a mixed source space and compute MNE inverse solution on an
+Create a mixed source space and compute an MNE inverse solution on an
 evoked dataset.
 """
 # Author: Annalisa Pascarella <a.pascarella@iac.cnr.it>
@@ -79,6 +79,8 @@
 print('the src space contains %d spaces and %d points' % (len(src), n))
 
 ###############################################################################
+# Viewing the source space
+# ------------------------
 # We could write the mixed source space with::
 #
 #    >>> write_source_spaces(fname_mixed_src, src, overwrite=True)
@@ -87,7 +89,6 @@
 
 nii_fname = op.join(bem_dir, '%s-mixed-src.nii' % subject)
 src.export_volume(nii_fname, mri_resolution=True, overwrite=True)
-
 plotting.plot_img(nii_fname, cmap='nipy_spectral')
 
 ###############################################################################
diff --git a/mne/source_space.py b/mne/source_space.py
--- a/mne/source_space.py
+++ b/mne/source_space.py
@@ -75,10 +75,8 @@ def _get_lut(fname=None):
     return np.genfromtxt(fname, dtype=dtype)
 
 
-def _get_lut_id(lut, label, use_lut):
+def _get_lut_id(lut, label):
     """Convert a label to a LUT ID number."""
-    if not use_lut:
-        return 1
     assert isinstance(label, str)
     mask = (lut['name'] == label)
     assert mask.sum() == 1
@@ -329,9 +327,15 @@ def export_volume(self, fname, include_surfaces=True,
             4x4 transformation matrix (like the ``--trans`` MNE-C option.
             Must be provided if source spaces are in head coordinates and
             include_surfaces and mri_resolution are True.
-        mri_resolution : bool
+        mri_resolution : bool | str
             If True, the image is saved in MRI resolution
-            (e.g. 256 x 256 x 256).
+            (e.g. 256 x 256 x 256), and each source region (surface or
+            segmentation volume) filled in completely. If "sparse", only a
+            single voxel in the high-resolution MRI is filled in for each
+            source point.
+
+            .. versionchanged:: 0.21.0
+               Support for "sparse" was added.
         use_lut : bool
             If True, assigns a numeric value to each source space that
             corresponds to a color on the freesurfer lookup table.
@@ -346,6 +350,12 @@ def export_volume(self, fname, include_surfaces=True,
         This method requires nibabel.
         """
         _check_fname(fname, overwrite)
+        _validate_type(mri_resolution, (bool, str), 'mri_resolution')
+        if isinstance(mri_resolution, str):
+            _check_option('mri_resolution', mri_resolution, ["sparse"],
+                          extra='when mri_resolution is a string')
+        else:
+            mri_resolution = bool(mri_resolution)
         fname = str(fname)
         # import nibabel or raise error
         try:
@@ -388,170 +398,138 @@ def export_volume(self, fname, include_surfaces=True,
         lut = _get_lut()
 
         # Setup a dictionary of source types
-        src_types = dict(volume=[], surface=[], discrete=[])
+        src_types = dict(volume=[], surface_discrete=[])
 
         # Populate dictionary of source types
         for src in self:
             # volume sources
             if src['type'] == 'vol':
                 src_types['volume'].append(src)
-            # surface sources
-            elif src['type'] == 'surf':
-                src_types['surface'].append(src)
-            # discrete sources
-            elif src['type'] == 'discrete':
-                src_types['discrete'].append(src)
-            # raise an error if dealing with source type other than volume
-            # surface or discrete
+            # surface and discrete sources
+            elif src['type'] in ('surf', 'discrete'):
+                src_types['surface_discrete'].append(src)
             else:
                 raise ValueError('Unrecognized source type: %s.' % src['type'])
 
+        # Raise error if there are no volume source spaces
+        if len(src_types['volume']) == 0:
+            raise ValueError('Source spaces must contain at least one volume.')
+
         # Get shape, inuse array and interpolation matrix from volume sources
-        inuse = 0
+        src = src_types['volume'][0]
+        aseg_data = None
+        if mri_resolution:
+            # read the mri file used to generate volumes
+            if mri_resolution is True:
+                aseg_data = _get_img_fdata(nib.load(src['mri_file']))
+            # get the voxel space shape
+            shape3d = (src['mri_width'], src['mri_depth'],
+                       src['mri_height'])
+        else:
+            # get the volume source space shape
+            # read the shape in reverse order
+            # (otherwise results are scrambled)
+            shape3d = src['shape']
+
+        # calculate affine transform for image (MRI_VOXEL to RAS)
+        if mri_resolution:
+            # MRI_VOXEL to MRI transform
+            transform = src['vox_mri_t']
+        else:
+            # MRI_VOXEL to MRI transform
+            # NOTE: 'src' indicates downsampled version of MRI_VOXEL
+            transform = src['src_mri_t']
+
+        # Figure out how to get from our input source space to output voxels
+        fro_dst_t = invert_transform(transform)
+        dest = transform['to']
+        if coords == 'head':
+            head_mri_t = _get_trans(trans, 'head', 'mri')[0]
+            fro_dst_t = combine_transforms(head_mri_t, fro_dst_t, 'head', dest)
+        else:
+            fro_dst_t = fro_dst_t
+
+        # Fill in the volumes
+        img = np.zeros(shape3d)
         for ii, vs in enumerate(src_types['volume']):
             # read the lookup table value for segmented volume
             if 'seg_name' not in vs:
                 raise ValueError('Volume sources should be segments, '
                                  'not the entire volume.')
             # find the color value for this volume
-            id_ = _get_lut_id(lut, vs['seg_name'], use_lut)
-
-            if ii == 0:
-                # get the inuse array
-                if mri_resolution:
-                    # read the mri file used to generate volumes
-                    aseg_data = _get_img_fdata(nib.load(vs['mri_file']))
-                    # get the voxel space shape
-                    shape3d = (vs['mri_height'], vs['mri_depth'],
-                               vs['mri_width'])
-                else:
-                    # get the volume source space shape
-                    # read the shape in reverse order
-                    # (otherwise results are scrambled)
-                    shape3d = vs['shape'][2::-1]
-            if mri_resolution:
+            use_id = 1.
+            if mri_resolution is True or use_lut:
+                id_ = _get_lut_id(lut, vs['seg_name'])
+                if use_lut:
+                    use_id = id_
+
+            if mri_resolution == 'sparse':
+                idx = apply_trans(fro_dst_t, vs['rr'][vs['vertno']])
+                idx = tuple(idx.round().astype(int).T)
+            elif mri_resolution is True:  # fill the represented vol
                 # get the values for this volume
-                use = id_ * (aseg_data == id_).astype(int).ravel('F')
+                idx = (aseg_data == id_)
             else:
-                use = id_ * vs['inuse']
-            inuse += use
-
-        # Raise error if there are no volume source spaces
-        if np.array(inuse).ndim == 0:
-            raise ValueError('Source spaces must contain at least one volume.')
-
-        # create 3d grid in the MRI_VOXEL coordinate frame
-        # len of inuse array should match shape regardless of mri_resolution
-        assert len(inuse) == np.prod(shape3d)
-
-        # setup the image in 3d space
-        img = inuse.reshape(shape3d).T
-
-        # include surface and/or discrete source spaces
-        if include_surfaces or include_discrete:
-
-            # setup affine transform for source spaces
-            if mri_resolution:
-                # get the MRI to MRI_VOXEL transform
-                affine = invert_transform(vs['vox_mri_t'])
+                assert mri_resolution is False
+                idx = vs['inuse'].reshape(shape3d, order='F').astype(bool)
+            img[idx] = use_id
+
+        # loop through the surface and discrete source spaces
+
+        # get the surface names (assumes left, right order. may want
+        # to add these names during source space generation
+        for src in src_types['surface_discrete']:
+            val = 1
+            if src['type'] == 'surf':
+                if not include_surfaces:
+                    continue
+                if use_lut:
+                    surf_name = {
+                        FIFF.FIFFV_MNE_SURF_LEFT_HEMI: 'Left',
+                        FIFF.FIFFV_MNE_SURF_RIGHT_HEMI: 'Right',
+                    }[src['id']] + '-Cerebral-Cortex'
+                    val = _get_lut_id(lut, surf_name)
             else:
-                # get the MRI to SOURCE (MRI_VOXEL) transform
-                affine = invert_transform(vs['src_mri_t'])
-
-            # modify affine if in head coordinates
-            if coords == 'head':
-
-                # read mri -> head transformation
-                mri_head_t = _get_trans(trans)[0]
-
-                # get the HEAD to MRI transform
-                head_mri_t = invert_transform(mri_head_t)
-
-                # combine transforms, from HEAD to MRI_VOXEL
-                affine = combine_transforms(head_mri_t, affine,
-                                            'head', 'mri_voxel')
-
-            # loop through the surface source spaces
-            if include_surfaces:
-
-                # get the surface names (assumes left, right order. may want
-                # to add these names during source space generation
-                surf_names = ['Left-Cerebral-Cortex', 'Right-Cerebral-Cortex']
-
-                for i, surf in enumerate(src_types['surface']):
-                    # convert vertex positions from their native space
-                    # (either HEAD or MRI) to MRI_VOXEL space
-                    srf_rr = apply_trans(affine['trans'], surf['rr'])
-                    # convert to numeric indices
-                    ix_orig, iy_orig, iz_orig = srf_rr.T.round().astype(int)
-                    # clip indices outside of volume space
-                    ix_clip = np.maximum(np.minimum(ix_orig, shape3d[2] - 1),
-                                         0)
-                    iy_clip = np.maximum(np.minimum(iy_orig, shape3d[1] - 1),
-                                         0)
-                    iz_clip = np.maximum(np.minimum(iz_orig, shape3d[0] - 1),
-                                         0)
-                    # compare original and clipped indices
-                    n_diff = np.array((ix_orig != ix_clip, iy_orig != iy_clip,
-                                       iz_orig != iz_clip)).any(0).sum()
-                    # generate use warnings for clipping
-                    if n_diff > 0:
-                        warn('%s surface vertices lay outside of volume space.'
-                             ' Consider using a larger volume space.' % n_diff)
-                    # get surface id or use default value
-                    i = _get_lut_id(lut, surf_names[i], use_lut)
-                    # update image to include surface voxels
-                    img[ix_clip, iy_clip, iz_clip] = i
-
-            # loop through discrete source spaces
-            if include_discrete:
-                for i, disc in enumerate(src_types['discrete']):
-                    # convert vertex positions from their native space
-                    # (either HEAD or MRI) to MRI_VOXEL space
-                    disc_rr = apply_trans(affine['trans'], disc['rr'])
-                    # convert to numeric indices
-                    ix_orig, iy_orig, iz_orig = disc_rr.T.astype(int)
-                    # clip indices outside of volume space
-                    ix_clip = np.maximum(np.minimum(ix_orig, shape3d[2] - 1),
-                                         0)
-                    iy_clip = np.maximum(np.minimum(iy_orig, shape3d[1] - 1),
-                                         0)
-                    iz_clip = np.maximum(np.minimum(iz_orig, shape3d[0] - 1),
-                                         0)
-                    # compare original and clipped indices
-                    n_diff = np.array((ix_orig != ix_clip, iy_orig != iy_clip,
-                                       iz_orig != iz_clip)).any(0).sum()
-                    # generate use warnings for clipping
-                    if n_diff > 0:
-                        warn('%s discrete vertices lay outside of volume '
-                             'space. Consider using a larger volume space.'
-                             % n_diff)
-                    # set default value
-                    img[ix_clip, iy_clip, iz_clip] = 1
-                    if use_lut:
-                        logger.info('Discrete sources do not have values on '
-                                    'the lookup table. Defaulting to 1.')
+                assert src['type'] == 'discrete'
+                if not include_discrete:
+                    continue
+                if use_lut:
+                    logger.info('Discrete sources do not have values on '
+                                'the lookup table. Defaulting to 1.')
+            # convert vertex positions from their native space
+            # (either HEAD or MRI) to MRI_VOXEL space
+            if mri_resolution is True:
+                use_rr = src['rr']
+            else:
+                assert mri_resolution is False or mri_resolution == 'sparse'
+                use_rr = src['rr'][src['vertno']]
+            srf_vox = apply_trans(fro_dst_t['trans'], use_rr)
+            # convert to numeric indices
+            ix_, iy_, iz_ = srf_vox.T.round().astype(int)
+            # clip indices outside of volume space
+            ix = np.clip(ix_, 0, shape3d[0] - 1),
+            iy = np.clip(iy_, 0, shape3d[1] - 1)
+            iz = np.clip(iz_, 0, shape3d[2] - 1)
+            # compare original and clipped indices
+            n_diff = ((ix_ != ix) | (iy_ != iy) | (iz_ != iz)).sum()
+            # generate use warnings for clipping
+            if n_diff > 0:
+                warn(f'{n_diff} {src["kind"]} vertices lay outside of volume '
+                     f'space. Consider using a larger volume space.')
+            # get surface id or use default value
+            # update image to include surface voxels
+            img[ix, iy, iz] = val
 
-        # calculate affine transform for image (MRI_VOXEL to RAS)
-        if mri_resolution:
-            # MRI_VOXEL to MRI transform
-            transform = vs['vox_mri_t']
-        else:
-            # MRI_VOXEL to MRI transform
-            # NOTE: 'src' indicates downsampled version of MRI_VOXEL
-            transform = vs['src_mri_t']
         if dest == 'mri':
             # combine with MRI to RAS transform
-            transform = combine_transforms(transform, vs['mri_ras_t'],
-                                           transform['from'],
-                                           vs['mri_ras_t']['to'])
+            transform = combine_transforms(
+                transform, vs['mri_ras_t'],
+                transform['from'], vs['mri_ras_t']['to'])
         # now setup the affine for volume image
         affine = transform['trans'].copy()
         # make sure affine converts from m to mm
         affine[:3] *= 1e3
 
-        # save volume data
-
         # setup image for file
         if fname.endswith(('.nii', '.nii.gz')):  # save as nifit
             # setup the nifti header


Test patch:
diff --git a/mne/forward/tests/test_make_forward.py b/mne/forward/tests/test_make_forward.py
--- a/mne/forward/tests/test_make_forward.py
+++ b/mne/forward/tests/test_make_forward.py
@@ -336,7 +336,7 @@ def test_forward_mixed_source_space(tmpdir):
 
     # head coordinates and mri_resolution, but wrong trans file
     vox_mri_t = vol1[0]['vox_mri_t']
-    with pytest.raises(ValueError, match='mri<->head, got mri_voxel->mri'):
+    with pytest.raises(ValueError, match='head<->mri, got mri_voxel->mri'):
         src_from_fwd.export_volume(fname_img, mri_resolution=True,
                                    trans=vox_mri_t)
 
diff --git a/mne/tests/test_source_space.py b/mne/tests/test_source_space.py
--- a/mne/tests/test_source_space.py
+++ b/mne/tests/test_source_space.py
@@ -19,6 +19,7 @@
                  morph_source_spaces, SourceEstimate, make_sphere_model,
                  head_to_mni, read_trans, compute_source_morph,
                  read_bem_solution, read_freesurfer_lut)
+from mne.fixes import _get_img_fdata
 from mne.utils import (requires_nibabel, run_subprocess,
                        modified_env, requires_mne, run_tests_if_main,
                        check_version)
@@ -682,13 +683,15 @@ def test_read_volume_from_src():
 @requires_nibabel()
 def test_combine_source_spaces(tmpdir):
     """Test combining source spaces."""
-    rng = np.random.RandomState(0)
+    import nibabel as nib
+    rng = np.random.RandomState(2)
     aseg_fname = op.join(subjects_dir, 'sample', 'mri', 'aseg.mgz')
-    label_names = get_volume_labels_from_aseg(aseg_fname)
-    volume_labels = rng.choice(label_names, 2)
+    volume_labels = ['Brain-Stem', 'Right-Hippocampus']  # two fairly large
 
-    # get a surface source space (no need to test creation here)
-    srf = read_source_spaces(fname, patch_stats=False)
+    # create a sparse surface source space to ensure all get mapped
+    # when mri_resolution=False
+    srf = setup_source_space('sample', 'oct3', add_dist=False,
+                             subjects_dir=subjects_dir)
 
     # setup 2 volume source spaces
     vol = setup_volume_source_space('sample', subjects_dir=subjects_dir,
@@ -696,7 +699,7 @@ def test_combine_source_spaces(tmpdir):
                                     mri=aseg_fname, add_interpolator=False)
 
     # setup a discrete source space
-    rr = rng.randint(0, 20, (100, 3)) * 1e-3
+    rr = rng.randint(0, 11, (20, 3)) * 5e-3
     nn = np.zeros(rr.shape)
     nn[:, -1] = 1
     pos = {'rr': rr, 'nn': nn}
@@ -754,6 +757,22 @@ def test_combine_source_spaces(tmpdir):
     pytest.raises(ValueError, src_mixed_coord.export_volume, image_fname,
                   verbose='error')
 
+    # now actually write it
+    fname_img = tmpdir.join('img.nii')
+    for mri_resolution in (False, 'sparse', True):
+        for src, up in ((vol, 705),
+                        (srf + vol, 27272),
+                        (disc + vol, 705)):
+            src.export_volume(
+                fname_img, use_lut=False,
+                mri_resolution=mri_resolution, overwrite=True)
+            img_data = _get_img_fdata(nib.load(str(fname_img)))
+            n_src = img_data.astype(bool).sum()
+            n_want = sum(s['nuse'] for s in src)
+            if mri_resolution is True:
+                n_want += up
+            assert n_src == n_want, src
+
 
 @testing.requires_testing_data
 def test_morph_source_spaces():