Chroma value varies for different hues, and I want to understand how I can find the maximum value that is displayable for the given hue and lightness programmatically.
I saw a couple of color pickers that can limit the selection visually, but I don't have access to their code, but anyway, I think it should be possible somehow.
Björn, who invented Oklab and Oklch, has published an article about gamut clipping using Oklch. In there you will find code that finds the bounds of sRGB in Oklch. The function gamut_clip_preserve_chroma
should do pretty much what you want, so long as you strip off the RGB conversion steps at the beginning and end, and pretend that you have a ridiculously large C value — 1.0 will do.
I happen to need the same thing (well, a very similar thing: I want to make an RGB color as saturated as possible), so here's the crude TypeScript translation (yes it works):
// This is just https://bottosson.github.io/posts/gamutclipping/ crudely translated to TS
type Lab = { L: number; a: number; b: number };
type RGB = { r: number; g: number; b: number };
type sRGB = { r: number; g: number; b: number };
function linear_srgb_to_oklab(c: RGB): Lab {
const l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
const m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
const s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
};
}
function oklab_to_linear_srgb(c: Lab): RGB {
const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b;
const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b;
const s_ = c.L - 0.0894841775 * c.a - 1.2914855480 * c.b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
};
}
function srgb_to_linear_rgb(c: sRGB): RGB {
return {
r: c.r <= 0.04045 ? c.r / 12.92 : Math.pow((c.r + 0.055) / 1.055, 2.4),
g: c.g <= 0.04045 ? c.g / 12.92 : Math.pow((c.g + 0.055) / 1.055, 2.4),
b: c.b <= 0.04045 ? c.b / 12.92 : Math.pow((c.b + 0.055) / 1.055, 2.4),
};
}
function linear_rgb_to_srgb(c: RGB): sRGB {
return {
r: c.r <= 0.0031308 ? 12.92 * c.r : 1.055 * Math.pow(c.r, 1 / 2.4) - 0.055,
g: c.g <= 0.0031308 ? 12.92 * c.g : 1.055 * Math.pow(c.g, 1 / 2.4) - 0.055,
b: c.b <= 0.0031308 ? 12.92 * c.b : 1.055 * Math.pow(c.b, 1 / 2.4) - 0.055,
};
}
function rgb_as_hex(c: sRGB): string {
return "#" + [c.r, c.g, c.b].map(x => Math.round(x * 255).toString(16).padStart(2, "0")).join("");
}
function linear_rgb_as_hex(c: RGB): string {
return rgb_as_hex(linear_rgb_to_srgb(c));
}
type LC = { L: number; C: number };
function compute_max_saturation(a: number, b: number): number {
let k0, k1, k2, k3, k4, wl, wm, ws;
if (-1.88170328 * a - 0.80936493 * b > 1) {
k0 = +1.19086277;
k1 = +1.76576728;
k2 = +0.59662641;
k3 = +0.75515197;
k4 = +0.56771245;
wl = +4.0767416621;
wm = -3.3077115913;
ws = +0.2309699292;
} else if (1.81444104 * a - 1.19445276 * b > 1) {
k0 = +0.73956515;
k1 = -0.45954404;
k2 = +0.08285427;
k3 = +0.12541070;
k4 = +0.14503204;
wl = -1.2684380046;
wm = +2.6097574011;
ws = -0.3413193965;
} else {
k0 = +1.35733652;
k1 = -0.00915799;
k2 = -1.15130210;
k3 = -0.50559606;
k4 = +0.00692167;
wl = -0.0041960863;
wm = -0.7034186147;
ws = +1.7076147010;
}
let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
let k_l = +0.3963377774 * a + 0.2158037573 * b;
let k_m = -0.1055613458 * a - 0.0638541728 * b;
let k_s = -0.0894841775 * a - 1.2914855480 * b;
{
let l_ = 1 + S * k_l;
let m_ = 1 + S * k_m;
let s_ = 1 + S * k_s;
let l = l_ * l_ * l_;
let m = m_ * m_ * m_;
let s = s_ * s_ * s_;
let l_dS = 3 * k_l * l_ * l_;
let m_dS = 3 * k_m * m_ * m_;
let s_dS = 3 * k_s * s_ * s_;
let l_dS2 = 6 * k_l * k_l * l_;
let m_dS2 = 6 * k_m * k_m * m_;
let s_dS2 = 6 * k_s * k_s * s_;
let f = wl * l + wm * m + ws * s;
let f1 = wl * l_dS + wm * m_dS + ws * s_dS;
let f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
S = S - f * f1 / (f1 * f1 - 0.5 * f * f2);
}
return S;
}
function find_cusp(a: number, b: number): LC {
let S_cusp = compute_max_saturation(a, b);
let rgb_at_max = oklab_to_linear_srgb({ L: 1, a: S_cusp * a, b: S_cusp * b });
let L_cusp = Math.cbrt(1 / Math.max(rgb_at_max.r, rgb_at_max.g, rgb_at_max.b));
let C_cusp = L_cusp * S_cusp;
return { L: L_cusp, C: C_cusp };
}
function find_gamut_intersection(a: number, b: number, L1: number, C1: number, L0: number): number {
let cusp = find_cusp(a, b);
let t: number;
if ((L1 - L0) * cusp.C - (cusp.L - L0) * C1 <= 0) {
t = (cusp.C * L0) / (C1 * cusp.L + cusp.C * (L0 - L1));
} else {
t = (cusp.C * (L0 - 1)) / (C1 * (cusp.L - 1) + cusp.C * (L0 - L1));
{
let dL = L1 - L0;
let dC = C1;
let k_l = +0.3963377774 * a + 0.2158037573 * b;
let k_m = -0.1055613458 * a - 0.0638541728 * b;
let k_s = -0.0894841775 * a - 1.2914855480 * b;
let l_dt = dL + dC * k_l;
let m_dt = dL + dC * k_m;
let s_dt = dL + dC * k_s;
{
let L = L0 * (1 - t) + t * L1;
let C = t * C1;
let l_ = L + C * k_l;
let m_ = L + C * k_m;
let s_ = L + C * k_s;
let l = l_ * l_ * l_;
let m = m_ * m_ * m_;
let s = s_ * s_ * s_;
let ldt = 3 * l_dt * l_ * l_;
let mdt = 3 * m_dt * m_ * m_;
let sdt = 3 * s_dt * s_ * s_;
let ldt2 = 6 * l_dt * l_dt * l_;
let mdt2 = 6 * m_dt * m_dt * m_;
let sdt2 = 6 * s_dt * s_dt * s_;
let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1;
let r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
let r2 = 4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
let u_r = r1 / (r1 * r1 - 0.5 * r * r2);
let t_r = -r * u_r;
let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1;
let g1 = -1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
let g2 = -1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
let u_g = g1 / (g1 * g1 - 0.5 * g * g2);
let t_g = -g * u_g;
let b = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s - 1;
let b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.7076147010 * sdt;
let b2 = -0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.7076147010 * sdt2;
let u_b = b1 / (b1 * b1 - 0.5 * b * b2);
let t_b = -b * u_b;
t_r = u_r >= 0 ? t_r : Number.MAX_VALUE;
t_g = u_g >= 0 ? t_g : Number.MAX_VALUE;
t_b = u_b >= 0 ? t_b : Number.MAX_VALUE;
t += Math.min(t_r, Math.min(t_g, t_b));
}
}
}
return t;
}
function clamp(x: number, min: number, max: number): number {
return x < min ? min : x > max ? max : x;
}
function sgn(x: number): number {
return +(0 < x) - +(x < 0);
}
function gamut_clip_preserve_chroma(rgb: RGB): RGB {
if (rgb.r < 1 && rgb.g < 1 && rgb.b < 1 && rgb.r > 0 && rgb.g > 0 && rgb.b > 0) {
return rgb;
}
let lab = linear_srgb_to_oklab(rgb);
let L = lab.L;
let eps = 0.00001;
let C = Math.max(eps, Math.sqrt(lab.a * lab.a + lab.b * lab.b));
let a_ = lab.a / C;
let b_ = lab.b / C;
let L0 = clamp(L, 0, 1);
let t = find_gamut_intersection(a_, b_, L, C, L0);
let L_clipped = L0 * (1 - t) + t * L;
let C_clipped = t * C;
return oklab_to_linear_srgb({ L: L_clipped, a: C_clipped * a_, b: C_clipped * b_ });
}
function maximum_chroma_for_lh(L: number, h: number): number {
let a = Math.cos(h);
let b = Math.sin(h);
let L0 = clamp(L, 0, 1);
let t = find_gamut_intersection(a, b, L, 1, L0);
return t;
}
function maximize_chroma_for_rgb(rgb: RGB): RGB {
let lab = linear_srgb_to_oklab(rgb);
let L = lab.L;
let a = lab.a;
let b = lab.b;
let h = Math.atan2(b, a);
let C = maximum_chroma_for_lh(L, h);
return oklab_to_linear_srgb({ L: L, a: C * Math.cos(h), b: C * Math.sin(h) });
}
function print_color(c: RGB): void {
console.log(linear_rgb_as_hex(c));
let lab = linear_srgb_to_oklab(c);
let C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
let h = Math.atan2(lab.b, lab.a);
console.log(`oklch(${lab.L}, ${C}, ${h})`);
}
function parse_hexcode(hex: string): RGB {
let r = parseInt(hex.slice(1, 3), 16) / 255;
let g = parseInt(hex.slice(3, 5), 16) / 255;
let b = parseInt(hex.slice(5, 7), 16) / 255;
return srgb_to_linear_rgb({ r: r, g: g, b: b });
}
module.exports = {
linear_srgb_to_oklab,
oklab_to_linear_srgb,
srgb_to_linear_rgb,
linear_rgb_to_srgb,
rgb_as_hex,
linear_rgb_as_hex,
compute_max_saturation,
find_cusp,
find_gamut_intersection,
clamp,
sgn,
gamut_clip_preserve_chroma,
maximum_chroma_for_lh,
maximize_chroma_for_rgb,
print_color,
parse_hexcode,
};
/*
>>> const X = require("./chroma");
>>> Z.print_color(Z.maximize_chroma_for_rgb(Z.parse_hexcode("#583e61")))
#72008d
oklch(0.40698242333583606, 0.19808518019193927, -0.7473789762405658)
*/
Gist
There's a super short example at the end that should illustrate how it works. In your specific case you probably want to call maximum_chroma_for_lh
directly.
It's not an elegant solution but if you can convert from Oklch
to an RGB
space, you can increment the chroma until the conversion results in an RGB that contains a value outside of the 0-1 range.
For example, with my Unicolour library I can do this in C#:
const double increment = 0.001;
var chroma = 0.0;
while (true)
{
// by default assumes sRGB display
var unicolour = new Unicolour(ColourSpace.Oklch, 0.5, chroma, 285);
if (!unicolour.IsInDisplayGamut)
{
chroma -= increment;
break;
}
chroma += increment;
}
Console.WriteLine(chroma); // 0.292
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With