Custom operations

edit

A custom operation is a filter of your own, applied like a built-in one: to every frame of the image and to its poster frame, in bands of rows processed by several threads when the configuration allows it.

Edit pixels in place

An IPixelRowOperation<TPixel> edits one row at a time. ApplyRowOperation runs it on every row:

public static void Solarize(Image image)
{
    // Rgba64 rows: the rows of any integer pixel format are converted to them without loss, and back after the call
    image.ApplyRowOperation<Rgba64, SolarizeFilter>(new SolarizeFilter(threshold: 32768));
}

/// <summary>Inverts the samples above a threshold. A struct: the processing loop is compiled for it.</summary>
[StructLayout(LayoutKind.Auto)]
public readonly struct SolarizeFilter(ushort threshold) : IPixelRowOperation<Rgba64>
{
    // Called by several threads at once when MaxDegreeOfParallelism > 1: it only reads its fields
    public void Invoke(int y, Span<Rgba64> row)
    {
        foreach (ref var pixel in row)
        {
            pixel = new Rgba64(Solarize(pixel.R), Solarize(pixel.G), Solarize(pixel.B), pixel.A);
        }
    }

    private ushort Solarize(ushort sample) => sample > threshold ? (ushort)(ushort.MaxValue - sample) : sample;
}

Read the neighbors of a pixel

A blur or an edge detector reads the pixels around the one it computes. An IPixelNeighborhoodOperation<TPixel> receives a PixelRowSource<TPixel> that holds the pixels of the frame before the operation, and writes one row of the result. ApplyNeighborhoodOperation runs it on every row:

public static void Emboss(Image image)
{
    // RgbaFloat rows hold the samples of every pixel format; results outside [0, 1] are clipped when they are stored
    image.ApplyNeighborhoodOperation<RgbaFloat, EmbossFilter>(default);
}

public readonly struct EmbossFilter : IPixelNeighborhoodOperation<RgbaFloat>
{
    public void Invoke(int y, PixelRowSource<RgbaFloat> source, Span<RgbaFloat> destination)
    {
        // The source holds the pixels before the operation, even those of the rows already written
        var above = source.GetRowClamped(y - 1);
        var below = source.GetRowClamped(y + 1);
        for (var x = 0; x < destination.Length; x++)
        {
            var left = above[Math.Max(x - 1, 0)];
            var right = below[Math.Min(x + 1, destination.Length - 1)];
            destination[x] = new RgbaFloat(
                0.5f + right.R - left.R,
                0.5f + right.G - left.G,
                0.5f + right.B - left.B,
                source.GetRow(y)[x].A);
        }
    }
}

GetRowClamped repeats the edge rows for the rows outside the frame. The source is a copy of the frame, made one frame at a time, which counts toward MaxLiveAllocationBytes.

Choose the pixel type of the rows

The first type argument is the pixel type of the rows your operation receives:

RowsImagesCost
The pixel type of the imageThat pixel type onlyNone: the rows of the image, in place
Rgba64Every integer pixel formatA conversion of each row, and back
RgbaFloatEvery pixel formatA conversion of each row, and back

The rows are converted without loss; other combinations throw a NotSupportedException before anything changes. When a row is stored back, the samples that the pixel format of the image cannot store are clipped (a floating-point value above 1 in an 8-bit image), a sample that is not a number throws an UnsupportedImageFeatureException, and so does a transparent pixel in a format without alpha.

Performance

  • Write the operation as a struct. The processing loop is then compiled for it, without an interface call per row.
  • Use several threads. The rows of a frame of at least 65,536 pixels are split into bands processed by up to MaxDegreeOfParallelism workers, with the same result as one worker. The workers call the same operation concurrently: it must not change its own state.
  • Work in the pixel type of the image when you can: the conversions add work to every row.

Like the built-in pixel operations, a failure or a cancellation can leave some rows changed, but the image stays valid. For the pixels of one frame, without parallelism, use ProcessPixelRows: see Ownership and lifetimes.