Coverage for src/colorspace/hclplot.py: 95%
277 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
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
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.
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.
23 More specifically, the following palettes can be visualized well:
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.
38 * Diverging with two (approximately) constant hues: This case
39 is visualized with two back-to-back sequential displays.
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.
48 Note: Requires `matplotlib` to be installed.
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.
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`).
77 Examples:
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();
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 """
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?)")
157 from .colorlib import hexcols
158 from .statshelper import split, nprange, lm
159 import numpy as np
160 import warnings
162 # Sanity checks
163 if not isinstance(_type, (type(None), str)):
164 raise TypeError("argument `_type` must be None or str")
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")
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")
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
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
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()
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]")
215 if not isinstance(axes, bool):
216 raise TypeError("argument `axes` must be bool (True or False)")
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")
230 # Determine type of palette based on luminance trajectory
231 if _type is None:
232 seqn = 1 + np.arange(0, len(cols), 1)
234 # Range of luminance values
235 lran = np.max(cols.get("L")) - np.min(cols.get("L"))
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
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"
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
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
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
277 # Split index into 'continuous segments'.
278 idxs = split(idx, np.cumsum(np.concatenate(([1], np.diff(idx))) > 1))
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)
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
307 # Remove first entry from list idxs
308 del idxs[0]
309 seg = e + 1 # Next segment start
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))
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)
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)
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]
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))
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)
353 return nd, nd_cols
355 # ---------------------------------------------------------------
356 # Sequential plot
357 # ---------------------------------------------------------------
358 if _type == "sequential":
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])
365 # 0 1 2
366 # Transpose to [[H], [C], [L]
367 nd = np.transpose(nd)
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")
383 # Write prediction for H
384 nd[0] = mod["Yout"]
387 # Convert to polarLUV -> hexcols without fixup
388 nd, nd_cols = conv_colors(nd)
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
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)
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}]"
414 # ---------------------------------------------------------------
415 # Diverging plot
416 # ---------------------------------------------------------------
417 elif _type == "diverging":
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")
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])
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)
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]]
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])
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:
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])
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))))
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)
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])
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)
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]])
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")
509 # Write prediction for H
510 nd[0] = m["Yout"]
513 # Convert to polarLUV -> hexcols without fixup
514 nd, nd_cols = conv_colors(nd)
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
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)])
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)
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}]"
554 # ---------------------------------------------------------------
555 # Qualitative plot
556 # ---------------------------------------------------------------
557 elif _type == "qualitative":
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])
564 # 0 1 2
565 # Transpose to [[H], [C], [L]]
566 # ... dummy coding used later for linear regression.
567 nd = np.transpose(nd)
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")
584 # Write prediction for L [0., 100.]
585 nd[2] = np.minimum(100., np.maximum(0., mod["Yout"]))
587 # Convert to polarLUV -> hexcols without fixup
588 nd, nd_cols = conv_colors(nd)
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
598 nd_x, nd_y = HC_to_xy(nd[0], nd[1])
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")
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)
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)
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
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
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}]"
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"])
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