Skip to content

Simplify normalization of multiple images #11380

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/api/next_api_changes/2018-07-09-TH.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added data argument to `.Normalize`
-----------------------------------

`.Normalize` got a data keyword argument to easy generation of normalized
plots. `Normalize(data=d)` is equivalent to
`Normalize(vmin=np.min(d), vmax=np.max(d))`. For an example see
:doc:`examples/images_contours_and_fields/multi_image.py`.
100 changes: 74 additions & 26 deletions examples/images_contours_and_fields/multi_image.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,93 @@
"""
===========
Multi Image
===========
==================================
Multiple images sharing a colorbar
==================================

Make a set of images with a single colormap, norm, and colorbar.
"""
A colorbar is always connected to a single image (more precisely, to a single
`.ScalarMappable`). If we want to use multiple images with a single colorbar,
we have to create a colorbar for one of the images and make sure that the
other images use the same color mapping, i.e. the same Colormap and the same
`.Normalize` instance.

"""
from matplotlib import colors
import matplotlib.pyplot as plt
import numpy as np

np.random.seed(19680801)
Nr = 3
Nc = 2
cmap = "cool"
nrows = 3
ncols = 2
cmap = "plasma"

data = [((1 + i) / 10) * np.random.rand(11, 21) * 1e-6
for i in range(nrows * ncols)]

#############################################################################
#
# All data available beforehand
# -----------------------------
#
# In the most simple case, we have all the image data sets available, e.g.
# in a list, before we start to plot. In this case, we create a common
# `.Normalize` instance scaling to the global min and max by passing all data
# to the *data* argument of Normalize. We then use this and a common colormap
# when creating the images.

fig, axs = plt.subplots(Nr, Nc)
fig.suptitle('Multiple images')
fig, axs = plt.subplots(nrows, ncols, sharex=True, sharey=True)
fig.suptitle('Multiple images sharing a colorbar')

norm = colors.Normalize(data=data)
images = [ax.imshow(data, cmap=cmap, norm=norm)
for data, ax in zip(data, axs.flat)]
fig.colorbar(images[0], ax=axs, orientation='horizontal', fraction=.06)
plt.show()

#############################################################################
#
# Not all data available beforehand
# ---------------------------------
#
# Things get a bit more complicated, if we don't have all the data beforehand,
# e.g. when we generate or load the data just before each plot command. In
# this case, the common norm has to be created and set afterwards. We can use
# a small helper function for that.


def normalize_images(images):
"""Normalize the given images to their global min and max."""
vmin = min(image.get_array().min() for image in images)
vmax = max(image.get_array().max() for image in images)
norm = colors.Normalize(vmin=vmin, vmax=vmax)
for im in images:
im.set_norm(norm)


fig, axs = plt.subplots(nrows, ncols, sharex=True, sharey=True)
fig.suptitle('Multiple images sharing a colorbar')

images = []
for i in range(Nr):
for j in range(Nc):
for i in range(nrows):
for j in range(ncols):
# Generate data with a range that varies from one plot to the next.
data = ((1 + i + j) / 10) * np.random.rand(10, 20) * 1e-6
data = ((1 + i + j) / 10) * np.random.rand(11, 21) * 1e-6
images.append(axs[i, j].imshow(data, cmap=cmap))
axs[i, j].label_outer()

# Find the min and max of all colors for use in setting the color scale.
vmin = min(image.get_array().min() for image in images)
vmax = max(image.get_array().max() for image in images)
norm = colors.Normalize(vmin=vmin, vmax=vmax)
for im in images:
im.set_norm(norm)
normalize_images(images)
fig.colorbar(images[0], ax=axs, orientation='horizontal', fraction=.06)

fig.colorbar(images[0], ax=axs, orientation='horizontal', fraction=.1)

#############################################################################
#
# Dynamically adapting to changes of the norm and cmap
# ----------------------------------------------------
#
# If the images norm or cmap can change later on (e.g. via the
# "edit axis, curves and images parameters" GUI on Qt), one can propagate
# these changes to all images by connecting to the 'changed' callback.
#
# Note: It's important to have the ``if`` statement to check whether there
# are really changes to apply. Otherwise, you would run into an infinite
# recursion with all images notifying each other infinitely.

# Make images respond to changes in the norm of other images (e.g. via the
# "edit axis, curves and images parameters" GUI on Qt), but be careful not to
# recurse infinitely!
def update(changed_image):
for im in images:
if (changed_image.get_cmap() != im.get_cmap()
Expand All @@ -50,7 +99,6 @@ def update(changed_image):
for im in images:
im.callbacksSM.connect('changed', update)

plt.show()

#############################################################################
#
Expand Down
24 changes: 17 additions & 7 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ class Normalize(object):
the ``[0.0, 1.0]`` interval.

"""
def __init__(self, vmin=None, vmax=None, clip=False):
def __init__(self, vmin=None, vmax=None, clip=False, *, data=None):
"""
If *vmin* or *vmax* is not given, they are initialized from the
minimum and maximum value respectively of the first input
Expand All @@ -876,10 +876,16 @@ def __init__(self, vmin=None, vmax=None, clip=False):
the over, under, and masked colors in the colormap, so it is
likely to lead to surprises; therefore the default is
*clip* = *False*.

You can pass an array-like object to *data*, e.g. a list of
numpy arrays. If *vmin* or *vmax* are *None*, the respective min/max
of the array is taken as *vmin* / *vmax*.
"""
self.vmin = _sanitize_extrema(vmin)
self.vmax = _sanitize_extrema(vmax)
self.clip = clip
if data is not None:
self.autoscale_None(data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you mean self.apply_autoscale here or am I misunderstanding? I'm not seeing how data can be a list of np arrays if you call autoscale_None. I don't see that you have a test of this below, i.e. you only call apply_autoscale.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. I mean autoscale_None. autoscale_None internally calls np.asanyarray() on the data, so a list of arrays is fine. I don't think we really need to test this, but if you want I can add a test.

norm = Normalize(data=data) is really just a shortcut for

norm = Normalize()
norm.autoscale(data)

i.e. it scales to the data. The autoscale_None variant is used so that you can consistently do Normalize(vmin=0, data=data) in which case only vmax will be taken from data.

apply_autoscale() serves a different purpose. While the above works on data, apply_autoscale() works on AxesImages. It creates a norm based on the data in the images and applies the norm to the images. Maybe we should better call it autoscale_images()?


@staticmethod
def process_value(value):
Expand Down Expand Up @@ -1061,8 +1067,8 @@ class SymLogNorm(Normalize):
*linthresh* allows the user to specify the size of this range
(-*linthresh*, *linthresh*).
"""
def __init__(self, linthresh, linscale=1.0,
vmin=None, vmax=None, clip=False):
def __init__(self, linthresh, linscale=1.0,
vmin=None, vmax=None, clip=False, *, data=None):
"""
*linthresh*:
The range within which the plot is linear (to
Expand All @@ -1076,11 +1082,15 @@ def __init__(self, linthresh, linscale=1.0,
default), the space used for the positive and negative
halves of the linear range will be equal to one decade in
the logarithmic range. Defaults to 1.

You can pass an array-like object to *data*, e.g. a list of
numpy arrays. If *vmin* or *vmax* are *None*, the respective min/max
of the array is taken as *vmin* / *vmax*.
"""
Normalize.__init__(self, vmin, vmax, clip)
Normalize.__init__(self, vmin, vmax, clip, data=data)
self.linthresh = float(linthresh)
self._linscale_adj = (linscale / (1.0 - np.e ** -1))
if vmin is not None and vmax is not None:
if self.vmin is not None and self.vmax is not None:
self._transform_vmin_vmax()

def __call__(self, value, clip=None):
Expand Down Expand Up @@ -1173,8 +1183,8 @@ class PowerNorm(Normalize):
Normalize a given value to the ``[0, 1]`` interval with a power-law
scaling. This will clip any negative data points to 0.
"""
def __init__(self, gamma, vmin=None, vmax=None, clip=False):
Normalize.__init__(self, vmin, vmax, clip)
def __init__(self, gamma, vmin=None, vmax=None, clip=False, *, data=None):
Normalize.__init__(self, vmin, vmax, clip, data=data)
self.gamma = gamma

def __call__(self, value, clip=None):
Expand Down
6 changes: 6 additions & 0 deletions lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ def test_Normalize():
assert 0 < norm(1 + 50 * eps) < 1


def test_Normalize_data():
vals = np.linspace(-1, 2, 5)
norm = mcolors.Normalize(data=vals)
assert_array_equal([norm.vmin, norm.vmax], [vals.min(), vals.max()])


def test_SymLogNorm():
"""
Test SymLogNorm behavior
Expand Down