1818import matplotlib .colors
1919from matplotlib .colors import LinearSegmentedColormap
2020
21- from colorspacious import cspace_converter
21+ from colorspacious import (cspace_converter , cspace_convert ,
22+ CIECAM02Space , CIECAM02Surround , CAM02UCS )
2223from .minimvc import Trigger
2324
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+
2647GREYSCALE_CONVERSION_SPACE = "JCh"
2748
2849_sRGB1_to_JCh = cspace_converter ("sRGB1" , GREYSCALE_CONVERSION_SPACE )
@@ -32,9 +53,6 @@ def to_greyscale(sRGB1):
3253 JCh [..., 1 ] = 0
3354 return _JCh_to_sRGB1 (JCh )
3455
35- _sRGB1_to_uniform = cspace_converter ("sRGB1" , UNIFORM_SPACE )
36- _uniform_to_sRGB1 = cspace_converter (UNIFORM_SPACE , "sRGB1" )
37-
3856_deuter50_space = {"name" : "sRGB1+CVD" ,
3957 "cvd_type" : "deuteranomaly" ,
4058 "severity" : 50 }
@@ -142,12 +160,15 @@ def _vis_axes():
142160# reduces quantization/aliasing artifacts (esp. in the perceptual deltas
143161# plot).
144162class 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 ):
146165 if isinstance (cm , str ):
147166 cm = plt .get_cmap (cm )
148167 if name is None :
149168 name = cm .name
150169
170+ self ._sRGB1_to_uniform = cspace_converter ("sRGB1" , uniform_space )
171+
151172 self .fig = plt .figure ()
152173 self .fig .suptitle ("Colormap evaluation: %s" % (name ,), fontsize = 24 )
153174 axes = _vis_axes ()
@@ -169,10 +190,11 @@ def label(ax, s):
169190 verticalalignment = "bottom" ,
170191 transform = ax .transAxes )
171192
172- Jpapbp = _sRGB1_to_uniform (RGB )
193+ Jpapbp = self . _sRGB1_to_uniform (RGB )
173194
174195 ax = axes ['deltas' ]
175196 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 ),))
176198 ax .plot (x [1 :], local_deltas )
177199 arclength = np .sum (local_deltas ) / N
178200 label (ax , "Perceptual deltas (total: %0.2f)" % (arclength ,))
@@ -231,15 +253,15 @@ def anom(ax, converter, name):
231253
232254 ax = axes ['gamut' ]
233255 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 )
235257 ax .scatter (Jpapbp_dots [:, 1 ],
236258 Jpapbp_dots [:, 2 ],
237259 Jpapbp_dots [:, 0 ],
238260 c = RGB_dots [:, :],
239261 s = 80 )
240262
241263 # Draw a wireframe indicating the sRGB gamut
242- self .gamut_patch = sRGB_gamut_patch ()
264+ self .gamut_patch = sRGB_gamut_patch (uniform_space )
243265 # That function returns a patch where each face is colored to match
244266 # the represented colors. For present purposes we want something
245267 # less... colorful.
@@ -299,7 +321,7 @@ def _deuter_transform(RGBA):
299321 axes ['image0' ].set_title ("Sample images" )
300322 axes ['image0-cb' ].set_title ("Moderate deuter." )
301323
302- def sRGB_gamut_patch (resolution = 20 ):
324+ def sRGB_gamut_patch (uniform_space , resolution = 20 ):
303325 step = 1.0 / resolution
304326 sRGB_quads = []
305327 sRGB_values = []
@@ -333,7 +355,7 @@ def sRGB_gamut_patch(resolution=20):
333355 # work around colorspace transform bugginess in handling high-dim
334356 # arrays
335357 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 )
337359 Jpapbp_quads = Jpapbp_quads_2d .reshape ((- 1 , 4 , 3 ))
338360 gamut_patch = mpl_toolkits .mplot3d .art3d .Poly3DCollection (
339361 Jpapbp_quads [:, :, [1 , 2 , 0 ]])
@@ -342,7 +364,7 @@ def sRGB_gamut_patch(resolution=20):
342364 return gamut_patch
343365
344366
345- def sRGB_gamut_Jp_slice (Jp ,
367+ def sRGB_gamut_Jp_slice (Jp , uniform_space ,
346368 ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 ), resolution = 200 ):
347369 bp_grid , ap_grid = np .mgrid [bp_lim [0 ] : bp_lim [1 ] : resolution * 1j ,
348370 ap_lim [0 ] : ap_lim [1 ] : resolution * 1j ]
@@ -351,7 +373,7 @@ def sRGB_gamut_Jp_slice(Jp,
351373 ap_grid [:, :, np .newaxis ],
352374 bp_grid [:, :, np .newaxis ]),
353375 axis = 2 )
354- sRGB = _uniform_to_sRGB1 (Jpapbp )
376+ sRGB = cspace_convert (Jpapbp , uniform_space , "sRGB1" )
355377 sRGBA = np .concatenate ((sRGB , np .ones (sRGB .shape [:2 ] + (1 ,))),
356378 axis = 2 )
357379 sRGBA [np .any ((sRGB < 0 ) | (sRGB > 1 ), axis = - 1 )] = [0 , 0 , 0 , 0 ]
@@ -369,9 +391,11 @@ def draw_pure_hue_angles(ax):
369391 ax .plot ([0 , x * 1000 ], [0 , y * 1000 ], color + "--" )
370392
371393
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 ),
373396 ** 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 )
375399 im = ax .imshow (sRGB , aspect = "equal" ,
376400 extent = ap_lim + bp_lim , origin = "lower" )
377401 draw_pure_hue_angles (ax )
@@ -404,7 +428,7 @@ def _viscm_editor_axes():
404428
405429
406430class 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 ):
408432 from .bezierbuilder import BezierModel , BezierBuilder
409433
410434 axes = _viscm_editor_axes ()
@@ -457,12 +481,14 @@ def __init__(self, min_Jp=15, max_Jp=95, xp=None, yp=None):
457481 self .bezier_model = BezierModel (xp , yp )
458482 self .cmap_model = BezierCMapModel (self .bezier_model ,
459483 self .jp_min_slider .val ,
460- self .jp_max_slider .val )
484+ self .jp_max_slider .val ,
485+ uniform_space )
461486 self .highlight_point_model = HighlightPointModel (self .cmap_model , 0.5 )
462487
463488 self .bezier_builder = BezierBuilder (axes ['bezier' ], self .bezier_model )
464489 self .bezier_gamut_viewer = GamutViewer2D (axes ['bezier' ],
465- self .highlight_point_model )
490+ self .highlight_point_model ,
491+ uniform_space )
466492 tmp = HighlightPoint2DView (axes ['bezier' ],
467493 self .highlight_point_model )
468494 self .bezier_highlight_point_view = tmp
@@ -559,11 +585,12 @@ def _jp_update(self, val):
559585 self .cmap_model .set_Jp_minmax (smallest , largest )
560586
561587class BezierCMapModel (object ):
562- def __init__ (self , bezier_model , min_Jp , max_Jp ):
588+ def __init__ (self , bezier_model , min_Jp , max_Jp , uniform_space ):
563589 self .bezier_model = bezier_model
564590 self .min_Jp = min_Jp
565591 self .max_Jp = max_Jp
566592 self .trigger = Trigger ()
593+ self .uniform_to_sRGB1 = cspace_converter (uniform_space , "sRGB1" )
567594
568595 self .bezier_model .trigger .add_callback (self .trigger .fire )
569596
@@ -583,7 +610,7 @@ def get_Jpapbp(self, num=200):
583610 def get_sRGB (self , num = 200 ):
584611 # Return sRGB and out-of-gamut mask
585612 Jp , ap , bp = self .get_Jpapbp (num = num )
586- sRGB = _uniform_to_sRGB1 (np .column_stack ((Jp , ap , bp )))
613+ sRGB = self . uniform_to_sRGB1 (np .column_stack ((Jp , ap , bp )))
587614 oog = np .any ((sRGB > 1 ) | (sRGB < 0 ), axis = - 1 )
588615 sRGB [oog , :] = np .nan
589616 return sRGB , oog
@@ -680,12 +707,13 @@ def _refresh(self):
680707
681708
682709class GamutViewer2D (object ):
683- def __init__ (self , ax , highlight_point_model ,
710+ def __init__ (self , ax , highlight_point_model , uniform_space ,
684711 ap_lim = (- 50 , 50 ), bp_lim = (- 50 , 50 )):
685712 self .ax = ax
686713 self .highlight_point_model = highlight_point_model
687714 self .ap_lim = ap_lim
688715 self .bp_lim = bp_lim
716+ self .uniform_space = uniform_space
689717
690718 self .bgcolors = {"light" : (0.9 , 0.9 , 0.9 ),
691719 "dark" : (0.1 , 0.1 , 0.1 )}
@@ -708,7 +736,8 @@ def _refresh(self):
708736 if not (low <= Jp <= high ):
709737 self .bg = self .bg_opposites [self .bg ]
710738 self .ax .set_axis_bgcolor (self .bgcolors [self .bg ])
711- sRGB = sRGB_gamut_Jp_slice (Jp , self .ap_lim , self .bp_lim )
739+ sRGB = sRGB_gamut_Jp_slice (Jp , self .uniform_space ,
740+ self .ap_lim , self .bp_lim )
712741 self .image .set_data (sRGB )
713742
714743
@@ -796,6 +825,17 @@ def main(argv):
796825 help = "A .py file saved from the editor, or "
797826 "the name of a matplotlib builtin colormap" ,
798827 nargs = "?" )
828+ parser .add_argument ("--uniform-space" , metavar = "SPACE" ,
829+ default = "CAM02-UCS" ,
830+ dest = "uniform_space" ,
831+ help = "The perceptually uniform space to use. Usually "
832+ "you should leave this alone. You can pass 'CIELab' "
833+ "if you're curious how uniform some colormap is in "
834+ "CIELab space. You can pass 'buggy-CAM02-UCS' if "
835+ "you're trying to reproduce the matplotlib colormaps "
836+ "(which turn out to have had a small bug in the "
837+ "assumed sRGB viewing conditions) from their bezier "
838+ "curves." )
799839 parser .add_argument ("--save" , metavar = "FILE" ,
800840 default = None ,
801841 help = "Immediately save visualization to a file (view-mode only)." )
@@ -825,19 +865,22 @@ def main(argv):
825865 else :
826866 cmap = plt .get_cmap (args .colormap )
827867
868+ uniform_space = args .uniform_space
869+ if uniform_space == "buggy-CAM02-UCS" :
870+ uniform_space = buggy_CAM02UCS
828871 # Easter egg! I keep typing 'show' instead of 'view' so accept both
829872 if args .action in ("view" , "show" ):
830873 if cmap is None :
831874 sys .exit ("Please specify a colormap" )
832- v = viscm (cmap )
875+ v = viscm (cmap , uniform_space )
833876 if args .save is not None :
834877 v .fig .set_size_inches (20 , 12 )
835878 v .fig .savefig (args .save )
836879 elif args .action == "edit" :
837880 if params is None :
838881 sys .exit ("Sorry, I don't know how to edit the specified colormap" )
839882 # Hold a reference so it doesn't get GC'ed
840- v = viscm_editor (** params )
883+ v = viscm_editor (uniform_space , ** params )
841884 else :
842885 raise RuntimeError ("can't happen" )
843886
0 commit comments