diff --git a/README.md b/README.md index 7e5c930..a12e0cf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Run `calibrate-pro --help` for the full 26-command list. Built-in USB HID driver for the X-Rite i1Display3 family (i1Display Pro, ColorMunki Display, Calibrite ColorChecker Display). No ArgyllCMS required. -CCMX spectral correction computed from EDID vs sensor primaries — fixes sensor matrix for QD-OLED emission spectra. +Native USB HID driver for the i1Display3 family reads per-unit calibration matrices from each device's EEPROM — 9 stored matrices for different display technologies (OLED, WhiteLED, CCFL, WideGamut, etc.). Falls back to approximate constants if EEPROM reading fails. ## Supported Displays diff --git a/calibrate_pro/hardware/i1d3_native.py b/calibrate_pro/hardware/i1d3_native.py index e840be4..0778c4c 100644 --- a/calibrate_pro/hardware/i1d3_native.py +++ b/calibrate_pro/hardware/i1d3_native.py @@ -49,6 +49,7 @@ CMD_MEASURE2 = 0x0200 # Measure (unlocked mode) CMD_SET_INTTIME = 0x0300 # Set integration time CMD_GET_INTTIME = 0x0301 # Get integration time +CMD_RD_EE = 0x0800 # Read EEPROM: offset(2B) + length(1B) → data CMD_UNLOCK = 0x0000 # Unlock with key # Status codes @@ -114,7 +115,8 @@ class I1D3Driver: def __init__(self): self._device = None self._info: I1D3Info | None = None - self._cal_matrix = None # 3x3 calibration matrix + self._cal_matrix = None # 3x3 calibration matrix (per-unit from EEPROM) + self._cal_source = "none" # "device_eeprom", "fallback_approximate", or "none" self._black_offset = None # 3-element black level offset self._integration_time = 0.2 # Default 200ms @@ -305,36 +307,139 @@ def _unlock(self): time.sleep(0.1) self._device.read(REPORT_SIZE, timeout_ms=2000) + def _read_eeprom(self, offset: int, length: int) -> bytes | None: + """ + Read bytes from the device's internal EEPROM. + + The i1Display3 EEPROM read command (0x0800) takes: + - 2 bytes: offset (big-endian) + - 1 byte: length (max ~58 per read due to 64-byte report size) + + Returns the raw bytes, or None on failure. + """ + if length > 58: + # Read in chunks for large requests + result = bytearray() + pos = 0 + while pos < length: + chunk_len = min(58, length - pos) + chunk = self._read_eeprom(offset + pos, chunk_len) + if chunk is None: + return None + result.extend(chunk) + pos += chunk_len + return bytes(result) + + data = struct.pack(">HB", offset, length) + resp = self._send_command(CMD_RD_EE, data) + if resp and len(resp) >= 3 + length: + return resp[3:3 + length] + return None + + def _parse_cal_matrix(self, raw: bytes) -> list[list[float]] | None: + """ + Parse a 3x3 calibration matrix from raw EEPROM data. + + Each matrix entry is a big-endian IEEE 754 double (8 bytes). + Total: 9 doubles = 72 bytes. + """ + if len(raw) < 72: + return None + matrix = [] + for row in range(3): + r = [] + for col in range(3): + offset = (row * 3 + col) * 8 + val = struct.unpack(">d", raw[offset:offset + 8])[0] + r.append(val) + matrix.append(r) + return matrix + + # Calibration matrix EEPROM offsets (from protocol analysis). + # Each contains a 3x3 double matrix (72 bytes) preceded by a + # header with the display technology label. + CAL_OFFSETS = { + "Ambient": 0x0058, + "CCFL": 0x04D8, + "WideGamutCCFL": 0x0958, + "WhiteLED": 0x0DD8, + "RGBLED": 0x1258, + "OLED": 0x191C, + "RGPhosphorBlueLED": 0x1B58, + "WideGamutLEDPA2": 0x1FD8, + "Last": 0x2458, + } + + # Offset from the start of each calibration block to the 3x3 matrix data. + # The block starts with a header; the matrix is at +0x80 from block start + # for most entries (may vary — this is the common layout). + CAL_MATRIX_OFFSET = 0x80 + def _read_calibration(self): """ - Read calibration data from the device's internal EEPROM. - - The i1Display3 stores 9 calibration matrices in its EEPROM, - each optimized for a different display technology. We use the - "Organic LED" matrix at offset 0x191C for OLED displays. - - Matrix labels and offsets (from EEPROM dump): - - 0x0058: Ambient - - 0x04D8: CCFL (cold cathode fluorescent) - - 0x0958: Wide Gamut CCFL - - 0x0DD8: White LED - - 0x1258: RGB LED - - 0x191C: Organic LED <-- used for QD-OLED/WOLED - - 0x1B58: RG Phosphor Blue LED - - 0x1FD8: Wide gamut LED PA2 - - 0x2458: Last + Read per-unit calibration data from the device's internal EEPROM. + + The i1Display3 stores 9 calibration matrices, each optimized for a + different display technology. Each unit has DIFFERENT calibration data + because the sensor filter characteristics vary between units. + + We attempt to read the OLED matrix (for QD-OLED/WOLED displays). + If EEPROM reading fails, we fall back to approximate constants. """ - # Organic LED calibration matrix from device EEPROM at offset 0x191C - # Extracted from NEC MDSVSENSOR3 (i1Display3 OEM) EEPROM dump - # Produces white point x=0.3127 (D65) for OLED panels + # Try to read the OLED calibration matrix from this specific device + oled_offset = self.CAL_OFFSETS["OLED"] + self.CAL_MATRIX_OFFSET + raw = self._read_eeprom(oled_offset, 72) + + if raw is not None: + matrix = self._parse_cal_matrix(raw) + if matrix is not None: + # Validate: matrix should have reasonable values (not zeros, not huge) + flat = [abs(v) for row in matrix for v in row] + if all(0.0 < v < 10.0 for v in flat if v != 0.0): + self._cal_matrix = matrix + self._cal_source = "device_eeprom" + return + + # Fallback: approximate matrix from a reference device (NEC MDSVSENSOR3). + # This does NOT account for per-unit sensor variance. self._cal_matrix = [ [0.03836831, -0.02175997, 0.01696057], [0.01449629, 0.01611903, 0.00057150], [-0.00004481, 0.00035042, 0.08032401], ] + self._cal_source = "fallback_approximate" self._black_offset = [0.0, 0.0, 0.0] + def set_display_type(self, display_type: str) -> bool: + """ + Load the calibration matrix for a specific display technology + from this device's EEPROM. + + Args: + display_type: One of the keys in CAL_OFFSETS (e.g., "OLED", + "WhiteLED", "CCFL", "WideGamutCCFL") + + Returns: + True if the matrix was read successfully, False if fallback used. + """ + if display_type not in self.CAL_OFFSETS: + return False + + offset = self.CAL_OFFSETS[display_type] + self.CAL_MATRIX_OFFSET + raw = self._read_eeprom(offset, 72) + + if raw is not None: + matrix = self._parse_cal_matrix(raw) + if matrix is not None: + flat = [abs(v) for row in matrix for v in row] + if all(0.0 < v < 10.0 for v in flat if v != 0.0): + self._cal_matrix = matrix + self._cal_source = f"device_eeprom_{display_type}" + return True + + return False + def _set_integration_time(self, seconds: float): """Set the sensor integration time.""" self._integration_time = max(0.01, min(5.0, seconds)) diff --git a/calibrate_pro/hardware/i1display.py b/calibrate_pro/hardware/i1display.py index 5c333c0..50e00a9 100644 --- a/calibrate_pro/hardware/i1display.py +++ b/calibrate_pro/hardware/i1display.py @@ -27,29 +27,45 @@ class I1DisplayType: UNKNOWN = "Unknown i1Display" -# Device-specific correction matrices for common display types +# KNOWN ISSUE: Per-unit spectral correction +# +# These matrices are PLACEHOLDERS. Each i1Display3 unit stores unique spectral +# sensitivity curves in its EEPROM. The correction matrix should be computed +# from these per-unit curves against the target display's spectral emission. +# Using hardcoded matrices ignores unit-to-unit variance, which can be +# significant (see: avsforum.com/threads/3268914 — spectral corrections thread). +# +# The correct approach: +# 1. Read the per-unit calibration data from the i1Display3 EEPROM via USB HID +# 2. Parse the stored spectral sensitivity curves (3 channels, ~380-730nm) +# 3. Compute a correction matrix for the target display type by integrating +# the sensor response curves against the display's spectral power distribution +# 4. Apply this per-unit matrix to raw XYZ measurements +# +# Until per-unit EEPROM reading is implemented, these fallback matrices provide +# approximate corrections based on typical i1Display Pro characteristics. +# For accurate measurements, use ArgyllCMS which reads per-unit calibration. + I1DISPLAY_CORRECTIONS = { - # OLED correction for i1Display Pro + # APPROXIMATE — does not account for per-unit sensor variance "OLED": { - "description": "OLED Display Correction", + "description": "OLED Display Correction (approximate, per-unit EEPROM needed)", "matrix": [ [1.0245, -0.0156, -0.0089], [-0.0087, 1.0134, -0.0047], [0.0021, -0.0098, 1.0077] ] }, - # Wide gamut LCD correction "WideGamut": { - "description": "Wide Gamut LCD Correction", + "description": "Wide Gamut LCD Correction (approximate, per-unit EEPROM needed)", "matrix": [ [1.0089, -0.0067, -0.0022], [-0.0045, 1.0078, -0.0033], [0.0012, -0.0056, 1.0044] ] }, - # Standard LCD (sRGB) "LCD": { - "description": "Standard LCD Correction", + "description": "Standard LCD (identity — no correction)", "matrix": [ [1.0000, 0.0000, 0.0000], [0.0000, 1.0000, 0.0000], @@ -165,9 +181,33 @@ def set_display_correction(self, display_type: str) -> bool: """ Apply display type correction matrix. + Prefers per-unit calibration from the native i1Display3 EEPROM driver + when available. Falls back to approximate hardcoded matrices. + Args: display_type: One of "OLED", "WideGamut", "LCD" """ + # Try per-unit EEPROM calibration via native driver + try: + from calibrate_pro.hardware.i1d3_native import I1D3Driver + native = I1D3Driver() + if native.open(): + # Map display_type names to EEPROM calibration block names + eeprom_map = { + "OLED": "OLED", + "WideGamut": "WideGamutLEDPA2", + "LCD": "WhiteLED", + } + eeprom_name = eeprom_map.get(display_type) + if eeprom_name and native.set_display_type(eeprom_name): + self._correction_matrix = native._cal_matrix + native.close() + return True + native.close() + except (ImportError, OSError): + pass + + # Fallback to approximate matrices if display_type in I1DISPLAY_CORRECTIONS: self._correction_matrix = I1DISPLAY_CORRECTIONS[display_type]["matrix"] return True