From ac1905328d37213c92fcf57d32417f9322294fe1 Mon Sep 17 00:00:00 2001
From: Andreas Eriksson <4438107+andreas-eriksson@users.noreply.github.com>
Date: Wed, 1 Apr 2026 09:51:36 +0200
Subject: [PATCH 01/12] Fix Identify returning incorrect frame count for
animated PNGs
The Identify method had two bugs when processing fdAT (FrameData) chunks:
1. A spurious Skip(4) before SkipChunkDataAndCrc caused the stream to
be misaligned by 4 bytes, since chunk.Length already includes the
4-byte sequence number.
2. Unlike Decode, which consumes all fdAT chunks for a frame in one shot
via ReadScanlines + ReadNextFrameDataChunk, Identify processed them
individually, calling InitializeFrameMetadata for each chunk and
inflating the frame count.
The fix removes the extra Skip(4) and adds SkipRemainingFrameDataChunks
to consume all continuation fdAT chunks for a frame, mirroring how
ReadNextFrameDataChunk works during decoding.
---
src/ImageSharp/Formats/Png/PngDecoderCore.cs | 30 +++++++++++++++++--
.../Formats/Png/PngDecoderTests.cs | 12 ++++++++
tests/ImageSharp.Tests/TestImages.cs | 1 +
.../animated/issue-animated-frame-count.png | 3 ++
4 files changed, 44 insertions(+), 2 deletions(-)
create mode 100644 tests/Images/Input/Png/animated/issue-animated-frame-count.png
diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs
index 8962182679..52858ec129 100644
--- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs
+++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs
@@ -428,9 +428,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 +2094,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/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
index a58101a6bd..69e656849b 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
@@ -411,6 +411,18 @@ public partial class PngDecoderTests
Assert.Equal(expectedPixelSize, imageInfo.PixelType.BitsPerPixel);
}
+ [Fact]
+ public void Identify_AnimatedPng_ReadsFrameCountCorrectly()
+ {
+ TestFile testFile = TestFile.Create(TestImages.Png.AnimatedFrameCount);
+
+ using MemoryStream stream = new(testFile.Bytes, false);
+ ImageInfo imageInfo = Image.Identify(stream);
+
+ Assert.NotNull(imageInfo);
+ Assert.Equal(50, imageInfo.FrameMetadataCollection.Count);
+ }
+
[Theory]
[WithFile(TestImages.Png.Bad.MissingDataChunk, PixelTypes.Rgba32)]
public void Decode_MissingDataChunk_ThrowsException(TestImageProvider provider)
diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs
index fab1b2891c..730e62d824 100644
--- a/tests/ImageSharp.Tests/TestImages.cs
+++ b/tests/ImageSharp.Tests/TestImages.cs
@@ -76,6 +76,7 @@ public static class TestImages
public const string BlendOverMultiple = "Png/animated/21-blend-over-multiple.png";
public const string FrameOffset = "Png/animated/frame-offset.png";
public const string DefaultNotAnimated = "Png/animated/default-not-animated.png";
+ public const string AnimatedFrameCount = "Png/animated/issue-animated-frame-count.png";
public const string Issue2666 = "Png/issues/Issue_2666.png";
public const string Issue2882 = "Png/issues/Issue_2882.png";
diff --git a/tests/Images/Input/Png/animated/issue-animated-frame-count.png b/tests/Images/Input/Png/animated/issue-animated-frame-count.png
new file mode 100644
index 0000000000..db8ff47b9b
--- /dev/null
+++ b/tests/Images/Input/Png/animated/issue-animated-frame-count.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:62d51679bcb096ae45ae0f5bf874916ad929014f68ae43b487253d5050c8b68b
+size 13561079
From 7b13e1df1b909510ba1303e4b7b25d9d1d8e9df4 Mon Sep 17 00:00:00 2001
From: Andreas Eriksson <4438107+andreas-eriksson@users.noreply.github.com>
Date: Wed, 1 Apr 2026 12:58:13 +0200
Subject: [PATCH 02/12] Add generated animated PNG tests for Identify and
Decode frame counts
---
.../Formats/Png/PngDecoderTests.cs | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
index 69e656849b..0ba8866127 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
@@ -423,6 +423,55 @@ public partial class PngDecoderTests
Assert.Equal(50, imageInfo.FrameMetadataCollection.Count);
}
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Identify_AnimatedPng_FrameCount_MatchesDecode(int frameCount)
+ {
+ using Image image = new(10, 10, Color.Red.ToPixel());
+ for (int i = 1; i < frameCount; i++)
+ {
+ using ImageFrame frame = new(Configuration.Default, 10, 10);
+ image.Frames.AddFrame(frame);
+ }
+
+ using MemoryStream stream = new();
+ image.Save(stream, new PngEncoder());
+ stream.Position = 0;
+
+ ImageInfo imageInfo = Image.Identify(stream);
+
+ Assert.NotNull(imageInfo);
+ Assert.Equal(frameCount, imageInfo.FrameMetadataCollection.Count);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(5)]
+ [InlineData(10)]
+ [InlineData(100)]
+ public void Decode_AnimatedPng_FrameCount(int frameCount)
+ {
+ using Image image = new(10, 10, Color.Red.ToPixel());
+ for (int i = 1; i < frameCount; i++)
+ {
+ using ImageFrame frame = new(Configuration.Default, 10, 10);
+ image.Frames.AddFrame(frame);
+ }
+
+ using MemoryStream stream = new();
+ image.Save(stream, new PngEncoder());
+ stream.Position = 0;
+
+ using Image decoded = Image.Load(stream);
+
+ Assert.Equal(frameCount, decoded.Frames.Count);
+ }
+
[Theory]
[WithFile(TestImages.Png.Bad.MissingDataChunk, PixelTypes.Rgba32)]
public void Decode_MissingDataChunk_ThrowsException(TestImageProvider provider)
From e06a015cf59148e24d1fc940aed3b9e4d8356103 Mon Sep 17 00:00:00 2001
From: James Jackson-South
Date: Tue, 7 Apr 2026 11:39:29 +1000
Subject: [PATCH 03/12] Fix SIMD slicing and padding length handling. Fix #3104
---
.../Common/Helpers/SimdUtils.HwIntrinsics.cs | 14 ++++++++------
.../Encoder/SpectralConverter{TPixel}.cs | 6 +++---
2 files changed, 11 insertions(+), 9 deletions(-)
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/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);
From a76c02f7c095df9b578a8c0336712b148abd92d1 Mon Sep 17 00:00:00 2001
From: Andreas <4438107+andreas-eriksson@users.noreply.github.com>
Date: Tue, 7 Apr 2026 07:46:22 +0200
Subject: [PATCH 04/12] Replace test image with a smaller one.
Adjusted Identify_AnimatedPng_ReadsFrameCountCorrectly to expect 48 frames instead of 50.
---
tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs | 2 +-
.../Images/Input/Png/animated/issue-animated-frame-count.png | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
index 0ba8866127..802f2aba39 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
@@ -420,7 +420,7 @@ public partial class PngDecoderTests
ImageInfo imageInfo = Image.Identify(stream);
Assert.NotNull(imageInfo);
- Assert.Equal(50, imageInfo.FrameMetadataCollection.Count);
+ Assert.Equal(48, imageInfo.FrameMetadataCollection.Count);
}
[Theory]
diff --git a/tests/Images/Input/Png/animated/issue-animated-frame-count.png b/tests/Images/Input/Png/animated/issue-animated-frame-count.png
index db8ff47b9b..88427f4873 100644
--- a/tests/Images/Input/Png/animated/issue-animated-frame-count.png
+++ b/tests/Images/Input/Png/animated/issue-animated-frame-count.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:62d51679bcb096ae45ae0f5bf874916ad929014f68ae43b487253d5050c8b68b
-size 13561079
+oid sha256:af4e320f586ab26c55612a7ccfc98a8c99cd6a0efe8a70d379503751d06fe8bd
+size 51542
From 9569449ac46e844c33c7b4073c8773d9f3d87134 Mon Sep 17 00:00:00 2001
From: Andreas <4438107+andreas-eriksson@users.noreply.github.com>
Date: Tue, 7 Apr 2026 08:19:40 +0200
Subject: [PATCH 05/12] Fix MaxFrames handling in PNG decoder
- Change >= to > for correct MaxFrames boundary
- Skip fdAT chunk data when hitting maxFrames in Identify to maintain stream alignment
- Add tests for Identify and Load with MaxFrames
---
src/ImageSharp/Formats/Png/PngDecoderCore.cs | 12 ++++++----
.../Formats/Png/PngDecoderTests.cs | 24 +++++++++++++++++++
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs
index 52858ec129..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;
}
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
index 802f2aba39..4712fc0dd5 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
@@ -423,6 +423,30 @@ public partial class PngDecoderTests
Assert.Equal(48, imageInfo.FrameMetadataCollection.Count);
}
+ [Fact]
+ public void Identify_AnimatedPngWithMaxFrames_ReadsFrameCountCorrectly()
+ {
+ TestFile testFile = TestFile.Create(TestImages.Png.AnimatedFrameCount);
+
+ using MemoryStream stream = new(testFile.Bytes, false);
+ ImageInfo imageInfo = Image.Identify(new DecoderOptions { MaxFrames = 40 }, stream);
+
+ Assert.NotNull(imageInfo);
+ Assert.Equal(40, imageInfo.FrameMetadataCollection.Count);
+ }
+
+ [Fact]
+ public void Load_AnimatedPngWithMaxFrames_ReadsFrameCountCorrectly()
+ {
+ TestFile testFile = TestFile.Create(TestImages.Png.AnimatedFrameCount);
+
+ using MemoryStream stream = new(testFile.Bytes, false);
+ using Image image = Image.Load(new DecoderOptions { MaxFrames = 40 }, stream);
+
+ Assert.NotNull(image);
+ Assert.Equal(40, image.Frames.Count);
+ }
+
[Theory]
[InlineData(1)]
[InlineData(2)]
From 90f0c0b5d47bc3d68cc4ad69222658f497e29516 Mon Sep 17 00:00:00 2001
From: James Jackson-South
Date: Tue, 7 Apr 2026 21:32:38 +1000
Subject: [PATCH 06/12] Update and simplify quantization color caches.
---
src/ImageSharp/Advanced/AotCompilerTools.cs | 2 -
.../Quantization/ColorMatchingMode.cs | 10 +-
.../EuclideanPixelMap{TPixel,TCache}.cs | 116 +++++-
.../Quantization/IColorIndexCache.cs | 387 +++++-------------
.../Quantization/OctreeQuantizer{TPixel}.cs | 2 +-
.../Processing/ColorMatchingCaches.cs | 302 ++++++++++++++
.../Formats/Png/PngEncoderTests.cs | 2 +-
.../Quantization/PaletteQuantizerTests.cs | 158 +++++++
8 files changed, 667 insertions(+), 312 deletions(-)
create mode 100644 tests/ImageSharp.Benchmarks/Processing/ColorMatchingCaches.cs
diff --git a/src/ImageSharp/Advanced/AotCompilerTools.cs b/src/ImageSharp/Advanced/AotCompilerTools.cs
index fef49bffd4..2944b58e5f 100644
--- a/src/ImageSharp/Advanced/AotCompilerTools.cs
+++ b/src/ImageSharp/Advanced/AotCompilerTools.cs
@@ -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 _);
}
///
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/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.
- }
-}
-
-///
-/// Represents a cache that does not store any values.
-/// It allows adding colors, but always returns false when trying to retrieve them.
-///
-internal readonly struct NullCache : IColorIndexCache
-{
- ///
- public static NullCache Create(MemoryAllocator allocator) => default;
-
- ///
- public bool TryAdd(Rgba32 color, short value) => true;
-
- ///
- public bool TryGetValue(Rgba32 color, out short value)
- {
- value = -1;
- return false;
- }
-
- ///
- public void Clear()
- {
- }
-
- ///
- public void Dispose()
- {
- }
+ ///
+ /// Maps a packed RGBA key to one of the cache sets used by .
+ ///
+ /// The packed key.
+ /// The zero-based set index for the key.
+ ///
+ ///
+ /// The cache is 4-way set-associative, so this hash only needs to choose one of
+ /// sets before probing up to four candidate entries.
+ ///
+ ///
+ /// is laid out as R | (G << 8) | (B << 16) | (A << 24).
+ /// The XOR-fold mixes neighboring bytes into the low bits, and the final mask selects the
+ /// set. With the current 128-set layout that makes the selected set effectively depend on
+ /// the low 7 bits of R ^ G ^ B. Alpha still participates in the later exact key
+ /// comparison, but not in set selection.
+ ///
+ ///
+ /// Collisions are expected and acceptable here. Correctness comes from the full packed-key
+ /// comparison during probing; this hash only aims to spread keys cheaply enough that each
+ /// access touches at most one 4-entry set.
+ ///
+ ///
+ [MethodImpl(InliningOptions.ShortMethod)]
+ private static int GetSetIndex(uint key)
+ => (int)(((key >> 16) ^ (key >> 8) ^ key) & SetMask);
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
index 07596b68a8..bdf2ba20a8 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
@@ -368,7 +368,7 @@ public struct OctreeQuantizer : IQuantizer
public void Dispose() => this.nodesOwner.Dispose();
[StructLayout(LayoutKind.Sequential)]
- internal unsafe struct OctreeNode
+ internal struct OctreeNode
{
public bool Leaf;
public int PixelCount;
diff --git a/tests/ImageSharp.Benchmarks/Processing/ColorMatchingCaches.cs b/tests/ImageSharp.Benchmarks/Processing/ColorMatchingCaches.cs
new file mode 100644
index 0000000000..dbaf21a8ef
--- /dev/null
+++ b/tests/ImageSharp.Benchmarks/Processing/ColorMatchingCaches.cs
@@ -0,0 +1,302 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using BenchmarkDotNet.Attributes;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+namespace SixLabors.ImageSharp.Benchmarks.Processing;
+
+[Config(typeof(Config.Standard))]
+public class ColorMatchingCaches
+{
+ // IterationSetup forces BenchmarkDotNet to use a single benchmark invocation per iteration.
+ // Repeated lookups can safely replay a smaller working set because that workload is explicitly
+ // meant to model steady-state cache hits after warmup.
+ private const int RepeatedLookupCount = 262_144;
+
+ // DitherLike should avoid replaying the same stream across multiple passes because that warms
+ // the caches in a way real high-churn input usually does not. Make the single pass larger instead.
+ private const int DitherLikeLookupCount = 1_048_576;
+ private const int RepeatedPassCount = 16;
+
+ private Rgba32[] palette;
+ private Rgba32[] repeatedSeedColors;
+ private Rgba32[] repeatedLookups;
+ private Rgba32[] ditherLookups;
+
+ private PixelMap coarse;
+ private PixelMap legacyCoarse;
+ private PixelMap exact;
+ private PixelMap uncached;
+
+ [Params(16, 256)]
+ public int PaletteSize { get; set; }
+
+ [Params(CacheWorkload.Repeated, CacheWorkload.DitherLike)]
+ public CacheWorkload Workload { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ this.palette = CreatePalette(this.PaletteSize);
+ this.repeatedSeedColors = CreateRepeatedSeedColors(this.palette);
+ this.repeatedLookups = CreateRepeatedLookups(this.repeatedSeedColors);
+ this.ditherLookups = CreateDitherLikeLookups();
+
+ this.coarse = CreatePixelMap(this.palette);
+ this.legacyCoarse = CreatePixelMap(this.palette);
+ this.exact = CreatePixelMap(this.palette);
+ this.uncached = CreatePixelMap(this.palette);
+ }
+
+ [IterationSetup]
+ public void ResetCaches()
+ {
+ // Each benchmark iteration should start from the same cache state so we measure
+ // the cache policy itself rather than warm state carried over from a previous iteration.
+ this.coarse.Clear(this.palette);
+ this.legacyCoarse.Clear(this.palette);
+ this.exact.Clear(this.palette);
+ this.uncached.Clear(this.palette);
+
+ if (this.Workload == CacheWorkload.Repeated)
+ {
+ // Prime the repeated workload so the benchmark reflects steady-state hit behavior
+ // instead of mostly measuring the first-wave fill cost.
+ Prime(this.coarse, this.repeatedSeedColors);
+ Prime(this.legacyCoarse, this.repeatedSeedColors);
+ Prime(this.exact, this.repeatedSeedColors);
+ Prime(this.uncached, this.repeatedSeedColors);
+ }
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ this.coarse.Dispose();
+ this.legacyCoarse.Dispose();
+ this.exact.Dispose();
+ this.uncached.Dispose();
+ }
+
+ [Benchmark(Baseline = true, Description = "Coarse")]
+ public int Coarse() => this.Run(this.coarse);
+
+ [Benchmark(Description = "Legacy Coarse")]
+ public int LegacyCoarse() => this.Run(this.legacyCoarse);
+
+ [Benchmark(Description = "Exact Cached")]
+ public int Exact() => this.Run(this.exact);
+
+ [Benchmark(Description = "Exact Uncached")]
+ public int Uncached() => this.Run(this.uncached);
+
+ public enum CacheWorkload
+ {
+ // A small working set that is intentionally reused after priming to measure hit-heavy behavior.
+ Repeated,
+
+ // A deterministic high-churn stream intended to resemble dithered lookups where exact repeats are rare.
+ DitherLike
+ }
+
+ private int Run(PixelMap map)
+ {
+ Rgba32[] lookups = this.Workload == CacheWorkload.Repeated ? this.repeatedLookups : this.ditherLookups;
+ int passCount = this.Workload == CacheWorkload.Repeated ? RepeatedPassCount : 1;
+ int checksum = 0;
+
+ // Repeated intentionally replays the same lookup stream to measure steady-state hit behavior.
+ // DitherLike runs as a single larger pass so we do not turn a churn-heavy workload into an
+ // artificially warmed cache benchmark by replaying the exact same sequence.
+ for (int pass = 0; pass < passCount; pass++)
+ {
+ for (int i = 0; i < lookups.Length; i++)
+ {
+ checksum = unchecked((checksum * 31) + map.GetClosestColor(lookups[i], out _));
+ }
+ }
+
+ return checksum;
+ }
+
+ private static PixelMap CreatePixelMap(Rgba32[] palette)
+ where TCache : struct, IColorIndexCache
+ => new EuclideanPixelMap(Configuration.Default, palette);
+
+ private static void Prime(PixelMap map, Rgba32[] colors)
+ {
+ for (int i = 0; i < colors.Length; i++)
+ {
+ map.GetClosestColor(colors[i], out _);
+ }
+ }
+
+ private static Rgba32[] CreatePalette(int count)
+ {
+ Rgba32[] result = new Rgba32[count];
+
+ for (int i = 0; i < result.Length; i++)
+ {
+ // Use the Knuth/golden-ratio multiplicative hash constant to spread colors across
+ // RGBA space without clustering into a gradient. That keeps the benchmark from
+ // accidentally favoring any cache because the palette itself is too regular.
+ uint value = unchecked((uint)(i + 1) * 2654435761U);
+ result[i] = new(
+ (byte)value,
+ (byte)(value >> 8),
+ (byte)(value >> 16),
+ (byte)((value >> 24) | 0x80));
+ }
+
+ return result;
+ }
+
+ private static Rgba32[] CreateRepeatedSeedColors(Rgba32[] palette)
+ {
+ // Reuse colors derived from the palette but perturb them slightly so the workload still
+ // exercises nearest-color matching rather than only exact palette-entry hits.
+ int count = Math.Min(64, palette.Length * 2);
+ Rgba32[] result = new Rgba32[count];
+
+ for (int i = 0; i < result.Length; i++)
+ {
+ Rgba32 source = palette[(i * 17) % palette.Length];
+ result[i] = new(
+ (byte)(source.R + ((i * 3) & 0x07)),
+ (byte)(source.G + ((i * 5) & 0x07)),
+ (byte)(source.B + ((i * 7) & 0x07)),
+ source.A);
+ }
+
+ return result;
+ }
+
+ private static Rgba32[] CreateRepeatedLookups(Rgba32[] seedColors)
+ {
+ Rgba32[] result = new Rgba32[RepeatedLookupCount];
+
+ // Cycle a small seed set to produce a stable, hit-heavy stream after priming.
+ for (int i = 0; i < result.Length; i++)
+ {
+ result[i] = seedColors[i % seedColors.Length];
+ }
+
+ return result;
+ }
+
+ private static Rgba32[] CreateDitherLikeLookups()
+ {
+ Rgba32[] result = new Rgba32[DitherLikeLookupCount];
+
+ // Generate a deterministic pseudo-image signal with independent channel slopes so nearby
+ // samples are correlated but exact repeats are uncommon, which is closer to dithered input.
+ for (int i = 0; i < result.Length; i++)
+ {
+ int x = i & 511;
+ int y = i >> 9;
+
+ result[i] = new(
+ (byte)((x * 17) + (y * 13)),
+ (byte)((x * 29) + (y * 7)),
+ (byte)((x * 11) + (y * 23)),
+ (byte)(255 - ((x * 3) + (y * 5))));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Preserves the original direct-mapped coarse cache implementation for side-by-side benchmarks.
+ ///
+ private unsafe struct LegacyCoarseCache : 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;
+ private const int IndexGCount = 1 << IndexGBits;
+ private const int IndexBCount = 1 << IndexBBits;
+ private const int IndexACount = 1 << IndexABits;
+ private const int TotalBins = IndexRCount * IndexGCount * IndexBCount * IndexACount;
+
+ private readonly IMemoryOwner binsOwner;
+ private readonly short* binsPointer;
+ private MemoryHandle binsHandle;
+
+ private LegacyCoarseCache(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 LegacyCoarseCache 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;
+ }
+
+ public readonly void Clear() => this.binsOwner.GetSpan().Fill(-1);
+
+ public void Dispose()
+ {
+ this.binsHandle.Dispose();
+ this.binsOwner.Dispose();
+ }
+
+ [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;
+ }
+ }
+
+ ///
+ /// Preserves the uncached path for exact-cache comparison benchmarks.
+ ///
+ private readonly struct UncachedCache : IColorIndexCache
+ {
+ public static UncachedCache Create(MemoryAllocator allocator) => default;
+
+ public bool TryAdd(Rgba32 color, short value) => true;
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ value = -1;
+ return false;
+ }
+
+ public void Clear()
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
index 4ebcbc13b6..eef8d5ba84 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
@@ -680,7 +680,7 @@ public partial class PngEncoderTests
PaletteQuantizer quantizer = new(
palette.Select(Color.FromPixel).ToArray(),
- new QuantizerOptions { ColorMatchingMode = ColorMatchingMode.Hybrid });
+ new QuantizerOptions { ColorMatchingMode = ColorMatchingMode.Exact });
using MemoryStream ms = new();
image.Save(ms, new PngEncoder
diff --git a/tests/ImageSharp.Tests/Processing/Processors/Quantization/PaletteQuantizerTests.cs b/tests/ImageSharp.Tests/Processing/Processors/Quantization/PaletteQuantizerTests.cs
index f2a4b079b5..07e9a4b0d6 100644
--- a/tests/ImageSharp.Tests/Processing/Processors/Quantization/PaletteQuantizerTests.cs
+++ b/tests/ImageSharp.Tests/Processing/Processors/Quantization/PaletteQuantizerTests.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
@@ -75,4 +76,161 @@ public class PaletteQuantizerTests
IQuantizer quantizer = KnownQuantizers.Werner;
Assert.Equal(QuantizerConstants.DefaultDither, quantizer.Options.Dither);
}
+
+ [Fact]
+ public void ExactColorMatchingMatchesUncachedAfterCacheOverflow()
+ {
+ Rgba32[] palette =
+ [
+ new Rgba32(0, 0, 0),
+ new Rgba32(7, 0, 0)
+ ];
+
+ using PixelMap exact = CreatePixelMap(palette);
+ using PixelMap cachedExact = CreatePixelMap(palette);
+
+ for (int i = 0; i < AccurateCache.Capacity; i++)
+ {
+ cachedExact.GetClosestColor(CreateOverflowFillerColor(i), out _);
+ }
+
+ Rgba32 first = new(1, 0, 0);
+ Rgba32 second = new(6, 0, 0);
+
+ AssertMatchesUncached(exact, cachedExact, first);
+ AssertMatchesUncached(exact, cachedExact, second);
+ }
+
+ [Fact]
+ public void ExactColorMatchingMatchesUncachedAcrossManyProbeBinsAfterRepeatedEviction()
+ {
+ Rgba32[] palette = CreateGrayscalePalette(256);
+
+ using PixelMap exact = CreatePixelMap(palette);
+ using PixelMap cachedExact = CreatePixelMap(palette);
+
+ for (int i = 0; i < AccurateCache.Capacity * 2; i++)
+ {
+ cachedExact.GetClosestColor(CreateEvictionFillerColor(i), out _);
+ }
+
+ for (int i = 0; i < AccurateCache.Capacity; i++)
+ {
+ AssertMatchesUncached(exact, cachedExact, CreateEvictionProbeColor(i));
+ }
+ }
+
+ [Fact]
+ public void ExactColorMatchingMatchesUncachedForDitherStressColorSequence()
+ {
+ Rgba32[] palette = CreateGrayscalePalette(16);
+
+ using Image source = CreateDitherStressImage();
+ using PixelMap exact = CreatePixelMap(palette);
+ using PixelMap cachedExact = CreatePixelMap(palette);
+
+ for (int y = 0; y < source.Height; y++)
+ {
+ for (int x = 0; x < source.Width; x++)
+ {
+ AssertMatchesUncached(exact, cachedExact, source[x, y]);
+ }
+ }
+ }
+
+ // Split the first 512 integers across R and G so the warmup loop produces 512 distinct exact colors:
+ // the low 8 bits go into R, and the ninth bit spills into G once R wraps after 255.
+ // Keeping B fixed and G offset away from zero also avoids accidentally probing the red-axis test colors below.
+ private static Rgba32 CreateOverflowFillerColor(int i)
+ => new((byte)i, (byte)(16 + (i >> 8)), 32);
+
+ // Treat i as three packed 5-bit coordinates and expand each coordinate back to an 8-bit channel by
+ // shifting left by 3. That lands on the lower edge of each 5-bit coarse bucket, giving the test a
+ // deterministic way to fill many distinct coarse buckets before probing nearby exact colors.
+ private static Rgba32 CreateEvictionFillerColor(int i)
+ {
+ byte r = (byte)((i & 31) << 3);
+ byte g = (byte)(((i >> 5) & 31) << 3);
+ byte b = (byte)(((i >> 10) & 31) << 3);
+ return new(r, g, b);
+ }
+
+ // Reconstruct the same 5-bit RGB bucket coordinates used by CreateEvictionFillerColor, then set the
+ // low 3 bits in each channel to 0b111. That keeps the probe inside the same coarse bucket while making
+ // it a different exact color, which is the shape that used to expose coarse-fallback false hits.
+ private static Rgba32 CreateEvictionProbeColor(int i)
+ {
+ byte r = (byte)(((i & 31) << 3) | 0x07);
+ byte g = (byte)((((i >> 5) & 31) << 3) | 0x07);
+ byte b = (byte)((((i >> 10) & 31) << 3) | 0x07);
+ return new(r, g, b);
+ }
+
+ private static PixelMap CreatePixelMap(Rgba32[] palette)
+ where TCache : struct, IColorIndexCache
+ => new EuclideanPixelMap(Configuration.Default, palette);
+
+ private static void AssertMatchesUncached(PixelMap exact, PixelMap cachedExact, Rgba32 color)
+ {
+ int exactIndex = exact.GetClosestColor(color, out Rgba32 exactMatch);
+ int cachedIndex = cachedExact.GetClosestColor(color, out Rgba32 cachedMatch);
+
+ Assert.Equal(exactIndex, cachedIndex);
+ Assert.Equal(exactMatch, cachedMatch);
+ }
+
+ private static Rgba32[] CreateGrayscalePalette(int count)
+ {
+ Rgba32[] palette = new Rgba32[count];
+ for (int i = 0; i < count; i++)
+ {
+ byte value = count == 1 ? (byte)0 : (byte)((i * 255) / (count - 1));
+ palette[i] = new Rgba32(value, value, value);
+ }
+
+ return palette;
+ }
+
+ // Generate a deterministic pseudo-image where each channel uses a different x/y slope.
+ // Neighboring pixels stay correlated, like real image content, but the combined RGB values
+ // churn heavily enough that exact repeats are rare. That makes this a useful stress input
+ // for verifying cached exact matching against an uncached baseline under dither-like access.
+ private static Image CreateDitherStressImage()
+ {
+ Image image = new(192, 96);
+
+ for (int y = 0; y < image.Height; y++)
+ {
+ for (int x = 0; x < image.Width; x++)
+ {
+ image[x, y] = new Rgba32(
+ (byte)((x * 17) + (y * 13)),
+ (byte)((x * 29) + (y * 7)),
+ (byte)((x * 11) + (y * 23)));
+ }
+ }
+
+ return image;
+ }
+
+ private readonly struct UncachedCache : IColorIndexCache
+ {
+ public static UncachedCache Create(MemoryAllocator allocator) => default;
+
+ public bool TryAdd(Rgba32 color, short value) => true;
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ value = -1;
+ return false;
+ }
+
+ public void Clear()
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+ }
}
From f0ce591a64381458cb276231a6c30c400fafe658 Mon Sep 17 00:00:00 2001
From: James Jackson-South
Date: Tue, 7 Apr 2026 22:14:58 +1000
Subject: [PATCH 07/12] Rename quantizer and update tests
---
src/ImageSharp/Advanced/AotCompilerTools.cs | 8 +-
src/ImageSharp/Formats/Bmp/BmpEncoder.cs | 2 +-
src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs | 2 +-
src/ImageSharp/Formats/Gif/GifEncoderCore.cs | 6 +-
src/ImageSharp/Formats/Tiff/TiffEncoder.cs | 2 +-
.../Formats/Tiff/TiffEncoderCore.cs | 2 +-
.../Quantization/QuantizeExtensions.cs | 8 +-
src/ImageSharp/Processing/KnownQuantizers.cs | 8 +-
...eQuantizer.cs => HexadecatreeQuantizer.cs} | 22 +-
...l}.cs => HexadecatreeQuantizer{TPixel}.cs} | 354 ++++++++++--------
.../Codecs/Png/EncodeIndexedPng.cs | 12 +-
.../Formats/Bmp/BmpEncoderTests.cs | 8 +-
.../Formats/GeneralFormatTests.cs | 2 +-
.../Formats/Gif/GifEncoderTests.cs | 6 +-
.../Formats/WebP/WebpEncoderTests.cs | 10 +-
...Tests.cs => HexadecatreeQuantizerTests.cs} | 20 +-
.../Processors/Quantization/QuantizerTests.cs | 26 +-
.../Quantization/QuantizedImageTests.cs | 14 +-
...tColor_WithHexadecatreeQuantizer_rgb32.bmp | 3 +
...zed_Encode_Artifacts_Rgba32_issue_2469.png | 4 +-
...Bike_HexadecatreeQuantizer_ErrorDither.png | 3 +
...ox_Bike_HexadecatreeQuantizer_NoDither.png | 3 +
...e_HexadecatreeQuantizer_OrderedDither.png} | 0
...InBox_Bike_OctreeQuantizer_ErrorDither.png | 3 -
...ionInBox_Bike_OctreeQuantizer_NoDither.png | 3 -
...ke_WebSafePaletteQuantizer_ErrorDither.png | 4 +-
..._Bike_WebSafePaletteQuantizer_NoDither.png | 4 +-
..._WebSafePaletteQuantizer_OrderedDither.png | 4 +-
...ike_WernerPaletteQuantizer_ErrorDither.png | 4 +-
...x_Bike_WernerPaletteQuantizer_NoDither.png | 4 +-
...e_WernerPaletteQuantizer_OrderedDither.png | 4 +-
...tionInBox_Bike_WuQuantizer_ErrorDither.png | 4 +-
...izationInBox_Bike_WuQuantizer_NoDither.png | 4 +-
...tial_HexadecatreeQuantizer_ErrorDither.png | 3 +
...Partial_HexadecatreeQuantizer_NoDither.png | 3 +
...al_HexadecatreeQuantizer_OrderedDither.png | 3 +
...oraPartial_OctreeQuantizer_ErrorDither.png | 3 -
...iphoraPartial_OctreeQuantizer_NoDither.png | 3 -
...aPartial_OctreeQuantizer_OrderedDither.png | 3 -
...al_WebSafePaletteQuantizer_ErrorDither.png | 4 +-
...rtial_WebSafePaletteQuantizer_NoDither.png | 4 +-
..._WebSafePaletteQuantizer_OrderedDither.png | 4 +-
...ial_WernerPaletteQuantizer_ErrorDither.png | 4 +-
...artial_WernerPaletteQuantizer_NoDither.png | 4 +-
...l_WernerPaletteQuantizer_OrderedDither.png | 4 +-
...liphoraPartial_WuQuantizer_ErrorDither.png | 4 +-
...CalliphoraPartial_WuQuantizer_NoDither.png | 4 +-
...phoraPartial_WuQuantizer_OrderedDither.png | 4 +-
...HexadecatreeQuantizer_ErrorDither_0.25.png | 3 +
..._HexadecatreeQuantizer_ErrorDither_0.5.png | 3 +
...HexadecatreeQuantizer_ErrorDither_0.75.png | 3 +
...id_HexadecatreeQuantizer_ErrorDither_0.png | 3 +
...id_HexadecatreeQuantizer_ErrorDither_1.png | 3 +
...adecatreeQuantizer_OrderedDither_0.25.png} | 0
...xadecatreeQuantizer_OrderedDither_0.5.png} | 0
...adecatreeQuantizer_OrderedDither_0.75.png} | 0
..._HexadecatreeQuantizer_OrderedDither_0.png | 3 +
...HexadecatreeQuantizer_OrderedDither_1.png} | 0
...david_OctreeQuantizer_ErrorDither_0.25.png | 3 -
..._david_OctreeQuantizer_ErrorDither_0.5.png | 3 -
...david_OctreeQuantizer_ErrorDither_0.75.png | 3 -
...le_david_OctreeQuantizer_ErrorDither_0.png | 3 -
...le_david_OctreeQuantizer_ErrorDither_1.png | 3 -
..._david_OctreeQuantizer_OrderedDither_0.png | 3 -
...WernerPaletteQuantizer_OrderedDither_1.png | 4 +-
...ale_david_WuQuantizer_ErrorDither_0.25.png | 4 +-
...cale_david_WuQuantizer_ErrorDither_0.5.png | 4 +-
...ale_david_WuQuantizer_ErrorDither_0.75.png | 4 +-
...gScale_david_WuQuantizer_ErrorDither_0.png | 4 +-
...gScale_david_WuQuantizer_ErrorDither_1.png | 4 +-
...e_david_WuQuantizer_OrderedDither_0.25.png | 4 +-
...e_david_WuQuantizer_OrderedDither_0.75.png | 4 +-
...cale_david_WuQuantizer_OrderedDither_0.png | 4 +-
...cale_david_WuQuantizer_OrderedDither_1.png | 4 +-
...ike_HexadecatreeQuantizer_ErrorDither.png} | 0
...n_Bike_HexadecatreeQuantizer_NoDither.png} | 0
...e_HexadecatreeQuantizer_OrderedDither.png} | 0
...e_WernerPaletteQuantizer_OrderedDither.png | 4 +-
...ntization_Bike_WuQuantizer_ErrorDither.png | 4 +-
...Quantization_Bike_WuQuantizer_NoDither.png | 4 +-
...ization_Bike_WuQuantizer_OrderedDither.png | 4 +-
...tial_HexadecatreeQuantizer_ErrorDither.png | 3 +
...Partial_HexadecatreeQuantizer_NoDither.png | 3 +
...l_HexadecatreeQuantizer_OrderedDither.png} | 0
...oraPartial_OctreeQuantizer_ErrorDither.png | 3 -
...iphoraPartial_OctreeQuantizer_NoDither.png | 3 -
...l_WernerPaletteQuantizer_OrderedDither.png | 4 +-
...liphoraPartial_WuQuantizer_ErrorDither.png | 4 +-
...CalliphoraPartial_WuQuantizer_NoDither.png | 4 +-
...phoraPartial_WuQuantizer_OrderedDither.png | 4 +-
90 files changed, 383 insertions(+), 354 deletions(-)
rename src/ImageSharp/Processing/Processors/Quantization/{OctreeQuantizer.cs => HexadecatreeQuantizer.cs} (51%)
rename src/ImageSharp/Processing/Processors/Quantization/{OctreeQuantizer{TPixel}.cs => HexadecatreeQuantizer{TPixel}.cs} (54%)
rename tests/ImageSharp.Tests/Processing/Processors/Quantization/{OctreeQuantizerTests.cs => HexadecatreeQuantizerTests.cs} (76%)
create mode 100644 tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithHexadecatreeQuantizer_rgb32.bmp
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_Bike_HexadecatreeQuantizer_ErrorDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_Bike_HexadecatreeQuantizer_NoDither.png
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantizationInBox_Bike_OctreeQuantizer_OrderedDither.png => ApplyQuantizationInBox_Bike_HexadecatreeQuantizer_OrderedDither.png} (100%)
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_Bike_OctreeQuantizer_ErrorDither.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_Bike_OctreeQuantizer_NoDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_HexadecatreeQuantizer_ErrorDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_HexadecatreeQuantizer_NoDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_HexadecatreeQuantizer_OrderedDither.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_OctreeQuantizer_ErrorDither.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_OctreeQuantizer_NoDither.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationInBox_CalliphoraPartial_OctreeQuantizer_OrderedDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_ErrorDither_0.25.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_ErrorDither_0.5.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_ErrorDither_0.75.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_ErrorDither_0.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_ErrorDither_1.png
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_OrderedDither_0.25.png => ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_OrderedDither_0.25.png} (100%)
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_OrderedDither_0.5.png => ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_OrderedDither_0.5.png} (100%)
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_OrderedDither_0.75.png => ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_OrderedDither_0.75.png} (100%)
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_OrderedDither_0.png
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_OrderedDither_1.png => ApplyQuantizationWithDitheringScale_david_HexadecatreeQuantizer_OrderedDither_1.png} (100%)
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_ErrorDither_0.25.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_ErrorDither_0.5.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_ErrorDither_0.75.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_ErrorDither_0.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_ErrorDither_1.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantizationWithDitheringScale_david_OctreeQuantizer_OrderedDither_0.png
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantization_Bike_OctreeQuantizer_ErrorDither.png => ApplyQuantization_Bike_HexadecatreeQuantizer_ErrorDither.png} (100%)
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantization_Bike_OctreeQuantizer_NoDither.png => ApplyQuantization_Bike_HexadecatreeQuantizer_NoDither.png} (100%)
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantization_Bike_OctreeQuantizer_OrderedDither.png => ApplyQuantization_Bike_HexadecatreeQuantizer_OrderedDither.png} (100%)
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_CalliphoraPartial_HexadecatreeQuantizer_ErrorDither.png
create mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_CalliphoraPartial_HexadecatreeQuantizer_NoDither.png
rename tests/Images/External/ReferenceOutput/QuantizerTests/{ApplyQuantization_CalliphoraPartial_OctreeQuantizer_OrderedDither.png => ApplyQuantization_CalliphoraPartial_HexadecatreeQuantizer_OrderedDither.png} (100%)
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_CalliphoraPartial_OctreeQuantizer_ErrorDither.png
delete mode 100644 tests/Images/External/ReferenceOutput/QuantizerTests/ApplyQuantization_CalliphoraPartial_OctreeQuantizer_NoDither.png
diff --git a/src/ImageSharp/Advanced/AotCompilerTools.cs b/src/ImageSharp/Advanced/AotCompilerTools.cs
index 2944b58e5f..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();
@@ -549,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/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/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/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/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/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 bdf2ba20a8..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 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/tests/ImageSharp.Benchmarks/Codecs/Png/EncodeIndexedPng.cs b/tests/ImageSharp.Benchmarks/Codecs/Png/EncodeIndexedPng.cs
index 125b42680d..69779731be 100644
--- a/tests/ImageSharp.Benchmarks/Codecs/Png/EncodeIndexedPng.cs
+++ b/tests/ImageSharp.Benchmarks/Codecs/Png/EncodeIndexedPng.cs
@@ -40,19 +40,19 @@ public class EncodeIndexedPng
this.bmpCore.Dispose();
}
- [Benchmark(Baseline = true, Description = "ImageSharp Octree Png")]
- public void PngCoreOctree()
+ [Benchmark(Baseline = true, Description = "ImageSharp Hexadecatree Png")]
+ public void PngCoreHexadecatree()
{
using MemoryStream memoryStream = new();
- PngEncoder options = new() { Quantizer = KnownQuantizers.Octree };
+ PngEncoder options = new() { Quantizer = KnownQuantizers.Hexadecatree };
this.bmpCore.SaveAsPng(memoryStream, options);
}
- [Benchmark(Description = "ImageSharp Octree NoDither Png")]
- public void PngCoreOctreeNoDither()
+ [Benchmark(Description = "ImageSharp Hexadecatree NoDither Png")]
+ public void PngCoreHexadecatreeNoDither()
{
using MemoryStream memoryStream = new();
- PngEncoder options = new() { Quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null }) };
+ PngEncoder options = new() { Quantizer = new HexadecatreeQuantizer(new QuantizerOptions { Dither = null }) };
this.bmpCore.SaveAsPng(memoryStream, options);
}
diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpEncoderTests.cs
index 5ebcc8bb96..6bd7e0103f 100644
--- a/tests/ImageSharp.Tests/Formats/Bmp/BmpEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpEncoderTests.cs
@@ -292,7 +292,7 @@ public class BmpEncoderTests
[Theory]
[WithFile(Bit32Rgb, PixelTypes.Rgba32)]
- public void Encode_8BitColor_WithOctreeQuantizer(TestImageProvider provider)
+ public void Encode_8BitColor_WithHexadecatreeQuantizer(TestImageProvider provider)
where TPixel : unmanaged, IPixel
{
if (!TestEnvironment.Is64BitProcess)
@@ -304,7 +304,7 @@ public class BmpEncoderTests
BmpEncoder encoder = new()
{
BitsPerPixel = BmpBitsPerPixel.Bit8,
- Quantizer = new OctreeQuantizer()
+ Quantizer = new HexadecatreeQuantizer()
};
string actualOutputFile = provider.Utility.SaveTestOutputFile(image, "bmp", encoder, appendPixelTypeToFileName: false);
@@ -385,7 +385,7 @@ public class BmpEncoderTests
{
BitsPerPixel = bitsPerPixel,
SupportTransparency = false,
- Quantizer = KnownQuantizers.Octree
+ Quantizer = KnownQuantizers.Hexadecatree
};
image.SaveAsBmp(reencodedStream, encoder);
reencodedStream.Seek(0, SeekOrigin.Begin);
@@ -478,7 +478,7 @@ public class BmpEncoderTests
{
BitsPerPixel = bitsPerPixel,
SupportTransparency = supportTransparency,
- Quantizer = quantizer ?? KnownQuantizers.Octree
+ Quantizer = quantizer ?? KnownQuantizers.Hexadecatree
};
// Does DebugSave & load reference CompareToReferenceInput():
diff --git a/tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs b/tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs
index 072b04fa0d..2b91c4dbfa 100644
--- a/tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs
+++ b/tests/ImageSharp.Tests/Formats/GeneralFormatTests.cs
@@ -125,7 +125,7 @@ public class GeneralFormatTests
public static readonly TheoryData QuantizerNames =
new()
{
- nameof(KnownQuantizers.Octree),
+ nameof(KnownQuantizers.Hexadecatree),
nameof(KnownQuantizers.WebSafe),
nameof(KnownQuantizers.Werner),
nameof(KnownQuantizers.Wu)
diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
index 370106ca30..b7bbe4971a 100644
--- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
@@ -115,7 +115,7 @@ public class GifEncoderTests
GifEncoder encoder = new()
{
ColorTableMode = FrameColorTableMode.Global,
- Quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null })
+ Quantizer = new HexadecatreeQuantizer(new QuantizerOptions { Dither = null })
};
// Always save as we need to compare the encoded output.
@@ -124,7 +124,7 @@ public class GifEncoderTests
encoder = new GifEncoder
{
ColorTableMode = FrameColorTableMode.Local,
- Quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null }),
+ Quantizer = new HexadecatreeQuantizer(new QuantizerOptions { Dither = null }),
};
provider.Utility.SaveTestOutputFile(image, "gif", encoder, "local");
@@ -191,7 +191,7 @@ public class GifEncoderTests
GifEncoder encoder = new()
{
ColorTableMode = colorMode,
- Quantizer = new OctreeQuantizer(new QuantizerOptions { MaxColors = maxColors })
+ Quantizer = new HexadecatreeQuantizer(new QuantizerOptions { MaxColors = maxColors })
};
image.Save(outStream, encoder);
diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs
index f9836ffb13..ee82687167 100644
--- a/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs
@@ -135,9 +135,9 @@ public class WebpEncoderTests
// Alpha thresholding is 64/255F.
GifEncoder gifEncoder = new()
{
- Quantizer = new OctreeQuantizer(options)
+ Quantizer = new HexadecatreeQuantizer(options)
};
- provider.Utility.SaveTestOutputFile(image, "gif", gifEncoder, "octree");
+ provider.Utility.SaveTestOutputFile(image, "gif", gifEncoder, "hexadecatree");
gifEncoder = new GifEncoder
{
@@ -152,8 +152,8 @@ public class WebpEncoderTests
};
using Image cloned1 = image.Clone();
- cloned1.Mutate(c => c.Quantize(new OctreeQuantizer(options)));
- provider.Utility.SaveTestOutputFile(cloned1, "webp", encoder, "octree");
+ cloned1.Mutate(c => c.Quantize(new HexadecatreeQuantizer(options)));
+ provider.Utility.SaveTestOutputFile(cloned1, "webp", encoder, "hexadecatree");
using Image cloned2 = image.Clone();
cloned2.Mutate(c => c.Quantize(new WuQuantizer(options)));
@@ -162,7 +162,7 @@ public class WebpEncoderTests
// Now blend the images with a blue background and save as webp.
using Image background1 = new(image.Width, image.Height, Color.White.ToPixel());
background1.Mutate(c => c.DrawImage(cloned1, 1));
- provider.Utility.SaveTestOutputFile(background1, "webp", encoder, "octree-blended");
+ provider.Utility.SaveTestOutputFile(background1, "webp", encoder, "hexadecatree-blended");
using Image background2 = new(image.Width, image.Height, Color.White.ToPixel());
background2.Mutate(c => c.DrawImage(cloned2, 1));
diff --git a/tests/ImageSharp.Tests/Processing/Processors/Quantization/OctreeQuantizerTests.cs b/tests/ImageSharp.Tests/Processing/Processors/Quantization/HexadecatreeQuantizerTests.cs
similarity index 76%
rename from tests/ImageSharp.Tests/Processing/Processors/Quantization/OctreeQuantizerTests.cs
rename to tests/ImageSharp.Tests/Processing/Processors/Quantization/HexadecatreeQuantizerTests.cs
index c9f3daf0f2..4ef2159305 100644
--- a/tests/ImageSharp.Tests/Processing/Processors/Quantization/OctreeQuantizerTests.cs
+++ b/tests/ImageSharp.Tests/Processing/Processors/Quantization/HexadecatreeQuantizerTests.cs
@@ -8,37 +8,37 @@ using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace SixLabors.ImageSharp.Tests.Processing.Processors.Quantization;
[Trait("Category", "Processors")]
-public class OctreeQuantizerTests
+public class HexadecatreeQuantizerTests
{
[Fact]
- public void OctreeQuantizerConstructor()
+ public void HexadecatreeQuantizerConstructor()
{
QuantizerOptions expected = new() { MaxColors = 128 };
- OctreeQuantizer quantizer = new(expected);
+ HexadecatreeQuantizer quantizer = new(expected);
Assert.Equal(expected.MaxColors, quantizer.Options.MaxColors);
Assert.Equal(QuantizerConstants.DefaultDither, quantizer.Options.Dither);
expected = new QuantizerOptions { Dither = null };
- quantizer = new OctreeQuantizer(expected);
+ quantizer = new HexadecatreeQuantizer(expected);
Assert.Equal(QuantizerConstants.MaxColors, quantizer.Options.MaxColors);
Assert.Null(quantizer.Options.Dither);
expected = new QuantizerOptions { Dither = KnownDitherings.Atkinson };
- quantizer = new OctreeQuantizer(expected);
+ quantizer = new HexadecatreeQuantizer(expected);
Assert.Equal(QuantizerConstants.MaxColors, quantizer.Options.MaxColors);
Assert.Equal(KnownDitherings.Atkinson, quantizer.Options.Dither);
expected = new QuantizerOptions { Dither = KnownDitherings.Atkinson, MaxColors = 0 };
- quantizer = new OctreeQuantizer(expected);
+ quantizer = new HexadecatreeQuantizer(expected);
Assert.Equal(QuantizerConstants.MinColors, quantizer.Options.MaxColors);
Assert.Equal(KnownDitherings.Atkinson, quantizer.Options.Dither);
}
[Fact]
- public void OctreeQuantizerCanCreateFrameQuantizer()
+ public void HexadecatreeQuantizerCanCreateFrameQuantizer()
{
- OctreeQuantizer quantizer = new();
+ HexadecatreeQuantizer quantizer = new();
IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(Configuration.Default);
Assert.NotNull(frameQuantizer);
@@ -46,14 +46,14 @@ public class OctreeQuantizerTests
Assert.Equal(QuantizerConstants.DefaultDither, frameQuantizer.Options.Dither);
frameQuantizer.Dispose();
- quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = null });
+ quantizer = new HexadecatreeQuantizer(new QuantizerOptions { Dither = null });
frameQuantizer = quantizer.CreatePixelSpecificQuantizer(Configuration.Default);
Assert.NotNull(frameQuantizer);
Assert.Null(frameQuantizer.Options.Dither);
frameQuantizer.Dispose();
- quantizer = new OctreeQuantizer(new QuantizerOptions { Dither = KnownDitherings.Atkinson });
+ quantizer = new HexadecatreeQuantizer(new QuantizerOptions { Dither = KnownDitherings.Atkinson });
frameQuantizer = quantizer.CreatePixelSpecificQuantizer(Configuration.Default);
Assert.NotNull(frameQuantizer);
Assert.Equal(KnownDitherings.Atkinson, frameQuantizer.Options.Dither);
diff --git a/tests/ImageSharp.Tests/Processing/Processors/Quantization/QuantizerTests.cs b/tests/ImageSharp.Tests/Processing/Processors/Quantization/QuantizerTests.cs
index 2ba757c117..00e09d83b0 100644
--- a/tests/ImageSharp.Tests/Processing/Processors/Quantization/QuantizerTests.cs
+++ b/tests/ImageSharp.Tests/Processing/Processors/Quantization/QuantizerTests.cs
@@ -74,15 +74,15 @@ public class QuantizerTests
= new()
{
// Known uses error diffusion by default.
- KnownQuantizers.Octree,
+ KnownQuantizers.Hexadecatree,
KnownQuantizers.WebSafe,
KnownQuantizers.Werner,
KnownQuantizers.Wu,
- new OctreeQuantizer(NoDitherOptions),
+ new HexadecatreeQuantizer(NoDitherOptions),
new WebSafePaletteQuantizer(NoDitherOptions),
new WernerPaletteQuantizer(NoDitherOptions),
new WuQuantizer(NoDitherOptions),
- new OctreeQuantizer(OrderedDitherOptions),
+ new HexadecatreeQuantizer(OrderedDitherOptions),
new WebSafePaletteQuantizer(OrderedDitherOptions),
new WernerPaletteQuantizer(OrderedDitherOptions),
new WuQuantizer(OrderedDitherOptions)
@@ -91,52 +91,52 @@ public class QuantizerTests
public static readonly TheoryData DitherScaleQuantizers
= new()
{
- new OctreeQuantizer(Diffuser0_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Diffuser0_ScaleDitherOptions),
new WebSafePaletteQuantizer(Diffuser0_ScaleDitherOptions),
new WernerPaletteQuantizer(Diffuser0_ScaleDitherOptions),
new WuQuantizer(Diffuser0_ScaleDitherOptions),
- new OctreeQuantizer(Diffuser0_25_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Diffuser0_25_ScaleDitherOptions),
new WebSafePaletteQuantizer(Diffuser0_25_ScaleDitherOptions),
new WernerPaletteQuantizer(Diffuser0_25_ScaleDitherOptions),
new WuQuantizer(Diffuser0_25_ScaleDitherOptions),
- new OctreeQuantizer(Diffuser0_5_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Diffuser0_5_ScaleDitherOptions),
new WebSafePaletteQuantizer(Diffuser0_5_ScaleDitherOptions),
new WernerPaletteQuantizer(Diffuser0_5_ScaleDitherOptions),
new WuQuantizer(Diffuser0_5_ScaleDitherOptions),
- new OctreeQuantizer(Diffuser0_75_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Diffuser0_75_ScaleDitherOptions),
new WebSafePaletteQuantizer(Diffuser0_75_ScaleDitherOptions),
new WernerPaletteQuantizer(Diffuser0_75_ScaleDitherOptions),
new WuQuantizer(Diffuser0_75_ScaleDitherOptions),
- new OctreeQuantizer(DiffuserDitherOptions),
+ new HexadecatreeQuantizer(DiffuserDitherOptions),
new WebSafePaletteQuantizer(DiffuserDitherOptions),
new WernerPaletteQuantizer(DiffuserDitherOptions),
new WuQuantizer(DiffuserDitherOptions),
- new OctreeQuantizer(Ordered0_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Ordered0_ScaleDitherOptions),
new WebSafePaletteQuantizer(Ordered0_ScaleDitherOptions),
new WernerPaletteQuantizer(Ordered0_ScaleDitherOptions),
new WuQuantizer(Ordered0_ScaleDitherOptions),
- new OctreeQuantizer(Ordered0_25_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Ordered0_25_ScaleDitherOptions),
new WebSafePaletteQuantizer(Ordered0_25_ScaleDitherOptions),
new WernerPaletteQuantizer(Ordered0_25_ScaleDitherOptions),
new WuQuantizer(Ordered0_25_ScaleDitherOptions),
- new OctreeQuantizer(Ordered0_5_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Ordered0_5_ScaleDitherOptions),
new WebSafePaletteQuantizer(Ordered0_5_ScaleDitherOptions),
new WernerPaletteQuantizer(Ordered0_5_ScaleDitherOptions),
new WuQuantizer(Ordered0_5_ScaleDitherOptions),
- new OctreeQuantizer(Ordered0_75_ScaleDitherOptions),
+ new HexadecatreeQuantizer(Ordered0_75_ScaleDitherOptions),
new WebSafePaletteQuantizer(Ordered0_75_ScaleDitherOptions),
new WernerPaletteQuantizer(Ordered0_75_ScaleDitherOptions),
new WuQuantizer(Ordered0_75_ScaleDitherOptions),
- new OctreeQuantizer(OrderedDitherOptions),
+ new HexadecatreeQuantizer(OrderedDitherOptions),
new WebSafePaletteQuantizer(OrderedDitherOptions),
new WernerPaletteQuantizer(OrderedDitherOptions),
new WuQuantizer(OrderedDitherOptions),
diff --git a/tests/ImageSharp.Tests/Quantization/QuantizedImageTests.cs b/tests/ImageSharp.Tests/Quantization/QuantizedImageTests.cs
index d832136a98..e940886520 100644
--- a/tests/ImageSharp.Tests/Quantization/QuantizedImageTests.cs
+++ b/tests/ImageSharp.Tests/Quantization/QuantizedImageTests.cs
@@ -15,12 +15,12 @@ public class QuantizedImageTests
{
WernerPaletteQuantizer werner = new();
WebSafePaletteQuantizer webSafe = new();
- OctreeQuantizer octree = new();
+ HexadecatreeQuantizer hexadecatree = new();
WuQuantizer wu = new();
Assert.NotNull(werner.Options.Dither);
Assert.NotNull(webSafe.Options.Dither);
- Assert.NotNull(octree.Options.Dither);
+ Assert.NotNull(hexadecatree.Options.Dither);
Assert.NotNull(wu.Options.Dither);
using (IQuantizer quantizer = werner.CreatePixelSpecificQuantizer(this.Configuration))
@@ -33,7 +33,7 @@ public class QuantizedImageTests
Assert.NotNull(quantizer.Options.Dither);
}
- using (IQuantizer quantizer = octree.CreatePixelSpecificQuantizer(this.Configuration))
+ using (IQuantizer quantizer = hexadecatree.CreatePixelSpecificQuantizer(this.Configuration))
{
Assert.NotNull(quantizer.Options.Dither);
}
@@ -47,7 +47,7 @@ public class QuantizedImageTests
[Theory]
[WithFile(TestImages.Gif.Giphy, PixelTypes.Rgba32, true)]
[WithFile(TestImages.Gif.Giphy, PixelTypes.Rgba32, false)]
- public void OctreeQuantizerYieldsCorrectTransparentPixel(
+ public void HexadecatreeQuantizerYieldsCorrectTransparentPixel(
TestImageProvider provider,
bool dither)
where TPixel : unmanaged, IPixel
@@ -60,7 +60,7 @@ public class QuantizedImageTests
options.Dither = null;
}
- OctreeQuantizer quantizer = new(options);
+ HexadecatreeQuantizer quantizer = new(options);
foreach (ImageFrame frame in image.Frames)
{
@@ -103,8 +103,8 @@ public class QuantizedImageTests
where TPixel : unmanaged, IPixel
{
using Image image = provider.GetImage();
- OctreeQuantizer octreeQuantizer = new();
- IQuantizer quantizer = octreeQuantizer.CreatePixelSpecificQuantizer(Configuration.Default, new QuantizerOptions { MaxColors = 128 });
+ HexadecatreeQuantizer hexadecatreeQuantizer = new();
+ IQuantizer quantizer = hexadecatreeQuantizer.CreatePixelSpecificQuantizer