You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Rotation of AffineTransform seems to offset the image by 1 pixel #2753
I'm not 100% up on this area but wouldn't the bottom right location be (99, 99) as the pixels are zero indexed?
I can't remember fully correctly but it could be confusion caused by the difference in how Drawing vs pixel manipulation work where, I believe, one is pixel centre based on the other is pixel boundary based.
My caveat here is that I might be entirely wrong about all this as I'm trying to dredge up some memories from quite some time ago.
Just to clarify because I'm not sure about what you said, is the problem with my example ? or is it a bug in ImageSharp ?
Taking a closer look at your code examples it looks like a bug for the .Transform(new AffineTransformBuilder().AppendRotationDegrees(180)) to me.
The .Transform(new AffineTransformBuilder().AppendRotationDegrees(i * 18, new Vector2(100, 100))) case however looks more suspect as I would have expected that line to have been a rotation around [99, 99] but if there's a bug in of those call sites then there could very will be a bug that effects both.
The .Transform(new AffineTransformBuilder().AppendRotationDegrees(i * 18, new Vector2(100, 100))) case however looks more suspect as I would have expected that line to have been a rotation around [99, 99] but if there's a bug in of those call sites then there could very will be a bug that effects both.
When it's rotating arround 100, 100, as I understand it, it should rotate arround the bottom-right edge of the pixel 99,99
So here another example, I resized it and added a cross to show where I think the rotation point should be
The result is offset by 1 pixel in each direction because the transformation matrix is centered using a 1-based coordinate system, which assumes the image's center is at (width * 0.5, height * 0.5). However, image pixels are zero-based, meaning their coordinates start from 0. This discrepancy causes a misalignment, as the matrix does not account for the zero-based nature of pixel indices, leading to a 1-pixel offset when the transformation is applied.
Fix:
To correct this issue, we need to use two separate matrices:
Transformation Matrix for Pixel Operations: This matrix accounts for the zero-based nature of pixel indices. It adjusts the center calculation to ensure the transformations (translation, rotation, etc.) align correctly with the pixel grid. This matrix is used to perform the actual image transformation.
Bounding Box Calculation Matrix: This matrix does not adjust for the zero-based pixel grid but instead accurately represents the intended transformation. It is used to compute the transformed image's size and bounds by transforming the image's corners and determining the extents.
By using these two matrices, we can ensure that both the pixel operations and the size/bounds calculations are handled correctly, resolving the 1-pixel offset issue and maintaining accurate and expected transformation results.
Rather than bombard me with a wall of text can you please acknowledge my explanation as to why your expectations were incorrect above
Sorry if you saw my repro as a wall of text, I just paste the code so you can easily reproduce this.
I acknowledge I understand why the value is offseted by 0.5, it make sense (Sorry if this was not clear in my previous message)
I'm sorry in my previous message I made a typo and I wrongly place the rotation point, so the bug is not an offset but only that the image is cropped by 1 pixel
Do you prefer I open an new issue for this one since it's not exactly the same problem ?
@Socolin can you please check my working here. I've applied an equivalent transform at both the index and bounds to see what's going on and applied a simplified form of the bounds calculation.
// Create the matrix.Matrix3x2mt=Matrix3x2.CreateRotation(GeometryUtilities.DegreeToRadian(270),newVector2(3.5f,3.5f));// Transform indices. Rectangle of size 4x4 at position [0, 0]// TLVector2.Transform(newVector2(0,0),mt);// [0, 7]// TRVector2.Transform(newVector2(3,0),mt);// [0, 4]// BRVector2.Transform(newVector2(3,3),mt);// [3, 4]// BLVector2.Transform(newVector2(0,3),mt);// [3, 7]// Transform Bounds. Rectangle of size 4x4 at position [0, 0]// TLvartl=Vector2.Transform(newVector2(0,0),mt);// [0, 7]// TRvartr=Vector2.Transform(newVector2(4,0),mt);// [0, 3]// BRvarbr=Vector2.Transform(newVector2(4,4),mt);// [4, 3]// BLvarbl=Vector2.Transform(newVector2(0,4),mt);// [4, 7]// Find the minimum and maximum "corners" based on the given vectorsfloatleft=MathF.Min(tl.X,MathF.Min(tr.X,MathF.Min(bl.X,br.X)));// 0floattop=MathF.Min(tl.Y,MathF.Min(tr.Y,MathF.Min(bl.Y,br.Y)));// 3floatright=MathF.Max(tl.X,MathF.Max(tr.X,MathF.Max(bl.X,br.X)));// 4floatbottom=MathF.Max(tl.Y,MathF.Max(tr.Y,MathF.Max(bl.Y,br.Y)));// 7varbounds=Rectangle.Round(RectangleF.FromLTRB(left,top,right,bottom));[0,3,4,7] @ 3x4// Take the max of height vs bottom and width vs right to house our transform.// ignore negative values for now since everything is positive.bounds=newRectangle(0,0,right,bottom);// [0, 0, 4, 7] @ 4x7
We end up with a rectangle of dimensions 4x7. However, if you look at the results of applying the transform when sampling at a given index you can see that the values exceed the given bounds.
Now.... What I'm curious about is whether we should in, fact be calculating the bounds based upon the maximum index of the given rectangle. 🤔
OK, what seems to get the expected result 4x8 for this test would be to update TransformUtils.GetTransformedSize(Size size, Matrix3x2 matrix) to do the following.
publicstaticSizeGetTransformedSize(Sizesize,Matrix3x2matrix){if(matrix.Equals(default)||matrix.Equals(Matrix3x2.Identity)){returnsize;}// Calculate the transform based upon the max zero-based indicesRectanglerectangle=GetTransformedRectangle(newRectangle(Point.Empty,size-newSize(1,1)),matrix);size=ConstrainSize(rectangle);// Pad out by 1 to transform to one-based dimensionsreturnsize+newSize(1,1);}
I'd have to double check this doesn't negatively affect the size calculation for automatically centered rotations but it shouldn't...
As far as I can tell the math are all right with the transform. I'm very visual so I draw a bunch of schema to understand this, I'm including them for clarity
The way the current bounding box is computed, I think, would work if it were using the plan coordinate (If I rotate a 4x4 square on a plan (0, 0, 4, 4) from (3.5, 3.5) to top coordinate is 7)
Since the pixel are centered on their coordinate (in the plan, the pixel 0, 0, is [-0.5, -0.5, 0.5, 0.5])
And after the rotation
it seems normal to have an offset of 0.5 to align the pixels with the plan, and then add another 0.5 to get the position of the edge of the pixel.
I'm not sure I understand the - new Size(1, 1) then +new Size(1, 1)` I understand that we offset everything to do the computation and then offset it back, from my understanding it should only be offset by 0.5 (But I'm not that good with math so I'm really not sure about this and probably I'm missing something).
I now understand why there is 2 matrix, and I wonder if instead of doing the -1 / +1 the center of rotation should be offset by (0.5, 0.5) instead when computing the bounding box matrix
Also while reading the code I'm also have a question regarding GetBoundingRectangle (I did no test so it's purely speculative at this point)
if after a rotation one of the border is slightly over a pixel (like a bounding box of 0.7, 0.7, 10.2, 10.2), seems like it's going to cut the pixels a bit (1,1, 10, 10) instead of (0,0, 11, 11) in (GetBoundingRectangle it's using Rectangle.Round) ?
@Socolin Your observation regarding GetBoundingRectangle is correct. Rounding should not be taking place and I've already updated my local code to reflect this.
privatestaticRectangleGetBoundingRectangle(Vector2tl,Vector2tr,Vector2bl,Vector2br){// Find the minimum and maximum "corners" based on the given vectorsfloatleft=MathF.Min(tl.X,MathF.Min(tr.X,MathF.Min(bl.X,br.X)));floattop=MathF.Min(tl.Y,MathF.Min(tr.Y,MathF.Min(bl.Y,br.Y)));floatright=MathF.Max(tl.X,MathF.Max(tr.X,MathF.Max(bl.X,br.X)));floatbottom=MathF.Max(tl.Y,MathF.Max(tr.Y,MathF.Max(bl.Y,br.Y)));returnRectangle.FromLTRB((int)Math.Floor(left),(int)Math.Floor(top),(int)Math.Ceiling(right),(int)Math.Ceiling(bottom));}
Since the pixel are centered on their coordinate (in the plan, the pixel 0, 0, is [-0.5, -0.5, 0.5, 0.5])
We've touched upon this a few times and I think there's a slight misunderstanding there. Pixels are not centered on their coordinates as you have drawn, rather a pixel's location is always considered the top-left corner.
What we have here is two separate coordinate spaces.
Coordinate Space (Geometric or Mathematical Space):
This refers to the continuous mathematical representation of a shape or region, often defined by vertices or boundaries like left (L), top (T), right (R), and bottom (B) positions of a rectangle.
Transforming in coordinate space means you are applying the transformation to these geometric boundaries or control points directly.
Pixel Space (Discrete or Raster Space):
This refers to the discrete grid of pixels that make up an image. The pixel indices (like (0,0) to (width-1, height-1)) are discrete and aligned with the grid's extents.
Applying a transformation in pixel space means you are transforming the actual grid points or pixel indices, which can result in different outcomes due to the discrete nature of the pixel grid.
Now the current code blurs those spaces and adds complexity. Everything we do should actually be based upon the Pixel Space.
This is why the suggested solution code subtracts [1, 1] from the given size then adds it on again at the end. It's a simple translation.
I'm going to do some experimentation, but I think I may be able to use a single transform matrix and refactor the bounds calculations to use the correct space.
It's more complicated calculating the correct transform than it first appears as you need to consider linear scaling in affine transforms and non-linear scaling in perspective ones. I think I have everything looking good locally though now.
I took a look, and it looks good. I'm sorry I'm really having a hard time understanding all this.
I think the main confusion for me is that almost all operation works the same between the Coordinate space / Pixel space except the rotation. I'm bad with abstract math, I generally need to visualize things or it take me a long time to understand a new concept. In this case trying to visualize this with what I knew was not working.
I think I now got the differences between both space:
The size of a 4x4 image is
5x5 wide in the coordinate space
4x4 in pixel space
When applying the matrix:
in coordinate space, pixel would be square of 1x1
In pixel space: pixels are only point
So the -1 make sense, to go from the 5x5 to 4x4 before working on it :)
And about the commit the only remark I would have, is that in the comments the Coordinate / Pixel space are not clearly explained. I think it would be nice to have this explained somewhere (the last explanation you gave here was good :) ) Either in the code directly, or linked to a doc somewhere.
Also I would suggest to update the doc of AppendRotationRadians() (like in <remarks>) to add that the rotation is done in the Pixel space and thus the rotation are done around the pixel/point and not around the coordinate.
And thanks a lot for the explanation and for your patience :)
Thanks for your feedback! I understand the confusion between coordinate space and pixel space, especially when it comes to transformations like rotation and scaling. Let's break it down:
Coordinate Space vs. Pixel Space:
Coordinate Space: This is a continuous grid where positions can be any value, even fractions. When you define a 4x4 image here, it spans from (0,0) to (4,4), but this includes the boundaries and represents exactly 4 units in each direction.
Pixel Space: This is a discrete grid where each position is a whole number, corresponding to specific pixels on the screen. In pixel space, a 4x4 image means 4 pixels wide and 4 pixels tall, covering positions (0,0) to (3,3).
Why the Offset Matters:
When you move from coordinate space to pixel space, there's a key difference: in pixel space, you’re working with exact grid points, while coordinate space can be more flexible. To align these properly, especially after transformations like scaling and rotation, we sometimes use an offset.
The Offset: This offset (which you can think of as 1/scale in each direction) ensures that after scaling, the transformed image aligns correctly with the pixel grid. Without this, you could end up with pixels slightly misaligned, causing visual artifacts like clipping.
When the Offset is Applied:
The offset is crucial when dealing with standard 2D transformations (like scaling or rotation) because it keeps everything lined up in pixel space. However, it’s only used when the transformation doesn’t involve more complex effects like perspective or non-linear scaling, where the transformation is less predictable.
In Simple Terms:
Coordinate space lets you place things with precision, but pixel space is where you make sure everything lines up on your screen. The offset helps bridge these two spaces when you scale or rotate, ensuring that your image looks right without any parts being cut off.
I’ll make sure to add a clearer explanation in the code comments to help others understand this distinction. I think I'll actually have to add an overload to both builders that takes an enum defining whether to use pixel or coordinate space as with all the changes the builders do not work well when drawing shapes since they operate in the coordinate space.
Reacted by Christian Rondeau, Socolin - Bertrand Provost and Tieson Trowbridge
Activity
tocsoft commentedon Jun 19, 2024
I'm not 100% up on this area but wouldn't the bottom right location be (99, 99) as the pixels are zero indexed?
I can't remember fully correctly but it could be confusion caused by the difference in how Drawing vs pixel manipulation work where, I believe, one is pixel centre based on the other is pixel boundary based.
My caveat here is that I might be entirely wrong about all this as I'm trying to dredge up some memories from quite some time ago.
JimBobSquarePants commentedon Jun 19, 2024
Yeah, I think you’re diagnosis is correct; it should be zero based.
Socolin commentedon Jun 19, 2024
Just to clarify because I'm not sure about what you said, is the problem with my example ? or is it a bug in ImageSharp ?
tocsoft commentedon Jun 19, 2024
Taking a closer look at your code examples it looks like a bug for the
.Transform(new AffineTransformBuilder().AppendRotationDegrees(180))to me.The
.Transform(new AffineTransformBuilder().AppendRotationDegrees(i * 18, new Vector2(100, 100)))case however looks more suspect as I would have expected that line to have been a rotation around [99, 99] but if there's a bug in of those call sites then there could very will be a bug that effects both.Socolin commentedon Jun 19, 2024
Ok thanks.
When it's rotating arround 100, 100, as I understand it, it should rotate arround the bottom-right edge of the pixel 99,99
So here another example, I resized it and added a cross to show where I think the rotation point should be
Before rotation


