Masking Bitmaps на Android



Введення
При розробці під Android досить часто виникає задача накласти маску на зображення. Найчастіше потрібно заокруглити кути фотографій або зробити зображення повністю круглим. Але іноді застосовуються маски і більш складної форми.

У цій статті я хочу проаналізувати наявні в арсеналі Android-розробника засоби для вирішення таких завдань і вибрати найбільш вдалий з них. Стаття буде корисна в першу чергу тим, хто зіткнувся з необхідністю реалізувати накладення маски вручну, не користуючись сторонніми бібліотеками.

Я припускаю, що читач має досвід в розробці під Android і знайомий з класами Canvas, Drawable і Bitmap.

Код, використовуваний в статті, можна знайти на GitHub

Постановка завдання
Припустимо, у нас є дві картинки, які представлені об'єктами Bitmap. Одна з них містить вихідне зображення, а друга — маску в своєму альфа-каналі. Потрібно відобразити зображення з накладеним маскою.

Зазвичай маска зберігатися в ресурсах, а зображення завантажується з мережі, але в нашому прикладі обидві картинки завантажуються ресурсів з наступним кодом:

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

Зверніть увагу на
.extractAlpha()
: цей виклик створює Bitmap з конфігурацією ALPHA_8, значить, на один піксел витрачається один байт, який кодує прозорість цього пікселя. У такому форматі дуже вигідно зберігати маски, так як колірна інформація в них не несе корисного навантаження і її можна викинути.

Тепер, коли завантажені зображення, можна переходити до найцікавішого — накладання маски. Які засоби для цього можуть застосовуватися?

PorterDuff modes
Одним з пропонованих рішень може стати використання PorterDuff-режимів накладення зображення на полотно (Canvas). Давайте освіжимо в пам'яті, що це таке.

Теорія
Введемо позначення (як в стандарті):

  • Da (destination alpha) —вихідна прозорість пікселя полотна;
  • Dc (destination color) — вихідний колір пікселя полотна;
  • Sa (source alpha) — прозорість пікселя накладеного зображення;
  • Sc (source color) — колір пікселя накладеного зображення;
  • Da' — прозорість пискела полотна після накладення;
  • Dc' — колір пискела полотна після накладення.
Режим визначається правилом, за яким визначаються Da' і Dc' в залежності від Dc, Da,Sa, Sc.

Таким чином, у нас є 4 параметри для кожного пікселя. Формула, за якою з цих чотирьох параметрів виходять колір і прозорість пікселів вихідного зображення, і є опис режиму накладення.

[Da', Dc'] = f(Dc, Da, Sa, Sc)

Наприклад, для режиму DST_IN справедливо

Da' = Sa·Da
Dc' = Sa·Dc

або в компактній запису [Da', Dc'] = [Sa·Da, Sa·Dc]. В документації Android це виглядає як


Сподіваюся, тепер можна давати посилання на не в міру лаконічну документацію від Google. Без попереднього пояснення споглядання неї часто вводить розробників в ступор: developer.android.com/reference/android/graphics/PorterDuff.Mode.html.

Але міркувати в розумі, як виглядатиме підсумкова картинка по цим формулам, досить утомливо. Набагато зручніше скористатися ось такий шпаргалкою режимами накладення:

З цієї шпаргалки відразу видно, що цікавлять нас режими SRC_IN і DST_IN. Вони, по суті, є перетином непрозорих областей полотна та накладеного зображення, при цьому DST_IN залишає колір полотна, а SRC_IN змінює колір. Якщо на полотні спочатку була отрисована картинка, то вибираємо DST_IN. Якщо на полотні спочатку була намальована маска — вибираємо SRC_IN.

Тепер, коли все стало зрозуміло, можна писати код.

