🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@texel/color

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@texel/color - npm Package Compare versions

Comparing version
1.1.3
to
1.1.4
+1
-1
package.json
{
"name": "@texel/color",
"version": "1.1.3",
"version": "1.1.4",
"description": "a minimal and modern color library",

@@ -5,0 +5,0 @@ "type": "module",

const EPSILON = Math.pow(2, -33); // ~= 0.0000000001
export default function arrayAlmostEqual(a, b, tolerance = EPSILON) {
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const p0 = a[i];
const p1 = b[i];
if (p0 !== p1) {
const diff = Math.abs(p1 - p0);
if (diff > tolerance) return false;
}
}
return true;
}

Sorry, the diff of this file is not supported yet

import Color from "colorjs.io";
import {
sRGB as sRGB_ColorJS,
OKLCH as OKLCH_ColorJS,
P3 as DisplayP3_ColorJS,
toGamut,
} from "colorjs.io/fn";
import {
convert,
OKLCH,
OKLab,
sRGB,
sRGBGamut,
DisplayP3Gamut,
DisplayP3,
gamutMapOKLCH,
constrainAngle,
findCuspOKLCH,
degToRad,
MapToCuspL,
} from "../src/index.js";
import { getSupportedColorJSSpaces } from "./colorjs-fn.js";
const supportedSpaces = getSupportedColorJSSpaces();
// @texel/color space interfaces
const spaces = supportedSpaces.map((s) => s.space);
// Colorjs.io space interfaces & IDs
const colorJSSpaces = supportedSpaces.map((s) => s.colorJSSpace);
const colorJSSpaceIDs = supportedSpaces.map((s) => s.colorJSSpace.id);
const N = 128 * 128;
// sampling variables within OKLab and converting to OKLCH
const vecs = Array(N)
.fill()
.map((_, i, lst) => {
const t = i / (lst.length - 1);
return convert([t, t * 2 - 1, t * 2 - 1], OKLab, OKLCH);
});
const tmp = [0, 0, 0];
let now, elapsedColorjs, elapsedOurs;
//// OKLCH to sRGB with gamut mapping (direct path)
const hueCusps = Array(360).fill(null);
const oklchVecs = Array(512 * 256)
.fill()
.map((_, i, lst) => {
const t0 = i / (lst.length - 1);
const t1 = i / lst.length;
const H = constrainAngle(Math.round(t1 * 360));
if (!hueCusps[H]) {
const Hr = degToRad(H);
const a = Math.cos(Hr);
const b = Math.sin(Hr);
hueCusps[H] = findCuspOKLCH(a, b, sRGBGamut);
}
return [t0, t0, H];
});
compare(
"conversion (Colorjs.io procedural API)",
() => {
for (let vec of vecs) {
for (let i = 0; i < colorJSSpaces.length; i++) {
for (let j = 0; j < colorJSSpaces.length; j++) {
const a = colorJSSpaces[i];
const b = colorJSSpaces[j];
const ret = b.from(a, vec);
}
}
}
},
() => {
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
for (let j = 0; j < spaces.length; j++) {
const a = spaces[i];
const b = spaces[j];
convert(vec, a, b, tmp);
}
}
}
}
);
compare(
"conversion (Colorjs.io main API)",
() => {
for (let vec of vecs) {
for (let i = 0; i < colorJSSpaceIDs.length; i++) {
for (let j = 0; j < colorJSSpaceIDs.length; j++) {
const a = colorJSSpaceIDs[i];
const b = colorJSSpaceIDs[j];
new Color(a, vec).to(b);
}
}
}
},
() => {
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
for (let j = 0; j < spaces.length; j++) {
const a = spaces[i];
const b = spaces[j];
convert(vec, a, b, tmp);
}
}
}
}
);
compare(
"gamut mapping OKLCH - sRGB (Colorjs.io procedural API)",
() => {
let tmpColor = { space: sRGB_ColorJS, coords: [0, 0, 0], alpha: 1 };
for (let vec of oklchVecs) {
tmpColor.coords = sRGB_ColorJS.from(OKLCH_ColorJS, vec);
toGamut(tmpColor);
}
},
() => {
for (let vec of oklchVecs) {
// you can omit the cusp and it will be found on the fly,
// however the test will run slightly slower
const cusp = hueCusps[vec[2]];
gamutMapOKLCH(vec, sRGBGamut, sRGB, tmp, MapToCuspL, cusp);
}
}
);
compare(
"gamut mapping OKLCH - sRGB (Colorjs.io main API)",
() => {
for (let vec of oklchVecs) {
new Color("oklch", vec)
.to("srgb")
.toGamut({ space: "srgb", method: "css" });
}
},
() => {
for (let vec of oklchVecs) {
// you can omit the cusp and it will be found on the fly,
// however the test will run slightly slower
const cusp = hueCusps[vec[2]];
gamutMapOKLCH(vec, sRGBGamut, sRGB, tmp, MapToCuspL, cusp);
}
}
);
compare(
"gamut mapping all spaces to P3 (Colorjs.io procedural API)",
() => {
let tmpColor = { space: DisplayP3_ColorJS, coords: [0, 0, 0], alpha: 1 };
for (let vec of vecs) {
for (let i = 0; i < colorJSSpaces.length; i++) {
const a = colorJSSpaces[i];
tmpColor.coords = DisplayP3_ColorJS.from(a, vec);
toGamut(tmpColor);
}
}
},
() => {
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
const a = spaces[i];
convert(vec, a, OKLCH, tmp);
gamutMapOKLCH(tmp, DisplayP3Gamut, DisplayP3, tmp);
}
}
}
);
compare(
"gamut mapping all spaces to P3 (Colorjs.io main API)",
() => {
for (let vec of vecs) {
for (let i = 0; i < colorJSSpaceIDs.length; i++) {
const a = colorJSSpaceIDs[i];
new Color(a, vec).to("p3").toGamut({ space: "p3", method: "css" });
}
}
},
() => {
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
const a = spaces[i];
convert(vec, a, OKLCH, tmp);
gamutMapOKLCH(tmp, DisplayP3Gamut, DisplayP3, tmp);
}
}
}
);
function print(label) {
console.log("%s --", label);
console.log("Colorjs.io: %s ms", elapsedColorjs.toFixed(2));
console.log("Ours: %s ms", elapsedOurs.toFixed(2));
if (elapsedColorjs > elapsedOurs)
console.log(
"Speedup: %sx faster",
(elapsedColorjs / elapsedOurs).toFixed(1)
);
else
console.log(
"Slowdown: %sx slower",
(elapsedOurs / elapsedColorjs).toFixed(1)
);
console.log();
}
function compare(label, colorJSFn, ourFn) {
now = performance.now();
colorJSFn();
elapsedColorjs = performance.now() - now;
now = performance.now();
ourFn();
elapsedOurs = performance.now() - now;
print(label);
}
import {
convert,
OKLCH,
sRGB,
sRGBGamut,
listColorSpaces,
DisplayP3Gamut,
DisplayP3,
gamutMapOKLCH,
constrainAngle,
findCuspOKLCH,
degToRad,
MapToCuspL,
OKHSL,
OKLab,
OKHSLToOKLab,
} from "../src/index.js";
import { p3, toGamut, oklch, okhsl, converter } from "culori";
const N = 256 * 256;
const gamut = DisplayP3Gamut;
const target = gamut.space;
// get N OKLCH in-gamut pixels by sampling uniformly from OKHSL cube
const oklchPixelsInGamut = Array(N)
.fill()
.map((_, i, lst) => {
const t = i / (lst.length - 1);
// note if we use the standard OKHSL space, it is bound to sRGB gamut...
// so instead we use the OKHSLToOKLab function and pass P3 gamut
const okhsl = [t * 360, t, t];
const oklab = OKHSLToOKLab(okhsl, gamut);
return convert(oklab, OKLab, OKLCH);
});
// get N OKLCH pixels by sampling OKLab, many will be out of srgb gamut
const oklchPixelsRandom = Array(N)
.fill()
.map((_, i, lst) => {
const t = i / (lst.length - 1);
return convert([t, t * 2 - 1, t * 2 - 1], OKLab, OKLCH);
});
const toP3Gamut = toGamut("p3", "oklch");
// same perf as p3() it seems
// const p3Converter = converter("p3");
test(oklchPixelsInGamut, "Random Sampling in P3 Gamut");
test(oklchPixelsRandom, "Random Sampling in OKLab L Planes");
test(oklchPixelsRandom, "Random Sampling in OKLab L Planes", true);
function test(inputPixelsOKLCH, label, fixedCusp) {
let cuspMap;
if (fixedCusp) {
cuspMap = new Array(360).fill(null);
inputPixelsOKLCH = inputPixelsOKLCH.map((oklch) => {
const H = constrainAngle(Math.round(oklch[2]));
oklch = oklch.slice();
oklch[2] = H;
if (!cuspMap[H]) {
const Hr = degToRad(H);
const a = Math.cos(Hr);
const b = Math.sin(Hr);
cuspMap[H] = findCuspOKLCH(a, b, gamut);
}
return oklch;
});
}
console.log(
"Testing with input type: %s%s",
label,
fixedCusp ? " (Fixed Cusp)" : ""
);
const culoriInputsOKLCH = inputPixelsOKLCH.map(([l, c, h]) => {
return {
mode: "oklch",
l,
c,
h,
};
});
let now, elapsedCulori, elapsedOurs;
let tmp = [0, 0, 0];
//// conversion
tmp = [0, 0, 0];
now = performance.now();
for (let oklch of inputPixelsOKLCH) {
convert(oklch, OKLCH, target, tmp);
}
elapsedOurs = performance.now() - now;
now = performance.now();
for (let oklchColor of culoriInputsOKLCH) {
p3(oklchColor);
// same perf ?
// p3Converter(oklchColor);
}
elapsedCulori = performance.now() - now;
print("Conversion OKLCH to P3");
//// gamut
tmp = [0, 0, 0];
if (fixedCusp && cuspMap) {
now = performance.now();
for (let oklch of inputPixelsOKLCH) {
const cusp = cuspMap[oklch[2]];
gamutMapOKLCH(oklch, gamut, target, tmp, undefined, cusp);
}
elapsedOurs = performance.now() - now;
} else {
now = performance.now();
for (let oklch of inputPixelsOKLCH) {
gamutMapOKLCH(oklch, gamut, target, tmp);
}
elapsedOurs = performance.now() - now;
}
now = performance.now();
for (let oklchColor of culoriInputsOKLCH) {
toP3Gamut(oklchColor);
}
elapsedCulori = performance.now() - now;
print("Gamut Mapping OKLCH to P3 Gamut");
function print(label) {
console.log("%s --", label);
console.log("Culori: %s ms", elapsedCulori.toFixed(2));
console.log("Ours: %s ms", elapsedOurs.toFixed(2));
if (elapsedCulori > elapsedOurs)
console.log(
"Speedup: %sx faster",
(elapsedCulori / elapsedOurs).toFixed(1)
);
else
console.log(
"Slowdown: %sx slower",
(elapsedOurs / elapsedCulori).toFixed(1)
);
console.log();
}
}
import {
convert,
OKLCH,
sRGB,
sRGBGamut,
listColorSpaces,
gamutMapOKLCH,
} from "../src/index.js";
const spaces = listColorSpaces().filter((f) => !/ok(hsv|hsl)/i.test(f.id));
const vecs = Array(128 * 128)
.fill()
.map((_, i, lst) => {
const t = i / (lst.length - 1);
return (
Array(3)
.fill()
// -0.5 .. 1.5
.map(() => t + (t * 2 - 1) * 0.5)
);
});
const tmp = [0, 0, 0];
// console.time("bench");
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
for (let j = 0; j < spaces.length; j++) {
const a = spaces[i];
const b = spaces[j];
// convert A to B
convert(vec, a, b, tmp);
// convert B to OKLCH
convert(tmp, b, OKLCH, tmp);
// gamut map OKLCH
gamutMapOKLCH(tmp, sRGBGamut, sRGB, tmp);
}
}
}
// benchmark for EOK
// for (let i = 0; i < 1000; i++) {
// for (let vec of vecs) {
// deltaEOK(vec, [0, 0.25, 1]);
// }
// }
// console.timeEnd("bench");
// To test @texel/color (~3.5 kb)
import * as colors from "../src/index.js";
const rgb = colors.convert([0.5, 0.15, 30], colors.OKLCH, colors.sRGB);
console.log(rgb);
// To test colorjs.io (~55.3 kb)
// import Color from "colorjs.io";
// console.log(new Color("oklch", [0.5, 0.15, 30]).to("srgb").coords);
// To test Culori (~43.2 kb)
// import { rgb } from "culori";
// console.log(rgb({ mode: "oklch", l: 0.5, c: 0.15, h: 30 }));
import canvasSketch from "canvas-sketch";
import {
findCuspOKLCH,
gamutMapOKLCH,
MapToAdaptiveCuspL,
A98RGBGamut,
convert,
DisplayP3Gamut,
OKLCH,
Rec2020Gamut,
sRGBGamut,
degToRad,
constrainAngle,
floatToByte,
isRGBInGamut,
clampedRGB,
} from "../src/index.js";
import arrayAlmostEqual from "./almost-equal.js";
const settings = {
dimensions: [768, 768],
animate: false,
playbackRate: "throttle",
fps: 2,
attributes: {
colorSpace: "display-p3",
},
};
const sketch = ({ width, height }) => {
return ({ context, width, height, frame, playhead }) => {
const { colorSpace = "srgb" } = context.getContextAttributes();
const gamut = colorSpace === "srgb" ? sRGBGamut : DisplayP3Gamut;
const mapping = MapToAdaptiveCuspL;
context.fillStyle = "gray";
context.fillRect(0, 0, width, height);
const H = 264.1;
// const H = constrainAngle((frame * 45) / 2);
// console.time("map");
// console.profile("map");
const tmp = [0, 0, 0];
const pixels = new Uint8ClampedArray(width * height * 4).fill(0xff);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const u = x / width;
const v = y / height;
const [L, C] = UVtoLC([u, v]);
let oklch = [L, C, H];
let rgb = convert(oklch, OKLCH, gamut.space, tmp);
if (!isRGBInGamut(rgb, 0)) {
rgb[0] = 0.25;
rgb[1] = 0.25;
rgb[2] = 0.25;
// if we wanted to fill the whole space with mapped colors
// rgb = gamutMapOKLCH(oklch, gamut, sRGB, tmp, mapping);
}
const idx = x + y * width;
pixels[idx * 4 + 0] = floatToByte(rgb[0]);
pixels[idx * 4 + 1] = floatToByte(rgb[1]);
pixels[idx * 4 + 2] = floatToByte(rgb[2]);
}
}
context.putImageData(
new ImageData(pixels, width, height, { colorSpace }),
0,
0
);
// console.profileEnd("map");
// console.timeEnd("map");
const A = [1, 0];
const B = [0, 0];
const lineWidth = width * 0.003;
context.lineWidth = lineWidth;
const gamuts = [
{ defaultColor: "yellow", gamut: sRGBGamut },
{ defaultColor: "palegreen", gamut: DisplayP3Gamut },
{ defaultColor: "red", gamut: Rec2020Gamut },
{ defaultColor: "pink", gamut: A98RGBGamut },
];
const hueAngle = degToRad(H);
const a = Math.cos(hueAngle);
const b = Math.sin(hueAngle);
for (let { gamut: dispGamut, defaultColor } of gamuts) {
const gamutCusp = findCuspOKLCH(a, b, dispGamut);
const gamutTri = [A, gamutCusp, B];
drawLCTriangle(
context,
gamutTri,
gamut === dispGamut ? "white" : defaultColor
);
}
context.strokeStyle = "white";
const steps = 64;
for (let i = 0; i < steps; i++) {
// get some LC point that is very likely to be out of gamut
const ox = 0.5;
const oy = 0.5;
const r = 1;
const t = (i / steps) * degToRad(360) + degToRad(-180);
const xy = [Math.cos(t) * r + ox, Math.sin(t) * r + oy];
const [L, C] = UVtoLC(xy);
const oklch = [L, C, H];
const lc = oklch.slice(0, 2);
const mapped = gamutMapOKLCH(oklch, gamut, OKLCH, tmp, mapping);
const radius = width * 0.01;
const didChange = !arrayAlmostEqual(mapped, oklch);
if (didChange) {
context.globalAlpha = 0.5;
drawLCPoint(context, lc, radius / 2, "white");
context.beginPath();
context.lineTo(...LCtoXY(lc));
context.lineTo(...LCtoXY(mapped));
context.stroke();
context.globalAlpha = 1;
drawLCPoint(context, mapped.slice(0, 2), radius / 4, "white");
} else {
drawLCPoint(context, lc, radius);
}
}
const fontSize = width * 0.03;
const boxHeight = fontSize * gamuts.length;
const pad = width * 0.05;
const padleft = width * 0.1;
context.fillStyle = "black";
for (let i = 0; i < gamuts.length; i++) {
const { gamut: dispGamut, defaultColor } = gamuts[i];
const curColor = dispGamut === gamut ? "white" : defaultColor;
context.font = `${fontSize}px monospace`;
context.textAlign = "right";
context.textBaseline = "top";
context.fillStyle = curColor;
const x = width - pad - padleft;
const y = height - boxHeight + i * fontSize - pad;
context.fillText(dispGamut.space.id, x, y);
context.beginPath();
context.lineTo(x + fontSize / 2, y + fontSize * 0.4);
context.lineTo(x + padleft, y + fontSize * 0.4);
context.strokeStyle = curColor;
context.stroke();
}
context.fillStyle = "white";
context.fillText(
`Hue: ${H.toFixed(0)}º`,
width - pad,
height - pad - boxHeight - fontSize * 2
);
};
function LCtoXY(okLC) {
const x = (okLC[1] / 1) * width;
const y = (1 - okLC[0]) * height;
return [x, y];
}
function XYtoLC(xy) {
return UVtoLC([xy[0] / width, xy[1] / height]);
}
function UVtoLC(xy) {
const L = 1 - xy[1];
const C = xy[0] * 1;
return [L, C];
}
function drawLCTriangle(context, triangle, color = "white") {
context.beginPath();
triangle.forEach((oklch) => {
const [x, y] = LCtoXY(oklch);
context.lineTo(x, y);
});
context.closePath();
context.strokeStyle = color;
context.stroke();
}
function drawLCPoint(
context,
okLC,
radius = width * 0.01,
color = "white",
fill = true
) {
context.beginPath();
context.arc(...LCtoXY(okLC), radius, 0, Math.PI * 2);
if (fill) {
context.fillStyle = color;
context.fill();
} else {
context.strokeStyle = color;
context.stroke();
}
}
};
canvasSketch(sketch, settings);
// When gamut mapping with OKLCH approximation,
// the resulting points do not always lie exactly in gamut.
// The same may be true of OKHSL to RGB spaces.
// Let's figure out how far away they are:
// if a given point is under this threshold, gamut mapping
// will be redundant as it will just produce the same epsilon.
// This value is used in gamut.js as the RGB_CLIP_EPSILON
import {
clampedRGB,
convert,
degToRad,
findCuspOKLCH,
findGamutIntersectionOKLCH,
gamutMapOKLCH,
isRGBInGamut,
lerp,
MapToCuspL,
OKLCH,
sRGB,
sRGBGamut,
sRGBLinear,
} from "../src/index.js";
const gamut = sRGBGamut;
const target = gamut.space.base; // linear form
// slice the plane into a square
const slices = 100;
let avgDelta = 0;
let avgCount = 0;
let minDelta = Infinity;
let maxDelta = -Infinity;
// a very small number which still catches many gamut-mapped points
// but produces very little difference in practical and visual results
const RGB_CLIP_EPSILON = 0.0000001;
let totalPointsOutOfGamut = 0;
let totalPointsUnderEpsilon = 0;
let totalPointsUnderEpsilonBeforeMapping = 0;
// this particular hue is a little funky
// https://github.com/color-js/color.js/issues/81
// it produces out of gamut sRGB, however, the oklab gamut approximation seems to handle it fine
const hue = 264.1;
const lightness = 0.4;
for (let chroma = 0.22; chroma < 0.285; chroma += 0.001) {
const oklch = [lightness, chroma, hue];
const rgb = convert(oklch, OKLCH, sRGBLinear);
if (!isRGBInGamut(rgb, 0)) {
const mappedOKLCH = gamutMapWithoutClipOKLCH(oklch);
const mappedRGB = convert(mappedOKLCH, OKLCH, sRGBLinear);
const delta = clipDelta(mappedRGB);
if (!delta.every((n) => n == 0)) console.log("hue", hue, "delta", delta);
}
}
// test all hue planes Nº difference apart
// we will see some gamut mapped points still do not lie exactly in gamut
for (let H = 0; H < 360; H += 0.5) {
for (let y = 0; y < slices; y++) {
for (let x = 0; x < slices; x++) {
const u = x / (slices - 1);
const v = y / (slices - 1);
const L = 1 - v;
const C = u * 0.4;
// try conversion
let rgbl = convert([L, C, H], OKLCH, target);
// not exactly in space
if (!isRGBInGamut(rgbl, 0)) {
// check epsilons
if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
totalPointsUnderEpsilonBeforeMapping++;
}
const oklch = gamutMapWithoutClipOKLCH([L, C, H]);
rgbl = convert(oklch, OKLCH, target);
const [dr, dg, db] = clipDelta(rgbl);
const avg = (dr + dg + db) / 3;
const min = Math.min(dr, dg, db);
const max = Math.max(dr, dg, db);
avgDelta += avg;
avgCount++;
minDelta = Math.min(min, minDelta);
maxDelta = Math.max(max, maxDelta);
totalPointsOutOfGamut++;
if (isRGBInGamut(rgbl, RGB_CLIP_EPSILON)) {
totalPointsUnderEpsilon++;
}
}
}
}
}
function clipDelta(rgb) {
const clipped = clampedRGB(rgb);
const dr = Math.abs(rgb[0] - clipped[0]);
const dg = Math.abs(rgb[1] - clipped[1]);
const db = Math.abs(rgb[2] - clipped[2]);
return [dr, dg, db];
}
function gamutMapWithoutClipOKLCH(oklch) {
const [L, C, H] = oklch;
// we aren't in gamut, so let's map toward it
const hueAngle = degToRad(H);
const aNorm = Math.cos(hueAngle);
const bNorm = Math.sin(hueAngle);
const out = [L, C, H];
// choose our strategy
const cusp = findCuspOKLCH(aNorm, bNorm, gamut);
const LTarget = MapToCuspL(out, cusp);
let t = findGamutIntersectionOKLCH(aNorm, bNorm, L, C, LTarget, cusp, gamut);
out[0] = lerp(LTarget, L, t);
out[1] *= t;
return out;
}
avgDelta /= avgCount;
console.log("Min Epsilon:", minDelta);
console.log("Max Epsilon:", maxDelta);
console.log("Average Epsilon:", avgDelta);
console.log("Compare against epsilon:", RGB_CLIP_EPSILON);
console.log("Total points out of gamut:", totalPointsOutOfGamut);
console.log(
"Total points under epsilon (before map):",
totalPointsUnderEpsilonBeforeMapping
);
console.log("Total points under epsilon (after map):", totalPointsUnderEpsilon);
import * as ColorJSFn from "colorjs.io/fn";
import { listColorSpaces } from "../src/index.js";
// Colorjs.io uses some different naming conventions than @texel/color
const getColorJSID = (name) => {
return name
.replace("display-", "")
.replace(/^xyz$/, "xyz-d65")
.replace("a98-rgb", "a98rgb")
.replace("prophoto-rgb", "prophoto");
};
// returns a list of ColorJS.io space IDs that are supported by @texel/color
// okhsl/okhsv is skipped due to it not being included in this npm version of colorjs.io
export function getSupportedColorJSSpaces() {
const spaceFns = Object.values(ColorJSFn);
const spaces = listColorSpaces().filter((s) => !/ok(hsv|hsl)/i.test(s.id));
return spaces.map((space) => {
const cjsID = getColorJSID(space.id);
const colorJSSpace = spaceFns.find((f) => f.id === cjsID);
if (!colorJSSpace)
throw new Error(`expected ${cjsID} to exist in colorjs.io/fn`);
return {
space,
colorJSSpace,
};
});
}
// Register all spaces
const spaces = getSupportedColorJSSpaces().map((s) => s.colorJSSpace);
for (let space of spaces) {
ColorJSFn.ColorSpace.register(space);
}
import canvasSketch from "canvas-sketch";
import {
convert,
DisplayP3Gamut,
gamutMapOKLCH,
lerp,
lerpAngle,
OKLab,
OKLCH,
serialize,
sRGB,
sRGBGamut,
} from "../src/index.js";
const settings = {
dimensions: [2048, 512],
attributes: {
// comment this out if you want sRGB output
colorSpace: "display-p3",
},
};
const mix = (() => {
const tmpA = [0, 0, 0];
const tmpB = [0, 0, 0];
// you can decide whether you'd like to interpolate in
// OKLab, OKLCH or another space (you may need to adjust interpolation
// if you use a custom space)
const interpolationSpace = OKLCH;
// e.g. mix({ space, coords }, { space, coords }, 0.5, sRGB)
return (a, b, t, outputSpace = sRGB, out = [0, 0, 0]) => {
// bring both spaces into the shared interpolation space
convert(a.coords, a.space, interpolationSpace, tmpA);
convert(b.coords, b.space, interpolationSpace, tmpB);
// now do interpolation
out[0] = lerp(tmpA[0], tmpB[0], t);
out[1] = lerp(tmpA[1], tmpB[1], t);
if (interpolationSpace.id === "oklch") {
// for cylindrical spaces, use a circular interpolation for Hue parameter
// note if you decide to use a custom space like HSL as your interpolation space,
// you'll have to use the first parameter instead...
out[2] = lerpAngle(tmpA[2], tmpB[2], t);
} else {
// otherwise can use a regular linear interpolation
out[2] = lerp(tmpA[2], tmpB[2], t);
}
// make sure we convert from interpolation space to the target output space
convert(out, interpolationSpace, outputSpace, out);
return out;
};
})();
// utility to create a ramp between two 'colors' as { space, coords }
function ramp(a, b, steps = 4, outputSpace = sRGB) {
return Array(steps)
.fill()
.map((_, i, lst) => {
const t = lst.length <= 1 ? 0 : i / (lst.length - 1);
return mix(a, b, t, outputSpace);
});
}
const sketch = ({ context }) => {
const { colorSpace = "srgb" } = context.getContextAttributes();
const gamut = colorSpace === "srgb" ? sRGBGamut : DisplayP3Gamut;
return ({ context, width, height }) => {
context.fillStyle = "white";
context.fillRect(0, 0, width, height);
const A = {
space: sRGB,
coords: [0, 0, 1],
};
const B = {
space: OKLCH,
coords: [0.55, 0.4, 30],
};
const slices = 16;
const sliceWidth = width / slices;
// the output space is whatever the canvas expects (sRGB or DisplayP3)
const outputSpace = gamut.space;
// create a ramp of colors in OKLCH
// then gamut map them to the outputSpace
const colors = ramp(A, B, slices, OKLCH).map((oklch) =>
gamutMapOKLCH(oklch, gamut, outputSpace)
);
for (let i = 0; i < slices; i++) {
const color = colors[i];
// turn the color (now in outputSpace) into a context string
context.fillStyle = serialize(color, outputSpace);
context.fillRect(i * sliceWidth, 0, sliceWidth, height);
}
};
};
canvasSketch(sketch, settings);
import fs from "node:fs/promises";
import {
ChunkType,
ColorType,
colorTypeToChannels,
encode,
encode_iCCP,
} from "png-tools";
import { deflate } from "pako";
import {
convert,
DisplayP3Gamut,
floatToByte,
hexToRGB,
OKHSLToOKLab,
OKLab,
radToDeg,
} from "../src/index.js";
const gamut = DisplayP3Gamut;
// or regular sRGB...
// const gamut = sRGBGamut;
const isLogo = process.argv.includes("--logo");
const colorType = ColorType.RGB;
const channels = colorTypeToChannels(colorType);
const depth = 8;
const width = isLogo ? 512 : 1024;
const height = isLogo ? 512 : 512;
const uint8 = new Uint8ClampedArray(width * height * channels);
let rgb = [0, 0, 0];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const u = x / width;
const v = y / height;
if (isLogo) {
const u2 = u * 2 - 1;
const v2 = v * 2 - 1;
const hueAngle = Math.atan2(v2, u2);
let H = radToDeg(hueAngle);
const hueSteps = 45 / 2;
H = Math.round(H / hueSteps) * hueSteps;
const S = 1;
const L = 0.7;
const oklab = OKHSLToOKLab([H, S, L], gamut);
convert(oklab, OKLab, gamut.space, rgb);
} else {
const margin = 0.1 * Math.min(width, height);
if (
x >= margin &&
y >= margin &&
x < width - margin &&
y < height - margin
) {
const u2 = inverseLerp(margin, width - margin, x);
const v2 = inverseLerp(margin, height - margin, y);
const hueSteps = 8;
const H = (Math.floor(u2 * hueSteps) / hueSteps) * 360;
const satSteps = 4;
const S = 1 - Math.floor(v2 * satSteps) / satSteps;
const L = 0.75;
const oklab = OKHSLToOKLab([H, S, L], gamut);
convert(oklab, OKLab, gamut.space, rgb);
} else {
hexToRGB("#e9e3d5", rgb);
}
}
const idx = x + y * width;
const [r, g, b] = rgb.map((f) => floatToByte(f));
uint8[idx * channels + 0] = r;
uint8[idx * channels + 1] = g;
uint8[idx * channels + 2] = b;
}
}
// optional color profile chunk
let iCCP = null;
if (gamut == DisplayP3Gamut) {
const iccFile = await fs.readFile("test/profiles/DisplayP3.icc");
const name = "Display P3";
const data = deflate(iccFile);
iCCP = {
type: ChunkType.iCCP,
data: encode_iCCP({ name, data }),
};
}
const png = encode(
{
width,
height,
data: uint8,
colorType,
depth,
ancillary: [iCCP].filter(Boolean),
},
deflate
);
fs.writeFile(`test/${isLogo ? "logo" : "banner"}.png`, png);
function inverseLerp(min, max, t) {
if (Math.abs(min - max) < Number.EPSILON) return 0;
else return (t - min) / (max - min);
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

// HSL space (hue, saturation, lightness within sRGB gamut)
// Reference:
// https://github.com/color-js/color.js/blob/cfe55d358adb6c2e23c8a897282adf42904fd32d/src/spaces/hsl.js
import { sRGB, sRGBLinear } from "../../src/index.js";
export const HSL = {
id: "hsl",
// Note: @texel/color currently only supports 1-level depth for color spaces
// (for performance & memory reasons) - so our base must be one without another base space
base: sRGBLinear,
// Adapted from https://drafts.csswg.org/css-color-4/better-rgbToHsl.js
fromBase: (rgb, out = [0, 0, 0]) => {
// from sRGBLinear (this space's base) to sRGB (for HSL conversion)
sRGB.fromBase(rgb, out);
const r = out[0];
const g = out[1];
const b = out[2];
let max = Math.max(r, g, b);
let min = Math.min(r, g, b);
let h = 0,
s = 0,
l = (min + max) / 2;
let d = max - min;
if (d !== 0) {
s = l === 0 || l === 1 ? 0 : (max - l) / Math.min(l, 1 - l);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
}
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;
}
out[0] = h;
out[1] = s * 100;
out[2] = l * 100;
return out;
},
// Adapted from https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative
toBase: (hsl, out = [0, 0, 0]) => {
let h = hsl[0];
let s = hsl[1];
let l = hsl[2];
h = h % 360;
if (h < 0) {
h += 360;
}
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
out[0] = f(h, l, a, 0);
out[1] = f(h, l, a, 8);
out[2] = f(h, l, a, 4);
// from sRGB to sRGBLinear (this space's base)
sRGB.toBase(out, out);
return out;
},
};
function f(h, l, a, n) {
let k = (n + h / 30) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
}
// Lab aka CIELAB aka L*a*b* (uses a D50 WHITE_D50 point and has to be adapted)
// refer to CSS Color Module Level 4 Spec for more details
// Reference:
// https://github.com/color-js/color.js/blob/cfe55d358adb6c2e23c8a897282adf42904fd32d/src/spaces/lab.js
import { D50_to_D65_M, D65_to_D50_M } from "../../src/index.js";
// K * e = 2^3 = 8
const e = 216 / 24389; // 6^3/29^3 == (24/116)^3
const e3 = 24 / 116;
const K = 24389 / 27; // 29^3/3^3
const WHITE_D50 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585];
const fterm = (value) =>
value > e ? Math.cbrt(value) : (K * value + 16) / 116;
const inv = (value) =>
value > e3 ? Math.pow(value, 3) : (116 * value - 16) / K;
export const Lab = {
id: "lab",
adapt: {
// chromatic adaptation to and from D65
to: D50_to_D65_M,
from: D65_to_D50_M,
},
// Convert D50-adapted XYX to Lab
// CIE 15.3:2004 section 8.2.1.1
fromXYZ(xyz, out = [0, 0, 0]) {
// XYZ scaled relative to reference WHITE_D50, then modified
out[0] = fterm(xyz[0] / WHITE_D50[0]);
out[1] = fterm(xyz[1] / WHITE_D50[1]);
out[2] = fterm(xyz[2] / WHITE_D50[2]);
let L = 116 * out[1] - 16;
let a = 500 * (out[0] - out[1]);
let b = 200 * (out[1] - out[2]);
out[0] = L;
out[1] = a;
out[2] = b;
return out;
},
// Convert Lab to D50-adapted XYZ
// Same result as CIE 15.3:2004 Appendix D although the derivation is different
// http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
toXYZ(Lab, out = [0, 0, 0]) {
// compute f, starting with the luminance-related term
const L = Lab[0];
const a = Lab[1];
const b = Lab[2];
out[1] = (L + 16) / 116;
out[0] = a / 500 + out[1];
out[2] = out[1] - b / 200;
// compute xyz and scale by WHITE_D50
out[0] = inv(out[0]) * WHITE_D50[0];
out[1] = (L > 8 ? Math.pow((L + 16) / 116, 3) : L / K) * WHITE_D50[1];
out[2] = inv(out[2]) * WHITE_D50[2];
return out;
},
};
import test from "tape";
import Color from "colorjs.io";
import arrayAlmostEqual from "./almost-equal.js";
import { convert } from "../src/index.js";
import { getSupportedColorJSSpaces } from "./colorjs-fn.js";
test("should approximately match colorjs.io conversions", async (t) => {
// note: we skip okhsv/hsl as colorjs.io doesn't support in the current npm version
const spaces = getSupportedColorJSSpaces();
const vecs = [
[0.12341, 0.12001, 0.05212],
[1, 1, 1],
[1, 0, 0],
[0, 0, 0],
[-0.5, -0.5, -0.5],
// some other inputs
[0.95, 1, 1.089],
[0.45, 1.236, -0.019],
[0, 1, 0],
[0.922, -0.671, 0.263],
[0, 0, 1],
[0.153, -1.415, -0.449],
];
// just a further sanity check, uncomment to go wild
// for (let i = 0; i < 100; i++)
// vecs.push([
// Math.random() * 2 - 1,
// Math.random() * 2 - 1,
// Math.random() * 2 - 1,
// ]);
for (let vec of vecs) {
for (let i = 0; i < spaces.length; i++) {
for (let j = 0; j < spaces.length; j++) {
const A = spaces[i];
const B = spaces[j];
// @texel/color spaces
const a = A.space;
const b = B.space;
const suffix = `${a.id}-to-${b.id}`;
console.log(suffix);
const expected0 = convert(vec, a, b);
const tmp = vec.slice();
const expected1 = convert(vec, a, b, tmp);
const colorjsid_a = A.colorJSSpace.id;
const colorjsid_b = B.colorJSSpace.id;
t.deepEqual(expected0, tmp, `${suffix} copies into`);
t.deepEqual(expected0, expected1, `${suffix} copies into`);
t.equal(expected1, tmp, `${suffix} copies into and returns`);
// ColorJS returns NaN for display-p3 0 0 0 --> OKLCH
const outCoords = new Color(colorjsid_a, vec)
.to(colorjsid_b)
.coords.map((n) => n || 0);
// Colorjs does not appear to have as high precision as the latest
// CSS working draft spec which uses rational numbers
// so I have lowered tolerance for A98RGB, and consider it an upstream bug.
// please open a PR/issue if you feel otherwise!
const tolerance =
colorjsid_a.includes("a98") || colorjsid_b.includes("a98")
? 0.0000001
: undefined;
if (!arrayAlmostEqual(expected0, outCoords, tolerance)) {
console.error(
`\nError: %s - In (%s) Out (%s) Expected (%s)`,
suffix,
vec,
expected0,
outCoords
);
}
t.equal(
arrayAlmostEqual(expected0, outCoords, tolerance),
true,
suffix
);
}
}
}
});
import {
convert,
XYZD50,
sRGB,
XYZ,
ProPhotoRGB,
OKLCH,
sRGBLinear,
} from "../src/index.js";
import { Lab } from "./spaces/lab.js";
import { HSL } from "./spaces/hsl.js";
import test from "tape";
import Color from "colorjs.io";
import arrayAlmostEqual from "./almost-equal.js";
test("should approximately match colorjs.io CIELAB", async (t) => {
let input = [0.5, 0.1243, -0.123];
let inputSpace = OKLCH;
let outputSpace = Lab;
let expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
let result = convert(input, inputSpace, outputSpace);
t.deepEqual(expected, result);
inputSpace = XYZD50;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = XYZ;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = sRGB;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = ProPhotoRGB;
expected = new Color("prophoto", input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = Lab;
outputSpace = XYZ;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = XYZD50;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = Lab;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = sRGBLinear;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = sRGB;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
});
test("should approximately match colorjs.io HSL", async (t) => {
let input = [30, 50, 50];
let inputSpace = HSL;
let outputSpace = OKLCH;
let expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
let result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = XYZD50;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = sRGB;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
outputSpace = sRGBLinear;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
input = [0.5, 0.2, 30];
inputSpace = OKLCH;
outputSpace = HSL;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = XYZ;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = sRGB;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
inputSpace = sRGBLinear;
expected = new Color(inputSpace.id, input).to(outputSpace.id).coords;
result = convert(input, inputSpace, outputSpace);
t.deepEqual(arrayAlmostEqual(expected, result), true);
});
import test from "tape";
import Color from "colorjs.io";
import arrayAlmostEqual from "./almost-equal.js";
import {
floatToByte,
hexToRGB,
isRGBInGamut,
RGBToHex,
linear_sRGB_to_LMS_M,
LMS_to_linear_sRGB_M,
LMS_to_XYZ_M,
XYZ_to_linear_sRGB_M,
XYZ_to_LMS_M,
convert,
OKLab,
OKLCH,
sRGB,
sRGBLinear,
XYZ,
OKLab_from,
OKLab_to,
transform,
OKHSL,
sRGBGamut,
OKHSV,
serialize,
gamutMapOKLCH,
degToRad,
OKHSLToOKLab,
OKHSVToOKLab,
OKLabToOKHSL,
OKLabToOKHSV,
XYZD65ToD50,
XYZD50ToD65,
XYZD50,
ProPhotoRGB,
ProPhotoRGBLinear,
findCuspOKLCH,
LMS_to_OKLab_M,
DisplayP3,
A98RGB,
A98RGBLinear,
XYZ_to_linear_A98RGB_M,
DisplayP3Gamut,
deserialize,
parse,
MapToL,
} from "../src/index.js";
test("should convert XYZ in different whitepoints", async (t) => {
const oklab = [0.56, 0.03, -0.1];
const xyz_d65_input = new Color("oklab", oklab).to("xyz").coords;
const xyz_d50_output = new Color("xyz", xyz_d65_input).to("xyz-d50").coords;
let tmp = [0, 0, 0];
const out = XYZD65ToD50(xyz_d65_input, tmp);
t.equal(tmp, out);
t.deepEqual(out, xyz_d50_output);
tmp = [0, 0, 0];
const out2 = XYZD50ToD65(xyz_d50_output, tmp);
t.deepEqual(out2, xyz_d65_input);
t.equal(out2, tmp);
t.deepEqual(convert(xyz_d50_output, XYZD50, XYZ), xyz_d65_input);
t.deepEqual(convert(xyz_d65_input, XYZ, XYZD50), xyz_d50_output);
});
test("should convert", async (t) => {
const oklab = [0.56, 0.03, -0.1];
const rgbExpected = new Color("oklab", oklab)
.to("srgb")
.coords.map((n) => floatToByte(n));
const lrgb = OKLab_to(oklab, LMS_to_linear_sRGB_M);
const rgb = convert(lrgb, sRGBLinear, sRGB).map((f) => floatToByte(f));
t.deepEqual(rgb, rgbExpected, "oklab to srgb");
const inArray = oklab.slice();
const outArray = OKLab_to(oklab, LMS_to_linear_sRGB_M, inArray);
t.equal(inArray, outArray);
const oklab2 = OKLab_from(lrgb, linear_sRGB_to_LMS_M);
t.equals(arrayAlmostEqual(oklab, oklab2), true, "linear srgb to oklab");
const xyzExpected = new Color("oklab", oklab).to("xyz").coords;
const xyz0 = OKLab_to(oklab, LMS_to_XYZ_M);
t.deepEqual(arrayAlmostEqual(xyzExpected, xyz0), true, "OKLab to XYZ");
const oklabResult = OKLab_from(xyzExpected, XYZ_to_LMS_M);
t.deepEqual(arrayAlmostEqual(oklabResult, oklab), true, "XYZ to OKLab");
const xyzToLSRGBExpected = new Color("xyz", xyzExpected).to(
"srgb-linear"
).coords;
const xyzToLSRGB = transform(xyzExpected, XYZ_to_linear_sRGB_M);
t.deepEqual(
arrayAlmostEqual(xyzToLSRGB, xyzToLSRGBExpected),
true,
"XYZ to sRGB-linear"
);
// XYZ to sRGBLinear
const outTest = [0, 0, 0];
const outTest2 = convert(xyzExpected, XYZ, sRGBLinear, outTest);
t.equal(outTest, outTest2, "returns out vec3");
t.deepEqual(outTest, xyzToLSRGB);
t.deepEqual(
convert(xyzExpected, XYZ, sRGB),
sRGB.fromBase(xyzToLSRGB),
"XYZ to sRGB"
);
const sRGBL_from_XYZ = new Color("xyz", xyzExpected).to("srgb-linear").coords;
t.ok(
arrayAlmostEqual(convert(xyzExpected, XYZ, sRGBLinear), sRGBL_from_XYZ),
true,
"XYZ to sRGBLinear"
);
// from https://bottosson.github.io/posts/oklab/
const knownPairsXYZToOKLab = [
[
[0.95, 1, 1.089],
[1, 0, 0],
],
[
[1, 0, 0],
[0.45, 1.236, -0.019],
],
[
[0, 1, 0],
[0.922, -0.671, 0.263],
],
[
[0, 0, 1],
[0.153, -1.415, -0.449],
],
];
for (let [xyz, oklabExpected] of knownPairsXYZToOKLab) {
const oklabRet = convert(xyz, XYZ, OKLab).map((n) => {
n = roundToNDecimals(n, 3);
if (n === -0) n = 0;
return n;
});
t.deepEqual(oklabRet, oklabExpected);
}
const oklch = [0.5, -0.36, 90];
t.deepEqual(
convert(oklch, OKLCH, OKLab),
new Color("oklch", oklch).to("oklab").coords,
"handle negative chroma"
);
t.equal(
arrayAlmostEqual(
convert(oklch, OKLCH, sRGB),
new Color("oklch", oklch).to("srgb").coords
),
true
);
});
test("should convert to okhsl", async (t) => {
const okhsl = [30, 0.5, 0.5];
const oklab = OKHSLToOKLab(okhsl, sRGBGamut);
const expectedLABfromOKHSL = [
0.568838198942395, 0.08553885335853362, 0.049385880012721296,
];
t.deepEqual(oklab, expectedLABfromOKHSL);
const okhslOut = OKLabToOKHSL(expectedLABfromOKHSL, sRGBGamut);
t.deepEqual(okhslOut, okhsl);
const okhsv = okhsl.slice();
const expectedLABfromOKHSV = [
0.45178419415172344, 0.0658295198906634, 0.03800669102949832,
];
t.deepEqual(OKHSVToOKLab(okhsv, sRGBGamut), expectedLABfromOKHSV);
t.deepEqual(
arrayAlmostEqual(OKLabToOKHSV(expectedLABfromOKHSV, sRGBGamut), okhsv),
true
);
t.deepEqual(convert(okhsl, OKHSL, OKLab), expectedLABfromOKHSL);
t.deepEqual(convert(okhsv, OKHSV, OKLab), expectedLABfromOKHSV);
t.deepEqual(
arrayAlmostEqual(convert(expectedLABfromOKHSV, OKLab, OKHSV), okhsv),
true
);
t.deepEqual(
arrayAlmostEqual(convert(expectedLABfromOKHSL, OKLab, OKHSL), okhsl),
true
);
});
test("should find cusp", async (t) => {
const H = 30;
const hueAngle = degToRad(H);
const aNorm = Math.cos(hueAngle);
const bNorm = Math.sin(hueAngle);
const out2 = [0, 0];
const cusp = findCuspOKLCH(aNorm, bNorm, sRGBGamut, out2);
const hue30sRGBCusp = [0.6322837041534408, 0.2535829789121266];
t.equal(out2, cusp);
t.deepEqual(cusp, hue30sRGBCusp);
const cuspP3 = findCuspOKLCH(aNorm, bNorm, DisplayP3Gamut, out2);
const hue30P3Cusp = [0.6542359095783624, 0.2931937837912358];
t.equal(out2, cuspP3);
t.deepEqual(cuspP3, hue30P3Cusp);
const l2 = 0.7;
const c2 = 0.3;
const newLCH = [l2, c2, H];
const mapped = gamutMapOKLCH(newLCH, sRGBGamut, OKLCH);
t.deepEqual(mapped, [0.679529110489262, 0.2093088779230169, 30]);
});
test("should gamut map", async (t) => {
const oklch = [0.9, 0.4, 30];
const rgb = convert(oklch, OKLCH, sRGB);
t.equals(isRGBInGamut(rgb, 0), false);
});
test("should serialize", async (t) => {
t.deepEqual(serialize([0, 0.5, 1], sRGB), "rgb(0, 128, 255)");
t.deepEqual(serialize([0, 0.5, 1], sRGBLinear), "color(srgb-linear 0 0.5 1)");
t.deepEqual(serialize([1, 0, 0], OKLCH, sRGB), "rgb(255, 255, 255)");
t.deepEqual(serialize([1, 0, 0], OKLCH), "oklch(100% 0 0)");
t.deepEqual(serialize([1, 0, 0], OKLab), "oklab(100% 0 0)");
t.deepEqual(
serialize([1, 0, 0, 0.4523], OKLCH, sRGB),
"rgba(255, 255, 255, 0.4523)"
);
t.deepEqual(
serialize([1, 0, 0, 0.4523], OKLCH, OKLCH),
"oklch(100% 0 0 / 0.4523)"
);
t.deepEqual(serialize([1, 0, 0, 0.4523], OKLCH), "oklch(100% 0 0 / 0.4523)");
t.deepEqual(
serialize([1, 0, 0, 0.4523], DisplayP3),
"color(display-p3 1 0 0 / 0.4523)"
);
});
// not yet finished
// test("should parse to a color coord", async (t) => {
// t.deepEqual(parse("rgb(0, 128, 255)", sRGB), [0, 128 / 0xff, 255 / 0xff]);
// t.deepEqual(parse("rgba(0, 128, 255, .25)", sRGB), [
// 0,
// 128 / 0xff,
// 255 / 0xff,
// 0.25,
// ]);
// let outVec = [0, 0, 0];
// t.deepEqual(parse("rgba(0, 128, 255, 1)", sRGB), [0, 128 / 0xff, 255 / 0xff]);
// let out;
// out = parse("rgba(0, 128, 255, 1)", sRGB, outVec);
// t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff]);
// t.equal(out, outVec);
// // trims to 3
// outVec = [0, 0, 0, 0];
// out = parse("rgba(0, 128, 255, 1)", sRGB, outVec);
// t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff]);
// t.equal(out, outVec);
// // ensures 4
// outVec = [0, 0, 0, 0];
// out = parse("rgba(0, 128, 255, 0.91)", sRGB, outVec);
// t.deepEqual(out, [0, 128 / 0xff, 255 / 0xff, 0.91]);
// t.equal(out, outVec);
// t.deepEqual(
// serialize(parse("oklch(1 0 0)", sRGB), sRGB),
// "rgb(255, 255, 255)"
// );
// });
test("should deserialize color string information", async (t) => {
t.deepEqual(deserialize("rgb(0, 128, 255)"), {
coords: [0, 128 / 0xff, 255 / 0xff],
id: "srgb",
});
t.deepEqual(deserialize("rgba(0, 128, 255)"), {
coords: [0, 128 / 0xff, 255 / 0xff],
id: "srgb",
});
t.deepEqual(deserialize("rgba(0, 128, 255, 50%)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.5],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0, 128, 255, 0.5)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.5],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0 128 255)"), {
coords: [0, 128 / 0xff, 255 / 0xff],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0 128 255 / 0.5)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.5],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0 128 255 / 1e-2)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 1e-2],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0 128 255 / 50%)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.5],
id: "srgb",
});
t.deepEqual(deserialize("rgb(0 128 255 / 0.35)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.35],
id: "srgb",
});
t.deepEqual(deserialize("RGBA(0 128 255 / 0.35)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.35],
id: "srgb",
});
t.deepEqual(deserialize("rgba(0, 128, 255, 0.35)"), {
coords: [0, 128 / 0xff, 255 / 0xff, 0.35],
id: "srgb",
});
t.deepEqual(deserialize("#ff00cc"), {
id: "srgb",
coords: [1, 0, 0.8],
});
t.deepEqual(deserialize("#ff00cccc"), {
id: "srgb",
coords: [1, 0, 0.8, 0.8],
});
t.deepEqual(deserialize("COLOR(sRGB-Linear 0 0.5 1)"), {
id: "srgb-linear",
coords: [0, 0.5, 1],
});
t.deepEqual(deserialize("COLOR(sRGB-Linear 0 50% 1)"), {
id: "srgb-linear",
coords: [0, 0.5, 1],
});
t.deepEqual(deserialize("color(srgb-linear 0 0.5 1)"), {
id: "srgb-linear",
coords: [0, 0.5, 1],
});
t.deepEqual(deserialize("color(srgb-linear 0 1e-2 1)"), {
id: "srgb-linear",
coords: [0, 1e-2, 1],
});
t.deepEqual(deserialize("color(srgb-linear 0 0.5 1/0.25)"), {
id: "srgb-linear",
coords: [0, 0.5, 1, 0.25],
});
t.deepEqual(deserialize("color(srgb-linear 0 0.5 1 / 0.25)"), {
id: "srgb-linear",
coords: [0, 0.5, 1, 0.25],
});
t.deepEqual(deserialize("oklch(1 0 0)"), {
id: "oklch",
coords: [1, 0, 0],
});
t.deepEqual(deserialize("oklch(1 0 0/0.25)"), {
id: "oklch",
coords: [1, 0, 0, 0.25],
});
});
test("utils", async (t) => {
t.deepEqual(RGBToHex([0, 0.5, 1]), "#0080ff");
t.deepEqual(hexToRGB("#0080ff"), [0, 0.5019607843137255, 1]);
const tmp = [0, 0, 0];
hexToRGB("#0080ff", tmp);
t.deepEqual(tmp, [0, 0.5019607843137255, 1]);
});
test("should convert D65 based to D50 based color spaces", async (t) => {
const rgbin = [0.25, 0.5, 1];
const xyzD65Input = new Color("srgb", rgbin).to("xyz-d65").coords;
const xyzD50Input = XYZD65ToD50(xyzD65Input);
const prophotoFromXYZD65_expected = new Color("xyz-d65", xyzD65Input).to(
"prophoto-linear"
).coords;
const ret = convert(xyzD65Input, XYZ, ProPhotoRGBLinear);
t.deepEqual(ret, prophotoFromXYZD65_expected);
const xyzD65 = convert(prophotoFromXYZD65_expected, ProPhotoRGBLinear, XYZ);
t.deepEqual(arrayAlmostEqual(xyzD65, xyzD65Input), true);
const prophoto2 = convert(rgbin, sRGB, ProPhotoRGBLinear);
const prophotoExpected = new Color("srgb", rgbin).to(
"prophoto-linear"
).coords;
t.deepEqual(arrayAlmostEqual(prophoto2, prophotoExpected), true);
const oklabIn = new Color("srgb", rgbin).to("oklab").coords;
const oklabToProphoto = new Color("oklab", oklabIn).to(
"prophoto-linear"
).coords;
t.deepEqual(
arrayAlmostEqual(
convert(oklabIn, OKLab, ProPhotoRGBLinear),
oklabToProphoto
),
true
);
const oklabIn2 = [1, 1, 1];
const oklabToProphoto2 = new Color("oklab", oklabIn2).to("prophoto").coords;
t.deepEqual(
arrayAlmostEqual(convert(oklabIn2, OKLab, ProPhotoRGB), oklabToProphoto2),
true
);
});
test("should handle problematic coords", async (t) => {
const in0 = [0.95, 1, 1.089];
const out0 = convert(in0, XYZ, OKLab);
const expected0lab = new Color("xyz", in0).to("oklab").coords;
t.deepEqual(arrayAlmostEqual(out0, expected0lab), true);
const inP3 = [0, 0, 1];
const outXYZ = convert(inP3, DisplayP3, XYZ);
t.deepEqual(
arrayAlmostEqual(outXYZ, new Color("p3", inP3).to("xyz").coords),
true
);
const outA98 = convert(outXYZ, XYZ, A98RGBLinear);
t.deepEqual(
arrayAlmostEqual(
outA98,
new Color("xyz", outXYZ).to("a98rgb-linear").coords
),
true
);
// Failing test here, but it appears Colorjs does not match the latest
// CSS spec (working draft with rational form). Please open a PR/issue if you
// think you could help, but unless I'm mistaken it seems to be an upstream issue
const tolerance = 0.0000001;
t.deepEqual(
arrayAlmostEqual(
convert(inP3, DisplayP3, A98RGB),
new Color("p3", inP3).to("a98rgb").coords,
tolerance
),
true
);
t.deepEqual(
convert([1, 0, 0], OKLCH, OKLab, undefined),
[1, 0, 0],
"handles [1,0,0] OKLCH to OKLab gamut map"
);
t.deepEqual(
arrayAlmostEqual(convert([1, 0, 0], OKLCH, sRGBLinear), [1, 1, 1]),
true,
"handles [1,1,1] OKLCH to sRGBLinear"
);
t.deepEqual(
gamutMapOKLCH([1, 0, 0], sRGBGamut, OKLCH, undefined, MapToL),
[1, 0, 0],
"handles [1,0,0] OKLCH to sRGB gamut map"
);
t.deepEqual(
gamutMapOKLCH([0, 0, 0], sRGBGamut, OKLCH, undefined, MapToL),
[0, 0, 0],
"handles [0,0,0] OKLCH to sRGB gamut map"
);
});
function roundToNDecimals(value, digits) {
var tenToN = 10 ** digits;
return Math.round(value * tenToN) / tenToN;
}

