cal_lib

  1import usb.core
  2import usb.util
  3import usb.backend.libusb1
  4import numpy as np
  5import struct
  6import time
  7import numpy as np
  8import matplotlib.pyplot as plt
  9from scipy.optimize import curve_fit
 10
 11# to run this code on windows10 [other OS not tested] you have to install the usblib drivers
 12# git clone https://github.com/microsoft/vcpkg.git
 13# cd vcpkg
 14# bootstrap-vcpkg.bat # (Visual Studio required)
 15# vcpkg install libusb
 16# then the library is located in ...\vcpkg\installed\x64-windows\bin\lib usb-1.0.dll
 17# IMPORTANT if the device is not found (or you see reading timeout) it's possibly is because the system loaded the wrong driver you should change the default driver with libusbK (if you previously isntalled the official DataColor drivers you have to remove it using https://github.com/lostindark/DriverStoreExplorer?tab=readme-ov-file)
 18
 19class SpyderX:
 20    """
 21    SpyderX colorimeter class for Windows using PyUSB + libusbK driver.
 22    """
 23
 24    def __init__(self, libusb_path):
 25        """
 26        Initialize the SpyderX device.
 27
 28        Args:
 29            libusb_path (str): Full path to libusb-1.0.dll, for example:
 30                               r'C:\\vcpkg\\installed\\x64-windows\\bin\\libusb-1.0.dll'
 31        """
 32        # Load the specified libusb-1.0.dll
 33        self.backend = usb.backend.libusb1.get_backend(find_library=lambda x: libusb_path)
 34        if self.backend is None:
 35            raise RuntimeError(f"Could not load libusb from: {libusb_path}")
 36
 37        # Find SpyderX device on the bus
 38        self.dev = usb.core.find(idVendor=0x085C, idProduct=0x0A00, backend=self.backend)
 39        if self.dev is None:
 40            raise ValueError("SpyderX device not found. Check if it's plugged in and using libusbK driver via Zadig.")
 41
 42        # Attempt to set configuration
 43        try:
 44            self.dev.set_configuration()
 45        except usb.core.USBError:
 46            pass  # Often already set
 47
 48        # Detach kernel driver if active (uncommon on Windows, but safe to try)
 49        try:
 50            if self.dev.is_kernel_driver_active(0):
 51                self.dev.detach_kernel_driver(0)
 52        except (NotImplementedError, usb.core.USBError):
 53            pass
 54
 55        # Claim interface
 56        usb.util.claim_interface(self.dev, 0)
 57
 58        # Initialize the device (control transfers, then get calibration data)
 59        self._initialize_device()
 60
 61    def _initialize_device(self):
 62        """
 63        Send the standard control transfers, then retrieve factory calibration and measurement setup.
 64        """
 65        # Standard set of control transfers to 'wake' the SpyderX
 66        self._ctrl(0x02, 1, 0, 1)
 67        self._ctrl(0x02, 1, 0, 129)
 68        self._ctrl(0x41, 2, 2, 0)
 69
 70        # Retrieve factory calibration (matrix, v1, v2, etc.)
 71        self._get_calibration()
 72        # Retrieve measurement setup values (s1, s2, s3)
 73        self._setup_measurement()
 74
 75    def _ctrl(self, bmRequestType, bRequest, wValue, wIndex):
 76        """
 77        Helper for control transfers.
 78        """
 79        self.dev.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, None)
 80
 81    def _bulk(self, data, read_size, timeout=1000):
 82        """
 83        Helper for bulk transfers. Writes 'data' to endpoint 1, reads 'read_size' bytes from endpoint 0x81.
 84        """
 85        self.dev.write(1, data, timeout=timeout)
 86        return self.dev.read(0x81, read_size, timeout=timeout)
 87
 88    def _read_ieee754(self, b):
 89        """
 90        Decode a 32-bit IEEE-754 float from 4 bytes in 'b'.
 91        The SpyderX data is little-endian, so we reverse and parse.
 92        """
 93        b = b[::-1]
 94        raw = int.from_bytes(b, "big")
 95        sign = (raw >> 31) & 1
 96        exponent = (raw >> 23) & 0xFF
 97        fraction = raw & 0x7FFFFF
 98        return (-1) ** sign * (1 + fraction / 2**23) * 2**(exponent - 127)
 99
