Table of Contents

Color management

Egami reads color information from every file and keeps it, but changes no pixel unless you ask: a file's numbers load as stored. The types below say what those numbers mean, and convert between them.

Profiles

A ColorProfile says what a raster's numbers mean: the color model (ColorModel.Rgb, Gray or Cmyk), the primaries and white point, and the transfer function. The built-ins cover sRGB, linear sRGB, Display P3, Adobe RGB, ProPhoto RGB, Rec. 2020, the two Rec. 2100 HDR encodings, gray, and one CMYK print profile, Fogra39 (offset on coated paper); ColorProfile.Create makes any other RGB encoding.

// A ColorProfile says what a raster's numbers mean. The built-ins cover the common encodings...
ColorProfile web = ColorProfile.Srgb;
ColorProfile wide = ColorProfile.DisplayP3;
ColorProfile hdr = ColorProfile.Rec2100Pq;

// ...and Create makes any other RGB encoding from primaries and a transfer function.
ColorProfile camera = ColorProfile.Create(ColorPrimaries.AdobeRgb, TransferFunction.Gamma(2.2), "Camera RGB");
Console.WriteLine($"{camera.Name}: {camera.Model}, HDR {camera.IsHdr}, same as Adobe RGB: {camera.IsEquivalentTo(ColorProfile.AdobeRgb)}");

Primaries and white points

