Integrating sphere

For some experiments it may be sufficient to perform light stimulation with a standard computer monitor, but where research calls for advanced control over the geometry of retinal stimulation, a bespoke setup is required. One solution is to use a Maxwellian view pupillometry system, where the light stimulus is focused onto an aperture positioned in front of the eye, or in the entrance plane of a pharmacologically dilated pupil, and the consensual pupil response is measured from the other eye. But this approach requires optical engineering and resources that may not be available in the average research setting. As an alternative, we developed a low-cost integrating sphere that provides a full-field, ‘Ganzfeld’, stimulus and precludes the need for optical engineering, pharmacological dilation of the pupil, and strict fixation control on behalf of the participant.

Construction

We built the sphere from two 45 cm flanged acrylic half domes (Project Plastics Ltd), each coated on the inside surface with Avian-B white reflectance coating to scatter light homogenously. A 28 cm opening in one of the domes serves as a viewing port and an additional 7 cm (subtending ~9 degrees from the plane of the viewing port) opening opposite the viewing port was included to allow for secondary stimuli (e.g., a fixation target) or to allow for exclusion of the foveal macular pigment from stimulation. On the same half of the sphere as the viewing port, a 30 mm entry port for the light source was cut at an angle of 22.5 deg from the top, such that it could not be seen directly when looking straight ahead. The sphere and STLAB were stabilised on a wooden fixing plate making it suitable for placement on a desk.

Integrating sphere

Calibration

To build a calibrated forward model of our STLAB-sphere rig that represents what an observer actually sees when looking into it, we collected measurements with an external spectrometer positioned at the plane of the viewing port. The pyplr.calibrate module streamlines this process with a SpectraTuneLabSampler() class, which is just a sub-class of pyplr.stlab.SpectraTuneLab with added sampling methods and support for an external spectrometer. Any spectrometer with a python interface can be integrated here with minimal effort, but we used an Ocean Optics STS-VIS, supported by pyplr.oceanops and the Seabreeze Python library.

It would take a long time to sample every possible device setting, so we opted to sample the full range of intensities in steps of 63 for each LED.

from pyplr.calibrate import SpectraTuneLabSampler
from pyplr.oceanops import OceanOptics

oo = OceanOptics.from_first_available()
d = SpectraTuneLabSampler(password='***************', external=oo)

# specify leds and intensities to sample
leds = [0,1,2,3,4,5,6,7,8,9]
intensities = [i for i in range(0, 4096, 65)]

# sample
d.sample(leds=leds,
         intensities=intensities,
         external=oo,
         randomise=True)
d.make_dfs(save_csv=True)

The above code instructs STLAB to cycle through all of the specified settings and obtain measurements with the on-board spectrometer and external spectrometer (if specified), and then finally to save the data to CSV format in the current working directory.

Calibrating the resulting OceanOptics data was an involved process complicated by the need to account for the effect of spectrometer PCB temperature and integration time on the raw data. Our pipeline is shown below, minus the code for fitting the data in MATLAB.

[1]:
import pandas as pd
from pyplr.oceanops import predict_dark_counts, calibrated_radiance

# Load Ocean Optics data
oo_spectra_fname = '../data/S2_oo_led_intensity_spectra.csv'
oo_info_fname = '../data/S2_oo_led_intensity_info.csv'
oo_spectra = pd.read_csv(
    oo_spectra_fname, index_col=['led','intensity'])
oo_spectra.reset_index(drop=True, inplace=True)
oo_info = pd.read_csv(oo_info_fname)

# Load a file with parameters accounting for the relationship
# between temperature and integration time. This was created by
# sampling the dark spectrum across a range of temperatures
# and times and fitting the data in MATLAB.
darkcal = pd.read_table(
    '../data/oo_dark_cal.txt', skiprows=2, index_col=False)

# Predict the dark spectrum for the temperatures and
# integration times of our measurements
oo_dark_counts = predict_dark_counts(oo_info, darkcal)

# Load some spectrometer constants
cal_per_wl = pd.read_csv(
    '../data/oo_calibration.csv', header=None)
sensor_area_cm2 = pd.read_csv(
    '../data/oo_sensorArea.csv', header=None)[0]

