RGB LEDs and color spectrum

I'm interested in the color spectrum of white light made from mixing individual colored LEDs. I had a look at the datasheets for the Avago ASMT-Jx3x series LEDs. These aren't the newest and most efficient, but they have a package that's easy to solder so they're a good choice for experimentation. The datasheet shows graphs of the spectrums of individual colors. I decided to use Deep Red, Green and Blue. I manually fitted cubic Bezier curves to the graphs in Inkscape, using four segments per curve to get a good approximation.

I recorded the control point coordinates for each curve and adjusted them all to the same scale in Gnumeric. I then used some Python code by unutbu to calculate the coordinates of points on the curve and from that made a table of interpolated relative intensity at each wavelength with 1nm resolution. None of the common Bezier curve calculation algorithms divides the curve into points with equally spaced X coordinates so to get evenly spaced 1nm bins I calculated a large number of points and stepped through them keeping only the points where the integer part of the X coordinate incremented.

I can then add these three curves together with different scaling factors to create spectrums for different colors. Because my spectrum only has three variables I assume metamerism won't be a problem and I can use a simple greedy search (partitioning an octree) to find the matching scaling factors for any color. My goal is to find the scaling factors for the color matching the CIE Standard Illuminant D65 (daylight). The color of D65 can be specified as a CIE XYZ tristimulus value (95.047, 100.00, 108.883).

Rip’s Applied Mathematics Blog demonstrates how to calculate an XYZ value from a spectrum. He's using a reflectance spectrum and illuminant spectrum. Because I'm only interested in directly emmited light I can skip the step of multiplying those two spectrums.

CIE 1964 XYZ color matching functions with 5nm resolution are available from: http://www.cvrl.org/cmfs.htm

I linearly interpolated them to 1nm resolution in Gnumeric. I converted the LED spectrums and the interpolated CMFs to CSV so I can read them in Python and process them with NumPy. Plotting them all on the same graph you can see that Deep Red really is deep red and it's going to be inefficient as visible lighting. Because of this I expect the scaling factor for red will be the much higher than the others.

Comparison of RGB LED spectrum with CMFs

The following Python code finds the scaling factors.:

import numpy as np
import math

# Columns are:
# Wavelength, CMF Red, CMF Green, CMF Blue, LED Red, LED Green, LED Blue
ifile = open('cmfandleds.csv', "rb")
cmf = np.loadtxt(ifile, delimiter=",", usecols=(1,2,3))
ifile = open('cmfandleds.csv', "rb")
ledspec = np.loadtxt(ifile, delimiter=",", usecols=(4,5,6))

def distance_from_D65(r, g, b):
    # Target tristimulus for D65 color
    D65 = (95.047, 100.00, 108.883)

    # Mix LED spectrums
    mixed = []
    for row in ledspec:
        mixed.append(row[0] * r + row[1] * g + row[2] * b)

    # Calculate tristimulus using color matching functions
    XYZ = cmf.T.dot(mixed)
    # This could be normalized if we're only interested in color
    # but that might give multiple solutions so find an absolute value including brightness

    # Calculate Euclidean distance
    return math.sqrt( (D65[0]-XYZ[0])**2 + (D65[1]-XYZ[1])**2 + (D65[2]-XYZ[2])**2 )

# recursively divide RGB scaling factors as octree finding closest distance from D65
def octree(rmin, rmax, gmin, gmax, bmin, bmax, bailout):
    if bailout == 0:
        return ((rmin + rmax)/2, (gmin + gmax)/2, (bmin + bmax)/2)
    bailout -=1

    # examine centers of each octant
    closest = 1000000.0
    bestoctant = (0.0,0.0,0.0)
    for r in 0.0, 1.0:
        for g in 0.0, 1.0:
            for b in 0.0, 1.0:
                d = distance_from_D65(rmin + ((rmax-rmin)*0.25) + ((rmax-rmin)*0.5*r), gmin + ((gmax-gmin)*0.25) + ((gmax-gmin)*0.5*g), bmin + ((bmax-bmin)*0.25) + ((bmax-bmin)*0.5*b))
                if d < closest:
                    closest = d
                    bestoctant = (r, g, b)

    # recursively search best octant
    rmin = rmin + (((rmax - rmin) / 2.0) * bestoctant[0])
    rmax = rmax - (((rmax - rmin) / 2.0) * (1 - bestoctant[0]))
    gmin = gmin + (((gmax - gmin) / 2.0) * bestoctant[1])
    gmax = gmax - (((gmax - gmin) / 2.0) * (1 - bestoctant[1]))
    bmin = bmin + (((bmax - bmin) / 2.0) * bestoctant[2])
    bmax = bmax - (((bmax - bmin) / 2.0) * (1 - bestoctant[2]))
    return octree(rmin, rmax, gmin, gmax, bmin, bmax, bailout)

closest_match = octree(0, 20, 0, 20, 0, 20, 20)

print closest_match
print distance_from_D65(closest_match[0],closest_match[1],closest_match[2])

I found the following match:

Deep Red Green Blue Distance
9.999 2.611 2.398 13.73

I was unable to get an exact match, which means my assumption that a greedy search will find a single unique solution is false. I tweaked the search parameters a bit and found closest_match = octree(0, 4000, 0, 400, 0, 400, 50) gives a much better result:

Deep Red Green Blue Distance
13.52 2.269 2.383 0.0004911

These are probably close enough to be indistinguishable. Deep Red indeed had a higher scaling factor.

Note that the spectrums for the individual LEDs were all normalized to peak at 1, so these aren't the scaling factors for current through the individual LEDs. Luminous flux is pretty much linear with current up to about 350mA so all that's needed is another linear scaling factor for each color of LED. The datasheet specifies some colors in lumens and some in radiometric power. I need radiometric power, which I could theoretically calculate using the spectrum data, but there's a lot of variance in allowed flux so it might be better to empirically test it. By comparing with a known D65 colored illuminant (eg. a correctly calibrated sRGB computer monitor) I could tweak the currents until the color matches. This will then give me all the data I need to generate any other color.

Although this will give the correct D65 color, it's not the real D65 standard illuminant. The CRI will be much lower because I'm only using three narrow spectrum sources. I plan on calculating the CRI of my spectrum and comparing it with other spectrums of the same color made from more colors of LED light (eg. adding an amber source to fill the large gap in the spectrum between red and green). With more than three variables there will be metamerism which will make finding a spectrum for a specific color with the best CRI more complicated.