Repository: nilearn/nilearn

Issue:
Add function for creating "carpet plots"

![image](https://user-images.githubusercontent.com/238759/34458010-db79fd22-ed77-11e7-844b-7af9b6c01c48.png)


Potentially ported from MRIQC (see https://github.com/poldracklab/mriqc/issues/674)


Discussion:
Thx for the suggestion.
* In the plot, you suggest to rely on a voxel-based representation, but for large number of voxels (10^4-10^6) this will not render nicely visually. Do you implicitly suggest to rely on some super-voxel representation ?
* what does the colorbar on left side mean ?
The Y axis is mislabeled. It should say "parcells" or "regions" since voxels are averaged to reduce dimensionality. The colorbar indicates different parcells and their class (cortical, subcortical, CSF). The legend could be improved. More resources:
- original paper describing this visualization: http://sci-hub.tw/10.1016/j.neuroimage.2016.08.009
- accompanying videos and MATLAB code: https://www.jonathanpower.net/2017-ni-the-plot.html

For nilearn I was thinking about the following function signature:

`plot_carpet(img, parcellation_img)`

where `img` will have to be and the `parcellation_img` needs to be in the same space as `img`. WDYT?
Sounds reasonable to me. Additionally 'parcellation_img' would be optional. Additionally,
'False' would mean no reduction, and an int value (k) would compute a parcellation to k supervoxels  on-the-fly .
PR welcome !
I'm planning to send a PR.

What parcellation should be used if none is provided?
You can simply rely on Ward clustering (with e.g. n_regions=1000).
I was actually incorrect. Each line of the plot corresponds to one voxel (within EPI mask). The segmentation/parcellation is only used to distinguish cortical and subcortical voxels. Ward parcellation cannot help with that, but it's questionable how useful that subdivision is.

Resolving patch:
diff --git a/examples/01_plotting/plot_carpet.py b/examples/01_plotting/plot_carpet.py
new file mode 100644
--- /dev/null
+++ b/examples/01_plotting/plot_carpet.py
@@ -0,0 +1,40 @@
+"""
+Visualizing global patterns with a carpet plot
+==============================================
+
+A common quality control step for functional MRI data is to visualize the data
+over time in a carpet plot (also known as a Power plot or a grayplot).
+
+The :func:`nilearn.plotting.plot_carpet()` function generates a carpet plot
+from a 4D functional image.
+"""
+
+###############################################################################
+# Fetching data from ADHD dataset
+# -------------------------------
+from nilearn import datasets
+
+adhd_dataset = datasets.fetch_adhd(n_subjects=1)
+
+# Print basic information on the dataset
+print('First subject functional nifti image (4D) is at: %s' %
+      adhd_dataset.func[0])  # 4D data
+
+###############################################################################
+# Deriving a mask
+# ---------------
+from nilearn import masking
+
+# Build an EPI-based mask because we have no anatomical data
+mask_img = masking.compute_epi_mask(adhd_dataset.func[0])
+
+###############################################################################
+# Visualizing global patterns over time
+# -------------------------------------
+import matplotlib.pyplot as plt
+
+from nilearn.plotting import plot_carpet
+
+display = plot_carpet(adhd_dataset.func[0], mask_img)
+
+display.show()
diff --git a/nilearn/plotting/__init__.py b/nilearn/plotting/__init__.py
--- a/nilearn/plotting/__init__.py
+++ b/nilearn/plotting/__init__.py
@@ -42,7 +42,7 @@ def _set_mpl_backend():
 from . import cm
 from .img_plotting import plot_img, plot_anat, plot_epi, \
     plot_roi, plot_stat_map, plot_glass_brain, plot_connectome, \
-    plot_connectome_strength, plot_prob_atlas, show
+    plot_connectome_strength, plot_prob_atlas, plot_carpet, show
 from .find_cuts import find_xyz_cut_coords, find_cut_slices, \
     find_parcellation_cut_coords, find_probabilistic_atlas_cut_coords
 from .matrix_plotting import plot_matrix
@@ -58,5 +58,5 @@ 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_surf', 'plot_surf_stat_map', 'plot_surf_roi', 'plot_carpet'
            ]
diff --git a/nilearn/plotting/img_plotting.py b/nilearn/plotting/img_plotting.py
--- a/nilearn/plotting/img_plotting.py
+++ b/nilearn/plotting/img_plotting.py
@@ -22,6 +22,7 @@
 from scipy import sparse
 from nibabel.spatialimages import SpatialImage
 
+from ..signal import clean
 from .._utils.numpy_conversions import as_ndarray
 from .._utils.niimg import _safe_get_data
 
@@ -29,15 +30,14 @@
 import matplotlib.pyplot as plt
 
 from .. import _utils
-from ..image import new_img_like
 from .._utils.extmath import fast_abs_percentile
 from .._utils.param_validation import check_threshold
 from .._utils.ndimage import get_border_data
 from ..datasets import load_mni152_template
-from ..image import iter_img
+from ..image import new_img_like, iter_img, get_data
+from ..masking import compute_epi_mask, apply_mask
 from .displays import get_slicer, get_projector
 from . import cm
-from nilearn.image import get_data
 
 
 def show():
@@ -1463,3 +1463,135 @@ def plot_connectome_strength(adjacency_matrix, node_coords, node_size="auto",
         display = None
 
     return display
+
+
+def plot_carpet(img, mask_img=None, detrend=True, output_file=None,
+                figure=None, axes=None, vmin=None, vmax=None, title=None):
+    """Plot an image representation of voxel intensities across time.
+
+    This figure is also known as a "grayplot" or "Power plot".
+
+    Parameters
+    ----------
+    img : Niimg-like object
+        4D image.
+        See http://nilearn.github.io/manipulating_images/input_output.html.
+    mask_img : Niimg-like object or None, optional
+        Limit plotted voxels to those inside the provided mask (default is
+        None). If not specified a new mask will be derived from data.
+        See http://nilearn.github.io/manipulating_images/input_output.html.
+    detrend : :obj:`bool`, optional
+        Detrend and z-score the data prior to plotting (default is `True`).
+    output_file : :obj:`str` or None, optional
+        The name of an image file to which to export the plot (default is
+        None). Valid extensions are .png, .pdf, and .svg.
+        If `output_file` is not None, the plot is saved to a file, and the
+        display is closed.
+    figure : :class:`matplotlib.figure.Figure` or None, optional
+        Matplotlib figure used (default is None).
+        If None is given, a new figure is created.
+    axes : matplotlib axes or None, optional
+        The axes used to display the plot (default is None).
+        If None, the complete figure is used.
+    title : :obj:`str` or None, optional
+        The title displayed on the figure (default is None).
+
+    Returns
+    -------
+    figure : :class:`matplotlib.figure.Figure`
+        Figure object with carpet plot.
+
+    Notes
+    -----
+    This figure was originally developed in [1]_.
+
+    In cases of long acquisitions (>800 volumes), the data will be downsampled
+    to have fewer than 800 volumes before being plotted.
+
+    References
+    ----------
+    .. [1] Power, J. D. (2017). A simple but useful way to assess fMRI scan
+            qualities. Neuroimage, 154, 150-158. doi:
+            https://doi.org/10.1016/j.neuroimage.2016.08.009
+    """
+    img = _utils.check_niimg_4d(img, dtype='auto')
+
+    # Define TR and number of frames
+    tr = img.header.get_zooms()[-1]
+    n_tsteps = img.shape[-1]
+
+    if mask_img is None:
+        mask_img = compute_epi_mask(img)
+    else:
+        mask_img = _utils.check_niimg_3d(mask_img, dtype='auto')
+
+    data = apply_mask(img, mask_img)
+    # Detrend and standardize data
+    if detrend:
+        data = clean(data, t_r=tr, detrend=True, standardize='zscore')
+
+    if figure is None:
+        if not axes:
+            figsize = (10, 5)
+            figure = plt.figure(figsize=figsize)
+        else:
+            figure = axes.figure
+
+    if axes is None:
+        axes = figure.add_subplot(1, 1, 1)
+    else:
+        assert axes.figure is figure, ("The axes passed are not "
+                                       "in the figure")
+
+    # Determine vmin and vmax based on the full data
+    std = np.mean(data.std(axis=0))
+    default_vmin = data.mean() - (2 * std)
+    default_vmax = data.mean() + (2 * std)
+
+    # Avoid segmentation faults for long acquisitions by decimating the data
+    LONG_CUTOFF = 800
+    # Get smallest power of 2 greater than the number of volumes divided by the
+    # cutoff, to determine how much to decimate (downsample) the data.
+    n_decimations = int(np.ceil(np.log2(np.ceil(n_tsteps / LONG_CUTOFF))))
+    data = data[::2 ** n_decimations, :]
+
+    axes.imshow(data.T, interpolation='nearest',
+                aspect='auto', cmap='gray',
+                vmin=vmin or default_vmin,
+                vmax=vmax or default_vmax)
+
+    axes.grid(False)
+    axes.set_yticks([])
+    axes.set_yticklabels([])
+
+    # Set 10 frame markers in X axis
+    interval = max(
+        (int(data.shape[0] + 1) // 10, int(data.shape[0] + 1) // 5, 1))
+    xticks = list(range(0, data.shape[0])[::interval])
+    axes.set_xticks(xticks)
+
+    axes.set_xlabel('time (s)')
+    axes.set_ylabel('voxels')
+    if title:
+        axes.set_title(title)
+    labels = tr * (np.array(xticks))
+    labels *= (2 ** n_decimations)
+    axes.set_xticklabels(['%.02f' % t for t in labels.tolist()])
+
+    # Remove and redefine spines
+    for side in ['top', 'right']:
+        # Toggle the spine objects
+        axes.spines[side].set_color('none')
+        axes.spines[side].set_visible(False)
+
+    axes.yaxis.set_ticks_position('left')
+    axes.xaxis.set_ticks_position('bottom')
+    axes.spines['bottom'].set_position(('outward', 20))
+    axes.spines['left'].set_position(('outward', 20))
+
+    if output_file is not None:
+        figure.savefig(output_file)
+        plt.close(figure)
+        figure = None
+
+    return figure


Test patch:
diff --git a/nilearn/plotting/tests/test_img_plotting.py b/nilearn/plotting/tests/test_img_plotting.py
--- a/nilearn/plotting/tests/test_img_plotting.py
+++ b/nilearn/plotting/tests/test_img_plotting.py
@@ -22,7 +22,7 @@
                                            plot_roi, plot_stat_map, plot_epi,
                                            plot_glass_brain, plot_connectome,
                                            plot_connectome_strength,
-                                           plot_prob_atlas,
+                                           plot_prob_atlas, plot_carpet,
                                            _get_colorbar_and_data_ranges)
 
 mni_affine = np.array([[-2.,    0.,    0.,   90.],
@@ -31,12 +31,35 @@
                        [0.,    0.,    0.,    1.]])
 
 
-def _generate_img():
+@pytest.fixture()
+def testdata_3d():
+    """A random 3D image for testing figures.
+    """
     data_positive = np.zeros((7, 7, 3))
     rng = np.random.RandomState(42)
     data_rng = rng.rand(7, 7, 3)
     data_positive[1:-1, 2:-1, 1:] = data_rng[1:-1, 2:-1, 1:]
-    return nibabel.Nifti1Image(data_positive, mni_affine)
+    img_3d = nibabel.Nifti1Image(data_positive, mni_affine)
+    data = {
+        'img': img_3d,
+    }
+    return data
+
+
+@pytest.fixture()
+def testdata_4d():
+    """Random 4D images for testing figures for multivolume data.
+    """
+    rng = np.random.RandomState(42)
+    img_4d = nibabel.Nifti1Image(rng.rand(7, 7, 3, 10), mni_affine)
+    img_4d_long = nibabel.Nifti1Image(rng.rand(7, 7, 3, 1777), mni_affine)
+    img_mask = nibabel.Nifti1Image(np.ones((7, 7, 3), int), mni_affine)
+    data = {
+        'img_4d': img_4d,
+        'img_4d_long': img_4d_long,
+        'img_mask': img_mask,
+    }
+    return data
 
 
 def demo_plot_roi(**kwargs):
@@ -70,8 +93,8 @@ def test_demo_plot_roi():
     assert out is None
 
 
-def test_plot_anat():
-    img = _generate_img()
+def test_plot_anat(testdata_3d):
+    img = testdata_3d['img']
 
     # Test saving with empty plot
     z_slicer = plot_anat(anat_img=False, display_mode='z')
@@ -99,15 +122,25 @@ def test_plot_anat():
     plt.close()
 
 
-def test_plot_functions():
-    img = _generate_img()
+def test_plot_functions(testdata_3d, testdata_4d):
+    img_3d = testdata_3d['img']
+    img_4d = testdata_4d['img_4d']
+    img_4d_mask = testdata_4d['img_mask']
 
-    # smoke-test for each plotting function with default arguments
+    # smoke-test for 3D plotting functions with default arguments
     for plot_func in [plot_anat, plot_img, plot_stat_map, plot_epi,
                       plot_glass_brain]:
         filename = tempfile.mktemp(suffix='.png')
         try:
-            plot_func(img, output_file=filename)
+            plot_func(img_3d, output_file=filename)
+        finally:
+            os.remove(filename)
+
+    # smoke-test for 4D plotting functions with default arguments
+    for plot_func in [plot_carpet]:
+        filename = tempfile.mktemp(suffix='.png')
+        try:
+            plot_func(img_4d, mask_img=img_4d_mask, output_file=filename)
         finally:
             os.remove(filename)
 
@@ -115,7 +148,7 @@ def test_plot_functions():
     ax = plt.subplot(111, rasterized=True)
     filename = tempfile.mktemp(suffix='.png')
     try:
-        plot_stat_map(img, symmetric_cbar=True,
+        plot_stat_map(img_3d, symmetric_cbar=True,
                       output_file=filename,
                       axes=ax, vmax=np.nan)
     finally:
@@ -123,8 +156,8 @@ def test_plot_functions():
     plt.close()
 
 
-def test_plot_glass_brain():
-    img = _generate_img()
+def test_plot_glass_brain(testdata_3d):
+    img = testdata_3d['img']
 
     # test plot_glass_brain with colorbar
     plot_glass_brain(img, colorbar=True, resampling_interpolation='nearest')
@@ -141,8 +174,8 @@ def test_plot_glass_brain():
     plt.close()
 
 
-def test_plot_stat_map():
-    img = _generate_img()
+def test_plot_stat_map(testdata_3d):
+    img = testdata_3d['img']
 
     plot_stat_map(img, cut_coords=(80, -120, -60))
 
@@ -171,7 +204,7 @@ def test_plot_stat_map():
     plt.close()
 
 
-def test_plot_stat_map_threshold_for_affine_with_rotation():
+def test_plot_stat_map_threshold_for_affine_with_rotation(testdata_3d):
     # threshold was not being applied when affine has a rotation
     # see https://github.com/nilearn/nilearn/issues/599 for more details
     data = np.random.randn(10, 10, 10)
@@ -193,7 +226,7 @@ def test_plot_stat_map_threshold_for_affine_with_rotation():
     plt.close()
 
 
-def test_plot_stat_map_threshold_for_uint8():
+def test_plot_stat_map_threshold_for_uint8(testdata_3d):
     # mask was applied in [-threshold, threshold] which is problematic
     # for uint8 data. See https://github.com/nilearn/nilearn/issues/611
     # for more details
@@ -219,7 +252,7 @@ def test_plot_stat_map_threshold_for_uint8():
     plt.close()
 
 
-def test_plot_glass_brain_threshold_for_uint8():
+def test_plot_glass_brain_threshold_for_uint8(testdata_3d):
     # mask was applied in [-threshold, threshold] which is problematic
     # for uint8 data. See https://github.com/nilearn/nilearn/issues/611
     # for more details
@@ -245,8 +278,40 @@ def test_plot_glass_brain_threshold_for_uint8():
     plt.close()
 
 
-def test_save_plot():
-    img = _generate_img()
+def test_plot_carpet(testdata_4d):
+    """Check contents of plot_carpet figure against data in image.
+    """
+    img_4d = testdata_4d['img_4d']
+    img_4d_long = testdata_4d['img_4d_long']
+    mask_img = testdata_4d['img_mask']
+    display = plot_carpet(img_4d, mask_img, detrend=False, title='TEST')
+    # Next two lines retrieve the numpy array from the plot
+    ax = display.axes[0]
+    plotted_array = ax.images[0].get_array()
+    assert plotted_array.shape == (np.prod(img_4d.shape[:-1]), img_4d.shape[-1])
+    # Make sure that the values in the figure match the values in the image
+    np.testing.assert_almost_equal(
+        plotted_array.sum(),
+        img_4d.get_fdata().sum(),
+        decimal=3
+    )
+    # Save execution time and memory
+    plt.close(display)
+
+    fig, ax = plt.subplots()
+    display = plot_carpet(img_4d_long, mask_img, detrend=True, title='TEST',
+                          figure=fig, axes=ax)
+    # Next two lines retrieve the numpy array from the plot
+    ax = display.axes[0]
+    plotted_array = ax.images[0].get_array()
+    # Check size
+    n_items = (np.prod(img_4d_long.shape[:-1]) * np.ceil(img_4d_long.shape[-1] / 4))
+    assert plotted_array.size == n_items
+    plt.close(display)
+
+
+def test_save_plot(testdata_3d):
+    img = testdata_3d['img']
 
     kwargs_list = [{}, {'display_mode': 'x', 'cut_coords': 3}]
 
@@ -269,8 +334,8 @@ def test_save_plot():
         plt.close()
 
 
-def test_display_methods():
-    img = _generate_img()
+def test_display_methods(testdata_3d):
+    img = testdata_3d['img']
 
     display = plot_img(img)
     display.add_overlay(img, threshold=0)
@@ -279,8 +344,8 @@ def test_display_methods():
                          colors=['limegreen', 'yellow'])
 
 
-def test_plot_with_axes_or_figure():
-    img = _generate_img()
+def test_plot_with_axes_or_figure(testdata_3d):
+    img = testdata_3d['img']
     figure = plt.figure()
     plot_img(img, figure=figure)
 
@@ -291,9 +356,9 @@ def test_plot_with_axes_or_figure():
     plt.close()
 
 
-def test_plot_stat_map_colorbar_variations():
+def test_plot_stat_map_colorbar_variations(testdata_3d):
     # This is only a smoke test
-    img_positive = _generate_img()
+    img_positive = testdata_3d['img']
     data_positive = get_data(img_positive)
     rng = np.random.RandomState(42)
     data_negative = -data_positive
@@ -312,7 +377,7 @@ def test_plot_stat_map_colorbar_variations():
             plt.close()
 
 
-def test_plot_empty_slice():
+def test_plot_empty_slice(testdata_3d):
     # Test that things don't crash when we give a map with nothing above
     # threshold
     # This is only a smoke test
@@ -324,13 +389,13 @@ def test_plot_empty_slice():
     plt.close()
 
 
-def test_plot_img_invalid():
+def test_plot_img_invalid(testdata_3d):
     # Check that we get a meaningful error message when we give a wrong
     # display_mode argument
     pytest.raises(Exception, plot_anat, display_mode='zzz')
 
 
-def test_plot_img_with_auto_cut_coords():
+def test_plot_img_with_auto_cut_coords(testdata_3d):
     data = np.zeros((20, 20, 20))
     data[3:-3, 3:-3, 3:-3] = 1
     img = nibabel.Nifti1Image(data, np.eye(4))
@@ -343,8 +408,8 @@ def test_plot_img_with_auto_cut_coords():
         plt.close()
 
 
-def test_plot_img_with_resampling():
-    data = get_data(_generate_img())
+def test_plot_img_with_resampling(testdata_3d):
+    data = get_data(testdata_3d['img'])
     affine = np.array([[1., -1.,  0.,  0.],
                        [1.,  1.,  0.,  0.],
                        [0.,  0.,  1.,  0.],
@@ -975,8 +1040,8 @@ def test_get_colorbar_and_data_ranges_masked_array():
     assert cbar_vmax == None
 
 
-def test_invalid_in_display_mode_cut_coords_all_plots():
-    img = _generate_img()
+def test_invalid_in_display_mode_cut_coords_all_plots(testdata_3d):
+    img = testdata_3d['img']
     for plot_func in [plot_img, plot_anat, plot_roi, plot_epi,
                       plot_stat_map, plot_prob_atlas, plot_glass_brain]:
         with pytest.raises(ValueError,
@@ -987,8 +1052,8 @@ def test_invalid_in_display_mode_cut_coords_all_plots():
             plot_func(img, display_mode='ortho', cut_coords=2)
 
 
-def test_invalid_in_display_mode_tiled_cut_coords_single_all_plots():
-    img = _generate_img()
+def test_invalid_in_display_mode_tiled_cut_coords_single_all_plots(testdata_3d):
+    img = testdata_3d['img']
 
     for plot_func in [plot_img, plot_anat, plot_roi, plot_epi,
                       plot_stat_map, plot_prob_atlas]:
@@ -1000,8 +1065,8 @@ def test_invalid_in_display_mode_tiled_cut_coords_single_all_plots():
             plot_func(img, display_mode='tiled', cut_coords=2)
 
 
-def test_invalid_in_display_mode_tiled_cut_coords_all_plots():
-    img = _generate_img()
+def test_invalid_in_display_mode_tiled_cut_coords_all_plots(testdata_3d):
+    img = testdata_3d['img']
 
     for plot_func in [plot_img, plot_anat, plot_roi, plot_epi,
                       plot_stat_map, plot_prob_atlas]:
@@ -1036,8 +1101,8 @@ def test_outlier_cut_coords():
                   bg_img=bg_img)
 
 
-def test_plot_stat_map_with_nans():
-    img = _generate_img()
+def test_plot_stat_map_with_nans(testdata_3d):
+    img = testdata_3d['img']
     data = get_data(img)
 
     data[6, 5, 1] = np.nan
@@ -1065,8 +1130,8 @@ def test_plotting_functions_with_cmaps():
     plt.close()
 
 
-def test_plotting_functions_with_nans_in_bg_img():
-    bg_img = _generate_img()
+def test_plotting_functions_with_nans_in_bg_img(testdata_3d):
+    bg_img = testdata_3d['img']
     bg_data = get_data(bg_img)
 
     bg_data[6, 5, 1] = np.nan
@@ -1078,17 +1143,17 @@ def test_plotting_functions_with_nans_in_bg_img():
     plot_anat(bg_img)
     # test with plot_roi passing background image which contains nans values
     # in it
-    roi_img = _generate_img()
+    roi_img = testdata_3d['img']
     plot_roi(roi_img=roi_img, bg_img=bg_img)
-    stat_map_img = _generate_img()
+    stat_map_img = testdata_3d['img']
     plot_stat_map(stat_map_img=stat_map_img, bg_img=bg_img)
 
     plt.close()
 
 
-def test_plotting_functions_with_dim_invalid_input():
+def test_plotting_functions_with_dim_invalid_input(testdata_3d):
     # Test whether error raises with bad error to input
-    img = _generate_img()
+    img = testdata_3d['img']
     pytest.raises(ValueError, plot_stat_map, img, dim='-10')
 
 
@@ -1099,16 +1164,16 @@ def test_add_markers_using_plot_glass_brain():
     fig.close()
 
 
-def test_plotting_functions_with_display_mode_tiled():
-    img = _generate_img()
+def test_plotting_functions_with_display_mode_tiled(testdata_3d):
+    img = testdata_3d['img']
     plot_stat_map(img, display_mode='tiled')
     plot_anat(display_mode='tiled')
     plot_img(img, display_mode='tiled')
     plt.close()
 
 
-def test_display_methods_with_display_mode_tiled():
-    img = _generate_img()
+def test_display_methods_with_display_mode_tiled(testdata_3d):
+    img = testdata_3d['img']
     display = plot_img(img, display_mode='tiled')
     display.add_overlay(img, threshold=0)
     display.add_edges(img, color='c')
@@ -1116,8 +1181,8 @@ def test_display_methods_with_display_mode_tiled():
                          colors=['limegreen', 'yellow'])
 
 
-def test_plot_glass_brain_colorbar_having_nans():
-    img = _generate_img()
+def test_plot_glass_brain_colorbar_having_nans(testdata_3d):
+    img = testdata_3d['img']
     data = get_data(img)
 
     data[6, 5, 2] = np.inf