把一张1920×1080的彩色照片原样存下来,需要多少空间?不压缩的RGB数据是1920×1080×3字节,算下来大概6.2MB。一部64GB的手机,刨去系统和App占用的空间,也就放得下一万张左右。可我们平时手机拍的照片,存储时大多只有2MB到5MB,放大看却几乎看不出差别。中间被"省掉"的那几个MB,正是图像压缩编码在起作用。
这是"信号处理仿真:图像信号处理"系列的第6篇。前几篇我们分别聊过图像的采样、量化、增强、复原这些环节,目标都是让信号更清晰、更完整。到了压缩编码这一步,任务反过来了:不是让数据变得更多,而是在尽量不损失视觉质量的前提下,把数据量狠狠压下去。这篇文章我用仿真实验的视角,把图像压缩编码这条技术路线从原理、实现到评价完整走一遍,代码以Python为主,MATLAB思路完全一致。适合正在学数字图像处理、信号处理仿真,或者想真正搞懂JPEG背后机制的人。
1. 一张图为什么这么占空间:三种冗余先说清楚
先搞清楚一个问题:一幅图像数据里,哪些内容是可以被省掉的?把这个问题想明白了,后面所有算法都是顺理成章的事。我把图像数据里能压缩的部分归纳成三类冗余,这三类冗余对应着三种不同的压缩手段。
1.1 空间冗余:相邻像素太"像"了
拍照时,一块纯净的天空、一面白墙、一片草地,在图像上表现为大范围的、颜色非常接近的像素。最极端的例子是纯色图像,比如一幅256×256、每个像素都是RGB(128,128,128)的图,理论上用三个字节就能完整描述出来,但BMP格式会老老实实存下196608个字节。这就是典型的空间冗余——像素与相邻像素之间有极强的相关性。
信号处理里经常说"去相关",就是把这种相邻像素高度相似的状态打破,让数据在统计上变得"独立"。DCT、小波变换这类正交变换,核心作用就是去相关。你可以这样理解:变换把图像从"像素值高度相关"的空间,搬到了一个"系数相对独立"的空间,再对系数做处理就方便多了。本质上,变换本身不压缩数据,它只是让后续的量化、编码更有效率。
1.2 视觉冗余:人眼本来就不敏感的部分
人眼对亮度比对颜色敏感,对低频信息比对高频细节敏感,对平滑区域里的微小噪声几乎无感。这是所有有损压缩技术的生理基础。JPEG在颜色空间转换后,把色度通道下采样到1/4分辨率;在量化阶段,把高频系数除以一个较大的数再取整。这两个操作都在利用视觉冗余:丢掉一些人类视觉系统感知不明显的信息,换取可观的码率下降。
视觉冗余最直观的例子是色度下采样。研究人员很早就发现,人眼对色彩细节的分辨能力远低于对亮度细节的分辨能力。把一个2×2像素块的颜色信息合并成一个值,人的眼睛几乎察觉不到,但色度数据量直接减少了75%。这种"骗过眼睛"的取舍,在压缩编码里无处不在。
1.3 编码冗余:等长编码不是最优解
即使像素值本身无法再省,存储环节依然有压缩空间。假设一幅灰度图里灰度值128出现得非常频繁,灰度值0几乎不出现,那给128分配一个短码字、给0分配一个长码字,平均每个像素的存储成本就能低于固定8bit。Huffman编码做的就是这件事:用变长编码给高频出现的值分配短码字,给低频出现的值分配长码字。
一个不涉及任何有损操作的理想Huffman编码,通常能压掉10%~30%的数据量。这意味着,即使我们不做任何视觉上的妥协,纯无损压缩也有不小的收益。后面讲到的JPEG流程里,Huffman编码是最后一道工序,它承接量化之后的数据,把符号流变成真正的比特流。
1.4 压缩率、bpp和比特率:先把口径统一
聊压缩之前,必须先把度量口径统一,不然很容易被数字误导。以一幅512×512的8bit灰度图为例:
- 原始大小 = 512 × 512 × 8 bits = 2097152 bits = 256KB
- 无损压缩后大约180~220KB,取决于图像内容
- JPEG质量因子80左右,通常能到30~60KB
- JPEG2000或更现代的标准,在相同质量下还能再小20%~40%
压缩率通常用"原始数据量 / 压缩后数据量"表示,256KB压到32KB,压缩率就是8:1,也叫8倍压缩。另一种常见口径是bpp(bit per pixel),即平均每个像素用多少bit编码,0.5bpp对8bit灰度图来说就是压缩到原来的1/16。还有场合用比特率,比如2Mbps,多用于视频。做图像仿真时,bpp和压缩率用得最多,记得在论文或博客里注明口径,不然别人复现时会对不上数。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JPEG编码器内部的三件套:变换、量化、熵编码
JPEG标准1992年发布,到现在仍是使用最广泛的图像编码标准,没有之一。它之所以能"活"这么久,是因为框架非常干净:颜色空间转换、分块DCT、量化、熵编码。接下来我把每个模块逐个拆开,讲清楚为什么每一步都是必须的,以及它们之间是怎么衔接的。
2.1 为什么先做DCT而不是直接量化像素
有损压缩最核心的操作是量化。但如果在像素域直接做量化,把256个灰度级变成32个或16个,图像会立刻出现明显的色阶断层,看起来像水渍一样,质量完全不可用。原因很简单:像素域中相邻像素的值被独立量化后,细微的亮度渐变会断裂成一块块突变。所以不能直接在像素域量化,得先把像素变换到频率域,再针对不同频率的重要性做程度不同的量化。
DCT(离散余弦变换)在这里承担的角色是"能量集中"。图像里平缓的区域在变换后集中到少数低频系数上,大多数高频系数接近0。8×8的块做DCT后,左上角的DC系数通常携带绝大部分能量,右下角的高频AC系数大量为0。这为后面的量化、去零、游程编码铺好了路。
类比一下:你写了一份很长的报告,内容大部分是重复的客套话。直接删改原文容易把意思弄乱,但先把报告拆成"核心观点"和"修饰措辞",再重点保留核心、大幅精简修饰,结果会好很多。DCT干的就是"拆"这件事,量化干的才是"删修饰措辞"。
2.2 量化表是怎么设计出来的
JPEG里的量化,就是让每个DCT系数除以一个对应的量化步长再取整。步长越大,舍掉的精度越多,压缩率越高,失真也越大。关键在量化表怎么设计。JPEG标准给了一张参考亮度量化表:
| 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 |
这张表左上角数字小,对应低频,量化步长小,保留精度高;右下角数字大,对应高频,量化步长大,细节损失多。表不是随便拍的,它综合了人眼对不同空间频率的敏感程度:人眼对低频亮度变化敏感,对高频纹理细节相对迟钝,所以高频可以下手狠一点。
实际仿真里,很少直接用这张表,而是用一个质量因子Q来控制整体缩放:
python复制def quality_scale(q):
if q < 50:
s = 5000 / q
else:
s = 200 - 2 * q
return s
def scaled_quant_table(base_table, q):
s = quality_scale(q)
return np.maximum(1, np.floor((base_table * s + 50) / 100))
Q=50时,缩放后基本就是标准表;Q越高,表里数字越小,压缩率越低、质量越高。Q=90以上时,很多系数除以1或2,几乎接近无损。注意量化是JPEG流程中唯一产生信息损失的环节,这就是"量化不可逆"这句话的由来。反量化只能把系数乘回去,无法找回取整丢掉的那部分精度。
2.3 Zigzag、DC预测和Huffman编码
量化之后,每个8×8块得到一组系数矩阵,里面大量是0。为了高效编码,要先把二维矩阵按Zigzag顺序扫成一维序列。目的很简单:把低频的大系数排在前面,把高频的0尽量连在一起,方便后面做游程编码。Zigzag顺序从左上角DC系数开始,沿对角线之字形走到右下角。
DC系数(左上角那个)描述的是整个块的平均亮度,相邻块之间通常很接近。JPEG对DC系数用DPCM(差分脉冲编码调制),只编码当前块与前一块的差值。AC系数则用"游程长度+幅度"的方式编码:记录连续0的个数,以及下一个非零系数的值,这是对大量0序列最经济的方式。最后,DC差分值和AC的(游程,幅度)对再经过Huffman编码,用短码字表示高频出现的符号。
到这一步,完整的JPEG编码流程就是:RGB转YCbCr、色度下采样、8×8分块、DCT、量化、Zigzag加DPCM/RLE、Huffman编码。每一步都不是多余的,去掉任何一环,压缩率或质量都会明显下降。
3. 从零写一个简化版JPEG编码器:仿真代码与细节
原理说得再多,不如代码跑一遍来得直接。我用Python写一个不依赖OpenCV的简化版JPEG编码器,重点放在流程完整性和可读性上。MATLAB读者把dct2、idct2对应替换就行。
3.1 环境准备与整体框架
我用numpy做矩阵运算,scipy.fftpack提供DCT,pillow负责读图。没有pillow的话,用matplotlib或cv2读图本质上是一样的,都是把图像加载成numpy数组。
python复制import numpy as np
from scipy.fftpack import dct, idct
from PIL import Image
整个编码器分四个模块:颜色空间转换、分块DCT、量化、Zigzag扫描。为了演示清晰,我先处理灰度图,彩色部分放到3.4节单独讲。
3.2 分块、DCT、量化的核心代码
python复制def rgb2gray(img_rgb):
return np.dot(img_rgb[..., :3], [0.299, 0.587, 0.114])
def dct2(block):
return dct(dct(block, axis=0, norm='ortho'), axis=1, norm='ortho')
def idct2(block):
return idct(idct(block, axis=0, norm='ortho'), axis=1, norm='ortho')
def encode_block(block, quant_table):
dct_coeff = dct2(block - 128.0)
return np.round(dct_coeff / quant_table)
def decode_block(q_block, quant_table):
coeff = q_block * quant_table
return idct2(coeff) + 128.0
这里有几个细节必须说明。第一,DCT前要把像素减去128,这是JPEG标准做法,让DC系数的范围落在合理区间,避免直流分量过大。第二,norm='ortho'表示正交归一化DCT,保证正变换和反变换对称,仿真时不会出现幅度缩放的问题。第三,量化后的结果一定要取整,取整前后的差别就是有损压缩真正的失真来源。
分块遍历时,图像尺寸不一定是8的倍数,最稳妥的做法是padding,把图像填充到8的倍数,记录原始尺寸,解码后再裁回来。用常数填充(比如128)比用零填充好,因为128在减去128之后DC分量为0,不会在边缘制造额外的高频成分。
python复制def pad_to_multiple(img, block_size=8):
h, w = img.shape
ph = (block_size - h % block_size) % block_size
pw = (block_size - w % block_size) % block_size
if ph == 0 and pw == 0:
return img, (h, w)
padded = np.pad(img, ((0, ph), (0, pw)), mode='constant', constant_values=128)
return padded, (h, w)
3.3 Zigzag扫描与游程编码
Zigzag说白了就是把8×8矩阵按对角线顺序拉直。网上有各种写法,最不容易出错的是查表法:把Zigzag顺序的下标预先固定成一张索引表。
python复制ZIGZAG_ORDER = [
(0,0), (0,1), (1,0), (2,0), (1,1), (0,2), (0,3), (1,2),
(2,1), (3,0), (4,0), (3,1), (2,2), (1,3), (0,4), (0,5),
(1,4), (2,3), (3,2), (4,1), (5,0), (6,0), (5,1), (4,2),
(3,3), (2,4), (1,5), (0,6), (0,7), (1,6), (2,5), (3,4),
(4,3), (5,2), (6,1), (7,0), (7,1), (6,2), (5,3), (4,4),
(3,5), (2,6), (1,7), (2,7), (3,6), (4,5), (5,4), (6,3),
(7,2), (7,3), (6,4), (5,5), (4,6), (3,7), (4,7), (5,6),
(6,5), (7,4), (7,5), (6,6), (5,7), (6,7), (7,6), (7,7),
]
查表实现的好处是代码短、不易错,还能直接复用在反Zigzag上。游程编码部分,我习惯把量化系数矩阵转成一维后,先跳过所有位于末尾的0(这部分用EOB,块结束标记),中间每遇到一个非零系数就记下"前面0的个数+数值"。这个简化实现已经能反映真实流程的核心逻辑。
3.4 彩色图处理:YCbCr与4:2:0下采样
灰度图跑通之后,彩色图还必须多做两步。第一步是RGB转YCbCr,Y是亮度,Cb和Cr是蓝色差、红色差。第二步是对Cb、Cr做2×2下采样(即4:2:0),每个2×2像素块只保留一个色度值。人眼对色度的空间分辨率远低于亮度,4:2:0就能让色度数据量直接减到1/4,视觉影响很小。
python复制def rgb2ycbcr(img):
r, g, b = img[..., 0], img[..., 1], img[..., 2]
y = 0.299 * r + 0.587 * g + 0.114 * b
cb = -0.169 * r - 0.331 * g + 0.500 * b + 128
cr = 0.500 * r - 0.419 * g - 0.081 * b + 128
return np.stack([y, cb, cr], axis=-1)
实际仿真时,建议先跑通灰度版本再扩展彩色。亮度通道用前面那张量化表,色度通道用另一张步长更大的表。JPEG标准里的色度量化表,数字明显更大,因为色度信号对量化误差更不敏感。
3.5 解码重建时的常见遗漏
解码端按相反顺序执行:反量化、反DCT、反Zigzag、合并块、插值色度、YCbCr转RGB。最后一步,务必把像素值clip到[0,255]。这一步很容易漏,DCT重建后某些像素会略低于0或略高于255,不clip的话显示出来会有异常色斑和过曝点。我见过不少仿真代码,前面全对,就差一个clip,结果图怎么看怎么脏。
4. 小波编码与JPEG2000:同场对比后我为什么这么选
很多仿真项目做到JPEG就停了,但如果你做的是遥感图像、医学图像存档,或者需要高倍率压缩,JPEG的分块效应是一个绕不开的瓶颈。这时候值得放进对比的,是JPEG2000这条小波路线。
4.1 小波变换为什么没有方块效应
JPEG的分块DCT是"开窗"处理,每个8×8块独立变换,块与块之间没有关联。压缩率一高,量化误差在各块的边界形成突变,就出现了所谓的方块效应。小波变换则是对整幅图像做多尺度分解,不存在固定分块,量化误差在空间上是铺开的,低码率下主要表现为整体平滑和轻微振铃,不会出现明显的方块边界。
对小波不熟的读者,可以把它理解成一套"变焦镜头":先看整体轮廓,再逐步放大看细节。小波分解把图像分成低频逼近子带(LL)和高频细节子带(LH、HL、HH),每一级都对低频子带继续分解。这样图像的能量被组织成不同分辨率、不同方向的子带,量化策略可以按子带的重要性区别对待。
4.2 相同码率下的JPEG与JPEG2000实测对比
我用同一张512×512的标准测试图,在相似码率下分别跑JPEG和JPEG2000仿真,得到一组有代表性的数据。具体数值会随图像内容浮动,但趋势是稳定的:
| 评价项 | JPEG | JPEG2000 |
|---|---|---|
| 0.5 bpp 时 PSNR | 30.2 dB | 32.8 dB |
| 0.25 bpp 时 PSNR | 27.1 dB | 30.4 dB |
| 主观方块效应 | 明显 | 基本无 |
| 支持无损模式 | 弱 | 原生支持 |
| ROI区域编码 | 不支持 | 支持 |
| 编码速度 | 快 | 慢约3~10倍 |
| 软硬件实现复杂度 | 低 | 高 |
注意bpp(bit per pixel)这个单位,它表示平均每个像素用了多少bit来编码,这比压缩率更科学,因为压缩率跟原始格式有关,而bpp直接对应码率水平。0.5bpp意味着8bit灰度图压缩到原来的1/16,此时JPEG已经能看到明显的块边界,JPEG2000的PSNR高出2~3dB,主观上平滑很多。
4.3 仿真项目里的选型建议
我的建议很直接。如果项目目标是学原理、做实时处理、对比算法在相同框架下的表现,选JPEG,它的模块边界清晰,每个环节都能单独分析。如果项目目标是高倍率存储、无损有损兼需,或者图像里有重点区域需要高质量还原,选JPEG2000或更现代的编码器。如果是给深度学习做数据预处理,我更推荐PNG这种无损格式,因为训练阶段任何有损压缩引入的伪影,都可能被模型当成真实特征学进去,这个锅谁都背不起。
5. 重建质量怎么验收:PSNR、SSIM和局部放大
压缩编码做完,不看指标就相当于考试不查分。但指标要选对,不然容易被数字骗了。
5.1 PSNR:最朴素的客观指标
PSNR(峰值信噪比)定义很直观。对一幅M×N的单通道图像,先算MSE(均方误差),再换算成分贝:
MSE = (1/(M×N)) × Σ(I(i,j) − K(i,j))^2
PSNR = 10 × log10(MAX^2 / MSE)
其中MAX是像素最大值,8bit图像取255。代码实现非常短:
python复制def psnr(orig, recon, peak=255.0):
mse = np.mean((orig.astype(np.float64) - recon.astype(np.float64)) ** 2)
if mse == 0:
return float('inf')
return 10 * np.log10(peak ** 2 / mse)
经验上,重建图像PSNR高于40dB时,人眼很难看出差异;30~40dB算良好;低于25dB,退化就比较明显了。但PSNR有个众所周知的缺点:它对"错误位置"不敏感。同样是30dB,可能是轻微模糊均匀分布,也可能是少数几个块严重损坏,两者PSNR一样,视觉体验完全不同。
5.2 SSIM:贴近人眼的结构相似性
SSIM(结构相似性)把图像质量拆成亮度、对比度、结构三项的乘积,取值范围0到1,越接近1越好。它计算的不是逐像素差值,而是局部窗口内的统计特征,因此对模糊、噪声这类不同失真类型有更好的区分度。scikit-image里有现成实现:
python复制from skimage.metrics import structural_similarity as ssim
score = ssim(orig, recon, data_range=255)
用SSIM做压缩对比时你会发现一个有意思的现象:JPEG在高码率下的SSIM可能很高,但低码率下暴跌;JPEG2000的SSIM曲线则平缓得多,这是因为分块效应会显著破坏局部结构信息。
5.3 我的验收三板斧
我个人做压缩仿真验收,会把三个手段配合使用:PSNR看总体数值水平,SSIM看结构保持程度,最后一定要放大到100%看局部细节。最忌讳的行为是只报一个PSNR就下结论。
具体操作上,我会固定几块典型区域做截图对比:平滑的天空、密集的纹理、高对比的边缘各取一块。这样哪里糊了、哪里出现振铃、哪里有块边界,一眼就清楚。主观评价不能看缩小后的整体图,必须看局部放大,否则方块效应很容易被缩图掩盖。
6. 图像压缩仿真里的坑:这些经验直接拿走
最后这部分,是我做图像压缩仿真以来踩过的坑和积累的经验。每一条都值得划重点,因为这些问题在教科书里往往只是一句带过,实操时才真正折磨人。
6.1 重建图出现网格:分块效应的定位
第一次跑通JPEG仿真,十有八九会在低码率下看到重建图出现淡淡的网格。这个网格有两个来源:一是分块量化误差不一致,导致块与块之间的亮度突变;二是padding方式不当,在边缘引入的高频分量扩散到了相邻块。定位方法很简单:把量化表调保守一点(比如Q从70调到90),如果网格消失,说明是量化误差导致的;如果网格还在,再检查padding和分块逻辑。解决思路有三条:用重叠变换从根源上去掉块边界;解码后加去块效应滤波器;或者干脆换小波路线。
6.2 DCT正反变换的类型与归一化问题
MATLAB和Python里,DCT正变换输出是浮点数,量化取整后变成整数数组,反变换时又得转回浮点。这个类型转换如果处理不好,会出现奇怪的精度损失。另外一个高频坑是归一化参数:不同版本的dct函数,默认norm参数不一样。如果没加norm='ortho',正反变换的缩放因子不一致,重建图会整体偏暗或偏亮。这种全局偏差很隐蔽,看整体图可能觉得"还行",一算PSNR才发现平白无故少了几个dB。
6.3 Quality Factor不是线性旋钮
很多人调Quality Factor时,以为Q从50调到100,质量就线性提升一倍,完全不是。Q在50以上时,量化步长变化非常快,Q=75和Q=95之间文件大小可能差三四倍,但PSNR只差1~2dB。反过来,Q从10调到50,文件大小变化不大,主观质量提升却非常明显。所以仿真时不要一上来就Q=95,又慢又没法反映压缩算法的真实能力。通常Q=70~85是比较合理的探索区间,在这个范围内做对比,结论更有说服力。
6.4 不做熵编码的仿真会误导结论
有的教程为了省事,量化完就直接结束,不写Huffman编码,然后用"量化系数非零个数"来估算压缩率。这种简化用在教学演示没问题,但如果你的实验结论要跟别人论文里的数据对比,就一定要把熵编码做完整。原因很简单:量化后非零系数多少,只反映"有多少信息要存",真正决定码流大小的是这些信息的熵。不做Huffman,你算出的压缩率可能比真实值偏大或偏小20%以上,结论就不具备可比性。
6.5 色偏问题几乎都出在颜色转换
彩色图像仿真里,色偏问题九成出在YCbCr与RGB的转换系数不一致上。同一套系数,正变换和反变换必须严格互逆。很多代码从网上复制时,正变换用BT.601系数,反变换却用了BT.709,或者少加了128的偏移,立刻就会出现全局色偏。我的习惯是把转换系数定义成常量,正反变换引用同一份定义,从源头杜绝不一致。这个方法看似笨,但在长期维护的实验代码里,能省下大量排查时间。
拿我自己来说,每次做压缩仿真对比,我都会先把量化表、颜色转换系数、归一化方式写进一个config文件,固定下来之后再跑实验。这样即使几个月后回来看代码,每个数字是什么含义、为什么取这个值,都清清楚楚。图像压缩编码的仿真并不难,难的是把每个细节都做对、把每个选择的原因都想明白。这个习惯,比任何一份现成代码都值钱。
