canvas导出图片变色:P3裁成sRGB与预乘alpha

0 阅读4分钟

我在个人项目里做了一个图片裁剪的小功能,裁完用 canvas 导出。导出的图总让我觉得颜色哪里不对劲。橙色没原来那么亮了。半透明的边上还多出一圈说不清的杂色。我一开始以为是眼花,后来把像素值读出来逐项对了一遍才确认颜色确实变了。原因有两个:一个跟色彩空间有关,另一个跟 canvas 存透明度的方式有关。

这批样本全是我用脚本合成的。测试环境是 Playwright 1.61.1 自带的 Chromium 149、WebKit 26.5 和 Firefox 151 三个构建。同一批样本里还有格式和体积的部分,那边的对照组我用的是图映 ImgIng(imging.cn/)的格式转换,同一批合… canvas 直出的数而不拿任何工具当对照。

canvas 画 P3 图片为什么颜色变淡

先解释几个词。sRGB 是绝大多数网页和图片默认使用的颜色范围。Display P3 是更宽的一套,能表示更饱和的红、绿和橙。图片文件里通常嵌着一段 ICC 配置来告诉浏览器这些数值该按哪套范围去理解。

我造了一张 12 个色块的 PNG 并嵌入 Display P3 的 ICC。前 8 块是 P3 里的高饱和色,其中 7 块超出了 sRGB 能表示的范围。后 4 块是 sRGB 里本来就有的颜色。我把它画进默认 canvas 再用 getImageData 读回来。Chromium 和 WebKit 都按 ICC 把颜色转成了 sRGB。超出范围的部分直接被裁掉。P3 橙 (255,128,0) 读回来变成了 (255,119,0)。

色差我用 CIEDE2000 来算。它衡量两种颜色看起来差多少,0 就是完全一样。超出范围的 7 块在 2.91 到 7.50 之间而 WebKit 最大到 7.71。sRGB 范围内的 4 块都不超过 0.23,这只是 8 位取整带来的误差。变淡的只有 sRGB 装不下的那些颜色。

蓝条是 Chromium 149 默认画布,橙条是 Playwright 的 Firefox 151 构建。上面 8 块是 P3 高饱和色,两家都偏;最下面 3 块是 sRGB 域内的肤色、天蓝、深绿,看蓝条几乎为 0、橙条却有 2.36 到 3.24

解法是建画布时就声明色彩空间:getContext('2d', {colorSpace: 'display-p3'})。Chromium 149 和 WebKit 26.5 都认这个选项。同一批色块读回来 ΔE 全部是 0。导出的 PNG 也自己带上了 P3 的 ICC。Playwright 的 Firefox 151 构建不一样。它静默忽略这个选项,也完全不读图片里的 ICC。在它那里连 sRGB 范围内的颜色都偏了 2.36 到 3.24。我现在建完画布会用 getContextAttributes().colorSpace 看一眼,这个 Firefox 构建返回的是 srgb。它和 Firefox 正式版的行为是否一致我手上没有条件确认。

半透明边缘为什么出现杂色

第二个原因叫预乘 alpha。alpha 就是透明度:0 是全透明而 255 是完全不透明。canvas 在内部存颜色时会先把 RGB 乘上透明度再存。透明度越低,乘完剩下的数就越小。能保留的精度也跟着变少。读回来时浏览器再把它除回去,丢掉的那部分补不回来。

我写了一个 255×6 的测试条,alpha 从 1 排到 255。用 putImageData 写进去后我马上用 getImageData 读出来。写入 (200,150,100) 且 alpha=1 的像素在 Chromium 和 WebKit 里读回 (255,255,0)。Firefox 读回的是 (255,255,255)。这一档的 RGB 最大误差分别是 127 和 252。alpha 升到 10 时误差降到 9 和 25。Chromium 和 WebKit 要到 alpha=249 才全部精确,Firefox 要到 242。整条测试条里 RGB 完全精确的像素在 Chromium 只占 57.06%。alpha 通道本身一点没错。错的只有颜色。

横轴是写入时的 alpha(对数刻度),纵轴是 RGB 最大误差。关键看最左边:alpha=1 时 Chromium/WebKit 误差 127,Firefox 252;到 64 以后两条线才贴近 0

裁剪后边缘那圈杂色就是这么来的。那些像素几乎透明而颜色早已走样,叠到别的背景上就露了出来。我试过 willReadFrequently 和先画到另一张画布再读这两条路,损失一模一样。Firefox 导出 PNG 时还会再多一轮舍入:完全精确的像素从 54.97% 掉到 39.41%。还有一个相关的坑。带透明区域的画布导出 JPEG 时透明的地方一律变黑。JPEG 不支持透明,浏览器按黑底合成且不给任何提示。

canvas 导出前怎么自查颜色

导出之前我现在会看这几处。来源是广色域图片又在意颜色时,建画布就用 display-p3 并用 getContextAttributes() 确认它真的生效。要保留透明边的素材尽量不经过 getImageData 改像素再写回。要导出 JPEG 的先在画布上铺一层白底再画图。自测也简单:造一个中间色比如 (200,150,100) 配 alpha=1 写进去再读出来,看自己的浏览器差了多少。别用纯白或纯红来测。它们在任何 alpha 下都是零误差,拿来测只会得出「没问题」。