RGB Saturation Gamut Mapping Approach and a Comp/VFX Perspective

The Munsell data set is from the Munsell Color Science Laboratory at RIT:

https://www.rit.edu/cos/colorscience/rc_munsell_renotation.php

In the 1940’s the color science community recognized that the most visually-uniform color space to date, the Munsell Color Order System, had inconsistencies that required examination and remedy.

all.dat: real and unreal

File download: all.dat

These are all the Munsell data, including the extrapolated colors. Note that extrapolated colors are in some cases unreal. That is, some lie outsize the Macadam limits.

This file should be used for those performing multidimensional interpolation to/from Munsell data. You will need the unreal colors in order to completely encompass the real colors, which is required to do the interpolation when near the Macadam limits.

I took a read through the BT.2446 paper. The log equation used for the upper portion of their tone-mapping algorithm looked useful.

Here’s a graph with all variables they have in their paper, comparing it to Reinhard and the natural exponential compression function, with variables modified to give similar y=1 intersection and threshold. And another graph, simplified, with d/dx, and inverse curve.

I’ve also implemented it in the GamutCompress blinkscript and DCTL. I need to do more extensive testing, but the initial results seem very nice. It feels a lot more natural than some of the other curves, and I haven’t yet noticed any issues from the lack of C2 continuity.

Now we have 5 different compression functions to test! :smiley:

I’m also going to start using releases to track progress as @matthias.scharfenber suggested. Release v0.1 has only Reinhard compression and is working with a regular expression node and the blinkscript and DCTL.

Next step is to do a lot more testing regarding hue and results in different gamuts…

Nice! Will need to add it to the notebook, not sure if we want to try maintaining parity but it could be a good idea.

Visual inspection is certainly not enough because the eye is notoriously bad at detecting those type of low-frequency changes, where it starts being problematic is more with grading tools relying on qualifiers, here is some selective red suppression:

I can almost see a desaturated cone where the log variant starts kicking:

It gets worse by tweaking the distance limits. It might be a contrived example, but it is the type of things we should look for, especially because the operator will eventually be applied before grading.

