From 98ed0f10701ae8650e33d2daf0d024c44aecfa43 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sun, 9 Jul 2023 21:17:26 +1000 Subject: [PATCH] Refactor and fix gif encoder --- src/ImageSharp/Color/Color.Conversions.cs | 4 +- src/ImageSharp/Formats/Gif/GifEncoderCore.cs | 475 ++++++++++-------- src/ImageSharp/ImageFrame{TPixel}.cs | 10 + .../Quantization/EuclideanPixelMap{TPixel}.cs | 55 +- .../Quantization/PaletteQuantizer.cs | 15 +- .../Quantization/PaletteQuantizer{TPixel}.cs | 17 +- .../Formats/Gif/GifEncoderTests.cs | 2 +- 7 files changed, 366 insertions(+), 212 deletions(-) diff --git a/src/ImageSharp/Color/Color.Conversions.cs b/src/ImageSharp/Color/Color.Conversions.cs index bbb848867..309ab83ec 100644 --- a/src/ImageSharp/Color/Color.Conversions.cs +++ b/src/ImageSharp/Color/Color.Conversions.cs @@ -139,7 +139,7 @@ public readonly partial struct Color /// /// The . /// The . - public static explicit operator Vector4(Color color) => color.ToVector4(); + public static explicit operator Vector4(Color color) => color.ToScaledVector4(); /// /// Converts an to . @@ -228,7 +228,7 @@ public readonly partial struct Color } [MethodImpl(InliningOptions.ShortMethod)] - internal Vector4 ToVector4() + internal Vector4 ToScaledVector4() { if (this.boxedHighPrecisionPixel is null) { diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs index 45819b751..4ea3795d7 100644 --- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs @@ -2,10 +2,10 @@ // Licensed under the Six Labors Split License. using System.Buffers; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Intrinsics; -using System.Runtime.Intrinsics.Arm; using System.Runtime.Intrinsics.X86; using SixLabors.ImageSharp.Advanced; using SixLabors.ImageSharp.Memory; @@ -52,11 +52,6 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// private GifColorTableMode? colorTableMode; - /// - /// The number of bits requires to store the color palette. - /// - private int bitDepth; - /// /// The pixel sampling strategy for global quantization. /// @@ -65,7 +60,7 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// /// Initializes a new instance of the class. /// - /// The configuration which allows altering default behaviour or extending the library. + /// The configuration which allows altering default behavior or extending the library. /// The encoder with options. public GifEncoderCore(Configuration configuration, GifEncoder encoder) { @@ -96,8 +91,13 @@ internal sealed class GifEncoderCore : IImageEncoderInternals this.colorTableMode ??= gifMetadata.ColorTableMode; bool useGlobalTable = this.colorTableMode == GifColorTableMode.Global; - // Quantize the image returning a palette. - IndexedImageFrame? quantized; + // Quantize the first image frame returning a palette. + IndexedImageFrame? quantized = null; + + // Work out if there is an explicit transparent index set for the frame. We use that to ensure the + // correct value is set for the background index when quantizing. + image.Frames.RootFrame.Metadata.TryGetGifMetadata(out GifFrameMetadata? frameMetadata); + int transparencyIndex = GetTransparentIndex(quantized, frameMetadata); if (this.quantizer is null) { @@ -105,7 +105,7 @@ internal sealed class GifEncoderCore : IImageEncoderInternals if (gifMetadata.ColorTableMode == GifColorTableMode.Global && gifMetadata.GlobalColorTable?.Length > 0) { // We avoid dithering by default to preserve the original colors. - this.quantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, new() { Dither = null }); + this.quantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, new() { Dither = null }, transparencyIndex); } else { @@ -127,27 +127,24 @@ internal sealed class GifEncoderCore : IImageEncoderInternals } } - // Get the number of bits. - this.bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); - // Write the header. WriteHeader(stream); // Write the LSD. - image.Frames.RootFrame.Metadata.TryGetGifMetadata(out GifFrameMetadata? frameMetadata); - - int transparentIndex = GetTransparentIndex(quantized, frameMetadata); - byte backgroundIndex = unchecked((byte)transparentIndex); - if (transparentIndex == -1) + transparencyIndex = GetTransparentIndex(quantized, frameMetadata); + byte backgroundIndex = unchecked((byte)transparencyIndex); + if (transparencyIndex == -1) { backgroundIndex = gifMetadata.BackgroundColor; } - this.WriteLogicalScreenDescriptor(metadata, image.Width, image.Height, backgroundIndex, useGlobalTable, stream); + // Get the number of bits. + int bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); + this.WriteLogicalScreenDescriptor(metadata, image.Width, image.Height, backgroundIndex, useGlobalTable, bitDepth, stream); if (useGlobalTable) { - this.WriteColorTable(quantized, stream); + this.WriteColorTable(quantized, bitDepth, stream); } if (!this.skipMetadata) @@ -160,67 +157,69 @@ internal sealed class GifEncoderCore : IImageEncoderInternals this.WriteApplicationExtensions(stream, image.Frames.Count, gifMetadata.RepeatCount, xmpProfile); } - this.EncodeFrames(stream, image, backgroundIndex, quantized, quantized.Palette.ToArray()); + this.EncodeFirstFrame(stream, frameMetadata, quantized, transparencyIndex); + + // Capture the global palette for reuse on subsequent frames and cleanup the quantized frame. + TPixel[] globalPalette = image.Frames.Count == 1 ? Array.Empty() : quantized.Palette.ToArray(); + + quantized.Dispose(); + + this.EncodeAdditionalFrames(stream, image, globalPalette); stream.WriteByte(GifConstants.EndIntroducer); } - private void EncodeFrames( + private void EncodeAdditionalFrames( Stream stream, Image image, - byte backgroundIndex, - IndexedImageFrame quantized, - ReadOnlyMemory palette) + ReadOnlyMemory globalPalette) where TPixel : unmanaged, IPixel { + if (image.Frames.Count == 1) + { + return; + } + PaletteQuantizer paletteQuantizer = default; bool hasPaletteQuantizer = false; - // Create a buffer to store de-duplicated pixel indices for encoding. - // These are used when the color table is global but we must always allocate since we don't know - // in advance whether the frames will use a local palette. - Buffer2D indices = this.memoryAllocator.Allocate2D(image.Width, image.Height); - // Store the first frame as a reference for de-duplication comparison. - IndexedImageFrame previousQuantized = quantized; - for (int i = 0; i < image.Frames.Count; i++) + ImageFrame previousFrame = image.Frames.RootFrame; + + // This frame is reused to store de-duplicated pixel buffers. + // This is more expensive memory-wise than de-duplicating indexed buffer but allows us to deduplicate + // frames using both local and global palettes. + using ImageFrame encodingFrame = new(previousFrame.GetConfiguration(), previousFrame.Size()); + + for (int i = 1; i < image.Frames.Count; i++) { // Gather the metadata for this frame. - ImageFrame frame = image.Frames[i]; - ImageFrameMetadata metadata = frame.Metadata; - bool hasMetadata = metadata.TryGetGifMetadata(out GifFrameMetadata? frameMetadata); - bool useLocal = this.colorTableMode == GifColorTableMode.Local || (hasMetadata && frameMetadata!.ColorTableMode == GifColorTableMode.Local); + ImageFrame currentFrame = image.Frames[i]; + ImageFrameMetadata metadata = currentFrame.Metadata; + metadata.TryGetGifMetadata(out GifFrameMetadata? gifMetadata); + bool useLocal = this.colorTableMode == GifColorTableMode.Local || (gifMetadata?.ColorTableMode == GifColorTableMode.Local); if (!useLocal && !hasPaletteQuantizer && i > 0) { - // The palette quantizer can reuse the same pixel map across multiple frames - // since the palette is unchanging. This allows a reduction of memory usage across - // multi frame gifs using a global palette. + // The palette quantizer can reuse the same global pixel map across multiple frames since the palette is unchanging. + // This allows a reduction of memory usage across multi-frame gifs using a global palette + // and also allows use to reuse the cache from previous runs. + int transparencyIndex = gifMetadata?.HasTransparency == true ? gifMetadata.TransparencyIndex : -1; + paletteQuantizer = new(this.configuration, this.quantizer!.Options, globalPalette, transparencyIndex); hasPaletteQuantizer = true; - paletteQuantizer = new(this.configuration, this.quantizer!.Options, palette); } - this.EncodeFrame( + this.EncodeAdditionalFrame( stream, - frame, - i, + previousFrame, + currentFrame, + encodingFrame, useLocal, - frameMetadata, - indices, - backgroundIndex, - ref previousQuantized, - ref quantized!, - ref paletteQuantizer); - - // Clean up for the next run. - if (quantized != previousQuantized) - { - quantized.Dispose(); - } - } + gifMetadata, + paletteQuantizer); - previousQuantized.Dispose(); - indices.Dispose(); + previousFrame = currentFrame; + } if (hasPaletteQuantizer) { @@ -228,161 +227,175 @@ internal sealed class GifEncoderCore : IImageEncoderInternals } } - private void EncodeFrame( + private void EncodeFirstFrame( Stream stream, - ImageFrame frame, - int frameIndex, + GifFrameMetadata? metadata, + IndexedImageFrame quantized, + int transparencyIndex) + where TPixel : unmanaged, IPixel + { + this.WriteGraphicalControlExtension(metadata, transparencyIndex, stream); + + Buffer2DRegion region = ((IPixelSource)quantized).PixelBuffer.GetRegion(); + bool useLocal = this.colorTableMode == GifColorTableMode.Local || (metadata?.ColorTableMode == GifColorTableMode.Local); + int bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); + + this.WriteImageDescriptor(region.Rectangle, useLocal, bitDepth, stream); + + if (useLocal) + { + this.WriteColorTable(quantized, bitDepth, stream); + } + + this.WriteImageData(region, stream, quantized.Palette.Length, transparencyIndex); + } + + private void EncodeAdditionalFrame( + Stream stream, + ImageFrame previousFrame, + ImageFrame currentFrame, + ImageFrame encodingFrame, bool useLocal, GifFrameMetadata? metadata, - Buffer2D indices, - byte backgroundIndex, - ref IndexedImageFrame previousQuantized, - ref IndexedImageFrame quantized, - ref PaletteQuantizer globalPaletteQuantizer) + PaletteQuantizer globalPaletteQuantizer) where TPixel : unmanaged, IPixel { - // The first frame has already been quantized so we do not need to do so again. - int transparencyIndex = -1; - if (frameIndex > 0) + // Capture any explicit transparency index from the metadata. + // We use it to determine the value to use to replace duplicate pixels. + int transparencyIndex = metadata?.HasTransparency == true ? metadata.TransparencyIndex : -1; + Vector4 replacement = Vector4.Zero; + if (transparencyIndex >= 0) { if (useLocal) { - // Reassign using the current frame and details. if (metadata?.LocalColorTable?.Length > 0) { - // We can use the color data from the decoded metadata here. - // We avoid dithering by default to preserve the original colors. - PaletteQuantizer localQuantizer = new(metadata.LocalColorTable.Value, new() { Dither = null }); - using IQuantizer frameQuantizer = localQuantizer.CreatePixelSpecificQuantizer(this.configuration, localQuantizer.Options); - quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(frame, frame.Bounds()); - } - else - { - // We must quantize the frame to generate a local color table. - IQuantizer localQuantizer = this.hasQuantizer ? this.quantizer! : KnownQuantizers.Octree; - using IQuantizer frameQuantizer = localQuantizer.CreatePixelSpecificQuantizer(this.configuration, localQuantizer.Options); - quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(frame, frame.Bounds()); + ReadOnlySpan palette = metadata.LocalColorTable.Value.Span; + if (transparencyIndex < palette.Length) + { + replacement = palette[transparencyIndex].ToScaledVector4(); + } } } else { - // Quantize the image using the global palette. - quantized = globalPaletteQuantizer.QuantizeFrame(frame, frame.Bounds()); - transparencyIndex = GetTransparentIndex(quantized, metadata); - - byte replacementIndex = unchecked((byte)transparencyIndex); - if (transparencyIndex == -1) + ReadOnlySpan palette = globalPaletteQuantizer.Palette.Span; + if (transparencyIndex < palette.Length) { - replacementIndex = backgroundIndex; + replacement = palette[transparencyIndex].ToScaledVector4(); } - - // De-duplicate pixels comparing to the previous frame. - // Only global is supported for now as the color palettes as the operation required to compare - // and offset the index lookups is too expensive for local palettes. - DeDuplicatePixels(previousQuantized, quantized, indices, replacementIndex); } + } + + this.DeDuplicatePixels(previousFrame, currentFrame, encodingFrame, replacement); - this.bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); + IndexedImageFrame quantized; + if (useLocal) + { + // Reassign using the current frame and details. + if (metadata?.LocalColorTable?.Length > 0) + { + // 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; + PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex); + using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options); + quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, encodingFrame.Bounds()); + } + else + { + // We must quantize the frame to generate a local color table. + IQuantizer quantizer = this.hasQuantizer ? this.quantizer! : KnownQuantizers.Octree; + using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options); + quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, encodingFrame.Bounds()); + } } else { - transparencyIndex = GetTransparentIndex(quantized, metadata); + // Quantize the image using the global palette. + // Individual frames, though using the shared palette, can use a different transparent index to represent transparency. + globalPaletteQuantizer.SetTransparentIndex(transparencyIndex); + quantized = globalPaletteQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds()); } - this.WriteGraphicalControlExtension(metadata, transparencyIndex, stream); - - // Assign the correct buffer to compress. - // If we are using a local palette or it's the first run then we want to use the quantized frame. - Buffer2D buffer = useLocal || frameIndex == 0 ? ((IPixelSource)quantized).PixelBuffer : indices; + // Recalculate the transparency index as depending on the quantizer used could have a new value. + transparencyIndex = GetTransparentIndex(quantized, metadata); // Trim down the buffer to the minimum size required. - Buffer2DRegion region = TrimTransparentPixels(buffer, transparencyIndex); - this.WriteImageDescriptor(region.Rectangle, useLocal, stream); + // Buffer2DRegion region = ((IPixelSource)quantized).PixelBuffer.GetRegion(); + Buffer2DRegion region = TrimTransparentPixels(((IPixelSource)quantized).PixelBuffer, transparencyIndex); + + this.WriteGraphicalControlExtension(metadata, transparencyIndex, stream); + + int bitDepth = ColorNumerics.GetBitsNeededForColorDepth(quantized.Palette.Length); + this.WriteImageDescriptor(region.Rectangle, useLocal, bitDepth, stream); if (useLocal) { - this.WriteColorTable(quantized, stream); + this.WriteColorTable(quantized, bitDepth, stream); } - this.WriteImageData(region, stream); - - // Swap the buffers. - (quantized, previousQuantized) = (previousQuantized, quantized); + this.WriteImageData(region, stream, quantized.Palette.Length, transparencyIndex); } - private static void DeDuplicatePixels( - IndexedImageFrame background, - IndexedImageFrame source, - Buffer2D indices, - byte replacementIndex) + private void DeDuplicatePixels( + ImageFrame backgroundFrame, + ImageFrame sourceFrame, + ImageFrame resultFrame, + Vector4 replacement) where TPixel : unmanaged, IPixel { - for (int y = 0; y < background.Height; y++) + IMemoryOwner buffers = this.memoryAllocator.Allocate(backgroundFrame.Width * 3); + Span background = buffers.GetSpan()[..backgroundFrame.Width]; + Span source = buffers.GetSpan()[backgroundFrame.Width..]; + Span result = buffers.GetSpan()[(backgroundFrame.Width * 2)..]; + + // TODO: This algorithm is greedy and will always replace matching colors, however, theoretically, if the proceeding color + // is the same, but not replaced, you would actually be better of not replacing it since longer runs compress better. + // This would require a more complex algorithm. + for (int y = 0; y < backgroundFrame.Height; y++) { - ref byte backgroundRowBase = ref MemoryMarshal.GetReference(background.DangerousGetRowSpan(y)); - ref byte sourceRowBase = ref MemoryMarshal.GetReference(source.DangerousGetRowSpan(y)); - ref byte indicesRowBase = ref MemoryMarshal.GetReference(indices.DangerousGetRowSpan(y)); + PixelOperations.Instance.ToVector4(this.configuration, backgroundFrame.DangerousGetPixelRowMemory(y).Span, background, PixelConversionModifiers.Scale); + PixelOperations.Instance.ToVector4(this.configuration, sourceFrame.DangerousGetPixelRowMemory(y).Span, source, PixelConversionModifiers.Scale); + + ref Vector256 backgroundBase = ref Unsafe.As>(ref MemoryMarshal.GetReference(background)); + ref Vector256 sourceBase = ref Unsafe.As>(ref MemoryMarshal.GetReference(source)); + ref Vector256 resultBase = ref Unsafe.As>(ref MemoryMarshal.GetReference(result)); uint x = 0; - if (Avx2.IsSupported) + int remaining = background.Length; + if (Avx.IsSupported && remaining >= 2) { - int remaining = background.Width; - Vector256 transparentVector = Vector256.Create(replacementIndex); - while (remaining >= Vector256.Count) - { - Vector256 b = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref backgroundRowBase, x)); - Vector256 s = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref sourceRowBase, x)); - Vector256 m = Avx2.CompareEqual(b, s); - Vector256 i = Avx2.BlendVariable(s, transparentVector, m); - - Unsafe.WriteUnaligned(ref Unsafe.Add(ref indicesRowBase, x), i); + Vector256 replacement256 = Vector256.Create(replacement.X, replacement.Y, replacement.Z, replacement.W, replacement.X, replacement.Y, replacement.Z, replacement.W); - x += (uint)Vector256.Count; - remaining -= Vector256.Count; - } - } - else if (Sse2.IsSupported) - { - int remaining = background.Width; - Vector128 transparentVector = Vector128.Create(replacementIndex); - while (remaining >= Vector128.Count) + while (remaining >= 2) { - Vector128 b = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref backgroundRowBase, x)); - Vector128 s = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref sourceRowBase, x)); - Vector128 m = Sse2.CompareEqual(b, s); - Vector128 i = SimdUtils.HwIntrinsics.BlendVariable(s, transparentVector, m); + Vector256 b = Unsafe.Add(ref backgroundBase, x); + Vector256 s = Unsafe.Add(ref sourceBase, x); - Unsafe.WriteUnaligned(ref Unsafe.Add(ref indicesRowBase, x), i); + Vector256 m = Avx.CompareEqual(b, s).AsInt32(); - x += (uint)Vector128.Count; - remaining -= Vector128.Count; - } - } - else if (AdvSimd.Arm64.IsSupported) - { - int remaining = background.Width; - Vector128 transparentVector = Vector128.Create(replacementIndex); - while (remaining >= Vector128.Count) - { - Vector128 b = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref backgroundRowBase, x)); - Vector128 s = Unsafe.ReadUnaligned>(ref Unsafe.Add(ref sourceRowBase, x)); - Vector128 m = AdvSimd.CompareEqual(b, s); - Vector128 i = SimdUtils.HwIntrinsics.BlendVariable(s, transparentVector, m); + m = Avx2.HorizontalAdd(m, m); + m = Avx2.HorizontalAdd(m, m); + m = Avx2.CompareEqual(m, Vector256.Create(-4)); - Unsafe.WriteUnaligned(ref Unsafe.Add(ref indicesRowBase, x), i); + Unsafe.Add(ref resultBase, x) = Avx.BlendVariable(s, replacement256, m.AsSingle()); - x += (uint)Vector128.Count; - remaining -= Vector128.Count; + x++; + remaining -= 2; } } - for (; x < (uint)background.Width; x++) + for (int i = remaining; i >= 0; i--) { - byte b = Unsafe.Add(ref backgroundRowBase, x); - byte s = Unsafe.Add(ref sourceRowBase, x); - ref byte i = ref Unsafe.Add(ref indicesRowBase, x); - i = (b == s) ? replacementIndex : s; + x = (uint)i; + Vector4 b = Unsafe.Add(ref Unsafe.As, Vector4>(ref backgroundBase), x); + Vector4 s = Unsafe.Add(ref Unsafe.As, Vector4>(ref sourceBase), x); + ref Vector4 r = ref Unsafe.Add(ref Unsafe.As, Vector4>(ref resultBase), x); + r = (b == s) ? replacement : s; } + + PixelOperations.Instance.FromVector4Destructive(this.configuration, result, resultFrame.DangerousGetPixelRowMemory(y).Span, PixelConversionModifiers.Scale); } } @@ -395,33 +408,85 @@ internal sealed class GifEncoderCore : IImageEncoderInternals byte trimmableIndex = unchecked((byte)transparencyIndex); - int top = int.MaxValue; - int bottom = int.MinValue; + int top = int.MinValue; + int bottom = int.MaxValue; int left = int.MaxValue; int right = int.MinValue; + // Run through th buffer in a single pass. Use variables to track the min/max values. + int minY = -1; + bool isTransparentRow = true; for (int y = 0; y < buffer.Height; y++) { + isTransparentRow = true; Span rowSpan = buffer.DangerousGetRowSpan(y); + + // TODO: It may be possible to optimize this inner loop using SIMD. for (int x = 0; x < rowSpan.Length; x++) { if (rowSpan[x] != trimmableIndex) { - top = Math.Min(top, y); - bottom = Math.Max(bottom, y); + isTransparentRow = false; left = Math.Min(left, x); right = Math.Max(right, x); } } + + if (!isTransparentRow) + { + if (y == 0) + { + // First row is opaque. + // Capture to prevent over assignment when a match is found below. + top = 0; + } + + // The minimum top bounds have already been captured. + // Increment the bottom to include the current opaque row. + if (minY < 0 && top != 0) + { + // Increment to the first opaque row. + top++; + } + + minY = top; + bottom = y; + } + else + { + // We've yet to hit an opaque row. Capture the top position. + if (minY < 0) + { + top = Math.Max(top, y); + } + + bottom = Math.Min(bottom, y); + } } - if (top == int.MaxValue || bottom == int.MinValue) + if (left == int.MaxValue) { - // No valid rectangle found + left = 0; + } + + if (right == int.MinValue) + { + right = buffer.Width; + } + + if (top == bottom || left == right) + { + // The entire image is transparent. return buffer.GetRegion(); } - return buffer.GetRegion(Rectangle.FromLTRB(left, top, right, bottom)); + if (!isTransparentRow) + { + // Last row is opaque. + bottom = buffer.Height; + } + + return buffer.GetRegion(Rectangle.FromLTRB(left, top, Math.Min(right + 1, buffer.Width), Math.Min(bottom + 1, buffer.Height))); } /// @@ -433,29 +498,29 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// /// The . /// - private static int GetTransparentIndex(IndexedImageFrame quantized, GifFrameMetadata? metadata) + private static int GetTransparentIndex(IndexedImageFrame? quantized, GifFrameMetadata? metadata) where TPixel : unmanaged, IPixel { - // Transparent pixels are much more likely to be found at the end of a palette. - int index = -1; - ReadOnlySpan paletteSpan = quantized.Palette.Span; - - using IMemoryOwner rgbaOwner = quantized.Configuration.MemoryAllocator.Allocate(paletteSpan.Length); - Span rgbaSpan = rgbaOwner.GetSpan(); - PixelOperations.Instance.ToRgba32(quantized.Configuration, paletteSpan, rgbaSpan); - ref Rgba32 rgbaSpanRef = ref MemoryMarshal.GetReference(rgbaSpan); - - for (int i = rgbaSpan.Length - 1; i >= 0; i--) + if (metadata?.HasTransparency == true) { - if (Unsafe.Add(ref rgbaSpanRef, (uint)i).Equals(default)) - { - index = i; - } + return metadata.TransparencyIndex; } - if (metadata?.HasTransparency == true && index == -1) + int index = -1; + if (quantized != null) { - index = metadata.TransparencyIndex; + TPixel transparentPixel = default; + transparentPixel.FromScaledVector4(Vector4.Zero); + ReadOnlySpan palette = quantized.Palette.Span; + + // Transparent pixels are much more likely to be found at the end of a palette. + for (int i = palette.Length - 1; i >= 0; i--) + { + if (palette[i].Equals(transparentPixel)) + { + index = i; + } + } } return index; @@ -476,6 +541,7 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// The image height. /// The index to set the default background index to. /// Whether to use a global or local color table. + /// The bit depth of the color palette. /// The stream to write to. private void WriteLogicalScreenDescriptor( ImageMetadata metadata, @@ -483,9 +549,10 @@ internal sealed class GifEncoderCore : IImageEncoderInternals int height, byte backgroundIndex, bool useGlobalTable, + int bitDepth, Stream stream) { - byte packedValue = GifLogicalScreenDescriptor.GetPackedValue(useGlobalTable, this.bitDepth - 1, false, this.bitDepth - 1); + byte packedValue = GifLogicalScreenDescriptor.GetPackedValue(useGlobalTable, bitDepth - 1, false, bitDepth - 1); // The Pixel Aspect Ratio is defined to be the quotient of the pixel's // width over its height. The value range in this field allows @@ -617,10 +684,11 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// The stream to write to. private void WriteGraphicalControlExtension(GifFrameMetadata? metadata, int transparencyIndex, Stream stream) { + GifFrameMetadata? data = metadata; bool hasTransparency; if (metadata is null) { - metadata = new(); + data = new(); hasTransparency = transparencyIndex >= 0; } else @@ -629,12 +697,12 @@ internal sealed class GifEncoderCore : IImageEncoderInternals } byte packedValue = GifGraphicControlExtension.GetPackedValue( - disposalMethod: metadata!.DisposalMethod, + disposalMethod: data!.DisposalMethod, transparencyFlag: hasTransparency); GifGraphicControlExtension extension = new( packed: packedValue, - delayTime: (ushort)metadata.FrameDelay, + delayTime: (ushort)data.FrameDelay, transparencyIndex: hasTransparency ? unchecked((byte)transparencyIndex) : byte.MinValue); this.WriteExtension(extension, stream); @@ -684,14 +752,15 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// /// The frame location and size. /// Whether to use the global color table. + /// The bit depth of the color palette. /// The stream to write to. - private void WriteImageDescriptor(Rectangle rectangle, bool hasColorTable, Stream stream) + private void WriteImageDescriptor(Rectangle rectangle, bool hasColorTable, int bitDepth, Stream stream) { byte packedValue = GifImageDescriptor.GetPackedValue( localColorTableFlag: hasColorTable, interfaceFlag: false, sortFlag: false, - localColorTableSize: this.bitDepth - 1); + localColorTableSize: bitDepth - 1); GifImageDescriptor descriptor = new( left: (ushort)rectangle.X, @@ -711,12 +780,13 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// /// The pixel format. /// The to encode. + /// The bit depth of the color palette. /// The stream to write to. - private void WriteColorTable(IndexedImageFrame image, Stream stream) + private void WriteColorTable(IndexedImageFrame image, int bitDepth, Stream stream) where TPixel : unmanaged, IPixel { // The maximum number of colors for the bit depth - int colorTableLength = ColorNumerics.GetColorCountForBitDepth(this.bitDepth) * Unsafe.SizeOf(); + int colorTableLength = ColorNumerics.GetColorCountForBitDepth(bitDepth) * Unsafe.SizeOf(); using IMemoryOwner colorTable = this.memoryAllocator.Allocate(colorTableLength, AllocationOptions.Clean); Span colorTableSpan = colorTable.GetSpan(); @@ -735,9 +805,18 @@ internal sealed class GifEncoderCore : IImageEncoderInternals /// /// The containing indexed pixels. /// The stream to write to. - private void WriteImageData(Buffer2DRegion indices, Stream stream) + /// The length of the frame color palette. + /// The index of the color used to represent transparency. + private void WriteImageData(Buffer2DRegion indices, Stream stream, int paletteLength, int transparencyIndex) { - using LzwEncoder encoder = new(this.memoryAllocator, (byte)this.bitDepth); + // Pad the bit depth when required for encoding the image data. + // This is a common trick which allows to use out of range indexes for transparency and avoid allocating a larger color palette + // as decoders skip indexes that are out of range. + int padding = transparencyIndex >= paletteLength + ? 1 + : 0; + + using LzwEncoder encoder = new(this.memoryAllocator, ColorNumerics.GetBitsNeededForColorDepth(paletteLength + padding)); encoder.Encode(indices, stream); } } diff --git a/src/ImageSharp/ImageFrame{TPixel}.cs b/src/ImageSharp/ImageFrame{TPixel}.cs index 3734402d3..0e7eef11e 100644 --- a/src/ImageSharp/ImageFrame{TPixel}.cs +++ b/src/ImageSharp/ImageFrame{TPixel}.cs @@ -21,6 +21,16 @@ public sealed class ImageFrame : ImageFrame, IPixelSource { private bool isDisposed; + /// + /// Initializes a new instance of the class. + /// + /// The configuration which allows altering default behaviour or extending the library. + /// The of the frame. + internal ImageFrame(Configuration configuration, Size size) + : this(configuration, size.Width, size.Height, new ImageFrameMetadata()) + { + } + /// /// Initializes a new instance of the class. /// diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs index 0c6ba7ddc..e767ac4f7 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Buffers; +using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using SixLabors.ImageSharp.Memory; @@ -14,13 +15,14 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization; /// /// The pixel format. /// -/// This class is not threadsafe and should not be accessed in parallel. +/// This class is not thread safe and should not be accessed in parallel. /// Doing so will result in non-idempotent results. /// internal sealed class EuclideanPixelMap : IDisposable where TPixel : unmanaged, IPixel { private Rgba32[] rgbaPalette; + private int transparentIndex; /// /// Do not make this readonly! Struct value would be always copied on non-readonly method calls. @@ -34,12 +36,24 @@ internal sealed class EuclideanPixelMap : IDisposable /// The configuration. /// The color palette to map from. public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette) + : this(configuration, palette, -1) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The configuration. + /// The color palette to map from. + /// An explicit index at which to match transparent pixels. + public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette, int transparentIndex = -1) { this.configuration = configuration; this.Palette = palette; this.rgbaPalette = new Rgba32[palette.Length]; this.cache = new ColorDistanceCache(configuration.MemoryAllocator); PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette); + this.transparentIndex = transparentIndex; } /// @@ -91,16 +105,43 @@ internal sealed class EuclideanPixelMap : IDisposable this.cache.Clear(); } + /// + /// Allows setting the transparent index after construction. + /// + /// An explicit index at which to match transparent pixels. + public void SetTransparentIndex(int index) => this.transparentIndex = index; + [MethodImpl(InliningOptions.ShortMethod)] private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match) { // Loop through the palette and find the nearest match. int index = 0; float leastDistance = float.MaxValue; + + if (this.transparentIndex >= 0 && rgba == default) + { + // We have explicit instructions. No need to search. + index = this.transparentIndex; + this.cache.Add(rgba, (byte)index); + + if (index >= 0 && index < this.Palette.Length) + { + match = Unsafe.Add(ref paletteRef, (uint)index); + } + else + { + Unsafe.SkipInit(out TPixel pixel); + pixel.FromScaledVector4(Vector4.Zero); + match = pixel; + } + + return index; + } + for (int i = 0; i < this.rgbaPalette.Length; i++) { Rgba32 candidate = this.rgbaPalette[i]; - int distance = DistanceSquared(rgba, candidate); + float distance = DistanceSquared(rgba, candidate); // If it's an exact match, exit the loop if (distance == 0) @@ -130,12 +171,12 @@ internal sealed class EuclideanPixelMap : IDisposable /// The second point. /// The distance squared. [MethodImpl(InliningOptions.ShortMethod)] - private static int DistanceSquared(Rgba32 a, Rgba32 b) + private static float DistanceSquared(Rgba32 a, Rgba32 b) { - int deltaR = a.R - b.R; - int deltaG = a.G - b.G; - int deltaB = a.B - b.B; - int deltaA = a.A - b.A; + float deltaR = a.R - b.R; + float deltaG = a.G - b.G; + float deltaB = a.B - b.B; + float deltaA = a.A - b.A; return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA); } diff --git a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs index fe4af9005..acd179ffc 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs @@ -11,6 +11,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization; public class PaletteQuantizer : IQuantizer { private readonly ReadOnlyMemory colorPalette; + private readonly int transparentIndex; /// /// Initializes a new instance of the class. @@ -27,12 +28,24 @@ public class PaletteQuantizer : IQuantizer /// The color palette. /// The quantizer options defining quantization rules. public PaletteQuantizer(ReadOnlyMemory palette, QuantizerOptions options) + : this(palette, options, -1) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The color palette. + /// The quantizer options defining quantization rules. + /// An explicit index at which to match transparent pixels. + internal PaletteQuantizer(ReadOnlyMemory palette, QuantizerOptions options, int transparentIndex) { Guard.MustBeGreaterThan(palette.Length, 0, nameof(palette)); Guard.NotNull(options, nameof(options)); this.colorPalette = palette; this.Options = options; + this.transparentIndex = transparentIndex; } /// @@ -52,6 +65,6 @@ public class PaletteQuantizer : IQuantizer // Always use the palette length over options since the palette cannot be reduced. TPixel[] palette = new TPixel[this.colorPalette.Length]; Color.ToPixel(this.colorPalette.Span, palette.AsSpan()); - return new PaletteQuantizer(configuration, options, palette); + return new PaletteQuantizer(configuration, options, palette, this.transparentIndex); } } diff --git a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs index 86db9f6f0..3df80ea9b 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs @@ -25,18 +25,23 @@ internal readonly struct PaletteQuantizer : IQuantizer /// /// Initializes a new instance of the struct. /// - /// The configuration which allows altering default behaviour or extending the library. + /// The configuration which allows altering default behavior or extending the library. /// The quantizer options defining quantization rules. /// The palette to use. + /// An explicit index at which to match transparent pixels. [MethodImpl(InliningOptions.ShortMethod)] - public PaletteQuantizer(Configuration configuration, QuantizerOptions options, ReadOnlyMemory palette) + public PaletteQuantizer( + Configuration configuration, + QuantizerOptions options, + ReadOnlyMemory palette, + int transparentIndex) { Guard.NotNull(configuration, nameof(configuration)); Guard.NotNull(options, nameof(options)); this.Configuration = configuration; this.Options = options; - this.pixelMap = new EuclideanPixelMap(configuration, palette); + this.pixelMap = new EuclideanPixelMap(configuration, palette, transparentIndex); } /// @@ -59,6 +64,12 @@ internal readonly struct PaletteQuantizer : IQuantizer { } + /// + /// Allows setting the transparent index after construction. + /// + /// An explicit index at which to match transparent pixels. + public void SetTransparentIndex(int index) => this.pixelMap.SetTransparentIndex(index); + /// [MethodImpl(InliningOptions.ShortMethod)] public readonly byte GetQuantizedColor(TPixel color, out TPixel match) diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs index 43114fa7e..31001e31b 100644 --- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs @@ -38,7 +38,7 @@ public class GifEncoderTests [Theory] [WithTestPatternImages(100, 100, TestPixelTypes, false)] - [WithTestPatternImages(100, 100, TestPixelTypes, false)] + [WithTestPatternImages(100, 100, TestPixelTypes, true)] public void EncodeGeneratedPatterns(TestImageProvider provider, bool limitAllocationBuffer) where TPixel : unmanaged, IPixel {