Coverage for src/colorspace/hclplot.py: 95%

277 statements  

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

1 

2 

3 

4 

5def hclplot(x, _type = None, h = None, c = None, l = None, axes = True, 

6 linewidth = 1, s = 150, **kwargs): 

7 """Palette Plot in HCL Space 

8 

9 The function `hclplot` is an auxiliary function for illustrating 

10 the trajectories of color palettes in two-dimensional HCL space 

11 projections. It collapses over one of the three coordinates 

12 (either the hue H or the luminance L) and displays a heatmap of 

13 colors combining the remaining two dimensions. The coordinates for 

14 the given color palette are highlighted to bring out its 

15 trajectory. 

16 

17 The function `hclplot` has been designed to work well with the 

18 :py:func:`hcl_palettes <colorspace.hcl_palettes.hcl_palettes>` 

19 in this package. While it is possible to apply it 

20 to other color palettes as well, the results might look weird or 

21 confusing if these palettes are constructed very differently. 

22 

23 More specifically, the following palettes can be visualized well: 

24 

25 * Qualitative with (approximately) constant luminance. In this 

26 case, `hclplot` shows a hue-chroma plane (in polar 

27 coordinates), keeping luminance at a fixed level (by default 

28 displayed in the main title of the plot). If the luminance 

29 is, in fact, not approximately constant, the luminance varies 

30 along with hue and chroma, using a simple linear function 

31 (fitted by least squares). `hclplot` shows a 

32 chroma-luminance plane, keeping hue at a fixed level (by 

33 default displayed in the main title of the plot). If the hue 

34 is, in fact, not approximately constant, the hue varies along 

35 with chroma and luminance, using a simple linear function 

36 (fitted by least squares. 

37 

38 * Diverging with two (approximately) constant hues: This case 

39 is visualized with two back-to-back sequential displays. 

40 

41 To infer the type of display to use, by default, the following 

42 heuristic is used: If luminance is not approximately constant 

43 (`range > 10`) and follows rougly a triangular pattern, a diverging 

44 display is used. If luminance is not constant and follows roughly 

45 a linear pattern, a sequential display is used. Otherwise a 

46 qualitative display is used. 

47 

48 Note: Requires `matplotlib` to be installed. 

49 

50 Args: 

51 x (str, list, colorobject): An object which can be converted into 

52 a :py:class:`hexcols <colorspace.colorlib.hexcols>` object. 

53 _type (None, str): Specifying which type of palette should be 

54 visualized (`"qualitative"`, `"sequential"`, or `"diverging"`). For 

55 qualitative palettes a hue-chroma plane is used, otherwise a 

56 chroma-luminance plane. By default (`_type = None`) the type is 

57 inferred from the luminance trajectory corresponding to `x`. 

58 h (None, int, float): If int or float, it must be within `[-360, 360]` 

59 c (None, int, float): If int or float, it must be positive 

60 l (None, int, float): If int or float, it must be positive 

61 axes (bool): Wheter or not axes should be drawn, defaults to `True`. 

62 linewidth (int, float, None): Line width, if set `0` or `None` the line connecting 

63 the colors of the palette will be suppressed. 

64 s (int, float, None): Marker size, defaults to `150`. If set `0` or `None` the 

65 position of the colors of the palette will be suppressed. 

66 **kwargs: Allowed to overwrite some default settings such as 

67 `title` (str), `xlabel` (str), `ylabel` (str), `figsize` (forwarded 

68 to `pyplot.figure`). `xlabel`/`ylabel` only used for qualitative 

69 and diverging plots. A matplotlib axis can be provided via `ax` 

70 (object of type `matplotlib.axes._axes.Axes`) which allows to draw 

71 multiple HCL spaces on one figure. 

72 

73 Returns: 

74 No return, visualizes the palette and HCL space either on a new 

75 figure or on an existing axis (if `ax` is provided, see `**kwargs`). 

76 

77 Examples: 

78 

79 >>> # Sequential HCL palette, hclplot with all available options 

80 >>> from colorspace import sequential_hcl, hclplot 

81 >>> 

82 >>> x = sequential_hcl("PurpOr")(5) 

83 >>> hclplot(x, 

84 >>> xlabel = "Chroma dimension", 

85 >>> ylabel = "Luminance dimension", 

86 >>> title = "hclplot Example (Sequential)", 

87 >>> figsize = (5, 5), s = 250); 

88 >>> #: Multiple subplots 

89 >>> import matplotlib.pyplot as plt  

90 >>> from colorspace import sequential_hcl, hclplot 

91 >>>  

92 >>> # Three different palettes  

93 >>> pal1 = sequential_hcl(h = 260, c = 80, l = [35, 95], power = 1) 

94 >>> pal2 = sequential_hcl(h = 245, c = [40, 75, 0], l = [30, 95], power = 1) 

95 >>> pal3 = sequential_hcl(h = 245, c = [40, 75, 0], l = [30, 95], power = [0.8, 1.4]) 

96 >>> #: 

97 >>> pal1.show_settings() 

98 >>> #: 

99 >>> pal2.show_settings() 

100 >>> #: 

101 >>> pal3.show_settings() 

102 >>>  

103 >>> #: 

104 >>> fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize = (12, 4)) 

105 >>> hclplot(pal1(7), ax = ax1)  

106 >>> hclplot(pal2(7), ax = ax2)  

107 >>> hclplot(pal3(7), ax = ax3)  

108 >>> plt.show(); 

109 >>> 

110 >>> #: Another example with two sequential and one 

111 >>> # diverging palettes with custom settings 

112 >>> from colorspace import sequential_hcl, diverging_hcl, hclplot 

113 >>> import matplotlib.pyplot as plt 

114 >>> 

115 >>> pal1 = sequential_hcl(h = [260, 220], c = [50, 0, 75], l = [30, 95], power = 1)  

116 >>> pal2 = sequential_hcl(h = [260, 60], c = 60, l = [40, 95], power = 1)  

117 >>> pal3 = diverging_hcl( h = [260, 0], c = 80, l = [35, 95], power = 1)  

118 >>>  

119 >>> fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize = (12, 4)) 

120 >>> hclplot(pal1(7), ax = ax1) 

121 >>> hclplot(pal2(7), ax = ax2) 

122 >>> hclplot(pal3(7), ax = ax3) 

123 >>> plt.show(); 

124 >>> 

125 >>> #: Another example with two sequential and one 

126 >>> # diverging palettes with custom settings 

127 >>> from colorspace import sequential_hcl, diverging_hcl, qualitative_hcl, hclplot 

128 >>> import matplotlib.pyplot as plt 

129 >>> 

130 >>> fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize = (12, 4)) 

131 >>> hclplot(sequential_hcl()(7), ax = ax1) 

132 >>> hclplot(diverging_hcl()(7), ax = ax2) 

133 >>> hclplot(qualitative_hcl()(7), ax = ax3) 

134 >>> plt.show(); 

135 

136 Raises: 

137 ImportError: If `matplotlib` is not installed. 

138 TypeError: If argument `_type` is not None or str. 

139 TypeError: If argument `_type` is str but not one of the allowed types. 

140 TypeError: If argument `c`, and/or `l` are not None, str, or int. 

141 TypeError: If argument `h` is neither None, int, float, or tuple, or tuple 

142 not containing int/float. 

143 ValueError: If `c`,`l` is not None and smaller or equal to `0` (must be positive). 

144 ValueError: If `h` is tuple length `0` or `>2` (must be one or two). 

145 ValueError: If `h` is not None and not within the range `[-360, 360]`. 

146 TypeError: If `s`, `linewidth` are not int, float, or None. 

147 ValueError: If `s`, `linewidth` are int/float but negative (`<0`). 

148 """ 

