C#でPictureBoxに画像を表示する方法について

C#でPictureBoxにBitmapをスクロールや拡大／縮小にも対応で表示する場合、Panelに入れ子にしてAutoScrollなどを設定するのが一般的だと思う。その場合、次のようなメリットやデメリットがある。

メリット
* 処理が手っ取り早く簡単に書ける
* 簡単に書ける割りに動作も速い

デメリット
* プロパティの設定だけだと画像が左上始点や中央でしか表示できない
* 拡大／縮小や回転など少し機能を足すと処理の度にメモリがガンガン食われる

今回、作成中の画像閲覧アプリでデメリットの方が気になって独自の処理を作りたくなったので、やってみた。
PictureBoxのSizeModeを設定する話は除くとして、コード上で教科書通りの処理を書いた場合と独自の処理を書いた場合の比較をする。

テストのため、2つのパターンに対してそれぞれクラスを作った。教科書通りの処理をする方がBadCanvasクラス、独自処理をする方がGoodCanvasクラスで、これらの基本クラスがCanvasBaseクラスになる。

先ずはCanvasBaseクラスだが、次のような感じにした。
~~~C#
namespace Test_PictureBox_Bitmap
{
    internal abstract class CanvasBase
    {
        // フィールド

        protected string imageFilePath = null;

        protected PictureBox drawTarget = null;
        protected Bitmap sourceBitmap = null;  // 元画像の保持用
        protected Bitmap currentBitmap = null;  // 描画用

        protected double zoomRatio = 1.0;

        // プロパティ

        public Bitmap SourceBitmap
        {
            get { return sourceBitmap; }
        }

        public Bitmap CurrentBitmap
        {
            get { return currentBitmap; }
        }

        // コンストラクタ

        protected CanvasBase()
        { }

        public CanvasBase(PictureBox drawTarget)
        {
            if (drawTarget.Parent.GetType().Equals(typeof(Panel)))
            {
                this.drawTarget = drawTarget;
            }
        }

        // メソッド

        public abstract void CreatePicture(string sourceFilePath);

        public abstract void UpdatePicture();

        public double CalcZoomRatio(Size targetWindow, Size sourceBitmap)
        {
            return CalcZoomRatio(targetWindow.Width, targetWindow.Height, sourceBitmap.Width, sourceBitmap.Height);
        }

        public double CalcZoomRatio(int targetWindowWidth, int targetWindowHeight, int sourceBitmapWidth, int sourceBitmapHeight)
        {
            double returnValue = 0;

            double windowWidth = Math.Abs(targetWindowWidth);
            double windowHeight = Math.Abs(targetWindowHeight);
            double bitmapWidth = Math.Abs(sourceBitmapWidth);
            double bitmapHeight = Math.Abs(sourceBitmapHeight);

            if (bitmapWidth != 0 && bitmapHeight != 0)
            {
                returnValue = Math.Min(windowWidth / bitmapWidth, windowHeight / bitmapHeight);
            }

            return returnValue;
        }
    }
}
~~~
このクラスは基本クラスで、比較する2つのクラスで共通するものが書かれてると思ってサラッと見てほしい。
なお、BitmapをsourceBitmapとcurrentBitmapの2つ用意してるのは、例えば画像の表示範囲を示す枠をアプリのサムネイル表示部で出したりするために、元画像のデータをなるべく弄らず保持しておくためだ。

次にBadCanvasクラスだが、次のような感じにした。
~~~C#
namespace Test_PictureBox_Bitmap
{
    internal class BadCanvas : CanvasBase
    {
        // フィールド

        protected Image currentImageOld = null;  // メモリ節約の処理用
        protected Bitmap currentBitmapOld = null;  // メモリ節約の処理用

        // コンストラクタ

        protected BadCanvas()
        { }

        public BadCanvas(PictureBox drawTarget) : base(drawTarget)
        { }

        public override void CreatePicture(string sourceFilePath)
        {
            Image img = Image.FromFile(sourceFilePath);
            Bitmap newBitmap = new Bitmap(img.Width, img.Height);
            Graphics gr = Graphics.FromImage(newBitmap);
            gr.DrawImage(img, 0, 0, newBitmap.Width, newBitmap.Height);
            gr.Dispose();
            img.Dispose();

            sourceBitmap = newBitmap;
        }

