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

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

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

前回の「C#で画像[Bitmap]を扱う①」では、Bitmap画像データ(7000 x 5000 : RGB24bit)の塗りつぶし処理について、Graphicsクラスを使った方法と、自前で実装した方法で処理を比べました。

塗りつぶし処理処理時間処理時間の比
(FillRectangle利用時を1とする)
Graphicsクラス
(FillRectangleメソッドを利用)
68ms1
自前
(Bitmap::SetPixelメソッドを利用)
59,774ms880

単純に実装した場合、話にならない遅さでした。

また、Bitmapデータがメモリ上にどのように配置されているのか、どの様にアドレスを計算できるかを確認しました。

今回は、前回の自前コードをベースとした高速化(処理時間の短縮)について記載していきたいと思います。

高速化検討

この記事では3rdパーティライブラリなどの機能を使って高速化するのではなく、処理の中の無駄を省いていく事で高速化を検討していきます。

高速化検討①:SetPixelメソッドを利用しないでポインタを利用

二重ループ内のSetPixelメソッドの呼び出しのオーバーヘッドをなくすために、ポインタを利用して速度改善を目指します。

※「ポインタを使えば早くなる」という話ではありません…

プロジェクトは前回「C#で画像[Bitmap]を扱う①」のsample_011を流用しFillBitmapメソッドを下記の様に書き換えます。

       // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装

            // 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* topPtr = (byte *)bmpDataPtr.ToPointer();

                // ポインタを使った処理
                for (int y = 0; y < bmp.Height; y++)
                {
                    for (int x = 0; x < bmp.Width; x++)
                    {
                        // 座標(x, y)のピクセルを色(colR, colR, colR)に設定する。
                        byte* bluePtr = topPtr + y * bmpData.Stride + x * 3;
                        byte* greenPtr = bluePtr + 1;
                        byte* redPtr = bluePtr + 2;

                        // 各色成分をセット
                        *redPtr = (byte)colR;
                        *greenPtr = (byte)colG;
                        *bluePtr = (byte)colB;
                    }
                }
            }
            // Bitmapデータメモリのアンロック
            bmp.UnlockBits(bmpData);
        }

48~60行はピクセルデータの先頭のアドレスをbyte型ポインタに格納するための手続きです。

68~70行は座標(x, y)の各ピクセルのRGBの各成分を格納しているアドレスを格納しています。

この辺りの詳細については、前回の記事「C#で画像[Bitmap]を扱う①」を参照して下さい。

73~75行は各色成分に指定の色成分を設定しています。

80行目は、49行目のLockBitsメソッドでロックしたメモリをアンロックする処理です。

プロジェクトをReleaseビルド実行して、処理時間を確認すると下記の様になります。

8,309ミリ秒(約8.3秒)になりました。

SetPixelメソッドをやめて、ポインタアクセスに変更した事で(メソッドのオーバーヘッドを減らしたため)、約1/7に処理時間が短縮できました。

ただ、まだGraphics::FillRectangleメソッドを使った時より約122倍遅いですが…

SetPixelメソッドを止めメソッドの呼び出しオーバーヘッドをなくしましたが、まだ、コードに無駄がありますので、地道にそれらを取り除いていく必要があります。

(推測ですが、SetPixelメソッド内では、Bitmap::LockBits、Bitmap::UnlockBitsメソッドを呼び出していると思いますが、それも二重ループの外にだして1回ずつの呼び出しになっているのも時間短縮の要因だの一つだと思います。)

高速化検討②:ループ内の冗長な(不要な)処理の除去(1)

二重ループ内のコードで下記のintからbyteへのキャスト部分がありますが、今回の場合colR, colG, colBの値は変更されないので、毎回キャストする意味はありません。

