Browse Source

Use different cache types and begin to make quantizers own clearing pixels.

pull/2894/head
James Jackson-South 1 year ago
parent
commit
6322bcb7d5
  1. 7
      src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
  2. 119
      src/ImageSharp/Formats/EncodingUtilities.cs
  3. 28
      src/ImageSharp/Formats/Gif/GifEncoderCore.cs
  4. 6
      src/ImageSharp/Formats/IAnimatedImageEncoder.cs
  5. 2
      src/ImageSharp/Formats/ISpecializedDecoderOptions.cs
  6. 5
      src/ImageSharp/Formats/Png/PngEncoder.cs
  7. 23
      src/ImageSharp/Formats/Png/PngEncoderCore.cs
  8. 4
      src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs
  9. 4
      src/ImageSharp/Formats/Tga/TgaEncoderCore.cs
  10. 4
      src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs
  11. 4
      src/ImageSharp/IndexedImageFrame{TPixel}.cs
  12. 0
      src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs
  13. 4
      src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
  14. 28
      src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
  15. 184
      src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
  16. 545
      src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
  17. 491
      src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
  18. 6
      src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs
  19. 35
      src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs
  20. 27
      src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs
  21. 121
      src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
  22. 21
      src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
  23. 11
      src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
  24. 365
      src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs
  25. 118
      src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
  26. 2
      tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
  27. 4
      tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs

7
src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs

@ -362,10 +362,13 @@ internal sealed class BmpEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(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<TPixel>(this.transparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

119
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;
/// </summary>
internal static class EncodingUtilities
{
public static bool ShouldClearTransparentPixels<TPixel>(TransparentColorMode mode)
/// <summary>
/// Determines if transparent pixels can be replaced based on the specified color mode and pixel type.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="mode">Indicates the color mode used to assess the ability to replace transparent pixels.</param>
/// <returns>Returns true if transparent pixels can be replaced; otherwise, false.</returns>
public static bool ShouldReplaceTransparentPixels<TPixel>(TransparentColorMode mode)
where TPixel : unmanaged, IPixel<TPixel>
=> mode == TransparentColorMode.Clear &&
TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
=> mode == TransparentColorMode.Clear && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
/// <summary>
/// Convert transparent pixels, to pixels represented by <paramref name="color"/>, which can yield
/// to better compression in some cases.
/// Replaces transparent pixels with pixels represented by <paramref name="color"/>.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="frame">The <see cref="ImageFrame{TPixel}"/> where the transparent pixels will be changed.</param>
/// <param name="color">The color to replace transparent pixels with.</param>
public static void ClearTransparentPixels<TPixel>(ImageFrame<TPixel> frame, Color color)
public static void ReplaceTransparentPixels<TPixel>(ImageFrame<TPixel> frame, Color color)
where TPixel : unmanaged, IPixel<TPixel>
=> ClearTransparentPixels(frame.Configuration, frame.PixelBuffer, color);
=> ReplaceTransparentPixels(frame.Configuration, frame.PixelBuffer, color);
/// <summary>
/// Convert transparent pixels, to pixels represented by <paramref name="color"/>, which can yield
/// to better compression in some cases.
/// Replaces transparent pixels with pixels represented by <paramref name="color"/>.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="configuration">The configuration.</param>
/// <param name="buffer">The <see cref="Buffer2D{TPixel}"/> where the transparent pixels will be changed.</param>
/// <param name="color">The color to replace transparent pixels with.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ClearTransparentPixels<TPixel>(
public static void ReplaceTransparentPixels<TPixel>(
Configuration configuration,
Buffer2D<TPixel> buffer,
Color color)
where TPixel : unmanaged, IPixel<TPixel>
{
Buffer2DRegion<TPixel> region = buffer.GetRegion();
ClearTransparentPixels(configuration, in region, color);
ReplaceTransparentPixels(configuration, in region, color);
}
/// <summary>
/// Convert transparent pixels, to pixels represented by <paramref name="color"/>, which can yield
/// to better compression in some cases.
/// Replaces transparent pixels with pixels represented by <paramref name="color"/>.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="configuration">The configuration.</param>
/// <param name="region">The <see cref="Buffer2DRegion{T}"/> where the transparent pixels will be changed.</param>
/// <param name="color">The color to replace transparent pixels with.</param>
public static void ClearTransparentPixels<TPixel>(
public static void ReplaceTransparentPixels<TPixel>(
Configuration configuration,
in Buffer2DRegion<TPixel> region,
Color color)
@ -71,20 +74,92 @@ internal static class EncodingUtilities
{
Span<TPixel> span = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, span, vectorsSpan, PixelConversionModifiers.Scale);
ClearTransparentPixelRow(vectorsSpan, replacement);
ReplaceTransparentPixels(vectorsSpan, replacement);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorsSpan, span, PixelConversionModifiers.Scale);
}
}
private static void ClearTransparentPixelRow(Span<Vector4> vectorsSpan, Vector4 replacement)
/// <summary>
/// Replaces transparent pixels with pixels represented by <paramref name="replacement"/>.
/// </summary>
/// <param name="source">A span of color vectors that will be checked for transparency and potentially modified.</param>
/// <param name="replacement">A color vector that will replace transparent pixels when the alpha value is below the specified threshold.</param>
public static void ReplaceTransparentPixels(Span<Vector4> source, Vector4 replacement)
{
if (Vector128.IsHardwareAccelerated)
if (Vector512.IsHardwareAccelerated && source.Length >= 4)
{
Vector128<float> replacement128 = replacement.AsVector128();
Vector256<float> replacement256 = Vector256.Create(replacement128, replacement128);
Vector512<float> replacement512 = Vector512.Create(replacement256, replacement256);
Span<Vector512<float>> source512 = MemoryMarshal.Cast<Vector4, Vector512<float>>(source);
for (int i = 0; i < source512.Length; i++)
{
ref Vector512<float> v = ref source512[i];
// Do `vector < threshold`
Vector512<float> mask = Vector512.Equals(v, Vector512<float>.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<float> replacement128 = replacement.AsVector128();
Vector256<float> replacement256 = Vector256.Create(replacement128, replacement128);
Span<Vector256<float>> source256 = MemoryMarshal.Cast<Vector4, Vector256<float>>(source);
for (int i = 0; i < source256.Length; i++)
{
ref Vector256<float> v = ref source256[i];
// Do `vector < threshold`
Vector256<float> mask = Vector256.Equals(v, Vector256<float>.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<float> 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<float> 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;
}
}
}

28
src/ImageSharp/Formats/Gif/GifEncoderCore.cs

@ -151,35 +151,20 @@ internal sealed class GifEncoderCore
Color background = Color.Transparent;
using (IQuantizer<TPixel> frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration))
{
ImageFrame<TPixel>? clonedFrame = null;
Configuration configuration = this.configuration;
TransparentColorMode mode = this.transparentColorMode;
IPixelSamplingStrategy strategy = this.pixelSamplingStrategy;
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(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<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;
ImageFrame<TPixel> 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<TPixel>(this.transparentColorMode))
{
EncodingUtilities.ClearTransparentPixels(encodingFrame, background);
}
using IndexedImageFrame<TPixel> quantized = this.QuantizeAdditionalFrameAndUpdateMetadata(
encodingFrame,
bounds,

6
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 <see cref="FrameDisposalMode.RestoreToBackground"/>.
/// </summary>
Color? BackgroundColor { get; }
public Color? BackgroundColor { get; }
/// <summary>
/// Gets the number of times any animation is repeated in supported encoders.
/// </summary>
ushort? RepeatCount { get; }
public ushort? RepeatCount { get; }
/// <summary>
/// Gets a value indicating whether the root frame is shown as part of the animated sequence in supported encoders.
/// </summary>
bool? AnimateRootFrame { get; }
public bool? AnimateRootFrame { get; }
}
/// <summary>

2
src/ImageSharp/Formats/ISpecializedDecoderOptions.cs

@ -11,5 +11,5 @@ public interface ISpecializedDecoderOptions
/// <summary>
/// Gets the general decoder options.
/// </summary>
DecoderOptions GeneralOptions { get; init; }
public DecoderOptions GeneralOptions { get; init; }
}

5
src/ImageSharp/Formats/Png/PngEncoder.cs

@ -41,11 +41,6 @@ public class PngEncoder : QuantizingAnimatedImageEncoder
/// <value>The gamma value of the image.</value>
public float? Gamma { get; init; }
/// <summary>
/// Gets the transparency threshold.
/// </summary>
public byte Threshold { get; init; } = byte.MaxValue;
/// <summary>
/// Gets a value indicating whether this instance should write an Adam7 interlaced image.
/// </summary>

23
src/ImageSharp/Formats/Png/PngEncoderCore.cs

@ -189,12 +189,15 @@ internal sealed class PngEncoderCore : IDisposable
{
int currentFrameIndex = 0;
bool clearTransparency = EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.encoder.TransparentColorMode);
if (clearTransparency)
bool clearTransparency = EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(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<TPixel>? 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);
}

4
src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs

@ -90,10 +90,10 @@ internal class QoiEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.encoder.TransparentColorMode))
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.encoder.TransparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

