OpenColorIO

Open Source Color Management

OpenColorIO 1.1.1 documentation

Internal Architecture Overview

External API

Configs

At the highest level, we have OCIO::Configs. This represents the entirety of the current color “universe”. Configs are serialized as .ocio files, read at runtime, and are often used in a ‘read-only’ context.

Config are loaded at runtime to allow for customized color handling in a show- dependent manner.

Example Configs:

  • ACES (Academy’s standard color workflow)
  • spi-vfx (Used on some Imageworks VFX shows such as spiderman, etc).
  • and others

ColorSpaces

The meat of an OCIO::Config is a list of named ColorSpaces. ColorSpace often correspond to input image states, output image states, or image states used for internal processing.

Example ColorSpaces (from ACES configuration):

  • aces (HDR, scene-linear)
  • adx10 (log-like density encoding space)
  • slogf35 (sony F35 slog camera encoding)
  • rrt_srgb (baked in display transform, suitable for srgb display)
  • rrt_p3dci (baked in display transform, suitable for dcip3 display)

Transforms

ColorSpaces contain an ordered list of transforms, which define the conversion to and from the Config’s “reference” space.

Transforms are the atomic units available to the designer in order to specify a color conversion.

Examples of OCIO::Transforms are:

  • File-based transforms (1d lut, 3d lut, mtx... anything, really.)
  • Math functions (gamma, log, mtx)
  • The ‘meta’ GroupTransform, which contains itself an ordered lists of transforms
  • The ‘meta’ LookTransform, which contains an ordered lists of transforms

For example, the adx10 ColorSpace (in one particular ACES configuration) -Transform FROM adx, to our reference ColorSpace:

  1. Apply FileTransform adx_adx10_to_cdd.spimtx
  2. Apply FileTransform adx_cdd_to_cid.spimtx
  3. Apply FileTransform adx_cid_to_rle.spi1d
  4. Apply LogTransform base 10 (inverse)
  5. Apply FileTransform adx_exp_to_aces.spimtx

If we have an image in the reference ColorSpace (unnamed), we can convert TO adx by applying each in the inverse direction:

  1. Apply FileTransform adx_exp_to_aces.spimtx (inverse)
  2. Apply LogTransform base 10 (forward)
  3. Apply FileTransform adx_cid_to_rle.spi1d (inverse)
  4. Apply FileTransform adx_cdd_to_cid.spimtx (inverse)
  5. Apply FileTransform adx_adx10_to_cdd.spimtx (inverse)

Note that this isn’t possible in all cases (what if a lut or matrix is not invertible?), but conceptually it’s a simple way to think about the design.

Summary

Configs and ColorSpaces are just a bookkeeping device used to get and ordered lists of Transforms corresponding to image color transformation.

Transforms are visible to the person AUTHORING the OCIO config, but are NOT visible to the client applications. Client apps need only concern themselves with Configs and Processors.

OCIO::Processors

A processor corresponds to a ‘baked’ color transformation. You specify two arguments when querying a processor: the ColorSpace you are coming from, and the ColorSpace you are going to.

Once you have the processor, you can apply the color transformation using the “apply” function. For the CPU veseion, first wrap your image in an ImageDesc class, and then call apply to process in place.

Example:

#include <OpenColorIO/OpenColorIO.h>
namespace OCIO = OCIO_NAMESPACE;

try
{
   // Get the global OpenColorIO config
   // This will auto-initialize (using $OCIO) on first use
   OCIO::ConstConfigRcPtr config = OCIO::GetCurrentConfig();

   // Get the processor corresponding to this transform.
   // These strings, in this example, are specific to the above
   // example. ColorSpace names should NEVER be hard-coded into client
   // software, but should be dynamically queried at runtime from the library
   OCIO::ConstProcessorRcPtr processor = config->getProcessor("adx10", "aces");

   // Wrap the image in a light-weight ImageDescription
   OCIO::PackedImageDesc img(imageData, w, h, 4);

   // Apply the color transformation (in place)
   processor->apply(img);
}
catch(OCIO::Exception & exception)
{
   std::cerr << "OpenColorIO Error: " << exception.what() << std::endl;
}

The GPU code path is similar. You get the processor from the config, and then query the shaderText and the lut3d. The client loads these to the GPU themselves, and then makes the appropriate calls to the newly defined function.

See src/apps/ociodisplay for an example.