100    def _get_calibration(self):
101        """
102        Retrieve factory calibration data, storing:
103          - v1, v2: calibration parameters
104          - calibration_matrix: 3x3 for XYZ computation
105        """
106        # Command 0xcb for factory calibration, read 47 bytes
107        out = self._bulk([0xcb, 0x05, 0x73, 0x00, 0x01, 0x00], 47)[5:]
108        self.v1 = out[1]
109        self.v2 = int.from_bytes(out[2:4], 'big')
110
111        # Build the 3x3 matrix
112        matrix = np.zeros((3, 3))
113        for i in range(3):
114            for j in range(3):
115                k = i*3 + j
116                matrix[i, j] = self._read_ieee754(out[k*4 + 4 : k*4 + 8])
117
118        self.calibration_matrix = matrix
119
120    def _setup_measurement(self):
121        """
122        Retrieve s1, s2, s3 from the device, used in calibrate() and measure() calls.
123        """
124        # 0xc3 sets up measurement for the device
125        payload = [0xc3, 0x29, 0x27, 0x00, 0x01, self.v1]
126        out = self._bulk(payload, 15)
127
128        self.s1 = out[5]
129        self.s2 = out[6:10]
130        self.s3 = out[10:14]
131
132    def calibrate(self):
133        """
134        Perform black (dark) calibration.
135        The user should cover the SpyderX lens before calling this.
136        """
137        # Standard control transfer first
138        self._ctrl(0x41, 2, 2, 0)
139
140        # build payload from v2, s1, s2
141        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
142        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
143
144        # parse the black calibration data
145        raw = struct.unpack('>HHHH', out[5:])
146        self.black_cal = np.array(raw[:3])
147
148    def measure(self):
149        """
150        Perform a measurement, returning XYZ as a numpy array [X, Y, Z].
151        Y is luminance in cd/m².
152        """
153        # Control transfer
154        self._ctrl(0x41, 2, 2, 0)
155
156        # build measurement payload
157        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
158        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
159
160        raw = np.array(struct.unpack('>HHHH', out[5:]))[:3]
161        corrected = raw - self.black_cal
162        xyz = np.dot(corrected, self.calibration_matrix)
163        return xyz
164
165    def get_luminance(self):
166        """
167        Return just the Y (luminance) component in cd/m².
168        """
169        return self.measure()[1]
170
171    def get_rgb_luminance(self):
172        """
173        Convert measured XYZ to approximate linear RGB intensities.
174        Useful for relative channel brightness checks.
175        """
176        xyz = self.measure()
177        xyz_to_rgb = np.array([
178            [ 3.2406, -1.5372, -0.4986],
179            [-0.9689,  1.8758,  0.0415],
180            [ 0.0557, -0.2040,  1.0570]
181        ])
182        rgb = np.dot(xyz_to_rgb, xyz)
183        return tuple(rgb)
184
185    def close(self):
186        """
187        Release the interface and USB resources.
188        """
189        usb.util.release_interface(self.dev, 0)
190        usb.util.dispose_resources(self.dev)
191
192def xyz_to_lms(xyz):
193    # XYZ to LMS conversion matrix (Hunt-Pointer-Estevez)
194    xyz_to_lms_matrix = np.array([
195        [0.4002, 0.7076, -0.0808],
196        [-0.2263, 1.1653, 0.0457],
197        [0.0, 0.0, 0.9182]
198    ])
199    return np.dot(xyz_to_lms_matrix, xyz)
200
201class GammaFitter:
202    """
203    A class to fit a gamma function to luminance data for monitor calibration.
204
205    This class takes grayscale intensity values and corresponding luminance measurements,
206    normalizes the data, and fits a gamma function to estimate the display gamma.
207    It also provides tools for visualizing the fit and customizing fit parameters.
208
209    Attributes:
210        original_intensities (numpy.ndarray): Original grayscale intensity values.
211        original_luminance (numpy.ndarray): Original luminance measurements.
212        intensities (numpy.ndarray): Normalized grayscale intensities [0, 1].
213        luminance (numpy.ndarray): Normalized luminance values [0, 1].
214        params (list): Parameters of the fitted gamma function [a, b, c].
215        gamma (float): The gamma value (b) obtained from the fitted curve.
216        lower_bounds (list): Lower bounds for curve fitting parameters.
217        upper_bounds (list): Upper bounds for curve fitting parameters.
218        initial_guess (list): Initial guess for the curve fitting parameters.
219    """
220
221    def __init__(self, intensities, luminance):
222        """
223        Initializes the GammaFitter class.
224
225        Args:
226            intensities (list or array): The grayscale intensity values.
227            luminance (list or array): The corresponding luminance measurements.
228        """
229        # Store original values for plotting
230        self.original_intensities = np.array(intensities)
231        self.original_luminance = np.array(luminance)
232
233        # Normalize input data to the range [0, 1]
234        self.intensities = (self.original_intensities - np.min(self.original_intensities)) / (
235                    np.max(self.original_intensities) - np.min(self.original_intensities))
236        self.luminance = (self.original_luminance - np.min(self.original_luminance)) / (
237                    np.max(self.original_luminance) - np.min(self.original_luminance))
238
239        self.params = None
240        self.gamma = None
241
242        # Default values for bounds and initial guess based on monitor gamma calibration
243        self.lower_bounds = [0, 0, 0]  # Lower bounds: a ≥ 0, b ≥ 1 (physical gamma), c ≥ 0
244        self.upper_bounds = [10, 5, 1]  # Upper bounds: a ≤ 10, b ≤ 5, c ≤ 0.2
245        self.initial_guess = [0, 1, 0.01]  # Typical monitor gamma: a=1, b=2.2, c=0.01
246
247    def gamma_function(self, x, a, b, c):
248        """
249        Gamma function to model luminance as a function of intensity.
250
251        Args:
252            x (float or array): The normalized intensity values.
253            a (float): Scaling factor for luminance.
254            b (float): Gamma value (exponent).
255            c (float): Offset or baseline luminance.
256
257        Returns:
258            float or array: Modeled luminance values.
259        """
260        return a * (x ** b) + c
261
262    def set_bounds(self, lower_bounds, upper_bounds):
263        """
264        Set custom lower and upper bounds for the gamma fit parameters.
265
266        Args:
267            lower_bounds (list): Lower bounds for the parameters [a, b, c].
268            upper_bounds (list): Upper bounds for the parameters [a, b, c].
269        """
270        self.lower_bounds = lower_bounds
271        self.upper_bounds = upper_bounds
272
273    def set_initial_guess(self, initial_guess):
274        """
275        Set a custom initial guess for the gamma fit parameters.
276
277        Args:
278            initial_guess (list): Initial guess for the parameters [a, b, c].
279        """
280        self.initial_guess = initial_guess
281
282    def fit(self):
283        """
284        Fits the gamma function to the normalized intensity and luminance data.
285
286        Returns:
287            list: The fitted parameters [a, b, c], where 'b' is the gamma value.
288        """
289        bounds = (self.lower_bounds, self.upper_bounds)
290        self.params, _ = curve_fit(self.gamma_function, self.intensities, self.luminance, p0=self.initial_guess,
291                                   bounds=bounds)
292        self.gamma = self.params[1]
293        return self.params
294
295    def plot(self):
296        """
297        Plots the original data and the fitted gamma curve.
298
299        Raises:
300            ValueError: If the fit has not been performed before calling this method.
301        """
302        if self.params is None:
303            raise ValueError("Fit the data first using the 'fit' method before plotting.")
304
305        plt.figure(figsize=(8, 6))
306        plt.scatter(self.original_intensities, self.original_luminance, label='Original Data', color='blue', alpha=0.6)
307
308        # Generate the fit curve in the normalized range
309        x_fit_normalized = np.linspace(0, 1, 100)
310        y_fit_normalized = self.gamma_function(x_fit_normalized, *self.params)
311
312        # Convert back to the original scale for plotting
313        x_fit_original = x_fit_normalized * (
314                    np.max(self.original_intensities) - np.min(self.original_intensities)) + np.min(
315            self.original_intensities)
316        y_fit_original = y_fit_normalized * (
317                    np.max(self.original_luminance) - np.min(self.original_luminance)) + np.min(self.original_luminance)
318
319        plt.plot(x_fit_original, y_fit_original,
320                 label=f'Gamma={self.params[1]:.3f}, Base luminance={self.params[2]:.3f}',
321                 color='red')
322        plt.xlabel('Original Intensity')
323        plt.ylabel('Original Luminance')
324        plt.title('Gamma Function Fit for Monitor Calibration')
325        plt.legend()
326        plt.grid()
327        plt.show()
328
329if __name__=='__main__':
330    spyder = SpyderX(r"C:\cancellami\vcpkg\installed\x64-windows\bin\libusb-1.0.dll")
331    try:
332        print("Performing Dark calibration...")
333        spyder.calibrate()
334        print("Starting measurements...")
335        while True:
336            xyz = spyder.measure()
337            lms = xyz_to_lms(xyz)
338            print(f"XYZ values: {xyz}")
339            print(f"LMS values: {lms}")
340            time.sleep(2)
341
342    except Exception as e:
343        print(f"An error occurred: {str(e)}")
344        raise e
345    finally:
346        spyder.close()
347        print("SpyderX closed.")
class SpyderX:
 20class SpyderX:
 21    """
 22    SpyderX colorimeter class for Windows using PyUSB + libusbK driver.
 23    """
 24
 25    def __init__(self, libusb_path):
 26        """
 27        Initialize the SpyderX device.
 28
 29        Args:
 30            libusb_path (str): Full path to libusb-1.0.dll, for example:
 31                               r'C:\\vcpkg\\installed\\x64-windows\\bin\\libusb-1.0.dll'
 32        """
 33        # Load the specified libusb-1.0.dll
 34        self.backend = usb.backend.libusb1.get_backend(find_library=lambda x: libusb_path)
 35        if self.backend is None:
 36            raise RuntimeError(f"Could not load libusb from: {libusb_path}")
 37
 38        # Find SpyderX device on the bus
 39        self.dev = usb.core.find(idVendor=0x085C, idProduct=0x0A00, backend=self.backend)
 40        if self.dev is None:
 41            raise ValueError("SpyderX device not found. Check if it's plugged in and using libusbK driver via Zadig.")
 42
 43        # Attempt to set configuration
 44        try:
 45            self.dev.set_configuration()
 46        except usb.core.USBError:
 47            pass  # Often already set
 48
 49        # Detach kernel driver if active (uncommon on Windows, but safe to try)
 50        try:
 51            if self.dev.is_kernel_driver_active(0):
 52                self.dev.detach_kernel_driver(0)
 53        except (NotImplementedError, usb.core.USBError):
 54            pass
 55
 56        # Claim interface
 57        usb.util.claim_interface(self.dev, 0)
 58
 59        # Initialize the device (control transfers, then get calibration data)
 60        self._initialize_device()
 61
 62    def _initialize_device(self):
 63        """
 64        Send the standard control transfers, then retrieve factory calibration and measurement setup.
 65        """
 66        # Standard set of control transfers to 'wake' the SpyderX
 67        self._ctrl(0x02, 1, 0, 1)
 68        self._ctrl(0x02, 1, 0, 129)
 69        self._ctrl(0x41, 2, 2, 0)
 70
 71        # Retrieve factory calibration (matrix, v1, v2, etc.)
 72        self._get_calibration()
 73        # Retrieve measurement setup values (s1, s2, s3)
 74        self._setup_measurement()
 75
 76    def _ctrl(self, bmRequestType, bRequest, wValue, wIndex):
 77        """
 78        Helper for control transfers.
 79        """
 80        self.dev.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, None)
 81
 82    def _bulk(self, data, read_size, timeout=1000):
 83        """
 84        Helper for bulk transfers. Writes 'data' to endpoint 1, reads 'read_size' bytes from endpoint 0x81.
 85        """
 86        self.dev.write(1, data, timeout=timeout)
 87        return self.dev.read(0x81, read_size, timeout=timeout)
 88
 89    def _read_ieee754(self, b):
 90        """
 91        Decode a 32-bit IEEE-754 float from 4 bytes in 'b'.
 92        The SpyderX data is little-endian, so we reverse and parse.
 93        """
 94        b = b[::-1]
 95        raw = int.from_bytes(b, "big")
 96        sign = (raw >> 31) & 1
 97        exponent = (raw >> 23) & 0xFF
 98        fraction = raw & 0x7FFFFF
 99        return (-1) ** sign * (1 + fraction / 2**23) * 2**(exponent - 127)
