Calculating RGB Values from Light Wavelengths in Python

A while ago I wrote a two-part article about visible light, and in particular the wavelength/frequency proportionality. These are the articles
Exploring the Visible Spectrum with Python Part 1
Exploring the Visible Spectrum with Python Part 2
For plotting the spectrum I used a bit of code which calculated the red, green and blue values of a given wavelength of light. I didn't discuss this code in the previous articles as it would have been a distraction from the central topic, but will now do so in this article.
I must stress that the methodology used is not my invention but was originally written in Fortran by Dan Bruton and various other people have implemented versions in other languages over the years. (Unfortunately I can't find a working link to the Fortran code.)
This project consists of the following files which you can clone/download the GitHub repository.
wlrgb.py
wlrgbdemp.py
The first file, wlrgb.py, contains a single function called wavelength_to_rgb which takes as its argument a wavelength in nanometres (billionths of a metre) and returns a dictionary of RGB values.
The second file, wlrgbdemo.py, uses the wavelength_to_rgb function for the following:
Print the raw data just to show what it looks like
Print wavelengths and their corresponding RGB values to the console
Output wavelength and RGB values to an HTML table with a sample of each colour
Plot the RGB values against wavelengths using Matplotlib
The code uses very simple functionality from the NumPy and Matplotlib libraries. If you are not familiar with these you might like to read my introductory articles on them:
The Code
Firstly let's look at wlrgb.py which contains a single function to convert a wavelength in nanometres to an RGB tuple.
from typing import Tuple
def wavelength_to_rgb(nm) -> Tuple:
'''
Takes a wavelength of visible light
between 380 and 780 nanometres inclusive.
Values outside this range will raise a ValueError.
Returns a list of corresponding RGB values.
Based on Dan Bruton's Fortran implementation.
'''
# raise error if nm is outside a (very generous)
# range of visible light
if nm < 380 or nm > 780:
raise ValueError("nm argument must be between 380 and 780")
# a few variables for later use
gamma = 0.8
max_intensity = 255
factor = 0
# a dictionary with values initialised to 0
# which will be calculated later
rgb = {"R": 0, "G": 0, "B": 0}
# set RGB values depending on ranges of wavelength
if 380 <= nm <= 439:
rgb["R"] = -(nm - 440) / (440 - 380)
rgb["G"] = 0.0
rgb["B"] = 1.0
elif 440 <= nm <= 489:
rgb["R"] = 0.0
rgb["G"] = (nm - 440) / (490 - 440)
rgb["B"] = 1.0
elif 490 <= nm <= 509:
rgb["R"] = 0.0
rgb["G"] = 1.0
rgb["B"] = -(nm - 510) / (510 - 490)
elif 510 <= nm <= 579:
rgb["R"] = (nm - 510) / (580 - 510)
rgb["G"] = 1.0
rgb["B"] = 0.0
elif 580 <= nm <= 644:
rgb["R"] = 1.0
rgb["G"] = -(nm - 645) / (645 - 580)
rgb["B"] = 0.0
elif 645 <= nm <= 780:
rgb["R"] = 1.0
rgb["G"] = 0.0
rgb["B"] = 0.0
# calculate a factor (value to multiply by)
# depending on range of nm
if 380 <= nm <= 419:
factor = 0.3 + 0.7 * (nm - 380) / (420 - 380)
elif 420 <= nm <= 700:
factor = 1.0
elif 701 <= nm <= 780:
factor = 0.3 + 0.7 * (780 - nm) / (780 - 700)
# adjust RGB values using various variables if > 0
# else set to 0
if rgb["R"] > 0:
rgb["R"] = int(max_intensity * ((rgb["R"] * factor) ** gamma))
else:
rgb["R"] = 0
if rgb["G"] > 0:
rgb["G"] = int(max_intensity * ((rgb["G"] * factor) ** gamma))
else:
rgb["G"] = 0
if rgb["B"] > 0:
rgb["B"] = int(max_intensity * ((rgb["B"] * factor) ** gamma))
else:
rgb["B"] = 0
return (rgb["R"], rgb["G"], rgb["B"])
I haven't attempted to fully understand the underlying logic of this code which I adapted from Dan Bruton's original, and probably don't have the necessary expertise to do so. However, I have added a few comments to explain the general gist of what the code is doing. When we get to plotting the values you'll get a better understanding of how the red, green and blue values change across the visible spectrum.
The core thing to take away from this function is that it takes a wavelength in nanometres and returns a tuple of the corresponding red, green and blue values.
Now let's write a few functions to try out the wavelength_to_rgb function.
from typing import Dict
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import wlrgb
def main():
print("-------------------------------")
print("| codedrome.com |")
print("| Wavelengths of Light to RGB |")
print("-------------------------------\n")
data = generate_data(10) # change argument to 1 for plot_data
print_data(data)
# console_table(data)
# html_table(data, "rgb.html")
# plot_data(data)
def generate_data(interval: int=1) -> Dict:
"""
Creates a dictionary of NumPy array with:
nm - wavelengths in nanometres
rgb - 3 arrays for red, green and blue values
"""
# create a function that can be called on an
# entire NumPy array rather than individual values
wavelength_to_rgb = np.vectorize(wlrgb.wavelength_to_rgb)
# array of wavelengths in nanometres
nm = np.arange(380,781,interval)
# array of rgb values
rgb = wavelength_to_rgb(nm)
# assemble arrays into dictionary
data = {'nm': nm,
'rgb': rgb}
return data
def print_data(data: Dict) -> None:
'''
Prints the raw data in four parts,
wavelength, red, green and blue
'''
print("data['nm']\n----------")
print(data['nm'])
print()
print("data['rgb'][0]\n--------------")
print(data['rgb'][0])
print()
print("data['rgb'][1]\n--------------")
print(data['rgb'][1])
print()
print("data['rgb'][2]\n--------------")
print(data['rgb'][2])
def console_table(data: Dict) -> None:
'''
Prints the wavelength and RGB data in a table
'''
for i in range(len(data['nm'])):
print(f" λ: {data['nm'][i]}nm ", end="")
print(f" RGB: ({data['rgb'][0][i]},", end="")
print(f" {data['rgb'][1][i]},", end="")
print(f" {data['rgb'][2][i]})")
def html_table(data: Dict, filename: str) -> None:
'''
Creates an HTML table displaying the wavelengths,
RGB values and samples of each colour.
Table is saved to specified file.
'''
html = []
html.append("<!DOCTYPE html>\n")
html.append("<head>\n")
html.append("<style>td {border: 1px solid #000000; padding:8px;}</style>\n")
html.append("</head>\n")
html.append("<body>\n")
html.append("<table style='border-collapse:collapse;'>\n")
html.append("<tr><th>Wavelength nm</th><th>RGB</th><th>Colour</th></tr>\n")
for i in range(len(data['nm'])):
html.append("<tr>")
css_color = f"({data['rgb'][0][i]},{data['rgb'][1][i]},{data['rgb'][2][i]})"
html.append(f"<td>{data['nm'][i]}nm</td>")
html.append(f"<td>{css_color}</td>")
html.append(f"<td style='background-color:rgb{css_color}; width:256px;'></td>")
html.append("</tr>\n")
html.append("</table>\n")
html.append("</body>")
html_string = "".join(html)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
try:
f = open(filename, "w+")
f.write(html_string)
f.close()
except IOError as ioe:
print(ioe)
def plot_data(data: Dict) -> None:
'''
Plots the red, green and blue values on the y-axis
and wavelengths on the x-axis.
'''
# red
plt.plot(data['nm'],
data['rgb'][0],
label='red',
linestyle="-",
linewidth=1.0,
color='#FF0000')
# green
plt.plot(data['nm'],
data['rgb'][1],
label='green',
linestyle="-",
linewidth=1.0,
color='#00FF00')
# blue
plt.plot(data['nm'],
data['rgb'][2],
label='blue',
linestyle="-",
linewidth=1.0,
color='#0000FF')
plt.xlabel("Wavelength nm")
plt.ylabel("RGB")
plt.title("Wavelengths and RGB Values")
ax = plt.gca()
# Matplotlib RGB values are 0-1, not 0-255
ax.set_facecolor((0.15, 0.15, 0.15))
plt.show()
if __name__ == "__main__":
main()
After a few imports comes main which simply calls a few functions to create and output RGB values in various formats.
generate_data
The first function is generate_data which returns a dictionary containing wavelengths and their corresponding rgb values as NumPy arrays. To call wavelength_to_rgb on an entire NumPy array it is necessary to use the NumPy vectorize method which creates a wrapper function.
For creating the nm (wavelengths in nanometres) array I have used a range of 380nm to 780nm which are the wavelengths of visible light from violet through to red, as shown in this screenshot from my previous article.
print_data
This function just dumps the data dictionary and is really just a reference to show how the data structure is organised. Note that the RGB values are in arrays indexed as data['rgb'][0], data['rgb'][1] and data['rgb'][2] respectively.
Run the code like with this:
python3 wlrgbdemo.py
This is the output of print_data.
console_table
Here we print the data in a rather more friendly format, and is just a few f-string in a loop. Note that λ is the Greek letter lambda, the usual symbol for wavelength.
If you comment out print_data in main and uncomment console_table you'll see this.
html_table
The html_table function outputs the data to an HTML table. Anyone who has done any web development using, for example, Django will be horrified by my mixture of HTML and Python but I wanted to keep everything self-contained in a single function. The individual bits of the finished table are added to a list which is then combined into a single string using join.
Sandwiched between a few HTML tags is a loop similar to the one in console_table but with a few HTML tags.
After all that we write the string to the specified file, using appropriate exception handling.
This is part of the HTML table created by the html_table function. You can probably discern how various combinations of RGB combine into the colour samples.
plot_data
This function takes our data and plots the RGB values for the range of wavelengths. It's very simple Matplotlib usage but if you are not familiar with the library you might like to read the Matplotlib reference article linked above. Before running the function change the generate_data argument in main to 1. This is the output.
The plot gives us a clear insight into what the rather impenetrable wavelength_to_rgb function is actually doing. The straight lines and abrupt changes may come as a surprise, and demonstrate that the code is only an approximation for little more than aesthetic or demonstrative purposes.
Conclusion
Having plotted the RGB values and gained an understanding of how they behave I suspect the algorithm used to calculate them could be streamlined and made a bit more mathematical. I might have a bash at doing so sometime...