diff --git a/.gitignore b/.gitignore index b8a5f34..1aa7ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules/ # Ignore generated HTML files *.html !*.tpl.html -!*/*.html \ No newline at end of file +!*/*.html +!*/**/*.html \ No newline at end of file diff --git a/README.md b/README.md index ed3f3bc..96f5e91 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ They are useful in their own right, but also serve as [Color.js](https://colorjs - [Convert across everything](convert) - [Gamut Mapping Gradients](gamut-mapping/gradients) - [Gamut Mapping Playground](gamut-mapping) +- [Gamut Mapping Comparison](gamut-mapping/compare) - [Gradient interpolation](gradients) - [Named color proximity](named) diff --git a/gamut-mapping/compare/index.html b/gamut-mapping/compare/index.html new file mode 100644 index 0000000..239c98f --- /dev/null +++ b/gamut-mapping/compare/index.html @@ -0,0 +1,57 @@ + + + + + + Color GMA Test + + + + +

GMA Comparison

+

+ This compares the output of different gamut mapping algorithms when + mapping to rec2020, and then clipping to p3 and + srgb to approximate clipping to the display device. +

+

+ It steps through the xyz-d65 space with the provided delta, + and checks a color if it is outside of rec2020. +

+
+
Scales:
+
+ +
+
Algorithms:
+
+ +
+ + + + + + + +

