@texel/color
Advanced tools
+1
-1
| { | ||
| "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); |
-112
| 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); | ||
| }); |
-481
| 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) |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
0
-100%74351
-58.22%18
-53.85%1556
-59.33%