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:
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):
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:
For example, the adx10 ColorSpace (in one particular ACES configuration) -Transform FROM adx, to our reference ColorSpace:
If we have an image in the reference ColorSpace (unnamed), we can convert TO adx by applying each in the inverse direction:
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’:
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¶
#. 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. |