4
src/ImageSharp/Formats/Tga/TgaEncoderCore.cs

@ -110,10 +110,10 @@ internal sealed class TgaEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

4
src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs

@ -146,10 +146,10 @@ internal sealed class TiffEncoderCore
{
cancellationToken.ThrowIfCancellationRequested();
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = frame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame, Color.Transparent);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? frame;

4
src/ImageSharp/IndexedImageFrame{TPixel}.cs

@ -25,7 +25,7 @@ public sealed class IndexedImageFrame<TPixel> : IPixelSource, IDisposable
/// Initializes a new instance of the <see cref="IndexedImageFrame{TPixel}"/> class.
/// </summary>
/// <param name="configuration">
/// The configuration which allows altering default behaviour or extending the library.
/// The configuration which allows altering default behavior or extending the library.
/// </param>
/// <param name="width">The frame width.</param>
/// <param name="height">The frame height.</param>
@ -49,7 +49,7 @@ public sealed class IndexedImageFrame<TPixel> : IPixelSource, IDisposable
}
/// <summary>
/// Gets the configuration which allows altering default behaviour or extending the library.
/// Gets the configuration which allows altering default behavior or extending the library.
/// </summary>
public Configuration Configuration { get; }

0
src/ImageSharp/Processing/Processors/Dithering/ErroDither.KnownTypes.cs → src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs

4
src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs

