Repository: nilearn/nilearn

Issue:
ENH: Surface contours on statistical maps
Hello all,
I love the nilearn surface plots for their ease of use, but often wish I could add the outline of a cortical region to the surface plot of a statistical map. I.e. like such
![Surface_Contours_1_1](https://user-images.githubusercontent.com/7125006/73030248-38fcb480-3e07-11ea-9dfc-05b81849ef16.png)

I've recently implemented a simple version of this [here](https://mjboos.github.io/Nilearn-surface-contours/). The problem is that the only way how to add such outlines is via editing the corresponding facecolors, therefore contours can never appear as a matplotlib artist and information from the original stat map will be lost.

My question is if you think it is worth adding this feature anyway, it certainly was helpful to me and could be to others. If so, I could open a PR.


Discussion:
If there is no better way to do this (looks like it is the case), I think it's worth adding the functionality (together with an example).
Thx,
I'm with Bertrand: send it along! (with tests and an addition to an
existing example).

Hi all, I will tackle this during the Nilearn dev days!
note that in the javascript plots it can be done by really drawing lines rather than changing some values in the stat map -- I would maybe start there
or actually even a line plot in matplotlib might work?
Thanks for the suggestions @jeromedockes 
I was thinking about Line3DCollection as well, but don't see a way of making it work ATM.
Not to get into too much details, but AFAIK Axes3D hard codes the assignment of the plotting order based on the minimum z-value in each collection (so Line3DCollection & trisurf's Poly3DCollection), resulting in the Poly3DCollection always being plotted last and hence hiding the countour lines.
I don't see a way of changing it without it being too hacky, but am glad if you have a suggestion.

I'll see if I can get it working in the Javascript version as well.
Ok, thanks! I had a quick IRL discussion with Moritz and it seems this
is a long-standing issue in matplotlib. In this case let's go for the
proposed solution: color some triangles of the map itself.


Resolving patch:
diff --git a/examples/01_plotting/plot_3d_map_to_surface_projection.py b/examples/01_plotting/plot_3d_map_to_surface_projection.py
--- a/examples/01_plotting/plot_3d_map_to_surface_projection.py
+++ b/examples/01_plotting/plot_3d_map_to_surface_projection.py
@@ -4,7 +4,8 @@
 
 project a 3D statistical map onto a cortical mesh using
 :func:`nilearn.surface.vol_to_surf`. Display a surface plot of the projected
-map using :func:`nilearn.plotting.plot_surf_stat_map`.
+map using :func:`nilearn.plotting.plot_surf_stat_map` and adding contours of
+regions of interest using :func:`nilearn.plotting.plot_surf_contours`.
 
 """
 
@@ -52,6 +53,38 @@
 plotting.plot_stat_map(stat_img, display_mode='x', threshold=1.,
                        cut_coords=range(0, 51, 10), title='Slices')
 
+##############################################################################
+# Use an atlas and choose regions to outline
+# ------------------------------------------
+
+import numpy as np
+
+destrieux_atlas = datasets.fetch_atlas_surf_destrieux()
+parcellation = destrieux_atlas['map_right']
+
+# these are the regions we want to outline
+regions_dict = {b'G_postcentral': 'Postcentral gyrus',
+                b'G_precentral': 'Precentral gyrus'}
+
+# get indices in atlas for these labels
+regions_indices = [np.where(np.array(destrieux_atlas['labels']) == region)[0][0]
+                   for region in regions_dict]
+
+labels = list(regions_dict.values())
+
+##############################################################################
+# Display outlines of the regions of interest on top of a statistical map
+# -----------------------------------------------------------------------
+
+figure = plotting.plot_surf_stat_map(fsaverage.infl_right, texture, hemi='right',
+                                     title='Surface right hemisphere',
+                                     colorbar=True, threshold=1.,
+                                     bg_map=fsaverage.sulc_right)
+
+plotting.plot_surf_contours(fsaverage.infl_right, parcellation, labels=labels,
+                            levels=regions_indices, figure=figure, legend=True,
+                            colors=['g', 'k'])
+plotting.show()
 
 ##############################################################################
 # Plot with higher-resolution mesh
diff --git a/nilearn/plotting/__init__.py b/nilearn/plotting/__init__.py
--- a/nilearn/plotting/__init__.py
+++ b/nilearn/plotting/__init__.py
@@ -49,7 +49,8 @@ def _set_mpl_backend():
 from .html_surface import view_surf, view_img_on_surf
 from .html_stat_map import view_img
 from .html_connectome import view_connectome, view_markers
-from .surf_plotting import plot_surf, plot_surf_stat_map, plot_surf_roi
+from .surf_plotting import (plot_surf, plot_surf_stat_map, plot_surf_roi,
+                            plot_surf_contours)
 
 __all__ = ['cm', 'plot_img', 'plot_anat', 'plot_epi',
            'plot_roi', 'plot_stat_map', 'plot_glass_brain',
@@ -58,6 +59,6 @@ def _set_mpl_backend():
            'show', 'plot_matrix', 'view_surf', 'view_img_on_surf',
            'view_img', 'view_connectome', 'view_markers',
            'find_parcellation_cut_coords', 'find_probabilistic_atlas_cut_coords',
-           'plot_surf', 'plot_surf_stat_map', 'plot_surf_roi', 
-           'plot_connectome_strength', 'plot_carpet'
+           'plot_surf', 'plot_surf_stat_map', 'plot_surf_roi',
+           'plot_connectome_strength', 'plot_carpet', 'plot_surf_contours'
            ]
diff --git a/nilearn/plotting/surf_plotting.py b/nilearn/plotting/surf_plotting.py
--- a/nilearn/plotting/surf_plotting.py
+++ b/nilearn/plotting/surf_plotting.py
@@ -10,7 +10,9 @@
 
 from matplotlib.colorbar import make_axes
 from matplotlib.cm import ScalarMappable, get_cmap
-from matplotlib.colors import Normalize, LinearSegmentedColormap
+from matplotlib.colors import Normalize, LinearSegmentedColormap, to_rgba
+from matplotlib.patches import Patch
+from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
 from ..surface import load_surf_data, load_surf_mesh
 from .img_plotting import _get_colorbar_and_data_ranges, _crop_colorbar
@@ -305,6 +307,159 @@ def plot_surf(surf_mesh, surf_map=None, bg_map=None,
         return figure
 
 
+def _get_faces_on_edge(faces, parc_idx):
+    '''
+    Internal function for identifying which faces lie on the outer
+    edge of the parcellation defined by the indices in parc_idx.
+
+    Parameters
+    ----------
+    faces: numpy.ndarray of shape (n, 3), indices of the mesh faces
+
+    parc_idx: numpy.ndarray, indices of the vertices
+        of the region to be plotted
+    '''
+    # count how many vertices belong to the given parcellation in each face
+    verts_per_face = np.isin(faces, parc_idx).sum(axis=1)
+
+    # test if parcellation forms regions
+    if np.all(verts_per_face < 2):
+        raise ValueError('Vertices in parcellation do not form region.')
+
+    vertices_on_edge = np.intersect1d(np.unique(faces[verts_per_face == 2]),
+                                      parc_idx)
+    faces_outside_edge = np.isin(faces, vertices_on_edge).sum(axis=1)
+
+    return np.logical_and(faces_outside_edge > 0, verts_per_face < 3)
+
+
+def plot_surf_contours(surf_mesh, roi_map, axes=None, figure=None, levels=None,
+                       labels=None, colors=None, legend=False, cmap='tab20',
+                       title=None, output_file=None, **kwargs):
+    """
+    Plotting contours of ROIs on a surface, optionally over a statistical map.
+
+    Parameters
+    ----------
+    surf_mesh: str or list of two numpy.ndarray
+        Surface mesh geometry, can be a file (valid formats are
+        .gii or Freesurfer specific files such as .orig, .pial,
+        .sphere, .white, .inflated) or
+        a list of two Numpy arrays, the first containing the x-y-z coordinates
+        of the mesh vertices, the second containing the indices
+        (into coords) of the mesh faces.
+
+    roi_map: str or numpy.ndarray or list of numpy.ndarray
+        ROI map to be displayed on the surface mesh, can be a file
+        (valid formats are .gii, .mgz, .nii, .nii.gz, or Freesurfer specific
+        files such as .annot or .label), or
+        a Numpy array with a value for each vertex of the surf_mesh.
+        The value at each vertex one inside the ROI and zero inside ROI, or an
+        integer giving the label number for atlases.
+
+    axes: instance of matplotlib axes, None, optional
+        The axes instance to plot to. The projection must be '3d' (e.g.,
+        `figure, axes = plt.subplots(subplot_kw={'projection': '3d'})`,
+        where axes should be passed.).
+        If None, uses axes from figure if available, else creates new axes.
+
+    figure: instance of matplotlib figure, None, optional
+        The figure instance to plot to.
+        If None, uses figure of axes if available, else creates a new figure.
+
+    levels: list of integers, or None, optional
+        A list of indices of the regions that are to be outlined.
+        Every index needs to correspond to one index in roi_map.
+        If None, all regions in roi_map are used.
+
+    labels: list of strings or None, or None, optional
+        A list of labels for the individual regions of interest.
+        Provide None as list entry to skip showing the label of that region.
+        If None no labels are used.
+
+    colors: list of matplotlib color names or RGBA values, or None.
+
+    legend: boolean,  optional
+        Whether to plot a legend of region's labels.
+
+    cmap: matplotlib colormap, str or colormap object, default is None
+        To use for plotting of the contours. Either a string
+        which is a name of a matplotlib colormap, or a matplotlib
+        colormap object.
+
+    title : str, optional
+        Figure title.
+
+    output_file: str, or None, optional
+        The name of an image file to export plot to. Valid extensions
+        are .png, .pdf, .svg. If output_file is not None, the plot
+        is saved to a file, and the display is closed.
+
+    See Also
+    --------
+    nilearn.datasets.fetch_surf_fsaverage : For surface data object to be
+        used as background map for this plotting function.
+
+    nilearn.plotting.plot_surf_stat_map : for plotting statistical maps on
+        brain surfaces.
+    """
+    if figure is None and axes is None:
+        figure = plot_surf(surf_mesh, **kwargs)
+        axes = figure.axes[0]
+    if figure is None:
+        figure = axes.get_figure()
+    if axes is None:
+        axes = figure.axes[0]
+    if axes.name != '3d':
+        raise ValueError('Axes must be 3D.')
+    # test if axes contains Poly3DCollection, if not initialize surface
+    if not axes.collections or not isinstance(axes.collections[0],
+                                              Poly3DCollection):
+        _ = plot_surf(surf_mesh, axes=axes, **kwargs)
+
+    coords, faces = load_surf_mesh(surf_mesh)
+    roi = load_surf_data(roi_map)
+    if levels is None:
+        levels = np.unique(roi_map)
+    if colors is None:
+        n_levels = len(levels)
+        vmax = n_levels
+        cmap = get_cmap(cmap)
+        norm = Normalize(vmin=0, vmax=vmax)
+        colors = [cmap(norm(color_i)) for color_i in range(vmax)]
+    else:
+        try:
+            colors = [to_rgba(color, alpha=1.) for color in colors]
+        except ValueError:
+            raise ValueError('All elements of colors need to be either a'
+                             ' matplotlib color string or RGBA values.')
+
+    if labels is None:
+        labels = [None] * len(levels)
+    if not (len(labels) == len(levels) and len(colors) == len(labels)):
+        raise ValueError('Levels, labels, and colors '
+                         'argument need to be either the same length or None.')
+
+    patch_list = []
+    for level, color, label in zip(levels, colors, labels):
+        roi_indices = np.where(roi == level)[0]
+        faces_outside = _get_faces_on_edge(faces, roi_indices)
+        axes.collections[0]._facecolors3d[faces_outside] = color
+        if label and legend:
+            patch_list.append(Patch(color=color, label=label))
+    # plot legend only if indicated and labels provided
+    if legend and np.any([lbl is not None for lbl in labels]):
+        figure.legend(handles=patch_list)
+    if title:
+        figure.suptitle(title)
+    # save figure if output file is given
+    if output_file is not None:
+        figure.savefig(output_file)
+        plt.close(figure)
+    else:
+        return figure
+
+
 def plot_surf_stat_map(surf_mesh, stat_map, bg_map=None,
                        hemi='left', view='lateral', threshold=None,
                        alpha='auto', vmax=None, cmap='cold_hot',


Test patch:
diff --git a/nilearn/plotting/tests/test_surf_plotting.py b/nilearn/plotting/tests/test_surf_plotting.py
--- a/nilearn/plotting/tests/test_surf_plotting.py
+++ b/nilearn/plotting/tests/test_surf_plotting.py
@@ -9,7 +9,7 @@
 from matplotlib import pyplot as plt
 
 from nilearn.plotting.surf_plotting import (plot_surf, plot_surf_stat_map,
-                                            plot_surf_roi)
+                                            plot_surf_roi, plot_surf_contours)
 from nilearn.surface.testing_utils import generate_surf
 
 
@@ -203,3 +203,57 @@ def test_plot_surf_roi_error():
             ValueError,
             match='roi_map does not have the same number of vertices'):
         plot_surf_roi(mesh, roi_map=roi_idx)
+
+def test_plot_surf_contours():
+    mesh = generate_surf()
+    # we need a valid parcellation for testing
+    parcellation = np.zeros((mesh[0].shape[0],))
+    parcellation[mesh[1][3]] = 1
+    parcellation[mesh[1][5]] = 2
+    plot_surf_contours(mesh, parcellation)
+    plot_surf_contours(mesh, parcellation, levels=[1, 2])
+    plot_surf_contours(mesh, parcellation, levels=[1, 2], cmap='gist_ncar')
+    plot_surf_contours(mesh, parcellation, levels=[1, 2],
+                       colors=['r', 'g'])
+    plot_surf_contours(mesh, parcellation, levels=[1, 2], colors=['r', 'g'],
+                       labels=['1', '2'])
+    fig = plot_surf_contours(mesh, parcellation, levels=[1, 2], colors=['r', 'g'],
+                       labels=['1', '2'], legend=True)
+    assert fig.legends is not None
+    plot_surf_contours(mesh, parcellation, levels=[1, 2],
+                       colors=[[0, 0, 0, 1], [1, 1, 1, 1]])
+    fig, axes = plt.subplots(1, 1, subplot_kw={'projection': '3d'})
+    plot_surf_contours(mesh, parcellation, axes=axes)
+    plot_surf_contours(mesh, parcellation, figure=fig)
+    fig = plot_surf(mesh)
+    plot_surf_contours(mesh, parcellation, figure=fig)
+    plot_surf_contours(mesh, parcellation, title='title')
+    with tempfile.NamedTemporaryFile() as tmp_file:
+        plot_surf_contours(mesh, parcellation, output_file=tmp_file.name)
+    plt.close()
+
+def test_plot_surf_contours_error():
+    mesh = generate_surf()
+    # we need an invalid parcellation for testing
+    rng = np.random.RandomState(0)
+    invalid_parcellation = rng.rand(mesh[0].shape[0])
+    parcellation = np.zeros((mesh[0].shape[0],))
+    parcellation[mesh[1][3]] = 1
+    parcellation[mesh[1][5]] = 2
+    with pytest.raises(
+            ValueError,
+            match='Vertices in parcellation do not form region.'):
+        plot_surf_contours(mesh, invalid_parcellation)
+    fig, axes = plt.subplots(1, 1)
+    with pytest.raises(
+            ValueError,
+            match='Axes must be 3D.'):
+        plot_surf_contours(mesh, parcellation, axes=axes)
+    with pytest.raises(
+            ValueError,
+            match='All elements of colors need to be either a matplotlib color string or RGBA values.'):
+        plot_surf_contours(mesh, parcellation, levels=[1, 2], colors=[[1, 2], 3])
+    with pytest.raises(
+            ValueError,
+            match='Levels, labels, and colors argument need to be either the same length or None.'):
+        plot_surf_contours(mesh, parcellation, levels=[1, 2], colors=['r'], labels=['1', '2'])