🖼️

[.NET] CommunityToolkit.HighPerformance で画像処理を書く

2023/06/10に公開

CommunityToolkit.HighPerformance について

Microsoftが提供している、各種ヘルパ処理群という感じのパッケージです。NuGetパッケージから導入できます (参考)。

https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/introduction

今回はこの中で、High Performance Package (CommunityToolkit.HighPerformance) を使う例を書いておこうと思います。
https://learn.microsoft.com/ja-jp/dotnet/communitytoolkit/high-performance/introduction

CommunityToolkit.HighPerformanceには大別して4種のAPIがあり、今回は太字になっている2種を使います。

特に Span2D<T> Memory2D<T> はまさに画像処理 (2次元配列・行列) のために用意されていると思える代物です。そこで簡単な画像処理を書いてみようと思います。

コード

https://github.com/shimat/CommunityToolkitSamples

先にまとめ

  • ネイティブ寄りの処理が書きやすくなったのは間違いない
  • ハイパフォーマンスかどうかは、私の能力不足によりなんとも

導入

https://www.nuget.org/packages/CommunityToolkit.HighPerformance

dotnet add package CommunityToolkit.HighPerformance

執筆時現在で8.2.0です。Windows 10上の .NET 7 にて以降実験しました。

BenchmarkDotNetの出力

// * Summary *

BenchmarkDotNet=v0.13.5, OS=Windows 10 (10.0.19045.2965/22H2/2022Update)
12th Gen Intel Core i7-12700K, 1 CPU, 20 logical and 12 physical cores
.NET SDK=7.0.203
  [Host]     : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2
  DefaultJob : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2

例1. グレースケール化 (ParallelHelper)

前提

カラー画像(BGRの8-bit3チャネル画像)を、8-bitグレースケール画像に変換する例です。ParallelHelperを使ってみます。

変換手法はいろいろありますが、一般的な方法の1つであるBT.601の式を使います。グレースケールの輝度値Yを以下のように定めます。

Y = 0.299R + 0.587G + 0.114B

処理対象の画像は、標準画像データベースSIDBA(Standard Image Data-BAse)からのマンドリルさんです(参考)。

画像のデコード・エンコード

画像のバイト列がある状態をスタートラインにするので、事前にデコードしておきます。ImageSharp 3.0.1を使用しました。
https://www.nuget.org/packages/SixLabors.ImageSharp/

PNG等の画像フォーマットからピクセルデータを得るには、以下記事が参考になります。
https://docs.sixlabors.com/articles/imagesharp/memorymanagement.html

実装例1. 素朴に

シンプルに、for文で1要素ずつ計算していきます。

using SixLabors.ImageSharp;
using CommunityToolkit.HighPerformance.Helpers;

var customConfig = Configuration.Default.Clone();
customConfig.PreferContiguousImageBuffers = true;

using var srcImage = Image.Load<Bgr24>("mandrill.png");
using var dstImage = new Image<L8>(customConfig, srcImage.Width, srcImage.Height);

// !注意! 本来はbool戻り値で成功かどうか確認したほうが良い
// 大きめの画像だとImageSharpは不連続なメモリ領域で保持するので失敗する
srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
dstImage.DangerousTryGetSinglePixelMemory(out var dstMemory);

var srcSpan = srcMemory.Span;
var dstSpan = dstMemory.Span;
Debug.Assert(srcSpan.Length == dstSpan.Length);

for (int i = 0, length = srcSpan.Length; i < length; i++)
{
    var bgr = srcSpan[i];
    dstSpan[i] = new L8((byte)(bgr.R * 0.299 + bgr.G * 0.587 + bgr.B * 0.114));
}

dstImage.SaveAsPng("dst_simple.png");

実装例2. ParallelHelper使用

では本題のParallelHelperです。for文の1ループごとに呼ばれるメソッドを定義するのですが、それをstructで定義するのが最大の特徴です。今回は以下のようにしました。

