結果だけでなく過程も見てください

たい焼きさんの日々の奮闘を綴る日記です。

AutoIt+OpenCVでデスクトップから任意の画像を曖昧検索(テンプレートマッチング)する

AutoItで画像の曖昧検索がしたい

UWSCにはchkimgという画像検索関数が標準で用意されていました。また有志の方がchkimgxという画像の曖昧検索を行う外部ライブラリを作成されていたようですが、アップローダーなどでのみ配布されていたようで現在では入手が難しくなっています。

一方、AutoItには画像検索をする標準関数が存在せず、searchImageという外部ライブラリを使う必要があるようです。
しかしこのsearchImage、2008年頃までしかメンテされていないようで、自分の環境でもいまいちちゃんと動いてくれず、単にdllの配置場所を間違えてたかもしれない件は置いといて、まぁ自分で画像マッチング出来た方が曖昧さ加減を調整出来たり、判定方法を自由にカスタマイズ出来たり、今後のことを考えると自作した方がいいかなぁなんて思いまして、OpenCVを触ってみることにしました。

こちらの環境です。

OS Windows 10
コンパイラ Visual Studio Community 2017
CMake 3.12.1(64-bit版)
OpenCV, OpenCV contrib 3.4.3
AutoIt v3.3.14.5

Visual Studio Community 2017のインストール

なにはともあれ、コンパイラをインストールします。

ダウンロード | IDE、Code、Team Foundation Server | Visual Studio

CMakeのインストール

OpenCVのビルドには、CMakeも必要です。インストールします。
ご自分の環境合わせてインストールしてください。自分の場合は「cmake-3.12.2-win64-x64.msi」でした。
https://cmake.org/download/

CMakeインストール中に「Add CMake to the system PATH for all users」を選択して、環境変数PathにCMakeのパスを追加してください。

↑を忘れてしまった場合は、手動で環境変数Pathに以下のCMakeのパスを追加してください。

C:\Program Files\CMake\bin

OpenCV, OpenCV contribのビルド

まずは以下からソースコードをダウンロードしましょう。

OpenCV

Releases · opencv/opencv · GitHub

opencv-3.4.3-vc14_vc15.exe」などのリンクをクリックしてダウンロードします。

OpenCV contrib

Releases · opencv/opencv_contrib · GitHub

「zip」と書かれたリンクをクリックしてダウンロードします。

上記ページのopencvのファイル名(例:opencv-3.4.3-vc14_vc15.exe)にあるvc14とかvc15とかが、
Visual Studioのバージョンに対応しているので、今後バージョンが上がった場合は注意して確認してみてください。

vc14 Visual Studio 2015
vc15 Visual Studio 2017

OpenCVのビルド

ここでは例として、「C:\temp」に上記ファイルをダウンロードします。

opencv-3.4.3-vc14_vc15.exe」をダブルクリックすると「opencv」というフォルダ名で展開されます。
opencv_contrib-3.4.3.zip」をダブルクリックすると「opencv_contrib-3.4.3」というフォルダ名で展開されます。

[スタート]メニューからcmake-guiを探して起動します。
[Where is the source code]に「C:/temp/opencv/sources」を指定します。
[Where to build the binaries]に「C:/temp/opencv/build」を指定します。
手動でパスを入力する場合、パス区切り文字は「/」にしてください
入力したら[Configure]ボタンを押下します。
f:id:taiyakisun:20180912000136p:plain

今回は、AutoItをデフォルト32-bitで動作させているので、OpenCVも32-bitでビルドします
そのため今回は「Win64が付いていない」プロジェクトを選択します。
f:id:taiyakisun:20180912000140p:plain

再びCMake-guiの画面に戻ります。
32-bit版の場合、cuda関連のビルドが通らないという噂がありますので、「WITH_CUDA」のチェックを外します(最近のは最初から外れてるかも)
また「OPENCV_EXTRA_MODULES_PATH」に「C:/temp/opencv_contrib-3.4.3/modules」を指定します。
何度も言いますが、パス区切り文字は「/」にしてください
f:id:taiyakisun:20180911221717p:plain
f:id:taiyakisun:20180911221720p:plain

