为什么很小的 JPEG 在 Chrome 里看起来不一样
文章摘要
作者(HN 上的 gutechh)在 2026 年 8 月 3 日写下这篇短文,起因是一次很日常的观察:他和同事一起看同事电脑上的界面时,注意到某个 logo 和自己机器上长得不一样——同事那边更细、更接近原图,自己这边更粗。那个 logo 渲染尺寸是 15px。差别就在浏览器:一边是 Firefox,一边是 Chrome;把图换成 SVG 就解决了,但他还是好奇为什么一开始会这样渲染。追下去发现的是 Chrome 在小尺寸渲染 JPEG 时用的一个「巧妙的解码优化」。
为什么缩小图片可能很浪费。 直觉做法是把 JPEG 完整解压到内存里再缩小。但这并不总是高效:想象一张 2000 × 2000 的 JPEG 要显示成 20 × 20,完整位图大约占 12 MB,而最终的 20 × 20 图只需要大约 1.2 KB——大图里绝大部分信息在缩小过程中都被丢掉了。
丢掉的是什么信息。 关键洞察是:丢掉的信息不是随机的。图片被大幅缩小时,消失的主要是高频细节。作者用一棵树举例:满是叶子的树冠和粗糙的树皮,这些细节在像素之间快速变化,属于高频信息;把这棵树缩到 20 × 10 这么小,最后只剩上面一团绿(树冠)和下面一根褐色的棍子(树干),精细的细节、也就是高频信息,都被丢掉了。当然有一部分高频信息还是会以「混在一起」的形式残留。
JPEG 是怎么存图像数据的。 压缩过程中,图像被切成 8 × 8 的块并转换到频域,这个运算叫 DCT(离散余弦变换)。在一个 8 × 8 块里,最低的「频率」是纯色——严格说它不算频率,因为什么都没变,它是常量分量(constant component);另一端最高的频率长得像棋盘格,值变化到最大程度;中间的一切代表频域的其余部分,这些叫基函数(basis functions)。所以把一个 8 × 8 块转换到频域,本质上是在问:这个块里每种图案各有多少?这些「多少」就是系数(coefficients)。JPEG 后面还有几步用来高效存储这些系数,有损压缩发生在那里,但对本文讨论不重要。
以 1/8 比例渲染 JPEG。 假设要把图缩小 8 倍。那些 8 × 8 的块现在正好可以由缩小图里的单个像素来表示。在这个尺寸下,图像主要只需要低频信息,因为就像树的例子一样高频细节基本都会在缩放中消失。于是不必解压整张 JPEG,可以直接跳过高频部分的系数,只用生成粗略版本所需的那些——这样就得到缩小结果而无需先把原图完整展开。解码出的图更小、解压也更快,因为跳过了相当一部分系数。这个技巧可以推广到其他比例,只要是分母为 8 的分数。它的技术名称是 partial IDCT scaling(部分逆离散余弦变换缩放,IDCT 即把频域变回图像域);作者指向 jpegclub.org,并提到这个技巧同样可以用来放大图像。
Chrome 在其中的位置。 Chrome 把图像解码和渲染委托给 Skia,而 Skia 对 JPEG 使用 libjpeg-turbo,后者实现了 partial IDCT scaling,因此当目标尺寸足够小时它可以只解码较低频的数据。换句话说,Chrome/Skia 并不总是先完整解压再缩放:它会算出最接近的、分母为 8 的分数,按那个比例解码,然后再用更传统的降采样算法继续缩到目标尺寸。这就是那个图在他机器上显得更粗的原因——因为渲染得太小,它被以 1/8 比例用 partial IDCT scaling 解码,于是频域表示里只剩下常量分量,所有边缘柔化和渐变都没被用上。
文章在 2026 年 8 月 12 日加了一条更正:有人在 Hacker News 上指出所用的缩放算法本身也起了很大作用,所以最终的画质退化实际上是 IDCT 与缩放算法共同造成的混合结果。
结论一句话:不要用 JPEG 来做图标之类的东西。这个格式及其优化都是围绕我们对照片的感知设计的——毕竟名字里就写着,Joint Photographic Experts Group。
HN 评论精华
这条帖子 339 分、67 条评论。讨论主线有三条:(一)「那 Firefox 是怎么做的?」——文章只讲了一半,评论区补齐了另一半,并且这条追问直接促成了文章末尾的更正;(二)缩放算法本身(模糊 vs 振铃)到底贡献了多少,以及客观画质指标与主观感受的分歧;(三)从「别用 JPEG 做图标」延伸出的一大段 SVG 实战经验,包括一段意外跑题到 XML 命名空间的吐槽。作者 gutechh 全程在场并很诚实地接受修正。
- PetitPrince 第一个提出关键质疑:那 Firefox 是完整渲染再缩放,还是也做部分渲染只是方式不同?「你只讲了故事的一面。」the8472 给出答案:Firefox 也不会把图解码成全分辨率缓冲再缩小,它做的是流式解码并在解码过程中降采样,官方叫 downscale-during-decode(附了 2014 年的 Bugzilla 单号 1045926),不过他猜 Firefox 不做 8 × 8 块的部分解码。muizelaar 补充说 Firefox 里「以更低比例解压」的工作正在另一个单子里进行(Bugzilla 2033250),gutechh 回应说他本来就知道这样效率高得多,但看到基准测试里的具体数字后更欣赏这个「技巧」了。gutechh 也说找到 Firefox 那一侧的答案「费了不少功夫」,也许会再写一篇讲 Firefox 的流水线。
- debazel 提出了最重要的技术修正:Chrome 和 Firefox 用的是两种不同的缩放算法,这大概比部分解码贡献大得多——Chrome 整体更模糊,Firefox 更锐但振铃伪影稍多,「我个人更喜欢 Firefox 的版本」。gutechh 说他也偏爱 Firefox 的样子,并说自己不是图像渲染专家,之所以得出文章的结论是因为在 DCT 可视化工具里检查那些图的边缘和曲线时,它们全都落在 AC 系数里。ack_complete 提了另一个可能的因素:会不会和伽马校正有关?JPEG 直接以非线性色彩编码(full-range YCbCr)存储,所以部分解码可能等于在没有伽马校正的情况下缩放。debazel 进一步说他不认为存在单一根因,部分解码和缩放算法都会影响最终结果,是一个混合;他判断在这个具体例子里缩放算法贡献更大,因为它「几乎完全像一个经典的黑白对比:一个锐利、振铃重的算法对一个更模糊的算法」,并动手做了 Lanczos 与 Bilinear 的对比让作者看,还建议用彩色图来试,因为那时振铃伪影会明显得多。gutechh 道谢并说明白了他的意思,会在当天晚些时候给文章加注(这正是文中那条 08-12 更正的来源);他还补了一个观察:要造出一张能把问题显示得这么清楚的图并不容易,他怀疑摩尔纹起了作用,因为大多数能显出问题的图都带某种网格或重复图案——「我猜有些图更受 IDCT 影响,有些更受缩放算法影响,所以它可见性有强有弱。就像你说的,是个混合。」
- 由此衍生出一场关于画质指标的小讨论。aidenn0 说他注意到很多客观画质度量偏好模糊而非振铃,但很多主观测量恰好相反。ryandamm 因此主张用 LPIPS 而非 PSNR,因为 LPIPS 是用人的主观输入学出来的指标;不过 LPIPS 受训练集限制,所以他们仍然常规使用 PSNR——「一个容易理解、局限也被广泛理解的指标,实践中不算太差,宁用熟悉的魔鬼。」m-schuetz 给了反例:他和同事做压缩画质评估时发现 PSNR 反而比 LPIPS 或 FLIP 更贴近他们的感知,猜测 LPIPS 偏向的画质伪影类型并不代表压缩伪影。hatthew 补了一句现实:这取决于目标受众,很多人就是偏爱过度锐化的内容——「总有那么多人从不改动电视上过度锐化、过度饱和的默认设置,我一直觉得难以置信。」
- 关于这个优化值不值得,smallnix 说要是只在内存紧张时才做这种部分缩放就好了。gutechh 笑说考虑到 Chrome 有多吃内存,20MB 的收益其实不算什么;而且他试着复现这个瑕疵时发现相当难——在照片上基本看不见,图标也不是全都受影响,「所以它工作得很好。用 jpg 做图标算是我们自己的错。」adzm 纠正了动机:这不是内存问题而是速度问题,差别很剧烈,因为内存慢(相对 CPU 缓存而言)。kristianp 说他一直以为 Chrome 内存大是因为 JavaScript 虚拟机,但大概还有很多以内存换速度的优化(不像这一个)。
- adzm 还贴出了一个很有价值的坑:他们出于同样原因使用 IDCT 缩放时,遇到某个供应商的图片出问题——有些编码器(以及野生 JPEG 里相当一部分)会在最后一个 MCU 的未使用行/列里留下零或垃圾数据;全尺寸解码时这些采样会被裁掉,但 IDCT 缩放会把它们混进相邻的输出像素,伪影就可见了(他给了 crbug.com/890745 和 libjpeg-turbo 的 issue 297)。他们的做法是直接把边缘裁掉,认为这个取舍值得。
- SVG 实战这一支来自 jonathanlydall:他相当确定 PNG 也有同样的问题(PNG 无损、适合图标、还支持 alpha);当 Chrome 引入这个「优化」并进入他们正在升级的 Electron 版本时,产品里很多地方的图标被搞坏了,他们只能推迟升级直到用 SVG 替换完毕。SVG 还有个好处是能响应亮/暗模式,但他们必须把每个图标放进各自的 shadow DOM,以免不同 SVG 之间同名的样式互相冲突。gutechh 觉得奇怪,认为 PNG 存储方式和 JPEG 完全不同(用调色板),所以不会是 IDCT 缩放;binaryturtle 纠正说 PNG 两种都支持(8 位及以下用调色板,也支持直接的真彩 RGB/ARGB),当然仍与 JPEG 的方法不同。polpo 认为无法像 JPEG 那样部分解码 PNG,唯一的例外可能是 adam7 隔行扫描(它确实把首遍存成 8 × 8 块),但带 adam7 的 PNG 在实践中很少见因为文件更大。bawolff 给出更可能的解释:Chrome 只是用了比图形编辑器更快(或者干脆是不合适)的降采样算法,「没有普适正确的算法,不同算法适合不同图像类型」;线稿这类高对比图像在为照片优化的缩放算法下表现都很差,这也是浏览器引入
image-rendering: pixelated这个 CSS 的原因,不过他也同意 SVG 几乎总是最好的答案。jonathanlydall 说他隐约记得研究时见过这个属性,可能是解法之一、也可能担心有明显性能开销,而且他们转向 SVG 还有别的理由——在 125% 之类的缩放比例下 SVG 好看得多。 - 关于「同名样式冲突」,jancsika 追问「named the same」是什么意思,jonathanlydall 解释得很具体:SVG 内部可以定义样式和元素 id,如果同样的 id 和 class 在 DOM 里多处出现(包括在别的 SVG 里)就会互相影响;他们的产品是个带扩展性的平台,UI 元素的图标可以由他们或用户提供,从 Adobe Illustrator 导出时非常容易不小心用了相同的 class 名或元素 id,尤其当一个图标是从另一个复制来的;SVG 作为背景图使用时不成问题,但他们希望 SVG 能通过 CSS 换色以适配亮暗模式,而这在把 SVG 当背景时做不到,shadow DOM 完美地绕开了整个问题。jancsika 由此发出全帖最长的一段吐槽:好笑的是 XML 里塞满了各种丰富的命名空间方案,专门解决你不会遇到的问题(比如有人想自己搞个 href 命名空间,xlink 就在那儿等着帮你消歧义),「而在 2026 年,你有两个设计师各自用了自己的 bgcolor,XML 就说:不行!」他顺手查了 MDN 发现 xlink 已经废弃,这让他很想写一篇 2000 年代中期的架空历史,讲「href 战争」和前端设计师在同一份文档里混搭几十个 href 命名空间的 XML 命名空间派对。bawolff 认真回了一句:那不是命名空间想解决的问题(XML 命名空间关乎可扩展的语义,不是内容隔离);jancsika 承认,但仍觉得好笑的是 SVG 和内部超链接规范里像 OP 这样需要内容隔离的案例越来越多,而他想不出任何一个真的有人用 XML 命名空间去扩展 SVG 或超链接的例子——「连玩具示例都想不出来」,并求人给他链接「做同人文研究」。
- 一些零散但有意思的点:bluedione 说这让他想起当年研究 HTML Canvas 缩放,尤其是 HiDPI Mac 刚出来那会儿。RunSet 提到 Chrome 在 canvas 里画文字时会加进伪影而 Firefox 不会,导致点阵字体很难看,「我内心的偏执者说这像是浏览器版的打印机追踪黄点,但也可能只是开发者不用心」。LoganDark 和 StilesCrisis 都在问最朴素的问题:为什么会用 JPEG 存一个小小的数字图标?gutechh 给了背景:那是一个原型里的内部图标,当时正在打磨,「所以我理解那个人当时为什么这么做——为一个最后可能被扔掉的东西去追 svg 太费劲了」。aljgz 替 jonathanlydall 猜了另一种情形:同一个图标在列表/菜单里用得很小、被选中时用得很大,如果所有分辨率都用同一个文件,实际尺寸会因显示器大小、DPI、分辨率、缩放而差异很大,所以留一个大的更保险;jonathanlydall 确认「差不多就是这样」,并补了一句:SVG 在这方面好太多了,文件更小、几乎任何分辨率下都很好看。