        public override void UpdatePicture()
        {
            zoomRatio = CalcZoomRatio(drawTarget.Parent.ClientSize, sourceBitmap.Size);

            Image img = Image.FromHbitmap(sourceBitmap.GetHbitmap());
            Bitmap newBitmap = new Bitmap((int)(img.Width * zoomRatio), (int)(img.Height * zoomRatio));
            Graphics gr = Graphics.FromImage(newBitmap);
            gr.DrawImage(img, 0, 0, newBitmap.Width, newBitmap.Height);
            gr.Dispose();
            img.Dispose();

            currentBitmap = newBitmap;

            drawTarget.Image = currentBitmap;
            drawTarget.Size = currentBitmap.Size;

            // メモリ節約
            if (currentBitmapOld != null)
            {
                currentBitmapOld.Dispose();
                currentBitmapOld = currentBitmap;
            }
            if (currentImageOld != null)
            {
                currentImageOld.Dispose();
                currentImageOld = drawTarget.Image;
            }
        }
    }
}
~~~
sourceBitmapとcurrentBitmapのどちらも、ImageからBitmapを作り、BitmapからGraphicsを作って描画するという一連の流れを一カ所に書いて、基本的な処理をしている。
メモリをなるべく節約することを考えてしつこいほどDispose()を入れてるんだが、これが何故か全く効かない。
どういう結果になるか、[比較テスト用に作ったアプリの動画1](https://drive.google.com/file/d/1jXy0jS34G2TUSmLGl7vXkeItK2qfg8DR/view?usp=share_link)を見てもらいたい。
アプリの実行で開発環境の画面右側に出てくる「プロセス メモリ」に注目すると、画像の処理でメモリをガンガン食っていって、あっという間にメモリ不足の例外が発生する。

今作ってる画像閲覧アプリでは拡大／縮小や回転／反転などの処理をするので、これだと場合によってはアプリ使用中にメモリ不足が発生することになる。使用中に頻繁にメモリ不足が発生して、その度に再起動を余儀なくされるアプリでは使い物にならない。
対策としては
1. 拡大／縮小などを一切諦めてPictureBoxSizeModeのNormalやZoomを使う
2. アプリケーションの設定でメモリ使用量の上限を変更する
3. 描画を独自処理にする

というような方法が考えられるんだが、1つめは嫌だし、2つめは対症療法で焼け石に水なので、3つめの方法で行きたい。

というわけで、その基本的な形になる物を作ってみた。それがGoodCanvasクラスで、次のような感じにした。
~~~C#
namespace Test_PictureBox_Bitmap
{
    internal class GoodCanvas : CanvasBase
    {
        // フィールド

        private Image currentImage = null;
        private Graphics currentGraphics = null;
        private SolidBrush currentBrush = null;
        private Rectangle drawRect = new Rectangle();

        // コンストラクタ

        protected GoodCanvas()
        { }

        public GoodCanvas(PictureBox drawTarget) : base(drawTarget)
        {
            currentBrush = new SolidBrush(drawTarget.BackColor);
        }

        public override void CreatePicture(string sourceFilePath)
        {
            // sourceBitmapは完全に作ってしまう
            {
                Image img = Image.FromFile(sourceFilePath);
                Bitmap newBitmap = new Bitmap(img.Width, img.Height);
                Graphics gr = Graphics.FromImage(newBitmap);
                gr.DrawImage(img, 0, 0, newBitmap.Width, newBitmap.Height);
                gr.Dispose();
                img.Dispose();

                sourceBitmap = newBitmap;
            }

            // currentBitmapは領域の用意だけ
            {
                currentImage = Image.FromHbitmap(sourceBitmap.GetHbitmap());
                Bitmap newBitmap = new Bitmap(10000, 10000);  // バッファ的な領域を用意
                currentGraphics = Graphics.FromImage(newBitmap);

                currentBitmap = newBitmap;
            }
        }

        public override void UpdatePicture()
        {
            currentGraphics.FillRectangle(currentBrush, drawRect);

            // currentBitmapはここで描画する
            zoomRatio = CalcZoomRatio(drawTarget.Parent.ClientSize, sourceBitmap.Size);
            drawRect.Width = (int)(sourceBitmap.Width * zoomRatio);
            drawRect.Height = (int)(sourceBitmap.Height * zoomRatio);
            currentGraphics.DrawImage(currentImage, drawRect);

            drawTarget.Image = currentBitmap;
            drawTarget.Size = drawRect.Size;
        }
    }
}
~~~
これで結果がどうなるか、[比較テスト用に作ったアプリの動画2](https://drive.google.com/file/d/1T5yjRM5-eFBjD18CSxYKQs-tDOSTfOWY/view?usp=share_link)を見てもらいたい。
メモリのドカ食いが完全に無くなり、動作も軽い。とても良い感じだ。
拡大／縮小や回転などBitmapの処理はやや面倒になるが、その代わりに処理さえ作れば大きな画像でどんな処理をどれだけやってもメモリ不足で落ちるようなことが無く、画像表示のあらゆる事が可能になる。

というわけで、一時はメモリ不足が出る問題をガーベジなど.Netの仕様の問題で不可避であり開発をC++に切り替えなければならないかと思ったんだが、C#で行けそうだという結論が出せた。.Netはアプリの画面を作ったりコーディングするのがC++に比べてメチャクチャ簡単だし、画像閲覧アプリはC#で作っていこうと思う。