Converting linear sRGB to ACEScg with Python

Hi ACES people,

I would like to to convert all material colors of a shaded Houdini asset via Python.
The materials were created in a linear sRGB rendering space environment but the scene they will be used in is rendering in the ACEScg color space.
So after updating the texture color space transforms to e.g. Utility-Linear-sRGB, I also need to transform each color value in the materials.

For reference, I created a constant color (0.9, 0.3, 0.0) in Nuke and applied an OCIOColorTransform with the input space set to Utility-Linear-sRGB and the output space set to ACES-ACES2065-1. The result is (0.511, 0.325, 0.049).
With the output space set to ACES-ACEScg, the result is (0.654, 0.338, 0.051).

Now I tried simply multiplying the same color (0.9, 0.3, 0.0) with the linear sRGB to CIE-XYZ matrix and multiplying the result (0.478, 0.406, 0.053) with a CIE-XYZ to ACES2065-1 matrix. But the resulting (0.502, 0.325, 0.053) doesn’t match what Nuke gave me, although it’s close.
Multiplying with the ACEScg matrix resulted in (0.39, 0.418, 0.053).
See my code here:

Looks like I got some nmbers wrong or there’s an important step missing. The sRGB ODT or any other LUT has no place in this, correct?

Instead of continuing this manual route, would the PyOpenImageIO Python bindings do all this and if so, how can I access them on Windows 10? Is it necessary to go down the compiling rabbit hole or could I just use the DLLs available in the Houdini or Nuke application paths and point the bindings module there?

Thanks for any hints,
Michael

2 Likes

Hi and Welcome @mheberlein!

Without looking to your code (it is not linked) or testing, the issue you are having is (almost certainly) caused by the different whitepoints, when you use the sRGB to XYZ matrix, the adopted white point is D65 while for the XYZ to ACES2065-1, it is that of ACES, i.e. ~= D60.

The simplest solution is to generate the RGB to RGB matrix that you need, easiest way is using our App here: https://www.colour-science.org/apps/, pick CAT02 as the CAT transform and you should be good to go.

Cheers,

Thomas

Thank you so much Thomas, that is exactly what I needed! :bowing_man:
I went with the Bradford CAT though, because it matched my Nuke transform exactly.

Just in case it could be of use for anyone else, here’s my script:

import hou
import logging
import sys

logging.basicConfig(level=logging.INFO, stream=sys.stdout, format='%(asctime)s  %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')


# Generated using the Bradford CAT on https://www.colour-science.org:8010/apps/rgb_colourspace_transformation_matrix
srgb_to_acescg = [[0.613132422390542, 0.339538015799666, 0.047416696048269],
                  [0.070124380833917, 0.916394011313573, 0.013451523958235],
                  [0.020587657528185, 0.109574571610682, 0.869785404035327]]


def mult_vector3_matrix3(v, m):
    """
    Multiplies the first three components of a vector by a 3x3 matrix.
    """

    v_out = [m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
             m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
             m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]]
    
    # Remaining components (alpha)
    if len(v) > 3:
        v_out += v[3::]
    
    return v_out


def convert_srgb_to_aces():
    """
    Finds HtoA materials in the scene and converts all color values from the sRGB color space to ACEScg.
    """
    
    # Group into one single undo step
    with hou.undos.group('HtoA sRGB to ACEScg'):
    
        for material in hou.nodeType(hou.vopNodeTypeCategory(), 'arnold_material').instances():
            logging.info(material.parent().path())
            
            for node in material.parent().children():
                logging.info('  %s' % node)
                
                for parm in node.parmTuples():
                    if parm.parmTemplate().namingScheme() == hou.parmNamingScheme.RGBA:
                    
                        # Skip mathematical parameters
                        if len(parm.name()) <= 4 and parm.name().startswith('id') or parm.name() in ['subsurface_radius', 'opacity']:
                            logging.debug('Skipping parameter %s: %s' % (parm.name(), parm.eval()))
                            continue
                        
                        # Apply color conversion
                        value_srgb = parm.eval()
                        value_acescg = mult_vector3_matrix3(value_srgb, srgb_to_acescg)
                        logging.info('    %s: %s sRGB --> %s ACEScg' % (parm.name(), value_srgb, value_acescg))
                        parm.set(value_acescg)


convert_srgb_to_aces()
2 Likes

Can I ask what parm.eval() returns. is it a vector of the RGB values of the pixels?