Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Fix contour level color mapping with explicit limits
  • Loading branch information
cvanelteren committed Feb 26, 2026
commit 6c87a648f776d58142234d780a4780756368ae17
29 changes: 21 additions & 8 deletions ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4555,6 +4555,8 @@ def _parse_level_vals(
nozero=False,
norm=None,
norm_kw=None,
vmin=None,
vmax=None,
skip_autolev=False,
min_levels=None,
center_levels=None,
Expand All @@ -4578,6 +4580,8 @@ def _parse_level_vals(
levels. The latter is useful for single-color contour plots.
norm, norm_kw : optional
Passed to `Norm`. Used to possbily infer levels or to convert values.
Comment thread
cvanelteren marked this conversation as resolved.
Outdated
vmin, vmax : float, optional
The user input normalization range.
skip_autolev : bool, optional
Whether to skip automatic level generation.
min_levels : int, optional
Expand Down Expand Up @@ -4625,7 +4629,6 @@ def _sanitize_levels(key, array, minsize):
return array

# Parse input arguments and resolve incompatibilities
vmin = vmax = None
levels = _not_none(N=N, levels=levels, norm_kw_levs=norm_kw.pop("levels", None))
if positive and negative:
warnings._warn_ultraplot(
Expand Down Expand Up @@ -4696,18 +4699,23 @@ def _sanitize_levels(key, array, minsize):
levels = values = None

# Determine default colorbar locator and norm and apply filters
# NOTE: DiscreteNorm does not currently support vmin and
# vmax different from level list minimum and maximum.
# NOTE: Explicit vmin/vmax should override defaults inferred from levels.
# NOTE: The level restriction should have no effect if levels were generated
# automatically. However want to apply these to manual-input levels as well.
if levels is not None:
levels = _restrict_levels(levels)
if len(levels) == 0: # skip
pass
elif len(levels) == 1: # use central colormap color
vmin, vmax = levels[0] - 1, levels[0] + 1
if vmin is None:
vmin = levels[0] - 1
if vmax is None:
vmax = levels[0] + 1
else: # use minimum and maximum
vmin, vmax = np.min(levels), np.max(levels)
if vmin is None:
vmin = np.min(levels)
if vmax is None:
vmax = np.max(levels)
if not np.allclose(levels[1] - levels[0], np.diff(levels)):
norm = _not_none(norm, "segmented")
if norm in ("segments", "segmented"):
Expand Down Expand Up @@ -4814,10 +4822,15 @@ def _parse_level_norm(
elif extend == "max":
unique = "neither"

# Generate DiscreteNorm and update "child" norm with vmin and vmax from
# levels. This lets the colorbar set tick locations properly!
# Generate DiscreteNorm for filled-contour style bins. For line contours
# (`min_levels == 1`) levels represent contour values, so keep the
# continuous normalizer to preserve one-to-one value->color mapping.
center_levels = _not_none(center_levels, rc["colorbar.center_levels"])
Comment on lines +4846 to 4849
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The _parse_level_norm docstring says it returns a DiscreteNorm/BoundaryNorm, but with the new min_levels == 1 behavior it can return the original continuous Normalize (line contours). Please update the Returns section to reflect the possible return types so users don't rely on an incorrect contract.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@copilot open a new pull request to apply changes based on this feedback

if not isinstance(norm, mcolors.BoundaryNorm) and len(levels) > 1:
if (
min_levels != 1
and not isinstance(norm, mcolors.BoundaryNorm)
and len(levels) > 1
):
norm = pcolors.DiscreteNorm(
levels,
norm=norm,
Expand Down
42 changes: 26 additions & 16 deletions ultraplot/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2492,8 +2492,9 @@ def __init__(
colorbar axis drawn with this normalizer will be reversed.
norm : `~matplotlib.colors.Normalize`, optional
The normalizer used to transform `levels` and data values passed to
`~DiscreteNorm.__call__` before discretization. The ``vmin`` and ``vmax``
of the normalizer are set to the minimum and maximum values in `levels`.
`~DiscreteNorm.__call__` before discretization. If the normalizer
``vmin`` and ``vmax`` are unset, they default to the minimum and
maximum values in `levels`.
unique : {'neither', 'both', 'min', 'max'}, optional
Which out-of-bounds regions should be assigned unique colormap colors.
Possible values are equivalent to the `extend` values. Internally, ultraplot
Expand Down Expand Up @@ -2522,11 +2523,10 @@ def __init__(

Note
----
This normalizer makes sure that levels always span the full range of
colors in the colormap, whether `extend` is set to ``'min'``, ``'max'``,
``'neither'``, or ``'both'``. In matplotlib, when `extend` is not ``'both'``,
the most intense colors are cut off (reserved for "out of bounds" data),
even though they are not being used.
By default this normalizer makes level bins span the full colormap range,
including when `extend` is set to ``'min'``, ``'max'``, ``'neither'``, or
``'both'``. If the input normalizer has explicit ``vmin`` and ``vmax`` that
differ from the level bounds, bins instead follow that explicit range.

See also
--------
Expand Down Expand Up @@ -2561,8 +2561,12 @@ def __init__(
# Instead user-reversed levels will always get passed here just as
# they are passed to SegmentedNorm inside plot.py
levels, descending = _sanitize_levels(levels)
vmin = norm.vmin = np.min(levels)
vmax = norm.vmax = np.max(levels)
level_min = np.min(levels)
level_max = np.max(levels)
vmin = _not_none(norm.vmin, level_min)
vmax = _not_none(norm.vmax, level_max)
norm.vmin = vmin
norm.vmax = vmax
bins, _ = _sanitize_levels(norm(levels))
vcenter = getattr(norm, "vcenter", None)
mids = np.zeros((levels.size + 1,))
Expand All @@ -2582,13 +2586,19 @@ def __init__(
mids[0] += step * (mids[1] - mids[2])
if unique in ("max", "both"):
mids[-1] += step * (mids[-2] - mids[-3])
mmin, mmax = np.min(mids), np.max(mids)
if vcenter is None:
mids = _interpolate_scalar(mids, mmin, mmax, vmin, vmax)
else:
mask1, mask2 = mids < vcenter, mids >= vcenter
mids[mask1] = _interpolate_scalar(mids[mask1], mmin, vcenter, vmin, vcenter)
mids[mask2] = _interpolate_scalar(mids[mask2], vcenter, mmax, vcenter, vmax)
stretch = np.isclose(vmin, level_min) and np.isclose(vmax, level_max)
if stretch:
mmin, mmax = np.min(mids), np.max(mids)
if vcenter is None:
mids = _interpolate_scalar(mids, mmin, mmax, vmin, vmax)
else:
mask1, mask2 = mids < vcenter, mids >= vcenter
mids[mask1] = _interpolate_scalar(
mids[mask1], mmin, vcenter, vmin, vcenter
)
mids[mask2] = _interpolate_scalar(
mids[mask2], vcenter, mmax, vcenter, vmax
)

# Instance attributes
# NOTE: If clip is True, we clip values to the centers of the end bins
Expand Down
45 changes: 45 additions & 0 deletions ultraplot/tests/test_2dplots.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import numpy as np
import pytest
import xarray as xr
from matplotlib.colors import Normalize

import ultraplot as uplt, warnings

Expand Down Expand Up @@ -291,6 +292,50 @@ def test_levels_with_vmin_vmax(rng):
return fig


def test_contour_levels_respect_explicit_vmin_vmax():
"""
Explicit `vmin` and `vmax` should be preserved with manual contour levels.
"""
data = np.linspace(0, 10, 25).reshape((5, 5))
levels = [2, 4, 6]
_, ax = uplt.subplots()
m = ax.contourf(data, levels=levels, cmap="viridis", vmin=0, vmax=10)
assert m.norm.vmin == pytest.approx(0)
assert m.norm.vmax == pytest.approx(10)
assert m.norm._norm.vmin == pytest.approx(0)
assert m.norm._norm.vmax == pytest.approx(10)
assert m.norm(3) == pytest.approx(0.3)
assert m.norm(5) == pytest.approx(0.5)


def test_contour_levels_default_stretch():
"""
Without explicit limits, level bins should continue to span full cmap range.
"""
data = np.linspace(0, 10, 25).reshape((5, 5))
levels = [2, 4, 6]
_, ax = uplt.subplots()
m = ax.contourf(data, levels=levels, cmap="viridis")
assert m.norm(3) == pytest.approx(0.0)
assert m.norm(5) == pytest.approx(1.0)


def test_contour_explicit_colors_match_levels():
"""
Explicit contour line colors should map one-to-one with contour levels.
"""
x = np.linspace(-1, 1, 100)
y = np.linspace(-1, 1, 100)
X, Y = np.meshgrid(x, y)
Z = np.exp(-(X**2 + Y**2))
levels = [0.3, 0.6, 0.9]
turbo = uplt.Colormap("turbo")
colors = turbo(Normalize(vmin=0, vmax=1)(levels))
_, ax = uplt.subplots()
m = ax.contour(X, Y, Z, levels=levels, colors=colors, linewidths=1)
assert np.allclose(np.asarray(m.get_edgecolor()), colors)


@pytest.mark.mpl_image_compare
def test_level_restriction(rng):
"""
Expand Down
Loading