diff --git a/src/ImageSharp/Common/InlineArray.cs b/src/ImageSharp/Common/InlineArray.cs
new file mode 100644
index 000000000..358121d0a
--- /dev/null
+++ b/src/ImageSharp/Common/InlineArray.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+//
+
+using System;
+using System.Runtime.CompilerServices;
+
+namespace SixLabors.ImageSharp;
+
+///
+/// Represents a safe, fixed sized buffer of 4 elements.
+///
+[InlineArray(4)]
+internal struct InlineArray4
+{
+ private T t;
+}
+
+///
+/// Represents a safe, fixed sized buffer of 16 elements.
+///
+[InlineArray(16)]
+internal struct InlineArray16
+{
+ private T t;
+}
+
+
diff --git a/src/ImageSharp/Common/InlineArray.tt b/src/ImageSharp/Common/InlineArray.tt
new file mode 100644
index 000000000..fae6ab227
--- /dev/null
+++ b/src/ImageSharp/Common/InlineArray.tt
@@ -0,0 +1,38 @@
+<#@ template debug="false" hostspecific="false" language="C#" #>
+<#@ assembly name="System.Core" #>
+<#@ import namespace="System.Linq" #>
+<#@ import namespace="System.Text" #>
+<#@ import namespace="System.Collections.Generic" #>
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+//
+
+using System;
+using System.Runtime.CompilerServices;
+
+namespace SixLabors.ImageSharp;
+
+<#GenerateInlineArrays();#>
+
+<#+
+private static int[] Lengths = new int[] {4, 16 };
+
+void GenerateInlineArrays()
+{
+ foreach (int length in Lengths)
+ {
+#>
+///
+/// Represents a safe, fixed sized buffer of <#=length#> elements.
+///
+[InlineArray(<#=length#>)]
+internal struct InlineArray<#=length#>
+{
+ private T t;
+}
+
+<#+
+ }
+}
+#>
diff --git a/src/ImageSharp/Formats/Bmp/BmpMetadata.cs b/src/ImageSharp/Formats/Bmp/BmpMetadata.cs
index d0c60421c..1dac74ba3 100644
--- a/src/ImageSharp/Formats/Bmp/BmpMetadata.cs
+++ b/src/ImageSharp/Formats/Bmp/BmpMetadata.cs
@@ -158,6 +158,5 @@ public class BmpMetadata : IFormatMetadata
///
public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.ColorTable = null;
}
diff --git a/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs b/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
index 01b7fbce0..f1ebec72f 100644
--- a/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
+++ b/src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
@@ -126,6 +126,7 @@ public class CurFrameMetadata : IFormatFrameMetadata
float ratioY = destination.Height / (float)source.Height;
this.EncodingWidth = ScaleEncodingDimension(this.EncodingWidth, destination.Width, ratioX);
this.EncodingHeight = ScaleEncodingDimension(this.EncodingHeight, destination.Height, ratioY);
+ this.ColorTable = null;
}
///
diff --git a/src/ImageSharp/Formats/Cur/CurMetadata.cs b/src/ImageSharp/Formats/Cur/CurMetadata.cs
index 19de7f434..5c725e291 100644
--- a/src/ImageSharp/Formats/Cur/CurMetadata.cs
+++ b/src/ImageSharp/Formats/Cur/CurMetadata.cs
@@ -152,8 +152,7 @@ public class CurMetadata : IFormatMetadata
///
public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.ColorTable = null;
///
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
diff --git a/src/ImageSharp/Formats/EncodingUtilities.cs b/src/ImageSharp/Formats/EncodingUtilities.cs
index a979fdf6f..db951b1c3 100644
--- a/src/ImageSharp/Formats/EncodingUtilities.cs
+++ b/src/ImageSharp/Formats/EncodingUtilities.cs
@@ -3,6 +3,7 @@
using System.Buffers;
using System.Numerics;
+using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
@@ -24,13 +25,29 @@ internal static class EncodingUtilities
/// to better compression in some cases.
///
/// The type of the pixel.
- /// The cloned where the transparent pixels will be changed.
+ /// The where the transparent pixels will be changed.
/// The color to replace transparent pixels with.
- public static void ClearTransparentPixels(ImageFrame clone, Color color)
+ public static void ClearTransparentPixels(ImageFrame frame, Color color)
+ where TPixel : unmanaged, IPixel
+ => ClearTransparentPixels(frame.Configuration, frame.PixelBuffer, color);
+
+ ///
+ /// Convert transparent pixels, to pixels represented by , which can yield
+ /// to better compression in some cases.
+ ///
+ /// The type of the pixel.
+ /// The configuration.
+ /// The where the transparent pixels will be changed.
+ /// The color to replace transparent pixels with.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void ClearTransparentPixels(
+ Configuration configuration,
+ Buffer2D buffer,
+ Color color)
where TPixel : unmanaged, IPixel
{
- Buffer2DRegion buffer = clone.PixelBuffer.GetRegion();
- ClearTransparentPixels(clone.Configuration, ref buffer, color);
+ Buffer2DRegion region = buffer.GetRegion();
+ ClearTransparentPixels(configuration, in region, color);
}
///
@@ -39,29 +56,27 @@ internal static class EncodingUtilities
///
/// The type of the pixel.
/// The configuration.
- /// The cloned where the transparent pixels will be changed.
+ /// The where the transparent pixels will be changed.
/// The color to replace transparent pixels with.
public static void ClearTransparentPixels(
Configuration configuration,
- ref Buffer2DRegion clone,
+ in Buffer2DRegion region,
Color color)
where TPixel : unmanaged, IPixel
{
- using IMemoryOwner vectors = configuration.MemoryAllocator.Allocate(clone.Width);
+ using IMemoryOwner vectors = configuration.MemoryAllocator.Allocate(region.Width);
Span vectorsSpan = vectors.GetSpan();
Vector4 replacement = color.ToScaledVector4();
- for (int y = 0; y < clone.Height; y++)
+ for (int y = 0; y < region.Height; y++)
{
- Span span = clone.DangerousGetRowSpan(y);
+ Span span = region.DangerousGetRowSpan(y);
PixelOperations.Instance.ToVector4(configuration, span, vectorsSpan, PixelConversionModifiers.Scale);
ClearTransparentPixelRow(vectorsSpan, replacement);
PixelOperations.Instance.FromVector4Destructive(configuration, vectorsSpan, span, PixelConversionModifiers.Scale);
}
}
- private static void ClearTransparentPixelRow(
- Span vectorsSpan,
- Vector4 replacement)
+ private static void ClearTransparentPixelRow(Span vectorsSpan, Vector4 replacement)
{
if (Vector128.IsHardwareAccelerated)
{
diff --git a/src/ImageSharp/Formats/Gif/GifDecoderCore.cs b/src/ImageSharp/Formats/Gif/GifDecoderCore.cs
index e18166c4b..e9012436e 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 used to fill the frame.
+ ///
+ private Color backgroundColor;
+
///
/// Initializes a new instance of the class.
///
@@ -108,9 +113,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
uint frameCount = 0;
Image? image = null;
ImageFrame? previousFrame = null;
+ FrameDisposalMode? previousDisposalMode = null;
+ bool globalColorTableUsed = false;
+
try
{
this.ReadLogicalScreenDescriptorAndGlobalColorTable(stream);
+ TPixel backgroundPixel = this.backgroundColor.ToPixel();
// Loop though the respective gif parts and read the data.
int nextFlag = stream.ReadByte();
@@ -123,7 +132,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore
break;
}
- this.ReadFrame(stream, ref image, ref previousFrame);
+ globalColorTableUsed |= this.ReadFrame(stream, ref image, ref previousFrame, ref previousDisposalMode, backgroundPixel);
// Reset per-frame state.
this.imageDescriptor = default;
@@ -158,6 +167,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
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 = FrameColorTableMode.Local;
+ }
}
finally
{
@@ -179,6 +195,8 @@ internal sealed class GifDecoderCore : ImageDecoderCore
uint frameCount = 0;
ImageFrameMetadata? previousFrame = null;
List framesMetadata = [];
+ bool globalColorTableUsed = false;
+
try
{
this.ReadLogicalScreenDescriptorAndGlobalColorTable(stream);
@@ -194,7 +212,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore
break;
}
- this.ReadFrameMetadata(stream, framesMetadata, ref previousFrame);
+ globalColorTableUsed |= this.ReadFrameMetadata(stream, framesMetadata, ref previousFrame);
// Reset per-frame state.
this.imageDescriptor = default;
@@ -229,6 +247,13 @@ internal sealed class GifDecoderCore : ImageDecoderCore
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 = FrameColorTableMode.Local;
+ }
}
finally
{
@@ -416,7 +441,15 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// 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 frame disposal mode.
+ /// The background color pixel.
+ /// Whether the frame has a global color table.
+ private bool ReadFrame(
+ BufferedReadStream stream,
+ ref Image? image,
+ ref ImageFrame? previousFrame,
+ ref FrameDisposalMode? previousDisposalMode,
+ TPixel backgroundPixel)
where TPixel : unmanaged, IPixel
{
this.ReadImageDescriptor(stream);
@@ -438,10 +471,12 @@ internal sealed class GifDecoderCore : ImageDecoderCore
}
ReadOnlySpan colorTable = MemoryMarshal.Cast(rawColorTable);
- this.ReadFrameColors(stream, ref image, ref previousFrame, colorTable);
+ this.ReadFrameColors(stream, ref image, ref previousFrame, ref previousDisposalMode, colorTable, backgroundPixel);
// Skip any remaining blocks
SkipBlock(stream);
+
+ return !hasLocalColorTable;
}
///
@@ -451,46 +486,36 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// The containing image data.
/// The image to decode the information to.
/// The previous frame.
+ /// The previous frame disposal mode.
/// The color table containing the available colors.
+ /// The background color pixel.
private void ReadFrameColors(
BufferedReadStream stream,
ref Image? image,
ref ImageFrame? previousFrame,
- ReadOnlySpan colorTable)
+ ref FrameDisposalMode? previousDisposalMode,
+ ReadOnlySpan colorTable,
+ TPixel backgroundPixel)
where TPixel : unmanaged, IPixel
{
GifImageDescriptor descriptor = this.imageDescriptor;
int imageWidth = this.logicalScreenDescriptor.Width;
int imageHeight = this.logicalScreenDescriptor.Height;
bool transFlag = this.graphicsControlExtension.TransparencyFlag;
-
- ImageFrame? prevFrame = null;
- ImageFrame? currentFrame = null;
- ImageFrame imageFrame;
+ FrameDisposalMode disposalMethod = this.graphicsControlExtension.DisposalMethod;
+ ImageFrame currentFrame;
if (previousFrame is null)
{
- if (!transFlag)
- {
- image = new Image(this.configuration, imageWidth, imageHeight, Color.Black.ToPixel(), this.metadata);
- }
- else
- {
- // This initializes the image to become fully transparent because the alpha channel is zero.
- image = new Image(this.configuration, imageWidth, imageHeight, this.metadata);
- }
+ image = transFlag
+ ? new Image(this.configuration, imageWidth, imageHeight, this.metadata)
+ : new Image(this.configuration, imageWidth, imageHeight, backgroundPixel, this.metadata);
this.SetFrameMetadata(image.Frames.RootFrame.Metadata);
-
- imageFrame = image.Frames.RootFrame;
+ currentFrame = image.Frames.RootFrame;
}
else
{
- if (this.graphicsControlExtension.DisposalMethod == FrameDisposalMode.RestoreToPrevious)
- {
- prevFrame = 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
@@ -498,9 +523,19 @@ internal sealed class GifDecoderCore : ImageDecoderCore
this.SetFrameMetadata(currentFrame.Metadata);
- imageFrame = currentFrame;
+ if (previousDisposalMode == FrameDisposalMode.RestoreToBackground)
+ {
+ this.RestoreToBackground(currentFrame, backgroundPixel, transFlag);
+ }
+ }
+
+ Rectangle interest = Rectangle.Intersect(image.Bounds, new(descriptor.Left, descriptor.Top, descriptor.Width, descriptor.Height));
+ previousFrame = currentFrame;
+ previousDisposalMode = disposalMethod;
- this.RestoreToBackground(imageFrame);
+ if (disposalMethod == FrameDisposalMode.RestoreToBackground)
+ {
+ this.restoreArea = interest;
}
if (colorTable.Length == 0)
@@ -568,7 +603,7 @@ internal sealed class GifDecoderCore : ImageDecoderCore
// #403 The left + width value can be larger than the image width
int maxX = Math.Min(descriptorRight, imageWidth);
- Span row = imageFrame.PixelBuffer.DangerousGetRowSpan(writeY);
+ Span row = currentFrame.PixelBuffer.DangerousGetRowSpan(writeY);
// Take the descriptorLeft..maxX slice of the row, so the loop can be simplified.
row = row[descriptorLeft..maxX];
@@ -599,19 +634,6 @@ internal sealed class GifDecoderCore : ImageDecoderCore
}
}
}
-
- if (prevFrame != null)
- {
- previousFrame = prevFrame;
- return;
- }
-
- previousFrame = currentFrame ?? image.Frames.RootFrame;
-
- if (this.graphicsControlExtension.DisposalMethod == FrameDisposalMode.RestoreToBackground)
- {
- this.restoreArea = new Rectangle(descriptor.Left, descriptor.Top, descriptor.Width, descriptor.Height);
- }
}
///
@@ -620,7 +642,8 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// The containing image data.
/// The collection of frame metadata.
/// The previous frame metadata.
- private void ReadFrameMetadata(BufferedReadStream stream, List frameMetadata, ref ImageFrameMetadata? previousFrame)
+ /// Whether the frame has a global color table.
+ private bool ReadFrameMetadata(BufferedReadStream stream, List frameMetadata, ref ImageFrameMetadata? previousFrame)
{
this.ReadImageDescriptor(stream);
@@ -632,6 +655,11 @@ internal sealed class GifDecoderCore : ImageDecoderCore
this.currentLocalColorTable ??= this.configuration.MemoryAllocator.Allocate(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.
@@ -649,6 +677,8 @@ internal sealed class GifDecoderCore : ImageDecoderCore
// Skip any remaining blocks
SkipBlock(stream);
+
+ return !this.imageDescriptor.LocalColorTableFlag;
}
///
@@ -656,7 +686,9 @@ internal sealed class GifDecoderCore : ImageDecoderCore
///
/// 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)
@@ -666,7 +698,14 @@ internal sealed class GifDecoderCore : ImageDecoderCore
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;
}
@@ -775,7 +814,19 @@ internal sealed class GifDecoderCore : ImageDecoderCore
}
}
- this.gifMetadata.BackgroundColorIndex = this.logicalScreenDescriptor.BackgroundColorIndex;
+ // If the global color table is present, we can set the background color
+ // otherwise we default to transparent to match browser behavior.
+ ReadOnlyMemory? table = this.gifMetadata.GlobalColorTable;
+ byte index = this.logicalScreenDescriptor.BackgroundColorIndex;
+ if (table is not null && index < table.Value.Length)
+ {
+ this.backgroundColor = table.Value.Span[index];
+ this.gifMetadata.BackgroundColorIndex = index;
+ }
+ else
+ {
+ this.backgroundColor = Color.Transparent;
+ }
}
private unsafe struct ScratchBuffer
diff --git a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
index 797e825dc..a4830d779 100644
--- a/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
+++ b/src/ImageSharp/Formats/Gif/GifEncoderCore.cs
@@ -39,6 +39,11 @@ internal sealed class GifEncoderCore
///
private IQuantizer? quantizer;
+ ///
+ /// The fallback quantizer to use when no quantizer is provided.
+ ///
+ private static readonly IQuantizer FallbackQuantizer = KnownQuantizers.Octree;
+
///
/// Whether the quantizer was supplied via options.
///
@@ -67,6 +72,9 @@ internal sealed class GifEncoderCore
///
private readonly ushort? repeatCount;
+ ///
+ /// The transparent color mode.
+ ///
private readonly TransparentColorMode transparentColorMode;
///
@@ -104,14 +112,18 @@ internal sealed class GifEncoderCore
GifMetadata gifMetadata = image.Metadata.CloneGifMetadata();
this.colorTableMode ??= gifMetadata.ColorTableMode;
bool useGlobalTable = this.colorTableMode == FrameColorTableMode.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 == FrameColorTableMode.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.
@@ -121,21 +133,22 @@ internal sealed class GifEncoderCore
int transparencyIndex = GetTransparentIndex(quantized, frameMetadata);
if (transparencyIndex >= 0 || gifMetadata.GlobalColorTable.Value.Length < 256)
{
- this.quantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, new() { Dither = null }, transparencyIndex);
+ this.quantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, new() { Dither = null });
}
else
{
- this.quantizer = KnownQuantizers.Octree;
+ this.quantizer = FallbackQuantizer;
}
}
else
{
- this.quantizer = KnownQuantizers.Octree;
+ this.quantizer = FallbackQuantizer;
}
}
// Quantize the first frame. Checking to see whether we can clear the transparent pixels
// to allow for a smaller color palette and encoded result.
+ Color background = Color.Transparent;
using (IQuantizer frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer(this.configuration))
{
ImageFrame? clonedFrame = null;
@@ -147,24 +160,40 @@ internal sealed class GifEncoderCore
clonedFrame = image.Frames.RootFrame.Clone();
GifFrameMetadata frameMeta = clonedFrame.Metadata.GetGifMetadata();
- Color background = frameMeta.DisposalMode == FrameDisposalMode.RestoreToBackground
- ? this.backgroundColor ?? Color.Transparent
- : Color.Transparent;
+ if (frameMeta.DisposalMode == FrameDisposalMode.RestoreToBackground)
+ {
+ background = this.backgroundColor ?? Color.Transparent;
+ }
EncodingUtilities.ClearTransparentPixels(clonedFrame, background);
}
ImageFrame encodingFrame = clonedFrame ?? image.Frames.RootFrame;
- if (useGlobalTable)
+ if (useGlobalTableForFirstFrame)
{
- frameQuantizer.BuildPalette(configuration, mode, strategy, image);
- quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds);
+ if (useGlobalTable)
+ {
+ frameQuantizer.BuildPalette(configuration, mode, strategy, image, background);
+ quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds);
+ }
+ else
+ {
+ frameQuantizer.BuildPalette(configuration, mode, strategy, encodingFrame, background);
+ quantized = frameQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds);
+ }
}
else
{
- frameQuantizer.BuildPalette(configuration, mode, strategy, encodingFrame);
- quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds);
+ quantized = this.QuantizeAdditionalFrameAndUpdateMetadata(
+ encodingFrame,
+ encodingFrame.Bounds,
+ frameMetadata,
+ true,
+ default,
+ false,
+ frameMetadata.HasTransparency ? frameMetadata.TransparencyIndex : -1,
+ background);
}
clonedFrame?.Dispose();
@@ -259,8 +288,8 @@ internal sealed class GifEncoderCore
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;
@@ -280,14 +309,13 @@ internal sealed class GifEncoderCore
GifFrameMetadata gifMetadata = GetGifFrameMetadata(currentFrame, globalTransparencyIndex);
bool useLocal = this.colorTableMode == FrameColorTableMode.Local || (gifMetadata.ColorTableMode == FrameColorTableMode.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);
+ hasGlobalPaletteQuantizer = true;
}
this.EncodeAdditionalFrame(
@@ -298,7 +326,7 @@ internal sealed class GifEncoderCore
encodingFrame,
useLocal,
gifMetadata,
- paletteQuantizer,
+ globalPaletteQuantizer,
previousDisposalMode);
previousFrame = currentFrame;
@@ -307,9 +335,9 @@ internal sealed class GifEncoderCore
}
finally
{
- if (hasPaletteQuantizer)
+ if (hasGlobalPaletteQuantizer)
{
- paletteQuantizer.Dispose();
+ globalPaletteQuantizer.Dispose();
}
}
}
@@ -387,7 +415,8 @@ internal sealed class GifEncoderCore
useLocal,
globalPaletteQuantizer,
difference,
- transparencyIndex);
+ transparencyIndex,
+ background);
this.WriteGraphicalControlExtension(metadata, stream);
@@ -410,7 +439,8 @@ internal sealed class GifEncoderCore
bool useLocal,
PaletteQuantizer globalPaletteQuantizer,
bool hasDuplicates,
- int transparencyIndex)
+ int transparencyIndex,
+ Color transparentColor)
where TPixel : unmanaged, IPixel
{
IndexedImageFrame quantized;
@@ -434,14 +464,14 @@ internal sealed class GifEncoderCore
transparencyIndex = palette.Length;
metadata.TransparencyIndex = ClampIndex(transparencyIndex);
- PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex);
+ PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex, transparentColor);
using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
}
else
{
// We must quantize the frame to generate a local color table.
- IQuantizer quantizer = this.hasQuantizer ? this.quantizer! : KnownQuantizers.Octree;
+ IQuantizer quantizer = this.hasQuantizer ? this.quantizer! : FallbackQuantizer;
using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
@@ -454,7 +484,7 @@ internal sealed class GifEncoderCore
else
{
// Just use the local palette.
- PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex);
+ PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex, transparentColor);
using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
}
@@ -462,7 +492,7 @@ internal sealed class GifEncoderCore
else
{
// We must quantize the frame to generate a local color table.
- IQuantizer quantizer = this.hasQuantizer ? this.quantizer! : KnownQuantizers.Octree;
+ IQuantizer quantizer = this.hasQuantizer ? this.quantizer! : FallbackQuantizer;
using IQuantizer frameQuantizer = quantizer.CreatePixelSpecificQuantizer(this.configuration, quantizer.Options);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
@@ -486,7 +516,8 @@ internal sealed class GifEncoderCore
else
{
// Quantize the image using the global palette.
- // Individual frames, though using the shared palette, can use a different transparent index to represent transparency.
+ // Individual frames, though using the shared palette, can use a different transparent index
+ // to represent transparency.
// A difference was captured but the metadata does not have transparency.
if (hasDuplicates && !metadata.HasTransparency)
@@ -496,7 +527,7 @@ internal sealed class GifEncoderCore
metadata.TransparencyIndex = ClampIndex(transparencyIndex);
}
- globalPaletteQuantizer.SetTransparentIndex(transparencyIndex);
+ globalPaletteQuantizer.SetTransparencyIndex(transparencyIndex, transparentColor.ToPixel());
quantized = globalPaletteQuantizer.QuantizeFrame(encodingFrame, bounds);
}
diff --git a/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs b/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
index 5fe892c65..92bd114e8 100644
--- a/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
+++ b/src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
@@ -129,8 +129,7 @@ public class GifFrameMetadata : IFormatFrameMetadata
///
public void AfterFrameApply(ImageFrame source, ImageFrame destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.LocalColorTable = null;
///
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
diff --git a/src/ImageSharp/Formats/Gif/GifMetadata.cs b/src/ImageSharp/Formats/Gif/GifMetadata.cs
index 517609af4..fc6c9ab9a 100644
--- a/src/ImageSharp/Formats/Gif/GifMetadata.cs
+++ b/src/ImageSharp/Formats/Gif/GifMetadata.cs
@@ -101,7 +101,7 @@ public class GifMetadata : IFormatMetadata
///
public PixelTypeInfo GetPixelTypeInfo()
{
- int bpp = this.GlobalColorTable.HasValue
+ int bpp = this.ColorTableMode == FrameColorTableMode.Global && this.GlobalColorTable.HasValue
? Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(this.GlobalColorTable.Value.Length), 1, 8)
: 8;
@@ -115,15 +115,16 @@ public class GifMetadata : IFormatMetadata
///
public FormatConnectingMetadata ToFormatConnectingMetadata()
{
- Color color = this.GlobalColorTable.HasValue && this.GlobalColorTable.Value.Span.Length > this.BackgroundColorIndex
+ bool global = this.ColorTableMode == FrameColorTableMode.Global;
+ Color color = global && this.GlobalColorTable.HasValue && this.GlobalColorTable.Value.Span.Length > this.BackgroundColorIndex
? this.GlobalColorTable.Value.Span[this.BackgroundColorIndex]
: Color.Transparent;
- return new()
+ return new FormatConnectingMetadata()
{
AnimateRootFrame = true,
+ ColorTable = global ? this.GlobalColorTable : null,
BackgroundColor = color,
- ColorTable = this.GlobalColorTable,
ColorTableMode = this.ColorTableMode,
PixelTypeInfo = this.GetPixelTypeInfo(),
RepeatCount = this.RepeatCount,
@@ -133,8 +134,7 @@ public class GifMetadata : IFormatMetadata
///
public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.GlobalColorTable = null;
///
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
diff --git a/src/ImageSharp/Formats/IFormatFrameMetadata.cs b/src/ImageSharp/Formats/IFormatFrameMetadata.cs
index 20f27d050..261cc1263 100644
--- a/src/ImageSharp/Formats/IFormatFrameMetadata.cs
+++ b/src/ImageSharp/Formats/IFormatFrameMetadata.cs
@@ -14,7 +14,7 @@ public interface IFormatFrameMetadata : IDeepCloneable
/// Converts the metadata to a instance.
///
/// The .
- FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata();
+ public FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata();
///
/// This method is called after a process has been applied to the image frame.
@@ -22,7 +22,7 @@ public interface IFormatFrameMetadata : IDeepCloneable
/// The type of pixel format.
/// The source image frame.
/// The destination image frame.
- void AfterFrameApply(ImageFrame source, ImageFrame destination)
+ public void AfterFrameApply(ImageFrame source, ImageFrame destination)
where TPixel : unmanaged, IPixel;
}
@@ -39,6 +39,6 @@ public interface IFormatFrameMetadata : IFormatFrameMetadata, IDeepClonea
/// The .
/// The .
#pragma warning disable CA1000 // Do not declare static members on generic types
- static abstract TSelf FromFormatConnectingFrameMetadata(FormatConnectingFrameMetadata metadata);
+ public static abstract TSelf FromFormatConnectingFrameMetadata(FormatConnectingFrameMetadata metadata);
#pragma warning restore CA1000 // Do not declare static members on generic types
}
diff --git a/src/ImageSharp/Formats/IFormatMetadata.cs b/src/ImageSharp/Formats/IFormatMetadata.cs
index a351431c9..3142b465c 100644
--- a/src/ImageSharp/Formats/IFormatMetadata.cs
+++ b/src/ImageSharp/Formats/IFormatMetadata.cs
@@ -14,20 +14,20 @@ public interface IFormatMetadata : IDeepCloneable
/// Converts the metadata to a instance.
///
/// The pixel type info.
- PixelTypeInfo GetPixelTypeInfo();
+ public PixelTypeInfo GetPixelTypeInfo();
///
/// Converts the metadata to a instance.
///
/// The .
- FormatConnectingMetadata ToFormatConnectingMetadata();
+ public FormatConnectingMetadata ToFormatConnectingMetadata();
///
/// This method is called after a process has been applied to the image.
///
/// The type of pixel format.
/// The destination image .
- void AfterImageApply(Image destination)
+ public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel;
}
@@ -44,6 +44,6 @@ public interface IFormatMetadata : IFormatMetadata, IDeepCloneable
/// The .
/// The .
#pragma warning disable CA1000 // Do not declare static members on generic types
- static abstract TSelf FromFormatConnectingMetadata(FormatConnectingMetadata metadata);
+ public static abstract TSelf FromFormatConnectingMetadata(FormatConnectingMetadata metadata);
#pragma warning restore CA1000 // Do not declare static members on generic types
}
diff --git a/src/ImageSharp/Formats/IQuantizingImageEncoder.cs b/src/ImageSharp/Formats/IQuantizingImageEncoder.cs
index 5edf6e40e..1ce2aa091 100644
--- a/src/ImageSharp/Formats/IQuantizingImageEncoder.cs
+++ b/src/ImageSharp/Formats/IQuantizingImageEncoder.cs
@@ -13,12 +13,12 @@ public interface IQuantizingImageEncoder
///
/// Gets the quantizer used to generate the color palette.
///
- IQuantizer? Quantizer { get; }
+ public IQuantizer? Quantizer { get; }
///
/// Gets the used for quantization when building color palettes.
///
- IPixelSamplingStrategy PixelSamplingStrategy { get; }
+ public IPixelSamplingStrategy PixelSamplingStrategy { get; }
}
///
diff --git a/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs b/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
index 62aa705cb..77096d524 100644
--- a/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
+++ b/src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
@@ -119,6 +119,7 @@ public class IcoFrameMetadata : IFormatFrameMetadata
float ratioY = destination.Height / (float)source.Height;
this.EncodingWidth = ScaleEncodingDimension(this.EncodingWidth, destination.Width, ratioX);
this.EncodingHeight = ScaleEncodingDimension(this.EncodingHeight, destination.Height, ratioY);
+ this.ColorTable = null;
}
///
diff --git a/src/ImageSharp/Formats/Ico/IcoMetadata.cs b/src/ImageSharp/Formats/Ico/IcoMetadata.cs
index a6c2704b3..09c1da1b1 100644
--- a/src/ImageSharp/Formats/Ico/IcoMetadata.cs
+++ b/src/ImageSharp/Formats/Ico/IcoMetadata.cs
@@ -152,8 +152,7 @@ public class IcoMetadata : IFormatMetadata
///
public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.ColorTable = null;
///
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
diff --git a/src/ImageSharp/Formats/Png/PngEncoder.cs b/src/ImageSharp/Formats/Png/PngEncoder.cs
index 63e675b50..1032f8852 100644
--- a/src/ImageSharp/Formats/Png/PngEncoder.cs
+++ b/src/ImageSharp/Formats/Png/PngEncoder.cs
@@ -1,8 +1,6 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using SixLabors.ImageSharp.Processing.Processors.Quantization;
-
namespace SixLabors.ImageSharp.Formats.Png;
///
@@ -10,16 +8,6 @@ namespace SixLabors.ImageSharp.Formats.Png;
///
public class PngEncoder : QuantizingAnimatedImageEncoder
{
- ///
- /// 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 ea36d9fe1..45d7d1270 100644
--- a/src/ImageSharp/Formats/Png/PngEncoderCore.cs
+++ b/src/ImageSharp/Formats/Png/PngEncoderCore.cs
@@ -3,8 +3,8 @@
using System.Buffers;
using System.Buffers.Binary;
+using System.Diagnostics.CodeAnalysis;
using System.IO.Hashing;
-using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
@@ -119,18 +119,13 @@ internal sealed class PngEncoderCore : IDisposable
///
private IQuantizer? quantizer;
- ///
- /// Any explicit quantized transparent index provided by the background color.
- ///
- 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 readonly Color? backgroundColor;
+ private Color? backgroundColor;
///
/// The number of times any animation is repeated.
@@ -158,7 +153,6 @@ internal sealed class PngEncoderCore : IDisposable
this.memoryAllocator = configuration.MemoryAllocator;
this.encoder = encoder;
this.quantizer = encoder.Quantizer;
- this.backgroundColor = encoder.BackgroundColor;
this.repeatCount = encoder.RepeatCount;
this.animateRootFrame = encoder.AnimateRootFrame;
}
@@ -187,74 +181,92 @@ internal sealed class PngEncoderCore : IDisposable
ImageFrame? clonedFrame = null;
ImageFrame currentFrame = image.Frames.RootFrame;
- int currentFrameIndex = 0;
+ IndexedImageFrame? quantized = null;
+ PaletteQuantizer? paletteQuantizer = null;
+ Buffer2DRegion currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
- bool clearTransparency = EncodingUtilities.ShouldClearTransparentPixels(this.encoder.TransparentColorMode);
- if (clearTransparency)
+ try
{
- currentFrame = clonedFrame = currentFrame.Clone();
- EncodingUtilities.ClearTransparentPixels(currentFrame, Color.Transparent);
- }
+ int currentFrameIndex = 0;
- // 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);
+ bool clearTransparency = EncodingUtilities.ShouldClearTransparentPixels(this.encoder.TransparentColorMode);
+ if (clearTransparency)
+ {
+ currentFrame = clonedFrame = currentFrame.Clone();
+ currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
+ EncodingUtilities.ClearTransparentPixels(this.configuration, in currentFrameRegion, this.backgroundColor.Value);
+ }
- if (image.Frames.Count > 1)
- {
- this.WriteAnimationControlChunk(
- stream,
- (uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)),
- this.repeatCount ?? pngMetadata.RepeatCount);
- }
+ // 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 the first frame isn't animated, write it as usual and skip it when writing animated frames
- bool userAnimateRootFrame = this.animateRootFrame == true;
- if ((!userAnimateRootFrame && !pngMetadata.AnimateRootFrame) || image.Frames.Count == 1)
- {
- cancellationToken.ThrowIfCancellationRequested();
- FrameControl frameControl = new((uint)this.width, (uint)this.height);
- this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
- currentFrameIndex++;
- }
+ if (image.Frames.Count > 1)
+ {
+ this.WriteAnimationControlChunk(
+ stream,
+ (uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)),
+ this.repeatCount ?? pngMetadata.RepeatCount);
+ }
+
+ // If the first frame isn't animated, write it as usual and skip it when writing animated frames
+ bool userAnimateRootFrame = this.animateRootFrame == true;
+ if ((!userAnimateRootFrame && !pngMetadata.AnimateRootFrame) || image.Frames.Count == 1)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ FrameControl frameControl = new((uint)this.width, (uint)this.height);
+ this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, false);
+ currentFrameIndex++;
+ }
- try
- {
if (image.Frames.Count > 1)
{
// Write the first animated frame.
currentFrame = image.Frames[currentFrameIndex];
+ currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
+
PngFrameMetadata frameMetadata = currentFrame.Metadata.GetPngMetadata();
FrameDisposalMode previousDisposal = frameMetadata.DisposalMode;
FrameControl frameControl = this.WriteFrameControlChunk(stream, frameMetadata, currentFrame.Bounds, 0);
uint sequenceNumber = 1;
if (pngMetadata.AnimateRootFrame)
{
- this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, false);
+ this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, false);
}
else
{
- sequenceNumber += this.WriteDataChunks(frameControl, currentFrame.PixelBuffer.GetRegion(), quantized, stream, true);
+ sequenceNumber += this.WriteDataChunks(in frameControl, in currentFrameRegion, quantized, stream, true);
}
currentFrameIndex++;
// Capture the global palette for reuse on subsequent frames.
- ReadOnlyMemory? previousPalette = quantized?.Palette.ToArray();
+ ReadOnlyMemory previousPalette = quantized?.Palette.ToArray();
+
+ 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);
+ }
// Write following frames.
ImageFrame previousFrame = image.Frames.RootFrame;
@@ -267,13 +279,16 @@ internal sealed class PngEncoderCore : IDisposable
cancellationToken.ThrowIfCancellationRequested();
ImageFrame? prev = previousDisposal == FrameDisposalMode.RestoreToBackground ? null : previousFrame;
+
currentFrame = image.Frames[currentFrameIndex];
+ currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
+
ImageFrame? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;
frameMetadata = currentFrame.Metadata.GetPngMetadata();
bool blend = frameMetadata.BlendMode == FrameBlendMode.Over;
Color background = frameMetadata.DisposalMode == FrameDisposalMode.RestoreToBackground
- ? this.backgroundColor ?? Color.Transparent
+ ? this.backgroundColor.Value
: Color.Transparent;
(bool difference, Rectangle bounds) =
@@ -296,8 +311,20 @@ internal sealed class PngEncoderCore : IDisposable
// 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;
+
+ quantized = this.CreateQuantizedFrame(
+ this.encoder,
+ this.colorType,
+ this.bitDepth,
+ pngMetadata,
+ image,
+ encodingFrame,
+ bounds,
+ paletteQuantizer,
+ default);
+
+ Buffer2DRegion encodingFrameRegion = encodingFrame.PixelBuffer.GetRegion(bounds);
+ sequenceNumber += this.WriteDataChunks(in frameControl, in encodingFrameRegion, quantized, stream, true) + 1;
previousFrame = currentFrame;
previousDisposal = frameMetadata.DisposalMode;
@@ -313,6 +340,7 @@ internal sealed class PngEncoderCore : IDisposable
// Dispose of allocations from final frame.
clonedFrame?.Dispose();
quantized?.Dispose();
+ paletteQuantizer?.Dispose();
}
}
@@ -328,18 +356,35 @@ internal sealed class PngEncoderCore : IDisposable
///
/// 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 = frame.Metadata.GetPngMetadata();
+ Color background = frameMetadata.DisposalMode == FrameDisposalMode.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;
}
@@ -1105,7 +1150,7 @@ internal sealed class PngEncoderCore : IDisposable
/// 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;
@@ -1123,12 +1168,12 @@ internal sealed class PngEncoderCore : IDisposable
}
else
{
- this.EncodeAdam7Pixels(frame, deflateStream);
+ this.EncodeAdam7Pixels(in frame, deflateStream);
}
}
else
{
- this.EncodePixels(frame, quantized, deflateStream);
+ this.EncodePixels(in frame, quantized, deflateStream);
}
}
@@ -1196,7 +1241,7 @@ internal sealed class PngEncoderCore : IDisposable
/// The 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);
@@ -1222,7 +1267,7 @@ internal sealed class PngEncoderCore : IDisposable
/// 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++)
@@ -1258,7 +1303,7 @@ internal sealed class PngEncoderCore : IDisposable
// Encode data
// Note: quantized parameter not used
// Note: row parameter not used
- this.CollectAndFilterPixelRow(block, ref filter, ref attempt, null, -1);
+ this.CollectAndFilterPixelRow(block, ref filter, ref attempt, null, -1);
deflateStream.Write(filter);
this.SwapScanlineBuffers();
@@ -1432,6 +1477,7 @@ internal sealed class PngEncoderCore : IDisposable
/// The PNG metadata.
/// if set to true [use16 bit].
/// The bytes per pixel.
+ [MemberNotNull(nameof(backgroundColor))]
private void SanitizeAndSetEncoderOptions(
PngEncoder encoder,
PngMetadata pngMetadata,
@@ -1473,6 +1519,7 @@ internal sealed class PngEncoderCore : IDisposable
this.interlaceMode = encoder.InterlaceMethod ?? pngMetadata.InterlaceMethod;
this.chunkFilter = encoder.SkipMetadata ? PngChunkFilter.ExcludeAll : encoder.ChunkFilter ?? PngChunkFilter.None;
+ this.backgroundColor = encoder.BackgroundColor ?? pngMetadata.TransparentColor ?? Color.Transparent;
}
///
@@ -1483,17 +1530,21 @@ internal sealed class PngEncoderCore : IDisposable
/// 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,
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)
@@ -1501,55 +1552,52 @@ internal sealed class PngEncoderCore : IDisposable
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?.Length > 0)
{
// We can use the color data from the decoded metadata here.
// We avoid dithering by default to preserve the original colors.
- ReadOnlySpan palette = metadata.ColorTable.Value.Span;
-
- // Certain operations perform alpha premultiplication, which can cause the color to change so we
- // must search for the transparency index in the palette.
- // Transparent pixels are much more likely to be found at the end of a palette.
- int index = -1;
- for (int i = palette.Length - 1; i >= 0; i--)
- {
- Vector4 instance = palette[i].ToScaledVector4();
- if (instance.W == 0f)
- {
- index = i;
- break;
- }
- }
-
- this.derivedTransparencyIndex = index;
-
- this.quantizer = new PaletteQuantizer(metadata.ColorTable.Value, new() { Dither = null }, this.derivedTransparencyIndex);
+ this.quantizer = new PaletteQuantizer(metadata.ColorTable.Value, new() { Dither = null });
}
else
{
- this.quantizer = new WuQuantizer(new QuantizerOptions { MaxColors = ColorNumerics.GetColorCountForBitDepth(bitDepth) });
+ // Don't use transparency threshold for quantization PNG can handle multiple transparent colors.
+ this.quantizer = new WuQuantizer(new QuantizerOptions { TransparencyThreshold = 0, MaxColors = ColorNumerics.GetColorCountForBitDepth(bitDepth) });
}
}
// 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());
+ }
+
+ frameQuantizer.BuildPalette(
+ this.configuration,
+ encoder.TransparentColorMode,
+ encoder.PixelSamplingStrategy,
+ image,
+ backgroundColor);
+
return frameQuantizer.QuantizeFrame(frame, bounds);
}
diff --git a/src/ImageSharp/Formats/Png/PngMetadata.cs b/src/ImageSharp/Formats/Png/PngMetadata.cs
index 00cba088c..bb80438ba 100644
--- a/src/ImageSharp/Formats/Png/PngMetadata.cs
+++ b/src/ImageSharp/Formats/Png/PngMetadata.cs
@@ -250,8 +250,7 @@ public class PngMetadata : IFormatMetadata
///
public void AfterImageApply(Image destination)
where TPixel : unmanaged, IPixel
- {
- }
+ => this.ColorTable = null;
///
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();
diff --git a/src/ImageSharp/Formats/TransparentColorMode.cs b/src/ImageSharp/Formats/TransparentColorMode.cs
index 39986b502..5b52e5fa7 100644
--- a/src/ImageSharp/Formats/TransparentColorMode.cs
+++ b/src/ImageSharp/Formats/TransparentColorMode.cs
@@ -18,5 +18,5 @@ public enum TransparentColorMode
/// to fully transparent pixels (all components set to zero),
/// which may improve compression.
///
- Clear = 1,
+ Clear = 1
}
diff --git a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
index 83f9e797a..2b843cc8f 100644
--- a/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
+++ b/src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
@@ -32,7 +32,7 @@ internal abstract class BitReaderBase : IDisposable
/// 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 b3c5bfaf4..eb4a51751 100644
--- a/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
+++ b/src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
@@ -67,14 +67,14 @@ internal class Vp8Decoder : IDisposable
int extraY = extraRows * this.CacheYStride;
int extraUv = extraRows / 2 * this.CacheUvStride;
this.YuvBuffer = memoryAllocator.Allocate((WebpConstants.Bps * 17) + (WebpConstants.Bps * 9) + extraY);
- this.CacheY = memoryAllocator.Allocate((16 * this.CacheYStride) + extraY);
+ this.CacheY = memoryAllocator.Allocate((16 * this.CacheYStride) + extraY, AllocationOptions.Clean);
int cacheUvSize = (16 * this.CacheUvStride) + extraUv;
- this.CacheU = memoryAllocator.Allocate(cacheUvSize);
- this.CacheV = memoryAllocator.Allocate(cacheUvSize);
- 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.CacheU = memoryAllocator.Allocate(cacheUvSize, AllocationOptions.Clean);
+ this.CacheV = memoryAllocator.Allocate(cacheUvSize, AllocationOptions.Clean);
+ this.TmpYBuffer = memoryAllocator.Allocate((int)width, AllocationOptions.Clean);
+ this.TmpUBuffer = memoryAllocator.Allocate((int)width, AllocationOptions.Clean);
+ this.TmpVBuffer = memoryAllocator.Allocate((int)width, AllocationOptions.Clean);
+ 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 b74337ef3..173d9436d 100644
--- a/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
+++ b/src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
@@ -81,16 +81,29 @@ internal class WebpAnimationDecoder : IDisposable
/// 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,16 @@ internal class WebpAnimationDecoder : IDisposable
switch (chunkType)
{
case WebpChunkType.FrameData:
- Color backgroundColor = this.backgroundColorHandling == BackgroundColorHandling.Ignore
- ? Color.FromPixel(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 +151,18 @@ internal class WebpAnimationDecoder : IDisposable
/// 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 +201,51 @@ internal class WebpAnimationDecoder : IDisposable
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);
-
- SetFrameMetadata(image.Frames.RootFrame.Metadata, frameData);
+ image = new Image(this.configuration, (int)width, (int)height, backgroundColor, this.metadata);
- 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 FrameDisposalMode.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 FrameDisposalMode.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 == FrameBlendMode.Over;
+ using Buffer2D pixelData = this.DecodeImageFrameData(frameData, webpInfo);
+ DrawDecodedImageFrameOnCanvas(pixelData, currentFrame, interest, blend);
+
+ webpInfo?.Dispose();
+ previousFrame = currentFrame;
+ prevFrameData = frameData;
if (frameData.DisposalMethod is FrameDisposalMode.RestoreToBackground)
{
- this.RestoreToBackground(imageFrame, backgroundColor);
+ this.restoreArea = interest;
}
- using Buffer2D decodedImageFrame = this.DecodeImageFrameData(frameData, webpInfo);
-
- bool blend = previousFrame != null && frameData.BlendingMethod == FrameBlendMode.Over;
- DrawDecodedImageFrameOnCanvas(decodedImageFrame, imageFrame, regionRectangle, blend);
-
- previousFrame = currentFrame ?? image.Frames.RootFrame;
- this.restoreArea = regionRectangle;
-
return (uint)(stream.Position - streamStartPosition);
}
@@ -257,31 +295,26 @@ internal class WebpAnimationDecoder : IDisposable
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)webpInfo.Width, (int)webpInfo.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)webpInfo.Width, (int)webpInfo.Height, webpInfo, this.alphaData);
}
- return pixelBufferDecoded;
+ return decodeBuffer;
}
catch
{
decodedFrame?.Dispose();
throw;
}
- finally
- {
- webpInfo.Dispose();
- }
}
///
@@ -335,7 +368,7 @@ internal class WebpAnimationDecoder : IDisposable
/// 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 +378,9 @@ internal class WebpAnimationDecoder : IDisposable
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/WebpCommonUtils.cs b/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
index a1e9821c0..1ca409f9a 100644
--- a/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
+++ b/src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
@@ -18,7 +18,7 @@ internal static class WebpCommonUtils
///
/// 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 0d36340bf..36c9375bd 100644
--- a/src/ImageSharp/ImageSharp.csproj
+++ b/src/ImageSharp/ImageSharp.csproj
@@ -44,6 +44,11 @@
+
+ True
+ True
+ InlineArray.tt
+
@@ -51,6 +56,11 @@
+
+ True
+ True
+ InlineArray.tt
+
True
True
@@ -154,6 +164,10 @@
+
+ TextTemplatingFileGenerator
+ InlineArray.cs
+
ImageMetadataExtensions.cs
TextTemplatingFileGenerator
diff --git a/src/ImageSharp/IndexedImageFrame{TPixel}.cs b/src/ImageSharp/IndexedImageFrame{TPixel}.cs
index 6807e77ad..49c9e33eb 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 @@ public sealed class IndexedImageFrame : IPixelSource, IDisposable
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/Processing/Processors/CloningImageProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/CloningImageProcessor{TPixel}.cs
index abe32e388..bc34f759a 100644
--- a/src/ImageSharp/Processing/Processors/CloningImageProcessor{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/CloningImageProcessor{TPixel}.cs
@@ -132,16 +132,14 @@ public abstract class CloningImageProcessor : ICloningImageProcessorThe source image. Cannot be null.
/// The cloned/destination image. Cannot be null.
protected virtual void AfterFrameApply(ImageFrame source, ImageFrame destination)
- {
- }
+ => destination.Metadata.AfterFrameApply(source, destination);
///
/// This method is called after the process is applied to prepare the processor.
///
/// The cloned/destination image. Cannot be null.
protected virtual void AfterImageApply(Image destination)
- {
- }
+ => destination.Metadata.AfterImageApply(destination);
///
/// Disposes the object and frees resources for the Garbage Collector.
diff --git a/src/ImageSharp/Processing/Processors/Dithering/IDither.cs b/src/ImageSharp/Processing/Processors/Dithering/IDither.cs
index ac2921b98..321760127 100644
--- a/src/ImageSharp/Processing/Processors/Dithering/IDither.cs
+++ b/src/ImageSharp/Processing/Processors/Dithering/IDither.cs
@@ -21,7 +21,7 @@ public interface IDither
/// The source image.
/// The destination quantized frame.
/// The region of interest bounds.
- void ApplyQuantizationDither(
+ public void ApplyQuantizationDither(
ref TFrameQuantizer quantizer,
ImageFrame source,
IndexedImageFrame destination,
@@ -38,7 +38,7 @@ public interface IDither
/// The palette dithering processor.
/// The source image.
/// The region of interest bounds.
- void ApplyPaletteDither(
+ public void ApplyPaletteDither(
in TPaletteDitherImageProcessor processor,
ImageFrame source,
Rectangle bounds)
diff --git a/src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs
index e406d82c6..347e2f0ef 100644
--- a/src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs
@@ -15,22 +15,22 @@ public interface IPaletteDitherImageProcessor
///
/// Gets the configuration instance to use when performing operations.
///
- Configuration Configuration { get; }
+ public Configuration Configuration { get; }
///
/// Gets the dithering palette.
///
- ReadOnlyMemory Palette { get; }
+ public ReadOnlyMemory Palette { get; }
///
/// Gets the dithering scale used to adjust the amount of dither. Range 0..1.
///
- float DitherScale { get; }
+ public float DitherScale { get; }
///
/// Returns the color from the dithering palette corresponding to the given color.
///
/// The color to match.
/// The match.
- TPixel GetPaletteColor(TPixel color);
+ public TPixel GetPaletteColor(TPixel color);
}
diff --git a/src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs
index 2fa79220e..e1f7d1fff 100644
--- a/src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs
@@ -95,7 +95,7 @@ public abstract class ImageProcessor : IImageProcessor
protected abstract void OnFrameApply(ImageFrame source);
///
- /// This method is called after the process is applied to prepare the processor.
+ /// This method is called after the process is applied to each frame.
///
/// The source image. Cannot be null.
protected virtual void AfterFrameApply(ImageFrame source)
@@ -103,11 +103,10 @@ public abstract class ImageProcessor : IImageProcessor
}
///
- /// This method is called after the process is applied to prepare the processor.
+ /// This method is called after the process is applied to the complete image.
///
protected virtual void AfterImageApply()
- {
- }
+ => this.Source.Metadata.AfterImageApply(this.Source);
///
/// Disposes the object and frees resources for the Garbage Collector.
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
index 4fd37d479..d11376e3b 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
@@ -2,6 +2,7 @@
// Licensed under the Six Labors Split License.
using System.Buffers;
+using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp.Memory;
@@ -21,13 +22,7 @@ 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 HybridColorDistanceCache cache;
private readonly Configuration configuration;
///
@@ -36,26 +31,12 @@ internal sealed class EuclideanPixelMap : IDisposable
/// 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);
+ this.cache = new HybridColorDistanceCache(configuration.MemoryAllocator);
PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
-
- this.transparentIndex = transparentIndex;
- this.transparentMatch = TPixel.FromRgba32(default);
}
///
@@ -70,21 +51,27 @@ internal sealed class EuclideanPixelMap : IDisposable
///
/// The color to match.
/// The matched color.
+ /// The transparency threshold.
/// The index.
- [MethodImpl(InliningOptions.ShortMethod)]
- public int GetClosestColor(TPixel color, out TPixel match)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public int GetClosestColor(TPixel color, out TPixel match, short transparencyThreshold = -1)
{
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
Rgba32 rgba = color.ToRgba32();
+ if (transparencyThreshold > -1 && rgba.A < transparencyThreshold)
+ {
+ rgba = default;
+ }
+
// Check if the color is in the lookup table
- if (!this.cache.TryGetValue(rgba, out short index))
+ 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;
}
- match = Unsafe.Add(ref paletteRef, (ushort)index);
- return index;
+ return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
}
///
@@ -96,46 +83,25 @@ internal sealed class EuclideanPixelMap : IDisposable
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)]
+ [MethodImpl(MethodImplOptions.NoInlining)]
private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
{
// Loop through the palette and find the nearest match.
int index = 0;
-
- 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 (candidate.PackedValue == rgba.PackedValue)
+ {
+ index = i;
+ break;
+ }
- // If it's an exact match, exit the loop
+ float distance = DistanceSquared(rgba, candidate);
if (distance == 0)
{
index = i;
@@ -144,7 +110,6 @@ internal sealed class EuclideanPixelMap : IDisposable
if (distance < leastDistance)
{
- // Less than... assign.
index = i;
leastDistance = distance;
}
@@ -153,6 +118,7 @@ internal sealed class EuclideanPixelMap : IDisposable
// 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;
}
@@ -162,96 +128,415 @@ internal sealed class EuclideanPixelMap : IDisposable
/// The first point.
/// The second point.
/// The distance squared.
- [MethodImpl(InliningOptions.ShortMethod)]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static float DistanceSquared(Rgba32 a, Rgba32 b)
{
- float deltaR = a.R - b.R;
- float deltaG = a.G - b.G;
- float deltaB = a.B - b.B;
- float deltaA = a.A - b.A;
- return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
+ Vector4 va = new(a.R, a.G, a.B, a.A);
+ Vector4 vb = new(b.R, b.G, b.B, b.A);
+ return Vector4.DistanceSquared(va, vb);
}
public void Dispose() => this.cache.Dispose();
///
- /// A cache for storing color distance matching results.
+ /// A hybrid color distance cache that combines a small, fixed-capacity exact-match dictionary
+ /// (ExactCache, ~4–5 KB for up to 512 entries) with a coarse lookup table (CoarseCache) for 5,5,5,6 precision.
///
///
- ///
- /// 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).
- ///
+ /// ExactCache provides O(1) lookup for common cases using a simple 256-entry hash-based dictionary, while CoarseCache
+ /// quantizes RGB channels to 5 bits (yielding 32^3 buckets) and alpha to 6 bits, storing up to 4 alpha entries per bucket
+ /// (a design chosen based on probability theory to capture most real-world variations) for a total memory footprint of
+ /// roughly 576 KB. Lookups and insertions are performed in constant time, making the overall design both fast and memory-predictable.
///
- private unsafe struct ColorDistanceCache : IDisposable
+#pragma warning disable CA1001 // Types that own disposable fields should be disposable
+ // https://github.com/dotnet/roslyn-analyzers/issues/6151
+ private readonly unsafe struct HybridColorDistanceCache : IDisposable
+#pragma warning restore CA1001 // Types that own disposable fields should be disposable
{
- private 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)
+ private readonly CoarseCache coarseCache;
+ private readonly ExactCache exactCache;
+
+ public HybridColorDistanceCache(MemoryAllocator allocator)
+ {
+ this.exactCache = new ExactCache(allocator);
+ this.coarseCache = new CoarseCache(allocator);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public readonly void Add(Rgba32 color, short index)
{
- this.table = allocator.Allocate(Entries);
- this.table.GetSpan().Fill(-1);
- this.tableHandle = this.table.Memory.Pin();
- this.tablePointer = (short*)this.tableHandle.Pointer;
+ if (this.exactCache.TryAdd(color.PackedValue, index))
+ {
+ return;
+ }
+
+ this.coarseCache.Add(color, index);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public readonly bool TryGetValue(Rgba32 color, out short match)
+ {
+ if (this.exactCache.TryGetValue(color.PackedValue, out match))
+ {
+ return true; // Exact match found
+ }
+
+ if (this.coarseCache.TryGetValue(color, out match))
+ {
+ return true; // Coarse match found
+ }
+
+ match = -1;
+ return false;
+ }
+
+ public readonly void Clear()
+ {
+ this.exactCache.Clear();
+ this.coarseCache.Clear();
+ }
+
+ public void Dispose()
+ {
+ this.exactCache.Dispose();
+ this.coarseCache.Dispose();
+ }
+ }
+
+ ///
+ /// 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 sealed unsafe class ExactCache : IDisposable
+ {
+ // Buckets array: each bucket holds the index (0-based) into the entries array
+ // of the first entry in the chain, or -1 if empty.
+ private readonly IMemoryOwner bucketsOwner;
+ private MemoryHandle bucketsHandle;
+ private short* buckets;
+
+ // Entries array: stores up to 256 entries.
+ private readonly IMemoryOwner entriesOwner;
+ private MemoryHandle entriesHandle;
+ private Entry* entries;
+
+ public const int Capacity = 512;
+
+ public ExactCache(MemoryAllocator allocator)
+ {
+ this.Count = 0;
+
+ // Allocate exactly 512 ints for buckets.
+ this.bucketsOwner = allocator.Allocate(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;
}
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Add(Rgba32 rgba, byte index)
+ public int Count { get; private set; }
+
+ ///
+ /// Adds a key/value pair to the dictionary.
+ /// If the key already exists, the dictionary is left unchanged.
+ ///
+ /// The key to add.
+ /// The value to add.
+ /// if the key was added; otherwise, .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryAdd(uint key, short value)
{
- int idx = GetPaletteIndex(rgba);
- this.tablePointer[idx] = index;
+ if (this.Count == Capacity)
+ {
+ return false; // Dictionary is full.
+ }
+
+ // The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A
+ // (with R in the most significant byte and A in the least significant).
+ // To compute the bucket index:
+ // 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels.
+ // 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A).
+ // 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A),
+ // which helps to counteract situations where one or more channels have a limited range.
+ // 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511,
+ // which corresponds to our fixed bucket count of 512.
+ int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
+ int i = this.buckets[bucket];
+
+ // Traverse the collision chain.
+ Entry* entries = this.entries;
+ while (i != -1)
+ {
+ Entry e = entries[i];
+ if (e.Key == key)
+ {
+ // Key already exists; do not overwrite.
+ return false;
+ }
+
+ i = e.Next;
+ }
+
+ short index = (short)this.Count;
+ this.Count++;
+
+ // Insert the new entry:
+ entries[index].Key = key;
+ entries[index].Value = value;
+
+ // Link this new entry into the bucket chain.
+ entries[index].Next = this.buckets[bucket];
+ this.buckets[bucket] = index;
+ return true;
}
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryGetValue(Rgba32 rgba, out short match)
+ ///
+ /// Tries to retrieve the value associated with the specified key.
+ /// Returns true if the key is found; otherwise, returns false.
+ ///
+ /// The key to search for.
+ /// The value associated with the key, if found.
+ /// if the key is found; otherwise, .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryGetValue(uint key, out short value)
{
- int idx = GetPaletteIndex(rgba);
- match = this.tablePointer[idx];
- return match > -1;
+ 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 cache resetting each entry to empty.
+ /// Clears the dictionary.
///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Clear() => this.table.GetSpan().Fill(-1);
+ public void Clear()
+ {
+ Span bucketSpan = this.bucketsOwner.GetSpan();
+ bucketSpan.Fill(-1);
+ this.Count = 0;
+ }
- [MethodImpl(InliningOptions.ShortMethod)]
- private static int GetPaletteIndex(Rgba32 rgba)
+ public void Dispose()
{
- 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;
+ 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.
+ }
+ }
+
+ ///
+ ///
+ /// CoarseCache is a fast, low-memory lookup structure for caching palette indices associated with RGBA values,
+ /// using a quantized representation of 5,5,5,6 (RGB: 5 bits each, Alpha: 6 bits).
+ ///
+ ///
+ /// The cache quantizes the RGB channels to 5 bits each, resulting in 32 levels per channel and a total of 32³ = 32,768 buckets.
+ /// Each bucket is represented by an , which holds a small, inline array of alpha entries.
+ /// Each alpha entry stores the alpha value quantized to 6 bits (0–63) along with a palette index (a 16-bit value).
+ ///
+ ///
+ /// Performance Characteristics:
+ /// - Lookup: O(1) for computing the bucket index from the RGB channels, plus a small constant time (up to 4 iterations)
+ /// to search through the alpha entries in the bucket.
+ /// - Insertion: O(1) for bucket index computation and a quick linear search over a very small (fixed) number of entries.
+ ///
+ ///
+ /// Memory Characteristics:
+ /// - The cache consists of 32,768 buckets.
+ /// - Each is implemented using an inline array with a capacity of 4 entries.
+ /// - Each bucket occupies approximately 18 bytes.
+ /// - Overall, the buckets occupy roughly 32,768 × 18 = 589,824 bytes (576 KB).
+ ///
+ ///
+ /// This design provides nearly constant-time lookup and insertion with minimal memory usage,
+ /// making it ideal for applications such as color distance caching in images with a limited palette (up to 256 entries).
+ ///
+ ///
+ internal sealed unsafe class CoarseCache : IDisposable
+ {
+ // Use 5 bits per channel for R, G, and B: 32 levels each.
+ // Total buckets = 32^3 = 32768.
+ private const int RgbBits = 5;
+ private const int BucketCount = 1 << (RgbBits * 3); // 32768
+ private readonly IMemoryOwner bucketsOwner;
+ private readonly AlphaBucket* buckets;
+ private MemoryHandle bucketHandle;
+
+ public CoarseCache(MemoryAllocator allocator)
+ {
+ this.bucketsOwner = allocator.Allocate(BucketCount, AllocationOptions.Clean);
+ this.bucketHandle = this.bucketsOwner.Memory.Pin();
+ this.buckets = (AlphaBucket*)this.bucketHandle.Pointer;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static int GetBucketIndex(byte r, byte g, byte b)
+ {
+ int qr = r >> (8 - RgbBits);
+ int qg = g >> (8 - RgbBits);
+ int qb = b >> (8 - RgbBits);
+
+ // Combine the quantized channels into a single index.
+ return (qr << (RgbBits * 2)) | (qg << RgbBits) | qb;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static byte QuantizeAlpha(byte a)
+
+ // Quantize to 6 bits: shift right by (8 - 6) = 2 bits.
+ => (byte)(a >> 2);
+
+ public void Add(Rgba32 color, short paletteIndex)
+ {
+ int bucketIndex = GetBucketIndex(color.R, color.G, color.B);
+ byte quantAlpha = QuantizeAlpha(color.A);
+ this.buckets[bucketIndex].Add(quantAlpha, paletteIndex);
}
public void Dispose()
{
- if (this.table != null)
+ this.bucketHandle.Dispose();
+ this.bucketsOwner.Dispose();
+ }
+
+ public bool TryGetValue(Rgba32 color, out short paletteIndex)
+ {
+ int bucketIndex = GetBucketIndex(color.R, color.G, color.B);
+ byte quantAlpha = QuantizeAlpha(color.A);
+ return this.buckets[bucketIndex].TryGetValue(quantAlpha, out paletteIndex);
+ }
+
+ public void Clear()
+ {
+ Span bucketsSpan = this.bucketsOwner.GetSpan();
+ bucketsSpan.Clear();
+ }
+
+ public struct AlphaEntry
+ {
+ // Store the alpha value quantized to 6 bits (0..63)
+ public byte QuantizedAlpha;
+ public short PaletteIndex;
+ }
+
+ public struct AlphaBucket
+ {
+ // Fixed capacity for alpha entries in this bucket.
+ // We choose a capacity of 4 for several reasons:
+ //
+ // 1. The alpha channel is quantized to 6 bits, so there are 64 possible distinct values.
+ // In the worst-case, a given RGB bucket might encounter up to 64 different alpha values.
+ //
+ // 2. However, in practice (based on probability theory and typical image data),
+ // the number of unique alpha values that actually occur for a given quantized RGB
+ // bucket is usually very small. If you randomly sample 4 values out of 64,
+ // the probability that these 4 samples are all unique is high if the distribution
+ // of alpha values is skewed or if only a few alpha values are used.
+ //
+ // 3. Statistically, for many real-world images, most RGB buckets will have only a couple
+ // of unique alpha values. Allocating 4 slots per bucket provides a good trade-off:
+ // it captures the common-case scenario while keeping overall memory usage low.
+ //
+ // 4. Even if more than 4 unique alpha values occur in a bucket,
+ // our design overwrites the first entry. This behavior gives us some "wriggle room"
+ // while preserving the most frequently encountered or most recent values.
+ public const int Capacity = 4;
+ public byte Count;
+ private InlineArray4 entries;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public bool TryGetValue(byte quantizedAlpha, out short paletteIndex)
{
- this.tableHandle.Dispose();
- this.table.Dispose();
+ for (int i = 0; i < this.Count; i++)
+ {
+ ref AlphaEntry entry = ref this.entries[i];
+ if (entry.QuantizedAlpha == quantizedAlpha)
+ {
+ paletteIndex = entry.PaletteIndex;
+ return true;
+ }
+ }
+
+ paletteIndex = -1;
+ return false;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public void Add(byte quantizedAlpha, short paletteIndex)
+ {
+ // Check for an existing entry with the same quantized alpha.
+ for (int i = 0; i < this.Count; i++)
+ {
+ ref AlphaEntry entry = ref this.entries[i];
+ if (entry.QuantizedAlpha == quantizedAlpha)
+ {
+ // Update palette index if found.
+ entry.PaletteIndex = paletteIndex;
+ return;
+ }
+ }
+
+ // If there's room, add a new entry.
+ if (this.Count < Capacity)
+ {
+ ref AlphaEntry newEntry = ref this.entries[this.Count];
+ newEntry.QuantizedAlpha = quantizedAlpha;
+ newEntry.PaletteIndex = paletteIndex;
+ this.Count++;
+ }
+ else
+ {
+ // Bucket is full. Overwrite the first entry to give us some wriggle room.
+ this.entries[0].QuantizedAlpha = quantizedAlpha;
+ this.entries[0].PaletteIndex = paletteIndex;
+ }
}
}
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs
index 35bbb1289..dc5bdbd62 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs
@@ -16,12 +16,12 @@ public interface IQuantizer : IDisposable
///
/// Gets the configuration.
///
- Configuration Configuration { get; }
+ public Configuration Configuration { get; }
///
/// Gets the quantizer options defining quantization rules.
///
- QuantizerOptions Options { get; }
+ public QuantizerOptions Options { get; }
///
/// Gets the quantized color palette.
@@ -29,13 +29,13 @@ public interface IQuantizer : IDisposable
///
/// The palette has not been built via .
///
- ReadOnlyMemory Palette { get; }
+ public ReadOnlyMemory Palette { get; }
///
/// Adds colors to the quantized palette from the given pixel source.
///
/// The of source pixels to register.
- void AddPaletteColors(Buffer2DRegion pixelRegion);
+ public void AddPaletteColors(in Buffer2DRegion pixelRegion);
///
/// Quantizes an image frame and return the resulting output pixels.
@@ -49,7 +49,7 @@ public interface IQuantizer : IDisposable
/// Only executes the second (quantization) step. The palette has to be built by calling .
/// To run both steps, use .
///
- IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds);
+ public IndexedImageFrame QuantizeFrame(ImageFrame source, Rectangle bounds);
///
/// Returns the index and color from the quantized palette corresponding to the given color.
@@ -57,7 +57,7 @@ public interface IQuantizer : IDisposable
/// The color to match.
/// The matched color.
/// The index.
- byte GetQuantizedColor(TPixel color, out TPixel match);
+ public byte GetQuantizedColor(TPixel color, out TPixel match);
// TODO: Enable bulk operations.
// void GetQuantizedColors(ReadOnlySpan colors, ReadOnlySpan palette, Span indices, Span matches);
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
index 8b39b7457..be00bc433 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
@@ -30,6 +30,7 @@ public struct OctreeQuantizer : IQuantizer
private ReadOnlyMemory palette;
private EuclideanPixelMap? pixelMap;
private readonly bool isDithering;
+ private readonly short transparencyThreshold;
private bool isDisposed;
///
@@ -44,8 +45,9 @@ public struct OctreeQuantizer : IQuantizer
this.Options = options;
this.maxColors = this.Options.MaxColors;
+ this.transparencyThreshold = (short)(this.Options.TransparencyThreshold * 255);
this.bitDepth = Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(this.maxColors), 1, 8);
- this.octree = new Octree(this.bitDepth);
+ this.octree = new Octree(this.bitDepth, this.maxColors, this.transparencyThreshold, configuration.MemoryAllocator);
this.paletteOwner = configuration.MemoryAllocator.Allocate(this.maxColors, AllocationOptions.Clean);
this.pixelMap = default;
this.palette = default;
@@ -60,65 +62,54 @@ public struct OctreeQuantizer : IQuantizer
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(in 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++)
+ Octree octree = this.octree;
+ int transparencyThreshold = this.transparencyThreshold;
+ 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
+ octree.AddColor(bufferSpan[x]);
}
}
+ }
- int paletteIndex = 0;
+ private void ResolvePalette()
+ {
+ short paletteIndex = 0;
Span paletteSpan = this.paletteOwner.GetSpan();
- // On very rare occasions, (blur.png), the quantizer does not preserve a
- // transparent entry when palletizing the captured colors.
- // To workaround this we ensure the palette ends with the default color
- // for higher bit depths. Lower bit depths will correctly reduce the palette.
- // TODO: Investigate more evenly reduced palette reduction.
- int max = this.maxColors;
- if (this.bitDepth >= 4)
- {
- max--;
- }
-
- this.octree.Palletize(paletteSpan, max, ref paletteIndex);
+ this.octree.Palettize(paletteSpan, 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)
+ if (this.isDithering)
{
this.pixelMap = new EuclideanPixelMap(this.Configuration, result);
}
- else
- {
- this.pixelMap.Clear(result);
- }
this.palette = result;
}
@@ -132,18 +123,19 @@ public struct OctreeQuantizer : IQuantizer
[MethodImpl(InliningOptions.ShortMethod)]
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
{
- // Octree only maps the RGB component of a color
- // so cannot tell the difference between a fully transparent
- // pixel and a black one.
- if (this.isDithering || color.Equals(default))
+ // Due to the addition of new colors by dithering that are not part of the original histogram,
+ // the octree nodes might not match the correct color.
+ // In this case, we must use the pixel map to get the closest color.
+ if (this.isDithering)
{
- return (byte)this.pixelMap!.GetClosestColor(color, out match);
+ return (byte)this.pixelMap!.GetClosestColor(color, out match, this.transparencyThreshold);
}
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.palette.Span);
- byte index = (byte)this.octree.GetPaletteIndex(color);
- match = Unsafe.Add(ref paletteRef, index);
- return index;
+
+ int index = this.octree.GetPaletteIndex(color);
+ match = Unsafe.Add(ref paletteRef, (nuint)index);
+ return (byte)index;
}
///
@@ -155,16 +147,521 @@ public struct OctreeQuantizer : IQuantizer
this.paletteOwner.Dispose();
this.pixelMap?.Dispose();
this.pixelMap = null;
+ this.octree.Dispose();
+ }
+ }
+
+ ///
+ /// A hexadecatree-based color quantization structure used for fast color distance lookups and palette generation.
+ /// This tree maintains a fixed pool of nodes (capacity 4096) where each node can have up to 16 children, stores
+ /// color accumulation data, and supports dynamic node allocation and reduction. It offers near-constant-time insertions
+ /// and lookups while consuming roughly 240 KB for the node pool.
+ ///
+ internal sealed class Octree : IDisposable
+ {
+ // Pooled buffer for OctreeNodes.
+ private readonly IMemoryOwner nodesOwner;
+
+ // Reducible nodes: one per level; we use an integer index; -1 means “no node.”
+ private readonly short[] reducibleNodes;
+
+ // Maximum number of allowable colors.
+ private readonly int maxColors;
+
+ // Maximum significant bits.
+ private readonly int maxColorBits;
+
+ // The threshold for transparent colors.
+ private readonly short transparencyThreshold;
+
+ // Instead of a reference to the root, we store the index of the root node.
+ // Index 0 is reserved for the root.
+ private readonly short rootIndex;
+
+ // Running index for node allocation. Start at 1 so that index 0 is reserved for the root.
+ private short nextNode = 1;
+
+ // Previously quantized node (index; -1 if none) and its color.
+ private int previousNode;
+ private Rgba32 previousColor;
+
+ // Free list for reclaimed node indices.
+ private readonly Stack freeIndices = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The maximum number of significant bits in the image.
+ /// The maximum number of colors to allow in the palette.
+ /// The threshold for transparent colors.
+ /// The memory allocator.
+ public Octree(int maxColorBits, int maxColors, short transparencyThreshold, MemoryAllocator allocator)
+ {
+ this.maxColorBits = maxColorBits;
+ this.maxColors = maxColors;
+ this.transparencyThreshold = transparencyThreshold;
+ this.Leaves = 0;
+ this.previousNode = -1;
+ this.previousColor = default;
+
+ // Allocate a conservative buffer for nodes.
+ const int capacity = 4096;
+ this.nodesOwner = allocator.Allocate(capacity, AllocationOptions.Clean);
+
+ // Create the reducible nodes array (one per level 0 .. maxColorBits-1).
+ this.reducibleNodes = new short[this.maxColorBits];
+ this.reducibleNodes.AsSpan().Fill(-1);
+
+ // Reserve index 0 for the root.
+ this.rootIndex = 0;
+ ref OctreeNode root = ref this.Nodes[this.rootIndex];
+ root.Initialize(0, this.maxColorBits, this, this.rootIndex);
+ }
+
+ ///
+ /// Gets or sets the number of leaves in the tree.
+ ///
+ public int Leaves { get; set; }
+
+ ///
+ /// Gets the full collection of nodes as a span.
+ ///
+ internal Span Nodes => this.nodesOwner.Memory.Span;
+
+ ///