コンソールに「Configuring done」が出力されたら成功です。
続いて「Generate」ボタンを押下します。
f:id:taiyakisun:20180911222335p:plain

C:\temp\opencv\build直下にOpenCV.slnが出来ているので、Visual Studioで開きます。
INSTALLプロジェクトをReleaseビルドしてください。

出力コンソールにこんな感じの表示が出れば成功です。

========== すべてリビルド: 134 正常終了、0 失敗、0 スキップ ==========

AutoItで使用するテンプレートマッチングを行うDLLの作成

Visual Studioで新規プロジェクトを作成します。
DLLのプロジェクトを作るか、プロジェクトの[構成プロパティ]→[全般]→[構成の種類]を「ダイナミック ライブラリ(.dll)」などにしてください。

同じプロジェクトのプロパティページの[C/C++]→[全般]→[追加のインクルードディレクトリ]に以下を入れます。

C:\temp\opencv\build\install\include

[リンカー]→[全般]→[追加のライブラリディレクトリ]に以下を入れます。

C:\temp\opencv\build\install\x86\vc15\lib
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/imgproc/imgproc.hpp>

#include <map>
using namespace std;

#include <Windows.h>

/**
* @brief ウィンドウハンドルhwndのイメージcv::Matを作成する。
* @param [in] hwnd	     ウィンドウハンドル。デスクトップを対象にしたい場合、GetDesktopWindow()の戻り値を渡すべし。
* @param [in] nPosLeft       検索対象となる左上のx座標
* @param [in] nPosTop        検索対象となる左上のy座標
* @param [in] nDestWidth     検索対象となる範囲のnPosLeftからの幅
* @param [in] nDestHeight    検索対象となる範囲のnPosTopからの高さ
*/
cv::Mat hwnd2mat(HWND hwnd, int nPosLeft, int nPosTop, int nDestWidth, int nDestHeight)
{
    HDC hwindowDC = GetDC(hwnd);
    HDC hwindowCompatibleDC = CreateCompatibleDC(hwindowDC);

    HBITMAP           hbwindow = NULL;
    cv::Mat           src;
    BITMAPINFOHEADER  bi;

    SetStretchBltMode(hwindowCompatibleDC, COLORONCOLOR);

    RECT windowsize;    // get the height and width of the screen
    GetClientRect(hwnd, &windowsize);

    // ここはデスクトップの幅と高さ
    int srcheight = windowsize.bottom;
    int srcwidth  = windowsize.right;

    src.create(nDestHeight, nDestWidth, CV_8UC3);

    // create a bitmap
    hbwindow = CreateCompatibleBitmap(hwindowDC, nDestWidth, nDestHeight);
    if (hbwindow == NULL)
    {
        src.data = NULL;
        return src;
    }

    bi.biSize = sizeof(BITMAPINFOHEADER);    // http://msdn.microsoft.com/en-us/library/windows/window/dd183402%28v=vs.85%29.aspx
    bi.biWidth         = nDestWidth;
    bi.biHeight        = -nDestHeight;
    bi.biPlanes        = 1;
    bi.biBitCount      = 24;
    bi.biCompression   = BI_RGB;
    bi.biSizeImage     = 0;
    bi.biXPelsPerMeter = 0;
    bi.biYPelsPerMeter = 0;
    bi.biClrUsed       = 0;
    bi.biClrImportant  = 0;

    // use the previously created device context with the bitmap
    SelectObject(hwindowCompatibleDC, hbwindow);

    // copy from the window device context to the bitmap device context
    StretchBlt(hwindowCompatibleDC, 0, 0, nDestWidth, nDestHeight, hwindowDC, nPosLeft, nPosTop, nDestWidth, nDestHeight, SRCCOPY);	// change SRCCOPY to NOTSRCCOPY for wacky colors !
    GetDIBits(hwindowCompatibleDC, hbwindow, 0, nDestHeight, src.data, (BITMAPINFO *)&bi, DIB_RGB_COLORS);		// copy from hwindowCompatibleDC to hbwindow

     // avoid memory leak
    if (hbwindow != NULL)
    {
        DeleteObject(hbwindow);
        hbwindow = NULL;
    }

    if (hwindowCompatibleDC != NULL)
    {
        DeleteDC(hwindowCompatibleDC);
        hwindowCompatibleDC = NULL;
    }

    if (hwindowDC != NULL)
    {
        ReleaseDC(hwnd, hwindowDC);
        hwindowDC = NULL;
    }

    return src;
}

