本文講解在linux下面,如何通過ffmpeg調用GPU硬件解碼,并保存解碼完的yuv文件。
其實,ffmpeg自帶的例子hw_decode.c這個文件,就已經能滿足要求了,因此,本文就嘗試講解以下hw_decode這個例子。hw_decode.c可以調用VDPAU硬件解碼,也可以調用VAAPI硬件解碼,下面依次講解如何進行操作。
下載hw_decode.c文件
我是從網上直接下載ffmpeg源碼,下載地址如下:https://ffmpeg.org/releases/ffmpeg-4.2.9.tar.bz2
我這里下載的是4.2.9的源碼,然后解壓縮之后,在ffmpeg-4.2.9/doc/examples/hw_decode.c路徑,就保存了我們需要的hw_decode.c文件。
搭建開發環境
搭建開發環境分2種,一種是直接使用系統自帶的軟件源里面的軟件包進行開發,另外一種就是自己重新編譯ffmpeg并進行開發,這兩種選一種就可以了。推薦使用軟件源的軟件包進行開發,因為相對簡單一些。下面分別講解如何操作。
使用軟件源的軟件包進行開發
需要安裝的依賴項如下,我這里是deb系列安裝方式。
sudo apt install libvdpau-dev libva-dev ffmpeg libavcodec-dev libavformat-dev libavutil-dev
編譯, cd 到ffmpeg-4.2.9/doc/examples目錄,執行如下命令
gcc hw_decode.c -lavcodec -lavutil -lavformat -o hw_decode
就可以得到hw_decode這個可執行文件。
自己編譯ffmpeg進行開發
自己編譯ffmpeg,首先要下載ffmpeg源碼,下載地址如下:https://ffmpeg.org/releases/ffmpeg-4.2.9.tar.bz2。
然后解壓縮,cd ffmpeg-4.2.9,然后進行configure配置,如果你想使用VDPAU解碼,那么configure命令如下
./configure --enable-shared --enable-vdpau
如果你想使用vaapi解碼,那么configure命令如下
./configure --enable-shared --enable-vaapi
如果你vdpau和vaapi都想使用,那么進行如下configure。
./configure --enable-shared --enable-vdpau --enable-vaapi
然后,這里可能會遇到問題,可能就是沒有安裝vdpau開發包,或者沒有安裝vaapi開發包導致的,輸入如下命令安裝就可以了。
sudo apt install libvdpau-dev libva-dev
然后再進行configure操作。
之后,再進行如下操作:
make -j8
make examples
sudo make install
其中,make -j8是使用8線程進行ffmpeg編譯。
make examples,就是把ffmpeg所有的例子都編譯,這樣在ffmpeg-4.2.9/doc/exmaples目錄,就會生成hw_decode這個可執行文件。
sudo make install,會將ffmpeg的動態庫安裝到/usr/local/lib下面,可執行文件安裝到/usr/local/bin下面,頭文件安裝到/usr/local/include目錄下面。
運行hw_decode例子
cd 到生成hw_decode的目錄,如果使用vdpau解碼,那么執行如下命令,你需要將第2個參數的視頻路徑,替換成你的視頻路徑。
./hw_decode vdpau ~/視頻/210329_06B_Bali_1080p_013.mp4 ./out.yuv
如果使用vaapi解碼,那么需要使用如下命令:
./hw_decode vaapi ~/視頻/210329_06B_Bali_1080p_013.mp4 ./out.yuv
同樣,需要將第2個參數替換成你的視頻路徑。
有的顯卡,需要添加環境變量LIBVA_DRIVER_NAME。比如景嘉微JM9系列顯卡,需要使用如下命令:
LIBVA_DRIVER_NAME=jmgpu ./hw_decode vaapi ~/視頻/210329_06B_Bali_1080p_013.mp4 ./out.yuv
檢驗out.yuv結果
ffplay -pix_fmt nv12 -s 1920x1080 out.yuv
如上所示,使用ffmpeg自帶的播放器ffplay,然后-pix_fmt 指定yuv格式, -s指定分辨率,然后播放。
hw_decode例子源碼講解
下面開始講解代碼,從main函數開始講解。
int main(int argc, char *argv[])
{AVFormatContext *input_ctx = NULL;int video_stream, ret;AVStream *video = NULL;AVCodecContext *decoder_ctx = NULL;AVCodec *decoder = NULL;AVPacket packet;enum AVHWDeviceType type;int i;if (argc < 4) {fprintf(stderr, "Usage: %s <device type> <input file> <output file>\n", argv[0]);return -1;}
剛開始的一段,全是變量聲明和定義,這些變量都是后面用的到的。然后if (argc < 4)這個判斷,是用來判斷使用方式的,下面的使用方式,正好是4個argc,第一個./hw_decode是程序名字,第2個參數vaapi表示使用的解碼接口,第3個參數是視頻路徑,第4個參數是輸出yuv路徑。
./hw_decode vaapi ~/視頻/210329_06B_Bali_1080p_013.mp4 ./out.yuv
如果argc < 4,那么提示使用方式,然后返回-1,程序結束。
type = av_hwdevice_find_type_by_name(argv[1]);if (type == AV_HWDEVICE_TYPE_NONE) {fprintf(stderr, "Device type %s is not supported.\n", argv[1]);fprintf(stderr, "Available device types:");while((type = av_hwdevice_iterate_types(type)) != AV_HWDEVICE_TYPE_NONE)fprintf(stderr, " %s", av_hwdevice_get_type_name(type));fprintf(stderr, "\n");return -1;}
接下來,就是去尋找第2個參數對應的硬件解碼類型,argv[1]就對應我們解碼程序的參數"vdpau",或者"vaapi",如果找到了,就保存在變量type中,如果沒找到,就通過一個while循環把支持的硬件類型列舉,并打印出來,然后return -1程序退出。
/* open the input file */if (avformat_open_input(&input_ctx, argv[2], NULL, NULL) != 0) {fprintf(stderr, "Cannot open input file '%s'\n", argv[2]);return -1;}
接下來,avformat_open_input,就是打開輸入文件,在我這里,對應的就是打開“~/視頻/210329_06B_Bali_1080p_013.mp4”這個文件,argv[2]就是輸入視頻路徑,如果失敗了,就返回-1,否則繼續。
if (avformat_find_stream_info(input_ctx, NULL) < 0) {fprintf(stderr, "Cannot find input stream information.\n");return -1;}
然后,查找視頻文件里面的碼流信息,一般就是找這個視頻里面,有幾個視頻流,有幾個音頻流,如果沒有找到因視頻信息,就加一條錯誤打印,然后返回-1.
/* find the video stream information */ret = av_find_best_stream(input_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);if (ret < 0) {fprintf(stderr, "Cannot find a video stream in the input file\n");return -1;}video_stream = ret;
接下來,查找AVMEDIA_TYPE_VIDEO,也就是查找視頻流信息,并將視頻流的索引號,保存在video_stream中。
for (i = 0;; i++) {const AVCodecHWConfig *config = avcodec_get_hw_config(decoder, i);if (!config) {fprintf(stderr, "Decoder %s does not support device type %s.\n",decoder->name, av_hwdevice_get_type_name(type));return -1;}if (config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX &&config->device_type == type) {hw_pix_fmt = config->pix_fmt;break;}}
接下來,就是通過一個循環,查找能支持的硬件格式對應的pix_fmt,比如我這里使用vaapi,那么通過AV_HWDEVICE_TYPE_VAAPI找到了pix_fmt為AV_PIX_FMT_VAAPI_VLD。
if (!(decoder_ctx = avcodec_alloc_context3(decoder)))return AVERROR(ENOMEM);video = input_ctx->streams[video_stream];if (avcodec_parameters_to_context(decoder_ctx, video->codecpar) < 0)return -1;decoder_ctx->get_format = get_hw_format;
繼續,分配一個解碼上下文 decoder_ctx,然后根據視頻碼流信息,填充decoder_ctx里面內容。
并將get_hw_format這個函數地址,給到decoder_ctx->get_format中,這樣后續解碼器解碼時會調用這個get_fomat函數指針來對格式進行判斷。
if (hw_decoder_init(decoder_ctx, type) < 0)return -1;
初始化完了解碼上下文,再初始化硬件解碼器。
if ((ret = avcodec_open2(decoder_ctx, decoder, NULL)) < 0) {fprintf(stderr, "Failed to open codec for stream #%u\n", video_stream);return -1;}
打開解碼器。
/* open the file to dump raw data */output_file = fopen(argv[3], "w+");
打開輸出文件,這個argv[3],就對應我們命令行里面的out.yuv,就是打開這個文件,方便后面寫入使用。
/* actual decoding and dump the raw data */while (ret >= 0) {if ((ret = av_read_frame(input_ctx, &packet)) < 0)break;if (video_stream == packet.stream_index)ret = decode_write(decoder_ctx, &packet);av_packet_unref(&packet);}
重點戲來了,就是這個while循環,av_read_frame讀取一幀數據,保存在packet中,然后判斷以下這個packet的stream_index是不是video_stream,如果是視頻數據,就調用decode_write,否則就什么也不做,處理完之后,調用av_packet_unref取消packet的引用。看來重點就在這個decode_write函數里面。
static int decode_write(AVCodecContext *avctx, AVPacket *packet)
{AVFrame *frame = NULL, *sw_frame = NULL;AVFrame *tmp_frame = NULL;uint8_t *buffer = NULL;int size;int ret = 0;ret = avcodec_send_packet(avctx, packet);if (ret < 0) {fprintf(stderr, "Error during decoding\n");return ret;}
decode_write拿到packet數據,調用avcodec_send_packet,將packet發送給解碼器。
while (1) {if (!(frame = av_frame_alloc()) || !(sw_frame = av_frame_alloc())) {fprintf(stderr, "Can not alloc frame\n");ret = AVERROR(ENOMEM);goto fail;}ret = avcodec_receive_frame(avctx, frame);if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {av_frame_free(&frame);av_frame_free(&sw_frame);return 0;} else if (ret < 0) {fprintf(stderr, "Error while decoding\n");goto fail;}if (frame->format == hw_pix_fmt) {/* retrieve data from GPU to CPU */if ((ret = av_hwframe_transfer_data(sw_frame, frame, 0)) < 0) {fprintf(stderr, "Error transferring the data to system memory\n");goto fail;}tmp_frame = sw_frame;} elsetmp_frame = frame;size = av_image_get_buffer_size(tmp_frame->format, tmp_frame->width,tmp_frame->height, 1);buffer = av_malloc(size);if (!buffer) {fprintf(stderr, "Can not alloc buffer\n");ret = AVERROR(ENOMEM);goto fail;}ret = av_image_copy_to_buffer(buffer, size,(const uint8_t * const *)tmp_frame->data,(const int *)tmp_frame->linesize, tmp_frame->format,tmp_frame->width, tmp_frame->height, 1);if (ret < 0) {fprintf(stderr, "Can not copy image to buffer\n");goto fail;}if ((ret = fwrite(buffer, 1, size, output_file)) < 0) {fprintf(stderr, "Failed to dump raw data.\n");goto fail;}fail:av_frame_free(&frame);av_frame_free(&sw_frame);av_freep(&buffer);if (ret < 0)return ret;}
然后一個大的while循環,這里其實就是讓解碼器去解碼,如果解碼得到數據,就將數據從GPU顯存拷貝到CPU內存,然后再寫入out.yuv文件中。下面分開講解。
while (1) {if (!(frame = av_frame_alloc()) || !(sw_frame = av_frame_alloc())) {fprintf(stderr, "Can not alloc frame\n");ret = AVERROR(ENOMEM);goto fail;}
while的開始,分配了2個frame,第一個frame,是用來保存GPU解碼完畢的數據,這個數據位于顯存。第2個sw_frame是用來保存內存數據,用來將GPU顯存的yuv數據拷貝到內存用的。
ret = avcodec_receive_frame(avctx, frame);if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {av_frame_free(&frame);av_frame_free(&sw_frame);return 0;} else if (ret < 0) {fprintf(stderr, "Error while decoding\n");goto fail;}
avcode_receive_frame,用來接受解碼器傳過來的frame數據,也就是如果解碼器解碼完了,會得到一個解碼完畢的AVFrame數據,這個數據就保存在frame中。如果返回值為EAGAIN或者AVERROR_EOF,說明之前的packet并沒有解碼得到一個完整的AVFrame數據,因此需要把前面分配的2個frame和sw_frame都釋放掉,然后返回0,說明這一個packet處理完畢了。如果ret 是其他值 < 0,說明解碼出錯了,goto fail。fail標簽后面再說。
if (frame->format == hw_pix_fmt) {/* retrieve data from GPU to CPU */if ((ret = av_hwframe_transfer_data(sw_frame, frame, 0)) < 0) {fprintf(stderr, "Error transferring the data to system memory\n");goto fail;}tmp_frame = sw_frame;} elsetmp_frame = frame;
否則,我們解碼得到了一幀數據,判斷一下,這一幀數據的格式,如果格式正好是hw_pix_fmt,那么調用av_hwframe_transfer_data,將frame里面的GPU數據,傳輸到sw_frame里面,tmp_frame正好等于sw_frame。如果不是hw_pix_fmt,那么tmp_frame就是frame。這個執行完之后,tmp_frame里面保存的就是內存數據了。
size = av_image_get_buffer_size(tmp_frame->format, tmp_frame->width,tmp_frame->height, 1);buffer = av_malloc(size);if (!buffer) {fprintf(stderr, "Can not alloc buffer\n");ret = AVERROR(ENOMEM);goto fail;}ret = av_image_copy_to_buffer(buffer, size,(const uint8_t * const *)tmp_frame->data,(const int *)tmp_frame->linesize, tmp_frame->format,tmp_frame->width, tmp_frame->height, 1);if (ret < 0) {fprintf(stderr, "Can not copy image to buffer\n");goto fail;}
接下來,判斷tmp_frame的數據大小,分配一個size大小的buffer,將tmp_frame的數據,搬到buffer中。
if ((ret = fwrite(buffer, 1, size, output_file)) < 0) {fprintf(stderr, "Failed to dump raw data.\n");goto fail;}
然后將buffer中的數據,寫入到output_file中,也就是寫入到out.yuv中。
fail:av_frame_free(&frame);av_frame_free(&sw_frame);av_freep(&buffer);if (ret < 0)return ret;}
}
如果失敗了,釋放frame, sw_frame, buffer內容,并且如果ret <0, 返回ret。
/* actual decoding and dump the raw data */while (ret >= 0) {if ((ret = av_read_frame(input_ctx, &packet)) < 0)break;if (video_stream == packet.stream_index)ret = decode_write(decoder_ctx, &packet);av_packet_unref(&packet);}/* flush the decoder */packet.data = NULL;packet.size = 0;ret = decode_write(decoder_ctx, &packet);av_packet_unref(&packet);if (output_file)fclose(output_file);avcodec_free_context(&decoder_ctx);avformat_close_input(&input_ctx);av_buffer_unref(&hw_device_ctx);return 0;
}
然后一直循環av_read_frame,解碼寫文件,直到av_read_frame < 0,也就是把整個輸入文件都處理完了,這個while循環結束。
接下來,還設置了一個packet.data = NULL, 調用了一次decode_write,就是告訴解碼器,我沒有數據了,你里面如果還緩存一些數據,都給我輸出出來吧。
最后就是關閉輸出文件,釋放解碼器上下文,關閉輸出,釋放硬件設備上下文。至此, hw_decode解析完畢。
常見問題
- 為什么硬件解碼這么慢,CPU占用率也很高?
答: 之所以這么慢,CPU占用率高,是因為有2個操作,1個操作是需要將數據從GPU顯存拷貝到CPU內存,另外1個操作是需要寫文件。如果你屏蔽av_hwframe_transfer_data及之后的操作,這里對應代碼107行到139行,那么速度將會特別快。
2. 為什么運行vaapi時提示找不到vaapi device。
答:可能原因是沒有安裝 vaapi驅動,或者沒有指定LIBVA_DRIVER_NAME這個環境變量。