Color calibration - colorfulness

I get it – thank you so much for taking the time to explain.

As a side note, in my work I decompose signals into different bases and handle spectra all the time. I’m lucky, though, in that my bases are orthonormal (Fourier, Nyquist, etc) and usually one can go from spectrum to signal and vice-versa.

As side note #2, I wish this wasn’t called RGB. When I see RGB, what comes to my mind is a set of three LEDs, red, green and blue, as in a computer monitor.

Conceding that the math has not fully sunk in yet, in practical terms when I use the sliders they act on two of the three channels. So if I lower red it desaturates red and the adjacent channel G. If I lower blue it desaturates the G and B channels. Finally lowering the middle channel green desaturates the red and blue channels.

This is with gamut compression 0 and normalization off. It seems to hold for all the transformations. There may be nuances to the way this behaves if you change color profile settings which I may also not understand. I am sure there is color theory to back this up however the description in the manual seems overly simplistic given this behaviour if indeed that is the expected behaviour of the colorfulness sliders

" colorfulness tab controls

input red/green/blue

Adjust the color saturation of pixels, based on the R, G and B channels of those pixels. For example, adjusting the input red slider will affect the color saturation of pixels containing a lot of red more than colors containing only a small amount of red."

Again I am not disputing the math as I have no right to only the practical application and description of this feature for the moment. Case in point from the manual

" colorfulness tab controls

input red/green/blue

Adjust the color saturation of pixels, based on the R, G and B channels of those pixels. For example, adjusting the input red slider will affect the color saturation of pixels containing a lot of red more than colors containing only a small amount of red."

Extending this to the green channel …dropping the green colorfulness actually impacts all the colors except the green ones ie the ones with the most green shown by blending in difference mode as the black areas.

Before

After

It is never foolish to be wrong in persuit of improving your knowledge.

3 Likes

Thanks :slight_smile:

Given this, perhaps it would be more accuartely (in terms of user expectations) be renamed yellow, cyan and Magenta?

As I said I am likely totally missing something so I am not challenging the math or application only the practical result from my attempts. Using @kofa 's approach I get as I said the red slider altering red and green the blue slider altering green and blue and the green slider changing red and blue. On the isolated colors of the letters this seems clear to be the net result. Now if I pull up an image of a color chart can can still see the same things happening but overall the red predominantly alters the red however you can see green changing as well. The blue slider seems to impact the blue related colors but the green slider really does very little to the greens if anything and alters some of the red and blue dominant patches with orange not touched too much even though red is changing in some patches clearly. Perhaps someone seeing this will be able to explain it…

I was trying to run through the code. I still don’t follow it exactly because I am not a programmer. I did notice what might be an anomaly. In the code for the blue sliders one line uses four parameters (sorry) you see I don’t even know what to call it but…all other colors and repeated sequences do not have this. It seemed weird it would only be there for blue but I thought I would check…line 2815 has the extra text ,0.f)
image

vs

image

All other color entries only have 3 parameters in the expected form but the blue one has that extra one…maybe it is intended or makes no difference…

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 printfs 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)

2 Likes

Actually, of course, the other two colours also affect green; but green colourfulness does not: it only affects the other colours. I didn’t pay attention to this when I used the ‘RGB’ tiff file, but here are the same figures when we process a green pixel with each of the individual colourfulness sliders set to -1 (so, first only red is set to -1; then only green; finally, only blue):

red colourfulness = -1
1 input: 0.011666, 0.994394, 0.011671
1 saturation: 0.000000, 0.000000, 1.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.339244, mix: 0.000000, norm: 0.994531
3 output: 0.011730, 0.999862, 0.011735
4 coeff_ratio: 0.325556
5 output: 0.333467, 0.999907, 0.333470
6 output: 0.331643, 0.994438, 0.331647

green colourfulness = -1
1 input: 0.011666, 0.994393, 0.011671
1 saturation: 0.000000, 1.000000, 0.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.339244, mix: 0.000000, norm: 0.994530
3 output: 0.011730, 0.999862, 0.011735
4 coeff_ratio: 0.000000
5 output: 0.011730, 0.999862, 0.011735
6 output: 0.011666, 0.994393, 0.011671

blue colourfulness = -1
1 input: 0.011666, 0.994394, 0.011671
1 saturation: 1.000000, 0.000000, 0.000000
1 lightness: 0.000000, 0.000000, 0.000000
2 avg: 0.339244, mix: 0.000000, norm: 0.994531
3 output: 0.011730, 0.999862, 0.011735
4 coeff_ratio: 0.325559
5 output: 0.333470, 0.999907, 0.333474
6 output: 0.331647, 0.994438, 0.331650

You obviously dove in to the code…what do you think about the line that I found that seemed to have an extra parameter 2815…looking through that section of code replicated for all the colors only that line which is replicated has that extra variable…)

This is a declaration of an array of values.
If the last value is omitted it will default to 0 anyway (if what I remember from C is correct). Therefore it should not be an issue

Thanks I would not have known that and it was the only line in the string of similar code repeated for each color that had that…thanks for clarifying…I am not sure it should be zero…the values in all similar lines are 0.5, 0.5 and 0.5*a color_stop variable so I am not sure a zero should be there but maybe as a fourth item in the sequence it gets ignored??

So should Red and Blue be inverted as they are?? Should the coeff ratio for green be zero at -1 when the others are .325556??

I don’t know colour science, I just followed the formulae. As Aurélien said, he has ideas for a v2 (Color calibration - colorfulness - #58 by aurelienpierre).

Well you have put forth some calculations so I am sure that he will be able to comment and clarify it. I think line 2815 is a typo I just don;t know if it impacts the value of the variable. It could potentally set something to zero that is not intended or as @MartinL said maybe it does nothing…

I don’t think it’s a typo.

I just can’t see why it is there as it is the only line with this format the others don’t have it…

You can see that it declares a float[4], which means an array of 4 floating-point numbers. The 4th value is not used, as far as I know (it’s sometimes used ‘alpha’ (opaqueness) control, but the LMS (a family of colour spaces modelling human vision) values are never used with masks in the calculation, if my understanding is correct.
My guess is that it’s declared as 4 elements to optimise memory access. 1 float value takes 4 bytes; 3 would take an awkward 12. Awkward, as if you read memory in e.g. 64-bit / 8-byte chunks, there will be some triplets (12-bytes for 3 floats) that lie on a chunk boundary, requiring two memory accesses. Additionally, in multithreaded environments, writing such a boundary-crossing value would dirty two pages in the cache, requiring more cache invalidation.
Unfortunately, my work does not require such optimisations (I’m slowed down by over-engineered, complex technology, not by cache access patterns), so I’m not up to speed :slight_smile: in this topic. If what I wrote is complete gibberish, someone will correct me (I hope).

That’s right.