@ -80,7 +80,7 @@ internal sealed class PaletteDitherProcessor<TPixel> : ImageProcessor<TPixel>
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
internal readonly struct DitherProcessor : IPaletteDitherImageProcessor<TPixel>, IDisposable
{
private readonly EuclideanPixelMap<TPixel> pixelMap;
private readonly PixelMap<TPixel> pixelMap;
[MethodImpl(InliningOptions.ShortMethod)]
public DitherProcessor(
@ -89,7 +89,7 @@ internal sealed class PaletteDitherProcessor<TPixel> : ImageProcessor<TPixel>
float ditherScale)
{
this.Configuration = configuration;
this.pixelMap = new EuclideanPixelMap<TPixel>(configuration, palette);
this.pixelMap = PixelMapFactory.Create(configuration, palette, ColorMatchingMode.Hybrid);
this.Palette = palette;
this.DitherScale = ditherScale;
}

28
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;
/// <summary>
/// Defines the precision level used when matching colors during quantization.
/// </summary>
public enum ColorMatchingMode
{
/// <summary>
/// Uses a coarse caching strategy optimized for performance at the expense of exact matches.
/// This provides the fastest matching but may yield approximate results.
/// </summary>
Coarse,
/// <summary>
/// Enables an exact color match cache for the first 512 unique colors encountered,
/// falling back to coarse matching thereafter.
/// </summary>
Hybrid,
/// <summary>
/// Performs exact color matching without any caching optimizations.
/// This is the slowest but most accurate matching strategy.
/// </summary>
Exact
}

184
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;
/// <summary>
/// Gets the closest color to the supplied color based upon the Euclidean distance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <typeparam name="TCache">The cache type.</typeparam>
/// <para>
/// This class is not thread safe and should not be accessed in parallel.
/// Doing so will result in non-idempotent results.
/// </para>
internal sealed class EuclideanPixelMap<TPixel, TCache> : PixelMap<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
where TCache : struct, IColorIndexCache<TCache>
{
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;
/// <summary>
/// Initializes a new instance of the <see cref="EuclideanPixelMap{TPixel, TCache}"/> class.
/// </summary>
/// <param name="configuration">Specifies the settings and resources for the pixel map's operations.</param>
/// <param name="palette">Defines the color palette used for pixel mapping.</param>
public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory<TPixel> palette)
{
this.configuration = configuration;
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
this.cache = TCache.Create(configuration.MemoryAllocator);
PixelOperations<TPixel>.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
}
/// <inheritdoc/>
[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);
}
/// <inheritdoc/>
public override void Clear(ReadOnlyMemory<TPixel> palette)
{
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
PixelOperations<TPixel>.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;
}
/// <summary>
/// Returns the Euclidean distance squared between two specified points.
/// </summary>
/// <param name="a">The first point.</param>
/// <param name="b">The second point.</param>
/// <returns>The distance squared.</returns>
[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);
}
/// <inheritdoc/>
public override void Dispose() => this.cache.Dispose();
}
/// <summary>
/// Represents a map of colors to indices.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
internal abstract class PixelMap<TPixel> : IDisposable
where TPixel : unmanaged, IPixel<TPixel>
{
/// <summary>
/// Gets the color palette of this <see cref="PixelMap{TPixel}"/>.
/// </summary>
public ReadOnlyMemory<TPixel> Palette { get; private protected set; }
/// <summary>
/// Returns the closest color in the palette and the index of that pixel.
/// </summary>
/// <param name="color">The color to match.</param>
/// <param name="match">The matched color.</param>
/// <returns>
/// The <see cref="int"/> index.
/// </returns>
public abstract int GetClosestColor(TPixel color, out TPixel match);
/// <summary>
/// Clears the map, resetting it to use the given palette.
/// </summary>
/// <param name="palette">The color palette to map from.</param>
public abstract void Clear(ReadOnlyMemory<TPixel> palette);
/// <inheritdoc/>
public abstract void Dispose();
}
/// <summary>
/// A factory for creating <see cref="PixelMap{TPixel}"/> instances.
/// </summary>
internal static class PixelMapFactory
{
/// <summary>
/// Creates a new <see cref="PixelMap{TPixel}"/> instance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="configuration">The configuration.</param>
/// <param name="palette">The color palette to map from.</param>
/// <param name="colorMatchingMode">The color matching mode.</param>
/// <returns>
/// The <see cref="PixelMap{TPixel}"/>.
/// </returns>
public static PixelMap<TPixel> Create<TPixel>(
Configuration configuration,
ReadOnlyMemory<TPixel> palette,
ColorMatchingMode colorMatchingMode)
where TPixel : unmanaged, IPixel<TPixel> => colorMatchingMode switch
{
ColorMatchingMode.Hybrid => new EuclideanPixelMap<TPixel, HybridCache>(configuration, palette),
ColorMatchingMode.Exact => new EuclideanPixelMap<TPixel, NullCache>(configuration, palette),
_ => new EuclideanPixelMap<TPixel, CoarseCache>(configuration, palette),
};
}

545
src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs

@ -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;
/// <summary>
/// Gets the closest color to the supplied color based upon the Euclidean distance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <para>
/// This class is not thread safe and should not be accessed in parallel.
/// Doing so will result in non-idempotent results.
/// </para>
internal sealed class EuclideanPixelMap<TPixel> : IDisposable
where TPixel : unmanaged, IPixel<TPixel>
{
private Rgba32[] rgbaPalette;
private readonly HybridColorDistanceCache cache;
private readonly Configuration configuration;
/// <summary>
/// Initializes a new instance of the <see cref="EuclideanPixelMap{TPixel}"/> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="palette">The color palette to map from.</param>
public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory<TPixel> palette)
{
this.configuration = configuration;
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
this.cache = new HybridColorDistanceCache(configuration.MemoryAllocator);
PixelOperations<TPixel>.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
}
/// <summary>
/// Gets the color palette of this <see cref="EuclideanPixelMap{TPixel}"/>.
/// The palette memory is owned by the palette source that created it.
/// </summary>
public ReadOnlyMemory<TPixel> Palette { get; private set; }
/// <summary>
/// Returns the closest color in the palette and the index of that pixel.
/// The palette contents must match the one used in the constructor.
/// </summary>
/// <param name="color">The color to match.</param>
/// <param name="match">The matched color.</param>
/// <param name="transparencyThreshold">The transparency threshold.</param>
/// <returns>The <see cref="int"/> index.</returns>
[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);
}
/// <summary>
/// Clears the map, resetting it to use the given palette.
/// </summary>
/// <param name="palette">The color palette to map from.</param>
public void Clear(ReadOnlyMemory<TPixel> palette)
{
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
PixelOperations<TPixel>.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;
}
/// <summary>
/// Returns the Euclidean distance squared between two specified points.
/// </summary>
/// <param name="a">The first point.</param>
/// <param name="b">The second point.</param>
/// <returns>The distance squared.</returns>
[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();
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
#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();
}
}
/// <summary>
/// A fixed-capacity dictionary with exactly 512 entries mapping a <see cref="uint"/> key
/// to a <see cref="short"/> value.
/// </summary>
/// <remarks>
/// 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 &amp; 0x1FF), and collisions are
/// resolved through a linked chain stored in the <see cref="Entry.Next"/> 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.
/// </remarks>
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<short> bucketsOwner;
private MemoryHandle bucketsHandle;
private short* buckets;
// Entries array: stores up to 256 entries.
private readonly IMemoryOwner<Entry> 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<short>(Capacity, AllocationOptions.Clean);
Span<short> 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<Entry>(Capacity, AllocationOptions.Clean);
this.entriesHandle = this.entriesOwner.Memory.Pin();
this.entries = (Entry*)this.entriesHandle.Pointer;
}
public int Count { get; private set; }
/// <summary>
/// Adds a key/value pair to the dictionary.
/// If the key already exists, the dictionary is left unchanged.
/// </summary>
/// <param name="key">The key to add.</param>
/// <param name="value">The value to add.</param>
/// <returns><see langword="true"/> if the key was added; otherwise, <see langword="false"/>.</returns>
[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;
}
/// <summary>
/// Tries to retrieve the value associated with the specified key.
/// Returns true if the key is found; otherwise, returns false.
/// </summary>
/// <param name="key">The key to search for.</param>
/// <param name="value">The value associated with the key, if found.</param>
/// <returns><see langword="true"/> if the key is found; otherwise, <see langword="false"/>.</returns>
[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;
}
/// <summary>
/// Clears the dictionary.
/// </summary>
public void Clear()
{
Span<short> 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.
}
}
/// <summary>
/// <para>
/// 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).
/// </para>
/// <para>
/// 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 <see cref="AlphaBucket"/>, 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).
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Memory Characteristics:
/// - The cache consists of 32,768 buckets.
/// - Each <see cref="AlphaBucket"/> 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).
/// </para>
/// <para>
/// 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).
/// </para>
/// </summary>
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<AlphaBucket> bucketsOwner;
private readonly AlphaBucket* buckets;
private MemoryHandle bucketHandle;
public CoarseCache(MemoryAllocator allocator)
{
this.bucketsOwner = allocator.Allocate<AlphaBucket>(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<AlphaBucket> 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<AlphaEntry> 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;
}
}
}
}
}

