Home › Graphics & UI › Image processing
algorithm low

Color space conversion

Problem Image and video software gets wrong colours or wrong blends when pixels come from different colour spaces. Resizing, blending and displaying correctly requires converting between spaces such as sRGB, Display P3 and video YCbCr.

Convert pixels between transfer curves (sRGB, linear light), primaries (sRGB, Display P3, XYZ) and Y'CbCr, using tables for the curve and 3x3 matrices for the rest.

O(1) per pixel; decode = one table load, encode = one table load, a 3x3 matrix = 9 multiply-adds
colorsrgbgammaycbcrgamutlinear-light
Overview

A pixel is three numbers only relative to a color space: its primaries (which colors R, G, B are), its transfer curve (numbers to light) and, for video, the matrix that splits luma from chroma. Averaging and blending are only correct on linear light, while storage uses curved values. So the loop is: decode curve, 3x3 matrix, encode curve.

Key facts

Use when

  • Linear-light resizing and blending, video decoding, P3 output.
  • Web pages mixing sRGB and wide-gamut colors, broadcast and streaming video (BT.601/709/2020 to RGB), print and photography with ICC profiles.

Avoid when

  • Only copying pixels with the same tagged space: do nothing.
  • Perceptual edits (gradients, hue shifts, gamut mapping): use a perceptual space such as Oklab, not linear RGB.

Deep dive

How it works

In: pixel values with their tagged space (primaries, transfer curve, matrix, range) and a target space · Out: the pixel in the target space · State: none (lookup tables built once) · Cost: per pixel one table load per channel and 9 FMAs for a matrix; 0.46 ns/px decode with the table (measured below)

  1. sRGB curve (IEC 61966-2-1), v in [0,1]: lin = v/12.92 for v <= 0.04045, else ((v+0.055)/1.055)^2.4. Inverse: v = 12.92*lin for lin <= 0.0031308, else 1.055*lin^(1/2.4) - 0.055.
  2. Primaries: with chromaticities (x,y) of R, G, B and white, the linear RGB to XYZ matrix is the primaries' XYZ columns scaled so that (1,1,1) hits the white point. Convert A to B as M_B^-1 * M_A, in linear light only. sRGB to Display P3 maps white to white (rows sum to 1) and sends sRGB red to a P3 value inside the gamut. P3 to sRGB can go negative or above 1: clamp (cheap) or gamut-map (perceptual).
  3. Y'CbCr acts on gamma-encoded R'G'B' (that is why Y' is luma, not luminance): Y' = Kr R' + Kg G' + Kb B', Cb = (B' - Y') / (2(1-Kb)), Cr = (R' - Y') / (2(1-Kr)). BT.601 uses (Kr, Kb) = (0.299, 0.114), BT.709 (0.2126, 0.0722), BT.2020 (0.2627, 0.0593). 8-bit limited ("studio") range: Y' = 16 + 219 Y', Cb/Cr = 128 + 224 C; full range uses 255 and 255/128.
  4. Video order: upsample chroma, undo range, inverse matrix, then curve to linear if you will filter or blend.
  5. Fold consecutive 3x3 matrices into one before running over the image.
YUV 4:2:0 --upsample--> Y'CbCr --range+matrix--> R'G'B' --LUT--> linear --3x3--> linear (other gamut) --LUT--> bytes

Code

pub fn srgb_to_linear(v: f64) -> f64 {
    if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
pub fn linear_to_srgb(l: f64) -> f64 {
    if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }
}

/// 8-bit sRGB -> linear f32: 256-entry table, decode is one load.
pub fn decode_lut() -> [f32; 256] {
    let mut t = [0f32; 256];
    for (i, e) in t.iter_mut().enumerate() { *e = srgb_to_linear(i as f64 / 255.0) as f32; }
    t
}
/// linear f32 -> 8-bit sRGB: 4096-entry table indexed by round(l * 4095).
pub fn encode_lut() -> Vec<u8> {
    (0..4096).map(|i| (linear_to_srgb(i as f64 / 4095.0) * 255.0 + 0.5) as u8).collect()
}
#[inline]
pub fn encode8(lut: &[u8], l: f32) -> u8 { lut[(l.clamp(0.0, 1.0) * 4095.0 + 0.5) as usize] }

pub type M3 = [[f64; 3]; 3];
pub fn mul(a: &M3, b: &M3) -> M3 {
    let mut r = [[0.0; 3]; 3];
    for i in 0..3 { for j in 0..3 { for k in 0..3 { r[i][j] += a[i][k] * b[k][j]; } } }
    r
}
pub fn inv(m: &M3) -> M3 {
    let c = |i: usize, j: usize| {
        let (i1, i2, j1, j2) = ((i + 1) % 3, (i + 2) % 3, (j + 1) % 3, (j + 2) % 3);
        m[i1][j1] * m[i2][j2] - m[i1][j2] * m[i2][j1]
    };
    let det = m[0][0] * c(0, 0) + m[0][1] * c(0, 1) + m[0][2] * c(0, 2);
    let mut r = [[0.0; 3]; 3];
    for i in 0..3 { for j in 0..3 { r[i][j] = c(j, i) / det; } }
    r
}
/// Linear RGB -> XYZ from xy chromaticities of R, G, B and the white point (Y = 1).
pub fn rgb_to_xyz(p: [(f64, f64); 3], w: (f64, f64)) -> M3 {
    let xyz = |(x, y): (f64, f64)| [x / y, 1.0, (1.0 - x - y) / y];
    let (r, g, b, wh) = (xyz(p[0]), xyz(p[1]), xyz(p[2]), xyz(w));
    let cols: M3 = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
    let s = {
        let i = inv(&cols);
        [0, 1, 2].map(|k| i[k][0] * wh[0] + i[k][1] * wh[1] + i[k][2] * wh[2])
    };
    [0, 1, 2].map(|i| [cols[i][0] * s[0], cols[i][1] * s[1], cols[i][2] * s[2]])
}
pub const D65: (f64, f64) = (0.3127, 0.3290);
pub const SRGB: [(f64, f64); 3] = [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)];
pub const P3: [(f64, f64); 3] = [(0.680, 0.320), (0.265, 0.690), (0.150, 0.060)];

