為什麼 OpenCV 計算的影片 FPS 是錯的

語言: CN / TW / HK

作者 | 王偉、劉一卓

導讀

網路直播功能作為一項網際網路基本能力已經越來越重要,手機中的直播功能也越來越完善,電商直播、新聞直播、娛樂直播等多種直播型別為使用者提供了豐富的直播內容。

隨著直播的普及,為使用者提供極速、流暢的直播觀看體驗我們有一個平臺來週期性的對線上的直播流資料進行某些檢測,例如黑/白屏檢測、靜態畫面檢測……

在檢測中,我們會根據提取到的直播流的幀率來預估要計算的幀數量,例如如果要檢測 5s 的直播流,而該直播流的幀率為 20 fps,需要計算的幀數量則為 100。忽然有一天,我們發現,平臺開始大面積的超時,之前只需要 2s 就能完成的計算,現在卻需要 30+ 分鐘。

查了之後,我們發現,之所以計算超時是因為 OpenCV 計算的幀率為 2000,從而導致需要計算的幀數量從之前的 100 變為了 10000,進而引起了計算超時。

全文9288字,預計閱讀時間24分鐘。

01 OpenCV 如何計算幀率

這個問題的具體描述可以參見 OpenCV Issues 21006。該問題的模擬直播流片段 test.ts 可以點選連結(https://pan.baidu.com/share/init?surl=RY0Zk5C_DOEwTXYe2SLFEg)下載,下載提取碼為 x87m。

如果用如下的程式碼獲取 test.ts 的 fps,

const double FPS = cap.get(cv::CAP_PROP_FPS);
std::cout << "fps: " << FPS << std::endl;

可以得到:

$ fps: 2000

用 ffprobe 對影片進行分析,

$ ffprobe -select_streams v -show_streams test.ts

可以得到:

codec_name=h264
r_frame_rate=30/1
avg_frame_rate=0/0
……

從 opencv/modules/videoio/src/cap_ffmpeg_impl.hpp 中,我們發現 fps 由 CvCapture_FFMPEG::get_fps() 計算而來,其計算邏輯如下:

double fps = r2d(ic->streams[video_stream]->avg_frame_rate);
if (fps < eps_zero) {
    fps = 1.0 / r2d(ic->streams[video_stream]->codec->time_base);
}

02 為什麼 OpenCV 得到的幀率是錯的

利用 test_time_base.cpp,我們可以得到:

time_base: 1/2000
framerate: 0/0
avg_framerate: 0/0
r2d(ic->streams[video_stream]->avg_frame_rate) = 0

所以 OpenCV 採用了:

1.0 / r2d(ic->streams[video_stream]->codec->time_base)

來計算該影片的 fps。而此處的 time_base = 1/2000,因此,最終得到的 fps 是 2000。

也就是說,AVStream->codec->time_base 的值導致了 OpenCV 得到一個看起來是錯誤的 fps。那麼,AVStream->codec->time_base 為什麼是這個數呢?FFMpeg 是怎麼計算這個欄位的呢?

03 FFMpeg 如何計算 AVCodecContext.time_base

AVStream->codec->time_baseAVCodecContext 中定義的 time_base 欄位,根據 libavcodec/avcodec.h中的定義,該欄位的解釋如下:

/**
  * This is the fundamental unit of time (in seconds) in terms
  * of which frame timestamps are represented. For fixed-fps content,
  * timebase should be 1/framerate and timestamp increments should be
  * identically 1.
  * This often, but not always is the inverse of the frame rate or field rate
  * for video. 1/time_base is not the average frame rate if the frame rate is not
  * constant.
  *
  * Like containers, elementary streams also can store timestamps, 1/time_base
  * is the unit in which these timestamps are specified.
  * As example of such codec time base see ISO/IEC 14496-2:2001(E)
  * vop_time_increment_resolution and fixed_vop_rate
  * (fixed_vop_rate == 0 implies that it is different from the framerate)
  *
  * - encoding: MUST be set by user.
  * - decoding: the use of this field for decoding is deprecated.
  *             Use framerate instead.
  */
AVRational time_base;

從中可以看出,對於解碼而言,time_base 已經被廢棄,需要使用 framerate 來替換 time_base。並且,對於固定幀率而言,time_base = 1/framerate,但是,並非總是如此。

利用 H264Naked 對 test.ts 對應的 H264 碼流進行分析,我們得到 SPS.Vui 資訊:

timing_info_present_flag :1
num_units_in_tick :1
time_scale :2000
fixed_frame_rate_flag :0

從中可以看到,test.ts 是非固定幀率影片。從 test_time_base.cpp 的結果看,test.ts 影片中,framerate = 0/0,而 time_base = 1/2000

難道,對於非固定幀率影片而言,time_baseframerate 之間沒有關聯?如果存在關聯,那又是怎樣的運算才能產生這種結果?這個 time_base 究竟是怎麼計算的呢?究竟和 framerate 有沒有關係呢?一連串的問題隨之而來……

原始碼面前,了無祕密。接下來,帶著這個問題,我們來一起分析一下 FFMpeg 究竟是如何處理 time_base 的。

04 avformat_find_stream_info

在 FFMpeg 中,avformat_find_stream_info() 會對 ic->streams[video_stream]->codec 進行初始化,因此,我們可以從 avformat_find_stream_info() 開始分析。

從 libavformat/avformat.h 中,可以得知avformat_open_input()會開啟影片流,從中讀取相關的資訊,然後儲存在AVFormatContext中,但是有時候,此處獲取的資訊並不完整,因此需要呼叫**avformat_find_stream_info()**來獲取更多的資訊。

* @section lavf_decoding_open Opening a media file
* The minimum information required to open a file is its URL, which
* is passed to avformat_open_input(), as in the following code:
* @code
* const char    *url = "file:in.mp3";
* AVFormatContext *s = NULL;
* int ret = avformat_open_input(&s, url, NULL, NULL);
* if (ret < 0)
*     abort();
* @endcode
* The above code attempts to allocate an AVFormatContext, open the
* specified file (autodetecting the format) and read the header, exporting the
* information stored there into s. Some formats do not have a header or do not
* store enough information there, so it is recommended that you call the
* avformat_find_stream_info() function which tries to read and decode a few
* frames to find missing information.

需要注意的是:avformat_find_stream_info() 會嘗試通過解碼部分影片幀來獲取需要的資訊。

/**
 * Read packets of a media file to get stream information. This
 * is useful for file formats with no headers such as MPEG. This
 * function also computes the real framerate in case of MPEG-2 repeat
 * frame mode.
 * The logical file position is not changed by this function;
 * examined packets may be buffered for later processing.
 *
 * @param ic media file handle
 * @param options  If non-NULL, an ic.nb_streams long array of pointers to
 *                 dictionaries, where i-th member contains options for
 *                 codec corresponding to i-th stream.
 *                 On return each dictionary will be filled with options that were not found.
 * @return >=0 if OK, AVERROR_xxx on error
 *
 * @note this function isn't guaranteed to open all the codecs, so
 *       options being non-empty at return is a perfectly normal behavior.
 *
 * @todo Let the user decide somehow what information is needed so that
 *       we do not waste time getting stuff the user does not need.
 */
int avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);

