Repository: mne-tools/mne-python

Issue:
Add norm parameter to plot_topomap
I would like to plot ERD/ERS values on a topomap using `mne.viz.plot_topomap`. I can already select a suitable diverging colormap such as `RdBu_r` (red&ndash;white&ndash;blue), but my values are not symmetrical around 0 (they range from e.g. &ndash;1 to +2.5). Because I would like to map white to zero, I need to set a different [colormap normalization](https://matplotlib.org/stable/tutorials/colors/colormapnorms.html) such as the [TwoSlopeNorm](https://matplotlib.org/stable/tutorials/colors/colormapnorms.html#twoslopenorm-different-mapping-on-either-side-of-a-center). The `mne.viz.plot_topomap` function uses [imshow](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.imshow.html) internally, which already has a `norm` parameter. Therefore, we could pretty easily expose this new parameter in `plot_topomap`. I'd have to test if this really works for my use case, but in principle this seems like a promising approach. Does anyone have any objections or alternative suggestions? Otherwise I'll submit a PR.


Discussion:
Some additional information: here's code to produce a topomap with values ranging from -1 to 2.5:

```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.cm import get_cmap
import mne
from mne.viz import plot_topomap


rng = np.random.default_rng(seed=42)
v = rng.uniform(low=-1, high=2.5, size=64)
v[:3] = [-1, 0, 2.5]

montage = mne.channels.make_standard_montage("biosemi64")
info = mne.create_info(montage.ch_names, 256, "eeg").set_montage("biosemi64")
cmap = get_cmap("RdBu_r")

fig, ax = plt.subplots()
im = plot_topomap(v, info, vmin=-1, vmax=2.5, cmap=cmap, axes=ax, show=False)
cb = fig.colorbar(im[0], ax=ax)
```
<img width="752" alt="before" src="https://user-images.githubusercontent.com/4377312/121737537-4a785680-caf9-11eb-8943-faa92a9606ff.png">

Notice how white does not map to 0, but to 0.75 (the middle of the data range).

By adding a `norm` parameter, this example would look as follows:

```python
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.cm import get_cmap
from matplotlib.colors import TwoSlopeNorm
import mne
from mne.viz import plot_topomap


rng = np.random.default_rng(seed=42)
v = rng.uniform(low=-1, high=2.5, size=64)
v[:3] = [-1, 0, 2.5]

montage = mne.channels.make_standard_montage("biosemi64")
info = mne.create_info(montage.ch_names, 256, "eeg").set_montage("biosemi64")
cmap = get_cmap("RdBu_r")
norm = TwoSlopeNorm(vmin=-1, vcenter=0, vmax=2.5)

fig, ax = plt.subplots()
im = plot_topomap(v, info, vmin=-1, vmax=2.5, cmap=cmap, axes=ax, norm=None, show=False)
cb = fig.colorbar(im[0], ax=ax)
```

<img width="752" alt="after" src="https://user-images.githubusercontent.com/4377312/121737714-814e6c80-caf9-11eb-8add-abc31ccd7fde.png">

Now white correctly maps to 0.
the only alternate suggestion I have is to expose `clim` instead, and you could pass something like `clim=np.array([-1, 1]) * max(abs(data))`. That would put zero in the middle (white) but in your example the blues would be not nearly so intense.
Yes, but that's what I actually wanted to avoid. I really need to show the full color range for my asymmetrical data.
+1 for a PR, then 🙂 

Resolving patch:
diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py
--- a/mne/viz/topomap.py
+++ b/mne/viz/topomap.py
@@ -25,7 +25,8 @@
                        _pick_data_channels, _picks_to_idx, _get_channel_types,
                        _MEG_CH_TYPES_SPLIT)
 from ..utils import (_clean_names, _time_mask, verbose, logger, fill_doc,
-                     _validate_type, _check_sphere, _check_option, _is_numeric)
+                     _validate_type, _check_sphere, _check_option, _is_numeric,
+                     warn, check_version)
 from .utils import (tight_layout, _setup_vmin_vmax, _prepare_trellis,
                     _check_delayed_ssp, _draw_proj_checkbox, figure_nobar,
                     plt_show, _process_times, DraggableColorbar,
@@ -690,7 +691,7 @@ def plot_topomap(data, pos, vmin=None, vmax=None, cmap=None, sensors=True,
                  contours=6, image_interp='bilinear', show=True,
                  onselect=None, extrapolate=_EXTRAPOLATE_DEFAULT,
                  sphere=None, border=_BORDER_DEFAULT,
-                 ch_type='eeg'):
+                 ch_type='eeg', cnorm=None):
     """Plot a topographic map as image.
 
     Parameters
@@ -757,19 +758,60 @@ def plot_topomap(data, pos, vmin=None, vmax=None, cmap=None, sensors=True,
     %(topomap_border)s
     %(topomap_ch_type)s
 
+        ..versionadded:: 0.24.0
+    cnorm : matplotlib.colors.Normalize | None
+        Colormap normalization, default None means linear normalization. If not
+        None, ``vmin`` and ``vmax`` arguments are ignored. See Notes for more
+        details.
+
+        .. versionadded:: 0.24
+
     Returns
     -------
     im : matplotlib.image.AxesImage
         The interpolated data.
     cn : matplotlib.contour.ContourSet
         The fieldlines.
+
+    Notes
+    -----
+    The ``cnorm`` parameter can be used to implement custom colormap
+    normalization. By default, a linear mapping from vmin to vmax is used,
+    which correspond to the first and last colors in the colormap. This might
+    be undesired when vmin and vmax are not symmetrical around zero (or a value
+    that can be interpreted as some midpoint). For example, assume we want to
+    use the RdBu colormap (red to white to blue) for values ranging from -1 to
+    3, and 0 should be white. However, white corresponds to the midpoint in the
+    data by default, i.e. 1. Therefore, we use the following colormap
+    normalization ``cnorm`` and pass it as the the ``cnorm`` argument:
+
+        from matplotlib.colors import TwoSlopeNorm
+        cnorm = TwoSlopeNorm(vmin=-1, vcenter=0, vmax=3)
+
+    Note that because we define ``vmin`` and ``vmax`` in the normalization,
+    arguments ``vmin`` and ``vmax`` to ``plot_topomap`` will be ignored if a
+    normalization is provided. See the
+    :doc:`matplotlib docs <matplotlib:tutorials/colors/colormapnorms>`
+    for more details on colormap normalization.
     """
     sphere = _check_sphere(sphere)