キャスト(型変換)にも処理コストが掛かるので、これを二重ループの外に出します。

        // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装

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

            // ピクセルデータの先頭をIntPtrで取得
            IntPtr bmpDataPtr = bmpData.Scan0;

            // 塗りつぶし色を予めキャストしておく
            byte r = (byte)colR;
            byte g = (byte)colG;
            byte b = (byte)colB;

            unsafe
            {
                // byte型ポインタへキャスト
                byte* topPtr = (byte *)bmpDataPtr.ToPointer();

                // ポインタを使った処理
                for (int y = 0; y < bmp.Height; y++)
                {
                    for (int x = 0; x < bmp.Width; x++)
                    {
                        // 座標(x, y)のピクセルを色(colR, colR, colR)に設定する。
                        byte* bluePtr = topPtr + y * bmpData.Stride + x * 3;
                        byte* greenPtr = bluePtr + 1;
                        byte* redPtr = bluePtr + 2;

                        // 各色成分をセット
                        *redPtr = r;
                        *greenPtr = g;
                        *bluePtr = b;
                    }
                }
            }
            // Bitmapデータメモリのアンロック
            bmp.UnlockBits(bmpData);
        }

58~60行で予め塗りつぶし色をbyte型へキャストし、その値を二重ループ内の78~80行で利用しています。

これで、冗長な(毎回する必要がない)処理を省略する事になります。

このコードをビルド実行し、処理速度を確認すると下記になります。

実行の度にばらつきはありますが、約100ms程度処理時間が短縮できました。

高々100msですが、目標は全体で68msなので馬鹿には出来ません。

※これくらいはReleaseビルドによる最適化で解決してくれるかもと期待していましたが、そうはならないみたいです。

高速化検討③:ループ内の冗長な(不要な)処理の除去(2)

二重ループ内で行っているアドレス計算にも冗長だったり、不要なコードがまだあります。

・x座標のforループ内で変数yを使った計算を毎回行っている

y * bmpData.Stride

 これは、x座標のforループの外で一回計算すれば良いです。また、yは1ずつ増えるだけなので掛け算ではなく足し算に置き換える事が出来ます。

・x座標のforループ内で変数xを使った計算を毎回行っている

x * 3

 これは、アドレス計算に使っていますが、よくよく考えれば隣のx座標のアドレスを格納したポインタに3を加えれば右隣の座標のアドレスになる事が分かります。

・greenPtr, redPtrは必要?

byte* greenPtr = bluePtr + 1;
byte* redPtr = bluePtr + 2;

 式の右辺にあるように、これらはbluePtrで置き換える事が出来るので、省略できそうです。

これらを念頭に置いて下記の様にFillBitmapメソッドを書き換えます。

        // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装

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

            // ピクセルデータの先頭をIntPtrで取得
            IntPtr bmpDataPtr = bmpData.Scan0;

            // 塗りつぶし色を予めキャストしておく
            byte r = (byte)colR;
            byte g = (byte)colG;
            byte b = (byte)colB;

            unsafe
            {
                // byte型ポインタへキャスト
                byte* topPtr = (byte *)bmpDataPtr.ToPointer();

                // ポインタを使った処理
                for (int y = 0; y < bmp.Height; y++, topPtr += bmpData.Stride)
                {
                    // 各ラインの先頭アドレスをコピー
                    byte* ptr = topPtr;
                    for (int x = 0; x < bmp.Width; x++, ptr += 3) 
                    {
                        // 各色成分をセット
                        *(ptr + 2) = r; // ptr[2] = r;
                        *(ptr + 1) = g; // ptr[1] = g;
                        *ptr = b;       // ptr[0] = b;
                    }
                }
            }
            // Bitmapデータメモリのアンロック
            bmp.UnlockBits(bmpData);
        }

68行目のforステートメントの最後に「topPtr += bmpData.Stride」を追加し、ポインタ変数topPtrが各yごとにラインの先頭になる様に更新する様にしています。

71行目では、topPtrの値は保存しておきたいので、ライン内の各ピクセルへアクセスするために変数ptrを宣言しtopPtrのアドレスを格納します。

72行目のforステートメントの最後に「ptr += 3」を追加し、各X座標に対応するようにポインタ変数ptrを更新しています。