ColorPrimaries holds the CIE 1931 Chromaticity of red, green, blue and white. The named sets are BT.709 (sRGB's), Display P3, BT.2020, Adobe RGB and ProPhoto, with the D65 and D50 white points.

// Primaries are the chromaticities of red, green, blue and white.
ColorPrimaries p3 = ColorPrimaries.DisplayP3;
Chromaticity red = p3.Red;
Console.WriteLine($"P3 red is x {red.X}, y {red.Y}; white is D65: {p3.White == Chromaticity.D65}");
ColorPrimaries custom = new(new(0.68, 0.32), new(0.265, 0.69), new(0.15, 0.06), Chromaticity.D65);

Transfer functions

A TransferFunction decodes stored values to linear light and back: sRGB, linear, a pure gamma, BT.709, the ICC parametric curve, a sampled table, and the HDR curves PQ and HLG. Single values use exact double arithmetic; rows use a vectorized form within 1e-5 of it, the same on every CPU. Linear 1.0 is reference white, which for PQ and HLG is 203 cd/m² (ITU-R BT.2408), so HDR highlights are values above 1.

// A transfer function decodes stored values to linear light and back; HDR curves put reference white at 1.0.
double linear = TransferFunction.Srgb.ToLinear(0.5);           // 0.214
double nits = TransferFunction.Pq.ToLinear(0.75) * 203;         // a 75% PQ signal is about 983 cd/m²
float[] row = [0f, 0.25f, 0.5f, 1f];
TransferFunction.Srgb.ToLinear(row, row);                        // a whole row at once, in place

ICC profiles

IccData parses an ICC profile (versions 2 and 4): the header fields (version, IccProfileClass, data and connection spaces, the suggested RenderingIntent, creation date), the description and copyright text, and every tag with its bytes. Like ExifData, it never throws: offsets or sizes outside the profile, a missing acsp signature or more bytes than SafetyLimits.MaxMetadataBytes mark it damaged, with Warnings saying why, and what could be read stays available.

ColorProfile.FromIcc turns a profile into an encoding. Egami converts with RGB, gray and CMYK profiles of the input, display, output and color space classes, whether they convert through a matrix and curves or through lut8, lut16, lutAtoB and lutBtoA tables; a damaged tag that conversion needs throws CorruptDataException, and other classes or data spaces throw UnsupportedFormatException. A matrix profile gets its primaries and curve, so IsEquivalentTo recognizes an ICC profile written for one of the built-ins, and a version 4.4 cicp tag naming PQ or HLG wins over the rest.

// IccData reads a profile's header and tags; it never throws, and says what was wrong with a damaged one.
IccData icc = IccData.Parse(bytes);
Console.WriteLine($"{icc.Description}: version {icc.Version}, {icc.ProfileClass}, {icc.DataSpace} to {icc.ConnectionSpace}, damaged: {icc.IsDamaged}");
foreach (IccTag tag in icc.Tags)
{
    Console.WriteLine($"  {tag.Signature} ({tag.Type}), {tag.Data.Length} bytes");
}

// ColorProfile.FromIcc makes an encoding from it: a matrix profile gets its primaries and curve, so an ICC profile written
// for Display P3 is equivalent to the built-in.
ColorProfile profile = ColorProfile.FromIcc(icc);
bool isP3 = profile.IsEquivalentTo(ColorProfile.DisplayP3);

Converting

Nothing converts unless asked. RasterMetadata.GetColorProfile says what a raster's pixels mean, from its ICC profile, its sRGB or linear tag or its gamma, and sRGB when the file said nothing. RasterOps.ConvertColor converts in place to another encoding of the same model and records the result: no profile for sRGB, the target's ICC profile otherwise.

// What the pixels mean, from the file's ICC profile, tags and gamma; sRGB when it said nothing.
ColorProfile stored = raster.Metadata.GetColorProfile();

// Convert in place to sRGB for the web: the metadata then says sRGB, so no profile is written.
raster.Apply(ops => ops.ConvertColor(ColorProfile.Srgb));

What a file says is kept in the metadata: the 1.0 IccProfile, ColorSpace hint and Gamma as before, and now CodePoints (PNG cICP), Primaries (PNG cHRM, BMP calibrated endpoints) and Hdr (PNG mDCV and cLLI, as HdrMetadata). GetColorProfile resolves them in one order for every format: code points, then the ICC profile, then an sRGB tag, then a gamma or linear tag with the primaries, then a camera's EXIF Adobe RGB marker, and sRGB when the file said nothing. PNG's gAMA 45455 (1/2.2) without other chromaticities than sRGB's counts as sRGB, which is what such files mean.

// What an HDR file says about its mastering display and light levels, alongside its code points.
RasterMetadata metadata = raster.Metadata;
double? peak = metadata.Hdr?.MaxContentLightLevel ?? metadata.Hdr?.MasteringMaxLuminance;
bool pq = metadata.GetColorProfile().IsHdr;

Raster.ConvertColor<TTo> converts into a new raster of another pixel type, which is how a CMYK image becomes RGB through its profile. ColorTransformOptions choose the rendering intent (perceptual by default) and black point compensation (off by default, as in Little CMS and Pillow).

// A CMYK image becomes RGB through its profile (or the plain formula if it has none), as a new raster.
using Raster<Cmyk8> print = Raster.Load<Cmyk8>(path);
using Raster<Rgb8> screen = print.ConvertColor<Rgb8>(ColorProfile.Srgb, new ColorTransformOptions { Intent = RenderingIntent.RelativeColorimetric, BlackPointCompensation = true });

ReaderOptions.ConvertColorTo converts while loading. In the natural pixel type the type is kept when the model stays the same; a CMYK file converted to sRGB loads as Rgb8.

// Load straight into sRGB: the same pixels as loading and then calling ConvertColor.
using Raster web = Raster.Load(path, options: new ReaderOptions { ConvertColorTo = ColorProfile.Srgb });

Display P3 phone photos, Adobe RGB camera files and CMYK print JPEGs all come out as sRGB, converted through their own profiles while they decode; files with no color information are taken as sRGB and left alone.

Transforms on pixel spans

ColorTransform compiles a conversion once and applies it to spans of any pixel type, in place or into another type. Transforms are cached per EgamiContext. Matrix encodings (sRGB, Display P3, Adobe RGB, matrix ICC profiles, gray) run as curves and one matrix; table profiles and CMYK are sampled onto a 33 or 17 point grid and interpolated. 8-bit pixels take a fast path; float and half types keep values outside 0..1.

// A transform is compiled once (and cached), then applied to spans of any pixel type, in place or into another type.
ColorTransform transform = ColorTransform.Create(ColorProfile.DisplayP3, ColorProfile.Srgb, new ColorTransformOptions { Intent = RenderingIntent.Perceptual });
Rgba8[] pixels = [new(255, 0, 0, 255), new(0, 128, 255, 128)];
transform.Apply<Rgba8>(pixels);
RgbaF32[] wide = new RgbaF32[pixels.Length];
transform.Apply<Rgba8, RgbaF32>(pixels, wide);   // float types keep values outside 0..1

ColorProfile.ToIcc writes an encoding as a small version 4 ICC profile, the one embedded after a conversion: version 4.3 with parametric curves, or 4.4 with a cicp tag for PQ and HLG. Little CMS, Pillow and ImageMagick read them.

// Any RGB or gray encoding as a small ICC profile, for files and other software.
byte[] p3 = ColorProfile.DisplayP3.ToIcc();
bool same = ColorProfile.FromIcc(p3).IsEquivalentTo(ColorProfile.DisplayP3);

ColorProfile.ToIccV2 writes a version 2.4 profile instead, for software that reads only version 2 (older image editors, viewers and print drivers): the same matrix-shaper with the conventions of version 2, curv curves only (a gamma, or a 1024-entry table for sRGB and other formula curves), text tags in place of mluc, and the encoding's own white as the media white point, with no chad. A profile read from version 2 bytes gives them back. Little CMS converts through it as through the version 4 profile, within 1 in 255. PQ and HLG get the rolled-off curve tables without the cicp tag, which version 2 lacks, so prefer ToIcc for HDR.

// A version 2 profile, for software that reads only those: older image editors, viewers and print drivers.
byte[] p3 = ColorProfile.DisplayP3.ToIccV2();
Version version = IccData.Parse(p3).Version;

Writing color tags

Writers take color tags from the generic metadata, so they survive a change of format: PNG writes cICP from CodePoints, cHRM from Primaries when there is no ICC profile, and mDCV and cLLI from Hdr; QOI marks a raster linear when its profile is linear. WriterOptions.OmitSrgbProfile leaves out an embedded profile that means sRGB (PNG writes its sRGB chunk instead).

// Leave out an embedded profile that only says sRGB, which is what a file without one means anyway.
raster.Save(output, "png", new WriterOptions { OmitSrgbProfile = true });

Linear light

Operations with ColorSpace.Linear (resize, blur, sharpen, grayscale) decode with the raster's own transfer function, from GetColorProfile or the last ConvertColor before them in the chain, so a gamma 1.8 file or a PQ image is linearized correctly and a raster tagged linear is used as it is. Untagged and sRGB rasters give the same bytes as before.

HDR

PQ and HLG decode to linear light with reference white (203 cd/m², ITU-R BT.2408) at 1.0, so highlights are values above 1, kept by float and half pixel types. Converting an HDR encoding to an SDR one tone maps by default: ToneMapping.Bt2390 applies the EETF of ITU-R BT.2390 to max(R, G, B), which keeps hue, and then clips what is still outside the target's gamut toward each pixel's luminance. The source peak comes from ColorTransformOptions.SourcePeakNits, else the raster's Hdr (MaxCLL, then the mastering display's peak), else 1,000 cd/m². ToneMapping.Reinhard is a simpler curve; ToneMapping.Clip turns mapping off. RasterOps.ToneMap maps linear HDR pixels in place.

