The Fundamental Distinction: Model vs Encoding vs Profile

Before converting colors, understand three layers:

Layer What it defines Example
Color model Channel semantics (additive RGB, cylindrical HSL) "3 channels: red, green, blue"
Color encoding Transfer function + primary chromaticities + white point sRGB, Display P3, Adobe RGB
Device profile How a specific display/printer reproduces encoded values ICC profile for "Dell U2723QE"

RGB values without a specified encoding are ambiguous. rgb(255, 0, 0) in sRGB and in Display P3 are different physical colors—P3 red is 26% more saturated.

The sRGB Transfer Function

sRGB does not use a simple gamma of 2.2. The actual transfer function is piecewise:

Encoding (Linear → 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

Decoding (sRGB → Linear)

code
if C_sRGB ≤ 0.04045:
    C_linear = C_sRGB / 12.92
else:
    C_linear = ((C_sRGB + 0.055) / 1.055)^2.4

The linear segment near black avoids infinite slope at zero. A simple pow(x, 2.2) approximation introduces up to 2% error in dark tones.

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);
}

Why This Matters

All color math (blending, interpolation, lighting) must happen in linear space. Interpolating directly in gamma-encoded sRGB produces the well-known "dark band" artifact in gradients.

CIE XYZ: The Profile Connection Space

CIE XYZ (1931) is the mathematically defined color space that connects all others. Any color encoding can be converted to XYZ, and from XYZ to any other encoding.

sRGB to XYZ