149 

150 # Requires matpotlib for plotting. If not available, throw ImportError 

151 try: 

152 import matplotlib.pyplot as plt 

153 from matplotlib.colors import LinearSegmentedColormap, ListedColormap 

154 except ImportError as e: 

155 raise ImportError("problems importing matplotlib.pyplt (not installed?)") 

156 

157 from .colorlib import hexcols 

158 from .statshelper import split, nprange, lm 

159 import numpy as np 

160 import warnings 

161 

162 # Sanity checks 

163 if not isinstance(_type, (type(None), str)): 

164 raise TypeError("argument `_type` must be None or str") 

165 

166 if not isinstance(c, (int, float, type(None))) or isinstance(c, bool): 

167 raise TypeError("argument `c` must be None, int, or float") 

168 elif c is not None and c <= 0: 

169 raise ValueError("argument `c` must be positive if set") 

170 

171 if not isinstance(l, (int, float, type(None))) or isinstance(l, bool): 

172 raise TypeError("argument `l` must be None, int, or float") 

173 elif l is not None and l <= 0: 

174 raise ValueError("argument `l` must be positive if set") 

175 

176 # Checking line width and marker size (linewidth, s) 

177 if not isinstance(linewidth, (type(None), int, float)): 

178 raise TypeError("argument `linewidth` must be None or int") 