// HDR to SDR tone maps by default (BT.2390, source peak from the file's mDCV/cLLI, else 1,000 cd/m²).
using Raster<Rgb8> web = hdr.ConvertColor<Rgb8>(ColorProfile.Srgb);

// Or choose the curve and peak; Clip turns tone mapping off.
using Raster<Rgb8> soft = hdr.ConvertColor<Rgb8>(ColorProfile.Srgb, new ColorTransformOptions { ToneMapping = ToneMapping.Reinhard, SourcePeakNits = 4000 });

// Linear HDR pixels (1.0 = reference white, 203 cd/m²) can be tone mapped in place.
using Raster<RgbaF32> linear = hdr.ConvertColor<RgbaF32>(ColorProfile.LinearSrgb, new ColorTransformOptions { ToneMapping = ToneMapping.Clip });
linear.Apply(ops => ops.ToneMap(ToneMapping.Bt2390, sourcePeakNits: 1000));

Color values

Nine small structs hold colors in the models apps ask for: HslColor and HsvColor (on encoded sRGB, as CSS and color pickers), XyzColor (D65, CSS Color 4's XYZ), LabColor and LchColor (D50, as ICC and CSS Color 4), OklabColor and OklchColor (Ottosson's perceptual space), YCbCrColor (BT.601 full range by default, or any YuvMatrix and YuvRange; values are 8-bit codes / 255, so gray chroma is 128 / 255, as JPEG stores it) and CmykColor (the plain formula, or a print profile). Each implements IColorValue<TSelf>, so it converts from and to Rgba8 and RgbaF32; ColorConvert.Convert converts between any two through XYZ, and ColorConvert.FromPixels and ToPixels convert whole rows, from pixels in any encoding. LabColor.DeltaE2000 and OklabColor.Distance measure how different two colors look.

