culori icon indicating copy to clipboard operation
culori copied to clipboard

Small numerical inconsistency in CIEDE2000 implementation (line 145) causes ΔE00 error of 4e-5

Open michel-leonard opened this issue 5 months ago • 1 comments

Hi,

I noticed a small but consistent numerical difference between the ΔE00 results from culori and a reference implementation of the CIEDE2000 formula. After analyzing the source, it seems the discrepancy originates from the logic around hue angle interpolation, particularly here difference.js#L145

The current implementation does not fully handle the case where hue angles are on opposite sides of the color wheel, leading to a slight discontinuity. As a result, the maximum ΔE00 difference can reach ~0.000045 in some edge cases.

Proposed Fix

Adjust the hue difference and mean hue computation to properly wrap around 2π. A corrected version — verified to match a reliable reference with error < 1e-13 — is available here: https://github.com/michel-leonard/ciede2000-color-matching

Or directly in this adapted function:

Click to expand
const deltaE00_test = (lStd, aStd, bStd, lSmp, aSmp, bSmp) => {
    const Kl = 1,
        Kc = 1,
        Kh = 1

    let cStd = Math.sqrt(aStd * aStd + bStd * bStd);
    let cSmp = Math.sqrt(aSmp * aSmp + bSmp * bSmp);
    let cAvg = (cStd + cSmp) / 2;
    let G =
        0.5 *
        (1 -
            Math.sqrt(
                Math.pow(cAvg, 7) / (Math.pow(cAvg, 7) + Math.pow(25, 7))
            ));

    let apStd = aStd * (1 + G);
    let apSmp = aSmp * (1 + G);

    let cpStd = Math.sqrt(apStd * apStd + bStd * bStd);
    let cpSmp = Math.sqrt(apSmp * apSmp + bSmp * bSmp);

    let hpStd =
        Math.abs(apStd) + Math.abs(bStd) === 0 ?
        0 :
        Math.atan2(bStd, apStd);
    hpStd += (hpStd < 0) * 2 * Math.PI;

    let hpSmp =
        Math.abs(apSmp) + Math.abs(bSmp) === 0 ?
        0 :
        Math.atan2(bSmp, apSmp);
    hpSmp += (hpSmp < 0) * 2 * Math.PI;

    let dL = lSmp - lStd;
    let dC = cpSmp - cpStd;

    let dhp = cpStd * cpSmp === 0 ? 0 : hpSmp - hpStd;
    let hp = (hpStd + hpSmp) / 2;

    if (Math.PI < Math.abs(hpStd - hpSmp)) {
        if (dhp < 0) dhp -= 2 * Math.PI;
        else dhp += 2 * Math.PI;
        hp += Math.PI;
    }

    let dH = 2 * Math.sqrt(cpStd * cpSmp) * Math.sin(dhp / 2);

    let Lp = (lStd + lSmp) / 2;
    let Cp = (cpStd + cpSmp) / 2;


    let Lpm50 = Math.pow(Lp - 50, 2);
    let T =
        1 -
        0.17 * Math.cos(hp - Math.PI / 6) +
        0.24 * Math.cos(2 * hp) +
        0.32 * Math.cos(3 * hp + Math.PI / 30) -
        0.2 * Math.cos(4 * hp - (63 * Math.PI) / 180);

    let Sl = 1 + (0.015 * Lpm50) / Math.sqrt(20 + Lpm50);
    let Sc = 1 + 0.045 * Cp;
    let Sh = 1 + 0.015 * Cp * T;

    let deltaTheta =
        ((30 * Math.PI) / 180) *
        Math.exp(-1 * Math.pow(((180 / Math.PI) * hp - 275) / 25, 2));
    let Rc =
        2 *
        Math.sqrt(Math.pow(Cp, 7) / (Math.pow(Cp, 7) + Math.pow(25, 7)));

    let Rt = -1 * Math.sin(2 * deltaTheta) * Rc;

    return Math.sqrt(
        Math.pow(dL / (Kl * Sl), 2) +
        Math.pow(dC / (Kc * Sc), 2) +
        Math.pow(dH / (Kh * Sh), 2) +
        (((Rt * dC) / (Kc * Sc)) * dH) / (Kh * Sh)
    );
};

Summary

Reference Test Max ΔE00 Error
culori ref impl ~0.0000457
fixed ref impl ~1.1e-13

Thanks for your great work on culori! I hope this helps make the CIEDE2000 result even more precise.

Best, Michel

michel-leonard avatar Jun 03 '25 08:06 michel-leonard