From e1555fd4ba6a972b7b0121b22bf502ebb49a9606 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 5 Aug 2024 23:46:53 +1000 Subject: [PATCH] Fix transform bounds calculations --- .../Processing/AffineTransformBuilder.cs | 38 +-- .../Linear/LinearTransformUtility.cs | 4 +- .../Transforms/Linear/RotateProcessor.cs | 5 +- .../Transforms/Linear/SkewProcessor.cs | 5 +- .../Processors/Transforms/TransformUtils.cs | 274 +++++++++--------- .../Processing/ProjectiveTransformBuilder.cs | 39 +-- .../Transforms/AffineTransformTests.cs | 18 ++ .../Transforms/ProjectiveTransformTests.cs | 10 +- 8 files changed, 191 insertions(+), 202 deletions(-) diff --git a/src/ImageSharp/Processing/AffineTransformBuilder.cs b/src/ImageSharp/Processing/AffineTransformBuilder.cs index 59264698bd..e8c628ff12 100644 --- a/src/ImageSharp/Processing/AffineTransformBuilder.cs +++ b/src/ImageSharp/Processing/AffineTransformBuilder.cs @@ -12,7 +12,6 @@ namespace SixLabors.ImageSharp.Processing; public class AffineTransformBuilder { private readonly List> transformMatrixFactories = new(); - private readonly List> boundsMatrixFactories = new(); /// /// Prepends a rotation matrix using the given rotation angle in degrees @@ -31,8 +30,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)); /// /// Prepends a rotation matrix using the given rotation in degrees at the given origin. @@ -68,9 +66,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)); /// /// Appends a rotation matrix using the given rotation in degrees at the given origin. @@ -145,9 +141,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.Prepend(size => TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, size)); /// /// Prepends a centered skew matrix from the give angles in radians. @@ -156,9 +150,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)); /// /// Prepends a skew matrix using the given angles in degrees at the given origin. @@ -187,9 +179,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.Append(size => TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, size)); /// /// Appends a centered skew matrix from the give angles in radians. @@ -198,9 +188,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)); /// /// Appends a skew matrix using the given angles in degrees at the given origin. @@ -267,7 +255,7 @@ public AffineTransformBuilder AppendTranslation(Vector2 position) public AffineTransformBuilder PrependMatrix(Matrix3x2 matrix) { CheckDegenerate(matrix); - return this.Prepend(_ => matrix, _ => matrix); + return this.Prepend(_ => matrix); } /// @@ -283,7 +271,7 @@ public AffineTransformBuilder PrependMatrix(Matrix3x2 matrix) public AffineTransformBuilder AppendMatrix(Matrix3x2 matrix) { CheckDegenerate(matrix); - return this.Append(_ => matrix, _ => matrix); + return this.Append(_ => matrix); } /// @@ -340,13 +328,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); } private static void CheckDegenerate(Matrix3x2 matrix) @@ -357,17 +345,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/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..aee7fd7816 100644 --- a/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs +++ b/src/ImageSharp/Processing/Processors/Transforms/Linear/RotateProcessor.cs @@ -29,14 +29,13 @@ public RotateProcessor(float degrees, Size sourceSize) public RotateProcessor(float degrees, IResampler sampler, Size sourceSize) : this( TransformUtils.CreateRotationTransformMatrixDegrees(degrees, sourceSize), - TransformUtils.CreateRotationBoundsMatrixDegrees(degrees, sourceSize), 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)) { } diff --git a/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs b/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs index 97b18de6c8..085d2bbec1 100644 --- a/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs +++ b/src/ImageSharp/Processing/Processors/Transforms/Linear/SkewProcessor.cs @@ -31,7 +31,6 @@ public SkewProcessor(float degreesX, float degreesY, Size sourceSize) public SkewProcessor(float degreesX, float degreesY, IResampler sampler, Size sourceSize) : this( TransformUtils.CreateSkewTransformMatrixDegrees(degreesX, degreesY, sourceSize), - TransformUtils.CreateSkewBoundsMatrixDegrees(degreesX, degreesY, sourceSize), 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)) { } diff --git a/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs b/src/ImageSharp/Processing/Processors/Transforms/TransformUtils.cs index 70112ab5a8..787e7fc3e3 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); @@ -81,9 +86,7 @@ public static Vector2 ProjectiveTransform2D(float x, float y, Matrix4x4 matrix) /// The . [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Matrix3x2 CreateRotationTransformMatrixDegrees(float degrees, Size size) - => CreateCenteredTransformMatrix( - new Rectangle(Point.Empty, size), - Matrix3x2Extensions.CreateRotationDegrees(degrees, PointF.Empty)); + => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateRotationDegrees(degrees, PointF.Empty), size); /// /// Creates a centered rotation transform matrix using the given rotation in radians and the source size. @@ -93,33 +96,7 @@ public static Matrix3x2 CreateRotationTransformMatrixDegrees(float degrees, Size /// 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)); + => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateRotation(radians, PointF.Empty), size); /// /// Creates a centered skew transform matrix from the give angles in degrees and the source size. @@ -130,9 +107,7 @@ public static Matrix3x2 CreateRotationBoundsMatrixRadians(float radians, Size si /// 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)); + => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateSkewDegrees(degreesX, degreesY, PointF.Empty), size); /// /// Creates a centered skew transform matrix from the give angles in radians and the source size. @@ -143,78 +118,28 @@ public static Matrix3x2 CreateSkewTransformMatrixDegrees(float degreesX, float d /// 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)); + => CreateCenteredTransformMatrix(Matrix3x2Extensions.CreateSkew(radiansX, radiansY, PointF.Empty), size); /// /// Gets the centered transform matrix based upon the source rectangle. /// - /// The source image bounds. - /// The transformation matrix. - /// The - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Matrix3x2 CreateCenteredTransformMatrix(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); - - // 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); - - return centered; - } - - /// - /// Gets the centered bounds matrix based upon the source rectangle. - /// - /// The source image bounds. /// The transformation matrix. + /// The source image size. /// The [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Matrix3x2 CreateCenteredBoundsMatrix(Rectangle sourceRectangle, Matrix3x2 matrix) + public static Matrix3x2 CreateCenteredTransformMatrix(Matrix3x2 matrix, Size size) { - Rectangle destinationRectangle = GetTransformedBoundingRectangle(sourceRectangle, matrix); + Size destinationSize = GetUnboundedTransformedSize(matrix, size); // 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); + // 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. + Matrix3x2 translationToTargetCenter = Matrix3x2.CreateTranslation(new Vector2(-(destinationSize.Width - 1), -(destinationSize.Height - 1)) * .5F); + Matrix3x2 translateToSourceCenter = Matrix3x2.CreateTranslation(new Vector2(size.Width - 1, size.Height - 1) * .5F); // Translate back to world space. Matrix3x2.Invert(translationToTargetCenter * inverted * translateToSourceCenter, out Matrix3x2 centered); @@ -236,6 +161,12 @@ public static Matrix4x4 CreateTaperMatrix(Size size, TaperSide side, TaperCorner { Matrix4x4 matrix = Matrix4x4.Identity; + // The source size is provided using the Coordinate/Geometric space of the source image. + // However, the transform should always be applied in the Discrete/Pixel space to ensure + // that the transformation fully encompasses all pixels without clipping at the edges. + // To account for this, we subtract [1,1] from the size to translate to the Discrete/Pixel space. + // size -= new Size(1, 1); + /* * SkMatrix is laid out in the following manner: * @@ -345,52 +276,101 @@ 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 source size. /// - /// The . + /// 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); - } + public static Size GetTransformedSize(Matrix3x2 matrix, Size size) + => GetTransformedSize(matrix, size, 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 . + /// + private static Size GetUnboundedTransformedSize(Matrix3x2 matrix, Size size) + => GetTransformedSize(matrix, size, false); + + /// + /// Returns the size relative to the source for the given transformation matrix. + /// /// The transformation matrix. + /// The source 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, bool constrain) { Guard.IsTrue(size.Width > 0 && size.Height > 0, nameof(size), "Source size dimensions cannot be 0!"); @@ -399,9 +379,20 @@ 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 + 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) + SizeF 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 +400,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 +479,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..06e6f0e71a 100644 --- a/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs +++ b/src/ImageSharp/Processing/ProjectiveTransformBuilder.cs @@ -12,7 +12,6 @@ namespace SixLabors.ImageSharp.Processing; public class ProjectiveTransformBuilder { private readonly List> transformMatrixFactories = new(); - private readonly List> boundsMatrixFactories = new(); /// /// Prepends a matrix that performs a tapering projective transform. @@ -22,9 +21,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 +31,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 +47,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))); /// /// Prepends a centered rotation matrix using the given rotation in degrees at the given origin. @@ -88,9 +81,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))); /// /// Appends a centered rotation matrix using the given rotation in degrees at the given origin. @@ -174,9 +165,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))); /// /// Prepends a skew matrix using the given angles in degrees at the given origin. @@ -214,9 +203,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))); /// /// Appends a skew matrix using the given angles in degrees at the given origin. @@ -283,7 +270,7 @@ public ProjectiveTransformBuilder AppendTranslation(Vector2 position) public ProjectiveTransformBuilder PrependMatrix(Matrix4x4 matrix) { CheckDegenerate(matrix); - return this.Prepend(_ => matrix, _ => matrix); + return this.Prepend(_ => matrix); } /// @@ -299,7 +286,7 @@ public ProjectiveTransformBuilder PrependMatrix(Matrix4x4 matrix) public ProjectiveTransformBuilder AppendMatrix(Matrix4x4 matrix) { CheckDegenerate(matrix); - return this.Append(_ => matrix, _ => matrix); + return this.Append(_ => matrix); } /// @@ -357,13 +344,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 +361,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/tests/ImageSharp.Tests/Processing/Processors/Transforms/AffineTransformTests.cs b/tests/ImageSharp.Tests/Processing/Processors/Transforms/AffineTransformTests.cs index 895cf60fd3..05604ac6e6 100644 --- a/tests/ImageSharp.Tests/Processing/Processors/Transforms/AffineTransformTests.cs +++ b/tests/ImageSharp.Tests/Processing/Processors/Transforms/AffineTransformTests.cs @@ -231,6 +231,24 @@ public void Issue1911() Assert.Equal(100, image.Height); } + [Theory] + [WithSolidFilledImages(4, 4, nameof(Color.Red), PixelTypes.Rgba32)] + public void Issue2753(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(); + + AffineTransformBuilder builder = + new AffineTransformBuilder().AppendRotationDegrees(270, new Vector2(3.5f, 3.5f)); + image.Mutate(x => x.BackgroundColor(Color.Red)); + image.Mutate(x => x = x.Transform(builder)); + + image.DebugSave(provider); + + Assert.Equal(4, image.Width); + Assert.Equal(8, image.Height); + } + [Theory] [WithTestPatternImages(100, 100, PixelTypes.Rgba32)] public void Identity(TestImageProvider provider) diff --git a/tests/ImageSharp.Tests/Processing/Transforms/ProjectiveTransformTests.cs b/tests/ImageSharp.Tests/Processing/Transforms/ProjectiveTransformTests.cs index 21eda034ea..128df01a06 100644 --- a/tests/ImageSharp.Tests/Processing/Transforms/ProjectiveTransformTests.cs +++ b/tests/ImageSharp.Tests/Processing/Transforms/ProjectiveTransformTests.cs @@ -128,11 +128,11 @@ public void PerspectiveTransformMatchesCSS(TestImageProvider pro using (Image image = provider.GetImage()) { #pragma warning disable SA1117 // Parameters should be on same line or separate lines - var matrix = new Matrix4x4( - 0.260987f, -0.434909f, 0, -0.0022184f, - 0.373196f, 0.949882f, 0, -0.000312129f, - 0, 0, 1, 0, - 52, 165, 0, 1); + Matrix4x4 matrix = new( + 0.260987f, -0.434909f, 0, -0.0022184f, + 0.373196f, 0.949882f, 0, -0.000312129f, + 0, 0, 1, 0, + 52, 165, 0, 1); #pragma warning restore SA1117 // Parameters should be on same line or separate lines ProjectiveTransformBuilder builder = new ProjectiveTransformBuilder()