set cut_paste_input [stack 0]
version 12.1 v1
push $cut_paste_input
Expression {
 expr0 "x / width - 0.25"
 expr1 "y / height - 0.25"
 name Expression14
 selected true
 xpos 3004
 ypos 2027
}
Group {
 name GamutCompress_blink9
 label "\[value method] : \[value direction]"
 selected true
 xpos 3004
 ypos 2051
 addUserKnob {20 GamutCompress}
 addUserKnob {4 method t "Choose the type of distance compression function you want to use" M {log reinhard exp atan tanh}}
 addUserKnob {22 reset -STARTLINE T "n = nuke.thisNode()\nknobs = \['threshold', 'cyan', 'magenta', 'yellow']\nfor k in knobs:\n    n\[k].setValue(0.2)"}
 addUserKnob {6 use_gpu l "use gpu" t "use gpu for blinkscript node" -STARTLINE}
 use_gpu true
 addUserKnob {7 threshold t "Percentage of the gamut to affect. A value of 0.2 will leave leave the core 80% of the colors within the gamut unaffected." R 0 0.2}
 threshold 0.3
 addUserKnob {26 distance_limit_label l " " t "Specifies the distance beyond the gamut boundary to map to the gamut boundary for each color component." T "<b>distance limit"}
 addUserKnob {7 cyan t "distance limit for the R component." R 0.001 1}
 cyan 0.2
 addUserKnob {7 magenta t "distance limit for the G component." R 0.001 1}
 magenta 0.2
 addUserKnob {7 yellow t "distance limit for the B component." R 0.001 1}
 yellow 0.2
 addUserKnob {26 ""}
 addUserKnob {4 direction M {forward inverse}}
 addUserKnob {20 info_tab l Info}
 addUserKnob {26 info_label l " " T "<style> a:link \{ color: #ccc \}</style>\n<font color=#ccc>\n<b>GamutCompress</b><br>\nmaps out of gamut colors back into gamut.<br><br>\n\n<b>Method</b><br>\nSpecify the tone compression curve to use when <br>\nmapping out of gamut colors into the boundary threshold.<br>\n<a href=https://www.desmos.com/calculator/hmzirlw7tj>log</a>\n<a href=https://www.desmos.com/calculator/lkhdtjbodx>reinhard</a>\n<a href=https://www.desmos.com/calculator/s2adnicmmr>exp</a>\n<a href=https://www.desmos.com/calculator/h96qmnozpo>atan</a>\n<a href=https://www.desmos.com/calculator/xiwliws24x>tanh</a>\n<br><br>\n\n<b>Threshold</b><br>\nPercentage of the gamut to affect. If threshold is 0.2, <br>\nthe inner 80% of the gamut will be unaffected and <br>\nout of gamut values will be compressed into <br>\nthe outer 20% of the gamut's color volume.<br><br>\n\n<b>Max Distance</b><br>\nPer color component control to specify what distance will be <br>\ncompressed to the gamut boundary. For example, <br>\na value of cyan=0.2 will map colors with a distance of red=1.2 from <br>\nthe achromatic axis to red=1.0, which is the gamut boundary.<br><br><br>\n\n<b>Direction</b><br>\nSpecifies whether to apply or inverse the gamut compression operation.\n<br><br>\n<a href=https://github.com/jedypod/gamut-compress>Additional Documentation</a><br><br>\n\nWritten by <a href=https://github.com/jedypod color=red>Jed Smith</a> with <a href=https://community.acescentral.com/t/rgb-saturation-gamut-mapping-approach-and-a-comp-vfx-perspective>help</a> from the <a href=https://community.acescentral.com/c/aces-development-acesnext/vwg-aces-gamut-mapping-working-group>ACES Gamut Mapping VWG</a>"}
}
 Input {
  inputs 0
  name Input
  xpos -40
  ypos -10
 }
 AddChannels {
  name AddChannels
  note_font Helvetica
  xpos -40
  ypos 26
 }
 BlinkScript {
  kernelSourceFile /cave/dev/github/gamut-compress/GamutCompress.cpp
  recompileCount 15
  ProgramGroup 1
  KernelDescription "2 \"GamutCompression\" iterate pixelWise 1dcee6a45b699c1349e8387e6472366b100957bee76685a075fd9740e0ab7c08 2 \"src\" Read Point \"dst\" Write Point 6 \"threshold\" Float 1 AAAAAA== \"cyan\" Float 1 AAAAAA== \"magenta\" Float 1 AAAAAA== \"yellow\" Float 1 AAAAAA== \"method\" Int 1 AAAAAA== \"invert\" Bool 1 AA== 6 \"threshold\" 1 1 \"cyan\" 1 1 \"magenta\" 1 1 \"yellow\" 1 1 \"method\" 1 1 \"invert\" 1 1 3 \"thr\" Float 1 1 AAAAAA== \"lim\" Float 3 1 AAAAAAAAAAAAAAAAAAAAAA== \"pi\" Float 1 1 AAAAAA=="
  kernelSource "kernel GamutCompression : ImageComputationKernel<ePixelWise> \{\n  Image<eRead, eAccessPoint, eEdgeClamped> src;\n  Image<eWrite> dst;\n\n  param:\n    float threshold;\n    float cyan;\n    float magenta;\n    float yellow;\n    int method; // compression method\n    bool invert;\n\n  local:\n  float thr;\n  float3 lim;\n  float pi;\n\n  void init() \{\n    pi = 3.14159265359;\n\n    // thr is the percentage of the core gamut to protect: the complement of threshold.\n    thr = (1 - threshold);\n        \n    // lim is the max distance from the gamut boundary that will be compressed\n    // 0 is a no-op, 1 will compress colors from a distance of 2.0 from achromatic to the gamut boundary\n    // if method is Reinhard, use the limit as-is\n    if (method == 0) \{\n      lim = float3(cyan+1, magenta+1, yellow+1);\n    \} else \{\n      // otherwise, we have to bruteforce the value of limit \n      // such that lim is the value of x where y=1 - also enforce sane ranges to avoid nans\n      // importantly, this runs once at the beginning of evaluation, NOT per-pixel!!!\n      lim = float3(\n        bisect(max(0.0001, cyan)+1), \n        bisect(max(0.0001, magenta)+1), \n        bisect(max(0.0001, yellow)+1));\n    \}\n  \}\n\n  // calculate hyperbolic tangent\n  float tanh( float in) \{\n    float f = exp(2.0f*in);\n    return (f-1.0f) / (f+1.0f);\n  \}\n\n  // calculate inverse hyperbolic tangent\n  float atanh( float in) \{\n    return log((1.0f+in)/(1.0f-in))/2.0f;\n  \}\n\n  // compression function which gives the y=1 x intersect at y=0\n  float f(float x, float k) \{\n    if (method == 0) \{\n      return k;\n    \} else if (method == 1) \{\n      // natural exponent compression method\n      return -log((-x+1)/(thr-x))*(-thr+x)+thr-k;\n    \} else if (method == 2) \{ \n      // natural logarithm compression method\n      return (exp((1-thr+thr*log(1-x)-x*thr*log(1-x))/(thr*(1-x))))*thr+x*thr-k;\n    \} else if (method == 3) \{\n      // arctangent compression method\n      return (2*tan( (pi*(1-thr))/(2*(x-thr)))*(x-thr))/pi+thr-k;\n    \} else if (method == 4) \{\n      // hyperbolic tangent compression method\n      return atanh((1-thr)/(x-thr))*(x-thr)+thr-k;\n    \}\n  \}\n\n  int sign(float x) \{\n    return x == 0 ? 0 : x > 0 ? 1 : 0;\n  \}\n\n  float bisect(float k) \{\n    // use a simple bisection algorithm to bruteforce the root of f\n    // returns an approximation of the value of limit \n    // such that the compression function intersects y=1 at desired value k\n    // this allows us to specify the max distance we will compress to the gamut boundary\n    \n    float a, b, c, y;\n    float tol = 0.0001; // accuracy of estimate\n    int nmax = 100; // max iterations\n\n\n    // set up reasonable initial guesses for each method given output ranges of each function\n    if (method == 2) \{\n      // natural logarithm needs a limit between -inf (linear), and 1 (clip)\n      a = -5;\n      b = 0.96;\n    \} else if (method == 4) \{\n      // tanh needs more precision\n      a = 1.000001;\n      b = 5;\n    \} else \{\n      a = 1.0001;\n      b = 5;\n    \}\n\n    if (sign(f(a, k)) == sign(f(b, k))) \{\n      // bad estimate. return something close to linear\n      if (method == 2) \{\n        return -100;\n      \} else \{\n        return 1.999999;\n      \}\n    \}\n    c = (a+b)/2;\n    y = f(c, k);\n    if (y == 0) \{\n      return c; // lucky guess\n    \}\n\n    int n = 1;\n    while ((fabs(y) > tol) && (n <= nmax)) \{\n      if (sign(y) == sign(f(a, k))) \{\n        a = c;\n      \} else \{\n        b = c;\n      \}\n      c = (a+b)/2;\n      y = f(c, k);\n      n += 1;\n    \}\n    return c;\n  \}\n\n\n  // calculate compressed distance\n  float compress(float dist, float lim) \{\n    float cdist;\n    if (dist < thr) \{\n      cdist = dist;\n    \} else \{\n      if (method == 0) \{\n        // simple Reinhard type compression suggested by Nick Shaw and Lars Borg\n        // https://community.acescentral.com/t/simplistic-gamut-mapping-approaches-in-nuke/2679/3\n        // https://community.acescentral.com/t/rgb-saturation-gamut-mapping-approach-and-a-comp-vfx-perspective/2715/52\n        // example plot: https://www.desmos.com/calculator/h2n8smtgkl\n        if (invert == 0) \{\n          cdist = thr + 1/(1/(dist - thr) + 1/(1 - thr) - 1/(lim - thr));\n        \} else \{\n          cdist = thr + 1/(1/(dist - thr) - 1/(1 - thr) + 1/(lim - thr));\n        \}\n      \} else if (method == 1) \{\n        // natural exponent compression method: plot https://www.desmos.com/calculator/jf99glamuc\n        if (invert == 0) \{\n          cdist = lim-(lim-thr)*exp(-(((dist-thr)*((1*lim)/(lim-thr))/lim)));\n        \} else \{\n          cdist = -log((dist-lim)/(thr-lim))*(-thr+lim)/1+thr;\n        \}\n      \} else if (method == 2) \{\n        // natural logarithm compression method: plot https://www.desmos.com/calculator/rv08vuzqjk\n        if (invert == 0) \{\n          cdist = thr*log(dist/thr-lim)-lim*thr*log(dist/thr-lim)+thr-thr*log(1-lim)+lim*thr*log(1-lim);\n        \} else \{\n          cdist = exp((dist-thr+thr*log(1-lim)-lim*thr*log(1-lim))/(thr*(1-lim)))*thr+lim*thr;\n        \}\n      \} else if (method == 3) \{\n        // arctangent compression method: plot https://www.desmos.com/calculator/olmjgev3sl\n        if (invert == 0) \{\n          cdist = thr + (lim - thr) * 2 / pi * atan(pi/2 * (dist - thr)/(lim - thr));\n        \} else \{\n          cdist = thr + (lim - thr) * 2 / pi * tan(pi/2 * (dist - thr)/(lim - thr));\n        \}\n      \} else if (method == 4) \{\n        // hyperbolic tangent compression method: plot https://www.desmos.com/calculator/sapcakq6t1\n        if (invert == 0) \{\n          cdist = thr + (lim - thr) * tanh( ( (dist- thr)/( lim-thr)));\n        \} else \{\n          cdist = thr + (lim - thr) * atanh( dist/( lim - thr) - thr/( lim - thr));\n        \}\n      \}\n    \}\n    return cdist;\n  \}\n\n\n  void process() \{\n    // source pixels\n    float3 rgb = float3(src().x, src().y, src().z);\n\n    // achromatic axis \n    float ach = max(rgb.x, max(rgb.y, rgb.z));\n\n    // distance from the achromatic axis for each color component aka inverse rgb ratios\n    // distance is normalized by achromatic, so that 1.0 is at gamut boundary, and avoiding 0 div\n    float3 dist = ach == 0 ? float3(0, 0, 0) : fabs(rgb-ach)/ach; \n\n    // compress distance with user controlled parameterized shaper function\n    float3 cdist = float3(\n      compress(dist.x, lim.x),\n      compress(dist.y, lim.y),\n      compress(dist.z, lim.z));\n\n    // recalculate rgb from compressed distance and achromatic\n    // effectively this scales each color component relative to achromatic axis by the compressed distance\n    float3 crgb = ach-cdist*ach;\n\n    // write to output\n    dst() = float4(crgb.x, crgb.y, crgb.z, src().w);\n  \}\n\};"
  useGPUIfAvailable {{parent.use_gpu}}
  rebuild ""
  GamutCompression_threshold {{parent.threshold}}
  GamutCompression_cyan {{parent.cyan}}
  GamutCompression_magenta {{parent.magenta}}
  GamutCompression_yellow {{parent.yellow}}
  GamutCompression_method {{parent.method}}
  GamutCompression_invert {{parent.direction}}
  rebuild_finalise ""
  name GamutCompress
  selected true
  xpos -40
  ypos 80
 }
 Output {
  name Output
  xpos -40
  ypos 134
 }