491
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;
/// <summary>
/// Represents a cache used for efficiently retrieving palette indices for colors.
/// </summary>
internal interface IColorIndexCache : IDisposable
{
/// <summary>
/// Adds a color to the cache.
/// </summary>
/// <param name="color">The color to add.</param>
/// <param name="value">The index of the color in the palette.</param>
/// <returns>
/// <see langword="true"/> if the color was added; otherwise, <see langword="false"/>.
/// </returns>
public bool TryAdd(Rgba32 color, short value);
/// <summary>
/// Gets the index of the color in the palette.
/// </summary>
/// <param name="color">The color to get the index for.</param>
/// <param name="value">The index of the color in the palette.</param>
/// <returns>
/// <see langword="true"/> if the color is in the palette; otherwise, <see langword="false"/>.
/// </returns>
public bool TryGetValue(Rgba32 color, out short value);
/// <summary>
/// Clears the cache.
/// </summary>
public void Clear();
}
/// <summary>
/// Represents a cache used for efficiently retrieving palette indices for colors.
/// </summary>
/// <typeparam name="T">The type of the cache.</typeparam>
internal interface IColorIndexCache<T> : IColorIndexCache
where T : struct, IColorIndexCache
{
/// <summary>
/// Creates a new instance of the cache.
/// </summary>
/// <param name="allocator">The memory allocator to use.</param>
/// <returns>
/// The new instance of the cache.
/// </returns>
public static abstract T Create(MemoryAllocator allocator);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal unsafe struct HybridCache : IColorIndexCache<HybridCache>
{
private CoarseCache coarseCache;
private ExactCache exactCache;
public HybridCache(MemoryAllocator allocator)
{
this.exactCache = ExactCache.Create(allocator);
this.coarseCache = CoarseCache.Create(allocator);
}
/// <inheritdoc/>
public static HybridCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryAdd(Rgba32 color, short index)
{
if (this.exactCache.TryAdd(color, index))
{
return true;
}
return this.coarseCache.TryAdd(color, index);
}
/// <inheritdoc/>
[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);
}
/// <inheritdoc/>
public readonly void Clear()
{
this.exactCache.Clear();
this.coarseCache.Clear();
}
/// <inheritdoc/>
public void Dispose()
{
this.exactCache.Dispose();
this.coarseCache.Dispose();
}
}
/// <summary>
/// <para>
/// 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).
/// </para>
/// <para>
/// 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 <see cref="AlphaBucket"/>, 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).
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Memory Characteristics:
/// - The cache consists of 32,768 buckets.
/// - Each <see cref="AlphaBucket"/> 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).
/// </para>
/// <para>
/// 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).
/// </para>
/// </summary>
internal unsafe struct CoarseCache : IColorIndexCache<CoarseCache>
{
// 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<AlphaBucket> bucketsOwner;
private readonly AlphaBucket* buckets;
private MemoryHandle bucketHandle;
private CoarseCache(MemoryAllocator allocator)
{
this.bucketsOwner = allocator.Allocate<AlphaBucket>(BucketCount, AllocationOptions.Clean);
this.bucketHandle = this.bucketsOwner.Memory.Pin();
this.buckets = (AlphaBucket*)this.bucketHandle.Pointer;
}
/// <inheritdoc/>
public static CoarseCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
public readonly void Clear()
{
Span<AlphaBucket> bucketsSpan = this.bucketsOwner.GetSpan();
bucketsSpan.Clear();
}
/// <inheritdoc/>
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<AlphaEntry> 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;
}
}
}
}
/// <summary>
/// A fixed-capacity dictionary with exactly 512 entries mapping a <see cref="uint"/> key
/// to a <see cref="short"/> value.
/// </summary>
/// <remarks>
/// 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 &amp; 0x1FF), and collisions are
/// resolved through a linked chain stored in the <see cref="Entry.Next"/> 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.
/// </remarks>
internal unsafe struct ExactCache : IColorIndexCache<ExactCache>
{
// 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<short> bucketsOwner;
private MemoryHandle bucketsHandle;
private short* buckets;
// Entries array: stores up to 256 entries.
private readonly IMemoryOwner<Entry> 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<short>(Capacity, AllocationOptions.Clean);
Span<short> 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<Entry>(Capacity, AllocationOptions.Clean);
this.entriesHandle = this.entriesOwner.Memory.Pin();
this.entries = (Entry*)this.entriesHandle.Pointer;
}
public int Count { get; private set; }
/// <inheritdoc/>
public static ExactCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
[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;
}
/// <inheritdoc/>
[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;
}
/// <summary>
/// Clears the dictionary.
/// </summary>
public void Clear()
{
Span<short> 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.
}
}
/// <summary>
/// Represents a cache that does not store any values.
/// It allows adding colors, but always returns false when trying to retrieve them.
/// </summary>
internal readonly struct NullCache : IColorIndexCache<NullCache>
{
/// <inheritdoc/>
public static NullCache Create(MemoryAllocator allocator) => default;
/// <inheritdoc/>
public bool TryAdd(Rgba32 color, short value) => true;
/// <inheritdoc/>
public bool TryGetValue(Rgba32 color, out short value)
{
value = -1;
return false;
}
/// <inheritdoc/>
public void Clear()
{
}
/// <inheritdoc/>
public void Dispose()
{
}
}

6
src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs

@ -13,7 +13,7 @@ public interface IQuantizer
/// <summary>
/// Gets the quantizer options defining quantization rules.
/// </summary>
QuantizerOptions Options { get; }
public QuantizerOptions Options { get; }
/// <summary>
/// Creates the generic frame quantizer.
@ -21,7 +21,7 @@ public interface IQuantizer
/// <param name="configuration">The <see cref="Configuration"/> to configure internal operations.</param>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <returns>The <see cref="IQuantizer{TPixel}"/>.</returns>
IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration)
public IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration)
where TPixel : unmanaged, IPixel<TPixel>;
/// <summary>
@ -31,6 +31,6 @@ public interface IQuantizer
/// <param name="configuration">The <see cref="Configuration"/> to configure internal operations.</param>
/// <param name="options">The options to create the quantizer with.</param>
/// <returns>The <see cref="IQuantizer{TPixel}"/>.</returns>
IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration, QuantizerOptions options)
public IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration, QuantizerOptions options)
where TPixel : unmanaged, IPixel<TPixel>;
}

