Comparing and measuring images
Calls that look at images rather than change them: comparison metrics and a picture of the differences, per-channel statistics
and histograms, unique colors, opacity and gray checks, perceptual hashes for near-duplicates, and a pixel signature. They take
any pixel type and match ImageMagick's numbers (Python's imagehash for the hashes). Types live in Egami.Analysis.
Compare two images
// Every cheap metric in one pass, per channel and overall, on the 0 to 1 scale; SSIM on request.
CompareResult result = Raster.Compare(expected, actual, new CompareOptions { Ssim = true, Fuzz = 0.02f });
Console.WriteLine($"PSNR {result.Overall.Psnr:F1} dB, SSIM {result.Overall.Ssim:F4}, {result.DifferentPixels} pixels off by more than 2%");
bool sameLook = result.Overall.Psnr >= 40;
Raster.Compare reads both images once and returns a CompareResult: for red, green, blue, alpha and overall, a
ChannelDifference with the mean absolute
error, mean squared error, RMSE, PSNR (+∞ for equal images) and the peak error, all on the 0 to 1 scale, so 8-bit and 16-bit
results line up; and DifferentPixels, the pixels with a channel off by more than Fuzz (ImageMagick's AE). Overall averages the
color channels, plus alpha when either image has it, as ImageMagick's "all" line.
The images must be the same size but may have different pixel types: an Rgb8 and an Rgba8 copy of one picture are identical.
Color profiles are not reconciled, so convert both with ConvertColor first when they differ.
Alpha. Colors are premultiplied by alpha first (AlphaCompare.Premultiplied, the default), so two fully transparent pixels are
equal whatever color is stored under them, which is what a viewer sees and what ImageMagick 7's metrics do. AlphaCompare.Straight
compares the stored channels.
SSIM. With Ssim = true, each channel also gets the structural similarity of Wang et al. (2004) with an 11 × 11 Gaussian window
(σ 1.5) and DSSIM = (1 − SSIM) / 2. It costs about as much as four blurs. ImageMagick 7.1 computes SSIM on straight channels, so
use AlphaCompare.Straight to reproduce its numbers on images with transparency.
Butteraugli. With Butteraugli = true, CompareResult.Butteraugli gets libjxl's psychovisual distance, the number cjxl's
-d aims at: under about 1 the images look the same, and each step up is a more visible difference. It models the eye (light
sensitivity, frequency bands, edges, masking by busy areas), so it costs far more than SSIM, about half a second a megapixel.
Butteraugli3Norm is its 3-norm, which follows the overall quality more than the single worst spot. The scores match libjxl
0.12's butteraugli_main within 0.1 %: colors are read as sRGB (or linear light when the profile says so) at 80 nits, and images
with alpha are compared over black and over white, the larger distance counting.
// How far a lossy save moved the picture to the eye: under about 1 it looks the same.
CompareResult result = Raster.Compare(original, saved, new CompareOptions { Butteraugli = true });
Console.WriteLine($"Butteraugli {result.Butteraugli:F2}, 3-norm {result.Butteraugli3Norm:F2}");
Region compares one rectangle and Frame another frame. Results do not depend on the thread count.
Visual regression tests
// A visual regression check: pass when the render is close to the stored baseline, else save a picture of what moved.
CompareResult result = Raster.Compare(baseline, rendered, new CompareOptions { Fuzz = 0.05f });
if (result.DifferentPixels > 0 || result.Overall.Psnr < 45)
{
using Raster where = Raster.Difference(baseline, rendered, DifferenceStyle.Highlight, new DifferenceOptions { Fuzz = 0.05f });
where.Save("regression.png");
}
A picture of the differences
// ImageMagick compare's red-over-faded picture, and the plain per-channel difference turned up 8 times.
using Raster highlight = Raster.Difference(a, b);
using Raster gaps = Raster.Difference(a, b, DifferenceStyle.Absolute, new DifferenceOptions { Amplify = 8 });
highlight.Save("highlight.png");
gaps.Save("gaps.png");
DifferenceStyle.Highlight is ImageMagick compare's picture: pixels that differ in HighlightColor (#F1001E) and the rest in
LowlightColor (white), with 20% of the first image, flattened onto white, showing through (LowlightOpacity). Set
LowlightOpacity = 0 for a plain mask. DifferenceStyle.Absolute shows |a − b| per color channel, times Amplify.
Statistics
// Per-channel statistics on the 0 to 1 scale, as identify -verbose prints them.
RasterStatistics stats = photo.Statistics();
Console.WriteLine($"red mean {stats.Red.Mean:F3} ± {stats.Red.StdDev:F3}, luma {stats.Luma.Min:F2} to {stats.Luma.Max:F2}");
bool dark = stats.Luma.Mean < 0.25;
Statistics returns a RasterStatistics with a ChannelStatistics for red, green, blue, alpha and the Rec. 709 luma: the minimum, maximum, mean, sample standard deviation,
skewness, excess kurtosis and entropy, as ImageMagick's identify -verbose reports them. Entropy is the Shannon entropy of the values
at 16-bit precision divided by its largest possible value for the number of distinct values present, so it is 1 when every
distinct value is equally common.
Histograms
// How the brightness is spread: 256 bins, and the levels below which 1% and 99% of the pixels lie.
Histogram histogram = photo.Histogram(ColorChannel.Luminance);
double shadows = histogram.Percentile(0.01), highlights = histogram.Percentile(0.99);
long midtones = histogram.Counts.Skip(64).Take(128).Sum();
Histogram counts pixels in equal bins over 0 to 1 (256 by default, one per 8-bit value) for one channel or the luma.
Percentile(p) and Median return the level of the bin where the running count reaches that fraction.
Colors, opacity and gray
// Unique colors, the palette of a small image, and quick checks before choosing an output format.
long count = photo.CountColors();
IReadOnlyList<Rgba16>? palette = photo.GetColors(256); // null: more than 256 colors
bool needsAlpha = !photo.IsOpaque();
bool grayscale = photo.IsGray(tolerance: 1 / 255f);
CountColors counts the distinct stored values exactly, as ImageMagick's %k. Pixel types of up to 3 bytes use a 2 MB table;
wider types keep a set of the colors seen, and its worst case (every pixel distinct, about 24 bytes a pixel) counts against
MaxTotalBytes. GetColors(limit) returns the colors as Rgba16 when there are at most limit, and stops scanning once there are
more. IsOpaque and IsGray stop at the first pixel that fails and answer at once for types without alpha and gray types; both
check every frame unless given one.
// A scan that is gray in all but name can be stored as one channel: a third of the size, no visible change.
if (gray.IsGray(tolerance: 2 / 255f))
{
using Raster<Gray8> oneChannel = gray.As<Gray8>();
oneChannel.Save("scan-gray.png");
}
Near-duplicates
// Find near-duplicate photos: hash each once, store the 64-bit keys, and call a pair a duplicate within about 10 bits.
ulong a = PerceptualHash.Dct(original), b = PerceptualHash.Dct(copy), c = PerceptualHash.Dct(other);
bool duplicate = PerceptualHash.Distance(a, b) <= 10;
bool unrelated = PerceptualHash.Distance(a, c) > 10;
string key = a.ToString("x16", System.Globalization.CultureInfo.InvariantCulture);
Keep the 64-bit hashes in a database column; a lookup within a few bits finds recompressed and resized copies.
PerceptualHash.Dct (pHash style) and PerceptualHash.Difference (dHash) reduce the first frame's brightness, flattened onto
white, to 32 × 32 or 9 × 8 with a Lanczos filter and keep one bit per low frequency or neighbour pair. The same picture after JPEG
recompression, resizing or a small brightness change stays within about 6 bits; unrelated photos average about 32 bits apart.
The bits are in reading order, so ToString("x16") matches Python imagehash's hex string to within a few bits.
Signatures
// The same pixels give the same signature, whatever the file or pixel type: dedupe exact copies.
using Raster<Rgba16> wide = png.As<Rgba16>();
bool same = png.Signature() == wide.Signature();
Signature is the SHA-256 of the size, the frame count and each frame's delay and pixels as 16-bit RGBA, as 64 hex digits: equal
exactly when the pixels are, whatever the file or pixel type. It is not ImageMagick's %#, whose bytes vary between versions.