end_group
HueCorrect {
 hue {sat {}
   lum {}
   red {curve 0 0.25 1 1 1 1 0 s0}
   green {}
   blue {}
   r_sup {}
   g_sup {}
   b_sup {}
   sat_thrsh {}}
 name HueCorrect1
 selected true
 xpos 3004
 ypos 2083
}

The work that is being done here blows my mind, even though the math goes too much over my head :smiley:
Thanks so much for the matchbox version from @nick - I started playing around with it.

Right now I’m using the other frame from the take of the woman in the bar - I’ve also uploaded it to the dropbox repository.

So this is how the image looks under default arri lut and this is what I use for kind of the best case scenario target, which I know is not really obtainable, but something to learn from.

I zoomed to this areas

these areas are interesting, because there are changes between colors which are quite sudden, but soft/gradient at the same time
so they are less “forgiving”

so this is the default arri lut

this is tahn

this is exp

this is simple

and this is the academy fix just for fun

all of the matchbox settings are default
I think all of them have their “problems”, but tahn is probably most pleasing, but still too dark and saturated
the shadows of the balls are especially “trippy” as the shadow should be darker, but it’s the other way around - I know it’s debatable, even the default arri doesn’t really do it like that and maybe even perceptually on the spot it would look darker…who knows…
and the other problems are the darker outlines - when the blue neon color blends into another one - green or brownish

