Repository: mne-tools/mne-python

Issue:
BUG: compute_source_morph does not work well with a rescaling
The tests added in #8447 exposed some bug with our `compute_source_morph` when dealing with the output of `scale_mri`. In principle the `compute_source_morph` should just detect and undo the scaling but it doesn't. This is true both of uniform and non-uniform scalings in `scale_mri`.

The thing to do is probably to first add a test that `compute_source_morph` for a volume to itself is nearly an identity transform, then test locally where things go wrong. Perhaps something about the order translation->rigid body->full affine is wrong, or we need to allow a greater exploration of the space (somehow) because for a scaled MRI the only one of these that should happen is the full affine (including the scale factors is only possible there), but the intermediate outputs in `verbose=True` mode suggests that there are translations and rotations being imposed...


Discussion:

Resolving patch:
diff --git a/mne/morph.py b/mne/morph.py
--- a/mne/morph.py
+++ b/mne/morph.py
@@ -220,8 +220,6 @@ 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
-            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
@@ -526,25 +524,57 @@ def _morph_vols(self, vols, mesg, subselect=True):
         # morph data
         for ii in ProgressBar(list(range(n_vols)), mesg=mesg):
             for attr in attrs:
+                # transform from source space to mri_from resolution/space
                 if vols is None:
                     img_real = interp[:, ii].toarray()
                 else:
                     img_real = interp @ getattr(vols[:, ii], attr)
                 img_real = img_real.reshape(
-                    self.src_data['src_shape_full'], order='F')
-                # reslice to match morph
-                img_real, _ = reslice(
-                    img_real, self.affine,
-                    _get_zooms_orig(self), self.zooms)
+                    self.src_data['src_shape_full'][::-1], order='F')
+                from_affine = np.dot(
+                    self.src_data['src_affine_ras'],  # mri_ras_t
+                    self.src_data['src_affine_vox'])  # vox_mri_t
+                from_affine[:3] *= 1000.
+                _debug_img(img_real, from_affine, 'From')
+
+                # reslice to match what was used during the morph
+                # (brain.mgz and whatever was used to create the source space
+                #  will not necessarily have the same domain/zooms)
+                img_real = _resample_from_to(
+                    img_real, from_affine,
+                    (self.pre_affine.codomain_shape,
+                     self.pre_affine.codomain_grid2world))
+                _debug_img(img_real, self.pre_affine.codomain_grid2world,
+                           'From-reslice')
 
                 img_real = self.pre_affine.transform(img_real)
                 if self.sdr_morph is not None:
                     img_real = self.sdr_morph.transform(img_real)
+                _debug_img(img_real, self.affine, 'From-reslice-transform')
 
                 # subselect the correct cube if src_to is provided
                 if self.src_data['to_vox_map'] is not None:
+                    affine = self.affine
+                    to_zooms = np.diag(self.src_data['to_vox_map'][1])[:3]
+                    if not np.allclose(self.zooms, to_zooms, atol=1e-3):
+                        img_real, affine = reslice(
+                            img_real, self.affine, self.zooms, to_zooms)
+                    _debug_img(img_real, affine,
+                               'From-reslice-transform-src')
                     img_real = _resample_from_to(
-                        img_real, self.affine, self.src_data['to_vox_map'])
+                        img_real, affine, self.src_data['to_vox_map'])
+                    _debug_img(img_real, self.src_data['to_vox_map'][1],
+                               'From-reslice-transform-src-subselect')
+
+                # This can be used to help debug, but it really should just
+                # show the brain filling the volume:
+                # img_want = np.zeros(np.prod(img_real.shape))
+                # img_want[np.concatenate(self._vol_vertices_to)] = 1.
+                # img_want = np.reshape(
+                #     img_want, self.src_data['src_shape'][::-1], order='F')
+                # _debug_img(img_want, self.src_data['to_vox_map'][1],
+                #            'To mask')
+                # raise RuntimeError('Check')
 
                 # combine real and complex parts
                 img_real = img_real.ravel(order='F')[vol_verts]
