Failing to calibrate flame-s-vis-nir-es spectrometer


Problem statement

Ever since I bought me the Torch Bearer Y21 spectrometer and borrowed another cheap-o spectrometer (Hopoocolor HPCS-320D, 370-780nm) from a friend, I’ve been suffering from Segal’s law:

A man with a watch knows what time it is. A man with two watches is never sure.

The obvious non-solution is to throw a third device in the mix1. Right?

Unfortunately unbeknownst to me, fancy (precise) spectrometers, like Ocean Optics Flame-S-VIS-NIR-ES2, don’t come “calibrated in absolute radiometric units”.

Worse yet, not being “calibrated in absolute radiometric units” means something different than I naïvely expected.

Expectation:

The readout is linear across the entire wavelength range, just the correspondence between Y axis and absolute value isn’t clear

Reality:

The readout across the wavelength range is highly non-linear

The only way to get the spectrometer to display proper sun spectrum instead of the following “garble”3 is to calibrate it.

flame vs torchbearer vs hpcs-320 comparison of the same spectra across two cheapo chinese (green, orange) and a fancy one (blue)

How not to do that4?

Read on… :-)

Root cause of the problem

Freaking out that I bought a lemon, I briefly chatted with Jürgen Söchtig from gmp.ch who calmed me down and explained that what I’m seeing is intrinsic to high-end devices, because they’re mainly used for relative measurements.

Before he could answer a few follow-up questions, I managed to figure out why I’m seeing what I’m seeing.

The issue is with the image sensor. The Flame-S has either Sony ILX511 or Sony ILX511B sensor with 2048 B&W pixels.

And one crucial design feature (“flaw”):

sony ilx511 spectral sensitivity Spectral sensitivity of SONY ILX511 (from datasheet)

sony ilx511b spectral sensitivity Spectral sensitivity of SONY ILX511B (from datasheet)

They are nowhere near linear across the wavelength range5.

And the sensor isn’t even the only thing in the optical pathway:

flame innards Ocean Optics Flame Spectrometer from the inside (labels in the manual above)

So there’s bound to be more fun to be had6.

First attempt at solving this

Well, if the datasheet says “this is the curve of the sensor”, translating the curve to floats and adjusting spectra I read out was my first attempt.

The lesson learned: how does one translate the curve in the age of “AI”.

I discovered WebPlotDigitizer which is an awesome tool. You tell it which pixels are which X coords, which are which Y coords, and with a bit of convincing it spits out CSV.

Sounds easy enough.

Spec sheet in hand, I uploaded the PDF, marked all that was necessary, and then discovered that since the datasheet is monochrome, WebPlotDigitizer takes the grid as points too. Ouch.

ilx511b failed read out Failed conversion to floats

Nothing a bit of pdftocairo:

pdftocairo -r 2400 -png ILX511.pdf -f 8 -singlefile ilx-old

and gimp love couldn’t fix:

ilx511b failed read out Gimped out the grid (mostly), recolored the axis, and even added teeny one-pixel red marks for the X1, X2, Y1, Y2 spots (500, 900, 0.1, 0.9)

After which it worked well:

ilx511b success Successful conversion to floats

Notice the much better resolution, because I went hog wild with 2400 dpi, resulting in 19834 x 28067 raw file trimmed down to 10288 x 7392.

That was a huge success, because saving the CSVs:

gave me the data needed (ILX511 sample):

>400.6758426916684, 0.8063234515811902
400.88530580103554, 0.8183199235748819
402.233450077073, 0.8257165255681422
...
994.3122834640396, 0.03020589465194501
998.2389145250553, 0.029199617702009517
1000.3496801423888, 0.02932951831005015

Now, with a bit of Mr. Python I could interpolate that to 1nm steps:

#!/usr/bin/env python3
"""Interpolates raw ilx511b sensitivities to 1nm steps"""

import csv
import json
import pprint
import sys

import numpy as np
from scipy.interpolate import interp1d
import matplotlib.pyplot as plt