tweaking the other parameters made the blue color brighter, which helps, but makes more visible outlines around the edges as a negative side effect

anyway, great to see the progress, I’ll take a look at the dctl version from @jedsmith next!
cheers!

3 Likes

Hey Martin!
Thanks so much for all the great imagery you’ve contributed for us to test with. It’s super important to have good test images, and the ones you’ve shared are super useful.

Interesting idea using the camera display lut to use as a sort of ground truth to compare against.

I’ve been playing around quite a bit with the different compression methods, and interestingly you can get pretty much the same results out of each compression method if you carefully adjust the threshold and max distance.

I think the max distance limit parameters are the critical thing to reduce undesired shifts in color and to keep as much saturation as possible.

For example for compressing the gamut of an Alexa Wide Gamut source image in ACEScg, the max possible distance (assuming there are no out of gamut values in the source camera gamut image) is 1.07, 1.21, 1.05. If you plug these values into the cyan magenta yellow max distance limit controls you get a result that is pretty close to the Arri view lut.

Here’s a comparison. (I’m using the log compression method here but the same results should be present for the other options as well).

Arri LogC2Video_709 View Transform / Display Rendering Transform (DRT)

Arri Wide Gamut source image with IDT applied and gamut compress with default values in ACEScg. Note the purple shift in the light on the top of the pool table.

