Coverage for src/colorspace/utils.py: 100%
276 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-29 15:11 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-10-29 15:11 +0000
2def mixcolor(alpha, color1, color2, where):
3 """Compute the Convex Combination of Two Colors
5 This function can be used to compute the result of color mixing, assuming
6 additive mixing (e.g., as appropriate for RGB and XYZ).
8 Args:
9 alpha (float): The mixed color is obtained by combining an amount
10 `1 - alpha` of `color1` with an amount `alpha` of `color2`.
11 color1: an object that can be converted into a
12 :py:class:`palette <colorspace.palettes.palette>`.
13 color2: a second object that can be converted into a
14 :py:class:`palette <colorspace.palettes.palette>`. Must have the same number
15 of colors as the argument on `color1`.
16 where (str): The color space where the mixing is to take place, either `"RGB"` or `"CIEXYZ"`.
18 Return:
19 colorspace.colorlib.*: Returns an object of the same class as either
20 `color1` with the new mixed color(s). Call `.swatchplot()` to check the
21 result or `.colors()` to get a list of mixed hex colors.
23 Examples:
24 >>> from colorspace.colorlib import RGB
25 >>> from colorspace.colorlib import hexcols
26 >>> from colorspace import *
27 >>>
28 >>> # Mixing two colors defined in the RGB space
29 >>> # via colorspace.colorlib.RGB. Mixing half-half
30 >>> # in the RGB color space (M1) and in the HCL space (M2).
31 >>> RGB_1 = RGB(R = 1, G = 0, B = 0)
32 >>> RGB_2 = RGB(R = 0, G = 1, B = 0)
33 >>> RGB_M1 = mixcolor(0.5, RGB_1, RGB_2, "sRGB")
34 >>> RGB_M1
35 >>> #: Mixing via XYZ color space
36 >>> RGB_M2 = mixcolor(0.5, RGB_1, RGB_2, "CIEXYZ")
37 >>> RGB_M2
38 >>>
39 >>> #: Mixing two lists of hex-colors of length 5.
40 >>> # Mixing takes place once in the RGB color space (M1)
41 >>> # and once in the HCL color space (M2)
42 >>> HEX_1 = diverging_hcl()(5)
43 >>> HEX_2 = diverging_hcl(rev = True)(5)
44 >>> HEX_M1 = mixcolor(0.2, HEX_1, HEX_2, "sRGB")
45 >>> HEX_M1
46 >>>
47 >>> #: Mixing via XYZ color space
48 >>> HEX_M2 = mixcolor(0.8, HEX_1, HEX_2, "CIEXYZ")
49 >>> HEX_M2
50 >>>
51 >>> #:
52 >>> swatchplot([HEX_1, HEX_2, HEX_M1, HEX_M2],
53 >>> show_names = False, figsize = (5.5, 1));
54 >>>
55 >>> #: Mixing objects of different length and type
56 >>> # Coordinates of the shorter object (RGB_1) will be recycled
57 >>> # to the same number of colors as in the longer object (HEX_2)
58 >>> RES_1 = mixcolor(0.2, RGB_1, HEX_2, "sRGB")
59 >>> RES_1.colors()
60 >>>
61 >>> #:
62 >>> RES_2 = mixcolor(0.8, RGB_1, HEX_2, "sRGB")
63 >>> RES_2.colors()
64 >>>
65 >>> #:
66 >>> swatchplot([RGB_1, RES_2, HEX_2, RES_1, RES_2],
67 >>> show_names = False, figsize = (5.5, 2));
69 Raises:
70 TypeError: In case `alpha` is not float or `int`.
71 ValueError: If `alpha` is not larger than `0.0` and smaller than `1.0`.
72 TypeError: If `where` is not a str.
73 ValueError: If `where` is not among the allowed color spaces used for adaptive mixing.
74 Exception: If `color1` or `color2` cannot be converted into a palette object.
75 """
77 from numpy import resize
78 from colorspace.colorlib import colorobject, hexcols
79 from colorspace.palettes import palette
81 if not isinstance(alpha, (float, int)):
82 raise TypeError("argument `alpha` must be float or int")
83 if isinstance(alpha, int): alpha = float(alpha)
84 if alpha < 0. or alpha > 1.:
85 raise ValueError("argument `alpha` must be in the range of [0., 1.]")
86 if not isinstance(where, str):
87 raise TypeError("argument `where` must be str")
89 # Allowed color types:
90 allowed_spaces = ["sRGB", "CIEXYZ"]
91 if not where in allowed_spaces:
92 raise ValueError(f"argument `{where}` none of the allowed types: {', '.join(allowed_spaces)}")
94 # Converting colors
95 try:
96 color1 = hexcols(palette(color1).colors())
97 except:
98 raise Exception("cannot convert object provided on `color1` into a `colorspace.palettes.palette`")
99 try:
100 color2 = hexcols(palette(color2).colors())
101 except:
102 raise Exception("cannot convert object provided on `color2` into a `colorspace.palettes.palette`")
104 # Convert and extract coordinates
105 color1.to(where)
106 color2.to(where)
107 coord1 = color1.get()
108 coord2 = color2.get()
110 # If length is not equal; recycle shorter color object
111 if len(color1) > len(color2):
112 for k in coord2:
113 if coord2[k] is None: continue
114 coord2[k] = resize(coord2[k], len(color1))
115 elif len(color1) < len(color2):
116 for k in coord1:
117 if coord1[k] is None: continue
118 coord1[k] = resize(coord1[k], len(color2))
120 # Mixing
121 res = dict()
122 for k in coord1:
123 if coord1[k] is None or coord2[k] is None: continue
124 res[k] = coord1[k] * (1. - alpha) + coord2[k] * alpha
126 import importlib
127 module = importlib.import_module("colorspace.colorlib")
128 FUN = getattr(module, where)
129 res = FUN(**res)
130 return res
134# --------------------------------------------------------------------
135# Performs the check on hex color str to see if they are valid.
136# --------------------------------------------------------------------
137def check_hex_colors(colors):
138 """Checking Hex Color Validity
140 Valid hex colors are three digit hex colors (e.g., `#F00`), six digit
141 hex colors (e.g., `#FF00FF`), or six digit colors with additional transparency
142 (eight digit representation) or `None`. If the inputs do not match one of these hex
143 representations `matplotlib.color.to_hex` will be called. This allows
144 to also convert standard colors such as `"0"`, `"black"`, or `"magenta"` into
145 their corresponding hex representation.
147 Args:
148 colors (str, list, numpy.ndarray): str or list of str with colors.
149 See function description for details. In case it is a
150 `numpy.ndarray` it will be flattened to 1-dimensional if needed.
152 Returns:
153 list: Returns a list (length 1 or more) in case all values provided are
154 valid hex colors or None. Three digit colors will be expanded to six
155 digit colors, all upper case. Else the function will raise a
156 ValueError.
158 Examples:
160 >>> from colorspace import check_hex_colors
161 >>> check_hex_colors("#ff003311")
162 >>> #:
163 >>> check_hex_colors("#ff0033")
164 >>> #:
165 >>> check_hex_colors("#f03")
166 >>> #:
167 >>> check_hex_colors(["#f0f", "#00F", "#00FFFF", "#ff003311"])
168 >>> #:
169 >>> check_hex_colors(["#FFF", "0", "black", "blue", "magenta"])
170 >>> #:
171 >>> check_hex_colors([None, "#ff0033", None])
172 >>>
173 >>> #:
174 >>> from numpy import asarray
175 >>> check_hex_colors(asarray(["#f0f", "#00F", "#00FFFF", "#ff003311"]))
177 Raises:
178 ValueError: In case `colors` is a list but does not only contain strnigs.
179 TypeError: If `colors` is neither str or list of str.
180 ValueError: If at least one of the colors is an invalid hex color.
181 """
182 from re import match, compile
183 from numpy import all, repeat, ndarray, nan, isnan
184 from .colorlib import colorobject
186 # Saniy checks
187 if isinstance(colors, str):
188 colors = [colors]
189 elif isinstance(colors, list):
190 if not all([isinstance(x, (str, type(None))) for x in colors]):
191 raise ValueError("list on argument `colors` must only contain str or None")
192 elif isinstance(colors, ndarray):
193 if not len(colors.shape) == 1:
194 raise TypeError("if an `numpy.ndarray` is provided on argument `colors` it must be 1-dimensional")
195 colors = colors.flatten().tolist()
196 elif isinstance(colors, colorobject):
197 colors = colors.colors()
198 else:
199 raise TypeError("argument `colors` none of the allowed types")
201 # Regular expression for checking for valid hex colors
202 pat = compile("^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?$")
204 # check individual entry. Also extends the color if needed.
205 def check(x, pat):
206 # If is none, leave it as None (from fixup = False)
207 if x is None: return x
209 # Check if str is of allowed type
210 tmp = pat.match(x)
212 # In case this is no hex definition (not matching the regular expression
213 # above) we try if we can convert the color via matplotlib.colors.to_hex.
214 # This allows to convert e.g., "0" or "black" into hex cols. If this works
215 # We once again shoot it trough our regular expression.
216 if not tmp:
217 if x[0] == "#":
218 raise ValueError(f"string \"{x}\" is not a valid 3/6/8 digit hex color")
219 try:
220 from matplotlib.colors import to_hex
221 x = to_hex(x)
222 except:
223 raise ValueError(f"string \"{x}\" could not be converted to valid hex color")
224 x = x.upper()
226 tmp = pat.match(x)
227 # Testing pattern matching (hex color validation)
228 if len(tmp.group(1)) == 3 and not tmp.group(2) == None:
229 raise ValueError(f"string \"{x}\" is no valid hex color")
230 # Three digit: extend
231 elif len(tmp.group(1)) == 3:
232 x = "#" + "".join(repeat([x for x in tmp.group(1)], 2))
234 return x.upper()
236 colors = [check(x, pat) for x in colors]
238 return colors
241# --------------------------------------------------------------------
242# Get transparency (or None if there is none defined)
243# --------------------------------------------------------------------
244def extract_transparency(x, mode = "float"):
245 """Extract Alpha Channel
247 Currently only for colorobjects. This function interfaces the
248 ``.get()`` method of the object.
250 Args:
251 x: an object which inherits from `colorsspace.colorlib.colorobject` or
252 an object of class `colorspace.palettes.palette`.
253 mode (str): mode of the return. One of `"float"`, `"int"`, or `"str"`.
255 Returns:
256 None, numpy.ndarray: `None` if the colorobject has no alpha channel,
257 else a numpy.ndarray. The `dtype` of the array depends
258 on the `mode` specified.
260 Raises:
261 TypeError: If input object does not inherit from `colorobject`.
262 TypeError: If 'mode' is not str.
263 ValueError: If 'mode' is not one of the allowed types shown in the arguments description.
265 Examples:
266 >>> from colorspace import *
267 >>> from colorspace.colorlib import hexcols
268 >>>
269 >>> # Three colors without alpha
270 >>> cols1 = ['#023FA5', '#E2E2E2', '#8E063B']
271 >>> # Same colors with transparency 80%, 40%, 80%
272 >>> cols2 = ['#023FA5CC', '#E2E2E266', '#8E063BCC']
273 >>>
274 >>> # Convert hex color lists to colorobjects
275 >>> x1 = hexcols(cols1)
276 >>> x2 = hexcols(cols2)
277 >>>
278 >>> # Extract transparency
279 >>> extract_transparency(x1)
280 >>> #:
281 >>> extract_transparency(x2)
282 >>>
283 >>> #: Return mode
284 >>> extract_transparency(x2, mode = "float")
285 >>> #:
286 >>> extract_transparency(x2, mode = "int")
287 >>> #:
288 >>> extract_transparency(x2, mode = "str")
289 >>>
290 >>> #: Extracting transparency from palette objects
291 >>> from colorspace import palette
292 >>> p1 = palette(cols1, name = "custom palette 1")
293 >>> p2 = palette(cols2, name = "custom palette 2")
294 >>>
295 >>> #: No return as colors in palette `p1` have no transparency
296 >>> extract_transparency(p1, mode = "str")
297 >>> #: Extracting transparency from colors in palette `p2`
298 >>> extract_transparency(p2, mode = "str")
299 """
301 from colorspace.palettes import palette
302 from colorspace.colorlib import colorobject
303 from numpy import asarray, int16
305 if not isinstance(x, (colorobject, palette)):
306 raise TypeError("argument `x` must inherit from `colorspace.colorlib.colorobject` or `colorspace.palettes.palette`")
307 if not isinstance(mode, str):
308 raise TypeError("argument `mode` must be a str")
309 if not mode in ["float", "int", "str"]:
310 raise ValueError("argument `mode` must be one of \"float\", \"int\", or \"str\"")
312 # Convert colorspace.palettes.palette to colorspace.colorlib.hexcols
313 if isinstance(x, palette):
314 from colorspace.colorlib import hexcols
315 x = hexcols(x.colors())
317 # Extract alpha dimension
318 alpha = x.get("alpha")
320 # If not none we have to convert it given input argument 'mode'.
321 # If mode == "float" we do not have to do anything, but for the other
322 # two options we do.
323 if not alpha is None:
324 if mode == "int":
325 alpha = asarray(alpha * 255, int16)
326 elif mode == "str":
327 alpha = asarray(["{:02X}".format(int(x * 255)) for x in alpha], dtype = "S2")
328 alpha = alpha.astype(str)
330 return alpha
333# --------------------------------------------------------------------
334# Remove or adjust transparency
335# --------------------------------------------------------------------
336def adjust_transparency(x, alpha):
337 """Adjust Alpha Channel
339 Allows to set, adjust, or remove transparency (alpha channel).
340 In case `alpha` is a single float, a constant
341 transparency will be added to all colors. If `alpha` is a list or `numpy.ndarray`
342 it must be the same length as the number of colors in the object `x` and all
343 values must be convertable to float/int in the range of `[0., 1.]`. Allows to
344 add individual transparency for each color in `x`.
346 Args:
347 x: sequence of colors; an object which inherits from colorsspace.colorlib.colorobject.
348 alpha (None, float, int, list, numpy.ndarray): ``None`` will remove existing
349 transparency (if existing). If `float`, `list`, or numpy.ndarray`
350 trnasparency will be added. See function description for more details.
352 Returns:
353 numpy.ndarray or None: None if the colorobject has no defined transparency,
354 else a numpy.ndarray is returned.
356 Examples:
357 >>> from colorspace import *
358 >>> from colorspace.colorlib import hexcols
359 >>> import numpy as np
360 >>>
361 >>> # Three colors without transparency
362 >>> cols1 = ['#023FA5', '#E2E2E2', '#8E063B']
363 >>> # Same colors as in `cols1` with transparency of 80%, 40%, 80%
364 >>> cols2 = ['#023FA5CC', '#E2E2E266', '#8E063BCC']
365 >>>
366 >>> # Converting list of hex colors `cols1` into `hexcolor` objects
367 >>> x1 = hexcols(cols1)
368 >>> x1
369 >>>
370 >>> #: Extract transparency
371 >>> extract_transparency(x1) # Returns 'None' (no transparency)
372 >>>
373 >>> #: `x1`: Setting constant transparency of 0.5 for all colors
374 >>> adjust_transparency(x1, 0.5)
375 >>>
376 >>> #: Setting custom transparency (adjusting; overwrite existing 0.5)
377 >>> adjust_transparency(x1, [0.7, 0.3, 0.7]) # Add transparency
378 >>>
379 >>> #: Converting list of hex colors `cols2` into `hexcolor` objects
380 >>> # and extract transparency defined via 8 digit hex color str
381 >>> x2 = hexcols(cols2)
382 >>> extract_transparency(x2)
383 >>>
384 >>> #: Removing transparency, extracting new values (None)
385 >>> x2 = adjust_transparency(x2, None)
386 >>> extract_transparency(x2) # Returns 'None' (no transparency)
387 >>>
388 >>> #: Adding transparency again
389 >>> x2 = adjust_transparency(x2, np.asarray([0.8, 0.4, 0.8]))
390 >>> x2
391 >>> #:
392 >>> extract_transparency(x2)
394 Raises:
395 TypeError: If input object does not inherit from `colorspace.colorlib.colorobject`.
396 TypeError: If `alpha` is not one of the expected types.
397 ValueError: If `alpha` is list or `numpy.ndarray` and does not match length
398 of colors in `x`.
399 ValueError: If `alpha` cannot be converted to float.
400 ValueError: If `alpha` is outside of range `[0., 1.]`.
401 """
403 import numpy as np
404 from colorspace.colorlib import colorobject
405 from copy import deepcopy
407 if not isinstance(x, colorobject):
408 raise TypeError("argument `x` must inherit from `colorspace.colorlib.colorobject`")
409 x = deepcopy(x)
410 # Checking the alpha object
411 if not isinstance(alpha, (type(None), list, float, int, np.ndarray)):
412 raise TypeError("unexpected input on argument `alpha`")
414 # Remove transparency as alpha was set to None
415 if isinstance(alpha, type(None)):
416 if "alpha" in x._data_.keys(): del x._data_["alpha"]
417 # Adding constant transparency to all colors
418 elif isinstance(alpha, (float, int)):
419 if alpha < 0 or alpha > 1:
420 raise ValueError("transparency (`alpha`) must be in the range of `[0., 1.]`")
421 x._data_["alpha"] = np.repeat(float(alpha), len(x))
422 # Using same procedure for lists and np.ndarrays.
423 elif isinstance(alpha, (list, np.ndarray)):
424 if not len(alpha) == len(x):
425 raise ValueError("lengt of `alpha` must match length of `x`")
426 try:
427 alpha = np.asarray(alpha, dtype = "float")
428 except:
429 raise ValueError("argument `alpha` cannot be converted to float")
430 # Check values
431 if np.any(alpha < 0) or np.any(alpha > 1):
432 raise ValueError("transparency (`alpha`) must be in the range of `[0., 1.]`")
433 x._data_["alpha"] = alpha
435 return x
438# --------------------------------------------------------------------
439# Calculate relative luminance
440# --------------------------------------------------------------------
441def relative_luminance(colors):
442 """Calculate Relative Luminance
444 Given a series of colors this function calculates the relative luminance.
446 Args:
447 colors (str, list, palette, colorobject): colors will be extracted from
448 the :py:class:`colorspace.colorlib.colorobject` or
449 :py:class:`colorspace.palette` object if provided. Else the input
450 is passed to :py:func:`colorspace.check_hex_colors`.
452 Returns:
453 numpy.array: Containing relative luminance.
455 Examples:
456 >>> colors = hexcols(["#ff0033", "#0033ff", "#00ffff", "#cecece"])
457 >>> relative_luminance(colors)
459 Raises:
460 TypeError: If cols is invalid.
461 """
463 from colorspace.colorlib import colorobject, hexcols
464 from colorspace import palette
465 from numpy import asarray, where, matmul, transpose
467 ## If the input is a colorobject we take it as it is
468 #if isinstance(colors, colorobject):
469 # pass
470 #elif isinstance(colors, palette):
471 # colors = hexcols(colors.colors())
472 ## Else we pass the input trough the hex checker first.
473 #else:
474 # try:
475 # colors = hexcols(check_hex_colors(colors))
476 # except:
477 # raise TypeError("Input 'colors' non of the recoginzed types or no valid hex colors.")
479 #colors.to("sRGB")
480 colors = hexcols(palette(colors).colors())
481 colors.to("sRGB")
483 rgb = transpose(asarray([colors.get("R"), colors.get("G"), colors.get("B")]))
484 rgb = where(rgb <= 0.03928, rgb / 12.92, ((rgb + 0.055) / 1.055)**2.4)
485 return matmul(rgb, asarray([0.2126, 0.7152, 0.0722]))
489# --------------------------------------------------------------------
490# Calculate W3C contrast ratio
491# --------------------------------------------------------------------
492def contrast_ratio(colors, bg = "#FFFFFF", plot = False, ax = None, \
493 fontsize = "xx-large", fontweight = "heavy", ha = "center", va = "center",
494 **kwargs):
495 """W3C Contrast Ratio
497 Compute (and visualize) the contrast ratio of pairs of colors, as defined
498 by the World Wide Web Consortium (W3C). Requires `matplotlib` to be installed.
500 The W3C Content Accessibility Guidelines (WCAG) recommend a contrast ratio
501 of at least 4.5 for the color of regular text on the background color, and
502 a ratio of at least 3 for large text. See
503 <https://www.w3.org/TR/WCAG21/#contrast-minimum>.
505 The contrast ratio is defined in <https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio>
506 as `(L1 + 0.05) / (L2 + 0.05)` where `L1` and `L2` are the relative luminances
507 (see <https://www.w3.org/TR/WCAG21/#dfn-relative-luminance>) of the lighter and darker
508 colors, respectively. The relative luminances are weighted sums of scaled sRGB coordinates:
509 `0.2126 * R + 0.7152 * G + 0.0722 * B` where each of `R`, `G`, and `B`
510 is defined as `RGB / 12.92 if RGB <= 0.03928 else (RGB + 0.055)/1.055)^2.4` based on
511 the `RGB` coordinates between 0 and 1.
513 Args:
514 colors (str, list, colorobject, palette): Single hex color (str), a list of hex colors (list),
515 a color object ,
516 or :py:class:`palette <colorspace.palettes.palette>`.
517 bg (str): background color against which the contrast will be calculated.
518 Defaults to white (`"#FFFFFF"`).
519 plot (bool): logical indicating whether the contrast ratios should also be
520 visualized by simple color swatches.
521 ax (None or matplotlib.axes.Axes): If None, a new matplotlib figure will
522 be created. If `ax` inherits from `matplotlib.axes.Axes` this object
523 will be used to create the demoplot. Handy to create multiple subplots.
524 Forwarded to different plot types.
525 fontsize (float, str): size of text, forwarded to `matplotlib.pyplot.text`.
526 Defaults to `"xx-large"`.
527 fontweight (str): weight of text, forwarded to `matplotlib.pyplot.text`.
528 Defaults to `"heavy"`.
529 ha (str): horizontal alignment, forwarded to `matplotlib.pyplot.text`.
530 Defaults to `"center"`.
531 va (str): vertical alignment, forwarded to `matplotlib.pyplot.text`.
532 Defaults to `"center"`.
533 **kwargs: Allows to specify `figsize` forwarded to `maptlotlib.pyplot.figure`,
534 only used if `ax` is None.
536 Returns:
537 A numeric vector with the contrast ratios is returned (invisibly, if `plot` is `True`).
539 Examples:
540 >>> # check contrast ratio of default palette on white background
541 >>> from colorspace import rainbow, contrast_ratio
542 >>> colors = rainbow().colors(7)
543 >>> contrast_ratio(colors, "#FFFFFF") # Against white
544 >>> contrast_ratio(colors, "#000000") # Against black
545 >>>
546 >>> #: Visualize contrast ratio against white
547 >>> contrast_ratio(colors, "#FFFFFF", plot = True);
548 >>> #: Visualize contrast ratio against black
549 >>> contrast_ratio(colors, "#000000", plot = True);
550 >>> #: Changing figure size
551 >>> contrast_ratio(colors, "#000000", plot = True, figsize = (4, 3));
553 Raises:
554 TypeError: If cols or bg is not one of the recognized types.
555 TypeError: If argument plot is not bool.
556 TypeError: If `ax` is not `None` or a `matplotlib.axes.Axes` object. Only
557 checked if `plot = True`.
558 """
560 from colorspace.palettes import palette
561 from colorspace.colorlib import colorobject, hexcols
562 from numpy import resize, where
564 # Convert inputs to palettes. They will fail in case the input
565 # is invalid.
566 colors = palette(colors)
567 bg = palette(bg)
569 if not isinstance(plot, bool):
570 raise TypeError("argument `plot` must be bool")
571 if len(colors) > len(bg):
572 bg = palette(resize(bg.colors(), len(colors)), "_tmp_palette_")
573 elif len(bg) > len(colors):
574 colors = palette(resize(colors.colors(), len(bg)), "_tmp_palette_")
576 # Compute contrast ratio
577 cols_hex = hexcols(colors.colors())
578 bg_hex = hexcols(bg.colors())
579 ratio = (relative_luminance(cols_hex) + 0.05) / (relative_luminance(bg_hex) + 0.05)
580 ratio = where(ratio < 1, 1 / ratio, ratio)
582 if plot:
583 import matplotlib.pyplot as plt
584 from matplotlib.axes import Axes
585 from matplotlib.pyplot import text
586 from matplotlib.patches import Rectangle
588 if not isinstance(ax, (type(None), Axes)):
589 raise TypeError("argument `ax` must be `None` or a `matplotlib.axes.Axes` object")
591 # Open figure if input "fig" is None, else use
592 # input "fig" handler.
593 if ax is None:
594 figsize = (5., 5.) # Default
595 figsize = (5., 5.) if not "figsize" in kwargs else kwargs["figsize"]
596 fig = plt.figure(figsize = figsize)
597 ax = plt.gca()
598 showfig = True
599 else:
600 showfig = False
602 ax.set_xlim([0, 2]); ax.set_ylim(0, len(cols_hex) - 0.05)
603 n = len(cols_hex)
605 # Drawing the information
606 for i in range(n):
607 # Drawing background
608 rect = Rectangle((0, i), 1, .95, linewidth = 1, facecolor = cols_hex.colors()[i])
609 ax.add_patch(rect)
610 rect = Rectangle((1, i), 1, .95, linewidth = 1, facecolor = bg_hex.colors()[i])
611 ax.add_patch(rect)
612 # Adding text
613 text(0.5, i + 0.5, "{:4.2f}".format(ratio[i]), color = bg_hex.colors()[i],
614 fontsize = fontsize, fontweight = fontweight, ha = ha, va = va)
615 text(1.5, i + 0.5, "{:4.2f}".format(ratio[i]), color = cols_hex.colors()[i],
616 fontsize = fontsize, fontweight = fontweight, ha = ha, va = va)
618 # Remove axis and make the thing tight
619 ax.axis("off")
621 if not showfig:
622 return ax
623 else:
624 fig.tight_layout()
625 plt.show()
627 return ratio
631def max_chroma(H, L, floor = False):
632 """Compute Maximum Chroma for Given Hue and Luminance in HCL
634 Compute approximately the maximum chroma possible for a given hue
635 and luminance combination in the HCL color space.
637 `H` and `L` can be single values or multiple values. If both have length `>
638 1`, the length must match. If one is of length `1` it will be recycled to
639 match the length of the other argument. In case the function is not able to
640 create two arrays of the same length an error will be thrown.
642 Args:
643 H (int, float, list, numpy.ndarray): hue, one or multiple values (must be
644 convertable to float).
645 L (int, float, list, numpy.ndarray): luminance, one or multiple values (must be
646 convertable to float).
647 floor (bool): should return be rounded? Defaults to `False`.
649 Returns:
650 numpy.ndarray: Array of the same length as `max(len(H), len(L))` with
651 maximum possible chroma for these hue-luminance combinations.
653 Examples:
655 >>> from colorspace import max_chroma
656 >>> # Max Chroma for Hue = 0 (red) with Luminance = 50
657 >>> max_chroma(0, 50)
658 >>>
659 >>> #: Max Chroma for Hue = 0 (red) for different Luminance levels
660 >>> max_chroma(0, [25, 50, 75])
661 >>>
662 >>> #: Max Chroma for Hue in sequence [0, 360] by 60, Luminace = 50
663 >>> import numpy as np
664 >>> max_chroma(np.arange(0, 360, 60), 50)
665 >>>
666 >>> #: Same as above but floored
667 >>> max_chroma(np.arange(0, 360, 60), 50, floor = True)
669 Raises:
670 TypeError: If unexpected input on `H` or `L`.
671 TypeError: If length of `H` and `L` do not match (see description).
672 TypeError: If input `floor` is not bool.
673 """
675 import numpy as np
676 import json
677 import os
678 import re
680 if isinstance(H, (float, int)):
681 H = np.atleast_1d(np.asarray(H, dtype = "float"))
682 elif isinstance(H, (list, np.ndarray)):
683 #H = np.asarray([H] if len(H.shape) == 0 else H, dtype = "float")
684 H = np.atleast_1d(np.asarray(H, dtype = "float"))
685 else:
686 raise TypeError("unexpected input on argument `H`")
687 if isinstance(L, (float, int)):
688 L = np.atleast_1d(np.asarray(L, dtype = "float"))
689 elif isinstance(L, (list, np.ndarray)):
690 L = np.atleast_1d(np.asarray(L, dtype = "float"))
691 else:
692 raise TypeError("unexpected input on argument `L`")
693 if not isinstance(floor, bool):
694 raise TypeError("argument `floor` must be bool")
696 # Check if we have to repeat one of the two inputs.
697 # This is only used if one is of length > 1 while the other
698 # one is of length 1.
699 if len(H) == 1 and len(L) > 1: H = np.repeat(H, len(L))
700 elif len(H) > 0 and len(L) == 1: L = np.repeat(L, len(H))
702 # Now both arrays must have the same number of elements. If not,
703 # stop execution and throw an error.
704 if not len(H) == len(L):
705 raise ValueError("number of values and `H` and `L` do not match or cannot be matched")
707 # Make sure that all hue values lie in the range of 0-360
708 while np.any(H < 0): H = np.where(H < 0, H + 360., H)
709 while np.any(H >= 360): H = np.where(H >= 360, H - 360., H)
711 # Prepare the values used for the 'table search'.
712 # Fix luminance to values between [0., 100.]
713 L = np.fmin(100, np.fmax(0, L))
715 # Loading json data set
716 resource_package = os.path.dirname(__file__)
717 filename = os.path.join(resource_package, "data", "max_chroma_table.json")
718 with open(filename, "r") as fid:
719 mctab = json.loads(fid.readline())
721 # Minimum/maximum hue and luminance
722 hmin = np.fmax(0, [int(np.floor(x + 1e-08)) for x in H])
723 lmin = np.fmax(0, [int(np.floor(x + 1e-08)) for x in L])
724 hmax = np.fmin(360, [int(np.ceil(x + 1e-08)) for x in H])
725 lmax = np.fmin(100, [int(np.ceil(x + 1e-08)) for x in L])
727 # Not very efficient. However, the best I came up for now :|
728 # Reading/loading the json data set takes about half of the time, maybe
729 # more efficient to directly code it rather than reading it from disc.
730 # TODO(enhancement): Investigate this at some point; room for improvement.
731 def get_max(a, b):
732 res = []
733 for i in range(len(a)):
734 res.append(mctab[f"{a[i]:d}-{b[i]:d}"])
735 return np.asarray(res).flatten()
737 # Calculate max chroma
738 C = (hmax - H) * (lmax - L) * get_max(hmin, lmin) + \
739 (hmax - H) * (L - lmin) * get_max(hmin, lmax) + \
740 (H - hmin) * (lmax - L) * get_max(hmax, lmin) + \
741 (H - hmin) * (L - lmin) * get_max(hmax, lmax)
742 C = np.where(np.logical_or(L < 0., L > 100.), 999, C)
744 # Floor if requested and return
745 if floor: C = np.floor(C)
746 return C
748def darken(col, amount = 0.1, method = "relative", space = "HCL", fixup = True):
749 """Algorithmically Darken Colors
751 Takes one or multiple colors and adjust them sucht hat they apper
752 darkened. See also: :py:func:`lighten`.
754 Args:
755 col: color (or colors) to be manipulated. Can be a
756 color object,
757 a :py:class:`palette <colorspace.palettes.palette>` object, or a
758 str/list of str with valid hex colors.
759 amount (float): value between `[0., 1.]` with the amount the colors
760 should be lightened. Defaults to `0.1`.
761 method (str): either `"relative"` (default) or `"absolute"`.
762 space (str): one of `"HCL"` or `"HSV"`. Defaults to `"HCL"`.
763 fixup (bool): should colors which fall outside the defined RGB space
764 be fixed (corrected)? Defaults to `True`.
766 Example:
768 >>> from colorspace import darken, lighten, swatchplot
769 >>> original = "#ff3322"
770 >>> lighter = lighten(original, amount = 0.3, method = "relative", space = "HCL")
771 >>> darker = darken(original, amount = 0.3, method = "relative", space = "HCL")
772 >>> swatchplot([lighter, original, darker],
773 >>> show_names = False, figsize = (6, 1));
775 Raises:
776 TypeError: If `method` is not str.
777 ValueError: If `method` is not one of `"absolute"` or `"relative"`.
778 TypeError: If `space` is not str.
779 ValueError: If `space` is not one of `"HCL"` or `"HSV"`.
780 TypeError: If 'col' is not among the one of the recognized objects.
781 TypeError: If `fixup` is not bool.
782 """
783 return lighten(col, amount = amount * -1., method = method, space = space, fixup = fixup)
786def lighten(col, amount = 0.1, method = "relative", space = "HCL", fixup = True):
787 """Algorithmically Lighten Colors
789 Takes one or multiple colors and adjust them sucht hat they apper
790 lightened. See also: :py:func:`darken`.
792 Args:
793 col: color (or colors) to be manipulated. Can be a color object
794 a :py:class:`palette <colorspace.palettes.palette>` object, or a
795 str/list of str with valid hex colors.
796 amount (float): value between `[0., 1.]` with the amount the colors
797 should be lightened. Defaults to `0.1`.
798 method (str): either `"relative"` (default) or `"absolute"`.
799 space (str): one of `"HCL"` or `"HLS"`. Defaults to `"HCL"`.
800 fixup (bool): should colors which fall outside the defined RGB space
801 be fixed (corrected)? Defaults to `True`.
803 Example:
805 >>> from colorspace import darken, lighten, swatchplot
806 >>> original = "#ff3322"
807 >>> lighter = lighten(original, amount = 0.3, method = "relative", space = "HCL")
808 >>> darker = darken(original, amount = 0.3, method = "relative", space = "HCL")
809 >>> swatchplot([lighter, original, darker],
810 >>> show_names = False, figsize = (6, 1));
812 Raises:
813 TypeError: If `method` is not str.
814 ValueError: If `method` is not one of `"absolute"` or `"relative"`.
815 TypeError: If `space` is not str.
816 ValueError: If `space` is not one of `"HCL"`, `"HLS"`, or `"combined"`.
817 TypeError: If input 'col' is not among the one of the recognized objects.
818 TypeError: If `fixup` is not bool.
819 """
821 from colorspace.colorlib import colorobject, hexcols
822 from colorspace.palettes import palette
823 from numpy import fmin, fmax, where
825 if not isinstance(method, str):
826 raise TypeError("argument `method` must be str")
827 elif not method in ["absolute", "relative"]:
828 raise ValueError("Wrong input for 'method'. Must be `\"absolute\"` or `\"relative\"`.")
830 if not isinstance(space, str):
831 raise TypeError("argument `space` must be str")
832 elif not space in ["HCL", "HLS", "combined"]:
833 raise ValueError("unexpected value on argument `space`. Must be `\"HCL\"`, `\"HLS\"`, or `\"combined\"`.")
835 if not isinstance(fixup, bool):
836 raise TypeError("argument `fixup` must be bool")
838 # If the input is a colorobject (hex, HSV, ...) we first
839 # put everything into a (temporary) palette.
840 if isinstance(col, colorobject): x = palette(col.colors(), "_temp_palette_object_")
841 # In case the input is a str or a list of str
842 # we convert the input (temporarily) into a palette.
843 # This allows us to check if all colors are valid hex colors.
844 elif isinstance(col, str): x = palette([col], "_temp_palette_object_")
845 elif isinstance(col, list): x = palette(col, "_temp_palette_object_")
846 # If the input is a palette object; keep it as it is.
847 elif isinstance(col, palette): x = col
848 else:
849 raise TypeError("argument `col` must be a colorobject, palette, a str, " + \
850 "or list of str with valid hex colors")
852 # Function to lighten colors in the HCL space.
853 # Returns a colorobject with transformed coordinates.
854 def _lighten_in_HCL(colors, amount, method):
855 tmp = hexcols(x.colors())
856 tmp.to("HCL")
857 tmp.set(L = fmin(100, fmax(0, tmp.get("L")))) # Fix bounds
858 if method == "relative":
859 tmp.set(L = where(amount >= 0, \
860 100. - (100. - tmp.get("L")) * (1. - amount), \
861 tmp.get("L") * (1. + amount)))
863 else:
864 tmp.set(L = tmp.get("L") + amount * 100.)
865 tmp.set(L = fmin(100, fmax(0, tmp.get("L")))) # Fix bounds again
866 tmp.set(C = fmin(max_chroma(tmp.get("H"), tmp.get("L"), floor = True), \
867 fmax(0, tmp.get("C"))))
869 return tmp
871 # Function to lighten colors in the HLS space.
872 # Returns a colorobject with transformed coordinates.
873 def _lighten_in_HLS(colors, amount, method):
874 tmp = hexcols(x.colors())
875 tmp.to("HLS")
876 if method == "relative":
877 tmp.set(L = where(amount >= 0, \
878 1. - (1. - tmp.get("L")) * (1. - amount), \
879 tmp.get("L") * (1. + amount)))
880 else:
881 tmp.set(L = tmp.get("L") + amount)
882 tmp.set(L = fmin(1., fmax(0, tmp.get("L"))))
884 return tmp
887 # Lighten colors depending on the users choice 'space'
888 if space == "HCL":
889 tmp = _lighten_in_HCL(x.colors(), amount, method)
890 elif space == "HLS":
891 tmp = _lighten_in_HLS(x.colors(), amount, method)
892 else:
893 tmp = _lighten_in_HCL(x.colors(), amount, method) # Via HCL color space
894 tmpHLS = _lighten_in_HLS(x.colors(), amount, method) # Via HLS color space
895 tmpHLS.to("RGB"); tmpHLS.to("HCL")
897 # fix-up L and copy C over from HLS-converted color
898 tmp.set(C = tmpHLS.get("C"))
900 # make sure chroma is in allowed range
901 tmp.set(C = fmin(max_chroma(tmp.get("H"), tmp.get("L"), floor = True), \
902 fmax(0, tmp.get("C"))))
904 # Job done, convert back to HEX
905 tmp.to("hex")
907 # If the original input was a single str: return str
908 if isinstance(col, str): res = tmp.colors()[0]
909 # In case the original input has been a list, return list
910 elif isinstance(col, list): res = tmp.colors()
911 # In case the input was a palette, return palette with original name.
912 elif isinstance(col, palette): res = palette(tmp.colors(), col.name())
913 # Else the input has been a colorobject, return hex color object :)
914 else: res = tmp
916 return res