☰
OKLAB颜色空间转换与色域判定:从sRGB到感知均匀坐标的工程实践
2026/10/20 17:56:24 网站建设 项目流程

OKLAB颜色空间在上一篇里聊过它为什么值得关注——感知均匀性比CIELAB更稳,色相恒定性也更符合直觉。这次把工程问题直接铺开:颜色空间转换怎么做,色域又怎么在OKLAB坐标里确定。这是两个在实验室里可以各聊一小时的题目,落到实际项目里却必须同时处理:你拿到一个sRGB像素,要把它转进OKLAB做计算,需要一条全链路可复现的转换公式;你想知道某个OKLCH颜色到底能不能在当前显示器上正常显示,又需要一套可计算的色域判定方法。做图像处理、色彩管理、前端取色器、可视化配色的人,基本都会被这两步消耗掉不少调试时间。

OKLAB的转换和色域问题有个共同特点:数学上不算特别难,但每一步都有隐藏假设。隐藏假设不摸清,直接抄矩阵会得到一堆"看起来差不多、实际整体偏掉"的颜色。所以这篇文章尽量把从编码值到OKLAB坐标的每一步拆开讲清,再给出确定色域边界的完整思路和能直接跑的代码。

1. 转换与色域为什么必须放在一起聊:先定位,再测量

1.1 颜色转换不是查表,是坐标换系

很多人第一次接触OKLAB,是从某个工具里复制了一段矩阵代码,跑通后觉得"也就那样"。但真正开始做项目时会发现,OKLAB并不是一个简单的调色模型,而是一套独立的坐标系:它把颜色映射到三个维度上——大致对应亮度的L、绿色到红色的a、蓝色到黄色的b。转换的过程,就是把一个颜色从RGB这类设备相关的坐标系,搬到一个以视觉感知为基准的坐标系里。

这个"搬"的动作有方向性。你从sRGB出发能进入OKLAB,也可以从OKLAB回到sRGB;但你从Display P3出发,就不应该直接套sRGB的矩阵——因为两者的基色和白点不同。同样的道理,你从Photoshop的D50工作空间拿一个颜色,直接丢进OKLAB转换矩阵,结果一定不对。转换不是背公式,而是先搞清楚输入数据到底属于哪个色彩空间,再做坐标换系。

1.2 色域问题的三个层次

色域这个概念在不同场景下有完全不同的含义,不区分清楚会一直踩坑。我把项目里遇到的色域问题分成三层:

  • 检入(Gamut Check):判断一个颜色是否落在某个色彩空间内,返回true/false。这是最简单的一层,常用于"这个颜色能显示吗"。
  • 边界描述(Gamut Boundary Description):得到色域在某个颜色空间中的几何边界,比如三维立体、某个亮度下的二维截面,或者某个色相下的最大彩度曲线。这层要做的是"画边界"。
  • 映射(Gamut Mapping):当一个颜色在色域外时,想办法把它挪回色域内,同时尽量保住视觉上的亮度和色相。这层是最终用户真正感知到的部分。

这篇文章主要解决前两层:转换是坐标换系,色域确定是边界描述。第三层映射我会在第5节给出一个工程上的推荐方向,因为如果不考虑映射,光有边界判断,很多场景依然落不了地。

1.3 这篇文章采用的约定

为了不让讨论发散,我把范围约束到最常见的工程场景:输入是8bit或浮点sRGB,输出是OKLAB坐标,色域以sRGB的可见显示范围为例。如果你需要处理Display P3、Adobe RGB等其它色彩空间,结论可以迁移,但矩阵和白点需要替换,这一点后面会特别提醒。

2. sRGB到OKLAB的四步变换:从伽马解码到投影矩阵

2.1 第一步:伽马解码,把编码值还原为线性结果

sRGB文件里存储的像素值不是亮度本身,而是经过伽马编码的非线性数值。所有颜色空间转换的第一步,都是把编码值转成线性值。这一步用下面这个分段公式:

c_linear = c_srgb <= 0.04045 ? c_srgb / 12.92 : ((c_srgb + 0.055) / 1.055) ^ 2.4

