H.266/VVC中的熵编码CABAC内容史上超详解!!(基础进阶必看系列)

之前一直对视频编码中熵编码CABAC这一块不是太清楚其中的细节,今天来从CABAC的原理以及从VTM4.0的代码中详细解析CABAC的工作流程,相信对于不了解视频编码中CABAC的人和有一定基础的人来说都有很大的收获。

1 CABAC工作流程

1.1 算术编码工作流程
与变长编码不同,算术编码的本质是为整个输入序列分配一个码字,而不是给每个字符分别指定码字,因此平均意义上可以为单个字符分配码长小于1的码字,所以算数编码可以给出接近最优的编码结果。
算术编码的基本原理是:根据信源不同符号的概率把[0,1)区间划分为互不重叠的子区间,子区间的宽度恰好是各符号序列的概率,这样信源发出的不同符号序列将与各子区间一一对应。然后递归地进行区间映射,最后得到一个小区间,从该区间内选取一个代表性的小数作为实际的编码输出。

传统算术编码举例:
在这里插入图片描述 假设需要编码的符号只有“e”,“h”,“l”,“o”四个,他们出现的总概率为1,各个符号出现的概率如上述表格所示。现在输入的符号序列为“hello”,求经过算术编码后的码字。算术编码的总体的编码流程可以参考图1.1。
在这里插入图片描述
1.2 CABAC工作流程
CABAC(Context-based Adaptive Binary Arithmetic Coding),基于上下文模型的自适应二进制算术编码,它的编码核心算法就是算术编码(Arithmetic Coding)。熵编码就是把一系列用来表示视频序列的语法元素转变为一个用来传输或存储的压缩码流。由于CABAC的编码器是二进制算术编码,意味着只对0或1进行编码,所以某一个语法元素拟进行编码,需要进行二进制化,将语法元素映射为一个或者多个符号(symbol),然后将这些symbol输入到算术编码器中。算术编码器有两个重要的参数,区间起始点(low)和区间宽度(range)。这一过程如图1.2所示。
在这里插入图片描述CABAC采用了高效的算术编码思想,同时充分考虑了视频流相关统计特性,大大提高编码效率。它
的编码过程主要包括四个步骤:
①二进制化;
②上下文建模;
③二进制算术编码;
④概率更新;
其具体流程框图如图1.3所示:
在这里插入图片描述二进制针对非二进制的语法元素,主要包括一元码,截断一元码,k阶指数哥伦布码等二值化方案。二进制算术编码有常规编码模式和旁路编码模式两种,在常规编码器中,二进制的symbol顺序的进入上下文模型器,上下文模型是一个概率模型,模型的选择取决于已编码数据符号的概率统计,为每一个输入的symbol分配合适的概率模型,该过程即为上下文建模。上下文模型存储的是与每位是1或0的概率有关的参数。编码器也会根据当前编码的symbol的值,更新概率模型,这就是编码中的自适应。另一种模式是旁路编码模式,无需对概率进行自适应更新,而是采用0和1概率各占 1/2 的固定概率进行编码。

2 算术编码算法概述
算术编码的理解:每一个符号(symbol)为算术编码的输入,算术编码的输出为编码比特(bin)。对于给定一个symbol,选择好其概率模型后,交给算术编码器。算术编码器根据当前的symbol和symbol的概率模型,调整算术编码器中的区间起始点(low)和区间宽度(range),并更新该概率模型。示意图如图2.1所示。
在这里插入图片描述
2.1 算术编码区间的划分
每个Symbol送入算术编码器,算术编码器根据其概率模型进行区间的划分,记录当前区间的区间起
始点和区间宽度,并根据symbol的取值更新概率模型以及区间起始点和区间宽度。
详细流程如下:
(1)接收到区间起始点(Low_input )和区间宽度(range_input );
(2)接收到symbol和symbol的概率模型,计算出0所占的区间长度(R0 = range_input p(0))以及1所
占的区间长度(R1 = range_input − R0 )
(3)根据接收到的symbol的取值更新(Low_input )和(range_input)。若symbol为1,更新后的区间起始点Low_output 仍为Low_input , 更新后的区间宽度range_output 缩小为R1 若symbol为0,Low_output 更新为Low_input + R1 , range_output缩小为R0.
(4)根据接收到的symbol的取值更新概率模型。
步骤3完成了算术编码器内部区间起始点和区间宽度的一次更新。示意图如图2.2所示。
在这里插入图片描述如果某一个symbol使用的是等概率模型,即R0= R1=1/2
range_input 。更新后的区间有两种选择,一是原先区间的左一半,此时区间起始点等于原始区间起始点,区间宽度减半;二是原先区间的右一半,此时区间起始点为原始区间的中心,区间宽度减半。这里需要注意的是旁路编码器中,1和0所占区间前后与常规编码器中相反。那么如果当前symbol为0,则选择区间一,即左一半区间,更新区间起始点和区间宽度;如果当前symbol为1,选择右一半区间进行相应的更新。

