x264源碼分析與應用示例(一)——視頻編碼基本流程

打算寫幾篇文章記錄一下學習x264源碼的成果,主要包含兩個方面的內容,一是基本的x264視頻編碼流程,二是x264中的碼率控制,之前分析過JM和HM的碼率控制,但是x264的碼率控制一直沒看,這回也算是補上了。然後再以兩個實際問題爲例介紹通過研究源碼後給出的解決方案,一個是如何修改編碼參數獲得更好的視頻質量的問題,一個是修改源碼改進x264碼率控制算法的問題。

本文包含以下內容

1、H.264編碼流程詳述與對應x264源碼解析

首先簡單介紹一下x264源碼調試與修改的基本方法。就是基本的conifigure和make,configure命令使用最簡單的就可以,需要注意的是要--enable-debug,如果是windows的話,需要mingw。然後打開eclipse+cdt,選File->new->Makefile Project with Existing Code,接下來的對話框中的Toolchain for Indexer Settings中選Linux GCC,完成項目的新建之後右鍵點擊x264選debug即可開始調試。如果進行修改的話,在每次修改了源碼之後重新進行make即可。

H.264編碼流程詳述與對應x264源碼解析

這裏的分析基於x264-146。先給出一個標準的H.264編碼流程圖,如下

由圖中可以看出一個編碼流程的關鍵步驟有變換、量化、預測、濾波、熵編碼、碼率控制。下面跟着代碼走一遍流程。以下面的編碼命令作爲測試

 

--threads 1 --no-cabac --vbv-bufsize 400 --vbv-maxrate 200 --bitrate 200 --input-res 176x144 -o test.264 testqcif.yuv

 

命令行解析 x264.c-parse()

 

調用瞭如下幾個關鍵的函數

1、x264_param_default():給各個參數設置默認值

2、x264_param_default_preset():設置默認的preset,內部調用了x264_param_apply_preset()和x264_param_apply_tune(),在它們之中即可找到各個preset和tune的詳細參數區別,例如ultrafast這個preset就是在默認值得基礎上做了如下改動

 

if( !strcasecmp( preset, "ultrafast" ) )
    {
        param->i_frame_reference = 1;
        param->i_scenecut_threshold = 0;
        param->b_deblocking_filter = 0;//不使用去塊濾波
        param->b_cabac = 0;//不使用CABAC
        param->i_bframe = 0;//不使用B幀
        param->analyse.intra = 0;
        param->analyse.inter = 0;
        param->analyse.b_transform_8x8 = 0;//不使用8x8DCT
        param->analyse.i_me_method = X264_ME_DIA;//運動搜索方法使用“Diamond”
        param->analyse.i_subpel_refine = 0;
        param->rc.i_aq_mode = 0;
        param->analyse.b_mixed_references = 0;
        param->analyse.i_trellis = 0;
        param->i_bframe_adaptive = X264_B_ADAPT_NONE;
        param->rc.b_mb_tree = 0;
        param->analyse.i_weighted_pred = X264_WEIGHTP_NONE;//不使用加權
        param->analyse.b_weighted_bipred = 0;
        param->rc.i_lookahead = 0;
    }

 

與此類似的還有x264_param_apply_profile(),是根據不同profile的設置修改對應的參數

3、x264_param_parse():對命令行參數進行解析,並進行對應的賦值,實質上就是用過strcmp()的方法,賦值的對象正是關鍵結構體x264_param_t。比如我們在前面設置了--vbv-bufsize 400,對應的代碼就是

 

OPT("vbv-bufsize")
        p->rc.i_vbv_buffer_size = atoi(value);

4、select_output(),select_input():這兩個函數都是根據命令行中輸入輸出文件名的後綴來判斷類型,從而可以在後續工作中調用對應的讀寫操作,這樣的做法在ffmpeg中也有類似的實現。比如,如果通過後綴判斷出輸入文件是raw格式的,就會有如下的操作

 

 

else if( !strcasecmp( module, "raw" ) || !strcasecmp( ext, "yuv" ) )
        cli_input = raw_input;

這裏的cli_input定義如下,可以看到就是一個文件i/o操作相關的結構體

 

 