@@ -608,6 +638,18 @@ def save(self, fname, overwrite=False, verbose=None):
         write_hdf5(fname, out_dict, overwrite=overwrite)
 
 
+_slicers = list()
+
+
+def _debug_img(data, affine, title):
+    # XXX uncomment these lines for debugging help with volume morph
+    # import nibabel as nib
+    # _slicers.append(nib.viewers.OrthoSlicer3D(
+    #     np.asarray(data), affine, axes=None, title=title))
+    # _slicers[-1].figs[0].suptitle(title, color='r')
+    return
+
+
 def _check_zooms(mri_from, zooms, zooms_src_to):
     # use voxel size of mri_from
     if isinstance(zooms, str) and zooms == 'auto':
@@ -621,12 +663,6 @@ def _check_zooms(mri_from, zooms, zooms_src_to):
         raise ValueError('zooms must be None, a singleton, or have shape (3,),'
                          ' got shape %s' % (zooms.shape,))
     zooms = tuple(zooms)
-    if zooms_src_to is not None:
-        if not np.allclose(zooms_src_to, zooms, atol=1e-6):
-            raise ValueError('If src_to is provided, zooms should be "auto" '
-                             'or match the src_to zooms (%s), got %s'
-                             % (zooms_src_to, zooms))
-        zooms = zooms_src_to
     return zooms
 
 
@@ -741,7 +777,7 @@ def _morphed_stc_as_volume(morph, stc, mri_resolution, mri_space, output):
 
     # setup empty volume
     if morph.src_data['to_vox_map'] is not None:
-        shape = morph.src_data['to_vox_map'][0]
+        shape = morph.src_data['to_vox_map'][0][::-1]
         affine = morph.src_data['to_vox_map'][1]
     else:
         shape = morph.shape
@@ -948,34 +984,24 @@ def _compute_r2(a, b):
 
 def _compute_morph_sdr(mri_from, mri_to, niter_affine, niter_sdr, zooms):
     """Get a matrix that morphs data from one subject to another."""
-    import nibabel as nib
     with np.testing.suppress_warnings():
         from dipy.align import imaffine, imwarp, metrics, transforms
     from dipy.align.reslice import reslice
 
     logger.info('Computing nonlinear Symmetric Diffeomorphic Registration...')
 
-    # reslice mri_from
-    mri_from_res, mri_from_res_affine = reslice(
-        _get_img_fdata(mri_from), mri_from.affine,
-        mri_from.header.get_zooms()[:3], zooms)
-
-    with warnings.catch_warnings():  # nibabel<->numpy warning
-        mri_from = nib.Nifti1Image(mri_from_res, mri_from_res_affine)
-
-    # reslice mri_to
-    mri_to_res, mri_to_res_affine = reslice(
-        _get_img_fdata(mri_to), mri_to.affine, mri_to.header.get_zooms()[:3],
-        zooms)
+    # reslice mri_from to zooms
+    mri_from_orig = mri_from
+    mri_from, mri_from_affine = reslice(
+        _get_img_fdata(mri_from_orig), mri_from_orig.affine,
+        mri_from_orig.header.get_zooms()[:3], zooms)
 
-    with warnings.catch_warnings():  # nibabel<->numpy warning
-        mri_to = nib.Nifti1Image(mri_to_res, mri_to_res_affine)
+    # reslice mri_to to zooms
+    mri_to, affine = reslice(
+        _get_img_fdata(mri_to), mri_to.affine,
+        mri_to.header.get_zooms()[:3], zooms)
 
-    affine = mri_to.affine
-    mri_to = _get_img_fdata(mri_to).copy()  # to ndarray
     mri_to /= mri_to.max()
-    mri_from_affine = mri_from.affine  # get mri_from to world transform
-    mri_from = _get_img_fdata(mri_from)  # to ndarray
     mri_from /= mri_from.max()  # normalize
 
     # compute center of mass
