mm2 / Little-CMS

A free, open source, CMM engine. It provides fast transforms between ICC profiles.
https://www.littlecms.com
MIT License
571 stars 176 forks source link

Support for Oklab/Oklch? #380

Closed sommerluk closed 1 year ago

sommerluk commented 1 year ago

Oklab is an alternative to Cielab. It works with the same logic, but claims to be more perceptually uniform than Cielab. It uses a D65 whitepoint. There are conversions defined from and to XYZ-D65 (a matrix + a cubic root respective power of three + another matrix).

Since its inclusion in the CSS Color Module Level 4 [W3C Candidate Recommendation Draft] it’s gaining wider support.

Would it be possible to support Oklab (and its derived cylindrical variant, Oklch) in LittleCMS out-of-the-box? So that pipelines can directly convert between Oklab/Oklch and an arbitrary ICC profile?

sommerluk commented 1 year ago

Here is the definition of Oklab: https://bottosson.github.io/posts/oklab/

mm2 commented 1 year ago

That seems a nice addition, and would be as easy as adding a color space profile bult-in. What I need is the set equations to convert CIE Lab* to this space and vice-versa. Also it would need some sort of chromatic adaptation to convert from D65 to D50, which is where ICC colorimetry is based.

sommerluk commented 1 year ago

That’s great to hear!

Currently, I’m doing this conversion in my own code. Probably this is not directly useful for you, because it’s hard-code and depends on C++ and Qt. But those are the steps:

Conversion from an arbitrary ICC profile to Oklch

Arbitrary ICC profile → XYZ-D50

This is done with LittleCMS as usual. Let’s call the result V₁.

XYZ-D50 → XYZ-D65

Let’s call the result V₂. The chromatic adaption is done with the Bradford transformation, as proposed here. Formula: V₂ = M⁻¹ × V₁. grafik

XYZ-D65 → Oklab

The transformation is done as described here in the Oklab definition. grafik grafik grafik grafik grafik [Note that l, m and s might be negative. Therefore, in my own code, I do not use std::pow(num, 1.0 / 3) which would not work for a negative num. Instead, I use std::cbrt(num) which is defined also for a negative num.] grafik grafik grafik grafik grafik

Oklab → Oklch

This can be done via void cmsLab2LCh(cmsCIELCh*LCh, const cmsCIELab* Lab)

Conversion from Oklch to an arbitrary ICC profile

Oklch → Oklab

This can be done via void cmsLCh2Lab(cmsCIELab* Lab, const cmsCIELCh* LCh)

Oklab → XYZ-D65

The transformation is done as described here in the Oklab definition. Let’s call the result V₂. grafik

XYZ-D65 → XYZ-D50

Let’s call the result V₁. The chromatic adaption is done with the Bradford transformation, as proposed here. Formula: V₁ = M × V₂.

XYZ-D50 → Arbitrary ICC profile

This is done with LittleCMS as usual.

mm2 commented 1 year ago

@sommerluk In f4e9f9122a001b71219d9b2d2a6ec5b8c784d785 you can see a preliminary implementation of OkLab space. This built-in profile cannot be saved as an ICC file, so its use is programmatically only.

It is a colorspace profile, so It can deal with OkLab-> whatever and whatever->OkLab. Note that it uses D50 as PCS white point.

The stages are:

[Conversion D60 to D65] ->[Conversion to LMS cone space] -> [Non-linearity] -> [Final matrix to OkLab] The input direction pipeline is like this one but reversed.

I still have to figure out how to deal with white point in unadapted absolute colorimetric, probably a D65 should be used as media white. It should do its way to 2.16, which is still months ahead.

Thanks again for the idea.

sommerluk commented 1 year ago

That's great! I've downloaded it and played around, and it works fine.

Maybe it makes sense to add

#define TYPE_OKLAB_DBL          (FLOAT_SH(1)|COLORSPACE_SH(PT_MCH3)|CHANNELS_SH(3)|BYTES_SH(0))

to the public header?

