はじめに
前回の記事「.NETによる画像処理の高速化Tips:非unsafe編」を投稿させていただきましたが、今回はunsafeなコードも利用した高速化のTipsを纏めさせて貰いたいと思います。
2015/11/08 改稿
サンプルのソースコードがあまりに投げっぱなしで何の説明もなかったので、全体的に見直しました。
コード全文が無くなっていますがそのうちどこかに公開するかもしれません。
2015/12/12 追記
本記事を参考にされる際は、以下もあわせてご一読いただくことを推奨いたします。
アンマネージドリソースのリークを回避する方法
なお本稿は例外処理については触れません。
例外処理は下記の記事をご参照ください。
.NETでBitmap、BitmapDataを扱う場合の例外処理について
前提条件
本文の中では、特に記載がない限り以下の前提条件のもと評価を行います。
- CPU:Intel Core i7-4770(3.4GHz)
- メモリ:24GB
- .NET Framework 4.5.2
- Visual Studio 2015
- Releaseビルドでコードの最適化を有効化
- 24bitカラー画像を白黒二値画像へ変換
- 画像はLenna.pngを利用
- 時間計測はそれぞれ500回実施した平均値を採用
Tips適用前
以下のコードをベースにTipsの説明と適用後を行います。
前回の記事の最終結果になります。
/// <summary>
/// 引数で指定されたカラー画像を二値化する。
/// </summary>
/// <param name="src">変換対象のカラー画像。</param>
/// <returns>変換結果の二値画像</returns>
public static System.Drawing.Bitmap Binarize(System.Drawing.Bitmap src)
{
// 変換結果のBitmapを作成する
System.Drawing.Bitmap dest =
new System.Drawing.Bitmap(
src.Width, src.Height, System.Drawing.Imaging.PixelFormat.Format1bppIndexed);
// Bitmapをロックし、BitmapDataを取得する
System.Drawing.Imaging.BitmapData srcBitmapData =
src.LockBits(
new System.Drawing.Rectangle(0, 0, src.Width, src.Height),
System.Drawing.Imaging.ImageLockMode.WriteOnly, src.PixelFormat);
System.Drawing.Imaging.BitmapData destBitmapData =
dest.LockBits(
new System.Drawing.Rectangle(0, 0, dest.Width, dest.Height),
System.Drawing.Imaging.ImageLockMode.WriteOnly, dest.PixelFormat);
// 変換対象のカラー画像の情報をバイト列へ書き出す
byte[] srcPixels = new byte[srcBitmapData.Stride * src.Height];
System.Runtime.InteropServices.Marshal.Copy(srcBitmapData.Scan0, srcPixels, 0, srcPixels.Length);
// 二値画像へ変換した結果を保管するバイト配列を作成する
byte[] destPixels = new byte[destBitmapData.Stride * destBitmapData.Height];
for (int y = 0; y < destBitmapData.Height; y++)
{
for (int x = 0; x < destBitmapData.Width; x++)
{
// 24bitカラーを256階層のグレースケールに変換し、180以上であれば白と判定する
if (128 <= ConvertToGrayscale(srcPixels, x, y, srcBitmapData.Stride))
{
// 二値画像は1ビットずつ格納されるため、座標は8で割ったアドレスのバイトに格納されている
int pos = (x >> 3) + destBitmapData.Stride * y;
// 該当のビットを立てることで、白にする
destPixels[pos] |= (byte)(0x80 >> (x & 0x7));
}
}
}
// 二値データを保管したバイト列を結果となるBitmapDataへ書き出す
System.Runtime.InteropServices.Marshal.Copy(destPixels, 0, destBitmapData.Scan0, destPixels.Length);
// BitmapDataのロックを解除する
src.UnlockBits(srcBitmapData);
dest.UnlockBits(destBitmapData);
return dest;
}
const int RedFactor = (int)(0.298912 * 1024);
const int GreenFactor = (int)(0.586611 * 1024);
const int BlueFactor = (int)(0.114478 * 1024);
/// <summary>
/// 指定された座標のピクセルのグレースケール値を求める
/// </summary>
/// <param name="srcPixels">変換元画像のBitmapデータのバイト配列</param>
/// <param name="x">X座標</param>
/// <param name="y">Y座標</param>
/// <param name="stride">スキャン幅</param>
/// <returns>変換結果</returns>
private static int ConvertToGrayscale(byte[] srcPixels, int x, int y, int stride)
{
int position = x * 3 + stride * y;
byte b = srcPixels[position + 0];
byte g = srcPixels[position + 1];
byte r = srcPixels[position + 2];
return (r * RedFactor + g * GreenFactor + b * BlueFactor) >> 10;
}
unsafeコードを用いた高速化の検討
まずそもそもなぜunsafeコードを利用すると、パフォーマンスの改善が見込めるのか?という点から説明します。
前述のコードでは、以下の手順で処理を実施しています。
- BitmapDataを一旦byte配列へコピーする
- byte配列からピクセル情報を取得
- 24bitカラーを256階調のグレースケースへ変換を経由して白黒二値へ変換
- 白黒二値を結果byte配列に設定
- 結果byte配列をBitmapDataへ一気に書き出す
この時、byte配列の実体は.NET Frameworkが管理しているヒープ領域に確保されます。
一般的な話になりますが、ヒープ領域上へのアクセスは安全な分、遅くもあります。
なぜヒープ領域は遅いのかは、また別途記事にしたいと思います。
その為、unsafeなコードを利用することで高速化が期待できます。
とは言え、一概にunsafeを利用するといっても選択肢は複数あります。
今回のケースでぱっと思いつくのは以下の4パターンでしょうか?
私が思いつかないだけで他にも方法はあるかもしれません。。。
- バイト配列はそのまま使うが、アドレスを固定(fixed)してポインタでアクセスする
- バイト配列をヒープではなくスタック上へ確保してアクセスする
- アンマネージド領域のメモリを確保してワークに利用する
- BitmapDataのメモリ領域を直接読み書きする
以下がその対応結果になります。
なお適用したのは変換前の画像のRead側だけでWrite側はそのままになっています。
Write側はまた後半で検証します。
No. | 項目 | 実行時間[ms] |
---|---|---|
1 | 変更前 | 1.2205 |
2 | バイト配列はそのまま使うが、アドレスを固定(fixed)してポインタでアクセスする | 0.9485 |
3 | バイト配列をヒープではなくスタック上へ確保してアクセスする | 0.9657 |
4 | アンマネージド領域のメモリを確保してワークに利用する | 0.9366 |
5 | BitmapDataのメモリ領域を直接読み書きする | 0.7658 |
当然といえば当然ですが、「BitmapDataのメモリ領域を直接読み書きする」が断トツで早いですね。
それ以外は誤差の範囲に思えます。
個々のソースコードは後述します。
さて、上表のNo.5をWrite側にも適用した結果が下表のとおりです。
No. | 項目 | 実行時間[ms] |
---|---|---|
1 | 読み込み側のみ適用 | 0.7727 |
2 | 書き込み側にも適用 | 0.7809 |
・・・誤差を考慮すると、効果がないように見えます。
ではWriteアクセスはbyte配列へのアクセスも、unsafeなコードもパフォーマンス的な違いはないのでしょうか?
上記検証に効果が見受けられない原因は以下の2ケースが考えられます。
- byte配列へのアクセスもunsafeなコードも書き込みは性能差は出にくい
- Write側は二値画像でピクセル数が少なく、且つ白ピクセルの時のみしか書き込みをしていないため前述の検証では正しく評価できていない
というわけで、24bitカラーのBitmapをピクセル単位で単純にコピーするだけのコードを作成して比較してみました。(コードは後述)
結果は以下の通りです。
No. | 項目 | 実行時間[ms] |
---|---|---|
1 | byte配列同士でコピー | 1.9674 |
2 | Read側をunsafeに変更 | 1.4915 |
3 | Write側をunsafeに変更 | 1.1421 |
4 | Read側・Write側ともにunsafeに変更 | 0.8836 |
Write側の方が、影響が大きいことが見て取れます。
したがって前述の検証はやはりアクセス回数がReadに比べてWriteが圧倒的に少なかったせいだと考えられます。
考察
ここまでの検証を通して以下の事が言えると思います。
- ヒープ上のバイト配列を直接利用するのではなく、unsafeなコードを利用する事でパフォーマンスの向上を図れることがある
- ReadよりもWriteの際に効果がでる可能性が高い
- ただし一概にunsafeにすれば必ず効果が表れるとも限らない
- Bitmapデータへのアクセスは、BitmapDataへ直接ポインタアクセスする事が最も早い可能性が高く、それ以外(byte配列のアドレス固定、スタック領域上の配列、アンマネージドなワーク領域)は今回のケースではそれぞれに大きな差異は見受けられなかった
実際のところ、今回の画像であればunsafeなコードを利用しなくても2[ms]もかかっていないため、わざわざunsafeなコードを利用する価値は低いかもしれません。
ただし、高解像度な画像を扱った場合、無視できないケースもあり得ますし、今回の二値化は同一ピクセルへのアクセスは1回きりでしたが、画像処理の内容によっては何度も同じピクセルへアクセスする必要がある場合もあります。
ケース毎に検証したうえで、unsafeの利用可否は判断したほうがよさそうですね。
では最後にコードサンプルを張っておきます。
コードサンプル
共通の修正点
まず24bitカラーからグレースケール値に変換するConvertToGrayscaleというメソッドがあります。
変更前は、以下のように変換元のピクセル情報を格納したbyte配列を渡していました。
private static int ConvertToGrayscale(byte[] srcPixels, int x, int y, int stride)
{
int position = x * 3 + stride * y;
byte b = srcPixels[position + 0];
byte g = srcPixels[position + 1];
byte r = srcPixels[position + 2];
return (r * RedFactor + g * GreenFactor + b * BlueFactor) >> 10;
}
この引数のbyte配列の宣言をbyteポインタに変更します。
メソッド内部は一切変更する必要はありません。
配列とポインタはここでは同じように扱えます。
private static int ConvertToGrayscale(byte* srcPixels, int x, int y, int stride)
{
int position = x * 3 + stride * y;
byte b = srcPixels[position + 0];
byte g = srcPixels[position + 1];
byte r = srcPixels[position + 2];
return (r * RedFactor + g * GreenFactor + b * BlueFactor) >> 10;
}
それからConvertToGrayscaleの呼び出し側で、byte配列を渡している個所をbyteポインタを渡すように変更します。
以下が変更前で
if (128 <= ConvertToGrayscale(srcPixels, x, y, srcBitmapData.Stride))
以下が変更後のソースコードです。
byte* srcPtr
if (128 <= ConvertToGrayscale(srcPtr, x, y, srcBitmapData.Stride))
srcPtrがbyteポインタにあたりますが、byteポインタの取得の仕方は前述の各手法で異なりますので、以下に説明します。
バイト配列はそのまま使うが、アドレスを固定(fixed)してポインタでアクセスする場合
byte配列のメモリ空間へ直接アクセスするためのポインタを取得することは非常に簡単です。
具体的にはbyte配列srcPixelsからbyteポインタを取得する方法は以下の通りになります。
unsafe
{
fixed(byte* srcPtr = srcPixels)
{
// この中へ具体的な処理を記載します。
}
}
unsafeなコード全体をunsafeブロックでくくり、その中でusingと類似した記法でfixedを利用してbyte配列へのポインタを取得します。
あとは、そのブロック内で具体的な処理を記載するだけです。
バイト配列をヒープではなくスタック上へ確保してアクセスする場合
変更前のコードでは以下のようにbyte配列を初期化していました。
byte[] srcPixels = new byte[srcBitmapData.Stride * src.Height];
ここをstackallocを使ってスタック上にメモリを確保します。
その際、stackallocで取得したメモリを利用する箇所全体をunsafeブロックでくくる必要があります。
unsafe
{
int srcLength = srcBitmapData.Stride * src.Height;
byte* srcPtr = stackalloc byte[srcLength];
// いかに具体的な処理を記載する
}
また、元々のソースコードでは以下のコードでBitmapDataの値をbyte配列へコピーしていました。
System.Runtime.InteropServices.Marshal.Copy(srcBitmapData.Scan0, srcPixels, 0, srcPixels.Length);
これをbyteポインタへコピーするために、以下のように修正します。
using (System.IO.UnmanagedMemoryStream inputStream = new System.IO.UnmanagedMemoryStream((byte*)srcBitmapData.Scan0, srcLength))
using (System.IO.UnmanagedMemoryStream outputStream = new System.IO.UnmanagedMemoryStream((byte*)srcPtr, srcLength, srcLength, System.IO.FileAccess.Write))
{
inputStream.CopyTo(outputStream);
}
他にもコピーする方法はありますが、ここではこれがお手軽で高速です。
あとは共通の変更を適用するだけです。
アンマネージド領域のメモリを確保してワークに利用する場合
変更前のコードでは以下のようにbyte配列を初期化していました。
byte[] srcPixels = new byte[srcBitmapData.Stride * src.Height];
ここをMarshal.AllocCoTaskMemを使ってアンマネージド領域にメモリを確保します。
その際、他と同様にunsafeブロックでくくる必要があります。
unsafe
{
// 変換対象のカラー画像の情報をバイト列へ書き出す
int srcLength = srcBitmapData.Stride * src.Height;
IntPtr srcIntPtr = System.Runtime.InteropServices.Marshal.AllocCoTaskMem(srcLength);
try
{
byte* srcPtr = (byte*)srcIntPtr;
// ここに処理を記載する
}
finally
{
System.Runtime.InteropServices.Marshal.FreeCoTaskMem(srcIntPtr);
}
}
この際に特に注意していただきたいのは、AllocCoTaskMemで確保したメモリは必ずFreeCoTaskMemで開放する必要があるということです。
開放漏れが発生するとリソースがリークします。
またsrcPtrへキャストしたのちに、stackallocの場合と同様に、BitmapDataの情報をポインタへコピーする必要があることを忘れないようにしてください。
BitmapDataのメモリ領域を直接読み書きする場合
ある意味、これが一番簡単です。
byte配列の宣言などを削除し、以下のようにBitmapDataの情報へアクセスするポインタを確保します。
unsafe
{
byte* srcPtr = (byte*)srcBitmapData.Scan0;
}
BitmapDataのメモリ空間へのポインタになるので、データのコピーも必要ないため、リソース的にも処理速度的にも当然ながら一番有利です。
しかも、あとは共通の変更を行ったConvertToGrayscaleへ渡すように修正するだけでコード自体も一番コンパクトになります。
実のところ、unsafeを利用しないコードよりもコンパクトになります。
24bitカラーBitmapの単純コピー
配列を使ったコード
private static void CopyByBiteArray(System.Drawing.Imaging.BitmapData srcBitmapData, System.Drawing.Imaging.BitmapData destBitmapData)
{
// 変換対象のカラー画像の情報をバイト列へ書き出す
byte[] srcPixels = new byte[srcBitmapData.Stride * srcBitmapData.Height];
System.Runtime.InteropServices.Marshal.Copy(srcBitmapData.Scan0, srcPixels, 0, srcPixels.Length);
// 二値画像へ変換した結果を保管するバイト配列を作成する
byte[] destPixels = new byte[destBitmapData.Stride * destBitmapData.Height];
for (int y = 0; y < srcBitmapData.Height; y++)
{
for (int x = 0; x < srcBitmapData.Stride; x++)
{
destPixels[destBitmapData.Stride * y + x] = srcPixels[srcBitmapData.Stride * y + x];
}
}
// 二値データを保管したバイト列を結果となるBitmapDataへ書き出す
System.Runtime.InteropServices.Marshal.Copy(destPixels, 0, destBitmapData.Scan0, destPixels.Length);
}
直接BitmapDataを操作するコード
private static void CopyByPointer(System.Drawing.Imaging.BitmapData srcBitmapData, System.Drawing.Imaging.BitmapData destBitmapData)
{
unsafe
{
byte* srcPixels = (byte*)srcBitmapData.Scan0;
byte* destPixels = (byte*)destBitmapData.Scan0;
for (int y = 0; y < srcBitmapData.Height; y++)
{
for (int x = 0; x < srcBitmapData.Stride; x++)
{
destPixels[destBitmapData.Stride * y + x] = srcPixels[srcBitmapData.Stride * y + x];
}
}
}
}