colorjs.io
Version:
Let’s get serious about color
432 lines (365 loc) • 14.1 kB
JavaScript
/**
* Helmlab MetricSpace — 13-stage perceptual color space.
*
* A data-driven analytical color space fit on COMBVD (3,813 paired human
* color-difference judgments aggregating BFD-P, Witt 1999, RIT-DuPont, and
* Leeds). Used internally by `deltaEHelmlab` and exposed as the CSS
* color space `helmlab-metric` for inspectability. Its coordinate system
* (asymmetric ab plane, L > 1 from H-K modeling, achromatic axis offset
* from origin) is shaped for ΔE accuracy rather than author-facing
* coordinates — for general CSS authoring use HelmGen (`helmgen`).
*
* Measured in this branch: ~24% lower STRESS than CIEDE2000 on COMBVD.
*
* Pipeline: XYZ → M1 → γ → M2 → hue correction → H-K → cubic L → dark L
* → hue-dep chroma scale → chroma power → L-dep chroma scale
* → HLC interaction → hue-dep lightness → rotation
*
* Reference: arXiv:2602.23010
* @see https://github.com/Grkmyldz148/helmlab
*/
import ColorSpace from "../ColorSpace.js";
import {multiply_v3_m3x3, spow, clamp} from "../util.js";
import XYZ_D65 from "./xyz-d65.js";
/** @import { Matrix3x3 } from "../types.js" */
const {cos, sin, sqrt, atan2, exp, abs, pow, PI} = Math;
// ── Bradford CAT: Color.js D65 ↔ Helmlab D65 ─────────────────────
// Color.js D65 = [0.3127/0.3290, 1, (1−0.3127−0.3290)/0.3290]
// Helmlab D65 = [0.95047, 1.0, 1.08883] (SPD-integrated, ASTM E308)
/** @type {number[]} */
export const HELMLAB_D65 = [0.95047, 1.0, 1.08883];
/** Bradford CAT: Color.js D65 → Helmlab D65 (apply in fromBase before M1) */
/** @type {Matrix3x3} */
// prettier-ignore
const CAT_TO_HELM = [
[1.000042977349746, 2.0718877053183e-05, -4.361018085669474e-05],
[2.6946201090235744e-05, 0.9999906145080147, -1.4898828405401079e-05],
[-7.941753620756204e-06, 1.2875204405137254e-05, 0.9997859822609763],
];
/** Bradford CAT: Helmlab D65 → Color.js D65 (apply in toBase after M1_INV) */
/** @type {Matrix3x3} */
// prettier-ignore
const CAT_FROM_HELM = [
[0.9999570254019492, -2.071874272730964e-05, 4.361733292468361e-05],
[-2.694517763358666e-05, 1.000009385946497, 1.490098223546482e-05],
[7.943459292954202e-06, -1.287824596735154e-05, 1.000214063706999],
];
// ── Utility functions ──────────────────────────────────────────────
// ── Core parameters (v21, 72 params) ──────────────────────────────
/** @type {Matrix3x3} */
// prettier-ignore
const M1 = [
[ 0.72129864331134985189, 0.45344826541531813024, -0.19288975751942616377],
[ -0.78821186949557897616, 1.79524137675723594043, 0.08761724511817850503],
[ -0.09177005999121559676, 0.45765588659459255361, 1.29220455139176770842],
];
/** @type {Matrix3x3} */
// prettier-ignore
const M1_INV = [
[ 1.06510729580885898926, -0.31500440753011210715, 0.18034923817410389302],
[ 0.47211077138377960383, 0.42719957659624552271, 0.04150680489380984689],
[ -0.09156391926309541407, -0.17367093631949789834, 0.77197903825582936399],
];
const GAMMA = [0.47229813098762524, 0.5149184096354483, 0.5113233386366979];
const INV_GAMMA = [2.1173067060606283, 1.9420552485353544, 1.9557096741686448];
/** @type {Matrix3x3} */
// prettier-ignore
const M2 = [
[ -0.26355622180094095963, 0.41683228837031738312, 0.49267631416564028335],
[ 1.88975705087773215851, -3.12122320342057735232, 1.04216669210603840590],
[ 0.35851086179620561545, 1.76940281937903676202, -1.41206260676953720967],
];
/** @type {Matrix3x3} */
// prettier-ignore
const M2_INV = [
[ 0.91838978228150214278, 0.52320512370886662623, 0.70658045980908557038],
[ 1.08990905744330257576, 0.07005324849041903723, 0.43197768747870440853],
[ 1.59889572926420897581, 0.22061850068770233468, 0.01250603735522095097],
];
/** Matrices used by this color space, also available as `Helmlab.M` */
export const M = { CAT_TO_HELM, CAT_FROM_HELM, M1, M1_INV, M2, M2_INV };
// Enrichment parameters
const hue_cos1 = -0.02833024015436984, hue_sin1 = -0.21131429516166544;
const hue_cos2 = 0.2189784817615645, hue_sin2 = -0.06871898981942523;
const hue_cos3 = 0.005506053349515315, hue_sin3 = -0.0641329861299175;
const hue_cos4 = -0.053592461436994296, hue_sin4 = -0.00954137464208059;
const hk_weight = 0.2676231133101982, hk_power = 0.8934892185255707;
const hk_hue_mod = 0.7173169828841472;
const hk_sin1 = 0.6915224124600773, hk_cos2 = 0.48647127559605596, hk_sin2 = 0.9853124591201782;
const L_corr_p1 = 0.5385456675962418, L_corr_p2 = 0.12508858146241716, L_corr_p3 = 0.6768950256217603;
const Lh_cos1 = -0.4963251525324449, Lh_sin1 = -0.09564696283240552;
const lp_dark = -0.029053748937210654, lp_dark_hcos = 1.3346761652952872, lp_dark_hsin = -0.1698908144723919;
const cs_cos1 = -0.195370576218515, cs_sin1 = 0.5330819227283227;
const cs_cos2 = 0.08863325582067766, cs_sin2 = 0.9365540137751136;
const cs_cos3 = 0.13789738139719568, cs_sin3 = 0.061650260197979936;
const cs_cos4 = 0.0641970862504494, cs_sin4 = -0.027401052793571013;
const cp_cos1 = -0.09900209889026965, cp_sin1 = 0.059635520647228726;
const cp_cos2 = -0.013586499967803128, cp_sin2 = 0.2253393118474472;
const lc1 = -1.5239477450767043, lc2 = -1.751157310240011;
const hlc_cos1 = -0.43576378069144767, hlc_sin1 = 1.060094063845983;
const hlc_cos2 = 0.47931193034584496, hlc_sin2 = -0.2622579649434462;
const hl_cos1 = 0.13610794232685908, hl_sin1 = 0.1168702235362288;
const hl_cos2 = -0.01617739641422492, hl_sin2 = 0.038145638815030566;
// Rigid rotation φ = −28.2°
const PHI = -28.2 * PI / 180;
const ROT_COS = cos(PHI);
const ROT_SIN = sin(PHI);
// ── Pipeline helper functions ──────────────────────────────────────
function hueDelta (h) {
return hue_cos1 * cos(h) + hue_sin1 * sin(h) +
hue_cos2 * cos(2 * h) + hue_sin2 * sin(2 * h) +
hue_cos3 * cos(3 * h) + hue_sin3 * sin(3 * h) +
hue_cos4 * cos(4 * h) + hue_sin4 * sin(4 * h);
}
function hueDeltaDeriv (h) {
return -hue_cos1 * sin(h) + hue_sin1 * cos(h) +
-2 * hue_cos2 * sin(2 * h) + 2 * hue_sin2 * cos(2 * h) +
-3 * hue_cos3 * sin(3 * h) + 3 * hue_sin3 * cos(3 * h) +
-4 * hue_cos4 * sin(4 * h) + 4 * hue_sin4 * cos(4 * h);
}
function chromaScaleH (h) {
let logS = cs_cos1 * cos(h) + cs_sin1 * sin(h) +
cs_cos2 * cos(2 * h) + cs_sin2 * sin(2 * h) +
cs_cos3 * cos(3 * h) + cs_sin3 * sin(3 * h) +
cs_cos4 * cos(4 * h) + cs_sin4 * sin(4 * h);
return exp(logS);
}
function lChromaScale (L) {
let dL = L - 0.5;
return exp(clamp(-30, lc1 * dL + lc2 * dL * dL, 30));
}
function hlcScale (h, L) {
let hueFactor = hlc_cos1 * cos(h) + hlc_sin1 * sin(h) +
hlc_cos2 * cos(2 * h) + hlc_sin2 * sin(2 * h);
return exp(clamp(-30, (L - 0.5) * hueFactor, 30));
}
function hueLightnessScale (h) {
let logS = hl_cos1 * cos(h) + hl_sin1 * sin(h) +
hl_cos2 * cos(2 * h) + hl_sin2 * sin(2 * h);
return exp(logS);
}
function chromaPowerH (h) {
return 1 + cp_cos1 * cos(h) + cp_sin1 * sin(h) +
cp_cos2 * cos(2 * h) + cp_sin2 * sin(2 * h);
}
function lCorrectFwd (L, h) {
let t = L * (1 - L);
let result = L + L_corr_p1 * t + L_corr_p2 * t * (0.5 - L) + L_corr_p3 * t * t;
result += t * (Lh_cos1 * cos(h) + Lh_sin1 * sin(h));
return result;
}
function lCorrectInv (L1, h) {
let Lh = Lh_cos1 * cos(h) + Lh_sin1 * sin(h);
let L = L1;
for (let i = 0; i < 15; i++) {
let t = L * (1 - L);
let dt = 1 - 2 * L;
let f = L + (L_corr_p1 + Lh) * t + L_corr_p2 * t * (0.5 - L) +
L_corr_p3 * t * t - L1;
let dfdL = 1 + (L_corr_p1 + Lh) * dt +
L_corr_p2 * (dt * (0.5 - L) - t) +
L_corr_p3 * 2 * t * dt;
if (abs(dfdL) < 1e-10) {
dfdL = 1;
}
L -= f / dfdL;
}
return L;
}
function darkLFwd (L, h) {
let coeff = lp_dark + lp_dark_hcos * cos(h) + lp_dark_hsin * sin(h);
let oml = L < 1 ? 1 - L : 0; // clamp at L=1: identity for L≥1
let g = coeff * L * oml * oml;
return L * exp(clamp(-30, g, 30));
}
function darkLInv (Ln, h) {
let coeff = lp_dark + lp_dark_hcos * cos(h) + lp_dark_hsin * sin(h);
let L = Ln;
for (let i = 0; i < 12; i++) {
let oml = L < 1 ? 1 - L : 0;
let g = coeff * L * oml * oml;
let eg = exp(clamp(-30, g, 30));
let f = L * eg - Ln;
let gp = coeff * oml * (1 - 3 * L);
let fp = eg * (1 + L * gp);
if (abs(fp) < 1e-10) {
fp = 1;
}
L -= f / fp;
}
return L;
}
// ── Color space definition ─────────────────────────────────────────
// Helmlab MetricSpace is the perceptual forward space behind `deltaEHelmlab`.
// Its coordinate system is shaped to maximize ΔE prediction accuracy on
// visual-difference datasets (COMBVD: BFD-P, Witt 1999, RIT-DuPont, Leeds),
// not author convenience. As a result:
// - the ab plane is asymmetric (the metric was fit to directionally
// asymmetric human discrimination thresholds);
// - the L axis exceeds 1 for highly chromatic colors because the
// Helmholtz–Kohlrausch boost is modeled explicitly (high-chroma colors
// appear brighter than equiluminant neutrals);
// - the achromatic axis is not pinned to (0, 0) — the gray axis sits
// slightly off-origin in this coordinate system. Distance is
// translation-invariant, so this does not affect ΔE quality.
//
// For CSS-author-facing color authoring (palettes, gradients, gamut
// mapping), use HelmGen (`helmgen`) instead. This space (`helmlab-metric`)
// is exposed primarily so the metric forward is inspectable for
// debugging, color-science exploration, and as the internal forward used
// by `deltaEHelmlab`.
export const fromXYZ = function (xyz) {
// Stage 0: Chromatic adaptation (Color.js D65 → Helmlab D65)
let adapted = multiply_v3_m3x3(xyz, M.CAT_TO_HELM);
// Stage 1: XYZ → LMS (M1)
let [lms0, lms1, lms2] = multiply_v3_m3x3(adapted, M.M1);
// Stage 2: Power compression (signed)
let c0 = spow(lms0, GAMMA[0]);
let c1 = spow(lms1, GAMMA[1]);
let c2 = spow(lms2, GAMMA[2]);
// Stage 3: LMS_c → Lab_raw (M2)
let [L, a, b] = multiply_v3_m3x3([c0, c1, c2], M.M2);
// Stage 3.5: Hue correction (4-harmonic Fourier)
let h = atan2(b, a);
let C = sqrt(a * a + b * b);
let delta = hueDelta(h);
let hNew = h + delta;
a = C * cos(hNew);
b = C * sin(hNew);
// Stage 3.7: Helmholtz-Kohlrausch correction
let Cr = sqrt(a * a + b * b);
let hkBoost = hk_weight * pow(Cr, clamp(0.01, hk_power, 10));
let hr = atan2(b, a);
let factor = 1 + hk_hue_mod * cos(hr) + hk_sin1 * sin(hr) +
hk_cos2 * cos(2 * hr) + hk_sin2 * sin(2 * hr);
L += hkBoost * factor;
// Stage 4: Cubic L correction (with hue modulation)
h = atan2(b, a);
L = lCorrectFwd(L, h);
// Stage 4.5: Dark L compression
h = atan2(b, a);
L = darkLFwd(L, h);
// Stage 5: Hue-dependent chroma scaling
h = atan2(b, a);
let cs = chromaScaleH(h);
a *= cs;
b *= cs;
// Stage 5.5: Nonlinear chroma power
h = atan2(b, a);
C = sqrt(a * a + b * b);
let p = chromaPowerH(h);
let Cn = C > 0 ? pow(C, p) : 0;
a = Cn * cos(h);
b = Cn * sin(h);
// Stage 6: L-dependent chroma scaling
let T = lChromaScale(L);
a *= T;
b *= T;
// Stage 6.5: HLC interaction
h = atan2(b, a);
let hlcS = hlcScale(h, L);
a *= hlcS;
b *= hlcS;
// Stage 8: Hue-dependent lightness scaling
h = atan2(b, a);
L *= hueLightnessScale(h);
// Stage 11: Rigid rotation (φ = −28.2°)
let aRot = a * ROT_COS - b * ROT_SIN;
let bRot = a * ROT_SIN + b * ROT_COS;
return [L, aRot, bRot];
};
export const toXYZ = function (lab) {
let [L, a, b] = lab;
// Undo Stage 11: rotation
let aUn = a * ROT_COS + b * ROT_SIN;
let bUn = -a * ROT_SIN + b * ROT_COS;
a = aUn;
b = bUn;
// Undo Stage 8: hue-dep lightness
let h = atan2(b, a);
L /= hueLightnessScale(h);
// Undo Stage 6.5: HLC
h = atan2(b, a);
let hlcS = hlcScale(h, L);
a /= hlcS;
b /= hlcS;
// Undo Stage 6: L-dep chroma
let T = lChromaScale(L);
a /= T;
b /= T;
// Undo Stage 5.5: chroma power
h = atan2(b, a);
let C = sqrt(a * a + b * b);
let p = chromaPowerH(h);
let Co = C > 0 ? pow(C, 1 / p) : 0;
a = Co * cos(h);
b = Co * sin(h);
// Undo Stage 5: chroma scaling
h = atan2(b, a);
let cs = chromaScaleH(h);
a /= cs;
b /= cs;
// Undo Stage 4.5: dark L
h = atan2(b, a);
L = darkLInv(L, h);
// Undo Stage 4: cubic L
h = atan2(b, a);
L = lCorrectInv(L, h);
// Undo Stage 3.7: H-K
let Cr = sqrt(a * a + b * b);
let hkBoost = hk_weight * pow(Cr, clamp(0.01, hk_power, 10));
let hr = atan2(b, a);
let factor = 1 + hk_hue_mod * cos(hr) + hk_sin1 * sin(hr) +
hk_cos2 * cos(2 * hr) + hk_sin2 * sin(2 * hr);
L -= hkBoost * factor;
// Undo Stage 3.5: hue correction (Newton iteration)
let hOut = atan2(b, a);
C = sqrt(a * a + b * b);
let hRaw = hOut;
for (let i = 0; i < 8; i++) {
let f = hRaw + hueDelta(hRaw) - hOut;
let fp = 1 + hueDeltaDeriv(hRaw);
if (abs(fp) < 1e-10) {
fp = 1;
}
hRaw -= f / fp;
}
a = C * cos(hRaw);
b = C * sin(hRaw);
// Undo Stage 3: Lab → LMS_c (M2_inv)
let [lc0, lc1, lc2] = multiply_v3_m3x3([L, a, b], M.M2_INV);
// Undo Stage 2: power compression
let l0 = spow(lc0, INV_GAMMA[0]);
let l1 = spow(lc1, INV_GAMMA[1]);
let l2 = spow(lc2, INV_GAMMA[2]);
// Undo Stage 1: LMS → XYZ (M1_inv)
let xyz = multiply_v3_m3x3([l0, l1, l2], M.M1_INV);
// Undo Stage 0: Chromatic adaptation (Helmlab D65 → Color.js D65)
return multiply_v3_m3x3(xyz, M.CAT_FROM_HELM);
};
export default new ColorSpace({
id: "helmlab-metric",
name: "Helmlab MetricSpace",
cssId: "--helmlab-metric",
coords: {
l: {
refRange: [0, 1.6],
name: "Lightness",
},
a: {
refRange: [-1.5, 1.5],
},
b: {
refRange: [-1.5, 1.5],
},
},
white: "D65",
base: XYZ_D65,
M,
fromBase (xyz) {
return fromXYZ(xyz);
},
toBase (lab) {
return toXYZ(lab);
},
});