Finally, I understand why green saturation affects red and blue, but red affects red, and blue affects blue.
Setting red saturation to -1 on the UI actually sets blue saturation to 1 in the code, and vice versa: setting blue to -1 sets red to 1. Setting green to -1 just sets it to 1 during processing, and that is the special property of green: value on UI vs value actually used in calculation is not swapped with another component. I knew it could not be completely symmetric (otherwise, the components would behave the same way), and here is the point where symmetry breaks.
The coefficient ratio is calculated based on how far the (normalised) pixel is from 1, multiplied by a saturation (for green, the value is derived from UI green, for red and blue, from blue and red, respectively):
float coeff_ratio = 0.f;
for(size_t c = 0; c < 3; c++)
coeff_ratio += sqf(1.0f - output[c]) * saturation[c];
coeff_ratio /= 3.f;
I’ve added a couple of printf
s to luma_chroma
. I then created an image that has a single red pixel. I assigned the Rec2020 input profile to it, left working profile as Rec2020, set colour calibration to channel mixer (adaptation is bypassed → luma_chroma
runs in pipe RGB).
static inline void luma_chroma(const float input[4], const float saturation[4], const float lightness[4],
float output[4])
{
printf("1 input: %f, %f, %f\n", input[0], input[1], input[2]);
printf("1 saturation: %f, %f, %f\n", saturation[0], saturation[1], saturation[2]);
printf("1 lightness: %f, %f, %f\n", lightness[0], lightness[1], lightness[2]);
// Compute euclidean norm and flat lightness adjustment
const float avg = (input[0] + input[1] + input[2]) / 3.0f;
const float mix = scalar_product(input, lightness);
float norm = euclidean_norm(input);
printf("2 avg: %f, mix: %f, norm: %f\n", avg, mix, norm);
// Ratios
for(size_t c = 0; c < 3; c++) output[c] = input[c] / norm;
printf("3 output: %f, %f, %f\n", output[0], output[1], output[2]);
// Compute ratios and a flat colorfulness adjustment for the whole pixel
float coeff_ratio = 0.f;
for(size_t c = 0; c < 3; c++)
coeff_ratio += sqf(1.0f - output[c]) * saturation[c];
coeff_ratio /= 3.f;
printf("4 coeff_ratio: %f\n", coeff_ratio);
// Adjust the RGB ratios with the pixel correction
for(size_t c = 0; c < 3; c++)
{
// if the ratio was already invalid (negative), we accept the result to be invalid too
// otherwise bright saturated blues end up solid black
const float min_ratio = (output[c] < 0.0f) ? output[c] : 0.0f;
const float output_inverse = 1.0f - output[c];
output[c] = fmaxf(DT_FMA(output_inverse, coeff_ratio, output[c]), min_ratio); // output_inverse * coeff_ratio + output
}
printf("5 output: %f, %f, %f\n", output[0], output[1], output[2]);
// Apply colorfulness adjustment channel-wise and repack with lightness to get LMS back
norm *= fmaxf(1.f + mix / avg, 0.f);
for(size_t c = 0; c < 3; c++) output[c] *= norm;
printf("6 output: %f, %f, %f\n", output[0], output[1], output[2]);
}
Red pixel without saturation processing:
1 input: 0.999540, 0.000034, 0.002424
1 saturation: 0.000000, 0.000000, 0.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.333999, mix: 0.000000, norm: 0.999543
3 output: 0.999997, 0.000034, 0.002425
4 coeff_ratio: 0.000000
5 output: 0.999997, 0.000034, 0.002425
6 output: 0.999540, 0.000034, 0.002424 → pixel remains red
Same, with >>>green<<< saturation = -1 on the UI
1 input: 0.999541, 0.000033, 0.002424
1 saturation: 0.000000, 1.000000, 0.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.333999, mix: 0.000000, norm: 0.999544
3 output: 0.999997, 0.000033, 0.002425
4 coeff_ratio: 0.333311
5 output: 0.999998, 0.333333, 0.334928
6 output: 0.999542, 0.333181, 0.334775 → less saturated red, with some green and blue
Same, with >>>blue<<< saturation = -1 on the UI
1 input: 0.999540, 0.000034, 0.002424
1 saturation: 1.000000, 0.000000, 0.000000 (red saturation is set in the code, but I guess that’s due to the whole inverted processing, like blue = -1 => red = 1?)
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.333999, mix: 0.000000, norm: 0.999543
3 output: 0.999997, 0.000034, 0.002425
4 coeff_ratio: 0.000000
5 output: 0.999997, 0.000034, 0.002425
6 output: 0.999540, 0.000034, 0.002424 → red
Same, with >>>red<<< saturation = -1 on the UI
1 input: 0.999540, 0.000034, 0.002424
1 saturation: 0.000000, 0.000000, 1.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.333999, mix: 0.000000, norm: 0.999543
3 output: 0.999997, 0.000034, 0.002425
4 coeff_ratio: 0.331719
5 output: 0.999998, 0.331741, 0.333339
6 output: 0.999541, 0.331590, 0.333187 → less saturated red
(removed the spreadsheet link, as it has some confusing copy-paste formula errors)