canvas 与 sharp 缩放透明图:默认预乘,非预乘得拆通道

抠图导出的透明 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 在变,就是预乘方式的问题,不是插值的问题。

相关推荐
静默回滚1 小时前
抠图 PNG 缩小再放大,边缘又糊又白是两件事
前端
用户11977079480731 小时前
GitHub 电商开源项目分享:用 sharp 批量出饰品主图,试了裁剪、细节图和色彩配置文件三个坑
前端·node.js
deli0071 小时前
地图定位为什么偏了 500 米?GPS 与火星坐标实测
前端
TK泰妞1 小时前
节日促销前,TikTok 商品视频应该提前准备什么?
前端·人工智能·节日
CCIE-Yasuo1 小时前
网络安全-学习web开发-列表、标签
前端·学习·web安全
小大宇1 小时前
Vue知识点-ElementPlus-06 单选+多选+下拉+日期选择
前端·vue.js·elementui
用户11481867894841 小时前
Nuxt 3 学习版 Demo:跟着做一遍,从跑起来到自己会改
前端
hiahiahia1232 小时前
Streaming Model Output
开发语言·前端
paopaokaka_luck2 小时前
基于springboot+vue的益暖微光公益服务平台(DeepSeek公益助手、善款信息追溯、志愿服务积分闭环、ECharts运营分析、活动状态自动流转)
java·前端·javascript·spring boot·mysql·架构·echarts