Repository: mne-tools/mne-python

Issue:
Diverging colormaps for scalp plots: Always have "white" as zero
# Problem description

I want to compare scalp maps of ERPs across three different tasks. To allow for comparability between the subplots, they all need to share the same colorbar, which I can achieve by the following:

1. find the maximum value across all tasks and the minimum value across all tasks
2. pass max and min from step 1. into the `vmin` and `vmax` arguments when plotting

The result is as follows (note the colorbar):

![figure_1](https://user-images.githubusercontent.com/9084751/47966885-7dabfc80-e057-11e8-8062-250b3cb3ca90.png)

The problem is, that the white color of the "RdBu_r" colormap should be centered on zero, but this is not the case, as soon as `vmin != vmax`.

A workaround is to perform an intermediate step between 1. and 2. from above:

1. find the maximum value across all tasks and the minimum value across all tasks
2. find the absolute maximum of the max and min identified in step 1.
3. pass abs_max from step 2. into both the `vmin` and `vmax` arguments when plotting

The result is as follows (note the colorbar ... `vmin == vmax`):

![figure_2](https://user-images.githubusercontent.com/9084751/47966986-9963d280-e058-11e8-9cac-76fd077534ca.png)


While it is great to have the white color centered on the zero value, here the problem is that we lose some of the color range into one direction of the scale. In this example, this is visible in the red colors of the positive scale ... dark red is at 6 ... but the maximum is actually 4, so dark red is never reached.

# Potential solutions

One solution to get the full color range and still the zero value centered on white color is described here (also provides a minimal working example to play around with):

http://chris35wills.github.io/matplotlib_diverging_colorbar/

Basically, it is possible by subclassing `mpl.color.Normalize`

```python
# set the colormap and centre the colorbar
class MidpointNormalize(mpl.colors.Normalize):
    """Normalise the colorbar."""
    def __init__(self, vmin=None, vmax=None, midpoint=None, clip=False):
        self.midpoint = midpoint
        mpl.colors.Normalize.__init__(self, vmin, vmax, clip)

    def __call__(self, value, clip=None):
        x, y = [self.vmin, self.midpoint, self.vmax], [0, 0.5, 1]
        return np.ma.masked_array(np.interp(value, x, y), np.isnan(value))

```

... and then passing this class as an argument to `plt.imshow`'s parameter called `norm`:

`    im = ax.imshow(mydata, vmin=mymin, vmax=mymax, norm=MidpointNormalize(mymin, mymax, 0.))`

What are your thoughts on implementing something like this in MNE-Python? How have other people solved this problem?

cc @cbrnr @jona-sassenhagen 






Discussion:
Also see https://github.com/matplotlib/matplotlib/issues/11377
Yes, I need that all the time, and I have included a function in my [ERDS example](https://github.com/mne-tools/mne-python/blob/master/examples/time_frequency/plot_time_frequency_erds.py#L54), which doesn't subclass and is pretty simple. If it is needed in more places, we could make it available somewhere in the `viz` package.
@jona-sassenhagen is Clemens' function also a solution for your problem?

At least for my part it's exactly what I regularly need and I'd be +1 for including it in `mne.viz`.

Edit: Just saw the following note linked at a [matplotlib example](https://matplotlib.org/users/colormapnorms.html#custom-normalization-two-linear-ranges):

> As above, non-symmetric mapping of data to color is non-standard practice for quantitative data, and should only be used advisedly

What are your thoughts on using non-symmetric mapping in the case of ERPs?
I think our zero should be white :)

Clemens‘ function isn’t really something I’d do myself, but if you can convince Alex, sure. Feel free to also add it to Eg plot_erpimage. 
Feel free to use the other approach, but I think we agree that something should be added.
> I think our zero should be white :)

I agree - my question was targeting whether a colorbar SHOULD be symmetric ... or can be asymmetric, see:
![image](https://user-images.githubusercontent.com/9084751/47988435-7da50e80-e0e2-11e8-8efa-840c9a879edb.png)

Apparently, it is "a bad idea" to have them asymmetric, see the note in [here](https://matplotlib.org/users/colormapnorms.html#custom-normalization-two-linear-ranges). I was wondering whether you ( all MNE people/scientists) also think that it's a bad idea to use asymmetric colorbar limits as shown in the upper panel of the example.

> Clemens‘ function isn’t really something I’d do myself

Why not?


> Feel free to use the other approach, but I think we agree that something should be added.

Yes, let's figure out the best way to do it

In some cases, asymmetric color bars make sense. For example, ERDS values are bounded by -100% in the negative direction, but are unbounded in the positive direction. I usually choose a sensible upper bound, e.g. 250%, because most values lie within that range. I still want 0% to be white, so in the case of ERDS asymmetric ranges totally make sense.

I'm not sure about ERPs though - there is no inherent reason why one direction is scaled differently than the other. In your example, it looks more like an interpolation artifact because these negative values occur at the outermost electrodes.
Ok, I guess my intuition would be that truncation of the colorbar is ok, but for gaussian data like MEEG, a nonlinear colormap is not. I strongly prefer the second image.
I also prefer the second image. But preferences aside, I think that it's fine for advanced users to be able to do this sort of tweaking when they know what they are doing -- but that we should not promote it because a) it's likely to be abused more often than used properly and b) complicates the code base.

What I we could consider doing is adding the asymmetric cmap conversion function in https://github.com/mne-tools/mne-python/blob/master/examples/time_frequency/plot_time_frequency_erds.py#L54 to `mne.viz.utils` with tests, and then use it in the example. That's a sort of in-between that allows us not to pollute our `viz` functions with more arguments, but also facilitates code reuse and simplifies things for advanced users.
@cbrnr do you want to do it?
Sure, I'll go ahead and make a PR.

Resolving patch:
diff --git a/examples/time_frequency/plot_time_frequency_erds.py b/examples/time_frequency/plot_time_frequency_erds.py
--- a/examples/time_frequency/plot_time_frequency_erds.py
+++ b/examples/time_frequency/plot_time_frequency_erds.py
@@ -43,33 +43,12 @@
 
 import numpy as np
 import matplotlib.pyplot as plt
-from matplotlib.colors import LinearSegmentedColormap
 import mne
 from mne.datasets import eegbci
 from mne.io import concatenate_raws, read_raw_edf
 from mne.time_frequency import tfr_multitaper
 from mne.stats import permutation_cluster_1samp_test as pcluster_test
-
-
-def center_cmap(cmap, vmin, vmax):
-    """Center given colormap (ranging from vmin to vmax) at value 0.
-
-    Note that eventually this could also be achieved by re-normalizing a given
-    colormap by subclassing matplotlib.colors.Normalize as described here:
-    https://matplotlib.org/users/colormapnorms.html#custom-normalization-two-linear-ranges
-    """  # noqa: E501
-    vzero = abs(vmin) / (vmax - vmin)
-    index_old = np.linspace(0, 1, cmap.N)
-    index_new = np.hstack([np.linspace(0, vzero, cmap.N // 2, endpoint=False),
-                           np.linspace(vzero, 1, cmap.N // 2)])
-    cdict = {"red": [], "green": [], "blue": [], "alpha": []}
-    for old, new in zip(index_old, index_new):
-        r, g, b, a = cmap(old)
-        cdict["red"].append((new, r, r))
-        cdict["green"].append((new, g, g))
-        cdict["blue"].append((new, b, b))
-        cdict["alpha"].append((new, a, a))
-    return LinearSegmentedColormap("erds", cdict)
+from mne.viz.utils import center_cmap
 
 
 # load and preprocess data ####################################################
diff --git a/mne/viz/utils.py b/mne/viz/utils.py
--- a/mne/viz/utils.py
+++ b/mne/viz/utils.py
@@ -7,6 +7,7 @@
 #          Eric Larson <larson.eric.d@gmail.com>
 #          Mainak Jas <mainak@neuro.hut.fi>
 #          Stefan Appelhoff <stefan.appelhoff@mailbox.org>
+#          Clemens Brunner <clemens.brunner@gmail.com>
 #
 # License: Simplified BSD
 
@@ -2731,3 +2732,48 @@ def _plot_masked_image(ax, data, times, mask=None, picks=None, yvals=None,
         t_end = ")"
 
     return im, t_end
+
+
+def center_cmap(cmap, vmin, vmax, name="cmap_centered"):
+    """Center given colormap (ranging from vmin to vmax) at value 0.
+
+    Parameters
+    ----------
+    cmap : matplotlib.colors.Colormap
+        The colormap to center around 0.
+    vmin : float
+        Minimum value in the data to map to the lower end of the colormap.
+    vmax : float
+        Maximum value in the data to map to the upper end of the colormap.
+    name : str
+        Name of the new colormap. Defaults to 'cmap_centered'.
+
+    Returns
+    -------
+    cmap_centered : matplotlib.colors.Colormap
+        The new colormap centered around 0.
+
+    Notes
+    -----
+    This function can be used in situations where vmin and vmax are not
+    symmetric around zero. Normally, this results in the value zero not being
+    mapped to white anymore in many colormaps. Using this function, the value
+    zero will be mapped to white even for asymmetric positive and negative
+    value ranges. Note that this could also be achieved by re-normalizing a
+    given colormap by subclassing matplotlib.colors.Normalize as described
+    here:
+    https://matplotlib.org/users/colormapnorms.html#custom-normalization-two-linear-ranges
+    """  # noqa: E501
+    from matplotlib.colors import LinearSegmentedColormap
+
+    vzero = abs(vmin) / float(vmax - vmin)
+    index_old = np.linspace(0, 1, cmap.N)
+    index_new = np.hstack([np.linspace(0, vzero, cmap.N // 2, endpoint=False),
+                           np.linspace(vzero, 1, cmap.N // 2)])
+
+    colors = "red", "green", "blue", "alpha"
+    cdict = {name: [] for name in colors}
+    for old, new in zip(index_old, index_new):
+        for color, name in zip(cmap(old), colors):
+            cdict[name].append((new, color, color))
+    return LinearSegmentedColormap(name, cdict)


Test patch:
diff --git a/mne/viz/tests/test_utils.py b/mne/viz/tests/test_utils.py
--- a/mne/viz/tests/test_utils.py
+++ b/mne/viz/tests/test_utils.py
@@ -10,7 +10,7 @@
 
 from mne.viz.utils import (compare_fiff, _fake_click, _compute_scalings,
                            _validate_if_list_of_axes, _get_color_list,
-                           _setup_vmin_vmax)
+                           _setup_vmin_vmax, center_cmap)
 from mne.viz import ClickableImage, add_background_image, mne_analyze_colormap
 from mne.utils import run_tests_if_main
 from mne.io import read_raw_fif
@@ -161,4 +161,22 @@ def test_validate_if_list_of_axes():
     plt.close('all')
 
 
+def test_center_cmap():
+    """Test centering of colormap."""
+    import matplotlib.cm as cm
+    from matplotlib.colors import LinearSegmentedColormap
+    from matplotlib.pyplot import Normalize
+    cmap = center_cmap(cm.get_cmap("RdBu"), -5, 10)
+
+    assert isinstance(cmap, LinearSegmentedColormap)
+
+    # get new colors for values -5 (red), 0 (white), and 10 (blue)
+    new_colors = cmap(Normalize(-5, 10)([-5, 0, 10]))
+    # get original colors for 0 (red), 0.5 (white), and 1 (blue)
+    reference = cm.RdBu([0., 0.5, 1.])
+    assert_allclose(new_colors, reference)
+    # new and old colors at 0.5 must be different
+    assert not np.allclose(cmap(0.5), reference[1])
+
+
 run_tests_if_main()