Same ACEScg image but with the max distance parameters of the gamut compress set to the max possible distance from an Alexa Wide Gamut → ACEScg mapping:

And for fun, here’s another with the threshold increased slightly so that we affect 30% of the outer gamut. The saturation is reduced a bit, because we are pulling the out of gamut values farther inside the gamut boundary.

And to help visualize what is going on, here is a CIE 1931 xy plot of the blue primary for each of these images:



With the default max distance limits, the Cyan corner of the triangle is over-compressed resulting in the purple shift.

With the max limit adjusted to the max possible distance for an Arri Wide Gamut → ACEScg mapping, the values are compressed more or less on a line straight through the blue primary of the source and destination gamuts. This reduces the purple shift.

From this:

  • To get a “hue” accurate mapping is it necessary to set the max distance ratios according to the source gamut? How would you derive this or parameterize it? Presets for different common digital cinema cameras?

Also another update on the gamut-compress repo. I’ve added a “shadow rolloff” parameter. This is a control to smoothly reduce the effect of the gamut compression below a threshold achromatic value. The idea is to protect dark areas of the image which are primarily grain. This works by doing a Toe adjustment on the achromatic used for the division and multiplication portion of the distance calculation.

I think it works pretty well and actually reduces invertibility issues that were occurring in grain areas, where the distance values were going crazy due to the very small value of the denominator in the distance calculation.

I’ve also done a v0.2 release with all compression functions.

1 Like

Thanks, @jedsmith! All of this work is really great.

We’ll chat about it in the meeting today, but I think we have to, for practicality’s sake, stay as far away as possible from parameterized options based on source or destination, as well as exposing those options to end users. It’s a can of worms, and could end up causing more harm than it solves.

