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)
- sRGB curve (IEC 61966-2-1), v in [0,1]:
lin = v/12.92forv <= 0.04045, else((v+0.055)/1.055)^2.4. Inverse:v = 12.92*linforlin <= 0.0031308, else1.055*lin^(1/2.4) - 0.055. - 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 asM_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). - 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. - Video order: upsample chroma, undo range, inverse matrix, then curve to linear if you will filter or blend.
- 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,
powfin f64): decode 7.3 ns/px withpowfvs 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
_SRGBtexture and render-target formats — decode on sampling and encode on write in fixed-function hardware — GPU
- 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
- Naive → standard → state of the art:
powfper 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
In production
- Web: CSS Color 4 defines the
display-p3and 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| palette | Rust | Ogeon/palette | cargo add palette | — | — | — | — | typed color spaces (sRGB, linear, Lab, Oklab, HSL), conversions as types |
| moxcms | Rust | awxkee/moxcms | cargo add moxcms | — | — | — | — | pure-Rust ICC color management, SIMD paths |
| yuvutils-rs | Rust | awxkee/yuvutils-rs | cargo add yuvutils-rs | — | — | — | — | SIMD YUV (Y'CbCr) to/from RGB, BT.601/709/2020, full and limited range |
| lcms2 | Rust | kornelski/rust-lcms2 | cargo add lcms2 | — | — | — | — | bindings to Little CMS (ICC engine, C), needs the C library |
| Little CMS | C | mm2/Little-CMS | brew 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.