P3图片画进canvas就变淡:换display-p3画布

0 阅读4分钟

商品中台上周来了个工单。同一张主图在运营的 Mac 上看是亮橙色,上传以后在详情页里发灰。我们的上传组件会先在浏览器里用 canvas 缩尺寸再导出,而原图带的是 Display P3 配置文件。问题出在「画进 canvas」这一步,跟导出格式和 quality 都没有关系。

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

2D 画布不指定色彩空间时按 sRGB 存像素。带 P3 ICC 的图画进去时浏览器会按 ICC 把颜色换算成 sRGB 并裁掉色域外的部分。我跑了一组对照来确认这件事:机器是 M4 Mac,浏览器是 Playwright 1.61.1 自带的三个内核。样本是我自己生成的一张 768×64 px 的 PNG,嵌了 P3 ICC,里面排着 12 个 64 px 见方的色块。前 8 块是 P3 的高饱和色,后 4 块是 sRGB 域内的普通颜色。每块取中心像素读回来,再和源色算 CIEDE2000 色差。

P3图片画进canvas的色差

这张图先看蓝条的上半截。Chromium 149 在 P3 橙这一块的 ΔE 是 7.50,P3 纯红是 6.83。ΔE 过了 2 肉眼就分得出来。这个差距足够让运营提工单。再看蓝条下半截的四块域内颜色。它们全都不超过 0.23,P3 橙的色差是它们的 32 倍多。换算本身没错,丢掉的只是色域外那一截。P3 橙 (255,128,0) 在 Chromium 里读回 (255,119,0)。Playwright 的 WebKit 26.5 构建读回 (255,118,0),最大色差 7.71。

做这组对比时我顺手把格式、质量、透明边和方向也在三个内核上跑了一遍。这轮对照我用的是图映 ImgIng的本地转换,三个内核上一共跑了 37 次用例并全程开着网络记录。我盯的是它有没有往外发数据。结果非 GET 请求 0 次。我们组做过一轮数据合规整改,原图能不能出浏览器是评审的第一问。颜色这一项下面只讲 canvas 自己的行为。

colorSpace:'display-p3' 画布怎么用

改法是在拿 context 时声明画布的色彩空间。真正要紧的是第二行的检查。Playwright 的 Firefox 151 构建对这个选项不报错也不生效。它不报错也不提示,给你的还是 sRGB 画布。

const ctx = canvas.getContext('2d', { colorSpace: 'display-p3' });
const wide = ctx.getContextAttributes?.().colorSpace === 'display-p3';
ctx.drawImage(image, 0, 0);
const orange = ctx.getImageData(224, 32, 1, 1);   // 第 4 块:P3 橙
const clipped = ctx.getImageData(224, 32, 1, 1, { colorSpace: 'srgb' });

我把 wide、orange.colorSpace 和两次读回的 RGB 打出来。Chromium 149 是 true display-p3 255,128,0 与 255,119,0。WebKit 26.5 是 true display-p3 255,128,0 与 255,118,0。Firefox 151 构建是 false undefined 255,128,0 与 255,128,0。

Chromium 和 WebKit 在 P3 画布上 12 个色块的 ΔE 全部是 0。导出的 PNG 会自己嵌一份 P3 描述文件:Chromium 写的是 Display P3 Gamut with sRGB Transfer,WebKit 写的是 Display P3 外加一个 cICP 块。导 JPEG(quality 0.95)色差也不超过 0.31。这组数我是逐块对过的,12 张色块没有一块例外。

Firefox 读回 255,128,0 为什么不能算保住了

Firefox 那一行最容易骗人。它读回的 255,128,0 和源数值一字不差。看起来像是颜色保住了。实际上这个构建压根不读 ICC。它把文件里的原始 P3 数值直接当成 sRGB 用。前面图里橙色条下半截就是证据:四块域内颜色在 Firefox 构建里偏了 2.36 到 3.24。判断画布到底是不是 P3 只能看 getContextAttributes() 的返回值而不能看像素。

还有个坑是我自己踩的。P3 画布上的 getImageData 默认返回 P3 数值。我第一次拿它当 sRGB 去算色差。结果得出了「P3 画布反而偏色 2 到 7」的反结论。组件里如果有取色器、主色提取这类下游逻辑,要么先读 ImageData.colorSpace 再决定怎么解读,要么像上面那样显式传 { colorSpace: 'srgb' } 拿裁切后的视图。

我们组最后怎么接的

我们按 wide 分两条路。拿到 P3 画布就正常缩图导出。拿不到就不在前端重编码,原文件直接进上传流程交给服务端决定。这牺牲了一部分用户的体积收益。换来的是颜色不会被悄悄改掉。有两处我还没把握。一是这组 ΔE 只来自 12 个纯色块,真实的广色域照片我没测。二是 Firefox 正式版的色彩管理配置可能和 Playwright 构建不同,这里的结论不能外推。

如果你的组件也在前端缩图,可以先拿一张带 P3 ICC 的测试图在目标浏览器里跑一遍上面那段代码,看 wide 是不是 true。然后对比默认画布和 P3 画布读回的同一块颜色。差值落在色域外的色块上才正常。