diff --git a/CHANGELOG b/CHANGELOG index abf009952571..e8cd1fc1b850 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +2014-09-27 Overhauled `colors.LightSource`. Added `LightSource.hillshade` to + allow the independent generation of illumination maps. Added new + types of blending for creating more visually appealing shaded relief + plots (e.g. `blend_mode="overlay"`, etc, in addition to the legacy + "hsv" mode). + 2014-06-10 Added Colorbar.remove() 2014-06-07 Fixed bug so radial plots can be saved as ps in py3k. diff --git a/examples/mplot3d/custom_shaded_3d_surface.py b/examples/mplot3d/custom_shaded_3d_surface.py new file mode 100644 index 000000000000..520dee749c63 --- /dev/null +++ b/examples/mplot3d/custom_shaded_3d_surface.py @@ -0,0 +1,32 @@ +""" +Demonstrates using custom hillshading in a 3D surface plot. +""" +from mpl_toolkits.mplot3d import Axes3D +from matplotlib import cbook +from matplotlib import cm +from matplotlib.colors import LightSource +import matplotlib.pyplot as plt +import numpy as np + +filename = cbook.get_sample_data('jacksboro_fault_dem.npz', asfileobj=False) +with np.load(filename) as dem: + z = dem['elevation'] + nrows, ncols = z.shape + x = np.linspace(dem['xmin'], dem['xmax'], ncols) + y = np.linspace(dem['ymin'], dem['ymax'], nrows) + x, y = np.meshgrid(x, y) + +region = np.s_[5:50, 5:50] +x, y, z = x[region], y[region], z[region] + +fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + +ls = LightSource(270, 45) +# To use a custom hillshading mode, override the built-in shading and pass +# in the rgb colors of the shaded surface calculated from "shade". +rgb = ls.shade(z, cmap=cm.gist_earth, vert_exag=0.1, blend_mode='soft') +surf = ax.plot_surface(x, y, z, rstride=1, cstride=1, facecolors=rgb, + linewidth=0, antialiased=False, shade=False) + +plt.show() + diff --git a/examples/pylab_examples/shading_example.py b/examples/pylab_examples/shading_example.py index c71395e33d53..7388c68c04ee 100644 --- a/examples/pylab_examples/shading_example.py +++ b/examples/pylab_examples/shading_example.py @@ -1,28 +1,55 @@ import numpy as np import matplotlib.pyplot as plt from matplotlib.colors import LightSource +from matplotlib.cbook import get_sample_data -# example showing how to make shaded relief plots +# Example showing how to make shaded relief plots # like Mathematica # (http://reference.wolfram.com/mathematica/ref/ReliefPlot.html) # or Generic Mapping Tools # (http://gmt.soest.hawaii.edu/gmt/doc/gmt/html/GMT_Docs/node145.html) -# test data -X,Y=np.mgrid[-5:5:0.05,-5:5:0.05] -Z=np.sqrt(X**2+Y**2)+np.sin(X**2+Y**2) -# create light source object. -ls = LightSource(azdeg=0,altdeg=65) -# shade data, creating an rgb array. -rgb = ls.shade(Z,plt.cm.copper) -# plot un-shaded and shaded images. -plt.figure(figsize=(12,5)) -plt.subplot(121) -plt.imshow(Z,cmap=plt.cm.copper) -plt.title('imshow') -plt.xticks([]); plt.yticks([]) -plt.subplot(122) -plt.imshow(rgb) -plt.title('imshow with shading') -plt.xticks([]); plt.yticks([]) -plt.show() +def main(): + # Test data + x, y = np.mgrid[-5:5:0.05, -5:5:0.05] + z = 5 * (np.sqrt(x**2 + y**2) + np.sin(x**2 + y**2)) + + filename = get_sample_data('jacksboro_fault_dem.npz', asfileobj=False) + with np.load(filename) as dem: + elev = dem['elevation'] + + fig = compare(z, plt.cm.copper) + fig.suptitle('HSV Blending Looks Best with Smooth Surfaces', y=0.95) + + fig = compare(elev, plt.cm.gist_earth, ve=0.05) + fig.suptitle('Overlay Blending Looks Best with Rough Surfaces', y=0.95) + + plt.show() + +def compare(z, cmap, ve=1): + # Create subplots and hide ticks + fig, axes = plt.subplots(ncols=2, nrows=2) + for ax in axes.flat: + ax.set(xticks=[], yticks=[]) + + # Illuminate the scene from the northwest + ls = LightSource(azdeg=315, altdeg=45) + + axes[0, 0].imshow(z, cmap=cmap) + axes[0, 0].set(xlabel='Colormapped Data') + + axes[0, 1].imshow(ls.hillshade(z, vert_exag=ve), cmap='gray') + axes[0, 1].set(xlabel='Illumination Intensity') + + rgb = ls.shade(z, cmap=cmap, vert_exag=ve, blend_mode='hsv') + axes[1, 0].imshow(rgb) + axes[1, 0].set(xlabel='Blend Mode: "hsv" (default)') + + rgb = ls.shade(z, cmap=cmap, vert_exag=ve, blend_mode='overlay') + axes[1, 1].imshow(rgb) + axes[1, 1].set(xlabel='Blend Mode: "overlay"') + + return fig + +if __name__ == '__main__': + main() diff --git a/examples/specialty_plots/advanced_hillshading.py b/examples/specialty_plots/advanced_hillshading.py new file mode 100644 index 000000000000..2b836ef8cb1c --- /dev/null +++ b/examples/specialty_plots/advanced_hillshading.py @@ -0,0 +1,68 @@ +""" +Demonstrates a few common tricks with shaded plots. +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import LightSource, Normalize + +def display_colorbar(): + """Display a correct numeric colorbar for a shaded plot.""" + y, x = np.mgrid[-4:2:200j, -4:2:200j] + z = 10 * np.cos(x**2 + y**2) + + cmap = plt.cm.copper + ls = LightSource(315, 45) + rgb = ls.shade(z, cmap) + + fig, ax = plt.subplots() + ax.imshow(rgb) + + # Use a proxy artist for the colorbar... + im = ax.imshow(z, cmap=cmap) + im.remove() + fig.colorbar(im) + + ax.set_title('Using a colorbar with a shaded plot', size='x-large') + +def avoid_outliers(): + """Use a custom norm to control the displayed z-range of a shaded plot.""" + y, x = np.mgrid[-4:2:200j, -4:2:200j] + z = 10 * np.cos(x**2 + y**2) + + # Add some outliers... + z[100, 105] = 2000 + z[120, 110] = -9000 + + ls = LightSource(315, 45) + fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(8, 4.5)) + + rgb = ls.shade(z, plt.cm.copper) + ax1.imshow(rgb) + ax1.set_title('Full range of data') + + rgb = ls.shade(z, plt.cm.copper, vmin=-10, vmax=10) + ax2.imshow(rgb) + ax2.set_title('Manually set range') + + fig.suptitle('Avoiding Outliers in Shaded Plots', size='x-large') + +def shade_other_data(): + """Demonstrates displaying different variables through shade and color.""" + y, x = np.mgrid[-4:2:200j, -4:2:200j] + z1 = np.sin(x**2) # Data to hillshade + z2 = np.cos(x**2 + y**2) # Data to color + + norm=Normalize(z2.min(), z2.max()) + cmap = plt.cm.jet + + ls = LightSource(315, 45) + rgb = ls.shade_rgb(cmap(norm(z2)), z1) + + fig, ax = plt.subplots() + ax.imshow(rgb) + ax.set_title('Shade by one variable, color by another', size='x-large') + +display_colorbar() +avoid_outliers() +shade_other_data() +plt.show() diff --git a/examples/specialty_plots/topographic_hillshading.py b/examples/specialty_plots/topographic_hillshading.py new file mode 100644 index 000000000000..1cf0d5a559fe --- /dev/null +++ b/examples/specialty_plots/topographic_hillshading.py @@ -0,0 +1,70 @@ +""" +Demonstrates the visual effect of varying blend mode and vertical exaggeration +on "hillshaded" plots. + +Note that the "overlay" and "soft" blend modes work well for complex surfaces +such as this example, while the default "hsv" blend mode works best for smooth +surfaces such as many mathematical functions. + +In most cases, hillshading is used purely for visual purposes, and *dx*/*dy* +can be safely ignored. In that case, you can tweak *vert_exag* (vertical +exaggeration) by trial and error to give the desired visual effect. However, +this example demonstrates how to use the *dx* and *dy* kwargs to ensure that +the *vert_exag* parameter is the true vertical exaggeration. +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.cbook import get_sample_data +from matplotlib.colors import LightSource + +dem = np.load(get_sample_data('jacksboro_fault_dem.npz')) +z = dem['elevation'] + +#-- Optional dx and dy for accurate vertical exaggeration -------------------- +# If you need topographically accurate vertical exaggeration, or you don't want +# to guess at what *vert_exag* should be, you'll need to specify the cellsize +# of the grid (i.e. the *dx* and *dy* parameters). Otherwise, any *vert_exag* +# value you specify will be realitive to the grid spacing of your input data +# (in other words, *dx* and *dy* default to 1.0, and *vert_exag* is calculated +# relative to those parameters). Similarly, *dx* and *dy* are assumed to be in +# the same units as your input z-values. Therefore, we'll need to convert the +# given dx and dy from decimal degrees to meters. +dx, dy = dem['dx'], dem['dy'] +dy = 111200 * dy +dx = 111200 * dx * np.cos(np.radians(dem['ymin'])) +#----------------------------------------------------------------------------- + +# Shade from the northwest, with the sun 45 degrees from horizontal +ls = LightSource(azdeg=315, altdeg=45) +cmap = plt.cm.gist_earth + +fig, axes = plt.subplots(nrows=4, ncols=3, figsize=(8, 9)) +plt.setp(axes.flat, xticks=[], yticks=[]) + +# Vary vertical exaggeration and blend mode and plot all combinations +for col, ve in zip(axes.T, [0.1, 1, 10]): + # Show the hillshade intensity image in the first row + col[0].imshow(ls.hillshade(z, vert_exag=ve, dx=dx, dy=dy), cmap='gray') + + # Place hillshaded plots with different blend modes in the rest of the rows + for ax, mode in zip(col[1:], ['hsv', 'overlay', 'soft']): + rgb = ls.shade(z, cmap=cmap, blend_mode=mode, + vert_exag=ve, dx=dx, dy=dy) + ax.imshow(rgb) + +# Label rows and columns +for ax, ve in zip(axes[0], [0.1, 1, 10]): + ax.set_title('{}'.format(ve), size=18) +for ax, mode in zip(axes[:,0], ['Hillshade', 'hsv', 'overlay', 'soft']): + ax.set_ylabel(mode, size=18) + +# Group labels... +axes[0,1].annotate('Vertical Exaggeration', (0.5, 1), xytext=(0, 30), + textcoords='offset points', xycoords='axes fraction', + ha='center', va='bottom', size=20) +axes[2,0].annotate('Blend Mode', (0, 0.5), xytext=(-30, 0), + textcoords='offset points', xycoords='axes fraction', + ha='right', va='center', size=20, rotation=90) +fig.subplots_adjust(bottom=0.05, right=0.95) + +plt.show() diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index dbc96197c134..d3963237e815 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1463,26 +1463,35 @@ class LightSource(object): Create a light source coming from the specified azimuth and elevation. Angles are in degrees, with the azimuth measured clockwise from north and elevation up from the zero plane of the surface. - The :meth:`shade` is used to produce rgb values for a shaded relief image - given a data array. - """ - def __init__(self, azdeg=315, altdeg=45, - hsv_min_val=0, hsv_max_val=1, hsv_min_sat=1, - hsv_max_sat=0): + The :meth:`shade` is used to produce "shaded" rgb values for a data array. + :meth:`shade_rgb` can be used to combine an rgb image with + The :meth:`shade_rgb` + The :meth:`hillshade` produces an illumination map of a surface. + """ + def __init__(self, azdeg=315, altdeg=45, hsv_min_val=0, hsv_max_val=1, + hsv_min_sat=1, hsv_max_sat=0): """ Specify the azimuth (measured clockwise from south) and altitude (measured up from the plane of the surface) of the light source in degrees. - The color of the resulting image will be darkened - by moving the (s,v) values (in hsv colorspace) toward - (hsv_min_sat, hsv_min_val) in the shaded regions, or - lightened by sliding (s,v) toward - (hsv_max_sat hsv_max_val) in regions that are illuminated. - The default extremes are chose so that completely shaded points - are nearly black (s = 1, v = 0) and completely illuminated points - are nearly white (s = 0, v = 1). + Parameters + ---------- + azdeg : number, optional + The azimuth (0-360, degrees clockwise from North) of the light + source. Defaults to 315 degrees (from the northwest). + altdeg : number, optional + The altitude (0-90, degrees up from horizontal) of the light + source. Defaults to 45 degrees from horizontal. + + Notes + ----- + For backwards compatibility, the parameters *hsv_min_val*, + *hsv_max_val*, *hsv_min_sat*, and *hsv_max_sat* may be supplied at + initialization as well. However, these parameters will only be used if + "blend_mode='hsv'" is passed into :meth:`shade` or :meth:`shade_rgb`. + See the documentation for :meth:`blend_hsv` for more details. """ self.azdeg = azdeg self.altdeg = altdeg @@ -1491,78 +1500,351 @@ def __init__(self, azdeg=315, altdeg=45, self.hsv_min_sat = hsv_min_sat self.hsv_max_sat = hsv_max_sat - def shade(self, data, cmap, norm=None): + def hillshade(self, elevation, vert_exag=1, dx=1, dy=1, fraction=1.): """ - Take the input data array, convert to HSV values in the - given colormap, then adjust those color values - to give the impression of a shaded relief map with a - specified light source. - RGBA values are returned, which can then be used to - plot the shaded image with imshow. + Calculates the illumination intensity for a surface using the defined + azimuth and elevation for the light source. + + Imagine an artificial sun placed at infinity in some azimuth and + elevation position illuminating our surface. The parts of the surface + that slope toward the sun should brighten while those sides facing away + should become darker. + + Parameters + ---------- + elevation : array-like + A 2d array (or equivalent) of the height values used to generate an + illumination map + vert_exag : number, optional + The amount to exaggerate the elevation values by when calculating + illumination. This can be used either to correct for differences in + units between the x-y coordinate system and the elevation + coordinate system (e.g. decimal degrees vs meters) or to exaggerate + or de-emphasize topographic effects. + dx : number, optional + The x-spacing (columns) of the input *elevation* grid. + dy : number, optional + The y-spacing (rows) of the input *elevation* grid. + fraction : number, optional + Increases or decreases the contrast of the hillshade. Values + greater than one will cause intermediate values to move closer to + full illumination or shadow (and clipping any values that move + beyond 0 or 1). Note that this is not visually or mathematically + the same as vertical exaggeration. + Returns + ------- + intensity : ndarray + A 2d array of illumination values between 0-1, where 0 is + completely in shadow and 1 is completely illuminated. + """ + # Azimuth is in degrees clockwise from North. Convert to radians + # counterclockwise from East (mathematical notation). + az = np.radians(90 - self.azdeg) + alt = np.radians(self.altdeg) + + # Because most image and raster GIS data has the first row in the array + # as the "top" of the image, dy is implicitly negative. This is + # consistent to what `imshow` assumes, as well. + dy = -dy + + #-- Calculate the intensity from the illumination angle + dy, dx = np.gradient(vert_exag * elevation, dy, dx) + # The aspect is defined by the _downhill_ direction, thus the negative + aspect = np.arctan2(-dy, -dx) + slope = 0.5 * np.pi - np.arctan(np.hypot(dx, dy)) + intensity = (np.sin(alt) * np.sin(slope) + + np.cos(alt) * np.cos(slope) + * np.cos(az - aspect)) + + #-- Apply contrast stretch + imin, imax = intensity.min(), intensity.max() + intensity *= fraction + + #-- Rescale to 0-1, keeping range before contrast stretch + # If constant slope, keep relative scaling (i.e. flat should be 0.5, + # fully occluded 0, etc.) + if (imax - imin) > 1e-6: + # Strictly speaking, this is incorrect. Negative values should be + # clipped to 0 because they're fully occluded. However, rescaling + # in this manner is consistent with the previous implementation and + # visually appears better than a "hard" clip. + intensity -= imin + intensity /= (imax - imin) + intensity = np.clip(intensity, 0, 1, intensity) + + return intensity + + def shade(self, data, cmap, norm=None, blend_mode='hsv', vmin=None, + vmax=None, vert_exag=1, dx=1, dy=1, fraction=1, **kwargs): """ + Combine colormapped data values with an illumination intensity map + (a.k.a. "hillshade") of the values. + + Parameters + ---------- + data : array-like + A 2d array (or equivalent) of the height values used to generate a + shaded map. + cmap : `~matplotlib.colors.Colormap` instance + The colormap used to color the *data* array. Note that this must be + a `~matplotlib.colors.Colormap` instance. For example, rather than + passing in `cmap='gist_earth'`, use + `cmap=plt.get_cmap('gist_earth')` instead. + norm : `~matplotlib.colors.Normalize` instance, optional + The normalization used to scale values before colormapping. If + None, the input will be linearly scaled between its min and max. + blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional + The type of blending used to combine the colormapped data values + with the illumination intensity. For backwards compatibility, this + defaults to "hsv". Note that for most topographic surfaces, + "overlay" or "soft" appear more visually realistic. If a + user-defined function is supplied, it is expected to combine an + MxNx3 RGB array of floats (ranging 0 to 1) with an MxNx1 hillshade + array (also 0 to 1). (Call signature `func(rgb, illum, **kwargs)`) + Additional kwargs supplied to this function will be passed on to + the *blend_mode* function. + vmin : scalar or None, optional + The minimum value used in colormapping *data*. If *None* the + minimum value in *data* is used. If *norm* is specified, then this + argument will be ignored. + vmax : scalar or None, optional + The maximum value used in colormapping *data*. If *None* the + maximum value in *data* is used. If *norm* is specified, then this + argument will be ignored. + vert_exag : number, optional + The amount to exaggerate the elevation values by when calculating + illumination. This can be used either to correct for differences in + units between the x-y coordinate system and the elevation + coordinate system (e.g. decimal degrees vs meters) or to exaggerate + or de-emphasize topography. + dx : number, optional + The x-spacing (columns) of the input *elevation* grid. + dy : number, optional + The y-spacing (rows) of the input *elevation* grid. + fraction : number, optional + Increases or decreases the contrast of the hillshade. Values + greater than one will cause intermediate values to move closer to + full illumination or shadow (and clipping any values that move + beyond 0 or 1). Note that this is not visually or mathematically + the same as vertical exaggeration. + Additional kwargs are passed on to the *blend_mode* function. + Returns + ------- + rgba : ndarray + An MxNx4 array of floats ranging between 0-1. + """ + if vmin is None: + vmin = data.min() + if vmax is None: + vmax = data.max() if norm is None: - norm = Normalize(vmin=data.min(), vmax=data.max()) + norm = Normalize(vmin=vmin, vmax=vmax) rgb0 = cmap(norm(data)) - rgb1 = self.shade_rgb(rgb0, elevation=data) - rgb0[:, :, 0:3] = rgb1 + rgb1 = self.shade_rgb(rgb0, elevation=data, blend_mode=blend_mode, + vert_exag=vert_exag, dx=dx, dy=dy, + fraction=fraction, **kwargs) + # Don't overwrite the alpha channel, if present. + rgb0[..., :3] = rgb1[..., :3] return rgb0 - def shade_rgb(self, rgb, elevation, fraction=1.): + def shade_rgb(self, rgb, elevation, fraction=1., blend_mode='hsv', + vert_exag=1, dx=1, dy=1, **kwargs): """ Take the input RGB array (ny*nx*3) adjust their color values to given the impression of a shaded relief map with a specified light source using the elevation (ny*nx). A new RGB array ((ny*nx*3)) is returned. + + Parameters + ---------- + rgb : array-like + An MxNx3 RGB array, assumed to be in the range of 0 to 1. + elevation : array-like + A 2d array (or equivalent) of the height values used to generate a + shaded map. + fraction : number + Increases or decreases the contrast of the hillshade. Values + greater than one will cause intermediate values to move closer to + full illumination or shadow (and clipping any values that move + beyond 0 or 1). Note that this is not visually or mathematically + the same as vertical exaggeration. + blend_mode : {'hsv', 'overlay', 'soft'} or callable, optional + The type of blending used to combine the colormapped data values + with the illumination intensity. For backwards compatibility, this + defaults to "hsv". Note that for most topographic surfaces, + "overlay" or "soft" appear more visually realistic. If a + user-defined function is supplied, it is expected to combine an + MxNx3 RGB array of floats (ranging 0 to 1) with an MxNx1 hillshade + array (also 0 to 1). (Call signature `func(rgb, illum, **kwargs)`) + Additional kwargs supplied to this function will be passed on to + the *blend_mode* function. + vert_exag : number, optional + The amount to exaggerate the elevation values by when calculating + illumination. This can be used either to correct for differences in + units between the x-y coordinate system and the elevation + coordinate system (e.g. decimal degrees vs meters) or to exaggerate + or de-emphasize topography. + dx : number, optional + The x-spacing (columns) of the input *elevation* grid. + dy : number, optional + The y-spacing (rows) of the input *elevation* grid. + Additional kwargs are passed on to the *blend_mode* function. + + Returns + ------- + shaded_rgb : ndarray + An MxNx3 array of floats ranging between 0-1. """ - # imagine an artificial sun placed at infinity in some azimuth and - # elevation position illuminating our surface. The parts of the - # surface that slope toward the sun should brighten while those sides - # facing away should become darker. convert alt, az to radians - az = self.azdeg * np.pi / 180.0 - alt = self.altdeg * np.pi / 180.0 - # gradient in x and y directions - dx, dy = np.gradient(elevation) - slope = 0.5 * np.pi - np.arctan(np.hypot(dx, dy)) - aspect = np.arctan2(dx, dy) - intensity = (np.sin(alt) * np.sin(slope) + np.cos(alt) * - np.cos(slope) * np.cos(-az - aspect - 0.5 * np.pi)) - # rescale to interval -1,1 - # +1 means maximum sun exposure and -1 means complete shade. - intensity = (intensity - intensity.min()) / \ - (intensity.max() - intensity.min()) - intensity = (2. * intensity - 1.) * fraction + # Calculate the "hillshade" intensity. + intensity = self.hillshade(elevation, vert_exag, dx, dy, fraction) + intensity = intensity[..., np.newaxis] + + # Blend the hillshade and rgb data using the specified mode + lookup = { + 'hsv': self.blend_hsv, + 'soft': self.blend_soft_light, + 'overlay': self.blend_overlay, + } + if blend_mode in lookup: + blend = lookup[blend_mode](rgb, intensity, **kwargs) + else: + try: + blend = blend_mode(rgb, intensity, **kwargs) + except TypeError: + msg = '"blend_mode" must be callable or one of {}' + raise ValueError(msg.format(lookup.keys)) + + # Only apply result where hillshade intensity isn't masked + if hasattr(intensity, 'mask'): + mask = intensity.mask[..., 0] + for i in range(3): + blend[..., i][mask] = rgb[..., i][mask] + + return blend + + def blend_hsv(self, rgb, intensity, hsv_max_sat=None, hsv_max_val=None, + hsv_min_val=None, hsv_min_sat=None): + """ + Take the input data array, convert to HSV values in the given colormap, + then adjust those color values to give the impression of a shaded + relief map with a specified light source. RGBA values are returned, + which can then be used to plot the shaded image with imshow. + + The color of the resulting image will be darkened by moving the (s,v) + values (in hsv colorspace) toward (hsv_min_sat, hsv_min_val) in the + shaded regions, or lightened by sliding (s,v) toward (hsv_max_sat + hsv_max_val) in regions that are illuminated. The default extremes are + chose so that completely shaded points are nearly black (s = 1, v = 0) + and completely illuminated points are nearly white (s = 0, v = 1). + + Parameters + ---------- + rgb : ndarray + An MxNx3 RGB array of floats ranging from 0 to 1 (color image). + intensity : ndarray + An MxNx1 array of floats ranging from 0 to 1 (grayscale image). + hsv_max_sat : number, optional + The maximum saturation value that the *intensity* map can shift the + output image to. Defaults to 1. + hsv_min_sat : number, optional + The minimum saturation value that the *intensity* map can shift the + output image to. Defaults to 0. + hsv_max_val : number, optional + The maximum value ("v" in "hsv") that the *intensity* map can shift + the output image to. Defaults to 1. + hsv_min_val: number, optional + The minimum value ("v" in "hsv") that the *intensity* map can shift + the output image to. Defaults to 0. + + Returns + ------- + rgb : ndarray + An MxNx3 RGB array representing the combined images. + """ + # Backward compatibility... + if hsv_max_sat is None: + hsv_max_sat = self.hsv_max_sat + if hsv_max_val is None: + hsv_max_val = self.hsv_max_val + if hsv_min_sat is None: + hsv_min_sat = self.hsv_min_sat + if hsv_min_val is None: + hsv_min_val = self.hsv_min_val + + # Expects a 2D intensity array scaled between -1 to 1... + intensity = intensity[..., 0] + intensity = 2 * intensity - 1 + # convert to rgb, then rgb to hsv - #rgb = cmap((data-data.min())/(data.max()-data.min())) hsv = rgb_to_hsv(rgb[:, :, 0:3]) - # modify hsv values to simulate illumination. + # modify hsv values to simulate illumination. hsv[:, :, 1] = np.where(np.logical_and(np.abs(hsv[:, :, 1]) > 1.e-10, intensity > 0), ((1. - intensity) * hsv[:, :, 1] + - intensity * self.hsv_max_sat), + intensity * hsv_max_sat), hsv[:, :, 1]) hsv[:, :, 2] = np.where(intensity > 0, ((1. - intensity) * hsv[:, :, 2] + - intensity * self.hsv_max_val), + intensity * hsv_max_val), hsv[:, :, 2]) hsv[:, :, 1] = np.where(np.logical_and(np.abs(hsv[:, :, 1]) > 1.e-10, intensity < 0), ((1. + intensity) * hsv[:, :, 1] - - intensity * self.hsv_min_sat), + intensity * hsv_min_sat), hsv[:, :, 1]) hsv[:, :, 2] = np.where(intensity < 0, ((1. + intensity) * hsv[:, :, 2] - - intensity * self.hsv_min_val), + intensity * hsv_min_val), hsv[:, :, 2]) hsv[:, :, 1:] = np.where(hsv[:, :, 1:] < 0., 0, hsv[:, :, 1:]) hsv[:, :, 1:] = np.where(hsv[:, :, 1:] > 1., 1, hsv[:, :, 1:]) # convert modified hsv back to rgb. return hsv_to_rgb(hsv) + def blend_soft_light(self, rgb, intensity): + """ + Combines an rgb image with an intensity map using "soft light" + blending. Uses the "pegtop" formula. + + Parameters + ---------- + rgb : ndarray + An MxNx3 RGB array of floats ranging from 0 to 1 (color image). + intensity : ndarray + An MxNx1 array of floats ranging from 0 to 1 (grayscale image). + + Returns + ------- + rgb : ndarray + An MxNx3 RGB array representing the combined images. + """ + return 2 * intensity * rgb + (1 - 2 * intensity) * rgb**2 + + def blend_overlay(self, rgb, intensity): + """ + Combines an rgb image with an intensity map using "overlay" blending. + + Parameters + ---------- + rgb : ndarray + An MxNx3 RGB array of floats ranging from 0 to 1 (color image). + intensity : ndarray + An MxNx1 array of floats ranging from 0 to 1 (grayscale image). + + Returns + ------- + rgb : ndarray + An MxNx3 RGB array representing the combined images. + """ + low = 2 * intensity * rgb + high = 1 - 2 * (1 - intensity) * (1 - rgb) + return np.where(rgb <= 0.5, low, high) + def from_levels_and_colors(levels, colors, extend='neither'): """ diff --git a/lib/matplotlib/mpl-data/sample_data/jacksboro_fault_dem.npz b/lib/matplotlib/mpl-data/sample_data/jacksboro_fault_dem.npz new file mode 100644 index 000000000000..d25028648b1d Binary files /dev/null and b/lib/matplotlib/mpl-data/sample_data/jacksboro_fault_dem.npz differ diff --git a/lib/matplotlib/tests/baseline_images/test_colors/light_source_shading_topo.png b/lib/matplotlib/tests/baseline_images/test_colors/light_source_shading_topo.png new file mode 100644 index 000000000000..332ab0a886b7 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colors/light_source_shading_topo.png differ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 38f013cc9407..5423e45d65e1 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -2,6 +2,7 @@ unicode_literals) import six +import itertools from nose.tools import assert_raises @@ -10,6 +11,7 @@ import matplotlib.colors as mcolors import matplotlib.cm as cm +import matplotlib.cbook as cbook import matplotlib.pyplot as plt from matplotlib.testing.decorators import image_comparison, cleanup @@ -228,32 +230,205 @@ def gray_from_float_rgba(): assert_raises(ValueError, gray_from_float_rgba) -def test_light_source_shading_color_range(): - # see also - #http://matplotlib.org/examples/pylab_examples/shading_example.html - - from matplotlib.colors import LightSource - from matplotlib.colors import Normalize - - refinput = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]) - norm = Normalize(vmin=0, vmax=50) - ls = LightSource(azdeg=0, altdeg=65) - testoutput = ls.shade(refinput, plt.cm.jet, norm=norm) - refoutput = np.array([ - [[0., 0., 0.58912656, 1.], - [0., 0., 0.67825312, 1.], - [0., 0., 0.76737968, 1.], - [0., 0., 0.85650624, 1.]], - [[0., 0., 0.9456328, 1.], - [0., 0., 1., 1.], - [0., 0.04901961, 1., 1.], - [0., 0.12745098, 1., 1.]], - [[0., 0.22156863, 1., 1.], - [0., 0.3, 1., 1.], - [0., 0.37843137, 1., 1.], - [0., 0.45686275, 1., 1.]] - ]) - assert_array_almost_equal(refoutput, testoutput) +@image_comparison(baseline_images=['light_source_shading_topo'], + extensions=['png']) +def test_light_source_topo_surface(): + """Shades a DEM using different v.e.'s and blend modes.""" + fname = cbook.get_sample_data('jacksboro_fault_dem.npz', asfileobj=False) + with np.load(fname) as dem: + elev = dem['elevation'] + # Get the true cellsize in meters for accurate vertical exaggeration + # Convert from decimal degrees to meters + dx, dy = dem['dx'], dem['dy'] + dx = 111320.0 * dx * np.cos(dem['ymin']) + dy = 111320.0 * dy + + ls = mcolors.LightSource(315, 45) + cmap = cm.gist_earth + + fig, axes = plt.subplots(nrows=3, ncols=3) + for row, mode in zip(axes, ['hsv', 'overlay', 'soft']): + for ax, ve in zip(row, [0.1, 1, 10]): + rgb = ls.shade(elev, cmap, vert_exag=ve, dx=dx, dy=dy, + blend_mode=mode) + ax.imshow(rgb) + ax.set(xticks=[], yticks=[]) + + +def test_light_source_shading_default(): + """Array comparison test for the default "hsv" blend mode. Ensure the + default result doesn't change without warning.""" + y, x = np.mgrid[-1.2:1.2:8j, -1.2:1.2:8j] + z = 10 * np.cos(x**2 + y**2) + + cmap = plt.cm.copper + ls = mcolors.LightSource(315, 45) + rgb = ls.shade(z, cmap) + + # Result stored transposed and rounded for for more compact display... + expect = np.array([[[0.87, 0.85, 0.90, 0.90, 0.82, 0.62, 0.34, 0.00], + [0.85, 0.94, 0.99, 1.00, 1.00, 0.96, 0.62, 0.17], + [0.90, 0.99, 1.00, 1.00, 1.00, 1.00, 0.71, 0.33], + [0.90, 1.00, 1.00, 1.00, 1.00, 0.98, 0.51, 0.29], + [0.82, 1.00, 1.00, 1.00, 1.00, 0.64, 0.25, 0.13], + [0.62, 0.96, 1.00, 0.98, 0.64, 0.22, 0.06, 0.03], + [0.34, 0.62, 0.71, 0.51, 0.25, 0.06, 0.00, 0.01], + [0.00, 0.17, 0.33, 0.29, 0.13, 0.03, 0.01, 0.00]], + + [[0.87, 0.79, 0.83, 0.80, 0.66, 0.44, 0.23, 0.00], + [0.79, 0.88, 0.93, 0.92, 0.83, 0.66, 0.38, 0.10], + [0.83, 0.93, 0.99, 1.00, 0.92, 0.75, 0.40, 0.18], + [0.80, 0.92, 1.00, 0.99, 0.93, 0.75, 0.28, 0.14], + [0.66, 0.83, 0.92, 0.93, 0.87, 0.44, 0.12, 0.06], + [0.44, 0.66, 0.75, 0.75, 0.44, 0.12, 0.03, 0.01], + [0.23, 0.38, 0.40, 0.28, 0.12, 0.03, 0.00, 0.00], + [0.00, 0.10, 0.18, 0.14, 0.06, 0.01, 0.00, 0.00]], + + [[0.87, 0.75, 0.78, 0.73, 0.55, 0.33, 0.16, 0.00], + [0.75, 0.85, 0.90, 0.86, 0.71, 0.48, 0.23, 0.05], + [0.78, 0.90, 0.98, 1.00, 0.82, 0.51, 0.21, 0.08], + [0.73, 0.86, 1.00, 0.97, 0.84, 0.47, 0.11, 0.05], + [0.55, 0.71, 0.82, 0.84, 0.71, 0.20, 0.03, 0.01], + [0.33, 0.48, 0.51, 0.47, 0.20, 0.02, 0.00, 0.00], + [0.16, 0.23, 0.21, 0.11, 0.03, 0.00, 0.00, 0.00], + [0.00, 0.05, 0.08, 0.05, 0.01, 0.00, 0.00, 0.00]], + + [[1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00]]]).T + assert_array_almost_equal(rgb, expect, decimal=2) + + +def test_light_source_masked_shading(): + """Array comparison test for a surface with a masked portion. Ensures that + we don't wind up with "fringes" of odd colors around masked regions.""" + y, x = np.mgrid[-1.2:1.2:8j, -1.2:1.2:8j] + z = 10 * np.cos(x**2 + y**2) + + z = np.ma.masked_greater(z, 9.9) + + cmap = plt.cm.copper + ls = mcolors.LightSource(315, 45) + rgb = ls.shade(z, cmap) + + # Result stored transposed and rounded for for more compact display... + expect = np.array([[[1.00, 0.95, 0.96, 0.94, 0.86, 0.67, 0.40, 0.03], + [0.95, 0.99, 1.00, 1.00, 1.00, 0.98, 0.67, 0.19], + [0.96, 1.00, 1.00, 1.00, 1.00, 1.00, 0.78, 0.36], + [0.94, 1.00, 1.00, 0.00, 0.00, 1.00, 0.55, 0.32], + [0.86, 1.00, 1.00, 0.00, 0.00, 1.00, 0.27, 0.14], + [0.67, 0.98, 1.00, 1.00, 1.00, 1.00, 0.07, 0.03], + [0.40, 0.67, 0.78, 0.55, 0.27, 0.07, 0.00, 0.01], + [0.03, 0.19, 0.36, 0.32, 0.14, 0.03, 0.01, 0.00]], + + [[1.00, 0.93, 0.93, 0.88, 0.72, 0.50, 0.28, 0.03], + [0.93, 0.97, 0.99, 0.96, 0.87, 0.70, 0.42, 0.11], + [0.93, 0.99, 0.74, 0.78, 0.78, 0.74, 0.45, 0.20], + [0.88, 0.96, 0.78, 0.00, 0.00, 0.78, 0.32, 0.16], + [0.72, 0.87, 0.78, 0.00, 0.00, 0.78, 0.14, 0.06], + [0.50, 0.70, 0.74, 0.78, 0.78, 0.74, 0.03, 0.01], + [0.28, 0.42, 0.45, 0.32, 0.14, 0.03, 0.00, 0.00], + [0.03, 0.11, 0.20, 0.16, 0.06, 0.01, 0.00, 0.00]], + + [[1.00, 0.91, 0.91, 0.84, 0.64, 0.39, 0.21, 0.03], + [0.91, 0.96, 0.98, 0.93, 0.77, 0.53, 0.27, 0.06], + [0.91, 0.98, 0.47, 0.50, 0.50, 0.47, 0.25, 0.10], + [0.84, 0.93, 0.50, 0.00, 0.00, 0.50, 0.13, 0.06], + [0.64, 0.77, 0.50, 0.00, 0.00, 0.50, 0.03, 0.01], + [0.39, 0.53, 0.47, 0.50, 0.50, 0.47, 0.00, 0.00], + [0.21, 0.27, 0.25, 0.13, 0.03, 0.00, 0.00, 0.00], + [0.03, 0.06, 0.10, 0.06, 0.01, 0.00, 0.00, 0.00]], + + [[1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 0.00, 0.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 0.00, 0.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00], + [1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00, 1.00]]]).T + assert_array_almost_equal(rgb, expect, decimal=2) + + +def test_light_source_hillshading(): + """Compare the current hillshading method against one that should be + mathematically equivalent. Illuminates a cone from a range of angles.""" + + def alternative_hillshade(azimuth, elev, z): + illum = _sph2cart(*_azimuth2math(azimuth, elev)) + illum = np.array(illum) + + dy, dx = np.gradient(-z) + dy = -dy + dz = np.ones_like(dy) + normals = np.dstack([dx, dy, dz]) + normals /= np.linalg.norm(normals, axis=2)[..., None] + + intensity = np.tensordot(normals, illum, axes=(2, 0)) + intensity -= intensity.min() + intensity /= intensity.ptp() + return intensity + + y, x = np.mgrid[5:0:-1, :5] + z = -np.hypot(x - x.mean(), y - y.mean()) + + for az, elev in itertools.product(range(0, 390, 30), range(0, 105, 15)): + ls = mcolors.LightSource(az, elev) + h1 = ls.hillshade(z) + h2 = alternative_hillshade(az, elev, z) + assert_array_almost_equal(h1, h2) + + +def test_light_source_planar_hillshading(): + """Ensure that the illumination intensity is correct for planar + surfaces.""" + + def plane(azimuth, elevation, x, y): + """Create a plane whose normal vector is at the given azimuth and + elevation.""" + theta, phi = _azimuth2math(azimuth, elevation) + a, b, c = _sph2cart(theta, phi) + z = -(a*x + b*y) / c + return z + + def angled_plane(azimuth, elevation, angle, x, y): + """Create a plane whose normal vector is at an angle from the given + azimuth and elevation.""" + elevation = elevation + angle + if elevation > 90: + azimuth = (azimuth + 180) % 360 + elevation = (90 - elevation) % 90 + return plane(azimuth, elevation, x, y) + + y, x = np.mgrid[5:0:-1, :5] + for az, elev in itertools.product(range(0, 390, 30), range(0, 105, 15)): + ls = mcolors.LightSource(az, elev) + + # Make a plane at a range of angles to the illumination + for angle in range(0, 105, 15): + z = angled_plane(az, elev, angle, x, y) + h = ls.hillshade(z) + assert_array_almost_equal(h, np.cos(np.radians(angle))) + + +def _sph2cart(theta, phi): + x = np.cos(theta) * np.sin(phi) + y = np.sin(theta) * np.sin(phi) + z = np.cos(phi) + return x, y, z + + +def _azimuth2math(azimuth, elevation): + """Converts from clockwise-from-north and up-from-horizontal to + mathematical conventions.""" + theta = np.radians((90 - azimuth) % 360) + phi = np.radians(90 - elevation) + return theta, phi if __name__ == '__main__':