Wednesday, 5 May 2021

Colour Pick 3: WebGL

If you look in the bottom-left corner of the Colour Pick experiment web page, you'll see a checkbox:

Colour Pick WebGL checkbox

This is an optimization. It turns out that updating even a few canvases interactively whilst the user is dragging the mouse can produce very "laggy" responses. The problem is that some of the canvases draw non-trivial shapes with each pixel's colour computed on-the-fly. For example:

CIE L*a*b* gamut canvas

The canonical way of updating a single pixel in a canvas via JavaScript is something akin to this:

var context = canvas.getContext("2d");
context.fillStyle = rgb;
context.fillRect(x, y, 1, 1);

This is fine for a few pixels but doesn't scale well. WebGL fragment shaders, on the other hand, are ideal for updating rectangular regions like this on a pixel-by-pixel basis. However, you cannot use both "2d" and "webgl" contexts on the same canvas. Consequently, the JS script, uses an off-screen "webgl" canvas to utilise fragment shaders and then "blits" the result to the on-screen canvas.

WebGL fragment shaders are only guaranteed to conform to OpenGL Shader Language (GLSL) version 1.00, so you have to be a bit conservative with your shader programming sometimes.

The "FRAGMENT_SHADER_SOURCE" variable at the top of the colourpick.js script contains the interesting GLSL code to perform the various colour space conversions.

The first function converts an HSL/HSV-style hue in the range zero to one into a fully-saturated RGB triple:

vec3 hue2rgb(float h) { float r = abs(h * 6.0 - 3.0) - 1.0; float g = 2.0 - abs(h * 6.0 - 2.0); float b = 2.0 - abs(h * 6.0 - 4.0); return clamp(vec3(r, g, b), 0.0, 1.0); }

The next function converts a red/green/blue triple to hue/chroma/value based on the work by Sam Hocevar and Emil Persson:

vec3 rgb2hcv(vec3 rgb) { vec4 p = (rgb.g < rgb.b) ? vec4(,-1.0,2.0/3.0) : vec4(,0.0,-1.0/3.0); vec4 q = (rgb.r < p.x) ? vec4(p.xyw, rgb.r) : vec4(rgb.r, p.yzx); float c = q.x - min(q.w, q.y); float h = abs((q.w - q.y) / (6.0 * c + 1.0e-10) + q.z); return vec3(h, c, q.x); }

Next we have our first true colour space conversion: HSV to RGB:

vec3 hsv2rgb(vec3 hsv) { vec3 rgb = hue2rgb(hsv.x); return ((rgb - 1.0) * hsv.y + 1.0) * hsv.z; }

HSL to RGB is similar:

vec3 hsl2rgb(vec3 hsl) { vec3 rgb = hue2rgb(hsl.x); return (rgb - 0.5) * (1.0 - abs(2.0 * hsl.z - 1.0)) * hsl.y + hsl.z; }

HWB (hue/whiteness/blackness) to RGB is also similar:

vec3 hwb2rgb(vec3 hwb) { vec3 rgb = hue2rgb(hwb.x); return rgb * (1.0 - hwb.y - hwb.z) + hwb.y; }

Converting "linear" RGB to sRGB requires component-wise conditionals. I'd love to be able to use 'vec3 mix(vec3, vec3, bvec3)' here, but basic WebGL doesn't support it, so we have to make sure that both "branches" of the conditional are finite:

vec3 lrgb2srgb(vec3 rgb) { vec3 linear = rgb * 12.92; vec3 nonlinear = 1.055 * pow(max(rgb, 0.0), vec3(1.0 / 2.4)) - 0.055; return mix(nonlinear, linear, vec3(lessThan(linear, vec3(0.04045)))); }

Converting CIE XYZ to linear RGB is a simple matrix multiplication, but basic WebGL doesn't support constant matrix initialization:

vec3 xyz2lrgb(vec3 xyz) { return vec3(dot(xyz, vec3(+3.2404542, -1.5371385, -0.4985314)), dot(xyz, vec3(-0.9692660, +1.8760108, +0.0415560)), dot(xyz, vec3(+0.0556434, -0.2040259, +1.0572252))); }

We have to avoid division by zero when converting CIE xyY to CIE XYZ:

vec3 xyy2xyz(vec3 xyy) { float d = xyy.z / max(xyy.y, 1.0e-10); return vec3(xyy.x * d, xyy.z, (1.0 - xyy.x - xyy.y) * d); }

We assume a D65 illuminant and a 2-degree standard colormetric observer for converting CIE L*a*b* to CIE XYZ. Again, it would be nice to use 'vec3 mix(vec3, vec3, bvec3)' in the final statement, but basic WebGL doesn't support it:

vec3 lab2xyz(vec3 lab) { float fy = (lab.x + 16.0) / 116.0; float fx = fy + lab.y * 0.002; float fz = fy - lab.z * 0.005; vec3 fxyz = vec3(fx, fy, fz); vec3 linear = (fxyz * 3132.0 - 432.0) / 24389.0; vec3 nonlinear = fxyz * fxyz * fxyz; return mix(nonlinear, linear, vec3(lessThan(nonlinear, vec3(216.0 / 24389.0)))) * vec3(0.95047,1.0,1.08883); }

Converting CIE LCHab to CIE L*a*b* is a simple polar-to-cartesian conversion:

vec3 lchab2lab(vec3 lch) { float hab = radians(lch.z); return vec3(lch.x, lch.y * cos(hab), lch.y * sin(hab)); }

As is converting CIE LCHuv to CIE LUV (it's actually identical, but we'll keep it distinct):

vec3 lchuv2luv(vec3 lch) { float huv = radians(lch.z); return vec3(lch.x, lch.y * cos(huv), lch.y * sin(huv)); }

Converting CIE LUV to CIE XYZ is relatively straightforward (assuming D65 and 2-degrees observer):

vec3 luv2xyz(vec3 luv) { float p = luv.x * 52.0 / (luv.y + luv.x * 2.571917722678301); float q = luv.x * 39.0 / (luv.z + luv.x * 6.088371938121326); float y = (luv.x > 8.0) ? pow((luv.x + 16.0)/116.0, 3.0) : (luv.x * 27.0/24389.0); float x = y * q * 3.0 / p; float z = x * (p - 1.0) / 3.0 - y * 5.0; return vec3(x, y, z); }

As noted in the previous post, "hue" in CIE LChab is not the same as "hue" in HSL/HSV/HWB. The following function provides a very close approximation of the latter when given the former without having to perform the complete LChab→LabXYZRGBsRGBHSL calculation chain. Note that both hues are in the range [0..1]:

float hab2hue(float hab) { const vec4 a = vec4(0.011681489, -0.053365543, -0.00331377, -0.009398634); const vec4 b = vec4(-0.527823872, -1.606202694, 1.054897946, 0.102411421); const float c = 0.91047088; vec4 v = sin(vec4(1.0, 2.0, 3.0, 4.0) * hab * 6.28318530718 + b); return fract(hab + dot(a, v) + c); }

The CIE LChuv hue to HSL/HSV/HWB hue conversion needs a few more terms in the expansion to be accurate. Again, both hues are in the range [0..1]:

float huv2hue(float huv) { const vec4 a03 = vec4(0.003125388, -0.033086796, 0.007706313, 0.004791156); const vec4 a47 = vec4(0.001477559, 0.001918466, -0.000558722, 0.001325); const vec4 b03 = vec4(0.006280978, -0.762775675, -0.798412429, -1.390621155); const vec4 b47 = vec4(1.509991161, -1.395042259, 1.09539382, 0.731749525); const float c = 0.957463921; vec4 v03 = sin(vec4(1.0, 2.0, 3.0, 4.0) * huv * 6.28318530718 + b03); vec4 v47 = sin(vec4(5.0, 6.0, 7.0, 8.0) * huv * 6.28318530718 + b47); return fract(huv + dot(a03, v03) + c); }