SRC_IN
Досить часто на stackoverflow.com зустрічаються відповіді, де при використанні PorterDuff рекомендують виділяти пам'ять під буфер. Іноді навіть це пропонується робити при кожному виклику onDraw. Звичайно, це вкрай неефективно. Потрібно намагатися уникати взагалі будь-якого виділення пам'яті на купі в onDraw. Тим більше дивно спостерігати там Bitmap.createBitmap, який запросто може зажадати кілька мегабайт пам'яті. Простий приклад: картинка 640*640 у форматі ARGB займає в пам'яті більше 1,5 Мб.

Щоб цього уникнути, буфер можна виділяти заздалегідь і переиспользовать його у викликах onDraw.
Ось приклад Drawable, в якій використовується режим SRC_IN. Пам'ять під буфер виділяється при зміні розміру 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);
}

У наведеному вище прикладі спочатку на полотно буфера малюється маска, потім у режимі SRC_IN малюється картинка.

Уважний читач помітить, що цей код неоптимален. Навіщо перемальовувати полотно буфера при кожному виклику draw? Адже можна робити це тільки коли щось змінилося.

Оптимізований код
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
На відміну від SRC_IN, при використанні DST_IN треба змінити порядок малювання: спочатку на полотно малюється картинка, а зверху маска. Зміни порівняно з попереднім прикладом будуть такі:

mPaintDstIn.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_IN));


mBufferCanvas.drawBitmap(mPictureBitmap, 0, 0, null);
mBufferCanvas.drawBitmap(mMaskBitmap, 0, 0, mPaintDstIn);

Що цікаво, цей код не дає очікуваного результату, якщо маска представлена у форматі ALPHA_8. Якщо ж вона представлена в неефективному форматі ARGB_8888, то все чудово. Вопрос stackoverflow.com на даний момент висить без відповіді. Якщо хтось знає причину — прохання поділитися знанням в коментарях.

CLEAR + DST_OVER
У прикладах вище пам'ять під буфер виділялася тільки при зміні розміру Drawable, що вже набагато краще, ніж виділяти її при кожній візуалізації.

Але якщо подумати, то в деяких випадках можна обійтися взагалі без виділення буфера і малювати відразу на полотно, який нам передали в draw. При цьому потрібно мати на увазі, що на ньому вже щось намальовано.

Для цього в полотні ми як би прорізаємо дірку за формою маски з допомогою режиму CLEAR, а потім малюємо картинку в режимі DST_OVER — образно кажучи, підкладаємо картинку під полотно. Через цю дірку видно картинку і ефект виходить якраз такий, як нам потрібно.

Такий трюк можна використовувати, коли відомо, що маска і зображення не містять напівпрозорих областей, а тільки або повністю прозорі або непрозорі пікселі.

Код буде виглядати так:

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

У цього рішення є проблеми з прозорістю. Якщо ми захочемо реалізувати метод setAlpha, то через зображення буде просвічувати фон вікна, а зовсім не те, що було намальовано на полотні під нашим Drawable. Порівняйте зображення:


Зліва — як має бути, справа — як виходить, якщо використовувати CLEAR + DST_OVER в комбінації з напівпрозорість.

Як бачимо, використання режимів PorterDuff на Android пов'язано або з виділенням зайвої пам'яті, або з обмеженням застосування. На щастя, є спосіб уникнути всіх цих проблем. Досить скористатися BitmapShader.

BitmapShader
Зазвичай, коли згадуються шейдери, згадують OpenGL. Але не варто лякатися, використання BitmapShader на Android не вимагає від розробника знань у цій області. По суті, реалізації android.graphics.Shader описують алгоритм, який визначає колір кожного пікселя, тобто є пискельными шейдерами.

Як їх використовувати? Дуже просто: якщо шейдер зарядити в Paint, то все, що малюється за допомогою цього Paint, буде брати колір пікселів з шейдера. У пакеті є реалізації шейдерів для малювання градієнтів, комбінування інших шейдерів і (найкорисніший у контексті нашої задачі) BitmapShader, який ініціалізується за допомогою Bitmap. Такий шейдер повертає колір відповідних пікселів з Bitmap, яке було передане при ініціалізації.

