Source

okhsl.js

import { vec3, constrainAngle as constrain } from "./util.js";
import { OKLab_to } from "./core.js";
import { sRGBGamut } from "./spaces.js";
import {
  findCuspOKLCH,
  findGamutIntersectionOKLCH,
  getGamutLMStoRGB,
} from "./gamut.js";

const K1 = 0.206;
const K2 = 0.03;
const K3 = (1.0 + K1) / (1.0 + K2);

const tmp2A = [0, 0];
const tmp2B = [0, 0];
const tmp3A = vec3();
const tmp2Cusp = [0, 0];

const tau = 2 * Math.PI;

const copySign = (to, from) => (Math.sign(to) === Math.sign(from) ? to : -to);

const spow = (base, exp) => copySign(Math.abs(base) ** exp, base);

const toe = (x) =>
  0.5 *
  (K3 * x - K1 + Math.sqrt((K3 * x - K1) * (K3 * x - K1) + 4 * K2 * K3 * x));

const toeInv = (x) => (x ** 2 + K1 * x) / (K3 * (x + K2));

const computeSt = (cusp, out) => {
  // To ST.
  let l = cusp[0];
  let c = cusp[1];
  out[0] = c / l;
  out[1] = c / (1 - l);
};

const toScaleL = (lv, cv, a_, b_, lmsToRgb) => {
  let lvt = toeInv(lv);
  let cvt = (cv * lvt) / lv;

  // RGB scale
  tmp3A[0] = lvt;
  tmp3A[1] = a_ * cvt;
  tmp3A[2] = b_ * cvt;
  let ret = OKLab_to(tmp3A, lmsToRgb, tmp3A);
  return spow(
    1.0 / Math.max(Math.max(ret[0], ret[1]), Math.max(ret[2], 0.0)),
    1 / 3
  );
};

const computeStMid = (a, b, out) => {
  // 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.

  let s =
    0.11516993 +
    1.0 /
      (7.4477897 +
        4.1590124 * b +
        a *
          (-2.19557347 +
            1.75198401 * b +
            a *
              (-2.13704948 -
                10.02301043 * b +
                a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));

  let t =
    0.11239642 +
    1.0 /
      (1.6132032 -
        0.68124379 * b +
        a *
          (0.40370612 +
            0.90148123 * b +
            a *
              (-0.27087943 +
                0.6122399 * b +
                a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));

  out[0] = s;
  out[1] = t;
};

const getCs = (l, a, b, cusp, gamut) => {
  // Get Cs
  let cMax = findGamutIntersectionOKLCH(a, b, l, 1, l, cusp, gamut);
  let stMax = tmp2A;
  computeSt(cusp, stMax);

  // Scale factor to compensate for the curved part of gamut shape:
  let k = cMax / Math.min(l * stMax[0], (1 - l) * stMax[1]);

  const stMid = tmp2B;
  computeStMid(a, b, stMid);

  // 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];
};

/**
 * Converts OKHSL color to OKLab color.
 *
 * @method
 * @category oklab
 * @param {Vector} hsl - The OKHSL color as an array [h, s, l].
 * @param {ColorGamut} [gamut=sRGBGamut] - The color gamut.
 * @param {Vector} [out=vec3()] - The output array to store the OKLab color.
 * @returns {Vector} The OKLab color as an array [L, a, b].
 */
export const OKHSLToOKLab = (hsl, gamut = sRGBGamut, out = vec3()) => {
  // Convert Okhsl to Oklab.
  let h = hsl[0],
    s = hsl[1],
    l = hsl[2];
  let L = toeInv(l);
  let a = 0;
  let b = 0;
  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);

    const cusp = findCuspOKLCH(a_, b_, gamut, tmp2Cusp);
    let Cs = getCs(L, a_, b_, cusp, gamut);
    let c0 = Cs[0],
      cMid = Cs[1],
      cMax = Cs[2];

    // 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_;
  }

  out[0] = L;
  out[1] = a;
  out[2] = b;
  return out;
};

/**
 * Converts OKLab color to OKHSL color.
 *
 * @method
 * @category oklab
 * @param {Vector} lab - The OKLab color as an array [L, a, b].
 * @param {ColorGamut} [gamut=sRGBGamut] - The color gamut.
 * @param {Vector} [out=vec3()] - The output array to store the OKHSL color.
 * @returns {Vector} The OKHSL color as an array [h, s, l].
 */
