LoginSignup
35
18

More than 5 years have passed since last update.

debunking the waitKeyは遅いよね

Last updated at Posted at 2018-12-01

はじめに

これは、OpenCV Advent Calendar 2018 2日目の記事です。関連記事は目次にまとめられています。なお、本記事は筆者個人の意見であり、筆者の所属組織とは無関係です。

cv::imshowcv::waitKeyについて

それぞれ、Windows10、OpenCV 4.0 を基準に説明します。みんな大好きOpenCV 4.0。OpenCV 4.0 万歳!

画像を10枚表示するループ文を考えましょう。


std::vector<cv::Mat> result;
function(result); // ここで10枚分の結果が格納される
for ( auto&& it : result )
{
    cv::imshow("window", it);
}

これを実行するとどうなるか、こんなウィンドウが表示されます。

Screenshot 2018-11-26 19.17.35.png

あれ?グレーのウィンドウが表示されるだけです。ただしく画像が表示されていません。
実はOpenCVの実装では、確実にウィンドウに画像を表示させるためにはcv::waitKeyを呼ぶ必要があるのです。

cv::waitKeyについて

さて、cv::waitKeyですが、引数delayを取るAPIです。
このAPIは、公式ドキュメントを見ると、delay0より大きければdelay[ms]だけ待ち、それ以外(delay0か負の数)の場合はキー入力があるまで待ちます。

The function waitKey waits for a key event infinitely (when delay≤0 ) or for delay milliseconds, when it is positive

通常、研究やプロトタイプを作成してる場合は、この挙動でなんら問題ないと思います。ところがWindows版のOpenCVには、短い待ち時間を指定した時の挙動に罠が潜んでいたのです。

cv::waitKeyに潜んでいた罠

結論から言うと、cv::waitKey内部で使っているAPIGetTickCountの分解能が粗いため、1ms待つつもりのコードでも、実際には15msぐらい待つ、という罠です。私が見つけた中で、分かり易いと思った記事を紹介します。OpenCV Advent Calendar 2016にも寄稿してくれたざわーくらうとさんの記事です。

なんと,cv::waitKey()はせいぜい15ms程度の精度しかないことが分かりました.

この記事にかかれている通り、従来のOpenCVのWindows版ではcv::waitKey内部でGetTickCountを呼んでおり、こいつの分解能に足を引っ張られる形でcv::waitKeyの分解能は15ms程度しかなかったのです。

OpenCV 4.0

しかし、2018年、ようやくその実装に終止符が打たれます

実際にOpenCV 4.0 を使って、実験をしてみましょう。


for( int d = 1;d < 100;d++ )
{
    imshow(windowName, image);
    int64 begin = getTickCount();
    waitKey(d);
    int64 end = getTickCount();
    std::cout << d << '\t' << ((end - begin) * 1000) / getTickFrequency() << "[ms]" << std::endl;
}

ざわーくらうとさんの記事と同様の実験をしてみます。waitKeyの前後でタイムスタンプを取得して経過時間を測定しています。この時の結果がこちらです。

measurementWaitKey.png

横軸に引数に指定したdを、縦軸に実際に待った時間をプロットしてあります。
待ち時間dに対して1回ずつしか測定してないので外れ値も含まれていますが、基本的には素直に、指定した時間の線形の待ち時間を提供してくれます。あのひどい挙動はすでに無いのです

どこでこの改善が入ったのでしょうか?それは今年の6月にマージされたPR#11714で解決されています。

highgui(win32): improve waitKey() timeout condition by alalek · Pull Request #11714 · opencv/opencv

という訳で、Webを調べると、OpenCVのwaitKeyは遅い、という記事が見つかるし、確かに遅かったのですが、4.0系列ではその挙動は改善されています1

おわりに

実は「cv::waitKey遅いでしょ、ほら遅いんだよ」というn番煎じの記事を書いてお茶を濁すつもりだった(n番煎じだけに)のですが、実験してみたら改善されていて驚きました。なのでタイトルも、かの有名な「遅いって言うな!」な論文のタイトルをもじりました。

なお、waitKeyが高精度に待てるようになったからと言って、imshowで100FPS、200FPSの表示ができるようになったわけではないことに注意してください。