В документації є важливе уточнення: малювати шейдером можна все, крім Bitmap. Насправді, якщо Bitmap у форматі ALPHA_8, то при відображенні такого Bitmap з допомогою шейдера все чудово працює. А наша маска саме в такому форматі, так давайте спробуємо відобразити маску з допомогою шейдера, який використовує зображення квітки.

По кроках:

  • створюємо BitmapShader, який завантажуємо зображення квітки;
  • створюємо Paint, який заряджаємо цей BitmapShader;
  • малюємо маску за допомогою цього 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);
}

Все дуже просто, чи не так? Насправді, якщо розміри маски і зображення не збігаються, то ми побачимо не зовсім те, що очікували. Маска буде замощена зображеннями, що відповідає використаному режиму
Shader.TileMode.REPEAT
.

Щоб привести розмір зображення до розміру маски, можна скористатися методом android.graphics.Shader#setLocalMatrixу який потрібно передати матрицю перетворення. На щастя, згадувати курс аналітичної геометрії не доведеться: клас android.graphics.Matrix містить зручні методи формування матриці. Стиснемо шейдер так, щоб зображення повністю помістилося в маску без спотворень пропорцій, і зрушимо його так, щоб поєднати центри зображення маски:

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

Також використання шейдерів дає нам можливість легко реалізувати методи зміни прозорості нашого Drawable і установки ColorFilter. Досить викликати однойменні методи шейдера.

Підсумковий код
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();
}
}


На мій погляд, це саме вдале рішення завдання: не потрібно виділення буфера, немає проблем з прозорістю. Більш того, якщо маска простої геометричної форми, то можна відмовитися від завантаження Bitmap з маскою, трубкою і малювати маску програмно. Це дозволить заощадити пам'ять, необхідну для зберігання маски у вигляді Bitmap.

Наприклад, використовувана в цій статті в якості прикладу маска — це досить проста геометрична фігура, яку нескладно промалювати.

Приклад коду
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.7 f, getIntrinsicHeight() * 0.7 f, 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();
}
}


Оскільки шейдер можна використовувати для малювання чого завгодно, то можна спробувати намалювати текст, наприклад:

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.9 f, mPaintShader);
}


Результат:



RoundedBitmapDrawable
Корисно знати про існування в Support Library класу RoundedBitmapDrawable. Він може стати в нагоді, якщо потрібно тільки заокруглені краї або зробити картинку повністю круглої. Всередині використовується BitmapShader.

Продуктивність
Давайте подивимося, як перераховані вище рішення впливають на продуктивність. Для цього я використовував RecyclerView з сотнею елементів. Графіки GPU monitor зняті при швидкому скролінгу на досить продуктивному смартфоні (Moto X Style).

Нагадаю, що на графіках по осі абсцис — час, по осі ординат — кількість мілісекунд, витрачений на обробку кожного кадру. В ідеалі графік повинен розташовуватись нижче зеленої лінії, що відповідає 60 FPS.


Plain BitmapDrawable (no masking)


SRC_IN


BitmapShader

Видно, що використання BitmapShader дозволяє домогтися такого ж високого фреймрейта, що і без накладання маски взагалі. У той час як SRC_IN рішення вже не можна визнати досить продуктивним, інтерфейс відчутно «пригальмовує» при швидкому скролінгу, що підтверджується графіком: багато кадри відмальовує довше 16 мс, а деякі і більше 33 мс, тобто FPS падає нижче 30.

Висновки
На мій погляд, переваги підходу з використанням BitmapShader очевидні: не треба виділяти пам'ять під буфер, відмінна гнучкість, підтримка прозорості, висока продуктивність.
Не дивно, що саме цей підхід використовується в бібліотечних реалізаціях.

Діліться своїми думками у коментарях!

Хай буде з вами stackoverflow.com!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.