sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

C# DMM 版ウマ娘のウィンドウ画像をキャプチャー保存する (+OCR メモ)

DMM 版のウマ娘のウィンドウ画像をキャプチャーをして、あれこれサポートするツールでは「UmaUmaCruise」が有名です。

で、このツールがどうやってウマ娘の画面をキャプチャーしているのかな、って調べたところ C++ のコーディングだったので、C# に置き換えつつ、キャプチャーできるかなーってのをやってみた。

以下、うまくできたので公開。

指定したウィンドウ(DMM 版ウマ娘)を取得する

C++ コードの真似なので、Win32 API をたくさん呼び出すことになりました。なので、DLL Import をたくさん使います。

ウィンドウの取得は FindWindow を使います。

[DllImport("user32.dll")]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);

public static IntPtr GetHandle(string className, string windowName)
{
    var handle = FindWindow(className, windowName);

    return handle;
}

DMM 版ウマ娘のハンドル IntPtr を取得するときはこうする。

var handle = GetHandle("UnityWndClass", "umamusume");

ハンドルからウィンドウの表示画像を取得する

取得する方法は、ざっくり2通りあって次のとおり:

  1. PC の画面全体をキャプチャー、指定ウィンドウの表示してる部分を切り取ってコピーする
  2. PrintWindow 関数からウィンドウの表示画面をコピーする

できることなら「2」を採用したいんだけど、DMM 版ウマ娘はこの方法だとキャプチャー画像が正しいものにならない(ピンク色の画面になる)ので、1を採用するみたいです。

ここで使用する DLL Import は次の関数:

[DllImport("user32.dll")]
static extern int GetClientRect(IntPtr hWnd, out RECT lpRect);

[DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
static extern int MapWindowPoints(IntPtr hWndFrom, IntPtr hWndTo, [In, Out] ref RECT rect, [MarshalAs(UnmanagedType.U4)] int cPoints);

[DllImport("user32.dll")]
static extern IntPtr GetWindowDC(IntPtr hwnd);

[DllImport("gdi32.dll")]
static extern IntPtr CreateDIBSection(IntPtr hdc, [In] ref BITMAPINFO pbmi, uint pila, out IntPtr ppvBits, IntPtr hSection, uint dwOffset);

[DllImport("gdi32.dll")]
static extern IntPtr CreateCompatibleDC(IntPtr hdc);

 [DllImport("gdi32.dll")]
static extern IntPtr SelectObject(IntPtr hDC, IntPtr hObject);

DLL Import で使われたりしている構造体と列挙子も定義する:

enum BitmapCompressionMode : uint
{
    BI_RGB = 0,
    BI_RLE8 = 1,
    BI_RLE4 = 2,
    BI_BITFIELDS = 3,
    BI_JPEG = 4,
    BI_PNG = 5
}

[StructLayout(LayoutKind.Sequential)]
struct RECT
{
    public int left;
    public int top;
    public int right;
    public int bottom;
}

[StructLayout(LayoutKind.Sequential)]
struct BITMAPINFOHEADER
{
    public uint biSize;
    public int biWidth;
    public int biHeight;
    public ushort biPlanes;
    public ushort biBitCount;
    public BitmapCompressionMode biCompression;
    public uint biSizeImage;
    public int biXPelsPerMeter;
    public int biYPelsPerMeter;
    public uint biClrUsed;
    public uint biClrImportant;

    public void Init()
    {
        biSize = (uint)Marshal.SizeOf(this);
    }
}

[StructLayout(LayoutKind.Sequential)]
struct BITMAPINFO
{
    public BITMAPINFOHEADER bmiHeader;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1024)]
    public byte[] bmiColors;
}

肝心の BitMap 画像として保存するのはこんな感じ:

using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;

