diff --git a/docs/comparer.md b/docs/comparer.md index 125432971..cf4409282 100644 --- a/docs/comparer.md +++ b/docs/comparer.md @@ -34,7 +34,7 @@ static Task CompareImages( return Task.FromResult(result); } ``` -snippet source | anchor +snippet source | anchor The returned `CompareResult.NotEqual` takes an optional message that will be rendered in the resulting text displayed to the user on test failure. @@ -103,7 +103,7 @@ VerifierSettings.RegisterStreamComparer( compare: CompareImages); await VerifyFile("TheImage.png"); ``` -snippet source | anchor +snippet source | anchor @@ -248,6 +248,45 @@ public Task InstanceSsimForPngFluent() => Dimension mismatches between the received and verified images are always reported as not equal, regardless of threshold. +### Standalone SSIM scoring + +The SSIM score can also be computed directly, outside of a verification. This is useful, for example, to report a similarity metric per rendered page in a custom report: + + + +```cs +public static double Score(Stream received, Stream verified) => + Ssim.Compare(received, verified); +``` +snippet source | anchor + + +`Ssim.Compare` accepts two PNG streams, two PNG byte arrays, or two `PngImage` instances decoded via `PngDecoder.Decode`. Scores range from `0` (completely different) to `1` (identical). Comparing images of different dimensions throws an `ArgumentException`. + +To treat a dimension mismatch as a distinct state instead of an exception, decode with `PngDecoder.Decode` and check dimensions before comparing: + + + +```cs +public static double? ScoreIfSameSize(Stream received, Stream verified) +{ + var receivedImage = PngDecoder.Decode(received); + var verifiedImage = PngDecoder.Decode(verified); + if (receivedImage.Width != verifiedImage.Width || + receivedImage.Height != verifiedImage.Height) + { + return null; + } + + return Ssim.Compare(receivedImage, verifiedImage); +} +``` +snippet source | anchor + + +`PngDecoder` supports the same PNG subset as the comparer (see below). + + ### Supported PNG variants The bundled decoder targets the common subset of PNGs produced by test scenarios: diff --git a/docs/mdsource/comparer.source.md b/docs/mdsource/comparer.source.md index 195734c3c..122f49f3a 100644 --- a/docs/mdsource/comparer.source.md +++ b/docs/mdsource/comparer.source.md @@ -59,6 +59,21 @@ snippet: InstanceSsimForPng Dimension mismatches between the received and verified images are always reported as not equal, regardless of threshold. +### Standalone SSIM scoring + +The SSIM score can also be computed directly, outside of a verification. This is useful, for example, to report a similarity metric per rendered page in a custom report: + +snippet: SsimCompare + +`Ssim.Compare` accepts two PNG streams, two PNG byte arrays, or two `PngImage` instances decoded via `PngDecoder.Decode`. Scores range from `0` (completely different) to `1` (identical). Comparing images of different dimensions throws an `ArgumentException`. + +To treat a dimension mismatch as a distinct state instead of an exception, decode with `PngDecoder.Decode` and check dimensions before comparing: + +snippet: SsimCompareDimensions + +`PngDecoder` supports the same PNG subset as the comparer (see below). + + ### Supported PNG variants The bundled decoder targets the common subset of PNGs produced by test scenarios: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ce2860dde..1d68a012f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CA1822;CS1591;CS0649;xUnit1026;xUnit1013;CS1573;VerifyTestsProjectDir;VerifySetParameters;PolyFillTargetsForNuget;xUnit1051;NU1608;NU1109 - 31.25.0 + 31.26.0 enable preview 1.0.0 diff --git a/src/Verify.Tests/Comparer/PngImageTests.cs b/src/Verify.Tests/Comparer/PngImageTests.cs new file mode 100644 index 000000000..140352dd5 --- /dev/null +++ b/src/Verify.Tests/Comparer/PngImageTests.cs @@ -0,0 +1,28 @@ +public class PngImageTests +{ + [Fact] + public void Valid() + { + var rgba = new byte[2 * 3 * 4]; + var image = new PngImage(2, 3, rgba); + Assert.Equal(2, image.Width); + Assert.Equal(3, image.Height); + Assert.Same(rgba, image.Rgba); + } + + [Fact] + public void Zero_Width_Throws() => + Assert.Throws(() => new PngImage(0, 3, [])); + + [Fact] + public void Zero_Height_Throws() => + Assert.Throws(() => new PngImage(3, 0, [])); + + [Fact] + public void Buffer_Length_Mismatch_Throws() + { + var exception = Assert.Throws(() => new PngImage(2, 2, new byte[15])); + Assert.Contains("Expected: 16", exception.Message); + Assert.Contains("Actual: 15", exception.Message); + } +} diff --git a/src/Verify.Tests/Comparer/SsimTests.cs b/src/Verify.Tests/Comparer/SsimTests.cs index 965845443..2c38ca6c8 100644 --- a/src/Verify.Tests/Comparer/SsimTests.cs +++ b/src/Verify.Tests/Comparer/SsimTests.cs @@ -48,6 +48,63 @@ public void Large_Difference_Low_Score() Assert.True(score < 0.5, $"Expected low score, got {score}"); } + [Fact] + public void Dimension_Mismatch_Throws() + { + var a = Solid(8, 8, 100); + var b = Solid(9, 8, 100); + var exception = Assert.Throws(() => Ssim.Compare(a, b)); + Assert.Contains("8x8", exception.Message); + Assert.Contains("9x8", exception.Message); + } + + [Fact] + public void Stream_Overload() + { + var png = PngTestHelper.EncodeRgba(16, 16, Random(16, 16, seed: 5).Rgba); + var score = Ssim.Compare(new MemoryStream(png), new MemoryStream(png)); + Assert.Equal(1.0, score, precision: 6); + } + + [Fact] + public void Stream_Overload_Dimension_Mismatch_Throws() + { + var a = PngTestHelper.EncodeRgba(10, 10, new byte[10 * 10 * 4]); + var b = PngTestHelper.EncodeRgba(11, 10, new byte[11 * 10 * 4]); + Assert.Throws(() => Ssim.Compare(new MemoryStream(a), new MemoryStream(b))); + } + + [Fact] + public void Bytes_Overload() + { + var png = PngTestHelper.EncodeRgba(16, 16, Random(16, 16, seed: 6).Rgba); + var score = Ssim.Compare(png, png); + Assert.Equal(1.0, score, precision: 6); + } + + [Fact] + public void Streaming_Matches_InMemory_BitForBit() + { + // The streaming Stream/byte overloads decode a band at a time; they must produce exactly the + // same score as decoding both images fully and comparing. Includes ragged sizes (not a + // multiple of the 8px window) and the sub-window tiny path. + (int w, int h)[] sizes = [(8, 8), (16, 16), (33, 17), (257, 101), (128, 128), (5, 40), (40, 5)]; + foreach (var (w, h) in sizes) + { + var aRgba = Random(w, h, seed: w * 7 + h).Rgba; + var bRgba = Random(w, h, seed: w * 7 + h + 1).Rgba; + + var inMemory = Ssim.Compare(new PngImage(w, h, aRgba), new PngImage(w, h, bRgba)); + var streamed = Ssim.Compare( + new MemoryStream(PngTestHelper.EncodeRgba(w, h, aRgba)), + new MemoryStream(PngTestHelper.EncodeRgba(w, h, bRgba))); + + Assert.Equal( + BitConverter.DoubleToInt64Bits(inMemory), + BitConverter.DoubleToInt64Bits(streamed)); + } + } + [Fact] public void Noise_Reduces_Score() { diff --git a/src/Verify.Tests/Snippets/ComparerSnippets.cs b/src/Verify.Tests/Snippets/ComparerSnippets.cs index 6ffc7250c..e47961b5e 100644 --- a/src/Verify.Tests/Snippets/ComparerSnippets.cs +++ b/src/Verify.Tests/Snippets/ComparerSnippets.cs @@ -36,6 +36,30 @@ public Task InstanceSsimForPngFluent() => #endregion + #region SsimCompare + + public static double Score(Stream received, Stream verified) => + Ssim.Compare(received, verified); + + #endregion + + #region SsimCompareDimensions + + public static double? ScoreIfSameSize(Stream received, Stream verified) + { + var receivedImage = PngDecoder.Decode(received); + var verifiedImage = PngDecoder.Decode(verified); + if (receivedImage.Width != verifiedImage.Width || + receivedImage.Height != verifiedImage.Height) + { + return null; + } + + return Ssim.Compare(receivedImage, verifiedImage); + } + + #endregion + public async Task StaticComparer() { #region StaticComparer diff --git a/src/Verify/Compare/Png/PngDecoder.cs b/src/Verify/Compare/Png/PngDecoder.cs index 780896814..4573caa39 100644 --- a/src/Verify/Compare/Png/PngDecoder.cs +++ b/src/Verify/Compare/Png/PngDecoder.cs @@ -1,383 +1,22 @@ -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'; +namespace VerifyTests; +public static class PngDecoder +{ 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) - { - if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length) - { - break; - } - - var length = ReadUInt32BigEndian(header); - var type = ((uint)header[4] << 24) | ((uint)header[5] << 16) | ((uint)header[6] << 8) | header[7]; - - if (length > int.MaxValue) - { - break; - } - - 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."); - } - - if (idat.Length == 0) - { - throw new("PNG missing IDAT."); - } - - 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); - } - - if (!seenIhdr) - { - throw new("PNG missing IHDR."); - } - - if (idat.Length == 0) - { - throw new("PNG missing IDAT."); - } - - idat.Position = 0; - return Reconstruct(idat, width, height, colorType, palette, transparency); - } - - 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); + using var reader = new PngReader(stream); + var width = reader.Width; + var height = reader.Height; + // The output escapes into the returned PngImage, so it is a plain allocation rather than + // pooled. The reader's transient row scratch is what comes from the pool. 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]; - + var rowBytes = width * 4; 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); + reader.ReadRgbaRow(rgba.AsSpan(y * rowBytes, rowBytes)); } 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 index 532cd6ede..9c1057e53 100644 --- a/src/Verify/Compare/Png/PngImage.cs +++ b/src/Verify/Compare/Png/PngImage.cs @@ -1,6 +1,31 @@ -readonly struct PngImage(int width, int height, byte[] rgba) +namespace VerifyTests; + +public readonly struct PngImage { - public int Width { get; } = width; - public int Height { get; } = height; - public byte[] Rgba { get; } = rgba; + public PngImage(int width, int height, byte[] rgba) + { + if (width < 1) + { + throw new ArgumentOutOfRangeException(nameof(width), width, "Must be at least 1."); + } + + if (height < 1) + { + throw new ArgumentOutOfRangeException(nameof(height), height, "Must be at least 1."); + } + + Ensure.NotNull(rgba); + if (rgba.Length != (long)width * height * 4) + { + throw new ArgumentException($"Length must be width * height * 4. Expected: {(long)width * height * 4}. Actual: {rgba.Length}.", nameof(rgba)); + } + + Width = width; + Height = height; + Rgba = rgba; + } + + public int Width { get; } + public int Height { get; } + public byte[] Rgba { get; } } diff --git a/src/Verify/Compare/Png/PngReader.cs b/src/Verify/Compare/Png/PngReader.cs new file mode 100644 index 000000000..84fcda170 --- /dev/null +++ b/src/Verify/Compare/Png/PngReader.cs @@ -0,0 +1,451 @@ +namespace VerifyTests; + +// Streaming PNG row reader shared by PngDecoder (full decode) and the streaming SSIM compare. +// Parses IHDR/PLTE/tRNS, then inflates IDAT directly (no full compressed buffer) and yields +// unfiltered, RGBA-expanded rows on demand. Row scratch is rented from ArrayPool so repeated +// decodes do not churn the heap. The decoded output buffer, when one is needed, is owned by the +// caller (PngDecoder allocates it because it escapes into a PngImage). +sealed class PngReader : IDisposable +{ + 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 int Width { get; } + public int Height { get; } + + readonly byte colorType; + readonly byte[]? palette; + readonly byte[]? transparency; + readonly int rawChannels; + readonly int stride; + + readonly IdatStream idatStream; + readonly Stream inflate; + byte[] prev; + byte[] curr; + bool disposed; + + public PngReader(Stream stream) + { + Span sig = stackalloc byte[8]; + ReadExact(stream, sig); + if (!sig.SequenceEqual(Signature)) + { + throw new("Not a PNG (bad signature)."); + } + + var seenIhdr = false; + var foundIdat = false; + var firstIdatLength = 0; + Span header = stackalloc byte[8]; + Span crc = stackalloc byte[4]; + Span ihdrData = stackalloc byte[13]; + + while (!foundIdat) + { + if (stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false) < header.Length) + { + break; + } + + var length = ReadUInt32BigEndian(header); + var type = ((uint)header[4] << 24) | ((uint)header[5] << 16) | ((uint)header[6] << 8) | header[7]; + + if (length > int.MaxValue) + { + break; + } + + 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; + ReadExact(stream, crc); + break; + + case plte: + if (intLength % 3 != 0) + { + throw new("Invalid PLTE length."); + } + + palette = new byte[intLength]; + ReadExact(stream, palette); + ReadExact(stream, crc); + break; + + case trns: + transparency = new byte[intLength]; + ReadExact(stream, transparency); + ReadExact(stream, crc); + break; + + case idatType: + // Hand off to IdatStream mid-chunk: it serves this payload then any subsequent + // IDAT chunks. Deliberately do not read the payload or CRC here. + firstIdatLength = intLength; + foundIdat = true; + break; + + case iend: + throw new(seenIhdr ? "PNG missing IDAT." : "PNG missing IHDR."); + + default: + Skip(stream, intLength); + ReadExact(stream, crc); + break; + } + } + + if (!seenIhdr) + { + throw new("PNG missing IHDR."); + } + + if (!foundIdat) + { + throw new("PNG missing IDAT."); + } + + rawChannels = colorType switch + { + 0 => 1, + 2 => 3, + 3 => 1, + 4 => 2, + 6 => 4, + _ => throw new("Unreachable.") + }; + stride = Width * rawChannels; + + idatStream = new(stream, firstIdatLength); + inflate = OpenInflate(idatStream); + + prev = ArrayPool.Shared.Rent(stride); + curr = ArrayPool.Shared.Rent(stride); + Array.Clear(prev, 0, stride); + } + + // Reads the next scanline, unfilters it, and expands it into dest as RGBA. dest must be Width * 4. + public void ReadRgbaRow(Span dest) + { + var filter = inflate.ReadByte(); + if (filter < 0) + { + throw new("Unexpected end of PNG stream."); + } + + var currSpan = curr.AsSpan(0, stride); + ReadExact(inflate, currSpan); + Unfilter((byte)filter, currSpan, prev.AsSpan(0, stride), rawChannels); + ExpandRow(currSpan, dest, Width, colorType, palette, transparency); + (prev, curr) = (curr, prev); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + inflate.Dispose(); + idatStream.Dispose(); + ArrayPool.Shared.Return(prev); + ArrayPool.Shared.Return(curr); + } + + 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(ReadOnlySpan src, Span dest, int width, byte colorType, byte[]? palette, byte[]? trns) + { + switch (colorType) + { + case 0: // gray + for (var x = 0; x < width; x++) + { + var g = src[x]; + var o = x * 4; + dest[o] = g; + dest[o + 1] = g; + dest[o + 2] = g; + dest[o + 3] = 255; + } + + break; + case 2: // rgb + for (var x = 0; x < width; x++) + { + var o = x * 4; + dest[o] = src[x * 3]; + dest[o + 1] = src[x * 3 + 1]; + dest[o + 2] = src[x * 3 + 2]; + dest[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 = x * 4; + dest[o] = palette[index * 3]; + dest[o + 1] = palette[index * 3 + 1]; + dest[o + 2] = palette[index * 3 + 2]; + dest[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 = x * 4; + dest[o] = g; + dest[o + 1] = g; + dest[o + 2] = g; + dest[o + 3] = src[x * 2 + 1]; + } + + break; + case 6: // rgba + src[..(width * 4)].CopyTo(dest); + 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 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]; + + // Presents the concatenated payloads of consecutive IDAT chunks as one continuous stream, + // transparently skipping chunk CRCs and headers, so the zlib/deflate layer can inflate + // directly from the source without the whole compressed image being buffered first. + sealed class IdatStream(Stream source, int firstChunkLength) : Stream + { + int chunkRemaining = firstChunkLength; + bool ended; + + public override int Read(byte[] buffer, int offset, int count) => + ReadCore(buffer.AsSpan(offset, count)); + +#if NET6_0_OR_GREATER + public override int Read(Span buffer) => + ReadCore(buffer); +#endif + + int ReadCore(Span buffer) + { + var total = 0; + while (buffer.Length > 0 && !ended) + { + if (chunkRemaining == 0 && !AdvanceToNextIdat()) + { + ended = true; + break; + } + + var toRead = Math.Min(buffer.Length, chunkRemaining); + var read = source.Read(buffer[..toRead]); + if (read == 0) + { + ended = true; + break; + } + + chunkRemaining -= read; + buffer = buffer[read..]; + total += read; + } + + return total; + } + + bool AdvanceToNextIdat() + { + Span temp = stackalloc byte[8]; + // Skip the CRC of the chunk just consumed. + if (source.ReadAtLeast(temp[..4], 4, throwOnEndOfStream: false) < 4) + { + return false; + } + + // Read the next chunk header; continue only while it is another IDAT. + if (source.ReadAtLeast(temp, temp.Length, throwOnEndOfStream: false) < temp.Length) + { + return false; + } + + var length = ((uint)temp[0] << 24) | ((uint)temp[1] << 16) | ((uint)temp[2] << 8) | temp[3]; + var type = ((uint)temp[4] << 24) | ((uint)temp[5] << 16) | ((uint)temp[6] << 8) | temp[7]; + if (type != idatType || length > int.MaxValue) + { + return false; + } + + chunkRemaining = (int)length; + return true; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/src/Verify/Compare/Png/PngSsimComparer.cs b/src/Verify/Compare/Png/PngSsimComparer.cs index 1630c67a0..a2d38ce61 100644 --- a/src/Verify/Compare/Png/PngSsimComparer.cs +++ b/src/Verify/Compare/Png/PngSsimComparer.cs @@ -10,17 +10,14 @@ internal static StreamCompare BuildCompare(double threshold) => internal static Task Compare(Stream received, Stream verified, double threshold) { - var receivedImage = PngDecoder.Decode(received); - var verifiedImage = PngDecoder.Decode(verified); - - if (receivedImage.Width != verifiedImage.Width || - receivedImage.Height != verifiedImage.Height) + // Streams both images a band at a time; dimensions come from the headers, so a size + // mismatch is reported without decoding pixels and without materializing full images. + if (!Ssim.TryCompare(received, verified, out var score, out var receivedWidth, out var receivedHeight, out var verifiedWidth, out var verifiedHeight)) { return Task.FromResult(CompareResult.NotEqual( - $"PNG dimensions differ. Received: {receivedImage.Width}x{receivedImage.Height}, Verified: {verifiedImage.Width}x{verifiedImage.Height}")); + $"PNG dimensions differ. Received: {receivedWidth}x{receivedHeight}, Verified: {verifiedWidth}x{verifiedHeight}")); } - var score = Ssim.Compare(receivedImage, verifiedImage); if (score >= threshold) { return Task.FromResult(CompareResult.Equal); diff --git a/src/Verify/Compare/Png/Ssim.cs b/src/Verify/Compare/Png/Ssim.cs index 01a07abfc..6b3f31058 100644 --- a/src/Verify/Compare/Png/Ssim.cs +++ b/src/Verify/Compare/Png/Ssim.cs @@ -1,4 +1,6 @@ -static class Ssim +namespace VerifyTests; + +public static class Ssim { const int windowSize = 8; const float k1 = 0.01f; @@ -7,8 +9,108 @@ static class Ssim const float c1 = k1 * l * k1 * l; const float c2 = k2 * l * k2 * l; + // Streams both PNGs a band of rows at a time rather than decoding two full images, so peak + // memory is a handful of rows per image instead of the whole RGBA buffer. Bit-identical to + // decoding both and calling Compare(PngImage, PngImage). + public static double Compare(Stream a, Stream b) + { + if (TryCompare(a, b, out var score, out var aWidth, out var aHeight, out var bWidth, out var bHeight)) + { + return score; + } + + throw new ArgumentException($"Image dimensions must match. a: {aWidth}x{aHeight}, b: {bWidth}x{bHeight}."); + } + + public static double Compare(byte[] a, byte[] b) => + Compare(new MemoryStream(a), new MemoryStream(b)); + + // Returns false (without computing a score) when the two images differ in size, so callers that + // treat a dimension mismatch as a distinct outcome do not pay for a throw. Dimensions come from + // the PNG headers, so a mismatch is detected before any pixel data is decoded. + internal static bool TryCompare(Stream a, Stream b, out double score, out int aWidth, out int aHeight, out int bWidth, out int bHeight) + { + using var readerA = new PngReader(a); + using var readerB = new PngReader(b); + aWidth = readerA.Width; + aHeight = readerA.Height; + bWidth = readerB.Width; + bHeight = readerB.Height; + + if (aWidth != bWidth || aHeight != bHeight) + { + score = 0; + return false; + } + + score = ComputeStreaming(readerA, readerB, aWidth, aHeight); + return true; + } + + static double ComputeStreaming(PngReader a, PngReader b, int width, int height) + { + var pool = ArrayPool.Shared; + var rowBytes = width * 4; + + if (width < windowSize || height < windowSize) + { + var wholeA = pool.Rent(rowBytes * height); + var wholeB = pool.Rent(rowBytes * height); + try + { + for (var y = 0; y < height; y++) + { + a.ReadRgbaRow(wholeA.AsSpan(y * rowBytes, rowBytes)); + b.ReadRgbaRow(wholeB.AsSpan(y * rowBytes, rowBytes)); + } + + return WindowSsim(wholeA, wholeB, 0, 0, width, height, width); + } + finally + { + pool.Return(wholeA); + pool.Return(wholeB); + } + } + + var bandA = pool.Rent(rowBytes * windowSize); + var bandB = pool.Rent(rowBytes * windowSize); + try + { + double sum = 0; + var count = 0; + for (var y = 0; y <= height - windowSize; y += windowSize) + { + for (var row = 0; row < windowSize; row++) + { + a.ReadRgbaRow(bandA.AsSpan(row * rowBytes, rowBytes)); + b.ReadRgbaRow(bandB.AsSpan(row * rowBytes, rowBytes)); + } + + for (var x = 0; x <= width - windowSize; x += windowSize) + { + sum += WindowSsim(bandA, bandB, x, 0, windowSize, windowSize, width); + count++; + } + } + + return count == 0 ? 1 : sum / count; + } + finally + { + pool.Return(bandA); + pool.Return(bandB); + } + } + public static double Compare(PngImage a, PngImage b) { + if (a.Width != b.Width || + a.Height != b.Height) + { + throw new ArgumentException($"Image dimensions must match. a: {a.Width}x{a.Height}, b: {b.Width}x{b.Height}."); + } + var width = a.Width; var height = a.Height; var rgbaA = a.Rgba;