前端

屏幕截图颜色不是 sRGB,你的 UI 会显示出来

从 macOS 截图中拾取的十六进制颜色值位于显示器配置文件中,而非 sRGB。将其粘贴到代码中,颜色就会发生偏移。以下是往返过程的证明。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

如果你用取色器从屏幕截图中取样颜色,并将十六进制值粘贴到你的样式表中,结果可能会有明显的色差。这个数值本身没有错,它只是在错误的色彩空间中被测量了。在广色域显示器上,屏幕截图是根据显示器配置文件编码的,而你的 CSS、主题清单和设计令牌则被解释为 sRGB。相同的数字,不同的颜色。

我在尝试让一个 UI 元素与另一个已经显示正确的元素匹配时,就遇到了这个问题。我取样的值是 #6DCEBD。而实际能产生那个像素颜色的值是 #28D0BC。这两个值相差甚远。

取色器实际提供给你的值

在广色域显示器上的屏幕截图并非 sRGB 值的转储。合成器会渲染到显示器的色彩空间中,而截图文件则带有一个描述该色彩空间的 ICC 配置文件。当你打开该文件并读取一个像素时,你会得到一个以该配置文件表示的数值。

你的代码则以不同的方式被读取。CSS 十六进制颜色值、主题清单以及大多数设计令牌格式根据定义都是 sRGB。处理流程中没有任何环节会为你进行转换。因此,这个往返过程包含两次转换,而其中只有一次转换对你是可见的。

为何采样十六进制值无法往返 1 渲染 2 采样 3 粘贴回来 sRGB 源 40,208,188 屏幕上 109,206,189 采样十六进制值 6DCEBD 再次渲染 138,204,190 // 第 2 步读取一个带色彩配置的数值,第 3 步将其视为 sRGB

往返验证

不要盲目相信理论。你可以用几行代码验证整个流程,因为你已经知道一些基准真相:你自己的代码所声明的任何颜色,根据定义,就是一个 sRGB 值。在截图中找到它,然后转换回来。

from PIL import Image, ImageCms
import io

im = Image.open("screenshot.png")
profile = ImageCms.ImageCmsProfile(io.BytesIO(im.info["icc_profile"]))
to_srgb = ImageCms.buildTransformFromOpenProfiles(
    profile, ImageCms.createProfile("sRGB"), "RGB", "RGB", renderingIntent=0
)
converted = ImageCms.applyTransform(im.convert("RGB"), to_srgb)

三个声明的颜色,经过原始采样然后转换:

声明的 (sRGB) 文件中的原始像素 转换后
10, 8, 23 10, 8, 22 10, 8, 23
25, 24, 36 25, 24, 35 25, 24, 36
163, 18, 98 147, 33, 96 164, 18, 99

每个声明的值返回后都在一个单位内。该流程已确认。现在,对您想要匹配的颜色正向运行该流程:

sRGB  40, 208, 188   becomes on screen   109, 206, 189
sRGB 109, 206, 189   becomes on screen   138, 204, 190

第一行是答案。第二行是当你将采样到的十六进制值直接粘贴到代码中时会发生的情况:你会得到第三种颜色,它足够接近,看起来像是故意的,但又错得足以在你要匹配的元素旁边显得浑浊。

为什么深色会隐藏问题

再看一下表格。前两行移动了一个单位。第三行在红色上移动了 16,在绿色上移动了 15。这种不对称性是这个 bug 能在粗略检查中幸存下来的原因。

在靠近中性轴和接近黑色的区域,色彩空间之间的转换几乎是恒等的。深色背景、边框和阴影的往返转换都几乎是完美的。如果你抽查截图,将其与背景色对比,看到颜色匹配,你就会断定截图是 sRGB 格式,然后就不再深究了。

差异存在于饱和度高的地方。强调色、品牌色调、状态指示器和语法高亮正是那些变化最大的值,也正是你最可能用肉眼去匹配的值。你执行的检查之所以能通过,是因为你是在该 bug 不存在的色域部分运行的检查。

还有一个形式相同的陷阱。如果某个工具在保存时剥离或忽略了 ICC 配置文件,那么数值会保持原样,但其含义会丢失。一张通过聊天应用粘贴、调整大小或重新编码的截图,最终可能完全没有配置文件。此时,文件中的任何信息都无法告诉你它处于哪个色彩空间,而唯一诚实的答案是,你无法仅从文件本身得知。

