diff --git a/readme.md b/readme.md index f6bdceb..cd38bd1 100644 --- a/readme.md +++ b/readme.md @@ -110,6 +110,52 @@ public Task VerifyImage() +## SSIM Image Comparison + +By default, image comparison is byte-exact. To tolerate minor rendering differences (anti-aliasing, font hinting, subpixel rendering), enable SSIM (Structural Similarity Index) comparison by passing a threshold to `Initialize`: + +```cs +[ModuleInitializer] +public static void Init() => + VerifyImageSharp.Initialize(ssimThreshold: 0.999); +``` + +SSIM returns a value between 0.0 (completely different) and 1.0 (identical). Images with an SSIM at or above the threshold are considered equal. Recommended thresholds: + + * `0.999` — tolerates anti-aliasing and subpixel rendering differences + * `0.995` — tolerates minor font/layout shifts across OS versions + * `0.99` — tolerates moderate rendering variation + +### Per-test threshold + +Override the global threshold for a specific test: + + + +```cs +[Test] +public Task VerifyImageWithSsimThreshold() +{ + var image = new Image(11, 11) + { + [5, 5] = Rgba32.ParseHex("#0000FF") + }; + return Verify(image) + .SsimThreshold(0.95); +} +``` +snippet source | anchor + + +### Direct SSIM calculation + +The `SsimComparer` class can also be used directly to get the raw SSIM value: + +```cs +double ssim = SsimComparer.Calculate(receivedStream, verifiedStream); +``` + + ## File Samples http://file-examples.com/ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index bf0e8c3..779bb6b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;CS0649;NU1608;NU1109 - 4.4.1 + 5.0.0 enable preview 1.0.0 diff --git a/src/Tests/SsimTests.VerifyImageWithSsimThreshold#00.verified.txt b/src/Tests/SsimTests.VerifyImageWithSsimThreshold#00.verified.txt new file mode 100644 index 0000000..eb9f3ae --- /dev/null +++ b/src/Tests/SsimTests.VerifyImageWithSsimThreshold#00.verified.txt @@ -0,0 +1,7 @@ +{ + Width: 11, + Height: 11, + HorizontalResolution: 96.0, + VerticalResolution: 96.0, + ResolutionUnits: PixelsPerInch +} \ No newline at end of file diff --git a/src/Tests/SsimTests.VerifyImageWithSsimThreshold#01.verified.txt b/src/Tests/SsimTests.VerifyImageWithSsimThreshold#01.verified.txt new file mode 100644 index 0000000..af39a23 --- /dev/null +++ b/src/Tests/SsimTests.VerifyImageWithSsimThreshold#01.verified.txt @@ -0,0 +1,7 @@ +{ + Width: 11, + Height: 11, + HorizontalResolution: 3780.0, + VerticalResolution: 3780.0, + ResolutionUnits: PixelsPerMeter +} \ No newline at end of file diff --git a/src/Tests/SsimTests.VerifyImageWithSsimThreshold.verified.png b/src/Tests/SsimTests.VerifyImageWithSsimThreshold.verified.png new file mode 100644 index 0000000..cb9dda2 Binary files /dev/null and b/src/Tests/SsimTests.VerifyImageWithSsimThreshold.verified.png differ diff --git a/src/Tests/SsimTests.cs b/src/Tests/SsimTests.cs new file mode 100644 index 0000000..c09bb02 --- /dev/null +++ b/src/Tests/SsimTests.cs @@ -0,0 +1,83 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +[TestFixture] +public class SsimTests +{ + [Test] + public void IdenticalImages() + { + using var image = new Image(100, 100); + var stream1 = Encode(image); + var stream2 = Encode(image); + var ssim = SsimComparer.Calculate(stream1, stream2); + Assert.That(ssim, Is.EqualTo(1.0)); + } + + [Test] + public void CompletelyDifferentImages() + { + using var black = new Image(100, 100); + using var white = new Image(100, 100); + for (var y = 0; y < 100; y++) + { + for (var x = 0; x < 100; x++) + { + white[x, y] = new Rgba32(255, 255, 255, 255); + } + } + + var ssim = SsimComparer.Calculate(Encode(black), Encode(white)); + Assert.That(ssim, Is.LessThan(0.1)); + } + + [Test] + public void SlightlyDifferentImages() + { + using var image1 = new Image(100, 100); + using var image2 = image1.Clone(); + + // change a few pixels slightly + for (var x = 0; x < 10; x++) + { + image2[x, 0] = new Rgba32(10, 10, 10, 255); + } + + var ssim = SsimComparer.Calculate(Encode(image1), Encode(image2)); + Assert.That(ssim, Is.GreaterThan(0.99)); + Assert.That(ssim, Is.LessThan(1.0)); + } + + [Test] + public void DifferentSizeReturnsZero() + { + using var small = new Image(50, 50); + using var large = new Image(100, 100); + var ssim = SsimComparer.Calculate(Encode(small), Encode(large)); + Assert.That(ssim, Is.EqualTo(0)); + } + + #region SsimThreshold + + [Test] + public Task VerifyImageWithSsimThreshold() + { + var image = new Image(11, 11) + { + [5, 5] = Rgba32.ParseHex("#0000FF") + }; + return Verify(image) + .SsimThreshold(0.95); + } + + #endregion + + static MemoryStream Encode(Image image) + { + var stream = new MemoryStream(); + image.Save(stream, new PngEncoder()); + stream.Position = 0; + return stream; + } +} diff --git a/src/Verify.ImageSharp/SsimComparer.cs b/src/Verify.ImageSharp/SsimComparer.cs new file mode 100644 index 0000000..7553ce4 --- /dev/null +++ b/src/Verify.ImageSharp/SsimComparer.cs @@ -0,0 +1,187 @@ +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using SixLabors.ImageSharp.PixelFormats; + +namespace VerifyTests; + +public static class SsimComparer +{ + const double k1 = 0.01; + const double k2 = 0.03; + const double l = 255.0; + const double c1 = k1 * l * k1 * l; + const double c2 = k2 * l * k2 * l; + + public static double Calculate(Stream received, Stream verified) + { + using var img1 = Image.Load(received); + using var img2 = Image.Load(verified); + + if (img1.Width != img2.Width || + img1.Height != img2.Height) + { + return 0; + } + + var width = img1.Width; + var height = img1.Height; + var pixelCount = (double) (width * height); + + double sumR1 = 0, sumG1 = 0, sumB1 = 0; + double sumR2 = 0, sumG2 = 0, sumB2 = 0; + double sumR1Sq = 0, sumG1Sq = 0, sumB1Sq = 0; + double sumR2Sq = 0, sumG2Sq = 0, sumB2Sq = 0; + double sumR12 = 0, sumG12 = 0, sumB12 = 0; + + img1.ProcessPixelRows(img2, (acc1, acc2) => + { + for (var y = 0; y < height; y++) + { + var row1 = MemoryMarshal.Cast(acc1.GetRowSpan(y)); + var row2 = MemoryMarshal.Cast(acc2.GetRowSpan(y)); + var x = 0; + + if (Vector256.IsHardwareAccelerated) + { + x = AccumulateVector256( + row1, row2, width, + ref sumR1, ref sumG1, ref sumB1, + ref sumR2, ref sumG2, ref sumB2, + ref sumR1Sq, ref sumG1Sq, ref sumB1Sq, + ref sumR2Sq, ref sumG2Sq, ref sumB2Sq, + ref sumR12, ref sumG12, ref sumB12); + } + else if (Vector128.IsHardwareAccelerated) + { + x = AccumulateVector128( + row1, row2, width, + ref sumR1, ref sumG1, ref sumB1, + ref sumR2, ref sumG2, ref sumB2, + ref sumR1Sq, ref sumG1Sq, ref sumB1Sq, + ref sumR2Sq, ref sumG2Sq, ref sumB2Sq, + ref sumR12, ref sumG12, ref sumB12); + } + + for (; x < width; x++) + { + double r1 = (byte) row1[x], g1 = (byte) (row1[x] >> 8), b1 = (byte) (row1[x] >> 16); + double r2 = (byte) row2[x], g2 = (byte) (row2[x] >> 8), b2 = (byte) (row2[x] >> 16); + + sumR1 += r1; sumG1 += g1; sumB1 += b1; + sumR2 += r2; sumG2 += g2; sumB2 += b2; + sumR1Sq += r1 * r1; sumG1Sq += g1 * g1; sumB1Sq += b1 * b1; + sumR2Sq += r2 * r2; sumG2Sq += g2 * g2; sumB2Sq += b2 * b2; + sumR12 += r1 * r2; sumG12 += g1 * g2; sumB12 += b1 * b2; + } + } + }); + + var ssimR = CalculateChannel(pixelCount, sumR1, sumR2, sumR1Sq, sumR2Sq, sumR12); + var ssimG = CalculateChannel(pixelCount, sumG1, sumG2, sumG1Sq, sumG2Sq, sumG12); + var ssimB = CalculateChannel(pixelCount, sumB1, sumB2, sumB1Sq, sumB2Sq, sumB12); + + return (ssimR + ssimG + ssimB) / 3.0; + } + + static int AccumulateVector256( + ReadOnlySpan row1, ReadOnlySpan row2, int width, + ref double sumR1, ref double sumG1, ref double sumB1, + ref double sumR2, ref double sumG2, ref double sumB2, + ref double sumR1Sq, ref double sumG1Sq, ref double sumB1Sq, + ref double sumR2Sq, ref double sumG2Sq, ref double sumB2Sq, + ref double sumR12, ref double sumG12, ref double sumB12) + { + var vR1 = Vector256.Zero; var vG1 = Vector256.Zero; var vB1 = Vector256.Zero; + var vR2 = Vector256.Zero; var vG2 = Vector256.Zero; var vB2 = Vector256.Zero; + var vR1Sq = Vector256.Zero; var vG1Sq = Vector256.Zero; var vB1Sq = Vector256.Zero; + var vR2Sq = Vector256.Zero; var vG2Sq = Vector256.Zero; var vB2Sq = Vector256.Zero; + var vR12 = Vector256.Zero; var vG12 = Vector256.Zero; var vB12 = Vector256.Zero; + var mask = Vector256.Create(0x000000FFu); + ref var ref1 = ref MemoryMarshal.GetReference(row1); + ref var ref2 = ref MemoryMarshal.GetReference(row2); + var x = 0; + + for (; x <= width - Vector256.Count; x += Vector256.Count) + { + var p1 = Vector256.LoadUnsafe(ref ref1, (nuint) x); + var p2 = Vector256.LoadUnsafe(ref ref2, (nuint) x); + + var r1 = Vector256.ConvertToSingle((p1 & mask).AsInt32()); + var g1 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p1, 8) & mask).AsInt32()); + var b1 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p1, 16) & mask).AsInt32()); + var r2 = Vector256.ConvertToSingle((p2 & mask).AsInt32()); + var g2 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p2, 8) & mask).AsInt32()); + var b2 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p2, 16) & mask).AsInt32()); + + vR1 += r1; vG1 += g1; vB1 += b1; + vR2 += r2; vG2 += g2; vB2 += b2; + vR1Sq += r1 * r1; vG1Sq += g1 * g1; vB1Sq += b1 * b1; + vR2Sq += r2 * r2; vG2Sq += g2 * g2; vB2Sq += b2 * b2; + vR12 += r1 * r2; vG12 += g1 * g2; vB12 += b1 * b2; + } + + sumR1 += Vector256.Sum(vR1); sumG1 += Vector256.Sum(vG1); sumB1 += Vector256.Sum(vB1); + sumR2 += Vector256.Sum(vR2); sumG2 += Vector256.Sum(vG2); sumB2 += Vector256.Sum(vB2); + sumR1Sq += Vector256.Sum(vR1Sq); sumG1Sq += Vector256.Sum(vG1Sq); sumB1Sq += Vector256.Sum(vB1Sq); + sumR2Sq += Vector256.Sum(vR2Sq); sumG2Sq += Vector256.Sum(vG2Sq); sumB2Sq += Vector256.Sum(vB2Sq); + sumR12 += Vector256.Sum(vR12); sumG12 += Vector256.Sum(vG12); sumB12 += Vector256.Sum(vB12); + return x; + } + + static int AccumulateVector128( + ReadOnlySpan row1, ReadOnlySpan row2, int width, + ref double sumR1, ref double sumG1, ref double sumB1, + ref double sumR2, ref double sumG2, ref double sumB2, + ref double sumR1Sq, ref double sumG1Sq, ref double sumB1Sq, + ref double sumR2Sq, ref double sumG2Sq, ref double sumB2Sq, + ref double sumR12, ref double sumG12, ref double sumB12) + { + var vR1 = Vector128.Zero; var vG1 = Vector128.Zero; var vB1 = Vector128.Zero; + var vR2 = Vector128.Zero; var vG2 = Vector128.Zero; var vB2 = Vector128.Zero; + var vR1Sq = Vector128.Zero; var vG1Sq = Vector128.Zero; var vB1Sq = Vector128.Zero; + var vR2Sq = Vector128.Zero; var vG2Sq = Vector128.Zero; var vB2Sq = Vector128.Zero; + var vR12 = Vector128.Zero; var vG12 = Vector128.Zero; var vB12 = Vector128.Zero; + var mask = Vector128.Create(0x000000FFu); + ref var ref1 = ref MemoryMarshal.GetReference(row1); + ref var ref2 = ref MemoryMarshal.GetReference(row2); + var x = 0; + + for (; x <= width - Vector128.Count; x += Vector128.Count) + { + var p1 = Vector128.LoadUnsafe(ref ref1, (nuint) x); + var p2 = Vector128.LoadUnsafe(ref ref2, (nuint) x); + + var r1 = Vector128.ConvertToSingle((p1 & mask).AsInt32()); + var g1 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p1, 8) & mask).AsInt32()); + var b1 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p1, 16) & mask).AsInt32()); + var r2 = Vector128.ConvertToSingle((p2 & mask).AsInt32()); + var g2 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p2, 8) & mask).AsInt32()); + var b2 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p2, 16) & mask).AsInt32()); + + vR1 += r1; vG1 += g1; vB1 += b1; + vR2 += r2; vG2 += g2; vB2 += b2; + vR1Sq += r1 * r1; vG1Sq += g1 * g1; vB1Sq += b1 * b1; + vR2Sq += r2 * r2; vG2Sq += g2 * g2; vB2Sq += b2 * b2; + vR12 += r1 * r2; vG12 += g1 * g2; vB12 += b1 * b2; + } + + sumR1 += Vector128.Sum(vR1); sumG1 += Vector128.Sum(vG1); sumB1 += Vector128.Sum(vB1); + sumR2 += Vector128.Sum(vR2); sumG2 += Vector128.Sum(vG2); sumB2 += Vector128.Sum(vB2); + sumR1Sq += Vector128.Sum(vR1Sq); sumG1Sq += Vector128.Sum(vG1Sq); sumB1Sq += Vector128.Sum(vB1Sq); + sumR2Sq += Vector128.Sum(vR2Sq); sumG2Sq += Vector128.Sum(vG2Sq); sumB2Sq += Vector128.Sum(vB2Sq); + sumR12 += Vector128.Sum(vR12); sumG12 += Vector128.Sum(vG12); sumB12 += Vector128.Sum(vB12); + return x; + } + + static double CalculateChannel(double n, double sum1, double sum2, double sum1Sq, double sum2Sq, double sum12) + { + var mu1 = sum1 / n; + var mu2 = sum2 / n; + var sigma1Sq = sum1Sq / n - mu1 * mu1; + var sigma2Sq = sum2Sq / n - mu2 * mu2; + var sigma12 = sum12 / n - mu1 * mu2; + + return (2 * mu1 * mu2 + c1) * (2 * sigma12 + c2) / + ((mu1 * mu1 + mu2 * mu2 + c1) * (sigma1Sq + sigma2Sq + c2)); + } +} diff --git a/src/Verify.ImageSharp/VerifyImageSharp.cs b/src/Verify.ImageSharp/VerifyImageSharp.cs index ceb7172..72cf125 100644 --- a/src/Verify.ImageSharp/VerifyImageSharp.cs +++ b/src/Verify.ImageSharp/VerifyImageSharp.cs @@ -4,7 +4,7 @@ public static class VerifyImageSharp { public static bool Initialized { get; private set; } - public static void Initialize() + public static void Initialize(double ssimThreshold = 1.0) { if (Initialized) { @@ -20,10 +20,40 @@ public static void Initialize() VerifierSettings.RegisterStreamConverter("png", ConvertPng); VerifierSettings.RegisterStreamConverter("tif", ConvertTiff); + if (ssimThreshold < 1.0) + { + Task Compare(Stream received, Stream verified, IReadOnlyDictionary context) + { + var threshold = context.TryGetValue("ImageSharpSsimThreshold", out var value) + ? (double) value + : ssimThreshold; + var ssim = SsimComparer.Calculate(received, verified); + var result = ssim >= threshold + ? CompareResult.Equal + : CompareResult.NotEqual($"SSIM: {ssim:F6}, threshold: {threshold:F6}"); + return Task.FromResult(result); + } + + VerifierSettings.RegisterStreamComparer("bmp", Compare); + VerifierSettings.RegisterStreamComparer("gif", Compare); + VerifierSettings.RegisterStreamComparer("jpg", Compare); + VerifierSettings.RegisterStreamComparer("png", Compare); + VerifierSettings.RegisterStreamComparer("tif", Compare); + } + var encoder = new PngEncoder(); VerifierSettings.RegisterFileConverter((image, context) => ConvertImage(null, image, context, "png", encoder)); } + public static void SsimThreshold(this VerifySettings settings, double threshold) => + settings.Context["ImageSharpSsimThreshold"] = threshold; + + public static SettingsTask SsimThreshold(this SettingsTask settings, double threshold) + { + settings.CurrentSettings.SsimThreshold(threshold); + return settings; + } + static void EncodeAs(this VerifySettings settings, string extension, IImageEncoder? encoder) where TEncoder : IImageEncoder, new() {