低レベルプログラミング(No.11:C#で画像[Bitmap]を扱う①)

使用ツール:Visual Studio Community 2019
使用言語 :C#言語

C#で画像[Bitmap]を扱う①

以前、仕事中に「画像を処理するプログラムが無茶苦茶遅いんですけど何故でしょう?」という質問を受けた事があります。

質問された方が質問してすぐに自己解決(?)されたので、ソースコードもどの様な処理を実装しようとしているのか聞けなかったのですが、自分の中ではしばらく『「遅い」とはどの程度だろう?』『画像データはデータ量が多いから処理量が多いのは自明だけど』などと頭の中で考えていました。

という訳で(?)、今回はC#を使って画像(Bitmapデータ)を扱ってみたいと思います。

時間計測方法

今回、画像データの処理に係る時間を計測する際、下記の様にStopwatchクラスを利用します。

using System.Diagnostics;

・・・

            Stopwatch sw = new Stopwatch();

            sw.Start();
            // 処理内容
            imageProc();
            sw.Stop();
            Console.WriteLine("time = {0} ms", sw.ElapsedMilliseconds);

上記の様に計測したい処理の前後でStopwatchをStart/Stopではさみ、ElapsedMillisecondsを出力すればms単位の経過時間を得られます。

空のプロジェクトの作成

コンソールアプリの空のプロジェクトを作成します。

詳しい手順は前回の記事「No.10:C#でポインタ」を参考にしてください。

また、今回は計測メインとなるので、ソリューションの構成は「Release」に切り換えておきます。

画像処理内容

今回の記事では、ビットマップを作成して、全体を指定の色で塗りつぶすという処理について考えて行こうと思います。

扱う画像データサイズ

画像データのサイズが小さいどういう方法でソースコードを実装してもあっと言う間に処理が終わってしまい、実装方法による違いが分かりにくいので、下記の様な画像データを扱う物とします。

項目
7000(ピクセル)
高さ5000(ピクセル)
ピクセル形式RGB24
R, G, Bそれぞれ8bit。
1ピクセルあたり24bit(=3byte)使用
総ピクセル数35,000,000(ピクセル)
総バイト数105,000,000(バイト)≒100MByte

この様なデータであれば、画像内の全データについてアクセスしようとすれば、最低限約1億回のアクセスが必要となり、そこそこの処理量となりますね。

サンプルコード

サンプルコードとしてまず下記の様なコードを用意します。

using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;

namespace sample_011
{
    class Program
    {
        // メイン関数
        static void Main(string[] args)
        {
            // ビットマップの作成
            Bitmap bitmap = new Bitmap(7000, 5000, PixelFormat.Format24bppRgb);
            // 塗りつぶし色
            byte r = 255; // 赤成分
            byte g = 0; // 緑成分
            byte b = 0; // 青成分

            // 所持時間計測用
            Stopwatch sw = new Stopwatch();

            sw.Start();

            // 塗りつぶし処理
            FillBitmap(bitmap, r, g, b);

            sw.Stop();

            // 処理時間の出力
            Console.WriteLine("処理時間 : {0:#,0} ms", sw.ElapsedMilliseconds);

            // 画像の保存(確認用)
            bitmap.Save("sample_011.png", ImageFormat.Png);
            // 画像のリソース解放
            bitmap.Dispose();

            // コンソールが閉じない様に…
            Console.Write("Hit any key to exit : ");
            Console.ReadKey();
        }

        // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装
        }
    }
}
関数説明
Main11~41メイン関数
14幅:7000,、高さ:5000、RGB24bitのビットマップの作成
16~18塗りつぶす色のRGBの設定
21時間計測用のStopwatchオブジェクトの生成
23計測開始
26塗りつぶし処理用メソッドFillBitmapの呼び出し
28計測終了
31コンソールへ処理時間をms単位で出力
34塗りつぶせているかどうかの確認
36ビットマップリソースの解放
39, 40プログラム終了時にコンソールが閉じない様にするためのコード
FillBitmap44~47ビットマップを指定の色で塗りつぶす関数
46(未実装)
コードの説明

FillBitmapメソッド内の実装を書き換えながら処理時間を計測していきます。

