UNPKG

@markgorzynski/color-utils

Version:

The only color science library with adaptive visual perception modeling and accessibility-driven optimization.

308 lines (266 loc) 9.41 kB
/** * @module rec2020 * @description ITU-R Rec. 2020 (Rec. 2020) color space conversions and utilities. * Rec. 2020 is an ultra-wide gamut RGB color space used for UHDTV (4K/8K) and HDR content. * It encompasses 75.8% of the CIE 1931 color space, significantly larger than sRGB (35.9%) * and Display P3 (53.6%). * * @see {@link https://www.itu.int/rec/R-REC-BT.2020} */ import { multiplyMatrixVector } from './utils.js'; import { srgbToLinearSrgb, linearSrgbToSrgb } from './srgb.js'; /** @typedef {{r: number, g: number, b: number}} Rec2020Color */ /** @typedef {{r: number, g: number, b: number}} LinearRec2020Color */ // --- Rec. 2020 Constants --- /** * Rec. 2020 primaries and white point in CIE xy chromaticity coordinates * @private */ export const REC2020_PRIMARIES = Object.freeze({ red: { x: 0.708, y: 0.292 }, green: { x: 0.170, y: 0.797 }, blue: { x: 0.131, y: 0.046 }, white: { x: 0.3127, y: 0.3290 } // D65 }); /** * Matrix to convert from linear Rec. 2020 to XYZ (D65) * Derived from the Rec. 2020 primaries * @private */ export const MATRIX_LINEAR_REC2020_TO_XYZ_D65 = Object.freeze([ Object.freeze([0.6369580, 0.1446169, 0.1688810]), Object.freeze([0.2627002, 0.6779981, 0.0593017]), Object.freeze([0.0000000, 0.0280727, 1.0609851]) ]); /** * Matrix to convert from XYZ to linear Rec. 2020 (D65) * Inverse of MATRIX_LINEAR_REC2020_TO_XYZ_D65 * @private */ export const MATRIX_XYZ_TO_LINEAR_REC2020_D65 = Object.freeze([ Object.freeze([1.7166511, -0.3556708, -0.2533663]), Object.freeze([-0.6666844, 1.6164812, 0.0157685]), Object.freeze([0.0176399, -0.0427706, 0.9421031]) ]); /** * Matrix for direct linear sRGB to linear Rec. 2020 conversion * @private */ export const MATRIX_LINEAR_SRGB_TO_LINEAR_REC2020 = Object.freeze([ Object.freeze([0.6274040, 0.3292820, 0.0433136]), Object.freeze([0.0690970, 0.9195400, 0.0113612]), Object.freeze([0.0163916, 0.0880132, 0.8955950]) ]); /** * Matrix for direct linear Rec. 2020 to linear sRGB conversion * @private */ export const MATRIX_LINEAR_REC2020_TO_LINEAR_SRGB = Object.freeze([ Object.freeze([1.6605010, -0.5876411, -0.0728499]), Object.freeze([-0.1246611, 1.1329096, -0.0082485]), Object.freeze([-0.0181508, -0.1005789, 1.1187297]) ]); // --- Gamma Correction --- /** * Rec. 2020 uses the same transfer function as Rec. 709 (BT.709) * which is slightly different from sRGB * @private */ const REC2020_ALPHA = 1.09929682680944; const REC2020_BETA = 0.018053968510807; /** * Apply Rec. 2020 gamma correction (OETF - Opto-Electronic Transfer Function) * @private */ function rec2020ChannelToLinear(channel) { if (channel === 0) return 0; if (channel === 1) return 1; // Rec. 2020 uses Rec. 709 transfer function if (channel < 0) { return -rec2020ChannelToLinear(-channel); } if (channel < REC2020_BETA * 4.5) { return channel / 4.5; } return Math.pow((channel + (REC2020_ALPHA - 1)) / REC2020_ALPHA, 1 / 0.45); } /** * Remove Rec. 2020 gamma correction (EOTF - Electro-Optical Transfer Function) * @private */ function linearChannelToRec2020(channel) { if (channel === 0) return 0; if (channel === 1) return 1; if (channel < 0) { return -linearChannelToRec2020(-channel); } if (channel < REC2020_BETA) { return channel * 4.5; } return REC2020_ALPHA * Math.pow(channel, 0.45) - (REC2020_ALPHA - 1); } // --- Rec. 2020 ↔ Linear Rec. 2020 --- /** * Convert Rec. 2020 to linear Rec. 2020 * @param {Rec2020Color} rec2020Color - Rec. 2020 color with gamma correction * @returns {LinearRec2020Color} Linear Rec. 2020 color */ export function rec2020ToLinearRec2020(rec2020Color) { return { r: rec2020ChannelToLinear(rec2020Color.r), g: rec2020ChannelToLinear(rec2020Color.g), b: rec2020ChannelToLinear(rec2020Color.b) }; } /** * Convert linear Rec. 2020 to Rec. 2020 with gamma correction * @param {LinearRec2020Color} linearRec2020Color - Linear Rec. 2020 color * @returns {Rec2020Color} Rec. 2020 color with gamma correction */ export function linearRec2020ToRec2020(linearRec2020Color) { return { r: linearChannelToRec2020(linearRec2020Color.r), g: linearChannelToRec2020(linearRec2020Color.g), b: linearChannelToRec2020(linearRec2020Color.b) }; } // --- sRGB ↔ Rec. 2020 Conversions --- /** * Convert sRGB to Rec. 2020 * @param {SrgbColor} srgbColor - sRGB color * @returns {Rec2020Color} Rec. 2020 color * @example * // Convert pure red from sRGB to Rec. 2020 * const rec2020Red = srgbToRec2020({ r: 1, g: 0, b: 0 }); */ export function srgbToRec2020(srgbColor) { // Convert to linear sRGB const linearSrgb = srgbToLinearSrgb(srgbColor); // Convert to linear Rec. 2020 using direct matrix const [r, g, b] = multiplyMatrixVector( MATRIX_LINEAR_SRGB_TO_LINEAR_REC2020, [linearSrgb.r, linearSrgb.g, linearSrgb.b] ); // Apply Rec. 2020 gamma return linearRec2020ToRec2020({ r, g, b }); } /** * Convert Rec. 2020 to sRGB * Note: Rec. 2020 has a much wider gamut than sRGB, so colors will often be clipped * @param {Rec2020Color} rec2020Color - Rec. 2020 color * @returns {SrgbColor} sRGB color (likely clipped if out of gamut) */ export function rec2020ToSrgb(rec2020Color) { // Convert to linear Rec. 2020 const linearRec2020 = rec2020ToLinearRec2020(rec2020Color); // Convert to linear sRGB using direct matrix const [r, g, b] = multiplyMatrixVector( MATRIX_LINEAR_REC2020_TO_LINEAR_SRGB, [linearRec2020.r, linearRec2020.g, linearRec2020.b] ); // Apply sRGB gamma return linearSrgbToSrgb({ r, g, b }); } // --- XYZ ↔ Rec. 2020 Conversions --- /** * Convert linear Rec. 2020 to XYZ (D65) * @param {LinearRec2020Color} linearRec2020Color - Linear Rec. 2020 color * @returns {{X: number, Y: number, Z: number}} XYZ color */ export function linearRec2020ToXyz(linearRec2020Color) { const [X, Y, Z] = multiplyMatrixVector( MATRIX_LINEAR_REC2020_TO_XYZ_D65, [linearRec2020Color.r, linearRec2020Color.g, linearRec2020Color.b] ); return { X, Y, Z }; } /** * Convert XYZ (D65) to linear Rec. 2020 * @param {{X: number, Y: number, Z: number}} xyzColor - XYZ color * @returns {LinearRec2020Color} Linear Rec. 2020 color */ export function xyzToLinearRec2020(xyzColor) { const [r, g, b] = multiplyMatrixVector( MATRIX_XYZ_TO_LINEAR_REC2020_D65, [xyzColor.X, xyzColor.Y, xyzColor.Z] ); return { r, g, b }; } // --- Utility Functions --- /** * Check if a Rec. 2020 color is within sRGB gamut * @param {Rec2020Color} rec2020Color - Rec. 2020 color to check * @returns {boolean} True if the color can be represented in sRGB */ export function isRec2020InSrgbGamut(rec2020Color) { const srgb = rec2020ToSrgb(rec2020Color); const epsilon = 0.00001; return ( srgb.r >= -epsilon && srgb.r <= 1 + epsilon && srgb.g >= -epsilon && srgb.g <= 1 + epsilon && srgb.b >= -epsilon && srgb.b <= 1 + epsilon ); } /** * Check if an sRGB color would benefit from Rec. 2020's wider gamut * @param {SrgbColor} srgbColor - sRGB color to check * @returns {boolean} True if converting to Rec. 2020 provides a wider gamut representation */ export function benefitsFromRec2020(srgbColor) { // For colors already in sRGB gamut, Rec. 2020 provides more precise representation // but not necessarily "wider" - this is mainly useful for HDR content const rec2020 = srgbToRec2020(srgbColor); // Check if the color uses the extended range const epsilon = 0.01; return ( Math.abs(rec2020.r - srgbColor.r) > epsilon || Math.abs(rec2020.g - srgbColor.g) > epsilon || Math.abs(rec2020.b - srgbColor.b) > epsilon ); } /** * Format a Rec. 2020 color for CSS Color Module Level 4 * @param {Rec2020Color} rec2020Color - Rec. 2020 color * @param {number} [precision=4] - Decimal precision * @returns {string} CSS color() function string * @example * formatRec2020ForCSS({ r: 0.627, g: 0.069, b: 0.016 }) * // Returns: "color(rec2020 0.627 0.069 0.016)" */ export function formatRec2020ForCSS(rec2020Color, precision = 4) { const r = rec2020Color.r.toFixed(precision); const g = rec2020Color.g.toFixed(precision); const b = rec2020Color.b.toFixed(precision); return `color(rec2020 ${r} ${g} ${b})`; } /** * Parse a CSS Color Module Level 4 rec2020 color * @param {string} cssString - CSS color() function string * @returns {Rec2020Color|null} Rec. 2020 color or null if invalid * @example * parseRec2020FromCSS("color(rec2020 0.627 0.069 0.016)") * // Returns: { r: 0.627, g: 0.069, b: 0.016 } */ export function parseRec2020FromCSS(cssString) { const match = cssString.match(/color\(\s*rec2020\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/); if (!match) return null; return { r: parseFloat(match[1]), g: parseFloat(match[2]), b: parseFloat(match[3]) }; } /** * Get the gamut volume of Rec. 2020 relative to other spaces * @param {string} [compareSpace='srgb'] - Space to compare against * @returns {number} Ratio of gamut volumes */ export function getRec2020GamutRatio(compareSpace = 'srgb') { const volumes = { 'srgb': 2.25, // Rec. 2020 is ~125% larger than sRGB 'display-p3': 1.67, // Rec. 2020 is ~67% larger than Display P3 'rec2020': 1.0, // Same space 'prophoto': 0.8 // Rec. 2020 is ~80% of ProPhoto RGB }; return volumes[compareSpace] || 1.0; }