gka / chroma.js

JavaScript library for all kinds of color manipulations
https://vis4.net/chromajs/
Other
9.92k stars 543 forks source link

Color conversion does not make the round trip: RGB - XYZ - LAB - LCH - LAB - XYZ - RGB #295

Open tdbe opened 2 years ago

tdbe commented 2 years ago

Hi, I ported your code to GLSL because I need to do gpu hue adjustments on CIE colors, so I needed to convert to LCH to change the hue, but I noticed that, without making any changes to the color, doing RGB - XYZ - LAB - LCH - LAB - XYZ - RGB I get a completely different result from the starting color. I am still fairly new to CIE so maybe I am not understanding it right? Is it not meant to work in a round trip? Just want to confirm at least if it's indeed supposed to work.

I did notice that if I do RGB - XYZ - Lab - XYZ - RGB, everything is correctly identical. When I involve LCH which is the only way I know how to change the Hue, I get broken results whether I change the hue or not.

My ported code, everything is done right AFAIK:

float atan2(float x, float y)
{
    bool s = (abs(x) > abs(y));
    return mix(_PI/2.0 - atan(x,y), atan(y,x), s);
}
//<--https://github.com/gka/chroma.js-->

//LAB Constants
// Corresponds roughly to RGB brighter/darker
const float _Kn = 18;

// D65 standard referent
const float _LAB_CONSTANTS_Xn = 0.950470;
const float _LAB_CONSTANTS_Yn = 1;
const float _LAB_CONSTANTS_Zn = 1.088830;

const float _LAB_CONSTANTS_t0 = 0.137931034;  // 4 / 29
const float _LAB_CONSTANTS_t1 = 0.206896552;  // 6 / 29
const float _LAB_CONSTANTS_t2 = 0.12841855;   // 3 * t1 * t1
const float _LAB_CONSTANTS_t3 = 0.008856452;  // t1 * t1 * t1

float rgb_xyz (float r) {
    if ((r /= 255) <= 0.04045) return r / 12.92;
    return pow((r + 0.055) / 1.055, 2.4);
}

float xyz_lab (float t) {
    if (t > _LAB_CONSTANTS_t3) return pow(t, 1 / 3);
    return t / _LAB_CONSTANTS_t2 + _LAB_CONSTANTS_t0;
}

vec3 rgb2xyz (float r,float g,float b) {
    r = rgb_xyz(r);
    g = rgb_xyz(g);
    b = rgb_xyz(b);
    float x = xyz_lab((0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / _LAB_CONSTANTS_Xn);
    float y = xyz_lab((0.2126729 * r + 0.7151522 * g + 0.0721750 * b) / _LAB_CONSTANTS_Yn);
    float z = xyz_lab((0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / _LAB_CONSTANTS_Zn);
    return vec3(x,y,z);
}

vec3 rgb2lab(float r,float g,float b) {
    vec3 xyz = rgb2xyz(r,g,b);
    float l = 116 * xyz.y - 16;
    return vec3(l < 0 ? 0 : l, 500 * (xyz.x - xyz.y), 200 * (xyz.y - xyz.z));
}

//-----

float xyz_rgb (float r) {
    return (255 * (r <= 0.00304 ? 12.92 * r : 1.055 * pow(r, 1 / 2.4) - 0.055));
}

float lab_xyz (float t) {
    return t > _LAB_CONSTANTS_t1 ? t * t * t : _LAB_CONSTANTS_t2 * (t - _LAB_CONSTANTS_t0);
}

/*
 * L* [0..100]
 * a [-100..100]
 * b [-100..100]
 */
vec3 lab2rgb (float l,float a,float b) {

    float x,y,z, r,g,b_;

    y = (l + 16) / 116;
    x = isnan(a) ? y : y + a / 500;
    z = isnan(b) ? y : y - b / 200;

    y = _LAB_CONSTANTS_Yn * lab_xyz(y);
    x = _LAB_CONSTANTS_Xn * lab_xyz(x);
    z = _LAB_CONSTANTS_Zn * lab_xyz(z);

    r = xyz_rgb(3.2404542 * x - 1.5371385 * y - 0.4985314 * z);  // D65 -> sRGB
    g = xyz_rgb(-0.9692660 * x + 1.8760108 * y + 0.0415560 * z);
    b_ = xyz_rgb(0.0556434 * x - 0.2040259 * y + 1.0572252 * z);

    return vec3(r,g,b_);
};

/**
Convert CIE Lab values of a color to CIE LCH(ab) values
The nominal ranges are as follows:
    1) Input: 0 to 100 for `L`; ±128 for `a` and `b`
    2) Output: 0 to 100 for `L`; 0 to 128√2 for `C` and 0 to 360° for `h`
Note: `L` is unchanged
*/
vec3 lab2lch(vec3 lab)
{
    vec3 temp = vec3(lab.x, 0, -1);

    //https://github.com/gka/chroma.js/blob/75ea5d8a5480c90ef1c7830003ac63c2d3a15c03/src/io/lch/lab2lch.js
    if (lab.y != 0)
    {
        float l = lab.x;
        float a = lab.y;
        float b = lab.z;
        float c = sqrt(a * a + b * b);
        float h = mod((atan2(b, a) * _RAD2DEG + 360), 360);//check atan2 definition up top
        //if (round(c*10000) == 0.0) h = Number.NaN;
        temp.y = c;
        temp.z = h;
    }

    return temp;
}

/**
Convert CIE LCH(ab) values of a color to CIE Lab values
The nominal ranges are as follows:
    1) Input: 0 to 100 for `L`; 0 to 128√2 for `C` and 0 to 360° for `h`
    2) Output: 0 to 100 for `L`; ±128 for `a` and `b`
Note: `L` is unchanged
*/
vec3 lch2lab(vec3 lch)
{
    vec3 temp = vec3(lch.x, 0, 0);

    //https://github.com/gka/chroma.js/blob/75ea5d8a5480c90ef1c7830003ac63c2d3a15c03/src/io/lch/lch2lab.js
    float h = lch.z;
    float c = lch.y;
    float l = lch.x;
    //if (isnan(h)) h = 0;
    h = h * _DEG2RAD;
    temp.y = cos(h) * c;
    temp.z = sin(h) * c;

    return temp;
}

//</--https://github.com/gka/chroma.js-->

    vec3 lab = rgb2lab(color.x, color.y, color.z);
    vec3 lch = lab2lch(lab);
    lch.z = lch.z + hue;
    lab = lch2lab(lch);
    vec3 rgb = lab2rgb(lab.x, lab.y, lab.z);

[EDIT]

I found this shadertoy in the meantime, and the code here makes the round trip and I can change hue. But the LCH code there is baked in with the LAB conversion, uses a lot of magic numbers etc, so I can't tell what's wrong here.

Anyway, at least this answers the question of "Is it not meant to work in a round trip?" I guess it is, and I am assuming (hoping) it's my fault somewhere and not the repo's

brandonmcconnell commented 2 years ago

I've heard that chroma does NOT support "round trip", but I personally think they should as well.

+1 from me 👍🏼

thomas-cruz commented 2 years ago

Had some base colors defined in HEX, got their LCH values and then setting new colors based on the same LCH values and converting it to HEX and the HEX values would be different to the expected values.

tdbe commented 2 years ago

Nice to see I wasn't crazy 🙂 I -guess- the problem here is there is loss of information going through all these color spaces and a lot of assumptions or approximations must happen to make a round trip.

But it would be amazing if they actually wrote this in like the start of their readme.md: "hey I know you're expecting to get color conversions out of this, but they don't make the round trip because of reasons" 🙂