colour icon indicating copy to clipboard operation
colour copied to clipboard

Implement support for alternative set of parametric transfer functions using the new CLF definitions.

Open KelSolaar opened this issue 3 years ago • 5 comments

Hi,

We discussed a bit about using the new CLF definitions to build some alternative variants for various camera log transfer functions. The main interest of such functionality is to be able to generate CLF LUTs by appending directly the transfer function of a colourspace with its metadata attached (or the necessary metadata) which in turn would inform one of the colour.models.logarithmic_function_camera definition for example.

As there are a few ways to go about that, thus I'm creating a new issue to discuss this.

This relates to #636.

KelSolaar avatar Oct 28 '20 08:10 KelSolaar

I have five somewhat overlapping desires:

  1. Easily represent / convert RGBColourspaces to CLF transforms The CLF Log and Exponent ProcessNodes (and in turn colour's newly-added exponent_function... and logarithmic_function... methods) are capable of representing most, but not all, of the various decoding and encoding CCTFs supported by colour. For those that cannot be represented, it would be nice to have a mechanism for sampling (and possibly caching) a transform to a colour.LUT1D or a colour.LUT3x1D (which, in turn, can be serialized to CLF). Ideally, we'd have a standard idiom for converting RGB_Colourspaces to other representations that "does the right thing" without much user intervention.

  2. Serve as a reference for pre-computed Log CLF / LogCameraTransform OCIO parameters for known CCTFs (and relevant permutations). @nick-shaw put it perfectly in slack: "Colour is an amazingly useful reference for colour science formulae and other information gathered in one place before you even start using the code!" I couldn't agree more. Among other things, it's definitely an extremely useful one-stop shop for device primaries, whitepoint, and vonkries methods data; and a map of precomputed coefficients for parametric representations of different device CCTFs seems like a natural next step. For the representable CCTFs, I propose we provide computed coefficients for the general form x < cut ? e * x + f : a + log(c * x + d) / log(base) + b. Compared to the CLF and OCIO parameterization, we'd provide as a convenience an additional value for f(the offset of the linear component). (A worthwhile deliverable, perhaps for colour-nuke, is a Nuke expression node with presets for different cameras).

  3. Optionally instantiate RGBColourspaces with colour.models.logarithmic_function_camera or colour.models.exponent_function_monitor_curve kwargs (i.e., in place of specifying cctf_encoding and cctf_decoding). Being able to structure / unstructure certain RGBColourspace instances to and from JSON would be sweet. There's a lot of value in being able to unambiguously represent an entire "color space" in a single line of text.

  4. Improve and expand colour's general LUT / transform toolset and user-experience Kind of a nebulous, larger end goal. colour's AbstractLUTOperator framework makes it extremely easy to add almost arbitrary support for all sorts of custom operations (which may or may not align perfectly with CLF 3.0). Ostensibly, colour could support its own flavor of CLF extensions without much difficulty. This is a larger conversation, and my fingers are getting tired.

  5. Improve colour's support for CLF / CTF / OCIO / ACES / etc. Another less-specific goal. To what degree colour could / should provide "support" for OCIO, ACES, etc. is definitely outside the scope of this issue, but it's worth considering how users will want to use parametric transfer function data outside colour itself, and to what degree colour can serve as a conduit between representations and uses.

zachlewis avatar Nov 21 '20 19:11 zachlewis

Hello,

1, 2, 3

So at the moment, we have that kind of functionality in the colour.RGB_Colourspace class:

colour.RGB_Colourspace.matrix_RGB_to_XYZ

if not self._use_derived_matrix_RGB_to_XYZ:
    return self._matrix_RGB_to_XYZ
else:
    return self._derived_matrix_RGB_to_XYZ

colour.RGB_Colourspace.matrix_XYZ_to_RGB

if not self._use_derived_matrix_XYZ_to_RGB:
    return self._matrix_XYZ_to_RGB
else:
    return self._derived_matrix_XYZ_to_RGB

And the behaviour can be changed with the following methods:

  • colour.RGB_Colourspace.use_derived_matrix_RGB_to_XYZ
  • colour.RGB_Colourspace.use_derived_matrix_XYZ_to_RGB
  • colour.RGB_Colourspace.use_derived_transformation_matrices

Note: I think we should reverse the conditions, starting with if not is not great!

Maybe the colour.RGB_Colourspace.cctf_encoding and colour.RGB_Colourspace.cctf_decoding properties could follow the same logic, e.g.

colour.RGB_Colourspace.cctf_encoding

if self._use_parametric_cctf_encoding:
    if self._parametric_cctf_encoding is None:
        raise RuntimeError('Parametric decoding CCTF for "{0}" colourspace is not defined!'.format(self._name))
    
    return self._parametric_cctf_encoding
else:
    return self._cctf_encoding

Then, in the __init__ method, we could maybe have something along those lines:

if parametric_cctf_encoding_kwargs is not None:
    self._parametric_cctf_encoding_kwargs = parametric_cctf_encoding_kwargs
    parametric_cctf_encoding_callable = parametric_cctf_encoding_kwargs.pop('callable')
    self._parametric_cctf_encoding = partial(parametric_cctf_encoding_callable, **parametric_cctf_encoding_kwargs)

with parametric_cctf_encoding_kwargs={'callable': exponent_function_monitor_curve, 'exponent': 2.4, 'offset': 0.055, 'style': 'monCurveFwd'}

And three new methods:

  • colour.RGB_Colourspace.use_parametric_cctf_encoding
  • colour.RGB_Colourspace.use_parametric_cctf_decoding
  • colour.RGB_Colourspace.use_parametric_cctfs

As far as the conversion to 1D/3x1D and JSON serialisation goes, sure!

Compared to the CLF and OCIO parameterization, we'd provide as a convenience an additional value for f(the offset of the linear component)

That would break compatibility with CLF / OCIO though no?

4

Yes, a lot of possibilities there!

5

Once the data is in, it should be not so bad to make it available elegantly, this, however, raises the point about what is the canonical source for the parameterics values?

KelSolaar avatar Dec 07 '20 09:12 KelSolaar

Hello,

1, 2, 3

So at the moment, we have that kind of functionality in the colour.RGB_Colourspace class: ... Maybe the colour.RGB_Colourspace.cctf_encoding and colour.RGB_Colourspace.cctf_decoding properties could follow the same logic

I was thinking the exact same thing.

Then, in the __init__ method, we could maybe have something along those lines:

if parametric_cctf_encoding_kwargs is not None:
    self._parametric_cctf_encoding_kwargs = parametric_cctf_encoding_kwargs
    parametric_cctf_encoding_callable = parametric_cctf_encoding_kwargs.pop('callable')
    self._parametric_cctf_encoding = partial(parametric_cctf_encoding_callable, **parametric_cctf_encoding_kwargs)

with parametric_cctf_encoding_kwargs={'callable': exponent_function_monitor_curve, 'exponent': 2.4, 'offset': 0.055, 'style': 'monCurveFwd'}

What do you think about inferring the callable from the style? Providing the callable seems redundant, no? Otherwise, we may as well use a partial directly. (I also have other thoughts on styles in general -- basically, I'd prefer not to expose CLF styles at all, and rely on idioms like "encoding" / "decoding", or "forward / "reverse". For exponents, maybe we can provide a "negative_style"

My Log class implements an external "log_function_factory" method that validates parameters and poops out partials based on the style (which is determined by an external method, detailed later) -- I think a similar approach might work pretty well for exponent functions -- i.e., if value for offset is provided, we must need an exponent_function_monitor_curve. And we can have a negative_style parameter whose value can be either "mirror" or "passthru" (or None, the default).

def logarithmic_function_factory(lin_side_slope=None,
                                 lin_side_offset=None,
                                 log_side_slope=None,
                                 log_side_offset=None,
                                 lin_side_break=None,
                                 linear_slope=None,
                                 base=10,
                                 decoding=True):
    """Returns an initilized *logarithmic_function_basic*, 
    *logarithmic_function_quasilog*,  or *logarithmic_function_camera* 
    function for the given parameters and style.
    """
    function_parameters = validate_log_parameters(
        lin_side_slope=lin_side_slope,
        lin_side_offset=lin_side_offset,
        log_side_slope=log_side_slope,
        log_side_offset=log_side_offset,
        lin_side_break=lin_side_break,
        linear_slope=linear_slope,
        base=base,
        decoding=decoding)
    
    style = function_parameters['style']
    
    # camera style
    if style.startswith('camera'):
        function = logarithmic_function_camera
    # quasilog style
    elif "to" in style.lower():  
        function = logarithmic_function_quasilog
    # basic style
    else:
        function = logarithmic_function_basic
    return partial(function, **filter_kwargs(function, **function_parameters))

Related, in my notebook, I've been putting together a Log node factory, modeled after colour.models.rgb.transfer_functions.log_decoding. In retrospect, I think maybe it might be more useful if it directly generated parameter dictionaries that could be used for initializing Log instances, instead of the instances themselves. However, I'm also adding comment metadata and changing the names of certain CCTFs to reflect non-default variants -- for example:

>>> logarithmic_clf_factory('ALEXA Log C', EI=400, firmware='SUP 2.X')
Log(base=10, logSideSlope=0.2565982404692082, logSideOffset=0.39100684261974583, linSideSlope=5.061087192662738, linSideOffset=0.08900430532070719, linearSlope=5.656074507309531, linSideBreak=0.0, style="cameraLogToLin", name="ALEXA Log C v2 EI0400")
>>> print(logarithmic_clf_factory('ALEXA Log C', EI=400, firmware='SUP 2.X'))
Log - ALEXA Log C v2 EI400 - Log to Linear
------------------------------------------

style          : cameraLogToLin
base           : 10.0
logSideSlope   : 0.256598
logSideOffset  : 0.391007
linSideSlope   : 5.061087
linSideOffset  : 0.089004
linearSlope    : 5.65619
linSideBreak   : 0.0

CCTF:           ALEXA Log C
Exposure Index: 400
Firmware:       SUP 2.x
Method:         Linear Scene Exposure Factor

Compared to the CLF and OCIO parameterization, we'd provide as a convenience an additional value for f(the offset of the linear component)

That would break compatibility with CLF / OCIO though no?

It does. I'm still on the fence about how much convenience is too much convenience. I figured we could use colour.utilities.filter_kwargs to drop extra keys (as I was also thinking about adding log_side_break). Maybe it would make more sense to have a separate method just for computing linear_offset, log_side_break, and possibly linear_slope, or make computation an option...

I've been trying to refactor some of the complexity in my Log node, and I keep ending up writing the same jumbo function for validating and sanitizing log params:

def conform_nonuniform_values(max_channels=3, **kwargs):
    if any([as_float_array(v).size > 1 for v in kwargs.values()]):
        kwargs = {k: v * np.ones(max_channels) for k, v in kwargs.items()}
    return kwargs


def validate_log_parameters(base=10,
                            lin_side_slope=None, 
                            lin_side_offset=None,
                            log_side_slope=None, 
                            log_side_offset=None,
                            lin_side_break=None, 
                            linear_slope=None,
                            decoding=False):
    
    base = int(base) if not float(base) == int(base) else float(base)

    # If any of the following are undefined, use 'basic' style
    kwargs = dict(log_side_slope=log_side_slope,
                  log_side_offset=log_side_offset,
                  lin_side_slope=lin_side_slope,
                  lin_side_offset=lin_side_offset)

    if any([x is None for x in kwargs.values()]):
        base = 'B' if not base in [2, 10] else base
        style = f'antiLog{base}' if decoding else f'log{base}'
        return {'base': base, 'style': style}
    
    # If `lin_side_break` is definied, use 'camera' style
    if lin_side_break is not None:
        style = 'cameraLogToLin' if decoding else 'cameraLinToLog'
        kwargs['lin_side_break'] = lin_side_break

        if linear_slope is not None:
            kwargs['linear_slope'] = linear_slope
        
        # make sure all parameter values are the same shape
        kwargs = conform_nonuniform_values(**kwargs)
        
        # compute "linear_offset", "lin_side_break", and "linear_slope"
        a, b, c, d, cut = [
            kwargs[v] for v in [
                'lin_side_slope', 'lin_side_offset',
                'log_side_slope', 'log_side_offset',
                'lin_side_break']]
        
        if linear_slope is None:
            kwargs['linear_slope'] = (
                c * (a / ((a * cut + b) * np.log(base)))
            )
        
        kwargs['log_side_break'] = (
            c * (np.log(a  * cut + b) / np.log(base)) + d
        )

        kwargs['linear_offset'] = (
            kwargs['log_side_break'] - kwargs['linear_slope'] * cut
        )

    # Otherwise, use 'quasilog' style 
    else:
        style = 'logToLin' if decoding else 'linToLog'
        
        # make sure all parameter values are the same shape
        kwargs = conform_nonuniform_values(**kwargs)

    kwargs.update({'base': base, 'style': style}) 

She's a bad mama jama for sure, and arguably does too much:

  1. Determines the value for style, based on which arguments are provided, and whether the style is decoding
  2. If lin_side_break is provided, also computes linear_slope (if not provided), lin_side_break, and linear_offset (taken from @njwardhan's implementation in colour.models.rgb.transfer_functions.log.logarithmic_function_camera).
  3. Makes sure all parameters except base and style are either all single floats, or all RGB float tuples -- i.e., to make sure they're all the same size, and help avoid casting LUT1D tables to LUT3x1D-sized tables if the function is applied uniformly for all channels. In other words, Log.apply(...) behaves as uniformly-applied transform unless a user articulates distinct R,G,B parameters for any argument (e.g., for PLog gamma values), in which case LUT1D table inputs are automatically resized to accommodate the shape of the parameter values)

(I also snipped out an option for converting keys from lowerCamelCase to lower_snake_case)

4

Yes, a lot of possibilities there!

See above, re: style stuff. One thing I'm eager to discuss is implementing inverse transforms, and discussing how to conceptualize and reconcile relative "transform direction" ("forwards" versus "reverse") and relative "node orientation" ("input-facing", "output-facing") styles with absolute encoding / decoding functions.

5

Once the data is in, it should be not so bad to make it available elegantly, this, however, raises the point about what is the canonical source for the parameterics values?

Maybe we could output a dictionary at build time, and if available, colour could load the precomputed values from json. It seems kind of convoluted, but I think it would be a useful artifact that gets you, in theory, the exact same parameter data as used in colour. We could then do all sorts of crazy things at that point -- maybe even procedurally generating frozen Log instances at import time or something.

zachlewis avatar Dec 07 '20 17:12 zachlewis

1, 2, 3

Providing the callable seems redundant, no? Otherwise, we may as well use a partial directly.

Yes, good point, I'm wondering if we should not do that, it would make the implementation cleaner with less magic. If magic is required later we could still introspect the parameters from the partial:

>>> import inspect
>>> from functools import partial
>>> def foo(bar): pass
...
>>> f = partial(foo, bar=2)
>>> inspect.signature(f)
<Signature (*, bar=2)>

Should not logarithmic_function_factory take only **kwargs as arguments? There is a bit of branching in the validate_log_parameters mama jama but it is not too bad. Should be careful that the parameters have similar ordering and defaults in any case.

4

Sorry, I did not understand this one properly but then it is late and I missed my post-lunch coffee :)

5

Maybe we could output a dictionary at build time

OCIO build time you mean for example? It would certainly be great to be able to obtain the parameterisation easily from wherever it is defined without having to manually extract it.

KelSolaar avatar Dec 09 '20 07:12 KelSolaar

1, 2, 3

Providing the callable seems redundant, no? Otherwise, we may as well use a partial directly.

Yes, good point, I'm wondering if we should not do that, it would make the implementation cleaner with less magic. If magic is required later we could still introspect the parameters from the partial.

You're right, that makes much more sense. Seems much easier to map from RGBColorSpace encoding and decoding transforms to other representations this way, too. Right on.

Should not logarithmic_function_factory take only **kwargs as arguments?

Now that you mention it... :) Looks like that call to validate_log_parameters could use a good filter-kwargsing too. kwargs-filtering. filters kwarg.

There is a bit of branching in the validate_log_parameters mama jama but it is not too bad. Should be careful that the parameters have similar ordering and defaults in any case.

Word. I don't actually know what branching means, but I'll look it up and unbranchify. Intuitively, I think I have an idea of what you mean. It definitely feels awkward to me... I think you're on to something with this branching business. Good call re: parameter ordering and defaults -- I'll make sure I'm consistent with logarithmic_function_camera

4

Sorry, I did not understand this one properly but then it is late and I missed my post-lunch coffee :)

Oh god I don't know what I put this here. This is a conversation for an entirely separate thread.

In as few words as possible, I'm talking about the relationship between style "forwards" and "reverse" variants (i.e., monCurveFwd) vs cctf encoding and decoding variants vs a node's "orientation" in a DAG (output-facing vs input-facing).

As I said... a whooooole different thing.

5

Maybe we could output a dictionary at build time OCIO build time you mean for example? It would certainly be great to be able to obtain the parameterisation easily from wherever it is defined without having to manually extract it.

I didn't mean OCIO, but that's an interesting idea. Reminds me of a bug report I need to file.

Kind of a wacky idea. By "build", I think I meant generated as an artifact in a (color) release hook, or during testing. If we were to solve approximate camera log coefficients for cctf samples, this could be a place to do it.

Maybe we could generate an XML catalog of CLF transforms for different parameterizations? That could be very powerful and handy. A job for colour_datasets, maybe?

zachlewis avatar Dec 10 '20 03:12 zachlewis