白底 PNG 存成 JPG 以后,背景里有些像素读出来是 253、254。这事我不意外。我在音视频 SDK 公司做过三年编解码,JPEG 的 DCT 加量化和视频里的块变换是同一个路数。让我多看一眼的是分布:杂点没有撒满整张背景,只贴着主体轮廓长。
我拿三张维基共享资源上的实拍图抠成白底再用 Pillow 11.3.0 存成质量 85 的 JPG,想弄清这个分布怎么来的。结论放前面:杂点来自 8×8 块里的量化误差。一个块里只要同时有深色的边和白底,误差就铺满整个块。亮度上块外的像素不受这个块的误差影响。白底这一侧只有往下掉的那一半留得住,往上冒的那一半被截在 255。
三张图是毛绒泰迪熊(Dan Bollinger)、波希米亚玻璃杯(Kritzolina)和白球鞋(JustinLiang522)。都是网上找的第三方照片,许可 CC BY-SA 4.0。下载的是维基提供的缩放版,长边 1707 到 1920 像素。白底 PNG 是用图映 ImgIng(imging.cn/)在电脑浏览器里抠图、... JPG,要 JPG 得再走一遍压缩转换,那一步输出是 4:2:0。抠图不是我参与的模块,压缩转换的内部我也没看过,下面凡是讲到它们内部的地方都只是对着输出猜的。
杂点只贴着轮廓出现
先看分布。白球鞋存成质量 85、4:2:0 的 JPG 以后背景像素里有 0.66% 不是 255。离主体 8 像素以内的背景里 88.42% 还是 255,8 像素以外是 99.9838%。毛绒这两个数是 86.24% 和 99.9943%,玻璃杯是 78.13% 和 99.9828%。三张图的规律一样:贴着主体那一圈的背景里最容易有杂点,外面几乎是干净的。