35
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<TPixel> : IDisposable
/// Gets the quantized color palette.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The palette has not been built via <see cref="AddPaletteColors"/>.
/// The palette has not been built via <see cref="AddPaletteColors(in Buffer2DRegion{TPixel})"/>.
/// </exception>
public ReadOnlyMemory<TPixel> Palette { get; }
@ -35,21 +36,45 @@ public interface IQuantizer<TPixel> : IDisposable
/// Adds colors to the quantized palette from the given pixel source.
/// </summary>
/// <param name="pixelRegion">The <see cref="Buffer2DRegion{T}"/> of source pixels to register.</param>
public void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion);
public void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
=> this.AddPaletteColors(pixelRegion, TransparentColorMode.Preserve);
/// <summary>
/// Adds colors to the quantized palette from the given pixel source.
/// </summary>
/// <param name="pixelRegion">The <see cref="Buffer2DRegion{T}"/> of source pixels to register.</param>
/// <param name="mode">The <see cref="TransparentColorMode"/> to use when adding colors to the palette.</param>
public void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion, TransparentColorMode mode);
/// <summary>
/// Quantizes an image frame and return the resulting output pixels.
/// </summary>
/// <param name="source">The source image frame to quantize.</param>
/// <param name="bounds">The bounds within the frame to quantize.</param>
/// <returns>
/// A <see cref="IndexedImageFrame{TPixel}"/> representing a quantized version of the source frame pixels.
/// </returns>
/// <remarks>
/// Only executes the second (quantization) step. The palette has to be built by calling <see cref="AddPaletteColors(in Buffer2DRegion{TPixel})"/>.
/// To run both steps, use <see cref="QuantizerUtilities.BuildPaletteAndQuantizeFrame{TPixel}(IQuantizer{TPixel}, ImageFrame{TPixel}, Rectangle)"/>.
/// </remarks>
public IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve);
/// <summary>
/// Quantizes an image frame and return the resulting output pixels.
/// </summary>
/// <param name="source">The source image frame to quantize.</param>
/// <param name="bounds">The bounds within the frame to quantize.</param>
/// <param name="mode">The <see cref="TransparentColorMode"/> to use when quantizing the frame.</param>
/// <returns>
/// A <see cref="IndexedImageFrame{TPixel}"/> representing a quantized version of the source frame pixels.
/// </returns>
/// <remarks>
/// Only executes the second (quantization) step. The palette has to be built by calling <see cref="AddPaletteColors"/>.
/// To run both steps, use <see cref="QuantizerUtilities.BuildPaletteAndQuantizeFrame{TPixel}"/>.
/// Only executes the second (quantization) step. The palette has to be built by calling <see cref="AddPaletteColors(in Buffer2DRegion{TPixel})"/>.
/// To run both steps, use <see cref="QuantizerUtilities.BuildPaletteAndQuantizeFrame{TPixel}(IQuantizer{TPixel}, ImageFrame{TPixel}, Rectangle)"/>.
/// </remarks>
public IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds);
public IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds, TransparentColorMode mode);
/// <summary>
/// Returns the index and color from the quantized palette corresponding to the given color.

27
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;
/// <summary>
/// Defines a delegate for processing a row of pixels in an image for quantization.
/// </summary>
/// <typeparam name="TPixel">Represents a pixel type that can be processed in a quantizing operation.</typeparam>
internal interface IQuantizingPixelRowDelegate<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
/// <summary>
/// Gets the transparent color mode to use when adding colors to the palette.
/// </summary>
public TransparentColorMode TransparentColorMode { get; }
/// <summary>
/// Processes a row of pixels for quantization.
/// </summary>
/// <param name="row">The row of pixels to process.</param>
/// <param name="rowIndex">The index of the row being processed.</param>
public void Invoke(ReadOnlySpan<TPixel> row, int rowIndex);
}

121
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;
/// <see href="http://msdn.microsoft.com/en-us/library/aa479306.aspx"/>
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
[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<TPixel> : IQuantizer<TPixel>
#pragma warning restore CA1001 // Types that own disposable fields should be disposable
where TPixel : unmanaged, IPixel<TPixel>
{
private readonly int maxColors;
@ -28,15 +28,14 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
private readonly Octree octree;
private readonly IMemoryOwner<TPixel> paletteOwner;
private ReadOnlyMemory<TPixel> palette;
private EuclideanPixelMap<TPixel>? pixelMap;
private PixelMap<TPixel>? pixelMap;
private readonly bool isDithering;
private readonly short transparencyThreshold;
private bool isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="OctreeQuantizer{TPixel}"/> struct.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behaviour or extending the library.</param>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public OctreeQuantizer(Configuration configuration, QuantizerOptions options)
@ -45,9 +44,8 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
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<TPixel>(this.maxColors, AllocationOptions.Clean);
this.pixelMap = default;
this.palette = default;
@ -77,25 +75,13 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
}
/// <inheritdoc/>
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion, TransparentColorMode mode)
{
using IMemoryOwner<Rgba32> buffer = this.Configuration.MemoryAllocator.Allocate<Rgba32>(pixelRegion.Width);
Span<Rgba32> bufferSpan = buffer.GetSpan();
// Loop through each row
for (int y = 0; y < pixelRegion.Height; y++)
{
Span<TPixel> row = pixelRegion.DangerousGetRowSpan(y);
PixelOperations<TPixel>.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<OctreeQuantizer<TPixel>, TPixel, Rgba32, PixelRowDelegate>(
ref Unsafe.AsRef(in this),
in pixelRegion,
in pixelRowDelegate);
}
private void ResolvePalette()
@ -108,7 +94,7 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
if (this.isDithering)
{
this.pixelMap = new EuclideanPixelMap<TPixel>(this.Configuration, result);
this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode);
}
this.palette = result;
@ -117,7 +103,12 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds);
=> this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds, TransparentColorMode mode)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
@ -128,7 +119,7 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
// 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<TPixel> : IQuantizer<TPixel>
}
}
private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate<Rgba32>
{
private readonly Octree octree;
public PixelRowDelegate(Octree octree, TransparentColorMode mode)
{
this.octree = octree;
this.TransparentColorMode = mode;
}
public TransparentColorMode TransparentColorMode { get; }
public void Invoke(ReadOnlySpan<Rgba32> row, int rowIndex) => this.octree.AddColors(row);
}
/// <summary>
/// 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<TPixel> : IQuantizer<TPixel>
/// </summary>
internal sealed class Octree : IDisposable
{
// The memory allocator.
private readonly MemoryAllocator allocator;
// Pooled buffer for OctreeNodes.
private readonly IMemoryOwner<OctreeNode> nodesOwner;
@ -172,7 +181,7 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
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<TPixel> : IQuantizer<TPixel>
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<short> freeIndices = new();
/// <summary>
/// Initializes a new instance of the <see cref="Octree"/> class.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="maxColorBits">The maximum number of significant bits in the image.</param>
/// <param name="maxColors">The maximum number of colors to allow in the palette.</param>
/// <param name="transparencyThreshold">The threshold for transparent colors.</param>
/// <param name="allocator">The memory allocator.</param>
public Octree(int maxColorBits, int maxColors, short transparencyThreshold, MemoryAllocator allocator)
/// <param name="thresholdReplacementColor">The color to use for pixels below the transparency threshold.</param>
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<Rgba32>();
this.Leaves = 0;
this.previousNode = -1;
this.previousColor = default;
// Allocate a conservative buffer for nodes.
const int capacity = 4096;
this.nodesOwner = allocator.Allocate<OctreeNode>(capacity, AllocationOptions.Clean);
this.allocator = configuration.MemoryAllocator;
this.nodesOwner = this.allocator.Allocate<OctreeNode>(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<TPixel> : IQuantizer<TPixel>
/// </summary>
internal Span<OctreeNode> Nodes => this.nodesOwner.Memory.Span;
/// <summary>
/// Adds a span of colors to the octree.
/// </summary>
/// <param name="row">A span of color values to be added.</param>
public void AddColors(ReadOnlySpan<Rgba32> row)
{
for (int x = 0; x < row.Length; x++)
{
this.AddColor(row[x]);
}
}
/// <summary>
/// Add a color to the Octree.
/// </summary>
/// <param name="color">The color to add.</param>
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<TPixel> : IQuantizer<TPixel>
}
}
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<TPixel> : IQuantizer<TPixel>
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<TPixel> : IQuantizer<TPixel>
/// <param name="octree">The parent octree.</param>
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)