※『FillBitmapメソッドの呼び出してる分処理時間が長くなるのでは?』と思われそうですが、メソッド内の処理量が膨大なので1回分のメソッドの呼び出し程度の時間は無視できます。

Graphicsクラスを利用した塗りつぶし

まず処理時間の目安として、GraphicsクラスのFillRectangleメソッドを使った場合の処理時間を計測してみます。

FillBitmap関数を下記の様に実装します。

       // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装
            // 塗りつぶし用ブラシの作成
            Brush brush = new SolidBrush(Color.FromArgb(colR, colG, colB));
            // FillRectangleによる塗りつぶし
            Graphics g = Graphics.FromImage(bmp);

            g.FillRectangle(brush, new Rectangle(0, 0, bmp.Width, bmp.Height));

            // リソースの解放
            g.Dispose();
        }

これを実行すると、コンソールに処理時間が表示されます。

(私のPCでは)68msで塗りつぶす事が出来ました。

Graphicsクラスで提供されている描画メソッドは最適化された高速な処理なので、この処理時間を基準とします。

保存された画像(sample_011.png)を確認すると、確かにサイズが7000 x 5000、全体が赤い画像が作成されています。

Bitmap::SetPixelを使った塗りつぶし

次にGraphicsクラスを使わないで、各画素を指定色で塗りつぶすような処理を、高速化とかはあまり考えずに、シンプルに実装してみます。(おそらく、普通に書くとこうなるのでは?という実装です)

FillBitmap関数を下記の様に実装します(書き換えます)。

        // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装
            for (int y = 0; y < bmp.Height; y++)
            {
                for (int x = 0; x < bmp.Width; x++)
                {
                    // 座標(x, y)のピクセルを色(colR, colR, colR)に設定する。
                    bmp.SetPixel(x, y, Color.FromArgb(colR, colG, colB));
                }
            }
        }

説明の必要のないくらいシンプルですね。

高さ方向、幅方向の二重のforループを作成し、全ピクセルをSetPixelメソッドで指定色に設定しています。

これを実行すると…(かなり時間が掛かります…)、下記の様な処理時間が表示されます。

59,774ミリ秒…約1分…
Graphicsクラスの場合の約880倍

こんなシンプルなコードでも、こんなに遅い。やっぱりC#は遅いじゃないか』という声が聞こえてきそうです…。

しかし、シンプルなコードでも処理が遅い(時間が掛かる)のには理由があります。

何が遅いのか?

SetPixelメソッドを使った塗りつぶし処理は何が遅いのでしょうか?

今回のコードでは、3500万ピクセルについてSetPixelメソッドを呼び出しているので、1回あたりの処理時間は約1.7us(=0.0017ms)と想定されます。

非常に短い時間ですが、大きな画像データ全体を扱うような場合は遅いと言えます。

まず、この連載の「No.04:関数について」で関数のオーバーヘッドについて記載した通り、関数の呼び出し自体にコストが掛かります。それが僅かでも「塵も3500万回も積もれば…」相当な時間になります。

まず、この部分を改善する必要があります。

SetPixelメソッドを用いずにピクセルにアクセスするには?

BitmapオブジェクトのピクセルデータにSetPixelメソッドを用いずにアクセスするにはどうすれば良いでしょうか?

Bitmapオブジェクトのピクセルデータへポインタでアクセスするには下記の様なコードで実現できます。

            // Bitmapデータをロック
            BitmapData bmpData = bmp.LockBits(
                new Rectangle(0, 0, bmp.Width, bmp.Height), // Bitmap全体
                ImageLockMode.ReadWrite,                    // 読み書き設定
                bmp.PixelFormat);                           // ピクセルフォーマット

            // ピクセルデータの先頭をIntPtrで取得
            IntPtr bmpDataPtr = bmpData.Scan0;
            unsafe
            {
                // byte型ポインタへキャスト
                byte* ptr = (byte *)bmpDataPtr.ToPointer(); // byte* ptr = (byte *)bmpData.Scan0.ToPointer(); でも良い

                // ポインタを使った処理
            }

            // Bitmapデータメモリのアンロック
            bmp.UnlockBits(bmpData);

BitmapデータのメモリをLockBitsメソッドでロックし、メソッドの戻り値でBitmapDataオブジェクトを取得します。

