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 1/9] 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 2/9] 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 b33f288a90e88160411d62553a40605b0ce2f842 Mon Sep 17 00:00:00 2001
From: James Jackson-South
Date: Wed, 1 Apr 2026 22:42:17 +1000
Subject: [PATCH 3/9] Add working-buffer blending and adjust row APIs
---
.../Advanced/IRowOperation{TBuffer}.cs | 4 +-
.../Advanced/ParallelRowIterator.cs | 8 +-
.../Formats/Webp/WebpAnimationDecoder.cs | 13 +-
.../PixelFormats/PixelBlender{TPixel}.cs | 198 ++++++++++++++++--
.../DrawImageProcessor{TPixelBg,TPixelFg}.cs | 16 +-
5 files changed, 208 insertions(+), 31 deletions(-)
diff --git a/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs b/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
index 8b46fc5c31..dfaec640b8 100644
--- a/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
+++ b/src/ImageSharp/Advanced/IRowOperation{TBuffer}.cs
@@ -15,12 +15,12 @@ public interface IRowOperation
///
/// The bounds of the operation.
/// The required buffer length.
- int GetRequiredBufferLength(Rectangle bounds);
+ public int GetRequiredBufferLength(Rectangle bounds);
///
/// Invokes the method passing the row and a buffer.
///
/// The row y coordinate.
/// The contiguous region of memory.
- void Invoke(int y, Span span);
+ public void Invoke(int y, Span span);
}
diff --git a/src/ImageSharp/Advanced/ParallelRowIterator.cs b/src/ImageSharp/Advanced/ParallelRowIterator.cs
index b878f9ec0a..d170631a29 100644
--- a/src/ImageSharp/Advanced/ParallelRowIterator.cs
+++ b/src/ImageSharp/Advanced/ParallelRowIterator.cs
@@ -68,7 +68,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowOperationWrapper wrappingOperation = new(top, bottom, verticalStep, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -138,7 +138,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowOperationWrapper wrappingOperation = new(top, bottom, verticalStep, bufferLength, allocator, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -195,7 +195,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowIntervalOperationWrapper wrappingOperation = new(top, bottom, verticalStep, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
@@ -262,7 +262,7 @@ public static partial class ParallelRowIterator
ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = numOfSteps };
RowIntervalOperationWrapper wrappingOperation = new(top, bottom, verticalStep, bufferLength, allocator, in operation);
- Parallel.For(
+ _ = Parallel.For(
0,
numOfSteps,
parallelOptions,
diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
index a237054133..71e609fbcb 100644
--- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
+++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Buffers;
+using System.Numerics;
using SixLabors.ImageSharp.Formats.Webp.Chunks;
using SixLabors.ImageSharp.Formats.Webp.Lossless;
using SixLabors.ImageSharp.Formats.Webp.Lossy;
@@ -456,15 +457,21 @@ internal class WebpAnimationDecoder : IDisposable
// The destination frame has already been prepopulated with the pixel data from the previous frame
// so blending will leave the desired result which takes into consideration restoration to the
// background color within the restore area.
- PixelBlender blender =
- PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver);
+ PixelBlender blender = PixelOperations.Instance.GetPixelBlender(
+ PixelColorBlendingMode.Normal,
+ PixelAlphaCompositionMode.SrcOver);
+
+ // By using a dedicated vector span we can avoid per-row pool allocations in PixelBlender.Blend
+ // We need 3 Vector4 values per pixel to store the background, foreground, and result pixels for blending.
+ using IMemoryOwner workingBufferOwner = imageFrame.Configuration.MemoryAllocator.Allocate(restoreArea.Width * 3);
+ Span workingBuffer = workingBufferOwner.GetSpan();
for (int y = 0; y < restoreArea.Height; y++)
{
Span framePixelRow = imageFramePixels.DangerousGetRowSpan(y);
Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y)[..restoreArea.Width];
- blender.Blend(imageFrame.Configuration, framePixelRow, framePixelRow, decodedPixelRow, 1f);
+ blender.Blend(imageFrame.Configuration, framePixelRow, framePixelRow, decodedPixelRow, 1f, workingBuffer);
}
return;
diff --git a/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs b/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
index bad9ae01c1..fcd28796fb 100644
--- a/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
+++ b/src/ImageSharp/PixelFormats/PixelBlender{TPixel}.cs
@@ -3,7 +3,6 @@
using System.Buffers;
using System.Numerics;
-using SixLabors.ImageSharp.Memory;
namespace SixLabors.ImageSharp.PixelFormats;
@@ -52,16 +51,53 @@ public abstract class PixelBlender
Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 3);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
- Span sourceVectors = buffer.Slice(maxLength * 2, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 3)]);
+ }
+
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// the pixel format of the source span
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A value between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ float amount,
+ Span workingBuffer)
+ where TPixelSrc : unmanaged, IPixel
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(source.Length, maxLength, nameof(source.Length));
+ Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 3, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
+ Span sourceVectors = workingBuffer.Slice(maxLength * 2, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
PixelOperations.Instance.ToVector4(configuration, source[..maxLength], sourceVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, sourceVectors, amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
@@ -87,14 +123,48 @@ public abstract class PixelBlender
Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 2)]);
+ }
+
+ ///
+ /// Blends a row against a constant source color using caller-provided temporary vector scratch.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source color
+ ///
+ /// A value between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 2 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ TPixel source,
+ float amount,
+ Span workingBuffer)
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeBetweenOrEqualTo(amount, 0, 1, nameof(amount));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 2, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, source.ToScaledVector4(), amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
@@ -116,6 +186,27 @@ public abstract class PixelBlender
ReadOnlySpan amount)
=> this.Blend(configuration, destination, background, source, amount);
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ ReadOnlySpan amount,
+ Span workingBuffer)
+ => this.Blend(configuration, destination, background, source, amount, workingBuffer);
+
///
/// Blends 2 rows together
///
@@ -142,20 +233,89 @@ public abstract class PixelBlender
Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 3);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
- Span sourceVectors = buffer.Slice(maxLength * 2, maxLength);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 3)]);
+ }
+
+ ///
+ /// Blends a row against a constant source color.
+ ///
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source color
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ TPixel source,
+ ReadOnlySpan amount)
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+
+ using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
+ this.Blend(
+ configuration,
+ destination,
+ background,
+ source,
+ amount,
+ buffer.Memory.Span[..(maxLength * 2)]);
+ }
+
+ ///
+ /// Blends 2 rows together using caller-provided temporary vector scratch.
+ ///
+ /// the pixel format of the source span
+ /// to use internally
+ /// the destination span
+ /// the background span
+ /// the source span
+ ///
+ /// A span with values between 0 and 1 indicating the weight of the second source vector.
+ /// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
+ ///
+ /// Reusable temporary vector scratch with capacity for at least 3 rows.
+ public void Blend(
+ Configuration configuration,
+ Span destination,
+ ReadOnlySpan background,
+ ReadOnlySpan source,
+ ReadOnlySpan amount,
+ Span workingBuffer)
+ where TPixelSrc : unmanaged, IPixel
+ {
+ int maxLength = destination.Length;
+ Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
+ Guard.MustBeGreaterThanOrEqualTo(source.Length, maxLength, nameof(source.Length));
+ Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 3, nameof(workingBuffer.Length));
+
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
+ Span sourceVectors = workingBuffer.Slice(maxLength * 2, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
PixelOperations.Instance.ToVector4(configuration, source[..maxLength], sourceVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, sourceVectors, amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
- /// Blends a row against a constant source color.
+ /// Blends a row against a constant source color using caller-provided temporary vector scratch.
///
/// to use internally
/// the destination span
@@ -165,26 +325,28 @@ public abstract class PixelBlender
/// A span with values between 0 and 1 indicating the weight of the second source vector.
/// At amount = 0, "background" is returned, at amount = 1, "source" is returned.
///
+ /// Reusable temporary vector scratch with capacity for at least 2 rows.
public void Blend(
Configuration configuration,
Span destination,
ReadOnlySpan background,
TPixel source,
- ReadOnlySpan amount)
+ ReadOnlySpan amount,
+ Span workingBuffer)
{
int maxLength = destination.Length;
Guard.MustBeGreaterThanOrEqualTo(background.Length, maxLength, nameof(background.Length));
Guard.MustBeGreaterThanOrEqualTo(amount.Length, maxLength, nameof(amount.Length));
+ Guard.MustBeGreaterThanOrEqualTo(workingBuffer.Length, maxLength * 2, nameof(workingBuffer.Length));
- using IMemoryOwner buffer = configuration.MemoryAllocator.Allocate(maxLength * 2);
- Span destinationVectors = buffer.Slice(0, maxLength);
- Span backgroundVectors = buffer.Slice(maxLength, maxLength);
+ Span destinationVectors = workingBuffer[..maxLength];
+ Span backgroundVectors = workingBuffer.Slice(maxLength, maxLength);
PixelOperations.Instance.ToVector4(configuration, background[..maxLength], backgroundVectors, PixelConversionModifiers.Scale);
this.BlendFunction(destinationVectors, backgroundVectors, source.ToScaledVector4(), amount);
- PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors[..maxLength], destination, PixelConversionModifiers.Scale);
+ PixelOperations.Instance.FromVector4Destructive(configuration, destinationVectors, destination, PixelConversionModifiers.Scale);
}
///
diff --git a/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs b/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
index 7a1ffb85f9..f2faee4f9c 100644
--- a/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
+++ b/src/ImageSharp/Processing/Processors/Drawing/DrawImageProcessor{TPixelBg,TPixelFg}.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Numerics;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.Memory;
@@ -145,7 +146,7 @@ internal class DrawImageProcessor : ImageProcessor
this.Blender,
this.Opacity);
- ParallelRowIterator.IterateRows(
+ ParallelRowIterator.IterateRows(
configuration,
new Rectangle(0, 0, foregroundRectangle.Width, foregroundRectangle.Height),
in operation);
@@ -161,7 +162,7 @@ internal class DrawImageProcessor : ImageProcessor
///
/// A implementing the draw logic for .
///
- private readonly struct RowOperation : IRowOperation
+ private readonly struct RowOperation : IRowOperation
{
private readonly Buffer2D background;
private readonly Buffer2D foreground;
@@ -190,13 +191,20 @@ internal class DrawImageProcessor : ImageProcessor
this.opacity = opacity;
}
+ ///
+ public int GetRequiredBufferLength(Rectangle bounds)
+
+ // By using a dedicated vector span we can avoid per-row pool allocations in PixelBlender.Blend
+ // We need 3 Vector4 values per pixel to store the background, foreground, and result pixels for blending.
+ => 3 * bounds.Width;
+
///
[MethodImpl(InliningOptions.ShortMethod)]
- public void Invoke(int y)
+ public void Invoke(int y, Span span)
{
Span background = this.background.DangerousGetRowSpan(y + this.backgroundRectangle.Top).Slice(this.backgroundRectangle.Left, this.backgroundRectangle.Width);
Span foreground = this.foreground.DangerousGetRowSpan(y + this.foregroundRectangle.Top).Slice(this.foregroundRectangle.Left, this.foregroundRectangle.Width);
- this.blender.Blend(this.configuration, background, background, foreground, this.opacity);
+ this.blender.Blend(this.configuration, background, background, foreground, this.opacity, span);
}
}
}
From e06a015cf59148e24d1fc940aed3b9e4d8356103 Mon Sep 17 00:00:00 2001
From: James Jackson-South
Date: Tue, 7 Apr 2026 11:39:29 +1000
Subject: [PATCH 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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