一、熵编码基本原理
熵编码即编码过程中按熵原理不丢失任何信息的无损编码方式,也是有损编码中的一个关键模块,处于编码器的末端。信息熵为信源的平均信息量(不确定性的度量)。常见的熵编码有:香农(Shannon)编码、哈夫曼(Huffman)编码,指数哥伦布编码(Exp-Golomb)和算术编码(arithmetic coding)。由于熵编码的是编码器通过量化、变换、运动、预测等一系列操作之后得到的需要编码的符号,根据编码符号的分布情况选择适合的熵编码模型,因此熵编码是一个相对独立的单元,可以不止适用于视频编解码,在其他编码器,如图像编码、点云编码中同样适用。
二、定长编码
固定长度编码一种二进制信息的信道编码。这种编码是一次变换的输入信息位数固定不变。简称“定长编码”。这种编码的编译码电路比较简单。但是以相同长度定义不同的数据会有很大的冗余,比如用8位二进制表示十进制0-255之间的数:
十进制 1 2 4 5 255
二进制 00000001 000000010 000000100 00000101 11111111
有效位 1 2 3 3 8
可以看出如果用8位定长的二进制表示0-255之间的数会有很大的冗余,但是我们又不能直接用有效位数来表示上面5个数,因为在解码端根本不能正常解码,如果直接用有效位数传上面五个数则会有11010010111111111,当解码端收到这一连串的二进制位,当收到第一个1的时候我们不知道1是代表数字1还是和后面的1组合成11解码出3,又或者是和后面的数字组成更大的数,这样就导致无法解码。变长编码则是作为一种压缩编码算法,能很有效地对原本的数据进行压缩,并且能很容易地把编码后的码流分离成码字。
三、变长编码
对信源输出的消息采用不同长度的码字表示,这种编码方式称为变长编码。为了提高编码效率,需要根据符号出现的概率大小设计码长,即大概率符号采用较短的码字表示,小概率符号用较长的码字表示,以达到平均码长最短的目的,变长码必须是唯一可译码才能实现无失真编码。常见的变长码有哈夫曼码,指数哥伦布码等。下面主要介绍这两种编码方式。
3.1 哈夫曼编码
哈夫曼码被证明是一种最佳变长码,也是最经典的变长码,其基本思想是大概率符号采用较短的码字表示,小概率符号用较长的码字表示,使平均码长最短。哈夫曼编码的具体原理及实现可以参考博客。
尽管哈夫曼编码是一种最佳变长码,但是它存在很多一些之处。
1.编解码器需要知道哈夫曼编码树的结构,编码器需要为解码器保存或者传输哈夫曼树。
2.传统哈夫曼解码方式是从码流中一次读入比特,直到在哈夫曼树中搜索得到相应码字,增加了解码器的解码复杂度。
3.哈夫曼编码的不规则结构导致了哈夫曼解码速度慢。
因此研究者提出一些具有规则结构的变长码来避免哈夫曼码的不足,下面的指数哥伦布码就是其中的一种。
3.2 指数哥伦布编码
上面说到,如果只是把有效码字串联起来,得到的只是一串无用的码流,因为这串码流中并没有描述单一码字的信息量,也就是无法对码流进行分离。哥伦布编码就采用了加0前缀,用于表达码字的信息量,在得到m个0前缀后,就能知道该码字在码流中的长度,并从码流中把码字分离出来。
指数哥伦布编码是一种在音视频编码标准中经常采用的可变长编码方法,它是使用一定规则构造码字的变长编码模式。它将所有数字分为等大小不同的组,符号值较小的组分配的码长较短,同一组内符号长基本相等,并且组的大小呈指数增长。在编码中经常被用于编码高层语法元素、参数集中,有时候也被用于编码比较大的残差。
指数哥伦布码由前缀和后缀组成,前缀和后缀都依赖于指数哥伦布码的阶数K,指数哥伦布码的逻辑结构为
[Mzero][1][INFO]——MZero表示M个0,INFO为后缀部分。
编码后码长为2M + 1 + k,其中M为前缀长度,1为中间的1长度,M+k为后缀长度。
用来表示非负整数N的K阶指数哥伦布码可用如下步骤生成:
① 将数字N以二进制表示,去掉最低的k个比特位,然后对得到的值加一。
②计算留下的比特位,将此数减一,得到需要增加的前缀0的个数。
③将不走①中去掉的最低k个比特位补回比特串尾部。
例如:求4的一阶指数哥伦布编码:
①4的二进制表示为100,去掉最低1个比特位0变成10,加1后变为11。
②11的比特数为2,减一得到前缀中0的个数为1。
③在比特串最低位补上①中去掉的一个0,最终的码字为0110。
下表给出了零阶指数哥伦布编码的部分码字和其代表的十进制数:
码字 十进制数
1 0
010 1
011 2
00100 3
00101 4
00110 5
00111 6
0001000 7
0001001 8
0001010 9
… …
对于有符号数,我们可以先将有符号数转化为无符号数,然后对其进行编码,转换规则如下所示:
unsigned long
IntToUInt(long value)
{
return (value < 0) ? static_cast<unsigned long>(-1 - (2 * value))
: static_cast<unsigned long>(2 * value);
}
我们可以根据需要编码的数值是有符号数还是无符号数选择有符号指数哥伦布编码还是无符号指数哥伦布编码。
四、算术编码
算术编码的基本原理是:根据信源可能发现的不同符号序列的概率,把(0,1)区间划分为互不重叠的子区间,子区间的宽度恰好是各符号序列的概率。这样信源发出的不同符号序列将与各子区间一一对应,因此每个子区间内的任意一个实数都可以用来表示对应的符号序列,这个数就是该符号序列所对应的码字。显然,一串符号序列发生的概率越大,对应的子区间就越宽,要表达它所用的比特数就减少,因而相应的码字就越短。算术编码作为一种高效的数据编码方法在文本,图像,音频,视频,点云等压缩中有广泛的应用。它是一种到目前为止编码效率最高的统计熵编码方法,它比著名的Huffman编码效率提高10%左右。算术编码的详细原理可以参考博客。算术编码中最著名的就是在视频编码中常用的CABAC(基于上下文的自适应二进制算术编码)。下面分开讲一些用到CABAC编码的熵编码器,可以通过编码符号的分布特性选择不同的熵编码方式。
4.1 游程编码
游程编码适合于编码的符号中有连续的重复符号时使用。游程编码的基本思想是碰到连续的重复符号只需要编码重复符号的个数和一个重复的符号即可。比如编码11111111时,原始数据需要8个比特,但是如果用游程编码,我们只需要编码10001(前面的1000是数字8,表示有8个重复的1,最后的一个1代表重复的元素是1)。只需要5个比特,节省了3个比特。游程编码一个经典的用处是点云压缩标准G-PCC中的属性残差编码,这是由于点云压缩属性编码中的预测已经非常准确,基本上预测值和实际值相当,得到的残差基本上是零,由于这种分布特性,选取游程编码是非常明智的选择。G-PCC中游程编码过程的伪代码如下所示:
for (size_t predictorIndex = 0; predictorIndex < pointCount;++predictorIndex)
{
if (!attValue0)//如果属性值为0
++zero_cnt;//用于对0的个数进行计数
else {//如果属性值不为0
zerorun.push_back(zero_cnt);//存储0的个数
zero_cnt = 0;//计数器重新置零
}
residual[predictorIndex] = attValue0;//如果属性值不是零,将该属性值保留下来
}
zerorun.push_back(zero_cnt);
int run_index = 0;
encoder.encodeRunLength(zerorun[run_index]);//编码0的个数
zero_cnt = zerorun[run_index++];
for (size_t predictorIndex = 0; predictorIndex < pointCount;++predictorIndex)
{
if (zero_cnt > 0)
zero_cnt--;
else
{
encoder.encode(residual[predictorIndex]);//编码非零残差值
encoder.encodeRunLength(zerorun[run_index]);//编码0的个数
}
}
其中两个编码函数encode以及encodeRunLength如下所示:
encode函数:
void
PCCResidualsEncoder::encode(int32_t value)
{
int mag = abs(value) - 1;//残差不可能为0,所以绝对值≥1
encodeSymbol(mag, 0, 0, 0);//编码残差的绝对值
arithmeticEncoder.encode(value < 0);//编码残差的符号位
}
其中encodeSymbol如下面函数所示:
void
PCCResidualsEncoder::encodeSymbol(uint32_t value, int k1, int k2, int k3)
{
bool isZero = value == 0;
arithmeticEncoder.encode(isZero, ctxCoeffEqN[0][k1]);//用一个上下文编码0
if (isZero)
return;
bool is1 = value == 1;
arithmeticEncoder.encode(is1, ctxCoeffEqN[1][k2]);//用一个上下文编码1
if (is1)
return;
arithmeticEncoder.encodeExpGolomb(
value - 2, 1, ctxCoeffRemPrefix[k3], ctxCoeffRemSuffix[k3]);//1阶指数哥伦布编码value - 2
}
通过统计可以知道,encodeSymbol编码的值value的分布是0和1占比比较多,因此分别用一个上下文编码value值为0或1的情况。其中:ctxCoeffRemPrefix模型和ctxCoeffRemSuffix模型分别是用来设计残差指数哥伦布编码时的前缀和后续比特位;
encodeRunLength函数:
void
PCCResidualsEncoder::encodeRunLength(int runLength)
{
auto* ctx = ctxRunLen;
for (int i = 0; i < std::min(3, runLength); i++, ctx++)
arithmeticEncoder.encode(1, *ctx);//小于3部分采用算术编码
if (runLength < 3) {
arithmeticEncoder.encode(0, *ctx);//利用算术编码进行编码指数哥伦布的0bit位,用于解码端恢复
return;
}
runLength -= 3;
auto prefix = runLength >> 1;
for (int i = 0; i < std::min(4, prefix); i++)
arithmeticEncoder.encode(1, *ctx);//利用算术编码来编码value小于8的部分,解码端利用非零的个数恢复得到数值
if (runLength < 8) {
arithmeticEncoder.encode(0, *ctx);//0bit位用于解码端恢复
arithmeticEncoder.encode(runLength & 1);//对最低bit为进行算术编码
return;
}
runLength -= 8;
arithmeticEncoder.encodeExpGolomb(runLength, 2, *++ctx);//二阶指数哥伦布编码编码
}
encodeRunLength是用来编码0的个数,但是0的个数同样是一些有规律的符号,通过统计可以发现0的个数为0-8的概率较大,因此0-8又通过设计上下文对其进行熵编码。
4.2 截断定长编码
4.3 常见熵编码方式举例
通过上面对熵编码的介绍我们可以通过编码符号的分布特性来设计基本的熵编码器。除了上面介绍的两种常见的熵编码方式外,下面我们再对一些常见的符号分布特性来设计基本的熵编码器:
4.3.1 编码符号集中分布在一个区间
假设需要编码一些符号,通过统计发现这些符号集中分布在0~15,我们可以对符号小于15时采用算术编码,分配15个上下文;在大于15时采用指数哥伦布编码,伪代码如下:
if (value < 15) {
_aec->encode(value & 1, ctxs[0]);
_aec->encode((value >> 1) & 1, ctxs[1 + (value & 1)]);
_aec->encode((value >> 2) & 1, ctxs[3 + (value & 3)]);
_aec->encode((value >> 3) & 1, ctxs[7 + (value & 7)]);
} else {
_aec->encode(1, ctxs[0]);
_aec->encode(1, ctxs[2]);
_aec->encode(1, ctxs[6]);
_aec->encode(1, ctxs[14]);
_aec->encodeExpGolomb(value - 15, 0, _ctxEG2);//采用零阶指数哥伦布编码,同时分配一个概率模型
}
4.3.2 编码符号是一个1bit的标志位
假设需要编码的符号是一个flag,只有1bit,用于控制一些工具的开关,那么我们可以直接用一个上下文对其进行熵编码,伪代码如下:
_arithmeticEncoder->encode(flag, ctxFlag);
1
4.3.3 编码符号是有符号数
假设需要编码的符号是有符号数,并且该符号为0的占比较大,那么我们可以用一个上下文编码该符号位是否为零,然后用一个上下文编码该符号的符号位,然后用指数哥伦布编码编码残差。伪代码如下:
if (v == 0)
{
_arithmeticEncoder->encode(1, ctxIsZero);
}
else
{
_arithmeticEncoder->encode(0, ctxIsZero);
_arithmeticEncoder->encode(v < 0, ctxSign);
if (v < 0)
v = -v;
v--;
// expGolomb on |v|-1 with truncation
_arithmeticEncoder->encodeExpGolomb(uint32_t(v), 1, expGolombV0, expGolombV, boundPrefix, boundSuffix);
}