public readonly unsafe struct GrayscaleConverter : IAction
{
    private readonly Bgr24 *srcPointer;
    private readonly L8 *dstPointer;

    public GrayscaleConverter(Bgr24 *srcPointer, L8 *dstPointer)
    {
        this.srcPointer = srcPointer;
        this.dstPointer = dstPointer;
    }

    public void Invoke(int i)
    {
        Bgr24 bgr = srcPointer[i];
        byte y = (byte)(bgr.R * 0.299 + bgr.G * 0.587 + bgr.B * 0.114);

        byte* mutDstPointer = (byte*)dstPointer;
        mutDstPointer[i] = y;
    }
}

Parallel.For等に渡すAction<T>デリゲートを普通にラムダ式等で指定してしまうと、それの呼び出しコストで並列処理のうまみが消えてしまうのが従来悩みでした。その点、structで指定させるのがミソのようでして、詳しくは解説ページをご覧ください(日本語訳がわかりにくいですが)。

これは、ジェネリック型が C# で実装される方法を大きく活用し、 のようなAction<T>デリゲートではなく、特定のインターフェイスを実装する型を使用structします。 これは、JIT コンパイラが使用されている個々のコールバックの種類を "確認" できるようにするために行われます。これにより、可能な場合はコールバックを完全にインライン化できます。 これにより、各並列イテレーションのオーバーヘッドが大幅に削減されます。特に、非常に小さなコールバックを使用する場合は、デリゲート呼び出しに関して単純なコストがかかります。 さらに、型を struct コールバックとして使用する場合、開発者はクロージャでキャプチャされている変数を手動で処理する必要があります。これにより、インスタンス メソッドからのポインターの誤ったキャプチャや、各コールバック呼び出しの速度が大幅に低下する可能性があるその他の this 値が回避されます。 これは、 などの ImageSharp他のパフォーマンス指向ライブラリで使用されるのと同じ方法です。

ということで、以下2点が肝というわけです。

  • IAction等を実装したreadonly structにコールバックを持たせることで、インライン化を効かせる
  • キャプチャする変数をstructに適宜持たせる

ここで悩ましい点があります。今回の処理的には入力と出力のポインタをstructに含めておくわけですが、readonly structだと、出力先ポインタもreadonlyフィールドにせざるを得ないのです。readonlyではないstructをParallelHelperに与えても正しく動作はするのですが、おそらく最適な状態ではなくなります。上のコードでは、書き込むときにreadonlyを外す悪いことをしています。

また、structのフィールドにSpan<T>を持たせられたら・・・と思いましたが、Spanはref structであり、ParallelHelperが受け付けないためダメです。Memory<T>なら持たせられますが、都度Spanを得るコストも気になる[1]ところです。

ParallelHelperは対マネージド配列に一番うまくはまるAPIであろうと理解しています。今回でいえば、ネイティブ配列を相手に頑張るよりは、マネージド配列を確保してin-placeに処理し、最後にネイティブへmemcpyするのが良いかもしれません。

さて話を戻し、上記structを使う部分です。

var customConfig = Configuration.Default.Clone();
customConfig.PreferContiguousImageBuffers = true;

using var srcImage = Image.Load<Bgr24>("mandrill.png");
using var dstImage = new Image<L8>(customConfig, srcImage.Width, srcImage.Height);
srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
dstImage.DangerousTryGetSinglePixelMemory(out var dstMemory);

using var srcHandle = srcMemory.Pin();
using var dstHandle = dstMemory.Pin();
unsafe
{
    var action = new GrayscaleConverter((Bgr24*)srcHandle.Pointer, (L8*)dstHandle.Pointer);
    ParallelHelper.For(0, srcMemory.Length, action);
}

実装例3. ImageSharp (参考)

ベンチマークでの比較用に書いておきました。

using var srcImage = Image.Load<Bgr24>("mandrill.png");
using var dstImage = srcImage.Clone();
dstImage.Mutate(x =>
{
    x.Grayscale(GrayscaleMode.Bt601);
});

ちなみに以下のようにグレースケール指定でLoadを書くと、かなり違う結果になります。できるだけ揃えるため上のようにしておきました[2]

