GDI+ で描画&保存あれこれ

このエントリーは「C# Advent Calendar 2013」の16日目のエントリーです。

前日は hiroyuki_mori さんの「デリゲート〜ラムダ式の歴史を辿って」でした。

みんな大好き System.Drawing

Windows Forms 世代ではお馴染みの GDI+ のお話です。Windows XP ももうオワコンというのにまだ GDI+ ですか?といいたいところですが、ちょっとした画像生成にはまあ便利です。その中でも画像の生成・加工を中心にまとめてみました。Dispose! Dispose! Dispose!

まずはコマンドプロンプト

Windows Forms のプロジェクトは必須ではありません。たとえば、コマンドプロンプトで画像を生成する場合は以下のようになります。Visual Studioコマンドプロンプトのプロジェクトを作成し、参照設定に System.Drawing を追加して、Program.cs を下記のように書き直してください。

using System.Drawing;
using System.Drawing.Text;
class Program
{
    static void Main(string[] args)
    {
        Bitmap bmp = new Bitmap(430, 100);
        using (Graphics g = Graphics.FromImage(bmp))
        {
            g.Clear(Color.DarkBlue);
            Font font = new Font("メイリオ", 50);
      g.TextRenderingHint = TextRenderingHint.AntiAlias; // ClearType を無効に
            g.DrawString("こんにちは!", font, Brushes.White, new PointF(0, 0));
            g.DrawRectangle(Pens.Red, new Rectangle(10, 10, 410, 80));
            font.Dispose();
        }
        bmp.Save("HelloWorld.png", System.Drawing.Imaging.ImageFormat.Png);
        bmp.Dispose();
    }
}

上記の出力はこんな感じです。

ソースコードが短くて素敵ですね。IDisposable な Bitmap や Font は Dispose をお忘れなく。

フォントをベクトル化する

フォントのベクトルデータがほしい時があります。そういう場合には、GraphicsPath を使います。

using System.Drawing;
class Program
{
    static void Main(string[] args)
    {
        Bitmap bmp = new Bitmap(430, 100);
        using (Graphics g = Graphics.FromImage(bmp))
        {
            // アンチエイリアスをかける
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

            g.Clear(Color.DarkBlue);

            // 文字描画に GraphicsPath を利用
            using (var gp = new System.Drawing.Drawing2D.GraphicsPath())
            {
                Font font = new Font("メイリオ", 50);
                float pSize = font.SizeInPoints * g.DpiY / 72;
                gp.AddString("こんにちは!",
                    font.FontFamily, (int)font.Style,
                    pSize,  // サイズ変換が必要
                    new PointF(0, 0),
                    StringFormat.GenericDefault);

                // 曲線と制御点が必ずしも重ならない 3次 ベジェ スプライン
                // を直線に変換
                gp.Flatten();
                // 制御点に丸を描画
                foreach (var p in gp.PathPoints)
                {
                    g.FillEllipse(Brushes.White, new RectangleF(p.X - 2, p.Y - 2, 5, 5));
                }
                // 輪郭を描画
                g.DrawPath(Pens.White, gp);

                font.Dispose();
            }
            g.DrawRectangle(Pens.Red, new Rectangle(10, 10, 410, 80));
        }
        bmp.Save("HelloWorld.png", System.Drawing.Imaging.ImageFormat.Png);
        bmp.Dispose();
    }
}

曲線上に制御点があるとは限らないので、Flatten で曲線を直線に変換しています。

縁取り文字を描く

一旦、ベクトルデータにすることで縁取り文字も簡単に描画できます。方法は、

  1. 太いペンを用意して文字の輪郭を描画する
  2. 文字を塗りつぶす

です。2番目を省略すれば、輪郭のみの文字も描画できます。

using System.Drawing;
class Program
{
    static void Main(string[] args)
    {
        Bitmap bmp = new Bitmap(430, 100);
        using (Graphics g = Graphics.FromImage(bmp))
        {
            // アンチエイリアスをかける
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
            g.Clear(Color.DarkBlue);

            // 文字描画に GraphicsPath を利用
            using (var gp = new System.Drawing.Drawing2D.GraphicsPath())
            {
                Font font = new Font("メイリオ", 50);
                float pSize = font.SizeInPoints * g.DpiY / 72;
                gp.AddString("こんにちは!",
                    font.FontFamily, (int)font.Style,
                    pSize,  // サイズ変換が必要
                    new PointF(0, 0),
                    StringFormat.GenericDefault);

                // 1. 文字の輪郭を描画
                Pen pen = new Pen(Brushes.Red, 10); // 輪郭の色・太さ
                pen.LineJoin = System.Drawing.Drawing2D.LineJoin.Round; // 文字にトゲが生えないように
                g.DrawPath(pen, gp);

                // 2. 文字を塗りつぶしで描画
                g.FillPath(Brushes.White, gp);
                font.Dispose();
                pen.Dispose();
            }
            g.DrawRectangle(Pens.Red, new Rectangle(10, 10, 410, 80));
        }
        bmp.Save("HelloWorld.png", System.Drawing.Imaging.ImageFormat.Png);
        bmp.Dispose();
    }
}