179 elif isinstance(linewidth, (int, float)) and linewidth < 0.: 

180 raise ValueError("argument `linewidth` must be >= 0.") 

181 elif isinstance(linewidth, (int, float)) and linewidth == 0.: 

182 linewidth = None # Setting line width to 0 

183 

184 if not isinstance(s, (type(None), int, float)): 

185 raise TypeError("argument `s` must be None, int or float") 

186 elif isinstance(s, (int, float)) and s < 0.: 

187 raise ValueError("argument `s` must be >= 0.") 

188 elif isinstance(s, (int, float)) and s == 0.: 

189 s = None # Setting line width to 0 

190 

191 allowed_types = ["diverging", "sequential", "qualitative"] 

192 if isinstance(_type, str): 

193 if not _type.lower() in allowed_types: 

194 raise ValueError("argument `_type` invalid. Must be None or any of: {', '.join(allowed_types)}") 

195 _type = _type.lower() 

196 

197 # Testin 'h' which is a bit more complex 

198 if not isinstance(h, (int, float, type(None), tuple)) or isinstance(h, bool): 

199 raise TypeError("argument `h` must be None, int, or float, or tuple") 

200 # If int/float: Convert to tuple for easier handling later on. 

201 elif isinstance(h, (int, float)): 

202 h = (h, ) 

203 # In case h is not None it is now a tuple. Check that length is 1 or 2, 

204 # and that all elements are int/float and withing range. Else raise 

205 # TypeError or ValueError. 

206 if isinstance(h, tuple): 

207 if len(h) < 1 or len(h) > 2: 

208 raise ValueError(f"h (if set) must be of length 1 or two, got {len(h)}") 

209 for tmp in h: 

210 if not isinstance(tmp, (int, float)) or isinstance(tmp, bool): 

211 raise TypeError("elements in `h` (tuple) must be int or float") 

212 elif tmp < -360. or tmp > 360: 

213 raise ValueError("argument(s) in `h` must be in range [-360, 360]") 

214 

215 if not isinstance(axes, bool): 

216 raise TypeError("argument `axes` must be bool (True or False)") 

217 

218 # Convert input to hexcols object; then convert to HCL 

219 # to extract the coordinates of the palette. 

220 if isinstance(x, (str, list)): 

221 cols = hexcols(x) 

222 elif isinstance(x, (LinearSegmentedColormap, ListedColormap)): 

223 from colorspace.cmap import cmap_to_sRGB 

224 cols = cmap_to_sRGB(x, 11) # Currently defaulting to 11 colors (hardcoded) 

225 cols.to("hex") 

226 else: 

227 cols = hexcols(x.colors()) 

228 cols.to("HCL") 

229 

230 # Determine type of palette based on luminance trajectory 

231 if _type is None: 

232 seqn = 1 + np.arange(0, len(cols), 1) 

233 

234 # Range of luminance values 

235 lran = np.max(cols.get("L")) - np.min(cols.get("L")) 

236 

237 # Calculate linear and triangular correlation 

238 llin = (np.corrcoef(cols.get("L"), seqn)[0][1])**2 

239 ltri = (np.corrcoef(cols.get("L"), np.abs(seqn - (len(cols) + 1) / 2))[0][1])**2 

240 

241 # Guess (inferr) which type of palette we have at hand 

242 if ltri > 0.75 and lran > 10: _type = "diverging" 

243 elif llin > 0.75 and lran > 10: _type = "sequential" 

244 else: _type = "qualitative" 

245 

246 

247 if len(cols) > 1: 

248 # Correcting negative Hues if we have a jump 

249 tmpH = cols.get("H") 

250 for i in range(1, len(cols)): 