wavelengths = []
intensities = []
#with open('sensitivity-fine.csv', newline='', encoding='utf-8') as csvfile:
with open('ilx-511-old.csv', newline='', encoding='utf-8') as csvfile:
    reader = csv.reader(csvfile)
    for ln in reader:
        if ln:
            wavelengths.append(float(ln[0].strip()))
            intensities.append(float(ln[1].strip()))

w_new = np.arange(340.0, 1025.0) # XXX: I know the orig data is 400..1000, sigh
i_new = interp1d(wavelengths, intensities, kind='linear', fill_value='extrapolate')(w_new)


if False:
    plt.plot(wavelengths, intensities, '-', label='Original Data', color='blue') 
    plt.plot(w_new, i_new, '-', label='Interpolated Data', color='red')

    plt.xlabel('X')
    plt.ylabel('Y')
    plt.title('Interpolation of Data')
    plt.legend()
    plt.grid(True)

    plt.show()

print(json.dumps(dict(zip(w_new.astype(int).astype(str), i_new)), indent=2))

and then with a bit of update to viz-scaled.rb from tobes-ui:

diff --git i/viz-scaled.rb w/viz-scaled.rb
index 7d31234..1020fe7 100644
--- i/viz-scaled.rb
+++ w/viz-scaled.rb
@@ -17,6 +17,7 @@ options = {
     max: true,
     rename: false,
     cmd: %w[python3 main.py -t overlay -d],
+    linearize_flame: false,
 }
 
 OptionParser.new do |opts|
@@ -38,6 +39,10 @@ OptionParser.new do |opts|
     opts.on('-c', '--command=CMD', "Tobes-ui command, default: #{options[:cmd]}") do |cmd|
         options[:command] = cmd
     end
+
+    opts.on('-l', '--[no-]linearize-flame', "Linearize FLAME-S data, default: #{options[:linearize_flame]}") do |lf|
+        options[:linearize_flame] = lf
+    end
 end.parse!
 
 if options[:wavelength] && options[:max]
@@ -53,6 +58,11 @@ Entries = Struct.new(:file, :wl, :max, :val_at_wl, :name, :data)
 
 entries = []
 
+ILX511=JSON.parse(File.read('ilx511.json')).map { |k,v| [k.to_i, v] }.to_h rescue nil
+def linearize_flame(wl, int)
+    int / ((ILX511 || {})[wl.to_i] || 1.0)
+end
+
 for file in ARGV
     begin
         name = nil
@@ -61,6 +71,9 @@ for file in ARGV
             file = $2
         end
         data = JSON.parse(File.read(file))
+        if options[:linearize_flame] && data['device'] =~ /^USB2000PLUS/
+            data['spd'] = data['spd'].map { |k, v| [k, linearize_flame(k, v)]}.to_h
+        end
         entries << Entries.new(
             name || file,
             Range.new(*data["wavelength_range"]),

I could plot (ruby viz-scaled.rb -l -n -w 550 examples/overcast-*json) the result.

Did that help?

Well, no:

after linearization by ilx511 After linearization by ILX511 curve (limited to 400..1000 due to artifacts)

and no:

after linearization by ilx511b After linearization by ILX511B curve

I mean… it’s better, but not nearly enough.

In other words, some other approach is needed.

Closing words

My next try is going to be calibrating against a few blackbodies (halogen incandescent bulbs) by curve fitting the response to the ideal curve and deriving the coefficients that way.

Hopefully that gets me closer to where I want to go7.

Stay tuned.

But in any case, I have a newfound respect for spectrography. Shit ain’t as easy as it seems, yo. Is this G….e?

  1. As luck would have it, I managed to score (second-hand) this high-end puppy that supposedly sold for $3500 and upwards… for peanuts.

  2. roughly 350..1000 nm range at ~0.38nm steps

  3. You can clearly see that the resolution of the Flame is far superior to both Chinesiums. But the curve doesn’t look right; it’s terribly attenuated prior to 530nm and past 650nm.

  4. For the record, you could fork over a pretty penny and gmp.ch would do the calibration for you. It was a bit over budget for me, so DIY it is.

  5. I’m guessing the Chinese end-user products already calibrate this away in the factory.

  6. Take that as the ominous clouds (foreshadowing) from future Wejn. As in: why my first attempt to calibrate failed.

  7. Maybe sans the UV range?