The L*u*v* and LChuv colour spaces

Posted by Michał ‘mina86’ Nazarewicz on 9th of May 2021

I’ve written about L*a*b* so it’s only fair that I’ll also describe its twin sister: the L*u*v* colour space (a.k.a. CIELUV). The two share a lot in common. For example, they use the same luminance value, base their chromaticity on opponent process theory and each of them has a corresponding cylindrical LCh coordinate system. Yet, despite those similarities — or perhaps because of them — the CIELUV colour space is often overlooked.

Panther Chameleon
Fig. 1. Picture of a chameleon with its decomposition into L*, u* and v* channels. Photo by Dr Pratt Datta.

Even though L*a*b* is getting all the limelight, L*u*v* model has its advantages. Before we start comparing the two colour spaces, let’s first go through the conversion formulæ.

sRGB and D65

Since I’ve already described translation between sRGB and XYZ (twice in fact), this article will focus on relations between XYZ, xyY, L*u*v* and LChuv colour spaces.

This article is still going to have some sRGB bias and assume everything is computed in reference to the D65 white point. CIELUV can be calculated compared to arbitrary illuminant but if one starts with an sRGB colour it’s easiest to stick to the same white point.

It’s important to keep in mind that others may use different illuminant — D50 is another common choice — and that to properly convert an sRGB colour (which uses D65) to L*u*v* with a different white point, the XYZ coordinates need to be treated with a Bradford transform first.

With those disclosures out of the way, let’s get to the conversion. In addition to presenting Maths equations, I’ll present Rust implementation of the operations. The code is also provided in luv crate.

XYZ or xyY to L*u*v*

L*a*b* and L*u*v* colour models use the same luminance component. Formula for calculating it will therefore be identical. Equations for the other two coordinates depends on whether conversion is done from XYZ or xyY colour space. $$ \begin{align} L^* &= \begin{cases} 24389 Y / 27 & \text{if }24389 Y ≤ 216 \\ 116 \sqrt[3]{Y} - 16 & \text{otherwise} \end{cases} \\ u^* &= 13 L^* (u' - u_w') \\ v^* &= 13 L^* (v' - v_w') \\[.5em] u_w' &= 0.1978330369967827 \\ v_w' &= 0.4683304743525223 \end{align} $$

The \(u_w'\) and \(v_w'\) variables specify chromaticity of the reference white point. As discussed earlier, provided values are for D65 illuminant. The \(u'\) and \(v'\) values for any colour can be calculated from XYZ coordinates or xy chromaticity as follows: $$ \begin{align} u' &= 4X / D & u' &= 4x / d \\ v' &= 9Y / D & v' &= 9y / d \\ D &= X + 15Y + 3Z & d &= -2x + 12y + 3 \end{align} $$

Expressed in code, the formulæ look as follows (for brevity only the XYZ version is shown):

/// Converts colour from XYZ space to CIELUV (a.k.a. L*u*v*) using
/// D65 reference white point.
fn luv_from_xyz(xyz: [f32; 3]) -> [f32; 3] {
    const EPSILON: f32 = 216.0 / 24389.0;
    const KAPPA: f32 = 24389.0 / 27.0;
    const WHITE_U_PRIME: f32 = 0.1978330369967827;
    const WHITE_V_PRIME: f32 = 0.4683304743525223;

    let [x, y, z] = xyz;

    let l = if y <= 0.0 {
        /* This case is needed to handle XYZ = (0, 0, 0)
           which would otherwise lead to division by zero. */
        return [0.0, 0.0, 0.0];
    } else if y <= EPSILON {
        KAPPA * y
    } else {
        116.0 * y.powf(1.0 / 3.0) - 16.0
    };

    let d = x + 15.0 * y + 3.0 * z;
    let u = 13.0 * l * (4.0 * x / d - WHITE_U_PRIME);
    let v = 13.0 * l * (9.0 * y / d - WHITE_V_PRIME);

    [ l, u, v ]
}

L*u*v* to XYZ or xyY

Conversion in the opposite direction can be easily derived with all the operations simply performed in reverse: $$ \begin{align} Y &= \begin{cases} \left({L^* + 16 \over 116}\right)^3 & \text{if }L^* > 8 \\ {27 L^* \over 24389} & \text{otherwise} \end{cases} \\ u' &= {u^* \over 13 L^*} + u_w' \\ v' &= {v^* \over 13 L^*} + v_w' \end{align} $$