avformat_find_stream_info() 的整體邏輯大致如下圖所示,其中特別需要關注圖中所示的 7 個步驟:

圖片

avformat_find_stream_info() 的重要步驟說明

  • STEP 1. 設定執行緒數,避免 H264 多執行緒解碼時沒有把 SPS/PPS 資訊提取到 extradata

  • STEP 2. 設定 *AVStream stst 會在後續的函式呼叫中一直透到 try_decode_frame()

  • STEP 4. 設定 *AVCodecContext avctx 為透傳的 st->internal->avctx,在後續的解碼函式呼叫中,一直透傳的就是這個 avctx,因此,從這裡開始的執行流程,FFMpeg 使用的全部都是 st->internal->avctx,而不是 st->codec,這裡要特別的注意。此處同時會設定解碼的執行緒數,其目的和 STEP 1是一致的。

  • STEP 5. 因為之前設定瞭解碼執行緒數為 1,因此此處會呼叫

ret = avctx->codec->decode(avctx, frame, &got_frame, pkt)

來解碼並計算 avctx->framerate。注意,此處的 avctx 實際上是透傳而來的 st->internal->avctx。計算 framerate 的邏輯會在 如何計算 framerate 介紹。

  • STEP 6. 根據解碼器得到的 framerate 資訊來計算 avctx->time_base,注意此處實際上是 st->internal->avctx->time_base。根據 下文 framerate 的計算 可知,此處 framerate = {1000, 1}。根據 AVCodecContext.ticks_per_frame 的介紹 可知,ticks_per_fram****e = 2。因此,此處 avctx->time_base = {1, 2000}:
avctx->time_base = av_inv_q(av_mul_q({1000, 1}, {2, 1})) = {1, 2000}
  • STEP 7. 這一步可謂是“瞞天過海,明修棧道暗度陳倉”,這一步為了解決 API 的前向相容,做了一個替換,把 st->internal->avctx->time_base 賦值給了 st->codec->time_base,而把 st->avg_frame_rate 賦值給了 st->codec->framerate。因此:
st->codec->time_base = {1, 2000}
st->codec->framerate = {0, 0}

st->codec->time_base 的計算和 st->codec->framerate 之間沒有任何關係,而是和 st->internal->avctx->framerate 有關。本質而言,和 sps.time_scalesps.num_units_in_tick 有關。

st->internal->avctx->time_base.num = sps->num_units_in_tick * 
    st->internal->avctx->ticks_per_frame

st->internal->avctx->time_base.den = sps->time_scale * 
    st->internal->avctx->ticks_per_frame;

st->internal->avctx->time_base = {sps->num_units_in_tick, sps->time_scale}

internal->avctx->time_base & internal->framerate

  • 所以實際上,internal->avctx->time_base 為:

    avctx->time_base = sps->num_units_in_tick / sps->time_scale
    
  • ‍而,internal->avctx->framerate 則是:

    avctx->time_base = sps->num_units_in_tick / sps->time_scale
    

    因此,對於 H264 碼流而言,time_base = 1 / (2 * framerate),而不是 1 / framerate

    這也就是為什麼 libavcodec/avcodec.h 中說:

    * This often, but not always is the inverse of the frame rate or field rate
    * for video.
    

    從如上的分析可以知道:

    avctx->framerate = 1 / (avctx->time_base * avctx->ticks_per_frame)
    

    因此,當 st->avg_frame_rate = 0 時,OpenCV 計算 fps 的邏輯 是錯誤的。

    在 H265 中,ticks_per_frame = 1,因此對於 H265 的編碼,OpenCV 是沒有這個問題的。可以使用 Zond 265工具來分析一個 H265 的影片碼流,然後對照 OpenCV 以及 FFMpeg 的結果來驗證。

同時,正是如上所示的 STEP 7 中的移花接木導致了 test_time_base.cpp 的結果:

st->codec->framerate: 0/0
st->codec->time_base: 1/2000

05 ff_h264_decoder

libavcodec/decode.c 中的 decode_simple_internal() 中會呼叫對應的解碼器來進行解碼(STPE 5)。而正如前所示,test.ts 為 H264 編碼的影片流,因此,此處會呼叫 H264 解碼器來進行解碼。在 FFMpeg 中,H264 解碼器位於 libavcodec/h264dec.c 中定義的 const AVCodec ff_h264_decoder。

const AVCodec ff_h264_decoder = {
    .name                  = "h264",
    .type                  = AVMEDIA_TYPE_VIDEO,
    .id                    = AV_CODEC_ID_H264,
    .priv_data_size        = sizeof(H264Context),
    .init                  = h264_decode_init,
    .close                 = h264_decode_end,
    .decode                = h264_decode_frame,
    ......
};

在上文圖中的 STPE 5 中,

ret = avctx->codec->decode(avctx, frame, &got_frame, pkt);

實際呼叫的就是

