UNPKG

colorjs.io

Version:

Let’s get serious about color

550 lines (449 loc) 13.5 kB
// Okhsl class. // // ---- License ---- // // 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. import ColorSpace from "../ColorSpace.js"; import Oklab from "./oklab.js"; import { LabtoLMS_M } from "./oklab.js"; import { spow, multiply_v3_m3x3 } from "../util.js"; import { constrain } from "../angles.js"; /** @import { Matrix3x3, Vector3 } from "../types.js" */ // Type re-exports /** @typedef {import("../types.js").OKCoeff} OKCoeff */ export const tau = 2 * Math.PI; /** @type {Matrix3x3} */ // prettier-ignore export const toLMS = [ [0.4122214694707629, 0.5363325372617349, 0.0514459932675022], [0.2119034958178251, 0.6806995506452344, 0.1073969535369405], [0.0883024591900564, 0.2817188391361215, 0.6299787016738222], ]; /** @type {Matrix3x3} */ // prettier-ignore export const toSRGBLinear = [ [ 4.0767416360759583, -3.3077115392580629, 0.2309699031821043], [-1.2684379732850315, 2.6097573492876882, -0.3413193760026570], [-0.0041960761386756, -0.7034186179359362, 1.7076146940746117], ]; /** @type {OKCoeff} */ export const RGBCoeff = [ // Red [ // Limit [-1.8817031, -0.80936501], // `Kn` coefficients [1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245], ], // Green [ // Limit [1.8144408, -1.19445267], // `Kn` coefficients [0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204], ], // Blue [ // Limit [0.13110758, 1.81333971], // `Kn` coefficients [1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167], ], ]; const floatMax = Number.MAX_VALUE; const K1 = 0.206; const K2 = 0.03; const K3 = (1.0 + K1) / (1.0 + K2); function vdot (a, b) { // Dot two vectors let l = a.length; if (l !== b.length) { throw new Error(`Vectors of size ${l} and ${b.length} are not aligned`); } let s = 0.0; a.forEach((c, i) => { s += c * b[i]; }); return s; } /** * Toe function for L_r * @param {number} x */ export function toe (x) { return 0.5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x)); } /** * Inverse toe function for L_r * @param {number} x */ export function toeInv (x) { return (x ** 2 + K1 * x) / (K3 * (x + K2)); } /** * @param {readonly [number, number]} cusp * @returns {[number, number]} */ export function toSt (cusp) { // To ST. let [l, c] = cusp; return [c / l, c / (1 - l)]; } function getStMid (a, b) { // Returns a smooth approximation of the location of the cusp. // // This polynomial was created by an optimization process. // It has been designed so that S_mid < S_max and T_mid < T_max. // prettier-ignore let s = 0.11516993 + 1.0 / ( 7.44778970 + 4.15901240 * b + a * ( -2.19557347 + 1.75198401 * b + a * ( -2.13704948 - 10.02301043 * b + a * ( -4.24894561 + 5.38770819 * b + 4.69891013 * a ) ) ) ); // prettier-ignore let t = 0.11239642 + 1.0 / ( 1.61320320 - 0.68124379 * b + a * ( 0.40370612 + 0.90148123 * b + a * ( -0.27087943 + 0.61223990 * b + a * ( 0.00299215 - 0.45399568 * b - 0.14661872 * a ) ) ) ); return [s, t]; } /** * @param {Vector3} lab * @param {Matrix3x3} lmsToRgb */ export function oklabToLinearRGB (lab, lmsToRgb) { // 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 lms = multiply_v3_m3x3(lab, LabtoLMS_M); lms[0] = lms[0] ** 3; lms[1] = lms[1] ** 3; lms[2] = lms[2] ** 3; return multiply_v3_m3x3(lms, lmsToRgb, lms); } /** * @param {number} a * @param {number} b * @param {Matrix3x3} lmsToRgb * @param {OKCoeff} okCoeff * @returns {[number, number]} * @todo Could probably make these types more specific/better-documented if desired */ export function findCusp (a, b, lmsToRgb, okCoeff) { // Finds L_cusp and C_cusp for a given hue. // // `a` and `b` must be normalized so `a^2 + b^2 == 1`. // First, find the maximum saturation (saturation `S = C/L`) let sCusp = computeMaxSaturation(a, b, lmsToRgb, okCoeff); // Convert to linear RGB to find the first point where at least one of r, g or b >= 1: let rgb = oklabToLinearRGB([1, sCusp * a, sCusp * b], lmsToRgb); let lCusp = spow(1.0 / Math.max(...rgb), 1 / 3); let cCusp = lCusp * sCusp; return [lCusp, cCusp]; } /** * @param {number} a * @param {number} b * @param {number} l1 * @param {number} c1 * @param {number} l0 * @param {Matrix3x3} lmsToRgb * @param {OKCoeff} okCoeff * @param {[number, number]} cusp * @returns {Number} * @todo Could probably make these types more specific/better-documented if desired */ export function findGamutIntersection (a, b, l1, c1, l0, lmsToRgb, okCoeff, cusp) { // Finds intersection of the line. // // Defined by the following: // // ``` // L = L0 * (1 - t) + t * L1 // C = t * C1 // ``` // // `a` and `b` must be normalized so `a^2 + b^2 == 1`. let t; if (cusp === undefined) { cusp = findCusp(a, b, lmsToRgb, okCoeff); } // Find the intersection for upper and lower half separately if ((l1 - l0) * cusp[1] - (cusp[0] - l0) * c1 <= 0.0) { // Lower half t = (cusp[1] * l0) / (c1 * cusp[0] + cusp[1] * (l0 - l1)); } else { // Upper half // First intersect with triangle t = (cusp[1] * (l0 - 1.0)) / (c1 * (cusp[0] - 1.0) + cusp[1] * (l0 - l1)); // Then one step Halley's method let dl = l1 - l0; let dc = c1; let kl = vdot(LabtoLMS_M[0].slice(1), [a, b]); let km = vdot(LabtoLMS_M[1].slice(1), [a, b]); let ks = vdot(LabtoLMS_M[2].slice(1), [a, b]); let ldt_ = dl + dc * kl; let mdt_ = dl + dc * km; let sdt_ = dl + dc * ks; // If higher accuracy is required, 2 or 3 iterations of the following block can be used: let L = l0 * (1.0 - t) + t * l1; let C = t * c1; let l_ = L + C * kl; let m_ = L + C * km; let s_ = L + C * ks; let l = l_ ** 3; let m = m_ ** 3; let s = s_ ** 3; let ldt = 3 * ldt_ * l_ ** 2; let mdt = 3 * mdt_ * m_ ** 2; let sdt = 3 * sdt_ * s_ ** 2; let ldt2 = 6 * ldt_ ** 2 * l_; let mdt2 = 6 * mdt_ ** 2 * m_; let sdt2 = 6 * sdt_ ** 2 * s_; let r_ = vdot(lmsToRgb[0], [l, m, s]) - 1; let r1 = vdot(lmsToRgb[0], [ldt, mdt, sdt]); let r2 = vdot(lmsToRgb[0], [ldt2, mdt2, sdt2]); let ur = r1 / (r1 * r1 - 0.5 * r_ * r2); let tr = -r_ * ur; let g_ = vdot(lmsToRgb[1], [l, m, s]) - 1; let g1 = vdot(lmsToRgb[1], [ldt, mdt, sdt]); let g2 = vdot(lmsToRgb[1], [ldt2, mdt2, sdt2]); let ug = g1 / (g1 * g1 - 0.5 * g_ * g2); let tg = -g_ * ug; let b_ = vdot(lmsToRgb[2], [l, m, s]) - 1; let b1 = vdot(lmsToRgb[2], [ldt, mdt, sdt]); let b2 = vdot(lmsToRgb[2], [ldt2, mdt2, sdt2]); let ub = b1 / (b1 * b1 - 0.5 * b_ * b2); let tb = -b_ * ub; tr = ur >= 0.0 ? tr : floatMax; tg = ug >= 0.0 ? tg : floatMax; tb = ub >= 0.0 ? tb : floatMax; t += Math.min(tr, Math.min(tg, tb)); } return t; } function getCs (lab, lmsToRgb, okCoeff) { // Get Cs let [l, a, b] = lab; let cusp = findCusp(a, b, lmsToRgb, okCoeff); let cMax = findGamutIntersection(a, b, l, 1, l, lmsToRgb, okCoeff, cusp); let stMax = toSt(cusp); // Scale factor to compensate for the curved part of gamut shape: let k = cMax / Math.min(l * stMax[0], (1 - l) * stMax[1]); let stMid = getStMid(a, b); // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma. let ca = l * stMid[0]; let cb = (1.0 - l) * stMid[1]; let cMid = 0.9 * k * Math.sqrt(Math.sqrt(1.0 / (1.0 / ca ** 4 + 1.0 / cb ** 4))); // For `C_0`, the shape is independent of hue, so `ST` are constant. // Values picked to roughly be the average values of `ST`. ca = l * 0.4; cb = (1.0 - l) * 0.8; // Use a soft minimum function, instead of a sharp triangle shape to get a smooth value for chroma. let c0 = Math.sqrt(1.0 / (1.0 / ca ** 2 + 1.0 / cb ** 2)); return [c0, cMid, cMax]; } function computeMaxSaturation (a, b, lmsToRgb, okCoeff) { // Finds the maximum saturation possible for a given hue that fits in RGB. // // Saturation here is defined as `S = C/L`. // `a` and `b` must be normalized so `a^2 + b^2 == 1`. // Max saturation will be when one of r, g or b goes below zero. // Select different coefficients depending on which component goes below zero first. let k0, k1, k2, k3, k4, wl, wm, ws; if (vdot(okCoeff[0][0], [a, b]) > 1) { // Red component [k0, k1, k2, k3, k4] = okCoeff[0][1]; [wl, wm, ws] = lmsToRgb[0]; } else if (vdot(okCoeff[1][0], [a, b]) > 1) { // Green component [k0, k1, k2, k3, k4] = okCoeff[1][1]; [wl, wm, ws] = lmsToRgb[1]; } else { // Blue component [k0, k1, k2, k3, k4] = okCoeff[2][1]; [wl, wm, ws] = lmsToRgb[2]; } // Approximate max saturation using a polynomial: let sat = k0 + k1 * a + k2 * b + k3 * a ** 2 + k4 * a * b; // Do one step Halley's method to get closer. // This gives an error less than 10e6, except for some blue hues where the `dS/dh` is close to infinite. // This should be sufficient for most applications, otherwise do two/three steps. let kl = vdot(LabtoLMS_M[0].slice(1), [a, b]); let km = vdot(LabtoLMS_M[1].slice(1), [a, b]); let ks = vdot(LabtoLMS_M[2].slice(1), [a, b]); let l_ = 1.0 + sat * kl; let m_ = 1.0 + sat * km; let s_ = 1.0 + sat * ks; let l = l_ ** 3; let m = m_ ** 3; let s = s_ ** 3; let lds = 3.0 * kl * l_ ** 2; let mds = 3.0 * km * m_ ** 2; let sds = 3.0 * ks * s_ ** 2; let lds2 = 6.0 * kl ** 2 * l_; let mds2 = 6.0 * km ** 2 * m_; let sds2 = 6.0 * ks ** 2 * s_; let f = wl * l + wm * m + ws * s; let f1 = wl * lds + wm * mds + ws * sds; let f2 = wl * lds2 + wm * mds2 + ws * sds2; sat = sat - (f * f1) / (f1 ** 2 - 0.5 * f * f2); return sat; } function okhslToOklab (hsl, lmsToRgb, okCoeff) { // Convert Okhsl to Oklab. let [h, s, l] = hsl; let L = toeInv(l); let a = null; let b = null; h = constrain(h) / 360.0; if (L !== 0.0 && L !== 1.0 && s !== 0) { let a_ = Math.cos(tau * h); let b_ = Math.sin(tau * h); let [c0, cMid, cMax] = getCs([L, a_, b_], lmsToRgb, okCoeff); // Interpolate the three values for C so that: // ``` // At s=0: dC/ds = C_0, C=0 // At s=0.8: C=C_mid // At s=1.0: C=C_max // ``` let mid = 0.8; let midInv = 1.25; let t, k0, k1, k2; if (s < mid) { t = midInv * s; k0 = 0.0; k1 = mid * c0; k2 = 1.0 - k1 / cMid; } else { t = 5 * (s - 0.8); k0 = cMid; k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0; k2 = 1.0 - k1 / (cMax - cMid); } let c = k0 + (t * k1) / (1.0 - k2 * t); a = c * a_; b = c * b_; } return [L, a, b]; } function oklabToOkhsl (lab, lmsToRgb, okCoeff) { // Oklab to Okhsl. // Epsilon for lightness should approach close to 32 bit lightness // Epsilon for saturation just needs to be sufficiently close when denoting achromatic let εL = 1e-7; let εS = 1e-4; let L = lab[0]; let s = 0.0; let l = toe(L); let c = Math.sqrt(lab[1] ** 2 + lab[2] ** 2); let h = 0.5 + Math.atan2(-lab[2], -lab[1]) / tau; if (l !== 0.0 && l !== 1.0 && c !== 0) { let a_ = lab[1] / c; let b_ = lab[2] / c; let [c0, cMid, cMax] = getCs([L, a_, b_], lmsToRgb, okCoeff); let mid = 0.8; let midInv = 1.25; let k0, k1, k2, t; if (c < cMid) { k1 = mid * c0; k2 = 1.0 - k1 / cMid; t = c / (k1 + k2 * c); s = t * mid; } else { k0 = cMid; k1 = (0.2 * cMid ** 2 * midInv ** 2) / c0; k2 = 1.0 - k1 / (cMax - cMid); t = (c - k0) / (k1 + k2 * (c - k0)); s = mid + 0.2 * t; } } const achromatic = Math.abs(s) < εS; if (achromatic || l === 0.0 || Math.abs(1 - l) < εL) { h = null; // Due to floating point imprecision near lightness of 1, we can end up // with really high around white, this is to provide consistency as // saturation can be really high for white due this imprecision. if (!achromatic) { s = 0.0; } } else { h = constrain(h * 360); } return [h, s, l]; } export default new ColorSpace({ id: "okhsl", name: "Okhsl", coords: { h: { refRange: [0, 360], type: "angle", name: "Hue", }, s: { range: [0, 1], name: "Saturation", }, l: { range: [0, 1], name: "Lightness", }, }, base: Oklab, gamutSpace: "self", // Convert Oklab to Okhsl fromBase (lab) { return oklabToOkhsl(lab, toSRGBLinear, RGBCoeff); }, // Convert Okhsl to Oklab toBase (hsl) { return okhslToOklab(hsl, toSRGBLinear, RGBCoeff); }, formats: { color: { id: "--okhsl", coords: ["<number> | <angle>", "<percentage> | <number>", "<percentage> | <number>"], }, }, });