最初のラインの先頭アドレスをBitmapDataオブジェクトのScan0プロパティで取得し、unsafeブロック内で、byteポインタにキャストする事でBitmapのピクセルデータへのポインタが取得できす。

※ピクセルデータへの処理が終了したら、Bitmap::UnlockBitsメソッドを呼び出して、ロックしたメモリをアンロック(解放)する必要があります。これを忘れると、この後同じBitmapデータのピクセルデータへアクセスする際にエラーになる場合があります。

このポインタを使えばピクセルにアクセスできそうですが、ポインタを使う場合はデータがどの様に格納されているかを把握する必要があります。

先に進む前に、ピクセルデータがどのようにメモリ上に配置されているのかを確認します。

ピクセルデータのメモリ上の配置

ここでは、サンプルで作成したRGB24bitの時の配置について説明します。

画像の座標系

メモリを見る前にまずはBitmap画像の座標系を確認します。

Bitmapの座標系は下記の様に、左上が原点(x = 0, y = 0)でY軸は下向きです。

各ピクセルのRGBデータ

各ピクセルはR、G、Bの3byteで構成されているので、以降は座標(X, Y)の各色成分をR(x,y)、G(x,y)、B(x,y)とします。

メモリ上の配置

Bitmap画像データ(ピクセルデータ)は下記の様にメモリに配置されています。

注意点がいくつかあります。

各ピクセルデータはアドレスが若い方からB(Blue) → G(Green) → R(Red)の順で並んでいます。(R→G→Bではありません)

ピクセルデータは左上のピクセル(x=0, y=0)から右隣りのピクセル(x=1, y=0)、さらに右隣り(x=2, y=0)の様にy=0のラインが1列分並び、続いて次のy=1ラインが並び…という順番で並んでいます。

1ライン分のデータサイズは、理論上Width * 3 バイトになりますが、メモリ上ではBitmapData::Stride バイトの長さになり、画像データによっては

Width * 3 < BitmapData::Stride

となる場合があり、その場合はBitmapData::Stride – Width * 3 バイト分データが追加されています。

例として幅が101ピクセルの場合、1ライン303(=101 x 3)byteとなりますが、BitmapデータではStride = 304byteとなります。これは、4byte単位でメモリアクセスをした方が効率的なため、1ラインのバイト数が4で割り切れない場合、その数を超える最小の4の倍数になる様にデータが追加されるためという理由だったと記憶しています。(違ってたらすみません)

これを理解していないと、まっすぐ引いたはずの線が斜めになったりします…。

指定座標のメモリ上のアドレス

ピクセルデータのメモリ上の開始位置や配置が分かれば、指定の座標(x、y)のアドレスが計算できます。

[座標(x,y)のピクセルの青の値が格納されているアドレス]
bluePtr = BitmapData::Scan0 + (y * BitmapData.Stride) + x * 3

[座標(x,y)のピクセルの緑の値が格納されているアドレス]
greenPtr = BitmapData::Scan0 + (y * BitmapData.Stride) + x * 3 + 1
         = bluePtr + 1

[座標(x,y)のピクセルの赤の値が格納されているアドレス]
redPtr = BitmapData::Scan0 + (y * BitmapData.Stride) + x * 3 + 2
       = bluePtr + 2
       = greenPtr + 1
※byte単位のアドレス、BitmapData::Scan0はbyteポインタではありませんが形式的に記載。

これでポインタを利用したピクセル操作の準備が出来ました。

今回のまとめ

大きな画像データを扱おうとした場合、標準で用意されている機能を使えば高速に出来るのに、C#でシンプルに実装しようとすると遅くなるという事を確認しました。

一番ネックになっていると思われる二重ループ内のSetPixelメソッドの処理が怪しいという事で、この部分をポインタ操作で置き換えるために、ピクセルデータがどのように配置され、各ピクセルのRGBデータにどのようにアクセスすれば良いかを確認しました。

いよいよ画像処理の高速化に取り掛かろうという段階になりましたが、キリが良いので今回はここまでにします。

次回の予定

次回は今回の続き「C#で画像(Bitmap)を扱う②」を予定しています。

さて、C#のコードでどれだけGraphicsクラスを使った処理時間(68ms)に近づけるでしょうか…。

前の記事次の記事
No.10:C#でポインタNo.12:C#で画像(Bitmap)を扱う②