☰
OKLab与sRGB颜色空间转换及色域边界判定详解
2026/10/20 8:41:47 网站建设 项目流程

1. 为什么第二篇要专门聊“从 sRGB 到 OKLab”和“色域边界”

写颜色选择器也好,做数据可视化也好,这两年用 OKLab 的人越来越多了。大家都说它“均匀”,说它在渐变和取色上比 CIELAB 舒服,但真正下手做 UI 里那个彩色圆盘时,很多人会突然卡住:我怎么知道某个 OKLab 颜色能不能在屏幕上显示出来?边界到底在哪?

这就落到 OKLab 最关键的一个能力上——颜色空间转换。你得先把 sRGB 值转进 OKLab,才能做感知均匀的插值和调色;反过来,你又得把 OKLab 值转回 sRGB,才能把颜色画到屏幕上。而在做取色器或调色面板的时候,更痛苦的问题是色域判定:OKLab 坐标里的 (L, a, b) 看着很合理,转回 sRGB 却可能得到负数或者大于 1 的通道值,根本没法显示。

我最初做模拟项目 X 的时候,就是被这个“转出去容易、转回来发现越界”的问题折腾了整整一个下午。后来把正向、逆向转换公式彻底吃透,再配合一套可靠的色域边界判定方法,取色器、渐变条、色板生成模块都稳定下来。这一篇就把我在项目里验证过的完整转换方法、色域判定算法,以及踩过的坑一次讲清楚。

很多文章只给公式,不讲为什么;只讲能转,不讲什么时候会炸。所以我会把每一步拆开,把那些“书上不会写”的细节也补齐。

2. 正向转换和逆向转换,核心就靠两组矩阵

OKLab 全称是 Oklab 颜色空间,由 Björn Ottosson 在 2020 年前后提出。它的设计目标很直接:让颜色的亮度感知、色相感知、饱和度感知尽量符合人眼实际感受。和我们熟悉的 CIELAB 相比,OKLab 在蓝色与紫色区间明显表现更好,这也是它被大量用在取色器和渐变算法里的原因。

但要注意,OKLab 的“均匀”是有前提的。它建立在 sRGB 色域之上,绝大多数公开公式都是“sRGB 换算成 OKLab”这个标准路径。如果你用的是 Adobe RGB 或 Display P3,直接套这套公式得到的结果只是习惯上可接受,严格说需要在 LMS 基底的标定矩阵上做替换。项目里追求通用时,我会先把输入统一归一到 sRGB 线性空间,再走下面的步骤。

2.1 第一步:把 sRGB 的 gamma 解开

很多人栽在第一步:把 8-bit 的 RGB 值直接扔进矩阵。sRGB 本质上是一个带 gamma 编码的非线性信号,显示器为了兼容老式显像管,故意把暗部拉亮。而 OKLab 的矩阵计算是线性运算,你必须在矩阵乘法之前先把 sRGB 的编码曲线解码,恢复成线性光强度。

标准 sRGB 解码公式如下,输入 r、g、b 是 0 到 1 范围内的数值,如果你手头是 0 到 255 的数值,先除以 255。

function srgbToLinear(c) { c = c / 255.0; if (c <= 0.04045) { return c / 12.92; } return Math.pow((c + 0.055) / 1.055, 2.4); }

为什么要用 0.04045 这个临界值?因为 sRGB 标准把最低端设计成近似线性区,直接用 12.92 做斜率可以避免曲线在零点附近过于陡峭。这段代码里的指数 2.4 不是随手写的,是 sRGB 定义曲线的主 gamma 值,但注意公式里不是Math.pow(c, 2.4),而是要先平移再除以 1.055,这一步很多实现会把颜色调暗或调亮。

我在项目里通常会写成一个数组版本,避免逐通道复制粘贴出低级错误:

function srgbToLinearRGB(r, g, b) { return [srgbToLinearChannel(r), srgbToLinearChannel(g), srgbToLinearChannel(b)]; } function srgbToLinearChannel(c) { let x = c / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }

2.2 第二步:用 M1 矩阵把线性 RGB 变成 LMS

这一步要理解一个底层逻辑:人眼之所以能感知颜色,是因为视网膜里有三种视锥细胞,分别对不同波长的光敏感。LMS 颜色空间指的就是这三类视锥细胞的响应信号:L 是长波,M 是中波,S 是短波。OKLab 是先模拟视锥响应,再对响应做非线性压缩,最后转成亮度加两个色度轴的坐标。