Like before, depending on whether conversion to XYZ or xyY colour space is desired, either of the two sets of equations should be used: $$ \begin{align} X &= Y {9u' \over 4v'} & x &= {9 u' \over d} \\ Z &= Y {12 - 3u' - 20v' \over 4v'} & y &= {4 v' \over d} \\ & & d &= 6 u' - 16 v' + 12 \end{align} $$

Again limiting the code to XYZ colour space, the above written in Rust becomes:

/// Converts colour from CIELUV (a.k.a. L*u*v*) space
/// using D65 reference white point to XYZ.
fn xyz_from_luv(luv: [f32; 3]) -> [f32; 3] {
    const ONE_OVER_KAPPA: f32 = 27.0 / 24389.0;
    const WHITE_U_PRIME: f32 = 0.1978330369967827;
    const WHITE_V_PRIME: f32 = 0.4683304743525223;

    if luv.l <= 0.0 {
        /* This case is needed to avoid division by zero */
        return [0.0, 0.0, 0.0];
    }
    let ll = 13.0 * luv.l;
    let u_prime = luv.u / ll + WHITE_U_PRIME;
    let v_prime = luv.v / ll + WHITE_V_PRIME;

    let y = if luv.l > 8.0 {
        ((luv.l + 16.0) / 116.0).powi(3)
    } else {
        luv.l * ONE_OVER_KAPPA
    };

    let a = 0.75 * y * u_prime / v_prime;
    let x = 3.0 * a;
    let z = y * (3.0 - 5.0 * v_prime) / v_prime - a;

    [x, y, z]
}

L*u*v* and LChuv

The L*u*v* model represents chromaticity using a green-red (\(u'\)) and blue-yellow (\(v'\)) axes. Those are hard to interpret (as per figure 1 at the beginning of the article) so for convince CIE defined a cylindrical LCHuv colour space (sometimes also called HCL). It is based on CIELUV and uses the same L* value but encodes chromaticity using chroma (\(C^*\)) and hue (\(h\)). If this all sounds familiar, it’s because this is the same motivation behind the LChab and the formulæ used to convert between models are equivalent as well: $$ \begin{align} C^* &= \sqrt{u^{*2} + v^{*2}} \\ h &= \text{atan}_2(v^*, u^*) \\[.5em] u^* &= C^* \cos(h) \\ v^* &= C^* \sin(h) \end{align} $$

Comparing L*a*b* and L*u*v*

The CIELAB and CIELUV colour spaces both attempt to be perceptually uniform. Neither managed to achieve that goal and no clearly better model could be selected so in 1976 the International Commission on Illumination (CIE) adopted both. If a commission full of experts couldn’t figure the best on out, I won’t convince anyone one way or another either. It’s still worth keeping in mind that there are more than one almost perceptually uniform colour model and it may not always be the best option to default to L*a*b*.

Panther Chameleon
Fig. 2. The hue wheel stretched into a single band for HSL (top bar), LChab (middle) and LChuv (bottom) colour spaces. In the top band, S = 0.5 and L varies from 0.2 to 0.8. In the middle and bottom bands, L* varies from 32.3 to 80.0. In the middle (bottom) band, C* varies from 38.93 (44.13) at the edges to 77.85 (88.26) in the middle.

Figure 2 above depicts the full range of hues for HSL, LChab and LChuv colour spaces. The red, green and blue lines on the figure indicate sRGB colours whose other two values are equal. In other words, the lines is where colour converted to sRGB has the coordinates (r, x, x), (x, g, x) or (x, x, b) respectively. Those lines demonstrate the three primary hues are spaced more evenly in CIELUV model than in CIELAB. This is, by the way, why the theme customisation on this blog uses the former colour space.

There are of course other aspects to consider. For example, with embedding of RGB gamuts in L*a*b* and L*u*v* having irregular shape, clipping may become an issue which affects the two models differently.

As an aside, the figure also clearly demonstrates that HSL’s lightness is a misnomer. HSL may be somewhat useful for picking colours as an alternative to RGB, but using it to compare or manipulate colours is a bad idea.

sRGB conversion optimisation

To finish up, it might be worth nothing that if conversion is performed from an RGB colour system the implementation can be optimised a bit. Normally, the chain of operations is RGB→linear RGB→XYZ→L*u*v* (and the same thing backwards when switching colour spaces in the opposite direction). Moving between linear RGB and XYZ coordinates involves a handful of linear transformations which can be combined with some operations performed when converting between XYZ and L*u*v*..

Firstly, observe that to calculate colour’s CIELUV coordinates we need \(4X\), \(Y\) and \(D = X + 15Y + 3Z\) values only. This is because L* is a function of \(Y\), \(u' = 4X / D\) and \(v' = 9Y / D\). Secondly, observe that colour’s XYZ coordinates are linear combination of \(Y\), \(Y / v'\) and \(Yu' / v'\) values. In particular, \(X = 2.25Yu'/v'\) and \(Z = -5Y + 3Y/v' - 0.75Yu'/v'\). With that we can derive the following: $$ \begin{align} \begin{bmatrix} 4X \\ Y \\ D \end{bmatrix} &= \begin{bmatrix} 4 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 15 & 3 \\ \end{bmatrix} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix} = \begin{bmatrix} 4 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 15 & 3 \\ \end{bmatrix} M \begin{bmatrix} R \\ G \\ B \end{bmatrix} \\[.5em] \begin{bmatrix} R \\ G \\ B \end{bmatrix} &= M^{-1} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix} = M^{-1} \begin{bmatrix} 0 & 0 & 2.25\\ 1 & 0 & 0 \\ -5 & 3 & -0.75 \end{bmatrix} \begin{bmatrix} Y \\ {Y \over v'} \\ {Y u' \over v'} \end{bmatrix} \end{align} $$

In other words: $$ \begin{align} \begin{bmatrix} 4X \\ Y \\ D \end{bmatrix} &= M^* \begin{bmatrix} R \\ G \\ B \end{bmatrix} & \text{where}\quad & M^* = \begin{bmatrix} 4 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 15 & 3 \\ \end{bmatrix} M \\[.5em] \begin{bmatrix} R \\ G \\ B \end{bmatrix} &= M^{-*} \begin{bmatrix} Y \\ {Y \over v'} \\ {Y u' \over v'} \end{bmatrix} & \text{where}\quad & M^{-*} = M^{-1} \begin{bmatrix} 0 & 0 & 2.25\\ 1 & 0 & 0 \\ -5 & 3 & -0.75 \end{bmatrix} \end{align} $$

The \(M\) and \(M^{-1}\) are of course the RGB↔XYZ change of basis matrices. The insight here is that \(M^*\) and \(M^{-*}\) matrices can be calculated beforehand thus reducing number of operations when performing conversion between RGB and L*u*v* colour.

It does lead to faster execution but due to the nature of floating point numbers it leads to less precise results. This is pretty much the reason why the luv crate doesn’t currently use this optimisation. Other implementations which favour speed may choose to do so.