typedef struct
{
    int (*open_file)( char *psz_filename, hnd_t *p_handle, video_info_t *info, cli_input_opt_t *opt );
    int (*picture_alloc)( cli_pic_t *pic, int csp, int width, int height );
    int (*read_frame)( cli_pic_t *pic, hnd_t handle, int i_frame );
    int (*release_frame)( cli_pic_t *pic, hnd_t handle );
    void (*picture_clean)( cli_pic_t *pic );
    int (*close_file)( hnd_t handle );
} cli_input_t;

而raw_input的定義就在raw.c中,裏面定義了針對raw格式的I/O操作,以打開文件爲例

 

 

static int open_file( char *psz_filename, hnd_t *p_handle, video_info_t *info, cli_input_opt_t *opt )
{
    raw_hnd_t *h = calloc( 1, sizeof(raw_hnd_t) );
    if( !h )
        return -1;

    if( !opt->resolution )
    {
        /* try to parse the file name */
        for( char *p = psz_filename; *p; p++ )
            if( *p >= '0' && *p <= '9' && sscanf( p, "%dx%d", &info->width, &info->height ) == 2 )
                break;
    }
    else
        sscanf( opt->resolution, "%dx%d", &info->width, &info->height );
    FAIL_IF_ERROR( !info->width || !info->height, "raw input requires a resolution.\n" )
    if( opt->colorspace )
    {
        for( info->csp = X264_CSP_CLI_MAX-1; info->csp > X264_CSP_NONE; info->csp-- )
        {
            if( x264_cli_csps[info->csp].name && !strcasecmp( x264_cli_csps[info->csp].name, opt->colorspace ) )
                break;
        }
        FAIL_IF_ERROR( info->csp == X264_CSP_NONE, "unsupported colorspace `%s'\n", opt->colorspace );
    }
    else /* default */
        info->csp = X264_CSP_I420;

    h->bit_depth = opt->bit_depth;
    FAIL_IF_ERROR( h->bit_depth < 8 || h->bit_depth > 16, "unsupported bit depth `%d'\n", h->bit_depth );
    if( h->bit_depth > 8 )
        info->csp |= X264_CSP_HIGH_DEPTH;

    if( !strcmp( psz_filename, "-" ) )
        h->fh = stdin;
    else
        h->fh = x264_fopen( psz_filename, "rb" );
    if( h->fh == NULL )
        return -1;

    info->thread_safe = 1;
    info->num_frames  = 0;
    info->vfr         = 0;

    const x264_cli_csp_t *csp = x264_cli_get_csp( info->csp );
    for( int i = 0; i < csp->planes; i++ )
    {
        h->plane_size[i] = x264_cli_pic_plane_size( info->csp, info->width, info->height, i );
        h->frame_size += h->plane_size[i];
        /* x264_cli_pic_plane_size returns the size in bytes, we need the value in pixels from here on */
        h->plane_size[i] /= x264_cli_csp_depth_factor( info->csp );
    }

    if( x264_is_regular_file( h->fh ) )
    {
        fseek( h->fh, 0, SEEK_END );
        uint64_t size = ftell( h->fh );
        fseek( h->fh, 0, SEEK_SET );
        info->num_frames = size / h->frame_size;
    }

    *p_handle = h;
    return 0;
}

 

此外還有一個cli_vid_filter_t,是輸入格式濾鏡結構體,可以對輸入數據做一些簡單的處理,例如拉伸(需要libswscale的支持)、裁剪等等(當然濾鏡也可以不作任何處理,直接讀取輸入數據)。在x264的編碼過程中,就是調用cli_vid_filter_t結構體的get_frame()讀取YUV數據,調用cli_output_t的write_frame()寫入數據。

 

整個parse()的內容都可以歸結爲是在給x264_param_t結構體賦值,先賦值爲默認值,再根據命令做相應的修改,最後再解析一下輸入輸出的文件格式。

 

編碼主流程 x264.c——encode()

依次調用瞭如下幾個關鍵函數

1、x264_encoder_open():打開編碼器,初始化編碼需要的各種變量,各種賦值。包括

