Computer Graphics · Color and Display

Why Every Conversion Should Pass Through XYZ, and When It Should Not

Chaining RGB-to-RGB transforms as a single matrix multiplies the errors of both, discards gamut information, and breaks the ability to apply chromatic adaptation.

Converting from one RGB space to another is a matrix product if both spaces are linear with respect to XYZ. The direct product $M_{B} M_{A}^{-1}$ is smaller, faster, and uses two fewer matrix-vector multiplies than the route through XYZ. It is also a trap, and the reason is not numerical accuracy in the ordinary sense — the products agree to many digits — but that the two intermediate steps each carry information the product destroys.

The three-vector route and what each stage can express

An XYZ value is the only representation in which a colour is defined independently of any primary set. That means each stage of a chain has a well-defined job. Decode the transfer function of the source. Apply the source’s RGB-to-XYZ matrix. If the source and destination have different white points, apply a chromatic adaptation transform. Apply the destination’s XYZ-to-RGB matrix. Encode the destination’s transfer function. Each step is separately invertible and separately testable.

Collapsing to a single matrix removes the adaptation step, because adaptation is not a matrix in RGB space: it depends on the cone response basis in which the scaling is performed. A three-by-three product of source-to-XYZ, adaptation, and XYZ-to-destination is still a 3×3 matrix, but the adaptation matrix must be computed in the cone space and then composed, which is precisely the work the collapse is trying to avoid. Any pipeline that supports adaptation, or that supports more than one destination space, will need to keep the XYZ step reachable.

More subtly, the route through XYZ is where out-of-gamut colours become visible. The destination matrix applied to an XYZ value produces negative channels for colours the destination primaries cannot reach. That negativity carries the direction of the shortfall — which primary is deficient and by how much — and it can drive a gamut-mapping decision. Once the value is clamped to zero and re-encoded, the information is gone. The matrix product does the same, but it does it in a space where the shortfall is harder to reason about, because it has been pre-mixed across channels.

Where the collapse is safe

The product is safe when four conditions hold: source and destination share a white point, both are linear-light with no clipping, no gamut mapping is required, and the destination is not a narrow-gamut space. Under those conditions the intermediate XYZ value of a surface colour is bounded and well-conditioned, and the composed matrix is a fixed linear operator that can be applied per pixel with nine multiplies and six adds.

That case is common: a renderer’s internal linear space and a display’s space often share D65, the renderer holds values outside the display gamut only for highlights that will be tone mapped, and no adaptation is needed. Collapsing there saves per-pixel work in the final pass, which is often the pass where fill rate matters most. It is also safe to fold the encode into the same pass when the destination is a display-referred target and the tone curve is applied afterwards in a separate stage.

StageCost per pixelReversibleHandles gamut loss
Direct matrix product9 mul, 6 addyes, if product non-singularno
RGB → XYZ → RGB18 mul, 12 addyes, up to clampyes, if clamping deferred
XYZ → cone → adapted → XYZ2 extra 3×3yesno additional loss
With perceptual intermediate (Lab, OKLab)nonlinear, per-channel cube rootapproximatelyno, but quantifies it

Adaptation is not a matrix in RGB

Chromatic adaptation is modelled as a diagonal scaling in a cone-response space: convert XYZ to a basis that approximates the long-, medium-, and short-wavelength cone responses, scale each by the ratio of the destination white to the source white as expressed in that basis, and convert back. The widely used Bradford matrix is a fixed 3×3 that maps XYZ into such a basis, and its inverse returns to XYZ. The composed operator is a 3×3 matrix on XYZ, but its entries depend on both white points and cannot be reduced to a per-channel gain in RGB.

The cruder approach — scale R, G, and B independently by the ratios of the white points’ RGB values — is what many older systems did, and it is measurably wrong for saturated colours, because the white point ratio in RGB space does not correspond to the cone ratio. It is right for neutrals by construction and drifts for saturated colours, which is exactly the failure that a test image of a colour chart exposes and a test image of grey steps does not.

von Kries adaptation preserves the shape of the spectrum in the cone basis and is therefore exact only when the two whites are related by a scaling in that basis. Real illuminant changes are not, so adaptation is an approximation: two surfaces that match under one illuminant will not match exactly under another after adaptation, because metamerism does not survive the change. This is a property of the model, not a bug, and it sets a ceiling on how accurate any adaptation matrix can be.

Precision, clamping, and order of operations

Order matters when clamping is involved. Clamping at each stage of a four-stage chain accumulates three opportunities to destroy a value that a later stage would have brought back into range. A colour that is slightly outside the destination gamut after the destination matrix may have been inside all the intermediate gamuts, and clamping in XYZ or in the source space clips it earlier than necessary. The rule is to clamp once, at the point where the number becomes a storable quantity, and to defer it as long as the representation allows — which is why float render targets exist.

