Browse Source

Merge pull request #2894 from SixLabors/js/fix-2866-v4

V4 : Fix GIF, PNG, and WEBP Edge Case Handling
pull/2926/head
James Jackson-South 1 year ago
committed by GitHub
parent
commit
62da42dd68
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      src/ImageSharp/Advanced/AotCompilerTools.cs
  2. 38
      src/ImageSharp/Common/InlineArray.cs
  3. 38
      src/ImageSharp/Common/InlineArray.tt
  4. 4
      src/ImageSharp/Formats/AlphaAwareImageEncoder.cs
  5. 7
      src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs
  6. 3
      src/ImageSharp/Formats/Bmp/BmpMetadata.cs
  7. 3
      src/ImageSharp/Formats/Cur/CurFrameMetadata.cs
  8. 9
      src/ImageSharp/Formats/Cur/CurMetadata.cs
  9. 138
      src/ImageSharp/Formats/EncodingUtilities.cs
  10. 5
      src/ImageSharp/Formats/FormatConnectingFrameMetadata.cs
  11. 5
      src/ImageSharp/Formats/FormatConnectingMetadata.cs
  12. 186
      src/ImageSharp/Formats/Gif/GifDecoderCore.cs
  13. 238
      src/ImageSharp/Formats/Gif/GifEncoderCore.cs
  14. 29
      src/ImageSharp/Formats/Gif/GifFrameMetadata.cs
  15. 41
      src/ImageSharp/Formats/Gif/GifMetadata.cs
  16. 6
      src/ImageSharp/Formats/IAnimatedImageEncoder.cs
  17. 6
      src/ImageSharp/Formats/IFormatFrameMetadata.cs
  18. 8
      src/ImageSharp/Formats/IFormatMetadata.cs
  19. 4
      src/ImageSharp/Formats/IQuantizingImageEncoder.cs
  20. 2
      src/ImageSharp/Formats/ISpecializedDecoderOptions.cs
  21. 5
      src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs
  22. 9
      src/ImageSharp/Formats/Ico/IcoMetadata.cs
  23. 16
      src/ImageSharp/Formats/Icon/IconEncoderCore.cs
  24. 78
      src/ImageSharp/Formats/Png/PngDecoderCore.cs
  25. 17
      src/ImageSharp/Formats/Png/PngEncoder.cs
  26. 281
      src/ImageSharp/Formats/Png/PngEncoderCore.cs
  27. 24
      src/ImageSharp/Formats/Png/PngMetadata.cs
  28. 6
      src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs
  29. 6
      src/ImageSharp/Formats/Tga/TgaEncoderCore.cs
  30. 6
      src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs
  31. 4
      src/ImageSharp/Formats/TransparentColorMode.cs
  32. 2
      src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs
  33. 4
      src/ImageSharp/Formats/Webp/Lossy/Vp8Decoder.cs
  34. 110
      src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs
  35. 2
      src/ImageSharp/Formats/Webp/WebpCommonUtils.cs
  36. 6
      src/ImageSharp/IDeepCloneable.cs
  37. 9
      src/ImageSharp/ImageSharp.csproj
  38. 8
      src/ImageSharp/IndexedImageFrame{TPixel}.cs
  39. 6
      src/ImageSharp/Processing/Processors/CloningImageProcessor{TPixel}.cs
  40. 0
      src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs
  41. 4
      src/ImageSharp/Processing/Processors/Dithering/IDither.cs
  42. 8
      src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs
  43. 4
      src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
  44. 7
      src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs
  45. 28
      src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
  46. 184
      src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
  47. 258
      src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
  48. 569
      src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
  49. 6
      src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs
  50. 18
      src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs
  51. 21
      src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs
  52. 685
      src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
  53. 23
      src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs
  54. 63
      src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
  55. 19
      src/ImageSharp/Processing/Processors/Quantization/QuantizerConstants.cs
  56. 53
      src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
  57. 340
      src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs
  58. 2
      src/ImageSharp/Processing/Processors/Quantization/WebSafePaletteQuantizer.cs
  59. 2
      src/ImageSharp/Processing/Processors/Quantization/WernerPaletteQuantizer.cs
  60. 138
      src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
  61. 15
      src/ImageSharp/Processing/Processors/Transforms/TransformProcessor{TPixel}.cs
  62. 59
      tests/ImageSharp.Benchmarks/Codecs/Gif/DecodeEncodeGif.cs
  63. 42
      tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
  64. 35
      tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
  65. 19
      tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
  66. 5
      tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs
  67. 63
      tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
  68. 4
      tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs
  69. 16
      tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs
  70. 59
      tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs
  71. 4
      tests/ImageSharp.Tests/Quantization/WuQuantizerTests.cs
  72. 33
      tests/ImageSharp.Tests/TestImages.cs
  73. 36
      tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs
  74. 91
      tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs
  75. 2
      tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp
  76. 3
      tests/Images/External/ReferenceOutput/DitherTests/ApplyDitherFilterInBox_Rgba32_CalliphoraPartial - Copy.png
  77. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Atkinson.png
  78. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Burks.png
  79. 2
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_FloydSteinberg.png
  80. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_JarvisJudiceNinke.png
  81. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Sierra2.png
  82. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Sierra3.png
  83. 4
      tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Stucki.png
  84. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Bgra32_filter0.png
  85. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Rgb24_filter0.png
  86. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Rgba32_filter0.png
  87. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_RgbaVector_filter0.png
  88. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_WorksWithAllDitherers_CalliphoraPartial_Bayer16x16.png
  89. 4
      tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_WorksWithAllDitherers_CalliphoraPartial_Bayer8x8.png
  90. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/00.png
  91. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/01.png
  92. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/02.png
  93. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/03.png
  94. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/00.png
  95. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/01.png
  96. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/02.png
  97. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/03.png
  98. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/00.png
  99. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/01.png
  100. 3
      tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/02.png

17
src/ImageSharp/Advanced/AotCompilerTools.cs

@ -138,10 +138,11 @@ internal static class AotCompilerTools
AotCompileResamplers<TPixel>();
AotCompileQuantizers<TPixel>();
AotCompilePixelSamplingStrategys<TPixel>();
AotCompilePixelMaps<TPixel>();
AotCompileDithers<TPixel>();
AotCompileMemoryManagers<TPixel>();
Unsafe.SizeOf<TPixel>();
_ = Unsafe.SizeOf<TPixel>();
// TODO: Do the discovery work to figure out what works and what doesn't.
}
@ -514,6 +515,20 @@ internal static class AotCompilerTools
default(ExtensivePixelSamplingStrategy).EnumeratePixelRegions(default(ImageFrame<TPixel>));
}
/// <summary>
/// This method pre-seeds the all <see cref="IColorIndexCache{T}" /> in the AoT compiler.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
[Preserve]
private static void AotCompilePixelMaps<TPixel>()
where TPixel : unmanaged, IPixel<TPixel>
{
default(EuclideanPixelMap<TPixel, HybridCache>).GetClosestColor(default, out _);
default(EuclideanPixelMap<TPixel, AccurateCache>).GetClosestColor(default, out _);
default(EuclideanPixelMap<TPixel, CoarseCache>).GetClosestColor(default, out _);
default(EuclideanPixelMap<TPixel, NullCache>).GetClosestColor(default, out _);
}
/// <summary>
/// This method pre-seeds the all <see cref="IDither" /> in the AoT compiler.
/// </summary>

38
src/ImageSharp/Common/InlineArray.cs

@ -0,0 +1,38 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
// <auto-generated />
using System;
using System.Runtime.CompilerServices;
namespace SixLabors.ImageSharp;
/// <summary>
/// Represents a safe, fixed sized buffer of 4 elements.
/// </summary>
[InlineArray(4)]
internal struct InlineArray4<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 8 elements.
/// </summary>
[InlineArray(8)]
internal struct InlineArray8<T>
{
private T t;
}
/// <summary>
/// Represents a safe, fixed sized buffer of 16 elements.
/// </summary>
[InlineArray(16)]
internal struct InlineArray16<T>
{
private T t;
}

38
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.
// <auto-generated />
using System;
using System.Runtime.CompilerServices;
namespace SixLabors.ImageSharp;
<#GenerateInlineArrays();#>
<#+
private static int[] Lengths = new int[] {4, 8, 16 };
void GenerateInlineArrays()
{
foreach (int length in Lengths)
{
#>
/// <summary>
/// Represents a safe, fixed sized buffer of <#=length#> elements.
/// </summary>
[InlineArray(<#=length#>)]
internal struct InlineArray<#=length#><T>
{
private T t;
}
<#+
}
}
#>

4
src/ImageSharp/Formats/AlphaAwareImageEncoder.cs

@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace SixLabors.ImageSharp.Formats;
/// <summary>
@ -10,6 +12,8 @@ public abstract class AlphaAwareImageEncoder : ImageEncoder
{
/// <summary>
/// Gets or initializes the mode that determines how transparent pixels are handled during encoding.
/// This overrides any other settings that may affect the encoding of transparent pixels
/// including those passed via <see cref="QuantizerOptions"/>.
/// </summary>
public TransparentColorMode TransparentColorMode { get; init; }
}

7
src/ImageSharp/Formats/Bmp/BmpEncoderCore.cs

@ -362,10 +362,13 @@ internal sealed class BmpEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
// No need to clone when quantizing. The quantizer will do it for us.
// TODO: We should really try to avoid the clone entirely.
int bpp = this.bitsPerPixel != null ? (int)this.bitsPerPixel : 32;
if (bpp > 8 && EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

3
src/ImageSharp/Formats/Bmp/BmpMetadata.cs

@ -158,6 +158,5 @@ public class BmpMetadata : IFormatMetadata<BmpMetadata>
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.ColorTable = null;
}

3
src/ImageSharp/Formats/Cur/CurFrameMetadata.cs

@ -104,7 +104,6 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
Compression = compression,
EncodingWidth = ClampEncodingDimension(metadata.EncodingWidth),
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight),
ColorTable = compression == IconFrameCompression.Bmp ? metadata.ColorTable : null
};
}
@ -113,7 +112,6 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
=> new()
{
PixelTypeInfo = this.GetPixelTypeInfo(),
ColorTable = this.ColorTable,
EncodingWidth = this.EncodingWidth,
EncodingHeight = this.EncodingHeight
};
@ -126,6 +124,7 @@ public class CurFrameMetadata : IFormatFrameMetadata<CurFrameMetadata>
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;
}
/// <inheritdoc/>

9
src/ImageSharp/Formats/Cur/CurMetadata.cs

@ -71,8 +71,7 @@ public class CurMetadata : IFormatMetadata<CurMetadata>
return new CurMetadata
{
BmpBitsPerPixel = bbpp,
Compression = compression,
ColorTable = compression == IconFrameCompression.Bmp ? metadata.ColorTable : null
Compression = compression
};
}
@ -145,15 +144,13 @@ public class CurMetadata : IFormatMetadata<CurMetadata>
EncodingType = this.Compression == IconFrameCompression.Bmp && this.BmpBitsPerPixel <= BmpBitsPerPixel.Bit8
? EncodingType.Lossy
: EncodingType.Lossless,
PixelTypeInfo = this.GetPixelTypeInfo(),
ColorTable = this.ColorTable
PixelTypeInfo = this.GetPixelTypeInfo()
};
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.ColorTable = null;
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();

138
src/ImageSharp/Formats/EncodingUtilities.cs

@ -3,6 +3,8 @@
using System.Buffers;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
@ -14,62 +16,132 @@ namespace SixLabors.ImageSharp.Formats;
/// </summary>
internal static class EncodingUtilities
{
public static bool ShouldClearTransparentPixels<TPixel>(TransparentColorMode mode)
/// <summary>
/// Determines if transparent pixels can be replaced based on the specified color mode and pixel type.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="mode">Indicates the color mode used to assess the ability to replace transparent pixels.</param>
/// <returns>Returns true if transparent pixels can be replaced; otherwise, false.</returns>
public static bool ShouldReplaceTransparentPixels<TPixel>(TransparentColorMode mode)
where TPixel : unmanaged, IPixel<TPixel>
=> mode == TransparentColorMode.Clear && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
/// <summary>
/// Replaces pixels with a transparent alpha component with fully transparent pixels.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="frame">The <see cref="ImageFrame{TPixel}"/> where the transparent pixels will be changed.</param>
public static void ReplaceTransparentPixels<TPixel>(ImageFrame<TPixel> frame)
where TPixel : unmanaged, IPixel<TPixel>
=> mode == TransparentColorMode.Clear &&
TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
=> ReplaceTransparentPixels(frame.Configuration, frame.PixelBuffer);
/// <summary>
/// Convert transparent pixels, to pixels represented by <paramref name="color"/>, which can yield
/// to better compression in some cases.
/// Replaces pixels with a transparent alpha component with fully transparent pixels.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="clone">The cloned <see cref="ImageFrame{TPixel}"/> where the transparent pixels will be changed.</param>
/// <param name="color">The color to replace transparent pixels with.</param>
public static void ClearTransparentPixels<TPixel>(ImageFrame<TPixel> clone, Color color)
/// <param name="configuration">The configuration.</param>
/// <param name="buffer">The <see cref="Buffer2D{TPixel}"/> where the transparent pixels will be changed.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ReplaceTransparentPixels<TPixel>(Configuration configuration, Buffer2D<TPixel> buffer)
where TPixel : unmanaged, IPixel<TPixel>
{
Buffer2DRegion<TPixel> buffer = clone.PixelBuffer.GetRegion();
ClearTransparentPixels(clone.Configuration, ref buffer, color);
Buffer2DRegion<TPixel> region = buffer.GetRegion();
ReplaceTransparentPixels(configuration, in region);
}
/// <summary>
/// Convert transparent pixels, to pixels represented by <paramref name="color"/>, which can yield
/// to better compression in some cases.
/// Replaces pixels with a transparent alpha component with fully transparent pixels.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="configuration">The configuration.</param>
/// <param name="clone">The cloned <see cref="Buffer2DRegion{T}"/> where the transparent pixels will be changed.</param>
/// <param name="color">The color to replace transparent pixels with.</param>
public static void ClearTransparentPixels<TPixel>(
/// <param name="region">The <see cref="Buffer2DRegion{T}"/> where the transparent pixels will be changed.</param>
public static void ReplaceTransparentPixels<TPixel>(
Configuration configuration,
ref Buffer2DRegion<TPixel> clone,
Color color)
in Buffer2DRegion<TPixel> region)
where TPixel : unmanaged, IPixel<TPixel>
{
using IMemoryOwner<Vector4> vectors = configuration.MemoryAllocator.Allocate<Vector4>(clone.Width);
using IMemoryOwner<Vector4> vectors = configuration.MemoryAllocator.Allocate<Vector4>(region.Width);
Span<Vector4> vectorsSpan = vectors.GetSpan();
Vector4 replacement = color.ToScaledVector4();
for (int y = 0; y < clone.Height; y++)
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> span = clone.DangerousGetRowSpan(y);
Span<TPixel> span = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, span, vectorsSpan, PixelConversionModifiers.Scale);
ClearTransparentPixelRow(vectorsSpan, replacement);
ReplaceTransparentPixels(vectorsSpan);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorsSpan, span, PixelConversionModifiers.Scale);
}
}
private static void ClearTransparentPixelRow(
Span<Vector4> vectorsSpan,
Vector4 replacement)
/// <summary>
/// Replaces pixels with a transparent alpha component with fully transparent pixels.
/// </summary>
/// <param name="source">A span of color vectors that will be checked for transparency and potentially modified.</param>
public static void ReplaceTransparentPixels(Span<Vector4> source)
{
if (Vector128.IsHardwareAccelerated)
if (Vector512.IsHardwareAccelerated && source.Length >= 4)
{
Span<Vector512<float>> source512 = MemoryMarshal.Cast<Vector4, Vector512<float>>(source);
for (int i = 0; i < source512.Length; i++)
{
ref Vector512<float> v = ref source512[i];
// Do `vector < threshold`
Vector512<float> mask = Vector512.Equals(v, Vector512<float>.Zero);
// Replicate the result for W to all elements (is AllBitsSet if the W was 0 and Zero otherwise)
mask = Vector512.Shuffle(mask, Vector512.Create(3, 3, 3, 3, 7, 7, 7, 7, 11, 11, 11, 11, 15, 15, 15, 15));
// Use the mask to select the replacement vector
// (replacement & mask) | (v512 & ~mask)
v = Vector512.ConditionalSelect(mask, Vector512<float>.Zero, v);
}
int m = Numerics.Modulo4(source.Length);
if (m != 0)
{
for (int i = source.Length - m; i < source.Length; i++)
{
if (source[i].W == 0)
{
source[i] = Vector4.Zero;
}
}
}
}
else if (Vector256.IsHardwareAccelerated && source.Length >= 2)
{
Vector128<float> replacement128 = replacement.AsVector128();
Span<Vector256<float>> source256 = MemoryMarshal.Cast<Vector4, Vector256<float>>(source);
for (int i = 0; i < source256.Length; i++)
{
ref Vector256<float> v = ref source256[i];
// Do `vector < threshold`
Vector256<float> mask = Vector256.Equals(v, Vector256<float>.Zero);
// Replicate the result for W to all elements (is AllBitsSet if the W was 0 and Zero otherwise)
mask = Vector256.Shuffle(mask, Vector256.Create(3, 3, 3, 3, 7, 7, 7, 7));
for (int i = 0; i < vectorsSpan.Length; i++)
// Use the mask to select the replacement vector
// (replacement & mask) | (v256 & ~mask)
v = Vector256.ConditionalSelect(mask, Vector256<float>.Zero, v);
}
int m = Numerics.Modulo2(source.Length);
if (m != 0)
{
for (int i = source.Length - m; i < source.Length; i++)
{
if (source[i].W == 0)
{
source[i] = Vector4.Zero;
}
}
}
}
else if (Vector128.IsHardwareAccelerated)
{
for (int i = 0; i < source.Length; i++)
{
ref Vector4 v = ref vectorsSpan[i];
ref Vector4 v = ref source[i];
Vector128<float> v128 = v.AsVector128();
// Do `vector == 0`
@ -80,16 +152,16 @@ internal static class EncodingUtilities
// Use the mask to select the replacement vector
// (replacement & mask) | (v128 & ~mask)
v = Vector128.ConditionalSelect(mask, replacement128, v128).AsVector4();
v = Vector128.ConditionalSelect(mask, Vector128<float>.Zero, v128).AsVector4();
}
}
else
{
for (int i = 0; i < vectorsSpan.Length; i++)
for (int i = 0; i < source.Length; i++)
{
if (vectorsSpan[i].W == 0F)
if (source[i].W == 0F)
{
vectorsSpan[i] = replacement;
source[i] = Vector4.Zero;
}
}
}

5
src/ImageSharp/Formats/FormatConnectingFrameMetadata.cs

@ -15,11 +15,6 @@ public class FormatConnectingFrameMetadata
/// </summary>
public PixelTypeInfo? PixelTypeInfo { get; init; }
/// <summary>
/// Gets the frame color table if any.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; init; }
/// <summary>
/// Gets the frame color table mode.
/// </summary>

5
src/ImageSharp/Formats/FormatConnectingMetadata.cs

@ -28,11 +28,6 @@ public class FormatConnectingMetadata
/// </summary>
public PixelTypeInfo PixelTypeInfo { get; init; }
/// <summary>
/// Gets the shared color table if any.
/// </summary>
public ReadOnlyMemory<Color>? ColorTable { get; init; }
/// <summary>
/// Gets the shared color table mode.
/// </summary>

186
src/ImageSharp/Formats/Gif/GifDecoderCore.cs

@ -89,6 +89,11 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// </summary>
private GifMetadata? gifMetadata;
/// <summary>
/// The background color index.
/// </summary>
private byte backgroundColorIndex;
/// <summary>
/// Initializes a new instance of the <see cref="GifDecoderCore"/> class.
/// </summary>
@ -108,6 +113,10 @@ internal sealed class GifDecoderCore : ImageDecoderCore
uint frameCount = 0;
Image<TPixel>? image = null;
ImageFrame<TPixel>? previousFrame = null;
FrameDisposalMode? previousDisposalMode = null;
bool globalColorTableUsed = false;
Color backgroundColor = Color.Transparent;
try
{
this.ReadLogicalScreenDescriptorAndGlobalColorTable(stream);
@ -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, ref backgroundColor);
// 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<ImageFrameMetadata> 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
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
/// <param name="image">The image to decode the information to.</param>
/// <param name="previousFrame">The previous frame.</param>
private void ReadFrame<TPixel>(BufferedReadStream stream, ref Image<TPixel>? image, ref ImageFrame<TPixel>? previousFrame)
/// <param name="previousDisposalMode">The previous frame disposal mode.</param>
/// <param name="backgroundColor">The background color.</param>
/// <returns>Whether the frame has a global color table.</returns>
private bool ReadFrame<TPixel>(
BufferedReadStream stream,
ref Image<TPixel>? image,
ref ImageFrame<TPixel>? previousFrame,
ref FrameDisposalMode? previousDisposalMode,
ref Color backgroundColor)
where TPixel : unmanaged, IPixel<TPixel>
{
this.ReadImageDescriptor(stream);
@ -438,10 +471,52 @@ internal sealed class GifDecoderCore : ImageDecoderCore
}
ReadOnlySpan<Rgb24> colorTable = MemoryMarshal.Cast<byte, Rgb24>(rawColorTable);
this.ReadFrameColors(stream, ref image, ref previousFrame, colorTable);
// First frame
if (image is null)
{
if (this.backgroundColorIndex < colorTable.Length)
{
backgroundColor = Color.FromPixel(colorTable[this.backgroundColorIndex]);
}
else
{
backgroundColor = Color.Transparent;
}
if (this.graphicsControlExtension.TransparencyFlag)
{
backgroundColor = backgroundColor.WithAlpha(0);
}
}
this.ReadFrameColors(stream, ref image, ref previousFrame, ref previousDisposalMode, colorTable, backgroundColor.ToPixel<TPixel>());
// Update from newly decoded frame.
if (this.graphicsControlExtension.DisposalMethod != FrameDisposalMode.RestoreToPrevious)
{
if (this.backgroundColorIndex < colorTable.Length)
{
backgroundColor = Color.FromPixel(colorTable[this.backgroundColorIndex]);
}
else
{
backgroundColor = Color.Transparent;
}
// TODO: I don't understand why this is always set to alpha of zero.
// This should be dependent on the transparency flag of the graphics
// control extension. ImageMagick does the same.
// if (this.graphicsControlExtension.TransparencyFlag)
{
backgroundColor = backgroundColor.WithAlpha(0);
}
}
// Skip any remaining blocks
SkipBlock(stream);
return !hasLocalColorTable;
}
/// <summary>
@ -451,56 +526,73 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
/// <param name="image">The image to decode the information to.</param>
/// <param name="previousFrame">The previous frame.</param>
/// <param name="previousDisposalMode">The previous frame disposal mode.</param>
/// <param name="colorTable">The color table containing the available colors.</param>
/// <param name="backgroundPixel">The background color pixel.</param>
private void ReadFrameColors<TPixel>(
BufferedReadStream stream,
ref Image<TPixel>? image,
ref ImageFrame<TPixel>? previousFrame,
ReadOnlySpan<Rgb24> colorTable)
ref FrameDisposalMode? previousDisposalMode,
ReadOnlySpan<Rgb24> colorTable,
TPixel backgroundPixel)
where TPixel : unmanaged, IPixel<TPixel>
{
GifImageDescriptor descriptor = this.imageDescriptor;
int imageWidth = this.logicalScreenDescriptor.Width;
int imageHeight = this.logicalScreenDescriptor.Height;
bool transFlag = this.graphicsControlExtension.TransparencyFlag;
FrameDisposalMode disposalMethod = this.graphicsControlExtension.DisposalMethod;
ImageFrame<TPixel> currentFrame;
ImageFrame<TPixel>? restoreFrame = null;
ImageFrame<TPixel>? prevFrame = null;
ImageFrame<TPixel>? currentFrame = null;
ImageFrame<TPixel> imageFrame;
if (previousFrame is null && previousDisposalMode is null)
{
image = transFlag
? new Image<TPixel>(this.configuration, imageWidth, imageHeight, this.metadata)
: new Image<TPixel>(this.configuration, imageWidth, imageHeight, backgroundPixel, this.metadata);
if (previousFrame is null)
this.SetFrameMetadata(image.Frames.RootFrame.Metadata);
currentFrame = image.Frames.RootFrame;
}
else
{
if (!transFlag)
if (previousFrame != null)
{
image = new Image<TPixel>(this.configuration, imageWidth, imageHeight, Color.Black.ToPixel<TPixel>(), this.metadata);
currentFrame = image!.Frames.AddFrame(previousFrame);
}
else
{
// This initializes the image to become fully transparent because the alpha channel is zero.
image = new Image<TPixel>(this.configuration, imageWidth, imageHeight, this.metadata);
currentFrame = image!.Frames.CreateFrame(backgroundPixel);
}
this.SetFrameMetadata(image.Frames.RootFrame.Metadata);
this.SetFrameMetadata(currentFrame.Metadata);
imageFrame = image.Frames.RootFrame;
}
else
{
if (this.graphicsControlExtension.DisposalMethod == FrameDisposalMode.RestoreToPrevious)
{
prevFrame = previousFrame;
restoreFrame = previousFrame;
}
// We create a clone of the frame and add it.
// We will overpaint the difference of pixels on the current frame to create a complete image.
// This ensures that we have enough pixel data to process without distortion. #2450
currentFrame = image!.Frames.AddFrame(previousFrame);
if (previousDisposalMode == FrameDisposalMode.RestoreToBackground)
{
this.RestoreToBackground(currentFrame, backgroundPixel, transFlag);
}
}
this.SetFrameMetadata(currentFrame.Metadata);
if (this.graphicsControlExtension.DisposalMethod == FrameDisposalMode.RestoreToPrevious)
{
previousFrame = restoreFrame;
}
else
{
previousFrame = currentFrame;
}
imageFrame = currentFrame;
previousDisposalMode = disposalMethod;
this.RestoreToBackground(imageFrame);
if (disposalMethod == FrameDisposalMode.RestoreToBackground)
{
this.restoreArea = Rectangle.Intersect(image.Bounds, new(descriptor.Left, descriptor.Top, descriptor.Width, descriptor.Height));
}
if (colorTable.Length == 0)
@ -568,7 +660,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<TPixel> row = imageFrame.PixelBuffer.DangerousGetRowSpan(writeY);
Span<TPixel> 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 +691,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);
}
}
/// <summary>
@ -620,7 +699,8 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// <param name="stream">The <see cref="BufferedReadStream"/> containing image data.</param>
/// <param name="frameMetadata">The collection of frame metadata.</param>
/// <param name="previousFrame">The previous frame metadata.</param>
private void ReadFrameMetadata(BufferedReadStream stream, List<ImageFrameMetadata> frameMetadata, ref ImageFrameMetadata? previousFrame)
/// <returns>Whether the frame has a global color table.</returns>
private bool ReadFrameMetadata(BufferedReadStream stream, List<ImageFrameMetadata> frameMetadata, ref ImageFrameMetadata? previousFrame)
{
this.ReadImageDescriptor(stream);
@ -632,6 +712,11 @@ internal sealed class GifDecoderCore : ImageDecoderCore
this.currentLocalColorTable ??= this.configuration.MemoryAllocator.Allocate<byte>(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 +734,8 @@ internal sealed class GifDecoderCore : ImageDecoderCore
// Skip any remaining blocks
SkipBlock(stream);
return !this.imageDescriptor.LocalColorTableFlag;
}
/// <summary>
@ -656,7 +743,9 @@ internal sealed class GifDecoderCore : ImageDecoderCore
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="frame">The frame.</param>
private void RestoreToBackground<TPixel>(ImageFrame<TPixel> frame)
/// <param name="background">The background color.</param>
/// <param name="transparent">Whether the background is transparent.</param>
private void RestoreToBackground<TPixel>(ImageFrame<TPixel> frame, TPixel background, bool transparent)
where TPixel : unmanaged, IPixel<TPixel>
{
if (this.restoreArea is null)
@ -666,7 +755,14 @@ internal sealed class GifDecoderCore : ImageDecoderCore
Rectangle interest = Rectangle.Intersect(frame.Bounds, this.restoreArea.Value);
Buffer2DRegion<TPixel> pixelRegion = frame.PixelBuffer.GetRegion(interest);
pixelRegion.Clear();
if (transparent)
{
pixelRegion.Clear();
}
else
{
pixelRegion.Fill(background);
}
this.restoreArea = null;
}
@ -775,7 +871,9 @@ internal sealed class GifDecoderCore : ImageDecoderCore
}
}
this.gifMetadata.BackgroundColorIndex = this.logicalScreenDescriptor.BackgroundColorIndex;
byte index = this.logicalScreenDescriptor.BackgroundColorIndex;
this.backgroundColorIndex = index;
this.gifMetadata.BackgroundColorIndex = index;
}
private unsafe struct ScratchBuffer