两种修复方法

显式转换。 如果你需要一个绝对值,请运行上面展示的转换并使用转换后的数字。当颜色需要写入样式表、清单文件或其他人会读取的令牌文件时,这是正确的选择。保留带有其配置文件的原始屏幕截图,以便以后可以重复推导过程。

或在同一张图片内比较。 如果你的实际目标是“让元素 A 与元素 B 的颜色相同”,那么你根本不需要绝对值。截取一张同时包含两者的屏幕截图,使用相同的工具和相同的代码路径提取两个像素,然后将它们相互比较。无论文件处于哪个色彩空间,两个样本都在其中。即使你永远不知道 sRGB 值,这种比较也是有效的。

第二种方法更稳健,我现在会首先采用它。在引发此问题的案例中,最终的检查正是如此:从一次截图中采样参考元素和目标元素,并比较色相和饱和度。色相相差 0.0 度,饱和度相差 0.003。无需转换即可确认匹配。

有两个细节可以使图像内比较变得可靠:

  • 采样字形内部,而非边缘。 抗锯齿文本在边界处会向背景色混合。应取该区域中饱和度最高的像素,而不是取平均值,否则你测量的将是混合色而不是本色。
  • 注意禁用状态。 工具栏中一个灰显的图标虽然与活动颜色相差甚远,但仍可能通过饱和度过滤器。应有意地选择你的参考元素,而不是扫描整个区域并相信最常见的值。

此问题在何处出现

这种模式并非特定于某一平台或某一文件格式。它出现在任何将渲染后的图像视为真值来源,而该值稍后将在固定空间中进行解释的场景中。

  • 将 UI 元素与在广色域设备上导出的设计模型中的颜色进行匹配。
  • 通过对一个无法直接读取的现有主题或皮肤文件进行采样来构建新的主题或皮肤文件。
  • 编写视觉回归测试,将捕获的像素与硬编码的期望值进行比较。
  • 从照片或视频帧中提取调色板以在 CSS 中使用。
  • 使用屏幕截图报告颜色错误,而读者对你的图像进行采样后,得到一个你们双方都没有渲染出的数值。

视觉回归测试值得特别警示。一个在笔记本电脑上捕获图像并与 sRGB 常量进行断言的测试套件,其通过或失败将取决于运行它的机器。如果 CI 运行器的配置文件与开发人员机器的配置文件不同,那么失败看起来就像是偶发性问题,并通过重试而被忽略。

常见问题解答

这是否也影响 Windows 和 Linux? 其机制是通用的,但受影响的程度取决于显示器和截图工具。当截图被编码为广色域空间而非 sRGB 时,问题就会出现。在标准色域显示器上截图到 sRGB 可以干净地往返转换,这就是为什么在广色域面板在笔记本电脑上普及之前,这个问题很少见。

我可以直接剥离配置文件吗? 不可以。移除配置文件并不会转换任何东西。它只是移除了转换所需的信息,留下的数字其含义已无从知晓。如果一个文件没有配置文件,你无法仅从像素值中恢复其原始空间。

我的设计工具显示的十六进制值与我的截图不同。哪一个是对的? 在没有声明色彩空间的情况下,两者都不对。询问每个数值处于哪个色彩空间。设计工具通常会显示 sRGB 值,同时通过广色域显示器来预览它们,这正是屏幕上的像素与报告的十六进制值不一致的原因。

这和伽马(gamma)或位深度(bit depth)是一回事吗? 不是。伽马编码和位深度是另外的问题,它们同样会扭曲简单的像素运算。这个特定的问题是关于数值所表示的色彩空间,并且即使在整个过程中正确处理了伽马并使用每通道 8 位,它也依然存在。

我如何防止这种情况再次发生? 在数值旁边写下其色彩空间。在十六进制常量旁边加上一条说明是 sRGB 的注释,或者用文件名记录样本的来源,这些做法几乎没有成本,却能消除导致此错误的歧义。保留带有配置文件的原始截图,这样任何从它派生出的值都可以被重新计算,而不是重新猜测。