// Color values in the models apps ask for, from and to pixels, and between each other.
Rgba8 orange = new(240, 128, 32, 255);
HslColor hsl = HslColor.From(orange);                          // hue 28°, saturation 0.87, lightness 0.53
LabColor lab = ColorConvert.Convert<HslColor, LabColor>(hsl);  // through XYZ
OklchColor oklch = OklchColor.From(orange);
YCbCrColor jpeg = YCbCrColor.From(orange);                     // BT.601 full range, as JPEG
YCbCrColor video = YCbCrColor.From(orange, YuvMatrix.Bt709, YuvRange.Limited);

// Color difference: CIEDE2000 on Lab, distance on Oklab.
double deltaE = lab.DeltaE2000(LabColor.From(new Rgba8(235, 130, 40, 255)));

// Whole rows at once, from any pixel type and encoding.
Rgba8[] pixels = [orange, new(10, 20, 30, 255)];
OklabColor[] row = new OklabColor[pixels.Length];
ColorConvert.FromPixels<Rgba8, OklabColor>(pixels, row);
ColorConvert.FromPixels<Rgba8, OklabColor>(pixels, row, ColorProfile.DisplayP3);   // the same pixels read as Display P3

Real inks come from a print profile. ColorProfile.Fogra39 is built in: FOGRA39L, the usual European condition for offset on coated paper, a free profile built by ArgyllCMS from Fogra's measurements and shipped unchanged; ToIcc gives its bytes to embed in a file or a PDF output intent. CMYK files without a profile still convert by the plain formula, as libjpeg-turbo, libtiff and browsers do, not through this profile.

// The inks a press would use for a color, and the color it would print, through the press's CMYK profile: here the
// built-in FOGRA39 (coated offset paper); ColorProfile.FromIcc reads any other.
ColorProfile print = ColorProfile.Fogra39;
CmykColor inks = CmykColor.From(new Rgba8(200, 60, 30, 255), print, new ColorTransformOptions { Intent = RenderingIntent.RelativeColorimetric });
Rgba8 proof = inks.ToRgba8(print);

A printed page can use more than the four process inks. An InkSet lists them: a CMYK profile's Cyan, Magenta, Yellow and Black (each a Colorant of ColorantKind.Process), then named spot colours and technical inks such as varnish, at most 64. An InkColor names inks with tints, as PDF's Separation and DeviceN colours do (an InkTint each), and carries an InkAppearance (CMYK, sRGB or Lab, an InkAppearanceKind) for anything that cannot print the inks: the tinted appearances of inks printed over one another combine as filters. Names match exactly, and Cyan, Magenta, Yellow and Black always mean the process inks.

// The inks of a page: FOGRA39's process inks, a spot colour with how it looks, and a varnish that never shows.
InkSet inks = InkSet.FromProfile(ColorProfile.Fogra39)
    .WithSpot("PANTONE 186 C", InkAppearance.Cmyk(0, 1, 0.81f, 0.04f))
    .WithSpot("Varnish", null, ColorantKind.Technical);