# Calculate calibrated radiance
w_m2_nm = calibrated_radiance(
    oo_spectra,
    oo_info,
    oo_dark_counts,
    cal_per_wl,
    sensor_area_cm2.values[0])

# Clean up
w_m2_nm['led'] = oo_info['led']
w_m2_nm['intensity'] = oo_info['intensity']
w_m2_nm.set_index(['led', 'intensity'], inplace=True)
w_m2_nm.sort_index(inplace=True)
w_m2_nm = w_m2_nm.interpolate(axis=1)
w_m2_nm.to_csv('../data/S2_corrected_oo_spectra.csv')
w_m2_nm
[1]:
380 381 382 383 384 385 386 387 388 389 ... 771 772 773 774 775 776 777 778 779 780
led intensity
0 0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000011 0.000000 0.000000
65 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
130 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000e+00 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
195 0.000026 0.000017 0.000041 0.000015 0.000036 0.000039 0.000022 0.000032 0.000026 0.000021 ... 0.000022 5.119073e-07 0.000008 0.000009 0.000000 0.000015 0.000012 0.000000 0.000018 0.000000
260 0.000141 0.000147 0.000168 0.000170 0.000155 0.000144 0.000154 0.000121 0.000167 0.000169 ... 0.000088 7.298278e-05 0.000088 0.000081 0.000071 0.000086 0.000086 0.000010 0.000100 0.000078
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9 3835 0.003609 0.004056 0.003476 0.003724 0.002943 0.003147 0.003482 0.002543 0.003630 0.004153 ... 0.002318 2.590713e-03 0.002629 0.002536 0.002962 0.002583 0.002606 0.002213 0.002467 0.002924
3900 0.003415 0.003666 0.003451 0.003724 0.002873 0.003021 0.003112 0.002289 0.003514 0.004000 ... 0.002279 2.669694e-03 0.002370 0.002300 0.002815 0.002574 0.002412 0.002107 0.002590 0.002938
3965 0.003560 0.003867 0.003539 0.003874 0.003191 0.003165 0.003544 0.002428 0.003695 0.004005 ... 0.002134 2.682856e-03 0.002604 0.002552 0.002775 0.002638 0.002748 0.002137 0.002676 0.002664
4030 0.003738 0.003652 0.003557 0.003737 0.002973 0.003205 0.003420 0.002227 0.003520 0.003932 ... 0.002444 2.647269e-03 0.002541 0.002408 0.002702 0.002547 0.002385 0.002038 0.002477 0.002837
4095 0.003459 0.004063 0.003610 0.004071 0.003099 0.003373 0.003231 0.002468 0.003654 0.004141 ... 0.002274 2.544598e-03 0.002587 0.002493 0.002923 0.002699 0.002592 0.002177 0.002471 0.002698

640 rows × 401 columns

We can now pass the calibrated spectrometer data to pyplr.calibrate.CalibrationContext(...), which will use linear interpolation to create lookup tables that predict the spectral output for the full range of device settings, along with the alphaopic irradiances, lux and radiance values.

[2]:
import seaborn as sns
sns.set_context('paper', font_scale=1.4)

from pyplr.calibrate import CalibrationContext

cc = CalibrationContext(
    '../data/S2_corrected_oo_spectra.csv', binwidth=1)
fig = cc.plot_calibrated_spectra()
_images/04c_integrating_sphere_6_0.png

Of note, CalibrationContext(...) has a .predict_spd(...) method for predicting the spectral output of STLAB from a list of led-intensity values. Here we compare the predicted output to the actual measured output for 20 random device settings.

[3]:
import ast
import matplotlib.pyplot as plt

# Load data from 40 random spectra
test_specs = pd.read_csv(
    '../data/S2_corrected_40_random_oo_spectra.csv')
test_spec_info = pd.read_csv(
    '../data/S2_oo_40_random_spectra_info.csv')

# Set up figure
fig, axs = plt.subplots(
    nrows=4, ncols=5, figsize=(15,12), sharey=True, sharex=True)
axs = [ax for sublist in axs for ax in sublist]

# Predict, measure and plot for 20 random inputs
for i, ax in enumerate(axs):
    pred = cc.predict_spd(ast.literal_eval(
        test_spec_info.loc[i,'intensities']))
    wls = pred.columns
    ax.plot(wls, test_specs.loc[i].to_numpy(), label='measured')
    ax.plot(wls, pred.to_numpy()[0], ls='dashed', label='predicted')
    if i==4:
        ax.legend()