After rotation
JimBobSquarePants commentedon Jun 21, 2024
I think I know what the issue is here. Will have a look over the weekend.
JimBobSquarePants commentedon Jun 29, 2024
I was right.... I was hoping I wasn't.
The result is offset by 1 pixel in each direction because the transformation matrix is centered using a 1-based coordinate system, which assumes the image's center is at
(width * 0.5, height * 0.5). However, image pixels are zero-based, meaning their coordinates start from 0. This discrepancy causes a misalignment, as the matrix does not account for the zero-based nature of pixel indices, leading to a 1-pixel offset when the transformation is applied.Fix:
To correct this issue, we need to use two separate matrices:
Transformation Matrix for Pixel Operations: This matrix accounts for the zero-based nature of pixel indices. It adjusts the center calculation to ensure the transformations (translation, rotation, etc.) align correctly with the pixel grid. This matrix is used to perform the actual image transformation.
Bounding Box Calculation Matrix: This matrix does not adjust for the zero-based pixel grid but instead accurately represents the intended transformation. It is used to compute the transformed image's size and bounds by transforming the image's corners and determining the extents.
By using these two matrices, we can ensure that both the pixel operations and the size/bounds calculations are handled correctly, resolving the 1-pixel offset issue and maintaining accurate and expected transformation results.
I'll get stuck in...
JimBobSquarePants commentedon Jun 29, 2024
PR opened
JimBobSquarePants commentedon Jul 24, 2024
Fixed with 3.1.5
Socolin commentedon Jul 30, 2024
@JimBobSquarePants I just tested and the problem is still present,
AppendRotationDegrees(float degrees, Vector2 origin)is not using the new matrix6 remaining items
Socolin commentedon Aug 1, 2024
I found another bug with the rotation, kind of the same as the original issue:
When I apply 90/270 rotation on this 4x4
with
I'm getting this:


