Skip to content

Commit ef486c0

Browse files
authored
Add ssim comparer (#725)
* . * Update Directory.Build.props * Update SsimComparer.cs
1 parent 9afe5cf commit ef486c0

8 files changed

Lines changed: 362 additions & 2 deletions

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/

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<Project>
33
<PropertyGroup>
44
<NoWarn>CS1591;CS0649;NU1608;NU1109</NoWarn>
5-
<Version>4.4.1</Version>
5+
<Version>5.0.0</Version>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<LangVersion>preview</LangVersion>
88
<AssemblyVersion>1.0.0</AssemblyVersion>
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: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
using System.Runtime.InteropServices;
2+
using System.Runtime.Intrinsics;
3+
using SixLabors.ImageSharp.PixelFormats;
4+
5+
namespace VerifyTests;
6+
7+
public static class SsimComparer
8+
{
9+
const double k1 = 0.01;
10+
const double k2 = 0.03;
11+
const double l = 255.0;
12+
const double c1 = k1 * l * k1 * l;
13+
const double c2 = k2 * l * k2 * l;
14+
15+
public static double Calculate(Stream received, Stream verified)
16+
{
17+
using var img1 = Image.Load<Rgba32>(received);
18+
using var img2 = Image.Load<Rgba32>(verified);
19+
20+
if (img1.Width != img2.Width ||
21+
img1.Height != img2.Height)
22+
{
23+
return 0;
24+
}
25+
26+
var width = img1.Width;
27+
var height = img1.Height;
28+
var pixelCount = (double) (width * height);
29+
30+
double sumR1 = 0, sumG1 = 0, sumB1 = 0;
31+
double sumR2 = 0, sumG2 = 0, sumB2 = 0;
32+
double sumR1Sq = 0, sumG1Sq = 0, sumB1Sq = 0;
33+
double sumR2Sq = 0, sumG2Sq = 0, sumB2Sq = 0;
34+
double sumR12 = 0, sumG12 = 0, sumB12 = 0;
35+
36+
img1.ProcessPixelRows(img2, (acc1, acc2) =>
37+
{
38+
for (var y = 0; y < height; y++)
39+
{
40+
var row1 = MemoryMarshal.Cast<Rgba32, uint>(acc1.GetRowSpan(y));
41+
var row2 = MemoryMarshal.Cast<Rgba32, uint>(acc2.GetRowSpan(y));
42+
var x = 0;
43+
44+
if (Vector256.IsHardwareAccelerated)
45+
{
46+
x = AccumulateVector256(
47+
row1, row2, width,
48+
ref sumR1, ref sumG1, ref sumB1,
49+
ref sumR2, ref sumG2, ref sumB2,
50+
ref sumR1Sq, ref sumG1Sq, ref sumB1Sq,
51+
ref sumR2Sq, ref sumG2Sq, ref sumB2Sq,
52+
ref sumR12, ref sumG12, ref sumB12);
53+
}
54+
else if (Vector128.IsHardwareAccelerated)
55+
{
56+
x = AccumulateVector128(
57+
row1, row2, width,
58+
ref sumR1, ref sumG1, ref sumB1,
59+
ref sumR2, ref sumG2, ref sumB2,
60+
ref sumR1Sq, ref sumG1Sq, ref sumB1Sq,
61+
ref sumR2Sq, ref sumG2Sq, ref sumB2Sq,
62+
ref sumR12, ref sumG12, ref sumB12);
63+
}
64+
65+
for (; x < width; x++)
66+
{
67+
double r1 = (byte) row1[x], g1 = (byte) (row1[x] >> 8), b1 = (byte) (row1[x] >> 16);
68+
double r2 = (byte) row2[x], g2 = (byte) (row2[x] >> 8), b2 = (byte) (row2[x] >> 16);
69+
70+
sumR1 += r1; sumG1 += g1; sumB1 += b1;
71+
sumR2 += r2; sumG2 += g2; sumB2 += b2;
72+
sumR1Sq += r1 * r1; sumG1Sq += g1 * g1; sumB1Sq += b1 * b1;
73+
sumR2Sq += r2 * r2; sumG2Sq += g2 * g2; sumB2Sq += b2 * b2;
74+
sumR12 += r1 * r2; sumG12 += g1 * g2; sumB12 += b1 * b2;
75+
}
76+
}
77+
});
78+
79+
var ssimR = CalculateChannel(pixelCount, sumR1, sumR2, sumR1Sq, sumR2Sq, sumR12);
80+
var ssimG = CalculateChannel(pixelCount, sumG1, sumG2, sumG1Sq, sumG2Sq, sumG12);
81+
var ssimB = CalculateChannel(pixelCount, sumB1, sumB2, sumB1Sq, sumB2Sq, sumB12);
82+
83+
return (ssimR + ssimG + ssimB) / 3.0;
84+
}
85+
86+
static int AccumulateVector256(
87+
ReadOnlySpan<uint> row1, ReadOnlySpan<uint> row2, int width,
88+
ref double sumR1, ref double sumG1, ref double sumB1,
89+
ref double sumR2, ref double sumG2, ref double sumB2,
90+
ref double sumR1Sq, ref double sumG1Sq, ref double sumB1Sq,
91+
ref double sumR2Sq, ref double sumG2Sq, ref double sumB2Sq,
92+
ref double sumR12, ref double sumG12, ref double sumB12)
93+
{
94+
var vR1 = Vector256<float>.Zero; var vG1 = Vector256<float>.Zero; var vB1 = Vector256<float>.Zero;
95+
var vR2 = Vector256<float>.Zero; var vG2 = Vector256<float>.Zero; var vB2 = Vector256<float>.Zero;
96+
var vR1Sq = Vector256<float>.Zero; var vG1Sq = Vector256<float>.Zero; var vB1Sq = Vector256<float>.Zero;
97+
var vR2Sq = Vector256<float>.Zero; var vG2Sq = Vector256<float>.Zero; var vB2Sq = Vector256<float>.Zero;
98+
var vR12 = Vector256<float>.Zero; var vG12 = Vector256<float>.Zero; var vB12 = Vector256<float>.Zero;
99+
var mask = Vector256.Create(0x000000FFu);
100+
ref var ref1 = ref MemoryMarshal.GetReference(row1);
101+
ref var ref2 = ref MemoryMarshal.GetReference(row2);
102+
var x = 0;
103+
104+
for (; x <= width - Vector256<uint>.Count; x += Vector256<uint>.Count)
105+
{
106+
var p1 = Vector256.LoadUnsafe(ref ref1, (nuint) x);
107+
var p2 = Vector256.LoadUnsafe(ref ref2, (nuint) x);
108+
109+
var r1 = Vector256.ConvertToSingle((p1 & mask).AsInt32());
110+
var g1 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p1, 8) & mask).AsInt32());
111+
var b1 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p1, 16) & mask).AsInt32());
112+
var r2 = Vector256.ConvertToSingle((p2 & mask).AsInt32());
113+
var g2 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p2, 8) & mask).AsInt32());
114+
var b2 = Vector256.ConvertToSingle((Vector256.ShiftRightLogical(p2, 16) & mask).AsInt32());
115+
116+
vR1 += r1; vG1 += g1; vB1 += b1;
117+
vR2 += r2; vG2 += g2; vB2 += b2;
118+
vR1Sq += r1 * r1; vG1Sq += g1 * g1; vB1Sq += b1 * b1;
119+
vR2Sq += r2 * r2; vG2Sq += g2 * g2; vB2Sq += b2 * b2;
120+
vR12 += r1 * r2; vG12 += g1 * g2; vB12 += b1 * b2;
121+
}
122+
123+
sumR1 += Vector256.Sum(vR1); sumG1 += Vector256.Sum(vG1); sumB1 += Vector256.Sum(vB1);
124+
sumR2 += Vector256.Sum(vR2); sumG2 += Vector256.Sum(vG2); sumB2 += Vector256.Sum(vB2);
125+
sumR1Sq += Vector256.Sum(vR1Sq); sumG1Sq += Vector256.Sum(vG1Sq); sumB1Sq += Vector256.Sum(vB1Sq);
126+
sumR2Sq += Vector256.Sum(vR2Sq); sumG2Sq += Vector256.Sum(vG2Sq); sumB2Sq += Vector256.Sum(vB2Sq);
127+
sumR12 += Vector256.Sum(vR12); sumG12 += Vector256.Sum(vG12); sumB12 += Vector256.Sum(vB12);
128+
return x;
129+
}
130+
131+
static int AccumulateVector128(
132+
ReadOnlySpan<uint> row1, ReadOnlySpan<uint> row2, int width,
133+
ref double sumR1, ref double sumG1, ref double sumB1,
134+
ref double sumR2, ref double sumG2, ref double sumB2,
135+
ref double sumR1Sq, ref double sumG1Sq, ref double sumB1Sq,
136+
ref double sumR2Sq, ref double sumG2Sq, ref double sumB2Sq,
137+
ref double sumR12, ref double sumG12, ref double sumB12)
138+
{
139+
var vR1 = Vector128<float>.Zero; var vG1 = Vector128<float>.Zero; var vB1 = Vector128<float>.Zero;
140+
var vR2 = Vector128<float>.Zero; var vG2 = Vector128<float>.Zero; var vB2 = Vector128<float>.Zero;
141+
var vR1Sq = Vector128<float>.Zero; var vG1Sq = Vector128<float>.Zero; var vB1Sq = Vector128<float>.Zero;
142+
var vR2Sq = Vector128<float>.Zero; var vG2Sq = Vector128<float>.Zero; var vB2Sq = Vector128<float>.Zero;
143+
var vR12 = Vector128<float>.Zero; var vG12 = Vector128<float>.Zero; var vB12 = Vector128<float>.Zero;
144+
var mask = Vector128.Create(0x000000FFu);
145+
ref var ref1 = ref MemoryMarshal.GetReference(row1);
146+
ref var ref2 = ref MemoryMarshal.GetReference(row2);
147+
var x = 0;
148+
149+
for (; x <= width - Vector128<uint>.Count; x += Vector128<uint>.Count)
150+
{
151+
var p1 = Vector128.LoadUnsafe(ref ref1, (nuint) x);
152+
var p2 = Vector128.LoadUnsafe(ref ref2, (nuint) x);
153+
154+
var r1 = Vector128.ConvertToSingle((p1 & mask).AsInt32());
155+
var g1 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p1, 8) & mask).AsInt32());
156+
var b1 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p1, 16) & mask).AsInt32());
157+
var r2 = Vector128.ConvertToSingle((p2 & mask).AsInt32());
158+
var g2 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p2, 8) & mask).AsInt32());
159+
var b2 = Vector128.ConvertToSingle((Vector128.ShiftRightLogical(p2, 16) & mask).AsInt32());
160+
161+
vR1 += r1; vG1 += g1; vB1 += b1;
162+
vR2 += r2; vG2 += g2; vB2 += b2;
163+
vR1Sq += r1 * r1; vG1Sq += g1 * g1; vB1Sq += b1 * b1;
164+
vR2Sq += r2 * r2; vG2Sq += g2 * g2; vB2Sq += b2 * b2;
165+
vR12 += r1 * r2; vG12 += g1 * g2; vB12 += b1 * b2;
166+
}
167+
168+
sumR1 += Vector128.Sum(vR1); sumG1 += Vector128.Sum(vG1); sumB1 += Vector128.Sum(vB1);
169+
sumR2 += Vector128.Sum(vR2); sumG2 += Vector128.Sum(vG2); sumB2 += Vector128.Sum(vB2);
170+
sumR1Sq += Vector128.Sum(vR1Sq); sumG1Sq += Vector128.Sum(vG1Sq); sumB1Sq += Vector128.Sum(vB1Sq);
171+
sumR2Sq += Vector128.Sum(vR2Sq); sumG2Sq += Vector128.Sum(vG2Sq); sumB2Sq += Vector128.Sum(vB2Sq);
172+
sumR12 += Vector128.Sum(vR12); sumG12 += Vector128.Sum(vG12); sumB12 += Vector128.Sum(vB12);
173+
return x;
174+
}
175+
176+
static double CalculateChannel(double n, double sum1, double sum2, double sum1Sq, double sum2Sq, double sum12)
177+
{
178+
var mu1 = sum1 / n;
179+
var mu2 = sum2 / n;
180+
var sigma1Sq = sum1Sq / n - mu1 * mu1;
181+
var sigma2Sq = sum2Sq / n - mu2 * mu2;
182+
var sigma12 = sum12 / n - mu1 * mu2;
183+
184+
return (2 * mu1 * mu2 + c1) * (2 * sigma12 + c2) /
185+
((mu1 * mu1 + mu2 * mu2 + c1) * (sigma1Sq + sigma2Sq + c2));
186+
}
187+
}

0 commit comments

Comments
 (0)