100
101    def _get_calibration(self):
102        """
103        Retrieve factory calibration data, storing:
104          - v1, v2: calibration parameters
105          - calibration_matrix: 3x3 for XYZ computation
106        """
107        # Command 0xcb for factory calibration, read 47 bytes
108        out = self._bulk([0xcb, 0x05, 0x73, 0x00, 0x01, 0x00], 47)[5:]
109        self.v1 = out[1]
110        self.v2 = int.from_bytes(out[2:4], 'big')
111
112        # Build the 3x3 matrix
113        matrix = np.zeros((3, 3))
114        for i in range(3):
115            for j in range(3):
116                k = i*3 + j
117                matrix[i, j] = self._read_ieee754(out[k*4 + 4 : k*4 + 8])
118
119        self.calibration_matrix = matrix
120
121    def _setup_measurement(self):
122        """
123        Retrieve s1, s2, s3 from the device, used in calibrate() and measure() calls.
124        """
125        # 0xc3 sets up measurement for the device
126        payload = [0xc3, 0x29, 0x27, 0x00, 0x01, self.v1]
127        out = self._bulk(payload, 15)
128
129        self.s1 = out[5]
130        self.s2 = out[6:10]
131        self.s3 = out[10:14]
132
133    def calibrate(self):
134        """
135        Perform black (dark) calibration.
136        The user should cover the SpyderX lens before calling this.
137        """
138        # Standard control transfer first
139        self._ctrl(0x41, 2, 2, 0)
140
141        # build payload from v2, s1, s2
142        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
143        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
144
145        # parse the black calibration data
146        raw = struct.unpack('>HHHH', out[5:])
147        self.black_cal = np.array(raw[:3])
148
149    def measure(self):
150        """
151        Perform a measurement, returning XYZ as a numpy array [X, Y, Z].
152        Y is luminance in cd/m².
153        """
154        # Control transfer
155        self._ctrl(0x41, 2, 2, 0)
156
157        # build measurement payload
158        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
159        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
160
161        raw = np.array(struct.unpack('>HHHH', out[5:]))[:3]
162        corrected = raw - self.black_cal
163        xyz = np.dot(corrected, self.calibration_matrix)
164        return xyz
165
166    def get_luminance(self):
167        """
168        Return just the Y (luminance) component in cd/m².
169        """
170        return self.measure()[1]
171
172    def get_rgb_luminance(self):
173        """
174        Convert measured XYZ to approximate linear RGB intensities.
175        Useful for relative channel brightness checks.
176        """
177        xyz = self.measure()
178        xyz_to_rgb = np.array([
179            [ 3.2406, -1.5372, -0.4986],
180            [-0.9689,  1.8758,  0.0415],
181            [ 0.0557, -0.2040,  1.0570]
182        ])
183        rgb = np.dot(xyz_to_rgb, xyz)
184        return tuple(rgb)
185
186    def close(self):
187        """
188        Release the interface and USB resources.
189        """
190        usb.util.release_interface(self.dev, 0)
191        usb.util.dispose_resources(self.dev)