2.2 算术编码的比特输出
可以想象,随着多个符号的输入,算术编码器划分的区间起始点会越来精细。原始区间为[0,1],划分越精细,区间起始点小数部分越多,区间宽度越小。为了有效地进行算术编码,在满足一定条件后,算术编码会启动比特输出机制,并对区间起始点和区间宽度进行相应的修正。
具体地,算术编码器会实时地监测当前区间的宽度。当区间宽度小于0.5,即原始区间宽度的一半
时,会进行比特的输出。输出比特时对区间起始点(low)进行左移操作,移出高位的比特,同时对区间宽度(range)也进行相应的左移,直到区间宽度重新回归到大于0.5的范围为止。示意图如图2.3所示
在这里插入图片描述
3 VTM4.0中的代码实现

3.1 start()
VTM4.0中的算术编码初始化函数如下

void BinEncoderBase::start()//算术编码初始化函数
{
  m_Low               = 0;//区间起始点
  m_Range             = 510;//区间宽度
  m_bufferedByte      = 0xff;//缓冲区
  m_numBufferedBytes  = 0;//已缓存字节数
  m_bitsLeft          = 23;//寄存器中剩余比特数
  BinCounter::reset();
  m_BinStore. reset();
}

• 区间起始点(low)的初始值设置为0,存储在一个32位的寄存器中,有效位为后9位。
• 区间宽度(range)将区间[0,1]扩展到初始值长度为510,用9位二进制数表示。当(range)的值小于初
始值510的一半时,将会进行重归一化操作(RenormE),将对(range)左移扩展到区间初始值的一半
以上,并对(low)左移进行比特输出,即(range)的取值需在[2^8 ,2^9 ) 之间。
•m_bitsLeft表示存储(low)的寄存器中还剩余多少bit。寄存器大小为32位,(low)的初始值占9位
(9个0),所以剩余比特数的初始值为23位。
• m_bufferedByte和m_numBufferedBytes是与输出缓存器有关的参数。

3.2 encodeBin()
常规编码器,CABAC的编码核心,编码一个比特位,输入待编码的symbol(0或1)和所选的概率模型。常规编码器流程图如图3.1所示。
首先进行symbol0所占区间长度的确定,根据所选概率模型中的概率P和接收到的区间宽度,以查表的方式代替乘法运算,查出0的区间长度。同时将1的区间长度赋值给range。之后根据待编码比特位symbol的取值,更新概率模型、区间起始点(low)和区间宽度(range)。
在这里插入图片描述

在2.2节中我们知道,当range的值小于初始区间宽度的一半时,会进行比特输出,同时对low和range进行左移操作,直到range的值大于初始区间宽度的一半。对range的左移是为了扩大区间宽度,对low的左移是为了输出比特,两者左移的位数是相同的。这一步骤是通过重归一化进行的。在代码中,当新的区间宽度小于初始值的一半(256)时,将会进行重归一化操作。

左移的位数是通过查表确定的,表中存储的是当前(range)至少需要左移几位才能达到[2^8 ,2^9 )。在对(range)进行重归一化的过程中,也会对(low)进行相同的移位操作,其移出的比特就是输出的比特。其示意图如图3.2所示。

在这里插入图片描述