extern "C"
{
    /**
     * @brief 教師画像が指定されたデスクトップ範囲に存在するかあいまい検索(テンプレートマッチング)する
     * @param [in]  pszFilePath    教師(テンプレート)画像のフルパス
     * @param [in]  nPosLeft       検索対象となるデスクトップの左上のx座標
     * @param [in]  nPosTop        検索対象となるデスクトップの左上のy座標
     * @param [in]  nDestWidth     検索対象となる範囲のnPosLeftからの幅
     *                             4の倍数でないと必ずマッチングしないため、4の倍数に増やす方向で調整をする。
     * @param [in]  nDestHeight    検索対象となる範囲のnPosTopからの高さ
     * @param [out] pDetectedPosX  検知した場合のX座標。検知してない場合なにも入らない。
     * @param [out] pDetectedPosY  検知した場合のY座標。検知してない場合なにも入らない。
     * @retval 0  正常終了(見つかった)
     * @retval 1  正常終了(見つからなかった)
     * @retval 10 教師画像ロード失敗
     * @retval 11 デスクトップ情報確保失敗
     * @retval 12 テンプレートマッチングでエラー
     */
    DllExport int searchImg(const char*  pszFilePath,
                            int          nPosLeft, 
                            int          nPosTop, 
                            int          nDestWidth, 
                            int          nDestHeight, 
                            int*         pDetectedPosX, 
                            int*         pDetectedPosY)
    {
        int nActualWidth = nDestWidth + (4 - (nDestWidth % 4));   // 4の倍数に増やす方向で合わせる

        cv::Mat template_image = cv::imread(pszFilePath);
        if (template_image.data == NULL)
        {
            return 10;
        }

        cv::Mat srcDesktop = hwnd2mat(GetDesktopWindow(), nPosLeft, nPosTop, nActualWidth, nDestHeight);
        if (srcDesktop.data == NULL)
        {
            return 11;
        }

        cv::Mat result;
        try
        {
            cv::matchTemplate(srcDesktop, template_image, result, cv::TM_CCORR_NORMED);
        }
        catch (cv::Exception ex)
        {
            return 12;
        }

        // Multiple results

        // 最もそれっぽい画像選ぶためのMap。同じ値の場合最初に検知したものを優先するため、insert自体しないこととする。
        // 比較をgreator<float>にすることにより降順に並ぶ。
        map<float, cv::Point, greater<float> > dectetedPointMap;

        const float threshold = 0.981f;     // TODO:ここも指定できるようにしたい

        for (int y = 0; y < result.rows; ++y)
        {
            for (int x = 0; x < result.cols; ++x)
            {
                if (result.at<float>(y, x) > threshold)
                {
                    if (dectetedPointMap.find(result.at<float>(y, x)) != dectetedPointMap.end())
                    {
                        // すでに同じ値がある場合、最初に見つかったものを採用するため、mapには追加しない
                        continue;
                    }

                    dectetedPointMap.insert(pair<float, cv::Point>(result.at<float>(y, x), cv::Point(x,y)));
                }
            }
        }

        
        if (dectetedPointMap.empty())
        {
            // 見つからなかった
            return 1;
        }
        else
        {
            // 最も似ている点(複数ある場合は最初に見つかった点)を採用する
            *pDetectedPosX = (dectetedPointMap.cbegin()->second.x + template_image.cols / 2);
            *pDetectedPosY = (dectetedPointMap.cbegin()->second.y + template_image.rows / 2);

            return 0;
        }
    }
}