这里有一个非常容易错的地方:分段阈值0.04045是在编码域(0到1的sRGB值)上的,而反向的0.0031308是在线性域上的。很多实现把这两个阈值搞混,导致在暗部区域出现一抹肉眼可见的色偏,尤其是在做深色渐变的时候。我刚开始做转换函数时,就因为这个阈值错位,浪费了整整一个下午去排查为什么暗部偏绿。

编码值通常还要先除以255归一化。如果输入是8bit整数,先转浮点再归一化,避免整数除法直接丢精度。如果输入已经是浮点sRGB,直接套公式即可。

2.2 第二步:RGB到LMS,模拟人眼锥体响应

线性sRGB值接下来要映射到一个模拟人眼三种视锥细胞响应的中间空间,也就是LMS空间。OKLAB的做法是不经过XYZ,直接从线性RGB用一个3x3矩阵乘到LMS,这比传统"RGB->XYZ->LMS"的路线少了一步矩阵运算,精度损失更小。

这里的矩阵在论文和各家实现里有一组标准值,可以直接用:

M1 = [ [ 0.4122214708, 0.5363325363, 0.0514459929 ], [ 0.2119034982, 0.6806995451, 0.1073969566 ], [ 0.0883024619, 0.2817188376, 0.6299787005 ], ]

这一组数值针对的是sRGB的D65白点。也就是说,只有输入是sRGB线性值时,乘这个矩阵才是对的。如果你在某个库看到"RGB转LMS"用的矩阵和我这里不同,先别急着改,很可能它已经合并了白点适应或做了色空间转换。

2.3 第三步:立方根压缩,让坐标接近感知均匀

拿到LMS三个分量后,对每个分量逐一开立方根。这一步是整个OKLAB的精华所在。为什么用立方根而不是对数?因为感知均匀性要求压缩函数在暗部更陡、亮部更缓,立方根正好满足这个形状;同时它对负值友好——LMS分量的值可能小于零(尤其是超色域的颜色),立方根可以直接处理负数,而对数在这种情况下就麻烦了。

用Python写就是一句:

lms_c = np.cbrt(lms)

注意这里不是abs后再开方,直接对负数开立方根会得到负的实数根,这正是我们需要的。如果某份实现把负值钳制到0,整个超色域部分的几何关系就全错了,色域判定自然也不准。

2.4 第四步:投影矩阵,得到Lab坐标

压缩后的lms用一个3x3矩阵组合成OKLAB坐标:

M2 = [ [ 0.2104542553, 0.7936177850, -0.0040720468 ], [ 1.9779984951, -2.4285922050, 0.4505937099 ], [ 0.0259040371, 0.7827717662, -0.8086757660 ], ]

乘出来之后,L的范围大致在0到1之间,a和b大约在正负0.4以内。这就是OKLAB坐标。把四步合起来,就是一张可以直接复用的表:

步骤操作物理含义
1sRGB伽马解码编码值转线性亮度
2M1矩阵变换线性RGB转LMS锥体响应
3逐通道开立方根非线性压缩,拟合感知
4M2矩阵组合得到OKLAB坐标

2.5 完整的Python实现与反向转换

正向转换用numpy可以写得非常简洁:

import numpy as np M1 = np.array([ [0.4122214708, 0.5363325363, 0.0514459929], [0.2119034982, 0.6806995451, 0.1073969566], [0.0883024619, 0.2817188376, 0.6299787005], ]) M2 = np.array([ [0.2104542553, 0.7936177850, -0.0040720468], [1.9779984951, -2.4285922050, 0.4505937099], [0.0259040371, 0.7827717662, -0.8086757660], ]) def srgb_to_linear(x): return np.where( x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4, ) def srgb_to_oklab(rgb): rgb = np.asarray(rgb, dtype=np.float64) lin = srgb_to_linear(rgb) lms = lin @ M1.T lms_c = np.cbrt(lms) return lms_c @ M2.T

反向转换用三个矩阵的逆矩阵依次还原。为了方便说明,我直接给出逆矩阵:

M1_inv = np.array([ [ 4.0767416621, -3.3077115913, 0.2309699292], [-1.2684380046, 2.6097574011, -0.3413193965], [-0.0041960863, -0.7034186147, 1.7076147010], ]) M2_inv = np.array([ [1.0, 0.3963377774, 0.2158037573], [1.0, -0.1055613458, -0.0638541728], [1.0, -0.0894841775, -1.2914855480], ]) def ok_lab_to_linear(lab): lab = np.asarray(lab, dtype=np.float64) lms_c = lab @ M2_inv.T lms = lms_c ** 3 return lms @ M1_inv.T def linear_to_srgb(x): return np.where( x <= 0.0031308, 12.92 * x, 1.055 * x ** (1.0 / 2.4) - 0.055, ) def oklab_to_srgb(lab): lin = ok_lab_to_linear(lab) return linear_to_srgb(lin)

有几个数值相关的经验想分享。第一,这些矩阵系数最好完整复制到双精度,不要只留6位小数。我在一次性能优化中把矩阵截断成6位,结果色差在纯蓝区域直接出现了肉眼可见的偏移。第二,numpy里用行向量乘以矩阵转置,比矩阵左乘列向量阅读起来直观,且批量处理大数组时能避免不必要的转置开销。第三,最终输出8bit时再quantize,中间过程全程保持float64;如果用float32,在颜色数量少、渐变跨度大的场景下banding会很明显。

3. 色域在OKLAB里的几何形状:边界是怎么形成的

3.1 色域边界来自RGB立方体的表面

要确定色域,先搞明白一个事情:sRGB能显示的所有颜色,在RGB空间里构成一个单位立方体——每个通道都在0到1之间。立方体的边界由6个面组成:r=0、r=1、g=0、g=1、b=0、b=1。这6个面经过OKLAB这套非线性变换后,会被扭曲成一个封闭的曲面体,这个曲面体就是sRGB色域在OKLAB坐标中的边界。

这是个很有用的结论:你不需要对整个RGB立方体做体素级采样,只需要采样它的6个表面,就能得到色域边界点云。表面以内的点映射后一定落在边界之内,不会对边界描述产生额外贡献。这个直觉在我第一次画色域三维散点时帮了大忙——如果对立方体内部也均匀采样,不仅计算量翻好几倍,可视化时边界还会被内部点彻底糊住。

3.2 凸包近似的局限性

拿到边界点云之后,最直接的做法是用三维凸包。任何一个点,只要它在凸包内部,就认为它在色域内;在外部,就认为超色域。这个方法实现简单,建好凸包后判断也很快。但凸包有一个前提假设:色域本身是凸的。RGB立方体经过线性矩阵变换后仍然是凸的,可惜第三次的逐通道开立方根是非线性变换,它会把凸立方体"拧"成一个不完全凸的曲面体。

结果就是,三维凸包在某些区域会高估色域,把一些实际上不可显示的颜色误判为可显示。这个问题在CIELAB里就已经被广泛讨论了,OKLAB虽然感知更均匀,但同样不是完全凸的。好消息是,对于sRGB来说,OKLAB里的非凸区域主要集中在高亮和极暗段,一般调色场景不太在意;坏消息是,如果你在做高精度的色域映射,凸包高估会导致映射后的结果在边界处出现轻微异常的色偏。

3.3 三种边界建模方法对比

根据项目精度和性能需求,我一般把色域建模分成三档:

方法原理精度性能适用场景
三维凸包对边界点云求凸包中,会高估非凸区域构建快、判定快可视化、粗略检入
L切片二维凸包在固定L层内对ab平面求凸包较高,可捕捉部分凹陷需要构建多层结构UI配色、亮度约束场景
沿C方向二分固定L和色相,逐点求最大彩度最高较慢,但无需预处理渲染、离线高质量映射

三维凸包适合快速搭建原型;L切片法是工程上最常用的折中;二分法是精度底线,能拿到真实边界,就是慢。实际项目中我会优先用L切片,因为它和人的思考方式一致——设计师说"我要一个高明度的蓝色",你直接看L=0.7那一层的边界,立刻知道这个色相最多能有多彩。

4. 可复现的色域判定方案:L切片边界与最大彩度二分

4.1 生成边界点云:表面采样而不是体素采样

先写一个采样函数,对RGB立方体的6个表面均匀打点,然后一次性转成OKLAB点云。这个函数不需要太过复杂,但要注意把6个面交界处的重复点去掉,否则后续求凸包或切片时会引入重复顶点。

def sample_rgb_surface(n=48): axis = np.linspace(0.0, 1.0, n + 1) u, v = np.meshgrid(axis, axis, indexing="ij") u = u.ravel() v = v.ravel() faces = [] for r in (0.0, 1.0): faces.append(np.stack([np.full_like(u, r), u, v], axis=1)) for g in (0.0, 1.0): faces.append(np.stack([u, np.full_like(u, g), v], axis=1)) for b in (0.0, 1.0): faces.append(np.stack([u, v, np.full_like(u, b)], axis=1)) pts = np.unique(np.vstack(faces), axis=0) return srgb_to_oklab(pts)

n的取值决定边界精度。n=48时,6个面一共约1.4万个点,去重后仍然有很多点在边界棱线上重复,但对普通工程足够;n=128时精度更好,但三维点云会到十万级,构建时间明显增加。第一次调试时我习惯用n=32快速验证,确认流程没问题再加大密度。

4.2 按L切片构建二维色域边界

得到边界点云后,对某个固定的L值,取点云中L与目标L差值小于阈值的点,再对这些点在ab平面求二维凸包。因为边界点云的密度有限,阈值太小会拿不到几个点,阈值太大又会让边界变厚。一般可以按L切片的间距来取:如果你按0.01切一层,阈值可以取0.005,让每个切片大约覆盖到半层厚度的点。

二维凸包可以用SciPy的ConvexHull:

from scipy.spatial import ConvexHull def build_l_slice(point_cloud, L_target, tol=0.005): mask = np.abs(point_cloud[:, 0] - L_target) < tol pts = point_cloud[mask][:, 1:3] # a, b if len(pts) < 3: return None hull = ConvexHull(pts) return pts[hull.vertices]

这里返回的是按逆时针排列的多边形顶点,后续做点内判断直接用这个顶点序列。

这个切片法有个明显的工程优势:它天然处理了L方向的非凸问题。虽然sRGB色域在OKLAB整体上不是严格凸三维体,但对绝大多数L值切片后,ab平面的截面是凸的。在很靠近L=0和L=1的区域,截面收敛成一个点,形状退化,直接返回None即可。

4.3 任意OKLCH颜色:点内判断与最大彩度二分

有了二维边界,判断一个OKLCH颜色是否在色域内就分两步:先把L、C、H转成OKLAB的a、b,再判断点是否落在该L层多边形内。

用标准的射线法就能做多边形内点判断:

def point_in_polygon(ab, poly): x, y = ab n = len(poly) inside = False j = n - 1 for i in range(n): xi, yi = poly[i] xj, yj = poly[j] if (yi > y) != (yj > y): if x < (xj - xi) * (y - yi) / (yj - yi) + xi: inside = not inside j = i return inside

但更常见的实际需求是:给定L和H,问"最大彩度C是多少"。在这个场景下,用二分法沿着C方向逼近真实边界,既不需要预处理边界结构,还能拿到高精度结果:

def ok_lch_to_oklab(L, C, H_deg): h = np.deg2rad(H_deg) return np.array([L, C * np.cos(h), C * np.sin(h)]) def in_srgb_gamut(lab): rgb = oklab_to_srgb(lab) return np.all((rgb >= 0) & (rgb <= 1)) def max_chroma_at(L, H_deg, lo=0.0, hi=0.5, eps=1e-6): for _ in range(40): mid = 0.5 * (lo + hi) lab = ok_lch_to_oklab(L, mid, H_deg) if in_srgb_gamut(lab): lo = mid else: hi = mid return lo

这里把上界初始值设为0.5,对sRGB来说绝大多数色相在L=0.5时都到不了彩度0.5,如果发现二分区间太小,可以先把hi翻倍再执行二分。每次迭代要跑一次OKLAB转sRGB,40次迭代对单个颜色完全不成问题,但如果对整张图像逐像素做,就得用预计算好的L切片结构来加速,或者大幅降低迭代次数。

