Color management
editThe samples of an image only mean a color together with a description of their color space: an ICC profile, a CICP color description, or nothing, which means sRGB. The library treats these descriptions as labels. It keeps them when it loads and saves an image, and it converts colors only when you ask for it.
Profiles are labels
- Decoders keep the ICC profile of a file in
image.Metadata.IccProfile, and encoders write it. A conversion of the pixel format keeps it when it can still describe the pixels (a gray profile for gray formats, an RGB profile for color formats). Metadata.Cicpholds the CICP color description of AVIF, HEIC, PNG (cICP) and JPEG XL files: color primaries, transfer characteristics, matrix coefficients and range. The decoder applies the matrix and the range to produce RGB; the primaries and the transfer characteristics stay a label, so the samples of an HDR image are returned as they are stored.Metadata.TransferFunctionis Linear for the linear-light samples of QOI and JPEG XL files, Srgb otherwise.- Metadata.ContentLightLevel and
Metadata.MasteringDisplaydescribe how HDR samples were mastered.
Only ConvertColorProfile, IccColorTransform and a load with CmykConversionOptions convert colors, and the pixels and their label always change together. To draw sRGB colors on an image of another color space, see Drawing.
Convert an image to sRGB
image.ConvertColorProfile(profile) converts every frame from the color space of the image to the one of a profile, then labels the image with it:
public static void ConvertToSrgb(string inputPath, string outputPath)
{
using var image = Image.Load(inputPath);
// Decoders keep the embedded profile as a label; converting the colors is always explicit.
// An image without profile is already sRGB (sGray for grayscale pixels): nothing is converted then.
var grayscale = image.PixelFormat is PixelFormat.Gray8 or PixelFormat.Gray16;
image.ConvertColorProfile(grayscale ? IccProfile.SrgbGray : IccProfile.Srgb);
// Untagged pixels are read as sRGB: the label can be removed for formats that cannot store a profile
image.Metadata.IccProfile = null;
image.Save(outputPath);
}- The source is the ICC profile of the image; without one, its CICP color description; without either, sRGB (IccProfile.Srgb), sGray for gray pixels (IccProfile.SrgbGray), or their linear-light forms when TransferFunction is Linear.
- The pixel format is kept, so the destination must be an RGB profile for color formats and a gray profile for Gray8 and Gray16. To change the color model, convert the pixel format, or convert sample buffers with an IccColorTransform (see below).
- Colors are converted at the precision of the storage: 16-bit images stay 16-bit. Alpha is copied unchanged. Colors outside the destination gamut are clipped per channel, unless GamutMapping maps them (see Map colors into the gamut).
- The conversion is all or nothing: on failure or cancellation, the image keeps its pixels and its profile.
- After the conversion, the profile describes the pixels: a color description that is not sRGB, the light levels and the mastering display are removed, and so is the EXIF thumbnail.
- When both profiles have the same bytes, nothing is converted: converting an untagged image to IccProfile.Srgb only labels it.
Convert to sRGB before saving to a format that cannot store a profile, or before showing an image where color management is not available: its colors then look right everywhere. IccProfile.DisplayP3 is the wide-gamut color space of the screens of phones and computers: convert to it, with 16-bit pixels, to keep the colors of a wider profile that sRGB would clip.
Convert an HDR image to a standard one
The samples of high dynamic range images (the PQ and HLG transfer characteristics, as in HDR AVIF and HEIC photos) are brighter than the white of an ICC profile. ConvertColorProfile only converts them when IccColorTransformOptions.ToneMapping says how to compress their highlights:
public static void ConvertHdrToSrgb(string avifPath, string pngPath)
{
using var image = Image.Load(avifPath);
// The image has no ICC profile: its color description (BT.2020 primaries with PQ samples, for
// example) is what the colors are converted from. PQ and HLG samples are only converted when
// the options say how the light above the white of the result is compressed.
var options = new IccColorTransformOptions
{
ToneMapping = new HdrToneMappingOptions { Operator = HdrToneMappingOperator.Bt2390 },
};
var grayscale = image.PixelFormat is PixelFormat.Gray8 or PixelFormat.Gray16;
image.ConvertColorProfile(grayscale ? IccProfile.SrgbGray : IccProfile.Srgb, options);
// The pixels are sRGB ones: the color description and the light levels went with the conversion
image.Save(pngPath, new PngEncoder { MetadataHandling = MetadataHandling.Strict });
}| HdrToneMappingOptions | Default | What it does |
|---|---|---|
| TargetWhiteLuminance | 203 cd/m² | The light that becomes the white of the result |
| SourcePeakLuminance | From the metadata | The brightest light of the source, mapped to that white: the maximum content light level (PQ), else the peak of the mastering display, else 10,000 cd/m² (PQ) or 1,000 cd/m² (HLG) |
| Operator | Bt2390 | The curve: Bt2390 keeps the light below a knee and rolls off above; also Clip, Reinhard, Hable and Mobius |
- Every curve applies to the largest component of a color and scales the three together, so highlights keep their hue.
- A source whose peak is below the white is neither compressed nor brightened.
- An ICC profile can also describe HDR samples with a
cicptag (IccProfile.Cicp): the samples are then converted from the color space of the tag.
Load CMYK images
No pixel format holds CMYK samples, so a CMYK image (a JPEG with four components, a TIFF page of separated data or a JPEG 2000 file) is only loaded by converting its colors. Without a conversion, Image.Load throws an UnsupportedImageFeatureException; Image.Identify always works and reports ImageColorModel.Cmyk or Ycck:
public static Image LoadCmykJpeg(string path)
{
// Default options: converted to sRGB from the CMYK profile of the file, or from
// IccProfile.Cgats001CompatibleCmyk when it has none. Images that are not CMYK are loaded as usual.
var conversion = new CmykConversionOptions
{
Transform = new IccColorTransformOptions { Intent = IccRenderingIntent.Perceptual },
};
// The pixels are RGB, labeled with the destination profile (image.Metadata.IccProfile)
return Image.Load(path, new ImageDecodeOptions { CmykConversion = conversion });
}| CmykConversionOptions | Default | What it does |
|---|---|---|
| DestinationProfile | IccProfile.Srgb | The RGB profile of the loaded pixels, which are labeled with it |
| AssumedSourceProfile | IccProfile.Cgats001CompatibleCmyk | The profile of a file without a CMYK profile; null rejects such files |
| Transform | IccColorTransformOptions.Default | The rendering intent and the black point compensation |
- The source is the CMYK profile of the file. A malformed or unsupported embedded profile is an error: it is never replaced by the assumed profile.
- IccProfile.Cgats001CompatibleCmyk describes web offset printing on coated paper. It is a compact profile that reproduces the overall appearance of that printing condition, not the exact colors of a press profile.
- The loaded image has Rgb24 pixels. A TIFF page keeps its alpha (Rgba32) and its 16-bit precision (Rgba64).
- Images that are not CMYK are loaded as usual: the setting can be used for every load.
- Every pixel goes through the ICC conversion: a CMYK image is much slower to load than an RGB image of the same size.
ImageReaderOptions.CmykConversion does the same for streaming readers.
Convert sample buffers
IccColorTransform is the conversion between two profiles that ConvertColorProfile uses, for your own buffers of interleaved samples without alpha: 8-bit, 16-bit or floating-point. It is the only way to reach CMYK:
public static byte[] ConvertSamplesToSrgb(IccProfile sourceProfile, ReadOnlySpan<byte> samples)
{
// A transform is immutable and thread-safe: create it once for a pair of profiles and reuse it
var options = new IccColorTransformOptions { Intent = IccRenderingIntent.Perceptual };
var transform = IccColorTransform.Create(sourceProfile, IccProfile.Srgb, options);
// Interleaved samples without alpha: 1 (gray), 3 (RGB) or 4 (CMYK) per source color, 3 per sRGB color
var converted = new byte[samples.Length / transform.SourceChannelCount * transform.DestinationChannelCount];
transform.Convert(samples, converted);
return converted;
}- A transform is immutable and thread-safe: create it once for a pair of profiles and reuse it. Converting samples allocates nothing.
- Integer samples cover the whole range of their type, floating-point samples are in [0, 1], and colors outside the destination gamut are clipped per channel unless GamutMapping maps them. CMYK samples are ink amounts: 0 is no ink. A conversion to a CMYK printer profile gives CMYK samples, which no pixel format holds and no encoder writes.
- With ClipFloatingPointSamples set to
false, floating-point samples keep their values outside [0, 1]: the light above the white of HDR colors, and the negative components of wide-gamut colors. The tone curves are extended beyond [0, 1] (mirrored below 0, as in CSS Color 4). Only matrix-based profiles can do this: a conversion through a lookup table, or with a gamut mapping, is refused. - The source and the destination can be the same buffer when both profiles have the same number of channels.
Render the HDR version of a phone photo
Ultra HDR JPEG files, and the HEIC and AVIF photos of recent phones, store a standard dynamic range image and a gain map: a second image that gives, pixel by pixel, how much brighter the high dynamic range version is. Loading the photo gives the standard image, as before. AuxiliaryImageCollection lists the gain map, and the thumbnails, depth maps and mattes of the file, and decodes them on request:
public static IReadOnlyList<AuxiliaryImageKind> SaveHdrRendition(string photoPath, string exrPath)
{
// Image.Load reads the main image only. The other images of the file are listed here, from its structure, and
// each one is decoded on request: the gain map of an Ultra HDR JPEG, the thumbnails, depth maps, mattes and
// gain maps of a HEIC or AVIF photo
var images = AuxiliaryImageCollection.Load(photoPath);
var kinds = images.Select(image => image.Kind).ToList();
// The gain map turns the standard dynamic range photo into its high dynamic range version. The headroom of the
// display, in stops (log2 of its peak light over the white of SDR), selects how much of the gain applies: the
// headroom of the alternate rendition gives all of it. The result is linear light, 1 being the white of SDR
if (images.GainMapImage?.GainMapMetadata is { } metadata)
{
using var hdr = images.DecodeHdr(metadata.AlternateHdrHeadroom);
// OpenEXR stores the floating-point samples, and the primaries as its chromaticities
hdr.Save(exrPath, new OpenExrEncoder());
}
return kinds;
}- The headroom of a display is in stops: the log2 of its peak light over the white of SDR. 0 gives the standard image in linear light; the headroom of the alternate rendition (the GainMapMetadata) gives the whole HDR version.
- The result is linear light where 1 is the white of SDR, in the primaries of the photo, with nothing clipped. The parameters come from the ISO 21496-1 metadata (JPEG, HEIC, AVIF) or from the
hdrgmXMP properties of Ultra HDR; the gain maps of Apple that have no ISO 21496-1 metadata are listed without parameters.
Choose a rendering intent
IccColorTransformOptions select how colors that differ between two media are rendered:
| Intent | What it does |
|---|---|
| RelativeColorimetric (the default) | Reproduces the colors in gamut, relative to the white of each medium |
| Perceptual | Uses the perceptual tables of the profiles, which their creators made for that purpose |
| Saturation | Uses the saturation tables of the profiles |
| AbsoluteColorimetric | Reproduces the white of the source medium instead of mapping it to the white of the destination |
- Matrix-based profiles, such as those of displays and of sRGB, give the same result for the first three intents. Table-based profiles, such as printer profiles, have a table per intent; a profile without the table of an intent uses its perceptual table.
- BlackPointCompensation (on by default) maps the darkest color of the source to the darkest color of the destination, so that shadow detail is neither clipped nor lifted. It changes nothing between two display profiles, and does not apply to the absolute colorimetric intent. Keep it on when a version 4 profile meets a version 2 or matrix-based one with the perceptual or saturation intent: the black of version 4 tables is that of the perceptual reference medium (L* = 3.1373), not 0, and without compensation nothing maps one black to the other.
Map colors into the gamut
A color that the destination cannot reproduce is clipped per channel by default (GamutMapping.Clip), which can change the hue and the lightness of a saturated color. GamutMapping.ReduceChroma reduces its chroma toward gray instead, at constant lightness and hue in Oklab, with the gamut mapping algorithm of CSS Color 4: it stops once the clipped color is within a just noticeable difference of the reduced one.
- In-gamut colors are unchanged, and so is every conversion that does not ask for the mapping.
- It applies to a matrix-based destination (displays, sRGB) whatever the intent, and to a table-based destination (printers) with the colorimetric intents; the destination then needs a table to the profile connection space. The perceptual and saturation tables of a printer profile map the gamut as their creator chose, and a gray destination has no chroma: they are used as they are.
- It applies to ConvertColorProfile and to IccColorTransform alike.
Convert through a sequence of profiles
IccColorTransform.Create also takes a list of profiles, in the order the colors go through them. It accepts device link profiles, which convert device values to other device values, and abstract profiles, which convert colors of the profile connection space (a color correction, a look):
public static ushort[] SoftProof(IccProfile display, IccProfile printer, ReadOnlySpan<ushort> rgb)
{
// The printer profile appears twice: the colors enter it from the profile connection space to become inks, then
// leave it as the colors of these inks. The colors the printer cannot reproduce lose chroma at constant
// lightness and hue, instead of being clipped channel by channel.
var proof = IccColorTransform.Create([display, printer, printer, display], new IccColorTransformOptions
{
Intent = IccRenderingIntent.RelativeColorimetric,
GamutMapping = GamutMapping.ReduceChroma,
});
// A transform is immutable and thread-safe: create it once and reuse it
var preview = new ushort[rgb.Length];
proof.Convert(rgb, preview);
return preview;
}- Device values that enter a device profile go to the profile connection space; colors of the connection space that enter a device profile become its device values. To go through the device values of a profile, as a soft proof goes through the inks of a printer, the profile appears twice.
- A device link profile uses its own table whatever the rendering intent, which was chosen when the link was made.
- Abstract profiles convert the colors after the black point compensation of the device profiles around them.
- A sequence that does not connect, such as an abstract profile first or a device link profile after a device profile, is an UnsupportedImageFeatureException.
Supported profiles
ICC version 2 and version 4 profiles of input, display, output and color space classes, for gray, RGB and CMYK data: matrix-based profiles (with sampled, gamma and parametric curves) and table-based ones; device link and abstract profiles in a sequence of profiles. Conversions are evaluated in double precision from the formulas of the ICC specification, so they give the same results on every platform.
A malformed profile is an InvalidImageContentException. A valid profile of another kind (version 5, named color, other color spaces, a device link or abstract profile outside a sequence), or one that cannot be a destination, is an UnsupportedImageFeatureException.