From 6322bcb7d5b7e0563bffd20424e6f43d44a05d81 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 27 Mar 2025 09:38:40 +1000 Subject: [PATCH] Use different cache types and begin to make quantizers own clearing pixels. --- src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs | 7 +- src/ImageSharp/Formats/EncodingUtilities.cs | 119 +++- src/ImageSharp/Formats/Gif/GifEncoderCore.cs | 28 +- .../Formats/IAnimatedImageEncoder.cs | 6 +- .../Formats/ISpecializedDecoderOptions.cs | 2 +- src/ImageSharp/Formats/Png/PngEncoder.cs | 5 - src/ImageSharp/Formats/Png/PngEncoderCore.cs | 23 +- src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs | 4 +- src/ImageSharp/Formats/Tga/TgaEncoderCore.cs | 4 +- .../Formats/Tiff/TiffEncoderCore.cs | 4 +- src/ImageSharp/IndexedImageFrame{TPixel}.cs | 4 +- ...nownTypes.cs => ErrorDither.KnownTypes.cs} | 0 .../PaletteDitherProcessor{TPixel}.cs | 4 +- .../Quantization/ColorMatchingMode.cs | 28 + .../EuclideanPixelMap{TPixel,TCache}.cs | 184 ++++++ .../Quantization/EuclideanPixelMap{TPixel}.cs | 545 ------------------ .../Quantization/IColorIndexCache.cs | 491 ++++++++++++++++ .../Processors/Quantization/IQuantizer.cs | 6 +- .../Quantization/IQuantizer{TPixel}.cs | 35 +- .../IQuantizingPixelRowDelegate{TPixel}.cs | 27 + .../Quantization/OctreeQuantizer{TPixel}.cs | 121 ++-- .../Quantization/PaletteQuantizer{TPixel}.cs | 21 +- .../Quantization/QuantizerOptions.cs | 11 + .../Quantization/QuantizerUtilities.cs | 365 ++++++++++-- .../Quantization/WuQuantizer{TPixel}.cs | 118 ++-- .../Formats/Gif/GifEncoderTests.cs | 2 +- .../Formats/Png/PngEncoderTests.cs | 4 +- 27 files changed, 1363 insertions(+), 805 deletions(-) rename src/ImageSharp/Processing/Processors/Dithering/{ErroDither.KnownTypes.cs => ErrorDither.KnownTypes.cs} (100%) create mode 100644 src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs create mode 100644 src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs delete mode 100644 src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs create mode 100644 src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs create mode 100644 src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs diff --git a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs index 321a559b1e..6a88f080d9 100644 --- a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs @@ -362,10 +362,13 @@ internal sealed class BmpEncoderCore ImageFrame? clonedFrame = null; try { - if (EncodingUtilities.ShouldClearTransparentPixels(this.transparentColorMode)) + // No need to clone when quantizing. The quantizer will do it for us. + // TODO: We should really try to avoid the clone entirely. + int bpp = this.bitsPerPixel != null ? (int)this.bitsPerPixel : 32; + if (bpp > 8 && EncodingUtilities.ShouldReplaceTransparentPixels(this.transparentColorMode)) { clonedFrame = image.Frames.RootFrame.Clone(); - EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent); + EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent); } ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame; diff --git a/src/ImageSharp/Formats/EncodingUtilities.cs b/src/ImageSharp/Formats/EncodingUtilities.cs index db951b1c33..130dd567eb 100644 --- a/src/ImageSharp/Formats/EncodingUtilities.cs +++ b/src/ImageSharp/Formats/EncodingUtilities.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Numerics; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Runtime.Intrinsics; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -15,50 +16,52 @@ namespace SixLabors.ImageSharp.Formats; /// internal static class EncodingUtilities { - public static bool ShouldClearTransparentPixels(TransparentColorMode mode) + /// + /// Determines if transparent pixels can be replaced based on the specified color mode and pixel type. + /// + /// The type of the pixel. + /// Indicates the color mode used to assess the ability to replace transparent pixels. + /// Returns true if transparent pixels can be replaced; otherwise, false. + public static bool ShouldReplaceTransparentPixels(TransparentColorMode mode) where TPixel : unmanaged, IPixel - => mode == TransparentColorMode.Clear && - TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated; + => mode == TransparentColorMode.Clear && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated; /// - /// Convert transparent pixels, to pixels represented by , which can yield - /// to better compression in some cases. + /// Replaces transparent pixels with pixels represented by . /// /// The type of the pixel. /// The where the transparent pixels will be changed. /// The color to replace transparent pixels with. - public static void ClearTransparentPixels(ImageFrame frame, Color color) + public static void ReplaceTransparentPixels(ImageFrame frame, Color color) where TPixel : unmanaged, IPixel - => ClearTransparentPixels(frame.Configuration, frame.PixelBuffer, color); + => ReplaceTransparentPixels(frame.Configuration, frame.PixelBuffer, color); /// - /// Convert transparent pixels, to pixels represented by , which can yield - /// to better compression in some cases. + /// Replaces transparent pixels with pixels represented by . /// /// The type of the pixel. /// The configuration. /// The where the transparent pixels will be changed. /// The color to replace transparent pixels with. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static void ClearTransparentPixels( + public static void ReplaceTransparentPixels( Configuration configuration, Buffer2D buffer, Color color) where TPixel : unmanaged, IPixel { Buffer2DRegion region = buffer.GetRegion(); - ClearTransparentPixels(configuration, in region, color); + ReplaceTransparentPixels(configuration, in region, color); } /// - /// Convert transparent pixels, to pixels represented by , which can yield - /// to better compression in some cases. + /// Replaces transparent pixels with pixels represented by . /// /// The type of the pixel. /// The configuration. /// The where the transparent pixels will be changed. /// The color to replace transparent pixels with. - public static void ClearTransparentPixels( + public static void ReplaceTransparentPixels( Configuration configuration, in Buffer2DRegion region, Color color) @@ -71,20 +74,92 @@ internal static class EncodingUtilities { Span span = region.DangerousGetRowSpan(y); PixelOperations.Instance.ToVector4(configuration, span, vectorsSpan, PixelConversionModifiers.Scale); - ClearTransparentPixelRow(vectorsSpan, replacement); + ReplaceTransparentPixels(vectorsSpan, replacement); PixelOperations.Instance.FromVector4Destructive(configuration, vectorsSpan, span, PixelConversionModifiers.Scale); } } - private static void ClearTransparentPixelRow(Span vectorsSpan, Vector4 replacement) + /// + /// Replaces transparent pixels with pixels represented by . + /// + /// A span of color vectors that will be checked for transparency and potentially modified. + /// A color vector that will replace transparent pixels when the alpha value is below the specified threshold. + public static void ReplaceTransparentPixels(Span source, Vector4 replacement) { - if (Vector128.IsHardwareAccelerated) + if (Vector512.IsHardwareAccelerated && source.Length >= 4) + { + Vector128 replacement128 = replacement.AsVector128(); + Vector256 replacement256 = Vector256.Create(replacement128, replacement128); + Vector512 replacement512 = Vector512.Create(replacement256, replacement256); + + Span> source512 = MemoryMarshal.Cast>(source); + for (int i = 0; i < source512.Length; i++) + { + ref Vector512 v = ref source512[i]; + + // Do `vector < threshold` + Vector512 mask = Vector512.Equals(v, Vector512.Zero); + + // Replicate the result for W to all elements (is AllBitsSet if the W was 0 and Zero otherwise) + mask = Vector512.Shuffle(mask, Vector512.Create(3, 3, 3, 3, 7, 7, 7, 7, 11, 11, 11, 11, 15, 15, 15, 15)); + + // Use the mask to select the replacement vector + // (replacement & mask) | (v512 & ~mask) + v = Vector512.ConditionalSelect(mask, replacement512, v); + } + + int m = Numerics.Modulo4(source.Length); + if (m != 0) + { + for (int i = source.Length - m; i < source.Length; i++) + { + if (source[i].W == 0) + { + source[i] = replacement; + } + } + } + } + else if (Vector256.IsHardwareAccelerated && source.Length >= 2) + { + Vector128 replacement128 = replacement.AsVector128(); + Vector256 replacement256 = Vector256.Create(replacement128, replacement128); + + Span> source256 = MemoryMarshal.Cast>(source); + for (int i = 0; i < source256.Length; i++) + { + ref Vector256 v = ref source256[i]; + + // Do `vector < threshold` + Vector256 mask = Vector256.Equals(v, Vector256.Zero); + + // Replicate the result for W to all elements (is AllBitsSet if the W was 0 and Zero otherwise) + mask = Vector256.Shuffle(mask, Vector256.Create(3, 3, 3, 3, 7, 7, 7, 7)); + + // Use the mask to select the replacement vector + // (replacement & mask) | (v256 & ~mask) + v = Vector256.ConditionalSelect(mask, replacement256, v); + } + + int m = Numerics.Modulo2(source.Length); + if (m != 0) + { + for (int i = source.Length - m; i < source.Length; i++) + { + if (source[i].W == 0) + { + source[i] = replacement; + } + } + } + } + else if (Vector128.IsHardwareAccelerated) { Vector128 replacement128 = replacement.AsVector128(); - for (int i = 0; i < vectorsSpan.Length; i++) + for (int i = 0; i < source.Length; i++) { - ref Vector4 v = ref vectorsSpan[i]; + ref Vector4 v = ref source[i]; Vector128 v128 = v.AsVector128(); // Do `vector == 0` @@ -100,11 +175,11 @@ internal static class EncodingUtilities } else { - for (int i = 0; i < vectorsSpan.Length; i++) + for (int i = 0; i < source.Length; i++) { - if (vectorsSpan[i].W == 0F) + if (source[i].W == 0F) { - vectorsSpan[i] = replacement; + source[i] = replacement; } } } diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs index a4830d7793..eff33f2d1c 100644 --- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs +++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs @@ -151,35 +151,20 @@ internal sealed class GifEncoderCore Color background = Color.Transparent; using (IQuantizer frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer(this.configuration)) { - ImageFrame? clonedFrame = null; - Configuration configuration = this.configuration; TransparentColorMode mode = this.transparentColorMode; IPixelSamplingStrategy strategy = this.pixelSamplingStrategy; - if (EncodingUtilities.ShouldClearTransparentPixels(mode)) - { - clonedFrame = image.Frames.RootFrame.Clone(); - - GifFrameMetadata frameMeta = clonedFrame.Metadata.GetGifMetadata(); - if (frameMeta.DisposalMode == FrameDisposalMode.RestoreToBackground) - { - background = this.backgroundColor ?? Color.Transparent; - } - - EncodingUtilities.ClearTransparentPixels(clonedFrame, background); - } - - ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame; + ImageFrame encodingFrame = image.Frames.RootFrame; if (useGlobalTableForFirstFrame) { if (useGlobalTable) { - frameQuantizer.BuildPalette(configuration, mode, strategy, image, background); + frameQuantizer.BuildPalette(mode, strategy, image); quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds); } else { - frameQuantizer.BuildPalette(configuration, mode, strategy, encodingFrame, background); + frameQuantizer.BuildPalette(mode, strategy, encodingFrame); quantized = frameQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds); } } @@ -195,8 +180,6 @@ internal sealed class GifEncoderCore frameMetadata.HasTransparency ? frameMetadata.TransparencyIndex : -1, background); } - - clonedFrame?.Dispose(); } // Write the header. @@ -403,11 +386,6 @@ internal sealed class GifEncoderCore background, true); - if (EncodingUtilities.ShouldClearTransparentPixels(this.transparentColorMode)) - { - EncodingUtilities.ClearTransparentPixels(encodingFrame, background); - } - using IndexedImageFrame quantized = this.QuantizeAdditionalFrameAndUpdateMetadata( encodingFrame, bounds, diff --git a/src/ImageSharp/Formats/IAnimatedImageEncoder.cs b/src/ImageSharp/Formats/IAnimatedImageEncoder.cs index d2c3ad6907..26f2114df2 100644 --- a/src/ImageSharp/Formats/IAnimatedImageEncoder.cs +++ b/src/ImageSharp/Formats/IAnimatedImageEncoder.cs @@ -14,17 +14,17 @@ public interface IAnimatedImageEncoder /// as well as the transparent pixels of the first frame. /// The background color is also used when a frame disposal mode is . /// - Color? BackgroundColor { get; } + public Color? BackgroundColor { get; } /// /// Gets the number of times any animation is repeated in supported encoders. /// - ushort? RepeatCount { get; } + public ushort? RepeatCount { get; } /// /// Gets a value indicating whether the root frame is shown as part of the animated sequence in supported encoders. /// - bool? AnimateRootFrame { get; } + public bool? AnimateRootFrame { get; } } /// diff --git a/src/ImageSharp/Formats/ISpecializedDecoderOptions.cs b/src/ImageSharp/Formats/ISpecializedDecoderOptions.cs index e0a4c9b62c..881b5bcd44 100644 --- a/src/ImageSharp/Formats/ISpecializedDecoderOptions.cs +++ b/src/ImageSharp/Formats/ISpecializedDecoderOptions.cs @@ -11,5 +11,5 @@ public interface ISpecializedDecoderOptions /// /// Gets the general decoder options. /// - DecoderOptions GeneralOptions { get; init; } + public DecoderOptions GeneralOptions { get; init; } } diff --git a/src/ImageSharp/Formats/Png/PngEncoder.cs b/src/ImageSharp/Formats/Png/PngEncoder.cs index 1032f88529..b6031c1640 100644 --- a/src/ImageSharp/Formats/Png/PngEncoder.cs +++ b/src/ImageSharp/Formats/Png/PngEncoder.cs @@ -41,11 +41,6 @@ public class PngEncoder : QuantizingAnimatedImageEncoder /// The gamma value of the image. public float? Gamma { get; init; } - /// - /// Gets the transparency threshold. - /// - public byte Threshold { get; init; } = byte.MaxValue; - /// /// Gets a value indicating whether this instance should write an Adam7 interlaced image. /// diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs index fea9e801c6..c05c7e8289 100644 --- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs @@ -189,12 +189,15 @@ internal sealed class PngEncoderCore : IDisposable { int currentFrameIndex = 0; - bool clearTransparency = EncodingUtilities.ShouldClearTransparentPixels(this.encoder.TransparentColorMode); - if (clearTransparency) + bool clearTransparency = EncodingUtilities.ShouldReplaceTransparentPixels(this.encoder.TransparentColorMode); + + // No need to clone when quantizing. The quantizer will do it for us. + // TODO: We should really try to avoid the clone entirely. + if (clearTransparency && this.colorType is not PngColorType.Palette) { currentFrame = clonedFrame = currentFrame.Clone(); currentFrameRegion = currentFrame.PixelBuffer.GetRegion(); - EncodingUtilities.ClearTransparentPixels(this.configuration, in currentFrameRegion, this.backgroundColor.Value); + EncodingUtilities.ReplaceTransparentPixels(this.configuration, in currentFrameRegion, this.backgroundColor.Value); } // Do not move this. We require an accurate bit depth for the header chunk. @@ -286,6 +289,7 @@ internal sealed class PngEncoderCore : IDisposable ImageFrame? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null; frameMetadata = currentFrame.Metadata.GetPngMetadata(); + bool blend = frameMetadata.BlendMode == FrameBlendMode.Over; Color background = frameMetadata.DisposalMode == FrameDisposalMode.RestoreToBackground ? this.backgroundColor.Value @@ -301,9 +305,9 @@ internal sealed class PngEncoderCore : IDisposable background, blend); - if (clearTransparency) + if (clearTransparency && this.colorType is not PngColorType.Palette) { - EncodingUtilities.ClearTransparentPixels(encodingFrame, background); + EncodingUtilities.ReplaceTransparentPixels(encodingFrame, background); } // Each frame control sequence number must be incremented by the number of frame data chunks that follow. @@ -779,11 +783,6 @@ internal sealed class PngEncoderCore : IDisposable byte alpha = rgba.A; Unsafe.Add(ref colorTableRef, (uint)i) = rgba.Rgb; - if (alpha > this.encoder.Threshold) - { - alpha = byte.MaxValue; - } - hasAlpha = hasAlpha || alpha < byte.MaxValue; Unsafe.Add(ref alphaTableRef, (uint)i) = alpha; } @@ -1596,11 +1595,9 @@ internal sealed class PngEncoderCore : IDisposable } frameQuantizer.BuildPalette( - this.configuration, encoder.TransparentColorMode, encoder.PixelSamplingStrategy, - image, - backgroundColor); + image); return frameQuantizer.QuantizeFrame(frame, bounds); } diff --git a/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs b/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs index 872cec3fd0..916830273a 100644 --- a/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs +++ b/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs @@ -90,10 +90,10 @@ internal class QoiEncoderCore ImageFrame? clonedFrame = null; try { - if (EncodingUtilities.ShouldClearTransparentPixels(this.encoder.TransparentColorMode)) + if (EncodingUtilities.ShouldReplaceTransparentPixels(this.encoder.TransparentColorMode)) { clonedFrame = image.Frames.RootFrame.Clone(); - EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent); + EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent); } ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame; diff --git a/src/ImageSharp/Formats/Tga/TgaEncoderCore.cs b/src/ImageSharp/Formats/Tga/TgaEncoderCore.cs index e2ea9c4fe7..17259629ae 100644 --- a/src/ImageSharp/Formats/Tga/TgaEncoderCore.cs +++ b/src/ImageSharp/Formats/Tga/TgaEncoderCore.cs @@ -110,10 +110,10 @@ internal sealed class TgaEncoderCore ImageFrame? clonedFrame = null; try { - if (EncodingUtilities.ShouldClearTransparentPixels(this.transparentColorMode)) + if (EncodingUtilities.ShouldReplaceTransparentPixels(this.transparentColorMode)) { clonedFrame = image.Frames.RootFrame.Clone(); - EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent); + EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent); } ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame; diff --git a/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs index da55ef9f9b..1e12781a99 100644 --- a/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs @@ -146,10 +146,10 @@ internal sealed class TiffEncoderCore { cancellationToken.ThrowIfCancellationRequested(); - if (EncodingUtilities.ShouldClearTransparentPixels(this.transparentColorMode)) + if (EncodingUtilities.ShouldReplaceTransparentPixels(this.transparentColorMode)) { clonedFrame = frame.Clone(); - EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent); + EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent); } ImageFrame encodingFrame = clonedFrame ?? frame; diff --git a/src/ImageSharp/IndexedImageFrame{TPixel}.cs b/src/ImageSharp/IndexedImageFrame{TPixel}.cs index 49c9e33eb1..a88cdb524e 100644 --- a/src/ImageSharp/IndexedImageFrame{TPixel}.cs +++ b/src/ImageSharp/IndexedImageFrame{TPixel}.cs @@ -25,7 +25,7 @@ public sealed class IndexedImageFrame : IPixelSource, IDisposable /// 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 frame width. /// The frame height. @@ -49,7 +49,7 @@ public sealed class IndexedImageFrame : IPixelSource, IDisposable } /// - /// Gets the configuration which allows altering default behaviour or extending the library. + /// Gets the configuration which allows altering default behavior or extending the library. /// public Configuration Configuration { get; } diff --git a/src/ImageSharp/Processing/Processors/Dithering/ErroDither.KnownTypes.cs b/src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs similarity index 100% rename from src/ImageSharp/Processing/Processors/Dithering/ErroDither.KnownTypes.cs rename to src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs diff --git a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs index 7e672393c7..bb9b4d8c8f 100644 --- a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs @@ -80,7 +80,7 @@ internal sealed class PaletteDitherProcessor : ImageProcessor Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")] internal readonly struct DitherProcessor : IPaletteDitherImageProcessor, IDisposable { - private readonly EuclideanPixelMap pixelMap; + private readonly PixelMap pixelMap; [MethodImpl(InliningOptions.ShortMethod)] public DitherProcessor( @@ -89,7 +89,7 @@ internal sealed class PaletteDitherProcessor : ImageProcessor float ditherScale) { this.Configuration = configuration; - this.pixelMap = new EuclideanPixelMap(configuration, palette); + this.pixelMap = PixelMapFactory.Create(configuration, palette, ColorMatchingMode.Hybrid); this.Palette = palette; this.DitherScale = ditherScale; } diff --git a/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs new file mode 100644 index 0000000000..26fd7d5d76 --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs @@ -0,0 +1,28 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Processing.Processors.Quantization; + +/// +/// Defines the precision level used when matching colors during quantization. +/// +public enum ColorMatchingMode +{ + /// + /// Uses a coarse caching strategy optimized for performance at the expense of exact matches. + /// This provides the fastest matching but may yield approximate results. + /// + Coarse, + + /// + /// Enables an exact color match cache for the first 512 unique colors encountered, + /// falling back to coarse matching thereafter. + /// + Hybrid, + + /// + /// Performs exact color matching without any caching optimizations. + /// This is the slowest but most accurate matching strategy. + /// + Exact +} diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs new file mode 100644 index 0000000000..5b0c7252cb --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs @@ -0,0 +1,184 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Quantization; + +/// +/// Gets the closest color to the supplied color based upon the Euclidean distance. +/// +/// The pixel format. +/// The cache type. +/// +/// 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 : PixelMap + where TPixel : unmanaged, IPixel + where TCache : struct, IColorIndexCache +{ + private Rgba32[] rgbaPalette; + + // Do not make readonly. It's a mutable struct. +#pragma warning disable IDE0044 // Add readonly modifier + private TCache cache; +#pragma warning restore IDE0044 // Add readonly modifier + + private readonly Configuration configuration; + + /// + /// Initializes a new instance of the class. + /// + /// Specifies the settings and resources for the pixel map's operations. + /// Defines the color palette used for pixel mapping. + public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette) + { + this.configuration = configuration; + this.Palette = palette; + this.rgbaPalette = new Rgba32[palette.Length]; + this.cache = TCache.Create(configuration.MemoryAllocator); + PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette); + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public override int GetClosestColor(TPixel color, out TPixel match) + { + ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span); + Rgba32 rgba = color.ToRgba32(); + + if (this.cache.TryGetValue(rgba, out short index)) + { + match = Unsafe.Add(ref paletteRef, (ushort)index); + return index; + } + + return this.GetClosestColorSlow(rgba, ref paletteRef, out match); + } + + /// + public override void Clear(ReadOnlyMemory palette) + { + this.Palette = palette; + this.rgbaPalette = new Rgba32[palette.Length]; + PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette); + this.cache.Clear(); + } + + [MethodImpl(InliningOptions.ColdPath)] + 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; + for (int i = 0; i < this.rgbaPalette.Length; i++) + { + Rgba32 candidate = this.rgbaPalette[i]; + if (candidate.PackedValue == rgba.PackedValue) + { + index = i; + break; + } + + float distance = DistanceSquared(rgba, candidate); + if (distance == 0) + { + index = i; + break; + } + + if (distance < leastDistance) + { + index = i; + leastDistance = distance; + } + } + + // Now I have the index, pop it into the cache for next time + _ = this.cache.TryAdd(rgba, (short)index); + match = Unsafe.Add(ref paletteRef, (uint)index); + + return index; + } + + /// + /// Returns the Euclidean distance squared between two specified points. + /// + /// The first point. + /// The second point. + /// The distance squared. + [MethodImpl(InliningOptions.ShortMethod)] + private static float DistanceSquared(Rgba32 a, Rgba32 b) + { + 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); + } + + /// + public override void Dispose() => this.cache.Dispose(); +} + +/// +/// Represents a map of colors to indices. +/// +/// The pixel format. +internal abstract class PixelMap : IDisposable + where TPixel : unmanaged, IPixel +{ + /// + /// Gets the color palette of this . + /// + public ReadOnlyMemory Palette { get; private protected set; } + + /// + /// Returns the closest color in the palette and the index of that pixel. + /// + /// The color to match. + /// The matched color. + /// + /// The index. + /// + public abstract int GetClosestColor(TPixel color, out TPixel match); + + /// + /// Clears the map, resetting it to use the given palette. + /// + /// The color palette to map from. + public abstract void Clear(ReadOnlyMemory palette); + + /// + public abstract void Dispose(); +} + +/// +/// A factory for creating instances. +/// +internal static class PixelMapFactory +{ + /// + /// Creates a new instance. + /// + /// The pixel format. + /// The configuration. + /// The color palette to map from. + /// The color matching mode. + /// + /// The . + /// + public static PixelMap Create( + Configuration configuration, + ReadOnlyMemory palette, + ColorMatchingMode colorMatchingMode) + where TPixel : unmanaged, IPixel => colorMatchingMode switch + { + ColorMatchingMode.Hybrid => new EuclideanPixelMap(configuration, palette), + ColorMatchingMode.Exact => new EuclideanPixelMap(configuration, palette), + _ => new EuclideanPixelMap(configuration, palette), + }; +} diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs deleted file mode 100644 index 250122e683..0000000000 --- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs +++ /dev/null @@ -1,545 +0,0 @@ -// Copyright (c) Six Labors. -// 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; -using SixLabors.ImageSharp.PixelFormats; - -namespace SixLabors.ImageSharp.Processing.Processors.Quantization; - -/// -/// Gets the closest color to the supplied color based upon the Euclidean distance. -/// -/// The pixel format. -/// -/// 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 readonly HybridColorDistanceCache cache; - private readonly Configuration configuration; - - /// - /// Initializes a new instance of the class. - /// - /// The configuration. - /// The color palette to map from. - public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette) - { - this.configuration = configuration; - this.Palette = palette; - this.rgbaPalette = new Rgba32[palette.Length]; - this.cache = new HybridColorDistanceCache(configuration.MemoryAllocator); - PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette); - } - - /// - /// Gets the color palette of this . - /// The palette memory is owned by the palette source that created it. - /// - public ReadOnlyMemory Palette { get; private set; } - - /// - /// Returns the closest color in the palette and the index of that pixel. - /// The palette contents must match the one used in the constructor. - /// - /// The color to match. - /// The matched color. - /// The transparency threshold. - /// The index. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public int GetClosestColor(TPixel color, out TPixel match, short transparencyThreshold = -1) - { - ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span); - Rgba32 rgba = color.ToRgba32(); - - if (transparencyThreshold > -1 && rgba.A < transparencyThreshold) - { - rgba = default; - } - - // Check if the color is in the lookup table - if (this.cache.TryGetValue(rgba, out short index)) - { - match = Unsafe.Add(ref paletteRef, (ushort)index); - return index; - } - - return this.GetClosestColorSlow(rgba, ref paletteRef, out match); - } - - /// - /// Clears the map, resetting it to use the given palette. - /// - /// The color palette to map from. - public void Clear(ReadOnlyMemory palette) - { - this.Palette = palette; - this.rgbaPalette = new Rgba32[palette.Length]; - PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette); - this.cache.Clear(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - 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; - for (int i = 0; i < this.rgbaPalette.Length; i++) - { - Rgba32 candidate = this.rgbaPalette[i]; - if (candidate.PackedValue == rgba.PackedValue) - { - index = i; - break; - } - - float distance = DistanceSquared(rgba, candidate); - if (distance == 0) - { - index = i; - break; - } - - if (distance < leastDistance) - { - index = i; - leastDistance = distance; - } - } - - // Now I have the index, pop it into the cache for next time - this.cache.Add(rgba, (short)index); - match = Unsafe.Add(ref paletteRef, (uint)index); - - return index; - } - - /// - /// Returns the Euclidean distance squared between two specified points. - /// - /// The first point. - /// The second point. - /// The distance squared. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static float DistanceSquared(Rgba32 a, Rgba32 b) - { - 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); - } - - public void Dispose() => this.cache.Dispose(); - - /// - /// A hybrid color distance cache that combines a small, fixed-capacity exact-match dictionary - /// (ExactCache, ~4–5 KB for up to 512 entries) with a coarse lookup table (CoarseCache) for 5,5,5,6 precision. - /// - /// - /// ExactCache provides O(1) lookup for common cases using a simple 256-entry hash-based dictionary, while CoarseCache - /// quantizes RGB channels to 5 bits (yielding 32^3 buckets) and alpha to 6 bits, storing up to 4 alpha entries per bucket - /// (a design chosen based on probability theory to capture most real-world variations) for a total memory footprint of - /// roughly 576 KB. Lookups and insertions are performed in constant time, making the overall design both fast and memory-predictable. - /// -#pragma warning disable CA1001 // Types that own disposable fields should be disposable - // https://github.com/dotnet/roslyn-analyzers/issues/6151 - private readonly unsafe struct HybridColorDistanceCache : IDisposable -#pragma warning restore CA1001 // Types that own disposable fields should be disposable - { - private readonly CoarseCache coarseCache; - private readonly ExactCache exactCache; - - public HybridColorDistanceCache(MemoryAllocator allocator) - { - this.exactCache = new ExactCache(allocator); - this.coarseCache = new CoarseCache(allocator); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly void Add(Rgba32 color, short index) - { - if (this.exactCache.TryAdd(color.PackedValue, index)) - { - return; - } - - this.coarseCache.Add(color, index); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public readonly bool TryGetValue(Rgba32 color, out short match) - { - if (this.exactCache.TryGetValue(color.PackedValue, out match)) - { - return true; // Exact match found - } - - if (this.coarseCache.TryGetValue(color, out match)) - { - return true; // Coarse match found - } - - match = -1; - return false; - } - - public readonly void Clear() - { - this.exactCache.Clear(); - this.coarseCache.Clear(); - } - - public void Dispose() - { - this.exactCache.Dispose(); - this.coarseCache.Dispose(); - } - } - - /// - /// A fixed-capacity dictionary with exactly 512 entries mapping a key - /// to a value. - /// - /// - /// The dictionary is implemented using a fixed array of 512 buckets and an entries array - /// of the same size. The bucket for a key is computed as (key & 0x1FF), and collisions are - /// resolved through a linked chain stored in the field. - /// The overall memory usage is approximately 4–5 KB. Both lookup and insertion operations are, - /// on average, O(1) since the bucket is determined via a simple bitmask and collision chains are - /// typically very short; in the worst-case, the number of iterations is bounded by 256. - /// This guarantees highly efficient and predictable performance for small, fixed-size color palettes. - /// - internal sealed unsafe class ExactCache : IDisposable - { - // Buckets array: each bucket holds the index (0-based) into the entries array - // of the first entry in the chain, or -1 if empty. - private readonly IMemoryOwner bucketsOwner; - private MemoryHandle bucketsHandle; - private short* buckets; - - // Entries array: stores up to 256 entries. - private readonly IMemoryOwner entriesOwner; - private MemoryHandle entriesHandle; - private Entry* entries; - - public const int Capacity = 512; - - public ExactCache(MemoryAllocator allocator) - { - this.Count = 0; - - // Allocate exactly 512 ints for buckets. - this.bucketsOwner = allocator.Allocate(Capacity, AllocationOptions.Clean); - Span bucketSpan = this.bucketsOwner.GetSpan(); - bucketSpan.Fill(-1); - this.bucketsHandle = this.bucketsOwner.Memory.Pin(); - this.buckets = (short*)this.bucketsHandle.Pointer; - - // Allocate exactly 512 entries. - this.entriesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean); - this.entriesHandle = this.entriesOwner.Memory.Pin(); - this.entries = (Entry*)this.entriesHandle.Pointer; - } - - public int Count { get; private set; } - - /// - /// Adds a key/value pair to the dictionary. - /// If the key already exists, the dictionary is left unchanged. - /// - /// The key to add. - /// The value to add. - /// if the key was added; otherwise, . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryAdd(uint key, short value) - { - if (this.Count == Capacity) - { - return false; // Dictionary is full. - } - - // The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A - // (with R in the most significant byte and A in the least significant). - // To compute the bucket index: - // 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels. - // 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A). - // 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A), - // which helps to counteract situations where one or more channels have a limited range. - // 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511, - // which corresponds to our fixed bucket count of 512. - int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF); - int i = this.buckets[bucket]; - - // Traverse the collision chain. - Entry* entries = this.entries; - while (i != -1) - { - Entry e = entries[i]; - if (e.Key == key) - { - // Key already exists; do not overwrite. - return false; - } - - i = e.Next; - } - - short index = (short)this.Count; - this.Count++; - - // Insert the new entry: - entries[index].Key = key; - entries[index].Value = value; - - // Link this new entry into the bucket chain. - entries[index].Next = this.buckets[bucket]; - this.buckets[bucket] = index; - return true; - } - - /// - /// Tries to retrieve the value associated with the specified key. - /// Returns true if the key is found; otherwise, returns false. - /// - /// The key to search for. - /// The value associated with the key, if found. - /// if the key is found; otherwise, . - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGetValue(uint key, out short value) - { - int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF); - int i = this.buckets[bucket]; - - // If the bucket is empty, return immediately. - if (i == -1) - { - value = -1; - return false; - } - - // Traverse the chain. - Entry* entries = this.entries; - do - { - Entry e = entries[i]; - if (e.Key == key) - { - value = e.Value; - return true; - } - - i = e.Next; - } - while (i != -1); - - value = -1; - return false; - } - - /// - /// Clears the dictionary. - /// - public void Clear() - { - Span bucketSpan = this.bucketsOwner.GetSpan(); - bucketSpan.Fill(-1); - this.Count = 0; - } - - public void Dispose() - { - this.bucketsHandle.Dispose(); - this.bucketsOwner.Dispose(); - this.entriesHandle.Dispose(); - this.entriesOwner.Dispose(); - this.buckets = null; - this.entries = null; - } - - private struct Entry - { - public uint Key; // The key (packed RGBA) - public short Value; // The value; -1 means unused. - public short Next; // Index of the next entry in the chain, or -1 if none. - } - } - - /// - /// - /// CoarseCache is a fast, low-memory lookup structure for caching palette indices associated with RGBA values, - /// using a quantized representation of 5,5,5,6 (RGB: 5 bits each, Alpha: 6 bits). - /// - /// - /// The cache quantizes the RGB channels to 5 bits each, resulting in 32 levels per channel and a total of 32³ = 32,768 buckets. - /// Each bucket is represented by an , which holds a small, inline array of alpha entries. - /// Each alpha entry stores the alpha value quantized to 6 bits (0–63) along with a palette index (a 16-bit value). - /// - /// - /// Performance Characteristics: - /// - Lookup: O(1) for computing the bucket index from the RGB channels, plus a small constant time (up to 4 iterations) - /// to search through the alpha entries in the bucket. - /// - Insertion: O(1) for bucket index computation and a quick linear search over a very small (fixed) number of entries. - /// - /// - /// Memory Characteristics: - /// - The cache consists of 32,768 buckets. - /// - Each is implemented using an inline array with a capacity of 4 entries. - /// - Each bucket occupies approximately 18 bytes. - /// - Overall, the buckets occupy roughly 32,768 × 18 = 589,824 bytes (576 KB). - /// - /// - /// This design provides nearly constant-time lookup and insertion with minimal memory usage, - /// making it ideal for applications such as color distance caching in images with a limited palette (up to 256 entries). - /// - /// - internal sealed unsafe class CoarseCache : IDisposable - { - // Use 5 bits per channel for R, G, and B: 32 levels each. - // Total buckets = 32^3 = 32768. - private const int RgbBits = 5; - private const int BucketCount = 1 << (RgbBits * 3); // 32768 - private readonly IMemoryOwner bucketsOwner; - private readonly AlphaBucket* buckets; - private MemoryHandle bucketHandle; - - public CoarseCache(MemoryAllocator allocator) - { - this.bucketsOwner = allocator.Allocate(BucketCount, AllocationOptions.Clean); - this.bucketHandle = this.bucketsOwner.Memory.Pin(); - this.buckets = (AlphaBucket*)this.bucketHandle.Pointer; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetBucketIndex(byte r, byte g, byte b) - { - int qr = r >> (8 - RgbBits); - int qg = g >> (8 - RgbBits); - int qb = b >> (8 - RgbBits); - - // Combine the quantized channels into a single index. - return (qr << (RgbBits * 2)) | (qg << RgbBits) | qb; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static byte QuantizeAlpha(byte a) - - // Quantize to 6 bits: shift right by (8 - 6) = 2 bits. - => (byte)(a >> 2); - - public void Add(Rgba32 color, short paletteIndex) - { - int bucketIndex = GetBucketIndex(color.R, color.G, color.B); - byte quantAlpha = QuantizeAlpha(color.A); - this.buckets[bucketIndex].Add(quantAlpha, paletteIndex); - } - - public void Dispose() - { - this.bucketHandle.Dispose(); - this.bucketsOwner.Dispose(); - } - - public bool TryGetValue(Rgba32 color, out short paletteIndex) - { - int bucketIndex = GetBucketIndex(color.R, color.G, color.B); - byte quantAlpha = QuantizeAlpha(color.A); - return this.buckets[bucketIndex].TryGetValue(quantAlpha, out paletteIndex); - } - - public void Clear() - { - Span bucketsSpan = this.bucketsOwner.GetSpan(); - bucketsSpan.Clear(); - } - - public struct AlphaEntry - { - // Store the alpha value quantized to 6 bits (0..63) - public byte QuantizedAlpha; - public short PaletteIndex; - } - - public struct AlphaBucket - { - // Fixed capacity for alpha entries in this bucket. - // We choose a capacity of 4 for several reasons: - // - // 1. The alpha channel is quantized to 6 bits, so there are 64 possible distinct values. - // In the worst-case, a given RGB bucket might encounter up to 64 different alpha values. - // - // 2. However, in practice (based on probability theory and typical image data), - // the number of unique alpha values that actually occur for a given quantized RGB - // bucket is usually very small. If you randomly sample 4 values out of 64, - // the probability that these 4 samples are all unique is high if the distribution - // of alpha values is skewed or if only a few alpha values are used. - // - // 3. Statistically, for many real-world images, most RGB buckets will have only a couple - // of unique alpha values. Allocating 4 slots per bucket provides a good trade-off: - // it captures the common-case scenario while keeping overall memory usage low. - // - // 4. Even if more than 4 unique alpha values occur in a bucket, - // our design overwrites the first entry. This behavior gives us some "wriggle room" - // while preserving the most frequently encountered or most recent values. - public const int Capacity = 4; - public byte Count; - private InlineArray4 entries; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public bool TryGetValue(byte quantizedAlpha, out short paletteIndex) - { - for (int i = 0; i < this.Count; i++) - { - ref AlphaEntry entry = ref this.entries[i]; - if (entry.QuantizedAlpha == quantizedAlpha) - { - paletteIndex = entry.PaletteIndex; - return true; - } - } - - paletteIndex = -1; - return false; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Add(byte quantizedAlpha, short paletteIndex) - { - // Check for an existing entry with the same quantized alpha. - for (int i = 0; i < this.Count; i++) - { - ref AlphaEntry entry = ref this.entries[i]; - if (entry.QuantizedAlpha == quantizedAlpha) - { - // Update palette index if found. - entry.PaletteIndex = paletteIndex; - return; - } - } - - // If there's room, add a new entry. - if (this.Count < Capacity) - { - ref AlphaEntry newEntry = ref this.entries[this.Count]; - newEntry.QuantizedAlpha = quantizedAlpha; - newEntry.PaletteIndex = paletteIndex; - this.Count++; - } - else - { - // Bucket is full. Overwrite the first entry to give us some wriggle room. - this.entries[0].QuantizedAlpha = quantizedAlpha; - this.entries[0].PaletteIndex = paletteIndex; - } - } - } - } -} diff --git a/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs new file mode 100644 index 0000000000..52efc62b7e --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs @@ -0,0 +1,491 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Buffers; +using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Quantization; + +/// +/// Represents a cache used for efficiently retrieving palette indices for colors. +/// +internal interface IColorIndexCache : IDisposable +{ + /// + /// Adds a color to the cache. + /// + /// The color to add. + /// The index of the color in the palette. + /// + /// if the color was added; otherwise, . + /// + public bool TryAdd(Rgba32 color, short value); + + /// + /// Gets the index of the color in the palette. + /// + /// The color to get the index for. + /// The index of the color in the palette. + /// + /// if the color is in the palette; otherwise, . + /// + public bool TryGetValue(Rgba32 color, out short value); + + /// + /// Clears the cache. + /// + public void Clear(); +} + +/// +/// Represents a cache used for efficiently retrieving palette indices for colors. +/// +/// The type of the cache. +internal interface IColorIndexCache : IColorIndexCache + where T : struct, IColorIndexCache +{ + /// + /// Creates a new instance of the cache. + /// + /// The memory allocator to use. + /// + /// The new instance of the cache. + /// + public static abstract T Create(MemoryAllocator allocator); +} + +/// +/// A hybrid color distance cache that combines a small, fixed-capacity exact-match dictionary +/// (ExactCache, ~4–5 KB for up to 512 entries) with a coarse lookup table (CoarseCache) for 5,5,5,6 precision. +/// +/// +/// ExactCache provides O(1) lookup for common cases using a simple 256-entry hash-based dictionary, while CoarseCache +/// quantizes RGB channels to 5 bits (yielding 32^3 buckets) and alpha to 6 bits, storing up to 4 alpha entries per bucket +/// (a design chosen based on probability theory to capture most real-world variations) for a total memory footprint of +/// roughly 576 KB. Lookups and insertions are performed in constant time, making the overall design both fast and memory-predictable. +/// +internal unsafe struct HybridCache : IColorIndexCache +{ + private CoarseCache coarseCache; + private ExactCache exactCache; + + public HybridCache(MemoryAllocator allocator) + { + this.exactCache = ExactCache.Create(allocator); + this.coarseCache = CoarseCache.Create(allocator); + } + + /// + public static HybridCache Create(MemoryAllocator allocator) => new(allocator); + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public bool TryAdd(Rgba32 color, short index) + { + if (this.exactCache.TryAdd(color, index)) + { + return true; + } + + return this.coarseCache.TryAdd(color, index); + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public readonly bool TryGetValue(Rgba32 color, out short value) + { + if (this.exactCache.TryGetValue(color, out value)) + { + return true; + } + + return this.coarseCache.TryGetValue(color, out value); + } + + /// + public readonly void Clear() + { + this.exactCache.Clear(); + this.coarseCache.Clear(); + } + + /// + public void Dispose() + { + this.exactCache.Dispose(); + this.coarseCache.Dispose(); + } +} + +/// +/// +/// CoarseCache is a fast, low-memory lookup structure for caching palette indices associated with RGBA values, +/// using a quantized representation of 5,5,5,6 (RGB: 5 bits each, Alpha: 6 bits). +/// +/// +/// The cache quantizes the RGB channels to 5 bits each, resulting in 32 levels per channel and a total of 32³ = 32,768 buckets. +/// Each bucket is represented by an , which holds a small, inline array of alpha entries. +/// Each alpha entry stores the alpha value quantized to 6 bits (0–63) along with a palette index (a 16-bit value). +/// +/// +/// Performance Characteristics: +/// - Lookup: O(1) for computing the bucket index from the RGB channels, plus a small constant time (up to 4 iterations) +/// to search through the alpha entries in the bucket. +/// - Insertion: O(1) for bucket index computation and a quick linear search over a very small (fixed) number of entries. +/// +/// +/// Memory Characteristics: +/// - The cache consists of 32,768 buckets. +/// - Each is implemented using an inline array with a capacity of 4 entries. +/// - Each bucket occupies approximately 18 bytes. +/// - Overall, the buckets occupy roughly 32,768 × 18 = 589,824 bytes (576 KB). +/// +/// +/// This design provides nearly constant-time lookup and insertion with minimal memory usage, +/// making it ideal for applications such as color distance caching in images with a limited palette (up to 256 entries). +/// +/// +internal unsafe struct CoarseCache : IColorIndexCache +{ + // Use 5 bits per channel for R, G, and B: 32 levels each. + // Total buckets = 32^3 = 32768. + private const int RgbBits = 5; + private const int RgbShift = 8 - RgbBits; // 3 + private const int BucketCount = 1 << (RgbBits * 3); // 32768 + private readonly IMemoryOwner bucketsOwner; + private readonly AlphaBucket* buckets; + private MemoryHandle bucketHandle; + + private CoarseCache(MemoryAllocator allocator) + { + this.bucketsOwner = allocator.Allocate(BucketCount, AllocationOptions.Clean); + this.bucketHandle = this.bucketsOwner.Memory.Pin(); + this.buckets = (AlphaBucket*)this.bucketHandle.Pointer; + } + + /// + public static CoarseCache Create(MemoryAllocator allocator) => new(allocator); + + /// + public readonly bool TryAdd(Rgba32 color, short paletteIndex) + { + int bucketIndex = GetBucketIndex(color.R, color.G, color.B); + byte quantAlpha = QuantizeAlpha(color.A); + this.buckets[bucketIndex].Add(quantAlpha, paletteIndex); + return true; + } + + /// + public readonly bool TryGetValue(Rgba32 color, out short paletteIndex) + { + int bucketIndex = GetBucketIndex(color.R, color.G, color.B); + byte quantAlpha = QuantizeAlpha(color.A); + return this.buckets[bucketIndex].TryGetValue(quantAlpha, out paletteIndex); + } + + /// + public readonly void Clear() + { + Span bucketsSpan = this.bucketsOwner.GetSpan(); + bucketsSpan.Clear(); + } + + /// + public void Dispose() + { + this.bucketHandle.Dispose(); + this.bucketsOwner.Dispose(); + } + + [MethodImpl(InliningOptions.ShortMethod)] + private static int GetBucketIndex(byte r, byte g, byte b) + { + int qr = r >> RgbShift; + int qg = g >> RgbShift; + int qb = b >> RgbShift; + + // Combine the quantized channels into a single index. + return (qr << (RgbBits << 1)) | (qg << RgbBits) | qb; + } + + [MethodImpl(InliningOptions.ShortMethod)] + private static byte QuantizeAlpha(byte a) + + // Quantize to 6 bits: shift right by (8 - 6) = 2 bits. + => (byte)(a >> 2); + + public struct AlphaEntry + { + // Store the alpha value quantized to 6 bits (0..63) + public byte QuantizedAlpha; + public short PaletteIndex; + } + + public struct AlphaBucket + { + // Fixed capacity for alpha entries in this bucket. + // We choose a capacity of 4 for several reasons: + // + // 1. The alpha channel is quantized to 6 bits, so there are 64 possible distinct values. + // In the worst-case, a given RGB bucket might encounter up to 64 different alpha values. + // + // 2. However, in practice (based on probability theory and typical image data), + // the number of unique alpha values that actually occur for a given quantized RGB + // bucket is usually very small. If you randomly sample 4 values out of 64, + // the probability that these 4 samples are all unique is high if the distribution + // of alpha values is skewed or if only a few alpha values are used. + // + // 3. Statistically, for many real-world images, most RGB buckets will have only a couple + // of unique alpha values. Allocating 4 slots per bucket provides a good trade-off: + // it captures the common-case scenario while keeping overall memory usage low. + // + // 4. Even if more than 4 unique alpha values occur in a bucket, + // our design overwrites the first entry. This behavior gives us some "wriggle room" + // while preserving the most frequently encountered or most recent values. + public const int Capacity = 4; + public byte Count; + private InlineArray4 entries; + + [MethodImpl(InliningOptions.ShortMethod)] + public bool TryGetValue(byte quantizedAlpha, out short paletteIndex) + { + for (int i = 0; i < this.Count; i++) + { + ref AlphaEntry entry = ref this.entries[i]; + if (entry.QuantizedAlpha == quantizedAlpha) + { + paletteIndex = entry.PaletteIndex; + return true; + } + } + + paletteIndex = -1; + return false; + } + + [MethodImpl(InliningOptions.ShortMethod)] + public void Add(byte quantizedAlpha, short paletteIndex) + { + // Check for an existing entry with the same quantized alpha. + for (int i = 0; i < this.Count; i++) + { + ref AlphaEntry entry = ref this.entries[i]; + if (entry.QuantizedAlpha == quantizedAlpha) + { + // Update palette index if found. + entry.PaletteIndex = paletteIndex; + return; + } + } + + // If there's room, add a new entry. + if (this.Count < Capacity) + { + ref AlphaEntry newEntry = ref this.entries[this.Count]; + newEntry.QuantizedAlpha = quantizedAlpha; + newEntry.PaletteIndex = paletteIndex; + this.Count++; + } + else + { + // Bucket is full. Overwrite the first entry to give us some wriggle room. + this.entries[0].QuantizedAlpha = quantizedAlpha; + this.entries[0].PaletteIndex = paletteIndex; + } + } + } +} + +/// +/// A fixed-capacity dictionary with exactly 512 entries mapping a key +/// to a value. +/// +/// +/// The dictionary is implemented using a fixed array of 512 buckets and an entries array +/// of the same size. The bucket for a key is computed as (key & 0x1FF), and collisions are +/// resolved through a linked chain stored in the field. +/// The overall memory usage is approximately 4–5 KB. Both lookup and insertion operations are, +/// on average, O(1) since the bucket is determined via a simple bitmask and collision chains are +/// typically very short; in the worst-case, the number of iterations is bounded by 256. +/// This guarantees highly efficient and predictable performance for small, fixed-size color palettes. +/// +internal unsafe struct ExactCache : IColorIndexCache +{ + // Buckets array: each bucket holds the index (0-based) into the entries array + // of the first entry in the chain, or -1 if empty. + private readonly IMemoryOwner bucketsOwner; + private MemoryHandle bucketsHandle; + private short* buckets; + + // Entries array: stores up to 256 entries. + private readonly IMemoryOwner entriesOwner; + private MemoryHandle entriesHandle; + private Entry* entries; + + public const int Capacity = 512; + + private ExactCache(MemoryAllocator allocator) + { + this.Count = 0; + + // Allocate exactly 512 indexes for buckets. + this.bucketsOwner = allocator.Allocate(Capacity, AllocationOptions.Clean); + Span bucketSpan = this.bucketsOwner.GetSpan(); + bucketSpan.Fill(-1); + this.bucketsHandle = this.bucketsOwner.Memory.Pin(); + this.buckets = (short*)this.bucketsHandle.Pointer; + + // Allocate exactly 512 entries. + this.entriesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean); + this.entriesHandle = this.entriesOwner.Memory.Pin(); + this.entries = (Entry*)this.entriesHandle.Pointer; + } + + public int Count { get; private set; } + + /// + public static ExactCache Create(MemoryAllocator allocator) => new(allocator); + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public bool TryAdd(Rgba32 color, short value) + { + if (this.Count == Capacity) + { + return false; // Dictionary is full. + } + + uint key = color.PackedValue; + + // The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A + // (with R in the most significant byte and A in the least significant). + // To compute the bucket index: + // 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels. + // 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A). + // 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A), + // which helps to counteract situations where one or more channels have a limited range. + // 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511, + // which corresponds to our fixed bucket count of 512. + int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF); + int i = this.buckets[bucket]; + + // Traverse the collision chain. + Entry* entries = this.entries; + while (i != -1) + { + Entry e = entries[i]; + if (e.Key == key) + { + // Key already exists; do not overwrite. + return false; + } + + i = e.Next; + } + + short index = (short)this.Count; + this.Count++; + + // Insert the new entry: + entries[index].Key = key; + entries[index].Value = value; + + // Link this new entry into the bucket chain. + entries[index].Next = this.buckets[bucket]; + this.buckets[bucket] = index; + return true; + } + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public bool TryGetValue(Rgba32 color, out short value) + { + uint key = color.PackedValue; + int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF); + int i = this.buckets[bucket]; + + // If the bucket is empty, return immediately. + if (i == -1) + { + value = -1; + return false; + } + + // Traverse the chain. + Entry* entries = this.entries; + do + { + Entry e = entries[i]; + if (e.Key == key) + { + value = e.Value; + return true; + } + + i = e.Next; + } + while (i != -1); + + value = -1; + return false; + } + + /// + /// Clears the dictionary. + /// + public void Clear() + { + Span bucketSpan = this.bucketsOwner.GetSpan(); + bucketSpan.Fill(-1); + this.Count = 0; + } + + public void Dispose() + { + this.bucketsHandle.Dispose(); + this.bucketsOwner.Dispose(); + this.entriesHandle.Dispose(); + this.entriesOwner.Dispose(); + this.buckets = null; + this.entries = null; + } + + private struct Entry + { + public uint Key; // The key (packed RGBA) + public short Value; // The value; -1 means unused. + public short Next; // Index of the next entry in the chain, or -1 if none. + } +} + +/// +/// Represents a cache that does not store any values. +/// It allows adding colors, but always returns false when trying to retrieve them. +/// +internal readonly struct NullCache : IColorIndexCache +{ + /// + public static NullCache Create(MemoryAllocator allocator) => default; + + /// + public bool TryAdd(Rgba32 color, short value) => true; + + /// + public bool TryGetValue(Rgba32 color, out short value) + { + value = -1; + return false; + } + + /// + public void Clear() + { + } + + /// + public void Dispose() + { + } +} diff --git a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs index 9d5b606040..02dce8ca48 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs @@ -13,7 +13,7 @@ public interface IQuantizer /// /// Gets the quantizer options defining quantization rules. /// - QuantizerOptions Options { get; } + public QuantizerOptions Options { get; } /// /// Creates the generic frame quantizer. @@ -21,7 +21,7 @@ public interface IQuantizer /// The to configure internal operations. /// The pixel format. /// The . - IQuantizer CreatePixelSpecificQuantizer(Configuration configuration) + public IQuantizer CreatePixelSpecificQuantizer(Configuration configuration) where TPixel : unmanaged, IPixel; /// @@ -31,6 +31,6 @@ public interface IQuantizer /// The to configure internal operations. /// The options to create the quantizer with. /// The . - IQuantizer CreatePixelSpecificQuantizer(Configuration configuration, QuantizerOptions options) + public IQuantizer CreatePixelSpecificQuantizer(Configuration configuration, QuantizerOptions options) where TPixel : unmanaged, IPixel; } diff --git a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs index dc5bdbd627..02c2052fef 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -27,7 +28,7 @@ public interface IQuantizer : IDisposable /// Gets the quantized color palette. /// /// - /// The palette has not been built via . + /// The palette has not been built via . /// public ReadOnlyMemory Palette { get; } @@ -35,21 +36,45 @@ public interface IQuantizer : IDisposable /// Adds colors to the quantized palette from the given pixel source. /// /// The of source pixels to register. - public void AddPaletteColors(in Buffer2DRegion pixelRegion); + public void AddPaletteColors(in Buffer2DRegion pixelRegion) + => this.AddPaletteColors(pixelRegion, TransparentColorMode.Preserve); + + /// + /// Adds colors to the quantized palette from the given pixel source. + /// + /// The of source pixels to register. + /// The to use when adding colors to the palette. + public void AddPaletteColors(in Buffer2DRegion pixelRegion, TransparentColorMode mode); + + /// + /// Quantizes an image frame and return the resulting output pixels. + /// + /// The source image frame to quantize. + /// The bounds within the frame to quantize. + /// + /// A representing a quantized version of the source frame pixels. + /// + /// + /// Only executes the second (quantization) step. The palette has to be built by calling . + /// To run both steps, use . + /// + public IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds) + => this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve); /// /// Quantizes an image frame and return the resulting output pixels. /// /// The source image frame to quantize. /// The bounds within the frame to quantize. + /// The to use when quantizing the frame. /// /// A representing a quantized version of the source frame pixels. /// /// - /// Only executes the second (quantization) step. The palette has to be built by calling . - /// To run both steps, use . + /// Only executes the second (quantization) step. The palette has to be built by calling . + /// To run both steps, use . /// - public IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds); + public IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds, TransparentColorMode mode); /// /// Returns the index and color from the quantized palette corresponding to the given color. diff --git a/src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs new file mode 100644 index 0000000000..ce06adf455 --- /dev/null +++ b/src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs @@ -0,0 +1,27 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Processing.Processors.Quantization; + +/// +/// Defines a delegate for processing a row of pixels in an image for quantization. +/// +/// Represents a pixel type that can be processed in a quantizing operation. +internal interface IQuantizingPixelRowDelegate + where TPixel : unmanaged, IPixel +{ + /// + /// Gets the transparent color mode to use when adding colors to the palette. + /// + public TransparentColorMode TransparentColorMode { get; } + + /// + /// Processes a row of pixels for quantization. + /// + /// The row of pixels to process. + /// The index of the row being processed. + public void Invoke(ReadOnlySpan row, int rowIndex); +} diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs index 65e814e620..e094b18964 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -16,11 +17,10 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization; /// /// /// The pixel format. -[SuppressMessage( - "Design", - "CA1001:Types that own disposable fields should be disposable", - Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")] +#pragma warning disable CA1001 // Types that own disposable fields should be disposable +// See https://github.com/dotnet/roslyn-analyzers/issues/6151 public struct OctreeQuantizer : IQuantizer +#pragma warning restore CA1001 // Types that own disposable fields should be disposable where TPixel : unmanaged, IPixel { private readonly int maxColors; @@ -28,15 +28,14 @@ public struct OctreeQuantizer : IQuantizer private readonly Octree octree; private readonly IMemoryOwner paletteOwner; private ReadOnlyMemory palette; - private EuclideanPixelMap? pixelMap; + private PixelMap? pixelMap; private readonly bool isDithering; - private readonly short transparencyThreshold; private bool isDisposed; /// /// 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. [MethodImpl(InliningOptions.ShortMethod)] public OctreeQuantizer(Configuration configuration, QuantizerOptions options) @@ -45,9 +44,8 @@ public struct OctreeQuantizer : IQuantizer this.Options = options; this.maxColors = this.Options.MaxColors; - this.transparencyThreshold = (short)(this.Options.TransparencyThreshold * 255); this.bitDepth = Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(this.maxColors), 1, 8); - this.octree = new Octree(this.bitDepth, this.maxColors, this.transparencyThreshold, configuration.MemoryAllocator); + this.octree = new Octree(configuration, this.bitDepth, this.maxColors, this.Options.TransparencyThreshold, this.Options.ThresholdReplacementColor); this.paletteOwner = configuration.MemoryAllocator.Allocate(this.maxColors, AllocationOptions.Clean); this.pixelMap = default; this.palette = default; @@ -77,25 +75,13 @@ public struct OctreeQuantizer : IQuantizer } /// - public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion) + public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion, TransparentColorMode mode) { - using IMemoryOwner buffer = this.Configuration.MemoryAllocator.Allocate(pixelRegion.Width); - Span bufferSpan = buffer.GetSpan(); - - // Loop through each row - for (int y = 0; y < pixelRegion.Height; y++) - { - Span row = pixelRegion.DangerousGetRowSpan(y); - PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan); - - Octree octree = this.octree; - int transparencyThreshold = this.transparencyThreshold; - for (int x = 0; x < bufferSpan.Length; x++) - { - // Add the color to the Octree - octree.AddColor(bufferSpan[x]); - } - } + PixelRowDelegate pixelRowDelegate = new(this.octree, mode); + QuantizerUtilities.AddPaletteColors, TPixel, Rgba32, PixelRowDelegate>( + ref Unsafe.AsRef(in this), + in pixelRegion, + in pixelRowDelegate); } private void ResolvePalette() @@ -108,7 +94,7 @@ public struct OctreeQuantizer : IQuantizer if (this.isDithering) { - this.pixelMap = new EuclideanPixelMap(this.Configuration, result); + this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode); } this.palette = result; @@ -117,7 +103,12 @@ public struct OctreeQuantizer : IQuantizer /// [MethodImpl(InliningOptions.ShortMethod)] public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds) - => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds); + => this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve); + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds, TransparentColorMode mode) + => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode); /// [MethodImpl(InliningOptions.ShortMethod)] @@ -128,7 +119,7 @@ public struct OctreeQuantizer : IQuantizer // In this case, we must use the pixel map to get the closest color. if (this.isDithering) { - return (byte)this.pixelMap!.GetClosestColor(color, out match, this.transparencyThreshold); + return (byte)this.pixelMap!.GetClosestColor(color, out match); } ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.palette.Span); @@ -151,6 +142,21 @@ public struct OctreeQuantizer : IQuantizer } } + private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate + { + private readonly Octree octree; + + public PixelRowDelegate(Octree octree, TransparentColorMode mode) + { + this.octree = octree; + this.TransparentColorMode = mode; + } + + public TransparentColorMode TransparentColorMode { get; } + + public void Invoke(ReadOnlySpan row, int rowIndex) => this.octree.AddColors(row); + } + /// /// A hexadecatree-based color quantization structure used for fast color distance lookups and palette generation. /// This tree maintains a fixed pool of nodes (capacity 4096) where each node can have up to 16 children, stores @@ -159,6 +165,9 @@ public struct OctreeQuantizer : IQuantizer /// internal sealed class Octree : IDisposable { + // The memory allocator. + private readonly MemoryAllocator allocator; + // Pooled buffer for OctreeNodes. private readonly IMemoryOwner nodesOwner; @@ -172,7 +181,7 @@ public struct OctreeQuantizer : IQuantizer private readonly int maxColorBits; // The threshold for transparent colors. - private readonly short transparencyThreshold; + private readonly int transparencyThreshold255; // Instead of a reference to the root, we store the index of the root node. // Index 0 is reserved for the root. @@ -185,28 +194,43 @@ public struct OctreeQuantizer : IQuantizer private int previousNode; private Rgba32 previousColor; + // The color to use for pixels below the transparency threshold. + private Vector4 thresholdReplacementColor; + private Vector4 thresholdReplacementColorV4; + private readonly Rgba32 thresholdReplacementColorRgba; + // Free list for reclaimed node indices. private readonly Stack freeIndices = new(); /// /// Initializes a new instance of the class. /// + /// The configuration which allows altering default behavior or extending the library. /// The maximum number of significant bits in the image. /// The maximum number of colors to allow in the palette. /// The threshold for transparent colors. - /// The memory allocator. - public Octree(int maxColorBits, int maxColors, short transparencyThreshold, MemoryAllocator allocator) + /// The color to use for pixels below the transparency threshold. + public Octree( + Configuration configuration, + int maxColorBits, + int maxColors, + float transparencyThreshold, + Color thresholdReplacementColor) { this.maxColorBits = maxColorBits; this.maxColors = maxColors; - this.transparencyThreshold = transparencyThreshold; + this.transparencyThreshold255 = (int)(transparencyThreshold * 255F); + this.thresholdReplacementColor = thresholdReplacementColor.ToScaledVector4(); + this.thresholdReplacementColorV4 = this.thresholdReplacementColor * 255F; + this.thresholdReplacementColorRgba = thresholdReplacementColor.ToPixel(); this.Leaves = 0; this.previousNode = -1; this.previousColor = default; // Allocate a conservative buffer for nodes. const int capacity = 4096; - this.nodesOwner = allocator.Allocate(capacity, AllocationOptions.Clean); + this.allocator = configuration.MemoryAllocator; + this.nodesOwner = this.allocator.Allocate(capacity, AllocationOptions.Clean); // Create the reducible nodes array (one per level 0 .. maxColorBits-1). this.reducibleNodes = new short[this.maxColorBits]; @@ -228,11 +252,23 @@ public struct OctreeQuantizer : IQuantizer /// internal Span Nodes => this.nodesOwner.Memory.Span; + /// + /// Adds a span of colors to the octree. + /// + /// A span of color values to be added. + public void AddColors(ReadOnlySpan row) + { + for (int x = 0; x < row.Length; x++) + { + this.AddColor(row[x]); + } + } + /// /// Add a color to the Octree. /// /// The color to add. - public void AddColor(Rgba32 color) + private void AddColor(Rgba32 color) { // Ensure that the tree is not already full. if (this.nextNode >= this.Nodes.Length && this.freeIndices.Count == 0) @@ -243,11 +279,6 @@ public struct OctreeQuantizer : IQuantizer } } - if (color.A < this.transparencyThreshold) - { - color = default; - } - // If the color is the same as the previous color, increment the node. // Otherwise, add a new node. if (this.previousColor.Equals(color)) @@ -540,9 +571,9 @@ public struct OctreeQuantizer : IQuantizer Vector4.Zero, new Vector4(255)); - if (vector.W < octree.transparencyThreshold) + if (vector.W < octree.transparencyThreshold255) { - vector = default; + vector = octree.thresholdReplacementColorV4; } palette[paletteIndex] = TPixel.FromRgba32(new Rgba32((byte)vector.X, (byte)vector.Y, (byte)vector.Z, (byte)vector.W)); @@ -571,9 +602,9 @@ public struct OctreeQuantizer : IQuantizer /// The parent octree. public int GetPaletteIndex(Rgba32 color, int level, Octree octree) { - if (color.A < octree.transparencyThreshold) + if (color.A < octree.transparencyThreshold255) { - color = default; + color = octree.thresholdReplacementColorRgba; } if (this.Leaf) diff --git a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs index d734b36c30..712204bbfe 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -20,7 +21,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization; internal struct PaletteQuantizer : IQuantizer where TPixel : unmanaged, IPixel { - private readonly EuclideanPixelMap pixelMap; + private readonly PixelMap pixelMap; private int transparencyIndex; private TPixel transparentColor; @@ -58,7 +59,7 @@ internal struct PaletteQuantizer : IQuantizer this.Configuration = configuration; this.Options = options; - this.pixelMap = new EuclideanPixelMap(configuration, palette); + this.pixelMap = PixelMapFactory.Create(this.Configuration, palette, options.ColorMatchingMode); this.transparencyIndex = transparencyIndex; this.transparentColor = transparentColor; } @@ -74,15 +75,25 @@ internal struct PaletteQuantizer : IQuantizer /// [MethodImpl(InliningOptions.ShortMethod)] - public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds) - => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds); + public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion) + => this.AddPaletteColors(in pixelRegion, TransparentColorMode.Preserve); /// [MethodImpl(InliningOptions.ShortMethod)] - public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion) + public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion, TransparentColorMode mode) { } + /// + [MethodImpl(InliningOptions.ShortMethod)] + public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds) + => this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve); + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds, TransparentColorMode mode) + => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode); + /// [MethodImpl(InliningOptions.ShortMethod)] public readonly byte GetQuantizedColor(TPixel color, out TPixel match) diff --git a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs index 4f4104a8a3..7a66204c64 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs @@ -49,4 +49,15 @@ public class QuantizerOptions get => this.threshold; set => this.threshold = Numerics.Clamp(value, QuantizerConstants.MinTransparencyThreshold, QuantizerConstants.MaxTransparencyThreshold); } + + /// + /// Gets or sets the color used for replacing colors with an alpha component below the threshold. + /// Defaults to . + /// + public Color ThresholdReplacementColor { get; set; } = Color.Transparent; + + /// + /// Gets or sets the color matching mode used for matching pixel values to palette colors. + /// + public ColorMatchingMode ColorMatchingMode { get; set; } = ColorMatchingMode.Hybrid; } diff --git a/src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs b/src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs index 49eaa79abb..d2d9d68b4f 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs @@ -1,7 +1,11 @@ // Copyright (c) Six Labors. // 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 SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -14,6 +18,126 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization; /// public static class QuantizerUtilities { + /// + /// Determines if transparent pixels can be replaced based on the specified color mode and pixel type. + /// + /// The type of the pixel. + /// The alpha threshold used to determine if a pixel is transparent. + /// Returns true if transparent pixels can be replaced; otherwise, false. + public static bool ShouldReplacePixelsByAlphaThreshold(float threshold) + where TPixel : unmanaged, IPixel + => threshold > 0 && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated; + + /// + /// Replaces transparent pixels in a span with a specified color based on an alpha threshold. + /// + /// A span of color vectors that will be checked for transparency and potentially modified. + /// A color vector that will replace transparent pixels when the alpha value is below the specified threshold. + /// The alpha threshold used to determine if a pixel is transparent. + public static void ReplacePixelsByAlphaThreshold(Span source, Vector4 replacement, float threshold) + { + if (Vector512.IsHardwareAccelerated && source.Length >= 4) + { + Vector128 replacement128 = replacement.AsVector128(); + Vector256 replacement256 = Vector256.Create(replacement128, replacement128); + Vector512 replacement512 = Vector512.Create(replacement256, replacement256); + Vector512 threshold512 = Vector512.Create(threshold); + + Span> source512 = MemoryMarshal.Cast>(source); + for (int i = 0; i < source512.Length; i++) + { + ref Vector512 v = ref source512[i]; + + // Do `vector < threshold` + Vector512 mask = Vector512.LessThan(v, threshold512); + + // Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise) + mask = Vector512.Shuffle(mask, Vector512.Create(3, 3, 3, 3, 7, 7, 7, 7, 11, 11, 11, 11, 15, 15, 15, 15)); + + // Use the mask to select the replacement vector + // (replacement & mask) | (v512 & ~mask) + v = Vector512.ConditionalSelect(mask, replacement512, v); + } + + int m = Numerics.Modulo4(source.Length); + if (m != 0) + { + for (int i = source.Length - m; i < source.Length; i++) + { + if (source[i].W < threshold) + { + source[i] = replacement; + } + } + } + } + else if (Vector256.IsHardwareAccelerated && source.Length >= 2) + { + Vector128 replacement128 = replacement.AsVector128(); + Vector256 replacement256 = Vector256.Create(replacement128, replacement128); + Vector256 threshold256 = Vector256.Create(threshold); + + Span> source256 = MemoryMarshal.Cast>(source); + for (int i = 0; i < source256.Length; i++) + { + ref Vector256 v = ref source256[i]; + + // Do `vector < threshold` + Vector256 mask = Vector256.LessThan(v, threshold256); + + // Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise) + mask = Vector256.Shuffle(mask, Vector256.Create(3, 3, 3, 3, 7, 7, 7, 7)); + + // Use the mask to select the replacement vector + // (replacement & mask) | (v256 & ~mask) + v = Vector256.ConditionalSelect(mask, replacement256, v); + } + + int m = Numerics.Modulo2(source.Length); + if (m != 0) + { + for (int i = source.Length - m; i < source.Length; i++) + { + if (source[i].W < threshold) + { + source[i] = replacement; + } + } + } + } + else if (Vector128.IsHardwareAccelerated) + { + Vector128 replacement128 = replacement.AsVector128(); + Vector128 threshold128 = Vector128.Create(threshold); + + for (int i = 0; i < source.Length; i++) + { + ref Vector4 v = ref source[i]; + Vector128 v128 = v.AsVector128(); + + // Do `vector < threshold` + Vector128 mask = Vector128.LessThan(v128, threshold128); + + // Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise) + mask = Vector128.Shuffle(mask, Vector128.Create(3, 3, 3, 3)); + + // Use the mask to select the replacement vector + // (replacement & mask) | (v128 & ~mask) + v = Vector128.ConditionalSelect(mask, replacement128, v128).AsVector4(); + } + } + else + { + for (int i = 0; i < source.Length; i++) + { + if (source[i].W < threshold) + { + source[i] = replacement; + } + } + } + } + /// /// Helper method for throwing an exception when a frame quantizer palette has /// been requested but not built yet. @@ -21,12 +145,13 @@ public static class QuantizerUtilities /// The pixel format. /// The frame quantizer palette. /// - /// The palette has not been built via + /// The palette has not been built via /// + [MethodImpl(InliningOptions.ColdPath)] public static void CheckPaletteState(in ReadOnlyMemory palette) where TPixel : unmanaged, IPixel { - if (palette.Equals(default)) + if (palette.IsEmpty) { throw new InvalidOperationException("Frame Quantizer palette has not been built."); } @@ -47,6 +172,29 @@ public static class QuantizerUtilities ImageFrame source, Rectangle bounds) where TPixel : unmanaged, IPixel + => BuildPaletteAndQuantizeFrame( + quantizer, + source, + bounds, + TransparentColorMode.Preserve); + + /// + /// Execute both steps of the quantization. + /// + /// The pixel specific quantizer. + /// The source image frame to quantize. + /// The bounds within the frame to quantize. + /// The transparent color mode. + /// The pixel type. + /// + /// A representing a quantized version of the source frame pixels. + /// + public static IndexedImageFrame BuildPaletteAndQuantizeFrame( + this IQuantizer quantizer, + ImageFrame source, + Rectangle bounds, + TransparentColorMode mode) + where TPixel : unmanaged, IPixel { Guard.NotNull(quantizer, nameof(quantizer)); Guard.NotNull(source, nameof(source)); @@ -54,8 +202,7 @@ public static class QuantizerUtilities Rectangle interest = Rectangle.Intersect(source.Bounds, bounds); Buffer2DRegion region = source.PixelBuffer.GetRegion(interest); - // Collect the palette. Required before the second pass runs. - quantizer.AddPaletteColors(in region); + quantizer.AddPaletteColors(in region, mode); return quantizer.QuantizeFrame(source, bounds); } @@ -67,13 +214,15 @@ public static class QuantizerUtilities /// The pixel specific quantizer. /// The source image frame to quantize. /// The bounds within the frame to quantize. + /// The transparent color mode. /// /// A representing a quantized version of the source frame pixels. /// public static IndexedImageFrame QuantizeFrame( ref TFrameQuantizer quantizer, ImageFrame source, - Rectangle bounds) + Rectangle bounds, + TransparentColorMode mode) where TFrameQuantizer : struct, IQuantizer where TPixel : unmanaged, IPixel { @@ -88,13 +237,13 @@ public static class QuantizerUtilities if (quantizer.Options.Dither is null) { - SecondPass(ref quantizer, source, destination, interest); + SecondPass(ref quantizer, source, destination, interest, mode); } else { // We clone the image as we don't want to alter the original via error diffusion based dithering. using ImageFrame clone = source.Clone(); - SecondPass(ref quantizer, clone, destination, interest); + SecondPass(ref quantizer, clone, destination, interest, mode); } return destination; @@ -113,49 +262,28 @@ public static class QuantizerUtilities Image source) where TPixel : unmanaged, IPixel => quantizer.BuildPalette( - source.Configuration, TransparentColorMode.Preserve, pixelSamplingStrategy, - source, - Color.Transparent); + source); /// /// Adds colors to the quantized palette from the given pixel regions. /// /// The pixel format. /// The pixel specific quantizer. - /// The configuration. /// The transparent color mode. /// The pixel sampling strategy. /// The source image to sample from. - /// The background color to use when clearing transparent pixels. public static void BuildPalette( this IQuantizer quantizer, - Configuration configuration, TransparentColorMode mode, IPixelSamplingStrategy pixelSamplingStrategy, - Image source, - Color backgroundColor) + Image source) where TPixel : unmanaged, IPixel { - if (EncodingUtilities.ShouldClearTransparentPixels(mode)) + foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) { - foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) - { - // We need to clone the region to ensure we don't alter the original image. - using Buffer2D clone = region.Buffer.CloneRegion(configuration, region.Rectangle); - Buffer2DRegion clonedRegion = clone.GetRegion(); - - EncodingUtilities.ClearTransparentPixels(configuration, in clonedRegion, backgroundColor); - quantizer.AddPaletteColors(in clonedRegion); - } - } - else - { - foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) - { - quantizer.AddPaletteColors(in region); - } + quantizer.AddPaletteColors(in region, mode); } } @@ -172,83 +300,208 @@ public static class QuantizerUtilities ImageFrame source) where TPixel : unmanaged, IPixel => quantizer.BuildPalette( - source.Configuration, TransparentColorMode.Preserve, pixelSamplingStrategy, - source, - Color.Transparent); + source); /// /// Adds colors to the quantized palette from the given pixel regions. /// /// The pixel format. /// The pixel specific quantizer. - /// The configuration. /// The transparent color mode. /// The pixel sampling strategy. /// The source image frame to sample from. - /// The background color to use when clearing transparent pixels. public static void BuildPalette( this IQuantizer quantizer, - Configuration configuration, TransparentColorMode mode, IPixelSamplingStrategy pixelSamplingStrategy, - ImageFrame source, - Color backgroundColor) + ImageFrame source) + where TPixel : unmanaged, IPixel + { + foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) + { + quantizer.AddPaletteColors(in region, mode); + } + } + + internal static void AddPaletteColors( + ref TFrameQuantizer quantizer, + in Buffer2DRegion source, + in TDelegate rowDelegate) + where TFrameQuantizer : struct, IQuantizer where TPixel : unmanaged, IPixel + where TPixel2 : unmanaged, IPixel + where TDelegate : struct, IQuantizingPixelRowDelegate { - if (EncodingUtilities.ShouldClearTransparentPixels(mode)) + Configuration configuration = quantizer.Configuration; + float threshold = quantizer.Options.TransparencyThreshold; + Color replacement = quantizer.Options.ThresholdReplacementColor; + + using IMemoryOwner delegateRowOwner = configuration.MemoryAllocator.Allocate(source.Width); + Span delegateRow = delegateRowOwner.Memory.Span; + + bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold(threshold); + bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels(rowDelegate.TransparentColorMode); + + if (replaceByThreshold || replaceTransparent) { - // We need to clone the region to ensure we don't alter the original image. - foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) + using IMemoryOwner vectorRowOwner = configuration.MemoryAllocator.Allocate(source.Width); + Span vectorRow = vectorRowOwner.Memory.Span; + Vector4 replacementV4 = replacement.ToScaledVector4(); + + if (replaceByThreshold) { - using Buffer2D clone = region.Buffer.CloneRegion(configuration, region.Rectangle); - Buffer2DRegion clonedRegion = clone.GetRegion(); + for (int y = 0; y < source.Height; y++) + { + Span sourceRow = source.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold); - EncodingUtilities.ClearTransparentPixels(configuration, in clonedRegion, backgroundColor); - quantizer.AddPaletteColors(in clonedRegion); + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale); + rowDelegate.Invoke(delegateRow, y); + } + } + else + { + for (int y = 0; y < source.Height; y++) + { + Span sourceRow = source.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4); + + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale); + rowDelegate.Invoke(delegateRow, y); + } } } else { - foreach (Buffer2DRegion region in pixelSamplingStrategy.EnumeratePixelRegions(source)) + for (int y = 0; y < source.Height; y++) { - quantizer.AddPaletteColors(in region); + Span sourceRow = source.DangerousGetRowSpan(y); + PixelOperations.Instance.To(configuration, sourceRow, delegateRow); + rowDelegate.Invoke(delegateRow, y); } } } - [MethodImpl(InliningOptions.ShortMethod)] private static void SecondPass( ref TFrameQuantizer quantizer, ImageFrame source, IndexedImageFrame destination, - Rectangle bounds) + Rectangle bounds, + TransparentColorMode mode) where TFrameQuantizer : struct, IQuantizer where TPixel : unmanaged, IPixel { + float threshold = quantizer.Options.TransparencyThreshold; + Color replacement = quantizer.Options.ThresholdReplacementColor; + bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold(threshold); + bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels(mode); + Vector4 replacementV4 = replacement.ToScaledVector4(); + IDither? dither = quantizer.Options.Dither; Buffer2D sourceBuffer = source.PixelBuffer; + Buffer2DRegion region = sourceBuffer.GetRegion(bounds); + + Configuration configuration = quantizer.Configuration; + using IMemoryOwner vectorOwner = configuration.MemoryAllocator.Allocate(region.Width); + Span vectorRow = vectorOwner.Memory.Span; if (dither is null) { - int offsetY = bounds.Top; - int offsetX = bounds.Left; + using IMemoryOwner quantizingRowOwner = configuration.MemoryAllocator.Allocate(region.Width); + Span quantizingRow = quantizingRowOwner.Memory.Span; - for (int y = 0; y < destination.Height; y++) + // This is NOT a clone so we DO NOT write back to the source. + if (replaceByThreshold || replaceTransparent) { - ReadOnlySpan sourceRow = sourceBuffer.DangerousGetRowSpan(y + offsetY); + if (replaceByThreshold) + { + for (int y = 0; y < region.Height; y++) + { + Span sourceRow = region.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold); + + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale); + + Span destinationRow = destination.GetWritablePixelRowSpanUnsafe(y); + for (int x = 0; x < destinationRow.Length; x++) + { + destinationRow[x] = quantizer.GetQuantizedColor(quantizingRow[x], out TPixel _); + } + } + } + else + { + for (int y = 0; y < region.Height; y++) + { + Span sourceRow = region.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4); + + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale); + + Span destinationRow = destination.GetWritablePixelRowSpanUnsafe(y); + for (int x = 0; x < destinationRow.Length; x++) + { + destinationRow[x] = quantizer.GetQuantizedColor(quantizingRow[x], out TPixel _); + } + } + } + + return; + } + + for (int y = 0; y < region.Height; y++) + { + ReadOnlySpan sourceRow = region.DangerousGetRowSpan(y); Span destinationRow = destination.GetWritablePixelRowSpanUnsafe(y); for (int x = 0; x < destinationRow.Length; x++) { - destinationRow[x] = Unsafe.AsRef(in quantizer).GetQuantizedColor(sourceRow[x + offsetX], out TPixel _); + destinationRow[x] = quantizer.GetQuantizedColor(sourceRow[x], out TPixel _); } } return; } + // This is a clone so we write back to the source. + if (replaceByThreshold || replaceTransparent) + { + if (replaceByThreshold) + { + for (int y = 0; y < region.Height; y++) + { + Span sourceRow = region.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold); + + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale); + } + } + else + { + for (int y = 0; y < region.Height; y++) + { + Span sourceRow = region.DangerousGetRowSpan(y); + PixelOperations.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale); + + EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4); + + PixelOperations.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale); + } + } + } + dither.ApplyQuantizationDither(ref quantizer, source, destination, bounds); } } diff --git a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs index db6490259f..a132aa2813 100644 --- a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs +++ b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -43,30 +44,10 @@ internal struct WuQuantizer : IQuantizer // The following two variables determine the amount of bits to preserve when calculating the histogram. // Reducing the value of these numbers the granularity of the color maps produced, making it much faster // and using much less memory but potentially less accurate. Current results are very good though! - - /// - /// The index bits. 6 in original code. - /// private const int IndexBits = 5; - - /// - /// The index alpha bits. 3 in original code. - /// private const int IndexAlphaBits = 5; - - /// - /// The index count. - /// private const int IndexCount = (1 << IndexBits) + 1; - - /// - /// The index alpha count. - /// private const int IndexAlphaCount = (1 << IndexAlphaBits) + 1; - - /// - /// The table length. Now 1185921. originally 2471625. - /// private const int TableLength = IndexCount * IndexCount * IndexCount * IndexAlphaCount; private readonly IMemoryOwner momentsOwner; @@ -74,17 +55,18 @@ internal struct WuQuantizer : IQuantizer private readonly IMemoryOwner paletteOwner; private ReadOnlyMemory palette; private int maxColors; - private readonly float transparencyThreshold; - private readonly short transparencyThreshold255; private readonly Box[] colorCube; - private EuclideanPixelMap? pixelMap; + private PixelMap? pixelMap; private readonly bool isDithering; + private readonly int transparencyThreshold255; + private Vector4 thresholdReplacementColorV4; + private readonly Rgba32 thresholdReplacementColorRgba; private bool isDisposed; /// /// 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. [MethodImpl(InliningOptions.ShortMethod)] public WuQuantizer(Configuration configuration, QuantizerOptions options) @@ -104,8 +86,9 @@ internal struct WuQuantizer : IQuantizer this.pixelMap = default; this.palette = default; this.isDithering = this.Options.Dither is not null; - this.transparencyThreshold = this.Options.TransparencyThreshold; - this.transparencyThreshold255 = (short)(this.Options.TransparencyThreshold * 255); + this.transparencyThreshold255 = (int)(this.Options.TransparencyThreshold * 255F); + this.thresholdReplacementColorV4 = this.Options.ThresholdReplacementColor.ToScaledVector4(); + this.thresholdReplacementColorRgba = this.Options.ThresholdReplacementColor.ToPixel(); } /// @@ -130,8 +113,14 @@ internal struct WuQuantizer : IQuantizer } /// - public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion) - => this.Build3DHistogram(pixelRegion); + public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion, TransparentColorMode mode) + { + PixelRowDelegate pixelRowDelegate = new(ref Unsafe.AsRef(in this), mode); + QuantizerUtilities.AddPaletteColors, TPixel, Rgba32, PixelRowDelegate>( + ref Unsafe.AsRef(in this), + in pixelRegion, + in pixelRowDelegate); + } /// /// Once all histogram data has been accumulated, this method computes the moments, @@ -148,6 +137,9 @@ internal struct WuQuantizer : IQuantizer // Compute the palette colors from the resolved cubes. Span paletteSpan = this.paletteOwner.GetSpan()[..this.maxColors]; ReadOnlySpan momentsSpan = this.momentsOwner.GetSpan(); + + float transparencyThreshold = this.Options.TransparencyThreshold; + Vector4 thresholdReplacementColor = this.thresholdReplacementColorV4; for (int k = 0; k < paletteSpan.Length; k++) { this.Mark(ref this.colorCube[k], (byte)k); @@ -155,9 +147,9 @@ internal struct WuQuantizer : IQuantizer if (moment.Weight > 0) { Vector4 normalized = moment.Normalize(); - if (normalized.W < this.transparencyThreshold) + if (normalized.W < transparencyThreshold) { - normalized = Vector4.Zero; + normalized = thresholdReplacementColor; } paletteSpan[k] = TPixel.FromScaledVector4(normalized); @@ -170,14 +162,19 @@ internal struct WuQuantizer : IQuantizer // Create the pixel map if dithering is enabled. if (this.isDithering && this.pixelMap is null) { - this.pixelMap = new EuclideanPixelMap(this.Configuration, this.palette); + this.pixelMap = PixelMapFactory.Create(this.Configuration, this.palette, this.Options.ColorMatchingMode); } } /// [MethodImpl(InliningOptions.ShortMethod)] public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds) - => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds); + => this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve); + + /// + [MethodImpl(InliningOptions.ShortMethod)] + public readonly IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds, TransparentColorMode mode) + => QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode); /// public readonly byte GetQuantizedColor(TPixel color, out TPixel match) @@ -187,13 +184,13 @@ internal struct WuQuantizer : IQuantizer // In this case, we must use the pixel map to get the closest color. if (this.isDithering) { - return (byte)this.pixelMap!.GetClosestColor(color, out match, this.transparencyThreshold255); + return (byte)this.pixelMap!.GetClosestColor(color, out match); } Rgba32 rgba = color.ToRgba32(); if (rgba.A < this.transparencyThreshold255) { - rgba = default; + rgba = this.thresholdReplacementColorRgba; } const int shift = 8 - IndexBits; @@ -376,37 +373,19 @@ internal struct WuQuantizer : IQuantizer /// /// Builds a 3-D color histogram of counts, r/g/b, c^2. /// - /// The source pixel data. - private readonly void Build3DHistogram(in Buffer2DRegion source) + /// The source pixel data. + private readonly void Build3DHistogram(ReadOnlySpan pixels) { - Span momentSpan = this.momentsOwner.GetSpan(); - - // Build up the 3-D color histogram - using IMemoryOwner buffer = this.memoryAllocator.Allocate(source.Width); - Span bufferSpan = buffer.GetSpan(); - - float transparencyThreshold = this.Options.TransparencyThreshold * 255; - - for (int y = 0; y < source.Height; y++) + Span moments = this.momentsOwner.GetSpan(); + for (int x = 0; x < pixels.Length; x++) { - Span row = source.DangerousGetRowSpan(y); - PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan); - - for (int x = 0; x < bufferSpan.Length; x++) - { - Rgba32 rgba = bufferSpan[x]; - if (rgba.A < transparencyThreshold) - { - rgba = default; - } - - int r = (rgba.R >> (8 - IndexBits)) + 1; - int g = (rgba.G >> (8 - IndexBits)) + 1; - int b = (rgba.B >> (8 - IndexBits)) + 1; - int a = (rgba.A >> (8 - IndexAlphaBits)) + 1; + Rgba32 rgba = pixels[x]; + int r = (rgba.R >> (8 - IndexBits)) + 1; + int g = (rgba.G >> (8 - IndexBits)) + 1; + int b = (rgba.B >> (8 - IndexBits)) + 1; + int a = (rgba.A >> (8 - IndexAlphaBits)) + 1; - momentSpan[GetPaletteIndex(r, g, b, a)] += rgba; - } + moments[GetPaletteIndex(r, g, b, a)] += rgba; } } @@ -918,4 +897,19 @@ internal struct WuQuantizer : IQuantizer return hash.ToHashCode(); } } + + private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate + { + private readonly WuQuantizer quantizer; + + public PixelRowDelegate(ref WuQuantizer quantizer, TransparentColorMode mode) + { + this.quantizer = quantizer; + this.TransparentColorMode = mode; + } + + public TransparentColorMode TransparentColorMode { get; } + + public void Invoke(ReadOnlySpan row, int rowIndex) => this.quantizer.Build3DHistogram(row); + } } diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs index 748f505cce..44ed5e38dd 100644 --- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs @@ -56,7 +56,7 @@ public class GifEncoderTests { // Use the palette quantizer without dithering to ensure results // are consistent - Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = null }) + Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = null, TransparencyThreshold = 0 }) }; // Always save as we need to compare the encoded output. diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs index 2a53e4de79..6836b98500 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs @@ -419,8 +419,8 @@ public partial class PngEncoderTests using Image output = Image.Load(memStream); - // some loss from original, due to compositing - ImageComparer.TolerantPercentage(0.01f).VerifySimilarity(output, image); + // Some loss from original, due to palette matching accuracy. + ImageComparer.TolerantPercentage(0.172F).VerifySimilarity(output, image); Assert.Equal(image.Frames.Count, output.Frames.Count);