From 8f2716b567542d7fa1638592f5d964d4ff3aa815 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 26 May 2026 14:01:08 +1000 Subject: [PATCH 1/9] GIF: background handling & quantizer overflow fix (#3133) * GIF: background handling & quantizer overflow fix * Fix tests --- src/ImageSharp/Formats/Gif/GifDecoderCore.cs | 6 +- src/ImageSharp/Formats/Gif/GifEncoderCore.cs | 74 ++++-------- src/ImageSharp/Formats/Gif/GifMetadata.cs | 8 +- .../HexadecatreeQuantizer{TPixel}.cs | 25 ++-- tests/ImageSharp.Tests/Issues/Issue_396.cs | 111 ++++++++++++++++++ tests/ImageSharp.Tests/TestImages.cs | 3 + ...tColor_WithHexadecatreeQuantizer_rgb32.bmp | 2 +- ...de_8BitColor_WithOctreeQuantizer_rgb32.bmp | 3 - ...Bike_HexadecatreeQuantizer_ErrorDither.png | 4 +- .../Input/Png/issues/Issue_396_dragon.png | 3 + .../Input/Png/issues/Issue_396_hand_1.png | 3 + .../Input/Png/issues/Issue_396_hand_2.png | 3 + 12 files changed, 171 insertions(+), 74 deletions(-) create mode 100644 tests/ImageSharp.Tests/Issues/Issue_396.cs delete mode 100644 tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp create mode 100644 tests/Images/Input/Png/issues/Issue_396_dragon.png create mode 100644 tests/Images/Input/Png/issues/Issue_396_hand_1.png create mode 100644 tests/Images/Input/Png/issues/Issue_396_hand_2.png diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs index c087b66811..dc6ce1846b 100644 --- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs @@ -990,7 +990,11 @@ internal sealed class GifDecoderCore : ImageDecoderCore byte index = this.logicalScreenDescriptor.BackgroundColorIndex; this.backgroundColorIndex = index; - this.gifMetadata.BackgroundColorIndex = index; + ReadOnlyMemory? globalColorTable = this.gifMetadata.GlobalColorTable; + if (globalColorTable.HasValue && index < globalColorTable.Value.Length) + { + this.gifMetadata.BackgroundColor = globalColorTable.Value.Span[index]; + } } private unsafe struct ScratchBuffer diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs index 49640fae08..dcba3953d5 100644 --- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs @@ -45,14 +45,6 @@ internal sealed class GifEncoderCore /// private readonly IPixelSamplingStrategy pixelSamplingStrategy; - /// - /// The default background color of the canvas when animating. - /// This color may be used to fill the unused space on the canvas around the frames, - /// as well as the transparent pixels of the first frame. - /// The background color is also used when a frame disposal mode is . - /// - private readonly Color? backgroundColor; - /// /// The number of times any animation is repeated. /// @@ -76,7 +68,6 @@ internal sealed class GifEncoderCore this.skipMetadata = encoder.SkipMetadata; this.colorTableMode = encoder.ColorTableMode; this.pixelSamplingStrategy = encoder.PixelSamplingStrategy; - this.backgroundColor = encoder.BackgroundColor; this.repeatCount = encoder.RepeatCount; this.transparentColorMode = encoder.TransparentColorMode; } @@ -113,7 +104,17 @@ internal sealed class GifEncoderCore TransparentColorMode mode = this.transparentColorMode; // Create a new quantizer options instance augmenting the transparent color mode to match the encoder. - QuantizerOptions options = (this.encoder.Quantizer?.Options ?? new QuantizerOptions()).DeepClone(o => o.TransparentColorMode = mode); + QuantizerOptions options = (this.encoder.Quantizer?.Options ?? new QuantizerOptions()).DeepClone(o => + { + o.TransparentColorMode = mode; + + // Animated GIF delta frames can use one padded color-table index as transparency. + // Express that through MaxColors so custom quantizers receive the same budget. + if (image.Frames.Count > 1 && o.MaxColors == QuantizerConstants.MaxColors) + { + o.MaxColors = QuantizerConstants.MaxColors - 1; + } + }); if (globalQuantizer is null) { @@ -145,6 +146,11 @@ internal sealed class GifEncoderCore IPixelSamplingStrategy strategy = this.pixelSamplingStrategy; ImageFrame encodingFrame = image.Frames.RootFrame; + + // This color is encoded as the logical-screen background index and is also + // used when de-duplicating frames that restore to the GIF background. + Color backgroundColor = this.encoder.BackgroundColor ?? gifMetadata.BackgroundColor ?? Color.Transparent; + byte backgroundIndex = 0; if (useGlobalTableForFirstFrame) { using IQuantizer firstFrameQuantizer = globalQuantizer.CreatePixelSpecificQuantizer(this.configuration, options); @@ -158,6 +164,8 @@ internal sealed class GifEncoderCore } quantized = firstFrameQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds); + TPixel backgroundPixel = backgroundColor.ToPixel(); + backgroundIndex = firstFrameQuantizer.GetQuantizedColor(backgroundPixel, out _); } else { @@ -184,8 +192,6 @@ internal sealed class GifEncoderCore frameMetadata.TransparencyIndex = ClampIndex(transparencyIndex); } - byte backgroundIndex = GetBackgroundIndex(quantized, gifMetadata, this.backgroundColor); - // Get the number of bits. int bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); this.WriteLogicalScreenDescriptor(image.Metadata, image.Width, image.Height, backgroundIndex, useGlobalTable, bitDepth, stream); @@ -222,6 +228,7 @@ internal sealed class GifEncoderCore image, globalQuantizer, globalFrameQuantizer, + backgroundColor, transparencyIndex, frameMetadata.DisposalMode, cancellationToken); @@ -253,6 +260,7 @@ internal sealed class GifEncoderCore Image image, IQuantizer globalQuantizer, PaletteQuantizer globalFrameQuantizer, + Color backgroundColor, int globalTransparencyIndex, FrameDisposalMode previousDisposalMode, CancellationToken cancellationToken) @@ -284,6 +292,7 @@ internal sealed class GifEncoderCore globalFrameQuantizer, useLocal, gifMetadata, + backgroundColor, previousDisposalMode); previousFrame = currentFrame; @@ -327,6 +336,7 @@ internal sealed class GifEncoderCore PaletteQuantizer globalFrameQuantizer, bool useLocal, GifFrameMetadata metadata, + Color backgroundColor, FrameDisposalMode previousDisposalMode) where TPixel : unmanaged, IPixel { @@ -347,7 +357,7 @@ internal sealed class GifEncoderCore previous.Metadata.GetGifMetadata().DisposalMode; Color background = !useTransparency && disposalMode == FrameDisposalMode.RestoreToBackground - ? this.backgroundColor ?? Color.Transparent + ? backgroundColor : Color.Transparent; // Deduplicate and quantize the frame capturing only required parts. @@ -534,44 +544,6 @@ internal sealed class GifEncoderCore return index; } - /// - /// Returns the index of the background color in the palette. - /// - /// The current quantized frame. - /// The gif metadata - /// The background color to match. - /// The pixel format. - /// The index of the background color. - private static byte GetBackgroundIndex(IndexedImageFrame? quantized, GifMetadata metadata, Color? background) - where TPixel : unmanaged, IPixel - { - int match = -1; - if (quantized != null) - { - if (background.HasValue) - { - TPixel backgroundPixel = background.Value.ToPixel(); - ReadOnlySpan palette = quantized.Palette.Span; - for (int i = 0; i < palette.Length; i++) - { - if (!backgroundPixel.Equals(palette[i])) - { - continue; - } - - match = i; - break; - } - } - else if (metadata.BackgroundColorIndex < quantized.Palette.Length) - { - match = metadata.BackgroundColorIndex; - } - } - - return ClampIndex(match); - } - /// /// Writes the file header signature and version to the stream. /// diff --git a/src/ImageSharp/Formats/Gif/GifMetadata.cs b/src/ImageSharp/Formats/Gif/GifMetadata.cs index 978209b23b..50a29775d0 100644 --- a/src/ImageSharp/Formats/Gif/GifMetadata.cs +++ b/src/ImageSharp/Formats/Gif/GifMetadata.cs @@ -26,7 +26,7 @@ public class GifMetadata : IFormatMetadata { this.RepeatCount = other.RepeatCount; this.ColorTableMode = other.ColorTableMode; - this.BackgroundColorIndex = other.BackgroundColorIndex; + this.BackgroundColor = other.BackgroundColor; if (other.GlobalColorTable?.Length > 0) { @@ -59,10 +59,9 @@ public class GifMetadata : IFormatMetadata public ReadOnlyMemory? GlobalColorTable { get; set; } /// - /// Gets or sets the index at the for the background color. - /// The background color is the color used for those pixels on the screen that are not covered by an image. + /// Gets or sets the background color used for pixels on the screen that are not covered by an image. /// - public byte BackgroundColorIndex { get; set; } + public Color? BackgroundColor { get; set; } /// /// Gets or sets the collection of comments about the graphics, credits, descriptions or any @@ -101,6 +100,7 @@ public class GifMetadata : IFormatMetadata { AnimateRootFrame = true, ColorTableMode = this.ColorTableMode, + BackgroundColor = this.BackgroundColor ?? Color.Transparent, PixelTypeInfo = this.GetPixelTypeInfo(), RepeatCount = this.RepeatCount, }; diff --git a/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs index b5d39d73ec..71c1b84571 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs @@ -392,11 +392,11 @@ public struct HexadecatreeQuantizer : IQuantizer internal struct Node { public bool Leaf; - public int PixelCount; - public int Red; - public int Green; - public int Blue; - public int Alpha; + public long PixelCount; + public long Red; + public long Green; + public long Blue; + public long Alpha; public short PaletteIndex; public short NextReducibleIndex; private InlineArray16 children; @@ -510,12 +510,13 @@ public struct HexadecatreeQuantizer : IQuantizer return; } - // Now merge the (presumably reduced) children. - int pixelCount = 0; - int sumRed = 0; - int sumGreen = 0; - int sumBlue = 0; - int sumAlpha = 0; + // Allocation fallback can accumulate samples on an interior node. Seed the merge + // with this node's own sums so reduction preserves those samples with its children. + long pixelCount = this.PixelCount; + long sumRed = this.Red; + long sumGreen = this.Green; + long sumBlue = this.Blue; + long sumAlpha = this.Alpha; Span children = this.Children; for (int i = 0; i < children.Length; i++) @@ -524,7 +525,7 @@ public struct HexadecatreeQuantizer : IQuantizer if (childIndex != -1) { ref Node child = ref tree.Nodes[childIndex]; - int pixels = child.PixelCount; + long pixels = child.PixelCount; sumRed += child.Red; sumGreen += child.Green; sumBlue += child.Blue; diff --git a/tests/ImageSharp.Tests/Issues/Issue_396.cs b/tests/ImageSharp.Tests/Issues/Issue_396.cs new file mode 100644 index 0000000000..0d76f0c808 --- /dev/null +++ b/tests/ImageSharp.Tests/Issues/Issue_396.cs @@ -0,0 +1,111 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace SixLabors.ImageSharp.Tests.Issues; + +// https://github.com/SixLabors/ImageSharp.Drawing/discussions/396 +public class Issue_396 +{ + [Theory] + [WithFile(TestImages.Png.Issue396Dragon, PixelTypes.Rgba32)] + public void GeneratesGifForInspection(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + Image[] handImages = + [ + Image.Load(TestFile.Create(TestImages.Png.Issue396Hand1).Bytes), + Image.Load(TestFile.Create(TestImages.Png.Issue396Hand2).Bytes) + ]; + + try + { + using Image inputImage = provider.GetImage(); + using Image outputImage = new(handImages[0].Width, handImages[0].Height); + + const float scaleFactor = 0.6F; + const float squashStepFactor = 0.22F; + + for (int i = 0; i < handImages.Length; i++) + { + using Image frameImage = new(handImages[i].Width, handImages[i].Height); + float squashFactorY = 1 - (i * squashStepFactor); + + frameImage.ProcessPixelRows(accessor => + { + Rgba32 gray = Color.Gray.ToPixel(); + + for (int y = 0; y < accessor.Height; y++) + { + accessor.GetRowSpan(y).Fill(gray); + } + }); + + Rectangle targetRect = new( + (int)((frameImage.Width - (frameImage.Width * scaleFactor)) / 2), + (int)(frameImage.Height * (((1 - scaleFactor) / 2) + (scaleFactor * (1 - squashFactorY)))), + (int)(frameImage.Width * scaleFactor), + (int)(frameImage.Height * scaleFactor * squashFactorY)); + + using Image dragonFrame = inputImage.CloneAs(); + dragonFrame.Mutate(context => context.Resize(targetRect.Size)); + + frameImage.Mutate(context => + { + context.DrawImage(dragonFrame, targetRect.Location, 1F); + context.DrawImage(handImages[i], Point.Empty, 1F); + }); + + ImageFrame outputFrame = outputImage.Frames.AddFrame(frameImage.Frames.RootFrame); + GifFrameMetadata gifFrameMetadata = outputFrame.Metadata.GetGifMetadata(); + gifFrameMetadata.FrameDelay = 40; + gifFrameMetadata.DisposalMode = FrameDisposalMode.RestoreToBackground; + } + + for (int i = handImages.Length - 1; i > 0; i--) + { + outputImage.Frames.AddFrame(outputImage.Frames[i]); + } + + outputImage.Frames.RemoveFrame(0); + GifMetadata gifMetadata = outputImage.Metadata.GetGifMetadata(); + gifMetadata.RepeatCount = 0; + + Assert.Equal((handImages.Length * 2) - 1, outputImage.Frames.Count); + foreach (ImageFrame frame in outputImage.Frames) + { + Assert.Equal(FrameDisposalMode.RestoreToBackground, frame.Metadata.GetGifMetadata().DisposalMode); + } + + outputImage.DebugSaveMultiFrame(provider, "Issue396-source-frames", appendPixelTypeToFileName: false); + provider.Utility.SaveTestOutputFile( + outputImage, + "gif", + new GifEncoder(), + "Issue396-encoded", + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); + + // Save and decode the GIF so the encoder's optimized frame diffs can be inspected separately. + using MemoryStream gifStream = new(); + outputImage.SaveAsGif(gifStream); + gifStream.Position = 0; + + using Image decodedImage = Image.Load(gifStream); + decodedImage.DebugSaveMultiFrame(provider, "Issue396-decoded-frames", appendPixelTypeToFileName: false); + + Assert.Equal(outputImage.Frames.Count, decodedImage.Frames.Count); + } + finally + { + foreach (Image handImage in handImages) + { + handImage.Dispose(); + } + } + } +} diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 2624d1cdad..d74db0eeb7 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -79,6 +79,9 @@ public static class TestImages public const string AnimatedFrameCount = "Png/animated/issue-animated-frame-count.png"; public const string Issue2666 = "Png/issues/Issue_2666.png"; public const string Issue2882 = "Png/issues/Issue_2882.png"; + public const string Issue396Dragon = "Png/issues/Issue_396_dragon.png"; + public const string Issue396Hand1 = "Png/issues/Issue_396_hand_1.png"; + public const string Issue396Hand2 = "Png/issues/Issue_396_hand_2.png"; // Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html public const string Filter0 = "Png/filter0.png"; diff --git a/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithHexadecatreeQuantizer_rgb32.bmp b/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithHexadecatreeQuantizer_rgb32.bmp index f4ae3b9b68..7f79c6f3a5 100644 --- a/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithHexadecatreeQuantizer_rgb32.bmp +++ b/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithHexadecatreeQuantizer_rgb32.bmp @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a98b1ec707af066f77fad7d1a64b858d460986beb6d27682717dd5e221310fd4 +oid sha256:fff91e78a87c2b2db661609dd70af2d7a74e6a0de18924ab2b6272e12c60977d size 9270 diff --git a/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp b/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp deleted file mode 100644 index f4ae3b9b68..0000000000 --- a/tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a98b1ec707af066f77fad7d1a64b858d460986beb6d27682717dd5e221310fd4 -size 9270 diff --git a/tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_Bike_HexadecatreeQuantizer_ErrorDither.png b/tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_Bike_HexadecatreeQuantizer_ErrorDither.png index e9782bc076..b17bce9e3e 100644 --- a/tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_Bike_HexadecatreeQuantizer_ErrorDither.png +++ b/tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_Bike_HexadecatreeQuantizer_ErrorDither.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b380eda5646fe97ee217ef711103001e54ee023fb8d95f7f3bbad19d886130da -size 83702 +oid sha256:822f4ae1d8676e2231a0c938296931be42a74c0a51b4ec63fdf6dcd17d64dc7e +size 83924 diff --git a/tests/Images/Input/Png/issues/Issue_396_dragon.png b/tests/Images/Input/Png/issues/Issue_396_dragon.png new file mode 100644 index 0000000000..4e8b2d8917 --- /dev/null +++ b/tests/Images/Input/Png/issues/Issue_396_dragon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:203aca58e0aa19cbb7ab0820ef55005af830bf4c9bfd1dd4d9967c7e1bc1d51a +size 494160 diff --git a/tests/Images/Input/Png/issues/Issue_396_hand_1.png b/tests/Images/Input/Png/issues/Issue_396_hand_1.png new file mode 100644 index 0000000000..1624bce043 --- /dev/null +++ b/tests/Images/Input/Png/issues/Issue_396_hand_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ab791784ac46f9e50a63bd255f39ff8af236a922aab3510a29b473bd93bffd5 +size 16295 diff --git a/tests/Images/Input/Png/issues/Issue_396_hand_2.png b/tests/Images/Input/Png/issues/Issue_396_hand_2.png new file mode 100644 index 0000000000..d12fa097ba --- /dev/null +++ b/tests/Images/Input/Png/issues/Issue_396_hand_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dbf54bcef6730b68b18770105a15448f984aaca6910693d4e65106a526af58d8 +size 17756 From 8e6cab957b80a56d29ecfbdc3b8f628d09c16d81 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Tue, 26 May 2026 14:06:11 +1000 Subject: [PATCH 2/9] Validate PBM max pixel value --- src/ImageSharp/Formats/Pbm/PbmDecoderCore.cs | 5 +++++ .../ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/ImageSharp/Formats/Pbm/PbmDecoderCore.cs b/src/ImageSharp/Formats/Pbm/PbmDecoderCore.cs index 989eadda7a..5451d3d461 100644 --- a/src/ImageSharp/Formats/Pbm/PbmDecoderCore.cs +++ b/src/ImageSharp/Formats/Pbm/PbmDecoderCore.cs @@ -155,6 +155,11 @@ internal sealed class PbmDecoderCore : ImageDecoderCore ThrowPrematureEof(); } + if (this.maxPixelValue <= 0 || this.maxPixelValue >= 65536) + { + throw new InvalidImageContentException("Invalid max pixel value."); + } + if (this.maxPixelValue > 255) { this.componentType = PbmComponentType.Short; diff --git a/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs index 476538ccdf..5c52c9785d 100644 --- a/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs @@ -123,6 +123,19 @@ public class PbmDecoderTests appendPixelTypeToFileName: false); } + [Theory] + [InlineData("P2")] + [InlineData("P3")] + [InlineData("P5")] + [InlineData("P6")] + public void Decode_MaxPixelValueZero_ThrowsInvalidImageContentException(string magic) + { + byte[] bytes = Encoding.ASCII.GetBytes($"{magic} 1 1 0\n0"); + using MemoryStream stream = new(bytes); + + Assert.Throws(() => Image.Load(stream)); + } + [Fact] public void PlainText_PrematureEof() { From 3e843a06a286cb65a126b9777bd6facf04695d21 Mon Sep 17 00:00:00 2001 From: Erik White <26148654+Erik-White@users.noreply.github.com> Date: Tue, 26 May 2026 15:35:52 +0200 Subject: [PATCH 3/9] Implement CgBI support --- .../Compression/Zlib/ZlibInflateStream.cs | 31 +++++++- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 73 ++++++++++++++++++- .../Formats/Png/PngDecoderTests.cs | 26 +++---- tests/ImageSharp.Tests/TestImages.cs | 14 +++- ...reshold-0_PerPixelManhattanThreshold-0.png | 3 + ...reshold-0_PerPixelManhattanThreshold-0.png | 3 + ...reshold-0_PerPixelManhattanThreshold-0.png | 3 + ...reshold-0_PerPixelManhattanThreshold-0.png | 3 + ...reshold-0_PerPixelManhattanThreshold-0.png | 3 + tests/Images/Input/Png/cgbi/clocks.png | 3 + tests/Images/Input/Png/cgbi/colors.png | 3 + tests/Images/Input/Png/cgbi/flecks.png | 3 + tests/Images/Input/Png/cgbi/screen.png | 3 + 13 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png create mode 100644 tests/Images/Input/Png/cgbi/clocks.png create mode 100644 tests/Images/Input/Png/cgbi/colors.png create mode 100644 tests/Images/Input/Png/cgbi/flecks.png create mode 100644 tests/Images/Input/Png/cgbi/screen.png diff --git a/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs b/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs index 1d743bf3a5..513171b179 100644 --- a/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs +++ b/src/ImageSharp/Compression/Zlib/ZlibInflateStream.cs @@ -52,12 +52,19 @@ internal sealed class ZlibInflateStream : Stream /// private readonly Func getData; + /// + /// When true, the inflated payload is treated as a raw DEFLATE stream with no zlib + /// CMF/FLG header (and no Adler-32 trailer). This is required to decode IDATs in + /// Apple's proprietary CgBI PNG variant. + /// + private readonly bool noHeader; + /// /// Initializes a new instance of the class. /// /// The inner raw stream. public ZlibInflateStream(BufferedReadStream innerStream) - : this(innerStream, GetDataNoOp) + : this(innerStream, GetDataNoOp, noHeader: false) { } @@ -67,9 +74,23 @@ internal sealed class ZlibInflateStream : Stream /// The inner raw stream. /// A delegate to get more data from the inner stream. public ZlibInflateStream(BufferedReadStream innerStream, Func getData) + : this(innerStream, getData, noHeader: false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The inner raw stream. + /// A delegate to get more data from the inner stream. + /// + /// When , the payload is treated as raw DEFLATE with no zlib header. + /// + public ZlibInflateStream(BufferedReadStream innerStream, Func getData, bool noHeader) { this.innerStream = innerStream; this.getData = getData; + this.noHeader = noHeader; } /// @@ -210,6 +231,14 @@ internal sealed class ZlibInflateStream : Stream [MemberNotNullWhen(true, nameof(CompressedStream))] private bool InitializeInflateStream(bool isCriticalChunk) { + // Apple CgBI IDATs omit the zlib CMF/FLG header and the Adler-32 trailer, + // wrapping a raw DEFLATE payload directly. Skip the header parsing in that mode. + if (this.noHeader) + { + this.CompressedStream = new DeflateStream(this, CompressionMode.Decompress, true); + return true; + } + // Read the zlib header : http://tools.ietf.org/html/rfc1950 // CMF(Compression Method and flags) // This byte is divided into a 4 - bit compression method and a diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 5b9eee1169..063c27a9a9 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -137,6 +137,13 @@ internal sealed class PngDecoderCore : ImageDecoderCore /// private bool hasImageData; + /// + /// Whether this is an Apple CgBI PNG. CgBI files store IDATs as raw DEFLATE + /// (no zlib header/Adler-32) and pixels as premultiplied BGRA, so they need + /// extra inversion steps to round-trip back to standard PNG semantics. + /// + private bool isCgbi; + /// /// Initializes a new instance of the class. /// @@ -314,7 +321,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore case PngChunkType.End: goto EOF; case PngChunkType.ProprietaryApple: - PngThrowHelper.ThrowInvalidChunkType("Proprietary Apple PNG detected! This PNG file is not conform to the specification and cannot be decoded."); + this.isCgbi = true; break; } } @@ -517,6 +524,10 @@ internal sealed class PngDecoderCore : ImageDecoderCore case PngChunkType.End: goto EOF; + case PngChunkType.ProprietaryApple: + this.isCgbi = true; + break; + default: if (this.colorMetadataOnly) { @@ -766,7 +777,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - using ZlibInflateStream inflateStream = new(this.currentStream, getData); + using ZlibInflateStream inflateStream = new(this.currentStream, getData, noHeader: this.isCgbi); if (!inflateStream.AllocateNewBytes(chunkLength, !this.hasImageData)) { return; @@ -887,6 +898,11 @@ internal sealed class PngDecoderCore : ImageDecoderCore break; } + if (this.isCgbi) + { + ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + } + this.ProcessDefilteredScanline(frameControl, currentRow, scanSpan, imageFrame, pngMetadata, blendRowBuffer); this.SwapScanlineBuffers(); currentRow++; @@ -1017,6 +1033,11 @@ internal sealed class PngDecoderCore : ImageDecoderCore break; } + if (this.isCgbi) + { + ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + } + Span rowSpan = imageBuffer.DangerousGetRowSpan(currentRow); this.ProcessInterlacedDefilteredScanline( frameControl, @@ -2470,4 +2491,52 @@ internal sealed class PngDecoderCore : ImageDecoderCore private void SwapScanlineBuffers() => (this.scanline, this.previousScanline) = (this.previousScanline, this.scanline); + + /// + /// Applies the inverse of Apple's CgBI pixel mangling to a defiltered scanline. + /// CgBI PNGs are emitted by pngcrush -iphone with channel order swapped + /// from RGB(A) to BGR(A) and RGB samples premultiplied by alpha. This converts + /// the bytes back to standard PNG semantics in place so the existing scanline + /// processors can consume them unchanged. CgBI is only emitted for 8-bit + /// truecolor (with or without alpha); other color types are left alone. + /// + /// + /// See https://theapplewiki.com/wiki/PNG_CgBI_Format + /// + /// The defiltered pixel bytes (without the leading filter byte). + /// The PNG color type from IHDR. + private static void ApplyCgbiTransform(Span scanline, PngColorType colorType) + { + if (colorType == PngColorType.RgbWithAlpha) + { + for (int i = 0; i + 3 < scanline.Length; i += 4) + { + byte b = scanline[i]; + byte g = scanline[i + 1]; + byte r = scanline[i + 2]; + byte a = scanline[i + 3]; + + if (a is not 0 and not 255) + { + // Reverse: c' = c * a / 255 => c = round(c' * 255 / a) + int half = a >> 1; + r = (byte)Math.Min(255, ((r * 255) + half) / a); + g = (byte)Math.Min(255, ((g * 255) + half) / a); + b = (byte)Math.Min(255, ((b * 255) + half) / a); + } + + scanline[i] = r; + scanline[i + 1] = g; + scanline[i + 2] = b; + scanline[i + 3] = a; + } + } + else if (colorType == PngColorType.Rgb) + { + for (int i = 0; i + 2 < scanline.Length; i += 3) + { + (scanline[i], scanline[i + 2]) = (scanline[i + 2], scanline[i]); + } + } + } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index 803a12b03a..88018709ce 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -714,26 +714,18 @@ public partial class PngDecoderTests Assert.Contains(metadata.ColorTable.Value.ToArray(), x => x.ToPixel().A < 255); } - // https://github.com/SixLabors/ImageSharp/issues/410 [Theory] - [WithFile(TestImages.Png.Bad.Issue410_MalformedApplePng, PixelTypes.Rgba32)] - public void Issue410_MalformedApplePng(TestImageProvider provider) + [WithFile(TestImages.Png.Cgbi.Issue410, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Colors, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Clocks, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Flecks, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Screen, PixelTypes.Rgba32)] + public void Decode_AppleCgBI(TestImageProvider provider) where TPixel : unmanaged, IPixel { - Exception ex = Record.Exception( - () => - { - using Image image = provider.GetImage(PngDecoder.Instance); - image.DebugSave(provider); - - // We don't have another x-plat reference decoder that can be compared for this image. - if (TestEnvironment.IsWindows) - { - image.CompareToOriginal(provider, ImageComparer.Exact, SystemDrawingReferenceDecoder.Png); - } - }); - Assert.NotNull(ex); - Assert.Contains("Proprietary Apple PNG detected!", ex.Message); + using Image image = provider.GetImage(PngDecoder.Instance); + image.DebugSave(provider); + image.CompareToReferenceOutput(provider, ImageComparer.Exact); } [Theory] diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 2624d1cdad..fee6fce375 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -177,6 +177,17 @@ public static class TestImages public const string PerceptualcLUTOnly = "Png/icc-profiles/Perceptual-cLUT-only.png"; } + public static class Cgbi + { + public const string Colors = "Png/cgbi/colors.png"; + public const string Clocks = "Png/cgbi/clocks.png"; + public const string Flecks = "Png/cgbi/flecks.png"; + public const string Screen = "Png/cgbi/screen.png"; + + // Issue 410: https://github.com/SixLabors/ImageSharp/issues/410 + public const string Issue410 = "Png/issues/Issue_410.png"; + } + public static class Bad { public const string MissingDataChunk = "Png/xdtn0g01.png"; @@ -199,9 +210,6 @@ public static class TestImages // Issue 1047: https://github.com/SixLabors/ImageSharp/issues/1047 public const string Issue1047_BadEndChunk = "Png/issues/Issue_1047.png"; - // Issue 410: https://github.com/SixLabors/ImageSharp/issues/410 - public const string Issue410_MalformedApplePng = "Png/issues/Issue_410.png"; - // Bad bit depth. public const string BitDepthZero = "Png/xd0n2c08.png"; public const string BitDepthThree = "Png/xd3n2c08.png"; diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..020facf415 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_Issue_410_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:511cb90e72fcb837e4c9a31561a3c914f5201452d4ca63502cb5219cb4dc42be +size 619 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..0f184812b4 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_clocks_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2dc73f1b4435a26125d910f005b5df7a540168a954f44c01a8e46df201adb1b6 +size 335851 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..74647f04fb --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_colors_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cdcc80a8c662c50d2f72ad8e123d595c6e80394538c05666b8d3531d651e71a +size 11270 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..06873607a1 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91bbb6c87f128920d4384f3be2e85ecd176e49a7a5166c6c6aa584e60d8131ed +size 204053 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..c3a61bde84 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_screen_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00bb4c7b345389f5d95252c19d70fc2b654c4f0198e6a704b603da92b78e9a0a +size 102982 diff --git a/tests/Images/Input/Png/cgbi/clocks.png b/tests/Images/Input/Png/cgbi/clocks.png new file mode 100644 index 0000000000..d7cf18367e --- /dev/null +++ b/tests/Images/Input/Png/cgbi/clocks.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc462d8c2697060cde9a2e975ffb828d822ee3b0d4d12e3c5f081114176c036b +size 389981 diff --git a/tests/Images/Input/Png/cgbi/colors.png b/tests/Images/Input/Png/cgbi/colors.png new file mode 100644 index 0000000000..9b3b371bd5 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/colors.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f34436f755e3c6d15341f29c992331045ae16ad413144ca798ede5c085c8e6a +size 12853 diff --git a/tests/Images/Input/Png/cgbi/flecks.png b/tests/Images/Input/Png/cgbi/flecks.png new file mode 100644 index 0000000000..625c82e458 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/flecks.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:314a5a52996953c0c12862262b4ff3fc191d8b6f2b7bb3853cdcea3267a4142d +size 212163 diff --git a/tests/Images/Input/Png/cgbi/screen.png b/tests/Images/Input/Png/cgbi/screen.png new file mode 100644 index 0000000000..57eca3d463 --- /dev/null +++ b/tests/Images/Input/Png/cgbi/screen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e9bfbac37a57b71fa27b38b21314bd49dbe1cb19a2eb9d0f272ec7be3b72a33 +size 94834 From 4f85600cb8d0d9e461d6647ab754594cde8fc4b7 Mon Sep 17 00:00:00 2001 From: Erik White <26148654+Erik-White@users.noreply.github.com> Date: Tue, 26 May 2026 15:43:09 +0200 Subject: [PATCH 4/9] Improve ApplyCgbiTransform --- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 31 ++++++++++---------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 063c27a9a9..dcd9ffd6c2 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -2509,33 +2509,34 @@ internal sealed class PngDecoderCore : ImageDecoderCore { if (colorType == PngColorType.RgbWithAlpha) { - for (int i = 0; i + 3 < scanline.Length; i += 4) + Span pixels = MemoryMarshal.Cast(scanline); + for (int i = 0; i < pixels.Length; i++) { - byte b = scanline[i]; - byte g = scanline[i + 1]; - byte r = scanline[i + 2]; - byte a = scanline[i + 3]; + ref Rgba32 p = ref pixels[i]; + byte r = p.B; + byte g = p.G; + byte b = p.R; + byte a = p.A; - if (a is not 0 and not 255) + if (a is not 0 and not byte.MaxValue) { // Reverse: c' = c * a / 255 => c = round(c' * 255 / a) int half = a >> 1; - r = (byte)Math.Min(255, ((r * 255) + half) / a); - g = (byte)Math.Min(255, ((g * 255) + half) / a); - b = (byte)Math.Min(255, ((b * 255) + half) / a); + r = (byte)Math.Min(byte.MaxValue, ((r * byte.MaxValue) + half) / a); + g = (byte)Math.Min(byte.MaxValue, ((g * byte.MaxValue) + half) / a); + b = (byte)Math.Min(byte.MaxValue, ((b * byte.MaxValue) + half) / a); } - scanline[i] = r; - scanline[i + 1] = g; - scanline[i + 2] = b; - scanline[i + 3] = a; + p = new Rgba32(r, g, b, a); } } else if (colorType == PngColorType.Rgb) { - for (int i = 0; i + 2 < scanline.Length; i += 3) + Span pixels = MemoryMarshal.Cast(scanline); + for (int i = 0; i < pixels.Length; i++) { - (scanline[i], scanline[i + 2]) = (scanline[i + 2], scanline[i]); + ref Rgb24 p = ref pixels[i]; + (p.R, p.B) = (p.B, p.R); } } } From 70a49b1d9d7a7086ffa5adb0bfcd057f7867142c Mon Sep 17 00:00:00 2001 From: Erik White <26148654+Erik-White@users.noreply.github.com> Date: Tue, 26 May 2026 15:50:58 +0200 Subject: [PATCH 5/9] Add tests for Identify --- .../Formats/Png/PngDecoderTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index 88018709ce..c1ba56f3b4 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -728,6 +728,22 @@ public partial class PngDecoderTests image.CompareToReferenceOutput(provider, ImageComparer.Exact); } + [Theory] + [InlineData(TestImages.Png.Cgbi.Colors, 120, 120)] + [InlineData(TestImages.Png.Cgbi.Issue410, 42, 26)] + public void Identify_AppleCgBI(string imagePath, int expectedWidth, int expectedHeight) + { + TestFile testFile = TestFile.Create(imagePath); + using MemoryStream stream = new(testFile.Bytes, false); + + ImageInfo imageInfo = Image.Identify(stream); + + Assert.NotNull(imageInfo); + Assert.Equal(PngFormat.Instance, imageInfo.Metadata.DecodedImageFormat); + Assert.Equal(expectedWidth, imageInfo.Width); + Assert.Equal(expectedHeight, imageInfo.Height); + } + [Theory] [WithFile(TestImages.Png.Splash, PixelTypes.Rgba32)] [WithFile(TestImages.Png.Bike, PixelTypes.Rgba32)] From 5d206ec10f9611603e0e8566ac11725bcb5d6c1f Mon Sep 17 00:00:00 2001 From: Erik White <26148654+Erik-White@users.noreply.github.com> Date: Tue, 26 May 2026 17:41:21 +0200 Subject: [PATCH 6/9] Add test data for RGB24 --- tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs | 3 ++- ...4_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png | 3 +++ ...2_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png | 3 --- tests/Images/Input/Png/cgbi/flecks.png | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgb24_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png delete mode 100644 tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index c1ba56f3b4..d3a699c492 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -718,8 +718,8 @@ public partial class PngDecoderTests [WithFile(TestImages.Png.Cgbi.Issue410, PixelTypes.Rgba32)] [WithFile(TestImages.Png.Cgbi.Colors, PixelTypes.Rgba32)] [WithFile(TestImages.Png.Cgbi.Clocks, PixelTypes.Rgba32)] - [WithFile(TestImages.Png.Cgbi.Flecks, PixelTypes.Rgba32)] [WithFile(TestImages.Png.Cgbi.Screen, PixelTypes.Rgba32)] + [WithFile(TestImages.Png.Cgbi.Flecks, PixelTypes.Rgb24)] public void Decode_AppleCgBI(TestImageProvider provider) where TPixel : unmanaged, IPixel { @@ -731,6 +731,7 @@ public partial class PngDecoderTests [Theory] [InlineData(TestImages.Png.Cgbi.Colors, 120, 120)] [InlineData(TestImages.Png.Cgbi.Issue410, 42, 26)] + [InlineData(TestImages.Png.Cgbi.Flecks, 510, 512)] public void Identify_AppleCgBI(string imagePath, int expectedWidth, int expectedHeight) { TestFile testFile = TestFile.Create(imagePath); diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgb24_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgb24_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png new file mode 100644 index 0000000000..a180f01186 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgb24_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10ee142fc1d3638ebe53fdd21c0a4c53008801befcc5e986051b796561cae887 +size 237676 diff --git a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png b/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png deleted file mode 100644 index 06873607a1..0000000000 --- a/tests/Images/External/ReferenceOutput/PngDecoderTests/Decode_AppleCgBI_Rgba32_flecks_ImageThreshold-0_PerPixelManhattanThreshold-0.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:91bbb6c87f128920d4384f3be2e85ecd176e49a7a5166c6c6aa584e60d8131ed -size 204053 diff --git a/tests/Images/Input/Png/cgbi/flecks.png b/tests/Images/Input/Png/cgbi/flecks.png index 625c82e458..6400442296 100644 --- a/tests/Images/Input/Png/cgbi/flecks.png +++ b/tests/Images/Input/Png/cgbi/flecks.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:314a5a52996953c0c12862262b4ff3fc191d8b6f2b7bb3853cdcea3267a4142d -size 212163 +oid sha256:6be7b478594ba5e4d37bc135c881c0a16cf1c804fece5440bab997c7b69182f1 +size 187703 From a3e9cc6fd4a097e333c014d592747c5a80dd4371 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 27 May 2026 13:34:17 +1000 Subject: [PATCH 7/9] Fix in-place shuffles and vectorize PNG CgBI transform --- .../Common/Helpers/Shuffle/IPad3Shuffle4.cs | 46 ++- .../Common/Helpers/Shuffle/IShuffle3.cs | 16 +- .../Common/Helpers/Shuffle/IShuffle4.cs | 14 +- .../Common/Helpers/Shuffle/IShuffle4Slice3.cs | 34 +- .../Common/Helpers/SimdUtils.Shuffle.cs | 14 +- src/ImageSharp/Formats/Png/PngDecoderCore.cs | 300 ++++++++++++++++-- .../Formats/Png/PngDecoderTests.cs | 28 +- 7 files changed, 390 insertions(+), 62 deletions(-) diff --git a/src/ImageSharp/Common/Helpers/Shuffle/IPad3Shuffle4.cs b/src/ImageSharp/Common/Helpers/Shuffle/IPad3Shuffle4.cs index 0f282c7f9a..bbe14b0991 100644 --- a/src/ImageSharp/Common/Helpers/Shuffle/IPad3Shuffle4.cs +++ b/src/ImageSharp/Common/Helpers/Shuffle/IPad3Shuffle4.cs @@ -31,19 +31,23 @@ internal readonly struct DefaultPad3Shuffle4([ConstantExpected] byte control) : SimdUtils.Shuffle.InverseMMShuffle(this.Control, out uint p3, out uint p2, out uint p1, out uint p0); - Span temp = stackalloc byte[4]; - ref byte t = ref MemoryMarshal.GetReference(temp); - ref uint tu = ref Unsafe.As(ref t); - for (nuint i = 0, j = 0; i < (uint)source.Length; i += 3, j += 4) { - ref byte s = ref Unsafe.Add(ref sBase, i); - tu = Unsafe.As(ref s) | 0xFF000000; - - Unsafe.Add(ref dBase, j + 0) = Unsafe.Add(ref t, p0); - Unsafe.Add(ref dBase, j + 1) = Unsafe.Add(ref t, p1); - Unsafe.Add(ref dBase, j + 2) = Unsafe.Add(ref t, p2); - Unsafe.Add(ref dBase, j + 3) = Unsafe.Add(ref t, p3); + // Expanding 3-byte pixels to 4 bytes can overwrite the next source + // triplet when spans overlap. Assemble the padded pixel first, then + // shuffle from the staged uint. + uint packed = + Unsafe.Add(ref sBase, i + 0u) | + ((uint)Unsafe.Add(ref sBase, i + 1u) << 8) | + ((uint)Unsafe.Add(ref sBase, i + 2u) << 16) | + 0xFF000000; + + ref byte pBase = ref Unsafe.As(ref packed); + + Unsafe.Add(ref dBase, j + 0u) = Unsafe.Add(ref pBase, p0); + Unsafe.Add(ref dBase, j + 1u) = Unsafe.Add(ref pBase, p1); + Unsafe.Add(ref dBase, j + 2u) = Unsafe.Add(ref pBase, p2); + Unsafe.Add(ref dBase, j + 3u) = Unsafe.Add(ref pBase, p3); } } } @@ -65,7 +69,12 @@ internal readonly struct XYZWPad3Shuffle4 : IPad3Shuffle4 while (Unsafe.IsAddressLessThan(ref sBase, ref sLoopEnd)) { - Unsafe.As(ref dBase) = Unsafe.As(ref sBase) | 0xFF000000; + // The fast scalar path reads one extra byte past the source triplet. + // Keep that widened read in a local before writing the expanded pixel + // so overlapping destinations cannot change what was read. + uint packed = Unsafe.As(ref sBase) | 0xFF000000; + + Unsafe.As(ref dBase) = packed; sBase = ref Unsafe.Add(ref sBase, 3); dBase = ref Unsafe.Add(ref dBase, 4); @@ -73,10 +82,15 @@ internal readonly struct XYZWPad3Shuffle4 : IPad3Shuffle4 while (Unsafe.IsAddressLessThan(ref sBase, ref sEnd)) { - Unsafe.Add(ref dBase, 0) = Unsafe.Add(ref sBase, 0); - Unsafe.Add(ref dBase, 1) = Unsafe.Add(ref sBase, 1); - Unsafe.Add(ref dBase, 2) = Unsafe.Add(ref sBase, 2); - Unsafe.Add(ref dBase, 3) = byte.MaxValue; + // The final triplet cannot use the widened read above, so assemble + // the same padded uint byte-by-byte before the overlapping store. + uint packed = + Unsafe.Add(ref sBase, 0u) | + ((uint)Unsafe.Add(ref sBase, 1u) << 8) | + ((uint)Unsafe.Add(ref sBase, 2u) << 16) | + 0xFF000000; + + Unsafe.As(ref dBase) = packed; sBase = ref Unsafe.Add(ref sBase, 3); dBase = ref Unsafe.Add(ref dBase, 4); diff --git a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle3.cs b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle3.cs index 3c0973ad69..3907df58c6 100644 --- a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle3.cs +++ b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle3.cs @@ -33,9 +33,19 @@ internal readonly struct DefaultShuffle3([ConstantExpected] byte control) : IShu for (nuint i = 0; i < (uint)source.Length; i += 3) { - Unsafe.Add(ref dBase, i + 0) = Unsafe.Add(ref sBase, p0 + i); - Unsafe.Add(ref dBase, i + 1) = Unsafe.Add(ref sBase, p1 + i); - Unsafe.Add(ref dBase, i + 2) = Unsafe.Add(ref sBase, p2 + i); + // The scalar remainder can run in-place after the vector body. Load + // the full 3-byte pixel into a register-sized value before stores so + // channel swaps cannot corrupt later reads from the same pixel. + uint packed = + Unsafe.Add(ref sBase, i + 0u) | + ((uint)Unsafe.Add(ref sBase, i + 1u) << 8) | + ((uint)Unsafe.Add(ref sBase, i + 2u) << 16); + + ref byte pBase = ref Unsafe.As(ref packed); + + Unsafe.Add(ref dBase, i + 0u) = Unsafe.Add(ref pBase, p0); + Unsafe.Add(ref dBase, i + 1u) = Unsafe.Add(ref pBase, p1); + Unsafe.Add(ref dBase, i + 2u) = Unsafe.Add(ref pBase, p2); } } } diff --git a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4.cs b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4.cs index d5c6df2c8b..68f34efd7c 100644 --- a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4.cs +++ b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4.cs @@ -35,10 +35,16 @@ internal readonly struct DefaultShuffle4([ConstantExpected] byte control) : IShu for (nuint i = 0; i < (uint)source.Length; i += 4) { - Unsafe.Add(ref dBase, i + 0) = Unsafe.Add(ref sBase, p0 + i); - Unsafe.Add(ref dBase, i + 1) = Unsafe.Add(ref sBase, p1 + i); - Unsafe.Add(ref dBase, i + 2) = Unsafe.Add(ref sBase, p2 + i); - Unsafe.Add(ref dBase, i + 3) = Unsafe.Add(ref sBase, p3 + i); + // The generic path may be used with source and destination pointing + // at the same pixel. Load all channels first so subsequent stores + // index only staged bytes, matching the specialized uint shuffles. + uint packed = Unsafe.As(ref Unsafe.Add(ref sBase, i)); + ref byte pBase = ref Unsafe.As(ref packed); + + Unsafe.Add(ref dBase, i + 0u) = Unsafe.Add(ref pBase, p0); + Unsafe.Add(ref dBase, i + 1u) = Unsafe.Add(ref pBase, p1); + Unsafe.Add(ref dBase, i + 2u) = Unsafe.Add(ref pBase, p2); + Unsafe.Add(ref dBase, i + 3u) = Unsafe.Add(ref pBase, p3); } } } diff --git a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4Slice3.cs b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4Slice3.cs index 3e7e440664..6134061670 100644 --- a/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4Slice3.cs +++ b/src/ImageSharp/Common/Helpers/Shuffle/IShuffle4Slice3.cs @@ -33,9 +33,15 @@ internal readonly struct DefaultShuffle4Slice3([ConstantExpected] byte control) for (nuint i = 0, j = 0; i < (uint)destination.Length; i += 3, j += 4) { - Unsafe.Add(ref dBase, i + 0) = Unsafe.Add(ref sBase, p0 + j); - Unsafe.Add(ref dBase, i + 1) = Unsafe.Add(ref sBase, p1 + j); - Unsafe.Add(ref dBase, i + 2) = Unsafe.Add(ref sBase, p2 + j); + // Shrinking 4-byte pixels to 3 bytes can still be called in-place by + // tail code. Read the complete source pixel first, then write only + // the requested channels into the destination triplet. + uint packed = Unsafe.As(ref Unsafe.Add(ref sBase, j)); + ref byte pBase = ref Unsafe.As(ref packed); + + Unsafe.Add(ref dBase, i + 0u) = Unsafe.Add(ref pBase, p0); + Unsafe.Add(ref dBase, i + 1u) = Unsafe.Add(ref pBase, p1); + Unsafe.Add(ref dBase, i + 2u) = Unsafe.Add(ref pBase, p2); } } } @@ -61,10 +67,18 @@ internal readonly struct XYZWShuffle4Slice3 : IShuffle4Slice3 while (Unsafe.IsAddressLessThan(ref sBase, ref sLoopEnd)) { - Unsafe.Add(ref dBase, 0) = Unsafe.As(ref Unsafe.Add(ref sBase, 0)); - Unsafe.Add(ref dBase, 1) = Unsafe.As(ref Unsafe.Add(ref sBase, 1)); - Unsafe.Add(ref dBase, 2) = Unsafe.As(ref Unsafe.Add(ref sBase, 2)); - Unsafe.Add(ref dBase, 3) = Unsafe.As(ref Unsafe.Add(ref sBase, 3)); + // Stage the four source pixels before the 3-byte stores. Even + // though this path preserves XYZ order, the packed loads must happen + // before destination writes when the spans overlap. + uint packed0 = Unsafe.Add(ref sBase, 0u); + uint packed1 = Unsafe.Add(ref sBase, 1u); + uint packed2 = Unsafe.Add(ref sBase, 2u); + uint packed3 = Unsafe.Add(ref sBase, 3u); + + Unsafe.Add(ref dBase, 0u) = Unsafe.As(ref packed0); + Unsafe.Add(ref dBase, 1u) = Unsafe.As(ref packed1); + Unsafe.Add(ref dBase, 2u) = Unsafe.As(ref packed2); + Unsafe.Add(ref dBase, 3u) = Unsafe.As(ref packed3); sBase = ref Unsafe.Add(ref sBase, 4); dBase = ref Unsafe.Add(ref dBase, 4); @@ -72,7 +86,11 @@ internal readonly struct XYZWShuffle4Slice3 : IShuffle4Slice3 while (Unsafe.IsAddressLessThan(ref sBase, ref sEnd)) { - Unsafe.Add(ref dBase, 0) = Unsafe.As(ref Unsafe.Add(ref sBase, 0)); + // Same overlap rule as the unrolled loop: take the 4-byte source + // pixel before storing the 3-byte destination value. + uint packed = Unsafe.Add(ref sBase, 0u); + + Unsafe.Add(ref dBase, 0u) = Unsafe.As(ref packed); sBase = ref Unsafe.Add(ref sBase, 1); dBase = ref Unsafe.Add(ref dBase, 1); diff --git a/src/ImageSharp/Common/Helpers/SimdUtils.Shuffle.cs b/src/ImageSharp/Common/Helpers/SimdUtils.Shuffle.cs index dbeb54a80c..8b2baec213 100644 --- a/src/ImageSharp/Common/Helpers/SimdUtils.Shuffle.cs +++ b/src/ImageSharp/Common/Helpers/SimdUtils.Shuffle.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -150,10 +151,15 @@ internal static partial class SimdUtils for (nuint i = 0; i < (uint)source.Length; i += 4) { - Unsafe.Add(ref dBase, i + 0) = Unsafe.Add(ref sBase, p0 + i); - Unsafe.Add(ref dBase, i + 1) = Unsafe.Add(ref sBase, p1 + i); - Unsafe.Add(ref dBase, i + 2) = Unsafe.Add(ref sBase, p2 + i); - Unsafe.Add(ref dBase, i + 3) = Unsafe.Add(ref sBase, p3 + i); + // Stage the scalar tail in a local Vector4 so p0..p3 index source + // values that were captured before any overlapping destination writes. + Vector4 v = Unsafe.As(ref Unsafe.Add(ref sBase, i)); + ref float pBase = ref Unsafe.As(ref v); + + Unsafe.Add(ref dBase, i + 0u) = Unsafe.Add(ref pBase, p0); + Unsafe.Add(ref dBase, i + 1u) = Unsafe.Add(ref pBase, p1); + Unsafe.Add(ref dBase, i + 2u) = Unsafe.Add(ref pBase, p2); + Unsafe.Add(ref dBase, i + 3u) = Unsafe.Add(ref pBase, p3); } } diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index dcd9ffd6c2..84245254a2 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -9,6 +9,8 @@ using System.IO.Compression; using System.IO.Hashing; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; using System.Text; using SixLabors.ImageSharp.Common.Helpers; using SixLabors.ImageSharp.Compression.Zlib; @@ -900,7 +902,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore if (this.isCgbi) { - ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + this.ApplyCgbiTransform(scanSpan[1..], this.pngColorType); } this.ProcessDefilteredScanline(frameControl, currentRow, scanSpan, imageFrame, pngMetadata, blendRowBuffer); @@ -1035,7 +1037,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore if (this.isCgbi) { - ApplyCgbiTransform(scanSpan[1..], this.pngColorType); + this.ApplyCgbiTransform(scanSpan[1..], this.pngColorType); } Span rowSpan = imageBuffer.DangerousGetRowSpan(currentRow); @@ -2505,39 +2507,289 @@ internal sealed class PngDecoderCore : ImageDecoderCore /// /// The defiltered pixel bytes (without the leading filter byte). /// The PNG color type from IHDR. - private static void ApplyCgbiTransform(Span scanline, PngColorType colorType) + private void ApplyCgbiTransform(Span scanline, PngColorType colorType) { if (colorType == PngColorType.RgbWithAlpha) { Span pixels = MemoryMarshal.Cast(scanline); - for (int i = 0; i < pixels.Length; i++) + int i = 0; + + if (Vector512.IsHardwareAccelerated && pixels.Length >= 16) { - ref Rgba32 p = ref pixels[i]; - byte r = p.B; - byte g = p.G; - byte b = p.R; - byte a = p.A; + i = ApplyCgbiTransformVector512(scanline, pixels.Length); + } - if (a is not 0 and not byte.MaxValue) - { - // Reverse: c' = c * a / 255 => c = round(c' * 255 / a) - int half = a >> 1; - r = (byte)Math.Min(byte.MaxValue, ((r * byte.MaxValue) + half) / a); - g = (byte)Math.Min(byte.MaxValue, ((g * byte.MaxValue) + half) / a); - b = (byte)Math.Min(byte.MaxValue, ((b * byte.MaxValue) + half) / a); - } + if (Vector256.IsHardwareAccelerated && Avx2.IsSupported && (pixels.Length - i) >= 8) + { + i = ApplyCgbiTransformVector256(scanline, i, pixels.Length); + } - p = new Rgba32(r, g, b, a); + if (Vector128.IsHardwareAccelerated && (pixels.Length - i) >= 4) + { + i = ApplyCgbiTransformVector128(scanline, i, pixels.Length); + } + + for (; i < pixels.Length; i++) + { + ref Rgba32 pixel = ref pixels[i]; + pixel = new Rgba32(pixel.B, pixel.G, pixel.R, pixel.A); + UndoCgbiPremultiplicationScalar(ref pixel); } } else if (colorType == PngColorType.Rgb) { - Span pixels = MemoryMarshal.Cast(scanline); - for (int i = 0; i < pixels.Length; i++) - { - ref Rgb24 p = ref pixels[i]; - (p.R, p.B) = (p.B, p.R); - } + // No alpha channel, so just swap R and B using built in SIMD-optimized pixel operations. + Span target = MemoryMarshal.Cast(scanline); + PixelOperations.Instance.FromBgr24Bytes(this.configuration, scanline, target, target.Length); } } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void UndoCgbiPremultiplicationScalar(ref Rgba32 pixel) + { + byte a = pixel.A; + if (a is 0 or byte.MaxValue) + { + return; + } + + // Reverse: c' = c * a / 255 => c = round(c' * 255 / a) + int half = a >> 1; + byte r = (byte)Math.Min(byte.MaxValue, ((pixel.R * byte.MaxValue) + half) / a); + byte g = (byte)Math.Min(byte.MaxValue, ((pixel.G * byte.MaxValue) + half) / a); + byte b = (byte)Math.Min(byte.MaxValue, ((pixel.B * byte.MaxValue) + half) / a); + pixel = new Rgba32(r, g, b, a); + } + + private static int ApplyCgbiTransformVector512(Span scanline, int pixelCount) + { + ref byte scanlineRef = ref MemoryMarshal.GetReference(scanline); + int i = 0; + + Span temp = stackalloc byte[Vector512.Count]; + SimdUtils.Shuffle.MMShuffleSpan(ref temp, SimdUtils.Shuffle.MMShuffle3012); + + // MMShuffle3012 expands to [2, 1, 0, 3] for each 4-byte pixel, converting + // CgBI's BGRA byte order to Rgba32's RGBA layout while keeping alpha in place. + // The generated mask only swaps bytes inside each pixel, so it remains + // correct for the optimized 512-bit byte shuffle helper. + Vector512 shuffleMask = Unsafe.As>(ref MemoryMarshal.GetReference(temp)); + + Vector512 zero = Vector512.Zero; + Vector512 one = Vector512.One; + Vector512 byteMask = Vector512.Create(0xFF); + Vector512 opaque = Vector512.Create(0xFF); + Vector512 byteMax = Vector512.Create((int)byte.MaxValue); + + for (; i <= pixelCount - 16; i += 16) + { + ref byte blockRef = ref Unsafe.Add(ref scanlineRef, i * Unsafe.SizeOf()); + Vector512 bgra = Unsafe.ReadUnaligned>(ref blockRef); + Vector512 rgba = Vector512_.ShuffleNative(bgra, shuffleMask); + Vector512 packed = rgba.AsInt32(); + Vector512 alpha = Vector512.ShiftRightLogical(packed, 24); + + // Fully transparent and fully opaque pixels are identity cases for + // unpremultiplication. Masking them keeps the scalar behavior and lets + // safeAlpha avoid dividing by zero for alpha == 0. + Vector512 partialMask = ~(Vector512.Equals(alpha, zero) | Vector512.Equals(alpha, opaque)); + + Vector512 r = packed & byteMask; + Vector512 g = Vector512.ShiftRightLogical(packed, 8) & byteMask; + Vector512 b = Vector512.ShiftRightLogical(packed, 16) & byteMask; + + Vector512 safeAlpha = Vector512.ConditionalSelect(partialMask, alpha, one); + Vector512 halfAlpha = Vector512.ShiftRightLogical(safeAlpha, 1); + Vector512 safeAlphaF = Vector512.ConvertToSingle(safeAlpha); + + // The scalar path computes ((c * 255) + (a >> 1)) / a with integer + // division. Floor the positive quotient before converting so SIMD does + // not use the default round-to-nearest conversion and drift by one. + Vector512 unpremultipliedR = Vector512.Min( + byteMax, + Vector512.ConvertToInt32(Vector512.Floor(Vector512.ConvertToSingle((r * byteMax) + halfAlpha) / safeAlphaF))); + + Vector512 unpremultipliedG = Vector512.Min( + byteMax, + Vector512.ConvertToInt32(Vector512.Floor(Vector512.ConvertToSingle((g * byteMax) + halfAlpha) / safeAlphaF))); + + Vector512 unpremultipliedB = Vector512.Min( + byteMax, + Vector512.ConvertToInt32(Vector512.Floor(Vector512.ConvertToSingle((b * byteMax) + halfAlpha) / safeAlphaF))); + + // ConditionalSelect applies the expensive unpremultiply only to pixels + // where alpha is between 1 and 254; alpha 0 and 255 lanes keep the + // shuffled channel values exactly as the scalar path does. + Vector512 finalR = Vector512.ConditionalSelect(partialMask, unpremultipliedR, r); + Vector512 finalG = Vector512.ConditionalSelect(partialMask, unpremultipliedG, g); + Vector512 finalB = Vector512.ConditionalSelect(partialMask, unpremultipliedB, b); + + // Rgba32 is laid out as little-endian 0xAABBGGRR in an int lane, so + // shifting the unpacked channels back to byte offsets 0, 1, 2, and 3 + // recreates the in-memory RGBA bytes for the unaligned store. + Vector512 result = + finalR | + Vector512.ShiftLeft(finalG, 8) | + Vector512.ShiftLeft(finalB, 16) | + Vector512.ShiftLeft(alpha, 24); + + Unsafe.WriteUnaligned(ref blockRef, result.AsByte()); + } + + return i; + } + + private static int ApplyCgbiTransformVector256(Span scanline, int startPixel, int pixelCount) + { + ref byte scanlineRef = ref MemoryMarshal.GetReference(scanline); + int i = startPixel; + + Span temp = stackalloc byte[Vector512.Count]; + SimdUtils.Shuffle.MMShuffleSpan(ref temp, SimdUtils.Shuffle.MMShuffle3012); + + // MMShuffle3012 expands to [2, 1, 0, 3] for each 4-byte pixel, converting + // CgBI's BGRA byte order to Rgba32's RGBA layout while keeping alpha in place. + // Avx2.Shuffle is 128-bit lane-local, and the generated mask repeats inside + // each lane, so no byte ever needs to cross the lane boundary. + Vector256 shuffleMask = Unsafe.As>(ref MemoryMarshal.GetReference(temp)); + + Vector256 zero = Vector256.Zero; + Vector256 one = Vector256.One; + Vector256 byteMask = Vector256.Create(0xFF); + Vector256 opaque = Vector256.Create(0xFF); + Vector256 byteMax = Vector256.Create((int)byte.MaxValue); + + for (; i <= pixelCount - 8; i += 8) + { + ref byte blockRef = ref Unsafe.Add(ref scanlineRef, i * Unsafe.SizeOf()); + Vector256 bgra = Unsafe.ReadUnaligned>(ref blockRef); + Vector256 rgba = Vector256_.ShufflePerLane(bgra, shuffleMask); + Vector256 packed = rgba.AsInt32(); + Vector256 alpha = Vector256.ShiftRightLogical(packed, 24); + + // Fully transparent and fully opaque pixels are identity cases for + // unpremultiplication. Masking them keeps the scalar behavior and lets + // safeAlpha avoid dividing by zero for alpha == 0. + Vector256 partialMask = ~(Vector256.Equals(alpha, zero) | Vector256.Equals(alpha, opaque)); + + Vector256 r = packed & byteMask; + Vector256 g = Vector256.ShiftRightLogical(packed, 8) & byteMask; + Vector256 b = Vector256.ShiftRightLogical(packed, 16) & byteMask; + + Vector256 safeAlpha = Vector256.ConditionalSelect(partialMask, alpha, one); + Vector256 halfAlpha = Vector256.ShiftRightLogical(safeAlpha, 1); + Vector256 safeAlphaF = Vector256.ConvertToSingle(safeAlpha); + + // The scalar path computes ((c * 255) + (a >> 1)) / a with integer + // division. Floor the positive quotient before converting so SIMD does + // not use the default round-to-nearest conversion and drift by one. + Vector256 unpremultipliedR = Vector256.Min( + byteMax, + Vector256.ConvertToInt32(Vector256.Floor(Vector256.ConvertToSingle((r * byteMax) + halfAlpha) / safeAlphaF))); + + Vector256 unpremultipliedG = Vector256.Min( + byteMax, + Vector256.ConvertToInt32(Vector256.Floor(Vector256.ConvertToSingle((g * byteMax) + halfAlpha) / safeAlphaF))); + + Vector256 unpremultipliedB = Vector256.Min( + byteMax, + Vector256.ConvertToInt32(Vector256.Floor(Vector256.ConvertToSingle((b * byteMax) + halfAlpha) / safeAlphaF))); + + // ConditionalSelect applies the expensive unpremultiply only to pixels + // where alpha is between 1 and 254; alpha 0 and 255 lanes keep the + // shuffled channel values exactly as the scalar path does. + Vector256 finalR = Vector256.ConditionalSelect(partialMask, unpremultipliedR, r); + Vector256 finalG = Vector256.ConditionalSelect(partialMask, unpremultipliedG, g); + Vector256 finalB = Vector256.ConditionalSelect(partialMask, unpremultipliedB, b); + + // Rgba32 is laid out as little-endian 0xAABBGGRR in an int lane, so + // shifting the unpacked channels back to byte offsets 0, 1, 2, and 3 + // recreates the in-memory RGBA bytes for the unaligned store. + Vector256 result = + finalR | + Vector256.ShiftLeft(finalG, 8) | + Vector256.ShiftLeft(finalB, 16) | + Vector256.ShiftLeft(alpha, 24); + + Unsafe.WriteUnaligned(ref blockRef, result.AsByte()); + } + + return i; + } + + private static int ApplyCgbiTransformVector128(Span scanline, int startPixel, int pixelCount) + { + ref byte scanlineRef = ref MemoryMarshal.GetReference(scanline); + int i = startPixel; + + Span temp = stackalloc byte[Vector512.Count]; + SimdUtils.Shuffle.MMShuffleSpan(ref temp, SimdUtils.Shuffle.MMShuffle3012); + + // MMShuffle3012 expands to [2, 1, 0, 3] for each 4-byte pixel, converting + // CgBI's BGRA byte order to Rgba32's RGBA layout while keeping alpha in place. + Vector128 shuffleMask = Unsafe.As>(ref MemoryMarshal.GetReference(temp)); + + Vector128 zero = Vector128.Zero; + Vector128 one = Vector128.One; + Vector128 byteMask = Vector128.Create(0xFF); + Vector128 opaque = Vector128.Create(0xFF); + Vector128 byteMax = Vector128.Create((int)byte.MaxValue); + + for (; i <= pixelCount - 4; i += 4) + { + ref byte blockRef = ref Unsafe.Add(ref scanlineRef, i * Unsafe.SizeOf()); + Vector128 bgra = Unsafe.ReadUnaligned>(ref blockRef); + Vector128 rgba = Vector128_.ShuffleNative(bgra, shuffleMask); + Vector128 packed = rgba.AsInt32(); + Vector128 alpha = Vector128.ShiftRightLogical(packed, 24); + + // Fully transparent and fully opaque pixels are identity cases for + // unpremultiplication. Masking them keeps the scalar behavior and lets + // safeAlpha avoid dividing by zero for alpha == 0. + Vector128 partialMask = ~(Vector128.Equals(alpha, zero) | Vector128.Equals(alpha, opaque)); + + Vector128 r = packed & byteMask; + Vector128 g = Vector128.ShiftRightLogical(packed, 8) & byteMask; + Vector128 b = Vector128.ShiftRightLogical(packed, 16) & byteMask; + + Vector128 safeAlpha = Vector128.ConditionalSelect(partialMask, alpha, one); + Vector128 halfAlpha = Vector128.ShiftRightLogical(safeAlpha, 1); + Vector128 safeAlphaF = Vector128.ConvertToSingle(safeAlpha); + + // The scalar path computes ((c * 255) + (a >> 1)) / a with integer + // division. Floor the positive quotient before converting so SIMD does + // not use the default round-to-nearest conversion and drift by one. + Vector128 unpremultipliedR = Vector128.Min( + byteMax, + Vector128.ConvertToInt32(Vector128.Floor(Vector128.ConvertToSingle((r * byteMax) + halfAlpha) / safeAlphaF))); + + Vector128 unpremultipliedG = Vector128.Min( + byteMax, + Vector128.ConvertToInt32(Vector128.Floor(Vector128.ConvertToSingle((g * byteMax) + halfAlpha) / safeAlphaF))); + + Vector128 unpremultipliedB = Vector128.Min( + byteMax, + Vector128.ConvertToInt32(Vector128.Floor(Vector128.ConvertToSingle((b * byteMax) + halfAlpha) / safeAlphaF))); + + // ConditionalSelect applies the expensive unpremultiply only to pixels + // where alpha is between 1 and 254; alpha 0 and 255 lanes keep the + // shuffled channel values exactly as the scalar path does. + Vector128 finalR = Vector128.ConditionalSelect(partialMask, unpremultipliedR, r); + Vector128 finalG = Vector128.ConditionalSelect(partialMask, unpremultipliedG, g); + Vector128 finalB = Vector128.ConditionalSelect(partialMask, unpremultipliedB, b); + + // Rgba32 is laid out as little-endian 0xAABBGGRR in an int lane, so + // shifting the unpacked channels back to byte offsets 0, 1, 2, and 3 + // recreates the in-memory RGBA bytes for the unaligned store. + Vector128 result = + finalR | + Vector128.ShiftLeft(finalG, 8) | + Vector128.ShiftLeft(finalB, 16) | + Vector128.ShiftLeft(alpha, 24); + + Unsafe.WriteUnaligned(ref blockRef, result.AsByte()); + } + + return i; + } } diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index d3a699c492..2e452b896d 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -722,10 +722,32 @@ public partial class PngDecoderTests [WithFile(TestImages.Png.Cgbi.Flecks, PixelTypes.Rgb24)] public void Decode_AppleCgBI(TestImageProvider provider) where TPixel : unmanaged, IPixel + => FeatureTestRunner.RunWithHwIntrinsicsFeature( + RunDecodeAppleCgbi, + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX2 | HwIntrinsics.DisableHWIntrinsic, + provider, + provider.PixelType.ToString()); + + private static void RunDecodeAppleCgbi(string providerDump, string pixelType) { - using Image image = provider.GetImage(PngDecoder.Instance); - image.DebugSave(provider); - image.CompareToReferenceOutput(provider, ImageComparer.Exact); + if (Enum.Parse(pixelType) == PixelTypes.Rgb24) + { + TestImageProvider provider = + FeatureTestRunner.DeserializeForXunit>(providerDump); + + using Image image = provider.GetImage(PngDecoder.Instance); + image.DebugSave(provider); + image.CompareToReferenceOutput(provider, ImageComparer.Exact); + + return; + } + + TestImageProvider rgbaProvider = + FeatureTestRunner.DeserializeForXunit>(providerDump); + + using Image rgbaImage = rgbaProvider.GetImage(PngDecoder.Instance); + rgbaImage.DebugSave(rgbaProvider); + rgbaImage.CompareToReferenceOutput(rgbaProvider, ImageComparer.Exact); } [Theory] From 3d5083419325a5ea305b62d6f6d4d082017fb4b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:49:56 +0000 Subject: [PATCH 8/9] Bump codecov/codecov-action from 6 to 7 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v6...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index fe2e4e5ca7..39d30ea459 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -93,7 +93,7 @@ jobs: path: tests/Images/ActualOutput/ - name: Codecov Update - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 if: matrix.options.codecov == true && startsWith(github.repository, 'SixLabors') with: flags: unittests From e5ca2782e67a954d8f3ec253a76311d265cb639d Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 11 Jun 2026 11:01:29 +1000 Subject: [PATCH 9/9] Fix GIF transparency handling and dither --- src/ImageSharp/Color/Color.WernerPalette.cs | 6 +++- src/ImageSharp/Formats/Gif/GifEncoderCore.cs | 31 +++++++++++++------ .../Processors/Dithering/ErrorDither.cs | 9 ++++++ .../Processors/Dithering/OrderedDither.cs | 10 ++++++ .../Formats/Gif/GifEncoderTests.cs | 20 +++++++++++- tests/ImageSharp.Tests/TestImages.cs | 4 ++- .../00.gif | 3 ++ .../08.gif | 3 ++ .../104.gif | 3 ++ .../112.gif | 3 ++ .../16.gif | 3 ++ .../24.gif | 3 ++ .../32.gif | 3 ++ .../40.gif | 3 ++ .../48.gif | 3 ++ .../56.gif | 3 ++ .../64.gif | 3 ++ .../72.gif | 3 ++ .../80.gif | 3 ++ .../88.gif | 3 ++ .../96.gif | 3 ++ ...zed_Encode_Artifacts_Rgba32_issue_2469.png | 4 +-- ...antized_Encode_Alpha_Rgba32_Issue_2668.png | 4 +-- tests/Images/Input/Gif/issues/issue_3142.gif | 3 ++ 24 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/00.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/08.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/104.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/112.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/16.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/24.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/32.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/40.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/48.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/56.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/64.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/72.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/80.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/88.gif create mode 100644 tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/96.gif create mode 100644 tests/Images/Input/Gif/issues/issue_3142.gif diff --git a/src/ImageSharp/Color/Color.WernerPalette.cs b/src/ImageSharp/Color/Color.WernerPalette.cs index 583c71379f..f6b6256256 100644 --- a/src/ImageSharp/Color/Color.WernerPalette.cs +++ b/src/ImageSharp/Color/Color.WernerPalette.cs @@ -127,6 +127,10 @@ public partial struct Color ParseHex("#8b7859"), ParseHex("#9b856b"), ParseHex("#766051"), - ParseHex("#453b32") + ParseHex("#453b32"), + + // Werner does not define a transparent color, but we need to add one to + // make the palette work with the rest of the library. + Transparent ]; } diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs index dcba3953d5..344c13cc94 100644 --- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs @@ -361,7 +361,10 @@ internal sealed class GifEncoderCore : Color.Transparent; // Deduplicate and quantize the frame capturing only required parts. - (bool difference, Rectangle bounds) = + // Pixels matching the previous frame are replaced with the transparent placeholder. + // When the entire frame matches there is no captured difference, but every pixel is + // still a placeholder, so a transparent index is always required for additional frames. + (_, Rectangle bounds) = AnimationUtilities.DeDuplicatePixels( this.configuration, previous, @@ -378,7 +381,7 @@ internal sealed class GifEncoderCore bounds, metadata, useLocal, - difference, + true, transparencyIndex, background); @@ -403,7 +406,7 @@ internal sealed class GifEncoderCore Rectangle bounds, GifFrameMetadata metadata, bool useLocal, - bool hasDuplicates, + bool requiresTransparency, int transparencyIndex, Color transparentColor) where TPixel : unmanaged, IPixel @@ -417,9 +420,11 @@ internal sealed class GifEncoderCore // We can use the color data from the decoded metadata here. // We avoid dithering by default to preserve the original colors. ReadOnlyMemory palette = metadata.LocalColorTable.Value; - if (hasDuplicates && !metadata.HasTransparency) + if (requiresTransparency && !metadata.HasTransparency) { - // Duplicates were captured but the metadata does not have transparency. + // The frame was de-duplicated against the previous frame, replacing matching + // pixels with the transparent placeholder, but the metadata does not yet carry + // a transparent index. Reserve one so those pixels encode as transparent. metadata.HasTransparency = true; if (palette.Length < 256) @@ -480,7 +485,7 @@ internal sealed class GifEncoderCore metadata.TransparencyIndex = ClampIndex(derivedTransparencyIndex); - if (hasDuplicates) + if (requiresTransparency) { metadata.HasTransparency = true; } @@ -492,11 +497,19 @@ internal sealed class GifEncoderCore // Individual frames, though using the shared palette, can use a different transparent index // to represent transparency. - // A difference was captured but the metadata does not have transparency. - if (hasDuplicates && !metadata.HasTransparency) + // The frame was de-duplicated against the previous frame, replacing matching pixels with + // the transparent placeholder. When the whole frame matches there is no captured difference, + // yet every pixel is still a placeholder, so we must always reserve a transparent index here; + // otherwise the placeholder pixels are matched to the nearest (typically darkest) palette color. + if (requiresTransparency && !metadata.HasTransparency) { metadata.HasTransparency = true; - transparencyIndex = globalFrameQuantizer.Palette.Length; + + // Normally we pad one index past the palette so the (out of range) value is treated as + // transparent by decoders without growing the color table. A full 256-color palette leaves + // no room to pad within the 8-bit index space (index 256 wraps to 0 when written and exceeds + // the maximum GIF bit depth), so reuse the last in-range index for transparency instead. + transparencyIndex = Math.Min(globalFrameQuantizer.Palette.Length, byte.MaxValue); metadata.TransparencyIndex = ClampIndex(transparencyIndex); } diff --git a/src/ImageSharp/Processing/Processors/Dithering/ErrorDither.cs b/src/ImageSharp/Processing/Processors/Dithering/ErrorDither.cs index 3c0ebb7074..115db81448 100644 --- a/src/ImageSharp/Processing/Processors/Dithering/ErrorDither.cs +++ b/src/ImageSharp/Processing/Processors/Dithering/ErrorDither.cs @@ -202,6 +202,15 @@ public readonly partial struct ErrorDither : IDither, IEquatable, I ref TPixel pixel = ref rowSpan[targetX]; Vector4 result = pixel.ToVector4(); + // Do not diffuse error into fully transparent pixels. They carry no visible color + // (a decoder shows whatever is behind them), so perturbing them is meaningless and, + // for indexed transparency, nudges them off the exact transparent color so they are + // matched to the nearest opaque palette entry instead of being kept transparent. + if (result.W <= 0) + { + continue; + } + result += error * coefficient; pixel = TPixel.FromVector4(result); } diff --git a/src/ImageSharp/Processing/Processors/Dithering/OrderedDither.cs b/src/ImageSharp/Processing/Processors/Dithering/OrderedDither.cs index b0622cac58..cec4874f67 100644 --- a/src/ImageSharp/Processing/Processors/Dithering/OrderedDither.cs +++ b/src/ImageSharp/Processing/Processors/Dithering/OrderedDither.cs @@ -182,6 +182,16 @@ public readonly partial struct OrderedDither : IDither, IEquatable { Rgba32 rgba = source.ToRgba32(); + + // Leave fully transparent pixels untouched. They carry no visible color (a decoder shows + // whatever is behind them), so perturbing them is meaningless and, for indexed transparency, + // nudges them off the exact transparent color so they are matched to the nearest opaque + // palette entry instead of being kept transparent. + if (rgba.A == 0) + { + return source; + } + Unsafe.SkipInit(out Rgba32 attempt); float factor = spread * this.thresholdMatrix[y % this.modulusY, x % this.modulusX] * scale; diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs index b7bbe4971a..a465b073b5 100644 --- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs @@ -7,6 +7,7 @@ using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; using SixLabors.ImageSharp.Metadata; using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; @@ -349,7 +350,7 @@ public class GifEncoderTests public static string[] Animated => TestImages.Gif.Animated; - [Theory(Skip = "Enable for visual animated testing")] + [Theory]//(Skip = "Enable for visual animated testing")] [WithFileCollection(nameof(Animated), PixelTypes.Rgba32)] public void Encode_Animated_VisualTest(TestImageProvider provider) where TPixel : unmanaged, IPixel @@ -436,4 +437,21 @@ public class GifEncoderTests static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them. image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate); } + + [Theory] + [WithFile(TestImages.Gif.Issues.Issue3142, PixelTypes.Rgba32)] + public void GifEncoder_CanDecode_AndEncode_Issue3142(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(); + + // Save the image for visual inspection. + provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder() { Quantizer = KnownQuantizers.Wu }, "animated"); + + // Now compare the debug output with the reference output. + // We do this because the gif encoding is lossy and encoding will lead to differences in the 10s of percent. + // From the unencoded image, we can see that the image is visually the same. + static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them. + image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate); + } } diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index 1b6ae56850..2e17c82dc2 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -614,6 +614,7 @@ public static class TestImages public const string Issue2859_B = "Gif/issues/issue_2859_B.gif"; public const string Issue2953 = "Gif/issues/issue_2953.gif"; public const string Issue2980 = "Gif/issues/issue_2980.gif"; + public const string Issue3142 = "Gif/issues/issue_3142.gif"; } public static readonly string[] Animated = @@ -635,7 +636,8 @@ public static class TestImages Issues.BadDescriptorWidth, Issues.Issue1530, Bit18RGBCube, - Global256NoTrans + Global256NoTrans, + Issues.Issue3142 ]; } diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/00.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/00.gif new file mode 100644 index 0000000000..4a2dcec8d8 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/00.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba81598dc214845f18064f987a55eebdab0e53149df8fd67904733b42c760ad6 +size 7389 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/08.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/08.gif new file mode 100644 index 0000000000..4a2dcec8d8 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/08.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba81598dc214845f18064f987a55eebdab0e53149df8fd67904733b42c760ad6 +size 7389 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/104.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/104.gif new file mode 100644 index 0000000000..a28b1dd603 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/104.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6bd47e66b07bab7edf53a07631016f8334ba337243d6b8d61b0c8a3cf7f3f0d +size 7974 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/112.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/112.gif new file mode 100644 index 0000000000..a28b1dd603 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/112.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6bd47e66b07bab7edf53a07631016f8334ba337243d6b8d61b0c8a3cf7f3f0d +size 7974 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/16.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/16.gif new file mode 100644 index 0000000000..016f9f9b6e --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/16.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03d4e5cdaed2def942ca8480c9e45bb3d7914c6dbe3eac2b287afc8ff815b95d +size 8705 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/24.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/24.gif new file mode 100644 index 0000000000..285514e230 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/24.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ad084d64942ea1dec0c1b22a88400b47f14145d761aa22a8e7deb42b5bd9fb0 +size 13011 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/32.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/32.gif new file mode 100644 index 0000000000..34eff6c6c4 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/32.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:714cf4be4fc6c4164494ca1950796211577ab61cbd939f7cc53a36c3f7f742c5 +size 16457 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/40.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/40.gif new file mode 100644 index 0000000000..7a732b4fed --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/40.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb04c45837b763abf2c326b3e831c5b772853d50c0bfc85fb7ac7ca62361292d +size 16872 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/48.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/48.gif new file mode 100644 index 0000000000..7a732b4fed --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/48.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb04c45837b763abf2c326b3e831c5b772853d50c0bfc85fb7ac7ca62361292d +size 16872 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/56.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/56.gif new file mode 100644 index 0000000000..6d60d49813 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/56.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c68bad5aebb65e86e4295956b9f0bc21e181a6d8dadd9dbfbd5febf0a5ba0d8 +size 17414 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/64.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/64.gif new file mode 100644 index 0000000000..9af6451a67 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/64.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d0672248b0e45ea700aa0c484ed0e342284ce18cad1cdc233700614636bed11 +size 16556 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/72.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/72.gif new file mode 100644 index 0000000000..282ef1f8ab --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/72.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b12d34adf8c531c0d4ab02f7b57507a585bb1d31fb82d828c8c52732cef9f18 +size 15590 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/80.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/80.gif new file mode 100644 index 0000000000..282ef1f8ab --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/80.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b12d34adf8c531c0d4ab02f7b57507a585bb1d31fb82d828c8c52732cef9f18 +size 15590 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/88.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/88.gif new file mode 100644 index 0000000000..108da2ce30 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/88.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a31b9aee61fbc7d5dd5f163f2c81cb36140a05d788a7ed2d15de550c0fe13232 +size 14561 diff --git a/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/96.gif b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/96.gif new file mode 100644 index 0000000000..161443e4f8 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/GifEncoderTests/GifEncoder_CanDecode_AndEncode_Issue3142_Rgba32_issue_3142.gif/96.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bc49ba55ee33a3aff5069b7a6bf45cdb429ad691f8701093ad7fe04abe25ea1 +size 12156 diff --git a/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2469_Quantized_Encode_Artifacts_Rgba32_issue_2469.png b/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2469_Quantized_Encode_Artifacts_Rgba32_issue_2469.png index ecf0691cd5..c10227c6bd 100644 --- a/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2469_Quantized_Encode_Artifacts_Rgba32_issue_2469.png +++ b/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2469_Quantized_Encode_Artifacts_Rgba32_issue_2469.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:770061fbb29cd20bc700ce3fc57e38a758c632c3e89de51f5fbee3d5d522539e -size 912635 +oid sha256:b33a960891c6b1e9cc6ac2ddc3cf49d8f49e0c749dfa7a67db49354988b129f6 +size 935007 diff --git a/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2668_Quantized_Encode_Alpha_Rgba32_Issue_2668.png b/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2668_Quantized_Encode_Alpha_Rgba32_Issue_2668.png index b292138707..9c679acee9 100644 --- a/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2668_Quantized_Encode_Alpha_Rgba32_Issue_2668.png +++ b/tests/Images/External/ReferenceOutput/PngEncoderTests/Issue2668_Quantized_Encode_Alpha_Rgba32_Issue_2668.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df34f8f3640b145add4f24f8003c288fe7991373b079a87b4be90842e18c82ae -size 8236 +oid sha256:ad1630f3da7c2b997d04c75de2ac1465255c977454461f2fdc7026b3f87e11f1 +size 8232 diff --git a/tests/Images/Input/Gif/issues/issue_3142.gif b/tests/Images/Input/Gif/issues/issue_3142.gif new file mode 100644 index 0000000000..6053eebcf5 --- /dev/null +++ b/tests/Images/Input/Gif/issues/issue_3142.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:982c394dbee297bb3bc5cc532827742260acb6e5aa07e1dca3f0b17e57bc3bbe +size 386565