diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
index 68f4e5fa2d..ad073ccbb1 100644
--- a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
+++ b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
@@ -89,6 +89,11 @@ internal sealed class GifDecoderCore : ImageDecoderCore
///
private GifMetadata? gifMetadata;
+ ///
+ /// The background color index.
+ ///
+ private byte backgroundColorIndex;
+
///
/// Initializes a new instance of the class.
///
@@ -108,6 +113,10 @@ protected override Image Decode(BufferedReadStream stream, Cance
uint frameCount = 0;
Image? image = null;
ImageFrame? previousFrame = null;
+ GifDisposalMethod? previousDisposalMethod = null;
+ bool globalColorTableUsed = false;
+ Color backgroundColor = Color.Transparent;
+
try
{
this.ReadLogicalScreenDescriptorAndGlobalColorTable(stream);
@@ -123,7 +132,7 @@ protected override Image Decode(BufferedReadStream stream, Cance
break;
}
- this.ReadFrame(stream, ref image, ref previousFrame);
+ globalColorTableUsed |= this.ReadFrame(stream, ref image, ref previousFrame, ref previousDisposalMethod, ref backgroundColor);
// Reset per-frame state.
this.imageDescriptor = default;
@@ -158,6 +167,13 @@ protected override Image Decode(BufferedReadStream stream, Cance
break;
}
}
+
+ // We cannot always trust the global GIF palette has actually been used.
+ // https://github.com/SixLabors/ImageSharp/issues/2866
+ if (!globalColorTableUsed)
+ {
+ this.gifMetadata.ColorTableMode = GifColorTableMode.Local;
+ }
}
finally
{
@@ -417,7 +433,14 @@ private void ReadComments(BufferedReadStream stream)
/// The containing image data.
/// The image to decode the information to.
/// The previous frame.
- private void ReadFrame(BufferedReadStream stream, ref Image? image, ref ImageFrame? previousFrame)
+ /// The previous disposal method.
+ /// The background color.
+ private bool ReadFrame(
+ BufferedReadStream stream,
+ ref Image? image,
+ ref ImageFrame? previousFrame,
+ ref GifDisposalMethod? previousDisposalMethod,
+ ref Color backgroundColor)
where TPixel : unmanaged, IPixel
{
this.ReadImageDescriptor(stream);
@@ -444,10 +467,52 @@ private void ReadFrame(BufferedReadStream stream, ref Image? ima
}
ReadOnlySpan colorTable = MemoryMarshal.Cast(rawColorTable);
- this.ReadFrameColors(stream, ref image, ref previousFrame, colorTable, this.imageDescriptor);
+
+ // First frame
+ if (image is null)
+ {
+ if (this.backgroundColorIndex < colorTable.Length)
+ {
+ backgroundColor = colorTable[this.backgroundColorIndex];
+ }
+ else
+ {
+ backgroundColor = Color.Transparent;
+ }
+
+ if (this.graphicsControlExtension.TransparencyFlag)
+ {
+ backgroundColor = backgroundColor.WithAlpha(0);
+ }
+ }
+
+ this.ReadFrameColors(stream, ref image, ref previousFrame, ref previousDisposalMethod, colorTable, this.imageDescriptor, backgroundColor.ToPixel());
+
+ // Update from newly decoded frame.
+ if (this.graphicsControlExtension.DisposalMethod != GifDisposalMethod.RestoreToPrevious)
+ {
+ if (this.backgroundColorIndex < colorTable.Length)
+ {
+ backgroundColor = colorTable[this.backgroundColorIndex];
+ }
+ else
+ {
+ backgroundColor = Color.Transparent;
+ }
+
+ // TODO: I don't understand why this is always set to alpha of zero.
+ // This should be dependent on the transparency flag of the graphics
+ // control extension. ImageMagick does the same.
+ // if (this.graphicsControlExtension.TransparencyFlag)
+ {
+ backgroundColor = backgroundColor.WithAlpha(0);
+ }
+ }
// Skip any remaining blocks
SkipBlock(stream);
+
+ return !hasLocalColorTable;
}
///
@@ -457,57 +522,74 @@ private void ReadFrame(BufferedReadStream stream, ref Image? ima
/// The containing image data.
/// The image to decode the information to.
/// The previous frame.
+ /// The previous disposal method.
/// The color table containing the available colors.
/// The
+ /// The background color pixel.
private void ReadFrameColors(
BufferedReadStream stream,
ref Image? image,
ref ImageFrame? previousFrame,
+ ref GifDisposalMethod? previousDisposalMethod,
ReadOnlySpan colorTable,
- in GifImageDescriptor descriptor)
+ in GifImageDescriptor descriptor,
+ TPixel backgroundPixel)
where TPixel : unmanaged, IPixel
{
int imageWidth = this.logicalScreenDescriptor.Width;
int imageHeight = this.logicalScreenDescriptor.Height;
bool transFlag = this.graphicsControlExtension.TransparencyFlag;
+ GifDisposalMethod disposalMethod = this.graphicsControlExtension.DisposalMethod;
+ ImageFrame currentFrame;
+ ImageFrame? restoreFrame = null;
- ImageFrame? prevFrame = null;
- ImageFrame? currentFrame = null;
- ImageFrame imageFrame;
+ if (previousFrame is null && previousDisposalMethod is null)
+ {
+ image = transFlag
+ ? new Image(this.configuration, imageWidth, imageHeight, this.metadata)
+ : new Image(this.configuration, imageWidth, imageHeight, backgroundPixel, this.metadata);
- if (previousFrame is null)
+ this.SetFrameMetadata(image.Frames.RootFrame.Metadata);
+ currentFrame = image.Frames.RootFrame;
+ }
+ else
{
- if (!transFlag)
+ if (previousFrame != null)
{
- image = new Image(this.configuration, imageWidth, imageHeight, Color.Black.ToPixel(), this.metadata);
+ currentFrame = image!.Frames.AddFrame(previousFrame);
}
else
{
- // This initializes the image to become fully transparent because the alpha channel is zero.
- image = new Image(this.configuration, imageWidth, imageHeight, this.metadata);
+ currentFrame = image!.Frames.CreateFrame(backgroundPixel);
}
- this.SetFrameMetadata(image.Frames.RootFrame.Metadata);
+ this.SetFrameMetadata(currentFrame.Metadata);
- imageFrame = image.Frames.RootFrame;
- }
- else
- {
if (this.graphicsControlExtension.DisposalMethod == GifDisposalMethod.RestoreToPrevious)
{
- prevFrame = previousFrame;
+ restoreFrame = previousFrame;
}
- // We create a clone of the frame and add it.
- // We will overpaint the difference of pixels on the current frame to create a complete image.
- // This ensures that we have enough pixel data to process without distortion. #2450
- currentFrame = image!.Frames.AddFrame(previousFrame);
+ if (previousDisposalMethod == GifDisposalMethod.RestoreToBackground)
+ {
+ this.RestoreToBackground(currentFrame, backgroundPixel, transFlag);
+ }
+ }
- this.SetFrameMetadata(currentFrame.Metadata);
+ if (this.graphicsControlExtension.DisposalMethod == GifDisposalMethod.RestoreToPrevious)
+ {
+ previousFrame = restoreFrame;
+ }
+ else
+ {
+ previousFrame = currentFrame;
+ }
- imageFrame = currentFrame;
+ previousDisposalMethod = disposalMethod;
- this.RestoreToBackground(imageFrame);
+ if (disposalMethod == GifDisposalMethod.RestoreToBackground)
+ {
+ this.restoreArea = Rectangle.Intersect(image.Bounds, new(descriptor.Left, descriptor.Top, descriptor.Width, descriptor.Height));
}
if (colorTable.Length == 0)
@@ -573,7 +655,7 @@ private void ReadFrameColors(
}
lzwDecoder.DecodePixelRow(indicesRow);
- ref TPixel rowRef = ref MemoryMarshal.GetReference(imageFrame.PixelBuffer.DangerousGetRowSpan(writeY));
+ ref TPixel rowRef = ref MemoryMarshal.GetReference(currentFrame.PixelBuffer.DangerousGetRowSpan(writeY));
if (!transFlag)
{
@@ -605,19 +687,6 @@ private void ReadFrameColors(
}
}
}
-
- if (prevFrame != null)
- {
- previousFrame = prevFrame;
- return;
- }
-
- previousFrame = currentFrame ?? image.Frames.RootFrame;
-
- if (this.graphicsControlExtension.DisposalMethod == GifDisposalMethod.RestoreToBackground)
- {
- this.restoreArea = new Rectangle(descriptor.Left, descriptor.Top, descriptor.Width, descriptor.Height);
- }
}
///
@@ -638,6 +707,11 @@ private void ReadFrameMetadata(BufferedReadStream stream, List(768, AllocationOptions.Clean);
stream.Read(this.currentLocalColorTable.GetSpan()[..length]);
}
+ else
+ {
+ this.currentLocalColorTable = null;
+ this.currentLocalColorTableSize = 0;
+ }
// Skip the frame indices. Pixels length + mincode size.
// The gif format does not tell us the length of the compressed data beforehand.
@@ -662,7 +736,9 @@ private void ReadFrameMetadata(BufferedReadStream stream, List
/// The pixel format.
/// The frame.
- private void RestoreToBackground(ImageFrame frame)
+ /// The background color.
+ /// Whether the background is transparent.
+ private void RestoreToBackground(ImageFrame frame, TPixel background, bool transparent)
where TPixel : unmanaged, IPixel
{
if (this.restoreArea is null)
@@ -672,7 +748,14 @@ private void RestoreToBackground(ImageFrame frame)
Rectangle interest = Rectangle.Intersect(frame.Bounds(), this.restoreArea.Value);
Buffer2DRegion pixelRegion = frame.PixelBuffer.GetRegion(interest);
- pixelRegion.Clear();
+ if (transparent)
+ {
+ pixelRegion.Clear();
+ }
+ else
+ {
+ pixelRegion.Fill(background);
+ }
this.restoreArea = null;
}
@@ -787,7 +870,9 @@ private void ReadLogicalScreenDescriptorAndGlobalColorTable(BufferedReadStream s
}
}
- this.gifMetadata.BackgroundColorIndex = this.logicalScreenDescriptor.BackgroundColorIndex;
+ byte index = this.logicalScreenDescriptor.BackgroundColorIndex;
+ this.backgroundColorIndex = index;
+ this.gifMetadata.BackgroundColorIndex = index;
}
private unsafe struct ScratchBuffer
diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
index 1daa713cbc..f51c97db9a 100644
--- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
+++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
@@ -88,18 +88,22 @@ public void Encode(Image image, Stream stream, CancellationToken
GifMetadata gifMetadata = GetGifMetadata(image);
this.colorTableMode ??= gifMetadata.ColorTableMode;
bool useGlobalTable = this.colorTableMode == GifColorTableMode.Global;
-
- // Quantize the first image frame returning a palette.
- IndexedImageFrame? quantized = null;
+ bool useGlobalTableForFirstFrame = useGlobalTable;
// Work out if there is an explicit transparent index set for the frame. We use that to ensure the
// correct value is set for the background index when quantizing.
GifFrameMetadata frameMetadata = GetGifFrameMetadata(image.Frames.RootFrame, -1);
+ if (frameMetadata.ColorTableMode == GifColorTableMode.Local)
+ {
+ useGlobalTableForFirstFrame = false;
+ }
+ // Quantize the first image frame returning a palette.
+ IndexedImageFrame? quantized = null;
if (this.quantizer is null)
{
// Is this a gif with color information. If so use that, otherwise use octree.
- if (gifMetadata.ColorTableMode == GifColorTableMode.Global && gifMetadata.GlobalColorTable?.Length > 0)
+ if (useGlobalTable && gifMetadata.GlobalColorTable?.Length > 0)
{
// We avoid dithering by default to preserve the original colors.
int transparencyIndex = GetTransparentIndex(quantized, frameMetadata);
@@ -118,8 +122,9 @@ public void Encode(Image image, Stream stream, CancellationToken
}
}
- using (IQuantizer frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer(this.configuration))
+ if (useGlobalTableForFirstFrame)
{
+ using IQuantizer frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer(this.configuration);
if (useGlobalTable)
{
frameQuantizer.BuildPalette(this.pixelSamplingStrategy, image);
@@ -131,6 +136,17 @@ public void Encode(Image image, Stream stream, CancellationToken
quantized = frameQuantizer.QuantizeFrame(image.Frames.RootFrame, image.Bounds);
}
}
+ else
+ {
+ quantized = this.QuantizeAdditionalFrameAndUpdateMetadata(
+ image.Frames.RootFrame,
+ image.Frames.RootFrame.Bounds(),
+ frameMetadata,
+ true,
+ default,
+ false,
+ frameMetadata.HasTransparency ? frameMetadata.TransparencyIndex : -1);
+ }
// Write the header.
WriteHeader(stream);
@@ -243,8 +259,8 @@ private void EncodeAdditionalFrames(
return;
}
- PaletteQuantizer paletteQuantizer = default;
- bool hasPaletteQuantizer = false;
+ PaletteQuantizer globalPaletteQuantizer = default;
+ bool hasGlobalPaletteQuantizer = false;
// Store the first frame as a reference for de-duplication comparison.
ImageFrame previousFrame = image.Frames.RootFrame;
@@ -260,14 +276,14 @@ private void EncodeAdditionalFrames(
GifFrameMetadata gifMetadata = GetGifFrameMetadata(currentFrame, globalTransparencyIndex);
bool useLocal = this.colorTableMode == GifColorTableMode.Local || (gifMetadata.ColorTableMode == GifColorTableMode.Local);
- if (!useLocal && !hasPaletteQuantizer && i > 0)
+ if (!useLocal && !hasGlobalPaletteQuantizer && i > 0)
{
// The palette quantizer can reuse the same global pixel map across multiple frames since the palette is unchanging.
// This allows a reduction of memory usage across multi-frame gifs using a global palette
// and also allows use to reuse the cache from previous runs.
int transparencyIndex = gifMetadata.HasTransparency ? gifMetadata.TransparencyIndex : -1;
- paletteQuantizer = new(this.configuration, this.quantizer!.Options, globalPalette, transparencyIndex);
- hasPaletteQuantizer = true;
+ globalPaletteQuantizer = new(this.configuration, this.quantizer!.Options, globalPalette, transparencyIndex);
+ hasGlobalPaletteQuantizer = true;
}
this.EncodeAdditionalFrame(
@@ -278,16 +294,16 @@ private void EncodeAdditionalFrames(
encodingFrame,
useLocal,
gifMetadata,
- paletteQuantizer,
+ globalPaletteQuantizer,
previousDisposalMethod);
previousFrame = currentFrame;
previousDisposalMethod = gifMetadata.DisposalMethod;
}
- if (hasPaletteQuantizer)
+ if (hasGlobalPaletteQuantizer)
{
- paletteQuantizer.Dispose();
+ globalPaletteQuantizer.Dispose();
}
}
diff --git a/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs b/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
index f8734bb5a3..2d994c14f1 100644
--- a/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
+++ b/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
@@ -1,7 +1,6 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using System.Numerics;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Gif;
@@ -79,35 +78,15 @@ private GifFrameMetadata(GifFrameMetadata other)
public IDeepCloneable DeepClone() => new GifFrameMetadata(this);
internal static GifFrameMetadata FromAnimatedMetadata(AnimatedImageFrameMetadata metadata)
- {
- // TODO: v4 How do I link the parent metadata to the frame metadata to get the global color table?
- int index = -1;
- float background = 1f;
- if (metadata.ColorTable.HasValue)
- {
- ReadOnlySpan colorTable = metadata.ColorTable.Value.Span;
- for (int i = 0; i < colorTable.Length; i++)
- {
- Vector4 vector = (Vector4)colorTable[i];
- if (vector.W < background)
- {
- index = i;
- }
- }
- }
-
- bool hasTransparency = index >= 0;
-
- return new()
+ => new()
{
- LocalColorTable = metadata.ColorTable,
+ // Do not copy the color table or transparency data.
+ // This will lead to a mismatch when the image is comprised of frames
+ // extracted individually from a multi-frame image.
ColorTableMode = metadata.ColorTableMode == FrameColorTableMode.Global ? GifColorTableMode.Global : GifColorTableMode.Local,
FrameDelay = (int)Math.Round(metadata.Duration.TotalMilliseconds / 10),
- DisposalMethod = GetMode(metadata.DisposalMode),
- HasTransparency = hasTransparency,
- TransparencyIndex = hasTransparency ? unchecked((byte)index) : byte.MinValue,
+ DisposalMethod = GetMode(metadata.DisposalMode)
};
- }
private static GifDisposalMethod GetMode(FrameDisposalMode mode) => mode switch
{
diff --git a/src/ImageSharp/Formats/Gif/GifMetadata.cs b/src/ImageSharp/Formats/Gif/GifMetadata.cs
index 1331edee89..088d4d088d 100644
--- a/src/ImageSharp/Formats/Gif/GifMetadata.cs
+++ b/src/ImageSharp/Formats/Gif/GifMetadata.cs
@@ -73,28 +73,12 @@ private GifMetadata(GifMetadata other)
public IDeepCloneable DeepClone() => new GifMetadata(this);
internal static GifMetadata FromAnimatedMetadata(AnimatedImageMetadata metadata)
- {
- int index = 0;
- Color background = metadata.BackgroundColor;
- if (metadata.ColorTable.HasValue)
+ => new()
{
- ReadOnlySpan colorTable = metadata.ColorTable.Value.Span;
- for (int i = 0; i < colorTable.Length; i++)
- {
- if (background == colorTable[i])
- {
- index = i;
- break;
- }
- }
- }
-
- return new()
- {
- GlobalColorTable = metadata.ColorTable,
+ // Do not copy the color table or bit depth.
+ // This will lead to a mismatch when the image is comprised of frames
+ // extracted individually from a multi-frame image.
ColorTableMode = metadata.ColorTableMode == FrameColorTableMode.Global ? GifColorTableMode.Global : GifColorTableMode.Local,
RepeatCount = metadata.RepeatCount,
- BackgroundColorIndex = (byte)Numerics.Clamp(index, 0, 255),
};
- }
}
diff --git a/src/ImageSharp/Formats/Gif/MetadataExtensions.cs b/src/ImageSharp/Formats/Gif/MetadataExtensions.cs
index ad06462e77..4e9977b3fd 100644
--- a/src/ImageSharp/Formats/Gif/MetadataExtensions.cs
+++ b/src/ImageSharp/Formats/Gif/MetadataExtensions.cs
@@ -61,18 +61,17 @@ public static bool TryGetGifMetadata(this ImageFrameMetadata source, [NotNullWhe
internal static AnimatedImageMetadata ToAnimatedImageMetadata(this GifMetadata source)
{
- Color background = Color.Transparent;
- if (source.GlobalColorTable != null)
- {
- background = source.GlobalColorTable.Value.Span[source.BackgroundColorIndex];
- }
+ bool global = source.ColorTableMode == GifColorTableMode.Global;
+ Color color = global && source.GlobalColorTable.HasValue && source.GlobalColorTable.Value.Span.Length > source.BackgroundColorIndex
+ ? source.GlobalColorTable.Value.Span[source.BackgroundColorIndex]
+ : Color.Transparent;
return new()
{
- ColorTable = source.GlobalColorTable,
ColorTableMode = source.ColorTableMode == GifColorTableMode.Global ? FrameColorTableMode.Global : FrameColorTableMode.Local,
+ ColorTable = global ? source.GlobalColorTable : null,
RepeatCount = source.RepeatCount,
- BackgroundColor = background,
+ BackgroundColor = color,
};
}
diff --git a/src/ImageSharp/Formats/Png/PngEncoder.cs b/src/ImageSharp/Formats/Png/PngEncoder.cs
index dcbaf3140d..50a6b44f83 100644
--- a/src/ImageSharp/Formats/Png/PngEncoder.cs
+++ b/src/ImageSharp/Formats/Png/PngEncoder.cs
@@ -2,8 +2,6 @@
// Licensed under the Six Labors Split License.
#nullable disable
-using SixLabors.ImageSharp.Processing.Processors.Quantization;
-
namespace SixLabors.ImageSharp.Formats.Png;
///
@@ -11,16 +9,6 @@ namespace SixLabors.ImageSharp.Formats.Png;
///
public class PngEncoder : QuantizingImageEncoder
{
- ///
- /// Initializes a new instance of the class.
- ///
- public PngEncoder()
-
- // Hack. TODO: Investigate means to fix/optimize the Wu quantizer.
- // The Wu quantizer does not handle the default sampling strategy well for some larger images.
- // It's expensive and the results are not better than the extensive strategy.
- => this.PixelSamplingStrategy = new ExtensivePixelSamplingStrategy();
-
///
/// Gets the number of bits per sample or per palette index (not per pixel).
/// Not all values are allowed for all values.
diff --git a/src/ImageSharp/Formats/Png/PngEncoderCore.cs b/src/ImageSharp/Formats/Png/PngEncoderCore.cs
index e01b5c2a59..db6a1ffc80 100644
--- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs
+++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs
@@ -3,6 +3,7 @@
using System.Buffers;
using System.Buffers.Binary;
+using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -124,6 +125,14 @@ internal sealed class PngEncoderCore : IDisposable
///
private int derivedTransparencyIndex = -1;
+ ///
+ /// The default background color of the canvas when animating.
+ /// This color may be used to fill the unused space on the canvas around the frames,
+ /// as well as the transparent pixels of the first frame.
+ /// The background color is also used when a frame disposal mode is .
+ ///
+ private Color? backgroundColor;
+
///
/// Initializes a new instance of the class.
///
@@ -161,118 +170,164 @@ public void Encode(Image image, Stream stream, CancellationToken
ImageFrame? clonedFrame = null;
ImageFrame currentFrame = image.Frames.RootFrame;
- int currentFrameIndex = 0;
+ IndexedImageFrame? quantized = null;
+ PaletteQuantizer? paletteQuantizer = null;
+ Buffer2DRegion currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
- bool clearTransparency = this.encoder.TransparentColorMode is PngTransparentColorMode.Clear;
- if (clearTransparency)
+ try
{
- currentFrame = clonedFrame = currentFrame.Clone();
- ClearTransparentPixels(currentFrame);
- }
-
- // Do not move this. We require an accurate bit depth for the header chunk.
- IndexedImageFrame? quantized = this.CreateQuantizedImageAndUpdateBitDepth(
- pngMetadata,
- currentFrame,
- currentFrame.Bounds(),
- null);
-
- this.WriteHeaderChunk(stream);
- this.WriteGammaChunk(stream);
- this.WriteCicpChunk(stream, metadata);
- this.WriteColorProfileChunk(stream, metadata);
- this.WritePaletteChunk(stream, quantized);
- this.WriteTransparencyChunk(stream, pngMetadata);
- this.WritePhysicalChunk(stream, metadata);
- this.WriteExifChunk(stream, metadata);
- this.WriteXmpChunk(stream, metadata);
- this.WriteTextChunks(stream, pngMetadata);
-
- if (image.Frames.Count > 1)
- {
- this.WriteAnimationControlChunk(stream, (uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)), pngMetadata.RepeatCount);
- }
+ int currentFrameIndex = 0;
- // If the first frame isn't animated, write it as usual and skip it when writing animated frames
- if (!pngMetadata.AnimateRootFrame || image.Frames.Count == 1)
- {
- FrameControl frameControl = new((uint)this.width, (uint)this.height);
- this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
- currentFrameIndex++;
- }
+ bool clearTransparency = this.encoder.TransparentColorMode is PngTransparentColorMode.Clear;
+ if (clearTransparency)
+ {
+ currentFrame = clonedFrame = currentFrame.Clone();
+ currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
+ ClearTransparentPixels(in currentFrameRegion, this.backgroundColor.Value);
+ }
- if (image.Frames.Count > 1)
- {
- // Write the first animated frame.
- currentFrame = image.Frames[currentFrameIndex];
- PngFrameMetadata frameMetadata = GetPngFrameMetadata(currentFrame);
- PngDisposalMethod previousDisposal = frameMetadata.DisposalMethod;
- FrameControl frameControl = this.WriteFrameControlChunk(stream, frameMetadata, currentFrame.Bounds(), 0);
- uint sequenceNumber = 1;
- if (pngMetadata.AnimateRootFrame)
+ // Do not move this. We require an accurate bit depth for the header chunk.
+ quantized = this.CreateQuantizedImageAndUpdateBitDepth(
+ pngMetadata,
+ image,
+ currentFrame,
+ currentFrame.Bounds(),
+ null);
+
+ this.WriteHeaderChunk(stream);
+ this.WriteGammaChunk(stream);
+ this.WriteCicpChunk(stream, metadata);
+ this.WriteColorProfileChunk(stream, metadata);
+ this.WritePaletteChunk(stream, quantized);
+ this.WriteTransparencyChunk(stream, pngMetadata);
+ this.WritePhysicalChunk(stream, metadata);
+ this.WriteExifChunk(stream, metadata);
+ this.WriteXmpChunk(stream, metadata);
+ this.WriteTextChunks(stream, pngMetadata);
+
+ if (image.Frames.Count > 1)
{
- this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
+ this.WriteAnimationControlChunk(
+ stream,
+ (uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)),
+ pngMetadata.RepeatCount);
}
- else
+
+ // If the first frame isn't animated, write it as usual and skip it when writing animated frames
+ if (!pngMetadata.AnimateRootFrame || image.Frames.Count == 1)
{
- sequenceNumber += this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, true);
+ cancellationToken.ThrowIfCancellationRequested();
+ FrameControl frameControl = new((uint)this.width, (uint)this.height);
+ this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, false);
+ currentFrameIndex++;
}
- currentFrameIndex++;
+ if (image.Frames.Count > 1)
+ {
+ // Write the first animated frame.
+ currentFrame = image.Frames[currentFrameIndex];
+ currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
- // Capture the global palette for reuse on subsequent frames.
- ReadOnlyMemory? previousPalette = quantized?.Palette.ToArray();
+ PngFrameMetadata frameMetadata = GetPngFrameMetadata(currentFrame);
+ PngDisposalMethod previousDisposal = frameMetadata.DisposalMethod;
+ FrameControl frameControl = this.WriteFrameControlChunk(stream, frameMetadata, currentFrame.Bounds(), 0);
+ uint sequenceNumber = 1;
+ if (pngMetadata.AnimateRootFrame)
+ {
+ this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, false);
+ }
+ else
+ {
+ sequenceNumber += this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, true);
+ }
- // Write following frames.
- ImageFrame previousFrame = image.Frames.RootFrame;
+ currentFrameIndex++;
- // This frame is reused to store de-duplicated pixel buffers.
- using ImageFrame encodingFrame = new(image.Configuration, previousFrame.Size());
+ // Capture the global palette for reuse on subsequent frames.
+ ReadOnlyMemory previousPalette = quantized?.Palette.ToArray();
- for (; currentFrameIndex < image.Frames.Count; currentFrameIndex++)
- {
- ImageFrame? prev = previousDisposal == PngDisposalMethod.RestoreToBackground ? null : previousFrame;
- currentFrame = image.Frames[currentFrameIndex];
- ImageFrame? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;
+ if (!previousPalette.IsEmpty)
+ {
+ // Use the previously derived global palette and a shared quantizer to
+ // quantize the subsequent frames. This allows us to cache the color matching resolution.
+ paletteQuantizer ??= new(
+ this.configuration,
+ this.quantizer!.Options,
+ previousPalette,
+ this.derivedTransparencyIndex);
+ }
- frameMetadata = GetPngFrameMetadata(currentFrame);
- bool blend = frameMetadata.BlendMethod == PngBlendMethod.Over;
+ // Write following frames.
+ ImageFrame previousFrame = image.Frames.RootFrame;
- (bool difference, Rectangle bounds) =
- AnimationUtilities.DeDuplicatePixels(
- image.Configuration,
- prev,
- currentFrame,
- nextFrame,
- encodingFrame,
- Color.Transparent,
- blend);
+ // This frame is reused to store de-duplicated pixel buffers.
+ using ImageFrame encodingFrame = new(image.Configuration, previousFrame.Size());
- if (clearTransparency)
+ for (; currentFrameIndex < image.Frames.Count; currentFrameIndex++)
{
- ClearTransparentPixels(encodingFrame);
- }
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ImageFrame? prev = previousDisposal == PngDisposalMethod.RestoreToBackground ? null : previousFrame;
+ currentFrame = image.Frames[currentFrameIndex];
+ ImageFrame? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;
+
+ frameMetadata = GetPngFrameMetadata(currentFrame);
+ bool blend = frameMetadata.BlendMethod == PngBlendMethod.Over;
+ Color background = frameMetadata.DisposalMethod == PngDisposalMethod.RestoreToBackground
+ ? this.backgroundColor.Value
+ : Color.Transparent;
+
+ (bool difference, Rectangle bounds) =
+ AnimationUtilities.DeDuplicatePixels(
+ image.Configuration,
+ prev,
+ currentFrame,
+ nextFrame,
+ encodingFrame,
+ background,
+ blend);
+
+ Buffer2DRegion encodingFrameRegion = encodingFrame.PixelBuffer.GetRegion(bounds);
+ if (clearTransparency)
+ {
+ ClearTransparentPixels(in encodingFrameRegion, background);
+ }
- // Each frame control sequence number must be incremented by the number of frame data chunks that follow.
- frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, sequenceNumber);
+ // Each frame control sequence number must be incremented by the number of frame data chunks that follow.
+ frameControl = this.WriteFrameControlChunk(stream, frameMetadata, bounds, sequenceNumber);
- // Dispose of previous quantized frame and reassign.
- quantized?.Dispose();
- quantized = this.CreateQuantizedImageAndUpdateBitDepth(pngMetadata, encodingFrame, bounds, previousPalette);
- sequenceNumber += this.WriteDataChunks(frameControl, encodingFrame.PixelBuffer.GetRegion(bounds), quantized, stream, true) + 1;
+ // Dispose of previous quantized frame and reassign.
+ quantized?.Dispose();
- previousFrame = currentFrame;
- previousDisposal = frameMetadata.DisposalMethod;
- }
- }
+ quantized = this.CreateQuantizedFrame(
+ this.encoder,
+ this.colorType,
+ this.bitDepth,
+ pngMetadata,
+ image,
+ encodingFrame,
+ bounds,
+ paletteQuantizer,
+ default);
- this.WriteEndChunk(stream);
+ sequenceNumber += this.WriteDataChunks(frameControl, in encodingFrameRegion, quantized, stream, true) + 1;
- stream.Flush();
+ previousFrame = currentFrame;
+ previousDisposal = frameMetadata.DisposalMethod;
+ }
+ }
- // Dispose of allocations from final frame.
- clonedFrame?.Dispose();
- quantized?.Dispose();
+ this.WriteEndChunk(stream);
+
+ stream.Flush();
+ }
+ finally
+ {
+ // Dispose of allocations from final frame.
+ clonedFrame?.Dispose();
+ quantized?.Dispose();
+ paletteQuantizer?.Dispose();
+ }
}
///
@@ -293,7 +348,9 @@ private static PngMetadata GetPngMetadata(Image image)
if (image.Metadata.TryGetGifMetadata(out GifMetadata? gif))
{
AnimatedImageMetadata ani = gif.ToAnimatedImageMetadata();
- return PngMetadata.FromAnimatedMetadata(ani);
+ PngMetadata metadata = PngMetadata.FromAnimatedMetadata(ani);
+ metadata.ColorType = PngColorType.Palette;
+ return metadata;
}
if (image.Metadata.TryGetWebpMetadata(out WebpMetadata? webp))
@@ -335,45 +392,62 @@ private static PngFrameMetadata GetPngFrameMetadata(ImageFrame f
///
/// The type of the pixel.
/// The cloned image frame where the transparent pixels will be changed.
- private static void ClearTransparentPixels(ImageFrame clone)
+ /// The color to change transparent pixels to.
+ private static void ClearTransparentPixels(in Buffer2DRegion clone, Color color)
where TPixel : unmanaged, IPixel
- => clone.ProcessPixelRows(accessor =>
+ {
+ Rgba32 rgba32 = default;
+ Rgba32 transparent = color;
+ for (int y = 0; y < clone.Height; y++)
{
- // TODO: We should be able to speed this up with SIMD and masking.
- Rgba32 rgba32 = default;
- Rgba32 transparent = Color.Transparent;
- for (int y = 0; y < accessor.Height; y++)
+ Span row = clone.DangerousGetRowSpan(y);
+ for (int x = 0; x < row.Length; x++)
{
- Span span = accessor.GetRowSpan(y);
- for (int x = 0; x < accessor.Width; x++)
- {
- span[x].ToRgba32(ref rgba32);
+ ref TPixel pixel = ref row[x];
+ pixel.ToRgba32(ref rgba32);
- if (rgba32.A is 0)
- {
- span[x].FromRgba32(transparent);
- }
+ if (rgba32.A is 0)
+ {
+ pixel.FromRgba32(transparent);
}
}
- });
+ }
+ }
///
/// Creates the quantized image and calculates and sets the bit depth.
///
/// The type of the pixel.
/// The image metadata.
- /// The frame to quantize.
+ /// The image.
+ /// The current image frame.
/// The area of interest within the frame.
- /// Any previously derived palette.
+ /// The quantizer containing any previously derived palette.
/// The quantized image.
private IndexedImageFrame? CreateQuantizedImageAndUpdateBitDepth(
PngMetadata metadata,
+ Image image,
ImageFrame frame,
Rectangle bounds,
- ReadOnlyMemory? previousPalette)
+ PaletteQuantizer? paletteQuantizer)
where TPixel : unmanaged, IPixel
{
- IndexedImageFrame? quantized = this.CreateQuantizedFrame(this.encoder, this.colorType, this.bitDepth, metadata, frame, bounds, previousPalette);
+ PngFrameMetadata frameMetadata = GetPngFrameMetadata(frame);
+ Color background = frameMetadata.DisposalMethod == PngDisposalMethod.RestoreToBackground
+ ? this.backgroundColor ?? Color.Transparent
+ : Color.Transparent;
+
+ IndexedImageFrame? quantized = this.CreateQuantizedFrame(
+ this.encoder,
+ this.colorType,
+ this.bitDepth,
+ metadata,
+ image,
+ frame,
+ bounds,
+ paletteQuantizer,
+ background);
+
this.bitDepth = CalculateBitDepth(this.colorType, this.bitDepth, quantized);
return quantized;
}
@@ -876,6 +950,7 @@ private void WriteXmpChunk(Stream stream, ImageMetadata meta)
///
/// The containing image data.
/// The image meta data.
+ /// CICP matrix coefficients other than Identity are not supported in PNG.
private void WriteCicpChunk(Stream stream, ImageMetadata metaData)
{
if (metaData.CicpProfile is null)
@@ -1139,7 +1214,7 @@ private FrameControl WriteFrameControlChunk(Stream stream, PngFrameMetadata fram
/// The quantized pixel data. Can be null.
/// The stream.
/// Is writing fdAT or IDAT.
- private uint WriteDataChunks(FrameControl frameControl, Buffer2DRegion frame, IndexedImageFrame? quantized, Stream stream, bool isFrame)
+ private uint WriteDataChunks(in FrameControl frameControl, in Buffer2DRegion frame, IndexedImageFrame? quantized, Stream stream, bool isFrame)
where TPixel : unmanaged, IPixel
{
byte[] buffer;
@@ -1157,12 +1232,12 @@ private uint WriteDataChunks(FrameControl frameControl, Buffer2DRegionThe image frame pixel buffer.
/// The quantized pixels.
/// The deflate stream.
- private void EncodePixels(Buffer2DRegion pixels, IndexedImageFrame? quantized, ZlibDeflateStream deflateStream)
+ private void EncodePixels(in Buffer2DRegion pixels, IndexedImageFrame? quantized, ZlibDeflateStream deflateStream)
where TPixel : unmanaged, IPixel
{
int bytesPerScanline = this.CalculateScanlineLength(pixels.Width);
@@ -1244,7 +1319,8 @@ private void EncodePixels(Buffer2DRegion pixels, IndexedImageFra
Span attempt = attemptBuffer.GetSpan();
for (int y = 0; y < pixels.Height; y++)
{
- this.CollectAndFilterPixelRow(pixels.DangerousGetRowSpan(y), ref filter, ref attempt, quantized, y);
+ ReadOnlySpan rowSpan = pixels.DangerousGetRowSpan(y);
+ this.CollectAndFilterPixelRow(rowSpan, ref filter, ref attempt, quantized, y);
deflateStream.Write(filter);
this.SwapScanlineBuffers();
}
@@ -1256,7 +1332,7 @@ private void EncodePixels(Buffer2DRegion pixels, IndexedImageFra
/// The type of the pixel.
/// The image frame pixel buffer.
/// The deflate stream.
- private void EncodeAdam7Pixels(Buffer2DRegion pixels, ZlibDeflateStream deflateStream)
+ private void EncodeAdam7Pixels(in Buffer2DRegion pixels, ZlibDeflateStream deflateStream)
where TPixel : unmanaged, IPixel
{
for (int pass = 0; pass < 7; pass++)
@@ -1292,7 +1368,7 @@ private void EncodeAdam7Pixels(Buffer2DRegion pixels, ZlibDeflat
// Encode data
// Note: quantized parameter not used
// Note: row parameter not used
- this.CollectAndFilterPixelRow(block, ref filter, ref attempt, null, -1);
+ this.CollectAndFilterPixelRow((ReadOnlySpan)block, ref filter, ref attempt, null, -1);
deflateStream.Write(filter);
this.SwapScanlineBuffers();
@@ -1464,6 +1540,7 @@ private void SwapScanlineBuffers()
/// The PNG metadata.
/// if set to true [use16 bit].
/// The bytes per pixel.
+ [MemberNotNull(nameof(backgroundColor))]
private void SanitizeAndSetEncoderOptions(
PngEncoder encoder,
PngMetadata pngMetadata,
@@ -1502,6 +1579,7 @@ private void SanitizeAndSetEncoderOptions(
this.interlaceMode = (encoder.InterlaceMethod ?? pngMetadata.InterlaceMethod)!.Value;
this.chunkFilter = encoder.SkipMetadata ? PngChunkFilter.ExcludeAll : encoder.ChunkFilter ?? PngChunkFilter.None;
+ this.backgroundColor = pngMetadata.TransparentColor ?? Color.Transparent;
}
///
@@ -1512,17 +1590,21 @@ private void SanitizeAndSetEncoderOptions(
/// The color type.
/// The bits per component.
/// The image metadata.
- /// The frame to quantize.
+ /// The image.
+ /// The current image frame.
/// The frame area of interest.
- /// Any previously derived palette.
+ /// The quantizer containing any previously derived palette.
+ /// The background color.
private IndexedImageFrame? CreateQuantizedFrame(
- QuantizingImageEncoder encoder,
+ PngEncoder encoder,
PngColorType colorType,
byte bitDepth,
PngMetadata metadata,
+ Image image,
ImageFrame frame,
Rectangle bounds,
- ReadOnlyMemory? previousPalette)
+ PaletteQuantizer? paletteQuantizer,
+ Color backgroundColor)
where TPixel : unmanaged, IPixel
{
if (colorType is not PngColorType.Palette)
@@ -1530,22 +1612,15 @@ private void SanitizeAndSetEncoderOptions(
return null;
}
- if (previousPalette is not null)
+ if (paletteQuantizer.HasValue)
{
- // Use the previously derived palette created by quantizing the root frame to quantize the current frame.
- using PaletteQuantizer paletteQuantizer = new(
- this.configuration,
- this.quantizer!.Options,
- previousPalette.Value,
- this.derivedTransparencyIndex);
- paletteQuantizer.BuildPalette(encoder.PixelSamplingStrategy, frame);
- return paletteQuantizer.QuantizeFrame(frame, bounds);
+ return paletteQuantizer.Value.QuantizeFrame(frame, bounds);
}
// Use the metadata to determine what quantization depth to use if no quantizer has been set.
if (this.quantizer is null)
{
- if (metadata.ColorTable is not null)
+ if (metadata.ColorTable.HasValue && !metadata.ColorTable.Value.IsEmpty)
{
// We can use the color data from the decoded metadata here.
// We avoid dithering by default to preserve the original colors.
@@ -1578,7 +1653,37 @@ private void SanitizeAndSetEncoderOptions(
// Create quantized frame returning the palette and set the bit depth.
using IQuantizer frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer(frame.Configuration);
- frameQuantizer.BuildPalette(encoder.PixelSamplingStrategy, frame);
+ if (image.Frames.Count > 1)
+ {
+ // Encoding animated frames with a global palette requires a transparent pixel in the palette
+ // since we only encode the delta between frames. To ensure that we have a transparent pixel
+ // we create a fake frame with a containing only transparent pixels and add it to the palette.
+ using Buffer2D px = image.Configuration.MemoryAllocator.Allocate2D(Math.Min(256, image.Width), Math.Min(256, image.Height));
+ TPixel backGroundPixel = backgroundColor.ToPixel();
+ for (int i = 0; i < px.Height; i++)
+ {
+ px.DangerousGetRowSpan(i).Fill(backGroundPixel);
+ }
+
+ frameQuantizer.AddPaletteColors(px.GetRegion());
+ }
+
+ if (encoder.TransparentColorMode == PngTransparentColorMode.Clear)
+ {
+ foreach (Buffer2DRegion region in encoder.PixelSamplingStrategy.EnumeratePixelRegions(image))
+ {
+ using Buffer2D clone = region.Buffer.CloneRegion(this.configuration, region.Rectangle);
+ Buffer2DRegion clonedRegion = clone.GetRegion();
+
+ ClearTransparentPixels(in clonedRegion, backgroundColor);
+ frameQuantizer.AddPaletteColors(clonedRegion);
+ }
+ }
+ else
+ {
+ frameQuantizer.BuildPalette(encoder.PixelSamplingStrategy, image);
+ }
+
return frameQuantizer.QuantizeFrame(frame, bounds);
}
diff --git a/src/ImageSharp/Formats/Png/PngMetadata.cs b/src/ImageSharp/Formats/Png/PngMetadata.cs
index d9028dd807..296465b773 100644
--- a/src/ImageSharp/Formats/Png/PngMetadata.cs
+++ b/src/ImageSharp/Formats/Png/PngMetadata.cs
@@ -93,32 +93,12 @@ private PngMetadata(PngMetadata other)
public IDeepCloneable DeepClone() => new PngMetadata(this);
internal static PngMetadata FromAnimatedMetadata(AnimatedImageMetadata metadata)
- {
- // Should the conversion be from a format that uses a 24bit palette entries (gif)
- // we need to clone and adjust the color table to allow for transparency.
- Color[]? colorTable = metadata.ColorTable.HasValue ? metadata.ColorTable.Value.ToArray() : null;
- if (colorTable != null)
+ => new()
{
- for (int i = 0; i < colorTable.Length; i++)
- {
- ref Color c = ref colorTable[i];
- if (c == metadata.BackgroundColor)
- {
- // Png treats background as fully empty
- c = Color.Transparent;
- break;
- }
- }
- }
-
- return new()
- {
- ColorType = colorTable != null ? PngColorType.Palette : null,
- BitDepth = colorTable != null
- ? (PngBitDepth)Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(colorTable.Length), 1, 8)
- : null,
- ColorTable = colorTable,
+ // Do not copy the color table or bit depth.
+ // This will lead to a mismatch when the image is comprised of frames
+ // extracted individually from a multi-frame image.
+ ColorType = metadata.ColorTable != null ? PngColorType.Palette : null,
RepeatCount = metadata.RepeatCount,
};
- }
}
diff --git a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
index 83f9e797ab..2b843cc8f6 100644
--- a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
+++ b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
@@ -32,7 +32,7 @@ protected BitReaderBase(Stream inputStream, int imageDataSize, MemoryAllocator m
/// Used for allocating memory during reading data from the stream.
protected static IMemoryOwner ReadImageDataFromStream(Stream input, int bytesToRead, MemoryAllocator memoryAllocator)
{
- IMemoryOwner data = memoryAllocator.Allocate(bytesToRead);
+ IMemoryOwner data = memoryAllocator.Allocate(bytesToRead, AllocationOptions.Clean);
Span dataSpan = data.Memory.Span;
input.Read(dataSpan[..bytesToRead], 0, bytesToRead);
diff --git a/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs b/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
index b3c5bfaf41..8e0c986a14 100644
--- a/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
+++ b/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
@@ -74,7 +74,7 @@ public Vp8Decoder(Vp8FrameHeader frameHeader, Vp8PictureHeader pictureHeader, Vp
this.TmpYBuffer = memoryAllocator.Allocate((int)width);
this.TmpUBuffer = memoryAllocator.Allocate((int)width);
this.TmpVBuffer = memoryAllocator.Allocate((int)width);
- this.Pixels = memoryAllocator.Allocate((int)(width * height * 4));
+ this.Pixels = memoryAllocator.Allocate((int)(width * height * 4), AllocationOptions.Clean);
#if DEBUG
// Filling those buffers with 205, is only useful for debugging,
diff --git a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
index 65f1a4da46..b9f58c3d84 100644
--- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
+++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
@@ -81,16 +81,29 @@ public WebpAnimationDecoder(MemoryAllocator memoryAllocator, Configuration confi
/// The width of the image.
/// The height of the image.
/// The size of the image data in bytes.
- public Image Decode(BufferedReadStream stream, WebpFeatures features, uint width, uint height, uint completeDataSize)
+ public Image Decode(
+ BufferedReadStream stream,
+ WebpFeatures features,
+ uint width,
+ uint height,
+ uint completeDataSize)
where TPixel : unmanaged, IPixel
{
Image? image = null;
ImageFrame? previousFrame = null;
+ WebpFrameData? prevFrameData = null;
this.metadata = new ImageMetadata();
this.webpMetadata = this.metadata.GetWebpMetadata();
this.webpMetadata.RepeatCount = features.AnimationLoopCount;
+ Color backgroundColor = this.backgroundColorHandling == BackgroundColorHandling.Ignore
+ ? Color.Transparent
+ : features.AnimationBackgroundColor!.Value;
+
+ this.webpMetadata.BackgroundColor = backgroundColor;
+ TPixel backgroundPixel = backgroundColor.ToPixel();
+
Span buffer = stackalloc byte[4];
uint frameCount = 0;
int remainingBytes = (int)completeDataSize;
@@ -101,10 +114,15 @@ public Image Decode(BufferedReadStream stream, WebpFeatures feat
switch (chunkType)
{
case WebpChunkType.FrameData:
- Color backgroundColor = this.backgroundColorHandling == BackgroundColorHandling.Ignore
- ? new Color(new Bgra32(0, 0, 0, 0))
- : features.AnimationBackgroundColor!.Value;
- uint dataSize = this.ReadFrame(stream, ref image, ref previousFrame, width, height, backgroundColor);
+ uint dataSize = this.ReadFrame(
+ stream,
+ ref image,
+ ref previousFrame,
+ ref prevFrameData,
+ width,
+ height,
+ backgroundPixel);
+
remainingBytes -= (int)dataSize;
break;
case WebpChunkType.Xmp:
@@ -132,10 +150,18 @@ public Image Decode(BufferedReadStream stream, WebpFeatures feat
/// The stream, where the image should be decoded from. Cannot be null.
/// The image to decode the information to.
/// The previous frame.
+ /// The previous frame data.
/// The width of the image.
/// The height of the image.
/// The default background color of the canvas in.
- private uint ReadFrame(BufferedReadStream stream, ref Image? image, ref ImageFrame? previousFrame, uint width, uint height, Color backgroundColor)
+ private uint ReadFrame(
+ BufferedReadStream stream,
+ ref Image? image,
+ ref ImageFrame? previousFrame,
+ ref WebpFrameData? prevFrameData,
+ uint width,
+ uint height,
+ TPixel backgroundColor)
where TPixel : unmanaged, IPixel
{
WebpFrameData frameData = WebpFrameData.Parse(stream);
@@ -174,40 +200,51 @@ private uint ReadFrame(BufferedReadStream stream, ref Image? ima
break;
}
- ImageFrame? currentFrame = null;
- ImageFrame imageFrame;
+ ImageFrame currentFrame;
if (previousFrame is null)
{
- image = new Image(this.configuration, (int)width, (int)height, backgroundColor.ToPixel(), this.metadata);
+ image = new Image(this.configuration, (int)width, (int)height, backgroundColor, this.metadata);
- SetFrameMetadata(image.Frames.RootFrame.Metadata, frameData);
-
- imageFrame = image.Frames.RootFrame;
+ currentFrame = image.Frames.RootFrame;
+ SetFrameMetadata(currentFrame.Metadata, frameData);
}
else
{
- currentFrame = image!.Frames.AddFrame(previousFrame); // This clones the frame and adds it the collection.
+ // If the frame is a key frame we do not need to clone the frame or clear it.
+ bool isKeyFrame = prevFrameData?.DisposalMethod is WebpDisposalMethod.RestoreToBackground
+ && this.restoreArea == image!.Bounds;
- SetFrameMetadata(currentFrame.Metadata, frameData);
+ if (isKeyFrame)
+ {
+ currentFrame = image!.Frames.CreateFrame(backgroundColor);
+ }
+ else
+ {
+ // This clones the frame and adds it the collection.
+ currentFrame = image!.Frames.AddFrame(previousFrame);
+ if (prevFrameData?.DisposalMethod is WebpDisposalMethod.RestoreToBackground)
+ {
+ this.RestoreToBackground(currentFrame, backgroundColor);
+ }
+ }
- imageFrame = currentFrame;
+ SetFrameMetadata(currentFrame.Metadata, frameData);
}
- Rectangle regionRectangle = frameData.Bounds;
+ Rectangle interest = frameData.Bounds;
+ bool blend = previousFrame != null && frameData.BlendingMethod == WebpBlendMethod.Over;
+ using Buffer2D pixelData = this.DecodeImageFrameData(frameData, webpInfo);
+ DrawDecodedImageFrameOnCanvas(pixelData, currentFrame, interest, blend);
+
+ webpInfo?.Dispose();
+ previousFrame = currentFrame;
+ prevFrameData = frameData;
if (frameData.DisposalMethod is WebpDisposalMethod.RestoreToBackground)
{
- this.RestoreToBackground(imageFrame, backgroundColor);
+ this.restoreArea = interest;
}
- using Buffer2D decodedImageFrame = this.DecodeImageFrameData(frameData, webpInfo);
-
- bool blend = previousFrame != null && frameData.BlendingMethod == WebpBlendMethod.Over;
- DrawDecodedImageFrameOnCanvas(decodedImageFrame, imageFrame, regionRectangle, blend);
-
- previousFrame = currentFrame ?? image.Frames.RootFrame;
- this.restoreArea = regionRectangle;
-
return (uint)(stream.Position - streamStartPosition);
}
@@ -257,31 +294,26 @@ private Buffer2D DecodeImageFrameData(WebpFrameData frameData, W
try
{
- Buffer2D pixelBufferDecoded = decodedFrame.PixelBuffer;
+ Buffer2D decodeBuffer = decodedFrame.PixelBuffer;
if (webpInfo.IsLossless)
{
- WebpLosslessDecoder losslessDecoder =
- new(webpInfo.Vp8LBitReader, this.memoryAllocator, this.configuration);
- losslessDecoder.Decode(pixelBufferDecoded, (int)webpInfo.Width, (int)webpInfo.Height);
+ WebpLosslessDecoder losslessDecoder = new(webpInfo.Vp8LBitReader, this.memoryAllocator, this.configuration);
+ losslessDecoder.Decode(decodeBuffer, (int)frameData.Width, (int)frameData.Height);
}
else
{
WebpLossyDecoder lossyDecoder =
new(webpInfo.Vp8BitReader, this.memoryAllocator, this.configuration);
- lossyDecoder.Decode(pixelBufferDecoded, (int)webpInfo.Width, (int)webpInfo.Height, webpInfo, this.alphaData);
+ lossyDecoder.Decode(decodeBuffer, (int)frameData.Width, (int)frameData.Height, webpInfo, this.alphaData);
}
- return pixelBufferDecoded;
+ return decodeBuffer;
}
catch
{
decodedFrame?.Dispose();
throw;
}
- finally
- {
- webpInfo.Dispose();
- }
}
///
@@ -290,17 +322,17 @@ private Buffer2D DecodeImageFrameData(WebpFrameData frameData, W
/// The type of the pixel.
/// The decoded image.
/// The image frame to draw into.
- /// The area of the frame.
+ /// The area of the frame to draw to.
/// Whether to blend the decoded frame data onto the target frame.
private static void DrawDecodedImageFrameOnCanvas(
Buffer2D decodedImageFrame,
ImageFrame imageFrame,
- Rectangle restoreArea,
+ Rectangle interest,
bool blend)
where TPixel : unmanaged, IPixel
{
// Trim the destination frame to match the restore area. The source frame is already trimmed.
- Buffer2DRegion imageFramePixels = imageFrame.PixelBuffer.GetRegion(restoreArea);
+ Buffer2DRegion imageFramePixels = imageFrame.PixelBuffer.GetRegion(interest);
if (blend)
{
// The destination frame has already been prepopulated with the pixel data from the previous frame
@@ -309,10 +341,10 @@ private static void DrawDecodedImageFrameOnCanvas(
PixelBlender blender =
PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver);
- for (int y = 0; y < restoreArea.Height; y++)
+ for (int y = 0; y < interest.Height; y++)
{
Span framePixelRow = imageFramePixels.DangerousGetRowSpan(y);
- Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y)[..restoreArea.Width];
+ Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y);
blender.Blend(imageFrame.Configuration, framePixelRow, framePixelRow, decodedPixelRow, 1f);
}
@@ -320,10 +352,10 @@ private static void DrawDecodedImageFrameOnCanvas(
return;
}
- for (int y = 0; y < restoreArea.Height; y++)
+ for (int y = 0; y < interest.Height; y++)
{
Span framePixelRow = imageFramePixels.DangerousGetRowSpan(y);
- Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y)[..restoreArea.Width];
+ Span decodedPixelRow = decodedImageFrame.DangerousGetRowSpan(y);
decodedPixelRow.CopyTo(framePixelRow);
}
}
@@ -335,7 +367,7 @@ private static void DrawDecodedImageFrameOnCanvas(
/// The pixel format.
/// The image frame.
/// Color of the background.
- private void RestoreToBackground(ImageFrame imageFrame, Color backgroundColor)
+ private void RestoreToBackground(ImageFrame imageFrame, TPixel backgroundColor)
where TPixel : unmanaged, IPixel
{
if (!this.restoreArea.HasValue)
@@ -345,8 +377,9 @@ private void RestoreToBackground(ImageFrame imageFrame, Color ba
Rectangle interest = Rectangle.Intersect(imageFrame.Bounds(), this.restoreArea.Value);
Buffer2DRegion pixelRegion = imageFrame.PixelBuffer.GetRegion(interest);
- TPixel backgroundPixel = backgroundColor.ToPixel();
- pixelRegion.Fill(backgroundPixel);
+ pixelRegion.Fill(backgroundColor);
+
+ this.restoreArea = null;
}
///
diff --git a/src/ImageSharp/Formats/Webp/WebpBlendMethod.cs b/src/ImageSharp/Formats/Webp/WebpBlendMethod.cs
index f16f7650c7..6961eb8541 100644
--- a/src/ImageSharp/Formats/Webp/WebpBlendMethod.cs
+++ b/src/ImageSharp/Formats/Webp/WebpBlendMethod.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Six Labors.
+// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Formats.Webp;
@@ -9,14 +9,14 @@ namespace SixLabors.ImageSharp.Formats.Webp;
public enum WebpBlendMethod
{
///
- /// Do not blend. After disposing of the previous frame,
- /// render the current frame on the canvas by overwriting the rectangle covered by the current frame.
+ /// Use alpha blending. After disposing of the previous frame, render the current frame on the canvas using alpha-blending.
+ /// If the current frame does not have an alpha channel, assume alpha value of 255, effectively replacing the rectangle.
///
- Source = 0,
+ Over = 0,
///
- /// Use alpha blending. After disposing of the previous frame, render the current frame on the canvas using alpha-blending.
- /// If the current frame does not have an alpha channel, assume alpha value of 255, effectively replacing the rectangle.
+ /// Do not blend. After disposing of the previous frame,
+ /// render the current frame on the canvas by overwriting the rectangle covered by the current frame.
///
- Over = 1,
+ Source = 1,
}
diff --git a/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs b/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
index 49482260bb..69eeab1204 100644
--- a/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
+++ b/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
@@ -68,7 +68,7 @@ public static WebpFrameMetadata GetWebpFrameMetadata(ImageFrame
///
/// The row to check.
/// Returns true if alpha has non-0xff values.
- public static unsafe bool CheckNonOpaque(Span row)
+ public static unsafe bool CheckNonOpaque(ReadOnlySpan row)
{
if (Avx2.IsSupported)
{
diff --git a/src/ImageSharp/ImageSharp.csproj b/src/ImageSharp/ImageSharp.csproj
index b08c27c41b..24b880a770 100644
--- a/src/ImageSharp/ImageSharp.csproj
+++ b/src/ImageSharp/ImageSharp.csproj
@@ -13,6 +13,13 @@
Image Resize Crop Gif Jpg Jpeg Bitmap Pbm Png Tga Tiff WebP NetCore
A new, fully featured, fully managed, cross-platform, 2D graphics API for .NET
Debug;Release
+
+
+ True
+ False
diff --git a/src/ImageSharp/IndexedImageFrame{TPixel}.cs b/src/ImageSharp/IndexedImageFrame{TPixel}.cs
index 6807e77ad2..49c9e33eb1 100644
--- a/src/ImageSharp/IndexedImageFrame{TPixel}.cs
+++ b/src/ImageSharp/IndexedImageFrame{TPixel}.cs
@@ -30,7 +30,7 @@ public sealed class IndexedImageFrame : IPixelSource, IDisposable
/// The frame width.
/// The frame height.
/// The color palette.
- internal IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory palette)
+ public IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory palette)
{
Guard.NotNull(configuration, nameof(configuration));
Guard.MustBeLessThanOrEqualTo(palette.Length, QuantizerConstants.MaxColors, nameof(palette));
@@ -42,7 +42,7 @@ internal IndexedImageFrame(Configuration configuration, int width, int height, R
this.Height = height;
this.pixelBuffer = configuration.MemoryAllocator.Allocate2D(width, height);
- // Copy the palette over. We want the lifetime of this frame to be independant of any palette source.
+ // Copy the palette over. We want the lifetime of this frame to be independent of any palette source.
this.paletteOwner = configuration.MemoryAllocator.Allocate(palette.Length);
palette.Span.CopyTo(this.paletteOwner.GetSpan());
this.Palette = this.paletteOwner.Memory[..palette.Length];
diff --git a/src/ImageSharp/Memory/Buffer2DExtensions.cs b/src/ImageSharp/Memory/Buffer2DExtensions.cs
index 2eb05ea935..ffddfcbd0e 100644
--- a/src/ImageSharp/Memory/Buffer2DExtensions.cs
+++ b/src/ImageSharp/Memory/Buffer2DExtensions.cs
@@ -25,6 +25,39 @@ public static IMemoryGroup GetMemoryGroup(this Buffer2D buffer)
return buffer.FastMemoryGroup.View;
}
+ ///
+ /// Performs a deep clone of the buffer covering the specified .
+ ///
+ /// The element type.
+ /// The source buffer.
+ /// The configuration.
+ /// The rectangle to clone.
+ /// The .
+ internal static Buffer2D CloneRegion(this Buffer2D source, Configuration configuration, Rectangle rectangle)
+ where T : unmanaged
+ {
+ Buffer2D buffer = configuration.MemoryAllocator.Allocate2D(
+ rectangle.Width,
+ rectangle.Height,
+ configuration.PreferContiguousImageBuffers);
+
+ // Optimization for when the size of the area is the same as the buffer size.
+ Buffer2DRegion sourceRegion = source.GetRegion(rectangle);
+ if (sourceRegion.IsFullBufferArea)
+ {
+ sourceRegion.Buffer.FastMemoryGroup.CopyTo(buffer.FastMemoryGroup);
+ }
+ else
+ {
+ for (int y = 0; y < rectangle.Height; y++)
+ {
+ sourceRegion.DangerousGetRowSpan(y).CopyTo(buffer.DangerousGetRowSpan(y));
+ }
+ }
+
+ return buffer;
+ }
+
///
/// TODO: Does not work with multi-buffer groups, should be specific to Resize.
/// Copy columns of inplace,
diff --git a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
index 982cc7d46c..067ae69fdf 100644
--- a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
@@ -80,7 +80,7 @@ protected override void Dispose(bool disposing)
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
internal readonly struct DitherProcessor : IPaletteDitherImageProcessor, IDisposable
{
- private readonly EuclideanPixelMap pixelMap;
+ private readonly PixelMap pixelMap;
[MethodImpl(InliningOptions.ShortMethod)]
public DitherProcessor(
@@ -89,7 +89,7 @@ public DitherProcessor(
float ditherScale)
{
this.Configuration = configuration;
- this.pixelMap = new EuclideanPixelMap(configuration, palette);
+ this.pixelMap = PixelMapFactory.Create(configuration, palette, ColorMatchingMode.Hybrid);
this.Palette = palette;
this.DitherScale = ditherScale;
}
@@ -103,7 +103,7 @@ public DitherProcessor(
[MethodImpl(InliningOptions.ShortMethod)]
public TPixel GetPaletteColor(TPixel color)
{
- this.pixelMap.GetClosestColor(color, out TPixel match);
+ _ = this.pixelMap.GetClosestColor(color, out TPixel match);
return match;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
new file mode 100644
index 0000000000..26fd7d5d76
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Defines the precision level used when matching colors during quantization.
+///
+public enum ColorMatchingMode
+{
+ ///
+ /// Uses a coarse caching strategy optimized for performance at the expense of exact matches.
+ /// This provides the fastest matching but may yield approximate results.
+ ///
+ Coarse,
+
+ ///
+ /// Enables an exact color match cache for the first 512 unique colors encountered,
+ /// falling back to coarse matching thereafter.
+ ///
+ Hybrid,
+
+ ///
+ /// Performs exact color matching without any caching optimizations.
+ /// This is the slowest but most accurate matching strategy.
+ ///
+ Exact
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
new file mode 100644
index 0000000000..a900d643bd
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
@@ -0,0 +1,219 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Gets the closest color to the supplied color based upon the Euclidean distance.
+///
+/// The pixel format.
+/// The cache type.
+///
+/// This class is not thread safe and should not be accessed in parallel.
+/// Doing so will result in non-idempotent results.
+///
+internal sealed class EuclideanPixelMap : PixelMap
+ where TPixel : unmanaged, IPixel
+ where TCache : struct, IColorIndexCache
+{
+ private Rgba32[] rgbaPalette;
+ private int transparentIndex;
+ private readonly TPixel transparentMatch;
+
+ // Do not make readonly. It's a mutable struct.
+#pragma warning disable IDE0044 // Add readonly modifier
+ private TCache cache;
+#pragma warning restore IDE0044 // Add readonly modifier
+ private readonly Configuration configuration;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configuration.
+ /// The color palette to map from.
+ /// An explicit index at which to match transparent pixels.
+ [RequiresPreviewFeatures]
+ public EuclideanPixelMap(
+ Configuration configuration,
+ ReadOnlyMemory palette,
+ int transparentIndex = -1)
+ {
+ this.configuration = configuration;
+ this.cache = TCache.Create(configuration.MemoryAllocator);
+
+ this.Palette = palette;
+ this.rgbaPalette = new Rgba32[palette.Length];
+ PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
+
+ this.transparentIndex = transparentIndex;
+ Unsafe.SkipInit(out this.transparentMatch);
+ this.transparentMatch.FromRgba32(default);
+ }
+
+ ///
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public override int GetClosestColor(TPixel color, out TPixel match)
+ {
+ ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
+ Unsafe.SkipInit(out Rgba32 rgba);
+ color.ToRgba32(ref rgba);
+
+ // Check if the color is in the lookup table
+ if (this.cache.TryGetValue(rgba, out short index))
+ {
+ match = Unsafe.Add(ref paletteRef, (ushort)index);
+ return index;
+ }
+
+ return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
+ }
+
+ ///
+ public override void Clear(ReadOnlyMemory palette)
+ {
+ this.Palette = palette;
+ this.rgbaPalette = new Rgba32[palette.Length];
+ PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
+ this.transparentIndex = -1;
+ this.cache.Clear();
+ }
+
+ ///
+ public override void SetTransparentIndex(int index)
+ {
+ if (index != this.transparentIndex)
+ {
+ this.cache.Clear();
+ }
+
+ this.transparentIndex = index;
+ }
+
+ [MethodImpl(InliningOptions.ColdPath)]
+ private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
+ {
+ // Loop through the palette and find the nearest match.
+ int index = 0;
+
+ if (this.transparentIndex >= 0 && rgba == default)
+ {
+ // We have explicit instructions. No need to search.
+ index = this.transparentIndex;
+ _ = this.cache.TryAdd(rgba, (short)index);
+ match = this.transparentMatch;
+ return index;
+ }
+
+ float leastDistance = float.MaxValue;
+ for (int i = 0; i < this.rgbaPalette.Length; i++)
+ {
+ Rgba32 candidate = this.rgbaPalette[i];
+ float distance = DistanceSquared(rgba, candidate);
+
+ // If it's an exact match, exit the loop
+ if (distance == 0)
+ {
+ index = i;
+ break;
+ }
+
+ if (distance < leastDistance)
+ {
+ // Less than... assign.
+ index = i;
+ leastDistance = distance;
+ }
+ }
+
+ // Now I have the index, pop it into the cache for next time
+ _ = this.cache.TryAdd(rgba, (short)index);
+ match = Unsafe.Add(ref paletteRef, (uint)index);
+
+ return index;
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ private static float DistanceSquared(Rgba32 a, Rgba32 b)
+ {
+ float deltaR = a.R - b.R;
+ float deltaG = a.G - b.G;
+ float deltaB = a.B - b.B;
+ float deltaA = a.A - b.A;
+ return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
+ }
+
+ ///
+ public override void Dispose() => this.cache.Dispose();
+}
+
+///
+/// Represents a map of colors to indices.
+///
+/// The pixel format.
+internal abstract class PixelMap : IDisposable
+ where TPixel : unmanaged, IPixel
+{
+ ///
+ /// Gets the color palette of this .
+ ///
+ public ReadOnlyMemory Palette { get; private protected set; }
+
+ ///
+ /// Returns the closest color in the palette and the index of that pixel.
+ ///
+ /// The color to match.
+ /// The matched color.
+ ///
+ /// The index.
+ ///
+ public abstract int GetClosestColor(TPixel color, out TPixel match);
+
+ ///
+ /// Clears the map, resetting it to use the given palette.
+ ///
+ /// The color palette to map from.
+ public abstract void Clear(ReadOnlyMemory palette);
+
+ ///
+ /// Allows setting the transparent index after construction.
+ ///
+ /// An explicit index at which to match transparent pixels.
+ public abstract void SetTransparentIndex(int index);
+
+ ///
+ public abstract void Dispose();
+}
+
+///
+/// A factory for creating instances.
+///
+internal static class PixelMapFactory
+{
+ ///
+ /// Creates a new instance.
+ ///
+ /// The pixel format.
+ /// The configuration.
+ /// The color palette to map from.
+ /// The color matching mode.
+ /// An explicit index at which to match transparent pixels.
+ ///
+ /// The .
+ ///
+ public static PixelMap Create(
+ Configuration configuration,
+ ReadOnlyMemory palette,
+ ColorMatchingMode colorMatchingMode,
+ int transparentIndex = -1)
+ where TPixel : unmanaged, IPixel => colorMatchingMode switch
+ {
+ ColorMatchingMode.Hybrid => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ ColorMatchingMode.Exact => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ _ => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ };
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
deleted file mode 100644
index 72148374aa..0000000000
--- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
+++ /dev/null
@@ -1,260 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-using System.Buffers;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using SixLabors.ImageSharp.Memory;
-using SixLabors.ImageSharp.PixelFormats;
-
-namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
-
-///
-/// Gets the closest color to the supplied color based upon the Euclidean distance.
-///
-/// The pixel format.
-///
-/// This class is not thread safe and should not be accessed in parallel.
-/// Doing so will result in non-idempotent results.
-///
-internal sealed class EuclideanPixelMap : IDisposable
- where TPixel : unmanaged, IPixel
-{
- private Rgba32[] rgbaPalette;
- private int transparentIndex;
- private readonly TPixel transparentMatch;
-
- ///
- /// Do not make this readonly! Struct value would be always copied on non-readonly method calls.
- ///
- private ColorDistanceCache cache;
- private readonly Configuration configuration;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The configuration.
- /// The color palette to map from.
- public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette)
- : this(configuration, palette, -1)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The configuration.
- /// The color palette to map from.
- /// An explicit index at which to match transparent pixels.
- public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette, int transparentIndex = -1)
- {
- this.configuration = configuration;
- this.Palette = palette;
- this.rgbaPalette = new Rgba32[palette.Length];
- this.cache = new ColorDistanceCache(configuration.MemoryAllocator);
- PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
-
- this.transparentIndex = transparentIndex;
- Unsafe.SkipInit(out this.transparentMatch);
- this.transparentMatch.FromRgba32(default);
- }
-
- ///
- /// Gets the color palette of this .
- /// The palette memory is owned by the palette source that created it.
- ///
- public ReadOnlyMemory Palette { get; private set; }
-
- ///
- /// Returns the closest color in the palette and the index of that pixel.
- /// The palette contents must match the one used in the constructor.
- ///
- /// The color to match.
- /// The matched color.
- /// The index.
- [MethodImpl(InliningOptions.ShortMethod)]
- public int GetClosestColor(TPixel color, out TPixel match)
- {
- ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
- Unsafe.SkipInit(out Rgba32 rgba);
- color.ToRgba32(ref rgba);
-
- // Check if the color is in the lookup table
- if (!this.cache.TryGetValue(rgba, out short index))
- {
- return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
- }
-
- match = Unsafe.Add(ref paletteRef, (ushort)index);
- return index;
- }
-
- ///
- /// Clears the map, resetting it to use the given palette.
- ///
- /// The color palette to map from.
- public void Clear(ReadOnlyMemory palette)
- {
- this.Palette = palette;
- this.rgbaPalette = new Rgba32[palette.Length];
- PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
- this.transparentIndex = -1;
- this.cache.Clear();
- }
-
- ///
- /// Allows setting the transparent index after construction.
- ///
- /// An explicit index at which to match transparent pixels.
- public void SetTransparentIndex(int index)
- {
- if (index != this.transparentIndex)
- {
- this.cache.Clear();
- }
-
- this.transparentIndex = index;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
- {
- // Loop through the palette and find the nearest match.
- int index = 0;
-
- if (this.transparentIndex >= 0 && rgba == default)
- {
- // We have explicit instructions. No need to search.
- index = this.transparentIndex;
- this.cache.Add(rgba, (byte)index);
- match = this.transparentMatch;
- return index;
- }
-
- float leastDistance = float.MaxValue;
- for (int i = 0; i < this.rgbaPalette.Length; i++)
- {
- Rgba32 candidate = this.rgbaPalette[i];
- float distance = DistanceSquared(rgba, candidate);
-
- // If it's an exact match, exit the loop
- if (distance == 0)
- {
- index = i;
- break;
- }
-
- if (distance < leastDistance)
- {
- // Less than... assign.
- index = i;
- leastDistance = distance;
- }
- }
-
- // Now I have the index, pop it into the cache for next time
- this.cache.Add(rgba, (byte)index);
- match = Unsafe.Add(ref paletteRef, (uint)index);
- return index;
- }
-
- ///
- /// Returns the Euclidean distance squared between two specified points.
- ///
- /// The first point.
- /// The second point.
- /// The distance squared.
- [MethodImpl(InliningOptions.ShortMethod)]
- private static float DistanceSquared(Rgba32 a, Rgba32 b)
- {
- float deltaR = a.R - b.R;
- float deltaG = a.G - b.G;
- float deltaB = a.B - b.B;
- float deltaA = a.A - b.A;
- return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
- }
-
- public void Dispose() => this.cache.Dispose();
-
- ///
- /// A cache for storing color distance matching results.
- ///
- ///
- ///
- /// The granularity of the cache has been determined based upon the current
- /// suite of test images and provides the lowest possible memory usage while
- /// providing enough match accuracy.
- /// Entry count is currently limited to 2335905 entries (4MB).
- ///
- ///
- private unsafe struct ColorDistanceCache : IDisposable
- {
- 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) + 1;
- private const int IndexGCount = (1 << IndexGBits) + 1;
- private const int IndexBCount = (1 << IndexBBits) + 1;
- private const int IndexACount = (1 << IndexABits) + 1;
- private const int RShift = 8 - IndexRBits;
- private const int GShift = 8 - IndexGBits;
- private const int BShift = 8 - IndexBBits;
- private const int AShift = 8 - IndexABits;
- private const int Entries = IndexRCount * IndexGCount * IndexBCount * IndexACount;
- private MemoryHandle tableHandle;
- private readonly IMemoryOwner table;
- private readonly short* tablePointer;
-
- public ColorDistanceCache(MemoryAllocator allocator)
- {
- this.table = allocator.Allocate(Entries);
- this.table.GetSpan().Fill(-1);
- this.tableHandle = this.table.Memory.Pin();
- this.tablePointer = (short*)this.tableHandle.Pointer;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Add(Rgba32 rgba, byte index)
- {
- int idx = GetPaletteIndex(rgba);
- this.tablePointer[idx] = index;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryGetValue(Rgba32 rgba, out short match)
- {
- int idx = GetPaletteIndex(rgba);
- match = this.tablePointer[idx];
- return match > -1;
- }
-
- ///
- /// Clears the cache resetting each entry to empty.
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Clear() => this.table.GetSpan().Fill(-1);
-
- [MethodImpl(InliningOptions.ShortMethod)]
- private static int GetPaletteIndex(Rgba32 rgba)
- {
- int rIndex = rgba.R >> RShift;
- int gIndex = rgba.G >> GShift;
- int bIndex = rgba.B >> BShift;
- int aIndex = rgba.A >> AShift;
-
- return (aIndex * (IndexRCount * IndexGCount * IndexBCount)) +
- (rIndex * (IndexGCount * IndexBCount)) +
- (gIndex * IndexBCount) + bIndex;
- }
-
- public void Dispose()
- {
- if (this.table != null)
- {
- this.tableHandle.Dispose();
- this.table.Dispose();
- }
- }
- }
-}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
new file mode 100644
index 0000000000..6234e2e63e
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
@@ -0,0 +1,377 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.Versioning;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Represents a cache used for efficiently retrieving palette indices for colors.
+///
+/// The type of the cache.
+internal interface IColorIndexCache : IColorIndexCache
+ where T : struct, IColorIndexCache
+{
+ ///
+ /// Creates a new instance of the cache.
+ ///
+ /// The memory allocator to use.
+ ///
+ /// The new instance of the cache.
+ ///
+#pragma warning disable IDE0060 // Remove unused parameter
+ [RequiresPreviewFeatures]
+ public static abstract T Create(MemoryAllocator allocator);
+#pragma warning restore IDE0060 // Remove unused parameter
+}
+
+///
+/// Represents a cache used for efficiently retrieving palette indices for colors.
+///
+internal interface IColorIndexCache : IDisposable
+{
+ ///
+ /// Adds a color to the cache.
+ ///
+ /// The color to add.
+ /// The index of the color in the palette.
+ ///
+ /// if the color was added; otherwise, .
+ ///
+ public bool TryAdd(Rgba32 color, short value);
+
+ ///
+ /// Gets the index of the color in the palette.
+ ///
+ /// The color to get the index for.
+ /// The index of the color in the palette.
+ ///
+ /// if the color is in the palette; otherwise, .
+ ///
+ public bool TryGetValue(Rgba32 color, out short value);
+
+ ///
+ /// Clears the cache.
+ ///
+ public void Clear();
+}
+
+///
+/// A hybrid cache for color distance lookups that combines an exact-match dictionary with
+/// a fallback coarse lookup table.
+///
+///
+/// This cache uses a fallback table with 2,097,152 bins, each storing a 2-byte value
+/// (approximately 4 MB total), while the exact-match dictionary is limited to 512 entries
+/// and occupies roughly 4 KB. Overall, the worst-case memory usage is about 4 MB.
+/// Lookups and insertions are performed in constant time (O(1)) because the fallback table
+/// is accessed via direct indexing and the dictionary employs a simple hash-based bucket mechanism.
+/// The design achieves extremely fast color distance lookups with a predictable, fixed memory footprint.
+///
+internal unsafe struct HybridCache : IColorIndexCache
+{
+ private ExactCache exactCache;
+ private CoarseCache coarseCache;
+
+ [RequiresPreviewFeatures]
+ private HybridCache(MemoryAllocator allocator)
+ {
+ this.exactCache = ExactCache.Create(allocator);
+ this.coarseCache = CoarseCache.Create(allocator);
+ }
+
+ [RequiresPreviewFeatures]
+ public static HybridCache Create(MemoryAllocator allocator) => new(allocator);
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public bool TryAdd(Rgba32 color, short index)
+ {
+ if (this.exactCache.TryAdd(color, index))
+ {
+ return true;
+ }
+
+ return this.coarseCache.TryAdd(color, index);
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ if (this.exactCache.TryGetValue(color, out value))
+ {
+ return true;
+ }
+
+ return this.coarseCache.TryGetValue(color, out value);
+ }
+
+ public void Clear()
+ {
+ this.exactCache.Clear();
+ this.coarseCache.Clear();
+ }
+
+ public void Dispose()
+ {
+ this.exactCache.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 worst-case 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;
+ }
+
+ [RequiresPreviewFeatures]
+ 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();
+ }
+}
+
+///
+/// A fixed-capacity dictionary with exactly 512 entries mapping a key
+/// to a value.
+///
+///
+/// The dictionary is implemented using a fixed array of 512 buckets and an entries array
+/// of the same size. The bucket for a key is computed as (key & 0x1FF), and collisions are
+/// resolved through a linked chain stored in the field.
+/// The overall memory usage is approximately 4–5 KB. Both lookup and insertion operations are,
+/// on average, O(1) since the bucket is determined via a simple bitmask and collision chains are
+/// typically very short; in the worst-case, the number of iterations is bounded by 256.
+/// This guarantees highly efficient and predictable performance for small, fixed-size color palettes.
+///
+internal unsafe struct ExactCache : IColorIndexCache
+{
+ // Buckets array: each bucket holds the index (0-based) into the entries array
+ // of the first entry in the chain, or -1 if empty.
+ private readonly IMemoryOwner bucketsOwner;
+ private MemoryHandle bucketsHandle;
+ private short* buckets;
+
+ // Entries array: stores up to 256 entries.
+ private readonly IMemoryOwner entriesOwner;
+ private MemoryHandle entriesHandle;
+ private Entry* entries;
+
+ private int count;
+
+ public const int Capacity = 512;
+
+ private ExactCache(MemoryAllocator allocator)
+ {
+ this.count = 0;
+
+ // Allocate exactly 512 ints for buckets.
+ this.bucketsOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ Span bucketSpan = this.bucketsOwner.GetSpan();
+ bucketSpan.Fill(-1);
+ this.bucketsHandle = this.bucketsOwner.Memory.Pin();
+ this.buckets = (short*)this.bucketsHandle.Pointer;
+
+ // Allocate exactly 512 entries.
+ this.entriesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ this.entriesHandle = this.entriesOwner.Memory.Pin();
+ this.entries = (Entry*)this.entriesHandle.Pointer;
+ }
+
+ [RequiresPreviewFeatures]
+ public static ExactCache Create(MemoryAllocator allocator) => new(allocator);
+
+ public bool TryAdd(Rgba32 color, short value)
+ {
+ if (this.count == Capacity)
+ {
+ return false; // Dictionary is full.
+ }
+
+ uint key = color.PackedValue;
+
+ // The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A
+ // (with R in the most significant byte and A in the least significant).
+ // To compute the bucket index:
+ // 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels.
+ // 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A).
+ // 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A),
+ // which helps to counteract situations where one or more channels have a limited range.
+ // 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511,
+ // which corresponds to our fixed bucket count of 512.
+ int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
+ int i = this.buckets[bucket];
+
+ // Traverse the collision chain.
+ Entry* entries = this.entries;
+ while (i != -1)
+ {
+ Entry e = entries[i];
+ if (e.Key == key)
+ {
+ // Key already exists; do not overwrite.
+ return false;
+ }
+
+ i = e.Next;
+ }
+
+ short index = (short)this.count;
+ this.count++;
+
+ // Insert the new entry:
+ entries[index].Key = key;
+ entries[index].Value = value;
+
+ // Link this new entry into the bucket chain.
+ entries[index].Next = this.buckets[bucket];
+ this.buckets[bucket] = index;
+ return true;
+ }
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ uint key = color.PackedValue;
+ int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
+ int i = this.buckets[bucket];
+
+ // If the bucket is empty, return immediately.
+ if (i == -1)
+ {
+ value = -1;
+ return false;
+ }
+
+ // Traverse the chain.
+ Entry* entries = this.entries;
+ do
+ {
+ Entry e = entries[i];
+ if (e.Key == key)
+ {
+ value = e.Value;
+ return true;
+ }
+
+ i = e.Next;
+ }
+ while (i != -1);
+
+ value = -1;
+ return false;
+ }
+
+ ///
+ /// Clears the dictionary.
+ ///
+ public void Clear()
+ {
+ Span bucketSpan = this.bucketsOwner.GetSpan();
+ bucketSpan.Fill(-1);
+ this.count = 0;
+ }
+
+ public void Dispose()
+ {
+ this.bucketsHandle.Dispose();
+ this.bucketsOwner.Dispose();
+ this.entriesHandle.Dispose();
+ this.entriesOwner.Dispose();
+ this.buckets = null;
+ this.entries = null;
+ }
+
+ private struct Entry
+ {
+ public uint Key; // The key (packed RGBA)
+ public short Value; // The value; -1 means unused.
+ public short Next; // Index of the next entry in the chain, or -1 if none.
+ }
+}
+
+internal readonly struct NullCache : IColorIndexCache
+{
+ [RequiresPreviewFeatures]
+ public static NullCache Create(MemoryAllocator allocator) => default;
+
+ public bool TryAdd(Rgba32 color, short value) => true;
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ value = -1;
+ return false;
+ }
+
+ public void Clear()
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
index fe422882bc..f510b102c5 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
@@ -28,7 +28,7 @@ public struct OctreeQuantizer : IQuantizer
private readonly Octree octree;
private readonly IMemoryOwner paletteOwner;
private ReadOnlyMemory palette;
- private EuclideanPixelMap? pixelMap;
+ private PixelMap? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
@@ -60,38 +60,43 @@ public OctreeQuantizer(Configuration configuration, QuantizerOptions options)
public QuantizerOptions Options { get; }
///
- public readonly ReadOnlyMemory Palette
+ public ReadOnlyMemory Palette
{
get
{
- QuantizerUtilities.CheckPaletteState(in this.palette);
+ if (this.palette.IsEmpty)
+ {
+ this.ResolvePalette();
+ QuantizerUtilities.CheckPaletteState(in this.palette);
+ }
+
return this.palette;
}
}
///
- public void AddPaletteColors(Buffer2DRegion pixelRegion)
+ public readonly void AddPaletteColors(Buffer2DRegion pixelRegion)
{
- using (IMemoryOwner buffer = this.Configuration.MemoryAllocator.Allocate(pixelRegion.Width))
+ using IMemoryOwner buffer = this.Configuration.MemoryAllocator.Allocate(pixelRegion.Width);
+ Span bufferSpan = buffer.GetSpan();
+
+ // Loop through each row
+ for (int y = 0; y < pixelRegion.Height; y++)
{
- Span bufferSpan = buffer.GetSpan();
+ Span row = pixelRegion.DangerousGetRowSpan(y);
+ PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan);
- // Loop through each row
- for (int y = 0; y < pixelRegion.Height; y++)
+ for (int x = 0; x < bufferSpan.Length; x++)
{
- Span row = pixelRegion.DangerousGetRowSpan(y);
- PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan);
-
- for (int x = 0; x < bufferSpan.Length; x++)
- {
- Rgba32 rgba = bufferSpan[x];
-
- // Add the color to the Octree
- this.octree.AddColor(rgba);
- }
+ // Add the color to the Octree
+ this.octree.AddColor(bufferSpan[x]);
}
}
+ }
+ [MemberNotNull(nameof(pixelMap))]
+ private void ResolvePalette()
+ {
int paletteIndex = 0;
Span paletteSpan = this.paletteOwner.GetSpan();
@@ -109,17 +114,7 @@ public void AddPaletteColors(Buffer2DRegion pixelRegion)
this.octree.Palletize(paletteSpan, max, ref paletteIndex);
ReadOnlyMemory result = this.paletteOwner.Memory[..paletteSpan.Length];
- // When called multiple times by QuantizerUtilities.BuildPalette
- // this prevents memory churn caused by reallocation.
- if (this.pixelMap is null)
- {
- this.pixelMap = new EuclideanPixelMap(this.Configuration, result);
- }
- else
- {
- this.pixelMap.Clear(result);
- }
-
+ this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode);
this.palette = result;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
index 3df80ea9b7..e6984ec98a 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
@@ -20,7 +20,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
internal readonly struct PaletteQuantizer : IQuantizer
where TPixel : unmanaged, IPixel
{
- private readonly EuclideanPixelMap pixelMap;
+ private readonly PixelMap pixelMap;
///
/// Initializes a new instance of the struct.
@@ -41,7 +41,7 @@ public PaletteQuantizer(
this.Configuration = configuration;
this.Options = options;
- this.pixelMap = new EuclideanPixelMap(configuration, palette, transparentIndex);
+ this.pixelMap = PixelMapFactory.Create(configuration, palette, options.ColorMatchingMode, transparentIndex);
}
///
diff --git a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
index a6bb265a81..2d6cc49db7 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
@@ -38,4 +38,9 @@ public int MaxColors
get => this.maxColors;
set => this.maxColors = Numerics.Clamp(value, QuantizerConstants.MinColors, QuantizerConstants.MaxColors);
}
+
+ ///
+ /// Gets or sets the color matching mode used for matching pixel values to palette colors.
+ ///
+ public ColorMatchingMode ColorMatchingMode { get; set; } = ColorMatchingMode.Hybrid;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
index f6928c3dd4..5eebb5b6de 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
@@ -75,7 +75,7 @@ internal struct WuQuantizer : IQuantizer
private ReadOnlyMemory palette;
private int maxColors;
private readonly Box[] colorCube;
- private EuclideanPixelMap? pixelMap;
+ private PixelMap? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
@@ -111,35 +111,43 @@ public WuQuantizer(Configuration configuration, QuantizerOptions options)
public QuantizerOptions Options { get; }
///
- public readonly ReadOnlyMemory Palette
+ public ReadOnlyMemory Palette
{
get
{
- QuantizerUtilities.CheckPaletteState(in this.palette);
+ if (this.palette.IsEmpty)
+ {
+ this.ResolvePalette();
+ QuantizerUtilities.CheckPaletteState(in this.palette);
+ }
+
return this.palette;
}
}
///
- public void AddPaletteColors(Buffer2DRegion pixelRegion)
+ public readonly void AddPaletteColors(Buffer2DRegion pixelRegion)
+ => this.Build3DHistogram(pixelRegion);
+
+ ///
+ /// Once all histogram data has been accumulated, this method computes the moments,
+ /// splits the color cube, and resolves the final palette from the accumulated histogram.
+ ///
+ private void ResolvePalette()
{
- // TODO: Something is destroying the existing palette when adding new colors.
- // When the QuantizingImageEncoder.PixelSamplingStrategy is DefaultPixelSamplingStrategy
- // this leads to performance issues + the palette is not preserved.
- // https://github.com/SixLabors/ImageSharp/issues/2498
- this.Build3DHistogram(pixelRegion);
+ // Calculate the cumulative moments from the accumulated histogram.
this.Get3DMoments(this.memoryAllocator);
+
+ // Partition the histogram into color cubes.
this.BuildCube();
- // Slice again since maxColors has been updated since the buffer was created.
+ // Compute the palette colors from the resolved cubes.
Span paletteSpan = this.paletteOwner.GetSpan()[..this.maxColors];
ReadOnlySpan momentsSpan = this.momentsOwner.GetSpan();
for (int k = 0; k < paletteSpan.Length; k++)
{
this.Mark(ref this.colorCube[k], (byte)k);
-
Moment moment = Volume(ref this.colorCube[k], momentsSpan);
-
if (moment.Weight > 0)
{
ref TPixel color = ref paletteSpan[k];
@@ -147,22 +155,14 @@ public void AddPaletteColors(Buffer2DRegion pixelRegion)
}
}
- ReadOnlyMemory result = this.paletteOwner.Memory[..paletteSpan.Length];
- if (this.isDithering)
+ // Update the palette to the new computed colors.
+ this.palette = this.paletteOwner.Memory[..paletteSpan.Length];
+
+ // Create the pixel map if dithering is enabled.
+ if (this.isDithering && this.pixelMap is null)
{
- // When called multiple times by QuantizerUtilities.BuildPalette
- // this prevents memory churn caused by reallocation.
- if (this.pixelMap is null)
- {
- this.pixelMap = new EuclideanPixelMap(this.Configuration, result);
- }
- else
- {
- this.pixelMap.Clear(result);
- }
+ this.pixelMap = PixelMapFactory.Create(this.Configuration, this.palette, this.Options.ColorMatchingMode);
}
-
- this.palette = result;
}
///
@@ -549,7 +549,7 @@ private readonly float Maximize(ref Box cube, int direction, int first, int last
/// The first set.
/// The second set.
/// Returns a value indicating whether the box has been split.
- private bool Cut(ref Box set1, ref Box set2)
+ private readonly bool Cut(ref Box set1, ref Box set2)
{
ReadOnlySpan momentSpan = this.momentsOwner.GetSpan();
Moment whole = Volume(ref set1, momentSpan);
diff --git a/src/ImageSharp/Properties/AssemblyInfo.cs b/src/ImageSharp/Properties/AssemblyInfo.cs
deleted file mode 100644
index 334737ac17..0000000000
--- a/src/ImageSharp/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-// Redundant suppressing of SA1413 for Rider.
-[assembly:
- System.Diagnostics.CodeAnalysis.SuppressMessage(
- "StyleCop.CSharp.MaintainabilityRules",
- "SA1413:UseTrailingCommasInMultiLineInitializers",
- Justification = "Follows SixLabors.ruleset")]
diff --git a/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs b/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
index 048c2aadda..23a39a6720 100644
--- a/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
+++ b/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
@@ -23,7 +23,7 @@ public class EncodeGif
// Try to get as close to System.Drawing's output as possible
private readonly GifEncoder encoder = new GifEncoder
{
- Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4 })
+ Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4, ColorMatchingMode = ColorMatchingMode.Coarse })
};
[Params(TestImages.Bmp.Car, TestImages.Png.Rgb48Bpp)]
diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
index bc6eeedcbe..dcce56e881 100644
--- a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
@@ -34,6 +34,41 @@ public void Decode_VerifyAllFrames(TestImageProvider provider)
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
+ [Theory]
+ [WithFile(TestImages.Gif.AnimatedLoop, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedLoopInterlaced, PixelTypes.Rgba32)]
+ public void Decode_Animated(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSaveMultiFrame(provider);
+ image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
+ }
+
+ [Theory]
+ [WithFile(TestImages.Gif.AnimatedTransparentNoRestore, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentRestorePrevious, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentLoop, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentFirstFrameRestorePrev, PixelTypes.Rgba32)]
+ public void Decode_Animated_WithTransparency(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSaveMultiFrame(provider);
+ image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
+ }
+
+ [Theory]
+ [WithFile(TestImages.Gif.StaticNontransparent, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.StaticTransparent, PixelTypes.Rgba32)]
+ public void Decode_Static_No_Animation(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSave(provider);
+ image.CompareFirstFrameToReferenceOutput(ImageComparer.Exact, provider);
+ }
+
[Theory]
[WithFile(TestImages.Gif.Issues.Issue2450_A, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.Issues.Issue2450_B, PixelTypes.Rgba32)]
diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
index a7e16f7737..dcbd4b38eb 100644
--- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
@@ -382,4 +382,21 @@ public void Encode_Animated_VisualTest(TestImageProvider provide
provider.Utility.SaveTestOutputFile(image, "png", new PngEncoder(), "animated");
provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated");
}
+
+ [Theory]
+ [WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)]
+ public void GifEncoder_CanDecode_AndEncode_Issue2866(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+
+ // Save the image for visual inspection.
+ provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated");
+
+ // Now compare the debug output with the reference output.
+ // We do this because the gif encoding is lossy and encoding will lead to differences in the 10s of percent.
+ // From the unencoded image, we can see that the image is visually the same.
+ static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them.
+ image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate);
+ }
}
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
index ca5aae961c..57c8ff667c 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
@@ -484,8 +484,9 @@ public void Encode_APng(TestImageProvider provider)
}
[Theory]
- [WithFile(TestImages.Gif.Leo, PixelTypes.Rgba32)]
- public void Encode_AnimatedFormatTransform_FromGif(TestImageProvider provider)
+ [WithFile(TestImages.Gif.Leo, PixelTypes.Rgba32, 0.7629F)]
+ [WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32, 1.06F)]
+ public void Encode_AnimatedFormatTransform_FromGif(TestImageProvider