void TBinEncoder<BinProbModel>::encodeBin( unsigned bin, unsigned ctxId )
{
  BinCounter::addCtx( ctxId );
  BinProbModel& rcProbModel = m_Ctx[ctxId];
  uint32_t      LPS         = rcProbModel.getLPS( m_Range );

  DTRACE( g_trace_ctx, D_CABAC, "%d" " %d " "%d" "  " "[%d:%d]" "  " "%2d(MPS=%d)"  "  " "  -  " "%d" "\n", DTRACE_GET_COUNTER( g_trace_ctx, D_CABAC ), ctxId, m_Range, m_Range - LPS, LPS, ( unsigned int ) ( rcProbModel.state() ), bin == rcProbModel.mps(), bin );

  m_Range   -=  LPS;
  if( bin != rcProbModel.mps() )//如果传过来的symbol是LPS,range肯定会小于256,因此要进行归一化操作
  {
    int numBits   = rcProbModel.getRenormBitsLPS( LPS );//归一化,得到需要左移的位数
    m_bitsLeft   -= numBits;//寄存器中剩余的供Low左移用的位数
    m_Low        += m_Range;
    m_Low         = m_Low << numBits;//Low左移numBits位进行比特输出
    m_Range       = LPS   << numBits;//range左移numBits位进行区间扩增
    if( m_bitsLeft < 12 )
    {
      writeOut();
    }
  }
  else//如果传过来的symbol是MPS
  {
    if( m_Range < 256 )//LPS首先要判断range是否小于一半的区间长度
    {
      int numBits   = rcProbModel.getRenormBitsRange( m_Range );
      m_bitsLeft   -= numBits;
      m_Low       <<= numBits;
      m_Range     <<= numBits;
      if( m_bitsLeft < 12 )
      {
        writeOut();
      }
    }
  }
  rcProbModel.update( bin );//更新概率模型
  BinEncoderBase::m_BinStore.addBin( bin, ctxId );//重归一化
}

3.3 encodeBinEP()
有些语法元素在二值化后选择的可能不是上述的算术编码,而是旁路编码。旁路编码器假设符 号0和1的概率各占1 2 的固定概率进行编码,不需要对(range)进行查表划分,不需要进行概率模型的更新。 传入的range_input在256到510之间,因为0和1的区间长度各占一半,所以新d的range=1/2range_input。故每 编码一次symbol都需要进行一次重归一化,左移位数为1,并输出1个比特。这里为了更简便,将对low的 移位操作提前,并保持range不变。需要注意的是旁路编码器中,0的区间在前,1的区间在后。具体推导 过程如下:
• 在常规编码其中,若LPS在前,MPS在后
symbol = 0时:low << numBits, range = R0 << numBits;
symbol = 1时:low = (low + R0) << numBits, range = R1 << numBits;
在旁路编码器中有R0 = R1 = 1 2range,numBits = 1:
symbol = 0时:low << 1, range = (1 2range) << 1 = range;
symbol = 1时:low = (low + 1 2range) << 1 = (low << 1) + range, range = (1 2range) << 1 = range;
所以旁路编码的流程图如图3.3所示:
在这里插入图片描述

void BinEncoderBase::encodeBinsEP( unsigned bins, unsigned numBins )
{
  for(int i = 0; i < numBins; i++)
  {
    DTRACE( g_trace_ctx, D_CABAC, "%d" "  " "%d" "  EP=%d \n", DTRACE_GET_COUNTER( g_trace_ctx, D_CABAC ), m_Range, ( bins >> ( numBins - 1 - i ) ) & 1 );
  }

  BinCounter::addEP( numBins );
  if( m_Range == 256 )
  {
    encodeAlignedBinsEP( bins, numBins );
    return;
  }
  while( numBins > 8 )
  {
    numBins          -= 8;
    unsigned pattern  = bins >> numBins;
    m_Low           <<= 8;
    m_Low            += m_Range * pattern;
    bins             -= pattern << numBins;
    m_bitsLeft       -= 8;
    if( m_bitsLeft < 12 )
    {
      writeOut();
    }
  }
  m_Low     <<= numBins;
  m_Low      += m_Range * bins;
  m_bitsLeft -= numBins;
  if( m_bitsLeft < 12 )
  {
    writeOut();
  }
}

3.4 testAndWriteOut()
常规编码器和旁路编码器的最后都会调用testAndWriteOut()函数,即每编码完一个symbol都会 对m bitsLeft进行检测,如果满足如果满足m bitsLeft < 12,即寄存器高位中剩余比特数小于初始值23 的一半时,则调用输出到比特流的函数WriteOut(),函数具体如下:

void BinEncoderBase::writeOut()
{
  unsigned leadByte = m_Low >> ( 24 - m_bitsLeft );
  m_bitsLeft       += 8;
  m_Low            &= 0xffffffffu >> m_bitsLeft;
  if( leadByte == 0xff )
  {
    m_numBufferedBytes++;
  }
  else
  {
    if( m_numBufferedBytes > 0 )
    {
      unsigned carry  = leadByte >> 8;
      unsigned byte   = m_bufferedByte + carry;
      m_bufferedByte  = leadByte & 0xff;
      m_Bitstream->write( byte, 8 );
      byte            = ( 0xff + carry ) & 0xff;
      while( m_numBufferedBytes > 1 )
      {
        m_Bitstream->write( byte, 8 );
        m_numBufferedBytes--;
      }
    }
    else
    {
      m_numBufferedBytes  = 1;
      m_bufferedByte      = leadByte;
    }
  }
}

示意图如图3.4所示:
在这里插入图片描述
当检测到寄存器中剩余比特数小于12时,会进行比特输出。将存储low的32位寄存器中存有数 值的高8位先存入一个8位的缓存器中,并将存储low的32位寄存器中的这8位清空,寄存器剩余比特 数m bitsLeft+ = 8。当下一次调用writeOut()函数时,将8位缓存器中的内容输出到比特流中,并存入新的8位数据。当所有需要编码的symbol都编码结束后,会调用finish()函数,将存储low的32位寄存器中剩 下的所有数据全部输出到比特流中。输出流程如图3.5所示。
在这里插入图片描述
4 上下文模型的选择与更新

4.1.1 initValue的选择
上下文模型中存储的值是initValue,这个值与0和1的概率大小p(0)和p(1)有关。上下文模型的选择就 是选择合适的initValue,initValue的值与slice的类型以及已编码的语法元素有关。这些用来作为条件的 已编码符号信息称为上下文。以splitQtflag为例,下面是存放splitQtflag的initValue值的表格。

const CtxSet ContextSetCfg::SplitQtFlag = ContextSetCfg::addCtxSet
({
  { 138, 140, 142, 136, 138, 140, },
  { 139, 126, 142, 107, 138, 125, },
  { 139, 125, 127, 136, 153, 126, },
#if JVET_M0453_CABAC_ENGINE
  { 0, 8, 8, 12, 12, 8, },//这一行是M0453提案新加的一行值,是一种新的编码引擎
#endif
});

该表格的横坐标与slice的类型有关,B、P、I分别对应第0、1、2行。该表格纵坐标的取值,与已编码过的 当前CU的左边和上边的CU的splitQtflag有关:当左块和上块都不在继续划分时取第0列;当左块继续划分上块不再划分时取第1列;当左块和上块都继续划分时取第2列。另外,当uiDepth < ucMinDepth时 取第3 列;当uiDepth >= ucMaxDepth + 1时取第4列。选取的initValue值的位置以ctxIdx标识,每一 个initValue值都对应一个ctxIdx号。语法元素ctxIdx称为上下文索引,每一个模型都能够由唯一的索引 号标注。

4.1.2 上下文模型参数的计算

在上下文建模的初始化中,确定initValue的值后,会根据这个值以及QP的值计算两个概率参数iPA和iPB。VTM4.0中采用了并行双窗口技术,使用了两个独立的概率参数iPA 和iPB ,表示概率的估计值。这两个参数以2^15(32153)将概率区间[0,1] 扩展, 以整数进行表示。此外还确定了一个控制上下文模型概率更新速度的参数Mi。

void BinProbModel_Std::init( int qp, int initId )
{
  int slope     = ( ( initId >>  4 )  * 5 ) - 45;
  int offset    = ( ( initId  & 15 ) << 3 ) - 16;
  int inistate  = ( ( slope   * qp ) >> 4 ) + offset;

  const int p1 = m_inistateToCount[inistate < 0 ? 0 : inistate > 127 ? 127 : inistate];
  m_state[0]   = p1 & MASK_0;
  m_state[1]   = p1 & MASK_1;//这也是M0453提案提出的对于上下文模型计算技术的改进  
}

在确定两个概率参数iPA和iPB后,计算两个参数的均值P = (iPA + iPB)/2,并将这个值用于算术编码 器中的1与0区间划分。在常规编码器中,会通过P和range的值通过查表的方式代替乘法操作确定R0的长度。在这个表格中,P的值越大,R0的取值越小。

