The L*u*v* and LChuv colour spaces
Posted by Michał ‘mina86’ Nazarewicz on 9th of May 2021 | (cite)
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.
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.
The
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:
Like before, depending on whether conversion to XYZ or xyY colour space is desired, either of the two sets of equations should be used:
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 (
Comparing L*a*b* and L*u*v*
The CIELAB and CIELUV colour spaces both attempted to be perceptually uniform. Neither achieved that goal and with no clearly better model in 1976 the International Commission on Illumination (CIE) adopted both. If a committee full of experts couldn’t identify the best one, I won’t try either. It’s nevertheless 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*.
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
In other words:
The
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.