这张图只用来对照红点和轮廓的距离:红点成串地贴在鞋边外侧往外几格就断了。右侧是蓝框放大 4 倍的样子,上面是 JPG 的实际像素下面是同一处标红。样本是 JustinLiang522 拍的白球鞋,许可 CC BY-SA 4.0。这张图是改编作品并沿用同一许可。
8 像素正好是一个块的宽度,这个对应太整齐了。我第一反应是巧合,就决定把一个块拆开自己算一遍。
纯色块为什么不会出事
JPEG 处理亮度通道,先切成 8×8 的块。每个块里的像素减 128 再做一次二维 DCT 得到 64 个系数。再逐个除以量化表里的步长取整。左上角那个系数是整块的平均亮度。往右下走是越来越细的纹理。默认量化表里越细的纹理步长越大。舍得也越狠。
整块纯白时纹理系数全是零,只剩平均亮度要存。这一项的步长在质量 85 是 5,在质量 75 是 8。折回像素上的误差不超过半个灰阶,四舍五入又回到 255。我在一张 16×8 的图上验过这一点。左边 8 列全黑而右边 8 列全白,质量 50、75、85、95、100 五档读回都是 0 个像素不一致。块里没有边就没有什么细节可丢。
一条边进块里会发生什么
我造了一个最小的块,8 列像素里左 3 列是 0 而右 5 列是 255,每一行都一样。我用 Pillow 存成灰度 JPEG 再读回。边是竖直的,所以 64 个系数里只有第一行 8 个不为零。质量 85 的量化表第一行是 5、3、3、5、7、12、15、18。我照上面的流程在 numpy 里手算了一遍,结果和 Pillow 读回的在质量 75、85、95 三档上逐像素一致。
python
import numpy as np
np.set_printoptions(suppress=True)
basis = np.array([[np.sqrt((1 if u == 0 else 2) / 8) * np.cos((2 * x + 1) * u * np.pi / 16)
for x in range(8)] for u in range(8)])
step = np.array([5, 3, 3, 5, 7, 12, 15, 18]) # 质量 85 的量化表第一行
row = np.where(np.arange(8) < 3, 0.0, 255.0) # 左 3 黑右 5 白
coef = np.sqrt(8) * (basis @ (row - 128)) # 竖直边,系数只在第一行
back = basis.T @ (np.round(coef / step) * step) / np.sqrt(8) + 128
print(coef.round(1), back.round(2), sep='\n')
css
[ 251. -853.9 -333.2 124.2 255. 83. -138. -169.9]
[ 0.03 -1.37 1.67 252.94 255.6 254.79 255.91 254.44]
每个系数的误差都不大。最大的是最后一个,-169.9 被取成 -162 差了 7.9。可这 8 个系数是 8 条横跨整个块的余弦波,每条都带着自己的误差铺到每一个像素上。反变换出来左边本该是 0 的三格成了 0.03、-1.37、1.67,右边本该是 255 的五格成了 252.94、255.6、254.79、255.91、254.44。第 8 列是白侧的第 5 个像素,离边最远也没躲掉。读回来是 254.44。边只占块里一道缝而误差是整个块平摊的。这个现象就是振铃。视频里的块效应和它是一个来路。
为什么白底上只看得到往下掉
上面右边五格里有两格是 255.6 和 255.91,超过了 255。解码的最后一步会把结果截到 0 到 255 之间。这两格直接变回 255。这部分误差就被吞掉了。剩下三格取整以后是 253、255、254,整行读回来是 0、0、2、253、255、255、255、254。往上冒的那一半在白底上被截掉而往下掉的那一半原样留下,所以取色器点到的总是 254、253 这种偏暗的值。黑色那一侧是镜像,往下掉的被截成 0 而往上冒的留成 2。
实拍图上杂点绝大多数落在 250 到 254。少数更暗。白鞋质量 75 时最暗到 228。这一小撮我没有逐个拆开来看。真实的边比我造的直边陡还带半透明过渡,系数分布要复杂得多。
边在块里的位置会改变杂点数
同一个块,边落在不同的列,结果不一样。下表是白侧读回不是 255 的列数,左边黑的列数从 1 数到 7。
| 质量 | 黑 1 列 | 黑 2 列 | 黑 3 列 | 黑 4 列 | 黑 5 列 | 黑 6 列 | 黑 7 列 |
|---|---|---|---|---|---|---|---|
| 75 | 3 | 2 | 3 | 2 | 2 | 1 | 1 |
| 85 | 1 | 2 | 2 | 1 | 1 | 0 | 0 |
| 95 | 0 | 1 | 1 | 0 | 0 | 0 | 0 |
| 100 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
质量越高列数越少,到 100 归零。边正好落在块的边界上时两边都是纯色块,哪一档都是 0。这只是竖直直边的小块,换成照片就不成立了。Pillow 质量 100 加 4:4:4 的三张实拍图,背景里仍有 0.022% 到 0.052% 不是 255。表里还有一处我没弄懂。质量 75 时黑 1 列读错 3 列,黑 2 列读错 2 列,黑 3 列又回到 3 列。误差大小和边的位置有关但不是单调的。原因我没有再往下查。
真图里杂点落在哪些块
回到照片上验证这套说法。块网格从图的左上角对齐,主体按抠图蒙版里 alpha 大于 0 算。背景像素分成两类:所在的 8×8 块里有主体像素的,和整块都是背景的。4:4:4、质量 85 的结果很干净。毛绒 2,467 个、玻璃 4,078 个、白鞋 3,501 个杂点,100.00% 在含主体的块里。整块都是背景的块里是 0 个。灰度图单独存一遍也是这样。
默认的 4:2:0 就没这么整齐。白鞋 7,916 个杂点里只有 83.41% 在含主体的 8×8 块里。毛绒是 80.85%,玻璃是 73.60%。剩下的落在整块都是背景的 8×8 块里。我的解释是色度:4:2:0 把色度通道横竖各缩一半。一个色度块要盖住 16×16 个像素。色度的误差会漏进隔壁整块都是背景的亮度块。改用 16×16 的网格去数,占比回到 98.90% 到 99.39%。数字对得上这个解释。不过我没有把色度通道单独拆出来验证。
还有一个小实验:同一张图左边补 k 列白(k 从 0 到 7 且蒙版同步平移)再按 4:4:4 质量 85 存一遍。杂点数在均值上下浮动,最大和最小相差均值的 14.36%(毛绒)、6.32%(玻璃)、9.46%(白鞋)。块网格相对图的位置变了,哪些背景像素落进含边的块就跟着变。同一张图换个裁剪位置,数出来的杂点数不会完全一样。
还有哪些地方没弄清
有几件事我没弄清。边的位置和误差大小为什么不单调,上面说过。实拍图里那些更暗的杂点来自什么样的块,我没有拆。4:2:0 的色度解释只有数字旁证。编码器我只试了 Pillow 11.3.0 这一个而且以质量 85 为主。量化表是从它读回的 quantization 属性里取的,没有读源码。把同一张白底 PNG 拖进图映的压缩转换选 85,背景的统计和 Pillow 一样。这是三张图上的结果。它内部怎么实现的我不猜。
自己的白底图想查一遍可以按 8 的整数倍把图切块,数一数不是 255 的像素落在哪些块里。杂点都在带边的块里就是正常的量化误差。整块都是背景的块里也有,先看是不是 4:2:0 再换成 4:4:4 数一遍。我自己查的时候不会只点一个像素下结论,也不指望 255 一个不差。