diff --git a/docs/colormaps.md b/docs/colormaps.md index ab9ccf123..4e4560bba 100644 --- a/docs/colormaps.md +++ b/docs/colormaps.md @@ -435,6 +435,30 @@ external visualization libraries. To that end, `cmap.Colormap` provides Returns an instance of [`pyqtgraph.ColorMap`](https://pyqtgraph.readthedocs.io/en/latest/api_reference/colormap.html#pyqtgraph.ColorMap) +### Using cmap names as matplotlib strings + +`cmap` does not register its colormaps with matplotlib on import. To use catalog +names as plain strings anywhere matplotlib accepts a colormap name, call [`cmap.register_mpl`][]: + +```python +import cmap +import matplotlib.pyplot as plt + +cmap.register_mpl("tol:*", "batlow") # names or fnmatch patterns +plt.imshow(data, cmap="tol:sunset") +plt.imshow(data, cmap="crameri:batlow_r") # reversed versions are registered too +``` + +Colormaps are registered under their canonical, fully-qualified name (lowercase, with +`_` in place of `-` or spaces: `Colormap(name).name`), so they never collide with +matplotlib's builtin names. + +!!!note "For library authors" + Please don't call `cmap.register_mpl("*")` on behalf of your users: it adds + 1300+ entries to matplotlib's global registry, visible to all code in the + session, and takes a second or more. Prefer registering the specific name(s) + you need. + ## Usage with pydantic `Colormap` can be used as a field type in [pydantic](https://docs.pydantic.dev/) diff --git a/src/cmap/__init__.py b/src/cmap/__init__.py index bda54a72d..ef5dc7c07 100644 --- a/src/cmap/__init__.py +++ b/src/cmap/__init__.py @@ -11,7 +11,7 @@ from ._color import HSLA, HSVA, RGBA, RGBA8, Color, ColorLike from ._colormap import Colormap, ColormapLike, ColorStop, ColorStops -from ._util import to_mpl +from ._util import register_mpl, to_mpl if TYPE_CHECKING: from collections.abc import Iterator, Mapping @@ -74,6 +74,7 @@ def resolve(self, name: str) -> str: "ColorStop", "Catalog", "CatalogItem", + "register_mpl", "to_mpl", "RGBA", "RGBA8", diff --git a/src/cmap/_util.py b/src/cmap/_util.py index b4dba85b0..d5d2b40b3 100644 --- a/src/cmap/_util.py +++ b/src/cmap/_util.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from fnmatch import fnmatchcase from typing import TYPE_CHECKING, TypedDict, cast import numpy as np @@ -593,6 +594,68 @@ def to_mpl( return cm.to_matplotlib(N=N, gamma=gamma) +def register_mpl(*patterns: str) -> list[str]: + """Register colormaps from the cmap catalog with `matplotlib.colormaps`. + + Call this explicitly to make catalog names usable as strings anywhere + matplotlib accepts a colormap name. + + Colormaps are registered under their canonical, fully-qualified cmap name + (lowercase, with `_` instead of `-` or spaces; see `Colormap(name).name`), along + with a reversed `_r` version. Short names like "viridis" are never registered, so + as not to collide with matplotlib's colormaps. Names that are already registered + are left untouched, so it is safe to call this multiple times. + + Parameters + ---------- + *patterns : str + Colormap names or `fnmatch`-style patterns (e.g. `"tol:*"`) to register. + Any name accepted by `Colormap` works (including short names and aliases). + `"*"` registers the entire catalog, but be aware that matplotlib's + registration time grows with the size of its registry: this may take many + seconds. + + Returns + ------- + list[str] + The canonical names of the matched colormaps. + + Examples + -------- + >>> import cmap + >>> cmap.register_mpl("tol:*", "batlow") # doctest: +SKIP + >>> plt.imshow(data, cmap="crameri:batlow_r") # doctest: +SKIP + """ + import matplotlib as mpl + + from cmap import Colormap + + if not patterns: + raise TypeError("register_mpl() requires at least one name or pattern.") + catalog = Colormap.catalog() + norm = catalog._norm_name + resolved: set[str] = set() + for pattern in patterns: + matches = { + catalog[catalog.resolve(key)].qualified_name + for key in catalog + if fnmatchcase(norm(key), norm(pattern)) + } + if not matches: + raise KeyError(f"No colormaps in the cmap catalog match {pattern!r}.") + resolved |= matches + + # look up `mpl.colormaps` at call time: downstream libraries (e.g. UltraPlot) + # may have replaced it with their own registry. + existing = set(mpl.colormaps) + for name in resolved - existing: + mpl_cm = Colormap(name).to_mpl() + for cm in (mpl_cm, mpl_cm.reversed()): + if cm.name not in existing: + mpl.colormaps.register(cm) + return sorted(resolved) + + if __name__ == "__main__": # pragma: no cover import matplotlib.pyplot as plt diff --git a/tests/test_utils.py b/tests/test_utils.py index 7663ef07d..98bcf2dce 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,7 @@ except ImportError: MplFigure = None +import cmap from cmap import Colormap, _util CMAP_NAME = "viridis" @@ -78,3 +79,27 @@ def test_report() -> None: report2 = _util.report(Colormap("red")) assert isinstance(report2, dict) + + +def test_register_mpl() -> None: + mpl = pytest.importorskip("matplotlib") + before = set(mpl.colormaps) + try: + names = cmap.register_mpl("tol:*", "batlow", "colorbrewer:Blues") + assert "tol:bright" in names + assert {"crameri:batlow", "colorbrewer:blues"} <= set(names) + for name in names: + assert mpl.colormaps[name].name == name + assert f"{name}_r" in mpl.colormaps + assert "batlow" not in mpl.colormaps + + # idempotent + added = set(mpl.colormaps) - before + assert cmap.register_mpl("tol:*") == [n for n in names if n.startswith("tol")] + assert set(mpl.colormaps) - before == added + + with pytest.raises(KeyError, match="No colormaps"): + cmap.register_mpl("nope:*") + finally: + for name in set(mpl.colormaps) - before: + mpl.colormaps.unregister(name)