From 39a540c5d976599d2cd76ac8af04b196f01372a5 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 16 Apr 2026 09:53:48 +1000 Subject: [PATCH 1/6] PngSsimComparer --- .../Compare/Png/PngDecoderTests.cs | 163 +++++++++ .../Compare/Png/PngSsimComparerTests.cs | 112 ++++++ src/Verify.Tests/Compare/Png/PngTestHelper.cs | 174 ++++++++++ src/Verify.Tests/Compare/Png/SsimTests.cs | 110 ++++++ src/Verify/Compare/Png/PngDecoder.cs | 323 ++++++++++++++++++ src/Verify/Compare/Png/PngSsimComparer.cs | 36 ++ src/Verify/Compare/Png/Ssim.cs | 97 ++++++ src/Verify/Compare/SharedSettings.cs | 7 + 8 files changed, 1022 insertions(+) create mode 100644 src/Verify.Tests/Compare/Png/PngDecoderTests.cs create mode 100644 src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs create mode 100644 src/Verify.Tests/Compare/Png/PngTestHelper.cs create mode 100644 src/Verify.Tests/Compare/Png/SsimTests.cs create mode 100644 src/Verify/Compare/Png/PngDecoder.cs create mode 100644 src/Verify/Compare/Png/PngSsimComparer.cs create mode 100644 src/Verify/Compare/Png/Ssim.cs diff --git a/src/Verify.Tests/Compare/Png/PngDecoderTests.cs b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs new file mode 100644 index 0000000000..d51055c720 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs @@ -0,0 +1,163 @@ +using VerifyTests; + +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 0000000000..5d5fbf85c3 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using VerifyTests; + +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_Is_NotEqual_With_Decode_Failure() + { + var valid = PngTestHelper.EncodeRgba(4, 4, new byte[4 * 4 * 4]); + var corrupt = new byte[8]; // just signature length garbage + var result = await PngSsimComparer.Compare(new MemoryStream(corrupt), new MemoryStream(valid), emptyContext); + Assert.False(result.IsEqual); + Assert.Contains("Failed to decode PNG", result.Message); + } + + 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 0000000000..9f50fa9712 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/PngTestHelper.cs @@ -0,0 +1,174 @@ +using System.IO.Compression; +using VerifyTests; + +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 0000000000..c6a86a34d4 --- /dev/null +++ b/src/Verify.Tests/Compare/Png/SsimTests.cs @@ -0,0 +1,110 @@ +using VerifyTests; + +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/Compare/Png/PngDecoder.cs b/src/Verify/Compare/Png/PngDecoder.cs new file mode 100644 index 0000000000..f609e4de0b --- /dev/null +++ b/src/Verify/Compare/Png/PngDecoder.cs @@ -0,0 +1,323 @@ +namespace VerifyTests; + +static class PngDecoder +{ + static readonly byte[] 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'; + + static uint ChunkType(byte a, byte b, byte c, byte d) => + ((uint)a << 24) | ((uint)b << 16) | ((uint)c << 8) | d; + + public static PngImage Decode(Stream stream) + { + var sig = new byte[8]; + ReadExact(stream, sig, 0, 8); + for (var i = 0; i < 8; i++) + { + if (sig[i] != signature[i]) + { + 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; + + while (true) + { + var header = new byte[8]; + ReadExact(stream, header, 0, 8); + var length = ReadUInt32BigEndian(header, 0); + var type = ChunkType(header[4], header[5], header[6], header[7]); + + if (length > int.MaxValue) + { + throw new("PNG chunk too large."); + } + + var data = new byte[length]; + if (length > 0) + { + ReadExact(stream, data, 0, (int)length); + } + + // skip CRC + ReadExact(stream, new byte[4], 0, 4); + + switch (type) + { + case ihdr: + if (length != 13) + { + throw new("Invalid IHDR length."); + } + + width = (int)ReadUInt32BigEndian(data, 0); + height = (int)ReadUInt32BigEndian(data, 4); + var bitDepth = data[8]; + colorType = data[9]; + var compression = data[10]; + var filter = data[11]; + var interlace = data[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 (length % 3 != 0) + { + throw new("Invalid PLTE length."); + } + + palette = data; + break; + case trns: + transparency = data; + break; + case idatType: + idat.Write(data, 0, data.Length); + break; + case iend: + if (!seenIhdr) + { + throw new("PNG missing IHDR."); + } + + idat.Position = 0; + var raw = Inflate(idat); + var pixels = Reconstruct(raw, width, height, colorType, palette, transparency); + return new(width, height, pixels); + } + } + } + + static byte[] Inflate(MemoryStream zlibData) + { +#if NET6_0_OR_GREATER + using var inflate = new System.IO.Compression.ZLibStream(zlibData, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true); + using var output = new MemoryStream(); + inflate.CopyTo(output); + return output.ToArray(); +#else + // Skip 2-byte zlib header, trailing Adler-32 ignored by DeflateStream EOF. + zlibData.ReadByte(); + zlibData.ReadByte(); + using var inflate = new System.IO.Compression.DeflateStream(zlibData, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true); + using var output = new MemoryStream(); + inflate.CopyTo(output); + return output.ToArray(); +#endif + } + + static byte[] Reconstruct(byte[] raw, int width, int height, byte colorType, byte[]? palette, byte[]? trns) + { + // channels in the raw stream (pre-expansion for palette) + var rawChannels = colorType switch + { + 0 => 1, // gray + 2 => 3, // rgb + 3 => 1, // palette index + 4 => 2, // gray + alpha + 6 => 4, // rgba + _ => throw new("Unreachable.") + }; + var stride = width * rawChannels; + var expected = (stride + 1) * height; + if (raw.Length < expected) + { + throw new($"PNG data too short: expected {expected}, got {raw.Length}."); + } + + var unfiltered = new byte[stride * height]; + var prevRow = new byte[stride]; + var currRow = new byte[stride]; + var rawPos = 0; + for (var y = 0; y < height; y++) + { + var filter = raw[rawPos++]; + Buffer.BlockCopy(raw, rawPos, currRow, 0, stride); + rawPos += stride; + Unfilter(filter, currRow, prevRow, rawChannels); + Buffer.BlockCopy(currRow, 0, unfiltered, y * stride, stride); + (prevRow, currRow) = (currRow, prevRow); + } + + // Expand to RGBA8 + var rgba = new byte[width * height * 4]; + switch (colorType) + { + case 0: // gray + for (var i = 0; i < width * height; i++) + { + var g = unfiltered[i]; + rgba[i * 4] = g; + rgba[i * 4 + 1] = g; + rgba[i * 4 + 2] = g; + rgba[i * 4 + 3] = 255; + } + + break; + case 2: // rgb + for (var i = 0; i < width * height; i++) + { + rgba[i * 4] = unfiltered[i * 3]; + rgba[i * 4 + 1] = unfiltered[i * 3 + 1]; + rgba[i * 4 + 2] = unfiltered[i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + + break; + case 3: // palette + if (palette is null) + { + throw new("Paletted PNG missing PLTE chunk."); + } + + var paletteEntries = palette.Length / 3; + for (var i = 0; i < width * height; i++) + { + var index = unfiltered[i]; + if (index >= paletteEntries) + { + throw new("PNG palette index out of range."); + } + + rgba[i * 4] = palette[index * 3]; + rgba[i * 4 + 1] = palette[index * 3 + 1]; + rgba[i * 4 + 2] = palette[index * 3 + 2]; + rgba[i * 4 + 3] = trns is not null && index < trns.Length ? trns[index] : (byte)255; + } + + break; + case 4: // gray + alpha + for (var i = 0; i < width * height; i++) + { + var g = unfiltered[i * 2]; + rgba[i * 4] = g; + rgba[i * 4 + 1] = g; + rgba[i * 4 + 2] = g; + rgba[i * 4 + 3] = unfiltered[i * 2 + 1]; + } + + break; + case 6: // rgba + Buffer.BlockCopy(unfiltered, 0, rgba, 0, unfiltered.Length); + break; + } + + return rgba; + } + + static void Unfilter(byte filter, byte[] curr, byte[] 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 ? (int)curr[i - bpp] : 0; + int up = prev[i]; + var upLeft = i >= bpp ? (int)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, byte[] buffer, int offset, int count) + { + while (count > 0) + { + var read = stream.Read(buffer, offset, count); + if (read == 0) + { + throw new("Unexpected end of PNG stream."); + } + + offset += read; + count -= read; + } + } + + static uint ReadUInt32BigEndian(byte[] data, int offset) => + ((uint)data[offset] << 24) | + ((uint)data[offset + 1] << 16) | + ((uint)data[offset + 2] << 8) | + data[offset + 3]; +} + +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 0000000000..68d8254c70 --- /dev/null +++ b/src/Verify/Compare/Png/PngSsimComparer.cs @@ -0,0 +1,36 @@ +namespace VerifyTests; + +static class PngSsimComparer +{ + public static double Threshold { get; set; } = 0.98; + + internal static Task Compare(Stream received, Stream verified, IReadOnlyDictionary context) + { + PngImage receivedImage; + PngImage verifiedImage; + try + { + receivedImage = PngDecoder.Decode(received); + verifiedImage = PngDecoder.Decode(verified); + } + catch (Exception exception) + { + return Task.FromResult(CompareResult.NotEqual($"Failed to decode PNG: {exception.Message}")); + } + + 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 0000000000..08ffb50aca --- /dev/null +++ b/src/Verify/Compare/Png/Ssim.cs @@ -0,0 +1,97 @@ +namespace VerifyTests; + +static class Ssim +{ + const int windowSize = 8; + const double k1 = 0.01; + const double k2 = 0.03; + const double l = 255; + const double c1 = k1 * l * k1 * l; + const double c2 = k2 * l * k2 * l; + + public static double Compare(PngImage a, PngImage b) + { + var width = a.Width; + var height = a.Height; + var lumA = ToLuminance(a); + var lumB = ToLuminance(b); + + if (width < windowSize || height < windowSize) + { + // Single window over whole image. + return WindowSsim(lumA, lumB, 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(lumA, lumB, x, y, windowSize, windowSize, width); + count++; + } + } + + return count == 0 ? 1 : sum / count; + } + + static double WindowSsim(double[] a, double[] b, int x0, int y0, int w, int h, int stride) + { + double sumA = 0; + double sumB = 0; + var n = w * h; + for (var y = 0; y < h; y++) + { + var row = (y0 + y) * stride + x0; + for (var x = 0; x < w; x++) + { + sumA += a[row + x]; + sumB += b[row + x]; + } + } + + var meanA = sumA / n; + var meanB = sumB / n; + + double varA = 0; + double varB = 0; + double cov = 0; + for (var y = 0; y < h; y++) + { + var row = (y0 + y) * stride + x0; + for (var x = 0; x < w; x++) + { + var da = a[row + x] - meanA; + var db = b[row + x] - meanB; + varA += da * da; + varB += db * db; + cov += da * db; + } + } + + varA /= n; + varB /= n; + cov /= n; + + var numerator = (2 * meanA * meanB + c1) * (2 * cov + c2); + var denominator = (meanA * meanA + meanB * meanB + c1) * (varA + varB + c2); + return numerator / denominator; + } + + static double[] ToLuminance(PngImage image) + { + var pixels = image.Width * image.Height; + var result = new double[pixels]; + var rgba = image.Rgba; + for (var i = 0; i < pixels; i++) + { + var r = rgba[i * 4]; + var g = rgba[i * 4 + 1]; + var b = rgba[i * 4 + 2]; + result[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + return result; + } +} diff --git a/src/Verify/Compare/SharedSettings.cs b/src/Verify/Compare/SharedSettings.cs index a02e0a55f4..6b39bca91e 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(); From 16de7e42f36d41a3699ce3a5273fa26ef4952595 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 16 Apr 2026 10:20:09 +1000 Subject: [PATCH 2/6] , --- .gitignore | 1 + src/Benchmarks/Benchmarks.csproj | 2 +- src/Benchmarks/PngSsimBenchmarks.cs | 183 ++++++++++++++++++++++++++++ src/Verify/Compare/Png/Ssim.cs | 79 ++++-------- 4 files changed, 212 insertions(+), 53 deletions(-) create mode 100644 src/Benchmarks/PngSsimBenchmarks.cs diff --git a/.gitignore b/.gitignore index 39c89ee5fb..a57193e23f 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/src/Benchmarks/Benchmarks.csproj b/src/Benchmarks/Benchmarks.csproj index 864980bf4c..d06607acdb 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 0000000000..d4c2eb79a0 --- /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/Verify/Compare/Png/Ssim.cs b/src/Verify/Compare/Png/Ssim.cs index 08ffb50aca..de0d34e265 100644 --- a/src/Verify/Compare/Png/Ssim.cs +++ b/src/Verify/Compare/Png/Ssim.cs @@ -3,23 +3,22 @@ namespace VerifyTests; static class Ssim { const int windowSize = 8; - const double k1 = 0.01; - const double k2 = 0.03; - const double l = 255; - const double c1 = k1 * l * k1 * l; - const double c2 = k2 * l * k2 * l; + 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 lumA = ToLuminance(a); - var lumB = ToLuminance(b); + var rgbaA = a.Rgba; + var rgbaB = b.Rgba; if (width < windowSize || height < windowSize) { - // Single window over whole image. - return WindowSsim(lumA, lumB, 0, 0, width, height, width); + return WindowSsim(rgbaA, rgbaB, 0, 0, width, height, width); } double sum = 0; @@ -28,7 +27,7 @@ public static double Compare(PngImage a, PngImage b) { for (var x = 0; x <= width - windowSize; x += windowSize) { - sum += WindowSsim(lumA, lumB, x, y, windowSize, windowSize, width); + sum += WindowSsim(rgbaA, rgbaB, x, y, windowSize, windowSize, width); count++; } } @@ -36,62 +35,38 @@ public static double Compare(PngImage a, PngImage b) return count == 0 ? 1 : sum / count; } - static double WindowSsim(double[] a, double[] b, int x0, int y0, int w, int h, int stride) + static float WindowSsim(byte[] rgbaA, byte[] rgbaB, int x0, int y0, int w, int h, int stride) { - double sumA = 0; - double sumB = 0; + 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; + var row = ((y0 + y) * stride + x0) * 4; for (var x = 0; x < w; x++) { - sumA += a[row + x]; - sumB += b[row + 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; - - double varA = 0; - double varB = 0; - double cov = 0; - for (var y = 0; y < h; y++) - { - var row = (y0 + y) * stride + x0; - for (var x = 0; x < w; x++) - { - var da = a[row + x] - meanA; - var db = b[row + x] - meanB; - varA += da * da; - varB += db * db; - cov += da * db; - } - } - - varA /= n; - varB /= n; - cov /= 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; } - - static double[] ToLuminance(PngImage image) - { - var pixels = image.Width * image.Height; - var result = new double[pixels]; - var rgba = image.Rgba; - for (var i = 0; i < pixels; i++) - { - var r = rgba[i * 4]; - var g = rgba[i * 4 + 1]; - var b = rgba[i * 4 + 2]; - result[i] = 0.2126 * r + 0.7152 * g + 0.0722 * b; - } - - return result; - } } From f33c546878e8949192b30d66f0fc348c53a95ea8 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 16 Apr 2026 10:32:33 +1000 Subject: [PATCH 3/6] , --- docs/comparer.md | 48 ++++ docs/explicit-targets.md | 2 +- docs/jsonappender.md | 2 +- docs/mdsource/comparer.source.md | 26 ++ src/ModuleInitDocs/UseSsimForPng.cs | 13 + src/ModuleInitDocs/UseSsimForPngThreshold.cs | 13 + src/Verify/Compare/Png/PngDecoder.cs | 277 +++++++++++-------- 7 files changed, 264 insertions(+), 117 deletions(-) create mode 100644 src/ModuleInitDocs/UseSsimForPng.cs create mode 100644 src/ModuleInitDocs/UseSsimForPngThreshold.cs diff --git a/docs/comparer.md b/docs/comparer.md index 4677c62603..161272a43d 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) produce a decode-failure message rather than a comparison score. 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/explicit-targets.md b/docs/explicit-targets.md index 8ea6401b9a..afa568fb78 100644 --- a/docs/explicit-targets.md +++ b/docs/explicit-targets.md @@ -144,5 +144,5 @@ public Task WithTargets() => ```txt Raw target value ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/jsonappender.md b/docs/jsonappender.md index d98b0c9672..4204318990 100644 --- a/docs/jsonappender.md +++ b/docs/jsonappender.md @@ -106,7 +106,7 @@ Then the appended content will be added to the `.verified.txt` file: theData: theValue } ``` -snippet source | anchor +snippet source | anchor See [Converters](/docs/converter.md) for more information on `*.00.verified.txt` files. diff --git a/docs/mdsource/comparer.source.md b/docs/mdsource/comparer.source.md index d55ce44e2f..5e123ee2d3 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) produce a decode-failure message rather than a comparison score. 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/ModuleInitDocs/UseSsimForPng.cs b/src/ModuleInitDocs/UseSsimForPng.cs new file mode 100644 index 0000000000..966c6e6c09 --- /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 0000000000..9f262262a9 --- /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/Compare/Png/PngDecoder.cs b/src/Verify/Compare/Png/PngDecoder.cs index f609e4de0b..c8368d998e 100644 --- a/src/Verify/Compare/Png/PngDecoder.cs +++ b/src/Verify/Compare/Png/PngDecoder.cs @@ -2,7 +2,7 @@ namespace VerifyTests; static class PngDecoder { - static readonly byte[] signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + 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'; @@ -10,19 +10,13 @@ static class PngDecoder const uint iend = ('I' << 24) | ('E' << 16) | ('N' << 8) | 'D'; const uint trns = ('t' << 24) | ('R' << 16) | ('N' << 8) | 'S'; - static uint ChunkType(byte a, byte b, byte c, byte d) => - ((uint)a << 24) | ((uint)b << 16) | ((uint)c << 8) | d; - public static PngImage Decode(Stream stream) { - var sig = new byte[8]; - ReadExact(stream, sig, 0, 8); - for (var i = 0; i < 8; i++) + Span sig = stackalloc byte[8]; + ReadExact(stream, sig); + if (!sig.SequenceEqual(Signature)) { - if (sig[i] != signature[i]) - { - throw new("Not a PNG (bad signature)."); - } + throw new("Not a PNG (bad signature)."); } var width = 0; @@ -33,42 +27,39 @@ public static PngImage Decode(Stream stream) 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) { - var header = new byte[8]; - ReadExact(stream, header, 0, 8); - var length = ReadUInt32BigEndian(header, 0); - var type = ChunkType(header[4], header[5], header[6], header[7]); + 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 data = new byte[length]; - if (length > 0) - { - ReadExact(stream, data, 0, (int)length); - } - - // skip CRC - ReadExact(stream, new byte[4], 0, 4); + var intLength = (int)length; switch (type) { case ihdr: - if (length != 13) + if (intLength != 13) { throw new("Invalid IHDR length."); } - width = (int)ReadUInt32BigEndian(data, 0); - height = (int)ReadUInt32BigEndian(data, 4); - var bitDepth = data[8]; - colorType = data[9]; - var compression = data[10]; - var filter = data[11]; - var interlace = data[12]; + 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."); @@ -91,107 +82,134 @@ public static PngImage Decode(Stream stream) seenIhdr = true; break; + case plte: - if (length % 3 != 0) + if (intLength % 3 != 0) { throw new("Invalid PLTE length."); } - palette = data; + palette = new byte[intLength]; + ReadExact(stream, palette); break; + case trns: - transparency = data; + transparency = new byte[intLength]; + ReadExact(stream, transparency); break; + case idatType: - idat.Write(data, 0, data.Length); + CopyExact(stream, idat, intLength); break; + case iend: if (!seenIhdr) { throw new("PNG missing IHDR."); } + ReadExact(stream, crc); idat.Position = 0; - var raw = Inflate(idat); - var pixels = Reconstruct(raw, width, height, colorType, palette, transparency); - return new(width, height, pixels); + return Reconstruct(idat, width, height, colorType, palette, transparency); + + default: + // unknown chunk — skip + Skip(stream, intLength); + break; } - } - } - static byte[] Inflate(MemoryStream zlibData) - { -#if NET6_0_OR_GREATER - using var inflate = new System.IO.Compression.ZLibStream(zlibData, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true); - using var output = new MemoryStream(); - inflate.CopyTo(output); - return output.ToArray(); -#else - // Skip 2-byte zlib header, trailing Adler-32 ignored by DeflateStream EOF. - zlibData.ReadByte(); - zlibData.ReadByte(); - using var inflate = new System.IO.Compression.DeflateStream(zlibData, System.IO.Compression.CompressionMode.Decompress, leaveOpen: true); - using var output = new MemoryStream(); - inflate.CopyTo(output); - return output.ToArray(); -#endif + ReadExact(stream, crc); + } } - static byte[] Reconstruct(byte[] raw, int width, int height, byte colorType, byte[]? palette, byte[]? trns) + static PngImage Reconstruct(Stream idat, int width, int height, byte colorType, byte[]? palette, byte[]? trns) { - // channels in the raw stream (pre-expansion for palette) var rawChannels = colorType switch { - 0 => 1, // gray - 2 => 3, // rgb - 3 => 1, // palette index - 4 => 2, // gray + alpha - 6 => 4, // rgba + 0 => 1, + 2 => 3, + 3 => 1, + 4 => 2, + 6 => 4, _ => throw new("Unreachable.") }; var stride = width * rawChannels; - var expected = (stride + 1) * height; - if (raw.Length < expected) + + using var inflate = OpenInflate(idat); + + var rgba = new byte[width * height * 4]; + + if (colorType == 6) { - throw new($"PNG data too short: expected {expected}, got {raw.Length}."); + // 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); } - var unfiltered = new byte[stride * height]; - var prevRow = new byte[stride]; - var currRow = new byte[stride]; - var rawPos = 0; + // 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++) { - var filter = raw[rawPos++]; - Buffer.BlockCopy(raw, rawPos, currRow, 0, stride); - rawPos += stride; - Unfilter(filter, currRow, prevRow, rawChannels); - Buffer.BlockCopy(currRow, 0, unfiltered, y * stride, stride); - (prevRow, currRow) = (currRow, prevRow); + 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); } - // Expand to RGBA8 - var rgba = new byte[width * height * 4]; + 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 i = 0; i < width * height; i++) + for (var x = 0; x < width; x++) { - var g = unfiltered[i]; - rgba[i * 4] = g; - rgba[i * 4 + 1] = g; - rgba[i * 4 + 2] = g; - rgba[i * 4 + 3] = 255; + 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 i = 0; i < width * height; i++) + for (var x = 0; x < width; x++) { - rgba[i * 4] = unfiltered[i * 3]; - rgba[i * 4 + 1] = unfiltered[i * 3 + 1]; - rgba[i * 4 + 2] = unfiltered[i * 3 + 2]; - rgba[i * 4 + 3] = 255; + 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; @@ -202,41 +220,38 @@ static byte[] Reconstruct(byte[] raw, int width, int height, byte colorType, byt } var paletteEntries = palette.Length / 3; - for (var i = 0; i < width * height; i++) + for (var x = 0; x < width; x++) { - var index = unfiltered[i]; + var index = src[x]; if (index >= paletteEntries) { throw new("PNG palette index out of range."); } - rgba[i * 4] = palette[index * 3]; - rgba[i * 4 + 1] = palette[index * 3 + 1]; - rgba[i * 4 + 2] = palette[index * 3 + 2]; - rgba[i * 4 + 3] = trns is not null && index < trns.Length ? trns[index] : (byte)255; + 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 i = 0; i < width * height; i++) + for (var x = 0; x < width; x++) { - var g = unfiltered[i * 2]; - rgba[i * 4] = g; - rgba[i * 4 + 1] = g; - rgba[i * 4 + 2] = g; - rgba[i * 4 + 3] = unfiltered[i * 2 + 1]; + 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; - case 6: // rgba - Buffer.BlockCopy(unfiltered, 0, rgba, 0, unfiltered.Length); break; } - - return rgba; } - static void Unfilter(byte filter, byte[] curr, byte[] prev, int bpp) + static void Unfilter(byte filter, Span curr, ReadOnlySpan prev, int bpp) { switch (filter) { @@ -293,26 +308,58 @@ static int Paeth(int a, int b, int c) return pb <= pc ? b : c; } - static void ReadExact(Stream stream, byte[] buffer, int offset, int count) + 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 read = stream.Read(buffer, offset, count); + var toRead = Math.Min(buffer.Length, count); + var read = stream.Read(buffer[..toRead]); if (read == 0) { throw new("Unexpected end of PNG stream."); } - offset += read; count -= read; } } - static uint ReadUInt32BigEndian(byte[] data, int offset) => - ((uint)data[offset] << 24) | - ((uint)data[offset + 1] << 16) | - ((uint)data[offset + 2] << 8) | - data[offset + 3]; + static uint ReadUInt32BigEndian(ReadOnlySpan data) => + ((uint)data[0] << 24) | + ((uint)data[1] << 16) | + ((uint)data[2] << 8) | + data[3]; } readonly struct PngImage(int width, int height, byte[] rgba) From 1a8eed8af3e2c04af8ae82c95cde363a99eae79e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 16 Apr 2026 00:32:58 +0000 Subject: [PATCH 4/6] Docs changes --- docs/explicit-targets.md | 2 +- docs/jsonappender.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/explicit-targets.md b/docs/explicit-targets.md index afa568fb78..8ea6401b9a 100644 --- a/docs/explicit-targets.md +++ b/docs/explicit-targets.md @@ -144,5 +144,5 @@ public Task WithTargets() => ```txt Raw target value ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/jsonappender.md b/docs/jsonappender.md index 4204318990..d98b0c9672 100644 --- a/docs/jsonappender.md +++ b/docs/jsonappender.md @@ -106,7 +106,7 @@ Then the appended content will be added to the `.verified.txt` file: theData: theValue } ``` -snippet source | anchor +snippet source | anchor See [Converters](/docs/converter.md) for more information on `*.00.verified.txt` files. From 3010f3dfabcc86eb9653ca90ee1bab6b0eb94a95 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 16 Apr 2026 10:37:43 +1000 Subject: [PATCH 5/6] . --- docs/comparer.md | 2 +- docs/mdsource/comparer.source.md | 2 +- src/Verify.Tests/Compare/Png/PngDecoderTests.cs | 2 -- .../Compare/Png/PngSsimComparerTests.cs | 3 --- src/Verify.Tests/Compare/Png/PngTestHelper.cs | 3 --- src/Verify.Tests/Compare/Png/SsimTests.cs | 2 -- src/Verify.Tests/GlobalUsings.cs | 1 + src/Verify/Compare/Png/PngDecoder.cs | 13 ++----------- src/Verify/Compare/Png/PngImage.cs | 6 ++++++ src/Verify/Compare/Png/PngSsimComparer.cs | 15 ++------------- src/Verify/Compare/Png/Ssim.cs | 2 -- 11 files changed, 13 insertions(+), 38 deletions(-) create mode 100644 src/Verify/Compare/Png/PngImage.cs diff --git a/docs/comparer.md b/docs/comparer.md index 161272a43d..fce91288e9 100644 --- a/docs/comparer.md +++ b/docs/comparer.md @@ -192,7 +192,7 @@ The bundled decoder targets the common subset of PNGs produced by test scenarios * Color types: grayscale, RGB, RGBA, grayscale+alpha, and paletted (with optional `tRNS` transparency) * Non-interlaced images -Unsupported variants (16-bit, Adam7 interlacing) produce a decode-failure message rather than a comparison score. 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). +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 diff --git a/docs/mdsource/comparer.source.md b/docs/mdsource/comparer.source.md index 5e123ee2d3..cfc9c82a96 100644 --- a/docs/mdsource/comparer.source.md +++ b/docs/mdsource/comparer.source.md @@ -54,7 +54,7 @@ The bundled decoder targets the common subset of PNGs produced by test scenarios * Color types: grayscale, RGB, RGBA, grayscale+alpha, and paletted (with optional `tRNS` transparency) * Non-interlaced images -Unsupported variants (16-bit, Adam7 interlacing) produce a decode-failure message rather than a comparison score. 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). +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 diff --git a/src/Verify.Tests/Compare/Png/PngDecoderTests.cs b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs index d51055c720..56fff801e5 100644 --- a/src/Verify.Tests/Compare/Png/PngDecoderTests.cs +++ b/src/Verify.Tests/Compare/Png/PngDecoderTests.cs @@ -1,5 +1,3 @@ -using VerifyTests; - public class PngDecoderTests { [Fact] diff --git a/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs index 5d5fbf85c3..fd1ebbf99a 100644 --- a/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs +++ b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using VerifyTests; - public class PngSsimComparerTests { static readonly Dictionary emptyContext = new(); diff --git a/src/Verify.Tests/Compare/Png/PngTestHelper.cs b/src/Verify.Tests/Compare/Png/PngTestHelper.cs index 9f50fa9712..61252f8bd2 100644 --- a/src/Verify.Tests/Compare/Png/PngTestHelper.cs +++ b/src/Verify.Tests/Compare/Png/PngTestHelper.cs @@ -1,6 +1,3 @@ -using System.IO.Compression; -using VerifyTests; - static class PngTestHelper { static readonly byte[] signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; diff --git a/src/Verify.Tests/Compare/Png/SsimTests.cs b/src/Verify.Tests/Compare/Png/SsimTests.cs index c6a86a34d4..9658454433 100644 --- a/src/Verify.Tests/Compare/Png/SsimTests.cs +++ b/src/Verify.Tests/Compare/Png/SsimTests.cs @@ -1,5 +1,3 @@ -using VerifyTests; - public class SsimTests { [Fact] diff --git a/src/Verify.Tests/GlobalUsings.cs b/src/Verify.Tests/GlobalUsings.cs index 43922434ba..02db356c25 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 index c8368d998e..1385ece9be 100644 --- a/src/Verify/Compare/Png/PngDecoder.cs +++ b/src/Verify/Compare/Png/PngDecoder.cs @@ -1,5 +1,3 @@ -namespace VerifyTests; - static class PngDecoder { static ReadOnlySpan Signature => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; @@ -282,9 +280,9 @@ static void Unfilter(byte filter, Span curr, ReadOnlySpan prev, int case 4: // Paeth for (var i = 0; i < curr.Length; i++) { - var left = i >= bpp ? (int)curr[i - bpp] : 0; + var left = i >= bpp ? curr[i - bpp] : 0; int up = prev[i]; - var upLeft = i >= bpp ? (int)prev[i - bpp] : 0; + var upLeft = i >= bpp ? prev[i - bpp] : 0; curr[i] = (byte)(curr[i] + Paeth(left, up, upLeft)); } @@ -361,10 +359,3 @@ static uint ReadUInt32BigEndian(ReadOnlySpan data) => ((uint)data[2] << 8) | data[3]; } - -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/PngImage.cs b/src/Verify/Compare/Png/PngImage.cs new file mode 100644 index 0000000000..532cd6edef --- /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 index 68d8254c70..271c1eca0c 100644 --- a/src/Verify/Compare/Png/PngSsimComparer.cs +++ b/src/Verify/Compare/Png/PngSsimComparer.cs @@ -1,22 +1,11 @@ -namespace VerifyTests; - static class PngSsimComparer { public static double Threshold { get; set; } = 0.98; internal static Task Compare(Stream received, Stream verified, IReadOnlyDictionary context) { - PngImage receivedImage; - PngImage verifiedImage; - try - { - receivedImage = PngDecoder.Decode(received); - verifiedImage = PngDecoder.Decode(verified); - } - catch (Exception exception) - { - return Task.FromResult(CompareResult.NotEqual($"Failed to decode PNG: {exception.Message}")); - } + var receivedImage = PngDecoder.Decode(received); + var verifiedImage = PngDecoder.Decode(verified); if (receivedImage.Width != verifiedImage.Width || receivedImage.Height != verifiedImage.Height) { diff --git a/src/Verify/Compare/Png/Ssim.cs b/src/Verify/Compare/Png/Ssim.cs index de0d34e265..01a07abfc1 100644 --- a/src/Verify/Compare/Png/Ssim.cs +++ b/src/Verify/Compare/Png/Ssim.cs @@ -1,5 +1,3 @@ -namespace VerifyTests; - static class Ssim { const int windowSize = 8; From 0c268636e7c55c03dab4eadf58e049dfbd4618ab Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Thu, 16 Apr 2026 10:59:39 +1000 Subject: [PATCH 6/6] Update PngSsimComparerTests.cs --- src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs index fd1ebbf99a..5648304c93 100644 --- a/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs +++ b/src/Verify.Tests/Compare/Png/PngSsimComparerTests.cs @@ -86,13 +86,12 @@ public async Task Threshold_Tuning_Tightens_Comparison() } [Fact] - public async Task Corrupt_Png_Is_NotEqual_With_Decode_Failure() + public async Task Corrupt_Png_Throws() { var valid = PngTestHelper.EncodeRgba(4, 4, new byte[4 * 4 * 4]); - var corrupt = new byte[8]; // just signature length garbage - var result = await PngSsimComparer.Compare(new MemoryStream(corrupt), new MemoryStream(valid), emptyContext); - Assert.False(result.IsEqual); - Assert.Contains("Failed to decode PNG", result.Message); + 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)