Skip to content

Commit f23ff70

Browse files
committed
Fix #190 (handle set_under/set_over cmap extremes)
1 parent 6134625 commit f23ff70

File tree

4 files changed

+128
-93
lines changed

4 files changed

+128
-93
lines changed

docs/cycles.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
# named color cycles are actually registered as `~proplot.colors.ListedColormap`
2626
# instances so that they can be `used with categorical data\
2727
# <https://journals.ametsoc.org/view-large/figure/9538246/bams-d-13-00155_1-f5.tif>`__.
28-
# Much more commonly, we build `*property cycles*\
28+
# Much more commonly, we build `property cycles\
2929
# <https://matplotlib.org/3.1.0/tutorials/intermediate/color_cycle.html>`__
3030
# from the `~proplot.colors.ListedColormap` colors using the
3131
# `~proplot.constructor.Cycle` constructor function or by
@@ -48,8 +48,15 @@
4848
# You can make your own color cycles using the `~proplot.constructor.Cycle`
4949
# constructor function.
5050

51+
# %%
52+
import numpy as np
53+
import pandas as pd
54+
55+
# %%
56+
# %%
5157
# %%
5258
import proplot as plot
59+
5360
fig, axs = plot.show_cycles()
5461

5562

@@ -69,9 +76,6 @@
6976
# `~proplot.constructor.Cycle` to the :rcraw:`axes.prop_cycle` setting (see
7077
# the :ref:`configuration guide <ug_config>`).
7178

72-
# %%
73-
import proplot as plot
74-
import numpy as np
7579
lw = 5
7680
state = np.random.RandomState(51423)
7781
data = (state.rand(12, 6) - 0.45).cumsum(axis=0)
@@ -122,9 +126,6 @@
122126
# lines are referenced with colorbars and legends. Note that ProPlot allows
123127
# you to :ref:`generate colorbars from lists of lines <ug_cbars>`.
124128

125-
# %%
126-
import proplot as plot
127-
import numpy as np
128129
fig, axs = plot.subplots(ncols=2, share=0, axwidth=2.3)
129130
state = np.random.RandomState(51423)
130131
data = (20 * state.rand(10, 21) - 10).cumsum(axis=0)
@@ -159,9 +160,6 @@
159160
# constructed and applied to the axes locally. To apply it globally, simply
160161
# use ``plot.rc['axes.prop_cycle'] = cycle``.
161162

162-
# %%
163-
import numpy as np
164-
import pandas as pd
165163

166164
# Create cycle that loops through 'dashes' Line2D property
167165
cycle = plot.Cycle(dashes=[(1, 0.5), (1, 1.5), (3, 0.5), (3, 1.5)])

