From 7632f8e9414087180ad6f2bd6d01db89a2482811 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 9 Mar 2026 20:59:12 +1000 Subject: [PATCH] Add Matrix4x4 transform overloads and tests --- src/ImageSharp/Primitives/Point.cs | 12 ++++ src/ImageSharp/Primitives/PointF.cs | 12 ++++ src/ImageSharp/Primitives/Rectangle.cs | 14 +++++ src/ImageSharp/Primitives/RectangleF.cs | 14 +++++ .../Transforms/TransformUtilities.cs | 16 +++-- .../Primitives/PointFTests.cs | 63 +++++++++++++++++++ .../ImageSharp.Tests/Primitives/PointTests.cs | 45 +++++++++++++ .../Primitives/RectangleFTests.cs | 43 +++++++++++++ .../Primitives/RectangleTests.cs | 33 ++++++++++ 9 files changed, 247 insertions(+), 5 deletions(-) diff --git a/src/ImageSharp/Primitives/Point.cs b/src/ImageSharp/Primitives/Point.cs index 8627fe980a..7660855479 100644 --- a/src/ImageSharp/Primitives/Point.cs +++ b/src/ImageSharp/Primitives/Point.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Numerics; using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace SixLabors.ImageSharp; @@ -234,6 +235,17 @@ public struct Point : IEquatable [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Point Transform(Point point, Matrix3x2 matrix) => Round(Vector2.Transform(new Vector2(point.X, point.Y), matrix)); + /// + /// Transforms a point by a specified 4x4 matrix, applying a projective transform + /// flattened into 2D space. + /// + /// The point to transform. + /// The transformation matrix used. + /// The transformed . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Point Transform(Point point, Matrix4x4 matrix) + => Round(TransformUtilities.ProjectiveTransform2D(point.X, point.Y, matrix)); + /// /// Deconstructs this point into two integers. /// diff --git a/src/ImageSharp/Primitives/PointF.cs b/src/ImageSharp/Primitives/PointF.cs index 35a506bb41..7979c0af06 100644 --- a/src/ImageSharp/Primitives/PointF.cs +++ b/src/ImageSharp/Primitives/PointF.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using System.Numerics; using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace SixLabors.ImageSharp; @@ -246,6 +247,17 @@ public struct PointF : IEquatable [MethodImpl(MethodImplOptions.AggressiveInlining)] public static PointF Transform(PointF point, Matrix3x2 matrix) => Vector2.Transform(point, matrix); + /// + /// Transforms a point by a specified 4x4 matrix, applying a projective transform + /// flattened into 2D space. + /// + /// The point to transform. + /// The transformation matrix used. + /// The transformed . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PointF Transform(PointF point, Matrix4x4 matrix) + => TransformUtilities.ProjectiveTransform2D(point.X, point.Y, matrix); + /// /// Deconstructs this point into two floats. /// diff --git a/src/ImageSharp/Primitives/Rectangle.cs b/src/ImageSharp/Primitives/Rectangle.cs index e2ae5071ef..6494964412 100644 --- a/src/ImageSharp/Primitives/Rectangle.cs +++ b/src/ImageSharp/Primitives/Rectangle.cs @@ -266,6 +266,20 @@ public struct Rectangle : IEquatable return new RectangleF(topLeft, new SizeF(bottomRight - topLeft)); } + /// + /// Transforms a rectangle by the given 4x4 matrix, applying a projective transform + /// flattened into 2D space. + /// + /// The source rectangle. + /// The transformation matrix. + /// A transformed rectangle. + public static RectangleF Transform(Rectangle rectangle, Matrix4x4 matrix) + { + PointF bottomRight = PointF.Transform(new PointF(rectangle.Right, rectangle.Bottom), matrix); + PointF topLeft = PointF.Transform(new PointF(rectangle.Location.X, rectangle.Location.Y), matrix); + return new RectangleF(topLeft, new SizeF(bottomRight - topLeft)); + } + /// /// Converts a to a by performing a truncate operation on all the coordinates. /// diff --git a/src/ImageSharp/Primitives/RectangleF.cs b/src/ImageSharp/Primitives/RectangleF.cs index 68add77d09..a66e3fcad6 100644 --- a/src/ImageSharp/Primitives/RectangleF.cs +++ b/src/ImageSharp/Primitives/RectangleF.cs @@ -241,6 +241,20 @@ public struct RectangleF : IEquatable return new RectangleF(topLeft, new SizeF(bottomRight - topLeft)); } + /// + /// Transforms a rectangle by the given 4x4 matrix, applying a projective transform + /// flattened into 2D space. + /// + /// The source rectangle. + /// The transformation matrix. + /// A transformed . + public static RectangleF Transform(RectangleF rectangle, Matrix4x4 matrix) + { + PointF bottomRight = PointF.Transform(new PointF(rectangle.Right, rectangle.Bottom), matrix); + PointF topLeft = PointF.Transform(rectangle.Location, matrix); + return new RectangleF(topLeft, new SizeF(bottomRight - topLeft)); + } + /// /// Creates a rectangle that represents the union between and . /// diff --git a/src/ImageSharp/Processing/Processors/Transforms/TransformUtilities.cs b/src/ImageSharp/Processing/Processors/Transforms/TransformUtilities.cs index 17bdeadde1..5c7de17100 100644 --- a/src/ImageSharp/Processing/Processors/Transforms/TransformUtilities.cs +++ b/src/ImageSharp/Processing/Processors/Transforms/TransformUtilities.cs @@ -69,11 +69,17 @@ internal static class TransformUtilities [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. + // Transforms the 2D point (x, y) as the homogeneous coordinate (x, y, 0, 1) and + // performs the perspective divide (X/W, Y/W) to project back into Cartesian 2D space. + // + // For affine matrices (M14=0, M24=0, M34=0, M44=1) W is always 1 and the divide + // is a no-op, producing the same result as Vector2.Transform(v, Matrix4x4).AsVector2() + // (the approach used by .NET 10+). + // + // For projective matrices (taper, quad distortion) W varies per point and the divide + // is essential for correct perspective mapping. W <= 0 means the point has crossed the + // vanishing line of the projection; clamping to epsilon avoids division by zero or + // negative values that would flip/mirror the output. 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); diff --git a/tests/ImageSharp.Tests/Primitives/PointFTests.cs b/tests/ImageSharp.Tests/Primitives/PointFTests.cs index 8b574daf0d..bea0f802fc 100644 --- a/tests/ImageSharp.Tests/Primitives/PointFTests.cs +++ b/tests/ImageSharp.Tests/Primitives/PointFTests.cs @@ -133,6 +133,69 @@ public class PointFTests Assert.Equal(new PointF(30, 30), pout); } + [Fact] + public void TransformMatrix4x4_AffineMatchesMatrix3x2() + { + PointF p = new(13, 17); + Matrix3x2 m3 = Matrix3x2Extensions.CreateRotationDegrees(45, PointF.Empty); + Matrix4x4 m4 = new(m3); + + PointF r3 = PointF.Transform(p, m3); + PointF r4 = PointF.Transform(p, m4); + + Assert.Equal(r3.X, r4.X, ApproximateFloatComparer); + Assert.Equal(r3.Y, r4.Y, ApproximateFloatComparer); + } + + [Fact] + public void TransformMatrix4x4_Identity() + { + PointF p = new(42.5F, -17.3F); + PointF result = PointF.Transform(p, Matrix4x4.Identity); + + Assert.Equal(p.X, result.X, ApproximateFloatComparer); + Assert.Equal(p.Y, result.Y, ApproximateFloatComparer); + } + + [Fact] + public void TransformMatrix4x4_Translation() + { + PointF p = new(10, 20); + Matrix4x4 m = Matrix4x4.CreateTranslation(5, -3, 0); + PointF result = PointF.Transform(p, m); + + Assert.Equal(15F, result.X, ApproximateFloatComparer); + Assert.Equal(17F, result.Y, ApproximateFloatComparer); + } + + [Fact] + public void TransformMatrix4x4_Scale() + { + PointF p = new(10, 20); + Matrix4x4 m = Matrix4x4.CreateScale(2, 3, 1); + PointF result = PointF.Transform(p, m); + + Assert.Equal(20F, result.X, ApproximateFloatComparer); + Assert.Equal(60F, result.Y, ApproximateFloatComparer); + } + + [Fact] + public void TransformMatrix4x4_Projective() + { + // A taper matrix with M14 != 0 produces W != 1, requiring perspective divide. + PointF p = new(100, 50); + Matrix4x4 m = Matrix4x4.Identity; + m.M14 = 0.005F; // perspective component + + PointF result = PointF.Transform(p, m); + + // W = x*M14 + M44 = 100*0.005 + 1 = 1.5 + // X = x*M11 + M41 = 100, Y = y*M22 + M42 = 50 + // result = (100/1.5, 50/1.5) + Assert.Equal(100F / 1.5F, result.X, ApproximateFloatComparer); + Assert.Equal(50F / 1.5F, result.Y, ApproximateFloatComparer); + } + [Theory] [InlineData(float.MaxValue, float.MinValue)] [InlineData(float.MinValue, float.MaxValue)] diff --git a/tests/ImageSharp.Tests/Primitives/PointTests.cs b/tests/ImageSharp.Tests/Primitives/PointTests.cs index 3ad2a83b3d..22c18a1470 100644 --- a/tests/ImageSharp.Tests/Primitives/PointTests.cs +++ b/tests/ImageSharp.Tests/Primitives/PointTests.cs @@ -174,6 +174,51 @@ public class PointTests Assert.Equal(new Point(30, 30), pout); } + [Fact] + public void TransformMatrix4x4_AffineMatchesMatrix3x2() + { + Point p = new(13, 17); + Matrix3x2 m3 = Matrix3x2Extensions.CreateRotationDegrees(45, Point.Empty); + Matrix4x4 m4 = new(m3); + + Point r3 = Point.Transform(p, m3); + Point r4 = Point.Transform(p, m4); + + Assert.Equal(r3, r4); + } + + [Fact] + public void TransformMatrix4x4_Identity() + { + Point p = new(42, -17); + Point result = Point.Transform(p, Matrix4x4.Identity); + + Assert.Equal(p, result); + } + + [Fact] + public void TransformMatrix4x4_Translation() + { + Point p = new(10, 20); + Matrix4x4 m = Matrix4x4.CreateTranslation(5, -3, 0); + Point result = Point.Transform(p, m); + + Assert.Equal(new Point(15, 17), result); + } + + [Fact] + public void TransformMatrix4x4_Projective() + { + Point p = new(100, 50); + Matrix4x4 m = Matrix4x4.Identity; + m.M14 = 0.005F; + + Point result = Point.Transform(p, m); + + // W = 100*0.005 + 1 = 1.5 => (100/1.5, 50/1.5) => rounded + Assert.Equal(Point.Round(new PointF(100F / 1.5F, 50F / 1.5F)), result); + } + [Theory] [InlineData(int.MaxValue, int.MinValue)] [InlineData(int.MinValue, int.MinValue)] diff --git a/tests/ImageSharp.Tests/Primitives/RectangleFTests.cs b/tests/ImageSharp.Tests/Primitives/RectangleFTests.cs index 4122daaa52..e3a13618bd 100644 --- a/tests/ImageSharp.Tests/Primitives/RectangleFTests.cs +++ b/tests/ImageSharp.Tests/Primitives/RectangleFTests.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Globalization; +using System.Numerics; namespace SixLabors.ImageSharp.Tests; @@ -243,6 +244,48 @@ public class RectangleFTests Assert.Equal(expectedRect, r1); } + [Fact] + public void TransformMatrix4x4_AffineMatchesMatrix3x2() + { + RectangleF rect = new(10, 20, 100, 50); + Matrix3x2 m3 = Matrix3x2.CreateTranslation(5, -3); + Matrix4x4 m4 = new(m3); + + RectangleF r3 = RectangleF.Transform(rect, m3); + RectangleF r4 = RectangleF.Transform(rect, m4); + + Assert.Equal(r3, r4); + } + + [Fact] + public void TransformMatrix4x4_Identity() + { + RectangleF rect = new(10, 20, 100, 50); + RectangleF result = RectangleF.Transform(rect, Matrix4x4.Identity); + + Assert.Equal(rect, result); + } + + [Fact] + public void TransformMatrix4x4_Translation() + { + RectangleF rect = new(10, 20, 100, 50); + Matrix4x4 m = Matrix4x4.CreateTranslation(5, -3, 0); + RectangleF result = RectangleF.Transform(rect, m); + + Assert.Equal(new RectangleF(15, 17, 100, 50), result); + } + + [Fact] + public void TransformMatrix4x4_Scale() + { + RectangleF rect = new(10, 20, 100, 50); + Matrix4x4 m = Matrix4x4.CreateScale(2, 3, 1); + RectangleF result = RectangleF.Transform(rect, m); + + Assert.Equal(new RectangleF(20, 60, 200, 150), result); + } + [Fact] public void ToStringTest() { diff --git a/tests/ImageSharp.Tests/Primitives/RectangleTests.cs b/tests/ImageSharp.Tests/Primitives/RectangleTests.cs index 2800852afd..104bce7541 100644 --- a/tests/ImageSharp.Tests/Primitives/RectangleTests.cs +++ b/tests/ImageSharp.Tests/Primitives/RectangleTests.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Globalization; +using System.Numerics; namespace SixLabors.ImageSharp.Tests; @@ -294,6 +295,38 @@ public class RectangleTests Assert.Equal(expectedRect, r1); } + [Fact] + public void TransformMatrix4x4_AffineMatchesMatrix3x2() + { + Rectangle rect = new(10, 20, 100, 50); + Matrix3x2 m3 = Matrix3x2.CreateTranslation(5, -3); + Matrix4x4 m4 = new(m3); + + RectangleF r3 = Rectangle.Transform(rect, m3); + RectangleF r4 = Rectangle.Transform(rect, m4); + + Assert.Equal(r3, r4); + } + + [Fact] + public void TransformMatrix4x4_Identity() + { + Rectangle rect = new(10, 20, 100, 50); + RectangleF result = Rectangle.Transform(rect, Matrix4x4.Identity); + + Assert.Equal(new RectangleF(10, 20, 100, 50), result); + } + + [Fact] + public void TransformMatrix4x4_Translation() + { + Rectangle rect = new(10, 20, 100, 50); + Matrix4x4 m = Matrix4x4.CreateTranslation(5, -3, 0); + RectangleF result = Rectangle.Transform(rect, m); + + Assert.Equal(new RectangleF(15, 17, 100, 50), result); + } + [Fact] public void ToStringTest() {