Internal API

The Op Abstraction

It is a useful abstraction, both for code-reuse and optimization, to not relying on the transforms to do pixel processing themselves.

Consider that the FileTransform represents a wide-range of image processing operations (basically all of em), many of which are really complex. For example, the houdini lut format in a single file may contain a log convert, a 1d lut, and then a 3d lut; all of which need to be applied in a row! If we don’t want the FileTransform to know how to process all possible pixel operations, it’s much simpler to make light-weight processing operations, which the transforms can create to do the dirty work as needed.

All image processing operations (ops) are a class that present the same interface, and it’s rather simple:

virtual void apply(float* rgbaBuffer, long numPixels)

Basically, given a packed float array with the specified number of pixels, process em.

Examples of ops include Lut1DOp, Lut3DOp, MtxOffsetOp, LogOp, etc.

Thus, the job of a transform becomes much simpler and they’re only responsible for converting themselves to a list of ops. A simple FileTransform that only has a single 1D lut internally may just generate a single Lut1DOp, but a FileTransform that references a more complex format (such as the houdini lut case referenced above) may generate a few ops:

void FileFormatHDL::BuildFileOps(OpRcPtrVec & ops,
                         const Config& /*config*/,
                         const ConstContextRcPtr & /*context*/,
                         CachedFileRcPtr untypedCachedFile,
                         const FileTransform& fileTransform,
                         TransformDirection dir) const {

// Code omitted which loads the lut file into the file cache...

CreateLut1DOp(ops, cachedFile->lut1D,
                   fileTransform.getInterpolation(), dir);
CreateLut3DOp(ops, cachedFile->lut3D,
                   fileTransform.getInterpolation(), dir);

See (src/core/*Ops.h) for the available ops.

Note that while compositors often have complex, branching trees of image processing operations, we just have a linear list of ops, lending itself very well to optimization.

Before the ops are run, they are optimized. (Collapsed with appropriate neighbors, etc).

An Example

Let us consider the internal steps when getProcessor() is called to convert from ColorSpace ‘adx10’ to ColorSpace ‘aces’:

  • The first step is to turn this ColorSpace conversion into an ordered list of transforms.

We do this by creating a single of the conversions from ‘adx10’ to reference, and then adding the transforms required to go from reference to ‘aces’. * The Transform list is then converted into a list of ops. It is during this stage luts, are loaded, etc.

CPU CODE PATH

The master list of ops is then optimized, and stored internally in the processor.

FinalizeOpVec(m_cpuOps);

During Processor::apply(...), a subunit of pixels in the image are formatted into a sequential rgba block. (Block size is optimized for computational (SSE) simplicity and performance, and is typically similar in size to an image scanline)

float * rgbaBuffer = 0;
long numPixels = 0;
while(true) {
   scanlineHelper.prepRGBAScanline(&rgbaBuffer, &numPixels);
   ...

Then for each op, op->apply is called in-place.

for(OpRcPtrVec::size_type i=0, size = m_cpuOps.size(); i<size; ++i)
{
   m_cpuOps[i]->apply(rgbaBuffer, numPixels);
}

After all ops have been applied, the results are copied back to the source

scanlineHelper.finishRGBAScanline();

GPU CODE PATH

  1. The master list of ops is partitioned into 3 ordered lists:
  • As many ops as possible from the BEGINNING of the op-list that can be done analytically in shader text. (called gpu-preops)
  • As many ops as possible from the END of the op-list that can be done analytically in shader text. (called gpu-postops)
  • The left-over ops in the middle that cannot support shader text, and thus will be baked into a 3dlut. (called gpu-lattice)

#. Between the first an the second lists (gpu-preops, and gpu-latticeops), we analyze the op-stream metadata and determine the appropriate allocation to use. (to minimize clamping, quantization, etc). This is accounted for here by interserting a forward allocation to the end of the pre-ops, and the inverse allocation to the start of the lattice ops.

See https://github.com/AcademySoftwareFoundation/OpenColorIO/blob/master/src/core/NoOps.cpp#L183

#. The 3 lists of ops are then optimized individually, and stored on the processor. The Lut3d is computed by applying the gpu-lattice ops, on the CPU, to a lut3d image.

The shader text is computed by calculating the shader for the gpu-preops, adding a sampling function of the 3d lut, and then calculating the shader for the gpu post ops.