Repository: mne-tools/mne-python

Issue:
[BUG] Swiss cheese structures in `mne.viz.Brain.add_volume_labels`
I'm getting structures that have a few holes in them when using the volume labels in `Brain`...
![example](https://user-images.githubusercontent.com/13473576/142739505-04811d66-c32d-43a3-ba21-6c7cac67166c.png)

I wonder if it wouldn't be too hard to implement a binary closing or something of the type but it's in VTK so I'm not really sure what's available there to close structures but I'd assume they would have something like that.


Discussion:
it has holes in the 3D grid in the first place?

It's made from marching cubes of the image so it shouldn't use a grid but it does look like the voxels of the image have holes in their segmentation
marching cubes takes a grid as input. you can maybe see if you can add
parameter so
a dilation or something can be done before the marching cubes.

it seems vtk knows how to do things like that:

https://vtk.org/doc/nightly/html/classvtkImageDilateErode3D.html

https://vtk.org/doc/nightly/html/classvtkImageDilateErode3D.html


Resolving patch:
diff --git a/mne/surface.py b/mne/surface.py
--- a/mne/surface.py
+++ b/mne/surface.py
@@ -1657,7 +1657,7 @@ def _mesh_borders(tris, mask):
     return np.unique(edges.row[border_edges])
 
 
-def _marching_cubes(image, level, smooth=0):
+def _marching_cubes(image, level, smooth=0, fill_hole_size=None):
     """Compute marching cubes on a 3D image."""
     # vtkDiscreteMarchingCubes would be another option, but it merges
     # values at boundaries which is not what we want
@@ -1669,6 +1669,7 @@ def _marching_cubes(image, level, smooth=0):
                      vtkWindowedSincPolyDataFilter, vtkDiscreteFlyingEdges3D,
                      vtkGeometryFilter, vtkDataSetAttributes, VTK_DOUBLE)
     from vtk.util import numpy_support
+    from scipy.ndimage.morphology import binary_dilation
     _validate_type(smooth, 'numeric', smooth)
     smooth = float(smooth)
     if not 0 <= smooth < 1:
@@ -1677,17 +1678,30 @@ def _marching_cubes(image, level, smooth=0):
 
     if image.ndim != 3:
         raise ValueError(f'3D data must be supplied, got {image.shape}')
-    # force double as passing integer types directly can be problematic!
-    image_shape = image.shape
-    data_vtk = numpy_support.numpy_to_vtk(
-        image.ravel(), deep=True, array_type=VTK_DOUBLE)
-    del image
+
     level = np.array(level)
     if level.ndim != 1 or level.size == 0 or level.dtype.kind not in 'ui':
         raise TypeError(
             'level must be non-empty numeric or 1D array-like of int, '
             f'got {level.ndim}D array-like of {level.dtype} with '
             f'{level.size} elements')
+
+    # fill holes
+    if fill_hole_size is not None:
+        image = image.copy()  # don't modify original
+        for val in level:
+            bin_image = image == val
+            mask = image == 0  # don't go into other areas
+            bin_image = binary_dilation(bin_image, iterations=fill_hole_size,
+                                        mask=mask)
+            image[bin_image] = val
+
+    # force double as passing integer types directly can be problematic!
+    image_shape = image.shape
+    data_vtk = numpy_support.numpy_to_vtk(
+        image.ravel(), deep=True, array_type=VTK_DOUBLE)
+    del image
+
     mc = vtkDiscreteFlyingEdges3D()
     # create image
     imdata = vtkImageData()
@@ -1704,7 +1718,7 @@ def _marching_cubes(image, level, smooth=0):
     sel_input = mc
     if smooth:
         smoother = vtkWindowedSincPolyDataFilter()
-        smoother.SetInputConnection(mc.GetOutputPort())
+        smoother.SetInputConnection(sel_input.GetOutputPort())
         smoother.SetNumberOfIterations(100)
         smoother.BoundarySmoothingOff()
         smoother.FeatureEdgeSmoothingOff()
@@ -1723,6 +1737,7 @@ def _marching_cubes(image, level, smooth=0):
         0, 0, 0, imdata.FIELD_ASSOCIATION_POINTS, dsa.SCALARS)
     geometry = vtkGeometryFilter()
     geometry.SetInputConnection(selector.GetOutputPort())
+
     out = list()
     for val in level:
         try:
diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py
--- a/mne/viz/_brain/_brain.py
+++ b/mne/viz/_brain/_brain.py
@@ -2465,7 +2465,8 @@ def remove_skull(self):
 
     @fill_doc
     def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
-                          alpha=0.5, smooth=0.9, legend=None):
+                          alpha=0.5, smooth=0.9, fill_hole_size=None,
+                          legend=None):
         """Add labels to the rendering from an anatomical segmentation.
 
         Parameters
@@ -2482,6 +2483,11 @@ def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
         alpha : float in [0, 1]
             Alpha level to control opacity.
         %(smooth)s
+        fill_hole_size : int | None
+            The size of holes to remove in the mesh in voxels. Default is None,
+            no holes are removed. Warning, this dilates the boundaries of the
+            surface by ``fill_hole_size`` number of voxels so use the minimal
+            size.
         legend : bool | None | dict
             Add a legend displaying the names of the ``labels``. Default (None)
             is ``True`` if the number of ``labels`` is 10 or fewer.
@@ -2515,6 +2521,7 @@ def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
             lut_r = {v: k for k, v in lut.items()}
             labels = [lut_r[idx] for idx in DEFAULTS['volume_label_indices']]
 
+        _validate_type(fill_hole_size, (int, None), 'fill_hole_size')
         _validate_type(legend, (bool, None), 'legend')
         if legend is None:
             legend = len(labels) < 11
@@ -2526,7 +2533,8 @@ def add_volume_labels(self, aseg='aparc+aseg', labels=None, colors=None,
         colors = [_to_rgb(color, alpha, name=f'colors[{ci}]', alpha=True)
                   for ci, color in enumerate(colors)]
         surfs = _marching_cubes(
-            aseg_data, [lut[label] for label in labels], smooth=smooth)
+            aseg_data, [lut[label] for label in labels], smooth=smooth,
+            fill_hole_size=fill_hole_size)
         for label, color, (verts, triangles) in zip(labels, colors, surfs):
             if len(verts) == 0:  # not in aseg vals
                 warn(f'Value {lut[label]} not found for label '


Test patch:
diff --git a/mne/tests/test_surface.py b/mne/tests/test_surface.py
--- a/mne/tests/test_surface.py
+++ b/mne/tests/test_surface.py
@@ -240,6 +240,12 @@ def test_marching_cubes(dtype, value, smooth):
     rtol = 1e-2 if smooth else 1e-9
     assert_allclose(verts.sum(axis=0), [14700, 14700, 14700], rtol=rtol)
     assert_allclose(triangles.sum(axis=0), [363402, 360865, 350588])
+    # test fill holes
+    data[24:27, 24:27, 24:27] = 0
+    verts, triangles = _marching_cubes(data, level, smooth=smooth,
+                                       fill_hole_size=2)[0]
+    # check that no surfaces in the middle
+    assert np.linalg.norm(verts - np.array([25, 25, 25]), axis=1).min() > 4
     # problematic values
     with pytest.raises(TypeError, match='1D array-like'):
         _marching_cubes(data, ['foo'])