/// Y'CbCr from non-linear R'G'B' in [0,1]; (kr, kb) = (0.299, 0.114) BT.601, (0.2126, 0.0722) BT.709.
/// Limited range: Y' 16..235, Cb/Cr 16..240 (8 bit).
pub fn rgb_to_ycbcr8(rgb: [f64; 3], kr: f64, kb: f64) -> [u8; 3] {
    let kg = 1.0 - kr - kb;
    let y = kr * rgb[0] + kg * rgb[1] + kb * rgb[2];
    let cb = (rgb[2] - y) / (2.0 * (1.0 - kb));
    let cr = (rgb[0] - y) / (2.0 * (1.0 - kr));
    let q = |v: f64| v.round().clamp(0.0, 255.0) as u8;
    [q(16.0 + 219.0 * y), q(128.0 + 224.0 * cb), q(128.0 + 224.0 * cr)]
}
pub fn ycbcr8_to_rgb(p: [u8; 3], kr: f64, kb: f64) -> [f64; 3] {
    let kg = 1.0 - kr - kb;
    let y = (p[0] as f64 - 16.0) / 219.0;
    let cb = (p[1] as f64 - 128.0) / 224.0;
    let cr = (p[2] as f64 - 128.0) / 224.0;
    let r = y + 2.0 * (1.0 - kr) * cr;
    let b = y + 2.0 * (1.0 - kb) * cb;
    [r, (y - kr * r - kb * b) / kg, b] // clamp on output; out-of-gamut values are legal
}

Tested: both tables round-trip all 256 bytes exactly; encode table within 1 code of the exact curve; derived sRGB matrix equals the standard 4-digit values; sRGB-to-P3 rows sum to 1; Y'CbCr white/black are (235,128,128)/(16,128,128); round trip within about 1 code; BT.709 red is (63,102,240).

Complexity and constants

  • Per pixel: curve = 1 table load (or a powf, see Efficiency); matrix 9 FMAs.
  • Bytes: u8 RGB is 3 B/px; linear f32 is 12 B/px (4x). 4:2:0 video is 1.5 B/px.
  • Tables: decode 256 f32 = 1 KiB, encode 4096 u8 = 4 KiB; both stay in L1.
  • 8-bit linear bands in the darks (sRGB spends about 10 codes below linear 0.0031): store linear as f16, f32 or 12-16 bit integers.

Efficiency

  • Cost: measured here (Rust, scalar, single thread, Apple silicon, 64 Mi pixels, powf in f64): decode 7.3 ns/px with powf vs 0.46 ns/px with the table; encode 6.4 vs 0.61 ns/px. The matrix is 9 FMAs. u8 RGB is 3 B/px, linear f32 12 B/px. Bound: memory bandwidth once the curve is a table.
  • Levers:
    • table for the curve — order of 10-15x less CPU time and energy (measured above) — 8-bit input needs exactly 256 entries; for float input a 4096-entry encode table is within 1 code, or use a polynomial/SIMD pow
    • fuse decode, filter and encode per tile (tiled pipeline) — avoids a round trip of a full f32 linear image (4x bytes) — resizing and blending
    • collapse matrices — YCbCr to RGB to P3 is one matrix per pixel, not three passes; skip when spaces match — any chain
    • _SRGB texture and render-target formats — decode on sampling and encode on write in fixed-function hardware — GPU
  • Naive → standard → state of the art:
    • powf per channel per pixel · 7.3 ns/px decode here
    • 256-entry decode table plus a 3x3 matrix · 0.46 ns/px decode here
    • fused tiled pipeline in SIMD, or fixed-function hardware sampling · bounded by memory bandwidth; gamma 2.0 (v*v, sqrt) only for previews, it differs from sRGB near black

