Repository: mne-tools/mne-python

Issue:
Coreg GUI doesn't start if only high-resolution, but no low-resolution skin surface is available
Reported on the forum: https://mne.discourse.group/t/mne-coreg-issues

```python
Using pyvistaqt 3d backend.

    Triangle neighbors and vertex normals...
Using high resolution head model in /media/dip_linux/Transcend/Visual_p3_data/3847/surf/lh.seghead
Traceback (most recent call last):
  File "/home/dip_linux/anaconda3/envs/mne/bin/mne", line 8, in <module>
    sys.exit(main())
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/commands/utils.py", line 107, in main
    cmd.run()
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/commands/mne_coreg.py", line 85, in run
    mne.gui.coregistration(
  File "<decorator-gen-558>", line 12, in coregistration
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/gui/__init__.py", line 193, in coregistration
    return CoregistrationUI(
  File "<decorator-gen-570>", line 12, in __init__
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/gui/_coreg.py", line 238, in __init__
    self.coreg = Coregistration(
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/coreg.py", line 1371, in __init__
    self._setup_bem()
  File "/home/dip_linux/anaconda3/envs/mne/lib/python3.10/site-packages/mne/coreg.py", line 1426, in _setup_bem
    'mesh (%d vertices): %s' % (len(self._bem_high_res.surf.rr),
AttributeError: 'dict' object has no attribute 'surf'
```

Workaround was to run `make_scalp_surfaces()` to create a low-res scalp


Discussion:

Resolving patch:
diff --git a/mne/conftest.py b/mne/conftest.py
--- a/mne/conftest.py
+++ b/mne/conftest.py
@@ -21,6 +21,7 @@
 import mne
 from mne import read_events, pick_types, Epochs
 from mne.channels import read_layout
+from mne.coreg import create_default_subject
 from mne.datasets import testing
 from mne.fixes import has_numba, _compare_version
 from mne.io import read_raw_fif, read_raw_ctf
@@ -225,6 +226,7 @@ def matplotlib_config():
         plt.rcParams['figure.raise_window'] = False
     except KeyError:  # MPL < 3.3
         pass
+
     # Make sure that we always reraise exceptions in handlers
     orig = cbook.CallbackRegistry
 
@@ -567,6 +569,23 @@ def subjects_dir_tmp(tmp_path):
     return str(tmp_path)
 
 
+@pytest.fixture(params=[testing._pytest_param()])
+def subjects_dir_tmp_few(tmp_path):
+    """Copy fewer files to a tmp_path."""
+    subjects_path = tmp_path / 'subjects'
+    os.mkdir(subjects_path)
+    # add fsaverage
+    create_default_subject(subjects_dir=subjects_path, fs_home=test_path,
+                           verbose=True)
+    # add sample (with few files)
+    sample_path = subjects_path / 'sample'
+    os.makedirs(sample_path / 'bem')
+    for dirname in ('mri', 'surf'):
+        shutil.copytree(
+            test_path / 'subjects' / 'sample' / dirname, sample_path / dirname)
+    return subjects_path
+
+
 # Scoping these as session will make things faster, but need to make sure
 # not to modify them in-place in the tests, so keep them private
 @pytest.fixture(scope='session', params=[testing._pytest_param()])
