Written on 4 February 2023
There has been some confusion about how lossy JPEG-XL (in this case libjxl) alters some color profiles. So in this post I want to make a deep dive about how they handle different color profiles, along with complete gamut analysis.
In libjxl, there are two ways we can define the color space: with ICC blob or with CICP-styled enumeration. The latter have an advantage of being more compact, although ICC have an advantage to define more complex profiles. As per libjxl 0.8.0, the color enum is still pretty limited to few commmon color spaces, but we can define custom values there.
While I was worked on Krita's JPEG-XL improvements, I was stumbled over this profile differences on non-enum profiles. Apparently, in order to be able to fully utilizing JXL's XYB optimization, the color profile needs to be defined only with CICP enum values to preserve the correct space. So we had to rework our ICC parsing ability to maximize its potential. So basically we need these values to write a custom CICP enum:
So let's take a look at some gamut analysis. For this, I used gamut plotter from Colour Science python library and pyvips for image handling. Then doing some manual ICC parsing to linearize the values and to give Colour lib the neccessary parameters. The full project is published on my github.
The image used for plotting is a X-rite ColorChecker Passport, although the saturation is heavily boosted just to surpass the sRGB gamut. So what you'll be seeing here is not an accurate representation for ColorChecker patches.
PS: Click / tap the image to enlarge.
Our reference point, 16-bit image with ProPhotoRGB / ROMM profile. We can see how it's nicely surpassed the sRGB gamut. However, this color space is currently not defined on lib and require custom values or ICC to be defined. Let's see how it performs.
Now with default lossy JXL export, with all default params (that reflects to cjxl as well). We can see that the gamut are clipped to sRGB and transfer function is converted to linear. As far as I know, this behavior is normal and well documented on libjxl docs. And seems like to correct this, we need to separately call for external CMS to make a further conversion to the original color profile, which JXL does keep besides their synthetic linear sRGB profile as seen here. But however, the gamut is already lost and I think converting back to original profile won't recover it. (IIRC it was also mentioned on source code somewhere...)
Edit: Apparently out-of gamut values are preserved inside the JXL file as well. Which makes sense, since XYB is internally stored with float values. Those can be accessed by explicitly requesting as float for the data_type during the decoding process.
With float, the gamut is preserved. Even though the color space is still in linear sRGB. The way they achieve this is to allow the pixel values go negative during color conversion, thus be able to 'escape' the sRGB barrier. In this case, the conversion to the original profile won't induce gamut loss, although do be careful not to clip negative values during color conversion. Or else it will get clipped back to sRGB. Filesize is identical to integer (both at 16-bit).
And finally, with writing custom enum values. As we can see here the gamut and transfer function is preserved here even though the data is integer. However, this does have some limitations. For example, IIRC other than sRGB, rec.709, Linear, DCI, PQ, and HLG, the only custom values that can be written as enum is single gamma. For more complex and exotic profiles, we escorted to the next solution...
Setting uses_original_profile to true on lossy disables XYB optimization and allows us to save with the original color format without alteration. However, I'd like to say that this should be the 'last escort' for lossy, as it blows the filesize up. We only use it for exotic color profiles, like LUT-based ICC, complex parametric transfer function that's not defined in the lib, or CMYK.
JXL image for ICC - Integer
JXL image for ICC - Float
JXL image for CICP - Integer
JXL image for ICC - Integer - Original Profile
Another warning for completeness sake: These images are deliberately oversaturated to to surpass sRGB gamut. Not an accurate representation for ColorChecker patches. Despite of the differences in profile above, on a properly managed sRGB screen (and browser), those images should look all the same.