@markgorzynski/color-utils
Version:
The only color science library with adaptive visual perception modeling and accessibility-driven optimization.
530 lines (475 loc) • 21.8 kB
JavaScript
/**
* @file src/aoklab.js
* @module color-utils/aoklab
* @description Provides an `AdaptiveOklab` class for color space conversions.
* This model modifies the standard Oklab color space to account for perceptual
* adaptation to different viewing surround conditions (white, gray, dark).
*
* **Novel Approach of Adaptive Oklab (AOkLab):**
*
* 1. **Foundation in Oklab:** AOkLab builds upon the standard Oklab transformation pipeline,
* which typically involves:
* Linear sRGB -> LMS_Oklab (via Oklab M1 matrix) -> LMS'_Oklab (via cube-root non-linearity, exponent 1/3) -> Oklab L,a,b (via Oklab M2 matrix).
*
* 2. **Adaptive Non-linearity:** Instead of the fixed 1/3 exponent, AOkLab introduces a
* **surround-dependent adaptive exponent `p`**. This `p` is applied to the
* LMS_Oklab values: `LMS'_adaptive = LMS_Oklab ^ p`.
*
* 3. **Goal-Derived Exponents `p`:** The exponents `p` for 'white', 'gray', and 'dark'
* surrounds are specifically derived to achieve a consistent target *adapted lightness*
* (L_AOk = 40). This means colors that would have certain *standard Oklab lightnesses*
* (L_std = 55.9 for white, 48.3 for gray, 41.7 for dark) are all mapped to L_AOk = 40
* when processed with the corresponding surround's adaptive exponent `p`.
* The derivation formula is: `p = ln(0.40) / (3 * ln(L_std_for_surround / 100))`.
*
* 4. **Hue Uniformity Correction:** Since the Oklab M2 matrix (LMS' -> Lab) was optimized
* for the 1/3 exponent, changing this exponent to `p` would alter hue appearance.
* To preserve hue uniformity relative to standard Oklab, a correction factor is
* applied to the resulting `a` and `b` opponent channels:
* `correction = x0 ^ ((1/3) - p)`, where `x0` is a representative LMS value (default 0.5).
* The final AOkLab `a` and `b` are `a_raw * correction` and `b_raw * correction`.
* The AOkLab `L` component is taken directly from the M2 matrix transformation.
*
* 5. **Pipeline Summary:**
* - Input (sRGB or XYZ) -> Linear sRGB
* - Linear sRGB -> LMS_Oklab (using standard Oklab M1 matrix)
* - LMS_Oklab -> LMS'_adaptive (using exponent `p`)
* - LMS'_adaptive -> Preliminary L', a', b' (using standard Oklab M2 matrix)
* - L = L', a = a' * correction, b = b' * correction -> Final AOkLab {L, a, b}
*
* The reverse transformations undo these adaptive steps before applying standard Oklab inverse matrices.
*
* @see {@link https://bottosson.github.io/posts/oklab/} for the base Oklab model.
* The adaptive mechanism and exponent derivation are based on custom design notes.
*/
// --- Type Imports for JSDoc ---
// --- Utility Imports ---
import {
signPreservingPow,
multiplyMatrixVector,
} from './utils.js';
// --- sRGB Module Imports ---
import {
srgbToLinearSrgb,
linearSrgbToSrgb,
xyzToLinearSrgb, // To convert XYZ to Linear sRGB as the starting point
linearSrgbToXyz, // For the .toXyz() method
parseSrgbHex,
formatSrgbAsHex,
} from './srgb.js';
// --- Standard Oklab Transformation Matrices (as defined in oklab.js or by Ottosson) ---
// These are the matrices from the standard Oklab model.
/**
* Standard Oklab M1 Matrix: Converts Linear sRGB to Oklab's specific LMS-like space.
* @private
*/
const MATRIX_LINEAR_SRGB_TO_LMS_OKLAB = Object.freeze([
Object.freeze([0.4122214708, 0.5363325363, 0.0514459929]),
Object.freeze([0.2119034982, 0.6806995451, 0.1073969566]),
Object.freeze([0.0883024619, 0.2817188376, 0.6299787005]),
]);
/**
* Standard Oklab M2 Matrix: Converts non-linearly compressed LMS' (exponent 1/3) to Oklab (L, a, b).
* This matrix is also used by AOkLab with its adaptively exponentiated LMS'_adaptive.
* @private
*/
const MATRIX_LMS_PRIME_TO_OKLAB = Object.freeze([ // Renamed for clarity from MATRIX_LMS_PRIME_TO_AOKLAB
Object.freeze([0.2104542553, 0.7936177850, -0.0040720468]), // L component
Object.freeze([1.9779984951, -2.4285922050, 0.4505937099]), // a component
Object.freeze([0.0259040371, 0.7827717662, -0.8086757660]), // b component
]);
/**
* Inverse of Standard Oklab M2 Matrix: Converts Oklab (L, a, b) to non-linear LMS'.
* Used in the reverse AOkLab transformation.
* @private
*/
const MATRIX_OKLAB_TO_LMS_PRIME = Object.freeze([ // Renamed for clarity from MATRIX_AOKLAB_TO_LMS_PRIME
Object.freeze([1.0, 0.3963377774, 0.2158037573]),
Object.freeze([1.0, -0.1055613458, -0.0638541728]),
Object.freeze([1.0, -0.0894841775, -1.2914855480]),
]);
/**
* Inverse of Standard Oklab M1 Matrix: Converts Oklab's LMS space to Linear sRGB.
* Used in the reverse AOkLab transformation.
* @private
*/
const MATRIX_LMS_OKLAB_TO_LINEAR_SRGB = Object.freeze([ // Renamed from MATRIX_LMS_AOKLAB_TO_LINEAR_SRGB
Object.freeze([4.0767416621, -3.3077115913, 0.2309699292]),
Object.freeze([-1.2684380046, 2.6097574011, -0.3413193965]),
Object.freeze([-0.0041960863, -0.7034186147, 1.7076147010]),
]);
/**
* Pre-calculated adaptive exponents 'p' for different surround conditions.
* Derived from `p = ln(0.40) / (3 * ln(L_std_for_surround / 100))`
* where L_std values (55.9 white, 48.3 gray, 41.7 dark) map to L_AOk = 40.
* @private
* @type {Readonly<Record<AdaptiveOklabSurround, number>>}
*/
const SURROUND_EXPONENTS = Object.freeze({
white: 0.526,
gray: 0.420,
dark: 0.349,
});
/**
* The standard non-linearity exponent used in Oklab (1/3).
* Used in calculating the hue correction factor.
* @private
* @type {number}
*/
const STANDARD_OKLAB_EXPONENT = 1 / 3;
/**
* @class AdaptiveOklab
* @classdesc A class to perform color conversions to and from an adaptive version of Oklab.
* This model adjusts lightness and chroma based on specified viewing surround conditions,
* building upon the standard Oklab color space.
*/
export class AdaptiveOklab {
/**
* The configured viewing surround.
* @type {AdaptiveOklabSurround}
* @readonly
*/
surround;
/**
* The adaptive exponent 'p' for the current surround.
* @type {number}
* @private
* @readonly
*/
_exponent;
/**
* A representative LMS value (default 0.5) for hue correction factor calculation.
* @type {number}
* @private
* @readonly
*/
_x0;
/**
* Correction factor `x0^((1/3) - p)` applied to 'a' and 'b' channels to maintain hue.
* @type {number}
* @private
* @readonly
*/
_correctionFactor;
/**
* Optional tone mapping configuration for advanced surround adaptation
* @type {Object}
* @private
*/
_toneMapping;
/**
* Creates an instance of the AdaptiveOklab converter.
* @param {AdaptiveOklabOptions} [options={}] - Configuration options.
* @example
* const aokWhite = new AdaptiveOklab({ surround: 'white' });
* const aokGray = new AdaptiveOklab(); // Defaults to 'gray' surround
* const aokDarkHighX0 = new AdaptiveOklab({ surround: 'dark', x0: 0.6 });
* const aokAdvanced = new AdaptiveOklab({
* surround: 'gray',
* toneMapping: {
* gammaAdjustment: 0.5, // Additional gamma on top of base
* sigmoidStrength: 0.3 // Shadow lifting strength
* }
* });
*/
constructor(options = {}) {
this.surround = options.surround || 'gray';
if (!SURROUND_EXPONENTS[this.surround]) {
console.warn(`AdaptiveOklab: Unknown surround "${this.surround}". Defaulting to "gray".`);
this.surround = 'gray';
}
this._exponent = SURROUND_EXPONENTS[this.surround];
this._x0 = (typeof options.x0 === 'number' && !Number.isNaN(options.x0)) ? options.x0 : 0.5;
// Handle x0 = 0 edge case to avoid infinity
if (this._x0 === 0) {
this._correctionFactor = 0; // Will make a and b channels 0
} else {
this._correctionFactor = Math.pow(this._x0, STANDARD_OKLAB_EXPONENT - this._exponent);
}
// Store tone mapping configuration if provided
this._toneMapping = options.toneMapping || null;
}
/**
* Get internal adaptation parameters (for testing/debugging)
* @returns {Object} Object containing FL (luminance factor) and other params
*/
get params() {
return {
FL: this._exponent, // Luminance adaptation factor (surround exponent)
x0: this._x0, // Reference LMS value
correctionFactor: this._correctionFactor,
surround: this.surround
};
}
/**
* Apply tone mapping to lightness value if configured
* @private
* @param {number} L - Lightness value (0-1 range)
* @returns {number} Tone-mapped lightness value
*/
_applyToneMapping(L) {
if (!this._toneMapping) return L;
let result = L;
// Apply gamma adjustment if specified
if (this._toneMapping.gammaAdjustment && Math.abs(this._toneMapping.gammaAdjustment) > 0.01) {
const gamma = 1.0 + this._toneMapping.gammaAdjustment;
result = Math.pow(result, gamma);
}
// Apply sigmoid shadow/highlight control if specified
if (this._toneMapping.sigmoidStrength && Math.abs(this._toneMapping.sigmoidStrength) > 0.001) {
result = this._applySigmoid(result, this._toneMapping.sigmoidStrength);
}
return Math.max(0, Math.min(1, result));
}
/**
* Apply sigmoid tone curve for shadow/highlight control
* @private
* @param {number} L - Input lightness (0-1)
* @param {number} strength - Sigmoid strength (-1 to 1)
* Positive: Lifts shadows (0-50% range)
* Negative: Increases contrast (S-curve)
* @returns {number} Modified lightness
*/
_applySigmoid(L, strength) {
if (L <= 0) return 0;
if (L >= 1) return 1;
if (Math.abs(strength) < 0.001) return L;
if (strength > 0) {
// Positive: Smooth shadow lift focusing on 0-50% range
// Increased multiplier for stronger shadow lifting
// Allows strength values up to 2.0 for more headroom
const amount = strength * 0.3;
// Smooth weight function using cosine
// Maximum at L=0, smoothly decreases to near-zero at L=1
const shadowWeight = (1 + Math.cos(Math.PI * Math.min(L * 2, 1))) / 2;
// Apply lift using a formula that can't clip at zero
const lifted = L + amount * shadowWeight * (1 - L) * Math.sqrt(L + 0.01);
return Math.max(0, Math.min(1, lifted));
} else {
// Negative: Increase contrast (S-curve)
// Standard sigmoid centered at 0.5
// Increased multiplier for stronger contrast effect
const k = -strength * 8; // Increased from 5 to 8 for stronger effect
const sigmoid = 1 / (1 + Math.exp(-k * (L - 0.5)));
// Normalize to maintain 0 and 1 endpoints
const sigmoidMin = 1 / (1 + Math.exp(-k * (0 - 0.5)));
const sigmoidMax = 1 / (1 + Math.exp(-k * (1 - 0.5)));
if (Math.abs(sigmoidMax - sigmoidMin) > 0.001) {
return (sigmoid - sigmoidMin) / (sigmoidMax - sigmoidMin);
} else {
return L;
}
}
}
/**
* Core conversion from Linear sRGB to Adaptive Oklab components.
* This is the central part of the forward AOkLab transformation.
* @private
* @param {LinearSrgbColor} linearSrgbColor - Linear sRGB color {r, g, b}.
* @returns {OklabColor} The Adaptive Oklab color object {L, a, b}.
*/
_fromLinearSrgbToAOkLab(linearSrgbColor) {
// Ensure components are non-negative for physical light before matrix multiply.
const r = Math.max(0, linearSrgbColor.r);
const g = Math.max(0, linearSrgbColor.g);
const b = Math.max(0, linearSrgbColor.b);
// Step 1: Linear sRGB to Oklab's specific LMS-like space (using Oklab M1)
const lmsOklab = multiplyMatrixVector(MATRIX_LINEAR_SRGB_TO_LMS_OKLAB, [r, g, b]);
// Step 2: Apply adaptive non-linearity (exponent 'p')
const lmsPrimeAdaptive = [
signPreservingPow(lmsOklab[0], this._exponent),
signPreservingPow(lmsOklab[1], this._exponent),
signPreservingPow(lmsOklab[2], this._exponent),
];
// Step 3: Convert adaptively non-linear LMS' to preliminary Oklab-like (L', a', b') (using Oklab M2)
const labArrayPrime = multiplyMatrixVector(MATRIX_LMS_PRIME_TO_OKLAB, lmsPrimeAdaptive);
// Step 4: Apply tone mapping to L if configured
let L = labArrayPrime[0];
if (this._toneMapping) {
L = this._applyToneMapping(L);
}
// Step 5: Apply hue correction factor to a' and b'
return {
L: L, // Tone-mapped L component
a: labArrayPrime[1] * this._correctionFactor, // Corrected a component
b: labArrayPrime[2] * this._correctionFactor, // Corrected b component
};
}
/**
* Converts a CIE XYZ (D65) color object to an Adaptive Oklab color object.
* Input `XyzColor` should have Y scaled relative to Y_n=1.0.
* @param {XyzColor} xyzColor - The CIE XYZ color object {X, Y, Z}.
* @returns {OklabColor} The Adaptive Oklab color object {L, a, b}.
* @throws {TypeError} if `xyzColor` is not a valid XyzColor object.
*/
fromXyz(xyzColor) {
// Type validation for xyzColor
if (
typeof xyzColor !== 'object' || xyzColor === null ||
typeof xyzColor.X !== 'number' || Number.isNaN(xyzColor.X) ||
typeof xyzColor.Y !== 'number' || Number.isNaN(xyzColor.Y) ||
typeof xyzColor.Z !== 'number' || Number.isNaN(xyzColor.Z)
) {
throw new TypeError('Input xyzColor must be an object with X, Y, Z valid number properties.');
}
const linearSrgbColor = xyzToLinearSrgb(xyzColor); // Convert XYZ (Y~0-1) to Linear sRGB
return this._fromLinearSrgbToAOkLab(linearSrgbColor);
}
/**
* Converts an sRGB color object to an Adaptive Oklab color object
* for the configured surround of this converter instance.
* @param {SrgbColor} srgbColor - The sRGB color object {r, g, b} with components in [0, 1].
* @returns {OklabColor} The Adaptive Oklab color object {L, a, b}.
* @throws {TypeError} if `srgbColor` is not a valid SrgbColor object.
* @example
* const aokConverter = new AdaptiveOklab({ surround: 'dark' });
* const adaptiveColor = aokConverter.fromSrgb({ r: 0.8, g: 0.2, b: 0.3 });
* console.log(adaptiveColor); // { L: ..., a: ..., b: ... }
*/
fromSrgb(srgbColor) {
// Type validation for srgbColor
if (
typeof srgbColor !== 'object' || srgbColor === null ||
typeof srgbColor.r !== 'number' || Number.isNaN(srgbColor.r) ||
typeof srgbColor.g !== 'number' || Number.isNaN(srgbColor.g) ||
typeof srgbColor.b !== 'number' || Number.isNaN(srgbColor.b)
) {
throw new TypeError('Input srgbColor must be an object with r, g, b valid number properties.');
}
const linearSrgbColor = srgbToLinearSrgb(srgbColor);
return this._fromLinearSrgbToAOkLab(linearSrgbColor);
}
/**
* Static helper method to convert an sRGB hex string directly to an Adaptive Oklab color object.
* Creates a temporary AdaptiveOklab converter instance with the specified options.
* @param {string} hexString - The sRGB hex color string (e.g., "#FF0000", "aabbcc").
* @param {AdaptiveOklabOptions} [options={}] - Configuration options for the AdaptiveOklab conversion.
* @returns {OklabColor} The Adaptive Oklab color object {L, a, b}.
* @throws {TypeError|SyntaxError} if `hexString` is invalid.
*/
static fromHex(hexString, options = {}) {
const srgbColor = parseSrgbHex(hexString); // from srgb.js
const converter = new AdaptiveOklab(options);
return converter.fromSrgb(srgbColor); // which will call _fromLinearSrgbToAOkLab
}
/**
* Reverse tone mapping to get original lightness value
* @private
* @param {number} L - Tone-mapped lightness value (0-1 range)
* @returns {number} Original lightness value
*/
_reverseToneMapping(L) {
if (!this._toneMapping) return L;
let result = L;
// Reverse sigmoid if it was applied
if (this._toneMapping.sigmoidStrength && Math.abs(this._toneMapping.sigmoidStrength) > 0.001) {
// This is an approximation - exact inverse would require solving a nonlinear equation
// For now, apply inverse sigmoid approximation
result = this._applySigmoid(result, -this._toneMapping.sigmoidStrength * 0.8);
}
// Reverse gamma adjustment if it was applied
if (this._toneMapping.gammaAdjustment && Math.abs(this._toneMapping.gammaAdjustment) > 0.01) {
const gamma = 1.0 + this._toneMapping.gammaAdjustment;
result = Math.pow(result, 1.0 / gamma);
}
return Math.max(0, Math.min(1, result));
}
/**
* Converts an Adaptive Oklab color object back to a Linear sRGB color object.
* This method reverses the adaptive transformation applied by this instance.
* @param {OklabColor} adaptiveOklabColor - The Adaptive Oklab color {L, a, b} to convert.
* @returns {LinearSrgbColor} The corresponding Linear sRGB color object {r, g, b}.
* Components may be outside [0, 1] if the color is out of sRGB gamut.
* @throws {TypeError} if `adaptiveOklabColor` is not a valid OklabColor object.
*/
toLinearSrgb(adaptiveOklabColor) {
if (
typeof adaptiveOklabColor !== 'object' || adaptiveOklabColor === null ||
typeof adaptiveOklabColor.L !== 'number' || Number.isNaN(adaptiveOklabColor.L) ||
typeof adaptiveOklabColor.a !== 'number' || Number.isNaN(adaptiveOklabColor.a) ||
typeof adaptiveOklabColor.b !== 'number' || Number.isNaN(adaptiveOklabColor.b)
) {
throw new TypeError('Input adaptiveOklabColor must be an object with L, a, b valid number properties.');
}
let { L } = adaptiveOklabColor;
// Step 0: Reverse tone mapping if it was applied
if (this._toneMapping) {
L = this._reverseToneMapping(L);
}
// Step 1: Undo the hue correction factor
// Handle potential division by zero if _correctionFactor is somehow zero
let aUncorrected, bUncorrected;
if (this._correctionFactor === 0 || Number.isNaN(this._correctionFactor)) {
// If correction factor is 0, assume a and b were zeroed out in forward transform
aUncorrected = 0;
bUncorrected = 0;
} else {
const correctionFactorInv = 1 / this._correctionFactor;
aUncorrected = adaptiveOklabColor.a * correctionFactorInv;
bUncorrected = adaptiveOklabColor.b * correctionFactorInv;
}
// Step 2: Convert "uncorrected" Oklab-like (L, a', b') to non-linear LMS'_adaptive (using Oklab M2_inverse)
const lmsPrimeAdaptive = multiplyMatrixVector(MATRIX_OKLAB_TO_LMS_PRIME, [L, aUncorrected, bUncorrected]);
// Step 3: Undo adaptive non-linearity (apply inverse exponent 1/p)
// Handle potential division by zero if _exponent is somehow zero
const inverseExponent = (this._exponent === 0 || Number.isNaN(this._exponent)) ?
Infinity : 1 / this._exponent;
const lmsOklab = [
signPreservingPow(lmsPrimeAdaptive[0], inverseExponent),
signPreservingPow(lmsPrimeAdaptive[1], inverseExponent),
signPreservingPow(lmsPrimeAdaptive[2], inverseExponent),
];
// Step 4: Convert Oklab's LMS space to Linear sRGB (using Oklab M1_inverse)
const linearRgbArray = multiplyMatrixVector(MATRIX_LMS_OKLAB_TO_LINEAR_SRGB, lmsOklab);
return { r: linearRgbArray[0], g: linearRgbArray[1], b: linearRgbArray[2] };
}
/**
* Converts an Adaptive Oklab color object to an sRGB (gamma-corrected) color object
* using the settings of this converter instance.
* @param {OklabColor} adaptiveOklabColor - The Adaptive Oklab color {L, a, b}.
* @returns {SrgbColor} The sRGB color object {r, g, b}. (Values may be outside [0,1]).
* @throws {TypeError} if `adaptiveOklabColor` is not a valid OklabColor object.
* @example
* const aokConverter = new AdaptiveOklab({ surround: 'white' });
* const aokColor = aokConverter.fromSrgb({r:0.5, g:0.5, b:0.5});
* const srgbOutput = aokConverter.toSrgb(aokColor); // srgbOutput reflects adaptation
*/
toSrgb(adaptiveOklabColor) {
// Type validation for adaptiveOklabColor is handled by this.toLinearSrgb
const linearSrgb = this.toLinearSrgb(adaptiveOklabColor);
return linearSrgbToSrgb(linearSrgb); // from srgb.js
}
/**
* Converts an Adaptive Oklab color object to a CIE XYZ (D65) color object
* using the settings of this converter instance. Output XYZ has Y scaled relative to Y_n=1.0.
* @param {OklabColor} adaptiveOklabColor - The Adaptive Oklab color {L, a, b}.
* @returns {XyzColor} The CIE XYZ color object {X, Y, Z}.
* @throws {TypeError} if `adaptiveOklabColor` is not a valid OklabColor object.
* @example
* const aokConverter = new AdaptiveOklab(); // Gray surround
* const aokColor = { L: 0.7, a: 0.05, b: -0.02 }; // Example AOkLab color
* const xyzOutput = aokConverter.toXyz(aokColor);
*/
toXyz(adaptiveOklabColor) {
// Type validation for adaptiveOklabColor is handled by this.toLinearSrgb
const linearSrgb = this.toLinearSrgb(adaptiveOklabColor);
// linearSrgbToXyz (from srgb.js) expects LinearSrgbColor and returns XyzColor (Y ~0-1)
return linearSrgbToXyz(linearSrgb);
}
/**
* Converts an Adaptive Oklab color object to an sRGB hex string
* using the settings of this converter instance.
* @param {OklabColor} adaptiveOklabColor - The Adaptive Oklab color {L, a, b}.
* @returns {string} The sRGB hex string (e.g., "#RRGGBB").
* @throws {TypeError} if `adaptiveOklabColor` is not a valid OklabColor object.
* @example
* const aokConverter = new AdaptiveOklab({ surround: 'dark' });
* const aokColor = aokConverter.fromHex("#336699");
* const hexOutput = aokConverter.toHex(aokColor);
*/
toHex(adaptiveOklabColor) {
// Type validation for adaptiveOklabColor is handled by this.toSrgb
const srgbColor = this.toSrgb(adaptiveOklabColor);
return formatSrgbAsHex(srgbColor); // from srgb.js
}
}