251 d = tmpH[i] - tmpH[i - 1] 

252 if np.abs(d) > 320.: 

253 tmpH[i] = tmpH[i] - np.sign(d) * 360 

254 if tmpH[i] > 360: 

255 tmpH[list(range(i + 1))] = tmpH[list(range(i + 1))] - np.sign(tmpH[i]) 

256 cols.set(H = tmpH) 

257 del tmpH 

258 

259 # Smoothing the values in batches where chroma is very low 

260 idx = np.where(cols.get("C") < 8.)[0] 

261 # If all Chroma values are very low (<8); replace hue with mean hue 

262 if len(idx) == len(cols): 

263 cols.set(H = np.repeat(np.mean(cols.get("H")), len(cols))) 

264 # If not all but at least some colors have very low chroma 

265 elif len(idx) > 0: 

266 from .statshelper import natural_cubic_spline 

267 

268 # Pre-smoothing hue 

269 if len(cols) >= 49: 

270 # Weighted rolling mean 

271 tmp = cols.get("H")[np.concatenate(([1, 1], np.arange(1, len(cols) - 1)))] + \ 

272 cols.get("H")[np.concatenate(([1], np.arange(1, len(cols))))] + \ 

273 cols.get("H") 

274 cols.set(H = 1./3. * tmp) # Calculate weighted mean, write back 

275 del tmp 

276 

277 # Split index into 'continuous segments'. 

278 idxs = split(idx, np.cumsum(np.concatenate(([1], np.diff(idx))) > 1)) 

279 

280 seg = 0 

281 while len(idxs) > 0: 

282 if seg in idxs[0]: 

283 if len(idxs) > 1: 

284 e = idxs[1][0] - 1 

285 else: 

286 e = len(cols) - 1 

287 else: 

288 if (len(cols) - 1) in idxs[0]: 

289 e = len(cols) - 1 

290 else: 

291 e = np.round(np.mean([np.max(idxs[0]), np.min(idxs[0])])) 

292 seq = np.arange(seg, e + 1) 

293 seql = np.asarray([x in idxs[0] for x in seq]) 

294 io = split(seq, seql) 

295 

296 if len(io) == 2 and np.sum(seql) > 0: 

297 tmpH = cols.get("H") 

298 iii = np.asarray(seq[seql == False], dtype = np.int16) # int 

299 res = natural_cubic_spline(x = seq[seql == False], 

300 y = tmpH[iii], 

301 xout = seq[seql == True]) 

302 jjj = np.asarray(seq[seql == True], dtype = np.int16) # int 

303 tmpH[jjj] = res["y"] 

304 cols.set(H = tmpH) 

305 del tmpH, res, iii, jjj 

306 

307 # Remove first entry from list idxs 

308 del idxs[0] 

309 seg = e + 1 # Next segment start 

310 

311 # Getting maximum chroma 

312 if c is not None: 

313 maxchroma = np.ceil(c) 

314 else: 

315 maxchroma = np.maximum(100., np.minimum(180, np.ceil(np.max(cols.get("C")) / 20) * 20)) 

316 

317 # --------------------------------------------------------------- 

318 # Preparing plot/axes 

319 # --------------------------------------------------------------- 

320 # If `ax` is specified, must be matplotlib.axes._axes.Axes 

321 if "ax" in kwargs.keys(): 

322 from matplotlib import axes 

323 ax = kwargs["ax"] # Keep this for plotting 

324 if not isinstance(ax, axes._axes.Axes): 

325 raise TypeError("argument `ax` (if set) must be a matplotlib.axes._axes.Axes") 

326 else: 

327 figsize = None if not "figsize" in kwargs.keys() else kwargs["figsize"] 

328 fig,ax = plt.subplots(1, 1, figsize = figsize) 

329 

330 

331 # --------------------------------------------------------------- 

332 # Helper functon to convert coordinates to hex colors and remove 

333 # unwanted colors (those where the hex color is 'nan' due to 'fixup = False' 

334 # and low-luminance colors with chroma > 1. 

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

336 def conv_colors(nd): 

337 from .colorlib import polarLUV 

338 hexcols = polarLUV(H = nd[0], C = np.abs(nd[1]), L = nd[2]) 

339 hexcols.to("hex", fixup = False) 

340 

