基本区分:模型 vs 编码 vs Profile
在转换颜色之前,理解三个层次:
| 层次 | 定义什么 | 示例 |
|---|---|---|
| 色彩模型 | 通道语义(加法 RGB、圆柱 HSL) | "3 通道:红、绿、蓝" |
| 色彩编码 | 传递函数 + 原色色度坐标 + 白点 | sRGB、Display P3、Adobe RGB |
| 设备 Profile | 特定显示器/打印机如何再现编码值 | "Dell U2723QE" 的 ICC profile |
没有指定编码的 RGB 值是模糊的。sRGB 中的 rgb(255, 0, 0) 和 Display P3 中的同一数值是不同的物理颜色——P3 红色饱和度高 26%。
sRGB 传递函数
sRGB 不使用简单的 2.2 gamma。实际传递函数是分段的:
编码(线性 → sRGB)
if C_linear ≤ 0.0031308:
C_sRGB = 12.92 × C_linear
else:
C_sRGB = 1.055 × C_linear^(1/2.4) − 0.055
解码(sRGB → 线性)
if C_sRGB ≤ 0.04045:
C_linear = C_sRGB / 12.92
else:
C_linear = ((C_sRGB + 0.055) / 1.055)^2.4
黑色附近的线性段避免了零点处的无限斜率。简单的 pow(x, 2.2) 近似在暗调中引入高达 2% 的误差。
function srgbToLinear(c) {
c /= 255;
return c <= 0.04045
? c / 12.92
: Math.pow((c + 0.055) / 1.055, 2.4);
}
function linearToSrgb(c) {
const v = c <= 0.0031308
? c * 12.92
: 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(v * 255);
}
为什么重要
所有色彩数学(混合、插值、光照)必须在线性空间中进行。直接在 gamma 编码的 sRGB 中插值会产生众所周知的渐变"暗带"伪影。
CIE XYZ:Profile 连接空间
CIE XYZ(1931)是数学定义的色彩空间,连接所有其他空间。任何色彩编码都可以转换为 XYZ,再从 XYZ 转换到任何其他编码。
sRGB 到 XYZ
转换使用 sRGB 原色矩阵(D65 白点):
[X] [0.4124564 0.3575761 0.1804375] [R_linear]
[Y] = [0.2126729 0.7151522 0.0721750] [G_linear]
[Z] [0.0193339 0.1191920 0.9503041] [B_linear]
import numpy as np
SRGB_TO_XYZ = np.array([
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
])
XYZ_TO_SRGB = np.linalg.inv(SRGB_TO_XYZ)
def srgb_to_xyz(r, g, b):
"""将 sRGB [0-255] 转换为 CIE XYZ。"""
linear = np.array([srgb_channel_to_linear(c / 255) for c in (r, g, b)])
return SRGB_TO_XYZ @ linear
def srgb_channel_to_linear(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
相对亮度(Y)
XYZ 的 Y 分量是相对亮度——WCAG 对比度计算的基础:
L = 0.2126 × R_linear + 0.7152 × G_linear + 0.0722 × B_linear
系数反映人类光谱敏感度:绿色对感知亮度贡献最大。
色适应(Bradford 变换)
在不同光源之间转换(如 D65 到 D50 用于 ICC profile)时需要色适应变换:
BRADFORD = np.array([
[ 0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[ 0.0389, -0.0685, 1.0296],
])
def adapt_d65_to_d50(xyz):
"""Bradford 色适应从 D65 到 D50。"""
D65 = np.array([0.95047, 1.0, 1.08883])
D50 = np.array([0.96422, 1.0, 0.82521])
src_cone = BRADFORD @ D65
dst_cone = BRADFORD @ D50
scale = dst_cone / src_cone
M = np.linalg.inv(BRADFORD) @ np.diag(scale) @ BRADFORD
return M @ xyz
OKLCH:感知均匀色彩
为什么 HSL 失败
HSL 的"亮度"是几何中点,不是感知亮度。hsl(60, 100%, 50%)(黄色)和 hsl(240, 100%, 50%)(蓝色)具有相同的 L=50%,但感知亮度差异巨大。
OKLAB/OKLCH 模型
Björn Ottosson 的 OKLAB(2020)通过精心调校的 XYZ 非线性变换实现感知均匀:
OKLCH 坐标:
L: 明度(0 = 黑,1 = 白,感知线性)
C: 彩度(0 = 灰,正值无界 = 饱和)
H: 色相(0–360°,感知均匀间距)
L 的等步长产生等感知亮度变化。H 的等步长产生等感知色相变化。这是 HSL 声称但未能实现的。
转换:sRGB → OKLAB → OKLCH
import math
def srgb_to_oklab(r, g, b):
"""将 sRGB [0-255] 转换为 OKLAB [L, a, b]。"""
# 线性化
lr = srgb_channel_to_linear(r / 255)
lg = srgb_channel_to_linear(g / 255)
lb = srgb_channel_to_linear(b / 255)
# 线性 RGB 到 LMS(锥体响应)
l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
# 立方根(感知非线性)
l_ = math.copysign(abs(l) ** (1/3), l)
m_ = math.copysign(abs(m) ** (1/3), m)
s_ = math.copysign(abs(s) ** (1/3), s)
# LMS 到 OKLAB
L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
b_val = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
return (L, a, b_val)
def oklab_to_oklch(L, a, b):
"""将 OKLAB 转换为 OKLCH。"""
C = math.sqrt(a * a + b * b)
H = math.degrees(math.atan2(b, a)) % 360
return (L, C, H)
CSS Color Level 4:使用 OKLCH
/* 感知均匀调色板 */
:root {
--primary: oklch(55% 0.2 250); /* 蓝色 */
--primary-light: oklch(75% 0.15 250); /* 同色相,更亮 */
--primary-dark: oklch(35% 0.2 250); /* 同色相,更暗 */
/* 相对颜色语法:从基色派生变体 */
--hover: oklch(from var(--primary) calc(l - 0.1) c h);
--muted: oklch(from var(--primary) l calc(c * 0.5) h);
}
/* Display P3 广色域 */
.vibrant {
background: color(display-p3 1 0.2 0.1);
}
/* 不支持 oklch 的浏览器回退 */
@supports not (color: oklch(50% 0.2 0)) {
:root {
--primary: hsl(210, 70%, 50%);
}
}
Delta E:测量色差
ΔE76(CIE 1976)
CIELAB 中的欧几里得距离。简单但对饱和色不准确:
ΔE₇₆ = √((L₁−L₂)² + (a₁−a₂)² + (b₁−b₂)²)
| ΔE | 人眼感知 |
|---|---|
| < 1 | 不可察觉 |
| 1–2 | 勉强可察觉 |
| 2–5 | 可注意到 |
| 5–10 | 明显不同 |
| > 10 | 不同颜色 |
ΔE2000(CIEDE2000)
当前色差感知标准。增加了明度、彩度和色相加权校正,以及蓝色区域的旋转项:
import math
def delta_e_2000(lab1, lab2):
"""CIEDE2000 色差。"""
L1, a1, b1 = lab1
L2, a2, b2 = lab2
# 步骤 1:计算 C' 和 h'
C1 = math.sqrt(a1**2 + b1**2)
C2 = math.sqrt(a2**2 + b2**2)
C_avg = (C1 + C2) / 2
C_avg_7 = C_avg**7
G = 0.5 * (1 - math.sqrt(C_avg_7 / (C_avg_7 + 25**7)))
a1_prime = a1 * (1 + G)
a2_prime = a2 * (1 + G)
C1_prime = math.sqrt(a1_prime**2 + b1**2)
C2_prime = math.sqrt(a2_prime**2 + b2**2)
h1_prime = math.degrees(math.atan2(b1, a1_prime)) % 360
h2_prime = math.degrees(math.atan2(b2, a2_prime)) % 360
# 步骤 2:计算 ΔL', ΔC', ΔH'
dL = L2 - L1
dC = C2_prime - C1_prime
if C1_prime * C2_prime == 0:
dh = 0
elif abs(h2_prime - h1_prime) <= 180:
dh = h2_prime - h1_prime
elif h2_prime - h1_prime > 180:
dh = h2_prime - h1_prime - 360
else:
dh = h2_prime - h1_prime + 360
dH = 2 * math.sqrt(C1_prime * C2_prime) * math.sin(math.radians(dh / 2))
# 步骤 3:加权函数
L_avg = (L1 + L2) / 2
C_avg_prime = (C1_prime + C2_prime) / 2
SL = 1 + 0.015 * (L_avg - 50)**2 / math.sqrt(20 + (L_avg - 50)**2)
SC = 1 + 0.045 * C_avg_prime
if C1_prime * C2_prime == 0:
h_avg = h1_prime + h2_prime
elif abs(h1_prime - h2_prime) <= 180:
h_avg = (h1_prime + h2_prime) / 2
else:
h_avg = (h1_prime + h2_prime + 360) / 2
T = (1 - 0.17 * math.cos(math.radians(h_avg - 30))
+ 0.24 * math.cos(math.radians(2 * h_avg))
+ 0.32 * math.cos(math.radians(3 * h_avg + 6))
- 0.20 * math.cos(math.radians(4 * h_avg - 63)))
SH = 1 + 0.015 * C_avg_prime * T
# 旋转
C_avg_prime_7 = C_avg_prime**7
RC = 2 * math.sqrt(C_avg_prime_7 / (C_avg_prime_7 + 25**7))
d_theta = 30 * math.exp(-((h_avg - 275) / 25)**2)
RT = -math.sin(math.radians(2 * d_theta)) * RC
return math.sqrt(
(dL / SL)**2 + (dC / SC)**2 + (dH / SH)**2
+ RT * (dC / SC) * (dH / SH)
)
色域映射
问题
Display P3 包含 sRGB 无法表示的颜色。P3 → sRGB 转换时,超色域颜色必须映射到色域内的等价物:
策略:
1. 裁剪:将每通道钳制到 [0, 1]。快但扭曲色相。
2. 彩度缩减:在 OKLCH 中缩减饱和度,保持 L 和 H。
3. 感知:ICC 呈现意图压缩整个色域。
CSS 色域映射(CSS Color Level 4)
CSS 规范定义了在 OKLCH 中通过二分搜索缩减彩度直到颜色适合目标色域的算法:
function gamutMapToSrgb(oklch) {
let [L, C, H] = oklch;
if (isInSrgbGamut(L, C, H)) return oklchToSrgb(L, C, H);
// 彩度二分搜索
let lo = 0, hi = C;
while (hi - lo > 0.001) {
const mid = (lo + hi) / 2;
if (isInSrgbGamut(L, mid, H)) {
lo = mid;
} else {
hi = mid;
}
}
return oklchToSrgb(L, lo, H);
}
function isInSrgbGamut(L, C, H) {
const [r, g, b] = oklchToLinearSrgb(L, C, H);
const epsilon = 0.000001;
return r >= -epsilon && r <= 1 + epsilon
&& g >= -epsilon && g <= 1 + epsilon
&& b >= -epsilon && b <= 1 + epsilon;
}
ICC Profile 架构
Profile 结构
ICC profile(ICC.1:2022)包含:
| 标签 | 用途 |
|---|---|
rXYZ, gXYZ, bXYZ |
原色色度坐标 |
rTRC, gTRC, bTRC |
传递曲线(色调响应曲线) |
wtpt |
白点 |
A2B0–A2B2 |
设备到 PCS 查找表 |
B2A0–B2A2 |
PCS 到设备查找表 |
呈现意图
| 意图 | 行为 | 用途 |
|---|---|---|
| 感知 | 压缩整个色域以保持关系 | 照片 |
| 相对色度 | 映射白点,裁剪超色域 | 打样、Logo |
| 饱和度 | 以准确性为代价最大化饱和度 | 商业图形 |
| 绝对色度 | 无适应,精确再现 | 专色 |
WCAG 对比度:正确计算
相对亮度
function relativeLuminance(r, g, b) {
const [lr, lg, lb] = [r, g, b].map(c => {
c /= 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
}
function contrastRatio(rgb1, rgb2) {
const L1 = relativeLuminance(...rgb1);
const L2 = relativeLuminance(...rgb2);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// WCAG 2.x 要求:
// 正常文本:≥ 4.5:1(AA),≥ 7:1(AAA)
// 大文本: ≥ 3:1(AA), ≥ 4.5:1(AAA)
APCA(WCAG 3.0 草案)
可感知对比度算法考虑极性(亮底暗字 vs 暗底亮字)和字体大小/粗细:
function apcaContrast(textRgb, bgRgb) {
const Ytxt = relativeLuminance(...textRgb);
const Ybg = relativeLuminance(...bgRgb);
// 软钳制亮度
const txtY = Ytxt > 0.022 ? Ytxt : Ytxt + (0.022 - Ytxt) ** 1.414;
const bgY = Ybg > 0.022 ? Ybg : Ybg + (0.022 - Ybg) ** 1.414;
// 极性相关指数
let Lc;
if (bgY > txtY) {
Lc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;
} else {
Lc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;
}
return Math.abs(Lc) < 0.1 ? 0 : Lc > 0 ? Lc - 0.027 : Lc + 0.027;
}
实用转换代码
RGB ↔ HSL
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
};
}
function hslToRgb(h, s, l) {
h /= 360; s /= 100; l /= 100;
if (s === 0) {
const v = Math.round(l * 255);
return { r: v, g: v, b: v };
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const hue2rgb = (t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
return {
r: Math.round(hue2rgb(h + 1/3) * 255),
g: Math.round(hue2rgb(h) * 255),
b: Math.round(hue2rgb(h - 1/3) * 255)
};
}
RGB ↔ CMYK(朴素 vs ICC)
朴素公式在没有 ICC profile 的情况下反转 CMY 模型——绝不应用于印刷生产:
// 朴素转换(无 ICC profile——仅用于屏幕预览)
function rgbToCmykNaive(r, g, b) {
if (r === 0 && g === 0 && b === 0) return { c: 0, m: 0, y: 0, k: 100 };
const rr = r / 255, gg = g / 255, bb = b / 255;
const k = 1 - Math.max(rr, gg, bb);
const denom = 1 - k;
return {
c: Math.round((1 - rr - k) / denom * 100),
m: Math.round((1 - gg - k) / denom * 100),
y: Math.round((1 - bb - k) / denom * 100),
k: Math.round(k * 100)
};
}
// 印刷用途:使用带 CMM 的 ICC profile
// 如 littlecms (C)、lcms2 (Python Pillow)、ColorSync (macOS)
总结:转换管线
源颜色
→ 解码传递函数(如 sRGB gamma 到线性)
→ 应用源 Profile 矩阵(线性 RGB → XYZ)
→ 需要时进行色适应(D65 → D50)
→ 应用目标 Profile 逆变换(XYZ → 线性目标 RGB)
→ 超色域时进行色域映射(钳制 / 彩度缩减)
→ 编码传递函数(线性 → 目标 gamma)
→ 量化到目标位深
跳过任何步骤都会引入误差。最常见的错误是在 gamma 编码的 sRGB 中插值(产生暗带)或在没有 ICC profile 的情况下转换为 CMYK(产生错误的印刷颜色)。
参考文献
- IEC 61966-2-1:1999 — sRGB colour space(定义传递函数和原色)
- CIE 015:2018 — Colorimetry(XYZ、CIELAB、标准观察者)
- Ottosson, B. (2020). "A perceptual color space for image processing" — OKLAB/OKLCH
- ICC.1:2022 — Image technology colour management(ICC profile 规范)
- CSS Color Level 4 — W3C Specification(color()、oklch()、相对颜色语法)
- WCAG 2.2 — Web Content Accessibility Guidelines(对比度要求)
- Sharma, G., Wu, W., Dalal, E. N. (2005). "The CIEDE2000 Color-Difference Formula" — ΔE2000