使用 ImageSharp 跨多个图像并行访问像素的正确方法

Right way to parallelize pixel access across multiple images using ImageSharp

我正在尝试使用 ImageSharp 并行处理图像。此处的文档:https://docs.sixlabors.com/articles/imagesharp/pixelbuffers.html 有一个使用以下代码并行处理两个图像的示例:

// Extract a sub-region of sourceImage as a new image
private static Image<Rgba32> Extract(Image<Rgba32> sourceImage, Rectangle sourceArea)
{
    Image<Rgba32> targetImage = new(sourceArea.Width, sourceArea.Height);
    int height = sourceArea.Height;
    sourceImage.ProcessPixelRows(targetImage, (sourceAccessor, targetAccessor) =>
    {
        for (int i = 0; i < height; i++)
        {
            Span<Rgba32> sourceRow = sourceAccessor.GetRowSpan(sourceArea.Y + i);
            Span<Rgba32> targetRow = targetAccessor.GetRowSpan(i);

            sourceRow.Slice(sourceArea.X, sourceArea.Width).CopyTo(targetRow);
        }
    });

    return targetImage;
}

但那种情况与我的情况有一个关键区别,那就是我需要从源图像 访问完全任意的像素。像这样:

Image<Rgb24> sourceImage = GetImage();
Image<Rgb24> outImage = GetImage();

for (var outY = 0; outY < outImage.Height; outY++)
{
    for (int outX = 0; outX < outImage.Width; outX++)
    {
        var outColor = GetArbitraryPixelFromAnywhereInsideSourceImage(sourceImage, outX, outY); // access arbitrary pixels from the source image based on some calculation, probably a block of between 2x2 and 4x4 pixels
         outImage[outX, outY] = outColor;
    }
}

我已经尝试在 outImage 上使用 ProcessPixelRows 方法,但我怀疑在该块内访问 sourceImage 中的像素会阻止并行化。

只需将 for 循环替换为 Parallel.For 即可打乱输出图像。

请注意,每个 outImage 像素只写入一次,sourceImage 永远不会改变,outImage 像素值的计算是基于源样本确定的。

我通常会建议使用我们更高级别的像素缓冲区操作来访问像素。虽然默认情况下不是并行的(Vector4 变体是),但它们非常高效。

但是,如果您想使用并行处理,您应该使用 SixLabors.ImageSharp.Advanced 命名空间中的 ParallelRowIterator。这将根据可用处理器的数量将处理分成多个块,将用户定义的 IRowOperation<T> 实例应用于图像。

这是一个将随机像素从源应用到目标的基本示例。

using Image<Rgba32> source = new(100, 100);
using Image<Rgba32> destination = new(100, 100);

Configuration configuration = Configuration.Default;

// You need access to individual frame pixel buffers in order
// to access some of the advanced APIs
RowOperation operation = new RowOperation(
configuration,
source.Frames[0].PixelBuffer,
destination.Frames[0].PixelBuffer);

// Ensure we don't go out of bounds
var interest = Rectangle.Intersect(source.Bounds(), destination.Bounds());
ParallelRowIterator.IterateRows<RowOperation, Rgba32>(
                configuration,
                interest,
                in operation);

// Save the output.

您的行操作看起来像这样。

private readonly struct RowOperation : IRowOperation<Rgba32>
{
    private readonly Random random;
    private readonly Buffer2D<Rgba32> source;
    private readonly Buffer2D<Rgba32> destination;
    private readonly Configuration configuration;

    public RowOperation(
        Configuration configuration,
        Buffer2D<Rgba32> source,
        Buffer2D<Rgba32> destination)
    {

        this.source = source;
        this.destination = destination;
        this.random = new();
        this.configuration = configuration;
    }

    public void Invoke(int y, Span<Rgba32> span)
    {
        Span<Rgba32> destinationRowSpan = this.destination.DangerousGetRowSpan(y);
        for (int x = 0; x < destinationRowSpan.Length; x++)
        {
            destinationRowSpan[x] = this.GetRandomPixel();
        }
    }

    private Rgba32 GetRandomPixel()
    {
        int y = this.random.Next(this.source.Height);
        int x = this.random.Next(this.source.Width);
        return this.source[x, y];
    }
}

根据我自己的问题,我有机会仔细阅读@James 的回答,并发现了一些我认为可能对分享有用的发现。如果你不关心正在发生的事情而只是想要代码,请跳到最后。