Implementation notes

  • Integer paths (video): fixed-point coefficients, multiply-shift in i16/i32 SIMD lanes (SIMD); round, then saturate to 0..255.
  • Derive matrices in f64, cast to f32; rows must still sum to 1 so white stays white (float error).
  • Planar Y, Cb, Cr vectorise better than packed YUYV.
  • Alpha: sRGB-encode only color, never alpha; convert unpremultiplied color, then premultiply for compositing.
  • Tag buffers with primaries, transfer, matrix and range (ICC profile, PNG cICP, H.273 code points). Untagged video: BT.601 for SD, BT.709 for HD.
  • HDR transfer curves (PQ, HLG) are not gamma: use tone mapping to leave them.

Pitfalls

  • Using BT.601 on BT.709 video (or vice versa): skin and greens visibly shift in hue.
  • Full vs limited range mix-up: washed-out blacks or crushed highlights.
  • Filtering, downscaling or building mipmaps and pyramids on sRGB bytes: dark halos and darkened fine detail.
  • Computing "luminance" with 0.2126/0.7152/0.0722 on gamma-encoded values: that is luma, off from the true Y.
  • Applying a primaries matrix to non-linear values.
  • No clamp after a wide-to-narrow gamut matrix: negatives wrap on integer cast.
  • BC/ASTC textures must be flagged sRGB or linear to match their data.

Variants and alternatives

Oklab / OKLCHperceptually uniform; for gradients, color mixing, lightness edits, gamut mapping (Ottosson). Not for physically correct blending.
CIE Lab / delta Ecolor difference metrics.
ICC color managementarbitrary, CMYK or printer profiles.

In production

  • Web: CSS Color 4 defines the display-p3 and linear-light predefined spaces that browsers convert between (spec); Skia, libvips and Pillow (ImageCms) do the same in graphics libraries.
  • Video and broadcast: decoders and display paths convert Y'CbCr to RGB with the BT.601, BT.709 or BT.2020 matrices (BT.709).
  • Print, photography and desktop publishing: ICC profiles are the interchange format (ICC), applied by engines such as Little CMS (site).
  • Games: sRGB textures and linear lighting.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
paletteRustOgeon/palettecargo add palette————typed color spaces (sRGB, linear, Lab, Oklab, HSL), conversions as types
moxcmsRustawxkee/moxcmscargo add moxcms————pure-Rust ICC color management, SIMD paths
yuvutils-rsRustawxkee/yuvutils-rscargo add yuvutils-rs————SIMD YUV (Y'CbCr) to/from RGB, BT.601/709/2020, full and limited range
lcms2Rustkornelski/rust-lcms2cargo add lcms2————bindings to Little CMS (ICC engine, C), needs the C library
Little CMSCmm2/Little-CMSbrew install little-cms2————the reference open ICC engine; used widely

Sources

  • IEC 61966-2-1:1999, Multimedia systems and equipment - Colour measurement and management - Part 2-1: sRGB. standard · sRGB overview
  • ITU-R Recommendation BT.709 (HDTV) and BT.601 (SDTV). BT.709 · BT.601
  • W3C, CSS Color Module Level 4: sRGB and Display P3 transfer functions and conversion matrices. spec
  • Lindbloom, RGB/XYZ Matrices. page
  • Ottosson, A perceptual color space for image processing (Oklab), 2020. post
  • Poynton, Digital Video and HD: Algorithms and Interfaces, 2nd ed., Morgan Kaufmann 2012.

Builds on

chroma subsampling · planned4:2:0/4:2:2 colour plane downsampling and siting

Used by

auto exposure focus white balance · plannedCamera 3A loops: metering-driven exposure, contrast/phase autofocus, gray-world/illuminant white balance
Physically based shading (microfacet BRDF)A renderer must compute how much light a surface sends toward the eye for each light direction, with materials that look right under any lighting and are described by a few intuitive parameters. This runs per pixel and per light.
temporal antialiasing · plannedTAA: jittered frames, motion-vector reprojection, history clamping; temporal upscaling

Systems (16):

Alternatives and related