238
src/ImageSharp/Formats/Gif/GifEncoderCore.cs

@ -9,7 +9,6 @@ using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.Metadata.Profiles.Xmp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace SixLabors.ImageSharp.Formats.Gif;
@ -19,6 +18,8 @@ namespace SixLabors.ImageSharp.Formats.Gif;
/// </summary>
internal sealed class GifEncoderCore
{
private readonly GifEncoder encoder;
/// <summary>
/// Used for allocating memory during processing operations.
/// </summary>
@ -34,16 +35,6 @@ internal sealed class GifEncoderCore
/// </summary>
private readonly bool skipMetadata;
/// <summary>
/// The quantizer used to generate the color palette.
/// </summary>
private IQuantizer? quantizer;
/// <summary>
/// Whether the quantizer was supplied via options.
/// </summary>
private readonly bool hasQuantizer;
/// <summary>
/// The color table mode: Global or local.
/// </summary>
@ -67,6 +58,9 @@ internal sealed class GifEncoderCore
/// </summary>
private readonly ushort? repeatCount;
/// <summary>
/// The transparent color mode.
/// </summary>
private readonly TransparentColorMode transparentColorMode;
/// <summary>
@ -78,9 +72,8 @@ internal sealed class GifEncoderCore
{
this.configuration = configuration;
this.memoryAllocator = configuration.MemoryAllocator;
this.encoder = encoder;
this.skipMetadata = encoder.SkipMetadata;
this.quantizer = encoder.Quantizer;
this.hasQuantizer = encoder.Quantizer is not null;
this.colorTableMode = encoder.ColorTableMode;
this.pixelSamplingStrategy = encoder.PixelSamplingStrategy;
this.backgroundColor = encoder.BackgroundColor;
@ -104,70 +97,76 @@ 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<TPixel>? 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<TPixel>? quantized = null;
IQuantizer? globalQuantizer = this.encoder.Quantizer;
TransparentColorMode mode = this.transparentColorMode;
if (this.quantizer is null)
// Create a new quantizer options instance augmenting the transparent color mode to match the encoder.
QuantizerOptions options = (this.encoder.Quantizer?.Options ?? new()).DeepClone(o => o.TransparentColorMode = mode);
if (globalQuantizer is null)
{
// Is this a gif with color information. If so use that, otherwise use octree.
if (gifMetadata.ColorTableMode == FrameColorTableMode.Global && gifMetadata.GlobalColorTable?.Length > 0)
{
// We avoid dithering by default to preserve the original colors.
int transparencyIndex = GetTransparentIndex(quantized, frameMetadata);
if (transparencyIndex >= 0 || gifMetadata.GlobalColorTable.Value.Length < 256)
{
this.quantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, new() { Dither = null }, transparencyIndex);
// We avoid dithering by default to preserve the original colors.
globalQuantizer = new PaletteQuantizer(gifMetadata.GlobalColorTable.Value, options.DeepClone(o => o.Dither = null));
}
else
{
this.quantizer = KnownQuantizers.Octree;
globalQuantizer = new OctreeQuantizer(options);
}
}
else
{
this.quantizer = KnownQuantizers.Octree;
globalQuantizer = new OctreeQuantizer(options);
}
}
// Quantize the first frame. Checking to see whether we can clear the transparent pixels
// to allow for a smaller color palette and encoded result.
using (IQuantizer<TPixel> frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration))
{
ImageFrame<TPixel>? clonedFrame = null;
Configuration configuration = this.configuration;
TransparentColorMode mode = this.transparentColorMode;
IPixelSamplingStrategy strategy = this.pixelSamplingStrategy;
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(mode))
{
clonedFrame = image.Frames.RootFrame.Clone();
GifFrameMetadata frameMeta = clonedFrame.Metadata.GetGifMetadata();
Color background = frameMeta.DisposalMode == FrameDisposalMode.RestoreToBackground
? this.backgroundColor ?? Color.Transparent
: Color.Transparent;
EncodingUtilities.ClearTransparentPixels(clonedFrame, background);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;
// Quantize the first frame.
IPixelSamplingStrategy strategy = this.pixelSamplingStrategy;
ImageFrame<TPixel> encodingFrame = image.Frames.RootFrame;
if (useGlobalTableForFirstFrame)
{
using IQuantizer<TPixel> firstFrameQuantizer = globalQuantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, options);
if (useGlobalTable)
{
frameQuantizer.BuildPalette(configuration, mode, strategy, image);
quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds);
firstFrameQuantizer.BuildPalette(strategy, image);
}
else
{
frameQuantizer.BuildPalette(configuration, mode, strategy, encodingFrame);
quantized = frameQuantizer.QuantizeFrame(encodingFrame, image.Bounds);
firstFrameQuantizer.BuildPalette(strategy, encodingFrame);
}
clonedFrame?.Dispose();
quantized = firstFrameQuantizer.QuantizeFrame(encodingFrame, encodingFrame.Bounds);
}
else
{
quantized = this.QuantizeFrameAndUpdateMetadata(
encodingFrame,
globalQuantizer,
default,
encodingFrame.Bounds,
frameMetadata,
true,
false,
frameMetadata.HasTransparency ? frameMetadata.TransparencyIndex : -1,
Color.Transparent);
}
// Write the header.
@ -181,6 +180,7 @@ internal sealed class GifEncoderCore
frameMetadata.TransparencyIndex = ClampIndex(derivedTransparencyIndex);
}
// TODO: We should be checking the metadata here also I think?
if (!TryGetBackgroundIndex(quantized, this.backgroundColor, out byte backgroundIndex))
{
backgroundIndex = derivedTransparencyIndex >= 0
@ -216,13 +216,18 @@ internal sealed class GifEncoderCore
// Capture the global palette for reuse on subsequent frames and cleanup the quantized frame.
TPixel[] globalPalette = image.Frames.Count == 1 ? [] : quantized.Palette.ToArray();
this.EncodeAdditionalFrames(
stream,
image,
globalPalette,
derivedTransparencyIndex,
frameMetadata.DisposalMode,
cancellationToken);
if (image.Frames.Count > 1)
{
using PaletteQuantizer<TPixel> globalFrameQuantizer = new(this.configuration, globalQuantizer.Options, quantized.Palette.ToArray());
this.EncodeAdditionalFrames(
stream,
image,
globalQuantizer,
globalFrameQuantizer,
derivedTransparencyIndex,
frameMetadata.DisposalMode,
cancellationToken);
}
}
finally
{
@ -248,69 +253,43 @@ internal sealed class GifEncoderCore
private void EncodeAdditionalFrames<TPixel>(
Stream stream,
Image<TPixel> image,
ReadOnlyMemory<TPixel> globalPalette,
IQuantizer globalQuantizer,
PaletteQuantizer<TPixel> globalFrameQuantizer,
int globalTransparencyIndex,
FrameDisposalMode previousDisposalMode,
CancellationToken cancellationToken)
where TPixel : unmanaged, IPixel<TPixel>
{
if (image.Frames.Count == 1)
{
return;
}
PaletteQuantizer<TPixel> paletteQuantizer = default;
bool hasPaletteQuantizer = false;
// Store the first frame as a reference for de-duplication comparison.
ImageFrame<TPixel> previousFrame = image.Frames.RootFrame;
// This frame is reused to store de-duplicated pixel buffers.
using ImageFrame<TPixel> encodingFrame = new(previousFrame.Configuration, previousFrame.Size);
try
for (int i = 1; i < image.Frames.Count; i++)
{
for (int i = 1; i < image.Frames.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
cancellationToken.ThrowIfCancellationRequested();
// Gather the metadata for this frame.
ImageFrame<TPixel> currentFrame = image.Frames[i];
ImageFrame<TPixel>? nextFrame = i < image.Frames.Count - 1 ? image.Frames[i + 1] : null;
GifFrameMetadata gifMetadata = GetGifFrameMetadata(currentFrame, globalTransparencyIndex);
bool useLocal = this.colorTableMode == FrameColorTableMode.Local || (gifMetadata.ColorTableMode == FrameColorTableMode.Local);
// Gather the metadata for this frame.
ImageFrame<TPixel> currentFrame = image.Frames[i];
ImageFrame<TPixel>? nextFrame = i < image.Frames.Count - 1 ? image.Frames[i + 1] : null;
GifFrameMetadata gifMetadata = GetGifFrameMetadata(currentFrame, globalTransparencyIndex);
bool useLocal = this.colorTableMode == FrameColorTableMode.Local || (gifMetadata.ColorTableMode == FrameColorTableMode.Local);
if (!useLocal && !hasPaletteQuantizer && 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;
}
this.EncodeAdditionalFrame(
stream,
previousFrame,
currentFrame,
nextFrame,
encodingFrame,
globalQuantizer,
globalFrameQuantizer,
useLocal,
gifMetadata,
previousDisposalMode);
this.EncodeAdditionalFrame(
stream,
previousFrame,
currentFrame,
nextFrame,
encodingFrame,
useLocal,
gifMetadata,
paletteQuantizer,
previousDisposalMode);
previousFrame = currentFrame;
previousDisposalMode = gifMetadata.DisposalMode;
}
}
finally
{
if (hasPaletteQuantizer)
{
paletteQuantizer.Dispose();
}
previousFrame = currentFrame;
previousDisposalMode = gifMetadata.DisposalMode;
}
}
@ -346,9 +325,10 @@ internal sealed class GifEncoderCore
ImageFrame<TPixel> currentFrame,
ImageFrame<TPixel>? nextFrame,
ImageFrame<TPixel> encodingFrame,
IQuantizer globalQuantizer,
PaletteQuantizer<TPixel> globalFrameQuantizer,
bool useLocal,
GifFrameMetadata metadata,
PaletteQuantizer<TPixel> globalPaletteQuantizer,
FrameDisposalMode previousDisposalMode)
where TPixel : unmanaged, IPixel<TPixel>
{
@ -375,19 +355,16 @@ internal sealed class GifEncoderCore
background,
true);
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
{
EncodingUtilities.ClearTransparentPixels(encodingFrame, background);
}
using IndexedImageFrame<TPixel> quantized = this.QuantizeAdditionalFrameAndUpdateMetadata(
using IndexedImageFrame<TPixel> quantized = this.QuantizeFrameAndUpdateMetadata(
encodingFrame,
globalQuantizer,
globalFrameQuantizer,
bounds,
metadata,
useLocal,
globalPaletteQuantizer,
difference,
transparencyIndex);
transparencyIndex,
background);
this.WriteGraphicalControlExtension(metadata, stream);
@ -403,14 +380,16 @@ internal sealed class GifEncoderCore
this.WriteImageData(indices, stream, quantized.Palette.Length, metadata.TransparencyIndex);
}
private IndexedImageFrame<TPixel> QuantizeAdditionalFrameAndUpdateMetadata<TPixel>(
private IndexedImageFrame<TPixel> QuantizeFrameAndUpdateMetadata<TPixel>(
ImageFrame<TPixel> encodingFrame,
IQuantizer globalQuantizer,
PaletteQuantizer<TPixel> globalFrameQuantizer,
Rectangle bounds,
GifFrameMetadata metadata,
bool useLocal,
PaletteQuantizer<TPixel> globalPaletteQuantizer,
bool hasDuplicates,
int transparencyIndex)
int transparencyIndex,
Color transparentColor)
where TPixel : unmanaged, IPixel<TPixel>
{
IndexedImageFrame<TPixel> quantized;
@ -434,15 +413,19 @@ internal sealed class GifEncoderCore
transparencyIndex = palette.Length;
metadata.TransparencyIndex = ClampIndex(transparencyIndex);
PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex);
using IQuantizer<TPixel> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, quantizer.Options);
QuantizerOptions options = globalQuantizer.Options.DeepClone(o =>
{
o.MaxColors = palette.Length;
o.Dither = null;
});
PaletteQuantizer quantizer = new(palette, options, transparencyIndex, transparentColor);
using IQuantizer<TPixel> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration);
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;
using IQuantizer<TPixel> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, quantizer.Options);
using IQuantizer<TPixel> frameQuantizer = globalQuantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
// The transparency index derived by the quantizer will differ from the index
@ -454,7 +437,12 @@ internal sealed class GifEncoderCore
else
{
// Just use the local palette.
PaletteQuantizer quantizer = new(palette, new() { Dither = null }, transparencyIndex);
QuantizerOptions paletteOptions = globalQuantizer.Options.DeepClone(o =>
{
o.MaxColors = palette.Length;
o.Dither = null;
});
PaletteQuantizer quantizer = new(palette, paletteOptions, transparencyIndex, transparentColor);
using IQuantizer<TPixel> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, quantizer.Options);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
}
@ -462,8 +450,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;
using IQuantizer<TPixel> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration, quantizer.Options);
using IQuantizer<TPixel> frameQuantizer = globalQuantizer.CreatePixelSpecificQuantizer<TPixel>(this.configuration);
quantized = frameQuantizer.BuildPaletteAndQuantizeFrame(encodingFrame, bounds);
// The transparency index derived by the quantizer might differ from the index
@ -486,18 +473,19 @@ 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)
{
metadata.HasTransparency = true;
transparencyIndex = globalPaletteQuantizer.Palette.Length;
transparencyIndex = globalFrameQuantizer.Palette.Length;
metadata.TransparencyIndex = ClampIndex(transparencyIndex);
}
globalPaletteQuantizer.SetTransparentIndex(transparencyIndex);
quantized = globalPaletteQuantizer.QuantizeFrame(encodingFrame, bounds);
globalFrameQuantizer.SetTransparencyIndex(transparencyIndex, transparentColor.ToPixel<TPixel>());
quantized = globalFrameQuantizer.QuantizeFrame(encodingFrame, bounds);
}
return quantized;

29
src/ImageSharp/Formats/Gif/GifFrameMetadata.cs

@ -1,7 +1,6 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Numerics;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Formats.Gif;
@ -77,34 +76,12 @@ public class GifFrameMetadata : IFormatFrameMetadata<GifFrameMetadata>
/// <inheritdoc />
public static GifFrameMetadata FromFormatConnectingFrameMetadata(FormatConnectingFrameMetadata metadata)
{
int index = -1;
const float background = 1f;
if (metadata.ColorTable.HasValue)
=> new()
{
ReadOnlySpan<Color> colorTable = metadata.ColorTable.Value.Span;
for (int i = 0; i < colorTable.Length; i++)
{
Vector4 vector = colorTable[i].ToScaledVector4();
if (vector.W < background)
{
index = i;
}
}
}
bool hasTransparency = index >= 0;
return new()
{
LocalColorTable = metadata.ColorTable,
ColorTableMode = metadata.ColorTableMode,
FrameDelay = (int)Math.Round(metadata.Duration.TotalMilliseconds / 10),
DisposalMode = metadata.DisposalMode,
HasTransparency = hasTransparency,
TransparencyIndex = hasTransparency ? unchecked((byte)index) : byte.MinValue,
};
}
/// <inheritdoc />
public FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata()
@ -118,7 +95,6 @@ public class GifFrameMetadata : IFormatFrameMetadata<GifFrameMetadata>
return new()
{
ColorTable = this.LocalColorTable,
ColorTableMode = this.ColorTableMode,
Duration = TimeSpan.FromMilliseconds(this.FrameDelay * 10),
DisposalMode = this.DisposalMode,
@ -129,8 +105,7 @@ public class GifFrameMetadata : IFormatFrameMetadata<GifFrameMetadata>
/// <inheritdoc/>
public void AfterFrameApply<TPixel>(ImageFrame<TPixel> source, ImageFrame<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.LocalColorTable = null;
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();

41
src/ImageSharp/Formats/Gif/GifMetadata.cs

@ -71,37 +71,19 @@ public class GifMetadata : IFormatMetadata<GifMetadata>
/// <inheritdoc/>
public static GifMetadata FromFormatConnectingMetadata(FormatConnectingMetadata metadata)
{
int index = 0;
Color background = metadata.BackgroundColor;
if (metadata.ColorTable.HasValue)
=> new()
{
ReadOnlySpan<Color> colorTable = metadata.ColorTable.Value.Span;
for (int i = 0; i < colorTable.Length; i++)
{
if (background != colorTable[i])
{
continue;
}
index = i;
break;
}
}
return new()
{
GlobalColorTable = metadata.ColorTable,
// Do not copy the color table or bit depth.
// This will lead to a mismatch when the image is comprised of frames
// extracted individually from a multi-frame image.
ColorTableMode = metadata.ColorTableMode,
RepeatCount = metadata.RepeatCount,
BackgroundColorIndex = (byte)Numerics.Clamp(index, 0, 255),
};
}
/// <inheritdoc/>
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;
@ -114,27 +96,18 @@ public class GifMetadata : IFormatMetadata<GifMetadata>
/// <inheritdoc/>
public FormatConnectingMetadata ToFormatConnectingMetadata()
{
Color color = this.GlobalColorTable.HasValue && this.GlobalColorTable.Value.Span.Length > this.BackgroundColorIndex
? this.GlobalColorTable.Value.Span[this.BackgroundColorIndex]
: Color.Transparent;
return new()
=> new()
{
AnimateRootFrame = true,
BackgroundColor = color,
ColorTable = this.GlobalColorTable,
ColorTableMode = this.ColorTableMode,
PixelTypeInfo = this.GetPixelTypeInfo(),
RepeatCount = this.RepeatCount,
};
}
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.GlobalColorTable = null;
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();

6
src/ImageSharp/Formats/IAnimatedImageEncoder.cs

@ -14,17 +14,17 @@ public interface IAnimatedImageEncoder
/// as well as the transparent pixels of the first frame.
/// The background color is also used when a frame disposal mode is <see cref="FrameDisposalMode.RestoreToBackground"/>.
/// </summary>
Color? BackgroundColor { get; }
public Color? BackgroundColor { get; }
/// <summary>
/// Gets the number of times any animation is repeated in supported encoders.
/// </summary>
ushort? RepeatCount { get; }
public ushort? RepeatCount { get; }
/// <summary>
/// Gets a value indicating whether the root frame is shown as part of the animated sequence in supported encoders.
/// </summary>
bool? AnimateRootFrame { get; }
public bool? AnimateRootFrame { get; }
}
/// <summary>

6
src/ImageSharp/Formats/IFormatFrameMetadata.cs

@ -14,7 +14,7 @@ public interface IFormatFrameMetadata : IDeepCloneable
/// Converts the metadata to a <see cref="FormatConnectingFrameMetadata"/> instance.
/// </summary>
/// <returns>The <see cref="FormatConnectingFrameMetadata"/>.</returns>
FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata();
public FormatConnectingFrameMetadata ToFormatConnectingFrameMetadata();
/// <summary>
/// This method is called after a process has been applied to the image frame.
@ -22,7 +22,7 @@ public interface IFormatFrameMetadata : IDeepCloneable
/// <typeparam name="TPixel">The type of pixel format.</typeparam>
/// <param name="source">The source image frame.</param>
/// <param name="destination">The destination image frame.</param>
void AfterFrameApply<TPixel>(ImageFrame<TPixel> source, ImageFrame<TPixel> destination)
public void AfterFrameApply<TPixel>(ImageFrame<TPixel> source, ImageFrame<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>;
}
@ -39,6 +39,6 @@ public interface IFormatFrameMetadata<TSelf> : IFormatFrameMetadata, IDeepClonea
/// <param name="metadata">The <see cref="FormatConnectingFrameMetadata"/>.</param>
/// <returns>The <typeparamref name="TSelf"/>.</returns>
#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
}

8
src/ImageSharp/Formats/IFormatMetadata.cs

@ -14,20 +14,20 @@ public interface IFormatMetadata : IDeepCloneable
/// Converts the metadata to a <see cref="PixelTypeInfo"/> instance.
/// </summary>
/// <returns>The pixel type info.</returns>
PixelTypeInfo GetPixelTypeInfo();
public PixelTypeInfo GetPixelTypeInfo();
/// <summary>
/// Converts the metadata to a <see cref="FormatConnectingMetadata"/> instance.
/// </summary>
/// <returns>The <see cref="FormatConnectingMetadata"/>.</returns>
FormatConnectingMetadata ToFormatConnectingMetadata();
public FormatConnectingMetadata ToFormatConnectingMetadata();
/// <summary>
/// This method is called after a process has been applied to the image.
/// </summary>
/// <typeparam name="TPixel">The type of pixel format.</typeparam>
/// <param name="destination">The destination image .</param>
void AfterImageApply<TPixel>(Image<TPixel> destination)
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>;
}
@ -44,6 +44,6 @@ public interface IFormatMetadata<TSelf> : IFormatMetadata, IDeepCloneable<TSelf>
/// <param name="metadata">The <see cref="FormatConnectingMetadata"/>.</param>
/// <returns>The <typeparamref name="TSelf"/>.</returns>
#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
}

4
src/ImageSharp/Formats/IQuantizingImageEncoder.cs

@ -13,12 +13,12 @@ public interface IQuantizingImageEncoder
/// <summary>
/// Gets the quantizer used to generate the color palette.
/// </summary>
IQuantizer? Quantizer { get; }
public IQuantizer? Quantizer { get; }
/// <summary>
/// Gets the <see cref="IPixelSamplingStrategy"/> used for quantization when building color palettes.
/// </summary>
IPixelSamplingStrategy PixelSamplingStrategy { get; }
public IPixelSamplingStrategy PixelSamplingStrategy { get; }
}
/// <summary>

2
src/ImageSharp/Formats/ISpecializedDecoderOptions.cs

@ -11,5 +11,5 @@ public interface ISpecializedDecoderOptions
/// <summary>
/// Gets the general decoder options.
/// </summary>
DecoderOptions GeneralOptions { get; init; }
public DecoderOptions GeneralOptions { get; init; }
}

5
src/ImageSharp/Formats/Ico/IcoFrameMetadata.cs

@ -96,8 +96,7 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
BmpBitsPerPixel = bbpp,
Compression = compression,
EncodingWidth = ClampEncodingDimension(metadata.EncodingWidth),
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight),
ColorTable = compression == IconFrameCompression.Bmp ? metadata.ColorTable : null
EncodingHeight = ClampEncodingDimension(metadata.EncodingHeight)
};
}
@ -106,7 +105,6 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
=> new()
{
PixelTypeInfo = this.GetPixelTypeInfo(),
ColorTable = this.ColorTable,
EncodingWidth = this.EncodingWidth,
EncodingHeight = this.EncodingHeight
};
@ -119,6 +117,7 @@ public class IcoFrameMetadata : IFormatFrameMetadata<IcoFrameMetadata>
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;
}
/// <inheritdoc/>

9
src/ImageSharp/Formats/Ico/IcoMetadata.cs

@ -71,8 +71,7 @@ public class IcoMetadata : IFormatMetadata<IcoMetadata>
return new IcoMetadata
{
BmpBitsPerPixel = bbpp,
Compression = compression,
ColorTable = compression == IconFrameCompression.Bmp ? metadata.ColorTable : null
Compression = compression
};
}
@ -145,15 +144,13 @@ public class IcoMetadata : IFormatMetadata<IcoMetadata>
EncodingType = this.Compression == IconFrameCompression.Bmp && this.BmpBitsPerPixel <= BmpBitsPerPixel.Bit8
? EncodingType.Lossy
: EncodingType.Lossless,
PixelTypeInfo = this.GetPixelTypeInfo(),
ColorTable = this.ColorTable
PixelTypeInfo = this.GetPixelTypeInfo()
};
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.ColorTable = null;
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();

16
src/ImageSharp/Formats/Icon/IconEncoderCore.cs