341 # Find colors where |C| > 0 and L < 1 

342 kill_lum = np.where(np.logical_and(np.abs(nd[1]) > 0, nd[2] < 1))[0] 

343 

344 # Find 'nan' colors (due to fixup) 

345 kill_nan = np.where([x is None for x in hexcols.colors()])[0] 

346 kill = np.unique(np.concatenate((kill_lum, kill_nan), 0)) 

347 

348 # Deleting coordinates and colors we do not need 

349 nd = np.delete(nd, kill, axis = 1) 

350 nd_cols = hexcols.colors() 

351 nd_cols = np.delete(nd_cols, kill) 

352 

353 return nd, nd_cols 

354 

355 # --------------------------------------------------------------- 

356 # Sequential plot 

357 # --------------------------------------------------------------- 

358 if _type == "sequential": 

359 

360 # Spanning grid, creates N x 3 array with H (np.nan), C, L values 

361 C = np.linspace(0., maxchroma, int(maxchroma + 1)) 

362 L = np.linspace(0., 100., 101) 

363 nd = np.asarray([(np.nan, a, b) for a in C for b in L]) 

364 

365 # 0 1 2 

366 # Transpose to [[H], [C], [L] 

367 nd = np.transpose(nd) 

368 

369 if h is not None: 

370 nd[0] = np.repeat(h, len(nd[0])) 

371 elif len(cols) < 3 or (np.max(cols.get("H")) - np.min(cols.get("H"))) < 12: 

372 nd[0] = np.repeat(np.median(cols.get("H")), len(nd[0])) 

373 else: 

374 # Model matrix for estimation and prediction 

375 X = np.transpose(np.asarray([np.repeat(1., len(cols)), 

376 cols.get("C"), cols.get("L")])) 

377 Xout = np.transpose(np.asarray([np.repeat(1., nd.shape[1]), 

378 nd[1], nd[2]])) 

379 mod = lm(y = cols.get("H"), X = X, Xout = Xout) 

380 if mod["sigma"] > 7.5: 

381 warnings.warn("cannot approximate H well as a linear function of C and L") 

382 

383 # Write prediction for H 

384 nd[0] = mod["Yout"] 

385 

386 

387 # Convert to polarLUV -> hexcols without fixup 

388 nd, nd_cols = conv_colors(nd) 

389 

390 # Plotting HCL space 

391 ax.scatter(nd[1], nd[2], color = nd_cols, s = 150) 

392 ax.set_xlim(np.floor(np.min(nd[1]) / 2.5) * 2.5, 

393 np.ceil(np.max(nd[1]) / 2.5) * 2.5) # Chroma 

394 ax.set_ylim(np.floor(np.min(nd[2]) / 2.5) * 2.5, 

395 np.ceil(np.max(nd[2]) / 2.5) * 2.5) # Luminance 

396 

397 # Adding actual palette 

398 if linewidth is not None: 

399 ax.plot(cols.get("C"), cols.get("L"), "-", color = "black", 

400 linewidth = linewidth, zorder = 3) 

401 if s is not None: 

402 ax.scatter(cols.get("C"), cols.get("L"), edgecolor = "white", s = s, 

403 linewidth = 2, color = cols.colors(), zorder = 3) 

404 

405 # Plot labels 

406 if "title" in kwargs.keys(): 

407 title = kwargs["title"] 

408 elif len(np.unique(np.round(nd[0]))) == 1: 

409 title = f"Hue = {nd[0][0]:.0f}" 

410 else: 

411 title = f"Hue = [{np.min(nd[0]):.0f}, {np.max(nd[0]):.0f}]" 

412 

413 

414 # --------------------------------------------------------------- 

415 # Diverging plot 

416 # --------------------------------------------------------------- 

417 elif _type == "diverging": 

418 

419 # TODO(R): When using the following sequence of colors in R 

420 # x <- c('#11C638', '#60CD6B', '#CCFF00', '#B0DAB3', '#D2E0D3', 

421 # '#E7DAD2', '#EDC9B0', '#CCFF00', '#F1A860', '#EF9708') 

422 # hclplot(x, "diverging") 

423 # ... is that actually correct? To me, the Python version looks more reasonable. 

424 # 

425 # Compare to Python 