(1)根據輸入參數x264_param_t 生成碼流的SPS、PPS信息,各種賦值,參見H.264標準即可——x264_sps_init() x264_pps_init()

(2)初始化幀內預測的C語言版本或彙編優化過的函數。H.264中有兩種幀內預測模式:16x16亮度幀內預測模式和4x4亮度幀內預測模式。其中16x16幀內預測模式一共有4種(Vertical,Horizontal,DC,Plane),4x4幀內預測模式一共有9種,簡單記爲249—— x264_predict_16x16_init() x264_predict_4x4_init() 

(3)初始化像素值計算相關的彙編函數,包括SAD(絕對誤差和)、SATD(hadamard變換後的絕對誤差和)、SSD(差值平方和),都是主要用於幀內預測模式以及幀間預測模式的判斷,即將一個宏塊的所有預測模式都走一遍,分別計算預測值和原始值之間的差距,選擇差距小的那種預測模式。早期的編碼器使用SAD進行計算,近期的編碼器多使用SATD進行計算。爲什麼使用SATD而不使用SAD呢?關鍵原因在於編碼之後碼流的大小是和圖像塊DCT變換後頻域信息緊密相關的,而和變換前的時域信息關聯性小一些。SAD只能反應時域信息;SATD卻可以反映頻域信息,而且計算複雜度也低於DCT變換,因此是比較合適的模式選擇的依據。對應的結構體是x264_pixel_function_t,所謂的初始化就是給這個結構體中的函數接口賦值——x264_pixel_init()

(4)初始化DCT變換和反變換相關的C語言版本或彙編優化過的函數,DCT變換都是針對殘差進行的,早期的DCT變換都使用了8x8的矩陣(變換系數爲小數)。在H.264標準中新提出了一種4x4的矩陣。這種4x4 DCT變換的係數都是整數,一方面提高了運算的準確性,一方面也利於代碼的優化。對應的結構體是x264_dct_function_t,所謂的初始化就是給這個結構體中的函數接口賦值—— x264_dct_init() 

(5)初始化運動估計、運動補償、半像素內插相關的C語言版本或彙編優化過的函數。對應的結構體是x264_mc_functions_t,所謂的初始化就是給這個結構體中的函數接口賦值——x264_mc_init()

(6)初始化量化和反量化相關的C語言版本或彙編優化過的函數,我們平時說的QP其實只是量化步長qstep的序號,qstep共有52個值,QP每增加6,qstep增加一倍,量化公式中的分母也是qstep,所以QP越大,量化越不精細,圖像質量受損越嚴重。量化是對DCT殘差矩陣進行的,比如,如果是對4x4的DCT殘差矩陣進行量化的話,就是對16個DCT係數依次使用量化公式。對應的結構體是x264_quant_function_t,所謂的初始化就是給這個結構體中的函數接口賦值——x264_quant_init()

(7)初始化去塊效應濾波器相關的C語言版本或彙編優化過的函數,塊效應主要由DCT變換後的量化誤差導致,環路濾波有兩種強度,普通強度針對方塊邊界周圍的6個點,強濾波器則針對8個點,通常幀內預測相關的圖像塊使用腔濾波器,而幀間預測使用普通濾波器,此外也需要邊界兩邊的兩個點的像素值差距達到一定值纔會進行環路濾波。對應的結構體是x264_deblock_function_t,所謂的初始化就是給這個結構體中的函數接口賦值——x264_deblock_init()

(8)初始化Lookahead相關的變量 x264_lookahead_init()

(9)初始化碼率控制相關的變量,在下一篇文章中詳細分析。x264_ratecontrol_new() 

2、x264_encoder_headers():分別調用了x264_sps_write(),x264_pps_write(),x264_sei_version_write()輸出了SPS,PPS,和SEI(附加信息,就是平時在mediainfo裏面看懂的編碼設置信息)信息,不同於前面的x264_sps_init()等,這裏的write就是直接以熵編碼的形式寫到碼流中了

3、encode_frame():編碼一幀YUV數據,循環運行。內部調用x264_encoder_encode()(在下一小節詳細分析)編碼x264_picture_t爲x264_nal_t,調用cli_output.write_frame()輸出碼流。如下

 