@ -120,17 +120,17 @@ internal abstract class IconEncoderCore
this.entries = this.iconFileType switch
{
IconFileType.ICO =>
image.Frames.Select(i =>
[.. image.Frames.Select(i =>
{
IcoFrameMetadata metadata = i.Metadata.GetIcoMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size));
}).ToArray(),
})],
IconFileType.CUR =>
image.Frames.Select(i =>
[.. image.Frames.Select(i =>
{
CurFrameMetadata metadata = i.Metadata.GetCurMetadata();
return new EncodingFrameMetadata(metadata.Compression, metadata.BmpBitsPerPixel, metadata.ColorTable, metadata.ToIconDirEntry(i.Size));
}).ToArray(),
})],
_ => throw new NotSupportedException(),
};
}
@ -149,9 +149,15 @@ internal abstract class IconEncoderCore
if (metadata.ColorTable is null)
{
int count = metadata.Entry.ColorCount;
if (count == 0)
{
count = 256;
}
return new WuQuantizer(new()
{
MaxColors = metadata.Entry.ColorCount
MaxColors = count
});
}

78
src/ImageSharp/Formats/Png/PngDecoderCore.cs

@ -1086,7 +1086,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
{
PixelBlender<TPixel> blender =
PixelOperations<TPixel>.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver);
blender.Blend<TPixel>(this.configuration, destination, destination, rowSpan, 1f);
blender.Blend<TPixel>(this.configuration, destination, destination, rowSpan, 1F);
}
}
finally
@ -1208,7 +1208,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
{
PixelBlender<TPixel> blender =
PixelOperations<TPixel>.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver);
blender.Blend<TPixel>(this.configuration, destination, destination, rowSpan, 1f);
blender.Blend<TPixel>(this.configuration, destination, destination, rowSpan, 1F);
}
}
finally
@ -1866,6 +1866,9 @@ internal sealed class PngDecoderCore : ImageDecoderCore
return false;
}
// Capture the current position so we can revert back to it if we fail to read a valid chunk.
long position = this.currentStream.Position;
if (!this.TryReadChunkLength(buffer, out int length))
{
// IEND
@ -1884,7 +1887,48 @@ internal sealed class PngDecoderCore : ImageDecoderCore
}
}
PngChunkType type = this.ReadChunkType(buffer);
PngChunkType type;
// Loop until we get a chunk type that is valid.
while (true)
{
type = this.ReadChunkType(buffer);
if (!IsValidChunkType(type))
{
// The chunk type is invalid.
// Revert back to the next byte past the previous position and try again.
this.currentStream.Position = ++position;
// If we are now at the end of the stream, we're done.
if (this.currentStream.Position >= this.currentStream.Length)
{
chunk = default;
return false;
}
// Read the next chunk’s length.
if (!this.TryReadChunkLength(buffer, out length))
{
chunk = default;
return false;
}
while (length < 0)
{
if (!this.TryReadChunkLength(buffer, out length))
{
chunk = default;
return false;
}
}
// Continue to try reading the next chunk.
continue;
}
// We have a valid chunk type.
break;
}
// If we're reading color metadata only we're only interested in the IHDR and tRNS chunks.
// We can skip most other chunk data in the stream for better performance.
@ -1901,7 +1945,7 @@ internal sealed class PngDecoderCore : ImageDecoderCore
// A chunk might report a length that exceeds the length of the stream.
// Take the minimum of the two values to ensure we don't read past the end of the stream.
long position = this.currentStream.Position;
position = this.currentStream.Position;
chunk = new PngChunk(
length: (int)Math.Min(length, this.currentStream.Length - position),
type: type,
@ -1919,6 +1963,32 @@ internal sealed class PngDecoderCore : ImageDecoderCore
return true;
}
/// <summary>
/// Determines whether the 4-byte chunk type is valid (all ASCII letters).
/// </summary>
/// <param name="type">The chunk type.</param>
[MethodImpl(InliningOptions.ShortMethod)]
private static bool IsValidChunkType(PngChunkType type)
{
uint value = (uint)type;
byte b0 = (byte)(value >> 24);
byte b1 = (byte)(value >> 16);
byte b2 = (byte)(value >> 8);
byte b3 = (byte)value;
return IsAsciiLetter(b0) && IsAsciiLetter(b1) && IsAsciiLetter(b2) && IsAsciiLetter(b3);
}
/// <summary>
/// Returns a value indicating whether the given byte is an ASCII letter.
/// </summary>
/// <param name="b">The byte to check.</param>
/// <returns>
/// <see langword="true"/> if the byte is an ASCII letter; otherwise, <see langword="false"/>.
/// </returns>
[MethodImpl(InliningOptions.ShortMethod)]
private static bool IsAsciiLetter(byte b)
=> (b >= (byte)'A' && b <= (byte)'Z') || (b >= (byte)'a' && b <= (byte)'z');
/// <summary>
/// Validates the png chunk.
/// </summary>

17
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;
/// <summary>
@ -10,16 +8,6 @@ namespace SixLabors.ImageSharp.Formats.Png;
/// </summary>
public class PngEncoder : QuantizingAnimatedImageEncoder
{
/// <summary>
/// Initializes a new instance of the <see cref="PngEncoder"/> class.
/// </summary>
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();
/// <summary>
/// Gets the number of bits per sample or per palette index (not per pixel).
/// Not all values are allowed for all <see cref="ColorType" /> values.
@ -53,11 +41,6 @@ public class PngEncoder : QuantizingAnimatedImageEncoder
/// <value>The gamma value of the image.</value>
public float? Gamma { get; init; }
/// <summary>
/// Gets the transparency threshold.
/// </summary>
public byte Threshold { get; init; } = byte.MaxValue;
/// <summary>
/// Gets a value indicating whether this instance should write an Adam7 interlaced image.
/// </summary>

281
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
/// </summary>
private IQuantizer? quantizer;
/// <summary>
/// Any explicit quantized transparent index provided by the background color.
/// </summary>
private int derivedTransparencyIndex = -1;
/// <summary>
/// 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 <see cref="FrameDisposalMode.RestoreToBackground"/>.
/// </summary>
private readonly Color? backgroundColor;
private Color? backgroundColor;
/// <summary>
/// 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,95 @@ internal sealed class PngEncoderCore : IDisposable
ImageFrame<TPixel>? clonedFrame = null;
ImageFrame<TPixel> currentFrame = image.Frames.RootFrame;
int currentFrameIndex = 0;
IndexedImageFrame<TPixel>? quantized = null;
PaletteQuantizer<TPixel>? paletteQuantizer = null;
Buffer2DRegion<TPixel> currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
bool clearTransparency = EncodingUtilities.ShouldClearTransparentPixels<TPixel>(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<TPixel>? 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.ShouldReplaceTransparentPixels<TPixel>(this.encoder.TransparentColorMode);
if (image.Frames.Count > 1)
{
this.WriteAnimationControlChunk(
stream,
(uint)(image.Frames.Count - (pngMetadata.AnimateRootFrame ? 0 : 1)),
this.repeatCount ?? pngMetadata.RepeatCount);
}
// No need to clone when quantizing. The quantizer will do it for us.
// TODO: We should really try to avoid the clone entirely.
if (clearTransparency && this.colorType is not PngColorType.Palette)
{
currentFrame = clonedFrame = currentFrame.Clone();
currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
EncodingUtilities.ReplaceTransparentPixels(this.configuration, in currentFrameRegion);
}
// 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++;
}
// Do not move this. We require an accurate bit depth for the header chunk.
quantized = this.CreateQuantizedImageAndUpdateBitDepth(
pngMetadata,
image,
currentFrame,
currentFrame.Bounds,
null);
this.WriteHeaderChunk(stream);
this.WriteGammaChunk(stream);
this.WriteCicpChunk(stream, metadata);
this.WriteColorProfileChunk(stream, metadata);
this.WritePaletteChunk(stream, quantized);
this.WriteTransparencyChunk(stream, pngMetadata);
this.WritePhysicalChunk(stream, metadata);
this.WriteExifChunk(stream, metadata);
this.WriteXmpChunk(stream, metadata);
this.WriteTextChunks(stream, pngMetadata);
if (image.Frames.Count > 1)
{
this.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<TPixel>? previousPalette = quantized?.Palette.ToArray();
ReadOnlyMemory<TPixel> 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<TPixel> previousFrame = image.Frames.RootFrame;
@ -267,13 +282,26 @@ internal sealed class PngEncoderCore : IDisposable
cancellationToken.ThrowIfCancellationRequested();
ImageFrame<TPixel>? prev = previousDisposal == FrameDisposalMode.RestoreToBackground ? null : previousFrame;
currentFrame = image.Frames[currentFrameIndex];
currentFrameRegion = currentFrame.PixelBuffer.GetRegion();
ImageFrame<TPixel>? nextFrame = currentFrameIndex < image.Frames.Count - 1 ? image.Frames[currentFrameIndex + 1] : null;
frameMetadata = currentFrame.Metadata.GetPngMetadata();
bool blend = frameMetadata.BlendMode == FrameBlendMode.Over;
// Determine whether to blend the current frame over the existing canvas.
// Blending is applied only when the blend method is 'Over' (source-over blending)
// and when the frame's disposal method is not 'RestoreToPrevious', which indicates that
// the frame should not permanently alter the canvas.
bool blend = frameMetadata.BlendMode == FrameBlendMode.Over
&& frameMetadata.DisposalMode != FrameDisposalMode.RestoreToPrevious;
// Establish the background color for the current frame.
// If the disposal method is 'RestoreToBackground', use the predefined background color;
// otherwise, use transparent, as no explicit background restoration is needed.
Color background = frameMetadata.DisposalMode == FrameDisposalMode.RestoreToBackground
? this.backgroundColor ?? Color.Transparent
? this.backgroundColor.Value
: Color.Transparent;
(bool difference, Rectangle bounds) =
@ -286,9 +314,9 @@ internal sealed class PngEncoderCore : IDisposable
background,
blend);
if (clearTransparency)
if (clearTransparency && this.colorType is not PngColorType.Palette)
{
EncodingUtilities.ClearTransparentPixels(encodingFrame, background);
EncodingUtilities.ReplaceTransparentPixels(encodingFrame);
}
// Each frame control sequence number must be incremented by the number of frame data chunks that follow.
@ -296,8 +324,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<TPixel> encodingFrameRegion = encodingFrame.PixelBuffer.GetRegion(bounds);
sequenceNumber += this.WriteDataChunks(in frameControl, in encodingFrameRegion, quantized, stream, true) + 1;
previousFrame = currentFrame;
previousDisposal = frameMetadata.DisposalMode;
@ -313,6 +353,7 @@ internal sealed class PngEncoderCore : IDisposable
// Dispose of allocations from final frame.
clonedFrame?.Dispose();
quantized?.Dispose();
paletteQuantizer?.Dispose();
}
}
@ -328,18 +369,35 @@ internal sealed class PngEncoderCore : IDisposable
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="metadata">The image metadata.</param>
/// <param name="frame">The frame to quantize.</param>
/// <param name="image">The image.</param>
/// <param name="frame">The current image frame.</param>
/// <param name="bounds">The area of interest within the frame.</param>
/// <param name="previousPalette">Any previously derived palette.</param>
/// <param name="paletteQuantizer">The quantizer containing any previously derived palette.</param>
/// <returns>The quantized image.</returns>
private IndexedImageFrame<TPixel>? CreateQuantizedImageAndUpdateBitDepth<TPixel>(
PngMetadata metadata,
Image<TPixel> image,
ImageFrame<TPixel> frame,
Rectangle bounds,
ReadOnlyMemory<TPixel>? previousPalette)
PaletteQuantizer<TPixel>? paletteQuantizer)
where TPixel : unmanaged, IPixel<TPixel>
{
IndexedImageFrame<TPixel>? 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<TPixel>? 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;
}
@ -734,11 +792,6 @@ internal sealed class PngEncoderCore : IDisposable
byte alpha = rgba.A;
Unsafe.Add(ref colorTableRef, (uint)i) = rgba.Rgb;
if (alpha > this.encoder.Threshold)
{
alpha = byte.MaxValue;
}
hasAlpha = hasAlpha || alpha < byte.MaxValue;
Unsafe.Add(ref alphaTableRef, (uint)i) = alpha;
}
@ -1105,7 +1158,7 @@ internal sealed class PngEncoderCore : IDisposable
/// <param name="quantized">The quantized pixel data. Can be null.</param>
/// <param name="stream">The stream.</param>
/// <param name="isFrame">Is writing fdAT or IDAT.</param>
private uint WriteDataChunks<TPixel>(FrameControl frameControl, Buffer2DRegion<TPixel> frame, IndexedImageFrame<TPixel>? quantized, Stream stream, bool isFrame)
private uint WriteDataChunks<TPixel>(in FrameControl frameControl, in Buffer2DRegion<TPixel> frame, IndexedImageFrame<TPixel>? quantized, Stream stream, bool isFrame)
where TPixel : unmanaged, IPixel<TPixel>
{
byte[] buffer;
@ -1123,12 +1176,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 +1249,7 @@ internal sealed class PngEncoderCore : IDisposable
/// <param name="pixels">The image frame pixel buffer.</param>
/// <param name="quantized">The quantized pixels.</param>
/// <param name="deflateStream">The deflate stream.</param>
private void EncodePixels<TPixel>(Buffer2DRegion<TPixel> pixels, IndexedImageFrame<TPixel>? quantized, ZlibDeflateStream deflateStream)
private void EncodePixels<TPixel>(in Buffer2DRegion<TPixel> pixels, IndexedImageFrame<TPixel>? quantized, ZlibDeflateStream deflateStream)
where TPixel : unmanaged, IPixel<TPixel>
{
int bytesPerScanline = this.CalculateScanlineLength(pixels.Width);
@ -1210,7 +1263,8 @@ internal sealed class PngEncoderCore : IDisposable
Span<byte> attempt = attemptBuffer.GetSpan();
for (int y = 0; y < pixels.Height; y++)
{
this.CollectAndFilterPixelRow(pixels.DangerousGetRowSpan(y), ref filter, ref attempt, quantized, y);
ReadOnlySpan<TPixel> rowSpan = pixels.DangerousGetRowSpan(y);
this.CollectAndFilterPixelRow(rowSpan, ref filter, ref attempt, quantized, y);
deflateStream.Write(filter);
this.SwapScanlineBuffers();
}
@ -1222,7 +1276,7 @@ internal sealed class PngEncoderCore : IDisposable
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="pixels">The image frame pixel buffer.</param>
/// <param name="deflateStream">The deflate stream.</param>
private void EncodeAdam7Pixels<TPixel>(Buffer2DRegion<TPixel> pixels, ZlibDeflateStream deflateStream)
private void EncodeAdam7Pixels<TPixel>(in Buffer2DRegion<TPixel> pixels, ZlibDeflateStream deflateStream)
where TPixel : unmanaged, IPixel<TPixel>
{
for (int pass = 0; pass < 7; pass++)
@ -1258,7 +1312,8 @@ internal sealed class PngEncoderCore : IDisposable
// Encode data
// Note: quantized parameter not used
// Note: row parameter not used
this.CollectAndFilterPixelRow<TPixel>(block, ref filter, ref attempt, null, -1);
ReadOnlySpan<TPixel> blockSpan = block;
this.CollectAndFilterPixelRow(blockSpan, ref filter, ref attempt, null, -1);
deflateStream.Write(filter);
this.SwapScanlineBuffers();
@ -1432,6 +1487,7 @@ internal sealed class PngEncoderCore : IDisposable
/// <param name="pngMetadata">The PNG metadata.</param>
/// <param name="use16Bit">if set to <c>true</c> [use16 bit].</param>
/// <param name="bytesPerPixel">The bytes per pixel.</param>
[MemberNotNull(nameof(backgroundColor))]
private void SanitizeAndSetEncoderOptions<TPixel>(
PngEncoder encoder,
PngMetadata pngMetadata,
@ -1473,6 +1529,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;
}
/// <summary>
@ -1483,17 +1540,21 @@ internal sealed class PngEncoderCore : IDisposable
/// <param name="colorType">The color type.</param>
/// <param name="bitDepth">The bits per component.</param>
/// <param name="metadata">The image metadata.</param>
/// <param name="frame">The frame to quantize.</param>
/// <param name="image">The image.</param>
/// <param name="frame">The current image frame.</param>
/// <param name="bounds">The frame area of interest.</param>
/// <param name="previousPalette">Any previously derived palette.</param>
/// <param name="paletteQuantizer">The quantizer containing any previously derived palette.</param>
/// <param name="backgroundColor">The background color.</param>
private IndexedImageFrame<TPixel>? CreateQuantizedFrame<TPixel>(
QuantizingImageEncoder encoder,
PngColorType colorType,
byte bitDepth,
PngMetadata metadata,
Image<TPixel> image,
ImageFrame<TPixel> frame,
Rectangle bounds,
ReadOnlyMemory<TPixel>? previousPalette)
PaletteQuantizer<TPixel>? paletteQuantizer,
Color backgroundColor)
where TPixel : unmanaged, IPixel<TPixel>
{
if (colorType is not PngColorType.Palette)
@ -1501,55 +1562,59 @@ 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<TPixel> 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<Color> 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);
QuantizerOptions options = new() { Dither = null, TransparentColorMode = encoder.TransparentColorMode };
this.quantizer = new PaletteQuantizer(metadata.ColorTable.Value, options);
}
else
{
this.quantizer = new WuQuantizer(new QuantizerOptions { MaxColors = ColorNumerics.GetColorCountForBitDepth(bitDepth) });
// Don't use the default transparency threshold for quantization as PNG can handle multiple transparent colors.
// We choose a value that is close to zero so that edge cases causes by lower bit depths for the alpha channel are handled correctly.
QuantizerOptions options = new()
{
TransparencyThreshold = 0,
MaxColors = ColorNumerics.GetColorCountForBitDepth(bitDepth),
TransparentColorMode = encoder.TransparentColorMode
};
this.quantizer = new WuQuantizer(options);
}
}
// Create quantized frame returning the palette and set the bit depth.
using IQuantizer<TPixel> frameQuantizer = this.quantizer.CreatePixelSpecificQuantizer<TPixel>(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<TPixel> fake = image.Configuration.MemoryAllocator.Allocate2D<TPixel>(Math.Min(256, image.Width), Math.Min(256, image.Height));
TPixel backGroundPixel = backgroundColor.ToPixel<TPixel>();
for (int i = 0; i < fake.Height; i++)
{
fake.DangerousGetRowSpan(i).Fill(backGroundPixel);
}
Buffer2DRegion<TPixel> fakeRegion = fake.GetRegion();
frameQuantizer.AddPaletteColors(in fakeRegion);
}
frameQuantizer.BuildPalette(
encoder.PixelSamplingStrategy,
image);
return frameQuantizer.QuantizeFrame(frame, bounds);
}

24
src/ImageSharp/Formats/Png/PngMetadata.cs

@ -93,25 +93,6 @@ public class PngMetadata : IFormatMetadata<PngMetadata>
/// <inheritdoc/>
public static PngMetadata FromFormatConnectingMetadata(FormatConnectingMetadata metadata)
{
// Should the conversion be from a format that uses a 24bit palette entries (gif)
// we need to clone and adjust the color table to allow for transparency.
Color[]? colorTable = metadata.ColorTable?.ToArray();
if (colorTable != null)
{
for (int i = 0; i < colorTable.Length; i++)
{
ref Color c = ref colorTable[i];
if (c != metadata.BackgroundColor)
{
continue;
}
// Png treats background as fully empty
c = Color.Transparent;
break;
}
}
PngColorType color;
PixelColorType colorType = metadata.PixelTypeInfo.ColorType;
@ -152,7 +133,6 @@ public class PngMetadata : IFormatMetadata<PngMetadata>
{
ColorType = color,
BitDepth = bitDepth,
ColorTable = colorTable,
RepeatCount = metadata.RepeatCount,
};
}
@ -241,7 +221,6 @@ public class PngMetadata : IFormatMetadata<PngMetadata>
public FormatConnectingMetadata ToFormatConnectingMetadata()
=> new()
{
ColorTable = this.ColorTable,
ColorTableMode = FrameColorTableMode.Global,
PixelTypeInfo = this.GetPixelTypeInfo(),
RepeatCount = (ushort)Numerics.Clamp(this.RepeatCount, 0, ushort.MaxValue),
@ -250,8 +229,7 @@ public class PngMetadata : IFormatMetadata<PngMetadata>
/// <inheritdoc/>
public void AfterImageApply<TPixel>(Image<TPixel> destination)
where TPixel : unmanaged, IPixel<TPixel>
{
}
=> this.ColorTable = null;
/// <inheritdoc/>
IDeepCloneable IDeepCloneable.DeepClone() => this.DeepClone();

6
src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs

@ -90,10 +90,12 @@ internal class QoiEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.encoder.TransparentColorMode))
// TODO: Try to avoid cloning the frame if possible.
// We should be cloning individual scanlines instead.
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.encoder.TransparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

6
src/ImageSharp/Formats/Tga/TgaEncoderCore.cs

@ -110,10 +110,12 @@ internal sealed class TgaEncoderCore
ImageFrame<TPixel>? clonedFrame = null;
try
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
// TODO: Try to avoid cloning the frame if possible.
// We should be cloning individual scanlines instead.
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = image.Frames.RootFrame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? image.Frames.RootFrame;

6
src/ImageSharp/Formats/Tiff/TiffEncoderCore.cs

@ -146,10 +146,12 @@ internal sealed class TiffEncoderCore
{
cancellationToken.ThrowIfCancellationRequested();
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(this.transparentColorMode))
// TODO: Try to avoid cloning the frame if possible.
// We should be cloning individual scanlines instead.
if (EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(this.transparentColorMode))
{
clonedFrame = frame.Clone();
EncodingUtilities.ClearTransparentPixels(clonedFrame, Color.Transparent);
EncodingUtilities.ReplaceTransparentPixels(clonedFrame);
}
ImageFrame<TPixel> encodingFrame = clonedFrame ?? frame;

4
src/ImageSharp/Formats/TransparentColorMode.cs

@ -4,7 +4,7 @@
namespace SixLabors.ImageSharp.Formats;
/// <summary>
/// Specifies how transparent pixels should be handled during encoding.
/// Specifies how pixels with transparent alpha components should be handled during encoding and quantization.
/// </summary>
public enum TransparentColorMode
{
@ -18,5 +18,5 @@ public enum TransparentColorMode
/// to fully transparent pixels (all components set to zero),
/// which may improve compression.
/// </summary>
Clear = 1,
Clear = 1
}

2
src/ImageSharp/Formats/Webp/BitReader/BitReaderBase.cs