筆者は以下の環境で調べました。

  • OpenCV 4.0.0
  • Windows 10 64bit
  • Visual Studio Community 2015 (Update 3)
  • CMake 3.11.0
  • Core i7 8650U

明日の投稿も私の予定で、タイトルは「OpenCVと環境変数(仮)」です

追記

かきあげてホッとしていたら、1日目担当の @dandelion1124 先生に煽られたので、追記します

とてつもなくテクい話が読めそうなのですごく楽しみです!!

そうだったよ・・・@dandelion1124 先生はこう言う時に煽ってくる人だった!

さて、メインの文章内ではループの中でwaitKeyを使う場合に、OpenCV 4.0からは時間の分解能が細かくなることを示しました。
しかしメインループ内で、msオーダの処理を回してると、このwaitKeyのせいで数割パフォーマンスが遅くなります。純粋にパフォーマンスを追求するならwaitKeyをループ内に入れないのですが、現実問題ループの中の経過をimshowで見せることが必要になる場合が多々あります。

という訳で、両立させる場合はスレッドを分けるのが必須となります。

ここではWindowsの実装をもとに実装例を紹介します。

unsigned __stdcall imageShowThread(void *parameter)
{
    messageBox* p = (messageBox*)parameter;
    cv::namedWindow(WINDOW_NAME);
    volatile int counter = *(p->frameCounter);
    while (p->loopFlag)
    {
        if (counter != *(p->frameCounter))
        {
            cv::imshow(WINDOW_NAME, *(p->image));
            counter = *(p->frameCounter);
        }
        int key = cv::waitKey(1);
        switch(key)
        {
        case 0:
            *(p->status) = 0;
            break;
        case 'q':
            *(p->status) = -1;
            *(p->loopFlag) = 0;
            break;
        default:
            break;
        }
    }
    cv::destroyWindow(WINDOW_NAME);
    return 0;
}

このようなコールバック関数を用意します。なお、messageBox構造体には以下のようにポインタを保持します。

struct _messageBox
{
    volatile cv::Mat *image
    volatile unsigned int *frameCounter;
    int *loopFlag;
    int *status;
};
typedef struct _messageBox messageBox;

ポイントはいくつかあって、

  • メインスレッドからimageframeCounterloopFlagstatusのポインタを共有する
  • ウィンドウの生成から破棄までメインループでなく、別スレッドで管理する
  • メインループから渡された画像はカウンタが更新された時にだけ表示する
  • キー入力に対するリアクションは押された一瞬だけ対応しようとすると難しいので、最後に押されたキーを保持しておく(何も押されていない時に返される-1を無視する)

と、非同期処理の最低限のポイントは抑えておきましょう。

でこれをメインスレッドから_beginthreadexでcallします

    cv::Mat gImage;
    static int gStatus= 0;
    static int gCounter = 0;
    static int loopFlag = 1;
    static messageBox threadMessage;
    threadMessage.loopFlag = &loopFlag;
    threadMessage.frameCounter= &gCounter;
    threadMessage.inputKey = &gStatus;
    threadMessage.image = &gImage;
    DWORD thResultId;
    HANDLE hThResult = (HANDLE)_beginthreadex(NULL, 0, imageShowThread, &threadMessage, 0, (unsigned int *)&thResultId);
    if (hThResult == 0)
    {
        std::cerr << "Failed to create result show thread" << std::endl;
        return -1;
    }

またメインスレッドでは

  • 結果をgImageに書き込み、書き込む度にgCounterをインクリメントする
  • statusに合わせて適切なリアクションを設定する
while(loopFlag)
{
    int status = gStatus;
    mainProcess(&gImage, status); // ここで結果をgImageに書き込む
    gCounter++;                   //  カウンタをインクリメントして画像を表示するトリガを入れる

}

こうすることで、メインループのオーバーヘッダを最低限にしつつ、別スレッドでウィンドウを表示できます。また、gStatusの中身を適切に処理することで最低限のインタラクションも可能です。簡単にまとめると、下記の図のようになります。

asyncThread.png

と言う訳で、これから明日の分の記事を書きます。


  1. なお、厳密な話をすれば2018年7月にリリースされた3.4.2には既に取り込まれていたので、3.4.2とそれ以降の3.4系列、並びに4.0系列でこの恩恵に預かれます 

35
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
18