2 Likes

I was going to do It, But then I you said you was already on the workings I will test It !!!

Thanks nick!!

These are all gamut volume escapes I believe. Hence the need for that ODT placeholder to evaluate imagery.

In spatial terms, not along the Cartesian XY coordinate, but the Cartesian Z that is leading to gamut clipping. Can see it in the reflections on the pool table too.

I agree, not only that but parameterisation for specific cameras/gamut is putting us back where we did not want to be: gnosticism and dependence on input.

Cheers,

Thomas

That said, and to play devil advocated a little bit, it is probably entirely viable to have official LMTs with hardcoded parameters for the operator and for a particular input device.

Now jumping back to the other side of the fence: the problem with that approach, is that it is stepping on the IDT toes and this is simply not great from an atomicity and separation of concerns: it should be the responsibility of the IDT to deliver colours in AP0 or AP1.

At this stage of the work (and to me), the gamut healing/medicina/compress operator responsibility is to guarantee that whatever is being fed to the RRT or rendering processes is within the working space and spectral locus, nothing more, nothing less. It is our guardian to continue image processing with sane values.

Cheers,

Thomas

What’s in the original image?
I’m curious about the capture’s original shadows etc.
How does the original look if just wholly desaturated a bit towards some mid gray?

The current proposals intentionally shift the hues.
Would it look better with a non-shifting method?
Best, Lars

I’m going to be a bit verbose here… bear with me. :bear:

Say for example we have an Arri AlexaWideGamut source image that has had the current 3x3 matrix IDT applied to bring the image into an ACEScg working gamut. Say there are out of gamut values in this RGB space.

Say we use the current proposed gamut compression algorithm with equal max distance parameter values (say 0.2). This process will result in each of the RGB components being compressed equally.

If we process this gamut compressed image and view it through the ACES RRT+ODT, and compare it to the stock Arri image display pipeline, there absolutely will be apparent hue shifts. This is because the IDT portion of this processing pipeline has introduced a difference in the RGB ratios, because the chromaticity coordinates of the source gamut (AWG) are not of equal distance from the target gamut (AP1).

Therefore, the max distance parameter needs to be biased according to the source gamut.

I think that this absolutely depends on the circumstance in which this tool is used.

For gamut compression applied before RRT+ODT, I absolutely agree. A good set of default parameter settings that work well for the most common source images, and are not exposed to the user, would be essential here.

For gamut compression applied in a VFX pipeline by a vendor, this is absolutely not the case. The more parameters and customization the better, in this circumstance. The gamut compress operator is likely to be customized for the specific needs of a show, likely shot by a specific set of digital cinema cameras.

For gamut compression in the DI, parameterization should be less technical and more artistically driven. Tweak the apparent hue and saturation after display rendering transform, make it look good. In this circumstance parameters with a good set of defaults would be essential.

Moving forward I think it might be important to consider for what circumstance the work we are doing is targeted. I haven’t heard that discussed much so far in this working group.

With all that said, here is a proposal for a default set of max distance values. I put together a set of max distance values in ACEScg from a variety of digital cinema camera source gamuts. Note that this is based on the assumption that there are no out of gamut values in the camera vendor’s source gamut, which may or may not be the case.

Gamut Max Distance R Max Distance G Max Distance B
Arri AlexaWideGamut 1.075553775 1.218766689 1.052656531
DJI D-Gamut 1.07113266 1.1887573 1.065459132
BMD WideGamutGen4 1.049126506 1.201927185 1.067178249
Panasonic VGamut 1.057701349 1.115383983 1.004894257
REDWideGamutRGB 1.059028029 1.201209426 1.24509275
Canon CinemaGamut 1.087849736 1.210064411 1.166528344
GoPro Protune Native 1.038570166 1.138519049 1.227653146
Sony SGamut 1.054785252 1.149565697 1.003163576
Sony SGamut3.Cine 1.072079659 1.198700786 1.026392341
Max 1.087849736 1.218766689 1.24509275
Average 1.059741957 1.172203864 1.111424208