The conversion uses the sRGB primaries matrix (D65 white point):

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):
    """Convert sRGB [0-255] to 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

Relative Luminance (Y)

The Y component of XYZ is relative luminance—the basis for WCAG contrast calculations:

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

The coefficients reflect human spectral sensitivity: green contributes most to perceived brightness.

Chromatic Adaptation (Bradford Transform)

When converting between illuminants (e.g., D65 to D50 for ICC profiles), a chromatic adaptation transform is needed:

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 chromatic adaptation from D65 to 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: Perceptually Uniform Color

Why HSL Fails

HSL's "lightness" is a geometric midpoint, not perceptual lightness. hsl(60, 100%, 50%) (yellow) and hsl(240, 100%, 50%) (blue) have the same L=50% but vastly different perceived brightness.

The OKLAB/OKLCH Model

Björn Ottosson's OKLAB (2020) achieves perceptual uniformity through a carefully tuned nonlinear transform of XYZ:

code
OKLCH coordinates:
  L: Lightness (0 = black, 1 = white, perceptually linear)
  C: Chroma (0 = gray, unbounded positive = saturated)
  H: Hue (0–360°, perceptually uniform spacing)

Equal steps in L produce equal perceived brightness changes. Equal steps in H produce equal perceived hue changes. This is what HSL claims but fails to deliver.

Conversion: sRGB → OKLAB → OKLCH

python
import math

def srgb_to_oklab(r, g, b):
    """Convert sRGB [0-255] to OKLAB [L, a, b]."""
    # Linearize
    lr = srgb_channel_to_linear(r / 255)
    lg = srgb_channel_to_linear(g / 255)
    lb = srgb_channel_to_linear(b / 255)
    
    # Linear RGB to LMS (cone response)
    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
    
    # Cube root (perceptual nonlinearity)
    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 to 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):
    """Convert OKLAB to OKLCH."""
    C = math.sqrt(a * a + b * b)
    H = math.degrees(math.atan2(b, a)) % 360
    return (L, C, H)

CSS Color Level 4: Using OKLCH

css
/* Perceptually uniform color palette */
:root {
  --primary: oklch(55% 0.2 250);       /* Blue */
  --primary-light: oklch(75% 0.15 250); /* Same hue, lighter */
  --primary-dark: oklch(35% 0.2 250);   /* Same hue, darker */
  
  /* Relative color syntax: derive variants from a base */
  --hover: oklch(from var(--primary) calc(l - 0.1) c h);
  --muted: oklch(from var(--primary) l calc(c * 0.5) h);
}

/* Display P3 wide gamut */
.vibrant {
  background: color(display-p3 1 0.2 0.1);
}

/* Fallback for browsers without oklch support */
@supports not (color: oklch(50% 0.2 0)) {
  :root {
    --primary: hsl(210, 70%, 50%);
  }
}

Delta E: Measuring Color Difference

ΔE76 (CIE 1976)

Euclidean distance in CIELAB. Simple but inaccurate for saturated colors:

code
ΔE₇₆ = √((L₁−L₂)² + (a₁−a₂)² + (b₁−b₂)²)
ΔE Human perception
< 1 Imperceptible
1–2 Barely perceptible
2–5 Noticeable
5–10 Clearly different
> 10 Different colors

ΔE2000 (CIEDE2000)

The current standard for perceptual color difference. It adds corrections for lightness, chroma, and hue weighting, plus a rotation term for the blue region:

python
import math

def delta_e_2000(lab1, lab2):
    """CIEDE2000 color difference."""
    L1, a1, b1 = lab1
    L2, a2, b2 = lab2
    
    # Step 1: Calculate C' and 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
    
    # Step 2: Calculate Δ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))
    
    # Step 3: Weighting functions
    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
    
    # h_avg calculation (simplified)
    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
    
    # Rotation
    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)
    )

Gamut Mapping

The Problem

Display P3 contains colors that sRGB cannot represent. When converting P3 → sRGB, out-of-gamut colors must be mapped to in-gamut equivalents:

code
Strategies:
  1. Clipping: clamp each channel to [0, 1]. Fast but distorts hue.
  2. Chroma reduction: reduce saturation in OKLCH while preserving L and H.
  3. Perceptual: ICC rendering intent that compresses the entire gamut.

CSS Gamut Mapping (CSS Color Level 4)

The CSS specification defines a binary-search algorithm in OKLCH that reduces chroma until the color fits within the target gamut while staying within a ΔE tolerance:

javascript
function gamutMapToSrgb(oklch) {
  let [L, C, H] = oklch;
  
  if (isInSrgbGamut(L, C, H)) return oklchToSrgb(L, C, H);
  
  // Binary search on chroma
  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 Architecture

Profile Structure

An ICC profile (ICC.1:2022) contains:

Tag Purpose
rXYZ, gXYZ, bXYZ Primary chromaticities
rTRC, gTRC, bTRC Transfer curves (tone response curves)
wtpt White point
A2B0A2B2 Device-to-PCS lookup tables
B2A0B2A2 PCS-to-device lookup tables

Rendering Intents

Intent Behavior Use case
Perceptual Compress entire gamut to preserve relationships Photographs
Relative colorimetric Map white point, clip out-of-gamut Proofing, logos
Saturation Maximize saturation at expense of accuracy Business graphics
Absolute colorimetric No adaptation, exact reproduction Spot colors

WCAG Contrast: The Correct Calculation

Relative Luminance

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 requirements:
// Normal text: ≥ 4.5:1 (AA), ≥ 7:1 (AAA)
// Large text:  ≥ 3:1 (AA),   ≥ 4.5:1 (AAA)

APCA (WCAG 3.0 Draft)

The Accessible Perceptual Contrast Algorithm accounts for polarity (light-on-dark vs dark-on-light) and font size/weight:

javascript
function apcaContrast(textRgb, bgRgb) {
  const Ytxt = relativeLuminance(...textRgb);
  const Ybg = relativeLuminance(...bgRgb);
  
  // Soft-clamp luminance
  const txtY = Ytxt > 0.022 ? Ytxt : Ytxt + (0.022 - Ytxt) ** 1.414;
  const bgY = Ybg > 0.022 ? Ybg : Ybg + (0.022 - Ybg) ** 1.414;
  
  // Polarity-dependent exponents
  let Lc;
  if (bgY > txtY) {
    Lc = (bgY ** 0.56 - txtY ** 0.57) * 1.14;
  } else {
    Lc = (bgY ** 0.65 - txtY ** 0.62) * 1.14;
  }
  
  // Apply offset
  return Math.abs(Lc) < 0.1 ? 0 : Lc > 0 ? Lc - 0.027 : Lc + 0.027;
}

Practical Conversion Code

RGB ↔ HSL (with Precision Notes)

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 (Naive vs ICC)

The naive formula inverts the CMY model without an ICC profile—it should never be used for print production:

javascript
// Naive conversion (NO ICC profile — for screen preview only)
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)
  };
}

// For print: use an ICC profile with a Color Management Module (CMM)
// e.g., littlecms (C), lcms2 (Python via Pillow), ColorSync (macOS)

Summary: The Conversion Pipeline

code
Source color
  → Decode transfer function (e.g., sRGB gamma to linear)
  → Apply source profile matrix (linear RGB → XYZ)
  → Chromatic adaptation if needed (D65 → D50)
  → Apply destination profile inverse (XYZ → linear target RGB)
  → Gamut map if out of range (clamp / chroma reduce)
  → Encode transfer function (linear → target gamma)
  → Quantize to target bit depth

Skipping any step introduces error. The most common mistake is interpolating in gamma-encoded sRGB (produces dark bands) or converting to CMYK without an ICC profile (produces wrong printed colors).

References

  • IEC 61966-2-1:1999 — sRGB colour space (defines the transfer function and primaries)
  • CIE 015:2018 — Colorimetry (XYZ, CIELAB, standard observer)
  • Ottosson, B. (2020). "A perceptual color space for image processing" — OKLAB/OKLCH
  • ICC.1:2022 — Image technology colour management (ICC profile specification)
  • CSS Color Level 4 — W3C Specification (color(), oklch(), relative color syntax)
  • WCAG 2.2 — Web Content Accessibility Guidelines (contrast requirements)
  • Sharma, G., Wu, W., Dalal, E. N. (2005). "The CIEDE2000 Color-Difference Formula" — ΔE2000