Sorry, the diff of this file is not supported yet

"""
Calculate `oklab` matrices.
Björn Ottosson, in his original calculations, used a different white point than
what CSS and most other people use. In the CSS repository, he commented on
how to calculate the M1 matrix using the exact same white point as CSS. He
provided the initial matrix used in this calculation, which we will call M0.
https://github.com/w3c/csswg-drafts/issues/6642#issuecomment-945714988.
This M0 matrix is used to create a precise matrix to convert XYZ to LMS using
the D65 white point as specified by CSS. Both ColorAide and CSS use the D65
chromaticity coordinates of `(0.31270, 0.32900)` which is documented and used
for sRGB as the standard. There are likely implementations unaware that the
they should, or even how to adapt the Oklab M1 matrix to their white point
as this is not documented in the author's Oklab blog post, but is buried in a
CSS repository discussion.
Additionally, the documented M2 matrix is specified as 32 bit values, and the
inverse is calculated directly from the this 32 bit matrix. The forward and
reverse transform is calculated to perfectly convert 32 bit values, but when
translating 64 bit values, the transform adds a lot of noise after about 7 - 8
digits (the precision of 32 bit floats). This is particularly problematic for
achromatic colors in Oklab and OkLCh and can cause chroma not to resolve to zero.
To provide an M2 matrix that works better for 64 bit, we take the inverse M2,
which provides a perfect transforms to white from Oklab `[1, 0, 0]` in 32 bit
floating point. We process the matrix as float 32 bit values and emit them as 64
bit double values, ~17 digit double accuracy. We then calculate the forward
matrix. This gives us a transform in 64 bit that drives chroma extremely close
to zero for 64 bit doubles and maintains the same 32 bit precision of up to about
7 digits, the 32 bit accuracy limit (~7.22).
To demonstrate that our 64 bit converted matrices work as we claim and does not
alter the intent of the values, we can observe by comparing the documented matrices
(adjusting for our white point).
Below we demonstrate by first using the documented 32 bit M2 matrix (adjusting the
M1 for our white point). This is what most implementations do, though some may not
properly correct the M1 matrix for their white point. Notice how the lightness for
white is only accurate up to about 7 digits making the expected value of 1 not very
accurate. Also notice that a and b do not resolve as close to 0. The a value is
pretty good, but the b value is substantially worse. Also notice the first 7 digits
(the 32 bit precision) for red, green, and blue as they will be used for comparison.
```
>>> from coloraide.everything import ColorAll as Color
>>> import numpy as np
>>> Color('white').convert('oklab')[:]
[0.9999999935000001, -1.6653345369377348e-16, 3.729999997759137e-08, 1.0]
>>> [np.float32(c) for c in Color('red').convert('oklab', norm=False)[:]]
[0.6279554, 0.22486307, 0.1258463, 1.0]
>>> [np.float32(c) for c in Color('green').convert('oklab', norm=False)[:]]
[0.51975185, -0.14030233, 0.107675895, 1.0]
>>> [np.float32(c) for c in Color('blue').convert('oklab', norm=False)[:]]
[0.4520137, -0.03245697, -0.31152815, 1.0]
```
When we use our 64 bit adjusted M2 matrix, we now get a precise 1 for lightness
when converting white and get zero or nearly zero for a and b. When comparing the
first 7 digits to the previous example we get the same values. Anything after
~7 digits is not guaranteed to be the same.
```
>>> from coloraide.everything import ColorAll as Color
>>> import numpy as np
>>> Color('white').convert('oklab')[:]
[1.0, -5.551115123125783e-17, 0.0, 1.0]
>>> [np.float32(c) for c in Color('red').convert('oklab', norm=False)[:]]
[0.6279554, 0.22486307, 0.12584628, 1.0]
>>> [np.float32(c) for c in Color('green').convert('oklab', norm=False)[:]]
[0.51975185, -0.14030233, 0.10767588, 1.0]
>>> [np.float32(c) for c in Color('blue').convert('oklab', norm=False)[:]]
[0.45201373, -0.032456975, -0.31152818, 1.0]
```
Okhsl is completely calculated using 32 bit floats as that is how the author
provided the algorithm, but we can see that when we calculate the new coefficients,
using our M1 and 64 bit adjusted M2 matrices, that we preserve the 32 precision.
Anything after ~7 digits is just noise due to the differences in 32 bit and 64 bit.
Comparing to the actual values returned using the author's code in his Okhsl and Okhsv
color pickers:
```
// Okhsl
> var value = srgb_to_okhsl(255, 255, 255); value[0] *= 360; value
[89.87556309590242, 0.5582831888483675, 0.9999999923961898]
> var value = srgb_to_okhsl(255, 0, 0); value[0] *= 360; value
[29.23388519234263, 1.0000000001433997, 0.5680846525040862]
> var value = srgb_to_okhsl(0, 255, 0); value[0] *= 360; value
[142.49533888780996, 0.9999999700728788, 0.8445289645307816]
> var value = srgb_to_okhsl(0, 0, 255); value[0] *= 360; value
[264.052020638055, 0.9999999948631134, 0.3665653394260194]
// Okhsv
> var value = srgb_to_okhsv(255, 255, 255); value[0] *= 360; value
[89.87556309590242, 1.0347523928230576e-7, 1.000000027003774]
> var value = srgb_to_okhsv(255, 0, 0); value[0] *= 360; value
[29.23388519234263, 0.9995219692256989, 1.0000000001685625]
> var value = srgb_to_okhsv(0, 255, 0); value[0] *= 360; value
[142.49533888780996, 0.9999997210415695, 0.9999999884428648]
> var value = srgb_to_okhsv(0, 0, 255); value[0] *= 360; value
[264.052020638055, 0.9999910912349018, 0.9999999646150918]
```
And then ours. Ignoring the authors hue and our hue results for white
and the oddly high chroma for the author's achromatic white in Okhsl
(both of which are meaningless in an achromatic color), we can see that
that we match quite well up to ~7 digits.
```
# Okhsl
>>> Color('white').convert('okhsl', norm=False)[:]
[180.0, 0.0, 1.0, 1.0]
>>> Color('#ff0000').convert('okhsl', norm=False)[:]
[29.233880279627876, 1.0000001765854427, 0.5680846563197033, 1.0]
>>> Color('#00ff00').convert('okhsl', norm=False)[:]
[142.4953450414438, 1.0000000000000009, 0.8445289714936317, 1.0]
>>> [264.05202261637004, 1.0000000005848086, 0.36656533918708145, 1.0]
[264.05202261637004, 1.0000000005848086, 0.36656533918708145, 1.0]
# Okhsv
>>> Color('white').convert('okhsv', norm=False)[:]
[180.0, 0.0, 1.0, 1.0]
>>> Color('#ff0000').convert('okhsv', norm=False)[:]
[29.233880279627876, 1.0000004019360378, 0.9999999999999994, 1.0]
>>> Color('#00ff00').convert('okhsv', norm=False)[:]
[142.4953450414438, 0.9999998662471965, 1.0000000000000004, 1.0]
>>> Color('#0000ff').convert('okhsv', norm=False)[:]
[264.05202261637004, 1.000000002300706, 0.9999999999999999, 1.0]
"""
import sys
import os
import struct
sys.path.insert(0, os.getcwd())
# import tools.calc_xyz_transform as xyzt # noqa: E402
from coloraide import util # noqa: E402
from coloraide import algebra as alg # noqa: E402
"""Calculate XYZ conversion matrices."""
import sys
import os
sys.path.insert(0, os.getcwd())
xyzt_white_d65 = util.xy_to_xyz((0.31270, 0.32900))
xyzt_white_d50 = util.xy_to_xyz((0.34570, 0.35850))
xyzt_white_aces = util.xy_to_xyz((0.32168, 0.33767))
def xyzt_get_matrix(wp, space):
"""Get the matrices for the specified space."""
if space == 'srgb':
x = [0.64, 0.30, 0.15]
y = [0.33, 0.60, 0.06]
elif space == 'display-p3':
x = [0.68, 0.265, 0.150]
y = [0.32, 0.69, 0.060]
elif space == 'rec2020':
x = [0.708, 0.17, 0.131]
y = [0.292, 0.797, 0.046]
elif space == 'a98-rgb':
x = [0.64, 0.21, 0.15]
y = [0.33, 0.71, 0.06]
elif space == 'prophoto-rgb':
x = [0.7347, 0.1596, 0.0366]
y = [0.2653, 0.8404, 0.0001]
elif space == 'aces-ap0':
x = [0.7347, 0.0, 0.0001]
y = [0.2653, 1.0, -0.0770]
elif space == 'aces-ap1':
x = [0.713, 0.165, 0.128]
y = [0.293, 0.830, 0.044]
else:
raise ValueError
m = alg.transpose([util.xy_to_xyz(xy) for xy in zip(x, y)])
rgb = alg.solve(m, wp)
rgb2xyz = alg.multiply(m, rgb)
xyz2rgb = alg.inv(rgb2xyz)
return rgb2xyz, xyz2rgb
float32 = alg.vectorize(lambda value: struct.unpack('f', struct.pack('f', value))[0])
# Calculated using our own `calc_xyz_transform.py`
RGB_TO_XYZ, XYZ_TO_RGB = xyzt_get_matrix(xyzt_white_d65, 'srgb')
# Matrix provided by the author of Oklab to allow for calculating a precise M1 matrix
# using any white point.
M0 = [
[0.77849780, 0.34399940, -0.12249720],
[0.03303601, 0.93076195, 0.03620204],
[0.05092917, 0.27933344, 0.66973739]
]
# Calculate XYZ to LMS and LMS to XYZ using our white point.
XYZ_TO_LMS = alg.divide(M0, alg.outer(alg.matmul(M0, xyzt_white_d65), alg.ones(3)))
XYZD50_TO_LMS = alg.divide(M0, alg.outer(alg.matmul(M0, xyzt_white_d50), alg.ones(3)))
# Calculate the inverse
LMS_TO_XYZ = alg.inv(XYZ_TO_LMS)
LMS_TO_XYZD50 = alg.inv(XYZD50_TO_LMS)
# Calculate linear sRGB to LMS (used for Okhsl and Okhsv)
SRGBL_TO_LMS = alg.matmul(XYZ_TO_LMS, RGB_TO_XYZ)
LMS_TO_SRGBL = alg.inv(SRGBL_TO_LMS)
# Oklab specifies the following matrix as M1 along with the inverse.
# ```
# LMS3_TO_OKLAB = [
# [0.2104542553, 0.7936177850, -0.0040720468],
# [1.9779984951, -2.4285922050, 0.4505937099],
# [0.0259040371, 0.7827717662, -0.8086757660]
# ]
# ```
# But since the matrix is provided in 32 bit, we are not able to get the
# proper inverse for `[1, 0, 0]` in 64 bit, even if we calculate the a
# new 64 bit inverse for the above forward transform. What we need is a
# proper 64 bit forward and reverse transform.
#
# In order to adjust for this, we take documented 32 bit inverse matrix which
# gives us a perfect translation from Oklab `[1, 0, 0]` to LMS of `[1, 1, 1]`
# and parse the matrix as float 32 and emit it as 64 bit and then take the inverse.
OKLAB_TO_LMS3 = float32([
[1.0, 0.3963377774, 0.2158037573],
[1.0, -0.1055613458, -0.0638541728],
[1.0, -0.0894841775, -1.2914855480]
])
# Calculate the inverse
LMS3_TO_OKLAB = alg.inv(OKLAB_TO_LMS3)
# -*- coding: utf-8 -*-
"""
Copyright (c) 2021 Björn Ottosson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
**Overview**
This script is pulled from Coloraide, and modified slightly by @mattdesl for JS output.
https://github.com/facelessuser/coloraide
The gamut approximation code was originally developed by Björn Ottosson.
Original file is located at
https://colab.research.google.com/drive/1JdXHhEyjjEE--19ZPH1bZV_LiGQBndzs
This notebook was used to compute coefficients for the compute_max_saturation function in this blog post:
http://bottosson.github.io/posts/gamutclipping/
The code is available for reference, since it could be useful to derive coefficients for other color spaces. It was
written quickly to derive the values and both structure and documentation is poor.
"""
# Commented out `IPython` magic to ensure Python compatibility.
import numpy as np
import scipy.optimize
import matplotlib.pyplot as plt
import sys
import os
import json
from coloraide import algebra as alg
sys.path.insert(0, os.getcwd())
# Use higher precision Oklab conversion matrix along with LMS matrix with our exact white point
from tools.calc_oklab_matrices import xyzt_white_d65, xyzt_white_d50, xyzt_get_matrix, SRGBL_TO_LMS, LMS_TO_SRGBL, LMS3_TO_OKLAB, OKLAB_TO_LMS3, LMS_TO_XYZD50, XYZD50_TO_LMS # noqa: E402
PRINT_DIAGS = False
# Recalculated for consistent reference white
# see https://github.com/w3c/csswg-drafts/issues/6642#issuecomment-943521484
XYZ_TO_LMS = [
[ 0.8190224379967030, 0.3619062600528904, -0.1288737815209879 ],
[ 0.0329836539323885, 0.9292868615863434, 0.0361446663506424 ],
[ 0.0481771893596242, 0.2642395317527308, 0.6335478284694309 ],
]
# inverse of XYZtoLMS_M
LMS_TO_XYZ = [
[ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647 ],
[ -0.0405757452148008, 1.1122868032803170, -0.0717110580655164 ],
[ -0.0763729366746601, -0.4214933324022432, 1.5869240198367816 ],
]
LMS3_TO_OKLAB = [
[ 0.2104542683093140, 0.7936177747023054, -0.0040720430116193 ],
[ 1.9779985324311684, -2.4285922420485799, 0.4505937096174110 ],
[ 0.0259040424655478, 0.7827717124575296, -0.8086757549230774 ],
]
# LMStoIab_M inverted
OKLAB_TO_LMS3 = [
[ 1.0000000000000000, 0.3963377773761749, 0.2158037573099136 ],
[ 1.0000000000000000, -0.1055613458156586, -0.0638541728258133 ],
[ 1.0000000000000000, -0.0894841775298119, -1.2914855480194092 ],
]
def print_matrix (a, b, arr):
data = json.dumps(arr.tolist(), indent=2, separators=(',', ': '))
suffix = '_M'
print(f'export const {a}_to_{b}{suffix} = {data};\n')
def print_rational (a, b, rstr):
suffix = '_M'
print(f'export const {a}_to_{b}{suffix} = {eval(rstr)};\n')
def print_json (label, data):
str = json.dumps(data, indent=2, separators=(',', ': '))
print(f'export const {label} = {str};\n')
def do_calc(GAMUT = 'srgb'):
global SRGBL_TO_LMS, LMS_TO_SRGBL, LMS3_TO_OKLAB, OKLAB_TO_LMS3, XYZD50_TO_LMS, LMS_TO_XYZD50, XYZD50_TO_LMS
np.set_printoptions(precision=8)
var_name = 'linear_sRGB'
if GAMUT == 'display-p3':
var_name = 'linear_DisplayP3'
elif GAMUT == 'rec2020':
var_name = 'linear_Rec2020'
elif GAMUT == 'a98-rgb':
var_name = 'linear_A98RGB'
elif GAMUT == 'prophoto-rgb':
var_name = 'linear_ProPhotoRGB'
white = xyzt_white_d50 if GAMUT == 'prophoto-rgb' else xyzt_white_d65
whitepoint = 'D50' if GAMUT == 'prophoto-rgb' else 'D65'
RGBL_TO_XYZ, XYZ_TO_RGBL = xyzt_get_matrix(white, GAMUT)
"""
Hard coding the matrices using rational numbers to match CSS working draft spec.
https://github.com/w3c/csswg-drafts/pull/7320
https://drafts.csswg.org/css-color-4/#color-conversion-code
"""
RGBL_TO_XYZ_RATIONAL = ""
XYZ_TO_RGBL_RATIONAL = ""
if GAMUT == 'srgb':
RGBL_TO_XYZ_RATIONAL = """[
[ 506752 / 1228815, 87881 / 245763, 12673 / 70218 ],
[ 87098 / 409605, 175762 / 245763, 12673 / 175545 ],
[ 7918 / 409605, 87881 / 737289, 1001167 / 1053270 ],
]"""
XYZ_TO_RGBL_RATIONAL = """[
[ 12831 / 3959, -329 / 214, -1974 / 3959 ],
[ -851781 / 878810, 1648619 / 878810, 36519 / 878810 ],
[ 705 / 12673, -2585 / 12673, 705 / 667 ],
]"""
elif GAMUT == 'rec2020':
RGBL_TO_XYZ_RATIONAL = """[
[ 63426534 / 99577255, 20160776 / 139408157, 47086771 / 278816314 ],
[ 26158966 / 99577255, 472592308 / 697040785, 8267143 / 139408157 ],
[ 0 / 1, 19567812 / 697040785, 295819943 / 278816314 ],
]"""
XYZ_TO_RGBL_RATIONAL = """[
[ 30757411 / 17917100, -6372589 / 17917100, -4539589 / 17917100 ],
[ -19765991 / 29648200, 47925759 / 29648200, 467509 / 29648200 ],
[ 792561 / 44930125, -1921689 / 44930125, 42328811 / 44930125 ],
]"""
elif GAMUT == 'a98-rgb':
# convert an array of linear-light a98-rgb values to CIE XYZ
# http://www.brucelindbloom.com/index.html?Eqn_RGB_XYZ_Matrix.html
# has greater numerical precision than section 4.3.5.3 of
# https://www.adobe.com/digitalimag/pdfs/AdobeRGB1998.pdf
# but the values below were calculated from first principles
# from the chromaticity coordinates of R G B W
RGBL_TO_XYZ_RATIONAL = """[
[ 573536 / 994567, 263643 / 1420810, 187206 / 994567 ],
[ 591459 / 1989134, 6239551 / 9945670, 374412 / 4972835 ],
[ 53769 / 1989134, 351524 / 4972835, 4929758 / 4972835 ],
]"""
XYZ_TO_RGBL_RATIONAL = """[
[ 1829569 / 896150, -506331 / 896150, -308931 / 896150 ],
[ -851781 / 878810, 1648619 / 878810, 36519 / 878810 ],
[ 16779 / 1248040, -147721 / 1248040, 1266979 / 1248040 ],
]"""
elif GAMUT == 'display-p3':
# TODO: Evaluate whether this is really superior to what coloraide suggests
# Right now it is needed to ensure accuracy with Colorjs.io
# However, it is not clear how they have computed the results
RGBL_TO_XYZ_RATIONAL = """[
[ 608311 / 1250200, 189793 / 714400, 198249 / 1000160 ],
[ 35783 / 156275, 247089 / 357200, 198249 / 2500400 ],
[ 0 / 1, 32229 / 714400, 5220557 / 5000800 ],
]"""
XYZ_TO_RGBL_RATIONAL = """[
[ 446124 / 178915, -333277 / 357830, -72051 / 178915 ],
[ -14852 / 17905, 63121 / 35810, 423 / 17905 ],
[ 11844 / 330415, -50337 / 660830, 316169 / 330415 ],
]"""
elif GAMUT == 'prophoto-rgb':
# override from https://github.com/w3c/csswg-drafts/issues/7675
# rational form exceeds JavaScript precision:
# https://github.com/w3c/csswg-drafts/pull/7320
RGBL_TO_XYZ = [
[ 0.79776664490064230, 0.13518129740053308, 0.03134773412839220 ],
[ 0.28807482881940130, 0.71183523424187300, 0.00008993693872564 ],
[ 0.00000000000000000, 0.00000000000000000, 0.82510460251046020 ]
]
XYZ_TO_RGBL = [
[ 1.34578688164715830, -0.25557208737979464, -0.05110186497554526 ],
[ -0.54463070512490190, 1.50824774284514680, 0.02052744743642139 ],
[ 0.00000000000000000, 0.00000000000000000, 1.21196754563894520 ]
]
if len(XYZ_TO_RGBL_RATIONAL) > 0:
XYZ_TO_RGBL = eval(XYZ_TO_RGBL_RATIONAL)
if len(RGBL_TO_XYZ_RATIONAL) > 0:
RGBL_TO_XYZ = eval(RGBL_TO_XYZ_RATIONAL)
# Calculate the gamut <-> LMS matrices to adjust the working gamut
if GAMUT == 'srgb':
RGBL_TO_LMS = SRGBL_TO_LMS
LMS_TO_RGBL = LMS_TO_SRGBL
elif GAMUT == 'prophoto-rgb':
# Note: this is not currently used in the final results as ProPhoto gamut is not yet supported
RGBL_TO_LMS = alg.matmul(XYZD50_TO_LMS, RGBL_TO_XYZ)
LMS_TO_RGBL = alg.inv(RGBL_TO_LMS)
else:
RGBL_TO_LMS = alg.matmul(XYZ_TO_LMS, RGBL_TO_XYZ)
LMS_TO_RGBL = alg.inv(RGBL_TO_LMS)
def printarray (label, arr):
print(label, '[ ' + ', '.join([str(n) for n in arr]) + ' ]')
# print('RGBL_TO_LMS',RGBL_TO_LMS)
# print('LMS_TO_RGBL', LMS_TO_RGBL)
RGBL_TO_LMS = np.asfarray(RGBL_TO_LMS)
LMS_TO_RGBL = np.asfarray(LMS_TO_RGBL)
LMS3_TO_OKLAB = np.asfarray(LMS3_TO_OKLAB)
OKLAB_TO_LMS3 = np.asfarray(OKLAB_TO_LMS3)
def linear_srgb_to_oklab(c):
l = RGBL_TO_LMS[0][0] * c[0, ...] + RGBL_TO_LMS[0][1] * c[1, ...] + RGBL_TO_LMS[0][2] * c[2, ...]
m = RGBL_TO_LMS[1][0] * c[0, ...] + RGBL_TO_LMS[1][1] * c[1, ...] + RGBL_TO_LMS[1][2] * c[2, ...]
s = RGBL_TO_LMS[2][0] * c[0, ...] + RGBL_TO_LMS[2][1] * c[1, ...] + RGBL_TO_LMS[2][2] * c[2, ...]
l_ = np.cbrt(l)
m_ = np.cbrt(m)
s_ = np.cbrt(s)
return np.array([
LMS3_TO_OKLAB[0][0] * l_ + LMS3_TO_OKLAB[0][1] * m_ + LMS3_TO_OKLAB[0][2] * s_,
LMS3_TO_OKLAB[1][0] * l_ + LMS3_TO_OKLAB[1][1] * m_ + LMS3_TO_OKLAB[1][2] * s_,
LMS3_TO_OKLAB[2][0] * l_ + LMS3_TO_OKLAB[2][1] * m_ + LMS3_TO_OKLAB[2][2] * s_,
])
# define functions for R, G and B as functions of S, h (with L = 1 and S = C/L)
def to_lms(S, h):
a = S * np.cos(h)
b = S * np.sin(h)
l_ = OKLAB_TO_LMS3[0][0] + OKLAB_TO_LMS3[0][1] * a + OKLAB_TO_LMS3[0][2] * b
m_ = OKLAB_TO_LMS3[1][0] + OKLAB_TO_LMS3[1][1] * a + OKLAB_TO_LMS3[1][2] * b
s_ = OKLAB_TO_LMS3[2][0] + OKLAB_TO_LMS3[2][1] * a + OKLAB_TO_LMS3[2][2] * b
l = l_ * l_ * l_
m = m_ * m_ * m_
s = s_ * s_ * s_
return (l, m, s)
def to_lms_dS(S, h):
a = S * np.cos(h)
b = S * np.sin(h)
l_ = OKLAB_TO_LMS3[0][0] + OKLAB_TO_LMS3[0][1] * a + OKLAB_TO_LMS3[0][2] * b
m_ = OKLAB_TO_LMS3[1][0] + OKLAB_TO_LMS3[1][1] * a + OKLAB_TO_LMS3[1][2] * b
s_ = OKLAB_TO_LMS3[2][0] + OKLAB_TO_LMS3[2][1] * a + OKLAB_TO_LMS3[2][2] * b
l = (LMS3_TO_OKLAB[0][1] * np.cos(h) + LMS3_TO_OKLAB[0][1] * np.sin(h)) * 3 * l_* l_
m = (LMS3_TO_OKLAB[1][1] * np.cos(h) + LMS3_TO_OKLAB[1][1] * np.sin(h)) * 3 * m_* m_
s = (LMS3_TO_OKLAB[2][1] * np.cos(h) + LMS3_TO_OKLAB[2][1] * np.sin(h)) * 3 * s_* s_
return (l, m, s)
def to_lms_dS2(S, h):
a = S * np.cos(h)
b = S * np.sin(h)
l_ = OKLAB_TO_LMS3[0][0] + OKLAB_TO_LMS3[0][1] * a + OKLAB_TO_LMS3[0][2] * b
m_ = OKLAB_TO_LMS3[1][0] + OKLAB_TO_LMS3[1][1] * a + OKLAB_TO_LMS3[1][2] * b
s_ = OKLAB_TO_LMS3[2][0] + OKLAB_TO_LMS3[2][1] * a + OKLAB_TO_LMS3[2][2] * b
l = (LMS3_TO_OKLAB[0][1] * np.cos(h) + LMS3_TO_OKLAB[0][2] * np.sin(h)) ** 2 * 6 * l_
m = (LMS3_TO_OKLAB[1][1] * np.cos(h) + LMS3_TO_OKLAB[0][2] * np.sin(h)) ** 2 * 6 * m_
s = (LMS3_TO_OKLAB[2][1] * np.cos(h) + LMS3_TO_OKLAB[0][2] * np.sin(h)) ** 2 * 6 * s_
return (l, m, s)
def to_R(S, h):
(l, m, s) = to_lms(S, h)
return LMS_TO_RGBL[0][0] * l + LMS_TO_RGBL[0][1] * m + LMS_TO_RGBL[0][2] * s
def to_R_dS(S, h):
(l, m, s) = to_lms_dS(S, h)
return LMS_TO_RGBL[0][0] * l + LMS_TO_RGBL[0][1] * m + LMS_TO_RGBL[0][2] * s
def to_R_dS2(S, h):
(l, m, s) = to_lms_dS2(S, h)
return LMS_TO_RGBL[0][0] * l + LMS_TO_RGBL[0][1] * m + LMS_TO_RGBL[0][2] * s
def to_G(S, h):
(l, m, s) = to_lms(S, h)
return LMS_TO_RGBL[1][0] * l + LMS_TO_RGBL[1][1] * m + LMS_TO_RGBL[1][2] * s
def to_G_dS(S, h):
(l, m, s) = to_lms_dS(S, h)
return LMS_TO_RGBL[1][0] * l + LMS_TO_RGBL[1][1] * m + LMS_TO_RGBL[1][2] * s
def to_G_dS2(S, h):
(l, m, s) = to_lms_dS2(S, h)
return LMS_TO_RGBL[1][0] * l + LMS_TO_RGBL[1][1] * m + LMS_TO_RGBL[1][2] * s
def to_B(S, h):
(l, m, s) = to_lms(S, h)
return LMS_TO_RGBL[2][0] * l + LMS_TO_RGBL[2][1] * m + LMS_TO_RGBL[2][2] * s
def to_B_dS(S, h):
(l, m, s) = to_lms_dS(S, h)
return LMS_TO_RGBL[2][0] * l + LMS_TO_RGBL[2][1] * m + LMS_TO_RGBL[2][2] * s
def to_B_dS2(S, h):
(l, m, s) = to_lms_dS2(S, h)
return LMS_TO_RGBL[2][0] * l + LMS_TO_RGBL[2][1] * m + LMS_TO_RGBL[2][2] * s
if GAMUT != 'prophoto-rgb':
hs, Ss = np.meshgrid(np.linspace(-np.pi, np.pi, 720), np.linspace(0, 1, 200))
Rs = to_R(Ss, hs)
Gs = to_G(Ss, hs)
Bs = to_B(Ss, hs)
gamut = np.minimum(Rs, np.minimum(Gs, Bs))
r_lab = linear_srgb_to_oklab(np.array([1, 0, 0]))
g_lab = linear_srgb_to_oklab(np.array([0, 1, 0]))
b_lab = linear_srgb_to_oklab(np.array([0, 0, 1]))
r_h = np.arctan2(r_lab[2], r_lab[1])
g_h = np.arctan2(g_lab[2], g_lab[1])
b_h = np.arctan2(b_lab[2], b_lab[1])
r_dir = 0.5 * np.array([np.cos(b_h) + np.cos(g_h), np.sin(b_h) + np.sin(g_h)])
g_dir = 0.5 * np.array([np.cos(b_h) + np.cos(r_h), np.sin(b_h) + np.sin(r_h)])
b_dir = 0.5 * np.array([np.cos(r_h) + np.cos(g_h), np.sin(r_h) + np.sin(g_h)])
r_dir /= r_dir[0]** 2 + r_dir[1]** 2
g_dir /= g_dir[0]** 2 + g_dir[1]** 2
b_dir /= b_dir[0]** 2 + b_dir[1]** 2
# These are coefficients to quickly test which component goes below zero first.
# Used like this in compute_max_saturation:
# if (-1.88170328f * a - 0.80936493f * b > 1) // Red component goes below zero first
r_hs, r_Ss = np.meshgrid(np.linspace(g_h, 2 * np.pi + b_h, 200), np.linspace(0, 1, 200))
r_Rs = to_R(r_Ss, r_hs)
g_hs, g_Ss = np.meshgrid(np.linspace(b_h, r_h, 200), np.linspace(0, 1, 200))
g_Gs = to_G(g_Ss, g_hs)
b_hs, b_Ss = np.meshgrid(np.linspace(r_h, g_h, 200), np.linspace(0, 1, 200))
b_Bs = to_B(b_Ss, b_hs)
# These are numerical fits to the edge of the chroma
# The resulting coefficient, x_R, x_G and x_B are used in compute_max_saturation, as values for k0
its = 1
resolution = 100000
h = np.linspace(g_h, 2 * np.pi + b_h, resolution)
a = np.cos(h)
b = np.sin(h)
def e_R(x):
S = x[0] + x[1] * a + x[2] * b + x[3] * a ** 2 + x[4] * a * b
S = np.maximum(0, S)
# optimize for solution that is easiest to solve with one step Haley's method
f = to_R(S, h)
f1 = to_R_dS(S, h)
f2 = to_R_dS2(S, h)
S_1 = S - f * f1 / (f1 ** 2 - f * f2 / 2)
f_ = to_R(S_1, h)
return np.average(f_ ** 10) # + f_[0] ** 2 + f_[-1] ** 2
x_R = scipy.optimize.minimize(e_R, np.array([1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245])).x
# printarray('R COEFF', x_R)
S_R = x_R[0] + x_R[1] * a + x_R[2] * b + x_R[3] * a ** 2 + x_R[4] * a * b
S_R1 = S_R
for i in range(0, its):
f = to_R(S_R1, h)
f1 = to_R_dS(S_R1, h)
f2 = to_R_dS2(S_R1, h)
S_R1 = S_R1 - f * f1 / (f1 ** 2 - f * f2 / (2))
plt.plot(S_R1, 'r')
#####
h = np.linspace(b_h, r_h, resolution)
a = np.cos(h)
b = np.sin(h)
def e_G(x):
S = x[0] + x[1] * a + x[2] * b + x[3] * a ** 2 + x[4] * a * b
S = np.maximum(0, S)
# optimize for solution that is easiest to solve with one step Haley's method
f = to_G(S, h)
f1 = to_G_dS(S, h)
f2 = to_G_dS2(S, h)
S_1 = S - f * f1 / (f1 ** 2 - f * f2 / 2)
f_ = to_G(S_1, h)
return np.average(f_ ** 10) # + f_[0] ** 2 + f_[-1] ** 2
x_G = scipy.optimize.minimize(e_G, np.array([0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204])).x
# printarray('G COEFF', x_G)
S_G = x_G[0] + x_G[1] * a + x_G[2] * b + x_G[3] * a ** 2 + x_G[4] * a * b
S_G1 = S_G
for i in range(0, its):
f = to_G(S_G1, h)
f1 = to_G_dS(S_G1, h)
f2 = to_G_dS2(S_G1, h)
S_G1 = S_G1 - f * f1 / (f1 ** 2 - f * f2 / (2))
#####
h = np.linspace(r_h, g_h, resolution)
a = np.cos(h)
b = np.sin(h)
def e_B(x):
S = x[0] + x[1] * a + x[2] * b + x[3] * a ** 2 + x[4] * a * b
S = np.maximum(0, S)
# optimize for solution that is easiest to solve with one step Haley's method
f = to_B(S, h)
f1 = to_B_dS(S, h)
f2 = to_B_dS2(S, h)
S_1 = S - f * f1 / (f1 ** 2 - f * f2 / 2)
f_ = to_B(S_1, h)
return np.average(f_ ** 10) # + f_[0] ** 2 + f_[-1] ** 2
x_B = scipy.optimize.minimize(e_B, np.array([1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167])).x
# printarray('B COEFF', x_B)
S_B = x_B[0] + x_B[1] * a + x_B[2] * b + x_B[3] * a ** 2 + x_B[4] * a * b
S_B1 = S_B
for i in range(0, its):
f = to_B(S_B1, h)
f1 = to_B_dS(S_B1, h)
f2 = to_B_dS2(S_B1, h)
S_B1 = S_B1 - f * f1 / (f1 ** 2 - f * f2 / (2))
print(f'// {var_name} space\n')
print(f'// {var_name} to XYZ ({whitepoint}) matrices\n')
if len(RGBL_TO_XYZ_RATIONAL) > 0:
print_rational(var_name, 'XYZ', RGBL_TO_XYZ_RATIONAL)
else:
print_matrix(var_name, 'XYZ', np.asfarray(RGBL_TO_XYZ))
if len(XYZ_TO_RGBL_RATIONAL) > 0:
print_rational('XYZ', var_name, XYZ_TO_RGBL_RATIONAL)
else:
print_matrix('XYZ', var_name, np.asfarray(XYZ_TO_RGBL))
print(f'// {var_name} to LMS matrices\n')
print_matrix(var_name, 'LMS', RGBL_TO_LMS)
print_matrix('LMS', var_name, LMS_TO_RGBL)
if GAMUT != 'prophoto-rgb':
coeff = [
[
r_dir.tolist(),
x_R.tolist(),
],
[
g_dir.tolist(),
x_G.tolist()
],
[
b_dir.tolist(),
x_B.tolist()
]
]
print(f'// {var_name} coefficients for OKLab gamut approximation\n')
print_json(f'OKLab_to_{var_name}_coefficients', coeff)
else:
print(f'// {var_name} does not yet support OKLab gamut approximation\n')
# print things...
print(f'/** This file is auto-generated by tools/print_matrices.py */\n')
print(f'// OKLab to LMS matrices\n')
print_matrix('OKLab', 'LMS', np.asfarray(OKLAB_TO_LMS3))
print_matrix('LMS', 'OKLab', np.asfarray(LMS3_TO_OKLAB))
print_matrix('XYZ', 'LMS', np.asfarray(XYZ_TO_LMS))
print_matrix('LMS', 'XYZ', np.asfarray(LMS_TO_XYZ))
# don't need these...
# print_matrix('XYZD50', 'LMS', np.asfarray(XYZD50_TO_LMS))
# print_matrix('LMS', 'XYZD50', np.asfarray(LMS_TO_XYZD50))
for gamut in ['srgb', 'display-p3', 'rec2020', 'a98-rgb', 'prophoto-rgb']:
d = do_calc(gamut)