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 CSS toGamut algorithm #344

Merged
merged 10 commits into from
Nov 16, 2023
8 changes: 1 addition & 7 deletions api/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -249,19 +249,13 @@
"name": "options.method",
"optional": true,
"type": "string",
"description": "How to force into gamut. If \"clip\", coordinates are just clipped to their reference range (don't do that unless you have a reason, it produces very poor results). If in the form [colorSpaceId].[coordName], that coordinate is reduced 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. Defaults to `\"lch.c\"`."
"description": "How to force into gamut. If \"css\", chroma is reduced in the Oklch space using the algorithm defined by CSS Color 4. If \"clip\", coordinates are just clipped to their reference range (don't do that unless you have a reason, it produces very poor results). If in the form [colorSpaceId].[coordName], that coordinate is reduced 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. Defaults to `\"css\"`."
svgeesus marked this conversation as resolved.
Show resolved Hide resolved
},
{
"name": "options.space",
"optional": true,
"type": "ColorSpace",
"description": "Color space whose gamut we are mapping to. Defaults to the current color space."
},
{
"name": "options.inPlace",
LeaVerou marked this conversation as resolved.
Show resolved Hide resolved
"optional": true,
"type": "boolean",
"description": "If true, modifies the current color instead of returning a new one. Defaults to false."
}
],
"returnType": "Color",
Expand Down
11 changes: 8 additions & 3 deletions docs/gamut-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,20 @@ sRGB_lime; // still out of gamut
```

Perhaps most important is the `method` parameter, which controls the algorithm used for gamut mapping.

The default method is `"css"`, which uses the binary search algorithm from [CSS Color Module Level 4](https://drafts.csswg.org/css-color/#css-gamut-mapping). The mapping is done in the Oklch space, and works by finding a chroma value where there is minimal difference between the mapped color and a clipped version. This difference is called the just noticeable difference, and is calculated in deltaEOK.

If the Oklch representation of the color has a lightness of less than or equal to 0, black is returned. Similarly, if the color has a lightness of greater than or equal to 1, white is returned.

You can pass `"clip"` to use simple clipping (not recommended), or any coordinate of any imported color space, which will make Color.js reduce that coordinate until the color is in gamut.

The default method is `"lch.c"` which means LCH hue and lightness remain constant while chroma is reduced until the color fits in gamut.
The default method before implementing the CSS Color 4 algorithm was `"lch.c"` which means LCH hue and lightness remain constant while chroma is reduced until the color fits in gamut.
Simply reducing chroma tends to produce good results for most colors, but most notably fails on yellows:

![chroma-reduction](images/p3-yellow-lab.svg)

Here is P3 yellow, with LCH Chroma reduced to the neutral axis. The RGB values are linear-light P3. The color wedge shows sRGB values, if in gamut; salmon, if outside sRGB and red if outside P3. Notice the red curve goes up (so, out of gamut) before finally dropping again. The chroma of P3 yellow is 123, while the chroma of the gamut-mapped result is far to low, only 25!
Here is P3 yellow, with LCH Chroma reduced to the neutral axis. The RGB values are linear-light P3. The color wedge shows sRGB values, if in gamut; salmon, if outside sRGB and red if outside P3. Notice the red curve goes up (so, out of gamut) before finally dropping again. The chroma of P3 yellow is 123, while the chroma of the gamut-mapped result is far too low, only 25!

Instead, the default algorithm reduces chroma (by binary search) and also, at each stage, calculates the deltaE2000 between the current estimate and a channel-clipped version of that color. If the deltaE is less than 2, the clipped color is displayed. Notice the red curve hugs the top edge now because clipping to sRGB also means it is inside P3 gamut. Notice how we get an in-gamut color much earlier. This method produces an in-gamut color with chroma 103.
Instead, the `"lch.c"` method reduces chroma (by binary search) and also, at each stage, calculates the deltaE2000 between the current estimate and a channel-clipped version of that color. If the deltaE is less than 2, the clipped color is displayed. Notice the red curve hugs the top edge now because clipping to sRGB also means it is inside P3 gamut. Notice how we get an in-gamut color much earlier. This method produces an in-gamut color with chroma 103.

![chroma-reduction-clip](images/p3-yellow-lab-clip.svg)
2 changes: 1 addition & 1 deletion src/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
const hasDOM = typeof document !== "undefined";

export default {
gamut_mapping: "lch.c",
svgeesus marked this conversation as resolved.
Show resolved Hide resolved
gamut_mapping: "css",
precision: 5,
deltaE: "76", // Default deltaE method
};
2 changes: 1 addition & 1 deletion src/index-fn.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export {default as to} from "./to.js";
export {default as serialize} from "./serialize.js";
export {default as display} from "./display.js";
export {default as inGamut} from "./inGamut.js";
export {default as toGamut} from "./toGamut.js";
export {default as toGamut, toGamutCSS} from "./toGamut.js";
export {default as distance} from "./distance.js";
export {default as equals} from "./equals.js";
export {default as contrast} from "./contrast.js";
Expand Down
4 changes: 4 additions & 0 deletions src/space.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export default class ColorSpace {
});
}

get isUnbounded () {
return Object.values(this.coords).every(coord => !("range" in coord));
}

get cssId () {
return this.formats.functions?.color?.id || this.id;
}
Expand Down
207 changes: 155 additions & 52 deletions src/toGamut.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import * as util from "./util.js";
import ColorSpace from "./space.js";
import defaults from "./defaults.js";
import deltaE2000 from "./deltaE/deltaE2000.js";
import deltaEOK from "./deltaE/deltaEOK.js";
import inGamut from "./inGamut.js";
import to from "./to.js";
import get from "./get.js";
import parse from "./parse.js";
import set from "./set.js";
import clone from "./clone.js";
import getColor from "./getColor.js";
Expand All @@ -15,84 +17,91 @@ import getColor from "./getColor.js";
* @param {Object|string} options object or spaceId string
* @param {string} options.method - How to force into gamut.
* If "clip", coordinates are just clipped to their reference range.
* If "css", coordinates are reduced according to the CSS 4 Gamut Mapping Algorithm.
* If in the form [colorSpaceId].[coordName], that coordinate is reduced
* 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
*/
export default function toGamut (color, {method = defaults.gamut_mapping, space = color.space} = {}) {

export default function toGamut (color, { method = defaults.gamut_mapping, space = color.space } = {}) {
if (util.isString(arguments[1])) {
space = arguments[1];
}

space = ColorSpace.get(space);

if (inGamut(color, space, {epsilon: 0})) {
return getColor(color);
}

// 3 spaces:
// color.space: current color space
// space: space whose gamut we are mapping to
// mapSpace: space with the coord we're reducing

let spaceColor = to(color, space);
if (method === "css") {
spaceColor = to(toGamutCSS(color, space), color.space);
jgerigmeyer marked this conversation as resolved.
Show resolved Hide resolved
}
else {
if (inGamut(color, space, { epsilon: 0 })) {
return getColor(color);
}

if (method !== "clip" && !inGamut(color, space)) {
let clipped = toGamut(clone(spaceColor), {method: "clip", space});
if (deltaE2000(color, clipped) > 2) {
// 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 bounds = coordMeta.range || coordMeta.refRange;
let min = bounds[0];
let ε = .01; // for deltaE
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);

if (deltaE - 2 < ε) {
low = get(mappedColor, coordId);
}
else {
high = get(mappedColor, coordId);
if (method !== "clip" && !inGamut(color, space)) {
let clipped = toGamut(clone(spaceColor), { method: "clip", space });
if (deltaE2000(color, clipped) > 2) {
// 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 bounds = coordMeta.range || coordMeta.refRange;
let min = bounds[0];
let ε = .01; // for deltaE
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);

if (deltaE - 2 < ε) {
low = get(mappedColor, coordId);
}
else {
high = get(mappedColor, coordId);
}

set(mappedColor, coordId, (low + high) / 2);
}

set(mappedColor, coordId, (low + high) / 2);
spaceColor = to(mappedColor, space);
}
else {
spaceColor = clipped;
}

spaceColor = to(mappedColor, space);
}
else {
spaceColor = clipped;
}
}

if (method === "clip" // Dumb coord clipping
// finish off smarter gamut mapping with clip to get rid of ε, see #17
|| !inGamut(spaceColor, space, {epsilon: 0})
) {
let bounds = Object.values(space.coords).map(c => c.range || []);
if (method === "clip" // Dumb coord clipping
// finish off smarter gamut mapping with clip to get rid of ε, see #17
|| !inGamut(spaceColor, space, { epsilon: 0 })
) {
let bounds = Object.values(space.coords).map(c => c.range || []);

spaceColor.coords = spaceColor.coords.map((c, i) => {
let [min, max] = bounds[i];
spaceColor.coords = spaceColor.coords.map((c, i) => {
let [min, max] = bounds[i];

if (min !== undefined) {
c = Math.max(min, c);
}
if (min !== undefined) {
c = Math.max(min, c);
}

if (max !== undefined) {
c = Math.min(c, max);
}
if (max !== undefined) {
c = Math.min(c, max);
}

return c;
});
return c;
});
}
}

if (space !== color.space) {
Expand All @@ -104,3 +113,97 @@ export default function toGamut (color, {method = defaults.gamut_mapping, space
}

toGamut.returns = "color";

svgeesus marked this conversation as resolved.
Show resolved Hide resolved
/**
* toGamutCSS
*
jgerigmeyer marked this conversation as resolved.
Show resolved Hide resolved
* Given a color `origin`, returns a new color that is in gamut using
* the CSS Gamut Mapping Algorithm. If `space` is specified, it will be in gamut
* in `space`, and returned in `space`. Otherwise, it will be in gamut and
* returned in the color space of `origin`.
* @param {Object} origin
* @param {Object} options
* @param {ColorSpace|string} options.space
* @returns
*/
export function toGamutCSS (origin, { space = origin.space }) {
const JND = 0.02;
const ε = 0.0001;
space = ColorSpace.get(space);

if (space.isUnbounded) {
return to(origin, space);
}

const origin_OKLCH = to(origin, ColorSpace.get("oklch"));
let L = origin_OKLCH.coords[0];

// return media white or black, if lightness is out of range
if (L >= 1) {
const white = to(parse("white"), space);
svgeesus marked this conversation as resolved.
Show resolved Hide resolved
white.alpha = origin.alpha;
return to(white, space);
}
if (L <= 0) {
const black = to(parse("black"), space);
black.alpha = origin.alpha;
return to(black, space);
};

if (inGamut(origin_OKLCH, space)) {
return to(origin_OKLCH, space);
}

function clip (_color) {
const destColor = to(_color, space);
const spaceCoords = Object.values(space.coords);
destColor.coords = destColor.coords.map((coord, index) => {
const spaceCoord = spaceCoords[index];
if (("range" in spaceCoord)) {
if (coord < spaceCoord.range[0]) {
return spaceCoord.range[0];
}
if (coord > spaceCoord.range[1]) {
return spaceCoord.range[1];
}
Comment on lines +169 to +174
Copy link
Member

Choose a reason for hiding this comment

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

Non-blocking: It feels this logic can be simplified, but couldn't come up with a refactor off the top of my head.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I considered a clamp function like return Math.min(Math.max(coord, spaceCoord.range[0]), spaceCoord.range[1]);. I went with clarity over brevity, but could easily switch.

Copy link
Member

Choose a reason for hiding this comment

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

If a clamp function is correct (they don't 100% have the same semantics as this code), I think the best course of action is to define a clamp(min, value, max) in util.js, and then use it here.

}
return coord;
});
return destColor;
}
let min = 0;
let max = origin_OKLCH.coords[1];

let min_inGamut = true;
let current;

while ((max - min) > ε) {
const chroma = (min + max) / 2;
current = clone(origin_OKLCH);
current.coords[1] = chroma;
if (min_inGamut && inGamut(current, space)) {
min = chroma;
continue;
}
else if (!inGamut(current, space)) {
const clipped = clip(current);
const E = deltaEOK(clipped, current);
if (E < JND) {
if ((JND - E < ε)) {
// match found
current = clipped;
break;
}
else {
min_inGamut = false;
min = chroma;
}
}
else {
max = chroma;
continue;
}
}
}
return to(current, space);
}
2 changes: 1 addition & 1 deletion types/src/index-fn.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export { default as to } from "./to.js";
export { default as serialize } from "./serialize.js";
export { default as display } from "./display.js";
export { default as inGamut } from "./inGamut.js";
export { default as toGamut } from "./toGamut.js";
export { default as toGamut, toGamutCSS } from "./toGamut.js";
export { default as distance } from "./distance.js";
export { default as equals } from "./equals.js";
export { default as contrast } from "./contrast.js";
Expand Down
12 changes: 12 additions & 0 deletions types/src/toGamut.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,15 @@ declare function toGamut(
): PlainColorObject;

export default toGamut;

declare namespace toGamutCSS {
let returns: "color";
}
jgerigmeyer marked this conversation as resolved.
Show resolved Hide resolved
declare function toGamutCSS(
color: ColorTypes,
options?: {
space?: string | ColorSpace | undefined;
}
): PlainColorObject;

export {toGamutCSS};