+    if check_version("matplotlib", "3.2.0"):
+        from matplotlib.colors import TwoSlopeNorm
+    else:
+        from matplotlib.colors import DivergingNorm as TwoSlopeNorm
+    _validate_type(cnorm, (TwoSlopeNorm, None), 'cnorm')
+    if cnorm is not None:
+        if vmin is not None:
+            warn(f"vmin={cnorm.vmin} is implicitly defined by cnorm, ignoring "
+                 f"vmin={vmin}.")
+        if vmax is not None:
+            warn(f"vmax={cnorm.vmax} is implicitly defined by cnorm, ignoring "
+                 f"vmax={vmax}.")
     return _plot_topomap(data, pos, vmin, vmax, cmap, sensors, res, axes,
                          names, show_names, mask, mask_params, outlines,
                          contours, image_interp, show,
                          onselect, extrapolate, sphere=sphere, border=border,
-                         ch_type=ch_type)[:2]
+                         ch_type=ch_type, cnorm=cnorm)[:2]
 
 
 def _setup_interp(pos, res, extrapolate, sphere, outlines, border):
@@ -828,7 +870,8 @@ def _plot_topomap(data, pos, vmin=None, vmax=None, cmap=None, sensors=True,
                   mask_params=None, outlines='head',
                   contours=6, image_interp='bilinear', show=True,
                   onselect=None, extrapolate=_EXTRAPOLATE_DEFAULT, sphere=None,
-                  border=_BORDER_DEFAULT, ch_type='eeg'):
+                  border=_BORDER_DEFAULT, ch_type='eeg', cnorm=None):
+    from matplotlib.colors import Normalize
     import matplotlib.pyplot as plt
     from matplotlib.widgets import RectangleSelector
     data = np.asarray(data)
@@ -915,9 +958,10 @@ def _plot_topomap(data, pos, vmin=None, vmax=None, cmap=None, sensors=True,
     patch_ = _get_patch(outlines, extrapolate, interp, ax)
 
     # plot interpolated map
-    im = ax.imshow(Zi, cmap=cmap, vmin=vmin, vmax=vmax, origin='lower',
-                   aspect='equal', extent=extent,
-                   interpolation=image_interp)
+    if cnorm is None:
+        cnorm = Normalize(vmin=vmin, vmax=vmax)
+    im = ax.imshow(Zi, cmap=cmap, origin='lower', aspect='equal',
+                   extent=extent, interpolation=image_interp, norm=cnorm)
 
     # gh-1432 had a workaround for no contours here, but we'll remove it
     # because mpl has probably fixed it


Test patch:
diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py
--- a/mne/viz/tests/test_topomap.py
+++ b/mne/viz/tests/test_topomap.py
@@ -24,7 +24,7 @@
 from mne.io.constants import FIFF
 from mne.io.pick import pick_info, channel_indices_by_type
 from mne.io.compensator import get_current_comp
-from mne.channels import read_layout, make_dig_montage
+from mne.channels import read_layout, make_dig_montage, make_standard_montage
 from mne.datasets import testing
 from mne.time_frequency.tfr import AverageTFR
 
@@ -32,7 +32,7 @@
 from mne.viz.topomap import (_get_pos_outlines, _onselect, plot_topomap,
                              plot_arrowmap, plot_psds_topomap)
 from mne.viz.utils import _find_peaks, _fake_click
-from mne.utils import requires_sklearn
+from mne.utils import requires_sklearn, check_version
 
 
 data_dir = testing.data_path(download=False)
@@ -602,3 +602,32 @@ def test_plot_cov_topomap():
     cov.plot_topomap(info)
     cov.plot_topomap(info, noise_cov=cov)
     plt.close('all')
+
+
+def test_plot_topomap_cnorm():
+    """Test colormap normalization."""
+    if check_version("matplotlib", "3.2.0"):
+        from matplotlib.colors import TwoSlopeNorm
+    else:
+        from matplotlib.colors import DivergingNorm as TwoSlopeNorm
+
+    np.random.seed(42)
+    v = np.random.uniform(low=-1, high=2.5, size=64)
+    v[:3] = [-1, 0, 2.5]
+
+    montage = make_standard_montage("biosemi64")
+    info = create_info(montage.ch_names, 256, "eeg").set_montage("biosemi64")
+    cnorm = TwoSlopeNorm(vmin=-1, vcenter=0, vmax=2.5)
+
+    # pass only cnorm, no vmin/vmax
+    plot_topomap(v, info, cnorm=cnorm)
+
+    # pass cnorm and vmin
+    msg = "vmin=-1 is implicitly defined by cnorm, ignoring vmin=-10."
+    with pytest.warns(RuntimeWarning, match=msg):
+        plot_topomap(v, info, vmin=-10, cnorm=cnorm)
+
+    # pass cnorm and vmax
+    msg = "vmax=2.5 is implicitly defined by cnorm, ignoring vmax=10."
+    with pytest.warns(RuntimeWarning, match=msg):
+        plot_topomap(v, info, vmax=10, cnorm=cnorm)