ODT Tonescale

Hello everyone,

I have a small technical question regarding the tone scaling done in ODT transformations (segmented_spline_c9_fwd functions).

I see that it uses two B-Splines to map luminance, lower / higher than neutral gray from RRT, plus linear segments at both extremity. How were the B-Splines coefficients chosen ? Are these arbitrary values ?



The RRT and ODT splines and thus the ACES system tone scale (RRT+ODT) were derived through visual testing on a large test set of images. During development, the splines allowed flexibility in tweaking the tonescale based on feedback from expert viewers. So no, the values are not arbitrary.

A segmented, multi-knot spline greatly simplified modification of the tone scale and allowed for rapid prototyping of tonescale variations. Contrasts throughout any portion of the curve could be very finely tuned based on feedback from the expert viewers.

As you mention, the break-point at the middle of the two splines is defined at the mid-gray map point and allowed for flexibility mapping a specific slope (contrast) through a particular mid-point luminance (in our case, 4.8 nits for the 48-nit system tonescale). The highest and lowest knots represented the maximum and minimum luminances, respectively.

The B-spline was segmented and made into a “joined pair” so that the curves above and below mid-gray were linked, but still independent. Thus, adjustment of the parameters for the lowest or highest knots would not affect the other portion of the curve. For example, if the shadow contrast had been finely tuned, it was not desirable that those coefficients would then need to be re-tuned if it was decided to try moving the (x,y) position of the highest knot. Had the spline not been segmented, changing the domain or range of the knots would have redistributed all of the knots and forced retuning the coefficients below mid-gray.

During development, it was always intended that a functional tonescale would be fit to the final resulting curve. However, it turned out that in order to get a close enough fit, the resulting equation was almost more complicated than just keeping the B-spline math as is. Furthermore, it is anticipated that the provided B-spline architecture will be useful in the future for crafting ODTs to additional/custom dynamic ranges.


Hello Scott,

Thanks a lot for your very detailed answer !
It make perfect sense and I see the benefits in terms of added flexibility and fast prototyping.

One last thing I don’t really understand is why there is two linear segment at both extremity, should’n’t the values be clipped outside the tonescale range ?


@sdyer: Hi

As I was talking about this with Alex (and you) privately a few weeks ago, I think that those experiment informations should be officially documented right next to the code along the surround adjustment factor and all the constants that “might appear magic” if one is not aware of their derivation.




@Thomas_Mansencal Do you know if this has been officially documented? I would love to know more about the tone scale from Aces… Thanks!

I will jump in and answer for Scott, since this has sat for awhile –

the linear segments at the extremes are to avoid asymptotes in the functions which would make
a full inversion of the function impossible. There is also a desire though that the function can cross
the extremes so a real 1.0 and 0 can be achieved.


Thanks @jim_houston! That’s great information! I just love the way ODT makes my renders look! :wink:
Have a nice week-end!

Thanks for the precision Jim !

On a related topic, I noticed that the inverse tonescale transform is clipping these values (low and high linear segments). I had some issues with this when doing round trip InvODT(A) -> OCES -> ODT(B).

Hi, we are working on an implementation of ACES transforms in an application and would like to separate the tonescaling back into two transform steps like it used to be, but use the ACES 1.1 consolidated solution offered by the ssts curve function if possible and recommended. We noticed that this did not work simply by calling ssts() twice with RRT params, then ODT params. Without a full understanding of the curve and coefficient making algorithm it is pretty hard. We took a stab at a new init_TsParams() function called init_TargetTsParams() and run the min,mid, and max through ssts using the RRT params similar to how the SegmentedSplineParams_c9 structures are initialized in the older RRT and ODT separate transform way. The image doesn’t look quite right, is darker and more saturated, so I know I’ve not quite got it right. Any help would be appreciated.

Jim DiNunzio

TsParams init_TargetTsParams(
        float minLum,
        float maxLum,
        float expShift
    TsPoint MIN_PT = { ssts(lookup_ACESmin(minLum), HDR_RRT_PARAMS), minLum, 0.0};
    TsPoint MID_PT = { ssts(0.18, HDR_RRT_PARAMS), 4.8, 1.55};
    TsPoint MAX_PT = { ssts(lookup_ACESmax(maxLum), HDR_RRT_PARAMS), maxLum, 0.0};
    float cLow[5];
    init_coefsLow( MIN_PT, MID_PT, cLow);
    float cHigh[5];
    init_coefsHigh( MID_PT, MAX_PT, cHigh);
    MIN_PT.x = shift(ssts(lookup_ACESmin(minLum), HDR_RRT_PARAMS),expShift);
    MID_PT.x = shift(ssts(0.18, HDR_RRT_PARAMS), expShift);
    MAX_PT.x = shift(ssts(lookup_ACESmax(maxLum), HDR_RRT_PARAMS),expShift);

    TsParams P = {
        {MIN_PT.x, MIN_PT.y, MIN_PT.slope},
        {MID_PT.x, MID_PT.y, MID_PT.slope},
        {MAX_PT.x, MAX_PT.y, MAX_PT.slope},
        {cLow[0], cLow[1], cLow[2], cLow[3], cLow[4], cLow[4]},
        {cHigh[0], cHigh[1], cHigh[2], cHigh[3], cHigh[4], cHigh[4]}

    return P;

// Code fragment to create new params for a target of 1000 nits
    float mYMin = 0.0001;
    float  mYMid = 10.0;
    float mYMax = 1000.0;

    float expShift = log2f(inv_ssts(mYMid, HDR_RRT_PARAMS)) - log2f(0.18);
    TsParams paramsFor1000NitTarget = init_TargetTsParams(mYMin, mYMax, expShift);

Then in the kernel do this

   // --- Initialize a 3-element vector with input variables (OCES) --- //
    V3f oces = {rIn, gIn, bIn};

// OCES to RGB rendering space
V3f rgbPre = mult_f3_f44( oces, AP0_2_AP1_MAT);

// Apply the tonescale independently in rendering-space RGB
V3f rgbPost = ssts_f3( rgbPre, params);

rOut = rgbPost[0];
gOut = rgbPost[1];
bOut = rgbPost[2];

Those two statements are contradictory. By definition, the SSTS (“single-stage” tone scale) function is designed to take ACES luminance in and output display luminance. To separate it back to two transform steps requires that you have a curve designed for ACES in > OCES out and another curve shaped for OCES in > display luminance out.

If you just take the SSTS curve and try to use it for the OCES to display luminance mapping, the parameters are going to be way out of any range that means anything.

The consolidation was to help make implementations more direct and intuitive to use. Why do you feel the need to separate the two functions? Just use the separate RRT/ODT code?

Hi Scott,
We are still learning how to go about this so please pardon our understanding gap. Here is the story: We have our own “look” algorithms which would ordinarily fit into the creative intent look transforms part of the pipeline. But they currently work in the luminance domain, not ACES, and so the input to them needs to be in OCES.(0-10000 nits.) We thought a two step process would allow us to get the data into OCES, apply our transforms, and then apply the second step to bring the data down to the target luminance. After your reply we are considering another approach: use SSTS to do ACES->OCES by setting max lum to 10000 and then running the data through our algorithm which will internally do it’s own tonescale to take the data down to the target luminance. Does this sound reasonable? Do you have any other advice for us?

Jim DiNunzio

If I understand correctly what you’re aiming to do, then yes, this is exactly what I’d recommend. If you set the SSTS tonescale (RRTODT, i.e. v1.1 HDR) to 10000 nits MAX and 4.8 nits MID, the curve is equivalent to the non SSTS (RRT+ODT) curve (i.e. v1.0.3).

So in theory, an RRTODT to an “OCES device” (if one existed), would have OCES encoding: primaries (AP0), no OETF (linear), with max and min luminance of 10000 nits and 0.0001nits, respectively.

const float Y_MIN = 0.0001;                     // black luminance (cd/m^2)
const float Y_MID = 4.8;                        // mid-point luminance (cd/m^2)
const float Y_MAX = 10000.0;                    // peak white luminance (cd/m^2)

const Chromaticities DISPLAY_PRI = AP0;    // encoding primaries (device setup)
const Chromaticities LIMITING_PRI = AP0;   // limiting primaries

const int EOTF = 4;                             // 0: ST-2084 (PQ)
                                                // 1: BT.1886 (Rec.709/2020 settings) 
                                                // 2: sRGB (mon_curve w/ presets)
                                                // 3: gamma 2.6
                                                // 4: linear (no EOTF)
                                                // 5: HLG

const int SURROUND = 0;                         // 0: dark ( NOTE: this is the only active setting! )
                                                // 1: dim ( *inactive* - selecting this will have no effect )
                                                // 2: normal ( *inactive* - selecting this will have no effect )

const bool STRETCH_BLACK = false;                // stretch black luminance to a PQ code value of 0
const bool D60_SIM = false;                       
const bool LEGAL_RANGE = false;

Does that make sense?

Hi Scott,
Yes that makes good sense. Thanks for the help.

JIm DiNunzio