20th July 2022

For my current course in the Toolbox Lehrerbildung1 on the chemistry of colours I had to learn, well, how colours work. Since my main task there is to design and code interactive visualization, I didn’t only need to know the underlying physical/chemical reasons how pigments and dyes get their colour. I also had to thoroughly comprehend how humans perceive colours and how colours are displayed on a screen to make my visualizations work properly. And while most of them are finished to a degree with which I’m satisfied, I still have a lot to learn and understand.

The biggest and most upsetting insight so far: Not only do humans not perceive red, green and blue light, respectively, with the cones in their eyes. Even if they would, these have very little to do with the red, green, and blue parts that make up the pixels on TV and computer screens. For over 30 years now, I’m drawing and painting as a hobby, and I’ve been working with computers just as long. But somehow, I never learned that… 🤷 A few weeks ago, I found a wonderful blog post by Josh Comeau about colour gradients.2 The key insight is that interpolating between two RGB colours might not look so good and other colour spaces might be better. The main reason is, that two colours which are vaguely complementary to each other will lie on opposite sides of the $$[0,1]^3$$ cube that comprises RGB space. Then, the interpolation between them will run close to the main diagonal in the cube—all colour vectors of the form $$(a,a,a)$$ for $$a\in [0,1]$$—which represents all the greys. So, the gradient will look washed out in the middle.

Josh Comeau suggests interpolating in other colour spaces and uses HSV as his main example. There, the hue gets interpolated on its own, so now greys will appear.3 Then he continues to discuss the Lch space which is similar, but closer modelled after human perception. Here, the hue is also used and works the same. And the other coordinates model similar concepts: Saturation in HSV and colourfulness in Lch both describe how intense a colour is; and value in HSV and luminance in Lch how bright it is. I don’t understand the technical details in the differences, though. As mentioned above, still lots to learn. But Lch supposedly takes into account that we perceive certain colours inherently brighter than others.4

Since I’m working on implementing colour functionalities for the chemistry anyway, I decided to implement functions in my CindyJS code library5 that translate between RGB and Lch.6 I couldn’t find any formulas that do it directly, but I found Bruce Lindbloom’s website7 where he gives formulas that allow to run from RGB to XYZ to Lab to Lch. I implemented all of them and after a lot of fiddling around to make them compatible with CindyJS’s shader plugin I was able to create the following demo:

You can very nicely see the almost grey in the centre of the RGB gradient. You also see that the other two take entirely different routes through the hue space. I’m not entirely sure why. The formulas work slightly differently: In HSV, hue is modelled after a hexagon and in Lch it’s a circle.8 But I am not convinced that this should affect the interpolation direction his drastically. Once again, I don’t understand colour spaces fully yet. Maybe the hues do work differently after all…

Apart from that, feel free to click anywhere on the canvas above to get two new random colours and their gradients.

After finishing the implementation, I was wondering how the paths of the gradients actually differ. In RGB space, the RGB gradient is a straight line. More or less by definition. But what do the other two look like? Due to CindyJS’s easy-to-use 3D plugin, it was a piece of cake. The only thing that took me forever was to compute the correct 3D-to-2D projection to overlay the names of the colours spaces. Bit embarrassing considering I’m teaching projective geometry at uni…

Click-and-drag to rotate. Use the mouse wheel zo zoom. Use the space bar to randomize the gradients.

The two coloured spheres are the initial colours and the tube-like paths show the colours in the three different gradients. You can click and drag with the mouse to look around. Press space to get new colours. Theoretically, you can scroll to zoom, but the website will scroll, too. Don’t know yet how to fix it. And sorry to anyone reading this on a touchscreen device. Click and drag at least should  work.

I’m not sure what this visualization is telling me, exactly, but there are two observations to make:

1. The HSV path has sharp corners on it. Should be because HSV is a hexagon8, as briefly mentioned above.
2. The Lch path will often leave the cube. That’s because it covers a larger colour gamut than RGB. The displayed colours are currently just cut-off, but maybe there’s a better way.

There’s surely more to learn from these and similar visualization. Perhaps I’ll write a more technical blog post in future once I actually understand things.

### Code

Lastly, if you’re one of the three people in the world who might care about the actual implementation in CindyJS,9 you can find the CindyScript code below:

        

rgb2hsv(vec) := (
regional(cMax, cMin, delta, maxIndex, h, s);

maxIndex = 1;
cMax = vec_1;
forall(2..3,
if(vec_# > cMax,
maxIndex = #;
cMax = vec_#;
);
);

cMin = min(vec);
delta = cMax - cMin;

if(delta <= 0.0001,
h = 0;
,if(maxIndex == 1,
h = mod((vec_2 - vec_3) / delta, 6);
,if(maxIndex == 2,
h = 2 + (vec_3 - vec_1) / delta;
,if(maxIndex == 3,
h = 4 + (vec_1 - vec_2) / delta;

))));
if(cMax <= 0.0001, s = 0, s = delta / cMax);

[h * 60°, s, cMax];
); // end rgb2hsv