With a few outliers (RedWideGamutRGB), there are some common trends here. With some padding, a sane set of default max distance values might be something in the realm of 0.09 0.24 0.12. These numbers were arrived at by both looking at the averages and max of the above distances, as well as evaluating different settings on the source imagery that we have available to work with.

All of the test images so far visually subjectively “look pretty good” with these settings.

I know what @Thomas_Mansencal is going to say - “we should not care about how it looks at this stage, the only thing we should do is compress all values into gamut” - but what if it looks bad after the view transform is applied? We can’t really go back and fix it. And again, I think this statement depends on the context in which this tool is applied.

Please correct me if any of my assumptions are wrong and I’m curious to hear thoughts on this! :slight_smile:

Those aren’t necessarily the max distances if you include the noisy shadows. Here are the ACEScg values (no gamut mapper) for a particularly bad pixel in a dark area of Fabián Matas’ ALEXA Mini nightclub shot:

>>> RGB_ACEScg = [-0.00624, -0.00199, 0.00006]
>>> ach = np.max(RGB_ACEScg)
>>> diff = ach - RGB_ACEScg
>>> diff
array([ 0.0063 ,  0.00205,  0.     ])
>>> diff_norm = diff / ach
>>> diff_norm
array([ 105.        ,   34.16666667,    0.        ])

