color.js
color.js copied to clipboard
Add support for color from wavelength
A color should be defineable by a wavelength.
There are well known algorithms that convert a wavelength to XYZ coordinates, e.g. at https://en.wikipedia.org/wiki/CIE_1931_color_space#Analytical_approximation
Note: this might also help a bit with #71
There are well known algorithms that convert a wavelength to XYZ coordinates,
That algorithm is for converting each wavelength (in the range300 to 780, typically) to the XYZ basis functions, before adding them (numerical integration) to get the actual XYZ value.
A color should be definable by a wavelength.
That formula would only do that if the color was on the spectral locus, and had 100% of the light energy at a single wavelength, zero elsewhere.
The linked Color matching functions is just converting one wavelength. So I recon the result will be a point that lies on the well know outer shape of the color space chromaticity diagram.
It's the next chapter (Computing XYZ from spectral data) has the integral over all wavelengths to convert a spectrum to a XYZ color.
This single-wavelength-to-XYZ function is thus useful by itself (when I want to have such a single wavelength color) as well as a base for further computations (like converting a spectrum to a color).
So I recon the result will be a point that lies on the well know outer shape of the color space chromaticity diagram.
It will, yes. That is called the spectral locus.
It's the next chapter (Computing XYZ from spectral data) has the integral over all wavelengths to convert a spectrum to a XYZ color.
Yes, that is how you get an XYZ color from a set of spectral measurements.
so why is it hard to implement an algorithm for conversion between Wavelength and color values?? just wondering 🤔
I've been looking up for a good algorithm, so I ended up implementing with this one:
Wavelength to RGB
I needed an algorithm for an art project I'm working on, so that's why I'm using a wavelength range between 380 and 760 (the color difference between 760 and 800 is almost nothing, it's all red).const wl2color = (from, to = 'RGB') => {
var w = parseFloat(from);
const v = [380,440,490,510,580,645,760];
if (w >= v[0] && w < v[1])
{
red = -(w - v[1]) / (v[1] - v[0]);
green = 0.0;
blue = 1.0;
}
else if (w >= v[1] && w < v[2])
{
red = 0.0;
green = (w - v[1]) / (v[2] - v[1]);
blue = 1.0;
}
else if (w >= v[2] && w < v[3])
{
red = 0.0;
green = 1.0;
blue = -(w - v[3]) / (v[3] - v[2]);
}
else if (w >= v[3] && w < v[4])
{
red = (w - v[3]) / (v[4] - v[3]);
green = 1.0;
blue = 0.0;
}
else if (w >= v[4] && w < v[5])
{
red = 1.0;
green = -(w - v[5]) / (v[5] - v[4]);
blue = 0.0;
}
else if (w >= v[5] && w < v[6])
{
red = 1.0;
green = 0.0;
blue = 1 - ( (w - v[6]) / (v[5] - v[6]) );
}
else
{
red = 0.0;
green = 0.0;
blue = 0.0;
}
// Let the intensity fall off near the vision limits
// const i = [380,420,701,781];
// if (w >= i[0] && w < i[1])
// factor = 0.3 + 0.7*(w - i[0]) / (i[1] - i[0]);
// else if (w >= i[1] && w < i[2])
// factor = 1.0;
// else if (w >= i[2] && w < i[3])
// factor = 0.3 + 0.7*((i[3]-1) - w) / ((i[3]-1) - (i[2]-1));
// else
// factor = 0.0;
return [red*255,green*255,blue*255]; // here the "to" variable would be used to use a color library to convert to
}
That algorithm is one special case of "map a range of arbitrary input values to a color in a color scale". Notably, it won't give you the actual color that you would see when viewing that wavelength; the algorithm has no colorimetric information and doesn't take any notice of the adapted white point.
I ended up here trying to understand the shape of the CIE chromaticity diagram in different color spaces, and tracing around it by wavelength is one way to approach it.
I found https://colour.readthedocs.io/en/latest/generated/colour.wavelength_to_XYZ.html and wrote this code:
# pip3 install --user colour-science
from colour import wavelength_to_XYZ, MSDS_CMFS
import json
cie1931 = MSDS_CMFS["CIE 1931 2 Degree Standard Observer"]
cie2015 = MSDS_CMFS["CIE 2015 2 Degree Standard Observer"]
data = []
for wavelength in range(360, 781):
xyz = wavelength_to_XYZ(wavelength, cie1931)
data.append([1931, wavelength, *xyz])
for wavelength in range(390, 831):
xyz = wavelength_to_XYZ(wavelength, cie2015)
data.append([2015, wavelength, *xyz])
print(json.dumps(data))
Converting the points in XYZ to xyY and plotting them showed a familiar horseshoe shape, so I think the data is what it seems to be. I hope this might help others trying to figure this out.
Converting the points in XYZ to xyY and plotting them showed a familiar horseshoe shape, so I think the data is what it seems to be. I hope this might help others trying to figure this out.
Yep, that is a great way to get the horseshoe shape. You can interpolate between the points to get finer resolution and/or calculate a spline through them to get a nice rounded shape. Colour also has built-in CIE diagram functions you can call as well (I don't recall them off hand though).
You can plot the chromaticity points for red, green, and blue for a given color spaces to get the shape within the horse shoe shape to get a feel for different spaces in relation to each other.
I know color science has some 3D plotting tools built in as well, I don't recall their names off hands, so you should be able to something similar, but you can take those xyY to the 3D world as well and compare the spaces to each other too. These are not done with Colour, but something similar I imagine could be done.
You can interpolate between the points to get finer resolution and/or calculate a spline through them to get a nice rounded shape.
Given CIE_xyz_1931_2deg.csv, what is the correct way to interpolate? Looking into the details, things are a bit confusing:
- Wikipedia makes it sound like the original data was in 5nm intervals from 360-830nm, and that 1nm datasets are interpolated. In the CSV it does look like every 5th row is different, using 4 significant digits at least early on in the table. A quick test shows that it's not linearly interpolated, but how then?
- The dataset metadata (
CIE_xyz_1931_2deg.csv_metadata.json) hasinterpolationMethodset to "linear". Does that really mean that the best interpolation method between integer wavelengths is linear? The docs are at https://github.com/CIEmetaSchemas/CIEmetaDigitalProduct/tree/main/v4.
Yeah, it does state they are derived from interpolation.
Regardless, if you want anything in between whatever data you have, all you can do is interpolate. Using something like a catrom spline would run the spline through the data points and smooth out the plot if all you had is 5nm.
If you are using 1nm values, which is what I use anyways, and what colour science provides, that should probably give you smooth enough plots though, but if not, you smooth out the rough edges.
Anyway, since you are using colour science, you can just have it plot the diagrams for you and then then plot whatever you want over them, like gamuts of RGB spaces, etc.
import colour
figure, axes = colour.plotting.plot_chromaticity_diagram_CIE1931(
diagram_opacity=0.0,
standalone=False,
bounding_box=[-0.4, 1.0, -0.2, 1.25])
colour.plotting.render();
or for 10 degree:
import colour
figure, axes = colour.plotting.plot_chromaticity_diagram_CIE1931(
cmfs="CIE 2015 10 Degree Standard Observer",
diagram_opacity=0.0,
standalone=False,
bounding_box=[-0.4, 1.0, -0.2, 1.25])
colour.plotting.render();
Thanks @facelessuser, do you know if colour can also plot chromaticity diagrams in other color spaces, specifically Oklab?
That's what I was really after, and I made my own visualization:
The cross is where a=b=0, and it looks a bit like a one-eyed ghost?
Regardless of the color space it's visualized in, there are some odd things that fall out of using the CIE 1931 2° Standard Observer data for a chromaticity diagram with constant lightness. At the beginning (violet, short wavelengths) the points are all very close to each other, and the line gets a bit erratic. A "color from wavelength" function should probably do some kind of adjustment there to get more smooth behavior.
These are the chromaticity diagrams that are offered.
colour.plotting.plot_chromaticity_diagram_CIE1931
colour.plotting.plot_chromaticity_diagram_CIE1960UCS
colour.plotting.plot_chromaticity_diagram_CIE1976UCS
From my understanding 2˚ does not best represent the human eye, 10˚ would as there are more subtle differences can occur with wider area of view, like in the case of the blue region.
You can see that the blue shifts a bit with the 10 deg observer. The 2015 CMFs seem to both be shifted.