hsv2rgb(vec) := (
regional(c, x, m, res);

vec_1 = vec_1 / 60°;
c = vec_2 * vec_3;
x = c * (  1 - abs(mod(vec_1, 2) - 1)  );
m = vec_3 - c;

if(vec_1 < 1,
res = [c, x, 0];
,if(vec_1 < 2,
res = [x, c, 0];
,if(vec_1 < 3,
res = [0, c, x];
,if(vec_1 < 4,
res = [0, x, c];
,if(vec_1 < 5,
res = [x, 0, c];
,if(vec_1 <= 6,
res = [c, 0, x];
))))));

[res_1 + m, res_2 + m, res_3 + m];
); // end hsv2rgb

rgb2xyz(vec) := (
vec = apply(vec,
if(# < 0.04045, # / 12.92, re(pow((# + 0.055) / 1.055, 2.4)));
);
apply([[0.4124564,  0.3575761, 0.1804375],
[0.2126729,  0.7151522, 0.0721750],
[0.0193339,  0.1191920, 0.9503041]] * vec, clamp(#, 0, 1));
); // end rgb2xyz

xyz2rgb(vec) := (
vec =  [[ 3.2404542, -1.5371385, -0.4985314],
[-0.9692660,  1.8760108,  0.0415560],
[ 0.0556434, -0.2040259,  1.0572252]] * vec;
apply(vec,
if(# < 0.0031308, # * 12.92, 1.055 * re(pow(#, 1 / 2.4)) - 0.055);
);
); // end xyz2rgb

whitePointD65 = [0.31271, 0.32902, 0.35827];

xyz2lab(vec) := (
regional(eps, kappa);

eps = 216 / 24389;
kappa = 24389 / 27;

vec = apply(1..3, vec_# / whitePointD65_#);
vec = apply(vec, if(# > eps, re(pow(#, 1 / 3)), (kappa * # + 16) / 116));

[116 * vec.y - 16, 500 * (vec.x - vec.y), 200 * (vec.y - vec.z)];
); // end xyz2lab

lab2xyz(vec) := (
regional(eps, kappa, f, x, y, z);

eps = 216 / 24389;
kappa = 24389 / 27;

f = [0.1,0,0];
f_2 = (vec_1 + 16) / 116;
f_1 = vec_2 / 500 + f_2;
f_3 = f_2 - vec_3 / 200;

x = re(pow(f_1, 3));
if(x <= eps, x = (116 * f_1 - 16) / kappa);

if(vec_1 > kappa * eps,
y = re(pow((vec_1 + 16) / 116, 3));
, // else //
y = vec_1 / kappa;
);

z = re(pow(f_3, 3));
if(z <= eps, z = (116 * f_3 - 16) / kappa);

[x * whitePointD65.x, y * whitePointD65.y, z * whitePointD65.z];
); // end lab2xyz

rgb2lab(vec) := xyz2lab(rgb2xyz(vec));

lab2rgb(vec) := xyz2rgb(lab2xyz(vec));

lab2lch(vec) := [vec_1, abs([vec_2, vec_3]), arctan2(vec_2, vec_3)];

lch2lab(vec) := [vec_1, vec_2 * cos(vec_3), vec_2 * sin(vec_3)];

rgb2lch(vec) := lab2lch(rgb2lab(vec));

lch2rgb(vec) := lab2rgb(lch2lab(vec));

lerpHSV(vecA, vecB, t) := (
regional(d, newH);

d = abs(vecA_1 - vecB_1);

if(d <= pi,
newH = lerp1(vecA_1, vecB_1, t);
, // else //
vecA_1 = vecA_1 + 180°;
vecB_1 = vecB_1 + 180°;
if(vecA_1 > 360°, vecA_1 = vecA_1 - 360°);
if(vecB_1 > 360°, vecB_1 = vecB_1 - 360°);

newH = lerp1(vecA_1, vecB_1, t) + 180°;
if(newH > 360°, newH = newH - 360°);
);

[newH, lerp1(vecA_2, vecB_2, t), lerp1(vecA_3, vecB_3, t)];
); // end lerpHSV

lerpLCH(vecA, vecB, t) := (
regional(d, newH);

d = abs(vecA_3 - vecB_3);

if(d <= pi,
newH = lerp1(vecA_3, vecB_3, t);
, // else //
vecA_3 = vecA_3 + 180°;
vecB_3 = vecB_3 + 180°;
if(vecA_3 > 360°, vecA_3 = vecA_3 - 360°);
if(vecB_3 > 360°, vecB_3 = vecB_3 - 360°);

newH = lerp1(vecA_3, vecB_3, t) + 180°;
if(newH > 360°, newH = newH - 360°);
);

[lerp1(vecA_1, vecB_1, t), lerp1(vecA_2, vecB_2, t), newH];
); // end lerpLCH




Note the functions lerpHSV and lerpLCH at the end. Since hue is a topological circle in both spaces, I wrote separate functions to interpolate along this circle. There are probably more elegant ways to do this, but at least it works.

### Final note

I’m perfectly on track with my one blog post every two months, so, I’ll see you in September. 1. A (German) platform for teacher trainees to learn various multidisciplinary topics. Link.

2. As long as no end point of the gradient is grey, I guess.

3. E.g., yellow versus blue.

4. It’s called Egdod and you can find it on GitHub: Link.