diff --git a/.gitignore b/.gitignore index 39c89ee5f..a57193e23 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/comparer.md b/docs/comparer.md index 4677c6260..fce91288e 100644 --- a/docs/comparer.md +++ b/docs/comparer.md @@ -147,6 +147,54 @@ static async Task ReadBufferAsync(Stream stream, byte[] buffer) +## 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. + + + +```cs +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() => + VerifierSettings.UseSsimForPng(); +} +``` +snippet source | anchor + + +The default threshold is `0.98`. SSIM scores range from `0` (completely different) to `1` (identical). A custom threshold can be supplied: + + + +```cs +public static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() => + VerifierSettings.UseSsimForPng(threshold: 0.995); +} +``` +snippet source | anchor + + +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). diff --git a/docs/mdsource/comparer.source.md b/docs/mdsource/comparer.source.md index d55ce44e2..cfc9c82a9 100644 --- a/docs/mdsource/comparer.source.md +++ b/docs/mdsource/comparer.source.md @@ -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). diff --git a/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index 864980bf4..d06607acd 100644 --- a/src/Benchmarks/Benchmarks.csproj +++ b/src/Benchmarks/Benchmarks.csproj @@ -1,7 +1,7 @@ Exe - net11.0 + net10.0 CA1822;CS7022 enable enable diff --git a/src/Benchmarks/PngSsimBenchmarks.cs b/src/Benchmarks/PngSsimBenchmarks.cs new file mode 100644 index 000000000..d4c2eb79a --- /dev/null +++ b/src/Benchmarks/PngSsimBenchmarks.cs @@ -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 emptyContext = new Dictionary(); + + [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; + } +} diff --git a/src/ModuleInitDocs/UseSsimForPng.cs b/src/ModuleInitDocs/UseSsimForPng.cs new file mode 100644 index 000000000..966c6e6c0 --- /dev/null +++ b/src/ModuleInitDocs/UseSsimForPng.cs @@ -0,0 +1,13 @@ +public class UseSsimForPng +{ + #region UseSsimForPng + + public static class ModuleInitializer + { + [ModuleInitializer] + public static void Init() => + VerifierSettings.UseSsimForPng(); + } + + #endregion +} diff --git a/src/ModuleInitDocs/UseSsimForPngThreshold.cs b/src/ModuleInitDocs/UseSsimForPngThreshold.cs new file mode 100644 index 000000000..9f262262a --- /dev/null +++ b/src/ModuleInitDocs/UseSsimForPngThreshold.cs @@ -0,0 +1,13 @@ +public class UseSsimForPngThreshold +{ + #region UseSsimForPngThreshold + + public static class ModuleInitializer + { + [ModuleInitializer] + public static void Init() => + VerifierSettings.UseSsimForPng(threshold: 0.995); + } + + #endregion +} diff --git a/src/Verify.Tests/Compare/Png/PngDecoderTests.cs b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs new file mode 100644 index 000000000..56fff801e --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs @@ -0,0 +1,161 @@ +public class PngDecoderTests +{ + [Fact] + public void Empty_1x1_Rgba() + { + byte[] pixels = [10, 20, 30, 40]; + var png = PngTestHelper.EncodeRgba(1, 1, pixels); + var image = PngDecoder.Decode(new MemoryStream(png)); + Assert.Equal(1, image.Width); + Assert.Equal(1, image.Height); + Assert.Equal(pixels, image.Rgba); + } + + [Fact] + public void Small_8x8_Rgb() + { + const int width = 8; + const int height = 8; + var rgb = new byte[width * height * 3]; + for (var i = 0; i < rgb.Length; i++) + { + rgb[i] = (byte)i; + } + + var png = PngTestHelper.EncodeRgb(width, height, rgb); + var image = PngDecoder.Decode(new MemoryStream(png)); + Assert.Equal(width, image.Width); + Assert.Equal(height, image.Height); + for (var i = 0; i < width * height; i++) + { + Assert.Equal(rgb[i * 3], image.Rgba[i * 4]); + Assert.Equal(rgb[i * 3 + 1], image.Rgba[i * 4 + 1]); + Assert.Equal(rgb[i * 3 + 2], image.Rgba[i * 4 + 2]); + Assert.Equal(255, image.Rgba[i * 4 + 3]); + } + } + + [Fact] + public void Small_Grayscale() + { + const int width = 4; + const int height = 4; + var gray = new byte[width * height]; + for (var i = 0; i < gray.Length; i++) + { + gray[i] = (byte)(i * 16); + } + + var png = PngTestHelper.EncodeGray(width, height, gray); + var image = PngDecoder.Decode(new MemoryStream(png)); + for (var i = 0; i < gray.Length; i++) + { + Assert.Equal(gray[i], image.Rgba[i * 4]); + Assert.Equal(gray[i], image.Rgba[i * 4 + 1]); + Assert.Equal(gray[i], image.Rgba[i * 4 + 2]); + Assert.Equal(255, image.Rgba[i * 4 + 3]); + } + } + + [Fact] + public void Paletted_With_Transparency() + { + const int width = 4; + const int height = 4; + byte[] palette = + [ + 255, 0, 0, // red + 0, 255, 0, // green + 0, 0, 255, // blue + 255, 255, 0 // yellow + ]; + byte[] trns = [0, 128, 255, 255]; // alpha per palette entry + + var indices = new byte[width * height]; + for (var i = 0; i < indices.Length; i++) + { + indices[i] = (byte)(i % 4); + } + + var png = PngTestHelper.EncodePaletted(width, height, indices, palette, trns); + var image = PngDecoder.Decode(new MemoryStream(png)); + + Assert.Equal(width, image.Width); + Assert.Equal(height, image.Height); + + for (var i = 0; i < indices.Length; i++) + { + var idx = indices[i]; + Assert.Equal(palette[idx * 3], image.Rgba[i * 4]); + Assert.Equal(palette[idx * 3 + 1], image.Rgba[i * 4 + 1]); + Assert.Equal(palette[idx * 3 + 2], image.Rgba[i * 4 + 2]); + Assert.Equal(trns[idx], image.Rgba[i * 4 + 3]); + } + } + + [Fact] + public void Paletted_Without_Transparency_Defaults_To_Opaque() + { + byte[] palette = [10, 20, 30, 40, 50, 60]; + byte[] indices = [0, 1, 0, 1]; + var png = PngTestHelper.EncodePaletted(2, 2, indices, palette); + var image = PngDecoder.Decode(new MemoryStream(png)); + for (var i = 0; i < 4; i++) + { + Assert.Equal(255, image.Rgba[i * 4 + 3]); + } + } + + [Fact] + public void Medium_64x64() + { + const int width = 64; + const int height = 64; + var rgba = new byte[width * height * 4]; + var rand = new Random(42); + rand.NextBytes(rgba); + var png = PngTestHelper.EncodeRgba(width, height, rgba); + var image = PngDecoder.Decode(new MemoryStream(png)); + Assert.Equal(rgba, image.Rgba); + } + + [Fact] + public void Large_256x256() + { + const int width = 256; + const int height = 256; + var rgba = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var o = (y * width + x) * 4; + rgba[o] = (byte)x; + rgba[o + 1] = (byte)y; + rgba[o + 2] = (byte)(x ^ y); + rgba[o + 3] = 255; + } + } + + var png = PngTestHelper.EncodeRgba(width, height, rgba); + var image = PngDecoder.Decode(new MemoryStream(png)); + Assert.Equal(width, image.Width); + Assert.Equal(height, image.Height); + Assert.Equal(rgba, image.Rgba); + } + + [Fact] + public void Rejects_Bad_Signature() + { + var bad = new byte[16]; + Assert.Throws(() => PngDecoder.Decode(new MemoryStream(bad))); + } + + [Fact] + public void Rejects_Truncated_Stream() + { + var png = PngTestHelper.EncodeRgba(2, 2, new byte[16]); + var truncated = png.Take(20).ToArray(); + Assert.ThrowsAny(() => PngDecoder.Decode(new MemoryStream(truncated))); + } +} diff --git a/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs new file mode 100644 index 000000000..5648304c9 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs @@ -0,0 +1,108 @@ +public class PngSsimComparerTests +{ + static readonly Dictionary emptyContext = new(); + + [Fact] + public async Task Identical_Byte_For_Byte_Equal() + { + var png = PngTestHelper.EncodeRgba(16, 16, RandomRgba(16, 16, seed: 1)); + var result = await PngSsimComparer.Compare(new MemoryStream(png), new MemoryStream(png), emptyContext); + Assert.True(result.IsEqual); + } + + [Fact] + public async Task Dimension_Mismatch_NotEqual_With_Message() + { + var a = PngTestHelper.EncodeRgba(10, 10, new byte[10 * 10 * 4]); + var b = PngTestHelper.EncodeRgba(11, 10, new byte[11 * 10 * 4]); + var result = await PngSsimComparer.Compare(new MemoryStream(a), new MemoryStream(b), emptyContext); + Assert.False(result.IsEqual); + Assert.Contains("dimensions differ", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("10x10", result.Message); + Assert.Contains("11x10", result.Message); + } + + [Fact] + public async Task Near_Identical_Passes_Default_Threshold() + { + var original = RandomRgba(32, 32, seed: 9); + var modified = (byte[])original.Clone(); + // Tweak a handful of pixels very slightly. + for (var i = 0; i < 16; i += 4) + { + modified[i] = (byte)(modified[i] ^ 1); + } + + var a = PngTestHelper.EncodeRgba(32, 32, original); + var b = PngTestHelper.EncodeRgba(32, 32, modified); + var result = await PngSsimComparer.Compare(new MemoryStream(a), new MemoryStream(b), emptyContext); + Assert.True(result.IsEqual, result.Message); + } + + [Fact] + public async Task Completely_Different_Fails() + { + var black = new byte[32 * 32 * 4]; + var white = new byte[32 * 32 * 4]; + for (var i = 0; i < white.Length; i++) + { + white[i] = 255; + } + + var a = PngTestHelper.EncodeRgba(32, 32, black); + var b = PngTestHelper.EncodeRgba(32, 32, white); + var result = await PngSsimComparer.Compare(new MemoryStream(a), new MemoryStream(b), emptyContext); + Assert.False(result.IsEqual); + Assert.Contains("SSIM", result.Message); + } + + [Fact] + public async Task Threshold_Tuning_Tightens_Comparison() + { + var a = PngTestHelper.EncodeRgba(32, 32, RandomRgba(32, 32, seed: 3)); + var modified = RandomRgba(32, 32, seed: 3); + for (var i = 0; i < modified.Length; i += 4) + { + modified[i] = (byte)Math.Clamp(modified[i] + 20, 0, 255); + } + + var b = PngTestHelper.EncodeRgba(32, 32, modified); + + var previous = PngSsimComparer.Threshold; + try + { + PngSsimComparer.Threshold = 0.5; + var lenient = await PngSsimComparer.Compare(new MemoryStream(a), new MemoryStream(b), emptyContext); + Assert.True(lenient.IsEqual); + + PngSsimComparer.Threshold = 0.9999; + var strict = await PngSsimComparer.Compare(new MemoryStream(a), new MemoryStream(b), emptyContext); + Assert.False(strict.IsEqual); + } + finally + { + PngSsimComparer.Threshold = previous; + } + } + + [Fact] + public async Task Corrupt_Png_Throws() + { + var valid = PngTestHelper.EncodeRgba(4, 4, new byte[4 * 4 * 4]); + var corrupt = new byte[8]; + await Assert.ThrowsAnyAsync(() => + PngSsimComparer.Compare(new MemoryStream(corrupt), new MemoryStream(valid), emptyContext)); + } + + static byte[] RandomRgba(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; + } + + return rgba; + } +} diff --git a/src/Verify.Tests/Compare/Png/PngTestHelper.cs b/src/Verify.Tests/Compare/Png/PngTestHelper.cs new file mode 100644 index 000000000..61252f8bd --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngTestHelper.cs @@ -0,0 +1,171 @@ +static class PngTestHelper +{ + static readonly byte[] signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + static readonly uint[] crcTable = BuildCrcTable(); + + public static byte[] EncodeRgba(int width, int height, byte[] rgba) + { + if (rgba.Length != width * height * 4) + { + throw new ArgumentException("rgba length mismatch"); + } + + var raw = AddFilterBytes(rgba, width * 4, height); + var ihdr = BuildIhdr(width, height, colorType: 6); + return BuildPng(ihdr, plte: null, trns: null, raw: raw); + } + + public static byte[] EncodeRgb(int width, int height, byte[] rgb) + { + var raw = AddFilterBytes(rgb, width * 3, height); + var ihdr = BuildIhdr(width, height, colorType: 2); + return BuildPng(ihdr, null, null, raw); + } + + public static byte[] EncodeGray(int width, int height, byte[] gray) + { + var raw = AddFilterBytes(gray, width, height); + var ihdr = BuildIhdr(width, height, colorType: 0); + return BuildPng(ihdr, null, null, raw); + } + + public static byte[] EncodePaletted(int width, int height, byte[] indices, byte[] palette, byte[]? trns = null) + { + var raw = AddFilterBytes(indices, width, height); + var ihdr = BuildIhdr(width, height, colorType: 3); + return BuildPng(ihdr, palette, trns, raw); + } + + static byte[] AddFilterBytes(byte[] data, int stride, int height) + { + var result = new byte[(stride + 1) * height]; + for (var y = 0; y < height; y++) + { + result[y * (stride + 1)] = 0; // filter None + Buffer.BlockCopy(data, y * stride, result, y * (stride + 1) + 1, stride); + } + + return result; + } + + static byte[] BuildIhdr(int width, int height, byte colorType) + { + var data = new byte[13]; + WriteUInt32Be(data, 0, (uint)width); + WriteUInt32Be(data, 4, (uint)height); + data[8] = 8; // bit depth + data[9] = colorType; + data[10] = 0; // compression + data[11] = 0; // filter method + data[12] = 0; // interlace + return data; + } + + static byte[] BuildPng(byte[] ihdr, byte[]? plte, byte[]? trns, byte[] raw) + { + var compressed = ZlibCompress(raw); + using var stream = new MemoryStream(); + stream.Write(signature, 0, signature.Length); + WriteChunk(stream, "IHDR", ihdr); + if (plte is not null) + { + WriteChunk(stream, "PLTE", plte); + } + + if (trns is not null) + { + WriteChunk(stream, "tRNS", trns); + } + + WriteChunk(stream, "IDAT", compressed); + WriteChunk(stream, "IEND", []); + return stream.ToArray(); + } + + static void WriteChunk(Stream stream, string type, byte[] data) + { + var header = new byte[4]; + WriteUInt32Be(header, 0, (uint)data.Length); + stream.Write(header, 0, 4); + var typeBytes = new[] { (byte)type[0], (byte)type[1], (byte)type[2], (byte)type[3] }; + stream.Write(typeBytes, 0, 4); + stream.Write(data, 0, data.Length); + + var combined = new byte[4 + data.Length]; + Buffer.BlockCopy(typeBytes, 0, combined, 0, 4); + Buffer.BlockCopy(data, 0, combined, 4, data.Length); + var crc = Crc32(combined); + var crcBytes = new byte[4]; + WriteUInt32Be(crcBytes, 0, crc); + stream.Write(crcBytes, 0, 4); + } + + static byte[] ZlibCompress(byte[] data) + { + using var output = new MemoryStream(); + // zlib header (deflate, 32K window, default level, no dict) + 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 modAdler = 65521; + uint a = 1; + uint b = 0; + foreach (var item in data) + { + a = (a + item) % modAdler; + b = (b + a) % modAdler; + } + + return (b << 16) | a; + } + + static void WriteUInt32Be(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 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; + } +} diff --git a/src/Verify.Tests/Compare/Png/SsimTests.cs b/src/Verify.Tests/Compare/Png/SsimTests.cs new file mode 100644 index 000000000..965845443 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/SsimTests.cs @@ -0,0 +1,108 @@ +public class SsimTests +{ + [Fact] + public void Identical_Returns_One() + { + var image = Random(32, 32, seed: 1); + var score = Ssim.Compare(image, image); + Assert.Equal(1.0, score, precision: 6); + } + + [Fact] + public void Tiny_Sub_Window() + { + // 4x4 — smaller than the 8x8 window, uses single-window path. + var a = Solid(4, 4, 128); + var b = Solid(4, 4, 128); + var score = Ssim.Compare(a, b); + Assert.Equal(1.0, score, precision: 6); + } + + [Fact] + public void Uniform_Gray_Identical() + { + var a = Solid(16, 16, 200); + var b = Solid(16, 16, 200); + Assert.Equal(1.0, Ssim.Compare(a, b), precision: 6); + } + + [Fact] + public void Small_Difference_High_Score() + { + var a = Gradient(64, 64); + var b = Gradient(64, 64); + // Perturb one pixel. + b.Rgba[0] ^= 0x10; + b.Rgba[1] ^= 0x10; + b.Rgba[2] ^= 0x10; + var score = Ssim.Compare(a, b); + Assert.InRange(score, 0.98, 1.0); + } + + [Fact] + public void Large_Difference_Low_Score() + { + var a = Solid(32, 32, 0); + var b = Solid(32, 32, 255); + var score = Ssim.Compare(a, b); + Assert.True(score < 0.5, $"Expected low score, got {score}"); + } + + [Fact] + public void Noise_Reduces_Score() + { + var a = Gradient(64, 64); + var b = Gradient(64, 64); + var rand = new Random(7); + for (var i = 0; i < b.Rgba.Length; i += 4) + { + var noise = rand.Next(-3, 4); + b.Rgba[i] = (byte)Math.Clamp(b.Rgba[i] + noise, 0, 255); + b.Rgba[i + 1] = (byte)Math.Clamp(b.Rgba[i + 1] + noise, 0, 255); + b.Rgba[i + 2] = (byte)Math.Clamp(b.Rgba[i + 2] + noise, 0, 255); + } + + var score = Ssim.Compare(a, b); + Assert.True(score < 1.0, $"Expected score < 1.0, got {score}"); + Assert.True(score > 0.9, $"Expected mild noise to retain high score, got {score}"); + } + + static PngImage Random(int w, int h, int seed) + { + var rgba = new byte[w * h * 4]; + new Random(seed).NextBytes(rgba); + return new(w, h, rgba); + } + + static PngImage Solid(int w, int h, byte value) + { + var rgba = new byte[w * h * 4]; + for (var i = 0; i < w * h; i++) + { + rgba[i * 4] = value; + rgba[i * 4 + 1] = value; + rgba[i * 4 + 2] = value; + rgba[i * 4 + 3] = 255; + } + + return new(w, h, rgba); + } + + static PngImage Gradient(int w, int h) + { + var rgba = new byte[w * h * 4]; + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var o = (y * w + x) * 4; + rgba[o] = (byte)(x * 255 / w); + rgba[o + 1] = (byte)(y * 255 / h); + rgba[o + 2] = (byte)((x + y) * 255 / (w + h)); + rgba[o + 3] = 255; + } + } + + return new(w, h, rgba); + } +} diff --git a/src/Verify.Tests/GlobalUsings.cs b/src/Verify.Tests/GlobalUsings.cs index 43922434b..02db356c2 100644 --- a/src/Verify.Tests/GlobalUsings.cs +++ b/src/Verify.Tests/GlobalUsings.cs @@ -7,6 +7,7 @@ global using EmptyFiles; global using Polyfills; global using System.Collections.ObjectModel; +global using System.IO.Compression; global using System.Reflection.Metadata; global using System.Reflection.PortableExecutable; global using System.Security.Claims; \ No newline at end of file diff --git a/src/Verify/Compare/Png/PngDecoder.cs b/src/Verify/Compare/Png/PngDecoder.cs new file mode 100644 index 000000000..1385ece9b --- /dev/null +++ b/src/Verify/Compare/Png/PngDecoder.cs @@ -0,0 +1,361 @@ +static class PngDecoder +{ + static ReadOnlySpan Signature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + const uint ihdr = ('I' << 24) | ('H' << 16) | ('D' << 8) | 'R'; + const uint plte = ('P' << 24) | ('L' << 16) | ('T' << 8) | 'E'; + const uint idatType = ('I' << 24) | ('D' << 16) | ('A' << 8) | 'T'; + const uint iend = ('I' << 24) | ('E' << 16) | ('N' << 8) | 'D'; + const uint trns = ('t' << 24) | ('R' << 16) | ('N' << 8) | 'S'; + + public static PngImage Decode(Stream stream) + { + Span sig = stackalloc byte[8]; + ReadExact(stream, sig); + if (!sig.SequenceEqual(Signature)) + { + throw new("Not a PNG (bad signature)."); + } + + var width = 0; + var height = 0; + byte colorType = 0; + byte[]? palette = null; + byte[]? transparency = null; + using var idat = new MemoryStream(); + var seenIhdr = false; + + Span header = stackalloc byte[8]; + Span crc = stackalloc byte[4]; + Span ihdrData = stackalloc byte[13]; + + while (true) + { + ReadExact(stream, header); + var length = ReadUInt32BigEndian(header); + var type = ((uint)header[4] << 24) | ((uint)header[5] << 16) | ((uint)header[6] << 8) | header[7]; + + if (length > int.MaxValue) + { + throw new("PNG chunk too large."); + } + + var intLength = (int)length; + + switch (type) + { + case ihdr: + if (intLength != 13) + { + throw new("Invalid IHDR length."); + } + + ReadExact(stream, ihdrData); + width = (int)ReadUInt32BigEndian(ihdrData); + height = (int)ReadUInt32BigEndian(ihdrData[4..]); + var bitDepth = ihdrData[8]; + colorType = ihdrData[9]; + var compression = ihdrData[10]; + var filter = ihdrData[11]; + var interlace = ihdrData[12]; + if (compression != 0 || filter != 0) + { + throw new("Unsupported PNG compression/filter method."); + } + + if (interlace != 0) + { + throw new("Unsupported PNG variant: Adam7 interlacing not supported."); + } + + if (bitDepth != 8) + { + throw new($"Unsupported PNG bit depth: {bitDepth}. Only 8-bit supported."); + } + + if (colorType != 0 && colorType != 2 && colorType != 3 && colorType != 4 && colorType != 6) + { + throw new($"Unsupported PNG color type: {colorType}."); + } + + seenIhdr = true; + break; + + case plte: + if (intLength % 3 != 0) + { + throw new("Invalid PLTE length."); + } + + palette = new byte[intLength]; + ReadExact(stream, palette); + break; + + case trns: + transparency = new byte[intLength]; + ReadExact(stream, transparency); + break; + + case idatType: + CopyExact(stream, idat, intLength); + break; + + case iend: + if (!seenIhdr) + { + throw new("PNG missing IHDR."); + } + + ReadExact(stream, crc); + idat.Position = 0; + return Reconstruct(idat, width, height, colorType, palette, transparency); + + default: + // unknown chunk — skip + Skip(stream, intLength); + break; + } + + ReadExact(stream, crc); + } + } + + static PngImage Reconstruct(Stream idat, int width, int height, byte colorType, byte[]? palette, byte[]? trns) + { + var rawChannels = colorType switch + { + 0 => 1, + 2 => 3, + 3 => 1, + 4 => 2, + 6 => 4, + _ => throw new("Unreachable.") + }; + var stride = width * rawChannels; + + using var inflate = OpenInflate(idat); + + var rgba = new byte[width * height * 4]; + + if (colorType == 6) + { + // Unfilter directly in the output buffer. + var filterByte = new byte[1]; + var prevRow = new byte[stride]; + for (var y = 0; y < height; y++) + { + ReadExact(inflate, filterByte.AsSpan()); + var rowSpan = rgba.AsSpan(y * stride, stride); + ReadExact(inflate, rowSpan); + Unfilter(filterByte[0], rowSpan, prevRow, rawChannels); + rowSpan.CopyTo(prevRow); + } + + return new(width, height, rgba); + } + + // Non-RGBA: one row scratch buffer, expand into rgba row-by-row. + var curr = new byte[stride]; + var prev = new byte[stride]; + var filter = new byte[1]; + + for (var y = 0; y < height; y++) + { + ReadExact(inflate, filter.AsSpan()); + ReadExact(inflate, curr.AsSpan()); + Unfilter(filter[0], curr, prev, rawChannels); + ExpandRow(curr, rgba, y, width, colorType, palette, trns); + (prev, curr) = (curr, prev); + } + + return new(width, height, rgba); + } + + static Stream OpenInflate(Stream zlibData) + { +#if NET6_0_OR_GREATER + var inflate = new ZLibStream(zlibData, CompressionMode.Decompress, leaveOpen: true); +#else + zlibData.ReadByte(); + zlibData.ReadByte(); + var inflate = new DeflateStream(zlibData, CompressionMode.Decompress, leaveOpen: true); +#endif + return new BufferedStream(inflate, 8192); + } + + static void ExpandRow(byte[] src, byte[] rgba, int y, int width, byte colorType, byte[]? palette, byte[]? trns) + { + var dstRow = y * width * 4; + switch (colorType) + { + case 0: // gray + for (var x = 0; x < width; x++) + { + var g = src[x]; + var o = dstRow + x * 4; + rgba[o] = g; + rgba[o + 1] = g; + rgba[o + 2] = g; + rgba[o + 3] = 255; + } + + break; + case 2: // rgb + for (var x = 0; x < width; x++) + { + var o = dstRow + x * 4; + rgba[o] = src[x * 3]; + rgba[o + 1] = src[x * 3 + 1]; + rgba[o + 2] = src[x * 3 + 2]; + rgba[o + 3] = 255; + } + + break; + case 3: // palette + if (palette is null) + { + throw new("Paletted PNG missing PLTE chunk."); + } + + var paletteEntries = palette.Length / 3; + for (var x = 0; x < width; x++) + { + var index = src[x]; + if (index >= paletteEntries) + { + throw new("PNG palette index out of range."); + } + + var o = dstRow + x * 4; + rgba[o] = palette[index * 3]; + rgba[o + 1] = palette[index * 3 + 1]; + rgba[o + 2] = palette[index * 3 + 2]; + rgba[o + 3] = trns is not null && index < trns.Length ? trns[index] : (byte)255; + } + + break; + case 4: // gray + alpha + for (var x = 0; x < width; x++) + { + var g = src[x * 2]; + var o = dstRow + x * 4; + rgba[o] = g; + rgba[o + 1] = g; + rgba[o + 2] = g; + rgba[o + 3] = src[x * 2 + 1]; + } + + break; + } + } + + static void Unfilter(byte filter, Span curr, ReadOnlySpan prev, int bpp) + { + switch (filter) + { + case 0: + return; + case 1: // Sub + for (var i = bpp; i < curr.Length; i++) + { + curr[i] = (byte)(curr[i] + curr[i - bpp]); + } + + return; + case 2: // Up + for (var i = 0; i < curr.Length; i++) + { + curr[i] = (byte)(curr[i] + prev[i]); + } + + return; + case 3: // Average + for (var i = 0; i < curr.Length; i++) + { + var left = i >= bpp ? curr[i - bpp] : 0; + curr[i] = (byte)(curr[i] + (left + prev[i]) / 2); + } + + return; + case 4: // Paeth + for (var i = 0; i < curr.Length; i++) + { + var left = i >= bpp ? curr[i - bpp] : 0; + int up = prev[i]; + var upLeft = i >= bpp ? prev[i - bpp] : 0; + curr[i] = (byte)(curr[i] + Paeth(left, up, upLeft)); + } + + return; + default: + throw new($"Unknown PNG filter type: {filter}."); + } + } + + static int Paeth(int a, int b, int c) + { + var p = a + b - c; + var pa = Math.Abs(p - a); + var pb = Math.Abs(p - b); + var pc = Math.Abs(p - c); + if (pa <= pb && pa <= pc) + { + return a; + } + + return pb <= pc ? b : c; + } + + static void ReadExact(Stream stream, Span buffer) + { + while (buffer.Length > 0) + { + var read = stream.Read(buffer); + if (read == 0) + { + throw new("Unexpected end of PNG stream."); + } + + buffer = buffer[read..]; + } + } + + static void CopyExact(Stream source, Stream destination, int count) + { + Span buffer = stackalloc byte[4096]; + while (count > 0) + { + var toRead = Math.Min(buffer.Length, count); + var read = source.Read(buffer[..toRead]); + if (read == 0) + { + throw new("Unexpected end of PNG stream."); + } + + destination.Write(buffer[..read]); + count -= read; + } + } + + static void Skip(Stream stream, int count) + { + Span buffer = stackalloc byte[1024]; + while (count > 0) + { + var toRead = Math.Min(buffer.Length, count); + var read = stream.Read(buffer[..toRead]); + if (read == 0) + { + throw new("Unexpected end of PNG stream."); + } + + count -= read; + } + } + + static uint ReadUInt32BigEndian(ReadOnlySpan data) => + ((uint)data[0] << 24) | + ((uint)data[1] << 16) | + ((uint)data[2] << 8) | + data[3]; +} diff --git a/src/Verify/Compare/Png/PngImage.cs b/src/Verify/Compare/Png/PngImage.cs new file mode 100644 index 000000000..532cd6ede --- /dev/null +++ b/src/Verify/Compare/Png/PngImage.cs @@ -0,0 +1,6 @@ +readonly struct PngImage(int width, int height, byte[] rgba) +{ + public int Width { get; } = width; + public int Height { get; } = height; + public byte[] Rgba { get; } = rgba; +} diff --git a/src/Verify/Compare/Png/PngSsimComparer.cs b/src/Verify/Compare/Png/PngSsimComparer.cs new file mode 100644 index 000000000..271c1eca0 --- /dev/null +++ b/src/Verify/Compare/Png/PngSsimComparer.cs @@ -0,0 +1,25 @@ +static class PngSsimComparer +{ + public static double Threshold { get; set; } = 0.98; + + internal static Task Compare(Stream received, Stream verified, IReadOnlyDictionary context) + { + var receivedImage = PngDecoder.Decode(received); + var verifiedImage = PngDecoder.Decode(verified); + + if (receivedImage.Width != verifiedImage.Width || receivedImage.Height != verifiedImage.Height) + { + return Task.FromResult(CompareResult.NotEqual( + $"PNG dimensions differ. Received: {receivedImage.Width}x{receivedImage.Height}, Verified: {verifiedImage.Width}x{verifiedImage.Height}")); + } + + var score = Ssim.Compare(receivedImage, verifiedImage); + if (score >= Threshold) + { + return Task.FromResult(CompareResult.Equal); + } + + return Task.FromResult(CompareResult.NotEqual( + $"PNG SSIM {score:F4} below threshold {Threshold:F4}.")); + } +} diff --git a/src/Verify/Compare/Png/Ssim.cs b/src/Verify/Compare/Png/Ssim.cs new file mode 100644 index 000000000..01a07abfc --- /dev/null +++ b/src/Verify/Compare/Png/Ssim.cs @@ -0,0 +1,70 @@ +static class Ssim +{ + const int windowSize = 8; + const float k1 = 0.01f; + const float k2 = 0.03f; + const float l = 255; + const float c1 = k1 * l * k1 * l; + const float c2 = k2 * l * k2 * l; + + public static double Compare(PngImage a, PngImage b) + { + var width = a.Width; + var height = a.Height; + var rgbaA = a.Rgba; + var rgbaB = b.Rgba; + + if (width < windowSize || height < windowSize) + { + return WindowSsim(rgbaA, rgbaB, 0, 0, width, height, width); + } + + double sum = 0; + var count = 0; + for (var y = 0; y <= height - windowSize; y += windowSize) + { + for (var x = 0; x <= width - windowSize; x += windowSize) + { + sum += WindowSsim(rgbaA, rgbaB, x, y, windowSize, windowSize, width); + count++; + } + } + + return count == 0 ? 1 : sum / count; + } + + static float WindowSsim(byte[] rgbaA, byte[] rgbaB, int x0, int y0, int w, int h, int stride) + { + float sumA = 0; + float sumB = 0; + float sumAA = 0; + float sumBB = 0; + float sumAB = 0; + var n = w * h; + for (var y = 0; y < h; y++) + { + var row = ((y0 + y) * stride + x0) * 4; + for (var x = 0; x < w; x++) + { + var offset = row + x * 4; + var la = 0.2126f * rgbaA[offset] + 0.7152f * rgbaA[offset + 1] + 0.0722f * rgbaA[offset + 2]; + var lb = 0.2126f * rgbaB[offset] + 0.7152f * rgbaB[offset + 1] + 0.0722f * rgbaB[offset + 2]; + sumA += la; + sumB += lb; + sumAA += la * la; + sumBB += lb * lb; + sumAB += la * lb; + } + } + + var meanA = sumA / n; + var meanB = sumB / n; + var varA = sumAA / n - meanA * meanA; + var varB = sumBB / n - meanB * meanB; + var cov = sumAB / n - meanA * meanB; + + var numerator = (2 * meanA * meanB + c1) * (2 * cov + c2); + var denominator = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2); + return numerator / denominator; + } +} diff --git a/src/Verify/Compare/SharedSettings.cs b/src/Verify/Compare/SharedSettings.cs index a02e0a55f..6b39bca91 100644 --- a/src/Verify/Compare/SharedSettings.cs +++ b/src/Verify/Compare/SharedSettings.cs @@ -32,6 +32,13 @@ public static void RegisterStreamComparer(string extension, StreamCompare compar streamComparers[extension] = compare; } + public static void UseSsimForPng(double threshold = 0.98) + { + InnerVerifier.ThrowIfVerifyHasBeenRun(); + PngSsimComparer.Threshold = threshold; + streamComparers["png"] = PngSsimComparer.Compare; + } + public static void RegisterStringComparer(string extension, StringCompare compare) { InnerVerifier.ThrowIfVerifyHasBeenRun();