public static Bitmap GetCaptureImage(IntPtr handle)
{
    int result = 0;

    IntPtr desktopDC = IntPtr.Zero;
    IntPtr memoryDC = IntPtr.Zero;
    Bitmap bitmap = null;

    try
    {
        result = GetClientRect(handle, out RECT rect);

        if (result == 0) throw new NullReferenceException($"指定したハンドルのウィンドウ({handle})を発見することができませんでした。");

        result = MapWindowPoints(handle, IntPtr.Zero, ref rect, 2);

        if (result == 0) throw new NullReferenceException($"指定したハンドルのウィンドウ({handle})の座標空間の変換に失敗しました。");
        
        var tempRect = rect;

        desktopDC = GetWindowDC(IntPtr.Zero); // デスクトップの DC を取得
        
        var header = new BITMAPINFOHEADER()
        {
            biSize = (uint)Marshal.SizeOf(typeof(BITMAPINFOHEADER)),
            biWidth = tempRect.right - rect.left,
            biHeight = tempRect.bottom - rect.top,
            biPlanes = 1,
            biCompression = BitmapCompressionMode.BI_RGB,
            biBitCount = 24,
        };

        var info = new BITMAPINFO
        {
            bmiHeader = header,
        };

        var hBitmap = CreateDIBSection(desktopDC, ref info, DIB_RGB_COLORS, out _, IntPtr.Zero, 0);
        
        memoryDC = CreateCompatibleDC(desktopDC);

        var phBitmap = SelectObject(memoryDC, hBitmap);

        BitBlt(memoryDC, 0, 0, header.biWidth, header.biHeight, desktopDC, rect.left, rect.top, SRCCOPY);

        SelectObject(memoryDC, phBitmap);

        bitmap = Bitmap.FromHbitmap(hBitmap, IntPtr.Zero);
    }
    finally
    {
        if (desktopDC != IntPtr.Zero)
        {
            ReleaseDC(IntPtr.Zero, desktopDC);
        }

        if (memoryDC != IntPtr.Zero)
        {
            ReleaseDC(handle, memoryDC);
        }
    }

    if (bitmap == null) return null;

    return bitmap;
}

ウマ娘のウィンドウ画像を保存するコードは、こんな感じ:

var handle = WindowsAPI.GetHandle("UnityWndClass", "umamusume");
var bitmap = WindowsAPI.GetCaptureImage(handle);

bitmap?.Save("test.png", System.Drawing.Imaging.ImageFormat.Png);

f:id:shikaku_sh:20210607131300p:plain:h400
こんな感じでできた

ここから、UmaUmaCruise は Tesseract OCR ライブラリーを使って画像解析、テキストを抽出して、テキストを SimString で評価して、イベントタイトルの中で一番類似するものを表示しているのだと思います。かしこい!

Tesseract OCR は読み取り精度があまり完璧な感じではないので、SimString で一致してるっぽいな、でやったのがシンプルにえらいと思います。

Tesseract OCR の訓練済モデルもおそらく、GitHub 版じゃなくて、精度がよいらしい Ubuntu 版を使用してるっぽい。

画像の切り出し

これが一番らくだと思います。

// 切り出しテスト
var snipBitmap = bitmap?.Clone(new Rectangle(x, y, width, height), bitmap.PixelFormat);

x, y の位置から width, height の領域を切り出す。切り出した領域を拡大縮小するときに使うかもしれません。OCR はすこし画像サイズが大きいくらいが有利なことも。

OCR にかけるとき

たぶんこんな感じ:

using (var tesseract = new Tesseract.TesseractEngine(System.IO.Path.Combine(exePath, "tessdata"), "jpn"))
{
    tesseract.SetVariable("tessedit_char_whitelist", "0123456789");

    var pix = PixConverter.ToPix(bitmap);

    // ここで領域を指定することも可能
    var p = tesseract.Process(pix, PageSegMode.SingleLine or SingleWord);

    Console.WriteLine(p.GetText());
}

tesseract 自体は NuGet で。

既存の学習データは tesseract-ocr-jpn がよさそう。

サンプル

GitHub にサンプルコードを公開しています。

現在ステータスの合計値とかを表示したいなーと思ったんだけど、これは読み取り精度がカギになってくるので、言語データの再学習とかも必要になってきそう。根気がたりん!

参考