SpyderX colorimeter class for Windows using PyUSB + libusbK driver.

SpyderX(libusb_path)
25    def __init__(self, libusb_path):
26        """
27        Initialize the SpyderX device.
28
29        Args:
30            libusb_path (str): Full path to libusb-1.0.dll, for example:
31                               r'C:\\vcpkg\\installed\\x64-windows\\bin\\libusb-1.0.dll'
32        """
33        # Load the specified libusb-1.0.dll
34        self.backend = usb.backend.libusb1.get_backend(find_library=lambda x: libusb_path)
35        if self.backend is None:
36            raise RuntimeError(f"Could not load libusb from: {libusb_path}")
37
38        # Find SpyderX device on the bus
39        self.dev = usb.core.find(idVendor=0x085C, idProduct=0x0A00, backend=self.backend)
40        if self.dev is None:
41            raise ValueError("SpyderX device not found. Check if it's plugged in and using libusbK driver via Zadig.")
42
43        # Attempt to set configuration
44        try:
45            self.dev.set_configuration()
46        except usb.core.USBError:
47            pass  # Often already set
48
49        # Detach kernel driver if active (uncommon on Windows, but safe to try)
50        try:
51            if self.dev.is_kernel_driver_active(0):
52                self.dev.detach_kernel_driver(0)
53        except (NotImplementedError, usb.core.USBError):
54            pass
55
56        # Claim interface
57        usb.util.claim_interface(self.dev, 0)
58
59        # Initialize the device (control transfers, then get calibration data)
60        self._initialize_device()

