Spectra Tune Lab light source

drawing

PyPlr’s PupilCore().light_stamper(...) method makes it easy to integrate any light source given a suitable geometry. For our own application, we chose to administer light stimuli with a custom-built integrating sphere and Spectra Tune Lab (STLAB: Ledmotive Technologies, LLC)—a spectrally tuneable light engine with 10 LED colour channels, capable of generating a broad range of spectral compositions. STLAB can be controlled programmatically with most languages via its REST API, which works with generic HTTP requests. The API includes commands to set a specific spectrum, turn the light off, get readouts from the onboard spectrometer, etc. We opted to work with STLAB because it offers a high level of control over the temporal and spectral properties of light stimuli.

PyPlr’s stlab.py module

stlab.py provides full native support for STLAB by encapsulating its REST API in a SpectraTuneLab() device class and streamlining various routines with helper functions. STLAB connects via ethernet to a small computer called the Light Hub (a BeagleBone running Linux), which in turn connects via USB to the controlling computer. When all hardware is correctly assembled and the appropriate software installed, establishing a connection to the device is easy (note that a password is required for access to the Light Hub):

[1]:
from pyplr import stlab
d = stlab.SpectraTuneLab(password='****************')
STLAB device setup complete...

With this handle on the device we can access the methods available from the REST API. For example, to blink each of the LEDs in turn at max intensity for ~1 second, we can use the .set_spectrum_a(...) method:

[2]:
from time import sleep

for led in range(10):
    intensities = [0]*10
    intensities[led] = 4095
    d.set_spectrum_a(intensities)
    sleep(1.)
    d.turn_off()
    sleep(1.)

Note that spectrums are defined by passing a list of ten values between 0-4095 (that’s one for each LED channel, 12-bit resolution), corresponding to the minimum and maximum output of the device.

STLAB also has an on-board spectrometer. The easiest way to get data from the spectrometer is with pyplr.calibrate.SpectraTuneLabSampler, a subclass of SpectraTuneLab with added sampling methods.

[3]:
from pyplr.calibrate import SpectraTuneLabSampler

d = SpectraTuneLabSampler(password='****************')
leds = [0,1,2,3,4,5,6,7,8,9]
intensity = [4095]
d.sample(leds=leds, intensities=intensity)
d.make_dfs()
d.stlab_spectra
STLAB device setup complete...
Sampling 10 leds at the following intensities: [4095]
Measurement: 1 / 10, LED: 0, intensity: 4095
Measurement: 2 / 10, LED: 1, intensity: 4095
Measurement: 3 / 10, LED: 2, intensity: 4095
Measurement: 4 / 10, LED: 3, intensity: 4095
Measurement: 5 / 10, LED: 4, intensity: 4095
Measurement: 6 / 10, LED: 5, intensity: 4095
Measurement: 7 / 10, LED: 6, intensity: 4095
Measurement: 8 / 10, LED: 7, intensity: 4095
Measurement: 9 / 10, LED: 8, intensity: 4095
Measurement: 10 / 10, LED: 9, intensity: 4095
[3]:
380 385 390 395 400 405 410 415 420 425 ... 745 750 755 760 765 770 775 780 led intensity
0 0.0 0.0 0.0 23.092050 69.099876 163.715586 292.749028 646.445204 1293.727715 2318.856104 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 0 4095
1 0.0 0.0 0.0 13.482852 10.065324 20.364725 29.868263 65.026672 142.974411 311.837770 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 1 4095
2 0.0 0.0 0.0 0.000000 0.000000 1.689246 2.240837 3.999032 7.308575 15.892703 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 2 4095
3 0.0 0.0 0.0 0.000000 3.733973 0.000000 1.982641 0.859144 2.412213 7.467946 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 3 4095
4 0.0 0.0 0.0 0.000000 0.000000 1.434468 0.956312 0.000000 0.000000 0.000000 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 4 4095
5 0.0 0.0 0.0 2.748689 1.536901 1.241343 1.655125 1.448234 2.009794 2.748689 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 5 4095
6 0.0 0.0 0.0 0.000000 3.008748 0.000000 4.851946 9.324410 14.420308 14.718472 ... 50.823453 73.700784 55.106176 0.0 0.0 0.0 0.0 0.0 6 4095
7 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 1.007043 2.098007 ... 93.403277 85.766531 70.493039 0.0 0.0 0.0 0.0 0.0 7 4095
8 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 8 4095
9 0.0 0.0 0.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.0 0.0 9 4095

