Coverage for src/colorspace/utils.py: 100%

276 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-23 19:54 +0000

1 

2def mixcolor(alpha, color1, color2, where): 

3 """Compute the Convex Combination of Two Colors 

4 

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

7 

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"`. 

17 

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. 

22 

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)); 

68 

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

76 

77 from numpy import resize 

78 from colorspace.colorlib import colorobject, hexcols 

79 from colorspace.palettes import palette 

80 

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") 

88 

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)}") 

93 

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`") 

103 

104 # Convert and extract coordinates 

105 color1.to(where) 

106 color2.to(where) 

107 coord1 = color1.get() 

108 coord2 = color2.get() 

109 

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

119 

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 

125 

126 import importlib 

127 module = importlib.import_module("colorspace.colorlib") 

128 FUN = getattr(module, where) 

129 res = FUN(**res) 

130 return res 

131 

132 

133 

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 

139 

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. 

146 

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. 

151 

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. 

157 

158 Examples: 

159 

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"])) 

176 

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 

185 

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") 

200 

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})?$") 

203 

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 

208 

209 # Check if str is of allowed type 

210 tmp = pat.match(x) 

211 

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

225 

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

233 

234 return x.upper() 

235 

236 colors = [check(x, pat) for x in colors] 

237 

238 return colors 

239 

240 

241# -------------------------------------------------------------------- 

242# Get transparency (or None if there is none defined) 

243# -------------------------------------------------------------------- 

244def extract_transparency(x, mode = "float"): 

245 """Extract Alpha Channel 

246 

247 Currently only for colorobjects. This function interfaces the 

248 ``.get()`` method of the object. 

249 

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"`. 

254 

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. 

259 

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. 

264 

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

300 

301 from colorspace.palettes import palette 

302 from colorspace.colorlib import colorobject 

303 from numpy import asarray, int16 

304 

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\"") 

311 

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

316 

317 # Extract alpha dimension 

318 alpha = x.get("alpha") 

319 

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) 

329 

330 return alpha 

331 

332 

333# -------------------------------------------------------------------- 

334# Remove or adjust transparency 

335# -------------------------------------------------------------------- 

336def adjust_transparency(x, alpha): 

337 """Adjust Alpha Channel 

338 

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`. 

345 

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. 

351 

352 Returns: 

353 numpy.ndarray or None: None if the colorobject has no defined transparency, 

354 else a numpy.ndarray is returned. 

355 

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) 

393 

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

402 

403 import numpy as np 

404 from colorspace.colorlib import colorobject 

405 from copy import deepcopy 

406 

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`") 

413 

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 

434 

435 return x 

436 

437 

438# -------------------------------------------------------------------- 

439# Calculate relative luminance 

440# -------------------------------------------------------------------- 

441def relative_luminance(colors): 

442 """Calculate Relative Luminance 

443 

444 Given a series of colors this function calculates the relative luminance. 

445 

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`. 

451 

452 Returns: 

453 numpy.array: Containing relative luminance. 

454 

455 Examples: 

456 >>> colors = hexcols(["#ff0033", "#0033ff", "#00ffff", "#cecece"]) 

457 >>> relative_luminance(colors) 

458 

459 Raises: 

460 TypeError: If cols is invalid. 

461 """ 

462 

463 from colorspace.colorlib import colorobject, hexcols 

464 from colorspace import palette 

465 from numpy import asarray, where, matmul, transpose 

466 

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

478 

479 #colors.to("sRGB") 

480 colors = hexcols(palette(colors).colors()) 

481 colors.to("sRGB") 

482 

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])) 

486 

487 

488 

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 

496 

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. 

499 

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

504 

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. 

512 

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. 

535 

536 Returns: 

537 A numeric vector with the contrast ratios is returned (invisibly, if `plot` is `True`). 

538 

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)); 

552 

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

559 

560 from colorspace.palettes import palette 

561 from colorspace.colorlib import colorobject, hexcols 

562 from numpy import resize, where 

563 

564 # Convert inputs to palettes. They will fail in case the input 

565 # is invalid. 

566 colors = palette(colors) 

567 bg = palette(bg) 

568 

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_") 

575 

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) 

581 

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 

587 

588 if not isinstance(ax, (type(None), Axes)): 

589 raise TypeError("argument `ax` must be `None` or a `matplotlib.axes.Axes` object") 

590 

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 

601 

602 ax.set_xlim([0, 2]); ax.set_ylim(0, len(cols_hex) - 0.05) 

603 n = len(cols_hex) 

604 

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) 

617 

618 # Remove axis and make the thing tight 

619 ax.axis("off") 

620 

621 if not showfig: 

622 return ax 

623 else: 

624 fig.tight_layout() 

625 plt.show() 

626 

627 return ratio 

628 

629 

630 

631def max_chroma(H, L, floor = False): 

632 """Compute Maximum Chroma for Given Hue and Luminance in HCL 

633 

634 Compute approximately the maximum chroma possible for a given hue 

635 and luminance combination in the HCL color space. 

636 

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. 

641 

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`. 

648 

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. 

652 

653 Examples: 

654 

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) 

668 

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

674 

675 import numpy as np 

676 import json 

677 import os 

678 import re 

679 

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") 

695 

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

701 

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") 

706 

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) 

710 

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

714 

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

720 

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]) 

726 

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

736 

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) 

743 

744 # Floor if requested and return 

745 if floor: C = np.floor(C) 

746 return C 

747 

748def darken(col, amount = 0.1, method = "relative", space = "HCL", fixup = True): 

749 """Algorithmically Darken Colors 

750 

751 Takes one or multiple colors and adjust them sucht hat they apper 

752 darkened. See also: :py:func:`lighten`. 

753 

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`. 

765 

766 Example: 

767 

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)); 

774 

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) 

784 

785 

786def lighten(col, amount = 0.1, method = "relative", space = "HCL", fixup = True): 

787 """Algorithmically Lighten Colors 

788 

789 Takes one or multiple colors and adjust them sucht hat they apper 

790 lightened. See also: :py:func:`darken`. 

791 

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`. 

802 

803 Example: 

804 

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)); 

811 

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

820 

821 from colorspace.colorlib import colorobject, hexcols 

822 from colorspace.palettes import palette 

823 from numpy import fmin, fmax, where 

824 

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\"`.") 

829 

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\"`.") 

834 

835 if not isinstance(fixup, bool): 

836 raise TypeError("argument `fixup` must be bool") 

837 

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") 

851 

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

862 

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")))) 

868 

869 return tmp 

870 

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")))) 

883 

884 return tmp 

885 

886 

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") 

896 

897 # fix-up L and copy C over from HLS-converted color 

898 tmp.set(C = tmpHLS.get("C")) 

899 

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")))) 

903 

904 # Job done, convert back to HEX 

905 tmp.to("hex") 

906 

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 

915 

916 return res 

917 

918