Initialize the SpyderX device.

Args: libusb_path (str): Full path to libusb-1.0.dll, for example: r'C:\vcpkg\installed\x64-windows\bin\libusb-1.0.dll'

backend
dev
def calibrate(self):
133    def calibrate(self):
134        """
135        Perform black (dark) calibration.
136        The user should cover the SpyderX lens before calling this.
137        """
138        # Standard control transfer first
139        self._ctrl(0x41, 2, 2, 0)
140
141        # build payload from v2, s1, s2
142        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
143        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
144
145        # parse the black calibration data
146        raw = struct.unpack('>HHHH', out[5:])
147        self.black_cal = np.array(raw[:3])

Perform black (dark) calibration. The user should cover the SpyderX lens before calling this.

def measure(self):
149    def measure(self):
150        """
151        Perform a measurement, returning XYZ as a numpy array [X, Y, Z].
152        Y is luminance in cd/m².
153        """
154        # Control transfer
155        self._ctrl(0x41, 2, 2, 0)
156
157        # build measurement payload
158        payload = [self.v2 >> 8, self.v2 & 0xFF, self.s1] + list(self.s2)
159        out = self._bulk([0xd2, 0x3f, 0xb9, 0x00, 0x07] + payload, 13)
160
161        raw = np.array(struct.unpack('>HHHH', out[5:]))[:3]
162        corrected = raw - self.black_cal
163        xyz = np.dot(corrected, self.calibration_matrix)
164        return xyz

