抠图导出的透明 PNG 缩成缩略图再放回去,人像的发际线和深色衣领边上常多出一圈偏白的毛边。后来我把这件事拆开量了一遍,发现发软和脏色根本不是同一个原因。canvas 和 sharp 对脏色的默认处理也不一样。
先说结论。这批样图上,canvas 的 drawImage 和不加参数的 sharp 缩放,行为都更接近预乘 alpha。边缘不容易混进透明处的颜色。想要非预乘(RGB 和 alpha 各缩各的),sharp 得自己把两部分拆开缩放。浏览器里则要给 createImageBitmap 显式传 premultiplyAlpha。下面的数字只对这几张样图、这台电脑和测的那一次成立,换版本要重测。
边缘发软和边缘脏色是两件事
我先做了一组对照。取同一张人像抠图缩小 4 倍再放大 4 倍,一份按预乘缩放另一份按非预乘缩放。结果里 alpha 通道两种方式逐像素一致,最大差是 0。半透明过渡带变宽、边缘变软,是插值本身带来的。预乘不预乘都躲不掉。
两种方式只在 RGB 上有差别,人像 4× 往返时边缘带里非预乘平均多出约 3 级误差(0--255 级,平均绝对差 2.991)。最大差 46.9 级,边缘像素里有 13.83% 和预乘结果差了 8 级以上。放大看是衣领边上一圈偏白。同样的对比换成白色卷毛小狗。4× 时毛发上只有 0.12% 的像素差到 8 级以上,肉眼几乎看不出。8× 才涨到 4.98%。不是每张图都会翻车。这三张里,发丝和深色衣服压在浅背景上的人像最明显。有一点要提醒:我拿预乘结果当基准,预乘那一列接近 0 有一部分是这个原因。这组数只能说明非预乘多出来的那部分脏色,说明不了预乘更接近真实。
脏色来自透明像素里存的颜色
边缘为什么会脏?非预乘缩放会把相邻像素的 RGB 直接加权平均,不管那个像素是不是几乎透明。抠图导出的 PNG 里,alpha 很低的像素并没有把颜色清掉。里面可能还留着原来的背景色。
样图是维基共享资源的三张 CC 图片,我缩到长边 2000 像素后用。抠图结果来自图映 ImgIng imging.cn/ ,电脑浏览器里的 AI 抠图,快速档,导出 PNG。抠图页自己写着不改透明度、不改边缘颜色,直接用 AI 的输出。导出之后我统计了 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,还是衣服的深色。透明处存白就出亮边,存黑就出暗边。这个方向是我对数据的解释。只有三张图,没拿第二套样本验证。
浏览器 canvas 缩放不会混进透明色
先看 canvas,我在 Chromium 149 里做了一个极小例子。图宽 8 像素。左边 4 个是不透明红 (255 0 0 255)。右边 4 个全透明,但 RGB 写的是蓝 (0 0 255 0)。把它缩成宽 5。如果透明处的蓝参与平均,过渡像素会发紫。drawImage 缩出来的过渡像素是 (255 0 0 128) 而且一点蓝都没有。quality 设 low、medium、high 都是这样而且只差 1 的 alpha 取整。canvas 内部按预乘处理,透明像素的颜色不参与平均。
我的解释是画布存的是预乘值,下面的读回可以对上。我把 (200 100 50) 配不同 alpha,用 putImageData 写进去再用 getImageData 读回。alpha 为 0 时读回 (0 0 0 0)。alpha 为 2 时读回 (255 128 0),alpha 为 4 时读回 (191 128 64)。alpha 到 10 才基本回到 (204 102 51)。预乘存储的话,alpha 很低时 RGB 的精度会被吃掉。用 canvas 读写抠图结果时,低 alpha 处的颜色本来就不可靠。上面毛发那张图里 alpha 为 0 的像素也全是 (0 0 0),现象一致。那份 PNG 的导出是不是走了 canvas 我没验证。
浏览器里要非预乘得显式声明
想要非预乘的话浏览器有现成的开关。createImageBitmap 的选项里有 premultiplyAlpha,取值是 none、premultiply 和 default。同样的极小例子里传 none,过渡像素变成 (128 0 128 128)。蓝混进来了。
在真实人像上我也跑了但读数要小心。下图是同一张人像缩小 4 倍再放大 4 倍的结果。纵向是各实现,横向是它与两个参考值(numpy 非预乘与预乘)的边缘带平均绝对误差,合成白底。

