资讯动态

C++手写JPEG压缩器:灰度图编码从DCT到Huffman全解析

发布时间:2026/9/1 17:54:26 来源:尧图企业网站定制
简介本资源是一套基于C实现的JPEG图像压缩与解压缩算法完整工程面向图像处理初学者、嵌入式开发者及计算机视觉方向学习者旨在帮助理解JPEG标准的核心原理与工程落地细节。压缩包共14个文件含4个核心cpp源码、3个头文件jpge.h/jpgd.h等、3个可执行程序含32/64位exe、1个Code::Blocks项目文件cbp、1个Visual Studio解决方案sln及1个C语言辅助模块stb_image.c总大小仅403KB轻量易集成。已有1898人学习下载说明其在教学实践与算法复现中具备较高参考价值。读者可直接编译运行观察灰度图转JPEG编码的全过程深入源码可掌握8×8块DCT变换、YCbCr颜色空间转换、量化表设计、霍夫曼熵编码等关键环节的C实现逻辑并复用其中独立模块如jpge.cpp中的编码器、jpgd.cpp中的解码器于自有图像处理项目。 手写JPEG压缩器这件事听起来有点像在造轮子但真做一遍你对图像编码的理解会立刻不一样。我最近用C重写了一个JPEG压缩算法核心目标很明确把一块灰度图数据变成标准JPEG压缩编码文件。也就是说输入是一个uint8_t数组按行优先排列的灰度像素输出是一个能直接被人看图软件打开的.jpg。这篇文章会把整个流程拆开讲清楚包括8x8分块、DCT变换、量化、Zigzag扫描、Huffman编码和JPEG文件段组织同时给出C实现的核心代码片段和排查经验。适合正在学图像处理、想理解JPEG内部原理、或者需要在嵌入式环境里手写编码器的朋友参考。我最初做这个需求是因为一个项目里需要把相机传感器输出的灰度Raw数据转成JPEG文件又不能引入庞大的开源库。查了一圈资料大部分教程都在讲彩色JPEG色度采样一下来就把人绕晕了。而灰度图恰好绕开了颜色空间转换和色度子采样能让你把精力全部集中在JPEG最核心的压缩链路上。这篇文章里的代码就是我在那个项目里裁减下来的版本保留了灰度图编码的全部关键环节但去掉了彩色分支所以特别适合第一次手写JPEG编码器的人入手。1. 为什么从灰度图开始手写JPEG压缩器1.1 JPEG到底在做什么JPEG不是一种具体的算法而是一套图像压缩标准。Baseline JPEG的核心思路是有损变换熵编码。一张灰度图每个像素是0到255的整数一个100x100的图像就有10000个字节。如果直接存成BMP这种无压缩格式数据量很大而JPEG能压到十分之一甚至更低靠的是三步先把空间像素变换到频率域然后对人眼不敏感的高频信息做粗糙量化最后用Huffman编码把统计冗余去掉。对灰度图来说JPEG的输入就是单个亮度分量Y。没有CbCr没有4:2:0采样省掉了一大块逻辑。压缩流程可以简化成一条直线像素分块、DCT、量化、Zigzag重排、DC差分编码、AC游程编码、Huffman编码、写文件。理解这条线后面所有代码都是在为这条线服务。1.2 为什么是8x8分块和DCTJPEG选8x8分块不是拍脑袋。自然图像在小范围内相关性很强8x8的块尺寸足够小能把纹理近似看成平稳信号块内像素之间冗余度高。DCT离散余弦变换的作用是把一个8x8的像素块变成8x8的频率系数块。低频系数集中在左上角代表图像的大面积亮度变化高频系数分散在右下角对应物体边缘、细节和噪声。你可以把DCT想成把一块图像拆成不同频率的叠加。类似一段声音可以拆成不同频率的正弦波图像也能拆成不同频率的余弦波。人眼对低频信息很敏感对高频细节的判断其实相当宽容。量化阶段故意把高频系数变粗糙视觉上影响不大却能去掉大量数据。这也是JPEG能在保证基本画质的情况下大幅压缩的根本原因。1.3 编码器整体流程与模块划分编码器的整体流程可以拆成下面这几个环节对灰度图像的每个8x8块先做中心化处理把像素值减128让数据落在-128到127之间。对每个8x8块做二维DCT得到64个频率系数。用量化表去除人眼不敏感的高频分量高频系数会变成很多0。按Zigzag顺序把64个系数重排成一维序列把能量集中在前面。对DC系数做差分编码对AC系数做游程编码得到一系列符号。用Huffman表把符号编码成比特流最后按JPEG文件格式封装。模块划分上我建议至少分成DCT、量化器、Huffman编码器、文件写入器四块。不要把所有逻辑堆在一个函数里。这样每一步都能单独测试比如先单独验证DCT是否正确再验证量化之后的高频系数是否足够多0。我在写第一版时把所有逻辑混在一起出问题后根本不知道是DCT算错还是Huffman表写错后来拆开就好调多了。1.4 技术选型标准Huffman表与灰度图简化Huffman表这里很多教材会让你统计频率动态建表。但JPEG编码器完全可以预定义一张标准Huffman表也就是ITU T.81附录K里的亮度DC表和亮度AC表。标准表是根据大量自然图像统计出来的对绝大多数灰度图效果都够用。动态建表的好处是理论上压缩率能再高一点代价是代码量暴涨还需要先扫描一遍所有块的符号统计频率。对新手来说先把手写标准表跑通比一上来就做自适应Huffman要靠谱得多。灰度图在这个方案里的简化是肉眼可见的只需要一个亮度量化表、一组DC Huffman表、一组AC Huffman表JPEG文件里的组件数Nf1。相比之下彩色JPEG要处理Y、Cb、Cr三套分量还要决定采样因子光是写SOF0和SOS段就要多绕好几层。所以我强烈建议第一次写JPEG编码器的人先用灰度图练手。2. JPEG编码核心环节DCT、量化与Huffman2.1 输入灰度数据的预处理与边缘填充当宽高不是8的倍数时JPEG标准允许边缘像素补齐到8的倍数。补齐方式有很多最粗暴的是补0也就是在图像右侧和下方填充黑色像素。但在实际测试中补0会让边缘块产生明显的高频伪影压缩质量会变差。我推荐用边缘复制方式也就是用最右侧一列和最下面一行的像素值向外填充。这样图像在块边界处过渡自然DCT之后的高频能量也更小压缩时不容易出现黑边或花边。预处理时还要注意SOF0标记中写入的高度和宽度必须是原始图像的尺寸而不是填充后的尺寸。解码端会根据SOF0里的宽高裁剪输出。编码端计算分块数量时则要按向上取整blocksX (width 7) / 8blocksY (height 7) / 8。遍历图像像素时一旦坐标超出原始宽高就取边缘像素值。这块逻辑虽然简单但漏掉任何一个判断输出文件都会在边缘出现不可控的杂色。2.2 DCT的工程化实现与常见坑二维DCT的数学公式是F(u,v) 1/4 * C(u)C(v) * sum_x sum_y f(x,y) cos((2x1)uπ/16) cos((2y1)vπ/16)其中C(0) 1/sqrt(2)其他情况下C 1。工程上很少直接套二维公式因为复杂度太高。更常见的做法是把二维DCT拆成两个一维DCT先对每一行做一维DCT再对每一列做一维DCT。这样64个点的二维DCT从O(64^2)降到O(2864)代码也更好读。先上代码#include cmath void dct_1d(const double src[8], double dst[8]) { for (int u 0; u 8; u) { double sum 0.0; for (int x 0; x 8; x) { sum src[x] * std::cos((2 * x 1) * u * M_PI / 16.0); } double cu (u 0) ? std::sqrt(0.5) : 1.0; dst[u] 0.5 * cu * sum; } } void dct_8x8(const uint8_t input[64], double output[64]) { double shifted[64], tmp[64]; for (int i 0; i 64; i) { shifted[i] static_castdouble(input[i]) - 128.0; } // 行变换 for (int y 0; y 8; y) { dct_1d(shifted[y * 8], tmp[y * 8]); } // 列变换 for (int x 0; x 8; x) { double col_in[8], col_out[8]; for (int y 0; y 8; y) { col_in[y] tmp[y * 8 x]; } dct_1d(col_in, col_out); for (int y 0; y 8; y) { output[y * 8 x] col_out[y]; } } }这里有个很容易踩的坑M_PI在部分C编译环境下不是标准宏Visual Studio里可能没定义。稳妥做法是自己定义const double PI 3.14159265358979323846;。另外输入像素一定要先减128否则DCT系数里会带一个很大的直流偏置导致量化后低频系数异常偏大压缩效果变差。还有一点DCT函数用double计算最后量化时四舍五入成整数这个顺序不能反。2.3 量化表与质量因子怎么定JPEG标准里给了一张推荐的亮度量化表按8x8矩阵排列大致如下16 11 10 16 24 40 51 61 12 12 14 19 26 58 60 55 14 13 16 24 40 57 69 56 14 17 22 29 51 87 80 62 18 22 37 56 68 109 103 77 24 35 55 64 81 104 113 92 49 64 78 87 103 121 120 101 72 92 95 98 112 100 103 99这张表越靠近右下角数值越大意味着高频系数被除的倍数越大被量化成0的概率越高。这正好对应人眼对高频细节不敏感的特性。实际项目中不会直接拿这张表去量化而是要配合质量因子quality来缩放。常用的换算公式是如果quality 50缩放因子scale 5000 / quality如果quality 50缩放因子scale 200 - 2 * quality然后对量化表中的每个元素计算new_value (old_value * scale 50) / 100最后限制在1到255之间。quality取值50左右时压缩比适中画质基本无损quality取75以上文件明显变大取90以上几乎接近无损但文件就不如直接用PNG来了。我个人的经验是灰度图场景下取60到70比较平衡。量化的时候还有一个顺序问题。DCT系数矩阵在内存里是8x8栅格顺序但JPEG文件里量化表元素是按Zigzag顺序保存的。也就是说你在写DQT段之前要把量化表按Zigzag扫描顺序重新排列再写入文件。如果直接把自然顺序的表写进去很多解码器也会宽容地读但读出来的量化矩阵和你预期的不一致最终画质会异常。2.4 Zigzag扫描、DC差分和AC游程编码量化之后的64个系数低频集中在左上角高频集中在右下角。Zigzag扫描就是把二维系数矩阵按照之字形拉成一维序列让低频系数尽量排前面高频0尽量排后面。标准的Zigzag顺序数组static const int kZigzag[64] { 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63 };重排之后序列第0个是DC系数。DC系数代表整个块的平均亮度相邻块的DC值通常很接近所以JPEG对DC做了差分编码diff currentDC - prevDC然后对diff编码而不是直接对DC系数编码。编码diff时需要确定它的category也就是这个值需要用多少位表示。比如0的category是01和-1的category是12、3、-2、-3的category是2依此类推。实际编码时正数直接写二进制负数要写成一个与负数对应的补码形式bits diff (1 category) - 1取低category位。这里很容易写错我一开始直接对负数用abs然后转二进制结果解码出来图像像打散的拼图。AC系数从Zigzag序列第1个开始处理。因为量化后大部分AC都是0所以JPEG用游程编码把连续0的个数和下一个非0系数的category组合成一个符号symbol (run 4) | size其中run是前面连续0的个数size是当前非0系数的category。如果一遇到连续16个0就发射一个ZRL符号0xF0然后继续数。如果当前块后面全是0就发射EOB符号0x00结束。这个逻辑虽然简单但循环边界很容易错尤其是最后一个非0系数恰好出现在块尾时不需要再补EOB但多数实现会补一个也无伤大雅。2.5 用标准Huffman表把符号变成比特流JPEG的Huffman编码分为DC和AC两套表。DC表输入的符号是DC系数的category0到11AC表输入的符号是(run 4) | size。标准表的定义方式先是16个字节的BITS数组表示长度为1到16的码字各有多少个然后是VAL数组按顺序列出每个码字对应的符号。在代码里最简单的做法是把标准亮度DC表和AC表直接写成数组。DC表BITS只有12个有效值AC表BITS有162个有效值长度不长。如果你不想手动抄表可以在项目里引用libjpeg公开的标准表常量或者从JPEG标准附录K里复制。重点是拿到BITS和VAL之后要能生成对应的Huffman码字。JPEG标准规定码字按高位先输出的方式写入所以生成码字时要从0开始按canonical Huffman方式递增。我自己写的时候没有把Huffman表硬编码成符号-码字的哈希表而是写了一个buildHuffmanCode函数输入BITS和VAL输出一个huffCode[256]、huffSize[256]数组。这样后面编码时直接查表效率高也不容易出错。这一步完成后整个编码器的核心逻辑已经打通剩下的就是文件封装。3. C动手实现从工程骨架到落盘细节3.1 工程结构设计与关键数据结构我建议把编码器拆成下面的文件结构jpeg_encoder.h jpeg_encoder.cpp bit_writer.h bit_writer.cpp main.cppJpegEncoder类负责整个压缩流程BitWriter类负责往内存字节流里按位写数据并处理JPEG的字节填充规则。数据结构上最少要维护这些成员class JpegEncoder { public: bool encode(const std::vectoruint8_t gray, int width, int height, std::vectoruint8_t out_jpeg, int quality 70); private: void write_sof0(int width, int height); void write_dqt(int quality); void write_dht(); void write_sos(); void write_entropy_data(const std::vectoruint8_t gray, int width, int height); std::vectoruint8_t out_; BitWriter bit_writer_; };out_保存完整的JPEG文件字节最后直接作为输出。用std::vectoruint8_t的好处是不用手动管理内存按字节追加时很方便。这里有一个小建议在encode开头就out_.reserve(width * height 1024)预分配好空间避免频繁扩容影响性能。3.2 BitWriterJPEG熵编码的基石BitWriter是整个编码器里最容易出bug的地方。JPEG的熵编码数据是按位写入的要求高位先行。我第一版用std::bitset拼接结果又慢又别扭。后来改成标准的位缓冲方式用一个uint32_t维护尚未输出到字节流的bit每当累积超过8个bit就输出一个字节。下面是一个简化版实现class BitWriter { public: std::vectoruint8_t data; explicit BitWriter(std::vectoruint8_t out) : data(out), bitbuf_(0), bitcnt_(0) {} void writeBits(uint16_t bits, int len) { bitbuf_ (bitbuf_ len) | bits; bitcnt_ len; while (bitcnt_ 8) { bitcnt_ - 8; uint8_t byte static_castuint8_t((bitbuf_ bitcnt_) 0xFF); putByte(byte); } } void flush() { if (bitcnt_ 0) { uint8_t byte static_castuint8_t((bitbuf_ (8 - bitcnt_)) 0xFF); putByte(byte); bitbuf_ 0; bitcnt_ 0; } } private: void putByte(uint8_t byte) { data.push_back(byte); if (byte 0xFF) { data.push_back(0x00); } } uint32_t bitbuf_; int bitcnt_; };这里最关键的是putByte里的字节填充JPEG规定熵编码数据里凡是出现0xFF字节后面必须紧跟一个0x00否则解码器会把0xFF当成下一段标记的开始。这个坑不处理生成的文件大概率无法正常解码。flush的时候如果还有不足8bit的尾巴标准推荐用1填充也就是把当前bit缓冲区左移到高位低位补1。图上大多数解码器对补0也能容忍但为了规范最好补1。我自己的实现里flush是用(bitbuf_ (8 - bitcnt_)) | ((1 (8 - bitcnt_)) - 1)补1。3.3 头部写入SOI到SOS段的字节级细节JPEG文件由一串标记段组成。每个标记都以0xFF开头后面跟一个标记码。灰度图最简文件至少要包含以下标记SOI、APP0、DQT、SOF0、DHT、SOS、EOI。写段的时候一个常见错误是长度字段没算对。JPEG的段长度字段是2字节而且长度值包含长度字段本身占的2字节。比如APP0段长度固定是16因为段内容里包括JFIF\0、版本、单位、密度等信息。SOF0段对于灰度图特别简单长度固定为11参数顺序是精度8、高度、宽度、组件数1然后组件ID为1、采样因子为0x11、量化表选择为0。高度和宽度这里要用大端字节序也就是高字节在前。很多第一次写JPEG的人会在这里栽跟头把宽高写反了结果图片是横过来的或者直接打不开。DQT段有两部分先写一个字节的表信息高4位是精度低4位是表号。灰度图这里写0x00表示8位精度、0号亮度量化表。后面跟64个字节的量化表注意前面提过要用Zigzag顺序写入。DHT段可以一次性定义DC表和AC表开头是DC表信息0x00然后16字节BITS再跟VAL接着AC表信息0x10然后是AC表的BITS和VAL。最后SOS段长度8组件数1组件ID1DC/AC表选择0x00谱选择开始0结束63近似位0。写头部的代码不复杂但建议把所有writeMarker和writeSegment封装成小函数每个函数只负责写一个段。这样排查问题时可以用十六进制编辑器逐段对照。我在项目里封装成了这样void JpegEncoder::write_sof0(int width, int height) { out_.push_back(0xFF); out_.push_back(0xC0); out_.push_back(0x00); out_.push_back(0x0B); // length 11 out_.push_back(0x08); // precision 8 out_.push_back((height 8) 0xFF); out_.push_back(height 0xFF); out_.push_back((width 8) 0xFF); out_.push_back(width 0xFF); out_.push_back(0x01); // components 1 out_.push_back(0x01); // component ID out_.push_back(0x11); // sampling factor 1x1 out_.push_back(0x00); // quant table select }3.4 压缩数据主体与EOI写入熵编码数据是JPEG文件里最核心也最容易出问题的地方。主循环遍历每一个8x8块先取像素做DCT和量化然后按Zigzag顺序处理。DC系数先做一次差分AC系数则分成两类一类的run和size组成Huffman符号另一类是系数本身的附加比特。下面这段伪代码展示了主循环的核心逻辑int prevDC 0; for (int by 0; by blocksY; by) { for (int bx 0; bx blocksX; bx) { uint8_t block[64]; extractBlock(gray, width, height, bx, by, block); double coeff[64]; dct_8x8(block, coeff); int qcoeff[64]; quantize(coeff, qcoeff, quality); int zz[64]; for (int i 0; i 64; i) zz[i] qcoeff[kZigzag[i]]; int diff zz[0] - prevDC; prevDC zz[0]; int dcCat category(diff); writeBits(dcHuffCode[dcCat], dcHuffSize[dcCat]); writeExtraBits(diff, dcCat); int run 0; for (int i 1; i 64; i) { if (zz[i] 0) { run; if (run 16) { writeBits(acHuffCode[0xF0], acHuffSize[0xF0]); run 0; } } else { int acSize category(zz[i]); int symbol (run 4) | acSize; writeBits(acHuffCode[symbol], acHuffSize[symbol]); writeExtraBits(zz[i], acSize); run 0; } } if (run 0) { writeBits(acHuffCode[0x00], acHuffSize[0x00]); // EOB } } } bit_writer_.flush(); out_.push_back(0xFF); out_.push_back(0xD9); // EOIcategory函数的实现是统计数值的二进制位数。比如category(0)0category(1)category(-1)1category(2)category(-2)2。writeExtraBits则是根据正负决定写原值还是补码形式。注意AC系数为0的情况不能进这个函数因为AC的0是通过游程处理的只有非0系数才会写附加比特。这块逻辑不复杂但很多人在负数处理上翻车。3.5 测试一张灰度图并计算压缩比写完编码器第一件事不是去压缩真实照片而是先造一张简单的测试图。我建议先用8x8纯色块再试16x16渐变最后再上真实灰度图。如果前面两个都正常基本能确定DCT和Huffman链路没大问题。测试程序可以写成一个从PGM P5格式读图的main本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价