OpenCV
ffmpeg
OpenCV3
OpenCVDay 19

cv::VideoCapture小ネタ(超マイナーなバグ情報)

これは、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)の指定をする術はありません.そこで

opencv-3.3.1/modules/videoio/src/cap_ffmpeg_impl.hpp
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);

も挙動が全く同じなのですよ.で,渋々ソースを見てみたら,

opencv-3.3.1/modules/videoio/src/cap.cpp
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()なる関数を見てみると,

opencv-3.3.1/modules/videoio/src/cap.cpp
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とかが優先で使われちゃうわけです.なにこれバカじゃないの?

ということで,元のコードをなるべく触らずにバグフィクスするとこんな感じでしょうか.

opencv-3.3.1/modules/videoio/src/cap.cpp
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;
以下略
opencv-3.3.1/modules/videoio/src/cap.cpp
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()の先頭で,

opencv-3.3.1/modules/videoio/src/cap_ffmpeg_impl.hpp
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行目)を,

opencv-3.3.1/modules/videoio/src/cap_ffmpeg_impl.hpp
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. 全体のまとめ

ただでさえ動画ファイルは扱い慣れてないのに落とし穴が多すぎて,人生いろいろツラいです.


明日はn_uさんRoBoHoNネタで何か書かれるようです.楽しみですね!