perfect-cmaps 1.0.0__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.
- perfect_cmaps/__init__.py +0 -0
- perfect_cmaps/color_utils.py +377 -0
- perfect_cmaps/colormaps.py +237 -0
- perfect_cmaps/customize_cmap.py +223 -0
- perfect_cmaps/data/cielab_gamut_max_L.npy +0 -0
- perfect_cmaps/data/cielab_gamut_max_L_RGB.npy +0 -0
- perfect_cmaps/lab_control_points/README.md +5 -0
- perfect_cmaps/lab_control_points/__init__.py +0 -0
- perfect_cmaps/lab_control_points/__pycache__/__init__.cpython-38.pyc +0 -0
- perfect_cmaps/lab_control_points/cividish.json +209 -0
- perfect_cmaps/lab_control_points/diverging_hot_cool.json +89 -0
- perfect_cmaps/lab_control_points/mcyk.json +25 -0
- perfect_cmaps/lab_control_points/plasmoid.json +209 -0
- perfect_cmaps/lab_control_points/rgb_test.json +85 -0
- perfect_cmaps/lab_control_points/rgb_test2.json +85 -0
- perfect_cmaps/lab_control_points/salmiak.json +25 -0
- perfect_cmaps/lab_control_points/spectrum.json +209 -0
- perfect_cmaps/lab_control_points/viridish.json +89 -0
- perfect_cmaps/lab_control_points/viridish_2.json +209 -0
- perfect_cmaps/optimization.py +114 -0
- perfect_cmaps/storage.py +65 -0
- perfect_cmaps/test_images/README.md +6 -0
- perfect_cmaps/test_images/__init__.py +0 -0
- perfect_cmaps/test_images/__pycache__/__init__.cpython-38.pyc +0 -0
- perfect_cmaps/test_images/colourmaptest.tif +0 -0
- perfect_cmaps/test_images/sem/BSD-sandpaper.jpg +0 -0
- perfect_cmaps/test_images/sem/Lily-Pollen.png +0 -0
- perfect_cmaps/test_images/sem/PhenomPharos-Gallery_Pyrite.jpg +0 -0
- perfect_cmaps/test_images/sem/PhenomXLGallery_5.jpg +0 -0
- perfect_cmaps/test_images/sem/Steel-mesh.png +0 -0
- perfect_cmaps/test_images/sem/charcoal4_49721153188_o-scaled.jpg +0 -0
- perfect_cmaps/test_images/sem/graphite5_49722003087_o-scaled.jpg +0 -0
- perfect_cmaps/test_images/sem/melamine-foam_49722002717_o-scaled.jpg +0 -0
- perfect_cmaps-1.0.0.dist-info/LICENSE +201 -0
- perfect_cmaps-1.0.0.dist-info/METADATA +70 -0
- perfect_cmaps-1.0.0.dist-info/RECORD +38 -0
- perfect_cmaps-1.0.0.dist-info/WHEEL +5 -0
- perfect_cmaps-1.0.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import matplotlib.colors as mcolors
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.stats import beta
|
|
4
|
+
from scipy.interpolate import interp1d
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
from colour import sRGB_to_XYZ, XYZ_to_Lab, Lab_to_XYZ, XYZ_to_sRGB
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
9
|
+
|
|
10
|
+
from perfect_cmaps.optimization import genetic_algorithm
|
|
11
|
+
from perfect_cmaps.storage import get_test_img_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RGB_WEIGHT = np.array([0.2989, 0.5870, 0.1140])
|
|
15
|
+
SUPPORTED_L_PROFILES = [
|
|
16
|
+
"linear",
|
|
17
|
+
"diverging",
|
|
18
|
+
"diverging_inverted",
|
|
19
|
+
"diverging_sharper",
|
|
20
|
+
"diverging_inverted_sharper",
|
|
21
|
+
"flat"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diverging_envelope(x: np.ndarray, c: float = 4, x1: float = 0.25) -> np.ndarray:
|
|
26
|
+
result = np.zeros(x.shape)
|
|
27
|
+
m = x1 * (c - 2) / (1 - 2 * x1 + 1e-5)
|
|
28
|
+
|
|
29
|
+
below_x1 = x <= x1
|
|
30
|
+
result[below_x1] = c*x[below_x1]
|
|
31
|
+
|
|
32
|
+
before_middle = np.logical_and(x > x1, x <= 0.5)
|
|
33
|
+
result[before_middle] = m + 2*(1-m) * x[before_middle]
|
|
34
|
+
|
|
35
|
+
after_middle = np.logical_and(x > 0.5, x <= 1-x1)
|
|
36
|
+
result[after_middle] = 2 - m + 2*(m-1) * x[after_middle]
|
|
37
|
+
|
|
38
|
+
in_middle = np.logical_and(x > x1, x <= 1 - x1)
|
|
39
|
+
result[in_middle] = result.max()
|
|
40
|
+
|
|
41
|
+
after_x2 = x > 1-x1
|
|
42
|
+
result[after_x2] = c * (1 - x[after_x2])
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dying_cos(x: np.ndarray, offset: float = 0.0):
|
|
47
|
+
result = np.zeros_like(x)
|
|
48
|
+
values = np.mod(np.round(x - offset), 2) == 0
|
|
49
|
+
result[values] = (1 + np.cos(2*np.pi*(x[values]-offset)))/2
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unwrap_channels(ijk: Union[Tuple[int, int, int], None] = None) -> tuple:
|
|
54
|
+
if ijk is None:
|
|
55
|
+
ijk = (0, 1, 2)
|
|
56
|
+
|
|
57
|
+
return ijk
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def linearize_rgba(rgba_colors: np.ndarray) -> np.ndarray:
|
|
61
|
+
lab_colors = XYZ_to_Lab(sRGB_to_XYZ(rgba_colors[:, :3]))
|
|
62
|
+
lab_colors[:, 0] = np.linspace(0, 100, lab_colors.shape[0])
|
|
63
|
+
rgb_colors_renormalized = Lab_to_sRGB(lab_colors)
|
|
64
|
+
rgb_colors_renormalized = np.concatenate(
|
|
65
|
+
(rgb_colors_renormalized, rgba_colors[:, 3:4]), axis=1
|
|
66
|
+
)
|
|
67
|
+
return rgb_colors_renormalized
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cold_blooded(x: np.ndarray, ijk: Union[Tuple[int, int, int], None] = None) -> np.ndarray:
|
|
71
|
+
i, j, k = unwrap_channels(ijk)
|
|
72
|
+
|
|
73
|
+
result = np.zeros([*x.shape, 4])
|
|
74
|
+
period = 2*np.pi
|
|
75
|
+
result[..., j] = x**2 - x/period * np.sin(period*x)
|
|
76
|
+
result[..., k] = ((1 - np.cos(period/2*x))/2)**2
|
|
77
|
+
result[..., i] = 3*x**2 - result[..., j] - result[..., k]
|
|
78
|
+
result[..., :3] = np.sqrt(result[..., :3])
|
|
79
|
+
|
|
80
|
+
result[..., :3] /= np.max(np.max(result))
|
|
81
|
+
result[..., 3] = 1
|
|
82
|
+
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cold_blooded_l(x: np.ndarray, ijk: Union[Tuple[int, int, int], None] = None) -> np.ndarray:
|
|
87
|
+
normal_colors = cold_blooded(x, ijk)
|
|
88
|
+
return linearize_rgba(normal_colors)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def copper_salt(x: np.ndarray, ijk: Union[Tuple[int, int, int], None] = None) -> np.ndarray:
|
|
92
|
+
i, j, k = unwrap_channels(ijk)
|
|
93
|
+
|
|
94
|
+
result = np.zeros([*x.shape, 4])
|
|
95
|
+
dist = beta(2, 4)
|
|
96
|
+
pdf = dist.pdf
|
|
97
|
+
envelope = diverging_envelope(x, c=4, x1=0.5)
|
|
98
|
+
result[..., i] = pdf(x)
|
|
99
|
+
result[..., k] = pdf(1-x)
|
|
100
|
+
result[..., i] /= np.max(result[..., i])
|
|
101
|
+
result[..., k] /= np.max(result[..., k])
|
|
102
|
+
result[..., j] = np.sqrt(3 * envelope - result[..., i] ** 2 - result[..., k] ** 2)
|
|
103
|
+
weight = 1 / np.sqrt(RGB_WEIGHT)
|
|
104
|
+
|
|
105
|
+
result[..., :3] = np.einsum(
|
|
106
|
+
'i...j,k...j->i...j',
|
|
107
|
+
result[..., :3],
|
|
108
|
+
weight.reshape(1, *weight.shape)
|
|
109
|
+
)
|
|
110
|
+
result[..., :3] /= np.max(result)
|
|
111
|
+
result[..., 3] = 1
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_lightness_profile(n_values: int, profile: str = "linear") -> np.ndarray:
|
|
116
|
+
if profile == "linear":
|
|
117
|
+
L_values = np.linspace(0, 100, n_values)
|
|
118
|
+
elif profile.startswith("diverging"):
|
|
119
|
+
base_envelope = np.linspace(-10, 10, n_values)
|
|
120
|
+
|
|
121
|
+
if profile == "diverging":
|
|
122
|
+
L_values = 100 - base_envelope ** 2
|
|
123
|
+
|
|
124
|
+
elif profile == "diverging_sharper":
|
|
125
|
+
L_values = 100 - (base_envelope ** 2 + np.abs(base_envelope * 10)) / 2
|
|
126
|
+
|
|
127
|
+
elif profile == "diverging_inverted":
|
|
128
|
+
L_values = base_envelope ** 2
|
|
129
|
+
|
|
130
|
+
elif profile == "diverging_inverted_sharper":
|
|
131
|
+
L_values = (base_envelope ** 2 + np.abs(base_envelope * 10)) / 2
|
|
132
|
+
|
|
133
|
+
elif profile == "flat":
|
|
134
|
+
# Use a flat lightness profile, e.g., L = 50
|
|
135
|
+
L_values = np.full(n_values, 50)
|
|
136
|
+
else:
|
|
137
|
+
# Default to linear
|
|
138
|
+
L_values = np.linspace(0, 100, n_values)
|
|
139
|
+
|
|
140
|
+
return L_values
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def interpolate_lab(
|
|
144
|
+
control_points: np.ndarray,
|
|
145
|
+
num_values: int = 1000,
|
|
146
|
+
profile: str = "linear",
|
|
147
|
+
interpolation: str = "quadratic"
|
|
148
|
+
) -> np.ndarray:
|
|
149
|
+
# Interpolate between the control points
|
|
150
|
+
lab_colors = np.zeros((num_values, 3))
|
|
151
|
+
space = np.linspace(0, 1, control_points.shape[0])
|
|
152
|
+
for i in range(2):
|
|
153
|
+
interpolator = interp1d(space, control_points[:, i], kind=interpolation)
|
|
154
|
+
lab_colors[:, i + 1] = interpolator(np.linspace(0, 1, num_values))
|
|
155
|
+
|
|
156
|
+
lab_colors[:, 0] = get_lightness_profile(num_values, profile)
|
|
157
|
+
return lab_colors
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rgb_to_grayscale(rgb: np.ndarray) -> np.ndarray:
|
|
161
|
+
"""Convert RGB values to grayscale using luminosity values of RGB channels."""
|
|
162
|
+
return np.sqrt(np.dot(rgb[...,:3] ** 2, RGB_WEIGHT))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def rgb_to_grayscale_lightness(rgb: np.ndarray) -> np.ndarray:
|
|
166
|
+
"""
|
|
167
|
+
Convert RGB values to grayscale values representing perceived lightness (L*).
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
rgb : ndarray
|
|
172
|
+
An array of RGB values in sRGB color space, with values in the range [0, 1].
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
L_normalized : ndarray
|
|
177
|
+
Grayscale values representing the L* component, normalized to [0, 1].
|
|
178
|
+
"""
|
|
179
|
+
# Ensure RGB values are within [0, 1]
|
|
180
|
+
rgb = np.clip(rgb, 0, 1)
|
|
181
|
+
|
|
182
|
+
# Convert sRGB to XYZ
|
|
183
|
+
XYZ = sRGB_to_XYZ(rgb)
|
|
184
|
+
|
|
185
|
+
# Convert XYZ to CIELAB
|
|
186
|
+
Lab = XYZ_to_Lab(XYZ)
|
|
187
|
+
|
|
188
|
+
# Extract the L* component
|
|
189
|
+
L = Lab[..., 0] # L* ranges from 0 to 100
|
|
190
|
+
|
|
191
|
+
# Normalize L* to [0, 1]
|
|
192
|
+
L_normalized = L / 100.0
|
|
193
|
+
|
|
194
|
+
return L_normalized
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def truncate_colormap(
|
|
198
|
+
cmap: mcolors.LinearSegmentedColormap,
|
|
199
|
+
minval: float = 0.0,
|
|
200
|
+
maxval: float = 1.0,
|
|
201
|
+
n: int = 1000
|
|
202
|
+
) -> mcolors.LinearSegmentedColormap:
|
|
203
|
+
|
|
204
|
+
new_cmap = mcolors.LinearSegmentedColormap.from_list(
|
|
205
|
+
'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval),
|
|
206
|
+
cmap(np.linspace(minval, maxval, n)))
|
|
207
|
+
return new_cmap
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def Lab_to_sRGB(lab_colors: np.ndarray) -> np.ndarray:
|
|
211
|
+
"""
|
|
212
|
+
Convert CIE-LAB colors to sRGB color space.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
lab_colors : ndarray
|
|
217
|
+
An array of Lab colors with shape (..., 3).
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
rgb_colors : ndarray
|
|
222
|
+
An array of sRGB colors with values in [0, 1].
|
|
223
|
+
"""
|
|
224
|
+
# Convert Lab to XYZ
|
|
225
|
+
XYZ = Lab_to_XYZ(lab_colors)
|
|
226
|
+
|
|
227
|
+
# Convert XYZ to sRGB
|
|
228
|
+
RGB = XYZ_to_sRGB(XYZ)
|
|
229
|
+
|
|
230
|
+
return RGB
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def find_valid_L_range(a_star: float, b_star: float) -> Tuple[float, float]:
|
|
234
|
+
# Vectorized L* samples
|
|
235
|
+
L_samples = np.linspace(0, 100, 100)
|
|
236
|
+
# Create an array of Lab colors
|
|
237
|
+
lab = np.column_stack((L_samples, np.full_like(L_samples, a_star), np.full_like(L_samples, b_star)))
|
|
238
|
+
|
|
239
|
+
# Convert all Lab colors to RGB at once
|
|
240
|
+
rgb = Lab_to_sRGB(lab)
|
|
241
|
+
|
|
242
|
+
# Determine which colors are in gamut
|
|
243
|
+
in_gamut = np.all((rgb >= 0) & (rgb <= 1), axis=1)
|
|
244
|
+
|
|
245
|
+
if np.any(in_gamut):
|
|
246
|
+
L_in_gamut = L_samples[in_gamut]
|
|
247
|
+
L_min_valid = L_in_gamut.min()
|
|
248
|
+
L_max_valid = L_in_gamut.max()
|
|
249
|
+
else:
|
|
250
|
+
L_min_valid = None
|
|
251
|
+
L_max_valid = None
|
|
252
|
+
|
|
253
|
+
return L_min_valid, L_max_valid
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def find_valid_L_range_coarse_optim(a_star: float, b_star: float) -> Tuple[float, float]:
|
|
257
|
+
# Coarse sampling
|
|
258
|
+
L_samples_coarse = np.linspace(0, 100, 50)
|
|
259
|
+
lab_coarse = np.column_stack((L_samples_coarse, np.full_like(L_samples_coarse, a_star), np.full_like(L_samples_coarse, b_star)))
|
|
260
|
+
rgb_coarse = Lab_to_sRGB(lab_coarse)
|
|
261
|
+
in_gamut_coarse = np.all((rgb_coarse >= 0) & (rgb_coarse <= 1), axis=1)
|
|
262
|
+
|
|
263
|
+
if np.any(in_gamut_coarse):
|
|
264
|
+
# Approximate L_min and L_max
|
|
265
|
+
L_in_gamut = L_samples_coarse[in_gamut_coarse]
|
|
266
|
+
L_min_approx = L_in_gamut.min()
|
|
267
|
+
L_max_approx = L_in_gamut.max()
|
|
268
|
+
|
|
269
|
+
# Fine sampling near L_min_approx
|
|
270
|
+
L_samples_min = np.linspace(max(L_min_approx - 1, 0), L_min_approx, 20)
|
|
271
|
+
lab_min = np.column_stack((L_samples_min, np.full_like(L_samples_min, a_star), np.full_like(L_samples_min, b_star)))
|
|
272
|
+
rgb_min = Lab_to_sRGB(lab_min)
|
|
273
|
+
in_gamut_min = np.all((rgb_min >= 0) & (rgb_min <= 1), axis=1)
|
|
274
|
+
L_min_valid = L_samples_min[in_gamut_min].min() if np.any(in_gamut_min) else L_min_approx
|
|
275
|
+
|
|
276
|
+
# Fine sampling near L_max_approx
|
|
277
|
+
L_samples_max = np.linspace(L_max_approx, min(L_max_approx + 1, 100), 20)
|
|
278
|
+
lab_max = np.column_stack((L_samples_max, np.full_like(L_samples_max, a_star), np.full_like(L_samples_max, b_star)))
|
|
279
|
+
rgb_max = Lab_to_sRGB(lab_max)
|
|
280
|
+
in_gamut_max = np.all((rgb_max >= 0) & (rgb_max <= 1), axis=1)
|
|
281
|
+
L_max_valid = L_samples_max[in_gamut_max].max() if np.any(in_gamut_max) else L_max_approx
|
|
282
|
+
else:
|
|
283
|
+
L_min_valid = None
|
|
284
|
+
L_max_valid = None
|
|
285
|
+
|
|
286
|
+
return L_min_valid, L_max_valid
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def rgb_renormalized_lightness(
|
|
290
|
+
lab_colors: np.ndarray,
|
|
291
|
+
population_size: int = 100,
|
|
292
|
+
num_generations: int = 200,
|
|
293
|
+
verbose: bool = False,
|
|
294
|
+
) -> np.ndarray:
|
|
295
|
+
|
|
296
|
+
# Compute L_min and L_max for each color
|
|
297
|
+
n_colors = lab_colors.shape[0]
|
|
298
|
+
L_min = np.zeros(n_colors)
|
|
299
|
+
L_max = L_min.copy()
|
|
300
|
+
|
|
301
|
+
for i in range(n_colors):
|
|
302
|
+
a_star = lab_colors[i, 1]
|
|
303
|
+
b_star = lab_colors[i, 2]
|
|
304
|
+
# Compute L_min[i] and L_max[i] for this (a*, b*) pair
|
|
305
|
+
L_min[i], L_max[i] = find_valid_L_range(a_star, b_star)
|
|
306
|
+
|
|
307
|
+
L_intended = lab_colors[:, 0]
|
|
308
|
+
|
|
309
|
+
# Optimize m and c
|
|
310
|
+
gene_limits = np.array([[0.0, 100.0], [-100.0, 100.0]])
|
|
311
|
+
genes, best_fitness = genetic_algorithm(population_size, num_generations, gene_limits, L_intended, L_min, L_max)
|
|
312
|
+
|
|
313
|
+
if best_fitness == 0.0:
|
|
314
|
+
print("Optimization failed for chosen lightness profile.")
|
|
315
|
+
print("Colormap is not perfectly perceptually uniform.")
|
|
316
|
+
return Lab_to_sRGB(lab_colors), 1.0, 0.0
|
|
317
|
+
|
|
318
|
+
if verbose:
|
|
319
|
+
print(f"Optimized colormap lightness range: {100 * best_fitness:.2f} %")
|
|
320
|
+
|
|
321
|
+
L_adjusted = L_intended * genes[0] + genes[1]
|
|
322
|
+
|
|
323
|
+
# Update L* values in lab_colors
|
|
324
|
+
lab_colors[:, 0] = L_adjusted
|
|
325
|
+
|
|
326
|
+
# Convert adjusted Lab colors to RGB
|
|
327
|
+
return Lab_to_sRGB(lab_colors)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def plot_colormap(colormap: LinearSegmentedColormap, num_points: int = 1000):
|
|
331
|
+
gradient = np.linspace(0, 1, num_points)
|
|
332
|
+
gradient = np.vstack((gradient, gradient))
|
|
333
|
+
|
|
334
|
+
# Get RGB values from colormap
|
|
335
|
+
gradient_rgb = colormap(gradient)
|
|
336
|
+
|
|
337
|
+
# Convert the RGB values to grayscale
|
|
338
|
+
lightness = rgb_to_grayscale_lightness(gradient_rgb[0][:, :3])
|
|
339
|
+
luminance = rgb_to_grayscale(gradient_rgb[0][:, :3])
|
|
340
|
+
gradient_gray = np.vstack((lightness, lightness))
|
|
341
|
+
|
|
342
|
+
# Plotting
|
|
343
|
+
fig = plt.figure(figsize=(20, 10), constrained_layout=True)
|
|
344
|
+
gs = fig.add_gridspec(3, 2)
|
|
345
|
+
|
|
346
|
+
# Define the axes
|
|
347
|
+
ax0 = fig.add_subplot(gs[0, 0]) # Top-left subplot
|
|
348
|
+
ax1 = fig.add_subplot(gs[1, 0]) # Mid-left subplot
|
|
349
|
+
ax2 = fig.add_subplot(gs[2, 0]) # Bottom-left subplot
|
|
350
|
+
ax3 = fig.add_subplot(gs[:, 1]) # Right subplot spanning all rows
|
|
351
|
+
|
|
352
|
+
# Plot RGB gradient on ax0
|
|
353
|
+
ax0.imshow(gradient_rgb, aspect="auto", vmin=0.0, vmax=1.0)
|
|
354
|
+
ax0.set_title("RGB gradient", fontsize=20)
|
|
355
|
+
ax0.axis("off")
|
|
356
|
+
|
|
357
|
+
# Plot Grayscale gradient on ax1
|
|
358
|
+
ax1.imshow(gradient_gray, cmap="gray", aspect="auto", vmin=0.0, vmax=1.0)
|
|
359
|
+
ax1.set_title("Lightness gradient", fontsize=20)
|
|
360
|
+
ax1.axis("off")
|
|
361
|
+
|
|
362
|
+
test_image_path = get_test_img_path()
|
|
363
|
+
colormap_test_img_path = test_image_path / "colourmaptest.tif"
|
|
364
|
+
colormap_test_image = plt.imread(colormap_test_img_path)
|
|
365
|
+
ax2.imshow(colormap_test_image, cmap=colormap)
|
|
366
|
+
ax2.set_title("Colormap test image", fontsize=20)
|
|
367
|
+
ax2.axis("off")
|
|
368
|
+
|
|
369
|
+
# Plot the color channels and luminance on ax2
|
|
370
|
+
for c, color_string in zip(range(3), ["red", "green", "blue"]):
|
|
371
|
+
ax3.plot(gradient_rgb[0, :, c], color=color_string, label=color_string, linewidth=4)
|
|
372
|
+
|
|
373
|
+
ax3.plot(luminance, color="black", linestyle="-.", label="luminance")
|
|
374
|
+
ax3.plot(lightness, color="grey", label="lightness", linewidth=4)
|
|
375
|
+
ax3.set_title("Color channel intensities", fontsize=20)
|
|
376
|
+
ax3.legend(loc=0, fontsize=20)
|
|
377
|
+
plt.show()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import matplotlib.colors as mcolors
|
|
2
|
+
from matplotlib import pyplot as plt
|
|
3
|
+
from matplotlib import colormaps
|
|
4
|
+
|
|
5
|
+
import os, sys
|
|
6
|
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Tuple, List
|
|
10
|
+
import argparse
|
|
11
|
+
import cv2
|
|
12
|
+
from scipy.ndimage import gaussian_filter1d
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from perfect_cmaps.color_utils import *
|
|
16
|
+
from perfect_cmaps.storage import load_data, get_test_img_path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_colormap(
|
|
20
|
+
cmap_name: str,
|
|
21
|
+
n: int = 100,
|
|
22
|
+
ijk: Tuple = None,
|
|
23
|
+
lightness: str = None,
|
|
24
|
+
interpolation: str = "quadratic",
|
|
25
|
+
smoothing: float = None
|
|
26
|
+
) -> mcolors.LinearSegmentedColormap:
|
|
27
|
+
|
|
28
|
+
"""Function for getting custom colormaps.
|
|
29
|
+
Two algorithmically generated colormaps are currently available:
|
|
30
|
+
- 'cold_blooded' a.k.a 'ectotherm'
|
|
31
|
+
- 'copper_salt'
|
|
32
|
+
The 'cold_blooded' map also has variants with completely linear lightness:
|
|
33
|
+
- 'cold_blooded_l', or 'ectotherm_l'.
|
|
34
|
+
|
|
35
|
+
Additionally, several control points in Lab format are also available in the "lab_control_points" folder.
|
|
36
|
+
These are custom generated through the script 'create_custom_cmap', and consist of a collection
|
|
37
|
+
points from 'a' and 'b' channels in Lab, along with a lightness profile.
|
|
38
|
+
These points are used in an optimization task, which optimizes the envelope for maximal expressiveness.
|
|
39
|
+
|
|
40
|
+
You can change the lightness profile in this script by choosing a lightness profile,
|
|
41
|
+
but note that the optimization task might not be feasible for certain lightness profiles.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cmap_name (str): The name of the colormap, either from the control points folder,
|
|
45
|
+
or the two algorithmically generated colormaps.
|
|
46
|
+
n (int): Number of points in the colormap sequence.
|
|
47
|
+
ijk (Tuple, optional): Order of the channels for the two algorithmic colormaps.
|
|
48
|
+
Typically you would choose a permutation of (0, 1, 2), such as (2, 1, 0).
|
|
49
|
+
Defaults to None, which amounts to the default permutation of the colormaps.
|
|
50
|
+
lightness (str, optional): Lightness profile - only applies for the Lab
|
|
51
|
+
control points. Defaults to None, which means the lightness profile
|
|
52
|
+
the chosen colormap was saved with.
|
|
53
|
+
interpolation (str): Interpolation method for L values. Defaults to 'quadratic.'
|
|
54
|
+
smoothing (float): Gaussian smoothing of colormap in Lab space,
|
|
55
|
+
applied after interpolation. Defaults to None.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
mcolors.LinearSegmentedColormap: Matplotlib-formatted colormap
|
|
59
|
+
"""
|
|
60
|
+
space = np.linspace(0, 1, n)
|
|
61
|
+
|
|
62
|
+
if not cmap_name in CMAP_DICT.keys():
|
|
63
|
+
json_data = load_data(cmap_name)
|
|
64
|
+
control_points = np.array(json_data["points"])
|
|
65
|
+
|
|
66
|
+
if lightness is None:
|
|
67
|
+
lightness = json_data["lightness"]
|
|
68
|
+
else:
|
|
69
|
+
assert lightness in SUPPORTED_L_PROFILES, f"Lightness profile {lightness} not supported"
|
|
70
|
+
|
|
71
|
+
interpolated_values = interpolate_lab(control_points, n, lightness, interpolation)
|
|
72
|
+
if smoothing is not None and smoothing > 0.0:
|
|
73
|
+
sigma = smoothing * n
|
|
74
|
+
interpolated_values[:, 1:] = gaussian_filter1d(interpolated_values[:, 1:], sigma=sigma, axis=0)
|
|
75
|
+
|
|
76
|
+
rgb_values = rgb_renormalized_lightness(interpolated_values, 500, 500)
|
|
77
|
+
|
|
78
|
+
else:
|
|
79
|
+
color_function = CMAP_DICT[cmap_name]
|
|
80
|
+
rgb_values = color_function(space, ijk)
|
|
81
|
+
reoptimize_map = False
|
|
82
|
+
|
|
83
|
+
if color_function in (cold_blooded_l, cold_blooded) and lightness == "linear":
|
|
84
|
+
reoptimize_map = False
|
|
85
|
+
|
|
86
|
+
elif lightness is not None:
|
|
87
|
+
reoptimize_map = True
|
|
88
|
+
|
|
89
|
+
if reoptimize_map:
|
|
90
|
+
lab_values = XYZ_to_Lab(sRGB_to_XYZ(rgb_values[:, :3]))
|
|
91
|
+
lab_values[:, 0] = get_lightness_profile(n, lightness)
|
|
92
|
+
rgb_values, _, _ = rgb_renormalized_lightness(lab_values, 200, 500)
|
|
93
|
+
|
|
94
|
+
cdict = dict()
|
|
95
|
+
|
|
96
|
+
for num, col in enumerate(["red", "green", "blue"]):
|
|
97
|
+
col_list = [[space[i], rgb_values[i][num], rgb_values[i][num]] for i in range(n)]
|
|
98
|
+
cdict[col] = col_list
|
|
99
|
+
cmp = mcolors.LinearSegmentedColormap(cmap_name, segmentdata=cdict, N=n)
|
|
100
|
+
return cmp
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def plot_images_with_colormap(image_paths: List[Path], colormap='viridis'):
|
|
104
|
+
# Create a figure with two rows, one for greyscale and one for colormap images
|
|
105
|
+
num_images = len(image_paths)
|
|
106
|
+
|
|
107
|
+
fig, axs = plt.subplots(2, num_images, figsize=(25, 10))
|
|
108
|
+
axs[0, 0].set_ylabel("Grayscale", fontsize=20)
|
|
109
|
+
axs[1, 0].set_ylabel("Custom colormap", fontsize=20)
|
|
110
|
+
|
|
111
|
+
for idx, img_path in enumerate(image_paths):
|
|
112
|
+
# Load the image in greyscale
|
|
113
|
+
img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
|
|
114
|
+
img = img[:int(img.shape[0] * 0.94)]
|
|
115
|
+
|
|
116
|
+
# Display the greyscale image
|
|
117
|
+
axs[0, idx].imshow(img, cmap='gray', vmin=0, vmax=255)
|
|
118
|
+
|
|
119
|
+
# Display the image with the specified colormap
|
|
120
|
+
axs[1, idx].imshow(img, cmap=colormap)
|
|
121
|
+
axs[0, idx].set_xticks([])
|
|
122
|
+
axs[0, idx].set_yticks([])
|
|
123
|
+
axs[1, idx].set_xticks([])
|
|
124
|
+
axs[1, idx].set_yticks([])
|
|
125
|
+
|
|
126
|
+
plt.tight_layout()
|
|
127
|
+
plt.show()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_args():
|
|
131
|
+
arg_parser = argparse.ArgumentParser(add_help=False)
|
|
132
|
+
arg_parser.add_argument(
|
|
133
|
+
"--colormap",
|
|
134
|
+
"-c",
|
|
135
|
+
type=str,
|
|
136
|
+
default="ectotherm_l",
|
|
137
|
+
help="The name of the colormap to be loaded"
|
|
138
|
+
)
|
|
139
|
+
arg_parser.add_argument(
|
|
140
|
+
"--num_points",
|
|
141
|
+
"-n",
|
|
142
|
+
type=int,
|
|
143
|
+
default=1000,
|
|
144
|
+
help="Number of points in the colormap spectrum"
|
|
145
|
+
)
|
|
146
|
+
arg_parser.add_argument(
|
|
147
|
+
"--lightness",
|
|
148
|
+
"-l",
|
|
149
|
+
type=str,
|
|
150
|
+
default=None,
|
|
151
|
+
help="The lightness profile of the colormap. Allowed values are: " +
|
|
152
|
+
"linear, diverging, diverging_inverted, flat."
|
|
153
|
+
)
|
|
154
|
+
arg_parser.add_argument(
|
|
155
|
+
"--interpolation",
|
|
156
|
+
"-i",
|
|
157
|
+
type=str,
|
|
158
|
+
default="quadratic",
|
|
159
|
+
help="Interpolation method for Lab control points"
|
|
160
|
+
)
|
|
161
|
+
arg_parser.add_argument(
|
|
162
|
+
"--ijk",
|
|
163
|
+
type=int,
|
|
164
|
+
nargs='+',
|
|
165
|
+
default=(0, 1, 2),
|
|
166
|
+
help="Channel permutation for algorithmically generated colormaps"
|
|
167
|
+
)
|
|
168
|
+
arg_parser.add_argument(
|
|
169
|
+
"--smoothing",
|
|
170
|
+
"-s",
|
|
171
|
+
type=float,
|
|
172
|
+
default=None,
|
|
173
|
+
help="Gaussian smoothing of colormap in Lab space, applied after interpolation"
|
|
174
|
+
)
|
|
175
|
+
arg_parser.add_argument(
|
|
176
|
+
"--mpl_cmap",
|
|
177
|
+
"-mpl",
|
|
178
|
+
action="store_true",
|
|
179
|
+
default=False,
|
|
180
|
+
help="If specified, looks for a matplotlib colormap instead of one made with this library"
|
|
181
|
+
)
|
|
182
|
+
arg_parser.add_argument(
|
|
183
|
+
"--help",
|
|
184
|
+
"-h",
|
|
185
|
+
action="help",
|
|
186
|
+
default=argparse.SUPPRESS,
|
|
187
|
+
help="Python script for colormap handling with perceptually uniform colormaps. " +
|
|
188
|
+
"Run as a standalone script or import and use the function 'get_colormap' in your own code."
|
|
189
|
+
)
|
|
190
|
+
return arg_parser.parse_args()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
CMAP_DICT = {
|
|
194
|
+
"cold_blooded": cold_blooded,
|
|
195
|
+
"cold_blooded_l": cold_blooded_l,
|
|
196
|
+
"ectotherm": cold_blooded,
|
|
197
|
+
"ectotherm_l": cold_blooded_l,
|
|
198
|
+
"copper_salt": copper_salt
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
"""
|
|
203
|
+
If used as a main script, you can try colormaps out on test images,
|
|
204
|
+
and inspect the RGB channel profile of the colormap along with lightness and luminance.
|
|
205
|
+
"""
|
|
206
|
+
def main():
|
|
207
|
+
args = parse_args()
|
|
208
|
+
|
|
209
|
+
if args.mpl_cmap:
|
|
210
|
+
cmap = colormaps[args.colormap]
|
|
211
|
+
else:
|
|
212
|
+
cmap = get_colormap(
|
|
213
|
+
args.colormap,
|
|
214
|
+
args.num_points,
|
|
215
|
+
ijk=args.ijk,
|
|
216
|
+
lightness=args.lightness,
|
|
217
|
+
interpolation=args.interpolation,
|
|
218
|
+
smoothing=args.smoothing
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
plot_colormap(cmap, args.num_points)
|
|
222
|
+
|
|
223
|
+
test_images = [
|
|
224
|
+
"charcoal4_49721153188_o-scaled.jpg",
|
|
225
|
+
"Lily-Pollen.png",
|
|
226
|
+
"melamine-foam_49722002717_o-scaled.jpg",
|
|
227
|
+
"PhenomPharos-Gallery_Pyrite.jpg",
|
|
228
|
+
"PhenomXLGallery_5.jpg",
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
test_image_path = get_test_img_path()
|
|
232
|
+
test_images = [test_image_path / "sem" / img_name for img_name in test_images]
|
|
233
|
+
plot_images_with_colormap(test_images, cmap)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
main()
|