426 # x = ['#11C638', '#60CD6B', '#CCFF00', '#B0DAB3', '#D2E0D3', 

427 # '#E7DAD2', '#EDC9B0', '#CCFF00', '#F1A860', '#EF9708'] 

428 # hclplot(x, "diverging") 

429 

430 # Spanning grid, creates N x 5 array with H (np.nan), C, L, as well 

431 # as left (binary) and right (binary) based on C (negative C = left, else right) 

432 C = np.linspace(-maxchroma, +maxchroma, int(1 + 2 * maxchroma)) 

433 L = np.linspace(0., 100., 101) 

434 nd = np.asarray([(np.nan, a, b, a < 0, a >= 0) for a in C for b in L]) 

435 

436 # 0 1 2 3 4 

437 # Transpose to [[H], [C], [L], [left], [right]] 

438 # If C < 0: left = 0, right = 1 

439 # IF C >= 0: left = 1, right = 0 

440 # ... dummy coding used later for linear regression. 

441 nd = np.transpose(nd) 

442 

443 # Left and right hand side of the diverging palette; original colors 

444 left = np.arange(0, np.floor(len(cols) / 2) + 1).astype(np.int8) 

445 left = left[np.where(cols.get("C")[left] > 10.)[0]] 

446 right = np.arange(np.ceil(len(cols) / 2), len(cols)).astype(np.int8) 

447 right = right[np.where(cols.get("C")[right] > 10.)[0]] 

448 

449 # If the user has set h's (after sanity checks we know it is  

450 # now a tuple of one or two numerics) 

451 if h is not None: 

452 if len(h) == 2: 

453 nd[0, np.where(nd[3] == 1)] = float(h[0]) # left 

454 nd[0, np.where(nd[4] == 1)] = float(h[1]) # right 

455 else: 

456 nd[0] = float(h[0]) 

457 

458 # Else we will infer it from the data (cols) 

459 elif len(cols) < 6 \ 

460 or np.diff(nprange(cols.get("H")[left]) - np.min(cols.get("H")[left]))[0] < 12 \ 

461 or np.diff(nprange(cols.get("H")[right]) - np.min(cols.get("H")[right]))[0] < 12: 

462 

463 # Update H 

464 nd[0, np.where(nd[3] == 1)] = np.median(cols.get("H")[left] - \ 

465 np.min(cols.get("H")[left])) + \ 

466 np.min(cols.get("H")[left]) 

467 nd[0, np.where(nd[3] == 0)] = np.median(cols.get("H")[right] -\ 

468 np.min(cols.get("H")[right])) + \ 

469 np.min(cols.get("H")[right]) 

470 

471 # Else 

472 else: 

473 # Adding 'left' to nd dimension 0 as 4th element 

474 tmp = np.concatenate((np.repeat(True, len(left)), np.repeat(False, len(right)))) 

475 

476 # Setting up y (response) and X (model matrix) for linear model 

477 is_left = np.asarray([x in left for x in np.arange(len(cols))], dtype = np.int16) 

478 is_right = np.asarray([x in right for x in np.arange(len(cols))], dtype = np.int16) 

479 

480 y = cols.get("H") 

481 X = np.transpose([np.repeat(1, len(y)), # Intercept 

482 is_left, # Dummy 'left' 

483 cols.get("C"), # Chroma 

484 cols.get("L"), # Luminance 

485 cols.get("C") * is_left, # + one-way interactions 

486 cols.get("L") * is_left]) 

487 

488 # left/right must have C > 10 (this is done before 

489 # this if-elif-else condition), here we are checking for colors 

490 # which are neither left nor right. If found, remove from y and X 

491 # before modeling. 

492 kill = np.where(is_right + is_left == 0)[0] 

493 y = np.delete(y, kill) 

494 X = np.delete(X, kill, axis = 0) 

495 

496 # Create xout based on nd 

497 Xout = np.transpose([np.repeat(1, nd.shape[1]), # Intercept 

498 nd[3], # Dummy 'left' 

499 np.abs(nd[1]), # Chroma 

500 nd[2], # Luminance 

501 np.abs(nd[1]) * nd[3], # + one-way interactions 

502 nd[2] * nd[3]]) 

503 

504 # Estimate model 

505 m = lm(y = y, X = X, Xout = Xout) 

