Repository: mne-tools/mne-python

Issue:
DOC: morph generated from src does not work because fwd exlcude some vertices
```python
"""Prep example from scratch to make sure we have a unique pipeline"""
import mne
import os
from mne.datasets import sample
from mne.minimum_norm import apply_inverse, make_inverse_operator

# Paths
data_path = sample.data_path()
subject = 'sample'
subjects_dir = os.path.join(data_path, 'subjects')
sample_dir = os.path.join(data_path, 'MEG', subject)

# Functional pipeline
raw_fname = sample_dir + '/sample_audvis_filt-0-40_raw.fif'
events_fname = sample_dir + '/sample_audvis_filt-0-40_raw-eve.fif'
raw = mne.io.read_raw_fif(raw_fname)
events = mne.read_events(events_fname)
epochs = mne.Epochs(raw, events, tmin=-.100, tmax=.200)
cov = mne.compute_covariance(epochs, tmax=0)
evo = epochs.average()

# Structural pipeline
trans_fname = sample_dir + '/sample_audvis_raw-trans.fif'
src = mne.setup_source_space(subject, spacing='oct6',
                             subjects_dir=subjects_dir, 
                             add_dist=False)
morph = mne.compute_source_morph(src,
                                 subject_to='fsaverage',
                                 subjects_dir=subjects_dir)
model = mne.make_bem_model(subject=subject, 
                           ico=4,
                           conductivity=(.3,),
                           subjects_dir=subjects_dir)
bem = mne.make_bem_solution(model)
fwd = mne.make_forward_solution(epochs.info, 
                                trans=trans_fname, 
                                src=src, bem=bem,
                                meg=True, eeg=False, mindist=5.0, n_jobs=-1)

# Inverse modeling
inv = make_inverse_operator(epochs.info, fwd, cov, loose=0., depth=0.8)
stc = apply_inverse(evo, inv)
# -----

```

critically
```python
# morphing from stc to stc works
morph = mne.compute_source_morph(stc,
                                 subject_from=subject,
                                 subject_to='fsaverage',
                                 subjects_dir=subjects_dir)

stc_success = morph.apply(stc)

# morphing from src to stc does not
morph = mne.compute_source_morph(src,
                                 subject_from=subject,
                                 subject_to='fsaverage',
                                 subjects_dir=subjects_dir)
stc_fails = morph.apply(stc)
```

returns 
```
~/opt/mne-python/mne/morph.py in _check_vertices_match(v1, v2, name)
   1160         raise ValueError('vertices do not match between morph (%s) '
   1161                          'and stc (%s) for the %s:\n%s\n%s'
-> 1162                          % (len(v1), len(v2), name, v1, v2))
   1163 
   1164 

ValueError: vertices do not match between morph (4098) and stc (3732) for the left hemisphere:
[    14     54     59 ... 155295 155323 155330]
[   841   1170   1329 ... 155249 155323 155330]
```


Discussion:
Does it work when you pass `fwd['src']` or `inv['src']`? It's possible/likely that the forward computation with `mindist` excluded some vertices
> Does it work when you pass fwd['src'] or inv['src']? It's possible/likely that the forward computation with mindist excluded some vertices

This works, thanks.

I wasn't aware the forward model could change the src. Shall I add a note in the tutorial? 

