一张抠好的人像 PNG 缩成四分之一再放回原尺寸,边缘多出一圈偏白的雾。第一反应多半是插值。缩放核在边缘上抹了一把,当然会糊。我手上这批数据把问题拆成了两半。边缘变软确实是插值的账。边缘变脏是另一回事,和缩放核几乎没关系。
这篇只讲后一半在图片管线里怎么放,也就是 alpha 预乘该发生在哪一步。抠图那块不是我做的。下面是观察加取舍,推断的地方会直接写「推断」。
边缘发软和发脏是两件事
三张样图取自维基共享资源(Wikimedia Commons,CC 许可),是人像、一只鞋和一只白色卷毛狗。长边缩到 2000 像素。抠图结果是图映 ImgIng imging.cn/ 在电脑浏览器里用 AI 抠图(快速档)导出的 PNG。缩放我自己写了浮点逐通道的实现,缩小走面积平均,放大走双线性。「非预乘」是 R、G、B、A 四个通道各缩各的。「预乘」是先把 RGB 乘上 alpha 再缩,放大后除回去。误差统计的是原图 alpha 在 1 到 254 之间的像素向外扩 F 个像素的边缘带。数值是合成后与基准的平均绝对差,按 0 到 255 级算。
| 样本 | 缩放 | 非预乘:平均 / 最大 / 相差超 8 级的占比 | 预乘:同三项 |
|---|---|---|---|
| 人像 | 4× | 2.991 / 46.9 / 13.83% | 0.025 / 0.4 / 0.00% |
| 人像 | 8× | 3.879 / 53.5 / 19.27% | 0.014 / 0.3 / 0.00% |
| 商品 | 4× | 1.220 / 37.6 / 6.57% | 0.023 / 0.4 / 0.00% |
| 毛发 | 4× | 0.505 / 15.1 / 0.12% | 0.043 / 0.5 / 0.00% |
| 毛发 | 8× | 1.560 / 26.9 / 4.98% | 0.026 / 0.4 / 0.00% |
两种做法的 alpha 逐像素一致,最大差 0。过渡带变宽、边缘变软两边都有,这是插值的账。差别只在 RGB:人像 4 倍往返,非预乘的边缘带平均误差 2.991,预乘 0.025。毛发 4 倍时只有 0.505 对 0.043,肉眼基本看不出来。预乘那一列接近 0 有个前提,基准本身就是预乘缩放。这张表只能说明非预乘多了多少脏色,说不出预乘离真值有多近。
脏色是透明像素里存着的背景色
脏色的来源在数据里找得到。这批导出的 PNG 里,低 alpha 像素保存的 RGB 就是原来的背景色。alpha 在 1 到 5 的像素平均 RGB 在毛发样图是偏黑的 (30,31,32)。人像是 (221,218,219),商品是 (230,224,218)。这两张都偏白。毛发样图 alpha 等于 0 的地方全是 (0,0,0)。
取人像第 1300 行靠边的十个像素,x 从 838 到 847。导出原图里低 alpha(1 到 5)的位置是 255 的白。非预乘缩放把这点白抹到了 alpha 15 到 69 的位置,RGB 变成 195 到 244。预乘缩放后这些位置是 76 到 113,仍是衣服的深色。非预乘做的事是把一个本来几乎不可见的白按面积平均进了可见的边缘。
方向取决于低 alpha 处存了什么。存白出亮边,存黑出暗边。这个方向是我对三组数据的解释。只有这一批样图且没换第二套样本验证,算推断。图映抠图完成后的精修区写着,默认直接用 AI 的输出,透明度和边缘颜色都不动。低 alpha 处留着原背景色,和这句话并不矛盾。它内部怎么实现这块,我不清楚。
浏览器画布存不住透明处的颜色
换成浏览器里常见的 canvas 读写,情况更微妙。看现象,画布内部像是按预乘存储,透明度很低的像素颜色根本存不住。我往画布里写进 (200,100,50),再按不同 alpha 读回来。
in (200,100,50, 0) -> out (0,0,0,0)
in (200,100,50, 2) -> out (255,128,0,2)
in (200,100,50, 4) -> out (191,128,64,4)
in (200,100,50, 6) -> out (212,85,42,6)
in (200,100,50, 8) -> out (191,96,64,8)
in (200,100,50,10) -> out (204,102,51,10)
in (200,100,50,12) -> out (191,106,42,12)
in (200,100,50,14) -> out (200,91,55,14)
in (200,100,50,16) -> out (207,96,48,16)
in (200,100,50,18) -> out (198,99,57,18)
in (200,100,50,20) -> out (204,102,51,20)
in (200,100,50,22) -> out (197,104,46,22)
alpha 为 0 读回全零。alpha 为 2 读回成 (255,128,0),alpha 为 4 是 (191,128,64)。到 alpha 10 以后,各通道才回到离输入 10 以内。我的推断是 8 位整数存的是预乘值。alpha 为 2 时 RGB 只剩 0 到 2 几个档位,除回去自然乱跳。这一点我没有去拆浏览器源码确认。
一个八像素宽的极小例也对得上。左 4 个是不透明红,右 4 个全透明但 RGB 是蓝,缩成宽 5 后 drawImage 的过渡像素是 (255,0,0,128),没有蓝混进来。createImageBitmap 把 premultiplyAlpha 设成 none 后过渡像素是混进了蓝的 (128,0,128,128)。同一个浏览器里,两条路给出两种答案。抠图导出的 PNG 里 alpha 为 0 处全是 (0,0,0) 的现象和画布这个行为一致。导出是不是走了 canvas,我没有验证。
三种实现的默认行为落在不同位置
再看真实人像 4 倍往返,几种实现各自靠近哪个参考。
| 实现 | 与非预乘参考的差 | 与预乘参考的差 |
|---|---|---|
| 浮点非预乘(参考) | 0.0 | 2.947 |
| 浮点预乘(参考) | 2.947 | 0.0 |
| canvas drawImage,quality=high | 7.456 | 6.636 |
| createImageBitmap,premultiplyAlpha 为 none | 6.108 | 5.184 |
| createImageBitmap,premultiplyAlpha 为 premultiply | 4.85 | 2.689 |
| sharp 默认(lanczos3) | 11.897 | 10.89 |
| sharp 手动拆开 RGB 与 alpha(lanczos3) | 11.508 | 11.089 |
缩放核各不相同所以这张表不比绝对值,只看各行靠近哪个参考。半透明像素(alpha 10 到 245)的 RGB 平均亮度更能说明问题。浮点非预乘是 129.48,预乘是 112.0。sharp 默认是 109.63,手动拆开是 125.1。可见 sharp 默认接近预乘而手动拆开接近非预乘。同一个库内的对照更干净。sharp 的 lanczos3 下 alpha 最大差是 0。边缘带 RGB 平均差 4.218,最大 51.7。
想在 sharp 里得到非预乘必须把 RGB 和 alpha 各自导成 raw 图再缩放。直接 removeAlpha 后 resize 不行。libvips 会先按 alpha 预乘再丢掉 alpha,RGB 会变暗。我第一次就是这么写的,结果是错的。
预乘放在哪一步各有代价
回到开头的问题。我看到三个位置,每个都有账要算。
放在落盘格式里,也就是让 PNG 直接存预乘值。下游缩放天然干净。代价是几乎所有读 PNG 的软件都按非预乘解释它(按我对 PNG 规范的理解,推断)。存成预乘等于让所有下游一起变暗。这条路我没测,直觉上走不通。
放在缩放之前并在缩完立刻除回去,落盘仍是非预乘。这是这批数据直接支持的做法。alpha 不变,边缘 RGB 干净。代价有两个,一是多一次乘和一次除,二是除回去时低 alpha 处可能放大量化误差(推断)。上面画布读回的 (255,128,0) 看着就是这个样子。预乘若在浮点或 16 位里做,这个代价应该小得多,但这是推断没测过。
放在导出那一步,保证 alpha 很低的像素不再带着背景色。可以清成黑、清成白,或者拿相邻主体色填充。好处是下游用什么缩放都行,脏色的上限被压住。代价是导出方要替下游负责,而下游是谁往往不知道。填哪种颜色最合适,我没有数据。
只能选一个位置的话我会选第二个。谁缩放谁预乘,最好在浮点里做。这是权衡后的倾向,不是实测结论。这批数据只比较了预乘和非预乘两种缩放,导出端各种填充方式一个都没比。还有一点我没想清楚:低 alpha 处留原背景色对预乘缩放的下游无害。对非预乘的下游就是脏色来源。现实里哪一边更多,我手上没有数。
这些数字能管到哪里
数字只对这三张样图、这台 Apple M4 电脑、单次运行成立。缩放核我只试了一种组合,面积平均往下缩,双线性往上放。换成别的核误差的绝对值会变。canvas 和 sharp 的结论来自 Chromium 149 与 sharp 0.35.5。换版本要重测。Safari 和 Firefox 的 canvas 我没测。
sharp 的极小例里 8 缩到 3、5、7 都是硬切没有出现过渡像素,它的结论只来自真实人像对照。毛发样图上两种做法差异很小,4 倍时相差超 8 级的像素只有 0.12%。不能由这一条推出所有 PNG 缩小都会脏。
自己验证很便宜。拿一张抠图 PNG,往画布里写一个低 alpha 的像素再读回来。看颜色回不回得去。再看导出文件里 alpha 为 0 处的 RGB 是不是全零。