Planar Iop: Image Processing with 2-Dimensional Outputs

Nuke also has support for Planar Iops, in addition to the usual Row-based Iops. These are primarily intended for Iops that find producing their output in single rows to be unnatural, and would prefer instead to output data containing multiple rows at a time (either the entire image, or smaller stripes.)

Planar Iops have two levels of abstraction: there is a core PlanarI interface, which provides a mechanism that PlanarIop then uses. We encourage users to implement PlanarIop rather than PlanarI, as PlanarIop provides additional caching, without which performance can be impaired.

Render Stripe

The main rendering function for a PlanarIop is renderStripe():

void renderStripe(ImagePlane& imagePlane)

This is the equivalent to engine() for a normal Iop, but is required to fill in a two-dimensional “stripe” of the output image, rather than a single row.

The implementation of renderStripe() should output a rendered stripe to imagePlane. This image plane will already have been set up with the required bounding box and channels; all the implementation needs to do is to fill in the output data for this region and channels.

By default, renderStripe() will be called for the entire bounding box of the Iop, as set by _validate(). This is usually the most efficient way to process for a plugin that takes the entire input image as a single input and produces a single output image. However, since the majority of Nuke nodes do row-based processing, users often don’t expect to have to wait for a whole frame’s worth of data to be calculated before they begin to see any output. A node that requires the whole of its input to be calculated before it can start to produce data might not give the fast feedback they would like, particularly when there is a large and complicated node graph above it. In addition, if a node requires a large amount of memory for its processing or needs to process on a device with a relatively small amount of memory, such as a GPU, it might not be appropriate or even possible to process the whole output area at once.

For the reasons above, a PlanarIop can choose to render in stripes rather than render the whole image in one go. To do this you would implement the function

bool useStripes() const

to return true.

You can also implement the function

int stripeHeight() const

to set the height of the render stripes. The stripes will then be of the height returned by stripeHeight(), apart from the final stripe (furthest from the origin), which will be clipped to the bounding box size if necessary. The stripes will always be the full width of the image, in order to interoperate nicely with the Row-based operators in Nuke.

Note

Unlike engine(), renderStripe() is called on a single thread at a time, so if you require multi-threaded processing inside a PlanarIop then you must implement this yourself. The PlanarIop interface was designed to support custom multi-threading by plug-ins which don’t fit well with Nuke’s standard scanline-based approach, or plug-ins which do their processing on a different device such as a GPU. In the past, such plug-ins had to implement locking inside engine() in order to launch their custom processing on a single engine thread, and further implement the reading back of scanlines from the output buffer they produced, as well as caching that output buffer internally somehow. With the PlanarIop interface, now all you need to do is implement the renderStripe(ImagePlane& imagePlane) function, and Nuke will take care of caching the output plane produced and then reading back scanlines from it as required.

Request

Important: PlanarIops should not implement _request(). Instead, they should implement getRequests(), which is a similar, but non-recursive, function:

virtual void getRequests(const Box& box,
                         const ChannelSet& channels,
                         int count,
                         RequestOutput &reqData) const

This will be used for more complex render management in future. In addition, for PlanarIops only getRequests() is guaranteed to be set up correctly for planar processing; _request() might not be called with the correct information, so we strongly recommend that you don’t try to implement it for a PlanarIop.

Channels and Layers

A PlanarIop will get a separate render stripe call to fill in each set of channels that go to make up an image plane. The default behaviour is to have a plane for each layer, e.g. forward and backward motion vectors will be stored in a single image plane. An image plane will never contain more than four channels.

The following function determines how the channels will be allocated to image planes:

virtual PlaneID PlanarI::getPlaneFromChannel(Channel chan);

Since it is virtual, a plugin can change the default behaviour and decide on its own definition of the image planes by overriding this function.

PlaneID is a presently a typedef to ChannelSet (this might change in subsequent versions). The default implementation of getPlaneFromChannel() implements the following mapping

Chan_Red

Mask_RGBA

Chan_Green

Mask_RGBA

Chan_Blue

Mask_RGBA

Chan_Alpha

Mask_RGBA

Chan_U

Mask_MoVec

Chan_V

Mask_MoVec

Chan_Backward_U

Mask_MoVec

Chan_Backward_V

Mask_MoVec

As an example, if you wanted the forward and backward vectors to be calculated separately, you could override getPlaneFromChannels() to provide the following mapping

Chan_Red

Mask_RGBA

Chan_Green

Mask_RGBA

Chan_Blue

Mask_RGBA

Chan_Alpha

Mask_RGBA

Chan_U

Mask_MoVecForward

Chan_V

Mask_MoVecForward

Chan_Backward_U

Mask_MoVecBackward

Chan_Backward_V

Mask_MoVecBackward