Thanks for implementing this as fast! That's a super-conveniant feature!

mm2 commented 1 year ago

Full support is now added by 6cabbcec8c229bf09dfa13b2b68eb0ab9f1030c8

sommerluk commented 1 year ago

That's great. Thanks a lot!

killyorz commented 12 months ago
int main()
{
    cmsSetLogErrorHandlerTHR(nullptr, log);
    cmsContext ctx = cmsCreateContext(nullptr, nullptr);

    cmsCIExyY D65xyY;
    cmsWhitePointFromTemp( &D65xyY, 6504);
    cmsHPROFILE oklabProfile = cmsCreateLab4Profile(&D65xyY);
    setupMetadata(ctx, oklabProfile);

    // Strict transformation between LAB and XYZ
    cmsSetDeviceClass(oklabProfile, cmsSigColorSpaceClass);
    cmsSetColorSpace(oklabProfile, cmsSigMCH3Data);
    cmsSetPCS(oklabProfile, cmsSigXYZData);
    cmsSetHeaderRenderingIntent(oklabProfile, INTENT_RELATIVE_COLORIMETRIC);

    const double M_D65_D50[] =
    {
       1.047886, 0.022919, -0.050216,
       0.029582, 0.990484, -0.017079,
      -0.009252, 0.015073,  0.751678
    };

    const double M_D50_D65[] =
    {
         0.955512609517083, -0.023073214184645,  0.063308961782107,
        -0.028324949364887,  1.009942432477107,  0.021054814890112,
         0.012328875695483, -0.020535835374141,  1.330713916450354
    };

    cmsStage* D65toD50 = cmsStageAllocMatrix(ctx, 3, 3, M_D65_D50, NULL);
    cmsStage* D50toD65 = cmsStageAllocMatrix(ctx, 3, 3, M_D50_D65, NULL);

    const double M_D65_LMS[] =
    {
        0.819022437996703, 0.3619062600528904, -0.1288737815209879,
        0.03298365393238847, 0.9292868615863434, 0.03614466635064236,
        0.04817718935962421, 0.2642395317527308, 0.6335478284694309
    };

    const double M_LMS_D65[] =
    {
        1.226879875845924, -0.5578149944602171, 0.2813910456659647,
       -0.04057574521480083, 1.112286803280317, -0.07171105806551635,
       -0.07637293667466008, -0.42149333240224324, 1.5869240198367818
    };

    cmsStage* D65toLMS = cmsStageAllocMatrix(ctx, 3, 3, M_D65_LMS, NULL);
    cmsStage* LMStoD65 = cmsStageAllocMatrix(ctx, 3, 3, M_LMS_D65, NULL);

    const double RootParameters[] = {1.0 / 3.0, 1, 0, 0};
    const double CubeParameters[] = {3.0, 1, 0, 0};

    cmsToneCurve* Root = cmsBuildParametricToneCurve(ctx, 6, RootParameters);
    cmsToneCurve* Cube = cmsBuildParametricToneCurve(ctx, 6, CubeParameters);

    cmsToneCurve* Roots[3] = { Root, Root, Root };
    cmsToneCurve* Cubes[3] = { Cube, Cube, Cube };

    cmsStage* NonLinearityFw = cmsStageAllocToneCurves(ctx, 3, Roots);
    cmsStage* NonLinearityRv = cmsStageAllocToneCurves(ctx, 3, Cubes);

    const double M_LMSprime_OkLab[] =
    {
        0.21045426830931396, 0.7936177747023053, -0.0040720430116192585,
        1.9779985324311686, -2.42859224204858, 0.450593709617411,
        0.025904042465547734, 0.7827717124575297, -0.8086757549230774
    };

    const double M_OkLab_LMSprime[] =
    {
        1.0, 0.3963377773761749, 0.21580375730991364,
        1.0, -0.10556134581565857, -0.0638541728258133,
        1.0, -0.08948417752981186, -1.2914855480194092
    };

    cmsStage* LMSprime_OkLab = cmsStageAllocMatrix(ctx, 3, 3, M_LMSprime_OkLab, NULL);
    cmsStage* OkLab_LMSprime = cmsStageAllocMatrix(ctx, 3, 3, M_OkLab_LMSprime, NULL);

    //LAB -> XYZD50
    cmsPipeline* AToB = cmsPipelineAlloc(ctx, 3, 3);
    cmsPipelineInsertStage(AToB, cmsAT_END, cmsStageDup(OkLab_LMSprime));      // Matrix = LAB -> LMS
    cmsPipelineInsertStage(AToB, cmsAT_END, cmsStageDup(NonLinearityRv));
    cmsPipelineInsertStage(AToB, cmsAT_END, cmsStageDup(LMStoD65));            // Matrix = LMS -> XYZ
    cmsPipelineInsertStage(AToB, cmsAT_END, cmsStageDup(D65toD50));            // Matrix = D65 -> D50
    cmsWriteTag(oklabProfile, cmsSigDToB0Tag, AToB);

    //XYZD50 -> LAB
    cmsPipeline* BToA = cmsPipelineAlloc(ctx, 3, 3);
    cmsPipelineInsertStage(BToA, cmsAT_END, cmsStageDup(D50toD65));            // Matrix = D50 -> D65
    cmsPipelineInsertStage(BToA, cmsAT_END, cmsStageDup(D65toLMS));            // Matrix = XYZ -> LMS
    cmsPipelineInsertStage(BToA, cmsAT_END, cmsStageDup(NonLinearityFw));
    cmsPipelineInsertStage(BToA, cmsAT_END, cmsStageDup(LMSprime_OkLab));      // Matrix = LMS -> LAB
    cmsWriteTag(oklabProfile, cmsSigBToD0Tag, BToA);

    cmsPipelineFree(AToB);
    cmsPipelineFree(BToA);

    cmsFreeToneCurve(Root);
    cmsFreeToneCurve(Cube);

    if (!cmsMD5computeID(oklabProfile)) {
        std::cerr << "Failed MD5 computation" << std::endl;
        return -1;
    }

    const std::string profileName{"oklab.icc"};

    if (!cmsSaveProfileToFile(oklabProfile, profileName.c_str())) {
        std::cerr << "CANNOT WRITE PROFILE" << std::endl;
        return -2;
    }
}