ff_h264_decoder->h264_decode_frame(avctx, frame, &got_frame, pkt);

而此處的 avctx 也就是 try_decode_frame() 中的透傳下來的 st->internal->avctx,即上文圖中的 STEP 4。

06 h264_decode_frame

h264_decode\frame() 的整體邏輯如下圖所示:

圖片

AVCodecContext.ticks_per_frame

後面會用到 ticks_per_frame 來計算 framerate。在 STEP 6 中計算 time_base 的時候也用到了該值。因此,有必要做一下特殊說明。在 H264 解碼器中,ticks_per_frame=2,其具體的取值可以從如下幾處得知:

libavcodec/avcodec.h 中的欄位說明:

/**
 * For some codecs, the time base is closer to the field rate than the frame rate.
 * Most notably, H.264 and MPEG-2 specify time_base as half of frame duration
 * if no telecine is used ...
 *
 * Set to time_base ticks per frame. Default 1, e.g., H.264/MPEG-2 set it to 2.
 */
int ticks_per_frame;

libavcodec/h264dec.c 中的 h264_decode_init()

avctx->ticks_per_frame = 2;

07 如何計算 framerate

如何計算 st->internal->avctx->framerate

  • STEP 1. 根據整體的計算流程可知,此處的 h 實際上就是 avformat_find_stream_info() 中的 st->internal->avctx->priv_data。h 會一直透傳到之後的所有流程,這個務必要注意。

  • STEP 2. 此處會首先獲取到 sps 的相關資訊,以備後續的計算使用,我們可以再次看一下 test.ts sps 的相關資訊。

timing_info_present_flag :1
num_units_in_tick :1
time_scale :2000
fixed_frame_rate_flag :0
  • STEP 3. 根據 sps 的相關資訊計算 framerate,在上文的 STEP 6 中計算 time_base 用到的 framerate 就是在此處計算的。因為 timing_info_present_flag = 1,因此會執行計算 framerate 的邏輯:
avctx->framerate.den = sps->num_units_in_tick * h->avctx->ticks_per_frame = 1 * 2 = 2
avctx->framerate.num = sps->time_scale = 2000
avctx->framerate = (AVRational){1000, 1}

因此,

st->internal->avctx->framerate = {1000, 1}

08 結論

通過如上的分析我們可以知道:

  • FFMpeg 在計算 AVCodecContex 中的 frameratetime_base 的時候,會用到:

  • sps.time_scale

  • sps.num_units_in_tick

  • AVCodecContex.ticks_per_frame

  • 在 FFMpeg 中,frameratetime_base 的關係為:

  • framerate = 1 / (time_base * ticks_per_frame)

  • time_base = 1 / (framerate * ticks_per_frame)

  • 對於非 H.264/MPEG-2,ticks_per_frame=1,因此 frameratetime_base 是互為倒數的關係。而對於 H.264/MPEG-2 而言,ticks_per_frame=2,因此,此時,二者並非是互為倒數的關係。因此,FFMpeg 中才說,frameratetime_base 通常是互為倒數的關係,但並非總是如此。

  • 在 OpenCV 中,對於 H.264/MPEG-2 影片而言,當 AVStream.avg_frame_rate=0 時,其計算 fps 的邏輯存在 BUG。

  • 因為在解碼時,AVCodecContex.time_base 已經廢棄,同時 AVStream.avctx 也已經廢棄,而 avformat_find_stream_info() 中為了相容老的 API,因此會利用 AVStream.internal.avctx 和其他的資訊來設定 AVStream.avctx。而 AVStream.avctx.time_base 取自 AVStream.internal.avctx,AVStream.avctx.framerate 則取自 AVStream.framerate

————END————

推薦閱讀:

百度 Android 直播秒開體驗優化

iOS SIGKILL 訊號量崩潰抓取以及優化實踐

如何在幾百萬qps的閘道器服務中實現靈活排程策略

深入淺出DDD程式設計

百度APP iOS端記憶體優化實踐-記憶體管控方案

Ernie-SimCSE對比學習在內容反作弊上應用