C# + WPFでSetPixel、GetPixelする

WinFromの時代には、System.Drawing.Bitmapクラスがあってそのクラスには、1ドットごとに色を指定して絵を描くことができるSetPixel関数が付いていました。

一応、「WriteableBitmap」というクラスがあるのですがちょっと操作感が求めてるのと違います。あとちょっとパフォーマンスが悪いので自分で自作することにしました。自分でBitmapを作成して1ドットずつ点描しつつ、SetPixel・GetPixel操作ができるたくなる時があります。そこで、そういった操作が実現できるようにクラスを自作しようと思います。

余談ですが、以下の自作の実装は余計な処理がない分動作が軽いので極めてパフォーマンスが良いです。

基底クラスを作成する

画像の形式が色々あるため(RBG24とかARGB形式とか)それらに派生クラスで対応したいので、まず、SetPixelとGetPixelメソッド(+派生クラスで定義してほしい機能)をまとめた基底クラスとして「ImageBuffer」を宣言します。ここに画像の取り扱いの規約などを記述していきます。

以下コードを書く前にプロジェクトのアセンブリ参照に以下をを追加します。

  • PresentationCore
  • WindowsBase
// ImageBuffer.cs

using System;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace WpfSetPixel
{
    // 画像バッファーの基底クラス
    public abstract class ImageBuffer
    {
        protected byte[] _buffer;

        public int Width { get; private set; }
        public int Height { get; private set; }
        public int RawStride { get; private set; }

        /// <summary>
        /// 画像バッファーのサイズを指定してオブジェクトを初期化します。
        /// </summary>
        public ImageBuffer(int width, int height)
        {
            this.Width = width;
            this.Height = height;
            this.RawStride = this.CalculateRawStride();
            this._buffer = new byte[this.RawStride * height];
        }

        //
        // Methods
        // - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// 指定した座標の色を更新します。
        /// </summary>
        public abstract void SetPixel(int x, int y, Color c);

        /// <summary>
        /// 指定した位置へ色を取得します。
        /// </summary>
        public abstract Color GetPixel(int x, int y);

        /// <summary>
        /// 現在のバッファーを取得します。
        /// </summary>
        public virtual byte[] GetBuffer()
        {
            byte[] _tempBuffer = new byte[this._buffer.Length]; // 複製して返す
            for (int i = 0; i < this._buffer.Length; i++)
            {
                _tempBuffer[i] = this._buffer[i];
            }
            return _tempBuffer;
        }

        //
        // Abstract Methods
        // - - - - - - - - - - - - - - - - - - - -

        // バッファーの1行の長さの計算方法を実装します。
        protected abstract int CalculateRawStride();

        // バッファーの各ピクセルにアクセスするためにインデックスの計算方法を実装します。
        protected abstract void GetBufferIndex(int x, int y, out int xIndex, out int yIndex);

        // 画像の形式を取得する
        protected abstract PixelFormat GetPixelFormat();

        //
        // Utilities
        // - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// 全ピクセルを列挙して述語による処理を行います。
        /// </summary>
        public void ForEach(Func<int, int, Color> action)
        {
            for (int y = 0; y < this.Height; y++)
            {
                for (int x = 0; x < this.Width; x++)
                {
                    this.SetPixel(x, y, action.Invoke(x, y));
                }
            }
        }

        /// <summary>
        /// 現在のオブジェクトの内容を<see cref="BitmapSource"/> へ変換します。
        /// </summary>
        public BitmapSource ToImageSource()
        {
            return BitmapSource.Create(this.Width, this.Height, 96, 96,
                this.GetPixelFormat(), null, this.GetBuffer(), this.RawStride);
        }

        /// <summary>
        /// 現在のオブジェクトの内容を指定した位置へ保存します。encoderを指定しない場合PNG形式で保存します。
        /// </summary>
        public void SaveImage(string path, BitmapEncoder encoder = null)
        {
            WpfImageUtil.SaveImage(this.ToImageSource(), path, encoder);
        }
    }
}

また、上記オブジェクトのから画像をファイルに保存するための機能をUtilityに抜き出しておきます。

// WpfImageUtil.cs

using System.IO;
using System.Windows.Media.Imaging;

namespace WpfSetPixel
{
    /// <summary>
    /// WPFのImage関係の汎用操作を提供します。
    /// </summary>
    public static class WpfImageUtil
    {
        /// <summary>
        /// 現在のオブジェクトの内容を指定した位置へ保存します。encoderを指定しない場合PNG形式で保存します。
        /// </summary>
        public static void SaveImage(BitmapSource source, string path, BitmapEncoder encoder = null)
        {
            // .NETでは以下クラスが用意されている
            //   System.Windows.Media.Imaging.BmpBitmapEncoder
            //   System.Windows.Media.Imaging.GifBitmapEncoder
            //   System.Windows.Media.Imaging.JpegBitmapEncoder
            //   System.Windows.Media.Imaging.PngBitmapEncoder
            //   System.Windows.Media.Imaging.TiffBitmapEncoder
            //   System.Windows.Media.Imaging.WmpBitmapEncoder

            BitmapEncoder _temp_encoder = encoder;
            if (encoder == null)
            {
                _temp_encoder = new PngBitmapEncoder();
            }

            using (FileStream fs = File.Create(path))
            {
                _temp_encoder.Frames.Add(BitmapFrame.Create(source));
                _temp_encoder.Save(fs);
            }
        }
    }
}