Hi, @mm2, I have created an ICC profile for oklab in this situation, but I found that when a or b is negative, when using cmsCreateTransform to convert oklab to an integer colour space, such as rgb8 or rgb16, the result of the conversion seems to be incorrect (a and b seem to be clipped to 0 before the conversion) . But when converting to floating point such as rgbF16 or rgbF32 the result is correct, could the error be caused by Optimisation? Thanks a lot!

mm2 commented 12 months ago

You are including only BtoD /DToB tags which according the spec are to be used as a complement specialized on floats. You should use AtoB/BToA tags and if you want more precision on floats then also add DToB/BToD . If you could use unstable, 2.16 has an OkLab implementation on cmsCreate_OkLabProfile(). You can look at the code too.

killyorz commented 12 months ago

Okay, thank you so much!

You are including only BtoD /DToB tags which according the spec are to be used as a complement specialized on floats. You should use AtoB/BToA tags and if you want more precision on floats then also add DToB/BToD . If you could use unstable, 2.16 has an OkLab implementation on cmsCreate_OkLabProfile(). You can look at the code too.

killyorz commented 12 months ago

You are including only BtoD /DToB tags which according the spec are to be used as a complement specialized on floats. You should use AtoB/BToA tags and if you want more precision on floats then also add DToB/BToD . If you could use unstable, 2.16 has an OkLab implementation on cmsCreate_OkLabProfile(). You can look at the code too.

Well, I tried to create the oklab icc profile in krita using cmsCreate_OkLabProfile(), but since krita uses cmsSaveProfileToMem, it doesn't seem to be able to do it successfully, which is really sad.

