Skip to content

Commit fffd16e

Browse files
committed
.
1 parent 9afe5cf commit fffd16e

7 files changed

Lines changed: 262 additions & 1 deletion

readme.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@ public Task VerifyImage()
110110
<!-- endSnippet -->
111111

112112

113+
## SSIM Image Comparison
114+
115+
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`:
116+
117+
```cs
118+
[ModuleInitializer]
119+
public static void Init() =>
120+
VerifyImageSharp.Initialize(ssimThreshold: 0.999);
121+
```
122+
123+
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:
124+
125+
* `0.999` — tolerates anti-aliasing and subpixel rendering differences
126+
* `0.995` — tolerates minor font/layout shifts across OS versions
127+
* `0.99` — tolerates moderate rendering variation
128+
129+
### Per-test threshold
130+
131+
Override the global threshold for a specific test:
132+
133+
<!-- snippet: SsimThreshold -->
134+
<a id='snippet-SsimThreshold'></a>
135+
```cs
136+
[Test]
137+
public Task VerifyImageWithSsimThreshold()
138+
{
139+
var image = new Image<Rgba32>(11, 11)
140+
{
141+
[5, 5] = Rgba32.ParseHex("#0000FF")
142+
};
143+
return Verify(image)
144+
.SsimThreshold(0.95);
145+
}
146+
```
147+
<sup><a href='/src/Tests/SsimTests.cs#L61-L74' title='Snippet source file'>snippet source</a> | <a href='#snippet-SsimThreshold' title='Start of snippet'>anchor</a></sup>
148+
<!-- endSnippet -->
149+
150+
### Direct SSIM calculation
151+
152+
The `SsimComparer` class can also be used directly to get the raw SSIM value:
153+
154+
```cs
155+
double ssim = SsimComparer.Calculate(receivedStream, verifiedStream);
156+
```
157+
158+
113159
## File Samples
114160

115161
http://file-examples.com/
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
Width: 11,
3+
Height: 11,
4+
HorizontalResolution: 96.0,
5+
VerticalResolution: 96.0,
6+
ResolutionUnits: PixelsPerInch
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
Width: 11,
3+
Height: 11,
4+
HorizontalResolution: 3780.0,
5+
VerticalResolution: 3780.0,
6+
ResolutionUnits: PixelsPerMeter
7+
}
108 Bytes
Loading

src/Tests/SsimTests.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using SixLabors.ImageSharp;
2+
using SixLabors.ImageSharp.Formats.Png;
3+
using SixLabors.ImageSharp.PixelFormats;
4+
5+
[TestFixture]
6+
public class SsimTests
7+
{
8+
[Test]
9+
public void IdenticalImages()
10+
{
11+
using var image = new Image<Rgba32>(100, 100);
12+
var stream1 = Encode(image);
13+
var stream2 = Encode(image);
14+
var ssim = SsimComparer.Calculate(stream1, stream2);
15+
Assert.That(ssim, Is.EqualTo(1.0));
16+
}
17+
18+
[Test]
19+
public void CompletelyDifferentImages()
20+
{
21+
using var black = new Image<Rgba32>(100, 100);
22+
using var white = new Image<Rgba32>(100, 100);
23+
for (var y = 0; y < 100; y++)
24+
{
25+
for (var x = 0; x < 100; x++)
26+
{
27+
white[x, y] = new Rgba32(255, 255, 255, 255);
28+
}
29+
}
30+
31+
var ssim = SsimComparer.Calculate(Encode(black), Encode(white));
32+
Assert.That(ssim, Is.LessThan(0.1));
33+
}
34+
35+
[Test]
36+
public void SlightlyDifferentImages()
37+
{
38+
using var image1 = new Image<Rgba32>(100, 100);
39+
using var image2 = image1.Clone();
40+
41+
// change a few pixels slightly
42+
for (var x = 0; x < 10; x++)
43+
{
44+
image2[x, 0] = new Rgba32(10, 10, 10, 255);
45+
}
46+
47+
var ssim = SsimComparer.Calculate(Encode(image1), Encode(image2));
48+
Assert.That(ssim, Is.GreaterThan(0.99));
49+
Assert.That(ssim, Is.LessThan(1.0));
50+
}
51+
52+
[Test]
53+
public void DifferentSizeReturnsZero()
54+
{
55+
using var small = new Image<Rgba32>(50, 50);
56+
using var large = new Image<Rgba32>(100, 100);
57+
var ssim = SsimComparer.Calculate(Encode(small), Encode(large));
58+
Assert.That(ssim, Is.EqualTo(0));
59+
}
60+
61+
#region SsimThreshold
62+
63+
[Test]
64+
public Task VerifyImageWithSsimThreshold()
65+
{
66+
var image = new Image<Rgba32>(11, 11)
67+
{
68+
[5, 5] = Rgba32.ParseHex("#0000FF")
69+
};
70+
return Verify(image)
71+
.SsimThreshold(0.95);
72+
}
73+
74+
#endregion
75+
76+
static MemoryStream Encode(Image image)
77+
{
78+
var stream = new MemoryStream();
79+
image.Save(stream, new PngEncoder());
80+
stream.Position = 0;
81+
return stream;
82+
}
83+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using SixLabors.ImageSharp.PixelFormats;
2+
3+
namespace VerifyTests;
4+
5+
public static class SsimComparer
6+
{
7+
const double k1 = 0.01;
8+
const double k2 = 0.03;
9+
const double l = 255.0;
10+
const double c1 = k1 * l * k1 * l;
11+
const double c2 = k2 * l * k2 * l;
12+
13+
public static double Calculate(Stream received, Stream verified)
14+
{
15+
using var imgReceived = Image.Load<Rgba32>(received);
16+
using var imgVerified = Image.Load<Rgba32>(verified);
17+
18+
if (imgReceived.Width != imgVerified.Width ||
19+
imgReceived.Height != imgVerified.Height)
20+
{
21+
return 0;
22+
}
23+
24+
var width = imgReceived.Width;
25+
var height = imgReceived.Height;
26+
var pixelCount = (double) (width * height);
27+
28+
double sumR1 = 0, sumG1 = 0, sumB1 = 0;
29+
double sumR2 = 0, sumG2 = 0, sumB2 = 0;
30+
double sumR1Sq = 0, sumG1Sq = 0, sumB1Sq = 0;
31+
double sumR2Sq = 0, sumG2Sq = 0, sumB2Sq = 0;
32+
double sumR12 = 0, sumG12 = 0, sumB12 = 0;
33+
34+
imgReceived.ProcessPixelRows(imgVerified, (accessorReceived, accessorVerified) =>
35+
{
36+
for (var y = 0; y < height; y++)
37+
{
38+
var row1 = accessorReceived.GetRowSpan(y);
39+
var row2 = accessorVerified.GetRowSpan(y);
40+
41+
for (var x = 0; x < width; x++)
42+
{
43+
var p1 = row1[x];
44+
var p2 = row2[x];
45+
46+
double r1 = p1.R, g1 = p1.G, b1 = p1.B;
47+
double r2 = p2.R, g2 = p2.G, b2 = p2.B;
48+
49+
sumR1 += r1;
50+
sumG1 += g1;
51+
sumB1 += b1;
52+
sumR2 += r2;
53+
sumG2 += g2;
54+
sumB2 += b2;
55+
56+
sumR1Sq += r1 * r1;
57+
sumG1Sq += g1 * g1;
58+
sumB1Sq += b1 * b1;
59+
sumR2Sq += r2 * r2;
60+
sumG2Sq += g2 * g2;
61+
sumB2Sq += b2 * b2;
62+
63+
sumR12 += r1 * r2;
64+
sumG12 += g1 * g2;
65+
sumB12 += b1 * b2;
66+
}
67+
}
68+
});
69+
70+
var ssimR = CalculateChannel(pixelCount, sumR1, sumR2, sumR1Sq, sumR2Sq, sumR12);
71+
var ssimG = CalculateChannel(pixelCount, sumG1, sumG2, sumG1Sq, sumG2Sq, sumG12);
72+
var ssimB = CalculateChannel(pixelCount, sumB1, sumB2, sumB1Sq, sumB2Sq, sumB12);
73+
74+
return (ssimR + ssimG + ssimB) / 3.0;
75+
}
76+
77+
static double CalculateChannel(double n, double sum1, double sum2, double sum1Sq, double sum2Sq, double sum12)
78+
{
79+
var mu1 = sum1 / n;
80+
var mu2 = sum2 / n;
81+
var sigma1Sq = sum1Sq / n - mu1 * mu1;
82+
var sigma2Sq = sum2Sq / n - mu2 * mu2;
83+
var sigma12 = sum12 / n - mu1 * mu2;
84+
85+
return (2 * mu1 * mu2 + c1) * (2 * sigma12 + c2) /
86+
((mu1 * mu1 + mu2 * mu2 + c1) * (sigma1Sq + sigma2Sq + c2));
87+
}
88+
}

src/Verify.ImageSharp/VerifyImageSharp.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ public static class VerifyImageSharp
44
{
55
public static bool Initialized { get; private set; }
66

7-
public static void Initialize()
7+
public static void Initialize(double ssimThreshold = 1.0)
88
{
99
if (Initialized)
1010
{
@@ -20,10 +20,40 @@ public static void Initialize()
2020
VerifierSettings.RegisterStreamConverter("png", ConvertPng);
2121
VerifierSettings.RegisterStreamConverter("tif", ConvertTiff);
2222

23+
if (ssimThreshold < 1.0)
24+
{
25+
Task<CompareResult> Compare(Stream received, Stream verified, IReadOnlyDictionary<string, object> context)
26+
{
27+
var threshold = context.TryGetValue("ImageSharpSsimThreshold", out var value)
28+
? (double) value
29+
: ssimThreshold;
30+
var ssim = SsimComparer.Calculate(received, verified);
31+
var result = ssim >= threshold
32+
? CompareResult.Equal
33+
: CompareResult.NotEqual($"SSIM: {ssim:F6}, threshold: {threshold:F6}");
34+
return Task.FromResult(result);
35+
}
36+
37+
VerifierSettings.RegisterStreamComparer("bmp", Compare);
38+
VerifierSettings.RegisterStreamComparer("gif", Compare);
39+
VerifierSettings.RegisterStreamComparer("jpg", Compare);
40+
VerifierSettings.RegisterStreamComparer("png", Compare);
41+
VerifierSettings.RegisterStreamComparer("tif", Compare);
42+
}
43+
2344
var encoder = new PngEncoder();
2445
VerifierSettings.RegisterFileConverter<Image>((image, context) => ConvertImage(null, image, context, "png", encoder));
2546
}
2647

48+
public static void SsimThreshold(this VerifySettings settings, double threshold) =>
49+
settings.Context["ImageSharpSsimThreshold"] = threshold;
50+
51+
public static SettingsTask SsimThreshold(this SettingsTask settings, double threshold)
52+
{
53+
settings.CurrentSettings.SsimThreshold(threshold);
54+
return settings;
55+
}
56+
2757
static void EncodeAs<TEncoder>(this VerifySettings settings, string extension, IImageEncoder? encoder)
2858
where TEncoder : IImageEncoder, new()
2959
{

0 commit comments

Comments
 (0)