Skip to content

Commit c05ba23

Browse files
committed
Merge pull request #7 from njsmith/uniform-space-cmdline-option
Add --uniform-space option to choose uniform space
2 parents fb25fa3 + bcbce11 commit c05ba23

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
@@ -559,11 +585,12 @@ def _jp_update(self, val):
559585
self.cmap_model.set_Jp_minmax(smallest, largest)
560586

561587
class 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

682709
class 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

Comments
 (0)