JPEG相关知识
JPEG编码流程
图像压缩非常重要,jpeg或jpg是一种图像格式,也是一种图像压缩标准。jpeg2000是对jpeg的改进,但本文只是对图像压缩有一个简单的了解,只分析JPEG。
图像压缩的核心是减小像素间的空间相关性,从而获得压缩。JPEG压缩过程一般如下:
graph LR
src_img--> convert["颜色空间转换"]--> block["分块"] --> DCT["DCT变换"] --> DCT_co["DCT系数"] --> quatization["量化"] --> codeing["编码"]
- JPEG处理的是YUV数据,因此需要先将其它格式的数据转为YUV,转换公式可在网上查阅;
- 然后对YUV图像分块,JPEG分为
8x8
的小块,并进行DCT
变换,得到DCT
系数; DCT
系数也是8x8
的矩阵。最左上角表示直流分量(DC系数),是图像的低频信息;其余部分代表交流分流(AC系数),是图像的中高频信息;- 压缩的核心:对高频信息压缩(量化),保留低频信息,如果采用的量化间隔越大,则压缩率越高,但图像丢失的信息越多,重建图像与原图像差距越大。JPEG是采用
zig-zag
顺序对DCT
系数矩阵进行压缩,这样能先对低频信息进行量化(TODO:有什么用呢?); - 对量化后的结果编码(熵编码),如采用Huffman或RLE。
JPEG文件格式
整体过程如上,但JPEG类型文件的最开始还包括一系列文件头信息和其它标志,JPEG图片格式组成部分:SOI(文件头)+APP0(图像识别信息)+ DQT(定义量化表)+ SOF0(图像基本信息)+ DHT(定义Huffman表) + DRI(定义重新开始间隔)+ SOS(扫描行开始)+ EOI(文件尾)。
JPEG文件是一段一段进行存储的,JPEG文件的每个段都一定包含两部分:一个是段的标识,它由两个字节构成:第一个字节是十六进制0xFF,第二个字节对于不同的段,这个值是不同的。另一部分是:紧接着的两个字节存放的是这个段的长度(除了前面的两个字节0xFF和0xXX,X表示不确定。他们是不算到段的长度中的)。段的结构如下:
-----------------------------------------------------------------
名称 字节数 数据 说明
-----------------------------------------------------------------
段标识 1 FF 每个新段的开始标识
段类型 1 类型编码(称作“标记码”)
段长度 2 包括段内容和段长度本身,不包括段标识和段类型
段内容 ≤65533字节
特别注意:长度的表示方法是按照高位在前,低位在后的,即big-endiam模式。
JPEG文件必须包含的段
JPEG总共有30个段,有10个段必须提供,其余的可选。可参考此链接
tinyjpeg.h文件解读
tinyjpeg.h
是单文件的JPEG encoder,可读性高,适合学习JPEG编码,可在GitHub上获取。使用方法也很简单,在文件最开始部分也有说明:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h" // 另外一个开源库
#define TJE_IMPLEMENTATION
#include "tiny_jpeg.h"
int main()
{
int width, height, num_components;
unsigned char* data = stbi_load("in.bmp", &width, &height, &num_components, 0);
if ( !data ) {
puts("Could not find file");
return EXIT_FAILURE;
}
if ( !tje_encode_to_file("out.jpg", width, height, num_components, data) ) {
fprintf(stderr, "Could not write JPEG\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
核心结构体
两个核心结构体,TJEState
包含TJEWriteContext
,图片数据流动过程:
graph LR
block["8x8块或<br/>段数据"] -->|"encoding"| sta["TJEState.output_buffer"] --> |"TJEState.wc.func()"| context["TJEState.wc.context"]
8x8
图像块经过压缩后或者JPEG文件段信息数据通过函数tjei_write()
写入到TJEState.output_buffer
中去,然后再通过TJEState.wc.func()
函数写入到TJEState.wc.context
中去,TJEState.wc.context
可以是一个输出文件的文件指针。TJEState.wc.func()
是一个核心函数。
结构体具体成员:
typedef struct {
void* context; // 数据目的地,如输出文件的文件指针
tje_write_func* func;
} TJEWriteContext;
typedef struct {
// Huffman data.
uint8_t ehuffsize[4][257];
uint16_t ehuffcode[4][256];
uint8_t const * ht_bits[4];
uint8_t const * ht_vals[4];
// Quantization tables.
uint8_t qt_luma[64];
uint8_t qt_chroma[64];
// fwrite by default. User-defined when using tje_encode_with_func.
TJEWriteContext write_context;
// Buffered output. Big performance win when using the usual stdlib implementations.
size_t output_buffer_count;
uint8_t output_buffer[TJEI_BUFFER_SIZE];
} TJEState;
核心函数
公共接口:tje_encode_to_file(),tje_encode_to_file_at_quality()
,以及tje_encode_with_func()
。
tje_encode_to_file() // qulity=3
|
tje_encode_to_file_at_quality() // 使用默认tjei_stdlib_func()
|
tje_encode_with_func()
|
tjei_encode_main()
|
传入的TJEState的成员write_context中包含func
核心函数tjei_encode_main()
及tjei_encode_and_write_MCU()
:
// 此函数输入的是整张图片,在函数中会遍历8x8的小块(MCU),然后调用下面的函数
static int tjei_encode_main(TJEState* state,
const unsigned char* src_data,
const int width,
const int height,
const int src_num_components)
// 处理8x8的MCU,JPEG压缩的核心函数
static void tjei_encode_and_write_MCU(
TJEState* state,
float* mcu,
#if TJE_USE_FAST_DCT
float* qt, // Pre-processed quantization matrix.
#else
uint8_t* qt,
#endif
uint8_t* huff_dc_len, uint16_t* huff_dc_code, // hfm tables
uint8_t* huff_ac_len, uint16_t* huff_ac_code,
int* pred, // DC系数用到的
uint32_t* bitbuffer, // Bitstack.
uint32_t* location)
tjei_encode_main()
主要是遍历原始图像,并进行分块,主要逻辑如下:
for ( int y = 0; y < height; y += 8 ) {
for ( int x = 0; x < width; x += 8 ) {
// Block loop: ====
for ( int off_y = 0; off_y < 8; ++off_y ) {
for ( int off_x = 0; off_x < 8; ++off_x ) {
// 块内索引0-63
int block_index = (off_y * 8 + off_x);
// 获取原始图像数据并转为yuv
du_y[block_index] = luma;
du_b[block_index] = cb;
du_r[block_index] = cr;
}
}
// 调用MCU压缩编码函数
tjei_encode_and_write_MCU(state, du_y, ...);
tjei_encode_and_write_MCU(state, du_b, ...);
tjei_encode_and_write_MCU(state, du_r, ...);
}
}
而tjei_encode_and_write_MCU()
就是真正进行压缩编码的函数,包括DCT变换、量化、编码。
工具函数:tjei_fdct(),tjei_write()
。tjei_write(TJEState* state, const void* data, size_t num_bytes, size_t num_elements)
是将num_bytes X num_elements
大小的数据data
通过state.wc.func()
写入到state.wc.context
,此函数会递归调用自己(如果data数据长度大于1024(TJEI_BUFFER_SIZE),则会递归调用)。
// 此函数会递归调用自己
static void tjei_write(TJEState* state, const void* data, size_t num_bytes, size_t num_elements) {
size_t to_write = num_bytes * num_elements;
// Cap to the buffer available size and copy memory.
size_t capped_count = tjei_min(to_write, TJEI_BUFFER_SIZE - 1 - state->output_buffer_count);
memcpy(state->output_buffer + state->output_buffer_count, data, capped_count);
state->output_buffer_count += capped_count;
assert (state->output_buffer_count <= TJEI_BUFFER_SIZE - 1);
// NOTE: (5) tje_encode_with_func()传入的自定义函数在此处被调用
// Flush the buffer.
if ( state->output_buffer_count == TJEI_BUFFER_SIZE - 1 ) {
// context是指已压缩数据要写入的地方,比如一个文件指针。即将output_buffer中的内容写入到context中去
state->write_context.func(state->write_context.context, state->output_buffer, (int)state->output_buffer_count);
state->output_buffer_count = 0;
}
// Recursively calling ourselves with the rest of the buffer.
if (capped_count < to_write) {
tjei_write(state, (uint8_t*)data+capped_count, to_write - capped_count, 1);
}
}
实践
下载源码
从github上下载tiny_jpeg.h
(点击这里)以及从开源图像库stb中提取出stb_image.h
,并放入同一文件夹中。
-jpegtiny-test
|-stb_image.h // 用于获取输入图片的原始图像数据,如bmp、gif等
|-tiny_jpeg.h // 对原始图像数据进行压缩编码
|-tinyjpeg_test.c // 测试程序
|-lena.bmp // 测试图片
stb_image.h
可加载不同类型的图片,包括JPG, PNG, TGA, BMP, PSD, GIF, HDR, PIC
,并解码出其原始数据(应该是YUV或者RGB),具体得看源码。
测试程序tinyjpeg_test.c
:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#define TJE_IMPLEMENTATION
#include "tiny_jpeg.h"
int main(int argc, char const *argv[])
{
int width, height, num_components, quality;
if (argc != 4) {
printf("usage:\n");
printf("xxx.exe infile.bmp outfile.jpg quality\n");
printf("quality:1 2 3, 3-->the output img is bigger");
return 0;
}
quality = (int)(*argv[3]-0x30);
printf("the quality is: %d\n", quality);
unsigned char* data = stbi_load(argv[1], &width, &height, &num_components, 0);
if ( !data ) {
puts("Could not find file");
return EXIT_FAILURE;
}
if ( !tje_encode_to_file_at_quality(argv[2], quality, width, height, num_components, data) ) {
fprintf(stderr, "Could not write JPEG\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
跑通程序
编译命令:gcc tinyjpeg_test.c tiny_jpeg.h stb_image.h -o tinyjpeg_test
执行命令:tinyjpeg_test.exe lena.bmp lena_q1.jpg 1
,1表示quality为1,tiny_jpeg.h
提供了3种quality。quality=3
时量化矩阵qt
的所有元素都为1,即不对DCT系数进行压缩,因此输出图片质量最高,但图片大小也最大;quality=2
时次之、quality=1
最次。可以自行修改源码中的量化矩阵,或者自己增加quality的选择。
3种quality
与原图的对比,几乎差不多:
自行修改源码学习
跑通程序后就可以自行修改源码学习JPEG,比如前面所说改变量化矩阵、将DCT变换修改为Wavelet变换、还可以自己修改分块大小,如4x4、16x16等。还可以计算块与块之间的相似性,运动补偿,找两幅相似的图片进行帧间预测学习。
tiny_jpeg.h
中可以自行定义写数据函数,即将压缩后的数据写到某个地方,默认是调用标准库的fwrite()
将压缩后的数据写入到context
(输出文件的文件指针)。此处自定义写数据函数,将压缩数据直接输出到stdout
,也就是屏幕。
...... // 输出到stdout
ret = tje_encode_with_func(cdj_tje_func, (void *)stdout, quality, width, height, num_components, data);
if ( !ret ) {
fprintf(stderr, "Could not write JPEG\n");
return EXIT_FAILURE;
}
// 可以自行实现tiny_jpeg.h的写函数(将编码后的数据写到某处)
static void cdj_tje_func(void* context, void* data, int size) {
printf("\n\n=======cdj_tje_func was called======\n");
// context其实就是stdout,tje_encode_with_func()传入的参数
FILE * where2write = (FILE *)context;// stdout是FILE*类型的变量
char * data_ch = (char *)data;
// 数据太多,只输出10B
for (int i = 0; i < 10 && i < size; ++i) {
fprintf(where2write, "%0x ", data_ch[i]);
}
}
如下图,直接将压缩后的数据(或JPEG文件段信息)输出到stdout
,即屏幕:
可以对比最开始调用tjei_write()
写入的内容,即TJEJPEGHeader
结构体:
{ // Write header
TJEJPEGHeader header;
// JFIF header.
header.SOI = tjei_be_word(0xffd8); // Sequential DCT
header.APP0 = tjei_be_word(0xffe0);
/*SOI & APP0 markers*/;
uint16_t jfif_len = sizeof(TJEJPEGHeader) - 4
header.jfif_len = tjei_be_word(jfif_len);
memcpy(header.jfif_id, (void*)tjeik_jfif_id, 5);
header.version = tjei_be_word(0x0102);
header.units = 0x01; // Dots-per-inch
header.x_density = tjei_be_word(0x0060); // 96 DPI
header.y_density = tjei_be_word(0x0060); // 96 DPI
header.x_thumb = 0;
header.y_thumb = 0;
tjei_write(state, &header, sizeof(TJEJPEGHeader), 1);
}
学习总结
结构体的1btye
对齐,一是更节省空间、二是在写一些文件头信息时的常用技巧:
#pragma pack(push)
#pragma pack(1)
typedef struct {
uint16_t SOI; // start of image
// JFIF header.
uint16_t APP0; // 图片信息
uint16_t version; // 版本
uint8_t units;
uint16_t x_density;
uint16_t y_density;
} TJEJPEGHeader;
#pragma pack(pop)
利用代码块{...}
,使代码更易读。
// NOTE: (3) 值得学习的写法,利用代码块来表示不同部分
// Write JPEG header to file
{
TJEJPEGHeader header;
// JFIF header.
header.SOI = tjei_be_word(0xffd8); // Sequential DCT
header.APP0 = tjei_be_word(0xffe0);
...
header.version = tjei_be_word(0x0102);
header.units = 0x01; // Dots-per-inch
header.x_density = tjei_be_word(0x0060); // 96 DPI
header.y_density = tjei_be_word(0x0060); // 96 DPI
// 将JPEG的文件头header写入到state的wc的context中去,也就是输出文件的文件指针(可以自定义输出位置,甚至输出到串口),只需调用tje_encode_with_func()并提供自定义func并作为参数传入
tjei_write(state, &header, sizeof(TJEJPEGHeader), 1);
}