这个二分法其实是最稳的"真相标尺"。我通常用它来验证L切片法的误差:随机抽几百个L和H,比较切片法判定结果和二分法结果,一旦发现大量不一致,说明切片太粗或阈值选得不对。

5. 工程里容易翻车的几个地方:白点、精度与映射方向

5.1 白点不匹配:什么数据才能喂给这个矩阵

这是所有转换错误里最隐蔽的一种。OKLAB的标准矩阵是为sRGB在D65白点下设计的。如果你的像素数据来自一个D50白点的工作色彩空间,或者来自Display P3这类不饱和基色范围更宽的输入,直接把线性值喂给M1矩阵,中间完全跳过了白点适应和基色矩阵换算,结果一定是错的,而且错得很有迷惑性——乍看颜色还算接近,放到色域边界附近就开始整体便宜。

严谨的做法是:先把输入色彩空间转换到sRGB线性值,再做OKLAB转换。比如Display P3要先通过P3到XYZ的矩阵,再从XYZ经过sRGB基色矩阵转回sRGB线性,同时完成D65白点适应。这条路看起来绕路,但能保证所有后续计算建立在同一个约定上。我在写通用处理库时,直接在入口处把每一种输入色彩空间都规范化成sRGB线性,后续流程只处理一种情况,复杂度下降很多。

5.2 浮点精度与阈值处理

OKLAB矩阵系数带有10位小数,这是经过拟合的,不是多余的精度。反向转换时,矩阵求逆本身会放大某些通道的误差,如果你用精度不够的工具链,整套转换来回几次后,颜色就再也回不到初始状态。

我在做批量图像测试时遇到过一个典型问题:8bit图像转OKLAB处理后再转回sRGB,结果整体对比度变小,暗部发灰。最后定位到原因,是中间某个环节用float16暂存了OKLAB坐标,立方根回乘3次之后误差被放大了。解决方案很简单:所有中间计算都保持float64,只在最终输出时重新量化。另一个常见问题是传入sRGB的值超出[0,1]范围,比如超色域值可能到1.2,这时伽马编码的pow(2.4)会出现NaN。处理时应该用np.clip或np.where兜底。

5.3 超色域后的裁剪方向

色域判定只是检测,真正影响观感的是检测之后怎么处理。最朴素的方案是保持L和H不变,把C一路压回到边界上。这个方案实现简单,但在高明度、高彩度区域,压缩后的颜色会明显变灰,视觉上"闷"。

一个更实用的方向是向中心点压缩:先把超色域颜色沿着"L方向"轻微压暗,同时向中性轴偏移。具体来说,可以以L=0.5、a=0、b=0为锚点,把超色域点往锚点方向压缩,直到进入色域。这个做法的优点是能保留更多彩度,缺点是色相会有一些偏移。实践中通常可以两个方案结合:先向锚点方向压缩,如果色相偏移超过某个阈值,再改用保持色相的压缩。

5.4 我实际采用的选型建议

以我自己的项目经验,小型工具和前端原型直接用三维凸包就够了——简单、够快、视觉上直观。等到做正式的图像处理库或色彩设计系统时,我会切换成L切片结构,因为它能直接回答"在某个亮度下这个色相最远能到哪",这恰恰是UI调色和可视化配色最常问的问题。离线渲染这类对精度要求极高的场景,才需要二分法逐一求边界,但二分法也可以作为切片结构构建时的一次性校准工具,两者并不互斥。

最后分享一个小经验:无论选哪种方案,边界点云的生成密度都要和你最终的使用场景匹配。如果只做前端取色器,n=32的表面采样点位已经完全够用;如果要做印刷预览或高端显示器色彩管理,建议直接把采样密度拉满,并用二分法校验误差。色域确定这件事没有万能参数,只有"足够贴合当前场景"的配置。

OKLAB的转换和色域边界,说到底是一套坐标系和一组合法操作。把矩阵含义、白点约定、非线性压缩这三个核心点吃透,后面所有的细节都是在为这三件事做工程化适配。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询