Evercoder/culori

Add RYB colors

meodai opened this issue · 19 comments

meodai commented

I recently wanted to play with RYB colors. I experimented using some code of: https://github.com/bahamas10/ryb/blob/gh-pages/js/RXB.js the results are pretty. Would be so nice o have this in culori as well

(Found an other pen using it https://codepen.io/yukulele/pen/XMbrBJ?editors=0010)

The paper referenced here proposes an "intuitive color mixing" technique: use an RYB (Red-Yellow-Blue) subtractive model inspired by how physical pigments work.

By expressing colors as RYB, you can perform simple compositing, presumably using the multiply operation ((b, s) => b * s) to achieve an intuitive result. Afterwards, to display the result, you perform the RYB -> RGB operation:

  1. given the eight colors in the corners of the RYB cube expressed as RGB, for each RGB component:
  2. perform trilinear interpolation of the eight corner values, made non-linear with the easing function t => t * t * (3 - 2 * t).

As defined like this, it’s not clear how much of the corresponding RGB space can a RYB space contain, and in any case it’s defined as a one-way conversion, with no formula provided for decomposing RGB into the RYB components.

RYB is therefore not a candidate for defining as color space in Culori. Similar issues make it challenging to add pigment-decomposition models inspired by Kubelka-Munk theory (e.g. mixbox) as color spaces.

Let’s see, however, if it makes sense to add some helper functions to Culori to make RYB to RGB conversion more pleasant to implement.

I’m thinking the following may be useful additions to the Culori API:

  • blerp (bilinear interpolation) and trilerp (trilinear interpolation) as low-level primitives
  • maybe mix(colors)(t), mix2d(colors)(tx, ty) and mix3d(colors)(tx, ty, tz) helpers

Added blerp() and trilerp() functions to culori@3.2.0.

With this in place, the RYB to RGB method looks like this:

import { trilerp } from 'culori';

const RYB_CUBE = [
	{ mode: 'rgb', r: 1, g: 1, b: 1 }, // white
	{ mode: 'rgb', r: 1, g: 0, b: 0 }, // red
	{ mode: 'rgb', r: 1, g: 1, b: 0 }, // yellow
	{ mode: 'rgb', r: 1, g: 0.5, b: 0 }, // orange
	{ mode: 'rgb', r: 0.163, g: 0.373, b: 0.6 }, // blue
	{ mode: 'rgb', r: 0.5, g: 0, b: 0.5 }, // violet
	{ mode: 'rgb', r: 0, g: 0.66, b: 0.2 }, // green
	{ mode: 'rgb', r: 0.2, g: 0.094, b: 0 } // black
];

function ryb2rgb(coords) {
	const biased_coords = coords.map(t => t * t * (3 - 2 * t));
	return {
		mode: 'rgb',
		r: trilerp(...RYB_CUBE.map(it => it.r), ...biased_coords),
		g: trilerp(...RYB_CUBE.map(it => it.g), ...biased_coords),
		b: trilerp(...RYB_CUBE.map(it => it.b), ...biased_coords)
	};
}

ryb2rgb([1, 0.5, 0.25]);
/*
{
	mode: 'rgb',
	r: 0.8984375,
	g: 0.21828124999999998,
	b: 0.0390625
}
*/

By changing the colors at the corner of the cube you can perform a trilinear interpolation between any 8 colors.

meodai commented

@danburzo thanks so much!

meodai commented

@danburzo Itten would be proud :D https://codepen.io/meodai/pen/NWELdGW/7cbdbbcc1d1dd0ae42527341eef1c23c?editors=0010 PS: looks like the teal color is getting lost in the "mix" somehow.

Yeah, the code is a crude approximation of real-world color mixing, but it looks pretty nice!

meodai commented

@danburzo I think the code in your example inverts white & black (they need to be swapped in RYB_CUBE)

RYB is a subtractive color model, so [1, 1, 1] produces black, not white (as with RGB/HSL).

meodai commented

haha I should not late-night code :D thanks

RYB is therefore not a candidate for defining as color space in Culori. Similar issues make it challenging to add pigment-decomposition models inspired by Kubelka-Munk theory (e.g. mixbox) as color spaces.

Just a note on this comment, the RYB space as Gosset and Chen describe can be translated both in the forward and reverse direction, even with biasing applied. Using Newton's method you can successfully implement a forward and reverse transform for all colors in gamut. If extrapolating outside of the gamut, things get a little dicey, but in gamut, it actually works quite well.

>>> c1 = Color.random('ryb')
>>> c1
color(--ryb 0.98577 0.35263 0.92582 / 1)
>>> c1.convert('srgb')
color(srgb 0.43535 0.05045 0.30214 / 1)
>>> c1.convert('srgb').convert('ryb')
color(--ryb 0.98577 0.35263 0.92582 / 1)

I think as a color space, biasing should not be the default as it just clumps all the colors toward the corners, but the approach works for both.

>>> c2 = Color.random('ryb-biased')
>>> c2
color(--ryb-biased 0.13099 0.43229 0.29285 / 1)
>>> c2.convert('srgb')
color(srgb 0.81598 0.85969 0.54394 / 1)
>>> c2.convert('srgb').convert('ryb-biased')
color(--ryb-biased 0.13099 0.43229 0.29285 / 1)

A reverse transform may not work for any 8 colors either. It is easy to turn the 3D space inside out. You can see how sRGB looks in the Gosset and Chen RYB color space. It gets quite twisted, but it still works.

Screenshot 2023-08-05 at 8 17 53 AM

Through testing, I found that picking any colors for the 8 RYB corners may yield degraded round trip results near the limits in some cases, but for the Gosset and Chen colors, it works quite well.

The actual implementation for inverse trilinear interpolation is straightforward, but no, I didn't come up with this part myself. Reference code for both reverse bilinear and trilinear interpolation is found here: https://stackoverflow.com/a/18332009/3609487. This approach seems to use this method of trilinear interpolation to calculate the inverse (http://paulbourke.net/miscellaneous/interpolation/) as it is easier to target and calculate the individual components in this way. As for the biasing, there was no reference, but I was able to just code up a simple Newton's method inverse as the easing function is simple to calculate the derivative from.

You do need a fairly sound matrix inverse function as you can sometimes get zeros at the pivot points, but that does not necessarily mean the matrix is not invertible, so you need to shuffle the rows around when reducing them. If you do get a Jacobian matrix that is not invertible (only ever occurs outside the gamut), you can probably just bail, or that is what I did. I do not guarantee round trips outside of the RYB gamut.

This may or may not be more work than this library cares to do for RYB, but I had been working on this off and on for a bit, and when I saw this, I figured I'd share. It helped motivate me to actually clean up the work and get it out into the wild.

I appreciate you taking the time to write this up, Isaac! Very useful additions to the thread.

Bourke’s variant of trilerp is much nicer to look at. You’re right though that the matrix toolkit involved in performing the inverse trilinear interpolation is too much for including in the library.

Playing some more with RYB, I noticed the easing function is in fact Smoothstep, so the code needed to add a convertXtoY-style function to Culori is even more compact, since it’s already part of the API:

import { trilerp, easingSmoothstep } from 'culori';

function convertRybToRgb(ryb) {
  // Omit the call to easingSmoothstep() to remove bias.
  const r = easingSmoothstep(ryb.r);
  const y = easingSmoothstep(ryb.y);
  const b = easingSmoothstep(ryb.b);
	return {
		mode: 'rgb',
		r: trilerp(1, 1, 1, 1, .163, .5, 0, .2, r, y, b),
		g: trilerp(1, 0, 1, .5, .373, 0, .66, .094, r, y, b),
		b: trilerp(1, 0, 0, 0, .6, .5, .2, 0, r, y, b)
	};
}

// Usage
convertRybToRgb({ mode: 'ryb', r:, y:, b: });

It’s also nice that Smoothstep has an analytical inverse, to help in the RGB to RYB conversion:

function easingInverseSmoothstep(t) {
  return 0.5 - Math.sin(Math.asin(1 - 2 * t) / 3);
}

Is the code to produce 3D gamut visualizations available somewhere I could take a closer look?

Just for fun, a little RYB color picker: https://danburzo.ro/demos/color/ryb.html

3D demo is also available in the repo to do locally, but the browser demo is more accessible and doesn't require me to explain setup :).

Whoops! I didn't enable RYB gamut in 3D demo I just updated it.

Certain algorithms do have limits. For instance, doing RYB in other gamuts will just give you messed up models, so it only works well with its own gamut, but doing other spaces in the RYB gamut is kind of fun.

Here's Jzazbz: https://facelessuser.github.io/coloraide/demos/3d_models.html?space=jzazbz&gamut=ryb&edges=false&aspect=false&ortho=false
Screenshot 2023-08-06 at 4 27 19 PM

EDIT: Some spaces will refuse to render in other gamuts, for instance, HPLuv I only renders in its own gamut. I should probably do that for RYB as well as it is smaller than the other gamuts, so you can't resize the gamut shell to fit it.

That’s an extremely useful tool for visualizing gamuts, thanks for it!

One small note: I believe the X rendered in Y gamut should actually be Y gamut rendered in X space? The image below looks like the gamut of RYB plotted in sRGB space:

The gamut of the RYB color space plotted in sRGB color space

One small note: I believe the X rendered in Y gamut should actually be Y gamut rendered in X space? The image below looks like the gamut of RYB plotted in sRGB space:

I do think it makes a bit more sense. I'll probably make that adjustment.

That's actually the phrasing I use in my docs 🤦🏻 .

Update made, thanks for the suggestion!