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 HCT gamut mapping and HCT color distance #420

Merged
merged 6 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
};
91 changes: 85 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,58 @@ 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);
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