UNPKG

@markgorzynski/color-utils

Version:

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

363 lines (304 loc) 11.1 kB
/** * @module cam16-ucs * @description CAM16-UCS (Uniform Color Space) - a perceptually uniform version of CIECAM16. * CAM16-UCS provides better perceptual uniformity for color difference calculations * and is recommended for applications requiring accurate color distance metrics. * * The UCS (Uniform Color Space) transformation makes Euclidean distances in the space * correspond more closely to perceived color differences. * * @see {@link https://doi.org/10.1002/col.22131} - Original CAM16-UCS paper * @see {@link https://observablehq.com/@jrus/cam16} - Interactive CAM16 explanation */ import { srgbToCiecam16 } from './ciecam16.js'; import { srgbToXyz, xyzToSrgb } from './srgb.js'; /** * @typedef {Object} Cam16UcsColor * @property {number} J - Lightness (0-100) * @property {number} a - Red-green component * @property {number} b - Yellow-blue component */ /** * @typedef {Object} Cam16UcsPolarColor * @property {number} J - Lightness (0-100) * @property {number} C - Chroma * @property {number} h - Hue angle in degrees */ // --- Constants --- /** * CAM16-UCS coefficients for the uniform space transformation * These values are optimized for perceptual uniformity * @private */ const UCS_K_L = 1.0; const UCS_C1 = 0.007; const UCS_C2 = 0.0228; // --- CAM16 to CAM16-UCS Conversion --- /** * Convert CIECAM16 appearance correlates to CAM16-UCS coordinates * @param {Ciecam16Appearance} cam16 - CIECAM16 appearance values * @returns {Cam16UcsColor} CAM16-UCS rectangular coordinates */ export function cam16ToUcs(cam16) { const { J, M, h } = cam16; // Convert to UCS lightness J' const J_prime = (1 + 100 * UCS_C1) * J / (1 + UCS_C1 * J); // Convert colorfulness M to UCS colorfulness M' const M_prime = Math.log(1 + UCS_C2 * M) / UCS_C2; // Convert to rectangular coordinates const hRad = (h * Math.PI) / 180; const a = M_prime * Math.cos(hRad); const b = M_prime * Math.sin(hRad); return { J: J_prime, a, b }; } /** * Convert CAM16-UCS coordinates back to CIECAM16 appearance * @param {Cam16UcsColor} ucs - CAM16-UCS coordinates * @returns {Ciecam16Appearance} CIECAM16 appearance values */ export function ucsToCam16(ucs) { const { J: J_prime, a, b } = ucs; // Convert from UCS lightness J' to CIECAM16 J const J = J_prime / (1 + UCS_C1 * (100 - J_prime)); // Calculate UCS colorfulness M' const M_prime = Math.sqrt(a * a + b * b); // Convert to CIECAM16 colorfulness M const M = (Math.exp(UCS_C2 * M_prime) - 1) / UCS_C2; // Calculate hue angle let h = (Math.atan2(b, a) * 180) / Math.PI; if (h < 0) h += 360; // Calculate chroma C from colorfulness M // This is an approximation since we don't have the viewing conditions const C = M; // Simplified - actual conversion needs viewing conditions return { J, C, M, h, Q: J, s: 0, H: h }; } // --- sRGB to/from CAM16-UCS --- /** * Convert sRGB to CAM16-UCS * @param {SrgbColor} srgbColor - sRGB color * @param {Ciecam16ViewingConditions} [viewingConditions] - Viewing conditions * @returns {Cam16UcsColor} CAM16-UCS coordinates * @example * const ucs = srgbToCam16Ucs({ r: 0.5, g: 0.7, b: 0.3 }); * // Returns: { J: 67.2, a: -15.3, b: 28.1 } */ export function srgbToCam16Ucs(srgbColor, viewingConditions) { const cam16 = srgbToCiecam16(srgbColor, viewingConditions); return cam16ToUcs(cam16); } /** * Convert CAM16-UCS to sRGB * Note: This requires reverse engineering through CIECAM16 which is complex * and may not always have an exact solution * @param {Cam16UcsColor} ucsColor - CAM16-UCS coordinates * @param {Ciecam16ViewingConditions} [viewingConditions] - Viewing conditions * @returns {SrgbColor|null} sRGB color or null if conversion fails */ export function cam16UcsToSrgb(ucsColor, viewingConditions = getDefaultViewingConditions()) { // This is a simplified implementation // Full implementation would require reverse CIECAM16 transform const cam16 = ucsToCam16(ucsColor); // Approximate reverse transform (simplified) // In practice, this would need iterative solving const xyz = approximateCam16ToXyz(cam16, viewingConditions); if (!xyz) return null; return xyzToSrgb(xyz); } // --- Polar Coordinates --- /** * Convert CAM16-UCS rectangular to polar coordinates * @param {Cam16UcsColor} ucs - CAM16-UCS rectangular coordinates * @returns {Cam16UcsPolarColor} CAM16-UCS polar coordinates */ export function ucsToPolar(ucs) { const { J, a, b } = ucs; const C = Math.sqrt(a * a + b * b); let h = (Math.atan2(b, a) * 180) / Math.PI; if (h < 0) h += 360; return { J, C, h }; } /** * Convert CAM16-UCS polar to rectangular coordinates * @param {Cam16UcsPolarColor} polar - CAM16-UCS polar coordinates * @returns {Cam16UcsColor} CAM16-UCS rectangular coordinates */ export function polarToUcs(polar) { const { J, C, h } = polar; const hRad = (h * Math.PI) / 180; const a = C * Math.cos(hRad); const b = C * Math.sin(hRad); return { J, a, b }; } // --- Color Difference --- /** * Calculate the color difference between two colors in CAM16-UCS space * This provides a perceptually uniform color difference metric * @param {Cam16UcsColor} color1 - First color in CAM16-UCS * @param {Cam16UcsColor} color2 - Second color in CAM16-UCS * @returns {number} Euclidean distance in CAM16-UCS space */ export function cam16UcsColorDifference(color1, color2) { const dJ = color1.J - color2.J; const da = color1.a - color2.a; const db = color1.b - color2.b; return Math.sqrt(dJ * dJ + da * da + db * db); } /** * Calculate color difference between two sRGB colors using CAM16-UCS * @param {SrgbColor} srgb1 - First sRGB color * @param {SrgbColor} srgb2 - Second sRGB color * @param {Ciecam16ViewingConditions} [viewingConditions] - Viewing conditions * @returns {number} Perceptual color difference */ export function calculateCam16UcsDifference(srgb1, srgb2, viewingConditions) { const ucs1 = srgbToCam16Ucs(srgb1, viewingConditions); const ucs2 = srgbToCam16Ucs(srgb2, viewingConditions); return cam16UcsColorDifference(ucs1, ucs2); } // --- Interpolation --- /** * Interpolate between two colors in CAM16-UCS space * @param {Cam16UcsColor} color1 - Start color * @param {Cam16UcsColor} color2 - End color * @param {number} t - Interpolation factor (0-1) * @returns {Cam16UcsColor} Interpolated color */ export function interpolateCam16Ucs(color1, color2, t) { return { J: color1.J + (color2.J - color1.J) * t, a: color1.a + (color2.a - color1.a) * t, b: color1.b + (color2.b - color1.b) * t }; } /** * Interpolate in polar coordinates (better for hue preservation) * @param {Cam16UcsPolarColor} color1 - Start color (polar) * @param {Cam16UcsPolarColor} color2 - End color (polar) * @param {number} t - Interpolation factor (0-1) * @returns {Cam16UcsPolarColor} Interpolated color (polar) */ export function interpolateCam16UcsPolar(color1, color2, t) { // Handle hue interpolation (shortest path) let h1 = color1.h; let h2 = color2.h; if (Math.abs(h2 - h1) > 180) { if (h2 > h1) { h1 += 360; } else { h2 += 360; } } let h = h1 + (h2 - h1) * t; if (h >= 360) h -= 360; return { J: color1.J + (color2.J - color1.J) * t, C: color1.C + (color2.C - color1.C) * t, h }; } // --- Gradient Generation --- /** * Generate a perceptually uniform gradient in CAM16-UCS space * @param {SrgbColor} startColor - Start color * @param {SrgbColor} endColor - End color * @param {number} steps - Number of steps * @param {Ciecam16ViewingConditions} [viewingConditions] - Viewing conditions * @returns {SrgbColor[]} Array of gradient colors */ export function generateCam16UcsGradient(startColor, endColor, steps, viewingConditions) { const start = srgbToCam16Ucs(startColor, viewingConditions); const end = srgbToCam16Ucs(endColor, viewingConditions); const gradient = []; for (let i = 0; i < steps; i++) { const t = i / (steps - 1); const interpolated = interpolateCam16Ucs(start, end, t); const srgb = cam16UcsToSrgb(interpolated, viewingConditions); gradient.push(srgb || startColor); // Fallback if conversion fails } return gradient; } // --- Helper Functions --- /** * Get default viewing conditions for CAM16-UCS * @private * @returns {Ciecam16ViewingConditions} */ function getDefaultViewingConditions() { return { whitePoint: { X: 95.047, Y: 100, Z: 108.883 }, // D65 adaptingLuminance: 40, // cd/m² backgroundLuminance: 20, // cd/m² surround: 'average', discounting: false }; } /** * Approximate reverse CIECAM16 to XYZ transform * This is a simplified version - full implementation is complex * @private */ function approximateCam16ToXyz(cam16, viewingConditions) { // This is a placeholder for the complex reverse transform // In practice, this requires iterative solving of the CIECAM16 equations // For now, return a simple approximation // Real implementation would need the full reverse CIECAM16 model const { J, C, h } = cam16; // Very rough approximation (not accurate!) const L = J; const a = C * Math.cos(h * Math.PI / 180) * 0.5; const b = C * Math.sin(h * Math.PI / 180) * 0.5; // Approximate Lab to XYZ (using D65) const fy = (L + 16) / 116; const fx = a / 500 + fy; const fz = fy - b / 200; const xr = fx * fx * fx > 0.008856 ? fx * fx * fx : (fx - 16/116) / 7.787; const yr = fy * fy * fy > 0.008856 ? fy * fy * fy : (fy - 16/116) / 7.787; const zr = fz * fz * fz > 0.008856 ? fz * fz * fz : (fz - 16/116) / 7.787; return { X: xr * 95.047, Y: yr * 100, Z: zr * 108.883 }; } // --- Utilities for Color Harmony --- /** * Find complementary color in CAM16-UCS space * @param {Cam16UcsColor} color - Input color * @returns {Cam16UcsColor} Complementary color */ export function findComplementaryCam16Ucs(color) { const polar = ucsToPolar(color); polar.h = (polar.h + 180) % 360; return polarToUcs(polar); } /** * Generate analogous colors in CAM16-UCS space * @param {Cam16UcsColor} color - Base color * @param {number} [angle=30] - Angle between colors * @param {number} [count=2] - Number of analogous colors per side * @returns {Cam16UcsColor[]} Array of analogous colors */ export function generateAnalogousCam16Ucs(color, angle = 30, count = 2) { const polar = ucsToPolar(color); const colors = []; for (let i = 1; i <= count; i++) { const h1 = (polar.h - angle * i + 360) % 360; const h2 = (polar.h + angle * i) % 360; colors.push(polarToUcs({ ...polar, h: h1 })); colors.push(polarToUcs({ ...polar, h: h2 })); } return colors; } /** * Generate triadic colors in CAM16-UCS space * @param {Cam16UcsColor} color - Base color * @returns {Cam16UcsColor[]} Array of triadic colors */ export function generateTriadicCam16Ucs(color) { const polar = ucsToPolar(color); return [ color, polarToUcs({ ...polar, h: (polar.h + 120) % 360 }), polarToUcs({ ...polar, h: (polar.h + 240) % 360 }) ]; }