10 rows × 83 columns

With these data, we can plot the spectral power distributions of the 10 channels at maximum and their CIE 1931 xy chromaticity coordinates, which define the gamut of the device. Note that we can calculate the xy chromaticity coordinates quickly by passing the spectra data frames to stlab.spectra_to_xyz(...).

[4]:
from colour.plotting import plot_chromaticity_diagram_CIE1931
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_context('notebook', font_scale=1.1)

# Prepare spectra
spectra = (d.stlab_spectra
            .set_index(['led','intensity']))
xyz = stlab.spectra_to_xyz(spectra, binwidth=5)
xyz = xyz.append(xyz.loc[0]) # join the dots
long_spectra = stlab.spectra_wide_to_long(spectra)

# Set up figure
f, (ax1, ax2) = plt.subplots(1, 2, figsize=(14,7))

# Plot SPDs
sns.lineplot(
    x='wavelength', y='flux',
    data=long_spectra, hue="led",
    palette=d.rgb_colors, ax=ax1,
    lw=4, legend=False)

ax1.set_xlabel("Wavelength (nm)")
ax1.set_ylabel("Flux (mW)")

# Plot the *CIE xy* chromaticity coordinates.
plot_chromaticity_diagram_CIE1931(
    standalone=False, axes=ax2,
    title=False, show_spectral_locus=True)

ax2.set_xlim((-.15,.9))
ax2.set_ylim((-.1,1))
ax2.plot(xyz["X"], xyz["Y"], color='k',
         lw=4, marker='o', markersize=6)

for item in ax2.texts:
    item.set_fontsize(20)

f.tight_layout()
_images/04b_stlab_light_source_8_0.png

For a more nuanced understanding of the device’s output we would need to sample at a range of intensities, such as from minimum to maximum in steps of 63:

leds = [0,1,2,3,4,5,6,7,8,9]
intensities = [val for val in range(0,4096,65)]
spectra, info = d.sample(leds=leds, intensity=intensities)

We do this elsewhere with an Ocean Optics STS-VIS spectrometer in order to create a forward model for the STLAB-sphere rig.

Device timing in standard mode

STLAB queues commands received by the Light Hub and processes them when possible. According to the device manual, response times in the standard mode of operation are on the order of around 250 milliseconds. We can test this.

[5]:
from time import time, sleep

import pandas as pd

on = [4095]*10
off = [0]*10
exec_times = []
for i in range(200):
    t1 = time()
    d.set_spectrum_a(on)
    t2 = time()
    sleep(.2)
    t3 = time()
    d.set_spectrum_a(off)
    t4 = time()
    exec_times.append(t2-t1)
    exec_times.append(t4-t3)

exec_times = pd.Series(exec_times)
print('Execution time summary:')
print(exec_times.describe())
ax = exec_times.plot()
ax.set_ylabel('Execution time (s)')
Execution time summary:
count    400.000000
mean       0.208223
std        0.021075
min        0.179315
25%        0.200251
50%        0.200554
75%        0.207376
max        0.385547
dtype: float64
_images/04b_stlab_light_source_11_1.png

Clearly there is variability. It probably depends on the machine, hardware, other processes, etc., but in our general testing we have seen even bigger delays, with commands sometimes taking up to 5 seconds to execute. Given that we will want to administer light stimuli of a precise duration, the uncertainty here is problematic. Thankfully, STLAB has an asynchronous mode of operation which allows for real-time spectral streaming with a spectral switching time of less than 10 milliseconds (i.e., 1 spectrum every 10 milliseconds). We leverage this mode in order to get the desired control over the temporal characteristics of our light stimuli.

Playing video files