When max(rgb) is very small, normalising the distance by dividing by it produces very large numbers. I assume this is the logic behind the shadow roll off. But this means that negatives remain in the noise floor, requiring them to be dealt with in comp using another approach. Indeed if all three channels are negative, the normalised distance is negative (because you are dividing by ach = max(r, g, b) which is still negative. So no roll off ever gets applied there.

Is this a problem? It certainly needs discussing. It doesn’t produce the coloured artefatcs that are the most obvious issue that we are combatting. But negative values are always potentially problematic, particularly if they are not immediately obvious.

This is similar to what I showed with the fire image during last night’s meeting. But it’s useful to show that it’s still an issue with a high end camera, not just a mid-range one like the FS-7.

1 Like

In my experience, negative values in dark grainy areas is very common. This is something compositors are used to dealing with and generally speaking these negative values are not problematic (unless a compositor is inexperienced and does not know how to deal it properly).

Yes exactly. In my humble opinion, negative values in grainy areas below a certain threshold should not be considered as out of gamut colors or even colors at all. If the max pixel value of an rgb triplet is 0.000006, should this really be considered a color?

Another question I have been wondering about is the question of exposure agnosticism. How important is this feature and why? The shadow rolloff approach to reduce the invertibility problems caused by very high distances in dark areas of grain does introduce a small change to the behavior of the algorithm with adjusted exposure. However this change is not excessive or even significant in the testing I have done. I would be curious to hear other opinions on this …

It is certainly important to look at images but it has to be done as the last step IMHO, here is, doctored, what I was writing on Slack last week:

I’m taking the exercise with three principles:

  • The outliers we are trying to bring inside the working space, are naturally narrow-band and extremely saturated sources, so I want to preserve that quality at all costs, we should not be taking too many creative decisions at this stage, I don’t think it is the purpose of this group.
  • I would like the solution to be defect-free as if we were doing a plane, very smooth curvature, I was playing with qualifiers the other day and curves with a kink can produce defects when grading.
  • Has to be elegant, the RGB method is extremely elegant in that sense.

I think the first point can be assessed without any visual tests, if the compression function we pick, reduces colour purity too much, it is not good.

The second point is a bit harder, you take it from a mathematical standpoint and enforce/strive for C2 continuity (as much as possible) which Tanh/Arctan or a custom spline can do but not the others as you get a kink with them.

The last point is easy, less code less operation and simple design primes :slight_smile:

Now for the context(s), there are many and it is entirely viable to use the Gamut Mapping operator in other pipelines than the strict ACES one, especially because it is agnostic. However in the context of ACES where the transformations are immutable and without user control, we, unfortunately, don’t have too much choice.

This resonates with this post I just made here: Notice of Meeting - ACES Gamut Mapping VWG - Meeting #15 - 6/4/2020 - #6 by Thomas_Mansencal.

Let’s assume that we have determined good thresholds for the various cameras and we ship a dozen of LMTs for them. In one year, the IDT Virtual Working Group propose new IDTs that are bringing values in AP1 or extremely close to it. We would have to ship another set of Gamut Mapping LMTs. The subsequent year, the RRT & ODT Virtual Working Group propose changes that make the parameterisation of the Gamut Mapping operator sub-optimal, we then have a dozen of LMTs to update in a backwards-incompatible way.

This also supports the reasoning that while it is critical to perform to visual assessment, it should certainly not be our single prime metric because what looks great today, might simply not tomorrow.

Cheers,

Thomas

I don’t disagree that in an ideal world, giving options to tweak and customize per shot, per plate even, to our heart’s desire is the way to fix all gamut issues. I agree that with this tool and some tweaking, you can get really great results across the board, especially with different cameras.

However, it isn’t this algorithm’s job to handle / compensate for disparate sources. That’s the IDT’s job. Only in the IDT can we truly solve per-camera/sensor/source gamut issues. In an ACES scene-referred world, where data handed to the algorithm is in an AP0 container, our best bet is to be as agnostic as possible. As stated in the original proposal for this group and in our progress report, we acknowledge that this isn’t a one-size-fits-all problem, and the work we do can only really help 80-90% of generalized use cases.

We are stuck in the inbetween - between known input and known output. Our work plays a crucial role in helping ease the path of the future RRT/ODT group, and could possibly be rendered irrelevant by future improvements in the IDT space. I acknowledge this isn’t as flexible as we would probably like - but here we are. I’ll also echo that the replies on the post @Thomas_Mansencal mentioned:

Are super relevant here.

2 Likes

I agree. In the context of an immutable OCIO implementation for example, you would want the best default values to handle the most common scenarios.

I believe there is good reason to optimize for the most common scenarios however.

As I outlined in my post above, data that has been transformed into an ACES 2065-1 container from different digital cinema cameras will not be the same, especially in the values that are out of gamut. There will be out of gamut values that have different maximum distance and biases.

I think it would be worth taking a look at the current most common digital cinema cameras in use by the industry, and making a set of default max distance limit values that work well on average with data from these cameras.

I believe that doing something like this will give us a better looking results than compressing equally in all directions from half-float inf to gamut boundary.

Quick development update:
I made a few additional changes to the gamut-compress master branch.

  • On the Nuke node and the Nuke Blinkscript Node:

    • Add helper functions for calculating max distance limits given a source gamut or an input image.
    • Set default distance limit values according to average of popular digital cinema cameras
    • As suggested a couple meetings ago, I modified the threshold parameter to be adjustable for each RGB component.
  • On the Resolve DCTL I’ve fixed a bug with the shadow rolloff parameter not working.

  • Thanks to some excellent help from @Jacob I’ve added a Fuse version of the GamutCompress tool for Blackmagic Fusion. It works in Fusion Studio and in Resolve Lite or Resolve Studio’s Fusion page. No watermark, fully functional.

  • For the record, both the Nuke node and the Fuse are fully functional in Nuke Non-Commercial, and Blackmagic Resolve Lite.

1 Like

The problem with picking the average for a given set of cameras is that at best you cover the average cases, those close to the center of the data distribution. All the cases on the far side of the distribution will not work and we are somehow back to square one with user complaining, etc… If anything, we should only consider the outermost outliers here, but we won’t be in a position where we can guarantee that all the data is within AP1 ∩ Spectral Locus.

Maybe the solution for immutable pipelines is to have an infinite (HalfFloat) operator that gives the guarantee that data ⊂ (AP1 ∩ Spectral Locus) and another one optimised for say something like FilmLight E-Gamut that do not give that guarantee but yields better result for the common cases. People could blend between the two output if they wanted… :slight_smile:

Cheers,

Thomas