-
Notifications
You must be signed in to change notification settings - Fork 92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Lab improvements. #46
Conversation
Merged matrices for linear RGB <-> XYZ D50 conversion, using the matrices from Lindbloom. Updated D50 to its correct value (rather than the one defined in the ICC spec).
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? |
Related notebook: https://beta.observablehq.com/@mbostock/lab-and-rgb |
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. |
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_to_xyzd65, then xyzd65_to_xyzd50, as matrix multiplications:
which are equivalent to:
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 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 |
(As for the benefit of D50 rather than D65 in the CSS Specs, I asked the question here) |
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). |
@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! |
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
] |
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. |
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. |
Okay, this looks good on my end. |
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 |
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. |
(I also tried looking at, and even computing my own, more precise matrices but I've never gotten any big difference indeed) |
Supersedes #45 #43.