From 542f8a8c76f1410cabadbd42a578181f76e1186e Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 09:34:43 -0400 Subject: [PATCH 1/7] Add Gamut comparison script --- .gitignore | 3 +- README.md | 1 + gamut-mapping/compare/index.html | 56 +++++++++ gamut-mapping/compare/script.js | 21 ++++ gamut-mapping/compare/utils.js | 68 +++++++++++ gamut-mapping/compare/worker.js | 190 +++++++++++++++++++++++++++++++ gamut-mapping/methods.js | 25 +++- 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 gamut-mapping/compare/index.html create mode 100644 gamut-mapping/compare/script.js create mode 100644 gamut-mapping/compare/utils.js create mode 100644 gamut-mapping/compare/worker.js 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..d52516b --- /dev/null +++ b/gamut-mapping/compare/index.html @@ -0,0 +1,56 @@ + + + + + + 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:
+
+
    +
  • L = 0-100 (So a 1 would be equivalent to 0.01 or 1%)
  • +
  • C = 0-0.4 (Most of the mapped colors had very high chroma to start with, so we're seeing large shifts here, as expected)
  • +
  • h = 0-360
  • + +
+
+
Algorithms:
+
+
    +
  • edge: Edge Tracer, mapping to rec2020
  • +
  • chromium: Chromium "baked in" implementation, mapping to rec2020
  • +
  • bjorn: Björn Ottosson method, mapping to p3
  • +
  • raytrace: Raytrace, mapping to p3
  • +
+
+ + + + + + + +

+	
+
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..bc44f5a --- /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.bjorn.compute(color); + +export const raytraceColor = (color) => methods["raytrace"].compute(color); diff --git a/gamut-mapping/compare/worker.js b/gamut-mapping/compare/worker.js new file mode 100644 index 0000000..13daf7e --- /dev/null +++ b/gamut-mapping/compare/worker.js @@ -0,0 +1,190 @@ +// @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 edgeDelta = deltas(color, edgeSeeker); + + const clippedToP3 = clipP3(edgeSeeker); + const clippedToSrgb = clipSrgb(edgeSeeker); + const clippedStraightToP3 = clipP3(color); + const p3Delta = deltas(color, clippedToP3); + const srgbDelta = deltas(color, clippedToSrgb); + const clippedStraightToP3Delta = deltas(color, clippedStraightToP3); + const chromiumClippedToP3 = chromiumColor(color); + const chromiumClippedToSrgb = clipSrgb(chromiumClippedToP3); + const chromiumP3Delta = deltas(color, chromiumClippedToP3); + const chromiumSrgbDelta = deltas(color, chromiumClippedToSrgb); + const bjornClippedToP3 = bjornColor(color); + const bjornClippedToSrgb = clipSrgb(bjornClippedToP3); + 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, + edgeDelta, + clippedToP3, + clippedToSrgb, + clippedStraightToP3, + p3Delta, + srgbDelta, + clippedStraightToP3Delta, + chromiumClippedToP3, + chromiumClippedToSrgb, + chromiumP3Delta, + chromiumSrgbDelta, + bjornClippedToP3, + bjornClippedToSrgb, + bjornP3Delta, + bjornSrgbDelta, + raytraceP3Delta, + raytraceSrgbDelta, + }; +}; + +const aggregate = (colorData, index) => { + const { + p3Delta, + clippedStraightToP3Delta, + srgbDelta, + chromiumP3Delta, + chromiumSrgbDelta, + bjornP3Delta, + bjornSrgbDelta, + raytraceP3Delta, + raytraceSrgbDelta, + } = colorData; + + const map = [ + ["edgeToP3", p3Delta], + ["clipToP3", clippedStraightToP3Delta], + ["edgeToSrgb", srgbDelta], + ["chromiumToP3", chromiumP3Delta], + ["chromiumToSrgb", chromiumSrgbDelta], + ["bjornToP3", bjornP3Delta], + ["bjornToSrgb", bjornSrgbDelta], + ["raytraceToP3", raytraceP3Delta], + ["raytraceToSrgb", raytraceSrgbDelta], + ]; + + map.forEach(([key, delta]) => { + const res = results[key]; + ["L", "C", "H", "delta2000"].forEach((d) => { + res[d] = runningAverage(res[d], delta[d], index); + }); + + // Update worst case + // if (!res.worst || res.worst.delta2000 < delta.delta2000) { + // res.worst = { + // color: colorData.color.toString(), + // delta2000: delta.delta2000, + // L: delta.L, + // C: delta.C, + // H: delta.H, + // }; + // } + }); + + // // To P3 Edge + // const p3Res = results.edgeToP3; + // ["L", "C", "H", "delta2000"].forEach((delta) => { + // p3Res[delta] = runningAverage(p3Res[delta], p3Delta[delta], index); + // }); + + // // To P3 clip + // const clipP3Res = results.clipToP3; + // ["L", "C", "H", "delta2000"].forEach((delta) => { + // clipP3Res[delta] = runningAverage( + // clipP3Res[delta], + // clippedStraightToP3Delta[delta], + // index + // ); + // }); + + // // To sRGB Edge + // const srgbRes = results.edgeToSrgb; + // ["L", "C", "H", "delta2000"].forEach((delta) => { + // srgbRes[delta] = runningAverage(srgbRes[delta], srgbDelta[delta], 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/methods.js b/gamut-mapping/methods.js index beafeaa..5eca20f 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": { @@ -488,7 +492,7 @@ const methods = { }, "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 +509,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; + } + // At this point it is safe to clip the values + return new Color("oklch", [l, c, h]).toGamut({ space: "p3", method: "clip" }); + }, + }, // "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.", From e4369881f6cc8a97c3aa2bf20d8418c063ae1991 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 09:36:28 -0400 Subject: [PATCH 2/7] Remove unused code --- gamut-mapping/compare/worker.js | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/gamut-mapping/compare/worker.js b/gamut-mapping/compare/worker.js index 13daf7e..7c2d531 100644 --- a/gamut-mapping/compare/worker.js +++ b/gamut-mapping/compare/worker.js @@ -144,41 +144,8 @@ const aggregate = (colorData, index) => { ["L", "C", "H", "delta2000"].forEach((d) => { res[d] = runningAverage(res[d], delta[d], index); }); - - // Update worst case - // if (!res.worst || res.worst.delta2000 < delta.delta2000) { - // res.worst = { - // color: colorData.color.toString(), - // delta2000: delta.delta2000, - // L: delta.L, - // C: delta.C, - // H: delta.H, - // }; - // } }); - // // To P3 Edge - // const p3Res = results.edgeToP3; - // ["L", "C", "H", "delta2000"].forEach((delta) => { - // p3Res[delta] = runningAverage(p3Res[delta], p3Delta[delta], index); - // }); - - // // To P3 clip - // const clipP3Res = results.clipToP3; - // ["L", "C", "H", "delta2000"].forEach((delta) => { - // clipP3Res[delta] = runningAverage( - // clipP3Res[delta], - // clippedStraightToP3Delta[delta], - // index - // ); - // }); - - // // To sRGB Edge - // const srgbRes = results.edgeToSrgb; - // ["L", "C", "H", "delta2000"].forEach((delta) => { - // srgbRes[delta] = runningAverage(srgbRes[delta], srgbDelta[delta], index); - // }); - return results; }; From c0594506e4d7cdd31f393f7fc4374b3f9d1aada9 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 09:44:52 -0400 Subject: [PATCH 3/7] Clean up names, remove edge rec2020 clip to p3 --- gamut-mapping/compare/worker.js | 28 +++++++++++++--------------- gamut-mapping/methods.js | 4 ++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/gamut-mapping/compare/worker.js b/gamut-mapping/compare/worker.js index 7c2d531..ff9ff98 100644 --- a/gamut-mapping/compare/worker.js +++ b/gamut-mapping/compare/worker.js @@ -68,13 +68,12 @@ const run = ({delta}) => { const processColor = (color) => { const edgeSeeker = edgeSeekerColor(color); - const edgeDelta = deltas(color, edgeSeeker); + const edgeClippedToP3 = clipP3(edgeSeeker); + const edgeClippedToSrgb = clipSrgb(edgeSeeker); + const edgeP3Delta = deltas(color, edgeClippedToP3); + const edgeSrgbDelta = deltas(color, edgeClippedToSrgb); - const clippedToP3 = clipP3(edgeSeeker); - const clippedToSrgb = clipSrgb(edgeSeeker); const clippedStraightToP3 = clipP3(color); - const p3Delta = deltas(color, clippedToP3); - const srgbDelta = deltas(color, clippedToSrgb); const clippedStraightToP3Delta = deltas(color, clippedStraightToP3); const chromiumClippedToP3 = chromiumColor(color); const chromiumClippedToSrgb = clipSrgb(chromiumClippedToP3); @@ -94,12 +93,11 @@ const processColor = (color) => { return { color, edgeSeeker, - edgeDelta, - clippedToP3, - clippedToSrgb, + edgeClippedToP3, + edgeClippedToSrgb, clippedStraightToP3, - p3Delta, - srgbDelta, + edgeP3Delta, + edgeSrgbDelta, clippedStraightToP3Delta, chromiumClippedToP3, chromiumClippedToSrgb, @@ -116,9 +114,9 @@ const processColor = (color) => { const aggregate = (colorData, index) => { const { - p3Delta, + edgeP3Delta, clippedStraightToP3Delta, - srgbDelta, + edgeSrgbDelta, chromiumP3Delta, chromiumSrgbDelta, bjornP3Delta, @@ -128,15 +126,15 @@ const aggregate = (colorData, index) => { } = colorData; const map = [ - ["edgeToP3", p3Delta], - ["clipToP3", clippedStraightToP3Delta], - ["edgeToSrgb", srgbDelta], + ["edgeToP3", edgeP3Delta], + ["edgeToSrgb", edgeSrgbDelta], ["chromiumToP3", chromiumP3Delta], ["chromiumToSrgb", chromiumSrgbDelta], ["bjornToP3", bjornP3Delta], ["bjornToSrgb", bjornSrgbDelta], ["raytraceToP3", raytraceP3Delta], ["raytraceToSrgb", raytraceSrgbDelta], + ["clipToP3", clippedStraightToP3Delta], ]; map.forEach(([key, delta]) => { diff --git a/gamut-mapping/methods.js b/gamut-mapping/methods.js index 5eca20f..2e499db 100644 --- a/gamut-mapping/methods.js +++ b/gamut-mapping/methods.js @@ -524,8 +524,8 @@ const methods = { if (c > maxChroma) { c = maxChroma; } - // At this point it is safe to clip the values - return new Color("oklch", [l, c, h]).toGamut({ space: "p3", method: "clip" }); + // Don't clip, and return rec2020 gamut + return new Color("oklch", [l, c, h]); }, }, // "scale125": { From 8ca1301c67f7d9262afe1d44657504d96aa19fab Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 09:53:41 -0400 Subject: [PATCH 4/7] Add Bjorn rec2020 method --- gamut-mapping/compare/index.html | 2 +- gamut-mapping/compare/utils.js | 2 +- gamut-mapping/compare/worker.js | 7 ++- gamut-mapping/methods.js | 81 +++++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/gamut-mapping/compare/index.html b/gamut-mapping/compare/index.html index d52516b..484fdab 100644 --- a/gamut-mapping/compare/index.html +++ b/gamut-mapping/compare/index.html @@ -33,7 +33,7 @@

GMA Comparison

  • edge: Edge Tracer, mapping to rec2020
  • chromium: Chromium "baked in" implementation, mapping to rec2020
  • -
  • bjorn: Björn Ottosson method, mapping to p3
  • +
  • bjorn: Björn Ottosson method, mapping to rec2020
  • raytrace: Raytrace, mapping to p3
diff --git a/gamut-mapping/compare/utils.js b/gamut-mapping/compare/utils.js index bc44f5a..001ae21 100644 --- a/gamut-mapping/compare/utils.js +++ b/gamut-mapping/compare/utils.js @@ -63,6 +63,6 @@ export const runningAverage = (average, newValue, count) => { export const chromiumColor = (color) => methods.chromium.compute(color); -export const bjornColor = (color) => methods.bjorn.compute(color); +export const bjornColor = (color) => methods.bjornRec2020.compute(color); export const raytraceColor = (color) => methods["raytrace"].compute(color); diff --git a/gamut-mapping/compare/worker.js b/gamut-mapping/compare/worker.js index ff9ff98..0df63c6 100644 --- a/gamut-mapping/compare/worker.js +++ b/gamut-mapping/compare/worker.js @@ -75,12 +75,15 @@ const processColor = (color) => { 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 bjornClippedToP3 = bjornColor(color); - const bjornClippedToSrgb = clipSrgb(bjornClippedToP3); + + const bjorn = bjornColor(color); + const bjornClippedToP3 = clipP3(bjorn); + const bjornClippedToSrgb = clipSrgb(bjorn); const bjornP3Delta = deltas(color, bjornClippedToP3); const bjornSrgbDelta = deltas(color, bjornClippedToSrgb); diff --git a/gamut-mapping/methods.js b/gamut-mapping/methods.js index 2e499db..4ce5db9 100644 --- a/gamut-mapping/methods.js +++ b/gamut-mapping/methods.js @@ -196,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], @@ -274,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.", From b12cae8e1608f2d836a87f721137d1d0bf02b4f8 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 09:57:37 -0400 Subject: [PATCH 5/7] Add Raytrace rec2020 alternate --- gamut-mapping/compare/utils.js | 2 +- gamut-mapping/methods.js | 161 +++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/gamut-mapping/compare/utils.js b/gamut-mapping/compare/utils.js index 001ae21..0e22b74 100644 --- a/gamut-mapping/compare/utils.js +++ b/gamut-mapping/compare/utils.js @@ -65,4 +65,4 @@ export const chromiumColor = (color) => methods.chromium.compute(color); export const bjornColor = (color) => methods.bjornRec2020.compute(color); -export const raytraceColor = (color) => methods["raytrace"].compute(color); +export const raytraceColor = (color) => methods.raytraceRec2020.compute(color); diff --git a/gamut-mapping/methods.js b/gamut-mapping/methods.js index 4ce5db9..d7bfa80 100644 --- a/gamut-mapping/methods.js +++ b/gamut-mapping/methods.js @@ -569,6 +569,167 @@ 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 p3 gamut and reduce chroma accordingly.", From a0a31ff76adbca56643d804ae6390b0d63f6a946 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 10:00:48 -0400 Subject: [PATCH 6/7] Update notes --- gamut-mapping/compare/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gamut-mapping/compare/index.html b/gamut-mapping/compare/index.html index 484fdab..239c98f 100644 --- a/gamut-mapping/compare/index.html +++ b/gamut-mapping/compare/index.html @@ -34,7 +34,8 @@

GMA Comparison

  • edge: Edge Tracer, mapping to rec2020
  • chromium: Chromium "baked in" implementation, mapping to rec2020
  • bjorn: Björn Ottosson method, mapping to rec2020
  • -
  • raytrace: Raytrace, mapping to p3
  • +
  • raytrace: Raytrace, mapping to rec2020
  • +
  • clip: Clipping directly to p3, as a comparison
  • From f8707d79a373c5e0f3438389cbab88d39a5a9f8a Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Wed, 11 Jun 2025 13:37:36 -0400 Subject: [PATCH 7/7] Add rec2020 variants to Gradients tool, add algorithm selection --- gamut-mapping/gradients.css | 5 +++++ gamut-mapping/gradients.html | 11 +++++++++-- gamut-mapping/gradients.js | 15 ++++++++++++--- gamut-mapping/timing-info.js | 7 ++++--- 4 files changed, 30 insertions(+), 8 deletions(-) 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/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,