//編碼1幀
static int encode_frame( x264_t *h, hnd_t hout, x264_picture_t *pic, int64_t *last_dts )
{
    x264_picture_t pic_out;
    x264_nal_t *nal;
    int i_nal;
    int i_frame_size = 0;
    //編碼API
    //編碼x264_picture_t爲x264_nal_t
    i_frame_size = x264_encoder_encode( h, &nal, &i_nal, pic, &pic_out );

    FAIL_IF_ERROR( i_frame_size < 0, "x264_encoder_encode failed\n" );

    if( i_frame_size )
    {
    	//通過cli_output_t中的方法輸出
    	//輸出raw H.264流的話,等同於直接fwrite()
    	//其他封裝格式,則還需進行一定的封裝
        i_frame_size = cli_output.write_frame( hout, nal[0].p_payload, i_frame_size, &pic_out );
        *last_dts = pic_out.i_dts;
    }

    return i_frame_size;
}

 

4、print_status():輸出編碼狀態,就是我們在命令行中看到的那些,如

 

[7.7%] 1/13 frames, 0.02 fps, 1133.40 kb/s, eta 0:13:07

需要注意的是這裏的fps,代表的是編碼速度

 

5、x264_encoder_close():關閉編碼器,輸出統計信息,就是在命令行窗口中最後看到的那一大串信息(不包括前面print_status()輸出的內容)

 

x264_encoder_encode

來詳細看看編碼的核心函數x264_encoder_encode(),其中依次調用瞭如下幾個函數,這些關鍵步驟在源碼中也給出了一些註釋

1、獲取一個空的x264_frame_t用於存儲編碼數據,即fenc,同時進行了初始化——x264_frame_pop_unused

2、將外部結構體的pic_in(x264_picture_t類型)的數據拷貝給內部結構體的fenc——x264_frame_copy_picture

3、將fenc放入Lookahead模塊的隊列中,等待確定幀類型——x264_lookahead_put_frame

4、分析Lookahead模塊中一個幀的幀類型,內部調用的是x264_slicetype_decide來確定幀類型,x264_slicetype_decide又調用了x264_slicetype_analyse。判斷幀類型的邏輯如下,如果通過scenecut()判斷爲場景切換,就設置爲I幀;如果不適用B幀,就將所有幀設置爲P幀;如果使用B幀,就計算開銷(使用哪種幀帶來的誤差satd小,在1/2分辨率下計算),判斷是否B幀,分析後的幀保存在frames.current[]中。——x264_lookahead_get_frames

5、從frames.current[]中取出分析幀類型之後的fenc——x264_frame_shift

6、更新參考幀隊列frames.reference[]——x264_reference_update

7、如果編碼幀fenc是IDR幀,清空參考幀隊列frames.reference[]——x264_reference_reset

8、創建參考幀列表List0和List1——x264_reference_build_list

關於參考幀這部分內容我一直還不是很清楚,留待以後研究

9、碼率控制單元初始化——x264_ratecontrol_start,在下一篇文章詳細分析

10、初始化Slice Header信息——x264_slice_init,內部調用x264_slice_header_init

11、進行編碼——x264_slices_write,循環一副圖像中的每一個slice進行編碼,調用x264_slice_write(沒有s,在下一小節詳細分析),編碼流程圖中的各個模塊就在這個函數中依次展開了

12、做一些編碼後的後續處理,記錄統計信息,輸出nalu(例如添加起始碼),輸出重建幀,還調用了x264_ratecontrol_end結束碼率控制,需要說明的是H.264碼流有兩種格式:

(1)annexb模式(傳統模式)。這種模式下每個NALU包含起始碼0x00000001;而且SPS、PPS存儲在ES碼流中。常見的H.264裸流就是屬於這種格式。
(2)mp4模式。這種模式下每個NALU不包含起始碼,原本存儲起始碼前4個字節存儲的是這個NALU的長度(不包含前4字節);而且SPS、PPS被單獨放在容器的其他位置上。這種H.264一般存儲在某些容器中,例如MP4中。
從源代碼中可以看出,x264_nal_encode()根據H.264碼流格式的不同分成兩種情況給NALU添加起始碼:
(1)annexb模式下,在每個NALU前面添加0x00000001。
(2)mp4模式下,先計算NALU的長度(不包含前4字節),再將長度信息寫入NALU前面的4個字節——x264_encoder_frame_end

 

