Home > Misc

## Constant-luminance colour wheels

Using a colour wheel to create a discrete colour scale is not best practice, because you probably won't end up with something that's colour-blind-friendly. But it's easy, so I do it a lot, and I'll assume full colour vision for everything that follows. These notes describe how to create scales in which each colour has the same luminance (at least approximately). I don't understand the underlying theory at all, and much of the derivation is taking formulas and mindlessly solving equations with them. The resulting wheels probably aren't original – my Googling on this general subject often left me bewildered, but it's clear that plenty of people have put plenty of thought into colour spaces. This post therefore has little to commend it, except that I solved the problem I wanted to solve, and I like the solution enough to use it in practice.

The simplest RGB colour wheel cycles continuously through the colours red-yellow-green-cyan-blue-magenta-red. If you have two colours in your scale and the first is red, then the other colour will be cyan. If you have three colours, they could be red, green, and blue (or yellow, cyan, magenta, or orange, greeny-cyan, bluey-magenta, or...). This is sometimes fine, but it can be awful if you have yellow in your scale. Yellow is super-bright, and is either barely visible if surrounded by a light background, or stands out prominently otherwise. If you're picking colours off a wheel, then you probably want to treat all the categories in your data equally, rather than emphasising whichever one happened to be assigned to yellow. (The yellow problem applies even more strongly if you're using it in the middle of a continuous colour scale: one possible link of many.)

Here's a map that uses an RGB colour wheel. It's colourful! And perhaps that's what you want. But, if nothing else, the yellow in the legend is hard to read.

Number of classes:
Towards black/white (-1 to 1):
Angular offset (0-360):
Regenerate map