proplot/axes/plot.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2618,7 +2618,7 @@ def cycle_changer(
26182618
def _build_discrete_norm(
26192619
data=None, N=None, levels=None, values=None,
26202620
norm=None, norm_kw=None, locator=None, locator_kw=None,
2621-
cmap=None, vmin=None, vmax=None, extend='neither', symmetric=False,
2621+
cmap=None, vmin=None, vmax=None, extend=None, symmetric=False,
26222622
minlength=2,
26232623
):
26242624
"""
@@ -2816,13 +2816,10 @@ def _build_discrete_norm(
28162816

28172817
# Generate DiscreteNorm and update "child" norm with vmin and vmax from
28182818
# levels. This lets the colorbar set tick locations properly!
2819+
# TODO: Move these to DiscreteNorm?
28192820
if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
2820-
if getattr(cmap, '_cyclic', None):
2821-
bin_kw = {'step': 0.5, 'extend': 'both'} # omit end colors
2822-
else:
2823-
bin_kw = {'extend': extend}
28242821
norm = pcolors.DiscreteNorm(
2825-
levels, norm=norm, descending=descending, **bin_kw
2822+
levels, cmap=cmap, norm=norm, descending=descending, unique=extend,
28262823
)
28272824
if descending:
28282825
cmap = cmap.reversed()
@@ -2832,7 +2829,7 @@ def _build_discrete_norm(
28322829
@warnings._rename_kwargs('0.6', centers='values')
28332830
@docstring.add_snippets
28342831
def cmap_changer(
2835-
self, func, *args, extend='neither',
2832+
self, func, *args, extend=None,
28362833
cmap=None, cmap_kw=None, norm=None, norm_kw=None,
28372834
vmin=None, vmax=None, N=None, levels=None, values=None,
28382835
symmetric=False, locator=None, locator_kw=None,
@@ -2920,22 +2917,6 @@ def cmap_changer(
29202917
proplot.constructor.Colormap
29212918
proplot.constructor.Norm
29222919
proplot.colors.DiscreteNorm
2923-
2924-
Note
2925-
----
2926-
The `~proplot.colors.DiscreteNorm` normalizer, used with all colormap
2927-
plots, makes sure that your levels always span the full range of colors
2928-
in the colormap, whether `extend` is set to ``'min'``, ``'max'``,
2929-
``'neither'``, or ``'both'``. By default, when `extend` is not ``'both'``,
2930-
matplotlib seems to just cut off the most intense colors (reserved for
2931-
coloring "out of bounds" data), even though they are not being used.
2932-
2933-
This could also be done by limiting the number of colors in the colormap
2934-
lookup table by selecting a smaller ``N`` (see
2935-
`~matplotlib.colors.LinearSegmentedColormap`). Instead, we prefer to
2936-
always build colormaps with high resolution lookup tables, and leave it
2937-
to the `~matplotlib.colors.Normalize` instance to handle discretization
2938-
of the color selections.
29392920
"""
29402921
name = func.__name__
29412922
autoformat = rc['autoformat'] # possibly manipulated by standardize_[12]d
@@ -2995,7 +2976,7 @@ def cmap_changer(
29952976
f'Cyclic colormap requires extend="neither". '
29962977
f'Overriding user input extend={extend!r}.'
29972978
)
2998-
extend = 'neither'
2979+
extend = None
29992980

30002981
# Translate standardized keyword arguments back into the keyword args
30012982
# accepted by native matplotlib methods. Also disable edgefix if user want

proplot/colors.py

Lines changed: 113 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,9 @@ def __repr__(self):
585585
if callable(data):
586586
string += f' {key!r}: <function>,\n'
587587
else:
588-
string += (f' {key!r}: [{data[0][2]:.3f}, '
589-
f'..., {data[-1][1]:.3f}],\n')
588+
string += (
589+
f' {key!r}: [{data[0][2]:.3f}, ..., {data[-1][1]:.3f}],\n'
590+
)
590591
return type(self).__name__ + '({\n' + string + '})'
591592

592593
@docstring.add_snippets
@@ -1230,7 +1231,8 @@ def __repr__(self):
12301231
'ListedColormap({\n'
12311232
f" 'name': {self.name!r},\n"
12321233
f" 'colors': {[mcolors.to_hex(color) for color in self.colors]},\n"
1233-
'})')
1234+
'})'
1235+
)
12341236

