diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index e17b8d724e..ec3ebfa1dc 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -67,8 +67,10 @@ jobs:
steps:
- name: Install libgdi+, which is required for tests running on ubuntu
- if: ${{ matrix.options.os == 'buildjet-4vcpu-ubuntu-2204-arm' }}
- run: sudo apt-get -y install libgdiplus libgif-dev libglib2.0-dev libcairo2-dev libtiff-dev libexif-dev
+ if: ${{ contains(matrix.options.os, 'ubuntu') }}
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libgdiplus libgif-dev libglib2.0-dev libcairo2-dev libtiff-dev libexif-dev
- name: Git Config
shell: bash
@@ -108,18 +110,12 @@ jobs:
restore-keys: ${{ runner.os }}-nuget-
- name: DotNet Setup
- if: ${{ matrix.options.sdk-preview != true }}
- uses: actions/setup-dotnet@v3
- with:
- dotnet-version: |
- 6.0.x
-
- - name: DotNet Setup Preview
- if: ${{ matrix.options.sdk-preview == true }}
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 8.0.x
7.0.x
+ 6.0.x
- name: DotNet Build
if: ${{ matrix.options.sdk-preview != true }}
@@ -152,7 +148,7 @@ jobs:
XUNIT_PATH: .\tests\ImageSharp.Tests # Required for xunit
- name: Export Failed Output
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
if: failure()
with:
name: actual_output_${{ runner.os }}_${{ matrix.options.framework }}${{ matrix.options.runtime }}.zip
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index e551afbd6d..de95bf01b2 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -17,6 +17,11 @@ jobs:
runs-on: ${{matrix.options.os}}
steps:
+ - name: Install libgdi+, which is required for tests running on ubuntu
+ run: |
+ sudo apt-get update
+ sudo apt-get -y install libgdiplus libgif-dev libglib2.0-dev libcairo2-dev libtiff-dev libexif-dev
+
- name: Git Config
shell: bash
run: |
@@ -55,9 +60,11 @@ jobs:
restore-keys: ${{ runner.os }}-nuget-
- name: DotNet Setup
- uses: actions/setup-dotnet@v3
+ uses: actions/setup-dotnet@v4
with:
dotnet-version: |
+ 8.0.x
+ 7.0.x
6.0.x
- name: DotNet Build
diff --git a/Directory.Build.props b/Directory.Build.props
index 26b3cc5afc..c94d9cbc9c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -21,9 +21,9 @@
-
+
- preview
+ 12.0
+ True
+ False
diff --git a/src/ImageSharp/IndexedImageFrame{TPixel}.cs b/src/ImageSharp/IndexedImageFrame{TPixel}.cs
index 6807e77ad2..49c9e33eb1 100644
--- a/src/ImageSharp/IndexedImageFrame{TPixel}.cs
+++ b/src/ImageSharp/IndexedImageFrame{TPixel}.cs
@@ -30,7 +30,7 @@ public sealed class IndexedImageFrame : IPixelSource, IDisposable
/// The frame width.
/// The frame height.
/// The color palette.
- internal IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory palette)
+ public IndexedImageFrame(Configuration configuration, int width, int height, ReadOnlyMemory palette)
{
Guard.NotNull(configuration, nameof(configuration));
Guard.MustBeLessThanOrEqualTo(palette.Length, QuantizerConstants.MaxColors, nameof(palette));
@@ -42,7 +42,7 @@ internal IndexedImageFrame(Configuration configuration, int width, int height, R
this.Height = height;
this.pixelBuffer = configuration.MemoryAllocator.Allocate2D(width, height);
- // Copy the palette over. We want the lifetime of this frame to be independant of any palette source.
+ // Copy the palette over. We want the lifetime of this frame to be independent of any palette source.
this.paletteOwner = configuration.MemoryAllocator.Allocate(palette.Length);
palette.Span.CopyTo(this.paletteOwner.GetSpan());
this.Palette = this.paletteOwner.Memory[..palette.Length];
diff --git a/src/ImageSharp/Memory/Buffer2DExtensions.cs b/src/ImageSharp/Memory/Buffer2DExtensions.cs
index 2eb05ea935..ffddfcbd0e 100644
--- a/src/ImageSharp/Memory/Buffer2DExtensions.cs
+++ b/src/ImageSharp/Memory/Buffer2DExtensions.cs
@@ -25,6 +25,39 @@ public static IMemoryGroup GetMemoryGroup(this Buffer2D buffer)
return buffer.FastMemoryGroup.View;
}
+ ///
+ /// Performs a deep clone of the buffer covering the specified .
+ ///
+ /// The element type.
+ /// The source buffer.
+ /// The configuration.
+ /// The rectangle to clone.
+ /// The .
+ internal static Buffer2D CloneRegion(this Buffer2D source, Configuration configuration, Rectangle rectangle)
+ where T : unmanaged
+ {
+ Buffer2D buffer = configuration.MemoryAllocator.Allocate2D(
+ rectangle.Width,
+ rectangle.Height,
+ configuration.PreferContiguousImageBuffers);
+
+ // Optimization for when the size of the area is the same as the buffer size.
+ Buffer2DRegion sourceRegion = source.GetRegion(rectangle);
+ if (sourceRegion.IsFullBufferArea)
+ {
+ sourceRegion.Buffer.FastMemoryGroup.CopyTo(buffer.FastMemoryGroup);
+ }
+ else
+ {
+ for (int y = 0; y < rectangle.Height; y++)
+ {
+ sourceRegion.DangerousGetRowSpan(y).CopyTo(buffer.DangerousGetRowSpan(y));
+ }
+ }
+
+ return buffer;
+ }
+
///
/// TODO: Does not work with multi-buffer groups, should be specific to Resize.
/// Copy columns of inplace,
diff --git a/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs b/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs
index 953ef74afb..fe510f250d 100644
--- a/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs
+++ b/src/ImageSharp/Metadata/Profiles/Exif/ExifReader.cs
@@ -7,6 +7,7 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
using System.Text;
using SixLabors.ImageSharp.Memory;
@@ -187,11 +188,21 @@ protected void ReadValues(List values, uint offset)
protected void ReadSubIfd(List values)
{
- if (this.subIfds is not null)
+ if (this.subIfds != null)
{
- foreach (ulong subIfdOffset in this.subIfds)
+ const int maxSubIfds = 8;
+ const int maxNestingLevel = 8;
+ Span buf = stackalloc ulong[maxSubIfds];
+ for (int i = 0; i < maxNestingLevel && this.subIfds.Count > 0; i++)
{
- this.ReadValues(values, (uint)subIfdOffset);
+ int sz = Math.Min(this.subIfds.Count, maxSubIfds);
+ CollectionsMarshal.AsSpan(this.subIfds)[..sz].CopyTo(buf);
+
+ this.subIfds.Clear();
+ foreach (ulong subIfdOffset in buf[..sz])
+ {
+ this.ReadValues(values, (uint)subIfdOffset);
+ }
}
}
}
@@ -481,8 +492,9 @@ private void Add(IList values, IExifValue exif, object? value)
foreach (IExifValue val in values)
{
- // Sometimes duplicates appear, can compare val.Tag == exif.Tag
- if (val == exif)
+ // to skip duplicates must be used Equals method,
+ // == operator not defined for ExifValue and IExifValue
+ if (exif.Equals(val))
{
Debug.WriteLine($"Duplicate Exif tag: tag={exif.Tag}, dataType={exif.DataType}");
return;
diff --git a/src/ImageSharp/Metadata/Profiles/Exif/Tags/ExifTagValue.cs b/src/ImageSharp/Metadata/Profiles/Exif/Tags/ExifTagValue.cs
index 56e8a3ffd1..07dbc51e7d 100644
--- a/src/ImageSharp/Metadata/Profiles/Exif/Tags/ExifTagValue.cs
+++ b/src/ImageSharp/Metadata/Profiles/Exif/Tags/ExifTagValue.cs
@@ -23,14 +23,6 @@ internal enum ExifTagValue
///
GPSIFDOffset = 0x8825,
- ///
- /// Indicates the identification of the Interoperability rule.
- /// See https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/interoperability/interoperabilityindex.html
- ///
- [ExifTagDescription("R98", "Indicates a file conforming to R98 file specification of Recommended Exif Interoperability Rules (ExifR98) or to DCF basic file stipulated by Design Rule for Camera File System.")]
- [ExifTagDescription("THM", "Indicates a file conforming to DCF thumbnail file stipulated by Design rule for Camera File System.")]
- InteroperabilityIndex = 0x0001,
-
///
/// A general indication of the kind of data contained in this subfile.
/// See Section 8: Baseline Fields.
diff --git a/src/ImageSharp/Processing/AffineTransformBuilder.cs b/src/ImageSharp/Processing/AffineTransformBuilder.cs
index 59264698bd..4ac9546f39 100644
--- a/src/ImageSharp/Processing/AffineTransformBuilder.cs
+++ b/src/ImageSharp/Processing/AffineTransformBuilder.cs
@@ -12,7 +12,28 @@ namespace SixLabors.ImageSharp.Processing;
public class AffineTransformBuilder
{
private readonly List> transformMatrixFactories = new();
- private readonly List> boundsMatrixFactories = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AffineTransformBuilder()
+ : this(TransformSpace.Pixel)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The to use when applying the affine transform.
+ ///
+ public AffineTransformBuilder(TransformSpace transformSpace)
+ => this.TransformSpace = transformSpace;
+
+ ///
+ /// Gets the to use when applying the affine transform.
+ ///
+ public TransformSpace TransformSpace { get; }
///
/// Prepends a rotation matrix using the given rotation angle in degrees
@@ -31,8 +52,7 @@ public AffineTransformBuilder PrependRotationDegrees(float degrees)
/// The .
public AffineTransformBuilder PrependRotationRadians(float radians)
=> this.Prepend(
- size => TransformUtils.CreateRotationTransformMatrixRadians(radians, size),
- size => TransformUtils.CreateRotationBoundsMatrixRadians(radians, size));
+ size => TransformUtils.CreateRotationTransformMatrixRadians(radians, size, this.TransformSpace));
///
/// Prepends a rotation matrix using the given rotation in degrees at the given origin.
@@ -68,9 +88,7 @@ public AffineTransformBuilder AppendRotationDegrees(float degrees)
/// The amount of rotation, in radians.
/// The .
public AffineTransformBuilder AppendRotationRadians(float radians)
- => this.Append(
- size => TransformUtils.CreateRotationTransformMatrixRadians(radians, size),
- size => TransformUtils.CreateRotationBoundsMatrixRadians(radians, size));
+ => this.Append(size => TransformUtils.CreateRotationTransformMatrixRadians(radians, size, this.TransformSpace));
///
/// Appends a rotation matrix using the given rotation in degrees at the given origin.
@@ -145,9 +163,7 @@ public AffineTransformBuilder AppendScale(Vector2 scales)
/// The Y angle, in degrees.
/// The .
public AffineTransformBuilder PrependSkewDegrees(float degreesX, float degreesY)
- => this.Prepend(
- size => TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, size),
- size => TransformUtils.CreateSkewBoundsMatrixDegrees(degreesX, degreesY, size));
+ => this.PrependSkewRadians(GeometryUtilities.DegreeToRadian(degreesX), GeometryUtilities.DegreeToRadian(degreesY));
///
/// Prepends a centered skew matrix from the give angles in radians.
@@ -156,9 +172,7 @@ public AffineTransformBuilder PrependSkewDegrees(float degreesX, float degreesY)
/// The Y angle, in radians.
/// The .
public AffineTransformBuilder PrependSkewRadians(float radiansX, float radiansY)
- => this.Prepend(
- size => TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size),
- size => TransformUtils.CreateSkewBoundsMatrixRadians(radiansX, radiansY, size));
+ => this.Prepend(size => TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size, this.TransformSpace));
///
/// Prepends a skew matrix using the given angles in degrees at the given origin.
@@ -187,9 +201,7 @@ public AffineTransformBuilder PrependSkewRadians(float radiansX, float radiansY,
/// The Y angle, in degrees.
/// The .
public AffineTransformBuilder AppendSkewDegrees(float degreesX, float degreesY)
- => this.Append(
- size => TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, size),
- size => TransformUtils.CreateSkewBoundsMatrixDegrees(degreesX, degreesY, size));
+ => this.AppendSkewRadians(GeometryUtilities.DegreeToRadian(degreesX), GeometryUtilities.DegreeToRadian(degreesY));
///
/// Appends a centered skew matrix from the give angles in radians.
@@ -198,9 +210,7 @@ public AffineTransformBuilder AppendSkewDegrees(float degreesX, float degreesY)
/// The Y angle, in radians.
/// The .
public AffineTransformBuilder AppendSkewRadians(float radiansX, float radiansY)
- => this.Append(
- size => TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size),
- size => TransformUtils.CreateSkewBoundsMatrixRadians(radiansX, radiansY, size));
+ => this.Append(size => TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size, this.TransformSpace));
///
/// Appends a skew matrix using the given angles in degrees at the given origin.
@@ -267,7 +277,7 @@ public AffineTransformBuilder AppendTranslation(Vector2 position)
public AffineTransformBuilder PrependMatrix(Matrix3x2 matrix)
{
CheckDegenerate(matrix);
- return this.Prepend(_ => matrix, _ => matrix);
+ return this.Prepend(_ => matrix);
}
///
@@ -283,7 +293,7 @@ public AffineTransformBuilder PrependMatrix(Matrix3x2 matrix)
public AffineTransformBuilder AppendMatrix(Matrix3x2 matrix)
{
CheckDegenerate(matrix);
- return this.Append(_ => matrix, _ => matrix);
+ return this.Append(_ => matrix);
}
///
@@ -340,13 +350,13 @@ public Size GetTransformedSize(Rectangle sourceRectangle)
// Translate the origin matrix to cater for source rectangle offsets.
Matrix3x2 matrix = Matrix3x2.CreateTranslation(-sourceRectangle.Location);
- foreach (Func factory in this.boundsMatrixFactories)
+ foreach (Func factory in this.transformMatrixFactories)
{
matrix *= factory(size);
CheckDegenerate(matrix);
}
- return TransformUtils.GetTransformedSize(size, matrix);
+ return TransformUtils.GetTransformedSize(matrix, size, this.TransformSpace);
}
private static void CheckDegenerate(Matrix3x2 matrix)
@@ -357,17 +367,15 @@ private static void CheckDegenerate(Matrix3x2 matrix)
}
}
- private AffineTransformBuilder Prepend(Func transformFactory, Func boundsFactory)
+ private AffineTransformBuilder Prepend(Func transformFactory)
{
this.transformMatrixFactories.Insert(0, transformFactory);
- this.boundsMatrixFactories.Insert(0, boundsFactory);
return this;
}
- private AffineTransformBuilder Append(Func transformFactory, Func boundsFactory)
+ private AffineTransformBuilder Append(Func transformFactory)
{
this.transformMatrixFactories.Add(transformFactory);
- this.boundsMatrixFactories.Add(boundsFactory);
return this;
}
}
diff --git a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
index 982cc7d46c..94a23ef5fe 100644
--- a/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Dithering/PaletteDitherProcessor{TPixel}.cs
@@ -80,7 +80,7 @@ protected override void Dispose(bool disposing)
Justification = "https://github.com/dotnet/roslyn-analyzers/issues/6151")]
internal readonly struct DitherProcessor : IPaletteDitherImageProcessor, IDisposable
{
- private readonly EuclideanPixelMap pixelMap;
+ private readonly PixelMap pixelMap;
[MethodImpl(InliningOptions.ShortMethod)]
public DitherProcessor(
@@ -89,7 +89,7 @@ public DitherProcessor(
float ditherScale)
{
this.Configuration = configuration;
- this.pixelMap = new EuclideanPixelMap(configuration, palette);
+ this.pixelMap = PixelMapFactory.Create(configuration, palette, ColorMatchingMode.Coarse);
this.Palette = palette;
this.DitherScale = ditherScale;
}
@@ -103,7 +103,7 @@ public DitherProcessor(
[MethodImpl(InliningOptions.ShortMethod)]
public TPixel GetPaletteColor(TPixel color)
{
- this.pixelMap.GetClosestColor(color, out TPixel match);
+ _ = this.pixelMap.GetClosestColor(color, out TPixel match);
return match;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
new file mode 100644
index 0000000000..26fd7d5d76
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/ColorMatchingMode.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Defines the precision level used when matching colors during quantization.
+///
+public enum ColorMatchingMode
+{
+ ///
+ /// Uses a coarse caching strategy optimized for performance at the expense of exact matches.
+ /// This provides the fastest matching but may yield approximate results.
+ ///
+ Coarse,
+
+ ///
+ /// Enables an exact color match cache for the first 512 unique colors encountered,
+ /// falling back to coarse matching thereafter.
+ ///
+ Hybrid,
+
+ ///
+ /// Performs exact color matching without any caching optimizations.
+ /// This is the slowest but most accurate matching strategy.
+ ///
+ Exact
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
new file mode 100644
index 0000000000..a900d643bd
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel,TCache}.cs
@@ -0,0 +1,219 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Gets the closest color to the supplied color based upon the Euclidean distance.
+///
+/// The pixel format.
+/// The cache type.
+///
+/// This class is not thread safe and should not be accessed in parallel.
+/// Doing so will result in non-idempotent results.
+///
+internal sealed class EuclideanPixelMap : PixelMap
+ where TPixel : unmanaged, IPixel
+ where TCache : struct, IColorIndexCache
+{
+ private Rgba32[] rgbaPalette;
+ private int transparentIndex;
+ private readonly TPixel transparentMatch;
+
+ // Do not make readonly. It's a mutable struct.
+#pragma warning disable IDE0044 // Add readonly modifier
+ private TCache cache;
+#pragma warning restore IDE0044 // Add readonly modifier
+ private readonly Configuration configuration;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configuration.
+ /// The color palette to map from.
+ /// An explicit index at which to match transparent pixels.
+ [RequiresPreviewFeatures]
+ public EuclideanPixelMap(
+ Configuration configuration,
+ ReadOnlyMemory palette,
+ int transparentIndex = -1)
+ {
+ this.configuration = configuration;
+ this.cache = TCache.Create(configuration.MemoryAllocator);
+
+ this.Palette = palette;
+ this.rgbaPalette = new Rgba32[palette.Length];
+ PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
+
+ this.transparentIndex = transparentIndex;
+ Unsafe.SkipInit(out this.transparentMatch);
+ this.transparentMatch.FromRgba32(default);
+ }
+
+ ///
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public override int GetClosestColor(TPixel color, out TPixel match)
+ {
+ ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
+ Unsafe.SkipInit(out Rgba32 rgba);
+ color.ToRgba32(ref rgba);
+
+ // Check if the color is in the lookup table
+ if (this.cache.TryGetValue(rgba, out short index))
+ {
+ match = Unsafe.Add(ref paletteRef, (ushort)index);
+ return index;
+ }
+
+ return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
+ }
+
+ ///
+ public override void Clear(ReadOnlyMemory palette)
+ {
+ this.Palette = palette;
+ this.rgbaPalette = new Rgba32[palette.Length];
+ PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
+ this.transparentIndex = -1;
+ this.cache.Clear();
+ }
+
+ ///
+ public override void SetTransparentIndex(int index)
+ {
+ if (index != this.transparentIndex)
+ {
+ this.cache.Clear();
+ }
+
+ this.transparentIndex = index;
+ }
+
+ [MethodImpl(InliningOptions.ColdPath)]
+ private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
+ {
+ // Loop through the palette and find the nearest match.
+ int index = 0;
+
+ if (this.transparentIndex >= 0 && rgba == default)
+ {
+ // We have explicit instructions. No need to search.
+ index = this.transparentIndex;
+ _ = this.cache.TryAdd(rgba, (short)index);
+ match = this.transparentMatch;
+ return index;
+ }
+
+ float leastDistance = float.MaxValue;
+ for (int i = 0; i < this.rgbaPalette.Length; i++)
+ {
+ Rgba32 candidate = this.rgbaPalette[i];
+ float distance = DistanceSquared(rgba, candidate);
+
+ // If it's an exact match, exit the loop
+ if (distance == 0)
+ {
+ index = i;
+ break;
+ }
+
+ if (distance < leastDistance)
+ {
+ // Less than... assign.
+ index = i;
+ leastDistance = distance;
+ }
+ }
+
+ // Now I have the index, pop it into the cache for next time
+ _ = this.cache.TryAdd(rgba, (short)index);
+ match = Unsafe.Add(ref paletteRef, (uint)index);
+
+ return index;
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ private static float DistanceSquared(Rgba32 a, Rgba32 b)
+ {
+ float deltaR = a.R - b.R;
+ float deltaG = a.G - b.G;
+ float deltaB = a.B - b.B;
+ float deltaA = a.A - b.A;
+ return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
+ }
+
+ ///
+ public override void Dispose() => this.cache.Dispose();
+}
+
+///
+/// Represents a map of colors to indices.
+///
+/// The pixel format.
+internal abstract class PixelMap : IDisposable
+ where TPixel : unmanaged, IPixel
+{
+ ///
+ /// Gets the color palette of this .
+ ///
+ public ReadOnlyMemory Palette { get; private protected set; }
+
+ ///
+ /// Returns the closest color in the palette and the index of that pixel.
+ ///
+ /// The color to match.
+ /// The matched color.
+ ///
+ /// The index.
+ ///
+ public abstract int GetClosestColor(TPixel color, out TPixel match);
+
+ ///
+ /// Clears the map, resetting it to use the given palette.
+ ///
+ /// The color palette to map from.
+ public abstract void Clear(ReadOnlyMemory palette);
+
+ ///
+ /// Allows setting the transparent index after construction.
+ ///
+ /// An explicit index at which to match transparent pixels.
+ public abstract void SetTransparentIndex(int index);
+
+ ///
+ public abstract void Dispose();
+}
+
+///
+/// A factory for creating instances.
+///
+internal static class PixelMapFactory
+{
+ ///
+ /// Creates a new instance.
+ ///
+ /// The pixel format.
+ /// The configuration.
+ /// The color palette to map from.
+ /// The color matching mode.
+ /// An explicit index at which to match transparent pixels.
+ ///
+ /// The .
+ ///
+ public static PixelMap Create(
+ Configuration configuration,
+ ReadOnlyMemory palette,
+ ColorMatchingMode colorMatchingMode,
+ int transparentIndex = -1)
+ where TPixel : unmanaged, IPixel => colorMatchingMode switch
+ {
+ ColorMatchingMode.Hybrid => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ ColorMatchingMode.Exact => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ _ => new EuclideanPixelMap(configuration, palette, transparentIndex),
+ };
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
deleted file mode 100644
index 72148374aa..0000000000
--- a/src/ImageSharp/Processing/Processors/Quantization/EuclideanPixelMap{TPixel}.cs
+++ /dev/null
@@ -1,260 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-using System.Buffers;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using SixLabors.ImageSharp.Memory;
-using SixLabors.ImageSharp.PixelFormats;
-
-namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
-
-///
-/// Gets the closest color to the supplied color based upon the Euclidean distance.
-///
-/// The pixel format.
-///
-/// This class is not thread safe and should not be accessed in parallel.
-/// Doing so will result in non-idempotent results.
-///
-internal sealed class EuclideanPixelMap : IDisposable
- where TPixel : unmanaged, IPixel
-{
- private Rgba32[] rgbaPalette;
- private int transparentIndex;
- private readonly TPixel transparentMatch;
-
- ///
- /// Do not make this readonly! Struct value would be always copied on non-readonly method calls.
- ///
- private ColorDistanceCache cache;
- private readonly Configuration configuration;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The configuration.
- /// The color palette to map from.
- public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette)
- : this(configuration, palette, -1)
- {
- }
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The configuration.
- /// The color palette to map from.
- /// An explicit index at which to match transparent pixels.
- public EuclideanPixelMap(Configuration configuration, ReadOnlyMemory palette, int transparentIndex = -1)
- {
- this.configuration = configuration;
- this.Palette = palette;
- this.rgbaPalette = new Rgba32[palette.Length];
- this.cache = new ColorDistanceCache(configuration.MemoryAllocator);
- PixelOperations.Instance.ToRgba32(configuration, this.Palette.Span, this.rgbaPalette);
-
- this.transparentIndex = transparentIndex;
- Unsafe.SkipInit(out this.transparentMatch);
- this.transparentMatch.FromRgba32(default);
- }
-
- ///
- /// Gets the color palette of this .
- /// The palette memory is owned by the palette source that created it.
- ///
- public ReadOnlyMemory Palette { get; private set; }
-
- ///
- /// Returns the closest color in the palette and the index of that pixel.
- /// The palette contents must match the one used in the constructor.
- ///
- /// The color to match.
- /// The matched color.
- /// The index.
- [MethodImpl(InliningOptions.ShortMethod)]
- public int GetClosestColor(TPixel color, out TPixel match)
- {
- ref TPixel paletteRef = ref MemoryMarshal.GetReference(this.Palette.Span);
- Unsafe.SkipInit(out Rgba32 rgba);
- color.ToRgba32(ref rgba);
-
- // Check if the color is in the lookup table
- if (!this.cache.TryGetValue(rgba, out short index))
- {
- return this.GetClosestColorSlow(rgba, ref paletteRef, out match);
- }
-
- match = Unsafe.Add(ref paletteRef, (ushort)index);
- return index;
- }
-
- ///
- /// Clears the map, resetting it to use the given palette.
- ///
- /// The color palette to map from.
- public void Clear(ReadOnlyMemory palette)
- {
- this.Palette = palette;
- this.rgbaPalette = new Rgba32[palette.Length];
- PixelOperations.Instance.ToRgba32(this.configuration, this.Palette.Span, this.rgbaPalette);
- this.transparentIndex = -1;
- this.cache.Clear();
- }
-
- ///
- /// Allows setting the transparent index after construction.
- ///
- /// An explicit index at which to match transparent pixels.
- public void SetTransparentIndex(int index)
- {
- if (index != this.transparentIndex)
- {
- this.cache.Clear();
- }
-
- this.transparentIndex = index;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- private int GetClosestColorSlow(Rgba32 rgba, ref TPixel paletteRef, out TPixel match)
- {
- // Loop through the palette and find the nearest match.
- int index = 0;
-
- if (this.transparentIndex >= 0 && rgba == default)
- {
- // We have explicit instructions. No need to search.
- index = this.transparentIndex;
- this.cache.Add(rgba, (byte)index);
- match = this.transparentMatch;
- return index;
- }
-
- float leastDistance = float.MaxValue;
- for (int i = 0; i < this.rgbaPalette.Length; i++)
- {
- Rgba32 candidate = this.rgbaPalette[i];
- float distance = DistanceSquared(rgba, candidate);
-
- // If it's an exact match, exit the loop
- if (distance == 0)
- {
- index = i;
- break;
- }
-
- if (distance < leastDistance)
- {
- // Less than... assign.
- index = i;
- leastDistance = distance;
- }
- }
-
- // Now I have the index, pop it into the cache for next time
- this.cache.Add(rgba, (byte)index);
- match = Unsafe.Add(ref paletteRef, (uint)index);
- return index;
- }
-
- ///
- /// Returns the Euclidean distance squared between two specified points.
- ///
- /// The first point.
- /// The second point.
- /// The distance squared.
- [MethodImpl(InliningOptions.ShortMethod)]
- private static float DistanceSquared(Rgba32 a, Rgba32 b)
- {
- float deltaR = a.R - b.R;
- float deltaG = a.G - b.G;
- float deltaB = a.B - b.B;
- float deltaA = a.A - b.A;
- return (deltaR * deltaR) + (deltaG * deltaG) + (deltaB * deltaB) + (deltaA * deltaA);
- }
-
- public void Dispose() => this.cache.Dispose();
-
- ///
- /// A cache for storing color distance matching results.
- ///
- ///
- ///
- /// The granularity of the cache has been determined based upon the current
- /// suite of test images and provides the lowest possible memory usage while
- /// providing enough match accuracy.
- /// Entry count is currently limited to 2335905 entries (4MB).
- ///
- ///
- private unsafe struct ColorDistanceCache : IDisposable
- {
- private const int IndexRBits = 5;
- private const int IndexGBits = 5;
- private const int IndexBBits = 5;
- private const int IndexABits = 6;
- private const int IndexRCount = (1 << IndexRBits) + 1;
- private const int IndexGCount = (1 << IndexGBits) + 1;
- private const int IndexBCount = (1 << IndexBBits) + 1;
- private const int IndexACount = (1 << IndexABits) + 1;
- private const int RShift = 8 - IndexRBits;
- private const int GShift = 8 - IndexGBits;
- private const int BShift = 8 - IndexBBits;
- private const int AShift = 8 - IndexABits;
- private const int Entries = IndexRCount * IndexGCount * IndexBCount * IndexACount;
- private MemoryHandle tableHandle;
- private readonly IMemoryOwner table;
- private readonly short* tablePointer;
-
- public ColorDistanceCache(MemoryAllocator allocator)
- {
- this.table = allocator.Allocate(Entries);
- this.table.GetSpan().Fill(-1);
- this.tableHandle = this.table.Memory.Pin();
- this.tablePointer = (short*)this.tableHandle.Pointer;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Add(Rgba32 rgba, byte index)
- {
- int idx = GetPaletteIndex(rgba);
- this.tablePointer[idx] = index;
- }
-
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly bool TryGetValue(Rgba32 rgba, out short match)
- {
- int idx = GetPaletteIndex(rgba);
- match = this.tablePointer[idx];
- return match > -1;
- }
-
- ///
- /// Clears the cache resetting each entry to empty.
- ///
- [MethodImpl(InliningOptions.ShortMethod)]
- public readonly void Clear() => this.table.GetSpan().Fill(-1);
-
- [MethodImpl(InliningOptions.ShortMethod)]
- private static int GetPaletteIndex(Rgba32 rgba)
- {
- int rIndex = rgba.R >> RShift;
- int gIndex = rgba.G >> GShift;
- int bIndex = rgba.B >> BShift;
- int aIndex = rgba.A >> AShift;
-
- return (aIndex * (IndexRCount * IndexGCount * IndexBCount)) +
- (rIndex * (IndexGCount * IndexBCount)) +
- (gIndex * IndexBCount) + bIndex;
- }
-
- public void Dispose()
- {
- if (this.table != null)
- {
- this.tableHandle.Dispose();
- this.table.Dispose();
- }
- }
- }
-}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
new file mode 100644
index 0000000000..eabe9af850
--- /dev/null
+++ b/src/ImageSharp/Processing/Processors/Quantization/IColorIndexCache.cs
@@ -0,0 +1,377 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.Versioning;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
+
+///
+/// Represents a cache used for efficiently retrieving palette indices for colors.
+///
+/// The type of the cache.
+internal interface IColorIndexCache : IColorIndexCache
+ where T : struct, IColorIndexCache
+{
+ ///
+ /// Creates a new instance of the cache.
+ ///
+ /// The memory allocator to use.
+ ///
+ /// The new instance of the cache.
+ ///
+#pragma warning disable IDE0060 // Remove unused parameter
+ [RequiresPreviewFeatures]
+ public static abstract T Create(MemoryAllocator allocator);
+#pragma warning restore IDE0060 // Remove unused parameter
+}
+
+///
+/// Represents a cache used for efficiently retrieving palette indices for colors.
+///
+internal interface IColorIndexCache : IDisposable
+{
+ ///
+ /// Adds a color to the cache.
+ ///
+ /// The color to add.
+ /// The index of the color in the palette.
+ ///
+ /// if the color was added; otherwise, .
+ ///
+ public bool TryAdd(Rgba32 color, short value);
+
+ ///
+ /// Gets the index of the color in the palette.
+ ///
+ /// The color to get the index for.
+ /// The index of the color in the palette.
+ ///
+ /// if the color is in the palette; otherwise, .
+ ///
+ public bool TryGetValue(Rgba32 color, out short value);
+
+ ///
+ /// Clears the cache.
+ ///
+ public void Clear();
+}
+
+///
+/// A hybrid cache for color distance lookups that combines an exact-match dictionary with
+/// a fallback coarse lookup table.
+///
+///
+/// This cache uses a fallback table with 2,097,152 bins, each storing a 2-byte value
+/// (approximately 4 MB total), while the exact-match dictionary is limited to 512 entries
+/// and occupies roughly 4 KB. Overall, the worst-case memory usage is about 4 MB.
+/// Lookups and insertions are performed in constant time (O(1)) because the fallback table
+/// is accessed via direct indexing and the dictionary employs a simple hash-based bucket mechanism.
+/// The design achieves extremely fast color distance lookups with a predictable, fixed memory footprint.
+///
+internal unsafe struct HybridCache : IColorIndexCache
+{
+ private AccurateCache accurateCache;
+ private CoarseCache coarseCache;
+
+ [RequiresPreviewFeatures]
+ private HybridCache(MemoryAllocator allocator)
+ {
+ this.accurateCache = AccurateCache.Create(allocator);
+ this.coarseCache = CoarseCache.Create(allocator);
+ }
+
+ [RequiresPreviewFeatures]
+ public static HybridCache Create(MemoryAllocator allocator) => new(allocator);
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public bool TryAdd(Rgba32 color, short index)
+ {
+ if (this.accurateCache.TryAdd(color, index))
+ {
+ return true;
+ }
+
+ return this.coarseCache.TryAdd(color, index);
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ if (this.accurateCache.TryGetValue(color, out value))
+ {
+ return true;
+ }
+
+ return this.coarseCache.TryGetValue(color, out value);
+ }
+
+ public void Clear()
+ {
+ this.accurateCache.Clear();
+ this.coarseCache.Clear();
+ }
+
+ public void Dispose()
+ {
+ this.accurateCache.Dispose();
+ this.coarseCache.Dispose();
+ }
+}
+
+///
+/// A coarse cache for color distance lookups that uses a fixed-size lookup table.
+///
+///
+/// This cache uses a fixed lookup table with 2,097,152 bins, each storing a 2-byte value,
+/// resulting in a worst-case memory usage of approximately 4 MB. Lookups and insertions are
+/// performed in constant time (O(1)) via direct table indexing. This design is optimized for
+/// speed while maintaining a predictable, fixed memory footprint.
+///
+internal unsafe struct CoarseCache : IColorIndexCache
+{
+ private const int IndexRBits = 5;
+ private const int IndexGBits = 5;
+ private const int IndexBBits = 5;
+ private const int IndexABits = 6;
+ private const int IndexRCount = 1 << IndexRBits; // 32 bins for red
+ private const int IndexGCount = 1 << IndexGBits; // 32 bins for green
+ private const int IndexBCount = 1 << IndexBBits; // 32 bins for blue
+ private const int IndexACount = 1 << IndexABits; // 64 bins for alpha
+ private const int TotalBins = IndexRCount * IndexGCount * IndexBCount * IndexACount; // 2,097,152 bins
+
+ private readonly IMemoryOwner binsOwner;
+ private readonly short* binsPointer;
+ private MemoryHandle binsHandle;
+
+ private CoarseCache(MemoryAllocator allocator)
+ {
+ this.binsOwner = allocator.Allocate(TotalBins);
+ this.binsOwner.GetSpan().Fill(-1);
+ this.binsHandle = this.binsOwner.Memory.Pin();
+ this.binsPointer = (short*)this.binsHandle.Pointer;
+ }
+
+ [RequiresPreviewFeatures]
+ public static CoarseCache Create(MemoryAllocator allocator) => new(allocator);
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public readonly bool TryAdd(Rgba32 color, short value)
+ {
+ this.binsPointer[GetCoarseIndex(color)] = value;
+ return true;
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ public readonly bool TryGetValue(Rgba32 color, out short value)
+ {
+ value = this.binsPointer[GetCoarseIndex(color)];
+ return value > -1; // Coarse match found
+ }
+
+ [MethodImpl(InliningOptions.ShortMethod)]
+ private static int GetCoarseIndex(Rgba32 color)
+ {
+ int rIndex = color.R >> (8 - IndexRBits);
+ int gIndex = color.G >> (8 - IndexGBits);
+ int bIndex = color.B >> (8 - IndexBBits);
+ int aIndex = color.A >> (8 - IndexABits);
+
+ return (aIndex * IndexRCount * IndexGCount * IndexBCount) +
+ (rIndex * IndexGCount * IndexBCount) +
+ (gIndex * IndexBCount) +
+ bIndex;
+ }
+
+ public readonly void Clear()
+ => this.binsOwner.GetSpan().Fill(-1);
+
+ public void Dispose()
+ {
+ this.binsHandle.Dispose();
+ this.binsOwner.Dispose();
+ }
+}
+
+///
+/// A fixed-capacity dictionary with exactly 512 entries mapping a key
+/// to a value.
+///
+///
+/// The dictionary is implemented using a fixed array of 512 buckets and an entries array
+/// of the same size. The bucket for a key is computed as (key & 0x1FF), and collisions are
+/// resolved through a linked chain stored in the field.
+/// The overall memory usage is approximately 4–5 KB. Both lookup and insertion operations are,
+/// on average, O(1) since the bucket is determined via a simple bitmask and collision chains are
+/// typically very short; in the worst-case, the number of iterations is bounded by 256.
+/// This guarantees highly efficient and predictable performance for small, fixed-size color palettes.
+///
+internal unsafe struct AccurateCache : IColorIndexCache
+{
+ // Buckets array: each bucket holds the index (0-based) into the entries array
+ // of the first entry in the chain, or -1 if empty.
+ private readonly IMemoryOwner bucketsOwner;
+ private MemoryHandle bucketsHandle;
+ private short* buckets;
+
+ // Entries array: stores up to 256 entries.
+ private readonly IMemoryOwner entriesOwner;
+ private MemoryHandle entriesHandle;
+ private Entry* entries;
+
+ private int count;
+
+ public const int Capacity = 512;
+
+ private AccurateCache(MemoryAllocator allocator)
+ {
+ this.count = 0;
+
+ // Allocate exactly 512 ints for buckets.
+ this.bucketsOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ Span bucketSpan = this.bucketsOwner.GetSpan();
+ bucketSpan.Fill(-1);
+ this.bucketsHandle = this.bucketsOwner.Memory.Pin();
+ this.buckets = (short*)this.bucketsHandle.Pointer;
+
+ // Allocate exactly 512 entries.
+ this.entriesOwner = allocator.Allocate(Capacity, AllocationOptions.Clean);
+ this.entriesHandle = this.entriesOwner.Memory.Pin();
+ this.entries = (Entry*)this.entriesHandle.Pointer;
+ }
+
+ [RequiresPreviewFeatures]
+ public static AccurateCache Create(MemoryAllocator allocator) => new(allocator);
+
+ public bool TryAdd(Rgba32 color, short value)
+ {
+ if (this.count == Capacity)
+ {
+ return false; // Dictionary is full.
+ }
+
+ uint key = color.PackedValue;
+
+ // The key is a 32-bit unsigned integer representing an RGBA color, where the bytes are laid out as R|G|B|A
+ // (with R in the most significant byte and A in the least significant).
+ // To compute the bucket index:
+ // 1. (key >> 16) extracts the top 16 bits, effectively giving us the R and G channels.
+ // 2. (key >> 8) shifts the key right by 8 bits, bringing R, G, and B into the lower 24 bits (dropping A).
+ // 3. XORing these two values with the original key mixes bits from all four channels (R, G, B, and A),
+ // which helps to counteract situations where one or more channels have a limited range.
+ // 4. Finally, we apply a bitmask of 0x1FF to keep only the lowest 9 bits, ensuring the result is between 0 and 511,
+ // which corresponds to our fixed bucket count of 512.
+ int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
+ int i = this.buckets[bucket];
+
+ // Traverse the collision chain.
+ Entry* entries = this.entries;
+ while (i != -1)
+ {
+ Entry e = entries[i];
+ if (e.Key == key)
+ {
+ // Key already exists; do not overwrite.
+ return false;
+ }
+
+ i = e.Next;
+ }
+
+ short index = (short)this.count;
+ this.count++;
+
+ // Insert the new entry:
+ entries[index].Key = key;
+ entries[index].Value = value;
+
+ // Link this new entry into the bucket chain.
+ entries[index].Next = this.buckets[bucket];
+ this.buckets[bucket] = index;
+ return true;
+ }
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ uint key = color.PackedValue;
+ int bucket = (int)(((key >> 16) ^ (key >> 8) ^ key) & 0x1FF);
+ int i = this.buckets[bucket];
+
+ // If the bucket is empty, return immediately.
+ if (i == -1)
+ {
+ value = -1;
+ return false;
+ }
+
+ // Traverse the chain.
+ Entry* entries = this.entries;
+ do
+ {
+ Entry e = entries[i];
+ if (e.Key == key)
+ {
+ value = e.Value;
+ return true;
+ }
+
+ i = e.Next;
+ }
+ while (i != -1);
+
+ value = -1;
+ return false;
+ }
+
+ ///
+ /// Clears the dictionary.
+ ///
+ public void Clear()
+ {
+ Span bucketSpan = this.bucketsOwner.GetSpan();
+ bucketSpan.Fill(-1);
+ this.count = 0;
+ }
+
+ public void Dispose()
+ {
+ this.bucketsHandle.Dispose();
+ this.bucketsOwner.Dispose();
+ this.entriesHandle.Dispose();
+ this.entriesOwner.Dispose();
+ this.buckets = null;
+ this.entries = null;
+ }
+
+ private struct Entry
+ {
+ public uint Key; // The key (packed RGBA)
+ public short Value; // The value; -1 means unused.
+ public short Next; // Index of the next entry in the chain, or -1 if none.
+ }
+}
+
+internal readonly struct NullCache : IColorIndexCache
+{
+ [RequiresPreviewFeatures]
+ public static NullCache Create(MemoryAllocator allocator) => default;
+
+ public bool TryAdd(Rgba32 color, short value) => true;
+
+ public bool TryGetValue(Rgba32 color, out short value)
+ {
+ value = -1;
+ return false;
+ }
+
+ public void Clear()
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
index fe422882bc..f510b102c5 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/OctreeQuantizer{TPixel}.cs
@@ -28,7 +28,7 @@ public struct OctreeQuantizer : IQuantizer
private readonly Octree octree;
private readonly IMemoryOwner paletteOwner;
private ReadOnlyMemory palette;
- private EuclideanPixelMap? pixelMap;
+ private PixelMap? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
@@ -60,38 +60,43 @@ public OctreeQuantizer(Configuration configuration, QuantizerOptions options)
public QuantizerOptions Options { get; }
///
- public readonly ReadOnlyMemory Palette
+ public ReadOnlyMemory Palette
{
get
{
- QuantizerUtilities.CheckPaletteState(in this.palette);
+ if (this.palette.IsEmpty)
+ {
+ this.ResolvePalette();
+ QuantizerUtilities.CheckPaletteState(in this.palette);
+ }
+
return this.palette;
}
}
///
- public void AddPaletteColors(Buffer2DRegion pixelRegion)
+ public readonly void AddPaletteColors(Buffer2DRegion pixelRegion)
{
- using (IMemoryOwner buffer = this.Configuration.MemoryAllocator.Allocate(pixelRegion.Width))
+ using IMemoryOwner buffer = this.Configuration.MemoryAllocator.Allocate(pixelRegion.Width);
+ Span bufferSpan = buffer.GetSpan();
+
+ // Loop through each row
+ for (int y = 0; y < pixelRegion.Height; y++)
{
- Span bufferSpan = buffer.GetSpan();
+ Span row = pixelRegion.DangerousGetRowSpan(y);
+ PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan);
- // Loop through each row
- for (int y = 0; y < pixelRegion.Height; y++)
+ for (int x = 0; x < bufferSpan.Length; x++)
{
- Span row = pixelRegion.DangerousGetRowSpan(y);
- PixelOperations.Instance.ToRgba32(this.Configuration, row, bufferSpan);
-
- for (int x = 0; x < bufferSpan.Length; x++)
- {
- Rgba32 rgba = bufferSpan[x];
-
- // Add the color to the Octree
- this.octree.AddColor(rgba);
- }
+ // Add the color to the Octree
+ this.octree.AddColor(bufferSpan[x]);
}
}
+ }
+ [MemberNotNull(nameof(pixelMap))]
+ private void ResolvePalette()
+ {
int paletteIndex = 0;
Span paletteSpan = this.paletteOwner.GetSpan();
@@ -109,17 +114,7 @@ public void AddPaletteColors(Buffer2DRegion pixelRegion)
this.octree.Palletize(paletteSpan, max, ref paletteIndex);
ReadOnlyMemory result = this.paletteOwner.Memory[..paletteSpan.Length];
- // When called multiple times by QuantizerUtilities.BuildPalette
- // this prevents memory churn caused by reallocation.
- if (this.pixelMap is null)
- {
- this.pixelMap = new EuclideanPixelMap(this.Configuration, result);
- }
- else
- {
- this.pixelMap.Clear(result);
- }
-
+ this.pixelMap = PixelMapFactory.Create(this.Configuration, result, this.Options.ColorMatchingMode);
this.palette = result;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
index 3df80ea9b7..e6984ec98a 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/PaletteQuantizer{TPixel}.cs
@@ -20,7 +20,7 @@ namespace SixLabors.ImageSharp.Processing.Processors.Quantization;
internal readonly struct PaletteQuantizer : IQuantizer
where TPixel : unmanaged, IPixel
{
- private readonly EuclideanPixelMap pixelMap;
+ private readonly PixelMap pixelMap;
///
/// Initializes a new instance of the struct.
@@ -41,7 +41,7 @@ public PaletteQuantizer(
this.Configuration = configuration;
this.Options = options;
- this.pixelMap = new EuclideanPixelMap(configuration, palette, transparentIndex);
+ this.pixelMap = PixelMapFactory.Create(configuration, palette, options.ColorMatchingMode, transparentIndex);
}
///
diff --git a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
index a6bb265a81..da64b26d66 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/QuantizerOptions.cs
@@ -38,4 +38,10 @@ public int MaxColors
get => this.maxColors;
set => this.maxColors = Numerics.Clamp(value, QuantizerConstants.MinColors, QuantizerConstants.MaxColors);
}
+
+ ///
+ /// Gets or sets the color matching mode used for matching pixel values to palette colors.
+ /// Defaults to .
+ ///
+ public ColorMatchingMode ColorMatchingMode { get; set; } = ColorMatchingMode.Coarse;
}
diff --git a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
index f6928c3dd4..5eebb5b6de 100644
--- a/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
+++ b/src/ImageSharp/Processing/Processors/Quantization/WuQuantizer{TPixel}.cs
@@ -75,7 +75,7 @@ internal struct WuQuantizer : IQuantizer
private ReadOnlyMemory palette;
private int maxColors;
private readonly Box[] colorCube;
- private EuclideanPixelMap? pixelMap;
+ private PixelMap? pixelMap;
private readonly bool isDithering;
private bool isDisposed;
@@ -111,35 +111,43 @@ public WuQuantizer(Configuration configuration, QuantizerOptions options)
public QuantizerOptions Options { get; }
///
- public readonly ReadOnlyMemory Palette
+ public ReadOnlyMemory Palette
{
get
{
- QuantizerUtilities.CheckPaletteState(in this.palette);
+ if (this.palette.IsEmpty)
+ {
+ this.ResolvePalette();
+ QuantizerUtilities.CheckPaletteState(in this.palette);
+ }
+
return this.palette;
}
}
///
- public void AddPaletteColors(Buffer2DRegion pixelRegion)
+ public readonly void AddPaletteColors(Buffer2DRegion pixelRegion)
+ => this.Build3DHistogram(pixelRegion);
+
+ ///
+ /// Once all histogram data has been accumulated, this method computes the moments,
+ /// splits the color cube, and resolves the final palette from the accumulated histogram.
+ ///
+ private void ResolvePalette()
{
- // TODO: Something is destroying the existing palette when adding new colors.
- // When the QuantizingImageEncoder.PixelSamplingStrategy is DefaultPixelSamplingStrategy
- // this leads to performance issues + the palette is not preserved.
- // https://github.com/SixLabors/ImageSharp/issues/2498
- this.Build3DHistogram(pixelRegion);
+ // Calculate the cumulative moments from the accumulated histogram.
this.Get3DMoments(this.memoryAllocator);
+
+ // Partition the histogram into color cubes.
this.BuildCube();
- // Slice again since maxColors has been updated since the buffer was created.
+ // Compute the palette colors from the resolved cubes.
Span paletteSpan = this.paletteOwner.GetSpan()[..this.maxColors];
ReadOnlySpan momentsSpan = this.momentsOwner.GetSpan();
for (int k = 0; k < paletteSpan.Length; k++)
{
this.Mark(ref this.colorCube[k], (byte)k);
-
Moment moment = Volume(ref this.colorCube[k], momentsSpan);
-
if (moment.Weight > 0)
{
ref TPixel color = ref paletteSpan[k];
@@ -147,22 +155,14 @@ public void AddPaletteColors(Buffer2DRegion pixelRegion)
}
}
- ReadOnlyMemory result = this.paletteOwner.Memory[..paletteSpan.Length];
- if (this.isDithering)
+ // Update the palette to the new computed colors.
+ this.palette = this.paletteOwner.Memory[..paletteSpan.Length];
+
+ // Create the pixel map if dithering is enabled.
+ if (this.isDithering && this.pixelMap is null)
{
- // When called multiple times by QuantizerUtilities.BuildPalette
- // this prevents memory churn caused by reallocation.
- if (this.pixelMap is null)
- {
- this.pixelMap = new EuclideanPixelMap(this.Configuration, result);
- }
- else
- {
- this.pixelMap.Clear(result);
- }
+ this.pixelMap = PixelMapFactory.Create(this.Configuration, this.palette, this.Options.ColorMatchingMode);
}
-
- this.palette = result;
}
///
@@ -549,7 +549,7 @@ private readonly float Maximize(ref Box cube, int direction, int first, int last
/// The first set.
/// The second set.
/// Returns a value indicating whether the box has been split.
- private bool Cut(ref Box set1, ref Box set2)
+ private readonly bool Cut(ref Box set1, ref Box set2)
{
ReadOnlySpan momentSpan = this.momentsOwner.GetSpan();
Moment whole = Volume(ref set1, momentSpan);
diff --git a/src/ImageSharp/Processing/Processors/Transforms/Linear/LinearTransformUtility.cs b/src/ImageSharp/Processing/Processors/Transforms/Linear/LinearTransformUtility.cs
index b5eb202c18..1f68e32744 100644
--- a/src/ImageSharp/Processing/Processors/Transforms/Linear/LinearTransformUtility.cs
+++ b/src/ImageSharp/Processing/Processors/Transforms/Linear/LinearTransformUtility.cs
@@ -43,7 +43,7 @@ public static float GetSamplingRadius(in TResampler sampler, int sou
/// The .
[MethodImpl(InliningOptions.ShortMethod)]
public static int GetRangeStart(float radius, float center, int min, int max)
- => Numerics.Clamp((int)MathF.Ceiling(center - radius), min, max);
+ => Numerics.Clamp((int)MathF.Floor(center - radius), min, max);
///
/// Gets the end position (inclusive) for a sampling range given
@@ -56,5 +56,5 @@ public static int GetRangeStart(float radius, float center, int min, int max)
/// The .
[MethodImpl(InliningOptions.ShortMethod)]
public static int GetRangeEnd(float radius, float center, int min, int max)
- => Numerics.Clamp((int)MathF.Floor(center + radius), min, max);
+ => Numerics.Clamp((int)MathF.Ceiling(center + radius), min, max);
}
diff --git a/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs b/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs
index 6580636a24..0af2b268a1 100644
--- a/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs
+++ b/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs
@@ -28,15 +28,14 @@ public RotateProcessor(float degrees, Size sourceSize)
/// The source image size
public RotateProcessor(float degrees, IResampler sampler, Size sourceSize)
: this(
- TransformUtils.CreateRotationTransformMatrixDegrees(degrees, sourceSize),
- TransformUtils.CreateRotationBoundsMatrixDegrees(degrees, sourceSize),
+ TransformUtils.CreateRotationTransformMatrixDegrees(degrees, sourceSize, TransformSpace.Pixel),
sampler,
sourceSize)
=> this.Degrees = degrees;
// Helper constructor
- private RotateProcessor(Matrix3x2 rotationMatrix, Matrix3x2 boundsMatrix, IResampler sampler, Size sourceSize)
- : base(rotationMatrix, sampler, TransformUtils.GetTransformedSize(sourceSize, boundsMatrix))
+ private RotateProcessor(Matrix3x2 rotationMatrix, IResampler sampler, Size sourceSize)
+ : base(rotationMatrix, sampler, TransformUtils.GetTransformedSize(rotationMatrix, sourceSize, TransformSpace.Pixel))
{
}
diff --git a/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs b/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs
index 97b18de6c8..0bbc8e0f60 100644
--- a/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs
+++ b/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs
@@ -30,8 +30,7 @@ public SkewProcessor(float degreesX, float degreesY, Size sourceSize)
/// The source image size
public SkewProcessor(float degreesX, float degreesY, IResampler sampler, Size sourceSize)
: this(
- TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, sourceSize),
- TransformUtils.CreateSkewBoundsMatrixDegrees(degreesX, degreesY, sourceSize),
+ TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, sourceSize, TransformSpace.Pixel),
sampler,
sourceSize)
{
@@ -40,8 +39,8 @@ public SkewProcessor(float degreesX, float degreesY, IResampler sampler, Size so
}
// Helper constructor:
- private SkewProcessor(Matrix3x2 skewMatrix, Matrix3x2 boundsMatrix, IResampler sampler, Size sourceSize)
- : base(skewMatrix, sampler, TransformUtils.GetTransformedSize(sourceSize, boundsMatrix))
+ private SkewProcessor(Matrix3x2 skewMatrix, IResampler sampler, Size sourceSize)
+ : base(skewMatrix, sampler, TransformUtils.GetTransformedSize(skewMatrix, sourceSize, TransformSpace.Pixel))
{
}
diff --git a/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs b/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs
index 70112ab5a8..62ea5e830d 100644
--- a/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs
+++ b/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs
@@ -68,6 +68,11 @@ public static bool IsNaN(Matrix4x4 matrix)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector2 ProjectiveTransform2D(float x, float y, Matrix4x4 matrix)
{
+ // The w component (v4.W) resulting from the transformation can be less than 0 in certain cases,
+ // such as when the point is transformed behind the camera in a perspective projection.
+ // However, in many 2D contexts, negative w values are not meaningful and could cause issues
+ // like flipped or distorted projections. To avoid this, we take the max of w and epsilon to ensure
+ // we don't divide by a very small or negative number, effectively treating any negative w as epsilon.
const float epsilon = 0.0000001F;
Vector4 v4 = Vector4.Transform(new Vector4(x, y, 0, 1F), matrix);
return new Vector2(v4.X, v4.Y) / MathF.Max(v4.W, epsilon);
@@ -78,48 +83,22 @@ public static Vector2 ProjectiveTransform2D(float x, float y, Matrix4x4 matrix)
///
/// The amount of rotation, in degrees.
/// The source image size.
+ /// The to use when creating the centered matrix.
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateRotationTransformMatrixDegrees(float degrees, Size size)
- => CreateCenteredTransformMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateRotationDegrees(degrees, PointF.Empty));
+ public static Matrix3x2 CreateRotationTransformMatrixDegrees(float degrees, Size size, TransformSpace transformSpace)
+ => CreateRotationTransformMatrixRadians(GeometryUtilities.DegreeToRadian(degrees), size, transformSpace);
///
/// Creates a centered rotation transform matrix using the given rotation in radians and the source size.
///
/// The amount of rotation, in radians.
/// The source image size.
+ /// The to use when creating the centered matrix.
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateRotationTransformMatrixRadians(float radians, Size size)
- => CreateCenteredTransformMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateRotation(radians, PointF.Empty));
-
- ///
- /// Creates a centered rotation bounds matrix using the given rotation in degrees and the source size.
- ///
- /// The amount of rotation, in degrees.
- /// The source image size.
- /// The .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateRotationBoundsMatrixDegrees(float degrees, Size size)
- => CreateCenteredBoundsMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateRotationDegrees(degrees, PointF.Empty));
-
- ///
- /// Creates a centered rotation bounds matrix using the given rotation in radians and the source size.
- ///
- /// The amount of rotation, in radians.
- /// The source image size.
- /// The .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateRotationBoundsMatrixRadians(float radians, Size size)
- => CreateCenteredBoundsMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateRotation(radians, PointF.Empty));
+ public static Matrix3x2 CreateRotationTransformMatrixRadians(float radians, Size size, TransformSpace transformSpace)
+ => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateRotation(radians, PointF.Empty), size, transformSpace);
///
/// Creates a centered skew transform matrix from the give angles in degrees and the source size.
@@ -127,12 +106,11 @@ public static Matrix3x2 CreateRotationBoundsMatrixRadians(float radians, Size si
/// The X angle, in degrees.
/// The Y angle, in degrees.
/// The source image size.
+ /// The to use when creating the centered matrix.
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateSkewTransformMatrixDegrees(float degreesX, float degreesY, Size size)
- => CreateCenteredTransformMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateSkewDegrees(degreesX, degreesY, PointF.Empty));
+ public static Matrix3x2 CreateSkewTransformMatrixDegrees(float degreesX, float degreesY, Size size, TransformSpace transformSpace)
+ => CreateSkewTransformMatrixRadians(GeometryUtilities.DegreeToRadian(degreesX), GeometryUtilities.DegreeToRadian(degreesY), size, transformSpace);
///
/// Creates a centered skew transform matrix from the give angles in radians and the source size.
@@ -140,81 +118,37 @@ public static Matrix3x2 CreateSkewTransformMatrixDegrees(float degreesX, float d
/// The X angle, in radians.
/// The Y angle, in radians.
/// The source image size.
+ /// The to use when creating the centered matrix.
/// The .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateSkewTransformMatrixRadians(float radiansX, float radiansY, Size size)
- => CreateCenteredTransformMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateSkew(radiansX, radiansY, PointF.Empty));
-
- ///
- /// Creates a centered skew bounds matrix from the give angles in degrees and the source size.
- ///
- /// The X angle, in degrees.
- /// The Y angle, in degrees.
- /// The source image size.
- /// The .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateSkewBoundsMatrixDegrees(float degreesX, float degreesY, Size size)
- => CreateCenteredBoundsMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateSkewDegrees(degreesX, degreesY, PointF.Empty));
-
- ///
- /// Creates a centered skew bounds matrix from the give angles in radians and the source size.
- ///
- /// The X angle, in radians.
- /// The Y angle, in radians.
- /// The source image size.
- /// The .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateSkewBoundsMatrixRadians(float radiansX, float radiansY, Size size)
- => CreateCenteredBoundsMatrix(
- new Rectangle(Point.Empty, size),
- Matrix3x2Extensions.CreateSkew(radiansX, radiansY, PointF.Empty));
+ public static Matrix3x2 CreateSkewTransformMatrixRadians(float radiansX, float radiansY, Size size, TransformSpace transformSpace)
+ => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateSkew(radiansX, radiansY, PointF.Empty), size, transformSpace);
///
/// Gets the centered transform matrix based upon the source rectangle.
///
- /// The source image bounds.
/// The transformation matrix.
+ /// The source image size.
+ ///
+ /// The to use when creating the centered matrix.
+ ///
/// The
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateCenteredTransformMatrix(Rectangle sourceRectangle, Matrix3x2 matrix)
+ public static Matrix3x2 CreateCenteredTransformMatrix(Matrix3x2 matrix, Size size, TransformSpace transformSpace)
{
- Rectangle destinationRectangle = GetTransformedBoundingRectangle(sourceRectangle, matrix);
+ Size transformSize = GetUnboundedTransformedSize(matrix, size, transformSpace);
// We invert the matrix to handle the transformation from screen to world space.
// This ensures scaling matrices are correct.
Matrix3x2.Invert(matrix, out Matrix3x2 inverted);
- // Centered transforms must be 0 based so we offset the bounds width and height.
- Matrix3x2 translationToTargetCenter = Matrix3x2.CreateTranslation(new Vector2(-(destinationRectangle.Width - 1), -(destinationRectangle.Height - 1)) * .5F);
- Matrix3x2 translateToSourceCenter = Matrix3x2.CreateTranslation(new Vector2(sourceRectangle.Width - 1, sourceRectangle.Height - 1) * .5F);
-
- // Translate back to world space.
- Matrix3x2.Invert(translationToTargetCenter * inverted * translateToSourceCenter, out Matrix3x2 centered);
+ // The source size is provided using the coordinate space of the source image.
+ // however the transform should always be applied in the pixel space.
+ // To account for this we offset by the size - 1 to translate to the pixel space.
+ float offset = transformSpace == TransformSpace.Pixel ? 1F : 0F;
- return centered;
- }
-
- ///
- /// Gets the centered bounds matrix based upon the source rectangle.
- ///
- /// The source image bounds.
- /// The transformation matrix.
- /// The
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Matrix3x2 CreateCenteredBoundsMatrix(Rectangle sourceRectangle, Matrix3x2 matrix)
- {
- Rectangle destinationRectangle = GetTransformedBoundingRectangle(sourceRectangle, matrix);
-
- // We invert the matrix to handle the transformation from screen to world space.
- // This ensures scaling matrices are correct.
- Matrix3x2.Invert(matrix, out Matrix3x2 inverted);
-
- Matrix3x2 translationToTargetCenter = Matrix3x2.CreateTranslation(new Vector2(-destinationRectangle.Width, -destinationRectangle.Height) * .5F);
- Matrix3x2 translateToSourceCenter = Matrix3x2.CreateTranslation(new Vector2(sourceRectangle.Width, sourceRectangle.Height) * .5F);
+ Matrix3x2 translationToTargetCenter = Matrix3x2.CreateTranslation(new Vector2(-(transformSize.Width - offset), -(transformSize.Height - offset)) * .5F);
+ Matrix3x2 translateToSourceCenter = Matrix3x2.CreateTranslation(new Vector2(size.Width - offset, size.Height - offset) * .5F);
// Translate back to world space.
Matrix3x2.Invert(translationToTargetCenter * inverted * translateToSourceCenter, out Matrix3x2 centered);
@@ -345,52 +279,100 @@ public static Matrix4x4 CreateTaperMatrix(Size size, TaperSide side, TaperCorner
}
///
- /// Returns the rectangle bounds relative to the source for the given transformation matrix.
+ /// Returns the size relative to the source for the given transformation matrix.
///
- /// The source rectangle.
/// The transformation matrix.
- ///
- /// The .
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Rectangle GetTransformedBoundingRectangle(Rectangle rectangle, Matrix3x2 matrix)
- {
- Rectangle transformed = GetTransformedRectangle(rectangle, matrix);
- return new Rectangle(0, 0, transformed.Width, transformed.Height);
- }
+ /// The source size.
+ /// The to use when calculating the size.
+ /// The .
+ public static Size GetTransformedSize(Matrix3x2 matrix, Size size, TransformSpace transformSpace)
+ => GetTransformedSize(matrix, size, transformSpace, true);
///
- /// Returns the rectangle relative to the source for the given transformation matrix.
+ /// Returns the size relative to the source for the given transformation matrix.
///
- /// The source rectangle.
/// The transformation matrix.
+ /// The source size.
///
- /// The .
+ /// The .
///
- public static Rectangle GetTransformedRectangle(Rectangle rectangle, Matrix3x2 matrix)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Size GetTransformedSize(Matrix4x4 matrix, Size size)
{
- if (rectangle.Equals(default) || Matrix3x2.Identity.Equals(matrix))
+ Guard.IsTrue(size.Width > 0 && size.Height > 0, nameof(size), "Source size dimensions cannot be 0!");
+
+ if (matrix.Equals(default) || matrix.Equals(Matrix4x4.Identity))
{
- return rectangle;
+ return size;
}
- Vector2 tl = Vector2.Transform(new Vector2(rectangle.Left, rectangle.Top), matrix);
- Vector2 tr = Vector2.Transform(new Vector2(rectangle.Right, rectangle.Top), matrix);
- Vector2 bl = Vector2.Transform(new Vector2(rectangle.Left, rectangle.Bottom), matrix);
- Vector2 br = Vector2.Transform(new Vector2(rectangle.Right, rectangle.Bottom), matrix);
+ // Check if the matrix involves only affine transformations by inspecting the relevant components.
+ // We want to use pixel space for calculations only if the transformation is purely 2D and does not include
+ // any perspective effects, non-standard scaling, or unusual translations that could distort the image.
+ // The conditions are as follows:
+ bool usePixelSpace =
+
+ // 1. Ensure there's no perspective distortion:
+ // M34 corresponds to the perspective component. For a purely 2D affine transformation, this should be 0.
+ (matrix.M34 == 0) &&
+
+ // 2. Ensure standard affine transformation without any unusual depth or perspective scaling:
+ // M44 should be 1 for a standard affine transformation. If M44 is not 1, it indicates non-standard depth
+ // scaling or perspective, which suggests a more complex transformation.
+ (matrix.M44 == 1) &&
+
+ // 3. Ensure no unusual translation in the x-direction:
+ // M14 represents translation in the x-direction that might be part of a more complex transformation.
+ // For standard affine transformations, M14 should be 0.
+ (matrix.M14 == 0) &&
- return GetBoundingRectangle(tl, tr, bl, br);
+ // 4. Ensure no unusual translation in the y-direction:
+ // M24 represents translation in the y-direction that might be part of a more complex transformation.
+ // For standard affine transformations, M24 should be 0.
+ (matrix.M24 == 0);
+
+ // Define an offset size to translate between pixel space and coordinate space.
+ // When using pixel space, apply a scaling sensitive offset to translate to discrete pixel coordinates.
+ // When not using pixel space, use SizeF.Empty as the offset.
+
+ // Compute scaling factors from the matrix
+ float scaleX = 1F / new Vector2(matrix.M11, matrix.M21).Length(); // sqrt(M11^2 + M21^2)
+ float scaleY = 1F / new Vector2(matrix.M12, matrix.M22).Length(); // sqrt(M12^2 + M22^2)
+
+ // Apply the offset relative to the scale
+ SizeF offsetSize = usePixelSpace ? new SizeF(scaleX, scaleY) : SizeF.Empty;
+
+ // Subtract the offset size to translate to the appropriate space (pixel or coordinate).
+ if (TryGetTransformedRectangle(new RectangleF(Point.Empty, size - offsetSize), matrix, out Rectangle bounds))
+ {
+ // Add the offset size back to translate the transformed bounds to the correct space.
+ return Size.Ceiling(ConstrainSize(bounds) + offsetSize);
+ }
+
+ return size;
}
///
/// Returns the size relative to the source for the given transformation matrix.
///
+ /// The transformation matrix.
/// The source size.
+ /// The to use when calculating the size.
+ /// The .
+ private static Size GetUnboundedTransformedSize(Matrix3x2 matrix, Size size, TransformSpace transformSpace)
+ => GetTransformedSize(matrix, size, transformSpace, false);
+
+ ///
+ /// Returns the size relative to the source for the given transformation matrix.
+ ///
/// The transformation matrix.
+ /// The source size.
+ /// The to use when calculating the size.
+ /// Whether to constrain the size to ensure that the dimensions are positive.
///
/// The .
///
- public static Size GetTransformedSize(Size size, Matrix3x2 matrix)
+ private static Size GetTransformedSize(Matrix3x2 matrix, Size size, TransformSpace transformSpace, bool constrain)
{
Guard.IsTrue(size.Width > 0 && size.Height > 0, nameof(size), "Source size dimensions cannot be 0!");
@@ -399,9 +381,24 @@ public static Size GetTransformedSize(Size size, Matrix3x2 matrix)
return size;
}
- Rectangle rectangle = GetTransformedRectangle(new Rectangle(Point.Empty, size), matrix);
+ // Define an offset size to translate between coordinate space and pixel space.
+ // Compute scaling factors from the matrix
+ SizeF offsetSize = SizeF.Empty;
+ if (transformSpace == TransformSpace.Pixel)
+ {
+ float scaleX = 1F / new Vector2(matrix.M11, matrix.M21).Length(); // sqrt(M11^2 + M21^2)
+ float scaleY = 1F / new Vector2(matrix.M12, matrix.M22).Length(); // sqrt(M12^2 + M22^2)
+ offsetSize = new(scaleX, scaleY);
+ }
+
+ // Subtract the offset size to translate to the pixel space.
+ if (TryGetTransformedRectangle(new RectangleF(Point.Empty, size - offsetSize), matrix, out Rectangle bounds))
+ {
+ // Add the offset size back to translate the transformed bounds to the coordinate space.
+ return Size.Ceiling((constrain ? ConstrainSize(bounds) : bounds.Size) + offsetSize);
+ }
- return ConstrainSize(rectangle);
+ return size;
}
///
@@ -409,46 +406,52 @@ public static Size GetTransformedSize(Size size, Matrix3x2 matrix)
///
/// The source rectangle.
/// The transformation matrix.
+ /// The resulting bounding rectangle.
///
- /// The .
+ /// if the transformation was successful; otherwise, .
///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Rectangle GetTransformedRectangle(Rectangle rectangle, Matrix4x4 matrix)
+ private static bool TryGetTransformedRectangle(RectangleF rectangle, Matrix3x2 matrix, out Rectangle bounds)
{
- if (rectangle.Equals(default) || Matrix4x4.Identity.Equals(matrix))
+ if (rectangle.Equals(default) || Matrix3x2.Identity.Equals(matrix))
{
- return rectangle;
+ bounds = default;
+ return false;
}
- Vector2 tl = ProjectiveTransform2D(rectangle.Left, rectangle.Top, matrix);
- Vector2 tr = ProjectiveTransform2D(rectangle.Right, rectangle.Top, matrix);
- Vector2 bl = ProjectiveTransform2D(rectangle.Left, rectangle.Bottom, matrix);
- Vector2 br = ProjectiveTransform2D(rectangle.Right, rectangle.Bottom, matrix);
+ Vector2 tl = Vector2.Transform(new Vector2(rectangle.Left, rectangle.Top), matrix);
+ Vector2 tr = Vector2.Transform(new Vector2(rectangle.Right, rectangle.Top), matrix);
+ Vector2 bl = Vector2.Transform(new Vector2(rectangle.Left, rectangle.Bottom), matrix);
+ Vector2 br = Vector2.Transform(new Vector2(rectangle.Right, rectangle.Bottom), matrix);
- return GetBoundingRectangle(tl, tr, bl, br);
+ bounds = GetBoundingRectangle(tl, tr, bl, br);
+ return true;
}
///
- /// Returns the size relative to the source for the given transformation matrix.
+ /// Returns the rectangle relative to the source for the given transformation matrix.
///
- /// The source size.
+ /// The source rectangle.
/// The transformation matrix.
+ /// The resulting bounding rectangle.
///
- /// The .
+ /// if the transformation was successful; otherwise, .
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static Size GetTransformedSize(Size size, Matrix4x4 matrix)
+ private static bool TryGetTransformedRectangle(RectangleF rectangle, Matrix4x4 matrix, out Rectangle bounds)
{
- Guard.IsTrue(size.Width > 0 && size.Height > 0, nameof(size), "Source size dimensions cannot be 0!");
-
- if (matrix.Equals(default) || matrix.Equals(Matrix4x4.Identity))
+ if (rectangle.Equals(default) || Matrix4x4.Identity.Equals(matrix))
{
- return size;
+ bounds = default;
+ return false;
}
- Rectangle rectangle = GetTransformedRectangle(new Rectangle(Point.Empty, size), matrix);
+ Vector2 tl = ProjectiveTransform2D(rectangle.Left, rectangle.Top, matrix);
+ Vector2 tr = ProjectiveTransform2D(rectangle.Right, rectangle.Top, matrix);
+ Vector2 bl = ProjectiveTransform2D(rectangle.Left, rectangle.Bottom, matrix);
+ Vector2 br = ProjectiveTransform2D(rectangle.Right, rectangle.Bottom, matrix);
- return ConstrainSize(rectangle);
+ bounds = GetBoundingRectangle(tl, tr, bl, br);
+ return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -482,6 +485,11 @@ private static Rectangle GetBoundingRectangle(Vector2 tl, Vector2 tr, Vector2 bl
float right = MathF.Max(tl.X, MathF.Max(tr.X, MathF.Max(bl.X, br.X)));
float bottom = MathF.Max(tl.Y, MathF.Max(tr.Y, MathF.Max(bl.Y, br.Y)));
- return Rectangle.Round(RectangleF.FromLTRB(left, top, right, bottom));
+ // Clamp the values to the nearest whole pixel.
+ return Rectangle.FromLTRB(
+ (int)Math.Floor(left),
+ (int)Math.Floor(top),
+ (int)Math.Ceiling(right),
+ (int)Math.Ceiling(bottom));
}
}
diff --git a/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs b/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs
index 0387adebb9..9027ee7266 100644
--- a/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs
+++ b/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs
@@ -12,7 +12,28 @@ namespace SixLabors.ImageSharp.Processing;
public class ProjectiveTransformBuilder
{
private readonly List> transformMatrixFactories = new();
- private readonly List> boundsMatrixFactories = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ProjectiveTransformBuilder()
+ : this(TransformSpace.Pixel)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The to use when applying the projective transform.
+ ///
+ public ProjectiveTransformBuilder(TransformSpace transformSpace)
+ => this.TransformSpace = transformSpace;
+
+ ///
+ /// Gets the to use when applying the projective transform.
+ ///
+ public TransformSpace TransformSpace { get; }
///
/// Prepends a matrix that performs a tapering projective transform.
@@ -22,9 +43,7 @@ public class ProjectiveTransformBuilder
/// The amount to taper.
/// The .
public ProjectiveTransformBuilder PrependTaper(TaperSide side, TaperCorner corner, float fraction)
- => this.Prepend(
- size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction),
- size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction));
+ => this.Prepend(size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction));
///
/// Appends a matrix that performs a tapering projective transform.
@@ -34,9 +53,7 @@ public ProjectiveTransformBuilder PrependTaper(TaperSide side, TaperCorner corne
/// The amount to taper.
/// The .
public ProjectiveTransformBuilder AppendTaper(TaperSide side, TaperCorner corner, float fraction)
- => this.Append(
- size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction),
- size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction));
+ => this.Append(size => TransformUtils.CreateTaperMatrix(size, side, corner, fraction));
///
/// Prepends a centered rotation matrix using the given rotation in degrees.
@@ -52,9 +69,7 @@ public ProjectiveTransformBuilder PrependRotationDegrees(float degrees)
/// The amount of rotation, in radians.
/// The .
public ProjectiveTransformBuilder PrependRotationRadians(float radians)
- => this.Prepend(
- size => new Matrix4x4(TransformUtils.CreateRotationTransformMatrixRadians(radians, size)),
- size => new Matrix4x4(TransformUtils.CreateRotationBoundsMatrixRadians(radians, size)));
+ => this.Prepend(size => new Matrix4x4(TransformUtils.CreateRotationTransformMatrixRadians(radians, size, this.TransformSpace)));
///
/// Prepends a centered rotation matrix using the given rotation in degrees at the given origin.
@@ -88,9 +103,7 @@ public ProjectiveTransformBuilder AppendRotationDegrees(float degrees)
/// The amount of rotation, in radians.
/// The .
public ProjectiveTransformBuilder AppendRotationRadians(float radians)
- => this.Append(
- size => new Matrix4x4(TransformUtils.CreateRotationTransformMatrixRadians(radians, size)),
- size => new Matrix4x4(TransformUtils.CreateRotationBoundsMatrixRadians(radians, size)));
+ => this.Append(size => new Matrix4x4(TransformUtils.CreateRotationTransformMatrixRadians(radians, size, this.TransformSpace)));
///
/// Appends a centered rotation matrix using the given rotation in degrees at the given origin.
@@ -174,9 +187,7 @@ internal ProjectiveTransformBuilder PrependSkewDegrees(float degreesX, float deg
/// The Y angle, in radians.
/// The .
public ProjectiveTransformBuilder PrependSkewRadians(float radiansX, float radiansY)
- => this.Prepend(
- size => new Matrix4x4(TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size)),
- size => new Matrix4x4(TransformUtils.CreateSkewBoundsMatrixRadians(radiansX, radiansY, size)));
+ => this.Prepend(size => new Matrix4x4(TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size, this.TransformSpace)));
///
/// Prepends a skew matrix using the given angles in degrees at the given origin.
@@ -214,9 +225,7 @@ internal ProjectiveTransformBuilder AppendSkewDegrees(float degreesX, float degr
/// The Y angle, in radians.
/// The .
public ProjectiveTransformBuilder AppendSkewRadians(float radiansX, float radiansY)
- => this.Append(
- size => new Matrix4x4(TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size)),
- size => new Matrix4x4(TransformUtils.CreateSkewBoundsMatrixRadians(radiansX, radiansY, size)));
+ => this.Append(size => new Matrix4x4(TransformUtils.CreateSkewTransformMatrixRadians(radiansX, radiansY, size, this.TransformSpace)));
///
/// Appends a skew matrix using the given angles in degrees at the given origin.
@@ -283,7 +292,7 @@ public ProjectiveTransformBuilder AppendTranslation(Vector2 position)
public ProjectiveTransformBuilder PrependMatrix(Matrix4x4 matrix)
{
CheckDegenerate(matrix);
- return this.Prepend(_ => matrix, _ => matrix);
+ return this.Prepend(_ => matrix);
}
///
@@ -299,7 +308,7 @@ public ProjectiveTransformBuilder PrependMatrix(Matrix4x4 matrix)
public ProjectiveTransformBuilder AppendMatrix(Matrix4x4 matrix)
{
CheckDegenerate(matrix);
- return this.Append(_ => matrix, _ => matrix);
+ return this.Append(_ => matrix);
}
///
@@ -357,13 +366,13 @@ public Size GetTransformedSize(Rectangle sourceRectangle)
// Translate the origin matrix to cater for source rectangle offsets.
Matrix4x4 matrix = Matrix4x4.CreateTranslation(new Vector3(-sourceRectangle.Location, 0));
- foreach (Func factory in this.boundsMatrixFactories)
+ foreach (Func factory in this.transformMatrixFactories)
{
matrix *= factory(size);
CheckDegenerate(matrix);
}
- return TransformUtils.GetTransformedSize(size, matrix);
+ return TransformUtils.GetTransformedSize(matrix, size);
}
private static void CheckDegenerate(Matrix4x4 matrix)
@@ -374,17 +383,15 @@ private static void CheckDegenerate(Matrix4x4 matrix)
}
}
- private ProjectiveTransformBuilder Prepend(Func transformFactory, Func boundsFactory)
+ private ProjectiveTransformBuilder Prepend(Func transformFactory)
{
this.transformMatrixFactories.Insert(0, transformFactory);
- this.boundsMatrixFactories.Insert(0, boundsFactory);
return this;
}
- private ProjectiveTransformBuilder Append(Func transformFactory, Func boundsFactory)
+ private ProjectiveTransformBuilder Append(Func transformFactory)
{
this.transformMatrixFactories.Add(transformFactory);
- this.boundsMatrixFactories.Add(boundsFactory);
return this;
}
}
diff --git a/src/ImageSharp/Processing/TransformSpace.cs b/src/ImageSharp/Processing/TransformSpace.cs
new file mode 100644
index 0000000000..bca676bd88
--- /dev/null
+++ b/src/ImageSharp/Processing/TransformSpace.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Processing;
+
+///
+/// Represents the different spaces used in transformation operations.
+///
+public enum TransformSpace
+{
+ ///
+ /// Coordinate space is a continuous, mathematical grid where objects and positions
+ /// are defined with precise, often fractional values. This space allows for fine-grained
+ /// transformations like scaling, rotation, and translation with high precision.
+ /// In coordinate space, an image can span from (0,0) to (4,4) for a 4x4 image, including the boundaries.
+ ///
+ Coordinate,
+
+ ///
+ /// Pixel space is a discrete grid where each position corresponds to a specific pixel on the screen.
+ /// In this space, positions are defined by whole numbers, with no fractional values.
+ /// A 4x4 image in pixel space covers exactly 4 pixels wide and 4 pixels tall, ranging from (0,0) to (3,3).
+ /// Pixel space is used when rendering images to ensure that everything aligns with the actual pixels on the screen.
+ ///
+ Pixel
+}
diff --git a/src/ImageSharp/Properties/AssemblyInfo.cs b/src/ImageSharp/Properties/AssemblyInfo.cs
deleted file mode 100644
index 334737ac17..0000000000
--- a/src/ImageSharp/Properties/AssemblyInfo.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-// Redundant suppressing of SA1413 for Rider.
-[assembly:
- System.Diagnostics.CodeAnalysis.SuppressMessage(
- "StyleCop.CSharp.MaintainabilityRules",
- "SA1413:UseTrailingCommasInMultiLineInitializers",
- Justification = "Follows SixLabors.ruleset")]
diff --git a/tests/ImageSharp.Benchmarks/Codecs/Gif/DecodeEncodeGif.cs b/tests/ImageSharp.Benchmarks/Codecs/Gif/DecodeEncodeGif.cs
new file mode 100644
index 0000000000..06b07c3187
--- /dev/null
+++ b/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 Stream 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 })
+ };
+}
diff --git a/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs b/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
index 048c2aadda..d11cb3218b 100644
--- a/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
+++ b/tests/ImageSharp.Benchmarks/Codecs/Gif/EncodeGif.cs
@@ -12,21 +12,16 @@
namespace SixLabors.ImageSharp.Benchmarks.Codecs;
-[Config(typeof(Config.ShortMultiFramework))]
-public class EncodeGif
+public abstract class EncodeGif
{
// System.Drawing needs this.
private Stream bmpStream;
private SDImage bmpDrawing;
private Image bmpCore;
- // Try to get as close to System.Drawing's output as possible
- private readonly GifEncoder encoder = new GifEncoder
- {
- Quantizer = new WebSafePaletteQuantizer(new QuantizerOptions { Dither = KnownDitherings.Bayer4x4 })
- };
+ 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]
@@ -61,6 +56,19 @@ public void GifSystemDrawing()
public void GifImageSharp()
{
using var memoryStream = new MemoryStream();
- this.bmpCore.SaveAsGif(memoryStream, this.encoder);
+ this.bmpCore.SaveAsGif(memoryStream, this.Encoder);
}
}
+
+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 })
+ };
+}
diff --git a/tests/ImageSharp.Benchmarks/ImageSharp.Benchmarks.csproj b/tests/ImageSharp.Benchmarks/ImageSharp.Benchmarks.csproj
index 0ba2f4b949..8315dfa4b5 100644
--- a/tests/ImageSharp.Benchmarks/ImageSharp.Benchmarks.csproj
+++ b/tests/ImageSharp.Benchmarks/ImageSharp.Benchmarks.csproj
@@ -11,7 +11,7 @@
false
Debug;Release
-
+
diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
index f4e6487a57..dcce56e881 100644
--- a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs
@@ -34,6 +34,41 @@ public void Decode_VerifyAllFrames(TestImageProvider provider)
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
+ [Theory]
+ [WithFile(TestImages.Gif.AnimatedLoop, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedLoopInterlaced, PixelTypes.Rgba32)]
+ public void Decode_Animated(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSaveMultiFrame(provider);
+ image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
+ }
+
+ [Theory]
+ [WithFile(TestImages.Gif.AnimatedTransparentNoRestore, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentRestorePrevious, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentLoop, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.AnimatedTransparentFirstFrameRestorePrev, PixelTypes.Rgba32)]
+ public void Decode_Animated_WithTransparency(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSaveMultiFrame(provider);
+ image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
+ }
+
+ [Theory]
+ [WithFile(TestImages.Gif.StaticNontransparent, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.StaticTransparent, PixelTypes.Rgba32)]
+ public void Decode_Static_No_Animation(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSave(provider);
+ image.CompareFirstFrameToReferenceOutput(ImageComparer.Exact, provider);
+ }
+
[Theory]
[WithFile(TestImages.Gif.Issues.Issue2450_A, PixelTypes.Rgba32)]
[WithFile(TestImages.Gif.Issues.Issue2450_B, PixelTypes.Rgba32)]
@@ -334,4 +369,16 @@ public void IssueTooLargeLzwBits(TestImageProvider provider)
image.DebugSaveMultiFrame(provider);
image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
}
+
+ // https://github.com/SixLabors/ImageSharp/issues/2859
+ [Theory]
+ [WithFile(TestImages.Gif.Issues.Issue2859_A, PixelTypes.Rgba32)]
+ [WithFile(TestImages.Gif.Issues.Issue2859_B, PixelTypes.Rgba32)]
+ public void Issue2859_LZWPixelStackOverflow(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ image.DebugSaveMultiFrame(provider);
+ image.CompareToReferenceOutputMultiFrame(provider, ImageComparer.Exact);
+ }
}
diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
index a7e16f7737..dcbd4b38eb 100644
--- a/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Gif/GifEncoderTests.cs
@@ -382,4 +382,21 @@ public void Encode_Animated_VisualTest(TestImageProvider provide
provider.Utility.SaveTestOutputFile(image, "png", new PngEncoder(), "animated");
provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated");
}
+
+ [Theory]
+ [WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32)]
+ public void GifEncoder_CanDecode_AndEncode_Issue2866(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+
+ // Save the image for visual inspection.
+ provider.Utility.SaveTestOutputFile(image, "gif", new GifEncoder(), "animated");
+
+ // Now compare the debug output with the reference output.
+ // We do this because the gif encoding is lossy and encoding will lead to differences in the 10s of percent.
+ // From the unencoded image, we can see that the image is visually the same.
+ static bool Predicate(int i, int _) => i % 8 == 0; // Image has many frames, only compare a selection of them.
+ image.CompareDebugOutputToReferenceOutputMultiFrame(provider, ImageComparer.Exact, extension: "gif", predicate: Predicate);
+ }
}
diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs
index e68dd1f879..bd2acaff1c 100644
--- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs
+++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs
@@ -452,6 +452,45 @@ public void Issue2758_DecodeWorks(TestImageProvider provider)
image.Save(ms, new JpegEncoder());
}
+ // https://github.com/SixLabors/ImageSharp/issues/2857
+ [Theory]
+ [WithFile(TestImages.Jpeg.Issues.Issue2857, PixelTypes.Rgb24)]
+ public void Issue2857_SubSubIfds(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage(JpegDecoder.Instance);
+
+ Assert.Equal(5616, image.Width);
+ Assert.Equal(3744, image.Height);
+
+ JpegMetadata meta = image.Metadata.GetJpegMetadata();
+ Assert.Equal(92, meta.LuminanceQuality);
+ Assert.Equal(93, meta.ChrominanceQuality);
+
+ ExifProfile exifProfile = image.Metadata.ExifProfile;
+ Assert.NotNull(exifProfile);
+
+ using MemoryStream ms = new();
+ bool hasThumbnail = exifProfile.TryCreateThumbnail(out _);
+ Assert.False(hasThumbnail);
+
+ Assert.Equal("BilderBox - Erwin Wodicka / wodicka@aon.at", exifProfile.GetValue(ExifTag.Copyright).Value);
+ Assert.Equal("Adobe Photoshop CS3 Windows", exifProfile.GetValue(ExifTag.Software).Value);
+
+ Assert.Equal("Carers; seniors; caregiver; senior care; retirement home; hands; old; elderly; elderly caregiver; elder care; elderly care; geriatric care; nursing home; age; old age care; outpatient; needy; health care; home nurse; home care; sick; retirement; medical; mobile; the elderly; nursing department; nursing treatment; nursing; care services; nursing services; nursing care; nursing allowance; nursing homes; home nursing; care category; nursing class; care; nursing shortage; nursing patient care staff\0", exifProfile.GetValue(ExifTag.XPKeywords).Value);
+
+ Assert.Equal(
+ new EncodedString(EncodedString.CharacterCode.ASCII, "StockSubmitter|Miscellaneous||Miscellaneous$|00|0000330000000110000000000000000|22$@NA_1005010.460@145$$@Miscellaneous.Miscellaneous$$@$@26$$@$@$@$@205$@$@$@$@$@$@$@$@$@43$@$@$@$$@Miscellaneous.Miscellaneous$$@90$$@22$@$@$@$@$@$@$|||"),
+ exifProfile.GetValue(ExifTag.UserComment).Value);
+
+ // the profile contains 4 duplicated UserComment
+ Assert.Equal(1, exifProfile.Values.Count(t => t.Tag == ExifTag.UserComment));
+
+ image.Mutate(x => x.Crop(new(0, 0, 100, 100)));
+
+ image.Save(ms, new JpegEncoder());
+ }
+
private static void VerifyEncodedStrings(ExifProfile exif)
{
Assert.NotNull(exif);
diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs
index 2fe4282607..97be5d8383 100644
--- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs
@@ -67,11 +67,11 @@ private static bool SkipTest(ITestImageProvider provider)
public void ParseStream_BasicPropertiesAreCorrect()
{
JpegDecoderOptions options = new();
+ Configuration configuration = options.GeneralOptions.Configuration;
byte[] bytes = TestFile.Create(TestImages.Jpeg.Progressive.Progress).Bytes;
using MemoryStream ms = new(bytes);
- using BufferedReadStream bufferedStream = new(Configuration.Default, ms);
using JpegDecoderCore decoder = new(options);
- using Image image = decoder.Decode(bufferedStream, cancellationToken: default);
+ using Image image = decoder.Decode(configuration, ms, cancellationToken: default);
// I don't know why these numbers are different. All I know is that the decoder works
// and spectral data is exactly correct also.
diff --git a/tests/ImageSharp.Tests/Formats/Jpg/Utils/JpegFixture.cs b/tests/ImageSharp.Tests/Formats/Jpg/Utils/JpegFixture.cs
index 978978989e..a3fbe4018e 100644
--- a/tests/ImageSharp.Tests/Formats/Jpg/Utils/JpegFixture.cs
+++ b/tests/ImageSharp.Tests/Formats/Jpg/Utils/JpegFixture.cs
@@ -5,7 +5,6 @@
using System.Text;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Jpeg.Components;
-using SixLabors.ImageSharp.IO;
using SixLabors.ImageSharp.PixelFormats;
using Xunit.Abstractions;
@@ -216,18 +215,17 @@ internal static bool CompareBlocks(Span a, Span b, float tolerance
internal static JpegDecoderCore ParseJpegStream(string testFileName, bool metaDataOnly = false)
{
byte[] bytes = TestFile.Create(testFileName).Bytes;
- using var ms = new MemoryStream(bytes);
- using var bufferedStream = new BufferedReadStream(Configuration.Default, ms);
-
- JpegDecoderOptions options = new();
- var decoder = new JpegDecoderCore(options);
+ using MemoryStream ms = new(bytes);
+ JpegDecoderOptions decoderOptions = new();
+ Configuration configuration = decoderOptions.GeneralOptions.Configuration;
+ JpegDecoderCore decoder = new(decoderOptions);
if (metaDataOnly)
{
- decoder.Identify(bufferedStream, cancellationToken: default);
+ decoder.Identify(configuration, ms, default);
}
else
{
- using Image image = decoder.Decode(bufferedStream, cancellationToken: default);
+ using Image image = decoder.Decode(configuration, ms, default);
}
return decoder;
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
index 11af57e39f..5a5dd9aaa7 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs
@@ -705,10 +705,20 @@ public void Decode_BadPalette(string file)
[Theory]
[WithFile(TestImages.Png.Issue2752, PixelTypes.Rgba32)]
public void CanDecodeJustOneFrame(TestImageProvider provider)
- where TPixel : unmanaged, IPixel
+ where TPixel : unmanaged, IPixel
{
DecoderOptions options = new() { MaxFrames = 1 };
using Image image = provider.GetImage(PngDecoder.Instance, options);
Assert.Equal(1, image.Frames.Count);
}
+
+ [Theory]
+ [WithFile(TestImages.Png.Issue2924, PixelTypes.Rgba32)]
+ public void CanDecode_Issue2924(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage(PngDecoder.Instance);
+ image.DebugSave(provider);
+ image.CompareToReferenceOutput(provider);
+ }
}
diff --git a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
index ca5aae961c..c94413cb69 100644
--- a/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Png/PngEncoderTests.cs
@@ -449,6 +449,7 @@ public void Encode_WithPngTransparentColorBehaviorClear_Works(PngColorType color
[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(TestImageProvider provider)
where TPixel : unmanaged, IPixel
{
@@ -484,8 +485,9 @@ public void Encode_APng(TestImageProvider provider)
}
[Theory]
- [WithFile(TestImages.Gif.Leo, PixelTypes.Rgba32)]
- public void Encode_AnimatedFormatTransform_FromGif(TestImageProvider provider)
+ [WithFile(TestImages.Gif.Leo, PixelTypes.Rgba32, 0.7921F)]
+ [WithFile(TestImages.Gif.Issues.Issue2866, PixelTypes.Rgba32, 1.06F)]
+ public void Encode_AnimatedFormatTransform_FromGif(TestImageProvider provider, float percentage)
where TPixel : unmanaged, IPixel
{
if (TestEnvironment.RunsOnCI && !TestEnvironment.IsWindows)
@@ -494,17 +496,18 @@ public void Encode_AnimatedFormatTransform_FromGif(TestImageProvider image = provider.GetImage(GifDecoder.Instance);
-
using MemoryStream memStream = new();
image.Save(memStream, PngEncoder);
memStream.Position = 0;
+ image.DebugSave(provider: provider, extension: "png", encoder: PngEncoder);
+
using Image output = Image.Load(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);
+ // The image has been visually checked but the coarse cache used by the palette quantizer
+ // can lead to minor differences between frames.
+ ImageComparer.TolerantPercentage(percentage).VerifySimilarity(output, image);
GifMetadata gif = image.Metadata.GetGifMetadata();
PngMetadata png = output.Metadata.GetPngMetadata();
@@ -699,6 +702,39 @@ public void Issue2668_Quantized_Encode_Alpha(TestImageProvider p
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)).ToArray();
+
+ using Image 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];
+ }
+ }
+
+ using MemoryStream ms = new();
+ PaletteQuantizer quantizer = new(
+ palette.Select(Color.FromPixel).ToArray(),
+ new QuantizerOptions() { ColorMatchingMode = ColorMatchingMode.Hybrid });
+
+ image.Save(ms, new PngEncoder
+ {
+ ColorType = PngColorType.Palette,
+ BitDepth = PngBitDepth.Bit8,
+ Quantizer = quantizer
+ });
+
+ ms.Position = 0;
+
+ using Image encoded = Image.Load(ms);
+ ImageComparer.Exact.VerifySimilarity(image, encoded);
+ }
+
private static void TestPngEncoderCore(
TestImageProvider provider,
PngColorType pngColorType,
diff --git a/tests/ImageSharp.Tests/Formats/Tiff/Compression/DeflateTiffCompressionTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/Compression/DeflateTiffCompressionTests.cs
index 1b12adac23..b16119f338 100644
--- a/tests/ImageSharp.Tests/Formats/Tiff/Compression/DeflateTiffCompressionTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Tiff/Compression/DeflateTiffCompressionTests.cs
@@ -23,7 +23,7 @@ public void Compress_Decompress_Roundtrip_Works(byte[] data)
using BufferedReadStream stream = CreateCompressedStream(data);
byte[] buffer = new byte[data.Length];
- using var decompressor = new DeflateTiffCompression(Configuration.Default.MemoryAllocator, 10, 8, TiffColorType.BlackIsZero8, TiffPredictor.None, false);
+ using var decompressor = new DeflateTiffCompression(Configuration.Default.MemoryAllocator, 10, 8, TiffColorType.BlackIsZero8, TiffPredictor.None, false, false, 0, 0);
decompressor.Decompress(stream, 0, (uint)stream.Length, 1, buffer, default);
diff --git a/tests/ImageSharp.Tests/Formats/Tiff/Compression/LzwTiffCompressionTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/Compression/LzwTiffCompressionTests.cs
index 635a3a33e4..8c21e346af 100644
--- a/tests/ImageSharp.Tests/Formats/Tiff/Compression/LzwTiffCompressionTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Tiff/Compression/LzwTiffCompressionTests.cs
@@ -37,7 +37,7 @@ public void Compress_Decompress_Roundtrip_Works(byte[] data)
using BufferedReadStream stream = CreateCompressedStream(data);
byte[] buffer = new byte[data.Length];
- using var decompressor = new LzwTiffCompression(Configuration.Default.MemoryAllocator, 10, 8, TiffColorType.BlackIsZero8, TiffPredictor.None, false);
+ using var decompressor = new LzwTiffCompression(Configuration.Default.MemoryAllocator, 10, 8, TiffColorType.BlackIsZero8, TiffPredictor.None, false, false, 0, 0);
decompressor.Decompress(stream, 0, (uint)stream.Length, 1, buffer, default);
Assert.Equal(data, buffer);
diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs
index ab49805a35..839334449d 100644
--- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs
@@ -91,6 +91,40 @@ public void TiffDecoder_CanDecode_Planar(TestImageProvider provi
public void TiffDecoder_CanDecode_Tiled(TestImageProvider provider)
where TPixel : unmanaged, IPixel => TestTiffDecoder(provider);
+ [Theory]
+ [WithFile(TiledRgbaDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgbDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGrayDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray16BitLittleEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray16BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray32BitLittleEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray32BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb48BitLittleEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb48BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgba64BitLittleEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgba64BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb96BitLittleEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb96BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ public void TiffDecoder_CanDecode_Tiled_Deflate_Compressed(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel => TestTiffDecoder(provider);
+
+ [Theory]
+ [WithFile(TiledRgbaLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgbLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGrayLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray16BitLittleEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray16BitBigEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray32BitLittleEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledGray32BitBigEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb48BitLittleEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb48BitBigEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgba64BitLittleEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgba64BitBigEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb96BitLittleEndianLzwCompressedWithPredictor, PixelTypes.Rgba32)]
+ [WithFile(TiledRgb96BitBigEndianDeflateCompressedWithPredictor, PixelTypes.Rgba32)]
+ public void TiffDecoder_CanDecode_Tiled_Lzw_Compressed(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel => TestTiffDecoder(provider);
+
[Theory]
[WithFile(Rgba8BitPlanarUnassociatedAlpha, PixelTypes.Rgba32)]
public void TiffDecoder_CanDecode_Planar_32Bit(TestImageProvider provider)
diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs
index 1fafb4cd04..86d518e902 100644
--- a/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffEncoderTests.cs
@@ -518,6 +518,16 @@ public void TiffEncoder_EncodeBiColor_WithModifiedHuffmanCompression_WhiteIsZero
public void TiffEncoder_EncodeBiColor_WithModifiedHuffmanCompression_BlackIsZero_Works(TestImageProvider provider)
where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit1, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.Ccitt1D);
+ [Theory]
+ [WithFile(Issue2909, PixelTypes.Rgba32)]
+ public void TiffEncoder_WithLzwCompression_Works(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit24, null, TiffCompression.Lzw, imageDecoder: TiffDecoder.Instance);
+
+ [Theory]
+ [WithFile(Issue2909, PixelTypes.Rgba32)]
+ public void TiffEncoder_WithDeflateCompression_Works(TestImageProvider provider)
+ where TPixel : unmanaged, IPixel => TestTiffEncoderCore(provider, TiffBitsPerPixel.Bit24, null, TiffCompression.Deflate, imageDecoder: TiffDecoder.Instance);
+
[Theory]
[WithFile(GrayscaleUncompressed, PixelTypes.L8, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.PackBits)]
[WithFile(GrayscaleUncompressed16Bit, PixelTypes.L16, TiffPhotometricInterpretation.BlackIsZero, TiffCompression.PackBits)]
diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs
index a3fe028db5..1491cd13cf 100644
--- a/tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs
+++ b/tests/ImageSharp.Tests/Formats/WebP/WebpCommonUtilsTests.cs
@@ -106,7 +106,7 @@ private static void RunCheckNoneOpaqueWithNoneOpaquePixelsTest()
174, 183, 189, 255,
148, 158, 158, 255,
};
- Span row = MemoryMarshal.Cast(rowBytes);
+ ReadOnlySpan row = MemoryMarshal.Cast(rowBytes);
bool noneOpaque;
for (int length = 8; length < row.Length; length += 8)
@@ -188,7 +188,7 @@ private static void RunCheckNoneOpaqueWithOpaquePixelsTest()
174, 183, 189, 255,
148, 158, 158, 255,
};
- Span row = MemoryMarshal.Cast(rowBytes);
+ ReadOnlySpan row = MemoryMarshal.Cast(rowBytes);
bool noneOpaque;
for (int length = 8; length < row.Length; length += 8)
diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs
index 0dda304b64..d81cd20849 100644
--- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs
+++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs
@@ -450,6 +450,22 @@ public void WebpDecoder_CanDecode_Issue2670(TestImageProvider pr
image.CompareToOriginal(provider, ReferenceDecoder);
}
+ // https://github.com/SixLabors/ImageSharp/issues/2866
+ [Theory]
+ [WithFile(Lossy.Issue2866, PixelTypes.Rgba32)]
+ public void WebpDecoder_CanDecode_Issue2866