18
18
import matplotlib .colors
19
19
from matplotlib .colors import LinearSegmentedColormap
20
20
21
- from colorspacious import cspace_converter
21
+ from colorspacious import (cspace_converter , cspace_convert ,
22
+ CIECAM02Space , CIECAM02Surround , CAM02UCS )
22
23
from .minimvc import Trigger
23
24
24
- # Our preferred space (mostly here so we can easily tweak it when curious)
25
- UNIFORM_SPACE = "CAM02-UCS"
25
+ # The correct L_A value for the standard sRGB viewing conditions is:
26
+ # (64 / np.pi) / 5
27
+ # Due to an error in our color conversion code, the matplotlib colormaps were
28
+ # designed using the assumption that they would be viewed with an L_A value of
29
+ # (64 / np.pi) * 5
30
+ # (i.e., 125x brighter ambient illumination than appropriate). It turns out
31
+ # that when all is said and done this has negligible effect on the uniformity
32
+ # of the resulting colormaps (phew), BUT fixing the bug has the effect of
33
+ # somewhat shrinking the sRGB color solid as projected into CAM02-UCS
34
+ # space. This means that the bezier points for existing colormaps (like the
35
+ # matplotlib ones) are in the wrong place. We can reproduce the original
36
+ # colormaps from these points by using this buggy_CAM02UCS space as our
37
+ # uniform space:
38
+ buggy_sRGB_viewing_conditions = CIECAM02Space (
39
+ XYZ100_w = "D65" ,
40
+ Y_b = 20 ,
41
+ L_A = (64 / np .pi ) * 5 , # bug: should be / 5
42
+ surround = CIECAM02Surround .AVERAGE )
43
+ buggy_CAM02UCS = {"name" : "CAM02-UCS" ,
44
+ "ciecam02_space" : buggy_sRGB_viewing_conditions ,
45
+ }
46
+
26
47
GREYSCALE_CONVERSION_SPACE = "JCh"
27
48
28
49
_sRGB1_to_JCh = cspace_converter ("sRGB1" , GREYSCALE_CONVERSION_SPACE )
@@ -32,9 +53,6 @@ def to_greyscale(sRGB1):
32
53
JCh [..., 1 ] = 0
33
54
return _JCh_to_sRGB1 (JCh )
34
55
35
- _sRGB1_to_uniform = cspace_converter ("sRGB1" , UNIFORM_SPACE )
36
- _uniform_to_sRGB1 = cspace_converter (UNIFORM_SPACE , "sRGB1" )
37
-
38
56
_deuter50_space = {"name" : "sRGB1+CVD" ,
39
57
"cvd_type" : "deuteranomaly" ,
40
58
"severity" : 50 }
@@ -142,12 +160,15 @@ def _vis_axes():
142
160
# reduces quantization/aliasing artifacts (esp. in the perceptual deltas
143
161
# plot).
144
162
class viscm (object ):
145
- def __init__ (self , cm , name = None , N = 256 , N_dots = 50 , show_gamut = False ):
163
+ def __init__ (self , cm , uniform_space ,
164
+ name = None , N = 256 , N_dots = 50 , show_gamut = False ):
146
165
if isinstance (cm , str ):
147
166
cm = plt .get_cmap (cm )
148
167
if name is None :
149
168
name = cm .name
150
169
170
+ self ._sRGB1_to_uniform = cspace_converter ("sRGB1" , uniform_space )
171
+
151
172
self .fig = plt .figure ()
152
173
self .fig .suptitle ("Colormap evaluation: %s" % (name ,), fontsize = 24 )
153
174
axes = _vis_axes ()
@@ -169,10 +190,11 @@ def label(ax, s):
169
190
verticalalignment = "bottom" ,
170
191
transform = ax .transAxes )
171
192
172
- Jpapbp = _sRGB1_to_uniform (RGB )
193
+ Jpapbp = self . _sRGB1_to_uniform (RGB )
173
194
174
195
ax = axes ['deltas' ]
175
196
local_deltas = N * np .sqrt (np .sum ((Jpapbp [:- 1 , :] - Jpapbp [1 :, :]) ** 2 , axis = - 1 ))
197
+ print ("perceptual delta peak-to-peak: %0.2f" % (np .ptp (local_deltas ),))
176
198
ax .plot (x [1 :], local_deltas )
177
199
arclength = np .sum (local_deltas ) / N
178
200
label (ax , "Perceptual deltas (total: %0.2f)" % (arclength ,))
@@ -231,15 +253,15 @@ def anom(ax, converter, name):
231
253
232
254
ax = axes ['gamut' ]
233
255
ax .plot (Jpapbp [:, 1 ], Jpapbp [:, 2 ], Jpapbp [:, 0 ])
234
- Jpapbp_dots = _sRGB1_to_uniform (RGB_dots )
256
+ Jpapbp_dots = self . _sRGB1_to_uniform (RGB_dots )
235
257
ax .scatter (Jpapbp_dots [:, 1 ],
236
258
Jpapbp_dots [:, 2 ],
237
259
Jpapbp_dots [:, 0 ],
238
260
c = RGB_dots [:, :],
239
261
s = 80 )
240
262
241
263
# Draw a wireframe indicating the sRGB gamut
242
- self .gamut_patch = sRGB_gamut_patch ()
264
+ self .gamut_patch = sRGB_gamut_patch (uniform_space )
243
265
# That function returns a patch where each face is colored to match
244
266
# the represented colors. For present purposes we want something
245
267
# less... colorful.
@@ -299,7 +321,7 @@ def _deuter_transform(RGBA):
299
321
axes ['image0' ].set_title ("Sample images" )
300
322
axes ['image0-cb' ].set_title ("Moderate deuter." )
301
323
302
- def sRGB_gamut_patch (resolution = 20 ):
324
+ def sRGB_gamut_patch (uniform_space , resolution = 20 ):
303
325
step = 1.0 / resolution
304
326
sRGB_quads = []
305
327
sRGB_values = []
@@ -333,7 +355,7 @@ def sRGB_gamut_patch(resolution=20):
333
355
# work around colorspace transform bugginess in handling high-dim
334
356
# arrays
335
357
sRGB_quads_2d = sRGB_quads .reshape ((- 1 , 3 ))
336
- Jpapbp_quads_2d = _sRGB1_to_uniform (sRGB_quads_2d )
358
+ Jpapbp_quads_2d = cspace_convert (sRGB_quads_2d , "sRGB1" , uniform_space )
337
359
Jpapbp_quads = Jpapbp_quads_2d .reshape ((- 1 , 4 , 3 ))
338
360
gamut_patch = mpl_toolkits .mplot3d .art3d .Poly3DCollection (
339
361
Jpapbp_quads [:, :, [1 , 2 , 0 ]])
@@ -342,7 +364,7 @@ def sRGB_gamut_patch(resolution=20):
342
364
return gamut_patch
343
365
344
366
345
- def sRGB_gamut_Jp_slice (Jp ,
367
+ def sRGB_gamut_Jp_slice (Jp , uniform_space ,
346
368
ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 ), resolution = 200 ):
347
369
bp_grid , ap_grid = np .mgrid [bp_lim [0 ] : bp_lim [1 ] : resolution * 1j ,
348
370
ap_lim [0 ] : ap_lim [1 ] : resolution * 1j ]
@@ -351,7 +373,7 @@ def sRGB_gamut_Jp_slice(Jp,
351
373
ap_grid [:, :, np .newaxis ],
352
374
bp_grid [:, :, np .newaxis ]),
353
375
axis = 2 )
354
- sRGB = _uniform_to_sRGB1 (Jpapbp )
376
+ sRGB = cspace_convert (Jpapbp , uniform_space , "sRGB1" )
355
377
sRGBA = np .concatenate ((sRGB , np .ones (sRGB .shape [:2 ] + (1 ,))),
356
378
axis = 2 )
357
379
sRGBA [np .any ((sRGB < 0 ) | (sRGB > 1 ), axis = - 1 )] = [0 , 0 , 0 , 0 ]
@@ -369,9 +391,11 @@ def draw_pure_hue_angles(ax):
369
391
ax .plot ([0 , x * 1000 ], [0 , y * 1000 ], color + "--" )
370
392
371
393
372
- def draw_sRGB_gamut_Jp_slice (ax , Jp , ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 ),
394
+ def draw_sRGB_gamut_Jp_slice (ax , Jp , uniform_space ,
395
+ ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 ),
373
396
** kwargs ):
374
- sRGB = sRGB_gamut_Jp_slice (Jp , ap_lim = ap_lim , bp_lim = bp_lim , ** kwargs )
397
+ sRGB = sRGB_gamut_Jp_slice (Jp , uniform_space ,
398
+ ap_lim = ap_lim , bp_lim = bp_lim , ** kwargs )
375
399
im = ax .imshow (sRGB , aspect = "equal" ,
376
400
extent = ap_lim + bp_lim , origin = "lower" )
377
401
draw_pure_hue_angles (ax )
@@ -404,7 +428,7 @@ def _viscm_editor_axes():
404
428
405
429
406
430
class viscm_editor (object ):
407
- def __init__ (self , min_Jp = 15 , max_Jp = 95 , xp = None , yp = None ):
431
+ def __init__ (self , uniform_space , min_Jp = 15 , max_Jp = 95 , xp = None , yp = None ):
408
432
from .bezierbuilder import BezierModel , BezierBuilder
409
433
410
434
axes = _viscm_editor_axes ()
@@ -457,12 +481,14 @@ def __init__(self, min_Jp=15, max_Jp=95, xp=None, yp=None):
457
481
self .bezier_model = BezierModel (xp , yp )
458
482
self .cmap_model = BezierCMapModel (self .bezier_model ,
459
483
self .jp_min_slider .val ,
460
- self .jp_max_slider .val )
484
+ self .jp_max_slider .val ,
485
+ uniform_space )
461
486
self .highlight_point_model = HighlightPointModel (self .cmap_model , 0.5 )
462
487
463
488
self .bezier_builder = BezierBuilder (axes ['bezier' ], self .bezier_model )
464
489
self .bezier_gamut_viewer = GamutViewer2D (axes ['bezier' ],
465
- self .highlight_point_model )
490
+ self .highlight_point_model ,
491
+ uniform_space )
466
492
tmp = HighlightPoint2DView (axes ['bezier' ],
467
493
self .highlight_point_model )
468
494
self .bezier_highlight_point_view = tmp
@@ -558,11 +584,12 @@ def _jp_update(self, val):
558
584
self .cmap_model .set_Jp_minmax (smallest , largest )
559
585
560
586
class BezierCMapModel (object ):
561
- def __init__ (self , bezier_model , min_Jp , max_Jp ):
587
+ def __init__ (self , bezier_model , min_Jp , max_Jp , uniform_space ):
562
588
self .bezier_model = bezier_model
563
589
self .min_Jp = min_Jp
564
590
self .max_Jp = max_Jp
565
591
self .trigger = Trigger ()
592
+ self .uniform_to_sRGB1 = cspace_converter (uniform_space , "sRGB1" )
566
593
567
594
self .bezier_model .trigger .add_callback (self .trigger .fire )
568
595
@@ -582,7 +609,7 @@ def get_Jpapbp(self, num=200):
582
609
def get_sRGB (self , num = 200 ):
583
610
# Return sRGB and out-of-gamut mask
584
611
Jp , ap , bp = self .get_Jpapbp (num = num )
585
- sRGB = _uniform_to_sRGB1 (np .column_stack ((Jp , ap , bp )))
612
+ sRGB = self . uniform_to_sRGB1 (np .column_stack ((Jp , ap , bp )))
586
613
oog = np .any ((sRGB > 1 ) | (sRGB < 0 ), axis = - 1 )
587
614
sRGB [oog , :] = np .nan
588
615
return sRGB , oog
@@ -679,12 +706,13 @@ def _refresh(self):
679
706
680
707
681
708
class GamutViewer2D (object ):
682
- def __init__ (self , ax , highlight_point_model ,
709
+ def __init__ (self , ax , highlight_point_model , uniform_space ,
683
710
ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 )):
684
711
self .ax = ax
685
712
self .highlight_point_model = highlight_point_model
686
713
self .ap_lim = ap_lim
687
714
self .bp_lim = bp_lim
715
+ self .uniform_space = uniform_space
688
716
689
717
self .bgcolors = {"light" : (0.9 , 0.9 , 0.9 ),
690
718
"dark" : (0.1 , 0.1 , 0.1 )}
@@ -707,7 +735,8 @@ def _refresh(self):
707
735
if not (low <= Jp <= high ):
708
736
self .bg = self .bg_opposites [self .bg ]
709
737
self .ax .set_axis_bgcolor (self .bgcolors [self .bg ])
710
- sRGB = sRGB_gamut_Jp_slice (Jp , self .ap_lim , self .bp_lim )
738
+ sRGB = sRGB_gamut_Jp_slice (Jp , self .uniform_space ,
739
+ self .ap_lim , self .bp_lim )
711
740
self .image .set_data (sRGB )
712
741
713
742
@@ -795,6 +824,17 @@ def main(argv):
795
824
help = "A .py file saved from the editor, or "
796
825
"the name of a matplotlib builtin colormap" ,
797
826
nargs = "?" )
827
+ parser .add_argument ("--uniform-space" , metavar = "SPACE" ,
828
+ default = "CAM02-UCS" ,
829
+ dest = "uniform_space" ,
830
+ help = "The perceptually uniform space to use. Usually "
831
+ "you should leave this alone. You can pass 'CIELab' "
832
+ "if you're curious how uniform some colormap is in "
833
+ "CIELab space. You can pass 'buggy-CAM02-UCS' if "
834
+ "you're trying to reproduce the matplotlib colormaps "
835
+ "(which turn out to have had a small bug in the "
836
+ "assumed sRGB viewing conditions) from their bezier "
837
+ "curves." )
798
838
parser .add_argument ("--save" , metavar = "FILE" ,
799
839
default = None ,
800
840
help = "Immediately save visualization to a file (view-mode only)." )
@@ -824,19 +864,22 @@ def main(argv):
824
864
else :
825
865
cmap = plt .get_cmap (args .colormap )
826
866
867
+ uniform_space = args .uniform_space
868
+ if uniform_space == "buggy-CAM02-UCS" :
869
+ uniform_space = buggy_CAM02UCS
827
870
# Easter egg! I keep typing 'show' instead of 'view' so accept both
828
871
if args .action in ("view" , "show" ):
829
872
if cmap is None :
830
873
sys .exit ("Please specify a colormap" )
831
- v = viscm (cmap )
874
+ v = viscm (cmap , uniform_space )
832
875
if args .save is not None :
833
876
v .fig .set_size_inches (20 , 12 )
834
877
v .fig .savefig (args .save )
835
878
elif args .action == "edit" :
836
879
if params is None :
837
880
sys .exit ("Sorry, I don't know how to edit the specified colormap" )
838
881
# Hold a reference so it doesn't get GC'ed
839
- v = viscm_editor (** params )
882
+ v = viscm_editor (uniform_space , ** params )
840
883
else :
841
884
raise RuntimeError ("can't happen" )
842
885
0 commit comments