Single row entries in "theClut" during calibration with a spectroradiometer (CalMonSpd)

Hi all,

firstly: validation string for paid support is 3QAHKSZA-2023622143459:2d306c40949a44a22d95ee1fa166c9e06100f4a8f59973e599fbaecb4d534c67

Secondly, system info:
PTB3 3.0.19 - Flavor: Debian package - psychtoolbox-3 ( running in Matlab R2021a;
Ubuntu Linux 20.04;
Dell Precision T1650;
Nvidia GeForce 1660ti graphics card, which appears to be operating in 10-bit mode (Screen(ReadNormalizedGammaTable, wPtr) returns a 1024 x 3 array with apparently sensible values in it);
NEC spectraview 241 monitor connected to DisplayPort and separate experimenter’s monitor (cheap Dell generic type)

And, for calibration, we have a PR670 spectroradiometer connected via serial/USB and verified as working OK (i.e. talking to Matlab in Remote Mode and returning meaningful measurement data).

The question is this: During calibration via CalibrateMonspd → CalibrateMonDrvr etc., why are the first and second rows of the CLUT set to equal, firstly, full white [1,1,1] and the background surround (as an RGB triplet) respectively? Then, during the main calibration run, why is row 2 of the CLUT repeatedly set to equal the colour triplet generated in the array mGammaInput before the normalized gamma table is uploaded to the graphics card?

The calibration isn’t working, and I’m trying to debug why that might be (e.g. I keep getting a white square on the monitor instead of red, then green, then blue etc.). However, I don’t understand why PTB would overwrite the 2nd row of the existing CLUT (obtained via Screen(‘ReadNormalizedGammaTable’, window) in MeasMonSpd.m) with a single RGB triplet. How is MeasMonSpd accessing that triplet to display it on the screen (or not display it on the screen, in my case)?

Before anyone says this, I have read and noted the comments at the top of CalibrateMonSpd.m which says that "This code is a bit dusty, as it is not being actively maintained… " and then Mario has supplied some helpful info on using the PsychImaging pipeline and LoadIdentityClut( ). However, I can’t see LoadIdentityClut( ) anywhere in the calibration functions, apart from the implication that it is called by PsychImaging.

Do I need to strip out the Screen(‘LoadNormalizedGammaTable’, ) calls? and replace them with LoadIdentityClut( )?

In that case, how does one pass specific values (such as the R, G, B values associated with the calibration box on the screen) via that method?

And, finally, is an “identity clut” just a clut (e.g. 256 x 3) populated with 1.0 values throughout? Or does it have a special meaning?

Thank you. If there are too many questions here, the most important thing is for us to understand why calibration stimuli are being sent to the graphics card as the 2nd line of a CLUT (via the line theClut(2,:slight_smile: = settings(:, i)';
in MeasMonSpd( ), lines 83, 93).

Stupid autocorrect!

The Matlab line near the end of my question should have read:

theClut(2, : ) = settings( : , i)';

with no smiley faces!

Ok, so this code is in very bad shape. I just spent 2 hours combing through it, just to make some sense of it, and found multiple bugs - or more specifically: This code was written to deal with operating systems and graphics cards or display devices from over 10 years ago. And not updated since then. And it shows badly.

In CalibrateMonSpd.m, the line cal.describe.dacsize = ScreenDacBits(whichScreen); sets dacsize to 0 Bits! This is a bug present since over 5 years, which makes this dysfunctional, and nobody ever reported it, so apparently nobody is using this function.
Problem is that on modern systems it is essentially almost impossible to automatically find the correct DAC output bit precision. So this should actually ask the user for the equivalent of DAC size. In your case, the proper value could possibly be 10 for 10 bit, or 12 for 12 bit, or even 8 bit.

→ Your framebuffer likely only operates with 8 bpc precision, unless you used XOrgConfCreator or nvidia’s display settings tool to create a xorg.conf for colordepth 30 aka 10 bpc for ~1 billion colors.
→ The hardware lut seems to have 1024 slots, so can accept up to 10 bpc from the framebuffer, as 2^10 == 1024. But in 8 bpc framebuffer mode, only every 4th slot of the lut will be actually used.
→ The output precision of NVidia’s hardware luts is 12 bpc, according to some notes of mine, and that is also what DisplayPort should be able to transport to your monitor unless there are cable problems causing signal degradation and fallback to 10 bpc or 8 bpc.
→ Your monitor itself is specced as having an internal 14 bpc lut and a native 10 bpc panel. So go figure what dacsize actually is for your setup…

Then the meterType in the same file is 1 for a PR-650, but it needs to be 5 for a PR-670 (see help CMCheckInit), or things may go wrong with measurement. And the assumed spectral sampling definition is also for a PR-650, and various other code bits assume a PR-650, as nobody ever bothered to update for later models of Photo Research Spectrometers!

The script also only “handles” CRS Bits and VPixx visual stimulators, and bog-standard 8 bit displays, and isn’t updated to use the myriad of other high precision stimulators or high color precision display modes PTB supports since 2007 and later.

Finally to your question: The reason it sets theClut(2,:slight_smile: = … because it assumes that clut row 2 defines the color of framebuffer pixels written with color value 1 (fb 0 → clut row 1, fb 1 → clut row 2, … fb 255 → clut row 256). So the code draws background in color fb 0 and the target for measurement in color fb 1 (see ‘FillRect’ commands) and assume that manipulating clut rows 1 and 2 allows to set background/target color with highest precision.

Except: That is only true for a 256 slot hardware clut driven by a 8 bpc framebuffer, not for a 1024 slot lut like yours! If you’d switch PTB into 10 bpc framebuffer mode, it might work under Linux with NVidia. Or update clut row 5,6,7 or 8.

Except: Microsoft windows would not ever accept cluts as specified in this script, as they need to be monotonically increasing and inside an “acceptable” corridor of values for a typical gamma correction curve. One can clearly see that the author of this code, David Brainard, is an Apple macOS user who never had much contact with MS-Windows… But also that his lab is no longer using or maintaining these routines, as they would fail just as catastrophically on any modern macOS.

And modern macOS and Linux on most hardware past the year ~2012 do not use discrete clut lookup tables anymore, but piece-wise linear cluts or other parameterized lookup tables. So the whole concept here is doomed. As you are likely using the NVidia proprietary graphics and display driver on your a NVidia gpu, I don’t know how your specific driver and gpu combo handles this.

The proper way one should rewrite this whole functionality is shown in MeasureLuminancePrecision.m, originally written by Denis Pelli, substantially rewritten and refined by myself. But that script is not meant for color calibration, only for measurement of the effective visual output precision.

[120 minutes spent out of 30 minutes paid.]

Ok, I just couldn’t stand looking at this misery and tried to unbreak this broken mess a bit.
The other option would have been ripping it out, but on the math side of it there’s a lot of clever work from the Brainard lab in there, so that would be a bit of a pity…

The result is in my GitHub master branch, and at least seems to do some reasonable things in simulation mode, ie. with meterType = 0, as I don’t have access to any real Photo Research colorimeters.

I think the old code sort of worked if you had a PR650 colorimeter in combination with a visual stimulator from CRS or VPixx. Or a standard display with an old graphics card running under old versions of Linux or old versions of macOS (probably older than macOS 10.12, maybe even older), iow. hardware and software from before the year 2012.

The new code should work on all operating systems and gpu’s and also with standard 8 bpc framebuffers. And support all other models of Photo Research colorimeters listed in ‘help CMCheckInit’. You have to set meterType accordingly, it now defaults to simulation.

The trick is to keep the weird use of clut’s, but on standard setups use virtual cluts with matching properties, as emulated/implemented via our imaging pipeline, ie. by use of the
PsychImaging('AddTask', 'AllViews', 'EnableCLUTMapping', 256, 1); task. Essentially we load a linear identity mapping ramp into the graphics cards hardware luts and then use our own clut implementation to get a 256 slot lut, compatible with the 256 slot luts on CRS and VPixx devices. There were various bugs in that code that got fixed.

This will be part of the future PTB release. Until then you’d need to get all the modified files from my GitHub, as listed in the left column here:

Ie. CalibrateMonSpd.m, Psychtoolbox/PsychCal/Calibrate*Drvr.m, MeasMonSpd.m, and MeasSpd.m at a minimum. CalDemo.m also saw a fix.

I hope this works better, because otherwise I wasted a full workday for nothing.

[11 hours work spent for 30 minutes of time paid, license exhausted multiple times over.]

Thank you Mario.

Lots of helpful info in your first reply and I really do appreciate you taking the time to set out all these aspects.

I had worked out a couple of those problems already (e.g. the meterType = 5 and then the wavelength spacing was incorrect for a PR670) but by no means everything. It really was a rat’s nest of nested function calls originally!

The Nvidia card is a mess and I’ve already started taking steps to replace it.

We have a CRS Bits# that I considered using, but our experiment needs 2 subject monitors and 1 * Bits# cannot cope with that.

I’ll look on your GitHub master branch, for sure. I came into the lab today determined to write my own script, taking the best aspects of Brainard’s original functions, but it sounds as if that is no longer necessary with your new script.

The 11hrs is understood, and I know that “Thank you”s don’t pay the bills! I will try and get our budget controller to purchase some more support hours.

Best wishes,


One minor observation on your reply: I don’t think cal.describe.dacsize is actually used anywhere, so the fact that it is set to zero by the line cal.describe.dacsize = ScreenDacBits(whichScreen);
would not be evident to most people.

I had fixed this bug prior to my post on this forum, but fixing it didn’t seem to make any difference to anything… and a search of the old calibration functions revealed that it’s not used by any of them (unless I missed something).

The wavelength spacing is an interesting one, which took me a while to wrap my head around. It seems that while the wavelength spacing of different devices differs, the code of CalibrateMonSpd() kind of assumes a unified wavelength spacing, as set via cal.describe.S. The measurement code of the specific PRxxx device then uses spline interpolation to map/resample from actual device sampled data to that target cal.describe.S, e.g., CalibrateMonSpd() → CalibrateMonDrv() → MeasMonSpd() → MeasSpd() → PR670measspd() → PR670parsespdstr() → spd = SplineSpd(S0, spd, S); where SplineSpd resamples from actual raw measured data ‘spd’ according to the actual device sampling ‘S0’ to the desired output distribution ‘S’ == cal.describe.S.

I guess the idea is that if a lab uses different models of colormeters simultaneously or over time, it can make all measurements/calibrations standardized to one sampling distribution to simplify workflow, interchange of devices or comparing old calibration data to new one. In that sense it doesn’t matter if cal.describe.S corresponds to the actual devices sampling - within reason… - At least that was my thinking and why I left that part untouched and on one fixed sampling.

If it worked/works for you, you don’t need to exchange it for this specific issue, as the new “virtual clut” implementation takes care of gpu differences. In general, other limitations wrt. NVidia and the closed source nature of the driver hold of course, making debugging / user support harder.

I think the only advantage NVidia currently has on Linux is when using frame-sequential stereo modes on the expensive Quadro Pro cards, where NVidia’s proprietary driver has a bunch of out-of-the-box stereomodes (untested by myself though - never had the hardware to test this aspect, but the drivers user manual suggests that), and the Intel/AMD drivers don’t. In all other areas it is, as far as I know, the worse choice.

Hope so. At least that would be the new starting point for further enhancements, as it is less bad. One limitation that is left is that the new code - just as the old code - does not support use of high color precision display modes during calibration apart from the ones implemented for CRS and VPixx devices. E.g., native 10 bpc framebuffers on all non-ancient AMD (> year 2006) , Intel (~ > 2010) and Nvidia (~ 2009) gpu’s, or 12 bpc framebuffers on modern AMD’s (iirc around year 2014 and later) when using the amdvlk Vulkan driver and suitable displays. All details are in help PsychImaging.
This could be easily added by just adding a few more lines of PsychImaging(‘AddTask’,…) statements to each of Calibrate*Drv.m, but was too tedious to do, given the large amount of unpaid work time I already spent. One would probably have to refactor the whole thing and have the whole display and calibration setup logic in one place, instead of replicated 3 times.

If it matters, I don’t know, but probably not: Nobody will do a fine enough calibration taking so many readings to cover the whole representable set of colors, so 12 / 10 bpc won’t have an advantage over the default 8 bpc for covering the displayable color gamut. The high quality calibration is an interpolated thing from the measurements and should apply equally if one switches to 10 bpc or 12 bpc modes for actually running the experiment.

I’ll send you a private message for purchasing extra hours for the work already done if you wanted to compensate us. It is a different “extra work hours product” that would normally kick in after the 30 minutes of standard support are exhausted. It comes with discounts, e.g., your license allowed for a 25% discount.

It is used at the very end of CalibrateMonDrv.m as input to cal = CalibrateFitGamma(cal, round(2^cal.describe.dacsize)); - It determines the size of the returned gammaTable which describes the gamma response curves, and therebe the size of the gamma correction table one would use for gamma correction. Ie. the lut loaded via Screen('LoadNormalizedGammatable'). The size of that table needs to match the size of the device gamma table if any (CRS or VPixx devices), or a virtual gamma table implemented by PsychColorCorrection() if you use our imaging pipelines gamma correction (cfe. AdditiveBlendingFor… demo), or in the simple case of the gpu gamma table - ie. matching size of the one returned by Screen('ReadNormalizedGammatable'). If you’d pass in a larger or smaller table than what the operating system expects in this simple case, either PTB or the OS would resample from what you provide to what the gpu hardware driver expects. So the wrong value would lose precision. On MS-Windows that table is always 256 slots (OS limitation), on Linux and macOS it varies by gpu model and vendor and driver versions, and resampling happens: On Linux PTB itself currently implements a simple nearest neighbour interpolation, on macOS we let the OS interpolate in an Apple specific and proprietary way iirc.

This is then not where it ends: Whatever interpolated lut is created at the end goes to the low level display driver, which then converts from this “naive” input lut to a lut and format that the display hardware actually implements, employing interpolation, extrapolation or curve / function fitting proprietary to each driver and gpu hardware generation. A piece-wise linear function fit to what you actually pass in as lut is quite common with current hardware, but the fitting algorithms and parameters and constraints vary across gpu vendors, models and drivers, and are usually unknown for proprietary operating systems or drivers.

This is a long-winded way to say it’s complicated, and best to feed the system a lut that is as close as possible to what it actually expects, to avoid too much interpolation with unknown properties.

That’s why my new code tries to load a linear ramp of proper lut size to establish a close to identity mapping framebuffer → video output during the calibration of standard displays. And tries to get cal.describe.dacsize more correct. ScreenDacBits() btw. is kind of hopelessly broken for modern systems. I kept it for questionable backwards compatibility, but now it just always returns a hard-coded 8 bpc + a warning message, as that is the best one can do atm. on any operating system.

Btw. LoadIdentityClut() would load a mapping that tries to guarantee identity passthrough of an 8 bpc framebuffer to 8 bpc outputs, as that is what is needed for special visual stimulators from VPixx or CRS or similar.

It’s all non-trivial and fraught with little spots where one can go slightly wrong…

And this will get much more complicated in the foreseeable future due to new developments in OS and display tech.


Hi Mario and all interested readers,

I am happy to report that Mario’s revised Github code for CalibrateMonSpd and the called functions works with a PR-670 radiometer.

The wavelength spacing on the PR-670 is 2nm, so line 128 of CalibrateMonSpd should read

cal.describe.S = [380 2 201];

since the instrument returns 201 spectral data points per measurement.

With that in place, all went smoothly, except that (on my Linux install) the script attempted to write the calibration file to the default directory for PTB, usr/share/psychtoolbox-3 and that requires root privileges. Changing that to point to a more friendly location is an easy edit, though.

I agree that more than 8 bits if resolution on each R, G and B channel is not always necessary; clearly it depends on the experiment and how sensitive our subjects are to the changes being made to stimuli. I will investigate adding a task to PsychImaging as you suggest. If the Nvidia card is labouring under the delusion that it supports 10 bit DAC tables, it would be good to use that capability (if it really exists) for situations where 2 x Bits++ or Bits# are not available.


See comments from previous post. The thing is that CalibrateMonSpd.m wasn’t meant to be a script on just runs, but more something to use as a starting template to customize for your own specific setup. At least that’s my understanding of it.

Yes, one can specify an actual target folder help SaveCalFile. Nowadays one could also, e.g., use the function dstpath = PsychtoolboxConfigDir('colorcalibration') to get a path to a (automatically created) subfolder ‘colorcalibration’ of the default folder for PTB configuration data, which is supposed to be in a user writable location, e.g., ~/.Psychtoolbox/colorcalibration on a Linux system.

I think it is not neccessarily needed for the calibration, as probably nobody will run so many calibration measurement to take so many samples that standard sampling of the color space at 8 bpc would impose a limitation.

Once you have the calibration and also properly interpolated gamma correction tables etc. from that limited number of measurements, you can use those tables to also configure the system for stimulation at a higher precision, e.g., 10 bpc or even 12 bpc under Linux + AMD graphics.

The MeasureLuminancePrecision.m script probably gives you a good starter on different standard dynamic range SDR display modes.

It is not a delusion for the graphics card, it has 1024 slots or more, and output lut’s nowadays can be as wide as 12 bpc, but what ends on the display depends on many software and hardware factors, also on factors like cable quality or if a given gpu + cable type + cable + display can handle a specific bit depths at a given display resolution and refresh rate and multi-monitor setup. On Linux, e.g., I spent quite a bit of time improving X-Server 21 in that area for some gpu models, shipping since Ubuntu 22.04-LTS. But yeah that’s why the updated script asks ‘ReadnormalizedGammatable’ for the desired optimal size.

Btw. for the CRS Bits+ the default is now 8 bit = 256 slots in that script, but a previous value of 14 bits was encoded in the old scripts (inside ScreenDacBits.m). That value can also make sense iff one has a Bits# and uses the device builtin dedicated hardware gammatable, instead of the 256 slot device clut. That gammatable needs to be configured via some text file that gets uploaded to the device iirc. and may have 2^14 slots → Consult your user manual about this stuff.

The settings in that script really depend a lot on your exact setup and use, but at least the mechanics of it should work now again.