UShort uiLPS = TComCABACTables : : sm aucLPSTable [ rcCtxModel . getState () >>6][(m uiRange>>2)−64];

4.2 上下文模型的更新
在算术编码的过程中,会根据当前编码symbol的值来更新上下文模型,更新的参数是当前所选择的 上下文模型所对应的iPA和iPB。实际上iPA和iPB都是与MPS的概率成正相关的概率估计值,初始值相 同,只是更新的速率不同。使用并行双窗口的目的是达到一个最佳的更新速度(shift values were chosen to obtain the best balance between speed of adaptation, when probability changes within a context) 。上 下文模型自适应更新达到的目的是,若当前编码的是symbol是0,则下一次再使用这个模型时(这个ctxIdx 位置的initV alue 值),0的概率增大,1的概率减小;若当前编码的是symbol 是1,则下一次再使用这个模 型时,0的概率减小,1的概率增大。这是因为在常规编码器中,每次所更新的range 越大,输出的比特数 就越少。上下文模型的更新是通过以下公式实现的:
在这里插入图片描述
当symbol为1时,iPA和iPB都增大,同时P也增大,在相同range的情况下,R0的长度减小;
当symbol为0时,iPA和iPB都减小,同时P也减小,在相同range的情况下,R0的长度增大;

上下文模型概率更新速度的参数Mi与切片类型和切片级量化参数有关,默认值是4,取值范围是(4,5,6,7)。iPA以变化的更新速率参数Mi进行更新,更新速度较快;iPB以固定的更新速率8进行更新,更新速率较慢。

5.2 代码实现
5.2.1 start()
解码器的初始化如下:

void BinDecoderBase::start()
{
  CHECK( m_Bitstream->getNumBitsUntilByteAligned(), "Bitstream is not byte aligned." );
#if RExt__DECODER_DEBUG_BIT_STATISTICS
  CodingStatistics::UpdateCABACStat(STATS__CABAC_INITIALISATION, 512, 510, 0);
#endif
  m_Range       = 510;
  m_Value       = ( m_Bitstream->readByte() << 8 ) + m_Bitstream->readByte();
  m_bitsNeeded  = -8;
}

• 其中range的初始值与编码端相同,为510。
• m_Value存储的是传过来的比特流中的前16位,可以理解为编码器最终的low_final的前16位。
• m_bitsNeeded可以理解为m_Value中的一个指针,该指针之前是比较过的位数,需要移出m_Value。(或 者可以理解为还需从比特流中读入多少为才能使得m_ Value中的位数达到8位)。示意图如图5.2所 示。每解码一位比特,都会进行m_bitsNeeded的判断,若m_bitsNeeded >= 0则从比特流中读 取8位,补入到m_Value中,并将m_bitsNeeded的值减8。
在这里插入图片描述
m_uiValue每左移一位,m_bitsNeeded都会加1。 每解码一位比特,都会进行m_bitsNeeded的检测, 若m_bitsNeeded >= 0,说明m_uiValue位数不足8位,则从比特流中读取8位,补入到m_uiValue中, 并将m bitsNeeded的值减8。
在这里插入图片描述
5.2.2 decodeBin()
解码流程如下:
• 接收到待解码的比特流m_uiValue,以及所采用的概率模型。

• 同编码端一样根据概率模型计算出R0,进而计算出R1 = range−R0。令scaledRange = R1 << 7, 扩展到16位方便比较。

• 若m_uiValue < scaledRange,则恢复出的symbol = 1,通过函数updatecode1()更新上下文模型。 进行与编码端一样的区间更新range = R1。

• 若m_uiValue >= scaledRange,则恢复出的symbol = 0,通过函数updatecode0()更新上下文模型。 进行与编码端一样的区间更新range = R0,m_uiValue =m_uiValue−scaledRange。

• 进行与编码端一样的重归一化操作,m_uiRange << numBits,m_uiValue<< numBits,m_bitsNeeded+ numBits。
• 判断m_bitsNeeded是否大于零。

具体解码示例如图5.3所示。
在这里插入图片描述
5.2.3 decodeBinEP()
旁路编码器的解码可以和编码端的流程对照起来看,如图5.4,5.5所示:
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/Peter_Red_Boy/article/details/89684179