By default, a render stripe call will not necessarily be asked to fill an image plane containing all of the channels that go to make up a plane, but only those channels that have been requested (by one or more Ops below the PlanarIop in the node graph). If your plugin needs to render all the channels in a plane (or would find it more efficient to do so), you can override the following function to return true:

virtual bool renderFullPlanes() const;

When renderFullPlanes() returns true, the plugin will never be given an image plane to fill that contains less than the full set of channels.

Packed Preference

There is a further virtual function you can implement

virtual PackedPreference packedPreference() const

The return value of this indicates whether the Iop prefers to produce packed or unpacked output planes. If it returns ePackedPreferenceNone it has no strong preference and renderStripe() can be called with either depending upon which the calling code finds more convenient; if it returns ePackedPreferencePacked or ePackedPreferenceUnpacked then renderStripe() will only be called to fill a packed or unpacked stripe.

PlanarI

We do not encourage plugins to use this directly; rather, they should inherit from PlanarIop.

Image Planes

The ImagePlane class is at the heart of the Planar code. ImagePlanes can be of various formats, with different bounding box, channels and packness. The bbox can be obtained with the accessor function bounds(); the channels with channels() and the packness with packed().

In all modes there is no padding. For example, in packed mode, with a 2x2 image, with RGBA channels, the floats would be

red 0,0

green 0,0

blue 0,0

alpha 0,0

red 0,1

green 0,1

blue 0,1

alpha 0,1

red 1,0

green 1,0

blue 1,0

alpha 1,0

red 1,1

green 1,1

blue 1,1

alpha 1,1

whereas in unpacked mode the data is represented by

red 0,0

red 0,1

red 1,0

red 1,1

green 0,0

green 0,1

green 1,0

green 1,1

blue 0,0

blue 0,1

blue 1,0

blue 1,1

alpha 0,0

alpha 0,1

alpha 1,0

alpha 1,1

ImagePlane provides several accessor functions for its data.

Low-level access

ImagePlane::readable() points at the start of the allocation. For the examples above, this will point at the red at (0, 0).

rowStride(), colStride() and chanStride() can then be used to multiply y, x, and channel values, to add to these. For example, in the first,

colStride()

4

rowStride()

8 (2 * 4)

chanStride()

1

and in the second

colStride()

1

rowStride()

2

chanStride()

4

data() points at the bottom-left corner of the image, which is not necessarily at the origin (0, 0).

There is no support for images with origins at the top-left.

Higher-level access

You can access individual pixels with

const float& ImagePlane::at(int x, int y, int z)

z is the channel number /within/ the ChannelSet for the ImagePlane, i.e. for a Mask_RGBA image plane then red = 0, green = 1, blue = 2, alpha = 3. If it was just red, green and alpha, then, then this would make red = 0, green = 1, alpha = 2.

The function ImagePlane::chanNo() can be used to look this up but this iterates over the ChannelSet and you should avoid calling it within a tight loop.

There is also a

const float& ImagePlane::at(int x, int y, Channel z)

which does the lookup of the Channel for you.

These functions give references to the actual image data. If you want to abstract some of that away, there are functions that return strided pointers.

const ImageTilePtr ImagePlane::readableAt(int y, int z);

This returns a pointer to (0, y, z). The pointer is strided: so it knows what chanStride() is, so that ++ increments it to the next x value.

Note that this returns a pointer to (0, y, z) even if the x bounds of the origin start rightwards of the origin. This means that the pointer cannot be safely deferenced without incrementing by (at a minimum) the x bounds. This is there to make multiplying in the offset slightly easier, and is consistent with the way that Row::operator[] works.

Writing to ImagePlane

ImagePlanes use shallow copying when possible.

If you are writing to an ImagePlane that might be shared with what ought to be read-only copies then you should call

void ImagePlane::makeUnique()

If you are writing to a new ImagePlane that you made, you should call

void ImagePlane::makeWritable()

One pattern that is valid, for example, is

void renderStripe(ImagePlane& outPlane) {

  input0().fetchPlane(outPlane);

  outPlane.makeUnique()

  Box box = outPlane.bounds();

  foreach(z, outPlane.channels()) {
    for (Box::iterator it = box.begin(); it != box.end(); it++) {
      outPlane.writableAt(it.x, it.y, outPlane.chanNo(z)) *= 2;
    }
  }
}

If there is another copy that shares backend data then it will copy the data and then make the modifications in-place. If there is no extra copy that is needed, because it has decided that the input does not need caching (perhaps it is really cheap to calculate), then it will not need to even do a copy, thus reducing peak memory use.

If you want to build an output from scratch, you can do something like:

outPlane.makeWritable()

foreach(z, outPlane.channels()) {
  for (Box::iterator it = box.begin(); it != box.end(); it++) {
    outPlane.writableAt(it.x, it.y, outPlane.chanNo(z)) = 0.5;
  }
}