JPEG知识及tinyjpeg.h学习

JPEG相关知识

JPEG编码流程

​图像压缩非常重要,jpeg或jpg是一种图像格式,也是一种图像压缩标准。jpeg2000是对jpeg的改进,但本文只是对图像压缩有一个简单的了解,只分析JPEG。

​图像压缩的核心是减小像素间的空间相关性,从而获得压缩。JPEG压缩过程一般如下:

graph LR
	src_img--> convert["颜色空间转换"]--> block["分块"] --> DCT["DCT变换"] --> DCT_co["DCT系数"] --> quatization["量化"] --> codeing["编码"]
  1. JPEG处理的是YUV数据,因此需要先将其它格式的数据转为YUV,转换公式可在网上查阅;
  2. 然后对YUV图像分块,JPEG分为8x8的小块,并进行DCT变换,得到DCT系数;
  3. DCT系数也是8x8的矩阵。最左上角表示直流分量(DC系数),是图像的低频信息;其余部分代表交流分流(AC系数),是图像的中高频信息;
  4. 压缩的核心:对高频信息压缩(量化),保留低频信息,如果采用的量化间隔越大,则压缩率越高,但图像丢失的信息越多,重建图像与原图像差距越大。JPEG是采用zig-zag顺序对DCT系数矩阵进行压缩,这样能先对低频信息进行量化(TODO:有什么用呢?);
  5. 对量化后的结果编码(熵编码),如采用Huffman或RLE。

JPEG文件格式

JPEG文件格式参考-click here

​整体过程如上,但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);
}
赞赏