📜 ⬆️ ⬇️

Masking Bitmaps on Android



Introduction


When developing for Android, quite often there is a task to put a mask on the image. Most often you want to round the corners of the photos or make the image completely round. But sometimes masks and more complex forms 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 of them. The article will be useful primarily to those who are faced with the need to implement the imposition of the mask manually, without using third-party libraries.
')
I assume that the reader has experience in developing for Android 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


Suppose we have two images that are represented by Bitmap objects. One of them contains the original image, and the second - a mask in its alpha channel. It is required to display an image with a superimposed mask.

Usually the mask is stored in resources, and the image is loaded over the network, but in our example both images are loaded from resources with the following code:

private void loadImages() { mPictureBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture); mMaskBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.mask_circle).extractAlpha(); } 

Note the .extractAlpha() : this call creates a Bitmap with the configuration ALPHA_8, which means that one byte is spent on one pixel, which encodes the transparency of this pixel. In such a format it is very advantageous to store masks, since the color information in them does not carry a payload and it can be thrown out.

Now, when the images are loaded, you can move on to the most interesting - overlaying the mask. What means for this can be used?

PorterDuff modes


One of the proposed solutions could be the use of PorterDuff-mode overlay images on canvas (Canvas). Let's refresh in memory what it is.

Theory


We introduce the notation (as in the standard ):


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 is 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 the compact notation [Da ', Dc'] = [Sa · Da, Sa · Dc]. In the Android documentation, it looks like


Hopefully, now you can link to overly concise Google documentation. Without prior explanation, contemplation thereof often puts developers into a stupor: developer.android.com/reference/android/graphics/PorterDuff.Mode.html .

But to think in your mind how the final picture of these formulas will look like is quite tiresome. It is much more convenient to use this crib for the blending modes:

From this cheat sheet you can immediately see the SRC_IN and DST_IN modes of interest. They are essentially the intersection of opaque areas of the canvas and the overlay image, while DST_IN leaves the color of the canvas, and SRC_IN changes color. If the picture was originally drawn on the canvas, then select DST_IN. If the mask was originally painted on the canvas, select SRC_IN.

Now that everything is clear, you can write code.

Src_in


Quite often on stackoverflow.com there are answers where, when using PorterDuff, it is recommended to allocate memory for the buffer. Sometimes even it is suggested to do this with every onDraw call. Of course, it is extremely inefficient. You should try to avoid any allocation of memory on a heap in onDraw at all. It’s even more surprising to see Bitmap.createBitmap there, which can easily require several megabytes of memory. A simple example: a 640 * 640 picture 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 an example Drawable that uses SRC_IN mode. Memory for 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, a mask is first drawn onto the buffer canvas, then a picture is drawn in the SRC_IN mode.

The attentive reader will notice that this code is not optimal. Why redraw the buffer canvas each time draw is called? After all, you can do it only when something has changed.

Optimized code
 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 must change the order of drawing: first, a picture is drawn onto the canvas, and a mask on top. 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 in ALPHA_8 format. If it is presented in an inefficient format ARGB_8888, then everything is fine. The question on stackoverflow.com currently hangs without an answer. 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 size of the Drawable was changed, which is much better than allocating it at each drawing.

But if you think about it, in some cases you can do without allocating a buffer at all and drawing directly onto the canvas, which we were transferred to draw. It should be borne in mind that something has already been drawn on it.

To do this, in the canvas, we kind of cut a hole in the shape of the mask using the CLEAR mode, and then draw a picture in the DST_OVER mode - figuratively speaking, we enclose 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 the 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 window background will appear through the image, and not at all what was painted on the canvas under our Drawable. Compare images:


On the left - as it should be, on the right - as 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 extra memory or with restriction of use. Fortunately, there is a way to avoid all these problems. Just use BitmapShader.

BitmapShader


Usually, when shaders are mentioned, OpenGL is remembered. But do not be afraid, the use of BitmapShader on Android does not require knowledge of the developer in this area. In fact, the implementations of android.graphics.Shader 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 with a Bitmap. This shader returns the color of the corresponding pixels from the Bitmap that was transmitted during initialization.

There is an important clarification in the documentation: you can draw anything with a shader except Bitmap. In fact, if the Bitmap is in ALPHA_8 format, then when drawing 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 images of a flower.

Steps:


 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); } 

It's very simple, is not it? In fact, if the dimensions of the mask and the image do not match, then we will see not exactly what was expected. The mask will be tiled with images, which corresponds to the used Shader.TileMode.REPEAT mode.

To bring the size of the image to the size of the mask, you can use the method android.graphics.Shader # setLocalMatrix , in which you need to transfer the transformation matrix. Fortunately, there is no need to recall the course of analytical geometry: the android.graphics.Matrix class contains convenient methods for matrix formation. We will compress the shader so that the image fits completely into the mask without distortion of proportions, and move it so as to combine the centers of the image and the mask:

 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, the use of the shader gives us the ability to easily implement the methods for changing the transparency of our Drawable and installing ColorFilter. Just call the shader methods of the same name.

Summary code
 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 load the 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 shape that is easy to draw.

Code example
 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 about the existence of the class RoundedBitmapDrawable in the Support Library. It can be useful if you only need to round the edges or make the picture completely round. Inside is used BitmapShader.

Performance


Let's see how the above solutions affect performance. For this, I used RecyclerView with hundreds of items. Graphics GPU monitor shot with fast scrolling on a fairly productive smartphone (Moto X Style).

Let me remind you that on the graphs on the abscissa axis is time, on the ordinate axis is the number of milliseconds spent on drawing each frame. Ideally, the graph 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 the mask at all. While the SRC_IN solution can no longer be considered sufficiently productive, the interface noticeably “slows down” with fast scrolling, which is confirmed by the schedule: many frames are rendered longer than 16 ms, and some more than 33 ms, that is, FPS drops below 30.

findings


In my opinion, the advantages of the approach using BitmapShader are obvious: 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!

Be with you at stackoverflow.com !

Source: https://habr.com/ru/post/310618/


All Articles