Fernando Voltolini de Azambuja

Imaging and color measurement · Methods

Color-correction matrix: formulas and implementation

The code linked here is the implementation that produced the published results. It begins where the measurement ends: with corrected chart RGB and target XYZ already in hand. Patch extraction, chart localization, reference rendering, and the spectrum readers are not part of this module — the patch-extraction and reference reports explain how its two inputs were obtained.

The fit

For patch i, let r_i = (R, G, B) be linear camera RGB and t_i = (X, Y, Z) the target. The model is

t_i ≈ M · r_i        with M a 3×3 matrix

Each output channel is independent, so M is three separate least-squares problems sharing one design matrix. Stacking the patch RGB row vectors into R and one target channel into t, the row m of M for that channel solves the normal equations:

(Rᵀ R) m = Rᵀ t

Rᵀ R is 3×3 regardless of patch count, which is what makes this cheap: 140 patches contribute to the accumulation, not to the size of the system.

Conditioning. The implementation solves those normal equations by Gaussian elimination with partial pivoting, and rejects a design whose pivot collapses — a rank-deficient set, such as patches that all lie on the neutral axis, has no unique solution and returning one anyway would be worse than failing. Forming RᵀR squares the condition number, which is a real cost in general. The archive fit completed without the singular-pivot rejection, but no condition number was retained, so this portfolio does not report a quantified stability margin. A QR or SVD solve is the appropriate extension for that question.

Measuring the error

Predicted and target XYZ are converted to CIELAB against the declared reference white:

f(u)  = u^(1/3)                       if u > (6/29)³
      = u / (3·(6/29)²) + 4/29        otherwise

L* = 116·f(Y/Yn) − 16
a* = 500·(f(X/Xn) − f(Y/Yn))
b* = 200·(f(Y/Yn) − f(Z/Zn))

The linear segment near zero gives the transform a finite slope instead of the cube root’s divergent slope at zero, which matters for dark patches.

Two difference metrics are reported:

  • ΔE76 — plain Euclidean distance in CIELAB. Simple, and useful precisely because it has no weighting to misapply.
  • CIEDE2000 — adds lightness, chroma, and hue weighting plus a hue-rotation term for the blue region. This is the headline metric.

They are not interchangeable and not convertible. A ΔE76 figure from one source cannot be subtracted from a CIEDE2000 figure from another; the study and reports are explicit about this where both appear.

Held-out evaluation

cross_validate_rgb_to_xyz_ccm assigns patch index i to partition i mod k, fits on k−1 partitions, and evaluates on the held-out one, repeating so every patch is evaluated exactly once by a matrix that never saw it. The split is deterministic but not a substitute for a separately captured chart.

Determinism is the design decision worth naming. A random partition would give a different number on every run, and the reported error would not be reproducible from the published inputs. The tests assert that repeating the evaluation returns a bit-identical result.

What this establishes is bounded: it detects a model memorizing individual patches. It does not establish physical generalization, because every fold is drawn from the same capture of the same chart.

Lightness selection and dark-patch diagnostics

select_reference_lightness partitions patches by target L*, and diagnose_dark_patches evaluates a matrix on the excluded subset. Together these make a restricted fit auditable: it is only possible to see that a better-looking headline came from patch selection if the excluded subset is still evaluated and reported.

Invalid inputs

The public API rejects rather than returning a plausible default:

Condition Behavior
Patch and target counts disagree rejected
Rank-deficient design rejected
Non-finite input sample rejected
Fewer than two folds rejected

What the tests pin

test_colorimetry.cpp runs on synthetic fixtures and published reference tables only.

The central case generates targets from a known 3×3 matrix and requires the solver to return that matrix to 1e-10, with color residual below 1e-9 — data produced by an exact linear map must leave nothing material behind at the declared tolerance.

The held-out case is the one that would be easy to write vacuously. Linear data cannot distinguish training from held-out error, so the fixture bends the targets with a quadratic term no 3×3 can represent, and the test requires the held-out-minus-training gap to exceed 1.0 CIEDE2000. This is a property of the synthetic fixture, not a tolerance applied to the archive result.

The remaining cases cover CIEDE2000 against the supplementary reference pairs from Sharma, Wu, and Dalal (2005) at 1e-4, including the neutral-chroma edge case and its symmetry; the CIELAB round trip; L* = 100 at the reference white; deterministic fold assignment; the kept/excluded partition; and the four listed rejection cases.

Source file: methods/color-correction-matrix.md