既定では文字の端々に意図しないトゲが出てしまうので、System.Drawing.Drawing2D.LineJoin.Round で直線の曲がり角を丸めておくとよい感じになります。

ベクトル形式(.emf)で保存する

PowerPointIllustrator 向けに生成したデータはベクトルデータとして渡したいところです。ベクトルデータの形式として、.emf があります。書き方は Bitmap に描画するときとほとんど同じ感じなのですが、Saveメソッドが、.emf の書き出しをサポートしていない(.emfで書き出したつもりが、中身を見るとPNG形式・・・)ので、ひと手間が必要です。

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

class Program
{
    static void Main(string[] args)
    {
        Bitmap dummy = new Bitmap(1, 1);
        Graphics gd = Graphics.FromImage(dummy);
        IntPtr ipHdc = gd.GetHdc();

        // 保存ファイル名、画像サイズを指定して初期化
        Metafile metafile = new Metafile("Hello.emf", ipHdc, new Rectangle(0, 0, 100, 100), MetafileFrameUnit.Pixel);

        // 描画の仕方は Bitmap の時と同じ
        using (Graphics g = Graphics.FromImage(metafile))
        {
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
            g.FillEllipse(Brushes.Red, new Rectangle(5, 5, 70, 70));
            g.FillEllipse(Brushes.Cyan, new Rectangle(25, 25, 70, 70));
        }

        // Disposeを忘れずに
        metafile.Dispose();
        gd.ReleaseHdc(ipHdc);
        gd.Dispose();
        dummy.Dispose();
    }
}

このようにして出力したファイルは、PowerPoint などで開くと、ちゃんと頂点の編集ができます。Illustrator の場合はそのまま編集できますが、PowerPointの場合は何度かグループ化を解除してください。また、色に透明色(α値が254以下)を指定すると意図しない感じになります。

圧縮率を指定してJPEGで保存

LINQで少しだけすっきり。

using System.Drawing;
using System.Drawing.Imaging;
using System.Linq;

class Program
{
    static void Main(string[] args)
    {
        Bitmap bmp = new Bitmap(100, 100);

        // 円を描画
        using (Graphics g = Graphics.FromImage(bmp))
        {
            g.Clear(Color.White);
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
            g.FillEllipse(Brushes.Red, new Rectangle(5, 5, 70, 70));
            g.FillEllipse(Brushes.Cyan, new Rectangle(25, 25, 70, 70));
        }
        
        // JPEGの圧縮率を指定して保存
        long quality = 95L; // 0(高圧縮) - 100(高画質) の値を long 型で指定

        // JPEGコーデックを取得
        ImageCodecInfo jpgEncoder = (
            from c in ImageCodecInfo.GetImageEncoders()
            where c.FormatID == ImageFormat.Jpeg.Guid
            select c).FirstOrDefault();

        EncoderParameters encParams = new EncoderParameters(1);
        encParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, quality);

        bmp.Save("sample.jpg", jpgEncoder, encParams);

        encParams.Dispose();
        bmp.Dispose();
    }
}

Bitmapをバイト配列として操作

C#3以降ならこんな感じで割とすっきり。

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;

class Program
{
    static void Main(string[] args)
    {
        Bitmap bmp = new Bitmap(256, 256);

        // Bitmap をバイト配列として操作
        BitmapBytes(bmp, (data, width, height) =>
        {
            for (int y = 0; y < height; y++)
            {
                for (int x = 0; x < width; x++)
                {
                    int p = (x + y * width) * 4;

                    // このあたりはお好みで
                    data[p + 3] = 255;     // A
                    data[p + 2] = (byte)x; // R
                    data[p + 1] = (byte)y; // G
                    data[p] = 128;         // B
                }
            }
        });

        bmp.Save("sample.png", ImageFormat.Png);
        bmp.Dispose();
    }

    /// <summary>
    /// ビットマップをbyte配列として操作
    /// </summary>
    /// <param name="bmp">32bppArgb の Bitmap</param>
    /// <param name="userMethod">Bitmap のbyte配列操作処理</param>
    private static void BitmapBytes(Bitmap bmp, Action<byte[], int, int> userMethod)
    {
        // 32bitARGB Bitmap をbyte配列に変換 
        Rectangle rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
        System.Drawing.Imaging.BitmapData bmpData =
            bmp.LockBits(rect, System.Drawing.Imaging.ImageLockMode.ReadWrite,
            PixelFormat.Format32bppArgb);
        IntPtr ptr = bmpData.Scan0;
        int bytes = bmp.Width * bmp.Height * 4;
        byte[] rgbValues = new byte[bytes];
        Marshal.Copy(ptr, rgbValues, 0, bytes);

        // ユーザー定義のBitmap配列操作処理
        userMethod(rgbValues, bmp.Width, bmp.Height);

        // byte配列をBitmap に書き戻し
        Marshal.Copy(rgbValues, 0, ptr, rgbValues.Length);
        bmp.UnlockBits(bmpData);
    }
}

出力結果はこんな感じ。


まとめ

.NET のグラフィックスライブラリがもっと充実して使いやすくなってくれるといいですね!というか、XNAなき今、3D関係はどうなってしまうの?

それでは、良い GDI+ ライフを!

明日はmatarilloさんです!
Java8とC# - 猫とC#について書くmatarilloの雑記