渐进式JPEG省下的体积,解码时要还回去多少
摘要
九月底组里一位前端同事问我一个问题。网上都说渐进式 JPEG 又小又能先糊后清,图映导出的 JPG 为什么不是渐进式?我没有直接回答而是先拿两张网上找的示例图跑了一轮对照。同质量下渐进式只小了 3.9%–6.3%,解码却慢了约 2.5 倍。两份文件解码出来的像素一个都不差。这篇从编码侧把这笔账摊开来讲。先讲省下的字节从哪来,再讲解码为什么慢以及多遍扫描对内存和一屏多图意味着什么。然后看只收到一部分字节的时候它能先显示多少。最后说说浏览器画布和图映为什么导出的都是基线式,并附上一份避坑清单。
版本声明
数字全部来自 2026-09-30 的一轮实测。机器是一台 Apple M4 加 16 GB 内存的 Mac,系统是 macOS 26.5.2。编码用的是底层为 libjpeg-turbo 的 Pillow 11.3,基线式和渐进式两份都开了 optimize。解码耗时是在 Chromium 149 开源构建里调 createImageBitmap 连跑 7 次取的中位数。无损转换用的是 libjpeg-turbo 3.2.0 自带的 jpegtran 命令行工具。开源构建和你电脑上装的 Chrome 不是一回事,文中说到浏览器的结论都只到这个构建为止。正式版 Safari 我没在上面验过,Firefox 这一轮也没有跑。
适用边界
两张样本都是网上找的示例图,不是我拍的也不是我做的。第一张是 Wikimedia Commons 上一张 5472×3648 的风景照,我把它缩到 2736×1824 当成网页上一张常见的大图。第二张是网上找的 1242×2688 国庆放假通知模板图,大块纯色背景上压着不少文字。每张各编质量 75 和 85 两档的基线式和渐进式,一共八个文件。两张图和两档质量能看出方向,但撑不起普遍规律。照片纹理更碎、尺寸更小或者编码器换成 MozJPEG 的时候比例都可能变。截断那一章用的是解码器模拟,并不是浏览器实拍。内存那一节是照着解码器的做法算出来的,浏览器里实际占多少我没有量。
文章目录
一、渐进式JPEG到底能省多少体积
- 1.1 同质量下渐进式小了百分之四到六
- 1.2 省下的字节全在熵编码里
二、渐进式JPEG为什么解码更慢
- 2.1 渐进式文件被拆成了10遍扫描
- 2.2 渐进式解码慢了约两倍半
- 2.3 多遍扫描要把整张图的系数攒着
三、一屏几十张图时解码开销怎么算
四、下载到一半时渐进式能先显示多少
- 4.1 收到一成字节时渐进式已有整张图
- 4.2 文件末尾的字节只补回不到3dB
- 4.3 浏览器里的逐帧效果这次没拍到
五、浏览器和图映导出的JPEG是哪一种
- 5.1 浏览器画布导出的JPEG都是基线式
- 5.2 图映的JPG走的是浏览器原生编码
六、想要渐进式该在哪一步转
- 6.1 用jpegtran转不会再损一次画质
- 6.2 缩略图转渐进式不太划算
七、渐进式JPEG要避开哪些坑
八、这些结论在哪些条件下不成立
九、测渐进式时哪些地方容易出错
- 9.1 截断模拟不等于浏览器实拍
- 9.2 比体积时两份要开同一套优化
- 9.3 解码耗时要跑多次取中位数
十、还有哪几件事没有验证
参考资料
一、渐进式JPEG到底能省多少体积
同事的问题其实有两层意思。一层是渐进式到底能小多少,另一层是小了以后要付出什么代价。网上的说法我翻了一圈。给出数字的不少,交代样本和编码参数的却不多。我在音视频 SDK 公司做过三年编解码,那几年养成的习惯是先写 benchmark 再下结论。这次也一样要先把两种格式在同一张图和同一个质量上各编一份再看字节数。
1.1 同质量下渐进式小了百分之四到六
编码这一步我用的是 Pillow,因为浏览器画布根本导不出渐进式(第五章会细说)。Pillow 底下的 libjpeg-turbo 能把 progressive 和 optimize 两个开关分开控制,做对照很方便。我让同一张图按同一个质量各编一次基线式和一次渐进式,两份都开着 optimize。这个开关的作用是按这张图实际的符号分布重新算一套哈夫曼表。它不能只开一边,原因放在第九章讲。风景照用的是缩到 2736×1824 以后存下的无损中间图,模板图直接用原图。
| 样本 | 质量 | 基线式字节 | 渐进式字节 | 渐进式小了 |
|---|---|---|---|---|
| 风景照 | 75 | 844,255 | 811,350 | 3.9% |
| 风景照 | 85 | 1,098,272 | 1,048,255 | 4.6% |
| 模板图 | 75 | 532,106 | 502,023 | 5.7% |
| 模板图 | 85 | 754,005 | 706,842 | 6.3% |
四组都是渐进式更小但小得不多。风景照两档分别小了 3.9% 和 4.6%,模板图两档分别小了 5.7% 和 6.3%。换成字节来看就是风景照质量 85 从 1,098,272 降到 1,048,255,一共少了大约 49 KB。笼统地说就是同质量下小了百分之四到六。这个量级放在一张一兆左右的大图上不算零,放在一张几十 KB 的缩略图上就只剩下一两 KB 了。模板图比风景照省得多一点。这件事我起初以为是巧合,看完扫描结构才找到一个说得通的解释。
1.2 省下的字节全在熵编码里
体积对完以后我又做了一件网上很少有人提到的事。我把基线式和渐进式两份文件都解码成 RGB 然后逐像素做差,四组的差值全是零。接着我拿 jpegtran 把基线式文件无损转成渐进式,用的命令是 jpegtran -copy none -progressive。转出来的四个文件和 Pillow 直接编出来的渐进式用 cmp 比完逐字节相同。这说明在这批样本上两种格式的 DCT 系数和量化结果完全一样,差别只出在最后一步的熵编码上。画质上渐进式一点没丢也一点没多,只是把同一批系数换了个顺序和写法存进文件里。
省下的那百分之几来自两个地方。这是我对格式本身的理解,并没有逐项拆开来量。第一个地方是哈夫曼表。渐进式分成好几遍扫描,每一遍都可以带上自己的一套表。低频系数和高频系数的统计分布差得很远,分开建表自然会更贴合。第二个地方是交流系数扫描里的 EOBRUN 机制,它能用一个符号表示后面连续多少个块在这个频段里全是零。基线式没有这个机制,每个块都得老老实实写一个块结束符。模板图大面积纯色背景上的块几乎没有高频系数,EOBRUN 可以一次跳过一大片。我猜这就是模板图多省了一两个百分点的原因。这个猜测只在两张图上方向一致,我没有做控制变量的实验。
二、渐进式JPEG为什么解码更慢
收益看清楚了再来看付出的那一边。要讲清解码为什么慢就得先看看渐进式文件里装了几遍扫描以及每一遍装的是什么。
2.1 渐进式文件被拆成了10遍扫描
JPEG 文件是一串以 0xFF 开头的标记段。帧头是 SOF0 的就是基线式,帧头是 SOF2 的就是渐进式。每一遍扫描都以 SOS 标记开头,头里写着这一遍包含哪几个颜色分量、覆盖哪段频率和精度切到第几位。我写了一段顺着标记往下走的小脚本,把每一遍的参数和字节数都打印出来。
def scan_end(data, p):
"""熵编码数据里 FF00 是转义、FFD0-FFD7 是复位标记,遇到别的 FFxx 才算这遍扫描结束"""
while True:
p = data.index(0xFF, p)
nxt = data[p + 1]
if nxt != 0x00 and not 0xD0 <= nxt <= 0xD7:
return p
p += 2
def walk_markers(data):
pos, sof, scans = 2, None, []
while True:
code = data[pos + 1]
if code == 0xD9:
return sof, scans
span = int.from_bytes(data[pos + 2:pos + 4], 'big')
if 0xC0 <= code <= 0xC2:
sof = 'SOF%d' % (code - 0xC0)
if code != 0xDA:
pos += 2 + span
continue
ncomp = data[pos + 4]
ss, se, a = data[pos + 5 + 2 * ncomp: pos + 8 + 2 * ncomp]
end = scan_end(data, pos + 2 + span)
scans.append((ncomp, ss, se, a >> 4, a & 15, end - pos))
pos = end
landscape_q85_base.jpg SOF0 1 遍
分量3 频段 0-63 Ah0 Al0 1097883 B
landscape_q85_prog.jpg SOF2 10 遍
分量3 频段 0- 0 Ah0 Al1 77467 B
分量1 频段 1- 5 Ah0 Al2 163971 B
分量1 频段 1-63 Ah0 Al1 13012 B
分量1 频段 1-63 Ah0 Al1 12213 B
分量1 频段 6-63 Ah0 Al2 179313 B
分量1 频段 1-63 Ah2 Al1 210582 B
分量3 频段 0- 0 Ah1 Al0 14714 B
分量1 频段 1-63 Ah1 Al0 21763 B
分量1 频段 1-63 Ah1 Al0 20002 B
分量1 频段 1-63 Ah1 Al0 334551 B
代码说明。scan_end 负责在熵编码数据里找到这一遍扫描的结尾。数据里的 FF00 是转义,FFD0 到 FFD7 是复位标记。只有碰到这两种以外的 FFxx 才算结尾。walk_markers 顺着标记段一路往下走,碰到 SOF 就记下帧类型。碰到 SOS 就读出分量数、频段起止 Ss 和 Se 以及逐次逼近用的 Ah 和 Al,再算出这一遍占了多少字节。频段 0 是直流系数,也就是每个 8×8 块的平均亮度或平均颜色。频段 1 到 63 是交流系数,数字越大代表频率越高。Al 表示这一遍只写到第几位精度,Ah 不为零的那几遍是在给前面补精度。
基线式只有一遍,三个颜色分量的全部 64 个系数一口气写完。渐进式是 libjpeg 默认扫描脚本给出的 10 遍,模板图那份也是同样的 10 遍。它的顺序是先写三个分量的直流系数,并且故意砍掉最低一位。然后依次写亮度的前 5 个低频系数、两个色度分量的全部交流系数和亮度剩下的高频系数。前六遍都只有粗精度,后四遍专门用来补精度。我顺手算了一下每一遍结束时占到文件的百分之几。风景照的直流那一遍在文件 7.4% 的位置就写完了。亮度低频、两个色度和亮度高频依次写到 23.1%、25.5% 和 42.6% 为止。最后一遍是亮度的最低一位精修。单这一遍就有 334,551 字节,差不多占了整个文件的三分之一。后面两章都要用这个结构来解释解码为什么慢和截断时为什么能先看到整张图。
2.2 渐进式解码慢了约两倍半
解码耗时是在浏览器里量的。我把八个文件各自读成 Blob 以后调 createImageBitmap 解码,每个文件连跑 7 次取中位数。

这张图要看什么。左半边是文件体积,右半边是解码耗时。灰色柱子代表基线式,蓝色柱子代表渐进式。每一半的四组从左到右依次是风景照质量 75、风景照质量 85、模板图质量 75 和模板图质量 85。左边每对柱子之间只差一小截。右边每对柱子里蓝色的都比灰色的高出一倍多。体积省下的那一点和解码多花的那一截放进同一张图里,比例一眼就能看出来。图上的体积是按 1024 字节折算的 KB,所以和上一章按字节写的数对不上整数。两者是同一组数据。
四组解码耗时是这样的。风景照质量 75 是 9.7 毫秒对 24.7 毫秒,质量 85 是 11.1 毫秒对 28.3 毫秒。模板图质量 75 是 6.4 毫秒对 15.6 毫秒,质量 85 是 7.5 毫秒对 19.2 毫秒。渐进式的解码耗时是基线式的约 2.3–2.6 倍,四组都在两倍半上下。多出来的绝对时间落在 9 到 17 毫秒之间。单独一张图的这点差别用户根本感知不到,要等页面上的图多起来才值得认真算。
慢的原因我能说出三条。它们都是从格式和解码器的做法推出来的,我没有拿性能剖析工具逐项量过。第一条原因最直接。基线式只读一遍,每读完一行块就能反变换成像素。数据在缓存里只流过一次。渐进式要读 10 遍并且每一遍都要回头去改同一批系数。同一块内存被来回翻了十次。第二条原因在补精度的那几遍上。每个非零系数都要逐位追加一位,这种按位进行的哈夫曼解码比成组解码慢得多。第三条原因跟 libjpeg-turbo 本身有关。据我所知它给基线式的哈夫曼解码做了一条专门的快速路径,渐进式走的是更通用也更慢的那一条。这三条里到底哪一条占大头这次我没有拆开来量。
2.3 多遍扫描要把整张图的系数攒着
解码慢只是一面,另一面是内存。基线式解码可以边读边出像素,一行块反变换完以后这一行的系数就能丢掉。渐进式做不到这一点。它的第一遍只给了直流系数,后面九遍还要不断往同一批块里补东西。解码器只能把整张图所有块的系数都攒在内存里,一直等到最后一遍补完才算定稿。libjpeg 的做法是把每个系数存成一个 2 字节的整数。我按这个口径把两张样本需要的系数缓冲算了一下。
import math
import PIL.Image as PImage
def coef_buffer(path):
"""渐进式解码要把整张图的 DCT 系数攒着等后面几遍来补;每个系数按 2 字节算"""
im = PImage.open(path)
w, h = im.size
hmax = max(l[1] for l in im.layer)
vmax = max(l[2] for l in im.layer)
blocks = sum(math.ceil(w * hs / hmax / 8) * math.ceil(h * vs / vmax / 8)
for _, hs, vs, _ in im.layer) # (分量ID, 水平采样, 垂直采样, 量化表)
return w, h, blocks * 64 * 2, w * h * 4
for f in ('out/landscape_q85_prog.jpg', 'out/template_q85_prog.jpg'):
w, h, coef, rgba = coef_buffer(f)
print(f'{w}x{h} 系数缓冲 {coef / 2**20:.1f} MiB 解码后 RGBA {rgba / 2**20:.1f} MiB')
2736x1824 系数缓冲 14.3 MiB 解码后 RGBA 19.0 MiB
1242x2688 系数缓冲 9.6 MiB 解码后 RGBA 12.7 MiB
代码说明。Pillow 打开 JPEG 以后,layer 属性里存着每个颜色分量的采样因子。两张样本用的都是 4:2:0 抽样。亮度通道按原尺寸切块,两个色度通道先横竖各减一半再切块。块数乘以每块 64 个系数再乘以每个系数 2 字节,得到的就是系数缓冲的大小。后一列是按每像素 4 字节算出的 RGBA 位图大小,拿来当参照用。这段代码只是做算术并不是测量。
算出来风景照要攒大约 14.3 MiB 的系数,解码完的位图本身是 19.0 MiB。也就是说渐进式在解码过程中除了最终那张位图,还要多撑一块接近位图四分之三大小的缓冲。基线式不需要这块整图缓冲,只要一两行块的工作区就够了。这块缓冲只在解码那一小会儿里存在,解完就可以释放。浏览器会不会用别的办法把它省掉、峰值到底会多出多少,这些我都没有量。这一节的结论只能当推测来看。它真正要紧的场合是同时解很多张图的时候。
三、一屏几十张图时解码开销怎么算
单张图的账很好算。风景照质量 85 的渐进式多花了 17.2 毫秒,另外多撑一块 14 MiB 左右的临时缓冲。没有哪个用户会感觉得到。麻烦出在页面一次出很多张图的时候,比如商品列表、相册网格和瀑布流。这一章的数全是从上面两组实测往外推出来的。我并没有真的搭一个几十张图的页面去量,这一点要先说在前面。
先说耗时。解码开销大体上跟像素数成正比,一张 400×400 的缩略图只有 2736×1824 那张图大约三十一分之一的像素。照这个比例粗略推算一张缩略图用渐进式多出来的解码时间只有零点几毫秒。一屏三十张缩略图加起来也就多出十几毫秒。十几毫秒摊进首屏里不算大,可它也换不来什么东西。缩略图本身只有几十 KB,省下的百分之四到六只是一两 KB。放到现在的网速下这几乎等于零。拿一两 KB 去换两倍半的解码时间,这笔账在缩略图上是亏的。解码到底是在主线程还是后台线程里做、会不会跟滚动抢时间,这次我也没有测。
再说内存。一屏的大图要是同时开始解码,每张图各自攒下的整图系数缓冲就会叠在一起推高峰值。按上一节的算法十张 2736×1824 的渐进式同时解码时光系数缓冲就要一百四十多 MiB。这还没有算上最终的位图。手机浏览器对图片解码用的内存是有预算的,超出以后怎么处理各家做法也不一样。我家里有一台内存很小的旧安卓机,平时拿来给女儿放动画片。那台机器上大图网格一滑快就是白花花的一片。那到底是不是渐进式造成的我没法下结论,只是写到这里想起了它。这一章我能确定的只有一件事,那就是渐进式的每一张图都要多付一份临时缓冲。这份开销会不会叠成问题要看页面怎么调度解码,我手上没有数据。
四、下载到一半时渐进式能先显示多少
前三章算的都是代价,这一章来讲它换来的东西。渐进式最常被人提起的好处是先糊后清。网速慢的时候基线式图片会从上往下一行一行地出来。渐进式则先出一张整体偏糊的图,然后再慢慢变清楚。这个好处在编码侧能不能量出来,我换了一个办法来试。
4.1 收到一成字节时渐进式已有整张图
我没有在浏览器里限速去拍的原因写在 4.3 节。我的办法是直接截断文件。风景照质量 85 的两份文件只保留前 10%、25%、50% 和 75% 的字节,再让解码器用数据不完整也尽量画的模式去解。缺掉的那部分会留成灰色。解出来的图再拿去跟原图算 PSNR。
import math, io
from PIL import Image, ImageFile, ImageChops, ImageStat
ImageFile.LOAD_TRUNCATED_IMAGES = True # 截断的文件照样解,后半截缺的地方留灰
def psnr_db(got, ref):
mse = sum(v * v for v in ImageStat.Stat(ImageChops.difference(got, ref)).rms) / 3
return 10 * math.log10(255 ** 2 / mse)
ref = Image.open('src/landscape_ref.png').convert('RGB')
for variant in ('base', 'prog'):
blob = open(f'out/landscape_q85_{variant}.jpg', 'rb').read()
row = []
for share in (10, 25, 50, 75, 100):
part = blob[:len(blob) * share // 100]
img = Image.open(io.BytesIO(part)).convert('RGB')
row.append(f'{share}%={psnr_db(img, ref):.2f}')
print(variant, ' '.join(row))
base 10%=13.58 25%=14.65 50%=15.55 75%=18.16 100%=36.81
prog 10%=18.90 25%=22.73 50%=30.97 75%=34.07 100%=36.81
代码说明。打开 LOAD_TRUNCATED_IMAGES 以后 Pillow 碰到提前结束的 JPEG 不会报错,它会把已经收到的那部分解出来。psnr_db 函数先求出两张图的逐像素差,再用每个通道的均方根还原出均方误差。三个通道取平均以后换算成分贝。参照图是缩放以后存下的无损中间图。两份完整文件的 PSNR 都是 36.81,这就是质量 85 本身带来的损失。截断比例按文件的字节数来算,模拟的是下载到这一步时手里有多少数据。
只收到一成字节的时候基线式只画出了最上面的一条。下面全是灰块,PSNR 只有 13.58。渐进式的整张图都在,只是画面偏软。它的 PSNR 是 18.90。对照上一章的扫描结构来看这件事就很好理解。直流那一遍在文件 7.4% 的位置就写完了。10% 的数据里已经有每个块的平均颜色和一部分亮度低频。整张图的轮廓和色块都出来了,只是还没有细节。收到一半字节的时候渐进式已经到了 30.97 dB。这时亮度高频那一遍早在 42.6% 的位置就写完了。缺的只是精度,要放大看才看得出和原图的差别。基线式这时还只画出了上半截,PSNR 是 15.55。这组数来自解码器模拟,这句话我先钉在这里。
4.2 文件末尾的字节只补回不到3dB
表里还有一个容易漏看的地方。渐进式的 PSNR 从 75% 时的 34.07 涨到 100% 时的 36.81。最后四分之一的字节只补回了 2.74 dB,不到 3 dB。原因同样藏在扫描结构里。最后一遍是亮度系数的最低一位精修。它从文件 68.1% 的位置一直写到结尾,一遍就占了三分之一的体积。这一遍补的只是每个系数最末一位的精度。它对画面的贡献很小,占掉的字节却最多。

这张图要看什么。左右两边是风景照里同一块芦苇和树枝,都放大了 3 倍。左边是渐进式文件只收到 25% 字节时解出来的样子,右边是完整文件解出来的样子。左边能看到一格一格的方块感。树枝的走向和芦苇的颜色都在,细枝末节却糊成了一片。右边的细枝和芦苇穗都是清清楚楚的。25% 这个位置正好处在两个色度分量的交流系数快写完、亮度高频还没开始的时候。这时颜色已经基本对了,边缘还没有出来。这张图说明不了浏览器里的观感,它说明的是同样 25% 的数据渐进式能拿出什么。在同一个位置上基线式只画出了图的最上面一截。
这个结果对编码侧有一个实际的意义。有人会想既然末尾那段字节的贡献这么小,干脆不要最后一遍换一个更小的文件行不行。那样做其实等于把质量降了一档。直接换一个更低的质量去编基线式通常更省事,还不用背上渐进式的解码代价。渐进式值钱的是前面那一小截数据能先拼出整张图,末尾那段本来就是给画质兜底的。
4.3 浏览器里的逐帧效果这次没拍到
我原本想拍的是浏览器里的真实过程。做法是起一个本地服务,按很低的速率一小块一小块地往外吐数据。风景照质量 85 的两份文件分别放进页面,在下载过程中按固定的时刻截一组图。结果截图里的画面总是在整张图下载完以后才出来,中间过程一张都没有拍到。我怀疑是截图的时机和浏览器刷新画面的时机没有对上,但没有再往下查。这篇文章里我不写浏览器里先糊后清快了多少秒,也不写首屏快了百分之几。这两个数我都没有测到。能写的只有截断模拟这一组数据。
五、浏览器和图映导出的JPEG是哪一种
收益和代价都摆出来以后再回到同事那个问题上。图映导出的 JPG 为什么是基线式,答案要先从浏览器说起。
5.1 浏览器画布导出的JPEG都是基线式
前端在浏览器里压图最常见的做法是先把图画到 canvas 上再调 toBlob 导出 JPEG。toBlob 只接收格式和一个质量参数,没有任何开关可以选渐进式。我在页面里写了一个读帧头的小函数,把几档质量导出的结果都读了一遍。
async function frameKind(blob) {
const buf = new Uint8Array(await blob.arrayBuffer());
let off = 2;
while (off + 4 < buf.length && buf[off] === 0xff) {
const t = buf[off + 1];
if (t >= 0xc0 && t <= 0xc1) return 'baseline';
if (t === 0xc2) return 'progressive';
off += 2 + (buf[off + 2] * 256 + buf[off + 3]);
}
return 'unknown';
}
const cv = Object.assign(document.createElement('canvas'), { width: 640, height: 480 });
const g = cv.getContext('2d');
g.fillStyle = '#c33'; g.fillRect(0, 0, 640, 480); g.fillStyle = '#fff'; g.fillText('test', 20, 40);
const results = {};
for (const q of [0.5, 0.85, 0.99]) {
const blob = await new Promise(r => cv.toBlob(r, 'image/jpeg', q));
results[q] = await frameKind(blob);
}
results;
{"0.5":"baseline","0.85":"baseline","0.99":"baseline"}
代码说明。frameKind 从文件的第 3 个字节开始顺着标记段往下走,每一段读出长度就直接跳过去。碰到 SOF0 或 SOF1 就返回 baseline,碰到 SOF2 就返回 progressive。这比在整个文件里搜 FFC2 更稳妥,因为熵编码数据和内嵌缩略图里都可能碰巧出现这两个字节。后半段随手画了一张 640×480 的画布,按质量 0.5、0.85 和 0.99 各导出一次。这段代码是在 Chromium 149 开源构建的页面里直接跑的。
三档质量导出的全都是基线式。我另外拿放假通知模板那张图按质量 0.85 导出过一次,文件头同样是 SOF0。至少在这个构建上浏览器画布这条路是导不出渐进式的。Firefox 和正式版 Safari 这一轮都没有验,我不替它们下结论。
5.2 图映的JPG走的是浏览器原生编码
先交代一下图映 ImgIng。它是一个在浏览器里本地压缩和转换图片的在线工具,端侧编解码这一块是我在负责。我读了 10-01 那轮测试留存的两份图映导出 JPG 的文件头,两份都是 SOF0 基线式。原因就是上一节说的那件事。图映导出 JPG 走的是浏览器原生编码,也就是 canvas 那条路。浏览器给什么我们就出什么。压缩界面上 JPG 标的是即时,AVIF 标的是按需 WASM。AVIF 靠浏览器画布导不出来,只能在首次使用时加载一个 libavif 的 WASM 编码器。JPG 浏览器自己就能编。用户点下去马上就出结果,不用多等一个编码器下载。
JPG 走原生编码是我定的,当时看重的就是即时出结果。渐进式的代价在这次之前我没有专门量过。这一轮量完以后我的判断是维持原样。要出渐进式就得像 AVIF 那样再带一个 JPEG 的 WASM 编码器。首次使用的人要多下载一份文件,编码速度多半也比原生慢。换来的是文件小百分之四到六,外加前面说的解码慢两倍半。这份解码代价会落在每一个看图的人身上。对一个面向普通用户、主打即时出结果的压缩工具来说,这笔账我算下来并不划算。这是我在这个产品上的取舍,换个场景结论可能就反过来。如果你的场景是少量大图加上用户网速慢,第六章会讲怎么在别的环节把它转出来。
还有一点顺带说明一下。图映 JPG 的质量滑杆拉到 100 的时候,送进编码器的其实是 0.99。这样做是为了避开浏览器对 1.0 的特殊处理,跟渐进式没有关系。它说明的是另一件事。走原生编码就意味着编码器的细节我们基本碰不到,能控制的只有质量这一个数。
六、想要渐进式该在哪一步转
浏览器画布出不了渐进式。这件事确定以后想用渐进式的人就只剩下一条路,那就是在别的环节转。最常见的做法是上传以后在服务端转,或者在发布前的构建流程里转。
6.1 用jpegtran转不会再损一次画质
转换的时候最容易犯的错误是把已经压好的 JPEG 解码成像素再按渐进式重新编一次。这样做等于又走了一遍有损压缩。画质会再掉一截,质量参数也未必能跟原来对得上。第一章说过两种格式的差别只在熵编码上。系数本身根本不用动,换个顺序重新写一遍就可以了。jpegtran 干的正是这件事。它直接读出原文件的 DCT 系数,不做反变换就按渐进式的扫描脚本重新写出来。
我拿四份基线式文件用 jpegtran -copy none -progressive 试了一遍。转出来的四个文件解码以后和原来的基线式逐像素相同,差值是零。字节数和 Pillow 直接编出来的渐进式也一模一样。风景照质量 85 转完就是 1,048,255 字节,四个文件用 cmp 比完都逐字节相同。转换这一步既不损画质,也不会比从头编多占一个字节。参数里的 -copy none 会把 EXIF 这类元数据段去掉。需要保留拍摄信息的话就换成 -copy all,体积也会跟着多一点。服务端的图片库大多也提供渐进式开关,底层通常还是 libjpeg 一系的编码器。它们到底是做重排还是重新编码,要看你调的是哪个接口。这一点我没有逐个去验。
6.2 缩略图转渐进式不太划算
哪些图值得转我现在主要看两样东西。一样是单张图有多大,另一样是用户网速怎么样。格式本身反倒不是重点。一张一兆左右的首屏大图用渐进式能省下 40 到 50 KB。网速慢的时候它还能让用户只收到一成数据就看到整张图的轮廓。这两样好处都是实打实的。代价是解码多出十几毫秒外加一块临时缓冲,单张大图完全付得起。缩略图的情况正好反过来。几十 KB 的小图几十毫秒就下载完了,先糊后清几乎没有露脸的机会。省下的一两 KB 可以忽略不计,多出来的解码时间却一张不落地乘到每一张图上。列表和网格这类一屏几十张图的页面,我会让缩略图保持基线式。
七、渐进式JPEG要避开哪些坑
前六章的结论压成下面这份清单,可以对着自己的项目逐条去查。
- 别把渐进式当成压缩手段。它和基线式解码出来的像素完全一样,同质量下只小 3.9%–6.3%。想要明显更小的话调低质量或者换成 WebP 和 AVIF 更有效。
- 别在浏览器里找渐进式开关。canvas 的 toBlob 在 Chromium 149 开源构建上只能导出基线式,前端压图这条路给不出渐进式。
- 别把压好的 JPEG 解码以后再重编一次渐进式。用 jpegtran 这类无损重排工具就够了。画质一点不损,体积和直接编出来的一样。
- 别在缩略图网格上默认开渐进式。收益只有一两 KB,解码耗时却是基线式的两倍半左右。每张图还要多撑一块临时缓冲。
- 别凭截断模拟就宣称首屏快了多少。先糊后清在解码器上可以模拟出来,浏览器里的真实观感要在真实网络上另外验证。
- 比体积的时候两份都要开 optimize。只开一边的话差出来的那部分是哈夫曼表优化的功劳而不是渐进式的功劳。
- 转换之前先读帧头确认原文件是哪一种。要看的是 SOF0 还是 SOF2,别看扩展名也别凭感觉猜。
八、这些结论在哪些条件下不成立
所有数字都来自两张网上找的示例图。一张是 2736×1824 的风景照,另一张是 1242×2688 的放假通知模板。尺寸小很多的图或者纹理特别碎的照片都可能得出另一个比例。编码器只用了 libjpeg-turbo 的默认扫描脚本。MozJPEG 会给每张图挑一套更省的扫描脚本,还会做网格量化之类的其他优化。它编出来的两种格式之间可能差得更多,它的解码耗时我也没有测。解码耗时只在一台 Apple M4 的 Chromium 149 开源构建上量过,换成手机芯片或者别的浏览器内核以后倍数都可能变化。内存那一节是照着 libjpeg 的缓冲方式算出来的。浏览器解码器要是换了实现那块缓冲可能会更小甚至根本不存在。截断那一组是解码器模拟,它说明的是同样多的数据两种格式能画出什么。这并不等于浏览器会在同样的时间点把它画到屏幕上。
图映这部分的结论也有前提。我读文件头的是 10-01 那轮留存的两份导出文件,都是在 Chromium 上导出的。导出 JPG 走浏览器原生编码这一点换了浏览器也一样,但各家浏览器原生编码器的细节并不相同。别的浏览器上导出的是不是也一律是基线式,我没有验。
九、测渐进式时哪些地方容易出错
9.1 截断模拟不等于浏览器实拍
截断文件再解码问的是同样多的数据能画出什么。浏览器实际显示图片的时候还牵扯到别的事情。多久刷新一次画面、是不是等到第几遍扫描才去画、图片在不在可视区域里,这些都会影响用户真正看到的东西。4.3 节那次限速截屏没有拍到中间过程,正好说明这两件事不能画等号。把截断模拟算出的 PSNR 写成浏览器首屏快了多少,是最容易犯的一种越界。
9.2 比体积时两份要开同一套优化
这一条我专门补了一组对照。基线式那份不开 optimize 只用标准哈夫曼表,渐进式那份照常编。这样比出来的结果是模板图质量 75 的渐进式小了 9.2%,质量 85 小了 8.1%。风景照两档分别是 4.7% 和 6.0%。两边都开 optimize 以后,四组的差距缩回到 3.9% 到 6.3%。差出来的那几个百分点其实是哈夫曼表优化的功劳。libjpeg 编渐进式的时候总会按图的实际分布去算哈夫曼表。基线式默认用的是标准表,不开 optimize 就先吃了亏。在模板图上单开 optimize 就能让基线式小 3.7%,这个开关本身的作用跟渐进式差不多大。比较的时候两份必须是同一张图、同一个质量和同一套优化开关,这样差出来的才是渐进式自己的那一份。
9.3 解码耗时要跑多次取中位数
一次解码只有十几二十毫秒。单跑一次的抖动很大,第一次调用往往还带着预热开销。我每个文件连跑 7 次再排序取中间那个值,四组倍数这才稳定在两倍半上下。还有一处要留意的是 createImageBitmap 量的是解码成位图的时间。它既不包含画到屏幕上的时间,也不包含下载的时间。拿它去推页面加载时间的话中间还隔着好几层。
十、还有哪几件事没有验证
第一件是浏览器里的真实观感。限速截屏没有拍到中间帧。渐进式在真实网络上能让用户提前多久看到一张可用的图,我手里没有数。第二件是一屏多图的实际开销。第三章全是推算。几十张渐进式同时解码会不会让滚动掉帧、内存峰值会多出多少,都得搭真实页面去量。第三件是 MozJPEG。它会自己挑扫描脚本,体积和解码耗时都可能跟这篇文章里的数不一样。第四件是 Firefox 和正式版 Safari。这两家画布导出的 JPEG 是不是也只有基线式,这一轮没有跑。第五件是图映以后要不要给 JPG 加一个渐进式选项。按这次的数据我倾向于不加。要是以后有人拿着一个真实的慢网大图场景来找我,我会先在那个场景上把这组测试重跑一遍再做决定。
手上有一批要上线的 JPEG 的话,可以先用第二章那段脚本读一下帧头和扫描遍数看看现在用的到底是哪一种。然后挑一张首屏大图用 jpegtran 转一份渐进式,对一下体积差了多少。要是差不到百分之五就别在这件事上多花时间了。
参考资料
- ITU-T T.81(ISO/IEC 10918-1)JPEG 标准原文 附录 G 渐进式扫描与逐次逼近
- libjpeg-turbo 文档 jpegtran 与 cjpeg 的 -progressive 和 -optimize 参数及默认扫描脚本
- Pillow 文档 JPEG 保存参数 quality、optimize、progressive 与 ImageFile.LOAD_TRUNCATED_IMAGES
- HTML 标准 HTMLCanvasElement.toBlob 的格式与质量参数
- Wikimedia Commons 本文风景照样本的来源
浙公网安备 33010602011771号