Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RYB colors #207

Closed
meodai opened this issue Jul 19, 2023 · 19 comments
Closed

Add RYB colors #207

meodai opened this issue Jul 19, 2023 · 19 comments
Labels
Feature New feature or request

Comments

@meodai
Copy link
Contributor

meodai commented Jul 19, 2023

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)

@danburzo
Copy link
Collaborator

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.

@danburzo
Copy link
Collaborator

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

@danburzo danburzo added the Feature New feature or request label Jul 23, 2023
danburzo added a commit that referenced this issue Jul 23, 2023
@danburzo
Copy link
Collaborator

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
Copy link
Contributor Author

meodai commented Jul 23, 2023

@danburzo thanks so much!

@meodai
Copy link
Contributor Author

meodai commented Jul 24, 2023

@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.

@danburzo
Copy link
Collaborator

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

@meodai
Copy link
Contributor Author

meodai commented Jul 26, 2023

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

@danburzo
Copy link
Collaborator

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

@meodai
Copy link
Contributor Author

meodai commented Jul 29, 2023

haha I should not late-night code :D thanks

@facelessuser
Copy link

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.

@danburzo
Copy link
Collaborator

danburzo commented Aug 6, 2023

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?

@danburzo
Copy link
Collaborator

danburzo commented Aug 6, 2023

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

@facelessuser
Copy link

3D demos and others found here https://facelessuser.github.io/coloraide/demos/

@facelessuser
Copy link

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 :).

@facelessuser
Copy link

facelessuser commented Aug 6, 2023

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.

@danburzo
Copy link
Collaborator

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

@facelessuser
Copy link

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.

@facelessuser
Copy link

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

@facelessuser
Copy link

Update made, thanks for the suggestion!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants