把抠好的透明 PNG 缩成小图再放回原尺寸,边缘会出现两种毛病。一种是发软:原来利落的轮廓糊成了一圈渐变。另一种是发白或者发暗:深色衣服的边上多出一条亮线。这两种毛病长在同一个位置,看上去像一件事。量一下才发现来源完全不同,修法也不一样。
这篇文章用图映 ImgIng 抠了三张公开样图。每张缩小 4 倍再放大 4 倍,人像和毛发另外做了 8 倍。缩放用同一套参数各做一遍预乘和非预乘,再拿浏览器画布和 sharp 的默认行为来对照。读完你能说清发软为什么必然发生,也能说清亮边和暗边为什么只在某种做法下出现。拿到自己的抠图 PNG,能照着第八章查一遍它会不会脏。
摘要。边缘发软来自插值本身。缩小是面积平均,放大是线性插值。透明度的过渡带因此变宽,预乘还是非预乘都一样。这批样图上两种做法得到的 alpha 通道完全相同,最大差 0。亮边和暗边来自另一件事:非预乘缩放把低透明度像素里存着的颜色按面积平均进了边缘。人像样图 4 倍往返后边缘带里非预乘的平均误差是 2.991 级而预乘是 0.025 级。非预乘有 13.83% 的边缘像素差超过 8 级。毛发样图 4 倍时只有 0.12%,那张图低透明处存的颜色接近黑色。浏览器画布的 drawImage 和 sharp 的默认缩放都更接近预乘。createImageBitmap 选不预乘和手动拆开通道缩放才会走非预乘。
版本声明。测试做于 2026-10-30。机器是 Apple M4 和 macOS 26.5.2。浏览器是桌面 Chromium 149.0.7827.55。sharp 是 0.35.5(libvips 8.18.7)。Node 是 v24.14.1。预乘与非预乘的往返对比用 Python 3.9、Pillow 11.3.0 和 numpy 写成。缩小用 BOX 面积平均,放大用 BILINEAR 线性插值。抠图用图映的 AI 智能抠图快速档导出 PNG,结果是这台电脑上的单次运行。文中的误差数字都是这几张样图、这台电脑、单次测量得出的。换图或换版本要重测。
适用边界。结论只对三张样图成立:一张人像、一张商品图和一张毛发图,每类只有一张。主表只用了一个缩放核组合,也就是 BOX 缩小加 BILINEAR 放大。canvas 和 sharp 只验了各自的默认设置。Safari 和 Firefox 的画布没测。抠图只测了长边 2000 像素以内的输出。我没有拿到别家抠图工具的输出,文中不比较任何抠图工具的好坏。只讲缩放这一步怎么处理边缘。
文章目录
一、抠图缩小再放大后边缘出了什么问题
- 边缘有两种毛病不能混着修
- 同一张人像上差别一眼能看出来
二、透明像素里到底存着什么颜色
- 每个像素除了透明度还带着一份颜色
- 三张样图低透明处存的颜色各不相同
- 全透明的地方存的是黑色
三、边缘发软是从哪来的
- 插值把过渡带拉宽了
- 两种缩放的透明度完全相同
- 发软和脏色可以分开验证
四、脏色是怎么被平均进边缘的
- 手算两个像素就能看明白
- 预乘让透明像素的颜色不起作用
- 预乘的代价是最后要除回去
五、真实人像上两种缩放差多少
- 人像边缘带的平均误差差了一百多倍
- 放大倍数越大差得越多
- 商品图的误差比人像小一些
六、为什么毛发那张图几乎看不出差别
- 低透明处存的是黑色时偏出来的是暗边
- 毛发在四倍缩放下只有极少像素偏色
七、浏览器和 sharp 默认走哪一条
- 画布存不住全透明处的颜色
- createImageBitmap 可以选不预乘
- sharp 默认的结果接近预乘
- 想要非预乘必须先拆开通道
八、自己的项目里怎么查怎么避
- 先看透明处存着什么颜色
- 缩放前后都要放在深浅两种底上看
- 抠图工具的边缘选项要分开看
九、测量方法自身的坑
- 预乘那一行接近零是定义出来的
- 不同缩放核之间不能比绝对值
- 极小例对 sharp 说明不了问题
- 样本和版本都很有限
十、适用边界与风险提示
- 结论只对这三张样图成立
- 方向取决于透明处存的颜色
- 不要把预乘当成万能解
十一、还没解决的问题
- 导出文件为什么存背景色没弄清
- 其他浏览器和缩放核没测
- 八位量化的损失没单独量
参考资料
一、抠图缩小再放大后边缘出了什么问题
先把现象钉死。抠图工具导出的 PNG 是带透明通道的 RGBA 图。主体里面 alpha 是 255,背景里是 0。只有边缘一圈是 1 到 254 的中间值,这一圈叫半透明边缘。发丝、衣领、鞋面的绒毛都在里面。整张图缩小再放大,处理的是所有像素。可是只有这一圈的结果会被眼睛注意到,因为别的地方要么是纯色要么是纯透明。
缩小再放大这个动作在项目里不算少见。比如先存一份缩略图当预览,页面在高分辨率屏幕上又把它拉大显示。或者同一张抠图经过好几道处理,每一道都顺手缩放一次。每多缩放一次,边缘带就多被平均一次。这篇用「缩小 4 倍再放大 4 倍」当往返的标准动作,是想把几次缩放的效果集中到一次里看。
边缘有两种毛病不能混着修
第一种毛病是轮廓变软。原本一个像素宽就切换的边界,缩放之后变成了好几个像素宽的渐变。第二种毛病是颜色不对。边缘比主体更亮或者更暗。把图放在深色背景上,原本没有的一圈浅色线就冒出来了。这两种毛病在截图里常常同时出现,很容易被当成一件事去修。
我先说结论,后面几章一件一件证明。发软来自插值:图缩小再放大边缘必然变宽,怎么处理颜色都躲不掉;它只能靠少缩小、少来回缩放来减轻。缩放核本身也会影响坡的宽度,这篇没有比较各个核。偏亮或偏暗来自颜色的算法,只在把 R、G、B 当成独立通道直接平均时才出现。后者可以完全避开。
值得分开讲,是因为两件事对应的排查动作不同。发软要去调缩放比例和缩放次数。发白或发暗要去看透明像素里存着什么颜色和缩放时有没有先乘 alpha;要是把亮线当成发软的一部分去调缩放核,方向就偏了。
同一张人像上差别一眼能看出来
下面这张图是同一张人像抠图的局部,放大 6 倍看。三栏从左到右是抠图导出的原图、非预乘缩放和预乘缩放;缩放都是缩小 4 倍再放大 4 倍。上排垫深灰底,下排垫白底。
这张图要看上排。左栏原图里带状区域的两侧各有一条细而窄的浅色线。中栏非预乘缩放之后这两条浅线变宽了,颜色也更亮。右栏预乘缩放的同一位置看不到浅线,带状区域的边界只是变软了。下排白底上三栏的差别小得多。两种缩放的过渡都变宽了而浅色线落在白底上本来就不容易看出来;这是我的判断,没有单独验证。样本取自维基共享资源(CC 许可),人像作者是 Dmitry Makeev。图里只放局部,没有放整脸。
图底部那句「两种缩放的 alpha 完全相同」是这篇文章的主线。第三章会用数据把它坐实。现在先看一个更基础的问题:边缘像素里面到底存着什么。
二、透明像素里到底存着什么颜色
非预乘缩放的问题要从 PNG 怎么存像素说起。容易想当然地把半透明像素的颜色当成「主体颜色」。其实不一定。它存的是导出工具写进去的一组 RGB,工具想写什么就能写什么。
每个像素除了透明度还带着一份颜色
PNG 的 RGBA 模式里,每个像素有四个数:红、绿、蓝和 alpha;alpha 是 0 到 255 的整数。255 完全不透明,0 完全透明。直觉上 alpha 是 0 的像素没有颜色。可实际文件里它仍然占着三个字节的 RGB,写成什么由导出程序决定。
这一点平时没有后果,因为合成时 alpha 为 0 的像素颜色乘以 0 根本不起作用。后果出现在缩放的时候。只要缩放算法把相邻像素当成数字去平均,这三个字节就会混进旁边有颜色的像素里。要先知道你手里的 PNG 在低 alpha 的地方存着什么。
三张样图低透明处存的颜色各不相同
我用的是图映 ImgIng imging.cn/ 的 AI 智能抠图,在电脑浏览器里选快速档(ISNet INT8)导出 PNG。页面状态条写着「默认逐像素直接使用 AI 输出,不改透明度、不改边缘」。导出只做格式转换,不会再跑一次模型。抓包看到的请求里只有模型文件下载,没有携带图片的请求。三张样图都是维基共享资源上的 CC 授权图片,我把长边缩到 2000 像素以内再用。
| 样图 | 来源与许可 | 本次尺寸 |
|---|---|---|
| 人像:发丝、深色衣服、白墙 | Russia. Young women.jpg,Dmitry Makeev,CC BY-SA 4.0 | 1330×2000 |
| 商品:鞋,浅灰渐变背景 | Michael Kors Jolie Korkwedge 40R4JLHA1S Veloursleder taupe (1),SPERA.de,CC BY 2.0 | 2000×1325 |
| 毛发:白色卷毛小狗,车座背景 | Cheerful Bichon Frise.jpg,TudorTulok,CC BY-SA 4.0 | 2000×2000 |
三张图抠完输出和输入同尺寸,都是 RGBA。半透明像素(alpha 在 1 到 254 之间)占整张图的比例,在快速档下是毛发 8.3%、人像 4.1%、商品 4.9%。数量不多。可是缩放的所有风险都挤在这几个百分点里。
专业档(BEN2 FP16)我也各跑了一遍,输出同样是和输入同尺寸的 RGBA。它的半透明像素占比是人像 4.7%、商品 3.2%、毛发 6.3%。和快速档比,人像略多一点而商品和毛发少一些;后面所有缩放对比用的都是快速档的导出。专业档的边缘只拿来做局部放大的观察,没有放进缩放表。文中的误差数字对专业档不一定成立。这一点放到适用边界里再说。
接着去看这些半透明像素里存了什么。我把 alpha 在 1 到 5 之间的像素单独拿出来求平均 RGB;这些像素几乎全透明,颜色基本不会被眼睛看到。但缩放时它们会被拖进邻居。毛发图的平均值是 (30,31,32),偏黑。人像图是 (221,218,219),偏白。商品图是 (230,224,218),也偏白。
这三个数能和样图对上。人像背景是白墙、商品背景是浅灰、毛发背景是深色车座。低 alpha 像素里存着的,看上去就是这张图原来的背景色。这是现象上的观察。导出程序为什么这么写是模型输出之后保留了原背景还是别的原因我没有查;图映内部怎么做的我也不清楚。
全透明的地方存的是黑色
alpha 等于 0 的那些像素是另一回事。三张图里全透明处存的 RGB 全是 (0,0,0),导出的文件里没有第二种值。同一张导出图里存在两类颜色:完全透明处是黑,几乎透明但不是 0 的地方是原背景色。
缩放的时候这两类颜色一起参与平均。毛发图里背景本来就是深色,两类颜色相差不大平均进去也不显眼。人像和商品图就不同。几乎透明的像素存的是接近白色而旁边全透明的像素存的是黑。平均出来是灰。这层灰再被拖进边缘,就是后面几章要拆的东西。
三、边缘发软是从哪来的
先拆第一件事。发软不需要任何颜色参与,光看 alpha 一个通道就能解释。
插值把过渡带拉宽了
缩放有两步。缩小用 BOX,也就是面积平均:一个小像素的值是原图里对应那一块像素的平均。放大用 BILINEAR,也就是线性插值:一个大像素的值由附近最近的两个小像素按距离加权得到。
拿一维的小例子看。一排 16 个像素的 alpha 里前 6 个是 255、后 10 个是 0。边界是一步切换,没有任何中间值。缩小 4 倍,BOX 把它压成 4 个值:255、127.5、0、0。中间那个 127.5 是 255、255、0、0 的平均。再放大 4 倍,线性插值把 4 个值拉回 16 个:255、255、239.1、207.2、175.3、143.4、111.6、79.7、47.8、15.9、0、0 一路排下去。一步切换的边界变成了跨越 8 个像素的缓坡。
这个小例子是我为了讲清楚算出来的,不是样图的实测。道理和样图一样。缩小这一步已经把边界的位置信息平均掉了,放大只能用插值把丢掉的东西补成光滑的坡;这个例子里坡宽是缩小倍数的两倍,倍数越大就越宽。没有哪种缩放核能凭空把已经平均掉的信息找回来。
两种缩放的透明度完全相同
主表里有一条我认为是整篇最重要的数据:同一张图用同一套缩放时,不管走预乘还是非预乘,alpha 通道的结果完全一样。各方法之间的最大差是 0。
道理在于预乘只动 RGB。预乘的做法是先把 R、G、B 各自乘上 alpha 再缩放;alpha 本身仍旧单独缩放,走的是同一条路。两种做法下 alpha 是同一组数,过渡带的宽度当然也一样。人像、商品、毛发三张图的 4 倍和 8 倍,每一种组合都是这个结果。
换句话说,在这个对比里发软是预乘不预乘都躲不掉的东西。有人看到边缘变软就想改成预乘,期望它更利落。这个期望会落空。预乘管不了发软。它管的是下一件事。
发软和脏色可以分开验证
两件事分开验证并不麻烦,只要读缩放前后的 alpha 通道。想知道发软有多严重,就量过渡带的宽度也就是 alpha 在 1 到 254 之间的像素数。这不涉及颜色。想知道脏色有多严重,就把缩放结果合成到深色底和浅色底上再和一份干净的基准去比;这不涉及 alpha 的宽度。
分开之后调参的方向也清楚了。发软可以靠少缩小、少来回缩放去减轻。脏色可以靠换算法彻底去掉。第四章先把脏色从哪来讲清楚。
四、脏色是怎么被平均进边缘的
接下来拆第二件事。这是真正有办法根治的那一件。
手算两个像素就能看明白
假设相邻两个像素。A 是不透明的深红 (200,50,50,255)。B 是几乎透明的边缘像素,alpha 只有 10 而里面存的是背景白 (255,255,255,10)。缩小时把它们平均成一个。
import numpy as np
# 一个深红的主体像素,旁边是一个 alpha=10 的边缘像素,里面存的是白色背景
a = np.array([200, 50, 50, 255], dtype=float)
b = np.array([255, 255, 255, 10], dtype=float)
# 非预乘:四个通道各自取平均
straight = (a + b) / 2
print("非预乘平均 :", straight)
# 预乘:先把颜色乘上 alpha,平均之后再除回去
pa = np.append(a[:3] * a[3] / 255, a[3])
pb = np.append(b[:3] * b[3] / 255, b[3])
mix = (pa + pb) / 2
back = np.append(mix[:3] * 255 / mix[3], mix[3])
print("预乘平均 :", np.round(back, 1))
非预乘平均 : [227.5 152.5 152.5 132.5]
预乘平均 : [202.1 57.7 57.7 132.5]
代码说明:a 和 b 是两个 RGBA 像素。straight 是不管 alpha 的做法,四个通道各自平均。pa 和 pb 先把 RGB 乘以 alpha 再除以 255,也就是把颜色按透明度折成「有多少颜色真正落在这个像素上」。mix 是预乘后的平均,最后一行再除回 alpha 还原成普通 RGBA。两种做法算出来的 alpha 都是 132.5。差别全在颜色。
读法如下:非预乘平均得到的颜色是 (227.5,152.5,152.5),一个偏粉的颜色。可是 B 的 alpha 只有 10,它在这个新像素里只该占很小的分量。几乎全透明的像素不该把粉白色贡献给边缘。预乘平均得到 (202.1,57.7,57.7) 几乎还是 A 的深红;B 的白色被它自己的小 alpha 压得没剩多少。这个例子是手算,不是样图实测。样图上发生的是同一件事,只是规模大了很多倍。
预乘平均还有一个更好记的说法:它就是用 alpha 当权重的加权平均。把上面的例子代进去,红色通道是 (200×255 + 255×10) ÷ (255 + 10) 等于 202.1。绿色和蓝色通道同理得到 57.7。权重大的像素说话算数,权重小的像素几乎不起作用。这正是我们对半透明边缘的直觉:越透明的东西,对最后看到的颜色贡献越小。非预乘平均等于每个像素权重一样,不管 alpha 是 10 还是 255。这个前提和直觉不符。
预乘让透明像素的颜色不起作用
预乘的关键是 R、G、B 先乘上 alpha。B 的 alpha 是 10,乘完颜色只剩 (10,10,10) 几乎没有分量;如果 alpha 是 0,乘完就是 (0,0,0)。它原来存的是白是黑还是别的什么,结果都一样。
这就是预乘缩放的一个好性质:结果和透明处存了什么颜色无关。工具爱写白也好爱写黑也好,预乘缩放之后都得到同一张图。非预乘做不到这一点。它的结果直接取决于透明处存的颜色:存白色就出亮边,存黑色就出暗边。这条方向是我对数据的解释,没有再用第二套样本验证。
预乘的代价是最后要除回去
预乘缩放不是白拿的。缩放完你拿到的是预乘形态的 RGB。要显示或存回普通 PNG,还得把 RGB 除以 alpha 还原;alpha 很小的像素除下来会放大误差。代码里要对 alpha 接近 0 的地方做保护,不能除以零。
好在这个代价只在最后一步,缩放过程本身不需要额外信息。浏览器画布内部是预乘存储(第七章会看到),用起来感觉不到这一步。第七章会看到,默认行为决定了它们走哪条路。
五、真实人像上两种缩放差多少
上一章是两个像素的玩具例子,这一章换成整张图。
人像边缘带的平均误差差了一百多倍
做法是这样。拿图映快速档导出的 PNG 缩小 4 倍再放大 4 倍,分别走非预乘和预乘各一次。基准是同一份 alpha 配上原照片的颜色做预乘缩放,也就是一份没有被背景色污染的理想结果。比较时把缩放结果和基准都合成到白底上,只统计边缘带:原图 alpha 在 1 到 254 的像素向外膨胀 4 个像素。人像这张图的边缘带有 167,713 个像素。
import numpy as np
from PIL import Image
K = 4
png = np.asarray(Image.open("cutout.png").convert("RGBA"), dtype=np.float32)
jpg = np.asarray(Image.open("photo.jpg").convert("RGB"), dtype=np.float32)
h, w = (png.shape[0] // K) * K, (png.shape[1] // K) * K
png, jpg = png[:h, :w], jpg[:h, :w]
mask = png[..., 3]
def scale_plane(plane, wh, kernel):
return np.asarray(Image.fromarray(plane).resize(wh, kernel), dtype=np.float32)
def down_up(rgba, use_premul):
al = rgba[..., 3]
weight = al / 255 if use_premul else 1.0
planes = [rgba[..., c] * weight for c in range(3)] + [al]
planes = [scale_plane(scale_plane(p, (w // K, h // K), Image.BOX), (w, h), Image.BILINEAR) for p in planes]
if use_premul:
for c in range(3):
planes[c] = np.where(planes[3] > 0.5, np.clip(planes[c] * 255 / np.maximum(planes[3], 1e-6), 0, 255), 0)
return np.stack(planes, axis=-1)
def on_white(rgba):
k = rgba[..., 3:4] / 255
return rgba[..., :3] * k + 255 * (1 - k)
truth = on_white(down_up(np.dstack([jpg, mask]), True))
ring = (mask > 0) & (mask < 255)
for _ in range(K):
q = np.pad(ring, 1)
ring = q[1:-1, 1:-1] | q[:-2, 1:-1] | q[2:, 1:-1] | q[1:-1, :-2] | q[1:-1, 2:]
for label, flag in (("非预乘", False), ("预乘 ", True)):
err = np.abs(on_white(down_up(png, flag)) - truth)
print(label, "平均误差 %.3f 最大 %.1f 差>8级占比 %.2f%%" % (
err[ring].mean(), err[ring].max(), (err.max(axis=-1)[ring] > 8).mean() * 100))
非预乘 平均误差 2.991 最大 46.9 差>8级占比 13.83%
预乘 平均误差 0.025 最大 0.4 差>8级占比 0.00%
代码说明:down_up 是整个对比的核心。use_premul 为真时 R、G、B 先乘 al / 255 再缩放,缩完用放大后的 alpha 除回去;alpha 太小的地方直接置 0 防止除零。truth 把原照片的颜色和导出图的 alpha 拼在一起,当作没被背景污染的基准;on_white 把结果合成到白底上再比较。ring 先取 alpha 在 1 到 254 的像素,再向外膨胀 K 个像素。最后三项分别是边缘带平均绝对误差、最大误差和与基准相差超过 8 级的像素占比,单位是 0 到 255 的灰度级。
结果很直白:非预乘的边缘带平均误差是 2.991 级,最大 46.9 级。13.83% 的边缘像素与基准相差超过 8 级;预乘是 0.025 级,最大 0.4 级。相差超过 8 级的像素是 0。两个平均误差的比值大约一百二十倍。
换成像素个数更直观一些。人像的边缘带有 167,713 个像素,13.83% 大约是两万三千个。这些像素不是均匀散在整张图上,而是沿着人物轮廓排成一圈。发丝、肩膀、衣领的边上最密。肉眼看到的那圈浅线,就是这两万多个像素连起来的结果。
读这组数要小心一点。预乘的结果几乎是 0,一部分原因是基准本身就是预乘缩放。预乘缩放的结果又和透明处存的颜色无关,所以和基准比自然几乎一致。这张表真正的含义是「非预乘比预乘多出来多少脏色」,不是「预乘比真值准多少」。第九章还会再讲。
再看具体像素。人像第 1300 行 x=838 到 847 这一段,原图 alpha 在 1 到 5 的像素 RGB 是 255。非预乘缩放之后 alpha 变成 15 到 69 的像素,RGB 变成 195 到 244。偏白的一圈就是这么来的。预乘缩放之后同位置的 RGB 是 76 到 113,保持着深色衣服的颜色。
放大倍数越大差得越多
我又把人像和毛发缩到 8 倍再放大。人像在 8 倍时小图是 166×250。非预乘的边缘带平均误差是 3.879 级,最大 53.5 级。19.27% 的边缘像素差超过 8 级。预乘是 0.014 级,最大 0.3 级。差超过 8 级的占 0.00%。毛发 8 倍的数放在第六章细讲。
| 样图 | 缩放 | 小图尺寸 | 非预乘:平均误差 / 最大 / 差超 8 级占比 | 预乘:同三项 |
|---|---|---|---|---|
| 人像 | 4 倍 | 332×500 | 2.991 / 46.9 / 13.83% | 0.025 / 0.4 / 0.00% |
| 人像 | 8 倍 | 166×250 | 3.879 / 53.5 / 19.27% | 0.014 / 0.3 / 0.00% |
| 商品 | 4 倍 | 500×331 | 1.220 / 37.6 / 6.57% | 0.023 / 0.4 / 0.00% |
| 毛发 | 4 倍 | 500×500 | 0.505 / 15.1 / 0.12% | 0.043 / 0.5 / 0.00% |
| 毛发 | 8 倍 | 250×250 | 1.560 / 26.9 / 4.98% | 0.026 / 0.4 / 0.00% |
人像从 4 倍到 8 倍,差超过 8 级的像素从 13.83% 涨到 19.27%。毛发涨得更猛,从 0.12% 涨到 4.98%。倍数大了,缩小那一步平均进来的邻居更多。透明处的颜色渗得更远,边缘带里被污染的像素就多。这两张图上趋势一致。商品图只测了 4 倍没有测 8 倍,对它不下这个结论。
预乘一列的数很稳。不管倍数怎么变,都在 0.014 到 0.043 之间。因为它的结果本来就不依赖透明处存的颜色,倍数变化带来的只是插值本身的量化误差。
商品图的误差比人像小一些
商品图 4 倍的非预乘边缘带平均误差是 1.220 级,最大 37.6 级。6.57% 的边缘像素差超过 8 级,不到一成。比人像的 2.991 级和 13.83% 小一半以上。
商品图低透明处存的是 (230,224,218)。和人像的 (221,218,219) 差不多,同样偏白。商品图的半透明像素占比是 4.9%,和人像的 4.1% 也差不多。差别可能在背景反差上。人像背后是深色衣服和黑发,和白色反差大。商品背景是浅灰而鞋面颜色也不深,反差小。被污染的像素就没有那么刺眼,统计上超过 8 级的占比也小了。这条是对数据的解释,没有再拿别的商品图验证。
六、为什么毛发那张图几乎看不出差别
毛发这张图是一个反例。它让我没办法把结论说成「所有 PNG 缩小都会脏」。
低透明处存的是黑色时偏出来的是暗边
毛发图低 alpha 像素的平均 RGB 是 (30,31,32),接近黑。全透明处也是 (0,0,0)。非预乘缩放把这些颜色按面积平均进边缘,方向是变暗而不是变白。毛发的主体是白色的卷毛。边缘稍微暗一点,肉眼几乎看不出两种缩放的差别。
方向是被透明处存的颜色决定的:存白色就出亮边,存黑色就出暗边。这是我对三张图数据的概括,没有用第四张图验证。它至少说明「缩小会出白边」不能当通用现象。有可能是白的或者黑的,也可能几乎看不出。要看导出工具往透明处写了什么。
毛发在四倍缩放下只有极少像素偏色
毛发 4 倍的结果是非预乘边缘带平均误差 0.505 级,最大 15.1 级。差超过 8 级的像素只有 0.12%。预乘是 0.043 级、0.5 级和 0.00%。差别存在,但小到肉眼分辨不了。
到了 8 倍,非预乘的平均误差涨到 1.560 级而最大是 26.9 级。差超过 8 级的像素占 4.98%。这时候就开始有可见的暗边了。毛发的边缘带像素本来就多。4 倍时有 383,867 个,8 倍时有 433,631 个;人像 4 倍时只有 167,713 个。毛发细碎,半透明像素占比 8.3% 是三张图里最高的。
毛发这个例子提醒我两点。其一,不要只拿一张图下结论。毛发和人像在同一个算法下,差超过 8 级的像素占比差了一百多倍。其二,肉眼看不出不等于没有偏差。4 倍毛发有 0.12% 的像素差超过 8 级。如果后面还有再次放大、套滤镜或叠别的底色这类二次处理,这点偏差可能被放大;这一条也只是推断,我没有测二次处理。
七、浏览器和 sharp 默认走哪一条
第四章到第六章讲的是 PIL 里手写的两种做法。真实项目里更常见的是直接调浏览器画布或 sharp;它们默认走哪条路,决定了你的缩略图会不会脏。
画布存不住全透明处的颜色
先看画布。浏览器的 canvas 内部按预乘存储像素。写进去的透明像素的颜色,读回来可能不是原来的值。写一个最小的例子:8 个像素,左边 4 个不透明红、右边 4 个全透明但存的是蓝。
const px = (d, i) => `(${d[i * 4]},${d[i * 4 + 1]},${d[i * 4 + 2]},${d[i * 4 + 3]})`;
const row = (d, n) => Array.from({ length: n }, (_, i) => px(d, i)).join(" ");
// 8 个像素:左 4 个不透明红,右 4 个全透明,里面存的是蓝
const src = new ImageData(8, 1);
for (let i = 0; i < 8; i++) src.data.set(i < 4 ? [255, 0, 0, 255] : [0, 0, 255, 0], i * 4);
// 写进画布再读回,看透明像素里的蓝还在不在
const c0 = new OffscreenCanvas(8, 1), x0 = c0.getContext("2d");
x0.putImageData(src, 0, 0);
console.log("画布读回 ", row(x0.getImageData(0, 0, 8, 1).data, 8));
// 方式一:drawImage 缩成 5 像素
const c1 = new OffscreenCanvas(5, 1), x1 = c1.getContext("2d");
x1.imageSmoothingQuality = "high";
x1.drawImage(c0, 0, 0, 5, 1);
console.log("drawImage ", row(x1.getImageData(0, 0, 5, 1).data, 5));
// 方式二:createImageBitmap 指定不预乘
const bmp = await createImageBitmap(src, { premultiplyAlpha: "none", resizeWidth: 5, resizeHeight: 1, resizeQuality: "high" });
const c2 = new OffscreenCanvas(5, 1), x2 = c2.getContext("2d");
x2.drawImage(bmp, 0, 0);
console.log("bitmap none ", row(x2.getImageData(0, 0, 5, 1).data, 5));
// 低 alpha 的颜色读回
for (const a of [0, 2, 4, 6]) {
const t = new OffscreenCanvas(1, 1), xt = t.getContext("2d");
xt.putImageData(new ImageData(new Uint8ClampedArray([200, 100, 50, a]), 1, 1), 0, 0);
const d = xt.getImageData(0, 0, 1, 1).data;
console.log(`in (200,100,50,${a}) -> out (${d[0]},${d[1]},${d[2]},${d[3]})`);
}
画布读回 (255,0,0,255) (255,0,0,255) (255,0,0,255) (255,0,0,255) (0,0,0,0) (0,0,0,0) (0,0,0,0) (0,0,0,0)
drawImage (255,0,0,255) (255,0,0,255) (255,0,0,128) (0,0,0,0) (0,0,0,0)
bitmap none (255,0,0,255) (255,0,0,255) (128,0,128,128) (0,0,0,0) (0,0,0,0)
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)
代码说明:第一行输出是把 ImageData 写进画布再读回来。右边四个全透明像素里的蓝变成了 (0,0,0,0),蓝没有留住。第二行是 drawImage 缩成 5 像素,过渡像素是 (255,0,0,128)。纯红带一半透明,没有蓝。第三行是 createImageBitmap 指定 premultiplyAlpha: "none" 之后缩成 5 像素,过渡像素是 (128,0,128,128)。蓝混进来了。后四行把 RGB 固定为 (200,100,50),只改 alpha 为 0、2、4、6 再读回来。
这一段要小心读,因为前两种方式的源不一样。drawImage 的源是画布,蓝在写进画布那一步就已经丢了。缩放时根本没有蓝可混。createImageBitmap 的源是 ImageData,蓝还在。不预乘就把它平均进了过渡像素。这个小例子说明的是两件事。画布存不住透明处的颜色,所以用画布缩放天然不会带来脏色。而一旦路线绕开画布并且指定不预乘,脏色就会回来。
后四行是另一个侧面。alpha 为 2 时,(200,100,50) 读回来是 (255,128,0)。alpha 为 4 时是 (191,128,64),alpha 为 6 时是 (212,85,42)。低 alpha 像素的颜色经过画布之后已经不是原来的值。我的理解是预乘之后要量化成 8 位整数而除回来时误差被放大。这一点没有单独验证。它也解释了一个现象:抠图导出的 PNG 里低 alpha 处的颜色不可靠,不要指望透明处存的颜色能被精确保留。导出的 PNG 里 alpha 为 0 处全是 (0,0,0),和画布读回的结果一致。但导出环节是不是走了画布我没有验证,只能说现象一致。
这里顺便说一句使用上的区别。我的理解是只调用 drawImage 做缩放时像素始终留在画布里按预乘处理,不会带脏色。要是先用 getImageData 把像素读出来自己写循环去平均,读出来的 RGB 已经是画布还原过的值。低 alpha 处的颜色本身就不可靠。在这个基础上再做非预乘平均,既会脏又不准。这一段是推断,没有拿真实图验证。
createImageBitmap 可以选不预乘
createImageBitmap 带 premultiplyAlpha 选项,可以选 none、premultiply 或默认。在人像 4 倍往返里,我把它和 numpy 里手写的两种做法逐个比较。
| 实现 | 与非预乘参考的差 | 与预乘参考的差 | alpha 为 0 处 RGB 非零的像素数 |
|---|---|---|---|
| numpy 非预乘(参考) | 0.0 | 2.947 | 18,416 |
| numpy 预乘(参考) | 2.947 | 0.0 | 4,703 |
| canvas drawImage,quality 为 high | 7.456 | 6.636 | 0 |
| createImageBitmap,premultiplyAlpha 为 none | 6.108 | 5.184 | 0 |
| createImageBitmap,premultiplyAlpha 为 premultiply | 4.85 | 2.689 | 0 |
| sharp 默认(lanczos3) | 11.897 | 10.89 | 0 |
| sharp 手动拆开 RGB 与 alpha(lanczos3) | 11.508 | 11.089 | 35,283 |
这张表不能横着比绝对值。各实现的缩放核不一样:画布有三档质量而 sharp 默认是 lanczos3,numpy 用 BOX 加 BILINEAR。只能看一件事,就是每一行离哪个参考更近。premultiplyAlpha 设成 premultiply 的那一行,与预乘参考的差是 2.689;它比与非预乘参考的差 4.85 小。设成 none 的那一行,与非预乘参考的差是 6.108,与预乘参考的差反而是 5.184,更小一些,所以这个指标下它对不上选项的名字。只有上面那个八像素的小例子里,none 才明显把全透明处存的蓝混进了过渡像素。这一行为什么不靠近非预乘参考,我猜是缩放核差得太远,没有验证。
drawImage 的两个差都很大,分别是 7.456 和 6.636,没有明显偏向任何一边。我对这一行的理解是它走的缩放核和 numpy 差太远,两个参考都靠不近而不好下判断。
sharp 默认的结果接近预乘
sharp 的情况用「半透明像素的 RGB 平均亮度」来判断更清楚。取 alpha 在 10 到 245 之间的像素。numpy 非预乘的平均亮度是 129.48,numpy 预乘是 112.0。sharp 默认是 109.63 接近预乘。sharp 手动把 RGB 与 alpha 拆开缩放是 125.1,接近非预乘。亮度越高,说明被白色拖得越多。非预乘偏亮正是亮边的数字表现。
更干净的对照是同一个库内部的比较。sharp 的 lanczos3 下默认和手动拆开的 alpha 最大差是 0;边缘带 RGB 平均差是 4.218,最大 51.7。换成 linear 核,差是 4.356 而最大 50.3。两种核给的结论一致:alpha 一样,RGB 差很大。
想要非预乘必须先拆开通道
要用 sharp 拿到非预乘的结果,我第一次写错了。直觉上把 alpha 去掉再缩放 RGB 就行,也就是 .removeAlpha().resize()。结果错了:libvips 在缩放之前会按 alpha 做预乘再把 alpha 丢掉,缩出来的 RGB 比期望的暗。分析下来才明白,缩放的那一刻 alpha 还在场并被预乘用掉了。不是缩放时没有 alpha。
改正的做法是先把 RGB 和 alpha 各自导出成单独的 raw 图,分别缩放后再拼回去。
const sharp = require("sharp");
const SRC = "cutout.png", F = 4, ROW = 1300, XS = [838, 840, 842, 844, 846];
const show = (raw, W) => XS.map(x => {
const i = (ROW * W + x) * 4;
return `(${raw[i]}, ${raw[i + 1]}, ${raw[i + 2]}, ${raw[i + 3]})`;
}).join(" ");
(async () => {
const { width: W, height: H } = await sharp(SRC).metadata();
const w = Math.floor(W / F), h = Math.floor(H / F);
// 默认:带着 alpha 一起缩小、放大
const small = await sharp(SRC).resize(w, h).png().toBuffer();
const byDefault = await sharp(small).resize(W, H).raw().toBuffer();
// 手动:RGB 和 alpha 先各自导成单独的 raw 图,分开缩放,再拼回去
const rgb = await sharp(SRC).removeAlpha().raw().toBuffer();
const al = await sharp(SRC).extractChannel(3).raw().toBuffer();
const scale = (buf, ch, fw, fh, tw, th) => {
const img = sharp(buf, { raw: { width: fw, height: fh, channels: ch } }).resize(tw, th);
return (ch === 1 ? img.extractChannel(0) : img).raw().toBuffer(); // 单通道缩完会变三通道,只留第 0 个
};
const rgbBack = await scale(await scale(rgb, 3, W, H, w, h), 3, w, h, W, H);
const alBack = await scale(await scale(al, 1, W, H, w, h), 1, w, h, W, H);
const manual = await sharp(rgbBack, { raw: { width: W, height: H, channels: 3 } })
.joinChannel(alBack, { raw: { width: W, height: H, channels: 1 } }).raw().toBuffer();
console.log("默认 ", show(byDefault, W));
console.log("手动拆", show(manual, W));
})();
默认 (49, 43, 49, 41) (0, 0, 0, 0) (255, 255, 255, 11) (79, 82, 89, 74) (33, 34, 38, 191)
手动拆 (220, 218, 219, 41) (255, 255, 255, 0) (240, 241, 243, 11) (180, 181, 184, 74) (72, 74, 78, 191)
代码说明:byDefault 是 RGBA 整张送进 sharp 缩小再放大。manual 把 RGB 三通道和 alpha 一个通道分别导成 raw 缓冲并分别缩放,最后用 joinChannel 拼回 RGBA。scale 里单通道图缩放完 sharp 会给出三通道的 raw,只留第 0 个通道。不加这一步拼回去的 alpha 全是 0。show 取第 1300 行 x 为 838、840、842、844、846 的五个像素。
读法如下:先看每组数的最后一个数,也就是 alpha。两行完全一致,都是 41、0、11、74、191;这印证了第三章说的 alpha 不受影响。再看 RGB。x=838 这个像素默认是 (49,43,49),手动拆开是 (220,218,219)。一个深色,一个近乎白。x=842 默认是 (255,255,255),手动是 (240,241,243)。这个位置 alpha 只有 11,颜色本身意义不大。x=846 默认是 (33,34,38),手动是 (72,74,78)。alpha 是 191,已经是比较实的像素了。手动拆开的结果亮度是默认结果的两倍多。默认的深色衣服颜色被保住了。手动拆开的那一行亮边,就是第一章图里那条浅线的数值版本。
八、自己的项目里怎么查怎么避
前七章是拆原因,这一章是拿到一张自己的抠图 PNG 之后能做的事。
先看透明处存着什么颜色
第一步读一遍图里 alpha 为 0 的像素的 RGB,看有几种不同的值;这三张导出图里只有 (0,0,0) 一种。别的工具导出的可能是白色或背景色,甚至每个像素各不相同。种类越杂,非预乘缩放时污染的方向越不可预测。
第二步看 alpha 在 1 到 5 的像素平均颜色,也就是几乎透明但不是零的那一圈。这一圈是缩放时被拖进主体边缘的最主要来源,因为它们离有颜色的像素最近。如果它的平均色明显偏离主体边缘本来的颜色就要小心。比如本该是深色衣服的边存了一圈白色。只要你的缩放流程可能走非预乘,就有脏边的风险。
缩放前后都要放在深浅两种底上看
脏边只有在背景和脏色反差大的时候才明显。白色脏色落在白底上看不出来,落在深灰底上才暴露。验收缩略图不能只放在一种背景上。至少在纯黑和纯白两种底色上各合成一次,拿放大镜看边缘。
第一章的图是个例子。同一组缩放结果在上排深灰底上能看到非预乘的浅线,在下排白底上差别小得多。验收时只看了白底就会漏掉。反过来主体是浅色而透明处存的是黑色,要放在浅底上才看得出暗边。
想量化的话可以像第五章那样做。造一份用原照片颜色和同一份 alpha 拼成的基准并用预乘缩放再把你的流程结果和它比,看边缘带平均误差和超过 8 级的像素占比。这个对比不依赖肉眼,换一张图也能直接跑。
把这几步串起来大概是四个动作:读透明处的颜色种类、读几乎透明处的平均颜色、把缩放结果分别放到黑白两种底上看,再按需要跑一次和基准的误差对比。前两步只读不改。后两步才需要写点代码。多数时候做完前三步已经能判断出这条流程有没有问题。第四步是想把它写进自动化检查时才用。
如果你的流程里缩放只发生一次而且走的就是预乘路线,上面这些检查多半用不上。真正需要警惕的是流程里夹着多个环节而自己又看不清每一环默认做了什么的时候。这时先在黑白两种底上各看一眼,比读文档快也比猜靠谱。能直接从原图一次缩放到目标尺寸的,就别先缩成小图再放大回来用。那一圈被平均两次的边缘本来就可以省掉。
抠图工具的边缘选项要分开看
抠图这一步本身也能影响边缘。图映的 AI 智能抠图在结果区有边缘收缩、蒙版柔化和去除背景色边几个选项。页面说明里写着默认逐像素直接使用 AI 输出,只有主动调整后才应用修饰。其中「去除背景色边」默认是关的,旁边注明可选而且会改变 AI 原始边缘。我这次的三张图都是默认状态导出而没有开这些选项。它们对后续缩放有什么影响我没有测。
这里值得记住的是分工。抠图工具里的去色边处理的是抠出来那一刻边缘带着的背景色;缩放流程里的预乘处理的是缩放过程中颜色怎么平均。两处是前后两个环节。开了前一个不等于后一个就不需要管,反过来也一样。
九、测量方法自身的坑
这一章单独把测量方法的毛病摆出来,免得读者把表里的数字读成它不是的东西。
预乘那一行接近零是定义出来的
主表里预乘一列的平均误差只有 0.014 到 0.043,看着像预乘几乎完美。可基准本身就是预乘缩放,用原照片的颜色加同一份 alpha 算出来;预乘缩放的结果和透明处存什么颜色无关。只要你的流程也是预乘,和基准比自然几乎重合。这一列接近零是对比的定义带来的。
那张表真正能读出来的,是非预乘比预乘多出来多少脏色。不能读成「预乘比真实更准」,也不能拿它去证明预乘缩放没有任何误差。预乘自己的 8 位量化误差和 alpha 很小时除回去放大的误差这个对比测不到,见第十一章。
不同缩放核之间不能比绝对值
第七章那张表里,画布、createImageBitmap 和 sharp 用的缩放核各不相同。sharp 用 lanczos3 和 linear而画布分三档质量,numpy 里用 BOX 缩小加 BILINEAR 放大。同样叫缩放,插值的权重不一样发软的程度也不一样。直接比 7.456 和 11.897 谁大谁小没有意义。
能做的是两件事。其一,看每一行离预乘参考近还是离非预乘参考近。其二,在同一个库内部比较默认和手动拆开。这时缩放核相同,差异只来自是否预乘。我在结论里只用这两种读法。
极小例对 sharp 说明不了问题
我还用 8 像素宽的极小例测了 sharp:左 4 个不透明红、右 4 个透明蓝,分别缩成 3、5、7 像素宽并试了两种核。输出里没有出现任何过渡像素,边界始终是硬切;这个例子对 sharp 没有说明任何事。
sharp 的结论只来自真实人像的对照,也就是第七章里五个像素的那一段和平均亮度。教训是:想用极小例验证一个库,得先确认它能产生过渡像素。我猜是例子构造得太规整而采样点正好落在像素边界上,不过原因没有查。
样本和版本都很有限
每类样图只有一张,每个倍数组合只跑了一次而没有重复取均值。误差数字应该读成「这张图这次的值」。浏览器只测了 Chromium 149,sharp 是 0.35.5。换一个版本默认行为是否改变,我没有验证。
边缘带的定义也会影响数字。我取的是 alpha 在 1 到 254 的像素向外膨胀 F 个像素。膨胀得更多,平均误差会被更多不受影响的像素稀释而变小。只取 alpha 在 1 到 254 的像素本身,按道理平均误差会变大,这一条我没有单独量。数字之间可以比,换了定义就不能比了。
十、适用边界与风险提示
结论只对这三张样图成立
三张图各代表一类,每类只有一个样本。人像背景是白墙、商品背景是浅灰渐变、毛发背景是深色车座。透明处存的颜色恰好是白、白、黑。换一个工具导出的 PNG,透明处存的是别的颜色就会让亮边暗边的方向和程度都变。文中「存白出亮边、存黑出暗边」是对三张图数据的概括,没有第四张图验证。
抠图只测了长边 2000 像素以内的输出,官网写的单张上限 40 MiB、2400 万像素没有验证。缩放对比只用了快速档的导出,专业档只做了边缘局部放大的观察。别家抠图工具的输出我没有拿到。不能说别家的 PNG 也是这样,也不能反过来说别家更好或更差。
方向取决于透明处存的颜色
这一条值得单独强调。同一套非预乘缩放在不同的 PNG 上可能出亮边或暗边,也可能几乎没有。完全看低 alpha 处存的是什么。不能把「缩小会出白边」当成事实去排查。也不能因为自己的图没出白边就认为流程没问题。可能只是你的图透明处存的是黑色,暗边被深色背景吃掉了。
另一个风险是流程里看不见的转换。把图从一个库传给另一个库,中间可能被预乘再还原。每次转换透明处的颜色都可能被改写。第七章的画布读回例子里,能看到低 alpha 处的颜色已经变了样。要是你的流水线有多个环节,别假设某一环的输入输出保留了透明处的颜色。
不要把预乘当成万能解
预乘能去掉脏色,去不掉发软。想靠预乘让边缘更利落是不可能的,第三章已经证明了 alpha 不变。需要边缘清晰的场合应该少缩小,比如印刷和高分辨率展示。也可以直接从原图出各个尺寸,不要缩了再放。
预乘也不是对所有场景免费。它要求缩放完成后再还原。如果你的下游也是预乘的合成管线,就可以省掉还原这一步。如果下游是普通的非预乘管线,还原时 alpha 很小的像素颜色可能被放大误差而需要加保护;我没有单独量这部分的损失。
十一、还没解决的问题
导出文件为什么存背景色没弄清
图映导出的 PNG 里低 alpha 像素存着原背景色,而 alpha 为 0 处存着黑色。我能看到现象但原因没有查。是模型输出的 alpha 本身带背景残留还是导出环节写的颜色,我不知道;内部怎么做的我不清楚。对写缩放代码的人这个问题不影响结论,只要缩放走预乘透明处存什么都不起作用。对想从源头减轻污染的人,它是一个没有答案的问题。
其他浏览器和缩放核没测
画布和 sharp 的结论来自 Chromium 149 和 sharp 0.35.5。Safari 和 Firefox 的画布是不是同样预乘存储、读回是不是同样失真,我没有测。缩放核方面只试了 BOX 加 BILINEAR、sharp 的 lanczos3 和 linear、画布的三档质量。更多核没有进来。
八位量化的损失没单独量
预乘缩放在 8 位整数里做。先乘 alpha 再取整会丢精度,低 alpha 的像素尤其明显。读回例子里 alpha 为 2 的像素颜色从 (200,100,50) 变成了 (255,128,0)。这部分损失在主表里被基准吃掉了,因为基准也是同样的预乘做法。要量化它得用浮点或 16 位的基准去比,这次没做。
自己想验证的话办法很简单。拿一张自己的抠图 PNG 读透明处有几种颜色,再按第五章的代码缩一次放一次并看边缘带的误差。
参考资料
- 样本:维基共享资源 File:Russia. Young women.jpg(Dmitry Makeev,CC BY-SA 4.0);File:Michael Kors Jolie Korkwedge 40R4JLHA1S Veloursleder taupe (1) (16182928588).jpg(SPERA.de,CC BY 2.0);File:Cheerful Bichon Frise.jpg(TudorTulok,CC BY-SA 4.0)。我把长边缩到 2000 像素以内使用
- Pillow 11.3.0 文档:Image.resize 的 BOX 与 BILINEAR 重采样滤镜
- sharp 0.35.5 文档:resize 的 kernel 选项、extractChannel、joinChannel 与 raw 输入输出
- HTML 标准中 createImageBitmap 的 premultiplyAlpha 选项,以及 Canvas 2D 的 getImageData 与 putImageData
- 图映 ImgIng 的 AI 智能抠图界面说明(电脑上的 Chromium 149,2026-10-30 查阅)