はじめに
.NETのBitmapオブジェクトを使って画像のフィルター処理や変換などの画像処理をする際、高速に処理するためにはいくつかのお作法的なTipsがあります。
ここでは、良く知られているTipsと、比較的汎用的に利用できるTipsを3つ紹介します。
なお本稿ではunsafeは用いずに高速化するTipsにとどめます。
unsafeなコードを利用した場合は、.NETによる画像処理の高速化Tips:unsafe編を参照ください。
また本稿は例外処理については触れません。
例外処理は下記の記事をご参照ください。
.NETでBitmap、BitmapDataを扱う場合の例外処理について
前提条件
本文の中では、特に記載がない限り以下の前提条件のもと評価を行います。
- CPU:Intel Core i7-4770(3.4GHz)
- メモリ:24GB
- .NET Framework 4.5.2
- Visual Studio 2015
- Releaseビルドでコードの最適化を有効化
- 24bitカラー画像を白黒二値画像へ変換
- 画像はLenna.pngを利用
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 destBitmapData =
dest.LockBits(
new System.Drawing.Rectangle(0, 0, dest.Width, dest.Height),
System.Drawing.Imaging.ImageLockMode.WriteOnly, dest.PixelFormat);
// 二値画像へ変換した結果を保管するバイト配列を作成する
byte[] destPixels = new byte[destBitmapData.Stride * dest.Height];
for (int y = 0; y < dest.Height; y++)
{
for (int x = 0; x < dest.Width; x++)
{
// 24bitカラーを256階層のグレースケールに変換し、128以上であれば白と判定する
if (128 <= ConvertToGrayscale(src.GetPixel(x, y)))
{
// 二値画像は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のロックを解除する
dest.UnlockBits(destBitmapData);
return dest;
}
/// <summary>
/// 引数で指定された色をグレースケールに変換する
/// </summary>
/// <param name="color">変換対象の色</param>
/// <returns>変換結果</returns>
private static int ConvertToGrayscale(System.Drawing.Color color)
{
// 人の目は色によって濃淡へ与える影響が異なるため、以下の係数を元に計算する
return (int)(color.R * 0.298912 + color.G * 0.586611 + color.B * 0.114478);
}
実行時間は139[ms]程度ですが、私がよく扱うA4 300dpiの2480×3508だと5.7秒程度かかってしまい、正直利用には耐えられません。
ここをスタートにチューニングしていきます。
.NETによる画像処理の高速化Tips
今回紹介する内容は、以下の3点になります。
- BitmapオブジェクトのGetPixelメソッドの実行の回避
- Bitmapオブジェクトの特定プロパティへのアクセスの最小化
- 浮動小数点演算を整数演算へ変換
これらを適用した結果、パフォーマンスは以下のように変化しました。(コードなどは後述)
No. | 項目 | 実行時間[ms] |
---|---|---|
1 | 変更前 | 137.47 |
2 | Bitmapオブジェクトの特定プロパティへのアクセスの最小化 | 121.95 |
3 | BitmapのGetPixelメソッド呼び出しの排除 | 18.15 |
4 | 浮動小数点演算を整数演算へ変換 | 137.40 |
5 | No.2+No.3 | 2.21 |
6 | No.2+No.4 | 121.46 |
7 | No.3+No.4 | 17.61 |
8 | No.2+No.3+No.4 | 1.55 |
これらの結果を見ると、GetPixelが飛びぬけて遅い事が見て取れると思います。
この為、画像のフィルター処理や変換処理をする場合、基本的にはGetPixelを使わず、BitmapDataを利用して作成する事が求められるケースが多いと思います。
またBitmapオブジェクトのプロパティへのアクセスや、浮動小数点演算(特に後者)は、個別にみると大きな影響はありません。
しかし、GetPixelの改善後で比較した場合、比率的に大きな効果が見られます。
具体的にはGetPixelの改善単体だと18.15[ms]でした。
ここに、プロパティアクセスの最小化を適用すると2.21[ms]に、さらに浮動小数点演算の整数演算への変換を適用すると、最終的に1.55[ms]まで改善されます。
二値化のアルゴリズムに特化した場合、他にもチューニングの余地はありますが、前述の3つのTipsはアルゴリズムにかかわらず、画像処理全般で効果が得られるケースが多いかと思います。
では以降は個別の項目をコードを踏まえて説明させていただきます。
BitmapオブジェクトのGetPixelメソッドの実行の回避
変換元のBitmapに対しても、ロックをかけてBitmapDataを取得し、ピクセルへ直接アクセスすることで、GetPixelメソッドの呼び出しを回避できます。
今回はunsafeコードは避けるため、BitmapDataから一旦byte配列にBitmapのデータを書き出して、byte配列からピクセルの情報を取得します。
Bitmapデータをバイト配列へ書き出すコードは以下の通りです。
// 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);
// 変換対象のカラー画像の情報をバイト列へ書き出す
byte[] srcPixels = new byte[srcBitmapData.Stride * src.Height];
System.Runtime.InteropServices.Marshal.Copy(srcBitmapData.Scan0, srcPixels, 0, srcPixels.Length);
また、ConvertToGrayscaleメソッドを以下のように修正します。
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 (int)(r * 0.298912 + g * 0.586611 + b * 0.114478);
}
合わせて呼び出し側も変更します。
if (128 <= ConvertToGrayscale(srcPixels, x, y, srcBitmapData.Stride))
以上になります。
Bitmapオブジェクトの特定プロパティへのアクセスの最小化
BitmapオブジェクトのWidthとHeight(もしかしたらそれ以外にも?)へのアクセスは比較的重たい処理になります。
これの対応は簡単で、以下の何れかで解消されます。
- 一旦ローカル変数へ代入し、以後はローカル変数を使う
- Bitmapをロックして作成したBitmapDataのプロパティを使う
ここでは2.の方法で実現します。
ここを
byte[] destPixels = new byte[destBitmapData.Stride * dest.Height];
for (int y = 0; y < dest.Height; y++)
{
for (int x = 0; x < dest.Width; x++)
こう変更します。
byte[] destPixels = new byte[destBitmapData.Stride * destBitmapData.Height];
for (int y = 0; y < destBitmapData.Height; y++)
{
for (int x = 0; x < destBitmapData.Width; x++)
浮動小数点演算を整数演算へ変換
やはり整数演算に比較すると浮動小数点演算は重たい処理になります。
グレースケール値への変換の際、以下のようなコードがあります。
private static int ConvertToGrayscale(System.Drawing.Color color)
{
// 人の目は色によって濃淡へ与える影響が異なるため、以下の係数を元に計算する
return (int)(color.R * 0.298912 + color.G * 0.586611 + color.B * 0.114478);
}
これを次のように変換することで、同じ意味合いでありながら浮動小数点演算を整数演算へ変換できます。
const int RedFactor = (int)(0.298912 * 1024);
const int GreenFactor = (int)(0.586611 * 1024);
const int BlueFactor = (int)(0.114478 * 1024);
private static int ConvertToGrayscale(System.Drawing.Color color)
{
// 10ビット右方向へシフト = 1024で割る
return (color.R * RedFactor + color.G * GreenFactor + color.B * BlueFactor) >> 10;
}
係数に1024をあらかじめ掛け合わせてintに変換しておき、計算時にそれらを掛けた後に割り算(上の例ではビットシフトしていますが)することで浮動小数点演算を整数演算へ変換しています。
案外、工夫すると浮動小数点演算をなくすことができるケースは多いです。
まとめ
以上で今回のTipsのご紹介は終了になります。
unsafeを利用する事でさらなる高速化を図ることもできますが、それはまた別の機会に書きたいと考えています。
最後に、3つの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 float 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;
}