75~77行では各RGBの色成分を設定しています。

これで計算の回数や変数も少なくなりました。

このコードをビルド実行し、処理速度を確認すると下記になります。

う~ん、あまり変わりません。

実行の度に若干処理時間が長くなったり短くなったりして、明らかに時間が短縮されたという感じではありません…。

※これである程度時間が短縮されると思っていたのですが、自分でも想定外です。Releaseビルド時にコードが最適化された際にあまり差がでないのでしょうか?

さて、二重ループの中はこれ以上処理を省くという事が出来なさそうです。

そして目標まで100倍程度の開きがあります。

これが限界でしょうか…?いえ、まだ削れます。

高速化検討④:ループ内のプロパティの除去

C#のメンバーにはフィールドの様に利用できるプロパティがあります。

今回のコードでもプロパティを利用しています。

IDE(統合開発環境)のコードにカーソルを合わせると、下記の様に、そのメンバーの説明が表示されます。

上記の様にメンバーの後ろに、「[get;}」や「{set;}」や「{get; set;}」と表示されるものは、プロパティです。

プロパティで値を取得したり、設定したりする事は、コード上ではフィールド(変数)へアクセスする様に記載しますが、実態はメソッドを呼び出しているのと変わりません。

そのためプロパティへアクセスするたびに、メソッド呼び出し時と同等のオーバーヘッドが生じます。

そして今回のコードでは、下記のプロパティがそれぞれ呼び出されています。

使用しているプロパティ使用回数
bmp.Height5,000 回
bmpData.Stride5,000 回
bmp.Width35,000,000 回 (=7,000 x 5000)

つまり、これだけメソッド呼び出しと同じオーバーヘッドが掛かっています。

今回使用している各プロパティの値は処理中に変化する事はないので、必ずしもプロパティから値を取得する必要はありません。

プロパティの値を二重ループの外で変数に格納し、その変数を用いる様にコードを修正します。

        // ビットマップの塗りつぶし処理
        static void FillBitmap(Bitmap bmp, int colR, int colG, int colB)
        {
            // 塗りつぶし処理の実装

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

            // ピクセルデータの先頭をIntPtrで取得
            IntPtr bmpDataPtr = bmpData.Scan0;

            // 塗りつぶし色を予めキャストしておく
            byte r = (byte)colR;
            byte g = (byte)colG;
            byte b = (byte)colB;

            // プロパティの値を変数に格納
            int width = bmp.Width;
            int height = bmp.Height;
            int stride = bmpData.Stride;

            unsafe
            {
                // byte型ポインタへキャスト
                byte* topPtr = (byte*)bmpDataPtr.ToPointer();

                // ポインタを使った処理
                for (int y = 0; y < height; y++, topPtr += stride)
                {
                    // 各ラインの先頭アドレスをコピー
                    byte* ptr = topPtr;
                    for (int x = 0; x < width; x++, ptr += 3)
                    {
                        // 各色成分をセット
                        *ptr = b;       // ptr[0] = b;
                        *(ptr + 1) = g; // ptr[1] = g;
                        *(ptr + 2) = r; // ptr[2] = r;
                    }
                }
            }
            // Bitmapデータメモリのアンロック
            bmp.UnlockBits(bmpData);
        }

63~65行で、ループで利用するプロパティの値を変数を宣言し格納しています。

73, 77行のforステートメントの終了判定でプロパティを使っていた部分を変数に置き換えています。

ソースコード的には、ほとんど変化はなく『本当にこれで速くなるの?』という感じですね。

修正したコードをビルド実行して、処理時間を確認すると…

63ms!!(実行の度に多少の増減はありますが)

Graphics::FillRectangleメソッドを用いたレベルまで、処理時間が短縮されました!!

何が処理時間短縮に寄与したのか?

高速化のため二重ループ内の処理の見直しを行いましたが、最も効果的だったのが以下の2つでした。

  • SetPixelメソッドの呼び出しを止めてポインタアクセスで代用(高速化検討①)
  • プロパティを変数に置き換え(高速化検討④)

