资讯动态

OKLab与sRGB颜色空间转换及色域边界判定详解

发布时间:2026/10/20 8:42:07 来源:尧图企业网站定制
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 再乘一个矩阵得到最终三个分量。这个矩阵就叫 M2const 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 转成线性 sRGBconst 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 值都对应真实可感知灰色。比如 L1 时只有白色是合法的任何带一点彩度的 C 都会越界。L0 时只有黑色合法。所以在某些极端亮度下二分出来的 Cmax 很可能就是 0UI 上表现为圆盘在该亮度下没有任何可显示颜色。你要是做 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 在这两个场景里的优势比我前两篇讲的还要明显。工具集在手慢慢玩你会发现颜色空间转换这件事比想象中有意思得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