これは、OpenCV Advent Calendar 2017 19日目の記事です。関連記事は目次にまとめられています。
#1. はじめに
私は普段何も情報発信していないのですが,なぜかOpenCV Advent Calendarだけは毎年参加しているので,今年も恒例の誰得小ネタ記事を書くことにしました.(なお,本稿が公開される12月中はおそらくデスマーチ状態なので,約一ヵ月前に執筆しています.12月の俺,頑張れ.)
私は今まで動画データを扱う場合は連番の画像ファイルを使うことがほとんどで,動画ファイルを使う時も単純に読み書きするだけなので何も考えずにOpenCVのAPIを使うだけで済んでいたのですが,諸事情で最近ちょっと動画の読み込みをいろいろゴニョったので紹介します.
本稿は大きく分けて2つの話があって,2節ではGPU(NVidia)によるデコーダを使いたいと思って苦労した(けれども報われなかった)話を紹介します.3節ではOpenCVのcv::VideoCaputeが本来想定される動作をしない問題(つまりバグ)を紹介します.
なお,本記事で使ったOpenCVは3.3.0(あるいは3.3.1)で,それより前のバージョンでどうだったかは未確認です.環境はWindowsですが,Linux等でも同じだと思います.
追記:3.1節で挙げたバグはOpenCV-3.4.0では修正済です.
#2. GPU使おうとして苦労した話(報われない)
諸事情でmp4ファイル(h264やHEVC)を複数(けっこう沢山)同時に読み込む必要が生じたので,とりあえず普通にcv::VideoCaptureを沢山使って読み込んだりしたのですが,同時再生数が増えると(当然)遅くなるので,素人くさい感じで「GPU使ったら早くならないかな?」と思ったわけです.
なお,先述のように報われなかったネタなので,よっぽど暇な人以外はこの項はスルーしましょう.というか,後で気づいたのですが,そもそもGPUのリソースが限られているので,大量の動画をGPUで読もうという方針自体が根本的にダメですよね…
##2.1. cudacodecモジュール
OpenCVにはcudacodecモジュールなるものがあって,CUDA有りでビルドしていれば,CUDAを使った動画エンコード/デコードがサポートされます.そして,いつも頼りになるdandelion1124先生が昨年のOpenCV ACでcudacodecを解説されています.
そこで,さっそく試してみました,メッチャ速い.爆速.体感でビックリするくらい速い.「おぉ!!!」と思ったのですが,何かおかしい.ホントか?ホントにこんな早いのか?だって動画を表示していても再生時間があまりにも短いぞ?
…ということで,読み込めるフレーム数をチェックしたところ,同じ動画ファイルから読み込める総フレーム数がffmpegやcv::VideoCaptureに比べて1割とか2割とかしか読めていませんでした.…クソが!
ということで私の中ではcudacodecモジュールはいらない子と判定されました.これを3.3.0でやった後で3.3.1が出たのですが,もうcudacodecは外してビルドしています.(←もちろん,今後エンコーダを使う機会があれば考え直す可能性は充分あります.)
##2.2. ffmpegのcuvid
皆さんご存知のように,OpenCVのcv::VideoCaptureクラスは様々なフレームワークの上に乗っかっていて,その中でも重要な地位を占めるのがffmpegです.で,ffmpegもnvencやcuvidに対応しているので,OpenCVから直でGPUを使わなくても,ffmpeg経由でGPUエンコーダ/デコーダが使えれば良いのでは?ということで試してみました.
まず,Windowsの場合はffmpegのライセンス(LGPL)の問題や,そもそもmingw-gccでビルドする必要があったりした事情から,OpenCV自体のライブラリとは別途のDLL(例:opencv_ffmpeg330_64.dll)として実装されています.そして具体的には「OpenCVから配布されているビルド済みDLLをcmakeが裏でダウンロード&インストールして,OpenCVのvideoioモジュールがffmpeg呼び出しを実行する際は,そのDLLを動的に読み込むことでffmpegの機能を使う」という実装になっています.
上記で「mingw-gccでビルドする必要があったりした」と過去形で書きましたが,実はffmpegも最近のバージョンはVC++でビルドできなくはないです(ただしconfigureするためにbourne shell等のUNIX環境が必須なので,ビルド作業自体にはmingwやcygwinが必要なのと,いくつか修正が必要です).そこで,まずは手元でcuvidやnvencをenableしたffmpegをVC++でビルドして,そこからオレオレopencv_ffmpeg330_64.dllを作成しました.(この過程でビルドしたffmpeg自体は普通に使えるのですが,本稿の目的である「OpenCVからcuvidを使って動画デコードを高速化する」という意味では役立たずだったので,この辺りの手順は割愛します.)
次に,cv::VideoCaptureからどうやってffmpegのデコーダを指定するかという問題があります.動画を開くフレームワークはオプショナルに指定可能ですから,apiPreferenceにcv::CAP_FFMPEGを指定すればOKです.しかし,その先のdecoder(やencoder)の指定をする術はありません.そこで
847 AVCodec *codec = avcodec_find_decoder(enc->codec_id);
のあたりを汚い感じでゴニョって,環境変数を使ってdecoderを指定するということをやってみました.結果として,cuvid_h264やcuvid_hevcが動きました.やった!!
…しかし,普通にVideoCaptureを使った場合よりも数倍~数十倍遅くなりました.なんでやねん!!!!
(GPUとCPUの間のメモリコピーとかのオーバーヘッドかな,と想像していますが,ここで挫折してこれ以上は何も調べていません)
なお,同様にしてエンコーダも指定できると思うのですが,エンコーダは今のところ必要になっていないので何も試していません.
##2.3 GPU使おうとしたまとめ
。。゚ヽ(゚`Д´゚)ノ゚。ウワァァァン!!
#3. cv::VideoCaputeの挙動がおかしい話
cv::VideoCapture関連で,あからさまなバグが1つと,よくわからないけどきっとバグな問題を1つ見つけました.
誰かプルリクとかいう作業をやって本家に反映してくれたらいいんじゃないかなぁと思います(他力本願).
##3.1. apiPreferenceが無視される
諸事情でmjpegのAVI動画を読む必要があったのですが,2.1節と似た感じで,cv::VideoCaptureで読み込めるフレーム数がめっちゃ少ないという怪現象に遭遇しました.そのファイルを試しにffmpegコマンドを使って読むと普通に読めます.ということは「cv::CAP_FFMPEGを指定すれば解決」だと思いますよね?ね?ね?ところがですね,
cv::VidepCapture cap("video.avi");
も
cv::VidepCapture cap("video.avi", cv::CAP_FFMPEG);
も挙動が全く同じなのですよ.で,渋々ソースを見てみたら,
623 bool VideoCapture::open(const String& filename, int apiPreference)
624 {
625 CV_TRACE_FUNCTION();
626
627 if (isOpened()) release();
628 icap = IVideoCapture_create(filename);
629 if (!icap.empty())
630 return true;
631
632 cap.reset(cvCreateFileCaptureWithPreference(filename.c_str(), apiPreference));
633 return isOpened();
634 }
と,apiPreferenceの設定と無関係にIVideoCapture_create()が最優先で使われています.なんじゃこりゃぁぁぁぁ!!!
心を静めてから,念のためIVideoCapture_create()なる関数を見てみると,
530 static Ptr<IVideoCapture> IVideoCapture_create(const String& filename)
531 {
532 int domains[] =
533 {
534 CAP_ANY,
535 #ifdef HAVE_GPHOTO2
536 CAP_GPHOTO2,
537 #endif
538 #ifdef HAVE_MFX
539 CAP_INTEL_MFX,
540 #endif
541 -1, -1
542 };
543
544 // try every possibly installed camera API
545 for (int i = 0; domains[i] >= 0; i++)
546 {
547 Ptr<IVideoCapture> capture;
548
549 switch (domains[i])
550 {
551 case CAP_ANY:
552 capture = createMotionJpegCapture(filename);
553 break;
554 #ifdef HAVE_GPHOTO2
555 case CAP_GPHOTO2:
556 capture = createGPhoto2Capture(filename);
557 break;
558 #endif
559 #ifdef HAVE_MFX
560 case CAP_INTEL_MFX:
561 capture = makePtr<VideoCapture_IntelMFX>(filename);
562 break;
563 #endif
564 }
565
566 if (capture && capture->isOpened())
567 {
568 return capture;
569 }
570 }
571 // failed open a camera
572 return Ptr<IVideoCapture>();
573 }
となっていて,要するに
OpenCV自家製のmjpegデコーダ
↓
(もしavailableであれば)Gphoto2
↓
(もしavailableであれば)Intel MFX
の順に試しにファイルオープンしてみて,問題なく開けたらそれでいく,という処理になっているわけです.つまり,mjpegに関しては何が何でも自家製デコーダが使われるし,それ以外でもMFXとかが優先で使われちゃうわけです.なにこれバカじゃないの?
ということで,元のコードをなるべく触らずにバグフィクスするとこんな感じでしょうか.
530 static Ptr<IVideoCapture> IVideoCapture_create(const String& filename, int apiPreference)
531 {
532 int domains[] =
533 {
534 CAP_ANY,
535 #ifdef HAVE_GPHOTO2
536 CAP_GPHOTO2,
537 #endif
538 #ifdef HAVE_MFX
539 CAP_INTEL_MFX,
540 #endif
541 -1, -1
542 };
543
544 if(apiPreference != CAP_ANY)
545 {
546 domains[0] = apiPreference;
547 domains[1] =
548 domains[2] = -1;
549 }
550
551 // try every possibly installed camera API
552 for (int i = 0; domains[i] >= 0; i++)
553 {
554 Ptr<IVideoCapture> capture;
555
556 switch (domains[i])
557 {
558 case CAP_ANY:
559 case CAP_OPENCV_MJPEG:
560 capture = createMotionJpegCapture(filename);
561 break;
以下略
631 bool VideoCapture::open(const String& filename, int apiPreference)
632 {
633 CV_TRACE_FUNCTION();
634
635 if (isOpened()) release();
636 if(apiPreference == CAP_ANY
637 #ifdef HAVE_GPHOTO2
638 || apiPreference == CAP_GPHOTO2
639 #endif
640 #ifdef HAVE_MFX
641 || apiPreference == CAP_INTEL_MFX
642 #endif
643 || apiPreference == CAP_OPENCV_MJPEG)
644 icap = IVideoCapture_create(filename, apiPreference);
645 if (!icap.empty())
646 return true;
647
648 cap.reset(cvCreateFileCaptureWithPreference(filename.c_str(), apiPreference));
649 return isOpened();
650 }
これで,めでたくcv::CAP_FFMPEGでmjpegのaviを読めるようになりました.(なお,OpenCVのmjpegリーダが手元のmjpegをちゃんと読み込めない原因は調べていませんし,そういう意味では,私の手元のファイルの中身がAVIフォーマットに厳密に従っていないとかいう可能性も否定できません.)
##3.2. cv::VideoCaptureでフレームを飛ばすと異常に遅い場合がある
一般的に,動画を読む時にはファイルを開いて先頭から1フレームずつ順番に読むことが大半だと思います.しかし諸事情で,とあるmp4の動画を開いてから(最初のフレームすら読まずに)いきなり特定のフレームを読み出したい,という状況になりました.
cv::VideoCapture::set()を使えば,これを実現することができます.例えば
cv::VidepCapture cap("video.mp4", cv::CAP_FFMPEG);
cap.set(cv::CAP_PROP_POS_FRAMES, 20);
cv::Mat frame;
cap.read(frame);
のように,いきなり21フレーム目(注:フレーム番号はゼロスタート)を読むことができる…はずでした.ところが実際にこういうコードを書くと,set(cv::CAP_PROP_POS_FRAMES)で尋常ならざる時間がかかる場合がありました.先頭から順番に1フレームずつ読んで21フレーム目まで到達するよりもさらに数十~数百倍遅いような感じです.
この問題の真の原因はよくわからないですが,内部的な挙動を軽く調べた結果,fixtars社の方が書かれているめっちゃナイスなffmpegの解説の
また、シークした後は、必ずキーフレームからしかデコードできませんが、ファイルによっては、なぜか、キーフレームでないフレームが最初にデコードされて、avcodec_receive_frameで返ってくることがあります。このフレームは壊れているので、キーフレーム前のフレームは捨てるようにした方が安全です。(バグなのか仕様なのか不明)
と似たような感じの現象かな,という印象です.ちゃんと調べてないので,あくまでも「印象」ですが.
で,肝心の対処法ですが,CvCapture_FFMPEG::seek()の先頭で,
1196 void CvCapture_FFMPEG::seek(int64_t _frame_number)
1197 {
1198 _frame_number = std::min(_frame_number, get_total_frames());
1199 int delta = 16;
1200
1201 // if we have not grabbed a single frame before first seek, let's read the first frame
1202 // and get some valuable information during the process
1203 if( first_frame_number < 0 && get_total_frames() > 1 )
1204 grabFrame();
のようにgrabFrame()を1回だけ呼び出している部分(1204行目)を,
1196 void CvCapture_FFMPEG::seek(int64_t _frame_number)
1197 {
1198 _frame_number = std::min(_frame_number, get_total_frames());
1199 int delta = 16;
1200
1201 // if we have not grabbed a single frame before first seek, let's read the first frame
1202 // and get some valuable information during the process
1203 if( first_frame_number < 0 && get_total_frames() > 1 )
1204 while( first_frame_number < 0 ){
1205 grabFrame();
1206 }
1207
と,first_frame_numberがマイナスの間は繰り返すように変更したところ,手元では問題が解決しています.ただしffmpegをよく知らずに書いているので,これが本当に正しい対処かどうかはよくわかりません.
##3.3. cv::VideoCaputeの動作がおかしい問題まとめ
こんなに露骨にapiReferenceがガン無視されてるコード,なんで誰も気づかなかったんや?!(#^ω^)ピキピキ
#4. 全体のまとめ
ただでさえ動画ファイルは扱い慣れてないのに落とし穴が多すぎて,人生いろいろツラいです.