Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
145 changes: 125 additions & 20 deletions calibrate_pro/hardware/i1d3_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand Down
54 changes: 47 additions & 7 deletions calibrate_pro/hardware/i1display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down
Loading