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.")
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.
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'
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.
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².
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².
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.
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.
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.
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.
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].
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].
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.
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.