forked from proplot-dev/proplot
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcolors.py
More file actions
3155 lines (2862 loc) · 114 KB
/
colors.py
File metadata and controls
3155 lines (2862 loc) · 114 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Various colormap classes and colormap normalization classes.
"""
# NOTE: To avoid name conflicts between registered colormaps and colors, print
# set(pplt.colors._cmap_database) & set(pplt.colors._color_database) whenever
# you add new colormaps. v0.8 result is {'gray', 'marine', 'ocean', 'pink'} due
# to the MATLAB and GNUPlot colormaps. Want to minimize conflicts.
# NOTE: We feel that LinearSegmentedColormap should always be used for smooth color
# transitions while ListedColormap should always be used for qualitative color sets.
# Other sources use ListedColormap for dense "perceptually uniform" colormaps possibly
# seeking optimization. However testing reveals that initialization of even very
# dense 256-level colormaps is only 1.25ms vs. 0.25ms for a ListedColormap with the
# same data (+1ms). Also ListedColormap was designed for qualitative transitions
# because specifying N different from len(colors) will cyclically loop around the
# colors or truncate colors. So we translate the relevant ListedColormaps to
# LinearSegmentedColormaps for consistency. See :rc:`cmap.listedthresh`
import functools
import json
import os
import re
from collections.abc import MutableMapping
from numbers import Integral, Number
from xml.etree import ElementTree
import matplotlib.cm as mcm
import matplotlib.colors as mcolors
import numpy as np
import numpy.ma as ma
from .config import rc
from .internals import ic # noqa: F401
from .internals import (
_kwargs_to_args,
_not_none,
_pop_props,
docstring,
inputs,
warnings,
)
from .utils import set_alpha, to_hex, to_rgb, to_rgba, to_xyz, to_xyza
__all__ = [
'DiscreteColormap',
'ContinuousColormap',
'PerceptualColormap',
'DiscreteNorm',
'DivergingNorm',
'SegmentedNorm',
'ColorDatabase',
'ColormapDatabase',
'ListedColormap', # deprecated
'LinearSegmentedColormap', # deprecated
'PerceptuallyUniformColormap', # deprecated
'LinearSegmentedNorm', # deprecated
]
# Default colormap properties
DEFAULT_NAME = '_no_name'
DEFAULT_SPACE = 'hsl'
# Color regexes
# NOTE: We do not compile hex regex because config.py needs this surrounded by \A\Z
_regex_hex = r'#(?:[0-9a-fA-F]{3,4}){2}' # 6-8 digit hex
REGEX_HEX_MULTI = re.compile(_regex_hex)
REGEX_HEX_SINGLE = re.compile(rf'\A{_regex_hex}\Z')
REGEX_ADJUST = re.compile(r'\A(light|dark|medium|pale|charcoal)?\s*(gr[ea]y[0-9]?)?\Z')
# Colormap constants
CMAPS_CYCLIC = tuple( # cyclic colormaps loaded from rgb files
key.lower() for key in (
'MonoCycle',
'twilight',
'Phase',
'romaO',
'brocO',
'corkO',
'vikO',
'bamO',
)
)
CMAPS_DIVERGING = { # mirrored dictionary mapping for reversed names
key.lower(): value.lower()
for key1, key2 in (
('BR', 'RB'),
('NegPos', 'PosNeg'),
('CoolWarm', 'WarmCool'),
('ColdHot', 'HotCold'),
('DryWet', 'WetDry'),
('PiYG', 'GYPi'),
('PRGn', 'GnRP'),
('BrBG', 'GBBr'),
('PuOr', 'OrPu'),
('RdGy', 'GyRd'),
('RdBu', 'BuRd'),
('RdYlBu', 'BuYlRd'),
('RdYlGn', 'GnYlRd'),
)
for key, value in ((key1, key2), (key2, key1))
}
for _cmap_diverging in ( # remaining diverging cmaps (see PlotAxes._parse_cmap)
'Div',
'Vlag',
'Spectral',
'Balance',
'Delta',
'Curl',
'roma',
'broc',
'cork',
'vik',
'bam',
'lisbon',
'tofino',
'berlin',
'vanimo',
):
CMAPS_DIVERGING[_cmap_diverging.lower()] = _cmap_diverging.lower()
CMAPS_REMOVED = {
'Blue0': '0.6.0',
'Cool': '0.6.0',
'Warm': '0.6.0',
'Hot': '0.6.0',
'Floral': '0.6.0',
'Contrast': '0.6.0',
'Sharp': '0.6.0',
'Viz': '0.6.0',
}
CMAPS_RENAMED = {
'GrayCycle': ('MonoCycle', '0.6.0'),
'Blue1': ('Blues1', '0.7.0'),
'Blue2': ('Blues2', '0.7.0'),
'Blue3': ('Blues3', '0.7.0'),
'Blue4': ('Blues4', '0.7.0'),
'Blue5': ('Blues5', '0.7.0'),
'Blue6': ('Blues6', '0.7.0'),
'Blue7': ('Blues7', '0.7.0'),
'Blue8': ('Blues8', '0.7.0'),
'Blue9': ('Blues9', '0.7.0'),
'Green1': ('Greens1', '0.7.0'),
'Green2': ('Greens2', '0.7.0'),
'Green3': ('Greens3', '0.7.0'),
'Green4': ('Greens4', '0.7.0'),
'Green5': ('Greens5', '0.7.0'),
'Green6': ('Greens6', '0.7.0'),
'Green7': ('Greens7', '0.7.0'),
'Green8': ('Greens8', '0.7.0'),
'Orange1': ('Yellows1', '0.7.0'),
'Orange2': ('Yellows2', '0.7.0'),
'Orange3': ('Yellows3', '0.7.0'),
'Orange4': ('Oranges2', '0.7.0'),
'Orange5': ('Oranges1', '0.7.0'),
'Orange6': ('Oranges3', '0.7.0'),
'Orange7': ('Oranges4', '0.7.0'),
'Orange8': ('Yellows4', '0.7.0'),
'Brown1': ('Browns1', '0.7.0'),
'Brown2': ('Browns2', '0.7.0'),
'Brown3': ('Browns3', '0.7.0'),
'Brown4': ('Browns4', '0.7.0'),
'Brown5': ('Browns5', '0.7.0'),
'Brown6': ('Browns6', '0.7.0'),
'Brown7': ('Browns7', '0.7.0'),
'Brown8': ('Browns8', '0.7.0'),
'Brown9': ('Browns9', '0.7.0'),
'RedPurple1': ('Reds1', '0.7.0'),
'RedPurple2': ('Reds2', '0.7.0'),
'RedPurple3': ('Reds3', '0.7.0'),
'RedPurple4': ('Reds4', '0.7.0'),
'RedPurple5': ('Reds5', '0.7.0'),
'RedPurple6': ('Purples1', '0.7.0'),
'RedPurple7': ('Purples2', '0.7.0'),
'RedPurple8': ('Purples3', '0.7.0'),
}
# Color constants
COLORS_OPEN = {} # populated during register_colors
COLORS_XKCD = {} # populated during register_colors
COLORS_KEEP = (
*( # always load these XKCD colors regardless of settings
'charcoal', 'tomato', 'burgundy', 'maroon', 'burgundy', 'lavendar',
'taupe', 'sand', 'stone', 'earth', 'sand brown', 'sienna',
'terracotta', 'moss', 'crimson', 'mauve', 'rose', 'teal', 'forest',
'grass', 'sage', 'pine', 'vermillion', 'russet', 'cerise', 'avocado',
'wine', 'brick', 'umber', 'mahogany', 'puce', 'grape', 'blurple',
'cranberry', 'sand', 'aqua', 'jade', 'coral', 'olive', 'magenta',
'turquoise', 'sea blue', 'royal blue', 'slate blue', 'slate grey',
'baby blue', 'salmon', 'beige', 'peach', 'mustard', 'lime', 'indigo',
'cornflower', 'marine', 'cloudy blue', 'tangerine', 'scarlet', 'navy',
'cool grey', 'warm grey', 'chocolate', 'raspberry', 'denim',
'gunmetal', 'midnight', 'chartreuse', 'ivory', 'khaki', 'plum',
'silver', 'tan', 'wheat', 'buff', 'bisque', 'cerulean',
),
*( # common combinations
'red orange', 'yellow orange', 'yellow green',
'blue green', 'blue violet', 'red violet',
'bright red', # backwards compatibility
),
*( # common names
prefix + color
for color in (
'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet',
'brown', 'grey', 'gray',
)
for prefix in ('', 'light ', 'dark ', 'medium ', 'pale ')
)
)
COLORS_REMOVE = (
# filter these out, let's try to be professional here...
'shit',
'poop',
'poo',
'pee',
'piss',
'puke',
'vomit',
'snot',
'booger',
'bile',
'diarrhea',
'icky',
'sickly',
)
COLORS_REPLACE = (
# prevent registering similar-sounding names
# these can all be combined
('/', ' '), # convert [color1]/[color2] to compound (e.g. grey/blue to grey blue)
("'s", 's'), # robin's egg
('egg blue', 'egg'), # robin's egg blue
('grey', 'gray'), # 'Murica
('ochre', 'ocher'), # ...
('forrest', 'forest'), # ...
('ocre', 'ocher'), # correct spelling
('kelley', 'kelly'), # ...
('reddish', 'red'), # remove [color]ish where it modifies the spelling of color
('purplish', 'purple'), # ...
('pinkish', 'pink'),
('yellowish', 'yellow'),
('bluish', 'blue'),
('greyish', 'grey'),
('ish', ''), # these are all [color]ish ('ish' substring appears nowhere else)
('bluey', 'blue'), # remove [color]y trailing y
('greeny', 'green'), # ...
('reddy', 'red'),
('pinky', 'pink'),
('purply', 'purple'),
('purpley', 'purple'),
('yellowy', 'yellow'),
('orangey', 'orange'),
('browny', 'brown'),
('minty', 'mint'), # now remove [object]y trailing y
('grassy', 'grass'), # ...
('mossy', 'moss'),
('dusky', 'dusk'),
('rusty', 'rust'),
('muddy', 'mud'),
('sandy', 'sand'),
('leafy', 'leaf'),
('dusty', 'dust'),
('dirty', 'dirt'),
('peachy', 'peach'),
('stormy', 'storm'),
('cloudy', 'cloud'),
('grayblue', 'gray blue'), # separate merge compounds
('bluegray', 'gray blue'), # ...
('lightblue', 'light blue'),
('yellowgreen', 'yellow green'),
('yelloworange', 'yellow orange'),
)
# Simple snippets
_N_docstring = """
N : int, default: :rc:`image.lut`
Number of points in the colormap lookup table.
"""
_alpha_docstring = """
alpha : float, optional
The opacity for the entire colormap. This overrides
the input opacities.
"""
_cyclic_docstring = """
cyclic : bool, optional
Whether the colormap is cyclic. If ``True``, this changes how the leftmost
and rightmost color levels are selected, and `extend` can only be
``'neither'`` (a warning will be issued otherwise).
"""
_gamma_docstring = """
gamma : float, optional
Set `gamma1` and `gamma2` to this identical value.
gamma1 : float, optional
If greater than 1, make low saturation colors more prominent. If
less than 1, make high saturation colors more prominent. Similar to
the `HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
gamma2 : float, optional
If greater than 1, make high luminance colors more prominent. If
less than 1, make low luminance colors more prominent. Similar to
the `HCLWizard <http://hclwizard.org:64230/hclwizard/>`_ option.
"""
_space_docstring = """
space : {'hsl', 'hpl', 'hcl', 'hsv'}, optional
The hue, saturation, luminance-style colorspace to use for interpreting
the channels. See `this page <http://www.hsluv.org/comparison/>`__ for
a full description.
"""
_name_docstring = """
name : str, default: '_no_name'
The colormap name. This can also be passed as the first
positional string argument.
"""
_ratios_docstring = """
ratios : sequence of float, optional
Relative extents of each color transition. Must have length
``len(colors) - 1``. Larger numbers indicate a slower
transition, smaller numbers indicate a faster transition.
"""
docstring._snippet_manager['colors.N'] = _N_docstring
docstring._snippet_manager['colors.alpha'] = _alpha_docstring
docstring._snippet_manager['colors.cyclic'] = _cyclic_docstring
docstring._snippet_manager['colors.gamma'] = _gamma_docstring
docstring._snippet_manager['colors.space'] = _space_docstring
docstring._snippet_manager['colors.ratios'] = _ratios_docstring
docstring._snippet_manager['colors.name'] = _name_docstring
# List classmethod snippets
_from_list_docstring = """
colors : sequence of color-spec or tuple
If a sequence of RGB[A] tuples or color strings, the colormap
transitions evenly from ``colors[0]`` at the left-hand side
to ``colors[-1]`` at the right-hand side.
If a sequence of (float, color-spec) tuples, the float values are the
coordinate of each transition and must range from 0 to 1. This
can be used to divide the colormap range unevenly.
%(colors.name)s
%(colors.ratios)s
For example, ``('red', 'blue', 'green')`` with ``ratios=(2, 1)``
creates a colormap with the transition from red to blue taking
*twice as long* as the transition from blue to green.
"""
docstring._snippet_manager['colors.from_list'] = _from_list_docstring
def _clip_colors(colors, clip=True, gray=0.2, warn=False):
"""
Clip impossible colors rendered in an HSL-to-RGB colorspace
conversion. Used by `PerceptualColormap`.
Parameters
----------
colors : sequence of 3-tuple
The RGB colors.
clip : bool, optional
If `clip` is ``True`` (the default), RGB channel values >1 are
clipped to 1. Otherwise, the color is masked out as gray.
gray : float, optional
The identical RGB channel values (gray color) to be used if
`clip` is ``True``.
warn : bool, optional
Whether to issue warning when colors are clipped.
"""
colors = np.asarray(colors)
under = colors < 0
over = colors > 1
if clip:
colors[under], colors[over] = 0, 1
else:
colors[under | over] = gray
if warn:
msg = 'Clipped' if clip else 'Invalid'
for i, name in enumerate('rgb'):
if np.any(under[:, i]) or np.any(over[:, i]):
warnings._warn_proplot(f'{msg} {name!r} channel.')
return colors
def _get_channel(color, channel, space='hcl'):
"""
Get the hue, saturation, or luminance channel value from the input color. The
color name `color` can optionally be a string with the format ``'color+x'``
or ``'color-x'``, where `x` is the offset from the channel value.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgba`.
channel : optional
The HCL channel to be retrieved.
space : optional
The colorspace for the corresponding channel value.
Returns
-------
value : float
The channel value.
"""
# Interpret channel
if callable(color) or isinstance(color, Number):
return color
if channel == 'hue':
channel = 0
elif channel in ('chroma', 'saturation'):
channel = 1
elif channel == 'luminance':
channel = 2
else:
raise ValueError(f'Unknown channel {channel!r}.')
# Interpret string or RGB tuple
offset = 0
if isinstance(color, str):
m = re.search('([-+][0-9.]+)$', color)
if m:
offset = float(m.group(0))
color = color[:m.start()]
return offset + to_xyz(color, space)[channel]
def _make_segment_data(values, coords=None, ratios=None):
"""
Return a segmentdata array or callable given the input colors
and coordinates.
Parameters
----------
values : sequence of float
The channel values.
coords : sequence of float, optional
The segment coordinates.
ratios : sequence of float, optional
The relative length of each segment transition.
"""
# Allow callables
if callable(values):
return values
values = np.atleast_1d(values)
if len(values) == 1:
value = values[0]
return [(0, value, value), (1, value, value)]
# Get coordinates
if not np.iterable(values):
raise TypeError('Colors must be iterable, got {values!r}.')
if coords is not None:
coords = np.atleast_1d(coords)
if ratios is not None:
warnings._warn_proplot(
f'Segment coordinates were provided, ignoring '
f'ratios={ratios!r}.'
)
if len(coords) != len(values) or coords[0] != 0 or coords[-1] != 1:
raise ValueError(
f'Coordinates must range from 0 to 1, got {coords!r}.'
)
elif ratios is not None:
coords = np.atleast_1d(ratios)
if len(coords) != len(values) - 1:
raise ValueError(
f'Need {len(values) - 1} ratios for {len(values)} colors, '
f'but got {len(coords)} ratios.'
)
coords = np.concatenate(([0], np.cumsum(coords)))
coords = coords / np.max(coords) # normalize to 0-1
else:
coords = np.linspace(0, 1, len(values))
# Build segmentdata array
array = []
for c, value in zip(coords, values):
array.append((c, value, value))
return array
def _make_lookup_table(N, data, gamma=1.0, inverse=False):
r"""
Generate lookup tables of HSL values given specified gradations. Similar to
`~matplotlib.colors.makeMappingArray` but permits *circular* hue gradations,
disables clipping of out-of-bounds values, and uses fancier "gamma" scaling.
Parameters
----------
N : int
Number of points in the colormap lookup table.
data : array-like
Sequence of `(x, y_0, y_1)` tuples specifying channel jumps
(from `y_0` to `y_1`) and `x` coordinate of those jumps
(ranges between 0 and 1). See `~matplotlib.colors.LinearSegmentedColormap`.
gamma : float or sequence of float, optional
To obtain channel values between coordinates `x_i` and `x_{i+1}`
in rows `i` and `i+1` of `data` we use the formula:
.. math::
y = y_{1,i} + w_i^{\gamma_i}*(y_{0,i+1} - y_{1,i})
where `\gamma_i` corresponds to `gamma` and the weight `w_i` ranges from
0 to 1 between rows ``i`` and ``i+1``. If `gamma` is float, it applies
to every transition. Otherwise, its length must equal ``data.shape[0]-1``.
This is similar to the `matplotlib.colors.makeMappingArray` `gamma` except
it controls the weighting for transitions *between* each segment data
coordinate rather than the coordinates themselves. This makes more sense
for `PerceptualColormap`\ s because they usually contain just a
handful of transitions representing chained segments.
inverse : bool, optional
If ``True``, `w_i^{\gamma_i}` is replaced with `1 - (1 - w_i)^{\gamma_i}` --
that is, when `gamma` is greater than 1, this weights colors toward *higher*
channel values instead of lower channel values.
This is implemented in case we want to apply *equal* "gamma scaling"
to different HSL channels in different directions. Usually, this
is done to weight low data values with higher luminance *and* lower
saturation, thereby emphasizing "extreme" data values.
"""
# Allow for *callable* instead of linearly interpolating between segments
gammas = np.atleast_1d(gamma)
if np.any(gammas < 0.01) or np.any(gammas > 10):
raise ValueError('Gamma can only be in range [0.01,10].')
if callable(data):
if len(gammas) > 1:
raise ValueError('Only one gamma allowed for functional segmentdata.')
x = np.linspace(0, 1, N)**gamma
lut = np.array(data(x), dtype=float)
return lut
# Get array
data = np.array(data)
shape = data.shape
if len(shape) != 2 or shape[1] != 3:
raise ValueError('Mapping data must have shape N x 3.')
if len(gammas) != 1 and len(gammas) != shape[0] - 1:
raise ValueError(f'Expected {shape[0] - 1} gammas for {shape[0]} coords. Got {len(gamma)}.') # noqa: E501
if len(gammas) == 1:
gammas = np.repeat(gammas, shape[:1])
# Get indices
x = data[:, 0]
y0 = data[:, 1]
y1 = data[:, 2]
if x[0] != 0.0 or x[-1] != 1.0:
raise ValueError('Data mapping points must start with x=0 and end with x=1.')
if np.any(np.diff(x) < 0):
raise ValueError('Data mapping points must have x in increasing order.')
x = x * (N - 1)
# Get distances from the segmentdata entry to the *left* for each requested
# level, excluding ends at (0, 1), which must exactly match segmentdata ends.
# NOTE: numpy.searchsorted returns where xq[i] must be inserted so it is
# larger than x[ind[i]-1] but smaller than x[ind[i]].
xq = (N - 1) * np.linspace(0, 1, N)
ind = np.searchsorted(x, xq)[1:-1]
offsets = (xq[1:-1] - x[ind - 1]) / (x[ind] - x[ind - 1])
# Scale distances in each segment by input gamma
# The ui are starting-points, the ci are counts from that point over which
# segment applies (i.e. where to apply the gamma), the relevant 'segment'
# is to the *left* of index returned by searchsorted
_, uind, cind = np.unique(ind, return_index=True, return_counts=True)
for ui, ci in zip(uind, cind): # length should be N-1
gamma = gammas[ind[ui] - 1] # the relevant segment is *left* of this number
if gamma == 1:
continue
if ci == 0: # no lookup table coordinates fall inside this segment
reverse = False
else: # reverse if we are transitioning to *lower* channel value
reverse = (y0[ind[ui]] - y1[ind[ui] - 1]) < 0
if inverse: # reverse if we are transitioning to *higher* channel value
reverse = not reverse
if reverse:
offsets[ui:ui + ci] = 1 - (1 - offsets[ui:ui + ci]) ** gamma
else:
offsets[ui:ui + ci] **= gamma
# Perform successive linear interpolations rolled up into one equation
lut = np.zeros((N,), float)
lut[1:-1] = y1[ind - 1] + offsets * (y0[ind] - y1[ind - 1])
lut[0] = y1[0]
lut[-1] = y0[-1]
return lut
def _load_colors(path, warn_on_failure=True):
"""
Read colors from the input file.
Parameters
----------
warn_on_failure : bool, optional
If ``True``, issue a warning when loading fails instead of raising an error.
"""
# Warn or raise error (matches Colormap._from_file behavior)
if not os.path.exists(path):
message = f'Failed to load color data file {path!r}. File not found.'
if warn_on_failure:
warnings._warn_proplot(message)
else:
raise FileNotFoundError(message)
# Iterate through lines
loaded = {}
with open(path, 'r') as fh:
for count, line in enumerate(fh):
stripped = line.strip()
if not stripped or stripped[0] == '#':
continue
pair = tuple(item.strip().lower() for item in line.split(':'))
if len(pair) != 2 or not REGEX_HEX_SINGLE.match(pair[1]):
warnings._warn_proplot(
f'Illegal line #{count + 1} in color file {path!r}:\n'
f'{line!r}\n'
f'Lines must be formatted as "name: hexcolor".'
)
continue
loaded[pair[0]] = pair[1]
return loaded
def _standardize_colors(input, space, margin):
"""
Standardize the input colors.
Parameters
----------
input : dict
The colors.
space : optional
The colorspace used to filter colors.
margin : optional
The proportional margin required for unique colors (e.g. 0.1
is 36 hue units, 10 saturation units, 10 luminance units).
"""
output = {}
colors = []
channels = []
# Always add these colors and ignore other colors that are too close
# We do this for colors with nice names or that proplot devs really like
for name in COLORS_KEEP:
color = input.pop(name, None)
if color is None:
continue
if 'grey' in name:
name = name.replace('grey', 'gray')
colors.append((name, color))
channels.append(to_xyz(color, space=space))
output[name] = color # required in case "kept" colors are close to each other
# Translate remaining colors and remove bad names
# WARNING: Unique axis argument requires numpy version >=1.13
for name, color in input.items():
for sub, rep in COLORS_REPLACE:
if sub in name:
name = name.replace(sub, rep)
if any(sub in name for sub in COLORS_REMOVE):
continue # remove "unpofessional" names
if name in output:
continue # prioritize names that come first
colors.append((name, color)) # category name pair
channels.append(to_xyz(color, space=space))
# Get locations of "perceptually distinct" colors
channels = np.asarray(channels)
if not channels.size:
return output
channels = channels / np.array([360, 100, 100])
channels = np.round(channels / margin).astype(np.int64)
_, idxs = np.unique(channels, return_index=True, axis=0)
# Return only "distinct" colors
for idx in idxs:
name, color = colors[idx]
output[name] = color
return output
class _Colormap(object):
"""
Mixin class used to add some helper methods.
"""
def _get_data(self, ext, alpha=True):
"""
Return a string containing the colormap colors for saving.
Parameters
----------
ext : {'hex', 'txt', 'rgb'}
The filename extension.
alpha : bool, optional
Whether to include an opacity column.
"""
# Get lookup table colors and filter out bad ones
if not self._isinit:
self._init()
colors = self._lut[:-3, :]
# Get data string
if ext == 'hex':
data = ', '.join(mcolors.to_hex(color) for color in colors)
elif ext in ('txt', 'rgb'):
rgb = mcolors.to_rgba if alpha else mcolors.to_rgb
data = [rgb(color) for color in colors]
data = '\n'.join(' '.join(f'{num:0.6f}' for num in line) for line in data)
else:
raise ValueError(
f'Invalid extension {ext!r}. Options are: '
"'hex', 'txt', 'rgb', 'rgba'."
)
return data
def _make_name(self, suffix=None):
"""
Generate a default colormap name. Do not append more than one
leading underscore or more than one identical suffix.
"""
name = self.name
name = name or ''
if name[:1] != '_':
name = '_' + name
suffix = suffix or 'copy'
suffix = '_' + suffix
if name[-len(suffix):] != suffix:
name = name + suffix
return name
def _parse_path(self, path, ext=None, subfolder=None):
"""
Parse the user input path.
Parameters
----------
path : path-like, optional
The file path.
ext : str
The default extension.
subfolder : str, optional
The subfolder.
"""
# Get the folder
folder = rc.user_folder(subfolder=subfolder)
if path is not None:
path = os.path.expanduser(path or '.') # interpret empty string as '.'
if os.path.isdir(path):
folder, path = path, None
# Get the filename
if path is None:
path = os.path.join(folder, self.name)
if not os.path.splitext(path)[1]:
path = path + '.' + ext # default file extension
return path
@staticmethod
def _pop_args(*args, names=None, **kwargs):
"""
Pop the name as a first positional argument or keyword argument.
Supports matplotlib-style ``Colormap(name, data, N)`` input
algongside more intuitive ``Colormap(data, name, N)`` input.
"""
names = names or ()
if isinstance(names, str):
names = (names,)
names = ('name', *names)
args, kwargs = _kwargs_to_args(names, *args, **kwargs)
if args[0] is not None and args[1] is None:
args[:2] = (None, args[0])
if args[0] is None:
args[0] = DEFAULT_NAME
return (*args, kwargs)
@classmethod
def _from_file(cls, path, warn_on_failure=False):
"""
Read generalized colormap and color cycle files.
"""
path = os.path.expanduser(path)
name, ext = os.path.splitext(os.path.basename(path))
listed = issubclass(cls, mcolors.ListedColormap)
reversed = name[-2:] == '_r'
# Warn if loading failed during `register_cmaps` or `register_cycles`
# but raise error if user tries to load a file.
def _warn_or_raise(descrip, error=RuntimeError):
prefix = f'Failed to load colormap or color cycle file {path!r}.'
if warn_on_failure:
warnings._warn_proplot(prefix + ' ' + descrip)
else:
raise error(prefix + ' ' + descrip)
if not os.path.exists(path):
return _warn_or_raise('File not found.', FileNotFoundError)
# Directly read segmentdata json file
# NOTE: This is special case! Immediately return name and cmap
ext = ext[1:]
if ext == 'json':
if listed:
return _warn_or_raise('Cannot load cycles from JSON files.')
try:
with open(path, 'r') as fh:
data = json.load(fh)
except json.JSONDecodeError:
return _warn_or_raise('JSON decoding error.', json.JSONDecodeError)
kw = {}
for key in ('cyclic', 'gamma', 'gamma1', 'gamma2', 'space'):
if key in data:
kw[key] = data.pop(key, None)
if 'red' in data:
cmap = ContinuousColormap(name, data)
else:
cmap = PerceptualColormap(name, data, **kw)
if reversed:
cmap = cmap.reversed(name[:-2])
return cmap
# Read .rgb and .rgba files
if ext in ('txt', 'rgb'):
# Load file
# NOTE: This appears to be biggest import time bottleneck! Increases
# time from 0.05s to 0.2s, with numpy loadtxt or with this regex thing.
delim = re.compile(r'[,\s]+')
data = [
delim.split(line.strip())
for line in open(path)
if line.strip() and line.strip()[0] != '#'
]
try:
data = [[float(num) for num in line] for line in data]
except ValueError:
return _warn_or_raise(
'Expected a table of comma or space-separated floats.'
)
# Build x-coordinates and standardize shape
data = np.array(data)
if data.shape[1] not in (3, 4):
return _warn_or_raise(
f'Expected 3 or 4 columns of floats. Got {data.shape[1]} columns.'
)
if ext[0] != 'x': # i.e. no x-coordinates specified explicitly
x = np.linspace(0, 1, data.shape[0])
else:
x, data = data[:, 0], data[:, 1:]
# Load XML files created with scivizcolor
# Adapted from script found here:
# https://sciviscolor.org/matlab-matplotlib-pv44/
elif ext == 'xml':
try:
doc = ElementTree.parse(path)
except ElementTree.ParseError:
return _warn_or_raise('XML parsing error.', ElementTree.ParseError)
x, data = [], []
for s in doc.getroot().findall('.//Point'):
# Verify keys
if any(key not in s.attrib for key in 'xrgb'):
return _warn_or_raise(
'Missing an x, r, g, or b key inside one or more <Point> tags.'
)
# Get data
color = []
for key in 'rgbao': # o for opacity
if key not in s.attrib:
continue
color.append(float(s.attrib[key]))
x.append(float(s.attrib['x']))
data.append(color)
# Convert to array
if not all(
len(data[0]) == len(color) and len(color) in (3, 4) for color in data
):
return _warn_or_raise(
'Unexpected channel number or mixed channels across <Point> tags.'
)
# Read hex strings
elif ext == 'hex':
# Read arbitrary format
string = open(path).read() # into single string
data = REGEX_HEX_MULTI.findall(string)
if len(data) < 2:
return _warn_or_raise(
'Failed to find 6-digit or 8-digit HEX strings.'
)
# Convert to array
x = np.linspace(0, 1, len(data))
data = [to_rgb(color) for color in data]
# Invalid extension
else:
return _warn_or_raise(
'Unknown colormap file extension {ext!r}. Options are: '
+ ', '.join(map(repr, ('json', 'txt', 'rgb', 'hex')))
+ '.'
)
# Standardize and reverse if necessary to cmap
# TODO: Document the fact that filenames ending in _r return a reversed
# version of the colormap stored in that file.
x = np.array(x)
x = (x - x.min()) / (x.max() - x.min()) # ensure they span 0-1
data = np.array(data)
if np.any(data > 2): # from 0-255 to 0-1
data = data / 255
if reversed:
name = name[:-2]
data = data[::-1, :]
x = 1 - x[::-1]
if listed:
return DiscreteColormap(data, name)
else:
data = [(x, color) for x, color in zip(x, data)]
return ContinuousColormap.from_list(name, data)
class ContinuousColormap(mcolors.LinearSegmentedColormap, _Colormap):
r"""
Replacement for `~matplotlib.colors.LinearSegmentedColormap`.
"""
def __str__(self):
return type(self).__name__ + f'(name={self.name!r})'
def __repr__(self):
string = f" 'name': {self.name!r},\n"
if hasattr(self, '_space'):
string += f" 'space': {self._space!r},\n"
if hasattr(self, '_cyclic'):
string += f" 'cyclic': {self._cyclic!r},\n"
for key, data in self._segmentdata.items():
if callable(data):
string += f' {key!r}: <function>,\n'
else:
stop = data[-1][1]
start = data[0][2]
string += f' {key!r}: [{start:.2f}, ..., {stop:.2f}],\n'
return type(self).__name__ + '({\n' + string + '})'
@docstring._snippet_manager
def __init__(self, *args, gamma=1, alpha=None, cyclic=False, **kwargs):
"""
Parameters
----------
segmentdata : dict-like
Dictionary containing the keys ``'red'``, ``'green'``, ``'blue'``, and
(optionally) ``'alpha'``. The shorthands ``'r'``, ``'g'``, ``'b'``,
and ``'a'`` are also acceptable. The key values can be callable
functions that return channel values given a colormap index, or
3-column arrays indicating the coordinates and channel transitions. See
`matplotlib.colors.LinearSegmentedColormap` for a detailed explanation.
%(colors.name)s
%(colors.N)s
gamma : float, optional
Gamma scaling used for the *x* coordinates.
%(colors.alpha)s
%(colors.cyclic)s
Other parameters
----------------
**kwargs
Passed to `matplotlib.colors.LinearSegmentedColormap`.
See also
--------
DiscreteColormap
matplotlib.colors.LinearSegmentedColormap
proplot.constructor.Colormap
"""
# NOTE: Additional keyword args should raise matplotlib error
name, segmentdata, N, kwargs = self._pop_args(
*args, names=('segmentdata', 'N'), **kwargs
)
if not isinstance(segmentdata, dict):
raise ValueError(f'Invalid segmentdata {segmentdata}. Must be a dict.')
N = _not_none(N, rc['image.lut'])
data = _pop_props(segmentdata, 'rgba', 'hsla')
if segmentdata:
raise ValueError(f'Invalid segmentdata keys {tuple(segmentdata)}.')
super().__init__(name, data, N=N, gamma=gamma, **kwargs)
self._cyclic = cyclic
if alpha is not None:
self.set_alpha(alpha)
def append(self, *args, ratios=None, name=None, N=None, **kwargs):
"""
Return the concatenation of this colormap with the
input colormaps.
Parameters
----------
*args
Instances of `ContinuousColormap`.
ratios : sequence of float, optional
Relative extent of each component colormap in the
merged colormap. Length must equal ``len(args) + 1``.
For example, ``cmap1.append(cmap2, ratios=(2, 1))`` generates
a colormap with the left two-thrids containing colors from
``cmap1`` and the right one-third containing colors from ``cmap2``.
name : str, optional
The colormap name. Default is to merge each name with underscores and
prepend a leading underscore, for example ``_name1_name2``.
N : int, optional
The number of points in the colormap lookup table. Default is
to sum the length of each lookup table.
Other parameters
----------------