12351237
def __init__(self, *args, alpha=None, **kwargs):
12361238
"""
@@ -1840,9 +1842,10 @@ class DiscreteNorm(mcolors.BoundaryNorm):
18401842
# WARNING: Must be child of BoundaryNorm. Many methods in ColorBarBase
18411843
# test for class membership, crucially including _process_values(), which
18421844
# if it doesn't detect BoundaryNorm will try to use DiscreteNorm.inverse().
1845+
@warnings._rename_kwargs('0.7', extend='unique')
18431846
def __init__(
1844-
self, levels, norm=None, step=1.0, extend=None,
1845-
clip=False, descending=False,
1847+
self, levels, norm=None, cmap=None,
1848+
unique=None, step=None, clip=False, descending=False,
18461849
):
18471850
"""
18481851
Parameters
@@ -1854,18 +1857,24 @@ def __init__(
18541857
to `~DiscreteNorm.__call__` before discretization. The ``vmin``
18551858
and ``vmax`` of the normalizer are set to the minimum and
18561859
maximum values in `levels`.
1860+
cmap : `matplotlib.colors.Colormap`, optional
1861+
The colormap associated with this normalizer. This is used to
1862+
apply default `unique` and `step` settings depending on whether the
1863+
colormap is cyclic and whether distinct "extreme" colors have been
1864+
designated with `~matplotlib.colors.Colormap.set_under` and/or
1865+
`~matplotlib.colors.Colormap.set_over`.
1866+
unique : {'neither', 'both', 'min', 'max'}, optional
1867+
Which out-of-bounds regions should be assigned unique colormap
1868+
colors. The normalizer needs this information so it can ensure
1869+
the colorbar always spans the full range of colormap colors.
18571870
step : float, optional
18581871
The intensity of the transition to out-of-bounds colors as a
18591872
fraction of the adjacent step between in-bounds colors.
18601873
Default is ``1``.
1861-
extend : {'neither', 'both', 'min', 'max'}, optional
1862-
Which out-of-bounds regions should be assigned unique colormap
1863-
colors. The normalizer needs this information so it can ensure
1864-
the colorbar always spans the full range of colormap colors.
18651874
clip : bool, optional
18661875
Whether to clip values falling outside of the level bins. This
1867-
only has an effect on lower colors when extend is
1868-
``'min'`` or ``'both'``, and on upper colors when extend is
1876+
only has an effect on lower colors when unique is
1877+
``'min'`` or ``'both'``, and on upper colors when unique is
18691878
``'max'`` or ``'both'``.
18701879
descending : bool, optional
18711880
Whether the levels are meant to be descending. This will cause
@@ -1874,11 +1883,52 @@ def __init__(
18741883
18751884
Note
18761885
----
1877-
If you are using a diverging colormap with ``extend='max'`` or
1878-
``extend='min'``, the center will get messed up. But that is very
1879-
strange usage anyway... so please just don't do that :)
1880-
"""
1881-
# Parse input
1886+
This normalizer also makes sure that your levels always span the full range
1887+
of colors in the colormap, whether `extend` is set to ``'min'``, ``'max'``,
1888+
``'neither'``, or ``'both'``. By default, when `extend` is not ``'both'``,
1889+
matplotlib simply cuts off the most intense colors (reserved for
1890+
"out of bounds" data), even though they are not being used.
1891+
1892+
While this could also be done by limiting the number of colors in the colormap
1893+
lookup table by selecting a smaller ``N`` (see
1894+
`~matplotlib.colors.LinearSegmentedColormap`), ProPlot chooses to
1895+
always build colormaps with high resolution lookup tables and leave it
1896+
to the `~matplotlib.colors.Normalize` instance to handle *discretization*
1897+
of the color selections.
1898+
1899+
Note that this approach means if you use a diverging colormap with
1900+
``extend='max'`` or ``extend='min'``, the central color will get messed up.
1901+
But that is very strange usage anyway... so please just don't do that :)
1902+
"""
1903+
# Special properties specific to colormap type
1904+
if cmap is not None:
1905+
over = cmap._rgba_over
1906+
under = cmap._rgba_under
1907+
cyclic = getattr(cmap, '_cyclic', None)
1908+
if cyclic:
1909+
# *Scale bins* as if extend='both' to omit end colors
1910+
step = 0.5
1911+
unique = 'both'
1912+
1913+
else:
1914+
# *Scale bins* as if unique='neither' because there may be *discrete
1915+
# change* between minimum color and out-of-bounds colors.
1916+
if over is not None and under is not None:
1917+
unique = 'both'
1918+
elif over is not None:
1919+
# Turn off unique bin for over-bounds colors
1920+
if unique == 'both':
1921+
unique = 'min'
1922+
elif unique == 'max':
1923+
unique = 'neither'
1924+
elif under is not None:
1925+
# Turn off unique bin for under-bounds colors
1926+
if unique == 'both':
1927+
unique = 'min'
1928+
elif unique == 'max':
1929+
unique = 'neither'
1930+
1931+
# Validate input arguments
18821932
# NOTE: This must be a subclass BoundaryNorm, so ColorbarBase will
18831933
# detect it... even though we completely override it.
18841934
if not norm:
@@ -1887,12 +1937,13 @@ def __init__(
18871937
raise ValueError('Normalizer cannot be instance of BoundaryNorm.')
18881938
elif not isinstance(norm, mcolors.Normalize):
18891939
raise ValueError('Normalizer must be instance of Normalize.')
1890-
extend = extend or 'neither'
1891-
extends = ('both', 'min', 'max', 'neither')
1892-
if extend not in extends:
1940+
if unique is None:
1941+
unique = 'neither'
1942+
uniques = ('both', 'min', 'max', 'neither')
1943+
if unique not in uniques:
18931944
raise ValueError(
1894-
f'Unknown extend option {extend!r}. Options are: '
1895-
+ ', '.join(map(repr, extends)) + '.'
1945+
f'Unknown unique option {unique!r}. Options are: '
1946+
+ ', '.join(map(repr, uniques)) + '.'
18961947
)
18971948

18981949
# Ensure monotonically increasing levels
@@ -1909,8 +1960,9 @@ def __init__(
19091960
# 2 color coordinates for out-of-bounds color bins.
19101961
# For *same* out-of-bounds colors, looks like [0, 0, ..., 1, 1]
19111962
# For *unique* out-of-bounds colors, looks like [0, X, ..., 1 - X, 1]
1912-
# NOTE: For cyclic colormaps, _build_discrete_norm sets extend to
1913-
# 'both' and step to 0.5 so that we omit end colors.
1963+
# NOTE: Ensure out-of-bounds bin values correspond to out-of-bounds
1964+
# coordinate in case user used set_under or set_over to apply discrete
1965+
# change. See lukelbd/proplot#190
19141966
# NOTE: Critical that we scale the bin centers in "physical space"
19151967
# and *then* translate to color coordinates so that nonlinearities in
19161968
# the normalization stay intact. If we scaled the bin centers in
@@ -1921,9 +1973,11 @@ def __init__(
19211973
mids = np.zeros((levels.size + 1,))
19221974
mids[1:-1] = 0.5 * (levels[1:] + levels[:-1])
19231975
mids[0], mids[-1] = mids[1], mids[-2]
1924-
if extend in ('min', 'both'):
1976+
if step is None:
1977+
step = 1.0
1978+
if unique in ('min', 'both'):
19251979
mids[0] += step * (mids[1] - mids[2])
1926-
if extend in ('max', 'both'):
1980+
if unique in ('max', 'both'):
19271981
mids[-1] += step * (mids[-2] - mids[-3])
19281982
if vcenter is None:
19291983
mids = _interpolate_basic(
@@ -1937,15 +1991,18 @@ def __init__(
19371991
mids[mids >= vcenter] = _interpolate_basic(
19381992
mids[mids >= vcenter], vcenter, np.max(mids), vcenter, vmax,
19391993
)
1994+
eps = 1e-10 # mids and dest are numpy.float64
19401995
dest = norm(mids)
1996+
dest[0] -= eps
1997+
dest[-1] += eps
19411998

19421999
# Attributes
19432000
# NOTE: If clip is True, we clip values to the centers of the end
19442001
# bins rather than vmin/vmax to prevent out-of-bounds colors from
19452002
# getting an in-bounds bin color due to landing on a bin edge.
1946-
# NOTE: With extend='min' the minimimum in-bounds and out-of-bounds
2003+
# NOTE: With unique='min' the minimimum in-bounds and out-of-bounds
19472004
# colors are the same so clip=True will have no effect. Same goes
1948-
# for extend='max' with maximum colors.
2005+
# for unique='max' with maximum colors.
19492006
# WARNING: For some reason must clip manually for LogNorm, or
19502007
# end up with unpredictable fill value, weird "out-of-bounds" colors
19512008
self._bmin = np.min(mids)
@@ -2272,6 +2329,34 @@ def _get_cmap(name=None, lut=None):
22722329
return cmap
22732330

22742331

2332+
def _to_proplot_colormap(cmap):
2333+
"""
2334+
Translate the input argument to a ProPlot colormap subclass.
2335+
"""
2336+
cmap_orig = cmap
2337+
if isinstance(cmap, (ListedColormap, LinearSegmentedColormap)):
2338+
pass
2339+
elif isinstance(cmap, mcolors.LinearSegmentedColormap):
2340+
cmap = LinearSegmentedColormap(
2341+
cmap.name, cmap._segmentdata, cmap.N, cmap._gamma
2342+
)
2343+
elif isinstance(cmap, mcolors.ListedColormap):
2344+
cmap = ListedColormap(
2345+
cmap.colors, cmap.name, cmap.N
2346+
)
2347+
elif isinstance(cmap, mcolors.Colormap): # base class
2348+
pass
2349+
else:
2350+
raise ValueError(
2351+
f'Invalid colormap type {type(cmap).__name__!r}. '
2352+
'Must be instance of matplotlib.colors.Colormap.'
2353+
)
2354+
cmap._rgba_bad = cmap_orig._rgba_bad
2355+
cmap._rgba_under = cmap_orig._rgba_under
2356+
cmap._rgba_over = cmap_orig._rgba_over
2357+
return cmap
2358+
2359+
22752360
class ColormapDatabase(dict):
22762361
"""
22772362
Dictionary subclass used to replace the matplotlib
@@ -2353,25 +2438,8 @@ def __setitem__(self, key, item):
23532438
"""
23542439
if not isinstance(key, str):
23552440
raise KeyError(f'Invalid key {key!r}. Must be string.')
2356-
if isinstance(item, (ListedColormap, LinearSegmentedColormap)):
2357-
pass
2358-
elif isinstance(item, mcolors.LinearSegmentedColormap):
2359-
item = LinearSegmentedColormap(
2360-
item.name, item._segmentdata, item.N, item._gamma
2361-
)
2362-
elif isinstance(item, mcolors.ListedColormap):
2363-
item = ListedColormap(
2364-
item.colors, item.name, item.N
2365-
)
2366-
elif isinstance(item, mcolors.Colormap): # base class
2367-
pass
2368-
else:
2369-
raise ValueError(
2370-
f'Invalid colormap {item}. Must be instance of '
2371-
'matplotlib.colors.ListedColormap or '
2372-
'matplotlib.colors.LinearSegmentedColormap.'
2373-
)
23742441
key = self._sanitize_key(key, mirror=False)
2442+
item = _to_proplot_colormap(item)
23752443
super().__setitem__(key, item)
23762444

23772445
def __contains__(self, item):

proplot/constructor.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -538,20 +538,8 @@ def _parse_modification(key, value):
538538
pass
539539

540540
# Convert matplotlib colormaps to subclasses
541-
if isinstance(arg, (
542-
pcolors.ListedColormap, pcolors.LinearSegmentedColormap
543-
)):
544-
cmap = arg
545-
elif isinstance(arg, mcolors.LinearSegmentedColormap):
546-
cmap = pcolors.LinearSegmentedColormap(
547-
arg.name, arg._segmentdata, arg.N, arg._gamma
548-
)
549-
elif isinstance(arg, mcolors.ListedColormap):
550-
cmap = pcolors.ListedColormap(
551-
arg.colors, arg.name, arg.N
552-
)
553-
elif isinstance(arg, mcolors.Colormap): # base class
554-
cmap = arg
541+
if isinstance(arg, mcolors.Colormap):
542+
cmap = pcolors._to_proplot_colormap(arg)
555543

556544
# Dictionary of hue/sat/luminance values or 2-tuples
557545
elif isinstance(arg, dict):

0 commit comments

Comments
 (0)