HEIC 转 JPG 时要不要统一转成 sRGB 这件事我不想凭感觉定。我跑了三种处理,看完数再取舍。样张是用 sips 从合成图和 CC0 照片编出来的 HEIC,这篇用到其中两张。合成图带 12 个色块,其中 8 个是 P3 高饱和色而另外 4 个落在 sRGB 范围内。另一张是 Wikimedia Commons 上的一张 CC0 机场照片换算到 Display P3 的版本,颜色全在 sRGB 范围内。它们都不是 iPhone 直出的文件,真机的 HDR、10 bit 和 nclx 色彩描述都没覆盖。机器是一台 Apple M4 的 Mac,工具有 sips、Pillow 11.3、sharp 0.34.5,外加 Chromium 149 和 WebKit 26.5 两个浏览器内核。每样只跑一次。色差一律用 CIEDE2000 来算。浏览器里那一路对照,我用的是图映 ImgIng imging.cn/ 的格式转换。那块不是我参与的模块。我只按普通用户的方式在电脑浏览器上一次转一张,然后看输出文件。
颜色变淡只出在丢了配置文件又没转换的那条路
三种处理我按字母记:A 保留 P3 配置文件,B 把 P3 数值原样写进 JPG 但不带 ICC,C 先转成 sRGB 再存。实测下来只有 B 会让颜色变淡。合成图里 sRGB 范围内的 4 个色块在 B 下的色差是 2.32 到 3.33。其中肤色 3.24、天蓝 2.32、深绿 3.33。P3 高饱和色最大到 6.98,出在 P3 纯红那块。机场照片全图平均 1.94。九成五的像素不超过 3.39。道理不复杂。文件里存的还是 P3 的数,没有 ICC 时浏览器就按 sRGB 去解读。同样一组 RGB 189/124/88 被放进 sRGB 里饱和度就低了。Pillow 默认的 save() 走的正是这条路。不传 icc_profile 时 ICC 被丢掉,像素也不做转换。行内常说色差 1 以下难分辨、2 到 3 以上并排能看出来。这只是行内经验值,不是这次量出来的结果。拿这把尺子量,B 在肤色上已经到了能看出来的那一档。
转成sRGB后范围内的色差不超过0.37
C 的结果说明转换本身靠得住:sRGB 范围内 4 块色差最大只有 0.37。肤色在文件里是 189/124/88,转到 sRGB 是 199/120/81,色差只有 0.26。天蓝从 75/119/194 转成 61/120/200,色差 0.30。照片全图平均 0.89,95% 的像素在 2.12 以内。A 对应的是 0.78 和 2.19 这两个数,在这张照片上差得不多。体积也不构成理由:sips 走 A 时合成图是 527916 字节,走 C 是 519675 字节,只差 1.56%。sharp 默认就落在 C 这一路:它把像素先转成 sRGB,再把 ICC 和 EXIF 一起丢掉。颜色是对的。只是文件里没写自己属于哪个色彩空间,下游按 sRGB 解读恰好也对。
P3高饱和色转sRGB只能被裁到边上
问题在另外 8 块。C 里色差最大的是 P3 橙 7.55,P3 纯红是 6.91,图里列出的 P3 纯绿是 5.33。P3 橙从 255/128/0 变成 255/119/1,P3 纯红变成 254/0/0。这不是转换写错。sRGB 的三角形比 P3 小一圈,三角形外的颜色只能压到边上。丢 ICC 不转的 B 也保不住它们,要留住这些颜色只能走 A。

这张图要看两处:左边红色三角形是 Display P3 而蓝色是 sRGB,六个黑点里有三个落在蓝色三角形外面。右表前三行 C 列的色差都在 5 以上,后三行都在 0.37 以内。图为自制。色块是合成的,数值来自单次实测。
保留P3要求下游每一环都认ICC
A 的色差最低。sips 默认导出的 JPG 里 12 个色块都在 0.31 以内。换成别的保 P3 写法最大也就 0.40。这次 sips 默认、Convertio 默认和图映的格式转换都是这样。转出来的 JPG 带着 Display P3 配置文件,拍摄时间和 GPS 也都在。代价是这条路很脆。链路上任何一个节点悄悄丢了 ICC 都会让 A 退化成 B。范围内的色差会直接跳到 2.32 到 3.33。链上几个常见节点这次都测了,各有各的默认值。Pillow 11.3 本身读不了 HEIC,只能喂它 sips 解码出的 PNG。它的 save() 要显式把原 ICC 传回去才不丢。sharp 0.34.5 的预编译包能读出 HEIC 的元数据,536 B 的 ICC 和 EXIF 都在。解码却报 Support for this compression format has not been built in。它只带了 AVIF 解码,所以下面的示意也是喂 PNG。浏览器画布这边 Chromium 149 解不了 HEIC,WebKit 26.5 能解。两者解码时都已经转成 sRGB。默认导出的 JPG 颜色是对的但丢了 EXIF 和 GPS。区别在于 Chromium 默认嵌 sRGB ICC 而 WebKit 不嵌。画布设成 colorSpace: 'display-p3' 时两边都嵌 P3。
sharp 的四种写法差别最大,下面是按实测表整理的示意,注释里的结果来自完整测试,这几行本身没有单独跑。
js
const p3Png = 'decoded-by-sips.png' // 带 P3 ICC 的 PNG
await sharp(p3Png).jpeg().toFile('dflt.jpg') // 转 sRGB,不嵌 ICC,EXIF 丢
await sharp(p3Png).keepIccProfile().jpeg().toFile('icc.jpg') // P3 ICC 原样,EXIF 丢
await sharp(p3Png).keepMetadata().jpeg().toFile('meta.jpg') // P3 ICC、EXIF、GPS 都在
await sharp(p3Png).withMetadata().jpeg().toFile('srgb.jpg') // 转 sRGB,嵌 480 B 的 sRGB ICC,EXIF 在
验收时别拿 sips -g profile 当证据,--matchTo 转出的那份 JPG 里没有 ICC,它也报 sRGB。sips -d profile 这次也没删掉 P3 ICC,输出和默认逐字节相同。查 ICC 更稳的是读文件里 ICC 的字节数。
按输出去向决定转不转
我的取舍按出口分:出口是管不住的下游时,我倾向转 sRGB 并嵌一份 sRGB ICC。第三方平台、别人的缩略图服务、会被再处理一遍的地方都算这一类。withMetadata() 给的就是这种结果。理由是 C 在范围内最多 0.37。代价只是三角形外的颜色被裁。A 一旦在下游被剥掉 ICC,范围内就会掉到 2.32 到 3.33。哪些平台会剥 ICC 我没测,这是这条取舍的前提假设。出口在自己手里、整条链都认 ICC 时再保 P3。原图归档和专门给 P3 屏看的展示页属于这一类。这时每个节点都要验一次 ICC 字节。还有一笔账要算:合成图里 P3 高饱和色占了 12 块里的 8 块,机场照片一块都没有。真实照片里超出 sRGB 的颜色有多少,决定了 A 到底值不值。这次手上没有真机样本。我判断不了。元数据要另算一笔:keepMetadata() 和 withMetadata() 都把 GPS 留着,要不要去定位得在这一步单独决定。
剩下没解决的是 iPhone 直出的 HEIC。真机文件可能用 nclx 来描述色彩而不是嵌一份 ICC。同样的流程会不会得到同样的结果,这次没测。手上有自己的 HEIC 的话可以按上面三种处理各导一份。再用 Pillow 读 info 里 icc_profile 的长度。是 0 就说明没带配置文件。最后并排看肤色那类颜色有没有变淡。