perfect-cmaps 1.0.5__tar.gz

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.
Files changed (43) hide show
  1. perfect_cmaps-1.0.5/MANIFEST.in +6 -0
  2. perfect_cmaps-1.0.5/PKG-INFO +60 -0
  3. perfect_cmaps-1.0.5/README.md +45 -0
  4. perfect_cmaps-1.0.5/perfect_cmaps/__init__.py +6 -0
  5. perfect_cmaps-1.0.5/perfect_cmaps/color_utils.py +377 -0
  6. perfect_cmaps-1.0.5/perfect_cmaps/colormaps.py +237 -0
  7. perfect_cmaps-1.0.5/perfect_cmaps/customize_cmap.py +223 -0
  8. perfect_cmaps-1.0.5/perfect_cmaps/data/cielab_gamut_max_L.npy +0 -0
  9. perfect_cmaps-1.0.5/perfect_cmaps/data/cielab_gamut_max_L_RGB.npy +0 -0
  10. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/README.md +5 -0
  11. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/__init__.py +0 -0
  12. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/__pycache__/__init__.cpython-38.pyc +0 -0
  13. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/cividish.json +209 -0
  14. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/diverging_hot_cool.json +89 -0
  15. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/mcyk.json +25 -0
  16. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/plasmoid.json +209 -0
  17. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/rgb_test.json +85 -0
  18. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/rgb_test2.json +85 -0
  19. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/salmiak.json +25 -0
  20. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/spectrum.json +209 -0
  21. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/viridish.json +89 -0
  22. perfect_cmaps-1.0.5/perfect_cmaps/lab_control_points/viridish_2.json +209 -0
  23. perfect_cmaps-1.0.5/perfect_cmaps/optimization.py +114 -0
  24. perfect_cmaps-1.0.5/perfect_cmaps/storage.py +65 -0
  25. perfect_cmaps-1.0.5/perfect_cmaps/test_images/README.md +6 -0
  26. perfect_cmaps-1.0.5/perfect_cmaps/test_images/__init__.py +0 -0
  27. perfect_cmaps-1.0.5/perfect_cmaps/test_images/__pycache__/__init__.cpython-38.pyc +0 -0
  28. perfect_cmaps-1.0.5/perfect_cmaps/test_images/colourmaptest.tif +0 -0
  29. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/BSD-sandpaper.jpg +0 -0
  30. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/Lily-Pollen.png +0 -0
  31. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/PhenomPharos-Gallery_Pyrite.jpg +0 -0
  32. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/PhenomXLGallery_5.jpg +0 -0
  33. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/Steel-mesh.png +0 -0
  34. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/charcoal4_49721153188_o-scaled.jpg +0 -0
  35. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/graphite5_49722003087_o-scaled.jpg +0 -0
  36. perfect_cmaps-1.0.5/perfect_cmaps/test_images/sem/melamine-foam_49722002717_o-scaled.jpg +0 -0
  37. perfect_cmaps-1.0.5/perfect_cmaps.egg-info/PKG-INFO +60 -0
  38. perfect_cmaps-1.0.5/perfect_cmaps.egg-info/SOURCES.txt +41 -0
  39. perfect_cmaps-1.0.5/perfect_cmaps.egg-info/dependency_links.txt +1 -0
  40. perfect_cmaps-1.0.5/perfect_cmaps.egg-info/requires.txt +8 -0
  41. perfect_cmaps-1.0.5/perfect_cmaps.egg-info/top_level.txt +1 -0
  42. perfect_cmaps-1.0.5/setup.cfg +4 -0
  43. perfect_cmaps-1.0.5/setup.py +37 -0
