Support for Oklab/Oklch?
sommerluk opened this issue · 18 comments
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?
Here is the definition of Oklab: https://bottosson.github.io/posts/oklab/
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.
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₁.
XYZ-D65 → Oklab
The transformation is done as described here in the Oklab definition.
[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
.]
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₂.
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.
@sommerluk
In f4e9f91 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.
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!
That's great. Thanks a lot!
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!
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.
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.
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.
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.
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.
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
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);
`
Okay, that's great. Thanks!