That would be great, and the tutorial should use the one from fwd or inv, not the original (even if it's the same in that specific case, it helps show the principle)
>
> Shall I add a note in the tutorial?
>
+1


Resolving patch:
diff --git a/examples/inverse/plot_morph_surface_stc.py b/examples/inverse/plot_morph_surface_stc.py
--- a/examples/inverse/plot_morph_surface_stc.py
+++ b/examples/inverse/plot_morph_surface_stc.py
@@ -33,6 +33,7 @@
 #
 # License: BSD (3-clause)
 import os
+import os.path as op
 
 import mne
 from mne.datasets import sample
@@ -42,9 +43,13 @@
 ###############################################################################
 # Setup paths
 
-sample_dir_raw = sample.data_path()
-sample_dir = os.path.join(sample_dir_raw, 'MEG', 'sample')
-subjects_dir = os.path.join(sample_dir_raw, 'subjects')
+data_path = sample.data_path()
+sample_dir = op.join(data_path, 'MEG', 'sample')
+subjects_dir = op.join(data_path, 'subjects')
+fname_src = op.join(subjects_dir, 'sample', 'bem', 'sample-oct-6-src.fif')
+fname_fwd = op.join(sample_dir, 'sample_audvis-meg-oct-6-fwd.fif')
+fname_fsaverage_src = os.path.join(subjects_dir, 'fsaverage', 'bem',
+                                   'fsaverage-ico-5-src.fif')
 
 fname_stc = os.path.join(sample_dir, 'sample_audvis-meg')
 
@@ -58,32 +63,40 @@
 # Setting up SourceMorph for SourceEstimate
 # -----------------------------------------
 #
-# In MNE surface source estimates represent the source space simply as
-# lists of vertices (see
-# :ref:`tut-source-estimate-class`).
-# This list can either be obtained from
-# :class:`mne.SourceSpaces` (src) or from the ``stc`` itself.
-#
-# Since the default ``spacing`` (resolution of surface mesh) is ``5`` and
-# ``subject_to`` is set to 'fsaverage', :class:`mne.SourceMorph` will use
-# default ico-5 ``fsaverage`` vertices to morph, which are the special
-# values ``[np.arange(10242)] * 2``.
-#
-# .. note:: This is not generally true for other subjects! The set of vertices
-#           used for ``fsaverage`` with ico-5 spacing was designed to be
-#           special. ico-5 spacings for other subjects (or other spacings
-#           for fsaverage) must be calculated and will not be consecutive
-#           integers.
+# In MNE, surface source estimates represent the source space simply as
+# lists of vertices (see :ref:`tut-source-estimate-class`).
+# This list can either be obtained from :class:`mne.SourceSpaces` (src) or from
+# the ``stc`` itself. If you use the source space, be sure to use the
+# source space from the forward or inverse operator, because vertices
+# can be excluded during forward computation due to proximity to the BEM
+# inner skull surface:
+
+src_orig = mne.read_source_spaces(fname_src)
+print(src_orig)  # n_used=4098, 4098
+fwd = mne.read_forward_solution(fname_fwd)
+print(fwd['src'])  # n_used=3732, 3766
+print([len(v) for v in stc.vertices])
+
+###############################################################################
+# We also need to specify the set of vertices to morph to. This can be done
+# using the ``spacing`` parameter, but for consistency it's better to pass the
+# ``src_to`` parameter.
 #
-# If src was not defined, the morph will actually not be precomputed, because
-# we lack the vertices *from* that we want to compute. Instead the morph will
-# be set up and when applying it, the actual transformation will be computed on
-# the fly.
+# .. note::
+#      Since the default values of :func:`mne.compute_source_morph` are
+#      ``spacing=5, subject_to='fsaverage'``, in this example
+#      we could actually omit the ``src_to`` and ``subject_to`` arguments
+#      below. The ico-5 ``fsaverage`` source space contains the
+#      special values ``[np.arange(10242)] * 2``, but in general this will
+#      not be true for other spacings or other subjects. Thus it is recommended
+#      to always pass the destination ``src`` for consistency.
 #
 # Initialize SourceMorph for SourceEstimate
 
+src_to = mne.read_source_spaces(fname_fsaverage_src)
+print(src_to[0]['vertno'])  # special, np.arange(10242)
 morph = mne.compute_source_morph(stc, subject_from='sample',
-                                 subject_to='fsaverage',
+                                 subject_to='fsaverage', src_to=src_to,
                                  subjects_dir=subjects_dir)
 
 ###############################################################################
diff --git a/mne/morph.py b/mne/morph.py
--- a/mne/morph.py
+++ b/mne/morph.py
@@ -12,10 +12,9 @@
 
 from .fixes import _get_img_fdata
 from .parallel import parallel_func
-from .source_estimate import (VolSourceEstimate, SourceEstimate,
-                              VolVectorSourceEstimate, VectorSourceEstimate,
-                              _BaseSurfaceSourceEstimate,
-                              _BaseSourceEstimate, _get_ico_tris)
+from .source_estimate import (
+    VolSourceEstimate, _BaseSurfaceSourceEstimate,
+    _BaseVolSourceEstimate, _BaseSourceEstimate, _get_ico_tris)
 from .source_space import SourceSpaces, _ensure_src
 from .surface import read_morph_map, mesh_edges, read_surface, _compute_nearest
 from .utils import (logger, verbose, check_version, get_subjects_dir,
@@ -77,6 +76,10 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
         If None, all vertices will be used (potentially filling the
         surface). If a list, then values will be morphed to the set of
         vertices specified in in ``spacing[0]`` and ``spacing[1]``.
+        This will be ignored if ``src_to`` is supplied.
+
+        .. versionchanged:: 0.21
+           src_to, if provided, takes precedence.
     smooth : int | str | None
         Number of iterations for the smoothing of the surface data.
         If None, smooth is automatically defined to fill the surface
@@ -96,11 +99,15 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
         SourceEstimate. If True the only parameters used are subject_to and
         subject_from, and spacing has to be None. Default is sparse=False.
     src_to : instance of SourceSpaces | None
-        The destination source space, only used for volume source spaces.
-        For volumetric morph, this should be passed so that 1) the resulting
-        morph volume is properly constrained to the brain volume, and 2) STCs
-        from multiple subjects morphed to the same destination subject/source
-        space have the vertices.
+        The destination source space.
+
+        - For surface-based morphing, this is the preferred over ``spacing``
+          for providing the vertices.
+        - For volumetric morphing, this should be passed so that 1) the
+          resultingmorph volume is properly constrained to the brain volume,
+          and 2) STCs from multiple subjects morphed to the same destination
+          subject/source space have the vertices.
+        - For mixed (surface + volume) morphing, this is required.
 
         .. versionadded:: 0.20
     %(verbose)s
