学习背景
近期为了完成公司项目中视频合成能力的开发,笔者接触和学习了较多音视频、FFmpeg等相关知识。为了更进一步了解音视频文件、编解码等相关的知识,笔者计划从易到难地学习一些常见的媒体文件格式,通过实现其编/解码器的方式来深化学习成果。JPEG作为图片格式,其使用的编码技术已有不少和视频编码有相通之处,并且入门难度尚可,因此可以作为较好的学习对象。
相关链接
学习过程中以下这些资料和文章是笔者参考较多的
- yasoob.me/posts/under... 主要讲述编码大致原理,以及使用Python如何实现一个解码器
- koushtav.me/jpeg/tutori...
- koushtav.me/jpeg/tutori... 同上,本篇是如何使用C++实现编码器
- www.w3.org/Graphics/JP...
- www.w3.org/Graphics/JP... JPEG工作组编写的官方规范。前者的内容是JPEG编解码,后者的内容是JFIF文件格式
解码篇
拆解文件和读取元信息
通过上图我们可以知道,JPEG作为一种二进制文件,内部是由若干个带有特殊标记开头的片段(marker segment)组成的。每个片段的特殊标记后紧随的是此片段的长度(segment length),由此我们可以先将文件完整读取一遍,并将上述的片段拆解出来,后续再分别处理各个片段。
读取JPEG需要对二进制文件进行操作。而对JPEG文件进行分段,主要涉及到以下这些文件操作:
- 以字节为单位读取文件内容,并判断是否与某字符串相等(判断是JFIF的哪个片段)
- 获取文件当前的读取位置,或者改变文件当前的读取位置(一般称为seek操作,用来跳过大段的JPEG文件数据,并定位到下一个片段的开头)
- 从文件内容中读取一个数字(用于获取片段的长度)
以下是代码示例
c++
void JpegDecoder::get_segments() {
// JPEG文件必须以SOI头开始,marker为ffd8
// read_assert_str_equal是一个宏,用来判断接下来读取的文件内容,是否等于某字符串
read_assert_str_equal(this->file, buf, "\xff\xd8", "start of image header error");
char marker[2];
int seg_length;
// 在到达文件结束位置之前,不断读取JPEG的文件片段
while (true) {
// 读取marker
this->file.read(marker, 2);
// 读取当前片段的长度,注意JPEG中的数字是大端序存储的,需要读取2个字节
seg_length = read_u16_be(this->file) - 2;
// 将此片段的位置和长度保持下来,方便后续快速跳转
long long cur = this->file.tellg();
segment_info_t info = { cur, seg_length };
// 根据marker的不同,将片段的位置和长度信息添加到不同类型的片段数组中。
// 某些片段(例如DHT, DQT等允许存在多个)
if (!memcmp(marker, "\xff\xe0", 2)) {
this->segments[segment_t::APP0].push_back(info);
} else if (!memcmp(marker, "\xff\xdb", 2)) {
this->segments[segment_t::DQT].push_back(info);
} else if (!memcmp(marker, "\xff\xc4", 2)) {
this->segments[segment_t::DHT].push_back(info);
} else if (!memcmp(marker, "\xff\xc0", 2)) {
this->segments[segment_t::SOF0].push_back(info);
} else if (!memcmp(marker, "\xff\xfe", 2)) {
this->segments[segment_t::COMMENT].push_back(info);
} else if (!memcmp(marker, "\xff\xdd", 2)) {
this->segments[segment_t::DRI].push_back(info);
} else if (!memcmp(marker, "\xff\xda", 2)) {
this->segments[segment_t::SOS].push_back(info);
// SOS片段的最后会紧跟着压缩后的图像数据,此时不会再有其他的marker了。跳出循环
break;
}
// 跳过当前片段,到达下一个片段的marker位置
this->file.seekg(seg_length, ios::cur);
}
}
准备解码上下文
JPEG文件中,除了SOS(Start of scan)片段紧跟着的图像数据,其余的部分都是为解码提供各种参数的上下文。如果需要实现一个最简单的JPEG解码器(不考虑一些JPEG规范中的高级功能的情况下),以下这些上下文是解码过程中所必须的:
量化表x2
指的是分别用于亮度(Luma)和色度(Chroma)的两张量化表。量化表的形式上是一个长度为64的正整数的数组,用于8x8的块中每个系数(coefficient)的量化。我们可以在JPEG的DQT段(Define Quantization Table)中解析出来量化表,由于量化表是在文件中连续储存的,并且使用时也是连续的,我们直接将这段文件数据储存到数组中即可。
Huffman表x4
JPEG编码的最后一步,是使用Huffman编码,将一组系数(coefficient)转化为连续的Huffman编码形成的0和1的bitstream。因此在解码的场景中,第一步就需要我们基于Huffman表进行解码。
Huffman编码是著名的信息无损压缩算法,其特点是
- 生成的编码为可变长度的,较为节省空间。实践中可以把一段信息中高频出现的符号分配给短的Huffman编码,达到更好的总体压缩率
- 一个Huffman表中的某一编码,不会是其他任意编码的前缀。这使得Huffman表也可以被转化成一个二叉树,对应编码的符号均在树的叶子节点上。在解码的场景中,我们可以借由Huffman表转化成的二叉树,一边读取由0和1组成的压缩数据流,一边从树的根节点出发,选择向左或向右走,到达叶子节点时,则表示解出了一个有效的符号。相比使用符号和编码的映射表来说,使用二叉树会更加适合JPEG解码的场景,更为高效。
在JPEG标准中,每张Huffman表使用了一种非常紧凑的方式来存储,被称为 Huffman table specification。大致格式如下(注意下面仅为示意,实际文件中,每一个字符对应着一个字节,该字节不一定是可见的ascii字符)
css
0 1 5 1 1 1 1 1 1 0 0 0 0 0 0 0 a b c d e f g h i j k l
上面这段 Huffman table specification 中,前16个字节表示从1-16每个长度分别有多少个编码 。从左至右,我们可以这样来解读:0表示这张Huffman表中,没有长度为1的编码;1表示有1个长度为2的编码;5表示有5个长度为3的编码;1表示有1个长度为4的编码。依此类推。剩余的所有字节,其总长度为前16个字节的数字之和(这里是1+5+1+1+1+1+1+1 = 12),表示Huffman表中所有的需要被编码的符号。
仅仅是了解了 Huffman table specification的结构,仍然不足以理解实际的Huffman表或者二叉树是如何生成的。我们可以进一步地举例来看:
rust
- 没有长度为1的编码
无需处理
- 有1个长度为2的编码
我们将长度为2的最小编码 00 赋给符号中的第一个a,即 a -> 00
- 有5个长度为3的编码
由于编码不能前缀重合的原则,则 00x 都不能使用了。我们将此时长度为3的最小编码 010 赋给下一个符号b,即 b -> 010;并依次确定其余的长度为5的编码 c -> 011, d -> 100, e -> 101, f -> 110
- 有1个长度为4的编码
仍然是由于编码不能前缀重合的原则,110x 不能使用了。我们将此时长度为4的最小编码 1110 赋给下一个符号g,即 g -> 1110
- 依此类推,剩余的编码为 h -> 11110, i -> 111110, j -> 1111110, k -> 11111110, l -> 111111110
基于以上理解,我们可以按下面的算法,将JPEG文件中的 Huffman table specification 转化为一个二叉树(这里主要参考了Let's Write a Simple JPEG Library, Part-II: The Decoder中 Huffman Tree 一节所提供的算法,并作了一定的改进)
构建HuffmanTree的要点在于:
- 树的深度代表了编码的长度,长度为N的编码,所对应的符号的叶子节点就会出现在这个深度上。
- 由于symbols(待编码的符号数组)是从短编码的符号长编码的符号顺序排列的,我们应从上到下逐层构建出树的节点
- 又由于同长度的编码,其递进关系是二进制的递增(如上面的b,c,d,e符号,编码依次是010,011,100,101),这相当于在二叉树中,同一层的节点上进行向右遍历。
据此,我们可以采用这样的构建树算法
- 创建一个队列,用来保存"当前树深度下,需要处理的节点列表"。
- 对于某一编码长度(在树的同一深度上),有N个待编码的符号。则我们在队列中从左至右遍历这些节点,将这些空白节点填上符号,变为叶子节点,直至N个符号被用完为止。
- 此深度上的剩余节点,将会作为下一个编码长度的编码前缀。因此我们从这些剩余节点上继续向下新增左右节点。并把这些新节点加入到队列中,并从队头将上一轮已遍历的节点出列。
下图中展示了先前的例子所对应的HuffmanTree的构建过程,可以看到节点是逐个深度加入到树中的。当前深度正在遍历的节点为粉色,从空白节点新增的,下一深度待遍历的节点为虚线白色。
c++
// HuffmanTree的构造函数,入参为Huffman table specification的两部分
HuffmanTree::HuffmanTree(char nb_sym[16], const char* symbols) {
// 初始化树的根节点,同时也将根节点的左右子节点初始化
this->root = std::make_shared<Node>();
auto root_left = std::make_shared<Node>();
auto root_right = std::make_shared<Node>();
this->root->left = root_left;
this->root->right = root_right;
// 我们可以维护一个遍历某一深度的节点所使用的队列。队列的头部是当前深度需要遍历的节点,尾部是新增的下一深度需要遍历的节点。在下一深度的节点遍历开始之前,将当前深度的节点出列即可
std::vector<std::shared_ptr<Node>> depth_queue { root_left, root_right };
size_t j = 0;
for (size_t depth = 0; depth < 16; depth++) {
// 对于1-16每一个编码长度
char nb_sym_in_depth = nb_sym[depth];
size_t queue_prev_length = depth_queue.size();
for (size_t i = 0; i < queue_prev_length; i++) {
// 遍历每一个当前深度的节点
Node* node = depth_queue[i].get();
// 从左向右,依次将待编码的符号分配给各个节点
if (nb_sym_in_depth > 0) {
node->letter = symbols[j];
j++;
nb_sym_in_depth--;
// 直到这个编码长度的符号没有了,则剩余的各个节点,需要继续向下新增左右节点
} else {
auto left = std::make_shared<Node>();
auto right = std::make_shared<Node>();
node->left = left;
node->right = right;
// 并作为下一次待遍历的节点,入列到队尾
depth_queue.push_back(left);
depth_queue.push_back(right);
}
}
// 当前深度节点已全部遍历,从队头出列
for (size_t i = 0; i < queue_prev_length; i++) {
depth_queue.erase(depth_queue.begin());
}
}
}
在JPEG中共有4张Huffman表,2张用于Luma,2张用于Chroma。每2张Huffman表中,又有1张用于DC系数(即8x8的块中最左上角的值,用以代表二维图片中最主要的信息,例如整体的亮度),另一张用于AC系数(即8x8的块中其余63个值,代表了一个二维信号中的次要信息,例如一些细节的亮度变化)。由通过上面的算法,我们可以得到4个HuffmanTree,并在解码的过程中按需选择。
SOF, SOS 片段
SOF全称为Start of frame,SOS全称为SOS,全称为Start of scan。从JPEG文件的结构来看,Frame表示的是一整个图像,一个JPEG文件中只能包含一个Frame;Scan表示的是一段图像数据,一个JPEG文件中可以包括若干个Scan。 SOF和SOS均提供了对于JPEG解码的重要信息,特别是对一个JPEG文件进行了性质上的分类。JPEG标准中,规定了很多JPEG的变体,例如
- Baseline or Progressive ,前者意味着JPEG文件中只包含一个清晰度的文件,图像会逐行解码加载;后者顾名思义,JPEG文件中包含了多张分辨率从低到高的图像,图像加载时会逐渐由模糊到清晰
- Huffman or Arithmetic,这是两种熵编码的算法。使用Huffman方式较为主流
- 8-bit or 12-bit,色彩的位深,后者使得JPEG的支持的色彩更为精细。8-bit为主流
如果只需要支持最主流JPEG解码的情况下(Baseline + Huffman + 8-bit),我们可以判断SOF的头是否是SOF0类型(marker为ffc0)即可。
同时,我们还需要从SOF,SOS中提取以下这些重要信息:
图像的宽高
存储于SOF片段的第2-5字节中,是两个16位大端数(注意顺序为先高后宽),分别读取到程序的变量中即可。
通道与Huffman table的对应关系
需要从SOS片段的第2个字节开始读取,一共有6个字节(对于3通道的图像来说)。所包含的信息可以通过下面的例子来说明
arduino
// 这两个字节表示 第一个通道 Y,使用第0张DC Huffman表和第0张AC Huffman表
0x01 0x00
// 这两个字节表示 第二个通道Cb,使用第1张DC Huffman表和第1张AC Huffman表
0x02 0x11
// 这两个字节表示 第三个通道Cr,使用第1张DC Huffman表和第1张AC Huffman表
0x03 0x11
图像的通道信息、采样信息
在理解这部分信息之前,需要回顾一些基础概念
关于 YUV(YCbCr), Luma和Chroma, 色度下采样:
RGB是一种大家比较熟悉也容易理解的色彩模型,即将某种颜色通过Red, Green, Blue三种通道进行表示。一张图片,或者视频中的一帧,可以看成是三个通道上各自图像的合成结果。
而在图像、视频编码领域,YUV(YCbCr)则是更为常用的色彩模型。这种色彩模型使用了1个亮度通道(即Y,英文称为Luma或Luminance),和2个色度通道(即Cb和Cr,英文称为Chroma或Chrominance)来表示一个颜色。 YUV(YCbCr)的优势在于:可以通过对2个色度通道的图像进行缩小(更准确的说法是 下采样 subsampling),来实现图像的压缩。由于人眼对于色度变化的敏感度弱于对于亮度变化的敏感度。对色度的下采样 (Chroma subsampling)可以带来较好的压缩率。
关于数据单元和MCU:数据单元(data unit),在JPEG的语境下指的是一个8x8大小的,对图像单个通道进行采样所形成的块。在JPEG编码规范中,诸如DCT/IDCT,Z字扫描,量化,游程编码等各类算法,也都是在8x8大小的最小单元下进行的
最小编码单元(Minimum coded unit,简称MCU),则是一个包括了所有通道的最小数据单元。由于一个MCU对应着图像上的某个特定位置,并且需要包括所有通道的数据,再加上有的通道可能出现下采样(导致各通道之间大小不统一),因此MCU的大小可能是8x8的倍数,不一定为8x8
图像的通道信息、采样信息,需要从SOF片段第7个字节开始读取,一共有9个字节(对于3通道的图像来说)。这些信息包括:每个通道与量化表的对应关系,有怎样的采样因子(sampling factor)等。 各个通道的采样因子是一个重要的解码参数,可以说很大程度上决定了解码逻辑的大致框架。我们可以举下面两个例子: 假设我们从图像中读取到的通道信息、采样信息的9个字节如下
arduino
// 这三个字节分别表示 第一个通道 Y,水平/垂直采样因子为1:1,使用第0个量化表(Luma量化表)
0x01 0x11 0x00
// 这三个字节分别表示 第二个通道Cb,水平/垂直采样因子为1:1,使用第1个量化表(Chroma量化表)
0x02 0x11 0x01
// 这三个字节分别表示 第三个通道Cr,水平/垂直采样因子为1:1,使用第1个量化表(Chroma量化表)
0x03 0x11 0x01
则从第2、5、8字节可知,三个通道的图像在编码时的采样因子相同,没有使用色度下采样(Chroma subsampling)。这就是俗称的YUV444图像格式。由此,我们可以确定:MCU的大小为8x8;并且从压缩后的数据流中解码时,每解出一个MCU所得到的数据中,只会包含Y, Cb, Cr通道的数据各一个,并按YCbCr的顺序排列。
另一个例子,假设9个字节如下
arduino
// 这三个字节分别表示 第一个通道 Y,水平/垂直采样因子为2:2,使用第0个量化表(Luma量化表)
0x01 0x22 0x00
// 这三个字节分别表示 第二个通道Cb,水平/垂直采样因子为1:1,使用第1个量化表(Chroma量化表)
0x02 0x11 0x01
// 这三个字节分别表示 第三个通道Cr,水平/垂直采样因子为1:1,使用第1个量化表(Chroma量化表)
0x03 0x11 0x01
仅第2个字节换成了0x22,这意味着Cb, Cr两个通道都使用了色度下采样,使得每4个Y通道的8x8采样,才能对应一个Cb和Cr通道的8x8采样。这就是俗称的YUV420图像格式。我们可以确定:MCU的大小为16x16;每解出一个MCU所得到的数据中,包含的通道数据的顺序为 Y1Y2Y3Y4CbCr。
以上两个例子(YUV444, YUV420)在JPEG解码时的采样方式以及bitstream中数据顺序的示意图如下
解析出了JPEG图像的通道信息和采样信息,我们才能为接下来的解码过程进行一些参数初始化和buffer的预分配操作,以下面的代码为例来说明:
c++
// 用于读取SOF0片段中的信息,并进行相应初始化的函数
void JpegDecoder::handle_sof0() {
segment_info_t info = this->segments[segment_t::SOF0][0]; // only one sof0 segment
// 之前已经为JPEG文件分段完毕,这里可以直接跳转到SOF0
this->file.seekg(info.offset, ios::beg);
// 只支持8-bit的JPEG文件
read_assert_str_equal(this->file, buf, "\x08", "data precision not 8");
// 按照先高后宽的顺序读取两个16位大端数,得到图片的宽高
this->h = read_u16_be(this->file);
this->w = read_u16_be(this->file);
// 只支持3通道的JPEG文件
read_assert_str_equal(this->file, buf, "\x03", "image component not 3");
// 读取图像的通道和采样信息(一共9个字节)
// frame_components是一个长度为3的struct数组,可以自动将字节中的信息拆解到相应的field中
this->file.read((char*)&this->frame_components[0], 9);
int* buf_ptr;
// 读取3个通道各自的水平/垂直采样因子
auto [yh, yv] = this->frame_components[0].sampling_factor_packed;
auto [cbh, cbv] = this->frame_components[1].sampling_factor_packed;
auto [crh, crv] = this->frame_components[2].sampling_factor_packed;
// YUV444格式的情况下
if (
yh == 1 && yv == 1 &&
cbh == 1 && cbv == 1 &&
crh == 1 && crv == 1
) {
// 确定MCU的宽高以及图像格式
this->mcu_w = 8; this->mcu_h = 8;
this->sampl = sampling_t::YUV444;
// 为解码阶段预分配MCU所需的内存,Y,Cb,Cr通道各自一个8x8的buffer即可
buf_ptr = new int[64];
this->y_bufs.push_back(buf_ptr);
buf_ptr = new int[64];
this->cb_bufs.push_back(buf_ptr);
buf_ptr = new int[64];
this->cr_bufs.push_back(buf_ptr);
// YUV420格式的情况下
} else if (
yh == 2 && yv == 2 &&
cbh == 1 && cbv == 1 &&
crh == 1 && crv == 1
) {
// 确定MCU的宽高以及图像格式
this->mcu_w = 16; this->mcu_h = 16;
this->sampl = sampling_t::YUV420;
// 为解码阶段预分配MCU所需的内存,Y通道4个,Cb,Cr通道各自一个8x8的buffer
for (int i = 0; i < 4; i++) {
buf_ptr = new int[64];
this->y_bufs.push_back(buf_ptr);
}
buf_ptr = new int[64];
this->cb_bufs.push_back(buf_ptr);
buf_ptr = new int[64];
this->cr_bufs.push_back(buf_ptr);
// 这里还可以支持更多的格式,不再赘述
} else {
throw std::runtime_error("not supported");
}
}
解码开始前的最后一步
在准备好所有的解码参数、表等上下文之后,在实际开始JPEG图像数据的解码之前,我们还需要执行一个被称为de-stuffing的数据流操作。这个概念难以翻译,大意是解码时,遇到数据流中(看成byte stream的情况下),遇到两个字节为ff00的情况,需要当作一个字节ff来处理。这是由于JPEG在编码时,如果编码器输出了一个字节ff,则容易和分段marker混淆,需要在编码后的数据流中,使用ff00来代表ff。
c++
void JpegDecoder::init_bitstream() {
char byte, cur, next;
// 跳转到SOS块的最后,也就是JPEG压缩后的数据开始的位置
this->file.seekg(this->segments[segment_t::SOS][0].offset + this->segments[segment_t::SOS][0].length, ios::beg);
while(true) {
cur = (char)this->file.peek();
if (cur == '\xff') {
this->file.seekg(1, ios::cur);
next = (char)this->file.peek();
// 前后两个字节分别是ff, 00的情况下,只保留ff
if (next == '\x00') {
this->bitstream += '\xff';
this->file.seekg(1, ios::cur);
// 前后两个字节分别是ff, d9的情况下,表示JPEG文件结束
} else if (next == '\xd9') {
break;
// 第一个字节是ff,其余情况下,数据流中正常保留这两个字节
} else {
this->bitstream += '\xff';
this->file.seekg(-1, ios::cur);
this->file.read(&byte, 1);
this->bitstream += byte;
}
// 其余情况下,数据流中正常保留这个字节
} else {
this->file.read(&byte, 1);
this->bitstream += byte;
}
}
}
解码过程
以下代码,将所有JPEG中的图片数据解码出来,并以ppm文件格式(一种无压缩的图片格式)输出到stdout中。
c++
void JpegDecoder::decode() {
// 三个通道,上一次解出的DC值(由于JPEG中所编码的DC信息实际上是相邻的数据单元之间的DC差量)
int dc_y = 0;
int dc_cr = 0;
int dc_cb = 0;
// 最终输出的未压缩的RGB图像buffer
auto* output = new uint8_t[this->w*this->h*3];
if (this->sampl == sampling_t::YUV420) {
for (int y_mcu = 0; y_mcu < this->h / this->mcu_h; y_mcu++) {
for (int x_mcu = 0; x_mcu < this->w / this->mcu_w; x_mcu++) {
// 逐个MCU进行解码,在单个MCU中,按照特定的图像格式规定的通道数据顺序进行解码
// YUV420的情况下,通道数据顺序为Y1Y2Y3Y4CbCr
// 这里的decode_8x8_per_component方法封装了在单个数据单元中
// 对单个通道的数据进行Huffman, 游程,反量化,Z字扫描,IDCT等解码过程
// 第一个参数用于写入解出的8x8数据,第三个参数用于指定是哪一个通道
dc_y = this->decode_8x8_per_component(this->y_bufs[0], dc_y, 0);
dc_y = this->decode_8x8_per_component(this->y_bufs[1], dc_y, 0);
dc_y = this->decode_8x8_per_component(this->y_bufs[2], dc_y, 0);
dc_y = this->decode_8x8_per_component(this->y_bufs[3], dc_y, 0);
dc_cb = this->decode_8x8_per_component(this->cb_bufs[0], dc_cb, 1);
dc_cr = this->decode_8x8_per_component(this->cr_bufs[0], dc_cr, 2);
// - 解出三个通道的buffer后,在下面这个方法中进行色彩空间转换以及输出
// - 由于YUV420的MCU大小为16x16,所以总共可以输出4个8x8块
// - identical, upsample_xx_xx等参数是c++中的lambda函数
// 用于表达采样时采样点的变换过程
// - 最后的三个参数用于定位MCU
// 确保当前MCU解码后输出结果是在output buffer中正确的位置
output_rgb_8x8_to_buffer(
output, this->y_bufs[0], this->cb_bufs[0], this->cr_bufs[0],
identical, upsample_top_left, upsample_top_left,
y_mcu*this->mcu_h, x_mcu*this->mcu_w, this->w
);
output_rgb_8x8_to_buffer(
output, this->y_bufs[1], this->cb_bufs[0], this->cr_bufs[0],
identical, upsample_bottom_left, upsample_bottom_left,
y_mcu*this->mcu_h, x_mcu*this->mcu_w + 8, this->w
);
output_rgb_8x8_to_buffer(
output, this->y_bufs[2], this->cb_bufs[0], this->cr_bufs[0],
identical, upsample_top_right, upsample_top_right,
y_mcu*this->mcu_h + 8, x_mcu*this->mcu_w, this->w
);
output_rgb_8x8_to_buffer(
output, this->y_bufs[3], this->cb_bufs[0], this->cr_bufs[0],
identical, upsample_bottom_right, upsample_bottom_right,
y_mcu*this->mcu_h + 8, x_mcu*this->mcu_w + 8, this->w
);
}
}
} else if (this->sampl == sampling_t::YUV444) {
for (int y_mcu = 0; y_mcu < this->h / this->mcu_h; y_mcu++) {
for (int x_mcu = 0; x_mcu < this->w / this->mcu_w; x_mcu++) {
// YUV444的情况则更简单,通道数据顺序为YCbCr
dc_y = this->decode_8x8_per_component(this->y_bufs[0], dc_y, 0);
dc_cb = this->decode_8x8_per_component(this->cb_bufs[0], dc_cb, 1);
dc_cr = this->decode_8x8_per_component(this->cr_bufs[0], dc_cr, 2);
// 由于MCU大小也为8x8,只需要输出一次到output buffer即可,并且不需要进行下采样
output_rgb_8x8_to_buffer(
output, this->y_bufs[0], this->cb_bufs[0], this->cr_bufs[0],
identical, identical, identical,
y_mcu*this->mcu_h, x_mcu*this->mcu_w, this->w
);
}
}
} else {
throw "not supported";
}
// 将output整体以ppm的格式输出到stdout
printf("P6\n%d %d\n255\n", this->w, this->h);
fwrite(output, 1, this->w*this->h*3, stdout);
delete[] output;
}
其中的decode_8x8_per_component
方法主要涉及Huffman,游程,反量化,Z字扫描,IDCT等算法的解码过程,将在后续的文章中进行剖析。