@@ -1034,14 +1060,10 @@ def _compute_morph_sdr(mri_from, mri_to, niter_affine, niter_sdr, zooms):
         sdr_morph = None
 
     logger.info(f'    R²:          {_compute_r2(mri_to, mri_from_to):6.1f}%')
-
-    # To debug to_vox_map, this can be used:
-    # from nibabel.processing import resample_from_to
-    # mri_from_to = sdr_morph.transform(pre_affine.transform(mri_from))
-    # mri_from_to = nib.Nifti1Image(mri_from_to, affine)
-    # fig1 = mri_from_to.orthoview()
-    # mri_from_to_cut = resample_from_to(mri_from_to, to_vox_map, 1)
-    # fig2 = mri_from_to_cut.orthoview()
+    _debug_img(mri_from_orig.dataobj, mri_from_orig.affine, 'From')
+    _debug_img(mri_from, affine, 'From-reslice')
+    _debug_img(mri_from_to, affine, 'From-reslice')
+    _debug_img(mri_to, affine, 'To-reslice')
     return shape, zooms, affine, pre_affine, sdr_morph
 
 
@@ -1360,7 +1382,8 @@ def _get_zooms_orig(morph):
     """Compute src zooms from morph zooms, morph shape and src shape."""
     # zooms_to = zooms_from / shape_to * shape_from for each spatial dimension
     return [mz / ss * ms for mz, ms, ss in
-            zip(morph.zooms, morph.shape, morph.src_data['src_shape_full'])]
+            zip(morph.zooms, morph.shape,
+                morph.src_data['src_shape_full'][::-1])]
 
 
 def _check_vertices_match(v1, v2, name):


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
@@ -432,13 +432,6 @@ def test_volume_source_morph(tmpdir):
         source_morph_vol.apply(stc_surf)
 
     # src_to
-    # zooms=20 does not match src_to zooms (7)
-    with pytest.raises(ValueError, match='If src_to is provided, zooms shoul'):
-        source_morph_vol = compute_source_morph(
-            fwd['src'], subject_from='sample', src_to=fwd['src'],
-            subject_to='sample', subjects_dir=subjects_dir, **kwargs)
-    # hack the src_to "zooms" to make it seem like a pos=20. source space
-    fwd['src'][0]['src_mri_t']['trans'][:3, :3] = 0.02 * np.eye(3)
     source_morph_vol = compute_source_morph(
         fwd['src'], subject_from='sample', src_to=fwd['src'],
         subject_to='sample', subjects_dir=subjects_dir, **kwargs)
@@ -463,10 +456,10 @@ def test_volume_source_morph(tmpdir):
 @testing.requires_testing_data
 @pytest.mark.parametrize(
     'subject_from, subject_to, lower, upper, dtype, morph_mat', [
-        ('sample', 'fsaverage', 8.5, 9, float, False),
-        ('fsaverage', 'fsaverage', 7, 7.5, float, False),
-        ('sample', 'sample', 6, 7, complex, False),
-        ('sample', 'sample', 1, 2, float, True),  # morph_mat
+        ('sample', 'fsaverage', 5.9, 6.1, float, False),
+        ('fsaverage', 'fsaverage', 0., 0.1, float, False),
+        ('sample', 'sample', 0., 0.1, complex, False),
+        ('sample', 'sample', 0., 0.1, float, True),  # morph_mat
         ('sample', 'fsaverage', 10, 12, float, True),  # morph_mat
     ])
 def test_volume_source_morph_round_trip(
@@ -539,6 +532,7 @@ def test_volume_source_morph_round_trip(
     src_rr = np.concatenate([s['rr'][s['vertno']] for s in src_from])
     dists = 1000 * np.linalg.norm(src_rr[use] - src_rr[maxs], axis=1)
     mu = np.mean(dists)
+    # fsaverage=5.99; 7.97 without additional src_ras_t fix
     # fsaverage=7.97; 25.4 without src_ras_t fix
     assert lower <= mu < upper, f'round-trip distance {mu}'
     # check that pre_affine is close to identity when subject_to==subject_from
@@ -549,9 +543,9 @@ def test_volume_source_morph_round_trip(
     # check that power is more or less preserved (labelizing messes with this)
     if morph_mat:
         if subject_to == 'fsaverage':
-            limits = (19, 20)
+            limits = (18, 18.5)
         else:
-            limits = (8, 9)
+            limits = (7, 7.5)
     else:
         limits = (1, 1.2)
     stc_from_unit = stc_from.copy().crop(0, 0)
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
@@ -29,7 +29,7 @@
                  MixedVectorSourceEstimate, setup_volume_source_space,
                  convert_forward_solution, pick_types_forward,
                  compute_source_morph, labels_to_stc, scale_mri,
-                 read_freesurfer_lut, write_source_spaces)
+                 write_source_spaces)
 from mne.datasets import testing
 from mne.externals.h5io import write_hdf5
 from mne.fixes import fft, _get_img_fdata, nullcontext
@@ -1592,8 +1592,9 @@ def _make_morph_map_hemi_same(subject_from, subject_to, subjects_dir,
 
 @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)))