Precision is a real cost at the extremes. In a half-float pipeline, the negative channels produced by a wide-to-narrow conversion are representable, and the subsequent clamp is exact. In an 8-bit unsigned pipeline, the negative channel is not representable, and the conversion from float to the integer format wraps or saturates depending on the API. Wrapping turns a slightly out-of-gamut colour into a wildly wrong one, and the failure is intermittent because it depends on the magnitude of the excursion. Saturation is at least predictable, but it flattens all excursions to the same zero, which makes the gamut-mapping step downstream impossible.

The transfer function order is a separate trap. Converting encoded RGB to encoded RGB through a matrix requires decoding first. Applying a matrix to encoded values is the same class of error as blending in encoded space: it produces a plausible image whose mid-tones are wrong by amounts that depend on the colours involved. The error is usually described as “the conversion looks a bit dark”, which is exactly what a linear operator applied to a compressive curve does to values below the middle of the range.

typedef struct { float m[3][3]; } mat3;

static void mul(const mat3 *a, const mat3 *b, mat3 *out) {
    for (int r = 0; r < 3; r++)
        for (int c = 0; c < 3; c++)
            out->m[r][c] = a->m[r][0]*b->m[0][c]
                         + a->m[r][1]*b->m[1][c]
                         + a->m[r][2]*b->m[2][c];
}

/* src_enc -> linear -> XYZ -> adapted XYZ -> dst linear (no clamping here). */
void convert(const float in[3], float out[3], const mat3 *src_to_xyz,
             const mat3 *adapt, const mat3 *xyz_to_dst) {
    float lin[3], xyz[3], tmp[3];
    for (int i = 0; i < 3; i++) lin[i] = srgb_decode(in[i]);
    for (int r = 0; r < 3; r++)
        xyz[r] = src_to_xyz->m[r][0]*lin[0] + src_to_xyz->m[r][1]*lin[1]
               + src_to_xyz->m[r][2]*lin[2];
    if (adapt) {
        for (int r = 0; r < 3; r++)
            tmp[r] = adapt->m[r][0]*xyz[0] + adapt->m[r][1]*xyz[1]
                   + adapt->m[r][2]*xyz[2];
    } else {
        for (int r = 0; r < 3; r++) tmp[r] = xyz[r];
    }
    for (int r = 0; r < 3; r++)
        lin[r] = xyz_to_dst->m[r][0]*tmp[0] + xyz_to_dst->m[r][1]*tmp[1]
               + xyz_to_dst->m[r][2]*tmp[2];
    for (int i = 0; i < 3; i++) out[i] = srgb_encode(lin[i]);
}

The adapt pointer being nullable is the one legitimate collapse: when the white points match, the identity adaptation matrix costs six multiplies and does nothing, and an identity check at setup removes it from the inner loop without changing the structure of the code. That is different from collapsing the matrices at build time, which forecloses adding a white-point change later.

Perceptual intermediates and their cost

Lab and its relatives place a cube-root nonlinearity between XYZ and a roughly uniform space. The nonlinearity is per-channel, so the conversion is not a matrix and cannot be collapsed into one; it costs three cube roots or a table lookup per pixel, both considerably more expensive than a matrix multiply. It buys back the ability to reason about colour differences with a metric that approximates perception, which matters when the destination is a printer or when a gamut-mapping algorithm needs a distance measure.

OKLab is a more recent construction with a similar structure and better behaviour for blues and for the hue of saturated colours, at the same per-channel cube-root cost. Both are appropriate as intermediates for analysis and mapping, and both are inappropriate as the storage format of a render target, because the round trip through a cube root in half float loses precision where the eye is most sensitive.

The choice between them is a choice of metric rather than a choice of pipeline topology. The route through XYZ is what makes either possible: a conversion chain that has already collapsed to a single matrix has nowhere to insert a nonlinear perceptual stage without unpacking the original spaces again. That is the strongest argument for keeping the intermediate representation explicit — the matrix product saves a few multiplies and costs the ability to change what happens in the middle, which is the part most likely to change.

Colours that leave the destination gamut are the case that makes this concrete; the mechanisms for handling them are in gamut mapping and rendering intents, and the encoding that keeps them representable through the chain is in linear-light blending.

References

  1. Colorimetry, 4th Edition (CIE 015:2018) — CIE
  2. Recommendation ITU-R BT.2020: Parameter values for ultra-high definition television systems — ITU-R
  3. Digital Video and HD: Algorithms and Interfaces — Charles Poynton, Morgan Kaufmann