using var srcImage = Image.Load<L8>("mandrill.png");

ベンチマーク結果

BenchmarkDotNetで測定してみました。入力が小さいと並列化のオーバーヘッドが勝りそうなので、マンドリル画像は10倍に引き延ばしたものを与えました。

ここでくっきり違いが出ればよかったのですが、全然差が出ず微妙な空気に。処理が簡単すぎたかもしれません。ImageSharpには勝ちました。

繰り返しますがあくまで興味本位の数値であり、ImageSharpより速かったこと含めあまり意味を持ちません。ほんの参考程度にどうぞ。

Method Mean Error StdDev Gen0 Gen1 Gen2 Allocated
SingleThread 815.5 ms 1.36 ms 1.27 ms - - - 5.97 MB
ParallelHelper 827.0 ms 0.98 ms 0.77 ms 1000.0000 1000.0000 1000.0000 5.97 MB
ImageSharp 945.5 ms 1.68 ms 1.49 ms 1000.0000 1000.0000 1000.0000 11.05 MB

例2. メディアンフィルタ (Span2D, Memory2D)

前提

メディアンフィルタについてはこちらなどご参照ください。詳細はここでは省略します。
https://imagingsolution.net/imaging/imageprocessingalgorithm/median-filter/

今回はカーネルサイズを3とします。着目画素の周囲1ピクセル、計9ピクセル分の画素値の中央値を求め、それを新しい値とします。

周りの画素を見に行かないといけない点がグレースケール化と異なります。Memory2D, Span2Dの使いどころです。

入力画像

これまで同様マンドリルですが、説明の簡単のため予めグレースケールにしておくことにします。

また、画像周囲の境界値チェックが面倒なので、1ピクセルだけ周囲に余分な領域を作っておくことにします。OpenCVのcopyMakeBorder相当です。[3]

using var srcOrgImage = Image.Load<L8>("mandrill_gray.png");
using var srcImage = new Image<L8>(customConfig, srcOrgImage.Width + 2, srcOrgImage.Height + 2);
srcImage.Mutate(x =>
{
    x.Fill(Color.Black);
    x.DrawImage(
        srcOrgImage,
        new Point(1, 1), 
        1f);
    });
}

なお、Fillメソッドを使うには以下のパッケージを追加で導入します。
https://www.nuget.org/packages/SixLabors.ImageSharp.Drawing/

実装例1. ポインタで素朴に

// srcImageの準備は省略

using var dstImage = srcImage.Clone(customConfig);
var width = srcImage.Width;
var height = srcImage.Height;

srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
dstImage.DangerousTryGetSinglePixelMemory(out var dstMemory);
Debug.Assert(!srcMemory.IsEmpty);
Debug.Assert(!dstMemory.IsEmpty);

using var srcHandle = srcMemory.Pin();
using var dstHandle = dstMemory.Pin();
unsafe
{
    var srcPointer = (byte*)srcHandle.Pointer;
    var dstPointer = (byte*)dstHandle.Pointer;
    Span<byte> buffer = stackalloc byte[9];

    for (int y = 1; y < height - 1; y++)
    {
        for (int x = 1; x < width - 1; x++)
        {
            buffer[0] = srcPointer[(y - 1) * width + (x - 1)];
            buffer[1] = srcPointer[(y - 1) * width + (x - 0)];
            buffer[2] = srcPointer[(y - 1) * width + (x + 1)];
            buffer[3] = srcPointer[(y - 0) * width + (x - 1)];
            buffer[4] = srcPointer[(y - 0) * width + (x - 0)];
            buffer[5] = srcPointer[(y - 0) * width + (x + 1)];
            buffer[6] = srcPointer[(y + 1) * width + (x - 1)];
            buffer[7] = srcPointer[(y + 1) * width + (x - 0)];
            buffer[8] = srcPointer[(y + 1) * width + (x + 1)];

            buffer.Sort();
            dstPointer[y * width + x] = buffer[4];
        }
    }
}

少しボケた感じになりました。