export const OKLabToOKHSL = (lab, gamut = sRGBGamut, out = vec3()) => {
  // 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;

    const cusp = findCuspOKLCH(a_, b_, gamut, tmp2Cusp);
    let Cs = getCs(L, a_, b_, cusp, gamut);
    let c0 = Cs[0],
      cMid = Cs[1],
      cMax = Cs[2];

    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) {
    // 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;
    }
  }

  h = constrain(h * 360);

  out[0] = h;
  out[1] = s;
  out[2] = l;
  return out;
};

/**
 * Converts OKHSV color to OKLab color.
 *
 * @method
 * @category oklab
 * @param {Vector} hsv - The OKHSV color as an array [h, s, v].
 * @param {ColorGamut} [gamut=sRGBGamut] - The color gamut.
 * @param {Vector} [out=vec3()] - The output array to store the OKLab color.
 * @returns {Vector} The OKLab color as an array [L, a, b].
 */
export const OKHSVToOKLab = (hsv, gamut = sRGBGamut, out = vec3()) => {
  // Convert from Okhsv to Oklab."""

  let h = hsv[0],
    s = hsv[1],
    v = hsv[2];
  h = constrain(h) / 360.0;

  let l = toeInv(v);
  let a = 0;
  let b = 0;

  // Avoid processing gray or colors with undefined hues
  if (l !== 0.0 && s !== 0.0) {
    let a_ = Math.cos(tau * h);
    let b_ = Math.sin(tau * h);

    const lmsToRgb = getGamutLMStoRGB(gamut);
    const cusp = findCuspOKLCH(a_, b_, gamut, tmp2Cusp);
    computeSt(cusp, tmp2A);
    const sMax = tmp2A[0];
    const tMax = tmp2A[1];
    let s0 = 0.5;
    let k = 1 - s0 / sMax;

    // first we compute L and V as if the gamut is a perfect triangle:

    // L, C when v==1:
    let lv = 1 - (s * s0) / (s0 + tMax - tMax * k * s);
    let cv = (s * tMax * s0) / (s0 + tMax - tMax * k * s);

    l = v * lv;
    let c = v * cv;

    // then we compensate for both toe and the curved top part of the triangle:
    const scaleL = toScaleL(lv, cv, a_, b_, lmsToRgb);

    let lNew = toeInv(l);
    c = (c * lNew) / l;
    l = lNew;

    l = l * scaleL;
    c = c * scaleL;

    a = c * a_;
    b = c * b_;
  }

  out[0] = l;
  out[1] = a;
  out[2] = b;
  return out;
};

/**
 * Converts OKLab color to OKHSV color.
 *
 * @method
 * @category oklab
 * @param {Vector} lab The OKLab color as an array [L, a, b].
 * @param {ColorGamut} [gamut=sRGBGamut] The color gamut.
 * @param {Vector} [out=vec3()] The output array to store the OKHSV color.
 * @returns {Vector} The OKHSV color as an array [h, s, v].
 */
export const OKLabToOKHSV = (lab, gamut = sRGBGamut, out = vec3()) => {
  // Oklab to Okhsv.
  const lmsToRgb = getGamutLMStoRGB(gamut);

  // Epsilon for saturation just needs to be sufficiently close when denoting achromatic
  let ε = 1e-4;
  let l = lab[0];
  let s = 0.0;
  let v = 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 && c !== 0.0) {
    let a_ = lab[1] / c;
    let b_ = lab[2] / c;

    const cusp = findCuspOKLCH(a_, b_, gamut, tmp2Cusp);
    computeSt(cusp, tmp2A);
    const sMax = tmp2A[0];
    const tMax = tmp2A[1];

    let s0 = 0.5;
    let k = 1 - s0 / sMax;

    // first we find `L_v`, `C_v`, `L_vt` and `C_vt`
    let t = tMax / (c + l * tMax);
    let lv = t * l;
    let cv = t * c;

    const scaleL = toScaleL(lv, cv, a_, b_, lmsToRgb);

    l = l / scaleL;
    c = c / scaleL;

    const toeL = toe(l);
    c = (c * toeL) / l;
    l = toeL;

    // we can now compute v and s:
    v = l / lv;
    s = ((s0 + tMax) * cv) / (tMax * s0 + tMax * k * cv);
  }

  // unlike colorjs.io, we are not worknig with none-types
  // if (Math.abs(s) < ε || v === 0.0) {
  //   h = null;
  // }

  h = constrain(h * 360);

  out[0] = h;
  out[1] = s;
  out[2] = v;
  return out;
};