首先,我研究了他关于避免使用 Advanced 命名空间的建议,而是考虑使用更高级像素缓冲区操作 API 的 ProcessPixelRowsAsVector4 变体。我发现我不能使用它,因为在我的例子中我需要行索引(即 int y)来进行我的计算,而 ProcessPixelRowsAsVector4 没有提供它。

我发起了一个讨论 here 关于提供实际上确实有行索引的 ProcessPixelRowsAsVector4 的重载,所以当你阅读这篇文章时,库实际上有一个签名是可能的像这样你应该尝试使用。


同时,目前,我着手实施 James 的另一个建议,即 ParallelRowIterator.IterateRows 解决方案。

我确实让它工作了,但是当我进行最后的润色时,我意识到 Invoke(int y, Span<Rgba32> span) 给了我一个我没有使用的跨度。那跨度是多少?为什么我没有使用它?我可以使用它吗?我应该discard吗?

这对我来说很重要,因为在我的例子中,这些跨度可能在 25,000 到 50,000 像素之间长,并且可以分配大约相同数量的跨度,所以如果没有必要不这样做似乎是个好主意. (有可能它只是 return 指向 already-allocated 内存中特定位置的指针,这使得这不是一个问题,但我想知道。)

我的第一个猜测是 IterateRows 的正常用例涉及在源图像上循环,所以也许那个跨度就像 sourceRowSpan,如果那是真的,也许我可以以某种方式获得迭代器循环遍历目标图像,只需 return destinationRowSpan 而无需我使用 DangerousGetRowSpan 将其额外放入循环中,这听起来像是那种穿着皮夹克并且不尊重你的方法调用妈妈.

但我根本看不出迭代器是如何选择从哪里获取跨度的,就像我的源缓冲区和目标缓冲区只是我的自定义 RowOperation class 中的成员一样,没有与 IRowOperation 接口的特殊关系。

所以我查看了 ParallelRowIterator class and followed the rabbit hole into RowOperationWrapper 的内部,这似乎是实际上调用 Invoke 的东西,我发现了 3 件事:

  1. 传入的 span 只是新分配的任何像素类型的内存范围。因此,事实上,它为此分配了一堆内存,而不仅仅是 return 指向内存的指针。
  2. 跨度并没有真正引用源图像或目标图像,所以如果我愿意,我可以安全地忽略它,最初建议的代码实际上是这样做的,但我的意思是我可以明确地丢弃它_ 如果我愿意的话。
  3. 还有一个完全不分配或传递跨度的签名!它只是传递行索引,这就是我真正需要的!

所以在发现这一点后,我修改了最初建议的代码如下:

using Image<Rgba32> source = new(100, 100);
using Image<Rgba32> destination = new(100, 100);

Configuration configuration = Configuration.Default;

// You need access to individual frame pixel buffers in order
// to access some of the advanced APIs
RowOperation operation = new RowOperation(
configuration,
source.Frames[0].PixelBuffer,
destination.Frames[0].PixelBuffer);

// Ensure we don't go out of bounds
var interest = Rectangle.Intersect(source.Bounds(), destination.Bounds());
ParallelRowIterator.IterateRows<RowOperation>(
                configuration,
                interest,
                in operation);

// Save the output.
private readonly struct RowOperation : IRowOperation
{
    private readonly Random random;
    private readonly Buffer2D<Rgba32> source;
    private readonly Buffer2D<Rgba32> destination;
    private readonly Configuration configuration;

    public RowOperation(
        Configuration configuration,
        Buffer2D<Rgba32> source,
        Buffer2D<Rgba32> destination)
    {

        this.source = source;
        this.destination = destination;
        this.random = new();
        this.configuration = configuration;
    }

    public void Invoke(int y)
    {
        Span<Rgba32> destinationRowSpan = this.destination.DangerousGetRowSpan(y);
        for (int x = 0; x < destinationRowSpan.Length; x++)
        {
            destinationRowSpan[x] = this.GetRandomPixel();
        }
    }

    private Rgba32 GetRandomPixel()
    {
        int y = this.random.Next(this.source.Height);
        int x = this.random.Next(this.source.Width);
        return this.source[x, y];
    }
}

基本上我让 RowOperation 实现 IRowOperation 而不是 IRowOperation<Rgba32>,并使用 .IterateRows<RowOperation>() 而不是 .IterateRows<RowOperation, Rgba32>() 调用它,它只调用行index 而不是行索引加上 Span 对象。