はじめに
これは、OpenCV Advent Calendar 2018 2日目の記事です。関連記事は目次にまとめられています。なお、本記事は筆者個人の意見であり、筆者の所属組織とは無関係です。
cv::imshow
とcv::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);
}
これを実行するとどうなるか、こんなウィンドウが表示されます。
あれ?グレーのウィンドウが表示されるだけです。ただしく画像が表示されていません。
実はOpenCVの実装では、確実にウィンドウに画像を表示させるためにはcv::waitKey
を呼ぶ必要があるのです。
cv::waitKey
について
さて、cv::waitKey
ですが、引数delay
を取るAPIです。
このAPIは、公式ドキュメントを見ると、delay
が0
より大きければdelay[ms]
だけ待ち、それ以外(delay
が0
か負の数)の場合はキー入力があるまで待ちます。
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
の前後でタイムスタンプを取得して経過時間を測定しています。この時の結果がこちらです。
横軸に引数に指定した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;
ポイントはいくつかあって、
- メインスレッドから
image
、frameCounter
、loopFlag
、status
のポインタを共有する - ウィンドウの生成から破棄までメインループでなく、別スレッドで管理する
- メインループから渡された画像はカウンタが更新された時にだけ表示する
- キー入力に対するリアクションは押された一瞬だけ対応しようとすると難しいので、最後に押されたキーを保持しておく(何も押されていない時に返される-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
の中身を適切に処理することで最低限のインタラクションも可能です。簡単にまとめると、下記の図のようになります。
と言う訳で、これから明日の分の記事を書きます。
-
なお、厳密な話をすれば2018年7月にリリースされた3.4.2には既に取り込まれていたので、3.4.2とそれ以降の3.4系列、並びに4.0系列でこの恩恵に預かれます ↩