d3 / d3-color

Color spaces! RGB, HSL, Cubehelix, CIELAB, and more.
https://d3js.org/d3-color
ISC License
398 stars 91 forks source link

Lab improvements. #46

Closed mbostock closed 6 years ago

mbostock commented 6 years ago

Supersedes #45 #43.

mbostock commented 6 years ago

Question for @danburzo: Can you verify my understanding of the new implementation? I’m going to write it out in a more pedagogical long form to make sure I understand what the steps are.

function rgb2lab([rgb_r, rgb_g, rgb_b]) {

  // 1. Convert from RGB to linear RGB.
  const lrgb_r = rgb2lrgb(rgb_r);
  const lrgb_g = rgb2lrgb(rgb_g);
  const lrgb_b = rgb2lrgb(rgb_b);

  // 2. Convert from linear RGB to XYZ D50.
  const xyz_x = (0.4360747 * lrgb_r + 0.3850649 * lrgb_g + 0.1430804 * lrgb_b) / 0.9642;
  const xyz_y = (0.2225045 * lrgb_r + 0.7168786 * lrgb_g + 0.0606169 * lrgb_b) / 1.0000;
  const xyz_z = (0.0139322 * lrgb_r + 0.0971045 * lrgb_g + 0.7141733 * lrgb_b) / 0.82521;

  // 3. Convert from XYZ D50 to Lab.
  const lab_x = xyz2lab(xyz_x);
  const lab_y = xyz2lab(xyz_y);
  const lab_z = xyz2lab(xyz_z);
  const lab_l = 116 * lab_y - 16;
  const lab_a = 500 * (lab_x - lab_y);
  const lab_b = 200 * (lab_y - lab_z);

  return [lab_l, lab_a, lab_b];
}

function rgb2lrgb(x) {
  return (x /= 255) <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
}

function xyz2lab(t) {
  return t > 0.008856452 ? Math.pow(t, 0.333333) : t / 0.1284185 + 0.137931;
}

I’m unclear about the last part, step 3: what does [lab_x, lab_y, lab_z] represent, if anything? Where is the chromatic adaptation to D65 happening?

mbostock commented 6 years ago

Related notebook: https://beta.observablehq.com/@mbostock/lab-and-rgb

mbostock commented 6 years ago

I’m also taking a stab at implementing the non-normative JavaScript reference in the CSS4 spec, but it’s a little difficult because it alludes to Math.matrix and matrix.multiply and matrix.valueOf which are not defined. Specifically, it looks like when you convert from linear RGB to XYZ, you need to normalize after the matrix multiply:

// Convert from linear RGB in [0, 1] to XYZ with sRGB’s D65 referent.
// http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
function lrgb_xyzd65([r, g, b]) {
  return [
    (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / (0.4124564 + 0.3575761 + 0.1804375),
    (0.2126729 * r + 0.7151522 * g + 0.0721750 * b) / (0.2126729 + 0.7151522 + 0.0721750),
    (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / (0.0193339 + 0.1191920 + 0.9503041)
  ];
}

(Those sums for normalization are Xn, Yn, Zn in d3-color.)

However, it’s not clear whether you need to apply the same normalization in the Bradford chromatic adaptation step?

// Bradford chromatic adaptation from D65 to D50.
// http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html
function xyzd65_xyzd50([x, y, z]) {
  return [
    (+1.0478112 * x + 0.0228866 * y - 0.0501270 * z), // / (+1.0478112 + 0.0228866 - 0.0501270),
    (+0.0295424 * x + 0.9904844 * y - 0.0170491 * z), // / (+0.0295424 + 0.9904844 - 0.0170491),
    (-0.0092345 * x + 0.0150436 * y + 0.7521316 * z) // / (-0.0092345 + 0.0150436 + 0.7521316)
  ];
}

Or at least if you do, then it’s not symmetric with the inverse adaptation from D50 back to D65, so I’m confused as to what I’m supposed to be doing here.

danburzo commented 6 years ago

The intent is to convert sRGB to a CIELab color that is relative to the D50 standard illuminant, rather than the D65 standard illuminant, like it does now. The implementation merges a few steps together, but if we break it apart:

1. sRGB → LRGB:

// 1. Convert from RGB to linear RGB.
  const lrgb_r = rgb2lrgb(rgb_r);
  const lrgb_g = rgb2lrgb(rgb_g);
  const lrgb_b = rgb2lrgb(rgb_b);

2. LRGB → XYZ:

Converting from LRGB to XYZ we get the x, y, z coordinates for the same illuminant as the source, which in sRGB's case is D65. But we want to convert to CIELab D50, so we need to perform chromatic adaptation on the x, y, z values to get them relative to D50.

The two-step process is described in the CSS Spec as _lrgb_toxyzd65, then _xyzd65_toxyzd50, as matrix multiplications:

                   a11 a12 a13
[A', B', C']   =   a21 a22 a23   ×  [A, B, C] 
                   a31 a32 a33

which are equivalent to:

A' = a11 * A + a12 * B + a13 * C
B' = a21 * A + a22 * B + a23 * C
C' = a31 * A + a32 * B + a33 * C

The two matrices are:

We can pre-multiply these two matrices to get a direct LRGB to XYZD50 matrix, which can be found on this page (the sRGB D50 entry in the last table). The code contains this pre-multiplied matrix:

  // 2. Convert from linear RGB to XYZ D50.
  const xyz_x = (0.4360747 * lrgb_r + 0.3850649 * lrgb_g + 0.1430804 * lrgb_b);
  const xyz_y = (0.2225045 * lrgb_r + 0.7168786 * lrgb_g + 0.0606169 * lrgb_b);
  const xyz_z = (0.0139322 * lrgb_r + 0.0971045 * lrgb_g + 0.7141733 * lrgb_b);

The x = X/Xn, y = Y/Yn, z = Z/Zn part actually belongs to the next step of converting XYZD50 to CIELabD50 below:

3. XYZD50 → LabD50

  // 3. Convert from XYZ D50 to Lab.
  const lab_x = xyz2lab(xyz_x / Xn);
  const lab_y = xyz2lab(xyz_y / Yn);
  const lab_z = xyz2lab(xyz_z / Zn);
  const lab_l = 116 * lab_y - 16;
  const lab_a = 500 * (lab_x - lab_y);
  const lab_b = 200 * (lab_y - lab_z);

  return [lab_l, lab_a, lab_b];

The lab_x, lab_y, and lab_z are just intermediary values in the computation. (The xyz2lab function name is confusing, as the whole step 3 is the conversion, and xyz2lab is just the function f as described on Wikipedia).

danburzo commented 6 years ago

(As for the benefit of D50 rather than D65 in the CSS Specs, I asked the question here)

svgeesus commented 6 years ago

Math.matrix and matrix.multiply and matrix.value

Why do you say they are not defined? note that Math (which defines matrix, among other things) is not the same as Math.

However, any convenient library that does matrix multiplication and inversion can be used. I used Math library mainly for clarity (rather than writing out the element by element matrix multiplication longhand).

mbostock commented 6 years ago

@svgeesus I don’t know what “Math” the specification is referring to. I get that it’s not JavaScript’s Math object, which would be my default assumption for JavaScript, but I can’t find the reference in the spec. Edit: Is it math.js? Stating that explicitly would be helpful. Thank you!

mbostock commented 6 years ago

Okay, great! Thanks @danburzo for the explanation. I was able to derive the Bradford-adapted sRGB to D50 matrix from the sRGB to D65 matrix and the Bbradford D65 to D50 matrix, so I think I understand what’s going on here.

This is the linear sRGB to XYZ D65 matrix:

matrix_rgb_xyz_d65 = [
  0.4124564, 0.3575761, 0.1804375,
  0.2126729, 0.7151522, 0.0721750,
  0.0193339, 0.1191920, 0.9503041
]

This is the Bradford XYZ D65 to XYZ D50 matrix:

matrix_bradford_d65_d50 = [
  1.0478112, 0.0228866, -0.0501270,
  0.0295424, 0.9904844, -0.0170491,
  -0.0092345, 0.0150436, 0.7521316
]

We want to multiply the two together, like so:

matrix_multiply_matrix(matrix_bradford_d65_d50, matrix_rgb_xyz_d65)

Where

function matrix_multiply_matrix([
  a0, b0, c0,
  d0, e0, f0,
  g0, h0, i0
], [
  a1, b1, c1,
  d1, e1, f1,
  g1, h1, i1
]) {
  return [
    a0 * a1 + b0 * d1 + c0 * g1, a0 * b1 + b0 * e1 + c0 * h1, a0 * c1 + b0 * f1 + c0 * i1,
    d0 * a1 + e0 * d1 + f0 * g1, d0 * b1 + e0 * e1 + f0 * h1, d0 * c1 + e0 * f1 + f0 * i1,
    g0 * a1 + h0 * d1 + i0 * g1, g0 * b1 + h0 * e1 + i0 * h1, g0 * c1 + h0 * f1 + i0 * i1
  ];
}

This produces the linear sRGB to XYZ D50 matrix, here rounded to the same precision as the input:

matrix_rgb_xyz_d50 = [
  0.4360747, 0.3850649, 0.1430804,
  0.2225045, 0.7168786, 0.0606169,
  0.0139322, 0.0971045, 0.7141733
]
mbostock commented 6 years ago

Okay, great. Now I understand the discrepancy you reported at w3c/csswg-drafts#2492:

tristimulus_d50 = [
  matrix_rgb_xyzd50[0] + matrix_rgb_xyzd50[1] + matrix_rgb_xyzd50[2],
  matrix_rgb_xyzd50[3] + matrix_rgb_xyzd50[4] + matrix_rgb_xyzd50[5],
  matrix_rgb_xyzd50[6] + matrix_rgb_xyzd50[7] + matrix_rgb_xyzd50[8]
]

This produces [0.96422, 1, 0.82521] whereas the non-normative implementation in the spec uses [0.9642, 1, 0.8249]. My take is we should use [0.96422, 1, 0.82521] for internal consistency, but I’m not sure if there are any negative consequences of that decision.

danburzo commented 6 years ago

Yes, I also think [0.96422, 1, 0.82521] is the way forward and the specs will most likely include this definition of the D50 white point, if I understand correctly.

mbostock commented 6 years ago

Okay, this looks good on my end.

danburzo commented 6 years ago

Looks good to me too. Should we add some tests that confirm that a round-trip through the Lab / LCh color space preserves the values of a RGB color? Just to check that the two matrices (LRGB → XYZD50 and XYZD50 → LRGB) are the inverse of one another

mbostock commented 6 years ago

I checked that in my notebook using Matrix.js. I am wondering whether there are higher-precision values for these matrices we could be using, but I don’t think it would make a big difference.

I’ll add some more tests if I have time.

danburzo commented 6 years ago

(I also tried looking at, and even computing my own, more precise matrices but I've never gotten any big difference indeed)