Skip to content

Possible bug in parametric curve handling inside cmsPipeline? #301

Description

@amyspark

Hi again,

I'm writing some code to generate an YCbCr ICC profile. For V2, in the YCbCr -> XYZ direction, I need to pack the full transformation in the LUT, as follows:

Demo
#include <lcms2.h>

#include <array>
#include <iostream>

#define RESOLUTION 24

// Source: Tooms (2015), table 11.1, p.192
constexpr cmsCIExyY d65 = {0.3127, 0.3290, 1.0};

// Inverse OETF curve
//
// Source: basic algebra on ITU-R BT.601-7, ss. 2.6.4
constexpr std::array<cmsFloat64Number, 5> rec601ParametersInv = {1.0 / 0.45, 1.0 / 1.099, 0.099 / 1.099, 1.0 / 4.5, 0.081};

// OETF curve
//
// Source: ITU-R BT.601-7, ss. 2.6.4
const std::array<cmsFloat64Number, 7> rec601Parameters = {0.45, 1.099, 0, 4.5, 0.018, -0.099, 0};

void log(cmsContext ctx, unsigned int errorCode, const char *msg)
{
    std::cerr << "context " << ctx << " error: " << errorCode << " (" << msg << ")" << std::endl;
}

cmsInt32Number sample(const cmsUInt16Number In[], cmsUInt16Number Out[], void *cargo)
{
    cmsPipelineEval16(In, Out, reinterpret_cast<cmsPipeline *>(cargo));
    return TRUE;
}

// From the V4 profile above EXTRACT:
// - cmsSigMediaWhitePointTag
// - any of the cmsSigRedTRCTag, cmsSigGreenTRCTag, cmsSigBlueTRCTag
// - cmsSigChromaticAdaptationTag
// and use with the YCbCr profile
cmsHPROFILE createBaseRec709Profile(cmsContext ctx)
{
    // Elle Stone's prequantized sRGB primaries
    const cmsCIExyYTRIPLE sRGBPrimariesPreQuantized = {{0.639998686, 0.330010138, 1.0}, {0.300003784, 0.600003357, 1.0}, {0.150002046, 0.059997204, 1.0}};

    auto toneCurveInv = cmsBuildParametricToneCurve(ctx, 4, rec601ParametersInv.data());
    const std::array<cmsToneCurve *, 3> curves = {toneCurveInv, toneCurveInv, toneCurveInv};

    return cmsCreateRGBProfileTHR(ctx, &d65, &sRGBPrimariesPreQuantized, curves.data());
}

void setupMetadata(cmsContext ctx, cmsHPROFILE profile)
{
    cmsMLU *description = cmsMLUalloc(ctx, 1);
    cmsMLUsetASCII(description, "en", "US", "ITU-R BT.601-7 YCbCr ICC V2 profile");
    cmsWriteTag(profile, cmsSigProfileDescriptionTag, description);
}