// Colours as inks: process CMYK, the spot at 60%, and the spot over 30% black (a DeviceN colour).
InkColor text = InkColor.Cmyk(0, 0, 0, 1);
InkColor logo = InkColor.Spot(inks[4], 0.6f);
InkColor shadow = InkColor.Mix(new InkTint(Colorant.Black, 0.3f), new InkTint(inks[4], 1));
InkAppearance? onScreen = shadow.Appearance;   // CMYK 0, 1, 0.81, 0.328: the spot's look printed over the black

An InkRaster holds a page as plates: one plane per ink of its set plus an alpha plane, 8 or 16 bits (InkDepth). A plane holds the ink as it prints over paper, 0 for none to the maximum for full ink. Each InkPlate gives its rows in place, or a Raster<Gray8> or Raster<Gray16> over the same memory to save or process like any image.

// A page as plates: CMYK and the spot, 8 bits each, plus alpha. Rows are written in place; a plate is also a gray raster.
using InkRaster page = new(inks, 200, 100, InkDepth.Unorm8);
page.Plate("PANTONE 186 C")!.Row8(10).Fill(255);              // a full-tint line of the spot
page.Alpha.Row8(10).Fill(255);
using Raster<Gray8> spot = page.Plate("PANTONE 186 C")!.AsRaster<Gray8>();   // no copy: 0 = no ink, 255 = full ink

InkRaster.Save writes all the plates as one separated TIFF, with the ink names and the output profile; InkRaster.Load reads it back, and any other separated TIFF of 8 or 16-bit strips. InkTiffOptions picks the compression and whether to keep the alpha plane (off by default, as a RIP wants only the plates). TIFF has no place for a spot's appearance, so loaded spots have none.

// One separated TIFF with every plate and the ink names, Deflate by default; it loads back plate for plate.
page.Save(file, new InkTiffOptions { Compression = Egami.Formats.Tiff.TiffCompression.Lzw });
file.Position = 0;
using InkRaster loaded = InkRaster.Load(file);

InkRaster.Proof shows the plates on screen: each spot's appearance, scaled by its tint, adds to the process inks (capped at full ink, as Ghostscript's and MuPDF's overprint simulation do), and the result converts through the ink set's profile to sRGB or another RGB or gray profile in ProofOptions. Technical inks such as varnish do not show. SimulatePaper shows the paper's own tone (absolute colorimetric). MeasureCoverage totals the printing inks per pixel against a limit (300% by default) and gives each plate's mean coverage.

// How the plates look on screen, and whether they print within the press's total ink limit.
using Raster<Rgba8> proof = page.Proof<Rgba8>(new ProofOptions { SimulatePaper = true });
InkCoverage coverage = page.MeasureCoverage(limit: 3.0f);
bool tooMuchInk = coverage.MaxTotal > coverage.Limit;      // where: coverage.MaxAt; how often: coverage.ShareOver

Presses with more than four process inks have n-colour ICC profiles (2CLR to FCLR): ColorProfile.FromIcc reads them as ColorModel.NColor, with ChannelCount inks named by ColorantNames, and InkSet.FromProfile takes those inks with their colours as appearances. No pixel type holds them, so conversions to and from such a profile run on float spans: ColorTransform.Apply(ReadOnlySpan<float>, Span<float>) takes InputChannels values per color and writes OutputChannels.

Code points

ColorCodePoints holds the ITU-T H.273 code points that PNG's cICP chunk, AVIF and JPEG XL use, exactly as stored, so codes Egami does not know round-trip.

// H.273 code points, as PNG cICP, AVIF and JPEG XL store them, name an encoding in four bytes.
ColorCodePoints pq = ColorCodePoints.Rec2100Pq;
ColorProfile? profile = ColorProfile.FromCodePoints(pq);              // Rec. 2100 PQ
ColorCodePoints? back = ColorProfile.DisplayP3.ToCodePoints();         // 12, 13, 0, full range