diff --git a/src/ImageSharp/Advanced/AotCompilerTools.cs b/src/ImageSharp/Advanced/AotCompilerTools.cs
index fef49bffd4..0f28b28901 100644
--- a/src/ImageSharp/Advanced/AotCompilerTools.cs
+++ b/src/ImageSharp/Advanced/AotCompilerTools.cs
@@ -54,7 +54,7 @@ internal static class AotCompilerTools
///
/// This method doesn't actually do anything but serves an important purpose...
/// If you are running ImageSharp on iOS and try to call SaveAsGif, it will throw an exception:
- /// "Attempting to JIT compile method... OctreeFrameQuantizer.ConstructPalette... while running in aot-only mode."
+ /// "Attempting to JIT compile method... HexadecatreeQuantizer.ConstructPalette... while running in aot-only mode."
/// The reason this happens is the SaveAsGif method makes heavy use of generics, which are too confusing for the AoT
/// compiler used on Xamarin.iOS. It spins up the JIT compiler to try and figure it out, but that is an illegal op on
/// iOS so it bombs out.
@@ -479,7 +479,7 @@ internal static class AotCompilerTools
private static void AotCompileQuantizers()
where TPixel : unmanaged, IPixel
{
- AotCompileQuantizer();
+ AotCompileQuantizer();
AotCompileQuantizer();
AotCompileQuantizer();
AotCompileQuantizer();
@@ -523,10 +523,8 @@ internal static class AotCompilerTools
private static void AotCompilePixelMaps()
where TPixel : unmanaged, IPixel
{
- default(EuclideanPixelMap).GetClosestColor(default, out _);
default(EuclideanPixelMap).GetClosestColor(default, out _);
default(EuclideanPixelMap).GetClosestColor(default, out _);
- default(EuclideanPixelMap).GetClosestColor(default, out _);
}
///
@@ -551,8 +549,8 @@ internal static class AotCompilerTools
where TPixel : unmanaged, IPixel
where TDither : struct, IDither
{
- OctreeQuantizer octree = default;
- default(TDither).ApplyQuantizationDither, TPixel>(ref octree, default, default, default);
+ HexadecatreeQuantizer hexadecatree = default;
+ default(TDither).ApplyQuantizationDither, TPixel>(ref hexadecatree, default, default, default);
PaletteQuantizer palette = default;
default(TDither).ApplyQuantizationDither, TPixel>(ref palette, default, default, default);
diff --git a/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs b/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
index 8b46fc5c31..dfaec640b8 100644
--- a/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
+++ b/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
@@ -15,12 +15,12 @@ public interface IRowOperation
///
/// The bounds of the operation.
/// The required buffer length.
- int GetRequiredBufferLength(Rectangle bounds);
+ public int GetRequiredBufferLength(Rectangle bounds);
///
/// Invokes the method passing the row and a buffer.
///
/// The row y coordinate.
/// The contiguous region of memory.
- void Invoke(int y, Span span);
+ public void Invoke(int y, Span span);
}
diff --git a/src/ImageSharp/Advanced/ParallelRowIterator.cs b/src/ImageSharp/Advanced/ParallelRowIterator.cs
index b878f9ec0a..d170631a29 100644
--- a/src/ImageSharp/Advanced/ParallelRowIterator.cs
+++ b/src/ImageSharp/Advanced/ParallelRowIterator.cs
@@ -68,7 +68,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowOperationWrapper wrappingOperation = new(top, bottom, verticalStep, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -138,7 +138,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowOperationWrapper wrappingOperation = new(top, bottom, verticalStep, bufferLength, allocator, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -195,7 +195,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowIntervalOperationWrapper wrappingOperation = new(top, bottom, verticalStep, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -262,7 +262,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowIntervalOperationWrapper wrappingOperation = new(top, bottom, verticalStep, bufferLength, allocator, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
diff --git a/src/ImageSharp/Common/Helpers/SimdUtils.HwIntrinsics.cs b/src/ImageSharp/Common/Helpers/SimdUtils.HwIntrinsics.cs
index ff5ea5de33..076590605d 100644
--- a/src/ImageSharp/Common/Helpers/SimdUtils.HwIntrinsics.cs
+++ b/src/ImageSharp/Common/Helpers/SimdUtils.HwIntrinsics.cs
@@ -752,7 +752,7 @@ internal static partial class SimdUtils
/// Implementation is based on MagicScaler code:
/// https://github.com/saucecontrol/PhotoSauce/blob/b5811908041200488aa18fdfd17df5fc457415dc/src/MagicScaler/Magic/Processors/ConvertersFloat.cs#L80-L182
///
- internal static unsafe void ByteToNormalizedFloat(
+ internal static void ByteToNormalizedFloat(
ReadOnlySpan source,
Span destination)
{
@@ -1172,8 +1172,10 @@ internal static partial class SimdUtils
Vector256 rgb, rg, bx;
Vector256 r, g, b;
+ // Each iteration consumes 8 Rgb24 pixels (24 bytes) but starts with a 32-byte load,
+ // so we need 3 extra pixels of addressable slack beyond the vectorized chunk.
const int bytesPerRgbStride = 24;
- nuint count = (uint)source.Length / 8;
+ nuint count = source.Length > 3 ? (uint)(source.Length - 3) / 8 : 0;
for (nuint i = 0; i < count; i++)
{
rgb = Avx2.PermuteVar8x32(Unsafe.AddByteOffset(ref rgbByteSpan, (uint)(bytesPerRgbStride * i)).AsUInt32(), extractToLanesMask).AsByte();
@@ -1193,10 +1195,10 @@ internal static partial class SimdUtils
}
int sliceCount = (int)(count * 8);
- redChannel = redChannel.Slice(sliceCount);
- greenChannel = greenChannel.Slice(sliceCount);
- blueChannel = blueChannel.Slice(sliceCount);
- source = source.Slice(sliceCount);
+ redChannel = redChannel[sliceCount..];
+ greenChannel = greenChannel[sliceCount..];
+ blueChannel = blueChannel[sliceCount..];
+ source = source[sliceCount..];
}
}
}
diff --git a/src/ImageSharp/Formats/Bmp/BmpEncoder.cs b/src/ImageSharp/Formats/Bmp/BmpEncoder.cs
index e255568047..210c08464a 100644
--- a/src/ImageSharp/Formats/Bmp/BmpEncoder.cs
+++ b/src/ImageSharp/Formats/Bmp/BmpEncoder.cs
@@ -13,7 +13,7 @@ public sealed class BmpEncoder : QuantizingImageEncoder
///
/// Initializes a new instance of the class.
///
- public BmpEncoder() => this.Quantizer = KnownQuantizers.Octree;
+ public BmpEncoder() => this.Quantizer = KnownQuantizers.Hexadecatree;
///
/// Gets the number of bits per pixel.
diff --git a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
index ccc620d6c4..0bf57c5612 100644
--- a/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
+++ b/src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
@@ -116,7 +116,7 @@ internal sealed class BmpEncoderCore
this.bitsPerPixel = encoder.BitsPerPixel;
// TODO: Use a palette quantizer if supplied.
- this.quantizer = encoder.Quantizer ?? KnownQuantizers.Octree;
+ this.quantizer = encoder.Quantizer ?? KnownQuantizers.Hexadecatree;
this.pixelSamplingStrategy = encoder.PixelSamplingStrategy;
this.transparentColorMode = encoder.TransparentColorMode;
this.infoHeaderType = encoder.SupportTransparency ? BmpInfoHeaderType.WinVersion4 : BmpInfoHeaderType.WinVersion3;
diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
index 78ceb0b233..3d32c7cdac 100644
--- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
+++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
@@ -468,7 +468,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore
int length = this.currentLocalColorTableSize = this.imageDescriptor.LocalColorTableSize * 3;
this.currentLocalColorTable ??= this.configuration.MemoryAllocator.Allocate(768, AllocationOptions.Clean);
stream.Read(this.currentLocalColorTable.GetSpan()[..length]);
- rawColorTable = this.currentLocalColorTable!.GetSpan()[..length];
+ rawColorTable = this.currentLocalColorTable.GetSpan()[..length];
}
else if (this.globalColorTable != null)
{
diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
index 07c73dcf22..d2883e2811 100644
--- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
+++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
@@ -117,7 +117,7 @@ internal sealed class GifEncoderCore
if (globalQuantizer is null)
{
- // Is this a gif with color information. If so use that, otherwise use octree.
+ // Is this a gif with color information. If so use that, otherwise use the adaptive hexadecatree quantizer.
if (gifMetadata.ColorTableMode == FrameColorTableMode.Global && gifMetadata.GlobalColorTable?.Length > 0)
{
int ti = GetTransparentIndex(quantized, frameMetadata);
@@ -132,12 +132,12 @@ internal sealed class GifEncoderCore
}
else
{
- globalQuantizer = new OctreeQuantizer(options);
+ globalQuantizer = new HexadecatreeQuantizer(options);
}
}
else
{
- globalQuantizer = new OctreeQuantizer(options);
+ globalQuantizer = new HexadecatreeQuantizer(options);
}
}
diff --git a/src/ImageSharp/Formats/Jpeg/Components/Encoder/SpectralConverter{TPixel}.cs b/src/ImageSharp/Formats/Jpeg/Components/Encoder/SpectralConverter{TPixel}.cs
index b60ef68f11..8662c5c49b 100644
--- a/src/ImageSharp/Formats/Jpeg/Components/Encoder/SpectralConverter{TPixel}.cs
+++ b/src/ImageSharp/Formats/Jpeg/Components/Encoder/SpectralConverter{TPixel}.cs
@@ -114,9 +114,9 @@ internal class SpectralConverter : SpectralConverter, IDisposable
Span sourceRow = this.pixelBuffer.DangerousGetRowSpan(srcIndex);
PixelOperations.Instance.UnpackIntoRgbPlanes(rLane, gLane, bLane, sourceRow);
- rLane.Slice(paddingStartIndex).Fill(rLane[paddingStartIndex - 1]);
- gLane.Slice(paddingStartIndex).Fill(gLane[paddingStartIndex - 1]);
- bLane.Slice(paddingStartIndex).Fill(bLane[paddingStartIndex - 1]);
+ rLane.Slice(paddingStartIndex, paddedPixelsCount).Fill(rLane[paddingStartIndex - 1]);
+ gLane.Slice(paddingStartIndex, paddedPixelsCount).Fill(gLane[paddingStartIndex - 1]);
+ bLane.Slice(paddingStartIndex, paddedPixelsCount).Fill(bLane[paddingStartIndex - 1]);
// Convert from rgb24 to target pixel type
JpegColorConverterBase.ComponentValues values = new(this.componentProcessors, y);
diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs
index 8962182679..d794c66e27 100644
--- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs
+++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs
@@ -214,7 +214,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
break;
case PngChunkType.FrameData:
{
- if (frameCount >= this.maxFrames)
+ if (frameCount > this.maxFrames)
{
goto EOF;
}
@@ -275,7 +275,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
previousFrameControl = currentFrameControl;
}
- if (frameCount >= this.maxFrames)
+ if (frameCount > this.maxFrames)
{
goto EOF;
}
@@ -402,7 +402,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
break;
case PngChunkType.FrameControl:
++frameCount;
- if (frameCount >= this.maxFrames)
+ if (frameCount > this.maxFrames)
{
break;
}
@@ -411,8 +411,12 @@ internal sealed class PngDecoderCore : ImageDecoderCore
break;
case PngChunkType.FrameData:
- if (frameCount >= this.maxFrames)
+ if (frameCount > this.maxFrames)
{
+ // Must skip the chunk data even when we've hit maxFrames, because TryReadChunk
+ // restores the stream position to the start of the fdAT data after CRC validation.
+ this.SkipChunkDataAndCrc(chunk);
+ this.SkipRemainingFrameDataChunks(buffer);
break;
}
@@ -428,9 +432,10 @@ internal sealed class PngDecoderCore : ImageDecoderCore
InitializeFrameMetadata(framesMetadata, currentFrameControl.Value);
- // Skip sequence number
- this.currentStream.Skip(4);
+ // Skip data for this and all remaining FrameData chunks belonging to the same frame
+ // (comparable to how Decode consumes them via ReadScanlines + ReadNextFrameDataChunk).
this.SkipChunkDataAndCrc(chunk);
+ this.SkipRemainingFrameDataChunks(buffer);
break;
case PngChunkType.Data:
@@ -2093,6 +2098,31 @@ internal sealed class PngDecoderCore : ImageDecoderCore
return 0;
}
+ ///
+ /// Skips any remaining chunks belonging to the current frame.
+ /// This mirrors how is used during decoding:
+ /// consecutive fdAT chunks are consumed until a non-fdAT chunk is encountered,
+ /// which is stored in for the next iteration.
+ ///
+ /// Temporary buffer.
+ private void SkipRemainingFrameDataChunks(Span buffer)
+ {
+ while (this.TryReadChunk(buffer, out PngChunk chunk))
+ {
+ if (chunk.Type is PngChunkType.FrameData)
+ {
+ chunk.Data?.Dispose();
+ this.SkipChunkDataAndCrc(chunk);
+ }
+ else
+ {
+ // Not a FrameData chunk; store it so the next TryReadChunk call returns it.
+ this.nextChunk = chunk;
+ return;
+ }
+ }
+ }
+
///
/// Reads a chunk from the stream.
///
diff --git a/src/ImageSharp/Formats/Tiff/TiffEncoder.cs b/src/ImageSharp/Formats/Tiff/TiffEncoder.cs
index a068613bf4..7859b2c902 100644
--- a/src/ImageSharp/Formats/Tiff/TiffEncoder.cs
+++ b/src/ImageSharp/Formats/Tiff/TiffEncoder.cs
@@ -15,7 +15,7 @@ public class TiffEncoder : QuantizingImageEncoder
///
/// Initializes a new instance of the class.
///
- public TiffEncoder() => this.Quantizer = KnownQuantizers.Octree;
+ public TiffEncoder() => this.Quantizer = KnownQuantizers.Hexadecatree;
///
/// Gets the number of bits per pixel.
diff --git a/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs
index d7508b02e8..e5e47166e9 100644
--- a/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs
+++ b/src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs
@@ -71,7 +71,7 @@ internal sealed class TiffEncoderCore
this.configuration = configuration;
this.memoryAllocator = configuration.MemoryAllocator;
this.PhotometricInterpretation = encoder.PhotometricInterpretation;
- this.quantizer = encoder.Quantizer ?? KnownQuantizers.Octree;
+ this.quantizer = encoder.Quantizer ?? KnownQuantizers.Hexadecatree;
this.pixelSamplingStrategy = encoder.PixelSamplingStrategy;
this.BitsPerPixel = encoder.BitsPerPixel;
this.HorizontalPredictor = encoder.HorizontalPredictor;
diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
index a237054133..71e609fbcb 100644
--- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
+++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Buffers;
+using System.Numerics;
using SixLabors.ImageSharp.Formats.Webp.Chunks;
using SixLabors.ImageSharp.Formats.Webp.Lossless;
using SixLabors.ImageSharp.Formats.Webp.Lossy;
@@ -456,15 +457,21 @@ internal class WebpAnimationDecoder : IDisposable
// The destination frame has already been prepopulated with the pixel data from the previous frame
// so blending will leave the desired result which takes into consideration restoration to the
// background color within the restore area.
- PixelBlender blender =
- PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver);
+ PixelBlender blender = PixelOperations.Instance.GetPixelBlender(
+ PixelColorBlendingMode.Normal,
+ PixelAlphaCompositionMode.SrcOver);
+
+ // By using a dedicated vector span we can avoid per-row pool allocations in PixelBlender.Blend
+ // We need 3 Vector4 values per pixel to store the background, foreground, and result pixels for blending.
+ using IMemoryOwner workingBufferOwner = imageFrame.Configuration.MemoryAllocator.Allocate(restoreArea.Width * 3);
+ Span workingBuffer = workingBufferOwner.GetSpan();
for (int y = 0; y < restoreArea.Height; y++)
{
Span framePixelRow = imageFramePixels.DangerousGetRowSpan(y);
Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y)[..restoreArea.Width];
- blender.Blend(imageFrame.Configuration, framePixelRow, framePixelRow, decodedPixelRow, 1f);
+ blender.Blend(imageFrame.Configuration, framePixelRow, framePixelRow, decodedPixelRow, 1f, workingBuffer);
}
return;
diff --git a/src/ImageSharp/ImageInfo.cs b/src/ImageSharp/ImageInfo.cs
index 0bbd73b63a..d27c4b9330 100644
--- a/src/ImageSharp/ImageInfo.cs
+++ b/src/ImageSharp/ImageInfo.cs
@@ -63,8 +63,12 @@ public class ImageInfo
public int Height => this.Size.Height;
///
- /// Gets the number of frames in the image.
+ /// Gets the number of frame metadata entries available for the image.
///
+ ///
+ /// This value is the same as count and may be 0 when frame
+ /// metadata was not populated by the decoder.
+ ///
public int FrameCount => this.FrameMetadataCollection.Count;
///
@@ -73,8 +77,12 @@ public class ImageInfo
public ImageMetadata Metadata { get; }
///
- /// Gets the collection of metadata associated with individual image frames.
+ /// Gets the metadata associated with the decoded image frames, if available.
///
+ ///
+ /// For multi-frame formats, decoders populate one entry per decoded frame. For single-frame formats, this
+ /// collection is typically empty.
+ ///
public IReadOnlyList FrameMetadataCollection { get; }
///
@@ -86,4 +94,24 @@ public class ImageInfo
/// Gets the bounds of the image.
///
public Rectangle Bounds => new(Point.Empty, this.Size);
+
+ ///
+ /// Gets the total number of bytes required to store the image pixels in memory.
+ ///
+ ///
+ /// This reports the in-memory size of the pixel data represented by this , not the
+ /// encoded size of the image file. The value is computed from the image dimensions and
+ /// . When contains decoded frame metadata, the
+ /// per-frame size is multiplied by that count. Otherwise, the value is the in-memory size of the single
+ /// image frame represented by this .
+ ///
+ /// The total number of bytes required to store the image pixels in memory.
+ public long GetPixelMemorySize()
+ {
+ int count = this.FrameMetadataCollection.Count > 0
+ ? this.FrameMetadataCollection.Count
+ : 1;
+
+ return (long)this.Size.Width * this.Size.Height * (this.PixelType.BitsPerPixel / 8) * count;
+ }
}
diff --git a/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs b/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
index bad9ae01c1..fcd28796fb 100644
--- a/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
+++ b/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
@@ -3,7 +3,6 @@
using System.Buffers;
using System.Numerics;
-using SixLabors.ImageSharp.Memory;
namespace SixLabors.ImageSharp.PixelFormats;
@@ -52,16 +51,53 @@ public abstract class PixelBlender
Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 3);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
- Span sourceVectors = buffer.Slice(maxLength * 2, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 3)]);
+ }
+
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// the pixel format of the source span
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A value between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ float amount,
+ Span workingBuffer)
+ where TPixelSrc : unmanaged, IPixel
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(source.Length, maxLength, nameof(source.Length));
+ Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 3, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
+ Span sourceVectors = workingBuffer.Slice(maxLength * 2, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
PixelOperations.Instance.ToVector4(configuration, source[..maxLength], sourceVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, sourceVectors, amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
@@ -87,14 +123,48 @@ public abstract class PixelBlender
Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 2)]);
+ }
+
+ ///
+ /// Blends a row against a constant source color using caller-provided temporary vector scratch.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source color
+ ///
+ /// A value between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 2 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ TPixel source,
+ float amount,
+ Span workingBuffer)
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 2, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, source.ToScaledVector4(), amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
@@ -116,6 +186,27 @@ public abstract class PixelBlender
ReadOnlySpan amount)
=> this.Blend(configuration, destination, background, source, amount);
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ ReadOnlySpan amount,
+ Span workingBuffer)
+ => this.Blend(configuration, destination, background, source, amount, workingBuffer);
+
///
/// Blends 2 rows together
///
@@ -142,20 +233,89 @@ public abstract class PixelBlender
Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 3);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
- Span sourceVectors = buffer.Slice(maxLength * 2, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 3)]);
+ }
+
+ ///
+ /// Blends a row against a constant source color.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source color
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ TPixel source,
+ ReadOnlySpan amount)
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+
+ using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 2)]);
+ }
+
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// the pixel format of the source span
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ ReadOnlySpan amount,
+ Span workingBuffer)
+ where TPixelSrc : unmanaged, IPixel
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(source.Length, maxLength, nameof(source.Length));
+ Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 3, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
+ Span sourceVectors = workingBuffer.Slice(maxLength * 2, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
PixelOperations.Instance.ToVector4(configuration, source[..maxLength], sourceVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, sourceVectors, amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
- /// Blends a row against a constant source color.
+ /// Blends a row against a constant source color using caller-provided temporary vector scratch.
///
/// to use internally
/// the destination span
@@ -165,26 +325,28 @@ public abstract class PixelBlender
/// A span with values between 0 and 1 indicating the weight of the second source vector.
/// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
///
+ /// Reusable temporary vector scratch with capacity for at least 2 rows.
public void Blend(
Configuration configuration,
Span destination,
ReadOnlySpan background,
TPixel source,
- ReadOnlySpan amount)
+ ReadOnlySpan amount,
+ Span workingBuffer)
{
int maxLength = destination.Length;
Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 2, nameof(workingBuffer.Length));
- using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, source.ToScaledVector4(), amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
diff --git a/src/ImageSharp/Processing/Extensions/Quantization/QuantizeExtensions.cs b/src/ImageSharp/Processing/Extensions/Quantization/QuantizeExtensions.cs
index bf6d2221f4..b0f5cb7d60 100644
--- a/src/ImageSharp/Processing/Extensions/Quantization/QuantizeExtensions.cs
+++ b/src/ImageSharp/Processing/Extensions/Quantization/QuantizeExtensions.cs
@@ -12,12 +12,12 @@ namespace SixLabors.ImageSharp.Processing;
public static class QuantizeExtensions
{
///
- /// Applies quantization to the image using the .
+ /// Applies quantization to the image using the .
///
/// The current image processing context.
/// The .
public static IImageProcessingContext Quantize(this IImageProcessingContext source) =>
- Quantize(source, KnownQuantizers.Octree);
+ Quantize(source, KnownQuantizers.Hexadecatree);
///
/// Applies quantization to the image.
@@ -29,7 +29,7 @@ public static class QuantizeExtensions
source.ApplyProcessor(new QuantizeProcessor(quantizer));
///
- /// Applies quantization to the image using the .
+ /// Applies quantization to the image using the .
///
/// The current image processing context.
///
@@ -37,7 +37,7 @@ public static class QuantizeExtensions
///
/// The .
public static IImageProcessingContext Quantize(this IImageProcessingContext source, Rectangle rectangle) =>
- Quantize(source, KnownQuantizers.Octree, rectangle);
+ Quantize(source, KnownQuantizers.Hexadecatree, rectangle);
///
/// Applies quantization to the image.
diff --git a/src/ImageSharp/Processing/KnownQuantizers.cs b/src/ImageSharp/Processing/KnownQuantizers.cs
index 6fb3c72e81..b63ba597d1 100644
--- a/src/ImageSharp/Processing/KnownQuantizers.cs
+++ b/src/ImageSharp/Processing/KnownQuantizers.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Processing.Processors.Quantization;
@@ -6,14 +6,14 @@ using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace SixLabors.ImageSharp.Processing;
///
-/// Contains reusable static instances of known quantizing algorithms
+/// Contains reusable static instances of known quantizing algorithms.
///
public static class KnownQuantizers
{
///
- /// Gets the adaptive Octree quantizer. Fast with good quality.
+ /// Gets the adaptive hexadecatree quantizer. Fast with good quality.
///
- public static IQuantizer Octree { get; } = new OctreeQuantizer();
+ public static IQuantizer Hexadecatree { get; } = new HexadecatreeQuantizer();
///
/// Gets the Xiaolin Wu's Color Quantizer which generates high quality output.
diff --git a/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs b/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
index 7a1ffb85f9..f2faee4f9c 100644
--- a/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
+++ b/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.Memory;
@@ -145,7 +146,7 @@ internal class DrawImageProcessor : ImageProcessor
this.Blender,
this.Opacity);
- ParallelRowIterator.IterateRows(
+ ParallelRowIterator.IterateRows(
configuration,
new Rectangle(0, 0, foregroundRectangle.Width, foregroundRectangle.Height),
in operation);
@@ -161,7 +162,7 @@ internal class DrawImageProcessor : ImageProcessor
///
/// A implementing the draw logic for .
///
- private readonly struct RowOperation : IRowOperation
+ private readonly struct RowOperation : IRowOperation
{
private readonly Buffer2D background;
private readonly Buffer2D foreground;
@@ -190,13 +191,20 @@ internal class DrawImageProcessor : ImageProcessor
this.opacity = opacity;
}
+ ///
+ public int GetRequiredBufferLength(Rectangle bounds)
+
+ // By using a dedicated vector span we can avoid per-row pool allocations in PixelBlender.Blend
+ // We need 3 Vector4 values per pixel to store the background, foreground, and result pixels for blending.
+ => 3 * bounds.Width;
+
///
[MethodImpl(InliningOptions.ShortMethod)]
- public void Invoke(int y)
+ public void Invoke(int y, Span span)
{
Span background = this.background.DangerousGetRowSpan(y + this.backgroundRectangle.Top).Slice(this.backgroundRectangle.Left, this.backgroundRectangle.Width);
Span foreground = this.foreground.DangerousGetRowSpan(y + this.foregroundRectangle.Top).Slice(this.foregroundRectangle.Left, this.foregroundRectangle.Width);
- this.blender.Blend(this.configuration, background, background, foreground, this.opacity);
+ this.blender.Blend(this.configuration, background, background, foreground, this.opacity, span);
}
}
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
index 26fd7d5d76..c520d7c54b 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
@@ -15,14 +15,8 @@ public enum ColorMatchingMode
Coarse,
///
- /// Enables an exact color match cache for the first 512 unique colors encountered,
- /// falling back to coarse matching thereafter.
- ///
- Hybrid,
-
- ///
- /// Performs exact color matching without any caching optimizations.
- /// This is the slowest but most accurate matching strategy.
+ /// Performs exact color matching using a bounded exact-match cache with eviction.
+ /// This preserves exact color matching while accelerating repeated colors.
///
Exact
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
index 5b0c7252cb..e2e7206e09 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
@@ -3,6 +3,8 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
+using System.Runtime.Intrinsics;
+using SixLabors.ImageSharp.Common.Helpers;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
@@ -71,32 +73,107 @@ internal sealed class EuclideanPixelMap : PixelMap
[MethodImpl(InliningOptions.ColdPath)]
private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
{
- // Loop through the palette and find the nearest match.
+ ReadOnlySpan rgbaPalette = this.rgbaPalette;
+ ref Rgba32 rgbaPaletteRef = ref MemoryMarshal.GetReference(rgbaPalette);
int index = 0;
- float leastDistance = float.MaxValue;
- for (int i = 0; i < this.rgbaPalette.Length; i++)
+ int leastDistance = int.MaxValue;
+ int i = 0;
+
+ if (Vector128.IsHardwareAccelerated && rgbaPalette.Length >= 4)
{
- Rgba32 candidate = this.rgbaPalette[i];
- if (candidate.PackedValue == rgba.PackedValue)
- {
- index = i;
- break;
- }
+ // Duplicate the query color so one 128-bit register can be subtracted from
+ // two packed RGBA candidates at a time after widening.
+ Vector128 pixel = Vector128.Create(
+ rgba.R,
+ rgba.G,
+ rgba.B,
+ rgba.A,
+ rgba.R,
+ rgba.G,
+ rgba.B,
+ rgba.A);
- float distance = DistanceSquared(rgba, candidate);
- if (distance == 0)
+ int vectorizedLength = rgbaPalette.Length & ~0x03;
+
+ for (; i < vectorizedLength; i += 4)
{
- index = i;
- break;
+ // Load four packed Rgba32 values (16 bytes) and widen them into two vectors:
+ // [c0.r, c0.g, c0.b, c0.a, c1.r, ...] and [c2.r, c2.g, c2.b, c2.a, c3.r, ...].
+ Vector128 packed = Vector128.LoadUnsafe(ref Unsafe.As(ref Unsafe.Add(ref rgbaPaletteRef, i)));
+ Vector128 lowerDiff = Vector128.WidenLower(packed).AsInt16() - pixel;
+ Vector128 upperDiff = Vector128.WidenUpper(packed).AsInt16() - pixel;
+
+ // MultiplyAddAdjacent collapses channel squares into RG + BA partial sums,
+ // so each pair of int lanes still corresponds to one candidate color.
+ Vector128 lowerPairs = Vector128_.MultiplyAddAdjacent(lowerDiff, lowerDiff);
+ Vector128 upperPairs = Vector128_.MultiplyAddAdjacent(upperDiff, upperDiff);
+
+ // Sum the two partials for candidates i and i + 1.
+ ref int lowerRef = ref Unsafe.As, int>(ref lowerPairs);
+ int distance = lowerRef + Unsafe.Add(ref lowerRef, 1);
+ if (distance < leastDistance)
+ {
+ index = i;
+ leastDistance = distance;
+ if (distance == 0)
+ {
+ goto Found;
+ }
+ }
+
+ distance = Unsafe.Add(ref lowerRef, 2) + Unsafe.Add(ref lowerRef, 3);
+ if (distance < leastDistance)
+ {
+ index = i + 1;
+ leastDistance = distance;
+ if (distance == 0)
+ {
+ goto Found;
+ }
+ }
+
+ // Sum the two partials for candidates i + 2 and i + 3.
+ ref int upperRef = ref Unsafe.As, int>(ref upperPairs);
+ distance = upperRef + Unsafe.Add(ref upperRef, 1);
+ if (distance < leastDistance)
+ {
+ index = i + 2;
+ leastDistance = distance;
+ if (distance == 0)
+ {
+ goto Found;
+ }
+ }
+
+ distance = Unsafe.Add(ref upperRef, 2) + Unsafe.Add(ref upperRef, 3);
+ if (distance < leastDistance)
+ {
+ index = i + 3;
+ leastDistance = distance;
+ if (distance == 0)
+ {
+ goto Found;
+ }
+ }
}
+ }
+ for (; i < rgbaPalette.Length; i++)
+ {
+ int distance = DistanceSquared(rgba, Unsafe.Add(ref rgbaPaletteRef, i));
if (distance < leastDistance)
{
index = i;
leastDistance = distance;
+ if (distance == 0)
+ {
+ goto Found;
+ }
}
}
+ Found:
+
// 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);
@@ -111,12 +188,12 @@ internal sealed class EuclideanPixelMap : PixelMap
/// The second point.
/// The distance squared.
[MethodImpl(InliningOptions.ShortMethod)]
- private static float DistanceSquared(Rgba32 a, Rgba32 b)
+ private static int 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;
+ int deltaR = a.R - b.R;
+ int deltaG = a.G - b.G;
+ int deltaB = a.B - b.B;
+ int deltaA = a.A - b.A;
return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
}
@@ -177,8 +254,7 @@ internal static class PixelMapFactory
ColorMatchingMode colorMatchingMode)
where TPixel : unmanaged, IPixel => colorMatchingMode switch
{
- ColorMatchingMode.Hybrid => new EuclideanPixelMap(configuration, palette),
- ColorMatchingMode.Exact => new EuclideanPixelMap(configuration, palette),
+ ColorMatchingMode.Exact => new EuclideanPixelMap(configuration, palette),
_ => new EuclideanPixelMap(configuration, palette),
};
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer.cs b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer.cs
similarity index 51%
rename from src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer.cs
rename to src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer.cs
index 0a1032bf0d..6b2f5a0131 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer.cs
@@ -6,25 +6,29 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
///
-/// Allows the quantization of images pixels using Octrees.
-///
+/// Quantizes images by grouping colors in an adaptive 16-way tree and reducing those groups into a palette.
///
-public class OctreeQuantizer : IQuantizer
+///
+/// Each level routes colors using one bit of RGB and, when useful, one bit of alpha. Fully opaque mid-tone colors
+/// use RGB-only routing so more branch resolution is spent on visible color detail, while transparent, dark, and
+/// light colors use alpha-aware routing so opacity changes can form their own palette buckets.
+///
+public class HexadecatreeQuantizer : IQuantizer
{
///
- /// Initializes a new instance of the class
+ /// Initializes a new instance of the class
/// using the default .
///
- public OctreeQuantizer()
+ public HexadecatreeQuantizer()
: this(new QuantizerOptions())
{
}
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// The quantizer options defining quantization rules.
- public OctreeQuantizer(QuantizerOptions options)
+ /// The quantizer options that control palette size, dithering, and transparency behavior.
+ public HexadecatreeQuantizer(QuantizerOptions options)
{
Guard.NotNull(options, nameof(options));
this.Options = options;
@@ -41,5 +45,5 @@ public class OctreeQuantizer : IQuantizer
///
public IQuantizer CreatePixelSpecificQuantizer(Configuration configuration, QuantizerOptions options)
where TPixel : unmanaged, IPixel
- => new OctreeQuantizer(configuration, options);
+ => new HexadecatreeQuantizer(configuration, options);
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs
similarity index 54%
rename from src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
rename to src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs
index 07596b68a8..b5d39d73ec 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/HexadecatreeQuantizer{TPixel}.cs
@@ -12,19 +12,28 @@ using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
///
-/// Encapsulates methods to calculate the color palette if an image using an Octree pattern.
-///
+/// Quantizes an image by building an adaptive 16-way color tree and reducing it to the requested palette size.
///
+///
+///
+/// Each level routes colors using one bit of RGB and, when useful, one bit of alpha, giving the tree up to 16 children
+/// per node and letting transparency participate directly in palette construction.
+///
+///
+/// Fully opaque mid-tone colors use RGB-only routing so more branch resolution is spent on visible color detail.
+/// Transparent, dark, and light colors use alpha-aware routing so opacity changes can form distinct palette buckets.
+///
+///
/// The pixel format.
#pragma warning disable CA1001 // Types that own disposable fields should be disposable
// See https://github.com/dotnet/roslyn-analyzers/issues/6151
-public struct OctreeQuantizer : IQuantizer
+public struct HexadecatreeQuantizer : IQuantizer
#pragma warning restore CA1001 // Types that own disposable fields should be disposable
where TPixel : unmanaged, IPixel
{
private readonly int maxColors;
private readonly int bitDepth;
- private readonly Octree octree;
+ private readonly Hexadecatree tree;
private readonly IMemoryOwner paletteOwner;
private ReadOnlyMemory palette;
private PixelMap? pixelMap;
@@ -32,19 +41,19 @@ public struct OctreeQuantizer : IQuantizer
private bool isDisposed;
///
- /// Initializes a new instance of the struct.
+ /// Initializes a new instance of the struct.
///
- /// The configuration which allows altering default behavior or extending the library.
- /// The quantizer options defining quantization rules.
+ /// The configuration that provides memory allocation and pixel conversion services.
+ /// The quantizer options that control palette size, dithering, and transparency behavior.
[MethodImpl(InliningOptions.ShortMethod)]
- public OctreeQuantizer(Configuration configuration, QuantizerOptions options)
+ public HexadecatreeQuantizer(Configuration configuration, QuantizerOptions options)
{
this.Configuration = configuration;
this.Options = options;
this.maxColors = this.Options.MaxColors;
this.bitDepth = Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(this.maxColors), 1, 8);
- this.octree = new Octree(configuration, this.bitDepth, this.maxColors, this.Options.TransparencyThreshold);
+ this.tree = new Hexadecatree(configuration, this.bitDepth, this.maxColors, this.Options.TransparencyThreshold);
this.paletteOwner = configuration.MemoryAllocator.Allocate(this.maxColors, AllocationOptions.Clean);
this.pixelMap = default;
this.palette = default;
@@ -76,23 +85,28 @@ public struct OctreeQuantizer : IQuantizer
///
public readonly void AddPaletteColors(in Buffer2DRegion pixelRegion)
{
- PixelRowDelegate pixelRowDelegate = new(this.octree);
- QuantizerUtilities.AddPaletteColors, TPixel, Rgba32, PixelRowDelegate>(
+ PixelRowDelegate pixelRowDelegate = new(this.tree);
+ QuantizerUtilities.AddPaletteColors, TPixel, Rgba32, PixelRowDelegate>(
ref Unsafe.AsRef(in this),
in pixelRegion,
in pixelRowDelegate);
}
+ ///
+ /// Materializes the final palette from the accumulated tree and prepares the dither lookup map when needed.
+ ///
private void ResolvePalette()
{
short paletteIndex = 0;
Span paletteSpan = this.paletteOwner.GetSpan();
- this.octree.Palettize(paletteSpan, ref paletteIndex);
+ this.tree.Palettize(paletteSpan, ref paletteIndex);
ReadOnlyMemory result = this.paletteOwner.Memory[..paletteSpan.Length];
if (this.isDithering)
{
+ // Dithered colors often no longer land on a color that was seen during palette construction,
+ // so the quantization pass switches to nearest-palette matching once the palette is finalized.
this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode);
}
@@ -108,17 +122,15 @@ public struct OctreeQuantizer : IQuantizer
[MethodImpl(InliningOptions.ShortMethod)]
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
{
- // Due to the addition of new colors by dithering that are not part of the original histogram,
- // the octree nodes might not match the correct color.
- // In this case, we must use the pixel map to get the closest color.
if (this.isDithering)
{
+ // Dithering introduces adjusted colors that were never inserted into the tree, so tree lookup
+ // is only reliable for the non-dithered path.
return (byte)this.pixelMap!.GetClosestColor(color, out match);
}
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.palette.Span);
-
- int index = this.octree.GetPaletteIndex(color);
+ int index = this.tree.GetPaletteIndex(color);
match = Unsafe.Add(ref paletteRef, (nuint)index);
return (byte)index;
}
@@ -132,34 +144,43 @@ public struct OctreeQuantizer : IQuantizer
this.paletteOwner.Dispose();
this.pixelMap?.Dispose();
this.pixelMap = null;
- this.octree.Dispose();
+ this.tree.Dispose();
}
}
+ ///
+ /// Forwards source rows into the tree without creating an intermediate buffer.
+ ///
private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate
{
- private readonly Octree octree;
+ private readonly Hexadecatree tree;
- public PixelRowDelegate(Octree octree) => this.octree = octree;
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The destination tree that should accumulate each visited row.
+ public PixelRowDelegate(Hexadecatree tree) => this.tree = tree;
- public void Invoke(ReadOnlySpan row, int rowIndex) => this.octree.AddColors(row);
+ ///
+ public void Invoke(ReadOnlySpan row, int rowIndex) => this.tree.AddColors(row);
}
///
- /// A hexadecatree-based color quantization structure used for fast color distance lookups and palette generation.
- /// This tree maintains a fixed pool of nodes (capacity 4096) where each node can have up to 16 children, stores
- /// color accumulation data, and supports dynamic node allocation and reduction. It offers near-constant-time insertions
- /// and lookups while consuming roughly 240 KB for the node pool.
+ /// Stores the adaptive 16-way partition tree used to accumulate colors and emit palette entries.
///
- internal sealed class Octree : IDisposable
+ ///
+ /// The tree uses a fixed node arena for predictable allocation behavior, keeps per-level reducible node lists so
+ /// deeper buckets can be merged until the palette fits, and caches the previously inserted leaf so repeated colors
+ /// can be accumulated cheaply.
+ ///
+ internal sealed class Hexadecatree : IDisposable
{
- // The memory allocator.
- private readonly MemoryAllocator allocator;
-
// Pooled buffer for OctreeNodes.
- private readonly IMemoryOwner nodesOwner;
+ private readonly IMemoryOwner nodesOwner;
- // Reducible nodes: one per level; we use an integer index; -1 means “no node.”
+ // One reducible-node head per level.
+ // Each entry stores a node index, or -1 when that level currently
+ // has no reducible nodes.
private readonly short[] reducibleNodes;
// Maximum number of allowable colors.
@@ -186,13 +207,13 @@ public struct OctreeQuantizer : IQuantizer
private readonly Stack freeIndices = new();
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// The configuration which allows altering default behavior or extending the library.
- /// The maximum number of significant bits in the image.
- /// The maximum number of colors to allow in the palette.
- /// The threshold for transparent colors.
- public Octree(
+ /// The configuration that provides the backing memory allocator.
+ /// The number of levels to descend before forcing leaves.
+ /// The maximum number of palette entries the reduced tree may retain.
+ /// The alpha threshold below which generated palette entries become fully transparent.
+ public Hexadecatree(
Configuration configuration,
int maxColorBits,
int maxColors,
@@ -207,8 +228,7 @@ public struct OctreeQuantizer : IQuantizer
// Allocate a conservative buffer for nodes.
const int capacity = 4096;
- this.allocator = configuration.MemoryAllocator;
- this.nodesOwner = this.allocator.Allocate(capacity, AllocationOptions.Clean);
+ this.nodesOwner = configuration.MemoryAllocator.Allocate(capacity, AllocationOptions.Clean);
// Create the reducible nodes array (one per level 0 .. maxColorBits-1).
this.reducibleNodes = new short[this.maxColorBits];
@@ -216,24 +236,24 @@ public struct OctreeQuantizer : IQuantizer
// Reserve index 0 for the root.
this.rootIndex = 0;
- ref OctreeNode root = ref this.Nodes[this.rootIndex];
+ ref Node root = ref this.Nodes[this.rootIndex];
root.Initialize(0, this.maxColorBits, this, this.rootIndex);
}
///
- /// Gets or sets the number of leaves in the tree.
+ /// Gets or sets the number of leaf nodes currently representing palette buckets.
///
public int Leaves { get; set; }
///
- /// Gets the full collection of nodes as a span.
+ /// Gets the underlying node arena.
///
- internal Span Nodes => this.nodesOwner.Memory.Span;
+ internal Span Nodes => this.nodesOwner.Memory.Span;
///
- /// Adds a span of colors to the octree.
+ /// Adds a row of colors to the tree.
///
- /// A span of color values to be added.
+ /// The colors to accumulate.
public void AddColors(ReadOnlySpan row)
{
for (int x = 0; x < row.Length; x++)
@@ -243,12 +263,13 @@ public struct OctreeQuantizer : IQuantizer
}
///
- /// Add a color to the Octree.
+ /// Adds a single color sample to the tree.
///
- /// The color to add.
+ /// The color to accumulate.
private void AddColor(Rgba32 color)
{
- // Ensure that the tree is not already full.
+ // Once the node arena is full and there are no recycled slots available, keep collapsing
+ // reducible leaves until the tree is small enough to make forward progress again.
if (this.nextNode >= this.Nodes.Length && this.freeIndices.Count == 0)
{
while (this.Leaves > this.maxColors)
@@ -257,32 +278,32 @@ public struct OctreeQuantizer : IQuantizer
}
}
- // If the color is the same as the previous color, increment the node.
- // Otherwise, add a new node.
+ // Scanlines often contain long runs of the same color. Caching the previous leaf lets those
+ // repeats skip the tree walk and just bump the accumulated sums in place.
if (this.previousColor.Equals(color))
{
if (this.previousNode == -1)
{
this.previousColor = color;
- OctreeNode.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
+ Node.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
}
else
{
- OctreeNode.Increment(this.previousNode, color, this);
+ Node.Increment(this.previousNode, color, this);
}
}
else
{
this.previousColor = color;
- OctreeNode.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
+ Node.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
}
}
///
- /// Construct the palette from the octree.
+ /// Reduces the tree to the requested palette size and emits the final palette entries.
///
- /// The palette to construct.
- /// The current palette index.
+ /// The destination palette span.
+ /// The running palette index.
public void Palettize(Span palette, ref short paletteIndex)
{
while (this.Leaves > this.maxColors)
@@ -294,48 +315,45 @@ public struct OctreeQuantizer : IQuantizer
}
///
- /// Get the palette index for the passed color.
+ /// Gets the palette index selected by the tree for the supplied color.
///
- /// The color to get the palette index for.
- /// The .
+ /// The color to resolve.
+ /// The palette index represented by the best matching leaf in the reduced tree.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetPaletteIndex(TPixel color)
=> this.Nodes[this.rootIndex].GetPaletteIndex(color.ToRgba32(), 0, this);
///
- /// Track the previous node and color.
+ /// Records the most recently touched leaf so repeated colors can bypass another descent.
///
- /// The node index.
+ /// The leaf node index.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void TrackPrevious(int nodeIndex)
=> this.previousNode = nodeIndex;
///
- /// Reduce the depth of the tree.
+ /// Collapses the deepest currently reducible node into a single leaf.
///
private void Reduce()
{
- // Find the deepest level containing at least one reducible node
int index = this.maxColorBits - 1;
while ((index > 0) && (this.reducibleNodes[index] == -1))
{
index--;
}
- // Reduce the node most recently added to the list at level 'index'
- ref OctreeNode node = ref this.Nodes[this.reducibleNodes[index]];
+ ref Node node = ref this.Nodes[this.reducibleNodes[index]];
this.reducibleNodes[index] = node.NextReducibleIndex;
-
- // Decrement the leaf count after reducing the node
node.Reduce(this);
- // And just in case I've reduced the last color to be added, and the next color to
- // be added is the same, invalidate the previousNode...
+ // If the last inserted leaf was merged away, the next repeated color must walk the tree again.
this.previousNode = -1;
}
- // Allocate a new OctreeNode from the pooled buffer.
- // First check the freeIndices stack.
+ ///
+ /// Allocates a node index from the free list or from the unused tail of the arena.
+ ///
+ /// The allocated node index, or -1 if no node can be allocated.
internal short AllocateNode()
{
if (this.freeIndices.Count > 0)
@@ -354,9 +372,9 @@ public struct OctreeQuantizer : IQuantizer
}
///
- /// Free a node index, making it available for re-allocation.
+ /// Returns a node index to the free list.
///
- /// The index to free.
+ /// The node index to recycle.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void FreeNode(short index)
{
@@ -367,8 +385,11 @@ public struct OctreeQuantizer : IQuantizer
///
public void Dispose() => this.nodesOwner.Dispose();
+ ///
+ /// Represents one node in the hexadecatree node arena.
+ ///
[StructLayout(LayoutKind.Sequential)]
- internal unsafe struct OctreeNode
+ internal struct Node
{
public bool Leaf;
public int PixelCount;
@@ -380,19 +401,21 @@ public struct OctreeQuantizer : IQuantizer
public short NextReducibleIndex;
private InlineArray16 children;
+ ///
+ /// Gets the 16 child slots for this node.
+ ///
[UnscopedRef]
public Span Children => this.children;
///
- /// Initialize the .
+ /// Initializes a node either as a leaf or as a reducible interior node.
///
- /// The level of the node.
- /// The number of significant color bits in the image.
- /// The parent octree.
- /// The index of the node.
- public void Initialize(int level, int colorBits, Octree octree, short index)
+ /// The depth of the node being initialized.
+ /// The maximum tree depth.
+ /// The owning tree.
+ /// The node index in the arena.
+ public void Initialize(int level, int colorBits, Hexadecatree tree, short index)
{
- // Construct the new node.
this.Leaf = level == colorBits;
this.Red = 0;
this.Green = 0;
@@ -401,76 +424,73 @@ public struct OctreeQuantizer : IQuantizer
this.PixelCount = 0;
this.PaletteIndex = 0;
this.NextReducibleIndex = -1;
-
- // Always clear the Children array.
this.Children.Fill(-1);
if (this.Leaf)
{
- octree.Leaves++;
+ tree.Leaves++;
}
else
{
- // Add this node to the reducible nodes list for its level.
- this.NextReducibleIndex = octree.reducibleNodes[level];
- octree.reducibleNodes[level] = index;
+ // Track reducible nodes per level so palette reduction can always collapse the deepest
+ // buckets first without scanning the entire arena.
+ this.NextReducibleIndex = tree.reducibleNodes[level];
+ tree.reducibleNodes[level] = index;
}
}
///
- /// Add a color to the Octree.
+ /// Descends the tree for the supplied color, allocating nodes as needed until a leaf is reached.
///
- /// The node index.
- /// The color to add.
- /// The number of significant color bits in the image.
- /// The level of the node.
- /// The parent octree.
- public static void AddColor(int nodeIndex, Rgba32 color, int colorBits, int level, Octree octree)
+ /// The current node index.
+ /// The color being accumulated.
+ /// The maximum tree depth.
+ /// The current depth.
+ /// The owning tree.
+ public static void AddColor(int nodeIndex, Rgba32 color, int colorBits, int level, Hexadecatree tree)
{
- ref OctreeNode node = ref octree.Nodes[nodeIndex];
+ ref Node node = ref tree.Nodes[nodeIndex];
if (node.Leaf)
{
- Increment(nodeIndex, color, octree);
- octree.TrackPrevious(nodeIndex);
+ Increment(nodeIndex, color, tree);
+ tree.TrackPrevious(nodeIndex);
+ return;
}
- else
- {
- int index = GetColorIndex(color, level);
- short childIndex;
- Span children = node.Children;
- childIndex = children[index];
+ int index = GetColorIndex(color, level);
+ Span children = node.Children;
+ short childIndex = children[index];
+ if (childIndex == -1)
+ {
+ childIndex = tree.AllocateNode();
if (childIndex == -1)
{
- childIndex = octree.AllocateNode();
-
- if (childIndex == -1)
- {
- // No room in the tree, so increment the count and return.
- Increment(nodeIndex, color, octree);
- octree.TrackPrevious(nodeIndex);
- return;
- }
-
- ref OctreeNode child = ref octree.Nodes[childIndex];
- child.Initialize(level + 1, colorBits, octree, childIndex);
- children[index] = childIndex;
+ // If the arena is exhausted and no node can be reclaimed yet, fall back to
+ // accumulating into the current node instead of failing the insert outright.
+ Increment(nodeIndex, color, tree);
+ tree.TrackPrevious(nodeIndex);
+ return;
}
- AddColor(childIndex, color, colorBits, level + 1, octree);
+ ref Node child = ref tree.Nodes[childIndex];
+ child.Initialize(level + 1, colorBits, tree, childIndex);
+ children[index] = childIndex;
}
+
+ // Keep descending until we reach the leaf bucket that should accumulate this sample.
+ AddColor(childIndex, color, colorBits, level + 1, tree);
}
///
- /// Increment the color components of this node.
+ /// Adds the supplied color sample to an existing node's running sums.
///
- /// The node index.
- /// The color to increment by.
- /// The parent octree.
- public static void Increment(int nodeIndex, Rgba32 color, Octree octree)
+ /// The node index to update.
+ /// The color sample being accumulated.
+ /// The owning tree.
+ public static void Increment(int nodeIndex, Rgba32 color, Hexadecatree tree)
{
- ref OctreeNode node = ref octree.Nodes[nodeIndex];
+ ref Node node = ref tree.Nodes[nodeIndex];
node.PixelCount++;
node.Red += color.R;
node.Green += color.G;
@@ -479,10 +499,10 @@ public struct OctreeQuantizer : IQuantizer
}
///
- /// Reduce this node by ensuring its children are all reduced (i.e. leaves) and then merging their data.
+ /// Merges all child nodes into this node and turns it into a leaf.
///
- /// The parent octree.
- public void Reduce(Octree octree)
+ /// The owning tree.
+ public void Reduce(Hexadecatree tree)
{
// If already a leaf, do nothing.
if (this.Leaf)
@@ -492,25 +512,27 @@ public struct OctreeQuantizer : IQuantizer
// Now merge the (presumably reduced) children.
int pixelCount = 0;
- int sumRed = 0, sumGreen = 0, sumBlue = 0, sumAlpha = 0;
+ int sumRed = 0;
+ int sumGreen = 0;
+ int sumBlue = 0;
+ int sumAlpha = 0;
Span children = this.Children;
+
for (int i = 0; i < children.Length; i++)
{
short childIndex = children[i];
if (childIndex != -1)
{
- ref OctreeNode child = ref octree.Nodes[childIndex];
+ ref Node child = ref tree.Nodes[childIndex];
int pixels = child.PixelCount;
-
sumRed += child.Red;
sumGreen += child.Green;
sumBlue += child.Blue;
sumAlpha += child.Alpha;
pixelCount += pixels;
- // Free the child immediately.
children[i] = -1;
- octree.FreeNode(childIndex);
+ tree.FreeNode(childIndex);
}
}
@@ -529,16 +551,16 @@ public struct OctreeQuantizer : IQuantizer
}
this.Leaf = true;
- octree.Leaves++;
+ tree.Leaves++;
}
///
- /// Traverse the tree to construct the palette.
+ /// Traverses the reduced tree and emits one palette color per leaf.
///
- /// The parent octree.
- /// The palette to construct.
- /// The current palette index.
- public void ConstructPalette(Octree octree, Span palette, ref short paletteIndex)
+ /// The owning tree.
+ /// The destination palette span.
+ /// The running palette index.
+ public void ConstructPalette(Hexadecatree tree, Span palette, ref short paletteIndex)
{
if (this.Leaf)
{
@@ -549,13 +571,12 @@ public struct OctreeQuantizer : IQuantizer
Vector4.Zero,
new Vector4(255));
- if (vector.W < octree.transparencyThreshold255)
+ if (vector.W < tree.transparencyThreshold255)
{
vector = Vector4.Zero;
}
palette[paletteIndex] = TPixel.FromRgba32(new Rgba32((byte)vector.X, (byte)vector.Y, (byte)vector.Z, (byte)vector.W));
-
this.PaletteIndex = paletteIndex++;
}
else
@@ -566,19 +587,20 @@ public struct OctreeQuantizer : IQuantizer
int childIndex = children[i];
if (childIndex != -1)
{
- octree.Nodes[childIndex].ConstructPalette(octree, palette, ref paletteIndex);
+ tree.Nodes[childIndex].ConstructPalette(tree, palette, ref paletteIndex);
}
}
}
}
///
- /// Get the palette index for the passed color.
+ /// Resolves the palette index represented by this node for the supplied color.
///
- /// The color to get the palette index for.
- /// The level of the node.
- /// The parent octree.
- public int GetPaletteIndex(Rgba32 color, int level, Octree octree)
+ /// The color to resolve.
+ /// The current tree depth.
+ /// The owning tree.
+ /// The palette index for the best reachable leaf, or -1 if no leaf can be reached.
+ public int GetPaletteIndex(Rgba32 color, int level, Hexadecatree tree)
{
if (this.Leaf)
{
@@ -590,15 +612,16 @@ public struct OctreeQuantizer : IQuantizer
int childIndex = children[colorIndex];
if (childIndex != -1)
{
- return octree.Nodes[childIndex].GetPaletteIndex(color, level + 1, octree);
+ return tree.Nodes[childIndex].GetPaletteIndex(color, level + 1, tree);
}
+ // After reductions the exact branch can disappear, so fall back to the first reachable descendant leaf.
for (int i = 0; i < children.Length; i++)
{
childIndex = children[i];
if (childIndex != -1)
{
- int childPaletteIndex = octree.Nodes[childIndex].GetPaletteIndex(color, level + 1, octree);
+ int childPaletteIndex = tree.Nodes[childIndex].GetPaletteIndex(color, level + 1, tree);
if (childPaletteIndex != -1)
{
return childPaletteIndex;
@@ -610,37 +633,35 @@ public struct OctreeQuantizer : IQuantizer
}
///
- /// Gets the color index at the given level.
+ /// Computes the child slot for a color at the supplied tree level.
///
- /// The color to get the index for.
- /// The level to get the index at.
+ /// The color being routed.
+ /// The tree depth whose bit plane should be sampled.
+ /// The child slot index for the color at the supplied level.
+ ///
+ /// For fully opaque mid-tone colors the tree ignores alpha and routes on RGB only, preserving more branch
+ /// resolution for visible color detail. For transparent, dark, and light colors it includes alpha as the
+ /// most significant routing bit so opacity changes can form their own branches.
+ ///
public static int GetColorIndex(Rgba32 color, int level)
{
- // Determine how many bits to shift based on the current tree level.
- // At level 0, shift = 7; as level increases, the shift decreases.
+ // Sample one bit plane per level, starting at the most significant bit and moving downward.
int shift = 7 - level;
byte mask = (byte)(1 << shift);
- // Compute the luminance of the RGB components using the BT.709 standard.
- // This gives a measure of brightness for the color.
+ // Use BT.709 luminance as a cheap brightness estimate for deciding whether alpha carries
+ // useful information at this level for fully opaque colors.
int luminance = ColorNumerics.Get8BitBT709Luminance(color.R, color.G, color.B);
- // Define thresholds for determining when to include the alpha bit in the index.
- // The thresholds are scaled according to the current level.
- // 128 is the midpoint of the 8-bit range (0–255), so shifting it right by 'level'
- // produces a threshold that scales with the color cube subdivision.
+ // Scale the brightness thresholds with depth so deeper levels become stricter about when
+ // to spend a branch bit on alpha instead of RGB detail.
int darkThreshold = 128 >> level;
-
- // The light threshold is set symmetrically: 255 minus the scaled midpoint.
int lightThreshold = 255 - (128 >> level);
- // If the pixel is fully opaque and its brightness falls between the dark and light thresholds,
- // ignore the alpha channel to maximize RGB resolution.
- // Otherwise (if the pixel is dark, light, or semi-transparent), include the alpha bit
- // to preserve any gradient that may be present.
if (color.A == 255 && luminance > darkThreshold && luminance < lightThreshold)
{
- // Extract one bit each from R, G, and B channels and combine them into a 3-bit index.
+ // Fully opaque mid-tone colors route on RGB only, which preserves more visible color
+ // resolution because alpha would contribute no extra separation here.
int rBits = ((color.R & mask) >> shift) << 2;
int gBits = ((color.G & mask) >> shift) << 1;
int bBits = (color.B & mask) >> shift;
@@ -648,7 +669,8 @@ public struct OctreeQuantizer : IQuantizer
}
else
{
- // Extract one bit from each channel including alpha (alpha becomes the most significant bit).
+ // Transparent, dark, and light colors include alpha as the high routing bit so opacity
+ // changes can form distinct buckets alongside RGB differences.
int aBits = ((color.A & mask) >> shift) << 3;
int rBits = ((color.R & mask) >> shift) << 2;
int gBits = ((color.G & mask) >> shift) << 1;
diff --git a/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
index 32d95137bc..76598e0046 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
@@ -56,147 +56,6 @@ internal interface IColorIndexCache : IColorIndexCache
public static abstract T Create(MemoryAllocator allocator);
}
-///
-/// A hybrid color distance cache that combines a small, fixed-capacity exact-match dictionary
-/// (ExactCache, ~4–5 KB for up to 512 entries) with a coarse lookup table (CoarseCache) for 5,5,5,6 precision.
-///
-///
-/// ExactCache provides O(1) lookup for common cases using a simple 256-entry hash-based dictionary, while CoarseCache
-/// quantizes RGB channels to 5 bits (yielding 32^3 buckets) and alpha to 6 bits, storing up to 4 alpha entries per bucket
-/// (a design chosen based on probability theory to capture most real-world variations) for a total memory footprint of
-/// roughly 576 KB. Lookups and insertions are performed in constant time, making the overall design both fast and memory-predictable.
-///
-internal unsafe struct HybridCache : IColorIndexCache
-{
- private CoarseCache coarseCache;
- private AccurateCache accurateCache;
-
- public HybridCache(MemoryAllocator allocator)
- {
- this.accurateCache = AccurateCache.Create(allocator);
- this.coarseCache = CoarseCache.Create(allocator);
- }
-
- ///
- public static HybridCache Create(MemoryAllocator allocator) => new(allocator);
-
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public bool TryAdd(Rgba32 color, short index)
- {
- if (this.accurateCache.TryAdd(color, index))
- {
- return true;
- }
-
- return this.coarseCache.TryAdd(color, index);
- }
-
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryGetValue(Rgba32 color, out short value)
- {
- if (this.accurateCache.TryGetValue(color, out value))
- {
- return true;
- }
-
- return this.coarseCache.TryGetValue(color, out value);
- }
-
- ///
- public readonly void Clear()
- {
- this.accurateCache.Clear();
- this.coarseCache.Clear();
- }
-
- ///
- public void Dispose()
- {
- this.accurateCache.Dispose();
- this.coarseCache.Dispose();
- }
-}
-
-///
-/// A coarse cache for color distance lookups that uses a fixed-size lookup table.
-///
-///
-/// This cache uses a fixed lookup table with 2,097,152 bins, each storing a 2-byte value,
-/// resulting in a memory usage of approximately 4 MB. Lookups and insertions are
-/// performed in constant time (O(1)) via direct table indexing. This design is optimized for
-/// speed while maintaining a predictable, fixed memory footprint.
-///
-internal unsafe struct CoarseCache : IColorIndexCache
-{
- private const int IndexRBits = 5;
- private const int IndexGBits = 5;
- private const int IndexBBits = 5;
- private const int IndexABits = 6;
- private const int IndexRCount = 1 << IndexRBits; // 32 bins for red
- private const int IndexGCount = 1 << IndexGBits; // 32 bins for green
- private const int IndexBCount = 1 << IndexBBits; // 32 bins for blue
- private const int IndexACount = 1 << IndexABits; // 64 bins for alpha
- private const int TotalBins = IndexRCount * IndexGCount * IndexBCount * IndexACount; // 2,097,152 bins
-
- private readonly IMemoryOwner binsOwner;
- private readonly short* binsPointer;
- private MemoryHandle binsHandle;
-
- private CoarseCache(MemoryAllocator allocator)
- {
- this.binsOwner = allocator.Allocate(TotalBins);
- this.binsOwner.GetSpan().Fill(-1);
- this.binsHandle = this.binsOwner.Memory.Pin();
- this.binsPointer = (short*)this.binsHandle.Pointer;
- }
-
- ///
- public static CoarseCache Create(MemoryAllocator allocator) => new(allocator);
-
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryAdd(Rgba32 color, short value)
- {
- this.binsPointer[GetCoarseIndex(color)] = value;
- return true;
- }
-
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryGetValue(Rgba32 color, out short value)
- {
- value = this.binsPointer[GetCoarseIndex(color)];
- return value > -1; // Coarse match found
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- private static int GetCoarseIndex(Rgba32 color)
- {
- int rIndex = color.R >> (8 - IndexRBits);
- int gIndex = color.G >> (8 - IndexGBits);
- int bIndex = color.B >> (8 - IndexBBits);
- int aIndex = color.A >> (8 - IndexABits);
-
- return (aIndex * IndexRCount * IndexGCount * IndexBCount) +
- (rIndex * IndexGCount * IndexBCount) +
- (gIndex * IndexBCount) +
- bIndex;
- }
-
- ///
- public readonly void Clear()
- => this.binsOwner.GetSpan().Fill(-1);
-
- ///
- public void Dispose()
- {
- this.binsHandle.Dispose();
- this.binsOwner.Dispose();
- }
-}
-
///
///
/// CoarseCache is a fast, low-memory lookup structure for caching palette indices associated with RGBA values,
@@ -225,7 +84,7 @@ internal unsafe struct CoarseCache : IColorIndexCache
/// making it ideal for applications such as color distance caching in images with a limited palette (up to 256 entries).
///
///
-internal unsafe struct CoarseCacheLite : IColorIndexCache
+internal unsafe struct CoarseCache : IColorIndexCache
{
// Use 5 bits per channel for R, G, and B: 32 levels each.
// Total buckets = 32^3 = 32768.
@@ -236,7 +95,7 @@ internal unsafe struct CoarseCacheLite : IColorIndexCache
private readonly AlphaBucket* buckets;
private MemoryHandle bucketHandle;
- private CoarseCacheLite(MemoryAllocator allocator)
+ private CoarseCache(MemoryAllocator allocator)
{
this.bucketsOwner = allocator.Allocate(BucketCount, AllocationOptions.Clean);
this.bucketHandle = this.bucketsOwner.Memory.Pin();
@@ -244,7 +103,7 @@ internal unsafe struct CoarseCacheLite : IColorIndexCache
}
///
- public static CoarseCacheLite Create(MemoryAllocator allocator) => new(allocator);
+ public static CoarseCache Create(MemoryAllocator allocator) => new(allocator);
///
public readonly bool TryAdd(Rgba32 color, short paletteIndex)
@@ -289,14 +148,11 @@ internal unsafe struct CoarseCacheLite : IColorIndexCache
}
[MethodImpl(InliningOptions.ShortMethod)]
- private static byte QuantizeAlpha(byte a)
-
- // Quantize to 6 bits: shift right by (8 - 6) = 2 bits.
- => (byte)(a >> 2);
+ private static byte QuantizeAlpha(byte a) => (byte)(a >> 2);
public struct AlphaEntry
{
- // Store the alpha value quantized to 6 bits (0..63)
+ // Store the alpha value quantized to 6 bits (0..63).
public byte QuantizedAlpha;
public short PaletteIndex;
}
@@ -312,7 +168,7 @@ internal unsafe struct CoarseCacheLite : IColorIndexCache
// 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 8 values out of 64,
- // the probability that these 4 samples are all unique is high if the distribution
+ // the probability that these 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
@@ -377,51 +233,49 @@ internal unsafe struct CoarseCacheLite : IColorIndexCache
}
///
-/// A fixed-capacity dictionary with exactly 512 entries mapping a key
-/// to a value.
+/// A fixed-size exact-match cache that stores packed RGBA keys with 4-way set associativity.
///
///
-/// The dictionary is implemented using a fixed array of 512 buckets and an entries array
-/// of the same size. The bucket for a key is computed as (key & 0x1FF), and collisions are
-/// resolved through a linked chain stored in the field.
+/// The cache holds 512 total entries split across 128 sets. Entries are evicted within a set
+/// using round-robin replacement, but cached values are returned only when the full packed RGBA
+/// key matches, preserving exact quantization results with predictable memory usage.
/// 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.
+/// on average, O(1) since each lookup probes at most four candidate entries within the selected set.
/// This guarantees highly efficient and predictable performance for small, fixed-size color palettes.
///
internal unsafe struct AccurateCache : IColorIndexCache
{
- // Buckets array: each bucket holds the index (0-based) into the entries array
- // of the first entry in the chain, or -1 if empty.
- private readonly IMemoryOwner bucketsOwner;
- private MemoryHandle bucketsHandle;
- private short* buckets;
+ public const int Capacity = 512;
+ private const int Ways = 4;
+ private const int SetCount = Capacity / Ways;
+ private const int SetMask = SetCount - 1;
- // Entries array: stores up to 256 entries.
- private readonly IMemoryOwner entriesOwner;
- private MemoryHandle entriesHandle;
- private Entry* entries;
+ private readonly IMemoryOwner keysOwner;
+ private MemoryHandle keysHandle;
+ private uint* keys;
- public const int Capacity = 512;
+ private readonly IMemoryOwner valuesOwner;
+ private MemoryHandle valuesHandle;
+ private ushort* values;
+
+ private readonly IMemoryOwner nextVictimOwner;
+ private MemoryHandle nextVictimHandle;
+ private byte* nextVictim;
private AccurateCache(MemoryAllocator allocator)
{
- this.Count = 0;
-
- // Allocate exactly 512 indexes for buckets.
- this.bucketsOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
- Span bucketSpan = this.bucketsOwner.GetSpan();
- bucketSpan.Fill(-1);
- this.bucketsHandle = this.bucketsOwner.Memory.Pin();
- this.buckets = (short*)this.bucketsHandle.Pointer;
-
- // Allocate exactly 512 entries.
- this.entriesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
- this.entriesHandle = this.entriesOwner.Memory.Pin();
- this.entries = (Entry*)this.entriesHandle.Pointer;
- }
+ this.keysOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ this.keysHandle = this.keysOwner.Memory.Pin();
+ this.keys = (uint*)this.keysHandle.Pointer;
- public int Count { get; private set; }
+ this.valuesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ this.valuesHandle = this.valuesOwner.Memory.Pin();
+ this.values = (ushort*)this.valuesHandle.Pointer;
+
+ this.nextVictimOwner = allocator.Allocate(SetCount, AllocationOptions.Clean);
+ this.nextVictimHandle = this.nextVictimOwner.Memory.Pin();
+ this.nextVictim = (byte*)this.nextVictimHandle.Pointer;
+ }
///
public static AccurateCache Create(MemoryAllocator allocator) => new(allocator);
@@ -430,140 +284,113 @@ internal unsafe struct AccurateCache : IColorIndexCache
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryAdd(Rgba32 color, short value)
{
- if (this.Count == Capacity)
- {
- return false; // Dictionary is full.
- }
-
uint key = color.PackedValue;
+ int set = GetSetIndex(key);
+ int start = set * Ways;
+ int empty = -1;
+
+ uint* keys = this.keys;
+ ushort* values = this.values;
+ ushort storedValue = (ushort)(value + 1);
- // 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)
+ for (int i = start; i < start + Ways; i++)
{
- Entry e = entries[i];
- if (e.Key == key)
+ ushort candidate = values[i];
+ if (candidate == 0)
{
- // Key already exists; do not overwrite.
- return false;
+ empty = i;
+ continue;
}
- i = e.Next;
+ if (keys[i] == key)
+ {
+ values[i] = storedValue;
+ return true;
+ }
}
- short index = (short)this.Count;
- this.Count++;
+ int slot = empty >= 0 ? empty : start + this.nextVictim[set];
+ keys[slot] = key;
+ values[slot] = storedValue;
- // Insert the new entry:
- entries[index].Key = key;
- entries[index].Value = value;
+ if (empty < 0)
+ {
+ this.nextVictim[set] = (byte)((this.nextVictim[set] + 1) & (Ways - 1));
+ }
- // Link this new entry into the bucket chain.
- entries[index].Next = this.buckets[bucket];
- this.buckets[bucket] = index;
return true;
}
///
[MethodImpl(InliningOptions.ShortMethod)]
- public bool TryGetValue(Rgba32 color, out short value)
+ public readonly 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];
+ int start = GetSetIndex(key) * Ways;
- // If the bucket is empty, return immediately.
- if (i == -1)
- {
- value = -1;
- return false;
- }
+ uint* keys = this.keys;
+ ushort* values = this.values;
- // Traverse the chain.
- Entry* entries = this.entries;
- do
+ for (int i = start; i < start + Ways; i++)
{
- Entry e = entries[i];
- if (e.Key == key)
+ ushort candidate = values[i];
+ if (candidate != 0 && keys[i] == key)
{
- value = e.Value;
+ value = (short)(candidate - 1);
return true;
}
-
- i = e.Next;
}
- while (i != -1);
value = -1;
return false;
}
///
- /// Clears the dictionary.
+ /// Clears the cache.
///
- public void Clear()
+ public readonly void Clear()
{
- Span bucketSpan = this.bucketsOwner.GetSpan();
- bucketSpan.Fill(-1);
- this.Count = 0;
+ this.valuesOwner.GetSpan().Clear();
+ this.nextVictimOwner.GetSpan().Clear();
}
public void Dispose()
{
- this.bucketsHandle.Dispose();
- this.bucketsOwner.Dispose();
- this.entriesHandle.Dispose();
- this.entriesOwner.Dispose();
- this.buckets = null;
- this.entries = null;
+ this.keysHandle.Dispose();
+ this.keysOwner.Dispose();
+ this.valuesHandle.Dispose();
+ this.valuesOwner.Dispose();
+ this.nextVictimHandle.Dispose();
+ this.nextVictimOwner.Dispose();
+ this.keys = null;
+ this.values = null;
+ this.nextVictim = 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.
- }
-}
-
-///