基本区分:模型 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)

code
if C_linear ≤ 0.0031308:
    C_sRGB = 12.92 × C_linear
else:
    C_sRGB = 1.055 × C_linear^(1/2.4) − 0.055

解码(sRGB → 线性)

code
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% 的误差。

javascript
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 白点):

code
[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]
python
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 对比度计算的基础:

code
L = 0.2126 × R_linear + 0.7152 × G_linear + 0.0722 × B_linear

系数反映人类光谱敏感度:绿色对感知亮度贡献最大。

色适应(Bradford 变换)

在不同光源之间转换(如 D65 到 D50 用于 ICC profile)时需要色适应变换:

python
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 非线性变换实现感知均匀:

code
OKLCH 坐标:
  L: 明度(0 = 黑,1 = 白,感知线性)
  C: 彩度(0 = 灰,正值无界 = 饱和)
  H: 色相(0–360°,感知均匀间距)

L 的等步长产生等感知亮度变化。H 的等步长产生等感知色相变化。这是 HSL 声称但未能实现的。

转换:sRGB → OKLAB → OKLCH

python
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

css
/* 感知均匀调色板 */
: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 中的欧几里得距离。简单但对饱和色不准确:

code
ΔE₇₆ = √((L₁−L₂)² + (a₁−a₂)² + (b₁−b₂)²)
ΔE 人眼感知
< 1 不可察觉
1–2 勉强可察觉
2–5 可注意到
5–10 明显不同
> 10 不同颜色

ΔE2000(CIEDE2000)

当前色差感知标准。增加了明度、彩度和色相加权校正,以及蓝色区域的旋转项:

python
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 转换时,超色域颜色必须映射到色域内的等价物:

code
策略:
  1. 裁剪:将每通道钳制到 [0, 1]。快但扭曲色相。
  2. 彩度缩减:在 OKLCH 中缩减饱和度,保持 L 和 H。
  3. 感知:ICC 呈现意图压缩整个色域。

CSS 色域映射(CSS Color Level 4)

CSS 规范定义了在 OKLCH 中通过二分搜索缩减彩度直到颜色适合目标色域的算法:

javascript
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 白点
A2B0A2B2 设备到 PCS 查找表
B2A0B2A2 PCS 到设备查找表

呈现意图

意图 行为 用途
感知 压缩整个色域以保持关系 照片
相对色度 映射白点,裁剪超色域 打样、Logo
饱和度 以准确性为代价最大化饱和度 商业图形
绝对色度 无适应,精确再现 专色

WCAG 对比度:正确计算

相对亮度

javascript
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 暗底亮字)和字体大小/粗细:

javascript
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

javascript
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 模型——绝不应用于印刷生产:

javascript
// 朴素转换(无 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)

总结:转换管线

code
源颜色
  → 解码传递函数(如 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