Using the asynchronous mode requires the creation of dynamic sequence files, which are essentially JSON files with a particular file structure and a .DSF extension. stlab.py has some handy tools for creating video files. For example, we can create a 2-second pulse of bright red light using stlab.pulse_protocol(...):

[6]:
spec = [0]*10
spec[-1] = 4095
vf_df = stlab.pulse_protocol(pulse_spec=spec,
                             pulse_duration=2000,
                             fname='1s_red_pulse',
                             return_df=True,
                             metadata={'whatever':'info',
                                       'you':'want'})
vf_df
"1s_red_pulse.dsf" saved in the current working directory.
[6]:
time LED-1 LED-2 LED-3 LED-4 LED-5 LED-6 LED-7 LED-8 LED-9 LED-10
0 0 0 0 0 0 0 0 0 0 0 4095
1 2000 0 0 0 0 0 0 0 0 0 4095
2 2000 0 0 0 0 0 0 0 0 0 0
3 2100 0 0 0 0 0 0 0 0 0 0

Calling stlab.video_file_to_dict(...), we can see how the video file looks internally:

[7]:
stlab.video_file_to_dict('1s_red_pulse.dsf')
[7]:
{'header': {'version': 1,
  'model': 'VEGA10',
  'channels': 10,
  'spectracount': 4,
  'transitionsCount': 4,
  'fluxReference': 0,
  'repeats': 1},
 'metadata': {'creation_time': '2021-03-24 14:05:04.537285',
  'creator': 'jtm',
  'whatever': 'info',
  'you': 'want',
  'protocol': 'pulse',
  'pulse_spec': '[0, 0, 0, 0, 0, 0, 0, 0, 0, 4095]',
  'pulse_duration': '2000'},
 'spectra': [[0, 0, 0, 0, 0, 0, 0, 0, 0, 4095],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 4095],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
 'transitions': [{'spectrum': 0, 'power': 100, 'time': 0, 'flags': 0},
  {'spectrum': 1, 'power': 100, 'time': 2000, 'flags': 0},
  {'spectrum': 2, 'power': 100, 'time': 2000, 'flags': 0},
  {'spectrum': 3, 'power': 100, 'time': 2100, 'flags': 0}]}

Here are some notes on video files based on communications with Ledmotive:

1. HEADER - “version”, “models” and “channels” should not be changed, as they are the default settings for the STLAB. This all gets handled by the stlab.make_video_file(...) function. - “repeats” is set to 1 by default, meaning the video will not run endlessly. Setting “repeats” to 0 will cause the video to start again after it finishes and repeat endlessly. - “spectraCount” is the number of different spectra used in the video. In this example we used 4 spectra (although only two were unique). - “transitionsCount” is the total number of transitions in the video. In this example there are 4 different transitions. - “fluxReference” is reserved for something else, and is fine like this.

2. METADATA - This is optional and not needed for the video file to work, but it is a good place to store information about the video file. When the file is loaded during runtime this info can then be unpacked and included in any triggers sent to Pupil Capture. Some information is included by default, but any dict passed as **metadata to the functions for creating video files will be unpacked into the video file (e.g. {‘color’:‘blue’}).

3. SPECTRA - These are the values (between 0 and 4095) for each of the 10 channels. Spectra should be stored as a list of lists.

4. TRANSITIONS - “spectrum” looks at the index from the list of lists to pick up the proper spectrum. This is set based on the index of the DataFrame passed to stlab.make_video_file(...). - “time” is the start of the current spectrum in milliseconds time since the start of video file playback. Here the 1st spectrum starts at t=0sec, the 2nd spectrum stats at t=3sec.

Once you’ve made a video file, playing it is easy:

[8]:
d.load_video_file('1s_red_pulse.dsf')
d.play_video_file()
video file loaded...
playing video file...
[8]:
<Response [200]>

Bear in mind that stlab.pulse_protocol(...) is just one of a few convenience functions that call an underlying stlab.make_video_file(...) function. The latter will turn any dataframe like the one returned above into a video file. Just make sure the column names are the same and that there is at least 10 ms between every consecutive value in the ‘time’ column (otherwise spectra may be dropped). So it is possible to get creative and produce all kinds of cool sequences.