这张图要看最后一列:alpha 为 0 处 RGB 非零的像素数。canvas 和 createImageBitmap 都是 0,sharp 默认也是 0。三个库里只有 sharp 手动拆开那一行是 35283。前两列的绝对值别拿来比高低,canvas 的缩放档和 sharp 的 lanczos3 本来就是不同的核。样本取自维基共享资源,CC 许可。表里 none 离预乘参考的差也比 premultiply 大。单看这几个数读不出方向。能下结论的是同一个库里的前后对比,下面 sharp 就是这么验证的。
sharp 默认也按预乘缩放
sharp 底层是 libvips,缩放带 alpha 的图时会先预乘。我用的 sharp 是 0.35.5 而 libvips 是 8.18.7。同一张人像做 4× 往返。默认和手动拆开两种结果的 alpha 最大差是 0。RGB 在边缘带平均差 4.218,最大 51.7。缩放核换成 linear 后平均差是 4.356,最大是 50.3。
再看亮度。取 alpha 在 10 到 245 之间的半透明像素,算 RGB 平均亮度。numpy 非预乘是 129.48,预乘是 112.0。sharp 默认是 109.63,手动拆开是 125.1。默认的靠近预乘,拆开的靠近非预乘。这和 canvas 的结论方向一致。还有一个侧面证据。sharp 默认缩出来的小图,alpha 等于 0 的 83416 个像素 RGB 全是 (0 0 0)。手动拆开那一份,alpha 等于 0 的位置还留着 35283 个非零 RGB。我的推断是预乘缩放结束后会除回 alpha,透明处就没有颜色可存。极小例对 sharp 帮不上忙。8 像素宽的硬边缘缩到 3、5、7 像素。sharp 都是硬切而没有出过渡像素,所以它的行为在这个例子里看不出来。sharp 的默认行为,我靠的只有人像那组往返数据。
想要非预乘就得拆开通道
我第一次写手动版是这样:先 removeAlpha 再 resize,想着去掉 alpha 单缩 RGB 就是非预乘。结果缩出来的 RGB 整体变暗了。libvips 在丢掉 alpha 之前已经按 alpha 预乘过一遍。留下的是预乘后的颜色。正确做法是把 RGB 和 alpha 各存成一份独立的 raw 数据,分别缩放,最后拼回去。下面这段是从我的实测脚本里整理的,去掉了往返放大和存盘,整理后的版本没有单独再跑。原脚本缩到 332×500 之后,alpha 与默认缩放一致。
js
const sharp = require('sharp');
const raw = (width, height, channels) => ({ raw: { width, height, channels } });
async function resizeStraight(input, w, h, kernel = 'lanczos3') {
const { width: W, height: H } = await sharp(input).metadata();
const rgb = await sharp(input).removeAlpha().raw().toBuffer();
const alpha = await sharp(input).extractChannel(3).raw().toBuffer();
const rgbSmall = await sharp(rgb, raw(W, H, 3)).resize(w, h, { kernel }).raw().toBuffer();
const alphaSmall = await sharp(alpha, raw(W, H, 1))
.resize(w, h, { kernel }).extractChannel(0).raw().toBuffer();
return sharp(rgbSmall, raw(w, h, 3)).joinChannel(alphaSmall, raw(w, h, 1)).png().toBuffer();
}
这里的 removeAlpha 后面只接 raw 导出而没有接 resize,不会提前预乘。RGB 缩的是原始颜色。透明处残留的背景色也就跟着进了平均。
拆开前后边缘差多少
人像第 1300 行,取 x 为 838、840、842、844、846 五个点。下表是缩小 4 倍再放大 4 倍以后同一位置的 RGBA。
| 位置 x | sharp 默认 lanczos3 | sharp 手动拆开 lanczos3 |
|---|---|---|
| 838 | (49 43 49 41) | (220 218 219 41) |
| 840 | (0 0 0 0) | (255 255 255 0) |
| 842 | (255 255 255 11) | (240 241 243 11) |
| 844 | (79 82 89 74) | (180 181 184 74) |
| 846 | (33 34 38 191) | (72 74 78 191) |
alpha 一列两边完全相同,是 41、0、11、74、191。RGB 差得很大。x=838 默认是接近深色衣服的 (49 43 49),拆开后是偏白的 (220 218 219)。x=840 的 alpha 是 0。默认里存黑,拆开里存白。当下看不见。但这张图一旦贴到别的底色上或者再过一道处理,这个颜色就可能跑出来。
排查自己的素材时,可以先看透明处存了什么。读出 alpha 为 0 的那些像素。RGB 全是同一个值,比如全黑或全白,说明导出方已经替你统一过,毛边的方向基本可以预判。RGB 是五花八门的原图颜色,用非预乘缩放就更容易带出杂色。这是我对这几张图的读法,换工具导出的 PNG 要再看一眼。
这些结论管到哪里
浏览器侧是 Chromium 149,Node 侧是 sharp 0.35.5。样图一张人像、一张商品、一张毛发,每类一张。Safari 和 Firefox 的 canvas 没测,sharp 的别的版本也没测。别读成「所有 PNG 缩小都会脏」,也别读成「预乘一定比非预乘好」。非预乘不算错,它只是假设透明处的颜色有意义。别的来源的素材透明处存什么我没测,可能干净,也可能不干净。抠图工具导出的素材,透明处存什么要看工具。做缩略图我会优先走预乘,也就是 canvas 的 drawImage 或者不加任何处理的 sharp。后面还要做通道级运算时,再拆开。
自己验证只要三步。拿一张抠图 PNG,读出边缘一行的 RGBA。缩小再放大一次,再读同一行。看 alpha 低的位置,RGB 有没有往原来的背景色靠。alpha 一列没变而 RGB 在变,就是预乘方式的问题,不是插值的问题。