2013年1月29日火曜日

wpf : クライアント領域の画像をキャプチャ

とりあえずwpfのクライアント領域がキャプチャしたかったんで、その方法を調べてみました。 wpfのコントロールはVisualクラスを継承しています。 GridとかTabControlとかButtonとか、全部Visualのサブクラスです。 ということでVisualがキャプチャできればok。 VisualをキャプチャするだけならRenderTargetBitmap.RenderとPngBitmapEncoderですぐできます。

  1. Visualのサイズを調べる。
  2. そのサイズに合わせたRenderTargetBitmapを作る。
  3. RenderTargetBitmapにVisualをレンダー。
  4. PngBitmapEncoderでRenderTargetBitmapを保存。

コレを元にwpfのクライアント領域をキャプチャするユーティリティクラスを作ってみましょう。

で、ちょっと気になることが。 Visual自体にはサイズを示すプロパティは無いんですよね。 なので画像キャプチャのユーティリティクラスとか作る場合、そのクラスを使う側が毎回サイズを計算しなくてはならなくなります。 それは面倒です。 クライアント領域をキャプチャできればいいんで、Visualクラスにこだわる必要はありません。 Visualクラスじゃなくて、Visualを継承したFrameworkElementにすればサイズを示すプロパティはあります。 それでサイズの計算をユーティリティクラスに組み込むことができますし、サイズ変更をイベントで察知してRenderTargetBitmapを作り直すこともできます。 クライアント領域のキャプチャをするには「Visualをキャプチャする」より「FrameworkElementをキャプチャする」方が良さそうですね。

普通にそれっぽい処理を並べただけだとキャプチャ範囲が切れてしまうケースがあります。 windowsのディスプレイの設定でdpiを変えている場合です。 dpiの変更によるスケーリングが考慮されないため端が切れます。 そういうのにも対応できるコードにしましょう。

って感じでできたのがコレ。 (要System.Drawingの参照追加。)

// FrameworkElementCapturer.cs

// PixelScaleTransformerクラスについてはこの投稿を参照
// http://pieceofnostalgy.blogspot.jp/2011/08/wpf_23.html

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

namespace Util
{
    // 注) キャプチャ対象のFrameworkElement全体がクライアント
    //     領域になければキャプチャ画像が崩れることがある。
    //     これはレンダリング時に透明部分が無視されるためで、
    //     透明部分を無くせば表示が崩れるのは回避可能。
    //     もしくは _brush.Stretch = Stretch.None; にして
    //     座標やスケーリングを自前で調節すれば回避可能。
    public class FrameworkElementCapturer
    {
        public const string FILE_NAME = "Capture";

        private readonly FrameworkElement _renderSource;
        private readonly DrawingVisual _renderTmpVisual = new DrawingVisual();
        private readonly VisualBrush _brush;
        private readonly PixelScaleTransformer _trans;
        private readonly DirectoryInfo _outputDir;

        private RenderTargetBitmap _renderTarget;
        private Rect _rect;
        private int _index = 0;

        public FrameworkElementCapturer(FrameworkElement renderSource, DirectoryInfo outputDir)
        {
            _renderSource = renderSource;
            _brush = new VisualBrush(renderSource);
            _trans = new PixelScaleTransformer(renderSource);
            _outputDir = outputDir;

            _renderSource.SizeChanged += SizeChangedHandler;
        }

        public void Capture()
        {
            // _renderTargetに直接_renderSourceを渡すとdpi次第で崩れる。
            // 座標変換されてた場合も崩れる。
            _renderTarget.Clear();
            using (DrawingContext dc = _renderTmpVisual.RenderOpen())
            {
                dc.DrawRectangle(_brush, null, _rect);
            }
            _renderTarget.Render(_renderTmpVisual);

            _index++;
            string path = _outputDir.FullName + '\\' + FILE_NAME + _index.ToString("D8") + ".png";
            using (FileStream stream = new FileStream(path, FileMode.Create, FileAccess.Write))
            {
                PngBitmapEncoder encoder = new PngBitmapEncoder();
                encoder.Frames.Add(BitmapFrame.Create(_renderTarget));
                encoder.Save(stream);
            }
        }

        private void SizeChangedHandler(object sender, SizeChangedEventArgs e)
        {
            int width = (int)_renderSource.ActualWidth;
            int height = (int)_renderSource.ActualHeight;

            if (_renderTarget == null
                || _renderTarget.PixelWidth != width
                || _renderTarget.PixelHeight != height)
            {
                HwndSource source = (HwndSource)HwndSource.FromVisual(_renderSource);
                System.Drawing.Graphics g = System.Drawing.Graphics.FromHwnd(source.Handle);
                _renderTarget = new RenderTargetBitmap(
                        width, height,
                        g.DpiX, g.DpiY, PixelFormats.Pbgra32); // dpiを96.0固定にしたらDeviceToWpfは要らないかもしれないが、dpiが変わらないという保障はあるんだろうか?
                
                Point size = _trans.DeviceToWpf(new Point(width, height));
                _rect.Width = size.X;
                _rect.Height = size.Y;
            }
        }
    }
}

TabControl/TabItemにMediaElementを追加してキャプチャしてみたら動画の絵もとれました。 どの動画でもキャプチャできるのかは分かりませんが、「動画のキャプチャは大変」と思ってたので意外でしたね。

ちなみに、このコードはクライアント領域にあるFrameworkElementしかキャプチャできません。 Windowクラスをキャプチャしようとすると失敗するので注意。 例えば、デバッガ上で試した場合はこんなエラー表示が出てきます。

まぁ、やろうとしてる事には影響しないんで、いいかな?

ウィンドウごとキャプチャしたいばあいはSystem.Drawingの方をガリガリやるしかないのかな?

手間的にはあんまり変わらなそうな気はしますが、試してなかったりします。