H264视频解码器C++工程说明
? ? ? ?為了弄清楚H264整個解碼流程,為此我專門按照H264標準文檔 《T-REC-H.264-201704-S!!PDF-E.pdf》,用C++實現了一個H264裸碼流視頻解碼器,代碼工程地址為?https://github.com/jfu222/h264_video_decoder_demo
一、前言
? ? 自己之前在視頻解碼行當也干了個兩三年,基本上都是用的開源的ffmpeg來解碼各種視頻,需要特別說明的是,早些年國內安防領域的視頻碼流真是百花齊放,也是填坑無數,苦了俺們這些搬磚的。好在現在基本上都是H264/H265碼流了。另一方面,現在基于視頻內容的深度學習又開始大流行,后來有一次出苦差,現場某一刑偵大佬說,你們現在這個算法識別速度太慢了,能不能不解碼,就直接識別出視頻中的運動目標?我當時是啞口無言滴,內心是復雜滴。哥也就是一個碼磚的,設計大廈這種活,哥也做不來啊。。。。。。
? ?后來我就到處找資料,發現H264視頻編解碼規范是在2003年就發布了第一版了,再瞅瞅ffmpeg里面的h264解碼代碼,最早也是2003年就開始寫了,都過去十多年了,當時就立刻打消了自己寫解碼器的念頭(莫錯,知難而退了),人家ffmpeg都實現的這么好了,多翻翻里面的代碼,然后自己再推演一下,摸清H264的解碼脈絡應該是綽綽有余了。事實證明,自己當時的想法圖樣圖省潑了,NND,這ffmpeg里面的大部分核心代碼都不是常人讀得懂的,里面的注釋是惜字如金,各種炫技的代碼寫法,我有讀破代碼的這功夫,早就自己寫一個解碼器了。
? ? 后來找來了《新一代視頻壓縮編碼標準-H.264_AVC(第二版).pdf》,光看一遍都花了一兩個月,主要是看不懂里面說的啥啊,現在再回頭再看,里面說的啥基本上都能知道啥意思了。后來又找到了《T-REC-H.264-201704-S!!PDF-E.pdf》這個pdf,嗯,這個pdf才是H264的權威出處,問題是里面全是英文啊,哥的英文也就不說了(說了都是淚 /捂臉),那就慢慢啃吧,只要不是天書,還是有希望看懂的。
? ?H264資料已經齊全了,接下來就是準備寫代碼了,網上我能找到的開源代碼,也就是ffmpeg,x264,jm 這三個。這3個都是用C寫的,那我就開始糾結了,用C++寫好,還是用C寫好,后來深思熟慮了一下,決定用C++,但是盡量保持里面的語法都是接近C的,禁止使用C++11,要把代碼的主動權盡量掌握在自己手里,不要交給編譯器。原計劃三到六個月利用周末寫完第一版,后來的實際情況是寫了一年半(熬了無數個夜,發際線走了,啤酒肚來了)。
? ?最后吐槽一下jm,里面的代碼真是讓人看了后抓狂。你有見過?imgpel *****imgUV_sub; 這種寫法的變量?
二、進入正題
? ? H264編解碼的整個過程基本上都是,解碼殘差 + 預測,這是整個解碼流程的核心。下面列一下簡單的解碼步驟:
1)? 在H264中,所有圖像都被分成一個一個16x16像素的正方形,這個正方形的名稱叫做"宏塊"(即macroblock)。宏塊還可以再細分成:兩個16x8矩形、兩個8x16矩形、四個8x8正方形,這些比宏塊小的塊名字叫“子宏塊”(即sub-macroblock)。宏塊和子宏塊的本質區別是:宏塊的大小是固定死了的,永遠是16x16大小,并且在一張圖像中的分割方法是唯一的,而一個宏塊中的子宏塊就有很多種組合。這些組合就相當于構成了一幅H264圖像的骨骼,骨架搭好了,就可以在上面敷上各種顏色的皮膚。另外一方面也說明了視頻的原始分辨率都必須是16的倍數。
2)? 宏塊又被進一步分成幀宏塊(frame macroblock)和場宏塊(field macoblock)(如圖1),特別需要注意的是,當提到幀/場宏塊時,需要弄清楚的是哪一種宏塊:頂幀宏塊(top frame macroblock)、底幀宏塊(bottom?frame macroblock)、頂場宏塊(top field macroblock)、底場宏塊(bottom?field macroblock)。即一個16x32像素塊,可以分成上下兩個16x16的宏塊對,如果這個16x32像素塊的上面連續16行構成一個宏塊,下面連續16行構成一個宏塊,那么這兩個宏塊就叫“一個幀宏塊對”(frame macroblock pair)。如果這個16x32像素塊從第零行開始,將16個偶數行的像素取出來,構成一個宏塊,然后將16個奇數行像素取出來,構成一個宏塊,那么這兩個宏塊就叫“一個場宏塊對”(field?macroblock pair)。凡是出現“場”(field)這個字的地方,其本質代表的是攝像機的隔行掃描采樣。所以,如果一幅圖像全部都是場宏塊對組成的話,所有的頂場宏塊組成的圖像叫做一個頂場圖像,所有的底場宏塊組成的圖像叫做一個底場圖像,即一個頂場加一個底場組成一幀圖像。同樣的道理,一幀圖像可以拆分成一個頂場和一個底場。我一開始以為這個場和場宏塊是同一個意思,直到后來摔了n個跟頭后才明白,壓根就不是同一個東西。所以,如果H264中沒有場的話,一切都是那么簡單。
? ? ? 如果H264的句法元素 slice_data->mb_field_decoding_flag == 1,那么就表示當前宏塊是一個場宏塊,那么接下來的問題就是這是一個頂場宏塊呢,還是一個底場宏塊?要回答這個問題,就需要知道H264各種宏塊的掃描順序。
?
圖1 - 幀場宏塊對?
3) 一幅圖像的宏塊劃分好了后,接下來就是這些宏塊的解碼順序了,H264中,每個宏塊都有一個對應的CurrMbAddr正整數值,這個CurrMbAddr的值從0開始,后面的每個宏塊依次加1。H264規定了掃描宏塊的一個基本原則就是:從上到下,從左到右。比如上面提到的宏塊對(圖1),就是先解碼頂宏塊,再解碼底宏塊,而頂宏塊的CurrMbAddr值都是偶數,底宏塊的CurrMbAddr值都是奇數,本質就是,先解碼一個16x32像素塊,再解碼該像素塊右邊緊挨著的16x32像素塊(如圖2)。
圖2 - 宏塊對編解碼掃描順序?
? ? 再比如一個16x16宏塊被分成了16個4x4的子宏塊,那么,這16個子宏塊的掃描順序就是如下圖3所示:
| 0 | 1 | 4 | 5 |
| 2 | 3 | 6 | 7 |
| 8 | 9 | 12 | 13 |
| 10 | 11 | 14 | 15 |
?
4) 對于H264的預測,說得直白一點就跟天氣預報一樣,從今天的天氣情況,來預測一下明天下不下雨。H264的所有預測都是基于宏塊預測的,就是說當前宏塊的256個像素值或當前子宏塊的像素值,都是根據它左邊和上邊已經解碼完畢的宏塊的相關句法元素值來預測得到的。為什么是左邊和右邊呢?因為根據從左到右,從上到下的原則,當前(子)宏塊的左邊和上邊的相應(子)宏塊都是已經解碼完畢了的(對于圖像最上面的第一行宏塊來說,他們的上邊宏塊都是不存在的,這是在解碼這些宏塊之前就已經知道的事實,也可以理解成這些不存在的宏塊都是已經解碼完畢了的)。如圖4所示,當前宏塊用CurrMbAddr表示,他的左邊和上邊總共有4個宏塊,分別是mbAddrA、mbAddrD、mbAddrB、mbAddrC。當然這些都是對于16x16宏塊來說的,對于其他比如4x4、4x8、8x4、8x8、16x8、8x16尺寸的子宏塊,獲取它們的鄰居宏塊,H264中已經有整理好了的表格。對應于《T-REC-H.264-201704-S!!PDF-E.pdf》中的Table 6-3 – Specification of mbAddrN 和 Table 6-4 – Specification of mbAddrN and yM。
| mbAddrD | mbAddrB | mbAddrC |
| mbAddrA | CurrMbAddr | ? |
| ? | ? | ? |
? ? ? ? H264中很多語法元素的值,都會基于相鄰宏塊來預測,比如,要解碼“量化后的殘差”,如果當前宏塊是幀內預測模式的宏塊,并且是cavlc殘差,就需要事先知道當前宏塊殘差中有多少個非零系數值(non_zero_count_coeff),而這個non_zero_count_coeff值,就是利用已經解碼的相鄰宏塊mbAddrA、mbAddrD、mbAddrB、mbAddrC的non_zero_count_coeff值來預測得到的,大致意思就是取mbAddrA和mbAddrB相應的non_zero_count_coeff值的平均值,即 nC = ( nA + nB + 1 ) / 2;相應的計算公式見《T-REC-H.264-201704-S!!PDF-E.pdf》中的 9.2.1?小節。
? ? ? ?又比如,P幀或B幀的運動矢量預測也會用到當前宏塊的相鄰宏塊的相應語法元素值。總而言之,H264的精髓就是榨干相鄰宏塊的所有信息,來得到當前宏塊的信息,從而減少編碼后的比特數量(即降低碼率)。
? ? ? ?什么是殘差?通俗的講,就是圖像編碼前的真實像素值減去對應位置的預測值,得到的差就叫殘差。那么什么又是“對應位置的預測值”?這個預測值,說到底就是上面講的利用已經解碼完畢的相鄰宏塊的像素值,按照相應的預測模式值對應的計算方法,計算出來的。最簡單的計算方法就是取左邊和上邊宏塊的像素的平均值。那么什么又是“相應的預測模式值”?這個值一般是由編碼器設置好了的,并且編碼到h264碼流中了的(即rem_intra4x4_pred_mode語法元素的值)。如果碼流中沒有出現這個語法元素,那么這個rem_intra4x4_pred_mode的值就又需要利用已經解碼完畢的相鄰宏塊的rem_intra4x4_pred_mode值來計算得到,一般是取mbAddrA和mbAddrB中對應的rem_intra4x4_pred_mode值最小的那個值。
5) cavlc/cabac殘差解碼完后,就需要進行DCT反變換,而進行DCT反變換之前,需要反量化,而進行反量化之前,需要將依據解碼完畢的殘差值進行一次重新排列,需要重新排列的原因是,編碼器編碼時,將殘差按 zig-zag 掃描(圖5,是4x4子宏塊的16個殘差值的掃描順序) 順序排列了一遍,所以解碼時,需要反向掃描回去(另外,ffmpeg的官網logo貌似就是zi-zag圖形)。? ?
圖5 - zig-zag 掃描順序? ? ? ?反向掃描完了后就是反量化,為啥要反量化呢?因為殘差經過DCT變換后的值范圍太大了,或者為了省碼流,需要進行類似四舍五入的操作,將某個范圍內的值,全部映射到同一個值,這個過程就會損失掉一部分圖像信息(即有損壓縮),有的視頻解碼后畫面很模糊,就可能跟這個量化得過了頭有關。量化的本質就是殘差DCT變換后的值除以了一個整數值,而這個整數值就是量化參數,這個量化參數在H264中,有專門的表格來存儲的(見《T-REC-H.264-201704-S!!PDF-E.pdf》中的 8.5.9 小節)。
? ? ? ?反量化完了后,就是DCT反變換。這個反變換類似快速傅里葉變換(FFT)一樣,有快速算法,類似蝶形運算。而蝶形運算的本質,即按照標準的變換定義,計算每一個變換后的值,都會重復進行某一個局部的加法和乘法運算,大可不必每次都算一次,可以用空間換時間的辦法,把這些大量重復計算的地方用一個中間變量保存起來,后面就直接用中間變量計算了。
/* H.264 _ MPEG-4 Part 10 White Paper.pdf Page15Equation 2-3:Y = Cf X XfT ? Ef/ \ / \ / \| M0 | | 1 1 1 1/2 | | m0 || M1 | = | 1 1/2 -1 -1 | | m1 || M2 | | 1 -1/2 -1 1 | | m2 || M3 | | 1 -1 1 -1/2 | | m3 |\ / \ / \ /M0 = (m0 + m2) + (m1 + m3*(1/2))M1 = (m0 - m2) + (m1*(1/2) - m3)M2 = (m0 - m2) - (m1*(1/2) - m3)M3 = (m0 + m2) - (m1 + m3*(1/2))蝶形運算符:m0 o---o---o (m0 + m2)=s02 o----o-----o (m0 + m2) + (m1 + m3*(1/2)) = M0 = s02 + s13\ / \ /X \ // \ Xm2 o---o---o (m0 - m2)=d02 o---o--/-\--o (m0 - m2) + (m1*(1/2) - m3) = M1 = d02 + d13\/ \//\ /\m1 o---o---o (m1 + m3*(1/2))=s13 o---o--\-/--o (m0 + m2) - (m1 + m3*(1/2)) = M3 = s02 - s13\ / XX / \/ \ / \m3 o---o---o (m1*(1/2) - m3)=d13 o----o-----o (m0 - m2) - (m1*(1/2) - m3) = M2 = d02 - d13 */?
6) DCT反變換后的值,再加上預測得到的像素值,結果就是環路濾波前的幀像素值,那么什么是“預測得到的像素值”? 這里得分兩種情況,第一種情況就是宏塊的幀內預測(Intra prediction)得到的像素值,第二種情況是宏塊的幀間預測(Inter?prediction)得到的像素值。這里所謂的預測,其本質還是前面說的利用已經解碼完畢的相鄰宏塊的像素值得到的。宏塊的幀內預測比較簡單,直接利用當前模塊的mbAddrA、mbAddrB的像素值來預測計算得到。而對于幀間預測,就需要添加額外的信息來找到相應的用于預測的宏塊(mbAddrForPred),這個mbAddrForPred一般不在本幀中,而是在本幀的參考幀(用 refIdxL0 或 refIdxL1 來表示)中。那么什么又是“本幀的參考幀”?這個參考幀其實就是指前面已經解碼完畢的幀/場(注意:是幀或場,不是宏塊),那么解碼器又咋個曉得參考幀是前面解碼完畢的哪一幀呢?這個就是一個比較復雜的問題了,通俗的來講,要得到refIdxL0或refIdxL1,編碼器可能已經將這個值編碼到碼流中了,解碼相應的語法元素ref_idx_l0[]就可以得到,那么如果碼流中沒出現這個語法元素呢?還是老辦法,利用相鄰的宏塊來預測得到這個語法元素值,因為一般來講,相鄰宏塊的參考幀和當前宏塊的參考幀都是同一幀,那么就沒必要將這個值重復編碼到碼流中了。
? ? ? ?找到當前待解碼宏塊的參考幀后,下一步就需要確定,用于預測的宏塊(mbAddrForPred)在參考幀中的哪一個位置,要找到這個位置,就要先找到當前幀的當前宏塊,在參考幀中對應的位置,然后再基于這個位置,加上一個偏移量,得到的結果位置就是mbAddrForPred。那么這個“偏移量”是哪里來的呢? 這個偏移量就叫運動矢量,是一個二維矢量Mv0[0,1],分別表示x橫向偏移量和y縱向偏移。這個運動矢量,同樣可能是由編碼器編到碼流里面了,解碼mvd_l0[]語法元素就可以得到,如果碼流中沒有出現這個語法元素,那就默認為0。同樣的,這個運動矢量還是需要再利用當前宏塊相鄰宏塊預測得到(即mvpL0[]),那么最終的運動矢量就是,mvL0[0,1] =?mvpL0[0,1]? +?mvd_l0[0,1],如果預測模式是雙向預測的B宏塊,還需要找到參考幀 refIdxL1,計算出mvL1[0,1] =?mvpL1[0,1]? +?mvd_l1[0,1]。
? ? ? ?將當前宏塊的每一個位置,在mbAddrForPred中,找到對應位置的像素值,然后利用這個像素值以及周圍“井”字型的像素值,共20個像素值,進行四分之一像素插值,最后相應位置的插值結果就是上面說的“預測得到的像素值”,如果是雙向預測,會得到兩個這樣的預測值,一般情況下,是取這兩個值的平均值,做為最終的預測值。
//8.4.2.2.1 Luma sample interpolation process //亮度像素值插值過程 //Figure 8-4 // // 口 口 A aa B 口 口 // 口 口 C bb D 口 口 // E F G a b c H I J // d e f g // cc dd h i j k m ee ff // n p q r // K L M s N P Q // 口 口 R gg S 口 口 // 口 口 T hh U 口 口?7)??DCT反變換后的值,再加上預測得到的像素值,結果就是環路濾波前的幀像素值,那么什么是“環路濾波”?因H264中人為劃分宏塊,導致解碼后,會出現塊效應,就是解碼后的圖像看起來是一塊一塊的,需要將這些塊與塊之間的邊界做一次平滑濾波(環路濾波)。平滑濾波后的整個幀的像素值,就是最終的像素值。
8)? 一幀圖像解碼完了后,只有顯示到屏幕上,才能顯示出解碼器的價值。有兩種方案,一種是將解碼后的每一幀數據保存到磁盤。另一種是直接在播放器中播放出來。這兩種方案,都有各自的使用場合。需要說明的是,H264解碼出來的一般都是YUV420P格式的像素值,像 BMP/PNG/JPG以及視頻播放器,需要的是RGB值,所以還需要先將YUV數據轉換成RGB值。
? ? 下面是我專門寫的一個SDH264Player播放器截圖,目的是方便研究H264的解碼過程。
三、尾聲
? ? ?1) 上面只是簡單羅列了一下H264的解碼基本步驟,更復雜的得去啃相關的書籍了。
? ? ?2) 另外需要糾正的是,以前我一直以為B幀是不能做參考的,現在看來這個認知是錯誤的。事實上,據我看到的一些碼流視頻,I幀,P幀,B幀都可能被編碼器用作參考幀。
? ? ? 3) 還有一點,P幀,B幀,里面的宏塊是可能包含幀內預測宏塊的。所以,對于P幀,和B幀的定義,其實還需要更深一層的理解。
? ? ? 4) 在h264_video_decoder_demo工程中,主要的精力放在H264的完整解碼流程,重心沒放在解碼速度和內存控制上,因此,還是有很多的優化空間的,并且目前我自己測試的H264裸碼流視頻數量也不多,頂多十多個,bug是存在的,后續有時間再慢慢琢磨要不要優化一下。這些優化工作夠掉很多頭發的。
? ? ? 5) 對于h264_video_decoder_demo工程的解碼速度的優化,可以參照ffmpeg的方法,在ffmpeg中大量使用了Intel的MMX和SSE指令集,即單指令多數據指令集。相當于一條指令開了多個線程來并行解碼,解碼速度當然是單指令單數據指令集的4到8倍。這種技術是很誘人的。英偉達的顯卡貌似就是這種技術的集大成者。
? ? ?6) 對于h264_video_decoder_demo工程使用的內存過高的優化,在編寫過程中,為了更加專注解碼過程本身(其實是想偷懶),我基本上是用32位int來定義一個變量,這樣的話,像有很多H264語法元素,只有0和1兩個取值,只需要一個比特位就夠了。另外,每個宏塊基本上有很多個256個元素的數組,這些數組元素的取值范圍本身只在-126到127范圍內,所以只需要一個char型變量就足夠了,內存可以降低三分之一。
? ? 7) 如果要寫一個工業級的H264解碼器,可以把ffmpeg中關于H264的大部分核心代碼和設計框架抄過來,自己再改一下就可以了。不過這個工作量,想想就夠刺激滴。
? ? 8)?本來想到H265來自于H264,既然H264解碼器都能寫出來,那么寫一個H265解碼器還不是月月鐘的事,后來大致瞄了一下《T-REC-H.265-201802-I!!PDF-E.pdf》這個文檔,漸漸打消了這個念頭,咱還想多留幾縷頭發。另一方面,從我目前觀察的ffmpeg的H265解碼性能,是不如H264的。
? ? 9) 前面提到的? “是否可以不解碼H264碼流,就能知道碼流里面有哪些運動目標嗎?”? 目前我的回答是,理論上是可以的,但這個理論上的可以,其實就相當于另外一種形式的解碼。所以最終的理想方案是,如果能在攝像頭編碼時,就把視頻中的運動物體信息編碼到碼流里面,這才一勞永逸的事情。
?----------------暫時就這么多吧!-------------------
?
?
總結
以上是生活随笔為你收集整理的H264视频解码器C++工程说明的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [css] 为什么要使用css spr
- 下一篇: [vue] vue部署上线前需要做哪些准