とりあえず複数見つかった場合は以下のルールに従って、単一の結果を返すようにします。返すデータはマッチングした画像の中心座標です。

  • もっとも類似度の高いもの
  • 最初に見つかったもの

ビルドしてdllを作成します。
includeやlibの設定が間違っていなければビルドが通るはず(通らなかったらごめんなさい)。

AutoItからDLLを呼び出す

AutoItのインストール手順などは省略します。
適当にAutoItのファイルを作成し、以下のDLLをAutoItのファイルと同じ位置に配置します。

上記で作成した自作のDLL
opencv_ccalib342.dll
opencv_core342.dll
opencv_highgui342.dll
opencv_imgcodecs342.dll
opencv_imgproc342.dll

AutoItのコードは以下です。
まずはDLL関数のラッパー関数。

; @param [in] $hDll	   DllOpen()済のハンドル。ここファイル名を渡すこともできるが、そうすると毎回オープン/クローズ
;			           するせい(?)なのか、連続して呼び出すとアプリケーションエラーが発生するので、必ずハンドルを渡すこと。
; @param [in] $x       デスクトップ上のx座標
; @param [in] $y       デスクトップ上のy座標
; @param [in] $width   $xからの幅
; @param [in] $height  $yからの幅
; @retval -1 DllCallでエラー発生
; @retval 0  イメージが正常に判定し、見つかった。イメージの中心座標が$detectedXと$detectedYに格納される。
; @retval 1  イメージを正常に判定し、見つからなかった
; @retval 10 イメージのファイルが見つからなかった
; @retval 11 デスクトップ画面の取得で失敗
; @retval 12 マッチング処理でエラー発生
Func searchImageCore($hDll, $imgFileName, $x, $y, $width, $height, ByRef $detectedX, ByRef $detectedY)

	Dim $ret = DllCall($hDll, _
			"int:cdecl", _
			"searchImg", _
			"str", $imgFileName, _
			"int", $x, _
			"int", $y, _
			"int", $width, _
			"int", $height, _
			"int*", $detectedX, _
			"int*", $detectedY)
	If (@error <> 0) Then
		ConsoleWrite( "DllCall error!." & @LF )
		MsgBox(0, "error", "DllCall returned " & @error & "  imgFile=" & $imgFileName & @CRLF)
		Return -1
	EndIf

	; 0    -> 見つかった
	; 1    -> 見つからなかった
	; その他 -> エラー発生

	; 見つかった場合だけ、値を格納して、戻り値はコール元にそのまま返す
	If ($ret[0] = 0) Then

		$detectedX = $x
		$detectedY = $y

	EndIf

	Return $ret[0]

EndFunc   ;==>searchImageCore

次に、ラッパー関数を呼び出すAutoItコード。

Global $g_hDll = DllOpen( "<自作のDLLファイル名>.dll" )

Local detectedX = 0
Local detectedY = 0

Local $ret = searchImageCore($g_hDll, _
	                "abc.bmp", _
			0, _
			0, _
			1920, _
			1080, _
			$detectedX, _
			$detectedY)
If ($ret = 0) Then
	; 見つかった
	ConsoleWrite("searchImage detected!  dx=" & $detectedX & "  dy=" & $detectedY & @CRLF)
Else
	; 普通に見つからなかったか、なんらかのエラー発生。見つからなかったとして帰る。
	If ($ret <> 1) Then
		; 普通に見つからない場合以外はログを出しておく
		ConsoleWriteError("[Error] searchImage returned " & $ret & "  imgFile=" & $imgFile & @CRLF)
	EndIf
EndIf

思ったより長くなってしまった。あぁ疲れた…。