diff --git a/mne/coreg.py b/mne/coreg.py
--- a/mne/coreg.py
+++ b/mne/coreg.py
@@ -1423,11 +1423,11 @@ def _setup_bem(self):
         if low_res_path is None:
             # This should be very rare!
             warn('No low-resolution head found, decimating high resolution '
-                 'mesh (%d vertices): %s' % (len(self._bem_high_res.surf.rr),
+                 'mesh (%d vertices): %s' % (len(self._bem_high_res['rr']),
                                              high_res_path,))
             # Create one from the high res one, which we know we have
-            rr, tris = decimate_surface(self._bem_high_res.surf.rr,
-                                        self._bem_high_res.surf.tris,
+            rr, tris = decimate_surface(self._bem_high_res['rr'],
+                                        self._bem_high_res['tris'],
                                         n_triangles=5120)
             # directly set the attributes of bem_low_res
             self._bem_low_res = complete_surface_info(
diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py
--- a/mne/gui/_coreg.py
+++ b/mne/gui/_coreg.py
@@ -339,7 +339,9 @@ def _set_subjects_dir(self, subjects_dir):
             subjects = _get_subjects(subjects_dir)
             low_res_path = _find_head_bem(
                 subjects[0], subjects_dir, high_res=False)
-            valid = low_res_path is not None
+            high_res_path = _find_head_bem(
+                subjects[0], subjects_dir, high_res=True)
+            valid = low_res_path is not None or high_res_path is not None
         except Exception:
             valid = False
         if valid:


Test patch:
diff --git a/mne/gui/tests/test_coreg_gui.py b/mne/gui/tests/test_coreg_gui.py
--- a/mne/gui/tests/test_coreg_gui.py
+++ b/mne/gui/tests/test_coreg_gui.py
@@ -2,13 +2,15 @@
 #
 # License: BSD-3-Clause
 
+from contextlib import nullcontext
 import os.path as op
+import os
 
 import pytest
-import warnings
 from numpy.testing import assert_allclose
 import numpy as np
 
+import mne
 from mne.datasets import testing
 from mne.io import read_info
 from mne.io.kit.tests import data_dir as kit_data_dir
@@ -88,12 +90,13 @@ def test_coreg_gui_pyvista_file_support(inst_path, tmp_path,
         inst_path = tmp_path / 'tmp-dig.fif'
         dig.save(inst_path)
 
-    # Suppressing warnings here is not ideal.
-    # However ctf_raw_path (catch-alp-good-f.ds) is poorly formed and causes
-    # mne.io.read_raw to issue warning.
-    # XXX consider replacing ctf_raw_path and removing warning ignore filter.
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore")
+    if inst_path == ctf_raw_path:
+        ctx = pytest.warns(RuntimeWarning, match='MEG ref channel RMSP')
+    elif inst_path == snirf_nirsport2_raw_path:  # TODO: This is maybe a bug?
+        ctx = pytest.warns(RuntimeWarning, match='assuming "head"')
+    else:
+        ctx = nullcontext()
+    with ctx:
         coregistration(inst=inst_path, subject='sample',
                        subjects_dir=subjects_dir)
 
@@ -286,3 +289,23 @@ def test_coreg_gui_notebook(renderer_notebook, nbexec):
         data_path = testing.data_path(download=False)
     subjects_dir = os.path.join(data_path, 'subjects')
     coregistration(subject='sample', subjects_dir=subjects_dir)
+
+
+@pytest.mark.slowtest
+def test_no_sparse_head(subjects_dir_tmp, renderer_interactive_pyvistaqt,
+                        monkeypatch):
+    """Test mne.gui.coregistration with no sparse head."""
+    from mne.gui import coregistration
+    subject = 'sample'
+    out_rr, out_tris = mne.read_surface(
+        op.join(subjects_dir_tmp, subject, 'bem', 'outer_skin.surf'))
+    for head in ('sample-head.fif', 'outer_skin.surf'):
+        os.remove(op.join(subjects_dir_tmp, subject, 'bem', head))
+    # Avoid actually doing the decimation (it's slow)
+    monkeypatch.setattr(
+        mne.coreg, 'decimate_surface',
+        lambda rr, tris, n_triangles: (out_rr, out_tris))
+    with pytest.warns(RuntimeWarning, match='No low-resolution head found'):
+        coreg = coregistration(
+            inst=raw_path, subject=subject, subjects_dir=subjects_dir_tmp)
+    coreg.close()
diff --git a/mne/tests/test_coreg.py b/mne/tests/test_coreg.py
--- a/mne/tests/test_coreg.py
+++ b/mne/tests/test_coreg.py
@@ -2,7 +2,7 @@
 from glob import glob
 import os
 import os.path as op
-from shutil import copyfile, copytree
+from shutil import copyfile
 
 import pytest
 import numpy as np
@@ -167,21 +167,11 @@ def test_scale_mri(tmp_path, few_surfaces, scale):
 @pytest.mark.slowtest  # can take forever on OSX Travis
 @testing.requires_testing_data
 @requires_nibabel()
-def test_scale_mri_xfm(tmp_path, few_surfaces):
+def test_scale_mri_xfm(tmp_path, few_surfaces, subjects_dir_tmp_few):
     """Test scale_mri transforms and MRI scaling."""
     # scale fsaverage
-    tempdir = str(tmp_path)
-    fake_home = data_path
-    # add fsaverage
-    create_default_subject(subjects_dir=tempdir, fs_home=fake_home,
-                           verbose=True)
-    # add sample (with few files)
-    sample_dir = op.join(tempdir, 'sample')
-    os.mkdir(sample_dir)
-    os.mkdir(op.join(sample_dir, 'bem'))
-    for dirname in ('mri', 'surf'):
-        copytree(op.join(fake_home, 'subjects', 'sample', dirname),
-                 op.join(sample_dir, dirname))
+    tempdir = str(subjects_dir_tmp_few)
+    sample_dir = subjects_dir_tmp_few / 'sample'
     subject_to = 'flachkopf'
     spacing = 'oct2'
     for subject_from in ('fsaverage', 'sample'):