Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CAM16 (JMh) #379

Merged
merged 5 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A shame that Li et al only give M16 to 7 figures, but there we are

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

const cat16Inv = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that using a full-precision inverse is better than the rounded, 9 figure inverse published in Appendix A

[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: {}
},
});
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";