diff --git a/doc/api/api_changes/2018-01-26-ZHD.rst b/doc/api/api_changes/2018-01-26-ZHD.rst new file mode 100644 index 000000000000..cf3ff080c87f --- /dev/null +++ b/doc/api/api_changes/2018-01-26-ZHD.rst @@ -0,0 +1,7 @@ +`Axes.imshow` clips RGB values to the valid range +------------------------------------------------- + +When `Axes.imshow` is passed an RGB or RGBA value with out-of-range +values, it now logs a warning and clips them to the valid range. +The old behaviour, wrapping back in to the range, often hid outliers +and made interpreting RGB images unreliable. diff --git a/doc/users/credits.rst b/doc/users/credits.rst index b843f81ad5c1..6d737f282955 100644 --- a/doc/users/credits.rst +++ b/doc/users/credits.rst @@ -386,6 +386,7 @@ Yu Feng, Yunfei Yang, Yuri D'Elia, Yuval Langer, +Zac Hatfield-Dodds, Zach Pincus, Zair Mubashar, alex, diff --git a/doc/users/next_whats_new/2018_01_26_imshow-rgb-clipping.rst b/doc/users/next_whats_new/2018_01_26_imshow-rgb-clipping.rst new file mode 100644 index 000000000000..cf3ff080c87f --- /dev/null +++ b/doc/users/next_whats_new/2018_01_26_imshow-rgb-clipping.rst @@ -0,0 +1,7 @@ +`Axes.imshow` clips RGB values to the valid range +------------------------------------------------- + +When `Axes.imshow` is passed an RGB or RGBA value with out-of-range +values, it now logs a warning and clips them to the valid range. +The old behaviour, wrapping back in to the range, often hid outliers +and made interpreting RGB images unreliable. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 07bff9bc2d52..e57dfa374b7c 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5323,10 +5323,14 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, - MxNx3 -- RGB (float or uint8) - MxNx4 -- RGBA (float or uint8) - The value for each component of MxNx3 and MxNx4 float arrays - should be in the range 0.0 to 1.0. MxN arrays are mapped - to colors based on the `norm` (mapping scalar to scalar) - and the `cmap` (mapping the normed scalar to a color). + MxN arrays are mapped to colors based on the `norm` (mapping + scalar to scalar) and the `cmap` (mapping the normed scalar to + a color). + + Elements of RGB and RGBA arrays represent pixels of an MxN image. + All values should be in the range [0 .. 1] for floats or + [0 .. 255] for integers. Out-of-range values will be clipped to + these bounds. cmap : `~matplotlib.colors.Colormap`, optional, default: None If None, default to rc `image.cmap` value. `cmap` is ignored @@ -5368,7 +5372,8 @@ def imshow(self, X, cmap=None, norm=None, aspect=None, settings for `vmin` and `vmax` will be ignored. alpha : scalar, optional, default: None - The alpha blending value, between 0 (transparent) and 1 (opaque) + The alpha blending value, between 0 (transparent) and 1 (opaque). + The ``alpha`` argument is ignored for RGBA input data. origin : ['upper' | 'lower'], optional, default: None Place the [0,0] index of the array in the upper left or lower left diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index ecd1179f1af0..3a983d058ee7 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -259,7 +259,7 @@ def to_rgba(self, x, alpha=None, bytes=False, norm=True): xx = (xx * 255).astype(np.uint8) elif xx.dtype == np.uint8: if not bytes: - xx = xx.astype(float) / 255 + xx = xx.astype(np.float32) / 255 else: raise ValueError("Image RGB array must be uint8 or " "floating point; found %s" % xx.dtype) diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index de7e5018125f..55d1cf41df26 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -13,6 +13,7 @@ from math import ceil import os +import logging import numpy as np @@ -34,6 +35,8 @@ from matplotlib.transforms import (Affine2D, BboxBase, Bbox, BboxTransform, IdentityTransform, TransformedBbox) +_log = logging.getLogger(__name__) + # map interpolation strings to module constants _interpd_ = { 'none': _image.NEAREST, # fall back to nearest when not supported @@ -623,6 +626,23 @@ def set_data(self, A): or self._A.ndim == 3 and self._A.shape[-1] in [3, 4]): raise TypeError("Invalid dimensions for image data") + if self._A.ndim == 3: + # If the input data has values outside the valid range (after + # normalisation), we issue a warning and then clip X to the bounds + # - otherwise casting wraps extreme values, hiding outliers and + # making reliable interpretation impossible. + high = 255 if np.issubdtype(self._A.dtype, np.integer) else 1 + if self._A.min() < 0 or high < self._A.max(): + _log.warning( + 'Clipping input data to the valid range for imshow with ' + 'RGB data ([0..1] for floats or [0..255] for integers).' + ) + self._A = np.clip(self._A, 0, high) + # Cast unsupported integer types to uint8 + if self._A.dtype != np.uint8 and np.issubdtype(self._A.dtype, + np.integer): + self._A = self._A.astype(np.uint8) + self._imcache = None self._rgbacache = None self.stale = True diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index ca3443a78445..bc0aff9daded 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -622,7 +622,7 @@ def test_minimized_rasterized(): def test_load_from_url(): req = six.moves.urllib.request.urlopen( "http://matplotlib.org/_static/logo_sidebar_horiz.png") - Z = plt.imread(req) + plt.imread(req) @image_comparison(baseline_images=['log_scale_image'], @@ -815,6 +815,27 @@ def test_imshow_no_warn_invalid(): assert len(warns) == 0 +@pytest.mark.parametrize( + 'dtype', [np.dtype(s) for s in 'u2 u4 i2 i4 i8 f4 f8'.split()]) +def test_imshow_clips_rgb_to_valid_range(dtype): + arr = np.arange(300, dtype=dtype).reshape((10, 10, 3)) + if dtype.kind != 'u': + arr -= 10 + too_low = arr < 0 + too_high = arr > 255 + if dtype.kind == 'f': + arr = arr / 255 + _, ax = plt.subplots() + out = ax.imshow(arr).get_array() + assert (out[too_low] == 0).all() + if dtype.kind == 'f': + assert (out[too_high] == 1).all() + assert out.dtype.kind == 'f' + else: + assert (out[too_high] == 255).all() + assert out.dtype == np.uint8 + + @image_comparison(baseline_images=['imshow_flatfield'], remove_text=True, style='mpl20', extensions=['png'])