From 03684da79a47f4dc4842831b6bbb3d751c0e8b69 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 19 Dec 2023 10:55:28 -0700 Subject: [PATCH 1/5] Add the HCT color space --- src/spaces/hct.js | 152 +++++++++++++++++++++++++++++++++++++++++ src/spaces/index-fn.js | 1 + test/conversions.js | 48 +++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 src/spaces/hct.js diff --git a/src/spaces/hct.js b/src/spaces/hct.js new file mode 100644 index 000000000..8f3a998c6 --- /dev/null +++ b/src/spaces/hct.js @@ -0,0 +1,152 @@ +import ColorSpace from "../space.js"; +import {constrain} from "../angles.js"; +import xyz_d65 from "./xyz-d65.js"; +import {fromCam16, toCam16, environment} from "./cam16.js"; +import {WHITES} from "../adapt.js"; + +const white = WHITES.D65; +const ε = 216/24389; // 6^3/29^3 == (24/116)^3 +const κ = 24389/27; // 29^3/3^3 + +function toLstar (y) { + // Convert XYZ Y to L* + + const fy = (y > ε) ? Math.cbrt(y) : (κ * y + 16) / 116; + return (116.0 * fy) - 16.0; +} + +function fromLstar (lstar) { + // Convert L* back to XYZ Y + + return (lstar > 8) ? Math.pow((lstar + 16) / 116, 3) : lstar / κ; +} + +function fromHct (coords, env) { + // Use Newton's method to try and converge as quick as possible or + // converge as close as we can. While the requested precision is achieved + // most of the time, it may not always be achievable. Especially past the + // visible spectrum, the algorithm will likely struggle to get the same + // precision. If, for whatever reason, we cannot achieve the accuracy we + // seek in the allotted iterations, just return the closest we were able to + // get. + + let [h, c, t] = coords; + let xyz = []; + let j = 0; + + // Shortcut out for black + if (t === 0) { + return [0.0, 0.0, 0.0]; + } + + // Calculate the Y we need to target + let y = fromLstar(t); + + if (t > 0) { + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; + } + else { + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - -21.928975842194614; + } + + // Threshold of how close is close enough, and max number of attempts. + // More precision and more attempts means more time spent iterating. Higher + // required precision gives more accuracy but also increases the chance of + // not hitting the goal. 2e-12 allows us to convert round trip with + // reasonable accuracy of six decimal places or more. + const threshold = 2e-12; + const max_attempts = 15; + + let attempt = 0; + let last = Infinity; + let best = j; + + // Try to find a J such that the returned y matches the returned y of the L* + while (attempt <= max_attempts) { + xyz = fromCam16({J: j, C: c, h: h}, env); + + // If we are within range, return XYZ + // If we are closer than last time, save the values + const delta = Math.abs(xyz[1] - y) + if (delta < last) { + if (delta <= threshold) { + return xyz; + } + best = j; + last = delta; + } + + // f(j_root) = (j ** (1 / 2)) * 0.1 + // f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + // f(j_root) = Y = y / 100 + // f(j) = (y ** 2) / j - 1 + // f'(j) = (2 * y) / j + j = j - (xyz[1] - y) * j / (2 * xyz[1]); + + attempt += 1; + } + + // We could not acquire the precision we desired, + // return our closest attempt. + return fromCam16({J: j, C: c, h: h}, env); +} + +function toHct (xyz, env) { + // Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue. + + const t = toLstar(xyz[1]); + if (t === 0.0) { + return [0.0, 0.0, 0.0]; + } + const cam16 = toCam16(xyz, viewingConditions); + return [constrain(cam16.h), cam16.C, t]; +} + +// Pre-calculate everything we can with the viewing conditions +export const viewingConditions = environment( + white, 200 / Math.PI * fromLstar(50.0), + fromLstar(50.0) * 100, + 'average', + false +); + +// https://material.io/blog/science-of-color-design +// This is not a port of the material-color-utilities, +// but instead implements the full color space as described, +// combining CAM16 JCh and Lab D65. This does not clamp conversion +// to HCT to specific chroma bands and provides support for wider +// gamuts than Google currently supports and does so at a greater +// precision (> 8 bits back to sRGB). +// This implementation comes from https://github.com/facelessuser/coloraide +// which is licensed under MIT. +export default new ColorSpace({ + id: "hct", + name: "HCT", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + c: { + refRange: [0, 145], + name: "Colorfulness", + }, + t: { + refRange: [0, 100], + name: "Tone", + } + }, + + base: xyz_d65, + + fromBase (xyz) { + return toHct(xyz, viewingConditions); + }, + toBase (hct) { + return fromHct(hct, viewingConditions); + }, + formats: { + color: {} + }, +}); diff --git a/src/spaces/index-fn.js b/src/spaces/index-fn.js index 854fcf69c..47696312f 100644 --- a/src/spaces/index-fn.js +++ b/src/spaces/index-fn.js @@ -20,5 +20,6 @@ export {default as REC_2020} from "./rec2020.js"; export {default as OKLab} from "./oklab.js"; export {default as OKLCH} from "./oklch.js"; export {default as CAM16_JMh} from "./cam16.js"; +export {default as HCT} from "./hct.js"; export * from "./index-fn-hdr.js"; diff --git a/test/conversions.js b/test/conversions.js index 3ea94b687..8ff1c0ad7 100644 --- a/test/conversions.js +++ b/test/conversions.js @@ -582,6 +582,54 @@ const tests = { } ] }, + { + name: "HCT", + data: { + toSpace: "hct", + }, + tests: [ + { + name: "sRGB white to CAM16 JMh", + args: "white", + expect: [209.5429, 2.871589, 100.0] + }, + { + name: "sRGB red to CAM16 JMh", + args: "red", + expect: [27.4098, 113.3564, 53.23712] + }, + { + name: "sRGB lime to CAM16 JMh", + args: "lime", + expect: [142.1404, 108.4065, 87.73552] + }, + { + name: "sRGB blue to CAM16 JMh", + args: "blue", + expect: [282.7622, 87.22804, 32.30087] + }, + { + name: "sRGB cyan to CAM16 JMh", + args: "cyan", + expect: [196.5475, 58.96368, 91.11475] + }, + { + name: "sRGB magenta to CAM16 JMh", + args: "magenta", + expect: [334.6332, 107.3899, 60.32273] + }, + { + name: "sRGB yellow to CAM16 JMh", + args: "yellow", + expect: [111.0456, 75.50438, 97.13856] + }, + { + name: "sRGB black to CAM16 JMh", + args: "black", + expect: [0.0, 0.0, 0.0] + } + ] + }, { name: "Get coordinates", data: { From 86312ac73c592f1bb213216ce6add7ed487c6530 Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 19 Dec 2023 11:49:42 -0700 Subject: [PATCH 2/5] Comment on the HCT polynomials --- src/spaces/hct.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spaces/hct.js b/src/spaces/hct.js index 8f3a998c6..dd21ddb20 100644 --- a/src/spaces/hct.js +++ b/src/spaces/hct.js @@ -42,6 +42,8 @@ function fromHct (coords, env) { // Calculate the Y we need to target let y = fromLstar(t); + // A better initial guess yields better results. Polynomials come from + // curve fitting the T vs J response. if (t > 0) { j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; } From 2e95b698a3959746039719b6fa85c15ce62d852a Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 19 Dec 2023 18:39:23 -0700 Subject: [PATCH 3/5] Fix eslint issues --- src/spaces/hct.js | 234 +++++++++++++++++++++++----------------------- 1 file changed, 117 insertions(+), 117 deletions(-) diff --git a/src/spaces/hct.js b/src/spaces/hct.js index dd21ddb20..cd38a70b1 100644 --- a/src/spaces/hct.js +++ b/src/spaces/hct.js @@ -5,111 +5,111 @@ import {fromCam16, toCam16, environment} from "./cam16.js"; import {WHITES} from "../adapt.js"; const white = WHITES.D65; -const ε = 216/24389; // 6^3/29^3 == (24/116)^3 -const κ = 24389/27; // 29^3/3^3 +const ε = 216 / 24389; // 6^3/29^3 == (24/116)^3 +const κ = 24389 / 27; // 29^3/3^3 function toLstar (y) { - // Convert XYZ Y to L* + // Convert XYZ Y to L* - const fy = (y > ε) ? Math.cbrt(y) : (κ * y + 16) / 116; - return (116.0 * fy) - 16.0; + const fy = (y > ε) ? Math.cbrt(y) : (κ * y + 16) / 116; + return (116.0 * fy) - 16.0; } function fromLstar (lstar) { - // Convert L* back to XYZ Y + // Convert L* back to XYZ Y - return (lstar > 8) ? Math.pow((lstar + 16) / 116, 3) : lstar / κ; + return (lstar > 8) ? Math.pow((lstar + 16) / 116, 3) : lstar / κ; } function fromHct (coords, env) { - // Use Newton's method to try and converge as quick as possible or - // converge as close as we can. While the requested precision is achieved - // most of the time, it may not always be achievable. Especially past the - // visible spectrum, the algorithm will likely struggle to get the same - // precision. If, for whatever reason, we cannot achieve the accuracy we - // seek in the allotted iterations, just return the closest we were able to - // get. - - let [h, c, t] = coords; - let xyz = []; - let j = 0; - - // Shortcut out for black - if (t === 0) { - return [0.0, 0.0, 0.0]; - } - - // Calculate the Y we need to target - let y = fromLstar(t); - - // A better initial guess yields better results. Polynomials come from - // curve fitting the T vs J response. - if (t > 0) { - j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; - } - else { - j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - -21.928975842194614; - } - - // Threshold of how close is close enough, and max number of attempts. - // More precision and more attempts means more time spent iterating. Higher - // required precision gives more accuracy but also increases the chance of - // not hitting the goal. 2e-12 allows us to convert round trip with - // reasonable accuracy of six decimal places or more. - const threshold = 2e-12; - const max_attempts = 15; - - let attempt = 0; - let last = Infinity; - let best = j; - - // Try to find a J such that the returned y matches the returned y of the L* - while (attempt <= max_attempts) { - xyz = fromCam16({J: j, C: c, h: h}, env); - - // If we are within range, return XYZ - // If we are closer than last time, save the values - const delta = Math.abs(xyz[1] - y) - if (delta < last) { - if (delta <= threshold) { - return xyz; - } - best = j; - last = delta; - } - - // f(j_root) = (j ** (1 / 2)) * 0.1 - // f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 - // f(j_root) = Y = y / 100 - // f(j) = (y ** 2) / j - 1 - // f'(j) = (2 * y) / j - j = j - (xyz[1] - y) * j / (2 * xyz[1]); - - attempt += 1; - } - - // We could not acquire the precision we desired, - // return our closest attempt. - return fromCam16({J: j, C: c, h: h}, env); + // Use Newton's method to try and converge as quick as possible or + // converge as close as we can. While the requested precision is achieved + // most of the time, it may not always be achievable. Especially past the + // visible spectrum, the algorithm will likely struggle to get the same + // precision. If, for whatever reason, we cannot achieve the accuracy we + // seek in the allotted iterations, just return the closest we were able to + // get. + + let [h, c, t] = coords; + let xyz = []; + let j = 0; + + // Shortcut out for black + if (t === 0) { + return [0.0, 0.0, 0.0]; + } + + // Calculate the Y we need to target + let y = fromLstar(t); + + // A better initial guess yields better results. Polynomials come from + // curve fitting the T vs J response. + if (t > 0) { + j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; + } + else { + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - -21.928975842194614; + } + + // Threshold of how close is close enough, and max number of attempts. + // More precision and more attempts means more time spent iterating. Higher + // required precision gives more accuracy but also increases the chance of + // not hitting the goal. 2e-12 allows us to convert round trip with + // reasonable accuracy of six decimal places or more. + const threshold = 2e-12; + const max_attempts = 15; + + let attempt = 0; + let last = Infinity; + let best = j; + + // Try to find a J such that the returned y matches the returned y of the L* + while (attempt <= max_attempts) { + xyz = fromCam16({J: j, C: c, h: h}, env); + + // If we are within range, return XYZ + // If we are closer than last time, save the values + const delta = Math.abs(xyz[1] - y); + if (delta < last) { + if (delta <= threshold) { + return xyz; + } + best = j; + last = delta; + } + + // f(j_root) = (j ** (1 / 2)) * 0.1 + // f(j) = ((f(j_root) * 100) ** 2) / j - 1 = 0 + // f(j_root) = Y = y / 100 + // f(j) = (y ** 2) / j - 1 + // f'(j) = (2 * y) / j + j = j - (xyz[1] - y) * j / (2 * xyz[1]); + + attempt += 1; + } + + // We could not acquire the precision we desired, + // return our closest attempt. + return fromCam16({J: j, C: c, h: h}, env); } function toHct (xyz, env) { - // Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue. - - const t = toLstar(xyz[1]); - if (t === 0.0) { - return [0.0, 0.0, 0.0]; - } - const cam16 = toCam16(xyz, viewingConditions); - return [constrain(cam16.h), cam16.C, t]; + // Calculate HCT by taking the L* of CIE LCh D65 and CAM16 chroma and hue. + + const t = toLstar(xyz[1]); + if (t === 0.0) { + return [0.0, 0.0, 0.0]; + } + const cam16 = toCam16(xyz, viewingConditions); + return [constrain(cam16.h), cam16.C, t]; } // Pre-calculate everything we can with the viewing conditions export const viewingConditions = environment( - white, 200 / Math.PI * fromLstar(50.0), - fromLstar(50.0) * 100, - 'average', - false + white, 200 / Math.PI * fromLstar(50.0), + fromLstar(50.0) * 100, + "average", + false ); // https://material.io/blog/science-of-color-design @@ -122,33 +122,33 @@ export const viewingConditions = environment( // This implementation comes from https://github.com/facelessuser/coloraide // which is licensed under MIT. export default new ColorSpace({ - id: "hct", - name: "HCT", - coords: { - h: { - refRange: [0, 360], - type: "angle", - name: "Hue", - }, - c: { - refRange: [0, 145], - name: "Colorfulness", - }, - t: { - refRange: [0, 100], - name: "Tone", - } - }, - - base: xyz_d65, - - fromBase (xyz) { - return toHct(xyz, viewingConditions); - }, - toBase (hct) { - return fromHct(hct, viewingConditions); - }, - formats: { - color: {} - }, + id: "hct", + name: "HCT", + coords: { + h: { + refRange: [0, 360], + type: "angle", + name: "Hue", + }, + c: { + refRange: [0, 145], + name: "Colorfulness", + }, + t: { + refRange: [0, 100], + name: "Tone", + } + }, + + base: xyz_d65, + + fromBase (xyz) { + return toHct(xyz, viewingConditions); + }, + toBase (hct) { + return fromHct(hct, viewingConditions); + }, + formats: { + color: {} + }, }); From 28db2e58e3d891b5dbc28f488feee3375fb68d6a Mon Sep 17 00:00:00 2001 From: facelessuser Date: Thu, 4 Jan 2024 10:56:19 -0700 Subject: [PATCH 4/5] Fix double minus sign --- src/spaces/hct.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spaces/hct.js b/src/spaces/hct.js index cd38a70b1..53d313477 100644 --- a/src/spaces/hct.js +++ b/src/spaces/hct.js @@ -48,7 +48,7 @@ function fromHct (coords, env) { j = 0.00379058511492914 * t ** 2 + 0.608983189401032 * t + 0.9155088574762233; } else { - j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - -21.928975842194614; + j = 9.514440756550361e-06 * t ** 2 + 0.08693057439788597 * t - 21.928975842194614; } // Threshold of how close is close enough, and max number of attempts. From 516d45011704eb319f858f8a5b11811222bee52d Mon Sep 17 00:00:00 2001 From: facelessuser Date: Tue, 16 Jan 2024 07:22:55 -0700 Subject: [PATCH 5/5] Fix test names --- test/conversions.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/conversions.js b/test/conversions.js index 8ff1c0ad7..606327304 100644 --- a/test/conversions.js +++ b/test/conversions.js @@ -589,42 +589,42 @@ const tests = { }, tests: [ { - name: "sRGB white to CAM16 JMh", + name: "sRGB white to HCT", args: "white", expect: [209.5429, 2.871589, 100.0] }, { - name: "sRGB red to CAM16 JMh", + name: "sRGB red to HCT", args: "red", expect: [27.4098, 113.3564, 53.23712] }, { - name: "sRGB lime to CAM16 JMh", + name: "sRGB lime to HCT", args: "lime", expect: [142.1404, 108.4065, 87.73552] }, { - name: "sRGB blue to CAM16 JMh", + name: "sRGB blue to HCT", args: "blue", expect: [282.7622, 87.22804, 32.30087] }, { - name: "sRGB cyan to CAM16 JMh", + name: "sRGB cyan to HCT", args: "cyan", expect: [196.5475, 58.96368, 91.11475] }, { - name: "sRGB magenta to CAM16 JMh", + name: "sRGB magenta to HCT", args: "magenta", expect: [334.6332, 107.3899, 60.32273] }, { - name: "sRGB yellow to CAM16 JMh", + name: "sRGB yellow to HCT", args: "yellow", expect: [111.0456, 75.50438, 97.13856] }, { - name: "sRGB black to CAM16 JMh", + name: "sRGB black to HCT", args: "black", expect: [0.0, 0.0, 0.0] }