Skip to content

Commit bcbce11

Browse files
committed
Add --uniform-space option to choose uniform space
This is useful to e.g. run viscm with --uniform-space=CIELab and check that parula is indeed uniform in L*a*b* space. It is also useful because it turns out that I had a stupid bug in the colorspace conversion code that meant we were not *quite* using the correct uniform space for our calculations. See: njsmith/colorspacious@9a4d871 So now you can pass --uniform-space=buggy-CAM02-UCS to reproduce the old results. Fortunately if we use the fixed code to evaluate the old colormaps then they are fine -- they aren't *quite* perceptually uniform anymore, but the deviations are not enough to care about. But if you want to edit the old colormaps or to reproduce them from their spline representation, then you do need this command-line option, because fixing the bug caused the sRGB solid in CAM02-UCS space to be slightly rescaled, so suddenly our existing CAM02-UCS coordinates are mostly out-of-gamut.
1 parent c3e4504 commit bcbce11

File tree

1 file changed

+68
-25
lines changed

1 file changed

+68
-25
lines changed

viscm/gui.py

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,32 @@
1818
import matplotlib.colors
1919
from matplotlib.colors import LinearSegmentedColormap
2020

21-
from colorspacious import cspace_converter
21+
from colorspacious import (cspace_converter, cspace_convert,
22+
CIECAM02Space, CIECAM02Surround, CAM02UCS)
2223
from .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+
2647
GREYSCALE_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).
144162
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):
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

406430
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):
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
@@ -558,11 +584,12 @@ def _jp_update(self, val):
558584
self.cmap_model.set_Jp_minmax(smallest, largest)
559585

560586
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):
562588
self.bezier_model = bezier_model
563589
self.min_Jp = min_Jp
564590
self.max_Jp = max_Jp
565591
self.trigger = Trigger()
592+
self.uniform_to_sRGB1 = cspace_converter(uniform_space, "sRGB1")
566593

567594
self.bezier_model.trigger.add_callback(self.trigger.fire)
568595

@@ -582,7 +609,7 @@ def get_Jpapbp(self, num=200):
582609
def get_sRGB(self, num=200):
583610
# Return sRGB and out-of-gamut mask
584611
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)))
586613
oog = np.any((sRGB > 1) | (sRGB < 0), axis=-1)
587614
sRGB[oog, :] = np.nan
588615
return sRGB, oog
@@ -679,12 +706,13 @@ def _refresh(self):
679706

680707

681708
class GamutViewer2D(object):
682-
def __init__(self, ax, highlight_point_model,
709+
def __init__(self, ax, highlight_point_model, uniform_space,
683710
ap_lim=(-50, 50), bp_lim=(-50, 50)):
684711
self.ax = ax
685712
self.highlight_point_model = highlight_point_model
686713
self.ap_lim = ap_lim
687714
self.bp_lim = bp_lim
715+
self.uniform_space = uniform_space
688716

689717
self.bgcolors = {"light": (0.9, 0.9, 0.9),
690718
"dark": (0.1, 0.1, 0.1)}
@@ -707,7 +735,8 @@ def _refresh(self):
707735
if not (low <= Jp <= high):
708736
self.bg = self.bg_opposites[self.bg]
709737
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)
711740
self.image.set_data(sRGB)
712741

713742

@@ -795,6 +824,17 @@ def main(argv):
795824
help="A .py file saved from the editor, or "
796825
"the name of a matplotlib builtin colormap",
797826
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.")
798838
parser.add_argument("--save", metavar="FILE",
799839
default=None,
800840
help="Immediately save visualization to a file (view-mode only).")
@@ -824,19 +864,22 @@ def main(argv):
824864
else:
825865
cmap = plt.get_cmap(args.colormap)
826866

867+
uniform_space = args.uniform_space
868+
if uniform_space == "buggy-CAM02-UCS":
869+
uniform_space = buggy_CAM02UCS
827870
# Easter egg! I keep typing 'show' instead of 'view' so accept both
828871
if args.action in ("view", "show"):
829872
if cmap is None:
830873
sys.exit("Please specify a colormap")
831-
v = viscm(cmap)
874+
v = viscm(cmap, uniform_space)
832875
if args.save is not None:
833876
v.fig.set_size_inches(20, 12)
834877
v.fig.savefig(args.save)
835878
elif args.action == "edit":
836879
if params is None:
837880
sys.exit("Sorry, I don't know how to edit the specified colormap")
838881
# Hold a reference so it doesn't get GC'ed
839-
v = viscm_editor(**params)
882+
v = viscm_editor(uniform_space, **params)
840883
else:
841884
raise RuntimeError("can't happen")
842885

0 commit comments

Comments
 (0)