mm2 commented 12 months ago

This particular profile cannot be saved. The ICC file format does not support the combination of stages in the pipeline. It only works as a built-in.

killyorz commented 12 months ago

This particular profile cannot be saved. The ICC file format does not support the combination of stages in the pipeline. It only works as a built-in.

Okay, I got it. Thank you.

killyorz commented 12 months ago

Hi, @mm2, I created the oklab profile using cmsCreate_OkLabProfile() but it doesn't seem to convert to rgb16 correctly, here's the code I'm testing with:

#define TYPE_LABA_F32 (FLOAT_SH(1)|COLORSPACE_SH(PT_MCH3)|EXTRA_SH(1)|CHANNELS_SH(3)|BYTES_SH(4))

    cmsUInt16Number rgb[3];
    cmsFloat32Number lab[3];

    cmsHPROFILE labProfile = cmsCreate_OkLabProfile(NULL);
    cmsHPROFILE rgbProfile = cmsCreate_sRGBProfile();

    cmsHTRANSFORM hBack = cmsCreateTransform(labProfile, TYPE_LABA_F32, rgbProfile, TYPE_RGB_16, INTENT_RELATIVE_COLORIMETRIC, 0);
    cmsHTRANSFORM hForth = cmsCreateTransform(rgbProfile, TYPE_RGB_16, labProfile, TYPE_LABA_F32, INTENT_RELATIVE_COLORIMETRIC, 0);

    cmsCloseProfile(labProfile);
    cmsCloseProfile(rgbProfile);

    rgb[0] = 0;
    rgb[1] = 0;
    rgb[2] = 65535;

    cmsDoTransform(hForth, rgb, &lab, 1);
    cmsDoTransform(hBack, lab, &rgb, 1);

    cmsDeleteTransform(hBack);
    cmsDeleteTransform(hForth);

    std::cout<<rgb[0]<<' '<<rgb[1]<<' '<<rgb[2]<< std::endl;

    //Target results: 0 0 65535
    //Actual results: 22025 22020 22012
mm2 commented 12 months ago

Thanks for reporting. This seems not related to OkLab but on transforms going float->integer. If you use floats on rgb the roundtrip works well, so it is not the profile. I will file a bug regarding this issue, not limited to OkLab.

See below the code that works fine

`

define TYPE_LABA_F32 (FLOAT_SH(1)|COLORSPACE_SH(PT_MCH3)|EXTRA_SH(1)|CHANNELS_SH(3)|BYTES_SH(4))

cmsFloat32Number rgb[3];
cmsFloat32Number lab[4];

cmsHPROFILE labProfile = cmsCreate_OkLabProfile(NULL);
cmsHPROFILE rgbProfile = cmsCreate_sRGBProfile();

cmsHTRANSFORM hBack = cmsCreateTransform(labProfile, TYPE_LABA_F32, rgbProfile, TYPE_RGB_FLT, INTENT_RELATIVE_COLORIMETRIC, 0);
cmsHTRANSFORM hForth = cmsCreateTransform(rgbProfile, TYPE_RGB_FLT, labProfile, TYPE_LABA_F32, INTENT_RELATIVE_COLORIMETRIC, 0);

cmsCloseProfile(labProfile);
cmsCloseProfile(rgbProfile);

rgb[0] = 0;
rgb[1] = 0;
rgb[2] = 1.0f;

cmsDoTransform(hForth, rgb, &lab, 1);
cmsDoTransform(hBack, lab, &rgb, 1);

cmsDeleteTransform(hBack);
cmsDeleteTransform(hForth);

`

killyorz commented 12 months ago

Okay, that's great. Thanks!

mm2 commented 12 months ago

It is now fixed by 4c0c66e7109a903c470159ecd3d753e8a4d56c79, but I have to check it more carefully.

killyorz commented 12 months ago

It is now fixed by 4c0c66e, but I have to check it more carefully.

Snipaste_2023-11-15_23-00-23

Now it works fine in krita! Really thank you for your help!