I think what you’re missing from Elle’s responses is that there are multiple ‘white points’ that are used in different ways, at different stages within the calculations used to generate the matrix used to convert between colorspaces. Specifically, the keyword you should look at more closely is ‘adapted’.
Disclaimer: I’m not an expert on the standards, I’ve just struggled with the math and figured this out after reading way too much documentation that was way too vague. I might still be misunderstanding a lot of this, so I would honestly like some feedback from experts like Elle.
So, consider for a moment that we consider ‘white’ to be [1, 1, 1] no matter what RGB colorspace we’re in. This doesn’t specify a whitepoint per se - no, we specify a white point in terms of the XYZ colorspace. For example, while sRGB’s white point has xy coordinates [0.3127, 0.3290], that still is just saying that the exact ‘color’ for [1, 1, 1] (or ‘white’) can be measured externally as having those xy coordinates.
ICC profiles use what’s called a ‘Profile Connection Space’ (PCS). What this is will vary, but most of the time it’s either XYZ or L*a*b* - and for ICC profiles (I guess versions 2 and 4), the white point that they use for the PCS isn’t E, but instead D50 - which is roughly equal to XYZ values [0.964, 1.000, 0.825]. This means that, to stay consistent, we have to transform whatever ‘white’ is to XYZ values such that ‘pure white’ is [0.964, 1.000, 0.825], rather than [1, 1, 1] (or, if we were using D65, roughly [0.950, 1.000, 1.089]).
However, because of how human eyes work, you can’t just rescale XYZ values directly to convert between white points. Instead, you have to convert XYZ values into LMS (native colorspace for the human eye), rescale those values, then convert back into XYZ.
There is some debate about what the best matrix to use is for converting between XYZ and LMS, and it often depends on your use case, needs, and specific setup. However, the most common when dealing with ICC profiles is the original ‘Bradford’ color transformation matrix. I specify ‘original’ because apparently there are two versions, and ICC profiles explicitly use the original one.
So, here’s an overview of how this looks:
Linear sRGB→XYZ→LMS→D50/D65→LMS→XYZ (PCS)
And going to another RGB space (for this example, to be displayed on a monitor with a D75 white point):
XYZ (PCS)→LMS→D75/D50→LMS→XYZ→RGB
It’s important to note that in both RGB colorspaces (both sRGB and the monitor’s colorspace), the RGB value for ‘white’ remains [1, 1, 1]. If the picture is a photo of a white piece of paper with a drawing on it, any part that shows the paper will have the same RGB value in both RGB colorspaces (assuming that it’s perfectly encoded as white and not slightly off-color, nor darkened to a light gray).
That’s why one of Elle’s comments carefully noted that the ICC specs assume that your eyes are 100% adapted to the white point of your display - because they’re designed to make sure that the display’s white point is always used for the actual value of white.
Now, for the math:
- orig = Original RGB value.
- final = Final resulting RGB value.
- toXyz = RGB to XYZ matrix for the initial (or ‘source’) RGB colorspace. Uses whatever that colorspace’s actual white point is, such as D65.
- toRgb = XYZ to RGB matrix for the final (or ‘destination’) RGB colorspace. Uses whatever that colorspace’s actual white point is, such as D75.
- whiteSource = Source RGB colorspace’s white point.
- whiteDest = Destination RGB colorspace’s white point.
- toLms = XYZ to LMS matrix, such as the Bradford or Hunt matrices.
- diag() = Function to turn a 3-element vector into a diagonal matrix.
final = toRgb * (toLms^-1) * diag((toLms*whiteDest)/(toLms*D50)) * toLms *
(toLms^-1) * diag((toLms*D50)/(toLms*whiteSource)) * toLms * toXyz * orig
I noticed that the built-in editor had decided to line-break right at the point where colors would be in the PCS (at the time I hadn’t put spaces around the asterisks), so I decided to put an actual line break in there. I put the spaces around most of the asterisks to help show where each colorspace conversion takes place. Decided not to with the ones inside ‘diag()’, to better group those together as a single ‘conversion’.
Hope this helps! While I did find this thread while googling for how to do matrix multiplication in gmic, I saw what looked like a very recent thread from someone going through some of the same issues I did.
Now, the reason I had gotten so confused while learning all this, was because I was wanting to figure this all out so that I could specifically use absolute colorimetric conversions between colorspaces; I didn’t want to use white point adaptation. Specifically, I wanted to make one image look identical on several different monitors, and make that look identical to the original object in real life. I had all displays in the same room as the object, too.
But I had in my head the idea that ‘white balance’ was meant to help adjust colors to be more or less white, going bluish or orangeish based on color temperature. So I kept trying to use white point adaptation to do the opposite of what it was intended to do, and since none of the documentation I could find was geared toward that, it was kinda frustrating!
Had to take a step back and figure out what it did first, in the context in which it was being used - and after I figured that out it was much easier to ‘undo’ the whitepoint adaptation.
Except then I learned that my phone’s camera was doing it all wrong and was assuming D50 was the actual white point for the display. Figuring out why certain shades of green lacked that slight tint of blue while everything else looked spot on was ‘fun’, alright.
… Actually it kinda was. And the whole project was just for fun anyway; can’t seem to get a job, so may as well mess around with colorspaces instead!