21
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<TPixel> : IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
private readonly EuclideanPixelMap<TPixel> pixelMap;
private readonly PixelMap<TPixel> pixelMap;
private int transparencyIndex;
private TPixel transparentColor;
@ -58,7 +59,7 @@ internal struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
this.Configuration = configuration;
this.Options = options;
this.pixelMap = new EuclideanPixelMap<TPixel>(configuration, palette);
this.pixelMap = PixelMapFactory.Create(this.Configuration, palette, options.ColorMatchingMode);
this.transparencyIndex = transparencyIndex;
this.transparentColor = transparentColor;
}
@ -74,15 +75,25 @@ internal struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds);
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
=> this.AddPaletteColors(in pixelRegion, TransparentColorMode.Preserve);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion, TransparentColorMode mode)
{
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds, TransparentColorMode mode)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)

11
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);
}
/// <summary>
/// Gets or sets the color used for replacing colors with an alpha component below the threshold.
/// Defaults to <see cref="Color.Transparent"/>.
/// </summary>
public Color ThresholdReplacementColor { get; set; } = Color.Transparent;
/// <summary>
/// Gets or sets the color matching mode used for matching pixel values to palette colors.
/// </summary>
public ColorMatchingMode ColorMatchingMode { get; set; } = ColorMatchingMode.Hybrid;
}

