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)
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)
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.
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):
[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):
"""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:
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:
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:
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
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
/* 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:
Δ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:
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:
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:
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 |
A2B0–A2B2 |
Device-to-PCS lookup tables |
B2A0–B2A2 |
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
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:
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)
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:
// 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
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