ImageSharpで8-bitグレイスケールを表すL8構造体はbyteフィールド1つを持ち、実質byteと同等です。ポインタ操作では早々に葬ってbyte*とみなすと楽に書けます。

実装例2. Memory2D, Span2D

本題です。ぐっと実装が見通しよくなります。

// srcImageの準備は省略

using var dstImage = srcImage.Clone(customConfig);
var width = srcImage.Width;
var height = srcImage.Height;

srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
dstImage.DangerousTryGetSinglePixelMemory(out var dstMemory);
Debug.Assert(!srcMemory.IsEmpty);
Debug.Assert(!dstMemory.IsEmpty);

var srcMemory2d = srcMemory.AsBytes().AsMemory2D(height, width);
var dstMemory2d = dstMemory.AsBytes().AsMemory2D(height, width);
var dstSpan2d = dstMemory2d.Span;

Span<byte> buffer = stackalloc byte[9];
for (int y = 1; y < height - 1; y++)
{
    for (int x = 1; x < width - 1; x++)
    {
        var m = srcMemory2d.Slice(y - 1, x - 1, 3, 3);
        m.Span.CopyTo(buffer);
        buffer.Sort();
        dstSpan2d[y, x] = buffer[4];
    }
}

既存のMemory<T>, Span<T>は、好きな時に自在に2D化できますし、逆もできます。部分領域を取ったり、特定の行だけ抜き出したり等できます。もちろん領域の再確保等は行っておらず、裏でオフセット計算等をしてくれています。

参考: ベンチマーク

参考までに、ImageSharpとOpenCV(拙作OpenCvSharp)も加えて、処理時間を比べてみました。
https://github.com/shimat/opencvsharp

ImageSharp

using var dstImage = srcImage.Clone();
dstImage.Mutate(x =>
{
    x.MedianBlur(1, false);
});

OpenCvSharp

using OpenCvSharp;

using var srcMat = new Mat("mandrill_gray.png", ImreadModes.Grayscale);
using var dstMat = new Mat();
Cv2.MedianBlur(srcMat, dstMat, 3);

結果

Method Mean Error StdDev Median Allocated
Unsafe 52,252.9 us 411.03 us 343.23 us 52,164.4 us 2.62 KB
Memory2d 89,922.0 us 499.63 us 467.35 us 89,714.4 us 2.68 KB
ImageSharp 24,422.2 us 658.92 us 1,890.57 us 24,037.6 us 222.38 KB
OpenCV 489.8 us 9.73 us 25.97 us 480.7 us 1.58 KB[4]

OpenCVが速すぎて全てをかっさらって行きました。

私の実装はどちらもだいぶ遅いですね。そしてMemory2D版はやはりオフセット計算等のオーバーヘッドがそれなりにあるようですね。

今回は速さではなくコードの書き心地を確かめるためですので、と捨て台詞を残してここは終わります。少なくともわかりやすく書ける価値は間違いありません。

例3. テンプレートマッチング (Span2D, Memory2D, ParallelHelper.For2D)

前提

検索対象の大きい画像と、そこから探したいテンプレート画像の2つを用意します。テンプレート画像と一番類似度が高い場所はどこなのかを探索する古典的な方法の1つです。これも巷に大量に情報があるので割愛します。
https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html

類似度を取る手法はいくつかある中で、今回は実装の簡単のため、OpenCVでいう TM_SQDIFF_NORMED で行います。normalized square difference (正規化二乗差分)です[5]
https://docs.opencv.org/4.x/df/dfb/group__imgproc__object.html#ga3a7850640f1fe1f58fe91a2d7583695d

R(x,y) = \frac{\sum_{x',y'} (T(x',y') - I(x+x', y+y'))^2 }{ \sqrt{ \sum_{x',y'} T(x',y')^2 \cdot \sum_{x',y'} I(x+x',y+y')^2} }

これまでに出てきたMemory2DやParallelHelperを両方使う例とします。

入力画像

対象画像は同じくマンドリルで、テンプレート画像は鼻の部分をあらかじめ切り抜いたものにしました。今回も説明の簡単のため予めグレースケールにしておくことにします。