@ -32,7 +32,7 @@ internal abstract class BitReaderBase : IDisposable
/// <param name="memoryAllocator">Used for allocating memory during reading data from the stream.</param>
protected static IMemoryOwner<byte> ReadImageDataFromStream(Stream input, int bytesToRead, MemoryAllocator memoryAllocator)
{
IMemoryOwner<byte> data = memoryAllocator.Allocate<byte>(bytesToRead);
IMemoryOwner<byte> data = memoryAllocator.Allocate<byte>(bytesToRead, AllocationOptions.Clean);
Span<byte> dataSpan = data.Memory.Span;
input.Read(dataSpan[..bytesToRead], 0, bytesToRead);

4
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<byte>((WebpConstants.Bps * 17) + (WebpConstants.Bps * 9) + extraY);
this.CacheY = memoryAllocator.Allocate<byte>((16 * this.CacheYStride) + extraY);
this.CacheY = memoryAllocator.Allocate<byte>((16 * this.CacheYStride) + extraY, AllocationOptions.Clean);
int cacheUvSize = (16 * this.CacheUvStride) + extraUv;
this.CacheU = memoryAllocator.Allocate<byte>(cacheUvSize);
this.CacheV = memoryAllocator.Allocate<byte>(cacheUvSize);
this.TmpYBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpUBuffer = memoryAllocator.Allocate<byte>((int)width);
this.TmpVBuffer = memoryAllocator.Allocate<byte>((int)width);
this.Pixels = memoryAllocator.Allocate<byte>((int)(width * height * 4));
this.Pixels = memoryAllocator.Allocate<byte>((int)(width * height * 4), AllocationOptions.Clean);
#if DEBUG
// Filling those buffers with 205, is only useful for debugging,

110
src/ImageSharp/Formats/Webp/WebpAnimationDecoder.cs

@ -81,16 +81,29 @@ internal class WebpAnimationDecoder : IDisposable
/// <param name="width">The width of the image.</param>
/// <param name="height">The height of the image.</param>
/// <param name="completeDataSize">The size of the image data in bytes.</param>
public Image<TPixel> Decode<TPixel>(BufferedReadStream stream, WebpFeatures features, uint width, uint height, uint completeDataSize)
public Image<TPixel> Decode<TPixel>(
BufferedReadStream stream,
WebpFeatures features,
uint width,
uint height,
uint completeDataSize)
where TPixel : unmanaged, IPixel<TPixel>
{
Image<TPixel>? image = null;
ImageFrame<TPixel>? 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<TPixel>();
Span<byte> 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
/// <param name="stream">The stream, where the image should be decoded from. Cannot be null.</param>
/// <param name="image">The image to decode the information to.</param>
/// <param name="previousFrame">The previous frame.</param>
/// <param name="prevFrameData">The previous frame data.</param>
/// <param name="width">The width of the image.</param>
/// <param name="height">The height of the image.</param>
/// <param name="backgroundColor">The default background color of the canvas in.</param>
private uint ReadFrame<TPixel>(BufferedReadStream stream, ref Image<TPixel>? image, ref ImageFrame<TPixel>? previousFrame, uint width, uint height, Color backgroundColor)
private uint ReadFrame<TPixel>(
BufferedReadStream stream,
ref Image<TPixel>? image,
ref ImageFrame<TPixel>? previousFrame,
ref WebpFrameData? prevFrameData,
uint width,
uint height,
TPixel backgroundColor)
where TPixel : unmanaged, IPixel<TPixel>
{
WebpFrameData frameData = WebpFrameData.Parse(stream);
@ -174,40 +201,51 @@ internal class WebpAnimationDecoder : IDisposable
break;
}
ImageFrame<TPixel>? currentFrame = null;
ImageFrame<TPixel> imageFrame;
ImageFrame<TPixel> currentFrame;
if (previousFrame is null)
{
image = new Image<TPixel>(this.configuration, (int)width, (int)height, backgroundColor.ToPixel<TPixel>(), this.metadata);
SetFrameMetadata(image.Frames.RootFrame.Metadata, frameData);
image = new Image<TPixel>(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<TPixel> pixelData = this.DecodeImageFrameData<TPixel>(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<TPixel> decodedImageFrame = this.DecodeImageFrameData<TPixel>(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<TPixel> pixelBufferDecoded = decodedFrame.PixelBuffer;
Buffer2D<TPixel> 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();
}
}
/// <summary>
@ -335,7 +368,7 @@ internal class WebpAnimationDecoder : IDisposable
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="imageFrame">The image frame.</param>
/// <param name="backgroundColor">Color of the background.</param>
private void RestoreToBackground<TPixel>(ImageFrame<TPixel> imageFrame, Color backgroundColor)
private void RestoreToBackground<TPixel>(ImageFrame<TPixel> imageFrame, TPixel backgroundColor)
where TPixel : unmanaged, IPixel<TPixel>
{
if (!this.restoreArea.HasValue)
@ -345,8 +378,9 @@ internal class WebpAnimationDecoder : IDisposable
Rectangle interest = Rectangle.Intersect(imageFrame.Bounds, this.restoreArea.Value);
Buffer2DRegion<TPixel> pixelRegion = imageFrame.PixelBuffer.GetRegion(interest);
TPixel backgroundPixel = backgroundColor.ToPixel<TPixel>();
pixelRegion.Fill(backgroundPixel);
pixelRegion.Fill(backgroundColor);
this.restoreArea = null;
}
/// <inheritdoc/>

2
src/ImageSharp/Formats/Webp/WebpCommonUtils.cs

@ -18,7 +18,7 @@ internal static class WebpCommonUtils
/// </summary>
/// <param name="row">The row to check.</param>
/// <returns>Returns true if alpha has non-0xff values.</returns>
public static unsafe bool CheckNonOpaque(Span<Bgra32> row)
public static unsafe bool CheckNonOpaque(ReadOnlySpan<Bgra32> row)
{
if (Avx2.IsSupported)
{

6
src/ImageSharp/IDeepCloneable.cs

@ -1,4 +1,4 @@
// Copyright (c) Six Labors.
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp;
@ -14,7 +14,7 @@ public interface IDeepCloneable<out T>
/// Creates a new <typeparamref name="T"/> that is a deep copy of the current instance.
/// </summary>
/// <returns>The <typeparamref name="T"/>.</returns>
T DeepClone();
public T DeepClone();
}
/// <summary>
@ -26,5 +26,5 @@ public interface IDeepCloneable
/// Creates a new object that is a deep copy of the current instance.
/// </summary>
/// <returns>The <see cref="IDeepCloneable"/>.</returns>
IDeepCloneable DeepClone();
public IDeepCloneable DeepClone();
}

9
src/ImageSharp/ImageSharp.csproj

@ -51,6 +51,11 @@
</ItemGroup>
<ItemGroup>
<Compile Update="Common\InlineArray.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>InlineArray.tt</DependentUpon>
</Compile>
<Compile Update="Formats\_Generated\ImageMetadataExtensions.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
@ -154,6 +159,10 @@
</ItemGroup>
<ItemGroup>
<None Update="Common\InlineArray.tt">
<Generator>TextTemplatingFileGenerator</Generator>
<LastGenOutput>InlineArray.cs</LastGenOutput>
</None>
<None Update="Formats\_Generated\ImageMetadataExtensions.tt">
<LastGenOutput>ImageMetadataExtensions.cs</LastGenOutput>
<Generator>TextTemplatingFileGenerator</Generator>

8
src/ImageSharp/IndexedImageFrame{TPixel}.cs

@ -25,12 +25,12 @@ public sealed class IndexedImageFrame<TPixel> : IPixelSource, IDisposable
/// Initializes a new instance of the <see cref="IndexedImageFrame{TPixel}"/> class.
/// </summary>
/// <param name="configuration">
/// The configuration which allows altering default behaviour or extending the library.
/// The configuration which allows altering default behavior or extending the library.
/// </param>
/// <param name="width">The frame width.</param>
/// <param name="height">The frame height.</param>
/// <param name="palette">The color palette.</param>
internal IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory<TPixel> palette)
public IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory<TPixel> palette)
{
Guard.NotNull(configuration, nameof(configuration));
Guard.MustBeLessThanOrEqualTo(palette.Length, QuantizerConstants.MaxColors, nameof(palette));
@ -42,14 +42,14 @@ public sealed class IndexedImageFrame<TPixel> : IPixelSource, IDisposable
this.Height = height;
this.pixelBuffer = configuration.MemoryAllocator.Allocate2D<byte>(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<TPixel>(palette.Length);
palette.Span.CopyTo(this.paletteOwner.GetSpan());
this.Palette = this.paletteOwner.Memory[..palette.Length];
}
/// <summary>
/// Gets the configuration which allows altering default behaviour or extending the library.
/// Gets the configuration which allows altering default behavior or extending the library.
/// </summary>
public Configuration Configuration { get; }

6
src/ImageSharp/Processing/Processors/CloningImageProcessor{TPixel}.cs

@ -132,16 +132,14 @@ public abstract class CloningImageProcessor<TPixel> : ICloningImageProcessor<TPi
/// <param name="source">The source image. Cannot be null.</param>
/// <param name="destination">The cloned/destination image. Cannot be null.</param>
protected virtual void AfterFrameApply(ImageFrame<TPixel> source, ImageFrame<TPixel> destination)
{
}
=> destination.Metadata.AfterFrameApply(source, destination);
/// <summary>
/// This method is called after the process is applied to prepare the processor.
/// </summary>
/// <param name="destination">The cloned/destination image. Cannot be null.</param>
protected virtual void AfterImageApply(Image<TPixel> destination)
{
}
=> destination.Metadata.AfterImageApply(destination);
/// <summary>
/// Disposes the object and frees resources for the Garbage Collector.

0
src/ImageSharp/Processing/Processors/Dithering/ErroDither.KnownTypes.cs → src/ImageSharp/Processing/Processors/Dithering/ErrorDither.KnownTypes.cs

4
src/ImageSharp/Processing/Processors/Dithering/IDither.cs

@ -21,7 +21,7 @@ public interface IDither
/// <param name="source">The source image.</param>
/// <param name="destination">The destination quantized frame.</param>
/// <param name="bounds">The region of interest bounds.</param>
void ApplyQuantizationDither<TFrameQuantizer, TPixel>(
public void ApplyQuantizationDither<TFrameQuantizer, TPixel>(
ref TFrameQuantizer quantizer,
ImageFrame<TPixel> source,
IndexedImageFrame<TPixel> destination,
@ -38,7 +38,7 @@ public interface IDither
/// <param name="processor">The palette dithering processor.</param>
/// <param name="source">The source image.</param>
/// <param name="bounds">The region of interest bounds.</param>
void ApplyPaletteDither<TPaletteDitherImageProcessor, TPixel>(
public void ApplyPaletteDither<TPaletteDitherImageProcessor, TPixel>(
in TPaletteDitherImageProcessor processor,
ImageFrame<TPixel> source,
Rectangle bounds)

8
src/ImageSharp/Processing/Processors/Dithering/IPaletteDitherImageProcessor{TPixel}.cs

@ -15,22 +15,22 @@ public interface IPaletteDitherImageProcessor<TPixel>
/// <summary>
/// Gets the configuration instance to use when performing operations.
/// </summary>
Configuration Configuration { get; }
public Configuration Configuration { get; }
/// <summary>
/// Gets the dithering palette.
/// </summary>
ReadOnlyMemory<TPixel> Palette { get; }
public ReadOnlyMemory<TPixel> Palette { get; }
/// <summary>
/// Gets the dithering scale used to adjust the amount of dither. Range 0..1.
/// </summary>
float DitherScale { get; }
public float DitherScale { get; }
/// <summary>
/// Returns the color from the dithering palette corresponding to the given color.
/// </summary>
/// <param name="color">The color to match.</param>
/// <returns>The <typeparamref name="TPixel"/> match.</returns>
TPixel GetPaletteColor(TPixel color);
public TPixel GetPaletteColor(TPixel color);
}

4
src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs

@ -80,7 +80,7 @@ internal sealed class PaletteDitherProcessor<TPixel> : ImageProcessor<TPixel>
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
internal readonly struct DitherProcessor : IPaletteDitherImageProcessor<TPixel>, IDisposable
{
private readonly EuclideanPixelMap<TPixel> pixelMap;
private readonly PixelMap<TPixel> pixelMap;
[MethodImpl(InliningOptions.ShortMethod)]
public DitherProcessor(
@ -89,7 +89,7 @@ internal sealed class PaletteDitherProcessor<TPixel> : ImageProcessor<TPixel>
float ditherScale)
{
this.Configuration = configuration;
this.pixelMap = new EuclideanPixelMap<TPixel>(configuration, palette);
this.pixelMap = PixelMapFactory.Create(configuration, palette, ColorMatchingMode.Coarse);
this.Palette = palette;
this.DitherScale = ditherScale;
}

7
src/ImageSharp/Processing/Processors/ImageProcessor{TPixel}.cs

@ -95,7 +95,7 @@ public abstract class ImageProcessor<TPixel> : IImageProcessor<TPixel>
protected abstract void OnFrameApply(ImageFrame<TPixel> source);
/// <summary>
/// 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.
/// </summary>
/// <param name="source">The source image. Cannot be null.</param>
protected virtual void AfterFrameApply(ImageFrame<TPixel> source)
@ -103,11 +103,10 @@ public abstract class ImageProcessor<TPixel> : IImageProcessor<TPixel>
}
/// <summary>
/// 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.
/// </summary>
protected virtual void AfterImageApply()
{
}
=> this.Source.Metadata.AfterImageApply(this.Source);
/// <summary>
/// Disposes the object and frees resources for the Garbage Collector.

28
src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs

@ -0,0 +1,28 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Defines the precision level used when matching colors during quantization.
/// </summary>
public enum ColorMatchingMode
{
/// <summary>
/// Uses a coarse caching strategy optimized for performance at the expense of exact matches.
/// This provides the fastest matching but may yield approximate results.
/// </summary>
Coarse,
/// <summary>
/// Enables an exact color match cache for the first 512 unique colors encountered,
/// falling back to coarse matching thereafter.
/// </summary>
Hybrid,
/// <summary>
/// Performs exact color matching without any caching optimizations.
/// This is the slowest but most accurate matching strategy.
/// </summary>
Exact
}

184
src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs

@ -0,0 +1,184 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Gets the closest color to the supplied color based upon the Euclidean distance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <typeparam name="TCache">The cache type.</typeparam>
/// <para>
/// This class is not thread safe and should not be accessed in parallel.
/// Doing so will result in non-idempotent results.
/// </para>
internal sealed class EuclideanPixelMap<TPixel, TCache> : PixelMap<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
where TCache : struct, IColorIndexCache<TCache>
{
private Rgba32[] rgbaPalette;
// Do not make readonly. It's a mutable struct.
#pragma warning disable IDE0044 // Add readonly modifier
private TCache cache;
#pragma warning restore IDE0044 // Add readonly modifier
private readonly Configuration configuration;
/// <summary>
/// Initializes a new instance of the <see cref="EuclideanPixelMap{TPixel, TCache}"/> class.
/// </summary>
/// <param name="configuration">Specifies the settings and resources for the pixel map's operations.</param>
/// <param name="palette">Defines the color palette used for pixel mapping.</param>
public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory<TPixel> palette)
{
this.configuration = configuration;
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
this.cache = TCache.Create(configuration.MemoryAllocator);
PixelOperations<TPixel>.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public override int GetClosestColor(TPixel color, out TPixel match)
{
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
Rgba32 rgba = color.ToRgba32();
if (this.cache.TryGetValue(rgba, out short index))
{
match = Unsafe.Add(ref paletteRef, (ushort)index);
return index;
}
return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
}
/// <inheritdoc/>
public override void Clear(ReadOnlyMemory<TPixel> palette)
{
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
PixelOperations<TPixel>.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
this.cache.Clear();
}
[MethodImpl(InliningOptions.ColdPath)]
private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
{
// Loop through the palette and find the nearest match.
int index = 0;
float leastDistance = float.MaxValue;
for (int i = 0; i < this.rgbaPalette.Length; i++)
{
Rgba32 candidate = this.rgbaPalette[i];
if (candidate.PackedValue == rgba.PackedValue)
{
index = i;
break;
}
float distance = DistanceSquared(rgba, candidate);
if (distance == 0)
{
index = i;
break;
}
if (distance < leastDistance)
{
index = i;
leastDistance = distance;
}
}
// Now I have the index, pop it into the cache for next time
_ = this.cache.TryAdd(rgba, (short)index);
match = Unsafe.Add(ref paletteRef, (uint)index);
return index;
}
/// <summary>
/// Returns the Euclidean distance squared between two specified points.
/// </summary>
/// <param name="a">The first point.</param>
/// <param name="b">The second point.</param>
/// <returns>The distance squared.</returns>
[MethodImpl(InliningOptions.ShortMethod)]
private static float DistanceSquared(Rgba32 a, Rgba32 b)
{
float deltaR = a.R - b.R;
float deltaG = a.G - b.G;
float deltaB = a.B - b.B;
float deltaA = a.A - b.A;
return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
}
/// <inheritdoc/>
public override void Dispose() => this.cache.Dispose();
}
/// <summary>
/// Represents a map of colors to indices.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
internal abstract class PixelMap<TPixel> : IDisposable
where TPixel : unmanaged, IPixel<TPixel>
{
/// <summary>
/// Gets the color palette of this <see cref="PixelMap{TPixel}"/>.
/// </summary>
public ReadOnlyMemory<TPixel> Palette { get; private protected set; }
/// <summary>
/// Returns the closest color in the palette and the index of that pixel.
/// </summary>
/// <param name="color">The color to match.</param>
/// <param name="match">The matched color.</param>
/// <returns>
/// The <see cref="int"/> index.
/// </returns>
public abstract int GetClosestColor(TPixel color, out TPixel match);
/// <summary>
/// Clears the map, resetting it to use the given palette.
/// </summary>
/// <param name="palette">The color palette to map from.</param>
public abstract void Clear(ReadOnlyMemory<TPixel> palette);
/// <inheritdoc/>
public abstract void Dispose();
}
/// <summary>
/// A factory for creating <see cref="PixelMap{TPixel}"/> instances.
/// </summary>
internal static class PixelMapFactory
{
/// <summary>
/// Creates a new <see cref="PixelMap{TPixel}"/> instance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="configuration">The configuration.</param>
/// <param name="palette">The color palette to map from.</param>
/// <param name="colorMatchingMode">The color matching mode.</param>
/// <returns>
/// The <see cref="PixelMap{TPixel}"/>.
/// </returns>
public static PixelMap<TPixel> Create<TPixel>(
Configuration configuration,
ReadOnlyMemory<TPixel> palette,
ColorMatchingMode colorMatchingMode)
where TPixel : unmanaged, IPixel<TPixel> => colorMatchingMode switch
{
ColorMatchingMode.Hybrid => new EuclideanPixelMap<TPixel, HybridCache>(configuration, palette),
ColorMatchingMode.Exact => new EuclideanPixelMap<TPixel, NullCache>(configuration, palette),
_ => new EuclideanPixelMap<TPixel, CoarseCache>(configuration, palette),
};
}

258
src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs

@ -1,258 +0,0 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Gets the closest color to the supplied color based upon the Euclidean distance.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <para>
/// This class is not thread safe and should not be accessed in parallel.
/// Doing so will result in non-idempotent results.
/// </para>
internal sealed class EuclideanPixelMap<TPixel> : IDisposable
where TPixel : unmanaged, IPixel<TPixel>
{
private Rgba32[] rgbaPalette;
private int transparentIndex;
private readonly TPixel transparentMatch;
/// <summary>
/// Do not make this readonly! Struct value would be always copied on non-readonly method calls.
/// </summary>
private ColorDistanceCache cache;
private readonly Configuration configuration;
/// <summary>
/// Initializes a new instance of the <see cref="EuclideanPixelMap{TPixel}"/> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="palette">The color palette to map from.</param>
public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory<TPixel> palette)
: this(configuration, palette, -1)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="EuclideanPixelMap{TPixel}"/> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="palette">The color palette to map from.</param>
/// <param name="transparentIndex">An explicit index at which to match transparent pixels.</param>
public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory<TPixel> palette, int transparentIndex = -1)
{
this.configuration = configuration;
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
this.cache = new ColorDistanceCache(configuration.MemoryAllocator);
PixelOperations<TPixel>.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
this.transparentIndex = transparentIndex;
this.transparentMatch = TPixel.FromRgba32(default);
}
/// <summary>
/// Gets the color palette of this <see cref="EuclideanPixelMap{TPixel}"/>.
/// The palette memory is owned by the palette source that created it.
/// </summary>
public ReadOnlyMemory<TPixel> Palette { get; private set; }
/// <summary>
/// Returns the closest color in the palette and the index of that pixel.
/// The palette contents must match the one used in the constructor.
/// </summary>
/// <param name="color">The color to match.</param>
/// <param name="match">The matched color.</param>
/// <returns>The <see cref="int"/> index.</returns>
[MethodImpl(InliningOptions.ShortMethod)]
public int GetClosestColor(TPixel color, out TPixel match)
{
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
Rgba32 rgba = color.ToRgba32();
// Check if the color is in the lookup table
if (!this.cache.TryGetValue(rgba, out short index))
{
return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
}
match = Unsafe.Add(ref paletteRef, (ushort)index);
return index;
}
/// <summary>
/// Clears the map, resetting it to use the given palette.
/// </summary>
/// <param name="palette">The color palette to map from.</param>
public void Clear(ReadOnlyMemory<TPixel> palette)
{
this.Palette = palette;
this.rgbaPalette = new Rgba32[palette.Length];
PixelOperations<TPixel>.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
this.transparentIndex = -1;
this.cache.Clear();
}
/// <summary>
/// Allows setting the transparent index after construction.
/// </summary>
/// <param name="index">An explicit index at which to match transparent pixels.</param>
public void SetTransparentIndex(int index)
{
if (index != this.transparentIndex)
{
this.cache.Clear();
}
this.transparentIndex = index;
}
[MethodImpl(InliningOptions.ShortMethod)]
private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
{
// Loop through the palette and find the nearest match.
int index = 0;
if (this.transparentIndex >= 0 && rgba == default)
{
// We have explicit instructions. No need to search.
index = this.transparentIndex;
this.cache.Add(rgba, (byte)index);
match = this.transparentMatch;
return index;
}
float leastDistance = float.MaxValue;
for (int i = 0; i < this.rgbaPalette.Length; i++)
{
Rgba32 candidate = this.rgbaPalette[i];
float distance = DistanceSquared(rgba, candidate);
// If it's an exact match, exit the loop
if (distance == 0)
{
index = i;
break;
}
if (distance < leastDistance)
{
// Less than... assign.
index = i;
leastDistance = distance;
}
}
// Now I have the index, pop it into the cache for next time
this.cache.Add(rgba, (byte)index);
match = Unsafe.Add(ref paletteRef, (uint)index);
return index;
}
/// <summary>
/// Returns the Euclidean distance squared between two specified points.
/// </summary>
/// <param name="a">The first point.</param>
/// <param name="b">The second point.</param>
/// <returns>The distance squared.</returns>
[MethodImpl(InliningOptions.ShortMethod)]
private static float DistanceSquared(Rgba32 a, Rgba32 b)
{
float deltaR = a.R - b.R;
float deltaG = a.G - b.G;
float deltaB = a.B - b.B;
float deltaA = a.A - b.A;
return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
}
public void Dispose() => this.cache.Dispose();
/// <summary>
/// A cache for storing color distance matching results.
/// </summary>
/// <remarks>
/// <para>
/// 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).
/// </para>
/// </remarks>
private unsafe struct ColorDistanceCache : IDisposable
{
private const int IndexRBits = 5;
private const int IndexGBits = 5;
private const int IndexBBits = 5;
private const int IndexABits = 6;
private const int IndexRCount = (1 << IndexRBits) + 1;
private const int IndexGCount = (1 << IndexGBits) + 1;
private const int IndexBCount = (1 << IndexBBits) + 1;
private const int IndexACount = (1 << IndexABits) + 1;
private const int RShift = 8 - IndexRBits;
private const int GShift = 8 - IndexGBits;
private const int BShift = 8 - IndexBBits;
private const int AShift = 8 - IndexABits;
private const int Entries = IndexRCount * IndexGCount * IndexBCount * IndexACount;
private MemoryHandle tableHandle;
private readonly IMemoryOwner<short> table;
private readonly short* tablePointer;
public ColorDistanceCache(MemoryAllocator allocator)
{
this.table = allocator.Allocate<short>(Entries);
this.table.GetSpan().Fill(-1);
this.tableHandle = this.table.Memory.Pin();
this.tablePointer = (short*)this.tableHandle.Pointer;
}
[MethodImpl(InliningOptions.ShortMethod)]
public readonly void Add(Rgba32 rgba, byte index)
{
int idx = GetPaletteIndex(rgba);
this.tablePointer[idx] = index;
}
[MethodImpl(InliningOptions.ShortMethod)]
public readonly bool TryGetValue(Rgba32 rgba, out short match)
{
int idx = GetPaletteIndex(rgba);
match = this.tablePointer[idx];
return match > -1;
}
/// <summary>
/// Clears the cache resetting each entry to empty.
/// </summary>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly void Clear() => this.table.GetSpan().Fill(-1);
[MethodImpl(InliningOptions.ShortMethod)]
private static int GetPaletteIndex(Rgba32 rgba)
{
int rIndex = rgba.R >> RShift;
int gIndex = rgba.G >> GShift;
int bIndex = rgba.B >> BShift;
int aIndex = rgba.A >> AShift;
return (aIndex * (IndexRCount * IndexGCount * IndexBCount)) +
(rIndex * (IndexGCount * IndexBCount)) +
(gIndex * IndexBCount) + bIndex;
}
public void Dispose()
{
if (this.table != null)
{
this.tableHandle.Dispose();
this.table.Dispose();
}
}
}
}

569
src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs

@ -0,0 +1,569 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers;
using System.Runtime.CompilerServices;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Represents a cache used for efficiently retrieving palette indices for colors.
/// </summary>
internal interface IColorIndexCache : IDisposable
{
/// <summary>
/// Adds a color to the cache.
/// </summary>
/// <param name="color">The color to add.</param>
/// <param name="value">The index of the color in the palette.</param>
/// <returns>
/// <see langword="true"/> if the color was added; otherwise, <see langword="false"/>.
/// </returns>
public bool TryAdd(Rgba32 color, short value);
/// <summary>
/// Gets the index of the color in the palette.
/// </summary>
/// <param name="color">The color to get the index for.</param>
/// <param name="value">The index of the color in the palette.</param>
/// <returns>
/// <see langword="true"/> if the color is in the palette; otherwise, <see langword="false"/>.
/// </returns>
public bool TryGetValue(Rgba32 color, out short value);
/// <summary>
/// Clears the cache.
/// </summary>
public void Clear();
}
/// <summary>
/// Represents a cache used for efficiently retrieving palette indices for colors.
/// </summary>
/// <typeparam name="T">The type of the cache.</typeparam>
internal interface IColorIndexCache<T> : IColorIndexCache
where T : struct, IColorIndexCache
{
/// <summary>
/// Creates a new instance of the cache.
/// </summary>
/// <param name="allocator">The memory allocator to use.</param>
/// <returns>
/// The new instance of the cache.
/// </returns>
public static abstract T Create(MemoryAllocator allocator);
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal unsafe struct HybridCache : IColorIndexCache<HybridCache>
{
private CoarseCache coarseCache;
private AccurateCache accurateCache;
public HybridCache(MemoryAllocator allocator)
{
this.accurateCache = AccurateCache.Create(allocator);
this.coarseCache = CoarseCache.Create(allocator);
}
/// <inheritdoc/>
public static HybridCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryAdd(Rgba32 color, short index)
{
if (this.accurateCache.TryAdd(color, index))
{
return true;
}
return this.coarseCache.TryAdd(color, index);
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly bool TryGetValue(Rgba32 color, out short value)
{
if (this.accurateCache.TryGetValue(color, out value))
{
return true;
}
return this.coarseCache.TryGetValue(color, out value);
}
/// <inheritdoc/>
public readonly void Clear()
{
this.accurateCache.Clear();
this.coarseCache.Clear();
}
/// <inheritdoc/>
public void Dispose()
{
this.accurateCache.Dispose();
this.coarseCache.Dispose();
}
}
/// <summary>
/// A coarse cache for color distance lookups that uses a fixed-size lookup table.
/// </summary>
/// <remarks>
/// This cache uses a fixed lookup table with 2,097,152 bins, each storing a 2-byte value,
/// resulting in a memory usage of approximately 4 MB. Lookups and insertions are
/// performed in constant time (O(1)) via direct table indexing. This design is optimized for
/// speed while maintaining a predictable, fixed memory footprint.
/// </remarks>
internal unsafe struct CoarseCache : IColorIndexCache<CoarseCache>
{
private const int IndexRBits = 5;
private const int IndexGBits = 5;
private const int IndexBBits = 5;
private const int IndexABits = 6;
private const int IndexRCount = 1 << IndexRBits; // 32 bins for red
private const int IndexGCount = 1 << IndexGBits; // 32 bins for green
private const int IndexBCount = 1 << IndexBBits; // 32 bins for blue
private const int IndexACount = 1 << IndexABits; // 64 bins for alpha
private const int TotalBins = IndexRCount * IndexGCount * IndexBCount * IndexACount; // 2,097,152 bins
private readonly IMemoryOwner<short> binsOwner;
private readonly short* binsPointer;
private MemoryHandle binsHandle;
private CoarseCache(MemoryAllocator allocator)
{
this.binsOwner = allocator.Allocate<short>(TotalBins);
this.binsOwner.GetSpan().Fill(-1);
this.binsHandle = this.binsOwner.Memory.Pin();
this.binsPointer = (short*)this.binsHandle.Pointer;
}
/// <inheritdoc/>
public static CoarseCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly bool TryAdd(Rgba32 color, short value)
{
this.binsPointer[GetCoarseIndex(color)] = value;
return true;
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly bool TryGetValue(Rgba32 color, out short value)
{
value = this.binsPointer[GetCoarseIndex(color)];
return value > -1; // Coarse match found
}
[MethodImpl(InliningOptions.ShortMethod)]
private static int GetCoarseIndex(Rgba32 color)
{
int rIndex = color.R >> (8 - IndexRBits);
int gIndex = color.G >> (8 - IndexGBits);
int bIndex = color.B >> (8 - IndexBBits);
int aIndex = color.A >> (8 - IndexABits);
return (aIndex * IndexRCount * IndexGCount * IndexBCount) +
(rIndex * IndexGCount * IndexBCount) +
(gIndex * IndexBCount) +
bIndex;
}
/// <inheritdoc/>
public readonly void Clear()
=> this.binsOwner.GetSpan().Fill(-1);
/// <inheritdoc/>
public void Dispose()
{
this.binsHandle.Dispose();
this.binsOwner.Dispose();
}
}
/// <summary>
/// <para>
/// 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).
/// </para>
/// <para>
/// 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 <see cref="AlphaBucket"/>, 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).
/// </para>
/// <para>
/// Performance Characteristics:
/// - Lookup: O(1) for computing the bucket index from the RGB channels, plus a small constant time (up to 8 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.
/// </para>
/// <para>
/// Memory Characteristics:
/// - The cache consists of 32,768 buckets.
/// - Each <see cref="AlphaBucket"/> is implemented using an inline array with a capacity of 8 entries.
/// - Each bucket occupies approximately 1 byte (Count) + (8 entries × 3 bytes each) ≈ 25 bytes.
/// - Overall, the buckets occupy roughly 32,768 × 25 bytes = 819,200 bytes (≈ 800 KB).
/// </para>
/// <para>
/// 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).
/// </para>
/// </summary>
internal unsafe struct CoarseCacheLite : IColorIndexCache<CoarseCacheLite>
{
// 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 RgbShift = 8 - RgbBits; // 3
private const int BucketCount = 1 << (RgbBits * 3); // 32768
private readonly IMemoryOwner<AlphaBucket> bucketsOwner;
private readonly AlphaBucket* buckets;
private MemoryHandle bucketHandle;
private CoarseCacheLite(MemoryAllocator allocator)
{
this.bucketsOwner = allocator.Allocate<AlphaBucket>(BucketCount, AllocationOptions.Clean);
this.bucketHandle = this.bucketsOwner.Memory.Pin();
this.buckets = (AlphaBucket*)this.bucketHandle.Pointer;
}
/// <inheritdoc/>
public static CoarseCacheLite Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
public readonly bool TryAdd(Rgba32 color, short paletteIndex)
{
int bucketIndex = GetBucketIndex(color.R, color.G, color.B);
byte quantAlpha = QuantizeAlpha(color.A);
this.buckets[bucketIndex].Add(quantAlpha, paletteIndex);
return true;
}
/// <inheritdoc/>
public readonly 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);
}
/// <inheritdoc/>
public readonly void Clear()
{
Span<AlphaBucket> bucketsSpan = this.bucketsOwner.GetSpan();
bucketsSpan.Clear();
}
/// <inheritdoc/>
public void Dispose()
{
this.bucketHandle.Dispose();
this.bucketsOwner.Dispose();
}
[MethodImpl(InliningOptions.ShortMethod)]
private static int GetBucketIndex(byte r, byte g, byte b)
{
int qr = r >> RgbShift;
int qg = g >> RgbShift;
int qb = b >> RgbShift;
// Combine the quantized channels into a single index.
return (qr << (RgbBits << 1)) | (qg << RgbBits) | qb;
}
[MethodImpl(InliningOptions.ShortMethod)]
private static byte QuantizeAlpha(byte a)
// Quantize to 6 bits: shift right by (8 - 6) = 2 bits.
=> (byte)(a >> 2);
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 8 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 8 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 8 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 8 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 = 8;
public byte Count;
private InlineArray8<AlphaEntry> entries;
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryGetValue(byte quantizedAlpha, out short paletteIndex)
{
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(InliningOptions.ShortMethod)]
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;
}
}
}
}
/// <summary>
/// A fixed-capacity dictionary with exactly 512 entries mapping a <see cref="uint"/> key
/// to a <see cref="short"/> value.
/// </summary>
/// <remarks>
/// 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 &amp; 0x1FF), and collisions are
/// resolved through a linked chain stored in the <see cref="Entry.Next"/> 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.
/// </remarks>
internal unsafe struct AccurateCache : IColorIndexCache<AccurateCache>
{
// 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<short> bucketsOwner;
private MemoryHandle bucketsHandle;
private short* buckets;
// Entries array: stores up to 256 entries.
private readonly IMemoryOwner<Entry> entriesOwner;
private MemoryHandle entriesHandle;
private Entry* entries;
public const int Capacity = 512;
private AccurateCache(MemoryAllocator allocator)
{
this.Count = 0;
// Allocate exactly 512 indexes for buckets.
this.bucketsOwner = allocator.Allocate<short>(Capacity, AllocationOptions.Clean);
Span<short> 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<Entry>(Capacity, AllocationOptions.Clean);
this.entriesHandle = this.entriesOwner.Memory.Pin();
this.entries = (Entry*)this.entriesHandle.Pointer;
}
public int Count { get; private set; }
/// <inheritdoc/>
public static AccurateCache Create(MemoryAllocator allocator) => new(allocator);
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryAdd(Rgba32 color, short value)
{
if (this.Count == Capacity)
{
return false; // Dictionary is full.
}
uint key = color.PackedValue;
// The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A
// (with R in the most significant byte and A in the least significant).
// To compute the bucket index:
// 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels.
// 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A).
// 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A),
// which helps to counteract situations where one or more channels have a limited range.
// 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511,
// which corresponds to our fixed bucket count of 512.
int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
int i = this.buckets[bucket];
// Traverse the collision chain.
Entry* entries = this.entries;
while (i != -1)
{
Entry e = entries[i];
if (e.Key == key)
{
// Key already exists; do not overwrite.
return false;
}
i = e.Next;
}
short index = (short)this.Count;
this.Count++;
// Insert the new entry:
entries[index].Key = key;
entries[index].Value = value;
// Link this new entry into the bucket chain.
entries[index].Next = this.buckets[bucket];
this.buckets[bucket] = index;
return true;
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public bool TryGetValue(Rgba32 color, out short value)
{
uint key = color.PackedValue;
int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
int i = this.buckets[bucket];
// If the bucket is empty, return immediately.
if (i == -1)
{
value = -1;
return false;
}
// Traverse the chain.
Entry* entries = this.entries;
do
{
Entry e = entries[i];
if (e.Key == key)
{
value = e.Value;
return true;
}
i = e.Next;
}
while (i != -1);
value = -1;
return false;
}
/// <summary>
/// Clears the dictionary.
/// </summary>
public void Clear()
{
Span<short> bucketSpan = this.bucketsOwner.GetSpan();
bucketSpan.Fill(-1);
this.Count = 0;
}
public void Dispose()
{
this.bucketsHandle.Dispose();
this.bucketsOwner.Dispose();
this.entriesHandle.Dispose();
this.entriesOwner.Dispose();
this.buckets = null;
this.entries = null;
}
private struct Entry
{
public uint Key; // The key (packed RGBA)
public short Value; // The value; -1 means unused.
public short Next; // Index of the next entry in the chain, or -1 if none.
}
}
/// <summary>
/// Represents a cache that does not store any values.
/// It allows adding colors, but always returns false when trying to retrieve them.
/// </summary>
internal readonly struct NullCache : IColorIndexCache<NullCache>
{
/// <inheritdoc/>
public static NullCache Create(MemoryAllocator allocator) => default;
/// <inheritdoc/>
public bool TryAdd(Rgba32 color, short value) => true;
/// <inheritdoc/>
public bool TryGetValue(Rgba32 color, out short value)
{
value = -1;
return false;
}
/// <inheritdoc/>
public void Clear()
{
}
/// <inheritdoc/>
public void Dispose()
{
}
}

6
src/ImageSharp/Processing/Processors/Quantization/IQuantizer.cs

@ -13,7 +13,7 @@ public interface IQuantizer
/// <summary>
/// Gets the quantizer options defining quantization rules.
/// </summary>
QuantizerOptions Options { get; }
public QuantizerOptions Options { get; }
/// <summary>
/// Creates the generic frame quantizer.
@ -21,7 +21,7 @@ public interface IQuantizer
/// <param name="configuration">The <see cref="Configuration"/> to configure internal operations.</param>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <returns>The <see cref="IQuantizer{TPixel}"/>.</returns>
IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration)
public IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration)
where TPixel : unmanaged, IPixel<TPixel>;
/// <summary>
@ -31,6 +31,6 @@ public interface IQuantizer
/// <param name="configuration">The <see cref="Configuration"/> to configure internal operations.</param>
/// <param name="options">The options to create the quantizer with.</param>
/// <returns>The <see cref="IQuantizer{TPixel}"/>.</returns>
IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration, QuantizerOptions options)
public IQuantizer<TPixel> CreatePixelSpecificQuantizer<TPixel>(Configuration configuration, QuantizerOptions options)
where TPixel : unmanaged, IPixel<TPixel>;
}

