Tone and color
Egami has the tone and color tools photo editors have: levels, gamma, curves and sigmoidal contrast; posterize, solarize, colorize, tint, duotone, thresholds and an accessibility filter; 3D lookup tables from .cube files and Hald images; and the adjustments that read the image first, from auto levels to white balance, equalization, CLAHE and local contrast.
The tone steps follow ImageMagick's formulas, with values from 0 to 1 where ImageMagick takes percentages: Level(0.1f, 0.9f) is
-level 10%,90%. They work on the stored, gamma-encoded values, as photo editors and LUT files do, change red, green and blue,
keep alpha, and clamp to 0..1 after each step.
Tone steps
// A basic tone panel: levels with a midtone lift, a gentle S-curve, and a warm tint in the midtones. The three steps run as
// one pass over the pixels.
raster.Apply(o => o
.Level(black: 0.04f, white: 0.96f, gamma: 1.1f)
.SigmoidalContrast(4, midpoint: 0.5f)
.Tint(new Rgba8(255, 200, 150, 255), 0.15f), Ct);
raster.Save("duck-toned.jpg");
Levels, an S-curve and a tint run as one pass over the pixels.
| Step | Does | ImageMagick |
|---|---|---|
Level(black, white, gamma, outputBlack, outputWhite) |
Stretches black..white to outputBlack..outputWhite with a midtone gamma | -level, +level |
Gamma(gamma) |
v^(1/gamma): 2.2 brightens | -gamma |
SigmoidalContrast(contrast, midpoint, increase) |
An S-curve that adds contrast without clipping, or its inverse | ±sigmoidal-contrast |
Posterize(levels, dither) |
Rounds each channel to evenly spaced levels, optionally with ordered or Floyd–Steinberg dithering | -posterize |
Solarize(threshold) |
Inverts values above the threshold | -solarize |
Threshold(level, mode, below, above) |
Two colors, by luminance, saturation, chroma or each channel (ThresholdMode) |
-threshold |
Colorize(color, amount) |
Blends toward a color | -colorize |
Tint(color, amount) |
Pushes the midtones toward a color, keeping black and white | -tint |
Duotone(shadows, highlights) |
Maps luminance onto a line between two colors | -grayscale then +level-colors |
HighContrast(grayscale, invert, contrast) |
Skia's high-contrast accessibility filter (ContrastInvert) |
— |
SimulateColorBlindness(kind, severity) |
Machado, Oliveira and Fernandes (2009), from mild anomaly to full -opia (ColorVisionDeficiency) |
— |
Every one of these matches ImageMagick 7 to within one level at 8 bits, and thresholds are identical, including where a pixel lies exactly on the level. Adjacent tone steps, color matrices and filters fuse into one pass over the pixels; on 8- and 16-bit images a fused chain is a lookup table per channel, rounded between steps, so it gives exactly the pixels the steps give one by one.
// A two-color poster, then a check of how it reads to someone with deuteranopia.
raster.Apply(o => o.Duotone(Rgba8.FromHex("#1b2a49"), Rgba8.FromHex("#f2c14e")).Posterize(4, DitherMode.Ordered), Ct);
using Raster<Rgba8> preview = raster.Derive(o => o.SimulateColorBlindness(ColorVisionDeficiency.Deuteranopia), Ct);
// High contrast for low vision: gray, lightness inverted so dark becomes light, and more contrast.
using Raster<Rgba8> readable = raster.Derive(o => o.HighContrast(grayscale: true, ContrastInvert.Lightness, contrast: 0.3f), Ct);
Color filters
A ColorFilter is a color adjustment as a value, as Skia's color filters are: a function of one pixel's color, with no
neighbours, position or statistics. Every tone step and color matrix has a factory; Curves takes ToneCurves, FromLut a 3D
table; Then chains filters and Lerp mixes two, so a look can be applied at any strength. Run one with ApplyFilter, on your
own pixels with Apply, or save it as a .cube file with ToLut3D.
// A look as a value: build it once, mix it in at 70%, apply it to any image or to your own pixels.
ColorFilter look = ColorFilter.Level(0.03f, 0.97f).Then(ColorFilter.Saturation(1.2f)).Then(ColorFilter.Tint(new Rgba8(255, 190, 140, 255), 0.2f));
ColorFilter subtle = ColorFilter.Lerp(ColorFilter.Identity, look, 0.7f);
raster.Apply(o => o.ApplyFilter(subtle), Ct);
Span<Rgba8> swatch = [new Rgba8(200, 120, 80, 255), new Rgba8(40, 60, 90, 255)];
subtle.Apply(swatch);
ToneCurve.FromPoints draws a curve as photo editors do, a monotone cubic through the points (Fritsch and Carlson) that never
overshoots; FromTable and FromFunction take a table or a function.
// Curves as a photo editor draws them: a smooth curve through the points that never overshoots, here a mild S on all
// three channels and a lift in the blue shadows.
ToneCurve s = ToneCurve.FromPoints([(0, 0), (0.25f, 0.2f), (0.75f, 0.82f), (1, 1)]);
ToneCurve blue = ToneCurve.FromPoints([(0, 0.06f), (0.5f, 0.5f), (1, 1)]);
raster.Apply(o => o.ApplyFilter(ColorFilter.Curves(s, s, s.Then(blue))), Ct);
3D lookup tables
A Lut3D is an N × N × N grid of output colors, with an optional 1D shaper before it. CubeFile reads and writes .cube files
(Adobe's 1.0 specification and DaVinci Resolve's 1D-plus-3D form); the reader is strict and checks the limits before it allocates.
Lut3D.FromHald and ToHald read and write Hald CLUT images, and Raster.HaldIdentity makes the identity to grade in an editor.
LutInterpolation.Tetrahedral (the default, as Resolve and OpenColorIO read tables) keeps grays neutral; Trilinear matches
ImageMagick's -hald-clut to within a level.
// Export a grade for DaVinci Resolve or Premiere, then load a .cube file and apply it at 70%.
ColorFilter grade = ColorFilter.Curves(ToneCurve.FromPoints([(0, 0), (0.25f, 0.2f), (0.75f, 0.82f), (1, 1)]))
.Then(ColorFilter.Saturation(1.1f));
CubeFile.Write("my-grade.cube", grade.ToLut3D(size: 33), title: "My grade");
Lut3D lut = CubeFile.Read("my-grade.cube");
raster.Apply(o => o.ApplyFilter(ColorFilter.Lerp(ColorFilter.Identity, ColorFilter.FromLut(lut), 0.7f)), Ct);
// Tables can also be built from a function, and read at any color.
Lut3D warm = Lut3D.FromFunction(c => new Vector3(c.X, c.Y * 0.97f, c.Z * 0.9f), size: 17);
Vector3 gray = warm.Evaluate(new Vector3(0.5f), LutInterpolation.Tetrahedral);
// Export the identity Hald image, grade it in any editor, and load the graded image back as a table.
using Raster<Rgba8> identity = Raster.HaldIdentity(level: 8); // 512 × 512, a 64³ table
identity.Save("hald.png");
using Raster graded = Raster.Load("hald.png");
Lut3D fromHald = Lut3D.FromHald(graded);
using Raster<Rgba16> back = fromHald.ToHald(level: 8);
Adjustments that read the image
AutoLevel, ContrastStretch, Normalize, AutoGamma, AutoWhiteBalance, Equalize and AutoThreshold measure the image
before they change it: the chain runs up to the step, the step measures the result, and it becomes a plain filter for the rest of
the chain. Inside Within or OnChannels they measure only the scope; transparent pixels do not count. An animation is measured
as a whole, so its frames get one correction and do not flicker; perFrame: true measures each frame alone, as ImageMagick does.
Black and white points, gammas and threshold levels equal ImageMagick's.
// Fix a dull scan: stretch it to the full range, clipping the darkest 2% and lightest 1%, then set the midtones.
raster.Apply(o => o.Normalize().AutoGamma(), Ct);
// Or stretch each channel on its own, which also removes a cast.
raster.Apply(o => o.AutoLevel(linked: false), Ct);
// Measured steps measure only their scope: here the sky alone is stretched.
raster.Apply(o => o.Within(new Rect(0, 0, raster.Width, raster.Height / 3), sky => sky.AutoLevel()), Ct);
linked: true (the default) gives red, green and blue one pair of points, which keeps colors; false gives each channel its own,
which also removes a cast. Each step has a Raster.Measure… helper that returns the filter without changing anything, to apply
one correction to many images.
// Measure once on a reference shot, then apply the same correction to the whole shoot.
ColorFilter fix = reference.MeasureAutoLevel().Then(reference.MeasureWhiteBalance());
foreach (Raster<Rgba8> shot in shoot)
{
shot.Apply(o => o.ApplyFilter(fix), Ct);
}
// The other measured steps have their Measure helpers too.
ColorFilter stretch = reference.MeasureContrastStretch(0.02f, 0.01f);
ColorFilter midtones = reference.MeasureAutoGamma();
ColorFilter spread = reference.MeasureEqualize();
White balance
WhiteBalance(temperature, tint) corrects for a light of a known color temperature with the Bradford transform, as a raw
editor's sliders do; WhiteBalance(neutral) makes a picked color gray; AutoWhiteBalance finds the cast itself
(WhiteBalanceMethod.GrayWorld, ImageMagick's -white-balance, or WhitePatch). Both fixed corrections are also filters,
ColorFilter.WhiteBalance(neutral) and ColorFilter.WhiteBalance(temperature, tint), so they can join a chain or a paint.
// Correct a tungsten-lit photo, as a raw editor's temperature slider does.
raster.Apply(o => o.WhiteBalance(temperature: 3200), Ct);
// Or pick a gray card's color and make it neutral.
raster.Apply(o => o.WhiteBalance(new Rgba8(182, 170, 151, 255)), Ct);
// Or let the image tell: ImageMagick's gray world, or the brightest pixels as white.
raster.Apply(o => o.AutoWhiteBalance(WhiteBalanceMethod.GrayWorld), Ct);
raster.Apply(o => o.AutoWhiteBalance(WhiteBalanceMethod.WhitePatch), Ct);
Equalization, CLAHE and local contrast
Equalize spreads the values evenly: of L* by default, so colors hold (EqualizeMode.Luminance), or of each channel as
ImageMagick's -equalize (EqualizeMode.Channels). Clahe equalizes each tile of the image with a clipped histogram and blends
the tiles smoothly (Zuiderveld's CLAHE, on L* as ImageMagick runs it); LocalContrast is ImageMagick's -local-contrast. Both
look at neighbours, and each frame is its own.
// Spread the lightness evenly without shifting colors, or each channel on its own as ImageMagick's -equalize.
using Raster<Rgba8> even = raster.Derive(o => o.Equalize(EqualizeMode.Luminance), Ct);
using Raster<Rgba8> strong = raster.Derive(o => o.Equalize(EqualizeMode.Channels), Ct);
// Bring out detail in a flat, foggy landscape: CLAHE on 8 × 8 tiles, then ImageMagick's local contrast.
raster.Apply(o => o.Clahe(tilesX: 8, tilesY: 8, clipLimit: 2).LocalContrast(radius: 0.1f, strength: 0.15f), Ct);
Thresholds
AutoThreshold finds the level with Otsu's, the triangle or Kapur's method (ThresholdMethod), as ImageMagick's
-auto-threshold; Raster.FindThreshold returns the level alone. AdaptiveThreshold compares each pixel with the mean of the
window around it (Bradley and Roth), for pages under uneven light.
// Two colors: navy below half luminance, cream above; alpha is kept.
using Raster<Rgba8> stamp = raster.Derive(o => o.Threshold(0.5f, ThresholdMode.Luminance, Rgba8.Navy, new Rgba8(250, 240, 220, 255)), Ct);
// Threshold a scanned page for OCR: Otsu's level for an evenly lit page, or Bradley's local threshold for uneven light.
float level = page.FindThreshold(ThresholdMethod.Otsu);
using Raster<Gray8> even = page.Derive(o => o.AutoThreshold(ThresholdMethod.Otsu), Ct);
using Raster<Gray8> uneven = page.Derive(o => o.AdaptiveThreshold(limit: 0.15f), Ct);
Recipes
Short answers to common tasks in this area. Each one compiles and runs in Egami's test run.
Tint a photo
// Tint toward sepia-orange, keeping the shading: Color takes the tint's hue and saturation and the photo's luminosity.
photo.Apply(ops => ops.BlendColor(new Rgba8(220, 150, 80, 255), BlendMode.Color, opacity: 0.5f), Ct);