x264_slice_write

前面說了這麼多,都沒看到前面編碼流程圖裏面的內容,彆着急,這就來了,都在x264_slice_write中。它依次包含了以下內容

1、開始寫一個NALU——x264_nal_start
2、初始化宏塊重建數據緩存fdec_buf[](在像素塊的左邊和上邊包含了左上方相鄰塊用於預測的像素)和編碼數據緩存fenc_buf[](YUV像素挨着存放,比較簡單)——x264_macroblock_thread_init

3、輸出 Slice Header,不同於前面的x264_slice_init,這裏直接就是通過熵編碼寫入碼流了——x264_slice_header_write
4、濾波模塊。環路濾波,半像素插值,SSIM/PSNR的計算——x264_fdec_filter_row,內部又調用瞭如下幾個函數

(4.1)去塊效應濾波——x264_frame_deblock_row。一次處理一行。

(4.2)半像素插值——x264_frame_filter。

(4.3)PSNR計算——x264_pixel_ssd_wxh。在需要計算編碼質量的時候,下同。這裏是先計算SSD,最後輸出的時候再用x264_psnr將SSD換算爲PSNR,公式如下

MSE=SSD*1/(w*h) PSNR=10*log10(MAX^2/MSE)
(4.4)SSIM計算——x264_pixel_ssim_wxh。

5、將要編碼的宏塊的周圍的宏塊的信息讀進來——x264_macroblock_cache_load
6、分析模塊。幀內預測模式分析以及幀間運動估計等,幀內預測的內容前面有講過,而對於幀間運動估計,不僅僅只可以選擇一個圖像作爲參考幀(P幀),而且還可以選擇兩張圖片作爲參考幀(B幀)。使用一張圖像作爲參考幀稱爲單向預測,而使用一張圖像作爲參考幀稱爲雙向預測。使用單向預測的時候,直接將參考幀上的匹配塊的數據“搬移下來”作後續的處理(“賦值”),而使用雙向預測的時候,需要首先將兩個參考幀上的匹配塊的數據求平均值(“求平均”),然後再做後續處理。毫無疑問雙向預測可以得到更好的壓縮效果,但是也會使碼流變得複雜一些。總體來說就是把各種模式都遍歷一遍,選擇SAD或者SATD最小的那一種,因爲可以認爲誤差越小,消耗的比特越少,編碼代價越小。幀間宏塊的劃分方式總共有8種。運動搜索主要有菱形搜索算法DIA、六邊形搜索算法HEX——x264_macroblock_analyse
7、宏塊編碼模塊。對殘差DCT變換、量化等方式對宏塊進行編碼,反變換反量化用於重建幀的內容也在這裏,宏塊編碼部分的DCT殘差反變換,並且疊加到預測數據上,形成重建幀是用來幹什麼的?重建幀有時會用來輸出,並且後面幾幀的預測是基於重建幀而非原始待編碼幀的數據——x264_macroblock_encode
8、熵編碼,熵編碼的內容包括Qp(存儲的是QP偏移值,即上一個宏塊和當前宏塊之間的差值)、殘差數據、IPB各個slice的header數據,包括預測模式,運動矢量差值(MV-預測MV,預測MV由周圍宏塊的MV取中值的來,而不是直接儲存運動矢量),參考幀序號等等——x264_macroblock_write_cabac,x264_macroblock_write_cavlc
9、保存當前宏塊的信息,包括幀內預測模式,DCT非零係數,運動矢量,參考幀序號等等,用於以後的宏塊的編碼——x264_macroblock_cache_save

10、碼率控制——x264_ratecontrol_mb,在下一篇文章中會詳細分析

11、結束寫一個NALU——x264_nal_end

 

關注公衆號,掌握更多多媒體領域知識與資訊

文章幫到你了?可以掃描如下二維碼進行打賞~,打賞多少您隨意~

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章