I my previous post, “Matrices for developers”, I’ve talked about matrices and how they can be used to compute 2D transformations.
In this post, I want to talk about how to apply what we know about matrices in order to perform 2D transformations, first using Java AWT and then with the Android SDK.
When I was working on the project I mentioned at the beginning of the previous article, I was constantly moving back and forth between the JDK’s implementation of affine transformations and the Android SDK’s implementation of matrices.
I find the java.awt.geom.AffineTransform
class fairly well featured,
but it is a bit ambiguous. Fortunately, the documentation is
good, it’s not perfect but at least it’s better than Android’s one on this
topic as we shall see later.
The Javadoc starts with a reminder of what are 2D affine transformations and a matrix multiplication pattern to transform coordinates.
That’s neat, you have to appreciate the effort there (I mean, is your Javadoc that great? :innocent: ), and Android’s Javadoc doesn’t have it so…
The way this pattern is written lets us see a glimpse of implementation details, right? Those $m_{00}$, $m_{01}$ and etc, they (not so) strangely resemble stringified versions of indexes in a two-dimensional array.
So what is “ambiguous” with this class? Granted it might be a matter of taste,
but if you look at the constructor
AffineTransform(m00, m10, m01, m11, m02, m12)
and the method
setTransform(m00, m10, m01, m11, m02, m12)
, they only take 6 input
parameters although the transformation matrices are $3 \times 3$ dimensions
matrices: they have 9 entries.
While it makes perfectly sense to not take as inputs parameters that are fixed
in the context of 2D affine transformations (0, 0, 1
), I find it disturbing.
More disturbing perhaps, is the ordering of those parameters.
If you make the parallel between those and our $a_{11}$, $a_{12}$, etc. from
the previous post, you can notice that the reading direction is not the same.
With $a_{11}$, $a_{12}$, etc., we used to read more “naturally” I would say,
like normal english written text: line by line.
Whereas $m_{00}$, $m_{10}$, etc. is reading the matrix column by column.
I’m not saying one is better than the other, just that I’m more familiar with
the first one, and that it’s worth pointing at it to clarify the use of this
class. Because the getMatrix(flatmatrix)
method will fill in an
array containing the entries of the matrix in that specific order.
Also, getMatrix
“Retrieves the 6 specifiable values in the $3 \times 3$
affine transformation matrix”, which means it will only give you those
$m_{00}$, $m_{10}$, etc., entries, not the ones from the third row.
To understand what I mean, let’s try to execute the kind of transformations we have seen throughout the first part of this series.
Yes! We have translate(tx, ty)
:
Concatenates this transform with a translation transformation.
We’ll see what “concatenates” means in this context in a moment, for now what we understand is that we have a method to apply a translation transformation.
Yes, but only by constants, not by angles, we have shear(shx, shy)
:
Concatenates this transform with a shearing transformation.
Yes! We have scale(sx, sy)
:
Concatenates this transform with a scaling transformation.
Not directly, at least I don’t see anything doing a reflexion directly, so we
either have to scale by negative values, or to use
setTransform(-1, 0, 0, -1, 0, 0)
(for example) manually and then
concatenate
.
Yes! We have rotate(theta)
:
Concatenates this transform with a rotation transformation.
Beware: theta
here is in radians, not in degrees.
No. You will have to compose your transformation as we’ve done it “by hand”
above, with a combination of scale(sx, sy)
and translate(tx, ty)
.
Yes! We have rotate(theta, anchorx, anchory)
:
Concatenates this transform with a transform that rotates coordinates around an anchor point.
Yes! We have several methods available in order to transform points (even shapes) from their original position to their new coordinates after the transformation has been applied.
I am, actually, and there are more methods that allow you to do interesting
stuff with this class.
I’m just wondering why they decided to implement
rotate(theta, anchorx, anchory)
but not
scale(sx, sy, anchorx, anchory)
.
On the other hand, all the methods I’ve outlined above are quite opinionated. Why? Because they assume that what you want to do is:
Concatenates this transform with a transformation
An that’s where bad stuff happen.
All the transformations we’ve seen in my previous post about matrices are defined this way:
Where:
Now, look at the definition of the
concatenate(AffineTransform Tx)
method:
Concatenates an AffineTransform Tx to this AffineTransform Cx in the most commonly useful way to provide a new user space that is mapped to the former user space by Tx. Cx is updated to perform the combined transformation. Transforming a point p by the updated transform Cx’ is equivalent to first transforming p by Tx and then transforming the result by the original transform Cx like this: Cx’(p) = Cx(Tx(p)) In matrix notation, if this transform Cx is represented by the matrix [this] and Tx is represented by the matrix [Tx] then this method does the following:
[this] = [this] x [Tx]
In our notation this gives that for transforming by $\mathbf{A}$, then $\mathbf{B}$, then $\mathbf{C}$ we have:
This is very different than:
Because matrix multiplication is associative
,
remember?
But matrix multiplication is also non-commutative
, so this will lead to
very different results than what you might expect!
The good news: there’s a method preConcatenate(AffineTransform Tx)
that does
what we want:
[this] = [Tx] x [this]
The bad news: you won’t be able to represent your transformations with the
built-in translate
, scale
, rotate
as is. Because they don’t behave the
way you think: they all fall down to the concatenate
method.
At least they don’t behave the way I think about transformations, which is
the one I’ve described in my post about matrices.
Honestly, I don’t know what the Javadoc means by “in the most commonly useful way to provide a new user space”. I’m sure it makes sense for some, but I don’t get it.
So how do we use the AffineTransform
class to chain our transformations the
way we want?
Fortunately, the class provides us with a bunch of useful static methods that
return new matrices that are ready to use and can be combined by using the
preConcatenate
method:
AffineTransform.getTranslateInstance(tx, ty)
AffineTransform.getRotateInstance(theta)
AffineTransform.getScaleInstance(sx, sy)
AffineTransform.getShearInstance(shx, shy)
Performance aside, here’s a class that will 2x zoom at the center of a rectangle:
package com.arnaudbos.java2d;
// imports stripped
public class AffineTransformZoomExample {
// code stripped
private static class ZoomCanvas extends JComponent {
public void paint(Graphics g) {
Graphics2D ourGraphics = (Graphics2D) g;
// code stripped
// Draw initial object
ourGraphics.setColor(Color.black);
ourGraphics.drawRect(100, 100, 100, 100);
// Create matrix (set to identity by default)
AffineTransform tx = new AffineTransform();
// This is not the transformation you're looking for
tx.translate(-150, -150);
tx.scale(2, 2);
tx.translate(150, 150);
ourGraphics.setTransform(tx);
ourGraphics.setColor(Color.red);
ourGraphics.drawRect(100, 100, 100, 100);
// Reset matrix to identity to clear previous transformations
tx.setToIdentity();
// Apply our transformations in order to zoom-in the square
tx.preConcatenate(AffineTransform.getTranslateInstance(-150, -150));
tx.preConcatenate(AffineTransform.getScaleInstance(2, 2));
tx.preConcatenate(AffineTransform.getTranslateInstance(150, 150));
ourGraphics.setTransform(tx);
ourGraphics.setColor(Color.green);
ourGraphics.drawRect(100, 100, 100, 100);
}
}
}
You can browse the source of java.awt.geom.AffineTransform if you’re interested, you’ll see all the matrix multiplications performed the same way as we’ve seen in my previous post.
As you can see, translate
and scale
which use concatenate
(aka. “post”-concatenate) under the hood, don’t give the result we might
expect.
On the other hand, manually using preConcatenate
and get...Instance
, will.
Let’s see how this works on Android.
Unlike Oracle, Google’s android.graphics.Matrix
class assumes you already
know your way around matrices. There’s no reminders, no details about matrices,
no explanations. And the source code will help but is a little more tricky
to unroll.
You can browse the source of android.graphics.Matrix and
notice a lot of oops();
method calls and native_
invocations but nothing
really helpful.
The java Matrix
class is in fact a proxy to the underlying JNI
implementation of matrix operations. The real operations are done by the C++
Matrix
class.
You can find the interface (Matrix.h
) here
and implementation (Matrix.cpp
) here. But this is just
another level of indirection because this class is just a facade on top of the
SkMatrix
dependency which does all the real work on matrix operations. The
interface (SkMatrix.h
) can be found here and the implementation
(SkMatrix.cpp
) can be found here.
Nonetheless, the API is good and well featured, as long as you understand a few things.
Unlike Java, Android provides ways of building matrices that seem more explicit and straightforward to me.
The first thing we see in the Javadoc is a bunch of constants that are used to describe each entry in the matrix:
int MPERSP_0 = 6
int MPERSP_1 = 7
int MPERSP_2 = 8
int MSCALE_X = 0
int MSCALE_Y = 4
int MSKEW_X = 1
int MSKEW_Y = 3
int MTRANS_X = 2
int MTRANS_Y = 5
Put this back ordered by their value and now look at:
setValues(values)
: “Copy 9 values from the array into the matrix."getValues(values)
: “Copy 9 values from the matrix into the array."What we see here is, I think, a more explicit API than the Java one: you are dealing with a 3x3 dimensions matrix, so you specify/retrieve the 9 entries that this matrix is composed of.
Granted Java’s AffineTransform
class is named this way for a reason, that
reason being you can only deal with affine transformations. Whereas
Android’s Matrix
class can be used to represent projections by playing with
the MPERSP_0
, MPERSP_1
and MPERSP_2
entries (hence their names and the
isAffine()
method).
Let’s do it again.
Yes! We have preTranslate(dx, dy)
and postTranslate(dx, dy)
:
Pre/Post-concats the matrix with the specified translation.
Yes, but only by constants not by angles, and it’s named “skew”.
We have preSkew(kx, ky)
and postSkew(kx, ky)
:
Pre/Post-concats the matrix with the specified skew.
We also have preSkew(kx, ky, px, py)
and postSkew(kx, ky, px, py)
in order
to skew not around the origin, by around a given anchor point. That’s nice.
Yes! We have preScale(sx, sy)
and postScale(sx, sy)
:
Pre/Post-concats the matrix with the specified scale.
Again, not directly, we can scale by negative values, or we can use
setValues({-1, 0, 0, 0, -1, 0, 0, 0, 1})
(for example) and then
postConcat
.
Yes! We have preRotate(degrees)
and postRotate(degrees)
:
Pre/Post-concats the matrix with the specified rotation.
Yes! We have preScale(sx, sy, px, py)
and postScale(sx, sy, px, py)
:
Pre/Post-concats the matrix with the specified scale.
Yes! We have preRotate(degrees, px, py)
and postRotate(degrees, px, py)
:
Pre-Post-concats the matrix with the specified rotation.
Also yes! We have several methods available in order to transform points and shapes from their original position to their new coordinates after the transformation has been applied.
Yes, I like this word…
The API is undeniably well featured, provides pre
and post
methods for
the most common transformations, a setValues
method to create matrices of
any shape, and also preConcat(Matrix other)
and postConcat(Matrix other)
.
What do they do?
Preconcats the matrix with the specified matrix. M’ = M * other
So, if I read correctly, this is equivalent to:
Wait… in Java’s AffineTransform
, this was the equivalent of the
concatenate
method…
Postconcats the matrix with the specified matrix. M’ = other * M
Again, if I read correctly, this is equivalent to:
Wait… in Java’s AffineTransform
, this was the equivalent of the
preConcatenate
method…
Exactly. If you don’t read the doc, you’re screwed :poop:
So who’s right?
I’ve searched a few minutes on the Interwebs and here’s what I’ve found from Wikipedia:
“pre-multiply (or left multiply) $\mathbf{A}$ by $\mathbf{B}$” means $\mathbf{B}.\mathbf{A}$, while “post-multiply (or right multiply) $\mathbf{A}$ by $\mathbf{C}$” means $\mathbf{A}.\mathbf{C}$
And because two sources are better than one, from this “ohio-state” course:
Pre-multiplication is to multiply the new matrix $\mathbf{B}$ to the left of the existing matrix $\mathbf{A}$ to get the result $\mathbf{C} = \mathbf{B}.\mathbf{A}$
Post-multiplication is to multiply the new matrix $\mathbf{B}$ to the right of the existing matrix $\mathbf{A}$ to get the result $\mathbf{C} = \mathbf{A}.\mathbf{B}$
So it seems like Sun/Oracle got it right, and Google got it backward. Which
seems weird…
I’ve filled a bug report on the
Android Open Source Project Issue Tracker in order to know if
I missed something or if it’s a real issue.
But it doesn’t solve our problem: we have to be cautious when applying affine transformations, because the order matters!
And because of the way we want to apply our transformations, in Android we’re
going to make use of the post
methods. But the pre
methods are here also
and will simplify your like if you need this kind of operations.
Again, performance aside, here’s a class that will rotate the Grumpy cat:
package com.arnaudbos.android2d;
// imports stripped
public class MainActivity extends AppCompatActivity {
// code stripped
private enum MatrixConcatenation {
PRE, POST
}
private static final float THETA = 30;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Display Grumpy cat
Drawable d = getDrawable(R.drawable.grumpy);
view = new ImageView(this);
view.setImageDrawable(d);
setContentView(view);
// Center Grumpy cat
view.setScaleType(ImageView.ScaleType.MATRIX);
final float[] dimensions = getSize(this);
width = dimensions[0];
height = dimensions[1];
matrix = center(width, height, d);
view.setImageMatrix(matrix);
}
private static Matrix center(float width, float height, Drawable d) {
final float drawableWidth = d.getIntrinsicWidth();
final float drawableHeight = d.getIntrinsicHeight();
final float widthScale = width / drawableWidth;
final float heightScale = height / drawableHeight;
final float scale = Math.min(1.0f, Math.min(widthScale, heightScale));
Matrix m = new Matrix();
m.postScale(scale, scale);
m.postTranslate((width - drawableWidth * scale) / 2F,
(height - drawableHeight * scale) / 2F);
return m;
}
private static void rotateGrumpyCat(ImageView view, float x, float y,
Matrix matrix, MatrixConcatenation p) {
switch (p) {
case PRE:
matrix.preTranslate(-x, -y);
matrix.preRotate(THETA);
matrix.preTranslate(x, y);
break;
case POST:
matrix.postTranslate(-x, -y);
matrix.postRotate(THETA);
matrix.postTranslate(x, y);
break;
}
view.setImageMatrix(matrix);
}
}
This time you can see postScale
and postTranslate
being called inside
center
in order to scale the image and have the Grumpy cat centered inside
its view. This is just the initial phase.
The interesting part is the rotateGrumpyCat
method, which is supposed to
rotate the Grumpy cat around a point, the center, but you see the different
results:
post
rotate gives the expected result, the Grumpy cat is rotate
“in place” by 30 degreespre
rotate sends our little buddy out of the screen.Well, it’s been fun writing those two articles. I definitely spent more time
writing the first one, which is full of math, than this one.
I hope you now have a better understanding of how matrices work and how to
manipulate them in order to apply the transformations you want. I’ve
kept the code examples really simple on purpose.
If you have questions or feedback, please leave a comment below.
I want to address my warmest thank you to the following people, who helped me during the review process of this article, by providing helpful feedbacks and advices:
This blog uses Hypothesis for public and private (group) comments.
You can annotate or highlight words or paragraphs directly by selecting the text!
If you want to leave a general comment or read what others have to say, please use the collapsible panel on the right of this page.