506 if m["sigma"] > 7.5: 

507 warnings.warn("cannot approximate H well as a linear function of C and L") 

508 

509 # Write prediction for H 

510 nd[0] = m["Yout"] 

511 

512 

513 # Convert to polarLUV -> hexcols without fixup 

514 nd, nd_cols = conv_colors(nd) 

515 

516 # Plotting HCL space 

517 ax.scatter(nd[1], nd[2], color = nd_cols, s = 150) 

518 ax.set_xlim(np.floor(np.min(nd[1]) / 2.5) * 2.5, 

519 np.ceil(np.max(nd[1]) / 2.5) * 2.5) # Chroma 

520 ax.set_ylim(np.floor(np.min(nd[2]) / 2.5) * 2.5, 

521 np.ceil(np.max(nd[2]) / 2.5) * 2.5) # Luminance 

522 

523 # Modify tick-labels on x-axis to always show positive value 

524 xtick = ax.get_xticks() 

525 ax.set_xticks(xtick) 

526 ax.set_xticklabels([int(t) for t in np.abs(xtick)]) 

527 

528 # Adding actual palette if needed 

529 C = cols.get("C") 

530 il = np.arange(len(cols) / 2, dtype = np.int16) 

531 C[il] = -1 * C[il] 

532 if linewidth is not None: 

533 ax.plot(C, cols.get("L"), "-", color = "black", 

534 linewidth = linewidth, zorder = 3) 

535 if s is not None: 

536 ax.scatter(C, cols.get("L"), edgecolor = "white", s = s, 

537 linewidth = 2, color = cols.colors(), zorder = 3) 

538 

539 # Specifying title 

540 if "title" in kwargs.keys(): 

541 title = kwargs["title"] 

542 elif len(np.unique(np.round(nd[0]))) <= 2: 

543 hl = nd[0, nd[3] == 1][0] # Picking left ... 

544 hr = nd[0, nd[4] == 1][0] # ... and right hue. 

545 title = f"Hue = {hl:.0f} | {hr:.0f}" 

546 else: 

547 from .statshelper import nprange 

548 hl = nprange(nd[0, nd[3] == 1]) # Range of Hue 'left' 

549 hr = nprange(nd[0, nd[4] == 1]) # Range of Hue 'right' 

550 title = f"Hue = [{np.min(hl[0]):.0f}, {np.max(hl[1]):.0f}]" 

551 title += f"/[{np.min(hr[0]):.0f}, {np.max(hr[1]):.0f}]" 

552 

553 

554 # --------------------------------------------------------------- 

555 # Qualitative plot 

556 # --------------------------------------------------------------- 

557 elif _type == "qualitative": 

558 

559 # Spanning grid, creates N x 3 array with H, C, and L (np.nan) 

560 H = np.linspace(0, 360, 180, endpoint = False) # 0-360 w/ interval width = 2 

561 C = np.linspace(0, maxchroma, int(maxchroma + 1)) 

562 nd = np.asarray([(a, b, np.nan) for a in H for b in C]) 

563 

564 # 0 1 2 

565 # Transpose to [[H], [C], [L]] 

566 # ... dummy coding used later for linear regression. 

567 nd = np.transpose(nd) 

568 

569 # If the user has specified l: Use this value. 

570 if l is not None: 

571 nd[2] = np.repeat(float(l), nd.shape[1]) 

572 elif len(cols) < 3 or np.diff(nprange(cols.get("L"))) < 10.: 

573 nd[2] = np.median(cols.get("L")) 

574 else: 

575 # Model matrix for estimation and prediction 

576 X = np.transpose(np.asarray([np.repeat(1., len(cols)), 

577 cols.get("C"), cols.get("H")])) 

578 Xout = np.transpose(np.asarray([np.repeat(1., nd.shape[1]), 

579 nd[1], nd[0]])) 

580 mod = lm(y = cols.get("L"), X = X, Xout = Xout) 

581 if mod["sigma"] > 7.5: 

582 warnings.warn("cannot approximate L well as a linear function of C and H") 

583 

584 # Write prediction for L [0., 100.] 

585 nd[2] = np.minimum(100., np.maximum(0., mod["Yout"])) 

586 

587 # Convert to polarLUV -> hexcols without fixup 

588 nd, nd_cols = conv_colors(nd) 