対象画像
対象画像
テンプレート画像
テンプレート画像

実装例1. Memory2D使用、シングルスレッド

float[,] dstData という2次元配列に各ピクセルの類似度が入ります。0に近いほどマッチしています。

対象画像を走査する2重for、テンプレート画像を走査する2重forで、典型的な計4重forの処理です。

using var srcImage = Image.Load<L8>("mandrill_gray.png");
using var tmplImage = Image.Load<L8>("mandrill_gray_template.png");
	
var w = srcImage.Width;
var h = srcImage.Height;
var tw = tmplImage.Width;
var th = tmplImage.Height;

srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
tmplImage.DangerousTryGetSinglePixelMemory(out var tmplMemory);
Debug.Assert(!srcMemory.IsEmpty);
Debug.Assert(!tmplMemory.IsEmpty);

var srcMemory2d = srcMemory.AsBytes().AsMemory2D(h, w);
var tmplMemory2d = tmplMemory.AsBytes().AsMemory2D(th, tw);
var tmplSpan2d = tmplMemory2d.Span;

var ylim = h - th + 1;
var xlim = w - tw + 1;
var dstData = new float[ylim, xlim];

for (int y = 0; y < ylim; y++)
{
    for (int x = 0; x < xlim; x++)
    {
        var srcSlice = srcMemory2d.Slice(y, x, th, tw);
        var srcSpan2d = srcSlice.Span;

        int diffSqSum = 0;
        long srcSqSum = 0;
        long tmplSqSum = 0;
        for (int ty = 0; ty < th; ty++)
        {
            for (int tx = 0; tx < tw; tx++)
            {
                var srcVal = srcSpan2d[ty, tx];
                var tmplVal = tmplSpan2d[ty, tx];

                diffSqSum += (tmplVal - srcVal) * (tmplVal - srcVal);
                srcSqSum += srcVal * srcVal;
                tmplSqSum += tmplVal * tmplVal;
            }
        }

        var denominator = Math.Sqrt(srcSqSum * tmplSqSum);
	// TODO 全画素が黒(0)だと0割
        dstData[y, x] = (float)(diffSqSum / denominator);
    }
}

出力結果を0~255に正規化し、疑似カラーでなんとか見やすくするとこうなります。


入力画像(左)とテンプレートマッチング出力(右)

出力結果はサイズが小さくなる (入力画像 - テンプレート画像 + 1 の大きさになる) ことを踏まえて見ると、鼻の左上のあたりが黒っぽくなっているのがわかります。黒は値が小さいことを示すので、正しくマッチングできたようですね。

実装例2. Memory2D使用、ParallelHelper使用

上記実装例1を並列化してみます。以下はまず処理本体です。後述するコールバック定義用structに多くを移管したので、相当すっきりしました。

2次元配列操作に適した ParallelHelper.For2Dが用意されており、利用しました。

using var srcImage = Image.Load<L8>("mandrill_gray.png");
using var tmplImage = Image.Load<L8>("mandrill_gray_template.png");

var w = srcImage.Width;
var h = srcImage.Height;
var tw = tmplImage.Width;
var th = tmplImage.Height;

srcImage.DangerousTryGetSinglePixelMemory(out var srcMemory);
tmplImage.DangerousTryGetSinglePixelMemory(out var tmplMemory);
Debug.Assert(!srcMemory.IsEmpty);
Debug.Assert(!tmplMemory.IsEmpty);

var srcMemory2d = srcMemory.AsBytes().AsMemory2D(h, w);
var tmplMemory2d = tmplMemory.AsBytes().AsMemory2D(th, tw);
var ylim = h - th + 1;
var xlim = w - tw + 1;
var dstData = new float[ylim, xlim];

var action = new TemplateMatchAction(srcMemory2d, tmplMemory2d, dstData, tw, th);
ParallelHelper.For2D(0..ylim, 0..xlim, action);

続いてParallelHelperに渡すstruct定義です。グレースケール変換ではインデックス1つを取るIActionを使いましたが、2つ取る IAction2Dも用意されています。