365
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;
/// </summary>
public static class QuantizerUtilities
{
/// <summary>
/// Determines if transparent pixels can be replaced based on the specified color mode and pixel type.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="threshold">The alpha threshold used to determine if a pixel is transparent.</param>
/// <returns>Returns true if transparent pixels can be replaced; otherwise, false.</returns>
public static bool ShouldReplacePixelsByAlphaThreshold<TPixel>(float threshold)
where TPixel : unmanaged, IPixel<TPixel>
=> threshold > 0 && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
/// <summary>
/// Replaces transparent pixels in a span with a specified color based on an alpha threshold.
/// </summary>
/// <param name="source">A span of color vectors that will be checked for transparency and potentially modified.</param>
/// <param name="replacement">A color vector that will replace transparent pixels when the alpha value is below the specified threshold.</param>
/// <param name="threshold">The alpha threshold used to determine if a pixel is transparent.</param>
public static void ReplacePixelsByAlphaThreshold(Span<Vector4> source, Vector4 replacement, float threshold)
{
if (Vector512.IsHardwareAccelerated && source.Length >= 4)
{
Vector128<float> replacement128 = replacement.AsVector128();
Vector256<float> replacement256 = Vector256.Create(replacement128, replacement128);
Vector512<float> replacement512 = Vector512.Create(replacement256, replacement256);
Vector512<float> threshold512 = Vector512.Create(threshold);
Span<Vector512<float>> source512 = MemoryMarshal.Cast<Vector4, Vector512<float>>(source);
for (int i = 0; i < source512.Length; i++)
{
ref Vector512<float> v = ref source512[i];
// Do `vector < threshold`
Vector512<float> 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<float> replacement128 = replacement.AsVector128();
Vector256<float> replacement256 = Vector256.Create(replacement128, replacement128);
Vector256<float> threshold256 = Vector256.Create(threshold);
Span<Vector256<float>> source256 = MemoryMarshal.Cast<Vector4, Vector256<float>>(source);
for (int i = 0; i < source256.Length; i++)
{
ref Vector256<float> v = ref source256[i];
// Do `vector < threshold`
Vector256<float> 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<float> replacement128 = replacement.AsVector128();
Vector128<float> threshold128 = Vector128.Create(threshold);
for (int i = 0; i < source.Length; i++)
{
ref Vector4 v = ref source[i];
Vector128<float> v128 = v.AsVector128();
// Do `vector < threshold`
Vector128<float> 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;
}
}
}
}
/// <summary>
/// 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
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="palette">The frame quantizer palette.</param>
/// <exception cref="InvalidOperationException">
/// The palette has not been built via <see cref="IQuantizer{TPixel}.AddPaletteColors"/>
/// The palette has not been built via <see cref="IQuantizer{TPixel}.AddPaletteColors(in Buffer2DRegion{TPixel})"/>
/// </exception>
[MethodImpl(InliningOptions.ColdPath)]
public static void CheckPaletteState<TPixel>(in ReadOnlyMemory<TPixel> palette)
where TPixel : unmanaged, IPixel<TPixel>
{
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<TPixel> source,
Rectangle bounds)
where TPixel : unmanaged, IPixel<TPixel>
=> BuildPaletteAndQuantizeFrame(
quantizer,
source,
bounds,
TransparentColorMode.Preserve);
/// <summary>
/// Execute both steps of the quantization.
/// </summary>
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="source">The source image frame to quantize.</param>
/// <param name="bounds">The bounds within the frame to quantize.</param>
/// <param name="mode">The transparent color mode.</param>
/// <typeparam name="TPixel">The pixel type.</typeparam>
/// <returns>
/// A <see cref="IndexedImageFrame{TPixel}"/> representing a quantized version of the source frame pixels.
/// </returns>
public static IndexedImageFrame<TPixel> BuildPaletteAndQuantizeFrame<TPixel>(
this IQuantizer<TPixel> quantizer,
ImageFrame<TPixel> source,
Rectangle bounds,
TransparentColorMode mode)
where TPixel : unmanaged, IPixel<TPixel>
{
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<TPixel> 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
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="source">The source image frame to quantize.</param>
/// <param name="bounds">The bounds within the frame to quantize.</param>
/// <param name="mode">The transparent color mode.</param>
/// <returns>
/// A <see cref="IndexedImageFrame{TPixel}"/> representing a quantized version of the source frame pixels.
/// </returns>
public static IndexedImageFrame<TPixel> QuantizeFrame<TFrameQuantizer, TPixel>(
ref TFrameQuantizer quantizer,
ImageFrame<TPixel> source,
Rectangle bounds)
Rectangle bounds,
TransparentColorMode mode)
where TFrameQuantizer : struct, IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
@ -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<TPixel> 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<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
=> quantizer.BuildPalette(
source.Configuration,
TransparentColorMode.Preserve,
pixelSamplingStrategy,
source,
Color.Transparent);
source);
/// <summary>
/// Adds colors to the quantized palette from the given pixel regions.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="mode">The transparent color mode.</param>
/// <param name="pixelSamplingStrategy">The pixel sampling strategy.</param>
/// <param name="source">The source image to sample from.</param>
/// <param name="backgroundColor">The background color to use when clearing transparent pixels.</param>
public static void BuildPalette<TPixel>(
this IQuantizer<TPixel> quantizer,
Configuration configuration,
TransparentColorMode mode,
IPixelSamplingStrategy pixelSamplingStrategy,
Image<TPixel> source,
Color backgroundColor)
Image<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(mode))
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
// We need to clone the region to ensure we don't alter the original image.
using Buffer2D<TPixel> clone = region.Buffer.CloneRegion(configuration, region.Rectangle);
Buffer2DRegion<TPixel> clonedRegion = clone.GetRegion();
EncodingUtilities.ClearTransparentPixels(configuration, in clonedRegion, backgroundColor);
quantizer.AddPaletteColors(in clonedRegion);
}
}
else
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
quantizer.AddPaletteColors(in region);
}
quantizer.AddPaletteColors(in region, mode);
}
}
@ -172,83 +300,208 @@ public static class QuantizerUtilities
ImageFrame<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
=> quantizer.BuildPalette(
source.Configuration,
TransparentColorMode.Preserve,
pixelSamplingStrategy,
source,
Color.Transparent);
source);
/// <summary>
/// Adds colors to the quantized palette from the given pixel regions.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="mode">The transparent color mode.</param>
/// <param name="pixelSamplingStrategy">The pixel sampling strategy.</param>
/// <param name="source">The source image frame to sample from.</param>
/// <param name="backgroundColor">The background color to use when clearing transparent pixels.</param>
public static void BuildPalette<TPixel>(
this IQuantizer<TPixel> quantizer,
Configuration configuration,
TransparentColorMode mode,
IPixelSamplingStrategy pixelSamplingStrategy,
ImageFrame<TPixel> source,
Color backgroundColor)
ImageFrame<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
quantizer.AddPaletteColors(in region, mode);
}
}
internal static void AddPaletteColors<TFrameQuantizer, TPixel, TPixel2, TDelegate>(
ref TFrameQuantizer quantizer,
in Buffer2DRegion<TPixel> source,
in TDelegate rowDelegate)
where TFrameQuantizer : struct, IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
where TPixel2 : unmanaged, IPixel<TPixel2>
where TDelegate : struct, IQuantizingPixelRowDelegate<TPixel2>
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(mode))
Configuration configuration = quantizer.Configuration;
float threshold = quantizer.Options.TransparencyThreshold;
Color replacement = quantizer.Options.ThresholdReplacementColor;
using IMemoryOwner<TPixel2> delegateRowOwner = configuration.MemoryAllocator.Allocate<TPixel2>(source.Width);
Span<TPixel2> delegateRow = delegateRowOwner.Memory.Span;
bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold<TPixel>(threshold);
bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(rowDelegate.TransparentColorMode);
if (replaceByThreshold || replaceTransparent)
{
// We need to clone the region to ensure we don't alter the original image.
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
using IMemoryOwner<Vector4> vectorRowOwner = configuration.MemoryAllocator.Allocate<Vector4>(source.Width);
Span<Vector4> vectorRow = vectorRowOwner.Memory.Span;
Vector4 replacementV4 = replacement.ToScaledVector4();
if (replaceByThreshold)
{
using Buffer2D<TPixel> clone = region.Buffer.CloneRegion(configuration, region.Rectangle);
Buffer2DRegion<TPixel> clonedRegion = clone.GetRegion();
for (int y = 0; y < source.Height; y++)
{
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold);
EncodingUtilities.ClearTransparentPixels(configuration, in clonedRegion, backgroundColor);
quantizer.AddPaletteColors(in clonedRegion);
PixelOperations<TPixel2>.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale);
rowDelegate.Invoke(delegateRow, y);
}
}
else
{
for (int y = 0; y < source.Height; y++)
{
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4);
PixelOperations<TPixel2>.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale);
rowDelegate.Invoke(delegateRow, y);
}
}
}
else
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
for (int y = 0; y < source.Height; y++)
{
quantizer.AddPaletteColors(in region);
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.To(configuration, sourceRow, delegateRow);
rowDelegate.Invoke(delegateRow, y);
}
}
}
[MethodImpl(InliningOptions.ShortMethod)]
private static void SecondPass<TFrameQuantizer, TPixel>(
ref TFrameQuantizer quantizer,
ImageFrame<TPixel> source,
IndexedImageFrame<TPixel> destination,
Rectangle bounds)
Rectangle bounds,
TransparentColorMode mode)
where TFrameQuantizer : struct, IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
float threshold = quantizer.Options.TransparencyThreshold;
Color replacement = quantizer.Options.ThresholdReplacementColor;
bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold<TPixel>(threshold);
bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(mode);
Vector4 replacementV4 = replacement.ToScaledVector4();
IDither? dither = quantizer.Options.Dither;
Buffer2D<TPixel> sourceBuffer = source.PixelBuffer;
Buffer2DRegion<TPixel> region = sourceBuffer.GetRegion(bounds);
Configuration configuration = quantizer.Configuration;
using IMemoryOwner<Vector4> vectorOwner = configuration.MemoryAllocator.Allocate<Vector4>(region.Width);
Span<Vector4> vectorRow = vectorOwner.Memory.Span;
if (dither is null)
{
int offsetY = bounds.Top;
int offsetX = bounds.Left;
using IMemoryOwner<TPixel> quantizingRowOwner = configuration.MemoryAllocator.Allocate<TPixel>(region.Width);
Span<TPixel> 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<TPixel> sourceRow = sourceBuffer.DangerousGetRowSpan(y + offsetY);
if (replaceByThreshold)
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale);
Span<byte> 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<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale);
Span<byte> 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<TPixel> sourceRow = region.DangerousGetRowSpan(y);
Span<byte> 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<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, replacementV4, threshold);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale);
}
}
else
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow, replacementV4);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale);
}
}
}
dither.ApplyQuantizationDither(ref quantizer, source, destination, bounds);
}
}