线性 sRGB 转换到 LMS 的矩阵是固定的,下面这个是 Ottosson 公布的标准矩阵:

const M1 = [ [0.4122214708, 0.5363325363, 0.0514459929], [0.2119034982, 0.6806997271, 0.1073969566], [0.0883024619, 0.2817188376, 0.6299787005] ];

矩阵乘法的结果就是三个 LMS 分量:

function linSrgbToLms(r, g, b) { let l = M1[0][0] * r + M1[0][1] * g + M1[0][2] * b; let m = M1[1][0] * r + M1[1][1] * g + M1[1][2] * b; let s = M1[2][0] * r + M1[2][1] * g + M1[2][2] * b; return [l, m, s]; }

M1 矩阵的行总和很接近 1,所以它不会像“超亮纯色”那样把峰值推到离谱的数值。接着要对 LMS 做立方根运算:

l = Math.cbrt(l); m = Math.cbrt(m); s = Math.cbrt(s);

这一步是关键。人眼对光的响应不是线性的,在暗区特别敏感,在亮区又不那么敏感。立方根近似这种非线性感知压缩。为什么是三次方根而不是自然对数?因为三次方根在零点附近的行为很干净,不会像对数那样在 0 附近直接炸到负无穷,而且逆运算就是三次方,写起来简单、计算也稳定。

2.3 第三步:M2 矩阵把压缩后的 LMS 映射到 L、a、b

现在把压缩后的 LMS 再乘一个矩阵,得到最终三个分量。这个矩阵就叫 M2:

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

计算方式:

function lmsToOklab(l, m, s) { let L = M2[0][0] * l + M2[0][1] * m + M2[0][2] * s; let a = M2[1][0] * l + M2[1][1] * m + M2[1][2] * s; let b = M2[2][0] * l + M2[2][1] * m + M2[2][2] * s; return [L, a, b]; }

结果中 L 的范围约在 0 到 1 之间,白色是 1,黑色是 0,中间灰 0.5 附近大致对应中灰。a 和 b 是有负数的,范围大约在 -0.4 到 0.4 之间。具体边界取决于色域,比如 sRGB 中最饱和的蓝色在 OKLab 里的 b 值就可能接近 -0.3。

整合成一个完整函数:

function srgbToOklab(r, g, b) { [r, g, b] = srgbToLinearRGB(r, g, b); let lms = linSrgbToLms(r, g, b); lms[0] = Math.cbrt(lms[0]); lms[1] = Math.cbrt(lms[1]); lms[2] = Math.cbrt(lms[2]); return lmsToOklab(lms[0], lms[1], lms[2]); }

这样写最大的好处是每一步都能单独调试。我以前图省事把整串矩阵合在一起,结果某个通道偏色,查了半天才发现是某个系数抄串了。

2.4 逆变换:从 OKLab 回到 sRGB 必须知道的那组数

正向转换会了,逆向转换其实就倒着走一遍,但要用到另一组矩阵。我先给结论,再解释不要踩的坑。

先把 L、a、b 用下面这个矩阵乘回去,得到三个 LMS 值,然后对它们取三次方:

const M2Inv = [ [ 1.0000000000, 0.3963377774, 0.2158037573], [ 1.0000000000, -0.1055613458, -0.0638541728], [ 1.0000000000, -0.0894841775, -1.2914855480] ];

注意 M2Inv 的第一列全是 1,第二列和第三列有正有负。这个矩阵是 M2 的逆矩阵,但正因为 OKLab 的 L 分量是三个方向大致求和的关系,逆矩阵的第一列会变成 1,这是中间灰的亮度和色度分量解耦的结果。

接下来对 LMS 做三次方:

l = Math.pow(lmsL, 3); m = Math.pow(lmsM, 3); s = Math.pow(lmsS, 3);

然后乘回 M1 的逆矩阵,把 LMS 转成线性 sRGB:

const M1Inv = [ [ 4.0767416621, -1.2684380046, -0.0041960863], [-3.3077115913, 2.6097574011, -0.7034186147], [ 0.2309699292, -0.3413193965, 1.7076147010] ];

最后对线性 sRGB 做 gamma 编码,转回 8-bit 值,也就是线性到 sRGB 的逆变换:

function linearToSrgb(c) { if (c <= 0.0031308) { return Math.round(255 * (12.92 * c)); } return Math.round(255 * (1.055 * Math.pow(c, 1 / 2.4) - 0.055)); }

完整的逆向函数就可以写出来:

function oklabToSrgb(L, a, b) { let lmsL = M2Inv[0][0] * L + M2Inv[0][1] * a + M2Inv[0][2] * b; let lmsM = M2Inv[1][0] * L + M2Inv[1][1] * a + M2Inv[1][2] * b; let lmsS = M2Inv[2][0] * L + M2Inv[2][1] * a + M2Inv[2][2] * b; lmsL = lmsL * lmsL * lmsL; lmsM = lmsM * lmsM * lmsM; lmsS = lmsS * lmsS * lmsS; let r = M1Inv[0][0] * lmsL + M1Inv[0][1] * lmsM + M1Inv[0][2] * lmsS; let g = M1Inv[1][0] * lmsL + M1Inv[1][1] * lmsM + M1Inv[1][2] * lmsS; let b2 = M1Inv[2][0] * lmsL + M1Inv[2][1] * lmsM + M1Inv[2][2] * lmsS; return [linearToSrgb(r), linearToSrgb(g), linearToSrgb(b2)]; }

这段公式里最容易犯的错误是矩阵转置。像这种 3x3 矩阵乘法,行列顺序不同结果完全不同。我的习惯是把矩阵定义成二维数组,然后在乘法时用双层循环,或者直接用明确的变量展开。展开虽然冗长,但肉眼可检查,排错效率更高。

3. 在 OKLab 空间里确定色域:不是简单画个圈就完事

如果你只是想把某个 sRGB 颜色转成 OKLab 看一眼,前面两节就够了。但在做取色器、色板生成、渐变调色这些工具时,真正的难点是“给定一个 OKLab 坐标,怎么知道它能不能在屏幕上正常显示”。

常见做法是在 UI 上画一个圆形色相盘,中心是灰色,越往外彩度越高。问题来了:sRGB 色域在 OKLab 空间里并不是规整的圆柱体,甚至不是一个简单的凸包形状。所以你不能用“半径小于某个固定值就是合法颜色”这种简单判断。

3.1 为什么不能直接拿 RGB 立方体当参考系

先想明白一件事:sRGB 色域在三维 RGB 空间里就是三个通道都在 0 到 1 之间的立方体。这个概念很直观,所有合法颜色都落在立方体内部。但把它通过非线性立方根和矩阵运算映射到 OKLab 空间后,这个立方体会被“拧”成一个形状,类似一块被扭曲但又保持连通的三维果冻。

更麻烦的是,OKLab 分离出了亮度轴 L,而 a、b 是色度轴。如果你固定 L,再固定一个色相角 h,让彩度 C 从 0 开始逐渐增大,得到的是一组在三维空间里呈螺旋状上升的点。这条曲线和 sRGB 立方体交界的位置,没有解析解可以直接算出来,只能靠数值方法逼近。

所以在 OKLab 里色域确定,本质上是求解“给定亮度 L 和色相 h,该方向上的最大可用彩度 Cmax 是多少”,或者反过来,“给定一个 (L, a, b),判断它逆变换后的 RGB 是否都在 [0,1] 区间内”。

3.2 最靠谱的方法:逆变换回检

逆变换回检是准确度最高的方法,因为它的判断条件就来自定义本身。给你一个 OKLab 坐标,只要把它逆变换回 sRGB,再看三个通道是否都在合法范围里,合法就说明它是 sRGB 色域内的颜色。

这个方法最大的优点是简单、不会误判。任何正向转换和逆向转换都精确对应的情况下,回检答案天然精确。缺点是每个点都要做一次矩阵乘法,在处理大量像素或者实时拖动滑块时可能有点慢,但在普通取色器里完全够用。

下面这句是核心判断:

function inSrgbGamut(L, a, b) { let [r, g, bCh] = oklabToSrgbLinear(L, a, b); return r >= -EPS && r <= 1 + EPS && g >= -EPS && g <= 1 + EPS && bCh >= -EPS && bCh <= 1 + EPS; }

注意这里我用的是线性 sRGB,而不是最终的 8-bit 数值。原因是 8-bit 量化会让边缘颜色变化不连续,线性值判断更干净。EPS 一般取 1e-5 或 1e-6 就行,用来吸收浮点误差。不要取太大,否则会把一些本来非法但只超一点点边缘的颜色当成合法。

我代码里一般单独写个oklabToSrgbLinear函数,把线性到 8-bit 的编码过程留给上层调用。这样色域判断不经过 gamma 编码,数学上更纯净。

3.3 二分搜索 Cmax:取色器里最常用的算法

既然有了回检工具,求固定 L 和 h 下的最大彩度 C,最自然的做法是二分搜索。你设一个区间,左端是 0,右端给定一个足够大的值,不断缩小判断边界,直到找到合法和非法之间的边界点。

比如 UI 色相盘上,用户选了一个色相角度 hue,输入一个亮度 L,程序要算出那一层能画到圆盘边缘的彩度半径。可以这样写:

function maxChromaForHue(L, hueDeg) { let hue = hueDeg * Math.PI / 180; let lo = 0; let hi = 0.5; // OKLab 最大彩度一般不会超过 0.5 for (let i = 0; i < 32; i++) { let mid = (lo + hi) / 2; let a = mid * Math.cos(hue); let b = mid * Math.sin(hue); if (inSrgbGamut(L, a, b)) { lo = mid; } else { hi = mid; } } return lo; }

二分迭代 32 次已经足够,再多也不会带来肉眼可见的精度差异。但这里有个容易被忽略的细节:二分搜索默认色度从 O 到 Cmax 之间是连续可行的。如果 OKLab 色域在这个方向上被切成两个不连续的区段,二分收敛到的可能是第一个边界的内部,而不是真正外部边界。实际项目中我测试下来,OKLab 的 dL、dh 组合在 sRGB 色域内基本都是“从中心到外”的连续延伸,少数极端暗部会有退化,但二分最终落在边界上仍然可用,不会造成取色器圆盘出现明显错误。

3.4 凸包近似:适合快速绘制边界轮廓

如果做一个实时的色域边界预览,每个像素都做逆变换回检,在 Web 上也能跑,但如果你要给整个色相环画一圈轮廓线,更快的办法是凸包近似。

思路是这样的:先用某个固定 L 值,在 RGB 立方体的表面均匀取大量点,转成 OKLab,然后在 a-b 平面上计算这些点的二维凸包。凸包内部就是你近似认为的该亮度下可显示颜色范围。

这种办法的好处是画轮廓极快,而且天然光滑。缺点也很明显:凸包会把一些凹陷处的非法颜色也包进去,造成判断过宽。实际用下来,sRGB 色域在 OKLab 的 a-b 投影里整体形态还算饱满,凹陷不太多,所以凸包落在 UI 上效果挺好。但如果你的用途是渲染一个严格色域,比如把非法区域画成灰显,那还得靠逆变换回检。

凹凸包实现时可以按角度扫描,也可以用现成的计算几何库。因为只是二维点集,计算量不大。建议在每个固定 L 层,把边界取自 RGB 立方体所有棱边上的点,采样密度每根棱 32 或者 64 个就够,太高反而会因为浮点噪声导致凸包抖动。

3.5 LUT 预计算:把色度边界做成表格

如果你在做一个可拖动的亮度滑块和一个 360 度色相环,每次拖动都实时算二分,虽然性能够,但代码会显得很乱。更工程化的做法是启动时预计算一份“色相-亮度-最大彩度”的查找表。

典型的表结构是按色相角度每 1 度或每 5 度一个槽位,亮度从 0 到 1 每 0.01 或 0.02 一个层级,表中存的是该位置的最大彩度 Cmax。整个表的存储量大概是 360 * 50 = 18000 个浮点数,几 KB 的东西,现代设备完全无压力。

绘制色相环时,查询某个色相角度的最大彩度后,直接把圆盘半径映射到对应的 Cmax,这样即便用户拖动亮度,圆盘边界也会实时变,视觉上特别自然。我自己做某跨平台系统的取色器时,就是预生成了这个表格,再用 Canvas 把色域外的部分画成灰化或者虚化,效果比直接画一个大圆好很多。

预计算的核心代码如下:

let gamutTable = {}; for (let h = 0; h < 360; h += 5) { for (let L = 0; L <= 1.0; L += 0.02) { let C = maxChromaForHue(L, h); gamutTable[h + '_' + L.toFixed(2)] = C; } }

用的时候直接查表,如果需要插值,就在相邻 L 和相邻 h 之间做双线性插值。亮度和色相两个方向都能平滑过渡。

4. 颜色转换和色域判定里的四个大坑,我都踩过

这一节想分享的都是一些看起来很小、实际影响很大的细节。你照着我前面代码写,大概率能跑对,但如果不了解这些坑,后续调试起来会非常难受。

4.1 浮点误差会让你把一个边缘合法颜色判定成非法

OKLab 逆变换末端那步,也就是 LMS 三次方之后乘 M1Inv,得到的线性 RGB 通常不会精确等于 0 或 1,而会差在 1e-6 量级。比如某个边缘颜色,理论上应该是 (1.0, 0.2, 0.3),实际算出来可能是 (0.9999999, 0.2, 0.3)。如果不留余量,你的色域判定会随机把一些合法颜色当成非法。

所以回检判断我强烈建议加上 EPS。但 EPS 也别乱设,1e-5 足够了。如果设成 1e-2,那么超过边界 1% 的颜色也会被判成合法,取色器圆盘边缘会糊掉一层本不该显示的颜色。

4.2 色相角计算要用 atan2,别用 arctan(b/a)

从 OKLab 的 a、b 计算色相时,标准做法是atan2(b, a),这样才能正确区分四个象限。如果你用Math.atan(b/a),在 a 为负时方向会出错,色相环会直接分成两半错位。这个错误极其隐蔽,因为很多颜色乍一看挺自然,但对比中间过渡就会发现色相跳变。

同时注意 JS 的三角函数用弧度,用户输入通常用角度,转换时别漏掉乘以Math.PI / 180。我见过不少代码写到最后才发现色相盘刻度对不上,查了半天是角度弧度混用了。

4.3 二分搜索别忘记检查 L 本身的合法性

二分 C 的过程默认 L 是合法的,但 L 超范围的情况是真实存在的。OKLab 的 L 范围虽然是 0 到 1,但并不是每个 L 值都对应真实可感知灰色。比如 L=1 时只有白色是合法的,任何带一点彩度的 C 都会越界。L=0 时只有黑色合法。所以在某些极端亮度下,二分出来的 Cmax 很可能就是 0,UI 上表现为圆盘在该亮度下没有任何可显示颜色。

你要是做 UI 圆盘,建议直接对亮度做一个最小值和最大值限制,比如 L 从 0.05 到 0.95,这样圆盘能始终显示一个适中的彩色区域。真实色域边界在靠近两极时很窄,一扫到边缘就变成一条细线,观感其实不好。

4.4 从 OKLab 圆柱坐标系回检时,要回到直角坐标

很多实现为了操作方便,会把 OKLab 转成 Oklch,也就是 (L, C, h) 形式。色相环 UI 上你用 Oklch 没问题,但做色域回检时一定要先转回 (L, a, b)。否则你只能拿一个包含色相信息的圆柱坐标去判断,判断函数还要临时转化,虽然也能写,但容易让人犯迷糊。我习惯内层函数只接受(L, a, b),所有圆柱坐标的转换放在外层完成。

提一个实际经历:有次我把某个 L 和 h 下二分出来的 C 直接用 Oklch 的圆柱表达式回检,结果因为忘记把C*cos(h)和C*sin(h)加上,颜色全都在一个方向偏掉,花了半天才意识到是坐标制混乱导致的锅。

5. 后话:这些计算我建议直接落进自己的 ColorUtils 工具集

前面写的函数虽然零散,但拼起来就是一个非常顺手的颜色工具集。在我的模拟项目 X 里,我把srgbToOklab、oklabToSrgb、oklabToOklch、oklchToOklab、inSrgbGamut、maxChromaForHue都放在一个模块里,然后用同一套基准跑单元测试。比如输入纯红、纯绿、纯蓝、白、黑,验证正逆转换回后数值误差在几个十进制以内。

验证方法很简单:先生成一组 0 到 255 之间随机 RGB,转成 OKLab,再转回来,看 RGB 误差是否在 1 以内。只要误差小于 1 就说明公式没写错,矩阵顺序没问题。这个测试成本极低,但能挡住 90% 的抄错系数、把矩阵转置写反之类的低级问题。

写到这里我想说一句大实话:OKLab 的数学门槛真的不高,难的是你要有一股“把所有步骤拆开、一个个验证”的耐心。我第一次在项目里接入时,也因为矩阵和 gamma 细节翻过车,但一旦把这些沉淀成工具函数,后面写渐变插值、做色板、做取色器,全都是水到渠成的事。

如果你正在做取色器或者渐变调色工具,强烈建议你接下来把 Oklch 的色相旋转、双线性渐变插值也一起实现。OKLab 在这两个场景里的优势比我前两篇讲的还要明显。工具集在手,慢慢玩,你会发现颜色空间转换这件事,比想象中有意思得多。

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

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

立即咨询