Skip to content

Commit

Permalink
Add HCT gamut mapping and HCT color distance (#420)
Browse files Browse the repository at this point in the history
* Add HCT gamut mapping and HCT color distance

- Add an HCT color distancing to keep from converting out of HCT which
  can be slower. Since HCT is based on CAM16, convert the C and h
  components to CAM16 UCS a and b. Perform euclidean distance on the
  resulting Lab lightness (tone) and CAM16 a and b.
- Add support for HCT gamut mapping. Add two keywords to configure
  HCT gamut mapping. "hct" giving a normal gamut mapping assuming a JND
  of 2 and "hct-tonal" which will perform a tighter gamut mapping with a
  JND of close to zero and will force clamping of black and white based
  on tone value. This will give results that quite close to Material's
  tonal palettes.
- toGamut has been extended to allow configuring the delta E method
  used, the JND, and enabling white and black SDR clamp.
- Epsilon is now generically calculated to be relative to the JND.
- The epsilon of min/max bisect threshold is still based on the JND
  epsilon, but now it is arbitrarily clamped to 1e-6 to prevent
  calculating a very small epsilon that could cause infinite loops.
- Gamut tests have been extended to allow for converting to target space
  after gamut mapping (if needed). This is used to demonstrate tonal
  maps which require us (for best results) to stay in HCT.

* Fix types, doc strings, formatting, and epsilon

* Export delta E HCT in type definitions

* Allow Ref type for blackWhiteClamp.channel

* Because we stay in mappedColor if already in it, we need to resolve NaN

* Fix linting in type file
  • Loading branch information
facelessuser authored Feb 11, 2024
1 parent 4cf563f commit decf024
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 8 deletions.
48 changes: 48 additions & 0 deletions src/deltaE/deltaEHCT.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import hct from "../spaces/hct.js";
import {viewingConditions} from "../spaces/hct.js";

const rad2deg = 180 / Math.PI;
const deg2rad = Math.PI / 180;
const ucsCoeff = [1.00, 0.007, 0.0228];

/**
* Convert HCT chroma and hue (CAM16 JMh colorfulness and hue) using UCS logic for a and b.
* @param {number[]} coords - HCT coordinates.
* @return {number[]}
*/
function convertUcsAb (coords) {
// We want the distance between the actual color.
// If chroma is negative, it will throw off our calculations.
// Normally, converting back to the base and forward will correct it.
// If we have a negative chroma after this, then we have a color that
// cannot resolve to positive chroma.
if (coords[1] < 0) {
coords = hct.fromBase(hct.toBase(coords));
}

// Only in extreme cases (usually outside the visible spectrum)
// can the input value for log become negative.
// Avoid domain error by forcing a zero result via "max" if necessary.
const M = Math.log(Math.max(1 + ucsCoeff[2] * coords[1] * viewingConditions.flRoot, 1.0)) / ucsCoeff[2];
const hrad = coords[0] * deg2rad;
const a = M * Math.cos(hrad);
const b = M * Math.sin(hrad);

return [coords[2], a, b];
}


/**
* Color distance using HCT.
* @param {Color} color - Color to compare.
* @param {Color} sample - Color to compare.
* @return {number[]}
*/
export default function (color, sample) {
let [ t1, a1, b1 ] = convertUcsAb(hct.from(color));
let [ t2, a2, b2 ] = convertUcsAb(hct.from(sample));

// Use simple euclidean distance with a and b using UCS conversion
// and LCh lightness (HCT tone).
return Math.sqrt((t1 - t2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2);
}
12 changes: 11 additions & 1 deletion src/deltaE/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,17 @@ import deltaE2000 from "./deltaE2000.js";
import deltaEJz from "./deltaEJz.js";
import deltaEITP from "./deltaEITP.js";
import deltaEOK from "./deltaEOK.js";
import deltaEHCT from "./deltaEHCT.js";

export { deltaE76, deltaECMC, deltaE2000, deltaEJz, deltaEITP, deltaEOK };
export {
deltaE76,
deltaECMC,
deltaE2000,
deltaEJz,
deltaEITP,
deltaEOK,
deltaEHCT
};

export default {
deltaE76,
Expand All @@ -14,4 +23,5 @@ export default {
deltaEJz,
deltaEITP,
deltaEOK,
deltaEHCT
};
97 changes: 91 additions & 6 deletions src/toGamut.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,40 @@ import inGamut from "./inGamut.js";
import to from "./to.js";
import get from "./get.js";
import oklab from "./spaces/oklab.js";
import xyzd65 from "./spaces/xyz-d65.js";
import set from "./set.js";
import clone from "./clone.js";
import getColor from "./getColor.js";
import deltaEMethods from "./deltaE/index.js";
import {WHITES} from "./adapt.js";

/**
* Calculate the epsilon to 2 degrees smaller than the specified JND.
* @param {Number} jnd - The target "just noticeable difference".
* @returns {Number}
*/
function calcEpsilon (jnd) {
// Calculate the epsilon to 2 degrees smaller than the specified JND.

const order = (!jnd) ? 0 : Math.floor(Math.log10(Math.abs(jnd)));
// Limit to an arbitrary value to ensure value is never too small and causes infinite loops.
return Math.max(parseFloat(`1e${order - 2}`), 1e-6);
}

const GMAPPRESET = {
"hct": {
method: "hct.c",
jnd: 2,
deltaEMethod: "hct",
blackWhiteClamp: {}
},
"hct-tonal": {
method: "hct.c",
jnd: 0,
deltaEMethod: "hct",
blackWhiteClamp: { channel: "hct.t", min: 0, max: 100 }
},
};

/**
* Force coordinates to be in gamut of a certain color space.
Expand All @@ -22,9 +53,25 @@ import getColor from "./getColor.js";
* until the color is in gamut. Please note that this may produce nonsensical
* results for certain coordinates (e.g. hue) or infinite loops if reducing the coordinate never brings the color in gamut.
* @param {ColorSpace|string} options.space - The space whose gamut we want to map to
* @param {string} options.deltaEMethod - The delta E method to use while performing gamut mapping.
* If no method is specified, delta E 2000 is used.
* @param {Number} options.jnd - The "just noticeable difference" to target.
* @param {Object} options.blackWhiteClamp - Used to configure SDR black and clamping.
* "channel" indicates the "space.channel" to use for determining when to clamp.
* "min" indicates the lower limit for black clamping and "max" indicates the upper
* limit for white clamping.
*/

export default function toGamut (color, { method = defaults.gamut_mapping, space = color.space } = {}) {
export default function toGamut (
color,
{
method = defaults.gamut_mapping,
space = color.space,
deltaEMethod = "",
jnd = 2,
blackWhiteClamp = {}
} = {}
) {
if (util.isString(arguments[1])) {
space = arguments[1];
}
Expand All @@ -46,26 +93,64 @@ export default function toGamut (color, { method = defaults.gamut_mapping, space
}

if (method !== "clip" && !inGamut(color, space)) {

if (Object.prototype.hasOwnProperty.call(GMAPPRESET, method)) {
({method, jnd, deltaEMethod, blackWhiteClamp} = GMAPPRESET[method]);
}

// Get the correct delta E method
let de = deltaE2000;
if (deltaEMethod !== "") {
for (let m in deltaEMethods) {
if ("deltae" + deltaEMethod.toLowerCase() === m.toLowerCase()) {
de = deltaEMethods[m];
break;
}
}
}

let clipped = toGamut(clone(spaceColor), { method: "clip", space });
if (deltaE2000(color, clipped) > 2) {
if (de(color, clipped) > jnd) {

// Clamp to SDR white and black if required
if (Object.keys(blackWhiteClamp).length === 3) {
let channelMeta = ColorSpace.resolveCoord(blackWhiteClamp.channel);
let channel = get(to(color, channelMeta.space), channelMeta.id);
if (util.isNone(channel)) {
channel = 0;
}
if (channel >= blackWhiteClamp.max) {
return to({ space: "xyz-d65", coords: WHITES["D65"] }, color.space);
}
else if (channel <= blackWhiteClamp.min) {
return to({ space: "xyz-d65", coords: [0, 0, 0] }, color.space);
}
}

// Reduce a coordinate of a certain color space until the color is in gamut
let coordMeta = ColorSpace.resolveCoord(method);
let mapSpace = coordMeta.space;
let coordId = coordMeta.id;

let mappedColor = to(spaceColor, mapSpace);
let mappedColor = to(color, mapSpace);
// If we were already in the mapped color space, we need to resolve undefined channels
mappedColor.coords.forEach((c, i) => {
if (util.isNone(c)) {
mappedColor.coords[i] = 0;
}
});
let bounds = coordMeta.range || coordMeta.refRange;
let min = bounds[0];
let ε = .01; // for deltaE
let ε = calcEpsilon(jnd);
let low = min;
let high = get(mappedColor, coordId);

while (high - low > ε) {
let clipped = clone(mappedColor);
clipped = toGamut(clipped, { space, method: "clip" });
let deltaE = deltaE2000(mappedColor, clipped);
let deltaE = de(mappedColor, clipped);

if (deltaE - 2 < ε) {
if (deltaE - jnd < ε) {
low = get(mappedColor, coordId);
}
else {
Expand Down
103 changes: 103 additions & 0 deletions test/gamut.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export default {
run (colorStr, args) {
let color = new Color(colorStr);
let inGamut = this.data.method ? {method: this.data.method} : true;
if (this.data.convertAfter) {
return color.toGamut({space: this.data.toSpace, method: this.data.method}).to(this.data.toSpace);
}
let color2 = color.to(this.data.toSpace, {inGamut});
return color2;
},
Expand Down Expand Up @@ -182,5 +185,105 @@ export default {
},
]
},
{
name: "P3 primaries to sRGB, HCT chroma reduction",
data: { method: "hct", toSpace: "sRGB" },
tests: [
{
args: ["color(display-p3 1 0 0)"],
expect: "rgb(100% 5.7911% 0%)"
},
{
args: ["color(display-p3 0 1 0)"],
expect: "rgb(0% 99.496% 0%)"
},
{
args: ["color(display-p3 0 0 1)"],
expect: "rgb(0% 0% 100%)"
},
{
args: ["color(display-p3 1 1 0)"],
expect: "rgb(99.749% 99.792% 0%)"
},
{
args: ["color(display-p3 0 1 1)"],
expect: "rgb(0% 100% 99.135%)"
},
{
args: ["color(display-p3 1 0 1)"],
expect: "rgb(100% 13.745% 96.626%)"
}
]
},
{
name: "HCT Gamut Mapping. Demonstrates tonal palettes (blue).",
data: { toSpace: "srgb", method: "hct-tonal", convertAfter: true},
tests: [
{
args: ["color(--hct 282.762176394358 87.22803916105873 0)"],
expect: "rgb(0% 0% 0%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 5)"],
expect: "rgb(0% 0.07618% 30.577%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 10)"],
expect: "rgb(0% 0.12788% 43.024%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 15)"],
expect: "rgb(0% 0.16162% 54.996%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 20)"],
expect: "rgb(0% 0.16388% 67.479%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 25)"],
expect: "rgb(0% 0.10802% 80.421%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 30)"],
expect: "rgb(0% 0% 93.775%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 35)"],
expect: "rgb(10.099% 12.729% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 40)"],
expect: "rgb(20.18% 23.826% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 50)"],
expect: "rgb(35.097% 39.075% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 60)"],
expect: "rgb(48.508% 51.958% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 70)"],
expect: "rgb(61.603% 64.093% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 80)"],
expect: "rgb(74.695% 75.961% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 90)"],
expect: "rgb(87.899% 87.77% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 95)"],
expect: "rgb(94.558% 93.686% 100%)"
},
{
args: ["color(--hct 282.762176394358 87.22803916105873 100)"],
expect: "rgb(100% 100% 100%)"
}
]
},
]
};
5 changes: 5 additions & 0 deletions types/src/deltaE/deltaEHCT.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Color, { ColorObject } from "../color.js";
export default function (
color: Color | ColorObject,
sample: Color | ColorObject
): number;
1 change: 1 addition & 0 deletions types/src/deltaE/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { default as deltaE2000 } from "./deltaE2000.js";
export { default as deltaEJz } from "./deltaEJz.js";
export { default as deltaEITP } from "./deltaEITP.js";
export { default as deltaEOK } from "./deltaEOK.js";
export { default as deltaEHCT } from "./deltaEHCT.js";

declare const deltaEMethods: Omit<typeof import("./index.js"), "default">;
export default deltaEMethods;
Expand Down
9 changes: 8 additions & 1 deletion types/src/toGamut.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ColorTypes, PlainColorObject } from "./color.js";
import ColorSpace from "./space.js";
import ColorSpace, { Ref } from "./space.js";

declare namespace toGamut {
let returns: "color";
Expand All @@ -10,6 +10,13 @@ declare function toGamut (
options?: {
method?: string | undefined;
space?: string | ColorSpace | undefined;
deltaEMethod?: string | undefined;
jnd?: number | undefined;
blackWhiteClamp?: {
channel: Ref;
min: number;
max: number;
} | undefined;
} | string
): PlainColorObject;

Expand Down

0 comments on commit decf024

Please sign in to comment.