118
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<TPixel> : IQuantizer<TPixel>
// 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!
/// <summary>
/// The index bits. 6 in original code.
/// </summary>
private const int IndexBits = 5;
/// <summary>
/// The index alpha bits. 3 in original code.
/// </summary>
private const int IndexAlphaBits = 5;
/// <summary>
/// The index count.
/// </summary>
private const int IndexCount = (1 << IndexBits) + 1;
/// <summary>
/// The index alpha count.
/// </summary>
private const int IndexAlphaCount = (1 << IndexAlphaBits) + 1;
/// <summary>
/// The table length. Now 1185921. originally 2471625.
/// </summary>
private const int TableLength = IndexCount * IndexCount * IndexCount * IndexAlphaCount;
private readonly IMemoryOwner<Moment> momentsOwner;
@ -74,17 +55,18 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
private readonly IMemoryOwner<TPixel> paletteOwner;
private ReadOnlyMemory<TPixel> palette;
private int maxColors;
private readonly float transparencyThreshold;
private readonly short transparencyThreshold255;
private readonly Box[] colorCube;
private EuclideanPixelMap<TPixel>? pixelMap;
private PixelMap<TPixel>? pixelMap;
private readonly bool isDithering;
private readonly int transparencyThreshold255;
private Vector4 thresholdReplacementColorV4;
private readonly Rgba32 thresholdReplacementColorRgba;
private bool isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="WuQuantizer{TPixel}"/> struct.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behaviour or extending the library.</param>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public WuQuantizer(Configuration configuration, QuantizerOptions options)
@ -104,8 +86,9 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
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<Rgba32>();
}
/// <inheritdoc/>
@ -130,8 +113,14 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
}
/// <inheritdoc/>
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
=> this.Build3DHistogram(pixelRegion);
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion, TransparentColorMode mode)
{
PixelRowDelegate pixelRowDelegate = new(ref Unsafe.AsRef(in this), mode);
QuantizerUtilities.AddPaletteColors<WuQuantizer<TPixel>, TPixel, Rgba32, PixelRowDelegate>(
ref Unsafe.AsRef(in this),
in pixelRegion,
in pixelRowDelegate);
}
/// <summary>
/// Once all histogram data has been accumulated, this method computes the moments,
@ -148,6 +137,9 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
// Compute the palette colors from the resolved cubes.
Span<TPixel> paletteSpan = this.paletteOwner.GetSpan()[..this.maxColors];
ReadOnlySpan<Moment> 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<TPixel> : IQuantizer<TPixel>
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<TPixel> : IQuantizer<TPixel>
// Create the pixel map if dithering is enabled.
if (this.isDithering && this.pixelMap is null)
{
this.pixelMap = new EuclideanPixelMap<TPixel>(this.Configuration, this.palette);
this.pixelMap = PixelMapFactory.Create(this.Configuration, this.palette, this.Options.ColorMatchingMode);
}
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds);
=> this.QuantizeFrame(source, bounds, TransparentColorMode.Preserve);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds, TransparentColorMode mode)
=> QuantizerUtilities.QuantizeFrame(ref Unsafe.AsRef(in this), source, bounds, mode);
/// <inheritdoc/>
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
@ -187,13 +184,13 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
// 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<TPixel> : IQuantizer<TPixel>
/// <summary>
/// Builds a 3-D color histogram of <c>counts, r/g/b, c^2</c>.
/// </summary>
/// <param name="source">The source pixel data.</param>
private readonly void Build3DHistogram(in Buffer2DRegion<TPixel> source)
/// <param name="pixels">The source pixel data.</param>
private readonly void Build3DHistogram(ReadOnlySpan<Rgba32> pixels)
{
Span<Moment> momentSpan = this.momentsOwner.GetSpan();
// Build up the 3-D color histogram
using IMemoryOwner<Rgba32> buffer = this.memoryAllocator.Allocate<Rgba32>(source.Width);
Span<Rgba32> bufferSpan = buffer.GetSpan();
float transparencyThreshold = this.Options.TransparencyThreshold * 255;
for (int y = 0; y < source.Height; y++)
Span<Moment> moments = this.momentsOwner.GetSpan();
for (int x = 0; x < pixels.Length; x++)
{
Span<TPixel> row = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.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<TPixel> : IQuantizer<TPixel>
return hash.ToHashCode();
}
}
private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate<Rgba32>
{
private readonly WuQuantizer<TPixel> quantizer;
public PixelRowDelegate(ref WuQuantizer<TPixel> quantizer, TransparentColorMode mode)
{
this.quantizer = quantizer;
this.TransparentColorMode = mode;
}
public TransparentColorMode TransparentColorMode { get; }
public void Invoke(ReadOnlySpan<Rgba32> row, int rowIndex) => this.quantizer.Build3DHistogram(row);
}
}

2
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.

4
tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs

@ -419,8 +419,8 @@ public partial class PngEncoderTests
using Image<Rgba32> output = Image.Load<Rgba32>(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);

Loading…
Cancel
Save