589 

590 def HC_to_xy(H, C): 

591 assert isinstance(H, np.ndarray) 

592 assert isinstance(C, np.ndarray) 

593 if len(H.shape) > 0: 

594 assert len(H) == len(C) 

595 return [np.cos(H * np.pi / 180.) * C, # x 

596 np.sin(H * np.pi / 180.) * C] # y 

597 

598 nd_x, nd_y = HC_to_xy(nd[0], nd[1]) 

599 

600 # Plotting HCL space 

601 ax.scatter(nd_x, nd_y, color = nd_cols, s = 150) 

602 ax.set_xlim(-maxchroma * 1.1, +maxchroma * 1.1) 

603 ax.set_ylim(-maxchroma * 1.1, +maxchroma * 1.1) 

604 ax.set_aspect("equal") 

605 

606 # Adding actual palette if needed 

607 cols_x, cols_y = HC_to_xy(cols.get("H"), cols.get("C")) 

608 if linewidth is not None: 

609 ax.plot(cols_x, cols_y, "-", color = "black", 

610 linewidth = linewidth, zorder = 3) 

611 if s is not None: 

612 ax.scatter(cols_x, cols_y, edgecolor = "white", s = s, 

613 linewidth = 2, color = cols.colors(), zorder = 3) 

614 

615 # Adding outer circle 

616 cx, cy = HC_to_xy(np.linspace(0, 360, 361), np.repeat(maxchroma, 361)) 

617 ax.plot(cx, cy, zorder = 1, color = "black", linewidth = 0.5) 

618 

619 # Adding axes if requested 

620 if axes: 

621 tx, ty = HC_to_xy(np.asarray(0), np.asarray(maxchroma + 20)) 

622 ax.text(tx, ty, "Hue", horizontalalignment = "left", verticalalignment = "center") 

623 for hue in np.linspace(0, 360, 6, endpoint = False): 

624 tx, ty = HC_to_xy(np.asarray(hue), np.asarray(maxchroma + 10)) 

625 ax.text(tx, ty, f"{hue:.0f}" if hue > 0 else "0\n360", 

626 horizontalalignment = "center", verticalalignment = "center") 

627 lx, ly = HC_to_xy(np.repeat(hue, 2), np.asarray([0, 4]) + maxchroma) 

628 ax.plot(lx, ly, color = "black", linewidth = 0.5) 

629 del cx, cy, tx, ty, lx, ly 

630 

631 # Radial 'axis' 

632 ax.plot(np.asarray([0, maxchroma]), np.repeat(0, 2), 

633 color = "black", linewidth = 0.5) 

634 tmp = np.arange(0, maxchroma, 50 if maxchroma > 150 else 25) 

635 ax.text(np.mean(tmp), -17, "Chroma", 

636 horizontalalignment = "center", verticalalignment = "top") 

637 for t in tmp: 

638 ax.text(t, -7.5, f"{t:.0f}", 

639 horizontalalignment = "center", verticalalignment = "top") 

640 ax.plot(np.repeat(t, 2), np.asarray([0., -5.]), 

641 color = "black", linewidth = 0.5) 

642 del tmp 

643 

644 

645 # Specifying title 

646 if "title" in kwargs.keys(): 

647 title = kwargs["title"] 

648 elif len(np.unique(np.round(nd[2]))) <= 1: 

649 title = f"Luminance = {nd[2][0]:.0f}" 

650 else: 

651 title = f"Luminance = [{np.min(nd[2]):.0f}, {np.max(nd[2]):.0f}]" 

652 

653 

654 # Plot annotations, done 

655 ax.set_title(title, fontsize = 10, fontweight = "bold") 

656 if _type == "qualitative" or not axes: 

657 ax.set_axis_off() 

658 else: 

659 ax.set_xlabel("Chroma" if not "xlabel" in kwargs.keys() else kwargs["xlabel"]) 

660 ax.set_ylabel("Luminance" if not "ylabel" in kwargs.keys() else kwargs["ylabel"]) 

661 

662 # If the user did not provide an axis, we started 

663 # a new figure and can now display it. 

664 if not "ax" in kwargs.keys(): 

665 plt.show() 

666 return fig 

667 else: 

668 return ax 

669 

670 

671 

672 

673 

674 

675