+    'surface',
+))
+@pytest.mark.parametrize('scale', ((1.0, 0.8, 1.2), 1., 0.9))
 def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
     """Test label extraction, morphing, and MRI scaling relationships."""
     tempdir = str(tmpdir)
@@ -1632,18 +1633,15 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
     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)
+        src_from = read_source_spaces(fname_src_vol)
+        src_from[0]['subject_his_id'] = subject_from
         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
+        assert src_from[0]['nuse'] == 4157
         write_source_spaces(
             op.join(from_dir, 'bem', 'sample-vol20-src.fif'), src_from)
     scale_mri(subject_from, subject_to, scale, subjects_dir=tempdir,
@@ -1687,7 +1685,7 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
         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
+        assert 0.93 < corr < 0.95
 
     #
     # 2. Changing STC subject to the surrogate and then extracting
@@ -1702,8 +1700,8 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
     # 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
+        ctx = nullcontext()
+        test_morph = True
     elif kind == 'surface':
         ctx = pytest.warns(RuntimeWarning, match='not included')
         test_morph = True
@@ -1713,7 +1711,19 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
     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)
+            subjects_dir=tempdir, niter_sdr=(), smooth=1,
+            zooms=14., verbose=True)  # speed up with higher zooms
+    if kind == 'volume':
+        got_affine = morph.pre_affine.affine
+        want_affine = np.eye(4)
+        want_affine.ravel()[::5][:3] = 1. / np.array(scale, float)
+        # just a scaling (to within 1% if zooms=None, 20% with zooms=10)
+        assert_allclose(want_affine[:, :3], got_affine[:, :3], atol=2e-1)
+        assert got_affine[3, 3] == 1.
+        # little translation (to within `limit` mm)
+        move = np.linalg.norm(got_affine[:3, 3])
+        limit = 2. if scale == 1. else 12
+        assert move < limit, scale
     if test_morph:
         stc_to = morph.apply(stc)
         label_tc_to_morph = extract_label_time_course(
@@ -1721,11 +1731,17 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
         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
+            if isinstance(scale, tuple):
+                # some other fixed constant
+                # min_, max_ = 0.84, 0.855  # zooms='auto' values
+                min_, max_ = 0.57, 0.67
+            elif scale == 1:
+                # min_, max_ = 0.85, 0.875  # zooms='auto' values
+                min_, max_ = 0.72, 0.75
             else:
-                # XXX even just a pure scaling does not work very well, bug...
-                assert -0.1 < corr < 0.1
+                # min_, max_ = 0.84, 0.855  # zooms='auto' values
+                min_, max_ = 0.61, 0.62
+            assert min_ < corr <= max_, scale
         else:
             assert_allclose(
                 label_tc, label_tc_to_morph, atol=1e-12, rtol=1e-12)
@@ -1741,4 +1757,4 @@ def test_scale_morph_labels(kind, scale, monkeypatch, tmpdir):
         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
+        assert 0.93 < corr < 0.96, scale