Skip to content

Commit fe88218

Browse files
ENH: Add cmasher.combine_cmaps
Co-authored-by: Clément Robert <[email protected]>
1 parent a68d6ee commit fe88218

File tree

5 files changed

+254
-1
lines changed

5 files changed

+254
-1
lines changed

cmasher/tests/test_utils.py

+119
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import cmasher as cmr
1717
from cmasher import cm as cmrcm
1818
from cmasher.utils import (
19+
combine_cmaps,
1920
create_cmap_mod,
2021
create_cmap_overview,
2122
get_bibtex,
@@ -65,6 +66,124 @@ def _MPL38_colormap_eq(cmap, other) -> bool:
6566

6667

6768
# %% PYTEST CLASSES AND FUNCTIONS
69+
# Pytest class for combine_cmaps
70+
class Test_combine_cmaps:
71+
# Test if multiple Colormaps or colormap names can be combined
72+
@pytest.mark.parametrize(
73+
"cmaps, nodes",
74+
[
75+
(["Blues", "Oranges", "Greens"], [0.25, 0.75]),
76+
(["Blues", "Oranges", "Greens"], np.array([0.25, 0.75])),
77+
(
78+
[
79+
mpl.colormaps["Blues"],
80+
mpl.colormaps["Oranges"],
81+
mpl.colormaps["Greens"],
82+
],
83+
[0.25, 0.75],
84+
),
85+
],
86+
)
87+
def test_combine_cmaps(self, cmaps, nodes):
88+
combined_cmap = combine_cmaps(*cmaps, nodes=nodes, n_rgb_levels=256)
89+
blues_cmap = mpl.colormaps["Blues"]
90+
oranges_cmap = mpl.colormaps["Oranges"]
91+
greens_cmap = mpl.colormaps["Greens"]
92+
93+
assert np.allclose(combined_cmap(0.0), blues_cmap(0))
94+
assert np.allclose(combined_cmap(0.25), oranges_cmap(0))
95+
assert np.allclose(combined_cmap(0.75), greens_cmap(0))
96+
assert np.allclose(combined_cmap(1.0), greens_cmap(255))
97+
98+
assert combined_cmap.N == 256
99+
100+
# Test combine cmaps with default nodes
101+
def test_default_nodes(self):
102+
combined_cmap = combine_cmaps("Blues", "Oranges", n_rgb_levels=256)
103+
104+
blues_cmap = mpl.colormaps["Blues"]
105+
oranges_cmap = mpl.colormaps["Oranges"]
106+
107+
assert np.allclose(combined_cmap(0.0), blues_cmap(0))
108+
assert np.allclose(combined_cmap(0.5), oranges_cmap(0))
109+
assert np.allclose(combined_cmap(1.0), oranges_cmap(255))
110+
111+
# Test if combining less than 2 colormaps triggers an error
112+
@pytest.mark.parametrize(
113+
"cmaps",
114+
[
115+
pytest.param([], id="no_cmap"),
116+
pytest.param(["Blues"], id="single_cmap"),
117+
pytest.param(["fake_name"], id="fake_cmap_name"),
118+
],
119+
)
120+
def test_not_enough_cmaps(self, cmaps):
121+
with pytest.raises(
122+
ValueError, match="Expected at least two colormaps to combine."
123+
):
124+
combine_cmaps(*cmaps)
125+
126+
# Test if invalid colormap name raise an error
127+
def test_invalid_cmap_name(self):
128+
with pytest.raises(
129+
KeyError,
130+
match="'fake_cmap' is not a known colormap name",
131+
):
132+
combine_cmaps("fake_cmap", "Blues")
133+
134+
# Test if invalid colormap types raise an error
135+
@pytest.mark.parametrize(
136+
"invalid_cmap",
137+
[0, 0.0, [], ()],
138+
)
139+
def test_invalid_cmap_types(self, invalid_cmap):
140+
with pytest.raises(
141+
TypeError,
142+
match=f"Unsupported colormap type: {type(invalid_cmap)}.",
143+
):
144+
combine_cmaps("Blues", invalid_cmap)
145+
146+
# Test if invalid nodes types raise an error
147+
def test_invalid_nodes_types(self):
148+
invalid_nodes = "0.5"
149+
with pytest.raises(
150+
TypeError,
151+
match=f"Unsupported nodes type: {type(invalid_nodes)}, expect list of float.",
152+
):
153+
combine_cmaps("Blues", "Greens", nodes=invalid_nodes)
154+
155+
# Test if mismatch cmaps and nodes length raise an error
156+
@pytest.mark.parametrize(
157+
"cmaps, nodes",
158+
[
159+
(["Blues", "Oranges", "Greens"], [0.5]),
160+
(["Reds", "Blues"], [0.2, 0.8]),
161+
],
162+
)
163+
def test_cmaps_nodes_length_mismatch(self, cmaps, nodes):
164+
with pytest.raises(
165+
ValueError,
166+
match=("Number of nodes should be one less than the number of colormaps."),
167+
):
168+
combine_cmaps(*cmaps, nodes=nodes)
169+
170+
# Test if invalid nodes raise an error
171+
@pytest.mark.parametrize(
172+
"cmaps, nodes",
173+
[
174+
(["Blues", "Oranges", "Greens"], [-1, 0.75]),
175+
(["Blues", "Oranges", "Greens"], [0.25, 2]),
176+
(["Blues", "Oranges", "Greens"], [0.75, 0.25]),
177+
],
178+
)
179+
def test_invalid_nodes(self, cmaps, nodes):
180+
with pytest.raises(
181+
ValueError,
182+
match="Nodes should only contain increasing values between 0.0 and 1.0.",
183+
):
184+
combine_cmaps(*cmaps, nodes=nodes)
185+
186+
68187
# Pytest class for create_cmap_mod
69188
class Test_create_cmap_mod:
70189
# Test if a standalone module of rainforest can be created

cmasher/utils.py

+111-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121

2222
# Package imports
2323
from colorspacious import cspace_converter
24-
from matplotlib.colors import Colormap, ListedColormap as LC, to_hex, to_rgb
24+
from matplotlib.colors import (
25+
Colormap,
26+
LinearSegmentedColormap,
27+
ListedColormap as LC,
28+
to_hex,
29+
to_rgb,
30+
)
2531

2632
# CMasher imports
2733
from cmasher import cm as cmrcm
@@ -40,6 +46,7 @@
4046

4147
# All declaration
4248
__all__ = [
49+
"combine_cmaps",
4350
"create_cmap_mod",
4451
"create_cmap_overview",
4552
"get_bibtex",
@@ -233,6 +240,109 @@ def _get_cmap_perceptual_rank(
233240

234241

235242
# %% FUNCTIONS
243+
# This function combines multiple colormaps at given nodes
244+
def combine_cmaps(
245+
*cmaps: Union[Colormap, str],
246+
nodes: Optional[Union[list[float], np.ndarray]] = None,
247+
n_rgb_levels: int = 256,
248+
combined_cmap_name: str = "combined_cmap",
249+
) -> LinearSegmentedColormap:
250+
"""Create a composite matplotlib colormap by combining multiple colormaps.
251+
252+
Parameters
253+
----------
254+
*cmaps: Colormap or colormap name (str) to be combined.
255+
nodes: list or numpy array of nodes (float). Defaults: equal divisions.
256+
The blending points between colormaps, in the range [0, 1].
257+
n_rgb_levels: int. Defaults: 256.
258+
Number of RGB levels for each colormap segment.
259+
combined_cmap_name: str. Defaults: "combined_cmap".
260+
name of the combined Colormap.
261+
262+
Returns
263+
-------
264+
Colormap: The composite colormap.
265+
266+
Raises
267+
------
268+
TypeError: If the list contains mixed datatypes or invalid
269+
colormap names.
270+
ValueError: If the cmaps contain only one single colormap,
271+
or if the number of nodes is not one less than the number
272+
of colormaps, or if the nodes do not contain incrementing values
273+
between 0.0 and 1.0.
274+
275+
Note
276+
----
277+
The colormaps are combined from low value to high value end.
278+
279+
References
280+
----------
281+
- https://stackoverflow.com/questions/31051488/combining-two-matplotlib-colormaps/31052741#31052741
282+
283+
Examples
284+
--------
285+
Using predefined colormap names::
286+
>>> custom_cmap_1 = combine_cmaps(
287+
["ocean", "prism", "coolwarm"], nodes=[0.2, 0.75]
288+
)
289+
290+
Using Colormap objects::
291+
>>> cmap_0 = plt.get_cmap("Blues")
292+
>>> cmap_1 = plt.get_cmap("Oranges")
293+
>>> cmap_2 = plt.get_cmap("Greens")
294+
>>> custom_cmap_2 = combine_cmaps([cmap_0, cmap_1, cmap_2])
295+
296+
"""
297+
# Check colormap datatype and convert to list[Colormap]
298+
if len(cmaps) <= 1:
299+
raise ValueError("Expected at least two colormaps to combine.")
300+
for cm in cmaps:
301+
if not isinstance(cm, (Colormap, str)):
302+
raise TypeError(f"Unsupported colormap type: {type(cm)}.")
303+
_cmaps: list[Colormap] = [
304+
cm if isinstance(cm, Colormap) else mpl.colormaps[cm] for cm in cmaps
305+
]
306+
307+
# Generate default nodes for equal separation
308+
if nodes is None:
309+
nodes_arr = np.linspace(0, 1, len(_cmaps) + 1)
310+
elif isinstance(nodes, (list, np.ndarray)):
311+
nodes_arr = np.concatenate([[0.0], nodes, [1.0]])
312+
else:
313+
raise TypeError(f"Unsupported nodes type: {type(nodes)}, expect list of float.")
314+
315+
# Check nodes length
316+
if len(nodes_arr) != len(_cmaps) + 1:
317+
raise ValueError(
318+
"Number of nodes should be one less than the number of colormaps."
319+
)
320+
321+
# Check node values
322+
if any((nodes_arr < 0) | (nodes_arr > 1)) or any(np.diff(nodes_arr) <= 0):
323+
raise ValueError(
324+
"Nodes should only contain increasing values between 0.0 and 1.0."
325+
)
326+
327+
# Generate composite colormap
328+
combined_cmap_segments = []
329+
330+
for i, cmap in enumerate(_cmaps):
331+
start_position = nodes_arr[i]
332+
end_position = nodes_arr[i + 1]
333+
334+
# Calculate the length of the segment
335+
segment_length = int(n_rgb_levels * (end_position - start_position))
336+
337+
# Append the segment to the combined colormap segments
338+
combined_cmap_segments.append(cmap(np.linspace(0, 1, segment_length)))
339+
340+
# Combine the segments (from bottom to top)
341+
return LinearSegmentedColormap.from_list(
342+
combined_cmap_name, np.vstack(combined_cmap_segments)
343+
)
344+
345+
236346
# This function creates a standalone module of a CMasher colormap
237347
def create_cmap_mod(
238348
cmap: str, *, save_dir: str = ".", _copy_name: Optional[str] = None
Loading
3.02 KB
Loading

docs/source/user/usage.rst

+24
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,29 @@ For that reason, below is an overview of all colormaps in *CMasher* (and the rev
111111
Application overview plot of *CMasher*'s colormaps.
112112

113113

114+
.. _combine_colormaps:
115+
116+
Combine colormaps
117+
-----------------
118+
*CMasher* offers a utility function :func:`~cmasher.combine_cmaps`, which enables the combination of multiple colormaps at specified ``nodes`` (where a node denotes the point separating adjacent colormaps, within the interval [0, 1]). You can directly pass several colormaps using the function like so :pycode:`combine_cmaps("cmr.rainforest", "cmr.torch_r")`. By default, each sub-colormap occupies an equal portion of the final colormap.
119+
120+
.. figure:: images/combine_cmaps_equal.png
121+
:alt: Combine two colormaps with default equal separation.
122+
:width: 100%
123+
:align: center
124+
125+
Combine two colormaps with default equal separation.
126+
127+
Alternatively, you may want to specify ``nodes`` explicitly. For example :pycode:`cmr.combine_cmaps("cmr.rainforest", "cmr.torch_r", nodes=[0.75])` would allocate the starting 75% of the final colormap to "cmr.rainforest"and the remaining 25% to "cmr.torch_r".
128+
129+
.. figure:: images/combine_cmaps_0.75_0.25.png
130+
:alt: Combine two colormaps with a 75%/25% separation.
131+
:width: 100%
132+
:align: center
133+
134+
Combine two colormaps with a 75%/25% separation.
135+
136+
114137
Command-line interface (CLI)
115138
----------------------------
116139
Although *CMasher* is written in Python, some of its utility functions do not require the interpreter in order to be used properly.
@@ -216,6 +239,7 @@ The script and image below show an example of this::
216239

217240
Hexbin plot using a colormap legend entry for the :ref:`rainforest` colormap.
218241

242+
219243
.. _sub_colormaps:
220244

221245
Sub-colormaps

0 commit comments

Comments
 (0)