Perform a measurement, returning XYZ as a numpy array [X, Y, Z]. Y is luminance in cd/m².

def get_luminance(self):
166    def get_luminance(self):
167        """
168        Return just the Y (luminance) component in cd/m².
169        """
170        return self.measure()[1]

Return just the Y (luminance) component in cd/m².

def get_rgb_luminance(self):
172    def get_rgb_luminance(self):
173        """
174        Convert measured XYZ to approximate linear RGB intensities.
175        Useful for relative channel brightness checks.
176        """
177        xyz = self.measure()
178        xyz_to_rgb = np.array([
179            [ 3.2406, -1.5372, -0.4986],
180            [-0.9689,  1.8758,  0.0415],
181            [ 0.0557, -0.2040,  1.0570]
182        ])
183        rgb = np.dot(xyz_to_rgb, xyz)
184        return tuple(rgb)

Convert measured XYZ to approximate linear RGB intensities. Useful for relative channel brightness checks.

def close(self):
186    def close(self):
187        """
188        Release the interface and USB resources.
189        """
190        usb.util.release_interface(self.dev, 0)
191        usb.util.dispose_resources(self.dev)

Release the interface and USB resources.

def xyz_to_lms(xyz):
193def xyz_to_lms(xyz):
194    # XYZ to LMS conversion matrix (Hunt-Pointer-Estevez)
195    xyz_to_lms_matrix = np.array([
196        [0.4002, 0.7076, -0.0808],
197        [-0.2263, 1.1653, 0.0457],
198        [0.0, 0.0, 0.9182]
199    ])
200    return np.dot(xyz_to_lms_matrix, xyz)
class GammaFitter:
202class GammaFitter:
203    """
204    A class to fit a gamma function to luminance data for monitor calibration.
205
206    This class takes grayscale intensity values and corresponding luminance measurements,
207    normalizes the data, and fits a gamma function to estimate the display gamma.
208    It also provides tools for visualizing the fit and customizing fit parameters.
209
210    Attributes:
211        original_intensities (numpy.ndarray): Original grayscale intensity values.
212        original_luminance (numpy.ndarray): Original luminance measurements.
213        intensities (numpy.ndarray): Normalized grayscale intensities [0, 1].
214        luminance (numpy.ndarray): Normalized luminance values [0, 1].
215        params (list): Parameters of the fitted gamma function [a, b, c].
216        gamma (float): The gamma value (b) obtained from the fitted curve.
217        lower_bounds (list): Lower bounds for curve fitting parameters.
218        upper_bounds (list): Upper bounds for curve fitting parameters.
219        initial_guess (list): Initial guess for the curve fitting parameters.
220    """
221
222    def __init__(self, intensities, luminance):
223        """
224        Initializes the GammaFitter class.
225
226        Args:
227            intensities (list or array): The grayscale intensity values.
228            luminance (list or array): The corresponding luminance measurements.
229        """
230        # Store original values for plotting
231        self.original_intensities = np.array(intensities)
232        self.original_luminance = np.array(luminance)
233
234        # Normalize input data to the range [0, 1]
235        self.intensities = (self.original_intensities - np.min(self.original_intensities)) / (
236                    np.max(self.original_intensities) - np.min(self.original_intensities))
237        self.luminance = (self.original_luminance - np.min(self.original_luminance)) / (
238                    np.max(self.original_luminance) - np.min(self.original_luminance))
239
240        self.params = None
241        self.gamma = None
242
243        # Default values for bounds and initial guess based on monitor gamma calibration
244        self.lower_bounds = [0, 0, 0]  # Lower bounds: a ≥ 0, b ≥ 1 (physical gamma), c ≥ 0
245        self.upper_bounds = [10, 5, 1]  # Upper bounds: a ≤ 10, b ≤ 5, c ≤ 0.2
246        self.initial_guess = [0, 1, 0.01]  # Typical monitor gamma: a=1, b=2.2, c=0.01
247
248    def gamma_function(self, x, a, b, c):
249        """
250        Gamma function to model luminance as a function of intensity.
251
252        Args:
253            x (float or array): The normalized intensity values.
254            a (float): Scaling factor for luminance.
255            b (float): Gamma value (exponent).
256            c (float): Offset or baseline luminance.
257
258        Returns:
259            float or array: Modeled luminance values.
260        """
261        return a * (x ** b) + c
262
263    def set_bounds(self, lower_bounds, upper_bounds):
264        """
265        Set custom lower and upper bounds for the gamma fit parameters.
266
267        Args:
268            lower_bounds (list): Lower bounds for the parameters [a, b, c].
269            upper_bounds (list): Upper bounds for the parameters [a, b, c].
270        """
271        self.lower_bounds = lower_bounds
272        self.upper_bounds = upper_bounds
273
274    def set_initial_guess(self, initial_guess):
275        """
276        Set a custom initial guess for the gamma fit parameters.
277
278        Args:
279            initial_guess (list): Initial guess for the parameters [a, b, c].
280        """
281        self.initial_guess = initial_guess
282
283    def fit(self):
284        """
285        Fits the gamma function to the normalized intensity and luminance data.
286
287        Returns:
288            list: The fitted parameters [a, b, c], where 'b' is the gamma value.
289        """
290        bounds = (self.lower_bounds, self.upper_bounds)
291        self.params, _ = curve_fit(self.gamma_function, self.intensities, self.luminance, p0=self.initial_guess,
292                                   bounds=bounds)
293        self.gamma = self.params[1]
294        return self.params
295
296    def plot(self):
297        """
298        Plots the original data and the fitted gamma curve.
299
300        Raises:
301            ValueError: If the fit has not been performed before calling this method.
302        """
303        if self.params is None:
304            raise ValueError("Fit the data first using the 'fit' method before plotting.")
305
306        plt.figure(figsize=(8, 6))
307        plt.scatter(self.original_intensities, self.original_luminance, label='Original Data', color='blue', alpha=0.6)
308
309        # Generate the fit curve in the normalized range
310        x_fit_normalized = np.linspace(0, 1, 100)
311        y_fit_normalized = self.gamma_function(x_fit_normalized, *self.params)
312
313        # Convert back to the original scale for plotting
314        x_fit_original = x_fit_normalized * (
315                    np.max(self.original_intensities) - np.min(self.original_intensities)) + np.min(
316            self.original_intensities)
317        y_fit_original = y_fit_normalized * (
318                    np.max(self.original_luminance) - np.min(self.original_luminance)) + np.min(self.original_luminance)
319
320        plt.plot(x_fit_original, y_fit_original,
321                 label=f'Gamma={self.params[1]:.3f}, Base luminance={self.params[2]:.3f}',
322                 color='red')
323        plt.xlabel('Original Intensity')
324        plt.ylabel('Original Luminance')
325        plt.title('Gamma Function Fit for Monitor Calibration')
326        plt.legend()
327        plt.grid()
328        plt.show()