画像を管理するクラスを作成する

で、肝心の画像を扱うクラスですが、RGB24という形式(透過無しの3原色のみ)で画像を管理する「Rgb24ImageBuffer」クラスを作成したいと思います。

RGB24は名前の通り、R → G → B が8bitずつStreamに並んで24ビットの形式です。先述の基底クラスを継承して作成します。

// Rgb24ImageBuffer.cs

using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace WpfSetPixel
{
    /// <summary>
    /// 透過色を含まないRGB24形式の画像バッファーを表します。
    /// </summary>
    public class Rgb24ImageBuffer : ImageBuffer
    {
        /// <summary>
        /// 画像バッファーのサイズを指定してオブジェクトを初期化します。
        /// </summary>
        public Rgb24ImageBuffer(int width, int height) : base(width, height)
        {
            // nop
        }

        /// <summary>
        /// 指定した位置へ色を設定します。
        /// </summary>
        public override void SetPixel(int x, int y, Color c)
        {
            this.GetBufferIndex(x, y, out int xIndex, out int yIndex);
            this._buffer[xIndex + yIndex] = c.R;
            this._buffer[xIndex + yIndex + 1] = c.G;
            this._buffer[xIndex + yIndex + 2] = c.B;
        }

        /// <summary>
        /// 指定した位置へ色を設定します。
        /// </summary>
        public override Color GetPixel(int x, int y)
        {
            this.GetBufferIndex(x, y, out int xIndex, out int yIndex);

            return new Color()
            {
                R = this._buffer[xIndex + yIndex],
                G = this._buffer[xIndex + yIndex + 1],
                B = this._buffer[xIndex + yIndex + 2],
            };
        }

        // RGB24のRawStrideを計算する
        protected override int CalculateRawStride() =>
            (this.Width * PixelFormats.Rgb24.BitsPerPixel + 7) / 8;

        // ユーザー値 → bute[]のR, G, Bへアクセスするための位置計算
        protected override void GetBufferIndex(int x, int y, out int xIndex, out int yIndex)
        {
            xIndex = x * 3;
            yIndex = y * this.RawStride;
        }

        public override BitmapSource ToImageSource()
        {
            return BitmapSource.Create(this.Width, this.Height,
                96, 96, PixelFormats.Bgr24, null, this.GetBuffer(), this.RawStride);
        }

        protected override PixelFormat GetPixelFormat() => PixelFormats.Rgb24;
    }
}

透過を扱うクラスを作成する

画像によっては透過色を扱いたい場合があるので、上記のクラスのほかに、透過を扱うためのArgbImageBufferクラスを作成します。

このクラスでは、赤、青、緑の3色と透過を含む4つの色を管理します。

// ArgbImageBuffer.cs

using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace WpfSetPixel
{
    /// <summary>
    /// 透過色を含むRGBA形式の画像バッファを表します。
    /// </summary>
    public class ArgbImageBuffer : ImageBuffer
    {
        /// <summary>
        /// 画像バッファーのサイズを指定してオブジェクトを初期化します。
        /// </summary>
        public ArgbImageBuffer(int width, int height) : base(width, height)
        {
            // nop
        }

        /// <summary>
        /// 指定した座標の色を更新します。
        /// </summary>
        public override void SetPixel(int x, int y, Color c)
        {
            this.GetBufferIndex(x, y, out int xIndex, out int yIndex);

            // チェックは必要なければコメントアウトしてもよい
            if (xIndex + yIndex < 0 || xIndex + yIndex + 3 > this._buffer.Length)
            {
                return;
            }

            // BGRA形式なので青→緑→緑→アルファの順になる
            this._buffer[xIndex + yIndex] = c.B;
            this._buffer[xIndex + yIndex + 1] = c.G;
            this._buffer[xIndex + yIndex + 2] = c.R;
            this._buffer[xIndex + yIndex + 3] = c.A;
        }

        /// <summary>
        /// 指定した位置へ色を取得します。
        /// </summary>
        public override Color GetPixel(int x, int y)
        {
            this.GetBufferIndex(x, y, out int xIndex, out int yIndex);
            return new Color()
            {
                B = this._buffer[xIndex + yIndex],
                G = this._buffer[xIndex + yIndex + 1],
                R = this._buffer[xIndex + yIndex + 2],
                A = this._buffer[xIndex + yIndex + 3],
            };
        }

        // RGB32のRawStrideを計算します。
        protected override int CalculateRawStride() =>
            (this.Width * PixelFormats.Bgra32.BitsPerPixel + 7) / 8;

        // ユーザー値 → bute[]のA, R, G, Bへアクセスするためのインデックスの計算
        protected override void GetBufferIndex(int x, int y, out int xIndex, out int yIndex)
        {
            xIndex = x * 4;
            yIndex = y * this.RawStride;
        }

        public override BitmapSource ToImageSource()
        {
            return BitmapSource.Create(this.Width, this.Height,
                96, 96, PixelFormats.Bgra32, null, this.GetBuffer(), this.RawStride);
        }

        protected override PixelFormat GetPixelFormat() => PixelFormats.Bgra32;
    }
}