int main()
{
    cmsSetLogErrorHandlerTHR(nullptr, log);
    auto ctx = cmsCreateContext(nullptr, nullptr);

    auto baseProfile = createBaseRec709Profile(ctx);
    cmsSaveProfileToFile(baseProfile, "srgb.icc");

    auto yCbrProfile = cmsCreateLab2Profile(&d65);
    setupMetadata(ctx, yCbrProfile);

    // Strict transformation between YCbCr and XYZ
    cmsSetDeviceClass(yCbrProfile, cmsSigColorSpaceClass);
    cmsSetColorSpace(yCbrProfile, cmsSigYCbCrData);
    cmsSetPCS(yCbrProfile, cmsSigXYZData);
    cmsSetHeaderRenderingIntent(yCbrProfile, INTENT_PERCEPTUAL);

    // The YCbCr -> XYZ conversion goes as follows:
    auto yCbrPipeline = cmsPipelineAlloc(ctx, T_CHANNELS(TYPE_YCbCr_16), T_CHANNELS(TYPE_XYZ_16));
    // 0. Dummy curves for the "gamma"-corrected YCbCr.
    auto pipeline1_M = cmsStageAllocToneCurves(ctx, T_CHANNELS(TYPE_YCbCr_16), nullptr);
    // 1. Chrominance channels are [-0.5, 0.5]. Adjust.
    // The offset is pre-applied before the transform.
    const std::array<double, 9> identity = {{1, 0, 0, 0, 1, 0, 0, 0, 1}};
    const std::array<double, 3> offset_ycbcr_to_rgb = {0, -0.5, -0.5};
    auto yCbrOffset = cmsStageAllocMatrix(ctx, T_CHANNELS(TYPE_YCbCr_16), T_CHANNELS(TYPE_YCbCr_16), identity.data(), offset_ycbcr_to_rgb.data());
    // 2. YCbCr -> normalized R'G'B. Source: Wolfram Alpha, inverted matrix.
    const std::array<double, 9> ycbcr_to_rgb = {{0.916602, 1.46156, 0.0287004, 1.04248, -0.744473, -0.358755, 1., 4.93315e-17, 1.772}};
    auto yCbrMatrix = cmsStageAllocMatrix(ctx, T_CHANNELS(TYPE_YCbCr_16), T_CHANNELS(TYPE_RGB_16), ycbcr_to_rgb.data(), NULL);
    // 2. Normalized R'G'B -> linear RGB. Source: ITU-R BT.601-7 ss. 2.6.4
    auto trc = reinterpret_cast<cmsToneCurve *>(cmsReadTag(baseProfile, cmsSigRedTRCTag));
    const std::array<cmsToneCurve *, 3> gamma = {trc, trc, trc};
    auto pipeline1_B = cmsStageAllocToneCurves(ctx, T_CHANNELS(TYPE_RGB_16), gamma.data());
    // 3. Linear RGB -> XYZ.
    const std::array<double, 9> rgb_to_xyz = {{0.4124, 0.3576, 0.1805, 0.2126, 0.7152, 0.0722, 0.0193, 0.1192, 0.9505}};
    auto pipeline1_C = cmsStageAllocMatrix(ctx, T_CHANNELS(TYPE_RGB_16), T_CHANNELS(TYPE_XYZ_16), rgb_to_xyz.data(), nullptr);

    // Assemble the YCbCr -> XYZ pipeline.
    cmsPipelineInsertStage(yCbrPipeline, cmsAT_END, yCbrOffset);
    cmsPipelineInsertStage(yCbrPipeline, cmsAT_END, yCbrMatrix);
    cmsPipelineInsertStage(yCbrPipeline, cmsAT_END, pipeline1_B); // M = OETF
    cmsPipelineInsertStage(yCbrPipeline, cmsAT_END, pipeline1_C); // Matrix = RGB -> XYZ

    auto lut1 = cmsStageAllocCLut16bit(ctx, RESOLUTION, T_CHANNELS(TYPE_YCbCr_16), T_CHANNELS(TYPE_XYZ_16), nullptr);
    cmsStageSampleCLut16bit(lut1, &sample, yCbrPipeline, 0);

    // This LUT is then saved to the profile
    auto p = cmsPipelineAlloc(ctx, T_CHANNELS(TYPE_YCbCr_16), T_CHANNELS(TYPE_XYZ_16));
    cmsPipelineInsertStage(p, cmsAT_END, pipeline1_M); // A = dummy curves
    // The CLUT is needed because AtoB0 in v2 can only pack a CLUT.
    cmsPipelineInsertStage(p, cmsAT_END, lut1);                     // CLUT = YCbr -> XYZ
    cmsPipelineInsertStage(p, cmsAT_END, cmsStageDup(pipeline1_M)); // B = dummy curves
    cmsWriteTag(yCbrProfile, cmsSigAToB0Tag, p);

    // The XYZ -> YCbCr conversion goes as follows:
    auto yCbrPipeline2 = cmsPipelineAlloc(ctx, T_CHANNELS(TYPE_XYZ_16), T_CHANNELS(TYPE_YCbCr_16));
    // 0. Dummy curves for the gamma-uncorrected YCbCr.
    auto pipeline2_M = cmsStageAllocToneCurves(ctx, 3, NULL);
    // 1. XYZ -> Linear RGB.
    const std::array<double, 9> xyz_to_rgb = {{3.2406, -1.5372, -0.4986, -0.9689, 1.8758, 0.0415, 0.0557, -0.2040, 1.0570}};
    auto pipeline2_C = cmsStageAllocMatrix(ctx, T_CHANNELS(TYPE_XYZ_16), T_CHANNELS(TYPE_RGB_16), xyz_to_rgb.data(), NULL);
    // 2. Linear RGB -> Normalized R'G'B. Source: ITU-R BT.601-7, ss. 2.6.4
    auto trcI = cmsBuildParametricToneCurve(ctx, 5, rec601Parameters.data());
    const std::array<cmsToneCurve *, 3> gamma_i = {trcI, trcI, trcI};
    cmsStage *pipeline2_B = cmsStageAllocToneCurves(ctx, 3, gamma_i.data());
    // 3. Normalized R'G'B -> YCbCr. Source: ITU-R BT.601-7, ss. 3.3
    const std::array<double, 9> rgb_to_ycbcr = {
        {0.299, 0.587, 0.114, 0.701 / 1.402, -0.507 / 1.402, -0.114 / 1.402, -0.299 / 1.772, -0.587 / 1.772, 0.886 / 1.772}};
    // 4. Chrominance channels are [-0.5, 0.5]. Adjust.
    // The offset is applied after the transform, so no additional matrix is
    // needed.
    const std::array<double, 3> offset_i = {0, 0.5, 0.5};
    auto pipeline2_Matrix = cmsStageAllocMatrix(ctx, T_CHANNELS(TYPE_RGB_16), T_CHANNELS(TYPE_YCbCr_16), rgb_to_ycbcr.data(), offset_i.data());

    cmsPipelineInsertStage(yCbrPipeline2, cmsAT_END, pipeline2_C); // Matrix = XYZ -> RGB
    cmsPipelineInsertStage(yCbrPipeline2, cmsAT_END, pipeline2_B); // M = OETF^-1
    cmsPipelineInsertStage(yCbrPipeline2, cmsAT_END, pipeline2_Matrix);

    auto lut2 = cmsStageAllocCLut16bit(ctx, RESOLUTION, T_CHANNELS(TYPE_XYZ_16), T_CHANNELS(TYPE_YCbCr_16), NULL);
    cmsStageSampleCLut16bit(lut2, &sample, yCbrPipeline2, 0);

    auto p2 = cmsPipelineAlloc(ctx, T_CHANNELS(TYPE_XYZ_16), T_CHANNELS(TYPE_YCbCr_16));

    cmsPipelineInsertStage(p2, cmsAT_END, pipeline2_M);              // B = dummy
    cmsPipelineInsertStage(p2, cmsAT_END, lut2);                     // CLUT = R'G'B' -> YCbr
    cmsPipelineInsertStage(p2, cmsAT_END, cmsStageDup(pipeline2_M)); // A = dummy
    cmsWriteTag(yCbrProfile, cmsSigBToA0Tag, p2);

    // Bradford transform from D65 (BT.709-6) to D50 (ICC 4.3)
    auto bradford = cmsReadTag(baseProfile, cmsSigChromaticAdaptationTag);
    cmsWriteTag(yCbrProfile, cmsSigChromaticAdaptationTag, bradford);

    if (!cmsMD5computeID(yCbrProfile))
        std::cerr << "Failed MD5 computation" << std::endl;
    if (!cmsSaveProfileToFile(yCbrProfile, "krita_bt601-7_ycbcr_v2.icc"))
        std::cerr << "CANNOT WRITE PROFILE" << std::endl;
}

However, doing so completely breaks the gamut of the profile as viewed in Krita's Color Space Selector. I've narrowed it down to the type 4 parametric curve I use for the linearization step after the YCbCr conversion; if I replace it by another type, e.g. cmsBuildGamma(ctx, 2.2), the problem disappears. This also doesn't occur in V4 profiles, where this step can be stored explicitly in the M curves of the AtoB0 tag.

@mm2 I'm not sure what is going on here, would you take a look?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions