摘要
做活动页的同事前几天问我导出 JPG 时那个「渐进式」到底要不要勾。网上两种说法我都见过。一种说渐进式文件更小还能在网慢时先糊后清。另一种说它解码更费劲,放到手机上反而拖后腿。我以前一直凭感觉乱勾,这回干脆拿同一张图两种各存一份挨个比了四样东西。比的是体积、解码耗时、只收到一部分数据时能画出什么、手边的工具各自导出哪一种。先说结论。同样质量下渐进式小了 3.9%–6.3%。它的解码耗时是基线式的约 2.3–2.6 倍。好在两边都只是毫秒级。渐进式真正占便宜的地方在网慢的时候。模拟只收到一成数据时渐进式已经能画出整张偏软的图,基线式只画出最上面一条。麻烦在于我测的三个浏览器内核用 canvas 导出的全是基线式,图映导出的 JPG 也一样。想要渐进式就得交给专门的编码工具。好在已有的基线式文件用 jpegtran 就能改排成渐进式,不用重新压也不动一个像素。我最后的做法是首屏那几张大图转,一百来像素宽的小缩略图不转。
版本声明
数据是 2026-09-30 跑的。机器是一台装着 macOS 26.5.2 的 16 GB 内存 Apple M4 Mac。存 JPG 主要用的是底下跑着 libjpeg-turbo 的 Pillow 11.3,存的时候都开了 optimize。命令行工具是 Homebrew 装的 libjpeg-turbo 3.2.0 里自带的 cjpeg、djpeg 和 jpegtran,外加 macOS 自带的 sips。浏览器用了 Chromium 149、Firefox 151 和 WebKit 26.5 三个开源构建。它们跟日常装的正式版 Chrome、Firefox、Safari 不是一回事。文里说到内核的地方只管这三个构建,正式版 Safari 上是什么情况我没验过。解码耗时是在 Chromium 149 里用 createImageBitmap 重复量 7 次取的中位数。KB 按 1024 字节算,表里的字节数都是照着文件大小原样抄下来的。
适用边界
样本只有两张网上找的图,不是我拍的也不是我做的。一张是 Wikimedia Commons 上的一张风景照。原图是 5472×3648,我把它缩到 2736×1824 当成网页里的一张大图来用。另一张是网上找的「2026年国庆节放假通知」模板图。这张 1242×2688 的图出自稿定设计的模板,红底上有插画、日历和好几行字。每张都存了质量 75 和 85 两档,每档各一份基线式和渐进式。「只收到一部分数据」那一章是拿解码器模拟的,做法是把文件截断以后再解码,不是在浏览器里实拍的。我试过在浏览器里限速截屏,可每次截到的都是整张图下完之后的画面。中间过程一次也没拍到。因为这个文里不会写渐进式在浏览器里让首屏快了几秒。手机上的解码耗时我也没测。
文章目录
一、渐进式JPEG和基线式有什么区别
- 1.1 勾不勾渐进式我一直凭感觉
- 1.2 两种文件解出来的像素完全一样
- 1.3 区别只在数据分几趟存
二、怎么看出一张JPG是哪一种
- 2.1 文件头里的SOF标记说了算
- 2.2 渐进式文件被拆成了10段
三、换成渐进式能省多少体积
- 3.1 同样质量下渐进式小了一截
- 3.2 基线式开了优化也能省下一部分
- 3.3 缩略图太小反而会变大
四、渐进式解码会慢多少
- 4.1 大图解码慢了约2.5倍
- 4.2 40张缩略图多花的时间不到一帧
五、网速慢时渐进式能先显示什么
- 5.1 只收到一成数据就能看到整张图
- 5.2 收到一半时已经接近原图
- 5.3 放假通知模板上差距更大
六、哪些工具能导出渐进式JPEG
- 6.1 三个浏览器内核只导出基线式
- 6.2 命令行工具加一个参数就能导出
- 6.3 jpegtran转换不重压也不丢像素
七、什么情况值得换成渐进式
- 7.1 首屏大图值得换
- 7.2 小缩略图不用换
- 7.3 已有的图可以批量无损转换
八、适用边界与风险
九、测这两种JPEG容易踩哪些坑
- 9.1 截断解码不等于浏览器实拍
- 9.2 比体积前先把优化选项对齐
- 9.3 解码耗时要多跑几轮
十、还有哪些没弄清楚
参考资料
一、渐进式JPEG和基线式有什么区别
1.1 勾不勾渐进式我一直凭感觉
我在一家内容公司做技术岗,写脚本、搭内部小工具什么都沾一点。我是中文系转过来的,图片格式这些都是边用边学。活动页和专题页上的大图常常要我帮忙压,那个「渐进式」选项我见过很多回。有的工具把它藏在高级设置里,有的直接默认勾上。我以前处理得很随便,默认勾了就勾着。没勾也懒得去点。前几天做活动页的同事问我到底要不要勾,我张嘴就想说「勾上吧听说更小」,话到嘴边才发现自己根本说不出小多少。网上两派说法我都看过。支持的一派说渐进式文件更小,网慢时先出一张糊图再慢慢变清楚,比基线式从上往下一截一截刷出来要好看。反对的一派说它解码要多跑好几趟,在手机上费 CPU 反而拖后腿。两边说得都挺像回事,可我没见过谁拿同一张图把这几项一起摆出来。这次我就按这个思路拿同一张图、同一个质量只换存法挨个量一遍。
1.2 两种文件解出来的像素完全一样
动手之前我有个误会,我一直以为渐进式是另一种压法,画质会跟基线式有点不同甚至更糊一点。第一件事我就去验这个,把风景照质量 85 的两份文件都解码成像素再一个一个比。最大差值是 0,每个像素的 RGB 都一模一样。也就是说在同一个质量下两种文件装的是同一张图。画质损失在压缩那一步就定了,存成哪种排法都不会再去动它。这一点对后面的判断很要紧,既然画质完全一样,要比的就只剩体积、解码速度和加载过程这三件事。哪个更清楚这事不用再纠结了。
1.3 区别只在数据分几趟存
这部分我是照着格式规范和别人的讲解理解的。讲得会土一点。JPEG 会先把图切成一个个 8×8 的小方块再把每个方块换算成一组系数。排在前面的几个系数管这一块大概是什么颜色和明暗,排在后面的管细节和边缘。基线式按顺序一块一块地存,一块的全部系数存完了再存下一块。浏览器收到多少数据就只能画出多少块,画面就这样从上往下一截一截长出来。渐进式把这个顺序换了过来,先把所有方块的「大概颜色」存一遍,然后一趟一趟地往上补细节。收到第一趟就能画出整张图的轮廓。只是画面很糊。后面每多收一趟画面就清楚一点。两边存的是同一批系数,只有先后顺序不同。上一节的像素为什么一样在这里就说得通了。至于体积为什么会差一点,我的理解是换了顺序以后同类的系数挨在一起更好压。这是我从讲解里看来的,编码细节没有自己去验。
二、怎么看出一张JPG是哪一种
2.1 文件头里的SOF标记说了算
两种文件打开以后长得一样,光看文件名和图片查看器是分不出来的。能分清的只有文件头。一段一段拼起来的 JPEG 文件每段开头都有两个字节的标记。其中叫 SOF 的那一段意思是「帧开始」,里面记着宽高和编码方式。标记是 FF C0 的是基线式。FF C2 的是渐进式。我在规范里查到这个标记以后写了个小脚本顺着段往下读。扫描段就是上一章说的那个「一趟」,我顺手把它的个数也数了。
# 看一张 JPG 是基线式还是渐进式:顺着段标记往下走,记下 SOF 类型和扫描段个数importsysdefkan_jpg(lujing):shuju=open(lujing,'rb').read()leixing,saomiao,i='没找到',0,2whilei+4<=len(shuju):biaoji=shuju[i+1]ifbiaoji==0xD9:breakchangdu=shuju[i+2]<<8|shuju[i+3]ifbiaojiin(0xC0,0xC1):leixing='基线式'elifbiaoji==0xC2:leixing='渐进式'i+=2+changduifbiaoji==0xDA:saomiao+=1# 扫描数据里的 FF 后面只会跟 00 或复位标记,碰到别的才是下一个段whilenot(shuju[i]==0xFFandshuju[i+1]!=0andnot0xD0<=shuju[i+1]<=0xD7):i+=1returnleixing,saomiao,len(shuju)forfinsys.argv[1:]:lx,sm,zj=kan_jpg(f)print(f'{f.split("/")[-1]:28s}{lx}扫描段{sm:2d}{zj:,}字节')代码说明开头两个字节 FF D8 是固定的文件起始标记,脚本从第 3 个字节开始读。每读到一段先看它是不是 SOF 再按段头里记的长度跳到下一段。标记是 FF DA 的扫描段比较特殊。段头后面紧跟着的压缩数据有多长段头里没写,只能一个字节一个字节往后找。压缩数据里碰巧出现的 FF 后面会被编码器补上一个 00,这种不算新段。FF D0 到 FF D7 这几个复位标记也不算新段。碰到别的 FF 开头的两个字节才是下一段真正的开始。日常很少见的扩展顺序编码 FF C1 我也算进了基线式这边。我拿这次存的八份文件和那张 Wikimedia 原图跑了一遍,输出是这样的。
landscape_q75_base.jpg 基线式 扫描段 1 844,255 字节 landscape_q75_prog.jpg 渐进式 扫描段 10 811,350 字节 landscape_q85_base.jpg 基线式 扫描段 1 1,098,272 字节 landscape_q85_prog.jpg 渐进式 扫描段 10 1,048,255 字节 template_q75_base.jpg 基线式 扫描段 1 532,106 字节 template_q75_prog.jpg 渐进式 扫描段 10 502,023 字节 template_q85_base.jpg 基线式 扫描段 1 754,005 字节 template_q85_prog.jpg 渐进式 扫描段 10 706,842 字节 landscape.jpg 基线式 扫描段 1 12,063,992 字节2.2 渐进式文件被拆成了10段
输出里最显眼的是扫描段那一列。基线式全是 1 段,所有数据一趟存完。渐进式全是 10 段也就是分了 10 趟。后面我用 cjpeg 和 jpegtran 生成的渐进式也都是 10 段,看来 libjpeg-turbo 默认就是这么排的。我猜是亮度和色度分开补再从粗到细分成几档,具体每一趟装的是什么我没去细看。最后一行那张 12,063,992 字节的 Wikimedia 原图也是一趟存完的基线式。我从 Wikimedia Commons 下的三张照片里,风景原图和小狗图是基线式,另一张是渐进式。我原以为放大图的站点会更爱用渐进式。三张里只占了一张。这个脚本后面还会反复用到,我判断某个工具到底导出了哪一种靠的就是它。比起工具界面上写的选项我更信文件头。
三、换成渐进式能省多少体积
3.1 同样质量下渐进式小了一截
先把口径说清楚,两张样本各存质量 75 和 85 两档。同一档里基线式和渐进式只差 progressive 这一个开关,其他参数都一样也都开了 optimize。下面是四组结果。
| 样本 | 质量 | 基线式字节 | 渐进式字节 | 渐进式小了 |
|---|---|---|---|---|
| 风景照 | 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%–6.3%。放假通知模板比风景照省得多一点。两个质量档都是这样。同一张图上质量 85 也比 75 多省一点。这个量说大不大,一张一兆出头的大图能省下四五十 KB。页面上只有一两张大图的话对加载的影响就很有限。要是一个页面挂着几十张大图,攒起来就不少了。画质既然完全一样,这四五个百分点就等于白捡。只是捡得并不多,没有网上说的那么夸张。
这张图左右两半要分开看,左半边是以 KB 为单位的文件体积,右半边是以毫秒为单位的解码耗时。灰色柱子是基线式。蓝色柱子是渐进式。每边从左到右依次是风景照质量 75、风景照质量 85、模板质量 75 和模板质量 85。左边每组的蓝柱只比灰柱矮一点点,右边的蓝柱却比灰柱高出一倍多。体积省得少和解码慢得多这两件事在图上一眼就能看出来。不过右边纵轴最高才 28.3 毫秒,这个「慢」要不要紧得放到第四章跟一帧的时间一起看。
3.2 基线式开了优化也能省下一部分
这里我绕了个弯。最开始我用 cjpeg 的默认参数存了一份基线式,再加 -progressive 存了一份渐进式。一比小了 6.0%,比上面表里风景照质量 85 的 4.6% 多出一截。我还以为自己测出了什么新东西,后来才想起 cjpeg 默认是不开 optimize 的。optimize 做的事是按这张图自己的数据重新算一套哈夫曼编码表。不开的时候用的是规范里给的那套通用表。我查了一下,libjpeg 在渐进式模式下不管你开没开 optimize 都会自己把这张表算好。数字也对得上。cjpeg 只加 -progressive 不加 -optimize 出来的文件跟 Pillow 开了 optimize 的渐进式一个字节都不差。拿一个没开优化的基线式去跟渐进式比,省下来的量里就混进了编码表的功劳。拆开看的话风景照质量 85 用 cjpeg 默认参数存是 1,115,122 字节。只加 -optimize 是 1,098,272 字节,小了 1.5%。再换成渐进式是 1,048,255 字节。那 6.0% 里有 1.5% 是基线式自己开个优化就能拿到的,剩下的才是换排法挣来的。第六章还会看到有的浏览器内核导出基线式时就没开这个优化,它的文件换成渐进式能省得更多。
3.3 缩略图太小反而会变大
网上还有一种说法是小图存成渐进式反而更大。我想知道这个「小」到底要多小,于是把两张样本缩成 64 到 1280 像素宽的七档。每档都用质量 85 并开着 optimize 各存一份基线式和渐进式。
# 同一张图缩成不同宽度,各存一份基线式和渐进式(都开 optimize、质量 85),看体积差随尺寸怎么变importsys,iofromPILimportImageasTudefcun(im,jianjin):huancun=io.BytesIO()im.save(huancun,'JPEG',quality=85,optimize=True,progressive=jianjin)returnhuancun.tell()foryuaninsys.argv[1:]:da=Tu.open(yuan).convert('RGB')forkuanin(64,96,120,160,320,640,1280):xiao=da.resize((kuan,round(da.height*kuan/da.width)),Tu.LANCZOS)ji,jian=cun(xiao,False),cun(xiao,True)print(f'{yuan.split("/")[-1]:18s}{xiao.width}×{xiao.height:<5d}基线{ji:>8,}渐进{jian:>8,}差{(jian-ji)/ji:+.1%}')代码说明cun 这个函数把图存进内存里的一个缓冲区 huancun。存完看缓冲区写到了第几个字节就是文件大小。不用真的落盘。缩图用的是 LANCZOS 并按原图的比例算高度。输入用还没压成 JPG 的那两份参考 PNG,免得先压一遍再缩多带进一层损失。最后一列是渐进式比基线式多了还是少了。正数就是变大。
landscape_ref.png 64×43 基线 1,506 渐进 1,678 差 +11.4% landscape_ref.png 96×64 基线 2,594 渐进 2,721 差 +4.9% landscape_ref.png 120×80 基线 3,876 渐进 3,916 差 +1.0% landscape_ref.png 160×107 基线 6,975 渐进 6,824 差 -2.2% landscape_ref.png 320×213 基线 25,815 渐进 24,525 差 -5.0% landscape_ref.png 640×427 基线 95,723 渐进 90,194 差 -5.8% landscape_ref.png 1280×853 基线 321,678 渐进 304,951 差 -5.2% template_ref.png 64×139 基线 4,354 渐进 4,422 差 +1.6% template_ref.png 96×208 基线 8,392 渐进 8,243 差 -1.8% template_ref.png 120×260 基线 12,228 渐进 11,870 差 -2.9% template_ref.png 160×346 基线 19,942 渐进 19,150 差 -4.0% template_ref.png 320×693 基线 62,509 渐进 59,578 差 -4.7% template_ref.png 640×1385 基线 202,015 渐进 190,903 差 -5.5% template_ref.png 1280×2770 基线 746,301 渐进 705,401 差 -5.5%这个说法是真的,风景照缩到 160 宽时渐进式还小 2.2%,到 120 宽就反过来大了 1.0%。96 宽大了 4.9%,64 宽大了 11.4%。我又往下补了两档,48 宽大了 25.0%,32 宽大了 36.8%。竖长的模板图在同样的宽度下像素多得多,到 64 宽才开始变大,96 宽还小 1.8%。看宽度不太准,看文件大小更靠谱。两张图翻过来的地方都落在四千多到七千字节之间,再小的文件存成渐进式就不划算了。我猜是渐进式每一趟都要带上自己的段头和编码表,10 趟下来这些固定开销就有好几百字节。这些开销在大文件里摊薄了看不出来,一两 KB 的小图就被它压着了。这个解释只是我按段结构推出来的。
四、渐进式解码会慢多少
4.1 大图解码慢了约2.5倍
体积说完说解码。我在 Chromium 149 里用 createImageBitmap 把八份文件各从头解码 7 次取中间那个数。
| 样本 | 质量 | 基线式解码 ms | 渐进式解码 ms |
|---|---|---|---|
| 风景照 | 75 | 9.7 | 24.7 |
| 风景照 | 85 | 11.1 | 28.3 |
| 放假通知模板 | 75 | 6.4 | 15.6 |
| 放假通知模板 | 85 | 7.5 | 19.2 |
渐进式的解码耗时是基线式的约 2.3–2.6 倍,笼统说就是解码慢了约 2.5 倍。这个倍数比体积那边的差距大得多,也是反对派最常拿出来说的一条。可绝对值得一起看。最慢的风景照质量 85 那组是 28.3 毫秒,比基线式多了十来毫秒。屏幕按 60 帧刷新的话一帧大约是 16.7 毫秒。一张 2736×1824 的大图多花的时间差不多就是一帧,单独一张图眼睛是分不出来的。慢在哪里我也有个猜测,基线式一块解完就可以扔掉。渐进式得先把整张图的系数都攒着,每来一趟就把所有方块再过一遍最后才输出像素。多出来的时间大概主要花在这几趟上。这是我按原理推的。解码器的代码我没去翻。还有一点要说在前头。手机的芯片要比这台 M4 的 Mac 慢不少,同样的图在手机上多花的时间会更长。我没测所以不好说长多少。
4.2 40张缩略图多花的时间不到一帧
单张大图不要紧,那一屏几十张缩略图呢。我把风景照缩成 320×213 并用质量 85 存了基线式和渐进式各一份。然后在 Chromium 149 里连着解码 40 张算总耗时并取 7 轮的中位数。这一项我跑了两次。基线式两次是 8.7 和 8.3 毫秒,渐进式是 19.6 和 16.8 毫秒。40 张加起来多出八九到十一毫秒。还是不到一帧。换来的体积是每张小 1,290 字节,40 张一共五十来 KB。两次结果之间差了将近 3 毫秒,这种毫秒级的数本来就晃得厉害。看个量级就行。真要算细账的话这点解码时间在电脑上基本可以忽略,问题在于缩略图本来就小、下得也快。渐进式那个先糊后清的好处在它们身上几乎用不上,多花的解码时间只换来体积上那一点点。
五、网速慢时渐进式能先显示什么
5.1 只收到一成数据就能看到整张图
这一章讲的是渐进式真正的卖点,也是我最想亲眼看到的。一开始我想在浏览器里直接拍下来。我在本地起了个服务把图片限速成每 100 毫秒吐 20 KB 再隔一段时间截一次屏。结果截到的画面要么是白的,要么整张图已经下完。中间那段「半张图」一次都没拍到。原因我没找到也不想为这个花一整天。于是改成拿解码器模拟,文件只留前 10%、25%、50%、75% 的字节。解码器按「数据不完整也尽量画」的方式解出来以后再跟原图比有多接近。比的是 PSNR,一个衡量跟原图差多少的数。它的单位是 dB,越大越接近原图。
# 模拟「只下载到前面一部分」:把 JPG 截到前 N% 字节,让 Pillow 能画多少画多少,再跟完整文件的参考图比 PSNRimportsys,ioimportnumpyfromPILimportImageFile,Image ImageFile.LOAD_TRUNCATED_IMAGES=True# 数据不完整也不报错,缺的部分照样出图defban_jie(lujing,baifenbi):quan=open(lujing,'rb').read()duan=quan[:len(quan)*baifenbi//100]returnnumpy.asarray(Image.open(io.BytesIO(duan)).convert('RGB'),dtype=numpy.float64)deffenshu(jie,yuan):wucha=((jie-yuan)**2).mean()return20*numpy.log10(255/numpy.sqrt(wucha))cankao=numpy.asarray(Image.open(sys.argv[1]).convert('RGB'),dtype=numpy.float64)forfinsys.argv[2:]:hang=[f'{fenshu(ban_jie(f,p),cankao):6.2f}'forpin(10,25,50,75,100)]print(f.split('/')[-1].ljust(24),' '.join(hang))代码说明关键是 LOAD_TRUNCATED_IMAGES 这一行。Pillow 默认碰到不完整的 JPG 会直接报错。打开它以后能解多少解多少,没解出来的地方都填成灰色。基线式缺的是后面那几截,也就是下面一片灰。渐进式缺的是后面几趟细节,整张图都在只是糊。参考图用的是压成 JPG 之前的那份 PNG,就算收到了 100% 的字节分数也不会是无穷大。它反映的是质量 85 本身的损失。fenshu 算的就是 PSNR,用的是均方误差换算成 dB 的那个公式。第一个参数传参考图,后面跟要比的 JPG。输出每行的五个数依次对应收到 10%、25%、50%、75% 和 100% 的字节。风景照和模板各跑了一次。
landscape_q85_base.jpg 13.58 14.65 15.55 18.16 36.81 landscape_q85_prog.jpg 18.90 22.73 30.97 34.07 36.81 template_q85_base.jpg 8.16 8.55 9.50 13.03 32.33 template_q85_prog.jpg 19.94 24.92 29.19 31.06 32.33先看风景照。收到 10% 的字节时基线式只画出最上面一条,其余全是灰块,PSNR 13.58。渐进式这时整张图都在只是偏软,PSNR 18.90。只收到一成数据就能看到整张图。这就是「先糊后清」的意思。这张风景照拍的是雾天的雪地,远处一排光秃秃的树前面是一大片芦苇和积雪。我把两张截断解码的结果并排看了看,渐进式那张的树、芦苇和积雪都在,颜色也对。只是芦苇的细秆糊成了一团,像一张没对上焦的照片。基线式那张上面一条雾蒙蒙的天和树梢倒是清清楚楚,可芦苇和雪地全是灰块,根本看不出这是一张什么图。
5.2 收到一半时已经接近原图
再往后看差距越拉越大,收到 25% 时渐进式是 22.73 对基线式的 14.65。收到 50% 时渐进式已经到了 30.97 dB,接近看不出差别。基线式这时还只画出上面一部分,只有 15.55。收到 75% 时渐进式是 34.07,基线式是 18.16。两边都要收完 100% 才汇合到同一个 36.81,这跟第一章验的像素完全一样也对得上。有一点要提醒一下。基线式的分数涨得这么慢并不代表它画出来的那部分糊,它画出来的每一块都已经是最终的样子。是底下那一大片灰把平均分拉了下来。PSNR 算的是整张图的平均误差,大片缺失在它眼里比整体变软要严重得多。用这个数比两种文件天然就对渐进式有利一点,可换成人眼去看,一张完整但偏软的图能提前告诉你这是什么。只有上半截的图做不到这一点,我觉得这个判断方向没错。
5.3 放假通知模板上差距更大
放假通知模板上的差距比风景照还要大,收到 10% 时模板的基线式只有 8.16,渐进式是 19.94。收到 25% 时基线式 8.55 对渐进式 24.92,已经差了十几个 dB。收到 50% 时渐进式到了 29.19,基线式才 9.50。我猜是因为模板整张都是大红底。基线式没画出来的地方填成灰,灰和红差得太远,平均误差就被拉得更狠。风景照本来就是一大片灰白的雾天雪景,填成灰色吃的亏小一点。这也说明 PSNR 的具体数值跟图的颜色有关。不同的图之间横着比没什么意义,同一张图上两种排法竖着比才有意义。
这张图是放假通知模板只收到 25% 字节时的样子,三栏从左往右看。左边的基线式只画出了最上面一小截,「放假通知」那行标题都没画全,下面全是灰。中间是同样只收到 25% 的渐进式,整张图已经出来了。标题、日历和底部的祝福语都认得出。细看比右边软一点。右边是拿来跟中间那栏对照的完整文件。放在网页上的话读者等到中间这个状态就知道这是一张放假通知了。要提醒的是这三栏是解码器模拟的结果。浏览器里实际显示成什么样还要看它多久重画一次、数据怎么分批到达,这个我没拍到。
六、哪些工具能导出渐进式JPEG
6.1 三个浏览器内核只导出基线式
搞清楚渐进式的好处以后,下一个问题就是怎么拿到它。我们组的内部小工具有不少是在网页里用 canvas 导出图片的,我就先看浏览器。我在三个内核里把风景照画到 canvas 上再用 toBlob 各导出质量 0.5、0.85 和 1 三档。再拿第二章的脚本读文件头,九个文件全是一趟存完的基线式。canvas 的 toBlob 只收格式和质量两个参数,根本没有地方告诉它要渐进式。下面是质量 0.85 那一档的三个文件。
| 内核 | 类型 | 扫描段 | 字节 |
|---|---|---|---|
| Chromium 149 | 基线式 | 1 | 1,098,746 |
| Firefox 151 | 基线式 | 1 | 1,115,122 |
| WebKit 26.5 | 基线式 | 1 | 1,916,407 |
这三个数我对着前面的结果看了半天,看出几件挺有意思的事。Chromium 导出的文件解码出来的像素跟 Pillow 质量 85 的基线式一个不差。大小只多了 474 字节,多出来的是文件头里的一段 ICC 色彩配置。Firefox 导出的文件跟 cjpeg 默认参数存的那份一个字节都不差。也就是说它没开 3.2 节说的那个编码表优化。WebKit 导出的文件跟 macOS 自带的 sips 用质量 85 存的那份也是一个字节都不差。我猜它俩用的是系统里同一套编码。同样填 0.85 的时候 WebKit 的文件比 Chromium 大了七成多。它的质量刻度跟另外两个显然不是一回事。这个我没往下查。图映的 JPG 我也看了。前两天压那张红底海报时我用的是图映 ImgIng(https://imging.cn/),在 Chromium 149 开源构建里用全默认参数导出过两份 JPG。这回我把这两份翻出来读了文件头,都是基线式。图映转 JPG 走的是浏览器自带的编码,浏览器只给基线式,它导出来的自然也是基线式。想在浏览器里一步到位拿到渐进式,至少在我测的这几样东西上走不通。
6.2 命令行工具加一个参数就能导出
浏览器导不出来就得交给专门的编码工具,我手边能直接用的有两样。一样是 libjpeg-turbo 自带的 cjpeg,加一个 -progressive 就是渐进式。另一样是 Python 的 Pillow,保存时传 progressive=True 就行。两边导出来的风景照质量 85 都是 1,048,255 字节的 10 段渐进式,一个字节都不差。这不奇怪。Pillow 底下用的就是 libjpeg-turbo,同一套代码出同一个文件。sips 默认存出来的是基线式,它有没有渐进式的开关我没去翻。要在服务端批量处理的话这两样随便挑一个都行。写脚本的用 Pillow。图省事的用 cjpeg。只是它们都要从像素重新编一遍,已经压好的 JPG 再压一次会多一轮损失。这就引出了我这次最想安利的一个工具。
6.3 jpegtran转换不重压也不丢像素
jpegtran 也是 libjpeg-turbo 自带的。它不解码成像素就直接在压缩后的系数上动手。加 -progressive 就是把系数换个顺序重新排成渐进式。我先拿 Pillow 存的基线式转了一份。出来的文件是 1,048,255 字节,跟直接编码的渐进式连 md5 都一样。反过来把渐进式转回基线式,也跟原来那份基线式的 md5 一样。两种排法之间可以来回倒而且一点都不走样。这跟第一章讲的原理完全对得上。接着我把三个内核导出的 0.85 文件各转了一遍,解码出来的像素跟转之前比最大差都是 0。Chromium 那份小了 4.6%。Firefox 那份小了 6.0%,因为它原本没开的编码表优化在转的时候顺带补上了。WebKit 那份小了 10.9%,是三个里省得最多的。这里我踩了个小坑,一开始我照着网上的例子写了表示不复制任何附加信息的 -copy none。转完才发现 Chromium 塞在文件头里的那段 ICC 色彩配置也被一起扔了。照片里的 EXIF 同样会被扔掉,要保留就得换成 -copy icc 或者 -copy all。换成 -copy icc 以后 Chromium 那份是 1,048,729 字节,照样小 4.6%。Wikimedia 那张一千二百多万字节的原图我也用 -copy all 转了。它从 12,063,992 字节变成 11,436,051 字节,小了 5.2%。
七、什么情况值得换成渐进式
7.1 首屏大图值得换
把前面的账合起来,我的判断是首屏大图值得换。几百 KB 以上的大图换成渐进式能省 4%–6% 的体积。原图要是 Firefox 或 WebKit 内核这类没开优化的编码器出的,省得还更多。解码多花的时间在电脑上差不多是一帧。用户感觉不到。最大的好处在网慢的时候,读者只收到一成数据就能先看到整张图的样子,不用盯着一片空白等它从上往下刷。活动页头图、文章题图和商品详情页的大图都属于这一类。这类图数量不多,一个页面也就一两张,转起来花不了什么工夫。我后来就是这么跟同事说的。首屏那几张大图勾上,别的先别管。
7.2 小缩略图不用换
小缩略图我不换。一是省不下什么,第三章测到四千多到七千字节以下的小图存成渐进式反而会变大。二是换了也没好处,缩略图本来就只有几 KB 到几十 KB,网再慢也是一眨眼就下完了。先糊后清那个过程根本来不及发生。一屏几十张缩略图全换成渐进式,电脑上多花的解码时间不到一帧。体积也就省几十 KB,折腾一遍的意义不大。要说例外就是那种三百来像素宽、二三十 KB 的中等缩略图。它们换了也能小 5% 左右,量特别大、流量特别贵的话可以考虑。我自己的活里没有这种量就没动它们。
7.3 已有的图可以批量无损转换
手上已经压好的一堆基线式 JPG 用 jpegtran 批量转最省事。我写了个小脚本,一个文件夹进去转完逐像素核对,没变小的就留原文件。
#!/bin/zsh# 把一个文件夹里的基线式 JPG 无损改排成渐进式:不重新压,改完逐像素核对,变大的就留原文件mkdir-pjianjinfortuinyuan/*.jpg;doxin=jianjin/${tu:t}jpegtran-copyall-progressive-outfile$xin$tuif!cmp-s<(djpeg-pnm$tu)<(djpeg-pnm$xin);thenecho"$tu像素对不上,跳过";rm$xin;continue;fiqian=$(stat-f%z $tu);hou=$(stat-f%z $xin)((hou>=qian))&&{cp$tu$xin;echo"${tu:t}转完没变小,留原文件";continue;}printf'%-22s %9d -> %9d %+.1f%%\n'${tu:t}$qian$hou$(((hou-qian)*100.0/qian))done代码说明原图放在 yuan 文件夹里不动,转好的放进 jianjin 文件夹。-copy all 会把 EXIF 和色彩配置都带上,这是上一章踩坑以后改的。核对那一步用 djpeg 把转换前后的两个文件都解成原始像素再用 cmp 逐字节比。只要有一个字节不一样就跳过,不留转坏的文件。stat -f%z 是 macOS 的写法,Linux 上要换成 stat -c%s。变大的文件直接把原图拷过去,这样 jianjin 文件夹里始终是一套齐全的图,拿去替换时不会少文件。我往 yuan 里放了五张图。两张是 Chromium 和 WebKit 导出的风景照,一张是前面那份图映导出的红底海报。另外两张是 320 宽和 64 宽的缩略图,跑出来是这样的。
fengjing_chromium.jpg 1098746 -> 1048729 -4.6% fengjing_webkit.jpg 1916407 -> 1707550 -10.9% haibao_tuying.jpg 147563 -> 133870 -9.3% suolue_320.jpg 25815 -> 24525 -5.0% suolue_64.jpg 转完没变小,留原文件五张里四张变小,64 宽那张缩略图转完没变小就按规则留了原文件。那张红底海报小了 9.3%,比两张照片省得都多。我猜是它大块都是纯色,基线式的通用编码表对它特别不合适,具体原因我没深究。脚本跑一遍就几秒钟也不改画质,出了问题删掉 jianjin 文件夹就当没发生过。我现在把这个脚本放在做活动页素材的文件夹旁边,首屏那几张图交出去之前都过一遍。
八、适用边界与风险
先说样本。我只测了一张风景照和一张放假通知模板外加一张图映导出的红底海报。体积差的百分比换一张图就会变。不能当成所有图都这样。解码耗时只在一台 M4 的 Mac 上的 Chromium 149 里量过,手机和别的内核都没量。Firefox 151 和 WebKit 26.5 我只测了它们导出的是哪一种。它们解码渐进式要多久我没测。三个浏览器都是开源构建,结论不能直接套到正式版的 Chrome、Firefox 和 Safari 上。再说加载过程。第五章全是解码器模拟,浏览器实际怎么显示我没拍到。有的浏览器可能要攒够一定的数据才画第一版,那样看到糊图的时间会比模拟的晚。还有三处风险要留意。一是 jpegtran 用 -copy none 会把 EXIF 和色彩配置一起扔掉。带 ICC 的图颜色可能会变,我现在都用 -copy all。二是图片上线以后要是还会被别的服务重新压缩一次,排法可能又被改回基线式。这个我没办法测,最稳的办法是上线后把线上那个文件下载下来用第二章的脚本读一下文件头。三是渐进式解码要把整张图的系数先攒在内存里。特别大的图会不会吃更多内存我没量,做批量处理的服务要自己盯一下。
九、测这两种JPEG容易踩哪些坑
9.1 截断解码不等于浏览器实拍
这是我这次花时间最多的一个坑。本来想在浏览器里限速截屏,把两种文件加载到一半的样子拍下来。结果截到的画面要么是白的要么已经是整张图,中间态一次都没有。我的猜测是截图那一刻浏览器还没把半截的图画上屏。也可能它等数据够多了才一次性画出来。具体是哪种我没查清楚。后来改成截断文件再解码,这个结果稳定也能复现。可它说的是「解码器拿到这么多数据能画出什么」,浏览器什么时候把这个画面真正显示给读者是另一回事。PSNR 本身也有偏向。它对大片缺失特别敏感,对整体偏软不那么敏感,天然偏向渐进式。拿它比同一张图的两种排法可以,拿它去说渐进式快了多少秒就过头了。
9.2 比体积前先把优化选项对齐
这个坑 3.2 节已经讲过,这里再说一次是因为它会直接把结论带偏。拿没开 optimize 的基线式去跟渐进式比会高估省下来的量。我第一次比出来是 6.0%,对齐以后是 4.6%。我见过说渐进式能省一成多的,不知道是不是也这么比出来的。WebKit 导出的那份转完小了 10.9%,里面也有 4.9% 是只开优化就能拿到的。我用 jpegtran -optimize 单独转了一遍才把它拆出来。比之前先用同一个编码器、同一个质量并且同样开着 optimize。只动 progressive 这一个开关比出来的数才是排法本身的账。
9.3 解码耗时要多跑几轮
毫秒级的数晃得很厉害,40 张缩略图那组在同一台机器上用同一个脚本跑了两次。渐进式一次 19.6 毫秒一次 16.8 毫秒,差了将近 3 毫秒。单张大图每次重跑也都会有零点几到一两毫秒的出入。我的做法是每组解码 7 次取中位数再把整组跑两遍看量级稳不稳。只跑一次就下「慢了几倍」的结论不太靠谱。还有一点要注意,createImageBitmap 量的是浏览器把一个文件完整解成图片的时间。它既不包括下载也不包括画到屏幕上。拿它比两种文件谁解码快是合适的,拿它当成页面上看到图片要多久就不对了。
十、还有哪些没弄清楚
这次留了好几个没弄明白的地方,最想知道的是浏览器里渐进式到底怎么显示。截屏那条路我没走通,下次想换成录屏再试一次。WebKit 导出的 JPG 同样填 0.85 却比另外两个大了七成多。我只看出它跟 sips 出的是同一个文件,它的质量刻度怎么换算我没查。libjpeg-turbo 默认那 10 个扫描段是怎么分的我也只猜了个大概。手机上渐进式要多花多少解码时间这篇完全没测。我只能说电脑上不要紧。两张样本的小图都在四千多到七千字节之间开始变大,换一张内容复杂或简单得多的图会不会挪位置也还不知道。手上有一批首屏大图的话可以先拿第二章的脚本扫一遍文件头看看里面有多少是基线式。再用第七章那个脚本转一份出来对比一下体积,然后决定换不换。
参考资料
- ITU-T T.81(JPEG 标准)中关于顺序编码与渐进编码、SOF0 / SOF2 标记的说明
- libjpeg-turbo 文档中 cjpeg、djpeg、jpegtran 的用法说明(-progressive、-optimize、-copy 参数)
- Pillow 文档中 JPEG 保存参数 quality、optimize、progressive 的说明
- HTML 标准中 canvas 的 toBlob 方法说明
- 样本来源:Wikimedia Commons 上的一张风景照(缩放后使用);稿定设计的「2026年国庆节放假通知」模板图