perfect-cmaps 1.0.0__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.
- perfect_cmaps-1.0.0/MANIFEST.in +6 -0
- perfect_cmaps-1.0.0/PKG-INFO +60 -0
- perfect_cmaps-1.0.0/README.md +45 -0
- perfect_cmaps-1.0.0/perfect_cmaps/__init__.py +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/color_utils.py +377 -0
- perfect_cmaps-1.0.0/perfect_cmaps/colormaps.py +237 -0
- perfect_cmaps-1.0.0/perfect_cmaps/customize_cmap.py +223 -0
- perfect_cmaps-1.0.0/perfect_cmaps/data/cielab_gamut_max_L.npy +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/data/cielab_gamut_max_L_RGB.npy +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/README.md +5 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/__init__.py +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/__pycache__/__init__.cpython-38.pyc +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/cividish.json +209 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/diverging_hot_cool.json +89 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/mcyk.json +25 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/plasmoid.json +209 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/rgb_test.json +85 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/rgb_test2.json +85 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/salmiak.json +25 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/spectrum.json +209 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/viridish.json +89 -0
- perfect_cmaps-1.0.0/perfect_cmaps/lab_control_points/viridish_2.json +209 -0
- perfect_cmaps-1.0.0/perfect_cmaps/optimization.py +114 -0
- perfect_cmaps-1.0.0/perfect_cmaps/storage.py +65 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/README.md +6 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/__init__.py +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/__pycache__/__init__.cpython-38.pyc +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/colourmaptest.tif +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/BSD-sandpaper.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/Lily-Pollen.png +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/PhenomPharos-Gallery_Pyrite.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/PhenomXLGallery_5.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/Steel-mesh.png +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/charcoal4_49721153188_o-scaled.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/graphite5_49722003087_o-scaled.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps/test_images/sem/melamine-foam_49722002717_o-scaled.jpg +0 -0
- perfect_cmaps-1.0.0/perfect_cmaps.egg-info/PKG-INFO +60 -0
- perfect_cmaps-1.0.0/perfect_cmaps.egg-info/SOURCES.txt +41 -0
- perfect_cmaps-1.0.0/perfect_cmaps.egg-info/dependency_links.txt +1 -0
- perfect_cmaps-1.0.0/perfect_cmaps.egg-info/requires.txt +8 -0
- perfect_cmaps-1.0.0/perfect_cmaps.egg-info/top_level.txt +1 -0
- perfect_cmaps-1.0.0/setup.cfg +4 -0
- perfect_cmaps-1.0.0/setup.py +30 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: perfect_cmaps
|
|
3
|
+
Version: 1.0.0
|
|
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
|
+

|
|
30
|
+
|
|
31
|
+
### Test images
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
Here's a compilation of some colormaps generated with this library, compared with some standard colormaps available in `matplotlib`:
|
|
35
|
+

|
|
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
|
+

|
|
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
|
+

|
|
44
|
+

|
|
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
|
+

|
|
49
|
+

|
|
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
|
+

|
|
22
|
+
|
|
23
|
+
### Test images
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+
Here's a compilation of some colormaps generated with this library, compared with some standard colormaps available in `matplotlib`:
|
|
27
|
+

|
|
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
|
+

|
|
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
|
+

|
|
36
|
+

|
|
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
|
+

|
|
41
|
+

|
|
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.
|
|
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()
|