使い方

使い方ですが、上記のバッファークラスにSetPixelメソッドが付いているので1ドットずつX,Y座標と色を指定してきます。

試しに全面を黒に塗りつぶして等間隔に灰色の縦線を描画したBitmapを表示したいと思います。

先に実行結果を張っておきます。

f:id:Takachan:20171013011037p:plain

コードですが、ImageクラスのSourceプロパティにSetPixelしたBitmapを指定する場合の使い方は以下の通りです。

画面の見た目にMahAppsを使用しています。使用方法はサイトを確認してください。

// MainWindow.xaml

<Controls:MetroWindow x:Class="WpfSetPixel.Debug.MainWindow"
                      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                      xmlns:Controls="clr-namespace:MahApps.Metro.Controls;assembly=MahApps.Metro"
                      mc:Ignorable="d"
                      Title="MainWindow"
                      Height="650"
                      Width="900"
                      BorderThickness="1"
                      GlowBrush="{DynamicResource AccentColorBrush}"
                      WindowTransitionsEnabled="False"
                      Loaded="MetroWindow_Loaded">
    <StackPanel>
        <Image x:Name="img"
               Height="593"
               Width="850"               
               Source="{Binding ImageSource}"/>
    </StackPanel>
</Controls:MetroWindow>

コードビハインド側で画像クラスの使い方の説明を兼ねた画像の作成を行います。

using MahApps.Metro.Controls;
using System.Windows.Media;

namespace WpfSetPixel.Debug
{
    public partial class MainWindow : MetroWindow
    {
        public MainWindow()
        {
            this.InitializeComponent();

            // 画像サイズを指定してオブジェクトを作成
            var buffer = new Rgb24ImageBuffer(850, 600);
            // 透過画像を扱い以下を指定する
            // var buffer = new ArgbImageBuffer(850, 600);

            // 全ての画素を列挙する場合、ImageBufferのForEachを使用できる
            buffer.ForEach((x, y) =>
            {
                if(x%10 > 6)
                {
                    return Color.FromRgb(160, 160, 160); // しましまを書く
                }

                return Color.FromRgb(40, 40, 40); // 基本的に灰色

                // 透過する場合は以下のように指定
                // Color.FromArgb(128, 40, 40, 40);
            });

            // BitmapImageに画像を設定する
            this.img.Source = buffer.ToImageSource();

            // ブラシを作成する場合以下のように書く
            var brush = new ImageBrush() { ImageSource = buffer.ToImageSource() };
        }
    }
}

マジで肝心なのが、バッファーからToImageSource()の箇所です。このコードだけで値千金だと思います。透過色を扱うArgbImageBufferクラスの場合Colorの指定を「Color.FromArgb(128, 40, 40, 40);」のように透過を指定したColorクラスとしてオブジェクトを作成します。

余談ですが、バッファーからブラシを作成して、MainWindowの背景に設定する場合以下のように記述します。

var brush = new ImageBrush() { ImageSource = buffer.ToImageSource() };
this.Background = brush;

おまけ

これをやっちゃあ、上記クラスを自作した意味がかなり薄くなるのですが、このバッファーを画像ファイルから作成するユーティリティです。

System.Drawing.Bitmapを使ってファイルから画像を読み取ってバッファーに移し替えています。WPFのImageSourceやImageBrushと、SystemDrawing.Bitmapの間を埋めるためにバッファーを使うという意味では正しいのかもしれませんが、、、あとRgb24BufferクラスのSetPixelhは余計な処理がないためBitmapの操作より結構軽いと思います。

// Rgb24ImageBufferUtility.cs

// System.Drawing.dllを追加

using System.Windows.Media;

namespace WpfSetPixel.Debug
{
    /// <summary>
    /// 画像操作に関する汎用処理を提供します。
    /// </summary>
    public static class Rgb24ImageBufferUtility
    {
        // needs "System.Drawing.dll"

        /// <summary>
        /// 指定したファイルパスからバッファーを作成します。
        /// </summary>
        public static Rgb24ImageBuffer CreateBuffer(string path)
        {
            var _bitmap = new System.Drawing.Bitmap(path);
            var buffer = new Rgb24ImageBuffer(_bitmap.Width, _bitmap.Height);

            buffer.ForEach((x, y) =>
            {
                System.Drawing.Color color = _bitmap.GetPixel(x, y);
                return Color.FromRgb(color.R, color.G, color.B);
            });

            return buffer;
        }
    }
}

あとは、自分で好きなようにSetPixelして画像を書いてください。

最後に

成果物をGithubへアップしています。よかったら参考にしてください。

github.com