d3-cam02
Version:
CAM02 color space.
494 lines (385 loc) • 16.1 kB
JavaScript
// Implementation based on Billy Bigg's CIECAM02 implementation in C
// (http://scanline.ca/ciecam02/)
// and information on Wikipedia (https://en.wikipedia.org/wiki/CIECAM02)
//
// IMPORTANT NOTE : uses XYZ [0,100] not [0,1]
//
// When transforming colors into CIECAM02 space we use Luo et al.'s uniform
// color space transform; however, we also provide commented out transform
// coefficients for the long-distance and short-distance CIECAM02 transforms,
// should others desire to use these alternative perceptually uniform
// approximation spaces instead.
//
// Another important note is that we provide the full range of CIECAM02 color
// values in the JCh constructor, but the d3 color object stores only lightness
// (J), chroma (C), and hue (h).
//
import {color, rgb} from "d3-color";
import {deg2rad, rad2deg} from "./math";
// used for brighter and darker functions
// Kn is completely arbitrary and was picked originally by Mike Bostock to make
// the Lab brighter and darker functions behave similarly to the RGB equivalents
// in d3-color. We copy and paste the value directly and encourage others to
// add a more systematically chosen value.
var Kn = 18;
// Conversion functions
function rgb2xyz(r, g, b) {
r = r / 255.0;
g = g / 255.0;
b = b / 255.0;
// assume sRGB
r = r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : (r / 12.92);
g = g > 0.04045 ? Math.pow(((g + 0.055) / 1.055), 2.4) : (g / 12.92);
b = b > 0.04045 ? Math.pow(((b + 0.055) / 1.055), 2.4) : (b / 12.92);
// Convert to XYZ in [0,100] rather than [0,1]
return {
x: ( (r * 0.4124) + (g * 0.3576) + (b * 0.1805) ) * 100.0,
y: ( (r * 0.2126) + (g * 0.7152) + (b * 0.0722) ) * 100.0,
z: ( (r * 0.0193) + (g * 0.1192) + (b * 0.9505) ) * 100.0
};
}
function xyz2rgb(x, y, z) {
x = x / 100.0;
y = y / 100.0;
z = z / 100.0;
var preR = x * 3.2404542 + y * -1.5371385 - z * 0.4985314,
preG = x * -0.9692660 + y * 1.8760108 + z * 0.0415560,
preB = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
function toRGB(c) {
return 255.0 * (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055);
}
return {r: toRGB(preR), g: toRGB(preG), b: toRGB(preB)};
}
function xyz2cat02(x,y,z) {
var l = ( 0.7328 * x) + (0.4296 * y) - (0.1624 * z),
m = (-0.7036 * x) + (1.6975 * y) + (0.0061 * z),
s = ( 0.0030 * x) + (0.0136 * y) + (0.9834 * z);
return {l: l, m: m, s: s};
}
function cat022xyz(l, m, s) {
var x = ( 1.096124 * l) - (0.278869 * m) + (0.182745 * s),
y = ( 0.454369 * l) + (0.473533 * m) + (0.072098 * s),
z = (-0.009628 * l) - (0.005698 * m) + (1.015326 * s);
return {x: x, y: y, z:z};
}
function cat022hpe(l,m,s) {
var lh = ( 0.7409792 * l) + (0.2180250 * m) + (0.0410058 * s),
mh = ( 0.2853532 * l) + (0.6242014 * m) + (0.0904454 * s),
sh = (-0.0096280 * l) - (0.0056980 * m) + (1.0153260 * s);
return {lh: lh, mh: mh, sh: sh};
}
function hpe2xyz(l, m, s) {
var x = (1.910197 * l) - (1.112124 * m) + (0.201908 * s),
y = (0.370950 * l) + (0.629054 * m) - (0.000008 * s),
z = s;
return {x:x, y:y, z:z};
}
function nonlinearAdaptation(coneResponse, fl) {
var p = Math.pow( (fl * coneResponse) / 100.0, 0.42 );
return ((400.0 * p) / (27.13 + p)) + 0.1;
}
function inverseNonlinearAdaptation(coneResponse, fl) {
return (100.0 / fl) *
Math.pow((27.13 * Math.abs(coneResponse - 0.1)) /
(400.0 - Math.abs(coneResponse - 0.1)),
1.0 / 0.42);
}
// CIECAM02_VC viewing conditions; assumes average viewing conditions
var CIECAM02_VC = (function() {
var vc = {
D65_X: 95.047, // D65 standard referent
D65_Y: 100.0,
D65_Z: 108.883,
// Viewing conditions
// Note about L_A:
// Billy Bigg's implementation just uses a value of 4 cd/m^2, but
// the colorspacious implementation uses greater precision by calculating
// it with (64 / numpy.pi) / 5
// This is based on Moroney (2000), "Usage guidelines for CIECAM97s" where
// sRGB illuminance is 64 lux. Because of its greater precision we use
// Moroney's alternative definition.
la: (64.0 / Math.PI) / 5.0,
yb: 20.0, // 20% gray
// Surround
f: 1.0, // average; dim: 0.9; dark: 0.8
c: 0.69, // average; dim: 0.59; dark: 0.525
nc: 1.0 // average; dim: 0.95; dark: 0.8
};
vc.D65_LMS = xyz2cat02(vc.D65_X, vc.D65_Y, vc.D65_Z),
vc.n = vc.yb / vc.D65_Y;
vc.z = 1.48 + Math.sqrt(vc.n);
var k = 1.0 / ((5.0 * vc.la) + 1.0);
vc.fl = (0.2 * Math.pow(k, 4.0) * (5.0 * vc.la)) +
0.1 * Math.pow(1.0 - Math.pow(k, 4.0), 2.0) *
Math.pow(5.0 * vc.la, 1.0/3.0);
vc.nbb = 0.725 * Math.pow(1.0 / vc.n, 0.2);
vc.ncb = vc.nbb;
vc.d = vc.f * ( 1.0 - (1.0 / 3.6) * Math.exp((-vc.la - 42.0) / 92.0) );
vc.achromaticResponseToWhite = (function() {
var l = vc.D65_LMS.l,
m = vc.D65_LMS.m,
s = vc.D65_LMS.s;
var lc = l * (((vc.D65_Y * vc.d) / l) + (1.0 - vc.d)),
mc = m * (((vc.D65_Y * vc.d) / m) + (1.0 - vc.d)),
sc = s * (((vc.D65_Y * vc.d) / s) + (1.0 - vc.d));
var hpeTransforms = cat022hpe(lc, mc, sc),
lp = hpeTransforms.lh,
mp = hpeTransforms.mh,
sp = hpeTransforms.sh;
var lpa = nonlinearAdaptation(lp, vc.fl),
mpa = nonlinearAdaptation(mp, vc.fl),
spa = nonlinearAdaptation(sp, vc.fl);
return (2.0 * lpa + mpa + 0.05 * spa - 0.305) * vc.nbb;
})();
return vc;
})(); // end CIECAM02_VC
function cat022cam02(l,m,s) {
var theColor = {};
var D65_CAT02 = xyz2cat02(CIECAM02_VC.D65_X,CIECAM02_VC.D65_Y,CIECAM02_VC.D65_Z);
function cTransform(cone, D65_cone) {
var D65_Y = CIECAM02_VC.D65_Y,
VC_d = CIECAM02_VC.d;
return cone * (((D65_Y * VC_d) / D65_cone) + (1.0 - VC_d));
}
var lc = cTransform(l, D65_CAT02.l),
mc = cTransform(m, D65_CAT02.m),
sc = cTransform(s, D65_CAT02.s);
var hpeTransforms = cat022hpe(lc, mc, sc),
lp = hpeTransforms.lh,
mp = hpeTransforms.mh,
sp = hpeTransforms.sh;
var lpa = nonlinearAdaptation(lp, CIECAM02_VC.fl),
mpa = nonlinearAdaptation(mp, CIECAM02_VC.fl),
spa = nonlinearAdaptation(sp, CIECAM02_VC.fl);
var ca = lpa - ((12.0*mpa) / 11.0) + (spa / 11.0),
cb = (1.0/9.0) * (lpa + mpa - 2.0*spa);
theColor.h = (180.0 / Math.PI) * Math.atan2(cb, ca);
if(theColor.h < 0.0) theColor.h += 360.0;
var temp;
if(theColor.h < 20.14) {
temp = ((theColor.h + 122.47)/1.2) + ((20.14 - theColor.h)/0.8);
theColor.H = 300 + (100*((theColor.h + 122.47)/1.2)) / temp;
} else if(theColor.h < 90.0) {
temp = ((theColor.h - 20.14)/0.8) + ((90.00 - theColor.h)/0.7);
theColor.H = (100*((theColor.h - 20.14)/0.8)) / temp;
} else if(theColor.h < 164.25) {
temp = ((theColor.h - 90.00)/0.7) + ((164.25 - theColor.h)/1.0);
theColor.H = 100 + ((100*((theColor.h - 90.00)/0.7)) / temp);
} else if (theColor.h < 237.53) {
temp = ((theColor.h - 164.25)/1.0) + ((237.53 - theColor.h)/1.2);
theColor.H = 200 + ((100*((theColor.h - 164.25)/1.0)) / temp);
} else {
temp = ((theColor.h - 237.53)/1.2) + ((360 - theColor.h + 20.14)/0.8);
theColor.H = 300 + ((100*((theColor.h - 237.53)/1.2)) / temp);
}
var a = ( 2.0*lpa + mpa + 0.05*spa - 0.305 ) * CIECAM02_VC.nbb;
theColor.J = 100.0 * Math.pow(a / CIECAM02_VC.achromaticResponseToWhite,
CIECAM02_VC.c * CIECAM02_VC.z);
var et = 0.25 * (Math.cos((theColor.h * Math.PI) / 180.0 + 2.0) + 3.8),
t = ((50000.0 / 13.0) * CIECAM02_VC.nc * CIECAM02_VC.ncb * et * Math.sqrt(ca*ca + cb*cb)) /
(lpa + mpa + (21.0/20.0)*spa);
theColor.C = Math.pow(t, 0.9) * Math.sqrt(theColor.J / 100.0) *
Math.pow(1.64 - Math.pow(0.29, CIECAM02_VC.n), 0.73);
theColor.Q = (4.0 / CIECAM02_VC.c) * Math.sqrt(theColor.J / 100.0) *
(CIECAM02_VC.achromaticResponseToWhite + 4.0) * Math.pow(CIECAM02_VC.fl, 0.25);
theColor.M = theColor.C * Math.pow(CIECAM02_VC.fl, 0.25);
theColor.s = 100.0 * Math.sqrt(theColor.M / theColor.Q);
return theColor;
}
function Aab2Cat02LMS(A, aa, bb, nbb) {
var x = (A / nbb) + 0.305;
var l = (0.32787 * x) + (0.32145 * aa) + (0.20527 * bb),
m = (0.32787 * x) - (0.63507 * aa) - (0.18603 * bb),
s = (0.32787 * x) - (0.15681 * aa) - (4.49038 * bb);
return {l:l, m:m, s:s};
}
function cam022rgb(J, C, h) {
// NOTE input is small h not big H, the later of which is corrected
var t = Math.pow(C / (Math.sqrt(J / 100.0) *
Math.pow(1.64-Math.pow(0.29, CIECAM02_VC.n), 0.73)),
(1.0 / 0.9)),
et = 1.0 / 4.0 * (Math.cos(((h * Math.PI) / 180.0) + 2.0) + 3.8);
var a = Math.pow( J / 100.0, 1.0 / (CIECAM02_VC.c * CIECAM02_VC.z) ) *
CIECAM02_VC.achromaticResponseToWhite;
var p1 = ((50000.0 / 13.0) * CIECAM02_VC.nc * CIECAM02_VC.ncb) * et / t,
p2 = (a / CIECAM02_VC.nbb) + 0.305,
p3 = 21.0 / 20.0,
p4, p5, ca, cb;
var hr = (h * Math.PI) / 180.0;
if (Math.abs(Math.sin(hr)) >= Math.abs(Math.cos(hr))) {
p4 = p1 / Math.sin(hr);
cb = (p2 * (2.0 + p3) * (460.0 / 1403.0)) /
(p4 + (2.0 + p3) * (220.0 / 1403.0) *
(Math.cos(hr) / Math.sin(hr)) - (27.0 / 1403.0) +
p3 * (6300.0 / 1403.0));
ca = cb * (Math.cos(hr) / Math.sin(hr));
}
else {
p5 = p1 / Math.cos(hr);
ca = (p2 * (2.0 + p3) * (460.0 / 1403.0)) /
(p5 + (2.0 + p3) * (220.0 / 1403.0) -
((27.0 / 1403.0) - p3 * (6300.0 / 1403.0)) *
(Math.sin(hr) / Math.cos(hr)));
cb = ca * (Math.sin(hr) / Math.cos(hr));
}
var lms_a = Aab2Cat02LMS(a, ca, cb, CIECAM02_VC.nbb),
lpa = lms_a.l,
mpa = lms_a.m,
spa = lms_a.s;
var lp = inverseNonlinearAdaptation(lpa, CIECAM02_VC.fl),
mp = inverseNonlinearAdaptation(mpa, CIECAM02_VC.fl),
sp = inverseNonlinearAdaptation(spa, CIECAM02_VC.fl);
var txyz = hpe2xyz(lp, mp, sp),
lms_c = xyz2cat02(txyz.x, txyz.y, txyz.z);
var D65_CAT02 = xyz2cat02(CIECAM02_VC.D65_X, CIECAM02_VC.D65_Y,
CIECAM02_VC.D65_Z);
var l = lms_c.l / ( ((CIECAM02_VC.D65_Y * CIECAM02_VC.d) / D65_CAT02.l) +
(1.0 - CIECAM02_VC.d) ),
m = lms_c.m / ( ((CIECAM02_VC.D65_Y * CIECAM02_VC.d) / D65_CAT02.m) +
(1.0 - CIECAM02_VC.d) ),
s = lms_c.s / ( ((CIECAM02_VC.D65_Y * CIECAM02_VC.d) / D65_CAT02.s) +
(1.0 - CIECAM02_VC.d) );
var xyz = cat022xyz(l, m, s),
rgb = xyz2rgb(xyz.x, xyz.y, xyz.z);
return rgb;
}
function jchConvert(o) {
if (o instanceof JCh) return new JCh(o.J, o.C, o.h, o.opacity);
if (!(o instanceof rgb)) o = rgb(o);
var xyz = rgb2xyz(o.r, o.g, o.b),
lmsConeResponses = xyz2cat02(xyz.x,xyz.y,xyz.z),
cam02obj = cat022cam02(lmsConeResponses.l,lmsConeResponses.m,
lmsConeResponses.s);
return new JCh(cam02obj.J, cam02obj.C, cam02obj.h, o.opacity);
}
export default function jch(J, C, h, opacity) {
return arguments.length === 1 ? jchConvert(J) : new JCh(J, C, h,
opacity == null ? 1 : opacity);
}
export function JCh(J, C, h, opacity) {
this.J = +J;
this.C = +C;
this.h = +h;
this.opacity = +opacity;
}
var jchPrototype = JCh.prototype = jch.prototype = Object.create(color.prototype);
jchPrototype.constructor = JCh;
jchPrototype.brighter = function(k) {
return new JCh(this.J + Kn * (k === null ? 1 : k), this.C, this.h,
this.opacity);
};
jchPrototype.darker = function(k) {
return new JCh(this.J - Kn * (k === null ? 1 : k), this.C, this.h,
this.opacity);
};
jchPrototype.rgb = function () {
var converted = cam022rgb(this.J, this.C, this.h);
return rgb(converted.r, converted.g, converted.b, this.opacity);
};
////////////////////////////////////////////////////////////////////////////////
// Updated attempts at perceptually uniform color spaces
// Formulas and constants taken from
// M.R. Luo and C. Li. "CIECAM02 and Its Recent Developments"
var altCam02Coef = {
lcd: {k_l: 0.77, c1: 0.007, c2:0.0053},
scd: {k_l: 1.24, c1: 0.007, c2:0.0363},
ucs: {k_l: 1.00, c1: 0.007, c2:0.0228}
};
function jabConvert(o) {
if(o instanceof Jab) {
return new Jab(o.J, o.a, o.b, o.opacity);
}
if (!(o instanceof rgb)) o = rgb(o);
var xyz = rgb2xyz(o.r, o.g, o.b),
lmsConeResponses = xyz2cat02(xyz.x,xyz.y,xyz.z),
cam02 = cat022cam02(lmsConeResponses.l, lmsConeResponses.m, lmsConeResponses.s);
var coefs = altCam02Coef.ucs;
var JPrime = ((1.0 + 100.0*coefs.c1) * cam02.J) / (1.0 + coefs.c1 * cam02.J);
JPrime = JPrime / coefs.k_l;
var MPrime = (1.0/coefs.c2) * Math.log(1.0 + coefs.c2*cam02.M); // log=ln
var a = MPrime * Math.cos(deg2rad*cam02.h),
b = MPrime * Math.sin(deg2rad*cam02.h);
return new Jab(JPrime, a, b, o.opacity);
}
// DE color distance function generator for the three CAM02 perceptually uniform
// models: lcd, scd, and ucs
function cam02de(coefs) {
return function(o) {
if (!(o instanceof Jab)) o = jabConvert(o);
var k_l = coefs.k_l,
diffJ = Math.abs(this.J - o.J),
diffA = Math.abs(this.a - o.a),
diffB = Math.abs(this.b - o.b);
var de = Math.sqrt( (diffJ/k_l)*(diffJ/k_l) + diffA*diffA + diffB*diffB );
return de;
};
}
export function jab(J, a, b, opacity) {
opacity = opacity == null ? 1 : opacity;
return arguments.length === 1 ? jabConvert(J) :
new Jab(J, a, b, opacity);
}
export function Jab(J, a, b, opacity) {
this.J = J;
this.a = a;
this.b = b;
this.opacity = opacity;
}
var jabPrototype = Jab.prototype = jab.prototype = Object.create(color.prototype);
jabPrototype.constructor = JCh;
jabPrototype.brighter = function(k) {
return new Jab(this.J + Kn * (k === null ? 1 : k), this.a, this.b,
this.opacity);
};
jabPrototype.darker = function(k) {
return new Jab(this.J - Kn * (k === null ? 1 : k), this.a, this.b,
this.opacity);
};
jabPrototype.rgb = function() {
var coefs = altCam02Coef.ucs;
var J = this.J, a = this.a, b = this.b;
// Get the new M using trigonomic identities
// MPrime = (1.0/coefs.c2) * Math.log(1.0 + coefs.c2*cam02.M); // log=ln
// var a = MPrime * Math.cos(o.h),
// b = MPrime * Math.sin(o.h);
// x*x = (x*cos(y))*(x(cos(y))) + (x*sin(y))*(x(sin(y)))
var newMPrime = Math.sqrt(a*a + b*b),
newM = (Math.exp(newMPrime * coefs.c2) - 1.0) / coefs.c2;
var newh = rad2deg*Math.atan2(b,a);
if(newh < 0) newh = 360.0 + newh;
// M = C * Math.pow(CIECAM02_VC.fl, 0.25);
// C = M / Math.pow(CIECAM02_VC.fl, 0.25);
var newC = newM / Math.pow(CIECAM02_VC.fl, 0.25);
// Last, derive the new Cam02J
// JPrime = ((1.0 + 100.0*coefs.c1) * cam02.J) / (1.0 + coefs.c1 * cam02.J)
// simplified: var cam02J = JPrime / (1.0 + coefs.c1*(100.0 - JPrime));
// if v = (d*x) / (b + a*x), x = (b*(v/d)) / (1 - a(v/d))
var newCam02J = J / (1.0 + coefs.c1*(100.0 - J));
var converted = cam022rgb(newCam02J, newC, newh);
return rgb(converted.r, converted.g, converted.b, this.opacity);
};
jabPrototype.de = cam02de(altCam02Coef.ucs);
export function interpolateJab(start, end) {
// constant, linear, and colorInterpolate are taken from d3-interpolate
// the colorInterpolate function is `nogamma` in the d3-interpolate's color.js
function constant(x) { return function() { return x; } }
function linear(a, d) { return function(t) { return a + t * d; }; }
function colorInterpolate(a, b) {
var d = b - a;
return d ? linear(a, d) : constant(isNaN(a) ? b : a);
}
start = jabConvert(start);
end = jabConvert(end);
// TODO import color function from d3-interpolate
var J = colorInterpolate(start.J, end.J),
a = colorInterpolate(start.a, end.a),
b = colorInterpolate(start.b, end.b),
opacity = colorInterpolate(start.opacity, end.opacity);
return function(t) {
start.J = J(t);
start.a = a(t);
start.b = b(t);
start.opacity = opacity(t);
return start + "";
};
}