bsplot 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bsplot/__init__.py +23 -0
- bsplot/anat.py +55 -0
- bsplot/animate.py +194 -0
- bsplot/bioicons.py +777 -0
- bsplot/brain.py +188 -0
- bsplot/colors.py +1935 -0
- bsplot/data/HCP_avg-SC.txt +379 -0
- bsplot/data/MNI152.rh.pial +0 -0
- bsplot/data/STN_lh.nii.gz +0 -0
- bsplot/data/STN_rh.nii.gz +0 -0
- bsplot/data/__init__.py +22 -0
- bsplot/data/left_electrode.ply +257270 -0
- bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
- bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
- bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
- bsplot/data/right_electrode.ply +257270 -0
- bsplot/data/surface.py +112 -0
- bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
- bsplot/figure.py +177 -0
- bsplot/graph/__init__.py +31 -0
- bsplot/graph/edges.py +312 -0
- bsplot/graph/flowchart.py +762 -0
- bsplot/graph/layout.py +201 -0
- bsplot/graph/network.py +974 -0
- bsplot/graph/nodes.py +452 -0
- bsplot/panels.py +110 -0
- bsplot/scientific_color_maps/__init__.py +0 -0
- bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
- bsplot/scientific_color_maps/batlowK.txt +256 -0
- bsplot/scientific_color_maps/batlowW.txt +256 -0
- bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
- bsplot/scientific_color_maps/diverging/bam.txt +256 -0
- bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
- bsplot/scientific_color_maps/diverging/broc.txt +256 -0
- bsplot/scientific_color_maps/diverging/cork.txt +256 -0
- bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
- bsplot/scientific_color_maps/diverging/managua.txt +256 -0
- bsplot/scientific_color_maps/diverging/roma.txt +256 -0
- bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
- bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
- bsplot/scientific_color_maps/diverging/vik.txt +256 -0
- bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
- bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
- bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
- bsplot/scientific_color_maps/naviaW.txt +256 -0
- bsplot/scientific_color_maps/sequential/acton.txt +256 -0
- bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
- bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
- bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
- bsplot/scientific_color_maps/sequential/buda.txt +256 -0
- bsplot/scientific_color_maps/sequential/davos.txt +256 -0
- bsplot/scientific_color_maps/sequential/devon.txt +256 -0
- bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
- bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
- bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
- bsplot/scientific_color_maps/sequential/imola.txt +256 -0
- bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
- bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
- bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
- bsplot/scientific_color_maps/sequential/navia.txt +256 -0
- bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
- bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
- bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
- bsplot/scientific_color_maps/sequential/turku.txt +256 -0
- bsplot/streamlines.py +314 -0
- bsplot/style.py +791 -0
- bsplot/styles/black.mplstyle +78 -0
- bsplot/styles/bss.mplstyle +50 -0
- bsplot/styles/bwcomp.mplstyle +30 -0
- bsplot/styles/nature.mplstyle +57 -0
- bsplot/styles/transparent.mplstyle +16 -0
- bsplot/styles/tvbo.mplstyle +57 -0
- bsplot/surface.py +2370 -0
- bsplot/templates.py +270 -0
- bsplot/text.py +199 -0
- bsplot/text2obj.py +59 -0
- bsplot/timeseries.py +10 -0
- bsplot/utils.py +13 -0
- bsplot/volume.py +958 -0
- bsplot-0.0.2.dist-info/METADATA +60 -0
- bsplot-0.0.2.dist-info/RECORD +89 -0
- bsplot-0.0.2.dist-info/WHEEL +5 -0
- bsplot-0.0.2.dist-info/entry_points.txt +2 -0
- bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
- bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/colors.py
ADDED
|
@@ -0,0 +1,1935 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import matplotlib as mpl
|
|
6
|
+
import matplotlib.colors as mcolors
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import yaml
|
|
10
|
+
from matplotlib.colors import rgb2hex
|
|
11
|
+
|
|
12
|
+
# from pybtex.database import Entry
|
|
13
|
+
import numpy as np
|
|
14
|
+
import matplotlib.cm as cm
|
|
15
|
+
from cycler import cycler
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import cmap as cmap_lib
|
|
19
|
+
HAS_CMAP = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
HAS_CMAP = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Palette:
|
|
25
|
+
"""
|
|
26
|
+
A color palette class for managing discrete sets of colors.
|
|
27
|
+
|
|
28
|
+
Use `Palette.list_available()` to see built-in palettes and
|
|
29
|
+
`Palette.load(name)` to load them.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Registry of built-in palettes
|
|
33
|
+
_registry = {}
|
|
34
|
+
|
|
35
|
+
def __init__(self, colors=None, reference=None, **kwargs):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the palette with an optional dictionary of colors.
|
|
38
|
+
|
|
39
|
+
Parameters:
|
|
40
|
+
colors (dict): Optional dictionary of colors where each key is a color name,
|
|
41
|
+
and each value is either a HEX string or an RGB tuple.
|
|
42
|
+
"""
|
|
43
|
+
self.colors = {}
|
|
44
|
+
|
|
45
|
+
if "name" in kwargs:
|
|
46
|
+
self.name = kwargs.pop("name")
|
|
47
|
+
else:
|
|
48
|
+
self.name = "Custom Palette"
|
|
49
|
+
|
|
50
|
+
if colors is not None:
|
|
51
|
+
|
|
52
|
+
if isinstance(colors, dict):
|
|
53
|
+
for name, color in colors.items():
|
|
54
|
+
if isinstance(color, tuple) and len(color) == 3:
|
|
55
|
+
self.add_color(name, rgb=color)
|
|
56
|
+
elif isinstance(color, str) and color.startswith("#"):
|
|
57
|
+
self.add_color(name, hex_code=color)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"Colors must be in RGB tuple or HEX string format."
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
for color in colors:
|
|
64
|
+
if len(color) == 3:
|
|
65
|
+
self.add_color(f"color_{len(self.colors) + 1}", rgb=color)
|
|
66
|
+
elif isinstance(color, str) and color.startswith("#"):
|
|
67
|
+
self.add_color(f"color_{len(self.colors) + 1}", hex_code=color)
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"Colors must be in RGB tuple or HEX string format."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if reference:
|
|
74
|
+
self.reference = reference
|
|
75
|
+
|
|
76
|
+
def __repr__(self):
|
|
77
|
+
return f"Palette('{self.name}', {len(self.colors)} colors)"
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def register(cls, name, palette):
|
|
81
|
+
"""Register a palette in the registry."""
|
|
82
|
+
cls._registry[name] = palette
|
|
83
|
+
palette.name = name
|
|
84
|
+
return palette
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def list_available(cls):
|
|
88
|
+
"""
|
|
89
|
+
List all available built-in palettes.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
list: List of palette names.
|
|
93
|
+
"""
|
|
94
|
+
return list(cls._registry.keys())
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def load(cls, name):
|
|
98
|
+
"""
|
|
99
|
+
Load a palette by name.
|
|
100
|
+
|
|
101
|
+
Parameters:
|
|
102
|
+
name (str): Name of the palette.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Palette: The requested palette.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValueError: If palette not found.
|
|
109
|
+
"""
|
|
110
|
+
if name not in cls._registry:
|
|
111
|
+
available = cls.list_available()
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Palette '{name}' not found. Available: {', '.join(available)}"
|
|
114
|
+
)
|
|
115
|
+
return cls._registry[name]
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_cmap(cls, cmap, n=8, start=0.0, stop=1.0, name=None):
|
|
119
|
+
"""
|
|
120
|
+
Create a Palette by sampling colors from a colormap.
|
|
121
|
+
|
|
122
|
+
Parameters:
|
|
123
|
+
cmap: Colormap name (str) or colormap instance.
|
|
124
|
+
n (int): Number of colors to sample.
|
|
125
|
+
start (float): Start of sampling range [0, 1].
|
|
126
|
+
stop (float): End of sampling range [0, 1].
|
|
127
|
+
name (str): Optional name for the palette.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Palette: A new Palette with sampled colors.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> palette = Palette.from_cmap('viridis', n=5)
|
|
134
|
+
>>> palette = Palette.from_cmap('batlow', n=8) # scientific cmap
|
|
135
|
+
"""
|
|
136
|
+
# Get the colormap (Colormap handles str -> cmap conversion)
|
|
137
|
+
if isinstance(cmap, str):
|
|
138
|
+
cmap_obj = Colormap(cmap)
|
|
139
|
+
cmap_name = cmap
|
|
140
|
+
else:
|
|
141
|
+
cmap_obj = cmap
|
|
142
|
+
cmap_name = getattr(cmap, "name", "custom")
|
|
143
|
+
|
|
144
|
+
# Sample colors
|
|
145
|
+
t = np.linspace(start, stop, n)
|
|
146
|
+
sampled_colors = {}
|
|
147
|
+
for i, val in enumerate(t):
|
|
148
|
+
rgba = cmap_obj(val)
|
|
149
|
+
hex_color = mcolors.to_hex(rgba)
|
|
150
|
+
sampled_colors[f"color_{i + 1}"] = hex_color
|
|
151
|
+
|
|
152
|
+
palette_name = name or f"{cmap_name}_{n}"
|
|
153
|
+
return cls(sampled_colors, name=palette_name)
|
|
154
|
+
|
|
155
|
+
def add_color(self, name, rgb=None, hex_code=None):
|
|
156
|
+
"""
|
|
157
|
+
Add a color to the palette.
|
|
158
|
+
|
|
159
|
+
Parameters:
|
|
160
|
+
name (str): Name of the color.
|
|
161
|
+
rgb (tuple): RGB tuple (R, G, B) with values from 0 to 255.
|
|
162
|
+
hex_code (str): HEX color code as a string (e.g., '#FF5733').
|
|
163
|
+
"""
|
|
164
|
+
if rgb is not None:
|
|
165
|
+
if max(rgb) > 1:
|
|
166
|
+
rgb = tuple(value / 255 for value in rgb)
|
|
167
|
+
hex_color = rgb2hex(rgb)
|
|
168
|
+
elif hex_code is not None:
|
|
169
|
+
hex_color = hex_code
|
|
170
|
+
rgb = tuple(int(hex_code[i : i + 2], 16) for i in (1, 3, 5))
|
|
171
|
+
else:
|
|
172
|
+
raise ValueError("Either rgb or hex_code must be provided.")
|
|
173
|
+
|
|
174
|
+
self.colors[name] = {"rgb": rgb, "hex": hex_color}
|
|
175
|
+
|
|
176
|
+
def get_color(self, name):
|
|
177
|
+
"""
|
|
178
|
+
Retrieve a color from the palette by name.
|
|
179
|
+
|
|
180
|
+
Parameters:
|
|
181
|
+
name (str): Name of the color.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
dict: A dictionary with RGB and HEX values of the color.
|
|
185
|
+
"""
|
|
186
|
+
return self.colors.get(name, None)
|
|
187
|
+
|
|
188
|
+
def get_hex_colors(self):
|
|
189
|
+
"""
|
|
190
|
+
Get a list of all colors in HEX format.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
list: List of HEX color codes as strings.
|
|
194
|
+
"""
|
|
195
|
+
return [color["hex"] for color in self.colors.values()]
|
|
196
|
+
|
|
197
|
+
def get_rgb_colors(self):
|
|
198
|
+
"""
|
|
199
|
+
Get a list of all colors in RGB format.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
list: List of RGB color tuples.
|
|
203
|
+
"""
|
|
204
|
+
return [color["rgb"] for color in self.colors.values()]
|
|
205
|
+
|
|
206
|
+
def to_json(self, file_path=None):
|
|
207
|
+
"""
|
|
208
|
+
Export the palette as JSON.
|
|
209
|
+
|
|
210
|
+
Parameters:
|
|
211
|
+
file_path (str): Optional path to save the JSON file.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
str: JSON string if file_path is None, otherwise saves JSON to file.
|
|
215
|
+
"""
|
|
216
|
+
if file_path:
|
|
217
|
+
with open(file_path, "w") as f:
|
|
218
|
+
json.dump(self.colors, f, indent=4)
|
|
219
|
+
return json.dumps(self.colors, indent=4)
|
|
220
|
+
|
|
221
|
+
def to_yaml(self, file_path=None):
|
|
222
|
+
"""
|
|
223
|
+
Export the palette as YAML.
|
|
224
|
+
|
|
225
|
+
Parameters:
|
|
226
|
+
file_path (str): Optional path to save the YAML file.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
str: YAML string if file_path is None, otherwise saves YAML to file.
|
|
230
|
+
"""
|
|
231
|
+
if file_path:
|
|
232
|
+
with open(file_path, "w") as f:
|
|
233
|
+
yaml.dump(self.colors, f)
|
|
234
|
+
return yaml.dump(self.colors)
|
|
235
|
+
|
|
236
|
+
def to_css(self, file_path=None):
|
|
237
|
+
"""
|
|
238
|
+
Export the palette as a CSS custom properties file.
|
|
239
|
+
|
|
240
|
+
Parameters:
|
|
241
|
+
file_path (str): Optional path to save the CSS file.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
str: CSS string if file_path is None, otherwise saves CSS to file.
|
|
245
|
+
"""
|
|
246
|
+
css = ":root {\n"
|
|
247
|
+
for name, color in self.colors.items():
|
|
248
|
+
css += f" --{name.lower().replace(' ', '-')}: {color['hex']};\n"
|
|
249
|
+
css += "}"
|
|
250
|
+
|
|
251
|
+
if file_path:
|
|
252
|
+
with open(file_path, "w") as f:
|
|
253
|
+
f.write(css)
|
|
254
|
+
return css
|
|
255
|
+
|
|
256
|
+
def plot(self):
|
|
257
|
+
"""
|
|
258
|
+
Display a bar plot of the colors in the palette.
|
|
259
|
+
"""
|
|
260
|
+
hex_colors = self.get_hex_colors()
|
|
261
|
+
color_names = list(self.colors.keys())
|
|
262
|
+
|
|
263
|
+
fig, ax = plt.subplots(figsize=(len(hex_colors), 1))
|
|
264
|
+
ax.imshow([hex_colors], aspect="auto", extent=[0, len(hex_colors), 0, 1])
|
|
265
|
+
ax.set_yticks([])
|
|
266
|
+
|
|
267
|
+
# Add color labels below each color bar
|
|
268
|
+
ax.set_xticks(range(len(hex_colors)))
|
|
269
|
+
ax.set_xticklabels(color_names, rotation=45, ha="right")
|
|
270
|
+
|
|
271
|
+
plt.tight_layout()
|
|
272
|
+
plt.show()
|
|
273
|
+
|
|
274
|
+
def update_rc_params(self):
|
|
275
|
+
"""
|
|
276
|
+
Update matplotlib's rcParams using the palette colors.
|
|
277
|
+
Sets the color cycle and other relevant properties.
|
|
278
|
+
"""
|
|
279
|
+
hex_colors = self.get_hex_colors()
|
|
280
|
+
|
|
281
|
+
# Update color cycle and other rcParams with available colors
|
|
282
|
+
mpl.rcParams["axes.prop_cycle"] = mpl.cycler("color", hex_colors)
|
|
283
|
+
|
|
284
|
+
def create_sequential_colormaps(self, base_color="#FFFFFF"):
|
|
285
|
+
"""
|
|
286
|
+
Create sequential colormaps from white to each color in the palette.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
dict: A dictionary where keys are color names and values are
|
|
290
|
+
LinearSegmentedColormap instances transitioning from white
|
|
291
|
+
to the respective color.
|
|
292
|
+
"""
|
|
293
|
+
colormaps = {}
|
|
294
|
+
|
|
295
|
+
for name, color in self.colors.items():
|
|
296
|
+
# Create a colormap that transitions from white to the color
|
|
297
|
+
cmap = mcolors.LinearSegmentedColormap.from_list(
|
|
298
|
+
f"{name}_sequential", [base_color, color["hex"]]
|
|
299
|
+
)
|
|
300
|
+
colormaps[name] = cmap
|
|
301
|
+
|
|
302
|
+
return colormaps
|
|
303
|
+
|
|
304
|
+
def create_colormap(self, type="linear"):
|
|
305
|
+
"""
|
|
306
|
+
Create a colormap from the palette colors.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
LinearSegmentedColormap: A colormap transitioning between the colors in the palette.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
# Create a colormap that transitions between the colors in the palette
|
|
313
|
+
colors = [color["hex"] for color in self.colors.values()]
|
|
314
|
+
if type == "linear":
|
|
315
|
+
cmap = mcolors.LinearSegmentedColormap.from_list("custom", colors)
|
|
316
|
+
if type == "listed":
|
|
317
|
+
cmap = mcolors.ListedColormap(colors)
|
|
318
|
+
cmap.name = self.name
|
|
319
|
+
return cmap
|
|
320
|
+
|
|
321
|
+
def get_cmap(self, type="linear"):
|
|
322
|
+
return self.create_colormap(type=type)
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def hex(self):
|
|
326
|
+
"""Get HEX colors as a property."""
|
|
327
|
+
return self.get_hex_colors()
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def rgb(self):
|
|
331
|
+
"""Get RGB colors as a property."""
|
|
332
|
+
return self.get_rgb_colors()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Register built-in palettes
|
|
336
|
+
|
|
337
|
+
# Wong (2011) colorblind-safe palette - Nature Methods
|
|
338
|
+
Palette.register(
|
|
339
|
+
"colorblind",
|
|
340
|
+
Palette(
|
|
341
|
+
{
|
|
342
|
+
"Black": (0, 0, 0),
|
|
343
|
+
"Orange": (230, 159, 0),
|
|
344
|
+
"Sky Blue": (86, 180, 233),
|
|
345
|
+
"Bluish Green": (0, 158, 115),
|
|
346
|
+
"Yellow": (240, 228, 66),
|
|
347
|
+
"Blue": (0, 114, 178),
|
|
348
|
+
"Vermillion": (213, 94, 0),
|
|
349
|
+
"Reddish Purple": (204, 121, 167),
|
|
350
|
+
},
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# NPG (Nature Publishing Group) from ggsci
|
|
355
|
+
Palette.register(
|
|
356
|
+
"npg",
|
|
357
|
+
Palette(
|
|
358
|
+
{
|
|
359
|
+
"Red": "#E64B35",
|
|
360
|
+
"Blue": "#4DBBD5",
|
|
361
|
+
"Green": "#00A087",
|
|
362
|
+
"Dark Blue": "#3C5488",
|
|
363
|
+
"Peach": "#F39B7F",
|
|
364
|
+
"Lavender": "#8491B4",
|
|
365
|
+
"Teal": "#91D1C2",
|
|
366
|
+
"Crimson": "#DC0000",
|
|
367
|
+
"Brown": "#7E6148",
|
|
368
|
+
},
|
|
369
|
+
),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Lancet from ggsci
|
|
373
|
+
Palette.register(
|
|
374
|
+
"lancet",
|
|
375
|
+
Palette(
|
|
376
|
+
{
|
|
377
|
+
"Dark Blue": "#00468B",
|
|
378
|
+
"Light Blue": "#42B540",
|
|
379
|
+
"Orange": "#ED1C24",
|
|
380
|
+
"Cyan": "#0099B4",
|
|
381
|
+
"Brown": "#925E9F",
|
|
382
|
+
"Red": "#FDAF91",
|
|
383
|
+
"Gray": "#AD002A",
|
|
384
|
+
"Teal": "#ADB6B6",
|
|
385
|
+
"Black": "#1B1919",
|
|
386
|
+
},
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# JAMA from ggsci
|
|
391
|
+
Palette.register(
|
|
392
|
+
"jama",
|
|
393
|
+
Palette(
|
|
394
|
+
{
|
|
395
|
+
"Dark Blue": "#374E55",
|
|
396
|
+
"Red": "#DF8F44",
|
|
397
|
+
"Teal": "#00A1D5",
|
|
398
|
+
"Brown": "#B24745",
|
|
399
|
+
"Gray": "#79AF97",
|
|
400
|
+
"Light": "#6A6599",
|
|
401
|
+
"Coral": "#80796B",
|
|
402
|
+
},
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Okabe-Ito colorblind-safe (alternative name)
|
|
407
|
+
Palette.register(
|
|
408
|
+
"okabe_ito",
|
|
409
|
+
Palette(
|
|
410
|
+
{
|
|
411
|
+
"Orange": "#E69F00",
|
|
412
|
+
"Sky Blue": "#56B4E9",
|
|
413
|
+
"Bluish Green": "#009E73",
|
|
414
|
+
"Yellow": "#F0E442",
|
|
415
|
+
"Blue": "#0072B2",
|
|
416
|
+
"Vermillion": "#D55E00",
|
|
417
|
+
"Reddish Purple": "#CC79A7",
|
|
418
|
+
"Black": "#000000",
|
|
419
|
+
},
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Paul Tol's qualitative palette (muted)
|
|
424
|
+
Palette.register(
|
|
425
|
+
"tol_muted",
|
|
426
|
+
Palette(
|
|
427
|
+
{
|
|
428
|
+
"Indigo": "#332288",
|
|
429
|
+
"Cyan": "#88CCEE",
|
|
430
|
+
"Teal": "#44AA99",
|
|
431
|
+
"Green": "#117733",
|
|
432
|
+
"Olive": "#999933",
|
|
433
|
+
"Sand": "#DDCC77",
|
|
434
|
+
"Rose": "#CC6677",
|
|
435
|
+
"Wine": "#882255",
|
|
436
|
+
"Purple": "#AA4499",
|
|
437
|
+
},
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# Paul Tol's bright palette
|
|
442
|
+
Palette.register(
|
|
443
|
+
"tol_bright",
|
|
444
|
+
Palette(
|
|
445
|
+
{
|
|
446
|
+
"Blue": "#4477AA",
|
|
447
|
+
"Cyan": "#66CCEE",
|
|
448
|
+
"Green": "#228833",
|
|
449
|
+
"Yellow": "#CCBB44",
|
|
450
|
+
"Red": "#EE6677",
|
|
451
|
+
"Purple": "#AA3377",
|
|
452
|
+
"Grey": "#BBBBBB",
|
|
453
|
+
},
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Paul Tol's vibrant palette
|
|
458
|
+
Palette.register(
|
|
459
|
+
"tol_vibrant",
|
|
460
|
+
Palette(
|
|
461
|
+
{
|
|
462
|
+
"Orange": "#EE7733",
|
|
463
|
+
"Blue": "#0077BB",
|
|
464
|
+
"Cyan": "#33BBEE",
|
|
465
|
+
"Magenta": "#EE3377",
|
|
466
|
+
"Red": "#CC3311",
|
|
467
|
+
"Teal": "#009988",
|
|
468
|
+
"Grey": "#BBBBBB",
|
|
469
|
+
},
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Tableau 10
|
|
474
|
+
Palette.register(
|
|
475
|
+
"tableau10",
|
|
476
|
+
Palette(
|
|
477
|
+
{
|
|
478
|
+
"Blue": "#4E79A7",
|
|
479
|
+
"Orange": "#F28E2B",
|
|
480
|
+
"Red": "#E15759",
|
|
481
|
+
"Teal": "#76B7B2",
|
|
482
|
+
"Green": "#59A14F",
|
|
483
|
+
"Yellow": "#EDC948",
|
|
484
|
+
"Purple": "#B07AA1",
|
|
485
|
+
"Pink": "#FF9DA7",
|
|
486
|
+
"Brown": "#9C755F",
|
|
487
|
+
"Gray": "#BAB0AC",
|
|
488
|
+
},
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Seaborn deep
|
|
493
|
+
Palette.register(
|
|
494
|
+
"seaborn_deep",
|
|
495
|
+
Palette(
|
|
496
|
+
{
|
|
497
|
+
"Blue": "#4C72B0",
|
|
498
|
+
"Orange": "#DD8452",
|
|
499
|
+
"Green": "#55A868",
|
|
500
|
+
"Red": "#C44E52",
|
|
501
|
+
"Purple": "#8172B3",
|
|
502
|
+
"Brown": "#937860",
|
|
503
|
+
"Pink": "#DA8BC3",
|
|
504
|
+
"Gray": "#8C8C8C",
|
|
505
|
+
"Yellow": "#CCB974",
|
|
506
|
+
"Cyan": "#64B5CD",
|
|
507
|
+
},
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Seaborn colorblind
|
|
512
|
+
Palette.register(
|
|
513
|
+
"seaborn_colorblind",
|
|
514
|
+
Palette(
|
|
515
|
+
{
|
|
516
|
+
"Blue": "#0173B2",
|
|
517
|
+
"Orange": "#DE8F05",
|
|
518
|
+
"Green": "#029E73",
|
|
519
|
+
"Red": "#D55E00",
|
|
520
|
+
"Purple": "#CC78BC",
|
|
521
|
+
"Brown": "#CA9161",
|
|
522
|
+
"Pink": "#FBAFE4",
|
|
523
|
+
"Gray": "#949494",
|
|
524
|
+
"Yellow": "#ECE133",
|
|
525
|
+
"Cyan": "#56B4E9",
|
|
526
|
+
},
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Set1 from ColorBrewer
|
|
531
|
+
Palette.register(
|
|
532
|
+
"set1",
|
|
533
|
+
Palette(
|
|
534
|
+
{
|
|
535
|
+
"Red": "#E41A1C",
|
|
536
|
+
"Blue": "#377EB8",
|
|
537
|
+
"Green": "#4DAF4A",
|
|
538
|
+
"Purple": "#984EA3",
|
|
539
|
+
"Orange": "#FF7F00",
|
|
540
|
+
"Yellow": "#FFFF33",
|
|
541
|
+
"Brown": "#A65628",
|
|
542
|
+
"Pink": "#F781BF",
|
|
543
|
+
"Gray": "#999999",
|
|
544
|
+
},
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Set2 from ColorBrewer
|
|
549
|
+
Palette.register(
|
|
550
|
+
"set2",
|
|
551
|
+
Palette(
|
|
552
|
+
{
|
|
553
|
+
"Teal": "#66C2A5",
|
|
554
|
+
"Orange": "#FC8D62",
|
|
555
|
+
"Purple": "#8DA0CB",
|
|
556
|
+
"Pink": "#E78AC3",
|
|
557
|
+
"Green": "#A6D854",
|
|
558
|
+
"Yellow": "#FFD92F",
|
|
559
|
+
"Brown": "#E5C494",
|
|
560
|
+
"Gray": "#B3B3B3",
|
|
561
|
+
},
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Dark2 from ColorBrewer
|
|
566
|
+
Palette.register(
|
|
567
|
+
"dark2",
|
|
568
|
+
Palette(
|
|
569
|
+
{
|
|
570
|
+
"Teal": "#1B9E77",
|
|
571
|
+
"Orange": "#D95F02",
|
|
572
|
+
"Purple": "#7570B3",
|
|
573
|
+
"Pink": "#E7298A",
|
|
574
|
+
"Green": "#66A61E",
|
|
575
|
+
"Yellow": "#E6AB02",
|
|
576
|
+
"Brown": "#A6761D",
|
|
577
|
+
"Gray": "#666666",
|
|
578
|
+
},
|
|
579
|
+
),
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Paired from ColorBrewer (good for paired comparisons)
|
|
583
|
+
Palette.register(
|
|
584
|
+
"paired",
|
|
585
|
+
Palette(
|
|
586
|
+
{
|
|
587
|
+
"Light Blue": "#A6CEE3",
|
|
588
|
+
"Blue": "#1F78B4",
|
|
589
|
+
"Light Green": "#B2DF8A",
|
|
590
|
+
"Green": "#33A02C",
|
|
591
|
+
"Light Pink": "#FB9A99",
|
|
592
|
+
"Red": "#E31A1C",
|
|
593
|
+
"Light Orange": "#FDBF6F",
|
|
594
|
+
"Orange": "#FF7F00",
|
|
595
|
+
"Light Purple": "#CAB2D6",
|
|
596
|
+
"Purple": "#6A3D9A",
|
|
597
|
+
},
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Register aliases for backward compatibility
|
|
602
|
+
Palette._registry["ggsci"] = Palette._registry["npg"] # ggsci is an alias for npg
|
|
603
|
+
|
|
604
|
+
# Backward compatibility aliases
|
|
605
|
+
colorblind_palette = Palette.load("colorblind")
|
|
606
|
+
ggsci_palette = Palette.load("ggsci")
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class _ColormapMixin:
|
|
610
|
+
"""
|
|
611
|
+
Mixin providing additional methods for colormaps.
|
|
612
|
+
|
|
613
|
+
This mixin adds intuitive methods for sampling, reversing, and converting
|
|
614
|
+
colormaps that work with both continuous and discrete colormaps.
|
|
615
|
+
"""
|
|
616
|
+
|
|
617
|
+
def sample(self, n=5, start=0.0, stop=1.0, as_hex=False):
|
|
618
|
+
"""
|
|
619
|
+
Sample n colors from this colormap.
|
|
620
|
+
|
|
621
|
+
Parameters:
|
|
622
|
+
n (int): Number of colors to sample.
|
|
623
|
+
start (float): Start of sampling range [0, 1].
|
|
624
|
+
stop (float): End of sampling range [0, 1].
|
|
625
|
+
as_hex (bool): If True, return hex strings instead of RGBA tuples.
|
|
626
|
+
|
|
627
|
+
Returns:
|
|
628
|
+
list: List of colors (RGBA tuples or hex strings).
|
|
629
|
+
|
|
630
|
+
Example:
|
|
631
|
+
>>> cmap = Colormap("viridis")
|
|
632
|
+
>>> cmap.sample(5, as_hex=True)
|
|
633
|
+
['#440154', '#3b528b', '#21918c', '#5ec962', '#fde725']
|
|
634
|
+
"""
|
|
635
|
+
t = np.linspace(start, stop, n)
|
|
636
|
+
colors = [self(v) for v in t]
|
|
637
|
+
if as_hex:
|
|
638
|
+
colors = [mcolors.to_hex(c) for c in colors]
|
|
639
|
+
return colors
|
|
640
|
+
|
|
641
|
+
def to_palette(self, n=8, start=0.0, stop=1.0, name=None):
|
|
642
|
+
"""
|
|
643
|
+
Convert this colormap to a Palette by sampling colors.
|
|
644
|
+
|
|
645
|
+
Parameters:
|
|
646
|
+
n (int): Number of colors to sample.
|
|
647
|
+
start (float): Start of sampling range [0, 1].
|
|
648
|
+
stop (float): End of sampling range [0, 1].
|
|
649
|
+
name (str): Optional name for the palette.
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Palette: A new Palette instance with sampled colors.
|
|
653
|
+
|
|
654
|
+
Example:
|
|
655
|
+
>>> cmap = Colormap("batlow")
|
|
656
|
+
>>> palette = cmap.to_palette(n=6)
|
|
657
|
+
"""
|
|
658
|
+
hex_colors = self.sample(n=n, start=start, stop=stop, as_hex=True)
|
|
659
|
+
palette_name = name or f"{self.name}_{n}"
|
|
660
|
+
return Palette(hex_colors, name=palette_name)
|
|
661
|
+
|
|
662
|
+
def truncate(self, start=0.0, stop=1.0, N=256, name=None):
|
|
663
|
+
"""
|
|
664
|
+
Create a new colormap from a subset of this colormap's range.
|
|
665
|
+
|
|
666
|
+
Parameters:
|
|
667
|
+
start (float): Start of the range [0, 1].
|
|
668
|
+
stop (float): End of the range [0, 1].
|
|
669
|
+
N (int): Number of quantization levels.
|
|
670
|
+
name (str): Optional name for the new colormap.
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
ContinuousColormap: A new colormap covering the specified range.
|
|
674
|
+
|
|
675
|
+
Example:
|
|
676
|
+
>>> cmap = Colormap("viridis")
|
|
677
|
+
>>> truncated = cmap.truncate(0.2, 0.8)
|
|
678
|
+
"""
|
|
679
|
+
colors = self.sample(n=N, start=start, stop=stop)
|
|
680
|
+
cmap_name = name or f"{self.name}_truncated"
|
|
681
|
+
return ContinuousColormap.from_list(cmap_name, colors, N=N)
|
|
682
|
+
|
|
683
|
+
# ------------------------------------------------------------------
|
|
684
|
+
# Color-vision analysis helpers
|
|
685
|
+
# ------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
# Vienot (1999) / Brettel (1997) colorblind simulation matrices.
|
|
688
|
+
# Applied in linear RGB space (after sRGB gamma removal).
|
|
689
|
+
_CVD_MATRICES = {
|
|
690
|
+
"protanopia": np.array([
|
|
691
|
+
[0.152286, 1.052583, -0.204868],
|
|
692
|
+
[0.114503, 0.786281, 0.099216],
|
|
693
|
+
[-0.003882, -0.048116, 1.051998],
|
|
694
|
+
]),
|
|
695
|
+
"deuteranopia": np.array([
|
|
696
|
+
[0.367322, 0.860646, -0.227968],
|
|
697
|
+
[0.280085, 0.672501, 0.047414],
|
|
698
|
+
[-0.011820, 0.042940, 0.968881],
|
|
699
|
+
]),
|
|
700
|
+
"tritanopia": np.array([
|
|
701
|
+
[1.255528, -0.076749, -0.178779],
|
|
702
|
+
[-0.078411, 0.930809, 0.147602],
|
|
703
|
+
[0.004733, 0.691367, 0.303900],
|
|
704
|
+
]),
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
@staticmethod
|
|
708
|
+
def _srgb_to_linear(c):
|
|
709
|
+
"""Convert sRGB [0-1] to linear RGB."""
|
|
710
|
+
c = np.asarray(c, dtype=float)
|
|
711
|
+
return np.where(c <= 0.04045, c / 12.92,
|
|
712
|
+
((c + 0.055) / 1.055) ** 2.4)
|
|
713
|
+
|
|
714
|
+
@staticmethod
|
|
715
|
+
def _linear_to_srgb(c):
|
|
716
|
+
"""Convert linear RGB to sRGB [0-1]."""
|
|
717
|
+
c = np.asarray(c, dtype=float)
|
|
718
|
+
return np.where(c <= 0.0031308, 12.92 * c,
|
|
719
|
+
1.055 * np.power(np.clip(c, 0, None), 1.0 / 2.4) - 0.055)
|
|
720
|
+
|
|
721
|
+
def _sample_rgba(self, N=256):
|
|
722
|
+
"""Sample the colormap into an (N, 4) RGBA array."""
|
|
723
|
+
t = np.linspace(0, 1, N)
|
|
724
|
+
return np.array([self(v) for v in t])
|
|
725
|
+
|
|
726
|
+
def to_grayscale(self, N=256, name=None):
|
|
727
|
+
"""
|
|
728
|
+
Create a grayscale version of this colormap using perceptual luminance.
|
|
729
|
+
|
|
730
|
+
Uses the Rec. 709 luminance weights (0.2126 R + 0.7152 G + 0.0722 B)
|
|
731
|
+
applied in linear RGB space for accurate perceptual conversion.
|
|
732
|
+
|
|
733
|
+
Parameters:
|
|
734
|
+
N (int): Number of quantization levels.
|
|
735
|
+
name (str): Name for the new colormap.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
ContinuousColormap: A grayscale version of this colormap.
|
|
739
|
+
|
|
740
|
+
Example:
|
|
741
|
+
>>> cmap = Colormap("viridis")
|
|
742
|
+
>>> gray = cmap.to_grayscale()
|
|
743
|
+
"""
|
|
744
|
+
rgba = self._sample_rgba(N)
|
|
745
|
+
rgb_linear = self._srgb_to_linear(rgba[:, :3])
|
|
746
|
+
lum = 0.2126 * rgb_linear[:, 0] + 0.7152 * rgb_linear[:, 1] + 0.0722 * rgb_linear[:, 2]
|
|
747
|
+
lum_srgb = self._linear_to_srgb(lum)
|
|
748
|
+
gray_colors = np.column_stack([lum_srgb, lum_srgb, lum_srgb,
|
|
749
|
+
rgba[:, 3]])
|
|
750
|
+
cmap_name = name or f"{self.name}_grayscale"
|
|
751
|
+
return ContinuousColormap.from_list(cmap_name, gray_colors.tolist(), N=N)
|
|
752
|
+
|
|
753
|
+
def colorblind(self, cvd_type="deuteranopia", N=256, name=None):
|
|
754
|
+
"""
|
|
755
|
+
Simulate how this colormap appears under color vision deficiency.
|
|
756
|
+
|
|
757
|
+
Parameters:
|
|
758
|
+
cvd_type (str): Type of color vision deficiency. One of
|
|
759
|
+
'protanopia', 'deuteranopia', or 'tritanopia'.
|
|
760
|
+
N (int): Number of quantization levels.
|
|
761
|
+
name (str): Name for the new colormap.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
ContinuousColormap: A colormap simulating the specified CVD.
|
|
765
|
+
|
|
766
|
+
Example:
|
|
767
|
+
>>> cmap = Colormap("viridis")
|
|
768
|
+
>>> deut = cmap.colorblind("deuteranopia")
|
|
769
|
+
"""
|
|
770
|
+
cvd_type = cvd_type.lower()
|
|
771
|
+
if cvd_type not in self._CVD_MATRICES:
|
|
772
|
+
raise ValueError(
|
|
773
|
+
f"Unknown CVD type '{cvd_type}'. "
|
|
774
|
+
f"Choose from: {', '.join(self._CVD_MATRICES.keys())}"
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
mat = self._CVD_MATRICES[cvd_type]
|
|
778
|
+
rgba = self._sample_rgba(N)
|
|
779
|
+
|
|
780
|
+
# Convert sRGB → linear → apply CVD matrix → linear → sRGB
|
|
781
|
+
rgb_linear = self._srgb_to_linear(rgba[:, :3])
|
|
782
|
+
rgb_cvd = rgb_linear @ mat.T
|
|
783
|
+
rgb_cvd = np.clip(rgb_cvd, 0, 1)
|
|
784
|
+
rgb_srgb = self._linear_to_srgb(rgb_cvd)
|
|
785
|
+
rgb_srgb = np.clip(rgb_srgb, 0, 1)
|
|
786
|
+
|
|
787
|
+
cvd_colors = np.column_stack([rgb_srgb, rgba[:, 3]])
|
|
788
|
+
cmap_name = name or f"{self.name}_{cvd_type}"
|
|
789
|
+
return ContinuousColormap.from_list(cmap_name, cvd_colors.tolist(), N=N)
|
|
790
|
+
|
|
791
|
+
def lightness(self, N=256):
|
|
792
|
+
"""
|
|
793
|
+
Compute the CIELAB L* lightness profile across the colormap.
|
|
794
|
+
|
|
795
|
+
Parameters:
|
|
796
|
+
N (int): Number of sample points.
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
numpy.ndarray: Array of L* values (0–100) of shape (N,).
|
|
800
|
+
|
|
801
|
+
Example:
|
|
802
|
+
>>> cmap = Colormap("viridis")
|
|
803
|
+
>>> L = cmap.lightness()
|
|
804
|
+
"""
|
|
805
|
+
rgba = self._sample_rgba(N)
|
|
806
|
+
rgb_linear = self._srgb_to_linear(rgba[:, :3])
|
|
807
|
+
|
|
808
|
+
# sRGB D65 whitepoint → XYZ conversion (Rec. 709 primaries)
|
|
809
|
+
M_rgb_to_xyz = np.array([
|
|
810
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
811
|
+
[0.2126729, 0.7151522, 0.0721750],
|
|
812
|
+
[0.0193339, 0.1191920, 0.9503041],
|
|
813
|
+
])
|
|
814
|
+
xyz = rgb_linear @ M_rgb_to_xyz.T
|
|
815
|
+
|
|
816
|
+
# D65 white point
|
|
817
|
+
Yn = 1.0
|
|
818
|
+
y_ratio = xyz[:, 1] / Yn
|
|
819
|
+
|
|
820
|
+
# CIE L* formula
|
|
821
|
+
delta = 6.0 / 29.0
|
|
822
|
+
f_y = np.where(y_ratio > delta ** 3,
|
|
823
|
+
np.cbrt(y_ratio),
|
|
824
|
+
y_ratio / (3.0 * delta ** 2) + 4.0 / 29.0)
|
|
825
|
+
L_star = 116.0 * f_y - 16.0
|
|
826
|
+
return L_star
|
|
827
|
+
|
|
828
|
+
def plot_analysis(self, N=256, figsize=None):
|
|
829
|
+
"""
|
|
830
|
+
Plot a comprehensive colormap analysis panel.
|
|
831
|
+
|
|
832
|
+
Shows the original colormap, its grayscale conversion, lightness
|
|
833
|
+
profile, and three color-vision deficiency simulations (protanopia,
|
|
834
|
+
deuteranopia, tritanopia).
|
|
835
|
+
|
|
836
|
+
Parameters:
|
|
837
|
+
N (int): Number of sample points.
|
|
838
|
+
figsize (tuple): Figure size (width, height). Default auto.
|
|
839
|
+
|
|
840
|
+
Returns:
|
|
841
|
+
matplotlib.figure.Figure: The analysis figure.
|
|
842
|
+
|
|
843
|
+
Example:
|
|
844
|
+
>>> cmap = Colormap("viridis")
|
|
845
|
+
>>> fig = cmap.plot_analysis()
|
|
846
|
+
"""
|
|
847
|
+
gradient = np.linspace(0, 1, N).reshape(1, -1)
|
|
848
|
+
|
|
849
|
+
rows = [
|
|
850
|
+
("Original", self),
|
|
851
|
+
("Grayscale", self.to_grayscale(N)),
|
|
852
|
+
("Protanopia", self.colorblind("protanopia", N)),
|
|
853
|
+
("Deuteranopia", self.colorblind("deuteranopia", N)),
|
|
854
|
+
("Tritanopia", self.colorblind("tritanopia", N)),
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
fig_w = figsize[0] if figsize else 10
|
|
858
|
+
fig_h = figsize[1] if figsize else (len(rows) * 0.6 + 2)
|
|
859
|
+
|
|
860
|
+
fig, axes = plt.subplots(len(rows) + 1, 1,
|
|
861
|
+
figsize=(fig_w, fig_h),
|
|
862
|
+
gridspec_kw={"height_ratios":
|
|
863
|
+
[1] * len(rows) + [2]})
|
|
864
|
+
|
|
865
|
+
for ax, (label, cm) in zip(axes[:-1], rows):
|
|
866
|
+
ax.imshow(gradient, aspect="auto", cmap=cm)
|
|
867
|
+
ax.set_yticks([])
|
|
868
|
+
ax.set_xticks([])
|
|
869
|
+
ax.set_ylabel(label, rotation=0, ha="right", va="center",
|
|
870
|
+
fontsize=9)
|
|
871
|
+
|
|
872
|
+
# Lightness plot
|
|
873
|
+
ax_l = axes[-1]
|
|
874
|
+
t = np.linspace(0, 1, N)
|
|
875
|
+
L = self.lightness(N)
|
|
876
|
+
L_gray = self.to_grayscale(N).lightness(N)
|
|
877
|
+
|
|
878
|
+
# Color the lightness line using the colormap
|
|
879
|
+
for i in range(N - 1):
|
|
880
|
+
ax_l.plot(t[i:i + 2], L[i:i + 2], color=self(t[i]), lw=2)
|
|
881
|
+
ax_l.plot(t, L_gray, color="0.5", lw=1, ls="--", label="grayscale")
|
|
882
|
+
ax_l.set_ylabel("L*", fontsize=9)
|
|
883
|
+
ax_l.set_xlabel("Colormap position", fontsize=9)
|
|
884
|
+
ax_l.set_xlim(0, 1)
|
|
885
|
+
ax_l.set_ylim(0, 100)
|
|
886
|
+
ax_l.legend(fontsize=8, loc="best")
|
|
887
|
+
|
|
888
|
+
fig.suptitle(f"Colormap analysis: {self.name}", fontsize=11)
|
|
889
|
+
fig.tight_layout()
|
|
890
|
+
return fig
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class ContinuousColormap(_ColormapMixin, mcolors.LinearSegmentedColormap):
|
|
894
|
+
"""
|
|
895
|
+
Extended LinearSegmentedColormap with additional convenience methods.
|
|
896
|
+
|
|
897
|
+
Inherits all functionality from matplotlib's LinearSegmentedColormap
|
|
898
|
+
and adds methods like sample(), to_palette(), and truncate().
|
|
899
|
+
"""
|
|
900
|
+
|
|
901
|
+
@classmethod
|
|
902
|
+
def from_list(cls, name, colors, N=256, gamma=1.0):
|
|
903
|
+
"""Create a ContinuousColormap from a list of colors."""
|
|
904
|
+
# Use parent's from_list but ensure we get our subclass back
|
|
905
|
+
parent_cmap = mcolors.LinearSegmentedColormap.from_list(name, colors, N, gamma)
|
|
906
|
+
# Create our subclass instance with the same data
|
|
907
|
+
cmap = cls(name, parent_cmap._segmentdata, N, gamma)
|
|
908
|
+
return cmap
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
class DiscreteColormap(_ColormapMixin, mcolors.ListedColormap):
|
|
912
|
+
"""
|
|
913
|
+
Extended ListedColormap with additional convenience methods.
|
|
914
|
+
|
|
915
|
+
Inherits all functionality from matplotlib's ListedColormap
|
|
916
|
+
and adds methods like sample(), to_palette(), and truncate().
|
|
917
|
+
"""
|
|
918
|
+
pass
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class Colormap:
|
|
922
|
+
"""
|
|
923
|
+
Factory for creating extended colormaps with additional methods.
|
|
924
|
+
|
|
925
|
+
Creates ContinuousColormap or DiscreteColormap instances that extend
|
|
926
|
+
matplotlib's colormaps with convenient methods like sample(), to_palette(),
|
|
927
|
+
and truncate().
|
|
928
|
+
|
|
929
|
+
Supports loading from:
|
|
930
|
+
- Matplotlib colormap names ("viridis", "plasma", etc.)
|
|
931
|
+
- Scientific colormap names ("batlow", "vik", etc.)
|
|
932
|
+
- cmap package colormaps ("cmasher:ember", "cmocean:deep", etc.)
|
|
933
|
+
- Lists of colors (hex strings, RGB tuples, or named colors)
|
|
934
|
+
- Palette instances
|
|
935
|
+
|
|
936
|
+
Usage:
|
|
937
|
+
cmap = Colormap("viridis") # Load matplotlib cmap
|
|
938
|
+
cmap = Colormap("batlow") # Load scientific cmap
|
|
939
|
+
cmap = Colormap("cmasher:ember") # Load from cmap package
|
|
940
|
+
cmap = Colormap(["#ff0000", "#00ff00", "#0000ff"]) # From colors
|
|
941
|
+
cmap = Colormap(my_palette) # From Palette instance
|
|
942
|
+
|
|
943
|
+
# Extended methods
|
|
944
|
+
colors = cmap.sample(5, as_hex=True)
|
|
945
|
+
palette = cmap.to_palette(n=8)
|
|
946
|
+
truncated = cmap.truncate(0.2, 0.8)
|
|
947
|
+
"""
|
|
948
|
+
|
|
949
|
+
CMAP_BASE_PATH = os.path.join(os.path.dirname(__file__), "scientific_color_maps")
|
|
950
|
+
CMAP_TYPES = ("sequential", "diverging", "multisequential", "cyclic", "qualitative")
|
|
951
|
+
|
|
952
|
+
def __new__(cls, source, name=None, N=256, discrete=False):
|
|
953
|
+
"""
|
|
954
|
+
Create an extended colormap from various sources.
|
|
955
|
+
|
|
956
|
+
Parameters:
|
|
957
|
+
source: Colormap name (str), list of colors, Palette, or existing colormap.
|
|
958
|
+
name (str): Optional name override.
|
|
959
|
+
N (int): Number of RGB quantization levels (for continuous colormaps).
|
|
960
|
+
discrete (bool): If True, create a DiscreteColormap instead of continuous.
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
ContinuousColormap or DiscreteColormap: An extended matplotlib colormap.
|
|
964
|
+
"""
|
|
965
|
+
# Already one of our extended colormaps - return as-is
|
|
966
|
+
if isinstance(source, (ContinuousColormap, DiscreteColormap)):
|
|
967
|
+
return source
|
|
968
|
+
|
|
969
|
+
# Wrap existing matplotlib colormap
|
|
970
|
+
if isinstance(source, mcolors.Colormap):
|
|
971
|
+
return cls._wrap_mpl_colormap(source, N)
|
|
972
|
+
|
|
973
|
+
# From Palette instance
|
|
974
|
+
if isinstance(source, Palette):
|
|
975
|
+
colors = source.get_hex_colors()
|
|
976
|
+
cmap_name = name or getattr(source, "name", "custom")
|
|
977
|
+
return cls._from_colors(colors, cmap_name, N, discrete)
|
|
978
|
+
|
|
979
|
+
# From list of colors
|
|
980
|
+
if isinstance(source, (list, tuple)) and not isinstance(source, str):
|
|
981
|
+
cmap_name = name or "custom"
|
|
982
|
+
return cls._from_colors(source, cmap_name, N, discrete)
|
|
983
|
+
|
|
984
|
+
# From string name - try loading
|
|
985
|
+
if isinstance(source, str):
|
|
986
|
+
cmap_name = name or source
|
|
987
|
+
return cls._load(source, cmap_name, N)
|
|
988
|
+
|
|
989
|
+
raise TypeError(f"Cannot create colormap from {type(source)}")
|
|
990
|
+
|
|
991
|
+
@classmethod
|
|
992
|
+
def _wrap_mpl_colormap(cls, cmap, N=256):
|
|
993
|
+
"""Wrap a matplotlib colormap in our extended class."""
|
|
994
|
+
if isinstance(cmap, mcolors.ListedColormap):
|
|
995
|
+
new_cmap = DiscreteColormap(cmap.colors, name=cmap.name)
|
|
996
|
+
return new_cmap
|
|
997
|
+
else:
|
|
998
|
+
# Sample and recreate as ContinuousColormap
|
|
999
|
+
colors = [cmap(i / (N - 1)) for i in range(N)]
|
|
1000
|
+
return ContinuousColormap.from_list(cmap.name, colors, N=N)
|
|
1001
|
+
|
|
1002
|
+
@classmethod
|
|
1003
|
+
def _from_colors(cls, colors, name, N, discrete):
|
|
1004
|
+
"""Create colormap from a list of colors."""
|
|
1005
|
+
normalized = [mcolors.to_rgba(c) for c in colors]
|
|
1006
|
+
if discrete:
|
|
1007
|
+
return DiscreteColormap(normalized, name=name)
|
|
1008
|
+
return ContinuousColormap.from_list(name, normalized, N=N)
|
|
1009
|
+
|
|
1010
|
+
@classmethod
|
|
1011
|
+
def _load(cls, name, cmap_name, N):
|
|
1012
|
+
"""Load colormap by name (matplotlib, scientific_color_maps, or cmap package)."""
|
|
1013
|
+
# Try matplotlib first
|
|
1014
|
+
try:
|
|
1015
|
+
mpl_cmap = plt.get_cmap(name)
|
|
1016
|
+
return cls._wrap_mpl_colormap(mpl_cmap, N)
|
|
1017
|
+
except ValueError:
|
|
1018
|
+
pass
|
|
1019
|
+
|
|
1020
|
+
# Try scientific colormaps
|
|
1021
|
+
file_path = cls._find_cmap_file(name)
|
|
1022
|
+
if file_path:
|
|
1023
|
+
colors = cls._load_colors_from_file(file_path)
|
|
1024
|
+
return ContinuousColormap.from_list(cmap_name, colors, N=N)
|
|
1025
|
+
|
|
1026
|
+
# Try cmap package
|
|
1027
|
+
if HAS_CMAP:
|
|
1028
|
+
try:
|
|
1029
|
+
cmap_obj = cmap_lib.Colormap(name)
|
|
1030
|
+
mpl_cmap = cmap_obj.to_mpl()
|
|
1031
|
+
return cls._wrap_mpl_colormap(mpl_cmap, N)
|
|
1032
|
+
except Exception:
|
|
1033
|
+
pass
|
|
1034
|
+
|
|
1035
|
+
# Not found
|
|
1036
|
+
available = cls.list_available()
|
|
1037
|
+
all_names = [n for names in available.values() for n in names]
|
|
1038
|
+
hint = ""
|
|
1039
|
+
if ":" in name and not HAS_CMAP:
|
|
1040
|
+
hint = (
|
|
1041
|
+
f" Namespaced colormaps like '{name}' require the 'cmap' "
|
|
1042
|
+
f"package (pip install cmap)."
|
|
1043
|
+
)
|
|
1044
|
+
raise ValueError(
|
|
1045
|
+
f"Colormap '{name}' not found.{hint} "
|
|
1046
|
+
f"Available: {', '.join(all_names[:10])}..."
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
@classmethod
|
|
1050
|
+
def _find_cmap_file(cls, name):
|
|
1051
|
+
"""Find the file path for a scientific colormap."""
|
|
1052
|
+
for cmap_type in cls.CMAP_TYPES:
|
|
1053
|
+
file_path = os.path.join(cls.CMAP_BASE_PATH, cmap_type, f"{name}.txt")
|
|
1054
|
+
if os.path.exists(file_path):
|
|
1055
|
+
return file_path
|
|
1056
|
+
return None
|
|
1057
|
+
|
|
1058
|
+
@classmethod
|
|
1059
|
+
def _load_colors_from_file(cls, file_path):
|
|
1060
|
+
"""Load RGB colors from a text file (values in 0-1 range)."""
|
|
1061
|
+
colors = []
|
|
1062
|
+
with open(file_path, "r") as f:
|
|
1063
|
+
for line in f:
|
|
1064
|
+
parts = line.strip().split()
|
|
1065
|
+
if len(parts) == 3:
|
|
1066
|
+
colors.append(tuple(float(v) for v in parts))
|
|
1067
|
+
return colors
|
|
1068
|
+
|
|
1069
|
+
@classmethod
|
|
1070
|
+
def list_available(cls, cmap_type=None, source='all'):
|
|
1071
|
+
"""
|
|
1072
|
+
List available colormaps by type and source.
|
|
1073
|
+
|
|
1074
|
+
Parameters:
|
|
1075
|
+
cmap_type (str): 'sequential', 'diverging', 'multisequential', 'cyclic',
|
|
1076
|
+
'qualitative', or None for all.
|
|
1077
|
+
source (str): 'all' (default), 'Crameri' (scientific folder),
|
|
1078
|
+
'matplotlib', or 'cmap' (cmap package catalog).
|
|
1079
|
+
|
|
1080
|
+
Returns:
|
|
1081
|
+
dict or list: Dictionary mapping cmap_type to list of colormap names, or list for a single type.
|
|
1082
|
+
"""
|
|
1083
|
+
mpl_colormaps = set(plt.colormaps())
|
|
1084
|
+
MPL_DEFAULTS = {
|
|
1085
|
+
'sequential': [
|
|
1086
|
+
'viridis', 'plasma', 'inferno', 'magma', 'cividis', 'Greys', 'Purples', 'Blues', 'Greens', 'Oranges', 'Reds',
|
|
1087
|
+
'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu', 'GnBu', 'PuBu', 'YlGnBu', 'PuBuGn', 'BuGn', 'YlGn'
|
|
1088
|
+
],
|
|
1089
|
+
'diverging': [
|
|
1090
|
+
'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGy', 'RdBu', 'RdYlBu', 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic'
|
|
1091
|
+
],
|
|
1092
|
+
'cyclic': [
|
|
1093
|
+
'twilight', 'twilight_shifted', 'hsv'
|
|
1094
|
+
],
|
|
1095
|
+
'multisequential': [],
|
|
1096
|
+
'qualitative': [],
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
# Mapping from bsplot categories to cmap package categories
|
|
1100
|
+
CMAP_CATEGORY_MAP = {
|
|
1101
|
+
'sequential': 'sequential',
|
|
1102
|
+
'diverging': 'diverging',
|
|
1103
|
+
'cyclic': 'cyclic',
|
|
1104
|
+
'qualitative': 'qualitative',
|
|
1105
|
+
'multisequential': None, # cmap package has no multisequential category
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
def get_crameri_names(cmaptype):
|
|
1109
|
+
type_path = os.path.join(cls.CMAP_BASE_PATH, cmaptype)
|
|
1110
|
+
if os.path.exists(type_path):
|
|
1111
|
+
names = [
|
|
1112
|
+
os.path.splitext(f)[0]
|
|
1113
|
+
for f in os.listdir(type_path)
|
|
1114
|
+
if f.endswith(".txt") and not re.search(r"(10|25|50|HEX)", f)
|
|
1115
|
+
]
|
|
1116
|
+
return sorted(names)
|
|
1117
|
+
return []
|
|
1118
|
+
|
|
1119
|
+
def get_mpl_names(cmaptype):
|
|
1120
|
+
return [n for n in MPL_DEFAULTS.get(cmaptype, []) if n in mpl_colormaps]
|
|
1121
|
+
|
|
1122
|
+
def get_cmap_names(cmaptype):
|
|
1123
|
+
"""Get colormap names from the cmap package catalog."""
|
|
1124
|
+
if not HAS_CMAP:
|
|
1125
|
+
return []
|
|
1126
|
+
cmap_category = CMAP_CATEGORY_MAP.get(cmaptype)
|
|
1127
|
+
if cmap_category is None:
|
|
1128
|
+
return []
|
|
1129
|
+
try:
|
|
1130
|
+
catalog = cmap_lib.Colormap.catalog()
|
|
1131
|
+
names = sorted(catalog.unique_keys(
|
|
1132
|
+
prefer_short_names=False,
|
|
1133
|
+
categories=(cmap_category,),
|
|
1134
|
+
))
|
|
1135
|
+
return names
|
|
1136
|
+
except Exception:
|
|
1137
|
+
return []
|
|
1138
|
+
|
|
1139
|
+
types_to_check = [cmap_type] if cmap_type else list(cls.CMAP_TYPES)
|
|
1140
|
+
|
|
1141
|
+
if source == 'all':
|
|
1142
|
+
if cmap_type is None:
|
|
1143
|
+
available = {}
|
|
1144
|
+
for cmaptype in types_to_check:
|
|
1145
|
+
available[cmaptype] = get_mpl_names(cmaptype) + get_crameri_names(cmaptype)
|
|
1146
|
+
return available
|
|
1147
|
+
else:
|
|
1148
|
+
return get_mpl_names(cmap_type) + get_crameri_names(cmap_type)
|
|
1149
|
+
elif source.lower() == 'crameri':
|
|
1150
|
+
if cmap_type is None:
|
|
1151
|
+
available = {}
|
|
1152
|
+
for cmaptype in types_to_check:
|
|
1153
|
+
available[cmaptype] = get_crameri_names(cmaptype)
|
|
1154
|
+
return available
|
|
1155
|
+
else:
|
|
1156
|
+
return get_crameri_names(cmap_type)
|
|
1157
|
+
elif source.lower() == 'matplotlib':
|
|
1158
|
+
if cmap_type is None:
|
|
1159
|
+
available = {}
|
|
1160
|
+
for cmaptype in types_to_check:
|
|
1161
|
+
available[cmaptype] = get_mpl_names(cmaptype)
|
|
1162
|
+
return available
|
|
1163
|
+
else:
|
|
1164
|
+
return get_mpl_names(cmap_type)
|
|
1165
|
+
elif source.lower() == 'cmap':
|
|
1166
|
+
if cmap_type is None:
|
|
1167
|
+
available = {}
|
|
1168
|
+
for cmaptype in types_to_check:
|
|
1169
|
+
available[cmaptype] = get_cmap_names(cmaptype)
|
|
1170
|
+
return available
|
|
1171
|
+
else:
|
|
1172
|
+
return get_cmap_names(cmap_type)
|
|
1173
|
+
else:
|
|
1174
|
+
raise ValueError(f"Unknown source '{source}'. Use 'all', 'Crameri', 'matplotlib', or 'cmap'.")
|
|
1175
|
+
|
|
1176
|
+
@classmethod
|
|
1177
|
+
def load(cls, name, N=256):
|
|
1178
|
+
"""
|
|
1179
|
+
Load a colormap by name.
|
|
1180
|
+
|
|
1181
|
+
Parameters:
|
|
1182
|
+
name (str): Colormap name (matplotlib or scientific).
|
|
1183
|
+
N (int): Number of quantization levels.
|
|
1184
|
+
|
|
1185
|
+
Returns:
|
|
1186
|
+
ContinuousColormap or DiscreteColormap
|
|
1187
|
+
"""
|
|
1188
|
+
return cls(name, N=N)
|
|
1189
|
+
|
|
1190
|
+
@classmethod
|
|
1191
|
+
def from_colors(cls, colors, name="custom", discrete=False, N=256):
|
|
1192
|
+
"""
|
|
1193
|
+
Create a colormap from a list of colors.
|
|
1194
|
+
|
|
1195
|
+
Parameters:
|
|
1196
|
+
colors (list): List of colors (hex strings, RGB tuples, or named colors).
|
|
1197
|
+
name (str): Name for the colormap.
|
|
1198
|
+
discrete (bool): If True, create a DiscreteColormap.
|
|
1199
|
+
N (int): Number of quantization levels (for continuous).
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
ContinuousColormap or DiscreteColormap
|
|
1203
|
+
"""
|
|
1204
|
+
return cls(colors, name=name, N=N, discrete=discrete)
|
|
1205
|
+
|
|
1206
|
+
@classmethod
|
|
1207
|
+
def from_palette(cls, palette, discrete=False, N=256):
|
|
1208
|
+
"""
|
|
1209
|
+
Create a colormap from a Palette instance.
|
|
1210
|
+
|
|
1211
|
+
Parameters:
|
|
1212
|
+
palette (Palette): A Palette instance.
|
|
1213
|
+
discrete (bool): If True, create a DiscreteColormap.
|
|
1214
|
+
N (int): Number of quantization levels (for continuous).
|
|
1215
|
+
|
|
1216
|
+
Returns:
|
|
1217
|
+
ContinuousColormap or DiscreteColormap
|
|
1218
|
+
"""
|
|
1219
|
+
return cls(palette, N=N, discrete=discrete)
|
|
1220
|
+
|
|
1221
|
+
@classmethod
|
|
1222
|
+
def concatenate(cls, cmaps, ratios=None, name="concatenated", N=256):
|
|
1223
|
+
"""
|
|
1224
|
+
Concatenate multiple colormaps into a single continuous colormap.
|
|
1225
|
+
|
|
1226
|
+
Parameters:
|
|
1227
|
+
cmaps (list): List of colormaps (names, Colormap instances, or matplotlib colormaps).
|
|
1228
|
+
ratios (list): Relative sizes of each segment. Defaults to equal sizes.
|
|
1229
|
+
E.g. [1, 2, 1] gives the middle cmap twice the space.
|
|
1230
|
+
name (str): Name for the resulting colormap.
|
|
1231
|
+
N (int): Number of quantization levels.
|
|
1232
|
+
|
|
1233
|
+
Returns:
|
|
1234
|
+
ContinuousColormap: A new colormap combining all inputs.
|
|
1235
|
+
|
|
1236
|
+
Example:
|
|
1237
|
+
>>> purple = Colormap("viridis").truncate(0.0, 0.2)
|
|
1238
|
+
>>> yellow = Colormap("viridis").truncate(0.8, 1.0)
|
|
1239
|
+
>>> combined = Colormap.concatenate([purple, yellow])
|
|
1240
|
+
"""
|
|
1241
|
+
if ratios is None:
|
|
1242
|
+
ratios = [1.0] * len(cmaps)
|
|
1243
|
+
ratios = np.array(ratios, dtype=float)
|
|
1244
|
+
ratios /= ratios.sum()
|
|
1245
|
+
|
|
1246
|
+
# Resolve all cmaps
|
|
1247
|
+
resolved = []
|
|
1248
|
+
for c in cmaps:
|
|
1249
|
+
if isinstance(c, str):
|
|
1250
|
+
resolved.append(cls(c))
|
|
1251
|
+
elif isinstance(c, mcolors.Colormap):
|
|
1252
|
+
resolved.append(c)
|
|
1253
|
+
else:
|
|
1254
|
+
raise TypeError(f"Cannot interpret {type(c)} as a colormap.")
|
|
1255
|
+
|
|
1256
|
+
# Sample colors from each cmap proportionally
|
|
1257
|
+
all_colors = []
|
|
1258
|
+
for cmap_obj, ratio in zip(resolved, ratios):
|
|
1259
|
+
n_samples = max(int(round(ratio * N)), 2)
|
|
1260
|
+
t = np.linspace(0, 1, n_samples)
|
|
1261
|
+
all_colors.extend([cmap_obj(v) for v in t])
|
|
1262
|
+
|
|
1263
|
+
return ContinuousColormap.from_list(name, all_colors, N=N)
|
|
1264
|
+
|
|
1265
|
+
@classmethod
|
|
1266
|
+
def make_diverging(cls, low, high, center="white", blend_width=0.3,
|
|
1267
|
+
low_range=None, high_range=None, name="diverging", N=256):
|
|
1268
|
+
"""
|
|
1269
|
+
Create a diverging colormap from two color sources with a blended center.
|
|
1270
|
+
|
|
1271
|
+
Samples colors from the low end and high end, then blends them towards
|
|
1272
|
+
a center color (e.g. white) to create a smooth diverging colormap.
|
|
1273
|
+
|
|
1274
|
+
Parameters:
|
|
1275
|
+
low: Colormap for the low (left) side. Can be a name (str), a Colormap,
|
|
1276
|
+
or a matplotlib colormap.
|
|
1277
|
+
high: Colormap for the high (right) side.
|
|
1278
|
+
center: Center color to blend towards (default: "white"). Any valid
|
|
1279
|
+
matplotlib color specification.
|
|
1280
|
+
blend_width (float): Fraction of each half that blends towards the center
|
|
1281
|
+
color. 0 = no blending (hard transition), 1 = blend
|
|
1282
|
+
across the entire half. Default: 0.3.
|
|
1283
|
+
low_range (tuple): (start, stop) range to sample from the low colormap.
|
|
1284
|
+
Default: (0, 1).
|
|
1285
|
+
high_range (tuple): (start, stop) range to sample from the high colormap.
|
|
1286
|
+
Default: (0, 1). Note: this is sampled in reverse
|
|
1287
|
+
so the high colormap's stop is at the outer edge.
|
|
1288
|
+
name (str): Name for the resulting colormap.
|
|
1289
|
+
N (int): Number of quantization levels.
|
|
1290
|
+
|
|
1291
|
+
Returns:
|
|
1292
|
+
ContinuousColormap: A diverging colormap.
|
|
1293
|
+
|
|
1294
|
+
Example:
|
|
1295
|
+
>>> # Diverging viridis: purple ← white → yellow
|
|
1296
|
+
>>> cmap = Colormap.make_diverging(
|
|
1297
|
+
... "viridis", "viridis",
|
|
1298
|
+
... low_range=(0, 0.2),
|
|
1299
|
+
... high_range=(0.8, 1.0),
|
|
1300
|
+
... center="white",
|
|
1301
|
+
... blend_width=0.4,
|
|
1302
|
+
... )
|
|
1303
|
+
|
|
1304
|
+
>>> # Diverging from two different colormaps
|
|
1305
|
+
>>> cmap = Colormap.make_diverging("Blues_r", "Reds")
|
|
1306
|
+
|
|
1307
|
+
>>> # Using existing Colormap objects
|
|
1308
|
+
>>> blue = Colormap("viridis").truncate(0.0, 0.3)
|
|
1309
|
+
>>> red = Colormap("inferno").truncate(0.3, 0.8)
|
|
1310
|
+
>>> cmap = Colormap.make_diverging(blue, red, center="#f0f0f0")
|
|
1311
|
+
"""
|
|
1312
|
+
# Resolve colormaps
|
|
1313
|
+
if isinstance(low, str):
|
|
1314
|
+
low = cls(low)
|
|
1315
|
+
if isinstance(high, str):
|
|
1316
|
+
high = cls(high)
|
|
1317
|
+
|
|
1318
|
+
low_start, low_stop = low_range if low_range else (0.0, 1.0)
|
|
1319
|
+
high_start, high_stop = high_range if high_range else (0.0, 1.0)
|
|
1320
|
+
|
|
1321
|
+
half = N // 2
|
|
1322
|
+
center_rgba = np.array(mcolors.to_rgba(center))
|
|
1323
|
+
|
|
1324
|
+
# Sample low side
|
|
1325
|
+
t_low = np.linspace(low_start, low_stop, half)
|
|
1326
|
+
low_colors = np.array([low(v) for v in t_low])
|
|
1327
|
+
|
|
1328
|
+
# Sample high side (reversed so center is at the inner edge)
|
|
1329
|
+
t_high = np.linspace(high_stop, high_start, half)
|
|
1330
|
+
high_colors = np.array([high(v) for v in t_high])
|
|
1331
|
+
|
|
1332
|
+
# Stack: low_colors | high_colors
|
|
1333
|
+
colors = np.vstack([low_colors, high_colors])
|
|
1334
|
+
|
|
1335
|
+
# Blend towards center color
|
|
1336
|
+
if blend_width > 0:
|
|
1337
|
+
n_total = len(colors)
|
|
1338
|
+
mid = n_total // 2
|
|
1339
|
+
for i in range(n_total):
|
|
1340
|
+
dist = abs(i - mid) / mid # 0 at center, 1 at edges
|
|
1341
|
+
if dist < blend_width:
|
|
1342
|
+
w = 1.0 - (dist / blend_width) # 1 at center, 0 at blend edge
|
|
1343
|
+
colors[i, :] = colors[i, :] * (1.0 - w) + center_rgba * w
|
|
1344
|
+
|
|
1345
|
+
return ContinuousColormap.from_list(name, colors.tolist(), N=N)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def get_cmap(name="batlow", N=256):
|
|
1349
|
+
"""
|
|
1350
|
+
Get a colormap by name.
|
|
1351
|
+
|
|
1352
|
+
Parameters:
|
|
1353
|
+
name (str): Name of the colormap (matplotlib or scientific).
|
|
1354
|
+
N (int): Number of quantization levels.
|
|
1355
|
+
|
|
1356
|
+
Returns:
|
|
1357
|
+
matplotlib.colors.Colormap: A matplotlib colormap.
|
|
1358
|
+
"""
|
|
1359
|
+
return Colormap(name, N=N)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def create_cmap_from_lut(lut_file_path, include_zero=False):
|
|
1363
|
+
lut = pd.read_csv(
|
|
1364
|
+
lut_file_path,
|
|
1365
|
+
comment="#",
|
|
1366
|
+
sep=r"\s+",
|
|
1367
|
+
header=None,
|
|
1368
|
+
names=["index", "R", "G", "B", "A"],
|
|
1369
|
+
)
|
|
1370
|
+
lut["A"] = lut.apply(
|
|
1371
|
+
lambda row: 255 if row["index"] not in ["NONE", "Unknown"] else row["A"], axis=1
|
|
1372
|
+
)
|
|
1373
|
+
rgba_colors = lut[["R", "G", "B", "A"]].div(255).to_numpy()
|
|
1374
|
+
if not include_zero:
|
|
1375
|
+
rgba_colors = rgba_colors[1:]
|
|
1376
|
+
cmap = mcolors.ListedColormap(rgba_colors)
|
|
1377
|
+
|
|
1378
|
+
return cmap
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def cmap_to_colors(
|
|
1382
|
+
cmap="viridis", n=8, start=0.0, stop=1.0, endpoint=True, reverse=False, to_hex=False
|
|
1383
|
+
):
|
|
1384
|
+
"""
|
|
1385
|
+
Sample a continuous Matplotlib colormap into a list of n colors.
|
|
1386
|
+
|
|
1387
|
+
Parameters:
|
|
1388
|
+
cmap (str or Colormap): Name or instance of a Matplotlib colormap.
|
|
1389
|
+
n (int): Number of samples.
|
|
1390
|
+
start (float): Start of the sampling range in [0, 1].
|
|
1391
|
+
stop (float): End of the sampling range in [0, 1].
|
|
1392
|
+
endpoint (bool): Include the stop value.
|
|
1393
|
+
reverse (bool): Reverse the order of colors.
|
|
1394
|
+
to_hex (bool): Return colors as hex strings instead of RGBA.
|
|
1395
|
+
|
|
1396
|
+
Returns:
|
|
1397
|
+
list: List of colors (RGBA tuples or hex strings).
|
|
1398
|
+
"""
|
|
1399
|
+
if isinstance(cmap, str):
|
|
1400
|
+
cmap = Colormap(cmap)
|
|
1401
|
+
t = np.linspace(start, stop, int(n), endpoint=endpoint)
|
|
1402
|
+
if reverse:
|
|
1403
|
+
t = t[::-1]
|
|
1404
|
+
cols = [cmap(v) for v in t]
|
|
1405
|
+
if to_hex:
|
|
1406
|
+
cols = [mcolors.to_hex(c) for c in cols]
|
|
1407
|
+
return cols
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def make_cycler_from_cmap(
|
|
1411
|
+
cmap="viridis",
|
|
1412
|
+
n=8,
|
|
1413
|
+
start=0.0,
|
|
1414
|
+
stop=1.0,
|
|
1415
|
+
endpoint=True,
|
|
1416
|
+
reverse=False,
|
|
1417
|
+
to_hex=False,
|
|
1418
|
+
return_hex=False,
|
|
1419
|
+
):
|
|
1420
|
+
"""
|
|
1421
|
+
Build a Matplotlib prop cycler from a continuous colormap.
|
|
1422
|
+
|
|
1423
|
+
Parameters mirror cmap_to_colors. If return_hex=True, return the list of colors
|
|
1424
|
+
(hex if to_hex or return_hex is True) instead of a cycler.
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
cycler | list: cycler('color', colors) by default, or list of colors when return_hex=True.
|
|
1428
|
+
"""
|
|
1429
|
+
colors = cmap_to_colors(
|
|
1430
|
+
cmap=cmap,
|
|
1431
|
+
n=n,
|
|
1432
|
+
start=start,
|
|
1433
|
+
stop=stop,
|
|
1434
|
+
endpoint=endpoint,
|
|
1435
|
+
reverse=reverse,
|
|
1436
|
+
to_hex=(to_hex or return_hex),
|
|
1437
|
+
)
|
|
1438
|
+
if return_hex:
|
|
1439
|
+
return colors
|
|
1440
|
+
return cycler("color", colors)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def discrete_cmap_from_continuous(
|
|
1444
|
+
cmap="viridis", n=8, start=0.0, stop=1.0, endpoint=True, reverse=False
|
|
1445
|
+
):
|
|
1446
|
+
"""
|
|
1447
|
+
Create a discrete ListedColormap from a continuous colormap.
|
|
1448
|
+
|
|
1449
|
+
Parameters:
|
|
1450
|
+
cmap (str or Colormap): Name or instance of a Matplotlib colormap.
|
|
1451
|
+
n (int): Number of discrete colors.
|
|
1452
|
+
start (float): Start of the sampling range in [0, 1].
|
|
1453
|
+
stop (float): End of the sampling range in [0, 1].
|
|
1454
|
+
endpoint (bool): Include the stop value.
|
|
1455
|
+
reverse (bool): Reverse the order of colors.
|
|
1456
|
+
|
|
1457
|
+
Returns:
|
|
1458
|
+
ListedColormap: A discrete ListedColormap instance.
|
|
1459
|
+
"""
|
|
1460
|
+
colors = cmap_to_colors(
|
|
1461
|
+
cmap=cmap,
|
|
1462
|
+
n=n,
|
|
1463
|
+
start=start,
|
|
1464
|
+
stop=stop,
|
|
1465
|
+
endpoint=endpoint,
|
|
1466
|
+
reverse=reverse,
|
|
1467
|
+
)
|
|
1468
|
+
return mcolors.ListedColormap(colors)
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
# ---------------------------------------------------------------------------
|
|
1472
|
+
# Colormap accessibility — computed from actual colormap data
|
|
1473
|
+
# ---------------------------------------------------------------------------
|
|
1474
|
+
|
|
1475
|
+
def _cmap_has_tag(name, tag):
|
|
1476
|
+
"""Check whether a *cmap*-package colormap carries a given catalog tag.
|
|
1477
|
+
|
|
1478
|
+
Uses the live catalog so that newly added colormaps or updated tags
|
|
1479
|
+
are picked up automatically.
|
|
1480
|
+
|
|
1481
|
+
Recognised tags (as of cmap 0.7):
|
|
1482
|
+
'colorblind', 'uniform', 'protanopia', 'deuteranopia', 'tritanopia'
|
|
1483
|
+
"""
|
|
1484
|
+
if not HAS_CMAP:
|
|
1485
|
+
return False
|
|
1486
|
+
try:
|
|
1487
|
+
import warnings as _w
|
|
1488
|
+
with _w.catch_warnings():
|
|
1489
|
+
_w.simplefilter("ignore")
|
|
1490
|
+
entry = cmap_lib.Colormap.catalog()[name]
|
|
1491
|
+
return tag in entry.tags
|
|
1492
|
+
except (KeyError, Exception):
|
|
1493
|
+
return False
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _luminance_profile(cmap_obj, n_samples=32):
|
|
1497
|
+
"""Return the luminance array for a colormap, sampled at *n_samples*
|
|
1498
|
+
evenly-spaced points."""
|
|
1499
|
+
t = np.linspace(0, 1, n_samples)
|
|
1500
|
+
rgba = np.array([cmap_obj(v) for v in t])
|
|
1501
|
+
# Relative luminance (Rec. 709)
|
|
1502
|
+
return 0.2126 * rgba[:, 0] + 0.7152 * rgba[:, 1] + 0.0722 * rgba[:, 2]
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _luminance_is_monotonic(cmap_obj, n_samples=32, tolerance=0.04):
|
|
1506
|
+
"""Return True if a colormap's perceived luminance is approximately
|
|
1507
|
+
monotonic (either increasing or decreasing).
|
|
1508
|
+
|
|
1509
|
+
This is the *computed* definition of print-friendliness: if the
|
|
1510
|
+
luminance changes monotonically, the colormap reproduces well in
|
|
1511
|
+
greyscale.
|
|
1512
|
+
|
|
1513
|
+
Parameters
|
|
1514
|
+
----------
|
|
1515
|
+
cmap_obj : matplotlib colormap
|
|
1516
|
+
Any callable that maps [0, 1] → RGBA.
|
|
1517
|
+
n_samples : int
|
|
1518
|
+
Number of points to sample along the colourmap.
|
|
1519
|
+
tolerance : float
|
|
1520
|
+
Fraction of the luminance range by which individual steps may
|
|
1521
|
+
violate strict monotonicity (accounts for small wiggles in
|
|
1522
|
+
otherwise perceptually uniform maps).
|
|
1523
|
+
"""
|
|
1524
|
+
lum = _luminance_profile(cmap_obj, n_samples)
|
|
1525
|
+
diffs = np.diff(lum)
|
|
1526
|
+
lum_range = lum.max() - lum.min()
|
|
1527
|
+
if lum_range < 0.05:
|
|
1528
|
+
# Near-constant luminance (e.g. pure-hue cyclic maps) — not useful
|
|
1529
|
+
# in greyscale.
|
|
1530
|
+
return False
|
|
1531
|
+
|
|
1532
|
+
eps = tolerance * lum_range
|
|
1533
|
+
# Check approximately increasing OR approximately decreasing
|
|
1534
|
+
return bool(np.all(diffs > -eps) or np.all(diffs < eps))
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def _luminance_has_structure(cmap_obj, n_samples=32, tolerance=0.04):
|
|
1538
|
+
"""Return True if a colormap encodes information in lightness, making
|
|
1539
|
+
it robust under colour-vision deficiency.
|
|
1540
|
+
|
|
1541
|
+
Accepts:
|
|
1542
|
+
- Monotonic luminance (sequential maps)
|
|
1543
|
+
- V-shaped or Λ-shaped luminance (diverging maps where each half is
|
|
1544
|
+
monotonic in opposite directions)
|
|
1545
|
+
"""
|
|
1546
|
+
lum = _luminance_profile(cmap_obj, n_samples)
|
|
1547
|
+
lum_range = lum.max() - lum.min()
|
|
1548
|
+
if lum_range < 0.05:
|
|
1549
|
+
return False
|
|
1550
|
+
|
|
1551
|
+
# 1. Fully monotonic?
|
|
1552
|
+
diffs = np.diff(lum)
|
|
1553
|
+
eps = tolerance * lum_range
|
|
1554
|
+
if np.all(diffs > -eps) or np.all(diffs < eps):
|
|
1555
|
+
return True
|
|
1556
|
+
|
|
1557
|
+
# 2. Diverging structure: split at midpoint — each half monotonic
|
|
1558
|
+
# in opposite directions (V or Λ shape).
|
|
1559
|
+
mid = n_samples // 2
|
|
1560
|
+
left = lum[:mid + 1]
|
|
1561
|
+
right = lum[mid:]
|
|
1562
|
+
left_diffs = np.diff(left)
|
|
1563
|
+
right_diffs = np.diff(right)
|
|
1564
|
+
left_range = left.max() - left.min()
|
|
1565
|
+
right_range = right.max() - right.min()
|
|
1566
|
+
|
|
1567
|
+
if left_range < 0.03 or right_range < 0.03:
|
|
1568
|
+
return False
|
|
1569
|
+
|
|
1570
|
+
eps_l = tolerance * left_range
|
|
1571
|
+
eps_r = tolerance * right_range
|
|
1572
|
+
|
|
1573
|
+
left_inc = np.all(left_diffs > -eps_l)
|
|
1574
|
+
left_dec = np.all(left_diffs < eps_l)
|
|
1575
|
+
right_inc = np.all(right_diffs > -eps_r)
|
|
1576
|
+
right_dec = np.all(right_diffs < eps_r)
|
|
1577
|
+
|
|
1578
|
+
# V shape: decreasing then increasing, or Λ: increasing then decreasing
|
|
1579
|
+
if (left_dec and right_inc) or (left_inc and right_dec):
|
|
1580
|
+
return True
|
|
1581
|
+
|
|
1582
|
+
return False
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _is_colorblind_safe(name, cmap_obj):
|
|
1586
|
+
"""Heuristic: a colormap is considered CVD-safe if
|
|
1587
|
+
|
|
1588
|
+
1. the cmap catalog tags it as ``'colorblind'``, **or**
|
|
1589
|
+
2. its luminance has structure (monotonic for sequential, V/Λ-shaped
|
|
1590
|
+
for diverging) — meaning information is encoded in lightness, not
|
|
1591
|
+
just hue, which is inherently robust under CVD.
|
|
1592
|
+
"""
|
|
1593
|
+
# Catalog tags (dynamic)
|
|
1594
|
+
if ":" in name and _cmap_has_tag(name, "colorblind"):
|
|
1595
|
+
return True
|
|
1596
|
+
# Compute: structured luminance ≈ CVD-robust
|
|
1597
|
+
return _luminance_has_structure(cmap_obj)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def _is_print_friendly(name, cmap_obj):
|
|
1601
|
+
"""Heuristic: a colormap is print-friendly if its luminance profile is
|
|
1602
|
+
approximately monotonic, meaning it survives greyscale conversion."""
|
|
1603
|
+
return _luminance_is_monotonic(cmap_obj)
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
# Human-friendly data description → internal cmap type
|
|
1607
|
+
_DATA_TYPE_MAP = {
|
|
1608
|
+
"ordered": "sequential", "sequential": "sequential",
|
|
1609
|
+
"centered": "diverging", "diverging": "diverging",
|
|
1610
|
+
"periodic": "cyclic", "cyclic": "cyclic",
|
|
1611
|
+
"categorical": "qualitative", "qualitative": "qualitative",
|
|
1612
|
+
"multirange": "multisequential", "multisequential": "multisequential",
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
# ---------------------------------------------------------------------------
|
|
1616
|
+
# Interactive wizard prompts
|
|
1617
|
+
# ---------------------------------------------------------------------------
|
|
1618
|
+
def _wizard_prompts():
|
|
1619
|
+
"""Run the interactive wizard and return resolved keyword arguments."""
|
|
1620
|
+
print("━" * 50)
|
|
1621
|
+
print(" bsplot Colormap Explorer")
|
|
1622
|
+
print("━" * 50)
|
|
1623
|
+
print()
|
|
1624
|
+
|
|
1625
|
+
# ── Q1 Data character ──────────────────────────────
|
|
1626
|
+
print("What kind of data are you visualising?")
|
|
1627
|
+
print(" 1 Ordered / sequential (intensity, temperature, counts)")
|
|
1628
|
+
print(" 2 Centered / diverging (anomalies, differences, ±values)")
|
|
1629
|
+
print(" 3 Periodic / cyclic (phase, angles, time of day)")
|
|
1630
|
+
print(" 4 Categorical (groups, labels, brain regions)")
|
|
1631
|
+
print(" 5 Multi-range (land / ocean elevation)")
|
|
1632
|
+
c = input("\n▸ Enter number [1-5]: ").strip()
|
|
1633
|
+
data = {
|
|
1634
|
+
"1": "ordered", "2": "centered", "3": "periodic",
|
|
1635
|
+
"4": "categorical", "5": "multirange",
|
|
1636
|
+
}.get(c, "ordered")
|
|
1637
|
+
print()
|
|
1638
|
+
|
|
1639
|
+
# ── Q2 Continuous vs discrete ──────────────────────
|
|
1640
|
+
n_categories = None
|
|
1641
|
+
print("Is your data continuous or discrete?")
|
|
1642
|
+
print(" 1 Continuous (smooth gradient)")
|
|
1643
|
+
print(" 2 Discrete (fixed number of categories)")
|
|
1644
|
+
c = input("\n▸ Enter number [1-2]: ").strip()
|
|
1645
|
+
if c == "2":
|
|
1646
|
+
try:
|
|
1647
|
+
n_categories = int(input(" How many categories? ").strip())
|
|
1648
|
+
except ValueError:
|
|
1649
|
+
n_categories = None
|
|
1650
|
+
print()
|
|
1651
|
+
|
|
1652
|
+
# ── Q3 Colorblind safety ───────────────────────────
|
|
1653
|
+
cb = input("Must be colorblind-safe? [y/N]: ").strip().lower()
|
|
1654
|
+
colorblind_safe = cb in ("y", "yes")
|
|
1655
|
+
print()
|
|
1656
|
+
|
|
1657
|
+
# ── Q4 Print / greyscale ───────────────────────────
|
|
1658
|
+
pf = input("Needs to work in greyscale print? [y/N]: ").strip().lower()
|
|
1659
|
+
print_friendly = pf in ("y", "yes")
|
|
1660
|
+
print()
|
|
1661
|
+
|
|
1662
|
+
# ── Q5 Preview style ──────────────────────────────
|
|
1663
|
+
print("How would you like to preview the colormaps?")
|
|
1664
|
+
print(" 1 Gradient bars")
|
|
1665
|
+
print(" 2 Heatmap / matrix")
|
|
1666
|
+
print(" 3 Brain surface")
|
|
1667
|
+
c = input("\n▸ Enter number [1-3]: ").strip()
|
|
1668
|
+
preview = {"1": "gradient", "2": "matrix", "3": "brain"}.get(c, "gradient")
|
|
1669
|
+
print()
|
|
1670
|
+
|
|
1671
|
+
return data, colorblind_safe, n_categories, print_friendly, preview
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
# ---------------------------------------------------------------------------
|
|
1675
|
+
# Preview renderers
|
|
1676
|
+
# ---------------------------------------------------------------------------
|
|
1677
|
+
def _preview_gradient(names, n_categories, figsize, cmap_type, source):
|
|
1678
|
+
"""Gradient-bar gallery."""
|
|
1679
|
+
n = len(names)
|
|
1680
|
+
if figsize is None:
|
|
1681
|
+
figsize = (10, max(2, 0.45 * n))
|
|
1682
|
+
|
|
1683
|
+
fig, axes = plt.subplots(n, 1, figsize=figsize)
|
|
1684
|
+
if n == 1:
|
|
1685
|
+
axes = [axes]
|
|
1686
|
+
|
|
1687
|
+
if n_categories:
|
|
1688
|
+
gradient = np.linspace(0, 1, n_categories).reshape(1, -1)
|
|
1689
|
+
gradient = np.repeat(gradient, 20, axis=0)
|
|
1690
|
+
else:
|
|
1691
|
+
gradient = np.linspace(0, 1, 256).reshape(1, -1)
|
|
1692
|
+
|
|
1693
|
+
for ax, name in zip(axes, names):
|
|
1694
|
+
try:
|
|
1695
|
+
cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
|
|
1696
|
+
except Exception:
|
|
1697
|
+
ax.set_visible(False)
|
|
1698
|
+
continue
|
|
1699
|
+
ax.imshow(gradient, aspect="auto", cmap=cmap, interpolation="nearest")
|
|
1700
|
+
ax.set_yticks([])
|
|
1701
|
+
ax.set_xticks([])
|
|
1702
|
+
ax.set_ylabel(name, rotation=0, ha="right", va="center", fontsize=8)
|
|
1703
|
+
|
|
1704
|
+
title = f"Colormaps — {cmap_type}"
|
|
1705
|
+
if source != "all":
|
|
1706
|
+
title += f" ({source})"
|
|
1707
|
+
fig.suptitle(title, fontsize=12, fontweight="bold")
|
|
1708
|
+
plt.tight_layout()
|
|
1709
|
+
plt.show()
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _preview_matrix(names, n_categories, figsize):
|
|
1713
|
+
"""Heatmap gallery on synthetic 2-D data."""
|
|
1714
|
+
ncols = min(4, len(names))
|
|
1715
|
+
nrows = (len(names) + ncols - 1) // ncols
|
|
1716
|
+
if figsize is None:
|
|
1717
|
+
figsize = (3.5 * ncols, 3 * nrows)
|
|
1718
|
+
|
|
1719
|
+
x = np.linspace(-3, 3, 120)
|
|
1720
|
+
X, Y = np.meshgrid(x, x)
|
|
1721
|
+
Z = np.sin(X) * np.cos(Y)
|
|
1722
|
+
|
|
1723
|
+
fig, axs = plt.subplots(nrows, ncols, figsize=figsize, constrained_layout=True)
|
|
1724
|
+
axs = np.atleast_1d(axs).ravel()
|
|
1725
|
+
for ax in axs:
|
|
1726
|
+
ax.axis("off")
|
|
1727
|
+
|
|
1728
|
+
for ax, name in zip(axs, names):
|
|
1729
|
+
try:
|
|
1730
|
+
cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
|
|
1731
|
+
except Exception:
|
|
1732
|
+
continue
|
|
1733
|
+
im = ax.imshow(Z, cmap=cmap, origin="lower")
|
|
1734
|
+
ax.set_title(name, fontsize=8)
|
|
1735
|
+
fig.colorbar(im, ax=ax, shrink=0.7)
|
|
1736
|
+
|
|
1737
|
+
fig.suptitle("Colormap preview — heatmap", fontsize=12, fontweight="bold")
|
|
1738
|
+
plt.show()
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _preview_brain(names, n_categories, figsize):
|
|
1742
|
+
"""Brain-surface gallery."""
|
|
1743
|
+
try:
|
|
1744
|
+
import random as _random
|
|
1745
|
+
import templateflow
|
|
1746
|
+
import nibabel as nib
|
|
1747
|
+
from bsplot import surface as bssurface, style as _style
|
|
1748
|
+
except ImportError:
|
|
1749
|
+
print(
|
|
1750
|
+
"Brain preview requires 'nibabel' and 'templateflow'. "
|
|
1751
|
+
"Falling back to gradient bars."
|
|
1752
|
+
)
|
|
1753
|
+
_preview_gradient(names, n_categories, figsize, "—", "all")
|
|
1754
|
+
return
|
|
1755
|
+
|
|
1756
|
+
fsLR_lh = templateflow.api.get(
|
|
1757
|
+
template="fsLR", density="32k", suffix="inflated", hemi="L",
|
|
1758
|
+
)
|
|
1759
|
+
lh = nib.load(fsLR_lh)
|
|
1760
|
+
vertices, _ = lh.agg_data()
|
|
1761
|
+
centre = _random.choice(range(len(vertices)))
|
|
1762
|
+
distances = np.linalg.norm(vertices - vertices[centre], axis=1)
|
|
1763
|
+
overlay = np.exp(-0.5 * (distances / 100.0) ** 2)
|
|
1764
|
+
|
|
1765
|
+
ncols = min(4, len(names))
|
|
1766
|
+
nrows = (len(names) + ncols - 1) // ncols
|
|
1767
|
+
if figsize is None:
|
|
1768
|
+
figsize = (3.5 * ncols, 2.5 * nrows)
|
|
1769
|
+
|
|
1770
|
+
fig, axs = plt.subplots(nrows, ncols, figsize=figsize, constrained_layout=True)
|
|
1771
|
+
axs = np.atleast_1d(axs).ravel()
|
|
1772
|
+
for ax in axs:
|
|
1773
|
+
ax.axis("off")
|
|
1774
|
+
|
|
1775
|
+
for ax, name in zip(axs, names):
|
|
1776
|
+
try:
|
|
1777
|
+
cmap = Colormap(name, N=n_categories) if n_categories else Colormap(name)
|
|
1778
|
+
except Exception:
|
|
1779
|
+
continue
|
|
1780
|
+
mappable = bssurface.plot_surf(
|
|
1781
|
+
surface="fsLR", overlay=overlay,
|
|
1782
|
+
ax=ax, view="lateral", hemi="lh", cmap=cmap,
|
|
1783
|
+
)
|
|
1784
|
+
fig.colorbar(mappable, ax=ax, shrink=0.6)
|
|
1785
|
+
ax.set_title(name, fontsize=8)
|
|
1786
|
+
|
|
1787
|
+
_style.format_fig(fig)
|
|
1788
|
+
plt.show()
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
# ---------------------------------------------------------------------------
|
|
1792
|
+
# Main explorer
|
|
1793
|
+
# ---------------------------------------------------------------------------
|
|
1794
|
+
def explore_colormaps(
|
|
1795
|
+
data=None,
|
|
1796
|
+
colorblind_safe=None,
|
|
1797
|
+
n_categories=None,
|
|
1798
|
+
print_friendly=None,
|
|
1799
|
+
preview=None,
|
|
1800
|
+
source="all",
|
|
1801
|
+
search=None,
|
|
1802
|
+
max_display=24,
|
|
1803
|
+
figsize=None,
|
|
1804
|
+
):
|
|
1805
|
+
"""Guided colormap explorer — find the best colormap for your plotting
|
|
1806
|
+
problem.
|
|
1807
|
+
|
|
1808
|
+
Call with **no arguments** for an interactive wizard that walks you through
|
|
1809
|
+
your requirements step by step, or pass keyword arguments directly for
|
|
1810
|
+
scripted use.
|
|
1811
|
+
|
|
1812
|
+
Parameters
|
|
1813
|
+
----------
|
|
1814
|
+
data : str, optional
|
|
1815
|
+
Describes what you are plotting. Human-friendly aliases are supported:
|
|
1816
|
+
|
|
1817
|
+
============== ================ ===================================
|
|
1818
|
+
Alias Internal type When to use
|
|
1819
|
+
============== ================ ===================================
|
|
1820
|
+
``'ordered'`` sequential intensity, temperature, counts
|
|
1821
|
+
``'centered'`` diverging anomalies, correlation, differences
|
|
1822
|
+
``'periodic'`` cyclic phase, angles, time of day
|
|
1823
|
+
``'categorical'`` qualitative groups, labels, brain regions
|
|
1824
|
+
``'multirange'`` multisequential land / ocean elevation
|
|
1825
|
+
============== ================ ===================================
|
|
1826
|
+
|
|
1827
|
+
If *None* (the default), the interactive wizard is launched.
|
|
1828
|
+
colorblind_safe : bool, optional
|
|
1829
|
+
Keep only colormaps that are designed for colour-vision deficiency.
|
|
1830
|
+
All Crameri colormaps, the *viridis* family, and most cmasher / cmocean
|
|
1831
|
+
maps pass this filter. Default *False* (or asked in wizard mode).
|
|
1832
|
+
n_categories : int, optional
|
|
1833
|
+
If set, show discrete colour swatches (``Colormap(name, N=n)``)
|
|
1834
|
+
instead of a smooth gradient. Useful for categorical data.
|
|
1835
|
+
print_friendly : bool, optional
|
|
1836
|
+
Keep only colormaps whose monotonically increasing luminance
|
|
1837
|
+
reproduces well in greyscale print.
|
|
1838
|
+
preview : str, optional
|
|
1839
|
+
How to preview the candidates:
|
|
1840
|
+
|
|
1841
|
+
* ``'gradient'`` — colour gradient bars (default)
|
|
1842
|
+
* ``'matrix'`` — 2-D heatmap on sample data
|
|
1843
|
+
* ``'brain'`` — lateral brain-surface view (requires *nibabel* &
|
|
1844
|
+
*templateflow*)
|
|
1845
|
+
source : str
|
|
1846
|
+
``'all'``, ``'matplotlib'``, ``'Crameri'``, or ``'cmap'``.
|
|
1847
|
+
search : str, optional
|
|
1848
|
+
Case-insensitive substring filter on colormap names.
|
|
1849
|
+
max_display : int
|
|
1850
|
+
Maximum number of colormaps to show (default 24).
|
|
1851
|
+
figsize : tuple, optional
|
|
1852
|
+
Matplotlib figure size override.
|
|
1853
|
+
|
|
1854
|
+
Returns
|
|
1855
|
+
-------
|
|
1856
|
+
list of str
|
|
1857
|
+
Names of the colormaps that were displayed.
|
|
1858
|
+
|
|
1859
|
+
Examples
|
|
1860
|
+
--------
|
|
1861
|
+
>>> explore_colormaps() # launch wizard
|
|
1862
|
+
>>> explore_colormaps("ordered", colorblind_safe=True)
|
|
1863
|
+
>>> explore_colormaps("centered", preview="brain", max_display=12)
|
|
1864
|
+
>>> explore_colormaps("periodic", source="cmap")
|
|
1865
|
+
"""
|
|
1866
|
+
# ── wizard mode when called bare ───────────────────────
|
|
1867
|
+
interactive = data is None
|
|
1868
|
+
if interactive:
|
|
1869
|
+
data, colorblind_safe, n_categories, print_friendly, preview = (
|
|
1870
|
+
_wizard_prompts()
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
# ── defaults for scripted calls ────────────────────────
|
|
1874
|
+
if colorblind_safe is None:
|
|
1875
|
+
colorblind_safe = False
|
|
1876
|
+
if print_friendly is None:
|
|
1877
|
+
print_friendly = False
|
|
1878
|
+
if preview is None:
|
|
1879
|
+
preview = "gradient"
|
|
1880
|
+
|
|
1881
|
+
# ── resolve cmap type ──────────────────────────────────
|
|
1882
|
+
cmap_type = _DATA_TYPE_MAP.get(data, "sequential")
|
|
1883
|
+
|
|
1884
|
+
# ── collect candidates ─────────────────────────────────
|
|
1885
|
+
available = Colormap.list_available(cmap_type=cmap_type, source=source)
|
|
1886
|
+
if isinstance(available, dict):
|
|
1887
|
+
names = [n for v in available.values() for n in v]
|
|
1888
|
+
else:
|
|
1889
|
+
names = list(available)
|
|
1890
|
+
|
|
1891
|
+
# ── filter: colorblind safety (computed) ─────────────────
|
|
1892
|
+
if colorblind_safe:
|
|
1893
|
+
def _cb(n):
|
|
1894
|
+
try:
|
|
1895
|
+
cmap_obj = Colormap(n)
|
|
1896
|
+
return _is_colorblind_safe(n, cmap_obj)
|
|
1897
|
+
except Exception:
|
|
1898
|
+
return False
|
|
1899
|
+
names = [n for n in names if _cb(n)]
|
|
1900
|
+
|
|
1901
|
+
# ── filter: print-friendly (computed) ──────────────────
|
|
1902
|
+
if print_friendly:
|
|
1903
|
+
def _pf(n):
|
|
1904
|
+
try:
|
|
1905
|
+
cmap_obj = Colormap(n)
|
|
1906
|
+
return _is_print_friendly(n, cmap_obj)
|
|
1907
|
+
except Exception:
|
|
1908
|
+
return False
|
|
1909
|
+
names = [n for n in names if _pf(n)]
|
|
1910
|
+
|
|
1911
|
+
# ── filter: keyword search ─────────────────────────────
|
|
1912
|
+
if search:
|
|
1913
|
+
sl = search.lower()
|
|
1914
|
+
names = [n for n in names if sl in n.lower()]
|
|
1915
|
+
|
|
1916
|
+
names = names[:max_display]
|
|
1917
|
+
|
|
1918
|
+
if not names:
|
|
1919
|
+
print("No colormaps match your criteria. Try relaxing some filters.")
|
|
1920
|
+
return []
|
|
1921
|
+
|
|
1922
|
+
# ── render ─────────────────────────────────────────────
|
|
1923
|
+
if preview == "matrix":
|
|
1924
|
+
_preview_matrix(names, n_categories, figsize)
|
|
1925
|
+
elif preview == "brain":
|
|
1926
|
+
_preview_brain(names, n_categories, figsize)
|
|
1927
|
+
else:
|
|
1928
|
+
_preview_gradient(names, n_categories, figsize, cmap_type, source)
|
|
1929
|
+
|
|
1930
|
+
# ── summary ────────────────────────────────────────────
|
|
1931
|
+
print(f"\n{len(names)} colormaps shown. Load with: Colormap('name')")
|
|
1932
|
+
if n_categories:
|
|
1933
|
+
print(f" Discrete version: Colormap('name', N={n_categories})")
|
|
1934
|
+
|
|
1935
|
+
return names
|