どちらも基本的には呼び出し(アクセス)のためのオーバーヘッドの削除という点が共通しています。

時間短縮の効果が大きいので、もしかしたら、C言語での関数呼び出しのオーバーヘッドと比べて、大きなコストがかかっているのかも知れません。

そのため、『画像処理の様な大きな連続データを扱う際にはメソッド呼び出しやプロパティへのアクセスを出来る限り削減する』事が重要だと分かります。

※数回程度の少数のメソッド呼び出しやプロパティへのアクセスは非常に短時間で終わるので、それを止めてもあまり時間短縮効果はないでしょう。

また、以下は効果が無いとは言いませんが、劇的に速くなるという事は無さそうです。

  • キャスト処理をループ外へ(高速化検討②)
  • アドレス更新処理の簡略化、利用する変数の削減(高速化検討③)

おそらく、最近のPC(CPU)は性能が良いので、単純な計算に係る時間は極めて短時間なので、計算回数が多くても、多少の違いは目に見えて現れないのかと思います。(コンパイラの最適化による部分もあるかも知れません)

補足

今回の処理時間の計測は、IDEのビルド実行(開始ボタン)でコンソールを起動して行っていましたが、その方法の場合、Release版であっても処理時間が長くなる傾向にあります。

ビルドを実行して作成された実行ファイル(sample_011.exe)を別途コンソールから実行した時の計測時間(出力内容)は下記となります。

塗りつぶし処理IDEから実行コンソールから実行
Graphics::FillRectangleを利用79 ms55 ms
高速化なし
(Bitmap::SetPixel利用)
57,702 ms34.723 ms
高速化検討①8,314 ms3,937 ms
高速化検討②8,613 ms3,969 ms
高速化検討③8,428 ms3,912 ms
高速化検討④65 ms54 ms

IDEから実行すると、コンソールから実行した場合のおおよそ2倍程度時間が掛かっています。

ただ、処理の方法間での比率はおおよそ一緒になっていると思います。(処理時間が短い場合を除いて)

また、今回のサンプルコードはRGB24bit形式のBitmapデータにしか利用できません。RGB8bit(パレット使用)やRGB32bitなど他の形式の処理の場合は別のコードを作成する必要があります。そうでない場合は、実施にはPixelFormatをチェックしてエラー判定を行う処理が必要になる。

今回のまとめ

今回はBitmap画像の塗りつぶし処理をC#で実装した際の高速化(処理時間の短縮)について見てきました。

C#での画像処理でも十分な処理速度を得られる事を確認しました。(『やはり、C#は遅くはない!』)

C#のソースコードにしか接していないと、メソッドやプロパティの処理コストという点に思いが至らない事もあるかと思いますが、そのコードがアセンブリコード(ネイティブコード)に変換された場合どうなるか?を思い浮かべられる様にすると、どこを気にすれば良いかも何となくわかる様になると思います。

ただ、処理時間の短縮ばかり考えると、下記の様なデメリットも生じます。

高速化のデメリット

  • ソースコードが複雑になり事による可読性・メンテナンス性の低下
  • ポインタを利用する事での安全性の低下
    • メモリ管理は自身で行う必要
    • 扱うデータのメモリ上の配置などの知識が必要
    • 安易に扱うと致命的なバグになる可能性

せっかくのC#の利点がかなり損なわれる感じになります。

そのため、今回紹介した様な高速化はメリット(処理時間の短縮)とデメリットのバランスを考えて採用するかしないかを決めると良いと思います。

(デメリットも、十分なレビュー、デバッグ、試験を行った後、C#のカプセル化やライブラリとしてブラックボックス化してしまえば克服できるとは思いますが)

次回の予定

次回は、今のところ未定です。

また、何か面白そうなネタがあれば、続きを書きたいt思います。

前の記事次の記事
No.11:C#で画像(Bitmap)を扱う①(未定)