(Some of you might be wondering why I embedded the polygons in a Google map layer. The reason is that d3.js's map-drawing onto a blank SVG element went horribly wrong on my Android browser.)

So, what can we do instead? An answer comes from the CIE 1976 (L*, u*, v*) colour space (CIE: Commission internationale de l'éclairage). Instead of working in terms of red, green, and blue components of colours, the CIELUV colour space works in some sort of magic variables, in which:

• the $$L^*$$ variable (which takes values between 0 and 100) measures the luminance – how bright the colour looks to the human eye, and
• the Euclidean distance between two colours in the space is proportional to how different they look.

Both of these are nice properties. If we hold $$L$$ constant (I'll drop L's asterisk from here on), and draw a wheel in the remaining two-dimensional $$(u^*, v^*)$$-space, then none of the colours in the wheel will stand out by brightness. And if we pick the colours at equal angular intervals, then there'll be a perceptually-equal amount of distance between each pair of neighbouring colours in the scale.

Below is another map, this time using a CIELUV-based colour wheel. It's a bit bland – but that's sort of by construction, since the idea is that no colour in the scale should appear brighter than the others. And there's no problem reading any of the legend entries.

(I don't know that this scheme works perfectly – the equal-colour-distance doesn't always look perfect to my eye, and at high L values, the cyan-ish colours stand out more clearly than the others. But overall it gives good control over the apparent brightness.)

Number of classes:
Luminance L (0-100):
Angular offset (0-360):
Regenerate map

We need to know how to convert between Luv values and RGB values. From my brief read of some Google results, there's no completely standard way to do this: the CIELUV colour space is supposed to represent colour as we actually see it, but the same RGB values will be displayed differently on different screens, or even on the same screen if the brightness or contrast settings are changed. So there's wiggle-room for different conversion methods, none of which will ever be ideal.

I settled on sRGB, for what might be reasons of historical accident (i.e., whichever web page on the topic that I first tried to understand). That Wikipedia article presents the formulas for converting to RGB from another set of colour variables: X, Y, and Z.

The earlier linked Wikipedia article on CIELUV gives the conversions from Luv to XYZ, so the procedure is going to be Luv --> XYZ --> RGB. I'm further going to make things more complicated than they perhaps need to be, and change the variables in the Luv-space a little, using $$u'$$ and $$v'$$ instead of $$u^*$$ and $$v^*$$ (I'll give the relations shortly). This choice preserves the equal-perceptual-distance property of the L*u*v* space only when restricting the space to a constant luminance L.

So, I want to start by fixing $$L$$, and then converting from $$(u', v')$$ to RGB, via XYZ. Ignoring what I think is an unimportant subtlety to do with defining the white point (which I just set to $$Y = 1$$), we first go to XYZ (all equations that follow come from the Wikipedia articles):

\begin{align} \nonumber Y &= \begin{cases} L(3/29)^3 & \text{if }L \leq 8, \\ \left(\frac{L+16}{116}\right)^3 & \text{if }L > 8, \end{cases} \\ \nonumber X &= Y\frac{9u'}{4v'}, \\ \label{eqn_Luv_to_XYZ}Z &= Y\frac{12 - 3u' - 20v'}{4v'}. \end{align}

Then from XYZ to an intermediate set of variables $$R_{\text{linear}}, G_{\text{linear}}, B_{\text{linear}}$$:

\begin{equation*} \begin{pmatrix}R_{\text{linear}} \\ G_{\text{linear}} \\ B_{\text{linear}} \end{pmatrix} = \begin{pmatrix}\phantom{-}3.2406 & -1.5372 & -0.4986 \\ -0.9689 & \phantom{-}1.8758 & \phantom{-}0.0415 \\ \phantom{-}0.0557 & -0.2040 & \phantom{-}1.0570 \end{pmatrix} \begin{pmatrix}X\\ Y\\ Z \end{pmatrix}. \end{equation*}

At this point, if any of $$R_{\text{linear}}, G_{\text{linear}}, B_{\text{linear}}$$ is outside the range $$[0, 1]$$, then the colour's not going to exist. We will return to this point when creating the colour wheels – we get a set of six linear inequalities in terms of $$u'$$ and $$v'$$. To complete the conversion to RGB, though, we assume that all values are in the unit interval. For each colour $$C$$ (i.e. $$R$$, $$G$$, and $$B$$),

\begin{equation*} C_{\text{sRGB}} = \begin{cases} 12.92C_{\text{linear}} & \text{if } C_{\text{linear}} \leq 0.0031308, \\ 1.055C_{\text{linear}}^{1/2.4} - 0.055 & \text{if } C_{\text{linear}} > 0.0031308. \end{cases} \end{equation*}

And now we have the conversion, and all we need to do is know what values of $$u'$$ and $$v'$$ to choose. For slightly-less-uncompleteness, the relation between the $$(u', v')$$ variables and $$(u^*, v^*)$$ involves defining a white point $$(u'_n, v'_n)$$. Then the starred variables are defined as

\begin{equation*}u^* = 13L(u' - u'_n) \end{equation*}

and an equation of the same form with $$v$$'s replacing the $$u$$'s.

We're now ready to start thinking about how to construct a colour wheel. I'm going to present what follows as though I saw each wrinkle of the problem ahead of time and worked out the logical solution to it, but what actually happened was that I just dived in and started coding the colour conversions and only worked out what was going on with the equations when I saw the numerical results on the screen. The overall procedure is:

• Work out the bounds of the region of valid colours in $$(u', v')$$-space. This region turns out to be a convex polygon.
• Find the largest circle that fits inside that polygon. This will be the wheel that we pick colours from.

Recall those inequalities resulting from the conversion of $$X, Y, Z$$ into $$R_{\text{linear}}, G_{\text{linear}}, B_{\text{linear}}$$. We need each of the latter to be in the unit interval, which gives us six inequalities: each of the three colour channels is greater than or equal to zero, and is also less than or equal to one. Calling the 3x3 conversion matrix with all the decimal places $$M$$, we can write the inequalities as

\begin{align} \label{eqn_ineq_0}M_{i1}X + M_{i2}Y + M_{i3}Z &\geq 0, \\ \label{eqn_ineq_1}M_{i1}X + M_{i2}Y + M_{i3}Z &\leq 1. \end{align}

From \eqref{eqn_Luv_to_XYZ}, we can see that both $$X$$ and $$Z$$ are defined in terms of $$Y$$, $$u'$$, and $$v'$$. Substituting those expressions into \eqref{eqn_ineq_0} – the inequality in \eqref{eqn_ineq_1} is similar – and cancelling the $$Y$$'s gives

\begin{equation*} M_{i1}\frac{9u'}{4v'} + M_{i2} + M_{i3}\frac{12 - 3u' - 20v'}{4v'} \geq 0. \end{equation*}

Multiplying through by $$4v'$$, it is immediately clear that the inequalities will be linear in $$(u', v')$$-space. After some re-arrangement, we have

\begin{equation*} \label{eqn_ineq_0_final}(9M_{i1} - 3M_{i3})u' + (4M_{i2} - 20M_{i3})v' + 12M_{i3} \geq 0, \end{equation*}

and similarly for the "less than or equal to one" inequality (the $$Y$$'s don't cancel this time):

\begin{equation*} \label{eqn_ineq_1_final}Y(9M_{i1} - 3M_{i3})u' + (Y(4M_{i2} - 20M_{i3}) - 4)v' + 12YM_{i3} \leq 0. \end{equation*}

It may be that up to three of the inequalities are redundant for purposes of defining the region of $$(u', v')$$-space with valid colours. For instance, at very low luminances, only the "greater than or equal to zero" inequalities are needed, as the low $$L$$ value prevents any point inside the resulting triangle from having any of the colour channels above 1. At higher $$L$$ values, I've seen up to 5 lines defining the region of valid colours.

Finding the vertices of the resulting polygon only needs some elementary vector geometry, but it takes a little bit of care to get it working. This is the algorithm I used:

• Use the "≥ 0" inequalities to define a triangle. The triangle is stored as a sequence (order is important) of vertices.
• For each "≤ 1" inequality:
• If the inequality line intersects two edges of the polygon, then
• Partition the existing polygon vertices into two sets.
• Check a vertex in one partition to see if it satisfies the inequality being tested.
• If it does satisfy the inequality, then the new polygon is that partition, plus the new vertices (careful with ordering!), otherwise the new polygon is the other partition, plus the new vertices (careful with ordering!).

(I ignore the potential degenerate case of a line intersecting a vertex rather than an edge of the polygon.)

That gives us a polygon. Now we want the largest circle that fits inside it, so that we have as much differentiation as possible between the colours in the scale to be created. Finding the Chebyshev centre of a convex polygon is a linear programming problem as explained in the answers to this Stack Overflow question. I don't know how to do linear programming, so I adopted the O(N3) approach, mentioned in one of the answers, of just finding the incircle of each triangle defined by a triplet of polygon edges (the edges are extended to create the triangle if necessary). The largest circle to fit inside the polygon will be one of these incircles – tangent to three sides of the polygon. At N=5, this procedure is fast enough.

There is a wrinkle to this idea though: when you extend the edges of the polygon to create the triangle, you may extend them so far that either the resulting incircle is entirely outside the polygon, or that the circle intersects one or more of the other edges (instead of being tangent). The first I check with a point-in-polygon function: start at a point inside the polygon (I use the centre of the bounding box), and see how many intersections there are as you go from that point to somewhere outside the polygon. If the answer is odd (in the convex case: if it is equal to 1), then the point is inside the polygon, otherwise it is outside.

The second possible error – the circle intersecting edges – can be checked by calculating the shortest distance from the centre of the circle to each edge; if this distance is smaller than the radius of the circle, then the circle goes outside the polygon and is not valid.

After the brute-force search, I end up with a circle, which becomes my colour wheel. The diagram below shows the colour diagram in $$(u', v')$$-space for the specified luminance value, with $$u'$$ on the horizontal axis and $$v'$$ on the vertical axis. Both axes are drawn from 0.1 to 0.6. Vertices of the polygon are circled; I hope the occasional mismatches are just round-off error. (Depending on your device, it may take a few seconds to update the diagram after you change the L value and click 'regenerate diagram'. It's also a bit buggy in the Android browser.)

Luminance L (0-100):
Regenerate diagram

Some JavaScript code follows, with the RGB and CIELUV colour wheel functions at the end. Probably much of it is coded better in already-existing libraries. I just show the construction of the colour wheel; for the other JS bits and pieces on this page, see the source.

var tau = 2*Math.PI;

var white_point_Y = 1;
// D65 standard luminant (used for u* and v*, not for u' and v'):
var white_point_u_prime = 0.1978405;
var white_point_v_prime = 0.4683231;

var XYZ_to_sRGB = [[ 3.2406, -1.5372, -0.4986],
[-0.9689,  1.8758,  0.0415],
[ 0.0557, -0.2040,  1.0570]];

function cieluv_to_rgb(luv) {
var L_star = luv[0];

var u_prime, v_prime;
var	X, Y, Z;
var R_linear, G_linear, B_linear;
var R, G, B;

// CIELu*v*:
/*
var u_star, v_star;
u_star = luv[1];
v_star = luv[2];
u_prime = u_star / (13 * L_star) + white_point_u_prime;
v_prime = v_star / (13 * L_star) + white_point_v_prime;
*/

// CIELu'v' if that's a thing:
u_prime = luv[1];
v_prime = luv[2];

if (L_star <= 8) {
Y = white_point_Y * L_star * Math.pow(3/29, 3);
} else {
Y = white_point_Y * Math.pow((L_star+16)/116, 3);
}

X = Y * 9*u_prime / (4*v_prime);
Z = Y * (12 - 3*u_prime - 20*v_prime) / (4*v_prime);

var XYZ = [X, Y, Z];

var rgb_vec = [0, 0, 0];
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 3; j++) {
rgb_vec[i] += XYZ_to_sRGB[i][j] * XYZ[j];
}
}

// I use the alpha channel in the canvas diagram; by default it should be opaque,
// but if the colour doesn't exist, then I set it to transparent.
var alpha = 255;

for (var i = 0; i < rgb_vec.length; i++) {
if ((rgb_vec[i] < 0) || (rgb_vec[i] > 1)) {
alpha = 0;
}
rgb_vec[i] = Math.min(rgb_vec[i], 1);
rgb_vec[i] = Math.max(rgb_vec[i], 0);
if (rgb_vec[i] <= 0.0031308) {
rgb_vec[i] = 12.92*rgb_vec[i];
} else {
rgb_vec[i] = 1.055*Math.pow(rgb_vec[i], 1/2.4) - 0.055;
}

rgb_vec[i] = Math.floor(rgb_vec[i]*255);
}
rgb_vec.push(alpha);

return rgb_vec;
}

function next_vertex(i, N) {
// Increment a vertex index but have it loop back to the first vertex.
// i is zero-based.

var j = i + 1;
if (j == N) {
return 0;
} else {
return j;
}
}

function prev_vertex(i, N) {
// Decrement a vertex index but have it loop back to the last vertex.
// i is zero-based.

var j = i - 1;
if (j == -1) {
return (N-1);
} else {
return j;
}
}

function intersection_segments_extended(vertices1, vertices2) {
// First segment defined by  (vertices1[0][0], vertices1[0][1])-(vertices1[1][0], vertices1[1][1])
// Second segment defined by (vertices2[0][0], vertices2[0][1])-(vertices2[1][0], vertices2[1][1])
// Returns the intersection of the two segments, extended if necessary.

var s;

var u_21_y, u_21_x, u_13_x, u_13_x, u_43_x, u_43_y, numer, denom;
u_21_x = vertices1[1][0] - vertices1[0][0];
u_21_y = vertices1[1][1] - vertices1[0][1];
u_13_x = vertices1[0][0] - vertices2[0][0];
u_13_y = vertices1[0][1] - vertices2[0][1];
u_43_x = vertices2[1][0] - vertices2[0][0];
u_43_y = vertices2[1][1] - vertices2[0][1];

numer = u_21_y*u_13_x - u_21_x*u_13_y;
denom = u_21_y*u_43_x - u_21_x*u_43_y;

if (denom == 0) {
return [];
} else {
var new_vertex = [0, 0];
s = numer / denom;

new_vertex[0] = vertices2[0][0] + s*(vertices2[1][0] - vertices2[0][0]);
new_vertex[1] = vertices2[0][1] + s*(vertices2[1][1] - vertices2[0][1]);

return new_vertex;
}
}

function distance_point_to_segment(segment, point) {
// Shortest distance between a point and a line defined by a segment.

var v1_x = segment[0][0];
var v1_y = segment[0][1];
var v2_x = segment[1][0];
var v2_y = segment[1][1];

var r_x = point[0];
var r_y = point[1];

// Line is a*x + b*y + c = 0
var a, b, c, d;
a = v1_y - v2_y;
b = v2_x - v1_x;
c = v1_x*(v2_y - v1_y) - v1_y*(v2_x - v1_x);

d = Math.abs(a*r_x + b*r_y + c) / Math.sqrt(a*a + b*b);
return d;
}

function point_in_polygon(segments, point) {
// Check if point is inside the polygon defined by segments.
// You should probably find a better point-in-polygon function.

var bbox_x1 = 1e10;
var bbox_y1 = 1e10;
var bbox_x2 = -1e10;
var bbox_y2 = -1e10;
var i;

// Get bounding box of polygon:
for (i = 0; i < segments.length; i++) {
if (segments[i][0][0] < bbox_x1) {
bbox_x1 = segments[i][0][0];
}

if (segments[i][0][1] < bbox_y1) {
bbox_y1 = segments[i][0][1];
}

if (segments[i][0][0] > bbox_x2) {
bbox_x2 = segments[i][0][0];
}

if (segments[i][0][1] > bbox_y2) {
bbox_y2 = segments[i][0][1];
}
}

var r = [0, 0];
r[0] = point[0];
r[1] = point[1];
var r2 = [bbox_x1 - 10, 0.5*(bbox_y1 + bbox_y2)];

var m = [0, 0];
m[0] = r2[0] - r[0];
m[1] = r2[1] - r[1];

var N = segments.length;
var s, t;
var u1 = [0, 0];
var u2 = [0, 0];

var intersection_count = 0;
var intersection = [0, 0];

for (i = 0; i < N; i++) {
u1[0] = segments[i][0][0];
u1[1] = segments[i][0][1];
u2[0] = segments[i][1][0];
u2[1] = segments[i][1][1];

s = (m[1]*(r[0] - u1[0]) - m[0]*(r[1] - u1[1])) / (m[1]*(u2[0] - u1[0]) - m[0]*(u2[1] - u1[1]));
t = (u1[0] + s*(u2[0] - u1[0]) - r[0]) / m[0];

if ((s > 0) && (s < 1) && (t > 0)) {
// Intersection!
intersection_count++;
intersection[0] = u1[0] + s*(u2[0] - u1[0]);
intersection[1] = u1[1] + s*(u2[1] - u1[1]);
}
}

if (intersection_count % 2 == 0) {
return false;
} else {
return true;
}
}

function triangle_incircle(vertices) {
// Returns the incentre and radius of incircle of a triangle defined by vertices.

var u_1 = vertices[0][0];
var u_2 = vertices[1][0];
var u_3 = vertices[2][0];

var v_1 = vertices[0][1];
var v_2 = vertices[1][1];
var v_3 = vertices[2][1];

// lengths:
var l_1 = Math.sqrt((u_3 - u_2)*(u_3 - u_2) + (v_3 - v_2)*(v_3 - v_2));
var l_2 = Math.sqrt((u_3 - u_1)*(u_3 - u_1) + (v_3 - v_1)*(v_3 - v_1));
var l_3 = Math.sqrt((u_2 - u_1)*(u_2 - u_1) + (v_2 - v_1)*(v_2 - v_1));

var incentre_u = (l_1*u_1 + l_2*u_2 + l_3*u_3) / (l_1 + l_2 + l_3);
var incentre_v = (l_1*v_1 + l_2*v_2 + l_3*v_3) / (l_1 + l_2 + l_3);

var s = (l_1 + l_2 + l_3)/2;
var area = Math.sqrt(s*(s-l_1)*(s-l_2)*(s-l_3));

var incircle_r = area / s;

return [[incentre_u, incentre_v], incircle_r];
}

// Existing region defined by vertices.
// ineq_coeffs[0]*x + ineq_coeffs[1]*y <= ineq_coeffs[2].
// Returns a new set of vertices.

if (vertices.length < 3) {
return [];
}

var i;

// Write the inequality line in the form r + t*m.

var r = [0, 0];
var m = [0, 0];

var r2 = [0, 0];

if (ineq_coeffs[1] == 0) {
// Line form alpha*x = gamma
r[0] = ineq_coeffs[2] / ineq_coeffs[0];
r[1] = 0;
m = [0, 1];
} else {
// Start point r at x=0:
r[0] = 0;
r[1] = ineq_coeffs[2] / ineq_coeffs[1];

// A second point on the line at x=1:
r2[0] = 1;
r2[1] = (ineq_coeffs[2] - ineq_coeffs[0]*r2[0]) / ineq_coeffs[1];

m[0] = r2[0] - r[0];
m[1] = r2[1] - r[1];
}

var intersected_segments = [];
var intersected_vertices = [];
var u1 = [];
var u2 = [];
var s;
var N = vertices.length;

for (i = 0; i < N; i++) {
j = next_vertex(i, N);
u1[0] = vertices[i][0];
u1[1] = vertices[i][1];
u2[0] = vertices[j][0];
u2[1] = vertices[j][1];

s = (m[1]*(r[0] - u1[0]) - m[0]*(r[1] - u1[1])) / (m[1]*(u2[0] - u1[0]) - m[0]*(u2[1] - u1[1]));

if ((s > 0) && (s < 1)) {
// Intersection!
intersected_segments.push([i, j]);
intersected_vertices.push([u1[0] + s*(u2[0] - u1[0]), u1[1] + s*(u2[1] - u1[1])]);
}
}

// Partition the vertices either side of the inequality line.
var partition1 = [];
var partition2 = [];
var new_vertices = [];

if (intersected_segments.length == 0) {
partition1 = vertices;
} else {

if (intersected_segments.length != 2) {
console.log(intersected_segments.length + " intersections?!?!");
}

i = intersected_segments[0][1];
do {
partition1.push(vertices[i]);
i = next_vertex(i, N);
} while (i != intersected_segments[1][1]);

// Go backwards for the other partition.
i = intersected_segments[0][0];
do {
partition1.push(vertices[i]);
i = prev_vertex(i, N);
} while (i != intersected_segments[1][0]);
}

// Check a vertex in a partition to see if it satisfies the inequality.
if (ineq_coeffs[0]*partition1[0][0] + ineq_coeffs[1]*partition1[0][1] <= ineq_coeffs[2]) {
// Partition 1
if (intersected_segments.length == 0) {
return partition1;
} else {
new_vertices.push(intersected_vertices[0]);
i = intersected_segments[0][1];
do {
new_vertices.push(vertices[i]);
i = next_vertex(i, N);
} while (i != intersected_segments[1][1]);
new_vertices.push(intersected_vertices[1]);
}
} else {
// Partition 2
if (intersected_segments.length == 0) {
return [];
} else {
new_vertices.push(intersected_vertices[0]);
i = intersected_segments[0][0];
do {
new_vertices.push(vertices[i]);
i = prev_vertex(i, N);
} while (i != intersected_segments[1][0]);
new_vertices.push(intersected_vertices[1]);
}
}

return new_vertices;
}

function polygon_incircle(vertices) {
// Now that we have the set of vertices of the (convex) polygon that
// define the colour region, find the Chebyshev centre by a brute force
// search through the incentres of the triangles defined by each triplet
// of polygon sides.  (Brute force search is O(N^3), but N <= 6, so it's
// OK here.)

var polygon_segments = [];
for (var i = 0; i < vertices.length; i++) {
var j = next_vertex(i, vertices.length);
polygon_segments.push([vertices[i], vertices[j]]);
}

var triangle_segments = [];
var triangle_vertices = [];
var incircle = [];
var incentre = [0, 0];
var incircle_r = 0;
var valid = 1;
var tol = 1e-6;

for (var i = 0; i < polygon_segments.length; i++) {
for (var j = i+1; j < polygon_segments.length; j++) {
for (var k = j+1; k < polygon_segments.length; k++) {
// The triangle segments and the triangle vertices are
// not equivalent -- the segments are polygon segments,
// which are extended if necessary to produce the triangle
// vertices.

triangle_segments = [];
triangle_vertices = [];
triangle_segments[0] = polygon_segments[i];
triangle_segments[1] = polygon_segments[j];
triangle_segments[2] = polygon_segments[k];

triangle_vertices.push(intersection_segments_extended(triangle_segments[0], triangle_segments[1]));
triangle_vertices.push(intersection_segments_extended(triangle_segments[1], triangle_segments[2]));
triangle_vertices.push(intersection_segments_extended(triangle_segments[2], triangle_segments[0]));

incircle = triangle_incircle(triangle_vertices);

// Check if this is a valid incircle of the polygon.
var valid = 1;

if (!(point_in_polygon(polygon_segments, incircle[0]))) {
valid = 0;
} else {
// Do nothing.
}

if (valid == 1) {
for (var l = 0; l < polygon_segments.length; l++) {
if (distance_point_to_segment(polygon_segments[l], incircle[0]) + tol < incircle[1]) {
valid = 0;
break;
}
}
}

if ((incircle[1] > incircle_r) && (valid == 1)) {
incircle_r = incircle[1];
incentre[0] = incircle[0][0];
incentre[1] = incircle[0][1];
}
}
}
}

return [incentre, incircle_r];
}

function colour_region_vertices(L) {
// Input L on scale [0, 100].
// Returns an array of the vertices of the sRGB colour region.

// Find the vertices of the polygon region of valid colours.
// alpha*u' + beta*v' <= gamma for each of R, G, B.

var Y;
var alpha = [0, 0, 0];
var beta = [0, 0, 0];
var gamma = [0, 0, 0];

if (L <= 8) {
Y = white_point_Y * L * Math.pow(3/29, 3);
} else {
Y = white_point_Y * Math.pow((L+16)/116, 3);
}

for (var i = 0; i < 3; i++) {
alpha[i] = (9*XYZ_to_sRGB[i][0] - 3*XYZ_to_sRGB[i][2])*Y;
beta[i] = (-20*XYZ_to_sRGB[i][2] + 4*XYZ_to_sRGB[i][1])*Y;
gamma[i] = -12*XYZ_to_sRGB[i][2]*Y;
}

// lines 0, 1:
var u_1 = (beta[0]*gamma[1] - beta[1]*gamma[0]) / (alpha[1]*beta[0] - alpha[0]*beta[1]);
var v_1 = (gamma[0] - alpha[0]*u_1) / beta[0];

// lines 0, 2:
var u_2 = (beta[0]*gamma[2] - beta[2]*gamma[0]) / (alpha[2]*beta[0] - alpha[0]*beta[2]);
var v_2 = (gamma[0] - alpha[0]*u_2) / beta[0];

// lines 1, 2:
var u_3 = (beta[1]*gamma[2] - beta[2]*gamma[1]) / (alpha[2]*beta[1] - alpha[1]*beta[2]);
var v_3 = (gamma[1] - alpha[1]*u_3) / beta[1];

var vertices = [[u_1, v_1], [u_2, v_2], [u_3, v_3]];

// The inequalities defined by colour <= 1 are of the same form as for colour >= 0
// except that the beta coefficient in alpha*u' + beta*v' <= gamma is reduced by 4
// (and the direction of the inequality changes).
var beta_1 = [beta[0] - 4, beta[1] - 4, beta[2] - 4];

for (var i = 0; i < 3; i++) {
vertices = add_inequality(vertices, [alpha[i], beta_1[i], gamma[i]]);
}

return vertices;
}

function from_rgb_colorwheel(i, N, towards_white, offset) {
// colour i out of N colours in a scale.
// i is zero-indexed, N is a count starting from 1.
// towards_white sends colours towards white if greater than 1,
// and to black if less than 1.
// offset is an angular offset measured in degrees.

// x_offset = 0 starts it at red, 4 starts it at blue.
var x_offset = offset / 360 * 6;

var x = (6.0 * i / N + x_offset) % 6;

var x0 = Math.floor(x);
var x1 = x0 + 1;

var r, g, b;

switch(x0) {
case 0:
r = 255;
g = Math.floor((x - x0)*255);
b = 0;
break;
case 1:
r = Math.floor((x1 - x)*255);
g = 255;
b = 0;
break;
case 2:
r = 0;
g = 255;
b = Math.floor((x - x0)*255);
break;
case 3:
r = 0;
g = Math.floor((x1 - x)*255);
b = 255;
break;
case 4:
r = Math.floor((x - x0)*255);
g = 0;
b = 255;
break;
case 5:
r = 255;
g = 0;
b = Math.floor((x1 - x)*255);
break;
}

if (towards_white >= 0) {
r = Math.floor((1.0 - towards_white)*r + towards_white*255);
g = Math.floor((1.0 - towards_white)*g + towards_white*255);
b = Math.floor((1.0 - towards_white)*b + towards_white*255);
} else {
r = Math.floor((1.0 + towards_white)*r);
g = Math.floor((1.0 + towards_white)*g);
b = Math.floor((1.0 + towards_white)*b);
}
var rgb_string = "rgb(" + r + "," + g + "," + b + ")";
return rgb_string;
}

function from_cieluv_colorwheel(i, N, L, incentre, incircle_r, offset) {
// colour i out of N colours in a scale.
// i is zero-indexed, N is a count starting from 1.
// L is luminance.
// (incentre[0], incentre[1]) are the co-ordinates of the centre of the colour wheel.
// which is of radius incircle_r.
// offset is an angular offset in degrees.

var theta_0 = tau * offset / 360;

var theta = theta_0 + i*tau/N;

var u = incentre[0] + incircle_r * Math.cos(theta);
var v = incentre[1] + incircle_r * Math.sin(theta);

var rgb_vec = cieluv_to_rgb([L, u, v]);
var r, g, b;

r = rgb_vec[0];
g = rgb_vec[1];
b = rgb_vec[2];

var rgb_string = "rgb(" + r + "," + g + "," + b + ")";
return rgb_string;
}

Posted 2015-05-17.

Home > Misc