Skip to content

Commit

Permalink
Merge branch 'main' into formatting-v2
Browse files Browse the repository at this point in the history
* main:
  Add CAM16 (JMh) (color-js#379)
  [spaces/prophoto] Use 64 bit-accurate conversion matrices
  formatting
  [spaces/hsl] Better handling of negative saturation on very oog colors
  Point to the actual, working tests not the old ones
  CSS4 toGamut fixes (color-js#352)
  • Loading branch information
jgerigmeyer committed Dec 19, 2023
2 parents cae6066 + 1036174 commit 330d01b
Show file tree
Hide file tree
Showing 9 changed files with 681 additions and 108 deletions.
364 changes: 364 additions & 0 deletions src/spaces/cam16.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
import ColorSpace from "../space.js";
import {multiplyMatrices, interpolate, copySign, spow, zdiv, bisectLeft} from "../util.js";
import {constrain} from "../angles.js";
import xyz_d65 from "./xyz-d65.js";
import {WHITES} from "../adapt.js";

const white = WHITES.D65;
const adaptedCoef = 0.42;
const adaptedCoefInv = 1 / adaptedCoef;
const tau = 2 * Math.PI

const cat16 = [
[ 0.401288, 0.650173, -0.051461 ],
[ -0.250268, 1.204414, 0.045854 ],
[ -0.002079, 0.048952, 0.953127 ]
];

const cat16Inv = [
[1.8620678550872327, -1.0112546305316843, 0.14918677544445175],
[0.38752654323613717, 0.6214474419314753, -0.008973985167612518],
[-0.015841498849333856, -0.03412293802851557, 1.0499644368778496]
]

const m1 = [
[460.0, 451.0, 288.0],
[460.0, -891.0, -261.0],
[460.0, -220.0, -6300.0]
]

const surroundMap = {
dark: [0.8, 0.525, 0.8],
dim: [0.9, 0.59, 0.9],
average: [1, 0.69, 1]
}

const hueQuadMap = {
// Red, Yellow, Green, Blue, Red
h: [20.14, 90.00, 164.25, 237.53, 380.14],
e: [0.8, 0.7, 1.0, 1.2, 0.8],
H: [0.0, 100.0, 200.0, 300.0, 400.0]
}

const rad2deg = 180 / Math.PI;
const deg2rad = Math.PI / 180;

export function adapt (coords, fl) {
const temp = coords.map(c => {
const x = spow(fl * Math.abs(c) * 0.01, adaptedCoef);
return 400 * copySign(x, c) / (x + 27.13);
});
return temp;
}

export function unadapt (adapted, fl) {
const constant = 100 / fl * (27.13 ** adaptedCoefInv);
return adapted.map(c => {
const cabs = Math.abs(c);
return copySign(constant * spow(cabs / (400 - cabs), adaptedCoefInv), c);
});
}

export function hueQuadrature (h) {
let hp = constrain(h)
if (hp <= hueQuadMap.h[0]) {
hp += 360;
}

const i = bisectLeft(hueQuadMap.h, hp) - 1;
const [hi, hii] = hueQuadMap.h.slice(i, i + 2);
const [ei, eii] = hueQuadMap.e.slice(i, i + 2);
const Hi = hueQuadMap.H[i];

const t = (hp - hi) / ei;
return Hi + (100 * t) / (t + (hii - hp) / eii);
}

export function invHueQuadrature (H) {
let Hp = ((H % 400 + 400) % 400);
const i = Math.floor(0.01 * Hp);
Hp = Hp % 100;
const [hi, hii] = hueQuadMap.h.slice(i, i + 2);
const [ei, eii] = hueQuadMap.e.slice(i, i + 2);

return constrain(
(Hp * (eii * hi - ei * hii) - 100 * hi * eii) /
(Hp * (eii - ei) - 100 * eii)
);
}

export function environment (
refWhite,
adaptingLuminance,
backgroundLuminance,
surround,
discounting
) {

const env = {};

env.discounting = discounting;
env.refWhite = refWhite;
env.surround = surround;
const xyzW = refWhite.map(c => {
return c * 100;
});

// The average luminance of the environment in `cd/m^2cd/m` (a.k.a. nits)
env.la = adaptingLuminance;
// The relative luminance of the nearby background
env.yb = backgroundLuminance;
// Absolute luminance of the reference white.
const yw = xyzW[1];

// Cone response for reference white
const rgbW = multiplyMatrices(cat16, xyzW);

// Surround: dark, dim, and average
surround = surroundMap[env.surround]
const f = surround[0];
env.c = surround[1];
env.nc = surround[2];

const k = 1 / (5 * env.la + 1);
const k4 = k ** 4;

// Factor of luminance level adaptation
env.fl = (k4 * env.la + 0.1 * (1 - k4) * (1 - k4) * Math.cbrt(5 * env.la));
env.flRoot = env.fl ** 0.25;

env.n = env.yb / yw;
env.z = 1.48 + Math.sqrt(env.n);
env.nbb = 0.725 * (env.n ** -0.2);
env.ncb = env.nbb;

// Degree of adaptation calculating if not discounting
// illuminant (assumed eye is fully adapted)
const d = (discounting) ?
1 :
Math.max(
Math.min(f * (1 - 1 / 3.6 * Math.exp((-env.la - 42) / 92)), 1),
0
);
env.dRgb = rgbW.map(c => {
return interpolate(1, yw / c, d);
});
env.dRgbInv = env.dRgb.map(c => {
return 1 / c;
});

// Achromatic response
const rgbCW = rgbW.map((c, i) => {
return c * env.dRgb[i];
});
const rgbAW = adapt(rgbCW, env.fl);
env.aW = env.nbb * (2 * rgbAW[0] + rgbAW[1] + 0.05 * rgbAW[2]);

// console.log(env);

return env;
}

// Pre-calculate everything we can with the viewing conditions
const viewingConditions = environment(
white,
64 / Math.PI * 0.2, 20,
'average',
false
);

export function fromCam16(cam16, env) {

// These check ensure one, and only one attribute for a
// given category is provided.
if (!((cam16.J !== undefined) ^ (cam16.Q !== undefined))) {
throw new Error("Conversion requires one and only one: 'J' or 'Q'");
}

if (!((cam16.C !== undefined) ^ (cam16.M !== undefined) ^ (cam16.s !== undefined))) {
throw new Error("Conversion requires one and only one: 'C', 'M' or 's'");
}

// Hue is absolutely required
if (!((cam16.h !== undefined) ^ (cam16.H !== undefined))) {
throw new Error("Conversion requires one and only one: 'h' or 'H'");
}

// Black
if (cam16.J === 0.0 || cam16.Q === 0.0) {
return [0.0, 0.0, 0.0];
}

// Break hue into Cartesian components
let hRad = 0.0;
if (cam16.h !== undefined) {
hRad = constrain(cam16.h) * deg2rad;
}
else {
h_rad = invHueQuadrature(H) * deg2rad;
}

const cosh = Math.cos(hRad);
const sinh = Math.sin(hRad);

// Calculate `Jroot` from one of the lightness derived coordinates.
let Jroot = 0.0;
if (cam16.J !== undefined) {
Jroot = spow(cam16.J, 1 / 2) * 0.1;
}
else if (cam16.Q !== undefined) {
Jroot = 0.25 * env.c * cam16.Q / ((env.aW + 4) * env.flRoot);
}

// Calculate the `t` value from one of the chroma derived coordinates
let alpha = 0.0;
if (cam16.C !== undefined) {
alpha = cam16.C / Jroot;
}
else if (cam16.M !== undefined) {
alpha = (cam16.M / env.flRoot) / Jroot;
}
else if (cam16.s !== undefined) {
alpha = 0.0004 * (cam16.s ** 2) * (env.aW + 4) / env.c;
}
const t = spow(
alpha * Math.pow(1.64 - Math.pow(0.29, env.n), -0.73),
10 / 9
);

// Eccentricity
const et = 0.25 * (Math.cos(hRad + 2) + 3.8);

// Achromatic response
const A = env.aW * spow(Jroot, 2 / env.c / env.z);

// Calculate red-green and yellow-blue components
const p1 = 5e4 / 13 * env.nc * env.ncb * et;
const p2 = A / env.nbb;
const r = (
23 * (p2 + 0.305) *
zdiv(t, 23 * p1 + t * (11 * cosh + 108 * sinh))
);
const a = r * cosh;
const b = r * sinh;

// Calculate back from cone response to XYZ
const rgb_c = unadapt(
multiplyMatrices(m1, [p2, a, b]).map(c => {
return c * 1 / 1403;
}),
env.fl
);
return multiplyMatrices(
cat16Inv,
rgb_c.map((c, i) => {
return c * env.dRgbInv[i];
})
).map(c => {
return c / 100;
});
}


export function toCam16 (xyzd65, env) {
// Cone response
const xyz100 = xyzd65.map(c => {
return c * 100;
});
const rgbA = adapt(
multiplyMatrices(cat16, xyz100).map((c, i) => {
return c * env.dRgb[i];
}),
env.fl
);

// Calculate hue from red-green and yellow-blue components
const a = rgbA[0] + (-12 * rgbA[1] + rgbA[2]) / 11;
const b = (rgbA[0] + rgbA[1] - 2 * rgbA[2]) / 9;
const hRad = ((Math.atan2(b, a) % tau) + tau) % tau;

// Eccentricity
const et = 0.25 * (Math.cos(hRad + 2) + 3.8);

const t = (
5e4 / 13 * env.nc * env.ncb *
zdiv(
et * Math.sqrt(a ** 2 + b ** 2),
rgbA[0] + rgbA[1] + 1.05 * rgbA[2] + 0.305
)
);
const alpha = spow(t, 0.9) * Math.pow(1.64 - Math.pow(0.29, env.n), 0.73);

// Achromatic response
const A = env.nbb * (2 * rgbA[0] + rgbA[1] + 0.05 * rgbA[2]);

const Jroot = spow(A / env.aW, 0.5 * env.c * env.z);

// Lightness
const J = 100 * spow(Jroot, 2);

// Brightness
const Q = (4 / env.c * Jroot * (env.aW + 4) * env.flRoot);

// Chroma
const C = alpha * Jroot;

// Colorfulness
const M = C * env.flRoot;

// Hue
const h = constrain(hRad * rad2deg);

// Hue quadrature
const H = hueQuadrature(h);

// Saturation
const s = 50 * spow(env.c * alpha / (env.aW + 4), 1 / 2);

// console.log({J: J, C: C, h: h, s: s, Q: Q, M: M, H: H});

return {J: J, C: C, h: h, s: s, Q: Q, M: M, H: H};
}


// Provided as a way to directly evaluate the CAM16 model
// https://observablehq.com/@jrus/cam16: reference implementation
// https://arxiv.org/pdf/1802.06067.pdf: Nico Schlömer
// https://onlinelibrary.wiley.com/doi/pdf/10.1002/col.22324: hue quadrature
// https://www.researchgate.net/publication/318152296_Comprehensive_color_solutions_CAM16_CAT16_and_CAM16-UCS
// Results compared against: https://github.com/colour-science/colour
export default new ColorSpace({
id: "cam16-jmh",
name: "CAM16-JMh",
coords: {
j: {
refRange: [0, 100],
name: "J",
},
m: {
refRange: [0, 105.0],
name: "Colorfulness",
},
h: {
refRange: [0, 360],
type: "angle",
name: "Hue",
}
},

base: xyz_d65,

fromBase (xyz) {
const cam16 = toCam16(xyz, viewingConditions);
return [cam16.J, cam16.M, cam16.h];
},
toBase (cam16) {
return fromCam16(
{J: cam16[0], M: cam16[1], h: cam16[2]},
viewingConditions
);
},
formats: {
color: {}
},
});
14 changes: 13 additions & 1 deletion src/spaces/hsl.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default new ColorSpace({

base: sRGB,

// Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB
// Adapted from https://drafts.csswg.org/css-color-4/better-rgbToHsl.js
fromBase: rgb => {
let max = Math.max(...rgb);
let min = Math.min(...rgb);
Expand All @@ -42,6 +42,18 @@ export default new ColorSpace({
h = h * 60;
}

// Very out of gamut colors can produce negative saturation
// If so, just rotate the hue by 180 and use a positive saturation
// see https://github.com/w3c/csswg-drafts/issues/9222
if (s < 0) {
h += 180;
s = Math.abs(s);
}

if (h >= 360) {
h -= 360;
}

return [h, s * 100, l * 100];
},

Expand Down
1 change: 1 addition & 0 deletions src/spaces/index-fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export {default as REC_2020_Linear} from "./rec2020-linear.js";
export {default as REC_2020} from "./rec2020.js";
export {default as OKLab} from "./oklab.js";
export {default as OKLCH} from "./oklch.js";
export {default as CAM16_JMh} from "./cam16.js";

export * from "./index-fn-hdr.js";

0 comments on commit 330d01b

Please sign in to comment.