Masking Bitmaps on Android
Introduction
When developing for Android, quite often the task arises to mask the image. Most often, you need to round the corners of photos or make the image completely round. But sometimes masks of a more complex form are used.
In this article I want to analyze the tools available in the Android developer’s arsenal for solving such problems and choose the most successful one. The article will be useful primarily to those who are faced with the need to implement masking manually, without using third-party libraries.
I assume that the reader is experienced in Android development and is familiar with the Canvas, Drawable, and Bitmap classes.
The code used in the article can be found on GitHub .
Formulation of the problem
Let's say we have two pictures that are represented by Bitmap objects. One of them contains the original image, and the second contains a mask in its alpha channel. It is required to display an image with a mask applied.
Usually the mask is stored in resources, and the image is downloaded over the network, but in our example both images are downloaded from the resources with the following code:
private void loadImages() {
mPictureBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
mMaskBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.mask_circle).extractAlpha();
}
Please note
.extractAlpha(): this call creates a Bitmap with the ALPHA_8 configuration, which means that one byte is consumed per pixel, which encodes the transparency of this pixel. It is very beneficial to store masks in this format, since the color information in them does not carry a payload and can be thrown out. Now that the images are uploaded, you can move on to the fun part - masking. What tools can be used for this?
PorterDuff modes
One of the proposed solutions may be the use of PorterDuff-modes of image overlay on canvas (Canvas). Let's refresh what it is.
Theory
We introduce the notation (as in the standard ):
- Da (destination alpha) —the original transparency of the canvas pixel;
- Dc (destination color) - the original color of the canvas pixel;
- Sa (source alpha) - pixel transparency of the overlay image;
- Sc (source color) - pixel color of the overlay image;
- Da '- transparency piskel canvas after applying;
- Dc 'is the color of the canvas after applying.
The mode is determined by the rule by which Da 'and Dc' are determined depending on Dc, Da, Sa, Sc.
Thus, we have 4 parameters for each pixel. The formula by which the color and transparency of the pixel of the final image are obtained from these four parameters is the description of the blending mode.
[Da ', Dc'] = f (Dc, Da, Sa, Sc)
For example, for the DST_IN mode,
Da '= Sa · Da
Dc' = Sa · Dc
or in compact notation [Da ', Dc'] = [Sa · Da, Sa · Dc]. In the Android documentation, it looks like
I hope that now you can give a link to excessively concise documentation from Google. Without a preliminary explanation, contemplation of this often leads developers into a stupor: developer.android.com/reference/android/graphics/PorterDuff.Mode.html .
But to figure out in your mind how the final picture will look according to these formulas is rather tedious. It’s much more convenient to use this cheat sheet for blending modes:
You can immediately see the SRC_IN and DST_IN modes that interest us from this cheat sheet. They, in fact, are the intersection of the opaque areas of the canvas and the overlay image, while DST_IN leaves the canvas color, and SRC_IN changes color. If the picture was originally drawn on the canvas, then select DST_IN. If a mask was originally painted on the canvas, select SRC_IN.
Now that everything is clear, you can write code.
SRC_IN
Quite often there are answers on stackoverflow.com where, when using PorterDuff, it is recommended to allocate memory for the buffer. Sometimes even this is suggested to be done with every call to onDraw. Of course, this is extremely inefficient. You should try to avoid any memory allocation on the heap in onDraw at all. It is even more surprising to observe Bitmap.createBitmap there, which can easily require several megabytes of memory. A simple example: a picture of 640 * 640 in the ARGB format takes up more than 1.5 MB in memory.
To avoid this, the buffer can be allocated in advance and reused in onDraw calls.
Here is a Drawable example that uses the SRC_IN mode. The memory for the buffer is allocated when resizing Drawable.
public class MaskedDrawablePorterDuffSrcIn extends Drawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
private final Paint mPaintSrcIn = new Paint();
public MaskedDrawablePorterDuffSrcIn() {
mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
}
public void setPictureBitmap(Bitmap pictureBitmap) {
mPictureBitmap = pictureBitmap;
}
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
}
@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
final int width = bounds.width();
final int height = bounds.height();
if (width <= 0 || height <= 0) {
return;
}
mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); mBufferCanvas = new Canvas(mBufferBitmap);
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn);
//dump the buffer
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
In the example above, first a mask is drawn on the buffer canvas, then in SRC_IN mode a picture is drawn.
An attentive reader will notice that this code is not optimal. Why redraw the buffer canvas with every draw call? After all, you can only do this when something has changed.
public class MaskedDrawablePorterDuffSrcIn extends MaskedDrawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
private final Paint mPaintSrcIn = new Paint();
public static MaskedDrawableFactory getFactory() {
return new MaskedDrawableFactory() {
@Override
public MaskedDrawable createMaskedDrawable() {
return new MaskedDrawablePorterDuffSrcIn();
}
};
}
public MaskedDrawablePorterDuffSrcIn() {
mPaintSrcIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
}
@Override
public void setPictureBitmap(Bitmap pictureBitmap) {
mPictureBitmap = pictureBitmap;
redrawBufferCanvas();
}
@Override
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
redrawBufferCanvas();
}
@Override
protected void onBoundsChange(Rect bounds) {
super.onBoundsChange(bounds);
final int width = bounds.width();
final int height = bounds.height();
if (width <= 0 || height <= 0) {
return;
}
if (mBufferBitmap != null
&& mBufferBitmap.getWidth() == width
&& mBufferBitmap.getHeight() == height) {
return;
}
mBufferBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); //that's too bad
mBufferCanvas = new Canvas(mBufferBitmap);
redrawBufferCanvas();
}
private void redrawBufferCanvas() {
if (mPictureBitmap == null || mMaskBitmap == null || mBufferCanvas == null) {
return;
}
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, mPaintSrcIn);
}
@Override
public void draw(Canvas canvas) {
//dump the buffer
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
@Override
public void setAlpha(int alpha) {
mPaintSrcIn.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
//Not implemented
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight();
}
}
DST_IN
Unlike SRC_IN, when using DST_IN, you need to change the drawing order: first, a picture is drawn on the canvas, and on top of the mask. Changes from the previous example will be as follows:
mPaintDstIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));
mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, mPaintDstIn);
Curiously, this code does not give the expected result if the mask is presented in ALPHA_8 format. If it is presented in an inefficient format ARGB_8888, then everything is fine. The question on stackoverflow.com is currently hanging unanswered. If someone knows the reason - please share knowledge in the comments.
CLEAR + DST_OVER
In the examples above, the memory for the buffer was allocated only when the Drawable size was changed, which is much better than allocating it each time.
But if you think about it, in some cases you can do without allocating a buffer at all and draw immediately onto the canvas that was passed to us in draw. It should be borne in mind that something is already painted on it.
To do this, in a canvas, we cut a hole in the shape of a mask using the CLEAR mode, and then draw a picture in the DST_OVER mode - figuratively speaking, we put the picture under the canvas. Through this hole you can see the picture and the effect is just what we need.
Such a trick can be used when it is known that the mask and image do not contain translucent areas, but only either completely transparent or completely opaque pixels.
The code will look like this:
mPaintDstOver.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER));
mPaintClear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
//draw the mask with clear mode
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintClear);
//draw picture with dst over mode
canvas.drawBitmap(mPictureBitmap, 0, 0, mPaintDstOver);
This solution has problems with transparency. If we want to implement the setAlpha method, then the background of the window will shine through the image, and not at all what was drawn on the canvas under our Drawable. Compare Images:
On the left - as it should be, on the right - how it turns out, if you use CLEAR + DST_OVER in combination with translucency.
As you can see, the use of PorterDuff modes on Android is associated either with the allocation of excess memory, or with the restriction of use. Fortunately, there is a way to avoid all these problems. Just use BitmapShader.
Bitmapshader
Usually, when shaders are mentioned, they recall OpenGL. But do not be afraid, using BitmapShader on Android does not require the developer to have knowledge in this area. In essence, the android.graphics.Shader implementations describe an algorithm that determines the color of each pixel, that is, they are pixel shaders.
How to use them? Very simple: if you load the shader in Paint, then everything that is drawn using this Paint will take the color of the pixels from the shader. The package has shader implementations for drawing gradients, combining other shaders and (most useful in the context of our task) BitmapShader, which is initialized using Bitmap. Such a shader returns the color of the corresponding pixels from the Bitmap that was transferred during initialization.
There is an important clarification in the documentation: you can draw with a shader everything except Bitmap. In fact, if Bitmap is in ALPHA_8 format, then when rendering such a Bitmap using a shader, everything works fine. And our mask is just in this format, so let's try to display the mask using a shader that uses flower images.
The steps:
- create a BitmapShader into which we load the flower image;
- create Paint, into which we load this BitmapShader;
- draw a mask with this paint.
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
}
public void draw(Canvas canvas) {
if (mPaintShader == null || mMaskBitmap == null) {
return;
}
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader);
}
Everything is very simple, right? In fact, if the size of the mask and the image do not match, then we will not see exactly what we expected. The mask will be tiled with images, which corresponds to the mode used
Shader.TileMode.REPEAT. To reduce the size of the image to the size of the mask, you can use the android.graphics.Shader # setLocalMatrix method , into which you need to transfer the transformation matrix. Fortunately, you don’t have to remember the course of analytic geometry: the android.graphics.Matrix class contains convenient methods for forming a matrix. We will compress the shader so that the image fits completely into the mask without distortion of proportions, and move it so that the image centers and the mask are aligned:
private void updateScaleMatrix() {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
int maskW = mMaskBitmap.getWidth();
int maskH = mMaskBitmap.getHeight();
int pictureW = mPictureBitmap.getWidth();
int pictureH = mPictureBitmap.getHeight();
float wScale = maskW / (float) pictureW;
float hScale = maskH / (float) pictureH;
float scale = Math.max(wScale, hScale);
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);
matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f);
mBitmapShader.setLocalMatrix(matrix);
}
Also, using a shader allows us to easily implement methods for changing the transparency of our Drawable and setting ColorFilter. It is enough to call the shader methods of the same name.
public class MaskedDrawableBitmapShader extends Drawable {
private Bitmap mPictureBitmap;
private Bitmap mMaskBitmap;
private final Paint mPaintShader = new Paint();
private BitmapShader mBitmapShader;
public void setMaskBitmap(Bitmap maskBitmap) {
mMaskBitmap = maskBitmap;
updateScaleMatrix();
}
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
updateScaleMatrix();
}
@Override
public void draw(Canvas canvas) {
if (mPaintShader == null || mMaskBitmap == null) {
return;
}
canvas.drawBitmap(mMaskBitmap, 0, 0, mPaintShader);
}
private void updateScaleMatrix() {
if (mPictureBitmap == null || mMaskBitmap == null) {
return;
}
int maskW = mMaskBitmap.getWidth();
int maskH = mMaskBitmap.getHeight();
int pictureW = mPictureBitmap.getWidth();
int pictureH = mPictureBitmap.getHeight();
float wScale = maskW / (float) pictureW;
float hScale = maskH / (float) pictureH;
float scale = Math.max(wScale, hScale);
Matrix matrix = new Matrix();
matrix.setScale(scale, scale);
matrix.postTranslate((maskW - pictureW * scale) / 2f, (maskH - pictureH * scale) / 2f);
mBitmapShader.setLocalMatrix(matrix);
}
@Override
public void setAlpha(int alpha) {
mPaintShader.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
mPaintShader.setColorFilter(cf);
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mMaskBitmap != null ? mMaskBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mMaskBitmap != null ? mMaskBitmap.getHeight() : super.getIntrinsicHeight();
}
}
In my opinion, this is the most successful solution to the problem: no buffer allocation is required, there are no problems with transparency. Moreover, if the mask is of a simple geometric shape, then you can refuse to download Bitmap with the mask and draw the mask programmatically. This will save the memory needed to store the mask as a Bitmap.
For example, the mask used in this article as an example is a fairly simple geometric figure that is easy to draw.
public class FixedMaskDrawableBitmapShader extends Drawable {
private Bitmap mPictureBitmap;
private final Paint mPaintShader = new Paint();
private BitmapShader mBitmapShader;
private Path mPath;
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
mPath = new Path();
mPath.addOval(0, 0, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW);
Path subPath = new Path();
subPath.addOval(getIntrinsicWidth() * 0.7f, getIntrinsicHeight() * 0.7f, getIntrinsicWidth(), getIntrinsicHeight(), Path.Direction.CW);
mPath.op(subPath, Path.Op.DIFFERENCE);
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null) {
return;
}
canvas.drawPath(mPath, mPaintShader);
}
@Override
public void setAlpha(int alpha) {
mPaintShader.setAlpha(alpha);
}
@Override
public void setColorFilter(ColorFilter cf) {
mPaintShader.setColorFilter(cf);
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
@Override
public int getIntrinsicWidth() {
return mPictureBitmap != null ? mPictureBitmap.getWidth() : super.getIntrinsicWidth();
}
@Override
public int getIntrinsicHeight() {
return mPictureBitmap != null ? mPictureBitmap.getHeight() : super.getIntrinsicHeight();
}
}
Since the shader can be used to draw anything, you can try to draw text, for example:
public void setPictureBitmap(Bitmap src) {
mPictureBitmap = src;
mBitmapShader = new BitmapShader(mPictureBitmap,
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT);
mPaintShader.setShader(mBitmapShader);
mPaintShader.setTextSize(getIntrinsicHeight());
mPaintShader.setStyle(Paint.Style.FILL);
mPaintShader.setTextAlign(Paint.Align.CENTER);
mPaintShader.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
}
@Override
public void draw(Canvas canvas) {
if (mPictureBitmap == null) {
return;
}
canvas.drawText("A", getIntrinsicWidth() / 2, getIntrinsicHeight() * 0.9f, mPaintShader);
}
Result:
RoundedBitmapDrawable
It is useful to know that the RoundedBitmapDrawable class exists in the Support Library. It can be useful if you only need to round the edges or make the picture completely round. Internally, BitmapShader is used.
Performance
Let's see how the above solutions affect performance. For this, I used a RecyclerView with a hundred elements. The GPU monitor graphs were shot with fast scrolling on a fairly productive smartphone (Moto X Style).
Let me remind you that on the graphs along the abscissa axis - time, along the ordinate axis - the number of milliseconds spent on rendering each frame. Ideally, the chart should be placed below the green line, which corresponds to 60 FPS.
Plain BitmapDrawable (no masking)
SRC_IN
BitmapShader
It can be seen that using BitmapShader allows you to achieve the same high frame rate as without masking in general. While the SRC_IN solution can no longer be considered sufficiently productive, the interface significantly slows down during fast scrolling, which is confirmed by the graph: many frames are drawn longer than 16 ms, and some more than 33 ms, that is, FPS drops below 30.
conclusions
In my opinion, the advantages of the approach using BitmapShader are obvious: there is no need to allocate memory for the buffer, excellent flexibility, support for translucency, high performance.
It is not surprising that this approach is used in library implementations.
Share your thoughts in the comments!
May stackoverflow.com be with you !