大概在2021年的时候,出现了一种新的图像格式。这个格式简单高效,虽然压缩后的体积比PNG稍微大了一点,但是编码效率比PNG快了20多倍左右,解码效率也快了2倍。图像格式不仅开源还能自由使用。最重要的是它简单,它整个规范就只有一页,不像PNG的规范有80多页这么长。这个图像格式简单到琪露诺看了规范都能写个解码器出来。这个如此高大上的格式却有着一个特别随意的名字,叫做QOI(Quite OK Image)。
感觉可以翻译成“差不多得了图像格式”(笑)
我们先来看看它的规范文档:

是不是很一目了然?这个格式最大的特点就是简单。相信你熟读了规范之后,也能自己动手在几天时间内做出一个解码器。
接下来开始深究规范。
首先QOI定义了14字节的头部。最开始的4字节magic是文件标识,内容恒定为“qoif”,用来说明这是个QOI文件(在Linux上通常是看文件内容而不是后缀名来区分格式)。接着是两个32位大端序无符号整数width和height,用来标识图像的长度和宽度。下一个则是8位无符号整数channels,用来说明图像的通道信息。目前仅定义了3表示RGB图像,4表示RGBA图像。最后一个是8位无符号整数colorspace用来表示图像的色彩空间。0代表RGB通道的色彩空间是sRGB,Alpha通道是线性空间。1代表所有通道都是线性空间(通常用于渲染方面,感兴趣的可以去了解一下图形学中的Gamma校正)。另外提一嘴QOI图像是不预乘Alpha的。
qoi_header {
char magic[4]; // magic bytes "qoif"
uint32_t width; // image width in pixels (BE)
uint32_t height; // image height in pixels (BE)
uint8_t channels; // 3 = RGB, 4 = RGBA
uint8_t colorspace; // 0 = sRGB with linear alpha, 1 = all channels linear
};
之后便紧跟着一系列区块(Chunk),类似于PNG的数据块。区块以2-bit或者8-bit的标识作为开头来区分不同区块,并且区块比特长度能被8整除(意味着与字节对齐)。目前定义的区块有:
-
0b11111110 QOI_OP_RGB:记录了完整的RGB像素
-
0b11111111 QOI_OP_RGBA:记录了完整的RGBA像素(只有RGBA图像才会出现)
-
0b00xxxxxx QOI_OP_INDEX:记录了索引,指向数组里的像素(这个数组下文会提到)
-
0b01xxxxxx QOI_OP_DIFF:记录了与前一个像素的差值
-
0b10xxxxxx QOI_OP_LUMA:也是记录了与前一个像素的差值,但允许记录的差值的范围更大
-
0b11xxxxxx QOI_OP_RUN:行程编码,记录了重复之前像素的次数
注意,8-bit标识优先于2-bit标识。当解码器读到2个0b1比特的时候会接着向后读取判断它是不是QOI_OP_RGB或者QOI_OP_RGBA,如果都不是才会判断为QOI_OP_RUN。
当整个图像都被像素填满时编解码工作就结束了,在数据流末尾还有7个0x00字节和1个0x01字节作为结束标志。而PNG貌似要读到IEND数据块才算结束。由于QOI有大部分区块是参考之前的像素来计算出当前像素的值,所以定义了编解码器以[r:0, g:0, b:0, a:255]作为第一个像素的参考值。同时QOI编解码器还维护着能存储64个像素的数组,初始值为0,每次处理完一个像素就复制一个放到这个数组里。存放的位置由以下公式计算:
index_position = (r * 3 + g * 5 + b * 7 + a * 11) % 64
接下来是区块的定义:
QOI_OP_RGB
Byte[0] 7 6 5 4 3 2 1 0 | Byte[1] 7 .. 0 | Byte[2] 7 .. 0 | Byte[3] 7 .. 0 |
| 1 1 1 1 1 1 1 0 | 红色值r | 绿色值g | 蓝色值b |
如果图像有Alpha通道,那么解码这个区块的时候,当前像素的Alpha值保持为前一个像素的Alpha值。
QOI_OP_RGBAByte[0] 7 6 5 4 3 2 1 0 | Byte[1] 7 .. 0 | Byte[2] 7 .. 0 | Byte[3] 7 .. 0 | Byte[4] 7 .. 0 |
|---|
| 1 1 1 1 1 1 1 1 | 红色值r | 绿色值g | 蓝色值b | Alpha值a |
QOI_OP_INDEXByte[0] 7 6 | 5 4 3 2 1 0 |
|---|
| 0 0 | 索引index |
索引值的范围是0-63。
QOI_OP_DIFFByte[0] 7 6 | 5 4 | 3 2 | 1 0 |
|---|
| 0 1 | 红色差dr | 绿色差dg | 蓝色差db |
色差存储的是当前像素与前一个像素的差值。举个例子:当前像素的红色值是114,前一个的是113,那么红色差就是114-113=1。
色差值的范围是-2-1,存储时要加上偏移量2,然后再存储为无符号整数。例子:-2存储为0(0b00),1存储为3(0b11)。
超出0 --- 255范围的值会环绕到此范围内,可能大家不知道是怎么个“环绕”法,具体做法是给小于0的值加上256,大于255的就减去256(这种做法并不是最高效的,只是为了演示原理)。举个例子:
1 - 2 = -1 --> -1 + 256 = 255 ( -1 --> 255)
1 - 3 = -2 --> -2 + 256 = 254 ( -2 --> 254)
255 + 1 = 256 --> 256 - 256 = 0 (256 --> 0)
255 + 2 = 257 --> 257 - 256 = 1 (257 --> 1)
Alpha值保持和前一个像素的不变。
QOI_OP_LUMAByte[0] 7 6 | 5 4 3 2 1 0 | Byte[1] 7 6 5 4 | 3 2 1 0 |
|---|
| 1 0 | 绿色差dg | 红色差与绿色差的差值dr_dg | 蓝色差与绿色差的差值db_dg |
与QOI_OP_DIFF类似,但是能存储的值的范围更大,也更复杂(bushi)
绿色差的范围是-32-31,存储时加上偏移量32。
红色差与绿色差的差值 & 蓝色差与绿色差的差值 的范围都是-8-7,偏移量都是8。这两个差值的计算方法如下:
/* cur_px 代表当前像素 */
/* prev_px 代表前一个像素 */
dr_dg = (cur_px.r - prev_px.r) - (cur_px.g - prev_px.g)
db_dg = (cur_px.b - prev_px.b) - (cur_px.g - prev_px.g)
和QOI_OP_DIFF一样有环绕操作,同样保持Alpha值不变。
QOI_OP_RUNByte[0] 7 6 | 5 4 3 2 1 0 |
|---|
| 1 1 | 行程长度run |
行程编码。行程长度决定了要重复多少次前一个像素的内容,范围是1-62,存储时加上偏移量-1。
需要注意的是,使用63(0b111110)和64(0b111111)作为行程长度会和QOI_OP_RGB以及QOI_OP_RGBA的区块标识冲突。
为了达到更好压缩,编码时通常会按顺序选择编码成哪种区块。从上往下依次是:
- (1-byte)QOI_OP_RUN
- (1-byte)QOI_OP_INDEX
- (1-byte)QOI_OP_DIFF
- (2-byte)QOI_OP_LUMA
- (4-byte)QOI_OP_RGB
- (5-byte)QOI_OP_RGBA
当像素无法被编码成第一种区块时就会向下尝试直至最后一个。由于所有的区块中只有QOI_OP_RGBA能记录Alpha值的变化,而这种区块会把单个像素编码成5个字节,所以QOI处理Alpha渐变的区域不仅节省不了空间反而还会倒贴(
到这里规范的内容讲的几乎差不多了。因为快到高二开学了,匆匆忙忙赶出了这篇博客,可能会有疏忽的地方。~~同时我这个人语言水平不太好,有些地方讲的可能甚至不如琪露诺。~~等有时间了看看能不能把QOI解码器教程给肝出来吧(挖坑ing)