+	
+
diff --git a/gamut-mapping/compare/script.js b/gamut-mapping/compare/script.js
new file mode 100644
index 0000000..b7011ee
--- /dev/null
+++ b/gamut-mapping/compare/script.js
@@ -0,0 +1,21 @@
+// @ts-check
+const worker = new Worker("./worker.js", { type: "module" });
+
+const output = document.getElementById("output");
+worker.onmessage = (message) => {
+	const data = JSON.parse(message.data);
+	if (data.results) {
+		output.innerHTML = JSON.stringify(data, null, 2) + "



"; + } +}; +worker.onerror = (...error) => { + console.error("Worker error:", error); + output.innerHTML = "Error: " + error.message; +}; + +const run = document.getElementById("run"); +run.addEventListener("click", () => { + const delta = parseFloat(document.getElementById("delta").value); + console.log("Running worker"); + worker.postMessage(["run", { delta }]); +}); diff --git a/gamut-mapping/compare/utils.js b/gamut-mapping/compare/utils.js new file mode 100644 index 0000000..0e22b74 --- /dev/null +++ b/gamut-mapping/compare/utils.js @@ -0,0 +1,68 @@ +// @ts-check +import Color from "https://colorjs.io/dist/color.js"; +import methods from "../methods.js"; + +export function* xyzColorGenerator(settings) { + let count = 0; + for (var x = settings.x.min; x <= settings.x.max; x = x + settings.x.delta) { + for ( + var y = settings.y.min; + y <= settings.y.max; + y = y + settings.y.delta + ) { + for ( + var z = settings.z.min; + z <= settings.z.max; + z = z + settings.z.delta + ) { + yield [`color(xyz ${x} ${y} ${z})`, count++]; + } + } + } +} + +export const deltas = (original, mapped) => { + let deltas = {}; + ["L", "C", "H"].forEach((c, i) => { + let delta = mapped.to("oklch").coords[i] - original.to("oklch").coords[i]; + + if (c === "L") { + // L is percentage + delta *= 100; + } else if (c === "H") { + // Hue is angular, so we need to normalize it + delta = ((delta % 360) + 720) % 360; + delta = Math.min(360 - delta, delta); + // Check: Is this hiding cases where only one value is NaN? + if (isNaN(delta)) delta = 0; + } else { + // debugger; + } + + // delta = Color.util.toPrecision(delta, 2); + // Use absolute because we are interested in magnitude, not direction + deltas[c] = Math.abs(delta); + }); + deltas.delta2000 = original.deltaE(mapped, { method: "2000" }); + return deltas; +}; + +export const clipP3 = (color) => + color.clone().toGamut({ space: "p3", method: "clip" }); +export const clipSrgb = (color) => + color.clone().toGamut({ space: "srgb", method: "clip" }); + +export const edgeSeekerColor = (color) => methods["edge-seeker-rec2020"].compute(color); + +export const runningAverage = (average, newValue, count) => { + if (count === 1) { + return newValue; + } + return (average * (count - 1)) / count + newValue / count; +}; + +export const chromiumColor = (color) => methods.chromium.compute(color); + +export const bjornColor = (color) => methods.bjornRec2020.compute(color); + +export const raytraceColor = (color) => methods.raytraceRec2020.compute(color); diff --git a/gamut-mapping/compare/worker.js b/gamut-mapping/compare/worker.js new file mode 100644 index 0000000..0df63c6 --- /dev/null +++ b/gamut-mapping/compare/worker.js @@ -0,0 +1,158 @@ +// @ts-check +import Color from "https://colorjs.io/dist/color.js"; + +import { + xyzColorGenerator, + deltas, + edgeSeekerColor, + clipP3, + clipSrgb, + runningAverage, + chromiumColor, + bjornColor, + raytraceColor, +} from "./utils.js"; + +const settings = (delta)=> ({ + x: { min: 0, max: 1, delta }, + y: { min: 0, max: 1, delta }, + z: { min: 0, max: 1, delta }, +}); +const results = { + edgeToP3: { L: 0, C: 0, H: 0, delta2000: 0 }, + edgeToSrgb: { L: 0, C: 0, H: 0, delta2000: 0 }, + chromiumToP3: { L: 0, C: 0, H: 0, delta2000: 0 }, + chromiumToSrgb: { L: 0, C: 0, H: 0, delta2000: 0 }, + bjornToP3: { L: 0, C: 0, H: 0, delta2000: 0 }, + bjornToSrgb: { L: 0, C: 0, H: 0, delta2000: 0 }, + clipToP3: { L: 0, C: 0, H: 0, delta2000: 0 }, + raytraceToP3: { L: 0, C: 0, H: 0, delta2000: 0 }, + raytraceToSrgb: { L: 0, C: 0, H: 0, delta2000: 0 }, + delta: null +}; + +const rawResults = []; + +// methods: +// - edge to rec2020, clip to p3 +// - edge to rec2020, clip to srgb +// - clip to p3 +// - clip to srgb + +const run = ({delta}) => { + const colors = xyzColorGenerator(settings(delta)); + results.delta = delta; + let color = colors.next(); + let count = 0; + + while (!color.done) { + const [colorString, index] = color.value; + count = index; + let _color = new Color(colorString); + if (_color.inGamut("rec2020", { epsilon: 0 })) { + color = colors.next(); + continue; // Skip colors in rec2020 gamut + } + + const colorData = processColor(_color); + // rawResults.push({ colorString, colorData }); + aggregate(colorData, index); + + color = colors.next(); + if (index % 100 === 0) { + postMessage(JSON.stringify({ results, count: index })); + } + } + postMessage(JSON.stringify({ results, count })); +}; + +const processColor = (color) => { + const edgeSeeker = edgeSeekerColor(color); + const edgeClippedToP3 = clipP3(edgeSeeker); + const edgeClippedToSrgb = clipSrgb(edgeSeeker); + const edgeP3Delta = deltas(color, edgeClippedToP3); + const edgeSrgbDelta = deltas(color, edgeClippedToSrgb); + + const clippedStraightToP3 = clipP3(color); + const clippedStraightToP3Delta = deltas(color, clippedStraightToP3); + + const chromiumClippedToP3 = chromiumColor(color); + const chromiumClippedToSrgb = clipSrgb(chromiumClippedToP3); + const chromiumP3Delta = deltas(color, chromiumClippedToP3); + const chromiumSrgbDelta = deltas(color, chromiumClippedToSrgb); + + const bjorn = bjornColor(color); + const bjornClippedToP3 = clipP3(bjorn); + const bjornClippedToSrgb = clipSrgb(bjorn); + const bjornP3Delta = deltas(color, bjornClippedToP3); + const bjornSrgbDelta = deltas(color, bjornClippedToSrgb); + + const raytrace = raytraceColor(color); + const raytraceClippedToP3 = clipP3(raytrace); + const raytraceClippedToSrgb = clipSrgb(raytrace); + const raytraceP3Delta = deltas(color, raytraceClippedToP3); + const raytraceSrgbDelta = deltas(color, raytraceClippedToSrgb); + + return { + color, + edgeSeeker, + edgeClippedToP3, + edgeClippedToSrgb, + clippedStraightToP3, + edgeP3Delta, + edgeSrgbDelta, + clippedStraightToP3Delta, + chromiumClippedToP3, + chromiumClippedToSrgb, + chromiumP3Delta, + chromiumSrgbDelta, + bjornClippedToP3, + bjornClippedToSrgb, + bjornP3Delta, + bjornSrgbDelta, + raytraceP3Delta, + raytraceSrgbDelta, + }; +}; + +const aggregate = (colorData, index) => { + const { + edgeP3Delta, + clippedStraightToP3Delta, + edgeSrgbDelta, + chromiumP3Delta, + chromiumSrgbDelta, + bjornP3Delta, + bjornSrgbDelta, + raytraceP3Delta, + raytraceSrgbDelta, + } = colorData; + + const map = [ + ["edgeToP3", edgeP3Delta], + ["edgeToSrgb", edgeSrgbDelta], + ["chromiumToP3", chromiumP3Delta], + ["chromiumToSrgb", chromiumSrgbDelta], + ["bjornToP3", bjornP3Delta], + ["bjornToSrgb", bjornSrgbDelta], + ["raytraceToP3", raytraceP3Delta], + ["raytraceToSrgb", raytraceSrgbDelta], + ["clipToP3", clippedStraightToP3Delta], + ]; + + map.forEach(([key, delta]) => { + const res = results[key]; + ["L", "C", "H", "delta2000"].forEach((d) => { + res[d] = runningAverage(res[d], delta[d], index); + }); + }); + + return results; +}; + +onmessage = (event) => { + console.log("Message received from main script - compare", event, event.data); + if (event.data[0] === "run") { + run(event.data[1]); + } +}; diff --git a/gamut-mapping/gradients.css b/gamut-mapping/gradients.css index 7bd3bc8..06c3c73 100644 --- a/gamut-mapping/gradients.css +++ b/gamut-mapping/gradients.css @@ -79,5 +79,10 @@ details.timing-info { justify-content: space-between; gap: 1em; } +} +#algorithms{ + label{ + margin-inline-end: 1em; + } } \ No newline at end of file diff --git a/gamut-mapping/gradients.html b/gamut-mapping/gradients.html index ed7553d..35fd7ef 100644 --- a/gamut-mapping/gradients.html +++ b/gamut-mapping/gradients.html @@ -23,6 +23,13 @@

Gamut Mapping Gradients


{{steps.length}} Gradient Steps
+
+ Algorithms + +
@@ -46,8 +53,8 @@

Gamut Mapping Gradients

-
-
+
+
diff --git a/gamut-mapping/gradients.js b/gamut-mapping/gradients.js index c789f62..1a92191 100644 --- a/gamut-mapping/gradients.js +++ b/gamut-mapping/gradients.js @@ -12,11 +12,13 @@ let app = createApp({ const urlToColor = params.get("to"); const from = urlFromColor || "oklch(90% .4 250)"; const to = urlToColor || "oklch(40% .1 20)"; - const methods = ["none", "clip", "scale-lh", "css", "css-rec2020", "raytrace", "bjorn", "edge-seeker", "chromium"]; + const methods = ["none", "clip", "scale-lh", "css", "css-rec2020", "raytrace", "raytraceRec2020", "bjorn", "bjornRec2020", "edge-seeker", "edge-seeker-rec2020", "chromium"]; + const enabledMethods = ["clip", "css-rec2020", "raytraceRec2020", "bjornRec2020", "edge-seeker-rec2020", "chromium"]; const runResults = {}; - methods.forEach(method => runResults[method] = []); + enabledMethods.forEach(method => runResults[method] = []); return { - methods: methods, + methods, + enabledMethods, from: from, to: to, parsedFrom: this.tryParse(from), @@ -27,6 +29,7 @@ let app = createApp({ params: params, interpolationSpaces: ["oklch", "oklab", "p3", "rec2020", "lab"], runResults: runResults, + refresh: 0, }; }, @@ -97,6 +100,12 @@ let app = createApp({ deep: true, immediate: true, }, + enabledMethods(newValue) { + const runResults = {}; + newValue.forEach(method => runResults[method] = []); + this.runResults = runResults; + this.refresh++; + } }, components: { diff --git a/gamut-mapping/methods.js b/gamut-mapping/methods.js index beafeaa..d7bfa80 100644 --- a/gamut-mapping/methods.js +++ b/gamut-mapping/methods.js @@ -11,6 +11,10 @@ const p3EdgeSeeker = makeEdgeSeeker((r, g, b) => { const [l, c, h = 0] = new Color("p3", [r, g, b]).to("oklch").coords; return { l, c, h }; }); +const rec2020EdgeSeeker = makeEdgeSeeker((r, g, b) => { + const [l, c, h = 0] = new Color("rec2020", [r, g, b]).to("oklch").coords; + return { l, c, h }; +}); const methods = { "clip": { @@ -192,7 +196,7 @@ const methods = { }, }, "bjorn" : { - label: "Björn Ottosson", + label: "Björn Ottosson P3", description: "Approach using Oklab as defined by the creator of Oklab, Bjorn Ottosson. Projected toward constant lightness.", lmsToP3Linear: [ [ 3.1277689713618737, -2.2571357625916377, 0.1293667912297650], @@ -270,6 +274,85 @@ const methods = { return oklab.to('p3').toGamut({method: 'clip'}); }, }, + "bjornRec2020" : { + label: "Björn Ottosson Rec2020", + description: "Approach using Oklab as defined by the creator of Oklab, Bjorn Ottosson. Projected toward constant lightness.", + lmsToRec2020Linear: [ + [2.1399067304346517, -1.2463894937606181, 0.10648276332596672], + [-0.8847358357577675, 2.1632309383612007, -0.27849510260343346], + [-0.04857374640044416, -0.454503149714096, 1.5030768961145402], + ], + rec2020Coeff: [ + // Red + [ + // Limit + [-1.36834899, -0.46664773], + // `Kn` coefficients + [1.2572445, 1.71580176, 0.5648733, 0.79507316, 0.58716363], + ], + // Green + [ + // Limit + [2.01150796, -2.0379096], + // `Kn` coefficients + [0.74087755, -0.4586733, 0.08182977, 0.12598705, -0.14570327], + ], + // Blue + [ + // Limit + [0.06454093, 2.29709336], + // `Kn` coefficients + [1.36920484e+00, -1.64666735e-02, -1.14197870e+00, -5.01064768e-01, 1.19905985e-03], + ], + ], + compute: (color) => { + // Approach described in https://bottosson.github.io/posts/gamutclipping/ + // For comparison against CSS approaches, constant lightness was used. + let oklab = color.to("oklab"); + + // Clamp lightness and see if we are in gamut. + oklab.l = util.clamp(0.0, oklab.l, 1.0); // If doing adaptive lightness, this might not be wanted. + if (oklab.inGamut("rec2020", { epsilon: 0 })) { + return oklab.to("rec2020"); + } + + // Get coordinates and calculate chroma + let [l, a, b] = oklab.coords; + // Bjorn used 0.00001, are there issues with 0.0? + const epsilon = 0.0 + let c = Math.max(epsilon, Math.sqrt(a ** 2 + b ** 2)); + + // Normalize a and b + if (c) { + a /= c; + b /= c; + } + + // Get gamut specific transform from LMS to RGB and related coefficients + const lmsToLinear = methods.bjornRec2020.lmsToRec2020Linear; + const coeff = methods.bjornRec2020.rec2020Coeff; + + // Find the lightness and chroma for the cusp. + let cusp = findCusp(a, b, lmsToLinear, coeff); + + // Set the target lightness towards which chroma reduction will take place. + // `cusp[0]` is approximate lightness of cusp, l is current lightness. + // One could apply some adaptive lightness if desired. + const target = l; // cusp[0]; + const t = findGamutIntersection(a, b, l, c, target, lmsToLinear, coeff, cusp); + + // Adjust lightness and chroma + if (target !== l) { + oklab.l = target * (1 - t) + t * l; + } + c *= t; + oklab.a = c * a; + oklab.b = c * b; + + // Convert back to rec2020 and clip. + return oklab.to("rec2020").toGamut({method: "clip"}); + }, + }, "raytrace": { label: "Raytrace", description: "Uses ray tracing to find a color with reduced chroma on the RGB surface.", @@ -486,9 +569,170 @@ const methods = { ]; }, }, + "raytraceRec2020": { + label: "Raytrace Rec2020", + description: "Uses ray tracing to find a color with reduced chroma on the RGB surface.", + compute: (color) => { + // An approached originally designed for ColorAide. + // https://facelessuser.github.io/coloraide/gamut/#ray-tracing-chroma-reduction + if (color.inGamut("rec2020", { epsilon: 0 })) { + return color.to("rec2020"); + } + + let mapColor = color.to("oklch"); + let lightness = mapColor.coords[0]; + + if (lightness >= 1) { + return new Color({ space: "xyz-d65", coords: WHITES["D65"] }).to("p3"); + } + else if (lightness <= 0) { + return new Color({ space: "xyz-d65", coords: [0, 0, 0] }).to("p3"); + } + return methods.raytraceRec2020.trace(mapColor); + }, + + oklchToLinearRGB (lch) { + // Convert from Oklab to linear RGB. + // + // Can be any gamut as long as `lmsToRgb` is a matrix + // that transform the LMS values to the linear RGB space. + + let c = lch[1]; + let h = lch[2]; + // to lab + let result = [ + lch[0], + c * Math.cos((h * Math.PI) / 180), + c * Math.sin((h * Math.PI) / 180) + ]; + + // To LMS + util.multiply_v3_m3x3( + result, + [ + [ 1.0000000000000000, 0.3963377773761749, 0.2158037573099136 ], + [ 1.0000000000000000, -0.1055613458156586, -0.0638541728258133 ], + [ 1.0000000000000000, -0.0894841775298119, -1.2914855480194092 ] + ], + result + ); + result[0] = result[0] ** 3; + result[1] = result[1] ** 3; + result[2] = result[2] ** 3; + + // To RGB + util.multiply_v3_m3x3( + result, + [ + [2.1399067304346517, -1.2463894937606181, 0.10648276332596672], + [-0.8847358357577675, 2.1632309383612007, -0.27849510260343346], + [-0.04857374640044416, -0.454503149714096, 1.5030768961145402] + ], + result + ); + return result; + }, + + LinearRGBtoOklch (rgb) { + // Convert from Oklch to linear RGB. + // + // Can be any gamut as long as `lmsToRgb` is a matrix + // that transform the LMS values to the linear RGB space. + + // To LMS + let result = util.multiply_v3_m3x3( + rgb, + [ + [0.6167557848654442, 0.3601984012264634, 0.023045813908092294], + [0.2651330593926367, 0.6358393720678492, 0.0990275685395141], + [0.10010262952034829, 0.20390652261661446, 0.6959908478630372] + ] + ); + + result[0] = Math.cbrt(result[0]); + result[1] = Math.cbrt(result[1]); + result[2] = Math.cbrt(result[2]); + + util.multiply_v3_m3x3( + result, + [ + [ 0.2104542683093140, 0.7936177747023054, -0.0040720430116193 ], + [ 1.9779985324311684, -2.4285922420485799, 0.4505937096174110 ], + [ 0.0259040424655478, 0.7827717124575296, -0.8086757549230774 ] + ], + result + ); + + let a = result[1]; + let b = result[2]; + return [ + result[0], + Math.sqrt(a ** 2 + b ** 2), + constrainAngle((Math.atan2(b, a) * 180) / Math.PI) + ]; + }, + + trace: (orig) => { + let coords = orig.coords; + let [light, chroma, hue] = coords; + coords[1] = 0; + + let anchor = methods.raytraceRec2020.oklchToLinearRGB(coords); + coords[1] = chroma; + let mapColor = methods.raytraceRec2020.oklchToLinearRGB(coords); + + let raytrace = methods.raytrace.raytrace_box; + // Assume an RGB range between 0 - 1. + // This could be different depending on the RGB max luminance and could + // be calculated to be different depending on needs. + // We'll use this to adjust our anchor point closer to the gamut surface. + let low = 1e-6; + let high = 1 - low; + + // Cast a ray from the zero chroma color to the target color. + // Trace the line to the RGB cube edge and find where it intersects. + // Correct L and h within the perceptual OkLCh after each attempt. + for (let i = 0; i < 4; i++) { + if (i) { + const oklch = methods.raytraceRec2020.LinearRGBtoOklch(mapColor); + oklch[0] = light; + oklch[2] = hue; + mapColor = methods.raytraceRec2020.oklchToLinearRGB(oklch); + } + const current = mapColor.slice(); + const intersection = raytrace(anchor, current); + + // Adjust anchor point closer to surface, when possible, to improve results for some spaces. + // But not too close to the surface. + if (i && current.every((x) => low < x && x < high)) { + anchor = current; + } + + if (intersection.length) { + mapColor = intersection.slice(); + continue; + } + + // If there was no change, we are done + break; + } + + // Remove noise from floating point math by clipping + orig.setAll( + 'rec2020-linear', + [ + util.clamp(0.0, mapColor[0], 1.0), + util.clamp(0.0, mapColor[1], 1.0), + util.clamp(0.0, mapColor[2], 1.0), + ] + ); + + return orig.to("rec2020"); + }, + }, "edge-seeker": { label: "Edge Seeker", - description: "Using a LUT to detect edges of the gamut and reduce chroma accordingly.", + description: "Using a LUT to detect edges of the p3 gamut and reduce chroma accordingly.", compute: (color) => { let [l, c, h] = color.to("oklch").coords; if (l <= 0) { @@ -505,6 +749,25 @@ const methods = { return new Color("oklch", [l, c, h]).toGamut({ space: "p3", method: "clip" }); }, }, + "edge-seeker-rec2020": { + label: "Edge Seeker rec2020", + description: "Using a LUT to detect edges of the rec2020 gamut and reduce chroma accordingly.", + compute: (color) => { + let [l, c, h] = color.to("oklch").coords; + if (l <= 0) { + return new Color("oklch", [0, 0, h]); + } + if (l >= 1) { + return new Color("oklch", [1, 0, h]); + } + let maxChroma = rec2020EdgeSeeker(l, h || 0); + if (c > maxChroma) { + c = maxChroma; + } + // Don't clip, and return rec2020 gamut + return new Color("oklch", [l, c, h]); + }, + }, // "scale125": { // label: "Scale from 0.125", // description: "Using a midpoint of 0.125 (perceptual midpoint per Oklab), scale the color to fit within the linear P3 gamut using different scaling factors for values below and above the midpoint.", diff --git a/gamut-mapping/timing-info.js b/gamut-mapping/timing-info.js index ff16bfa..e3ea856 100644 --- a/gamut-mapping/timing-info.js +++ b/gamut-mapping/timing-info.js @@ -8,10 +8,11 @@ export default { computed: { results () { - const [none, ...methodsTested] = Object.keys(this.runResults); + const methodsTested = Object.keys(this.runResults).filter(method => method !== "none"); + return methodsTested.map(method => { - const data = this.runResults[method]; - const total = data.reduce((acc, value) => acc + value); + const data = this.runResults[method] ?? []; + const total = data.reduce((acc, value) => acc + value, 0); return { id: method,