@@ -0,0 +1,6 @@
1
+ recursive-include perfect_cmaps/data *
2
+ recursive-include perfect_cmaps/test_images *
3
+ recursive-include perfect_cmaps/lab_control_points *
4
+
5
+ exclude assets/*
6
+ exclude __pycache__/*
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.1
2
+ Name: perfect_cmaps
3
+ Version: 1.0.5
4
+ Summary: Create amazing perceptually uniform colormaps with ease!
5
+ Home-page: https://github.com/m-ulmestrand/perfect-cmaps
6
+ Author: Mattias Ulmestrand
7
+ Author-email: m.ulmestrand@gmail.com
8
+ License: UNKNOWN
9
+ Description: # perfect-cmaps
10
+ This repository provides Python functionality for creating custom colormaps which are perfectly perceptually uniform.
11
+ The project came up as a result of there being too few easily accessible perceptually uniform colormaps, so I wanted to have a go at creating a good library where users can easily create their own. I wasn't quite happy with colormaps such as `viridis`, `plasma` or `magma` from `matplotlib`, so I started making some algorithmic colormaps.
12
+
13
+ To stay as true as possible to the lightness achieved by a true grayscale colormap, but add more information with colors, the perceptual range of colormaps is optimized to be as high as possible.
14
+
15
+ ### Example of creating a custom colormap
16
+ run: `python ./perfect_cmaps/customize_cmap.py --num_points 21 --lightness linear` or a similar command
17
+
18
+ This specifies that your colormap will have 21 control points, which will later be interpolated for desired granularity, and that the colormap will have a linear lightness profile.
19
+
20
+ https://github.com/user-attachments/assets/a63d0c31-d799-49d0-a2cd-30c0b423378f
21
+
22
+ ### Generated colormap
23
+ To test out your colormap, you can use the function `get_colormap()` or use the main function from `colormaps.py`:
24
+
25
+ `python ./perfect_cmaps/colormaps.py --colormap test_colormap -n 1000 --interpolation quadratic --smoothing 0.03`
26
+
27
+ Here' the colormap was named `test_colormap`, and we specify the colormap to have 1000 points, with quadratic interpolation and some amount of smoothing applied.
28
+
29
+ ![Generated colormap](assets/test_colormap.png)
30
+
31
+ ### Test images
32
+ ![Test images](assets/test_colormap_images.png)
33
+
34
+ Here's a compilation of some colormaps generated with this library, compared with some standard colormaps available in `matplotlib`:
35
+ ![Comparison](assets/cmap_comparison.png)
36
+
37
+ The top two colormaps, `viridis` and `cividis` have a lower perceptual range, and thus look less clear -- almost like there's a filter on them. The colormaps generated with `perfect-cmaps` are crisper and offer a maximal perceptual range. Additionally, the perceptual limitation for the standard colormaps is especially visible when for example a background in grayscale is very white, in which case the standard colormaps are in this case yellow and clearly do not cover the entire lightness spectrum.
38
+
39
+ ![Comparison2](assets/cmap_comparison2.png)
40
+
41
+ The algorithmically created `ectotherm` colormap in fact covers 100 % of the perceptual range, while adding more information through colors. It ranges from completely black to completely white, while adding colors in between and maintaining truly linearly increasing lightness. This is one of my favorites! The other two displayed custom colormaps mimic `viridis` and `cividis`, but with a higher perceptual range. The standard `ectotherm` colormap is linear in luminance, whereas the `ectotherm_l` colormap is linear in lightness. I recommend the `ectotherm_l` colormap for general use.
42
+
43
+ ![Ectotherm gradient](assets/ectotherm_gradient.png)
44
+ ![Ectotherm examples](assets/ectotherm.png)
45
+
46
+ You may have heard that standard RGB colormaps should be avoided. Well, common RGB colormaps are indeed not at all perceptually uniform, but if you design them right, you can still have a useful colormap with much color information, perceptual range and true perceptual uniformity.
47
+
48
+ ![Spectrum gradient](assets/spectrum_gradient.png)
49
+ ![Spectrum examples](assets/spectrum.png)
50
+
51
+ Thanks to *Nanoscience.com* for letting me use the grayscale images as examples! Find them at https://www.nanoscience.com/techniques/scanning-electron-microscopy/.
52
+
53
+ These custom generated colormaps are theoretically perceptually uniform, but note that some screen settings may show colormap artifacts, making the colormaps appear less perceptually uniform. This may happen at lower computer screen lighting, for example.
54
+
55
+ Platform: UNKNOWN
56
+ Classifier: Programming Language :: Python :: 3
57
+ Classifier: License :: OSI Approved :: Apache Software License
58
+ Classifier: Operating System :: OS Independent
59
+ Requires-Python: >=3.6
60
+ Description-Content-Type: text/markdown
@@ -0,0 +1,45 @@
1
+ # perfect-cmaps
2
+ This repository provides Python functionality for creating custom colormaps which are perfectly perceptually uniform.
3
+ The project came up as a result of there being too few easily accessible perceptually uniform colormaps, so I wanted to have a go at creating a good library where users can easily create their own. I wasn't quite happy with colormaps such as `viridis`, `plasma` or `magma` from `matplotlib`, so I started making some algorithmic colormaps.
4
+
5
+ To stay as true as possible to the lightness achieved by a true grayscale colormap, but add more information with colors, the perceptual range of colormaps is optimized to be as high as possible.
6
+
7
+ ### Example of creating a custom colormap
8
+ run: `python ./perfect_cmaps/customize_cmap.py --num_points 21 --lightness linear` or a similar command
9
+
10
+ This specifies that your colormap will have 21 control points, which will later be interpolated for desired granularity, and that the colormap will have a linear lightness profile.
11
+
12
+ https://github.com/user-attachments/assets/a63d0c31-d799-49d0-a2cd-30c0b423378f
13
+
14
+ ### Generated colormap
15
+ To test out your colormap, you can use the function `get_colormap()` or use the main function from `colormaps.py`:
16
+
17
+ `python ./perfect_cmaps/colormaps.py --colormap test_colormap -n 1000 --interpolation quadratic --smoothing 0.03`
18
+
19
+ Here' the colormap was named `test_colormap`, and we specify the colormap to have 1000 points, with quadratic interpolation and some amount of smoothing applied.
20
+
21
+ ![Generated colormap](assets/test_colormap.png)
22
+
23
+ ### Test images
24
+ ![Test images](assets/test_colormap_images.png)
25
+
26
+ Here's a compilation of some colormaps generated with this library, compared with some standard colormaps available in `matplotlib`:
27
+ ![Comparison](assets/cmap_comparison.png)
28
+
29
+ The top two colormaps, `viridis` and `cividis` have a lower perceptual range, and thus look less clear -- almost like there's a filter on them. The colormaps generated with `perfect-cmaps` are crisper and offer a maximal perceptual range. Additionally, the perceptual limitation for the standard colormaps is especially visible when for example a background in grayscale is very white, in which case the standard colormaps are in this case yellow and clearly do not cover the entire lightness spectrum.
30
+
31
+ ![Comparison2](assets/cmap_comparison2.png)
32
+
33
+ The algorithmically created `ectotherm` colormap in fact covers 100 % of the perceptual range, while adding more information through colors. It ranges from completely black to completely white, while adding colors in between and maintaining truly linearly increasing lightness. This is one of my favorites! The other two displayed custom colormaps mimic `viridis` and `cividis`, but with a higher perceptual range. The standard `ectotherm` colormap is linear in luminance, whereas the `ectotherm_l` colormap is linear in lightness. I recommend the `ectotherm_l` colormap for general use.
34
+
35
+ ![Ectotherm gradient](assets/ectotherm_gradient.png)
36
+ ![Ectotherm examples](assets/ectotherm.png)
37
+
38
+ You may have heard that standard RGB colormaps should be avoided. Well, common RGB colormaps are indeed not at all perceptually uniform, but if you design them right, you can still have a useful colormap with much color information, perceptual range and true perceptual uniformity.
39
+
40
+ ![Spectrum gradient](assets/spectrum_gradient.png)
41
+ ![Spectrum examples](assets/spectrum.png)
42
+
43
+ Thanks to *Nanoscience.com* for letting me use the grayscale images as examples! Find them at https://www.nanoscience.com/techniques/scanning-electron-microscopy/.
44
+
45
+ These custom generated colormaps are theoretically perceptually uniform, but note that some screen settings may show colormap artifacts, making the colormaps appear less perceptually uniform. This may happen at lower computer screen lighting, for example.
@@ -0,0 +1,6 @@
1
+ from .colormaps import get_colormap
2
+ from .customize_cmap import create_custom_colormap
3
+
4
+
5
+ __all__ = ["get_colormap", "create_custom_colormap"]
6
+ __version__ = "1.0.5"
@@ -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()