From 9380611161ac87b56f05954acc3c40463272e3b3 Mon Sep 17 00:00:00 2001 From: Matthias Schabel Date: Mon, 10 Aug 2026 13:27:46 -0700 Subject: [PATCH 01/10] feat: add per-class colors for infinities, NaN, and masked entries cmap colors three exceptional classes: under, over, and bad. Floating point data has more. Negative and positive infinity are indistinguishable from ordinary out-of-range values, and NaN is indistinguishable from a masked entry. Adds neg_inf, pos_inf, nan, and masked. Each falls back to the color its class uses now: neg_inf to under, pos_inf to over, nan and masked to bad. bad is kept as the joint fallback for both of its children, so code that sets it is unaffected and either child may be set alone. Routing appends four fallback-resolved rows to a call-local copy of the over/under LUT, so a class with no color of its own lands on exactly the row it lands on now. Colormap.lut() is unchanged. The infinity masks are taken before the input is scaled by N: that multiply overflows large finite values to infinity (float16 65504 does it), and those are out of range rather than infinite. Co-Authored-By: Claude Opus 5 (1M context) Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh) --- src/cmap/_colormap.py | 145 ++++++++++++++++++++++++++++++++++---- src/cmap/_external.py | 6 +- tests/test_colormap.py | 98 ++++++++++++++++++++++++++ tests/test_third_party.py | 4 ++ 4 files changed, 238 insertions(+), 15 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 599e18ef9..b576e9613 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -141,6 +141,18 @@ class Colormap: 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`. + neg_inf : ColorLike | None + The color to use for negative infinity. When unset, negative infinity uses + `under`. + pos_inf : ColorLike | None + The color to use for positive infinity. When unset, positive infinity uses + `over`. + nan : ColorLike | None + The color to use for NaN. When unset, NaN uses `bad`. + masked : ColorLike | None + The color to use for entries masked by a `numpy.ma` masked array. When unset, + masked entries use `bad`. A masked entry takes this color whatever value it + hides, so a masked infinity is masked rather than infinite. Raises ------ @@ -153,6 +165,7 @@ class Colormap: __slots__ = ( "__weakref__", + "_has_exceptional", "_initialized", "_lut_cache", "bad_color", @@ -161,8 +174,12 @@ class Colormap: "identifier", "info", "interpolation", + "masked_color", "name", + "nan_color", + "neg_inf_color", "over_color", + "pos_inf_color", "under_color", ) @@ -224,6 +241,24 @@ class Colormap: If provided, and `Colormap.lut` is called with `with_over_under=True`, `bad_color` will be the last color in the LUT (`lut[-1]`). + + `nan_color` and `masked_color` override it for their own class. It remains the + color both of them fall back to. + """ + + neg_inf_color: Color | None + """A color to use for negative infinity, overriding `under_color`.""" + + pos_inf_color: Color | None + """A color to use for positive infinity, overriding `over_color`.""" + + nan_color: Color | None + """A color to use for NaN, overriding `bad_color`.""" + + masked_color: Color | None + """A color to use for masked entries, overriding `bad_color`. + + Applies to any entry masked by a `numpy.ma` masked array, whatever value it hides. """ _catalog_instance: Catalog | None = None @@ -246,6 +281,10 @@ def __init__( under: ColorLike | None = None, over: ColorLike | None = None, bad: ColorLike | None = None, + neg_inf: ColorLike | None = None, + pos_inf: ColorLike | None = None, + nan: ColorLike | None = None, + masked: ColorLike | None = None, cmap_kwargs: dict[str, Any] | None = None, ) -> None: self.info: CatalogItem | None = None @@ -318,6 +357,20 @@ def __init__( self.under_color = None if under is None else Color(under) self.over_color = None if over is None else Color(over) self.bad_color = None if bad is None else Color(bad) + self.neg_inf_color = None if neg_inf is None else Color(neg_inf) + self.pos_inf_color = None if pos_inf is None else Color(pos_inf) + self.nan_color = None if nan is None else Color(nan) + self.masked_color = None if masked is None else Color(masked) + # a colormap with none of these takes the same path it did before they existed + self._has_exceptional = any( + c is not None + for c in ( + self.neg_inf_color, + self.pos_inf_color, + self.nan_color, + self.masked_color, + ) + ) self._lut_cache: dict[LutCacheKey, np.ndarray] = {} self._initialized = True @@ -361,12 +414,14 @@ def __call__( 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). + - values below 0 use `under_color` (when unset, the first color in the + colormap), and values above 1 use `over_color` (when unset, the last). + - negative and positive infinity use `neg_inf_color` and `pos_inf_color` + (when unset, `under_color` and `over_color`). + - NaN uses `nan_color`, and entries masked by a `numpy.ma` masked array use + `masked_color` (when either is unset, `bad_color`, which is itself + transparent when unset). A masked entry takes the masked color whatever + value it hides. 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 @@ -410,16 +465,24 @@ def __call__( >>> colored_img = cmap(data) """ lut = self.lut(N=N, gamma=gamma, with_over_under=True) - if bytes: - lut = (lut * 255).astype(np.uint8) # the lut will have three additional colors at the end for under, over, and bad N = len(lut) - 3 + if self._has_exceptional: + lut = self._with_exceptional_colors(lut) + if bytes: + lut = (lut * 255).astype(np.uint8) xa = np.array(x, copy=True) if not xa.dtype.isnative: # Native byteorder is faster. xa = xa.byteswap().view(xa.dtype.newbyteorder()) - if xa.dtype.kind == "f": + is_float = xa.dtype.kind == "f" + if self._has_exceptional and is_float: + # before the scaling below: it overflows large finite values to infinity + # (float16 65504), and those are out of range rather than infinite. + mask_neg_inf = np.isneginf(xa) + mask_pos_inf = np.isposinf(xa) + if is_float: xa *= N # xa == 1 (== N after multiplication) is not out of range. xa[xa == N] = N - 1 @@ -429,11 +492,12 @@ def __call__( # 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) + mask_masked = x.mask # type: ignore + mask_nan = np.isnan(xa) if is_float else False + mask_bad = (mask_masked | mask_nan) if is_float else mask_masked else: - mask_bad = np.isnan(xa) + mask_masked = False + mask_nan = mask_bad = np.isnan(xa) with np.errstate(invalid="ignore"): # We need this cast for unsigned ints as well as floats @@ -442,16 +506,45 @@ def __call__( xa[mask_under] = N xa[mask_over] = N + 1 xa[mask_bad] = N + 2 + if self._has_exceptional: + # last wins: a masked entry is masked whatever value it hides + if is_float: + xa[mask_neg_inf] = N + 3 + xa[mask_pos_inf] = N + 4 + xa[mask_nan] = N + 5 + xa[mask_masked] = N + 6 rgba = lut.take(xa, axis=0, mode="clip") return rgba if np.iterable(x) else Color(rgba) + def _with_exceptional_colors(self, lut: np.ndarray) -> np.ndarray: + """Return `lut` with four rows appended, one per exceptional value class. + + Each appended row falls back to the row its class would otherwise have used, + so routing a class to its own row cannot change any color while that class + has no color of its own. `lut` must be an over/under LUT. + """ + under, over, bad = lut[-3], lut[-2], lut[-1] + return np.vstack( + ( + lut, + under if self.neg_inf_color is None else self.neg_inf_color.rgba, + over if self.pos_inf_color is None else self.pos_inf_color.rgba, + bad if self.nan_color is None else self.nan_color.rgba, + bad if self.masked_color is None else self.masked_color.rgba, + ) + ) + def with_extremes( self, *, bad: ColorLike | None = None, under: ColorLike | None = None, over: ColorLike | None = None, + neg_inf: ColorLike | None = None, + pos_inf: ColorLike | None = None, + nan: ColorLike | None = None, + masked: ColorLike | None = None, ) -> Colormap: """Return a copy of the colormap with new extreme values.""" return type(self)( @@ -462,6 +555,10 @@ def with_extremes( bad=bad, under=under, over=over, + neg_inf=neg_inf, + pos_inf=pos_inf, + nan=nan, + masked=masked, ) def as_dict(self) -> ColormapDict: @@ -609,6 +706,10 @@ def shifted( under=self.under_color, over=self.over_color, bad=self.bad_color, + neg_inf=self.neg_inf_color, + pos_inf=self.pos_inf_color, + nan=self.nan_color, + masked=self.masked_color, ) def to_css( @@ -670,6 +771,10 @@ def __eq__(self, other: object) -> bool: and self.under_color == other.under_color and self.over_color == other.over_color and self.bad_color == other.bad_color + and self.neg_inf_color == other.neg_inf_color + and self.pos_inf_color == other.pos_inf_color + and self.nan_color == other.nan_color + and self.masked_color == other.masked_color and self.interpolation == other.interpolation ) @@ -718,6 +823,20 @@ def _repr_html_(self) -> str: f"over {_html_color_patch(self.over_color)}" "" ) + if self._has_exceptional: + patches = ( + ("neg_inf", self.neg_inf_color), + ("pos_inf", self.pos_inf_color), + ("nan", self.nan_color), + ("masked", self.masked_color), + ) + swatches = " ".join( + f"{name} {_html_color_patch(c)}" for name, c in patches if c is not None + ) + html += ( + '
' + f"{swatches}
" + ) return html diff --git a/src/cmap/_external.py b/src/cmap/_external.py index 68a5bd8ce..23ed001a4 100644 --- a/src/cmap/_external.py +++ b/src/cmap/_external.py @@ -109,8 +109,10 @@ def to_napari(cm: Colormap) -> NapariColormap: kwargs["interpolation"] = ( "zero" if cm.interpolation == "nearest" else "linear" ) - if "nan_color" in param_names and cm.bad_color is not None: - kwargs["nan_color"] = cm.bad_color.rgba + # napari's nan_color covers nan alone, so prefer cmap's nan color over bad + nan_color = cm.nan_color or cm.bad_color + if "nan_color" in param_names and nan_color is not None: + kwargs["nan_color"] = nan_color.rgba if "high_color" in param_names and cm.over_color is not None: kwargs["high_color"] = cm.over_color.rgba if "low_color" in param_names and cm.under_color is not None: diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 41524269e..bad939546 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -187,6 +187,104 @@ def test_colormap_masked_array_with_unmasked_nan() -> None: npt.assert_array_equal(cmap(all_false), cmap(np.array([0.25, np.nan]))) +def test_exceptional_colors() -> None: + cmap = Colormap( + ["red", "blue"], + under="green", + over="yellow", + bad="black", + neg_inf="cyan", + pos_inf="magenta", + nan="white", + masked="orange", + ) + mask = [False] * 6 + [True] * 3 + data = np.ma.masked_array( + [-np.inf, -0.5, 0.5, 1.5, np.inf, np.nan, np.inf, np.nan, 0.25], mask=mask + ) + expect = np.array( + [ + Color("cyan").rgba, # -inf + Color("green").rgba, # under range, finite + Colormap(["red", "blue"])(0.5).rgba, # in range + Color("yellow").rgba, # over range, finite + Color("magenta").rgba, # +inf + Color("white").rgba, # nan, not masked + Color("orange").rgba, # masked wins over +inf + Color("orange").rgba, # masked wins over nan + Color("orange").rgba, # masked + ] + ) + + npt.assert_array_equal(cmap(data), expect) + npt.assert_array_equal(cmap(data, bytes=True), (expect * 255).astype(np.uint8)) + npt.assert_array_equal(data.mask, mask) + + +def test_exceptional_colors_fall_back_to_the_legacy_extremes() -> None: + cmap = Colormap(["red", "blue"], under="green", over="yellow", bad="black") + data = np.ma.masked_array( + [-np.inf, np.inf, np.nan, 0.5], mask=[False, False, False, True] + ) + legacy = [ + Color("green").rgba, # -inf -> under + Color("yellow").rgba, # +inf -> over + Color("black").rgba, # nan -> bad + Color("black").rgba, # masked -> bad + ] + npt.assert_array_equal(cmap(data), legacy) + + # setting one leaves the other three on their legacy destinations + one = cmap.with_extremes(under="green", over="yellow", bad="black", nan="white") + npt.assert_array_equal(one(data), [*legacy[:2], Color("white").rgba, legacy[3]]) + + +def test_finite_values_that_overflow_when_scaled_are_not_infinite() -> None: + # float16 65504 becomes inf once multiplied by N, but it is over-range, not infinite + cmap = Colormap( + ["red", "blue"], under="green", over="yellow", neg_inf="cyan", pos_inf="magenta" + ) + data = np.array([65504, -65504, np.inf, -np.inf], dtype=np.float16) + with np.errstate(over="ignore"): + rgba = cmap(data) + npt.assert_array_equal( + rgba, + [ + Color("yellow").rgba, + Color("green").rgba, + Color("magenta").rgba, + Color("cyan").rgba, + ], + ) + + +def test_masked_dtypes_keep_their_existing_behavior() -> None: + cmap = Colormap(["red", "blue"], bad="black") + bad = Color("black").rgba + + npt.assert_array_equal(cmap(np.ma.masked_array([0, 1], mask=[True, False]))[0], bad) + + obj = np.ma.masked_array(np.array([0.25, 0.5], dtype=object), mask=[True, False]) + npt.assert_array_equal(cmap(obj)[0], bad) + + # object dtype without a mask reaches np.isnan, which has never accepted it + with pytest.raises(TypeError): + cmap(np.ma.masked_array(np.array([0.25], dtype=object), mask=np.ma.nomask)) + + npt.assert_array_equal(cmap(np.ma.masked_array(0.5, mask=True)).rgba, bad) + + +@pytest.mark.parametrize("field", ["neg_inf", "pos_inf", "nan", "masked"]) +def test_exceptional_colors_are_colormap_state(field: str) -> None: + plain = Colormap(["red", "blue"]) + cmap = Colormap(["red", "blue"], **{field: "orange"}) + + assert cmap != plain + assert plain.with_extremes(**{field: "orange"}) == cmap + assert cmap.shifted(1) == cmap + assert field in cmap._repr_html_() + + 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] diff --git a/tests/test_third_party.py b/tests/test_third_party.py index a161dac75..f02cbc4b3 100644 --- a/tests/test_third_party.py +++ b/tests/test_third_party.py @@ -94,6 +94,10 @@ def test_napari_extreme_colors() -> None: np.testing.assert_allclose(ncm.high_color, Color(OVER).rgba) np.testing.assert_allclose(ncm.nan_color, Color(bad).rgba) + # a more specific nan color takes precedence over bad + specific = Colormap(["black", "white"], bad=bad, nan="magenta").to_napari() + np.testing.assert_allclose(specific.nan_color, Color("magenta").rgba) + @pytest.mark.skipif( sys.platform == "darwin" and sys.version_info >= (3, 13), From 1a998dfd6cf0ad33c28152cfb713c8754b8e6b06 Mon Sep 17 00:00:00 2001 From: Matthias Schabel Date: Mon, 24 Aug 2026 16:51:01 -0700 Subject: [PATCH 02/10] Remove the mask-specific exceptional color Keep the proposal scoped to negative infinity, NaN, and positive infinity in addition to the existing under and over behavior. Masked arrays continue to use the existing bad color, including when they hide an infinity or NaN. --- src/cmap/_colormap.py | 36 +++++++----------------------------- tests/test_colormap.py | 11 +++++------ 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index b576e9613..614a6ec78 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -149,10 +149,6 @@ class Colormap: `over`. nan : ColorLike | None The color to use for NaN. When unset, NaN uses `bad`. - masked : ColorLike | None - The color to use for entries masked by a `numpy.ma` masked array. When unset, - masked entries use `bad`. A masked entry takes this color whatever value it - hides, so a masked infinity is masked rather than infinite. Raises ------ @@ -174,7 +170,6 @@ class Colormap: "identifier", "info", "interpolation", - "masked_color", "name", "nan_color", "neg_inf_color", @@ -242,8 +237,7 @@ class Colormap: If provided, and `Colormap.lut` is called with `with_over_under=True`, `bad_color` will be the last color in the LUT (`lut[-1]`). - `nan_color` and `masked_color` override it for their own class. It remains the - color both of them fall back to. + `nan_color` overrides it for NaN. Masked values continue to use `bad_color`. """ neg_inf_color: Color | None @@ -255,12 +249,6 @@ class Colormap: nan_color: Color | None """A color to use for NaN, overriding `bad_color`.""" - masked_color: Color | None - """A color to use for masked entries, overriding `bad_color`. - - Applies to any entry masked by a `numpy.ma` masked array, whatever value it hides. - """ - _catalog_instance: Catalog | None = None @classmethod @@ -284,7 +272,6 @@ def __init__( neg_inf: ColorLike | None = None, pos_inf: ColorLike | None = None, nan: ColorLike | None = None, - masked: ColorLike | None = None, cmap_kwargs: dict[str, Any] | None = None, ) -> None: self.info: CatalogItem | None = None @@ -360,7 +347,6 @@ def __init__( self.neg_inf_color = None if neg_inf is None else Color(neg_inf) self.pos_inf_color = None if pos_inf is None else Color(pos_inf) self.nan_color = None if nan is None else Color(nan) - self.masked_color = None if masked is None else Color(masked) # a colormap with none of these takes the same path it did before they existed self._has_exceptional = any( c is not None @@ -368,7 +354,6 @@ def __init__( self.neg_inf_color, self.pos_inf_color, self.nan_color, - self.masked_color, ) ) @@ -418,10 +403,9 @@ def __call__( colormap), and values above 1 use `over_color` (when unset, the last). - negative and positive infinity use `neg_inf_color` and `pos_inf_color` (when unset, `under_color` and `over_color`). - - NaN uses `nan_color`, and entries masked by a `numpy.ma` masked array use - `masked_color` (when either is unset, `bad_color`, which is itself - transparent when unset). A masked entry takes the masked color whatever - value it hides. + - NaN uses `nan_color` (when unset, `bad_color`, which is itself transparent + when unset). Entries masked by a `numpy.ma` masked array use `bad_color`, + whatever value they hide. 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 @@ -507,18 +491,18 @@ def __call__( xa[mask_over] = N + 1 xa[mask_bad] = N + 2 if self._has_exceptional: - # last wins: a masked entry is masked whatever value it hides if is_float: xa[mask_neg_inf] = N + 3 xa[mask_pos_inf] = N + 4 xa[mask_nan] = N + 5 - xa[mask_masked] = N + 6 + # A masked entry remains bad whatever value it hides. + xa[mask_masked] = N + 2 rgba = lut.take(xa, axis=0, mode="clip") return rgba if np.iterable(x) else Color(rgba) def _with_exceptional_colors(self, lut: np.ndarray) -> np.ndarray: - """Return `lut` with four rows appended, one per exceptional value class. + """Return `lut` with three rows appended, one per exceptional value class. Each appended row falls back to the row its class would otherwise have used, so routing a class to its own row cannot change any color while that class @@ -531,7 +515,6 @@ def _with_exceptional_colors(self, lut: np.ndarray) -> np.ndarray: under if self.neg_inf_color is None else self.neg_inf_color.rgba, over if self.pos_inf_color is None else self.pos_inf_color.rgba, bad if self.nan_color is None else self.nan_color.rgba, - bad if self.masked_color is None else self.masked_color.rgba, ) ) @@ -544,7 +527,6 @@ def with_extremes( neg_inf: ColorLike | None = None, pos_inf: ColorLike | None = None, nan: ColorLike | None = None, - masked: ColorLike | None = None, ) -> Colormap: """Return a copy of the colormap with new extreme values.""" return type(self)( @@ -558,7 +540,6 @@ def with_extremes( neg_inf=neg_inf, pos_inf=pos_inf, nan=nan, - masked=masked, ) def as_dict(self) -> ColormapDict: @@ -709,7 +690,6 @@ def shifted( neg_inf=self.neg_inf_color, pos_inf=self.pos_inf_color, nan=self.nan_color, - masked=self.masked_color, ) def to_css( @@ -774,7 +754,6 @@ def __eq__(self, other: object) -> bool: and self.neg_inf_color == other.neg_inf_color and self.pos_inf_color == other.pos_inf_color and self.nan_color == other.nan_color - and self.masked_color == other.masked_color and self.interpolation == other.interpolation ) @@ -828,7 +807,6 @@ def _repr_html_(self) -> str: ("neg_inf", self.neg_inf_color), ("pos_inf", self.pos_inf_color), ("nan", self.nan_color), - ("masked", self.masked_color), ) swatches = " ".join( f"{name} {_html_color_patch(c)}" for name, c in patches if c is not None diff --git a/tests/test_colormap.py b/tests/test_colormap.py index bad939546..7da0b90c1 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -196,7 +196,6 @@ def test_exceptional_colors() -> None: neg_inf="cyan", pos_inf="magenta", nan="white", - masked="orange", ) mask = [False] * 6 + [True] * 3 data = np.ma.masked_array( @@ -210,9 +209,9 @@ def test_exceptional_colors() -> None: Color("yellow").rgba, # over range, finite Color("magenta").rgba, # +inf Color("white").rgba, # nan, not masked - Color("orange").rgba, # masked wins over +inf - Color("orange").rgba, # masked wins over nan - Color("orange").rgba, # masked + Color("black").rgba, # masked +inf -> bad + Color("black").rgba, # masked nan -> bad + Color("black").rgba, # masked -> bad ] ) @@ -234,7 +233,7 @@ def test_exceptional_colors_fall_back_to_the_legacy_extremes() -> None: ] npt.assert_array_equal(cmap(data), legacy) - # setting one leaves the other three on their legacy destinations + # setting one leaves the other exceptional classes on their legacy destinations one = cmap.with_extremes(under="green", over="yellow", bad="black", nan="white") npt.assert_array_equal(one(data), [*legacy[:2], Color("white").rgba, legacy[3]]) @@ -274,7 +273,7 @@ def test_masked_dtypes_keep_their_existing_behavior() -> None: npt.assert_array_equal(cmap(np.ma.masked_array(0.5, mask=True)).rgba, bad) -@pytest.mark.parametrize("field", ["neg_inf", "pos_inf", "nan", "masked"]) +@pytest.mark.parametrize("field", ["neg_inf", "pos_inf", "nan"]) def test_exceptional_colors_are_colormap_state(field: str) -> None: plain = Colormap(["red", "blue"]) cmap = Colormap(["red", "blue"], **{field: "orange"}) From 313a7d9a378509b2d5974e32204c7327d0c593b4 Mon Sep 17 00:00:00 2001 From: Matthias Schabel Date: Thu, 13 Aug 2026 10:03:36 -0700 Subject: [PATCH 03/10] fix: preserve colormap state through reversed(), with_extremes(), pickle, and as_dict() None of the four channels carried the extreme colors, and reversed() and with_extremes() dropped the interpolation mode as well. Following matplotlib where it has a position: reversed() swaps the directional colors and keeps the rest, with_extremes() keeps anything not passed, __reduce__ carries the constructor state alongside the stops, and as_dict() gains optional keys that are written only when set, so existing payloads are unchanged. The pydantic serializer emitted a catalog colormap's qualified name unconditionally, which discarded any added extremes and turned "viridis_r" back into plain viridis. It now emits the name only when the name alone rebuilds the same colormap. Colormap("x_r") swaps the catalog record's under and over, so it agrees with Colormap("x").reversed(). Co-Authored-By: Claude Opus 5 (1M context) Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh) --- src/cmap/_colormap.py | 119 +++++++++++++++++++++++++++++++------ tests/test_colormap.py | 86 +++++++++++++++++++++++++++ tests/test_model_fields.py | 45 ++++++++++++++ 3 files changed, 232 insertions(+), 18 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 614a6ec78..3c8324f04 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -44,12 +44,27 @@ class MPLSegmentData(TypedDict, total=False): blue: list[tuple[float, float, float]] | Callable[[np.ndarray], np.ndarray] alpha: list[tuple[float, float, float]] | Callable[[np.ndarray], np.ndarray] - class ColormapDict(TypedDict): + class _RequiredColormapDict(TypedDict): name: str identifier: str category: str | None value: list[tuple[float, list[float]]] + class ColormapDict(_RequiredColormapDict, total=False): + # written only when they hold non-default state, so a colormap that has none + # of them serializes exactly as it did before they existed + interpolation: str + under: list[float] + over: list[float] + bad: list[float] + neg_inf: list[float] + pos_inf: list[float] + nan: list[float] + + +# the extreme colors, in constructor-argument spelling; `_color` is the property. +# Copy and serialization paths iterate this rather than repeating the list. +_EXTREME_FIELDS = ("under", "over", "bad", "neg_inf", "pos_inf", "nan") LutCallable: TypeAlias = Callable[["NDArray"], "NDArray"] """Function type for a callable that takes an array of values in the range [0, 1] and returns an (N, 4) array of RGBA values in the range [0, 1].""" # noqa @@ -281,8 +296,14 @@ def __init__( info = self.catalog()[value[:-2] if rev else value] name = name or f"{info.namespace}:{info.name}" category = category or info.category - over = info.over if over is None else over - under = info.under if under is None else under + # the record's directional colors follow the ends they extend, so a "_r" + # name swaps them. Swapping here rather than after the lines below keeps + # an explicit `under=`/`over=` argument on the end the caller named. + info_under, info_over = ( + (info.over, info.under) if rev else (info.under, info.over) + ) + over = info_over if over is None else over + under = info_under if under is None else under bad = info.bad if bad is None else bad self.info = info @@ -528,32 +549,64 @@ def with_extremes( pos_inf: ColorLike | None = None, nan: ColorLike | None = None, ) -> Colormap: - """Return a copy of the colormap with new extreme values.""" + """Return a copy of the colormap with new extreme values. + + Colors that are not passed are carried over from this colormap, as in + matplotlib. To clear one, construct a new `Colormap`. + """ return type(self)( self.color_stops, name=self.name, + identifier=self.identifier, category=self.category, interpolation=self.interpolation, - bad=bad, - under=under, - over=over, - neg_inf=neg_inf, - pos_inf=pos_inf, - nan=nan, + under=self.under_color if under is None else under, + over=self.over_color if over is None else over, + bad=self.bad_color if bad is None else bad, + neg_inf=self.neg_inf_color if neg_inf is None else neg_inf, + pos_inf=self.pos_inf_color if pos_inf is None else pos_inf, + nan=self.nan_color if nan is None else nan, ) + @property + def _extremes(self) -> dict[str, Color | None]: + """The extreme colors, keyed by their constructor argument name.""" + return {f: getattr(self, f"{f}_color") for f in _EXTREME_FIELDS} + + def _constructor_kwargs(self) -> dict[str, Any]: + """Every keyword argument needed to rebuild this colormap from its stops.""" + kwargs: dict[str, Any] = { + "name": self.name, + "identifier": self.identifier, + "category": self.category, + "interpolation": self.interpolation, + } + kwargs.update({k: v for k, v in self._extremes.items() if v is not None}) + return kwargs + def as_dict(self) -> ColormapDict: """Return a dictionary representation of the colormap. The returned dictionary is suitable for serialization, or for passing to the Colormap constructor. + + Note that a colormap backed by a lut function is sampled into fixed stops + here; use `pickle` or `copy` to duplicate one without resampling. """ - return { + d: ColormapDict = { "name": self.name, "identifier": self.identifier, "category": self.category, "value": [(p, list(c)) for p, c in self.color_stops], } + # optional keys, written only when they hold non-default state: `value` is + # always stops, which the constructor reads as linear. + if self.interpolation != "linear": + d["interpolation"] = self.interpolation + for field, color in self._extremes.items(): + if color is not None: + d[field] = list(color) # type: ignore[literal-required] + return d def lut( self, N: int = 256, gamma: float = 1, *, with_over_under: bool = False @@ -631,6 +684,9 @@ def iter_colors(self, N: Iterable[float] | int | None = None) -> Iterator[Color] def reversed(self, name: str | None = None) -> Colormap: """Return a new Colormap, with reversed colors. + `under`/`over` and `neg_inf`/`pos_inf` are swapped, since they name the ends + they extend. The other extreme colors and the interpolation carry over. + Parameters ---------- name: str | None @@ -642,8 +698,19 @@ def reversed(self, name: str | None = None) -> Colormap: if name is None: name = self.name[:-2] if self.name.endswith("_r") else f"{self.name}_r" + # `identifier` is not carried: it is derived from the name, which changed here return type(self)( - self.color_stops.reversed(), name=name, category=self.category + self.color_stops.reversed(), + name=name, + category=self.category, + interpolation=self.interpolation, + # under/over and neg_inf/pos_inf name the ends they extend, so they follow + under=self.over_color, + over=self.under_color, + neg_inf=self.pos_inf_color, + pos_inf=self.neg_inf_color, + bad=self.bad_color, + nan=self.nan_color, ) def shifted( @@ -736,8 +803,13 @@ def __setattr__(self, _name: str, _value: Any) -> None: object.__setattr__(self, _name, _value) def __reduce__(self) -> str | tuple[Any, ...]: - # for pickle - return self.__class__, (self.color_stops,) + # for pickle. The stops go through as the object rather than as as_dict()'s + # samples, so a colormap backed by a lut function keeps the function. + return _rebuild_colormap, ( + self.__class__, + self.color_stops, + self._constructor_kwargs(), + ) def __eq__(self, other: object) -> bool: if not isinstance(other, Colormap): @@ -832,10 +904,13 @@ def __get_pydantic_core_schema__( schema = handler(Any) def _serialize(obj: Colormap) -> Any: - if obj.info is not None and obj.info.qualified_name: - # this is a catalog item - return obj.info.qualified_name - return obj.as_dict() + state = obj.as_dict() + if obj.info is not None and (qualified := obj.info.qualified_name): + # the name alone is a complete serialization only when it rebuilds the + # same colormap; "viridis_r" and a modified viridis both fail that + if state == Colormap(qualified).as_dict(): + return qualified + return state ser = core_schema.plain_serializer_function_ser_schema(_serialize) return core_schema.no_info_after_validator_function( @@ -952,6 +1027,14 @@ def to_pyqtgraph(self) -> pyqtgraph.ColorMap: return _external.to_pyqtgraph(self) +def _rebuild_colormap( + cls: type[Colormap], value: ColormapLike, kwargs: dict[str, Any] +) -> Colormap: + # pickle's two-tuple reduce form passes positional arguments only, and the rest of + # the colormap's state is keyword-only + return cls(value, **kwargs) + + class ColorStop(NamedTuple): """A color stop in a color gradient. diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 7da0b90c1..b587eaaee 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -368,6 +368,92 @@ def test_with_extremes() -> None: assert "under" in cm2._repr_html_() +def _configured() -> Colormap: + return Colormap( + ["red", "blue"], + name="foo", + category="sequential", + interpolation="nearest", + under="green", + over="yellow", + bad="black", + neg_inf="cyan", + pos_inf="magenta", + nan="white", + ) + + +def test_with_extremes_preserves_what_is_not_passed() -> None: + cm = Colormap(["red", "blue"], identifier="my_id", under="green", nan="white") + + new = cm.with_extremes(over="yellow") + + assert new.under_color == Color("green") + assert new.nan_color == Color("white") + assert new.over_color == Color("yellow") + assert new.identifier == "my_id" + + +def test_reversed_preserves_state_and_swaps_the_directional_colors() -> None: + cm = _configured() + + rev = cm.reversed() + + assert rev.name == "foo_r" + assert rev.category == "sequential" + assert rev.interpolation == "nearest" + # under/over and neg_inf/pos_inf name the ends they extend, so they follow them + assert (rev.under_color, rev.over_color) == (Color("yellow"), Color("green")) + assert (rev.neg_inf_color, rev.pos_inf_color) == (Color("magenta"), Color("cyan")) + assert (rev.bad_color, rev.nan_color) == (Color("black"), Color("white")) + assert rev.reversed() == cm + + +def test_reversed_agrees_with_the_r_suffix() -> None: + assert Colormap("napari:HiLo").reversed() == Colormap("napari:HiLo_r") + # the record's colors swap; an explicit argument names the end it lands on + assert Colormap("napari:HiLo_r", under="green").under_color == Color("green") + + +def test_pickle_preserves_constructor_state() -> None: + import pickle + + cm = _configured() + + rt = pickle.loads(pickle.dumps(cm)) # noqa: S301 + + assert rt == cm + assert (rt.name, rt.identifier, rt.category, rt.interpolation) == ( + cm.name, + cm.identifier, + cm.category, + cm.interpolation, + ) + + +def test_copies_of_a_callable_colormap_stay_callable() -> None: + import pickle + from copy import copy, deepcopy + + # as_dict() samples the function into 256 stops; a copy must keep the function + cm = Colormap("cubehelix", cmap_kwargs={"start": 1.0, "rotation": -1.0}) + + for copied in (pickle.loads(pickle.dumps(cm)), copy(cm), deepcopy(cm)): # noqa: S301 + npt.assert_array_equal(copied.lut(17, gamma=2), cm.lut(17, gamma=2)) + + +def test_as_dict_round_trip() -> None: + plain = Colormap(["red", "blue"]) + assert set(plain.as_dict()) == {"name", "identifier", "category", "value"} + + cm = _configured() + d = cm.as_dict() + + assert d["interpolation"] == "nearest" + assert d["nan"] == list(Color("white")) + assert Colormap(**d) == cm + + def test_shifted() -> None: cm = Colormap(["red", "blue", "yellow"]) assert cm.shifted(1) == cm diff --git a/tests/test_model_fields.py b/tests/test_model_fields.py index 43c42add1..f91c0e90a 100644 --- a/tests/test_model_fields.py +++ b/tests/test_model_fields.py @@ -85,3 +85,48 @@ class MyModel(psygnal.EventedModel): # type: ignore assert MyModel.model_validate_json(data) == obj else: assert MyModel.parse_raw(data) == obj + + +def test_pydantic_preserves_modified_catalog_colormaps() -> None: + class MyModel(BaseModel): + colormap: Colormap + + def round_trip(cmap: Colormap) -> Colormap: + obj = MyModel(colormap=cmap) + data = obj.model_dump_json() if V2 else obj.json() + model = ( + MyModel.model_validate_json(data) + if hasattr(MyModel, "model_validate_json") + else MyModel.parse_raw(data) + ) + return model.colormap + + # an unmodified catalog colormap still serializes to its qualified name alone + obj = MyModel(colormap=Colormap("viridis")) + assert '"colormap":"bids:viridis"' in (obj.model_dump_json() if V2 else obj.json()) + + assert round_trip(Colormap("viridis", under="red")).under_color == Color("red") + assert round_trip(Colormap("viridis", name="renamed")).name == "renamed" + assert round_trip(Colormap("viridis_r")) == Colormap("viridis_r") + + # a parametrization too small for the tolerant __eq__ to see still must not + # collapse to the plain catalog name + tweaked = Colormap("cubehelix", cmap_kwargs={"start": 0.500001}) + assert round_trip(tweaked).as_dict() == tweaked.as_dict() + + +def test_psygnal_serialization_of_a_configured_colormap() -> None: + psygnal = pytest.importorskip("psygnal") + + class MyModel(psygnal.EventedModel): # type: ignore + colormap: Colormap + + cmap = Colormap(["r", "b"], interpolation="nearest", under="green", nan="orange") + obj = MyModel(colormap=cmap) + + data = obj.model_dump_json() if V2 else obj.json() + + if hasattr(MyModel, "model_validate_json"): + assert MyModel.model_validate_json(data).colormap == cmap + else: + assert MyModel.parse_raw(data).colormap == cmap From b481840b98334ef6ad2b35e37d288dda7118d2c9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 16:02:29 +0200 Subject: [PATCH 04/10] refactor: use NotRequired for ColormapDict optional keys Co-Authored-By: Claude Opus 5.5 --- src/cmap/_colormap.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 9f2662c3b..b9a7b8cc1 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from collections.abc import Iterator - from typing import Literal, TypeAlias, TypeGuard + from typing import Literal, NotRequired, TypeAlias, TypeGuard import bokeh.models import matplotlib.colors @@ -45,22 +45,20 @@ class MPLSegmentData(TypedDict, total=False): blue: list[tuple[float, float, float]] | Callable[[np.ndarray], np.ndarray] alpha: list[tuple[float, float, float]] | Callable[[np.ndarray], np.ndarray] - class _RequiredColormapDict(TypedDict): + class ColormapDict(TypedDict): name: str identifier: str category: str | None value: list[tuple[float, list[float]]] - - class ColormapDict(_RequiredColormapDict, total=False): # written only when they hold non-default state, so a colormap that has none # of them serializes exactly as it did before they existed - interpolation: str - under: list[float] - over: list[float] - bad: list[float] - neg_inf: list[float] - pos_inf: list[float] - nan: list[float] + interpolation: NotRequired[Interpolation] + under: NotRequired[list[float]] + over: NotRequired[list[float]] + bad: NotRequired[list[float]] + neg_inf: NotRequired[list[float]] + pos_inf: NotRequired[list[float]] + nan: NotRequired[list[float]] # the extreme colors, in constructor-argument spelling; `_color` is the property. From 2cf67c7f7be21475e9c3bcbabd01930a85c4be9a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 16:08:49 +0200 Subject: [PATCH 05/10] perf: compare state directly in the pydantic serializer; hoist test imports Co-Authored-By: Claude Opus 5.5 --- src/cmap/_colormap.py | 15 ++++++++++++--- tests/test_colormap.py | 10 ++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index b9a7b8cc1..21d3e4d07 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -813,6 +813,16 @@ def __reduce__(self) -> str | tuple[Any, ...]: self._constructor_kwargs(), ) + def _is_identical(self, other: Colormap) -> bool: + """Whether `other` has exactly the state that `as_dict()` would write.""" + # unlike `__eq__`, which ignores the names and compares stops with allclose + return ( + (self.name, self.identifier, self.category, self.interpolation) + == (other.name, other.identifier, other.category, other.interpolation) + and self._extremes == other._extremes + and np.array_equal(self.color_stops._stops, other.color_stops._stops) + ) + def __eq__(self, other: object) -> bool: if not isinstance(other, Colormap): try: @@ -906,13 +916,12 @@ def __get_pydantic_core_schema__( schema = handler(Any) def _serialize(obj: Colormap) -> Any: - state = obj.as_dict() if obj.info is not None and (qualified := obj.info.qualified_name): # the name alone is a complete serialization only when it rebuilds the # same colormap; "viridis_r" and a modified viridis both fail that - if state == Colormap(qualified).as_dict(): + if obj._is_identical(Colormap(qualified)): return qualified - return state + return obj.as_dict() ser = core_schema.plain_serializer_function_ser_schema(_serialize) return core_schema.no_info_after_validator_function( diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 1fc8e4640..2fed91b49 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -1,4 +1,6 @@ +import pickle import sys +from copy import copy, deepcopy from functools import partial from typing import Any @@ -136,9 +138,6 @@ def test_with_extremes_preserves_interpolation() -> None: def test_colormap_copy() -> None: """Test Colormap copy.""" - import pickle - from copy import copy, deepcopy - cmap1 = Colormap(["red", "magenta", "blue"]) cmap2 = Colormap(["r", (0.5, "magenta"), "b"]) assert cmap1 == cmap2 @@ -457,8 +456,6 @@ def test_reversed_agrees_with_the_r_suffix() -> None: def test_pickle_preserves_constructor_state() -> None: - import pickle - cm = _configured() rt = pickle.loads(pickle.dumps(cm)) # noqa: S301 @@ -473,9 +470,6 @@ def test_pickle_preserves_constructor_state() -> None: def test_copies_of_a_callable_colormap_stay_callable() -> None: - import pickle - from copy import copy, deepcopy - # as_dict() samples the function into 256 stops; a copy must keep the function cm = Colormap("cubehelix", cmap_kwargs={"start": 1.0, "rotation": -1.0}) From 0e5ba031105c954dad45f9b362c4a7f16f09c44a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 16:18:47 +0200 Subject: [PATCH 06/10] test: compare reversed() against the original colormap, not literals Co-Authored-By: Claude Opus 5.5 --- tests/test_colormap.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 2fed91b49..7ee65a437 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -436,16 +436,25 @@ def test_with_extremes_preserves_what_is_not_passed() -> None: def test_reversed_preserves_state_and_swaps_the_directional_colors() -> None: cm = _configured() + # the comparisons with `cm` below only catch a dropped or unswapped field if + # every field is non-default and the extreme colors are all distinct + extremes = [cm.under_color, cm.over_color, cm.bad_color] + extremes += [cm.neg_inf_color, cm.pos_inf_color, cm.nan_color] + assert None not in extremes and len(set(extremes)) == len(extremes) + assert cm.category is not None and cm.interpolation != "linear" rev = cm.reversed() - assert rev.name == "foo_r" - assert rev.category == "sequential" - assert rev.interpolation == "nearest" + assert rev.name == f"{cm.name}_r" + assert rev.category == cm.category + assert rev.interpolation == cm.interpolation # under/over and neg_inf/pos_inf name the ends they extend, so they follow them - assert (rev.under_color, rev.over_color) == (Color("yellow"), Color("green")) - assert (rev.neg_inf_color, rev.pos_inf_color) == (Color("magenta"), Color("cyan")) - assert (rev.bad_color, rev.nan_color) == (Color("black"), Color("white")) + assert rev.under_color == cm.over_color + assert rev.over_color == cm.under_color + assert rev.neg_inf_color == cm.pos_inf_color + assert rev.pos_inf_color == cm.neg_inf_color + assert rev.bad_color == cm.bad_color + assert rev.nan_color == cm.nan_color assert rev.reversed() == cm From ad8ff137f644ad82180c83c415cf29580b18ed88 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 16:35:18 +0200 Subject: [PATCH 07/10] test: cover each kind of state in the _r suffix test Co-Authored-By: Claude Opus 5.5 --- tests/test_colormap.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_colormap.py b/tests/test_colormap.py index 7ee65a437..c8bf1d2ed 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -458,8 +458,20 @@ def test_reversed_preserves_state_and_swaps_the_directional_colors() -> None: assert rev.reversed() == cm -def test_reversed_agrees_with_the_r_suffix() -> None: - assert Colormap("napari:HiLo").reversed() == Colormap("napari:HiLo_r") +# one catalog entry per kind of state that "_r" and reversed() could disagree on +@pytest.mark.parametrize( + "name", + [ + "napari:HiLo", # under/over in the record, which must swap + "tab10", # nearest interpolation + "napari:nan", # bad in the record + ], +) +def test_reversed_agrees_with_the_r_suffix(name: str) -> None: + assert Colormap(name).reversed() == Colormap(f"{name}_r") + + +def test_r_suffix_keeps_explicit_directional_colors() -> None: # the record's colors swap; an explicit argument names the end it lands on assert Colormap("napari:HiLo_r", under="green").under_color == Color("green") From e7fca1bb7b131c1bd6c27ef41cade4c94e878059 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 16:56:21 +0200 Subject: [PATCH 08/10] perf: record the catalog name at construction instead of comparing at serialization An unmodified catalog colormap now serializes and pickles as its qualified name (keeping `_r`), which restores `info` after pickle/copy and makes serialization as cheap as on main. Co-Authored-By: Claude Opus 5.5 --- src/cmap/_colormap.py | 35 +++++++++++++++++----------------- tests/test_colormap.py | 39 ++++++++++++++++++++++++++++++++++++++ tests/test_model_fields.py | 6 ++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 21d3e4d07..4f6df1a6b 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -175,6 +175,7 @@ class Colormap: __slots__ = ( "__weakref__", + "_catalog_name", "_has_exceptional", "_initialized", "_lut_cache", @@ -289,10 +290,19 @@ def __init__( cmap_kwargs: dict[str, Any] | None = None, ) -> None: self.info: CatalogItem | None = None + # the catalog name that alone rebuilds this colormap, if nothing overrode it + self._catalog_name: str | None = None if isinstance(value, str): rev = value.endswith("_r") info = self.catalog()[value[:-2] if rev else value] + # every other constructor argument must be listed here: one that is + # missed is silently dropped when this colormap serializes or pickles + # as its bare name + overrides = (name, identifier, category, interpolation, cmap_kwargs) + extremes = (under, over, bad, neg_inf, pos_inf, nan) + if all(v is None for v in (*overrides, *extremes)): + self._catalog_name = info.qualified_name + ("_r" if rev else "") name = name or f"{info.namespace}:{info.name}" category = category or info.category # the record's directional colors follow the ends they extend, so a "_r" @@ -805,24 +815,18 @@ def __setattr__(self, _name: str, _value: Any) -> None: object.__setattr__(self, _name, _value) def __reduce__(self) -> str | tuple[Any, ...]: - # for pickle. The stops go through as the object rather than as as_dict()'s - # samples, so a colormap backed by a lut function keeps the function. + # for pickle. An unmodified catalog colormap goes through as its name, which + # also restores `info`. + if self._catalog_name is not None: + return self.__class__, (self._catalog_name,) + # The stops go through as the object rather than as as_dict()'s samples, so a + # colormap backed by a lut function keeps the function. return _rebuild_colormap, ( self.__class__, self.color_stops, self._constructor_kwargs(), ) - def _is_identical(self, other: Colormap) -> bool: - """Whether `other` has exactly the state that `as_dict()` would write.""" - # unlike `__eq__`, which ignores the names and compares stops with allclose - return ( - (self.name, self.identifier, self.category, self.interpolation) - == (other.name, other.identifier, other.category, other.interpolation) - and self._extremes == other._extremes - and np.array_equal(self.color_stops._stops, other.color_stops._stops) - ) - def __eq__(self, other: object) -> bool: if not isinstance(other, Colormap): try: @@ -916,12 +920,7 @@ def __get_pydantic_core_schema__( schema = handler(Any) def _serialize(obj: Colormap) -> Any: - if obj.info is not None and (qualified := obj.info.qualified_name): - # the name alone is a complete serialization only when it rebuilds the - # same colormap; "viridis_r" and a modified viridis both fail that - if obj._is_identical(Colormap(qualified)): - return qualified - return obj.as_dict() + return obj._catalog_name or obj.as_dict() ser = core_schema.plain_serializer_function_ser_schema(_serialize) return core_schema.no_info_after_validator_function( diff --git a/tests/test_colormap.py b/tests/test_colormap.py index c8bf1d2ed..5f39b3ac0 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -1,3 +1,4 @@ +import inspect import pickle import sys from copy import copy, deepcopy @@ -490,6 +491,44 @@ def test_pickle_preserves_constructor_state() -> None: ) +@pytest.mark.parametrize("name", ["viridis", "viridis_r", "napari:HiLo_r"]) +def test_copies_of_a_catalog_colormap_keep_its_info(name: str) -> None: + cm = Colormap(name) + + for copied in (pickle.loads(pickle.dumps(cm)), copy(cm), deepcopy(cm)): # noqa: S301 + assert copied == cm + assert copied.name == cm.name + assert copied.info is not None and copied.info == cm.info + + +# a sample for every constructor argument besides `value`. A new argument fails the +# test below until it has a sample here, and that sample fails it until the argument +# is in the overrides that `Colormap.__init__` checks before recording a catalog name. +_ARGUMENT_SAMPLES: dict[str, Any] = { + "name": "renamed", + "identifier": "renamed", + "category": "sequential", + "interpolation": False, + "under": "red", + "over": "red", + "bad": "red", + "neg_inf": "red", + "pos_inf": "red", + "nan": "red", + "cmap_kwargs": {"start": 1.0}, +} + + +def test_any_constructor_argument_disables_the_catalog_name() -> None: + params = set(inspect.signature(Colormap).parameters) - {"value"} + assert params == set(_ARGUMENT_SAMPLES) + + assert Colormap("cubehelix")._catalog_name == "cubehelix:cubehelix" + for arg, sample in _ARGUMENT_SAMPLES.items(): + cm = Colormap("cubehelix", **{arg: sample}) + assert cm._catalog_name is None, f"{arg}= is not treated as an override" + + def test_copies_of_a_callable_colormap_stay_callable() -> None: # as_dict() samples the function into 256 stops; a copy must keep the function cm = Colormap("cubehelix", cmap_kwargs={"start": 1.0, "rotation": -1.0}) diff --git a/tests/test_model_fields.py b/tests/test_model_fields.py index 464fb44d4..28e1640c1 100644 --- a/tests/test_model_fields.py +++ b/tests/test_model_fields.py @@ -75,6 +75,12 @@ def round_trip(cmap: Colormap) -> Colormap: assert round_trip(Colormap("viridis", under="red")).under_color == Color("red") assert round_trip(Colormap("viridis", name="renamed")).name == "renamed" assert round_trip(Colormap("viridis_r")) == Colormap("viridis_r") + # ... and the reversed name alone rebuilds it, so it serializes to that + obj = MyModel(colormap=Colormap("viridis_r")) + assert '"colormap":"bids:viridis_r"' in obj.model_dump_json() + # a falsy argument is still an override: False is "nearest", viridis is linear + nearest = Colormap("viridis", interpolation=False) + assert round_trip(nearest).interpolation == "nearest" # a parametrization too small for the tolerant __eq__ to see still must not # collapse to the plain catalog name From 40270741a7d6640ceba700b8ea51a7060c355c64 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 19 Oct 2026 18:31:52 +0200 Subject: [PATCH 09/10] docs: drop misleading advice on clearing an extreme from with_extremes Co-Authored-By: Claude Opus 5.5 --- src/cmap/_colormap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index 4f6df1a6b..628005999 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -563,7 +563,7 @@ def with_extremes( """Return a copy of the colormap with new extreme values. Colors that are not passed are carried over from this colormap, as in - matplotlib. To clear one, construct a new `Colormap`. + matplotlib. """ return type(self)( self.color_stops, From 7d0f11e0dae95079ddbb2a5cd91501471deb18be Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 20 Oct 2026 11:51:55 +0200 Subject: [PATCH 10/10] docs: say which methods list the extremes by hand Co-Authored-By: Claude Opus 5.5 --- src/cmap/_colormap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cmap/_colormap.py b/src/cmap/_colormap.py index e55d28b0b..4dab625fe 100644 --- a/src/cmap/_colormap.py +++ b/src/cmap/_colormap.py @@ -62,7 +62,8 @@ class ColormapDict(TypedDict): # the extreme colors, in constructor-argument spelling; `_color` is the property. -# Copy and serialization paths iterate this rather than repeating the list. +# `as_dict()` and pickle iterate this; `reversed()`, `shifted()` and `with_extremes()` +# spell out each one, so a new extreme must be added there too. _EXTREME_FIELDS = ("under", "over", "bad", "neg_inf", "pos_inf", "nan") LutCallable: TypeAlias = Callable[["NDArray"], "NDArray"]