forked from proplot-dev/proplot
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstyletools.py
More file actions
3949 lines (3643 loc) · 147 KB
/
Copy pathstyletools.py
File metadata and controls
3949 lines (3643 loc) · 147 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
"""
Tools for registering and visualizing colormaps, color cycles, color string
names, and fonts. New colormap classes, new colormap normalizer
classes, and new constructor functions for generating instances of these
classes. Related utilities for manipulating colors. See
:ref:`Colormaps`, :ref:`Color cycles`, and :ref:`Colors and fonts`
for details.
"""
# Potential bottleneck, loading all this stuff? *No*. Try using @timer on
# register functions, turns out worst is colormap one at 0.1 seconds.
import os
import re
import json
import glob
import cycler
from xml.etree import ElementTree
from numbers import Number, Integral
from matplotlib import rcParams
import numpy as np
import numpy.ma as ma
import matplotlib.colors as mcolors
import matplotlib.cm as mcm
from .utils import _warn_proplot, _notNone, _timer
from .external import hsluv
try: # use this for debugging instead of print()!
from icecream import ic
except ImportError: # graceful fallback if IceCream isn't installed
ic = lambda *a: None if not a else (a[0] if len(a) == 1 else a) # noqa
__all__ = [
'BinNorm', 'CmapDict', 'ColorDict',
'LinearSegmentedNorm',
'LinearSegmentedColormap',
'ListedColormap',
'MidpointNorm', 'PerceptuallyUniformColormap',
'cmaps', 'colors', 'cycles', 'fonts',
'make_mapping_array',
'register_cmaps', 'register_colors', 'register_cycles', 'register_fonts',
'saturate', 'shade', 'show_cmaps', 'show_channels',
'show_colors', 'show_colorspaces', 'show_cycles', 'show_fonts',
'to_rgb', 'to_xyz',
'Colormap', 'Colors', 'Cycle', 'Norm',
]
# Colormap stuff
CYCLES_TABLE = {
'Matplotlib defaults': (
'default', 'classic',
),
'Matplotlib stylesheets': (
'colorblind', 'colorblind10', 'ggplot', 'bmh', 'solarized', '538',
),
'ColorBrewer2.0 qualitative': (
'Accent', 'Dark2',
'Paired', 'Pastel1', 'Pastel2',
'Set1', 'Set2', 'Set3',
),
'Other qualitative': (
'FlatUI', 'Qual1', 'Qual2',
),
}
CMAPS_TABLE = {
# Assorted origin, but these belong together
'Grayscale': (
'Grays', 'Mono', 'GrayC', 'GrayCycle',
),
# Builtin
'Matplotlib sequential': (
'viridis', 'plasma', 'inferno', 'magma', 'cividis',
),
'Matplotlib cyclic': (
'twilight',
),
# Seaborn
'Seaborn sequential': (
'Rocket', 'Mako',
),
'Seaborn diverging': (
'IceFire', 'Vlag',
),
# PerceptuallyUniformColormap
'ProPlot sequential': (
'Fire',
'Stellar',
'Boreal',
'Marine',
'Dusk',
'Glacial',
'Sunrise',
'Sunset',
),
'ProPlot diverging': (
'Div', 'NegPos', 'DryWet',
),
# Nice diverging maps
'Other diverging': (
'ColdHot', 'CoolWarm', 'BR',
),
# cmOcean
'cmOcean sequential': (
'Oxy', 'Thermal', 'Dense', 'Ice', 'Haline',
'Deep', 'Algae', 'Tempo', 'Speed', 'Turbid', 'Solar', 'Matter',
'Amp',
),
'cmOcean diverging': (
'Balance', 'Delta', 'Curl',
),
'cmOcean cyclic': (
'Phase',
),
# Fabio Crameri
'Scientific colour maps sequential': (
'batlow', 'oleron',
'devon', 'davos', 'oslo', 'lapaz', 'acton',
'lajolla', 'bilbao', 'tokyo', 'turku', 'bamako', 'nuuk',
'hawaii', 'buda', 'imola',
),
'Scientific colour maps diverging': (
'roma', 'broc', 'cork', 'vik', 'berlin', 'lisbon', 'tofino',
),
'Scientific colour maps cyclic': (
'romaO', 'brocO', 'corkO', 'vikO',
),
# ColorBrewer
'ColorBrewer2.0 sequential': (
'Purples', 'Blues', 'Greens', 'Oranges', 'Reds',
'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu',
'PuBu', 'PuBuGn', 'BuGn', 'GnBu', 'YlGnBu', 'YlGn'
),
'ColorBrewer2.0 diverging': (
'Spectral', 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGY',
'RdBu', 'RdYlBu', 'RdYlGn',
),
# SciVisColor
'SciVisColor blues': (
'Blue0', 'Blue1', 'Blue2', 'Blue3', 'Blue4', 'Blue5',
'Blue6', 'Blue7', 'Blue8', 'Blue9', 'Blue10', 'Blue11',
),
'SciVisColor greens': (
'Green1', 'Green2', 'Green3', 'Green4', 'Green5',
'Green6', 'Green7', 'Green8',
),
'SciVisColor oranges': (
'Orange1', 'Orange2', 'Orange3', 'Orange4', 'Orange5',
'Orange6', 'Orange7', 'Orange8',
),
'SciVisColor browns': (
'Brown1', 'Brown2', 'Brown3', 'Brown4', 'Brown5',
'Brown6', 'Brown7', 'Brown8', 'Brown9',
),
'SciVisColor reds and purples': (
'RedPurple1', 'RedPurple2', 'RedPurple3', 'RedPurple4',
'RedPurple5', 'RedPurple6', 'RedPurple7', 'RedPurple8',
),
# Builtin maps that will be deleted; categories are taken from comments in
# matplotlib source code. Some of these are really bad, some are segmented
# maps when the should be color cycles, and some are just uninspiring.
'MATLAB': (
'bone', 'cool', 'copper', 'autumn', 'flag', 'prism',
'jet', 'hsv', 'hot', 'spring', 'summer', 'winter', 'pink', 'gray',
),
'GNUplot': (
'gnuplot', 'gnuplot2', 'ocean', 'afmhot', 'rainbow',
),
'GIST': (
'gist_earth', 'gist_gray', 'gist_heat', 'gist_ncar',
'gist_rainbow', 'gist_stern', 'gist_yarg',
),
'Other': (
'binary', 'bwr', 'brg', # appear to be custom matplotlib
'cubehelix', 'Wistia', 'CMRmap', # individually released
'seismic', 'terrain', 'nipy_spectral', # origin ambiguous
'tab10', 'tab20', 'tab20b', 'tab20c', # merged colormap cycles
)
}
CMAPS_DIVERGING = tuple(
(key1.lower(), key2.lower()) for key1, key2 in (
('PiYG', 'GYPi'),
('PRGn', 'GnRP'),
('BrBG', 'GBBr'),
('PuOr', 'OrPu'),
('RdGy', 'GyRd'),
('RdBu', 'BuRd'),
('RdYlBu', 'BuYlRd'),
('RdYlGn', 'GnYlRd'),
('BR', 'RB'),
('CoolWarm', 'WarmCool'),
('ColdHot', 'HotCold'),
('NegPos', 'PosNeg'),
('DryWet', 'WetDry')
))
# Named color filter props
COLORS_SPACE = 'hcl' # color "distincness" is defined with this space
COLORS_THRESH = 0.10 # bigger number equals fewer colors
COLORS_TRANSLATIONS = tuple((re.compile(regex), sub) for regex, sub in (
('/', ' '),
('\'s', ''),
(r'\s?majesty', ''), # purple mountains majesty is too long
('reddish', 'red'), # remove 'ish'
('purplish', 'purple'),
('bluish', 'blue'),
(r'ish\b', ''),
('grey', 'gray'),
('pinky', 'pink'),
('greeny', 'green'),
('bluey', 'blue'),
('purply', 'purple'),
('purpley', 'purple'),
('yellowy', 'yellow'),
('robin egg', 'robins egg'),
('egg blue', 'egg'),
('bluegray', 'blue gray'),
('grayblue', 'gray blue'),
('lightblue', 'light blue'),
)) # prevent registering similar-sounding names
COLORS_IGNORE = re.compile('(' + '|'.join((
'shit', 'poop', 'poo', 'pee', 'piss', 'puke', 'vomit', 'snot',
'booger', 'bile', 'diarrhea',
)) + ')') # filter these out, let's try to be professional here...
COLORS_INCLUDE = (
'charcoal', 'sky blue', 'eggshell', 'sea blue', 'coral', 'aqua',
'tomato red', 'brick red', 'crimson',
'red orange', 'yellow orange', 'yellow green', 'blue green',
'blue violet', 'red violet',
) # common names that should always be included
COLORS_OPEN = (
'red', 'pink', 'grape', 'violet',
'indigo', 'blue', 'cyan', 'teal',
'green', 'lime', 'yellow', 'orange', 'gray'
)
COLORS_BASE = {
'blue': (0, 0, 1),
'green': (0, 0.5, 0),
'red': (1, 0, 0),
'cyan': (0, 0.75, 0.75),
'magenta': (0.75, 0, 0.75),
'yellow': (0.75, 0.75, 0),
'black': (0, 0, 0),
'white': (1, 1, 1),
}
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` specifies the offset from the
channel value.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
channel : {'hue', 'chroma', 'saturation', 'luminance'}
The HCL channel to be retrieved.
space : {'hcl', 'hpl', 'hsl', 'hsv', 'rgb'}, 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):
match = re.search('([-+][0-9.]+)$', color)
if match:
offset = float(match.group(0))
color = color[:match.start()]
return offset + to_xyz(color, space)[channel]
def shade(color, scale=1):
"""
Scale the luminance channel of the input color.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
scale : float, optoinal
The luminance channel is multiplied by this value.
Returns
-------
color
The new RGB tuple.
"""
*color, alpha = to_rgb(color, alpha=True)
color = [*hsluv.rgb_to_hsl(*color)]
# multiply luminance by this value
color[2] = max(0, min(color[2] * scale, 100))
color = [*hsluv.hsl_to_rgb(*color)]
return (*color, alpha)
def saturate(color, scale=0.5):
"""
Scale the saturation channel of the input color.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
scale : float, optoinal
The HCL saturation channel is multiplied by this value.
Returns
-------
color
The new RGB tuple.
"""
*color, alpha = to_rgb(color, alpha=True)
color = [*hsluv.rgb_to_hsl(*color)]
# multiply luminance by this value
color[1] = max(0, min(color[1] * scale, 100))
color = [*hsluv.hsl_to_rgb(*color)]
return (*color, alpha)
def to_rgb(color, space='rgb', cycle=None, alpha=False):
"""
Translate the color in *any* format and from *any* colorspace to an RGB
tuple. This is a generalization of `matplotlib.colors.to_rgb` and the
inverse of `to_xyz`.
Parameters
----------
color : str or length-3 list
The color specification. Can be a tuple of channel values for the
`space` colorspace, a hex string, a registered color name, a cycle
color, or a colormap color (see `ColorDict`).
If `space` is ``'rgb'``, this is a tuple of RGB values, and any
channels are larger than ``2``, the channels are assumed to be on
a ``0`` to ``255`` scale and are therefore divided by ``255``.
space : {'rgb', 'hsv', 'hsl', 'hpl', 'hcl'}, optional
The colorspace for the input channel values. Ignored unless `color` is
an container of numbers.
cycle : str or list, optional
The registered color cycle name used to interpret colors that
look like ``'C0'``, ``'C1'``, etc. Default is :rc:`cycle`.
alpha : bool, optional
Whether to preserve the opacity channel, if it exists. Default
is ``False``.
Returns
-------
color
The RGB tuple.
"""
# Convert color cycle strings
if isinstance(color, str) and re.match('^C[0-9]$', color):
if isinstance(cycle, str):
try:
cycle = mcm.cmap_d[cycle].colors
except (KeyError, AttributeError):
cycles = sorted(
name for name, cmap in mcm.cmap_d.items()
if isinstance(cmap, ListedColormap)
)
raise ValueError(
f'Invalid cycle {cycle!r}. Options are: '
+ ', '.join(map(repr, cycles)) + '.'
)
elif cycle is None:
cycle = rcParams['axes.prop_cycle'].by_key()
if 'color' not in cycle:
cycle = ['k']
else:
cycle = cycle['color']
else:
raise ValueError(f'Invalid cycle {cycle!r}.')
color = cycle[int(color[-1]) % len(cycle)]
# Translate RGB strings and (cmap,index) tuples
opacity = 1
if isinstance(color, str) or (np.iterable(color) and len(color) == 2):
try:
*color, opacity = mcolors.to_rgba(color) # ensure is valid color
except (ValueError, TypeError):
raise ValueError(f'Invalid RGB argument {color!r}.')
# Pull out alpha channel
if len(color) == 4:
*color, opacity = color
elif len(color) != 3:
raise ValueError(f'Invalid RGB argument {color!r}.')
# Translate arbitrary colorspaces
if space == 'rgb':
try:
if any(c > 2 for c in color):
color = [c / 255 for c in color] # scale to within 0-1
color = tuple(color)
except (ValueError, TypeError):
raise ValueError(f'Invalid RGB argument {color!r}.')
elif space == 'hsv':
color = hsluv.hsl_to_rgb(*color)
elif space == 'hpl':
color = hsluv.hpluv_to_rgb(*color)
elif space == 'hsl':
color = hsluv.hsluv_to_rgb(*color)
elif space == 'hcl':
color = hsluv.hcl_to_rgb(*color)
else:
raise ValueError('Invalid color {color!r} for colorspace {space!r}.')
# Return RGB or RGBA
if alpha:
return (*color, opacity)
else:
return color
def to_xyz(color, space='hcl', alpha=False):
"""
Translate color in *any* format to a tuple of channel values in *any*
colorspace. This is the inverse of `to_rgb`.
Parameters
----------
color : color-spec
The color. Sanitized with `to_rgb`.
space : {'hcl', 'hpl', 'hsl', 'hsv', 'rgb'}, optional
The colorspace for the output channel values.
alpha : bool, optional
Whether to preserve the opacity channel, if it exists. Default
is ``False``.
Returns
-------
color
Tuple of colorspace `space` channel values.
"""
# Run tuple conversions
# NOTE: Don't pass color tuple, because we may want to permit
# out-of-bounds RGB values to invert conversion
*color, opacity = to_rgb(color, alpha=True)
if space == 'rgb':
pass
elif space == 'hsv':
color = hsluv.rgb_to_hsl(*color) # rgb_to_hsv would also work
elif space == 'hpl':
color = hsluv.rgb_to_hpluv(*color)
elif space == 'hsl':
color = hsluv.rgb_to_hsluv(*color)
elif space == 'hcl':
color = hsluv.rgb_to_hcl(*color)
else:
raise ValueError(f'Invalid colorspace {space}.')
if alpha:
return (*color, opacity)
else:
return color
def _clip_colors(colors, clip=True, gray=0.2):
"""
Clip impossible colors rendered in an HSL-to-RGB colorspace conversion.
Used by `PerceptuallyUniformColormap`. If `mask` is ``True``, impossible
colors are masked out.
Parameters
----------
colors : list of length-3 tuples
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 `mask`
is ``True``.
"""
# Clip colors
colors = np.array(colors)
over = (colors > 1)
under = (colors < 0)
if clip:
colors[under] = 0
colors[over] = 1
else:
colors[(under | over)] = gray
# Message
# NOTE: Never print warning because happens when using builtin maps
# message = 'Clipped' if clip else 'Invalid'
# for i,name in enumerate('rgb'):
# if under[:,i].any():
# _warn_proplot(f'{message} {name!r} channel ( < 0).')
# if over[:,i].any():
# _warn_proplot(f'{message} {name!r} channel ( > 1).')
return colors
def _make_segmentdata_array(values, coords=None, ratios=None):
"""
Return a segmentdata array or callable given the input colors
and coordinates.
Parameters
----------
values : list of float
The channel values.
coords : list of float, optional
The segment coordinates.
ratios : list 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:
_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(ratios)} 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_mapping_array(N, data, gamma=1.0, inverse=False):
r"""
Similar to `~matplotlib.colors.makeMappingArray` but permits
*circular* hue gradations along 0-360, disables clipping of
out-of-bounds channel values, and uses fancier "gamma" scaling.
Parameters
----------
N : int
Number of points in the colormap lookup table.
data : 2D array-like
List of :math:`(x, y_0, y_1)` tuples specifying the channel jump (from
:math:`y_0` to :math:`y_1`) and the :math:`x` coordinate of that
transition (ranges between 0 and 1).
See `~matplotlib.colors.LinearSegmentedColormap` for details.
gamma : float or list of float, optional
To obtain channel values between coordinates :math:`x_i` and
:math:`x_{i+1}` in rows :math:`i` and :math:`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 :math:`\gamma_i` corresponds to `gamma` and the weight
:math:`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 like the `gamma` used with matplotlib's
`~matplotlib.colors.makeMappingArray`, except it controls the
weighting for transitions *between* each segment data coordinate rather
than the coordinates themselves. This makes more sense for
`PerceptuallyUniformColormap`\\ s because they usually consist of just
one linear transition for *sequential* colormaps and two linear
transitions for *diverging* colormaps -- and in the latter case, it
is often desirable to modify both "halves" of the colormap in the
same way.
inverse : bool, optional
If ``True``, :math:`w_i^{\gamma_i}` is replaced with
:math:`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 with stronger
colors.
"""
# Allow for *callable* instead of linearly interpolating between segments
gammas = np.atleast_1d(gamma)
if (gammas < 0.01).any() or (gammas > 10).any():
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('Data must be nx3 format.')
if len(gammas) != 1 and len(gammas) != shape[0] - 1:
raise ValueError(
f'Need {shape[0]-1} gammas for {shape[0]}-level mapping array, '
f'but got {len(gamma)}.'
)
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.diff(x) < 0).any():
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
xq = (N - 1) * np.linspace(0, 1, N)
# where xq[i] must be inserted so it is larger than x[ind[i]-1] but
# smaller than x[ind[i]]
ind = np.searchsorted(x, xq)[1:-1]
distance = (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
# the relevant segment is to *left* of this number
gamma = gammas[ind[ui] - 1]
if gamma == 1:
continue
ireverse = False
if ci > 1: # i.e. more than 1 color in this 'segment'
# by default want to weight toward a *lower* channel value
ireverse = ((y0[ind[ui]] - y1[ind[ui] - 1]) < 0)
if inverse:
ireverse = (not ireverse)
if ireverse:
distance[ui:ui + ci] = 1 - (1 - distance[ui:ui + ci])**gamma
else:
distance[ui:ui + ci] **= gamma
# Perform successive linear interpolations all rolled up into one equation
lut = np.zeros((N,), float)
lut[1:-1] = distance * (y0[ind] - y1[ind - 1]) + y1[ind - 1]
lut[0] = y1[0]
lut[-1] = y0[-1]
return lut
_from_file_docstring = """
Valid file extensions are as follows:
================== =====================================================================================================================================================================================================================
Extension Description
================== =====================================================================================================================================================================================================================
``.hex`` List of HEX strings in any format (comma-separated, separate lines, with double quotes... anything goes).
``.xml`` XML files with ``<Point .../>`` tags specifying ``x``, ``r``, ``g``, ``b``, and (optionally) ``o`` parameters, where ``x`` is the coordinate and the rest are the red, blue, green, and opacity channel values.
``.rgb``, ``.txt`` 3-4 column table of red, blue, green, and (optionally) opacity channel values, delimited by commas or spaces. If values larger than 1 are detected, they are assumed to be on the 0-255 scale and are divided by 255.
================== =====================================================================================================================================================================================================================
Parameters
----------
path : str
The file path.
warn_on_failure : bool, optional
If ``True``, issue a warning when loading fails rather than
raising an error.
""" # noqa
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 _parse_path(self, path, dirname='.', ext=''):
"""
Parse the user input path.
Parameters
----------
dirname : str, optional
The default directory.
ext : str, optional
The default extension.
"""
path = os.path.expanduser(path or '')
dirname = os.path.expanduser(dirname or '')
if not path or os.path.isdir(path):
path = os.path.join(path or dirname, self.name) # default name
dirname, basename = os.path.split(path) # default to current directory
path = os.path.join(dirname or '.', basename)
if not os.path.splitext(path)[-1]:
path = path + '.' + ext # default file extension
return path
class LinearSegmentedColormap(mcolors.LinearSegmentedColormap, _Colormap):
r"""
New base class for all `~matplotlib.colors.LinearSegmentedColormap`\ s.
"""
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:
string += (f' {key!r}: [{data[0][2]:.3f}, '
f'..., {data[-1][1]:.3f}],\n')
return type(self).__name__ + '({\n' + string + '})'
def __init__(self, *args, cyclic=False, alpha=None, **kwargs):
"""
Parameters
----------
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).
alpha : float, optional
The opacity for the entire colormap. Overrides the input
segment data.
*args, **kwargs
Passed to `~matplotlib.colors.LinearSegmentedColormap`.
"""
super().__init__(*args, **kwargs)
self._cyclic = cyclic
if alpha is not None:
self.set_alpha(alpha)
def concatenate(self, *args, ratios=1, name=None, N=None, **kwargs):
"""
Return the concatenation of this colormap with the
input colormaps.
Parameters
----------
*args
Instances of `LinearSegmentedColormap`.
ratios : list of float, optional
Relative extent of each component colormap in the merged colormap.
Length must equal ``len(args) + 1``.
For example, ``cmap1.concatenate(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
``'_'.join(cmap.name for cmap in args)``.
N : int, optional
The number of points in the colormap lookup table.
Default is :rc:`image.lut` times ``len(args)``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
# Try making a simple copy
if not args:
raise ValueError(
f'Got zero positional args, you must provide at least one.'
)
if not all(isinstance(cmap, type(self)) for cmap in args):
raise ValueError(
f'Colormaps {cmap.name + ": " + repr(cmap) for cmap in args} '
f'must all belong to the same class.'
)
cmaps = (self, *args)
spaces = {cmap.name: getattr(cmap, '_space', None) for cmap in cmaps}
if len({*spaces.values(), }) > 1:
raise ValueError(
'Cannot merge PerceptuallyUniformColormaps that use '
'different colorspaces: '
+ ', '.join(map(repr, spaces)) + '.'
)
N = N or len(cmaps) * rcParams['image.lut']
if name is None:
name = '_'.join(cmap.name for cmap in cmaps)
# Combine the segmentdata, and use the y1/y2 slots at merge points so
# we never interpolate between end colors of different colormaps
segmentdata = {}
ratios = ratios or 1
if isinstance(ratios, Number):
ratios = [1] * len(cmaps)
ratios = np.array(ratios) / np.sum(ratios)
x0 = np.concatenate([[0], np.cumsum(ratios)]) # coordinates for edges
xw = x0[1:] - x0[:-1] # widths between edges
for key in self._segmentdata.keys():
# Callable segments
# WARNING: If just reference a global 'funcs' list from inside the
# 'data' function it can get overwritten in this loop. Must
# embed 'funcs' into the definition using a keyword argument.
callable_ = [callable(cmap._segmentdata[key]) for cmap in cmaps]
if all(callable_): # expand range from x-to-w to 0-1
funcs = [cmap._segmentdata[key] for cmap in cmaps]
def xyy(ix, funcs=funcs):
ix = np.atleast_1d(ix)
kx = np.empty(ix.shape)
for j, jx in enumerate(ix.flat):
idx = max(np.searchsorted(x0, jx) - 1, 0)
kx.flat[j] = funcs[idx]((jx - x0[idx]) / xw[idx])
return kx
# Concatenate segment arrays and make the transition at the
# seam instant so we *never interpolate* between end colors
# of different maps.
elif not any(callable_):
datas = []
for x, w, cmap in zip(x0[:-1], xw, cmaps):
xyy = np.array(cmap._segmentdata[key])
xyy[:, 0] = x + w * xyy[:, 0]
datas.append(xyy)
for i in range(len(datas) - 1):
datas[i][-1, 2] = datas[i + 1][0, 2]
datas[i + 1] = datas[i + 1][1:, :]
xyy = np.concatenate(datas, axis=0)
xyy[:, 0] = xyy[:, 0] / xyy[:, 0].max(axis=0) # fix fp errors
else:
raise ValueError(
'Mixed callable and non-callable colormap values.'
)
segmentdata[key] = xyy
# Handle gamma values
if key == 'saturation':
ikey = 'gamma1'
elif key == 'luminance':
ikey = 'gamma2'
else:
continue
if ikey in kwargs:
continue
gamma = []
for cmap in cmaps:
igamma = getattr(cmap, '_' + ikey)
if not np.iterable(igamma):
if all(callable_):
igamma = [igamma]
else:
igamma = (len(cmap._segmentdata[key]) - 1) * [igamma]
gamma.extend(igamma)
if all(callable_):
if any(igamma != gamma[0] for igamma in gamma[1:]):
_warn_proplot(
'Cannot use multiple segment gammas when '
'concatenating callable segments. Using the first '
f'gamma of {gamma[0]}.'
)
gamma = gamma[0]
kwargs[ikey] = gamma
# Return copy
return self.updated(name=name, segmentdata=segmentdata, **kwargs)
def punched(self, cut=None, name=None, **kwargs):
"""
Return a version of the colormap with the center "punched out".
This is great for making the transition from "negative" to "positive"
in a diverging colormap more distinct.
Parameters
----------
cut : float, optional
The proportion to cut from the center of the colormap.
For example, ``center=0.1`` cuts the central 10%.
name : str, optional
The name of the new colormap. Default is
``self.name + '_punched'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
Returns
-------
`LinearSegmentedColormap`
The colormap.
"""
cut = _notNone(cut, 0)
if cut == 0:
return self
if name is None:
name = self.name + '_punched'
# Decompose cut into two truncations followed by concatenation
left_center = 0.5 - cut / 2
right_center = 0.5 + cut / 2
cmap_left = self.truncated(0, left_center)
cmap_right = self.truncated(right_center, 1)
return cmap_left.concatenate(cmap_right, name=name, **kwargs)
def reversed(self, name=None, **kwargs):
"""
Return a reversed copy of the colormap, as in
`~matplotlib.colors.LinearSegmentedColormap`.
Parameters
----------
name : str, optional
The new colormap name. Default is ``self.name + '_r'``.
**kwargs
Passed to `LinearSegmentedColormap.updated`
or `PerceptuallyUniformColormap.updated`.
"""
if name is None:
name = self.name + '_r'
def factory(dat):
def func_r(x):
return dat(1.0 - x)
return func_r
segmentdata = {key:
factory(data) if callable(data) else
[(1.0 - x, y1, y0) for x, y0, y1 in reversed(data)]
for key, data in self._segmentdata.items()}
for key in ('gamma1', 'gamma2'):
if key in kwargs:
continue
gamma = getattr(self, '_' + key, None)
if gamma is not None and np.iterable(gamma):