A class to fit a gamma function to luminance data for monitor calibration.

This class takes grayscale intensity values and corresponding luminance measurements, normalizes the data, and fits a gamma function to estimate the display gamma. It also provides tools for visualizing the fit and customizing fit parameters.

Attributes: original_intensities (numpy.ndarray): Original grayscale intensity values. original_luminance (numpy.ndarray): Original luminance measurements. intensities (numpy.ndarray): Normalized grayscale intensities [0, 1]. luminance (numpy.ndarray): Normalized luminance values [0, 1]. params (list): Parameters of the fitted gamma function [a, b, c]. gamma (float): The gamma value (b) obtained from the fitted curve. lower_bounds (list): Lower bounds for curve fitting parameters. upper_bounds (list): Upper bounds for curve fitting parameters. initial_guess (list): Initial guess for the curve fitting parameters.

GammaFitter(intensities, luminance)
222    def __init__(self, intensities, luminance):
223        """
224        Initializes the GammaFitter class.
225
226        Args:
227            intensities (list or array): The grayscale intensity values.
228            luminance (list or array): The corresponding luminance measurements.
229        """
230        # Store original values for plotting
231        self.original_intensities = np.array(intensities)
232        self.original_luminance = np.array(luminance)
233
234        # Normalize input data to the range [0, 1]
235        self.intensities = (self.original_intensities - np.min(self.original_intensities)) / (
236                    np.max(self.original_intensities) - np.min(self.original_intensities))
237        self.luminance = (self.original_luminance - np.min(self.original_luminance)) / (
238                    np.max(self.original_luminance) - np.min(self.original_luminance))
239
240        self.params = None
241        self.gamma = None
242
243        # Default values for bounds and initial guess based on monitor gamma calibration
244        self.lower_bounds = [0, 0, 0]  # Lower bounds: a ≥ 0, b ≥ 1 (physical gamma), c ≥ 0
245        self.upper_bounds = [10, 5, 1]  # Upper bounds: a ≤ 10, b ≤ 5, c ≤ 0.2
246        self.initial_guess = [0, 1, 0.01]  # Typical monitor gamma: a=1, b=2.2, c=0.01