@@ -131,6 +138,9 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
 
     .. versionadded:: 0.17.0
 
+    .. versionadded:: 0.21.0
+       Support for morphing mixed source estimates.
+
     References
     ----------
     .. [1] Greve D. N., Van der Haegen L., Cai Q., Stufflebeam S., Sabuncu M.
@@ -162,14 +172,14 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
                          'sparse morph.')
 
     subjects_dir = get_subjects_dir(subjects_dir, raise_error=True)
+    shape = affine = pre_affine = sdr_morph = morph_mat = None
+    vertices_to_surf, vertices_to_vol = list(), list()
 
-    # VolSourceEstimate
-    if kind in ('volume',):  # XXX mixed
+    if kind in ('volume', 'mixed'):
         _check_dep(nibabel='2.1.0', dipy='0.10.1')
+        import nibabel as nib
 
         logger.info('volume source space(s) present...')
-        import nibabel as nib
-        morph_mat = vertices_to = None
 
         # load moving MRI
         mri_subpath = op.join('mri', 'brain.mgz')
@@ -190,11 +200,16 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
 
         # deal with `src_to` subsampling
         zooms_src_to = None
-        if src_to is not None:
+        if src_to is None:
+            if kind == 'mixed':
+                raise ValueError('src_to must be provided when using a '
+                                 'mixed source space')
+        else:
+            surf_offset = 2 if src_to.kind == 'mixed' else 0
             src_data['to_vox_map'] = (
-                src_to[0]['shape'], src_to[0]['src_mri_t']['trans'] *
+                src_to[-1]['shape'], src_to[-1]['src_mri_t']['trans'] *
                 np.array([[1e3, 1e3, 1e3, 1]]).T)
-            vertices_to = [src_to[0]['vertno']]
+            vertices_to_vol = [s['vertno'] for s in src_to[surf_offset:]]
             zooms_src_to = np.diag(src_data['to_vox_map'][1])[:3]
             assert (zooms_src_to[0] == zooms_src_to).all()
             zooms_src_to = tuple(zooms_src_to)
@@ -204,9 +219,8 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
         shape, zooms, affine, pre_affine, sdr_morph = _compute_morph_sdr(
             mri_from, mri_to, niter_affine, niter_sdr, zooms)
 
-    if kind in ('surface',):  # XXX mixed
+    if kind in ('surface', 'mixed'):
         logger.info('surface source space present ...')
-        shape = affine = pre_affine = sdr_morph = None
         vertices_from = src_data['vertices_from']
         if sparse:
             if spacing is not None:
@@ -215,19 +229,26 @@ def compute_source_morph(src, subject_from=None, subject_to='fsaverage',
             if xhemi:
                 raise ValueError('xhemi=True can only be used with '
                                  'sparse=False')
-            vertices_to, morph_mat = _compute_sparse_morph(
+            vertices_to_surf, morph_mat = _compute_sparse_morph(
                 vertices_from, subject_from, subject_to, subjects_dir)
         else:
-            vertices_to = grade_to_vertices(
-                subject_to, spacing, subjects_dir, 1)
+            if src_to is not None:
+                assert src_to.kind in ('surface', 'mixed')
+                vertices_to_surf = [s['vertno'].copy() for s in src_to[:2]]
+            else:
+                vertices_to_surf = grade_to_vertices(
+                    subject_to, spacing, subjects_dir, 1)
             morph_mat = _compute_morph_matrix(
                 subject_from=subject_from, subject_to=subject_to,
-                vertices_from=vertices_from, vertices_to=vertices_to,
+                vertices_from=vertices_from, vertices_to=vertices_to_surf,
                 subjects_dir=subjects_dir, smooth=smooth, warn=warn,
                 xhemi=xhemi)
-            n_verts = sum(len(v) for v in vertices_to)
+            n_verts = sum(len(v) for v in vertices_to_surf)
             assert morph_mat.shape[0] == n_verts
 
+    vertices_to = vertices_to_surf + vertices_to_vol
+    if src_to is not None:
+        assert len(vertices_to) == len(src_to)
     morph = SourceMorph(subject_from, subject_to, kind, zooms,
                         niter_affine, niter_sdr, spacing, smooth, xhemi,
                         morph_mat, vertices_to, shape, affine,
@@ -361,19 +382,22 @@ def __init__(self, subject_from, subject_to, kind, zooms,
         self.verbose = verbose
         # compute vertices_to here (partly for backward compat and no src
         # provided)
-        if vertices_to is None and kind == 'volume':
+        if vertices_to is None or len(vertices_to) == 0 and kind == 'volume':
             assert src_data['to_vox_map'] is None
-            vertices_to = self._get_vertices_nz(src_data['inuse'])
+            vertices_to = self._get_vol_vertices_to_nz()
         self.vertices_to = vertices_to
 
-    def _get_vertices_nz(self, inuse):
+    @property
+    def _vol_vertices_from(self):
+        assert isinstance(self.src_data['inuse'], list)
+        vertices_from = [np.where(in_)[0] for in_ in self.src_data['inuse']]
+        return vertices_from
+
+    def _get_vol_vertices_to_nz(self):
         logger.info('Computing nonzero vertices after morph ...')
-        assert isinstance(inuse, list)
-        vertices_from = [np.where(inuse_)[0] for inuse_ in inuse]
-        n_vertices = sum(len(v) for v in vertices_from)
-        stc_ones = VolSourceEstimate(
-            np.ones((n_vertices, 1)), vertices_from, tmin=0., tstep=1.)
-        return [np.where(self._morph_one_vol(stc_ones))[0]]
+        n_vertices = sum(len(v) for v in self._vol_vertices_from)
+        ones = np.ones((n_vertices, 1))
+        return [np.where(self._morph_one_vol(ones))[0]]
 
     @verbose
     def apply(self, stc_from, output='stc', mri_resolution=False,
@@ -402,8 +426,16 @@ def apply(self, stc_from, output='stc', mri_resolution=False,
         stc_to : VolSourceEstimate | SourceEstimate | VectorSourceEstimate | Nifti1Image | Nifti2Image
             The morphed source estimates.
         """  # noqa: E501
+        _validate_type(output, str, 'output')
         _validate_type(stc_from, _BaseSourceEstimate, 'stc_from',
-                       'SourceEstimate or VolSourceEstimate')
+                       'source estimate')
+        if isinstance(stc_from, _BaseSurfaceSourceEstimate):
+            allowed_kinds = ('stc',)
+            extra = 'when stc is a surface source estimate'
+        else:
+            allowed_kinds = ('stc', 'nifti1', 'nifti2')
+            extra = ''
+        _check_option('output', output, allowed_kinds, extra)
         stc = copy.deepcopy(stc_from)
 
         mri_space = mri_resolution if mri_space is None else mri_space
@@ -415,9 +447,6 @@ def apply(self, stc_from, output='stc', mri_resolution=False,
             raise ValueError('stc_from.subject and '
                              'morph.subject_from must match. (%s != %s)' %
                              (stc.subject, self.subject_from))
-        if not isinstance(output, str):
-            raise TypeError('output must be str, got type %s (%s)'
-                            % (type(output), output))
         out = _apply_morph_data(self, stc)
         if output != 'stc':  # convert to volume
             out = _morphed_stc_as_volume(
@@ -425,14 +454,17 @@ def apply(self, stc_from, output='stc', mri_resolution=False,
                 output=output)
         return out
 
-    def _morph_one_vol(self, stc_one):
+    def _morph_one_vol(self, one):
         # prepare data to be morphed
         # here we use mri_resolution=True, mri_space=True because
         # we will slice afterward
         from dipy.align.reslice import reslice
         from nibabel.processing import resample_from_to
         from nibabel.spatialimages import SpatialImage
-        assert stc_one.data.shape[1] == 1
+        assert isinstance(one, np.ndarray)
+        assert one.shape[1] == 1
+        stc_one = VolSourceEstimate(
+            one, self._vol_vertices_from, 0., 1., self.subject_from)
         img_to = _interpolate_data(stc_one, self, mri_resolution=True,
                                    mri_space=True, output='nifti1')
         img_to = _get_img_fdata(img_to)
@@ -592,11 +624,9 @@ def _check_dep(nibabel='2.1.0', dipy='0.10.1'):
 
 def _morphed_stc_as_volume(morph, stc, mri_resolution, mri_space, output):
     """Return volume source space as Nifti1Image and/or save to disk."""
-    if isinstance(stc, VolVectorSourceEstimate):
+    assert isinstance(stc, _BaseVolSourceEstimate)  # should be guaranteed
+    if stc._data_ndim == 3:
         stc = stc.magnitude()
-    if not isinstance(stc, VolSourceEstimate):
-        raise ValueError('Only volume source estimates can be converted to '
-                         'volumes')
     _check_dep(nibabel='2.1.0', dipy=False)
 
     NiftiImage, NiftiHeader = _triage_output(output)
@@ -666,11 +696,11 @@ def _get_src_data(src, mri_resolution=True):
         src_kind = src_t.kind
         src_subject = src_t._subject
     del src
-    _check_option('src kind', src_kind, ('surface', 'volume'))  # XXX mixed
+    _check_option('src kind', src_kind, ('surface', 'volume', 'mixed'))
 
     # extract all relevant data for volume operations
     src_data = dict()
-    if src_kind in ('volume',):  # 'mixed'):
+    if src_kind in ('volume', 'mixed'):
         use_src = src_t[-1]
         shape = use_src['shape']
         start = 0 if src_kind == 'volume' else 2
@@ -696,7 +726,7 @@ def _get_src_data(src, mri_resolution=True):
                          'inuse': inuses,
                          'to_vox_map': None,
                          })
-    if src_kind in ('surface',):  # 'mixed'):
+    if src_kind in ('surface', 'mixed'):
         src_data.update(vertices_from=[s['vertno'].copy() for s in src_t[:2]])
 
     # delete copy
@@ -731,7 +761,9 @@ def _interpolate_data(stc, morph, mri_resolution, mri_space, output):
     """Interpolate source estimate data to MRI."""
     _check_dep(nibabel='2.1.0', dipy=False)
     NiftiImage, NiftiHeader = _triage_output(output)
-    assert morph.kind == 'volume'
+    _validate_type(stc, _BaseVolSourceEstimate, 'stc',
+                   'volume source estimate')
+    assert morph.kind in ('volume', 'mixed')
 
     voxel_size_defined = False
 
@@ -752,20 +784,25 @@ def _interpolate_data(stc, morph, mri_resolution, mri_space, output):
     # stc_unmorphed.as_volume. Since only the shape of src is known, it cannot
     # be resliced to a given voxel size without knowing the original.
     if isinstance(morph, SourceSpaces):
-        assert morph.kind == 'volume'
+        assert morph.kind in ('volume', 'mixed')
+        offset = 2 if morph.kind == 'mixed' else 0
         if voxel_size_defined:
             raise ValueError(
                 "Cannot infer original voxel size for reslicing... "
                 "set mri_resolution to boolean value or apply morph first.")
         # Now deal with the fact that we may have multiple sub-volumes
-        inuse = [morph[k]['inuse'] for k in range(len(morph))]
-        src_shape = [morph[k]['shape'] for k in range(len(morph))]
+        inuse = [s['inuse'] for s in morph[offset:]]
+        src_shape = [s['shape'] for s in morph[offset:]]
         assert len(set(map(tuple, src_shape))) == 1
+        src_subject = morph._subject
         morph = BunchConst(src_data=_get_src_data(morph, mri_resolution)[0])
     else:
         # Make a list as we may have many inuse when using multiple sub-volumes
         inuse = morph.src_data['inuse']
+        src_subject = morph.subject_from
     assert isinstance(inuse, list)
+    if stc.subject is not None:
+        _check_subject_src(stc.subject, src_subject, 'stc.subject')
 
     n_times = stc.data.shape[1]
     shape = morph.src_data['src_shape'][::-1] + (n_times,)  # SAR->RAST
@@ -1220,9 +1257,13 @@ def _get_zooms_orig(morph):
 
 def _check_vertices_match(v1, v2, name):
     if not np.array_equal(v1, v2):
-        raise ValueError('vertices do not match between morph (%s) '
-                         'and stc (%s) for the %s:\n%s\n%s'
-                         % (len(v1), len(v2), name, v1, v2))
+        ext = ''
+        if np.in1d(v2, v1).all():
+            ext = ' Vertices were likely excluded during forward computation.'
+        raise ValueError(
+            'vertices do not match between morph (%s) and stc (%s) for %s:\n%s'
+            '\n%s\nPerhaps src_to=fwd["src"] needs to be passed when calling '
+            'compute_source_morph.%s' % (len(v1), len(v2), name, v1, v2, ext))
 
 
 def _apply_morph_data(morph, stc_from):
@@ -1230,55 +1271,70 @@ def _apply_morph_data(morph, stc_from):
     if stc_from.subject is not None and stc_from.subject != morph.subject_from:
         raise ValueError('stc.subject (%s) != morph.subject_from (%s)'
                          % (stc_from.subject, morph.subject_from))
-    _check_option('morph.kind', morph.kind, ('surface', 'volume'))  # XXX mixed
-    if morph.kind == 'volume':
-        # XXX needs to support vector, too
-        if isinstance(stc_from, VolSourceEstimate):
-            klass = VolSourceEstimate
-        elif isinstance(stc_from, VolVectorSourceEstimate):
-            klass = VolVectorSourceEstimate
-        else:
-            raise ValueError('stc_from was type %s but must be a volume '
-                             'source estimate' % (type(stc_from),))
-        vertices_from = [
-            np.where(inuse_)[0] for inuse_ in morph.src_data['inuse']]
-        for ii, (v1, v2) in enumerate(zip(vertices_from, stc_from.vertices)):
+    _check_option('morph.kind', morph.kind, ('surface', 'volume', 'mixed'))
+    if morph.kind == 'surface':
+        _validate_type(stc_from, _BaseSurfaceSourceEstimate, 'stc_from',
+                       'volume source estimate when using a surface morph')
+    elif morph.kind == 'volume':
+        _validate_type(stc_from, _BaseVolSourceEstimate, 'stc_from',
+                       'surface source estimate when using a volume morph')
+    else:
+        assert morph.kind == 'mixed'  # can handle any
+        _validate_type(stc_from, _BaseSourceEstimate, 'stc_from',
+                       'source estimate when using a mixed source morph')
+
+    # figure out what to actually morph
+    do_vol = not isinstance(stc_from, _BaseSurfaceSourceEstimate)
+    do_surf = not isinstance(stc_from, _BaseVolSourceEstimate)
+
+    vol_src_offset = 2 if do_surf else 0
+    from_surf_stop = sum(len(v) for v in stc_from.vertices[:vol_src_offset])
+    to_surf_stop = sum(len(v) for v in morph.vertices_to[:vol_src_offset])
+    from_vol_stop = stc_from.data.shape[0]
+    vertices_to = morph.vertices_to
+    if morph.kind == 'mixed':
+        vertices_to = vertices_to[0 if do_surf else 2:None if do_vol else 2]
+    to_vol_stop = sum(len(v) for v in vertices_to)
+
+    data_from = np.reshape(stc_from.data, (stc_from.data.shape[0], -1))
+    n_times = data_from.shape[1]  # oris treated as times
+    data = np.empty((to_vol_stop, n_times), stc_from.data.dtype)
+    to_used = np.zeros(data.shape[0], bool)
+    from_used = np.zeros(data_from.shape[0], bool)
+    if do_vol:
+        stc_from_vertices = stc_from.vertices[vol_src_offset:]
+        vertices_from = morph._vol_vertices_from
+        for ii, (v1, v2) in enumerate(zip(vertices_from, stc_from_vertices)):
             _check_vertices_match(v1, v2, 'volume[%d]' % (ii,))
-        n_times = np.prod(stc_from.data.shape[1:])
-        data = np.empty((len(morph.vertices_to[0]), n_times))
-        data_from = np.reshape(stc_from.data, (stc_from.data.shape[0], -1))
+        vol_verts = np.concatenate(
+            morph.vertices_to[0 if morph.kind == 'volume' else 2:])
+        from_sl = slice(from_surf_stop, from_vol_stop)
+        assert not from_used[from_sl].any()
+        from_used[from_sl] = True
+        to_sl = slice(to_surf_stop, to_vol_stop)
+        assert not to_used[to_sl].any()
+        to_used[to_sl] = True
         # Loop over time points to save memory
         for k in range(n_times):
-            this_stc = VolSourceEstimate(
-                data_from[:, k:k + 1], stc_from.vertices, tmin=0., tstep=1.)
-            this_img_to = morph._morph_one_vol(this_stc)
-            data[:, k] = this_img_to[morph.vertices_to[0]]
-        data.shape = (len(morph.vertices_to[0]),) + stc_from.data.shape[1:]
-    else:
-        assert morph.kind == 'surface'
-        if not isinstance(stc_from, (SourceEstimate, VectorSourceEstimate)):
-            raise ValueError('stc_from was type %s but must be a surface '
-                             'source estimate' % (type(stc_from),))
-        morph_mat = morph.morph_mat
+            this_data = data_from[from_sl, k:k + 1]
+            this_img_to = morph._morph_one_vol(this_data)
+            data[to_sl, k] = this_img_to[vol_verts]
+    if do_surf:
         for hemi, v1, v2 in zip(('left', 'right'),
                                 morph.src_data['vertices_from'],
-                                stc_from.vertices):
+                                stc_from.vertices[:2]):
             _check_vertices_match(v1, v2, '%s hemisphere' % (hemi,))
-
-        # select correct data - since vertices_to can have empty hemispheres,
-        # the correct data needs to be selected in order to apply the morph_mat
-        # correctly
-        data = stc_from.data
-        # apply morph and return new morphed instance of (Vector)SourceEstimate
-        if isinstance(stc_from, VectorSourceEstimate):
-            # Morph the locations of the dipoles, but not their orientation
-            n_verts, _, n_samples = stc_from.data.shape
-            data = morph_mat * data.reshape(n_verts, 3 * n_samples)
-            data = data.reshape(morph_mat.shape[0], 3, n_samples)
-            klass = VectorSourceEstimate
-        else:
-            data = morph_mat * data
-            klass = SourceEstimate
-    stc_to = klass(data, morph.vertices_to, stc_from.tmin, stc_from.tstep,
+        from_sl = slice(0, from_surf_stop)
+        assert not from_used[from_sl].any()
+        from_used[from_sl] = True
+        to_sl = slice(0, to_surf_stop)
+        assert not to_used[to_sl].any()
+        to_used[to_sl] = True
+        data[to_sl] = morph.morph_mat * data_from[from_sl]
+    assert to_used.all()
+    assert from_used.all()
+    data.shape = (data.shape[0],) + stc_from.data.shape[1:]
+    klass = stc_from.__class__
+    stc_to = klass(data, vertices_to, stc_from.tmin, stc_from.tstep,
                    morph.subject_to)
     return stc_to
diff --git a/mne/source_estimate.py b/mne/source_estimate.py
--- a/mne/source_estimate.py
+++ b/mne/source_estimate.py
@@ -1963,8 +1963,9 @@ def as_volume(self, src, dest='mri', mri_resolution=False,
 
         Parameters
         ----------
-        src : list
-            The list of source spaces (should all be of type volume).
+        src : instance of SourceSpaces
+            The source spaces (should all be of type volume, or part of a
+            mixed source space).
         dest : 'mri' | 'surf'
             If 'mri' the volume is defined in the coordinate system of
             the original T1 image. If 'surf' the coordinate system


Test patch:
diff --git a/mne/tests/test_morph.py b/mne/tests/test_morph.py
--- a/mne/tests/test_morph.py
+++ b/mne/tests/test_morph.py
@@ -22,7 +22,8 @@
 from mne.fixes import _get_img_fdata
 from mne.minimum_norm import (apply_inverse, read_inverse_operator,
                               make_inverse_operator)
-from mne.source_space import get_volume_labels_from_aseg
+from mne.source_space import (get_volume_labels_from_aseg, _get_mri_info_data,
+                              _get_atlas_values)
 from mne.utils import (run_tests_if_main, requires_nibabel, check_version,
                        requires_dipy, requires_h5py)
 from mne.fixes import _get_args
@@ -48,6 +49,7 @@
 fname_t1 = op.join(subjects_dir, 'sample', 'mri', 'T1.mgz')
 fname_brain = op.join(subjects_dir, 'sample', 'mri', 'brain.mgz')
 fname_aseg = op.join(subjects_dir, 'sample', 'mri', 'aseg.mgz')
+fname_aseg_fs = op.join(subjects_dir, 'fsaverage', 'mri', 'aseg.mgz')
 fname_stc = op.join(sample_dir, 'fsaverage_audvis_trunc-meg')
 
 
@@ -219,7 +221,7 @@ def test_surface_vector_source_morph(tmpdir):
     stc_surf_morphed = source_morph_surf.apply(stc_surf)
     assert isinstance(stc_surf_morphed, SourceEstimate)
     stc_vec_morphed = source_morph_surf.apply(stc_vec)
-    with pytest.raises(ValueError, match='Only volume source estimates'):
+    with pytest.raises(ValueError, match="Invalid value for the 'output'"):
         source_morph_surf.apply(stc_surf, output='nifti1')
 
     # check if correct class after morphing
@@ -244,7 +246,7 @@ def test_surface_vector_source_morph(tmpdir):
 
     # degenerate
     stc_vol = read_source_estimate(fname_vol, 'sample')
-    with pytest.raises(ValueError, match='stc_from was type'):
+    with pytest.raises(TypeError, match='stc_from must be an instance'):
         source_morph_surf.apply(stc_vol)
 
 
@@ -403,7 +405,7 @@ def test_volume_source_morph(tmpdir):
         stc_vol.as_volume(inverse_operator_vol['src'], mri_resolution=4)
 
     stc_surf = read_source_estimate(fname_stc, 'sample')
-    with pytest.raises(ValueError, match='stc_from was type'):
+    with pytest.raises(TypeError, match='stc_from must be an instance'):
         source_morph_vol.apply(stc_surf)
 
     # src_to
@@ -423,7 +425,11 @@ def test_volume_source_morph(tmpdir):
     stc_vol_bad = VolSourceEstimate(
         stc_vol.data[:-1], [stc_vol.vertices[0][:-1]],
         stc_vol.tmin, stc_vol.tstep)
-    with pytest.raises(ValueError, match='vertices do not match between morp'):
+    match = (
+        'vertices do not match between morph \\(4157\\) and stc \\(4156\\).*'
+        '\n.*\n.*\n.*Vertices were likely excluded during forward computatio.*'
+    )
+    with pytest.raises(ValueError, match=match):
         source_morph_vol.apply(stc_vol_bad)
 
 
@@ -614,4 +620,90 @@ def test_volume_labels_morph(tmpdir, sl, n_real, n_mri, n_orig):
         stc.as_volume(src, mri_resolution=True)
 
 
+@pytest.fixture(scope='session', params=[testing._pytest_param()])
+def _mixed_morph_srcs():
+    # create a mixed source space
+    labels_vol = ['Left-Cerebellum-Cortex', 'Right-Cerebellum-Cortex']
+    src = mne.setup_source_space('sample', spacing='oct3',
+                                 add_dist=False, subjects_dir=subjects_dir)
+    src += mne.setup_volume_source_space(
+        'sample', mri=fname_aseg, pos=10.0,
+        volume_label=labels_vol, subjects_dir=subjects_dir,
+        add_interpolator=True, verbose=True)
+    # create the destination space
+    src_fs = mne.read_source_spaces(
+        op.join(subjects_dir, 'fsaverage', 'bem', 'fsaverage-ico-5-src.fif'))
+    src_fs += mne.setup_volume_source_space(
+        'fsaverage', pos=7., volume_label=labels_vol,
+        subjects_dir=subjects_dir, add_interpolator=False, verbose=True)
+    del labels_vol
+
+    with pytest.raises(ValueError, match='src_to must be provided .* mixed'):
+        mne.compute_source_morph(
+            src=src, subject_from='sample', subject_to='fsaverage',
+            subjects_dir=subjects_dir)
+
+    with pytest.warns(RuntimeWarning, match='not included in smoothing'):
+        morph = mne.compute_source_morph(
+            src=src, subject_from='sample', subject_to='fsaverage',
+            subjects_dir=subjects_dir, niter_affine=[1, 0, 0],
+            niter_sdr=[1, 0, 0], src_to=src_fs, smooth=5, verbose=True)
+    return morph, src, src_fs
+
+
+@requires_nibabel()
+@requires_dipy()
+@pytest.mark.parametrize('vector', (False, True))
+def test_mixed_source_morph(_mixed_morph_srcs, vector):
+    """Test mixed source space morphing."""
+    import nibabel as nib
+    morph, src, src_fs = _mixed_morph_srcs
+    # Test some basic properties in the subject's own space
+    lut, _ = read_freesurfer_lut()
+    ids = [lut[s['seg_name']] for s in src[2:]]
+    del lut
+    vertices = [s['vertno'] for s in src]
+    n_vertices = sum(len(v) for v in vertices)
+    data = np.zeros((n_vertices, 3, 1))
+    data[:, 1] = 1.
+    klass = mne.MixedVectorSourceEstimate
+    if not vector:
+        data = data[:, 1]
+        klass = klass._scalar_class
+    stc = klass(data, vertices, 0, 1, 'sample')
+    vol_info = _get_mri_info_data(fname_aseg, data=True)
+    rrs = np.concatenate([src[2]['rr'][sp['vertno']] for sp in src[2:]])
+    n_want = np.in1d(_get_atlas_values(vol_info, rrs), ids).sum()
+    img = _get_img_fdata(stc.volume().as_volume(src, mri_resolution=False))
+    assert img.astype(bool).sum() == n_want
+    img_res = nib.load(fname_aseg)
+    n_want = np.in1d(_get_img_fdata(img_res), ids).sum()
+    img = _get_img_fdata(stc.volume().as_volume(src, mri_resolution=True))
+    assert img.astype(bool).sum() > n_want  # way more get interpolated into
+
+    with pytest.raises(TypeError, match='stc_from must be an instance'):
+        morph.apply(1.)
+
+    # Now actually morph
+    stc_fs = morph.apply(stc)
+    img = stc_fs.volume().as_volume(src_fs, mri_resolution=False)
+    vol_info = _get_mri_info_data(fname_aseg_fs, data=True)
+    rrs = np.concatenate([src_fs[2]['rr'][sp['vertno']] for sp in src_fs[2:]])
+    n_want = np.in1d(_get_atlas_values(vol_info, rrs), ids).sum()
+    with pytest.raises(ValueError, match=r'stc\.subject does not match src s'):
+        stc_fs.volume().as_volume(src, mri_resolution=False)
+    img = _get_img_fdata(
+        stc_fs.volume().as_volume(src_fs, mri_resolution=False))
+    assert img.astype(bool).sum() == n_want  # correct number of voxels
+
+    # Morph separate parts and compare to morphing the entire one
+    stc_fs_surf = morph.apply(stc.surface())
+    stc_fs_vol = morph.apply(stc.volume())
+    stc_fs_2 = stc_fs.__class__(
+        np.concatenate([stc_fs_surf.data, stc_fs_vol.data]),
+        stc_fs_surf.vertices + stc_fs_vol.vertices, stc_fs.tmin, stc_fs.tstep,
+        stc_fs.subject)
+    assert_allclose(stc_fs.data, stc_fs_2.data)
+
+
 run_tests_if_main()