抠图换深色背景有白边?先分清直通和预乘alpha

摘要

白底图抠出来贴回白底上看不出毛病,换成黑色或深蓝背景后主体外面就浮出一圈发灰发白的边。很多人第一反应是抠得不准。其实这圈边的透明度大体是对的,错的是它的颜色。本文从两种 alpha 存法讲起:直通 alpha 存的是颜色本身,预乘 alpha 存的是乘过透明度的颜色。抠图导出的 PNG 是直通存法,半透明像素里的 RGB 基本就是原图像素,而原图边缘本来就混着白底。「去除背景色边」能把这圈白压下去,可它主要动的是透明度,接近底色的主体会被一起压透明。前端合成时存法和公式配错,边缘还会再亮一倍。文中数字来自两张 Wikimedia Commons 白底图的实测。抠图模型和去色边算法不是我做的,涉及实现的部分都是我从导出文件反推的。

版本声明

实测日期是 2026 年 9 月 30 日,机器是一台装着 macOS 26.5.2 的 Apple M4。浏览器是开着 WebGPU 的 Chromium 149.0.7827.55。抠图用的是图映线上「去背景」里的「快速 AI」档。模型是 ISNet INT8,状态行显示走 WebGPU。像素分析用 Python 3 加 Pillow 和 NumPy,亮度一律按 Rec.709 加权。前端合成那组对照也在同一个 Chromium 里跑完再读回像素计算。换浏览器、换档位、换样本之后数字都可能不一样。

适用边界

这篇只讲一件事:透明 PNG 的半透明边为什么带着原底色,以及几种处理方式各自改了什么。样本只有两张网上找的白底图:一张小狗和一根插在玻璃瓶里的带白斑叶片的枝条。抠图只测了快速档,专业档和骨灰级档没测,换档之后的结果我不推断。灰底、彩色底和画笔精修也都没测。文中凡是讲「它内部怎么做」的地方都是我对着导出文件做的推测,不是那块代码的说明。怎么选抠图档位、蒙版画笔怎么用都不在这篇里。

文章目录

1 抠图换黑底为什么会出白边

  • 白边只出现在半透明那一圈
  • 合成到黑底后边缘亮了约11
    2 直通alpha和预乘alpha各存什么
  • 直通存的是颜色本身
  • 预乘存的是乘过透明度的颜色
  • 两种存法合成结果相同
  • 预乘在8位下会丢精度
    3 抠图导出的PNG是哪种存法
  • PNG规范只允许直通存法
  • 不透明像素和原图完全相同
  • 低透明度像素偏差最大到127
    4 半透明边为什么带着原底色
  • 原图的边缘像素本来就混了白底
  • 模型只给透明度不给主体色
  • 换PNG或WebP改变不了边缘
    5 去色边为什么会改动透明度
  • 不透明像素一个都没变色
  • 被改的像素大多是透明度变小
  • 越接近白色的像素被压得越多
  • 去色边是在重估透明度
    6 去色边会误伤哪些主体
  • 白斑叶片被压出黑洞
  • 玻璃瓶的光晕换成了台阶硬边
  • 小狗前腿交界多出锯齿
    7 前端合成配错为什么会放大白边
  • 把直通当预乘用边缘会亮一倍
  • 预乘上传再乘一次会偏暗
  • canvas 2D合成没有偏差
    8 这一圈白边该怎么处理
  • 先看主体里有没有接近底色的部分
  • 边缘收缩和柔化帮不上大忙
  • 合成端先对齐存法再谈修边
    9 适用边界与风险提示
    10 测量方法自身的坑
  • 应有亮度只是近似值
  • 逐像素对比要先确认解码器一致
  • 亮度分档里混着背景残留
  • 测试环境的WebGL可能不走显卡
    11 还没解决的
    参考资料

1 抠图换黑底为什么会出白边

白边只出现在半透明那一圈

我拿两张网上找的白底图试。第一张是小狗,来自 Wikimedia Commons(作者 George Hodan,CC0)。第二张是一根插在玻璃瓶里的榕树枝条,原图来自 Wikimedia Commons,作者 Biusch,CC BY-SA 3.0,下文用到它的合成图同样按 CC BY-SA 3.0 提供。两张都是纯白背景。它们也都是抠图最常见的那类素材。抠完导出的透明 PNG 贴到白底上完全正常,贴到黑底上就不一样了。小狗的毛边外面是一圈发灰的软边。玻璃瓶外沿那一圈更明显。那是一道大约 20 px 宽的灰白光晕。

