@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
JavaScript
/**
* @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 })
];
}