今回はテンプレート領域走査の2重forを持ってきており、ある程度込み入っているので、オーバーヘッドは気にしないことにしてポインタ操作はせずMemory<T>で持ってきました。

TemplateMatchAction.cs
public readonly struct TemplateMatchAction : IAction2D
{
    private readonly Memory2D<byte> srcMemory2d;
    private readonly Memory2D<byte> tmplMemory2d;
    private readonly float[,] dstData;
    private readonly int tw;
    private readonly int th;

    public TemplateMatchAction(Memory2D<byte> srcMemory2d, Memory2D<byte> tmplMemory2d, float[,] dstData, int tw, int th)
    {
        this.srcMemory2d = srcMemory2d;
        this.tmplMemory2d = tmplMemory2d;
        this.dstData = dstData;
        this.tw = tw;
        this.th = th;
    }

    public void Invoke(int y, int x)
    {
        var srcSlice = srcMemory2d.Slice(y, x, th, tw);
        var srcSpan2d = srcSlice.Span;
        var tmplSpan2d = tmplMemory2d.Span;

        int diffSqSum = 0;
        long srcSqSum = 0;
        long tmplSqSum = 0;
        for (int ty = 0; ty < th; ty++)
        {
            for (int tx = 0; tx < tw; tx++)
            {
                var srcVal = srcSpan2d[ty, tx];
                var tmplVal = tmplSpan2d[ty, tx];

                diffSqSum += (tmplVal - srcVal) * (tmplVal - srcVal);
                srcSqSum += srcVal * srcVal;
                tmplSqSum += tmplVal * tmplVal;
            }
        }

        var denominator = Math.Sqrt(srcSqSum * tmplSqSum);
	// TODO 全画素が黒(0)だと0割
        dstData[y, x] = (float)(diffSqSum / denominator);
    }
}

実装例3. OpenCV

参考までに。なお私の実装はここまでいずれもOpenCVの出力と一致することを確認しています。

using var srcMat = new Mat("mandrill_gray.png");
using var tmplMat = new Mat("mandrill_gray_template.png");
using var dstMat = new Mat();

Cv2.MatchTemplate(srcMat, tmplMat, dstMat, TemplateMatchModes.SqDiffNormed);

dstMat.GetRectangularArray(out float[,] dstData);

参考: ベンチマーク

Method Mean Error StdDev Allocated
Memory2d 222.092 ms 4.3025 ms 5.7437 ms 128.91 KB
Memory2dParallel 38.934 ms 0.7405 ms 0.7923 ms 134.89 KB
OpenCV 2.560 ms 0.0770 ms 0.2233 ms 129.15 KB[6]

OpenCVの爆速ぶりはさすがです。で、今回は並列化の効果が無事現れました。ある程度込み入ったコールバック(処理の分割単位)にしたほうが良さそうですね。

繰り返しますが、本記事に載せた私の実装は教科書の定義通りの愚直なものであり、実用に適するものではないことを申しあげておきます[7]。大昔から様々な最適化手法が考案されていますので、興味あればOpenCV含め先人の実装を当たってみてください[8]

脚注
  1. 逆アセンブルしてみると、Memory<T>.Spanプロパティは多少の込み入った処理が入っていました ↩︎

  2. 実はそれでも若干ズレます(わずかに明るい色になる)。よくわからない・・・。 ↩︎

  3. 本当はBORDER_REPLICATE的処理が望ましいと思いましたが、書くのが面倒なので黒の定数で埋めています。 ↩︎

  4. OpenCVにおけるAllocated欄のメモリ使用量は、ネイティブコードでの確保分が現れないはずなので参考になりません。 ↩︎

  5. 日本語の定訳は不明 ↩︎

  6. 前述の通りここは参考になりません。 ↩︎

  7. そもそも、SQDIFF_NORMEDのテンプレートマッチングがいまどき実用に値するのか、みたいな話もあるかもしれませんが、それはさておき。 ↩︎

  8. と偉そうに締めくくりつつ詳しくはない ↩︎

Discussion