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()
{