抠图我用的是图映 ImgIng(https://imging.cn/)的快速档,模型 ISNet INT8,两张白底图抠完再合成到黑底和深蓝底比较。图映是我参与的项目,我在里面负责端侧编解码和模型加载,抠图模型和「去除背景色边」这两块都不是我做的。这篇我没有去翻那两块的代码,只按导出文件说话,这样谁拿同样的图都能复核。抠图在浏览器本地跑,两张图导出六次全程的非 GET 请求是 0。抠完之后精修区有一句原话:默认逐像素直接使用 AI 输出,不改透明度、不改边缘颜色。这句话后面会反复用到。它说明默认导出的边就是模型给的边,没有谁替你修过。

这张图看三处。左边是原图。小狗肚皮下面的毛尖和白底之间没有一条清楚的线,只有一段慢慢变白的过渡。中间是默认导出贴到黑底的样子,那段过渡还在,只是底下的白换成了黑,毛尖外面留着一层灰。右边是开了去色边之后贴到黑底的样子,灰基本没了,前腿和肚皮交界的地方却多出一块锯齿状的硬边。三张并排放着。白边和它的代价都在里面。小狗图来自 Wikimedia Commons,CC0。

先把「半透明像素」说清楚。8 位 alpha 通道的取值是 0 到 255,0 是全透明,255 是完全不透明。这篇里半透明像素指 alpha 在 1 到 254 之间的像素,白边就长在这些像素上。默认导出里小狗图有 113,438 个半透明像素,植物图有 1,732,202 个。我又在不透明区里取了贴边 3 px 以内的一圈当作主体本来颜色的参照。量出来的差距很直观:小狗半透明像素的原色平均亮度是 160.1,贴边的不透明像素只有 108.6。植物是 200.8 对 123.6。半透明那一圈明显比主体亮。亮出来的部分就是原来的白底。

样本 半透明像素 半透明原色平均亮度 边缘内侧平均亮度 半透明里亮度 >200 的占比
小狗 · PNG 默认 113,438 160.1 108.6 39.9%
植物 · PNG 默认 1,732,202 200.8 123.6 57.8%

最后一列更能说明问题。小狗的半透明像素里有 39.9% 亮度超过 200,植物是 57.8%。亮度 200 以上在这两张图里基本就是白底的颜色,主体本身没有那么亮的毛或叶子。换句话说,半透明那一圈里有将近一半像素存着的 RGB 就是白色。

合成到黑底后边缘亮了约11

光看 RGB 还不够,读者最终看到的是合成之后的颜色。我按直通 alpha 的正确公式 rgb×a + 底色×(1−a) 把两张图合成到黑底和深蓝底 #1A2A4A 上。再用贴边不透明像素的平均色当主体色算出这圈边「应该」有多亮,两者对比。

样本 黑底实际 黑底应有 深蓝底实际 深蓝底应有
小狗 · 默认 75.4 64.3 92.1 81.0
植物 · 默认 52.5 40.9 79.9 68.2

两张图在两种底色上的实际值都比应有值高出约 11 个亮度单位。这个数我一开始以为是巧合,毕竟一张是短毛动物、一张是叶片和玻璃,边缘形态完全不同。后来想明白了。11 这个量本身没什么特别,只是两张图恰好都有这么多白底被带进了边缘。换一张原图这个数会变。偏亮的方向不会变。偏亮的原因只有一个:公式是对的,alpha 也大体是对的,乘进去的 rgb 却不是主体的颜色,是主体和白底混在一起的颜色。要讲清楚这一点得先把两种 alpha 存法分开。

2 直通alpha和预乘alpha各存什么

直通存的是颜色本身

直通 alpha 英文叫 straight alpha,也叫非预乘或 unassociated alpha。它把一个像素拆成两件互不干扰的事来存:RGB 是这个像素「如果完全不透明」应该是什么颜色,alpha 是它覆盖了多少面积。一根毛尖只盖住像素的一小部分。直通存法会把 RGB 存成毛的颜色,再给 alpha 存一个很小的值。把 alpha 调大它就变成一块实心的毛色。把 alpha 调小颜色不变,只是更淡。

这种存法对编辑友好。改透明度不用碰颜色,改颜色也不用碰透明度。调色、描边、抠图精修都是在这两个量上分别做事。它的麻烦在合成时才出现:每次叠到背景上都要先把 RGB 乘以 alpha 再加上背景乘以剩下的部分。公式里 RGB 和 alpha 是分开出场的,漏乘一次或多乘一次结果都会错。

预乘存的是乘过透明度的颜色

预乘 alpha 英文叫 premultiplied alpha,也叫 associated alpha。它在存的那一刻就把乘法做完了。RGB 里存的已经是「颜色乘以覆盖率」,也就是这个像素对最终画面实际贡献了多少光。同一根毛尖在预乘存法里 RGB 会很暗,因为它只贡献了一点点毛色。alpha 照样存覆盖率,它在合成时只负责决定背景要留多少。

预乘存法在合成和缩放时占便宜。叠加只需要 rgb + 底色×(1−a),少一次乘法。更重要的是插值时不会串色。全透明像素在预乘里 RGB 一定是 0,拿去和邻居做双线性插值也不会把自己的颜色掺进来。直通存法里全透明像素的 RGB 可以是任意值,缩放前如果不先处理边缘就会渗出莫名其妙的颜色。我以前做编解码时在 YUV 缩放上吃过类似的亏,道理相通:参与插值的量必须是能线性相加的物理量。预乘 RGB 是,直通 RGB 不是。

两种存法合成结果相同

这两种存法描述的是同一个像素,只要各自配对的公式用对合成出来就一模一样。直通 alpha 的公式是 rgb×a + 底色×(1−a)。预乘 alpha 的公式是 rgb + 底色×(1−a),后者的 rgb 里已经带了那个 a。两者之间换算也简单:直通转预乘就是 RGB 乘以 alpha,预乘转直通就是 RGB 除以 alpha。

这里要把一件事说死:存法本身不产生白边。白边的根在于 RGB 里装的是什么,和它是直通还是预乘无关。一个 RGB 里混着白底的直通像素转成预乘之后照样混着白底。存法只在一种情况下会放大白边,就是存法和公式没对上。这个放到第 7 章讲。

预乘在8位下会丢精度

预乘有一个代价,在 8 位存储时尤其明显。RGB 乘以一个很小的 alpha 之后会挤到 0 附近的几个整数上,等到转回直通时再除以 alpha,原来的颜色已经回不来了。alpha 越小能区分的颜色越少。alpha 只有 1 的时候预乘后的 RGB 只能是 0 或 1,转回直通就只剩两档。HTML 规范讲 canvas 的 getImageData 和 putImageData 时专门提醒过这一点。画布内部如果按预乘存的话像素写进去再读出来可能对不上。

这个代价对看图的人几乎没有影响,alpha 为 1 的像素本来就看不见。但它对逐像素比较很重要。因为它会在文件里留下一个可以辨认的痕迹。下一章我就是靠这个痕迹去猜导出文件经过了什么。

3 抠图导出的PNG是哪种存法

PNG规范只允许直通存法

这个问题其实不用猜。PNG 规范写得很清楚:带 alpha 的 PNG 一律按非预乘存,RGB 是像素本身的颜色,alpha 独立表示不透明度。WebP 文件也是按非预乘存的。工具内部不管怎么处理,只要最后写成 PNG 或 WebP 文件里就只能是直通 alpha。浏览器解码这类图片时也按直通理解,<img> 和 canvas drawImage 都会替你把乘法做对。

但知道存法只解决了一半。直通存法说的是「RGB 应该是主体颜色」,并没有保证工具真的往 RGB 里写了主体颜色。工具完全可以按直通的格式往 RGB 里写一个并不干净的颜色。要知道 RGB 里到底是什么只能拿导出文件和原图逐像素比。

不透明像素和原图完全相同

导出文件和原图尺寸一致,小狗是 1280×1920、植物是 2734×3355,可以逐像素对齐。我比的是 RGB 三个通道里和原图差得最多的那一个。结果很干脆:alpha 为 255 的不透明像素 RGB 和原图 100% 完全相同。小狗 503,980 个、植物 2,359,053 个,一个都不差。这也顺带说明我用的解码器和浏览器解出来的原图一致,否则不透明区不可能全等。

半透明像素就不全等了。默认导出里半透明像素和原图完全相同的占比小狗是 57.35%、植物是 65.56%。放宽到差值不超过 2 的话小狗是 86.75%、植物是 76.08%。大部分半透明像素的 RGB 就是原图像素,剩下那部分偏差的规律在 alpha 上。

低透明度像素偏差最大到127

我把默认导出的半透明像素按 alpha 分四档,看每档偏离原图的最大值。

alpha 档 小狗像素数 小狗最大差 植物像素数 植物最大差
1--7 20,957 127 1,021,986 127
8--31 9,844 16 68,881 16
32--127 15,013 4 71,898 4
128--254 67,624 1 569,437 1

两张完全不同的图四档的最大差一模一样:127、16、4、1。alpha 越小偏差上限越大,上限还随 alpha 成倍地缩。这正是上一章说的 8 位预乘往返误差的形状,乘以一个小 alpha 再除回来,取整误差被放大的倍数就在 alpha 倒数那个量级。看到这四个数时我先怀疑是自己的脚本写错了,两张图换着跑了几遍结果都一样。

我的推测是导出之前像素在某处经过了一次 8 位预乘存储,再按 PNG 的要求除回直通。浏览器的 canvas 就是这类存储的常见来源。但这只是从文件形状反推的。那块代码不是我写的,我也没去核实。另外有一个反例值得记下:同批另一组测试把导出的 PNG 用 canvas 2D drawImage 画上去再 getImageData 读回,半透明像素的 RGB 误差是 0,连 alpha 小于 32 的也是 0。可见在 Chromium 149 里单纯画一张图再读回不一定丢精度。取整发生在导出链路的哪一步我还说不上来。

这个偏差对白边几乎没有贡献。偏差大的集中在 alpha 1 到 7,这些像素合成时权重很小,肉眼看不到。它在这篇里的意义是另一件事:去掉这层取整噪声之后导出文件的 RGB 基本就是原图像素,工具没有替边缘重新算颜色。这和精修区那句「不改边缘颜色」对得上。

4 半透明边为什么带着原底色

原图的边缘像素本来就混了白底

回到原图。照片里毛尖或叶子边缘所在的那个像素记录的是这一小格里所有光的平均,主体盖住一部分,白底占另一部分。抠图领域有一个几十年没变过的式子描述这件事:原图像素颜色 = alpha × 主体色 + (1 − alpha) × 背景色。Smith 和 Blinn 1996 年那篇蓝幕抠像的论文就是从这个式子出发的。

照这个式子看原图的边缘像素本身就是一次以白色为背景的「合成」结果。它是主体色和白色按 alpha 混出来的颜色。前面量到的半透明原色亮度 160.1 和 200.8 明显高于主体贴边的 108.6 和 123.6,差出来的那部分就是白色的份额。植物半透明像素里 57.8% 亮度超过 200,是因为那张图的半透明区里有大量 alpha 很低的背景残留。这些像素几乎就是纯白。

模型只给透明度不给主体色

ISNet 这类分割模型输出的是一张前景概率图,工具把它当 alpha 用。它回答的是「这个像素有多少属于主体」,不回答「主体在这个像素里是什么颜色」。前面那个式子里有三个未知量:alpha、主体色、背景色。模型只给了一个。

这时候工具要写一张直通 PNG,RGB 那一栏填什么?最省事也最不容易出错的办法是直接把原图像素填进去。默认导出就是这么做的:不透明区 100% 等于原图,半透明区去掉取整误差后也等于原图。问题在于直通存法约定的是「RGB 等于主体色」,填进去的却是「主体色和白底的混合」。约定和内容对不上白边就来了。

合成时正确的直通公式把这个混合色乘以 alpha,再加上新背景乘以 (1 − alpha)。新背景那一份是对的。但混合色里本来就有一份白底,等于旧背景被按 alpha 打了个折带到新画面上。在白底上看旧背景和新背景是同一种白,完全看不出来。在黑底上看那份被带过来的白就是高出「应有」的约 11 个亮度单位。深蓝底上同样高出约 11。这说明它和新背景是什么颜色无关,只取决于旧背景混进来多少。

换PNG或WebP改变不了边缘

有人会怀疑是 WebP 有损压缩把边缘弄脏了。图映的去背景模式默认选中的是 WEBP,我把默认 WEBP 和默认 PNG 放在一起比。两者的半透明像素数完全相同,小狗都是 113,438、植物都是 1,732,202。原色平均亮度的差不超过 0.2,合成之后的差不超过 0.1。白边在两种格式里一样宽、一样亮。

这和前面的推理对得上。两种格式都是直通存法。工具往里面写的是同一份 RGB 和同一份 alpha,格式只是换了个容器。白边是内容问题不是容器问题。换 PNG 解决不了。

这张图是植物默认导出合成到黑底的整图,看两处。第一处是玻璃瓶的外沿,瓶身左右两侧各有一圈约 20 px 宽的灰白光晕,是两张样本里白边最重的地方。玻璃本身就是半透明的。模型把瓶身边缘判成了一大片中低 alpha,每个像素都带着一份白底。第二处是叶子上的白斑。它们在黑底上显得很亮,看起来像叶片本身的颜色,其实有一部分是 alpha 不满的白底。上方那片带白斑的叶子在第 6 章还会出场。原图来自 Wikimedia Commons,作者 Biusch,CC BY-SA 3.0,这张图为抠图后合成,同样按 CC BY-SA 3.0 提供。

5 去色边为什么会改动透明度

不透明像素一个都没变色

精修区里的「去除背景色边」是个默认关闭的开关,旁边标着「可选 · 会改变 AI 原始边缘」。打开之后再导出白边基本消失了。小狗去色边后黑底上的半透明边是 76.0,按主体色估算的应有值是 75.6,只差 0.4。按字面理解「去除背景色边」应该是把混在边缘里的背景色去掉,也就是改颜色。我原本也这么以为。量完之后才发现它主要改的不是颜色。

先看不透明像素。默认和去色边两版都不透明的像素里(小狗 503,803 个、植物 2,356,103 个)颜色变化超过 30 的是 0 个。和原图逐像素比时去色边版的不透明像素 RGB 仍然 100% 等于原图,小狗 504,075 个、植物 2,356,500 个。去色边版半透明像素和原图完全相同的占比小狗是 56.8%、植物是 65.55%,和默认版的 57.35%、65.56% 几乎一样。颜色这一栏去色边基本没动。

被改的像素大多是透明度变小

那变的是什么?是 alpha。小狗有 76,236 个像素的 alpha 被改了,其中 67,485 个变小、8,751 个变大。植物被改了 1,369,864 个,其中 1,363,556 个变小、只有 6,308 个变大。半透明像素的数量也跟着大减:小狗从 113,438 降到 77,761,植物从 1,732,202 降到 583,152。

样本 半透明像素(默认→去色边) 半透明原色亮度 黑底实际 / 应有 alpha 被改 其中变小
小狗 113,438 → 77,761 160.1 → 118.1 76.0 / 75.6 76,236 67,485
植物 1,732,202 → 583,152 200.8 → 153.6 106.5 / 91.0 1,369,864 1,363,556

半透明原色亮度小狗从 160.1 降到 118.1,植物从 200.8 降到 153.6。下降的原因是最亮的那批半透明像素被压成了全透明从而退出了「半透明」的统计。剩下的半透明像素本来就比较暗。平均值自然下来了。

越接近白色的像素被压得越多

哪些像素的 alpha 被压?我按原图该像素的亮度分档统计去色边前后 alpha 的平均下降量,只看默认导出里 alpha 大于 0 的像素。

原图亮度 小狗平均下降 植物平均下降 植物下降 >64 的占比
0--99 0.1 0.1 0.0%
100--149 4.8 5.0 3.1%
150--199 21.1 32.5 19.4%
200--229 20.0 66.0 34.2%
230--255 24.8 11.1 5.8%

暗像素几乎不动,两张图亮度低于 100 的档平均只降 0.1。亮度一过 150 下降量就上来了。植物在 200 到 229 这一档平均降了 66.0,超过三成像素降了 64 以上,相当于四分之一个满量程。原图越像白底 alpha 被压得越狠。

植物最亮那一档平均只降了 11.1,看起来和趋势相反。原因在这一档的构成:它里面大部分是 alpha 本来就很低的背景残留,植物 alpha 1 到 7 的像素有 1,021,986 个。这些像素原本的 alpha 只有个位数,再怎么压也降不了多少。真正被大幅压低的是那些原本 alpha 不低、颜色却接近白的像素,也就是白斑叶片和玻璃瓶身。

去色边是在重估透明度

把上面三组数放在一起能反推出去色边大致在做什么。这块不是我做的。下面是我从导出结果反推的逻辑,不是它的实现说明。

回到那个式子:原图像素 = alpha × 主体色 + (1 − alpha) × 背景色。要消掉白边有两条路。第一条是保留 alpha 把 RGB 从混合色改回主体色,这需要估计主体色,业内一般叫颜色去污染。第二条是保留 RGB 重新判断这个像素到底有多少属于主体:颜色非常接近背景色的像素大概率就是背景,alpha 应该更小。图映这版去色边走的是第二条路。不透明像素颜色不变、半透明像素颜色基本不变、alpha 大量变小、越接近白色压得越多,这四个现象都指向同一个结论。

第二条路有它的道理。只动 alpha 不会凭空造出原图里没有的颜色,也不会在边缘画出奇怪的色块,实现起来更稳。它的代价同样来自那个式子:它判断「像不像背景」只能看颜色,主体里本来就接近背景色的部分它分不出来。白底图里的白色主体在它眼里就是背景,下一章那几个误伤都是从这里来的。

6 去色边会误伤哪些主体

白斑叶片被压出黑洞

植物上方那片带白斑的叶子,是两张样本里去色边代价最大的地方。叶区里 alpha 大于 0 的像素有 208,058 个,默认导出里其中 151,728 个就已经是 alpha 在 128 到 254 之间的半透明。也就是说快速档本来就把这片浅色叶子判成了「半透明」,从上一张整图也能看出叶子的白斑区在黑底上有点发虚。

开了去色边之后这 151,728 个里有 70,011 个降到 alpha 小于 128,41,644 个直接变成全透明。合成到黑底上叶片的白斑区出现了大块黑洞,像被虫啃过。不透明像素的颜色一个都没变。变化超过 30 的是 0。叶子没有被涂黑。它是被判成了背景。白斑的颜色接近白底,按「像不像背景」重估透明度时它就被当成了背景。

这个例子把取舍说得最清楚:白边消了,主体的一部分也没了。商品图里白色的瓶盖、浅色的衣服、白色的包装都可能落进同一个坑。

玻璃瓶的光晕换成了台阶硬边

玻璃瓶是另一种情况。瓶身本身就是半透明的,默认导出时外沿那一圈约 20 px 宽的灰白光晕是白边最重的地方。开去色边之后光晕基本消失,瓶身外沿却变成了台阶状的硬边,原来那段平缓的 alpha 过渡被压成了几级陡峭的台阶。

矛盾在于玻璃边缘本来就该是半透明的。透过玻璃看到的白底从物理上说确实是这个像素颜色的一部分,去色边把它当成背景压掉之后光晕没了,玻璃也少了一层质感。植物整图合成到黑底后去色边版半透明边的实际亮度是 106.5、应有亮度 91.0,差 15.5,比默认版的 11.6 还大。这个差值在植物上已经不太能说明问题,因为 alpha 被大面积改写之后我估算「应有亮度」的前提也跟着变了。这一点第 10 章再说。

小狗前腿交界多出锯齿

小狗是两张图里去色边效果最好的,白边基本消失,实际 76.0 对应有 75.6。回头看第 1 章那张三联图的右边,前腿和肚皮交界的地方多出一块锯齿状的硬边。那一段原图里是白色肚皮毛和深色前腿的交界,颜色本身就接近白底。去色边把其中一部分判成了背景。边缘从一段平滑的过渡变成了一条锯齿线。

小狗身上的白色部分不多。它只在交界处露了一点。换一只白色的狗会怎样我不确定。这一组我没测。

7 前端合成配错为什么会放大白边

把直通当预乘用边缘会亮一倍

前面讲的白边是「内容」问题,大小约 11 个亮度单位。还有一类白边是「公式」问题,它会叠在前者上面而且大得多。

如果把直通 alpha 的 PNG 当成预乘去合成,也就是用 rgb + 底色×(1−a),就漏掉了 rgb 乘 alpha 那一步,半透明像素会以满亮度出现。我自己写公式算了一遍:黑底上半透明边的平均亮度小狗变成 160.1、植物变成 200.8,正确合成分别是 75.4 和 52.5。这是我按公式算出来的机制演示。它不是图映的行为。它说明原本「打了折」的白底在公式错了之后完全不打折地贴了上去。

真实前端里最容易踩这个坑的是 WebGL。同批另一组对照在 Chromium 149里做,输入是小狗默认导出的一块含 43,740 个半透明像素的 480×480 裁块。按直通公式合成到黑底后半透明边的参照亮度是 54.9。

合成方式 半透明边亮度 与公式的差
canvas 2D drawImage 54.9 最大 0
WebGL 不预乘上传 + SRC_ALPHA 与 ONE_MINUS_SRC_ALPHA 54.9 平均 0.2
WebGL 预乘上传 + ONE 与 ONE_MINUS_SRC_ALPHA 54.9 平均 0.2
WebGL 不预乘上传 + ONE 与 ONE_MINUS_SRC_ALPHA 108.9 平均 54.6
WebGL 预乘上传 + SRC_ALPHA 与 ONE_MINUS_SRC_ALPHA 48.8 平均 6.3

WebGL 上传纹理时有一个开关 UNPACK_PREMULTIPLY_ALPHA_WEBGL,决定上传时要不要替你把 RGB 乘上 alpha。混合函数 blendFunc 的第一个参数决定合成时要不要再乘一次。两处各管一次乘法。必须恰好乘一次。不预乘上传配 ONE 是一次都没乘,也就是上面「把直通当预乘」的情况。边缘亮度 108.9。这差不多是正确值的两倍。

预乘上传再乘一次会偏暗

反过来,预乘上传配 SRC_ALPHA 会让 RGB 被乘两次 alpha。边缘偏暗到 48.8,平均比公式低 6.3。偏暗在黑底上不如偏亮扎眼。换到浅色底上就会变成一圈发脏的暗边。两种配错方向相反,根子是同一个:没搞清楚手里的数据是直通还是预乘,公式就跟着猜。

只要配对两种组合结果都对,平均 0.2 的差来自浮点取整,选哪一组都可以。我个人倾向预乘上传配 ONE。理由是第 2 章说的插值问题:纹理一旦要缩放或做 mipmap,预乘数据插值不会串色,直通数据会。

canvas 2D合成没有偏差

浏览器的 canvas 2D 在这组对照里是对的。drawImage 到黑底上的半透明边亮度 54.9,和公式的最大差是 0。浏览器知道 PNG 是直通存法,解码和合成时替你做了正确的乘法。在透明画布上 drawImage 之后用 getImageData 读回,半透明像素的 RGB 与文件原值误差也是 0。alpha 小于 32 的像素也一样,alpha 本身不变。这只代表 Chromium 149这一个版本,Firefox 和 Safari 没测。

这组数说明一件事。用 <img>、CSS 背景或者 canvas 2D 时看到的白边就是文件里本来那份约 11 的白边,前端没有额外添乱。在 WebGL、WebGPU 或者自己写的像素循环里合成时,如果看到的边比别人亮一倍,先查上传方式和混合公式。

8 这一圈白边该怎么处理

先看主体里有没有接近底色的部分

去色边能不能开,取决于主体本身有没有和原背景相近的颜色。小狗这类以深色或中间色为主、白色部分很少的主体开了之后白边基本消失,代价是个别交界处的锯齿。带白斑的叶子、白色瓶盖、浅色织物这类主体开了之后会被压出洞。玻璃和薄纱这类本身半透明的主体开了之后光晕会变成台阶硬边。

我现在的做法是先把默认导出贴到目标底色上看一眼,再看主体里有没有接近原背景的大块区域。没有就开去色边。有就不开,接受那一圈约 11 的白边或者只在白边最明显的局部用画笔修。拿不准的时候两版都导出来贴到真实要用的底色上比,这比什么理论都快。

边缘收缩和柔化帮不上大忙

精修区里还有边缘收缩和蒙版柔化两项,看起来也能处理白边。同批的补充实测把它们都试了一遍,结果不太乐观。边缘收缩的滑杆范围是 -4 到 4,正数往里收、负数往外扩。

小狗 半透明原色亮度 黑底实际 / 应有 差
默认 160.1 75.4 / 64.3 11.1
收缩 +1 139.9 71.0 / 62.6 8.4
收缩 -1 162.5 81.7 / 65.9 15.8
收缩 -2 173.1 89.7 / 69.9 19.8
柔化 1 px 150.3 74.9 / 63.2 11.7
柔化 3 px 155.3 72.8 / 59.4 13.4
去色边 118.1 76.0 / 75.6 0.4

收缩 +1 对小狗略有改善,差从 11.1 降到 8.4。往外扩更白。-2 时差到了 19.8。柔化基本无效。3 px 时差反而变成 13.4。这些结果用第 4 章的式子都能解释:收缩和柔化只是把 alpha 的边界往里推、往外推或者抹平,RGB 里那份白底一点没少。往里收会切掉最外面那圈最白的像素,往外扩会把更多白底拉进来,柔化则是把白底摊得更开。植物图上收缩和柔化的差都在 11.2 到 11.6 之间。玻璃瓶这类本身半透明的主体收缩和柔化都去不掉光晕。

合成端先对齐存法再谈修边

在前端自己合成时先做的事情不是修边,是确认存法。PNG 和 WebP 解出来是直通。WebGL 纹理上传要么不预乘配 SRC_ALPHA,要么预乘配 ONE,二选一。自己写像素循环时直通数据要先乘 alpha 再加背景。这一步对了边缘上就只剩文件本身那份白边。这一步错了修边工具怎么调都只是在错的底子上补。

我会在项目里放一张已知半透明边亮度的固定裁块,每次改渲染管线都贴到黑底上读一次像素。和 canvas 2D 的结果差一倍就是漏乘,偏暗就是多乘。这个检查比肉眼看靠谱得多。偏暗那种情况肉眼很容易放过。

9 适用边界与风险提示

先说样本。两张图都是 Wikimedia Commons 上的白底图:一张短毛小狗和一根带白斑叶片与玻璃瓶的枝条。白底是白边最典型的来源。灰底、彩色底、复杂背景都没测。背景不是白色时混进边缘的就是另一种颜色,去色边判断「像不像背景」的标准也会跟着变,误伤的对象可能不同。

再说档位。只测了快速档 ISNet INT8。专业档 BEN2 和骨灰级档 BiRefNet HR-Matting 都没测。更精细的模型可能给出更准的 alpha。按第 4 章的推理只要导出时 RGB 还是原图像素,半透明边里就还带着原背景,这一点换模型不会变。这是推理不是实测。我没有数据支持它在别的档上成立。

第 3 章和第 5 章里所有关于「它内部怎么做」的判断都是从导出文件反推的。预乘往返的猜测和去色边重估透明度的猜测都可能和真实实现不一致。数据本身可以复核。解读是我的。去色边在这两张图上主要改 alpha,不代表它在所有图上都不改颜色。

浏览器只测了 Chromium 149,真 Chrome、Safari、Firefox 都没测。WebGL 那组对照在测试环境里可能走的是软件渲染,真实显卡上的取整可能略有不同。配错时亮一倍或暗一截的方向不会因为换显卡就变。

最后是风险。去色边会改变 AI 原始边缘。界面旁注也写了这一点。导出之后被压成全透明的像素在文件里 alpha 就是 0,后续用别的工具处理也找不回来。精修区里有「恢复 AI 原始透明通道」,导出之前可以撤回。导出之后能不能恢复取决于你手上还有没有默认版的文件。我一般两版都留着。

10 测量方法自身的坑

应有亮度只是近似值

「应有亮度」是我用贴边 3 px 以内的不透明像素平均色当作主体色估算出来的。真实的主体色每个位置都不一样,毛尖和毛根、叶脉和叶缘颜色都不同。这个估算只适合比较默认和去色边两版的相对差,不能当作精确的真值。

它在植物去色边版上会失效。去色边大面积改写了 alpha,原本半透明的叶片区域有一部分变成了全透明,贴边不透明像素的构成也变了,估算出来的主体色就不再是同一个参照。植物去色边版的差 15.5 看起来比默认的 11.6 还大,原因就在这里。我没有把它当成「去色边让植物更白」的证据。

逐像素对比要先确认解码器一致

第 3 章拿导出文件和原图逐像素比有一个前提。我用 Pillow 解出来的原图 JPEG,必须和浏览器里解出来的是同一组像素。JPEG 解码的不同实现在色度上采样和取整上会有细小差别。解码器一旦不一致不透明区就不会 100% 相等,后面所有「RGB 等于原图」的结论都站不住。

我没有假设它一致。结论是用结果验证的:两张图的不透明像素都 100% 完全相同,说明至少在这两张图上两边解码结果一致。换一张图或换一个解码库这一步要重新验。

亮度分档里混着背景残留

第 5 章那张亮度分档表里,植物最亮一档的平均下降只有 11.1。初看会以为去色边对最白的像素反而手下留情。原因前面说过:这一档里大部分是 alpha 本来就只有个位数的背景残留,它们的下降量上限很低,平均值被拉了下来。按平均值读表会被带偏。

更稳的读法是看「下降超过 64 的占比」或者按 alpha 和亮度两个维度一起分档。我这次只做了亮度一个维度。后者没做。

测试环境的WebGL可能不走显卡

WebGL 那组对照是在 Chromium 149里跑的。这个环境下 WebGL 可能走软件渲染,不走真实的 GPU。软件渲染和硬件渲染在混合时的取整精度不一定相同,配对组合那平均 0.2 的差在真显卡上可能是别的数。

我认为结论的方向不受影响,漏乘一次就是亮一倍,多乘一次就是偏暗,这是公式层面的事。具体数值要当成「这一个环境里的数」来读。

11 还没解决的

第一个是那组 127、16、4、1。两张图四档的最大偏差完全相同,形状和 8 位预乘往返误差一致,可同批另一组测试里 canvas 2D 读回的误差是 0。取整到底发生在导出链路的哪一步我从文件上看不出来。

第二个是去色边里那几千个 alpha 变大的像素,小狗 8,751 个、植物 6,308 个。如果去色边只是「像背景就压低」,这些像素不应该存在。它们可能来自某种平滑或形态学处理,也可能是别的逻辑,我没有拆开看。

第三个是换档位。专业档和骨灰级档给出的 alpha 更精细之后半透明边会变窄还是变宽?白边会轻还是重?去色边的误伤会不会少?这一轮都没有数。

手上正好有一张白底抠出来的透明图的话,可以先贴到纯黑底上截一块边缘放大看。再用任意图像库把半透明像素的 RGB 和原图同位置的像素比一下。相同的占多数就说明白边来自原底色,换格式没用。这时候再按主体里有没有接近底色的部分决定开不开去色边。

参考资料

  • PNG Specification(W3C):alpha 通道按非预乘存储的规定。
  • WebP Container Specification:WebP 中 alpha 数据与 RGB 的关系。
  • Porter 与 Duff:Compositing Digital Images(SIGGRAPH 1984)。预乘 alpha 与合成算子的来源。
  • Smith 与 Blinn:Blue Screen Matting(SIGGRAPH 1996)。抠图方程与背景色溢的经典表述。
  • W3C Compositing and Blending Level 1:浏览器合成公式。
  • HTML Living Standard 的 canvas 章节:getImageData 与 putImageData 在预乘存储下可能丢失精度的说明。
  • WebGL 1.0 Specification:UNPACK_PREMULTIPLY_ALPHA_WEBGL 与 blendFunc。
  • Qin 等:Highly Accurate Dichotomous Image Segmentation(ECCV 2022)。ISNet 的出处。
  • ITU-R BT.709:本文亮度加权系数的来源。
  • 样本一:Puppy Dog on White。作者 George Hodan,来自 Wikimedia Commons,CC0。
  • 样本二:Ficus cuttings with roots in a bottle, White background。作者 Biusch,来自 Wikimedia Commons,CC BY-SA 3.0。
相关推荐
feasibility.1 小时前
BeefTV:数据留在本地的 AI 视频工作台,和 ComfyUI 分工不同
人工智能·音视频
正经教主1 小时前
【FDE系列】阶段3:Day 72:RAGFlow 平台 — 端到端知识库
人工智能·rag·fde
EatFan1 小时前
AI Agent 进入工程化下半场:从多智能体编排走向治理、标准化与运行沙箱
人工智能·多智能体·ai agent·开源框架·mcp·agents.md
一木 之林1 小时前
DDPM扩散模型代码实战:前向加噪造数据、U-Net噪声预测与反向生成手写数字
人工智能·算法·计算机视觉
弹简特1 小时前
【Java项目-企悦抽】17-抽奖模块02-抽奖接口实现
java·开发语言·状态模式·springboot
foenix661 小时前
AI 程序化建模:用数学构建一朵蘑菇云
人工智能
IvorySQL1 小时前
PostgreSQL 日报| relchecks 溢出导致表无法删除(9 月 28 日)
数据库·人工智能·ai·postgresql·区块链
workflower1 小时前
世界模型的内涵与发展
人工智能·机器人·云计算·无人机
9i编程1 小时前
1. 教 AI 上班:带出我的数字同事 —— 把开发习惯交给 Qoder,从零搭脚手架
人工智能·openai·ai编程