Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ src/Verify.NUnit.Tests/Tests.AutoVerifyHasAttachment.verified.txt
src/Verify.TUnit.Tests/Tests.AutoVerifyHasAttachment.verified.txt
/src/Benchmarks/BenchmarkDotNet.Artifacts
nul
/BenchmarkDotNet.Artifacts
48 changes: 48 additions & 0 deletions docs/comparer.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,54 @@ static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer)
<!-- endSnippet -->


## PNG SSIM comparer

Verify includes a built-in [Structural Similarity Index](https://en.wikipedia.org/wiki/Structural_similarity_index_measure) (SSIM) comparer for PNG files. It is opt-in and, when enabled, replaces the default byte-for-byte comparison for the `.png` extension.

This is useful when rendered images differ slightly between runs (e.g. anti-aliasing, font hinting, platform-specific rasterization) but are perceptually identical.

<!-- snippet: UseSsimForPng -->
<a id='snippet-UseSsimForPng'></a>
```cs
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifierSettings.UseSsimForPng();
}
```
<sup><a href='/src/ModuleInitDocs/UseSsimForPng.cs#L3-L12' title='Snippet source file'>snippet source</a> | <a href='#snippet-UseSsimForPng' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The default threshold is `0.98`. SSIM scores range from `0` (completely different) to `1` (identical). A custom threshold can be supplied:

<!-- snippet: UseSsimForPngThreshold -->
<a id='snippet-UseSsimForPngThreshold'></a>
```cs
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifierSettings.UseSsimForPng(threshold: 0.995);
}
```
<sup><a href='/src/ModuleInitDocs/UseSsimForPngThreshold.cs#L3-L12' title='Snippet source file'>snippet source</a> | <a href='#snippet-UseSsimForPngThreshold' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Dimension mismatches between the received and verified images are always reported as not equal, regardless of threshold.


### Supported PNG variants

The bundled decoder targets the common subset of PNGs produced by test scenarios:

* 8-bit bit depth
* Color types: grayscale, RGB, RGBA, grayscale+alpha, and paletted (with optional `tRNS` transparency)
* Non-interlaced images

Unsupported variants (16-bit, Adam7 interlacing) cause the decoder to throw. For scenarios that require full PNG support, use [Verify.ImageMagick](https://github.com/VerifyTests/Verify.ImageMagick) or [Verify.ImageHash](https://github.com/VerifyTests/Verify.ImageHash).


## Pre-packaged comparers

* [Verify.AngleSharp.Diffing](https://github.com/VerifyTests/Verify.AngleSharp.Diffing): Comparison of html files via [AngleSharp.Diffing](https://github.com/AngleSharp/AngleSharp.Diffing).
Expand Down
26 changes: 26 additions & 0 deletions docs/mdsource/comparer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ snippet: StaticComparer
snippet: DefualtCompare


## PNG SSIM comparer

Verify includes a built-in [Structural Similarity Index](https://en.wikipedia.org/wiki/Structural_similarity_index_measure) (SSIM) comparer for PNG files. It is opt-in and, when enabled, replaces the default byte-for-byte comparison for the `.png` extension.

This is useful when rendered images differ slightly between runs (e.g. anti-aliasing, font hinting, platform-specific rasterization) but are perceptually identical.

snippet: UseSsimForPng

The default threshold is `0.98`. SSIM scores range from `0` (completely different) to `1` (identical). A custom threshold can be supplied:

snippet: UseSsimForPngThreshold

Dimension mismatches between the received and verified images are always reported as not equal, regardless of threshold.


### Supported PNG variants

The bundled decoder targets the common subset of PNGs produced by test scenarios:

* 8-bit bit depth
* Color types: grayscale, RGB, RGBA, grayscale+alpha, and paletted (with optional `tRNS` transparency)
* Non-interlaced images

Unsupported variants (16-bit, Adam7 interlacing) cause the decoder to throw. For scenarios that require full PNG support, use [Verify.ImageMagick](https://github.com/VerifyTests/Verify.ImageMagick) or [Verify.ImageHash](https://github.com/VerifyTests/Verify.ImageHash).


## Pre-packaged comparers

* [Verify.AngleSharp.Diffing](https://github.com/VerifyTests/Verify.AngleSharp.Diffing): Comparison of html files via [AngleSharp.Diffing](https://github.com/AngleSharp/AngleSharp.Diffing).
Expand Down
2 changes: 1 addition & 1 deletion src/Benchmarks/Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>CA1822;CS7022</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
183 changes: 183 additions & 0 deletions src/Benchmarks/PngSsimBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
using System.IO.Compression;

[MemoryDiagnoser]
[SimpleJob(iterationCount: 10, warmupCount: 3)]
public class PngSsimBenchmarks
{
byte[] smallPng = null!;
byte[] mediumPng = null!;
byte[] largePng = null!;

PngImage smallImage;
PngImage smallImageCopy;
PngImage mediumImage;
PngImage mediumImageCopy;
PngImage largeImage;
PngImage largeImageCopy;

static readonly IReadOnlyDictionary<string, object> emptyContext = new Dictionary<string, object>();

[GlobalSetup]
public void Setup()
{
smallPng = BuildPng(16, 16, seed: 1);
mediumPng = BuildPng(128, 128, seed: 2);
largePng = BuildPng(512, 512, seed: 3);

smallImage = PngDecoder.Decode(new MemoryStream(smallPng));
smallImageCopy = PngDecoder.Decode(new MemoryStream(smallPng));
mediumImage = PngDecoder.Decode(new MemoryStream(mediumPng));
mediumImageCopy = PngDecoder.Decode(new MemoryStream(mediumPng));
largeImage = PngDecoder.Decode(new MemoryStream(largePng));
largeImageCopy = PngDecoder.Decode(new MemoryStream(largePng));
}

[Benchmark]
public int Decode_Small() => PngDecoder.Decode(new MemoryStream(smallPng)).Width;

[Benchmark]
public int Decode_Medium() => PngDecoder.Decode(new MemoryStream(mediumPng)).Width;

[Benchmark]
public int Decode_Large() => PngDecoder.Decode(new MemoryStream(largePng)).Width;

[Benchmark]
public double Ssim_Small() => Ssim.Compare(smallImage, smallImageCopy);

[Benchmark]
public double Ssim_Medium() => Ssim.Compare(mediumImage, mediumImageCopy);

[Benchmark]
public double Ssim_Large() => Ssim.Compare(largeImage, largeImageCopy);

[Benchmark]
public async Task Compare_Small() =>
await PngSsimComparer.Compare(new MemoryStream(smallPng), new MemoryStream(smallPng), emptyContext);

[Benchmark]
public async Task Compare_Medium() =>
await PngSsimComparer.Compare(new MemoryStream(mediumPng), new MemoryStream(mediumPng), emptyContext);

[Benchmark]
public async Task Compare_Large() =>
await PngSsimComparer.Compare(new MemoryStream(largePng), new MemoryStream(largePng), emptyContext);

static byte[] BuildPng(int width, int height, int seed)
{
var rgba = new byte[width * height * 4];
new Random(seed).NextBytes(rgba);
for (var i = 3; i < rgba.Length; i += 4)
{
rgba[i] = 255;
}

// Inline a minimal RGBA PNG builder so this file has no test-project dependency.
var raw = new byte[(width * 4 + 1) * height];
for (var y = 0; y < height; y++)
{
raw[y * (width * 4 + 1)] = 0; // filter None
Buffer.BlockCopy(rgba, y * width * 4, raw, y * (width * 4 + 1) + 1, width * 4);
}

var compressed = ZlibCompress(raw);
using var stream = new MemoryStream();
stream.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 0, 8);

var ihdr = new byte[13];
WriteBe(ihdr, 0, (uint)width);
WriteBe(ihdr, 4, (uint)height);
ihdr[8] = 8;
ihdr[9] = 6;
WriteChunk(stream, "IHDR"u8.ToArray(), ihdr);
WriteChunk(stream, "IDAT"u8.ToArray(), compressed);
WriteChunk(stream, "IEND"u8.ToArray(), []);
return stream.ToArray();
}

static void WriteChunk(Stream stream, byte[] type, byte[] data)
{
var header = new byte[4];
WriteBe(header, 0, (uint)data.Length);
stream.Write(header, 0, 4);
stream.Write(type, 0, 4);
stream.Write(data, 0, data.Length);

var combined = new byte[type.Length + data.Length];
Buffer.BlockCopy(type, 0, combined, 0, type.Length);
Buffer.BlockCopy(data, 0, combined, type.Length, data.Length);
var crc = Crc32(combined);
var crcBytes = new byte[4];
WriteBe(crcBytes, 0, crc);
stream.Write(crcBytes, 0, 4);
}

static byte[] ZlibCompress(byte[] data)
{
using var output = new MemoryStream();
output.WriteByte(0x78);
output.WriteByte(0x9C);
using (var deflate = new DeflateStream(output, CompressionLevel.Optimal, leaveOpen: true))
{
deflate.Write(data, 0, data.Length);
}

var adler = Adler32(data);
output.WriteByte((byte)((adler >> 24) & 0xFF));
output.WriteByte((byte)((adler >> 16) & 0xFF));
output.WriteByte((byte)((adler >> 8) & 0xFF));
output.WriteByte((byte)(adler & 0xFF));
return output.ToArray();
}

static uint Adler32(byte[] data)
{
const uint mod = 65521;
uint a = 1;
uint b = 0;
foreach (var item in data)
{
a = (a + item) % mod;
b = (b + a) % mod;
}

return (b << 16) | a;
}

static void WriteBe(byte[] buffer, int offset, uint value)
{
buffer[offset] = (byte)((value >> 24) & 0xFF);
buffer[offset + 1] = (byte)((value >> 16) & 0xFF);
buffer[offset + 2] = (byte)((value >> 8) & 0xFF);
buffer[offset + 3] = (byte)(value & 0xFF);
}

static readonly uint[] crcTable = BuildCrcTable();

static uint[] BuildCrcTable()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++)
{
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
}

table[n] = c;
}

return table;
}

static uint Crc32(byte[] data)
{
var c = 0xFFFFFFFF;
foreach (var item in data)
{
c = crcTable[(c ^ item) & 0xFF] ^ (c >> 8);
}

return c ^ 0xFFFFFFFF;
}
}
13 changes: 13 additions & 0 deletions src/ModuleInitDocs/UseSsimForPng.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
public class UseSsimForPng
{
#region UseSsimForPng

public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifierSettings.UseSsimForPng();
}

#endregion
}
13 changes: 13 additions & 0 deletions src/ModuleInitDocs/UseSsimForPngThreshold.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
public class UseSsimForPngThreshold
{
#region UseSsimForPngThreshold

public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifierSettings.UseSsimForPng(threshold: 0.995);
}

#endregion
}
Loading
Loading