The functions above give you the building blocks necessary to convert most colour space points into RGB. The reverse transformations are left as an exercise for the reader. [Hint: I've previously written some of them in HLSL]

On my PC, using offscreen WebGL techniques speeds up the rendering of all the colour picker canvases by a factor of about twelve which means the response times go from "annoyingly sluggish" to "unnoticeably fast".

Thursday, 1 April 2021

Colour Pick 2: History

Pick a colour, any colour. Now tell me what colour you've chosen.

It's surprisingly difficult to:

  1. Design a user interface to allow someone to intuitively pick a colour, and
  2. Come up with a description of colours that is meaningful to both humans and computers.
If we ask a browser to ask the user to pick a colour, we get a control like this:

Depending on your browser, you may get a dialog like this:

Colour input dialog from Google Chrome 89 (2021)

Chrome allows you to switch between RGB, HSL and hexadecimal input schemes. The "result" of the dialog is an HTML hexadecimal colour in the format "#rrggbb", e.g. "#DAA520".

Is that the state of the art? The Colour Pick experiment is a personal investigation into user interfaces like this.

A Bit of History

One of the first graphical editing programs was Sketchpad (1963) but this was designed for a monochrome vector display.

According to Alvy Ray SmithWilliam Kubitz (1968) and Joan Miller (1969) both created hardware with eight fixed colours, presumably black, blue, red, magenta, green, cyan, yellow and white.

Until SuperPaint (1973) appeared with its 256-entry, 24-bit RGB palette, the number of colours available meant that a "palette box" was sufficient for most software. SuperPaint introduced the ability to specify a colour via HSB (HSV) sliders:

SuperPaint tool palette (1973)

The first PC graphics software used palette boxes because of the colour limitations of early graphics cards such as CGA/EGA:

PCPaint+ (1985)

Meanwhile, Apple software was at a similar stage with MacPaint.

With the advent of 24-bit colour hardware on home and office computers, techniques for picking colours "caught up" with professional image- and video-editing systems. Mechanisms that used the RGB, HSV and HSL colour spaces became the norm:

Paint Shop Pro 5 (1998)

The CIE L*a*b* and related colour spaces started to appear in pickers too:

Adobe Photoshop (after 1990)

Although Microsoft remains stubbornly old-school:

Microsoft Paint for Windows 10 (2020)

Apple's current colour picker has multiple tabs; the first is an HSV circle picker:

Apple Color Picker (2020)

An interesting variation on the HSV circle is the discrete hexagonal palette popular on Windows:

Mechanika Design (2015)

GIMP has a number of built-in colour pickers. The "triangle wheel" is described in the documentation as based on HSV, but it looks more like HWB to me:

GIMP 2.10.22 (2020)

I've tried to systematically encapsulate as many of these ideas as possible in the Colour Pick web page.

Monday, 29 March 2021

Intern Ethel P

I've just received a very suspicious email from "internethelp@<[-redacted-].com". It purports to be assistance for a query I had with an online service. But I know better! It's just another blatant phishing scam by that master cyber-criminal intern Ethel P.

Friday, 26 March 2021

Colour Pick 1: Goldenrod

As part of my investigation into colour names, I've been writing a lot of JavaScript code to perform colour space transformations.

When I started the colour pick work, I did the "right thing" and refactored all the colour-related functionality into a library. The library is named "goldenrod" after the X11 colour of that name (#DAA520).

Solidago virgaurea minuta "Dwarf Goldenrod"

The library is just a plain old bunch of functions and lists organised into three files:


This script contains the main entry points exposed as fields of the global object "chilliant.goldenrod". They are primarily conversion functions with signatures of the following form:

float[3] chilliant.goldenrod.{source}_to_{destination}(float value[3])

Above, "{source}" and "{destination}" are colour space tags as listed in the following table:

TagColour Spacevalue[0]value[1]value[2]
0.0 to 1.0
0.0 to 1.0
0.0 to 1.0
a.k.a. HSB
0.0 to 360.0 degrees
0.0 to 1.0
0.0 to 1.0
0.0 to 360.0 degrees
0.0 to 1.0
0.0 to 1.0
0.0 to 360.0 degrees
0.0 to 1.0
0.0 to 1.0
0.0 to 1.0
Y luminance
0.0 to 1.0
0.0 to 1.0
CIExyYCIE 1931 xyYx
0.0 to 100.0
0.0 to 100.0
Y luminance
0.0 to 100.0
CIELabCIE 1976 L*a*b*L* lightness
0.0 to 100.0
~ -100.0 to 100.0
~ -100.0 to 100.0
CIELuvCIE 1976 L*u*v*L* lightness
0.0 to 100.0
~ -200.0 to 200.0
~ -200.0 to 200.0
CIELChabCIE 1976 L*C*habL* lightness
0.0 to 100.0
C* chroma
~ 0.0 to 100.0
hab hue
0.0 to 360.0 degrees
CIELChuvCIE 1976 L*C*huvL* lightness
0.0 to 100.0
C* chroma
~ 0.0 to 100.0
huv hue
0.0 to 360.0 degrees

Note that for conversions to and from the hexacone colour spaces (HSV/HSL/HWB) the tag "RGB" is used instead of "sRGB" to emphasize the fact that no linearization or delinearization is performed. Thus, the name "chilliant.goldenrod.RGB_to_HSL" is used instead of "chilliant.goldenrod.sRGB_to_HSL".

All conversions assume a standard D65 illuminant with a two-degree observer angle.

At the time of writing, not all permutations of source and destination are directly supported, but it's a trivial change to add the missing "chains".

The additional members of "chilliant.goldenrod" are listed below.

bool chilliant.goldenrod.within_sRGB(any srgb)

A function that returns true if, and only if, 'srgb' is an array of at least three numbers all between 0.0 and 1.0 inclusive.

float[3] chilliant.goldenrod.force_sRGB(any srgb)

Forces 'srgb' to be a valid sRGB triplet with each element between 0.0 and 1.0. If any channel is greater than one, the whole triplet is scaled back. If 'srgb' cannot be interpretted as a valid triplet, black [0,0,0] is returned.

float chilliant.goldenrod.deltaE1994(float ref[3], float lab[3])

Computes the CIELAB ΔE*(1994) colour difference between the two CIEL*a*b* colours. Note that this metric is not symmetric.

float chilliant.goldenrod.deltaE2000(float ref[3], float lab[3])

Computes the CIELAB ΔE*(2000) colour difference between the two CIEL*a*b* colours.

string chilliant.goldenrod.sRGB_to_HEX(float srgb[])

Converts the sRGB triplet (or quartet with alpha) 'srgb' to a hexadecimal colour in the form "#rrggbb" or "#rrggbbaa".

float[3] chilliant.goldenrod.HEX_to_sRGB(string hex)

Converts an hexadecimal colour string in the form "#rgb", "#rgba", "#rrggbb" or "#rrggbbaa" into an sRGB triplet. Any alpha component is ignored.

string chilliant.goldenrod.version

A string field containing the goldenrod library version in semver format, e.g. "1.0.0".


This script contains the guts of the functionality of goldenrod exposed as members of the object "chilliant.goldenrod.util". A large portion is based on the formulae outlined on Bruce Lindbloom's excellent site and should be fairly self-explanatory.


This script contains useful datasets exposed as members of the object "".


The CIE 1931 2-degree chromaticity xyY coordinates from UCL Colour & Vision Research Laboratory. The columns are:

  1. Wavelength in nm,
  2. CIE xyY x-ordinate,
  3. CIE xyY y-ordinate, and
  4. CIE xyY Y luminance.


The CSS 4 colour names taken from Mozilla.


The 267 ISCC-NBS centroids from Paul Centore's site in index order. The columns are:

  1. Name (string),
  2. Munsell colour (string),
  3. Red (int),
  4. Green (int),
  5. Blue (int),
  6. sRGB hexadecimal (string).

Note that seven of the centroids are outside the sRGB gamut so are missing the final four columns. It would be nice to have the out-of-gamut values for red/green/blue because those points could still be matched via (for instance) Euclidean distance searchers. However, I have been unable to recreate Paul's methodology to fill in the blanks.


A 627-entry map between the Color Naming System and sRGB values in the form:

  "black": {hex: "#000000", srgb: [0,0,0]},
  "yellowish orange": {hex: "#CC9333", srgb: [0.8,0.575,0.2]}

This is a "best guess" at the values based on "A New Color-Naming System for Graphics Languages".

See "generateCNS()" for details.


The 260 ISCC-NBS centroids from "data.centore" that are within the sRGB gamut, formatted like "data.cns".

See "generateISCC()" for details.

Unit Tests

The goldenrod library has a minimal unit test suite. The values were checked against Bruce Lindbloom's colour calculator, amongst other tools.

Tuesday, 23 March 2021

HSL and CIELCh(ab) Hue Wheels

I've been looking at colour pickers recently and came across (what I think is) an interesting cul-de-sac.

HSL colour pickers often have a hue wheel similar to this one from Paint Shop Pro 5 (1998):

Paint Shop Pro 5 colour picker dialog

The wheel is rendered as an angle sweeps from 0 to 360 degrees clockwise:

output = HSL_to_RGB(angle, 100%, 50%)

The HSL wheel looks like this:

HSL hue wheel

Note that the "hue" components of HSL, HSV and HWB are identical. However, this is not the case for the cylindrical version of CIE Lab, a.k.a. CIELCh(ab).

We can construct the CIELCh(ab) hue wheel as follows:

rgb = CIELChab_to_sRGB(70, 30, angle)
hsl = RGB_to_HSL(rgb)
output = HSL_to_RGB(hsl.h, 100%, 50%)

We choose a CIE perceptual lightness (L*) of 70 and chroma (C*) of 30 so as not to go out-of-gamut when converting to sRGB. We also implicitly assume the reference white to be the CIE standard illuminant D65 and a 2-degree observer angle.

We use HSL to fully saturate the output colour to produce the following wheel:

CIELCh(ab) hue wheel

Note that the colours have shifted somewhat. This becomes clear if we plot both wheels on the same chart:

HSL (outer) and CIELCh(ab) (inner) hue wheels

There's already an excellent comparison of these two wheels by Elle Stone, so I won't go into that here.

For reasons that will hopefully become clear in a later post, what I really wanted to do was plot the CIELCh(ab) hue wheel efficiently in WebGL. Who wouldn't?

I coded "CIELChab_to_sRGB" et al in GLSL (more on that later too) but felt a bit queasy performing such complicated arithmetic, particularly when the results get shunted through an RGB-HSL-RGB hack at the end. There must be an efficient way of translating CIELCh(ab) hue to HSL hue, surely?

If we plot the relationship, we get:

CIELCh(ab) hue versus HSL hue

I looks like a fairly linear albeit wavy relationship, doesn't it? So let's plot the difference modulo 360 degrees:

Difference in hues modulo 360 degrees

It now looks fairly sinusoidal, so maybe we can approximate it with the sum of a few sine waves.

If the input is the CIELCh(ab) hue angle in degrees and the desired output is the HSL hue, we can write:

output = input + c + a0*sin(b0+input) + a1*sin(b1+2*input) + a2*sin(b2+3*input) + …

It turns out you only need four sine terms to get a good approximation and Microsoft Excel Solver is more that capable of finding appropriate values for the offset "c", scale factors "a0" to "a3" and phase shifts "b0" to "b3".

I initially coded the formula as a GLSL function that takes the hues in the range zero to one, like so:

float hstar2hue(float input) {
  // Convert CLIELCh(ab) hue [0..1] to HSV/HSL/HWB hue [0..1]
  float a0 = 0.011681489;
  float a1 = -0.053365543;
  float a2 = -0.00331377;
  float a3 = -0.009398634;
  float b0 = -0.527823872;
  float b1 = -1.606202694;
  float b2 = 1.054897946;
  float b3 = 0.102411421;
  float c = 0.91047088;
  float output = input + c;
  output += a0 * sin(b0 + input * 2.0 * 3.1415926536);
  output += a1 * sin(b1 + input * 4.0 * 3.1415926536);
  output += a2 * sin(b2 + input * 6.0 * 3.1415926536);
  output += a3 * sin(b3 + input * 8.0 * 3.1415926536);
  return mod(output, 1.0);

But we can take advantage of GLSL's SIMD nature and get:

float hstar2hue(float input) {
  // Convert CLIELCh(ab) hue [0..1] to HSV/HSL/HWB hue [0..1]
  vec4 a = vec4(0.011681489, -0.053365543, -0.00331377, -0.009398634);
  vec4 b = vec4(-0.527823872, -1.606202694, 1.054897946, 0.102411421);
  float c = 0.91047088;
  vec4 v = sin(vec4(1.0, 2.0, 3.0, 4.0) * input * 6.28318530718 + b);
  return mod(input + dot(a, v) + c, 1.0);

It's then just a question of converting the HSL-style hue to RGB:

vec3 hue2rgb(float h) {
  float r = abs(h * 6.0 - 3.0) - 1.0;
  float g = 2.0 - abs(h * 6.0 - 2.0);
  float b = 2.0 - abs(h * 6.0 - 4.0);
  return clamp(vec3(r, g, b), 0.0, 1.0);

And we've got all the ingredients for a fragment shader to render the CIELCh(ab) hue wheel.

I'll leave it as an exercise for the reader to work out the reverse HSL-to-CIELCh(ab) hue approximation as well as those for CIELCh(uv).

Friday, 5 March 2021

Colour Names 5

Still more colour naming experiments...

My experiments with HWB-51 last time divided up the whiteness-blackness plane for a particular hue (e.g. blue) like this:

Partitioning the whiteness-blackness plane in HWB-51

It can be seen that the partitioning doesn't occur in native whiteness-blackness space. Here's the relevant JavaScript pseudo-code:

var hwb_w = Math.min(linear_r, linear_g, linear_b);
var hwb_b = 1 - Math.max(linear_r, linear_g, linear_b);
var p = hwb_w + hwb_b;
var q = hwb_w / p;
if (p < 0.125) {
  return "vivid " + name;
if (p < 0.375) {
  return name;
if (p >= 0.875) {
  return achromatic(q);

Above, "p" is the Manhattan distance from the vivid corner; a measure of "achromicity". And "q" is a measure of how much of the "achromicity" is white, as opposed to black.

We compare this with the computation of HSV saturation and value parameters:

var hsv_v = Math.max(linear_r, linear_g, linear_b);
var chroma = hsv_v - Math.min(linear_r, linear_g, linear_b);
var hsv_s = (hsv_v > 0) ? (chroma / hsv_v) : 0;

We see that there are obvious similarities:

p == 1 - chroma
  == 1 - hsv_s * hsv_v


q == (hsv_v - chroma) / (1 - chroma)
  == hsv_v * (1 - hsv_s) / (1 - hsv_s * hsv_v)

This shouldn't be a complete surprise as Smith and Lyons themselves noted that the "HWB model is very closely related to the HSV model". So we could quite simply reformulate the HWB-51 rules to use HSV or, indeed, HSL.

However, one of the reasons for the creation of HWB in 1995 was to have a "more intuitive hue-based color model" and I quite like the symmetry in the whiteness-blackness plane:

  • Adding whiteness creates a tint (baselightpale → white)
  • Adding blackness creates a shade (base → dark → deep → black)
  • Adding both creates a tone (base → dull → grey)
So describing the HWB-51 and HWB-91 colour naming schemes with reference to the HWB colour space may be useful to end users.

NOTE: Don't forget that HWB assumes that red, green and blue channels are linear. So sRGB values need linearizing, that is [and this was a new phrase for me] "inverse companding".

Colour Names 4

Even more colour naming experiments...

I added conversions for the ISCC-NBS System of Color Designation (1939 onwards) based on Paul Centore's centroids. I was unable to reproduce Paul's results, so I cannot fill in the seven blanks for out-of-sRGB-gamut points:

  • brilliant orange (4.5YR8.0/12.1)
  • vivid orange yellow (9.0YR7.2/16)
  • deep olive green (5.0GY2.0/8.0)
  • deep green (6.0G2.3/9.1)
  • deep bluish green (4.5BG2.3/9.0) and
  • deep greenish blue (4.5B2.5/9.0)

The "extrapolated" sRGB centroids could still be useful when searching for colour names but need to be computed from the original Munsell data points. This is a fiendish problem that I hope to re-visit if and when I implement a colour library in Egg.

To improve upon the ad hoc naming scheme of ISCC-NBS, the Color Naming Scheme (CNS) (1982) sought to systematize the name construction. CNS produces a palette of 627 colours, some of which have rather ambiguous names:

  • very dark vivid yellowish brown
  • very light grayish bluish purple

It does, however, encompass the majority of the eleven basic colour terms:

  1. white
  2. black
  3. red
  4. green
  5. yellow
  6. blue
  7. brown
  8. purple
  9. pink
  10. orange
  11. grey

All but "pink" are included, though "brown" is somewhat shoe-horned in.

As pointed out elsewhere, CNS doesn't seem to have a reference implementation, so it's very difficult to judge results. I've implemented a simple HSL centroid scheme pending further information.

Thinking about the basic colour terms (and grating against the use of terms like "chartreuse"), I implemented HWB-51 as a cut-down version of HWB-51 but just using the eleven basic terms plus the following adjectives:

  • deep
  • dark
  • light
  • pale
  • vivid
  • dull

This produces 77 combinations, but only 55 are used as names ("vivid black" is not meaningful):

greydeep greydark greylight greypale grey--
pink*--light pink*--dull pink
reddeep reddark redlight red*pale red*vivid reddull red
orangedeep orange*dark orange*light orangepale orangevivid orangedull orange
brown*-dark brown*---dull brown
yellowdeep yellowdark yellowlight yellowpale yellowvivid yellowdull yellow
greendeep greendark greenlight greenpale greenvivid greendull green
bluedeep bluedark bluelight bluepale bluevivid bluedull blue
purpledeep purpledark purplelight purplepale purplevivid purpledull purple

There are four pairs of synonyms:

  • "pink" = "light red"
  • "light pink" = "pale red"
  • "brown" = "dark orange"
  • "dark brown" = "deep orange"

This makes a total of 51 unique colours. Unfortunately, there's a large gap in the hue wheel between green and blue where cyan usually sits:

Hue ranges for HWB-51 names

Perhaps turquoise could be added.

The light/dark split for orange/brown and pink/red works quite well in HWB:

Approximate partitioning of whiteness-darkness plane for HWB-51 (orange/brown)

We could extend the idea to all sectors of the hue wheel:

  • red → pink/red
  • orange → orange/brown
  • yellow → yellow/olive
  • green → lime/green
  • blue → blue/navy
  • purple → violet/purple

However, now we're drifting away from using basic colour terms.

As an example of the various naming schemes, here are the results of looking up the web-safe colour "#FFCCFF":

ISCC-NBSpurplish white0.1
CNSvery light strong purple1.4
HSV-79dull magenta-
HWB-51pale purple-
HWB-91pale magenta-

The final column is the ΔE*(2000) distance metric.

This is an extreme example because every name is different!

Monday, 1 March 2021

Colour Names 3

Some more colour naming experiments...

After creating the RGB-125 palette (one-word names for colours based on five levels each of red, green and blue), I limited the number of levels to 64 (4x4x4). This is similar to the EGA source palette.

The EGA palette uses the adjective "bright" to describe colours with any channel at 100% intensity. This idea is mirrored in the "vivid" modifier of the ISCC-NBS system and Color Naming System. I thought that "bright" could be confused with "light", so I chose "vivid" as the prefix for any RGB-64 colour that has maximal chroma.

RGB-64 has 39 base colour names ("amaranth", "amber", "apple", "aquamarine", "azure", "black", "blue", "bluebell", "brown", "celeste", "cerise", "cerulean", "chartreuse", "cyan", "denim", "erin", "green", "grey", "harlequin", "inchworm", "jade", "liberty", "magenta", "mauve", "milan", "mint", "olive", "orange", "orchid", "pink", "plum", "poison", "purple", "red", "spring", "tradewind", "violet", "white" and "yellow") and 3 modifiers ("dark", "light" and "vivid"). The bold names correspond to the eleven (contentious) basic colour terms.

RGB-27 is a strict subset of RGB-125 covering 3x3x3 colour points. Unfortunately, we lose "brown" and "pink" in the process.

At this point, I turned my attention to limited, hue-based colour naming schemes.

HSL-79 is a scheme based on the HSL colour space. The hue (an angle between 0 and 360 degrees) is split into 12 equal segments:

  • red
  • orange
  • yellow
  • chartreuse
  • green
  • spring
  • cyan
  • azure
  • blue
  • violet
  • magenta
  • rose
These hues can be optionally modified with prefixes:
  • deep (very low lightness)
  • dark (low lightness)
  • light (high lightness)
  • pale (very high lightness)
  • dull (low saturation)
If the saturation is high, no prefix is used. If the saturation is very low, the colour is achromatic and the lightness is used to pick from a grey scale:
  • black
  • deep grey
  • dark grey
  • grey
  • light grey
  • pale grey
  • white
These combine to form 79 unique colours: "black" and "white" do not take prefixes and "grey" cannot be "dull". I quite liked the names, though "chartreuse" and "spring" feel a little clunky.

HSV-79 is similar but based on the HSV colour space instead of HSL. I tried to parameterize the partitioning of the segment so that each contains approximately the same number of RGB (256x256x256) colour points.

HWB-91 is based on Alvy Ray Smith's HWB colour space. I can't believe I hadn't come across this before, but there you go! The twelve additional colour points (up from 79 to 91) are due to the addition of the "vivid" modify for hues. Instead of using explicit conditions to partition the space, the "whiteness" and "blackness" coordinates are used as the basis for a Euclidean "nearest neighbour" algorithm based on the following reference points (in this case, for "red"):

HWB-91 reference points for a given hue (x-axis is blackness, y-axis is whiteness)

This produces quite intuitive results. Take, for example, the web-safe colour "#FFCCFF"; "pale magenta" seems like an appropriate name:

Colour names for #FFCCFF in various schemes

The numbers in the final column are the ΔE*(2000) colour differences between "#FFCCFF" and the nearest colour from the discrete palette.

Next, I hope to look into CNS (from 1982) and/or the Artist's Color Naming System (from 1986).

Tuesday, 23 February 2021

Colour Names 2

The companion colournames.html web page, contains experiments with naming colours.

As mentioned previously, HTML5/CSS colour names are highly subjective and lists have developed organically over many decades. As the 140 unique names mentioned in the first post derive from the original X11 list, I'll refer to them as X11 colours.

When plotted in RGB space, it is obvious that named X11 colours aren't evenly distributed:

The 140 unique X11 colours plotted in RGB space

There is a large cluster of very pale (near white) colours. But what about hue distribution?

A simplistic mechanism for dividing a colour space is to slice it up according to HSL parameters. If we use the following pseudocode, we get nine colour groups:

if lightness >= 95% then group = "whites"
else if saturation <= 15% then group = "blacks"
else if hue < 20° or hue >= 320° then group = "reds"
else if hue < 45° then group = "oranges"
else if hue < 75° then group = "yellows"
else if hue < 155° then group = "greens"
else if hue < 185° then group = "aquas"
else if hue < 255° then group = "blues"
else group = "purples"

For the X11 colours, we get 12 whites, 9 blacks, 23 reds, 20 oranges, 12 yellows, 17 greens, 14 aquas, 19 blues and 14 purples. This sounds like a fairly good distribution until you plot them as the number of colour names per degree of HSL hue:

X11 colour names per degree of HSL hue

We can see that the red-orange-yellow hue range is much more crowded.

One way to deal with this lack of uniform distribution is to pick colour points that are uniformly distributed and then name those points according to some dictionary. Our first attempt could be to pick RGB colours at regular intervals.

Web-safe colours are limited to six levels for each RGB channel (0%, 20%, 40%, 60%, 80% and 100%) for a total of 216 distinct colours. However, they are mostly unnamed. If we reduce the number of levels to five (0%, 25%, 50%, 75% and 100%), we get 125 distinct colours, similar to the number of X11 colours. Obviously, the X11 colours won't align perfectly, so there must be some fettling.

In the process of performing this experiment, I found that jan Misali has attempted something very similar. But it's an experiment, right? So repeating it cannot hurt.

The names in the so-called RGB-125 dictionary come from a variety of sources:
I also wanted the colour names to be single words, without qualifiers and unambiguous. For instance, "Chocolate" has very different RGB values in the various source dictionaries. "Lavender" is another example.

To compare colours objectively, I used the CIELAB ΔE*(1994) colour difference metric purely because there was a JavaScript function readily available. I should probably have used CIEDE2000, not least because the CIE94 algorithm is frustratingly quasimetric , i.e. CIE94(a, b) ≠ CIE94(b, a).

I had to invent ten names: "majorelle", "leaf", "lagoon", "felicia", "frog", "lettuce", "roxo", "sororia", "limon" and "kovidar".

See the full RGB-125 table here.

Thursday, 18 February 2021

Tetrascii 3

I took the plunge and animated the tetromino character set with each glyph made up of 25 pieces falling in order:

The final code to produce the animations is surprisingly concise, but the process of generating the data tables was a bit more involved.

Firstly, I originally drew each glyph in PowerPoint, so the construction wasn't very data-friendly. I had to screen-grab the slide and process the resultant image via JavaScript and HTML5 canvas elements.

Each screen-grabbed glyph was "pixel walked" to work out which of the 10x10 texels were directly connected to their neighbours. From the 100 texel neighbour data, I was able to reconstruct the shape, position and orientation of the 25 tetrominoes that made up the glyph. The pixel intensity was used to determine if a tetromino was foreground or background.

The 25 tetrominoes for each glyph were ordered so that they stacked correctly, from bottom to top. This involved finding candidates (pieces whose lower boundaries all fit exactly on top of existing pieces) and picking a random candidate. Note that this simple scheme disallows overhangs, so pieces can drop vertically.

The glyphs are string-encoded as 25 groups of three characters: "<letter><x><y>".

"<letter>" indicates the shape and orientation of the piece. Lowercase letters are background pieces, uppercase are foreground:

     +---- +---- cyan
AB   |#### |#
     |     |#
     |     |#
     |     |#

     +---- +---- +---- +---- orange
EFGH |###  |##   |  #  |#
     |#    | #   |###  |#
     |     | #   |     |##
     |     |     |     |

     +---- +---- +---- +---- blue
IJKL |###  | #   |#    |##
     |  #  | #   |###  |#
     |     |##   |     |#
     |     |     |     |

     +---- +---- +---- +---- purple
MNOP |###  | #   | #   |#
     | #   |##   |###  |##
     |     | #   |     |#
     |     |     |     |

     +---- +---- red
QR   |##   | #
     | ##  |##
     |     |#
     |     |

     +---- +---- green
UV   | ##  |#
     |##   |##
     |     | #
     |     |

     +---- yellow
Y    |##

"<x><y>" is the coordinate within the 10x10 glyph grid of the top-left corner of the piece.

The resultant 75-character encoding for each glyph therefore encapsulates:

  • The order that the pieces fall
  • The shape and orientation of each piece
  • The column that each piece falls in
  • The row that each piece comes to rest on
  • The colour of each piece
A few more hoops have to be jumped through to convert this information into the final animated SVG elements, but that's because of the baroque relationships between SVG, HTML and CSS.

Wednesday, 10 February 2021

Tetrascii 2

Well, it turns out that the Tetrascii lowercase letters are relatively easy if you open up the smaller counters:


It's not too bad a bitmap font, given the limitations imposed. Some of the glyphs are necessarily quirky, but that just adds character. Cough, cough.

Tetrascii 1

What do we get when you cross two old computer phenomena: ASCII and Tetris?


The "rules" are as follows:
  1. Each glyph must fit within a 10x10 grid.
  2. The foreground pixels must be constructible from standard tetrominoes.
  3. The background pixels (including counters) must be constructible from standard tetrominoes.
It's surprisingly difficult to construct a readable font. Obviously, I've cheated with the lowercase letters; but in my defence, the counters of the uppercase letters are hard enough. The minimum counter size is four pixels and you can see the trouble I had with the dollar sign.

A project for another time would be to animate the construction of text by falling pieces.

Wednesday, 27 January 2021

Whitty T-Shirt


  • Unpack your bags
  • Wash your hands
  • Next slide, please
* A three-word remark uttered by Chris Whitty