Initializes the GammaFitter class.

Args: intensities (list or array): The grayscale intensity values. luminance (list or array): The corresponding luminance measurements.

original_intensities
original_luminance
intensities
luminance
params
gamma
lower_bounds
upper_bounds
initial_guess
def gamma_function(self, x, a, b, c):
248    def gamma_function(self, x, a, b, c):
249        """
250        Gamma function to model luminance as a function of intensity.
251
252        Args:
253            x (float or array): The normalized intensity values.
254            a (float): Scaling factor for luminance.
255            b (float): Gamma value (exponent).
256            c (float): Offset or baseline luminance.
257
258        Returns:
259            float or array: Modeled luminance values.
260        """
261        return a * (x ** b) + c

Gamma function to model luminance as a function of intensity.

Args: x (float or array): The normalized intensity values. a (float): Scaling factor for luminance. b (float): Gamma value (exponent). c (float): Offset or baseline luminance.

Returns: float or array: Modeled luminance values.

def set_bounds(self, lower_bounds, upper_bounds):
263    def set_bounds(self, lower_bounds, upper_bounds):
264        """
265        Set custom lower and upper bounds for the gamma fit parameters.
266
267        Args:
268            lower_bounds (list): Lower bounds for the parameters [a, b, c].
269            upper_bounds (list): Upper bounds for the parameters [a, b, c].
270        """
271        self.lower_bounds = lower_bounds
272        self.upper_bounds = upper_bounds

Set custom lower and upper bounds for the gamma fit parameters.

Args: lower_bounds (list): Lower bounds for the parameters [a, b, c]. upper_bounds (list): Upper bounds for the parameters [a, b, c].

def set_initial_guess(self, initial_guess):
274    def set_initial_guess(self, initial_guess):
275        """
276        Set a custom initial guess for the gamma fit parameters.
277
278        Args:
279            initial_guess (list): Initial guess for the parameters [a, b, c].
280        """
281        self.initial_guess = initial_guess

Set a custom initial guess for the gamma fit parameters.

Args: initial_guess (list): Initial guess for the parameters [a, b, c].

def fit(self):
283    def fit(self):
284        """
285        Fits the gamma function to the normalized intensity and luminance data.
286
287        Returns:
288            list: The fitted parameters [a, b, c], where 'b' is the gamma value.
289        """
290        bounds = (self.lower_bounds, self.upper_bounds)
291        self.params, _ = curve_fit(self.gamma_function, self.intensities, self.luminance, p0=self.initial_guess,
292                                   bounds=bounds)
293        self.gamma = self.params[1]
294        return self.params

Fits the gamma function to the normalized intensity and luminance data.

Returns: list: The fitted parameters [a, b, c], where 'b' is the gamma value.

def plot(self):
296    def plot(self):
297        """
298        Plots the original data and the fitted gamma curve.
299
300        Raises:
301            ValueError: If the fit has not been performed before calling this method.
302        """
303        if self.params is None:
304            raise ValueError("Fit the data first using the 'fit' method before plotting.")
305
306        plt.figure(figsize=(8, 6))
307        plt.scatter(self.original_intensities, self.original_luminance, label='Original Data', color='blue', alpha=0.6)
308
309        # Generate the fit curve in the normalized range
310        x_fit_normalized = np.linspace(0, 1, 100)
311        y_fit_normalized = self.gamma_function(x_fit_normalized, *self.params)
312
313        # Convert back to the original scale for plotting
314        x_fit_original = x_fit_normalized * (
315                    np.max(self.original_intensities) - np.min(self.original_intensities)) + np.min(
316            self.original_intensities)
317        y_fit_original = y_fit_normalized * (
318                    np.max(self.original_luminance) - np.min(self.original_luminance)) + np.min(self.original_luminance)
319
320        plt.plot(x_fit_original, y_fit_original,
321                 label=f'Gamma={self.params[1]:.3f}, Base luminance={self.params[2]:.3f}',
322                 color='red')
323        plt.xlabel('Original Intensity')
324        plt.ylabel('Original Luminance')
325        plt.title('Gamma Function Fit for Monitor Calibration')
326        plt.legend()
327        plt.grid()
328        plt.show()

Plots the original data and the fitted gamma curve.

Raises: ValueError: If the fit has not been performed before calling this method.