fig.text(0.5, 0.07, 'Wavelength [nm]', ha='center')
fig.text(
    0.07, 0.5, 'SPD (W/m$^2$/nm)', va='center', rotation='vertical');

fig.savefig('../img/STLAB_sphere_measured_predicted.tiff', dpi=300)
_images/04c_integrating_sphere_8_0.png

Looks like we can predict the spectral output with a good level of accuracy. There is also a method to fit curves to the unweighted radiance of spectral measurements.

[4]:
fig = cc.fit_curves()
_images/04c_integrating_sphere_10_0.png

Nice and linear, overall, with a only a slight loss of linearity at the ends and for some channels more than others. If necessary, we can correct for this using the model fit parameters and the .optimse(...) method.

Safety

We evaluated the safety of our stimulation system in accordance with the British Standards Document on the Photobiological Safety of Lamps and Lamp Systems (BS EN 62471). Initial scoping measurements collected with a Photo Research SpectraScan PR-670 for all LEDs at 100% gave a luminance reading of 18000 cd/m\(^2\) at the plane of the viewing port.

BS EN 62471: Section 4.1 (Annex ZB, page 40)

“Detailed spectral data is required if the luminance of the source exceeds 10\(^4\) cd/m\(^{-2}\)

The maximum output of our source exceeded this specification so we obtained detailed spectral measurements.

BS EN 62471: Section 4.3.3

“To protect against retinal photochemical injury from chronic blue-light exposure, the integrated spectral radiance of the light source weighted against the blue-light hazard function, \(B(\lambda)\), i.e., the blue light weighted radiance, \(L_B\), shall not exceed the levels defined by:

\(L_B \cdot t = \sum_{700}^{300} \sum_{t} L_\lambda (\lambda, t) \cdot B(\lambda) \cdot \Delta t \cdot \Delta\lambda \le 10^6 J \cdot m^{-2} \cdot sr^{-1}\) (for \(t \le 10^4s\))

\(L_B = \sum_{700}^{300} \sum_{t} L_\lambda \cdot B(\lambda) \cdot \Delta\lambda \le 100\,W \cdot m^{-2} \cdot sr^{-1}\) (for \(t \gt 10^4\)s)

Where:

\(L\lambda(\lambda, t)\) is the spectral radiance in \(W \cdot m^{-2} \cdot sr^{-1} \cdot nm^{-1}\)

\(B(\lambda)\) is the blue light hazard weighting function

\(\Delta\lambda\) is the bandwidth in nm

\(t\) is the exposure duration in seconds”

Using the minimum radiance limit for the retinal blue light hazard exposure limit, given as 100 \(W \cdot m^{-2} \cdot sr^{-1}\) for exposures of greater than 10000 s, we note that our source is below the retinal blue light hazard exposure limit. These findings were confirmed by processing the data with the NPL EyeLight software:

(Units are \(W \cdot m^2 \cdot sr^{-1} \cdot nm^{-1}\))

  • Raw Radiance: 71.5

  • Blue Light Corrected Radiance: 18.8

  • EyeLight: 16.8

_images/EyeLight.png

Section 4.2.1 (Annex ZB, page 40)

“When the luminance of the source is adequately high (\(\gt 10\,cd \cdot m^{-2}\)), and the exposure duration is greater the 0.25 s, a 3 mm pupil diameter (7mm\(^2\) area) was used to derive the exposure limit”

Given that our sphere may be used in a dark room following a period of dark adaptation, pupil diameter will be greater than 3 mm at the start of exposure. Typical experimental exposures will also be greater than 0.25 s.

Pupil ratio \((\frac{7}{3})^2\) = 5.4

To take this into account we applied a pupil correction factor of 6, which reduces the retinal blue light hazard exposure limit to 16.6 \(W \cdot m^2 \cdot sr^{-1}\)

In conclusion, when running the source at 100% and applying a safety factor to correct for the pupil size, our stimulation system is above the radiance retinal blue light hazard exposure limit value of 100 \(W \cdot m^2 \cdot sr^{-1}\) for an exposure of 10000 s.