And this for a 90 degree rotation
The output is offset by one pixel
To reproduce
JimBobSquarePants commentedon Aug 1, 2024
Rather than bombard me with a wall of text can you please acknowledge my explanation as to why your expectations were incorrect above
Socolin commentedon Aug 1, 2024
Sorry if you saw my repro as a wall of text, I just paste the code so you can easily reproduce this.
I acknowledge I understand why the value is offseted by 0.5, it make sense (Sorry if this was not clear in my previous message)
I'm sorry in my previous message I made a typo and I wrongly place the rotation point, so the bug is not an offset but only that the image is cropped by 1 pixel
Do you prefer I open an new issue for this one since it's not exactly the same problem ?
JimBobSquarePants commentedon Aug 2, 2024
@Socolin can you please check my working here. I've applied an equivalent transform at both the index and bounds to see what's going on and applied a simplified form of the bounds calculation.
We end up with a rectangle of dimensions 4x7. However, if you look at the results of applying the transform when sampling at a given index you can see that the values exceed the given bounds.
Now.... What I'm curious about is whether we should in, fact be calculating the bounds based upon the maximum index of the given rectangle. 🤔
JimBobSquarePants commentedon Aug 2, 2024
OK, what seems to get the expected result
4x8for this test would be to updateTransformUtils.GetTransformedSize(Size size, Matrix3x2 matrix)to do the following.I'd have to double check this doesn't negatively affect the size calculation for automatically centered rotations but it shouldn't...
Socolin commentedon Aug 3, 2024
As far as I can tell the math are all right with the transform. I'm very visual so I draw a bunch of schema to understand this, I'm including them for clarity
The way the current bounding box is computed, I think, would work if it were using the plan coordinate (If I rotate a 4x4 square on a plan (0, 0, 4, 4) from (3.5, 3.5) to top coordinate is 7)
Since the pixel are centered on their coordinate (in the plan, the pixel 0, 0, is [-0.5, -0.5, 0.5, 0.5])
And after the rotation
it seems normal to have an offset of 0.5 to align the pixels with the plan, and then add another 0.5 to get the position of the edge of the pixel.
I'm not sure I understand the
- new Size(1, 1)then+new Size(1, 1)` I understand that we offset everything to do the computation and then offset it back, from my understanding it should only be offset by 0.5 (But I'm not that good with math so I'm really not sure about this and probably I'm missing something).I now understand why there is 2 matrix, and I wonder if instead of doing the
-1/+1the center of rotation should be offset by (0.5, 0.5) instead when computing the bounding box matrix(If this is a solution,
private AffineTransformBuilder Appendmay need to be made public so people doing custom math can give both matrix)Socolin commentedon Aug 3, 2024
Also while reading the code I'm also have a question regarding
GetBoundingRectangle(I did no test so it's purely speculative at this point)if after a rotation one of the border is slightly over a pixel (like a bounding box of 0.7, 0.7, 10.2, 10.2), seems like it's going to cut the pixels a bit (1,1, 10, 10) instead of (0,0, 11, 11) in (
GetBoundingRectangleit's usingRectangle.Round) ?JimBobSquarePants commentedon Aug 5, 2024
@Socolin Your observation regarding
GetBoundingRectangleis correct. Rounding should not be taking place and I've already updated my local code to reflect this.We've touched upon this a few times and I think there's a slight misunderstanding there. Pixels are not centered on their coordinates as you have drawn, rather a pixel's location is always considered the top-left corner.
What we have here is two separate coordinate spaces.
Coordinate Space (Geometric or Mathematical Space):
This refers to the continuous mathematical representation of a shape or region, often defined by vertices or boundaries like left (L), top (T), right (R), and bottom (B) positions of a rectangle.
Transforming in coordinate space means you are applying the transformation to these geometric boundaries or control points directly.
Pixel Space (Discrete or Raster Space):
This refers to the discrete grid of pixels that make up an image. The pixel indices (like (0,0) to (width-1, height-1)) are discrete and aligned with the grid's extents.
Applying a transformation in pixel space means you are transforming the actual grid points or pixel indices, which can result in different outcomes due to the discrete nature of the pixel grid.
Now the current code blurs those spaces and adds complexity. Everything we do should actually be based upon the Pixel Space.
This is why the suggested solution code subtracts
[1, 1]from the given size then adds it on again at the end. It's a simple translation.I'm going to do some experimentation, but I think I may be able to use a single transform matrix and refactor the bounds calculations to use the correct space.
JimBobSquarePants commentedon Aug 5, 2024
It's more complicated calculating the correct transform than it first appears as you need to consider linear scaling in affine transforms and non-linear scaling in perspective ones. I think I have everything looking good locally though now.
JimBobSquarePants commentedon Aug 5, 2024
@Socolin I've pushed my fixes to a branch but it's very late now so I haven't updated the reference output for the tests.
e1555fd
Please have a good look. I've added a LOT of comments to describe how things work and why.
Socolin commentedon Aug 5, 2024
I took a look, and it looks good. I'm sorry I'm really having a hard time understanding all this.
I think the main confusion for me is that almost all operation works the same between the Coordinate space / Pixel space except the rotation. I'm bad with abstract math, I generally need to visualize things or it take me a long time to understand a new concept. In this case trying to visualize this with what I knew was not working.
I think I now got the differences between both space:
So the
-1make sense, to go from the 5x5 to 4x4 before working on it :)And about the commit the only remark I would have, is that in the comments the Coordinate / Pixel space are not clearly explained. I think it would be nice to have this explained somewhere (the last explanation you gave here was good :) ) Either in the code directly, or linked to a doc somewhere.
Also I would suggest to update the doc of
AppendRotationRadians()(like in<remarks>) to add that the rotation is done in the Pixel space and thus the rotation are done around the pixel/point and not around the coordinate.And thanks a lot for the explanation and for your patience :)
JimBobSquarePants commentedon Aug 5, 2024
Thanks for your feedback! I understand the confusion between coordinate space and pixel space, especially when it comes to transformations like rotation and scaling. Let's break it down:
Coordinate Space vs. Pixel Space:
(0,0)to(4,4), but this includes the boundaries and represents exactly 4 units in each direction.(0,0)to(3,3).Why the Offset Matters:
1/scalein each direction) ensures that after scaling, the transformed image aligns correctly with the pixel grid. Without this, you could end up with pixels slightly misaligned, causing visual artifacts like clipping.When the Offset is Applied:
In Simple Terms:
I’ll make sure to add a clearer explanation in the code comments to help others understand this distinction. I think I'll actually have to add an overload to both builders that takes an enum defining whether to use pixel or coordinate space as with all the changes the builders do not work well when drawing shapes since they operate in the coordinate space.
JimBobSquarePants commentedon Aug 13, 2024
New PR #2791 has been opened to fix all known issues.