18
src/ImageSharp/Processing/Processors/Quantization/IQuantizer{TPixel}.cs

@ -16,26 +16,26 @@ public interface IQuantizer<TPixel> : IDisposable
/// <summary>
/// Gets the configuration.
/// </summary>
Configuration Configuration { get; }
public Configuration Configuration { get; }
/// <summary>
/// Gets the quantizer options defining quantization rules.
/// </summary>
QuantizerOptions Options { get; }
public QuantizerOptions Options { get; }
/// <summary>
/// Gets the quantized color palette.
/// </summary>
/// <exception cref="InvalidOperationException">
/// The palette has not been built via <see cref="AddPaletteColors"/>.
/// The palette has not been built via <see cref="AddPaletteColors(in Buffer2DRegion{TPixel})"/>.
/// </exception>
ReadOnlyMemory<TPixel> Palette { get; }
public ReadOnlyMemory<TPixel> Palette { get; }
/// <summary>
/// Adds colors to the quantized palette from the given pixel source.
/// </summary>
/// <param name="pixelRegion">The <see cref="Buffer2DRegion{T}"/> of source pixels to register.</param>
void AddPaletteColors(Buffer2DRegion<TPixel> pixelRegion);
public void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion);
/// <summary>
/// Quantizes an image frame and return the resulting output pixels.
@ -46,10 +46,10 @@ public interface IQuantizer<TPixel> : IDisposable
/// A <see cref="IndexedImageFrame{TPixel}"/> representing a quantized version of the source frame pixels.
/// </returns>
/// <remarks>
/// Only executes the second (quantization) step. The palette has to be built by calling <see cref="AddPaletteColors"/>.
/// To run both steps, use <see cref="QuantizerUtilities.BuildPaletteAndQuantizeFrame{TPixel}"/>.
/// Only executes the second (quantization) step. The palette has to be built by calling <see cref="AddPaletteColors(in Buffer2DRegion{TPixel})"/>.
/// To run both steps, use <see cref="QuantizerUtilities.BuildPaletteAndQuantizeFrame{TPixel}(IQuantizer{TPixel}, ImageFrame{TPixel}, Rectangle)"/>.
/// </remarks>
IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds);
public IndexedImageFrame<TPixel> QuantizeFrame(ImageFrame<TPixel> source, Rectangle bounds);
/// <summary>
/// Returns the index and color from the quantized palette corresponding to the given color.
@ -57,7 +57,7 @@ public interface IQuantizer<TPixel> : IDisposable
/// <param name="color">The color to match.</param>
/// <param name="match">The matched color.</param>
/// <returns>The <see cref="byte"/> index.</returns>
byte GetQuantizedColor(TPixel color, out TPixel match);
public byte GetQuantizedColor(TPixel color, out TPixel match);
// TODO: Enable bulk operations.
// void GetQuantizedColors(ReadOnlySpan<TPixel> colors, ReadOnlySpan<TPixel> palette, Span<byte> indices, Span<TPixel> matches);

21
src/ImageSharp/Processing/Processors/Quantization/IQuantizingPixelRowDelegate{TPixel}.cs

@ -0,0 +1,21 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.PixelFormats;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Defines a delegate for processing a row of pixels in an image for quantization.
/// </summary>
/// <typeparam name="TPixel">Represents a pixel type that can be processed in a quantizing operation.</typeparam>
internal interface IQuantizingPixelRowDelegate<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
/// <summary>
/// Processes a row of pixels for quantization.
/// </summary>
/// <param name="row">The row of pixels to process.</param>
/// <param name="rowIndex">The index of the row being processed.</param>
public void Invoke(ReadOnlySpan<TPixel> row, int rowIndex);
}

685
src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs

@ -16,11 +16,10 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <see href="http://msdn.microsoft.com/en-us/library/aa479306.aspx"/>
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
[SuppressMessage(
"Design",
"CA1001:Types that own disposable fields should be disposable",
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
#pragma warning disable CA1001 // Types that own disposable fields should be disposable
// See https://github.com/dotnet/roslyn-analyzers/issues/6151
public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
#pragma warning restore CA1001 // Types that own disposable fields should be disposable
where TPixel : unmanaged, IPixel<TPixel>
{
private readonly int maxColors;
@ -28,14 +27,14 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
private readonly Octree octree;
private readonly IMemoryOwner<TPixel> paletteOwner;
private ReadOnlyMemory<TPixel> palette;
private EuclideanPixelMap<TPixel>? pixelMap;
private PixelMap<TPixel>? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="OctreeQuantizer{TPixel}"/> struct.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behaviour or extending the library.</param>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public OctreeQuantizer(Configuration configuration, QuantizerOptions options)
@ -45,7 +44,7 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
this.maxColors = this.Options.MaxColors;
this.bitDepth = Numerics.Clamp(ColorNumerics.GetBitsNeededForColorDepth(this.maxColors), 1, 8);
this.octree = new Octree(this.bitDepth);
this.octree = new Octree(configuration, this.bitDepth, this.maxColors, this.Options.TransparencyThreshold);
this.paletteOwner = configuration.MemoryAllocator.Allocate<TPixel>(this.maxColors, AllocationOptions.Clean);
this.pixelMap = default;
this.palette = default;
@ -60,64 +59,41 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
public QuantizerOptions Options { get; }
/// <inheritdoc/>
public readonly ReadOnlyMemory<TPixel> Palette
public ReadOnlyMemory<TPixel> Palette
{
get
{
QuantizerUtilities.CheckPaletteState(in this.palette);
if (this.palette.IsEmpty)
{
this.ResolvePalette();
QuantizerUtilities.CheckPaletteState(in this.palette);
}
return this.palette;
}
}
/// <inheritdoc/>
public void AddPaletteColors(Buffer2DRegion<TPixel> pixelRegion)
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
{
using (IMemoryOwner<Rgba32> buffer = this.Configuration.MemoryAllocator.Allocate<Rgba32>(pixelRegion.Width))
{
Span<Rgba32> bufferSpan = buffer.GetSpan();
// Loop through each row
for (int y = 0; y < pixelRegion.Height; y++)
{
Span<TPixel> row = pixelRegion.DangerousGetRowSpan(y);
PixelOperations<TPixel>.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);
}
}
}
PixelRowDelegate pixelRowDelegate = new(this.octree);
QuantizerUtilities.AddPaletteColors<OctreeQuantizer<TPixel>, TPixel, Rgba32, PixelRowDelegate>(
ref Unsafe.AsRef(in this),
in pixelRegion,
in pixelRowDelegate);
}
int paletteIndex = 0;
private void ResolvePalette()
{
short paletteIndex = 0;
Span<TPixel> 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<TPixel> result = this.paletteOwner.Memory[..paletteSpan.Length];
// When called multiple times by QuantizerUtilities.BuildPalette
// this prevents memory churn caused by reallocation.
if (this.pixelMap is null)
{
this.pixelMap = new EuclideanPixelMap<TPixel>(this.Configuration, result);
}
else
if (this.isDithering)
{
this.pixelMap.Clear(result);
this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode);
}
this.palette = result;
@ -132,18 +108,19 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
[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);
}
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;
}
/// <inheritdoc/>
@ -155,413 +132,529 @@ public struct OctreeQuantizer<TPixel> : IQuantizer<TPixel>
this.paletteOwner.Dispose();
this.pixelMap?.Dispose();
this.pixelMap = null;
this.octree.Dispose();
}
}
private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate<Rgba32>
{
private readonly Octree octree;
public PixelRowDelegate(Octree octree) => this.octree = octree;
public void Invoke(ReadOnlySpan<Rgba32> row, int rowIndex) => this.octree.AddColors(row);
}
/// <summary>
/// Class which does the actual quantization.
/// 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.
/// </summary>
private sealed class Octree
internal sealed class Octree : IDisposable
{
/// <summary>
/// The root of the Octree
/// </summary>
private readonly OctreeNode root;
// The memory allocator.
private readonly MemoryAllocator allocator;
/// <summary>
/// Maximum number of significant bits in the image
/// </summary>
// Pooled buffer for OctreeNodes.
private readonly IMemoryOwner<OctreeNode> 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;
/// <summary>
/// Store the last node quantized
/// </summary>
private OctreeNode? previousNode;
// The threshold for transparent colors.
private readonly int transparencyThreshold255;
/// <summary>
/// Cache the previous color quantized
/// </summary>
// 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<short> freeIndices = new();
/// <summary>
/// Initializes a new instance of the <see cref="Octree"/> class.
/// </summary>
/// <param name="maxColorBits">
/// The maximum number of significant bits in the image
/// </param>
public Octree(int maxColorBits)
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="maxColorBits">The maximum number of significant bits in the image.</param>
/// <param name="maxColors">The maximum number of colors to allow in the palette.</param>
/// <param name="transparencyThreshold">The threshold for transparent colors.</param>
public Octree(
Configuration configuration,
int maxColorBits,
int maxColors,
float transparencyThreshold)
{
this.maxColorBits = maxColorBits;
this.maxColors = maxColors;
this.transparencyThreshold255 = (int)(transparencyThreshold * 255F);
this.Leaves = 0;
this.ReducibleNodes = new OctreeNode[9];
this.root = new OctreeNode(0, this.maxColorBits, this);
this.previousNode = -1;
this.previousColor = default;
this.previousNode = null;
// Allocate a conservative buffer for nodes.
const int capacity = 4096;
this.allocator = configuration.MemoryAllocator;
this.nodesOwner = this.allocator.Allocate<OctreeNode>(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);
}
/// <summary>
/// Gets or sets the number of leaves in the tree
/// Gets or sets the number of leaves in the tree.
/// </summary>
public int Leaves
{
[MethodImpl(InliningOptions.ShortMethod)]
get;
public int Leaves { get; set; }
[MethodImpl(InliningOptions.ShortMethod)]
set;
}
/// <summary>
/// Gets the full collection of nodes as a span.
/// </summary>
internal Span<OctreeNode> Nodes => this.nodesOwner.Memory.Span;
/// <summary>
/// Gets the array of reducible nodes
/// Adds a span of colors to the octree.
/// </summary>
private OctreeNode?[] ReducibleNodes
/// <param name="row">A span of color values to be added.</param>
public void AddColors(ReadOnlySpan<Rgba32> row)
{
[MethodImpl(InliningOptions.ShortMethod)]
get;
for (int x = 0; x < row.Length; x++)
{
this.AddColor(row[x]);
}
}
/// <summary>
/// Add a given color value to the Octree
/// Add a color to the Octree.
/// </summary>
/// <param name="color">The color to add.</param>
public void AddColor(Rgba32 color)
private void AddColor(Rgba32 color)
{
// Check if this request is for the same color as the last
// Ensure that the tree is not already full.
if (this.nextNode >= this.Nodes.Length && this.freeIndices.Count == 0)
{
while (this.Leaves > this.maxColors)
{
this.Reduce();
}
}
// If the color is the same as the previous color, increment the node.
// Otherwise, add a new node.
if (this.previousColor.Equals(color))
{
// If so, check if I have a previous node setup.
// This will only occur if the first color in the image
// happens to be black, with an alpha component of zero.
if (this.previousNode is null)
if (this.previousNode == -1)
{
this.previousColor = color;
this.root.AddColor(ref color, this.maxColorBits, 0, this);
OctreeNode.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
}
else
{
// Just update the previous node
this.previousNode.Increment(ref color);
OctreeNode.Increment(this.previousNode, color, this);
}
}
else
{
this.previousColor = color;
this.root.AddColor(ref color, this.maxColorBits, 0, this);
OctreeNode.AddColor(this.rootIndex, color, this.maxColorBits, 0, this);
}
}
/// <summary>
/// Convert the nodes in the Octree to a palette with a maximum of colorCount colors
/// Construct the palette from the octree.
/// </summary>
/// <param name="palette">The palette to fill.</param>
/// <param name="colorCount">The maximum number of colors</param>
/// <param name="paletteIndex">The palette index, used to calculate the final size of the palette.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public void Palletize(Span<TPixel> palette, int colorCount, ref int paletteIndex)
/// <param name="palette">The palette to construct.</param>
/// <param name="paletteIndex">The current palette index.</param>
public void Palettize(Span<TPixel> palette, ref short paletteIndex)
{
while (this.Leaves > colorCount)
while (this.Leaves > this.maxColors)
{
this.Reduce();
}
this.root.ConstructPalette(palette, ref paletteIndex);
this.Nodes[this.rootIndex].ConstructPalette(this, palette, ref paletteIndex);
}
/// <summary>
/// Get the palette index for the passed color
/// Get the palette index for the passed color.
/// </summary>
/// <param name="color">The color to match.</param>
/// <returns>
/// The <see cref="int"/> index.
/// </returns>
[MethodImpl(InliningOptions.ShortMethod)]
/// <param name="color">The color to get the palette index for.</param>
/// <returns>The <see cref="int"/>.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int GetPaletteIndex(TPixel color)
{
Rgba32 rgba = color.ToRgba32();
return this.root.GetPaletteIndex(ref rgba, 0);
}
=> this.Nodes[this.rootIndex].GetPaletteIndex(color.ToRgba32(), 0, this);
/// <summary>
/// Keep track of the previous node that was quantized
/// Track the previous node and color.
/// </summary>
/// <param name="node">
/// The node last quantized
/// </param>
[MethodImpl(InliningOptions.ShortMethod)]
public void TrackPrevious(OctreeNode node) => this.previousNode = node;
/// <param name="nodeIndex">The node index.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void TrackPrevious(int nodeIndex)
=> this.previousNode = nodeIndex;
/// <summary>
/// Reduce the depth of the tree
/// Reduce the depth of the tree.
/// </summary>
private void Reduce()
{
// Find the deepest level containing at least one reducible node
int index = this.maxColorBits - 1;
while ((index > 0) && (this.ReducibleNodes[index] is null))
while ((index > 0) && (this.reducibleNodes[index] == -1))
{
index--;
}
// Reduce the node most recently added to the list at level 'index'
OctreeNode node = this.ReducibleNodes[index]!;
this.ReducibleNodes[index] = node.NextReducible;
ref OctreeNode node = ref this.Nodes[this.reducibleNodes[index]];
this.reducibleNodes[index] = node.NextReducibleIndex;
// Decrement the leaf count after reducing the node
this.Leaves -= node.Reduce();
node.Reduce(this);
// And just in case I've reduced the last color to be added, and the next color to
// be added is the same, invalidate the previousNode...
this.previousNode = null;
this.previousNode = -1;
}
/// <summary>
/// Class which encapsulates each node in the tree
/// </summary>
public sealed class OctreeNode
// Allocate a new OctreeNode from the pooled buffer.
// First check the freeIndices stack.
internal short AllocateNode()
{
/// <summary>
/// Pointers to any child nodes
/// </summary>
private readonly OctreeNode?[]? children;
/// <summary>
/// Flag indicating that this is a leaf node
/// </summary>
private bool leaf;
if (this.freeIndices.Count > 0)
{
return this.freeIndices.Pop();
}
/// <summary>
/// Number of pixels in this node
/// </summary>
private int pixelCount;
if (this.nextNode >= this.Nodes.Length)
{
return -1;
}
/// <summary>
/// Red component
/// </summary>
private int red;
short newIndex = this.nextNode;
this.nextNode++;
return newIndex;
}
/// <summary>
/// Green Component
/// </summary>
private int green;
/// <summary>
/// Free a node index, making it available for re-allocation.
/// </summary>
/// <param name="index">The index to free.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal void FreeNode(short index)
{
this.freeIndices.Push(index);
this.Leaves--;
}
/// <summary>
/// Blue component
/// </summary>
private int blue;
/// <inheritdoc/>
public void Dispose() => this.nodesOwner.Dispose();
/// <summary>
/// The index of this node in the palette
/// </summary>
private int paletteIndex;
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct OctreeNode
{
public bool Leaf;
public int PixelCount;
public int Red;
public int Green;
public int Blue;
public int Alpha;
public short PaletteIndex;
public short NextReducibleIndex;
private InlineArray16<short> children;
[UnscopedRef]
public Span<short> Children => this.children;
/// <summary>
/// Initializes a new instance of the <see cref="OctreeNode"/> class.
/// Initialize the <see cref="OctreeNode"/>.
/// </summary>
/// <param name="level">The level in the tree = 0 - 7.</param>
/// <param name="level">The level of the node.</param>
/// <param name="colorBits">The number of significant color bits in the image.</param>
/// <param name="octree">The tree to which this node belongs.</param>
public OctreeNode(int level, int colorBits, Octree octree)
/// <param name="octree">The parent octree.</param>
/// <param name="index">The index of the node.</param>
public void Initialize(int level, int colorBits, Octree octree, short index)
{
// Construct the new node
this.leaf = level == colorBits;
this.red = this.green = this.blue = 0;
this.pixelCount = 0;
// If a leaf, increment the leaf count
if (this.leaf)
// Construct the new node.
this.Leaf = level == colorBits;
this.Red = 0;
this.Green = 0;
this.Blue = 0;
this.Alpha = 0;
this.PixelCount = 0;
this.PaletteIndex = 0;
this.NextReducibleIndex = -1;
// Always clear the Children array.
this.Children.Fill(-1);
if (this.Leaf)
{
octree.Leaves++;
this.NextReducible = null;
this.children = null;
}
else
{
// Otherwise add this to the reducible nodes
this.NextReducible = octree.ReducibleNodes[level];
octree.ReducibleNodes[level] = this;
this.children = new OctreeNode[8];
// Add this node to the reducible nodes list for its level.
this.NextReducibleIndex = octree.reducibleNodes[level];
octree.reducibleNodes[level] = index;
}
}
/// <summary>
/// Gets the next reducible node
/// </summary>
public OctreeNode? NextReducible
{
[MethodImpl(InliningOptions.ShortMethod)]
get;
}
/// <summary>
/// Add a color into the tree
/// Add a color to the Octree.
/// </summary>
/// <param name="nodeIndex">The node index.</param>
/// <param name="color">The color to add.</param>
/// <param name="colorBits">The number of significant color bits.</param>
/// <param name="level">The level in the tree.</param>
/// <param name="octree">The tree to which this node belongs.</param>
public void AddColor(ref Rgba32 color, int colorBits, int level, Octree octree)
/// <param name="colorBits">The number of significant color bits in the image.</param>
/// <param name="level">The level of the node.</param>
/// <param name="octree">The parent octree.</param>
public static void AddColor(int nodeIndex, Rgba32 color, int colorBits, int level, Octree octree)
{
// Update the color information if this is a leaf
if (this.leaf)
ref OctreeNode node = ref octree.Nodes[nodeIndex];
if (node.Leaf)
{
this.Increment(ref color);
// Setup the previous node
octree.TrackPrevious(this);
Increment(nodeIndex, color, octree);
octree.TrackPrevious(nodeIndex);
}
else
{
// Go to the next level down in the tree
int index = GetColorIndex(ref color, level);
int index = GetColorIndex(color, level);
short childIndex;
OctreeNode? child = this.children![index];
if (child is null)
Span<short> children = node.Children;
childIndex = children[index];
if (childIndex == -1)
{
// Create a new child node and store it in the array
child = new OctreeNode(level + 1, colorBits, octree);
this.children[index] = child;
childIndex = octree.AllocateNode();
if (childIndex == -1)
{
// No room in the tree, so increment the count and return.
Increment(nodeIndex, color, octree);
octree.TrackPrevious(nodeIndex);
return;
}
ref OctreeNode child = ref octree.Nodes[childIndex];
child.Initialize(level + 1, colorBits, octree, childIndex);
children[index] = childIndex;
}
// Add the color to the child node
child.AddColor(ref color, colorBits, level + 1, octree);
AddColor(childIndex, color, colorBits, level + 1, octree);
}
}
/// <summary>
/// Reduce this node by removing all of its children
/// Increment the color components of this node.
/// </summary>
/// <param name="nodeIndex">The node index.</param>
/// <param name="color">The color to increment by.</param>
/// <param name="octree">The parent octree.</param>
public static void Increment(int nodeIndex, Rgba32 color, Octree octree)
{
ref OctreeNode node = ref octree.Nodes[nodeIndex];
node.PixelCount++;
node.Red += color.R;
node.Green += color.G;
node.Blue += color.B;
node.Alpha += color.A;
}
/// <summary>
/// Reduce this node by ensuring its children are all reduced (i.e. leaves) and then merging their data.
/// </summary>
/// <returns>The number of leaves removed</returns>
public int Reduce()
/// <param name="octree">The parent octree.</param>
public void Reduce(Octree octree)
{
this.red = this.green = this.blue = 0;
int childNodes = 0;
// If already a leaf, do nothing.
if (this.Leaf)
{
return;
}
// Loop through all children and add their information to this node
for (int index = 0; index < 8; index++)
// Now merge the (presumably reduced) children.
int pixelCount = 0;
int sumRed = 0, sumGreen = 0, sumBlue = 0, sumAlpha = 0;
Span<short> children = this.Children;
for (int i = 0; i < children.Length; i++)
{
OctreeNode? child = this.children![index];
if (child != null)
short childIndex = children[i];
if (childIndex != -1)
{
this.red += child.red;
this.green += child.green;
this.blue += child.blue;
this.pixelCount += child.pixelCount;
++childNodes;
this.children[index] = null;
ref OctreeNode child = ref octree.Nodes[childIndex];
int pixels = child.PixelCount;
sumRed += child.Red;
sumGreen += child.Green;
sumBlue += child.Blue;
sumAlpha += child.Alpha;
pixelCount += pixels;
// Free the child immediately.
children[i] = -1;
octree.FreeNode(childIndex);
}
}
// Now change this to a leaf node
this.leaf = true;
if (pixelCount > 0)
{
this.Red = sumRed;
this.Green = sumGreen;
this.Blue = sumBlue;
this.Alpha = sumAlpha;
this.PixelCount = pixelCount;
}
else
{
this.Red = this.Green = this.Blue = this.Alpha = 0;
this.PixelCount = 0;
}
// Return the number of nodes to decrement the leaf count by
return childNodes - 1;
this.Leaf = true;
octree.Leaves++;
}
/// <summary>
/// Traverse the tree, building up the color palette
/// Traverse the tree to construct the palette.
/// </summary>
/// <param name="palette">The palette</param>
/// <param name="index">The current palette index</param>
[MethodImpl(InliningOptions.ColdPath)]
public void ConstructPalette(Span<TPixel> palette, ref int index)
/// <param name="octree">The parent octree.</param>
/// <param name="palette">The palette to construct.</param>
/// <param name="paletteIndex">The current palette index.</param>
public void ConstructPalette(Octree octree, Span<TPixel> palette, ref short paletteIndex)
{
if (this.leaf)
if (this.Leaf)
{
// Set the color of the palette entry
Vector3 vector = Vector3.Clamp(
new Vector3(this.red, this.green, this.blue) / this.pixelCount,
Vector3.Zero,
new Vector3(255));
Vector4 sum = new(this.Red, this.Green, this.Blue, this.Alpha);
Vector4 offset = new(this.PixelCount >> 1);
Vector4 vector = Vector4.Clamp(
(sum + offset) / this.PixelCount,
Vector4.Zero,
new Vector4(255));
if (vector.W < octree.transparencyThreshold255)
{
vector = Vector4.Zero;
}
palette[index] = TPixel.FromRgba32(new Rgba32((byte)vector.X, (byte)vector.Y, (byte)vector.Z));
palette[paletteIndex] = TPixel.FromRgba32(new Rgba32((byte)vector.X, (byte)vector.Y, (byte)vector.Z, (byte)vector.W));
// Consume the next palette index
this.paletteIndex = index++;
this.PaletteIndex = paletteIndex++;
}
else
{
// Loop through children looking for leaves
for (int i = 0; i < 8; i++)
Span<short> children = this.Children;
for (int i = 0; i < children.Length; i++)
{
this.children![i]?.ConstructPalette(palette, ref index);
int childIndex = children[i];
if (childIndex != -1)
{
octree.Nodes[childIndex].ConstructPalette(octree, palette, ref paletteIndex);
}
}
}
}
/// <summary>
/// Return the palette index for the passed color
/// Get the palette index for the passed color.
/// </summary>
/// <param name="pixel">The pixel data.</param>
/// <param name="level">The level.</param>
/// <returns>
/// The <see cref="int"/> representing the index of the pixel in the palette.
/// </returns>
[MethodImpl(InliningOptions.ColdPath)]
public int GetPaletteIndex(ref Rgba32 pixel, int level)
/// <param name="color">The color to get the palette index for.</param>
/// <param name="level">The level of the node.</param>
/// <param name="octree">The parent octree.</param>
public int GetPaletteIndex(Rgba32 color, int level, Octree octree)
{
if (this.leaf)
if (this.Leaf)
{
return this.paletteIndex;
return this.PaletteIndex;
}
int colorIndex = GetColorIndex(ref pixel, level);
OctreeNode? child = this.children![colorIndex];
int index = 0;
if (child != null)
int colorIndex = GetColorIndex(color, level);
Span<short> children = this.Children;
int childIndex = children[colorIndex];
if (childIndex != -1)
{
index = child.GetPaletteIndex(ref pixel, level + 1);
return octree.Nodes[childIndex].GetPaletteIndex(color, level + 1, octree);
}
else
for (int i = 0; i < children.Length; i++)
{
// Check other children.
for (int i = 0; i < this.children.Length; i++)
childIndex = children[i];
if (childIndex != -1)
{
child = this.children[i];
if (child != null)
int childPaletteIndex = octree.Nodes[childIndex].GetPaletteIndex(color, level + 1, octree);
if (childPaletteIndex != -1)
{
int childIndex = child.GetPaletteIndex(ref pixel, level + 1);
if (childIndex != 0)
{
return childIndex;
}
return childPaletteIndex;
}
}
}
return index;
return -1;
}
/// <summary>
/// Gets the color index at the given level.
/// </summary>
/// <param name="color">The color.</param>
/// <param name="level">The node level.</param>
/// <returns>The <see cref="int"/> index.</returns>
[MethodImpl(InliningOptions.ShortMethod)]
private static int GetColorIndex(ref Rgba32 color, int level)
/// <param name="color">The color to get the index for.</param>
/// <param name="level">The level to get the index at.</param>
public static int GetColorIndex(Rgba32 color, int level)
{
// Determine how many bits to shift based on the current tree level.
// At level 0, shift = 7; as level increases, the shift decreases.
int shift = 7 - level;
byte mask = (byte)(1 << shift);
return ((color.R & mask) >> shift)
| (((color.G & mask) >> shift) << 1)
| (((color.B & mask) >> shift) << 2);
}
// Compute the luminance of the RGB components using the BT.709 standard.
// This gives a measure of brightness for the color.
int luminance = ColorNumerics.Get8BitBT709Luminance(color.R, color.G, color.B);
/// <summary>
/// Increment the color count and add to the color information
/// </summary>
/// <param name="color">The pixel to add.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public void Increment(ref Rgba32 color)
{
this.pixelCount++;
this.red += color.R;
this.green += color.G;
this.blue += color.B;
// Define thresholds for determining when to include the alpha bit in the index.
// The thresholds are scaled according to the current level.
// 128 is the midpoint of the 8-bit range (0–255), so shifting it right by 'level'
// produces a threshold that scales with the color cube subdivision.
int darkThreshold = 128 >> level;
// The light threshold is set symmetrically: 255 minus the scaled midpoint.
int lightThreshold = 255 - (128 >> level);
// If the pixel is fully opaque and its brightness falls between the dark and light thresholds,
// ignore the alpha channel to maximize RGB resolution.
// Otherwise (if the pixel is dark, light, or semi-transparent), include the alpha bit
// to preserve any gradient that may be present.
if (color.A == 255 && luminance > darkThreshold && luminance < lightThreshold)
{
// Extract one bit each from R, G, and B channels and combine them into a 3-bit index.
int rBits = ((color.R & mask) >> shift) << 2;
int gBits = ((color.G & mask) >> shift) << 1;
int bBits = (color.B & mask) >> shift;
return rBits | gBits | bBits;
}
else
{
// Extract one bit from each channel including alpha (alpha becomes the most significant bit).
int aBits = ((color.A & mask) >> shift) << 3;
int rBits = ((color.R & mask) >> shift) << 2;
int gBits = ((color.G & mask) >> shift) << 1;
int bBits = (color.B & mask) >> shift;
return aBits | rBits | gBits | bBits;
}
}
}
}

23
src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer.cs

@ -11,7 +11,8 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
public class PaletteQuantizer : IQuantizer
{
private readonly ReadOnlyMemory<Color> colorPalette;
private readonly int transparentIndex;
private readonly int transparencyIndex;
private readonly Color transparentColor;
/// <summary>
/// Initializes a new instance of the <see cref="PaletteQuantizer"/> class.
@ -25,27 +26,33 @@ public class PaletteQuantizer : IQuantizer
/// <summary>
/// Initializes a new instance of the <see cref="PaletteQuantizer"/> class.
/// </summary>
/// <param name="palette">The color palette.</param>
/// <param name="palette">The color palette to use.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
public PaletteQuantizer(ReadOnlyMemory<Color> palette, QuantizerOptions options)
: this(palette, options, -1)
: this(palette, options, -1, default)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="PaletteQuantizer"/> class.
/// </summary>
/// <param name="palette">The color palette.</param>
/// <param name="palette">The color palette to use.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
/// <param name="transparentIndex">An explicit index at which to match transparent pixels.</param>
internal PaletteQuantizer(ReadOnlyMemory<Color> palette, QuantizerOptions options, int transparentIndex)
/// <param name="transparencyIndex">The index of the color in the palette that should be considered as transparent.</param>
/// <param name="transparentColor">The color that should be considered as transparent.</param>
internal PaletteQuantizer(
ReadOnlyMemory<Color> palette,
QuantizerOptions options,
int transparencyIndex,
Color transparentColor)
{
Guard.MustBeGreaterThan(palette.Length, 0, nameof(palette));
Guard.NotNull(options, nameof(options));
this.colorPalette = palette;
this.Options = options;
this.transparentIndex = transparentIndex;
this.transparencyIndex = transparencyIndex;
this.transparentColor = transparentColor;
}
/// <inheritdoc />
@ -66,6 +73,6 @@ public class PaletteQuantizer : IQuantizer
// treat the buffer as FILO.
TPixel[] palette = new TPixel[Math.Min(options.MaxColors, this.colorPalette.Length)];
Color.ToPixel(this.colorPalette.Span[..palette.Length], palette.AsSpan());
return new PaletteQuantizer<TPixel>(configuration, options, palette, this.transparentIndex);
return new PaletteQuantizer<TPixel>(configuration, options, palette, this.transparencyIndex, this.transparentColor.ToPixel<TPixel>());
}
}

63
src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs

@ -17,10 +17,12 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
"Design",
"CA1001:Types that own disposable fields should be disposable",
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
internal readonly struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
internal struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
private readonly EuclideanPixelMap<TPixel> pixelMap;
private readonly PixelMap<TPixel> pixelMap;
private int transparencyIndex;
private TPixel transparentColor;
/// <summary>
/// Initializes a new instance of the <see cref="PaletteQuantizer{TPixel}"/> struct.
@ -28,20 +30,37 @@ internal readonly struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
/// <param name="palette">The palette to use.</param>
/// <param name="transparentIndex">An explicit index at which to match transparent pixels.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public PaletteQuantizer(Configuration configuration, QuantizerOptions options, ReadOnlyMemory<TPixel> palette)
: this(configuration, options, palette, -1, default)
{
Guard.NotNull(configuration, nameof(configuration));
Guard.NotNull(options, nameof(options));
}
/// <summary>
/// Initializes a new instance of the <see cref="PaletteQuantizer{TPixel}"/> struct.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
/// <param name="palette">The palette to use.</param>
/// <param name="transparencyIndex">The index of the color in the palette that should be considered as transparent.</param>
/// <param name="transparentColor">The color that should be considered as transparent.</param>
public PaletteQuantizer(
Configuration configuration,
QuantizerOptions options,
ReadOnlyMemory<TPixel> palette,
int transparentIndex)
int transparencyIndex,
TPixel transparentColor)
{
Guard.NotNull(configuration, nameof(configuration));
Guard.NotNull(options, nameof(options));
this.Configuration = configuration;
this.Options = options;
this.pixelMap = new EuclideanPixelMap<TPixel>(configuration, palette, transparentIndex);
this.pixelMap = PixelMapFactory.Create(this.Configuration, palette, options.ColorMatchingMode);
this.transparencyIndex = transparencyIndex;
this.transparentColor = transparentColor;
}
/// <inheritdoc/>
@ -51,7 +70,13 @@ internal readonly struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
public QuantizerOptions Options { get; }
/// <inheritdoc/>
public ReadOnlyMemory<TPixel> Palette => this.pixelMap.Palette;
public readonly ReadOnlyMemory<TPixel> Palette => this.pixelMap.Palette;
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
{
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
@ -60,21 +85,23 @@ internal readonly struct PaletteQuantizer<TPixel> : IQuantizer<TPixel>
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public void AddPaletteColors(Buffer2DRegion<TPixel> pixelRegion)
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
{
}
if (this.transparencyIndex >= 0 && color.Equals(this.transparentColor))
{
match = this.transparentColor;
return (byte)this.transparencyIndex;
}
/// <summary>
/// Allows setting the transparent index after construction.
/// </summary>
/// <param name="index">An explicit index at which to match transparent pixels.</param>
public void SetTransparentIndex(int index) => this.pixelMap.SetTransparentIndex(index);
return (byte)this.pixelMap.GetClosestColor(color, out match);
}
/// <inheritdoc/>
[MethodImpl(InliningOptions.ShortMethod)]
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
=> (byte)this.pixelMap.GetClosestColor(color, out match);
public void SetTransparencyIndex(int transparencyIndex, TPixel transparentColor)
{
this.transparencyIndex = transparencyIndex;
this.transparentColor = transparentColor;
}
/// <inheritdoc/>
public void Dispose() => this.pixelMap.Dispose();
public readonly void Dispose() => this.pixelMap.Dispose();
}

19
src/ImageSharp/Processing/Processors/Quantization/QuantizerConstants.cs

@ -21,15 +21,30 @@ public static class QuantizerConstants
public const int MaxColors = 256;
/// <summary>
/// The minumim dithering scale used to adjust the amount of dither.
/// The minimum dithering scale used to adjust the amount of dither.
/// </summary>
public const float MinDitherScale = 0;
/// <summary>
/// The max dithering scale used to adjust the amount of dither.
/// The maximum dithering scale used to adjust the amount of dither.
/// </summary>
public const float MaxDitherScale = 1F;
/// <summary>
/// The default threshold at which to consider a pixel transparent.
/// </summary>
public const float DefaultTransparencyThreshold = 64 / 255F;
/// <summary>
/// The minimum threshold at which to consider a pixel transparent.
/// </summary>
public const float MinTransparencyThreshold = 0F;
/// <summary>
/// The maximum threshold at which to consider a pixel transparent.
/// </summary>
public const float MaxTransparencyThreshold = 1F;
/// <summary>
/// Gets the default dithering algorithm to use.
/// </summary>

53
src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs

@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Processing.Processors.Dithering;
namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
@ -8,10 +9,34 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// Defines options for quantization.
/// </summary>
public class QuantizerOptions
public class QuantizerOptions : IDeepCloneable<QuantizerOptions>
{
#pragma warning disable IDE0032 // Use auto property
private float ditherScale = QuantizerConstants.MaxDitherScale;
private int maxColors = QuantizerConstants.MaxColors;
private float threshold = QuantizerConstants.DefaultTransparencyThreshold;
#pragma warning restore IDE0032 // Use auto property
/// <summary>
/// Initializes a new instance of the <see cref="QuantizerOptions"/> class.
/// </summary>
public QuantizerOptions()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="QuantizerOptions"/> class.
/// </summary>
/// <param name="options">The options to clone.</param>
private QuantizerOptions(QuantizerOptions options)
{
this.Dither = options.Dither;
this.DitherScale = options.DitherScale;
this.MaxColors = options.MaxColors;
this.TransparencyThreshold = options.TransparencyThreshold;
this.ColorMatchingMode = options.ColorMatchingMode;
this.TransparentColorMode = options.TransparentColorMode;
}
/// <summary>
/// Gets or sets the algorithm to apply to the output image.
@ -38,4 +63,30 @@ public class QuantizerOptions
get => this.maxColors;
set => this.maxColors = Numerics.Clamp(value, QuantizerConstants.MinColors, QuantizerConstants.MaxColors);
}
/// <summary>
/// Gets or sets the color matching mode used for matching pixel values to palette colors.
/// Defaults to <see cref="ColorMatchingMode.Coarse"/>.
/// </summary>
public ColorMatchingMode ColorMatchingMode { get; set; } = ColorMatchingMode.Coarse;
/// <summary>
/// Gets or sets the threshold at which to consider a pixel transparent. Range 0..1.
/// Defaults to <see cref="QuantizerConstants.DefaultTransparencyThreshold"/>.
/// </summary>
public float TransparencyThreshold
{
get => this.threshold;
set => this.threshold = Numerics.Clamp(value, QuantizerConstants.MinTransparencyThreshold, QuantizerConstants.MaxTransparencyThreshold);
}
/// <summary>
/// Gets or sets the transparent color mode used for handling transparent colors
/// when not using thresholding.
/// Defaults to <see cref="TransparentColorMode.Preserve"/>.
/// </summary>
public TransparentColorMode TransparentColorMode { get; set; } = TransparentColorMode.Preserve;
/// <inheritdoc/>
public QuantizerOptions DeepClone() => new(this);
}

340
src/ImageSharp/Processing/Processors/Quantization/QuantizerUtilities.cs

@ -1,7 +1,11 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Buffers;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
@ -14,6 +18,130 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// </summary>
public static class QuantizerUtilities
{
/// <summary>
/// Performs a deep clone the <see cref="QuantizerOptions"/> instance and optionally mutates the clone.
/// </summary>
/// <param name="options">The <see cref="QuantizerOptions"/> instance to clone.</param>
/// <param name="mutate">An optional delegate to mutate the cloned instance.</param>
/// <returns>The cloned <see cref="QuantizerOptions"/> instance.</returns>
public static QuantizerOptions DeepClone(this QuantizerOptions options, Action<QuantizerOptions>? mutate)
{
QuantizerOptions clone = options.DeepClone();
mutate?.Invoke(clone);
return clone;
}
/// <summary>
/// Determines if transparent pixels can be replaced based on the specified color mode and pixel type.
/// </summary>
/// <typeparam name="TPixel">The type of the pixel.</typeparam>
/// <param name="threshold">The alpha threshold used to determine if a pixel is transparent.</param>
/// <returns>Returns true if transparent pixels can be replaced; otherwise, false.</returns>
public static bool ShouldReplacePixelsByAlphaThreshold<TPixel>(float threshold)
where TPixel : unmanaged, IPixel<TPixel>
=> threshold > 0 && TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Unassociated;
/// <summary>
/// Replaces pixels in a span with fully transparent pixels based on an alpha threshold.
/// </summary>
/// <param name="source">A span of color vectors that will be checked for transparency and potentially modified.</param>
/// <param name="threshold">The alpha threshold used to determine if a pixel is transparent.</param>
public static void ReplacePixelsByAlphaThreshold(Span<Vector4> source, float threshold)
{
if (Vector512.IsHardwareAccelerated && source.Length >= 4)
{
Vector512<float> threshold512 = Vector512.Create(threshold);
Span<Vector512<float>> source512 = MemoryMarshal.Cast<Vector4, Vector512<float>>(source);
for (int i = 0; i < source512.Length; i++)
{
ref Vector512<float> v = ref source512[i];
// Do `vector < threshold`
Vector512<float> mask = Vector512.LessThan(v, threshold512);
// Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise)
mask = Vector512.Shuffle(mask, Vector512.Create(3, 3, 3, 3, 7, 7, 7, 7, 11, 11, 11, 11, 15, 15, 15, 15));
// Use the mask to select the replacement vector
// (replacement & mask) | (v512 & ~mask)
v = Vector512.ConditionalSelect(mask, Vector512<float>.Zero, v);
}
int m = Numerics.Modulo4(source.Length);
if (m != 0)
{
for (int i = source.Length - m; i < source.Length; i++)
{
if (source[i].W < threshold)
{
source[i] = Vector4.Zero;
}
}
}
}
else if (Vector256.IsHardwareAccelerated && source.Length >= 2)
{
Vector256<float> threshold256 = Vector256.Create(threshold);
Span<Vector256<float>> source256 = MemoryMarshal.Cast<Vector4, Vector256<float>>(source);
for (int i = 0; i < source256.Length; i++)
{
ref Vector256<float> v = ref source256[i];
// Do `vector < threshold`
Vector256<float> mask = Vector256.LessThan(v, threshold256);
// Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise)
mask = Vector256.Shuffle(mask, Vector256.Create(3, 3, 3, 3, 7, 7, 7, 7));
// Use the mask to select the replacement vector
// (replacement & mask) | (v256 & ~mask)
v = Vector256.ConditionalSelect(mask, Vector256<float>.Zero, v);
}
int m = Numerics.Modulo2(source.Length);
if (m != 0)
{
for (int i = source.Length - m; i < source.Length; i++)
{
if (source[i].W < threshold)
{
source[i] = Vector4.Zero;
}
}
}
}
else if (Vector128.IsHardwareAccelerated)
{
Vector128<float> threshold128 = Vector128.Create(threshold);
for (int i = 0; i < source.Length; i++)
{
ref Vector4 v = ref source[i];
Vector128<float> v128 = v.AsVector128();
// Do `vector < threshold`
Vector128<float> mask = Vector128.LessThan(v128, threshold128);
// Replicate the result for W to all elements (is AllBitsSet if the W was less than threshold and Zero otherwise)
mask = Vector128.Shuffle(mask, Vector128.Create(3, 3, 3, 3));
// Use the mask to select the replacement vector
// (replacement & mask) | (v128 & ~mask)
v = Vector128.ConditionalSelect(mask, Vector128<float>.Zero, v128).AsVector4();
}
}
else
{
for (int i = 0; i < source.Length; i++)
{
if (source[i].W < threshold)
{
source[i] = Vector4.Zero;
}
}
}
}
/// <summary>
/// Helper method for throwing an exception when a frame quantizer palette has
/// been requested but not built yet.
@ -21,12 +149,13 @@ public static class QuantizerUtilities
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="palette">The frame quantizer palette.</param>
/// <exception cref="InvalidOperationException">
/// The palette has not been built via <see cref="IQuantizer{TPixel}.AddPaletteColors"/>
/// The palette has not been built via <see cref="IQuantizer{TPixel}.AddPaletteColors(in Buffer2DRegion{TPixel})"/>
/// </exception>
[MethodImpl(InliningOptions.ColdPath)]
public static void CheckPaletteState<TPixel>(in ReadOnlyMemory<TPixel> palette)
where TPixel : unmanaged, IPixel<TPixel>
{
if (palette.Equals(default))
if (palette.IsEmpty)
{
throw new InvalidOperationException("Frame Quantizer palette has not been built.");
}
@ -54,8 +183,7 @@ public static class QuantizerUtilities
Rectangle interest = Rectangle.Intersect(source.Bounds, bounds);
Buffer2DRegion<TPixel> region = source.PixelBuffer.GetRegion(interest);
// Collect the palette. Required before the second pass runs.
quantizer.AddPaletteColors(region);
quantizer.AddPaletteColors(in region);
return quantizer.QuantizeFrame(source, bounds);
}
@ -112,39 +240,10 @@ public static class QuantizerUtilities
IPixelSamplingStrategy pixelSamplingStrategy,
Image<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
=> quantizer.BuildPalette(source.Configuration, TransparentColorMode.Preserve, pixelSamplingStrategy, source);
/// <summary>
/// Adds colors to the quantized palette from the given pixel regions.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="mode">The transparent color mode.</param>
/// <param name="pixelSamplingStrategy">The pixel sampling strategy.</param>
/// <param name="source">The source image to sample from.</param>
public static void BuildPalette<TPixel>(
this IQuantizer<TPixel> quantizer,
Configuration configuration,
TransparentColorMode mode,
IPixelSamplingStrategy pixelSamplingStrategy,
Image<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(mode))
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
using Buffer2D<TPixel> clone = region.Buffer.CloneRegion(configuration, region.Rectangle);
quantizer.AddPaletteColors(clone.GetRegion());
}
}
else
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
quantizer.AddPaletteColors(region);
}
quantizer.AddPaletteColors(in region);
}
}
@ -160,43 +259,75 @@ public static class QuantizerUtilities
IPixelSamplingStrategy pixelSamplingStrategy,
ImageFrame<TPixel> source)
where TPixel : unmanaged, IPixel<TPixel>
=> quantizer.BuildPalette(source.Configuration, TransparentColorMode.Preserve, pixelSamplingStrategy, source);
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
{
quantizer.AddPaletteColors(in region);
}
}
/// <summary>
/// Adds colors to the quantized palette from the given pixel regions.
/// </summary>
/// <typeparam name="TPixel">The pixel format.</typeparam>
/// <param name="quantizer">The pixel specific quantizer.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="mode">The transparent color mode.</param>
/// <param name="pixelSamplingStrategy">The pixel sampling strategy.</param>
/// <param name="source">The source image frame to sample from.</param>
public static void BuildPalette<TPixel>(
this IQuantizer<TPixel> quantizer,
Configuration configuration,
TransparentColorMode mode,
IPixelSamplingStrategy pixelSamplingStrategy,
ImageFrame<TPixel> source)
internal static void AddPaletteColors<TFrameQuantizer, TPixel, TPixel2, TDelegate>(
ref TFrameQuantizer quantizer,
in Buffer2DRegion<TPixel> source,
in TDelegate rowDelegate)
where TFrameQuantizer : struct, IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
where TPixel2 : unmanaged, IPixel<TPixel2>
where TDelegate : struct, IQuantizingPixelRowDelegate<TPixel2>
{
if (EncodingUtilities.ShouldClearTransparentPixels<TPixel>(mode))
Configuration configuration = quantizer.Configuration;
float threshold = quantizer.Options.TransparencyThreshold;
TransparentColorMode mode = quantizer.Options.TransparentColorMode;
using IMemoryOwner<TPixel2> delegateRowOwner = configuration.MemoryAllocator.Allocate<TPixel2>(source.Width);
Span<TPixel2> delegateRow = delegateRowOwner.Memory.Span;
bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold<TPixel>(threshold);
bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(mode);
if (replaceByThreshold || replaceTransparent)
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
using IMemoryOwner<Vector4> vectorRowOwner = configuration.MemoryAllocator.Allocate<Vector4>(source.Width);
Span<Vector4> vectorRow = vectorRowOwner.Memory.Span;
if (replaceByThreshold)
{
using Buffer2D<TPixel> clone = region.Buffer.CloneRegion(configuration, region.Rectangle);
quantizer.AddPaletteColors(clone.GetRegion());
for (int y = 0; y < source.Height; y++)
{
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, threshold);
PixelOperations<TPixel2>.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale);
rowDelegate.Invoke(delegateRow, y);
}
}
else
{
for (int y = 0; y < source.Height; y++)
{
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow);
PixelOperations<TPixel2>.Instance.FromVector4Destructive(configuration, vectorRow, delegateRow, PixelConversionModifiers.Scale);
rowDelegate.Invoke(delegateRow, y);
}
}
}
else
{
foreach (Buffer2DRegion<TPixel> region in pixelSamplingStrategy.EnumeratePixelRegions(source))
for (int y = 0; y < source.Height; y++)
{
quantizer.AddPaletteColors(region);
Span<TPixel> sourceRow = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.To(configuration, sourceRow, delegateRow);
rowDelegate.Invoke(delegateRow, y);
}
}
}
[MethodImpl(InliningOptions.ShortMethod)]
private static void SecondPass<TFrameQuantizer, TPixel>(
ref TFrameQuantizer quantizer,
ImageFrame<TPixel> source,
@ -205,28 +336,111 @@ public static class QuantizerUtilities
where TFrameQuantizer : struct, IQuantizer<TPixel>
where TPixel : unmanaged, IPixel<TPixel>
{
float threshold = quantizer.Options.TransparencyThreshold;
bool replaceByThreshold = ShouldReplacePixelsByAlphaThreshold<TPixel>(threshold);
TransparentColorMode mode = quantizer.Options.TransparentColorMode;
bool replaceTransparent = EncodingUtilities.ShouldReplaceTransparentPixels<TPixel>(mode);
IDither? dither = quantizer.Options.Dither;
Buffer2D<TPixel> sourceBuffer = source.PixelBuffer;
Buffer2DRegion<TPixel> region = sourceBuffer.GetRegion(bounds);
Configuration configuration = quantizer.Configuration;
using IMemoryOwner<Vector4> vectorOwner = configuration.MemoryAllocator.Allocate<Vector4>(region.Width);
Span<Vector4> vectorRow = vectorOwner.Memory.Span;
if (dither is null)
{
int offsetY = bounds.Top;
int offsetX = bounds.Left;
using IMemoryOwner<TPixel> quantizingRowOwner = configuration.MemoryAllocator.Allocate<TPixel>(region.Width);
Span<TPixel> quantizingRow = quantizingRowOwner.Memory.Span;
// This is NOT a clone so we DO NOT write back to the source.
if (replaceByThreshold || replaceTransparent)
{
if (replaceByThreshold)
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, threshold);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale);
Span<byte> destinationRow = destination.GetWritablePixelRowSpanUnsafe(y);
for (int x = 0; x < destinationRow.Length; x++)
{
destinationRow[x] = quantizer.GetQuantizedColor(quantizingRow[x], out TPixel _);
}
}
}
else
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, quantizingRow, PixelConversionModifiers.Scale);
Span<byte> destinationRow = destination.GetWritablePixelRowSpanUnsafe(y);
for (int x = 0; x < destinationRow.Length; x++)
{
destinationRow[x] = quantizer.GetQuantizedColor(quantizingRow[x], out TPixel _);
}
}
}
for (int y = 0; y < destination.Height; y++)
return;
}
for (int y = 0; y < region.Height; y++)
{
ReadOnlySpan<TPixel> sourceRow = sourceBuffer.DangerousGetRowSpan(y + offsetY);
ReadOnlySpan<TPixel> sourceRow = region.DangerousGetRowSpan(y);
Span<byte> destinationRow = destination.GetWritablePixelRowSpanUnsafe(y);
for (int x = 0; x < destinationRow.Length; x++)
{
destinationRow[x] = Unsafe.AsRef(in quantizer).GetQuantizedColor(sourceRow[x + offsetX], out TPixel _);
destinationRow[x] = quantizer.GetQuantizedColor(sourceRow[x], out TPixel _);
}
}
return;
}
// This is a clone so we write back to the source.
if (replaceByThreshold || replaceTransparent)
{
if (replaceByThreshold)
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
ReplacePixelsByAlphaThreshold(vectorRow, threshold);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale);
}
}
else
{
for (int y = 0; y < region.Height; y++)
{
Span<TPixel> sourceRow = region.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToVector4(configuration, sourceRow, vectorRow, PixelConversionModifiers.Scale);
EncodingUtilities.ReplaceTransparentPixels(vectorRow);
PixelOperations<TPixel>.Instance.FromVector4Destructive(configuration, vectorRow, sourceRow, PixelConversionModifiers.Scale);
}
}
}
dither.ApplyQuantizationDither(ref quantizer, source, destination, bounds);
}
}

2
src/ImageSharp/Processing/Processors/Quantization/WebSafePaletteQuantizer.cs

@ -6,7 +6,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// <summary>
/// A palette quantizer consisting of web safe colors as defined in the CSS Color Module Level 4.
/// </summary>
public class WebSafePaletteQuantizer : PaletteQuantizer
public sealed class WebSafePaletteQuantizer : PaletteQuantizer
{
/// <summary>
/// Initializes a new instance of the <see cref="WebSafePaletteQuantizer" /> class.

2
src/ImageSharp/Processing/Processors/Quantization/WernerPaletteQuantizer.cs

@ -7,7 +7,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
/// A palette quantizer consisting of colors as defined in the original second edition of Werner’s Nomenclature of Colours 1821.
/// The hex codes were collected and defined by Nicholas Rougeux <see href="https://www.c82.net/werner"/>
/// </summary>
public class WernerPaletteQuantizer : PaletteQuantizer
public sealed class WernerPaletteQuantizer : PaletteQuantizer
{
/// <summary>
/// Initializes a new instance of the <see cref="WernerPaletteQuantizer" /> class.

138
src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs

@ -43,30 +43,10 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
// The following two variables determine the amount of bits to preserve when calculating the histogram.
// Reducing the value of these numbers the granularity of the color maps produced, making it much faster
// and using much less memory but potentially less accurate. Current results are very good though!
/// <summary>
/// The index bits. 6 in original code.
/// </summary>
private const int IndexBits = 5;
/// <summary>
/// The index alpha bits. 3 in original code.
/// </summary>
private const int IndexAlphaBits = 5;
/// <summary>
/// The index count.
/// </summary>
private const int IndexCount = (1 << IndexBits) + 1;
/// <summary>
/// The index alpha count.
/// </summary>
private const int IndexAlphaCount = (1 << IndexAlphaBits) + 1;
/// <summary>
/// The table length. Now 1185921. originally 2471625.
/// </summary>
private const int TableLength = IndexCount * IndexCount * IndexCount * IndexAlphaCount;
private readonly IMemoryOwner<Moment> momentsOwner;
@ -75,14 +55,14 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
private ReadOnlyMemory<TPixel> palette;
private int maxColors;
private readonly Box[] colorCube;
private EuclideanPixelMap<TPixel>? pixelMap;
private PixelMap<TPixel>? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
/// <summary>
/// Initializes a new instance of the <see cref="WuQuantizer{TPixel}"/> struct.
/// </summary>
/// <param name="configuration">The configuration which allows altering default behaviour or extending the library.</param>
/// <param name="configuration">The configuration which allows altering default behavior or extending the library.</param>
/// <param name="options">The quantizer options defining quantization rules.</param>
[MethodImpl(InliningOptions.ShortMethod)]
public WuQuantizer(Configuration configuration, QuantizerOptions options)
@ -101,7 +81,7 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
this.isDisposed = false;
this.pixelMap = default;
this.palette = default;
this.isDithering = this.isDithering = this.Options.Dither is not null;
this.isDithering = this.Options.Dither is not null;
}
/// <inheritdoc/>
@ -111,57 +91,71 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
public QuantizerOptions Options { get; }
/// <inheritdoc/>
public readonly ReadOnlyMemory<TPixel> Palette
public ReadOnlyMemory<TPixel> Palette
{
get
{
QuantizerUtilities.CheckPaletteState(in this.palette);
if (this.palette.IsEmpty)
{
this.ResolvePalette();
QuantizerUtilities.CheckPaletteState(in this.palette);
}
return this.palette;
}
}
/// <inheritdoc/>
public void AddPaletteColors(Buffer2DRegion<TPixel> pixelRegion)
public readonly void AddPaletteColors(in Buffer2DRegion<TPixel> pixelRegion)
{
PixelRowDelegate pixelRowDelegate = new(ref Unsafe.AsRef(in this));
QuantizerUtilities.AddPaletteColors<WuQuantizer<TPixel>, TPixel, Rgba32, PixelRowDelegate>(
ref Unsafe.AsRef(in this),
in pixelRegion,
in pixelRowDelegate);
}
/// <summary>
/// Once all histogram data has been accumulated, this method computes the moments,
/// splits the color cube, and resolves the final palette from the accumulated histogram.
/// </summary>
private void ResolvePalette()
{
// TODO: Something is destroying the existing palette when adding new colors.
// When the QuantizingImageEncoder.PixelSamplingStrategy is DefaultPixelSamplingStrategy
// this leads to performance issues + the palette is not preserved.
// https://github.com/SixLabors/ImageSharp/issues/2498
this.Build3DHistogram(pixelRegion);
// Calculate the cumulative moments from the accumulated histogram.
this.Get3DMoments(this.memoryAllocator);
// Partition the histogram into color cubes.
this.BuildCube();
// Slice again since maxColors has been updated since the buffer was created.
// Compute the palette colors from the resolved cubes.
Span<TPixel> paletteSpan = this.paletteOwner.GetSpan()[..this.maxColors];
ReadOnlySpan<Moment> momentsSpan = this.momentsOwner.GetSpan();
float transparencyThreshold = this.Options.TransparencyThreshold;
for (int k = 0; k < paletteSpan.Length; k++)
{
this.Mark(ref this.colorCube[k], (byte)k);
Moment moment = Volume(ref this.colorCube[k], momentsSpan);
if (moment.Weight > 0)
{
paletteSpan[k] = TPixel.FromScaledVector4(moment.Normalize());
Vector4 normalized = moment.Normalize();
if (normalized.W < transparencyThreshold)
{
normalized = Vector4.Zero;
}
paletteSpan[k] = TPixel.FromScaledVector4(normalized);
}
}
ReadOnlyMemory<TPixel> result = this.paletteOwner.Memory[..paletteSpan.Length];
if (this.isDithering)
// Update the palette to the new computed colors.
this.palette = this.paletteOwner.Memory[..paletteSpan.Length];
// Create the pixel map if dithering is enabled.
if (this.isDithering && this.pixelMap is null)
{
// When called multiple times by QuantizerUtilities.BuildPalette
// this prevents memory churn caused by reallocation.
if (this.pixelMap is null)
{
this.pixelMap = new EuclideanPixelMap<TPixel>(this.Configuration, result);
}
else
{
this.pixelMap.Clear(result);
}
this.pixelMap = PixelMapFactory.Create(this.Configuration, this.palette, this.Options.ColorMatchingMode);
}
this.palette = result;
}
/// <inheritdoc/>
@ -172,6 +166,9 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
/// <inheritdoc/>
public readonly byte GetQuantizedColor(TPixel color, out TPixel match)
{
// Due to the addition of new colors by dithering that are not part of the original histogram,
// the color cube 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);
@ -188,7 +185,7 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
ReadOnlySpan<byte> tagSpan = this.tagsOwner.GetSpan();
byte index = tagSpan[GetPaletteIndex(r + 1, g + 1, b + 1, a + 1)];
ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.palette.Span);
match = Unsafe.Add(ref paletteRef, index);
match = Unsafe.Add(ref paletteRef, (nuint)index);
return index;
}
@ -359,31 +356,19 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
/// <summary>
/// Builds a 3-D color histogram of <c>counts, r/g/b, c^2</c>.
/// </summary>
/// <param name="source">The source pixel data.</param>
private readonly void Build3DHistogram(Buffer2DRegion<TPixel> source)
/// <param name="pixels">The source pixel data.</param>
private readonly void Build3DHistogram(ReadOnlySpan<Rgba32> pixels)
{
Span<Moment> momentSpan = this.momentsOwner.GetSpan();
// Build up the 3-D color histogram
using IMemoryOwner<Rgba32> buffer = this.memoryAllocator.Allocate<Rgba32>(source.Width);
Span<Rgba32> bufferSpan = buffer.GetSpan();
for (int y = 0; y < source.Height; y++)
Span<Moment> moments = this.momentsOwner.GetSpan();
for (int x = 0; x < pixels.Length; x++)
{
Span<TPixel> row = source.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.ToRgba32(this.Configuration, row, bufferSpan);
for (int x = 0; x < bufferSpan.Length; x++)
{
Rgba32 rgba = bufferSpan[x];
int r = (rgba.R >> (8 - IndexBits)) + 1;
int g = (rgba.G >> (8 - IndexBits)) + 1;
int b = (rgba.B >> (8 - IndexBits)) + 1;
int a = (rgba.A >> (8 - IndexAlphaBits)) + 1;
Rgba32 rgba = pixels[x];
int r = (rgba.R >> (8 - IndexBits)) + 1;
int g = (rgba.G >> (8 - IndexBits)) + 1;
int b = (rgba.B >> (8 - IndexBits)) + 1;
int a = (rgba.A >> (8 - IndexAlphaBits)) + 1;
momentSpan[GetPaletteIndex(r, g, b, a)] += rgba;
}
moments[GetPaletteIndex(r, g, b, a)] += rgba;
}
}
@ -895,4 +880,13 @@ internal struct WuQuantizer<TPixel> : IQuantizer<TPixel>
return hash.ToHashCode();
}
}
private readonly struct PixelRowDelegate : IQuantizingPixelRowDelegate<Rgba32>
{
private readonly WuQuantizer<TPixel> quantizer;
public PixelRowDelegate(ref WuQuantizer<TPixel> quantizer) => this.quantizer = quantizer;
public void Invoke(ReadOnlySpan<Rgba32> row, int rowIndex) => this.quantizer.Build3DHistogram(row);
}
}

15
src/ImageSharp/Processing/Processors/Transforms/TransformProcessor{TPixel}.cs

@ -3,6 +3,7 @@
using SixLabors.ImageSharp.PixelFormats;
// TODO: DO we need this class?
namespace SixLabors.ImageSharp.Processing.Processors.Transforms;
/// <summary>
@ -22,18 +23,4 @@ internal abstract class TransformProcessor<TPixel> : CloningImageProcessor<TPixe
: base(configuration, source, sourceRectangle)
{
}
/// <inheritdoc/>
protected override void AfterFrameApply(ImageFrame<TPixel> source, ImageFrame<TPixel> destination)
{
base.AfterFrameApply(source, destination);
destination.Metadata.AfterFrameApply(source, destination);
}
/// <inheritdoc/>
protected override void AfterImageApply(Image<TPixel> destination)
{
base.AfterImageApply(destination);
destination.Metadata.AfterImageApply(destination);
}
}

59
tests/ImageSharp.Benchmarks/Codecs/Gif/DecodeEncodeGif.cs

@ -0,0 +1,59 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System.Drawing.Imaging;
using BenchmarkDotNet.Attributes;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using SixLabors.ImageSharp.Tests;
using SDImage = System.Drawing.Image;
namespace SixLabors.ImageSharp.Benchmarks.Codecs;
public abstract class DecodeEncodeGif
{
private MemoryStream outputStream;
protected abstract GifEncoder Encoder { get; }
[Params(TestImages.Gif.Leo, TestImages.Gif.Cheers)]
public string TestImage { get; set; }
private string TestImageFullPath => Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, this.TestImage);
[GlobalSetup]
public void Setup() => this.outputStream = new MemoryStream();
[GlobalCleanup]
public void Cleanup() => this.outputStream.Close();
[Benchmark(Baseline = true)]
public void SystemDrawing()
{
this.outputStream.Position = 0;
using SDImage image = SDImage.FromFile(this.TestImageFullPath);
image.Save(this.outputStream, ImageFormat.Gif);
}
[Benchmark]
public void ImageSharp()
{
this.outputStream.Position = 0;
using Image image = Image.Load(this.TestImageFullPath);
image.SaveAsGif(this.outputStream, this.Encoder);
}
}
public class DecodeEncodeGif_DefaultEncoder : DecodeEncodeGif
{
protected override GifEncoder Encoder => new();
}
public class DecodeEncodeGif_CoarsePaletteEncoder : DecodeEncodeGif
{
protected override GifEncoder Encoder => new()
{
Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4, ColorMatchingMode = ColorMatchingMode.Coarse })
};
}

42
tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs

@ -3,6 +3,7 @@
using System.Drawing.Imaging;
using BenchmarkDotNet.Attributes;
using ImageMagick;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
@ -12,21 +13,17 @@ using SDImage = System.Drawing.Image;
namespace SixLabors.ImageSharp.Benchmarks.Codecs;
[Config(typeof(Config.Short))]
public class EncodeGif
public abstract class EncodeGif
{
// System.Drawing needs this.
private FileStream bmpStream;
private SDImage bmpDrawing;
private Image<Rgba32> bmpCore;
private MagickImageCollection magickImage;
// Try to get as close to System.Drawing's output as possible
private readonly GifEncoder encoder = new()
{
Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4 })
};
protected abstract GifEncoder Encoder { get; }
[Params(TestImages.Bmp.Car, TestImages.Png.Rgb48Bpp)]
[Params(TestImages.Gif.Leo, TestImages.Gif.Cheers)]
public string TestImage { get; set; }
[GlobalSetup]
@ -34,10 +31,14 @@ public class EncodeGif
{
if (this.bmpStream == null)
{
this.bmpStream = File.OpenRead(Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, this.TestImage));
string filePath = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, this.TestImage);
this.bmpStream = File.OpenRead(filePath);
this.bmpCore = Image.Load<Rgba32>(this.bmpStream);
this.bmpStream.Position = 0;
this.bmpDrawing = SDImage.FromStream(this.bmpStream);
this.bmpStream.Position = 0;
this.magickImage = new MagickImageCollection(this.bmpStream);
}
}
@ -48,6 +49,7 @@ public class EncodeGif
this.bmpStream = null;
this.bmpCore.Dispose();
this.bmpDrawing.Dispose();
this.magickImage.Dispose();
}
[Benchmark(Baseline = true, Description = "System.Drawing Gif")]
@ -61,6 +63,26 @@ public class EncodeGif
public void GifImageSharp()
{
using MemoryStream memoryStream = new();
this.bmpCore.SaveAsGif(memoryStream, this.encoder);
this.bmpCore.SaveAsGif(memoryStream, this.Encoder);
}
[Benchmark(Description = "Magick.NET Gif")]
public void GifMagickNet()
{
using MemoryStream ms = new();
this.magickImage.Write(ms, MagickFormat.Gif);
}
}
public class EncodeGif_DefaultEncoder : EncodeGif
{
protected override GifEncoder Encoder => new();
}
public class EncodeGif_CoarsePaletteEncoder : EncodeGif
{
protected override GifEncoder Encoder => new()
{
Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4, ColorMatchingMode = ColorMatchingMode.Coarse })
};
}

35
tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs

@ -34,6 +34,41 @@ public class GifDecoderTests
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
[Theory]
[WithFile(TestImages.Gif.AnimatedLoop, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.AnimatedLoopInterlaced, PixelTypes.Rgba32)]
public void Decode_Animated<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage();
image.DebugSaveMultiFrame(provider);
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
[Theory]
[WithFile(TestImages.Gif.AnimatedTransparentNoRestore, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.AnimatedTransparentRestorePrevious, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.AnimatedTransparentLoop, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.AnimatedTransparentFirstFrameRestorePrev, PixelTypes.Rgba32)]
public void Decode_Animated_WithTransparency<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage();
image.DebugSaveMultiFrame(provider);
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
[Theory]
[WithFile(TestImages.Gif.StaticNontransparent, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.StaticTransparent, PixelTypes.Rgba32)]
public void Decode_Static_No_Animation<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage();
image.DebugSave(provider);
image.CompareFirstFrameToReferenceOutput(ImageComparer.Exact, provider);
}
[Theory]
[WithFile(TestImages.Gif.Issues.Issue2450_A, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.Issues.Issue2450_B, PixelTypes.Rgba32)]

19
tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs

@ -56,7 +56,7 @@ public class GifEncoderTests
{
// Use the palette quantizer without dithering to ensure results
// are consistent
Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = null })
Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = null, TransparencyThreshold = 0 })
};
// Always save as we need to compare the encoded output.
@ -419,4 +419,21 @@ public class GifEncoderTests
}
});
}
[Theory]
[WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)]
public void GifEncoder_CanDecode_AndEncode_Issue2866<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage();
// Save the image for visual inspection.
provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated");
// Now compare the debug output with the reference output.
// We do this because the gif encoding is lossy and encoding will lead to differences in the 10s of percent.
// From the unencoded image, we can see that the image is visually the same.
static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them.
image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate);
}
}

5
tests/ImageSharp.Tests/Formats/Icon/Cur/CurEncoderTests.cs

@ -4,6 +4,7 @@
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Cur;
using SixLabors.ImageSharp.Formats.Ico;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using static SixLabors.ImageSharp.Tests.TestImages.Cur;
@ -49,8 +50,8 @@ public class CurEncoderTests
using Image<TPixel> encoded = Image.Load<TPixel>(memStream);
encoded.DebugSaveMultiFrame(provider);
// Despite preservation of the palette. The process can still be lossy
encoded.CompareToOriginalMultiFrame(provider, ImageComparer.TolerantPercentage(.23f), IcoDecoder.Instance);
// Color palettes are not preserved when transcoding.
encoded.CompareToOriginalMultiFrame(provider, ImageComparer.TolerantPercentage(.05F), IcoDecoder.Instance);
for (int i = 0; i < image.Frames.Count; i++)
{

63
tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs

@ -407,6 +407,7 @@ public partial class PngEncoderTests
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
[WithFile(TestImages.Png.DefaultNotAnimated, PixelTypes.Rgba32)]
[WithFile(TestImages.Png.FrameOffset, PixelTypes.Rgba32)]
[WithFile(TestImages.Png.Issue2882, PixelTypes.Rgba32)]
public void Encode_APng<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
@ -419,8 +420,8 @@ public partial class PngEncoderTests
using Image<Rgba32> output = Image.Load<Rgba32>(memStream);
// some loss from original, due to compositing
ImageComparer.TolerantPercentage(0.01f).VerifySimilarity(output, image);
// Some loss from original, due to palette matching accuracy.
ImageComparer.TolerantPercentage(0.172F).VerifySimilarity(output, image);
Assert.Equal(image.Frames.Count, output.Frames.Count);
@ -443,6 +444,7 @@ public partial class PngEncoderTests
[Theory]
[WithFile(TestImages.Gif.Leo, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)]
public void Encode_AnimatedFormatTransform_FromGif<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
@ -453,26 +455,30 @@ public partial class PngEncoderTests
using Image<TPixel> image = provider.GetImage(GifDecoder.Instance);
// Save the image for visual inspection.
provider.Utility.SaveTestOutputFile(image, "png", PngEncoder, "animated");
// Now compare the debug output with the reference output.
// We do this because the transcoding encoding is lossy and encoding will lead to differences.
// From the unencoded image, we can see that the image is visually the same.
static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them.
image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "png", encoder: PngEncoder, predicate: Predicate);
// Now save the image and load it again to compare the metadata.
using MemoryStream memStream = new();
image.Save(memStream, PngEncoder);
memStream.Position = 0;
using Image<TPixel> output = Image.Load<TPixel>(memStream);
// TODO: Find a better way to compare.
// The image has been visually checked but the quantization pattern used in the png encoder
// means we cannot use an exact comparison nor replicate using the quantizing processor.
ImageComparer.TolerantPercentage(0.613f).VerifySimilarity(output, image);
using Image<TPixel> encoded = Image.Load<TPixel>(memStream);
GifMetadata gif = image.Metadata.GetGifMetadata();
PngMetadata png = output.Metadata.GetPngMetadata();
PngMetadata png = encoded.Metadata.GetPngMetadata();
Assert.Equal(gif.RepeatCount, png.RepeatCount);
for (int i = 0; i < image.Frames.Count; i++)
{
GifFrameMetadata gifF = image.Frames[i].Metadata.GetGifMetadata();
PngFrameMetadata pngF = output.Frames[i].Metadata.GetPngMetadata();
PngFrameMetadata pngF = encoded.Frames[i].Metadata.GetPngMetadata();
Assert.Equal(gifF.FrameDelay, (int)(pngF.FrameDelay.ToDouble() * 100));
@ -641,7 +647,7 @@ public partial class PngEncoderTests
encoded.CompareToReferenceOutput(ImageComparer.Exact, provider);
}
// https://github.com/SixLabors/ImageSharp/issues/2469
// https://github.com/SixLabors/ImageSharp/issues/2668
[Theory]
[WithFile(TestImages.Png.Issue2668, PixelTypes.Rgba32)]
public void Issue2668_Quantized_Encode_Alpha<TPixel>(TestImageProvider<TPixel> provider)
@ -657,6 +663,39 @@ public partial class PngEncoderTests
encoded.CompareToReferenceOutput(ImageComparer.Exact, provider);
}
[Fact]
public void Issue_2862()
{
// Create a grayscale palette (or any other palette with colors that are very close to each other):
Rgba32[] palette = [.. Enumerable.Range(0, 256).Select(i => new Rgba32((byte)i, (byte)i, (byte)i))];
using Image<Rgba32> image = new(254, 4);
for (int y = 0; y < image.Height; y++)
{
for (int x = 0; x < image.Width; x++)
{
image[x, y] = palette[x];
}
}
PaletteQuantizer quantizer = new(
palette.Select(Color.FromPixel).ToArray(),
new QuantizerOptions() { ColorMatchingMode = ColorMatchingMode.Hybrid });
using MemoryStream ms = new();
image.Save(ms, new PngEncoder
{
ColorType = PngColorType.Palette,
BitDepth = PngBitDepth.Bit8,
Quantizer = quantizer
});
ms.Position = 0;
using Image<Rgba32> encoded = Image.Load<Rgba32>(ms);
ImageComparer.Exact.VerifySimilarity(image, encoded);
}
private static void TestPngEncoderCore<TPixel>(
TestImageProvider<TPixel> provider,
PngColorType pngColorType,

4
tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs

@ -106,7 +106,7 @@ public class WebpCommonUtilsTests
174, 183, 189, 255,
148, 158, 158, 255,
};
Span<Bgra32> row = MemoryMarshal.Cast<byte, Bgra32>((Span<byte>)rowBytes);
ReadOnlySpan<Bgra32> row = MemoryMarshal.Cast<byte, Bgra32>(rowBytes);
bool noneOpaque;
for (int length = 8; length < row.Length; length += 8)
@ -188,7 +188,7 @@ public class WebpCommonUtilsTests
174, 183, 189, 255,
148, 158, 158, 255,
};
Span<Bgra32> row = MemoryMarshal.Cast<byte, Bgra32>((Span<byte>)rowBytes);
ReadOnlySpan<Bgra32> row = MemoryMarshal.Cast<byte, Bgra32>(rowBytes);
bool noneOpaque;
for (int length = 8; length < row.Length; length += 8)

16
tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs

@ -450,6 +450,22 @@ public class WebpDecoderTests
image.CompareToOriginal(provider, ReferenceDecoder);
}
// https://github.com/SixLabors/ImageSharp/issues/2866
[Theory]
[WithFile(Lossy.Issue2866, PixelTypes.Rgba32)]
public void WebpDecoder_CanDecode_Issue2866<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
// Web
using Image<TPixel> image = provider.GetImage(
WebpDecoder.Instance,
new WebpDecoderOptions() { BackgroundColorHandling = BackgroundColorHandling.Ignore });
// We can't use the reference decoder here.
// It creates frames of different size without blending the frames.
image.DebugSave(provider, extension: "webp", encoder: new WebpEncoder());
}
[Theory]
[WithFile(Lossless.LossLessCorruptImage3, PixelTypes.Rgba32)]
public void WebpDecoder_ThrowImageFormatException_OnInvalidImages<TPixel>(TestImageProvider<TPixel> provider)

59
tests/ImageSharp.Tests/Formats/WebP/WebpEncoderTests.cs

@ -8,6 +8,8 @@ using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Metadata;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
using SixLabors.ImageSharp.Tests.TestUtilities;
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs;
@ -110,6 +112,63 @@ public class WebpEncoderTests
}
}
[Theory]
// [WithFile(AlphaBlend, PixelTypes.Rgba32)]
// [WithFile(AlphaBlend2, PixelTypes.Rgba32)]
[WithFile(AlphaBlend3, PixelTypes.Rgba32)]
// [WithFile(AlphaBlend4, PixelTypes.Rgba32)]
public void Encode_AlphaBlended<TPixel>(TestImageProvider<TPixel> provider)
where TPixel : unmanaged, IPixel<TPixel>
{
using Image<TPixel> image = provider.GetImage();
WebpEncoder encoder = new()
{
FileFormat = WebpFileFormatType.Lossless
};
QuantizerOptions options = new()
{
TransparencyThreshold = 128 / 255F
};
// First save as gif to gif using different quantizers with default options.
// Alpha thresholding is 64/255F.
GifEncoder gifEncoder = new()
{
Quantizer = new OctreeQuantizer(options)
};
provider.Utility.SaveTestOutputFile(image, "gif", gifEncoder, "octree");
gifEncoder = new GifEncoder()
{
Quantizer = new WuQuantizer(options)
};
provider.Utility.SaveTestOutputFile(image, "gif", gifEncoder, "wu");
// Now clone and quantize the image using the same quantizers without alpha thresholding and save as webp.
options = new()
{
TransparencyThreshold = 0
};
using Image<TPixel> cloned1 = image.Clone();
cloned1.Mutate(c => c.Quantize(new OctreeQuantizer(options)));
provider.Utility.SaveTestOutputFile(cloned1, "webp", encoder, "octree");
using Image<TPixel> cloned2 = image.Clone();
cloned2.Mutate(c => c.Quantize(new WuQuantizer(options)));
provider.Utility.SaveTestOutputFile(cloned2, "webp", encoder, "wu");
// Now blend the images with a blue background and save as webp.
using Image<Rgba32> background1 = new(image.Width, image.Height, Color.White.ToPixel<Rgba32>());
background1.Mutate(c => c.DrawImage(cloned1, 1));
provider.Utility.SaveTestOutputFile(background1, "webp", encoder, "octree-blended");
using Image<Rgba32> background2 = new(image.Width, image.Height, Color.White.ToPixel<Rgba32>());
background2.Mutate(c => c.DrawImage(cloned2, 1));
provider.Utility.SaveTestOutputFile(background2, "webp", encoder, "wu-blended");
}
[Theory]
[WithFile(TestImages.Png.APng, PixelTypes.Rgba32)]
public void Encode_AnimatedFormatTransform_FromPng<TPixel>(TestImageProvider<TPixel> provider)

4
tests/ImageSharp.Tests/Quantization/WuQuantizerTests.cs

@ -79,7 +79,7 @@ public class WuQuantizerTests
}
Configuration config = Configuration.Default;
WuQuantizer quantizer = new(new QuantizerOptions { Dither = null });
WuQuantizer quantizer = new(new QuantizerOptions { Dither = null, TransparencyThreshold = 0 });
ImageFrame<Rgba32> frame = image.Frames.RootFrame;
@ -152,7 +152,7 @@ public class WuQuantizerTests
}
Configuration config = Configuration.Default;
WuQuantizer quantizer = new(new QuantizerOptions { Dither = null });
WuQuantizer quantizer = new(new QuantizerOptions { Dither = null, TransparencyThreshold = 0 });
ImageFrame<Rgba32> frame = image.Frames.RootFrame;
using (IQuantizer<Rgba32> frameQuantizer = quantizer.CreatePixelSpecificQuantizer<Rgba32>(config))

33
tests/ImageSharp.Tests/TestImages.cs

@ -76,6 +76,7 @@ public static class TestImages
public const string FrameOffset = "Png/animated/frame-offset.png";
public const string DefaultNotAnimated = "Png/animated/default-not-animated.png";
public const string Issue2666 = "Png/issues/Issue_2666.png";
public const string Issue2882 = "Png/issues/Issue_2882.png";
// Filtered test images from http://www.schaik.com/pngsuite/pngsuite_fil_png.html
public const string Filter0 = "Png/filter0.png";
@ -520,6 +521,31 @@ public static class TestImages
public const string Bit18RGBCube = "Gif/18-bit_RGB_Cube.gif";
public const string Global256NoTrans = "Gif/global-256-no-trans.gif";
// Test images from: https://github.com/peterdn/gif-test-suite.git
// Animated gif with 4 frames, looping forever, no transparency.
public const string AnimatedLoop = "Gif/animated_loop.gif";
// Animated gif with 4 frames, interlaced, looping forever, no transparency.
public const string AnimatedLoopInterlaced = "Gif/animated_loop_interlaced.gif";
// Transparent gif with 4 frames, loops forever.
public const string AnimatedTransparentLoop = "Gif/animated_transparent_loop.gif";
// Transparent gif with 4 frames, loops forever, first frame restore previous.
public const string AnimatedTransparentFirstFrameRestorePrev = "Gif/animated_transparent_firstframerestoreprev_loop.gif";
// Transparent gif with 4 transparent frames, loops forever, no dispose
public const string AnimatedTransparentNoRestore = "Gif/animated_transparent_frame_norestore_loop.gif";
// Transparent gif with 4 transparent frames, loops forever, restore previous.
public const string AnimatedTransparentRestorePrevious = "Gif/animated_transparent_frame_restoreprev_loop.gif";
// Static gif with no animation, no transparency.
public const string StaticNontransparent = "Gif/static_nontransparent.gif";
// Static transparent gif with no animation.
public const string StaticTransparent = "Gif/static_transparent.gif";
// Test images from https://github.com/robert-ancell/pygif/tree/master/test-suite
public const string ZeroSize = "Gif/image-zero-size.gif";
public const string ZeroHeight = "Gif/image-zero-height.gif";
@ -548,6 +574,7 @@ public static class TestImages
public const string Issue2450_B = "Gif/issues/issue_2450_2.gif";
public const string Issue2198 = "Gif/issues/issue_2198.gif";
public const string Issue2758 = "Gif/issues/issue_2758.gif";
public const string Issue2866 = "Gif/issues/issue_2866.gif";
public const string Issue2859_A = "Gif/issues/issue_2859_A.gif";
public const string Issue2859_B = "Gif/issues/issue_2859_B.gif";
}
@ -842,7 +869,13 @@ public static class TestImages
public const string Issue2670 = "Webp/issues/Issue2670.webp";
public const string Issue2763 = "Webp/issues/Issue2763.png";
public const string Issue2801 = "Webp/issues/Issue2801.webp";
public const string Issue2866 = "Webp/issues/Issue2866.webp";
}
public const string AlphaBlend = "Webp/alpha-blend.webp";
public const string AlphaBlend2 = "Webp/alpha-blend-2.webp";
public const string AlphaBlend3 = "Webp/alpha-blend-3.webp";
public const string AlphaBlend4 = "Webp/alpha-blend-4.webp";
}
public static class Tiff

36
tests/ImageSharp.Tests/TestUtilities/ReferenceCodecs/MagickReferenceDecoder.cs

@ -64,20 +64,27 @@ public class MagickReferenceDecoder : ImageDecoder
settings.SetDefines(pngReadDefines);
using MagickImageCollection magickImageCollection = new(stream, settings);
int imageWidth = magickImageCollection.Max(x => x.Width);
int imageHeight = magickImageCollection.Max(x => x.Height);
List<ImageFrame<TPixel>> framesList = [];
foreach (IMagickImage<ushort> magicFrame in magickImageCollection)
{
ImageFrame<TPixel> frame = new(configuration, (int)magicFrame.Width, (int)magicFrame.Height);
ImageFrame<TPixel> frame = new(configuration, imageWidth, imageHeight);
framesList.Add(frame);
MemoryGroup<TPixel> framePixels = frame.PixelBuffer.FastMemoryGroup;
Buffer2DRegion<TPixel> buffer = frame.PixelBuffer.GetRegion(
imageWidth - magicFrame.Width,
imageHeight - magicFrame.Height,
magicFrame.Width,
magicFrame.Height);
using IUnsafePixelCollection<ushort> pixels = magicFrame.GetPixelsUnsafe();
if (magicFrame.Depth is 12 or 10 or 8 or 6 or 5 or 4 or 3 or 2 or 1)
{
byte[] data = pixels.ToByteArray(PixelMapping.RGBA);
FromRgba32Bytes(configuration, data, framePixels);
FromRgba32Bytes(configuration, data, buffer);
}
else if (magicFrame.Depth is 14 or 16 or 32)
{
@ -88,7 +95,7 @@ public class MagickReferenceDecoder : ImageDecoder
ushort[] data = pixels.ToShortArray(PixelMapping.RGBA);
Span<byte> bytes = MemoryMarshal.Cast<ushort, byte>(data.AsSpan());
FromRgba64Bytes(configuration, bytes, framePixels);
FromRgba64Bytes(configuration, bytes, buffer);
}
else
{
@ -111,33 +118,40 @@ public class MagickReferenceDecoder : ImageDecoder
PixelType = metadata.GetDecodedPixelTypeInfo()
};
}
private static void FromRgba32Bytes<TPixel>(Configuration configuration, Span<byte> rgbaBytes, IMemoryGroup<TPixel> destinationGroup)
private static void FromRgba32Bytes<TPixel>(
Configuration configuration,
Span<byte> rgbaBytes,
Buffer2DRegion<TPixel> destinationGroup)
where TPixel : unmanaged, ImageSharp.PixelFormats.IPixel<TPixel>
{
Span<Rgba32> sourcePixels = MemoryMarshal.Cast<byte, Rgba32>(rgbaBytes);
foreach (Memory<TPixel> m in destinationGroup)
for (int y = 0; y < destinationGroup.Height; y++)
{
Span<TPixel> destBuffer = m.Span;
Span<TPixel> destBuffer = destinationGroup.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.FromRgba32(
configuration,
sourcePixels[..destBuffer.Length],
destBuffer);
sourcePixels = sourcePixels[destBuffer.Length..];
}
}
private static void FromRgba64Bytes<TPixel>(Configuration configuration, Span<byte> rgbaBytes, IMemoryGroup<TPixel> destinationGroup)
private static void FromRgba64Bytes<TPixel>(
Configuration configuration,
Span<byte> rgbaBytes,
Buffer2DRegion<TPixel> destinationGroup)
where TPixel : unmanaged, ImageSharp.PixelFormats.IPixel<TPixel>
{
foreach (Memory<TPixel> m in destinationGroup)
for (int y = 0; y < destinationGroup.Height; y++)
{
Span<TPixel> destBuffer = m.Span;
Span<TPixel> destBuffer = destinationGroup.DangerousGetRowSpan(y);
PixelOperations<TPixel>.Instance.FromRgba64Bytes(
configuration,
rgbaBytes,
destBuffer,
destBuffer.Length);
rgbaBytes = rgbaBytes[(destBuffer.Length * 8)..];
}
}

91
tests/ImageSharp.Tests/TestUtilities/TestImageExtensions.cs

@ -107,6 +107,7 @@ public static class TestImageExtensions
ITestImageProvider provider,
object testOutputDetails = null,
string extension = "png",
IImageEncoder encoder = null,
bool appendPixelTypeToFileName = true,
Func<int, int, bool> predicate = null)
where TPixel : unmanaged, IPixel<TPixel>
@ -119,6 +120,7 @@ public static class TestImageExtensions
provider.Utility.SaveTestOutputFileMultiFrame(
image,
extension,
encoder: encoder,
testOutputDetails: testOutputDetails,
appendPixelTypeToFileName: appendPixelTypeToFileName,
predicate: predicate);
@ -277,6 +279,47 @@ public static class TestImageExtensions
return image;
}
public static Image<TPixel> CompareDebugOutputToReferenceOutputMultiFrame<TPixel>(
this Image<TPixel> image,
ITestImageProvider provider,
ImageComparer comparer,
object testOutputDetails = null,
string extension = "png",
IImageEncoder encoder = null,
bool appendPixelTypeToFileName = true,
Func<int, int, bool> predicate = null)
where TPixel : unmanaged, IPixel<TPixel>
{
image.DebugSaveMultiFrame(
provider,
testOutputDetails,
extension,
encoder,
appendPixelTypeToFileName,
predicate: predicate);
using (Image<TPixel> debugImage = GetDebugOutputImageMultiFrame<TPixel>(
provider,
image.Frames.Count,
testOutputDetails,
extension,
appendPixelTypeToFileName,
predicate: predicate))
using (Image<TPixel> referenceImage = GetReferenceOutputImageMultiFrame<TPixel>(
provider,
image.Frames.Count,
testOutputDetails,
extension,
appendPixelTypeToFileName,
predicate: predicate))
{
comparer.VerifySimilarity(referenceImage, debugImage);
}
return image;
}
public static Image<TPixel> CompareToReferenceOutputMultiFrame<TPixel>(
this Image<TPixel> image,
ITestImageProvider provider,
@ -375,6 +418,54 @@ public static class TestImageExtensions
return result;
}
public static Image<TPixel> GetDebugOutputImageMultiFrame<TPixel>(
this ITestImageProvider provider,
int frameCount,
object testOutputDetails = null,
string extension = "png",
bool appendPixelTypeToFileName = true,
Func<int, int, bool> predicate = null)
where TPixel : unmanaged, IPixel<TPixel>
{
(int Index, string FileName)[] frameFiles = [.. provider.Utility.GetTestOutputFileNamesMultiFrame(
frameCount,
extension,
testOutputDetails,
appendPixelTypeToFileName,
predicate: predicate)];
List<Image<TPixel>> temporaryFrameImages = [];
IImageDecoder decoder = TestEnvironment.GetReferenceDecoder(frameFiles[0].FileName);
for (int i = 0; i < frameFiles.Length; i++)
{
string path = frameFiles[i].FileName;
if (!File.Exists(path))
{
throw new FileNotFoundException("Reference output file missing: " + path);
}
using FileStream stream = File.OpenRead(path);
Image<TPixel> tempImage = decoder.Decode<TPixel>(DecoderOptions.Default, stream);
temporaryFrameImages.Add(tempImage);
}
Image<TPixel> firstTemp = temporaryFrameImages[0];
Image<TPixel> result = new(firstTemp.Width, firstTemp.Height);
foreach (Image<TPixel> fi in temporaryFrameImages)
{
result.Frames.AddFrame(fi.Frames.RootFrame);
fi.Dispose();
}
// Remove the initial empty frame:
result.Frames.RemoveFrame(0);
return result;
}
public static IEnumerable<ImageSimilarityReport> GetReferenceOutputSimilarityReports<TPixel>(
this Image<TPixel> image,
ITestImageProvider provider,

2
tests/Images/External/ReferenceOutput/BmpEncoderTests/Encode_8BitColor_WithOctreeQuantizer_rgb32.bmp

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11375b15df083d98335f4a4baf0717e7fdd6b21ab2132a6815cadc787ac17e7d
oid sha256:a98b1ec707af066f77fad7d1a64b858d460986beb6d27682717dd5e221310fd4
size 9270

3
tests/Images/External/ReferenceOutput/DitherTests/ApplyDitherFilterInBox_Rgba32_CalliphoraPartial - Copy.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a899a84c6af24bfad89f9fde75957c7a979d65bcf096ab667cb976efd71cb560
size 271171

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Atkinson.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e22401dddf6552cd91517c1cdd142d3b9a66a7ad5c80d2e52ae07a7f583708e
size 57657
oid sha256:e44c49a8f2ab1280c38e6ba71da29a93803b2aa4cf117e1e919909521b0373e6
size 57636

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Burks.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:819a0ce38e27e2adfa454d8c5ad5b24e818bf8954c9f2406f608dcecf506c2c4
size 59838
oid sha256:359a44bb957481c85d5acd65559b43ffc0acf806d4f4e57d6a791ca65b28295b
size 59839

2
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_FloydSteinberg.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:007ac609ec61b39c7bdd04bc87a698f5cdc76eadd834c1457f41eb9c135c3f7b
oid sha256:7fb3743098a8147fd24294d933d93a61ec0155d754f52544650f6589719905be
size 60688

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_JarvisJudiceNinke.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:46892c07e9a93f1df71f0e38b331a437fb9b7c52d8f40cf62780cb6bd35d3b13
size 58963
oid sha256:41fa7d92a10db450f3b3729ab9e36074224baaefeda21cffd0466e37a111e138
size 59113

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Sierra2.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b83345ca3de8d1fc0fbb5d8e68329b94ad79fc29b9f10a1392a97ffe9a0733e
size 58985
oid sha256:bebf3b3762b339874891e3d434511e5f2557be90d66d6d7fe827b50334ede6c2
size 58976

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Sierra3.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c775a5b19ba09e1b335389e0dc12cb0c3feaff6072e904da750a676fcd6b07dc
size 59202
oid sha256:fd4358826739db2c22064e8aa90597f8b6403b9d7e2866ec280e743c51d2f41f
size 59203

4
tests/Images/External/ReferenceOutput/DitherTests/DiffusionFilter_WorksWithAllErrorDiffusers_CalliphoraPartial_Stucki.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8cc216ed952216d203836dc559234216614f1ed059651677cc0ea714010bd932
size 58855
oid sha256:174ee39c08eb9a174b48b19dc618d043bf6b71eee68ab7127407eb713e164e61
size 58934

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Bgra32_filter0.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3253003b088c9975725cf321c2fc827547a5feb199f2d1aa515c69bde59deb7
size 871
oid sha256:1110b46ec3296a1631420e0bb915f6fdc3d1cead4b0fc5a63a7a280fbf841ea2
size 870

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Rgb24_filter0.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb3e3b9b3001e76505fb0e2db7ad200cad2a016c06f1993c60c3cab42c134863
size 867
oid sha256:e51abcab66201997deda99637de604330ef977fd2d1dbebaa0416c621d03b8f9
size 869

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_Rgba32_filter0.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3253003b088c9975725cf321c2fc827547a5feb199f2d1aa515c69bde59deb7
size 871
oid sha256:1110b46ec3296a1631420e0bb915f6fdc3d1cead4b0fc5a63a7a280fbf841ea2
size 870

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_ShouldNotDependOnSinglePixelType_RgbaVector_filter0.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3253003b088c9975725cf321c2fc827547a5feb199f2d1aa515c69bde59deb7
size 871
oid sha256:1110b46ec3296a1631420e0bb915f6fdc3d1cead4b0fc5a63a7a280fbf841ea2
size 870

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_WorksWithAllDitherers_CalliphoraPartial_Bayer16x16.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9316cbbcb137ae6ff31646f6a5ba1d0aec100db4512509f7684187e74d16a111
size 51074
oid sha256:eb86f2037a0aff48a84c0161f22eb2e2495daadbfa9c33185ddfd7b8429a4ea9
size 51266

4
tests/Images/External/ReferenceOutput/DitherTests/DitherFilter_WorksWithAllDitherers_CalliphoraPartial_Bayer8x8.png

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d2289ed4fa0c679f0f120d260fec8ab40b1599043cc0a1fbebc6b67e238ff87
size 51428
oid sha256:ef033a419e2e1b06b57a66175bad9068f71ae4c862a66c5734f65cdaae8a27f0
size 51461

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/00.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b980fb1927a70f88bd26b039c54e4ea20a6a1ad68aacd6f1a68a46eb1997a29
size 1180

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/01.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4006374b88ff4c4ed665333608a19e693fc083ae72beb71850d0e39ad45c9943
size 1144

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/02.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f5054e1e464c9e9fc999eec00b9949a6dc256ee062e9910b5718b6d4658661a
size 1303

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop.gif/03.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49e8bcbcc5dc63fbd555f90a52b4e111cfc058f3adba2ca9c52dec966dbbae8f
size 1371

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/00.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b980fb1927a70f88bd26b039c54e4ea20a6a1ad68aacd6f1a68a46eb1997a29
size 1180

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/01.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4006374b88ff4c4ed665333608a19e693fc083ae72beb71850d0e39ad45c9943
size 1144

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/02.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f5054e1e464c9e9fc999eec00b9949a6dc256ee062e9910b5718b6d4658661a
size 1303

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_Rgba32_animated_loop_interlaced.gif/03.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49e8bcbcc5dc63fbd555f90a52b4e111cfc058f3adba2ca9c52dec966dbbae8f
size 1371

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/00.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:18b60d2066cb53d41988da37b8c521ddcb5355b995320a8413b95522a0492140
size 687

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/01.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30ff7708250c5f02dc02d74238d398b319d8fc6c071178f32f82a17e3b637afd
size 542

3
tests/Images/External/ReferenceOutput/GifDecoderTests/Decode_Animated_WithTransparency_Rgba32_animated_transparent_firstframerestoreprev_loop.gif/02.png

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d21f4576486692122b6ee719d75883849f65ddb07f632ea1c62b42651c289688
size 591

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save