MicrosoftOCRで文字列抽出してみた

21世紀も5分の1以上が経過し、多くの場面でテキストの読み上げを見かけるようになってきました。
その一方でゲーム等では未だに読み上げ機能が対応していないことも多く、特に文字チャットでの交流時に名前を呼ばれても気づかない事も…

ちょうどゲーム配信等を始めるに当たって読み上げツールの整備が終わっていたこともあり、未経験分野のOCRの勉強も兼ねて読み上げソフトを作ってやろう!と思ったのがすべての始まりです。

割と長い戦いになってきたので忘備録代わりに少しずつ記事を残していこうと思います。

作業環境

主な作業環境は次の通りです(空きPCを使っているので少し古いです)
OS : Windows10
CPU : Corei5 9400F
GPU : Geforce GTX 1660
Tools : VS2022

まずはVS2022をインストールし、Windowsフォームでプロジェクトを生成します

対象ウインドウの選択

こちらを参考にキャプチャする対象を選択する機能を追加します

    public class EnumWindow
    {
        public interface IMenuItem
        {
            void OnClick(IntPtr hWnd);
        }

        public delegate bool EnumWindowsDelegate(IntPtr hWnd, IntPtr lparam);

        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        public extern static bool EnumWindows(EnumWindowsDelegate lpEnumFunc,
            IntPtr lparam);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern int GetWindowText(IntPtr hWnd,
            StringBuilder lpString, int nMaxCount);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern int GetWindowTextLength(IntPtr hWnd);

        [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern int GetClassName(IntPtr hWnd,
            StringBuilder lpClassName, int nMaxCount);

        [DllImport("user32")]
        private static extern bool IsWindowVisible(IntPtr hWnd);

        [DllImport("user32")]
        private static extern bool IsIconic(IntPtr hWnd);

        [DllImport("user32.dll")]
        private static extern int GetWindowRect(IntPtr hwnd, ref RECT lpRect);

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

        public IntPtr SelectWindowHandle { get; private set; }
        public ToolStripMenuItem WindowToolStripMenuItem { get; private set; }

        private IMenuItem _iMenuItem;

        public EnumWindow(ToolStripMenuItem toolStripMenuItem, IMenuItem iMenuItem)
        {
            SelectWindowHandle = IntPtr.Zero;
            WindowToolStripMenuItem = toolStripMenuItem;
            _iMenuItem = iMenuItem;
        }

        public void Refresh()
        {
            WindowToolStripMenuItem.DropDownItems.Clear();
            EnumWindows(new EnumWindowsDelegate(EnumWindowCallBack), IntPtr.Zero);
        }

        private bool EnumWindowCallBack(IntPtr hWnd, IntPtr lparam)
        {
            //ウィンドウのタイトルの長さを取得する
            int textLen = GetWindowTextLength(hWnd);
            if (0 < textLen)
            {
                //ウィンドウのタイトルを取得する
                StringBuilder tsb = new StringBuilder(textLen + 1);
                GetWindowText(hWnd, tsb, tsb.Capacity);

                //ウィンドウのクラス名を取得する
                StringBuilder csb = new StringBuilder(256);
                GetClassName(hWnd, csb, csb.Capacity);

                //結果を表示する

                // ウィンドウのクラス名が"Progman"なら無視
                if (csb.ToString() == "Progman")
                    return true;

                // 可視ウインドウでないなら無視
                if (!IsWindowVisible(hWnd))
                    return true;

                // 最小化されているウインドウは無視
                if (IsIconic(hWnd))
                    return true;

                // ウインドウサイズがゼロなら無視
                RECT winRect = new RECT();
                GetWindowRect(hWnd, ref winRect);
                if (winRect.top == winRect.bottom || winRect.right == winRect.left)
                    return true;

                string windowTitle = tsb.ToString();

                ToolStripMenuItem item = new ToolStripMenuItem();
                item.Text = windowTitle;
                item.Tag = hWnd;
                item.Click += Item_Click;
                WindowToolStripMenuItem.DropDownItems.Add(item);
            }
            return true;
        }

        private void Item_Click(object? sender, EventArgs e)
        {
            if(sender is ToolStripMenuItem)
            {
                ToolStripMenuItem item = (ToolStripMenuItem)sender;
                SelectWindowHandle = (IntPtr)item.Tag;
                _iMenuItem?.OnClick(SelectWindowHandle);
            }
        }
    }

ウインドウキャプチャ

ウインドウ全体のキャプチャ

取得したハンドルを使ってPrintWindowで画面キャプチャーします(参考

[DllImport("user32.dll")]
private static extern bool GetWindowRect(IntPtr hwnd, out RECT lpRect);
 
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
    public int left;
    public int top;
    public int right;
    public int bottom;
}
[System.Runtime.InteropServices.DllImport("User32.dll")]
private extern static bool PrintWindow(IntPtr hwnd, IntPtr hDC, uint nFlags);
 
private void Button_Click(object sender, RoutedEventArgs e)
{
    IntPtr handle = (IntPtr)0x33162; //取得したいWindowのハンドル
 
    //ウィンドウサイズ取得
    RECT rect;
    bool flag = GetWindowRect(handle, out rect);
 
    int width = rect.right - rect.left;
    int height = rect.bottom - rect.top;
 
    //ウィンドウをキャプチャする
    Bitmap img = new Bitmap(width, height);
    Graphics memg = Graphics.FromImage(img);
    IntPtr dc = memg.GetHdc();
    PrintWindow(handle, dc, 0);
    memg.ReleaseHdc(dc);
    memg.Dispose();
 }

一部だけをキャプチャ

画面の一部だけ欲しい場合はBitBltを使用します。
下記はテキストが表示される部分だけ取り出すソースです。

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

        [DllImport("gdi32.dll")]
        private static extern int BitBlt(IntPtr hDestDC, int x, int y, int nWidth, int nHeight, IntPtr hSrcDC, int xSrc, int ySrc, int dwRop);

        [DllImport("user32.dll")]
        private static extern IntPtr ReleaseDC(IntPtr hwnd, IntPtr hdc);

        private const int SRCCOPY = 13369376;

        const int width = 966;     //  画面全体 1002
        const int height = 448;    //  画面全体 1842 
        const int src_x = 20;
        const int src_y = 1220;
        public Bitmap TextCapture(IntPtr windowHandle)
        {
            var winDC = GetWindowDC(windowHandle);

            //ウィンドウの大きさを取得
            GetWindowRect(windowHandle, out var winRect);

            //Bitmapの作成
            // Windowのフレーム部分全域を取りたい場合は↓を使います
//            Bitmap bmp = new Bitmap(winRect.right - winRect.left, winRect.bottom - winRect.top);
            // 一部分だけ取り出すのに使うキャンバス
            Bitmap bmp = new Bitmap(width, height);

            //Graphicsの作成
            var g = Graphics.FromImage(bmp);

            //Graphicsのデバイスコンテキストを取得
            var hDC = g.GetHdc();

            //Bitmapに画像をコピーする
            BitBlt(hDC, 0, 0, width, height, winDC, src_x, src_y, SRCCOPY);

            g.ReleaseHdc(hDC);
            g.Dispose();
            ReleaseDC(windowHandle, winDC);

            return bmp;
        }

MicrosoftOCRに画像を渡す

OCR自体は
using Windows.Media.Ocr;
を定義するだけで簡単に使うことができます。

ただしSoftwareBitmap形式に変更する必要があるため、一旦BitmapからInMemoryRandomAccessStreamに格納しなおします。

using Windows.Media.Ocr;

        public static async Task<IReadOnlyList<OcrLine>> AnalizeTask(Bitmap image)
        {
            using (var stream = new Windows.Storage.Streams.InMemoryRandomAccessStream())
            {
                image.Save(stream.AsStream(), ImageFormat.Bmp);//choose the specific image format by your own bitmap source
                var decoder = await Windows.Graphics.Imaging.BitmapDecoder.CreateAsync(stream);
                var softwareBitmap = await decoder.GetSoftwareBitmapAsync();
                var ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages();
                var result = await ocrEngine.RecognizeAsync(softwareBitmap);
                Console.WriteLine(result.Text);
                return result.Lines;
            }
        }

これで画像から文字列を複数ブロックに分けて格納した結果を受け取ることができます
(result.Textで連結した文字を取得できます)

実際にOCRに関係するのはこの2行だけです

var ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages();
var result = await ocrEngine.RecognizeAsync(softwareBitmap);

文字列の認識制度はそれほど悪くはないのですが

  • 色味によっては文字を誤読しやすい

  • 絵文字を文字として強引に判定する

  • 文字色が違うと別の段落として扱われることがある

  • 解析結果が格納される順番が変わることがある(必ず左上からではない)

  • カスタマイズがほぼできない(追加学習ができない)

など、OCREngineに引き渡す画像に工夫が必要になってきます。
特に読み上げ機能としては下2つは致命的で、オープンソースのOCRライブラリ「Tesseract」の導入を検討しています。
(格納順が異なるのはIRandomAccessStreamが必須なのでおそらく仕様?)

この記事が気に入ったらサポートをしてみませんか?