From 4f06d0344798094b3dbac82313b5b6216830d679 Mon Sep 17 00:00:00 2001 From: "Haoyu (Daniel)" Date: Tue, 30 Jan 2024 11:33:58 +0800 Subject: [PATCH] ENH: Add cmasher.combine_cmaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Clément Robert --- cmasher/tests/test_utils.py | 119 ++++++++++++++++++ cmasher/utils.py | 112 ++++++++++++++++- .../user/images/combine_cmaps_0.75_0.25.png | Bin 0 -> 3148 bytes .../user/images/combine_cmaps_equal.png | Bin 0 -> 3092 bytes docs/source/user/usage.rst | 24 ++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 docs/source/user/images/combine_cmaps_0.75_0.25.png create mode 100644 docs/source/user/images/combine_cmaps_equal.png diff --git a/cmasher/tests/test_utils.py b/cmasher/tests/test_utils.py index 4024dcaf..1c0769d3 100644 --- a/cmasher/tests/test_utils.py +++ b/cmasher/tests/test_utils.py @@ -16,6 +16,7 @@ import cmasher as cmr from cmasher import cm as cmrcm from cmasher.utils import ( + combine_cmaps, create_cmap_mod, create_cmap_overview, get_bibtex, @@ -65,6 +66,124 @@ def _MPL38_colormap_eq(cmap, other) -> bool: # %% PYTEST CLASSES AND FUNCTIONS +# Pytest class for combine_cmaps +class Test_combine_cmaps: + # Test if multiple Colormaps or colormap names can be combined + @pytest.mark.parametrize( + "cmaps, nodes", + [ + (["Blues", "Oranges", "Greens"], [0.25, 0.75]), + (["Blues", "Oranges", "Greens"], np.array([0.25, 0.75])), + ( + [ + mpl.colormaps["Blues"], + mpl.colormaps["Oranges"], + mpl.colormaps["Greens"], + ], + [0.25, 0.75], + ), + ], + ) + def test_combine_cmaps(self, cmaps, nodes): + combined_cmap = combine_cmaps(*cmaps, nodes=nodes, n_rgb_levels=256) + blues_cmap = mpl.colormaps["Blues"] + oranges_cmap = mpl.colormaps["Oranges"] + greens_cmap = mpl.colormaps["Greens"] + + assert np.allclose(combined_cmap(0.0), blues_cmap(0)) + assert np.allclose(combined_cmap(0.25), oranges_cmap(0)) + assert np.allclose(combined_cmap(0.75), greens_cmap(0)) + assert np.allclose(combined_cmap(1.0), greens_cmap(255)) + + assert combined_cmap.N == 256 + + # Test combine cmaps with default nodes + def test_default_nodes(self): + combined_cmap = combine_cmaps("Blues", "Oranges", n_rgb_levels=256) + + blues_cmap = mpl.colormaps["Blues"] + oranges_cmap = mpl.colormaps["Oranges"] + + assert np.allclose(combined_cmap(0.0), blues_cmap(0)) + assert np.allclose(combined_cmap(0.5), oranges_cmap(0)) + assert np.allclose(combined_cmap(1.0), oranges_cmap(255)) + + # Test if combining less than 2 colormaps triggers an error + @pytest.mark.parametrize( + "cmaps", + [ + pytest.param([], id="no_cmap"), + pytest.param(["Blues"], id="single_cmap"), + pytest.param(["fake_name"], id="fake_cmap_name"), + ], + ) + def test_not_enough_cmaps(self, cmaps): + with pytest.raises( + ValueError, match="Expected at least two colormaps to combine." + ): + combine_cmaps(*cmaps) + + # Test if invalid colormap name raise an error + def test_invalid_cmap_name(self): + with pytest.raises( + KeyError, + match="'fake_cmap' is not a known colormap name", + ): + combine_cmaps("fake_cmap", "Blues") + + # Test if invalid colormap types raise an error + @pytest.mark.parametrize( + "invalid_cmap", + [0, 0.0, [], ()], + ) + def test_invalid_cmap_types(self, invalid_cmap): + with pytest.raises( + TypeError, + match=f"Unsupported colormap type: {type(invalid_cmap)}.", + ): + combine_cmaps("Blues", invalid_cmap) + + # Test if invalid nodes types raise an error + def test_invalid_nodes_types(self): + invalid_nodes = "0.5" + with pytest.raises( + TypeError, + match=f"Unsupported nodes type: {type(invalid_nodes)}, expect list of float.", + ): + combine_cmaps("Blues", "Greens", nodes=invalid_nodes) + + # Test if mismatch cmaps and nodes length raise an error + @pytest.mark.parametrize( + "cmaps, nodes", + [ + (["Blues", "Oranges", "Greens"], [0.5]), + (["Reds", "Blues"], [0.2, 0.8]), + ], + ) + def test_cmaps_nodes_length_mismatch(self, cmaps, nodes): + with pytest.raises( + ValueError, + match=("Number of nodes should be one less than the number of colormaps."), + ): + combine_cmaps(*cmaps, nodes=nodes) + + # Test if invalid nodes raise an error + @pytest.mark.parametrize( + "cmaps, nodes", + [ + (["Blues", "Oranges", "Greens"], [-1, 0.75]), + (["Blues", "Oranges", "Greens"], [0.25, 2]), + (["Blues", "Oranges", "Greens"], [0.75, 0.25]), + ], + ) + def test_invalid_nodes(self, cmaps, nodes): + with pytest.raises( + ValueError, + match="Nodes should only contain increasing values between 0.0 and 1.0.", + ): + combine_cmaps(*cmaps, nodes=nodes) + + # Pytest class for create_cmap_mod class Test_create_cmap_mod: # Test if a standalone module of rainforest can be created diff --git a/cmasher/utils.py b/cmasher/utils.py index b8f2e0c6..44d8eaf2 100644 --- a/cmasher/utils.py +++ b/cmasher/utils.py @@ -21,7 +21,13 @@ # Package imports from colorspacious import cspace_converter -from matplotlib.colors import Colormap, ListedColormap as LC, to_hex, to_rgb +from matplotlib.colors import ( + Colormap, + LinearSegmentedColormap, + ListedColormap as LC, + to_hex, + to_rgb, +) # CMasher imports from cmasher import cm as cmrcm @@ -40,6 +46,7 @@ # All declaration __all__ = [ + "combine_cmaps", "create_cmap_mod", "create_cmap_overview", "get_bibtex", @@ -233,6 +240,109 @@ def _get_cmap_perceptual_rank( # %% FUNCTIONS +# This function combines multiple colormaps at given nodes +def combine_cmaps( + *cmaps: Union[Colormap, str], + nodes: Optional[Union[list[float], np.ndarray]] = None, + n_rgb_levels: int = 256, + combined_cmap_name: str = "combined_cmap", +) -> LinearSegmentedColormap: + """Create a composite matplotlib colormap by combining multiple colormaps. + + Parameters + ---------- + *cmaps: Colormap or colormap name (str) to be combined. + nodes: list or numpy array of nodes (float). Defaults: equal divisions. + The blending points between colormaps, in the range [0, 1]. + n_rgb_levels: int. Defaults: 256. + Number of RGB levels for each colormap segment. + combined_cmap_name: str. Defaults: "combined_cmap". + name of the combined Colormap. + + Returns + ------- + Colormap: The composite colormap. + + Raises + ------ + TypeError: If the list contains mixed datatypes or invalid + colormap names. + ValueError: If the cmaps contain only one single colormap, + or if the number of nodes is not one less than the number + of colormaps, or if the nodes do not contain incrementing values + between 0.0 and 1.0. + + Note + ---- + The colormaps are combined from low value to high value end. + + References + ---------- + - https://stackoverflow.com/questions/31051488/combining-two-matplotlib-colormaps/31052741#31052741 + + Examples + -------- + Using predefined colormap names:: + >>> custom_cmap_1 = combine_cmaps( + ["ocean", "prism", "coolwarm"], nodes=[0.2, 0.75] + ) + + Using Colormap objects:: + >>> cmap_0 = plt.get_cmap("Blues") + >>> cmap_1 = plt.get_cmap("Oranges") + >>> cmap_2 = plt.get_cmap("Greens") + >>> custom_cmap_2 = combine_cmaps([cmap_0, cmap_1, cmap_2]) + + """ + # Check colormap datatype and convert to list[Colormap] + if len(cmaps) <= 1: + raise ValueError("Expected at least two colormaps to combine.") + for cm in cmaps: + if not isinstance(cm, (Colormap, str)): + raise TypeError(f"Unsupported colormap type: {type(cm)}.") + _cmaps: list[Colormap] = [ + cm if isinstance(cm, Colormap) else mpl.colormaps[cm] for cm in cmaps + ] + + # Generate default nodes for equal separation + if nodes is None: + nodes_arr = np.linspace(0, 1, len(_cmaps) + 1) + elif isinstance(nodes, (list, np.ndarray)): + nodes_arr = np.concatenate([[0.0], nodes, [1.0]]) + else: + raise TypeError(f"Unsupported nodes type: {type(nodes)}, expect list of float.") + + # Check nodes length + if len(nodes_arr) != len(_cmaps) + 1: + raise ValueError( + "Number of nodes should be one less than the number of colormaps." + ) + + # Check node values + if any((nodes_arr < 0) | (nodes_arr > 1)) or any(np.diff(nodes_arr) <= 0): + raise ValueError( + "Nodes should only contain increasing values between 0.0 and 1.0." + ) + + # Generate composite colormap + combined_cmap_segments = [] + + for i, cmap in enumerate(_cmaps): + start_position = nodes_arr[i] + end_position = nodes_arr[i + 1] + + # Calculate the length of the segment + segment_length = int(n_rgb_levels * (end_position - start_position)) + + # Append the segment to the combined colormap segments + combined_cmap_segments.append(cmap(np.linspace(0, 1, segment_length))) + + # Combine the segments (from bottom to top) + return LinearSegmentedColormap.from_list( + combined_cmap_name, np.vstack(combined_cmap_segments) + ) + + # This function creates a standalone module of a CMasher colormap def create_cmap_mod( cmap: str, *, save_dir: str = ".", _copy_name: Optional[str] = None diff --git a/docs/source/user/images/combine_cmaps_0.75_0.25.png b/docs/source/user/images/combine_cmaps_0.75_0.25.png new file mode 100644 index 0000000000000000000000000000000000000000..e91d73d14b6d4530aa760300653a08bdec07b87c GIT binary patch literal 3148 zcmeH||8E*~7{{+u=f2o=Kf1M7_-RZ00&cE`LR+foVp-BowVWNTET10 zva}janyfJ}S7TKa5C&s?*<6J_(vFic=CFZ z&nHjvyz{)D=hL^k->f}w^Z)<=wW`jJa{%y6Ijw2mx3`Q`?zd0MXttd0v7CprmNCu9 z0MMnen1&$BknVl4bzsD-gN(0AWRjCFi9fJdOlG-MYWVMi1R5EXiiP4&gg33tO?HM($P|oX3U;JznGli~nC!*4VXK^#+d~+P zW+j9}L;ltHTtqPy|0jfI!|p_W%tupRmhy}d({(P%w3KJU2`CmL729vTai4a_#;E*4zZ-Ep5$hIDY~8mQ%r!Cb%)v~JnHTGpI7T1w~^T}v5LusW*SeH)UDP!v_-(@Abg6Uv^+|BzcgOu>!=Sq^%#_N~nYSGU;(HkNrHE6( zvoR;7otgFF{Z4THv+1~x0x`+)Sa9A2axRyP>(WQaU)0~lA6~S`k}^fh@kUBZQQHp# zW|(WtcSVASVlmYB_!Z&k;DYiTl7Db=U5b>hq1nCjqLM^EGt;-3$mJqGaJEvw11||{ z8pEWBtJS07)N4%e*6jy(ZsA@Mqg#y|IJ|)iyPUqz%iXPwIsH58k4imCzYwvpPo7-b zs2Al&(*JA4}JOeU(JBM_j?x{U29w_!zLKjCN=Bu_{7!5~_Bn?u6=MtX>Ck h*#^}U`F|6k@GKzgPOkaRd|l~Gb-KI5^TvBu{spIywv7M) literal 0 HcmV?d00001 diff --git a/docs/source/user/images/combine_cmaps_equal.png b/docs/source/user/images/combine_cmaps_equal.png new file mode 100644 index 0000000000000000000000000000000000000000..05147eb183f18da3393e117553b2983ef9153125 GIT binary patch literal 3092 zcmeH}-%k@+6vqb>7j(B`VnP&(_Q@v#g4m*E05|x92U&k6YGK%Jh1yV=(&-P}aauH{ zRTF9Ut)iX!V8Fy^1C5~6ku_`_AD|P{gy~Q_h>eonX&H9A(?P4P?Tdee>BGtAoZOp} zlaq75=UzYBen_&hd?Nq=lD5OG#{pn{&Mj&w`5}i`^7XYG_L>fyHl2WbOjfnA8#tmi z8T7D8ulr4A={6!dxNmoLLv>x1?7YcjK=#$tT=;gO8a8TcWa-Rbxg;A5htD7YP^!*b z(f#I89RU1zxvh2o$$>kG@P1?D;O3TNgZHYIGtMJbf3$AeMYwN_e-ha72gO&<-ME{* zC?(DM29mjOD;hK>Z7j`<1-CD25MC2jQB4j8YYglSj1XNj?7l!^rPHg@E)!-8&N2NK zm6pWZ;QZnXVJw4NwY*CJ>*t^yT>2ky7UvR`KJrtU{o$ms(Mjm!o%}_6sh=R|CyHk) z9~g00pl?PWRyqjHKokipY5PP>3TYYx0mW=zj1llrBC_hgiH3w+2@U8D5mKS9{s|oW ziB4}#HT6;1XJRj!B7#A+JdUx?=b5L9G?v+xnrHZ=SIzTg-VIs5h+m{Q>?Qf`6!*E; zEkVgCu{NFvZV$!fiHDRc>KAqL4){&vPSjQx@cEPS#+p%Lj_L36O@e3~wEJ}Mz)r6y z6j_@bPWG*EHe0=T+s{4D%ysYrY*36!b1tk%o?O!Mz9fSD2`&!E2$;rtJQJ3K{dSv@+}2o$Qt``xQElG(?k%O zVL9-U%M)z(_j*8*5cW)nFBz7q^?G_Z-j;*5T z9^VoiSBi5J37jh#%wzLTU|kL^ZlEL|s@KW#xVm95kHTT0357{0JO^1`3<{c1XhLB- g6jtPS^G^s*pK5}Jj^gj?^YYeqpuP3c&%a*z3aU(obN~PV literal 0 HcmV?d00001 diff --git a/docs/source/user/usage.rst b/docs/source/user/usage.rst index 00f29442..38c87595 100644 --- a/docs/source/user/usage.rst +++ b/docs/source/user/usage.rst @@ -111,6 +111,29 @@ For that reason, below is an overview of all colormaps in *CMasher* (and the rev Application overview plot of *CMasher*'s colormaps. +.. _combine_colormaps: + +Combine colormaps +----------------- +*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. + +.. figure:: images/combine_cmaps_equal.png + :alt: Combine two colormaps with default equal separation. + :width: 100% + :align: center + + Combine two colormaps with default equal separation. + +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". + +.. figure:: images/combine_cmaps_0.75_0.25.png + :alt: Combine two colormaps with a 75%/25% separation. + :width: 100% + :align: center + + Combine two colormaps with a 75%/25% separation. + + Command-line interface (CLI) ---------------------------- 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:: Hexbin plot using a colormap legend entry for the :ref:`rainforest` colormap. + .. _sub_colormaps: Sub-colormaps