From 2cc7653d2c7f1aaff0eab132291efdf9029328b5 Mon Sep 17 00:00:00 2001 From: Matthias Schabel Date: Sun, 9 Aug 2026 14:51:56 -0700 Subject: [PATCH 1/2] fix: treat unmasked nans as bad when the input is a masked array The mask and the nan test were exclusive, so a masked array's unmasked nans were never found. Such a value survived to the int cast, where it became INT_MIN, and take(..., mode="clip") clipped it to index 0: nan rendered as the colormap's first ramp color instead of `bad`, silently and plausibly. The same values in a plain ndarray render correctly, so the two input types disagreed about identical data. `bad` is documented as the color for values that are "NaN or masked" (docs/faq.md), so this restores the stated contract. The nan test is applied only to float dtypes on the masked path, keeping object-dtype masked arrays working, and the mask is combined with `|` rather than `|=` so the caller's own mask is left alone. Co-Authored-By: Claude Opus 5 (1M context) Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh) --- src/cmap/_colormap.py | 10 ++++++++-- tests/test_colormap.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index c20371cbd..de68a823c 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -405,8 +405,14 @@ def __call__( mask_under = xa < 0 mask_over = xa >= N - # If input was masked, get the bad mask from it; else mask out nans. - mask_bad = x.mask if np.ma.is_masked(x) else np.isnan(xa) # type: ignore + # If input was masked, start from its mask: a masked array can still carry + # unmasked nans. `|` rather than `|=`, so x's own mask isn't written to. + if np.ma.is_masked(x): + mask_bad = x.mask # type: ignore + if xa.dtype.kind == "f": + mask_bad = mask_bad | np.isnan(xa) + else: + mask_bad = np.isnan(xa) with np.errstate(invalid="ignore"): # We need this cast for unsigned ints as well as floats diff --git a/tests/test_colormap.py b/tests/test_colormap.py index d1550136b..f4911cfd8 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -135,6 +135,23 @@ def test_colormap_apply() -> None: assert cmap1(swapped).shape == (10, 10, 4) +def test_colormap_masked_array_with_unmasked_nan() -> None: + cmap = Colormap("viridis", bad="red") + mask = [True, False, False, False] + data = np.ma.masked_array([0.0, 0.25, np.nan, 1.0], mask=mask) + + rgba = cmap(data) + + npt.assert_array_equal(rgba[0], Color("red").rgba) # masked + npt.assert_array_equal(rgba[2], Color("red").rgba) # nan, not masked + npt.assert_array_equal(rgba[[1, 3]], cmap(np.array([0.25, 1.0]))) + npt.assert_array_equal(data.mask, mask) + + # an all-false mask takes the same path as a plain array + all_false = np.ma.masked_array([0.25, np.nan], mask=[False, False]) + npt.assert_array_equal(cmap(all_false), cmap(np.array([0.25, np.nan]))) + + def test_fill_stops() -> None: assert _fill_stops([None, None, None]) == [0, 0.5, 1.0] assert _fill_stops([None, 0.8, None]) == [0, 0.8, 1.0] From e53f057de2ca968764cece419b9ef1b3cfcfd990 Mon Sep 17 00:00:00 2001 From: Matthias Schabel Date: Sun, 9 Aug 2026 15:06:33 -0700 Subject: [PATCH 2/2] docs: correct and complete the NaN, masked, and infinity documentation The `bad` parameter was documented as the color for "(NaN, inf)" values, but infinities are not routed there: negative infinity uses `under` and positive infinity uses `over`, falling back to the first and last ramp colors when unset. `__call__`, the primary API, said nothing about exceptional values at all. Documents the routing where a reader looks for it: the constructor parameters, the under/over/bad attributes, `__call__`, and `lut`. The `__call__` text is scoped to float input, since integer input indexes the LUT directly and only leaves the ramp at or beyond N, or below 0. "When no ... color is set" rather than "if not provided", because a catalog entry can supply one (napari:HiLo, napari:nan). Documentation only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh) --- src/cmap/_colormap.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index de68a823c..849261b5e 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -130,11 +130,16 @@ class Colormap: will use `"nearest"`. Providing this value will override any interpolation from a catalog entry. under : ColorLike | None - The color to use for values below the colormap's range. + The color to use for values below the colormap's range, including negative + infinity. When no under color is set, the first color in the colormap is + used. over : ColorLike | None - The color to use for values above the colormap's range. + The color to use for values above the colormap's range, including positive + infinity. When no over color is set, the last color in the colormap is used. bad : ColorLike | None - The color to use for bad (NaN, inf) values. + The color to use for NaN and masked values. When no bad color is set, they + are transparent. Note that infinities are not bad values here: they use + `under` and `over`. Raises ------ @@ -200,21 +205,21 @@ class Colormap: """ under_color: Color | None - """A color to use for values below 0 when converting scalars to colors. + """A color to use for values below 0, and for negative infinity. If provided, and `Colormap.lut` is called with `with_over_under=True`, `under_color` will be the third-to-last color in the LUT (`lut[-3]`). """ over_color: Color | None - """A color to use for values above 1 when converting scalars to colors. + """A color to use for values above 1, and for positive infinity. If provided, and `Colormap.lut` is called with `with_over_under=True`, `over_color` will be the second-to-last color in the LUT (`lut[-2]`). """ bad_color: Color | None - """A color to use for missing/bad values when converting scalars to colors. + """A color to use for NaN and masked values when converting scalars to colors. If provided, and `Colormap.lut` is called with `with_over_under=True`, `bad_color` will be the last color in the LUT (`lut[-1]`). @@ -350,6 +355,20 @@ def __call__( be a normalized value in [0, 1] and will be mapped linearly to the nearest color in the LUT (use a higher N for finer sampling). + For float input, values outside the [0, 1] range and values that are not + finite do not map into the ramp: + + - values below 0, and negative infinity, use `under_color` (when unset, the + first color in the colormap). + - values above 1, and positive infinity, use `over_color` (when unset, the + last color in the colormap). + - NaN, and entries masked by a `numpy.ma` masked array, use `bad_color` + (when unset, transparent). + + For integer input, which indexes the LUT directly, an index at or beyond N + uses `over_color`, and a negative index uses `under_color` rather than + wrapping around. + Parameters ---------- x : float | array-like @@ -463,7 +482,8 @@ def lut( The returned LUT is a numpy array of RGBA values, with shape (N, 4), where N is the number of requested colors in the LUT. If `with_over_under` is `True` the returned shape will be (N + 3, 4), where index N is the under - color, index N + 1 is the over color, and index N + 2 is the bad (NaN) color. + color, index N + 1 is the over color, and index N + 2 is the bad color (used + for NaN and masked values). The LUT can be used to map scalar values (that have been normalized to 0-1) to colors, using fancy indexing or `np.take`.